PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
Data Structures | Functions | Variables
picurv_cli.core Namespace Reference

Data Structures

class  _LazyNumpyProxy
 Module-like proxy that preserves picurv.np without eager import. More...
 
class  CheckpointIndexWidthError
 A committed checkpoint written by a PETSc of the other index width than the driven solver's. More...
 
class  CommandExecutionError
 Raised when an external command exits unsuccessfully. More...
 
class  PlotDependencyError
 Raised when plot.gen reports a missing optional dependency. More...
 

Functions

 _prune_incompatible_python_site_paths (paths)
 Remove site-package paths for a different Python major/minor version.
 
 _drop_imported_package (str package_name)
 Remove a failed/partial import package tree from sys.modules.
 
 require_numpy ()
 Import NumPy only for commands that need numeric reductions.
 
 optional_matplotlib_pyplot ()
 Import matplotlib.pyplot lazily for study plot generation.
 
str _read_release_version ()
 Read the single release version shared by every PICurv executable.
 
"str | None" _git_output (*arguments)
 Run one git command in the source checkout.
 
dict _source_build_identity (str release_version)
 Resolve reproducible release, commit, and dirty-tree build identity.
 
dict picurv_build ()
 The conductor's own build identity, resolved on first use and kept for the process.
 
 _find_named_file_upwards (str start, str filename)
 Find a named file at or above an arbitrary filesystem anchor.
 
 find_workspace_root (*anchors)
 Locate the nearest initialized PICurv workspace for supplied anchors.
 
dict load_workspace_config (str workspace_root)
 Load and validate the immutable workspace identity/configuration file.
 
dict enforce_workspace_version (str workspace_root)
 Enforce an optional workspace PICurv version requirement.
 
dict enforce_reproducibility_policy (str workspace_root)
 Enforce an optional workspace policy demanding a clean, released build.
 
str resolve_workspace_path (str anchor_file, str candidate, *bool allow_external=False)
 Resolve a user path against its workspace and reject implicit escapes.
 
None ensure_workspace_layout (str workspace_root)
 Materialize the uniform, cheap directory skeleton for one workspace.
 
str initialize_workspace_root (str workspace_root, str template_name)
 Create the workspace skeleton and its identity file at one root.
 
None ensure_run_layout (str run_dir)
 Materialize the uniform, cheap directory skeleton for one run.
 
tuple validate_run_directory_structure (str run_dir)
 Refuse a run whose root has grown a directory the layout does not define.
 
None enforce_run_directory_structure (str run_dir)
 Apply validate_run_directory_structure() as a refusal at run time.
 
str case_run_label (dict case_cfg, str case_path)
 Resolve the stable human-facing portion of a generated run identifier.
 
str allocate_generated_run_id (str runs_root, dict case_cfg, str case_path)
 Build the generated run identity, disambiguating a same-second collision.
 
bool discard_unused_run_directory (str run_dir, *bool created, bool staged=False)
 Remove a generated run directory that never received any content.
 
 _file_sha256 (str path)
 Content digest of one file, or None when it cannot be read.
 
dict build_software_lock ("str | None" run_dir=None)
 Capture the exact software identity a run is about to execute with.
 
dict _toolchain_identity ()
 Best-effort record of the PETSc, MPI, and compiler in the shell staging the run.
 
str write_software_lock (str run_dir)
 Write the run's software lock beside its asset lock.
 
str workspace_artifact_root (str workspace_root, str kind)
 Return the canonical workspace-owned root for runs or studies.
 
 _relative_to_workspace (str path, str workspace_root)
 Return a portable workspace-relative path when possible.
 
dict snapshot_run_configuration (str run_dir, dict source_paths, bool continuation=False)
 Snapshot editable YAML inputs without erasing prior continuation state.
 
dict capture_continuation_records (str run_dir, str restart_dir)
 Save what a continuation's staging changes, so a refusal can undo it.
 
 restore_continuation_records (str run_dir, dict saved, str revision)
 Undo a refused continuation's staging: restore its records, drop its revision.
 
dict load_active_run_configuration (str run_dir)
 Load the active immutable configuration revision for a run.
 
dict load_run_executables (str run_dir)
 Return the executables a run is pinned to, as recorded by its active configuration.
 
str run_executable_path ("str | None" run_dir, str name)
 Resolve the executable a run launches: its pinned copy, else the installation's.
 
dict pin_run_executables (str run_dir, str revision)
 Copy the installation's executables into a run's configuration revision.
 
dict plan_run_executable_paths (str run_dir, args, "str | None" workspace_root)
 Predict, without writing, which executables staging would give a run.
 
bool resolve_executable_pinning (args, "str | None" workspace_root)
 Decide whether staging pins the installation's executables into the run.
 
dict apply_run_executable_pinning (str run_dir, str revision, args, "str | None" workspace_root)
 Pin, keep, or refuse the run's executables before anything records them.
 
dict archive_active_generated_configuration (str run_dir, list paths)
 Preserve generated control sidecars beside a continuation's YAML revision.
 
dict _run_component_states (str run_dir, dict stages_requested)
 Report each run component's home, retention class, and lifecycle state.
 
dict build_run_lineage (str parent_run_dir, int checkpoint_step, *workspace_root=None, statistics_state=None, requested_source=None)
 Record which run and which checkpoint a branched run was started from.
 
dict build_run_manifest (str run_dir, str run_id, *workspace_root=None, str launch_mode="local", int num_procs=1, int post_num_procs=1, stages_requested=None, stages_completed=None, inputs=None, asset_lock=None, submission=None, lineage=None, str artifact_type="run", study_id=None, case_id=None)
 Build the authoritative run identity, topology, and lifecycle manifest.
 
 _recorded_run_root (str run_dir)
 Return the location a run's generated files refer to.
 
int adopt_relocated_artifact (str root, str kind="run", bool dry_run=False)
 Point a moved run's or study's generated files at where it now lives.
 
str _checkpoint_bundle_path (str source_dir, int step)
 Resolve a run/output root or an exact checkpoint bundle.
 
dict _read_checkpoint_options (str metadata_path)
 Parse the deliberately small PETSc-options checkpoint manifest.
 
dict validate_committed_checkpoint (str source_dir, int step, bool require_particles=False)
 Validate one committed bundle using the same manifest contract as C.
 
"set[int]" _scan_committed_checkpoint_steps (str source_dir, bool require_particles=False)
 Return only fully validated, committed checkpoint steps.
 
int parse_slurm_time_limit_to_seconds (str time_text)
 Parse a Slurm time-limit string into total seconds.
 
"dict | None" resolve_walltime_guard_policy ("dict | None" cluster_cfg)
 Resolve the effective Slurm walltime-guard policy for generated solver jobs.
 
dict build_walltime_guard_exports ("dict | None" cluster_cfg)
 Build shell-evaluated environment exports for the runtime walltime guard.
 
str resolve_runtime_executable (str executable_name)
 Resolve solver/post executable path, preferring local sibling binaries.
 
"dict | None" _parse_binary_petsc_line (str line)
 Parse the PETSc build line a native executable reports.
 
"dict | None" read_binary_petsc_stamp (str executable_path)
 Read the PETSc build stamp from an executable's bytes, without running it.
 
"str | None" resolved_petsc_library (str executable_path)
 The PETSc shared library the dynamic loader would give this executable when picurv launches it.
 
bool petsc_library_mismatch ("dict | None" petsc, "str | None" library)
 Whether the library the loader resolves lies outside the PETSc the binary was built against.
 
"dict | None" _read_binary_build_stamp (str executable_path)
 Read the build identity stamped into an executable's bytes, without running it.
 
dict read_binary_build_identity (str executable_path)
 Read the build identity a native executable was compiled with.
 
dict runtime_build_identities ("dict | None" source_identity=None, "str | None" run_dir=None)
 Read the build identity of every native executable a run would launch.
 
str rebuild_command (str name, dict identity)
 The command that rebuilds one native executable.
 
list build_identity_problems (dict identities, workspace_requirement=None, "dict | None" source_identity=None)
 Report every reason the active build identity is not internally coherent.
 
list warn_on_stale_runtime_binaries (dict identities)
 Report native executables whose build identity is not the active source.
 
str _sanitize_error_field (value)
 Normalize error fields into a single-line string.
 
 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.
 
 fail_cli_usage (str message, str hint=None)
 Emit a structured CLI usage error and exit with code 2.
 
 _split_error_file_and_message (str raw_error)
 Separate a validation error into its source-file and message fields when possible.
 
str _extract_key_path (str message)
 Best-effort key-path extraction from free-form validation messages.
 
str _classify_error_code (str message)
 Map existing validation/error messages to the standardized code set.
 
dict read_yaml_file (str filepath)
 Safely reads a YAML file and returns its content.
 
 write_yaml_file (str filepath, dict data)
 Write YAML with stable ordering for generated study artifacts.
 
 write_json_file (str filepath, dict payload)
 Write JSON metadata/manifests with a stable, readable format.
 
str write_runtime_execution_file (str filepath, str template_source_path=None)
 Write a default runtime execution config, copying a source template when available.
 
bool _launcher_arg_contains_whitespace (token)
 Return True when a launcher arg token contains embedded whitespace and should be split.
 
"str | None" resolve_runtime_execution_seed_source (str source_project_root)
 Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
 
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.
 
bool is_project_root (str candidate)
 Return True when a directory looks like the PICurv source repository root.
 
 _iter_parent_dirs (str start_path)
 Yield a path and all of its parents up to filesystem root.
 
 find_project_root_upwards (str start_path)
 Search upward from an anchor and return the first matching project root.
 
 discover_local_project_root (*extra_anchors)
 Best-effort source repo discovery from runtime anchors.
 
 find_case_origin_metadata_file (str case_dir_hint=None)
 Find the nearest case-origin metadata file from known runtime anchors.
 
 load_case_origin_metadata (str case_dir_hint=None)
 Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
 
 find_runtime_execution_config_file (*anchors)
 Find the nearest optional execution config from runtime/case anchors.
 
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.
 
 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 merge_execution_overrides ("dict | None" base, "dict | None" override)
 Merge execution overrides, letting explicit override values win key-by-key.
 
dict resolve_runtime_execution_context (dict runtime_execution_cfg, str context)
 Resolve default plus context-specific execution overrides.
 
str get_git_commit (str repo_root=None)
 Best-effort git commit lookup for run/study manifests and case metadata.
 
 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.
 
bool make_args_include_explicit_goal ("list[str]" make_args)
 Return True when make args contain an explicit target rather than only options/assignments.
 
 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.
 
 require_project_root (str candidate, str purpose)
 Validate that a source repo root was resolved and is structurally valid.
 
 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.
 
 resolve_template_directory (str source_project_root, str template_name)
 Resolve an example template directory inside the source repository.
 
 list_template_relative_files (str template_dir, excluded_rel_paths=None)
 List all files in a template directory as case-relative paths.
 
dict render_template_workspace (str template_dir, str template_name, str destination)
 Render an example template into the canonical workspace layout at a new path.
 
list list_rendered_template_files (str rendered_root)
 List the template-managed files of a rendered workspace, workspace-relative.
 
 list_source_binaries (str source_project_root)
 List binary artifacts currently available in the source repo bin directory.
 
 sync_case_binaries (str case_dir, str source_project_root)
 Copy current source-repo binaries into a case directory for version-pinning.
 
 sync_case_template_files (str case_dir, str template_dir, bool overwrite=False, bool prune=False, managed_rel_paths=None, str template_name=None)
 Sync template files into a case directory, preserving modified files unless overwrite is requested.
 
 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.
 
 print_case_source_status (dict status)
 Render human-readable source/case drift details.
 
 status_source_command (args)
 Report source/case drift for an initialized case directory.
 
str resolve_path (str anchor_file, str candidate)
 Resolve a potentially relative path against a source YAML file path.
 
 _mapping_value_with_aliases (dict mapping, *keys, default=None)
 Return the first defined value from a mapping across alias keys.
 
 get_post_run_control_value (dict post_cfg, str canonical_key, default=None)
 Resolve post run_control values with backwards-compatible legacy aliases.
 
None warn_on_grid_generator_hyphen_keys (dict generator, str case_path, list warnings)
 Warn when grid.generator uses unsupported hyphenated wrapper keys.
 
str retired_destination_message (str label, str key)
 Explain why a generator may not choose its own output destination.
 
list reject_generator_destination_keys (generator, str case_path, str label)
 Reject generator settings that try to choose their own output destination.
 
 get_post_source_data (dict post_cfg)
 Return source_data as a mapping when valid, else an empty mapping.
 
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.
 
 get_post_input_extensions (dict post_cfg)
 Return post input_extensions, preferring io.
 
 get_post_statistics_task_tokens (dict post_cfg)
 Return normalized statistics pipeline tokens that will be written into post.run.
 
str get_monitor_output_directory (dict monitor_cfg, str default="output")
 Resolve the solver output root from monitor.yml, preserving the default layout.
 
str get_post_statistics_output_prefix (dict post_cfg, str default="Stats")
 Resolve the statistics CSV prefix, preserving legacy top-level override support.
 
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.
 
 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.
 
dict resolve_scalar_transport (dict case_cfg, dict solver_cfg, bool warn=False)
 Resolve the scalar-transport parameters from case.yml or their deprecated location.
 
dict normalize_post_spectra_config (dict post_cfg)
 Validate and canonicalize the spectra block of post.yml.
 
list validate_post_spectra_preconditions (dict spectra_cfg, dict case_cfg, str post_path)
 Check spectra tasks against what the case can actually support.
 
str _resolve_spectra_payload (dict bundle, str kind, str field, int block)
 Locate one checkpoint payload by its inventory entry rather than by path shape.
 
list _spectra_mean_arguments (dict task_cfg, dict bundle, dict mean_bundle=None)
 Build the generator arguments implementing a task's fluctuation choice.
 
set resolve_post_stage_selection (only)
 Resolve the --only selector into the set of post stages to execute.
 
dict run_post_spectra_stage (str run_dir, dict post_cfg, dict monitor_cfg, str source_dir, steps, bool quiet=False, "dict | None" plan=None, bool recompute=False)
 Measure spectra for every requested task across a window of committed steps.
 
dict normalize_post_reducers_config (dict post_cfg)
 Validate and canonicalize the reducers block of post.yml.
 
list run_post_reducers_stage (str run_dir, dict post_cfg, bool quiet=False)
 Run every reducer task over the profiles the recipe has written.
 
list _merge_spectra_rows (str path, tuple fieldnames, set remeasured, list new_rows)
 Combine newly measured spectra rows with those already written for other steps.
 
str compute_post_spectra_signature (dict spectra_cfg)
 Reduce a normalized spectra recipe to a stable identity string.
 
str resolve_post_spectra_output_dir (monitor_cfg=None)
 Resolve the run-relative directory spectra CSVs are written to.
 
str resolve_recipe_spectra_output_dir (dict post_cfg, monitor_cfg=None)
 Resolve the canonical spectra directory for one versioned recipe.
 
list get_post_spectra_output_artifacts (dict post_cfg, str run_dir, monitor_cfg=None)
 Predict the spectra CSV paths a recipe will write.
 
str post_spectra_task_basename (dict task_cfg, str output_prefix)
 Build the file basename one normalized spectra task writes.
 
list _normalize_post_statistics_ensembles (raw)
 Validate and canonicalize the ensembles a post recipe defines.
 
dict normalize_post_field_statistics_config (dict post_cfg)
 Validate and canonicalize the field_statistics block of post.yml.
 
int _post_window_derived_field_count (dict window_cfg, list outputs, dict eligible=None)
 Count the derived fields one window would produce for a set of outputs.
 
bool _post_requests_field_statistics (dict post_cfg)
 Return whether the current post recipe derives accumulated field statistics.
 
 get_post_field_statistics_artifacts (dict post_cfg, str run_dir)
 Predict the per-window statistics artifacts a recipe will produce.
 
dict build_post_recipe_config (dict post_cfg, monitor_cfg=None)
 Build the flat key=value mapping consumed by the C post-processor.
 
str compute_post_recipe_id (dict post_cfg)
 Compute a stable human-readable identity for one post recipe.
 
"tuple[dict, str]" apply_canonical_post_paths (dict post_cfg, str run_dir)
 Route every post artifact into its fixed analysis or visualization home.
 
dict normalize_post_recipe_signature (dict recipe_cfg)
 Normalize post recipe settings into a stable signature mapping.
 
"tuple[dict, str]" compute_post_recipe_fingerprint (dict recipe_cfg)
 Return normalized recipe signature plus SHA-256 fingerprint.
 
str get_post_resume_state_path (str run_dir, dict post_cfg=None)
 Return the JSON resume metadata path for a run directory.
 
str get_post_recipe_root (str run_dir, dict post_cfg)
 Return the versioned run-local control directory for one post recipe.
 
dict get_post_lock_paths (str run_dir, str recipe_id=None)
 Return lock-wrapper related paths for a run directory.
 
str _post_output_directory_abs (str run_dir, dict post_cfg)
 Resolve the absolute post output directory for the current recipe.
 
bool _post_requests_eulerian_output (dict post_cfg)
 Return whether the current post recipe expects Eulerian VTK output artifacts.
 
bool _post_requests_particle_output (dict post_cfg)
 Return whether the current post recipe expects particle VTP output artifacts.
 
dict normalize_post_paraview_series_config (dict post_cfg)
 Normalize the optional ParaView collection policy in a post recipe.
 
str _resolve_lineage_parent_run (str child_run_dir, dict lineage)
 Resolve and identity-check one recorded lineage parent.
 
list resolve_run_lineage (str run_dir, str scope="lineage")
 Return root-to-leaf run segments, for a ParaView collection or a reducer's metrics.
 
dict _existing_pvd_times (str pvd_path)
 Read step-to-time fallbacks from an existing PVD collection.
 
float _checkpoint_time_for_series (str run_dir, int step, dict fallback, float time_scale=1.0)
 Read the authoritative time of one visualization frame.
 
None _write_pvd_collection (str path, list entries)
 Atomically write one VTK Collection file.
 
int _lineage_reset_index (list segments, str family)
 Return the first segment belonging to one continuous artifact family.
 
list finalize_post_paraview_series (str run_dir, dict post_cfg)
 Build physical-time ParaView collections from current or lineage VTK output.
 
str _post_finalize_python_source ()
 Return the importable serial finalizer used by generated jobs.
 
list build_post_finalize_command (str run_dir, str archived_post_path, dict post_cfg, post_stages=POST_STAGE_NAMES)
 Build the serial post-success command for staged execution.
 
None finalize_post_outputs (str run_dir, dict post_cfg, stages)
 Run every serial step that reads a successful field stage's output.
 
bool _post_requests_statistics (dict post_cfg)
 Return whether the current post recipe expects statistics CSV artifacts.
 
bool _post_needs_particle_source (dict post_cfg)
 Return whether the current post recipe requires particle source files to be present.
 
 _iter_post_steps (int start_step, int end_step, int step_interval)
 Yield configured post-processing steps inclusively.
 
"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.
 
int resolve_post_owned_start (str run_dir, dict post_cfg, int requested_start, int step_interval)
 Intersect a recipe's logical cadence with this run's branch.
 
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.
 
"dict[int, str]" _scan_post_vtk_steps (str prefix_path, str extension)
 Collect the VTK files named with a prefix, step suffix, and extension.
 
"set[int]" _scan_post_statistics_csv_steps (str csv_path)
 Scan step ids from the first CSV column of a statistics artifact.
 
"list[tuple]" collect_post_completion_families (str run_dir, dict post_cfg, monitor_cfg=None)
 Collect the output files each output family of the current recipe holds per step.
 
 _field_statistics_window_samples (str run_dir, int step)
 Read each field-statistics window's sample count from one committed checkpoint.
 
 post_step_outputs (str run_dir, dict post_cfg, monitor_cfg=None)
 Return a lookup of the output files one step of the current recipe holds.
 
 _nearest_step ("set[int]" steps, int target)
 Return the complete source step nearest to a target step.
 
str _format_optional_step (step)
 Format an optional step number for user-facing diagnostics.
 
"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.
 
"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.
 
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.
 
"tuple[dict, dict]" _read_post_records (str state_path)
 Read the per-step output records a recipe's state holds.
 
 persist_post_resume_state (str run_dir, dict plan, steps=None, spectra=None)
 Write a recipe's state: the window last planned and its per-step output records.
 
 _checkpoint_commit (str source_dir, int step)
 Identify one committed checkpoint by its commit marker.
 
"str | None" classify_post_step (record, files, commit, str recipe, bool recompute=False)
 Decide whether one step's existing output is still valid.
 
None warn_overlapping_post_recipes (str run_dir, dict post_cfg, dict plan, post_stages)
 Warn when another recipe in this run already produced outputs this one repeats.
 
str _build_post_lock_wrapper_source ()
 Return the Python wrapper that runs one post job under an exclusive lock.
 
str ensure_post_lock_wrapper (str run_dir)
 Ensure the lock wrapper exists for a run directory and return its path.
 
"tuple[list, dict]" build_post_locked_command (str run_dir, str recipe_fingerprint, list wrapped_command, bool create_wrapper=True, "list | None" success_command=None, "str | None" post_recipe=None, "str | None" postprocessor=None, bool recompute=False)
 Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
 
None run_post_finalize_locked (str run_dir, str recipe_fingerprint, list finalize_command)
 Refresh a collection-only result under the ordinary post writer lock.
 
dict build_post_execution_plan (str run_dir, str run_id, dict case_cfg, dict monitor_cfg, dict post_cfg, bool recompute=False, bool allow_source_frontier_scan=True, "str | None" postprocessor_sha=None)
 Decide which steps of a recipe's window one invocation processes.
 
None report_post_plan (dict plan)
 Print which steps a post plan processes, keeps, and waits for.
 
str get_post_step_list_path (str run_dir, dict post_cfg)
 Return the file naming the steps a launched post job processes.
 
"tuple[dict, dict, dict]" _load_post_job_configs (str run_dir, str post_recipe_path)
 Load the configuration a post job needs from the run it runs in.
 
int prepare_post_job (str run_dir, str post_recipe_path, str postprocessor, bool recompute=False)
 Decide, as a post job starts, which steps it processes.
 
int finish_post_job (str run_dir, str post_recipe_path, bool exited_cleanly=False)
 Record, after a post job ends, which steps it produced.
 
bool needs_restart_source (dict case_cfg, dict solver_cfg)
 Return True when the solver requires restart data from disk.
 
str resolve_run_output_dir (str run_dir, dict monitor_cfg)
 Resolve the output data directory within a run directory.
 
str resolve_run_restart_dir (str run_dir, dict monitor_cfg)
 Resolve the restart staging directory within a run directory.
 
str compute_physical_case_identity (dict case_cfg)
 Compute the hidden identity used to guard in-place continuation.
 
None validate_continue_case_identity (str run_dir, dict case_cfg)
 Reject in-place continuation when the physical case has changed.
 
 populate_restart_directory (str source_output, str target_restart, int start_step, dict monitor_cfg, "int | None" end_step=None, bool materialize=True)
 Atomically materialize an immutable checkpoint interval into a run.
 
 validate_eulerian_checkpoint (str source_dir, int step, dict monitor_cfg)
 Validate the mandatory Eulerian field set required by ReadSimulationFields().
 
 detect_last_checkpoint_step (str output_dir)
 Scan output directory for the highest step number available.
 
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.
 
 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.
 
 validate_particle_checkpoint (str source_dir, int start_step, dict monitor_cfg)
 Validate that particle checkpoint files exist for the given step.
 
dict read_monitor_from_run (str run_dir)
 Read the monitor.yml from a run directory's config/ subdirectory.
 
str resolve_latest_restart_run (dict case_cfg, str case_path, int start_step)
 Select the newest local workspace run compatible with a requested restart.
 
 resolve_restart_source (args, dict case_cfg, dict solver_cfg, dict monitor_cfg, str run_dir, bool materialize=True)
 Resolve the restart source directory based on –restart-from or –continue CLI flags.
 
 absolutize_case_external_paths (dict case_cfg, str case_anchor_path)
 Convert external grid/generator paths in case config to absolute paths.
 
 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.
 
bool is_valid_email (str email)
 Lightweight email validation for scheduler notifications.
 
str normalize_statistics_task (str task_name)
 Normalizes user-facing statistics task names to C pipeline keywords.
 
 _iter_nonempty_noncomment_lines (file_obj)
 Yield (lineno, stripped_line) for non-empty, non-comment lines.
 
str format_picgrid_coordinate (float value)
 Format a coordinate with round-trip-safe binary64 precision.
 
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.
 
list read_picgrid_header_dimensions (str source_grid, int expected_nblk=None)
 Read only the canonical PICGRID header dimensions.
 
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 _face_artifact_token (str face)
 Convert a BC face token into a filesystem-friendly artifact token.
 
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.
 
str _resolve_generator_script (str configured_script, str case_path, str default_name)
 Resolve an optional generator script override or repository default.
 
dict _normalize_square_duct_poiseuille_params (params, str field_name)
 Validate square-duct Poiseuille generator parameters.
 
dict _normalize_field_slice_source (source, str field_name)
 Validate a prescribed_flow field_slice source block.
 
dict _normalize_field_slice_selector (slice_cfg, str field_name)
 Validate the field_slice slice selector.
 
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.
 
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 _resolve_case_relative_path (str path_value, str case_dir)
 Resolve a path relative to the current case directory.
 
float _resolve_field_slice_velocity_scale (dict source, str case_dir)
 Resolve field_slice dimensional velocity scale.
 
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.
 
str resolve_target_grid_for_generated_profile (dict case_cfg, str case_path, str run_dir)
 Resolve the target canonical PICGRID for generated profile sampling.
 
str write_profile_info (str config_dir, list summaries)
 Write a profile.info summary for generated inlet profiles.
 
list validate_grid_generator_cli_args (cli_args, str case_path)
 Check closed-choice values inside the generator's opaque token list.
 
str run_grid_generator (str case_path, str run_dir, dict grid_cfg, dict case_cfg=None)
 Runs generators/grid.gen to produce a PICGRID file for this run.
 
dict _normalize_prescribed_flow_source (source, str field_name)
 Validate the structured source block for prescribed_flow BCs.
 
tuple _bc_profile_expected_dims (str face, tuple block_dims)
 Return expected PICSLICE dimensions for a face and block node dimensions.
 
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.
 
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.
 
float _to_float (value, str field_name)
 Convert a YAML scalar to float with a clear error message.
 
float _to_finite_float (value, str field_name)
 Convert a non-boolean YAML scalar to a finite float.
 
bool _to_bool (value, str field_name)
 Convert a YAML scalar/string to bool with a clear error message.
 
 normalize_boundary_conditions_layout (all_blocks_bcs, int num_blocks)
 Normalize boundary_conditions to list-of-lists form and validate block count.
 
set _les_periodic_axes (dict case_cfg)
 Reports which logical axes a case declares periodic on both faces.
 
 validate_wall_model_pairing (dict case_cfg, les_cfg, wall_cfg, str case_path, list errors, list warnings)
 Rejects wall-model selections that no turbulence treatment can support.
 
 _case_reynolds_number (dict case_cfg)
 Reynolds number implied by a case's scaling and fluid properties.
 
 validate_les_configuration (dict case_cfg, dict les_cfg, str case_path, list errors, list warnings)
 Checks the structured LES block for values the closure cannot honour.
 
 validate_and_prepare_boundary_conditions (dict case_cfg)
 Validate BC entries against currently supported C-side handlers/types and.
 
str _schema_path_text (tuple path)
 Render an internal schema path tuple as a user-facing YAML path.
 
 _lookup_allowed_schema_keys (dict schema, tuple path)
 Return allowed keys for a path, honoring '*' dynamic mapping entries.
 
str _schema_key_hint (dict schema, tuple path, str key, set allowed)
 Build a concise typo or hierarchy hint for an unsupported YAML key.
 
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.
 
dict _ensemble_source_fields (str source)
 Return the field table an ensemble of one source names its fields from.
 
tuple input_quantity (tuple path)
 Return the (dimension, conversion site) recorded for one configuration input.
 
float reference_scale (tuple dimension, dict scales)
 Return the factor that turns a physical quantity of this dimension into solver units.
 
float input_reference_scale (tuple path, dict scales)
 Return the reference scale of one recorded input, for converting a whole payload.
 
 quantity_to_solver_units (value, tuple quantity, dict scales, str label)
 Convert a physical value, or a list of them, by one recorded quantity entry.
 
 to_solver_units (value, tuple path, dict scales)
 Convert one physical configuration value, or a list of them, to solver units.
 
dict resolve_reference_scales (dict case_cfg)
 Resolve the reference length and velocity (and density, when given) of a case.
 
str directory_value_charset_problem (str value)
 Describe why a directory value cannot be written to a PETSc options line.
 
str classify_run_directory_value (value)
 Classify a configured run directory value.
 
str normalized_run_directory (str value)
 Normalized, comparable form of a contained run directory value.
 
bool paths_overlap (str first, str second)
 Whether two run-relative directories are the same or nested in one another.
 
tuple resolve_unsafe_paths_override (dict dirs, str monitor_path)
 Resolve the unsafe-paths override, requiring a real YAML boolean.
 
tuple evaluate_run_directories (dict values, bool override, set explicit=None)
 Apply every run-directory safety rule to a set of effective directory values.
 
list classify_physical_containment (str run_dir, dict values)
 Classify where each run-owned directory physically lands.
 
list check_physical_containment (str run_dir, dict values)
 Human-readable physical containment violations.
 
dict effective_run_directories (dict configured)
 Fill in defaults for run-owned directories that were not configured.
 
tuple validate_run_directory_containment (dict monitor_cfg, str monitor_path)
 Classify legacy directory values as defense-in-depth during validation.
 
list validate_reserved_directory_flags (dict config, str config_path, str label)
 Reject raw PETSc passthrough options that set run-owned directories.
 
list physical_units_transition_notices (dict case_cfg, dict solver_cfg, dict monitor_cfg)
 Name the inputs whose meaning changed when every input became physical.
 
 validate_simulation_configs (dict case_cfg, dict solver_cfg, dict monitor_cfg, str case_path, str solver_path, str monitor_path, bool restart_source_given=False)
 Validates every configuration a simulation run consumes, before any work is done.
 
"tuple[list, list]" check_post_checkpoint_cadence_alignment (dict post_cfg, dict monitor_cfg, str post_path, str monitor_path="monitor.yml")
 Report post step selections that cannot land on a committed checkpoint.
 
 validate_post_config (dict post_cfg, str post_path, dict monitor_cfg=None, dict case_cfg=None)
 Validates the post-processing config before running the post-processor.
 
 validate_cluster_config (dict cluster_cfg, str cluster_path)
 Validate Slurm scheduler configuration from cluster.yml.
 
 validate_study_config (dict study_cfg, str study_path, bool skip_base_file_check=False)
 Validate sweep/study specification from study.yml.
 
 _deep_set (dict container, str dotted_path, value)
 Set nested dictionary value, creating intermediate maps when needed.
 
list expand_parameter_matrix (dict parameters)
 Expand study parameter lists into cartesian-product combinations.
 
list expand_study_parameter_combinations (dict study_cfg)
 Expand either cartesian-study parameters or explicit parameter sets.
 
dict flatten_study_parameters (dict parameters)
 Flatten grouped study overrides into scalar dotted-path columns.
 
list get_study_parameter_keys (dict study_cfg)
 Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
 
int get_cluster_total_tasks (dict cluster_cfg)
 Return cluster total tasks.
 
str normalize_extension (str ext)
 Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
 
list resolve_conductor_entry_point ()
 Resolve how to invoke this conductor again from a batch script.
 
list build_spectra_follow_command (str run_dir, str post_path, dict post_cfg, bool recompute=False)
 Build the batch-script step that measures spectra after the field stage.
 
"str | None" executable_petsc_library_dir (str executable_path)
 The lib/ directory of the PETSc an executable was built against, from its stamp.
 
list petsc_library_path_lines ("str | None" library_dir)
 Shell lines putting one PETSc's lib/ first on LD_LIBRARY_PATH, after module setup.
 
list executable_identity_check_lines (str executable, "str | None" expected_version_line, bool pinned=False)
 Shell lines that compare an executable's reported identity with the staged one.
 
 render_slurm_script (str script_path, str job_name, dict cluster_cfg, list command, str workdir, str stdout_path, str stderr_path=None, dict env_vars=None, dict shell_env_vars=None, str array_spec=None, list follow_commands=None, "tuple | None" identity_check=None)
 Render a Slurm batch script for a single command.
 
"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.
 
"tuple[str | None, list[str]]" normalize_cluster_launcher (dict execution)
 Canonicalize cluster launcher config into executable token plus argv-style flags.
 
"list[str]" strip_launcher_size_flags (str launcher_name, "list[str]" launcher_args)
 Remove explicit MPI task-count flags from known launchers.
 
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.
 
dict restart_grid_inputs (str run_dir, dict case_cfg)
 The inputs a run's geometry is derived from, to compare across a restart.
 
 warn_on_restart_grid_change (dict source_inputs, dict branch_inputs)
 Warn at staging when a restart's grid inputs differ from its source run's.
 
 check_restart_continuation (str solver_exe, list solver_args, str run_dir, str config_search_anchor=None)
 Ask the simulator whether a staged restart's carried statistics windows can continue.
 
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.yml overrides.
 
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.
 
str parse_slurm_job_id (str sbatch_output)
 Extract numeric job id from standard sbatch output.
 
"tuple[str, str | None]" slurm_job_end_state (str job_id)
 Whether a recorded Slurm job (or array) is still queued, and how it ended if not.
 
dict submit_sbatch (str script_path, str dependency=None, str dependency_type="afterok")
 Submit sbatch script and return submission metadata.
 
 _print_validation_errors (list errors)
 Prints validation errors and exits.
 
str generate_header (str run_id, dict source_files)
 Creates a standard header block for all generated files.
 
str generate_simple_list_file (str run_dir, str run_id, dict cfg, str section, str key, str filename, dict header_sources, str config_dir=None)
 Generic function to create a file containing a simple list of strings.
 
list normalize_monitor_function_names (list names, str field_name)
 Translate renamed solver functions in a monitor.yml function list.
 
bool has_explicit_monitor_whitelist (dict monitor_cfg)
 Return True when logging.enabled_functions contains at least one entry.
 
dict resolve_profiling_config (dict monitor_cfg)
 Resolve profiling reporting config from monitor.yml.
 
dict _diagnostic_info (value)
 Validate PETSc info logging configuration.
 
 _diagnostic_bool_or_path (value, str key)
 Validate a diagnostics value that can be false, true, or a path/viewer string.
 
bool _diagnostic_bool (value, str key)
 Validate a diagnostics boolean value.
 
 _diagnostic_bool_or_all (value, str key)
 Validate a diagnostics value that can be false, true, or "all".
 
str _diagnostic_default_file (str run_dir, str filename)
 Return an absolute run-local diagnostics file path.
 
 _diagnostic_resolve_path_or_default (value, str run_dir, str default_filename)
 Resolve true/string diagnostics values to a concrete file path.
 
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 build_petsc_diagnostics_args (dict monitor_cfg, str run_dir, str stage_label)
 Build PETSc diagnostics command-line arguments for a run stage.
 
bool _statistics_subsystem_available (dict case_cfg, requirement)
 Report whether the subsystem a statistics field depends on is active.
 
dict normalize_field_statistics_config (dict monitor_cfg, dict case_cfg=None)
 Validate and canonicalize the field-statistics block of monitor.yml.
 
 _normalize_statistics_requests (str label, fields_raw, covariances_raw, dict eligible, case_cfg=None)
 Validate and canonicalize the fields and covariances one window or ensemble requests.
 
list resolve_field_statistics_flags (dict monitor_cfg, dict case_cfg=None)
 Serialize field-statistics configuration into control-file option lines.
 
"int | None" resolve_statistics_console_output_frequency (dict io_cfg)
 Resolve the statistics console cadence, mirroring the particle one.
 
dict normalize_solution_monitoring_config (dict monitor_cfg)
 Validate and canonicalize physical-solution convergence monitoring.
 
dict resolve_solution_monitoring_flags (dict monitor_cfg)
 Translate solution-monitoring YAML into the existing C convergence flags.
 
dict prepare_monitor_files (str run_dir, str run_id, dict monitor_cfg, dict source_files, str config_dir=None)
 Generate monitor sidecar files and resolve profiling reporting behavior.
 
list generate_multi_block_bcs (str run_dir, str run_id, dict case_cfg, dict source_files, str config_dir=None)
 Parses multi-block BCs from YAML, generates a .run file for each block, and returns a list of their absolute paths.
 
 format_flag_value (value)
 Converts Python types to C-style command-line flag values.
 
dict translate_programmatic_grid_settings (dict grid_settings)
 Return programmatic-grid settings translated to the C node-count contract.
 
None validate_programmatic_bridge_grid_settings (dict raw_settings)
 Validate the scalar programmatic grid settings the bridge PICGRID is built from.
 
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.
 
dict resolve_grid_da_processor_layout (dict grid_cfg)
 Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
 
"list | None" resolve_grid_node_dims (dict case_cfg, str case_path, "str | None" staged_grid=None)
 Per-block (IM, JM, KM) node counts the solver will read, without building the grid.
 
bool case_declares_periodic_axis (dict case_cfg)
 Whether any face of any block is PERIODIC, which sets the DMDA stencil width.
 
list da_processor_layout_problems (dict grid_cfg, "int | None" num_procs, "list | None" block_dims=None, "dict | None" solver_cfg=None, bool any_periodic=False, "str | None" source=None)
 Problems with a user-set DMDA layout that would abort grid setup.
 
None normalize_decomposition_args (args)
 Resolve -n and the optional --ni/--nj/--nk of picurv run into one rank count.
 
dict _decomposition_layout (layout)
 Express an (ni, nj, nk) tuple as the da_processors mapping the layout checks read.
 
 choose_decomposition (int num_procs, list block_dims, "dict | None" solver_cfg, bool any_periodic)
 Choose the MPI decomposition of a structured grid for a rank count.
 
dict resolve_decomposition (int num_procs, dict grid_cfg, "list | None" block_dims, "dict | None" solver_cfg, bool any_periodic, explicit=None, "dict | None" cluster_cfg=None)
 Resolve the MPI decomposition a stage launches with.
 
"dict | None" _decomposition_record ("dict | None" resolved)
 The JSON-serializable record of a resolved decomposition for run metadata.
 
str describe_decomposition (dict resolved, int num_procs)
 One line stating the decomposition a stage launches with.
 
dict append_grid_da_processor_layout (list control_lines, dict grid_cfg, int num_procs, "list | None" block_dims=None, "dict | None" solver_cfg=None, bool any_periodic=False, explicit=None, "dict | None" cluster_cfg=None)
 Resolve the solver's MPI decomposition, report it, and append its flags.
 
str normalize_momentum_solver_type (str value)
 Maps canonical user-facing momentum solver names to C-enum CLI values.
 
dict validate_newton_krylov_config (dict cfg)
 Validate and normalize the structured Newton–Krylov solver block.
 
str normalize_solution_convergence_mode (str value)
 Normalizes the solution-convergence mode selector to the C-side canonical string.
 
int normalize_field_init_mode (str value)
 Maps canonical field init mode names to C enum/int codes (-finit).
 
"tuple[str, int]" normalize_initial_condition_field (str value)
 Normalize a file IC field selector to its staged basename and C enum value.
 
dict resolve_ic_payload_provenance (dict ic, int field_code)
 Resolve which scales a file-backed initial condition was written in.
 
float ic_payload_divisor (dict resolved_ic, str case_dir)
 Return the factor a file-backed initial condition is divided by when staged.
 
bool is_generated_ic_provider (dict resolved_ic)
 Return whether a resolved IC is backed by a registered file generator.
 
dict resolve_fluid_scaling (dict case_cfg)
 Resolve the shared physical and nondimensional fluid scaling contract.
 
 load_generator_module (str script_name, str module_name)
 Load one of the extensionless scripts under generators/ as a module.
 
 load_ic_generator_module ()
 Load generators/ic.gen, which owns the expression language and the IC providers.
 
list normalize_particle_fields (fields_cfg)
 Validate models.physics.particles.fields and lower each value to expressions.
 
dict ic_provider_params_to_solver_units (str generator, dict params, dict scales)
 Convert a provider's validated physical parameters to solver units.
 
dict resolve_resampled_velocity_params (dict params, prepared_blocks, dict scales, provider_context=None)
 Validate resampled_velocity parameters and convert them to the provider contract.
 
 normalize_ic_initial_spectra (requested, excluded_axes, generator, required)
 Validate the spectra a structured IC provider measures on its own output.
 
dict resolve_initial_condition_config (dict ic, prepared_blocks, dict scales, provider_context=None)
 Resolve legacy and structured initial-condition YAML into one launcher contract.
 
int runtime_petsc_index_bytes ()
 Bytes of a PetscInt in the PETSc the solver this conductor drives was built on.
 
"int | None" requested_petsc_index_bytes (args=None)
 The index width a command was told to use: --petsc-index-bits, else PICURV_PETSC_INDEX_BITS.
 
"tuple[int, str]" required_petsc_index_bytes (dict case_cfg, str case_path, "dict | None" monitor_cfg, "str | None" staged_grid=None)
 The PetscInt width a new run needs, from its particle count and grid size.
 
"int | None" checkpoint_source_petsc_index_bytes (str source_dir)
 PetscInt width the committed checkpoints under a run output root were written with.
 
int select_petsc_index_bytes (int index_bytes, str reason)
 Make the current command drive the build of one PetscInt width.
 
int select_run_petsc_index_bytes (args, dict case_cfg, str case_path, "dict | None" monitor_cfg, "str | None" restart_source_dir=None)
 Choose and select the PetscInt width a run's solve stage needs.
 
int select_existing_run_petsc_index_bytes (str run_dir, args=None)
 Select the PetscInt width an existing run was staged with, for its later stages.
 
None record_run_petsc_index_bytes (str run_dir)
 Record in config/active.json the PetscInt width the current command selected.
 
int payload_petsc_index_bytes (str path)
 PetscInt width of a PETSc payload: as its checkpoint recorded it, else the driven solver's.
 
list petsc_index_arguments (int index_bytes)
 Generator arguments naming a PetscInt width; none for PETSc's default 32-bit.
 
dict validate_petsc_vec_binary (str path, "int | None" index_bytes=None)
 Validate the basic PETSc binary VecView envelope used by ReadFieldData.
 
dict write_scaled_petsc_vec_binary (str source, str destination, float divisor)
 Copy a PETSc binary Vec, dividing every scalar by one factor.
 
 initial_condition_diagnostic_paths (run_dir, resolved_ic)
 Resolve spectral IC artifacts through the canonical run layout, also for dry runs.
 
str run_initial_spectrum_generator (str field_path, str staged_grid, str spectrum_path, str case_dir, task_cfg=None)
 Measure the shell-averaged spectrum of a staged initial condition.
 
str run_initial_condition_generator (str case_path, str run_dir, dict resolved_ic)
 Run the repository IC generator.
 
None _check_file_ic_matches_grid (dict payload, str staged_grid, dict resolved_ic)
 Refuse a mode: file payload sized for another grid, and say what imports one.
 
dict 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.
 
int normalize_flow_direction_token (str value)
 Maps a face-token flow direction string to the C FlowDirection enum integer.
 
bool _ic_has_inlet (prepared_blocks)
 Return True if any prepared BC block contains an INLET face.
 
dict resolve_ic_cli_params (dict ic, int finit_code, prepared_blocks, dict scales)
 Resolve all IC parameters and return a dict of PETSc option values.
 
str normalize_eulerian_field_source (str value)
 Normalizes the Eulerian field source selector to the C-side canonical string.
 
bool initial_condition_is_authoritative (dict case_cfg, dict solver_cfg)
 Whether the configured initial condition is the field the solver starts from.
 
tuple unused_asset_kinds (dict case_cfg, dict solver_cfg)
 Asset kinds a run with these configurations would build but never read.
 
str normalize_analytical_type (str value)
 Normalizes the analytical solution selector to the C-side canonical string.
 
"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.
 
"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 normalize_particle_init_mode (str value)
 Maps canonical particle init mode names to C enum/int codes (-pinit).
 
int normalize_interpolation_method (str value)
 Maps interpolation method names to C enum/int codes (-interpolation_method).
 
int normalize_les_model (value)
 Maps LES model selectors to C enum/int codes (-les).
 
int normalize_les_test_filter (value)
 Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
 
int normalize_les_filter_width (value)
 Maps LES grid-filter-width model names to the C -les_filter_width flag.
 
int normalize_les_averaging_mode (value)
 Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
 
int normalize_les_clip_mode (value)
 Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
 
str normalize_les_averaging_directions (value)
 Maps a list of homogeneous logical directions to the C flag's string form.
 
int normalize_wall_function_model (value)
 Maps wall-function model selectors to the C -wallfunction flag.
 
bool resolve_enabled_flag (dict cfg, str path, bool default=True)
 Resolves a structured enabled flag and rejects non-boolean values.
 
 append_les_parameter_flags (dict les_cfg, list control_lines)
 Appends the LES closure parameter flags from a structured les block.
 
"dict | None" resolve_les_diagnostics (dict case_cfg, dict monitor_cfg, bool warn=False)
 Resolve the LES diagnostic log settings from monitor.yml or their deprecated location.
 
 append_les_diagnostics_flags ("dict | None" diagnostics, list control_lines)
 Append the LES diagnostic log flags resolved by resolve_les_diagnostics().
 
 append_turbulence_flags (dict models, list control_lines, dict scales=None)
 Appends turbulence model flags from legacy or structured case.yml blocks.
 
 control_value (value, str context)
 Guard a value that is written verbatim into the generated control file.
 
 append_passthrough_flags (list control_lines, dict options)
 Appends raw CLI flags to the control list from a {flag: value} dict.
 
dict resolve_solver_monitoring_flags (dict monitor_cfg)
 Resolve human-readable solver monitoring YAML to raw control flags.
 
list resolve_traced_particle_ids (dict monitor_cfg)
 Return the sorted, distinct particle IDs of logging.trace_particles.
 
"int | None" resolve_particle_console_output_frequency (dict io_cfg)
 Return the effective particle-console snapshot cadence from monitor.yml.
 
 parse_and_add_model_flags (dict case_cfg, list control_lines)
 Parses the 'models' section of case.yml and adds corresponding C-solver flags.
 
dict parse_solver_config (dict solver_cfg, dict scales=None, dict case_cfg=None)
 Parses the structured solver.yml into a flat dictionary of {flag: value}.
 
 generate_solver_control_file (run_dir, run_id, configs, num_procs, monitor_files, restart_source_dir=None, continue_mode=False, str config_dir=None)
 Generates the main .control file for the C-solver.
 
dict resolve_post_decomposition (str run_dir, int num_procs, explicit=None, "dict | None" cluster_cfg=None)
 Resolve the postprocessor's MPI decomposition from the run's staged configuration.
 
str generate_post_recipe_file (str run_dir, str run_id, dict post_cfg, dict source_files, monitor_cfg=None, "dict | None" decomposition=None)
 Generates a key=value config file (post.run) for the C post-processor.
 
 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 format_command_for_display (list command)
 Render a shell-safe command string for console and log output.
 
str resolve_command_log_path (str run_dir, str log_filename)
 Resolve a command log filename relative to the run directory.
 
subprocess.CompletedProcess _run_captured_command (list command, str run_dir)
 Run a command and capture combined stdout/stderr details for later inspection.
 
 _require_successful_command (list command, subprocess.CompletedProcess result)
 Raise CommandExecutionError when a captured command failed.
 
str _capture_command_stdout (list command, str run_dir)
 Run a command, require success, and return stripped stdout text.
 
 _stream_command_to_console_and_log (list command, str run_dir, log_file)
 Stream command output to stdout and an already-open log file.
 
dict _get_git_head_state (str run_dir)
 Capture the current git HEAD branch name and commit hash.
 
"list[tuple[str, str | None]]" _get_local_branches_with_upstreams (str run_dir)
 Return local branch names plus their configured upstreams.
 
bool _working_tree_has_tracked_changes (str run_dir)
 Return True when the repository has staged or unstaged tracked changes.
 
 _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.
 
 _restore_git_head (str run_dir, dict original_head, log_file)
 Restore the repository back to the branch or detached commit it started on.
 
None _refuse_detached_source_checkout (str source_project_root, str action)
 Refuse a branch pull when the source checkout is pinned to a detached commit.
 
 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.
 
 auto_identify_run_inputs (str config_dir)
 Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
 
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.
 
 render_slurm_array_stage_script (str script_path, str job_name, dict cluster_cfg, str array_spec, str case_index_tsv, str stage, str solver_exe, str post_exe, str stdout_path, str stderr_path, "str | None" expected_version_line=None)
 Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
 
 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.
 
 reduce_metric_values (values, str reduction)
 Reduce a metric series to one scalar according to the requested reducer.
 
 extract_metric_from_csv (str case_dir, dict spec)
 Extract a scalar metric from a CSV source.
 
 extract_metric_from_log (str case_dir, dict spec)
 Extract a scalar metric from a log file using regex.
 
 normalize_metric_spec (metric)
 Normalize study metric definitions to a common dictionary form.
 
dict _read_previous_metric_rows (str results_dir)
 Read the metrics table an earlier aggregation wrote, keyed by case id.
 
str aggregate_study_metrics (dict study_cfg, list cases, str results_dir)
 Collect metric values from generated case directories into one CSV.
 
str _study_parameter_label (str key)
 Return a concise report label for a study parameter path.
 
str _study_metric_label (dict study_cfg, str metric)
 Resolve an optional configured metric label or humanize its name.
 
"list | None" _numeric_study_column (list rows, str key)
 Parse one complete, finite numeric study-table column.
 
"dict | None" _infer_study_plot_axis (dict study_cfg, list rows)
 Infer the scientifically meaningful independent variable of a study.
 
 infer_plot_x_axis (dict study_cfg, list rows)
 Infer x-axis key/values for study plots.
 
str _format_study_group_value (value)
 Format a secondary study parameter compactly for a legend.
 
list _study_plot_groups (dict study_cfg, list rows, dict axis, str metric)
 Group metric points by any secondary varied study parameters.
 
bool _study_use_log_scale (list values, bool semantic_hint=False)
 Use log scaling only for positive data spanning a meaningful range.
 
"tuple[float, float] | None" _study_linear_y_limits (list values)
 Build padded linear limits that include zero for non-negative metrics.
 
 generate_study_plots (dict study_cfg, str metrics_csv, str plots_dir)
 Generate metric-vs-parameter plots for completed studies.
 
str _command_to_string (list command_tokens)
 Render a command list as a shell-safe display 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 build_run_dry_plan (args)
 Build a no-write execution plan for run --dry-run.
 
None add_planned_grid_artifacts (dict plan, dict case_cfg, str run_dir)
 Add grid-mode-specific staged artifacts to a dry-run plan.
 
None add_planned_profile_artifacts (dict plan, dict case_cfg, str run_dir)
 Add generated prescribed-flow profile artifacts to a dry-run plan.
 
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.
 
 render_run_dry_plan (dict plan, str output_format="text")
 Render dry-run plan in human or JSON format.
 
 validate_workflow (args)
 Implements picurv validate without launching solver/post workflows.
 
str _asset_file_sha256 (str path)
 Hash an asset source or payload without loading it into memory.
 
str _stable_mapping_sha256 (payload)
 Hash a JSON-compatible value with deterministic serialization.
 
dict _provider_source_fingerprints (value, str case_path, str key="")
 Hash every existing file explicitly referenced by an asset provider.
 
dict build_case_asset_graph (dict case_cfg, str case_path)
 Classify case inputs into precomputable or simulator-runtime providers.
 
list _asset_selection (dict graph, requested=None, *bool precomputable_only=False)
 Resolve requested asset kinds plus dependency closure.
 
list _asset_payload_files (str build_root, str kind)
 Enumerate canonical files belonging to one asset kind in a build tree.
 
dict _build_selected_asset_payloads (str build_root, dict case_cfg, str case_path, list selected)
 Execute existing generators into one isolated run-like build tree.
 
dict _picgrid_geometry_summary (str path)
 Read a canonical PICGRID and summarize what a user would want to check.
 
bool _write_structured_grid_preview (str picgrid_path, str destination, dims)
 Write a single-block ASCII VTS preview of a staged PICGRID.
 
dict _build_asset_inspection (str kind, str build_root, dict provider, list payload_files)
 Produce the inspection material published beside an asset's payload.
 
dict _publish_asset_object (str workspace_root, str build_root, dict provider, list payload_files)
 Publish one immutable content-addressed asset object atomically.
 
str _workspace_asset_set_name (str workspace_root, str case_path)
 Return a collision-free, readable mutable asset-set name.
 
str _write_workspace_asset_set (str workspace_root, str case_path, dict graph, dict references)
 Atomically update the named asset set and workspace asset catalog.
 
dict precompute_case_assets (str workspace_root, dict case_cfg, str case_path, requested=None, bool precomputable_only=False)
 Build and publish a selected deterministic asset dependency closure.
 
str _workspace_asset_set_path (str workspace_root, str case_path)
 Return the mutable asset-set pointer associated with a case config name.
 
 _find_stored_asset (str workspace_root, dict provider)
 Find a published object built from exactly this provider's inputs.
 
dict plan_run_assets (dict case_cfg, str case_path, skip_kinds=())
 Plan reuse/build/runtime actions for all configured run providers.
 
str _materialize_asset_file (str source, str destination)
 Expose one immutable shared-asset file through reflink, hardlink, or copy.
 
dict materialize_run_assets (str run_dir, dict case_cfg, str case_path, bool require_precomputed=False, bool fetch_missing=False, skip_kinds=())
 Resolve/build workspace assets and write the exact run input lock.
 
 precompute_workflow (args)
 Resolve, preflight, and atomically publish reusable workspace assets.
 
 run_workflow (args)
 Main orchestrator for the 'run' command (local and Slurm modes).
 
list parse_case_index_tsv (str tsv_path)
 Parse a case_index.tsv file back into a list of case entry dicts.
 
dict resolve_sweep_stage_executables (list run_dirs)
 Choose the executables an array stage launches for every member of a study.
 
 sweep_workflow (args)
 Study/sweep orchestration using Slurm job arrays.
 
 sweep_continue_workflow (args)
 Continue a partially-completed Slurm parameter sweep study.
 
 sweep_reaggregate_workflow (args)
 Re-run metrics aggregation and plot generation for an existing study.
 
 _read_yaml_if_exists (str filepath)
 Read YAML when present, otherwise return None.
 
 _read_json_if_exists (str filepath)
 Read JSON when present, otherwise return None.
 
 _parse_int_loose (value)
 Best-effort integer parsing for summary extraction.
 
 _parse_float_loose (value)
 Best-effort float parsing for summary extraction.
 
 _extract_numeric_tuple (str text)
 Extract a numeric tuple from a string like '(1, 2, 3)'.
 
dict _build_summary_context (str run_dir)
 Resolve run-local config and artifact paths for summarize.
 
dict _require_summary_config (dict context, str name)
 Return one explicitly requested copied config or fail with a structured error.
 
dict _build_run_overview (dict context)
 Build timestep-independent run metadata for summarize.
 
dict _summarize_turbulence (dict turbulence_cfg)
 Build compact turbulence and wall-model selections.
 
dict _build_case_overview (dict context)
 Build a curated case.yml summary with useful derived quantities.
 
dict _summary_scalar_transport (dict context, dict solver_cfg)
 Scalar-transport parameters as the run resolves them, wherever they are set.
 
dict _build_solver_overview (dict context)
 Build a curated solver.yml summary with normalized selections.
 
dict _build_monitor_overview (dict context)
 Build a curated monitor.yml summary with resolved defaults.
 
"tuple[dict, list[int]]" _parse_continuity_metrics_log (str log_dir)
 Read the continuity record into rows by step (one per block) plus observed order.
 
"tuple[dict, list[int]]" _parse_particle_metrics_log (str log_dir, str metrics_dir)
 Read the per-step particle record into rows by step plus observed order.
 
"tuple[dict, dict, list[int]]" _parse_momentum_convergence_logs (str log_dir)
 Read the per-block momentum records (Picard history, Newton summary) into rows by step.
 
"tuple[dict, dict, list[int]]" _parse_poisson_convergence_logs (str log_dir)
 Read the per-block Poisson histories into each step's last iteration.
 
"tuple[dict, list[int]]" _parse_profiling_timestep_csv (str filepath)
 Parse profiling timestep CSV into latest rows by step plus observed order.
 
"tuple[dict, list[int], dict]" _parse_runtime_memory_log (str filepath)
 Read the runtime memory record into rows by step and its final status.
 
"tuple[dict, list[int]]" _parse_solution_convergence_log (str log_dir)
 Read the solution-convergence record into rows by step plus observed order.
 
"list[str]" _find_solver_stream_log_candidates (str run_dir, str log_dir)
 Return plausible solver stream logs for local and Slurm runs.
 
dict _parse_particle_snapshot_file (str filepath)
 Parse sampled particle snapshots from a solver stream log.
 
"int | None" _find_previous_snapshot_step ("list[int]" snapshot_steps, int step)
 Return the nearest earlier snapshot step when available.
 
dict _compute_particle_snapshot_delta ("list[dict]" current_rows, "list[dict]" previous_rows)
 Compute sampled deltas between two particle snapshot samples.
 
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 _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.
 
 _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.
 
str _format_summary_float (value, str spec=".6e", str missing="n/a")
 Format optional numeric values for summary text output.
 
float _summary_source_mtime (paths)
 Return the newest modification time among one or more summary sources.
 
"list[list[int]]" _order_summary_step_orders ("list[tuple[list[int], object]]" sources)
 Order observed step sequences by the recency of their source files.
 
dict 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.
 
 render_run_summary (dict payload, str output_format="text")
 Render a run-step summary in human or JSON form.
 
str _summary_display_value (value)
 Format one configuration-summary value for compact text output.
 
 _print_config_header (str title, "str | None" subtitle=None)
 Print a strong dashboard-style configuration summary header.
 
 _print_config_group (str title, list rows)
 Print an aligned configuration-summary field group.
 
list _flatten_summary_mapping (dict mapping, str prefix="")
 Flatten nested summary mappings into readable dotted field rows.
 
 _render_run_overview_text (dict summary)
 Render run metadata as a compact dashboard.
 
 _render_case_summary_text (dict summary)
 Render the case summary as a glanceable simulation dashboard.
 
 _render_solver_summary_text (dict summary)
 Render the solver summary as a glanceable numerical-method dashboard.
 
 _render_monitor_summary_text (dict summary)
 Render the monitor summary as a glanceable observability dashboard.
 
 render_selected_summary (dict payload, str output_format="text")
 Render selected timestep-independent config views and optional health.
 
str _humanize_plot_identifier (str value)
 Convert one machine-oriented identifier into a readable plot label.
 
str _summary_field_label (str field)
 Return the report-facing label for one logged scalar field.
 
"float | None" _summary_physical_time (dict context, dict record)
 Resolve a record's physical time from its artifact or copied case configuration.
 
 _read_runtime_diagnostics_csv (path)
 Yields (segment, row) for each data line of one of the solver's CSV records.
 
"str | None" _log_family_path (str log_dir, str base)
 Return a solver record's CSV, or the text log an older run wrote instead.
 
"list[tuple[int, str]]" _block_family_paths (str log_dir, *str bases)
 Return (block, path) for each per-block solver record, CSV preferred per block.
 
"tuple[str | None, list]" _continuity_rows (str log_dir)
 Return the continuity record's path and its (segment, row) pairs.
 
"tuple[str | None, list]" _particle_step_rows (str log_dir, str metrics_dir)
 Return the per-step particle record's path and its (segment, row) pairs.
 
"list[tuple[int, str, list]]" _picard_history_rows (str log_dir)
 Return (block, path, [(segment, row)]) for each Picard momentum history.
 
"list[tuple[int, str, list]]" _newton_history_rows (str log_dir)
 Return (block, path, [(segment, row)]) for each Newton iteration history.
 
"list[tuple[int, str, list]]" _newton_summary_rows (str log_dir)
 Return (block, path, [(segment, row)]) for each Newton per-step summary.
 
"list[tuple[int, str, list]]" _poisson_history_rows (str log_dir)
 Return (block, path, [(segment, row)]) for each Poisson iteration history.
 
list _runtime_memory_rows (str path)
 Return the runtime memory record's (segment, row) pairs (CSV, or an older run's text log).
 
 _typed_csv_value (str text)
 Return a CSV cell as a float (written with a point or exponent), an int, or the text.
 
"tuple[str | None, list]" _solution_convergence_rows (str log_dir)
 Return the solution-convergence record's path and its (segment, row) pairs.
 
 _append_summary_plot_record (list records, str source, step, str line, dict values, str source_path, int segment=0, dict coordinates=None)
 Append one numeric append-ordered record for summarize plotting.
 
bool _is_summary_plot_continuation_marker (str line)
 Return whether a log line starts a new continuation segment.
 
list _collect_summary_plot_records (dict context)
 Collect append-ordered numeric records from summarize-supported scalar logs.
 
list _collect_spectra_plot_records (dict context)
 Collect the per-step scalar histories written by the spectra post stage.
 
list _collect_profile_plot_records (dict context)
 Collect the profile histories the profile_series reducer wrote.
 
list _build_summary_plot_catalog (list records)
 Build available qualified-series metadata from plot records.
 
list _representative_indices (int count, int maximum=6)
 Select evenly distributed indices while always retaining both endpoints.
 
dict _build_spectrum_plot_request (dict context, str task, bool reference, bool linear_y, "str | None" output_path)
 Build a plot.gen request drawing representative measured spectra.
 
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.
 
 _render_summary_plot_catalog (list catalog, str output_format)
 Render available summarize plot-series metadata.
 
str _summary_table_path (dict context, str name)
 Resolve a summarize --table name to the CSV it prints.
 
 _list_summary_tables (dict context, str output_format)
 Print the summarize --table names whose CSV this run has, with row counts.
 
 _render_summary_table (dict context, str name, last_n, columns, str output_format)
 Print the last rows of one of the run's CSV records as aligned columns (or JSON).
 
 _invoke_plot_gen (dict request)
 Invoke standalone plot.gen with one normalized request over stdin.
 
 summarize_workflow (args)
 Build and render a read-only health summary for a run step.
 
dict _resolve_submission_target (str run_dir=None, str study_dir=None)
 Resolve a run/study submission target from explicit directory flags.
 
dict _get_submission_stage_metadata (dict target_context, str stage_name)
 Return stored metadata for one staged submission target.
 
list _get_recorded_submission_stages (dict target_context)
 Return stage names explicitly recorded in scheduler submission metadata.
 
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.
 
 _set_submission_stage_metadata (dict target_context, str stage_name, dict stage_meta)
 Persist one stage's metadata back into the submission payload.
 
 _write_submission_target_metadata (dict target_context)
 Write updated submission metadata back to disk.
 
tuple staged_control_directories (str control_path)
 Read run-owned directory values from a staged control file.
 
list preflight_config_directories (str root_dir)
 Every config directory under a run or study root that may hold a control file.
 
tuple preflight_staged_run_directories (str root_dir)
 Re-check run-directory safety against an already-staged run or study.
 
list read_text_file_lines (str path)
 Read a text file into a list of lines.
 
 submit_staged_jobs (args)
 Submit previously staged Slurm artifacts from an existing run/study directory.
 
 submit_staged_local_run (args, dict target_context, list selected_stages)
 Execute previously staged local run commands from scheduler/submission.json.
 
 cancel_run_jobs (args)
 Cancel Slurm-submitted jobs for an existing run directory.
 
 _workspace_yaml_role (str path)
 Infer the owned workspace role of one copied YAML file.
 
 _rewrite_workspace_path_values (value, dict replacements)
 Rewrite copied template path scalars to workspace-root-relative homes.
 
 _choose_primary_workspace_role (list candidates, str role, str template_name)
 Select the canonical role file from a template that may carry variants.
 
dict organize_initialized_workspace (str workspace_root, str template_name, str source_template_root=None)
 Convert a copied example into the canonical editable workspace layout.
 
dict import_workspace_input (str workspace_root, str kind, str source, str name=None, str mode="copy")
 Explicitly import or register one workspace input.
 
 inputs_workflow (args)
 Handle explicit workspace input management.
 
 version_workflow (args)
 Report, and for the status action validate, the shared build identity.
 
None _print_petsc_build (dict identity)
 Print the PETSc an executable was built against and the library it would load.
 
None _require_clean_source_checkout (str action)
 Refuse version-changing Git operations in a dirty source checkout.
 
subprocess.CompletedProcess _git_source_command (list arguments)
 Run a checked Git command against the active PICurv source checkout.
 
 source_workflow (args)
 Fetch source history without silently changing the active code.
 
 _workspace_requested_version (str workspace_root)
 Resolve an exact version requested by an initialized workspace.
 
 versions_workflow (args)
 List or activate a release using the existing source/build owners.
 
str _resolve_version_ref (str version)
 Resolve a user-named version to a Git ref that exists in the source checkout.
 
 init_case (args)
 Implements the 'init' command.
 
 sync_case_config_command (args)
 Refresh template-managed config/docs files in a case directory.
 
 pull_source_repo (args)
 Refresh source branches in the repository resolved from a case directory.
 
 build_project (args)
 Implements the 'build' command.
 
None run_project_make (str source_project_root, "list[str]" make_args)
 Run the project Makefile with caller-supplied arguments, defaulting to all.
 

Variables

 _package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 _storage_module = importlib.import_module("picurv_cli.storage")
 
 StorageError = _storage_module.StorageError
 
 cold_study_members = _storage_module.cold_study_members
 
 restore_cold_study_members = _storage_module.restore_cold_study_members
 
 is_artifact_cold = _storage_module.is_artifact_cold
 
 read_artifact_identity = _storage_module.read_artifact_identity
 
 STORAGE_LOCK_FILENAME = _storage_module.STORAGE_LOCK_FILENAME
 
 STORAGE_STATE_FILENAME = _storage_module.STORAGE_STATE_FILENAME
 
 require_storage_payload_local = _storage_module.require_storage_payload_local
 
 runtime_stage_lock = _storage_module.runtime_stage_lock
 
 storage_state_summary = _storage_module.storage_state_summary
 
 _rebase_restored_text_paths = _storage_module._rebase_restored_text_paths
 
 _NUMPY_MODULE = None
 
 _MATPLOTLIB_PYPLOT = None
 
 np = _LazyNumpyProxy()
 
 PACKAGE_PATH = os.path.dirname(os.path.realpath(__file__))
 
 PACKAGE_PROJECT_ROOT = os.path.dirname(PACKAGE_PATH)
 
 INVOKED_SCRIPT_DIR
 
 SCRIPT_PATH
 
 PROJECT_ROOT = os.path.dirname(SCRIPT_PATH)
 
 GENERATORS_PATH = os.path.join(PACKAGE_PROJECT_ROOT, "generators")
 
 DEFAULT_BIN_DIR = SCRIPT_PATH
 
 VERSION_FILE = os.path.join(PACKAGE_PROJECT_ROOT, "VERSION")
 
str PICURV_RELEASE_VERSION = _read_release_version()
 
str CASE_ORIGIN_METADATA_FILENAME = ".picurv-origin.json"
 
str WORKSPACE_CONFIG_FILENAME = ".picurv-workspace.yml"
 
int WORKSPACE_SCHEMA_VERSION = 1
 
int RUN_MANIFEST_SCHEMA_VERSION = 3
 
int ASSET_MANIFEST_SCHEMA_VERSION = 1
 
int ASSET_LOCK_SCHEMA_VERSION = 1
 
str RUNTIME_EXECUTION_CONFIG_FILENAME = ".picurv-execution.yml"
 
str LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME = ".picurv-local.yml"
 
str RUNTIME_EXECUTION_EXAMPLE_FILENAME = "execution.example.yml"
 
tuple RUNTIME_EXECUTION_CONFIG_FILENAMES
 
str DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE
 
str CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT = "my_project_account"
 
str CLUSTER_TEMPLATE_PLACEHOLDER_MAIL = "user@example.edu"
 
dict DEFAULT_WALLTIME_GUARD_POLICY
 
str WALLTIME_GUARD_ENV_JOB_START_EPOCH = "PICURV_JOB_START_EPOCH"
 
str WALLTIME_GUARD_ENV_LIMIT_SECONDS = "PICURV_WALLTIME_LIMIT_SECONDS"
 
str POST_RESUME_STATE_FILENAME = "post.resume.json"
 
str POST_LOCK_FILENAME = "post.lock"
 
str POST_LOCK_METADATA_FILENAME = "post.lock.json"
 
str POST_LOCK_WRAPPER_FILENAME = "post_lock_wrapper.py"
 
int POST_RESUME_SCHEMA_VERSION = 2
 
dict POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {"startTime", "endTime", "timeStep", "source_directory"}
 
str CHECKPOINT_FORMAT = "picurv-checkpoint"
 
int CHECKPOINT_VERSION = 1
 
int CHECKPOINT_STEP_WIDTH = 12
 
dict CHECKPOINT_REQUIRED_EULERIAN_FIELDS = {"Ucat", "Ucont", "Ucont_rm1", "P", "Nvert"}
 
tuple WORKSPACE_DIRECTORY_LAYOUT
 
tuple RUN_DIRECTORY_LAYOUT
 
dict CANONICAL_RUN_PATHS
 
 INITIAL_CONDITION_SPECTRUM_RELPATH
 
dict _WORKSPACE_ARTIFACT_ROOT_VALUES = {"runs", "studies"}
 
dict _ASSET_SOURCE_REFERENCE_KEYS
 
dict _PYTHON_INITIAL_CONDITION_PROVIDERS
 
tuple SPECTRAL_IC_PROVIDERS
 
tuple SHELL_SPECTRUM_IC_PROVIDERS = ("spectral_random_velocity", "resampled_velocity")
 
 SPECTRAL_RANDOM_VELOCITY_PARAMS
 
 RESAMPLED_VELOCITY_PARAMS
 
tuple RESAMPLED_VELOCITY_FASTEST_AXES = ("x", "z")
 
tuple RESAMPLED_VELOCITY_COMPONENT_LAYOUTS = ("interleaved", "separate")
 
tuple RESAMPLED_VELOCITY_RAW_DTYPES = ("float32", "float64")
 
tuple RESAMPLED_VELOCITY_BYTE_ORDERS = ("little", "big", "native")
 
tuple RESAMPLED_VELOCITY_PROJECTIONS = ("picurv_discrete", "continuum", "none")
 
dict RESAMPLED_VELOCITY_LAYOUT_KEYS
 
dict _FILE_BACKED_GRID_VALUES = {"file", "grid_gen"}
 
dict _WORKSPACE_MANAGED_PATHS = {"assets", "inputs", "runs", "studies"}
 
dict _VENDORABLE_CONFIG_REFERENCE_KEYS = {"config_file", "script"}
 
dict _PLAIN_FILENAME_SENTINELS = {"", ".", ".."}
 
dict _VERSION_BUILD_ACTIONS = {"install", "activate"}
 
 RUN_DIRECTORY_ROOTS
 
 RUN_ROOT_ALLOWED_FILES = frozenset({"manifest.json"})
 
tuple CONTINUATION_STAGED_RECORDS
 
tuple RUN_EXECUTABLE_NAMES = ("simulator", "postprocessor")
 
 STORAGE_OFFLOADED_STATES = frozenset({"COLD", "PARTIAL"})
 
tuple RUN_COMPONENT_STATES
 
tuple RUN_COMPONENT_LAYOUT
 
 _SELECTED_PETSC_INDEX_BYTES = None
 
 _BINARY_VERSION_PATTERN
 
 _BINARY_PETSC_PATTERN
 
dict PETSC_INDEX_BYTES = {"int32": 4, "int64": 8}
 
str _BINARY_PETSC_STAMP_MARKER = b"PICURV_PETSC_BUILD:"
 
str _BINARY_BUILD_STAMP_MARKER = b"PICURV_BUILD:"
 
str ERROR_CODE_CLI_USAGE_INVALID = "CLI_USAGE_INVALID"
 
str ERROR_CODE_CFG_MISSING_SECTION = "CFG_MISSING_SECTION"
 
str ERROR_CODE_CFG_MISSING_KEY = "CFG_MISSING_KEY"
 
str ERROR_CODE_CFG_INVALID_TYPE = "CFG_INVALID_TYPE"
 
str ERROR_CODE_CFG_INVALID_VALUE = "CFG_INVALID_VALUE"
 
str ERROR_CODE_CFG_FILE_NOT_FOUND = "CFG_FILE_NOT_FOUND"
 
str ERROR_CODE_CFG_GRID_PARSE = "CFG_GRID_PARSE"
 
str ERROR_CODE_CFG_INCONSISTENT_COMBO = "CFG_INCONSISTENT_COMBO"
 
str ERROR_CODE_DEPENDENCY_MISSING = "DEPENDENCY_MISSING"
 
tuple RESTART_RUN_DIR_REQUIRED_MESSAGE
 
dict _ERROR_HINTS
 
tuple _RESTART_HINT
 
tuple _STORAGE_RESTORE_HINT
 
str _STUDY_DIR_HINT = "Pass a study directory that 'picurv sweep' created (studies/<study_id>)."
 
 _MAKE_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*[:+?]?=.*$")
 
dict POST_RUN_CONTROL_ALIASES
 
dict GRID_GENERATOR_HYPHEN_KEY_HINTS
 
tuple RETIRED_GENERATOR_DESTINATION_KEYS
 
tuple RETIRED_INITIAL_CONDITION_DESTINATION_KEYS = ("output_file", "summary_json", "spectrum_csv")
 
dict POST_SPECTRA_TASKS
 
tuple POST_SPECTRA_SYMBOLS = ("continuum", "discrete")
 
tuple POST_SPECTRA_MEAN_MODES = ("none", "domain", "sample")
 
tuple POST_FIELD_STATISTICS_OUTPUTS = ("mean", "reynolds_stress", "rms", "tke", "flux", "point_variance")
 
tuple POST_FIELD_STATISTICS_DEFAULT_OUTPUTS = POST_FIELD_STATISTICS_OUTPUTS[:-1]
 
tuple POST_FIELD_STATISTICS_FORMATS = ("vtk", "csv")
 
tuple GRID_MODES = ("file", "programmatic_c", "grid_gen")
 
tuple DOMAIN_DIMENSIONALITY_MODES = ("3D", "2D")
 
tuple GRID_GENERATOR_TYPES = ("box", "sweep")
 
tuple GRID_CROSS_SECTION_KINDS = ("rectangle", "circle")
 
tuple GRID_WALL_SEGMENT_KINDS = ("flat", "step", "ramp", "arc", "sine", "gaussian", "hill")
 
tuple GRID_PATH_SEGMENT_KINDS = ("straight", "arc")
 
tuple GRID_TRANSFORM_KINDS = ("anchor", "translate", "scale", "rotate", "mirror", "permute", "reverse")
 
tuple PARTICLE_RESTART_MODES = ("init", "load")
 
tuple POST_EULERIAN_PIPELINE_TASKS = ("q_criterion", "normalize_field", "nodal_average")
 
tuple POST_LAGRANGIAN_PIPELINE_TASKS = ("specific_ke",)
 
tuple STUDY_TYPES = ("grid_independence", "timestep_independence", "sensitivity")
 
tuple NEWTON_KRYLOV_PRECONDITIONER_MODELS = ("none", "frozen_momentum_jacobian")
 
tuple NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES = ("none", "point_block")
 
tuple POISSON_PRECONDITIONER_TYPES = ("multigrid",)
 
dict POISSON_PRECONDITIONER_SPELLINGS = {"mg": "multigrid", "pcmg": "multigrid"}
 
tuple PRESCRIBED_FLOW_SOURCE_TYPES = ("file", "generated", "field_slice")
 
tuple VERIFICATION_SCALAR_PROFILES = ("CONSTANT", "LINEAR_X", "SIN_PRODUCT")
 
dict SCALAR_TRANSPORT_FLAGS
 
dict SCALAR_TRANSPORT_CASE_PATHS
 
tuple STUDY_PLOT_FORMATS = ("png", "pdf", "svg")
 
tuple PROFILING_TIMESTEP_MODES = ("off", "selected", "all")
 
dict RENAMED_MONITOR_FUNCTIONS
 
tuple REMOVED_MONITOR_FUNCTIONS
 
tuple GMRES_RESTART_METHODS = ("gmres", "fgmres", "lgmres")
 
dict POISSON_KSP_METHODS
 
dict POISSON_KSP_REFUSED
 
tuple ANALYTICAL_SOLUTION_TYPES = ("TGV3D", "ZERO_FLOW", "UNIFORM_FLOW")
 
tuple LEGACY_FIELD_INIT_SPELLINGS = ("Zero", "Constant", "Poiseuille")
 
tuple PROJECTION_OPERATORS = ("continuum", "picurv_discrete")
 
tuple METRIC_SOURCE_KINDS = ("statistics_csv", "csv", "log_regex", "log")
 
tuple LAUNCH_MODES = ("local", "cluster")
 
tuple POST_SPECTRA_SCALAR_COLUMNS
 
tuple POST_STAGE_NAMES = ("fields", "spectra", "reducers")
 
dict POST_REDUCER_TASKS
 
dict POST_REDUCER_FRICTION_SOURCES
 
dict _REDUCER_AXIS_TOKENS = {"i": "Xi", "j": "Eta", "k": "Zeta"}
 
dict POST_PARAVIEW_SERIES_SCOPES = {"run", "lineage"}
 
dict POST_PROCESS_REASON_LABELS
 
str PICGRID_FLOAT_FORMAT = ".17e"
 
dict GENERATED_PROFILE_GENERATORS = {"square_duct_poiseuille"}
 
dict GRID_GENERATOR_CHOICE_FLAGS
 
dict GRID_GENERATOR_SPEC_FLAGS
 
dict BC_FACE_MAP
 
dict BC_TYPE_MAP
 
tuple PARTICLE_BOUNDARY_POLICIES = ("reflect", "remove", "wrap")
 
dict BC_HANDLER_SPECS
 
dict _NUMERIC_BC_PARAMS = {"vx", "vy", "vz", "v_max", "target_flux"}
 
dict _BOOL_BC_PARAMS = {"enforce_seam_flux", "apply_trim"}
 
dict _DEPRECATED_BC_PARAM_ALIASES = {"apply_trim": "enforce_seam_flux"}
 
tuple RANS_PLANNED_REFUSAL
 
dict _CASE_SCHEMA
 
dict _SOLVER_SCHEMA
 
dict _MONITOR_SCHEMA
 
dict STATISTICS_ELIGIBLE_FIELDS
 
dict STATISTICS_PARTICLE_FIELDS
 
tuple POST_STATISTICS_ENSEMBLE_SOURCES = ("particles", "eulerian")
 
tuple STATISTICS_MOMENT_NAMES = ("first", "second")
 
tuple STATISTICS_WEIGHTING_MODES = ("sample", "physical_time")
 
dict _POST_SCHEMA
 
dict _CLUSTER_SCHEMA
 
dict _STUDY_SCHEMA
 
dict _WORKSPACE_SCHEMA
 
tuple DIMENSIONLESS = (0, 0, 0)
 
tuple LENGTH = (1, 0, 0)
 
tuple VELOCITY = (0, 1, 0)
 
tuple TIME = (1, -1, 0)
 
tuple WAVENUMBER = (-1, 0, 0)
 
tuple VOLUME_FLUX = (2, 1, 0)
 
tuple DIFFUSIVITY = (1, 1, 0)
 
tuple PRESSURE = (0, 2, 1)
 
tuple DENSITY = (0, 0, 1)
 
tuple DYNAMIC_VISCOSITY = (1, 1, 1)
 
tuple INPUT_CONVERSION_SITES = ("cli", "c", "staging", "provider", "evaluator", "reference", "passthrough", "")
 
tuple NOT_A_QUANTITY = (None, "")
 
tuple UNITLESS = (DIMENSIONLESS, "")
 
dict INPUT_QUANTITIES
 
dict BC_PARAM_QUANTITIES
 
dict IC_PARAM_QUANTITIES
 
tuple RUN_OWNED_DIRECTORY_KEYS = ("log", "output")
 
str UNSAFE_PATHS_OVERRIDE_KEY = "allow_unsafe_paths"
 
tuple RESERVED_RUN_DIRECTORY_NAMES = ("config", "scheduler", "checkpoints", "visualization")
 
dict RUN_DIRECTORY_DEFAULTS = {"log": "logs", "output": "output"}
 
tuple RESERVED_DIRECTORY_FLAGS
 
tuple RESERVED_INDIRECTION_FLAGS = ("-options_file", "-options_file_yaml", "-alias")
 
tuple UNSAFE_DIRECTORY_CHARACTERS = ('"', "'", "#", "\n", "\r", "\t")
 
str PHYSICAL_VERDICT_CONTAINED = "contained"
 
str PHYSICAL_VERDICT_RUN_ROOT = "run_root"
 
str PHYSICAL_VERDICT_ANCESTOR = "ancestor"
 
str PHYSICAL_VERDICT_RELATIVE_ESCAPE = "relative_escape"
 
str PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE = "external_absolute"
 
 WAIVABLE_PHYSICAL_VERDICTS = frozenset({PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE})
 
dict _SLURM_FAILED_END_STATES
 
str RUNTIME_MEMORY_FILE = "Runtime_Memory.csv"
 
str PROFILING_TIMESTEP_FILE = "Profiling_Timestep_Summary.csv"
 
dict DIAGNOSTICS_PETSC_KEYS
 
tuple PROGRAMMATIC_BRIDGE_GRID_KEYS
 
tuple GRID_DA_PROCESSOR_KEYS = ("da_processors_x", "da_processors_y", "da_processors_z")
 
dict IC_PAYLOAD_DIMENSIONS = {0: VELOCITY, 1: VOLUME_FLUX}
 
dict GENERATED_IC_PROVIDERS
 
dict PARTICLE_SETTABLE_FIELDS = {"Psi": 1}
 
tuple PETSC_INDEX_BITS_CHOICES = ("32", "64")
 
int PETSC_MAX_INT_32BIT = 2**31 - 1
 
int PARTICLE_FIELD_MAX_COMPONENTS = 3
 
int GRID_FIELD_MAX_COMPONENTS = 3
 
int STATISTICS_FIELD_COMPONENTS = 6
 
dict LES_MODEL_NAMES = {1: "constant_smagorinsky", 2: "dynamic_smagorinsky", 3: "vreman", 4: "wale"}
 
dict LES_MODEL_OWNED_KEYS
 
dict LES_MODEL_KEY_OWNER_DESCRIPTIONS
 
tuple LES_AVERAGING_DIRECTION_AXES = ("i", "j", "k")
 
dict SOLVER_MONITORING_POISSON_FLAG_MAP
 
dict SOLVER_MONITORING_MOMENTUM_FLAG_MAP
 
int MAX_TRACED_PARTICLES = 64
 
dict KRYLOV_KSP_TYPES
 
tuple _STUDY_REPORT_COLORS
 
dict ASSET_KIND_DIRECTORIES
 
int ASSET_PREVIEW_NODE_LIMIT = 2_000_000
 
 _SUMMARY_NUMERIC_RE = re.compile(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
 
int _CONFIG_SUMMARY_WIDTH = 78
 
dict _SUMMARY_PLOT_LOG_SCALE_FIELDS
 
dict _SUMMARY_PLOT_FIELD_LABELS
 
dict _SUMMARY_PLOT_SOURCE_TITLES
 
dict _SUMMARY_ITERATION_HISTORY_FIELDS
 
dict _SUMMARY_COUNT_FIELDS
 
 _LEGACY_PICARD_REGEX
 
 _LEGACY_NEWTON_HISTORY_REGEX
 
 _LEGACY_NEWTON_SUMMARY_REGEX
 
 _LEGACY_POISSON_REGEX
 
tuple _PLOT_ROW_IDENTITY_KEYS = ("step", "block")
 
dict SUMMARY_TABLES
 
dict WORKSPACE_INPUT_DIRECTORIES
 

Function Documentation

◆ _prune_incompatible_python_site_paths()

picurv_cli.core._prune_incompatible_python_site_paths (   paths)
protected

Remove site-package paths for a different Python major/minor version.

Parameters
[in]pathsCandidate sys.path entries.
Returns
Filtered path list.

Definition at line 101 of file core.py.

101def _prune_incompatible_python_site_paths(paths):
102 """!
103 @brief Remove site-package paths for a different Python major/minor version.
104 @param[in] paths Candidate sys.path entries.
105 @return Filtered path list.
106 """
107 current = (sys.version_info[0], sys.version_info[1])
108 pattern = re.compile(r"python(?:-)?(\d+)\.(\d+)", re.IGNORECASE)
109 filtered = []
110 for path in paths:
111 text = str(path)
112 match = pattern.search(text)
113 if match:
114 path_version = (int(match.group(1)), int(match.group(2)))
115 if path_version != current and ("site-packages" in text or "dist-packages" in text):
116 continue
117 filtered.append(path)
118 return filtered
119
120
Here is the caller graph for this function:

◆ _drop_imported_package()

picurv_cli.core._drop_imported_package ( str  package_name)
protected

Remove a failed/partial import package tree from sys.modules.

Parameters
[in]package_nameTop-level package name.

Definition at line 121 of file core.py.

121def _drop_imported_package(package_name: str):
122 """!
123 @brief Remove a failed/partial import package tree from sys.modules.
124 @param[in] package_name Top-level package name.
125 """
126 prefix = package_name + "."
127 for module_name in list(sys.modules):
128 if module_name == package_name or module_name.startswith(prefix):
129 sys.modules.pop(module_name, None)
130
131
Head of a generic C-style linked list.
Definition variables.h:518
Here is the caller graph for this function:

◆ require_numpy()

picurv_cli.core.require_numpy ( )

Import NumPy only for commands that need numeric reductions.

Returns
Imported NumPy module.

Definition at line 132 of file core.py.

132def require_numpy():
133 """!
134 @brief Import NumPy only for commands that need numeric reductions.
135 @return Imported NumPy module.
136 """
137 global _NUMPY_MODULE
138 if _NUMPY_MODULE is not None:
139 return _NUMPY_MODULE
140 try:
141 import numpy
142 except Exception as exc:
143 first_error = exc
144 original_path = list(sys.path)
145 try:
146 _drop_imported_package("numpy")
147 sys.path = _prune_incompatible_python_site_paths(original_path)
148 import numpy
149 except Exception as retry_exc:
150 raise RuntimeError(
151 "NumPy is required for this operation, but no compatible NumPy "
152 "could be imported for this Python interpreter. PICurv ignored "
153 "site-packages paths for other Python versions and retried. "
154 f"First error: {first_error}. Retry error: {retry_exc}"
155 ) from retry_exc
156 finally:
157 sys.path = original_path
158 _NUMPY_MODULE = numpy
159 return _NUMPY_MODULE
160
161
Here is the call graph for this function:
Here is the caller graph for this function:

◆ optional_matplotlib_pyplot()

picurv_cli.core.optional_matplotlib_pyplot ( )

Import matplotlib.pyplot lazily for study plot generation.

Returns
matplotlib.pyplot when available, otherwise None.

Definition at line 162 of file core.py.

162def optional_matplotlib_pyplot():
163 """!
164 @brief Import matplotlib.pyplot lazily for study plot generation.
165 @return matplotlib.pyplot when available, otherwise None.
166 """
167 global _MATPLOTLIB_PYPLOT
168 if _MATPLOTLIB_PYPLOT is not None:
169 return _MATPLOTLIB_PYPLOT
170 original_path = list(sys.path)
171 try:
172 import matplotlib.pyplot as pyplot
173 except Exception:
174 try:
175 _drop_imported_package("matplotlib")
176 sys.path = _prune_incompatible_python_site_paths(original_path)
177 import matplotlib.pyplot as pyplot
178 except Exception:
179 return None
180 finally:
181 sys.path = original_path
182 _MATPLOTLIB_PYPLOT = pyplot
183 return _MATPLOTLIB_PYPLOT
184
185# --- Global Path Definitions ---
186# The implementation package and source-tree entrypoint live in picurv_cli/,
187# while generators/ owns standalone generators.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _read_release_version()

str picurv_cli.core._read_release_version ( )
protected

Read the single release version shared by every PICurv executable.

Returns
Release version from VERSION, or a safe development fallback.

Definition at line 208 of file core.py.

208def _read_release_version() -> str:
209 """!
210 @brief Read the single release version shared by every PICurv executable.
211 @return Release version from VERSION, or a safe development fallback.
212 """
213 configured = os.environ.get("PICURV_RELEASE_VERSION")
214 if configured:
215 return configured.strip()
216 try:
217 with open(VERSION_FILE, "r", encoding="utf-8") as stream:
218 value = stream.read().strip()
219 except OSError:
220 value = "0.0.0"
221 return value or "0.0.0"
222
223
Here is the caller graph for this function:

◆ _git_output()

"str | None" picurv_cli.core._git_output ( *  arguments)
protected

Run one git command in the source checkout.

Parameters
[in]argumentsGit arguments.
Returns
Stripped standard output, or None when git is unavailable or the command fails.

Definition at line 224 of file core.py.

224def _git_output(*arguments) -> "str | None":
225 """!
226 @brief Run one git command in the source checkout.
227 @param[in] arguments Git arguments.
228 @return Stripped standard output, or None when git is unavailable or the command fails.
229 """
230 try:
231 result = subprocess.run(["git", *arguments], cwd=PACKAGE_PROJECT_ROOT,
232 text=True, capture_output=True, check=False)
233 except OSError:
234 return None
235 return result.stdout.strip() if result.returncode == 0 else None
236
237
Here is the caller graph for this function:

◆ _source_build_identity()

dict picurv_cli.core._source_build_identity ( str  release_version)
protected

Resolve reproducible release, commit, and dirty-tree build identity.

One git describe --long --dirty gives the commit, its distance from the release tag, and whether tracked files differ from it (untracked files are ignored, as git status --untracked-files=no did). Only when the tag is missing does it fall back to the commit, a dirty check, and the commit count. On a cold network filesystem each git call costs seconds, which is why this is reached lazily through picurv_build() rather than at import.

Parameters
[in]release_versionRelease version read from VERSION.
Returns
Mapping suitable for manifests and user-facing status output.

Definition at line 238 of file core.py.

238def _source_build_identity(release_version: str) -> dict:
239 """!
240 @brief Resolve reproducible release, commit, and dirty-tree build identity.
241 @details One `git describe --long --dirty` gives the commit, its distance from the
242 release tag, and whether tracked files differ from it (untracked files are
243 ignored, as `git status --untracked-files=no` did). Only when the tag is
244 missing does it fall back to the commit, a dirty check, and the commit count.
245 On a cold network filesystem each git call costs seconds, which is why this is
246 reached lazily through picurv_build() rather than at import.
247 @param[in] release_version Release version read from VERSION.
248 @return Mapping suitable for manifests and user-facing status output.
249 """
250 identity = {
251 "release_version": release_version,
252 "version": release_version,
253 "git_commit": None,
254 "git_short_commit": None,
255 "dirty": None,
256 "build_id": release_version,
257 "dev_distance": None,
258 "released": False,
259 }
260 described = _git_output("describe", "--tags", "--long", "--dirty", "--abbrev=40",
261 "--match", f"v{release_version}")
262 match = re.match(r"^v.+-(\d+)-g([0-9a-f]{40})(-dirty)?$", described or "")
263 if match:
264 identity["dev_distance"] = int(match.group(1))
265 identity["git_commit"] = match.group(2)
266 identity["dirty"] = bool(match.group(3))
267 else:
268 # No tag for this release yet: every commit is development toward it.
269 identity["git_commit"] = _git_output("rev-parse", "HEAD")
270 status = _git_output("status", "--porcelain", "--untracked-files=no")
271 identity["dirty"] = None if status is None else bool(status)
272 count = _git_output("rev-list", "--count", "HEAD")
273 if count is not None and count.isdigit():
274 identity["dev_distance"] = int(count)
275 if identity["git_commit"]:
276 identity["git_short_commit"] = identity["git_commit"][:12]
277 # Commits since the release tag, so a development build is visibly ahead of the
278 # release it names rather than claiming to be that release.
279 identity["released"] = identity["dev_distance"] == 0 and not identity["dirty"]
280 if identity["git_short_commit"]:
281 development = "" if identity["dev_distance"] in (0, None) else f".dev{identity['dev_distance']}"
282 suffix = f"{development}+g{identity['git_short_commit']}"
283 if identity["dirty"]:
284 suffix += ".dirty"
285 identity["build_id"] = release_version + suffix
286 identity["version"] = identity["build_id"]
287 return identity
288
289
290@functools.lru_cache(maxsize=None)
Here is the call graph for this function:
Here is the caller graph for this function:

◆ picurv_build()

dict picurv_cli.core.picurv_build ( )

The conductor's own build identity, resolved on first use and kept for the process.

Commands that only parse, validate, or scaffold never touch git; manifests, locks, and picurv version do, once. Callers that store it copy it.

Returns
The cached identity mapping from _source_build_identity().

Definition at line 291 of file core.py.

291def picurv_build() -> dict:
292 """!
293 @brief The conductor's own build identity, resolved on first use and kept for the process.
294 @details Commands that only parse, validate, or scaffold never touch git; manifests,
295 locks, and `picurv version` do, once. Callers that store it copy it.
296 @return The cached identity mapping from _source_build_identity().
297 """
298 return _source_build_identity(PICURV_RELEASE_VERSION)
299
300
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _find_named_file_upwards()

picurv_cli.core._find_named_file_upwards ( str  start,
str  filename 
)
protected

Find a named file at or above an arbitrary filesystem anchor.

Parameters
[in]startFile or directory from which to search.
[in]filenameBasename to locate.
Returns
Absolute path when found, otherwise None.

Definition at line 467 of file core.py.

467def _find_named_file_upwards(start: str, filename: str):
468 """!
469 @brief Find a named file at or above an arbitrary filesystem anchor.
470 @param[in] start File or directory from which to search.
471 @param[in] filename Basename to locate.
472 @return Absolute path when found, otherwise None.
473 """
474 current = os.path.abspath(start or os.getcwd())
475 if os.path.isfile(current):
476 current = os.path.dirname(current)
477 while True:
478 candidate = os.path.join(current, filename)
479 if os.path.isfile(candidate):
480 return candidate
481 parent = os.path.dirname(current)
482 if parent == current:
483 return None
484 current = parent
485
486
Here is the caller graph for this function:

◆ find_workspace_root()

picurv_cli.core.find_workspace_root ( *  anchors)

Locate the nearest initialized PICurv workspace for supplied anchors.

Parameters
[in]anchorsCandidate config, run, study, or current-working-directory paths.
Returns
Workspace root path when found, otherwise None.

Definition at line 487 of file core.py.

487def find_workspace_root(*anchors):
488 """!
489 @brief Locate the nearest initialized PICurv workspace for supplied anchors.
490 @param[in] anchors Candidate config, run, study, or current-working-directory paths.
491 @return Workspace root path when found, otherwise None.
492 """
493 for anchor in anchors or (os.getcwd(),):
494 if not anchor:
495 continue
496 found = _find_named_file_upwards(str(anchor), WORKSPACE_CONFIG_FILENAME)
497 if found:
498 return os.path.dirname(found)
499 return None
500
501
Here is the call graph for this function:
Here is the caller graph for this function:

◆ load_workspace_config()

dict picurv_cli.core.load_workspace_config ( str  workspace_root)

Load and validate the immutable workspace identity/configuration file.

Parameters
[in]workspace_rootInitialized workspace directory.
Returns
Parsed workspace configuration mapping.

Definition at line 502 of file core.py.

502def load_workspace_config(workspace_root: str) -> dict:
503 """!
504 @brief Load and validate the immutable workspace identity/configuration file.
505 @param[in] workspace_root Initialized workspace directory.
506 @return Parsed workspace configuration mapping.
507 """
508 path = os.path.join(os.path.abspath(workspace_root), WORKSPACE_CONFIG_FILENAME)
509 payload = read_yaml_file(path)
510 if payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION:
511 raise ValueError(
512 f"{path}: unsupported workspace schema_version "
513 f"{payload.get('schema_version')!r}; expected {WORKSPACE_SCHEMA_VERSION}."
514 )
515 errors: list = []
516 _validate_yaml_schema_keys(payload, _WORKSPACE_SCHEMA, path, errors)
517 if errors:
518 raise ValueError("\n".join(errors))
519 return payload
520
521
Here is the call graph for this function:
Here is the caller graph for this function:

◆ enforce_workspace_version()

dict picurv_cli.core.enforce_workspace_version ( str  workspace_root)

Enforce an optional workspace PICurv version requirement.

Parameters
[in]workspace_rootInitialized workspace directory.
Returns
Current build identity after successful validation.

Definition at line 522 of file core.py.

522def enforce_workspace_version(workspace_root: str) -> dict:
523 """!
524 @brief Enforce an optional workspace PICurv version requirement.
525 @param[in] workspace_root Initialized workspace directory.
526 @return Current build identity after successful validation.
527 """
528 payload = load_workspace_config(workspace_root)
529 software = payload.get("software") or {}
530 requirement = software.get("picurv") if isinstance(software, dict) else None
531 if requirement in (None, ""):
532 return dict(picurv_build())
533 try:
534 from packaging.specifiers import SpecifierSet
535 from packaging.version import Version
536 requirement_text = str(requirement).strip()
537 if not any(token in requirement_text for token in "<>=!~"):
538 requirement_text = "==" + requirement_text
539 matches = Version(PICURV_RELEASE_VERSION) in SpecifierSet(requirement_text)
540 except Exception as exc:
541 raise ValueError(
542 f"{WORKSPACE_CONFIG_FILENAME}: software.picurv={requirement!r} is not a valid "
543 "version or version range."
544 ) from exc
545 if not matches:
546 raise ValueError(
547 f"Workspace requires PICurv {requirement!r}, but the active release is "
548 f"{PICURV_RELEASE_VERSION} (build {picurv_build()['build_id']}).\n"
549 f"Activate a matching installation with 'picurv versions activate <version>' "
550 "and retry.\n"
551 f"Note: {PACKAGE_PROJECT_ROOT} is a single shared installation. Activating "
552 "re-points every workspace and every unpinned job that resolves executables "
553 "from it, so two workspaces pinned to different releases cannot both be "
554 "satisfied at once. A run staged with --pin-executables keeps its own copy and "
555 "survives an activation."
556 )
557 return dict(picurv_build())
558
559
Here is the call graph for this function:
Here is the caller graph for this function:

◆ enforce_reproducibility_policy()

dict picurv_cli.core.enforce_reproducibility_policy ( str  workspace_root)

Enforce an optional workspace policy demanding a clean, released build.

Exploratory work should stay frictionless, so this is opt-in: a workspace running a campaign that will be published sets it once, and PICurv then refuses to stage from a modified or untagged tree instead of recording the compromise in a manifest nobody reads until later.

Parameters
[in]workspace_rootInitialized workspace directory, or None.
Returns
The resolved policy mapping, empty when none is configured.
Exceptions
ValueErrorwhen the active build does not satisfy the configured policy.

Definition at line 560 of file core.py.

560def enforce_reproducibility_policy(workspace_root: str) -> dict:
561 """!
562 @brief Enforce an optional workspace policy demanding a clean, released build.
563
564 @details Exploratory work should stay frictionless, so this is opt-in: a workspace
565 running a campaign that will be published sets it once, and PICurv then
566 refuses to stage from a modified or untagged tree instead of recording the
567 compromise in a manifest nobody reads until later.
568 @param[in] workspace_root Initialized workspace directory, or None.
569 @return The resolved policy mapping, empty when none is configured.
570 @throws ValueError when the active build does not satisfy the configured policy.
571 """
572 if not workspace_root:
573 return {}
574 payload = load_workspace_config(workspace_root)
575 policy = payload.get("reproducibility") or {}
576 if not isinstance(policy, dict) or not policy:
577 return {}
578 if policy.get("require_clean_release"):
579 problems = []
580 if picurv_build().get("dirty"):
581 problems.append("the source tree has uncommitted changes")
582 if picurv_build().get("dev_distance"):
583 problems.append(
584 f"HEAD is {picurv_build()['dev_distance']} commit(s) past the "
585 f"v{PICURV_RELEASE_VERSION} release tag"
586 )
587 elif picurv_build().get("dev_distance") is None:
588 problems.append("the release tag could not be resolved from this checkout")
589 if problems:
590 raise ValueError(
591 f"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.require_clean_release, "
592 f"but this build is {picurv_build()['build_id']}: " + "; ".join(problems) + ".\n"
593 "Commit and tag the release, or clear the policy for exploratory work."
594 )
595 if policy.get("pin_executables"):
596 identities = runtime_build_identities()
597 stale = [
598 name for name, identity in sorted(identities.items())
599 if identity.get("available") and not identity.get("matches_source")
600 ]
601 unavailable = [
602 name for name, identity in sorted(identities.items())
603 if not identity.get("available")
604 ]
605 if stale or unavailable:
606 detail = []
607 if stale:
608 detail.append("built from another revision: " + ", ".join(stale))
609 if unavailable:
610 detail.append("no build identity available: " + ", ".join(unavailable))
611 commands = sorted({rebuild_command(name, identities[name]) for name in stale + unavailable})
612 raise ValueError(
613 f"{WORKSPACE_CONFIG_FILENAME} sets reproducibility.pin_executables, but "
614 "the executables do not match the active source (" + "; ".join(detail) + ").\n"
615 "Rebuild with " + " and ".join(f"'{command}'" for command in commands)
616 + " so the run records the build that produced it."
617 )
618 return dict(policy)
619
620
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_workspace_path()

str picurv_cli.core.resolve_workspace_path ( str  anchor_file,
str  candidate,
*bool   allow_external = False 
)

Resolve a user path against its workspace and reject implicit escapes.

Parameters
[in]anchor_fileConfig file whose workspace owns the reference.
[in]candidateWorkspace-relative path text.
[in]allow_externalWhether an explicit import/reference operation permits an absolute path.
Returns
Absolute resolved path.

Definition at line 621 of file core.py.

621def resolve_workspace_path(anchor_file: str, candidate: str, *, allow_external: bool = False) -> str:
622 """!
623 @brief Resolve a user path against its workspace and reject implicit escapes.
624 @param[in] anchor_file Config file whose workspace owns the reference.
625 @param[in] candidate Workspace-relative path text.
626 @param[in] allow_external Whether an explicit import/reference operation permits an absolute path.
627 @return Absolute resolved path.
628 """
629 if not isinstance(candidate, str) or not candidate.strip():
630 raise ValueError("Referenced path must be a non-empty string.")
631 text = os.path.expanduser(candidate.strip())
632 workspace_root = find_workspace_root(anchor_file)
633 if not workspace_root:
634 return os.path.abspath(text if os.path.isabs(text) else os.path.join(os.path.dirname(os.path.abspath(anchor_file)), text))
635 if os.path.isabs(text):
636 if allow_external:
637 return os.path.abspath(text)
638 raise ValueError(
639 f"{anchor_file}: absolute path {candidate!r} is not allowed in workspace configuration; "
640 "import it with 'picurv inputs import' or use an explicit reference-mode import."
641 )
642 resolved = os.path.abspath(os.path.join(workspace_root, text))
643 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
644 raise ValueError(
645 f"{anchor_file}: path {candidate!r} escapes the workspace; parent traversal is not allowed."
646 )
647 if resolved.endswith(".reference.yml") and os.path.isfile(resolved):
648 pointer = read_yaml_file(resolved)
649 external = pointer.get("picurv_external_reference") if isinstance(pointer, dict) else None
650 if not isinstance(external, str) or not os.path.isabs(external):
651 raise ValueError(f"Invalid external-reference descriptor: {resolved}")
652 if not os.path.isfile(external):
653 raise ValueError(f"Registered external input is unavailable: {external}")
654 return external
655 return resolved
656
657
Here is the call graph for this function:
Here is the caller graph for this function:

◆ ensure_workspace_layout()

None picurv_cli.core.ensure_workspace_layout ( str  workspace_root)

Materialize the uniform, cheap directory skeleton for one workspace.

Parameters
[in]workspace_rootWorkspace root to initialize.

Definition at line 658 of file core.py.

658def ensure_workspace_layout(workspace_root: str) -> None:
659 """!
660 @brief Materialize the uniform, cheap directory skeleton for one workspace.
661 @param[in] workspace_root Workspace root to initialize.
662 """
663 for relative in WORKSPACE_DIRECTORY_LAYOUT:
664 os.makedirs(os.path.join(workspace_root, *relative.split("/")), exist_ok=True)
665
666
Here is the caller graph for this function:

◆ initialize_workspace_root()

str picurv_cli.core.initialize_workspace_root ( str  workspace_root,
str  template_name 
)

Create the workspace skeleton and its identity file at one root.

Parameters
[in]workspace_rootWorkspace root to initialize.
[in]template_nameExample template the workspace was created from.
Returns
Path to the written workspace configuration file.

Definition at line 667 of file core.py.

667def initialize_workspace_root(workspace_root: str, template_name: str) -> str:
668 """!
669 @brief Create the workspace skeleton and its identity file at one root.
670 @param[in] workspace_root Workspace root to initialize.
671 @param[in] template_name Example template the workspace was created from.
672 @return Path to the written workspace configuration file.
673 """
674 workspace_root = os.path.abspath(workspace_root)
675 ensure_workspace_layout(workspace_root)
676 config_path = os.path.join(workspace_root, WORKSPACE_CONFIG_FILENAME)
677 write_yaml_file(config_path, {
678 "schema_version": WORKSPACE_SCHEMA_VERSION,
679 "workspace": {
680 "id": os.path.basename(workspace_root),
681 "template": template_name,
682 "created_at": datetime.now().astimezone().isoformat(),
683 },
684 "software": {},
685 "paths": {
686 "config": "config",
687 "inputs": "inputs",
688 "assets": "assets",
689 "runs": "runs",
690 "studies": "studies",
691 },
692 })
693 return config_path
694
695
Here is the call graph for this function:
Here is the caller graph for this function:

◆ ensure_run_layout()

None picurv_cli.core.ensure_run_layout ( str  run_dir)

Materialize the uniform, cheap directory skeleton for one run.

Parameters
[in]run_dirRun root to initialize.

Definition at line 696 of file core.py.

696def ensure_run_layout(run_dir: str) -> None:
697 """!
698 @brief Materialize the uniform, cheap directory skeleton for one run.
699 @param[in] run_dir Run root to initialize.
700 """
701 for relative in RUN_DIRECTORY_LAYOUT:
702 os.makedirs(os.path.join(run_dir, *relative.split("/")), exist_ok=True)
703
704
705#: Top-level directory names a run owns. Anything else beside them is a peer PICurv
706#: does not route, cannot classify for storage, and will not prune.
Here is the caller graph for this function:

◆ validate_run_directory_structure()

tuple picurv_cli.core.validate_run_directory_structure ( str  run_dir)

Refuse a run whose root has grown a directory the layout does not define.

The run topology is a contract: manifest.json publishes it, the solver's reserved-directory guard defends it, and storage classifies against it. A directory beside output/ breaks all three at once - it is routed by nothing, classified as unclassified, and therefore archived forever and pruned never. Catching it when the run is staged or resumed is the only point where the answer is still "move it", rather than "it is already in every archive of this run".

Unexpected files are reported and allowed. A stray note at a run root costs nothing and refusing to resume a long campaign over one would be a worse failure than the one being prevented.

Parameters
[in]run_dirRun root to check.
Returns
Tuple of (errors, warnings) as human-readable message lists.

Definition at line 716 of file core.py.

716def validate_run_directory_structure(run_dir: str) -> tuple:
717 """!
718 @brief Refuse a run whose root has grown a directory the layout does not define.
719
720 @details The run topology is a contract: `manifest.json` publishes it, the solver's
721 reserved-directory guard defends it, and storage classifies against it. A
722 directory beside `output/` breaks all three at once - it is routed by
723 nothing, classified as `unclassified`, and therefore archived forever and
724 pruned never. Catching it when the run is staged or resumed is the only
725 point where the answer is still "move it", rather than "it is already in
726 every archive of this run".
727
728 Unexpected *files* are reported and allowed. A stray note at a run root
729 costs nothing and refusing to resume a long campaign over one would be a
730 worse failure than the one being prevented.
731 @param[in] run_dir Run root to check.
732 @return Tuple of (errors, warnings) as human-readable message lists.
733 """
734 root = os.path.abspath(run_dir)
735 if not os.path.isdir(root):
736 return [], []
737 allowed_files = set(RUN_ROOT_ALLOWED_FILES) | {
738 STORAGE_STATE_FILENAME, STORAGE_LOCK_FILENAME,
739 }
740 errors, warnings = [], []
741 for name in sorted(os.listdir(root)):
742 path = os.path.join(root, name)
743 if os.path.isdir(path) and not os.path.islink(path):
744 if name not in RUN_DIRECTORY_ROOTS:
745 errors.append(
746 f"{run_dir}: '{name}/' is not part of the run layout. Scientific "
747 f"output belongs under {CANONICAL_RUN_PATHS['checkpoints']}, "
748 f"{CANONICAL_RUN_PATHS['analysis']}, or "
749 f"{CANONICAL_RUN_PATHS['visualization']}; move or remove '{name}/' "
750 f"before running. Run-owned roots are: "
751 + ", ".join(sorted(RUN_DIRECTORY_ROOTS)) + "."
752 )
753 elif name not in allowed_files:
754 warnings.append(
755 f"{run_dir}: unexpected file '{name}' at the run root. It is archived "
756 "as unclassified and never pruned."
757 )
758 return errors, warnings
759
760
Here is the caller graph for this function:

◆ enforce_run_directory_structure()

None picurv_cli.core.enforce_run_directory_structure ( str  run_dir)

Apply validate_run_directory_structure() as a refusal at run time.

Parameters
[in]run_dirRun root to check.
Returns
None. Exits non-zero when the run root carries a directory outside the layout.

Definition at line 761 of file core.py.

761def enforce_run_directory_structure(run_dir: str) -> None:
762 """!
763 @brief Apply `validate_run_directory_structure()` as a refusal at run time.
764 @param[in] run_dir Run root to check.
765 @return None. Exits non-zero when the run root carries a directory outside the layout.
766 """
767 errors, warnings = validate_run_directory_structure(run_dir)
768 for message in warnings:
769 print(f"[WARNING] {message}", file=sys.stderr)
770 if not errors:
771 return
772 for message in errors:
773 emit_structured_error(
774 ERROR_CODE_CFG_INCONSISTENT_COMBO,
775 key="run_layout",
776 file_path=os.path.abspath(run_dir),
777 message=message,
778 hint="Move the directory under output/ or out of the run, then retry.",
779 )
780 sys.exit(1)
781
782
Here is the call graph for this function:
Here is the caller graph for this function:

◆ case_run_label()

str picurv_cli.core.case_run_label ( dict  case_cfg,
str  case_path 
)

Resolve the stable human-facing portion of a generated run identifier.

Parameters
[in]case_cfgParsed case configuration.
[in]case_pathSource case YAML path.
Returns
Filesystem-safe case title, name, or source stem.

Definition at line 783 of file core.py.

783def case_run_label(case_cfg: dict, case_path: str) -> str:
784 """!
785 @brief Resolve the stable human-facing portion of a generated run identifier.
786 @param[in] case_cfg Parsed case configuration.
787 @param[in] case_path Source case YAML path.
788 @return Filesystem-safe case title, name, or source stem.
789 """
790 metadata = case_cfg.get("metadata") or {}
791 candidate = (
792 case_cfg.get("title")
793 or (metadata.get("title") if isinstance(metadata, dict) else None)
794 or (metadata.get("name") if isinstance(metadata, dict) else None)
795 or Path(case_path).stem
796 )
797 label = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(candidate).strip()).strip("-.")
798 return label or "case"
799
800
Here is the caller graph for this function:

◆ allocate_generated_run_id()

str picurv_cli.core.allocate_generated_run_id ( str  runs_root,
dict  case_cfg,
str  case_path 
)

Build the generated run identity, disambiguating a same-second collision.

The identity is <run label>_<timestamp> at one-second resolution, so two runs of the same case launched back to back - a script, or a smoke sequence - would otherwise name the same directory. Creating a run never writes into an existing one, so the identity is advanced instead of the launch being refused for a clock artifact.

Parameters
[in]runs_rootDirectory generated runs are created beneath.
[in]case_cfgParsed case configuration supplying the run label.
[in]case_pathCase path, for the label's fallback.
Returns
Run identity that does not currently exist under runs_root.

Definition at line 801 of file core.py.

801def allocate_generated_run_id(runs_root: str, case_cfg: dict, case_path: str) -> str:
802 """!
803 @brief Build the generated run identity, disambiguating a same-second collision.
804
805 @details The identity is `<run label>_<timestamp>` at one-second resolution, so two
806 runs of the same case launched back to back - a script, or a smoke
807 sequence - would otherwise name the same directory. Creating a run never
808 writes into an existing one, so the identity is advanced instead of the
809 launch being refused for a clock artifact.
810 @param[in] runs_root Directory generated runs are created beneath.
811 @param[in] case_cfg Parsed case configuration supplying the run label.
812 @param[in] case_path Case path, for the label's fallback.
813 @return Run identity that does not currently exist under `runs_root`.
814 """
815 base = f"{case_run_label(case_cfg, case_path)}_{datetime.now().strftime('%Y%m%d-%H%M%S')}"
816 run_id, suffix = base, 1
817 while os.path.exists(os.path.join(runs_root, run_id)):
818 suffix += 1
819 run_id = f"{base}-{suffix}"
820 return run_id
821
822
Here is the caller graph for this function:

◆ discard_unused_run_directory()

bool picurv_cli.core.discard_unused_run_directory ( str  run_dir,
*bool  created,
bool   staged = False 
)

Remove a generated run directory that never received any content.

Only a directory this invocation created, and which still holds no files, is removed: an existing run, or one that already produced output, is never touched by a staging failure. With staged the directory may hold the files this invocation staged, for a refusal after staging but before any launch.

Parameters
[in]run_dirRun directory that was about to be staged.
[in]createdWhether this invocation created the directory.
[in]stagedWhether files this invocation staged, and nothing else, may be present.
Returns
True when the directory was removed.

Definition at line 823 of file core.py.

823def discard_unused_run_directory(run_dir: str, *, created: bool, staged: bool = False) -> bool:
824 """!
825 @brief Remove a generated run directory that never received any content.
826
827 @details Only a directory this invocation created, and which still holds no files,
828 is removed: an existing run, or one that already produced output, is never
829 touched by a staging failure. With @p staged the directory may hold the files
830 this invocation staged, for a refusal after staging but before any launch.
831 @param[in] run_dir Run directory that was about to be staged.
832 @param[in] created Whether this invocation created the directory.
833 @param[in] staged Whether files this invocation staged, and nothing else, may be present.
834 @return True when the directory was removed.
835 """
836 if not created or not os.path.isdir(run_dir):
837 return False
838 for _root, _dirs, files in os.walk(run_dir):
839 if files and not staged:
840 return False
841 shutil.rmtree(run_dir, ignore_errors=True)
842 return True
843
844
Here is the caller graph for this function:

◆ _file_sha256()

picurv_cli.core._file_sha256 ( str  path)
protected

Content digest of one file, or None when it cannot be read.

Parameters
[in]pathFile to digest.
Returns
Hex digest, or None.

Definition at line 845 of file core.py.

845def _file_sha256(path: str):
846 """!
847 @brief Content digest of one file, or None when it cannot be read.
848 @param[in] path File to digest.
849 @return Hex digest, or None.
850 """
851 digest = hashlib.sha256()
852 try:
853 with open(path, "rb") as stream:
854 for block in iter(lambda: stream.read(1024 * 1024), b""):
855 digest.update(block)
856 except OSError:
857 return None
858 return digest.hexdigest()
859
860
Here is the caller graph for this function:

◆ build_software_lock()

dict picurv_cli.core.build_software_lock ( "str | None"   run_dir = None)

Capture the exact software identity a run is about to execute with.

The release and commit say which source is checked out; they do not say which bytes ran. Hashing the executables and the generators pins that, so a queued job rebuilt out from under it is detectable afterwards rather than merely suspected.

Parameters
[in]run_dirRun whose pinned executables are hashed; the installation's when None.
Returns
Software lock mapping.

Definition at line 861 of file core.py.

861def build_software_lock(run_dir: "str | None" = None) -> dict:
862 """!
863 @brief Capture the exact software identity a run is about to execute with.
864
865 @details The release and commit say which source is checked out; they do not say
866 which bytes ran. Hashing the executables and the generators pins that, so
867 a queued job rebuilt out from under it is detectable afterwards rather
868 than merely suspected.
869 @param[in] run_dir Run whose pinned executables are hashed; the installation's when None.
870 @return Software lock mapping.
871 """
872 lock = {
873 "schema_version": 1,
874 "picurv_version": PICURV_RELEASE_VERSION,
875 "build_id": picurv_build().get("build_id"),
876 "git_commit": picurv_build().get("git_commit"),
877 "git_dirty": picurv_build().get("dirty"),
878 "captured_at": datetime.now().astimezone().isoformat(),
879 "executables": {},
880 "generators": {},
881 "environment": _toolchain_identity(),
882 }
883 for name in RUN_EXECUTABLE_NAMES:
884 path = run_executable_path(run_dir, name)
885 identity = read_binary_build_identity(path)
886 lock["executables"][name] = {
887 "path": path,
888 "sha256": _file_sha256(path),
889 "build_id": identity.get("build_id"),
890 "matches_source": identity.get("matches_source"),
891 "petsc": identity.get("petsc"),
892 }
893 conductor = os.path.join(PACKAGE_PROJECT_ROOT, "picurv_cli", "core.py")
894 lock["python_conductor_sha256"] = _file_sha256(conductor)
895 if os.path.isdir(GENERATORS_PATH):
896 for entry in sorted(os.listdir(GENERATORS_PATH)):
897 candidate = os.path.join(GENERATORS_PATH, entry)
898 if os.path.isfile(candidate):
899 lock["generators"][entry] = _file_sha256(candidate)
900 return lock
901
902
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _toolchain_identity()

dict picurv_cli.core._toolchain_identity ( )
protected

Best-effort record of the PETSc, MPI, and compiler in the shell staging the run.

This is the staging environment, which need not be what the binaries were built against; each executable's own PETSc is recorded from its build stamp.

Returns
Mapping of what could be determined; absent keys mean it could not be read.

Definition at line 903 of file core.py.

903def _toolchain_identity() -> dict:
904 """!
905 @brief Best-effort record of the PETSc, MPI, and compiler in the shell staging the run.
906 @details This is the staging environment, which need not be what the binaries were
907 built against; each executable's own PETSc is recorded from its build stamp.
908 @return Mapping of what could be determined; absent keys mean it could not be read.
909 """
910 identity = {}
911 petsc_dir = os.environ.get("PETSC_DIR")
912 if petsc_dir:
913 identity["petsc_dir"] = petsc_dir
914 version_header = os.path.join(petsc_dir, "include", "petscversion.h")
915 try:
916 with open(version_header, "r", encoding="utf-8", errors="replace") as stream:
917 numbers = {}
918 for line in stream:
919 match = re.match(
920 r"#define\s+PETSC_VERSION_(MAJOR|MINOR|SUBMINOR)\s+(\d+)", line
921 )
922 if match:
923 numbers[match.group(1)] = match.group(2)
924 if len(numbers) == 3:
925 identity["petsc_version"] = (
926 f"{numbers['MAJOR']}.{numbers['MINOR']}.{numbers['SUBMINOR']}"
927 )
928 except OSError:
929 pass
930 if os.environ.get("PETSC_ARCH"):
931 identity["petsc_arch"] = os.environ["PETSC_ARCH"]
932 for tool, key in (("mpiexec", "mpi"), ("mpicc", "compiler")):
933 executable = shutil.which(tool)
934 if not executable:
935 continue
936 try:
937 result = subprocess.run(
938 [executable, "--version"], text=True, capture_output=True, timeout=20, check=False
939 )
940 except (OSError, subprocess.SubprocessError):
941 continue
942 first = (result.stdout or result.stderr or "").strip().splitlines()
943 if first:
944 identity[key] = first[0][:200]
945 return identity
946
947
Here is the caller graph for this function:

◆ write_software_lock()

str picurv_cli.core.write_software_lock ( str  run_dir)

Write the run's software lock beside its asset lock.

Parameters
[in]run_dirRun directory receiving the lock.
Returns
Path to the written lock.

Definition at line 948 of file core.py.

948def write_software_lock(run_dir: str) -> str:
949 """!
950 @brief Write the run's software lock beside its asset lock.
951 @param[in] run_dir Run directory receiving the lock.
952 @return Path to the written lock.
953 """
954 path = os.path.join(run_dir, CANONICAL_RUN_PATHS["inputs"], "software.lock.json")
955 os.makedirs(os.path.dirname(path), exist_ok=True)
956 write_json_file(path, build_software_lock(run_dir))
957 return path
958
959
Here is the call graph for this function:
Here is the caller graph for this function:

◆ workspace_artifact_root()

str picurv_cli.core.workspace_artifact_root ( str  workspace_root,
str  kind 
)

Return the canonical workspace-owned root for runs or studies.

Parameters
[in]workspace_rootInitialized workspace, or None for standalone mode.
[in]kindEither runs or studies.
Returns
Absolute artifact root.

Definition at line 960 of file core.py.

960def workspace_artifact_root(workspace_root: str, kind: str) -> str:
961 """!
962 @brief Return the canonical workspace-owned root for runs or studies.
963 @param[in] workspace_root Initialized workspace, or None for standalone mode.
964 @param[in] kind Either runs or studies.
965 @return Absolute artifact root.
966 """
967 if kind not in _WORKSPACE_ARTIFACT_ROOT_VALUES:
968 raise ValueError(f"Unsupported workspace artifact kind: {kind}")
969 if workspace_root:
970 return os.path.join(os.path.abspath(workspace_root), kind)
971 return os.path.abspath(kind)
972
973
Here is the caller graph for this function:

◆ _relative_to_workspace()

picurv_cli.core._relative_to_workspace ( str  path,
str  workspace_root 
)
protected

Return a portable workspace-relative path when possible.

Parameters
[in]pathPath to normalize.
[in]workspace_rootOptional initialized workspace root.
Returns
Workspace-relative POSIX path, absolute path, or None.

Definition at line 974 of file core.py.

974def _relative_to_workspace(path: str, workspace_root: str):
975 """!
976 @brief Return a portable workspace-relative path when possible.
977 @param[in] path Path to normalize.
978 @param[in] workspace_root Optional initialized workspace root.
979 @return Workspace-relative POSIX path, absolute path, or None.
980 """
981 if not path:
982 return None
983 absolute = os.path.abspath(path)
984 if workspace_root and os.path.commonpath([absolute, workspace_root]) == os.path.abspath(workspace_root):
985 return os.path.relpath(absolute, workspace_root).replace(os.sep, "/")
986 return absolute
987
988
Here is the caller graph for this function:

◆ snapshot_run_configuration()

dict picurv_cli.core.snapshot_run_configuration ( str  run_dir,
dict  source_paths,
bool   continuation = False 
)

Snapshot editable YAML inputs without erasing prior continuation state.

Parameters
[in]run_dirOwning run directory.
[in]source_pathsRole-to-source-path mapping.
[in]continuationWhether this is a continuation configuration revision.
Returns
Active snapshot metadata.

Definition at line 989 of file core.py.

990 continuation: bool = False) -> dict:
991 """!
992 @brief Snapshot editable YAML inputs without erasing prior continuation state.
993 @param[in] run_dir Owning run directory.
994 @param[in] source_paths Role-to-source-path mapping.
995 @param[in] continuation Whether this is a continuation configuration revision.
996 @return Active snapshot metadata.
997 """
998 config_root = os.path.join(run_dir, "config")
999 os.makedirs(config_root, exist_ok=True)
1000 previous = _read_json_if_exists(os.path.join(config_root, "active.json")) or {}
1001 revision = None
1002 destination_root = config_root
1003 if continuation:
1004 revision = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
1005 destination_root = os.path.join(config_root, "history", revision)
1006 os.makedirs(destination_root, exist_ok=False)
1007 snapshots = {}
1008 for role, source in source_paths.items():
1009 if not source:
1010 continue
1011 source = os.path.abspath(source)
1012 suffix = os.path.splitext(source)[1] or ".yml"
1013 name = f"{role}{suffix}" if role != "cluster" else "cluster.yml"
1014 destination = os.path.join(destination_root, name)
1015 shutil.copy2(source, destination)
1016 snapshots[role] = os.path.relpath(destination, run_dir).replace(os.sep, "/")
1017 active = {
1018 "schema_version": 1,
1019 "revision": revision or "initial",
1020 "updated_at": datetime.now().astimezone().isoformat(),
1021 "files": snapshots,
1022 }
1023 if continuation and isinstance(previous.get("executables"), dict):
1024 # A continuation changes what is configured, not which build runs it. The pin
1025 # moves only when the caller re-pins, which records a new executables entry.
1026 active["executables"] = previous["executables"]
1027 write_json_file(os.path.join(config_root, "active.json"), active)
1028 return active
1029
1030
1031#: Run records a continuation's staging rewrites in place, relative to the run directory.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ capture_continuation_records()

dict picurv_cli.core.capture_continuation_records ( str  run_dir,
str  restart_dir 
)

Save what a continuation's staging changes, so a refusal can undo it.

Staging rewrites the records in CONTINUATION_STAGED_RECORDS and materializes the restart checkpoint into the run's restart input, so both are captured before restart resolution runs.

Parameters
[in]run_dirExisting run directory.
[in]restart_dirThe run's restart input directory.
Returns
Saved state for restore_continuation_records().

Definition at line 1036 of file core.py.

1036def capture_continuation_records(run_dir: str, restart_dir: str) -> dict:
1037 """!
1038 @brief Save what a continuation's staging changes, so a refusal can undo it.
1039 @details Staging rewrites the records in CONTINUATION_STAGED_RECORDS and materializes
1040 the restart checkpoint into the run's restart input, so both are captured
1041 before restart resolution runs.
1042 @param[in] run_dir Existing run directory.
1043 @param[in] restart_dir The run's restart input directory.
1044 @return Saved state for restore_continuation_records().
1045 """
1046 records = {}
1047 for relative in CONTINUATION_STAGED_RECORDS:
1048 path = os.path.join(run_dir, *relative.split("/"))
1049 records[relative] = open(path, "rb").read() if os.path.isfile(path) else None
1050 checkpoints = os.path.join(restart_dir, "checkpoints")
1051 return {"records": records, "checkpoints": checkpoints,
1052 "checkpoint_entries": set(os.listdir(checkpoints)) if os.path.isdir(checkpoints) else None}
1053
1054
Here is the caller graph for this function:

◆ restore_continuation_records()

picurv_cli.core.restore_continuation_records ( str  run_dir,
dict  saved,
str  revision 
)

Undo a refused continuation's staging: restore its records, drop its revision.

Removes the restart checkpoints staging materialized; everything else a continuation stages, including pinned executables, lives in the revision directory, so removing it leaves the run as it was.

Parameters
[in]run_dirExisting run directory.
[in]savedState returned by capture_continuation_records().
[in]revisionConfiguration revision the refused staging created.

Definition at line 1055 of file core.py.

1055def restore_continuation_records(run_dir: str, saved: dict, revision: str):
1056 """!
1057 @brief Undo a refused continuation's staging: restore its records, drop its revision.
1058 @details Removes the restart checkpoints staging materialized; everything else a
1059 continuation stages, including pinned executables, lives in the revision
1060 directory, so removing it leaves the run as it was.
1061 @param[in] run_dir Existing run directory.
1062 @param[in] saved State returned by capture_continuation_records().
1063 @param[in] revision Configuration revision the refused staging created.
1064 """
1065 checkpoints, before = saved["checkpoints"], saved["checkpoint_entries"]
1066 if os.path.isdir(checkpoints):
1067 for entry in set(os.listdir(checkpoints)) - (before or set()):
1068 shutil.rmtree(os.path.join(checkpoints, entry), ignore_errors=True)
1069 if before is None and not os.listdir(checkpoints):
1070 os.rmdir(checkpoints)
1071 for relative, content in saved["records"].items():
1072 path = os.path.join(run_dir, *relative.split("/"))
1073 if content is None:
1074 if os.path.isfile(path):
1075 os.remove(path)
1076 else:
1077 with open(path, "wb") as stream:
1078 stream.write(content)
1079 shutil.rmtree(os.path.join(run_dir, "config", "history", revision), ignore_errors=True)
1080
1081
Here is the caller graph for this function:

◆ load_active_run_configuration()

dict picurv_cli.core.load_active_run_configuration ( str  run_dir)

Load the active immutable configuration revision for a run.

Parameters
[in]run_dirRun directory.
Returns
Role-to-absolute-path mapping.

Definition at line 1082 of file core.py.

1082def load_active_run_configuration(run_dir: str) -> dict:
1083 """!
1084 @brief Load the active immutable configuration revision for a run.
1085 @param[in] run_dir Run directory.
1086 @return Role-to-absolute-path mapping.
1087 """
1088 config_root = os.path.join(os.path.abspath(run_dir), "config")
1089 active = _read_json_if_exists(os.path.join(config_root, "active.json"))
1090 if isinstance(active, dict) and isinstance(active.get("files"), dict):
1091 return {
1092 role: os.path.join(run_dir, *relative.split("/"))
1093 for role, relative in active["files"].items()
1094 if isinstance(relative, str)
1095 }
1096 return {
1097 role: os.path.join(config_root, f"{role}.yml")
1098 for role in ("case", "solver", "monitor", "cluster")
1099 if os.path.isfile(os.path.join(config_root, f"{role}.yml"))
1100 }
1101
1102
1103#: Native executables a run launches, and therefore the ones a run pins.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ load_run_executables()

dict picurv_cli.core.load_run_executables ( str  run_dir)

Return the executables a run is pinned to, as recorded by its active configuration.

Parameters
[in]run_dirRun directory.
Returns
Mapping of executable name to its recorded entry, with path made absolute; empty when the run launches the installation's executables.

Definition at line 1107 of file core.py.

1107def load_run_executables(run_dir: str) -> dict:
1108 """!
1109 @brief Return the executables a run is pinned to, as recorded by its active configuration.
1110 @param[in] run_dir Run directory.
1111 @return Mapping of executable name to its recorded entry, with `path` made absolute;
1112 empty when the run launches the installation's executables.
1113 """
1114 active = _read_json_if_exists(os.path.join(os.path.abspath(run_dir), "config", "active.json"))
1115 recorded = active.get("executables") if isinstance(active, dict) else None
1116 if not isinstance(recorded, dict):
1117 return {}
1118 pinned = {}
1119 for name, entry in recorded.items():
1120 if isinstance(entry, dict) and isinstance(entry.get("path"), str):
1121 pinned[name] = dict(entry, path=os.path.join(os.path.abspath(run_dir), *entry["path"].split("/")))
1122 return pinned
1123
1124
Here is the call graph for this function:
Here is the caller graph for this function:

◆ run_executable_path()

str picurv_cli.core.run_executable_path ( "str | None"  run_dir,
str  name 
)

Resolve the executable a run launches: its pinned copy, else the installation's.

Parameters
[in]run_dirRun directory, or None when no run exists yet.
[in]namesimulator or postprocessor.
Returns
Absolute executable path.
Exceptions
ValueErrorwhen the run records a pinned copy that is no longer on disk.

Definition at line 1125 of file core.py.

1125def run_executable_path(run_dir: "str | None", name: str) -> str:
1126 """!
1127 @brief Resolve the executable a run launches: its pinned copy, else the installation's.
1128 @param[in] run_dir Run directory, or None when no run exists yet.
1129 @param[in] name `simulator` or `postprocessor`.
1130 @return Absolute executable path.
1131 @throws ValueError when the run records a pinned copy that is no longer on disk.
1132 """
1133 pinned = load_run_executables(run_dir).get(name) if run_dir else None
1134 if not pinned:
1135 return resolve_runtime_executable(name)
1136 if not os.path.isfile(pinned["path"]):
1137 raise ValueError(
1138 f"{run_dir} is pinned to {pinned['path']}, which no longer exists. Restore it "
1139 "from the run's archive, or re-pin with --pin-executables to run the current build."
1140 )
1141 return pinned["path"]
1142
1143
Here is the call graph for this function:
Here is the caller graph for this function:

◆ pin_run_executables()

dict picurv_cli.core.pin_run_executables ( str  run_dir,
str  revision 
)

Copy the installation's executables into a run's configuration revision.

The installation's bin/ is rebuilt in place, so a job that resolves its executable there when it starts runs whatever was built last, not what was staged. The copy lives under config/, which storage retains as essential and the solver refuses to write into, and active.json records it so every later stage of the run - continuation, post-processing - launches the same build. Each copy is written beside its destination and renamed into place.

Parameters
[in]run_dirRun directory.
[in]revisionActive configuration revision (initial or a continuation id).
Returns
Recorded executables mapping, keyed by executable name; an executable that has not been built is left unpinned with a warning, as staging treats it today.

Definition at line 1144 of file core.py.

1144def pin_run_executables(run_dir: str, revision: str) -> dict:
1145 """!
1146 @brief Copy the installation's executables into a run's configuration revision.
1147
1148 @details The installation's `bin/` is rebuilt in place, so a job that resolves its
1149 executable there when it starts runs whatever was built last, not what was
1150 staged. The copy lives under `config/`, which storage retains as essential
1151 and the solver refuses to write into, and `active.json` records it so every
1152 later stage of the run - continuation, post-processing - launches the same
1153 build. Each copy is written beside its destination and renamed into place.
1154 @param[in] run_dir Run directory.
1155 @param[in] revision Active configuration revision (`initial` or a continuation id).
1156 @return Recorded executables mapping, keyed by executable name; an executable that
1157 has not been built is left unpinned with a warning, as staging treats it today.
1158 """
1159 run_root = os.path.abspath(run_dir)
1160 config_root = os.path.join(run_root, "config")
1161 target = (os.path.join(config_root, "bin") if revision == "initial"
1162 else os.path.join(config_root, "history", revision, "bin"))
1163 recorded = {}
1164 for name in RUN_EXECUTABLE_NAMES:
1165 source = resolve_runtime_executable(name)
1166 if not os.path.isfile(source):
1167 print(f"[WARNING] {name} is not built at {source}, so it is not pinned; a stage that "
1168 "launches it will use whatever is there when it starts.", file=sys.stderr)
1169 continue
1170 os.makedirs(target, exist_ok=True)
1171 destination = os.path.join(target, name)
1172 partial = destination + ".partial"
1173 shutil.copy2(source, partial)
1174 os.replace(partial, destination)
1175 identity = read_binary_build_identity(destination)
1176 recorded[name] = {
1177 "path": os.path.relpath(destination, run_root).replace(os.sep, "/"),
1178 "pinned_from": source,
1179 "sha256": _file_sha256(destination),
1180 "build_id": identity.get("build_id"),
1181 "version_line": identity.get("version_line"),
1182 "identity_reason": None if identity.get("available") else identity.get("reason"),
1183 }
1184 if not recorded:
1185 return {}
1186 active_path = os.path.join(config_root, "active.json")
1187 active = _read_json_if_exists(active_path) or {"schema_version": 1, "revision": revision, "files": {}}
1188 active["executables"] = recorded
1189 active["updated_at"] = datetime.now().astimezone().isoformat()
1190 write_json_file(active_path, active)
1191 return recorded
1192
1193
Here is the call graph for this function:
Here is the caller graph for this function:

◆ plan_run_executable_paths()

dict picurv_cli.core.plan_run_executable_paths ( str  run_dir,
  args,
"str | None"  workspace_root 
)

Predict, without writing, which executables staging would give a run.

Mirrors apply_run_executable_pinning() for run --dry-run: an existing pin is kept unless re-pinned, a new pin lands in the configuration revision staging would create, and otherwise the installation's executables run.

Parameters
[in]run_dirRun directory the plan targets.
[in]argsParsed command arguments.
[in]workspace_rootOwning workspace, or None.
Returns
Mapping with pinning (kept, new, or none) and per-executable paths.

Definition at line 1194 of file core.py.

1194def plan_run_executable_paths(run_dir: str, args, workspace_root: "str | None") -> dict:
1195 """!
1196 @brief Predict, without writing, which executables staging would give a run.
1197 @details Mirrors `apply_run_executable_pinning()` for `run --dry-run`: an existing
1198 pin is kept unless re-pinned, a new pin lands in the configuration revision
1199 staging would create, and otherwise the installation's executables run.
1200 @param[in] run_dir Run directory the plan targets.
1201 @param[in] args Parsed command arguments.
1202 @param[in] workspace_root Owning workspace, or None.
1203 @return Mapping with `pinning` (`kept`, `new`, or `none`) and per-executable `paths`.
1204 """
1205 existing = load_run_executables(run_dir) if os.path.isdir(run_dir) else {}
1206 if existing and getattr(args, "pin_executables", None) is not True:
1207 return {"pinning": "kept", "paths": {name: entry["path"] for name, entry in existing.items()}}
1208 if resolve_executable_pinning(args, workspace_root):
1209 revision_root = (os.path.join(run_dir, "config") if not existing
1210 and not getattr(args, "continue_run", False)
1211 else os.path.join(run_dir, "config", "history", "<new-revision>"))
1212 return {"pinning": "new",
1213 "paths": {name: os.path.join(revision_root, "bin", name) for name in RUN_EXECUTABLE_NAMES}}
1214 return {"pinning": "none",
1215 "paths": {name: resolve_runtime_executable(name) for name in RUN_EXECUTABLE_NAMES}}
1216
1217
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_executable_pinning()

bool picurv_cli.core.resolve_executable_pinning (   args,
"str | None"  workspace_root 
)

Decide whether staging pins the installation's executables into the run.

Pinning is opt-in: an explicit --pin-executables/--no-pin-executables wins, then the workspace's reproducibility.pin_executables. Unpinned jobs are still protected by the job-start identity check, which stops a job whose executable was rebuilt after staging instead of running the new build.

Parameters
[in]argsParsed command arguments.
[in]workspace_rootOwning workspace, or None.
Returns
True to pin, False not to.

Definition at line 1218 of file core.py.

1218def resolve_executable_pinning(args, workspace_root: "str | None") -> bool:
1219 """!
1220 @brief Decide whether staging pins the installation's executables into the run.
1221 @details Pinning is opt-in: an explicit `--pin-executables`/`--no-pin-executables`
1222 wins, then the workspace's `reproducibility.pin_executables`. Unpinned jobs
1223 are still protected by the job-start identity check, which stops a job whose
1224 executable was rebuilt after staging instead of running the new build.
1225 @param[in] args Parsed command arguments.
1226 @param[in] workspace_root Owning workspace, or None.
1227 @return True to pin, False not to.
1228 """
1229 explicit = getattr(args, "pin_executables", None)
1230 if explicit is not None:
1231 return bool(explicit)
1232 if workspace_root:
1233 policy = load_workspace_config(workspace_root).get("reproducibility") or {}
1234 if isinstance(policy, dict):
1235 return bool(policy.get("pin_executables"))
1236 return False
1237
1238
Here is the call graph for this function:
Here is the caller graph for this function:

◆ apply_run_executable_pinning()

dict picurv_cli.core.apply_run_executable_pinning ( str  run_dir,
str  revision,
  args,
"str | None"  workspace_root 
)

Pin, keep, or refuse the run's executables before anything records them.

A run already pinned keeps its build unless the caller explicitly re-pins, so a continuation or post-processing pass cannot silently switch builds. Refusing --no-pin-executables on a pinned run keeps the run's records describing one build rather than a pin nobody launched.

Parameters
[in]run_dirRun directory.
[in]revisionActive configuration revision.
[in]argsParsed command arguments.
[in]workspace_rootOwning workspace, or None.
Returns
The run's pinned executables; empty when it launches the installation's.
Exceptions
ValueErrorwhen --no-pin-executables is given for a pinned run.

Definition at line 1239 of file core.py.

1240 workspace_root: "str | None") -> dict:
1241 """!
1242 @brief Pin, keep, or refuse the run's executables before anything records them.
1243 @details A run already pinned keeps its build unless the caller explicitly re-pins,
1244 so a continuation or post-processing pass cannot silently switch builds.
1245 Refusing `--no-pin-executables` on a pinned run keeps the run's records
1246 describing one build rather than a pin nobody launched.
1247 @param[in] run_dir Run directory.
1248 @param[in] revision Active configuration revision.
1249 @param[in] args Parsed command arguments.
1250 @param[in] workspace_root Owning workspace, or None.
1251 @return The run's pinned executables; empty when it launches the installation's.
1252 @throws ValueError when `--no-pin-executables` is given for a pinned run.
1253 """
1254 explicit = getattr(args, "pin_executables", None)
1255 existing = load_run_executables(run_dir)
1256 if existing and explicit is not True:
1257 if explicit is False:
1258 build = existing.get("simulator", {}).get("build_id") or "a pinned build"
1259 raise ValueError(
1260 f"{run_dir} is pinned to {build}. --no-pin-executables would launch the "
1261 "installation's build instead while the run still records the pin; re-pin "
1262 "with --pin-executables to move this run to the current build."
1263 )
1264 build = existing.get("simulator", {}).get("build_id") or "unknown build"
1265 print(f"[INFO] Launching the run's pinned executables ({build}).")
1266 return existing
1267 if not resolve_executable_pinning(args, workspace_root):
1268 return {}
1269 recorded = pin_run_executables(run_dir, revision)
1270 if not recorded:
1271 return {}
1272 first = next(iter(recorded.values()))
1273 build = first.get("build_id") or "build identity unavailable"
1274 print(f"[INFO] Pinned {', '.join(sorted(recorded))} into "
1275 f"{os.path.dirname(first['path'])}/ ({build}).")
1276 return load_run_executables(run_dir)
1277
1278
Here is the call graph for this function:
Here is the caller graph for this function:

◆ archive_active_generated_configuration()

dict picurv_cli.core.archive_active_generated_configuration ( str  run_dir,
list  paths 
)

Preserve generated control sidecars beside a continuation's YAML revision.

Parameters
[in]run_dirOwning run.
[in]pathsGenerated control and sidecar paths.
Returns
Updated active configuration record.

Definition at line 1279 of file core.py.

1279def archive_active_generated_configuration(run_dir: str, paths: list) -> dict:
1280 """!
1281 @brief Preserve generated control sidecars beside a continuation's YAML revision.
1282 @param[in] run_dir Owning run.
1283 @param[in] paths Generated control and sidecar paths.
1284 @return Updated active configuration record.
1285 """
1286 config_root = os.path.join(os.path.abspath(run_dir), "config")
1287 active_path = os.path.join(config_root, "active.json")
1288 active = _read_json_if_exists(active_path) or {"schema_version": 1, "revision": "initial", "files": {}}
1289 revision = active.get("revision", "initial")
1290 target_root = config_root if revision == "initial" else os.path.join(config_root, "history", revision)
1291 os.makedirs(target_root, exist_ok=True)
1292 files = active.setdefault("files", {})
1293 for source in paths:
1294 if not source or not os.path.isfile(source):
1295 continue
1296 source = os.path.abspath(source)
1297 destination = os.path.join(target_root, os.path.basename(source))
1298 if source != destination:
1299 shutil.copy2(source, destination)
1300 relative = os.path.relpath(destination, run_dir).replace(os.sep, "/")
1301 if destination.endswith(".control"):
1302 files["control"] = relative
1303 else:
1304 files.setdefault("generated", []).append(relative)
1305 active["updated_at"] = datetime.now().astimezone().isoformat()
1306 write_json_file(active_path, active)
1307 return active
1308
1309
1310#: Storage states in which a run's heavy payload is no longer wholly local. A component
1311#: absent under one of these was archived, not skipped.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _run_component_states()

dict picurv_cli.core._run_component_states ( str  run_dir,
dict  stages_requested 
)
protected

Report each run component's home, retention class, and lifecycle state.

The skeleton is created whole, so an empty directory says nothing about whether its contents were requested. This is where that question is answered, and it is answered for every component whether or not it ran.

Parameters
[in]run_dirRun directory being described.
[in]stages_requestedMapping of requested stage names to booleans.
Returns
Component name to {path, retention, state} mapping.

Definition at line 1333 of file core.py.

1333def _run_component_states(run_dir: str, stages_requested: dict) -> dict:
1334 """!
1335 @brief Report each run component's home, retention class, and lifecycle state.
1336
1337 @details The skeleton is created whole, so an empty directory says nothing about
1338 whether its contents were requested. This is where that question is
1339 answered, and it is answered for every component whether or not it ran.
1340 @param[in] run_dir Run directory being described.
1341 @param[in] stages_requested Mapping of requested stage names to booleans.
1342 @return Component name to `{path, retention, state}` mapping.
1343 """
1344 marker = storage_state_summary(run_dir) if os.path.isdir(run_dir) else {"state": "LOCAL"}
1345 offloaded = marker.get("state") in STORAGE_OFFLOADED_STATES
1346 solve_requested = bool(stages_requested.get("solve"))
1347 post_requested = bool(stages_requested.get("post_process"))
1348 requested = {
1349 "configuration": True,
1350 "inputs": True,
1351 "logs": True,
1352 "scheduler": True,
1353 "checkpoints": solve_requested,
1354 "analysis": post_requested,
1355 "field_statistics": post_requested,
1356 "spectra": post_requested,
1357 "visualization": post_requested,
1358 }
1359 components = {}
1360 for name, relative, retention in RUN_COMPONENT_LAYOUT:
1361 path = os.path.join(run_dir, *relative.split("/"))
1362 populated = False
1363 if os.path.isdir(path):
1364 populated = any(files for _root, _dirs, files in os.walk(path))
1365 if populated:
1366 state = "offloaded" if offloaded and retention != "essential" else "complete"
1367 elif not requested.get(name, False):
1368 state = "not_requested"
1369 elif offloaded:
1370 state = "offloaded"
1371 else:
1372 state = "planned"
1373 components[name] = {"path": relative, "retention": retention, "state": state}
1374 return components
1375
1376
Here is the caller graph for this function:

◆ build_run_lineage()

dict picurv_cli.core.build_run_lineage ( str  parent_run_dir,
int  checkpoint_step,
*  workspace_root = None,
  statistics_state = None,
  requested_source = None 
)

Record which run and which checkpoint a branched run was started from.

Without this a branch is indistinguishable from a fresh run: the copied bundle under inputs/restart/ carries the parent's geometry and software identity but never says which run produced it, so the trajectory a result belongs to cannot be reconstructed after the fact.

The parent's own manifest is asked for its identity rather than its directory name, so a parent that was renamed or restored under another name is still named correctly here.

Parameters
[in]parent_run_dirAbsolute path to the run being branched from.
[in]checkpoint_stepCheckpoint step the branch starts from.
[in]workspace_rootOptional workspace root, to record a portable path.
[in]statistics_stateResolved field-statistics decision for the branch.
[in]requested_sourceWhat the user asked for, e.g. "latest" or a path.
Returns
JSON-serializable lineage record.

Definition at line 1377 of file core.py.

1378 statistics_state=None, requested_source=None) -> dict:
1379 """!
1380 @brief Record which run and which checkpoint a branched run was started from.
1381
1382 @details Without this a branch is indistinguishable from a fresh run: the copied
1383 bundle under `inputs/restart/` carries the parent's geometry and software
1384 identity but never says which run produced it, so the trajectory a result
1385 belongs to cannot be reconstructed after the fact.
1386
1387 The parent's own manifest is asked for its identity rather than its
1388 directory name, so a parent that was renamed or restored under another
1389 name is still named correctly here.
1390 @param[in] parent_run_dir Absolute path to the run being branched from.
1391 @param[in] checkpoint_step Checkpoint step the branch starts from.
1392 @param[in] workspace_root Optional workspace root, to record a portable path.
1393 @param[in] statistics_state Resolved field-statistics decision for the branch.
1394 @param[in] requested_source What the user asked for, e.g. "latest" or a path.
1395 @return JSON-serializable lineage record.
1396 """
1397 parent_identity = read_artifact_identity(parent_run_dir)
1398 return {
1399 "relationship": "branch",
1400 "parent_run_id": parent_identity["run_id"],
1401 "parent_study_id": parent_identity["study_id"] if parent_identity["case_id"] else None,
1402 "parent_case_id": parent_identity["case_id"],
1403 "parent_identity_source": parent_identity["identity_source"],
1404 "parent_path": _relative_to_workspace(parent_run_dir, workspace_root),
1405 "checkpoint_step": int(checkpoint_step),
1406 "statistics_state": statistics_state,
1407 "requested_source": requested_source,
1408 "recorded_at": datetime.now().astimezone().isoformat(),
1409 }
1410
1411
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_run_manifest()

dict picurv_cli.core.build_run_manifest ( str  run_dir,
str  run_id,
*  workspace_root = None,
str   launch_mode = "local",
int   num_procs = 1,
int   post_num_procs = 1,
  stages_requested = None,
  stages_completed = None,
  inputs = None,
  asset_lock = None,
  submission = None,
  lineage = None,
str   artifact_type = "run",
  study_id = None,
  case_id = None 
)

Build the authoritative run identity, topology, and lifecycle manifest.

Parameters
[in]run_dirOwning run directory.
[in]run_idStable run identity.
[in]workspace_rootOptional initialized workspace root.
[in]launch_modeLocal or scheduler-backed launch mode.
[in]num_procsEffective solver process count.
[in]post_num_procsEffective post-process count.
[in]stages_requestedRequested stage mapping.
[in]stages_completedCompleted or submitted stage names.
[in]inputsActive configuration identities.
[in]asset_lockResolved run asset lock.
[in]submissionScheduler submission metadata.
[in]lineageWhere this run's initial state came from; see build_run_lineage().
[in]artifact_type"run" for a standalone run, "study-case" for a study member.
[in]study_idOwning study identity, for a study member.
[in]case_idMember identity within its study.
Returns
JSON-serializable run manifest.

Definition at line 1412 of file core.py.

1417 study_id=None, case_id=None) -> dict:
1418 """!
1419 @brief Build the authoritative run identity, topology, and lifecycle manifest.
1420 @param[in] run_dir Owning run directory.
1421 @param[in] run_id Stable run identity.
1422 @param[in] workspace_root Optional initialized workspace root.
1423 @param[in] launch_mode Local or scheduler-backed launch mode.
1424 @param[in] num_procs Effective solver process count.
1425 @param[in] post_num_procs Effective post-process count.
1426 @param[in] stages_requested Requested stage mapping.
1427 @param[in] stages_completed Completed or submitted stage names.
1428 @param[in] inputs Active configuration identities.
1429 @param[in] asset_lock Resolved run asset lock.
1430 @param[in] submission Scheduler submission metadata.
1431 @param[in] lineage Where this run's initial state came from; see `build_run_lineage()`.
1432 @param[in] artifact_type "run" for a standalone run, "study-case" for a study member.
1433 @param[in] study_id Owning study identity, for a study member.
1434 @param[in] case_id Member identity within its study.
1435 @return JSON-serializable run manifest.
1436 """
1437 existing = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
1438 created_at = existing.get("created_at") or datetime.now().astimezone().isoformat()
1439 # The manifest describes the run, not the latest invocation: a post-only pass over a
1440 # solved run keeps the solve it records, and each stage keeps the rank count it ran on.
1441 requested_now = dict(stages_requested or {})
1442 stages_requested = {stage: bool(requested_now.get(stage) or (existing.get("stages_requested") or {}).get(stage))
1443 for stage in {**(existing.get("stages_requested") or {}), **requested_now}}
1444 stages_completed = list(dict.fromkeys(
1445 list(existing.get("stages_completed_or_submitted") or []) + list(stages_completed or [])))
1446 if not requested_now.get("solve") and "solver_num_procs" in existing:
1447 num_procs = existing["solver_num_procs"]
1448 if not requested_now.get("post_process") and "post_num_procs" in existing:
1449 post_num_procs = existing["post_num_procs"]
1450 payload = {
1451 "schema_version": RUN_MANIFEST_SCHEMA_VERSION,
1452 "artifact_type": artifact_type,
1453 "run_id": run_id,
1454 "study_id": study_id,
1455 "case_id": case_id,
1456 "created_at": created_at,
1457 "updated_at": datetime.now().astimezone().isoformat(),
1458 "workspace": (
1459 (load_workspace_config(workspace_root).get("workspace") or {})
1460 if workspace_root else None
1461 ),
1462 "software": dict(picurv_build()),
1463 # The conductor's identity says which source staged the run; the binaries'
1464 # says which build produced its checkpoints. They can differ - an edited C
1465 # tree that was never rebuilt - so provenance records both.
1466 "binaries": runtime_build_identities(run_dir=run_dir),
1467 "launch_mode": launch_mode,
1468 "num_procs": num_procs,
1469 "solver_num_procs": num_procs,
1470 "post_num_procs": post_num_procs,
1471 "stages_requested": stages_requested or {},
1472 "stages_completed_or_submitted": stages_completed or [],
1473 "inputs": inputs or {},
1474 "paths": dict(CANONICAL_RUN_PATHS),
1475 # Where the run lived when its files were generated; adopt_relocated_artifact()
1476 # compares it with where the run is now.
1477 "root_path": os.path.abspath(run_dir),
1478 # Fixed schema, every component present whatever happened, so a reader never
1479 # has to distinguish "absent because it was not asked for" from "absent because
1480 # it failed" by looking for empty directories.
1481 "components": _run_component_states(run_dir, stages_requested or {}),
1482 "assets": (asset_lock or {}).get("assets", {}),
1483 "runtime_providers": (asset_lock or {}).get("runtime_providers", {}),
1484 # Always present, so a reader distinguishes "this run began from nothing" from
1485 # "this manifest predates lineage" by the value rather than by the key's absence.
1486 # A re-staged continuation keeps the lineage recorded when the run was created:
1487 # what a run branched from does not change because it was resumed.
1488 "lineage": lineage or existing.get("lineage") or {"relationship": "root"},
1489 "submission": submission or {},
1490 }
1491 return payload
1492
1493
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _recorded_run_root()

picurv_cli.core._recorded_run_root ( str  run_dir)
protected

Return the location a run's generated files refer to.

Runs staged before the manifest recorded root_path are read from the active control file, whose generated input paths all start at the run root.

Parameters
[in]run_dirCurrent run directory.
Returns
Recorded absolute root, or None when it cannot be determined.

Definition at line 1494 of file core.py.

1494def _recorded_run_root(run_dir: str):
1495 """!
1496 @brief Return the location a run's generated files refer to.
1497 @details Runs staged before the manifest recorded `root_path` are read from the
1498 active control file, whose generated input paths all start at the run root.
1499 @param[in] run_dir Current run directory.
1500 @return Recorded absolute root, or None when it cannot be determined.
1501 """
1502 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
1503 if manifest.get("root_path"):
1504 return manifest["root_path"]
1505 control = auto_identify_run_inputs(os.path.join(run_dir, CANONICAL_RUN_PATHS["config"]))[2]
1506 if not (control and os.path.isfile(control)):
1507 return None
1508 markers = tuple(f"/{CANONICAL_RUN_PATHS[key]}/" for key in ("config", "inputs"))
1509 with open(control, "r", encoding="utf-8", errors="replace") as stream:
1510 for line in stream:
1511 for token in line.split()[1:]:
1512 for marker in markers:
1513 if not (token.startswith("/") and marker in token):
1514 continue
1515 candidate, rest = token.split(marker, 1)
1516 # Only a path that names a file of this run identifies its old root;
1517 # an external input that merely contains the same segment does not.
1518 if os.path.exists(os.path.join(run_dir, marker.strip("/"), rest)):
1519 return candidate
1520 return None
1521
1522
Here is the call graph for this function:
Here is the caller graph for this function:

◆ adopt_relocated_artifact()

int picurv_cli.core.adopt_relocated_artifact ( str  root,
str   kind = "run",
bool   dry_run = False 
)

Point a moved run's or study's generated files at where it now lives.

Generated control files, recipes, scheduler scripts, and case indexes hold absolute paths, so a run or study moved or copied to another directory would otherwise launch against its old location. The rewrite is the one a relocated storage restore applies, after which the recorded root is updated.

Parameters
[in]rootCurrent run or study directory.
[in]kind"run" or "study".
[in]dry_runReport the relocation without rewriting anything.
Returns
Number of files rewritten (0 when the artifact has not moved).

Definition at line 1523 of file core.py.

1523def adopt_relocated_artifact(root: str, kind: str = "run", dry_run: bool = False) -> int:
1524 """!
1525 @brief Point a moved run's or study's generated files at where it now lives.
1526 @details Generated control files, recipes, scheduler scripts, and case indexes hold
1527 absolute paths, so a run or study moved or copied to another directory would
1528 otherwise launch against its old location. The rewrite is the one a
1529 relocated storage restore applies, after which the recorded root is updated.
1530 @param[in] root Current run or study directory.
1531 @param[in] kind "run" or "study".
1532 @param[in] dry_run Report the relocation without rewriting anything.
1533 @return Number of files rewritten (0 when the artifact has not moved).
1534 """
1535 root = os.path.abspath(root)
1536 if kind == "study":
1537 manifest_path = os.path.join(root, "study_manifest.json")
1538 recorded = ((_read_json_if_exists(manifest_path) or {}).get("paths") or {}).get("study_dir")
1539 else:
1540 manifest_path = os.path.join(root, "manifest.json")
1541 recorded = _recorded_run_root(root)
1542 if not recorded or os.path.realpath(recorded) == os.path.realpath(root) or recorded == root:
1543 return 0
1544 if dry_run:
1545 print(f"[WARNING] This {kind} was moved from {recorded}; a real invocation first updates "
1546 "its generated files to the new location.", file=sys.stderr)
1547 return 0
1548 changed = _rebase_restored_text_paths(root, [(recorded, root)])
1549 manifest = _read_json_if_exists(manifest_path)
1550 if isinstance(manifest, dict):
1551 if kind == "study":
1552 manifest.setdefault("paths", {})["study_dir"] = root
1553 else:
1554 manifest["root_path"] = root
1555 write_json_file(manifest_path, manifest)
1556 print(f"[INFO] This {kind} was moved from {recorded}; updated {len(changed)} generated "
1557 "file(s) to its new location.")
1558 return len(changed)
1559
1560
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _checkpoint_bundle_path()

str picurv_cli.core._checkpoint_bundle_path ( str  source_dir,
int  step 
)
protected

Resolve a run/output root or an exact checkpoint bundle.

Parameters
[in]source_dirRun/output root or exact bundle path.
[in]stepCheckpoint step to resolve.
Returns
Absolute path to the checkpoint bundle.

Definition at line 1561 of file core.py.

1561def _checkpoint_bundle_path(source_dir: str, step: int) -> str:
1562 """!
1563 @brief Resolve a run/output root or an exact checkpoint bundle.
1564 @param[in] source_dir Run/output root or exact bundle path.
1565 @param[in] step Checkpoint step to resolve.
1566 @return Absolute path to the checkpoint bundle.
1567 """
1568 source_dir = os.path.abspath(source_dir)
1569 if os.path.isfile(os.path.join(source_dir, "checkpoint.meta")):
1570 return source_dir
1571 return os.path.join(source_dir, "checkpoints", f"step_{step:0{CHECKPOINT_STEP_WIDTH}d}")
1572
1573
Here is the caller graph for this function:

◆ _read_checkpoint_options()

dict picurv_cli.core._read_checkpoint_options ( str  metadata_path)
protected

Parse the deliberately small PETSc-options checkpoint manifest.

Parameters
[in]metadata_pathPath to checkpoint.meta.
Returns
Mapping of option names to scalar text values.

Definition at line 1574 of file core.py.

1574def _read_checkpoint_options(metadata_path: str) -> dict:
1575 """!
1576 @brief Parse the deliberately small PETSc-options checkpoint manifest.
1577 @param[in] metadata_path Path to checkpoint.meta.
1578 @return Mapping of option names to scalar text values.
1579 """
1580 options = {}
1581 with open(metadata_path, "r", encoding="utf-8") as stream:
1582 for line_number, raw_line in enumerate(stream, start=1):
1583 tokens = shlex.split(raw_line, comments=True, posix=True)
1584 if not tokens:
1585 continue
1586 if len(tokens) != 2 or not tokens[0].startswith("-"):
1587 raise ValueError(
1588 f"Invalid checkpoint metadata line {line_number} in {metadata_path}."
1589 )
1590 key = tokens[0][1:]
1591 if key in options:
1592 raise ValueError(f"Duplicate checkpoint metadata key '-{key}' in {metadata_path}.")
1593 options[key] = tokens[1]
1594 return options
1595
1596
Here is the caller graph for this function:

◆ validate_committed_checkpoint()

dict picurv_cli.core.validate_committed_checkpoint ( str  source_dir,
int  step,
bool   require_particles = False 
)

Validate one committed bundle using the same manifest contract as C.

Parameters
[in]source_dirRun/output root or exact bundle path.
[in]stepExpected checkpoint step.
[in]require_particlesWhether particle restart payloads are required.
Returns
Validated bundle path, metadata, and payload inventory.

Definition at line 1605 of file core.py.

1605def validate_committed_checkpoint(source_dir: str, step: int, require_particles: bool = False) -> dict:
1606 """!
1607 @brief Validate one committed bundle using the same manifest contract as C.
1608 @param[in] source_dir Run/output root or exact bundle path.
1609 @param[in] step Expected checkpoint step.
1610 @param[in] require_particles Whether particle restart payloads are required.
1611 @return Validated bundle path, metadata, and payload inventory.
1612 """
1613 bundle = _checkpoint_bundle_path(source_dir, step)
1614 metadata_path = os.path.join(bundle, "checkpoint.meta")
1615 commit_path = os.path.join(bundle, "COMMITTED")
1616 if not os.path.isfile(metadata_path) or not os.path.isfile(commit_path):
1617 raise ValueError(f"No committed checkpoint for step {step}: {bundle}")
1618
1619 with open(commit_path, "r", encoding="ascii") as stream:
1620 expected_digest = stream.read().strip()
1621 if not re.fullmatch(r"[0-9a-fA-F]{64}", expected_digest):
1622 raise ValueError(f"Invalid checkpoint commit marker: {commit_path}")
1623 with open(metadata_path, "rb") as stream:
1624 actual_digest = hashlib.sha256(stream.read()).hexdigest()
1625 if actual_digest.lower() != expected_digest.lower():
1626 raise ValueError(f"Checkpoint metadata hash mismatch: {bundle}")
1627
1628 options = _read_checkpoint_options(metadata_path)
1629 try:
1630 saved_version = int(options.get("checkpoint_version", "-1"))
1631 saved_step = int(options.get("checkpoint_step", "-1"))
1632 payload_count = int(options.get("checkpoint_payload_count", "-1"))
1633 particle_count = int(options.get("checkpoint_particle_count", "-1"))
1634 block_count = int(options.get("checkpoint_block_count", "-1"))
1635 float(options["checkpoint_time"])
1636 except (KeyError, TypeError, ValueError) as exc:
1637 raise ValueError(f"Checkpoint metadata is incomplete or malformed: {metadata_path}") from exc
1638 if options.get("checkpoint_format") != CHECKPOINT_FORMAT or saved_version != CHECKPOINT_VERSION:
1639 raise ValueError(f"Unsupported checkpoint format/version: {metadata_path}")
1640 if saved_step != step:
1641 raise ValueError(f"Checkpoint records step {saved_step}, expected {step}: {bundle}")
1642 # Checked once the command has selected its build; restart resolution validates before
1643 # selecting, and selection then takes this checkpoint's width, so the two agree.
1644 saved_index_bytes = int(options.get("checkpoint_petsc_index_bytes", "4"))
1645 solver_index_bytes = _SELECTED_PETSC_INDEX_BYTES or saved_index_bytes
1646 if saved_index_bytes != solver_index_bytes:
1647 raise CheckpointIndexWidthError(
1648 f"Checkpoint {bundle} was written by a PETSc with {8 * saved_index_bytes}-bit indices, but this "
1649 f"picurv drives a {8 * solver_index_bytes}-bit-index build ({resolve_runtime_executable('simulator')}). "
1650 f"Restart or post-process it with the PICurv built on a {8 * saved_index_bytes}-bit-index PETSc.")
1651 if payload_count <= 0 or particle_count < 0 or block_count <= 0:
1652 raise ValueError(f"Checkpoint inventory counts are invalid: {metadata_path}")
1653
1654 payloads = []
1655 for index in range(payload_count):
1656 prefix = f"checkpoint_payload_{index}_"
1657 relative_path = options.get(prefix + "path")
1658 try:
1659 expected_bytes = int(options[prefix + "bytes"])
1660 except (KeyError, TypeError, ValueError) as exc:
1661 raise ValueError(f"Checkpoint payload {index} has invalid metadata: {metadata_path}") from exc
1662 if not relative_path or os.path.isabs(relative_path) or ".." in relative_path.split("/"):
1663 raise ValueError(f"Checkpoint payload {index} has an unsafe path: {relative_path!r}")
1664 payload_path = os.path.join(bundle, *relative_path.split("/"))
1665 if not os.path.isfile(payload_path) or os.path.getsize(payload_path) != expected_bytes:
1666 raise ValueError(f"Checkpoint payload is missing or truncated: {payload_path}")
1667 payloads.append({
1668 "path": relative_path,
1669 "kind": options.get(prefix + "kind"),
1670 "field": options.get(prefix + "field"),
1671 "block": options.get(prefix + "block"),
1672 })
1673
1674 for block in range(block_count):
1675 fields = {
1676 item["field"] for item in payloads
1677 if item["kind"] == "eulerian" and item["block"] == str(block)
1678 }
1679 missing = CHECKPOINT_REQUIRED_EULERIAN_FIELDS - fields
1680 if missing:
1681 raise ValueError(
1682 f"Checkpoint block {block} is missing required Eulerian field(s): "
1683 f"{', '.join(sorted(missing))}."
1684 )
1685 particle_payloads = [item for item in payloads if item["kind"] == "particle"]
1686 has_particles = options.get("checkpoint_particles", "false").lower() == "true"
1687 if (not has_particles and particle_count != 0) or (has_particles and not particle_payloads):
1688 raise ValueError(f"Checkpoint particle inventory is inconsistent: {metadata_path}")
1689 if require_particles and not has_particles:
1690 raise ValueError(f"Checkpoint step {step} does not contain particle state: {bundle}")
1691
1692 return {
1693 "bundle": bundle,
1694 "metadata": options,
1695 "payloads": payloads,
1696 "has_particles": has_particles,
1697 "particle_count": particle_count,
1698 }
1699
1700
Here is the caller graph for this function:

◆ _scan_committed_checkpoint_steps()

"set[int]" picurv_cli.core._scan_committed_checkpoint_steps ( str  source_dir,
bool   require_particles = False 
)
protected

Return only fully validated, committed checkpoint steps.

Parameters
[in]source_dirRun/output root containing checkpoints.
[in]require_particlesWhether particle restart payloads are required.
Returns
Set of valid committed step numbers.

Definition at line 1701 of file core.py.

1701def _scan_committed_checkpoint_steps(source_dir: str, require_particles: bool = False) -> "set[int]":
1702 """!
1703 @brief Return only fully validated, committed checkpoint steps.
1704 @param[in] source_dir Run/output root containing checkpoints.
1705 @param[in] require_particles Whether particle restart payloads are required.
1706 @return Set of valid committed step numbers.
1707 """
1708 checkpoints_dir = os.path.join(os.path.abspath(source_dir), "checkpoints")
1709 if not os.path.isdir(checkpoints_dir):
1710 return set()
1711 pattern = re.compile(rf"^step_(\d{{{CHECKPOINT_STEP_WIDTH}}})$")
1712 steps = set()
1713 for name in os.listdir(checkpoints_dir):
1714 match = pattern.fullmatch(name)
1715 if not match:
1716 continue
1717 step = int(match.group(1))
1718 try:
1719 validate_committed_checkpoint(source_dir, step, require_particles=require_particles)
1720 except CheckpointIndexWidthError:
1721 raise
1722 except ValueError:
1723 continue
1724 steps.add(step)
1725 return steps
1726
1727
Here is the call graph for this function:
Here is the caller graph for this function:

◆ parse_slurm_time_limit_to_seconds()

int picurv_cli.core.parse_slurm_time_limit_to_seconds ( str  time_text)

Parse a Slurm time-limit string into total seconds.

Parameters
[in]time_textArgument passed to parse_slurm_time_limit_to_seconds().
Returns
Value returned by parse_slurm_time_limit_to_seconds().

Definition at line 1728 of file core.py.

1728def parse_slurm_time_limit_to_seconds(time_text: str) -> int:
1729 """!
1730 @brief Parse a Slurm time-limit string into total seconds.
1731 @param[in] time_text Argument passed to `parse_slurm_time_limit_to_seconds()`.
1732 @return Value returned by `parse_slurm_time_limit_to_seconds()`.
1733 """
1734 text = str(time_text).strip()
1735 if not text:
1736 raise ValueError("time limit cannot be empty")
1737
1738 days = 0
1739 clock_text = text
1740 if "-" in text:
1741 day_text, clock_text = text.split("-", 1)
1742 if not day_text.isdigit():
1743 raise ValueError(f"invalid day field '{day_text}'")
1744 days = int(day_text)
1745 if not clock_text:
1746 raise ValueError("missing time portion after day field")
1747
1748 parts = clock_text.split(":")
1749 if len(parts) > 3:
1750 raise ValueError(f"unsupported time format '{time_text}'")
1751 if any(part == "" for part in parts):
1752 raise ValueError(f"malformed time field '{time_text}'")
1753 if any(not part.isdigit() for part in parts):
1754 raise ValueError(f"non-numeric time field '{time_text}'")
1755
1756 nums = [int(part) for part in parts]
1757 if days > 0:
1758 if len(nums) == 1:
1759 hours, minutes, seconds = nums[0], 0, 0
1760 elif len(nums) == 2:
1761 hours, minutes = nums
1762 seconds = 0
1763 else:
1764 hours, minutes, seconds = nums
1765 else:
1766 if len(nums) == 1:
1767 hours, minutes, seconds = 0, nums[0], 0
1768 elif len(nums) == 2:
1769 hours = 0
1770 minutes, seconds = nums
1771 else:
1772 hours, minutes, seconds = nums
1773
1774 if minutes >= 60 or seconds >= 60:
1775 raise ValueError(f"minutes and seconds must be < 60 in '{time_text}'")
1776 if days == 0 and len(nums) == 3 and hours < 0:
1777 raise ValueError(f"hours must be non-negative in '{time_text}'")
1778
1779 total_seconds = (((days * 24) + hours) * 60 + minutes) * 60 + seconds
1780 if total_seconds <= 0:
1781 raise ValueError("time limit must be positive")
1782 return total_seconds
1783
1784
Here is the caller graph for this function:

◆ resolve_walltime_guard_policy()

"dict | None" picurv_cli.core.resolve_walltime_guard_policy ( "dict | None"  cluster_cfg)

Resolve the effective Slurm walltime-guard policy for generated solver jobs.

Parameters
[in]cluster_cfgArgument passed to resolve_walltime_guard_policy().
Returns
Value returned by resolve_walltime_guard_policy().

Definition at line 1785 of file core.py.

1785def resolve_walltime_guard_policy(cluster_cfg: "dict | None") -> "dict | None":
1786 """!
1787 @brief Resolve the effective Slurm walltime-guard policy for generated solver jobs.
1788 @param[in] cluster_cfg Argument passed to `resolve_walltime_guard_policy()`.
1789 @return Value returned by `resolve_walltime_guard_policy()`.
1790 """
1791 if not isinstance(cluster_cfg, dict):
1792 return None
1793
1794 scheduler = cluster_cfg.get("scheduler", {}) or {}
1795 if str(scheduler.get("type", "slurm")).lower() != "slurm":
1796 return None
1797
1798 execution = cluster_cfg.get("execution", {}) or {}
1799 guard_cfg = execution.get("walltime_guard")
1800 if guard_cfg is None:
1801 guard_cfg = {}
1802 elif not isinstance(guard_cfg, dict):
1803 raise ValueError("execution.walltime_guard must be a mapping when provided")
1804
1805 policy = copy.deepcopy(DEFAULT_WALLTIME_GUARD_POLICY)
1806 policy.update(guard_cfg)
1807 policy["enabled"] = bool(policy["enabled"])
1808 policy["warmup_steps"] = int(policy["warmup_steps"])
1809 policy["multiplier"] = float(policy["multiplier"])
1810 policy["min_seconds"] = float(policy["min_seconds"])
1811 policy["estimator_alpha"] = float(policy["estimator_alpha"])
1812 return policy
1813
1814
Here is the caller graph for this function:

◆ build_walltime_guard_exports()

dict picurv_cli.core.build_walltime_guard_exports ( "dict | None"  cluster_cfg)

Build shell-evaluated environment exports for the runtime walltime guard.

Parameters
[in]cluster_cfgArgument passed to build_walltime_guard_exports().
Returns
Value returned by build_walltime_guard_exports().

Definition at line 1815 of file core.py.

1815def build_walltime_guard_exports(cluster_cfg: "dict | None") -> dict:
1816 """!
1817 @brief Build shell-evaluated environment exports for the runtime walltime guard.
1818 @param[in] cluster_cfg Argument passed to `build_walltime_guard_exports()`.
1819 @return Value returned by `build_walltime_guard_exports()`.
1820 """
1821 policy = resolve_walltime_guard_policy(cluster_cfg)
1822 if not policy or not policy.get("enabled", False):
1823 return {}
1824 walltime_limit_seconds = parse_slurm_time_limit_to_seconds(cluster_cfg.get("resources", {}).get("time", ""))
1825 return {
1826 WALLTIME_GUARD_ENV_JOB_START_EPOCH: "$(date +%s)",
1827 WALLTIME_GUARD_ENV_LIMIT_SECONDS: str(walltime_limit_seconds),
1828 }
1829
1830#: PetscInt width of the build the current command drives, chosen once per command by
1831#: `select_petsc_index_bytes()`; None keeps the default 32-bit `simulator`/`postprocessor`.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_runtime_executable()

str picurv_cli.core.resolve_runtime_executable ( str  executable_name)

Resolve solver/post executable path, preferring local sibling binaries.

A command that selected the 64-bit-index build resolves simulator64 and postprocessor64, which make INDEX_BITS=64 installs beside the 32-bit pair.

Parameters
[in]executable_namesimulator or postprocessor.
Returns
Absolute executable path.

Definition at line 1835 of file core.py.

1835def resolve_runtime_executable(executable_name: str) -> str:
1836 """!
1837 @brief Resolve solver/post executable path, preferring local sibling binaries.
1838 @details A command that selected the 64-bit-index build resolves `simulator64` and
1839 `postprocessor64`, which `make INDEX_BITS=64` installs beside the 32-bit pair.
1840 @param[in] executable_name `simulator` or `postprocessor`.
1841 @return Absolute executable path.
1842 """
1843 if _SELECTED_PETSC_INDEX_BYTES == 8 and executable_name in RUN_EXECUTABLE_NAMES:
1844 executable_name += "64"
1845 local_candidate = os.path.join(INVOKED_SCRIPT_DIR, executable_name)
1846 if os.path.isfile(local_candidate):
1847 return os.path.abspath(local_candidate)
1848 return os.path.join(DEFAULT_BIN_DIR, executable_name)
1849
1850
1851#: Identity a native executable prints for `--version`:
1852#: `<name> <release>[.dev<N>]+g<commit>[.dirty]`; executables built before the `.dev<N>` stamp omit it.
Here is the caller graph for this function:

◆ _parse_binary_petsc_line()

"dict | None" picurv_cli.core._parse_binary_petsc_line ( str  line)
protected

Parse the PETSc build line a native executable reports.

Parameters
[in]linepetsc <version> <debug|optimized> [int32|int64] <arch> <dir>.
Returns
Mapping with version, mode, indices, arch, and dir, or None when the line is not one. A binary built before the index width was stamped is 32-bit; indices_stamped records whether the line named it, so the line can be rebuilt exactly.

Definition at line 1868 of file core.py.

1868def _parse_binary_petsc_line(line: str) -> "dict | None":
1869 """!
1870 @brief Parse the PETSc build line a native executable reports.
1871 @param[in] line `petsc <version> <debug|optimized> [int32|int64] <arch> <dir>`.
1872 @return Mapping with version, mode, indices, arch, and dir, or None when the line is not
1873 one. A binary built before the index width was stamped is 32-bit; `indices_stamped`
1874 records whether the line named it, so the line can be rebuilt exactly.
1875 """
1876 match = _BINARY_PETSC_PATTERN.match((line or "").strip())
1877 if not match:
1878 return None
1879 parsed = {key: match.group(key) for key in ("version", "mode", "arch", "dir")}
1880 parsed["indices_stamped"] = match.group("indices") is not None
1881 parsed["indices"] = match.group("indices") or "int32"
1882 return parsed
1883
1884
Here is the caller graph for this function:

◆ read_binary_petsc_stamp()

"dict | None" picurv_cli.core.read_binary_petsc_stamp ( str  executable_path)

Read the PETSc build stamp from an executable's bytes, without running it.

A binary built against one PETSc and started against another cannot run --version at all, which is exactly when knowing its PETSc matters.

Parameters
[in]executable_pathPath to simulator or postprocessor.
Returns
The parsed stamp, or None when the file carries none (built before it existed).

Definition at line 1885 of file core.py.

1885def read_binary_petsc_stamp(executable_path: str) -> "dict | None":
1886 """!
1887 @brief Read the PETSc build stamp from an executable's bytes, without running it.
1888 @details A binary built against one PETSc and started against another cannot run
1889 `--version` at all, which is exactly when knowing its PETSc matters.
1890 @param[in] executable_path Path to `simulator` or `postprocessor`.
1891 @return The parsed stamp, or None when the file carries none (built before it existed).
1892 """
1893 try:
1894 with open(executable_path, "rb") as stream:
1895 data = stream.read()
1896 except OSError:
1897 return None
1898 start = data.find(_BINARY_PETSC_STAMP_MARKER)
1899 if start < 0:
1900 return None
1901 start += len(_BINARY_PETSC_STAMP_MARKER)
1902 text = data[start:start + 4096].split(b"\0", 1)[0].split(b"\n", 1)[0]
1903 return _parse_binary_petsc_line(text.decode("utf-8", errors="replace"))
1904
1905
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolved_petsc_library()

"str | None" picurv_cli.core.resolved_petsc_library ( str  executable_path)

The PETSc shared library the dynamic loader would give this executable when picurv launches it.

Uses ldd on the current shell's library path with the executable's own PETSc lib/ first, as launches and job scripts put it; returns None where ldd is unavailable or the executable links PETSc statically.

Parameters
[in]executable_pathPath to simulator or postprocessor.
Returns
Absolute library path, not found, or None.

Definition at line 1906 of file core.py.

1906def resolved_petsc_library(executable_path: str) -> "str | None":
1907 """!
1908 @brief The PETSc shared library the dynamic loader would give this executable when picurv launches it.
1909 @details Uses `ldd` on the current shell's library path with the executable's own PETSc
1910 lib/ first, as launches and job scripts put it; returns None where `ldd` is
1911 unavailable or the executable links PETSc statically.
1912 @param[in] executable_path Path to `simulator` or `postprocessor`.
1913 @return Absolute library path, `not found`, or None.
1914 """
1915 ldd = shutil.which("ldd")
1916 if not ldd:
1917 return None
1918 env = dict(os.environ)
1919 library_dir = executable_petsc_library_dir(executable_path)
1920 if library_dir:
1921 env["LD_LIBRARY_PATH"] = os.pathsep.join(filter(None, (library_dir, env.get("LD_LIBRARY_PATH"))))
1922 try:
1923 result = subprocess.run([ldd, executable_path], text=True, capture_output=True,
1924 timeout=20, check=False, env=env)
1925 except (OSError, subprocess.SubprocessError):
1926 return None
1927 for line in (result.stdout or "").splitlines():
1928 name, _, target = line.strip().partition("=>")
1929 if not name.strip().startswith("libpetsc"):
1930 continue
1931 target = target.strip()
1932 if target.startswith("not found"):
1933 return "not found"
1934 return target.split(" (")[0].strip() or None
1935 return None
1936
1937
Here is the call graph for this function:
Here is the caller graph for this function:

◆ petsc_library_mismatch()

bool picurv_cli.core.petsc_library_mismatch ( "dict | None"  petsc,
"str | None"  library 
)

Whether the library the loader resolves lies outside the PETSc the binary was built against.

Parameters
[in]petscStamp from read_binary_petsc_stamp() or the --version line.
[in]libraryPath from resolved_petsc_library().
Returns
True only when both are known and the library is not under the build's tree.

Definition at line 1938 of file core.py.

1938def petsc_library_mismatch(petsc: "dict | None", library: "str | None") -> bool:
1939 """!
1940 @brief Whether the library the loader resolves lies outside the PETSc the binary was built against.
1941 @param[in] petsc Stamp from `read_binary_petsc_stamp()` or the `--version` line.
1942 @param[in] library Path from `resolved_petsc_library()`.
1943 @return True only when both are known and the library is not under the build's tree.
1944 """
1945 if not petsc or not petsc.get("dir") or not library:
1946 return False
1947 if library == "not found":
1948 return True
1949 build_root = os.path.realpath(os.path.join(petsc["dir"], petsc.get("arch") or ""))
1950 return not os.path.realpath(library).startswith(build_root.rstrip(os.sep) + os.sep)
1951
1952
Here is the caller graph for this function:

◆ _read_binary_build_stamp()

"dict | None" picurv_cli.core._read_binary_build_stamp ( str  executable_path)
protected

Read the build identity stamped into an executable's bytes, without running it.

src/setup.c stores PICURV_BUILD:<version> <commit> <dirty> beside the PETSc stamp. Running --version on a login node starts the dynamic loader against a cold filesystem, tens of seconds; reading the file costs one read.

Parameters
[in]executable_pathPath to simulator or postprocessor.
Returns
The identity mapping read_binary_build_identity() returns, or None for a binary built before the stamp existed.

Definition at line 1953 of file core.py.

1953def _read_binary_build_stamp(executable_path: str) -> "dict | None":
1954 """!
1955 @brief Read the build identity stamped into an executable's bytes, without running it.
1956 @details src/setup.c stores `PICURV_BUILD:<version> <commit> <dirty>` beside the PETSc
1957 stamp. Running `--version` on a login node starts the dynamic loader against a
1958 cold filesystem, tens of seconds; reading the file costs one read.
1959 @param[in] executable_path Path to `simulator` or `postprocessor`.
1960 @return The identity mapping `read_binary_build_identity()` returns, or None for a binary
1961 built before the stamp existed.
1962 """
1963 try:
1964 with open(executable_path, "rb") as stream:
1965 data = stream.read()
1966 except OSError:
1967 return None
1968 start = data.find(_BINARY_BUILD_STAMP_MARKER)
1969 if start < 0:
1970 return None
1971 fields = data[start + len(_BINARY_BUILD_STAMP_MARKER):start + 512].split(b"\0", 1)[0].decode(
1972 "utf-8", errors="replace").split()
1973 if len(fields) != 3 or not re.fullmatch(r"[0-9a-f]+", fields[1]) or fields[2] not in ("true", "false"):
1974 return None
1975 version, commit, dirty = fields[0], fields[1][:12], fields[2] == "true"
1976 release = re.sub(r"\.dev\d+$", "", version)
1977 petsc = read_binary_petsc_stamp(executable_path)
1978 if petsc is None:
1979 return None
1980 # Exactly what `--version` prints (src/setup.c), which the job-start check compares with.
1981 name = os.path.basename(executable_path)
1982 version_line = (f"{name} {version}+g{commit}{'.dirty' if dirty else ''}\n"
1983 f"petsc {petsc['version']} {petsc['mode']}"
1984 f"{' ' + petsc['indices'] if petsc.get('indices_stamped') else ''} {petsc['arch']} {petsc['dir']}")
1985 return {
1986 "available": True,
1987 "path": executable_path,
1988 "version_line": version_line,
1989 "release_version": release,
1990 "git_commit": commit,
1991 "dirty": dirty,
1992 "build_id": f"{version}+g{commit}{'.dirty' if dirty else ''}",
1993 "petsc": petsc,
1994 "identity_source": "stamp",
1995 }
1996
1997
Here is the call graph for this function:
Here is the caller graph for this function:

◆ read_binary_build_identity()

dict picurv_cli.core.read_binary_build_identity ( str  executable_path)

Read the build identity a native executable was compiled with.

The Makefile stamps the release, commit, and dirty state into the binaries, and they are written into every checkpoint manifest. Reading it back is what makes the run manifest's provenance a statement about the binary that ran rather than about whatever source happens to be checked out when the conductor is invoked.

Parameters
[in]executable_pathPath to simulator or postprocessor.
Returns
Identity mapping, or an available: False mapping when it cannot be read.

Definition at line 1998 of file core.py.

1998def read_binary_build_identity(executable_path: str) -> dict:
1999 """!
2000 @brief Read the build identity a native executable was compiled with.
2001
2002 @details The Makefile stamps the release, commit, and dirty state into the
2003 binaries, and they are written into every checkpoint manifest. Reading it
2004 back is what makes the run manifest's provenance a statement about the
2005 binary that ran rather than about whatever source happens to be checked
2006 out when the conductor is invoked.
2007 @param[in] executable_path Path to `simulator` or `postprocessor`.
2008 @return Identity mapping, or an `available: False` mapping when it cannot be read.
2009 """
2010 if not os.path.isfile(executable_path) or not os.access(executable_path, os.X_OK):
2011 return {"available": False, "reason": "not built", "path": executable_path}
2012 stamped = _read_binary_build_stamp(executable_path)
2013 if stamped is not None:
2014 return stamped
2015 try:
2016 result = subprocess.run(
2017 [executable_path, "--version"], text=True, capture_output=True, timeout=30,
2018 )
2019 except (OSError, subprocess.SubprocessError) as exc:
2020 return {"available": False, "reason": str(exc), "path": executable_path}
2021 if result.returncode != 0:
2022 # A binary that cannot start - typically its shared libraries are not loadable
2023 # in this shell - is not the same failure as one that predates the identity
2024 # flag, and recording it as such hides a broken staging environment.
2025 detail = (result.stderr or result.stdout or "").strip().splitlines()
2026 return {"available": False,
2027 "reason": f"--version exited {result.returncode}"
2028 + (f": {detail[0]}" if detail else ""),
2029 "path": executable_path,
2030 "petsc": read_binary_petsc_stamp(executable_path)}
2031 lines = (result.stdout or "").strip().splitlines()
2032 match = _BINARY_VERSION_PATTERN.match(lines[0] if lines else "")
2033 if not match:
2034 # A binary from before the identity flag existed, or a wrapper that prints
2035 # something else. Recorded as unavailable rather than guessed at.
2036 return {"available": False, "reason": "no build identity reported",
2037 "path": executable_path}
2038 return {
2039 "available": True,
2040 "path": executable_path,
2041 "version_line": (result.stdout or "").strip(),
2042 "release_version": match.group("release"),
2043 "git_commit": match.group("commit"),
2044 "dirty": bool(match.group("dirty")),
2045 "build_id": (
2046 f"{match.group('version')}+g{match.group('commit')}"
2047 f"{'.dirty' if match.group('dirty') else ''}"
2048 ),
2049 "petsc": _parse_binary_petsc_line(lines[1]) if len(lines) > 1 else None,
2050 }
2051
2052
Here is the call graph for this function:
Here is the caller graph for this function:

◆ runtime_build_identities()

dict picurv_cli.core.runtime_build_identities ( "dict | None"   source_identity = None,
"str | None"   run_dir = None 
)

Read the build identity of every native executable a run would launch.

Parameters
[in]source_identitySource identity to compare against; defaults to the identity this process computed at startup.
[in]run_dirRun whose pinned executables are read; the installation's when None, which also lists the 64-bit-index pair (simulator64, postprocessor64) when it is built.
Returns
Mapping of executable name to its identity, each carrying matches_source.

Definition at line 2053 of file core.py.

2054 run_dir: "str | None" = None) -> dict:
2055 """!
2056 @brief Read the build identity of every native executable a run would launch.
2057 @param[in] source_identity Source identity to compare against; defaults to the
2058 identity this process computed at startup.
2059 @param[in] run_dir Run whose pinned executables are read; the installation's when None,
2060 which also lists the 64-bit-index pair (`simulator64`,
2061 `postprocessor64`) when it is built.
2062 @return Mapping of executable name to its identity, each carrying `matches_source`.
2063 """
2064 source_identity = source_identity or picurv_build()
2065 identities = {}
2066 source_commit = str(source_identity.get("git_commit") or "")
2067 paths = {name: run_executable_path(run_dir, name) for name in RUN_EXECUTABLE_NAMES}
2068 if run_dir is None and _SELECTED_PETSC_INDEX_BYTES is None:
2069 for name in RUN_EXECUTABLE_NAMES:
2070 wide = os.path.join(os.path.dirname(paths[name]), name + "64")
2071 if os.path.isfile(wide):
2072 paths[name + "64"] = wide
2073 for name, path in paths.items():
2074 identity = read_binary_build_identity(path)
2075 if identity.get("available"):
2076 # The stamped commit is abbreviated, so compare on the prefix it carries.
2077 identity["matches_source"] = bool(
2078 source_commit
2079 and source_commit.startswith(identity["git_commit"])
2080 and identity["dirty"] == bool(source_identity.get("dirty"))
2081 )
2082 identities[name] = identity
2083 return identities
2084
2085
Here is the call graph for this function:
Here is the caller graph for this function:

◆ rebuild_command()

str picurv_cli.core.rebuild_command ( str  name,
dict  identity 
)

The command that rebuilds one native executable.

The 64-bit-index pair needs INDEX_BITS=64 and its own PETSc, named from the executable's PETSc stamp when it has one; a plain build makes the 32-bit pair.

Parameters
[in]nameExecutable name, such as simulator64.
[in]identityIdentity mapping from read_binary_build_identity().
Returns
Shell command text.

Definition at line 2086 of file core.py.

2086def rebuild_command(name: str, identity: dict) -> str:
2087 """!
2088 @brief The command that rebuilds one native executable.
2089 @details The 64-bit-index pair needs `INDEX_BITS=64` and its own PETSc, named from the
2090 executable's PETSc stamp when it has one; a plain build makes the 32-bit pair.
2091 @param[in] name Executable name, such as `simulator64`.
2092 @param[in] identity Identity mapping from `read_binary_build_identity()`.
2093 @return Shell command text.
2094 """
2095 if not name.endswith("64"):
2096 return "picurv build"
2097 petsc = identity.get("petsc") or {}
2098 command = f"picurv build INDEX_BITS=64 PETSC_DIR={petsc.get('dir') or '<64-bit PETSc>'}"
2099 return command + (f" PETSC_ARCH={petsc['arch']}" if petsc.get("arch") else "")
2100
2101
Here is the caller graph for this function:

◆ build_identity_problems()

list picurv_cli.core.build_identity_problems ( dict  identities,
  workspace_requirement = None,
"dict | None"   source_identity = None 
)

Report every reason the active build identity is not internally coherent.

The conductor, the simulator, and the postprocessor are three artifacts that must agree before a run's provenance means anything. This states the disagreements rather than printing them, so version status can exit on them while ordinary staging only warns.

Parameters
[in]identitiesMapping returned by runtime_build_identities().
[in]workspace_requirementOptional software.picurv constraint to check.
[in]source_identitySource identity the executables must match; defaults to the identity this process computed at startup.
Returns
Human-readable problem descriptions; empty when the build is coherent.

Definition at line 2102 of file core.py.

2103 source_identity: "dict | None" = None) -> list:
2104 """!
2105 @brief Report every reason the active build identity is not internally coherent.
2106
2107 @details The conductor, the simulator, and the postprocessor are three artifacts
2108 that must agree before a run's provenance means anything. This states the
2109 disagreements rather than printing them, so `version status` can exit on
2110 them while ordinary staging only warns.
2111 @param[in] identities Mapping returned by `runtime_build_identities()`.
2112 @param[in] workspace_requirement Optional `software.picurv` constraint to check.
2113 @param[in] source_identity Source identity the executables must match; defaults to
2114 the identity this process computed at startup.
2115 @return Human-readable problem descriptions; empty when the build is coherent.
2116 """
2117 source_identity = source_identity or picurv_build()
2118 problems = []
2119 for name, identity in sorted(identities.items()):
2120 if not identity.get("available"):
2121 problems.append(
2122 f"{name}: no build identity available ({identity.get('reason', 'unknown')}); "
2123 f"rebuild with '{rebuild_command(name, identity)}'."
2124 )
2125 elif not identity.get("matches_source"):
2126 problems.append(
2127 f"{name}: built from {identity['build_id']}, but the active source is "
2128 f"{source_identity['build_id']}; rebuild with '{rebuild_command(name, identity)}'."
2129 )
2130 if workspace_requirement not in (None, ""):
2131 try:
2132 from packaging.specifiers import SpecifierSet
2133 from packaging.version import Version
2134 requirement_text = str(workspace_requirement).strip()
2135 if not any(token in requirement_text for token in "<>=!~"):
2136 requirement_text = "==" + requirement_text
2137 satisfied = Version(PICURV_RELEASE_VERSION) in SpecifierSet(requirement_text)
2138 except Exception:
2139 problems.append(
2140 f"workspace: software.picurv={workspace_requirement!r} is not a valid "
2141 "version or version range."
2142 )
2143 else:
2144 if not satisfied:
2145 problems.append(
2146 f"workspace: requires PICurv {workspace_requirement!r}, but the active "
2147 f"release is {PICURV_RELEASE_VERSION}."
2148 )
2149 return problems
2150
2151
Here is the call graph for this function:
Here is the caller graph for this function:

◆ warn_on_stale_runtime_binaries()

list picurv_cli.core.warn_on_stale_runtime_binaries ( dict  identities)

Report native executables whose build identity is not the active source.

An executable whose identity cannot be read is reported too: the manifest would otherwise record "unavailable" for a run with no word at staging.

Parameters
[in]identitiesMapping returned by runtime_build_identities().
Returns
Names of executables that disagree with the active source identity.

Definition at line 2152 of file core.py.

2152def warn_on_stale_runtime_binaries(identities: dict) -> list:
2153 """!
2154 @brief Report native executables whose build identity is not the active source.
2155 @details An executable whose identity cannot be read is reported too: the manifest
2156 would otherwise record "unavailable" for a run with no word at staging.
2157 @param[in] identities Mapping returned by `runtime_build_identities()`.
2158 @return Names of executables that disagree with the active source identity.
2159 """
2160 stale = [name for name, identity in sorted(identities.items())
2161 if identity.get("available") and not identity.get("matches_source")]
2162 for name in stale:
2163 print(
2164 f"[WARNING] {name} was built from {identities[name]['build_id']}, but the active "
2165 f"source is {picurv_build()['build_id']}. Checkpoints will record the binary's "
2166 f"identity, not the source's. Rebuild with '{rebuild_command(name, identities[name])}'.",
2167 file=sys.stderr,
2168 )
2169 for name, identity in sorted(identities.items()):
2170 if identity.get("available"):
2171 continue
2172 print(
2173 f"[WARNING] The build identity of {name} could not be read "
2174 f"({identity.get('reason', 'unknown')}); this run's manifest will not record "
2175 f"which build it used. Path: {identity.get('path')}",
2176 file=sys.stderr,
2177 )
2178 return stale
2179
2180# Standardized error codes used for CLI/validation reporting.
Here is the caller graph for this function:

◆ _sanitize_error_field()

str picurv_cli.core._sanitize_error_field (   value)
protected

Normalize error fields into a single-line string.

Parameters
[in]valueArgument passed to _sanitize_error_field().
Returns
Value returned by _sanitize_error_field().

Definition at line 2218 of file core.py.

2218def _sanitize_error_field(value) -> str:
2219 """!
2220 @brief Normalize error fields into a single-line string.
2221 @param[in] value Argument passed to `_sanitize_error_field()`.
2222 @return Value returned by `_sanitize_error_field()`.
2223 """
2224 if value is None:
2225 return "-"
2226 text = str(value).strip()
2227 if not text:
2228 return "-"
2229 return " ".join(text.splitlines())
2230
2231

◆ emit_structured_error()

picurv_cli.core.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.

Parameters
[in]codeArgument passed to emit_structured_error().
[in]keyArgument passed to emit_structured_error().
[in]file_pathArgument passed to emit_structured_error().
[in]messageArgument passed to emit_structured_error().
[in]hintArgument passed to emit_structured_error().
[in]streamArgument passed to emit_structured_error().

Definition at line 2232 of file core.py.

2233 message: str = "", hint: str = None, stream=None):
2234 """!
2235 @brief Emit one standardized error line for tooling and users.
2236 @param[in] code Argument passed to `emit_structured_error()`.
2237 @param[in] key Argument passed to `emit_structured_error()`.
2238 @param[in] file_path Argument passed to `emit_structured_error()`.
2239 @param[in] message Argument passed to `emit_structured_error()`.
2240 @param[in] hint Argument passed to `emit_structured_error()`.
2241 @param[in] stream Argument passed to `emit_structured_error()`.
2242 """
2243 if stream is None:
2244 stream = sys.stderr
2245 resolved_hint = hint if hint is not None else _ERROR_HINTS.get(code, "-")
2246 print(
2247 f"ERROR {_sanitize_error_field(code)} | "
2248 f"key={_sanitize_error_field(key)} | "
2249 f"file={_sanitize_error_field(file_path)} | "
2250 f"message={_sanitize_error_field(message)} | "
2251 f"hint={_sanitize_error_field(resolved_hint)}",
2252 file=stream,
2253 )
2254
2255
Here is the caller graph for this function:

◆ fail_cli_usage()

picurv_cli.core.fail_cli_usage ( str  message,
str   hint = None 
)

Emit a structured CLI usage error and exit with code 2.

Without a hint, the default names the command the user ran.

Parameters
[in]messageArgument passed to fail_cli_usage().
[in]hintArgument passed to fail_cli_usage().

Definition at line 2256 of file core.py.

2256def fail_cli_usage(message: str, hint: str = None):
2257 """!
2258 @brief Emit a structured CLI usage error and exit with code 2.
2259 @details Without a @p hint, the default names the command the user ran.
2260 @param[in] message Argument passed to `fail_cli_usage()`.
2261 @param[in] hint Argument passed to `fail_cli_usage()`.
2262 """
2263 command = next((token for token in sys.argv[1:] if not token.startswith("-")), "")
2264 emit_structured_error(
2265 ERROR_CODE_CLI_USAGE_INVALID,
2266 key="-",
2267 file_path="-",
2268 message=message,
2269 hint=hint or _ERROR_HINTS[ERROR_CODE_CLI_USAGE_INVALID].replace(
2270 "<command>", command if re.fullmatch(r"[a-z][a-z-]*", command) else "<command>"),
2271 )
2272 sys.exit(2)
2273
2274
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _split_error_file_and_message()

picurv_cli.core._split_error_file_and_message ( str  raw_error)
protected

Separate a validation error into its source-file and message fields when possible.

Parameters
[in]raw_errorValidation error text, optionally beginning with a file path and colon.
Returns
A pair containing the detected file path (or -) and the message text.

Definition at line 2275 of file core.py.

2275def _split_error_file_and_message(raw_error: str):
2276 """!
2277 @brief Separate a validation error into its source-file and message fields when possible.
2278 @param[in] raw_error Validation error text, optionally beginning with a file path and colon.
2279 @return A pair containing the detected file path (or ``-``) and the message text.
2280 """
2281 text = str(raw_error).strip()
2282 match = re.match(r"^(?P<file>[^:]+):\s*(?P<msg>.+)$", text)
2283 if not match:
2284 return "-", text
2285 file_candidate = match.group("file").strip()
2286 msg = match.group("msg").strip()
2287 known_suffixes = (".yml", ".yaml", ".cfg", ".picgrid", ".control", ".run", ".txt")
2288 if "/" in file_candidate or file_candidate.endswith(known_suffixes):
2289 return file_candidate, msg
2290 return "-", text
2291
2292
Here is the caller graph for this function:

◆ _extract_key_path()

str picurv_cli.core._extract_key_path ( str  message)
protected

Best-effort key-path extraction from free-form validation messages.

Parameters
[in]messageArgument passed to _extract_key_path().
Returns
Value returned by _extract_key_path().

Definition at line 2293 of file core.py.

2293def _extract_key_path(message: str) -> str:
2294 """!
2295 @brief Best-effort key-path extraction from free-form validation messages.
2296 @param[in] message Argument passed to `_extract_key_path()`.
2297 @return Value returned by `_extract_key_path()`.
2298 """
2299 dotted = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_\[\]-]+)+)\b", message)
2300 if dotted:
2301 return dotted.group(1)
2302
2303 bracketed = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*\[[^\]]+\](?:\[[^\]]+\])*)\b", message)
2304 if bracketed:
2305 return bracketed.group(1)
2306
2307 quoted = re.findall(r"'([A-Za-z0-9_.\[\]-]+)'", message)
2308 for token in quoted:
2309 if "." in token or "[" in token or token.isidentifier():
2310 return token
2311 return "-"
2312
2313
Here is the caller graph for this function:

◆ _classify_error_code()

str picurv_cli.core._classify_error_code ( str  message)
protected

Map existing validation/error messages to the standardized code set.

Parameters
[in]messageArgument passed to _classify_error_code().
Returns
Value returned by _classify_error_code().

Definition at line 2314 of file core.py.

2314def _classify_error_code(message: str) -> str:
2315 """!
2316 @brief Map existing validation/error messages to the standardized code set.
2317 @param[in] message Argument passed to `_classify_error_code()`.
2318 @return Value returned by `_classify_error_code()`.
2319 """
2320 msg = message.lower()
2321 if "missing required section" in msg:
2322 return ERROR_CODE_CFG_MISSING_SECTION
2323 if "missing required key" in msg or "missing key" in msg:
2324 return ERROR_CODE_CFG_MISSING_KEY
2325 if "not found" in msg or "does not exist" in msg:
2326 return ERROR_CODE_CFG_FILE_NOT_FOUND
2327 if "invalid dimensions line" in msg or "invalid coordinate row" in msg or "grid file" in msg:
2328 return ERROR_CODE_CFG_GRID_PARSE
2329 if (
2330 "must both be periodic" in msg
2331 or "inconsistent periodicity" in msg
2332 or "mismatch" in msg
2333 or "requires --" in msg
2334 or "must be 1 (auto) or exactly" in msg
2335 ):
2336 return ERROR_CODE_CFG_INCONSISTENT_COMBO
2337 if (
2338 "must be a mapping" in msg
2339 or "must be a list" in msg
2340 or "must be a string" in msg
2341 or "must be a boolean" in msg
2342 or "must be either" in msg
2343 ):
2344 return ERROR_CODE_CFG_INVALID_TYPE
2345 if "unsupported key" in msg or "unsupported top-level section" in msg:
2346 return ERROR_CODE_CFG_INVALID_VALUE
2347 return ERROR_CODE_CFG_INVALID_VALUE
2348
2349# ==============================================================================
2350# HELPER FUNCTIONS
2351# ==============================================================================
2352
Here is the caller graph for this function:

◆ read_yaml_file()

dict picurv_cli.core.read_yaml_file ( str  filepath)

Safely reads a YAML file and returns its content.

Parameters
[in]filepathPath to the YAML file.
Returns
A dictionary containing the parsed YAML content.
Exceptions
SystemExitif the file is not found or cannot be parsed.

Definition at line 2353 of file core.py.

2353def read_yaml_file(filepath: str) -> dict:
2354 """!
2355 @brief Safely reads a YAML file and returns its content.
2356 @param[in] filepath Path to the YAML file.
2357 @return A dictionary containing the parsed YAML content.
2358 @throws SystemExit if the file is not found or cannot be parsed.
2359 """
2360 if not os.path.exists(filepath):
2361 emit_structured_error(
2362 ERROR_CODE_CFG_FILE_NOT_FOUND,
2363 key="-",
2364 file_path=filepath,
2365 message="Configuration file not found.",
2366 )
2367 sys.exit(1)
2368 try:
2369 with open(filepath, 'r') as f:
2370 return yaml.safe_load(f)
2371 except yaml.YAMLError as e:
2372 emit_structured_error(
2373 ERROR_CODE_CFG_INVALID_VALUE,
2374 key="-",
2375 file_path=filepath,
2376 message=f"YAML parse error: {e}",
2377 hint="Fix YAML syntax/indentation and retry validation.",
2378 )
2379 sys.exit(1)
2380
Here is the call graph for this function:
Here is the caller graph for this function:

◆ write_yaml_file()

picurv_cli.core.write_yaml_file ( str  filepath,
dict  data 
)

Write YAML with stable ordering for generated study artifacts.

Parameters
[in]filepathArgument passed to write_yaml_file().
[in]dataArgument passed to write_yaml_file().

Definition at line 2381 of file core.py.

2381def write_yaml_file(filepath: str, data: dict):
2382 """!
2383 @brief Write YAML with stable ordering for generated study artifacts.
2384 @param[in] filepath Argument passed to `write_yaml_file()`.
2385 @param[in] data Argument passed to `write_yaml_file()`.
2386 """
2387 path = os.path.abspath(filepath)
2388 os.makedirs(os.path.dirname(path), exist_ok=True)
2389 temporary = f"{path}.tmp.{os.getpid()}"
2390 with open(temporary, "w") as f:
2391 yaml.safe_dump(data, f, sort_keys=False)
2392 f.flush()
2393 os.fsync(f.fileno())
2394 os.replace(temporary, path)
2395
Here is the caller graph for this function:

◆ write_json_file()

picurv_cli.core.write_json_file ( str  filepath,
dict  payload 
)

Write JSON metadata/manifests with a stable, readable format.

Parameters
[in]filepathArgument passed to write_json_file().
[in]payloadArgument passed to write_json_file().

Definition at line 2396 of file core.py.

2396def write_json_file(filepath: str, payload: dict):
2397 """!
2398 @brief Write JSON metadata/manifests with a stable, readable format.
2399 @param[in] filepath Argument passed to `write_json_file()`.
2400 @param[in] payload Argument passed to `write_json_file()`.
2401 """
2402 path = os.path.abspath(filepath)
2403 os.makedirs(os.path.dirname(path), exist_ok=True)
2404 temporary = f"{path}.tmp.{os.getpid()}"
2405 with open(temporary, "w") as f:
2406 json.dump(payload, f, indent=2, sort_keys=True)
2407 f.write("\n")
2408 f.flush()
2409 os.fsync(f.fileno())
2410 os.replace(temporary, path)
2411
2412
Here is the caller graph for this function:

◆ write_runtime_execution_file()

str picurv_cli.core.write_runtime_execution_file ( str  filepath,
str   template_source_path = None 
)

Write a default runtime execution config, copying a source template when available.

Parameters
[in]filepathArgument passed to write_runtime_execution_file().
[in]template_source_pathArgument passed to write_runtime_execution_file().
Returns
Value returned by write_runtime_execution_file().

Definition at line 2413 of file core.py.

2413def write_runtime_execution_file(filepath: str, template_source_path: str = None) -> str:
2414 """!
2415 @brief Write a default runtime execution config, copying a source template when available.
2416 @param[in] filepath Argument passed to `write_runtime_execution_file()`.
2417 @param[in] template_source_path Argument passed to `write_runtime_execution_file()`.
2418 @return Value returned by `write_runtime_execution_file()`.
2419 """
2420 os.makedirs(os.path.dirname(filepath), exist_ok=True)
2421
2422 if template_source_path and os.path.isfile(template_source_path):
2423 shutil.copy2(template_source_path, filepath)
2424 return filepath
2425
2426 with open(filepath, "w", encoding="utf-8") as f:
2427 f.write(DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE)
2428 return filepath
2429
2430
Here is the caller graph for this function:

◆ _launcher_arg_contains_whitespace()

bool picurv_cli.core._launcher_arg_contains_whitespace (   token)
protected

Return True when a launcher arg token contains embedded whitespace and should be split.

Parameters
[in]tokenArgument passed to _launcher_arg_contains_whitespace().
Returns
Value returned by _launcher_arg_contains_whitespace().

Definition at line 2431 of file core.py.

2431def _launcher_arg_contains_whitespace(token) -> bool:
2432 """!
2433 @brief Return True when a launcher arg token contains embedded whitespace and should be split.
2434 @param[in] token Argument passed to `_launcher_arg_contains_whitespace()`.
2435 @return Value returned by `_launcher_arg_contains_whitespace()`.
2436 """
2437 return isinstance(token, str) and any(ch.isspace() for ch in token.strip())
2438
2439
Here is the caller graph for this function:

◆ resolve_runtime_execution_seed_source()

"str | None" picurv_cli.core.resolve_runtime_execution_seed_source ( str  source_project_root)

Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.

Parameters
[in]source_project_rootArgument passed to resolve_runtime_execution_seed_source().
Returns
Value returned by resolve_runtime_execution_seed_source().

Definition at line 2440 of file core.py.

2440def resolve_runtime_execution_seed_source(source_project_root: str) -> "str | None":
2441 """!
2442 @brief Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
2443 @param[in] source_project_root Argument passed to `resolve_runtime_execution_seed_source()`.
2444 @return Value returned by `resolve_runtime_execution_seed_source()`.
2445 """
2446 source_root_abs = os.path.abspath(source_project_root)
2447 repo_local_runtime = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2448 if os.path.isfile(repo_local_runtime):
2449 return repo_local_runtime
2450
2451 tracked_example = os.path.join(
2452 source_root_abs,
2453 "config",
2454 "runtime",
2455 RUNTIME_EXECUTION_EXAMPLE_FILENAME,
2456 )
2457 if os.path.isfile(tracked_example):
2458 return tracked_example
2459 return None
2460
2461
Here is the caller graph for this function:

◆ ensure_case_runtime_execution_config()

dict picurv_cli.core.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.

Parameters
[in]case_dirArgument passed to ensure_case_runtime_execution_config().
[in]source_project_rootArgument passed to ensure_case_runtime_execution_config().
[in]overwriteArgument passed to ensure_case_runtime_execution_config().
Returns
Value returned by ensure_case_runtime_execution_config().

Definition at line 2462 of file core.py.

2462def ensure_case_runtime_execution_config(case_dir: str, source_project_root: str, overwrite: bool = False) -> dict:
2463 """!
2464 @brief Create case-local runtime execution config if missing, seeded from repo-local config when available.
2465 @param[in] case_dir Argument passed to `ensure_case_runtime_execution_config()`.
2466 @param[in] source_project_root Argument passed to `ensure_case_runtime_execution_config()`.
2467 @param[in] overwrite Argument passed to `ensure_case_runtime_execution_config()`.
2468 @return Value returned by `ensure_case_runtime_execution_config()`.
2469 """
2470 case_dir_abs = os.path.abspath(case_dir)
2471 dest_path = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
2472 if os.path.exists(dest_path) and not overwrite:
2473 return {
2474 "path": dest_path,
2475 "created": False,
2476 "seed_source": None,
2477 }
2478
2479 seed_source = resolve_runtime_execution_seed_source(source_project_root)
2480 write_runtime_execution_file(dest_path, seed_source)
2481 return {
2482 "path": dest_path,
2483 "created": True,
2484 "seed_source": seed_source,
2485 }
2486
2487
Here is the call graph for this function:
Here is the caller graph for this function:

◆ is_project_root()

bool picurv_cli.core.is_project_root ( str  candidate)

Return True when a directory looks like the PICurv source repository root.

Parameters
[in]candidateArgument passed to is_project_root().
Returns
Value returned by is_project_root().

Definition at line 2488 of file core.py.

2488def is_project_root(candidate: str) -> bool:
2489 """!
2490 @brief Return True when a directory looks like the PICurv source repository root.
2491 @param[in] candidate Argument passed to `is_project_root()`.
2492 @return Value returned by `is_project_root()`.
2493 """
2494 if not candidate:
2495 return False
2496 candidate_abs = os.path.abspath(candidate)
2497 return (
2498 os.path.isfile(os.path.join(candidate_abs, "Makefile"))
2499 and os.path.isdir(os.path.join(candidate_abs, "src"))
2500 and os.path.isdir(os.path.join(candidate_abs, "include"))
2501 and os.path.isdir(os.path.join(candidate_abs, "picurv_cli"))
2502 )
2503
2504
Here is the caller graph for this function:

◆ _iter_parent_dirs()

picurv_cli.core._iter_parent_dirs ( str  start_path)
protected

Yield a path and all of its parents up to filesystem root.

Parameters
[in]start_pathArgument passed to _iter_parent_dirs().

Definition at line 2505 of file core.py.

2505def _iter_parent_dirs(start_path: str):
2506 """!
2507 @brief Yield a path and all of its parents up to filesystem root.
2508 @param[in] start_path Argument passed to `_iter_parent_dirs()`.
2509 """
2510 current = os.path.abspath(start_path)
2511 if os.path.isfile(current):
2512 current = os.path.dirname(current)
2513 while True:
2514 yield current
2515 parent = os.path.dirname(current)
2516 if parent == current:
2517 break
2518 current = parent
2519
2520
Here is the caller graph for this function:

◆ find_project_root_upwards()

picurv_cli.core.find_project_root_upwards ( str  start_path)

Search upward from an anchor and return the first matching project root.

Parameters
[in]start_pathArgument passed to find_project_root_upwards().
Returns
Value returned by find_project_root_upwards().

Definition at line 2521 of file core.py.

2521def find_project_root_upwards(start_path: str):
2522 """!
2523 @brief Search upward from an anchor and return the first matching project root.
2524 @param[in] start_path Argument passed to `find_project_root_upwards()`.
2525 @return Value returned by `find_project_root_upwards()`.
2526 """
2527 if not start_path:
2528 return None
2529 for directory in _iter_parent_dirs(start_path):
2530 if is_project_root(directory):
2531 return directory
2532 return None
2533
2534
Here is the call graph for this function:
Here is the caller graph for this function:

◆ discover_local_project_root()

picurv_cli.core.discover_local_project_root ( *  extra_anchors)

Best-effort source repo discovery from runtime anchors.

Parameters
[in]extra_anchorsArgument passed to discover_local_project_root().
Returns
Value returned by discover_local_project_root().

Definition at line 2535 of file core.py.

2535def discover_local_project_root(*extra_anchors):
2536 """!
2537 @brief Best-effort source repo discovery from runtime anchors.
2538 @param[in] extra_anchors Argument passed to `discover_local_project_root()`.
2539 @return Value returned by `discover_local_project_root()`.
2540 """
2541 anchors = list(extra_anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR, SCRIPT_PATH, PROJECT_ROOT]
2542 seen = set()
2543 for anchor in anchors:
2544 if not anchor:
2545 continue
2546 anchor_abs = os.path.abspath(anchor)
2547 if anchor_abs in seen:
2548 continue
2549 seen.add(anchor_abs)
2550 project_root = find_project_root_upwards(anchor_abs)
2551 if project_root:
2552 return project_root
2553 return None
2554
2555
Here is the call graph for this function:
Here is the caller graph for this function:

◆ find_case_origin_metadata_file()

picurv_cli.core.find_case_origin_metadata_file ( str   case_dir_hint = None)

Find the nearest case-origin metadata file from known runtime anchors.

An explicit case directory is the only anchor searched, so a wrong one cannot fall back to the case in the working directory.

Parameters
[in]case_dir_hintArgument passed to find_case_origin_metadata_file().
Returns
Value returned by find_case_origin_metadata_file().
Exceptions
ValueErrorwhen case_dir_hint is given but is not a directory.

Definition at line 2556 of file core.py.

2556def find_case_origin_metadata_file(case_dir_hint: str = None):
2557 """!
2558 @brief Find the nearest case-origin metadata file from known runtime anchors.
2559 @details An explicit case directory is the only anchor searched, so a wrong one cannot
2560 fall back to the case in the working directory.
2561 @param[in] case_dir_hint Argument passed to `find_case_origin_metadata_file()`.
2562 @return Value returned by `find_case_origin_metadata_file()`.
2563 @throws ValueError when @p case_dir_hint is given but is not a directory.
2564 """
2565 if case_dir_hint and not os.path.isdir(case_dir_hint):
2566 raise ValueError(f"Case directory does not exist: {os.path.abspath(case_dir_hint)}")
2567 search_roots = []
2568 for candidate in (case_dir_hint,) if case_dir_hint else (os.getcwd(), INVOKED_SCRIPT_DIR):
2569 if not candidate:
2570 continue
2571 abs_candidate = os.path.abspath(candidate)
2572 if abs_candidate not in search_roots:
2573 search_roots.append(abs_candidate)
2574
2575 for root in search_roots:
2576 for directory in _iter_parent_dirs(root):
2577 metadata_path = os.path.join(directory, CASE_ORIGIN_METADATA_FILENAME)
2578 if os.path.isfile(metadata_path):
2579 return metadata_path
2580 return None
2581
2582
Here is the call graph for this function:
Here is the caller graph for this function:

◆ load_case_origin_metadata()

picurv_cli.core.load_case_origin_metadata ( str   case_dir_hint = None)

Load case-origin metadata if present, returning (case_dir, metadata_path, payload).

Parameters
[in]case_dir_hintArgument passed to load_case_origin_metadata().
Returns
Value returned by load_case_origin_metadata().

Definition at line 2583 of file core.py.

2583def load_case_origin_metadata(case_dir_hint: str = None):
2584 """!
2585 @brief Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
2586 @param[in] case_dir_hint Argument passed to `load_case_origin_metadata()`.
2587 @return Value returned by `load_case_origin_metadata()`.
2588 """
2589 metadata_path = find_case_origin_metadata_file(case_dir_hint)
2590 if not metadata_path:
2591 return None, None, None
2592 try:
2593 with open(metadata_path, "r", encoding="utf-8") as f:
2594 payload = json.load(f)
2595 if not isinstance(payload, dict):
2596 raise ValueError("Case origin metadata must be a JSON object.")
2597 except Exception as exc:
2598 raise ValueError(f"Failed to read case origin metadata at {metadata_path}: {exc}") from exc
2599 return os.path.dirname(metadata_path), metadata_path, payload
2600
2601
Here is the call graph for this function:
Here is the caller graph for this function:

◆ find_runtime_execution_config_file()

picurv_cli.core.find_runtime_execution_config_file ( *  anchors)

Find the nearest optional execution config from runtime/case anchors.

Parameters
[in]anchorsArgument passed to find_runtime_execution_config_file().
Returns
Value returned by find_runtime_execution_config_file().

Definition at line 2602 of file core.py.

2602def find_runtime_execution_config_file(*anchors):
2603 """!
2604 @brief Find the nearest optional execution config from runtime/case anchors.
2605 @param[in] anchors Argument passed to `find_runtime_execution_config_file()`.
2606 @return Value returned by `find_runtime_execution_config_file()`.
2607 """
2608 seen = set()
2609 search_roots = []
2610 for candidate in list(anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR]:
2611 if not candidate:
2612 continue
2613 current = os.path.abspath(candidate)
2614 if os.path.isfile(current):
2615 current = os.path.dirname(current)
2616 if current in seen:
2617 continue
2618 seen.add(current)
2619 search_roots.append(current)
2620
2621 seen_dirs = set()
2622 for root in search_roots:
2623 for directory in _iter_parent_dirs(root):
2624 if directory in seen_dirs:
2625 continue
2626 seen_dirs.add(directory)
2627 for filename in RUNTIME_EXECUTION_CONFIG_FILENAMES:
2628 config_path = os.path.join(directory, filename)
2629 if os.path.isfile(config_path):
2630 return config_path
2631 return None
2632
2633
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _normalize_execution_override_section()

dict picurv_cli.core._normalize_execution_override_section ( dict  payload,
str  section_name,
str  config_path,
str  config_label 
)
protected

Validate one execution override section while preserving missing-vs-empty semantics.

Parameters
[in]payloadArgument passed to _normalize_execution_override_section().
[in]section_nameArgument passed to _normalize_execution_override_section().
[in]config_pathArgument passed to _normalize_execution_override_section().
[in]config_labelArgument passed to _normalize_execution_override_section().
Returns
Value returned by _normalize_execution_override_section().

Definition at line 2634 of file core.py.

2634def _normalize_execution_override_section(payload: dict, section_name: str, config_path: str, config_label: str) -> dict:
2635 """!
2636 @brief Validate one execution override section while preserving missing-vs-empty semantics.
2637 @param[in] payload Argument passed to `_normalize_execution_override_section()`.
2638 @param[in] section_name Argument passed to `_normalize_execution_override_section()`.
2639 @param[in] config_path Argument passed to `_normalize_execution_override_section()`.
2640 @param[in] config_label Argument passed to `_normalize_execution_override_section()`.
2641 @return Value returned by `_normalize_execution_override_section()`.
2642 """
2643 section = payload.get(section_name)
2644 if section is None:
2645 return {"launcher": None, "launcher_args": None}
2646 if not isinstance(section, dict):
2647 raise ValueError(f"{config_label} at {config_path}: {section_name} must be a mapping.")
2648
2649 launcher = section.get("launcher")
2650 if launcher is not None and not isinstance(launcher, str):
2651 raise ValueError(f"{config_label} at {config_path}: {section_name}.launcher must be a string.")
2652
2653 launcher_args = None
2654 if "launcher_args" in section:
2655 launcher_args = section.get("launcher_args", [])
2656 if launcher_args is None:
2657 launcher_args = []
2658 if not isinstance(launcher_args, list):
2659 raise ValueError(f"{config_label} at {config_path}: {section_name}.launcher_args must be a list.")
2660 for i, token in enumerate(launcher_args):
2661 if not isinstance(token, (str, int, float)):
2662 raise ValueError(
2663 f"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2664 "must be a scalar CLI token."
2665 )
2666 if _launcher_arg_contains_whitespace(token):
2667 raise ValueError(
2668 f"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
2669 "must be a single CLI token; split whitespace-separated arguments into separate list items."
2670 )
2671 launcher_args = [str(x) for x in launcher_args]
2672
2673 return {
2674 "launcher": launcher,
2675 "launcher_args": launcher_args,
2676 }
2677
2678
Here is the call graph for this function:
Here is the caller graph for this function:

◆ load_runtime_execution_config()

picurv_cli.core.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.

Parameters
[in]config_search_anchorArgument passed to load_runtime_execution_config().
[in]extra_search_anchorsArgument passed to load_runtime_execution_config().
Returns
Value returned by load_runtime_execution_config().

Definition at line 2679 of file core.py.

2679def load_runtime_execution_config(config_search_anchor: str = None, extra_search_anchors=None):
2680 """!
2681 @brief Load optional shared execution launcher config from the nearest runtime config file.
2682 @param[in] config_search_anchor Argument passed to `load_runtime_execution_config()`.
2683 @param[in] extra_search_anchors Argument passed to `load_runtime_execution_config()`.
2684 @return Value returned by `load_runtime_execution_config()`.
2685 """
2686 anchors = []
2687 if config_search_anchor is not None:
2688 anchors.append(config_search_anchor)
2689 if extra_search_anchors:
2690 anchors.extend(extra_search_anchors)
2691
2692 config_path = find_runtime_execution_config_file(*anchors)
2693 if not config_path:
2694 return None, {}
2695
2696 try:
2697 with open(config_path, "r", encoding="utf-8") as f:
2698 payload = yaml.safe_load(f) or {}
2699 except yaml.YAMLError as exc:
2700 raise ValueError(f"{os.path.basename(config_path)} YAML parse error at {config_path}: {exc}") from exc
2701
2702 if not isinstance(payload, dict):
2703 raise ValueError(f"{os.path.basename(config_path)} at {config_path} must be a YAML mapping.")
2704
2705 return config_path, {
2706 "default_execution": _normalize_execution_override_section(
2707 payload,
2708 "default_execution",
2709 config_path,
2710 os.path.basename(config_path),
2711 ),
2712 "local_execution": _normalize_execution_override_section(
2713 payload,
2714 "local_execution",
2715 config_path,
2716 os.path.basename(config_path),
2717 ),
2718 "cluster_execution": _normalize_execution_override_section(
2719 payload,
2720 "cluster_execution",
2721 config_path,
2722 os.path.basename(config_path),
2723 ),
2724 }
2725
2726
Here is the call graph for this function:
Here is the caller graph for this function:

◆ merge_execution_overrides()

dict picurv_cli.core.merge_execution_overrides ( "dict | None"  base,
"dict | None"  override 
)

Merge execution overrides, letting explicit override values win key-by-key.

Parameters
[in]baseArgument passed to merge_execution_overrides().
[in]overrideArgument passed to merge_execution_overrides().
Returns
Value returned by merge_execution_overrides().

Definition at line 2727 of file core.py.

2727def merge_execution_overrides(base: "dict | None", override: "dict | None") -> dict:
2728 """!
2729 @brief Merge execution overrides, letting explicit override values win key-by-key.
2730 @param[in] base Argument passed to `merge_execution_overrides()`.
2731 @param[in] override Argument passed to `merge_execution_overrides()`.
2732 @return Value returned by `merge_execution_overrides()`.
2733 """
2734 base = base or {}
2735 override = override or {}
2736
2737 launcher = override.get("launcher")
2738 if launcher is None:
2739 launcher = base.get("launcher")
2740
2741 launcher_args = override.get("launcher_args")
2742 if launcher_args is None:
2743 launcher_args = base.get("launcher_args")
2744
2745 return {
2746 "launcher": launcher,
2747 "launcher_args": None if launcher_args is None else [str(x) for x in launcher_args],
2748 }
2749
2750
Here is the caller graph for this function:

◆ resolve_runtime_execution_context()

dict picurv_cli.core.resolve_runtime_execution_context ( dict  runtime_execution_cfg,
str  context 
)

Resolve default plus context-specific execution overrides.

Parameters
[in]runtime_execution_cfgArgument passed to resolve_runtime_execution_context().
[in]contextArgument passed to resolve_runtime_execution_context().
Returns
Value returned by resolve_runtime_execution_context().

Definition at line 2751 of file core.py.

2751def resolve_runtime_execution_context(runtime_execution_cfg: dict, context: str) -> dict:
2752 """!
2753 @brief Resolve default plus context-specific execution overrides.
2754 @param[in] runtime_execution_cfg Argument passed to `resolve_runtime_execution_context()`.
2755 @param[in] context Argument passed to `resolve_runtime_execution_context()`.
2756 @return Value returned by `resolve_runtime_execution_context()`.
2757 """
2758 if context not in LAUNCH_MODES:
2759 raise ValueError(f"Unsupported execution context '{context}'.")
2760 return merge_execution_overrides(
2761 runtime_execution_cfg.get("default_execution"),
2762 runtime_execution_cfg.get(f"{context}_execution"),
2763 )
2764
2765
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_git_commit()

str picurv_cli.core.get_git_commit ( str   repo_root = None)

Best-effort git commit lookup for run/study manifests and case metadata.

Parameters
[in]repo_rootArgument passed to get_git_commit().
Returns
Value returned by get_git_commit().

Definition at line 2766 of file core.py.

2766def get_git_commit(repo_root: str = None) -> str:
2767 """!
2768 @brief Best-effort git commit lookup for run/study manifests and case metadata.
2769 @param[in] repo_root Argument passed to `get_git_commit()`.
2770 @return Value returned by `get_git_commit()`.
2771 """
2772 cwd = repo_root or PROJECT_ROOT
2773 try:
2774 result = subprocess.run(
2775 ["git", "rev-parse", "HEAD"],
2776 cwd=cwd,
2777 text=True,
2778 capture_output=True,
2779 check=False
2780 )
2781 if result.returncode == 0:
2782 return result.stdout.strip()
2783 except Exception:
2784 pass
2785 return None
2786
2787
Here is the caller graph for this function:

◆ write_case_origin_metadata()

picurv_cli.core.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.

Parameters
[in]case_dirArgument passed to write_case_origin_metadata().
[in]source_project_rootArgument passed to write_case_origin_metadata().
[in]template_nameArgument passed to write_case_origin_metadata().
[in]existingArgument passed to write_case_origin_metadata().
[in]template_managed_filesArgument passed to write_case_origin_metadata().
Returns
Value returned by write_case_origin_metadata().

Definition at line 2788 of file core.py.

2789 existing: dict = None, template_managed_files=None):
2790 """!
2791 @brief Create or refresh case-origin metadata for repo-aware case maintenance commands.
2792 @param[in] case_dir Argument passed to `write_case_origin_metadata()`.
2793 @param[in] source_project_root Argument passed to `write_case_origin_metadata()`.
2794 @param[in] template_name Argument passed to `write_case_origin_metadata()`.
2795 @param[in] existing Argument passed to `write_case_origin_metadata()`.
2796 @param[in] template_managed_files Argument passed to `write_case_origin_metadata()`.
2797 @return Value returned by `write_case_origin_metadata()`.
2798 """
2799 payload = dict(existing or {})
2800 if "initialized_at" not in payload:
2801 payload["initialized_at"] = datetime.now().isoformat()
2802 payload["source_repo_root"] = os.path.abspath(source_project_root)
2803 if template_name:
2804 payload["template_name"] = template_name
2805 if template_managed_files is not None:
2806 payload["template_managed_files"] = sorted(set(str(p) for p in template_managed_files))
2807 payload["last_known_source_git_commit"] = get_git_commit(source_project_root)
2808 metadata_path = os.path.join(os.path.abspath(case_dir), CASE_ORIGIN_METADATA_FILENAME)
2809 write_json_file(metadata_path, payload)
2810 return metadata_path, payload
2811
2812
2813#: A `make` variable assignment such as `SYSTEM=cluster` or `CFLAGS+=-g`.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ make_args_include_explicit_goal()

bool picurv_cli.core.make_args_include_explicit_goal ( "list[str]"  make_args)

Return True when make args contain an explicit target rather than only options/assignments.

Parameters
[in]make_argsArgument passed to make_args_include_explicit_goal().
Returns
Value returned by make_args_include_explicit_goal().

Definition at line 2817 of file core.py.

2817def make_args_include_explicit_goal(make_args: "list[str]") -> bool:
2818 """!
2819 @brief Return True when make args contain an explicit target rather than only options/assignments.
2820 @param[in] make_args Argument passed to `make_args_include_explicit_goal()`.
2821 @return Value returned by `make_args_include_explicit_goal()`.
2822 """
2823 if not make_args:
2824 return False
2825
2826 options_with_value = {
2827 "-C", "-f", "-I", "-j", "-l", "-o", "-W",
2828 "--directory", "--file", "--makefile", "--include-dir", "--jobs",
2829 "--load-average", "--max-load", "--old-file", "--assume-old",
2830 "--what-if", "--new-file", "--assume-new",
2831 }
2832
2833 skip_next = False
2834 for token in make_args:
2835 if skip_next:
2836 skip_next = False
2837 continue
2838 if token in options_with_value:
2839 skip_next = True
2840 continue
2841 if token.startswith("-"):
2842 continue
2843 if _MAKE_ASSIGNMENT_PATTERN.match(token):
2844 continue
2845 return True
2846 return False
2847
2848
Here is the caller graph for this function:

◆ resolve_case_origin_context()

picurv_cli.core.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.

Parameters
[in]case_dir_hintArgument passed to resolve_case_origin_context().
[in]source_root_overrideArgument passed to resolve_case_origin_context().
[in]template_name_overrideArgument passed to resolve_case_origin_context().
Returns
Value returned by resolve_case_origin_context().

Definition at line 2849 of file core.py.

2849def resolve_case_origin_context(case_dir_hint: str = None, source_root_override: str = None, template_name_override: str = None):
2850 """!
2851 @brief Resolve case directory, source repo root, and optional template metadata.
2852 @param[in] case_dir_hint Argument passed to `resolve_case_origin_context()`.
2853 @param[in] source_root_override Argument passed to `resolve_case_origin_context()`.
2854 @param[in] template_name_override Argument passed to `resolve_case_origin_context()`.
2855 @return Value returned by `resolve_case_origin_context()`.
2856 """
2857 metadata_case_dir, metadata_path, metadata = load_case_origin_metadata(case_dir_hint)
2858
2859 if metadata_case_dir:
2860 case_dir = metadata_case_dir
2861 else:
2862 case_dir = os.path.abspath(case_dir_hint or os.getcwd())
2863
2864 source_project_root = source_root_override
2865 if source_project_root:
2866 source_project_root = os.path.abspath(source_project_root)
2867 elif isinstance(metadata, dict) and isinstance(metadata.get("source_repo_root"), str):
2868 source_project_root = os.path.abspath(metadata["source_repo_root"])
2869 else:
2870 source_project_root = discover_local_project_root(case_dir)
2871
2872 template_name = template_name_override
2873 if not template_name and isinstance(metadata, dict):
2874 template_name = metadata.get("template_name")
2875
2876 return {
2877 "case_dir": case_dir,
2878 "metadata_path": metadata_path,
2879 "metadata": metadata or {},
2880 "source_project_root": source_project_root,
2881 "template_name": template_name,
2882 }
2883
2884
Here is the call graph for this function:
Here is the caller graph for this function:

◆ require_project_root()

picurv_cli.core.require_project_root ( str  candidate,
str  purpose 
)

Validate that a source repo root was resolved and is structurally valid.

Parameters
[in]candidateArgument passed to require_project_root().
[in]purposeArgument passed to require_project_root().
Returns
Value returned by require_project_root().

Definition at line 2885 of file core.py.

2885def require_project_root(candidate: str, purpose: str):
2886 """!
2887 @brief Validate that a source repo root was resolved and is structurally valid.
2888 @param[in] candidate Argument passed to `require_project_root()`.
2889 @param[in] purpose Argument passed to `require_project_root()`.
2890 @return Value returned by `require_project_root()`.
2891 """
2892 if not candidate:
2893 raise ValueError(
2894 f"Could not determine the PICurv source repository for {purpose}. "
2895 "Run this command from an initialized case directory or pass --source-root."
2896 )
2897 candidate_abs = os.path.abspath(candidate)
2898 if not is_project_root(candidate_abs):
2899 raise ValueError(
2900 f"Resolved source repository for {purpose} is not a valid PICurv root: {candidate_abs}"
2901 )
2902 return candidate_abs
2903
2904
Here is the call graph for this function:
Here is the caller graph for this function:

◆ require_existing_case_dir()

picurv_cli.core.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.

Parameters
[in]case_dirArgument passed to require_existing_case_dir().
[in]purposeArgument passed to require_existing_case_dir().
[in]source_project_rootArgument passed to require_existing_case_dir().
Returns
Value returned by require_existing_case_dir().

Definition at line 2905 of file core.py.

2905def require_existing_case_dir(case_dir: str, purpose: str, source_project_root: str = None):
2906 """!
2907 @brief Validate that a target case directory exists and is not the source repo root.
2908 @param[in] case_dir Argument passed to `require_existing_case_dir()`.
2909 @param[in] purpose Argument passed to `require_existing_case_dir()`.
2910 @param[in] source_project_root Argument passed to `require_existing_case_dir()`.
2911 @return Value returned by `require_existing_case_dir()`.
2912 """
2913 if not case_dir:
2914 raise ValueError(f"Could not determine the case directory for {purpose}. Pass --case-dir.")
2915 case_dir_abs = os.path.abspath(case_dir)
2916 if not os.path.isdir(case_dir_abs):
2917 raise ValueError(f"Case directory for {purpose} does not exist: {case_dir_abs}")
2918 if source_project_root and os.path.abspath(source_project_root) == case_dir_abs:
2919 raise ValueError(
2920 f"Refusing to run {purpose} against the source repository root itself: {case_dir_abs}"
2921 )
2922 return case_dir_abs
2923
2924
Here is the caller graph for this function:

◆ resolve_template_directory()

picurv_cli.core.resolve_template_directory ( str  source_project_root,
str  template_name 
)

Resolve an example template directory inside the source repository.

Parameters
[in]source_project_rootArgument passed to resolve_template_directory().
[in]template_nameArgument passed to resolve_template_directory().
Returns
Value returned by resolve_template_directory().

Definition at line 2925 of file core.py.

2925def resolve_template_directory(source_project_root: str, template_name: str):
2926 """!
2927 @brief Resolve an example template directory inside the source repository.
2928 @param[in] source_project_root Argument passed to `resolve_template_directory()`.
2929 @param[in] template_name Argument passed to `resolve_template_directory()`.
2930 @return Value returned by `resolve_template_directory()`.
2931 """
2932 if not template_name:
2933 raise ValueError(
2934 "Template name is required for config sync. Re-run with --template-name or from a case initialized by current picurv."
2935 )
2936 template_dir = os.path.join(source_project_root, "examples", template_name)
2937 if not os.path.isdir(template_dir):
2938 raise ValueError(f"Case template '{template_name}' not found at '{template_dir}'")
2939 return template_dir
2940
2941
Here is the caller graph for this function:

◆ list_template_relative_files()

picurv_cli.core.list_template_relative_files ( str  template_dir,
  excluded_rel_paths = None 
)

List all files in a template directory as case-relative paths.

Parameters
[in]template_dirArgument passed to list_template_relative_files().
[in]excluded_rel_pathsArgument passed to list_template_relative_files().
Returns
Value returned by list_template_relative_files().

Definition at line 2942 of file core.py.

2942def list_template_relative_files(template_dir: str, excluded_rel_paths=None):
2943 """!
2944 @brief List all files in a template directory as case-relative paths.
2945 @param[in] template_dir Argument passed to `list_template_relative_files()`.
2946 @param[in] excluded_rel_paths Argument passed to `list_template_relative_files()`.
2947 @return Value returned by `list_template_relative_files()`.
2948 """
2949 template_dir_abs = os.path.abspath(template_dir)
2950 if not os.path.isdir(template_dir_abs):
2951 raise ValueError(f"Template directory not found: {template_dir_abs}")
2952 excluded = set(excluded_rel_paths or [])
2953 relative_paths = []
2954 for root, _, files in os.walk(template_dir_abs):
2955 rel_root = os.path.relpath(root, template_dir_abs)
2956 for filename in sorted(files):
2957 rel_path = filename if rel_root == "." else os.path.join(rel_root, filename)
2958 if rel_path in excluded:
2959 continue
2960 relative_paths.append(rel_path)
2961 return relative_paths
2962
2963
Here is the caller graph for this function:

◆ render_template_workspace()

dict picurv_cli.core.render_template_workspace ( str  template_dir,
str  template_name,
str  destination 
)

Render an example template into the canonical workspace layout at a new path.

This is the one transformation init applies to a template: copy it, drop the execution example, then relocate and rewrite its files through organize_initialized_workspace(). sync-config and status-source render the current template the same way, so all three agree on where each file lives and what it contains.

Parameters
[in]template_dirExample template directory to render.
[in]template_nameExample template identity.
[in]destinationPath to render into; it must not exist.
Returns
The layout mapping returned by organize_initialized_workspace().

Definition at line 2964 of file core.py.

2964def render_template_workspace(template_dir: str, template_name: str, destination: str) -> dict:
2965 """!
2966 @brief Render an example template into the canonical workspace layout at a new path.
2967 @details This is the one transformation `init` applies to a template: copy it, drop the
2968 execution example, then relocate and rewrite its files through
2969 organize_initialized_workspace(). `sync-config` and `status-source` render the
2970 current template the same way, so all three agree on where each file lives and
2971 what it contains.
2972 @param[in] template_dir Example template directory to render.
2973 @param[in] template_name Example template identity.
2974 @param[in] destination Path to render into; it must not exist.
2975 @return The layout mapping returned by organize_initialized_workspace().
2976 """
2977 shutil.copytree(template_dir, destination)
2978 copied_runtime_example = os.path.join(destination, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
2979 if os.path.isfile(copied_runtime_example):
2980 os.remove(copied_runtime_example)
2981 return organize_initialized_workspace(destination, template_name, source_template_root=template_dir)
2982
2983
Here is the call graph for this function:
Here is the caller graph for this function:

◆ list_rendered_template_files()

list picurv_cli.core.list_rendered_template_files ( str  rendered_root)

List the template-managed files of a rendered workspace, workspace-relative.

The workspace identity file is excluded: it records when and where the workspace was created, which is the workspace's own state, not the template's.

Parameters
[in]rendered_rootRoot produced by render_template_workspace().
Returns
Sorted workspace-relative paths.

Definition at line 2984 of file core.py.

2984def list_rendered_template_files(rendered_root: str) -> list:
2985 """!
2986 @brief List the template-managed files of a rendered workspace, workspace-relative.
2987 @details The workspace identity file is excluded: it records when and where the
2988 workspace was created, which is the workspace's own state, not the template's.
2989 @param[in] rendered_root Root produced by render_template_workspace().
2990 @return Sorted workspace-relative paths.
2991 """
2992 return sorted(
2993 rel for rel in list_template_relative_files(rendered_root)
2994 if rel != WORKSPACE_CONFIG_FILENAME
2995 )
2996
2997
Here is the call graph for this function:
Here is the caller graph for this function:

◆ list_source_binaries()

picurv_cli.core.list_source_binaries ( str  source_project_root)

List binary artifacts currently available in the source repo bin directory.

Parameters
[in]source_project_rootArgument passed to list_source_binaries().
Returns
Value returned by list_source_binaries().

Definition at line 2998 of file core.py.

2998def list_source_binaries(source_project_root: str):
2999 """!
3000 @brief List binary artifacts currently available in the source repo bin directory.
3001 @param[in] source_project_root Argument passed to `list_source_binaries()`.
3002 @return Value returned by `list_source_binaries()`.
3003 """
3004 source_bin_dir = os.path.join(os.path.abspath(source_project_root), "bin")
3005 if not os.path.isdir(source_bin_dir):
3006 raise ValueError(f"Source bin directory not found: {source_bin_dir}. Run 'picurv build' first.")
3007 binaries = sorted(
3008 f for f in os.listdir(source_bin_dir)
3009 if os.path.isfile(os.path.join(source_bin_dir, f)) and f != "picurv"
3010 )
3011 if not binaries:
3012 raise ValueError(f"Source bin directory contains no files: {source_bin_dir}")
3013 return source_bin_dir, binaries
3014
3015
Here is the caller graph for this function:

◆ sync_case_binaries()

picurv_cli.core.sync_case_binaries ( str  case_dir,
str  source_project_root 
)

Copy current source-repo binaries into a case directory for version-pinning.

Parameters
[in]case_dirArgument passed to sync_case_binaries().
[in]source_project_rootArgument passed to sync_case_binaries().
Returns
Value returned by sync_case_binaries().

Definition at line 3016 of file core.py.

3016def sync_case_binaries(case_dir: str, source_project_root: str):
3017 """!
3018 @brief Copy current source-repo binaries into a case directory for version-pinning.
3019 @param[in] case_dir Argument passed to `sync_case_binaries()`.
3020 @param[in] source_project_root Argument passed to `sync_case_binaries()`.
3021 @return Value returned by `sync_case_binaries()`.
3022 """
3023 case_dir_abs = os.path.abspath(case_dir)
3024 os.makedirs(case_dir_abs, exist_ok=True)
3025 source_bin_dir, binaries = list_source_binaries(source_project_root)
3026 copied = []
3027 for binary_name in binaries:
3028 source_path = os.path.join(source_bin_dir, binary_name)
3029 dest_path = os.path.join(case_dir_abs, binary_name)
3030 shutil.copy2(source_path, dest_path)
3031 copied.append(dest_path)
3032 return copied
3033
3034
Here is the call graph for this function:
Here is the caller graph for this function:

◆ sync_case_template_files()

picurv_cli.core.sync_case_template_files ( str  case_dir,
str  template_dir,
bool   overwrite = False,
bool   prune = False,
  managed_rel_paths = None,
str   template_name = None 
)

Sync template files into a case directory, preserving modified files unless overwrite is requested.

Parameters
[in]case_dirArgument passed to sync_case_template_files().
[in]template_dirArgument passed to sync_case_template_files().
[in]overwriteArgument passed to sync_case_template_files().
[in]pruneArgument passed to sync_case_template_files().
[in]managed_rel_pathsArgument passed to sync_case_template_files().
[in]template_nameTemplate identity; defaults to the template directory name.
Returns
Value returned by sync_case_template_files().

Files are compared with a fresh rendering of the template in the workspace layout init creates, so a case file is matched with the template file it was made from, under its workspace name and rewritten contents.

Definition at line 3035 of file core.py.

3036 prune: bool = False, managed_rel_paths=None, template_name: str = None):
3037 """!
3038 @brief Sync template files into a case directory, preserving modified files unless overwrite is requested.
3039 @param[in] case_dir Argument passed to `sync_case_template_files()`.
3040 @param[in] template_dir Argument passed to `sync_case_template_files()`.
3041 @param[in] overwrite Argument passed to `sync_case_template_files()`.
3042 @param[in] prune Argument passed to `sync_case_template_files()`.
3043 @param[in] managed_rel_paths Argument passed to `sync_case_template_files()`.
3044 @param[in] template_name Template identity; defaults to the template directory name.
3045 @return Value returned by `sync_case_template_files()`.
3046 @details Files are compared with a fresh rendering of the template in the workspace
3047 layout `init` creates, so a case file is matched with the template file it
3048 was made from, under its workspace name and rewritten contents.
3049 """
3050 case_dir_abs = os.path.abspath(case_dir)
3051 template_dir_abs = os.path.abspath(template_dir)
3052 if not os.path.isdir(template_dir_abs):
3053 raise ValueError(f"Template directory not found: {template_dir_abs}")
3054 template_name = template_name or os.path.basename(template_dir_abs)
3055
3056 summary = {
3057 "copied": [],
3058 "overwritten": [],
3059 "skipped_modified": [],
3060 "unchanged": [],
3061 "pruned": [],
3062 "prune_requested_without_tracking": False,
3063 }
3064 with tempfile.TemporaryDirectory(prefix="picurv-template-") as scratch:
3065 rendered_root = os.path.join(scratch, os.path.basename(case_dir_abs) or "workspace")
3066 render_template_workspace(template_dir_abs, template_name, rendered_root)
3067 current_template_files = list_rendered_template_files(rendered_root)
3068 for rel_path in current_template_files:
3069 src_path = os.path.join(rendered_root, rel_path)
3070 dest_path = os.path.join(case_dir_abs, rel_path)
3071 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
3072
3073 if not os.path.exists(dest_path):
3074 shutil.copy2(src_path, dest_path)
3075 summary["copied"].append(dest_path)
3076 continue
3077
3078 if filecmp.cmp(src_path, dest_path, shallow=False):
3079 summary["unchanged"].append(dest_path)
3080 continue
3081
3082 if overwrite:
3083 shutil.copy2(src_path, dest_path)
3084 summary["overwritten"].append(dest_path)
3085 else:
3086 summary["skipped_modified"].append(dest_path)
3087 current_template_set = set(current_template_files)
3088
3089 managed_set = set(managed_rel_paths or [])
3090 if prune:
3091 if not managed_set:
3092 summary["prune_requested_without_tracking"] = True
3093 for rel_path in sorted(managed_set - current_template_set):
3094 dest_path = os.path.join(case_dir_abs, rel_path)
3095 if os.path.isfile(dest_path):
3096 os.remove(dest_path)
3097 summary["pruned"].append(dest_path)
3098
3099 summary["template_managed_files"] = current_template_files
3100 return summary
3101
3102
Here is the call graph for this function:
Here is the caller graph for this function:

◆ compute_case_source_status()

picurv_cli.core.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.

Parameters
[in]case_dirArgument passed to compute_case_source_status().
[in]source_project_rootArgument passed to compute_case_source_status().
[in]template_nameArgument passed to compute_case_source_status().
[in]metadataArgument passed to compute_case_source_status().
Returns
Value returned by compute_case_source_status().

Definition at line 3103 of file core.py.

3103def compute_case_source_status(case_dir: str, source_project_root: str, template_name: str = None, metadata: dict = None):
3104 """!
3105 @brief Compute source/case drift across commits, binaries, and template-managed files.
3106 @param[in] case_dir Argument passed to `compute_case_source_status()`.
3107 @param[in] source_project_root Argument passed to `compute_case_source_status()`.
3108 @param[in] template_name Argument passed to `compute_case_source_status()`.
3109 @param[in] metadata Argument passed to `compute_case_source_status()`.
3110 @return Value returned by `compute_case_source_status()`.
3111 """
3112 case_dir_abs = os.path.abspath(case_dir)
3113 source_root_abs = os.path.abspath(source_project_root)
3114 metadata = metadata or {}
3115 status = {
3116 "case_dir": case_dir_abs,
3117 "source_repo_root": source_root_abs,
3118 "metadata_present": bool(metadata),
3119 "template_name": template_name,
3120 "last_known_source_git_commit": metadata.get("last_known_source_git_commit"),
3121 "current_source_git_commit": get_git_commit(source_root_abs),
3122 }
3123 status["source_commit_changed"] = (
3124 bool(status["last_known_source_git_commit"])
3125 and bool(status["current_source_git_commit"])
3126 and status["last_known_source_git_commit"] != status["current_source_git_commit"]
3127 )
3128
3129 binary_status = {
3130 "source_bin_present": False,
3131 "source_bin_missing": [],
3132 "case_bin_missing": [],
3133 "case_bin_different": [],
3134 "case_bin_current": [],
3135 }
3136 try:
3137 source_bin_dir, binaries = list_source_binaries(source_root_abs)
3138 binary_status["source_bin_present"] = True
3139 for binary_name in binaries:
3140 source_path = os.path.join(source_bin_dir, binary_name)
3141 case_path = os.path.join(case_dir_abs, binary_name)
3142 if not os.path.isfile(case_path):
3143 binary_status["case_bin_missing"].append(binary_name)
3144 elif filecmp.cmp(source_path, case_path, shallow=False):
3145 binary_status["case_bin_current"].append(binary_name)
3146 else:
3147 binary_status["case_bin_different"].append(binary_name)
3148 except ValueError as exc:
3149 binary_status["source_bin_missing"].append(str(exc))
3150 status["binaries"] = binary_status
3151
3152 config_status = {
3153 "template_available": False,
3154 "template_files": [],
3155 "case_missing_files": [],
3156 "case_modified_files": [],
3157 "case_current_files": [],
3158 "template_removed_since_last_sync": [],
3159 "tracking_available": isinstance(metadata.get("template_managed_files"), list),
3160 }
3161 if template_name:
3162 try:
3163 template_dir = resolve_template_directory(source_root_abs, template_name)
3164 with tempfile.TemporaryDirectory(prefix="picurv-template-") as scratch:
3165 rendered_root = os.path.join(scratch, os.path.basename(case_dir_abs) or "workspace")
3166 render_template_workspace(template_dir, template_name, rendered_root)
3167 template_files = list_rendered_template_files(rendered_root)
3168 config_status["template_available"] = True
3169 config_status["template_files"] = template_files
3170 for rel_path in template_files:
3171 src_path = os.path.join(rendered_root, rel_path)
3172 case_path = os.path.join(case_dir_abs, rel_path)
3173 if not os.path.isfile(case_path):
3174 config_status["case_missing_files"].append(rel_path)
3175 elif filecmp.cmp(src_path, case_path, shallow=False):
3176 config_status["case_current_files"].append(rel_path)
3177 else:
3178 config_status["case_modified_files"].append(rel_path)
3179 managed_files = metadata.get("template_managed_files")
3180 if isinstance(managed_files, list):
3181 config_status["template_removed_since_last_sync"] = sorted(set(managed_files) - set(template_files))
3182 except ValueError:
3183 pass
3184 status["config"] = config_status
3185
3186 case_runtime_cfg = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
3187 repo_runtime_seed = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
3188 runtime_status = {
3189 "case_config_present": os.path.isfile(case_runtime_cfg),
3190 "repo_seed_present": os.path.isfile(repo_runtime_seed),
3191 "case_matches_repo_seed": False,
3192 }
3193 if runtime_status["case_config_present"] and runtime_status["repo_seed_present"]:
3194 runtime_status["case_matches_repo_seed"] = filecmp.cmp(
3195 case_runtime_cfg,
3196 repo_runtime_seed,
3197 shallow=False,
3198 )
3199 status["runtime_execution"] = runtime_status
3200 return status
3201
3202
Here is the call graph for this function:
Here is the caller graph for this function:

◆ print_case_source_status()

picurv_cli.core.print_case_source_status ( dict  status)

Render human-readable source/case drift details.

Parameters
[in]statusArgument passed to print_case_source_status().

Definition at line 3203 of file core.py.

3203def print_case_source_status(status: dict):
3204 """!
3205 @brief Render human-readable source/case drift details.
3206 @param[in] status Argument passed to `print_case_source_status()`.
3207 """
3208 print(f"[INFO] Case directory : {status['case_dir']}")
3209 print(f"[INFO] Source repo : {status['source_repo_root']}")
3210 print(f"[INFO] Template : {status.get('template_name') or '(unknown)'}")
3211 if status.get("last_known_source_git_commit"):
3212 print(f"[INFO] Last synced commit : {status['last_known_source_git_commit']}")
3213 if status.get("current_source_git_commit"):
3214 print(f"[INFO] Current src commit : {status['current_source_git_commit']}")
3215 print(f"[INFO] Source changed : {'yes' if status.get('source_commit_changed') else 'no'}")
3216
3217 binaries = status["binaries"]
3218 if binaries["source_bin_present"] and not (binaries["case_bin_current"] or binaries["case_bin_different"]):
3219 print("[INFO] Binaries : not pinned (the installation's executables are used)")
3220 elif binaries["source_bin_present"]:
3221 print(
3222 f"[INFO] Binaries : current={len(binaries['case_bin_current'])} "
3223 f"changed={len(binaries['case_bin_different'])} missing={len(binaries['case_bin_missing'])}"
3224 )
3225 else:
3226 print("[INFO] Binaries : source bin/ unavailable")
3227
3228 config = status["config"]
3229 if config["template_available"]:
3230 print(
3231 f"[INFO] Template files : current={len(config['case_current_files'])} "
3232 f"modified={len(config['case_modified_files'])} missing={len(config['case_missing_files'])}"
3233 )
3234 if config["tracking_available"]:
3235 print(f"[INFO] Prune candidates : {len(config['template_removed_since_last_sync'])}")
3236 else:
3237 print("[INFO] Prune candidates : tracking unavailable")
3238 elif status.get("template_name"):
3239 print("[INFO] Template files : template unavailable in source repo")
3240
3241 runtime_cfg = status.get("runtime_execution", {})
3242 print(
3243 f"[INFO] Runtime config : case={'yes' if runtime_cfg.get('case_config_present') else 'no'} "
3244 f"repo-seed={'yes' if runtime_cfg.get('repo_seed_present') else 'no'} "
3245 f"matches-repo-seed={'yes' if runtime_cfg.get('case_matches_repo_seed') else 'no'}"
3246 )
3247
3248
Here is the caller graph for this function:

◆ status_source_command()

picurv_cli.core.status_source_command (   args)

Report source/case drift for an initialized case directory.

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 3249 of file core.py.

3249def status_source_command(args):
3250 """!
3251 @brief Report source/case drift for an initialized case directory.
3252 @param[in] args Command-line style argument list supplied to the function.
3253 """
3254 try:
3255 context = resolve_case_origin_context(
3256 case_dir_hint=getattr(args, "case_dir", None),
3257 source_root_override=getattr(args, "source_root", None),
3258 template_name_override=getattr(args, "template_name", None),
3259 )
3260 source_project_root = require_project_root(context["source_project_root"], "status-source")
3261 case_dir = require_existing_case_dir(context["case_dir"], "status-source", source_project_root)
3262 status = compute_case_source_status(
3263 case_dir,
3264 source_project_root,
3265 template_name=context.get("template_name"),
3266 metadata=context.get("metadata"),
3267 )
3268 except ValueError as exc:
3269 print(f"[FATAL] {exc}", file=sys.stderr)
3270 sys.exit(1)
3271
3272 if getattr(args, "output_format", "text") == "json":
3273 print(json.dumps(status, indent=2, sort_keys=True))
3274 return
3275 print_case_source_status(status)
3276
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_path()

str picurv_cli.core.resolve_path ( str  anchor_file,
str  candidate 
)

Resolve a potentially relative path against a source YAML file path.

Parameters
[in]anchor_fileArgument passed to resolve_path().
[in]candidateArgument passed to resolve_path().
Returns
Value returned by resolve_path().

Definition at line 3277 of file core.py.

3277def resolve_path(anchor_file: str, candidate: str) -> str:
3278 """!
3279 @brief Resolve a potentially relative path against a source YAML file path.
3280 @param[in] anchor_file Argument passed to `resolve_path()`.
3281 @param[in] candidate Argument passed to `resolve_path()`.
3282 @return Value returned by `resolve_path()`.
3283 """
3284 if candidate is None:
3285 return None
3286 return resolve_workspace_path(anchor_file, candidate)
3287
3288
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _mapping_value_with_aliases()

picurv_cli.core._mapping_value_with_aliases ( dict  mapping,
*  keys,
  default = None 
)
protected

Return the first defined value from a mapping across alias keys.

Parameters
[in]mappingArgument passed to _mapping_value_with_aliases().
[in]defaultArgument passed to _mapping_value_with_aliases().
[in]keysArgument passed to _mapping_value_with_aliases().
Returns
Value returned by _mapping_value_with_aliases().

Definition at line 3309 of file core.py.

3309def _mapping_value_with_aliases(mapping: dict, *keys, default=None):
3310 """!
3311 @brief Return the first defined value from a mapping across alias keys.
3312 @param[in] mapping Argument passed to `_mapping_value_with_aliases()`.
3313 @param[in] default Argument passed to `_mapping_value_with_aliases()`.
3314 @param[in] keys Argument passed to `_mapping_value_with_aliases()`.
3315 @return Value returned by `_mapping_value_with_aliases()`.
3316 """
3317 if not isinstance(mapping, dict):
3318 return default
3319 for key in keys:
3320 if key in mapping:
3321 return mapping.get(key)
3322 return default
3323
3324
Here is the caller graph for this function:

◆ get_post_run_control_value()

picurv_cli.core.get_post_run_control_value ( dict  post_cfg,
str  canonical_key,
  default = None 
)

Resolve post run_control values with backwards-compatible legacy aliases.

Parameters
[in]post_cfgArgument passed to get_post_run_control_value().
[in]canonical_keyArgument passed to get_post_run_control_value().
[in]defaultArgument passed to get_post_run_control_value().
Returns
Value returned by get_post_run_control_value().

Definition at line 3325 of file core.py.

3325def get_post_run_control_value(post_cfg: dict, canonical_key: str, default=None):
3326 """!
3327 @brief Resolve post run_control values with backwards-compatible legacy aliases.
3328 @param[in] post_cfg Argument passed to `get_post_run_control_value()`.
3329 @param[in] canonical_key Argument passed to `get_post_run_control_value()`.
3330 @param[in] default Argument passed to `get_post_run_control_value()`.
3331 @return Value returned by `get_post_run_control_value()`.
3332 """
3333 aliases = POST_RUN_CONTROL_ALIASES.get(canonical_key, (canonical_key,))
3334 rc = post_cfg.get("run_control", {})
3335 return _mapping_value_with_aliases(rc, *aliases, default=default)
3336
3337
Here is the call graph for this function:
Here is the caller graph for this function:

◆ warn_on_grid_generator_hyphen_keys()

None picurv_cli.core.warn_on_grid_generator_hyphen_keys ( dict  generator,
str  case_path,
list  warnings 
)

Warn when grid.generator uses unsupported hyphenated wrapper keys.

Parameters
[in]generatorgrid.generator mapping from case.yml.
[in]case_pathCase file path for diagnostics.
[in,out]warningsWarning list to append to.

Definition at line 3338 of file core.py.

3338def warn_on_grid_generator_hyphen_keys(generator: dict, case_path: str, warnings: list) -> None:
3339 """!
3340 @brief Warn when grid.generator uses unsupported hyphenated wrapper keys.
3341 @param[in] generator grid.generator mapping from case.yml.
3342 @param[in] case_path Case file path for diagnostics.
3343 @param[in,out] warnings Warning list to append to.
3344 """
3345 if not isinstance(generator, dict):
3346 return
3347 for bad_key, expected_key in GRID_GENERATOR_HYPHEN_KEY_HINTS.items():
3348 if bad_key in generator and bad_key != expected_key:
3349 warnings.append(
3350 f"{case_path}: grid.generator.{bad_key} is ignored; use grid.generator.{expected_key}."
3351 )
3352
3353
3354#: Destination keys a generated initial condition once accepted. Its payload, summary and
3355#: spectrum always land at fixed paths in the asset store, so these were read and ignored.
Here is the caller graph for this function:

◆ retired_destination_message()

str picurv_cli.core.retired_destination_message ( str  label,
str  key 
)

Explain why a generator may not choose its own output destination.

Parameters
[in]labelDotted configuration path of the mapping carrying the key.
[in]keyThe refused destination key.
Returns
The refusal message, shared by every generator that once took one.

Definition at line 3359 of file core.py.

3359def retired_destination_message(label: str, key: str) -> str:
3360 """!
3361 @brief Explain why a generator may not choose its own output destination.
3362 @param[in] label Dotted configuration path of the mapping carrying the key.
3363 @param[in] key The refused destination key.
3364 @return The refusal message, shared by every generator that once took one.
3365 """
3366 return (
3367 f"'{label}.{key}' is no longer accepted. PICurv chooses where generated artifacts "
3368 "go; the published asset carries the payload, its preview, and its validation record."
3369 )
3370
3371
Here is the caller graph for this function:

◆ reject_generator_destination_keys()

list picurv_cli.core.reject_generator_destination_keys (   generator,
str  case_path,
str  label 
)

Reject generator settings that try to choose their own output destination.

Parameters
[in]generatorGenerator mapping from the case configuration.
[in]case_pathCase file path for diagnostics.
[in]labelDotted configuration path being checked, for the message.
Returns
List of error strings.

Definition at line 3372 of file core.py.

3372def reject_generator_destination_keys(generator, case_path: str, label: str) -> list:
3373 """!
3374 @brief Reject generator settings that try to choose their own output destination.
3375 @param[in] generator Generator mapping from the case configuration.
3376 @param[in] case_path Case file path for diagnostics.
3377 @param[in] label Dotted configuration path being checked, for the message.
3378 @return List of error strings.
3379 """
3380 if not isinstance(generator, dict):
3381 return []
3382 return [
3383 f" {case_path}: {retired_destination_message(label, key)}"
3384 for key in RETIRED_GENERATOR_DESTINATION_KEYS if key in generator
3385 ]
3386
3387
Here is the caller graph for this function:

◆ get_post_source_data()

picurv_cli.core.get_post_source_data ( dict  post_cfg)

Return source_data as a mapping when valid, else an empty mapping.

Parameters
[in]post_cfgArgument passed to get_post_source_data().
Returns
Value returned by get_post_source_data().

Definition at line 3388 of file core.py.

3388def get_post_source_data(post_cfg: dict):
3389 """!
3390 @brief Return source_data as a mapping when valid, else an empty mapping.
3391 @param[in] post_cfg Argument passed to `get_post_source_data()`.
3392 @return Value returned by `get_post_source_data()`.
3393 """
3394 source_cfg = post_cfg.get("source_data", {})
3395 if isinstance(source_cfg, dict):
3396 return source_cfg
3397 return {}
3398
3399
Here is the caller graph for this function:

◆ get_post_source_directory_template()

str picurv_cli.core.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.

Parameters
[in]post_cfgArgument passed to get_post_source_directory_template().
[in]defaultArgument passed to get_post_source_directory_template().
Returns
Value returned by get_post_source_directory_template().

Definition at line 3400 of file core.py.

3400def get_post_source_directory_template(post_cfg: dict, default: str = "<solver_output_dir>") -> str:
3401 """!
3402 @brief Resolve the source directory template from source_data with a safe default.
3403 @param[in] post_cfg Argument passed to `get_post_source_directory_template()`.
3404 @param[in] default Argument passed to `get_post_source_directory_template()`.
3405 @return Value returned by `get_post_source_directory_template()`.
3406 """
3407 return get_post_source_data(post_cfg).get("directory", default)
3408
3409
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_post_input_extensions()

picurv_cli.core.get_post_input_extensions ( dict  post_cfg)

Return post input_extensions, preferring io.

  • and tolerating legacy source_data.* placement.
    Parameters
    [in]post_cfgArgument passed to get_post_input_extensions().
    Returns
    Value returned by get_post_input_extensions().

Definition at line 3410 of file core.py.

3410def get_post_input_extensions(post_cfg: dict):
3411 """!
3412 @brief Return post input_extensions, preferring io.* and tolerating legacy source_data.* placement.
3413 @param[in] post_cfg Argument passed to `get_post_input_extensions()`.
3414 @return Value returned by `get_post_input_extensions()`.
3415 """
3416 io_cfg = post_cfg.get("io", {})
3417 io_ext = io_cfg.get("input_extensions") if isinstance(io_cfg, dict) else None
3418 if isinstance(io_ext, dict):
3419 return io_ext
3420
3421 source_ext = get_post_source_data(post_cfg).get("input_extensions")
3422 if isinstance(source_ext, dict):
3423 return source_ext
3424
3425 return {}
3426
3427
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_post_statistics_task_tokens()

picurv_cli.core.get_post_statistics_task_tokens ( dict  post_cfg)

Return normalized statistics pipeline tokens that will be written into post.run.

Parameters
[in]post_cfgArgument passed to get_post_statistics_task_tokens().
Returns
Value returned by get_post_statistics_task_tokens().

Definition at line 3428 of file core.py.

3428def get_post_statistics_task_tokens(post_cfg: dict):
3429 """!
3430 @brief Return normalized statistics pipeline tokens that will be written into post.run.
3431 @param[in] post_cfg Argument passed to `get_post_statistics_task_tokens()`.
3432 @return Value returned by `get_post_statistics_task_tokens()`.
3433 """
3434 stats_cfg = post_cfg.get("statistics_pipeline")
3435 stats_entries = []
3436 if isinstance(stats_cfg, list):
3437 stats_entries = stats_cfg
3438 elif isinstance(stats_cfg, dict):
3439 stats_entries = stats_cfg.get("tasks", [])
3440
3441 tokens = []
3442 for entry in stats_entries:
3443 if isinstance(entry, str):
3444 task_name = entry
3445 elif isinstance(entry, dict):
3446 task_name = entry.get("task")
3447 else:
3448 continue
3449 try:
3450 tokens.append(normalize_statistics_task(task_name))
3451 except ValueError:
3452 continue
3453 return tokens
3454
3455
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_monitor_output_directory()

str picurv_cli.core.get_monitor_output_directory ( dict  monitor_cfg,
str   default = "output" 
)

Resolve the solver output root from monitor.yml, preserving the default layout.

Parameters
[in]monitor_cfgArgument passed to get_monitor_output_directory().
[in]defaultArgument passed to get_monitor_output_directory().
Returns
Value returned by get_monitor_output_directory().

Definition at line 3456 of file core.py.

3456def get_monitor_output_directory(monitor_cfg: dict, default: str = "output") -> str:
3457 """!
3458 @brief Resolve the solver output root from monitor.yml, preserving the default layout.
3459 @param[in] monitor_cfg Argument passed to `get_monitor_output_directory()`.
3460 @param[in] default Argument passed to `get_monitor_output_directory()`.
3461 @return Value returned by `get_monitor_output_directory()`.
3462 """
3463 del monitor_cfg, default
3464 return CANONICAL_RUN_PATHS["output"]
3465
3466

◆ get_post_statistics_output_prefix()

str picurv_cli.core.get_post_statistics_output_prefix ( dict  post_cfg,
str   default = "Stats" 
)

Resolve the statistics CSV prefix, preserving legacy top-level override support.

Parameters
[in]post_cfgArgument passed to get_post_statistics_output_prefix().
[in]defaultArgument passed to get_post_statistics_output_prefix().
Returns
Value returned by get_post_statistics_output_prefix().

Definition at line 3467 of file core.py.

3467def get_post_statistics_output_prefix(post_cfg: dict, default: str = "Stats") -> str:
3468 """!
3469 @brief Resolve the statistics CSV prefix, preserving legacy top-level override support.
3470 @param[in] post_cfg Argument passed to `get_post_statistics_output_prefix()`.
3471 @param[in] default Argument passed to `get_post_statistics_output_prefix()`.
3472 @return Value returned by `get_post_statistics_output_prefix()`.
3473 """
3474 stats_cfg = post_cfg.get("statistics_pipeline")
3475 if isinstance(stats_cfg, dict):
3476 prefix = stats_cfg.get("output_prefix")
3477 if isinstance(prefix, str) and prefix.strip():
3478 return prefix.strip()
3479
3480 legacy_prefix = post_cfg.get("statistics_output_prefix")
3481 if isinstance(legacy_prefix, str) and legacy_prefix.strip():
3482 return legacy_prefix.strip()
3483
3484 return default
3485
3486
Here is the caller graph for this function:

◆ resolve_post_statistics_output_prefix()

str picurv_cli.core.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.

Parameters
[in]post_cfgArgument passed to resolve_post_statistics_output_prefix().
[in]monitor_cfgOptional monitor configuration used to anchor the default statistics home.
[in]defaultArgument passed to resolve_post_statistics_output_prefix().
Returns
Value returned by resolve_post_statistics_output_prefix().

Definition at line 3487 of file core.py.

3487def resolve_post_statistics_output_prefix(post_cfg: dict, monitor_cfg=None, default: str = "Stats") -> str:
3488 """!
3489 @brief Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
3490 @param[in] post_cfg Argument passed to `resolve_post_statistics_output_prefix()`.
3491 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
3492 @param[in] default Argument passed to `resolve_post_statistics_output_prefix()`.
3493 @return Value returned by `resolve_post_statistics_output_prefix()`.
3494 """
3495 prefix = get_post_statistics_output_prefix(post_cfg, default=default).strip()
3496 if os.path.isabs(prefix):
3497 return prefix
3498
3499 if os.path.dirname(prefix):
3500 return prefix
3501
3502 del monitor_cfg
3503 return os.path.join(CANONICAL_RUN_PATHS["statistics"], prefix)
3504
3505
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_post_statistics_output_artifacts()

picurv_cli.core.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.

Parameters
[in]post_cfgArgument passed to get_post_statistics_output_artifacts().
[in]run_dirArgument passed to get_post_statistics_output_artifacts().
[in]monitor_cfgOptional monitor configuration used to anchor the default statistics home.
Returns
Value returned by get_post_statistics_output_artifacts().

Definition at line 3506 of file core.py.

3506def get_post_statistics_output_artifacts(post_cfg: dict, run_dir: str, monitor_cfg=None):
3507 """!
3508 @brief Predict statistics CSV output paths relative to the postprocessor runtime cwd.
3509 @param[in] post_cfg Argument passed to `get_post_statistics_output_artifacts()`.
3510 @param[in] run_dir Argument passed to `get_post_statistics_output_artifacts()`.
3511 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
3512 @return Value returned by `get_post_statistics_output_artifacts()`.
3513 """
3514 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
3515 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
3516 token_to_suffix = {
3517 "ComputeMSD": "_msd.csv",
3518 }
3519 prefix = resolve_post_statistics_output_prefix(post_cfg, monitor_cfg)
3520 if os.path.isabs(prefix):
3521 base_path = os.path.abspath(prefix)
3522 else:
3523 base_path = os.path.abspath(os.path.join(run_dir, prefix))
3524
3525 output_paths = []
3526 for token in get_post_statistics_task_tokens(post_cfg):
3527 suffix = token_to_suffix.get(token)
3528 if suffix:
3529 output_paths.append(base_path + suffix)
3530
3531 return list(dict.fromkeys(output_paths))
3532
3533
3534#: Spectra tasks a post recipe may request, with the preconditions each needs.
3535#:
3536#: `requires_uniform_cartesian` is checked against the staged PICGRID and
3537#: `requires_periodic_geometric` against the resolved boundary conditions, both
3538#: before any field is read. A task whose preconditions the case cannot meet is
3539#: refused at validation rather than producing a curve with no meaning: a spectrum
3540#: needs the transform direction to be uniformly spaced, periodic, and statistically
3541#: homogeneous. Wall-bounded Cartesian cases may transform only their periodic axes.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_scalar_transport()

dict picurv_cli.core.resolve_scalar_transport ( dict  case_cfg,
dict  solver_cfg,
bool   warn = False 
)

Resolve the scalar-transport parameters from case.yml or their deprecated location.

Each parameter is read from its case.yml location (SCALAR_TRANSPORT_CASE_PATHS) or, as a deprecated alias, from solver.yml scalar_transport. Setting one in both places is refused rather than resolved by precedence.

Parameters
[in]case_cfgParsed case configuration.
[in]solver_cfgParsed solver configuration.
[in]warnPrint a deprecation warning for each parameter read from solver.yml.
Returns
Mapping of parameter name to validated value, for the parameters that are set.
Exceptions
ValueErrorfor a malformed, duplicated, or out-of-range parameter.

Definition at line 3662 of file core.py.

3662def resolve_scalar_transport(case_cfg: dict, solver_cfg: dict, warn: bool = False) -> dict:
3663 """!
3664 @brief Resolve the scalar-transport parameters from case.yml or their deprecated location.
3665 @details Each parameter is read from its case.yml location (SCALAR_TRANSPORT_CASE_PATHS)
3666 or, as a deprecated alias, from solver.yml `scalar_transport`. Setting one in both
3667 places is refused rather than resolved by precedence.
3668 @param[in] case_cfg Parsed case configuration.
3669 @param[in] solver_cfg Parsed solver configuration.
3670 @param[in] warn Print a deprecation warning for each parameter read from solver.yml.
3671 @return Mapping of parameter name to validated value, for the parameters that are set.
3672 @throws ValueError for a malformed, duplicated, or out-of-range parameter.
3673 """
3674 legacy = (solver_cfg or {}).get("scalar_transport") or {}
3675 if not isinstance(legacy, dict):
3676 raise ValueError("solver.yml scalar_transport must be a mapping when provided.")
3677 unknown = sorted(set(legacy) - set(SCALAR_TRANSPORT_FLAGS))
3678 if unknown:
3679 raise ValueError(f"solver.yml scalar_transport has unsupported key(s): {unknown}. "
3680 f"Use one of {sorted(SCALAR_TRANSPORT_FLAGS)}, in case.yml.")
3681 values = {}
3682 for key, path in SCALAR_TRANSPORT_CASE_PATHS.items():
3683 node = case_cfg or {}
3684 for part in path:
3685 node = node.get(part) if isinstance(node, dict) else None
3686 case_label = ".".join(path + (key,))
3687 in_case = isinstance(node, dict) and key in node
3688 if in_case and key in legacy:
3689 raise ValueError(f"{key} is set in case.yml ({case_label}) and in its deprecated "
3690 f"solver.yml location (scalar_transport.{key}); keep only the case.yml one.")
3691 if not in_case and key not in legacy:
3692 continue
3693 raw, label = (node[key], f"case.yml {case_label}") if in_case else \
3694 (legacy[key], f"solver.yml scalar_transport.{key}")
3695 try:
3696 value = _to_finite_float(raw, label) if key == "iem_constant" else float(raw)
3697 except (TypeError, ValueError) as exc:
3698 raise ValueError(f"{label} must be numeric.") from exc
3699 # iem_constant = 0 switches micromixing off; the Schmidt numbers divide.
3700 if value < 0.0 or (value == 0.0 and key != "iem_constant"):
3701 raise ValueError(f"{label} must be {'non-negative' if key == 'iem_constant' else 'positive'}.")
3702 if not in_case and warn:
3703 print(f"[WARNING] solver.yml scalar_transport.{key} is deprecated; set it in case.yml "
3704 f"as {case_label}.", file=sys.stderr)
3705 values[key] = value
3706 return values
3707
3708#: Image formats `picurv sweep` can render study plots in.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_post_spectra_config()

dict picurv_cli.core.normalize_post_spectra_config ( dict  post_cfg)

Validate and canonicalize the spectra block of post.yml.

The recipe chooses what to measure; whether the case can support that measurement is a separate question answered by validate_post_spectra_preconditions(), which needs the grid and the boundary conditions. Keeping the two apart means a recipe stays valid on its own terms even when it is read without a case beside it.

Parameters
[in]post_cfgParsed post-processing configuration.
Returns
Normalized mapping with an output prefix and a list of tasks.
Exceptions
ValueErroron a malformed or inconsistent recipe.

Definition at line 3770 of file core.py.

3770def normalize_post_spectra_config(post_cfg: dict) -> dict:
3771 """!
3772 @brief Validate and canonicalize the spectra block of post.yml.
3773
3774 @details The recipe chooses what to measure; whether the case *can* support that
3775 measurement is a separate question answered by
3776 `validate_post_spectra_preconditions()`, which needs the grid and the
3777 boundary conditions. Keeping the two apart means a recipe stays valid on
3778 its own terms even when it is read without a case beside it.
3779
3780 @param[in] post_cfg Parsed post-processing configuration.
3781 @return Normalized mapping with an output prefix and a list of tasks.
3782 @throws ValueError on a malformed or inconsistent recipe.
3783 """
3784 raw = (post_cfg or {}).get("spectra")
3785 if raw is None:
3786 return {"output_prefix": "Spectrum", "tasks": []}
3787 if not isinstance(raw, dict):
3788 raise ValueError("'spectra' must be a mapping.")
3789
3790 prefix = raw.get("output_prefix", "Spectrum")
3791 if not isinstance(prefix, str) or not prefix.strip():
3792 raise ValueError("'spectra.output_prefix' must be a non-empty string.")
3793
3794 tasks = raw.get("tasks")
3795 if not isinstance(tasks, list) or not tasks:
3796 raise ValueError("'spectra.tasks' must be a non-empty list.")
3797
3798 cleaned = []
3799 seen = []
3800 for position, entry in enumerate(tasks):
3801 if not isinstance(entry, dict):
3802 raise ValueError(f"spectra task {position}: each task must be a mapping.")
3803 name = entry.get("task")
3804 if not isinstance(name, str) or name not in POST_SPECTRA_TASKS:
3805 raise ValueError(
3806 f"spectra task {position}: unknown task {name!r}. "
3807 f"Available tasks: {sorted(POST_SPECTRA_TASKS)}."
3808 )
3809 spec = POST_SPECTRA_TASKS[name]
3810
3811 field = entry.get("field", spec["fields"][0])
3812 if field not in spec["fields"]:
3813 raise ValueError(
3814 f"spectra task '{name}': field {field!r} is not supported. "
3815 f"Supported fields: {list(spec['fields'])}."
3816 )
3817
3818 block = entry.get("block", 0)
3819 if not isinstance(block, int) or isinstance(block, bool) or block < 0:
3820 raise ValueError(f"spectra task '{name}': 'block' must be a non-negative integer.")
3821
3822 symbol = entry.get("symbol", "continuum")
3823 if symbol not in POST_SPECTRA_SYMBOLS:
3824 raise ValueError(
3825 f"spectra task '{name}': unknown symbol {symbol!r}. "
3826 f"Available symbols: {list(POST_SPECTRA_SYMBOLS)}."
3827 )
3828
3829 subtract_mean = entry.get("subtract_mean", "none")
3830 if not isinstance(subtract_mean, str) or not subtract_mean.strip():
3831 raise ValueError(f"spectra task '{name}': 'subtract_mean' must be a string.")
3832 subtract_mean = subtract_mean.strip()
3833 if subtract_mean.startswith("window:"):
3834 window_name = subtract_mean.split(":", 1)[1].strip()
3835 if not window_name:
3836 raise ValueError(
3837 f"spectra task '{name}': 'subtract_mean: window:<name>' needs a window name."
3838 )
3839 elif subtract_mean not in POST_SPECTRA_MEAN_MODES:
3840 raise ValueError(
3841 f"spectra task '{name}': unknown subtract_mean {subtract_mean!r}. "
3842 f"Use one of {list(POST_SPECTRA_MEAN_MODES)} or 'window:<name>'."
3843 )
3844
3845 # A window mean is only worth subtracting once the window has converged, so the
3846 # bundle the mean is read from is chosen separately from the step being
3847 # transformed. Left unset, each step uses its own bundle, whose running mean is
3848 # undefined at the moment the window activates.
3849 mean_source_step = entry.get("mean_source_step")
3850 if mean_source_step is not None:
3851 if (not isinstance(mean_source_step, int) or isinstance(mean_source_step, bool)
3852 or mean_source_step < 0):
3853 raise ValueError(
3854 f"spectra task '{name}': 'mean_source_step' must be a non-negative integer."
3855 )
3856 if not subtract_mean.startswith("window:"):
3857 raise ValueError(
3858 f"spectra task '{name}': 'mean_source_step' only applies to "
3859 f"'subtract_mean: window:<name>'."
3860 )
3861
3862 reference = entry.get("reference")
3863 if reference is not None and (not isinstance(reference, str) or not reference.strip()):
3864 raise ValueError(f"spectra task '{name}': 'reference' must be a non-empty string.")
3865
3866 selection = {}
3867 if name != "shell_spectrum":
3868 axes = entry.get("axes")
3869 fixed = entry.get("fixed_indices")
3870 count = 2 if name == "plane_spectrum" else 1
3871 if (not isinstance(axes, list) or len(axes) != count or any(a not in ("i", "j", "k") for a in axes)
3872 or len(set(axes)) != count or not isinstance(fixed, dict)
3873 or set(fixed) != set("ijk")-set(axes)
3874 or any(isinstance(i, bool) or not isinstance(i, int) or i < 0 for i in fixed.values())):
3875 raise ValueError(f"{name} requires {count} distinct axes and zero-based fixed_indices on all remaining axes.")
3876 if symbol != "continuum":
3877 raise ValueError("plane/line spectra require symbol: continuum.")
3878 selection = {"axes": sorted(axes), "fixed_indices": dict(sorted(fixed.items()))}
3879 elif subtract_mean == "sample" or "axes" in entry or "fixed_indices" in entry:
3880 raise ValueError("shell_spectrum does not accept sample subtraction or plane/line selections.")
3881 # Each task writes its own file, so a repeat would overwrite its own output.
3882 identity = (name, field, block, symbol, json.dumps(selection, sort_keys=True))
3883 if identity in seen:
3884 raise ValueError(
3885 f"spectra recipe lists task '{name}' for field {field} on block {block} "
3886 f"with symbol '{symbol}' more than once."
3887 )
3888 seen.append(identity)
3889 cleaned.append({
3890 "task": name,
3891 **selection,
3892 "field": field,
3893 "block": block,
3894 "symbol": symbol,
3895 "subtract_mean": subtract_mean,
3896 "mean_source_step": mean_source_step,
3897 "reference": reference.strip() if isinstance(reference, str) else None,
3898 })
3899
3900 return {"output_prefix": prefix.strip(), "tasks": cleaned}
3901
3902
Here is the caller graph for this function:

◆ validate_post_spectra_preconditions()

list picurv_cli.core.validate_post_spectra_preconditions ( dict  spectra_cfg,
dict  case_cfg,
str  post_path 
)

Check spectra tasks against what the case can actually support.

Runs before any field is read, so a case with no homogeneous direction is refused rather than yielding a curve that means nothing. Only the checks the case file can answer are made here: periodicity and block count. Grid uniformity is enforced by generators/spectra.gen, which reads the staged PICGRID and is the only place the real node coordinates are known.

Parameters
[in]spectra_cfgNormalized spectra recipe.
[in]case_cfgParsed case configuration.
[in]post_pathPost recipe path, for error messages.
Returns
List of formatted error strings; empty when every task is supportable.

Definition at line 3903 of file core.py.

3903def validate_post_spectra_preconditions(spectra_cfg: dict, case_cfg: dict, post_path: str) -> list:
3904 """!
3905 @brief Check spectra tasks against what the case can actually support.
3906
3907 @details Runs before any field is read, so a case with no homogeneous direction is
3908 refused rather than yielding a curve that means nothing. Only the checks
3909 the case file can answer are made here: periodicity and block count. Grid
3910 uniformity is enforced by `generators/spectra.gen`, which reads the staged
3911 PICGRID and is the only place the real node coordinates are known.
3912
3913 @param[in] spectra_cfg Normalized spectra recipe.
3914 @param[in] case_cfg Parsed case configuration.
3915 @param[in] post_path Post recipe path, for error messages.
3916 @return List of formatted error strings; empty when every task is supportable.
3917 """
3918 errors = []
3919 if not spectra_cfg.get("tasks"):
3920 return errors
3921
3922 try:
3923 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
3924 except ValueError:
3925 # The case file reports its own boundary-condition errors; do not repeat them.
3926 return errors
3927
3928 block_count = int((case_cfg.get("models", {}) or {}).get("domain", {}).get("blocks", 1))
3929 periodic_faces = {}
3930 for block_index, block_bcs in enumerate(prepared_blocks):
3931 faces = {entry.get("face") for entry in block_bcs if entry.get("type") == "PERIODIC"}
3932 periodic_faces[block_index] = faces
3933
3934 # run_post_spectra_stage reads the staged PICGRID, which only file and grid_gen
3935 # grids provide; refuse here rather than after the solve has run.
3936 grid_mode = str((case_cfg.get("grid", {}) or {}).get("mode", "")).strip()
3937 if grid_mode == "programmatic_c":
3938 errors.append(
3939 f" {post_path}: spectra need a staged PICGRID, which grid.mode 'programmatic_c' "
3940 "does not provide. Use grid.mode 'grid_gen' or 'file' for a case whose spectra "
3941 "are measured."
3942 )
3943 return errors
3944
3945 all_faces = {"-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta"}
3946 for task_cfg in spectra_cfg["tasks"]:
3947 spec = POST_SPECTRA_TASKS[task_cfg["task"]]
3948 label = f"spectra task '{task_cfg['task']}'"
3949
3950 if spec.get("requires_single_block") and block_count != 1:
3951 errors.append(
3952 f" {post_path}: {label} requires a single-block domain; this case has "
3953 f"{block_count} blocks."
3954 )
3955
3956 block = task_cfg["block"]
3957 if block >= block_count:
3958 errors.append(
3959 f" {post_path}: {label} targets block {block}, but the case defines "
3960 f"{block_count} block(s)."
3961 )
3962 continue
3963
3964 if task_cfg["task"] != "shell_spectrum":
3965 if (case_cfg.get("models", {}).get("physics", {}).get("fsi", {}) or {}).get("immersed", False):
3966 errors.append(f" {post_path}: {label} does not support immersed masks.")
3967 names = {"i": "Xi", "j": "Eta", "k": "Zeta"}
3968 needed = {sign+names[a] for a in task_cfg["axes"] for sign in ("-", "+")}
3969 if not needed <= periodic_faces.get(block, set()):
3970 errors.append(f" {post_path}: {label} requires PERIODIC boundaries on its transform axes.")
3971 if spec.get("requires_periodic_geometric"):
3972 missing = sorted(all_faces - periodic_faces.get(block, set()))
3973 if missing:
3974 errors.append(
3975 f" {post_path}: {label} requires every face of block {block} to be "
3976 f"PERIODIC, because a shell-averaged spectrum is only defined for a "
3977 f"triply periodic homogeneous box. Non-periodic faces: {missing}."
3978 )
3979 return errors
3980
3981
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _resolve_spectra_payload()

str picurv_cli.core._resolve_spectra_payload ( dict  bundle,
str  kind,
str  field,
int  block 
)
protected

Locate one checkpoint payload by its inventory entry rather than by path shape.

Parameters
[in]bundleValidated checkpoint bundle mapping.
[in]kindPayload kind recorded in the inventory.
[in]fieldPayload field name recorded in the inventory.
[in]blockBlock index the payload belongs to.
Returns
Absolute path to the payload file.
Exceptions
ValueErrorwhen the bundle carries no such payload.

Definition at line 3982 of file core.py.

3982def _resolve_spectra_payload(bundle: dict, kind: str, field: str, block: int) -> str:
3983 """!
3984 @brief Locate one checkpoint payload by its inventory entry rather than by path shape.
3985 @param[in] bundle Validated checkpoint bundle mapping.
3986 @param[in] kind Payload kind recorded in the inventory.
3987 @param[in] field Payload field name recorded in the inventory.
3988 @param[in] block Block index the payload belongs to.
3989 @return Absolute path to the payload file.
3990 @throws ValueError when the bundle carries no such payload.
3991 """
3992 for payload in bundle["payloads"]:
3993 if (payload["kind"] == kind and payload["field"] == field
3994 and payload["block"] == str(block)):
3995 return os.path.join(bundle["bundle"], *payload["path"].split("/"))
3996 raise ValueError(
3997 f"checkpoint {os.path.basename(bundle['bundle'])} carries no {kind} payload "
3998 f"'{field}' for block {block}."
3999 )
4000
4001
Here is the caller graph for this function:

◆ _spectra_mean_arguments()

list picurv_cli.core._spectra_mean_arguments ( dict  task_cfg,
dict  bundle,
dict   mean_bundle = None 
)
protected

Build the generator arguments implementing a task's fluctuation choice.

Parameters
[in]task_cfgNormalized spectra task.
[in]bundleValidated checkpoint bundle for the step being transformed.
[in]mean_bundleBundle supplying the window mean; defaults to bundle.
Returns
Argument list to append to the generator command.
Exceptions
ValueErrorwhen a named window is absent from the bundle.

Definition at line 4002 of file core.py.

4002def _spectra_mean_arguments(task_cfg: dict, bundle: dict, mean_bundle: dict = None) -> list:
4003 """!
4004 @brief Build the generator arguments implementing a task's fluctuation choice.
4005 @param[in] task_cfg Normalized spectra task.
4006 @param[in] bundle Validated checkpoint bundle for the step being transformed.
4007 @param[in] mean_bundle Bundle supplying the window mean; defaults to @p bundle.
4008 @return Argument list to append to the generator command.
4009 @throws ValueError when a named window is absent from the bundle.
4010 """
4011 mode = task_cfg["subtract_mean"]
4012 if not mode.startswith("window:"):
4013 return ["--subtract-mean", mode]
4014 window = mode.split(":", 1)[1].strip()
4015 block = task_cfg["block"]
4016 source = mean_bundle or bundle
4017 mean_path = _resolve_spectra_payload(source, "mean", f"{window}/{task_cfg['field']}_mean", block)
4018 count_path = _resolve_spectra_payload(source, "occupancy", f"{window}/count", block)
4019 return ["--subtract-mean", "field", "--mean-file", mean_path, "--count-file", count_path]
4020
4021
4022#: Scalar columns the spectra stage records once per processed step.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_post_stage_selection()

set picurv_cli.core.resolve_post_stage_selection (   only)

Resolve the --only selector into the set of post stages to execute.

Parameters
[in]onlyComma-separated selector text, or None for every stage.
Returns
Set of stage names.
Exceptions
ValueErrorwhen the selector names an unknown or empty stage.

Definition at line 4034 of file core.py.

4034def resolve_post_stage_selection(only) -> set:
4035 """!
4036 @brief Resolve the `--only` selector into the set of post stages to execute.
4037 @param[in] only Comma-separated selector text, or None for every stage.
4038 @return Set of stage names.
4039 @throws ValueError when the selector names an unknown or empty stage.
4040 """
4041 if not only:
4042 return set(POST_STAGE_NAMES)
4043 requested = [token.strip() for token in str(only).split(",")]
4044 if not all(requested):
4045 raise ValueError("--only must not contain an empty stage name.")
4046 unknown = sorted({token for token in requested if token not in POST_STAGE_NAMES})
4047 if unknown:
4048 raise ValueError(
4049 f"--only names unknown post stage(s) {unknown}. "
4050 f"Available stages: {list(POST_STAGE_NAMES)}."
4051 )
4052 return set(requested)
4053
4054
Here is the caller graph for this function:

◆ run_post_spectra_stage()

dict picurv_cli.core.run_post_spectra_stage ( str  run_dir,
dict  post_cfg,
dict  monitor_cfg,
str  source_dir,
  steps,
bool   quiet = False,
"dict | None"   plan = None,
bool   recompute = False 
)

Measure spectra for every requested task across a window of committed steps.

One spectrum per step per task, because a spectrum is a property of one state: averaging across steps would be wrong for a decaying flow, where every step is a different statistical state. Results are written in long form so a family of curves stays one file, alongside a scalar history that plots through the ordinary series machinery.

Parameters
[in]run_dirRun directory receiving the output.
[in]post_cfgParsed post-processing configuration.
[in]monitor_cfgParsed monitor configuration anchoring the output root.
[in]source_dirDirectory holding the committed checkpoints.
[in]stepsIterable of checkpoint steps to process, in order.
[in]quietSuppress progress reporting.
[in]planPost plan whose recipe state records each measured step; with it, a step whose rows are still valid (classify_post_step()) is not measured again. None measures every committed step given.
[in]recomputeMeasure every committed step even when its rows are valid.
Returns
Summary with the written paths and the steps actually processed.
Exceptions
ValueErrorwhen the generator fails or a requested payload is absent.

Definition at line 4055 of file core.py.

4057 plan: "dict | None" = None, recompute: bool = False) -> dict:
4058 """!
4059 @brief Measure spectra for every requested task across a window of committed steps.
4060
4061 @details One spectrum per step per task, because a spectrum is a property of one
4062 state: averaging across steps would be wrong for a decaying flow, where
4063 every step is a different statistical state. Results are written in long
4064 form so a family of curves stays one file, alongside a scalar history that
4065 plots through the ordinary series machinery.
4066
4067 @param[in] run_dir Run directory receiving the output.
4068 @param[in] post_cfg Parsed post-processing configuration.
4069 @param[in] monitor_cfg Parsed monitor configuration anchoring the output root.
4070 @param[in] source_dir Directory holding the committed checkpoints.
4071 @param[in] steps Iterable of checkpoint steps to process, in order.
4072 @param[in] quiet Suppress progress reporting.
4073 @param[in] plan Post plan whose recipe state records each measured step; with it, a
4074 step whose rows are still valid (classify_post_step()) is not
4075 measured again. None measures every committed step given.
4076 @param[in] recompute Measure every committed step even when its rows are valid.
4077 @return Summary with the written paths and the steps actually processed.
4078 @throws ValueError when the generator fails or a requested payload is absent.
4079 """
4080 spectra = normalize_post_spectra_config(post_cfg)
4081 if not spectra["tasks"]:
4082 return {"tasks": [], "steps": [], "artifacts": []}
4083
4084 script = os.path.join(GENERATORS_PATH, "spectra.gen")
4085 if not os.path.isfile(script):
4086 raise ValueError(f"spectra.gen script not found: {script}")
4087 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
4088 if not os.path.isfile(staged_grid):
4089 raise ValueError(f"spectra require a staged PICGRID at {staged_grid}.")
4090
4091 output_dir = os.path.join(
4092 os.path.abspath(run_dir), resolve_recipe_spectra_output_dir(post_cfg, monitor_cfg)
4093 )
4094 os.makedirs(output_dir, exist_ok=True)
4095
4096 # Spectra are computed from the staged non-dimensional field on the staged
4097 # non-dimensional grid, so physical units are the generator's to apply. The run's
4098 # own configuration snapshot is the authority for the scales it used.
4099 scale_arguments = []
4100 # Checkpoint times are solver time; a dimensionalized result reports physical time.
4101 time_scale = 1.0
4102 if bool((post_cfg.get("global_operations") or {}).get("dimensionalize", False)):
4103 active_case = load_active_run_configuration(run_dir).get("case")
4104 case_path = os.path.join(run_dir, active_case) if active_case else None
4105 if case_path and os.path.isfile(case_path):
4106 scaling = resolve_fluid_scaling(read_yaml_file(case_path))
4107 scale_arguments = [
4108 "--velocity-ref", repr(float(scaling["velocity_ref"])),
4109 "--length-ref", repr(float(scaling["length_ref"])),
4110 ]
4111 time_scale = reference_scale(TIME, scaling)
4112 elif not quiet:
4113 print("[WARNING] Spectra: dimensionalize was requested but this run carries no "
4114 "readable case snapshot; results stay non-dimensional.", file=sys.stderr)
4115
4116 requested = sorted(set(int(step) for step in steps))
4117 signature = compute_post_spectra_signature(spectra)
4118 records = {}
4119 if plan is not None:
4120 # Steps whose rows are still valid are not measured again; unrecorded rows are
4121 # adopted on the same terms as field output (classify_post_step()).
4122 existing = _read_post_records(plan['resume_state_path'])[1]
4123 # Each task writes a spectrum and a history file; a step is measured only when
4124 # both of every task's files hold it.
4125 spectra_files = [
4126 os.path.join(output_dir, f"{post_spectra_task_basename(task, spectra['output_prefix'])}{suffix}.csv")
4127 for task in spectra["tasks"] for suffix in ("", "_history")
4128 ]
4129 measured = set.intersection(*(_scan_post_statistics_csv_steps(path) for path in spectra_files))
4130 pending = []
4131 for step in requested:
4132 record = existing.get(str(step))
4133 commit = _checkpoint_commit(source_dir, step)
4134 reason = classify_post_step(record, spectra_files if step in measured else None,
4135 commit, signature, recompute)
4136 if reason is not None:
4137 pending.append(step)
4138 elif record is None:
4139 records[step] = {"recipe": signature, "checkpoint": commit[0] if commit else None}
4140 if not quiet and len(pending) != len(requested):
4141 print(f"[INFO] Spectra: {len(requested) - len(pending)} requested step(s) are up to date.")
4142 try:
4143 require_storage_payload_local(run_dir, "spectra", checkpoints=pending)
4144 except StorageError as exc:
4145 raise ValueError(str(exc)) from exc
4146 requested = pending
4147 if not requested:
4148 persist_post_resume_state(run_dir, plan, spectra=records)
4149 return {"tasks": [], "steps": [], "artifacts": []}
4150 # The requested window is what the recipe asks for, not what exists. Like the field
4151 # stage, only committed checkpoints are measured: a window reaching past the last
4152 # committed checkpoint is normal while a solve is still running, and is not an error.
4153 available = _scan_committed_checkpoint_steps(source_dir)
4154 ordered_steps = [step for step in requested if step in available]
4155 if not ordered_steps:
4156 if not quiet:
4157 print("[INFO] Spectra: no step needing measurement has a committed checkpoint "
4158 "yet; nothing measured.")
4159 if plan is not None:
4160 persist_post_resume_state(run_dir, plan, spectra=records)
4161 return {"tasks": [], "steps": [], "artifacts": []}
4162 if not quiet and len(ordered_steps) != len(requested):
4163 print(f"[INFO] Spectra: {len(ordered_steps)} of {len(requested)} step(s) needing "
4164 f"measurement are committed; measuring those.")
4165 artifacts = []
4166 for task_cfg in spectra["tasks"]:
4167 basename = post_spectra_task_basename(task_cfg, spectra["output_prefix"])
4168 spectrum_path = os.path.join(output_dir, f"{basename}.csv")
4169 scalar_path = os.path.join(output_dir, f"{basename}_history.csv")
4170 spectrum_rows = []
4171 scalar_rows = []
4172 mean_bundle = None
4173 if task_cfg["mean_source_step"] is not None:
4174 mean_bundle = validate_committed_checkpoint(source_dir, task_cfg["mean_source_step"])
4175
4176 for step in ordered_steps:
4177 bundle = validate_committed_checkpoint(source_dir, step)
4178 time = float(bundle["metadata"]["checkpoint_time"]) * time_scale
4179 field_path = _resolve_spectra_payload(
4180 bundle, "eulerian", task_cfg["field"], task_cfg["block"]
4181 )
4182 cmd = [sys.executable, script, task_cfg["task"].replace("_", "-"),
4183 "--field-file", field_path, "--source-grid", staged_grid,
4184 "--block", str(task_cfg["block"]), "--symbol", task_cfg["symbol"]]
4185 if task_cfg["task"] != "shell_spectrum":
4186 cmd.extend(["--axes", *task_cfg["axes"], "--fixed-indices", json.dumps(task_cfg["fixed_indices"])])
4187 cmd.extend(_spectra_mean_arguments(task_cfg, bundle, mean_bundle))
4188 cmd.extend(scale_arguments)
4189 cmd.extend(petsc_index_arguments(int(bundle["metadata"].get("checkpoint_petsc_index_bytes", "4"))))
4190 result = subprocess.run(cmd, text=True, capture_output=True)
4191 if result.returncode != 0:
4192 details = (result.stderr or result.stdout or "").strip()
4193 raise ValueError(
4194 f"spectra task '{task_cfg['task']}' failed at step {step} with exit code "
4195 f"{result.returncode}. Details:\n{details}"
4196 )
4197 summary = json.loads(result.stdout)
4198 for row in summary.get("sampled_spectrum", summary.get("shell_spectrum")):
4199 spectrum_rows.append({"step": step, "time": time, **row})
4200 scalar_columns = (POST_SPECTRA_SCALAR_COLUMNS if task_cfg["task"] == "shell_spectrum"
4201 else ("resolved_kinetic_energy", "parseval_residual"))
4202 scalar_rows.append({"step": step, "time": time,
4203 **{name: summary[name] for name in scalar_columns}})
4204
4205 spectrum_fields = ("step", "time", *summary.get("spectrum_columns", ("k", "energy")))
4206 scalar_fields = ("step", "time") + scalar_columns
4207 # Only the steps that were missing or stale are measured; keep the rows already
4208 # written for every other step, replacing only those re-measured now.
4209 remeasured = set(ordered_steps)
4210 spectrum_rows = _merge_spectra_rows(spectrum_path, spectrum_fields, remeasured, spectrum_rows)
4211 scalar_rows = _merge_spectra_rows(scalar_path, scalar_fields, remeasured, scalar_rows)
4212 with open(spectrum_path, "w", newline="", encoding="utf-8") as stream:
4213 writer = csv.DictWriter(stream, fieldnames=spectrum_fields)
4214 writer.writeheader()
4215 writer.writerows(spectrum_rows)
4216 with open(scalar_path, "w", newline="", encoding="utf-8") as stream:
4217 writer = csv.DictWriter(stream, fieldnames=scalar_fields)
4218 writer.writeheader()
4219 writer.writerows(scalar_rows)
4220 artifacts.extend([spectrum_path, scalar_path])
4221 if not quiet:
4222 print(f"[INFO] Spectra: measured {len(ordered_steps)} step(s) for task "
4223 f"'{task_cfg['task']}'; {os.path.relpath(spectrum_path, run_dir)} now holds "
4224 f"{len(scalar_rows)} step(s)")
4225
4226 if plan is not None:
4227 for step in ordered_steps:
4228 commit = _checkpoint_commit(source_dir, step)
4229 records[step] = {"recipe": signature, "checkpoint": commit[0] if commit else None}
4230 persist_post_resume_state(run_dir, plan, spectra=records)
4231 return {
4232 "tasks": [entry["task"] for entry in spectra["tasks"]],
4233 "steps": ordered_steps,
4234 "artifacts": artifacts,
4235 }
4236
4237
4238#: Reducer tasks a post recipe may request. Each runs a `generators/reduce.gen`
4239#: subcommand over the field-statistics profiles of one window or ensemble.
4240#: `retains` is how many directions the profile must keep, and `axis_key` the task key
4241#: naming the one it keeps (`wall_axis`) or the one it merged (`stream_axis`).
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_post_reducers_config()

dict picurv_cli.core.normalize_post_reducers_config ( dict  post_cfg)

Validate and canonicalize the reducers block of post.yml.

A reducer reads the profiles field_statistics writes, so each task names a window or ensemble of the same recipe, and the recipe's homogeneous must leave the directions the task needs.

Parameters
[in]post_cfgParsed post-processing configuration.
Returns
Mapping with the canonical task list.

Definition at line 4263 of file core.py.

4263def normalize_post_reducers_config(post_cfg: dict) -> dict:
4264 """!
4265 @brief Validate and canonicalize the reducers block of post.yml.
4266 @details A reducer reads the profiles `field_statistics` writes, so each task names a
4267 window or ensemble of the same recipe, and the recipe's `homogeneous` must
4268 leave the directions the task needs.
4269 @param[in] post_cfg Parsed post-processing configuration.
4270 @return Mapping with the canonical task list.
4271 """
4272 raw = (post_cfg or {}).get("reducers")
4273 if raw is None:
4274 return {"tasks": []}
4275 if not isinstance(raw, dict) or not isinstance(raw.get("tasks"), list) or not raw["tasks"]:
4276 raise ValueError("'reducers' must be a mapping with a non-empty 'tasks' list.")
4277 statistics = normalize_post_field_statistics_config(post_cfg)
4278 windows = set(statistics["windows"])
4279 owners = windows | {ensemble["name"] for ensemble in statistics["ensembles"]}
4280 retained = [axis for axis in ("i", "j", "k") if axis not in statistics["homogeneous"]]
4281 tasks = []
4282 for index, entry in enumerate(raw["tasks"]):
4283 if not isinstance(entry, dict):
4284 raise ValueError(f"'reducers.tasks[{index}]' must be a mapping.")
4285 name = entry.get("task")
4286 if not isinstance(name, str) or name not in POST_REDUCER_TASKS:
4287 raise ValueError(f"reducer task {name!r} is unknown. Available tasks: {sorted(POST_REDUCER_TASKS)}.")
4288 spec = POST_REDUCER_TASKS[name]
4289 label = f"reducer '{name}'"
4290 source = entry.get("source")
4291 if not isinstance(source, str) or source not in owners:
4292 raise ValueError(f"{label}: 'source' must name a field_statistics window or ensemble of this "
4293 f"recipe (got {source!r}; available: {sorted(owners)}).")
4294 if "csv" not in statistics["formats"] or len(retained) != spec["retains"]:
4295 raise ValueError(f"{label} reads a profile keeping {spec['retains']} direction(s), so "
4296 f"field_statistics needs 'csv' in formats and 'homogeneous' leaving that "
4297 f"many (it leaves {retained}).")
4298 task = {"task": name, "source": source}
4299 if spec["axis_key"]:
4300 axis = entry.get(spec["axis_key"], spec["default_axis"])
4301 if axis not in _REDUCER_AXIS_TOKENS:
4302 raise ValueError(f"{label}: '{spec['axis_key']}' must be one of i, j, k (got {axis!r}).")
4303 kept = axis in retained
4304 if kept != (spec["axis_key"] == "wall_axis"):
4305 raise ValueError(f"{label}: the profile {'must keep' if not kept else 'must merge over'} "
4306 f"'{axis}', but 'homogeneous' is {statistics['homogeneous']}.")
4307 task[spec["axis_key"]] = axis
4308 stream = entry.get("stream_axis", "k")
4309 if stream not in _REDUCER_AXIS_TOKENS or stream == task.get("wall_axis"):
4310 raise ValueError(f"{label}: 'stream_axis' must be i, j or k and differ from the wall axis.")
4311 task["stream_axis"] = stream
4312 friction = entry.get("friction_velocity", "first_cell")
4313 if friction not in POST_REDUCER_FRICTION_SOURCES or (name == "cross_section" and friction == "wall_model"):
4314 raise ValueError(f"{label}: 'friction_velocity' must be one of "
4315 f"{[f for f in POST_REDUCER_FRICTION_SOURCES if name != 'cross_section' or f != 'wall_model']} "
4316 f"(got {friction!r}).")
4317 needs = POST_REDUCER_FRICTION_SOURCES.get(friction, {}).get("needs")
4318 if needs == "window" and source not in windows:
4319 raise ValueError(f"{label}: friction_velocity '{friction}' averages over a run window's time "
4320 f"bounds, so 'source' must be a run window, not an ensemble.")
4321 task["friction_velocity"] = friction
4322 if needs == "value":
4323 value = entry.get("value")
4324 if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
4325 raise ValueError(f"{label}: friction_velocity '{friction}' needs a positive 'value'.")
4326 task["value"] = float(value)
4327 if name == "cross_section":
4328 task["fold"] = bool(entry.get("fold", True))
4329 if name == "wall_profile" and entry.get("half_height") is not None:
4330 task["half_height"] = float(entry["half_height"])
4331 tasks.append(task)
4332 return {"tasks": tasks}
4333
4334
Here is the call graph for this function:
Here is the caller graph for this function:

◆ run_post_reducers_stage()

list picurv_cli.core.run_post_reducers_stage ( str  run_dir,
dict  post_cfg,
bool   quiet = False 
)

Run every reducer task over the profiles the recipe has written.

Reducers read only small CSVs, so every profile present is reduced again on each invocation rather than tracked step by step. Inputs come from the run: the viscosity and reference scales from its case snapshot, the grid it was staged with, a window's effective bounds from each step's checkpoint.meta, and the runtime metrics CSVs.

Parameters
[in]run_dirRun directory.
[in]post_cfgPost recipe with canonical paths applied.
[in]quietSuppress progress reporting.
Returns
Absolute paths written.
Exceptions
ValueErrorwhen a reducer fails.

Definition at line 4335 of file core.py.

4335def run_post_reducers_stage(run_dir: str, post_cfg: dict, quiet: bool = False) -> list:
4336 """!
4337 @brief Run every reducer task over the profiles the recipe has written.
4338 @details Reducers read only small CSVs, so every profile present is reduced again on
4339 each invocation rather than tracked step by step. Inputs come from the run:
4340 the viscosity and reference scales from its case snapshot, the grid it was
4341 staged with, a window's effective bounds from each step's `checkpoint.meta`,
4342 and the runtime metrics CSVs.
4343 @param[in] run_dir Run directory.
4344 @param[in] post_cfg Post recipe with canonical paths applied.
4345 @param[in] quiet Suppress progress reporting.
4346 @return Absolute paths written.
4347 @throws ValueError when a reducer fails.
4348 """
4349 tasks = normalize_post_reducers_config(post_cfg)["tasks"]
4350 if not tasks:
4351 return []
4352 run_dir = os.path.abspath(run_dir)
4353 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
4354 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
4355 script = os.path.join(GENERATORS_PATH, "reduce.gen")
4356 grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
4357 case_path = load_active_run_configuration(run_dir).get("case")
4358 case_cfg = read_yaml_file(case_path) if case_path and os.path.isfile(case_path) else {}
4359 scaling = resolve_fluid_scaling(case_cfg)
4360 dimensional = bool((post_cfg.get("global_operations") or {}).get("dimensionalize", False))
4361 velocity_scale = scaling["velocity_ref"] if dimensional else 1.0
4362 dt_physical = float((case_cfg.get("run_control") or {}).get("dt_physical", 0.0) or 0.0)
4363 step_time = dt_physical if dimensional else dt_physical / reference_scale(TIME, scaling)
4364 prefix = os.path.join(run_dir, post_cfg["_picurv_paths"]["field_statistics_prefix"])
4365 output_root = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
4366 lineage = resolve_run_lineage(run_dir)
4367 written = []
4368
4369 def lineage_metrics(name):
4370 """!
4371 @brief List a metrics CSV across the restart lineage, root to leaf.
4372 @details A window carried into a branched run spans its ancestors' time, whose
4373 metrics rows are in their own files. Each ancestor's file is limited to
4374 the steps up to the checkpoint its child branched from.
4375 @param[in] name Metrics CSV basename.
4376 @return `path` or `path@last_step` entries for every segment holding the file.
4377 """
4378 entries = []
4379 for index, segment in enumerate(lineage):
4380 path = os.path.join(segment["run_dir"], CANONICAL_RUN_PATHS["metrics"], name)
4381 if not os.path.isfile(path):
4382 continue
4383 child = lineage[index + 1] if index + 1 < len(lineage) else None
4384 entries.append(f"{path}@{child['fork_step']}" if child else path)
4385 return entries or [os.path.join(run_dir, CANONICAL_RUN_PATHS["metrics"], name)]
4386
4387 def run(arguments, label):
4388 """!
4389 @brief Run one reduce.gen subcommand, failing with its own diagnostics.
4390 @param[in] arguments Subcommand and its arguments.
4391 @param[in] label Reducer and step named in a failure.
4392 """
4393 result = subprocess.run([sys.executable, script, *arguments], text=True, capture_output=True)
4394 if result.returncode != 0:
4395 raise ValueError(f"reducer {label} failed with exit code {result.returncode}:\n"
4396 f"{(result.stderr or result.stdout).strip()}")
4397
4398 for task in tasks:
4399 family = f"{prefix}_profile_{task['source']}"
4400 if task["task"] == "profile_series":
4401 output = f"{family}_history.csv"
4402 if _scan_post_vtk_steps(family, "csv"):
4403 run(["profile-series", "--profile-prefix", family, "--dt", repr(step_time),
4404 "--output", output], f"profile_series of '{task['source']}'")
4405 written.append(output)
4406 continue
4407 friction = {"first_cell": [], "body_force": ["--body-force", repr(task.get("value"))],
4408 "u_tau": ["--u-tau", repr(task.get("value"))],
4409 "driven_flow": ["--driven-flow-csv", *lineage_metrics("driven_flow.csv")],
4410 "wall_model": ["--wall-model-csv", *lineage_metrics("wall_model.csv")]}[task["friction_velocity"]]
4411 for step, path in sorted(_scan_post_vtk_steps(family, "csv").items()):
4412 arguments = [POST_REDUCER_TASKS[task["task"]]["command"], "--profile", path, "--grid", grid,
4413 "--window", task["source"], "--viscosity",
4414 repr(scaling["nondimensional_kinematic_viscosity"]),
4415 "--velocity-scale", repr(velocity_scale),
4416 "--stream-axis", _REDUCER_AXIS_TOKENS[task["stream_axis"]], *friction]
4417 metadata = os.path.join(_checkpoint_bundle_path(output_root, step), "checkpoint.meta")
4418 options = _read_checkpoint_options(metadata) if os.path.isfile(metadata) else {}
4419 for index in range(int(options.get("checkpoint_statistics_window_count", 0) or 0)):
4420 key = f"checkpoint_statistics_window_{index}_"
4421 if options.get(key + "name") == task["source"]:
4422 arguments += ["--window-start", options[key + "effective_start"],
4423 "--window-end", options[key + "effective_end"],
4424 "--window-note", f"{options.get(key + 'state')}, "
4425 f"{options.get(key + 'sample_count')} samples"]
4426 if task["task"] == "wall_profile":
4427 output = f"{prefix}_wall_profile_{task['source']}_{step}.csv"
4428 arguments += ["--wall-axis", _REDUCER_AXIS_TOKENS[task["wall_axis"]], "--output", output]
4429 if "half_height" in task:
4430 arguments += ["--half-height", repr(task["half_height"])]
4431 written.append(output)
4432 else:
4433 output = f"{prefix}_cross_section_{task['source']}_{step}"
4434 arguments += ["--output-prefix", output] + ([] if task["fold"] else ["--no-fold"])
4435 written.append(output + "_cross_section.csv")
4436 run(arguments, f"{task['task']} of '{task['source']}' at step {step}")
4437 if not quiet:
4438 for path in written:
4439 print(f"[INFO] Reducer output: {os.path.relpath(path)}")
4440 return written
4441
4442
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _merge_spectra_rows()

list picurv_cli.core._merge_spectra_rows ( str  path,
tuple  fieldnames,
set  remeasured,
list  new_rows 
)
protected

Combine newly measured spectra rows with those already written for other steps.

The file belongs to one recipe, whose identity covers the spectra settings, so its existing rows come from the same measurement. Rows for a re-measured step are replaced; rows for every other step are kept. A file whose columns differ is not merged: it was written by another format and is rewritten whole.

Parameters
[in]pathExisting CSV path, which may be absent.
[in]fieldnamesColumns the new rows carry.
[in]remeasuredSteps measured in this invocation.
[in]new_rowsRows measured in this invocation.
Returns
All rows, ordered by step with each step's rows in their measured order.

Definition at line 4443 of file core.py.

4443def _merge_spectra_rows(path: str, fieldnames: tuple, remeasured: set, new_rows: list) -> list:
4444 """!
4445 @brief Combine newly measured spectra rows with those already written for other steps.
4446 @details The file belongs to one recipe, whose identity covers the spectra settings, so
4447 its existing rows come from the same measurement. Rows for a re-measured step
4448 are replaced; rows for every other step are kept. A file whose columns differ
4449 is not merged: it was written by another format and is rewritten whole.
4450 @param[in] path Existing CSV path, which may be absent.
4451 @param[in] fieldnames Columns the new rows carry.
4452 @param[in] remeasured Steps measured in this invocation.
4453 @param[in] new_rows Rows measured in this invocation.
4454 @return All rows, ordered by step with each step's rows in their measured order.
4455 """
4456 kept = []
4457 try:
4458 with open(path, newline="", encoding="utf-8") as stream:
4459 reader = csv.DictReader(stream)
4460 if tuple(reader.fieldnames or ()) == tuple(fieldnames):
4461 kept = [row for row in reader if int(row["step"]) not in remeasured]
4462 except FileNotFoundError:
4463 pass
4464 return sorted(kept + list(new_rows), key=lambda row: int(row["step"]))
4465
4466
Here is the caller graph for this function:

◆ compute_post_spectra_signature()

str picurv_cli.core.compute_post_spectra_signature ( dict  spectra_cfg)

Reduce a normalized spectra recipe to a stable identity string.

Parameters
[in]spectra_cfgNormalized spectra recipe.
Returns
Hex digest covering every choice that changes the produced spectra.

Definition at line 4467 of file core.py.

4467def compute_post_spectra_signature(spectra_cfg: dict) -> str:
4468 """!
4469 @brief Reduce a normalized spectra recipe to a stable identity string.
4470 @param[in] spectra_cfg Normalized spectra recipe.
4471 @return Hex digest covering every choice that changes the produced spectra.
4472 """
4473 payload = json.dumps(spectra_cfg, sort_keys=True, separators=(",", ":")).encode("utf-8")
4474 return hashlib.sha256(payload).hexdigest()[:16]
4475
4476
Here is the caller graph for this function:

◆ resolve_post_spectra_output_dir()

str picurv_cli.core.resolve_post_spectra_output_dir (   monitor_cfg = None)

Resolve the run-relative directory spectra CSVs are written to.

Parameters
[in]monitor_cfgOptional monitor configuration anchoring the output root.
Returns
Run-relative directory path.

Definition at line 4477 of file core.py.

4477def resolve_post_spectra_output_dir(monitor_cfg=None) -> str:
4478 """!
4479 @brief Resolve the run-relative directory spectra CSVs are written to.
4480 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
4481 @return Run-relative directory path.
4482 """
4483 del monitor_cfg
4484 return CANONICAL_RUN_PATHS["spectra"]
4485
4486
Here is the caller graph for this function:

◆ resolve_recipe_spectra_output_dir()

str picurv_cli.core.resolve_recipe_spectra_output_dir ( dict  post_cfg,
  monitor_cfg = None 
)

Resolve the canonical spectra directory for one versioned recipe.

Parameters
[in]post_cfgParsed runtime post config.
[in]monitor_cfgOptional monitor config for standalone compatibility.
Returns
Run-relative spectra directory.

Definition at line 4487 of file core.py.

4487def resolve_recipe_spectra_output_dir(post_cfg: dict, monitor_cfg=None) -> str:
4488 """!
4489 @brief Resolve the canonical spectra directory for one versioned recipe.
4490 @param[in] post_cfg Parsed runtime post config.
4491 @param[in] monitor_cfg Optional monitor config for standalone compatibility.
4492 @return Run-relative spectra directory.
4493 """
4494 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
4495 return internal.get("spectra") or resolve_post_spectra_output_dir(monitor_cfg)
4496
4497
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_post_spectra_output_artifacts()

list picurv_cli.core.get_post_spectra_output_artifacts ( dict  post_cfg,
str  run_dir,
  monitor_cfg = None 
)

Predict the spectra CSV paths a recipe will write.

Parameters
[in]post_cfgParsed post-processing configuration.
[in]run_dirRun directory the recipe operates on.
[in]monitor_cfgOptional monitor configuration anchoring the output root.
Returns
Absolute CSV paths, one per task, in recipe order.

Definition at line 4498 of file core.py.

4498def get_post_spectra_output_artifacts(post_cfg: dict, run_dir: str, monitor_cfg=None) -> list:
4499 """!
4500 @brief Predict the spectra CSV paths a recipe will write.
4501 @param[in] post_cfg Parsed post-processing configuration.
4502 @param[in] run_dir Run directory the recipe operates on.
4503 @param[in] monitor_cfg Optional monitor configuration anchoring the output root.
4504 @return Absolute CSV paths, one per task, in recipe order.
4505 """
4506 try:
4507 spectra = normalize_post_spectra_config(post_cfg)
4508 except ValueError:
4509 # The recipe reports its own errors during validation; predict nothing here.
4510 return []
4511 if not spectra["tasks"]:
4512 return []
4513 base = os.path.join(os.path.abspath(run_dir), resolve_recipe_spectra_output_dir(post_cfg, monitor_cfg))
4514 paths = []
4515 for task_cfg in spectra["tasks"]:
4516 name = post_spectra_task_basename(task_cfg, spectra["output_prefix"])
4517 paths.append(os.path.join(base, f"{name}.csv"))
4518 return list(dict.fromkeys(paths))
4519
4520
Here is the call graph for this function:

◆ post_spectra_task_basename()

str picurv_cli.core.post_spectra_task_basename ( dict  task_cfg,
str  output_prefix 
)

Build the file basename one normalized spectra task writes.

Parameters
[in]task_cfgNormalized task mapping.
[in]output_prefixRecipe output prefix.
Returns
Basename without directory or extension.

Definition at line 4521 of file core.py.

4521def post_spectra_task_basename(task_cfg: dict, output_prefix: str) -> str:
4522 """!
4523 @brief Build the file basename one normalized spectra task writes.
4524 @param[in] task_cfg Normalized task mapping.
4525 @param[in] output_prefix Recipe output prefix.
4526 @return Basename without directory or extension.
4527 """
4528 parts = [output_prefix, task_cfg["task"], task_cfg["field"],
4529 f"block{task_cfg['block']:04d}", task_cfg["symbol"]]
4530 if task_cfg["task"] != "shell_spectrum":
4531 parts.extend(["".join(task_cfg["axes"]), *[f"{a}{i}" for a,i in task_cfg["fixed_indices"].items()]])
4532 return "_".join(parts)
4533
4534
Here is the caller graph for this function:

◆ _normalize_post_statistics_ensembles()

list picurv_cli.core._normalize_post_statistics_ensembles (   raw)
protected

Validate and canonicalize the ensembles a post recipe defines.

An ensemble is a window over one instant: its samples are the particles in each cell, or each cell's own state, at the step being processed. It is defined in the recipe rather than the run, because it needs no state accumulated during the solve.

Parameters
[in]rawThe recipe's field_statistics.ensembles list, or None.
Returns
List of ensembles with name, source, fields and covariances.

Definition at line 4535 of file core.py.

4535def _normalize_post_statistics_ensembles(raw) -> list:
4536 """!
4537 @brief Validate and canonicalize the ensembles a post recipe defines.
4538 @details An ensemble is a window over one instant: its samples are the particles in
4539 each cell, or each cell's own state, at the step being processed. It is
4540 defined in the recipe rather than the run, because it needs no state
4541 accumulated during the solve.
4542 @param[in] raw The recipe's `field_statistics.ensembles` list, or None.
4543 @return List of ensembles with name, source, fields and covariances.
4544 """
4545 if raw is None:
4546 return []
4547 if not isinstance(raw, list):
4548 raise ValueError("'field_statistics.ensembles' must be a list.")
4549 ensembles = []
4550 for index, entry in enumerate(raw):
4551 if not isinstance(entry, dict):
4552 raise ValueError(f"'field_statistics.ensembles[{index}]' must be a mapping.")
4553 name = entry.get("name")
4554 if not isinstance(name, str) or not name.strip():
4555 raise ValueError(f"'field_statistics.ensembles[{index}]' needs a non-empty 'name'.")
4556 name = name.strip()
4557 if any(existing["name"] == name for existing in ensembles):
4558 raise ValueError(f"field statistics ensemble '{name}' is defined more than once.")
4559 source = entry.get("source")
4560 if source not in POST_STATISTICS_ENSEMBLE_SOURCES:
4561 raise ValueError(
4562 f"field statistics ensemble '{name}': 'source' must be one of "
4563 f"{list(POST_STATISTICS_ENSEMBLE_SOURCES)} (got {source!r})."
4564 )
4565 fields, covariances = _normalize_statistics_requests(
4566 f"field statistics ensemble '{name}'", entry.get("fields"), entry.get("covariances"),
4567 _ensemble_source_fields(source))
4568 ensembles.append({"name": name, "source": source, "fields": fields, "covariances": covariances})
4569 return ensembles
4570
4571
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_post_field_statistics_config()

dict picurv_cli.core.normalize_post_field_statistics_config ( dict  post_cfg)

Validate and canonicalize the field_statistics block of post.yml.

The recipe names windows rather than redescribing them, because the window definitions already reach the post-processor through the run's solver control. Validation therefore covers the recipe's own choices only; a name no window matches is caught in C against the resolved list, which is the only place the real set is known.

Parameters
[in]post_cfgParsed post-processing configuration.
Returns
Value returned by normalize_post_field_statistics_config().

Definition at line 4572 of file core.py.

4572def normalize_post_field_statistics_config(post_cfg: dict) -> dict:
4573 """!
4574 @brief Validate and canonicalize the field_statistics block of post.yml.
4575
4576 @details The recipe names windows rather than redescribing them, because the
4577 window definitions already reach the post-processor through the run's
4578 solver control. Validation therefore covers the recipe's own choices
4579 only; a name no window matches is caught in C against the resolved list,
4580 which is the only place the real set is known.
4581
4582 @param[in] post_cfg Parsed post-processing configuration.
4583 @return Value returned by `normalize_post_field_statistics_config()`.
4584 """
4585 raw = (post_cfg or {}).get("field_statistics")
4586 if raw is None:
4587 return {"windows": [], "ensembles": [], "homogeneous": [], "source_step": None,
4588 "outputs": [], "formats": []}
4589 if not isinstance(raw, dict):
4590 raise ValueError("'field_statistics' must be a mapping.")
4591
4592 ensembles = _normalize_post_statistics_ensembles(raw.get("ensembles"))
4593 windows = raw.get("windows", [] if ensembles else None)
4594 if not isinstance(windows, list) or not (windows or ensembles):
4595 raise ValueError("'field_statistics.windows' must be a non-empty list of window names "
4596 "unless 'ensembles' defines what to derive.")
4597 cleaned = []
4598 for entry in windows:
4599 if not isinstance(entry, str) or not entry.strip():
4600 raise ValueError("'field_statistics.windows' entries must be non-empty window names.")
4601 name = entry.strip()
4602 # Each window writes its own file, so a repeat would overwrite its own output.
4603 if name in cleaned:
4604 raise ValueError(f"field statistics recipe lists window '{name}' more than once.")
4605 cleaned.append(name)
4606 for ensemble in ensembles:
4607 if ensemble["name"] in cleaned:
4608 raise ValueError(f"field statistics ensemble '{ensemble['name']}' has the name of a "
4609 "requested window; both would write the same files.")
4610
4611 label = ", ".join(cleaned + [ensemble["name"] for ensemble in ensembles])
4612 source_step = raw.get("source_step")
4613 if source_step is not None:
4614 if not isinstance(source_step, int) or isinstance(source_step, bool) or source_step < 0:
4615 raise ValueError(
4616 f"field statistics recipe for [{label}]: 'source_step' must be a non-negative "
4617 f"integer (got {source_step!r})."
4618 )
4619
4620 outputs = raw.get("outputs", list(POST_FIELD_STATISTICS_DEFAULT_OUTPUTS))
4621 if not isinstance(outputs, list) or not outputs:
4622 raise ValueError(f"field statistics recipe for [{label}]: 'outputs' must be a non-empty list.")
4623 unknown = [item for item in outputs if item not in POST_FIELD_STATISTICS_OUTPUTS]
4624 if unknown:
4625 raise ValueError(
4626 f"field statistics recipe for [{label}]: unknown outputs {unknown}. "
4627 f"Available outputs: {list(POST_FIELD_STATISTICS_OUTPUTS)}."
4628 )
4629 if len(set(outputs)) != len(outputs):
4630 raise ValueError(f"field statistics recipe for [{label}]: 'outputs' lists a duplicate.")
4631
4632 formats = raw.get("formats", ["vtk"])
4633 if not isinstance(formats, list) or not formats:
4634 raise ValueError(f"field statistics recipe for [{label}]: 'formats' must be a non-empty list.")
4635 unknown = [item for item in formats if item not in POST_FIELD_STATISTICS_FORMATS]
4636 if unknown:
4637 raise ValueError(
4638 f"field statistics recipe for [{label}]: unknown formats {unknown}. "
4639 f"Available formats: {list(POST_FIELD_STATISTICS_FORMATS)}."
4640 )
4641 if len(set(formats)) != len(formats):
4642 raise ValueError(f"field statistics recipe for [{label}]: 'formats' lists a duplicate.")
4643
4644 homogeneous = raw.get("homogeneous", [])
4645 if (not isinstance(homogeneous, list) or any(axis not in ("i", "j", "k") for axis in homogeneous)
4646 or len(set(homogeneous)) != len(homogeneous)):
4647 raise ValueError(
4648 f"field statistics recipe for [{label}]: 'homogeneous' must list distinct axes from "
4649 f"['i', 'j', 'k'] (got {homogeneous!r})."
4650 )
4651 # An ensemble has no convergence history, so its CSV output is the profile alone.
4652 if ensembles and "csv" in formats and not homogeneous:
4653 raise ValueError(
4654 f"field statistics recipe for [{label}]: an ensemble writes CSV only as a profile, "
4655 "so 'csv' needs 'homogeneous' to name the directions to merge over."
4656 )
4657
4658 return {
4659 "windows": cleaned,
4660 "ensembles": ensembles,
4661 "homogeneous": [axis for axis in ("i", "j", "k") if axis in homogeneous],
4662 "source_step": source_step,
4663 "outputs": list(outputs),
4664 "formats": list(formats),
4665 }
4666
4667
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _post_window_derived_field_count()

int picurv_cli.core._post_window_derived_field_count ( dict  window_cfg,
list  outputs,
dict   eligible = None 
)
protected

Count the derived fields one window would produce for a set of outputs.

Mirrors the resolution the C derivation performs, so a recipe that would produce an empty file is refused before the run rather than after it. Each output resolves against what the window accumulated: a window keeping only first moments has no stresses, RMS, or turbulent kinetic energy to give.

Parameters
[in]window_cfgNormalized window definition from monitor.yml.
[in]outputsRequested output kinds.
[in]eligibleField table the window's fields are named from; the Eulerian table when None.
Returns
Value returned by _post_window_derived_field_count().

Definition at line 4668 of file core.py.

4668def _post_window_derived_field_count(window_cfg: dict, outputs: list, eligible: dict = None) -> int:
4669 """!
4670 @brief Count the derived fields one window would produce for a set of outputs.
4671
4672 @details Mirrors the resolution the C derivation performs, so a recipe that would
4673 produce an empty file is refused before the run rather than after it. Each
4674 output resolves against what the window accumulated: a window keeping only
4675 first moments has no stresses, RMS, or turbulent kinetic energy to give.
4676
4677 @param[in] window_cfg Normalized window definition from monitor.yml.
4678 @param[in] outputs Requested output kinds.
4679 @param[in] eligible Field table the window's fields are named from; the Eulerian
4680 table when None.
4681 @return Value returned by `_post_window_derived_field_count()`.
4682 """
4683 eligible = STATISTICS_ELIGIBLE_FIELDS if eligible is None else eligible
4684 fields = window_cfg.get("fields", []) or []
4685 covariances = window_cfg.get("covariances", []) or []
4686 with_second = [f for f in fields if "second" in (f.get("moments") or [])]
4687 total = 0
4688 for kind in outputs:
4689 if kind == "mean":
4690 total += len(fields)
4691 elif kind == "reynolds_stress":
4692 for entry in with_second:
4693 dof = eligible[entry["field"]]["components"]
4694 total += 6 if dof == 3 else 1
4695 elif kind == "rms":
4696 for entry in with_second:
4697 total += eligible[entry["field"]]["components"]
4698 elif kind == "tke":
4699 total += sum(1 for entry in with_second
4700 if eligible[entry["field"]]["components"] == 3)
4701 elif kind == "flux":
4702 total += len(covariances)
4703 return total
4704
4705
Here is the caller graph for this function:

◆ _post_requests_field_statistics()

bool picurv_cli.core._post_requests_field_statistics ( dict  post_cfg)
protected

Return whether the current post recipe derives accumulated field statistics.

Parameters
[in]post_cfgArgument passed to _post_requests_field_statistics().
Returns
Value returned by _post_requests_field_statistics().

Definition at line 4706 of file core.py.

4706def _post_requests_field_statistics(post_cfg: dict) -> bool:
4707 """!
4708 @brief Return whether the current post recipe derives accumulated field statistics.
4709 @param[in] post_cfg Argument passed to `_post_requests_field_statistics()`.
4710 @return Value returned by `_post_requests_field_statistics()`.
4711 """
4712 try:
4713 config = normalize_post_field_statistics_config(post_cfg)
4714 return bool(config["windows"] or config["ensembles"])
4715 except ValueError:
4716 # Malformed configuration is reported by validation, not by this predicate.
4717 return False
4718
4719
Here is the call graph for this function:

◆ get_post_field_statistics_artifacts()

picurv_cli.core.get_post_field_statistics_artifacts ( dict  post_cfg,
str  run_dir 
)

Predict the per-window statistics artifacts a recipe will produce.

Returns one entry per window and format, so resume tracking can tell a half-finished window from a completed one.

Parameters
[in]post_cfgParsed post-processing configuration.
[in]run_dirRun directory the outputs are written under.
Returns
List of (kind, path_prefix, window) tuples; kind is 'vtk', 'csv' (a history shared by every step) or 'profile' (a prefix taking _<step>.csv). window is None for a recipe ensemble, which every processed step produces.

Definition at line 4720 of file core.py.

4720def get_post_field_statistics_artifacts(post_cfg: dict, run_dir: str):
4721 """!
4722 @brief Predict the per-window statistics artifacts a recipe will produce.
4723 @details Returns one entry per window and format, so resume tracking can tell a
4724 half-finished window from a completed one.
4725 @param[in] post_cfg Parsed post-processing configuration.
4726 @param[in] run_dir Run directory the outputs are written under.
4727 @return List of (kind, path_prefix, window) tuples; kind is 'vtk', 'csv' (a history
4728 shared by every step) or 'profile' (a prefix taking `_<step>.csv`). window is
4729 None for a recipe ensemble, which every processed step produces.
4730 """
4731 config = normalize_post_field_statistics_config(post_cfg)
4732 if not (config["windows"] or config["ensembles"]):
4733 return []
4734 io_cfg = post_cfg.get("io", {}) or {}
4735 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
4736 visualization_dir = _post_output_directory_abs(run_dir, post_cfg)
4737 visualization_prefix = io_cfg.get("output_filename_prefix", "Field")
4738 csv_prefix_path = None
4739 if internal.get("field_statistics_prefix"):
4740 csv_prefix_path = os.path.join(run_dir, internal["field_statistics_prefix"])
4741 csv_base = csv_prefix_path or os.path.join(visualization_dir, str(visualization_prefix))
4742 artifacts = []
4743 # An ensemble is built from the step itself, so every processed step produces it;
4744 # its entries carry no window, which marks them as always expected.
4745 owners = [(window, window) for window in config["windows"]]
4746 owners += [(ensemble["name"], None) for ensemble in config["ensembles"]]
4747 for name, window in owners:
4748 if "vtk" in config["formats"]:
4749 artifacts.append(("vtk", os.path.join(
4750 visualization_dir, f"{visualization_prefix}_statistics_{name}"
4751 ), window))
4752 if "csv" in config["formats"] and window is not None:
4753 artifacts.append(("csv", f"{csv_base}_statistics_{name}.csv", window))
4754 if "csv" in config["formats"] and config["homogeneous"]:
4755 artifacts.append(("profile", f"{csv_base}_profile_{name}", window))
4756 return artifacts
4757
4758
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_post_recipe_config()

dict picurv_cli.core.build_post_recipe_config ( dict  post_cfg,
  monitor_cfg = None 
)

Build the flat key=value mapping consumed by the C post-processor.

Parameters
[in]post_cfgArgument passed to build_post_recipe_config().
[in]monitor_cfgOptional parsed monitor YAML configuration dictionary.
Returns
Value returned by build_post_recipe_config().

Definition at line 4759 of file core.py.

4759def build_post_recipe_config(post_cfg: dict, monitor_cfg=None) -> dict:
4760 """!
4761 @brief Build the flat key=value mapping consumed by the C post-processor.
4762 @param[in] post_cfg Argument passed to `build_post_recipe_config()`.
4763 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
4764 @return Value returned by `build_post_recipe_config()`.
4765 """
4766 c_config = {}
4767
4768 c_config['startTime'] = get_post_run_control_value(post_cfg, 'start_step', 0)
4769 c_config['endTime'] = get_post_run_control_value(post_cfg, 'end_step', 0)
4770 c_config['timeStep'] = get_post_run_control_value(post_cfg, 'step_interval', 1)
4771
4772 eulerian_pipeline_parts = []
4773 dimensionalize = bool(post_cfg.get('global_operations', {}).get('dimensionalize', False))
4774 if dimensionalize:
4775 # A setting rather than a pipeline stage: loaded fields are scaled as they are
4776 # read, derived statistics in the accumulator, particles after their MSD, and
4777 # spectra in their generator, each once, where the value is produced.
4778 c_config['dimensionalize'] = 'true'
4779
4780 for task in post_cfg.get('eulerian_pipeline', []):
4781 task_name = task.get('task')
4782 if task_name == 'q_criterion':
4783 eulerian_pipeline_parts.append('ComputeQCriterion')
4784 elif task_name == 'normalize_field':
4785 field = task.get('field', 'P')
4786 eulerian_pipeline_parts.append(f'NormalizeRelativeField:{field}')
4787 ref_point = task.get('reference_point', [1, 1, 1])
4788 c_config['reference_ip'] = ref_point[0]
4789 c_config['reference_jp'] = ref_point[1]
4790 c_config['reference_kp'] = ref_point[2]
4791 elif task_name == 'nodal_average':
4792 in_field = task.get('input_field')
4793 out_field = task.get('output_field')
4794 if in_field and out_field:
4795 eulerian_pipeline_parts.append(f'CellToNodeAverage:{in_field}>{out_field}')
4796
4797 if eulerian_pipeline_parts:
4798 c_config['process_pipeline'] = ";".join(eulerian_pipeline_parts)
4799
4800 lagrangian_pipeline_parts = []
4801 for task in post_cfg.get('lagrangian_pipeline', []):
4802 task_name = task.get('task')
4803 if task_name == 'specific_ke':
4804 in_field = task.get('input_field')
4805 out_field = task.get('output_field')
4806 if in_field and out_field:
4807 lagrangian_pipeline_parts.append(f'ComputeSpecificKE:{in_field}>{out_field}')
4808 if lagrangian_pipeline_parts:
4809 c_config['particle_pipeline'] = ";".join(lagrangian_pipeline_parts)
4810
4811 statistics_pipeline_parts = get_post_statistics_task_tokens(post_cfg)
4812 statistics_output_prefix = None
4813 stats_cfg = post_cfg.get('statistics_pipeline')
4814 if isinstance(stats_cfg, dict):
4815 statistics_output_prefix = stats_cfg.get('output_prefix')
4816
4817 if statistics_pipeline_parts:
4818 c_config['statistics_pipeline'] = ";".join(statistics_pipeline_parts)
4819 statistics_output_prefix = resolve_post_statistics_output_prefix(post_cfg, monitor_cfg)
4820 elif statistics_output_prefix is None:
4821 statistics_output_prefix = post_cfg.get('statistics_output_prefix')
4822 if statistics_output_prefix:
4823 c_config['statistics_output_prefix'] = statistics_output_prefix
4824
4825 io = post_cfg.get('io', {})
4826 internal_paths = post_cfg.get('_picurv_paths', {}) or {}
4827 field_statistics = normalize_post_field_statistics_config(post_cfg)
4828 if field_statistics["windows"] or field_statistics["ensembles"]:
4829 c_config['field_statistics_windows'] = ",".join(field_statistics["windows"])
4830 c_config['field_statistics_outputs'] = ",".join(field_statistics["outputs"])
4831 c_config['field_statistics_formats'] = ",".join(field_statistics["formats"])
4832 # An omitted source step means each processed step derives from its own
4833 # bundle, which is what turns a multi-step recipe into a convergence history.
4834 if field_statistics["source_step"] is not None:
4835 c_config['field_statistics_source_step'] = field_statistics["source_step"]
4836 if internal_paths.get("field_statistics_prefix"):
4837 c_config['field_statistics_output_prefix'] = internal_paths["field_statistics_prefix"]
4838 if field_statistics["homogeneous"]:
4839 c_config['field_statistics_homogeneous'] = ",".join(field_statistics["homogeneous"])
4840 # Ensemble lines are the run windows' option families under the ensemble kind;
4841 # the post-processor forwards them to the window parser it shares.
4842 if field_statistics["ensembles"]:
4843 c_config['field_statistics_ensemble_count'] = len(field_statistics["ensembles"])
4844 for index, ensemble in enumerate(field_statistics["ensembles"]):
4845 prefix = f"field_statistics_ensemble_{index}"
4846 c_config[f'{prefix}_name'] = ensemble["name"]
4847 c_config[f'{prefix}_source'] = ensemble["source"]
4848 c_config[f'{prefix}_field_count'] = len(ensemble["fields"])
4849 for field_index, field_entry in enumerate(ensemble["fields"]):
4850 c_config[f'{prefix}_field_{field_index}_name'] = field_entry["field"]
4851 c_config[f'{prefix}_field_{field_index}_moments'] = ",".join(field_entry["moments"])
4852 c_config[f'{prefix}_covariance_count'] = len(ensemble["covariances"])
4853 for pair_index, pair in enumerate(ensemble["covariances"]):
4854 c_config[f'{prefix}_covariance_{pair_index}'] = f"{pair[0]},{pair[1]}"
4855
4856 c_config['output_prefix'] = io.get('output_directory', 'viz') + '/' + io.get('output_filename_prefix', 'Field')
4857 c_config['particle_output_prefix'] = io.get('output_directory', 'viz') + '/' + io.get('particle_filename_prefix', 'Particle')
4858 c_config['output_particles'] = io.get('output_particles', False)
4859 c_config['particle_output_freq'] = io.get('particle_subsampling_frequency', 1)
4860 c_config['output_fields_instantaneous'] = ",".join(io.get('eulerian_fields', []))
4861 c_config['particle_fields_instantaneous'] = ",".join(io.get('particle_fields', []))
4862 input_extensions = get_post_input_extensions(post_cfg)
4863 if isinstance(input_extensions, dict):
4864 for extension_name in ('eulerian', 'particle'):
4865 extension = input_extensions.get(extension_name)
4866 if extension and str(extension).strip().lstrip('.').lower() != 'dat':
4867 raise ValueError(
4868 f"post input extension '{extension_name}' must be 'dat'; "
4869 "committed checkpoint payload names are fixed."
4870 )
4871
4872 source_directory = get_post_source_directory_template(post_cfg, default=None)
4873 if source_directory is not None:
4874 c_config['source_directory'] = source_directory
4875
4876 # Spectra run in the conductor's Python stage, but the recipe fingerprint is
4877 # computed over this mapping, so the block has to be represented here or a
4878 # changed spectra recipe would leave its steps' records looking valid. The C
4879 # post-processor accepts and ignores the key.
4880 spectra = normalize_post_spectra_config(post_cfg)
4881 if spectra["tasks"]:
4882 c_config['spectra_signature'] = compute_post_spectra_signature(spectra)
4883
4884 return c_config
4885
4886
Here is the call graph for this function:
Here is the caller graph for this function:

◆ compute_post_recipe_id()

str picurv_cli.core.compute_post_recipe_id ( dict  post_cfg)

Compute a stable human-readable identity for one post recipe.

Parameters
[in]post_cfgParsed post configuration before runtime path injection.
Returns
Filesystem-safe recipe identity.

Definition at line 4887 of file core.py.

4887def compute_post_recipe_id(post_cfg: dict) -> str:
4888 """!
4889 @brief Compute a stable human-readable identity for one post recipe.
4890 @param[in] post_cfg Parsed post configuration before runtime path injection.
4891 @return Filesystem-safe recipe identity.
4892 """
4893 normalized = copy.deepcopy(post_cfg or {})
4894 normalized.pop("_picurv_paths", None)
4895 # source_data.directory is always replaced by the run's own output, so an absent
4896 # block and an emptied one are the same recipe; both hash as the emptied form that
4897 # every earlier recipe, which carried the directory, reduced to.
4898 source = normalized.setdefault("source_data", {})
4899 if isinstance(source, dict):
4900 source.pop("directory", None)
4901 io = normalized.get("io")
4902 label = "post"
4903 if isinstance(io, dict):
4904 io.pop("output_directory", None)
4905 # ParaView collection generation changes only how existing VTK artifacts are
4906 # indexed. It must not split an otherwise identical computational recipe,
4907 # especially when a child enables lineage indexing for an ancestor whose VTK
4908 # files predate the option.
4909 io.pop("paraview_series", None)
4910 raw_label = io.get("output_filename_prefix")
4911 if isinstance(raw_label, str) and raw_label.strip():
4912 label = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw_label.strip()).strip("-") or "post"
4913 run_control = normalized.get("run_control")
4914 if isinstance(run_control, dict):
4915 for key in POST_RUN_CONTROL_ALIASES["start_step"] + POST_RUN_CONTROL_ALIASES["end_step"]:
4916 run_control.pop(key, None)
4917 # Reducers read the recipe's outputs without changing them, so adding or revising
4918 # one must not move the recipe to a new home and recompute every field.
4919 normalized.pop("reducers", None)
4920 digest = hashlib.sha256(
4921 json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode("utf-8")
4922 ).hexdigest()[:12]
4923 return f"{label}-{digest}"
4924
4925
Here is the caller graph for this function:

◆ apply_canonical_post_paths()

"tuple[dict, str]" picurv_cli.core.apply_canonical_post_paths ( dict  post_cfg,
str  run_dir 
)

Route every post artifact into its fixed analysis or visualization home.

Parameters
[in]post_cfgParsed user recipe.
[in]run_dirRun receiving derived artifacts.
Returns
Runtime-only config copy and stable recipe id.

Definition at line 4926 of file core.py.

4926def apply_canonical_post_paths(post_cfg: dict, run_dir: str) -> "tuple[dict, str]":
4927 """!
4928 @brief Route every post artifact into its fixed analysis or visualization home.
4929 @param[in] post_cfg Parsed user recipe.
4930 @param[in] run_dir Run receiving derived artifacts.
4931 @return Runtime-only config copy and stable recipe id.
4932 """
4933 recipe_id = compute_post_recipe_id(post_cfg)
4934 runtime = copy.deepcopy(post_cfg)
4935 if not isinstance(runtime.get("source_data"), dict):
4936 runtime["source_data"] = {}
4937 runtime["source_data"]["directory"] = os.path.join(
4938 os.path.abspath(run_dir), CANONICAL_RUN_PATHS["output"]
4939 )
4940 io = runtime.setdefault("io", {})
4941 io["output_directory"] = os.path.join(CANONICAL_RUN_PATHS["visualization"], recipe_id)
4942 output_prefix = io.get("output_filename_prefix", "Field")
4943 stats_cfg = runtime.get("statistics_pipeline")
4944 if isinstance(stats_cfg, dict):
4945 basename = stats_cfg.get("output_prefix", "Stats")
4946 stats_cfg["output_prefix"] = os.path.join(
4947 CANONICAL_RUN_PATHS["statistics"], recipe_id, os.path.basename(str(basename))
4948 )
4949 runtime["_picurv_paths"] = {
4950 "recipe_id": recipe_id,
4951 "recipe_root": os.path.join("config", "post-recipes", recipe_id),
4952 "visualization": io["output_directory"],
4953 "statistics": os.path.join(CANONICAL_RUN_PATHS["statistics"], recipe_id),
4954 "field_statistics_prefix": os.path.join(
4955 CANONICAL_RUN_PATHS["statistics"], recipe_id, str(output_prefix)
4956 ),
4957 "spectra": os.path.join(CANONICAL_RUN_PATHS["spectra"], recipe_id),
4958 }
4959 return runtime, recipe_id
4960
4961
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_post_recipe_signature()

dict picurv_cli.core.normalize_post_recipe_signature ( dict  recipe_cfg)

Normalize post recipe settings into a stable signature mapping.

Parameters
[in]recipe_cfgArgument passed to normalize_post_recipe_signature().
Returns
Value returned by normalize_post_recipe_signature().

Definition at line 4962 of file core.py.

4962def normalize_post_recipe_signature(recipe_cfg: dict) -> dict:
4963 """!
4964 @brief Normalize post recipe settings into a stable signature mapping.
4965 @param[in] recipe_cfg Argument passed to `normalize_post_recipe_signature()`.
4966 @return Value returned by `normalize_post_recipe_signature()`.
4967 """
4968 signature = {}
4969 for key, value in (recipe_cfg or {}).items():
4970 if key in POST_RECIPE_SIGNATURE_EXCLUDED_KEYS or value is None:
4971 continue
4972 if isinstance(value, bool):
4973 text = 'true' if value else 'false'
4974 else:
4975 text = str(value).strip()
4976 if text.lower() in {'true', 'false'}:
4977 text = text.lower()
4978 if text:
4979 signature[str(key)] = text
4980 return signature
4981
4982
Here is the caller graph for this function:

◆ compute_post_recipe_fingerprint()

"tuple[dict, str]" picurv_cli.core.compute_post_recipe_fingerprint ( dict  recipe_cfg)

Return normalized recipe signature plus SHA-256 fingerprint.

Parameters
[in]recipe_cfgArgument passed to compute_post_recipe_fingerprint().
Returns
Value returned by compute_post_recipe_fingerprint().

Definition at line 4983 of file core.py.

4983def compute_post_recipe_fingerprint(recipe_cfg: dict) -> "tuple[dict, str]":
4984 """!
4985 @brief Return normalized recipe signature plus SHA-256 fingerprint.
4986 @param[in] recipe_cfg Argument passed to `compute_post_recipe_fingerprint()`.
4987 @return Value returned by `compute_post_recipe_fingerprint()`.
4988 """
4989 signature = normalize_post_recipe_signature(recipe_cfg)
4990 payload = json.dumps(signature, sort_keys=True, separators=(',', ':')).encode('utf-8')
4991 return signature, hashlib.sha256(payload).hexdigest()
4992
4993
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_post_resume_state_path()

str picurv_cli.core.get_post_resume_state_path ( str  run_dir,
dict   post_cfg = None 
)

Return the JSON resume metadata path for a run directory.

Parameters
[in]run_dirArgument passed to get_post_resume_state_path().
[in]post_cfgOptional versioned post recipe configuration.
Returns
Value returned by get_post_resume_state_path().

Definition at line 4994 of file core.py.

4994def get_post_resume_state_path(run_dir: str, post_cfg: dict = None) -> str:
4995 """!
4996 @brief Return the JSON resume metadata path for a run directory.
4997 @param[in] run_dir Argument passed to `get_post_resume_state_path()`.
4998 @param[in] post_cfg Optional versioned post recipe configuration.
4999 @return Value returned by `get_post_resume_state_path()`.
5000 """
5001 if post_cfg is None:
5002 return os.path.join(run_dir, 'config', POST_RESUME_STATE_FILENAME)
5003 return os.path.join(get_post_recipe_root(run_dir, post_cfg), "state.json")
5004
5005
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_post_recipe_root()

str picurv_cli.core.get_post_recipe_root ( str  run_dir,
dict  post_cfg 
)

Return the versioned run-local control directory for one post recipe.

Parameters
[in]run_dirOwning run root.
[in]post_cfgRuntime post config with canonical paths, or a user recipe.
Returns
Absolute recipe control directory.

Definition at line 5006 of file core.py.

5006def get_post_recipe_root(run_dir: str, post_cfg: dict) -> str:
5007 """!
5008 @brief Return the versioned run-local control directory for one post recipe.
5009 @param[in] run_dir Owning run root.
5010 @param[in] post_cfg Runtime post config with canonical paths, or a user recipe.
5011 @return Absolute recipe control directory.
5012 """
5013 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
5014 recipe_id = internal.get("recipe_id") or compute_post_recipe_id(post_cfg)
5015 return os.path.join(os.path.abspath(run_dir), "config", "post-recipes", recipe_id)
5016
5017
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_post_lock_paths()

dict picurv_cli.core.get_post_lock_paths ( str  run_dir,
str   recipe_id = None 
)

Return lock-wrapper related paths for a run directory.

Parameters
[in]run_dirArgument passed to get_post_lock_paths().
[in]recipe_idOptional stable recipe identity used to scope the lock.
Returns
Value returned by get_post_lock_paths().

Definition at line 5018 of file core.py.

5018def get_post_lock_paths(run_dir: str, recipe_id: str = None) -> dict:
5019 """!
5020 @brief Return lock-wrapper related paths for a run directory.
5021 @param[in] run_dir Argument passed to `get_post_lock_paths()`.
5022 @param[in] recipe_id Optional stable recipe identity used to scope the lock.
5023 @return Value returned by `get_post_lock_paths()`.
5024 """
5025 scheduler_dir = os.path.join(run_dir, 'scheduler')
5026 suffix = f".{recipe_id}" if recipe_id else ""
5027 return {
5028 'lock_file': os.path.join(scheduler_dir, f"post{suffix}.lock"),
5029 'metadata_file': os.path.join(scheduler_dir, f"post{suffix}.lock.json"),
5030 'wrapper_path': os.path.join(scheduler_dir, POST_LOCK_WRAPPER_FILENAME),
5031 }
5032
5033
Here is the caller graph for this function:

◆ _post_output_directory_abs()

str picurv_cli.core._post_output_directory_abs ( str  run_dir,
dict  post_cfg 
)
protected

Resolve the absolute post output directory for the current recipe.

Parameters
[in]run_dirArgument passed to _post_output_directory_abs().
[in]post_cfgArgument passed to _post_output_directory_abs().
Returns
Value returned by _post_output_directory_abs().

Definition at line 5034 of file core.py.

5034def _post_output_directory_abs(run_dir: str, post_cfg: dict) -> str:
5035 """!
5036 @brief Resolve the absolute post output directory for the current recipe.
5037 @param[in] run_dir Argument passed to `_post_output_directory_abs()`.
5038 @param[in] post_cfg Argument passed to `_post_output_directory_abs()`.
5039 @return Value returned by `_post_output_directory_abs()`.
5040 """
5041 io_cfg = post_cfg.get('io', {}) or {}
5042 internal = (post_cfg or {}).get("_picurv_paths", {}) or {}
5043 relative = internal.get("visualization") or io_cfg.get('output_directory')
5044 if not relative:
5045 relative = os.path.join(CANONICAL_RUN_PATHS["visualization"], compute_post_recipe_id(post_cfg))
5046 return os.path.abspath(os.path.join(run_dir, relative))
5047
5048
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _post_requests_eulerian_output()

bool picurv_cli.core._post_requests_eulerian_output ( dict  post_cfg)
protected

Return whether the current post recipe expects Eulerian VTK output artifacts.

Parameters
[in]post_cfgArgument passed to _post_requests_eulerian_output().
Returns
Value returned by _post_requests_eulerian_output().

Definition at line 5049 of file core.py.

5049def _post_requests_eulerian_output(post_cfg: dict) -> bool:
5050 """!
5051 @brief Return whether the current post recipe expects Eulerian VTK output artifacts.
5052 @param[in] post_cfg Argument passed to `_post_requests_eulerian_output()`.
5053 @return Value returned by `_post_requests_eulerian_output()`.
5054 """
5055 io_cfg = post_cfg.get('io', {}) or {}
5056 return bool(io_cfg.get('eulerian_fields'))
5057
5058
Here is the caller graph for this function:

◆ _post_requests_particle_output()

bool picurv_cli.core._post_requests_particle_output ( dict  post_cfg)
protected

Return whether the current post recipe expects particle VTP output artifacts.

Parameters
[in]post_cfgArgument passed to _post_requests_particle_output().
Returns
Value returned by _post_requests_particle_output().

Definition at line 5059 of file core.py.

5059def _post_requests_particle_output(post_cfg: dict) -> bool:
5060 """!
5061 @brief Return whether the current post recipe expects particle VTP output artifacts.
5062 @param[in] post_cfg Argument passed to `_post_requests_particle_output()`.
5063 @return Value returned by `_post_requests_particle_output()`.
5064 """
5065 io_cfg = post_cfg.get('io', {}) or {}
5066 return bool(io_cfg.get('output_particles')) and bool(io_cfg.get('particle_fields'))
5067
5068
Here is the caller graph for this function:

◆ normalize_post_paraview_series_config()

dict picurv_cli.core.normalize_post_paraview_series_config ( dict  post_cfg)

Normalize the optional ParaView collection policy in a post recipe.

Parameters
[in]post_cfgParsed post-processing configuration.
Returns
Mapping with enabled and scope keys.

Definition at line 5072 of file core.py.

5072def normalize_post_paraview_series_config(post_cfg: dict) -> dict:
5073 """!
5074 @brief Normalize the optional ParaView collection policy in a post recipe.
5075 @param[in] post_cfg Parsed post-processing configuration.
5076 @return Mapping with enabled and scope keys.
5077 """
5078 raw = ((post_cfg or {}).get("io") or {}).get("paraview_series")
5079 if raw is None:
5080 return {"enabled": False, "scope": "run"}
5081 if isinstance(raw, bool):
5082 return {"enabled": raw, "scope": "lineage" if raw else "run"}
5083 if not isinstance(raw, dict):
5084 raise ValueError("'io.paraview_series' must be a boolean or mapping.")
5085 enabled = raw.get("enabled", True)
5086 scope = str(raw.get("scope", "lineage")).strip().lower()
5087 if not isinstance(enabled, bool):
5088 raise ValueError("'io.paraview_series.enabled' must be a boolean.")
5089 if scope not in POST_PARAVIEW_SERIES_SCOPES:
5090 raise ValueError("'io.paraview_series.scope' must be 'run' or 'lineage'.")
5091 return {"enabled": enabled, "scope": scope}
5092
5093
Here is the caller graph for this function:

◆ _resolve_lineage_parent_run()

str picurv_cli.core._resolve_lineage_parent_run ( str  child_run_dir,
dict  lineage 
)
protected

Resolve and identity-check one recorded lineage parent.

Parameters
[in]child_run_dirChild run directory used to resolve workspace-relative paths.
[in]lineageRecorded branch-lineage mapping.
Returns
Absolute path to the identity-matched parent run.

Definition at line 5094 of file core.py.

5094def _resolve_lineage_parent_run(child_run_dir: str, lineage: dict) -> str:
5095 """!
5096 @brief Resolve and identity-check one recorded lineage parent.
5097 @param[in] child_run_dir Child run directory used to resolve workspace-relative paths.
5098 @param[in] lineage Recorded branch-lineage mapping.
5099 @return Absolute path to the identity-matched parent run.
5100 """
5101 recorded = lineage.get("parent_path")
5102 parent_id = lineage.get("parent_run_id")
5103 if not recorded or not parent_id:
5104 raise ValueError(f"Run lineage is missing parent_path or parent_run_id: {child_run_dir}")
5105 if os.path.isabs(str(recorded)):
5106 candidates = [os.path.abspath(str(recorded))]
5107 else:
5108 workspace_root = find_workspace_root(child_run_dir)
5109 candidates = ([os.path.abspath(os.path.join(workspace_root, str(recorded)))]
5110 if workspace_root else [])
5111 workspace_root = find_workspace_root(child_run_dir)
5112 if workspace_root:
5113 runs_root = os.path.join(workspace_root, "runs")
5114 if os.path.isdir(runs_root):
5115 candidates.extend(
5116 os.path.join(runs_root, name) for name in os.listdir(runs_root)
5117 if os.path.isdir(os.path.join(runs_root, name))
5118 )
5119 seen = set()
5120 for candidate in candidates:
5121 candidate = os.path.abspath(candidate)
5122 if candidate in seen or not os.path.isdir(candidate):
5123 continue
5124 seen.add(candidate)
5125 try:
5126 identity = read_artifact_identity(candidate)
5127 except (OSError, ValueError, KeyError):
5128 continue
5129 if identity.get("run_id") == parent_id:
5130 return candidate
5131 raise ValueError(
5132 f"Lineage parent '{parent_id}' for run '{os.path.basename(child_run_dir)}' "
5133 f"could not be resolved from recorded path '{recorded}'."
5134 )
5135
5136
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_run_lineage()

list picurv_cli.core.resolve_run_lineage ( str  run_dir,
str   scope = "lineage" 
)

Return root-to-leaf run segments, for a ParaView collection or a reducer's metrics.

Parameters
[in]run_dirLeaf run whose lineage is resolved.
[in]scopeEither run-local or full-lineage collection scope.
Returns
Ordered run-segment mappings from root to leaf.

Each entry carries the run path, manifest, and the checkpoint at which that run branched from its parent. Cycles and unresolved parents fail.

Definition at line 5137 of file core.py.

5137def resolve_run_lineage(run_dir: str, scope: str = "lineage") -> list:
5138 """!
5139 @brief Return root-to-leaf run segments, for a ParaView collection or a reducer's metrics.
5140 @param[in] run_dir Leaf run whose lineage is resolved.
5141 @param[in] scope Either run-local or full-lineage collection scope.
5142 @return Ordered run-segment mappings from root to leaf.
5143 @details Each entry carries the run path, manifest, and the checkpoint at which
5144 that run branched from its parent. Cycles and unresolved parents fail.
5145 """
5146 current = os.path.abspath(run_dir)
5147 reverse = []
5148 seen_ids = set()
5149 while True:
5150 manifest = _read_json_if_exists(os.path.join(current, "manifest.json")) or {}
5151 run_id = manifest.get("run_id") or os.path.basename(current)
5152 if run_id in seen_ids:
5153 chain = " -> ".join([entry["run_id"] for entry in reversed(reverse)] + [run_id])
5154 raise ValueError(f"Cycle detected while resolving visualization lineage: {chain}")
5155 seen_ids.add(run_id)
5156 lineage = manifest.get("lineage") or {"relationship": "root"}
5157 reverse.append({
5158 "run_dir": current,
5159 "run_id": run_id,
5160 "manifest": manifest,
5161 "lineage": lineage,
5162 "fork_step": (int(lineage["checkpoint_step"])
5163 if lineage.get("relationship") == "branch" else None),
5164 })
5165 if scope == "run" or lineage.get("relationship") != "branch":
5166 break
5167 current = _resolve_lineage_parent_run(current, lineage)
5168 return list(reversed(reverse))
5169
5170
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _existing_pvd_times()

dict picurv_cli.core._existing_pvd_times ( str  pvd_path)
protected

Read step-to-time fallbacks from an existing PVD collection.

Parameters
[in]pvd_pathExisting collection path.
Returns
Mapping from output step to physical time.

Definition at line 5171 of file core.py.

5171def _existing_pvd_times(pvd_path: str) -> dict:
5172 """!
5173 @brief Read step-to-time fallbacks from an existing PVD collection.
5174 @param[in] pvd_path Existing collection path.
5175 @return Mapping from output step to physical time.
5176 """
5177 if not os.path.isfile(pvd_path):
5178 return {}
5179 try:
5180 root = ElementTree.parse(pvd_path).getroot()
5181 except (ElementTree.ParseError, OSError):
5182 return {}
5183 result = {}
5184 for dataset in root.findall(".//DataSet"):
5185 filename = os.path.basename(dataset.get("file", ""))
5186 match = re.search(r"_(\d+)\.(?:vts|vtp)$", filename)
5187 try:
5188 if match:
5189 result[int(match.group(1))] = float(dataset.get("timestep"))
5190 except (TypeError, ValueError):
5191 continue
5192 return result
5193
5194
Here is the caller graph for this function:

◆ _checkpoint_time_for_series()

float picurv_cli.core._checkpoint_time_for_series ( str  run_dir,
int  step,
dict  fallback,
float   time_scale = 1.0 
)
protected

Read the authoritative time of one visualization frame.

Checkpoints record solver time. A dimensionalized recipe writes physical frames, so its collection reports physical time: the checkpoint time multiplied by the owning run's reference time.

Parameters
[in]run_dirRun owning the frame.
[in]stepFrame checkpoint step.
[in]fallbackExisting collection times, already in the collection's units, used after checkpoint pruning.
[in]time_scaleFactor from solver time to the collection's time unit.
Returns
Frame time in the collection's unit system.

Definition at line 5195 of file core.py.

5195def _checkpoint_time_for_series(run_dir: str, step: int, fallback: dict, time_scale: float = 1.0) -> float:
5196 """!
5197 @brief Read the authoritative time of one visualization frame.
5198 @details Checkpoints record solver time. A dimensionalized recipe writes physical
5199 frames, so its collection reports physical time: the checkpoint time
5200 multiplied by the owning run's reference time.
5201 @param[in] run_dir Run owning the frame.
5202 @param[in] step Frame checkpoint step.
5203 @param[in] fallback Existing collection times, already in the collection's units,
5204 used after checkpoint pruning.
5205 @param[in] time_scale Factor from solver time to the collection's time unit.
5206 @return Frame time in the collection's unit system.
5207 """
5208 metadata_path = os.path.join(
5209 run_dir, CANONICAL_RUN_PATHS["checkpoints"],
5210 f"step_{step:0{CHECKPOINT_STEP_WIDTH}d}", "checkpoint.meta",
5211 )
5212 commit_path = os.path.join(os.path.dirname(metadata_path), "COMMITTED")
5213 if os.path.isfile(metadata_path) and os.path.isfile(commit_path):
5214 options = _read_checkpoint_options(metadata_path)
5215 try:
5216 recorded_step = int(options["checkpoint_step"])
5217 physical_time = float(options["checkpoint_time"])
5218 except (KeyError, TypeError, ValueError) as exc:
5219 raise ValueError(f"Checkpoint time metadata is malformed: {metadata_path}") from exc
5220 if recorded_step != step or not math.isfinite(physical_time):
5221 raise ValueError(f"Checkpoint time metadata does not match step {step}: {metadata_path}")
5222 return physical_time * time_scale
5223 if step in fallback:
5224 return float(fallback[step])
5225 raise ValueError(
5226 f"Visualization frame at step {step} has no locally available checkpoint time in "
5227 f"'{run_dir}'. Restore its checkpoints or retain an existing PVD carrying the time."
5228 )
5229
5230
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _write_pvd_collection()

None picurv_cli.core._write_pvd_collection ( str  path,
list  entries 
)
protected

Atomically write one VTK Collection file.

Parameters
[in]pathDestination PVD path.
[in]entriesOrdered frame mappings.
Returns
None.

Definition at line 5231 of file core.py.

5231def _write_pvd_collection(path: str, entries: list) -> None:
5232 """!
5233 @brief Atomically write one VTK Collection file.
5234 @param[in] path Destination PVD path.
5235 @param[in] entries Ordered frame mappings.
5236 @return None.
5237 """
5238 root = ElementTree.Element("VTKFile", {
5239 "type": "Collection", "version": "0.1", "byte_order": "LittleEndian",
5240 })
5241 collection = ElementTree.SubElement(root, "Collection")
5242 base = os.path.dirname(os.path.abspath(path))
5243 for entry in entries:
5244 ElementTree.SubElement(collection, "DataSet", {
5245 "timestep": format(float(entry["time"]), ".17g"),
5246 "group": "", "part": "0",
5247 "file": os.path.relpath(entry["path"], base).replace(os.sep, "/"),
5248 })
5249 content = '<?xml version="1.0"?>\n' + ElementTree.tostring(root, encoding="unicode") + "\n"
5250 os.makedirs(base, exist_ok=True)
5251 temporary = f"{path}.tmp.{os.getpid()}"
5252 try:
5253 with open(temporary, "w", encoding="utf-8") as stream:
5254 stream.write(content)
5255 stream.flush()
5256 os.fsync(stream.fileno())
5257 os.replace(temporary, path)
5258 finally:
5259 if os.path.exists(temporary):
5260 os.unlink(temporary)
5261
5262
Here is the caller graph for this function:

◆ _lineage_reset_index()

int picurv_cli.core._lineage_reset_index ( list  segments,
str  family 
)
protected

Return the first segment belonging to one continuous artifact family.

Parameters
[in]segmentsRoot-to-leaf lineage segments.
[in]familyArtifact family being indexed.
Returns
Index of the first segment after the last relevant reset.

Definition at line 5263 of file core.py.

5263def _lineage_reset_index(segments: list, family: str) -> int:
5264 """!
5265 @brief Return the first segment belonging to one continuous artifact family.
5266 @param[in] segments Root-to-leaf lineage segments.
5267 @param[in] family Artifact family being indexed.
5268 @return Index of the first segment after the last relevant reset.
5269 """
5270 start = 0
5271 for index, segment in enumerate(segments[1:], start=1):
5272 if family == "particle":
5273 case_path = os.path.join(segment["run_dir"], "config", "case.yml")
5274 case_cfg = read_yaml_file(case_path) if os.path.isfile(case_path) else {}
5275 particles = (((case_cfg.get("models") or {}).get("physics") or {}).get("particles") or {})
5276 if str(particles.get("restart_mode", "init")).strip().lower() != "load":
5277 start = index
5278 elif family == "statistics":
5279 if str(segment["lineage"].get("statistics_state", "reset")).lower() != "carry":
5280 start = index
5281 return start
5282
5283
Here is the call graph for this function:
Here is the caller graph for this function:

◆ finalize_post_paraview_series()

list picurv_cli.core.finalize_post_paraview_series ( str  run_dir,
dict  post_cfg 
)

Build physical-time ParaView collections from current or lineage VTK output.

Parameters
[in]run_dirLeaf run receiving the collection.
[in]post_cfgParsed post-processing recipe.
Returns
Absolute PVD paths written. Disabled recipes return an empty list.

Definition at line 5284 of file core.py.

5284def finalize_post_paraview_series(run_dir: str, post_cfg: dict) -> list:
5285 """!
5286 @brief Build physical-time ParaView collections from current or lineage VTK output.
5287 @param[in] run_dir Leaf run receiving the collection.
5288 @param[in] post_cfg Parsed post-processing recipe.
5289 @return Absolute PVD paths written. Disabled recipes return an empty list.
5290 """
5291 policy = normalize_post_paraview_series_config(post_cfg)
5292 if not policy["enabled"]:
5293 return []
5294 run_dir = os.path.abspath(run_dir)
5295 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
5296 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
5297 recipe_id = post_cfg["_picurv_paths"]["recipe_id"]
5298 requested_start, requested_end, _interval = resolve_post_requested_window(
5299 post_cfg,
5300 read_yaml_file(os.path.join(run_dir, "config", "case.yml")),
5301 )
5302 segments = resolve_run_lineage(run_dir, policy["scope"])
5303 io_cfg = post_cfg.get("io", {}) or {}
5304 families = []
5305 if _post_requests_eulerian_output(post_cfg):
5306 families.append(("field", io_cfg.get("output_filename_prefix", "Field"), "vts"))
5307 if _post_requests_particle_output(post_cfg):
5308 families.append(("particle", io_cfg.get("particle_filename_prefix", "Particle"), "vtp"))
5309 for kind, prefix_path, _window in get_post_field_statistics_artifacts(post_cfg, run_dir):
5310 if kind == "vtk":
5311 families.append(("statistics", os.path.basename(prefix_path), "vts"))
5312
5313 # Each run's frames are timed by that run's own reference time, so a lineage stays
5314 # physically continuous even across runs whose scales differ.
5315 dimensionalize = bool((post_cfg.get("global_operations") or {}).get("dimensionalize", False))
5316
5317 def segment_time_scale(segment: dict) -> float:
5318 """!
5319 @brief Return the factor from one lineage run's solver time to the collection's time.
5320 @param[in] segment Lineage segment.
5321 @return Reference time of the run when dimensionalizing, otherwise 1.
5322 """
5323 if not dimensionalize:
5324 return 1.0
5325 case_path = os.path.join(segment["run_dir"], "config", "case.yml")
5326 return reference_scale(TIME, resolve_reference_scales(read_yaml_file(case_path)))
5327
5328 written = []
5329 for family, prefix, extension in families:
5330 active_segments = segments[_lineage_reset_index(segments, family):]
5331 entries = []
5332 for index, segment in enumerate(active_segments):
5333 lower = requested_start
5334 if segment["fork_step"] is not None:
5335 lower = max(lower, int(segment["fork_step"]))
5336 upper = requested_end
5337 if index + 1 < len(active_segments):
5338 upper = min(upper, int(active_segments[index + 1]["fork_step"]) - 1)
5339 if lower > upper:
5340 continue
5341 viz_dir = os.path.join(
5342 segment["run_dir"], CANONICAL_RUN_PATHS["visualization"], recipe_id
5343 )
5344 pvd_path = os.path.join(viz_dir, f"{prefix}.pvd")
5345 fallback = _existing_pvd_times(pvd_path)
5346 pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)\.{re.escape(extension)}$")
5347 found = []
5348 if os.path.isdir(viz_dir):
5349 for name in os.listdir(viz_dir):
5350 match = pattern.match(name)
5351 if match and lower <= int(match.group(1)) <= upper:
5352 found.append((int(match.group(1)), os.path.join(viz_dir, name)))
5353 if not found and index + 1 < len(active_segments):
5354 raise ValueError(
5355 f"Lineage run '{segment['run_id']}' has no {prefix} visualization "
5356 f"frames for its required segment {lower}..{upper} and recipe '{recipe_id}'."
5357 )
5358 for step, frame_path in sorted(found):
5359 entries.append({
5360 "step": step,
5361 "time": _checkpoint_time_for_series(segment["run_dir"], step, fallback,
5362 segment_time_scale(segment)),
5363 "path": os.path.abspath(frame_path),
5364 "run_id": segment["run_id"],
5365 })
5366 if not entries:
5367 continue
5368 for previous, current in zip(entries, entries[1:]):
5369 if current["time"] <= previous["time"]:
5370 raise ValueError(
5371 f"ParaView series time is not strictly increasing: {previous['run_id']} "
5372 f"step {previous['step']} has {previous['time']:.17g}, followed by "
5373 f"{current['run_id']} step {current['step']} at {current['time']:.17g}."
5374 )
5375 output_dir = _post_output_directory_abs(run_dir, post_cfg)
5376 output_path = os.path.join(output_dir, f"{prefix}.pvd")
5377 _write_pvd_collection(output_path, entries)
5378 print(
5379 f"[SUCCESS] Wrote ParaView {family} series: {output_path} "
5380 f"({len(entries)} frame(s), time {entries[0]['time']:.17g}..{entries[-1]['time']:.17g})"
5381 )
5382 written.append(output_path)
5383 return written
5384
5385
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _post_finalize_python_source()

str picurv_cli.core._post_finalize_python_source ( )
protected

Return the importable serial finalizer used by generated jobs.

Returns
Python source suitable for a serial interpreter invocation.

Definition at line 5386 of file core.py.

5386def _post_finalize_python_source() -> str:
5387 """!
5388 @brief Return the importable serial finalizer used by generated jobs.
5389 @return Python source suitable for a serial interpreter invocation.
5390 """
5391 package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5392 return (
5393 "import os,sys;"
5394 f"sys.path.insert(0,{package_root!r});"
5395 "from picurv_cli.core import read_yaml_file,apply_canonical_post_paths,finalize_post_outputs;"
5396 "run=os.path.abspath(sys.argv[1]);cfg=read_yaml_file(sys.argv[2]);"
5397 "cfg,_=apply_canonical_post_paths(cfg,run);finalize_post_outputs(run,cfg,(sys.argv[3] if len(sys.argv)>3 else '').split(','))"
5398 )
5399
5400
Here is the caller graph for this function:

◆ build_post_finalize_command()

list picurv_cli.core.build_post_finalize_command ( str  run_dir,
str  archived_post_path,
dict  post_cfg,
  post_stages = POST_STAGE_NAMES 
)

Build the serial post-success command for staged execution.

It runs after the field post-processor succeeds, in the conductor or in the submitted job, so whatever reads the field stage's output belongs here: the ParaView collections and the reducers.

Parameters
[in]run_dirRun receiving the collection.
[in]archived_post_pathArchived post recipe read by the finalizer.
[in]post_cfgParsed post-processing recipe.
[in]post_stagesSelected post stages; reducers run only when selected.
Returns
Command tokens, or an empty list when there is nothing to finalize.

Definition at line 5401 of file core.py.

5402 post_stages=POST_STAGE_NAMES) -> list:
5403 """!
5404 @brief Build the serial post-success command for staged execution.
5405 @details It runs after the field post-processor succeeds, in the conductor or in the
5406 submitted job, so whatever reads the field stage's output belongs here: the
5407 ParaView collections and the reducers.
5408 @param[in] run_dir Run receiving the collection.
5409 @param[in] archived_post_path Archived post recipe read by the finalizer.
5410 @param[in] post_cfg Parsed post-processing recipe.
5411 @param[in] post_stages Selected post stages; reducers run only when selected.
5412 @return Command tokens, or an empty list when there is nothing to finalize.
5413 """
5414 reducers = "reducers" in post_stages and bool(normalize_post_reducers_config(post_cfg)["tasks"])
5415 if not normalize_post_paraview_series_config(post_cfg)["enabled"] and not reducers:
5416 return []
5417 return [
5418 sys.executable, "-c", _post_finalize_python_source(),
5419 os.path.abspath(run_dir), os.path.abspath(archived_post_path),
5420 "reducers" if reducers else "-",
5421 ]
5422
5423
Here is the call graph for this function:
Here is the caller graph for this function:

◆ finalize_post_outputs()

None picurv_cli.core.finalize_post_outputs ( str  run_dir,
dict  post_cfg,
  stages 
)

Run every serial step that reads a successful field stage's output.

Parameters
[in]run_dirRun directory.
[in]post_cfgRecipe with canonical paths applied.
[in]stagesStage names the finalizer covers beyond the collections.
Returns
None.

Definition at line 5424 of file core.py.

5424def finalize_post_outputs(run_dir: str, post_cfg: dict, stages) -> None:
5425 """!
5426 @brief Run every serial step that reads a successful field stage's output.
5427 @param[in] run_dir Run directory.
5428 @param[in] post_cfg Recipe with canonical paths applied.
5429 @param[in] stages Stage names the finalizer covers beyond the collections.
5430 @return None.
5431 """
5432 finalize_post_paraview_series(run_dir, post_cfg)
5433 if "reducers" in stages:
5434 run_post_reducers_stage(run_dir, post_cfg)
5435
5436
Here is the call graph for this function:

◆ _post_requests_statistics()

bool picurv_cli.core._post_requests_statistics ( dict  post_cfg)
protected

Return whether the current post recipe expects statistics CSV artifacts.

Parameters
[in]post_cfgArgument passed to _post_requests_statistics().
Returns
Value returned by _post_requests_statistics().

Definition at line 5437 of file core.py.

5437def _post_requests_statistics(post_cfg: dict) -> bool:
5438 """!
5439 @brief Return whether the current post recipe expects statistics CSV artifacts.
5440 @param[in] post_cfg Argument passed to `_post_requests_statistics()`.
5441 @return Value returned by `_post_requests_statistics()`.
5442 """
5443 return bool(get_post_statistics_task_tokens(post_cfg))
5444
5445
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _post_needs_particle_source()

bool picurv_cli.core._post_needs_particle_source ( dict  post_cfg)
protected

Return whether the current post recipe requires particle source files to be present.

Parameters
[in]post_cfgArgument passed to _post_needs_particle_source().
Returns
Value returned by _post_needs_particle_source().

Definition at line 5446 of file core.py.

5446def _post_needs_particle_source(post_cfg: dict) -> bool:
5447 """!
5448 @brief Return whether the current post recipe requires particle source files to be present.
5449 @param[in] post_cfg Argument passed to `_post_needs_particle_source()`.
5450 @return Value returned by `_post_needs_particle_source()`.
5451 """
5452 io_cfg = post_cfg.get('io', {}) or {}
5453 return bool(io_cfg.get('output_particles')) or bool(post_cfg.get('lagrangian_pipeline')) or _post_requests_statistics(post_cfg)
5454
5455
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _iter_post_steps()

picurv_cli.core._iter_post_steps ( int  start_step,
int  end_step,
int  step_interval 
)
protected

Yield configured post-processing steps inclusively.

Parameters
[in]start_stepArgument passed to _iter_post_steps().
[in]end_stepArgument passed to _iter_post_steps().
[in]step_intervalArgument passed to _iter_post_steps().

Definition at line 5456 of file core.py.

5456def _iter_post_steps(start_step: int, end_step: int, step_interval: int):
5457 """!
5458 @brief Yield configured post-processing steps inclusively.
5459 @param[in] start_step Argument passed to `_iter_post_steps()`.
5460 @param[in] end_step Argument passed to `_iter_post_steps()`.
5461 @param[in] step_interval Argument passed to `_iter_post_steps()`.
5462 """
5463 if step_interval <= 0 or end_step < start_step:
5464 return
5465 step = start_step
5466 while step <= end_step:
5467 yield step
5468 step += step_interval
5469
5470
Here is the caller graph for this function:

◆ resolve_post_requested_window()

"tuple[int, int, int]" picurv_cli.core.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.

Parameters
[in]post_cfgArgument passed to resolve_post_requested_window().
[in]case_cfgOptional case configuration for end-step expansion.
Returns
Value returned by resolve_post_requested_window().

Definition at line 5471 of file core.py.

5471def resolve_post_requested_window(post_cfg: dict, case_cfg: dict = None) -> "tuple[int, int, int]":
5472 """!
5473 @brief Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
5474 @param[in] post_cfg Argument passed to `resolve_post_requested_window()`.
5475 @param[in] case_cfg Optional case configuration for end-step expansion.
5476 @return Value returned by `resolve_post_requested_window()`.
5477 """
5478 start_step = int(get_post_run_control_value(post_cfg, 'start_step', 0) or 0)
5479 end_step = int(get_post_run_control_value(post_cfg, 'end_step', 0) or 0)
5480 step_interval = int(get_post_run_control_value(post_cfg, 'step_interval', 1))
5481 if step_interval <= 0:
5482 raise ValueError('run_control.step_interval must be positive.')
5483 if end_step < 0 and case_cfg:
5484 case_run = case_cfg.get('run_control', {}) or {}
5485 case_start = int(case_run.get('start_step', 0) or 0)
5486 case_total = int(case_run.get('total_steps', 0) or 0)
5487 end_step = case_start + case_total
5488 return start_step, end_step, step_interval
5489
5490
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_post_owned_start()

int picurv_cli.core.resolve_post_owned_start ( str  run_dir,
dict  post_cfg,
int  requested_start,
int  step_interval 
)

Intersect a recipe's logical cadence with this run's branch.

A branch run holds checkpoints only from the step it forked at, so steps before it belong to its ancestors and never appear here; the run's window starts at its first cadence step at or after the fork.

Parameters
[in]run_dirRun whose branch ownership is inspected.
[in]post_cfgParsed post-processing recipe.
[in]requested_startLogical recipe start step.
[in]step_intervalLogical recipe cadence.
Returns
First cadence step owned by the run.

Definition at line 5491 of file core.py.

5492 step_interval: int) -> int:
5493 """!
5494 @brief Intersect a recipe's logical cadence with this run's branch.
5495 @details A branch run holds checkpoints only from the step it forked at, so steps
5496 before it belong to its ancestors and never appear here; the run's window
5497 starts at its first cadence step at or after the fork.
5498 @param[in] run_dir Run whose branch ownership is inspected.
5499 @param[in] post_cfg Parsed post-processing recipe.
5500 @param[in] requested_start Logical recipe start step.
5501 @param[in] step_interval Logical recipe cadence.
5502 @return First cadence step owned by the run.
5503 """
5504 del post_cfg
5505 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
5506 lineage = manifest.get("lineage") or {}
5507 if lineage.get("relationship") != "branch":
5508 return requested_start
5509 fork_step = int(lineage["checkpoint_step"])
5510 if fork_step <= requested_start:
5511 return requested_start
5512 intervals = (fork_step - requested_start + step_interval - 1) // step_interval
5513 return requested_start + intervals * step_interval
5514
5515
Here is the call graph for this function:
Here is the caller graph for this function:

◆ prepare_effective_post_config()

dict picurv_cli.core.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.

Parameters
[in]post_cfgArgument passed to prepare_effective_post_config().
[in]resolved_source_dirArgument passed to prepare_effective_post_config().
[in]start_stepArgument passed to prepare_effective_post_config().
[in]end_stepArgument passed to prepare_effective_post_config().
Returns
Value returned by prepare_effective_post_config().

Definition at line 5516 of file core.py.

5516def prepare_effective_post_config(post_cfg: dict, resolved_source_dir: str, start_step: int = None, end_step: int = None) -> dict:
5517 """!
5518 @brief Return a copy of post_cfg with resolved source dir and optional effective bounds.
5519 @param[in] post_cfg Argument passed to `prepare_effective_post_config()`.
5520 @param[in] resolved_source_dir Argument passed to `prepare_effective_post_config()`.
5521 @param[in] start_step Argument passed to `prepare_effective_post_config()`.
5522 @param[in] end_step Argument passed to `prepare_effective_post_config()`.
5523 @return Value returned by `prepare_effective_post_config()`.
5524 """
5525 effective_cfg = copy.deepcopy(post_cfg)
5526 if not isinstance(effective_cfg.get('source_data'), dict):
5527 effective_cfg['source_data'] = {}
5528 effective_cfg['source_data']['directory'] = resolved_source_dir
5529 rc = effective_cfg.setdefault('run_control', {})
5530 if start_step is not None:
5531 rc['start_step'] = int(start_step)
5532 if end_step is not None:
5533 rc['end_step'] = int(end_step)
5534 return effective_cfg
5535
5536
Here is the caller graph for this function:

◆ _scan_post_vtk_steps()

"dict[int, str]" picurv_cli.core._scan_post_vtk_steps ( str  prefix_path,
str  extension 
)
protected

Collect the VTK files named with a prefix, step suffix, and extension.

Parameters
[in]prefix_pathOutput path prefix before the numeric step suffix.
[in]extensionVTK file extension to match without its leading dot.
Returns
Mapping of each step number to its file path in the prefix directory.

Definition at line 5537 of file core.py.

5537def _scan_post_vtk_steps(prefix_path: str, extension: str) -> "dict[int, str]":
5538 """!
5539 @brief Collect the VTK files named with a prefix, step suffix, and extension.
5540 @param[in] prefix_path Output path prefix before the numeric step suffix.
5541 @param[in] extension VTK file extension to match without its leading dot.
5542 @return Mapping of each step number to its file path in the prefix directory.
5543 """
5544 directory = os.path.dirname(prefix_path)
5545 if not os.path.isdir(directory):
5546 return {}
5547 basename = os.path.basename(prefix_path)
5548 pattern = re.compile(rf'^{re.escape(basename)}_(\d+)\.{re.escape(extension)}$')
5549 steps = {}
5550 for name in os.listdir(directory):
5551 match = pattern.match(name)
5552 if match:
5553 steps[int(match.group(1))] = os.path.join(directory, name)
5554 return steps
5555
5556
Here is the caller graph for this function:

◆ _scan_post_statistics_csv_steps()

"set[int]" picurv_cli.core._scan_post_statistics_csv_steps ( str  csv_path)
protected

Scan step ids from the first CSV column of a statistics artifact.

Parameters
[in]csv_pathArgument passed to _scan_post_statistics_csv_steps().
Returns
Value returned by _scan_post_statistics_csv_steps().

Definition at line 5557 of file core.py.

5557def _scan_post_statistics_csv_steps(csv_path: str) -> "set[int]":
5558 """!
5559 @brief Scan step ids from the first CSV column of a statistics artifact.
5560 @param[in] csv_path Argument passed to `_scan_post_statistics_csv_steps()`.
5561 @return Value returned by `_scan_post_statistics_csv_steps()`.
5562 """
5563 if not os.path.isfile(csv_path):
5564 return set()
5565 steps = set()
5566 with open(csv_path, 'r', encoding='utf-8', errors='replace', newline='') as f:
5567 reader = csv.reader(f)
5568 for row in reader:
5569 if not row:
5570 continue
5571 head = str(row[0]).strip().lower()
5572 if head in {'step', 'timestep', 'time_step'}:
5573 continue
5574 step_val = _parse_int_loose(row[0])
5575 if step_val is not None:
5576 steps.add(step_val)
5577 return steps
5578
5579
Here is the call graph for this function:
Here is the caller graph for this function:

◆ collect_post_completion_families()

"list[tuple]" picurv_cli.core.collect_post_completion_families ( str  run_dir,
dict  post_cfg,
  monitor_cfg = None 
)

Collect the output files each output family of the current recipe holds per step.

Parameters
[in]run_dirArgument passed to collect_post_completion_families().
[in]post_cfgArgument passed to collect_post_completion_families().
[in]monitor_cfgOptional parsed monitor YAML configuration dictionary.
Returns
(step-to-path mapping, window, per_step) per family; window names the field-statistics window a family belongs to, or is None for a family every processed step must produce; per_step is False for a CSV shared by all steps.

Definition at line 5580 of file core.py.

5580def collect_post_completion_families(run_dir: str, post_cfg: dict, monitor_cfg=None) -> "list[tuple]":
5581 """!
5582 @brief Collect the output files each output family of the current recipe holds per step.
5583 @param[in] run_dir Argument passed to `collect_post_completion_families()`.
5584 @param[in] post_cfg Argument passed to `collect_post_completion_families()`.
5585 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
5586 @return (step-to-path mapping, window, per_step) per family; window names the
5587 field-statistics window a family belongs to, or is None for a family every
5588 processed step must produce; per_step is False for a CSV shared by all steps.
5589 """
5590 io_cfg = post_cfg.get('io', {}) or {}
5591 output_dir_abs = _post_output_directory_abs(run_dir, post_cfg)
5592 families = []
5593
5594 def csv_family(path):
5595 """!
5596 @brief Map every step a shared CSV holds to that CSV.
5597 @param[in] path CSV artifact path.
5598 @return Mapping of step to the CSV path.
5599 """
5600 return {step: path for step in _scan_post_statistics_csv_steps(path)}
5601
5602 if _post_requests_eulerian_output(post_cfg):
5603 prefix = os.path.join(output_dir_abs, io_cfg.get('output_filename_prefix', 'Field'))
5604 families.append((_scan_post_vtk_steps(prefix, 'vts'), None, True))
5605
5606 if _post_requests_particle_output(post_cfg):
5607 prefix = os.path.join(output_dir_abs, io_cfg.get('particle_filename_prefix', 'Particle'))
5608 families.append((_scan_post_vtk_steps(prefix, 'vtp'), None, True))
5609
5610 for stats_path in get_post_statistics_output_artifacts(post_cfg, run_dir, monitor_cfg):
5611 families.append((csv_family(stats_path), None, False))
5612
5613 # Each window and format is its own family, so a run that produced the Eulerian
5614 # fields but not the statistics is not mistaken for a completed step.
5615 for kind, path, window in get_post_field_statistics_artifacts(post_cfg, run_dir):
5616 if kind == 'vtk':
5617 families.append((_scan_post_vtk_steps(path, 'vts'), window, True))
5618 elif kind == 'profile':
5619 families.append((_scan_post_vtk_steps(path, 'csv'), window, True))
5620 else:
5621 families.append((csv_family(path), window, False))
5622
5623 return families
5624
5625
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _field_statistics_window_samples()

picurv_cli.core._field_statistics_window_samples ( str  run_dir,
int  step 
)
protected

Read each field-statistics window's sample count from one committed checkpoint.

Parameters
[in]run_dirRun directory whose output holds the checkpoint.
[in]stepCheckpoint step.
Returns
Mapping of window name to sample count, or None when the metadata is absent (never written, pruned, or offloaded).

Definition at line 5626 of file core.py.

5626def _field_statistics_window_samples(run_dir: str, step: int):
5627 """!
5628 @brief Read each field-statistics window's sample count from one committed checkpoint.
5629 @param[in] run_dir Run directory whose output holds the checkpoint.
5630 @param[in] step Checkpoint step.
5631 @return Mapping of window name to sample count, or None when the metadata is absent
5632 (never written, pruned, or offloaded).
5633 """
5634 metadata = os.path.join(
5635 _checkpoint_bundle_path(os.path.join(run_dir, CANONICAL_RUN_PATHS["output"]), step),
5636 "checkpoint.meta",
5637 )
5638 if not os.path.isfile(metadata):
5639 return None
5640 options = _read_checkpoint_options(metadata)
5641 samples = {}
5642 for index in range(int(options.get("checkpoint_statistics_window_count", 0) or 0)):
5643 name = options.get(f"checkpoint_statistics_window_{index}_name")
5644 if name is not None:
5645 samples[name] = int(options.get(f"checkpoint_statistics_window_{index}_sample_count", 0) or 0)
5646 return samples
5647
5648
Here is the call graph for this function:
Here is the caller graph for this function:

◆ post_step_outputs()

picurv_cli.core.post_step_outputs ( str  run_dir,
dict  post_cfg,
  monitor_cfg = None 
)

Return a lookup of the output files one step of the current recipe holds.

A step is complete when every family it must produce has its output. The post-processor writes a statistics window's output at a step only if the window had accumulated a sample in the bundle it reads, the pinned source_step or the step itself, so a window that had not opened yet is not required there.

Parameters
[in]run_dirRun whose output is inspected.
[in]post_cfgRuntime recipe with canonical paths.
[in]monitor_cfgParsed monitor configuration.
Returns
Function mapping a step to (per-step files, shared files), or None when the step lacks an output it must have.

Definition at line 5649 of file core.py.

5649def post_step_outputs(run_dir: str, post_cfg: dict, monitor_cfg=None):
5650 """!
5651 @brief Return a lookup of the output files one step of the current recipe holds.
5652 @details A step is complete when every family it must produce has its output. The
5653 post-processor writes a statistics window's output at a step only if the
5654 window had accumulated a sample in the bundle it reads, the pinned
5655 source_step or the step itself, so a window that had not opened yet is not
5656 required there.
5657 @param[in] run_dir Run whose output is inspected.
5658 @param[in] post_cfg Runtime recipe with canonical paths.
5659 @param[in] monitor_cfg Parsed monitor configuration.
5660 @return Function mapping a step to (per-step files, shared files), or None when the
5661 step lacks an output it must have.
5662 """
5663 families = collect_post_completion_families(run_dir, post_cfg, monitor_cfg)
5664 pinned = normalize_post_field_statistics_config(post_cfg).get("source_step")
5665 sample_cache = {}
5666
5667 def expected(window, step):
5668 """!
5669 @brief Whether a family's output must exist at this step.
5670 @param[in] window Field-statistics window the family belongs to, or None.
5671 @param[in] step Processed step.
5672 @return True when the family is always required or its window had samples.
5673 """
5674 if window is None:
5675 return True
5676 source = int(pinned) if pinned is not None else step
5677 if source not in sample_cache:
5678 sample_cache[source] = _field_statistics_window_samples(run_dir, source)
5679 samples = sample_cache[source]
5680 # Without the checkpoint's record the output cannot be ruled out, so it stays required.
5681 return samples is None or samples.get(window, 0) > 0
5682
5683 def outputs(step):
5684 """!
5685 @brief Collect one step's output files.
5686 @param[in] step Processed step.
5687 @return (per-step files, shared files), or None when an expected output is missing.
5688 """
5689 own, shared = [], []
5690 for mapping, window, per_step in families:
5691 if step in mapping:
5692 (own if per_step else shared).append(mapping[step])
5693 elif expected(window, step):
5694 return None
5695 return own, shared
5696
5697 return outputs
5698
5699
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _nearest_step()

picurv_cli.core._nearest_step ( "set[int]"  steps,
int  target 
)
protected

Return the complete source step nearest to a target step.

Parameters
[in]stepsCandidate step numbers.
[in]targetTarget step number.
Returns
Nearest candidate, or None when no candidates exist.

Definition at line 5700 of file core.py.

5700def _nearest_step(steps: "set[int]", target: int):
5701 """!
5702 @brief Return the complete source step nearest to a target step.
5703 @param[in] steps Candidate step numbers.
5704 @param[in] target Target step number.
5705 @return Nearest candidate, or None when no candidates exist.
5706 """
5707 if not steps:
5708 return None
5709 return min(steps, key=lambda step: (abs(step - target), step))
5710
5711
Here is the caller graph for this function:

◆ _format_optional_step()

str picurv_cli.core._format_optional_step (   step)
protected

Format an optional step number for user-facing diagnostics.

Parameters
[in]stepStep number or None.
Returns
Printable step text.

Definition at line 5712 of file core.py.

5712def _format_optional_step(step) -> str:
5713 """!
5714 @brief Format an optional step number for user-facing diagnostics.
5715 @param[in] step Step number or None.
5716 @return Printable step text.
5717 """
5718 return 'none' if step is None else str(step)
5719
5720

◆ _scan_complete_source_steps()

"tuple[set[int], dict]" picurv_cli.core._scan_complete_source_steps ( str  source_dir,
dict  monitor_cfg,
dict  post_cfg 
)
protected

Scan source artifacts and return steps with every file required by the recipe.

Parameters
[in]source_dirSource output root directory.
[in]monitor_cfgParsed monitor configuration.
[in]post_cfgParsed post-processing configuration.
Returns
Tuple of complete source steps and source path metadata.

Definition at line 5721 of file core.py.

5721def _scan_complete_source_steps(source_dir: str, monitor_cfg: dict, post_cfg: dict) -> "tuple[set[int], dict]":
5722 """!
5723 @brief Scan source artifacts and return steps with every file required by the recipe.
5724 @param[in] source_dir Source output root directory.
5725 @param[in] monitor_cfg Parsed monitor configuration.
5726 @param[in] post_cfg Parsed post-processing configuration.
5727 @return Tuple of complete source steps and source path metadata.
5728 """
5729 del monitor_cfg
5730 require_particles = _post_needs_particle_source(post_cfg)
5731 complete_steps = _scan_committed_checkpoint_steps(
5732 source_dir, require_particles=require_particles
5733 )
5734 return complete_steps, {
5735 'source_dir': os.path.abspath(source_dir),
5736 'require_particles': require_particles,
5737 }
5738
5739
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _expected_source_paths_for_step()

"list[str]" picurv_cli.core._expected_source_paths_for_step ( int  step,
dict  source_scan,
dict  post_cfg 
)
protected

Build required source file paths for a single post-processing step.

Parameters
[in]stepRequested step number.
[in]source_scanMetadata returned by _scan_complete_source_steps().
[in]post_cfgParsed post-processing configuration.
Returns
Required source artifact paths.

Definition at line 5740 of file core.py.

5740def _expected_source_paths_for_step(step: int, source_scan: dict, post_cfg: dict) -> "list[str]":
5741 """!
5742 @brief Build required source file paths for a single post-processing step.
5743 @param[in] step Requested step number.
5744 @param[in] source_scan Metadata returned by `_scan_complete_source_steps()`.
5745 @param[in] post_cfg Parsed post-processing configuration.
5746 @return Required source artifact paths.
5747 """
5748 del post_cfg
5749 bundle = _checkpoint_bundle_path(source_scan['source_dir'], step)
5750 paths = [os.path.join(bundle, 'checkpoint.meta'), os.path.join(bundle, 'COMMITTED')]
5751 if source_scan.get('require_particles'):
5752 paths.append(os.path.join(bundle, 'particles', 'position.dat'))
5753 return paths
5754
5755
Here is the call graph for this function:
Here is the caller graph for this function:

◆ detect_post_source_frontier()

dict picurv_cli.core.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.

Parameters
[in]source_dirArgument passed to detect_post_source_frontier().
[in]monitor_cfgArgument passed to detect_post_source_frontier().
[in]post_cfgArgument passed to detect_post_source_frontier().
[in]start_stepArgument passed to detect_post_source_frontier().
[in]end_stepArgument passed to detect_post_source_frontier().
[in]step_intervalArgument passed to detect_post_source_frontier().
Returns
Value returned by detect_post_source_frontier().

Definition at line 5756 of file core.py.

5756def detect_post_source_frontier(source_dir: str, monitor_cfg: dict, post_cfg: dict, start_step: int, end_step: int, step_interval: int) -> dict:
5757 """!
5758 @brief Detect the highest contiguous fully available source step for live post-processing.
5759 @param[in] source_dir Argument passed to `detect_post_source_frontier()`.
5760 @param[in] monitor_cfg Argument passed to `detect_post_source_frontier()`.
5761 @param[in] post_cfg Argument passed to `detect_post_source_frontier()`.
5762 @param[in] start_step Argument passed to `detect_post_source_frontier()`.
5763 @param[in] end_step Argument passed to `detect_post_source_frontier()`.
5764 @param[in] step_interval Argument passed to `detect_post_source_frontier()`.
5765 @return Value returned by `detect_post_source_frontier()`.
5766 """
5767 diagnostic = {
5768 'first_requested_step': start_step,
5769 'first_incomplete_step': None,
5770 'missing_files_for_first_incomplete_step': [],
5771 'closest_complete_step_to_start': None,
5772 'closest_complete_step_to_end': None,
5773 }
5774 if step_interval <= 0 or end_step < start_step or not os.path.isdir(source_dir):
5775 return {
5776 'frontier_step': None,
5777 'diagnostic': diagnostic,
5778 }
5779
5780 complete_steps, source_scan = _scan_complete_source_steps(source_dir, monitor_cfg, post_cfg)
5781 diagnostic['closest_complete_step_to_start'] = _nearest_step(complete_steps, start_step)
5782 diagnostic['closest_complete_step_to_end'] = _nearest_step(complete_steps, end_step)
5783
5784 frontier = None
5785 for step in _iter_post_steps(start_step, end_step, step_interval):
5786 try:
5787 validate_committed_checkpoint(
5788 source_dir, step,
5789 require_particles=source_scan.get('require_particles', False),
5790 )
5791 except CheckpointIndexWidthError:
5792 raise
5793 except ValueError:
5794 expected_paths = _expected_source_paths_for_step(step, source_scan, post_cfg)
5795 diagnostic['first_incomplete_step'] = step
5796 diagnostic['missing_files_for_first_incomplete_step'] = [
5797 os.path.relpath(path, source_dir) for path in expected_paths if not os.path.isfile(path)
5798 ]
5799 if not diagnostic['missing_files_for_first_incomplete_step']:
5800 diagnostic['missing_files_for_first_incomplete_step'] = [
5801 os.path.relpath(_checkpoint_bundle_path(source_dir, step), source_dir)
5802 + ' (invalid bundle)'
5803 ]
5804 break
5805 frontier = step
5806 return {
5807 'frontier_step': frontier,
5808 'diagnostic': diagnostic,
5809 }
5810
5811
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _read_post_records()

"tuple[dict, dict]" picurv_cli.core._read_post_records ( str  state_path)
protected

Read the per-step output records a recipe's state holds.

Parameters
[in]state_pathThe recipe's state.json.
Returns
(field-stage records, spectra records), each keyed by the step as text; both empty for a state written before records existed.

Definition at line 5812 of file core.py.

5812def _read_post_records(state_path: str) -> "tuple[dict, dict]":
5813 """!
5814 @brief Read the per-step output records a recipe's state holds.
5815 @param[in] state_path The recipe's state.json.
5816 @return (field-stage records, spectra records), each keyed by the step as text; both
5817 empty for a state written before records existed.
5818 """
5819 state = _read_json_if_exists(state_path)
5820 if not isinstance(state, dict) or state.get("schema_version") != POST_RESUME_SCHEMA_VERSION:
5821 return {}, {}
5822 return dict(state.get("steps") or {}), dict(state.get("spectra") or {})
5823
5824
Here is the call graph for this function:
Here is the caller graph for this function:

◆ persist_post_resume_state()

picurv_cli.core.persist_post_resume_state ( str  run_dir,
dict  plan,
  steps = None,
  spectra = None 
)

Write a recipe's state: the window last planned and its per-step output records.

Parameters
[in]run_dirRun the recipe belongs to.
[in]planPlan from build_post_execution_plan().
[in]stepsField-stage records to merge in, keyed by step, or None.
[in]spectraSpectra records to merge in, keyed by step, or None.
Returns
Path of the written state file.

Definition at line 5825 of file core.py.

5825def persist_post_resume_state(run_dir: str, plan: dict, steps=None, spectra=None):
5826 """!
5827 @brief Write a recipe's state: the window last planned and its per-step output records.
5828 @param[in] run_dir Run the recipe belongs to.
5829 @param[in] plan Plan from build_post_execution_plan().
5830 @param[in] steps Field-stage records to merge in, keyed by step, or None.
5831 @param[in] spectra Spectra records to merge in, keyed by step, or None.
5832 @return Path of the written state file.
5833 """
5834 state_path = plan.get("resume_state_path") or get_post_resume_state_path(run_dir)
5835 field_records, spectra_records = _read_post_records(state_path)
5836 field_records.update({str(step): record for step, record in (steps or {}).items()})
5837 spectra_records.update({str(step): record for step, record in (spectra or {}).items()})
5838 write_json_file(state_path, {
5839 'schema_version': POST_RESUME_SCHEMA_VERSION,
5840 'run_id': plan.get('run_id'),
5841 'recipe_fingerprint': plan.get('recipe_fingerprint'),
5842 'recipe_signature': plan.get('recipe_signature'),
5843 'requested_start_step': plan.get('requested_start_step'),
5844 'requested_end_step': plan.get('requested_end_step'),
5845 'step_interval': plan.get('step_interval'),
5846 'source_directory': plan.get('source_data_directory'),
5847 'updated_at': datetime.now().isoformat(),
5848 'steps': dict(sorted(field_records.items(), key=lambda item: int(item[0]))),
5849 'spectra': dict(sorted(spectra_records.items(), key=lambda item: int(item[0]))),
5850 })
5851 return state_path
5852
5853
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _checkpoint_commit()

picurv_cli.core._checkpoint_commit ( str  source_dir,
int  step 
)
protected

Identify one committed checkpoint by its commit marker.

Parameters
[in]source_dirOutput root holding the checkpoints.
[in]stepCheckpoint step.
Returns
(marker digest, marker modification time), or None when the checkpoint is not local (never written, archived, or pruned).

Definition at line 5854 of file core.py.

5854def _checkpoint_commit(source_dir: str, step: int):
5855 """!
5856 @brief Identify one committed checkpoint by its commit marker.
5857 @param[in] source_dir Output root holding the checkpoints.
5858 @param[in] step Checkpoint step.
5859 @return (marker digest, marker modification time), or None when the checkpoint is not
5860 local (never written, archived, or pruned).
5861 """
5862 marker = os.path.join(_checkpoint_bundle_path(source_dir, step), "COMMITTED")
5863 try:
5864 with open(marker, "r", encoding="ascii") as stream:
5865 return stream.read().strip(), os.stat(marker).st_mtime
5866 except OSError:
5867 return None
5868
5869
Here is the call graph for this function:
Here is the caller graph for this function:

◆ classify_post_step()

"str | None" picurv_cli.core.classify_post_step (   record,
  files,
  commit,
str  recipe,
bool   recompute = False 
)

Decide whether one step's existing output is still valid.

Output stays valid while it was made by the same recipe from the checkpoint now on disk. The build that made it does not invalidate it: a rebuild changes every binary's hash, and redoing a whole window after each one is what --recompute is for. Output without a record (written before records existed, or by a job killed before it could record) is accepted when it is newer than its checkpoint, or when the checkpoint is no longer local.

Parameters
[in]recordThe step's record, or None.
[in]filesFiles the step's output consists of, or None when any is missing.
[in]commitCheckpoint identity from _checkpoint_commit(), or None.
[in]recipeCurrent recipe fingerprint.
[in]recomputeWhether the invocation regenerates every step.
Returns
None when the output is valid, else the reason it must be (re)made: missing, recompute, recipe, or checkpoint.

Definition at line 5870 of file core.py.

5870def classify_post_step(record, files, commit, recipe: str, recompute: bool = False) -> "str | None":
5871 """!
5872 @brief Decide whether one step's existing output is still valid.
5873 @details Output stays valid while it was made by the same recipe from the checkpoint
5874 now on disk. The build that made it does not invalidate it: a rebuild changes
5875 every binary's hash, and redoing a whole window after each one is what
5876 `--recompute` is for. Output without a record (written before records
5877 existed, or by a job killed before it could record) is accepted when it is
5878 newer than its checkpoint, or when the checkpoint is no longer local.
5879 @param[in] record The step's record, or None.
5880 @param[in] files Files the step's output consists of, or None when any is missing.
5881 @param[in] commit Checkpoint identity from _checkpoint_commit(), or None.
5882 @param[in] recipe Current recipe fingerprint.
5883 @param[in] recompute Whether the invocation regenerates every step.
5884 @return None when the output is valid, else the reason it must be (re)made:
5885 `missing`, `recompute`, `recipe`, or `checkpoint`.
5886 """
5887 if files is None or (not files and record is None):
5888 return "missing"
5889 if recompute:
5890 return "recompute"
5891 if record is None:
5892 if commit is None or min(os.path.getmtime(path) for path in files) >= commit[1]:
5893 return None
5894 return "checkpoint"
5895 if record.get("recipe") != recipe:
5896 return "recipe"
5897 if commit is not None and record.get("checkpoint") not in (None, commit[0]):
5898 return "checkpoint"
5899 return None
5900
5901
Here is the caller graph for this function:

◆ warn_overlapping_post_recipes()

None picurv_cli.core.warn_overlapping_post_recipes ( str  run_dir,
dict  post_cfg,
dict  plan,
  post_stages 
)

Warn when another recipe in this run already produced outputs this one repeats.

A recipe writes one file per step holding all its fields, so a recipe that repeats another's fields recomputes them rather than reusing them. The warning names the shared fields and statistics windows and the overlapping steps, so the new outputs can go into a recipe of their own instead.

Parameters
[in]run_dirRun being post-processed.
[in]post_cfgRuntime recipe with canonical paths.
[in]planPlan from build_post_execution_plan().
[in]post_stagesSelected post stages.
Returns
None.

Definition at line 5902 of file core.py.

5902def warn_overlapping_post_recipes(run_dir: str, post_cfg: dict, plan: dict, post_stages) -> None:
5903 """!
5904 @brief Warn when another recipe in this run already produced outputs this one repeats.
5905 @details A recipe writes one file per step holding all its fields, so a recipe that
5906 repeats another's fields recomputes them rather than reusing them. The
5907 warning names the shared fields and statistics windows and the overlapping
5908 steps, so the new outputs can go into a recipe of their own instead.
5909 @param[in] run_dir Run being post-processed.
5910 @param[in] post_cfg Runtime recipe with canonical paths.
5911 @param[in] plan Plan from build_post_execution_plan().
5912 @param[in] post_stages Selected post stages.
5913 @return None.
5914 """
5915 if 'fields' not in post_stages:
5916 return
5917 current_id = ((post_cfg.get("_picurv_paths") or {}).get("recipe_id")
5918 or compute_post_recipe_id(post_cfg))
5919 steps = set(plan['steps_to_process'])
5920 fields = set(((post_cfg.get('io') or {}).get('eulerian_fields')) or [])
5921 windows = set(normalize_post_field_statistics_config(post_cfg)["windows"])
5922 root = os.path.join(os.path.abspath(run_dir), "config", "post-recipes")
5923 if not steps or not (fields or windows) or not os.path.isdir(root):
5924 return
5925 for other_id in sorted(os.listdir(root)):
5926 archived = os.path.join(root, other_id, "post.yml")
5927 if other_id == current_id or not os.path.isfile(archived):
5928 continue
5929 try:
5930 other_cfg, resolved_id = apply_canonical_post_paths(read_yaml_file(archived), run_dir)
5931 except Exception: # an unreadable archived recipe says nothing about overlap
5932 continue
5933 if resolved_id != other_id:
5934 continue
5935 labels, done = [], set()
5936 shared_fields = fields & set(((other_cfg.get('io') or {}).get('eulerian_fields')) or [])
5937 if shared_fields:
5938 prefix = os.path.join(_post_output_directory_abs(run_dir, other_cfg),
5939 (other_cfg.get('io') or {}).get('output_filename_prefix', 'Field'))
5940 found = steps & _scan_post_vtk_steps(prefix, 'vts').keys()
5941 if found:
5942 labels.append(f"fields {', '.join(sorted(shared_fields))}")
5943 done |= found
5944 shared_windows = []
5945 for kind, path, window in get_post_field_statistics_artifacts(other_cfg, run_dir):
5946 if kind == 'vtk' and window in windows:
5947 found = steps & _scan_post_vtk_steps(path, 'vts').keys()
5948 if found:
5949 shared_windows.append(window)
5950 done |= found
5951 if shared_windows:
5952 labels.append(f"statistics windows {', '.join(shared_windows)}")
5953 if labels:
5954 print(f"[WARNING] Recipe {other_id} already produced {'; '.join(labels)} for "
5955 f"{len(done)} of these steps ({min(done)}..{max(done)}); this recipe computes "
5956 "them again. If only some outputs are new, a recipe holding just those avoids "
5957 "the repeat.", file=sys.stderr)
5958
5959
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _build_post_lock_wrapper_source()

str picurv_cli.core._build_post_lock_wrapper_source ( )
protected

Return the Python wrapper that runs one post job under an exclusive lock.

Given --post-recipe, the wrapper plans the job's steps as it starts (prepare_post_job()), skips the postprocessor when none need work, and records the steps produced when the postprocessor exits, even on failure (finish_post_job()). It imports the conductor from this installation, so it must run under the conductor's interpreter.

Returns
Value returned by _build_post_lock_wrapper_source().

Definition at line 5960 of file core.py.

5960def _build_post_lock_wrapper_source() -> str:
5961 """!
5962 @brief Return the Python wrapper that runs one post job under an exclusive lock.
5963 @details Given `--post-recipe`, the wrapper plans the job's steps as it starts
5964 (prepare_post_job()), skips the postprocessor when none need work, and
5965 records the steps produced when the postprocessor exits, even on failure
5966 (finish_post_job()). It imports the conductor from this installation, so it
5967 must run under the conductor's interpreter.
5968 @return Value returned by `_build_post_lock_wrapper_source()`.
5969 """
5970 package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5971 return """#!/usr/bin/env python3
5972import argparse
5973import fcntl
5974import json
5975import os
5976import socket
5977import subprocess
5978import sys
5979import time
5980
5981PICURV_PACKAGE_ROOT = """ + repr(package_root) + """
5982
5983
5984def main():
5985 parser = argparse.ArgumentParser(description='PICurv post-stage lock wrapper')
5986 parser.add_argument('--lock-file', required=True)
5987 parser.add_argument('--metadata-file', required=True)
5988 parser.add_argument('--run-dir', required=True)
5989 parser.add_argument('--recipe-fingerprint', default='')
5990 parser.add_argument('--post-recipe')
5991 parser.add_argument('--postprocessor')
5992 parser.add_argument('--recompute', action='store_true')
5993 parser.add_argument('--success-command-json')
5994 parser.add_argument('command', nargs=argparse.REMAINDER)
5995 args = parser.parse_args()
5996
5997 command = list(args.command or [])
5998 if not command or command[0] != '--':
5999 parser.error("expected '-- <command ...>' after wrapper arguments")
6000 command = command[1:]
6001
6002 os.makedirs(os.path.dirname(args.lock_file), exist_ok=True)
6003 fd = os.open(args.lock_file, os.O_RDWR | os.O_CREAT, 0o644)
6004 try:
6005 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
6006 except BlockingIOError:
6007 owner = None
6008 try:
6009 with open(args.metadata_file, 'r', encoding='utf-8') as handle:
6010 owner = json.load(handle)
6011 except Exception:
6012 owner = None
6013 if owner:
6014 print(
6015 f"[FATAL] Post stage already active for {args.run_dir} "
6016 f"(pid={owner.get('pid')}, host={owner.get('host')}, started_at={owner.get('started_at')}).",
6017 file=sys.stderr,
6018 )
6019 else:
6020 print(f"[FATAL] Post stage already active for {args.run_dir}.", file=sys.stderr)
6021 return 2
6022
6023 metadata = {
6024 'pid': os.getpid(),
6025 'host': socket.gethostname(),
6026 'started_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
6027 'run_dir': args.run_dir,
6028 'recipe_fingerprint': args.recipe_fingerprint,
6029 'command': command,
6030 }
6031 with open(args.metadata_file, 'w', encoding='utf-8') as handle:
6032 json.dump(metadata, handle, indent=2, sort_keys=True)
6033 handle.write('\\n')
6034
6035 try:
6036 planned = None
6037 if args.post_recipe:
6038 sys.path.insert(0, PICURV_PACKAGE_ROOT)
6039 from picurv_cli.core import finish_post_job, prepare_post_job
6040 planned = prepare_post_job(args.run_dir, args.post_recipe, args.postprocessor,
6041 recompute=args.recompute)
6042 sys.stdout.flush()
6043 if planned != 0:
6044 result = None
6045 try:
6046 result = subprocess.run(command)
6047 finally:
6048 if args.post_recipe:
6049 finish_post_job(args.run_dir, args.post_recipe,
6050 exited_cleanly=result is not None and result.returncode == 0)
6051 sys.stdout.flush()
6052 if result.returncode != 0:
6053 return int(result.returncode)
6054 else:
6055 print('[INFO] Every requested step with a committed checkpoint is up to date; '
6056 'the postprocessor is not launched.')
6057 if args.success_command_json:
6058 success_command = json.loads(args.success_command_json)
6059 if not isinstance(success_command, list) or not success_command:
6060 print('[FATAL] Invalid post success command.', file=sys.stderr)
6061 return 2
6062 result = subprocess.run([str(token) for token in success_command])
6063 return int(result.returncode)
6064 return 0
6065 finally:
6066 try:
6067 os.remove(args.metadata_file)
6068 except FileNotFoundError:
6069 pass
6070 os.close(fd)
6071
6072
6073if __name__ == '__main__':
6074 raise SystemExit(main())
6075"""
6076
6077
Here is the caller graph for this function:

◆ ensure_post_lock_wrapper()

str picurv_cli.core.ensure_post_lock_wrapper ( str  run_dir)

Ensure the lock wrapper exists for a run directory and return its path.

Parameters
[in]run_dirArgument passed to ensure_post_lock_wrapper().
Returns
Value returned by ensure_post_lock_wrapper().

Definition at line 6078 of file core.py.

6078def ensure_post_lock_wrapper(run_dir: str) -> str:
6079 """!
6080 @brief Ensure the lock wrapper exists for a run directory and return its path.
6081 @param[in] run_dir Argument passed to `ensure_post_lock_wrapper()`.
6082 @return Value returned by `ensure_post_lock_wrapper()`.
6083 """
6084 paths = get_post_lock_paths(run_dir)
6085 wrapper_path = paths['wrapper_path']
6086 content = _build_post_lock_wrapper_source()
6087 existing_content = None
6088 os.makedirs(os.path.dirname(wrapper_path), exist_ok=True)
6089 if os.path.isfile(wrapper_path):
6090 with open(wrapper_path, 'r', encoding='utf-8', errors='replace') as f:
6091 existing_content = f.read()
6092 if existing_content != content:
6093 with open(wrapper_path, 'w', encoding='utf-8') as f:
6094 f.write(content)
6095 os.chmod(wrapper_path, 0o755)
6096 return wrapper_path
6097
6098
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_post_locked_command()

"tuple[list, dict]" picurv_cli.core.build_post_locked_command ( str  run_dir,
str  recipe_fingerprint,
list  wrapped_command,
bool   create_wrapper = True,
"list | None"   success_command = None,
"str | None"   post_recipe = None,
"str | None"   postprocessor = None,
bool   recompute = False 
)

Wrap a postprocessor command behind the run-dir-scoped lock wrapper.

Parameters
[in]run_dirArgument passed to build_post_locked_command().
[in]recipe_fingerprintArgument passed to build_post_locked_command().
[in]wrapped_commandArgument passed to build_post_locked_command().
[in]create_wrapperArgument passed to build_post_locked_command().
[in]success_commandOptional serial command run under the same lock after success.
[in]post_recipeRecipe whose steps the wrapper plans at launch; None runs the command as given.
[in]postprocessorPostprocessor the command launches, recorded with its output.
[in]recomputeRegenerate every step whose checkpoint is committed.
Returns
Value returned by build_post_locked_command().

Definition at line 6099 of file core.py.

6104 recompute: bool = False) -> "tuple[list, dict]":
6105 """!
6106 @brief Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
6107 @param[in] run_dir Argument passed to `build_post_locked_command()`.
6108 @param[in] recipe_fingerprint Argument passed to `build_post_locked_command()`.
6109 @param[in] wrapped_command Argument passed to `build_post_locked_command()`.
6110 @param[in] create_wrapper Argument passed to `build_post_locked_command()`.
6111 @param[in] success_command Optional serial command run under the same lock after success.
6112 @param[in] post_recipe Recipe whose steps the wrapper plans at launch; None runs the
6113 command as given.
6114 @param[in] postprocessor Postprocessor the command launches, recorded with its output.
6115 @param[in] recompute Regenerate every step whose checkpoint is committed.
6116 @return Value returned by `build_post_locked_command()`.
6117 """
6118 lock_paths = get_post_lock_paths(run_dir)
6119 wrapper_path = ensure_post_lock_wrapper(run_dir) if create_wrapper else lock_paths['wrapper_path']
6120 command = [
6121 sys.executable, wrapper_path,
6122 '--lock-file', lock_paths['lock_file'],
6123 '--metadata-file', lock_paths['metadata_file'],
6124 '--run-dir', run_dir,
6125 '--recipe-fingerprint', recipe_fingerprint,
6126 ]
6127 if post_recipe:
6128 command += ['--post-recipe', os.path.abspath(post_recipe),
6129 '--postprocessor', os.path.abspath(postprocessor)]
6130 if recompute:
6131 command.append('--recompute')
6132 if success_command:
6133 command += ['--success-command-json', json.dumps(list(success_command))]
6134 command += ['--'] + list(wrapped_command)
6135 return command, lock_paths
6136
6137
Here is the call graph for this function:
Here is the caller graph for this function:

◆ run_post_finalize_locked()

None picurv_cli.core.run_post_finalize_locked ( str  run_dir,
str  recipe_fingerprint,
list  finalize_command 
)

Refresh a collection-only result under the ordinary post writer lock.

Parameters
[in]run_dirRun whose post writer lock is acquired.
[in]recipe_fingerprintRecipe identity recorded in lock metadata.
[in]finalize_commandSerial collection finalizer command.
Returns
None.

Definition at line 6138 of file core.py.

6139 finalize_command: list) -> None:
6140 """!
6141 @brief Refresh a collection-only result under the ordinary post writer lock.
6142 @param[in] run_dir Run whose post writer lock is acquired.
6143 @param[in] recipe_fingerprint Recipe identity recorded in lock metadata.
6144 @param[in] finalize_command Serial collection finalizer command.
6145 @return None.
6146 """
6147 if not finalize_command:
6148 return
6149 command, _ = build_post_locked_command(
6150 run_dir, recipe_fingerprint, [sys.executable, "-c", "pass"],
6151 create_wrapper=True, success_command=finalize_command,
6152 )
6153 result = subprocess.run(command, cwd=run_dir)
6154 if result.returncode != 0:
6155 raise ValueError(
6156 f"Post finalization (ParaView series, reducers) failed with exit code {result.returncode}."
6157 )
6158
6159
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_post_execution_plan()

dict picurv_cli.core.build_post_execution_plan ( str  run_dir,
str  run_id,
dict  case_cfg,
dict  monitor_cfg,
dict  post_cfg,
bool   recompute = False,
bool   allow_source_frontier_scan = True,
"str | None"   postprocessor_sha = None 
)

Decide which steps of a recipe's window one invocation processes.

Every requested step is classified by classify_post_step(): valid output is kept, and missing or stale output is (re)made once its checkpoint is committed. Steps need not be contiguous. When the solver stage has not run yet the source scan is deferred and every step without valid output is planned; the post job plans again when it starts (prepare_post_job()).

Parameters
[in]run_dirRun being post-processed.
[in]run_idRun identity recorded in the recipe state.
[in]case_cfgParsed case configuration.
[in]monitor_cfgParsed monitor configuration.
[in]post_cfgParsed post recipe.
[in]recomputeRegenerate every step whose checkpoint is committed.
[in]allow_source_frontier_scanFalse when the solver stage has not produced output yet.
[in]postprocessor_shaHash of the postprocessor that would run, used only to report valid output another build made.
Returns
Plan mapping; see the keys of the returned dictionary.

Definition at line 6160 of file core.py.

6169) -> dict:
6170 """!
6171 @brief Decide which steps of a recipe's window one invocation processes.
6172 @details Every requested step is classified by classify_post_step(): valid output is
6173 kept, and missing or stale output is (re)made once its checkpoint is
6174 committed. Steps need not be contiguous. When the solver stage has not run
6175 yet the source scan is deferred and every step without valid output is
6176 planned; the post job plans again when it starts (prepare_post_job()).
6177 @param[in] run_dir Run being post-processed.
6178 @param[in] run_id Run identity recorded in the recipe state.
6179 @param[in] case_cfg Parsed case configuration.
6180 @param[in] monitor_cfg Parsed monitor configuration.
6181 @param[in] post_cfg Parsed post recipe.
6182 @param[in] recompute Regenerate every step whose checkpoint is committed.
6183 @param[in] allow_source_frontier_scan False when the solver stage has not produced output yet.
6184 @param[in] postprocessor_sha Hash of the postprocessor that would run, used only to
6185 report valid output another build made.
6186 @return Plan mapping; see the keys of the returned dictionary.
6187 """
6188 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
6189 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
6190 requested_start_step, requested_end_step, step_interval = resolve_post_requested_window(post_cfg, case_cfg)
6191 owned_start_step = resolve_post_owned_start(
6192 run_dir, post_cfg, requested_start_step, step_interval
6193 )
6194 resolved_source_dir = _resolve_post_source_directory_preview(run_dir, monitor_cfg, post_cfg)
6195 resolved_post_cfg = prepare_effective_post_config(post_cfg, resolved_source_dir)
6196 recipe_cfg = build_post_recipe_config(resolved_post_cfg, monitor_cfg)
6197 recipe_signature, recipe_fingerprint = compute_post_recipe_fingerprint(recipe_cfg)
6198 state_path = get_post_resume_state_path(run_dir, resolved_post_cfg)
6199 records, _ = _read_post_records(state_path)
6200
6201 source_frontier_deferred = not allow_source_frontier_scan
6202 available = None
6203 source_frontier_step = None
6204 source_frontier_diagnostic = None
6205 if not source_frontier_deferred:
6206 available = _scan_committed_checkpoint_steps(
6207 resolved_source_dir, require_particles=_post_needs_particle_source(resolved_post_cfg)
6208 )
6209 source_frontier_info = detect_post_source_frontier(
6210 resolved_source_dir, monitor_cfg, resolved_post_cfg,
6211 owned_start_step, requested_end_step, step_interval,
6212 )
6213 source_frontier_step = source_frontier_info['frontier_step']
6214 source_frontier_diagnostic = source_frontier_info['diagnostic']
6215
6216 outputs = post_step_outputs(run_dir, resolved_post_cfg, monitor_cfg)
6217 process, unavailable, reasons, adopted = [], [], {}, {}
6218 current = other_build = 0
6219 for step in _iter_post_steps(owned_start_step, requested_end_step, step_interval):
6220 record = records.get(str(step))
6221 found = outputs(step)
6222 files = None if found is None else found[0] + found[1]
6223 commit = _checkpoint_commit(resolved_source_dir, step)
6224 reason = classify_post_step(record, files, commit, recipe_fingerprint, recompute)
6225 if reason is None:
6226 current += 1
6227 if record is None:
6228 record = adopted[step] = {
6229 "recipe": recipe_fingerprint,
6230 "checkpoint": commit[0] if commit else None,
6231 "postprocessor": None,
6232 }
6233 if postprocessor_sha and record.get("postprocessor") != postprocessor_sha:
6234 other_build += 1
6235 elif available is None or step in available:
6236 process.append(step)
6237 reasons[reason] = reasons.get(reason, 0) + 1
6238 else:
6239 unavailable.append(step)
6240
6241 skip_reason = None
6242 if not process:
6243 if not unavailable:
6244 skip_reason = 'already-complete-window'
6245 elif current:
6246 skip_reason = 'already-caught-up-to-current-source-frontier'
6247 else:
6248 skip_reason = 'nothing-available-yet'
6249 effective_start_step = process[0] if process else None
6250 effective_end_step = process[-1] if process else None
6251 effective_post_cfg = None
6252 if skip_reason is None:
6253 effective_post_cfg = prepare_effective_post_config(
6254 post_cfg, resolved_source_dir,
6255 start_step=effective_start_step, end_step=effective_end_step,
6256 )
6257
6258 return {
6259 'run_id': run_id,
6260 'recompute': bool(recompute),
6261 'requested_start_step': requested_start_step,
6262 'requested_end_step': requested_end_step,
6263 'owned_start_step': owned_start_step,
6264 'step_interval': step_interval,
6265 'source_data_directory': resolved_source_dir,
6266 'recipe_config': recipe_cfg,
6267 'recipe_signature': recipe_signature,
6268 'recipe_fingerprint': recipe_fingerprint,
6269 'resume_state_path': state_path,
6270 'steps_to_process': process,
6271 'steps_unavailable': unavailable,
6272 'steps_current': current,
6273 'steps_other_build': other_build,
6274 'process_reasons': reasons,
6275 'adopted_records': adopted,
6276 'source_frontier_step': source_frontier_step,
6277 'source_frontier_diagnostic': source_frontier_diagnostic,
6278 'source_frontier_deferred': source_frontier_deferred,
6279 'source_committed_count': None if available is None else len(available),
6280 'source_latest_step': max(available) if available else None,
6281 'effective_start_step': effective_start_step,
6282 'effective_end_step': effective_end_step,
6283 'skip_reason': skip_reason,
6284 'resolved_post_cfg': resolved_post_cfg,
6285 'effective_post_cfg': effective_post_cfg,
6286 # The lock build_post_locked_command() takes: one post writer per run.
6287 'lock_paths': get_post_lock_paths(run_dir),
6288 }
6289
6290
Here is the call graph for this function:
Here is the caller graph for this function:

◆ report_post_plan()

None picurv_cli.core.report_post_plan ( dict  plan)

Print which steps a post plan processes, keeps, and waits for.

Parameters
[in]planPlan from build_post_execution_plan().
Returns
None.

Definition at line 6299 of file core.py.

6299def report_post_plan(plan: dict) -> None:
6300 """!
6301 @brief Print which steps a post plan processes, keeps, and waits for.
6302 @param[in] plan Plan from build_post_execution_plan().
6303 @return None.
6304 """
6305 process = plan['steps_to_process']
6306 if plan['source_frontier_deferred']:
6307 print(f"[INFO] Post steps: {len(process)} planned; the post job decides when it starts, "
6308 "after the solver has written them.")
6309 return
6310 why = ", ".join(f"{count} {POST_PROCESS_REASON_LABELS.get(reason, reason)}"
6311 for reason, count in sorted(plan['process_reasons'].items()))
6312 span = f" ({process[0]}..{process[-1]}; {why})" if process else ""
6313 print(f"[INFO] Post steps: {len(process)} to process{span}, {plan['steps_current']} up to date, "
6314 f"{len(plan['steps_unavailable'])} without a committed checkpoint.")
6315 if plan['steps_other_build']:
6316 print(f"[INFO] {plan['steps_other_build']} up-to-date step(s) were made by another or an "
6317 "unrecorded postprocessor build and are kept; --recompute regenerates them.")
6318
6319
Here is the caller graph for this function:

◆ get_post_step_list_path()

str picurv_cli.core.get_post_step_list_path ( str  run_dir,
dict  post_cfg 
)

Return the file naming the steps a launched post job processes.

Parameters
[in]run_dirRun directory.
[in]post_cfgRuntime recipe with canonical paths.
Returns
Absolute path; a companion <path>.json holds what recording needs.

Definition at line 6320 of file core.py.

6320def get_post_step_list_path(run_dir: str, post_cfg: dict) -> str:
6321 """!
6322 @brief Return the file naming the steps a launched post job processes.
6323 @param[in] run_dir Run directory.
6324 @param[in] post_cfg Runtime recipe with canonical paths.
6325 @return Absolute path; a companion `<path>.json` holds what recording needs.
6326 """
6327 return os.path.abspath(os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.steps"))
6328
6329
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _load_post_job_configs()

"tuple[dict, dict, dict]" picurv_cli.core._load_post_job_configs ( str  run_dir,
str  post_recipe_path 
)
protected

Load the configuration a post job needs from the run it runs in.

Parameters
[in]run_dirRun directory.
[in]post_recipe_pathThe recipe the job was staged with.
Returns
(case configuration, monitor configuration, runtime recipe with canonical paths).

Definition at line 6330 of file core.py.

6330def _load_post_job_configs(run_dir: str, post_recipe_path: str) -> "tuple[dict, dict, dict]":
6331 """!
6332 @brief Load the configuration a post job needs from the run it runs in.
6333 @param[in] run_dir Run directory.
6334 @param[in] post_recipe_path The recipe the job was staged with.
6335 @return (case configuration, monitor configuration, runtime recipe with canonical paths).
6336 """
6337 case_path, monitor_path, _ = auto_identify_run_inputs(os.path.join(run_dir, "config"))
6338 if not (case_path and monitor_path):
6339 raise ValueError(f"Could not identify the case and monitor files of {run_dir}.")
6340 post_cfg, _ = apply_canonical_post_paths(read_yaml_file(post_recipe_path), run_dir)
6341 return read_yaml_file(case_path), read_yaml_file(monitor_path), post_cfg
6342
6343
Here is the call graph for this function:
Here is the caller graph for this function:

◆ prepare_post_job()

int picurv_cli.core.prepare_post_job ( str  run_dir,
str  post_recipe_path,
str  postprocessor,
bool   recompute = False 
)

Decide, as a post job starts, which steps it processes.

Runs under the post lock immediately before the postprocessor, so a job staged long before it runs still skips output produced in the meantime and sees checkpoints the solver has written since. Writes the step list the postprocessor reads, and records what finish_post_job() needs.

Parameters
[in]run_dirRun directory.
[in]post_recipe_pathThe recipe the job was staged with.
[in]postprocessorPostprocessor executable the job launches.
[in]recomputeRegenerate every step whose checkpoint is committed.
Returns
Number of steps to process; 0 means the postprocessor need not run.

Definition at line 6344 of file core.py.

6345 recompute: bool = False) -> int:
6346 """!
6347 @brief Decide, as a post job starts, which steps it processes.
6348 @details Runs under the post lock immediately before the postprocessor, so a job
6349 staged long before it runs still skips output produced in the meantime and
6350 sees checkpoints the solver has written since. Writes the step list the
6351 postprocessor reads, and records what finish_post_job() needs.
6352 @param[in] run_dir Run directory.
6353 @param[in] post_recipe_path The recipe the job was staged with.
6354 @param[in] postprocessor Postprocessor executable the job launches.
6355 @param[in] recompute Regenerate every step whose checkpoint is committed.
6356 @return Number of steps to process; 0 means the postprocessor need not run.
6357 """
6358 run_dir = os.path.abspath(run_dir)
6359 case_cfg, monitor_cfg, post_cfg = _load_post_job_configs(run_dir, post_recipe_path)
6360 try:
6361 run_id = read_artifact_identity(run_dir)["run_id"]
6362 except Exception:
6363 run_id = os.path.basename(run_dir)
6364 sha = _file_sha256(postprocessor)
6365 plan = build_post_execution_plan(run_dir, run_id, case_cfg, monitor_cfg, post_cfg,
6366 recompute=recompute, postprocessor_sha=sha)
6367 report_post_plan(plan)
6368 persist_post_resume_state(run_dir, plan, steps=plan['adopted_records'])
6369 list_path = get_post_step_list_path(run_dir, post_cfg)
6370 steps = plan['steps_to_process'] if plan['skip_reason'] is None else []
6371 if not steps:
6372 for path in (list_path, list_path + ".json"):
6373 if os.path.exists(path):
6374 os.remove(path)
6375 return 0
6376 outputs = post_step_outputs(run_dir, plan['resolved_post_cfg'], monitor_cfg)
6377 before = {}
6378 for step in steps:
6379 found = outputs(step)
6380 before[str(step)] = {path: os.stat(path).st_mtime_ns
6381 for path in (found[0] + found[1] if found else [])}
6382 write_json_file(list_path + ".json", {
6383 "postprocessor": sha,
6384 "outputs_before": before,
6385 "plan": {key: plan[key] for key in (
6386 "run_id", "recipe_fingerprint", "recipe_signature", "requested_start_step",
6387 "requested_end_step", "step_interval", "source_data_directory", "resume_state_path")},
6388 })
6389 with open(list_path, "w", encoding="utf-8") as stream:
6390 stream.write("".join(f"{step}\n" for step in steps))
6391 return len(steps)
6392
6393
Here is the call graph for this function:

◆ finish_post_job()

int picurv_cli.core.finish_post_job ( str  run_dir,
str  post_recipe_path,
bool   exited_cleanly = False 
)

Record, after a post job ends, which steps it produced.

Runs whether or not the postprocessor succeeded, so the steps a failed or interrupted job completed are kept. A step is credited only when all its output exists and was written by this job.

Parameters
[in]run_dirRun directory.
[in]post_recipe_pathThe recipe the job was staged with.
[in]exited_cleanlyWhether the postprocessor exited with status zero; a clean exit that left an expected output unwritten is reported.
Returns
Number of steps recorded.

Definition at line 6394 of file core.py.

6394def finish_post_job(run_dir: str, post_recipe_path: str, exited_cleanly: bool = False) -> int:
6395 """!
6396 @brief Record, after a post job ends, which steps it produced.
6397 @details Runs whether or not the postprocessor succeeded, so the steps a failed or
6398 interrupted job completed are kept. A step is credited only when all its
6399 output exists and was written by this job.
6400 @param[in] run_dir Run directory.
6401 @param[in] post_recipe_path The recipe the job was staged with.
6402 @param[in] exited_cleanly Whether the postprocessor exited with status zero; a clean
6403 exit that left an expected output unwritten is reported.
6404 @return Number of steps recorded.
6405 """
6406 run_dir = os.path.abspath(run_dir)
6407 _case_cfg, monitor_cfg, post_cfg = _load_post_job_configs(run_dir, post_recipe_path)
6408 list_path = get_post_step_list_path(run_dir, post_cfg)
6409 job = _read_json_if_exists(list_path + ".json")
6410 if not isinstance(job, dict):
6411 return 0
6412 plan = job["plan"]
6413 source = plan["source_data_directory"]
6414 runtime_cfg = prepare_effective_post_config(post_cfg, source)
6415 outputs = post_step_outputs(run_dir, runtime_cfg, monitor_cfg)
6416 records = {}
6417 incomplete = []
6418 for step_text, before in job["outputs_before"].items():
6419 step = int(step_text)
6420 found = outputs(step)
6421 if found is None:
6422 incomplete.append(step)
6423 continue
6424 # A shared CSV changes with every step, so it proves a step was written only
6425 # when the step has no file of its own.
6426 written = found[0] or found[1]
6427 if not written or any(before.get(path) == os.stat(path).st_mtime_ns for path in written):
6428 continue
6429 commit = _checkpoint_commit(source, step)
6430 records[step] = {
6431 "recipe": plan["recipe_fingerprint"],
6432 "checkpoint": commit[0] if commit else None,
6433 "postprocessor": job["postprocessor"],
6434 }
6435 persist_post_resume_state(run_dir, plan, steps=records)
6436 for path in (list_path, list_path + ".json"):
6437 if os.path.exists(path):
6438 os.remove(path)
6439 print(f"[INFO] Post job recorded {len(records)} of {len(job['outputs_before'])} planned step(s).")
6440 # A postprocessor that does not know a recipe key ignores it and still exits
6441 # cleanly, so a step can finish without an output the recipe expects. That is
6442 # what a pinned executable older than the recipe looks like.
6443 if incomplete and exited_cleanly:
6444 try:
6445 executable = run_executable_path(run_dir, "postprocessor")
6446 except ValueError:
6447 executable = "the run's postprocessor"
6448 print(f"[WARNING] The postprocessor exited cleanly, but {len(incomplete)} planned step(s) "
6449 f"lack an output this recipe expects (first: step {min(incomplete)}). It may "
6450 f"predate a requested output: {executable} ignores recipe keys it does not know. "
6451 "Check its build with 'picurv version', or post-process a branch of the run "
6452 "built from the current source.", file=sys.stderr)
6453 return len(records)
6454
6455
Here is the call graph for this function:

◆ needs_restart_source()

bool picurv_cli.core.needs_restart_source ( dict  case_cfg,
dict  solver_cfg 
)

Return True when the solver requires restart data from disk.

Correctly identifies that analytical + init + start_step > 0 does NOT need a restart source (C code never reads from restart_dir in that case).

Parameters
[in]case_cfgParsed case YAML dictionary.
[in]solver_cfgParsed solver YAML dictionary.
Returns
True if a restart source (–restart-from or –continue) is required.

Definition at line 6456 of file core.py.

6456def needs_restart_source(case_cfg: dict, solver_cfg: dict) -> bool:
6457 """!
6458 @brief Return True when the solver requires restart data from disk.
6459 @details Correctly identifies that analytical + init + start_step > 0 does NOT
6460 need a restart source (C code never reads from restart_dir in that case).
6461 @param[in] case_cfg Parsed case YAML dictionary.
6462 @param[in] solver_cfg Parsed solver YAML dictionary.
6463 @return True if a restart source (--restart-from or --continue) is required.
6464 """
6465 try:
6466 start_step = int(case_cfg.get("run_control", {}).get("start_step", 0) or 0)
6467 except (TypeError, ValueError):
6468 start_step = 0
6469 eulerian_source = str(
6470 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
6471 ).strip().lower()
6472 particle_restart_mode = str(
6473 (case_cfg.get("models", {}).get("physics", {}).get("particles", {}) or {}).get("restart_mode", "init")
6474 ).strip().lower()
6475 euler_needs = (eulerian_source == "load") or (eulerian_source == "solve" and start_step > 0)
6476 particle_needs = (particle_restart_mode == "load")
6477 return euler_needs or particle_needs
6478
6479
Here is the caller graph for this function:

◆ resolve_run_output_dir()

str picurv_cli.core.resolve_run_output_dir ( str  run_dir,
dict  monitor_cfg 
)

Resolve the output data directory within a run directory.

Parameters
[in]run_dirPath to the run directory.
[in]monitor_cfgParsed monitor YAML dictionary.
Returns
Absolute path to the output directory.

Definition at line 6480 of file core.py.

6480def resolve_run_output_dir(run_dir: str, monitor_cfg: dict) -> str:
6481 """!
6482 @brief Resolve the output data directory within a run directory.
6483 @param[in] run_dir Path to the run directory.
6484 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6485 @return Absolute path to the output directory.
6486 """
6487 del monitor_cfg
6488 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["output"]))
6489
6490
Here is the caller graph for this function:

◆ resolve_run_restart_dir()

str picurv_cli.core.resolve_run_restart_dir ( str  run_dir,
dict  monitor_cfg 
)

Resolve the restart staging directory within a run directory.

Parameters
[in]run_dirPath to the run directory.
[in]monitor_cfgParsed monitor YAML dictionary.
Returns
Absolute path to the restart directory.

Definition at line 6491 of file core.py.

6491def resolve_run_restart_dir(run_dir: str, monitor_cfg: dict) -> str:
6492 """!
6493 @brief Resolve the restart staging directory within a run directory.
6494 @param[in] run_dir Path to the run directory.
6495 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6496 @return Absolute path to the restart directory.
6497 """
6498 del monitor_cfg
6499 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["restart"]))
6500
6501
Here is the caller graph for this function:

◆ compute_physical_case_identity()

str picurv_cli.core.compute_physical_case_identity ( dict  case_cfg)

Compute the hidden identity used to guard in-place continuation.

Run length/timestep controls, the MPI domain decomposition, and particle load-vs-init policy do not define the physical case. All other case.yml content does. Checkpoints are decomposition-independent, so a run may continue on a different rank layout.

Parameters
[in]case_cfgParsed case configuration.
Returns
Lowercase SHA-256 identity of normalized physical-case content.

Definition at line 6502 of file core.py.

6502def compute_physical_case_identity(case_cfg: dict) -> str:
6503 """!
6504 @brief Compute the hidden identity used to guard in-place continuation.
6505 @details Run length/timestep controls, the MPI domain decomposition, and particle
6506 load-vs-init policy do not define the physical case. All other case.yml
6507 content does. Checkpoints are decomposition-independent, so a run may
6508 continue on a different rank layout.
6509 @param[in] case_cfg Parsed case configuration.
6510 @return Lowercase SHA-256 identity of normalized physical-case content.
6511 """
6512 normalized = copy.deepcopy(case_cfg)
6513 if isinstance(normalized, dict):
6514 normalized.pop("run_control", None)
6515 grid = normalized.get("grid")
6516 if isinstance(grid, dict):
6517 for key in GRID_DA_PROCESSOR_KEYS:
6518 grid.pop(key, None)
6519 if isinstance(grid.get("programmatic_settings"), dict):
6520 for key in GRID_DA_PROCESSOR_KEYS:
6521 grid["programmatic_settings"].pop(key, None)
6522 particles = (((normalized.get("models") or {}).get("physics") or {}).get("particles"))
6523 if isinstance(particles, dict):
6524 particles.pop("restart_mode", None)
6525 payload = json.dumps(
6526 normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False
6527 ).encode("utf-8")
6528 return hashlib.sha256(payload).hexdigest()
6529
6530
Here is the caller graph for this function:

◆ validate_continue_case_identity()

None picurv_cli.core.validate_continue_case_identity ( str  run_dir,
dict  case_cfg 
)

Reject in-place continuation when the physical case has changed.

Parameters
[in]run_dirExisting run directory being continued.
[in]case_cfgNewly requested case configuration.
Returns
None.

Definition at line 6531 of file core.py.

6531def validate_continue_case_identity(run_dir: str, case_cfg: dict) -> None:
6532 """!
6533 @brief Reject in-place continuation when the physical case has changed.
6534 @param[in] run_dir Existing run directory being continued.
6535 @param[in] case_cfg Newly requested case configuration.
6536 @return None.
6537 """
6538 saved_case_path = os.path.join(run_dir, "config", "case.yml")
6539 if not os.path.isfile(saved_case_path):
6540 raise ValueError(f"--continue run is missing its saved case.yml: {saved_case_path}")
6541 saved_case = read_yaml_file(saved_case_path)
6542 if compute_physical_case_identity(saved_case) != compute_physical_case_identity(case_cfg):
6543 raise ValueError(
6544 "--continue cannot change the physical case. Change only run_control, the "
6545 "da_processors layout, solver.yml, monitor.yml, or post.yml; use --restart-from "
6546 "for a new case branch."
6547 )
6548
6549
Here is the call graph for this function:
Here is the caller graph for this function:

◆ populate_restart_directory()

picurv_cli.core.populate_restart_directory ( str  source_output,
str  target_restart,
int  start_step,
dict  monitor_cfg,
"int | None"   end_step = None,
bool   materialize = True 
)

Atomically materialize an immutable checkpoint interval into a run.

Parameters
[in]source_outputPath to the source output directory containing checkpoint data.
[in]target_restartPath to the target restart directory to populate.
[in]start_stepFirst checkpoint step to materialize.
[in]monitor_cfgParsed monitor YAML dictionary (for subdirectory names).
[in]end_stepOptional inclusive last checkpoint step.
[in]materializeWhether to copy the bundles, or only validate and resolve the path a real run would use. The dry-run planner needs the second: it reports what a run would refuse, and promises to write nothing.
Returns
Canonical restart root containing committed checkpoint bundles.

Definition at line 6550 of file core.py.

6552 materialize: bool = True):
6553 """!
6554 @brief Atomically materialize an immutable checkpoint interval into a run.
6555 @param[in] source_output Path to the source output directory containing checkpoint data.
6556 @param[in] target_restart Path to the target restart directory to populate.
6557 @param[in] start_step First checkpoint step to materialize.
6558 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6559 @param[in] end_step Optional inclusive last checkpoint step.
6560 @param[in] materialize Whether to copy the bundles, or only validate and resolve
6561 the path a real run would use. The dry-run planner needs the second:
6562 it reports what a run would refuse, and promises to write nothing.
6563 @return Canonical restart root containing committed checkpoint bundles.
6564 """
6565 del monitor_cfg
6566 checkpoints_root = os.path.join(os.path.abspath(target_restart), "checkpoints")
6567 if materialize:
6568 os.makedirs(checkpoints_root, exist_ok=True)
6569 final_step = start_step if end_step is None else end_step
6570 if final_step < start_step:
6571 raise ValueError("Restart checkpoint interval end must not precede its start.")
6572 for step in range(start_step, final_step + 1):
6573 source = validate_committed_checkpoint(source_output, step)["bundle"]
6574 if not materialize:
6575 continue
6576 destination = os.path.join(
6577 checkpoints_root, f"step_{step:0{CHECKPOINT_STEP_WIDTH}d}"
6578 )
6579 if os.path.isdir(destination):
6580 validate_committed_checkpoint(destination, step)
6581 print(f"[INFO] Reusing committed restart bundle: {destination}")
6582 continue
6583 temporary = os.path.join(
6584 checkpoints_root,
6585 f".step_{step:0{CHECKPOINT_STEP_WIDTH}d}.copying.{os.getpid()}",
6586 )
6587 if os.path.exists(temporary):
6588 raise ValueError(f"Restart staging path already exists: {temporary}")
6589 try:
6590 os.makedirs(temporary)
6591 for current, dirnames, filenames in os.walk(source):
6592 relative = os.path.relpath(current, source)
6593 target_dir = temporary if relative == "." else os.path.join(temporary, relative)
6594 os.makedirs(target_dir, exist_ok=True)
6595 for dirname in dirnames:
6596 os.makedirs(os.path.join(target_dir, dirname), exist_ok=True)
6597 for filename in filenames:
6598 _materialize_asset_file(
6599 os.path.join(current, filename), os.path.join(target_dir, filename)
6600 )
6601 validate_committed_checkpoint(temporary, step)
6602 os.replace(temporary, destination)
6603 except Exception:
6604 if os.path.isdir(temporary):
6605 shutil.rmtree(temporary)
6606 raise
6607 print(f"[INFO] Materialized committed restart bundle for step {step}: {destination}")
6608 return os.path.abspath(target_restart)
6609
6610
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_eulerian_checkpoint()

picurv_cli.core.validate_eulerian_checkpoint ( str  source_dir,
int  step,
dict  monitor_cfg 
)

Validate the mandatory Eulerian field set required by ReadSimulationFields().

Parameters
[in]source_dirRoot directory containing the Eulerian subdirectory.
[in]stepCheckpoint step to validate.
[in]monitor_cfgMonitor configuration defining the Eulerian subdirectory.
Returns
Validated checkpoint description.
Exceptions
ValueErrorif any mandatory Eulerian field is absent.

Definition at line 6611 of file core.py.

6611def validate_eulerian_checkpoint(source_dir: str, step: int, monitor_cfg: dict):
6612 """!
6613 @brief Validate the mandatory Eulerian field set required by `ReadSimulationFields()`.
6614 @param[in] source_dir Root directory containing the Eulerian subdirectory.
6615 @param[in] step Checkpoint step to validate.
6616 @param[in] monitor_cfg Monitor configuration defining the Eulerian subdirectory.
6617 @return Validated checkpoint description.
6618 @throws ValueError if any mandatory Eulerian field is absent.
6619 """
6620 del monitor_cfg
6621 return validate_committed_checkpoint(source_dir, step)
6622
6623
Here is the call graph for this function:
Here is the caller graph for this function:

◆ detect_last_checkpoint_step()

picurv_cli.core.detect_last_checkpoint_step ( str  output_dir)

Scan output directory for the highest step number available.

Ignores incomplete temporary directories and invalid bundles.

Parameters
[in]output_dirPath to the output directory.
Returns
The highest step number found, or None if no checkpoints exist.

Definition at line 6624 of file core.py.

6624def detect_last_checkpoint_step(output_dir: str):
6625 """!
6626 @brief Scan output directory for the highest step number available.
6627 @details Ignores incomplete temporary directories and invalid bundles.
6628 @param[in] output_dir Path to the output directory.
6629 @return The highest step number found, or None if no checkpoints exist.
6630 """
6631 steps = _scan_committed_checkpoint_steps(output_dir)
6632 return max(steps) if steps else None
6633
6634
Here is the call graph for this function:
Here is the caller graph for this function:

◆ detect_case_completion_status()

dict picurv_cli.core.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.

Parameters
[in]run_dirPath to the case run directory.
[in]monitor_cfgParsed monitor YAML dictionary.
[in]target_final_stepThe step number the case should reach for completion.
Returns
Dictionary with keys 'last_step' (int or None), 'target_step' (int), and 'status' ('complete', 'partial', or 'empty').

Definition at line 6635 of file core.py.

6635def detect_case_completion_status(run_dir: str, monitor_cfg: dict, target_final_step: int) -> dict:
6636 """!
6637 @brief Determine whether a study case is complete, partially complete, or empty.
6638 @param[in] run_dir Path to the case run directory.
6639 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6640 @param[in] target_final_step The step number the case should reach for completion.
6641 @return Dictionary with keys 'last_step' (int or None), 'target_step' (int),
6642 and 'status' ('complete', 'partial', or 'empty').
6643 """
6644 output_dir = resolve_run_output_dir(run_dir, monitor_cfg)
6645 last_step = detect_last_checkpoint_step(output_dir)
6646 if last_step is not None and last_step >= target_final_step:
6647 status = "complete"
6648 elif last_step is not None:
6649 status = "partial"
6650 else:
6651 status = "empty"
6652 return {"last_step": last_step, "target_step": target_final_step, "status": status}
6653
6654
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_load_mode_step_range()

picurv_cli.core.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.

Checks that ufield files exist for every step from start_step through start_step + total_steps (inclusive). Reports missing steps clearly.

Parameters
[in]source_outputPath to the output directory containing eulerian data.
[in]start_stepFirst step that will be loaded.
[in]total_stepsNumber of steps to run.
[in]monitor_cfgParsed monitor YAML dictionary (for subdirectory names).

Definition at line 6655 of file core.py.

6655def validate_load_mode_step_range(source_output: str, start_step: int, total_steps: int, monitor_cfg: dict):
6656 """!
6657 @brief Validate that all required eulerian step files exist for "load" mode.
6658 @details Checks that ufield files exist for every step from start_step through
6659 start_step + total_steps (inclusive). Reports missing steps clearly.
6660 @param[in] source_output Path to the output directory containing eulerian data.
6661 @param[in] start_step First step that will be loaded.
6662 @param[in] total_steps Number of steps to run.
6663 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6664 """
6665 missing = []
6666 for step in range(start_step, start_step + total_steps + 1):
6667 try:
6668 validate_eulerian_checkpoint(source_output, step, monitor_cfg)
6669 except CheckpointIndexWidthError:
6670 raise
6671 except ValueError:
6672 missing.append(step)
6673
6674 if missing:
6675 sample = missing[:3] + (["..."] if len(missing) > 6 else []) + missing[-3:]
6676 raise ValueError(
6677 f"Eulerian 'load' mode: {len(missing)} committed checkpoint(s) missing in {source_output}. "
6678 f"Missing steps include: {sample}"
6679 )
6680
6681
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_particle_checkpoint()

picurv_cli.core.validate_particle_checkpoint ( str  source_dir,
int  start_step,
dict  monitor_cfg 
)

Validate that particle checkpoint files exist for the given step.

Checks that at least a position file exists at the expected step in the particle subdirectory.

Parameters
[in]source_dirPath to the directory containing the particle subdirectory.
[in]start_stepThe step number whose particle checkpoint is expected.
[in]monitor_cfgParsed monitor YAML dictionary (for subdirectory names).
Returns
Validated checkpoint description.

Definition at line 6682 of file core.py.

6682def validate_particle_checkpoint(source_dir: str, start_step: int, monitor_cfg: dict):
6683 """!
6684 @brief Validate that particle checkpoint files exist for the given step.
6685 @details Checks that at least a position file exists at the expected step in
6686 the particle subdirectory.
6687 @param[in] source_dir Path to the directory containing the particle subdirectory.
6688 @param[in] start_step The step number whose particle checkpoint is expected.
6689 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
6690 @return Validated checkpoint description.
6691 """
6692 del monitor_cfg
6693 return validate_committed_checkpoint(source_dir, start_step, require_particles=True)
6694
6695
Here is the call graph for this function:
Here is the caller graph for this function:

◆ read_monitor_from_run()

dict picurv_cli.core.read_monitor_from_run ( str  run_dir)

Read the monitor.yml from a run directory's config/ subdirectory.

Parameters
[in]run_dirPath to the run directory.
Returns
Parsed monitor YAML dictionary.

Definition at line 6696 of file core.py.

6696def read_monitor_from_run(run_dir: str) -> dict:
6697 """!
6698 @brief Read the monitor.yml from a run directory's config/ subdirectory.
6699 @param[in] run_dir Path to the run directory.
6700 @return Parsed monitor YAML dictionary.
6701 """
6702 monitor_path = os.path.join(run_dir, "config", "monitor.yml")
6703 if not os.path.isfile(monitor_path):
6704 raise ValueError(f"Run directory is missing config/monitor.yml: {monitor_path}")
6705 return read_yaml_file(monitor_path)
6706
6707
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_latest_restart_run()

str picurv_cli.core.resolve_latest_restart_run ( dict  case_cfg,
str  case_path,
int  start_step 
)

Select the newest local workspace run compatible with a requested restart.

Parameters
[in]case_cfgCurrent case configuration.
[in]case_pathCurrent case path.
[in]start_stepRequired committed checkpoint.
Returns
Absolute source run directory.

Definition at line 6708 of file core.py.

6708def resolve_latest_restart_run(case_cfg: dict, case_path: str, start_step: int) -> str:
6709 """!
6710 @brief Select the newest local workspace run compatible with a requested restart.
6711 @param[in] case_cfg Current case configuration.
6712 @param[in] case_path Current case path.
6713 @param[in] start_step Required committed checkpoint.
6714 @return Absolute source run directory.
6715 """
6716 workspace_root = find_workspace_root(case_path, os.getcwd())
6717 if not workspace_root:
6718 raise ValueError("--from latest requires an initialized workspace.")
6719 current_graph = build_case_asset_graph(case_cfg, case_path)
6720 current_grid = next(
6721 (item for item in current_graph.get("providers", []) if item.get("kind") == "grid"),
6722 None,
6723 )
6724 candidates = []
6725 for candidate in Path(workspace_root, "runs").iterdir():
6726 if not candidate.is_dir():
6727 continue
6728 manifest = _read_json_if_exists(str(candidate / "manifest.json")) or {}
6729 lock = read_yaml_file(str(candidate / "inputs" / "assets.lock.yml")) \
6730 if (candidate / "inputs" / "assets.lock.yml").is_file() else {}
6731 locked_grid = (lock.get("assets") or {}).get("grid")
6732 runtime_grid = (lock.get("runtime_providers") or {}).get("grid")
6733 if current_grid:
6734 recorded_hash = None
6735 if isinstance(locked_grid, dict):
6736 recorded_hash = locked_grid.get("provider_spec_sha256")
6737 elif isinstance(runtime_grid, dict):
6738 recorded_hash = runtime_grid.get("provider_spec_sha256")
6739 if recorded_hash and recorded_hash != current_grid.get("spec_sha256"):
6740 continue
6741 output_root = candidate / CANONICAL_RUN_PATHS["output"]
6742 try:
6743 validate_committed_checkpoint(str(output_root), start_step)
6744 except CheckpointIndexWidthError:
6745 raise
6746 except (OSError, ValueError):
6747 continue
6748 ordering = manifest.get("updated_at") or manifest.get("created_at") or ""
6749 candidates.append((ordering, candidate.stat().st_mtime_ns, str(candidate)))
6750 if not candidates:
6751 raise ValueError(
6752 f"No local workspace run has a compatible committed checkpoint at step {start_step}. "
6753 "Restore a run from storage first, or name one explicitly with --from <run-dir>."
6754 )
6755 candidates.sort(reverse=True)
6756 selected = candidates[0][2]
6757 print(f"[INFO] Selected latest compatible restart run: {os.path.relpath(selected, workspace_root)}")
6758 return selected
6759
6760
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_restart_source()

picurv_cli.core.resolve_restart_source (   args,
dict  case_cfg,
dict  solver_cfg,
dict  monitor_cfg,
str  run_dir,
bool   materialize = True 
)

Resolve the restart source directory based on –restart-from or –continue CLI flags.

Implements the full restart resolution logic including smart resolution for –continue (checks restart/ first for user-curated data, falls back to output/) and direct reference for eulerian "load" mode.

Parameters
[in]argsParsed CLI arguments (must have restart_from, continue_run, run_dir attrs).
[in]case_cfgParsed case YAML dictionary.
[in]solver_cfgParsed solver YAML dictionary.
[in]monitor_cfgParsed monitor YAML dictionary.
[in]run_dirPath to the current run directory.
[in]materializeWhether to copy restart bundles into the run. The dry-run planner passes False: it still validates and resolves, but writes nothing.
Returns
Tuple of (restart_source_dir, continue_mode, lineage) where restart_source_dir is the resolved path (or None), continue_mode is a boolean, and lineage is the branch provenance record from build_run_lineage(), or None when this run did not branch from another.

Definition at line 6761 of file core.py.

6762 run_dir: str, materialize: bool = True):
6763 """!
6764 @brief Resolve the restart source directory based on --restart-from or --continue CLI flags.
6765 @details Implements the full restart resolution logic including smart resolution for
6766 --continue (checks restart/ first for user-curated data, falls back to output/)
6767 and direct reference for eulerian "load" mode.
6768 @param[in] args Parsed CLI arguments (must have restart_from, continue_run, run_dir attrs).
6769 @param[in] case_cfg Parsed case YAML dictionary.
6770 @param[in] solver_cfg Parsed solver YAML dictionary.
6771 @param[in] monitor_cfg Parsed monitor YAML dictionary.
6772 @param[in] run_dir Path to the current run directory.
6773 @param[in] materialize Whether to copy restart bundles into the run. The dry-run
6774 planner passes False: it still validates and resolves, but writes nothing.
6775 @return Tuple of (restart_source_dir, continue_mode, lineage) where restart_source_dir
6776 is the resolved path (or None), continue_mode is a boolean, and lineage is the
6777 branch provenance record from `build_run_lineage()`, or None when this run did
6778 not branch from another.
6779 """
6780 try:
6781 start_step = int(case_cfg.get("run_control", {}).get("start_step", 0) or 0)
6782 except (TypeError, ValueError):
6783 start_step = 0
6784 try:
6785 total_steps = int(case_cfg.get("run_control", {}).get("total_steps", 0) or 0)
6786 except (TypeError, ValueError):
6787 total_steps = 0
6788
6789 eulerian_source = str(
6790 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
6791 ).strip().lower()
6792
6793 particle_restart_mode = str(
6794 (case_cfg.get("models", {}).get("physics", {}).get("particles", {}) or {}).get("restart_mode", "init")
6795 ).strip().lower()
6796 particle_needs = (particle_restart_mode == "load")
6797
6798 requires_source = needs_restart_source(case_cfg, solver_cfg)
6799 restart_from = getattr(args, 'restart_from', None)
6800 continue_run = getattr(args, 'continue_run', False)
6801
6802 if restart_from and continue_run:
6803 raise ValueError("--restart-from and --continue are mutually exclusive.")
6804
6805 # The named run must exist before its start step can mean anything.
6806 if continue_run and getattr(args, 'run_dir', None) and not os.path.isdir(args.run_dir):
6807 raise ValueError(f"--run-dir does not exist: {os.path.abspath(args.run_dir)}")
6808 if continue_run and start_step <= 0:
6809 raise ValueError(
6810 "--continue with --solve requires run_control.start_step > 0; "
6811 "start_step=0 is a fresh start. Omit --continue to start a fresh run."
6812 )
6813
6814 if restart_from:
6815 requested_restart_source = str(restart_from)
6816 if str(restart_from).strip().lower() == "latest":
6817 restart_from = resolve_latest_restart_run(
6818 case_cfg, getattr(args, "case", "case.yml"), start_step
6819 )
6820 # A branch may follow a different physical trajectory than the samples already
6821 # collected, so the retention of the parent's windows is the user's decision and
6822 # not a default's. Silently resetting reports a sample count that describes a
6823 # shorter average than the user expects; silently carrying averages two
6824 # different flows together. Neither is safe to guess, so the flag is required
6825 # whenever there are windows to decide about.
6826 requested_statistics_state = getattr(args, "statistics_state", None)
6827 statistics_enabled = normalize_field_statistics_config(monitor_cfg or {})["enabled"]
6828 if requested_statistics_state is None:
6829 if statistics_enabled:
6830 raise ValueError(
6831 "A branched restart of a run with field_statistics.enabled: true must "
6832 "state what happens to the parent's accumulated windows. Pass "
6833 "--statistics-state reset to discard them and start the averages over, "
6834 "or --statistics-state carry to resume compatible saved window state."
6835 )
6836 requested_statistics_state = "reset"
6837 statistics_state = str(requested_statistics_state).lower()
6838 if statistics_state == "carry" and not statistics_enabled:
6839 raise ValueError("--statistics-state carry requires field_statistics.enabled: true.")
6840 # Carried windows are read from the source checkpoint even when nothing else is:
6841 # an analytical Eulerian source restores no fields, but its statistics still
6842 # live in that bundle, and an unstaged restart directory fails at start-up.
6843 if statistics_state == "carry":
6844 requires_source = True
6845
6846 # === MODE 1: New run, restart from another run ===
6847 source_run = os.path.abspath(restart_from)
6848 if not os.path.isdir(source_run):
6849 raise ValueError(f"--restart-from run directory does not exist: {source_run}")
6850 try:
6851 require_storage_payload_local(source_run, "--restart-from", checkpoint=start_step)
6852 except StorageError as exc:
6853 raise ValueError(str(exc)) from exc
6854 source_monitor = read_monitor_from_run(source_run)
6855 source_output = resolve_run_output_dir(source_run, source_monitor)
6856 if not os.path.isdir(source_output):
6857 raise ValueError(f"Source output directory does not exist: {source_output}")
6858 lineage = build_run_lineage(
6859 source_run, start_step,
6860 workspace_root=find_workspace_root(run_dir),
6861 statistics_state=statistics_state,
6862 requested_source=requested_restart_source,
6863 )
6864
6865 if not requires_source:
6866 # R7: analytical + init — warn that --restart-from is unused
6867 print(
6868 "[WARNING] --restart-from has no effect: an analytical flow with freshly "
6869 "initialized particles reads no restart data.",
6870 file=sys.stderr,
6871 )
6872 return None, False, None
6873
6874 if eulerian_source == "load":
6875 # A load-mode run may consume a complete saved sequence. Materialize it
6876 # into this run so storage never depends on another run remaining local.
6877 validate_load_mode_step_range(source_output, start_step, total_steps, source_monitor)
6878 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
6879 restart_root = populate_restart_directory(
6880 source_output, target_restart, start_step, monitor_cfg,
6881 end_step=start_step + total_steps, materialize=materialize,
6882 )
6883 if particle_needs:
6884 validate_particle_checkpoint(
6885 restart_root if materialize else source_output, start_step, monitor_cfg
6886 )
6887 return restart_root, False, lineage
6888 else:
6889 # Materialize one immutable bundle into the new run's restart input.
6890 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
6891 restart_root = populate_restart_directory(
6892 source_output, target_restart, start_step, monitor_cfg,
6893 materialize=materialize,
6894 )
6895 if particle_needs:
6896 validate_particle_checkpoint(
6897 restart_root if materialize else source_output, start_step, monitor_cfg
6898 )
6899 return restart_root, False, lineage
6900
6901 elif continue_run:
6902 # === MODE 2: Continue in-place ===
6903 continue_run_dir = getattr(args, 'run_dir', None)
6904 if not continue_run_dir:
6905 raise ValueError(RESTART_RUN_DIR_REQUIRED_MESSAGE)
6906 continue_run_dir = os.path.abspath(continue_run_dir)
6907 try:
6908 require_storage_payload_local(continue_run_dir, "--continue", checkpoint=start_step)
6909 except StorageError as exc:
6910 raise ValueError(str(exc)) from exc
6911 validate_continue_case_identity(continue_run_dir, case_cfg)
6912
6913 source_output = resolve_run_output_dir(continue_run_dir, monitor_cfg)
6914 last_step = detect_last_checkpoint_step(source_output)
6915 # The solver never overwrites a committed checkpoint, so continuing from an earlier
6916 # one would keep the old states after it and mix two histories in one run.
6917 if last_step is not None and start_step < last_step:
6918 raise ValueError(
6919 f"--continue starts at step {start_step}, but this run already has committed "
6920 f"checkpoints through step {last_step}. Continuing in place from an earlier "
6921 "checkpoint would keep the later ones from the old trajectory. Set "
6922 f"run_control.start_step to {last_step} to extend the run, or branch a new run "
6923 f"from step {start_step} with --restart-from {continue_run_dir}."
6924 )
6925 if last_step is not None and start_step > last_step:
6926 raise ValueError(
6927 f"--continue starts at step {start_step}, but this run's last committed "
6928 f"checkpoint is step {last_step}. Set run_control.start_step to {last_step}."
6929 )
6930
6931 if eulerian_source == "load":
6932 # Preserve the saved sequence under the run's immutable restart input.
6933 validate_load_mode_step_range(source_output, start_step, total_steps, monitor_cfg)
6934 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
6935 restart_root = populate_restart_directory(
6936 source_output, target_restart, start_step, monitor_cfg,
6937 end_step=start_step + total_steps, materialize=materialize,
6938 )
6939 if particle_needs:
6940 validate_particle_checkpoint(
6941 restart_root if materialize else source_output, start_step, monitor_cfg
6942 )
6943 return restart_root, True, None
6944 elif not requires_source:
6945 # C6: analytical + init — only log-append behavior, no data needed
6946 return None, True, None
6947 else:
6948 # In-place continuation also uses the fixed restart-input home. Reflink or
6949 # hardlink materialization normally makes this metadata-cheap.
6950 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
6951 restart_root = populate_restart_directory(
6952 source_output, target_restart, start_step, monitor_cfg,
6953 materialize=materialize,
6954 )
6955 validate_committed_checkpoint(
6956 restart_root if materialize else source_output, start_step,
6957 require_particles=particle_needs,
6958 )
6959 return restart_root, True, None
6960
6961 elif requires_source:
6962 raise ValueError(
6963 "Restart data required but no source specified. Use:\n"
6964 " --restart-from <run_dir> (new run from another run's data)\n"
6965 " --continue --run-dir <run_dir> (resume in same directory)"
6966 )
6967
6968 return None, False, None
6969
Here is the call graph for this function:
Here is the caller graph for this function:

◆ absolutize_case_external_paths()

picurv_cli.core.absolutize_case_external_paths ( dict  case_cfg,
str  case_anchor_path 
)

Convert external grid/generator paths in case config to absolute paths.

Parameters
[in]case_cfgArgument passed to absolutize_case_external_paths().
[in]case_anchor_pathArgument passed to absolutize_case_external_paths().

Definition at line 6970 of file core.py.

6970def absolutize_case_external_paths(case_cfg: dict, case_anchor_path: str):
6971 """!
6972 @brief Convert external grid/generator paths in case config to absolute paths.
6973 @param[in] case_cfg Argument passed to `absolutize_case_external_paths()`.
6974 @param[in] case_anchor_path Argument passed to `absolutize_case_external_paths()`.
6975 """
6976 # Initialized workspaces deliberately keep portable, root-relative paths in
6977 # every materialized study member. The run/study directory remains below
6978 # the workspace, so normal resolution can still find the owning manifest.
6979 if find_workspace_root(case_anchor_path):
6980 return
6981 grid_cfg = case_cfg.get("grid", {})
6982 if not isinstance(grid_cfg, dict):
6983 return
6984 mode = grid_cfg.get("mode")
6985 if mode == "file":
6986 source_file = grid_cfg.get("source_file")
6987 if isinstance(source_file, str):
6988 grid_cfg["source_file"] = resolve_path(case_anchor_path, source_file)
6989 elif mode == "grid_gen":
6990 gen = grid_cfg.get("generator", {})
6991 if isinstance(gen, dict):
6992 for key in ("script", "config_file"):
6993 val = gen.get(key)
6994 if isinstance(val, str):
6995 gen[key] = resolve_path(case_anchor_path, val)
6996 ic = (case_cfg.get("properties", {}) or {}).get("initial_conditions", {})
6997 if isinstance(ic, dict):
6998 if str(ic.get("mode", "")).strip().lower() == "file":
6999 source_file = ic.get("source_file")
7000 if isinstance(source_file, str):
7001 ic["source_file"] = resolve_path(case_anchor_path, source_file)
7002 elif str(ic.get("generator", "")).strip().lower() == "ic_gen":
7003 params = ic.get("params", {})
7004 if isinstance(params, dict):
7005 for key in ("script", "config_file"):
7006 value = params.get(key)
7007 if isinstance(value, str):
7008 params[key] = resolve_path(case_anchor_path, value)
7009 boundary_conditions = case_cfg.get("boundary_conditions", [])
7010 blocks = boundary_conditions if boundary_conditions and isinstance(boundary_conditions[0], list) else [boundary_conditions]
7011 for block in blocks:
7012 if not isinstance(block, list):
7013 continue
7014 for bc in block:
7015 if not isinstance(bc, dict) or str(bc.get("handler", "")).strip().lower() != "prescribed_flow":
7016 continue
7017 source = ((bc.get("params") or {}).get("source") or {})
7018 if not isinstance(source, dict):
7019 continue
7020 source_type = str(source.get("type", "")).strip().lower()
7021 if source_type == "file":
7022 keys = ("path",)
7023 elif source_type == "generated":
7024 keys = ("script",)
7025 elif source_type == "field_slice":
7026 keys = ("script", "field_file", "grid_file", "source_case")
7027 else:
7028 keys = ()
7029 for key in keys:
7030 value = source.get(key)
7031 if isinstance(value, str):
7032 source[key] = resolve_path(case_anchor_path, value)
7033
7034
Here is the call graph for this function:
Here is the caller graph for this function:

◆ prepare_case_for_continuation()

picurv_cli.core.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.

Updates the case config with new start_step/total_steps, sets particle restart_mode to 'load' if checkpoint exists, populates the restart directory, and regenerates the solver control file with continue_mode. Delegates all restart resolution to resolve_restart_source().

Parameters
[in]run_dirPath to the case run directory.
[in]case_idThe case identifier (e.g. 'case_0002').
[in]last_stepThe last checkpoint step found in the output directory.
[in]target_final_stepThe step number the case should reach for completion.
[in]cluster_cfgParsed cluster YAML dictionary (for num_procs, walltime guard).
Returns
The absolute path to the regenerated control file.

Definition at line 7035 of file core.py.

7036 target_final_step: int, cluster_cfg: dict):
7037 """!
7038 @brief Set up a partially-completed study case for continuation in-place.
7039 @details Updates the case config with new start_step/total_steps, sets particle
7040 restart_mode to 'load' if checkpoint exists, populates the restart
7041 directory, and regenerates the solver control file with continue_mode.
7042 Delegates all restart resolution to resolve_restart_source().
7043 @param[in] run_dir Path to the case run directory.
7044 @param[in] case_id The case identifier (e.g. 'case_0002').
7045 @param[in] last_step The last checkpoint step found in the output directory.
7046 @param[in] target_final_step The step number the case should reach for completion.
7047 @param[in] cluster_cfg Parsed cluster YAML dictionary (for num_procs, walltime guard).
7048 @return The absolute path to the regenerated control file.
7049 """
7050 config_dir = os.path.join(run_dir, "config")
7051 case_cfg = read_yaml_file(os.path.join(config_dir, "case.yml"))
7052 solver_cfg = read_yaml_file(os.path.join(config_dir, "solver.yml"))
7053 monitor_cfg = read_yaml_file(os.path.join(config_dir, "monitor.yml"))
7054
7055 remaining = target_final_step - last_step
7056 case_cfg["run_control"]["start_step"] = last_step
7057 case_cfg["run_control"]["total_steps"] = remaining
7058 print(f"[INFO] {case_id}: updating start_step={last_step}, total_steps={remaining}")
7059
7060 output_dir = resolve_run_output_dir(run_dir, monitor_cfg)
7061 particles_cfg = (case_cfg.get("models", {}).get("physics", {}) or {}).get("particles")
7062 checkpoint_has_particles = False
7063 try:
7064 checkpoint_has_particles = validate_committed_checkpoint(
7065 output_dir, last_step, require_particles=True
7066 )["has_particles"]
7067 except CheckpointIndexWidthError:
7068 raise
7069 except ValueError:
7070 checkpoint_has_particles = False
7071 if particles_cfg is not None and checkpoint_has_particles:
7072 current_mode = str(particles_cfg.get("restart_mode", "init")).strip().lower()
7073 if current_mode != "load":
7074 particles_cfg["restart_mode"] = "load"
7075 print(f"[INFO] {case_id}: setting particle restart_mode='load' (checkpoint found)")
7076
7077 write_yaml_file(os.path.join(config_dir, "case.yml"), case_cfg)
7078
7079 mock_args = argparse.Namespace(restart_from=None, continue_run=True, run_dir=run_dir)
7080 restart_source_dir, continue_mode, _lineage = resolve_restart_source(
7081 mock_args, case_cfg, solver_cfg, monitor_cfg, run_dir
7082 )
7083
7084 source_files = {
7085 'Case': os.path.join(config_dir, "case.yml"),
7086 'Solver': os.path.join(config_dir, "solver.yml"),
7087 'Monitor': os.path.join(config_dir, "monitor.yml"),
7088 }
7089 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
7090 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
7091 configs = {
7092 "case": case_cfg, "case_path": source_files['Case'],
7093 "solver": solver_cfg, "solver_path": source_files['Solver'],
7094 "monitor": monitor_cfg, "monitor_path": source_files['Monitor'],
7095 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
7096 "cluster_cfg": cluster_cfg,
7097 }
7098 control_file = generate_solver_control_file(
7099 run_dir, case_id, configs, cluster_tasks, monitor_files,
7100 restart_source_dir=restart_source_dir, continue_mode=continue_mode,
7101 )
7102 print(f"[SUCCESS] {case_id}: regenerated control file for continuation")
7103 return control_file
7104
7105
Here is the call graph for this function:
Here is the caller graph for this function:

◆ is_valid_email()

bool picurv_cli.core.is_valid_email ( str  email)

Lightweight email validation for scheduler notifications.

Parameters
[in]emailArgument passed to is_valid_email().
Returns
Value returned by is_valid_email().

Definition at line 7106 of file core.py.

7106def is_valid_email(email: str) -> bool:
7107 """!
7108 @brief Lightweight email validation for scheduler notifications.
7109 @param[in] email Argument passed to `is_valid_email()`.
7110 @return Value returned by `is_valid_email()`.
7111 """
7112 if not isinstance(email, str):
7113 return False
7114 pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
7115 return re.match(pattern, email.strip()) is not None
7116
Here is the caller graph for this function:

◆ normalize_statistics_task()

str picurv_cli.core.normalize_statistics_task ( str  task_name)

Normalizes user-facing statistics task names to C pipeline keywords.

Parameters
[in]task_nameTask name from YAML.
Returns
Canonical keyword accepted by C statistics pipeline.
Exceptions
ValueErrorif task is unsupported.

Definition at line 7117 of file core.py.

7117def normalize_statistics_task(task_name: str) -> str:
7118 """!
7119 @brief Normalizes user-facing statistics task names to C pipeline keywords.
7120 @param[in] task_name Task name from YAML.
7121 @return Canonical keyword accepted by C statistics pipeline.
7122 @throws ValueError if task is unsupported.
7123 """
7124 # Only implemented statistics kernels belong here.
7125 if task_name is None:
7126 raise ValueError("statistics task cannot be None")
7127 normalized = str(task_name).strip().lower().replace("-", "_").replace(" ", "_")
7128 if normalized != "msd":
7129 raise ValueError(f"Unsupported statistics task '{task_name}'. Currently supported: 'msd'.")
7130 return "ComputeMSD"
7131
Here is the caller graph for this function:

◆ _iter_nonempty_noncomment_lines()

picurv_cli.core._iter_nonempty_noncomment_lines (   file_obj)
protected

Yield (lineno, stripped_line) for non-empty, non-comment lines.

Parameters
[in]file_objArgument passed to _iter_nonempty_noncomment_lines().

Definition at line 7132 of file core.py.

7132def _iter_nonempty_noncomment_lines(file_obj):
7133 """!
7134 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
7135 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
7136 """
7137 for lineno, raw in enumerate(file_obj, start=1):
7138 line = raw.strip()
7139 if not line or line.startswith("#"):
7140 continue
7141 yield lineno, line
7142
Here is the caller graph for this function:

◆ format_picgrid_coordinate()

str picurv_cli.core.format_picgrid_coordinate ( float  value)

Format a coordinate with round-trip-safe binary64 precision.

Parameters
[in]valueCoordinate value.
Returns
Formatted coordinate.

Definition at line 7146 of file core.py.

7146def format_picgrid_coordinate(value: float) -> str:
7147 """!
7148 @brief Format a coordinate with round-trip-safe binary64 precision.
7149 @param[in] value Coordinate value.
7150 @return Formatted coordinate.
7151 """
7152 return format(value, PICGRID_FLOAT_FORMAT)
7153
7154

◆ validate_and_nondimensionalize_picgrid()

dict picurv_cli.core.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.

Requires canonical PICGRID input with leading "PICGRID" token. Output is always written in canonical PICGRID format with header and per-block dims.

Parameters
[in]source_gridInput grid file path.
[in]dest_gridOutput grid file path.
[in]L_refReference length for non-dimensionalization.
[in]expected_nblkOptional expected block count.
Returns
Summary dictionary with nblk, dims, and total_nodes.
Exceptions
ValueErroron malformed grid.

Definition at line 7155 of file core.py.

7155def validate_and_nondimensionalize_picgrid(source_grid: str, dest_grid: str, L_ref: float, expected_nblk: int = None) -> dict:
7156 """!
7157 @brief Validates PICGRID payload and writes a non-dimensionalized copy.
7158 @details Requires canonical PICGRID input with leading "PICGRID" token.
7159 Output is always written in canonical PICGRID format with header and per-block dims.
7160 @param[in] source_grid Input grid file path.
7161 @param[in] dest_grid Output grid file path.
7162 @param[in] L_ref Reference length for non-dimensionalization.
7163 @param[in] expected_nblk Optional expected block count.
7164 @return Summary dictionary with nblk, dims, and total_nodes.
7165 @throws ValueError on malformed grid.
7166 """
7167 if L_ref == 0.0:
7168 raise ValueError("length_ref must be non-zero when processing grid coordinates.")
7169 if not os.path.isfile(source_grid):
7170 raise ValueError(f"Grid file not found: {source_grid}")
7171
7172 with open(source_grid, "r") as fin:
7173 line_iter = _iter_nonempty_noncomment_lines(fin)
7174 try:
7175 _, first_token = next(line_iter)
7176 except StopIteration:
7177 raise ValueError(f"Grid file '{source_grid}' is empty.")
7178
7179 if first_token != "PICGRID":
7180 raise ValueError(
7181 f"Grid file '{source_grid}' must begin with the canonical PICGRID header token."
7182 )
7183 try:
7184 _, nblk_line = next(line_iter)
7185 except StopIteration:
7186 raise ValueError(f"Grid file '{source_grid}' missing block count after PICGRID header.")
7187
7188 try:
7189 nblk = int(nblk_line)
7190 except ValueError:
7191 raise ValueError(f"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
7192 if nblk <= 0:
7193 raise ValueError(f"Grid file '{source_grid}' has non-positive block count: {nblk}.")
7194 if expected_nblk is not None and nblk != expected_nblk:
7195 raise ValueError(
7196 f"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}."
7197 )
7198
7199 dims = []
7200 for bi in range(nblk):
7201 try:
7202 lineno, dim_line = next(line_iter)
7203 except StopIteration:
7204 raise ValueError(f"Grid file '{source_grid}' missing dimensions for block {bi}.")
7205 parts = dim_line.split()
7206 if len(parts) != 3:
7207 raise ValueError(
7208 f"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
7209 )
7210 try:
7211 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
7212 except ValueError:
7213 raise ValueError(
7214 f"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
7215 )
7216 if im <= 0 or jm <= 0 or km <= 0:
7217 raise ValueError(
7218 f"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
7219 )
7220 dims.append((im, jm, km))
7221
7222 total_nodes_expected = sum(im * jm * km for (im, jm, km) in dims)
7223 os.makedirs(os.path.dirname(dest_grid), exist_ok=True)
7224 with open(dest_grid, "w") as fout:
7225 fout.write("PICGRID\n")
7226 fout.write(f"{nblk}\n")
7227 for (im, jm, km) in dims:
7228 fout.write(f"{im} {jm} {km}\n")
7229
7230 total_nodes_seen = 0
7231 for lineno, coord_line in line_iter:
7232 parts = coord_line.split()
7233 if len(parts) != 3:
7234 raise ValueError(
7235 f"Invalid coordinate row at {source_grid}:{lineno}. Expected 3 floats, got: '{coord_line}'."
7236 )
7237 try:
7238 x = float(parts[0]) / L_ref
7239 y = float(parts[1]) / L_ref
7240 z = float(parts[2]) / L_ref
7241 except ValueError:
7242 raise ValueError(
7243 f"Invalid coordinate row at {source_grid}:{lineno}. Non-numeric values: '{coord_line}'."
7244 )
7245 total_nodes_seen += 1
7246 if total_nodes_seen > total_nodes_expected:
7247 raise ValueError(
7248 f"Grid file '{source_grid}' has more coordinates ({total_nodes_seen}) than expected ({total_nodes_expected})."
7249 )
7250 fout.write(
7251 f"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
7252 f"{format_picgrid_coordinate(z)}\n"
7253 )
7254
7255 if total_nodes_seen != total_nodes_expected:
7256 raise ValueError(
7257 f"Grid file '{source_grid}' has {total_nodes_seen} coordinates, expected {total_nodes_expected} from header."
7258 )
7259
7260 return {"nblk": nblk, "dims": dims, "total_nodes": total_nodes_expected}
7261
Here is the call graph for this function:
Here is the caller graph for this function:

◆ read_picgrid_header_dimensions()

list picurv_cli.core.read_picgrid_header_dimensions ( str  source_grid,
int   expected_nblk = None 
)

Read only the canonical PICGRID header dimensions.

Parameters
[in]source_gridInput grid file path.
[in]expected_nblkOptional expected block count.
Returns
List of (IM, JM, KM) node-count tuples.
Exceptions
ValueErroron malformed header.

Definition at line 7262 of file core.py.

7262def read_picgrid_header_dimensions(source_grid: str, expected_nblk: int = None) -> list:
7263 """!
7264 @brief Read only the canonical PICGRID header dimensions.
7265 @param[in] source_grid Input grid file path.
7266 @param[in] expected_nblk Optional expected block count.
7267 @return List of (IM, JM, KM) node-count tuples.
7268 @throws ValueError on malformed header.
7269 """
7270 if not os.path.isfile(source_grid):
7271 raise ValueError(f"Grid file not found: {source_grid}")
7272
7273 with open(source_grid, "r") as fin:
7274 line_iter = _iter_nonempty_noncomment_lines(fin)
7275 try:
7276 _, first_token = next(line_iter)
7277 except StopIteration:
7278 raise ValueError(f"Grid file '{source_grid}' is empty.")
7279 if first_token != "PICGRID":
7280 raise ValueError(f"Grid file '{source_grid}' must begin with the canonical PICGRID header token.")
7281
7282 try:
7283 _, nblk_line = next(line_iter)
7284 nblk = int(nblk_line)
7285 except StopIteration:
7286 raise ValueError(f"Grid file '{source_grid}' missing block count after PICGRID header.")
7287 except ValueError:
7288 raise ValueError(f"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
7289 if nblk <= 0:
7290 raise ValueError(f"Grid file '{source_grid}' has non-positive block count: {nblk}.")
7291 if expected_nblk is not None and nblk != expected_nblk:
7292 raise ValueError(f"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}.")
7293
7294 dims = []
7295 for bi in range(nblk):
7296 try:
7297 lineno, dim_line = next(line_iter)
7298 except StopIteration:
7299 raise ValueError(f"Grid file '{source_grid}' missing dimensions for block {bi}.")
7300 parts = dim_line.split()
7301 if len(parts) != 3:
7302 raise ValueError(
7303 f"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
7304 )
7305 try:
7306 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
7307 except ValueError:
7308 raise ValueError(
7309 f"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
7310 )
7311 if im <= 0 or jm <= 0 or km <= 0:
7312 raise ValueError(
7313 f"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
7314 )
7315 dims.append((im, jm, km))
7316
7317 return dims
7318
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_and_nondimensionalize_picslice()

dict picurv_cli.core.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.

Parameters
[in]source_sliceInput PICSLICE path.
[in]dest_sliceOutput staged PICSLICE path.
[in]U_refReference velocity for non-dimensionalization.
[in]expected_dimsOptional expected (n1, n2) slice dimensions.
Returns
Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
Exceptions
ValueErroron malformed slice.

Definition at line 7319 of file core.py.

7320 expected_dims: tuple = None) -> dict:
7321 """!
7322 @brief Validate a canonical PICSLICE payload and write a solver-scale copy.
7323 @param[in] source_slice Input PICSLICE path.
7324 @param[in] dest_slice Output staged PICSLICE path.
7325 @param[in] U_ref Reference velocity for non-dimensionalization.
7326 @param[in] expected_dims Optional expected (n1, n2) slice dimensions.
7327 @return Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
7328 @throws ValueError on malformed slice.
7329 """
7330 if U_ref == 0.0:
7331 raise ValueError("velocity_ref must be non-zero when processing PICSLICE speeds.")
7332 if not os.path.isfile(source_slice):
7333 raise ValueError(f"PICSLICE file not found: {source_slice}")
7334
7335 with open(source_slice, "r") as fin:
7336 line_iter = _iter_nonempty_noncomment_lines(fin)
7337 try:
7338 _, first_token = next(line_iter)
7339 except StopIteration:
7340 raise ValueError(f"PICSLICE file '{source_slice}' is empty.")
7341 if first_token != "PICSLICE":
7342 raise ValueError(f"PICSLICE file '{source_slice}' must begin with the canonical PICSLICE header token.")
7343
7344 try:
7345 _, frame_line = next(line_iter)
7346 frame_count = int(frame_line)
7347 except StopIteration:
7348 raise ValueError(f"PICSLICE file '{source_slice}' missing frame count after PICSLICE header.")
7349 except ValueError:
7350 raise ValueError(f"Invalid frame count '{frame_line}' in PICSLICE file '{source_slice}'.")
7351 if frame_count != 1:
7352 raise ValueError(
7353 f"PICSLICE file '{source_slice}' has frame count {frame_count}; PICurv reads exactly one frame."
7354 )
7355
7356 try:
7357 lineno, dim_line = next(line_iter)
7358 except StopIteration:
7359 raise ValueError(f"PICSLICE file '{source_slice}' missing slice dimensions.")
7360 parts = dim_line.split()
7361 if len(parts) != 2:
7362 raise ValueError(
7363 f"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Expected 2 integers, got: '{dim_line}'."
7364 )
7365 try:
7366 n1, n2 = (int(parts[0]), int(parts[1]))
7367 except ValueError:
7368 raise ValueError(
7369 f"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Non-integer values: '{dim_line}'."
7370 )
7371 if n1 <= 0 or n2 <= 0:
7372 raise ValueError(f"Invalid PICSLICE dimensions at {source_slice}:{lineno}: ({n1}, {n2}). Must be > 0.")
7373 if expected_dims is not None and (n1, n2) != tuple(expected_dims):
7374 raise ValueError(
7375 f"PICSLICE dimension mismatch for '{source_slice}': expected {tuple(expected_dims)}, found {(n1, n2)}."
7376 )
7377
7378 values = []
7379 for lineno, value_line in line_iter:
7380 parts = value_line.split()
7381 if len(parts) != 1:
7382 raise ValueError(
7383 f"Invalid PICSLICE value row at {source_slice}:{lineno}. Expected 1 float, got: '{value_line}'."
7384 )
7385 try:
7386 value = float(parts[0])
7387 except ValueError:
7388 raise ValueError(f"Invalid PICSLICE value at {source_slice}:{lineno}: '{value_line}'.")
7389 if not math.isfinite(value):
7390 raise ValueError(f"PICSLICE value at {source_slice}:{lineno} must be finite.")
7391 if value < 0.0:
7392 raise ValueError(f"PICSLICE value at {source_slice}:{lineno} must be nonnegative.")
7393 values.append(value)
7394
7395 expected_count = n1 * n2
7396 if len(values) != expected_count:
7397 raise ValueError(
7398 f"PICSLICE file '{source_slice}' has {len(values)} values, expected {expected_count} from dimensions {(n1, n2)}."
7399 )
7400
7401 os.makedirs(os.path.dirname(dest_slice), exist_ok=True)
7402 with open(dest_slice, "w") as fout:
7403 fout.write("PICSLICE\n")
7404 fout.write("1\n")
7405 fout.write(f"{n1} {n2}\n")
7406 for value in values:
7407 fout.write(f"{value / U_ref:.8e}\n")
7408
7409 return {
7410 "frame_count": frame_count,
7411 "dims": (n1, n2),
7412 "value_count": len(values),
7413 "min_speed": min(values) if values else 0.0,
7414 "max_speed": max(values) if values else 0.0,
7415 }
7416
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _face_artifact_token()

str picurv_cli.core._face_artifact_token ( str  face)
protected

Convert a BC face token into a filesystem-friendly artifact token.

Parameters
[in]faceCanonical face token such as -Zeta.
Returns
Filesystem-friendly face token.

Definition at line 7417 of file core.py.

7417def _face_artifact_token(face: str) -> str:
7418 """!
7419 @brief Convert a BC face token into a filesystem-friendly artifact token.
7420 @param[in] face Canonical face token such as -Zeta.
7421 @return Filesystem-friendly face token.
7422 """
7423 return face.replace("+", "pos").replace("-", "neg")
7424
Here is the caller graph for this function:

◆ _resolve_run_artifact_path()

str picurv_cli.core._resolve_run_artifact_path ( str  run_dir,
str  configured_path,
str  default_path,
bool   default_to_config_dir = False 
)
protected

Resolve a run artifact path with run-dir-relative defaults.

Parameters
[in]run_dirRun/precompute directory root.
[in]configured_pathOptional user-provided artifact path.
[in]default_pathDefault path relative to run_dir.
[in]default_to_config_dirIf true, bare relative names are placed under config/.
Returns
Absolute artifact path.

Definition at line 7425 of file core.py.

7426 default_to_config_dir: bool = False) -> str:
7427 """!
7428 @brief Resolve a run artifact path with run-dir-relative defaults.
7429 @param[in] run_dir Run/precompute directory root.
7430 @param[in] configured_path Optional user-provided artifact path.
7431 @param[in] default_path Default path relative to run_dir.
7432 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
7433 @return Absolute artifact path.
7434 """
7435 path = configured_path if configured_path else default_path
7436 if not isinstance(path, str) or not path.strip():
7437 raise ValueError("generated profile output_file must be a non-empty path when provided.")
7438 path = path.strip()
7439 if os.path.isabs(path):
7440 return os.path.abspath(path)
7441 if default_to_config_dir and os.path.dirname(path) == "":
7442 path = os.path.join("config", path)
7443 return os.path.abspath(os.path.join(run_dir, path))
7444

◆ _resolve_generator_script()

str picurv_cli.core._resolve_generator_script ( str  configured_script,
str  case_path,
str  default_name 
)
protected

Resolve an optional generator script override or repository default.

Parameters
[in]configured_scriptOptional absolute or case-relative script path.
[in]case_pathCurrent case.yml path used to anchor relative overrides.
[in]default_nameRepository generator filename under GENERATORS_PATH.
Returns
Absolute generator script path.

Definition at line 7445 of file core.py.

7445def _resolve_generator_script(configured_script: str, case_path: str, default_name: str) -> str:
7446 """!
7447 @brief Resolve an optional generator script override or repository default.
7448 @param[in] configured_script Optional absolute or case-relative script path.
7449 @param[in] case_path Current case.yml path used to anchor relative overrides.
7450 @param[in] default_name Repository generator filename under GENERATORS_PATH.
7451 @return Absolute generator script path.
7452 """
7453 if configured_script is None:
7454 return os.path.join(GENERATORS_PATH, default_name)
7455 if not isinstance(configured_script, str) or not configured_script.strip():
7456 raise ValueError(f"Generator script override for {default_name} must be a non-empty path.")
7457 script = configured_script.strip()
7458 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
7459 return _resolve_case_relative_path(script, case_dir)
7460
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _normalize_square_duct_poiseuille_params()

dict picurv_cli.core._normalize_square_duct_poiseuille_params (   params,
str  field_name 
)
protected

Validate square-duct Poiseuille generator parameters.

Parameters
[in]paramsGenerator params mapping.
[in]field_nameHuman-readable YAML field name for diagnostics.
Returns
Normalized params.

Definition at line 7461 of file core.py.

7461def _normalize_square_duct_poiseuille_params(params, field_name: str) -> dict:
7462 """!
7463 @brief Validate square-duct Poiseuille generator parameters.
7464 @param[in] params Generator params mapping.
7465 @param[in] field_name Human-readable YAML field name for diagnostics.
7466 @return Normalized params.
7467 """
7468 if params is None:
7469 params = {}
7470 if not isinstance(params, dict):
7471 raise ValueError(f"{field_name}.params must be a mapping when provided.")
7472 unknown = sorted(set(params.keys()) - {"bulk_velocity", "n_terms"})
7473 if unknown:
7474 raise ValueError(f"Unknown keys in {field_name}.params: {unknown}. Allowed: ['bulk_velocity', 'n_terms'].")
7475 bulk_velocity = _to_float(params.get("bulk_velocity", 1.0), f"{field_name}.params.bulk_velocity")
7476 if bulk_velocity <= 0.0:
7477 raise ValueError(f"{field_name}.params.bulk_velocity must be positive.")
7478 try:
7479 n_terms = int(params.get("n_terms", 101))
7480 except (TypeError, ValueError):
7481 raise ValueError(f"{field_name}.params.n_terms must be a positive odd integer.")
7482 if n_terms <= 0 or n_terms % 2 == 0:
7483 raise ValueError(f"{field_name}.params.n_terms must be a positive odd integer.")
7484 return {"bulk_velocity": bulk_velocity, "n_terms": n_terms}
7485
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _normalize_field_slice_source()

dict picurv_cli.core._normalize_field_slice_source (   source,
str  field_name 
)
protected

Validate a prescribed_flow field_slice source block.

Parameters
[in]sourceSource mapping from case.yml.
[in]field_nameHuman-readable YAML path for diagnostics.
Returns
Normalized source mapping.

Definition at line 7488 of file core.py.

7488def _normalize_field_slice_source(source, field_name: str) -> dict:
7489 """!
7490 @brief Validate a prescribed_flow field_slice source block.
7491 @param[in] source Source mapping from case.yml.
7492 @param[in] field_name Human-readable YAML path for diagnostics.
7493 @return Normalized source mapping.
7494 """
7495 allowed = {
7496 "type",
7497 "script",
7498 "field_file",
7499 "grid_file",
7500 "source_case",
7501 "velocity_scale",
7502 "source_block",
7503 "slice",
7504 }
7505 if "output_file" in source:
7506 raise ValueError(retired_destination_message(field_name, "output_file"))
7507 unknown = sorted(set(source.keys()) - allowed)
7508 if unknown:
7509 raise ValueError(f"Unknown keys in {field_name}: {unknown}. Allowed: {sorted(allowed)}.")
7510 field_file = source.get("field_file")
7511 grid_file = source.get("grid_file")
7512 if not isinstance(field_file, str) or not field_file.strip():
7513 raise ValueError(f"{field_name}.field_file must be a non-empty path.")
7514 if not isinstance(grid_file, str) or not grid_file.strip():
7515 raise ValueError(f"{field_name}.grid_file must be a non-empty path.")
7516 if source.get("source_case") is None and source.get("velocity_scale") is None:
7517 raise ValueError(f"{field_name} requires source_case or velocity_scale.")
7518
7519 normalized = {
7520 "type": "field_slice",
7521 "field_file": field_file.strip(),
7522 "grid_file": grid_file.strip(),
7523 "slice": _normalize_field_slice_selector(source.get("slice"), f"{field_name}.slice"),
7524 }
7525 if source.get("script") is not None:
7526 script = source.get("script")
7527 if not isinstance(script, str) or not script.strip():
7528 raise ValueError(f"{field_name}.script must be a non-empty path when provided.")
7529 normalized["script"] = script.strip()
7530 if source.get("source_case") is not None:
7531 source_case = source.get("source_case")
7532 if not isinstance(source_case, str) or not source_case.strip():
7533 raise ValueError(f"{field_name}.source_case must be a non-empty path when provided.")
7534 normalized["source_case"] = source_case.strip()
7535 if source.get("velocity_scale") is not None:
7536 velocity_scale = _to_float(source.get("velocity_scale"), f"{field_name}.velocity_scale")
7537 if velocity_scale <= 0.0:
7538 raise ValueError(f"{field_name}.velocity_scale must be positive.")
7539 normalized["velocity_scale"] = velocity_scale
7540 if source.get("source_block") is not None:
7541 try:
7542 source_block = int(source.get("source_block"))
7543 except (TypeError, ValueError):
7544 raise ValueError(f"{field_name}.source_block must be a non-negative integer.")
7545 if source_block < 0:
7546 raise ValueError(f"{field_name}.source_block must be a non-negative integer.")
7547 normalized["source_block"] = source_block
7548 return normalized
7549
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _normalize_field_slice_selector()

dict picurv_cli.core._normalize_field_slice_selector (   slice_cfg,
str  field_name 
)
protected

Validate the field_slice slice selector.

Parameters
[in]slice_cfgSlice selector mapping.
[in]field_nameHuman-readable YAML path for diagnostics.
Returns
Normalized selector mapping.

Definition at line 7550 of file core.py.

7550def _normalize_field_slice_selector(slice_cfg, field_name: str) -> dict:
7551 """!
7552 @brief Validate the field_slice slice selector.
7553 @param[in] slice_cfg Slice selector mapping.
7554 @param[in] field_name Human-readable YAML path for diagnostics.
7555 @return Normalized selector mapping.
7556 """
7557 if not isinstance(slice_cfg, dict):
7558 raise ValueError(f"{field_name} must be a mapping.")
7559 orientation = str(slice_cfg.get("orientation", "opposite")).strip().lower()
7560 if orientation not in {"opposite", "same"}:
7561 raise ValueError(f"{field_name}.orientation must be 'opposite' or 'same'.")
7562 normal_tolerance = _to_float(slice_cfg.get("normal_tolerance", 0.99), f"{field_name}.normal_tolerance")
7563 if normal_tolerance <= 0.0 or normal_tolerance > 1.0:
7564 raise ValueError(f"{field_name}.normal_tolerance must be in the range (0, 1].")
7565
7566 if slice_cfg.get("face") is not None:
7567 unknown = sorted(set(slice_cfg.keys()) - {"face", "orientation", "normal_tolerance"})
7568 if unknown:
7569 raise ValueError(
7570 f"Unknown keys in {field_name}: {unknown}. "
7571 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
7572 )
7573 face = str(slice_cfg.get("face", "")).strip()
7574 if face.lower() not in BC_FACE_MAP:
7575 raise ValueError(f"{field_name}.face must be one of {sorted(BC_FACE_MAP.values())}.")
7576 return {
7577 "face": BC_FACE_MAP[face.lower()],
7578 "orientation": orientation,
7579 "normal_tolerance": normal_tolerance,
7580 }
7581
7582 required = {"axis", "index", "normal"}
7583 missing = sorted(key for key in required if slice_cfg.get(key) is None)
7584 if missing:
7585 raise ValueError(f"{field_name} requires either face or axis/index/normal; missing {missing}.")
7586 unknown = sorted(set(slice_cfg.keys()) - {"axis", "index", "normal", "orientation", "normal_tolerance"})
7587 if unknown:
7588 raise ValueError(
7589 f"Unknown keys in {field_name}: {unknown}. "
7590 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
7591 )
7592 axis = str(slice_cfg.get("axis", "")).strip()
7593 axis_map = {"xi": "Xi", "eta": "Eta", "zeta": "Zeta"}
7594 if axis.lower() not in axis_map:
7595 raise ValueError(f"{field_name}.axis must be one of Xi, Eta, Zeta.")
7596 normal = str(slice_cfg.get("normal", "")).strip()
7597 if normal.lower() not in BC_FACE_MAP:
7598 raise ValueError(f"{field_name}.normal must be one of {sorted(BC_FACE_MAP.values())}.")
7599 normal = BC_FACE_MAP[normal.lower()]
7600 if normal[1:].lower() != axis.lower():
7601 raise ValueError(f"{field_name}.normal must use the same axis as {field_name}.axis.")
7602 try:
7603 index = int(slice_cfg.get("index"))
7604 except (TypeError, ValueError):
7605 raise ValueError(f"{field_name}.index must be an integer.")
7606 if index < 0:
7607 raise ValueError(f"{field_name}.index must be non-negative.")
7608 return {
7609 "axis": axis_map[axis.lower()],
7610 "index": index,
7611 "normal": normal,
7612 "orientation": orientation,
7613 "normal_tolerance": normal_tolerance,
7614 }
7615
Here is the call graph for this function:
Here is the caller graph for this function:

◆ generate_square_duct_poiseuille_picslice()

dict picurv_cli.core.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.

Parameters
[in]output_pathPath to write.
[in]dimsPICSLICE dimensions in face storage order (n1, n2).
[in]paramsNormalized generator params.
[in]target_gridOptional canonical target PICGRID for grid-aware sampling.
[in]target_blockTarget block index when target_grid is provided.
[in]target_faceTarget inlet face when target_grid is provided.
[in]scriptOptional profile.gen-compatible script override.
[in]case_pathCurrent case.yml path used to anchor relative script overrides.
Returns
Summary dictionary.

Definition at line 7616 of file core.py.

7619 case_path: str = None) -> dict:
7620 """!
7621 @brief Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
7622 @param[in] output_path Path to write.
7623 @param[in] dims PICSLICE dimensions in face storage order (n1, n2).
7624 @param[in] params Normalized generator params.
7625 @param[in] target_grid Optional canonical target PICGRID for grid-aware sampling.
7626 @param[in] target_block Target block index when `target_grid` is provided.
7627 @param[in] target_face Target inlet face when `target_grid` is provided.
7628 @param[in] script Optional profile.gen-compatible script override.
7629 @param[in] case_path Current case.yml path used to anchor relative script overrides.
7630 @return Summary dictionary.
7631 """
7632 n1, n2 = tuple(dims)
7633 profilegen_script = _resolve_generator_script(script, case_path, "profile.gen")
7634 if not os.path.isfile(profilegen_script):
7635 raise ValueError(f"profile.gen script not found: {profilegen_script}")
7636 cmd = [
7637 sys.executable,
7638 profilegen_script,
7639 "square_duct_poiseuille",
7640 "--output",
7641 output_path,
7642 "--dims",
7643 str(n1),
7644 str(n2),
7645 "--bulk-velocity",
7646 str(float(params["bulk_velocity"])),
7647 "--n-terms",
7648 str(int(params["n_terms"])),
7649 ]
7650 if target_grid:
7651 cmd.extend([
7652 "--target-grid",
7653 target_grid,
7654 "--target-block",
7655 str(int(target_block)),
7656 f"--target-face={target_face}",
7657 ])
7658 result = subprocess.run(cmd, text=True, capture_output=True)
7659 if result.returncode != 0:
7660 details = (result.stderr or result.stdout or "").strip()
7661 raise ValueError(f"profile.gen failed with exit code {result.returncode}. Details:\n{details}")
7662 try:
7663 summary = json.loads((result.stdout or "").strip().splitlines()[-1])
7664 except (IndexError, json.JSONDecodeError) as exc:
7665 raise ValueError(f"profile.gen did not emit valid JSON summary. Output:\n{result.stdout}") from exc
7666 summary["dims"] = tuple(summary["dims"])
7667 return summary
7668
Here is the call graph for this function:
Here is the caller graph for this function:

◆ generate_field_slice_picslice()

dict picurv_cli.core.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.

Parameters
[in]output_pathPath to write.
[in]expected_dimsExpected PICSLICE dimensions.
[in]sourceNormalized field_slice source mapping.
[in]target_gridTarget canonical PICGRID path.
[in]target_faceTarget inlet face token.
[in]target_blockTarget block index.
[in]case_pathPath to current case.yml for relative source resolution.
Returns
Summary dictionary from profile.gen.

Definition at line 7669 of file core.py.

7671 case_path: str) -> dict:
7672 """!
7673 @brief Invoke profile.gen to extract a field_slice PICSLICE artifact.
7674 @param[in] output_path Path to write.
7675 @param[in] expected_dims Expected PICSLICE dimensions.
7676 @param[in] source Normalized field_slice source mapping.
7677 @param[in] target_grid Target canonical PICGRID path.
7678 @param[in] target_face Target inlet face token.
7679 @param[in] target_block Target block index.
7680 @param[in] case_path Path to current case.yml for relative source resolution.
7681 @return Summary dictionary from profile.gen.
7682 """
7683 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
7684 field_file = _resolve_case_relative_path(source["field_file"], case_dir)
7685 source_grid = _resolve_case_relative_path(source["grid_file"], case_dir)
7686 velocity_scale = _resolve_field_slice_velocity_scale(source, case_dir)
7687 profilegen_script = _resolve_generator_script(source.get("script"), case_path, "profile.gen")
7688 if not os.path.isfile(profilegen_script):
7689 raise ValueError(f"profile.gen script not found: {profilegen_script}")
7690 n1, n2 = tuple(expected_dims)
7691 slice_cfg = source["slice"]
7692 cmd = [
7693 sys.executable,
7694 profilegen_script,
7695 "field-slice",
7696 "--output",
7697 output_path,
7698 "--field-file",
7699 field_file,
7700 "--source-grid",
7701 source_grid,
7702 "--target-grid",
7703 target_grid,
7704 "--source-block",
7705 str(int(source.get("source_block", 0))),
7706 "--target-block",
7707 str(int(target_block)),
7708 f"--target-face={target_face}",
7709 "--orientation",
7710 slice_cfg["orientation"],
7711 "--normal-tolerance",
7712 str(float(slice_cfg["normal_tolerance"])),
7713 "--velocity-scale",
7714 str(float(velocity_scale)),
7715 "--expected-dims",
7716 str(n1),
7717 str(n2),
7718 *petsc_index_arguments(payload_petsc_index_bytes(field_file)),
7719 ]
7720 if "face" in slice_cfg:
7721 cmd.append(f"--slice-face={slice_cfg['face']}")
7722 else:
7723 cmd.extend([
7724 "--slice-axis",
7725 slice_cfg["axis"],
7726 "--slice-index",
7727 str(int(slice_cfg["index"])),
7728 f"--slice-normal={slice_cfg['normal']}",
7729 ])
7730 result = subprocess.run(cmd, text=True, capture_output=True)
7731 if result.returncode != 0:
7732 details = (result.stderr or result.stdout or "").strip()
7733 raise ValueError(f"profile.gen field-slice failed with exit code {result.returncode}. Details:\n{details}")
7734 try:
7735 summary = json.loads((result.stdout or "").strip().splitlines()[-1])
7736 except (IndexError, json.JSONDecodeError) as exc:
7737 raise ValueError(f"profile.gen field-slice did not emit valid JSON summary. Output:\n{result.stdout}") from exc
7738 summary["dims"] = tuple(summary["dims"])
7739 return summary
7740
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _resolve_case_relative_path()

str picurv_cli.core._resolve_case_relative_path ( str  path_value,
str  case_dir 
)
protected

Resolve a path relative to the current case directory.

Parameters
[in]path_valuePath from case.yml.
[in]case_dirCurrent case directory.
Returns
Absolute path.

Definition at line 7741 of file core.py.

7741def _resolve_case_relative_path(path_value: str, case_dir: str) -> str:
7742 """!
7743 @brief Resolve a path relative to the current case directory.
7744 @param[in] path_value Path from case.yml.
7745 @param[in] case_dir Current case directory.
7746 @return Absolute path.
7747 """
7748 if not isinstance(path_value, str) or not path_value.strip():
7749 raise ValueError("path value must be a non-empty string.")
7750 workspace_root = find_workspace_root(case_dir)
7751 if workspace_root:
7752 if os.path.isabs(path_value):
7753 raise ValueError(
7754 f"absolute path {path_value!r} is not allowed in workspace case configuration; "
7755 "use 'picurv inputs import --mode reference' for an explicit external reference."
7756 )
7757 resolved = os.path.abspath(os.path.join(workspace_root, path_value))
7758 if os.path.commonpath([workspace_root, resolved]) != os.path.abspath(workspace_root):
7759 raise ValueError(f"path {path_value!r} escapes the workspace.")
7760 if resolved.endswith(".reference.yml") and os.path.isfile(resolved):
7761 pointer = read_yaml_file(resolved)
7762 external = pointer.get("picurv_external_reference") if isinstance(pointer, dict) else None
7763 if not isinstance(external, str) or not os.path.isabs(external):
7764 raise ValueError(f"Invalid external-reference descriptor: {resolved}")
7765 if not os.path.isfile(external):
7766 raise ValueError(f"Registered external input is unavailable: {external}")
7767 return external
7768 return resolved
7769 if os.path.isabs(path_value):
7770 return os.path.abspath(path_value)
7771 return os.path.abspath(os.path.join(case_dir, path_value))
7772
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _resolve_field_slice_velocity_scale()

float picurv_cli.core._resolve_field_slice_velocity_scale ( dict  source,
str  case_dir 
)
protected

Resolve field_slice dimensional velocity scale.

Parameters
[in]sourceNormalized field_slice source mapping.
[in]case_dirCurrent case directory.
Returns
Positive velocity scale.

Definition at line 7773 of file core.py.

7773def _resolve_field_slice_velocity_scale(source: dict, case_dir: str) -> float:
7774 """!
7775 @brief Resolve field_slice dimensional velocity scale.
7776 @param[in] source Normalized field_slice source mapping.
7777 @param[in] case_dir Current case directory.
7778 @return Positive velocity scale.
7779 """
7780 if source.get("velocity_scale") is not None:
7781 return float(source["velocity_scale"])
7782 source_case = _resolve_case_relative_path(source["source_case"], case_dir)
7783 source_case_cfg = read_yaml_file(source_case)
7784 try:
7785 velocity_scale = _to_float(
7786 source_case_cfg.get("properties", {}).get("scaling", {}).get("velocity_ref"),
7787 "source_case.properties.scaling.velocity_ref",
7788 )
7789 except AttributeError as exc:
7790 raise ValueError("source_case must contain properties.scaling.velocity_ref.") from exc
7791 if velocity_scale <= 0.0:
7792 raise ValueError("source_case.properties.scaling.velocity_ref must be positive.")
7793 return velocity_scale
7794
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_target_grid_for_field_slice()

str picurv_cli.core.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.

Parameters
[in]case_cfgParsed current case config.
[in]case_pathCurrent case.yml path.
[in]run_dirCurrent run/precompute directory.
Returns
Absolute target PICGRID path.

Definition at line 7795 of file core.py.

7795def resolve_target_grid_for_field_slice(case_cfg: dict, case_path: str, run_dir: str) -> str:
7796 """!
7797 @brief Resolve the target canonical PICGRID path needed for field_slice normals.
7798 @param[in] case_cfg Parsed current case config.
7799 @param[in] case_path Current case.yml path.
7800 @param[in] run_dir Current run/precompute directory.
7801 @return Absolute target PICGRID path.
7802 """
7803 grid_cfg = case_cfg.get("grid", {}) or {}
7804 grid_mode = grid_cfg.get("mode")
7805 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
7806 if grid_mode == "file":
7807 source_grid = grid_cfg.get("source_file")
7808 if not isinstance(source_grid, str) or not source_grid.strip():
7809 raise ValueError("grid.source_file is required for field_slice target-grid normals.")
7810 source_grid = _resolve_case_relative_path(source_grid, case_dir)
7811 return source_grid
7812 if grid_mode == "grid_gen":
7813 candidate = os.path.abspath(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
7814 if os.path.isfile(candidate):
7815 return candidate
7816 staged = os.path.join(run_dir, "inputs", "grid", "grid.run")
7817 if os.path.isfile(staged):
7818 return staged
7819 raise ValueError("field_slice requires the generated target PICGRID to exist before profile extraction.")
7820 raise ValueError(
7821 f"field_slice requires grid.mode 'file' or 'grid_gen' for target-grid normals; got '{grid_mode}'."
7822 )
7823
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_target_grid_for_generated_profile()

str picurv_cli.core.resolve_target_grid_for_generated_profile ( dict  case_cfg,
str  case_path,
str  run_dir 
)

Resolve the target canonical PICGRID for generated profile sampling.

The simulator builds a programmatic_c grid itself, so no grid file exists here. The profile is sampled instead on the bridge PICGRID that a Python initial condition already uses, written with the solver's own node formula. Without a grid the generator could only sample uniform logical points and normalize to the continuous-area mean, which delivered about 2/n too much flux on n cells across (programmatic-inlet-flux-2026-09-18).

Parameters
[in]case_cfgParsed current case config.
[in]case_pathCurrent case.yml path.
[in]run_dirCurrent run/precompute directory.
Returns
Absolute target PICGRID path.

Definition at line 7824 of file core.py.

7824def resolve_target_grid_for_generated_profile(case_cfg: dict, case_path: str, run_dir: str) -> str:
7825 """!
7826 @brief Resolve the target canonical PICGRID for generated profile sampling.
7827 @details The simulator builds a programmatic_c grid itself, so no grid file exists
7828 here. The profile is sampled instead on the bridge PICGRID that a Python
7829 initial condition already uses, written with the solver's own node formula.
7830 Without a grid the generator could only sample uniform logical points and
7831 normalize to the continuous-area mean, which delivered about 2/n too much
7832 flux on n cells across (programmatic-inlet-flux-2026-09-18).
7833 @param[in] case_cfg Parsed current case config.
7834 @param[in] case_path Current case.yml path.
7835 @param[in] run_dir Current run/precompute directory.
7836 @return Absolute target PICGRID path.
7837 """
7838 grid_cfg = case_cfg.get("grid", {}) or {}
7839 if grid_cfg.get("mode") == "programmatic_c":
7840 bridge = os.path.abspath(os.path.join(run_dir, "inputs", "grid", "grid.run"))
7841 if not os.path.isfile(bridge):
7842 generate_picgrid_from_programmatic_settings(
7843 grid_cfg.get("programmatic_settings", {}), bridge,
7844 input_reference_scale(("case", "grid", "programmatic_settings", "xMins"),
7845 resolve_reference_scales(case_cfg)),
7846 )
7847 return bridge
7848 return resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
7849
Here is the call graph for this function:
Here is the caller graph for this function:

◆ write_profile_info()

str picurv_cli.core.write_profile_info ( str  config_dir,
list  summaries 
)

Write a profile.info summary for generated inlet profiles.

Parameters
[in]config_dirRun/precompute config directory.
[in]summariesGenerated profile summaries.
Returns
Path to profile.info.

Definition at line 7850 of file core.py.

7850def write_profile_info(config_dir: str, summaries: list) -> str:
7851 """!
7852 @brief Write a profile.info summary for generated inlet profiles.
7853 @param[in] config_dir Run/precompute config directory.
7854 @param[in] summaries Generated profile summaries.
7855 @return Path to profile.info.
7856 """
7857 info_path = os.path.join(config_dir, "profile.info")
7858 os.makedirs(config_dir, exist_ok=True)
7859 with open(info_path, "w") as fout:
7860 fout.write("# PICurv generated profile summary\n")
7861 fout.write(f"profile_count = {len(summaries)}\n\n")
7862 for idx, summary in enumerate(summaries):
7863 dims = summary.get("dims", (0, 0))
7864 fout.write(f"[profile_{idx}]\n")
7865 fout.write(f"generator = {summary.get('generator')}\n")
7866 fout.write(f"block = {summary.get('block')}\n")
7867 fout.write(f"face = {summary.get('face')}\n")
7868 fout.write(f"dimensions = {dims[0]} {dims[1]}\n")
7869 if summary.get("bulk_velocity") is not None:
7870 fout.write(f"bulk_velocity = {summary.get('bulk_velocity'):.16e}\n")
7871 if summary.get("n_terms") is not None:
7872 fout.write(f"n_terms = {summary.get('n_terms')}\n")
7873 fout.write(f"mean_speed = {summary.get('mean_speed'):.16e}\n")
7874 if "area_mean_speed" in summary:
7875 fout.write(f"area_mean_speed = {summary.get('area_mean_speed'):.16e}\n")
7876 if "discrete_mean_speed" in summary:
7877 fout.write(f"discrete_mean_speed = {summary.get('discrete_mean_speed'):.16e}\n")
7878 fout.write(f"min_speed = {summary.get('min_speed'):.16e}\n")
7879 fout.write(f"max_speed = {summary.get('max_speed'):.16e}\n")
7880 if summary.get("umax_over_ubulk") is not None:
7881 fout.write(f"umax_over_ubulk = {summary.get('umax_over_ubulk'):.16e}\n")
7882 for key in (
7883 "normalization",
7884 "sampling",
7885 "area_weighted_mean_before_normalization",
7886 "area_weighted_mean_after_normalization",
7887 "total_inlet_area",
7888 "face_area_min",
7889 "face_area_max",
7890 "source_field",
7891 "source_grid",
7892 "target_grid",
7893 "source_block",
7894 "target_block",
7895 "target_face",
7896 "source_slice",
7897 "orientation",
7898 "normal_tolerance",
7899 "normal_dot",
7900 "velocity_scale",
7901 ):
7902 if key in summary:
7903 fout.write(f"{key} = {summary.get(key)}\n")
7904 fout.write(f"output_file = {summary.get('path')}\n\n")
7905 return info_path
7906
7907#: Generator flags whose values are drawn from a closed choice set. Checking them where
7908#: the case is validated turns a mid-run subprocess failure into a startup error.
Here is the caller graph for this function:

◆ validate_grid_generator_cli_args()

list picurv_cli.core.validate_grid_generator_cli_args (   cli_args,
str  case_path 
)

Check closed-choice values inside the generator's opaque token list.

cli_args is passed through to grid.gen untouched, so a misspelled geometry selector there is only discovered when the subprocess exits nonzero partway into a run. The generator remains the authority; this reports the same set earlier, and stays silent on anything it does not recognize.

Parameters
[in]cli_argsRaw token list from grid.generator.cli_args.
[in]case_pathCase file path for diagnostics.
Returns
List of error strings.

Definition at line 7925 of file core.py.

7925def validate_grid_generator_cli_args(cli_args, case_path: str) -> list:
7926 """!
7927 @brief Check closed-choice values inside the generator's opaque token list.
7928 @details `cli_args` is passed through to grid.gen untouched, so a misspelled geometry
7929 selector there is only discovered when the subprocess exits nonzero partway
7930 into a run. The generator remains the authority; this reports the same set
7931 earlier, and stays silent on anything it does not recognize.
7932 @param[in] cli_args Raw token list from grid.generator.cli_args.
7933 @param[in] case_path Case file path for diagnostics.
7934 @return List of error strings.
7935 """
7936 if not isinstance(cli_args, list):
7937 return []
7938 tokens = [str(token) for token in cli_args]
7939 errors = []
7940 for index, token in enumerate(tokens):
7941 values = []
7942 for follower in tokens[index + 1:]:
7943 if follower.startswith("--"):
7944 break
7945 values.append(follower)
7946 if token in GRID_GENERATOR_CHOICE_FLAGS:
7947 allowed = GRID_GENERATOR_CHOICE_FLAGS[token]
7948 for value in values[:1]:
7949 if value not in allowed:
7950 errors.append(
7951 f" {case_path}: grid.generator.cli_args {token} must be one of "
7952 f"{list(allowed)} (got '{value}')."
7953 )
7954 elif token in GRID_GENERATOR_SPEC_FLAGS:
7955 allowed = GRID_GENERATOR_SPEC_FLAGS[token]
7956 for value in values:
7957 kind = value.split(":", 1)[0]
7958 if kind not in allowed:
7959 errors.append(
7960 f" {case_path}: grid.generator.cli_args {token} entry '{value}' "
7961 f"names '{kind}', which is not one of {list(allowed)}."
7962 )
7963 return errors
7964
7965
Here is the caller graph for this function:

◆ run_grid_generator()

str picurv_cli.core.run_grid_generator ( str  case_path,
str  run_dir,
dict  grid_cfg,
dict   case_cfg = None 
)

Runs generators/grid.gen to produce a PICGRID file for this run.

Parameters
[in]case_pathPath to case.yml (used for relative path resolution).
[in]run_dirRun directory path.
[in]grid_cfgThe grid config section from case.yml.
[in]case_cfgParsed case configuration, when available. Its reference scales are handed to the generator so the quality report can carry solver and wall units. Reporting only: the generator never scales coordinates, which validate_and_nondimensionalize_picgrid does once for every grid regardless of origin.
Returns
Absolute path to generated dimensional PICGRID file.
Exceptions
ValueErroron invalid config or generator failure.

Definition at line 7966 of file core.py.

7967 case_cfg: dict = None) -> str:
7968 """!
7969 @brief Runs generators/grid.gen to produce a PICGRID file for this run.
7970 @param[in] case_path Path to case.yml (used for relative path resolution).
7971 @param[in] run_dir Run directory path.
7972 @param[in] grid_cfg The grid config section from case.yml.
7973 @param[in] case_cfg Parsed case configuration, when available. Its reference scales
7974 are handed to the generator so the quality report can carry
7975 solver and wall units. Reporting only: the generator never scales
7976 coordinates, which validate_and_nondimensionalize_picgrid does
7977 once for every grid regardless of origin.
7978 @return Absolute path to generated dimensional PICGRID file.
7979 @throws ValueError on invalid config or generator failure.
7980 """
7981 generator = grid_cfg.get("generator", {})
7982 if not isinstance(generator, dict):
7983 raise ValueError("grid.generator must be a mapping when grid.mode is 'grid_gen'.")
7984
7985 case_dir = os.path.dirname(os.path.abspath(case_path))
7986 gridgen_script = generator.get("script", os.path.join(GENERATORS_PATH, "grid.gen"))
7987 if generator.get("script"):
7988 gridgen_script = _resolve_case_relative_path(gridgen_script, case_dir)
7989 else:
7990 gridgen_script = os.path.abspath(gridgen_script)
7991 if not os.path.isfile(gridgen_script):
7992 raise ValueError(f"grid.gen script not found: {gridgen_script}")
7993
7994 config_file = generator.get("config_file")
7995 if not config_file:
7996 raise ValueError("grid.generator.config_file is required when grid.mode is 'grid_gen'.")
7997 config_file = _resolve_case_relative_path(config_file, case_dir)
7998 if not os.path.isfile(config_file):
7999 raise ValueError(f"grid.generator.config_file not found: {config_file}")
8000
8001 output_file = os.path.abspath(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
8002 os.makedirs(os.path.dirname(output_file), exist_ok=True)
8003
8004 grid_type = generator.get("grid_type")
8005 cli_args = generator.get("cli_args", [])
8006 if cli_args is None:
8007 cli_args = []
8008 if not isinstance(cli_args, list):
8009 raise ValueError("grid.generator.cli_args must be a list of CLI tokens.")
8010
8011 cmd = [sys.executable, gridgen_script, "-c", config_file]
8012 if grid_type:
8013 cmd.append(str(grid_type))
8014 cmd.extend([str(token) for token in cli_args])
8015 cmd.extend(["--output", output_file])
8016
8017 # Destinations are PICurv's, not the user's. Inspection material is produced
8018 # unconditionally so a published asset is always something the user can look at,
8019 # rather than only when a configuration file happened to name an output path.
8020 vts_file = os.path.abspath(
8021 os.path.join(run_dir, "output", "visualization", "precompute", "grid.vts")
8022 )
8023 os.makedirs(os.path.dirname(vts_file), exist_ok=True)
8024 cmd.extend(["--vts", vts_file])
8025
8026 stats_file = os.path.abspath(
8027 os.path.join(run_dir, "output", "analysis", "metrics", "grid.info")
8028 )
8029 os.makedirs(os.path.dirname(stats_file), exist_ok=True)
8030 cmd.extend(["--stats-file", stats_file])
8031
8032 # The case already knows these; the generator would otherwise report a grid in metres
8033 # with no way to say what it becomes downstream. Resolution is best-effort: a case
8034 # that omits them still gets its grid, just without the derived-unit sections.
8035 if case_cfg:
8036 try:
8037 scaling = resolve_fluid_scaling(case_cfg)
8038 except (KeyError, ValueError, TypeError):
8039 scaling = None
8040 if scaling:
8041 cmd.extend(["--length-ref", repr(scaling["length_ref"]),
8042 "--velocity-ref", repr(scaling["velocity_ref"]),
8043 "--nu", repr(scaling["physical_kinematic_viscosity"])])
8044
8045 print(f"[INFO] Grid generator command: {' '.join(cmd)}")
8046 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
8047 if result.returncode != 0:
8048 stderr = (result.stderr or "").strip()
8049 stdout = (result.stdout or "").strip()
8050 details = stderr if stderr else stdout
8051 raise ValueError(
8052 f"grid.gen failed with exit code {result.returncode}. Details:\n{details}"
8053 )
8054 if result.stdout:
8055 print(result.stdout.strip())
8056 if result.stderr:
8057 print(result.stderr.strip())
8058
8059 if not os.path.isfile(output_file):
8060 raise ValueError(f"grid.gen did not produce expected output file: {output_file}")
8061
8062 return output_file
8063
8064
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _normalize_prescribed_flow_source()

dict picurv_cli.core._normalize_prescribed_flow_source (   source,
str  field_name 
)
protected

Validate the structured source block for prescribed_flow BCs.

Parameters
[in]sourceSource mapping from case.yml.
[in]field_nameHuman-readable YAML path for diagnostics.
Returns
Normalized source mapping.
Exceptions
ValueErroron invalid source contract.

Definition at line 8156 of file core.py.

8156def _normalize_prescribed_flow_source(source, field_name: str) -> dict:
8157 """!
8158 @brief Validate the structured source block for prescribed_flow BCs.
8159 @param[in] source Source mapping from case.yml.
8160 @param[in] field_name Human-readable YAML path for diagnostics.
8161 @return Normalized source mapping.
8162 @throws ValueError on invalid source contract.
8163 """
8164 if not isinstance(source, dict):
8165 raise ValueError(f"{field_name} must be a mapping with type: file, generated, or field_slice.")
8166 source_type = str(source.get("type", "")).strip().lower()
8167 if source_type == "file":
8168 path = source.get("path")
8169 if not isinstance(path, str) or not path.strip():
8170 raise ValueError(f"{field_name}.path must be a non-empty file path.")
8171 unknown = sorted(set(source.keys()) - {"type", "path"})
8172 if unknown:
8173 raise ValueError(f"Unknown keys in {field_name}: {unknown}. Allowed: ['path', 'type'].")
8174 return {"type": "file", "path": path.strip()}
8175
8176 if source_type == "generated":
8177 generator = str(source.get("generator", "")).strip().lower()
8178 if generator not in GENERATED_PROFILE_GENERATORS:
8179 raise ValueError(
8180 f"{field_name}.generator must be one of {sorted(GENERATED_PROFILE_GENERATORS)} "
8181 f"(got '{source.get('generator')}')."
8182 )
8183 if "output_file" in source:
8184 raise ValueError(retired_destination_message(field_name, "output_file"))
8185 unknown = sorted(set(source.keys()) - {"type", "generator", "script", "params"})
8186 if unknown:
8187 raise ValueError(
8188 f"Unknown keys in {field_name}: {unknown}. "
8189 "Allowed: ['generator', 'params', 'script', 'type']."
8190 )
8191 normalized = {
8192 "type": "generated",
8193 "generator": generator,
8194 "params": _normalize_square_duct_poiseuille_params(source.get("params", {}), field_name),
8195 }
8196 script = source.get("script")
8197 if script is not None:
8198 if not isinstance(script, str) or not script.strip():
8199 raise ValueError(f"{field_name}.script must be a non-empty path when provided.")
8200 normalized["script"] = script.strip()
8201 return normalized
8202
8203 if source_type == "field_slice":
8204 return _normalize_field_slice_source(source, field_name)
8205
8206 raise ValueError(f"{field_name}.type must be 'file', 'generated', or 'field_slice'.")
8207
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _bc_profile_expected_dims()

tuple picurv_cli.core._bc_profile_expected_dims ( str  face,
tuple  block_dims 
)
protected

Return expected PICSLICE dimensions for a face and block node dimensions.

Parameters
[in]faceCanonical BC face token.
[in]block_dims(IM, JM, KM) node counts.
Returns
(n1, n2) dimensions in profile storage order.

Definition at line 8208 of file core.py.

8208def _bc_profile_expected_dims(face: str, block_dims: tuple) -> tuple:
8209 """!
8210 @brief Return expected PICSLICE dimensions for a face and block node dimensions.
8211 @param[in] face Canonical BC face token.
8212 @param[in] block_dims (IM, JM, KM) node counts.
8213 @return (n1, n2) dimensions in profile storage order.
8214 """
8215 im, jm, km = block_dims
8216 if min(im, jm, km) < 2:
8217 raise ValueError(
8218 f"Block dimensions {block_dims} are too small for an inlet profile; each axis needs at least 2 nodes."
8219 )
8220 if face in {"-Xi", "+Xi"}:
8221 return (km - 1, jm - 1)
8222 if face in {"-Eta", "+Eta"}:
8223 return (km - 1, im - 1)
8224 if face in {"-Zeta", "+Zeta"}:
8225 return (jm - 1, im - 1)
8226 raise ValueError(f"Unsupported face '{face}' for prescribed_flow profile dimensions.")
8227
Here is the caller graph for this function:

◆ resolve_grid_block_dimensions_for_profiles()

list picurv_cli.core.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.

Parameters
[in]case_cfgParsed case.yml configuration.
[in]case_pathPath to case.yml for relative path resolution.
[in]run_dirCurrent run directory, used for optional generated grid outputs.
Returns
List of (IM, JM, KM) node-count tuples.
Exceptions
ValueErrorwhen dimensions cannot be resolved.

Definition at line 8228 of file core.py.

8228def resolve_grid_block_dimensions_for_profiles(case_cfg: dict, case_path: str, run_dir: str = None) -> list:
8229 """!
8230 @brief Resolve per-block node dimensions for prescribed inlet profile validation.
8231 @param[in] case_cfg Parsed case.yml configuration.
8232 @param[in] case_path Path to case.yml for relative path resolution.
8233 @param[in] run_dir Current run directory, used for optional generated grid outputs.
8234 @return List of (IM, JM, KM) node-count tuples.
8235 @throws ValueError when dimensions cannot be resolved.
8236 """
8237 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
8238 grid_cfg = case_cfg.get("grid", {})
8239 grid_mode = grid_cfg.get("mode")
8240 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
8241
8242 if grid_mode == "programmatic_c":
8243 settings = grid_cfg.get("programmatic_settings", {})
8244 dims_by_axis = []
8245 for key in ("im", "jm", "km"):
8246 raw = settings.get(key)
8247 if raw is None:
8248 raise ValueError(f"grid.programmatic_settings.{key} is required for prescribed_flow profiles.")
8249 if isinstance(raw, list):
8250 if len(raw) != num_blocks:
8251 raise ValueError(
8252 f"grid.programmatic_settings.{key} has {len(raw)} entries, expected {num_blocks} blocks."
8253 )
8254 values = raw
8255 else:
8256 values = [raw] * num_blocks
8257 try:
8258 values = [int(v) + 1 for v in values]
8259 except (TypeError, ValueError):
8260 raise ValueError(f"grid.programmatic_settings.{key} values must be positive integer cell counts.")
8261 if any(v <= 1 for v in values):
8262 raise ValueError(f"grid.programmatic_settings.{key} values must be positive integer cell counts.")
8263 dims_by_axis.append(values)
8264 return list(zip(dims_by_axis[0], dims_by_axis[1], dims_by_axis[2]))
8265
8266 if grid_mode == "file":
8267 source_grid = grid_cfg.get("source_file")
8268 if not isinstance(source_grid, str) or not source_grid.strip():
8269 raise ValueError("grid.source_file is required for file-grid prescribed_flow profile validation.")
8270 source_grid = _resolve_case_relative_path(source_grid, case_dir)
8271 return read_picgrid_header_dimensions(source_grid, expected_nblk=num_blocks)
8272
8273 if grid_mode == "grid_gen":
8274 candidates = []
8275 if run_dir:
8276 candidates.append(os.path.join(run_dir, "inputs", "grid", "grid.generated.picgrid"))
8277 candidates.append(os.path.join(run_dir, "inputs", "grid", "grid.run"))
8278 for candidate in candidates:
8279 if os.path.isfile(candidate):
8280 return read_picgrid_header_dimensions(candidate, expected_nblk=num_blocks)
8281 raise ValueError(
8282 "prescribed_flow profile dimension validation for grid.mode='grid_gen' requires an existing generated "
8283 "PICGRID output. Run or stage the grid first, or use grid.mode='file' with the generated .picgrid."
8284 )
8285
8286 raise ValueError(f"Unsupported grid.mode '{grid_mode}' for prescribed_flow profile validation.")
8287
Here is the call graph for this function:
Here is the caller graph for this function:

◆ materialize_generated_prescribed_flow_profiles()

list picurv_cli.core.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.

Parameters
[in]run_dirRun/precompute directory root.
[in]case_cfgParsed case.yml.
[in]case_pathPath to case.yml for relative grid/source resolution.
[in]profile_grid_dimsOptional pre-resolved block node dimensions.
Returns
List of generated profile summaries.

Definition at line 8288 of file core.py.

8289 profile_grid_dims: list = None) -> list:
8290 """!
8291 @brief Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
8292 @param[in] run_dir Run/precompute directory root.
8293 @param[in] case_cfg Parsed case.yml.
8294 @param[in] case_path Path to case.yml for relative grid/source resolution.
8295 @param[in] profile_grid_dims Optional pre-resolved block node dimensions.
8296 @return List of generated profile summaries.
8297 """
8298 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
8299 if not any(
8300 bc.get("handler") == "prescribed_flow"
8301 and ((bc.get("params") or {}).get("source") or {}).get("type") in PRESCRIBED_FLOW_SOURCE_TYPES[1:]
8302 for block in prepared_blocks for bc in block
8303 ):
8304 return []
8305 if profile_grid_dims is None:
8306 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
8307
8308 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
8309 target_grid = None
8310 generated_target_grid = None
8311 summaries = []
8312 for block_idx, block in enumerate(prepared_blocks):
8313 for bc in block:
8314 if bc.get("handler") != "prescribed_flow":
8315 continue
8316 source = (bc.get("params") or {}).get("source", {})
8317 if source.get("type") not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
8318 continue
8319 face = bc["face"]
8320 dims = _bc_profile_expected_dims(face, profile_grid_dims[block_idx])
8321 face_token = _face_artifact_token(face)
8322 suffix = "generated" if source.get("type") == "generated" else "sliced"
8323 output_path = os.path.join(
8324 profile_dir,
8325 f"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice",
8326 )
8327 if source.get("type") == "generated" and source["generator"] == "square_duct_poiseuille":
8328 if generated_target_grid is None:
8329 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
8330 summary = generate_square_duct_poiseuille_picslice(
8331 output_path,
8332 dims,
8333 source["params"],
8334 target_grid=generated_target_grid,
8335 target_block=block_idx,
8336 target_face=face,
8337 script=source.get("script"),
8338 case_path=case_path,
8339 )
8340 elif source.get("type") == "generated":
8341 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
8342 else:
8343 if target_grid is None:
8344 target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
8345 summary = generate_field_slice_picslice(
8346 output_path,
8347 dims,
8348 source,
8349 target_grid,
8350 face,
8351 block_idx,
8352 case_path,
8353 )
8354 summary.update({"block": block_idx, "face": face})
8355 summaries.append(summary)
8356 print(
8357 f"[SUCCESS] Materialized prescribed_flow profile for block {block_idx}, face {face}: "
8358 f"{os.path.relpath(output_path)} dims={summary['dims']}"
8359 )
8360
8361 if summaries:
8362 info_path = write_profile_info(profile_dir, summaries)
8363 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
8364 return summaries
8365
Here is the call graph for this function:

◆ _to_float()

float picurv_cli.core._to_float (   value,
str  field_name 
)
protected

Convert a YAML scalar to float with a clear error message.

Parameters
[in]valueArgument passed to _to_float().
[in]field_nameArgument passed to _to_float().
Returns
Value returned by _to_float().

Definition at line 8366 of file core.py.

8366def _to_float(value, field_name: str) -> float:
8367 """!
8368 @brief Convert a YAML scalar to float with a clear error message.
8369 @param[in] value Argument passed to `_to_float()`.
8370 @param[in] field_name Argument passed to `_to_float()`.
8371 @return Value returned by `_to_float()`.
8372 """
8373 try:
8374 return float(value)
8375 except (TypeError, ValueError):
8376 raise ValueError(f"'{field_name}' must be numeric (got {value!r}).")
8377
8378
Here is the caller graph for this function:

◆ _to_finite_float()

float picurv_cli.core._to_finite_float (   value,
str  field_name 
)
protected

Convert a non-boolean YAML scalar to a finite float.

Parameters
[in]valueRaw YAML scalar.
[in]field_nameUser-facing configuration path.
Returns
Finite floating-point value.

Definition at line 8379 of file core.py.

8379def _to_finite_float(value, field_name: str) -> float:
8380 """!
8381 @brief Convert a non-boolean YAML scalar to a finite float.
8382 @param[in] value Raw YAML scalar.
8383 @param[in] field_name User-facing configuration path.
8384 @return Finite floating-point value.
8385 """
8386 if isinstance(value, bool):
8387 raise ValueError(f"'{field_name}' must be numeric, not boolean.")
8388 parsed = _to_float(value, field_name)
8389 if not math.isfinite(parsed):
8390 raise ValueError(f"'{field_name}' must be finite (got {value!r}).")
8391 return parsed
8392
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _to_bool()

bool picurv_cli.core._to_bool (   value,
str  field_name 
)
protected

Convert a YAML scalar/string to bool with a clear error message.

Parameters
[in]valueArgument passed to _to_bool().
[in]field_nameArgument passed to _to_bool().
Returns
Value returned by _to_bool().

Definition at line 8393 of file core.py.

8393def _to_bool(value, field_name: str) -> bool:
8394 """!
8395 @brief Convert a YAML scalar/string to bool with a clear error message.
8396 @param[in] value Argument passed to `_to_bool()`.
8397 @param[in] field_name Argument passed to `_to_bool()`.
8398 @return Value returned by `_to_bool()`.
8399 """
8400 if isinstance(value, bool):
8401 return value
8402 if isinstance(value, str):
8403 raw = value.strip().lower()
8404 if raw in {"true", "1", "yes"}:
8405 return True
8406 if raw in {"false", "0", "no"}:
8407 return False
8408 raise ValueError(f"'{field_name}' must be boolean (got {value!r}).")
8409
Here is the caller graph for this function:

◆ normalize_boundary_conditions_layout()

picurv_cli.core.normalize_boundary_conditions_layout (   all_blocks_bcs,
int  num_blocks 
)

Normalize boundary_conditions to list-of-lists form and validate block count.

Parameters
[in]all_blocks_bcsArgument passed to normalize_boundary_conditions_layout().
[in]num_blocksArgument passed to normalize_boundary_conditions_layout().
Returns
Value returned by normalize_boundary_conditions_layout().

Definition at line 8410 of file core.py.

8410def normalize_boundary_conditions_layout(all_blocks_bcs, num_blocks: int):
8411 """!
8412 @brief Normalize boundary_conditions to list-of-lists form and validate block count.
8413 @param[in] all_blocks_bcs Argument passed to `normalize_boundary_conditions_layout()`.
8414 @param[in] num_blocks Argument passed to `normalize_boundary_conditions_layout()`.
8415 @return Value returned by `normalize_boundary_conditions_layout()`.
8416 """
8417 if not all_blocks_bcs:
8418 raise ValueError("The 'boundary_conditions' section in case.yml is empty.")
8419
8420 is_simple_list = isinstance(all_blocks_bcs[0], dict)
8421 if num_blocks == 1 and is_simple_list:
8422 all_blocks_bcs = [all_blocks_bcs]
8423 elif is_simple_list and num_blocks > 1:
8424 raise ValueError(
8425 f"case.yml declares {num_blocks} blocks but boundary_conditions is a single face-list. "
8426 "Use a list-of-lists, one inner list per block."
8427 )
8428
8429 if len(all_blocks_bcs) != num_blocks:
8430 raise ValueError(
8431 f"Mismatch: case.yml declares {num_blocks} block(s) but found {len(all_blocks_bcs)} BC definitions."
8432 )
8433 return all_blocks_bcs
8434
Here is the caller graph for this function:

◆ _les_periodic_axes()

set picurv_cli.core._les_periodic_axes ( dict  case_cfg)
protected

Reports which logical axes a case declares periodic on both faces.

Parameters
[in]case_cfgParsed case.yml mapping.
Returns
Set of axis letters drawn from {'i', 'j', 'k'}.

Definition at line 8435 of file core.py.

8435def _les_periodic_axes(case_cfg: dict) -> set:
8436 """!
8437 @brief Reports which logical axes a case declares periodic on both faces.
8438 @param[in] case_cfg Parsed case.yml mapping.
8439 @return Set of axis letters drawn from {'i', 'j', 'k'}.
8440 """
8441 axis_faces = {"i": ("-Xi", "+Xi"), "j": ("-Eta", "+Eta"), "k": ("-Zeta", "+Zeta")}
8442 declared = {}
8443 entries = case_cfg.get("boundary_conditions") or []
8444 if entries and isinstance(entries[0], list):
8445 entries = entries[0]
8446 for entry in entries:
8447 if isinstance(entry, dict):
8448 declared[str(entry.get("face"))] = str(entry.get("type", "")).upper()
8449 return {
8450 axis for axis, faces in axis_faces.items()
8451 if all(declared.get(face) == "PERIODIC" for face in faces)
8452 }
8453
8454
8455#: Refused whenever a case carries a RANS block. The k-omega transport update was never
8456#: written and its fields were never allocated, so the closure was removed on 2026-09-22
8457#: and the subsystem returned to `planned`; src/guide.md records the hooks that went with
8458#: it. The message names the status rather than leaving a silently ignored block.
Here is the caller graph for this function:

◆ validate_wall_model_pairing()

picurv_cli.core.validate_wall_model_pairing ( dict  case_cfg,
  les_cfg,
  wall_cfg,
str  case_path,
list  errors,
list  warnings 
)

Rejects wall-model selections that no turbulence treatment can support.

A wall model replaces the near-wall flow with an analytic profile so that the boundary layer need not be resolved. That only means something if the unresolved motions are modelled somewhere. Three combinations cannot be, and each fails here rather than after the mesh is built.

Parameters
case_cfgParsed case configuration, read for the Reynolds number.
les_cfgmodels.physics.turbulence.les, or None.
wall_cfgmodels.physics.turbulence.wall_function, or None.
case_pathCase path, for message prefixes.
errorsCollected blocking messages, appended to.
warningsCollected advisory messages, appended to.

Definition at line 8467 of file core.py.

8468 case_path: str, errors: list, warnings: list):
8469 """!
8470 @brief Rejects wall-model selections that no turbulence treatment can support.
8471
8472 A wall model replaces the near-wall flow with an analytic profile so that the
8473 boundary layer need not be resolved. That only means something if the unresolved
8474 motions are modelled somewhere. Three combinations cannot be, and each fails here
8475 rather than after the mesh is built.
8476
8477 @param case_cfg Parsed case configuration, read for the Reynolds number.
8478 @param les_cfg `models.physics.turbulence.les`, or None.
8479 @param wall_cfg `models.physics.turbulence.wall_function`, or None.
8480 @param case_path Case path, for message prefixes.
8481 @param errors Collected blocking messages, appended to.
8482 @param warnings Collected advisory messages, appended to.
8483 """
8484 if not isinstance(wall_cfg, dict):
8485 return
8486 if not bool(wall_cfg.get('enabled', False)):
8487 return
8488
8489 try:
8490 model = normalize_wall_function_model(wall_cfg.get('model', 'log_law'))
8491 except ValueError:
8492 return # The selector's own validation already reported this.
8493
8494 les_on = isinstance(les_cfg, dict) and bool(les_cfg.get('enabled', False)) \
8495 and str(les_cfg.get('model', 'dynamic_smagorinsky')).strip().lower() != 'none'
8496 # A wall model with nothing to sit on. The convective scheme here is QUICK, whose
8497 # dissipation is linear and upwind-biased; it is not a limiter-based scheme whose
8498 # truncation error stands in for a subgrid stress, so there is no implicit LES to
8499 # appeal to.
8500 if not les_on:
8501 errors.append(
8502 f" {case_path}: models.physics.turbulence.wall_function is enabled with no "
8503 "turbulence model. A wall model supplies the stress of a boundary layer it "
8504 "does not resolve, which needs the unresolved motions modelled somewhere. "
8505 "This solver has no implicit-LES scheme to supply that - its convection is "
8506 "QUICK, whose numerical dissipation is not a subgrid model - so enable "
8507 "models.physics.turbulence.les, or resolve the wall and disable the wall "
8508 "function.")
8509
8510 # A wall law describes a turbulent boundary layer. Below transition there is no
8511 # inertial region for it to stand on, and it would impose a profile the flow does not
8512 # have. The threshold is deliberately far below any transitional value, so that it
8513 # only catches cases that are unambiguously laminar.
8514 reynolds = _case_reynolds_number(case_cfg)
8515 if reynolds is not None and reynolds < 1000.0:
8516 errors.append(
8517 f" {case_path}: models.physics.turbulence.wall_function is enabled at "
8518 f"Reynolds number {reynolds:g}, which is laminar. The log law and the "
8519 "Werner-Wengle power law both describe a turbulent boundary layer; at this "
8520 "Reynolds number there is no inertial region for either to represent, and "
8521 "the model would impose a profile the flow does not have. Resolve the wall "
8522 "instead.")
8523
8524
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _case_reynolds_number()

picurv_cli.core._case_reynolds_number ( dict  case_cfg)
protected

Reynolds number implied by a case's scaling and fluid properties.

Parameters
case_cfgParsed case configuration.
Returns
The Reynolds number, or None when the inputs are absent or unusable.

Definition at line 8525 of file core.py.

8525def _case_reynolds_number(case_cfg: dict):
8526 """!
8527 @brief Reynolds number implied by a case's scaling and fluid properties.
8528 @param case_cfg Parsed case configuration.
8529 @return The Reynolds number, or None when the inputs are absent or unusable.
8530 """
8531 try:
8532 props = case_cfg.get('properties', {}) or {}
8533 scaling = props.get('scaling', {}) or {}
8534 fluid = props.get('fluid', {}) or {}
8535 density = float(fluid.get('density'))
8536 viscosity = float(fluid.get('viscosity'))
8537 length_ref = float(scaling.get('length_ref'))
8538 velocity_ref = float(scaling.get('velocity_ref'))
8539 except (TypeError, ValueError):
8540 return None
8541 if viscosity <= 0.0:
8542 return None
8543 return density * velocity_ref * length_ref / viscosity
8544
8545
Here is the caller graph for this function:

◆ validate_les_configuration()

picurv_cli.core.validate_les_configuration ( dict  case_cfg,
dict  les_cfg,
str  case_path,
list  errors,
list  warnings 
)

Checks the structured LES block for values the closure cannot honour.

Parameters
[in]case_cfgParsed case.yml mapping, used to read declared periodicity.
[in]les_cfgParsed models.physics.turbulence.les mapping.
[in]case_pathCase file path used to prefix diagnostics.
[out]errorsList collecting blocking validation failures.
[out]warningsList collecting advisory messages.
Returns
None; findings are appended to errors and warnings.

Definition at line 8546 of file core.py.

8547 errors: list, warnings: list):
8548 """!
8549 @brief Checks the structured LES block for values the closure cannot honour.
8550 @param[in] case_cfg Parsed case.yml mapping, used to read declared periodicity.
8551 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
8552 @param[in] case_path Case file path used to prefix diagnostics.
8553 @param[out] errors List collecting blocking validation failures.
8554 @param[out] warnings List collecting advisory messages.
8555 @return None; findings are appended to `errors` and `warnings`.
8556 """
8557 def _numeric(container, key, path, minimum=None, exclusive_minimum=None):
8558 """!
8559 @brief Reads one numeric key and records a range or type failure against it.
8560 @param[in] container Mapping holding the key.
8561 @param[in] key Key to read; absent keys are accepted silently.
8562 @param[in] path Dotted key path used in diagnostics.
8563 @param[in] minimum Inclusive lower bound, or None to impose none.
8564 @param[in] exclusive_minimum Exclusive lower bound, or None to impose none.
8565 @return The parsed value, or None when the key is absent or unparseable.
8566 """
8567 if key not in container:
8568 return None
8569 try:
8570 value = float(container[key])
8571 except (TypeError, ValueError):
8572 errors.append(f" {case_path}: {path} must be numeric.")
8573 return None
8574 if minimum is not None and value < minimum:
8575 errors.append(f" {case_path}: {path} must be at least {minimum}.")
8576 if exclusive_minimum is not None and value <= exclusive_minimum:
8577 errors.append(f" {case_path}: {path} must be greater than {exclusive_minimum}.")
8578 return value
8579
8580 if 'enabled' in les_cfg and not isinstance(les_cfg['enabled'], bool):
8581 errors.append(f" {case_path}: models.physics.turbulence.les.enabled must be true or false.")
8582
8583 _numeric(les_cfg, 'constant_cs', "models.physics.turbulence.les.constant_cs", minimum=0.0)
8584 _numeric(les_cfg, 'vreman_coefficient', "models.physics.turbulence.les.vreman_coefficient", minimum=0.0)
8585 _numeric(les_cfg, 'wale_coefficient', "models.physics.turbulence.les.wale_coefficient", minimum=0.0)
8586
8587 if 'dynamic_frequency' in les_cfg:
8588 try:
8589 if int(les_cfg['dynamic_frequency']) <= 0:
8590 errors.append(f" {case_path}: models.physics.turbulence.les.dynamic_frequency must be positive.")
8591 except (TypeError, ValueError):
8592 errors.append(f" {case_path}: models.physics.turbulence.les.dynamic_frequency must be an integer.")
8593
8594 for key, normalizer in (('filter_width', normalize_les_filter_width),):
8595 if key in les_cfg:
8596 try:
8597 normalizer(les_cfg[key])
8598 except ValueError as exc:
8599 errors.append(f" {case_path}: {exc}")
8600
8601 periodic = _les_periodic_axes(case_cfg)
8602
8603 test_filter = les_cfg.get('test_filter')
8604 if test_filter is not None:
8605 if not isinstance(test_filter, dict):
8606 test_filter = {'kernel': test_filter}
8607 if 'kernel' in test_filter:
8608 try:
8609 kernel = normalize_les_test_filter(test_filter['kernel'])
8610 except ValueError as exc:
8611 errors.append(f" {case_path}: {exc}")
8612 else:
8613 # The Simpson stencil collapses onto the central eta-plane, which is
8614 # only a valid average when xi and zeta are homogeneous.
8615 if kernel == 1 and not {"i", "k"} <= periodic:
8616 errors.append(
8617 f" {case_path}: models.physics.turbulence.les.test_filter.kernel "
8618 "'simpson_ik' assumes the xi and zeta directions are homogeneous, but "
8619 "this case does not declare both of them PERIODIC. Use "
8620 "'volume_weighted_box' instead."
8621 )
8622 # A test filter no wider than the grid filter leaves the dynamic procedure with
8623 # nothing to measure, because both terms of the model tensor coincide.
8624 _numeric(test_filter, 'width_ratio',
8625 "models.physics.turbulence.les.test_filter.width_ratio", exclusive_minimum=1.0)
8626
8627 averaging = les_cfg.get('averaging')
8628 if averaging is not None:
8629 if not isinstance(averaging, dict):
8630 averaging = {'mode': averaging}
8631 mode = None
8632 if 'mode' in averaging:
8633 try:
8634 mode = normalize_les_averaging_mode(averaging['mode'])
8635 except ValueError as exc:
8636 errors.append(f" {case_path}: {exc}")
8637 directions = None
8638 if 'directions' in averaging:
8639 try:
8640 directions = normalize_les_averaging_directions(averaging['directions'])
8641 except ValueError as exc:
8642 errors.append(f" {case_path}: {exc}")
8643 else:
8644 if not directions:
8645 errors.append(
8646 f" {case_path}: models.physics.turbulence.les.averaging.directions "
8647 "cannot be empty; omit the key to use the periodic axes."
8648 )
8649 if mode is not None and mode != 1:
8650 errors.append(
8651 f" {case_path}: models.physics.turbulence.les.averaging.directions "
8652 "applies only to mode 'homogeneous'; local and global averaging choose "
8653 "their own directions."
8654 )
8655 for axis in directions:
8656 if axis not in periodic:
8657 warnings.append(
8658 f"{case_path}: models.physics.turbulence.les.averaging.directions "
8659 f"names '{axis}', which this case does not declare PERIODIC. "
8660 "Averaging assumes the flow is statistically homogeneous there."
8661 )
8662 if mode == 1 and directions is None and not periodic:
8663 errors.append(
8664 f" {case_path}: models.physics.turbulence.les.averaging.mode 'homogeneous' "
8665 "derives its directions from the periodic boundary pairs, and this case declares "
8666 "none. Name the directions explicitly or use 'local'."
8667 )
8668
8669 clipping = les_cfg.get('clipping')
8670 if clipping is not None:
8671 if not isinstance(clipping, dict):
8672 clipping = {'mode': clipping}
8673 mode = None
8674 if 'mode' in clipping:
8675 try:
8676 mode = normalize_les_clip_mode(clipping['mode'])
8677 except ValueError as exc:
8678 errors.append(f" {case_path}: {exc}")
8679 _numeric(clipping, 'max_cs', "models.physics.turbulence.les.clipping.max_cs", minimum=0.0)
8680 _numeric(clipping, 'min_viscosity_ratio',
8681 "models.physics.turbulence.les.clipping.min_viscosity_ratio", minimum=0.0)
8682 if 'max_cs' in clipping and mode is not None and mode != 0:
8683 errors.append(
8684 f" {case_path}: models.physics.turbulence.les.clipping.max_cs applies only to "
8685 "mode 'clamp'; 'positive' and 'signed' impose no upper bound. Remove the key or "
8686 "select mode 'clamp'."
8687 )
8688
8689 # The dynamic procedure's controls have no meaning for a prescribed coefficient.
8690 # Only an explicitly named constant model triggers this: a template that documents
8691 # every key while LES is switched off should not be rejected on an inferred default.
8692 try:
8693 model_code = normalize_les_model(les_cfg['model']) if 'model' in les_cfg else None
8694 except ValueError:
8695 model_code = None
8696 if model_code in LES_MODEL_OWNED_KEYS and les_cfg.get('enabled', True):
8697 # Each model's own parameters are refused under any other, so a key that would be
8698 # silently ignored at runtime fails at validation instead. filter_width is shared
8699 # by every model that takes a scalar width; Vreman resolves each direction from
8700 # the cell's own edges and takes none.
8701 model_name = LES_MODEL_NAMES[model_code]
8702 for owner, keys in LES_MODEL_OWNED_KEYS.items():
8703 if owner == model_code:
8704 continue
8705 for key in keys:
8706 if key in les_cfg:
8707 errors.append(
8708 f" {case_path}: models.physics.turbulence.les.{key} configures "
8709 f"{LES_MODEL_KEY_OWNER_DESCRIPTIONS[owner]} and cannot be used with "
8710 f"model '{model_name}'."
8711 )
8712 if model_code == 3 and 'filter_width' in les_cfg:
8713 errors.append(
8714 f" {case_path}: models.physics.turbulence.les.filter_width cannot be used with "
8715 "model 'vreman', which weights each grid direction by the cell's own edge "
8716 "rather than by one scalar width."
8717 )
8718
8719
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_and_prepare_boundary_conditions()

picurv_cli.core.validate_and_prepare_boundary_conditions ( dict  case_cfg)

Validate BC entries against currently supported C-side handlers/types and.

return normalized entries ready for bcs.run generation.

Parameters
[in]case_cfgArgument passed to validate_and_prepare_boundary_conditions().
Returns
Value returned by validate_and_prepare_boundary_conditions().

Definition at line 8720 of file core.py.

8720def validate_and_prepare_boundary_conditions(case_cfg: dict):
8721 """!
8722 @brief Validate BC entries against currently supported C-side handlers/types and
8723 @details return normalized entries ready for bcs.run generation.
8724 @param[in] case_cfg Argument passed to `validate_and_prepare_boundary_conditions()`.
8725 @return Value returned by `validate_and_prepare_boundary_conditions()`.
8726 """
8727 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
8728 scales = resolve_reference_scales(case_cfg)
8729
8730 all_blocks_bcs = normalize_boundary_conditions_layout(case_cfg.get('boundary_conditions', []), num_blocks)
8731 prepared_blocks = []
8732
8733 expected_faces = {"-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta"}
8734 axis_pairs = [("-Xi", "+Xi"), ("-Eta", "+Eta"), ("-Zeta", "+Zeta")]
8735
8736 for bi, block_bcs in enumerate(all_blocks_bcs):
8737 if not isinstance(block_bcs, list):
8738 raise ValueError(f"boundary_conditions[{bi}] must be a list of face configs.")
8739
8740 prepared_block = []
8741 seen_faces = {}
8742
8743 for idx, bc in enumerate(block_bcs):
8744 if not isinstance(bc, dict):
8745 raise ValueError(f"boundary_conditions[{bi}][{idx}] must be a mapping.")
8746
8747 for req in ("face", "type", "handler"):
8748 if req not in bc:
8749 raise ValueError(f"boundary_conditions[{bi}][{idx}] missing required key '{req}'.")
8750
8751 face_raw = str(bc["face"]).strip()
8752 face_key = face_raw.lower()
8753 face = BC_FACE_MAP.get(face_key)
8754 if face is None:
8755 raise ValueError(
8756 f"Unsupported BC face '{face_raw}' at boundary_conditions[{bi}][{idx}]. "
8757 f"Supported: {sorted(expected_faces)}."
8758 )
8759 if face in seen_faces:
8760 raise ValueError(f"Duplicate face '{face}' in boundary_conditions[{bi}] (entries {seen_faces[face]} and {idx}).")
8761 seen_faces[face] = idx
8762
8763 bc_type_raw = str(bc["type"]).strip()
8764 bc_type = BC_TYPE_MAP.get(bc_type_raw.lower())
8765 # A symmetry plane is a free-slip wall; the separate type was retired.
8766 if bc_type_raw.lower() == "symmetry":
8767 raise ValueError(
8768 f"BC type 'symmetry' for face {face} in block {bi} is retired: a symmetry plane "
8769 "is a free-slip wall, so use type 'wall' with handler 'free_slip'."
8770 )
8771 if bc_type is None:
8772 raise ValueError(
8773 f"Unsupported BC type '{bc_type_raw}' for face {face} in block {bi}. "
8774 f"Supported: {sorted(set(BC_TYPE_MAP.values()))}."
8775 )
8776
8777 handler = str(bc["handler"]).strip().lower()
8778 handler_spec = BC_HANDLER_SPECS.get(handler)
8779 if handler_spec is None:
8780 raise ValueError(
8781 f"Unsupported BC handler '{bc['handler']}' for face {face} in block {bi}. "
8782 f"Supported now: {sorted(BC_HANDLER_SPECS.keys())}."
8783 )
8784 if bc_type not in handler_spec["types"]:
8785 raise ValueError(
8786 f"Invalid BC combination on block {bi}, face {face}: type '{bc_type}' cannot use handler '{handler}'."
8787 )
8788
8789 params = bc.get("params", {})
8790 if params is None:
8791 params = {}
8792 if not isinstance(params, dict):
8793 raise ValueError(f"'params' for block {bi}, face {face} must be a mapping.")
8794
8795 # Reject unsupported older structured keys explicitly.
8796 if "vector" in params or "velocity" in params:
8797 raise ValueError(
8798 f"Unsupported older params key ('vector'/'velocity') found on block {bi}, face {face}. "
8799 "Use scalar keys 'vx', 'vy', 'vz'."
8800 )
8801
8802 required = handler_spec["required_params"]
8803 optional = handler_spec["optional_params"]
8804 allowed = required | optional
8805
8806 missing = sorted(required - set(params.keys()))
8807 if missing:
8808 raise ValueError(
8809 f"Missing required params for handler '{handler}' on block {bi}, face {face}: {missing}."
8810 )
8811 unknown = sorted(set(params.keys()) - allowed)
8812 if unknown:
8813 raise ValueError(
8814 f"Unknown params for handler '{handler}' on block {bi}, face {face}: {unknown}. "
8815 f"Allowed: {sorted(allowed)}."
8816 )
8817
8818 converted_params = {}
8819 for key, value in params.items():
8820 if key in _NUMERIC_BC_PARAMS:
8821 converted_params[key] = quantity_to_solver_units(
8822 value, BC_PARAM_QUANTITIES[key], scales,
8823 f"boundary_conditions[{bi}][{idx}].params.{key}")
8824 elif key in _BOOL_BC_PARAMS:
8825 canonical = _DEPRECATED_BC_PARAM_ALIASES.get(key, key)
8826 if canonical != key:
8827 if canonical in params:
8828 raise ValueError(
8829 f"boundary_conditions[{bi}][{idx}].params sets both '{key}' and its "
8830 f"replacement '{canonical}'. Use '{canonical}' only."
8831 )
8832 print(
8833 f"[WARNING] boundary_conditions[{bi}][{idx}].params.{key} is deprecated; "
8834 f"use '{canonical}'. It enables the local seam-flux correction on the "
8835 "periodic boundary plane, on top of the body force that sustains the bulk "
8836 "flow. See docs/pages/54_Geometric_Periodic_Boundaries.md.",
8837 file=sys.stderr,
8838 )
8839 converted_params[canonical] = _to_bool(
8840 value, f"boundary_conditions[{bi}][{idx}].params.{key}")
8841 elif handler == "prescribed_flow" and key == "source":
8842 converted_params[key] = _normalize_prescribed_flow_source(
8843 value, f"boundary_conditions[{bi}][{idx}].params.source"
8844 )
8845 else:
8846 # Defensive fallback; should not happen due unknown-key gate above.
8847 converted_params[key] = value
8848
8849 policy = str(bc.get("particle_policy", handler_spec["particle_policy"])).strip().lower()
8850 if policy not in PARTICLE_BOUNDARY_POLICIES:
8851 raise ValueError(
8852 f"Unsupported particle_policy '{bc.get('particle_policy')}' for face {face} in block {bi}. "
8853 f"Supported: {list(PARTICLE_BOUNDARY_POLICIES)}."
8854 )
8855 if (policy == "wrap") != (bc_type == "PERIODIC"):
8856 raise ValueError(
8857 f"boundary_conditions[{bi}][{idx}].particle_policy '{policy}' does not fit a {bc_type} face: "
8858 "PERIODIC faces wrap particles to the paired face, and only they can."
8859 )
8860 converted_params["particle_policy"] = policy
8861
8862 prepared_block.append({
8863 "face": face,
8864 "type": bc_type,
8865 "handler": handler,
8866 "params": converted_params,
8867 })
8868
8869 missing_faces = sorted(expected_faces - set(seen_faces.keys()))
8870 if missing_faces:
8871 raise ValueError(
8872 f"boundary_conditions[{bi}] is incomplete. Missing faces: {missing_faces}. "
8873 "Provide all six faces explicitly."
8874 )
8875
8876 # Pairwise periodic consistency checks.
8877 face_map = {entry["face"]: entry for entry in prepared_block}
8878 for neg_face, pos_face in axis_pairs:
8879 neg = face_map[neg_face]
8880 pos = face_map[pos_face]
8881 neg_periodic = (neg["type"] == "PERIODIC")
8882 pos_periodic = (pos["type"] == "PERIODIC")
8883 if neg_periodic != pos_periodic:
8884 raise ValueError(
8885 f"Inconsistent periodicity in block {bi}: {neg_face} and {pos_face} must both be PERIODIC or neither."
8886 )
8887
8888 driven_handlers = {"constant_flux", "initial_flux"}
8889 if (neg["handler"] in driven_handlers) or (pos["handler"] in driven_handlers):
8890 if neg["handler"] != pos["handler"]:
8891 raise ValueError(
8892 f"In block {bi}, driven periodic handlers on {neg_face}/{pos_face} must match exactly."
8893 )
8894 if not (neg_periodic and pos_periodic):
8895 raise ValueError(
8896 f"In block {bi}, driven periodic handler '{neg['handler']}' requires PERIODIC type on both faces."
8897 )
8898
8899 prepared_blocks.append(prepared_block)
8900
8901 return prepared_blocks
8902
8903
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _schema_path_text()

str picurv_cli.core._schema_path_text ( tuple  path)
protected

Render an internal schema path tuple as a user-facing YAML path.

Parameters
[in]pathInternal path tuple.
Returns
Dotted YAML path.

Definition at line 8904 of file core.py.

8904def _schema_path_text(path: tuple) -> str:
8905 """!
8906 @brief Render an internal schema path tuple as a user-facing YAML path.
8907 @param[in] path Internal path tuple.
8908 @return Dotted YAML path.
8909 """
8910 return ".".join(part for part in path if part != "[]") or "<root>"
8911
8912
Here is the caller graph for this function:

◆ _lookup_allowed_schema_keys()

picurv_cli.core._lookup_allowed_schema_keys ( dict  schema,
tuple  path 
)
protected

Return allowed keys for a path, honoring '*' dynamic mapping entries.

Parameters
[in]schemaRole schema mapping.
[in]pathInternal path tuple.
Returns
Allowed key set, None for free-form mappings, or False when path is not schema-checked.

Definition at line 8913 of file core.py.

8913def _lookup_allowed_schema_keys(schema: dict, path: tuple):
8914 """!
8915 @brief Return allowed keys for a path, honoring '*' dynamic mapping entries.
8916 @param[in] schema Role schema mapping.
8917 @param[in] path Internal path tuple.
8918 @return Allowed key set, None for free-form mappings, or False when path is not schema-checked.
8919 """
8920 if path in schema:
8921 return schema[path]
8922 for idx, part in enumerate(path):
8923 if part == "[]":
8924 continue
8925 candidate = path[:idx] + ("*",) + path[idx + 1:]
8926 if candidate in schema:
8927 return schema[candidate]
8928 return False
8929
8930
Here is the caller graph for this function:

◆ _schema_key_hint()

str picurv_cli.core._schema_key_hint ( dict  schema,
tuple  path,
str  key,
set  allowed 
)
protected

Build a concise typo or hierarchy hint for an unsupported YAML key.

Parameters
[in]schemaRole schema mapping.
[in]pathCurrent internal YAML path tuple.
[in]keyUnsupported YAML key.
[in]allowedAllowed keys at the current path.
Returns
Optional hint string.

Definition at line 8931 of file core.py.

8931def _schema_key_hint(schema: dict, path: tuple, key: str, allowed: set) -> str:
8932 """!
8933 @brief Build a concise typo or hierarchy hint for an unsupported YAML key.
8934 @param[in] schema Role schema mapping.
8935 @param[in] path Current internal YAML path tuple.
8936 @param[in] key Unsupported YAML key.
8937 @param[in] allowed Allowed keys at the current path.
8938 @return Optional hint string.
8939 """
8940 hints = []
8941 allowed_strings = sorted(str(item) for item in allowed)
8942 lower_matches = [item for item in allowed_strings if item.lower() == key.lower()]
8943 close_matches = lower_matches or difflib.get_close_matches(key, allowed_strings, n=1, cutoff=0.80)
8944 if close_matches:
8945 hints.append(f"Did you mean '{close_matches[0]}'?")
8946
8947 valid_paths = []
8948 for schema_path, schema_allowed in schema.items():
8949 if schema_path == path or not schema_allowed:
8950 continue
8951 if key in schema_allowed:
8952 valid_paths.append(_schema_path_text(schema_path))
8953 if valid_paths:
8954 hints.append(f"This key is valid at: {', '.join(sorted(valid_paths))}.")
8955
8956 return " ".join(hints)
8957
8958
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _validate_yaml_schema_keys()

None picurv_cli.core._validate_yaml_schema_keys (   cfg,
dict  schema,
str  file_path,
list  errors,
tuple   path = () 
)
protected

Reject unsupported YAML keys before they can be silently ignored by staging.

Parameters
[in]cfgParsed YAML node.
[in]schemaRole schema mapping.
[in]file_pathSource file path for diagnostics.
[in,out]errorsValidation error accumulator.
[in]pathCurrent internal YAML path tuple.

Definition at line 8959 of file core.py.

8959def _validate_yaml_schema_keys(cfg, schema: dict, file_path: str, errors: list, path: tuple = ()) -> None:
8960 """!
8961 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
8962 @param[in] cfg Parsed YAML node.
8963 @param[in] schema Role schema mapping.
8964 @param[in] file_path Source file path for diagnostics.
8965 @param[in,out] errors Validation error accumulator.
8966 @param[in] path Current internal YAML path tuple.
8967 """
8968 if isinstance(cfg, dict):
8969 allowed = _lookup_allowed_schema_keys(schema, path)
8970 if allowed is not False and allowed is not None:
8971 unknown = sorted(str(key) for key in cfg.keys() if key not in allowed)
8972 for key in unknown:
8973 hint = _schema_key_hint(schema, path, key, allowed)
8974 hint_text = f" {hint}" if hint else ""
8975 errors.append(
8976 f" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
8977 f"Allowed keys: {sorted(allowed)}.{hint_text}"
8978 )
8979 if allowed is None:
8980 return
8981 for key, value in cfg.items():
8982 _validate_yaml_schema_keys(value, schema, file_path, errors, path + (key,))
8983 elif isinstance(cfg, list) and _lookup_allowed_schema_keys(schema, path + ("[]",)) is not False:
8984 for item in cfg:
8985 _validate_yaml_schema_keys(item, schema, file_path, errors, path + ("[]",))
8986 elif (isinstance(cfg, (str, int, float, list)) and not isinstance(cfg, bool)
8987 and path and path[-1] != "[]" and _lookup_allowed_schema_keys(schema, path) is not False):
8988 # A section given as a string, number or list would reach code that reads it as a
8989 # mapping. A boolean is left alone: several sections take true/false as shorthand
8990 # (models.physics.turbulence.les, io.paraview_series). So are list items, some of
8991 # which accept a scalar shorthand (a study metric name).
8992 errors.append(
8993 f" {file_path}: '{_schema_path_text(path)}' must be a mapping, not "
8994 f"{type(cfg).__name__} {cfg!r}."
8995 )
8996 elif isinstance(cfg, list):
8997 for item in cfg:
8998 _validate_yaml_schema_keys(item, schema, file_path, errors, path + ("[]",))
8999
9000
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _ensemble_source_fields()

dict picurv_cli.core._ensemble_source_fields ( str  source)
protected

Return the field table an ensemble of one source names its fields from.

Parameters
[in]sourceOne of POST_STATISTICS_ENSEMBLE_SOURCES.
Returns
The particle or the Eulerian statistics field table.

Definition at line 9231 of file core.py.

9231def _ensemble_source_fields(source: str) -> dict:
9232 """!
9233 @brief Return the field table an ensemble of one source names its fields from.
9234 @param[in] source One of `POST_STATISTICS_ENSEMBLE_SOURCES`.
9235 @return The particle or the Eulerian statistics field table.
9236 """
9237 return STATISTICS_PARTICLE_FIELDS if source == "particles" else STATISTICS_ELIGIBLE_FIELDS
9238
9239
9240#: Moment names one field may request. Higher moments cannot be recovered from
9241#: centered state after the fact, so they need their own accumulation rather than
9242#: an extension of this list.
Here is the caller graph for this function:

◆ input_quantity()

tuple picurv_cli.core.input_quantity ( tuple  path)

Return the (dimension, conversion site) recorded for one configuration input.

Parameters
[in]pathRole followed by the schema path, e.g. ("case", "run_control", "dt_physical").
Returns
The entry for the path, or for its nearest covering ancestor.
Exceptions
KeyErrorwhen no entry covers the path.

Definition at line 9571 of file core.py.

9571def input_quantity(path: tuple) -> tuple:
9572 """!
9573 @brief Return the `(dimension, conversion site)` recorded for one configuration input.
9574 @param[in] path Role followed by the schema path, e.g. `("case", "run_control", "dt_physical")`.
9575 @return The entry for the path, or for its nearest covering ancestor.
9576 @throws KeyError when no entry covers the path.
9577 """
9578 for length in range(len(path), 0, -1):
9579 entry = INPUT_QUANTITIES.get(tuple(path[:length]))
9580 if entry is not None:
9581 return entry
9582 raise KeyError(f"No physical dimension is recorded for input {'.'.join(map(str, path))}.")
9583
9584
Here is the caller graph for this function:

◆ reference_scale()

float picurv_cli.core.reference_scale ( tuple  dimension,
dict  scales 
)

Return the factor that turns a physical quantity of this dimension into solver units.

Parameters
[in]dimension(length, velocity, density) exponents.
[in]scalesMapping with length_ref and velocity_ref, and density when used.
Returns
length_ref**a * velocity_ref**b * density**c.

Definition at line 9585 of file core.py.

9585def reference_scale(dimension: tuple, scales: dict) -> float:
9586 """!
9587 @brief Return the factor that turns a physical quantity of this dimension into solver units.
9588 @param[in] dimension `(length, velocity, density)` exponents.
9589 @param[in] scales Mapping with `length_ref` and `velocity_ref`, and `density` when used.
9590 @return `length_ref**a * velocity_ref**b * density**c`.
9591 """
9592 length, velocity, density = dimension
9593 factor = float(scales["length_ref"]) ** length * float(scales["velocity_ref"]) ** velocity
9594 if density:
9595 factor *= float(scales["density"]) ** density
9596 return factor
9597
9598
Here is the caller graph for this function:

◆ input_reference_scale()

float picurv_cli.core.input_reference_scale ( tuple  path,
dict  scales 
)

Return the reference scale of one recorded input, for converting a whole payload.

File payloads (grids, inlet profiles) are converted element by element by their own readers; this gives them the one factor their recorded dimension implies, so they do not restate the dimension.

Parameters
[in]pathRole and schema path of the input.
[in]scalesReference scales, as returned by resolve_reference_scales().
Returns
The reference scale of the input's recorded dimension.

Definition at line 9599 of file core.py.

9599def input_reference_scale(path: tuple, scales: dict) -> float:
9600 """!
9601 @brief Return the reference scale of one recorded input, for converting a whole payload.
9602 @details File payloads (grids, inlet profiles) are converted element by element by
9603 their own readers; this gives them the one factor their recorded dimension
9604 implies, so they do not restate the dimension.
9605 @param[in] path Role and schema path of the input.
9606 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9607 @return The reference scale of the input's recorded dimension.
9608 """
9609 dimension, _site = input_quantity(path)
9610 if dimension is None:
9611 raise ValueError(f"Input {'.'.join(map(str, path[1:]))} has no fixed dimension.")
9612 return reference_scale(dimension, scales)
9613
9614
Here is the call graph for this function:
Here is the caller graph for this function:

◆ quantity_to_solver_units()

picurv_cli.core.quantity_to_solver_units (   value,
tuple  quantity,
dict  scales,
str  label 
)

Convert a physical value, or a list of them, by one recorded quantity entry.

Parameters
[in]valuePhysical value as written by the user.
[in]quantity(dimension, conversion site) entry from a dimension table.
[in]scalesReference scales, as returned by resolve_reference_scales().
[in]labelConfiguration path, for diagnostics.
Returns
The value divided by its dimension's reference scale.
Exceptions
ValueErrorwhen the entry records no fixed dimension.

Definition at line 9615 of file core.py.

9615def quantity_to_solver_units(value, quantity: tuple, scales: dict, label: str):
9616 """!
9617 @brief Convert a physical value, or a list of them, by one recorded quantity entry.
9618 @param[in] value Physical value as written by the user.
9619 @param[in] quantity `(dimension, conversion site)` entry from a dimension table.
9620 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9621 @param[in] label Configuration path, for diagnostics.
9622 @return The value divided by its dimension's reference scale.
9623 @throws ValueError when the entry records no fixed dimension.
9624 """
9625 dimension, _site = quantity
9626 if dimension is None:
9627 raise ValueError(f"Input {label} has no fixed dimension to convert.")
9628 factor = reference_scale(dimension, scales)
9629 if isinstance(value, (list, tuple)):
9630 return [_to_finite_float(item, label) / factor for item in value]
9631 return _to_finite_float(value, label) / factor
9632
9633
Here is the call graph for this function:
Here is the caller graph for this function:

◆ to_solver_units()

picurv_cli.core.to_solver_units (   value,
tuple  path,
dict  scales 
)

Convert one physical configuration value, or a list of them, to solver units.

Parameters
[in]valuePhysical value as written by the user.
[in]pathRole and schema path whose recorded dimension governs the conversion.
[in]scalesReference scales, as returned by resolve_reference_scales().
Returns
The value divided by its dimension's reference scale.
Exceptions
ValueErrorwhen the path records no fixed dimension.

Definition at line 9634 of file core.py.

9634def to_solver_units(value, path: tuple, scales: dict):
9635 """!
9636 @brief Convert one physical configuration value, or a list of them, to solver units.
9637 @param[in] value Physical value as written by the user.
9638 @param[in] path Role and schema path whose recorded dimension governs the conversion.
9639 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
9640 @return The value divided by its dimension's reference scale.
9641 @throws ValueError when the path records no fixed dimension.
9642 """
9643 return quantity_to_solver_units(value, input_quantity(path), scales,
9644 ".".join(str(part) for part in path[1:]))
9645
9646
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_reference_scales()

dict picurv_cli.core.resolve_reference_scales ( dict  case_cfg)

Resolve the reference length and velocity (and density, when given) of a case.

This is the part of the scaling contract every conversion needs. It does not require the fluid block, so boundary-condition preparation can use it before the full fluid contract is validated.

Parameters
[in]case_cfgParsed case configuration.
Returns
Mapping with length_ref, velocity_ref, and optionally density.
Exceptions
ValueErrorwhen a reference scale is missing, non-finite, or not positive.

Definition at line 9647 of file core.py.

9647def resolve_reference_scales(case_cfg: dict) -> dict:
9648 """!
9649 @brief Resolve the reference length and velocity (and density, when given) of a case.
9650 @details This is the part of the scaling contract every conversion needs. It does not
9651 require the fluid block, so boundary-condition preparation can use it before
9652 the full fluid contract is validated.
9653 @param[in] case_cfg Parsed case configuration.
9654 @return Mapping with `length_ref`, `velocity_ref`, and optionally `density`.
9655 @throws ValueError when a reference scale is missing, non-finite, or not positive.
9656 """
9657 scaling = ((case_cfg.get("properties") or {}).get("scaling") or {})
9658 length_ref = _to_finite_float(scaling.get("length_ref"), "properties.scaling.length_ref")
9659 velocity_ref = _to_finite_float(scaling.get("velocity_ref"), "properties.scaling.velocity_ref")
9660 if length_ref <= 0.0 or velocity_ref <= 0.0:
9661 raise ValueError("properties.scaling.length_ref and velocity_ref must be positive.")
9662 scales = {"length_ref": length_ref, "velocity_ref": velocity_ref}
9663 density = ((case_cfg.get("properties") or {}).get("fluid") or {}).get("density")
9664 if density is not None:
9665 scales["density"] = _to_finite_float(density, "properties.fluid.density")
9666 return scales
9667
9668
Here is the call graph for this function:
Here is the caller graph for this function:

◆ directory_value_charset_problem()

str picurv_cli.core.directory_value_charset_problem ( str  value)

Describe why a directory value cannot be written to a PETSc options line.

Parameters
[in]valueConfigured directory value.
Returns
Human-readable reason, or an empty string when the value is safe.

Definition at line 9702 of file core.py.

9702def directory_value_charset_problem(value: str) -> str:
9703 """!
9704 @brief Describe why a directory value cannot be written to a PETSc options line.
9705 @param[in] value Configured directory value.
9706 @return Human-readable reason, or an empty string when the value is safe.
9707 """
9708 if any(character.isspace() for character in value):
9709 return "contains whitespace"
9710 for character in UNSAFE_DIRECTORY_CHARACTERS:
9711 if character in value:
9712 label = {'"': "a double quote", "'": "a single quote", "#": "a comment marker"}.get(
9713 character, "a control character"
9714 )
9715 return f"contains {label}"
9716 return ""
9717
9718
Here is the caller graph for this function:

◆ classify_run_directory_value()

str picurv_cli.core.classify_run_directory_value (   value)

Classify a configured run directory value.

Containment is judged lexically against the run directory, because the run directory does not exist yet at validation time. Beyond escaping, a value is rejected when it resolves to the run root itself: the log directory is recursively deleted on a fresh solve, so . or a/.. would delete the run.

Parameters
[in]valueConfigured directory value from monitor io.directories.
Returns
One of "contained", "escaping", "tilde", "run_root", or "invalid".

Definition at line 9719 of file core.py.

9719def classify_run_directory_value(value) -> str:
9720 """!
9721 @brief Classify a configured run directory value.
9722
9723 @details Containment is judged lexically against the run directory, because the run
9724 directory does not exist yet at validation time. Beyond escaping, a value
9725 is rejected when it resolves to the run root itself: the log directory is
9726 recursively deleted on a fresh solve, so `.` or `a/..` would delete the run.
9727 @param[in] value Configured directory value from monitor `io.directories`.
9728 @return One of "contained", "escaping", "tilde", "run_root", or "invalid".
9729 """
9730 if not isinstance(value, str) or not value.strip():
9731 return "invalid"
9732 candidate = value.strip()
9733 # `~` is its own verdict because nothing expands it anywhere in the pipeline. The
9734 # control file is read by PETSc, not by a shell, and both the planner and the C
9735 # runtime treat a value not starting with `/` as relative to the run - so `~/logs`
9736 # names a literal `~` directory inside the run tree.
9737 #
9738 # It was worse before this verdict existed: physical containment classified `~` as
9739 # an authorizable external absolute location by expanding it, while every layer
9740 # that actually used the value treated it literally. The two disagreed about which
9741 # directory was being deleted. The fix is to refuse the value, not to expand it -
9742 # expanding it in one layer would only move the disagreement.
9743 if candidate.startswith("~"):
9744 return "tilde"
9745 if os.path.isabs(candidate):
9746 return "escaping"
9747 normalized = os.path.normpath(candidate)
9748 if normalized == os.pardir or normalized.startswith(os.pardir + os.sep):
9749 return "escaping"
9750 if normalized in (".", ""):
9751 return "run_root"
9752 return "contained"
9753
9754
Here is the caller graph for this function:

◆ normalized_run_directory()

str picurv_cli.core.normalized_run_directory ( str  value)

Normalized, comparable form of a contained run directory value.

Parameters
[in]valueConfigured directory value.
Returns
Normalized relative path.

Definition at line 9755 of file core.py.

9755def normalized_run_directory(value: str) -> str:
9756 """!
9757 @brief Normalized, comparable form of a contained run directory value.
9758 @param[in] value Configured directory value.
9759 @return Normalized relative path.
9760 """
9761 return os.path.normpath(str(value).strip()).replace(os.sep, "/")
9762
9763
Here is the caller graph for this function:

◆ paths_overlap()

bool picurv_cli.core.paths_overlap ( str  first,
str  second 
)

Whether two run-relative directories are the same or nested in one another.

Parameters
[in]firstNormalized directory.
[in]secondNormalized directory.
Returns
True when one contains the other.

Definition at line 9764 of file core.py.

9764def paths_overlap(first: str, second: str) -> bool:
9765 """!
9766 @brief Whether two run-relative directories are the same or nested in one another.
9767 @param[in] first Normalized directory.
9768 @param[in] second Normalized directory.
9769 @return True when one contains the other.
9770 """
9771 if first == second:
9772 return True
9773 return first.startswith(second + "/") or second.startswith(first + "/")
9774
9775
Here is the caller graph for this function:

◆ resolve_unsafe_paths_override()

tuple picurv_cli.core.resolve_unsafe_paths_override ( dict  dirs,
str  monitor_path 
)

Resolve the unsafe-paths override, requiring a real YAML boolean.

A truthy string such as "false" must never enable an override that permits a destructive path. Only a genuine boolean true enables it; any other type is a configuration error rather than a silent interpretation.

Parameters
[in]dirsThe io.directories mapping.
[in]monitor_pathPath to the monitor file, for error messages.
Returns
Tuple of (enabled, errors).

Definition at line 9776 of file core.py.

9776def resolve_unsafe_paths_override(dirs: dict, monitor_path: str) -> tuple:
9777 """!
9778 @brief Resolve the unsafe-paths override, requiring a real YAML boolean.
9779
9780 @details A truthy string such as "false" must never enable an override that permits
9781 a destructive path. Only a genuine boolean `true` enables it; any other
9782 type is a configuration error rather than a silent interpretation.
9783 @param[in] dirs The `io.directories` mapping.
9784 @param[in] monitor_path Path to the monitor file, for error messages.
9785 @return Tuple of (enabled, errors).
9786 """
9787 if UNSAFE_PATHS_OVERRIDE_KEY not in dirs:
9788 return False, []
9789 raw = dirs[UNSAFE_PATHS_OVERRIDE_KEY]
9790 if raw is True:
9791 return True, []
9792 if raw is False:
9793 return False, []
9794 return False, [
9795 f" {monitor_path}: 'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}' must be a YAML boolean "
9796 f"(true or false), got {raw!r}. Quoted strings and numbers are rejected so a value like "
9797 f"\"false\" cannot silently enable an unsafe path."
9798 ]
9799
9800
Here is the caller graph for this function:

◆ evaluate_run_directories()

tuple picurv_cli.core.evaluate_run_directories ( dict  values,
bool  override,
set   explicit = None 
)

Apply every run-directory safety rule to a set of effective directory values.

Single source of truth for the rules, shared by configuration validation and submission preflight so the two cannot drift apart. Callers must pass effective values with defaults filled in.

Two classes of finding are distinguished. Waivable findings concern a deliberate external location; allow_unsafe_paths downgrades those to warnings. Non-waivable findings concern self-destruction or a value that cannot be written unambiguously - the run root itself, a reserved run directory, log/output overlap, and malformed characters. The override was granted for deliberate external storage, never for deleting the run's own config or emitting an ambiguous option line, so those stay errors.

Parameters
[in]valuesEffective mapping of directory key to configured value.
[in]overrideWhether the unsafe-paths override is enabled.
[in]explicitKeys the user actually configured; the rest are reported as defaults.
Returns
Tuple of (errors, warnings) as bare messages without a file prefix.

Definition at line 9801 of file core.py.

9801def evaluate_run_directories(values: dict, override: bool, explicit: set = None) -> tuple:
9802 """!
9803 @brief Apply every run-directory safety rule to a set of effective directory values.
9804
9805 @details Single source of truth for the rules, shared by configuration validation and
9806 submission preflight so the two cannot drift apart. Callers must pass
9807 *effective* values with defaults filled in.
9808
9809 Two classes of finding are distinguished. **Waivable** findings concern a
9810 deliberate external location; `allow_unsafe_paths` downgrades those to
9811 warnings. **Non-waivable** findings concern self-destruction or a value that
9812 cannot be written unambiguously - the run root itself, a reserved run
9813 directory, log/output overlap, and malformed characters. The override was
9814 granted for deliberate external storage, never for deleting the run's own
9815 config or emitting an ambiguous option line, so those stay errors.
9816 @param[in] values Effective mapping of directory key to configured value.
9817 @param[in] override Whether the unsafe-paths override is enabled.
9818 @param[in] explicit Keys the user actually configured; the rest are reported as defaults.
9819 @return Tuple of (errors, warnings) as bare messages without a file prefix.
9820 """
9821 errors: list = []
9822 warnings: list = []
9823 configured_keys = set(values) if explicit is None else set(explicit)
9824
9825 def waivable(message: str) -> None:
9826 """!
9827 @brief Record a finding the unsafe-paths override may downgrade.
9828 @param[in] message Finding text.
9829 @return None.
9830 """
9831 if override:
9832 warnings.append(
9833 f"{message} Allowed only because '{UNSAFE_PATHS_OVERRIDE_KEY}: true' is set."
9834 )
9835 else:
9836 errors.append(
9837 f"{message} Use a directory inside the run tree, or set "
9838 f"'io.directories.{UNSAFE_PATHS_OVERRIDE_KEY}: true' to override deliberately."
9839 )
9840
9841 def fatal(message: str) -> None:
9842 """!
9843 @brief Record a finding the override must never waive.
9844 @param[in] message Finding text.
9845 @return None.
9846 """
9847 errors.append(f"{message} This cannot be overridden.")
9848
9849 destructive_note = (
9850 "On a fresh solve the runtime RECURSIVELY DELETES this directory before writing to it."
9851 )
9852 resolved: dict = {}
9853 for key in RUN_OWNED_DIRECTORY_KEYS:
9854 if key not in values:
9855 continue
9856 raw = values[key]
9857 detail = destructive_note if key == "log" else (
9858 "Run output must stay inside the run directory so it can be archived and restored."
9859 )
9860 if not isinstance(raw, str) or not raw.strip():
9861 fatal(f"'io.directories.{key}' must be a non-empty relative path (got {raw!r}).")
9862 continue
9863
9864 # Both axes are reported. Escaping is the safety-critical property and leads,
9865 # but character problems are non-waivable, so a value that is both must not be
9866 # able to slip through by overriding only the escape.
9867 verdict = classify_run_directory_value(raw)
9868 charset_problem = directory_value_charset_problem(raw)
9869 if charset_problem:
9870 fatal(
9871 f"'io.directories.{key}' = {raw!r} {charset_problem}. Run directory names must be "
9872 f"writable to a PETSc options line without quoting; use a plain relative path such "
9873 f"as 'logs' or 'diagnostics/run1'."
9874 )
9875 if verdict == "tilde":
9876 fatal(
9877 f"'io.directories.{key}' = {raw!r} starts with '~', which nothing expands. "
9878 f"The control file is read by PETSc rather than by a shell, and the C "
9879 f"runtime resolves a value not starting with '/' relative to the run - so "
9880 f"this would be planned as one directory and deleted as another. Give a "
9881 f"real absolute path if an external location is intended. This cannot be "
9882 f"overridden."
9883 )
9884 continue
9885 if verdict == "escaping":
9886 if not (isinstance(raw, str) and raw.strip().startswith("/")):
9887 fatal(
9888 f"'io.directories.{key}' = {raw!r} escapes the run directory by relative "
9889 f"traversal. {detail} A relative escape lands among sibling runs and study "
9890 f"members, so it is never authorizable; give an absolute path if an external "
9891 f"location is genuinely intended."
9892 )
9893 continue
9894 # No `continue`: an authorized absolute path must still face the
9895 # non-waivable checks below, or it could target a run-owned directory such
9896 # as `/abs/run/config`.
9897 waivable(f"'io.directories.{key}' = {raw!r} escapes the run directory. {detail}")
9898 if verdict == "run_root":
9899 fatal(
9900 f"'io.directories.{key}' = {raw!r} resolves to the run directory itself. "
9901 + (destructive_note + " That would delete the entire run."
9902 if key == "log" else "Run output must live in its own subdirectory.")
9903 )
9904 continue
9905 if charset_problem:
9906 continue
9907 normalized = normalized_run_directory(raw)
9908 resolved[key] = normalized
9909 # Every segment is checked, not just the first: `./config`, `output/sub`, and
9910 # `/abs/run/config` all target run-owned directories.
9911 segments = [s for s in normalized.split("/") if s not in ("", ".")]
9912 hit = next((s for s in segments if s in RESERVED_RUN_DIRECTORY_NAMES), None)
9913 if hit:
9914 fatal(
9915 f"'io.directories.{key}' = {raw!r} targets the reserved run directory "
9916 f"'{hit}'. " + (destructive_note if key == "log"
9917 else "That directory is owned by the run tree.")
9918 )
9919
9920 if "log" in resolved and "output" in resolved and paths_overlap(resolved["log"], resolved["output"]):
9921 log_source = "" if "log" in configured_keys else " (default)"
9922 out_source = "" if "output" in configured_keys else " (default)"
9923 fatal(
9924 f"'io.directories.log' ({resolved['log']!r}{log_source}) and 'io.directories.output' "
9925 f"({resolved['output']!r}{out_source}) overlap. {destructive_note} "
9926 f"That would delete solver output."
9927 )
9928 return errors, warnings
9929
9930
9931# Typed physical verdicts. Waivability is decided on the verdict, never on a substring
9932# of the message: a finding reading "resolves to the run directory itself" contains no
9933# word like "escapes", so message matching silently downgraded self-destruction to a
9934# warning. Message text is for humans; these constants are for the rule.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ classify_physical_containment()

list picurv_cli.core.classify_physical_containment ( str  run_dir,
dict  values 
)

Classify where each run-owned directory physically lands.

Lexical containment is not enough: a contained name can be a symlink to an external directory, and PetscRMTree follows symlinks. This resolves the real path - including any symlinked ancestor - and reports a typed verdict so the caller can apply the waiver rule structurally.

Parameters
[in]run_dirRun directory the values are relative to.
[in]valuesEffective directory mapping.
Returns
List of (key, verdict, message) for every value that is not contained.

Definition at line 9948 of file core.py.

9948def classify_physical_containment(run_dir: str, values: dict) -> list:
9949 """!
9950 @brief Classify where each run-owned directory physically lands.
9951
9952 @details Lexical containment is not enough: a contained name can be a symlink to an
9953 external directory, and `PetscRMTree` follows symlinks. This resolves the
9954 real path - including any symlinked ancestor - and reports a typed verdict
9955 so the caller can apply the waiver rule structurally.
9956 @param[in] run_dir Run directory the values are relative to.
9957 @param[in] values Effective directory mapping.
9958 @return List of (key, verdict, message) for every value that is not contained.
9959 """
9960 findings: list = []
9961 try:
9962 root = os.path.realpath(run_dir)
9963 except OSError:
9964 return findings
9965 for key in RUN_OWNED_DIRECTORY_KEYS:
9966 raw = values.get(key)
9967 if not isinstance(raw, str) or not raw.strip():
9968 continue
9969 text = raw.strip()
9970 # Whether the *value* was given as an absolute location, which is the only form
9971 # an authorization can cover. A relative name that resolves outside the tree got
9972 # there through a symlink, and that is never what an operator asked for.
9973 # Only a real absolute path can be waived. `~` is refused before this point by
9974 # the lexical rules, and is not expanded here either: an earlier version did
9975 # expand it, which made this layer reason about a home directory no other layer
9976 # ever resolves.
9977 absolute = text.startswith("/")
9978 candidate = os.path.join(run_dir, text)
9979 real = os.path.realpath(candidate)
9980
9981 if real == root:
9982 findings.append((key, PHYSICAL_VERDICT_RUN_ROOT,
9983 f"'io.directories.{key}' = {raw!r} resolves to the run directory itself "
9984 f"({real!r}); deleting it would destroy the run. This cannot be overridden."))
9985 elif real == os.sep or root.startswith(real.rstrip(os.sep) + os.sep):
9986 findings.append((key, PHYSICAL_VERDICT_ANCESTOR,
9987 f"'io.directories.{key}' = {raw!r} resolves to {real!r}, which CONTAINS the run "
9988 f"directory {root!r}. The runtime deletes this path recursively, so it would "
9989 f"destroy the run and everything beside it. This cannot be overridden."))
9990 elif real.startswith(root + os.sep):
9991 continue
9992 elif absolute:
9993 findings.append((key, PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE,
9994 f"'io.directories.{key}' = {raw!r} resolves to {real!r}, which is outside the run "
9995 f"directory {root!r}. The runtime deletes its log directory recursively."))
9996 else:
9997 findings.append((key, PHYSICAL_VERDICT_RELATIVE_ESCAPE,
9998 f"'io.directories.{key}' = {raw!r} is a relative name that resolves to {real!r}, "
9999 f"outside the run directory {root!r} - a symlink leads out of the tree. A "
10000 f"relative escape is never authorizable; name an absolute path if an external "
10001 f"location is genuinely intended. This cannot be overridden."))
10002 return findings
10003
10004
Here is the caller graph for this function:

◆ check_physical_containment()

list picurv_cli.core.check_physical_containment ( str  run_dir,
dict  values 
)

Human-readable physical containment violations.

Parameters
[in]run_dirRun directory the values are relative to.
[in]valuesEffective directory mapping.
Returns
Violation lines.

Definition at line 10005 of file core.py.

10005def check_physical_containment(run_dir: str, values: dict) -> list:
10006 """!
10007 @brief Human-readable physical containment violations.
10008 @param[in] run_dir Run directory the values are relative to.
10009 @param[in] values Effective directory mapping.
10010 @return Violation lines.
10011 """
10012 return [message for _, _, message in classify_physical_containment(run_dir, values)]
10013
10014
Here is the call graph for this function:

◆ effective_run_directories()

dict picurv_cli.core.effective_run_directories ( dict  configured)

Fill in defaults for run-owned directories that were not configured.

An omitted key is not absent at runtime, it takes its default. Checking only explicit keys would miss log: output, which collides with the default output directory and would delete solver output.

Parameters
[in]configuredConfigured directory mapping, possibly partial.
Returns
Effective mapping with defaults applied.

Definition at line 10015 of file core.py.

10015def effective_run_directories(configured: dict) -> dict:
10016 """!
10017 @brief Fill in defaults for run-owned directories that were not configured.
10018
10019 @details An omitted key is not absent at runtime, it takes its default. Checking
10020 only explicit keys would miss `log: output`, which collides with the
10021 default output directory and would delete solver output.
10022 @param[in] configured Configured directory mapping, possibly partial.
10023 @return Effective mapping with defaults applied.
10024 """
10025 effective = dict(RUN_DIRECTORY_DEFAULTS)
10026 for key in RUN_OWNED_DIRECTORY_KEYS:
10027 if key in configured:
10028 effective[key] = configured[key]
10029 return effective
10030
10031
Here is the caller graph for this function:

◆ validate_run_directory_containment()

tuple picurv_cli.core.validate_run_directory_containment ( dict  monitor_cfg,
str  monitor_path 
)

Classify legacy directory values as defense-in-depth during validation.

The monitor schema rejects this removed surface. Keeping the stricter classifier here ensures malformed or manually constructed configurations still receive the safety findings that protect recursive log cleanup.

Parameters
[in]monitor_cfgParsed monitor YAML dictionary.
[in]monitor_pathPath to the monitor file, for error messages.
Returns
Tuple of (errors, warnings).

Definition at line 10032 of file core.py.

10032def validate_run_directory_containment(monitor_cfg: dict, monitor_path: str) -> tuple:
10033 """!
10034 @brief Classify legacy directory values as defense-in-depth during validation.
10035 @details The monitor schema rejects this removed surface. Keeping the stricter
10036 classifier here ensures malformed or manually constructed configurations
10037 still receive the safety findings that protect recursive log cleanup.
10038 @param[in] monitor_cfg Parsed monitor YAML dictionary.
10039 @param[in] monitor_path Path to the monitor file, for error messages.
10040 @return Tuple of (errors, warnings).
10041 """
10042 io_cfg = (monitor_cfg or {}).get("io") or {}
10043 dirs = io_cfg.get("directories")
10044 if not isinstance(dirs, dict):
10045 return [], []
10046 override, override_errors = resolve_unsafe_paths_override(dirs, monitor_path)
10047 errors, warnings = evaluate_run_directories(
10048 effective_run_directories(dirs), override, explicit=set(dirs)
10049 )
10050 return (
10051 override_errors + [f" {monitor_path}: {message}" for message in errors],
10052 [f" {monitor_path}: {message}" for message in warnings],
10053 )
10054
10055
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_reserved_directory_flags()

list picurv_cli.core.validate_reserved_directory_flags ( dict  config,
str  config_path,
str  label 
)

Reject raw PETSc passthrough options that set run-owned directories.

Passthrough surfaces emit {flag: value} verbatim into the generated control file. Run-owned path flags are reserved for the fixed workspace topology and may only be emitted by the generator.

Parameters
[in]configParsed configuration mapping to scan.
[in]config_pathPath to the file, for error messages.
[in]labelHuman-readable description of the surface being scanned.
Returns
Violation lines.

Definition at line 10056 of file core.py.

10056def validate_reserved_directory_flags(config: dict, config_path: str, label: str) -> list:
10057 """!
10058 @brief Reject raw PETSc passthrough options that set run-owned directories.
10059
10060 @details Passthrough surfaces emit `{flag: value}` verbatim into the generated
10061 control file. Run-owned path flags are reserved for the fixed workspace
10062 topology and may only be emitted by the generator.
10063 @param[in] config Parsed configuration mapping to scan.
10064 @param[in] config_path Path to the file, for error messages.
10065 @param[in] label Human-readable description of the surface being scanned.
10066 @return Violation lines.
10067 """
10068 violations: list = []
10069
10070 def scan(node, trail: str) -> None:
10071 """!
10072 @brief Walk the mapping looking for reserved flags used as keys.
10073 @param[in] node Current mapping, list, or scalar node.
10074 @param[in] trail Dotted path to the current node, for error messages.
10075 @return None.
10076 """
10077 if isinstance(node, dict):
10078 for key, value in node.items():
10079 token = key.strip() if isinstance(key, str) else key
10080 if token in RESERVED_DIRECTORY_FLAGS:
10081 violations.append(
10082 f" {config_path}: {label} sets the reserved flag '{token}' at "
10083 f"{trail or '<root>'}. Run directories are fixed by the workspace "
10084 "contract; raw passthrough cannot override them."
10085 )
10086 elif token in RESERVED_INDIRECTION_FLAGS:
10087 violations.append(
10088 f" {config_path}: {label} sets '{token}' at {trail or '<root>'}. PETSc "
10089 f"evaluates that indirection itself, so its contents cannot be checked "
10090 "here and could reintroduce a run-directory flag. Remove the indirection."
10091 )
10092 scan(value, f"{trail}.{key}" if trail else str(key))
10093 elif isinstance(node, list):
10094 for index, item in enumerate(node):
10095 scan(item, f"{trail}[{index}]")
10096
10097 scan(config, "")
10098 return violations
10099
10100
Here is the caller graph for this function:

◆ physical_units_transition_notices()

list picurv_cli.core.physical_units_transition_notices ( dict  case_cfg,
dict  solver_cfg,
dict  monitor_cfg 
)

Name the inputs whose meaning changed when every input became physical.

These inputs were once read in solver units. At unit reference scales the two readings coincide, so a notice is only needed when the scales differ from one; it says which values are now divided by a reference scale.

Parameters
[in]case_cfgParsed case configuration.
[in]solver_cfgParsed solver configuration.
[in]monitor_cfgParsed monitor configuration.
Returns
Warning lines, empty when the case uses unit reference scales.

Definition at line 10101 of file core.py.

10101def physical_units_transition_notices(case_cfg: dict, solver_cfg: dict, monitor_cfg: dict) -> list:
10102 """!
10103 @brief Name the inputs whose meaning changed when every input became physical.
10104 @details These inputs were once read in solver units. At unit reference scales the
10105 two readings coincide, so a notice is only needed when the scales differ
10106 from one; it says which values are now divided by a reference scale.
10107 @param[in] case_cfg Parsed case configuration.
10108 @param[in] solver_cfg Parsed solver configuration.
10109 @param[in] monitor_cfg Parsed monitor configuration.
10110 @return Warning lines, empty when the case uses unit reference scales.
10111 """
10112 try:
10113 scales = resolve_reference_scales(case_cfg)
10114 except (ValueError, TypeError, AttributeError):
10115 return []
10116 if scales["length_ref"] == 1.0 and scales["velocity_ref"] == 1.0:
10117 return []
10118 physics = ((case_cfg.get("models") or {}).get("physics") or {})
10119 ic = ((case_cfg.get("properties") or {}).get("initial_conditions") or {})
10120 operation = (solver_cfg or {}).get("operation_mode") or {}
10121 windows = (((monitor_cfg or {}).get("field_statistics") or {}).get("windows") or [])
10122 changed = []
10123 if isinstance((physics.get("particles") or {}).get("point_source"), dict):
10124 changed.append("models.physics.particles.point_source")
10125 wall_cfg = (physics.get("turbulence") or {}).get("wall_function")
10126 if isinstance(wall_cfg, dict) and "roughness_height" in wall_cfg:
10127 changed.append("models.physics.turbulence.wall_function.roughness_height")
10128 if str(ic.get("mode", "")).strip().lower() == "file":
10129 changed.append("properties.initial_conditions (mode: file payload)")
10130 if str(ic.get("generator", "")).strip().lower().replace("-", "_") in _PYTHON_INITIAL_CONDITION_PROVIDERS:
10131 changed.append(f"properties.initial_conditions.params ({ic.get('generator')})")
10132 if isinstance(operation, dict) and operation.get("uniform_flow"):
10133 changed.append("operation_mode.uniform_flow")
10134 if ((solver_cfg or {}).get("verification") or {}).get("sources"):
10135 changed.append("verification.sources")
10136 if any(isinstance(window, dict) and ({"start_time", "end_time", "time_cadence"} & set(window))
10137 for window in windows):
10138 changed.append("field_statistics.windows[].{start_time,end_time,time_cadence}")
10139 if not changed:
10140 return []
10141 return [
10142 f"Physical units: with length_ref={scales['length_ref']:g} and "
10143 f"velocity_ref={scales['velocity_ref']:g}, these inputs are read as physical values and "
10144 f"converted to solver units: {', '.join(changed)}. Inputs written in solver units "
10145 f"before this rule must be converted; see docs/pages/19_Nondimensionalization.md."
10146 ]
10147
10148
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_simulation_configs()

picurv_cli.core.validate_simulation_configs ( dict  case_cfg,
dict  solver_cfg,
dict  monitor_cfg,
str  case_path,
str  solver_path,
str  monitor_path,
bool   restart_source_given = False 
)

Validates every configuration a simulation run consumes, before any work is done.

Covers the three roles the solver is launched with: the case, the solver, and the monitor. Only one of those is a solver configuration, which is why this is not named for the solver; the monitor in particular carries observation and field-statistics contracts that have nothing to do with the numerical scheme.

Checks required sections, required keys, physical sanity, and the cross-file combinations no single file can rule out. Post-processing configuration is validated separately by validate_post_config().

Parameters
[in]case_cfgParsed case YAML dictionary.
[in]solver_cfgParsed solver YAML dictionary.
[in]monitor_cfgParsed monitor YAML dictionary.
[in]case_pathPath to case file (for error messages).
[in]solver_pathPath to solver file (for error messages).
[in]monitor_pathPath to monitor file (for error messages).
[in]restart_source_givenWhether the invocation names a restart source (–restart-from or –continue), which silences the reminder that the case needs one.
Exceptions
SystemExiton validation failure.

Definition at line 10149 of file core.py.

10151 restart_source_given: bool = False):
10152 """!
10153 @brief Validates every configuration a simulation run consumes, before any work is done.
10154 @details Covers the three roles the solver is launched with: the case, the solver,
10155 and the monitor. Only one of those is a solver configuration, which is
10156 why this is not named for the solver; the monitor in particular carries
10157 observation and field-statistics contracts that have nothing to do with
10158 the numerical scheme.
10159
10160 Checks required sections, required keys, physical sanity, and the
10161 cross-file combinations no single file can rule out. Post-processing
10162 configuration is validated separately by `validate_post_config()`.
10163 @param[in] case_cfg Parsed case YAML dictionary.
10164 @param[in] solver_cfg Parsed solver YAML dictionary.
10165 @param[in] monitor_cfg Parsed monitor YAML dictionary.
10166 @param[in] case_path Path to case file (for error messages).
10167 @param[in] solver_path Path to solver file (for error messages).
10168 @param[in] monitor_path Path to monitor file (for error messages).
10169 @param[in] restart_source_given Whether the invocation names a restart source
10170 (--restart-from or --continue), which silences the
10171 reminder that the case needs one.
10172 @throws SystemExit on validation failure.
10173 """
10174 errors = []
10175 warnings = []
10176 warnings.extend(physical_units_transition_notices(case_cfg, solver_cfg, monitor_cfg))
10177 errors.extend(validate_reserved_directory_flags(
10178 case_cfg, case_path, "case solver_parameters / passthrough"))
10179 errors.extend(validate_reserved_directory_flags(
10180 solver_cfg, solver_path, "solver petsc_passthrough_options"))
10181 eulerian_source_mode = "solve"
10182
10183 legacy_models = case_cfg.get("models") if isinstance(case_cfg, dict) else None
10184 legacy_statistics = legacy_models.get("statistics") if isinstance(legacy_models, dict) else None
10185 if legacy_statistics is not None:
10186 errors.append(
10187 f" {case_path}: 'models.statistics' was removed with the legacy averaging system. "
10188 "Use instantaneous output and offline postprocessing until the replacement "
10189 "field-statistics pipeline is available."
10190 )
10191 if isinstance(solver_cfg, dict) and "solution_convergence" in solver_cfg:
10192 errors.append(
10193 f" {solver_path}: 'solution_convergence' moved to "
10194 "monitor.yml -> solution_monitoring.convergence."
10195 )
10196 if errors:
10197 _print_validation_errors(errors)
10198
10199 _validate_yaml_schema_keys(case_cfg, _CASE_SCHEMA, case_path, errors)
10200 _validate_yaml_schema_keys(solver_cfg, _SOLVER_SCHEMA, solver_path, errors)
10201 _validate_yaml_schema_keys(monitor_cfg, _MONITOR_SCHEMA, monitor_path, errors)
10202
10203 # --- case.yml: required top-level sections ---
10204 required_case_sections = ['properties', 'run_control', 'grid', 'models', 'boundary_conditions']
10205 for section in required_case_sections:
10206 if section not in case_cfg:
10207 errors.append(f" {case_path}: missing required section '{section}'.")
10208
10209 if errors:
10210 _print_validation_errors(errors)
10211
10212 # --- case.yml: properties sub-keys ---
10213 props = case_cfg.get('properties', {})
10214 for group, keys in [('scaling', ['length_ref', 'velocity_ref']),
10215 ('fluid', ['density', 'viscosity'])]:
10216 sub = props.get(group, {})
10217 if not sub:
10218 errors.append(f" {case_path}: missing 'properties.{group}' section.")
10219 else:
10220 for k in keys:
10221 if k not in sub:
10222 errors.append(f" {case_path}: missing key 'properties.{group}.{k}'.")
10223
10224 # --- case.yml: run_control sub-keys ---
10225 rc = case_cfg.get('run_control', {})
10226 for k in ['start_step', 'total_steps', 'dt_physical']:
10227 if k not in rc:
10228 errors.append(f" {case_path}: missing key 'run_control.{k}'.")
10229
10230 # --- Physical sanity checks ---
10231 # Each value is checked for its type here: later stages convert them with int()/float()
10232 # and would otherwise stop with a Python error instead of naming the key.
10233 for key, value, positive in (
10234 ('properties.fluid.density', props.get('fluid', {}).get('density', 0), True),
10235 ('properties.fluid.viscosity', props.get('fluid', {}).get('viscosity', 0), False),
10236 ('run_control.dt_physical', rc.get('dt_physical', 0), True),
10237 ):
10238 if not isinstance(value, (int, float)) or isinstance(value, bool):
10239 errors.append(f" {case_path}: '{key}' must be a number (got {value!r}).")
10240 elif positive and value <= 0:
10241 errors.append(f" {case_path}: '{key}' must be positive (got {value}).")
10242 elif value < 0:
10243 errors.append(f" {case_path}: '{key}' must be non-negative (got {value}).")
10244 particles_count = (((case_cfg.get('models') or {}).get('physics') or {}).get('particles') or {}).get('count', 0)
10245 for key, value in (('run_control.start_step', rc.get('start_step', 0)),
10246 ('run_control.total_steps', rc.get('total_steps', 0)),
10247 ('models.physics.particles.count', particles_count)):
10248 if not isinstance(value, int) or isinstance(value, bool) or value < 0:
10249 errors.append(f" {case_path}: '{key}' must be a non-negative integer (got {value!r}).")
10250 if errors:
10251 _print_validation_errors(errors)
10252
10253 # --- case.yml: grid mode ---
10254 grid_cfg = case_cfg.get('grid', {})
10255 grid_mode = grid_cfg.get('mode')
10256 valid_grid_modes = list(GRID_MODES)
10257 if grid_mode not in valid_grid_modes:
10258 errors.append(f" {case_path}: 'grid.mode' must be one of {valid_grid_modes} (got '{grid_mode}').")
10259 elif grid_mode == 'file':
10260 source_file = grid_cfg.get('source_file')
10261 if not source_file:
10262 errors.append(f" {case_path}: 'grid.source_file' is required when grid.mode is 'file'.")
10263 else:
10264 try:
10265 source_abs = resolve_workspace_path(case_path, source_file)
10266 except ValueError as exc:
10267 errors.append(f" {case_path}: {exc}")
10268 else:
10269 if not os.path.isfile(source_abs):
10270 errors.append(f" {case_path}: grid.source_file does not exist: {source_abs}")
10271 elif grid_mode == 'programmatic_c':
10272 grid_settings = grid_cfg.get('programmatic_settings')
10273 if not grid_settings:
10274 errors.append(f" {case_path}: 'grid.programmatic_settings' is required when grid.mode is 'programmatic_c'.")
10275 elif not isinstance(grid_settings, dict):
10276 errors.append(f" {case_path}: 'grid.programmatic_settings' must be a mapping.")
10277 elif grid_mode == 'grid_gen':
10278 gen_cfg = grid_cfg.get('generator')
10279 if not isinstance(gen_cfg, dict):
10280 errors.append(f" {case_path}: 'grid.generator' must be a mapping when grid.mode is 'grid_gen'.")
10281 else:
10282 warn_on_grid_generator_hyphen_keys(gen_cfg, case_path, warnings)
10283 errors.extend(
10284 reject_generator_destination_keys(gen_cfg, case_path, "grid.generator")
10285 )
10286
10287 config_file = gen_cfg.get('config_file')
10288 if not config_file:
10289 errors.append(f" {case_path}: 'grid.generator.config_file' is required for grid.mode='grid_gen'.")
10290 else:
10291 try:
10292 config_abs = resolve_workspace_path(case_path, config_file)
10293 except ValueError as exc:
10294 errors.append(f" {case_path}: {exc}")
10295 else:
10296 if not os.path.isfile(config_abs):
10297 errors.append(f" {case_path}: grid.generator.config_file does not exist: {config_abs}")
10298
10299 grid_type = gen_cfg.get('grid_type')
10300 if grid_type is not None and str(grid_type) not in GRID_GENERATOR_TYPES:
10301 errors.append(f" {case_path}: grid.generator.grid_type must be one of "
10302 f"{list(GRID_GENERATOR_TYPES)} (got '{grid_type}').")
10303
10304 # cli_args is an opaque token list handed straight to the generator, so a bad
10305 # geometry selector inside it would otherwise surface as a nonzero subprocess
10306 # exit in the middle of a run rather than as a validation error before one.
10307 errors.extend(validate_grid_generator_cli_args(gen_cfg.get('cli_args'), case_path))
10308
10309 cli_args = gen_cfg.get('cli_args', [])
10310 if cli_args is not None and not isinstance(cli_args, list):
10311 errors.append(f" {case_path}: grid.generator.cli_args must be a list of CLI tokens.")
10312 try:
10313 resolve_grid_da_processor_layout(grid_cfg)
10314 except ValueError as e:
10315 errors.append(f" {case_path}: {e}")
10316
10317 # --- case.yml: boundary_conditions strict validation ---
10318 prepared_blocks = None
10319 try:
10320 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
10321 except ValueError as e:
10322 errors.append(f" {case_path}: {e}")
10323
10324 # --- case.yml: initial_conditions mode-aware validation ---
10325 ic = props.get('initial_conditions', {})
10326 resolved_ic = None
10327 try:
10328 ic_start_step = int((case_cfg.get("run_control", {}) or {}).get("start_step", 0) or 0)
10329 except (TypeError, ValueError):
10330 ic_start_step = 0
10331 try:
10332 ic_eulerian_source = normalize_eulerian_field_source(
10333 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
10334 )
10335 except ValueError:
10336 # The solver-specific validation below reports an invalid source value.
10337 # Keep IC validation independent of that diagnostic.
10338 ic_eulerian_source = "solve"
10339 ic_is_authoritative = ic_eulerian_source == "solve" and ic_start_step == 0
10340 if not ic:
10341 errors.append(f" {case_path}: missing 'properties.initial_conditions' section.")
10342 elif not isinstance(ic, dict):
10343 errors.append(f" {case_path}: 'properties.initial_conditions' must be a mapping.")
10344 elif 'mode' not in ic:
10345 errors.append(
10346 f" {case_path}: missing key 'properties.initial_conditions.mode'. "
10347 "Specify 'generated' or 'file' explicitly."
10348 )
10349 elif ic_is_authoritative:
10350 try:
10351 scaling_contract = resolve_fluid_scaling(case_cfg)
10352 resolved_ic = resolve_initial_condition_config(
10353 ic, prepared_blocks, scales=scaling_contract,
10354 provider_context={"kinematic_viscosity": scaling_contract["nondimensional_kinematic_viscosity"]},
10355 )
10356 except KeyError as e:
10357 errors.append(f" {case_path}: missing key 'properties.initial_conditions.{e.args[0]}'.")
10358 except ValueError as e:
10359 errors.append(f" {case_path}: {e}")
10360 if ic_is_authoritative and resolved_ic and resolved_ic.get("kind") == "stability_mode_velocity":
10361 dimensionality = str((((case_cfg.get("models", {}) or {}).get("physics", {}) or {})
10362 .get("dimensionality", "3D"))).strip().upper()
10363 smv = resolved_ic["params"]
10364 if dimensionality == "2D" and ("i" in (smv["streamwise_axis"], smv["cross_axis"])
10365 or not (smv.get("noise") or {"planar": True})["planar"]):
10366 errors.append(f" {case_path}: stability_mode_velocity in 2D mode needs i as the normal axis "
10367 "(streamwise and cross axes j and k) and noise.planar: true.")
10368 if (ic_is_authoritative and resolved_ic and
10369 GENERATED_IC_PROVIDERS.get(resolved_ic.get("kind"), {}).get("requires_fresh_3d")):
10370 if resolved_ic["kind"] in ("channel_spectral_velocity", "duct_spectral_velocity"):
10371 if (case_cfg.get("models", {}).get("physics", {}).get("fsi", {}) or {}).get("immersed", False):
10372 errors.append(f" {case_path}: wall spectral ICs do not support immersed geometry.")
10373 dimensionality = str((((case_cfg.get("models", {}) or {}).get("physics", {}) or {})
10374 .get("dimensionality", "3D"))).strip().upper()
10375 if dimensionality != "3D":
10376 errors.append(f" {case_path}: {resolved_ic['label']} requires models.physics.dimensionality: 3D.")
10377 if grid_mode == "programmatic_c":
10378 settings = grid_cfg.get("programmatic_settings", {}) or {}
10379 ratios = [settings.get(key, 1.0) for key in ("rxs", "rys", "rzs")]
10380 try:
10381 if any(abs(float(value) - 1.0) > 1.0e-12 for value in ratios):
10382 errors.append(f" {case_path}: {resolved_ic['label']} requires uniform programmatic spacing (rxs/rys/rzs: 1.0).")
10383 except (TypeError, ValueError):
10384 pass
10385 generated_profile = any(
10386 bc.get("handler") == "prescribed_flow"
10387 and ((bc.get("params") or {}).get("source") or {}).get("type") == "generated"
10388 for block in (prepared_blocks or []) for bc in block
10389 )
10390 if grid_mode == 'programmatic_c' and (
10391 generated_profile or (resolved_ic and is_generated_ic_provider(resolved_ic))):
10392 try:
10393 validate_programmatic_bridge_grid_settings(grid_cfg.get('programmatic_settings'))
10394 except ValueError as e:
10395 errors.append(f" {case_path}: {e}")
10396
10397 # --- case.yml: particle initialization validation ---
10398 particles_cfg = case_cfg.get('models', {}).get('physics', {}).get('particles', {})
10399 if particles_cfg and not isinstance(particles_cfg, dict):
10400 errors.append(f" {case_path}: 'models.physics.particles' must be a mapping.")
10401 elif isinstance(particles_cfg, dict):
10402 init_mode_raw = particles_cfg.get('init_mode', 'Surface')
10403 try:
10404 pinit_code = normalize_particle_init_mode(init_mode_raw)
10405 except ValueError as e:
10406 errors.append(f" {case_path}: {e}")
10407 pinit_code = None
10408
10409 # One base seed drives every particle random stream - initial placement, inlet
10410 # re-placement, and Brownian motion - so identical inputs on the same rank count
10411 # reproduce. Omitted, the runtime uses 12345.
10412 random_seed = particles_cfg.get('random_seed')
10413 if random_seed is not None and (isinstance(random_seed, bool) or not isinstance(random_seed, int)
10414 or not 0 <= random_seed <= 2147483647):
10415 errors.append(
10416 f" {case_path}: models.physics.particles.random_seed must be an integer from 0 to "
10417 f"2147483647 (got {random_seed!r})."
10418 )
10419
10420 restart_mode = particles_cfg.get('restart_mode')
10421 if restart_mode is not None and str(restart_mode).lower() not in PARTICLE_RESTART_MODES:
10422 errors.append(
10423 f" {case_path}: models.physics.particles.restart_mode must be 'init' or 'load' (got '{restart_mode}')."
10424 )
10425 elif 'restart_mode' not in particles_cfg:
10426 try:
10427 start_step = int(rc.get('start_step', 0))
10428 particle_count = int(particles_cfg.get('count', 0) or 0)
10429 except (TypeError, ValueError):
10430 start_step = 0
10431 particle_count = 0
10432 if start_step > 0 and particle_count > 0:
10433 warnings.append(
10434 f"{case_path}: models.physics.particles.restart_mode is omitted for a particle restart "
10435 "(run_control.start_step > 0, count > 0). C will default to 'load'."
10436 )
10437
10438 if pinit_code == 2:
10439 point_cfg = particles_cfg.get('point_source', {})
10440 if not isinstance(point_cfg, dict):
10441 errors.append(f" {case_path}: models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
10442 else:
10443 for coord in ('x', 'y', 'z'):
10444 if coord not in point_cfg:
10445 errors.append(
10446 f" {case_path}: models.physics.particles.point_source.{coord} is required when init_mode is PointSource."
10447 )
10448
10449 if 'fields' in particles_cfg:
10450 try:
10451 particle_fields = normalize_particle_fields(particles_cfg['fields'])
10452 except ValueError as e:
10453 errors.append(f" {case_path}: {e}")
10454 particle_fields = []
10455 if particle_fields and int(particles_cfg.get('count', 0) or 0) <= 0:
10456 errors.append(f" {case_path}: models.physics.particles.fields needs particles (count > 0).")
10457 if particle_fields and ((solver_cfg.get('verification') or {}).get('sources') or {}).get('scalar'):
10458 errors.append(
10459 f" {case_path}: models.physics.particles.fields cannot be combined with "
10460 f"{solver_path} verification.sources.scalar, which prescribes Psi at every step.")
10461 if pinit_code == 2:
10462 for entry in particle_fields:
10463 if not any(re.search(r"\b(uniform|normal)\s*\‍(", e) for e in entry["expressions"]):
10464 warnings.append(
10465 f"{case_path}: models.physics.particles.fields.{entry['name']} has no random draw, "
10466 "but init_mode is PointSource: every particle starts at one point and gets the "
10467 "same value. Use a constant, or uniform()/normal() for per-particle values.")
10468 try:
10469 resumes = int(rc.get('start_step', 0)) > 0
10470 except (TypeError, ValueError):
10471 resumes = False
10472 if particle_fields and resumes and str(particles_cfg.get('restart_mode', 'load')).lower() == 'load':
10473 warnings.append(
10474 f"{case_path}: models.physics.particles.fields is ignored when particles are restored "
10475 "(restart_mode: load); their values come from the checkpoint.")
10476
10477 # --- case.yml: domain and physics switches ---
10478 # Multi-block coupling, immersed boundaries and moving bodies are planned, not
10479 # implemented. The switches stay in the schema so that setting one fails here with a
10480 # reason, rather than as an unknown key or, worse, as a run that solves a different
10481 # problem: the solver would accept all three and silently ignore what they promise.
10482 models_cfg = case_cfg.get('models', {}) or {}
10483 domain_cfg = models_cfg.get('domain', {}) or {}
10484 physics_cfg = models_cfg.get('physics', {}) or {}
10485 try:
10486 block_count = int(domain_cfg.get('blocks', 1))
10487 except (TypeError, ValueError):
10488 block_count = None
10489 if block_count != 1:
10490 errors.append(
10491 f" {case_path}: models.domain.blocks must be 1 (got {domain_cfg.get('blocks')!r}). "
10492 "Multi-block domains are planned, not implemented: no solver exchanges data across "
10493 "the faces blocks share, so each block would be solved as an isolated domain."
10494 )
10495 fsi_cfg = physics_cfg.get('fsi', {}) or {}
10496 if not isinstance(fsi_cfg, dict):
10497 errors.append(f" {case_path}: 'models.physics.fsi' must be a mapping.")
10498 else:
10499 for key, feature in (('immersed', 'An immersed boundary'),
10500 ('moving_fsi', 'Moving-body fluid-structure interaction')):
10501 if fsi_cfg.get(key):
10502 errors.append(
10503 f" {case_path}: models.physics.fsi.{key} must be false. {feature} is planned, "
10504 "not implemented: no body geometry is loaded, and the interpolation that would "
10505 "impose one on the flow was never ported."
10506 )
10507 dimensionality = str(physics_cfg.get('dimensionality', '3D')).strip()
10508 if dimensionality not in DOMAIN_DIMENSIONALITY_MODES:
10509 errors.append(
10510 f" {case_path}: models.physics.dimensionality must be one of "
10511 f"{list(DOMAIN_DIMENSIONALITY_MODES)} (got '{dimensionality}')."
10512 )
10513
10514 # --- case.yml: turbulence model validation ---
10515 turbulence_cfg = case_cfg.get('models', {}).get('physics', {}).get('turbulence', {})
10516 if turbulence_cfg is not None and not isinstance(turbulence_cfg, dict):
10517 errors.append(f" {case_path}: 'models.physics.turbulence' must be a mapping.")
10518 elif isinstance(turbulence_cfg, dict) and turbulence_cfg:
10519 try:
10520 # Only the selectors are checked here and the flags are discarded, so unit
10521 # scales stand in for the case's, whose own validation runs separately.
10522 append_turbulence_flags(case_cfg.get('models', {}), [],
10523 {"length_ref": 1.0, "velocity_ref": 1.0})
10524 except ValueError as e:
10525 errors.append(f" {case_path}: {e}")
10526
10527 les_cfg = turbulence_cfg.get('les')
10528 rans_cfg = turbulence_cfg.get('rans')
10529 wall_cfg = turbulence_cfg.get('wall_function')
10530
10531 if isinstance(les_cfg, dict):
10532 validate_les_configuration(case_cfg, les_cfg, case_path, errors, warnings)
10533
10534 validate_wall_model_pairing(case_cfg, les_cfg, wall_cfg, case_path,
10535 errors, warnings)
10536
10537 if rans_cfg is not None:
10538 errors.append(f" {case_path}: {RANS_PLANNED_REFUSAL}")
10539
10540 if isinstance(wall_cfg, dict):
10541 if 'enabled' in wall_cfg and not isinstance(wall_cfg['enabled'], bool):
10542 errors.append(f" {case_path}: models.physics.turbulence.wall_function.enabled must be true or false.")
10543 if 'roughness_height' in wall_cfg:
10544 try:
10545 value = float(wall_cfg['roughness_height'])
10546 if value < 0.0:
10547 errors.append(f" {case_path}: models.physics.turbulence.wall_function.roughness_height must be nonnegative.")
10548 except (TypeError, ValueError):
10549 errors.append(f" {case_path}: models.physics.turbulence.wall_function.roughness_height must be numeric.")
10550 # Only the log law has a roughness formulation. Werner-Wengle has no
10551 # roughness term and Cabot discards the argument, so accepting the key
10552 # for either would silently ignore a value the user set deliberately.
10553 try:
10554 wall_model = normalize_wall_function_model(wall_cfg.get('model'))
10555 except ValueError:
10556 wall_model = None
10557 if wall_model in (2, 3):
10558 errors.append(
10559 f" {case_path}: models.physics.turbulence.wall_function.roughness_height "
10560 "applies only to model 'log_law'; 'werner' has no roughness formulation and "
10561 "'cabot' ignores it. Remove the key or select 'log_law'.")
10562
10563 # --- solver.yml: basic structure ---
10564 if not isinstance(solver_cfg, dict) or not solver_cfg:
10565 errors.append(f" {solver_path}: solver config is empty or not a valid YAML mapping.")
10566 else:
10567 strategy_cfg = solver_cfg.get('strategy', {})
10568 if not isinstance(strategy_cfg, dict):
10569 errors.append(f" {solver_path}: 'strategy' must be a mapping.")
10570 elif 'implicit' in strategy_cfg:
10571 errors.append(
10572 f" {solver_path}: unsupported old key 'strategy.implicit' is not supported. "
10573 "Use 'strategy.momentum_solver' with named solver values."
10574 )
10575 if isinstance(strategy_cfg, dict) and 'momentum_solver' in strategy_cfg:
10576 try:
10577 normalize_momentum_solver_type(strategy_cfg['momentum_solver'])
10578 except ValueError as e:
10579 errors.append(f" {solver_path}: {e}")
10580
10581 op_mode_cfg = solver_cfg.get('operation_mode', {})
10582 if op_mode_cfg is not None and not isinstance(op_mode_cfg, dict):
10583 errors.append(f" {solver_path}: 'operation_mode' must be a mapping when provided.")
10584 elif isinstance(op_mode_cfg, dict):
10585 eulerian_source_mode = None
10586 normalized_analytical_type = None
10587 if 'eulerian_field_source' in op_mode_cfg:
10588 try:
10589 eulerian_source_mode = normalize_eulerian_field_source(op_mode_cfg.get('eulerian_field_source'))
10590 except ValueError as e:
10591 errors.append(f" {solver_path}: {e}")
10592
10593 analytical_type = op_mode_cfg.get('analytical_type')
10594 if analytical_type is not None:
10595 try:
10596 normalized_analytical_type = normalize_analytical_type(analytical_type)
10597 except ValueError as e:
10598 errors.append(f" {solver_path}: {e}")
10599 else:
10600 uniform_flow_cfg = op_mode_cfg.get('uniform_flow')
10601 if uniform_flow_cfg is not None and not isinstance(uniform_flow_cfg, dict):
10602 errors.append(f" {solver_path}: 'operation_mode.uniform_flow' must be a mapping when provided.")
10603 elif normalized_analytical_type == "UNIFORM_FLOW":
10604 if not isinstance(uniform_flow_cfg, dict):
10605 errors.append(
10606 f" {solver_path}: operation_mode.uniform_flow is required when "
10607 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
10608 )
10609 else:
10610 for coord in ("u", "v", "w"):
10611 if coord not in uniform_flow_cfg:
10612 errors.append(
10613 f" {solver_path}: operation_mode.uniform_flow.{coord} is required for UNIFORM_FLOW."
10614 )
10615 else:
10616 try:
10617 float(uniform_flow_cfg[coord])
10618 except (TypeError, ValueError):
10619 errors.append(
10620 f" {solver_path}: operation_mode.uniform_flow.{coord} must be numeric."
10621 )
10622 elif uniform_flow_cfg is not None:
10623 errors.append(
10624 f" {solver_path}: operation_mode.uniform_flow is only valid when "
10625 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
10626 )
10627
10628 if eulerian_source_mode == "analytical":
10629 effective_analytical_type = normalized_analytical_type or "TGV3D"
10630 if effective_analytical_type == "TGV3D":
10631 # programmatic_c: the solver assigns the [0, 2*pi] TGV domain itself.
10632 # file: the given grid is kept and the field is evaluated at its cell centres.
10633 if grid_mode not in (GRID_MODES[1], GRID_MODES[0]):
10634 errors.append(
10635 f" {case_path}: analytical type '{effective_analytical_type}' requires grid.mode "
10636 "'programmatic_c' (solver-assigned TGV domain) or 'file' (a grid file the field "
10637 "is evaluated on)."
10638 )
10639 elif grid_mode == 'programmatic_c' and isinstance(grid_cfg.get('programmatic_settings'), dict):
10640 missing_dims = [key for key in ('im', 'jm', 'km') if key not in grid_cfg['programmatic_settings']]
10641 if missing_dims:
10642 errors.append(
10643 f" {case_path}: grid.programmatic_settings must include {missing_dims} when "
10644 f"operation_mode.analytical_type resolves to '{effective_analytical_type}'."
10645 )
10646 else:
10647 if grid_mode not in (GRID_MODES[1], GRID_MODES[0]):
10648 errors.append(
10649 f" {case_path}: grid.mode '{grid_mode}' is not supported when "
10650 f"operation_mode.analytical_type is '{effective_analytical_type}'. "
10651 "Use 'programmatic_c' or 'file'."
10652 )
10653 elif grid_mode == 'programmatic_c' and isinstance(grid_cfg.get('programmatic_settings'), dict):
10654 missing_dims = [key for key in ('im', 'jm', 'km') if key not in grid_cfg['programmatic_settings']]
10655 if missing_dims:
10656 errors.append(
10657 f" {case_path}: grid.programmatic_settings must include {missing_dims} when "
10658 f"operation_mode.analytical_type is '{effective_analytical_type}' and "
10659 "grid.mode is 'programmatic_c'."
10660 )
10661
10662 verification_cfg = solver_cfg.get('verification', {})
10663 if verification_cfg is not None and not isinstance(verification_cfg, dict):
10664 errors.append(f" {solver_path}: 'verification' must be a mapping when provided.")
10665 elif isinstance(verification_cfg, dict) and verification_cfg:
10666 sources_cfg = verification_cfg.get('sources', {})
10667 if sources_cfg is not None and not isinstance(sources_cfg, dict):
10668 errors.append(f" {solver_path}: 'verification.sources' must be a mapping when provided.")
10669 elif isinstance(sources_cfg, dict) and sources_cfg:
10670 diff_cfg = sources_cfg.get('diffusivity')
10671 scalar_cfg = sources_cfg.get('scalar')
10672
10673 if diff_cfg is not None:
10674 if not isinstance(diff_cfg, dict):
10675 errors.append(f" {solver_path}: 'verification.sources.diffusivity' must be a mapping.")
10676 else:
10677 if eulerian_source_mode != "analytical":
10678 errors.append(
10679 f" {solver_path}: verification.sources.diffusivity is only valid when "
10680 "operation_mode.eulerian_field_source is 'analytical'."
10681 )
10682 mode = diff_cfg.get('mode')
10683 profile = diff_cfg.get('profile')
10684 if str(mode).strip().lower() != "analytical":
10685 errors.append(
10686 f" {solver_path}: verification.sources.diffusivity.mode must be 'analytical'."
10687 )
10688 if str(profile).strip().upper() != "LINEAR_X":
10689 errors.append(
10690 f" {solver_path}: verification.sources.diffusivity.profile must be 'LINEAR_X'."
10691 )
10692 for key in ("gamma0", "slope_x"):
10693 if key not in diff_cfg:
10694 errors.append(
10695 f" {solver_path}: verification.sources.diffusivity.{key} is required."
10696 )
10697 else:
10698 try:
10699 float(diff_cfg[key])
10700 except (TypeError, ValueError):
10701 errors.append(
10702 f" {solver_path}: verification.sources.diffusivity.{key} must be numeric."
10703 )
10704
10705 if scalar_cfg is not None:
10706 if not isinstance(scalar_cfg, dict):
10707 errors.append(f" {solver_path}: 'verification.sources.scalar' must be a mapping.")
10708 else:
10709 if eulerian_source_mode != "analytical":
10710 errors.append(
10711 f" {solver_path}: verification.sources.scalar is only valid when "
10712 "operation_mode.eulerian_field_source is 'analytical'."
10713 )
10714 mode = scalar_cfg.get('mode')
10715 profile = str(scalar_cfg.get('profile', '')).strip().upper()
10716 if str(mode).strip().lower() != "analytical":
10717 errors.append(
10718 f" {solver_path}: verification.sources.scalar.mode must be 'analytical'."
10719 )
10720 if profile not in VERIFICATION_SCALAR_PROFILES:
10721 errors.append(
10722 f" {solver_path}: verification.sources.scalar.profile must be one of CONSTANT, LINEAR_X, SIN_PRODUCT."
10723 )
10724 required_scalar_keys = {
10725 "CONSTANT": ("value",),
10726 "LINEAR_X": ("phi0", "slope_x"),
10727 "SIN_PRODUCT": ("amplitude", "kx", "ky", "kz"),
10728 }.get(profile, ())
10729 for key in required_scalar_keys:
10730 if key not in scalar_cfg:
10731 errors.append(
10732 f" {solver_path}: verification.sources.scalar.{key} is required for profile '{profile}'."
10733 )
10734 else:
10735 try:
10736 float(scalar_cfg[key])
10737 except (TypeError, ValueError):
10738 errors.append(
10739 f" {solver_path}: verification.sources.scalar.{key} must be numeric."
10740 )
10741
10742 unknown_source_keys = sorted(set(sources_cfg.keys()) - {"diffusivity", "scalar"})
10743 if unknown_source_keys:
10744 errors.append(
10745 f" {solver_path}: unsupported verification.sources entries: {unknown_source_keys}. "
10746 "Currently supported: 'diffusivity', 'scalar'."
10747 )
10748 unknown_verification_keys = sorted(set(verification_cfg.keys()) - {"sources"})
10749 if unknown_verification_keys:
10750 errors.append(
10751 f" {solver_path}: unsupported verification keys: {unknown_verification_keys}. "
10752 "Currently supported: 'sources'."
10753 )
10754
10755 try:
10756 resolve_scalar_transport(case_cfg, solver_cfg, warn=True)
10757 except ValueError as exc:
10758 errors.append(f" {solver_path}: {exc}")
10759 try:
10760 resolve_les_diagnostics(case_cfg, monitor_cfg, warn=True)
10761 except ValueError as exc:
10762 errors.append(f" {monitor_path}: {exc}")
10763
10764 tolerances_cfg = solver_cfg.get('tolerances', {})
10765 if tolerances_cfg is not None and not isinstance(tolerances_cfg, dict):
10766 errors.append(f" {solver_path}: 'tolerances' must be a mapping when provided.")
10767 elif isinstance(tolerances_cfg, dict):
10768 for key in ("absolute_tol", "relative_tol", "residual_absolute_tol", "residual_relative_tol"):
10769 if key in tolerances_cfg:
10770 try:
10771 float(tolerances_cfg[key])
10772 except (TypeError, ValueError):
10773 errors.append(f" {solver_path}: tolerances.{key} must be numeric.")
10774
10775 ms_cfg = solver_cfg.get('momentum_solver', {})
10776 if ms_cfg is not None and not isinstance(ms_cfg, dict):
10777 errors.append(f" {solver_path}: 'momentum_solver' must be a mapping when provided.")
10778 elif isinstance(ms_cfg, dict):
10779 unsupported_flat_keys = {
10780 'max_pseudo_steps', 'absolute_tol', 'relative_tol', 'step_tol',
10781 'pseudo_cfl', 'jameson_residual_noise_allowance_factor',
10782 'rk4_residual_noise_allowance_factor'
10783 }
10784 present_unsupported = sorted(unsupported_flat_keys.intersection(ms_cfg.keys()))
10785 if present_unsupported:
10786 errors.append(
10787 f" {solver_path}: unsupported flat keys in 'momentum_solver' are not supported: {present_unsupported}. "
10788 "Use solver-specific sub-blocks (e.g., momentum_solver.dual_time_picard_jameson_rk)."
10789 )
10790
10791 allowed_ms_keys = {'dual_time_picard_jameson_rk', 'dual_time_picard_rk4', 'newton_krylov'}
10792 unknown_ms_keys = sorted(set(ms_cfg.keys()) - allowed_ms_keys)
10793 if unknown_ms_keys:
10794 errors.append(
10795 f" {solver_path}: unsupported momentum_solver blocks/keys: {unknown_ms_keys}. "
10796 "Currently supported: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
10797 )
10798 if 'dual_time_picard_jameson_rk' in ms_cfg and 'dual_time_picard_rk4' in ms_cfg:
10799 errors.append(
10800 f" {solver_path}: use only momentum_solver.dual_time_picard_jameson_rk; "
10801 "do not also set its deprecated dual_time_picard_rk4 alias."
10802 )
10803
10804 selected_solver = None
10805 if isinstance(strategy_cfg, dict) and 'momentum_solver' in strategy_cfg:
10806 try:
10807 selected_solver = normalize_momentum_solver_type(strategy_cfg['momentum_solver'])
10808 except ValueError:
10809 pass
10810 if selected_solver is None:
10811 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
10812
10813 has_dualtime_block = (
10814 'dual_time_picard_jameson_rk' in ms_cfg or 'dual_time_picard_rk4' in ms_cfg
10815 )
10816 if selected_solver != "DUALTIME_PICARD_JAMESON_RK" and has_dualtime_block:
10817 errors.append(
10818 f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk is set but selected solver is "
10819 f"{selected_solver}. Solver-specific blocks must match the selected solver."
10820 )
10821
10822 newton_cfg = ms_cfg.get('newton_krylov')
10823 if newton_cfg is not None:
10824 if selected_solver != "newton_krylov":
10825 errors.append(
10826 f" {solver_path}: momentum_solver.newton_krylov is set but selected solver is "
10827 f"{selected_solver}. Solver-specific blocks must match the selected solver."
10828 )
10829 try:
10830 validate_newton_krylov_config(newton_cfg)
10831 except ValueError as exc:
10832 errors.append(f" {solver_path}: {exc}")
10833
10834 dt_picard_cfg = ms_cfg.get('dual_time_picard_jameson_rk', ms_cfg.get('dual_time_picard_rk4'))
10835 if dt_picard_cfg is not None:
10836 if not isinstance(dt_picard_cfg, dict):
10837 errors.append(f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
10838 else:
10839 allowed_dt_keys = {
10840 'max_pseudo_steps', 'absolute_tol', 'relative_tol', 'step_tol',
10841 'pseudo_cfl', 'jameson_residual_noise_allowance_factor',
10842 'rk4_residual_noise_allowance_factor', 'ratio_ema_alpha'
10843 }
10844 unknown_dt_keys = sorted(set(dt_picard_cfg.keys()) - allowed_dt_keys)
10845 if unknown_dt_keys:
10846 errors.append(
10847 f" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk: {unknown_dt_keys}."
10848 )
10849 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
10850 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
10851 errors.append(
10852 f" {solver_path}: use only jameson_residual_noise_allowance_factor; "
10853 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
10854 )
10855 if 'pseudo_cfl' in dt_picard_cfg:
10856 pcfl_cfg = dt_picard_cfg['pseudo_cfl']
10857 if not isinstance(pcfl_cfg, dict):
10858 errors.append(f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl must be a mapping.")
10859 else:
10860 allowed_pcfl_keys = {'initial', 'minimum', 'maximum', 'growth_factor', 'reduction_factor'}
10861 unknown_pcfl_keys = sorted(set(pcfl_cfg.keys()) - allowed_pcfl_keys)
10862 if unknown_pcfl_keys:
10863 errors.append(
10864 f" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl: {unknown_pcfl_keys}."
10865 )
10866 numeric_pcfl = {}
10867 for key in allowed_pcfl_keys:
10868 if key in pcfl_cfg:
10869 try:
10870 numeric_pcfl[key] = float(pcfl_cfg[key])
10871 except (TypeError, ValueError):
10872 errors.append(
10873 f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl.{key} must be numeric."
10874 )
10875 if numeric_pcfl.get('minimum', 1.0) <= 0.0:
10876 errors.append(f" {solver_path}: pseudo_cfl.minimum must be positive.")
10877 if numeric_pcfl.get('growth_factor', 1.0) < 1.0:
10878 errors.append(f" {solver_path}: pseudo_cfl.growth_factor must be at least 1.")
10879 reduction = numeric_pcfl.get('reduction_factor', 1.0)
10880 if reduction <= 0.0 or reduction >= 1.0:
10881 errors.append(f" {solver_path}: pseudo_cfl.reduction_factor must be in (0, 1).")
10882 if all(key in numeric_pcfl for key in ('minimum', 'initial', 'maximum')):
10883 if not numeric_pcfl['minimum'] <= numeric_pcfl['initial'] <= numeric_pcfl['maximum']:
10884 errors.append(f" {solver_path}: pseudo_cfl requires minimum <= initial <= maximum.")
10885 noise_key = (
10886 'jameson_residual_noise_allowance_factor'
10887 if 'jameson_residual_noise_allowance_factor' in dt_picard_cfg
10888 else 'rk4_residual_noise_allowance_factor'
10889 )
10890 if noise_key in dt_picard_cfg:
10891 try:
10892 if float(dt_picard_cfg[noise_key]) < 1.0:
10893 errors.append(f" {solver_path}: {noise_key} must be at least 1.")
10894 except (TypeError, ValueError):
10895 errors.append(f" {solver_path}: {noise_key} must be numeric.")
10896 if 'ratio_ema_alpha' in dt_picard_cfg:
10897 try:
10898 alpha_val = float(dt_picard_cfg['ratio_ema_alpha'])
10899 if not 0.0 <= alpha_val <= 1.0:
10900 errors.append(f" {solver_path}: ratio_ema_alpha must be in [0, 1].")
10901 except (TypeError, ValueError):
10902 errors.append(f" {solver_path}: ratio_ema_alpha must be numeric.")
10903
10904 # --- solver.yml: interpolation section ---
10905 interp_cfg = solver_cfg.get('interpolation', {}) if isinstance(solver_cfg, dict) else {}
10906 if interp_cfg is not None and not isinstance(interp_cfg, dict):
10907 errors.append(f" {solver_path}: 'interpolation' must be a mapping when provided.")
10908 elif isinstance(interp_cfg, dict) and 'method' in interp_cfg:
10909 try:
10910 interp_code = normalize_interpolation_method(interp_cfg['method'])
10911 except ValueError as e:
10912 errors.append(f" {solver_path}: {e}")
10913 else:
10914 # FaceFlux reads the face fluxes; the analytical types other than UNIFORM_FLOW set Ucat only.
10915 op_mode = solver_cfg.get('operation_mode') if isinstance(solver_cfg.get('operation_mode'), dict) else {}
10916 if (interp_code == 2 and str(op_mode.get('eulerian_field_source', 'solve')).strip().lower() == 'analytical'
10917 and str(op_mode.get('analytical_type', 'TGV3D')).strip().upper() != 'UNIFORM_FLOW'):
10918 errors.append(
10919 f" {solver_path}: interpolation.method 'FaceFlux' needs the face fluxes the flow is "
10920 f"built from, which analytical type '{op_mode.get('analytical_type', 'TGV3D')}' does not set; "
10921 "use 'Trilinear', or the UNIFORM_FLOW analytical type.")
10922
10923 # --- monitor.yml: basic structure ---
10924 if not isinstance(monitor_cfg, dict) or not monitor_cfg:
10925 errors.append(f" {monitor_path}: monitor config is empty or not a valid YAML mapping.")
10926 else:
10927 io_cfg = monitor_cfg.get('io', {})
10928 freq = io_cfg.get('data_output_frequency')
10929 if freq is not None and (not isinstance(freq, int) or freq <= 0):
10930 errors.append(f" {monitor_path}: 'io.data_output_frequency' must be a positive integer (got {freq}).")
10931 for key in ('write_initial_checkpoint', 'write_final_checkpoint'):
10932 if key in io_cfg and not isinstance(io_cfg[key], bool):
10933 errors.append(f" {monitor_path}: 'io.{key}' must be true or false (got {io_cfg[key]!r}).")
10934 try:
10935 physics_cfg = (case_cfg.get('models') or {}).get('physics') if isinstance(case_cfg, dict) and isinstance(case_cfg.get('models') or {}, dict) else None
10936 particles_cfg = physics_cfg.get('particles') if isinstance(physics_cfg, dict) else None
10937 particle_count = particles_cfg.get('count', 0) if isinstance(particles_cfg, dict) else 0
10938 if resolve_traced_particle_ids(monitor_cfg) and not particle_count:
10939 errors.append(f" {monitor_path}: 'logging.trace_particles' needs particles; "
10940 "models.physics.particles.count is 0.")
10941 except ValueError as e:
10942 errors.append(f" {monitor_path}: {e}")
10943 particle_console_freq = io_cfg.get('particle_console_output_frequency')
10944 if particle_console_freq is not None and (not isinstance(particle_console_freq, int) or particle_console_freq < 0):
10945 errors.append(
10946 f" {monitor_path}: 'io.particle_console_output_frequency' must be a non-negative integer "
10947 f"(got {particle_console_freq})."
10948 )
10949 containment_errors, containment_warnings = validate_run_directory_containment(
10950 monitor_cfg, monitor_path
10951 )
10952 errors.extend(containment_errors)
10953 warnings.extend(containment_warnings)
10954 errors.extend(validate_reserved_directory_flags(
10955 monitor_cfg, monitor_path, "monitor passthrough"))
10956 try:
10957 resolve_profiling_config(monitor_cfg)
10958 except ValueError as e:
10959 errors.append(f" {monitor_path}: {e}")
10960 try:
10961 resolve_diagnostics_config(monitor_cfg)
10962 except ValueError as e:
10963 errors.append(f" {monitor_path}: {e}")
10964 try:
10965 resolve_solver_monitoring_flags(monitor_cfg)
10966 except ValueError as e:
10967 errors.append(f" {monitor_path}: {e}")
10968 try:
10969 normalize_solution_monitoring_config(monitor_cfg)
10970 except ValueError as e:
10971 errors.append(f" {monitor_path}: {e}")
10972 statistics_console_freq = io_cfg.get('statistics_console_output_frequency')
10973 if statistics_console_freq is not None and (
10974 not isinstance(statistics_console_freq, int)
10975 or isinstance(statistics_console_freq, bool)
10976 or statistics_console_freq < 0):
10977 errors.append(
10978 f" {monitor_path}: 'io.statistics_console_output_frequency' must be a non-negative "
10979 f"integer (got {statistics_console_freq})."
10980 )
10981 try:
10982 normalize_field_statistics_config(monitor_cfg, case_cfg)
10983 except ValueError as e:
10984 errors.append(f" {monitor_path}: {e}")
10985
10986 if not errors and not restart_source_given:
10987 if needs_restart_source(case_cfg, solver_cfg):
10988 warnings.append(
10989 f"{case_path}: This configuration requires restart data (start_step > 0, "
10990 "eulerian_field_source='load', or particle restart_mode='load'). "
10991 "Use --restart-from or --continue when running."
10992 )
10993
10994 if errors:
10995 _print_validation_errors(errors)
10996 for warning in warnings:
10997 print(f"[WARNING] {warning}", file=sys.stderr)
10998
10999
Here is the call graph for this function:
Here is the caller graph for this function:

◆ check_post_checkpoint_cadence_alignment()

"tuple[list, list]" picurv_cli.core.check_post_checkpoint_cadence_alignment ( dict  post_cfg,
dict  monitor_cfg,
str  post_path,
str   monitor_path = "monitor.yml" 
)

Report post step selections that cannot land on a committed checkpoint.

The post-processor reads committed bundles, and the solver commits one every io.data_output_frequency completed steps. A step_interval that is not a multiple of that cadence therefore asks for steps that were never written: those steps wait forever for a checkpoint, and far less than the recipe requested is processed. That is only discovered after a solve has already run, so it is caught here instead.

The solver also commits the initial and final states off cadence, which is why a misaligned start_step is a warning rather than an error: it may legitimately be the run's initial or final step. A bounded selection containing only one checkpoint has no stride to align; its interval must still be positive. Multi-checkpoint selections retain the cadence check.

Parameters
[in]post_cfgParsed post-processing configuration.
[in]monitor_cfgParsed monitor configuration governing the source run.
[in]post_pathPath to the post file, for error messages.
[in]monitor_pathPath to the monitor file, for error messages.
Returns
(errors, warnings), each a list of message strings. Both are empty when the comparison cannot be made, because the inputs that would make it meaningful are validated and reported elsewhere.

Definition at line 11000 of file core.py.

11001 monitor_path: str = "monitor.yml") -> "tuple[list, list]":
11002 """!
11003 @brief Report post step selections that cannot land on a committed checkpoint.
11004
11005 @details The post-processor reads committed bundles, and the solver commits one
11006 every `io.data_output_frequency` completed steps. A `step_interval` that
11007 is not a multiple of that cadence therefore asks for steps that were
11008 never written: those steps wait forever for a checkpoint, and far less
11009 than the recipe requested is processed. That is only
11010 discovered after a solve has already run, so it is caught here instead.
11011
11012 The solver also commits the initial and final states off cadence, which
11013 is why a misaligned `start_step` is a warning rather than an error: it
11014 may legitimately be the run's initial or final step. A bounded selection
11015 containing only one checkpoint has no stride to align; its interval must
11016 still be positive. Multi-checkpoint selections retain the cadence check.
11017
11018 @param[in] post_cfg Parsed post-processing configuration.
11019 @param[in] monitor_cfg Parsed monitor configuration governing the source run.
11020 @param[in] post_path Path to the post file, for error messages.
11021 @param[in] monitor_path Path to the monitor file, for error messages.
11022 @return `(errors, warnings)`, each a list of message strings. Both are empty when
11023 the comparison cannot be made, because the inputs that would make it
11024 meaningful are validated and reported elsewhere.
11025 """
11026 errors = []
11027 warnings = []
11028 if not isinstance(post_cfg, dict) or not isinstance(monitor_cfg, dict):
11029 return errors, warnings
11030
11031 io_cfg = monitor_cfg.get("io") or {}
11032 if not isinstance(io_cfg, dict):
11033 return errors, warnings
11034 try:
11035 cadence = int(io_cfg["data_output_frequency"])
11036 except (KeyError, TypeError, ValueError):
11037 # A missing or malformed cadence is reported by the monitor's own checks.
11038 return errors, warnings
11039 # A disabled cadence commits only the initial and final states, so there is no
11040 # stride to align to.
11041 if cadence <= 0:
11042 return errors, warnings
11043
11044 try:
11045 start_step, end_step, step_interval = resolve_post_requested_window(post_cfg)
11046 except (TypeError, ValueError):
11047 # Reported by the run_control checks, which run against the same values.
11048 return errors, warnings
11049
11050 single_checkpoint = end_step >= start_step and end_step - start_step < step_interval
11051 if not single_checkpoint and step_interval % cadence != 0:
11052 suggestion = max(cadence, (step_interval // cadence) * cadence)
11053 errors.append(
11054 f" {post_path}: 'run_control.step_interval' is {step_interval}, which is not a "
11055 f"multiple of 'io.data_output_frequency' ({cadence}) in {monitor_path}. The solver "
11056 f"only commits a checkpoint every {cadence} steps, so most requested steps were "
11057 f"never written and post-processing would stop at the first missing one. Use "
11058 f"{suggestion}, or another multiple of {cadence}, or lower the monitor cadence."
11059 )
11060
11061 if start_step > 0 and start_step % cadence != 0:
11062 warnings.append(
11063 f"{post_path}: 'run_control.start_step' is {start_step}, which is not a multiple of "
11064 f"'io.data_output_frequency' ({cadence}) in {monitor_path}. That step only exists if "
11065 f"it is the run's initial or final step, which is committed off cadence."
11066 )
11067
11068 return errors, warnings
11069
11070
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_post_config()

picurv_cli.core.validate_post_config ( dict  post_cfg,
str  post_path,
dict   monitor_cfg = None,
dict   case_cfg = None 
)

Validates the post-processing config before running the post-processor.

Parameters
[in]post_cfgParsed post-processing YAML dictionary.
[in]post_pathPath to post file (for error messages).
[in]monitor_cfgParsed monitor configuration, when available. Two checks span both files: field statistics, where post.yml names the windows and monitor.yml decides what each accumulates, and step cadence, where monitor.yml decides which steps exist to be read. Both run only when the monitor is known.
[in]case_cfgParsed case configuration, when available. Spectra preconditions need the boundary conditions and block count, so they are checked only when the case is known; validating a recipe on its own still checks it on its own terms.
Exceptions
SystemExiton validation failure.

Definition at line 11071 of file core.py.

11071def validate_post_config(post_cfg: dict, post_path: str, monitor_cfg: dict = None, case_cfg: dict = None):
11072 """!
11073 @brief Validates the post-processing config before running the post-processor.
11074 @param[in] post_cfg Parsed post-processing YAML dictionary.
11075 @param[in] post_path Path to post file (for error messages).
11076 @param[in] monitor_cfg Parsed monitor configuration, when available. Two checks
11077 span both files: field statistics, where post.yml names
11078 the windows and monitor.yml decides what each accumulates,
11079 and step cadence, where monitor.yml decides which steps
11080 exist to be read. Both run only when the monitor is known.
11081 @param[in] case_cfg Parsed case configuration, when available. Spectra
11082 preconditions need the boundary conditions and block count, so
11083 they are checked only when the case is known; validating a
11084 recipe on its own still checks it on its own terms.
11085 @throws SystemExit on validation failure.
11086 """
11087 errors = []
11088 warnings = []
11089
11090 _validate_yaml_schema_keys(post_cfg, _POST_SCHEMA, post_path, errors)
11091
11092 try:
11093 recipe = normalize_post_field_statistics_config(post_cfg)
11094 except ValueError as e:
11095 recipe = None
11096 errors.append(f" {post_path}: {e}")
11097
11098 try:
11099 spectra_recipe = normalize_post_spectra_config(post_cfg)
11100 except ValueError as e:
11101 spectra_recipe = None
11102 errors.append(f" {post_path}: {e}")
11103 if recipe is not None:
11104 try:
11105 normalize_post_reducers_config(post_cfg)
11106 except ValueError as e:
11107 errors.append(f" {post_path}: {e}")
11108 if spectra_recipe and spectra_recipe["tasks"] and case_cfg is not None:
11109 errors.extend(validate_post_spectra_preconditions(spectra_recipe, case_cfg, post_path))
11110 if recipe and recipe["windows"] and monitor_cfg is not None:
11111 try:
11112 configured = normalize_field_statistics_config(monitor_cfg)
11113 except ValueError:
11114 # The monitor file reports its own errors; do not repeat them here.
11115 configured = None
11116 if configured is not None:
11117 if not configured["enabled"]:
11118 errors.append(
11119 f" {post_path}: field statistics are requested, but "
11120 "'field_statistics.enabled' is not set in the monitor configuration, so no "
11121 "window is accumulated."
11122 )
11123 else:
11124 by_name = {window["name"]: window for window in configured["windows"]}
11125 for name in recipe["windows"]:
11126 if name not in by_name:
11127 errors.append(
11128 f" {post_path}: field-statistics window '{name}' is not defined in "
11129 f"the monitor configuration. Defined windows: {sorted(by_name)}."
11130 )
11131 elif _post_window_derived_field_count(by_name[name], recipe["outputs"]) == 0:
11132 errors.append(
11133 f" {post_path}: outputs {recipe['outputs']} produce no field for "
11134 f"window '{name}'; it accumulates none of the state they need. Add "
11135 "'second' to a field's moments for stresses, RMS, or turbulent "
11136 "kinetic energy, or a covariance for a flux."
11137 )
11138
11139 for ensemble in (recipe or {}).get("ensembles", []):
11140 eligible = _ensemble_source_fields(ensemble["source"])
11141 if _post_window_derived_field_count(ensemble, recipe["outputs"], eligible) == 0:
11142 errors.append(
11143 f" {post_path}: outputs {recipe['outputs']} produce no field for ensemble "
11144 f"'{ensemble['name']}'; add 'second' to a field's moments or a covariance."
11145 )
11146 if case_cfg is not None:
11147 requirements = {eligible[entry["field"]]["requires"] for entry in ensemble["fields"]}
11148 for requirement in sorted(r for r in requirements if r):
11149 if not _statistics_subsystem_available(case_cfg, requirement):
11150 errors.append(
11151 f" {post_path}: ensemble '{ensemble['name']}' needs the '{requirement}' "
11152 "subsystem, which is not enabled for this case."
11153 )
11154
11155 if not isinstance(post_cfg, dict) or not post_cfg:
11156 errors.append(f" {post_path}: post-processing config is empty or not a valid YAML mapping.")
11157 _print_validation_errors(errors)
11158
11159 # --- run_control ---
11160 if 'run_control' not in post_cfg:
11161 errors.append(f" {post_path}: missing required section 'run_control'.")
11162 else:
11163 rc = post_cfg.get('run_control', {})
11164 if not isinstance(rc, dict):
11165 errors.append(f" {post_path}: 'run_control' must be a mapping.")
11166 else:
11167 for canonical_key, aliases in POST_RUN_CONTROL_ALIASES.items():
11168 if not any(alias in rc for alias in aliases):
11169 alias_list = "', '".join(aliases)
11170 errors.append(
11171 f" {post_path}: missing required key 'run_control.{canonical_key}' "
11172 f"(accepted aliases: '{alias_list}')."
11173 )
11174 continue
11175 raw_value = _mapping_value_with_aliases(rc, *aliases)
11176 try:
11177 value = int(raw_value)
11178 if canonical_key == "step_interval" and value <= 0:
11179 errors.append(f" {post_path}: 'run_control.step_interval' must be positive.")
11180 except (TypeError, ValueError):
11181 alias_name = next((alias for alias in aliases if alias in rc), canonical_key)
11182 errors.append(
11183 f" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
11184 )
11185
11186 # --- io section ---
11187 io_cfg = post_cfg.get('io', {})
11188 source_cfg = post_cfg.get('source_data')
11189 if source_cfg is not None and not isinstance(source_cfg, dict):
11190 errors.append(f" {post_path}: 'source_data' must be a mapping when provided.")
11191 global_ops = post_cfg.get('global_operations')
11192 if global_ops is not None:
11193 if not isinstance(global_ops, dict):
11194 errors.append(f" {post_path}: 'global_operations' must be a mapping when provided.")
11195 elif 'dimensionalize' in global_ops and not isinstance(global_ops.get('dimensionalize'), bool):
11196 errors.append(f" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
11197 if not io_cfg:
11198 errors.append(f" {post_path}: missing required section 'io'.")
11199 elif not isinstance(io_cfg, dict):
11200 errors.append(f" {post_path}: 'io' must be a mapping.")
11201 else:
11202 for k in ['output_filename_prefix']:
11203 if k not in io_cfg:
11204 errors.append(f" {post_path}: missing required key 'io.{k}'.")
11205 for key_name in ('output_directory', 'output_filename_prefix', 'particle_filename_prefix'):
11206 if key_name in io_cfg and not isinstance(io_cfg.get(key_name), str):
11207 errors.append(f" {post_path}: 'io.{key_name}' must be a string when provided.")
11208 # The conductor files every post artifact by recipe identity, which excludes
11209 # this key, so two recipes differing only in it write over each other.
11210 if 'output_directory' in io_cfg:
11211 warnings.append(
11212 f"{post_path}: 'io.output_directory' is ignored; post output goes to the run's "
11213 "visualization and analysis directories under the recipe's identity, so recipes "
11214 "differing only in this key share one home and overwrite each other. Change "
11215 "'io.output_filename_prefix' to keep them apart."
11216 )
11217 if 'output_particles' in io_cfg and not isinstance(io_cfg.get('output_particles'), bool):
11218 errors.append(f" {post_path}: 'io.output_particles' must be a boolean when provided.")
11219 try:
11220 normalize_post_paraview_series_config(post_cfg)
11221 except ValueError as exc:
11222 errors.append(f" {post_path}: {exc}")
11223 particle_subsampling_frequency = io_cfg.get('particle_subsampling_frequency')
11224 if particle_subsampling_frequency is not None:
11225 if not isinstance(particle_subsampling_frequency, int) or particle_subsampling_frequency <= 0:
11226 errors.append(
11227 f" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
11228 )
11229 input_extensions = io_cfg.get('input_extensions')
11230 source_input_extensions = get_post_source_data(post_cfg).get('input_extensions')
11231 if input_extensions is not None:
11232 if not isinstance(input_extensions, dict):
11233 errors.append(f" {post_path}: 'io.input_extensions' must be a mapping when provided.")
11234 else:
11235 for ext_key in ('eulerian', 'particle'):
11236 ext_val = input_extensions.get(ext_key)
11237 if ext_val is not None and not isinstance(ext_val, str):
11238 errors.append(f" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
11239 elif ext_val is not None and str(ext_val).strip().lstrip('.').lower() != 'dat':
11240 errors.append(
11241 f" {post_path}: 'io.input_extensions.{ext_key}' must be 'dat'; "
11242 "committed checkpoint payload names are fixed."
11243 )
11244 if source_input_extensions is not None:
11245 if not isinstance(source_input_extensions, dict):
11246 errors.append(f" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
11247 else:
11248 for ext_key in ('eulerian', 'particle'):
11249 ext_val = source_input_extensions.get(ext_key)
11250 if ext_val is not None and not isinstance(ext_val, str):
11251 errors.append(
11252 f" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
11253 )
11254 elif ext_val is not None and str(ext_val).strip().lstrip('.').lower() != 'dat':
11255 errors.append(
11256 f" {post_path}: 'source_data.input_extensions.{ext_key}' must be 'dat'; "
11257 "committed checkpoint payload names are fixed."
11258 )
11259
11260 for list_key in ('eulerian_fields', 'particle_fields'):
11261 list_val = io_cfg.get(list_key)
11262 if list_val is not None and (not isinstance(list_val, list)
11263 or not all(isinstance(item, str) for item in list_val)):
11264 errors.append(f" {post_path}: 'io.{list_key}' must be a list of field names when provided.")
11265 # The nodal_average inputs are computed at cell centres, and a .vts carries point data
11266 # only: the writer skips them, so a listed one would silently never reach the file.
11267 eulerian_fields = io_cfg.get('eulerian_fields')
11268 for cell_field in ('Ucat', 'P', 'Psi', 'Qcrit'):
11269 if isinstance(eulerian_fields, list) and cell_field in eulerian_fields:
11270 after = " after q_criterion" if cell_field == 'Qcrit' else ""
11271 errors.append(
11272 f" {post_path}: 'io.eulerian_fields' lists '{cell_field}', which is cell-centred and "
11273 f"cannot be written as point data. Add a nodal_average task (input_field: {cell_field}, "
11274 f"output_field: {cell_field}_nodal){after} and write '{cell_field}_nodal'."
11275 )
11276
11277 # --- Check eulerian_pipeline entries have 'task' key ---
11278 eulerian_pipeline = post_cfg.get('eulerian_pipeline', [])
11279 if eulerian_pipeline is not None and not isinstance(eulerian_pipeline, list):
11280 errors.append(f" {post_path}: 'eulerian_pipeline' must be a list when provided.")
11281 eulerian_pipeline = []
11282 for i, entry in enumerate(eulerian_pipeline):
11283 if not isinstance(entry, dict) or 'task' not in entry:
11284 errors.append(f" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
11285 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
11286 continue
11287 task_name = entry.get('task')
11288 if task_name == 'q_criterion':
11289 continue
11290 if task_name == 'nodal_average':
11291 in_field = entry.get('input_field')
11292 out_field = entry.get('output_field')
11293 if not isinstance(in_field, str) or not in_field.strip():
11294 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
11295 if not isinstance(out_field, str) or not out_field.strip():
11296 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
11297 if isinstance(in_field, str) and isinstance(out_field, str) and in_field == out_field:
11298 errors.append(
11299 f" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
11300 )
11301 continue
11302 if task_name == 'normalize_field':
11303 field = entry.get('field', 'P')
11304 if not isinstance(field, str) or not field.strip():
11305 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
11306 elif field != 'P':
11307 errors.append(
11308 f" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
11309 f"(got '{field}')."
11310 )
11311 reference_point = entry.get('reference_point', [1, 1, 1])
11312 if not isinstance(reference_point, (list, tuple)) or len(reference_point) != 3:
11313 errors.append(
11314 f" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
11315 )
11316 else:
11317 for rp_idx, coord in enumerate(reference_point):
11318 try:
11319 int(coord)
11320 except (TypeError, ValueError):
11321 errors.append(
11322 f" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
11323 "must be integer-compatible."
11324 )
11325 continue
11326 errors.append(
11327 f" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]. "
11328 f"Available tasks: {list(POST_EULERIAN_PIPELINE_TASKS)}."
11329 )
11330
11331 # --- Check lagrangian_pipeline entries have 'task' key ---
11332 lagrangian_pipeline = post_cfg.get('lagrangian_pipeline', [])
11333 if lagrangian_pipeline is not None and not isinstance(lagrangian_pipeline, list):
11334 errors.append(f" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
11335 lagrangian_pipeline = []
11336 for i, entry in enumerate(lagrangian_pipeline):
11337 if not isinstance(entry, dict) or 'task' not in entry:
11338 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
11339 continue
11340 task_name = entry.get('task')
11341 if task_name == 'specific_ke':
11342 in_field = entry.get('input_field')
11343 out_field = entry.get('output_field')
11344 if not isinstance(in_field, str) or not in_field.strip():
11345 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
11346 if not isinstance(out_field, str) or not out_field.strip():
11347 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
11348 continue
11349 errors.append(
11350 f" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
11351 )
11352
11353 # --- Check statistics pipeline entries ---
11354 stats_cfg = post_cfg.get('statistics_pipeline')
11355 stats_entries = []
11356 if stats_cfg is not None:
11357 if isinstance(stats_cfg, list):
11358 stats_entries = stats_cfg
11359 elif isinstance(stats_cfg, dict):
11360 stats_entries = stats_cfg.get('tasks', [])
11361 if not isinstance(stats_entries, list):
11362 errors.append(f" {post_path}: 'statistics_pipeline.tasks' must be a list.")
11363 stats_output_prefix = stats_cfg.get('output_prefix')
11364 if stats_output_prefix is not None and not isinstance(stats_output_prefix, str):
11365 errors.append(f" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
11366 else:
11367 errors.append(
11368 f" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
11369 )
11370 for i, entry in enumerate(stats_entries):
11371 if isinstance(entry, str):
11372 task_name = entry
11373 elif isinstance(entry, dict) and 'task' in entry:
11374 task_name = entry.get('task')
11375 else:
11376 errors.append(
11377 f" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
11378 )
11379 continue
11380 try:
11381 normalize_statistics_task(task_name)
11382 except ValueError as e:
11383 errors.append(f" {post_path}: {e}")
11384
11385 # MSD measures displacement from the configured point source, not from each
11386 # particle's own start. Any other seeding leaves that point unset - the coordinate
11387 # origin - and the statistic measures distance from the origin instead. With no
11388 # particles the task writes nothing, so only a seeded case is refused.
11389 if case_cfg is not None and "ComputeMSD" in get_post_statistics_task_tokens(post_cfg):
11390 particles_cfg = (((case_cfg.get('models') or {}).get('physics') or {}).get('particles') or {})
11391 try:
11392 particle_count = int(particles_cfg.get('count', 0) or 0)
11393 except (TypeError, ValueError):
11394 particle_count = 0
11395 init_mode = particles_cfg.get('init_mode', 'Surface')
11396 try:
11397 is_point_source = normalize_particle_init_mode(init_mode) == 2
11398 except ValueError:
11399 is_point_source = True # the case file reports its own init_mode error
11400 if particle_count > 0 and not is_point_source:
11401 errors.append(
11402 f" {post_path}: statistics task 'msd' measures displacement from "
11403 f"models.physics.particles.point_source, so it requires init_mode: PointSource "
11404 f"(the case seeds with '{init_mode}')."
11405 )
11406
11407 legacy_stats_output_prefix = post_cfg.get('statistics_output_prefix')
11408 if legacy_stats_output_prefix is not None and not isinstance(legacy_stats_output_prefix, str):
11409 errors.append(f" {post_path}: 'statistics_output_prefix' must be a string when provided.")
11410
11411 # Runs last so it reports against values the run_control checks above have
11412 # already established are integers.
11413 cadence_errors, cadence_warnings = check_post_checkpoint_cadence_alignment(
11414 post_cfg, monitor_cfg, post_path)
11415 errors.extend(cadence_errors)
11416 warnings.extend(cadence_warnings)
11417
11418 if errors:
11419 _print_validation_errors(errors)
11420 for warning in warnings:
11421 print(f"[WARNING] {warning}", file=sys.stderr)
11422
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_cluster_config()

picurv_cli.core.validate_cluster_config ( dict  cluster_cfg,
str  cluster_path 
)

Validate Slurm scheduler configuration from cluster.yml.

Parameters
[in]cluster_cfgArgument passed to validate_cluster_config().
[in]cluster_pathArgument passed to validate_cluster_config().

Definition at line 11423 of file core.py.

11423def validate_cluster_config(cluster_cfg: dict, cluster_path: str):
11424 """!
11425 @brief Validate Slurm scheduler configuration from cluster.yml.
11426 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
11427 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
11428 """
11429 errors = []
11430 warnings = []
11431 _validate_yaml_schema_keys(cluster_cfg, _CLUSTER_SCHEMA, cluster_path, errors)
11432 if not isinstance(cluster_cfg, dict) or not cluster_cfg:
11433 errors.append(f" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
11434 _print_validation_errors(errors)
11435
11436 scheduler = cluster_cfg.get("scheduler", {})
11437 if not isinstance(scheduler, dict):
11438 errors.append(f" {cluster_path}: 'scheduler' must be a mapping.")
11439 else:
11440 scheduler_type = scheduler.get("type", "slurm")
11441 if str(scheduler_type).lower() != "slurm":
11442 errors.append(f" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
11443
11444 resources = cluster_cfg.get("resources", {})
11445 if not isinstance(resources, dict):
11446 errors.append(f" {cluster_path}: 'resources' must be a mapping.")
11447 else:
11448 for req in ("account", "nodes", "ntasks_per_node", "mem", "time"):
11449 if req not in resources:
11450 errors.append(f" {cluster_path}: missing required key 'resources.{req}'.")
11451 for int_key in ("nodes", "ntasks_per_node"):
11452 if int_key in resources:
11453 val = resources.get(int_key)
11454 if not isinstance(val, int) or val <= 0:
11455 errors.append(f" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
11456 for str_key in ("account", "mem", "time", "partition"):
11457 if str_key in resources and resources.get(str_key) is not None:
11458 if not isinstance(resources.get(str_key), str):
11459 errors.append(f" {cluster_path}: resources.{str_key} must be a string when provided.")
11460 if isinstance(resources.get("time"), str):
11461 try:
11462 parse_slurm_time_limit_to_seconds(resources["time"])
11463 except ValueError as exc:
11464 errors.append(
11465 f" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
11466 )
11467 account = resources.get("account")
11468 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
11469 warnings.append(
11470 f"{cluster_path}: resources.account still uses the sample placeholder "
11471 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
11472 )
11473
11474 notifications = cluster_cfg.get("notifications", {})
11475 if notifications is not None and not isinstance(notifications, dict):
11476 errors.append(f" {cluster_path}: 'notifications' must be a mapping when provided.")
11477 elif isinstance(notifications, dict):
11478 mail_user = notifications.get("mail_user")
11479 if mail_user is not None and not is_valid_email(mail_user):
11480 errors.append(f" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
11481 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
11482 warnings.append(
11483 f"{cluster_path}: notifications.mail_user still uses the sample placeholder "
11484 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
11485 )
11486 mail_type = notifications.get("mail_type")
11487 if mail_type is not None and not isinstance(mail_type, str):
11488 errors.append(f" {cluster_path}: notifications.mail_type must be a string when provided.")
11489
11490 execution = cluster_cfg.get("execution", {})
11491 if execution is not None and not isinstance(execution, dict):
11492 errors.append(f" {cluster_path}: 'execution' must be a mapping when provided.")
11493 elif isinstance(execution, dict):
11494 module_setup = execution.get("module_setup", [])
11495 if module_setup is not None and not isinstance(module_setup, list):
11496 errors.append(f" {cluster_path}: execution.module_setup must be a list of shell lines.")
11497 elif isinstance(module_setup, list):
11498 for i, line in enumerate(module_setup):
11499 if not isinstance(line, str):
11500 errors.append(f" {cluster_path}: execution.module_setup[{i}] must be a string.")
11501
11502 launcher = execution.get("launcher")
11503 if launcher is not None and not isinstance(launcher, str):
11504 errors.append(f" {cluster_path}: execution.launcher must be a string when provided.")
11505 launcher_args = execution.get("launcher_args")
11506 if launcher_args is not None and not isinstance(launcher_args, list):
11507 errors.append(f" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
11508 elif isinstance(launcher_args, list):
11509 for i, token in enumerate(launcher_args):
11510 if not isinstance(token, (str, int, float)):
11511 errors.append(f" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
11512 elif _launcher_arg_contains_whitespace(token):
11513 errors.append(
11514 f" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
11515 "split whitespace-separated arguments into separate list items."
11516 )
11517 if (launcher is None or isinstance(launcher, str)) and (launcher_args is None or isinstance(launcher_args, list)):
11518 try:
11519 normalize_cluster_launcher(execution)
11520 except ValueError as exc:
11521 errors.append(f" {cluster_path}: {exc}.")
11522
11523 extra_sbatch = execution.get("extra_sbatch")
11524 if extra_sbatch is not None and not isinstance(extra_sbatch, (dict, list)):
11525 errors.append(f" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
11526
11527 walltime_guard = execution.get("walltime_guard")
11528 if walltime_guard is not None and not isinstance(walltime_guard, dict):
11529 errors.append(f" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
11530 elif isinstance(walltime_guard, dict):
11531 enabled = walltime_guard.get("enabled")
11532 if enabled is not None and not isinstance(enabled, bool):
11533 errors.append(f" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
11534
11535 warmup_steps = walltime_guard.get("warmup_steps")
11536 if warmup_steps is not None and (not isinstance(warmup_steps, int) or isinstance(warmup_steps, bool) or warmup_steps <= 0):
11537 errors.append(
11538 f" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
11539 )
11540
11541 multiplier = walltime_guard.get("multiplier")
11542 if multiplier is not None:
11543 if isinstance(multiplier, bool) or not isinstance(multiplier, (int, float)) or multiplier <= 0.0:
11544 errors.append(
11545 f" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
11546 )
11547 elif float(multiplier) > 5.0:
11548 errors.append(
11549 f" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
11550 )
11551
11552 min_seconds = walltime_guard.get("min_seconds")
11553 if min_seconds is not None and (
11554 isinstance(min_seconds, bool) or not isinstance(min_seconds, (int, float)) or float(min_seconds) <= 0.0
11555 ):
11556 errors.append(
11557 f" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
11558 )
11559
11560 estimator_alpha = walltime_guard.get("estimator_alpha")
11561 if estimator_alpha is not None:
11562 if isinstance(estimator_alpha, bool) or not isinstance(estimator_alpha, (int, float)):
11563 errors.append(
11564 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
11565 )
11566 elif float(estimator_alpha) <= 0.0 or float(estimator_alpha) > 1.0:
11567 errors.append(
11568 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
11569 )
11570
11571 if warnings:
11572 for warning in warnings:
11573 print(f"[WARNING] {warning}", file=sys.stderr)
11574
11575 if errors:
11576 _print_validation_errors(errors)
11577
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_study_config()

picurv_cli.core.validate_study_config ( dict  study_cfg,
str  study_path,
bool   skip_base_file_check = False 
)

Validate sweep/study specification from study.yml.

Parameters
[in]study_cfgArgument passed to validate_study_config().
[in]study_pathArgument passed to validate_study_config().
[in]skip_base_file_checkWhen True, skip file-existence check for base_configs paths.

Definition at line 11578 of file core.py.

11578def validate_study_config(study_cfg: dict, study_path: str, skip_base_file_check: bool = False):
11579 """!
11580 @brief Validate sweep/study specification from study.yml.
11581 @param[in] study_cfg Argument passed to `validate_study_config()`.
11582 @param[in] study_path Argument passed to `validate_study_config()`.
11583 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
11584 """
11585 errors = []
11586 _validate_yaml_schema_keys(study_cfg, _STUDY_SCHEMA, study_path, errors)
11587 if not isinstance(study_cfg, dict) or not study_cfg:
11588 errors.append(f" {study_path}: study config is empty or not a valid YAML mapping.")
11589 _print_validation_errors(errors)
11590
11591 base_cfgs = study_cfg.get("base_configs")
11592 if not isinstance(base_cfgs, dict):
11593 errors.append(f" {study_path}: missing required mapping 'base_configs'.")
11594 else:
11595 for req in ("case", "solver", "monitor", "post"):
11596 path_val = base_cfgs.get(req)
11597 if not path_val or not isinstance(path_val, str):
11598 errors.append(f" {study_path}: base_configs.{req} must be a path string.")
11599 elif not skip_base_file_check:
11600 resolved = resolve_path(study_path, path_val)
11601 if not os.path.isfile(resolved):
11602 errors.append(f" {study_path}: base_configs.{req} does not exist: {resolved}")
11603
11604 study_type = study_cfg.get("study_type")
11605 allowed_types = set(STUDY_TYPES)
11606 if study_type not in allowed_types:
11607 errors.append(
11608 f" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
11609 )
11610
11611 parameters = study_cfg.get("parameters")
11612 parameter_sets = study_cfg.get("parameter_sets")
11613 allowed_roots = {"case", "solver", "monitor", "post"}
11614 if bool(parameters) == bool(parameter_sets):
11615 errors.append(f" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
11616 elif parameter_sets:
11617 if not isinstance(parameter_sets, list) or not parameter_sets:
11618 errors.append(f" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
11619 else:
11620 for set_index, param_set in enumerate(parameter_sets):
11621 if not isinstance(param_set, dict) or not param_set:
11622 errors.append(
11623 f" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
11624 )
11625 continue
11626 for key, value in param_set.items():
11627 if not isinstance(key, str) or "." not in key:
11628 errors.append(
11629 f" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
11630 )
11631 continue
11632 root = key.split(".", 1)[0]
11633 if root not in allowed_roots:
11634 errors.append(
11635 f" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
11636 )
11637 if isinstance(value, (dict, list)):
11638 errors.append(
11639 f" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
11640 )
11641 else:
11642 if not isinstance(parameters, dict) or not parameters:
11643 errors.append(f" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
11644 else:
11645 for key, values in parameters.items():
11646 if not isinstance(key, str) or "." not in key:
11647 errors.append(
11648 f" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
11649 )
11650 continue
11651 root = key.split(".", 1)[0]
11652 if root not in allowed_roots:
11653 errors.append(
11654 f" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
11655 )
11656 if not isinstance(values, list) or len(values) == 0:
11657 errors.append(f" {study_path}: parameters.{key} must be a non-empty list.")
11658
11659 metrics = study_cfg.get("metrics", [])
11660 if metrics is not None and not isinstance(metrics, list):
11661 errors.append(f" {study_path}: 'metrics' must be a list when provided.")
11662 elif isinstance(metrics, list):
11663 for i, metric in enumerate(metrics):
11664 if isinstance(metric, str):
11665 continue
11666 if not isinstance(metric, dict):
11667 errors.append(
11668 f" {study_path}: metrics[{i}] must be a string or mapping."
11669 )
11670 continue
11671 if "name" not in metric:
11672 errors.append(f" {study_path}: metrics[{i}] missing required key 'name'.")
11673 if "source" not in metric:
11674 errors.append(f" {study_path}: metrics[{i}] missing required key 'source'.")
11675 if str(metric.get("source", "")).strip().lower() in METRIC_SOURCE_KINDS[2:]:
11676 # extract_metric_from_log reads group(1); without it every case scores empty.
11677 regex = metric.get("regex")
11678 try:
11679 has_group = isinstance(regex, str) and re.compile(regex).groups >= 1
11680 except re.error:
11681 has_group = False
11682 if not has_group:
11683 errors.append(
11684 f" {study_path}: metrics[{i}].regex must be a valid regular expression "
11685 "with a capture group for the value when source is a log."
11686 )
11687 for label_key in ("plot_label", "label", "units"):
11688 label_value = metric.get(label_key)
11689 if label_value is not None and (not isinstance(label_value, str) or not label_value.strip()):
11690 errors.append(
11691 f" {study_path}: metrics[{i}].{label_key} must be a non-empty string when provided."
11692 )
11693
11694 plotting = study_cfg.get("plotting", {})
11695 if plotting is not None and not isinstance(plotting, dict):
11696 errors.append(f" {study_path}: 'plotting' must be a mapping when provided.")
11697 elif isinstance(plotting, dict):
11698 enabled = plotting.get("enabled")
11699 if enabled is not None and not isinstance(enabled, bool):
11700 errors.append(f" {study_path}: plotting.enabled must be boolean when provided.")
11701 output_format = plotting.get("output_format")
11702 if output_format is not None and output_format not in STUDY_PLOT_FORMATS:
11703 errors.append(f" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
11704
11705 execution = study_cfg.get("execution", {})
11706 if execution is not None and not isinstance(execution, dict):
11707 errors.append(f" {study_path}: 'execution' must be a mapping when provided.")
11708 elif isinstance(execution, dict):
11709 max_conc = execution.get("max_concurrent_array_tasks")
11710 if max_conc is not None and (not isinstance(max_conc, int) or max_conc <= 0):
11711 errors.append(
11712 f" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
11713 )
11714
11715 if errors:
11716 _print_validation_errors(errors)
11717
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _deep_set()

picurv_cli.core._deep_set ( dict  container,
str  dotted_path,
  value 
)
protected

Set nested dictionary value, creating intermediate maps when needed.

Parameters
[in]containerArgument passed to _deep_set().
[in]dotted_pathArgument passed to _deep_set().
[in]valueArgument passed to _deep_set().

Definition at line 11718 of file core.py.

11718def _deep_set(container: dict, dotted_path: str, value):
11719 """!
11720 @brief Set nested dictionary value, creating intermediate maps when needed.
11721 @param[in] container Argument passed to `_deep_set()`.
11722 @param[in] dotted_path Argument passed to `_deep_set()`.
11723 @param[in] value Argument passed to `_deep_set()`.
11724 """
11725 keys = dotted_path.split(".")
11726 current = container
11727 for key in keys[:-1]:
11728 if key not in current or not isinstance(current[key], dict):
11729 current[key] = {}
11730 current = current[key]
11731 current[keys[-1]] = value
11732
Here is the caller graph for this function:

◆ expand_parameter_matrix()

list picurv_cli.core.expand_parameter_matrix ( dict  parameters)

Expand study parameter lists into cartesian-product combinations.

Parameters
[in]parametersArgument passed to expand_parameter_matrix().
Returns
Value returned by expand_parameter_matrix().

Definition at line 11733 of file core.py.

11733def expand_parameter_matrix(parameters: dict) -> list:
11734 """!
11735 @brief Expand study parameter lists into cartesian-product combinations.
11736 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
11737 @return Value returned by `expand_parameter_matrix()`.
11738 """
11739 param_keys = list(parameters.keys())
11740 all_values = [parameters[k] for k in param_keys]
11741 combos = []
11742 for combo in itertools.product(*all_values):
11743 combos.append(dict(zip(param_keys, combo)))
11744 return combos
11745
11746
Here is the caller graph for this function:

◆ expand_study_parameter_combinations()

list picurv_cli.core.expand_study_parameter_combinations ( dict  study_cfg)

Expand either cartesian-study parameters or explicit parameter sets.

Parameters
[in]study_cfgArgument passed to expand_study_parameter_combinations().
Returns
Value returned by expand_study_parameter_combinations().

Definition at line 11747 of file core.py.

11747def expand_study_parameter_combinations(study_cfg: dict) -> list:
11748 """!
11749 @brief Expand either cartesian-study parameters or explicit parameter sets.
11750 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
11751 @return Value returned by `expand_study_parameter_combinations()`.
11752 """
11753 parameter_sets = study_cfg.get("parameter_sets")
11754 if parameter_sets:
11755 return [dict(param_set) for param_set in parameter_sets]
11756 return expand_parameter_matrix(study_cfg.get("parameters") or {})
11757
11758
Here is the call graph for this function:
Here is the caller graph for this function:

◆ flatten_study_parameters()

dict picurv_cli.core.flatten_study_parameters ( dict  parameters)

Flatten grouped study overrides into scalar dotted-path columns.

A grouped override such as case.run_control: {dt_physical: ...} materializes correctly in case YAML but is not a useful CSV cell or plot coordinate. Flattening preserves the actual varied variables.

Parameters
[in]parametersOne expanded study parameter combination.
Returns
Flat dotted-path-to-scalar mapping.

Definition at line 11759 of file core.py.

11759def flatten_study_parameters(parameters: dict) -> dict:
11760 """!
11761 @brief Flatten grouped study overrides into scalar dotted-path columns.
11762
11763 @details A grouped override such as `case.run_control: {dt_physical: ...}`
11764 materializes correctly in case YAML but is not a useful CSV cell or
11765 plot coordinate. Flattening preserves the actual varied variables.
11766
11767 @param[in] parameters One expanded study parameter combination.
11768 @return Flat dotted-path-to-scalar mapping.
11769 """
11770 flattened = {}
11771
11772 def visit(prefix, value):
11773 """!
11774 @brief Recursively flatten one grouped override value.
11775 @param[in] prefix Current dotted parameter path.
11776 @param[in] value Scalar or nested mapping at the current path.
11777 """
11778 if isinstance(value, dict):
11779 for child, child_value in value.items():
11780 visit(f"{prefix}.{child}" if prefix else str(child), child_value)
11781 else:
11782 flattened[prefix] = value
11783
11784 for key, value in (parameters or {}).items():
11785 visit(str(key), value)
11786 return flattened
11787
11788
Here is the caller graph for this function:

◆ get_study_parameter_keys()

list picurv_cli.core.get_study_parameter_keys ( dict  study_cfg)

Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.

Parameters
[in]study_cfgArgument passed to get_study_parameter_keys().
Returns
Value returned by get_study_parameter_keys().

Definition at line 11789 of file core.py.

11789def get_study_parameter_keys(study_cfg: dict) -> list:
11790 """!
11791 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
11792 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
11793 @return Value returned by `get_study_parameter_keys()`.
11794 """
11795 parameters = study_cfg.get("parameters")
11796 if isinstance(parameters, dict) and parameters:
11797 keys = []
11798 for key, candidates in parameters.items():
11799 candidate_dicts = [value for value in candidates if isinstance(value, dict)] if isinstance(candidates, list) else []
11800 expanded = []
11801 for value in candidate_dicts:
11802 for flat_key in flatten_study_parameters({key: value}):
11803 if flat_key not in expanded:
11804 expanded.append(flat_key)
11805 for flat_key in expanded or [key]:
11806 if flat_key not in keys:
11807 keys.append(flat_key)
11808 return keys
11809
11810 keys = []
11811 parameter_sets = study_cfg.get("parameter_sets") or []
11812 for param_set in parameter_sets:
11813 if not isinstance(param_set, dict):
11814 continue
11815 for key in flatten_study_parameters(param_set):
11816 if key not in keys:
11817 keys.append(key)
11818 return keys
11819
11820
Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_cluster_total_tasks()

int picurv_cli.core.get_cluster_total_tasks ( dict  cluster_cfg)

Return cluster total tasks.

Parameters
[in]cluster_cfgArgument passed to get_cluster_total_tasks().
Returns
Value returned by get_cluster_total_tasks().

Definition at line 11821 of file core.py.

11821def get_cluster_total_tasks(cluster_cfg: dict) -> int:
11822 """!
11823 @brief Return cluster total tasks.
11824 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
11825 @return Value returned by `get_cluster_total_tasks()`.
11826 """
11827 resources = cluster_cfg.get("resources", {})
11828 return int(resources.get("nodes", 1)) * int(resources.get("ntasks_per_node", 1))
11829
Here is the caller graph for this function:

◆ normalize_extension()

str picurv_cli.core.normalize_extension ( str  ext)

Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.

Parameters
[in]extArgument passed to normalize_extension().
Returns
Value returned by normalize_extension().

Definition at line 11830 of file core.py.

11830def normalize_extension(ext: str) -> str:
11831 """!
11832 @brief Canonicalize a user-supplied filename extension by trimming whitespace and leading dots.
11833 @param[in] ext Argument passed to `normalize_extension()`.
11834 @return Value returned by `normalize_extension()`.
11835 """
11836 if ext is None:
11837 return None
11838 return str(ext).strip().lstrip(".")
11839

◆ resolve_conductor_entry_point()

list picurv_cli.core.resolve_conductor_entry_point ( )

Resolve how to invoke this conductor again from a batch script.

Prefers the bin/picurv wrapper, which selects the managed Python environment a cluster node needs, and falls back to the running interpreter with the package entry point when that wrapper is absent.

Returns
Argv prefix that re-invokes the conductor.

Definition at line 11840 of file core.py.

11840def resolve_conductor_entry_point() -> list:
11841 """!
11842 @brief Resolve how to invoke this conductor again from a batch script.
11843
11844 @details Prefers the `bin/picurv` wrapper, which selects the managed Python
11845 environment a cluster node needs, and falls back to the running
11846 interpreter with the package entry point when that wrapper is absent.
11847
11848 @return Argv prefix that re-invokes the conductor.
11849 """
11850 wrapper = os.path.join(PACKAGE_PROJECT_ROOT, "bin", "picurv")
11851 if os.path.isfile(wrapper) and os.access(wrapper, os.X_OK):
11852 return [wrapper]
11853 return [sys.executable, os.path.join(PACKAGE_PROJECT_ROOT, "picurv_cli", "picurv")]
11854
11855
Here is the caller graph for this function:

◆ build_spectra_follow_command()

list picurv_cli.core.build_spectra_follow_command ( str  run_dir,
str  post_path,
dict  post_cfg,
bool   recompute = False 
)

Build the batch-script step that measures spectra after the field stage.

Spectra are a serial pass over committed checkpoints, so the command is returned bare rather than wrapped in the MPI launcher: run under srun with the post stage's task count it would become one identical copy per task, each writing the same files.

Parameters
[in]run_dirRun directory the batch job operates on.
[in]post_pathPost recipe path, reachable from the compute node.
[in]post_cfgEffective post configuration.
[in]recomputeRe-measure steps whose spectra are still valid.
Returns
Argv list, or an empty list when the recipe requests no spectra.

Definition at line 11856 of file core.py.

11857 recompute: bool = False) -> list:
11858 """!
11859 @brief Build the batch-script step that measures spectra after the field stage.
11860
11861 @details Spectra are a serial pass over committed checkpoints, so the command is
11862 returned bare rather than wrapped in the MPI launcher: run under `srun`
11863 with the post stage's task count it would become one identical copy per
11864 task, each writing the same files.
11865
11866 @param[in] run_dir Run directory the batch job operates on.
11867 @param[in] post_path Post recipe path, reachable from the compute node.
11868 @param[in] post_cfg Effective post configuration.
11869 @param[in] recompute Re-measure steps whose spectra are still valid.
11870 @return Argv list, or an empty list when the recipe requests no spectra.
11871 """
11872 try:
11873 spectra = normalize_post_spectra_config(post_cfg)
11874 except ValueError:
11875 # Validation reports recipe errors; do not raise a second time here.
11876 return []
11877 if not spectra["tasks"]:
11878 return []
11879 return resolve_conductor_entry_point() + [
11880 "run", "--post-process", "--only", "spectra",
11881 "--run-dir", os.path.abspath(run_dir),
11882 "--post", os.path.abspath(post_path),
11883 ] + (["--recompute"] if recompute else [])
11884
11885
Here is the call graph for this function:
Here is the caller graph for this function:

◆ executable_petsc_library_dir()

"str | None" picurv_cli.core.executable_petsc_library_dir ( str  executable_path)

The lib/ directory of the PETSc an executable was built against, from its stamp.

Both PETSc index widths name their library libpetsc.so.MAJOR.MINOR, so the loader takes whichever comes first on LD_LIBRARY_PATH; launching with this directory first gives each executable its own PETSc whatever modules the job loaded.

Parameters
[in]executable_pathPath to simulator, postprocessor or their 64-bit pair.
Returns
Absolute directory, or None when the stamp does not name one.

Definition at line 11886 of file core.py.

11886def executable_petsc_library_dir(executable_path: str) -> "str | None":
11887 """!
11888 @brief The lib/ directory of the PETSc an executable was built against, from its stamp.
11889 @details Both PETSc index widths name their library libpetsc.so.MAJOR.MINOR, so the loader
11890 takes whichever comes first on LD_LIBRARY_PATH; launching with this directory first
11891 gives each executable its own PETSc whatever modules the job loaded.
11892 @param[in] executable_path Path to `simulator`, `postprocessor` or their 64-bit pair.
11893 @return Absolute directory, or None when the stamp does not name one.
11894 """
11895 petsc = read_binary_petsc_stamp(executable_path) if os.path.isfile(executable_path) else None
11896 if not petsc or not petsc.get("dir"):
11897 return None
11898 library_dir = os.path.join(petsc["dir"], petsc.get("arch") or "", "lib")
11899 return os.path.normpath(library_dir) if os.path.isdir(library_dir) else None
11900
11901
Here is the call graph for this function:
Here is the caller graph for this function:

◆ petsc_library_path_lines()

list picurv_cli.core.petsc_library_path_lines ( "str | None"  library_dir)

Shell lines putting one PETSc's lib/ first on LD_LIBRARY_PATH, after module setup.

Parameters
[in]library_dirFrom executable_petsc_library_dir(), or None for no change.
Returns
Script lines.

Definition at line 11902 of file core.py.

11902def petsc_library_path_lines(library_dir: "str | None") -> list:
11903 """!
11904 @brief Shell lines putting one PETSc's lib/ first on LD_LIBRARY_PATH, after module setup.
11905 @param[in] library_dir From `executable_petsc_library_dir()`, or None for no change.
11906 @return Script lines.
11907 """
11908 if not library_dir:
11909 return []
11910 return [f'export LD_LIBRARY_PATH={shlex.quote(library_dir)}"${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}"']
11911
11912
Here is the caller graph for this function:

◆ executable_identity_check_lines()

list picurv_cli.core.executable_identity_check_lines ( str  executable,
"str | None"  expected_version_line,
bool   pinned = False 
)

Shell lines that compare an executable's reported identity with the staged one.

A job resolves its executable when it starts, which can be long after it was staged. The installation's bin/ may have been rebuilt in between, and a job that silently runs the new build produces checkpoints that disagree with the run's manifest. The lines run after module setup, because --version still needs the executable's shared libraries, and before the launcher, so a mismatch stops the job before any rank starts. When staging could not read an identity there is nothing to compare, so the reported one is only logged.

Parameters
[in]executableExecutable path, emitted verbatim when it is a shell variable.
[in]expected_version_lineExact --version output captured at staging, or None.
[in]pinnedWhether the executable is the run's own pinned copy.
Returns
Script lines.

Definition at line 11913 of file core.py.

11914 pinned: bool = False) -> list:
11915 """!
11916 @brief Shell lines that compare an executable's reported identity with the staged one.
11917
11918 @details A job resolves its executable when it starts, which can be long after it
11919 was staged. The installation's `bin/` may have been rebuilt in between, and
11920 a job that silently runs the new build produces checkpoints that disagree
11921 with the run's manifest. The lines run after module setup, because
11922 `--version` still needs the executable's shared libraries, and before the
11923 launcher, so a mismatch stops the job before any rank starts. When staging
11924 could not read an identity there is nothing to compare, so the reported one
11925 is only logged.
11926 @param[in] executable Executable path, emitted verbatim when it is a shell variable.
11927 @param[in] expected_version_line Exact `--version` output captured at staging, or None.
11928 @param[in] pinned Whether the executable is the run's own pinned copy.
11929 @return Script lines.
11930 """
11931 token = executable if executable.startswith("$") else shlex.quote(executable)
11932 lines = [
11933 f'PICURV_EXECUTABLE_IDENTITY=$({token} --version 2>&1) || {{ '
11934 f'echo "[FATAL] {executable} --version failed: ${{PICURV_EXECUTABLE_IDENTITY}}" >&2; exit 1; }}',
11935 'echo "[$(date)] Executable identity: ${PICURV_EXECUTABLE_IDENTITY}"',
11936 ]
11937 if not expected_version_line:
11938 lines.append('echo "[$(date)] No identity was readable at staging, so none is compared."')
11939 return lines
11940 remedy = (
11941 "The run's pinned copy was replaced after staging; restore it or re-pin the run."
11942 if pinned else
11943 "It was rebuilt after staging; restage the job, or stage with --pin-executables so "
11944 "the run keeps its own copy."
11945 )
11946 lines.extend([
11947 f"PICURV_STAGED_IDENTITY={shlex.quote(expected_version_line)}",
11948 'if [ "${PICURV_EXECUTABLE_IDENTITY}" != "${PICURV_STAGED_IDENTITY}" ]; then',
11949 f' echo "[FATAL] {executable} reports ${{PICURV_EXECUTABLE_IDENTITY}}, but this job was '
11950 f'staged against ${{PICURV_STAGED_IDENTITY}}. {remedy}" >&2',
11951 " exit 1",
11952 "fi",
11953 ])
11954 return lines
11955
11956
Here is the caller graph for this function:

◆ render_slurm_script()

picurv_cli.core.render_slurm_script ( str  script_path,
str  job_name,
dict  cluster_cfg,
list  command,
str  workdir,
str  stdout_path,
str   stderr_path = None,
dict   env_vars = None,
dict   shell_env_vars = None,
str   array_spec = None,
list   follow_commands = None,
"tuple | None"   identity_check = None 
)

Render a Slurm batch script for a single command.

Parameters
[in]script_pathArgument passed to render_slurm_script().
[in]job_nameArgument passed to render_slurm_script().
[in]cluster_cfgArgument passed to render_slurm_script().
[in]commandArgument passed to render_slurm_script().
[in]workdirArgument passed to render_slurm_script().
[in]stdout_pathArgument passed to render_slurm_script().
[in]stderr_pathArgument passed to render_slurm_script().
[in]env_varsArgument passed to render_slurm_script().
[in]shell_env_varsArgument passed to render_slurm_script().
[in]array_specArgument passed to render_slurm_script().
[in]follow_commandsCommands to run after the launched one, each an argv list. They run in the batch shell rather than under the MPI launcher, so a serial step does not become one copy per task. Supplying any of them drops the exec.
[in]identity_check(executable, expected --version line, pinned) checked before launch; see executable_identity_check_lines().

Definition at line 11957 of file core.py.

11970):
11971 """!
11972 @brief Render a Slurm batch script for a single command.
11973 @param[in] script_path Argument passed to `render_slurm_script()`.
11974 @param[in] job_name Argument passed to `render_slurm_script()`.
11975 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
11976 @param[in] command Argument passed to `render_slurm_script()`.
11977 @param[in] workdir Argument passed to `render_slurm_script()`.
11978 @param[in] stdout_path Argument passed to `render_slurm_script()`.
11979 @param[in] stderr_path Argument passed to `render_slurm_script()`.
11980 @param[in] env_vars Argument passed to `render_slurm_script()`.
11981 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
11982 @param[in] array_spec Argument passed to `render_slurm_script()`.
11983 @param[in] follow_commands Commands to run after the launched one, each an argv
11984 list. They run in the batch shell rather than under the
11985 MPI launcher, so a serial step does not become one copy
11986 per task. Supplying any of them drops the `exec`.
11987 @param[in] identity_check `(executable, expected --version line, pinned)` checked
11988 before launch; see `executable_identity_check_lines()`.
11989 """
11990 resources = cluster_cfg.get("resources", {})
11991 notifications = cluster_cfg.get("notifications", {}) or {}
11992 execution = cluster_cfg.get("execution", {}) or {}
11993 extra_sbatch = execution.get("extra_sbatch")
11994 module_setup = execution.get("module_setup", []) or []
11995
11996 if stderr_path is None:
11997 stderr_path = stdout_path.replace(".out", ".err")
11998
11999 lines = [
12000 "#!/bin/bash",
12001 f"#SBATCH --job-name={job_name}",
12002 f"#SBATCH --nodes={resources['nodes']}",
12003 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
12004 f"#SBATCH --mem={resources['mem']}",
12005 f"#SBATCH --time={resources['time']}",
12006 f"#SBATCH --output={stdout_path}",
12007 f"#SBATCH --error={stderr_path}",
12008 f"#SBATCH --account={resources['account']}",
12009 ]
12010 partition = resources.get("partition")
12011 if partition:
12012 lines.append(f"#SBATCH --partition={partition}")
12013 if array_spec:
12014 lines.append(f"#SBATCH --array={array_spec}")
12015 mail_user = notifications.get("mail_user")
12016 mail_type = notifications.get("mail_type")
12017 if mail_user:
12018 lines.append(f"#SBATCH --mail-user={mail_user}")
12019 if mail_type:
12020 lines.append(f"#SBATCH --mail-type={mail_type}")
12021
12022 if isinstance(extra_sbatch, dict):
12023 for key, value in extra_sbatch.items():
12024 flag = str(key)
12025 if not flag.startswith("--"):
12026 flag = f"--{flag}"
12027 if isinstance(value, bool):
12028 if value:
12029 lines.append(f"#SBATCH {flag}")
12030 elif value is not None:
12031 lines.append(f"#SBATCH {flag}={value}")
12032 elif isinstance(extra_sbatch, list):
12033 for token in extra_sbatch:
12034 lines.append(f"#SBATCH {token}")
12035
12036 lines.extend(
12037 [
12038 "",
12039 "set -euo pipefail",
12040 "",
12041 f"cd {shlex.quote(workdir)}",
12042 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
12043 'echo "[$(date)] Working directory: $PWD"',
12044 ]
12045 )
12046
12047 if shell_env_vars:
12048 for key, value in shell_env_vars.items():
12049 lines.append(f"export {key}={value}")
12050
12051 for setup_line in module_setup:
12052 lines.append(str(setup_line))
12053
12054 if env_vars:
12055 for key, value in env_vars.items():
12056 lines.append(f"export {key}={shlex.quote(str(value))}")
12057
12058 if identity_check:
12059 lines.extend(petsc_library_path_lines(executable_petsc_library_dir(identity_check[0])))
12060 lines.extend(executable_identity_check_lines(*identity_check))
12061
12062 cmd = " ".join(shlex.quote(str(tok)) for tok in command)
12063 if follow_commands:
12064 # `exec` would replace the shell and the trailing commands would never run.
12065 # `set -e` is already in effect, so a failure in the launched command aborts
12066 # the job before anything downstream of it executes.
12067 lines.append(cmd)
12068 for follow in follow_commands:
12069 lines.append(" ".join(shlex.quote(str(tok)) for tok in follow))
12070 else:
12071 lines.append(f"exec {cmd}")
12072
12073 os.makedirs(os.path.dirname(script_path), exist_ok=True)
12074 with open(script_path, "w") as f:
12075 f.write("\n".join(lines) + "\n")
12076 os.chmod(script_path, 0o755)
12077
Here is the call graph for this function:
Here is the caller graph for this function:

◆ split_launcher_tokens()

"tuple[str | None, list[str]]" picurv_cli.core.split_launcher_tokens ( "str | None"  launcher,
"list | None"   launcher_args = None,
str   label = "launcher" 
)

Canonicalize launcher config into executable token plus argv-style flags.

Parameters
[in]launcherArgument passed to split_launcher_tokens().
[in]launcher_argsArgument passed to split_launcher_tokens().
[in]labelArgument passed to split_launcher_tokens().
Returns
Value returned by split_launcher_tokens().

Definition at line 12078 of file core.py.

12082) -> "tuple[str | None, list[str]]":
12083 """!
12084 @brief Canonicalize launcher config into executable token plus argv-style flags.
12085 @param[in] launcher Argument passed to `split_launcher_tokens()`.
12086 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
12087 @param[in] label Argument passed to `split_launcher_tokens()`.
12088 @return Value returned by `split_launcher_tokens()`.
12089 """
12090 normalized_args = [str(x) for x in (launcher_args or [])]
12091
12092 if launcher is None:
12093 return None, normalized_args
12094
12095 try:
12096 launcher_tokens = shlex.split(str(launcher))
12097 except ValueError as exc:
12098 raise ValueError(f"{label} is not shell-parseable: {exc}") from exc
12099
12100 if not launcher_tokens:
12101 return None, normalized_args
12102
12103 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
12104
12105
Here is the caller graph for this function:

◆ normalize_cluster_launcher()

"tuple[str | None, list[str]]" picurv_cli.core.normalize_cluster_launcher ( dict  execution)

Canonicalize cluster launcher config into executable token plus argv-style flags.

Parameters
[in]executionArgument passed to normalize_cluster_launcher().
Returns
Value returned by normalize_cluster_launcher().

Definition at line 12106 of file core.py.

12106def normalize_cluster_launcher(execution: dict) -> "tuple[str | None, list[str]]":
12107 """!
12108 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
12109 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
12110 @return Value returned by `normalize_cluster_launcher()`.
12111 """
12112 return split_launcher_tokens(
12113 execution.get("launcher"),
12114 execution.get("launcher_args") or [],
12115 label="execution.launcher",
12116 )
12117
12118
Here is the call graph for this function:
Here is the caller graph for this function:

◆ strip_launcher_size_flags()

"list[str]" picurv_cli.core.strip_launcher_size_flags ( str  launcher_name,
"list[str]"  launcher_args 
)

Remove explicit MPI task-count flags from known launchers.

Parameters
[in]launcher_nameBasename-normalized launcher executable.
[in]launcher_argsLauncher argument list.
Returns
Filtered launcher arguments with explicit size flags removed.

Definition at line 12119 of file core.py.

12119def strip_launcher_size_flags(launcher_name: str, launcher_args: "list[str]") -> "list[str]":
12120 """!
12121 @brief Remove explicit MPI task-count flags from known launchers.
12122 @param[in] launcher_name Basename-normalized launcher executable.
12123 @param[in] launcher_args Launcher argument list.
12124 @return Filtered launcher arguments with explicit size flags removed.
12125 """
12126 filtered = []
12127 idx = 0
12128 while idx < len(launcher_args):
12129 token = str(launcher_args[idx])
12130
12131 if launcher_name == "srun":
12132 if token in {"-n", "--ntasks"}:
12133 idx += 2
12134 continue
12135 if token.startswith("--ntasks="):
12136 idx += 1
12137 continue
12138 elif launcher_name in {"mpiexec", "mpirun"}:
12139 if token in {"-n", "-np"}:
12140 idx += 2
12141 continue
12142 if token.startswith("-n=") or token.startswith("-np="):
12143 idx += 1
12144 continue
12145
12146 filtered.append(token)
12147 idx += 1
12148
12149 return filtered
12150
12151
Here is the caller graph for this function:

◆ build_local_launch_command()

list picurv_cli.core.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.

Parameters
[in]executableArgument passed to build_local_launch_command().
[in]executable_argsArgument passed to build_local_launch_command().
[in]num_procsArgument passed to build_local_launch_command().
[in]config_search_anchorArgument passed to build_local_launch_command().
[in]allow_single_rank_launcher_overrideWhen true, explicit launcher overrides also apply to 1-rank commands.
[in]force_num_procsOptional explicit MPI rank count override applied after stripping conflicting launcher size flags.
Returns
Value returned by build_local_launch_command().

Definition at line 12152 of file core.py.

12159) -> list:
12160 """!
12161 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
12162 @param[in] executable Argument passed to `build_local_launch_command()`.
12163 @param[in] executable_args Argument passed to `build_local_launch_command()`.
12164 @param[in] num_procs Argument passed to `build_local_launch_command()`.
12165 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
12166 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
12167 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
12168 @return Value returned by `build_local_launch_command()`.
12169 """
12170 target_num_procs = force_num_procs if force_num_procs is not None else num_procs
12171 command = [executable] + executable_args
12172 if target_num_procs <= 1 and not allow_single_rank_launcher_override:
12173 return command
12174
12175 launcher_override = os.environ.get("PICURV_MPI_LAUNCHER")
12176 if launcher_override is None:
12177 launcher_override = os.environ.get("MPI_LAUNCHER")
12178
12179 try:
12180 if launcher_override is not None:
12181 explicit_launcher_config = True
12182 launcher, launcher_args = split_launcher_tokens(
12183 launcher_override,
12184 label="local MPI launcher override",
12185 )
12186 else:
12187 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor)
12188 local_execution = resolve_runtime_execution_context(runtime_execution_cfg, "local")
12189 configured_launcher = local_execution.get("launcher")
12190 configured_args = local_execution.get("launcher_args") or []
12191 explicit_launcher_config = configured_launcher is not None or bool(configured_args)
12192 if target_num_procs <= 1 and not explicit_launcher_config:
12193 return command
12194 launcher, launcher_args = split_launcher_tokens(
12195 configured_launcher if configured_launcher is not None else "mpiexec",
12196 configured_args,
12197 label="local_execution.launcher",
12198 )
12199 except ValueError as exc:
12200 print(f"[FATAL] {exc}", file=sys.stderr)
12201 sys.exit(1)
12202
12203 if not launcher:
12204 return command
12205
12206 launcher_name = os.path.basename(launcher).lower()
12207 if force_num_procs is not None:
12208 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
12209 prefix = [launcher] + launcher_args
12210
12211 if launcher_name == "srun":
12212 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
12213 if not has_n:
12214 prefix += ["-n", str(target_num_procs)]
12215 elif launcher_name in {"mpiexec", "mpirun"}:
12216 has_n = any(token in {"-n", "-np"} for token in launcher_args)
12217 if not has_n:
12218 prefix += ["-n", str(target_num_procs)]
12219
12220 return prefix + command
12221
Here is the call graph for this function:
Here is the caller graph for this function:

◆ restart_grid_inputs()

dict picurv_cli.core.restart_grid_inputs ( str  run_dir,
dict  case_cfg 
)

The inputs a run's geometry is derived from, to compare across a restart.

The solver compares the checkpoint's geometry, which it hashes from the built grid, only after the job has started. These inputs determine that geometry within one build, so a difference is worth saying at staging: a staged inputs/grid/grid.run (file and generated grids, already non-dimensional) by content, otherwise the grid section without its rank layout and the length scale; and the periodic faces of every block.

Parameters
[in]run_dirRun whose staged grid is read.
[in]case_cfgThat run's case configuration.
Returns
Mapping of input name to a comparable value.

Definition at line 12222 of file core.py.

12222def restart_grid_inputs(run_dir: str, case_cfg: dict) -> dict:
12223 """!
12224 @brief The inputs a run's geometry is derived from, to compare across a restart.
12225 @details The solver compares the checkpoint's geometry, which it hashes from the built
12226 grid, only after the job has started. These inputs determine that geometry
12227 within one build, so a difference is worth saying at staging: a staged
12228 `inputs/grid/grid.run` (file and generated grids, already non-dimensional)
12229 by content, otherwise the grid section without its rank layout and the
12230 length scale; and the periodic faces of every block.
12231 @param[in] run_dir Run whose staged grid is read.
12232 @param[in] case_cfg That run's case configuration.
12233 @return Mapping of input name to a comparable value.
12234 """
12235 staged = os.path.join(run_dir, "inputs", "grid", "grid.run")
12236 grid = {key: value for key, value in (case_cfg.get("grid") or {}).items()
12237 if not str(key).startswith("da_processors_")}
12238 try:
12239 periodic = [sorted(bc["face"] for bc in block if bc["type"] == "PERIODIC")
12240 for block in validate_and_prepare_boundary_conditions(case_cfg)]
12241 except (ValueError, KeyError, TypeError):
12242 periodic = None
12243 inputs = {"periodic faces": periodic}
12244 if os.path.isfile(staged):
12245 inputs["grid file"] = _file_sha256(staged)
12246 else:
12247 inputs["grid"] = grid
12248 inputs["length_ref"] = ((case_cfg.get("properties") or {}).get("scaling") or {}).get("length_ref")
12249 return inputs
12250
12251
Here is the call graph for this function:
Here is the caller graph for this function:

◆ warn_on_restart_grid_change()

picurv_cli.core.warn_on_restart_grid_change ( dict  source_inputs,
dict  branch_inputs 
)

Warn at staging when a restart's grid inputs differ from its source run's.

Parameters
[in]source_inputsrestart_grid_inputs() of the run that wrote the checkpoint.
[in]branch_inputsrestart_grid_inputs() of the staged restart.
Returns
Names of the inputs that differ.

Definition at line 12252 of file core.py.

12252def warn_on_restart_grid_change(source_inputs: dict, branch_inputs: dict):
12253 """!
12254 @brief Warn at staging when a restart's grid inputs differ from its source run's.
12255 @param[in] source_inputs restart_grid_inputs() of the run that wrote the checkpoint.
12256 @param[in] branch_inputs restart_grid_inputs() of the staged restart.
12257 @return Names of the inputs that differ.
12258 """
12259 changed = sorted(name for name in set(source_inputs) | set(branch_inputs)
12260 if source_inputs.get(name) != branch_inputs.get(name))
12261 if changed:
12262 print(f"[WARNING] This restart's grid inputs differ from the source run's ({', '.join(changed)}). "
12263 "The solver refuses a checkpoint whose geometry differs, but only once the job has "
12264 "started and built the grid.", file=sys.stderr)
12265 return changed
12266
12267
Here is the caller graph for this function:

◆ check_restart_continuation()

picurv_cli.core.check_restart_continuation ( str  solver_exe,
list  solver_args,
str  run_dir,
str   config_search_anchor = None 
)

Ask the simulator whether a staged restart's carried statistics windows can continue.

Runs the simulator's restart check on one rank. It parses the run's control file and compares each window with the source checkpoint's manifest exactly as the restart will, but builds no grid, so it costs about a second and runs on a login node whatever the production rank count. Passing it does not replace the full check at solver start, which also compares geometry.

Parameters
[in]solver_exeSimulator executable the run will launch.
[in]solver_argsSimulator arguments the run will launch with.
[in]run_dirStaged run directory, the simulator's working directory.
[in]config_search_anchorPath from which the local launcher configuration is found.
Returns
None when the windows can continue, else the simulator's own reason.

Definition at line 12268 of file core.py.

12269 config_search_anchor: str = None):
12270 """!
12271 @brief Ask the simulator whether a staged restart's carried statistics windows can continue.
12272 @details Runs the simulator's restart check on one rank. It parses the run's control
12273 file and compares each window with the source checkpoint's manifest exactly
12274 as the restart will, but builds no grid, so it costs about a second and runs
12275 on a login node whatever the production rank count. Passing it does not
12276 replace the full check at solver start, which also compares geometry.
12277 @param[in] solver_exe Simulator executable the run will launch.
12278 @param[in] solver_args Simulator arguments the run will launch with.
12279 @param[in] run_dir Staged run directory, the simulator's working directory.
12280 @param[in] config_search_anchor Path from which the local launcher configuration is found.
12281 @return None when the windows can continue, else the simulator's own reason.
12282 """
12283 command = build_local_launch_command(
12284 solver_exe, solver_args + ["-restart_check_only", "true"], 1,
12285 config_search_anchor=config_search_anchor,
12286 allow_single_rank_launcher_override=True, force_num_procs=1,
12287 )
12288 result = _run_captured_command(command, run_dir)
12289 if result.returncode == 0:
12290 print("[INFO] Statistics continuation check passed.")
12291 return None
12292 # PETSc prints a banner, its generic error class, then the simulator's own message.
12293 messages = [line.split("PETSC ERROR:", 1)[1].strip()
12294 for line in (result.stdout + result.stderr).splitlines() if "PETSC ERROR:" in line]
12295 messages = [m for m in messages if m and not m.startswith("-")]
12296 return (messages[1] if len(messages) > 1 else messages[0] if messages
12297 else (result.stderr or result.stdout).strip() or f"exit status {result.returncode}")
12298
12299
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_cluster_execution()

dict picurv_cli.core.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.yml overrides.

Parameters
[in]cluster_cfgArgument passed to resolve_cluster_execution().
[in]config_search_anchorArgument passed to resolve_cluster_execution().
[in]extra_search_anchorsArgument passed to resolve_cluster_execution().
Returns
Value returned by resolve_cluster_execution().

Definition at line 12300 of file core.py.

12300def resolve_cluster_execution(cluster_cfg: dict, config_search_anchor: str = None, extra_search_anchors=None) -> dict:
12301 """!
12302 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
12303 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
12304 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
12305 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
12306 @return Value returned by `resolve_cluster_execution()`.
12307 """
12308 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor, extra_search_anchors=extra_search_anchors)
12309 shared_cluster_execution = resolve_runtime_execution_context(runtime_execution_cfg, "cluster")
12310 execution = cluster_cfg.get("execution", {}) or {}
12311 cluster_override = {
12312 "launcher": execution.get("launcher") if "launcher" in execution else None,
12313 "launcher_args": execution.get("launcher_args") if "launcher_args" in execution else None,
12314 }
12315 return merge_execution_overrides(shared_cluster_execution, cluster_override)
12316
12317
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_cluster_launch_command()

list picurv_cli.core.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.

Parameters
[in]cluster_cfgArgument passed to build_cluster_launch_command().
[in]executableArgument passed to build_cluster_launch_command().
[in]executable_argsArgument passed to build_cluster_launch_command().
[in]config_search_anchorArgument passed to build_cluster_launch_command().
[in]extra_search_anchorsArgument passed to build_cluster_launch_command().
[in]force_num_procsOptional explicit MPI rank count override applied after stripping conflicting launcher size flags.
Returns
Value returned by build_cluster_launch_command().

Definition at line 12318 of file core.py.

12325) -> list:
12326 """!
12327 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
12328 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
12329 @param[in] executable Argument passed to `build_cluster_launch_command()`.
12330 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
12331 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
12332 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
12333 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
12334 @return Value returned by `build_cluster_launch_command()`.
12335 """
12336 try:
12337 execution = resolve_cluster_execution(
12338 cluster_cfg,
12339 config_search_anchor=config_search_anchor,
12340 extra_search_anchors=extra_search_anchors,
12341 )
12342 launcher, launcher_args = split_launcher_tokens(
12343 execution.get("launcher") if execution.get("launcher") is not None else "srun",
12344 execution.get("launcher_args") or [],
12345 label="cluster execution launcher",
12346 )
12347 except ValueError as exc:
12348 print(f"[FATAL] {exc}", file=sys.stderr)
12349 sys.exit(1)
12350
12351 ntasks = int(force_num_procs) if force_num_procs is not None else get_cluster_total_tasks(cluster_cfg)
12352 launcher_name = launcher.lower() if launcher else ""
12353 if force_num_procs is not None:
12354 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
12355
12356 if launcher and launcher_name == "srun":
12357 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
12358 cmd = ["srun"] + launcher_args
12359 if not has_n:
12360 cmd += ["-n", str(ntasks)]
12361 return cmd + [executable] + executable_args
12362
12363 if launcher and launcher_name == "mpirun":
12364 has_np = any(token in {"-np", "-n"} for token in launcher_args)
12365 cmd = ["mpirun"] + launcher_args
12366 if not has_np:
12367 cmd += ["-np", str(ntasks)]
12368 return cmd + [executable] + executable_args
12369
12370 if launcher and launcher_name == "mpiexec":
12371 has_np = any(token in {"-np", "-n"} for token in launcher_args)
12372 cmd = ["mpiexec"] + launcher_args
12373 if not has_np:
12374 cmd += ["-np", str(ntasks)]
12375 return cmd + [executable] + executable_args
12376
12377 # Custom launcher or no launcher.
12378 cmd = []
12379 if launcher:
12380 cmd.append(str(launcher))
12381 cmd += launcher_args
12382 cmd += [executable] + executable_args
12383 return cmd
12384
Here is the call graph for this function:
Here is the caller graph for this function:

◆ parse_slurm_job_id()

str picurv_cli.core.parse_slurm_job_id ( str  sbatch_output)

Extract numeric job id from standard sbatch output.

Parameters
[in]sbatch_outputArgument passed to parse_slurm_job_id().
Returns
Value returned by parse_slurm_job_id().

Definition at line 12385 of file core.py.

12385def parse_slurm_job_id(sbatch_output: str) -> str:
12386 """!
12387 @brief Extract numeric job id from standard sbatch output.
12388 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
12389 @return Value returned by `parse_slurm_job_id()`.
12390 """
12391 match = re.search(r"Submitted batch job\s+(\d+)", sbatch_output or "")
12392 return match.group(1) if match else None
12393
12394#: Slurm end states after which an `afterok` dependency can never be satisfied.
Here is the caller graph for this function:

◆ slurm_job_end_state()

"tuple[str, str | None]" picurv_cli.core.slurm_job_end_state ( str  job_id)

Whether a recorded Slurm job (or array) is still queued, and how it ended if not.

Slurm forgets a finished job after its MinJobAge, and a dependency on a job it no longer knows is rejected, so a stage submitted long after the job it waits for must not depend on it. squeue answers for live jobs; sacct for ended ones.

Parameters
[in]job_idRecorded job id; for an array, the array's id covers every task.
Returns
("active", state), ("completed", "COMPLETED"), ("failed", state), ("unknown", state-or-None) when the job is not queued but sacct cannot say how it ended, or ("unchecked", None) where squeue is unavailable.

Definition at line 12401 of file core.py.

12401def slurm_job_end_state(job_id: str) -> "tuple[str, str | None]":
12402 """!
12403 @brief Whether a recorded Slurm job (or array) is still queued, and how it ended if not.
12404 @details Slurm forgets a finished job after its MinJobAge, and a dependency on a job it
12405 no longer knows is rejected, so a stage submitted long after the job it waits
12406 for must not depend on it. `squeue` answers for live jobs; `sacct` for ended ones.
12407 @param[in] job_id Recorded job id; for an array, the array's id covers every task.
12408 @return `("active", state)`, `("completed", "COMPLETED")`, `("failed", state)`,
12409 `("unknown", state-or-None)` when the job is not queued but `sacct` cannot say how
12410 it ended, or `("unchecked", None)` where `squeue` is unavailable.
12411 """
12412 squeue = shutil.which("squeue")
12413 if not squeue:
12414 return "unchecked", None
12415 result = subprocess.run([squeue, "-h", "-j", str(job_id), "-o", "%T"],
12416 text=True, capture_output=True, check=False)
12417 live = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
12418 if result.returncode == 0 and live:
12419 return "active", live[0]
12420 sacct = shutil.which("sacct")
12421 if not sacct:
12422 return "unknown", None
12423 result = subprocess.run([sacct, "-n", "-X", "-P", "-j", str(job_id), "-o", "State"],
12424 text=True, capture_output=True, check=False)
12425 # "CANCELLED by 123" carries the canceller; the state is the first word.
12426 states = [line.split()[0] for line in (result.stdout or "").splitlines() if line.strip()]
12427 if result.returncode != 0 or not states:
12428 return "unknown", None
12429 failed = [state for state in states if state in _SLURM_FAILED_END_STATES]
12430 if failed:
12431 return "failed", failed[0]
12432 if all(state == "COMPLETED" for state in states):
12433 return "completed", "COMPLETED"
12434 # A state sacct reports for a job squeue no longer lists, such as a transient one.
12435 return "unknown", states[0]
12436
12437
Here is the caller graph for this function:

◆ submit_sbatch()

dict picurv_cli.core.submit_sbatch ( str  script_path,
str   dependency = None,
str   dependency_type = "afterok" 
)

Submit sbatch script and return submission metadata.

Parameters
[in]script_pathArgument passed to submit_sbatch().
[in]dependencyArgument passed to submit_sbatch().
[in]dependency_typeSlurm dependency type (default: afterok). Common values: afterok, afterany.
Returns
Value returned by submit_sbatch().

Definition at line 12438 of file core.py.

12438def submit_sbatch(script_path: str, dependency: str = None, dependency_type: str = "afterok") -> dict:
12439 """!
12440 @brief Submit sbatch script and return submission metadata.
12441 @param[in] script_path Argument passed to `submit_sbatch()`.
12442 @param[in] dependency Argument passed to `submit_sbatch()`.
12443 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
12444 @return Value returned by `submit_sbatch()`.
12445 """
12446 cmd = ["sbatch"]
12447 if dependency:
12448 cmd.append(f"--dependency={dependency_type}:{dependency}")
12449 cmd.append(script_path)
12450 result = subprocess.run(cmd, text=True, capture_output=True, check=False)
12451 metadata = {
12452 "command": cmd,
12453 "returncode": result.returncode,
12454 "stdout": (result.stdout or "").strip(),
12455 "stderr": (result.stderr or "").strip(),
12456 "script": script_path,
12457 }
12458 if result.returncode != 0:
12459 print(f"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
12460 sys.exit(result.returncode)
12461 metadata["job_id"] = parse_slurm_job_id(metadata["stdout"])
12462 if not metadata["job_id"]:
12463 print(
12464 f"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
12465 file=sys.stderr
12466 )
12467 sys.exit(1)
12468 return metadata
12469
12470
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _print_validation_errors()

picurv_cli.core._print_validation_errors ( list  errors)
protected

Prints validation errors and exits.

Parameters
[in]errorsList of error message strings.

Definition at line 12471 of file core.py.

12471def _print_validation_errors(errors: list):
12472 """!
12473 @brief Prints validation errors and exits.
12474 @param[in] errors List of error message strings.
12475 """
12476 print(f"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
12477 for raw_error in errors:
12478 file_path, message = _split_error_file_and_message(raw_error)
12479 key_path = _extract_key_path(message)
12480 code = _classify_error_code(message)
12481 emit_structured_error(code, key=key_path, file_path=file_path, message=message)
12482 print(
12483 "\nHint: See examples/master_template/ for valid config structure and "
12484 "docs/pages/14_Config_Contract.md for key-level contract details.",
12485 file=sys.stderr,
12486 )
12487 sys.exit(1)
12488
12489
Here is the call graph for this function:
Here is the caller graph for this function:

◆ generate_header()

str picurv_cli.core.generate_header ( str  run_id,
dict  source_files 
)

Creates a standard header block for all generated files.

Parameters
[in]run_idThe unique identifier for the current simulation run.
[in]source_filesA dictionary of source profile files used.
Returns
A formatted string containing the header.

Definition at line 12490 of file core.py.

12490def generate_header(run_id: str, source_files: dict) -> str:
12491 """!
12492 @brief Creates a standard header block for all generated files.
12493 @param[in] run_id The unique identifier for the current simulation run.
12494 @param[in] source_files A dictionary of source profile files used.
12495 @return A formatted string containing the header.
12496 """
12497 header_parts = [
12498 "# ==============================================================================",
12499 "# AUTO-GENERATED CONFIGURATION FILE",
12500 "# ------------------------------------------------------------------------------",
12501 f"# Run ID: {run_id}",
12502 f"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
12503 "#",
12504 "# Source Configuration:"
12505 ]
12506 for name, path in source_files.items():
12507 header_parts.append(f"# - {name:<12}: {os.path.basename(path)}")
12508 header_parts.extend([
12509 "#",
12510 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
12511 "# ==============================================================================\n"
12512 ])
12513 return "\n".join(header_parts)
12514
Here is the caller graph for this function:

◆ generate_simple_list_file()

str picurv_cli.core.generate_simple_list_file ( str  run_dir,
str  run_id,
dict  cfg,
str  section,
str  key,
str  filename,
dict  header_sources,
str   config_dir = None 
)

Generic function to create a file containing a simple list of strings.

Parameters
[in]run_dirThe path to the main run directory.
[in]run_idThe unique identifier for the run.
[in]cfgThe dictionary containing the configuration data.
[in]sectionThe top-level key in the cfg dictionary.
[in]keyThe second-level key whose value is the list of strings.
[in]filenameThe name of the file to generate (e.g., 'whitelist.run').
[in]header_sourcesA dictionary of source files for the header.
[in]config_dirOptional configuration revision directory.
Returns
The absolute path to the generated file.

Definition at line 12515 of file core.py.

12517 config_dir: str = None) -> str:
12518 """!
12519 @brief Generic function to create a file containing a simple list of strings.
12520 @param[in] run_dir The path to the main run directory.
12521 @param[in] run_id The unique identifier for the run.
12522 @param[in] cfg The dictionary containing the configuration data.
12523 @param[in] section The top-level key in the cfg dictionary.
12524 @param[in] key The second-level key whose value is the list of strings.
12525 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
12526 @param[in] header_sources A dictionary of source files for the header.
12527 @param[in] config_dir Optional configuration revision directory.
12528 @return The absolute path to the generated file.
12529 """
12530 print(f"[INFO] Generating {filename}...")
12531 config_dir = config_dir or os.path.join(run_dir, "config")
12532 os.makedirs(config_dir, exist_ok=True)
12533 file_path = os.path.join(config_dir, filename)
12534
12535 lines = [generate_header(run_id, header_sources)]
12536 items = cfg.get(section, {}).get(key, [])
12537 lines.extend(items)
12538
12539 with open(file_path, "w") as f: f.write("\n".join(lines))
12540 print(f"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
12541 return os.path.abspath(file_path)
12542
12543
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_monitor_function_names()

list picurv_cli.core.normalize_monitor_function_names ( list  names,
str  field_name 
)

Translate renamed solver functions in a monitor.yml function list.

Parameters
[in]namesFunction names as written in monitor.yml.
[in]field_nameYAML path of the list, used in warnings.
Returns
The list with renamed functions replaced and removed ones dropped.

Logging and profiling select functions by exact name, so a stale name silently selects nothing. Renamed functions are mapped to their current names and removed ones dropped, each with a warning.

Definition at line 12544 of file core.py.

12544def normalize_monitor_function_names(names: list, field_name: str) -> list:
12545 """!
12546 @brief Translate renamed solver functions in a monitor.yml function list.
12547 @param[in] names Function names as written in monitor.yml.
12548 @param[in] field_name YAML path of the list, used in warnings.
12549 @return The list with renamed functions replaced and removed ones dropped.
12550 @details Logging and profiling select functions by exact name, so a stale name
12551 silently selects nothing. Renamed functions are mapped to their current
12552 names and removed ones dropped, each with a warning.
12553 """
12554 normalized = []
12555 for name in names or []:
12556 if name in RENAMED_MONITOR_FUNCTIONS:
12557 current = RENAMED_MONITOR_FUNCTIONS[name]
12558 print(f"[WARNING] {field_name}: '{name}' was renamed to '{current}'; using '{current}'.",
12559 file=sys.stderr)
12560 name = current
12561 elif name in REMOVED_MONITOR_FUNCTIONS:
12562 print(f"[WARNING] {field_name}: '{name}' no longer exists (removed by the Poisson "
12563 "rewrite, 0d34592); dropping it.", file=sys.stderr)
12564 continue
12565 if name not in normalized:
12566 normalized.append(name)
12567 return normalized
12568
12569
Here is the caller graph for this function:

◆ has_explicit_monitor_whitelist()

bool picurv_cli.core.has_explicit_monitor_whitelist ( dict  monitor_cfg)

Return True when logging.enabled_functions contains at least one entry.

Parameters
[in]monitor_cfgArgument passed to has_explicit_monitor_whitelist().
Returns
Value returned by has_explicit_monitor_whitelist().

Definition at line 12570 of file core.py.

12570def has_explicit_monitor_whitelist(monitor_cfg: dict) -> bool:
12571 """!
12572 @brief Return True when logging.enabled_functions contains at least one entry.
12573 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
12574 @return Value returned by `has_explicit_monitor_whitelist()`.
12575 """
12576 items = monitor_cfg.get("logging", {}).get("enabled_functions", [])
12577 return bool(items)
12578
12579
12580#: Fixed names of the solver records under a run's `logs/`.
Here is the caller graph for this function:

◆ resolve_profiling_config()

dict picurv_cli.core.resolve_profiling_config ( dict  monitor_cfg)

Resolve profiling reporting config from monitor.yml.

Parameters
[in]monitor_cfgArgument passed to resolve_profiling_config().
Returns
Value returned by resolve_profiling_config().

Definition at line 12585 of file core.py.

12585def resolve_profiling_config(monitor_cfg: dict) -> dict:
12586 """!
12587 @brief Resolve profiling reporting config from monitor.yml.
12588 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
12589 @return Value returned by `resolve_profiling_config()`.
12590 """
12591 profiling_cfg = monitor_cfg.get("profiling", {}) or {}
12592 timestep_cfg = profiling_cfg.get("timestep_output")
12593 final_cfg = profiling_cfg.get("final_summary")
12594
12595 if timestep_cfg is None:
12596 mode = "off"
12597 functions = []
12598 else:
12599 if not isinstance(timestep_cfg, dict):
12600 raise ValueError("monitor.profiling.timestep_output must be a mapping when provided.")
12601 mode = str(timestep_cfg.get("mode", "off")).lower()
12602 functions = timestep_cfg.get("functions", [])
12603
12604 if mode not in PROFILING_TIMESTEP_MODES:
12605 raise ValueError("monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
12606 if functions is None:
12607 functions = []
12608 if not isinstance(functions, list):
12609 raise ValueError("monitor.profiling.timestep_output.functions must be a list of function names.")
12610 if not all(isinstance(item, str) and item.strip() for item in functions):
12611 raise ValueError("monitor.profiling.timestep_output.functions entries must be non-empty strings.")
12612 if mode == "selected" and not functions:
12613 raise ValueError("monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
12614 if mode != "selected" and functions:
12615 raise ValueError("monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
12616 functions = normalize_monitor_function_names(functions, "monitor.profiling.timestep_output.functions")
12617 if mode == "selected" and not functions:
12618 raise ValueError("monitor.profiling.timestep_output.functions lists only functions that no longer exist.")
12619
12620 if final_cfg is None:
12621 final_enabled = True
12622 elif isinstance(final_cfg, dict):
12623 final_enabled = bool(final_cfg.get("enabled", True))
12624 else:
12625 raise ValueError("monitor.profiling.final_summary must be a mapping when provided.")
12626
12627 # Per-particle and per-cell helper timers are off unless named here, or 'all'.
12628 helpers = profiling_cfg.get("helpers", [])
12629 if helpers is None:
12630 helpers = []
12631 if isinstance(helpers, str):
12632 if helpers.strip().lower() != "all":
12633 raise ValueError("monitor.profiling.helpers must be 'all' or a list of function names.")
12634 helpers = ["all"]
12635 elif not isinstance(helpers, list) or not all(isinstance(item, str) and item.strip() for item in helpers):
12636 raise ValueError("monitor.profiling.helpers must be 'all' or a list of non-empty function names.")
12637 elif helpers != ["all"]:
12638 helpers = normalize_monitor_function_names(helpers, "monitor.profiling.helpers")
12639 if any("," in item or item.strip() != item for item in helpers):
12640 raise ValueError("monitor.profiling.helpers names must not contain commas or surrounding spaces.")
12641
12642 return {
12643 "mode": mode,
12644 "functions": functions,
12645 "final_summary_enabled": final_enabled,
12646 "helpers": helpers,
12647 }
12648
12649
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _diagnostic_info()

dict picurv_cli.core._diagnostic_info (   value)
protected

Validate PETSc info logging configuration.

Parameters
[in]valueBoolean or structured PETSc info configuration.
Returns
Normalized enabled/class-filter mapping.

Definition at line 12667 of file core.py.

12667def _diagnostic_info(value) -> dict:
12668 """!
12669 @brief Validate PETSc info logging configuration.
12670 @param[in] value Boolean or structured PETSc info configuration.
12671 @return Normalized enabled/class-filter mapping.
12672 """
12673 if isinstance(value, bool):
12674 return {"enabled": value, "classes": []}
12675 if value is None:
12676 return {"enabled": False, "classes": []}
12677 if not isinstance(value, dict):
12678 raise ValueError("monitor.diagnostics.petsc.info must be boolean, null, or a mapping.")
12679 unknown = sorted(set(value) - {"enabled", "classes"})
12680 if unknown:
12681 raise ValueError(f"monitor.diagnostics.petsc.info has unsupported key(s): {unknown}.")
12682 enabled = value.get("enabled", True)
12683 classes = value.get("classes", [])
12684 if not isinstance(enabled, bool):
12685 raise ValueError("monitor.diagnostics.petsc.info.enabled must be boolean.")
12686 if not isinstance(classes, list) or not all(
12687 isinstance(item, str) and item.strip() and "," not in item and ":" not in item
12688 for item in classes
12689 ):
12690 raise ValueError(
12691 "monitor.diagnostics.petsc.info.classes must be a list of non-empty PETSc class names."
12692 )
12693 return {"enabled": enabled, "classes": [item.strip() for item in classes]}
12694
12695
Here is the caller graph for this function:

◆ _diagnostic_bool_or_path()

picurv_cli.core._diagnostic_bool_or_path (   value,
str  key 
)
protected

Validate a diagnostics value that can be false, true, or a path/viewer string.

Parameters
[in]valueCandidate value.
[in]keyDiagnostics key used in error messages.
Returns
Normalized value.

Definition at line 12696 of file core.py.

12696def _diagnostic_bool_or_path(value, key: str):
12697 """!
12698 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
12699 @param[in] value Candidate value.
12700 @param[in] key Diagnostics key used in error messages.
12701 @return Normalized value.
12702 """
12703 if isinstance(value, bool) or value is None:
12704 return value
12705 if isinstance(value, str) and value.strip():
12706 return value.strip()
12707 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
12708
12709
Here is the caller graph for this function:

◆ _diagnostic_bool()

bool picurv_cli.core._diagnostic_bool (   value,
str  key 
)
protected

Validate a diagnostics boolean value.

Parameters
[in]valueCandidate value.
[in]keyDiagnostics key used in error messages.
Returns
Boolean value.

Definition at line 12710 of file core.py.

12710def _diagnostic_bool(value, key: str) -> bool:
12711 """!
12712 @brief Validate a diagnostics boolean value.
12713 @param[in] value Candidate value.
12714 @param[in] key Diagnostics key used in error messages.
12715 @return Boolean value.
12716 """
12717 if isinstance(value, bool):
12718 return value
12719 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean.")
12720
12721
Here is the caller graph for this function:

◆ _diagnostic_bool_or_all()

picurv_cli.core._diagnostic_bool_or_all (   value,
str  key 
)
protected

Validate a diagnostics value that can be false, true, or "all".

Parameters
[in]valueCandidate value.
[in]keyDiagnostics key used in error messages.
Returns
Normalized value.

Definition at line 12722 of file core.py.

12722def _diagnostic_bool_or_all(value, key: str):
12723 """!
12724 @brief Validate a diagnostics value that can be false, true, or "all".
12725 @param[in] value Candidate value.
12726 @param[in] key Diagnostics key used in error messages.
12727 @return Normalized value.
12728 """
12729 if isinstance(value, bool) or value is None:
12730 return value
12731 if isinstance(value, str) and value.strip().lower() == "all":
12732 return "all"
12733 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
12734
12735
Here is the caller graph for this function:

◆ _diagnostic_default_file()

str picurv_cli.core._diagnostic_default_file ( str  run_dir,
str  filename 
)
protected

Return an absolute run-local diagnostics file path.

Parameters
[in]run_dirRun directory.
[in]filenameDiagnostics filename.
Returns
Absolute diagnostics path under the run logs directory.

Definition at line 12736 of file core.py.

12736def _diagnostic_default_file(run_dir: str, filename: str) -> str:
12737 """!
12738 @brief Return an absolute run-local diagnostics file path.
12739 @param[in] run_dir Run directory.
12740 @param[in] filename Diagnostics filename.
12741 @return Absolute diagnostics path under the run logs directory.
12742 """
12743 return os.path.abspath(os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"], filename))
12744
12745
Here is the caller graph for this function:

◆ _diagnostic_resolve_path_or_default()

picurv_cli.core._diagnostic_resolve_path_or_default (   value,
str  run_dir,
str  default_filename 
)
protected

Resolve true/string diagnostics values to a concrete file path.

Parameters
[in]valueBoolean/string diagnostics value.
[in]run_dirRun directory.
[in]default_filenameDefault file name when value is true.
Returns
False, or an absolute/explicit path string.

Definition at line 12746 of file core.py.

12746def _diagnostic_resolve_path_or_default(value, run_dir: str, default_filename: str):
12747 """!
12748 @brief Resolve true/string diagnostics values to a concrete file path.
12749 @param[in] value Boolean/string diagnostics value.
12750 @param[in] run_dir Run directory.
12751 @param[in] default_filename Default file name when value is true.
12752 @return False, or an absolute/explicit path string.
12753 """
12754 if value is True:
12755 return _diagnostic_default_file(run_dir, default_filename)
12756 if isinstance(value, str):
12757 if os.path.isabs(value) or value.startswith(":"):
12758 return value
12759 return os.path.abspath(os.path.join(run_dir, "logs", value))
12760 return False
12761
12762
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_diagnostics_config()

dict picurv_cli.core.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.

Parameters
[in]monitor_cfgParsed monitor.yml mapping.
[in]run_dirOptional run directory for default artifact paths.
[in]stage_labelSolver/PostProcessor suffix used for PETSc output defaults.
Returns
Normalized diagnostics config.

Definition at line 12763 of file core.py.

12763def resolve_diagnostics_config(monitor_cfg: dict, run_dir: "str | None" = None, stage_label: str = "Solver") -> dict:
12764 """!
12765 @brief Resolve monitor diagnostics config and default run-local log paths.
12766 @param[in] monitor_cfg Parsed monitor.yml mapping.
12767 @param[in] run_dir Optional run directory for default artifact paths.
12768 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
12769 @return Normalized diagnostics config.
12770 """
12771 diagnostics_cfg = (monitor_cfg.get("diagnostics", {}) or {}) if isinstance(monitor_cfg, dict) else {}
12772 if not isinstance(diagnostics_cfg, dict):
12773 raise ValueError("monitor.diagnostics must be a mapping when provided.")
12774
12775 petsc_raw = diagnostics_cfg.get("petsc", {}) or {}
12776 if not isinstance(petsc_raw, dict):
12777 raise ValueError("monitor.diagnostics.petsc must be a mapping when provided.")
12778 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
12779 if unknown:
12780 raise ValueError(f"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
12781
12782 petsc = {
12783 "info": _diagnostic_info(petsc_raw.get("info", False)),
12784 "malloc_debug": _diagnostic_bool(petsc_raw.get("malloc_debug", False), "malloc_debug"),
12785 "malloc_test": _diagnostic_bool(petsc_raw.get("malloc_test", False), "malloc_test"),
12786 "malloc_dump": _diagnostic_bool(petsc_raw.get("malloc_dump", False), "malloc_dump"),
12787 "malloc_view": _diagnostic_bool_or_path(petsc_raw.get("malloc_view", False), "malloc_view"),
12788 "malloc_view_threshold": petsc_raw.get("malloc_view_threshold"),
12789 "memory_view": _diagnostic_bool(petsc_raw.get("memory_view", False), "memory_view"),
12790 "log_view": _diagnostic_bool_or_path(petsc_raw.get("log_view", False), "log_view"),
12791 "log_view_memory": _diagnostic_bool(petsc_raw.get("log_view_memory", False), "log_view_memory"),
12792 "log_all": _diagnostic_bool(petsc_raw.get("log_all", False), "log_all"),
12793 "log_trace": _diagnostic_bool_or_path(petsc_raw.get("log_trace", False), "log_trace"),
12794 "objects_dump": _diagnostic_bool_or_all(petsc_raw.get("objects_dump", False), "objects_dump"),
12795 "options_left": petsc_raw.get("options_left"),
12796 }
12797 if petsc["malloc_view_threshold"] is not None and not isinstance(petsc["malloc_view_threshold"], (int, float)):
12798 raise ValueError("monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
12799 if petsc["options_left"] is not None and not isinstance(petsc["options_left"], bool):
12800 raise ValueError("monitor.diagnostics.petsc.options_left must be boolean or null.")
12801
12802 memory_raw = diagnostics_cfg.get("runtime_memory_log", {}) or {}
12803 if not isinstance(memory_raw, dict):
12804 raise ValueError("monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
12805 # `file` is refused by the monitor schema; a stored run's config may still carry it.
12806 memory_unknown = sorted(set(memory_raw.keys()) - {"enabled", "file"})
12807 if memory_unknown:
12808 raise ValueError(f"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
12809 memory_enabled = memory_raw.get("enabled", True)
12810 if not isinstance(memory_enabled, bool):
12811 raise ValueError("monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
12812
12813 resolved_petsc = dict(petsc)
12814 artifacts = []
12815 if run_dir:
12816 suffix = "PostProcessor" if stage_label == "PostProcessor" else "Solver"
12817 resolved_petsc["info"] = False
12818 defaults = {
12819 "malloc_view": f"PETSc_MallocView_{suffix}.log",
12820 "log_view": f"PETSc_LogView_{suffix}.log",
12821 "log_trace": f"PETSc_LogTrace_{suffix}.log",
12822 }
12823 if petsc["info"]["enabled"]:
12824 info_path = _diagnostic_default_file(run_dir, f"PETSc_Info_{suffix}.log")
12825 classes = petsc["info"]["classes"]
12826 resolved_petsc["info"] = info_path + (f":{','.join(classes)}" if classes else "")
12827 # PetscInfoSetFile appends the emitting MPI rank (for example `.0`).
12828 artifacts.append(f"{info_path}.*")
12829 for key, default_name in defaults.items():
12830 resolved_value = _diagnostic_resolve_path_or_default(petsc[key], run_dir, default_name)
12831 if key == "log_view" and resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
12832 resolved_value = f":{resolved_value}"
12833 resolved_petsc[key] = resolved_value
12834 if resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
12835 artifacts.append(resolved_value)
12836 elif resolved_value and isinstance(resolved_value, str) and resolved_value.startswith(":"):
12837 artifacts.append(resolved_value[1:])
12838 if memory_enabled:
12839 artifacts.append(
12840 os.path.abspath(os.path.join(
12841 run_dir, CANONICAL_RUN_PATHS["logs"], RUNTIME_MEMORY_FILE
12842 ))
12843 )
12844
12845 return {
12846 "petsc": resolved_petsc,
12847 "runtime_memory_log": {"enabled": memory_enabled},
12848 "artifacts": artifacts,
12849 }
12850
12851
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_petsc_diagnostics_args()

list picurv_cli.core.build_petsc_diagnostics_args ( dict  monitor_cfg,
str  run_dir,
str  stage_label 
)

Build PETSc diagnostics command-line arguments for a run stage.

Parameters
[in]monitor_cfgParsed monitor.yml mapping.
[in]run_dirRun directory used to resolve default diagnostics files.
[in]stage_labelStage label for default output names.
Returns
List of executable arguments.

Definition at line 12852 of file core.py.

12852def build_petsc_diagnostics_args(monitor_cfg: dict, run_dir: str, stage_label: str) -> list:
12853 """!
12854 @brief Build PETSc diagnostics command-line arguments for a run stage.
12855 @param[in] monitor_cfg Parsed monitor.yml mapping.
12856 @param[in] run_dir Run directory used to resolve default diagnostics files.
12857 @param[in] stage_label Stage label for default output names.
12858 @return List of executable arguments.
12859 """
12860 diagnostics = resolve_diagnostics_config(monitor_cfg, run_dir, stage_label)
12861 petsc = diagnostics["petsc"]
12862 args = []
12863 if petsc["info"]:
12864 args.extend(["-info", str(petsc["info"])])
12865 if petsc["malloc_debug"]:
12866 args.append("-malloc_debug")
12867 if petsc["malloc_test"]:
12868 args.append("-malloc_test")
12869 for key, flag in (
12870 ("malloc_dump", "-malloc_dump"),
12871 ("malloc_view", "-malloc_view"),
12872 ("memory_view", "-memory_view"),
12873 ("log_view", "-log_view"),
12874 ("log_trace", "-log_trace"),
12875 ("objects_dump", "-objects_dump"),
12876 ):
12877 value = petsc.get(key)
12878 if value is True:
12879 args.append(flag)
12880 elif value:
12881 args.extend([flag, str(value)])
12882 if petsc["malloc_view_threshold"] is not None:
12883 args.extend(["-malloc_view_threshold", str(petsc["malloc_view_threshold"])])
12884 if petsc["log_view_memory"]:
12885 args.append("-log_view_memory")
12886 if petsc["log_all"]:
12887 args.append("-log_all")
12888 if petsc["options_left"] is not None:
12889 args.extend(["-options_left", "true" if petsc["options_left"] else "false"])
12890 elif stage_label == "PostProcessor":
12891 # The postprocessor reads the solver's control file, so every solver-only option
12892 # would be reported as unused on each run; left unset, the report is turned off.
12893 args.extend(["-options_left", "false"])
12894 return args
12895
12896
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _statistics_subsystem_available()

bool picurv_cli.core._statistics_subsystem_available ( dict  case_cfg,
  requirement 
)
protected

Report whether the subsystem a statistics field depends on is active.

Parameters
[in]case_cfgParsed case configuration.
[in]requirementSubsystem key from STATISTICS_ELIGIBLE_FIELDS, or None.
Returns
Value returned by _statistics_subsystem_available().

Definition at line 12897 of file core.py.

12897def _statistics_subsystem_available(case_cfg: dict, requirement) -> bool:
12898 """!
12899 @brief Report whether the subsystem a statistics field depends on is active.
12900 @param[in] case_cfg Parsed case configuration.
12901 @param[in] requirement Subsystem key from `STATISTICS_ELIGIBLE_FIELDS`, or None.
12902 @return Value returned by `_statistics_subsystem_available()`.
12903 """
12904 if requirement is None:
12905 return True
12906 physics = ((case_cfg or {}).get("models", {}) or {}).get("physics", {}) or {}
12907 if requirement == "particles":
12908 particles = physics.get("particles", {}) or {}
12909 try:
12910 return int(particles.get("count", 0) or 0) > 0
12911 except (TypeError, ValueError):
12912 return False
12913 turbulence = physics.get("turbulence", {}) or {}
12914 les_on = bool((turbulence.get("les", {}) or {}).get("enabled", False))
12915 if requirement == "les":
12916 return les_on
12917 if requirement == "turbulence":
12918 return les_on
12919 return False
12920
12921
Here is the caller graph for this function:

◆ normalize_field_statistics_config()

dict picurv_cli.core.normalize_field_statistics_config ( dict  monitor_cfg,
dict   case_cfg = None 
)

Validate and canonicalize the field-statistics block of monitor.yml.

Rejects every condition the field-statistics contract forbids, naming the offending window so a message points at one entry rather than the block. Returns a canonical form the flag resolver serializes without further interpretation, so validation and emission cannot disagree.

Parameters
[in]monitor_cfgParsed monitor configuration.
[in]case_cfgParsed case configuration, used to check that each field's subsystem is active. Subsystem checks are skipped when None.
Returns
Value returned by normalize_field_statistics_config().

Definition at line 12922 of file core.py.

12922def normalize_field_statistics_config(monitor_cfg: dict, case_cfg: dict = None) -> dict:
12923 """!
12924 @brief Validate and canonicalize the field-statistics block of monitor.yml.
12925
12926 @details Rejects every condition the field-statistics contract forbids, naming the
12927 offending window so a message points at one entry rather than the block.
12928 Returns a canonical form the flag resolver serializes without further
12929 interpretation, so validation and emission cannot disagree.
12930
12931 @param[in] monitor_cfg Parsed monitor configuration.
12932 @param[in] case_cfg Parsed case configuration, used to check that each field's
12933 subsystem is active. Subsystem checks are skipped when None.
12934 @return Value returned by `normalize_field_statistics_config()`.
12935 """
12936 raw = (monitor_cfg or {}).get("field_statistics")
12937 if raw is None:
12938 return {"enabled": False, "windows": []}
12939 if not isinstance(raw, dict):
12940 raise ValueError("'field_statistics' must be a mapping.")
12941
12942 enabled = raw.get("enabled", False)
12943 if not isinstance(enabled, bool):
12944 raise ValueError("'field_statistics.enabled' must be true or false.")
12945 windows_raw = raw.get("windows", []) or []
12946 if not isinstance(windows_raw, list):
12947 raise ValueError("'field_statistics.windows' must be a list.")
12948 if enabled and not windows_raw:
12949 raise ValueError("'field_statistics.enabled' is true but no window is defined.")
12950
12951 windows = []
12952 seen_names = set()
12953 for index, window in enumerate(windows_raw):
12954 if not isinstance(window, dict):
12955 raise ValueError(f"'field_statistics.windows[{index}]' must be a mapping.")
12956 name = window.get("name")
12957 if not isinstance(name, str) or not name.strip():
12958 raise ValueError(f"'field_statistics.windows[{index}]' needs a non-empty 'name'.")
12959 name = name.strip()
12960 # Window names identify saved state across a restart, so a duplicate would
12961 # make two windows indistinguishable in the checkpoint.
12962 if name in seen_names:
12963 raise ValueError(f"field statistics window '{name}' is defined more than once.")
12964 seen_names.add(name)
12965
12966 start_time = window.get("start_time")
12967 if not isinstance(start_time, (int, float)) or isinstance(start_time, bool):
12968 raise ValueError(f"field statistics window '{name}': 'start_time' must be a number.")
12969 end_time = window.get("end_time")
12970 if end_time is not None:
12971 if not isinstance(end_time, (int, float)) or isinstance(end_time, bool):
12972 raise ValueError(f"field statistics window '{name}': 'end_time' must be a number.")
12973 if float(end_time) <= float(start_time):
12974 raise ValueError(
12975 f"field statistics window '{name}': 'end_time' ({end_time}) must be greater "
12976 f"than 'start_time' ({start_time})."
12977 )
12978
12979 weighting = window.get("weighting")
12980 if weighting not in STATISTICS_WEIGHTING_MODES:
12981 raise ValueError(
12982 f"field statistics window '{name}': 'weighting' must be one of "
12983 f"{list(STATISTICS_WEIGHTING_MODES)} (got {weighting!r})."
12984 )
12985
12986 has_step = "step_cadence" in window and window["step_cadence"] is not None
12987 has_time = "time_cadence" in window and window["time_cadence"] is not None
12988 if has_step == has_time:
12989 raise ValueError(
12990 f"field statistics window '{name}': set exactly one of 'step_cadence' and "
12991 "'time_cadence'."
12992 )
12993 step_cadence = None
12994 time_cadence = None
12995 if has_step:
12996 step_cadence = window["step_cadence"]
12997 if not isinstance(step_cadence, int) or isinstance(step_cadence, bool) or step_cadence <= 0:
12998 raise ValueError(
12999 f"field statistics window '{name}': 'step_cadence' must be a positive integer "
13000 f"(got {step_cadence!r})."
13001 )
13002 else:
13003 time_cadence = window["time_cadence"]
13004 if (not isinstance(time_cadence, (int, float)) or isinstance(time_cadence, bool)
13005 or float(time_cadence) <= 0.0):
13006 raise ValueError(
13007 f"field statistics window '{name}': 'time_cadence' must be a positive number "
13008 f"(got {time_cadence!r})."
13009 )
13010
13011 fields, covariances = _normalize_statistics_requests(
13012 f"field statistics window '{name}'", window.get("fields"), window.get("covariances"),
13013 STATISTICS_ELIGIBLE_FIELDS, case_cfg)
13014
13015 windows.append({
13016 "name": name,
13017 "start_time": float(start_time),
13018 "end_time": None if end_time is None else float(end_time),
13019 "weighting": weighting,
13020 "step_cadence": step_cadence,
13021 "time_cadence": None if time_cadence is None else float(time_cadence),
13022 "fields": fields,
13023 "covariances": covariances,
13024 })
13025
13026 return {"enabled": bool(enabled), "windows": windows}
13027
13028
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _normalize_statistics_requests()

picurv_cli.core._normalize_statistics_requests ( str  label,
  fields_raw,
  covariances_raw,
dict  eligible,
  case_cfg = None 
)
protected

Validate and canonicalize the fields and covariances one window or ensemble requests.

Parameters
[in]labelOwner named in error messages, such as ‘field statistics window 'w’`.
[in]fields_rawThe requested fields list of the owner.
[in]covariances_rawThe requested covariance pairs of the owner, or None.
[in]eligibleFields the owner may name, with component counts and requirements.
[in]case_cfgParsed case configuration for subsystem checks, or None to skip them.
Returns
Tuple of the canonical field entries and covariance pairs.

Definition at line 13029 of file core.py.

13029def _normalize_statistics_requests(label: str, fields_raw, covariances_raw, eligible: dict, case_cfg=None):
13030 """!
13031 @brief Validate and canonicalize the fields and covariances one window or ensemble requests.
13032 @param[in] label Owner named in error messages, such as `field statistics window 'w'`.
13033 @param[in] fields_raw The requested fields list of the owner.
13034 @param[in] covariances_raw The requested covariance pairs of the owner, or None.
13035 @param[in] eligible Fields the owner may name, with component counts and requirements.
13036 @param[in] case_cfg Parsed case configuration for subsystem checks, or None to skip them.
13037 @return Tuple of the canonical field entries and covariance pairs.
13038 """
13039 if not isinstance(fields_raw, list) or not fields_raw:
13040 raise ValueError(f"{label}: 'fields' must be a non-empty list.")
13041 fields = []
13042 for field_entry in fields_raw:
13043 if not isinstance(field_entry, dict):
13044 raise ValueError(f"{label}: each 'fields' entry must be a mapping.")
13045 field_name = field_entry.get("field")
13046 if not isinstance(field_name, str) or field_name not in eligible:
13047 raise ValueError(
13048 f"{label}: field {field_name!r} cannot be accumulated. "
13049 f"Available fields: {sorted(eligible)}."
13050 )
13051 requirement = eligible[field_name]["requires"]
13052 if case_cfg is not None and not _statistics_subsystem_available(case_cfg, requirement):
13053 raise ValueError(
13054 f"{label}: field '{field_name}' requires the "
13055 f"'{requirement}' subsystem, which is not enabled for this case."
13056 )
13057 if any(existing["field"] == field_name for existing in fields):
13058 raise ValueError(
13059 f"{label}: field '{field_name}' is listed more than once."
13060 )
13061 moments = field_entry.get("moments")
13062 if not isinstance(moments, list) or not moments:
13063 raise ValueError(
13064 f"{label}: field '{field_name}' needs a non-empty "
13065 "'moments' list."
13066 )
13067 unknown = [m for m in moments if m not in STATISTICS_MOMENT_NAMES]
13068 if unknown:
13069 raise ValueError(
13070 f"{label}: field '{field_name}' requests unknown "
13071 f"moments {unknown}. Available moments: {list(STATISTICS_MOMENT_NAMES)}."
13072 )
13073 # The first moment is always kept, because every centered product is
13074 # measured against it.
13075 fields.append({"field": field_name, "moments": ["first"] + (["second"] if "second" in moments else [])})
13076
13077 covariances_raw = covariances_raw or []
13078 if not isinstance(covariances_raw, list):
13079 raise ValueError(f"{label}: 'covariances' must be a list.")
13080 requested = {entry["field"] for entry in fields}
13081 covariances = []
13082 for pair in covariances_raw:
13083 if not isinstance(pair, list) or len(pair) != 2:
13084 raise ValueError(
13085 f"{label}: each covariance must be a pair of field names."
13086 )
13087 first, second = pair
13088 for member in (first, second):
13089 if not isinstance(member, str) or member not in eligible:
13090 raise ValueError(
13091 f"{label}: covariance member {member!r} cannot be "
13092 f"accumulated. Available fields: {sorted(eligible)}."
13093 )
13094 if first == second:
13095 raise ValueError(
13096 f"{label}: covariance ['{first}', '{second}'] pairs a "
13097 "field with itself; request that through moments: [second] instead."
13098 )
13099 missing = sorted({first, second} - requested)
13100 if missing:
13101 raise ValueError(
13102 f"{label}: covariance ['{first}', '{second}'] needs "
13103 f"{missing} in 'fields' as well, because a co-moment is centered against their means."
13104 )
13105 # Two three-component fields would need a full nine-component tensor,
13106 # which nothing allocates.
13107 if (eligible[first]["components"] == 3
13108 and eligible[second]["components"] == 3):
13109 raise ValueError(
13110 f"{label}: covariance ['{first}', '{second}'] pairs two "
13111 "vector fields, which is not supported."
13112 )
13113 if sorted((first, second)) in [sorted(existing) for existing in covariances]:
13114 raise ValueError(
13115 f"{label}: covariance ['{first}', '{second}'] is "
13116 "requested more than once."
13117 )
13118 covariances.append([first, second])
13119
13120 return fields, covariances
13121
13122
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_field_statistics_flags()

list picurv_cli.core.resolve_field_statistics_flags ( dict  monitor_cfg,
dict   case_cfg = None 
)

Serialize field-statistics configuration into control-file option lines.

A window list is variable arity, so its option names are constructed from the index. Each name belongs to a family declared in the ingress audit manifest.

Parameters
[in]monitor_cfgParsed monitor configuration.
[in]case_cfgParsed case configuration, for subsystem availability checks.
Returns
Value returned by resolve_field_statistics_flags().

Definition at line 13123 of file core.py.

13123def resolve_field_statistics_flags(monitor_cfg: dict, case_cfg: dict = None) -> list:
13124 """!
13125 @brief Serialize field-statistics configuration into control-file option lines.
13126 @details A window list is variable arity, so its option names are constructed from
13127 the index. Each name belongs to a family declared in the ingress audit
13128 manifest.
13129 @param[in] monitor_cfg Parsed monitor configuration.
13130 @param[in] case_cfg Parsed case configuration, for subsystem availability checks.
13131 @return Value returned by `resolve_field_statistics_flags()`.
13132 """
13133 config = normalize_field_statistics_config(monitor_cfg, case_cfg)
13134 if not config["enabled"]:
13135 return []
13136
13137 # Window times are physical; the runtime compares them against its own clock, which
13138 # is in solver time, so each is divided by the reference time L_ref / U_ref.
13139 scales = resolve_reference_scales(case_cfg or {})
13140 window_path = ("monitor", "field_statistics", "windows", "[]")
13141
13142 def solver_time(key: str, value: float) -> float:
13143 """!
13144 @brief Convert one physical window time to solver time.
13145 @param[in] key Window key, for the recorded dimension.
13146 @param[in] value Physical time.
13147 @return Solver time.
13148 """
13149 return to_solver_units(value, window_path + (key,), scales)
13150
13151 lines = ["-field_statistics_enabled true",
13152 f"-field_statistics_window_count {len(config['windows'])}"]
13153 for index, window in enumerate(config["windows"]):
13154 prefix = f"-field_statistics_window_{index}"
13155 lines.append(f"{prefix}_name {window['name']}")
13156 lines.append(f"{prefix}_start_time {solver_time('start_time', window['start_time'])!r}")
13157 # An absent end time is what makes a window open ended, so the option is
13158 # omitted rather than given a sentinel value.
13159 if window["end_time"] is not None:
13160 lines.append(f"{prefix}_end_time {solver_time('end_time', window['end_time'])!r}")
13161 lines.append(f"{prefix}_weighting {window['weighting']}")
13162 if window["step_cadence"] is not None:
13163 lines.append(f"{prefix}_step_cadence {window['step_cadence']}")
13164 else:
13165 lines.append(f"{prefix}_time_cadence {solver_time('time_cadence', window['time_cadence'])!r}")
13166 lines.append(f"{prefix}_field_count {len(window['fields'])}")
13167 for field_index, field_entry in enumerate(window["fields"]):
13168 lines.append(f"{prefix}_field_{field_index}_name {field_entry['field']}")
13169 lines.append(f"{prefix}_field_{field_index}_moments {','.join(field_entry['moments'])}")
13170 lines.append(f"{prefix}_covariance_count {len(window['covariances'])}")
13171 for pair_index, pair in enumerate(window["covariances"]):
13172 lines.append(f"{prefix}_covariance_{pair_index} {pair[0]},{pair[1]}")
13173 return lines
13174
13175
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_statistics_console_output_frequency()

"int | None" picurv_cli.core.resolve_statistics_console_output_frequency ( dict  io_cfg)

Resolve the statistics console cadence, mirroring the particle one.

Parameters
[in]io_cfgParsed monitor io block.
Returns
Value returned by resolve_statistics_console_output_frequency().

Definition at line 13176 of file core.py.

13176def resolve_statistics_console_output_frequency(io_cfg: dict) -> "int | None":
13177 """!
13178 @brief Resolve the statistics console cadence, mirroring the particle one.
13179 @param[in] io_cfg Parsed monitor `io` block.
13180 @return Value returned by `resolve_statistics_console_output_frequency()`.
13181 """
13182 if 'statistics_console_output_frequency' in io_cfg:
13183 return io_cfg['statistics_console_output_frequency']
13184 return io_cfg.get('data_output_frequency')
13185
13186
Here is the caller graph for this function:

◆ normalize_solution_monitoring_config()

dict picurv_cli.core.normalize_solution_monitoring_config ( dict  monitor_cfg)

Validate and canonicalize physical-solution convergence monitoring.

Parameters
[in]monitor_cfgParsed monitor.yml mapping.
Returns
Canonical solution-monitoring configuration.
Exceptions
ValueErrorwhen convergence settings are invalid.

Definition at line 13187 of file core.py.

13187def normalize_solution_monitoring_config(monitor_cfg: dict) -> dict:
13188 """!
13189 @brief Validate and canonicalize physical-solution convergence monitoring.
13190 @param[in] monitor_cfg Parsed monitor.yml mapping.
13191 @return Canonical solution-monitoring configuration.
13192 @throws ValueError when convergence settings are invalid.
13193 """
13194 if not isinstance(monitor_cfg, dict):
13195 raise ValueError("monitor.yml must be a mapping before solution monitoring can be normalized.")
13196 monitoring = monitor_cfg.get("solution_monitoring", {}) or {}
13197 if not isinstance(monitoring, dict):
13198 raise ValueError("solution_monitoring must be a mapping when provided.")
13199 convergence = monitoring.get("convergence", {}) or {}
13200 if not isinstance(convergence, dict):
13201 raise ValueError("solution_monitoring.convergence must be a mapping when provided.")
13202 enabled = convergence.get("enabled", True)
13203 if not isinstance(enabled, bool):
13204 raise ValueError("solution_monitoring.convergence.enabled must be boolean.")
13205 mode = normalize_solution_convergence_mode(convergence.get("mode", "steady_deterministic"))
13206 normalized = {"enabled": enabled, "mode": mode.lower()}
13207 periodic_cfg = convergence.get("periodic_deterministic")
13208 statistical_cfg = convergence.get("statistical_steady")
13209 if mode == "PERIODIC_DETERMINISTIC":
13210 if not isinstance(periodic_cfg, dict):
13211 raise ValueError(
13212 "solution_monitoring.convergence.periodic_deterministic is required for periodic_deterministic mode."
13213 )
13214 period_steps = periodic_cfg.get("period_steps")
13215 if isinstance(period_steps, bool) or not isinstance(period_steps, int) or period_steps <= 0:
13216 raise ValueError(
13217 "solution_monitoring.convergence.periodic_deterministic.period_steps must be a positive integer."
13218 )
13219 normalized["periodic_deterministic"] = {"period_steps": period_steps}
13220 elif periodic_cfg is not None:
13221 raise ValueError(
13222 "solution_monitoring.convergence.periodic_deterministic is only valid for periodic_deterministic mode."
13223 )
13224 if mode == "STATISTICAL_STEADY":
13225 if not isinstance(statistical_cfg, dict):
13226 raise ValueError(
13227 "solution_monitoring.convergence.statistical_steady is required for statistical_steady mode."
13228 )
13229 window_steps = statistical_cfg.get("window_steps")
13230 if isinstance(window_steps, bool) or not isinstance(window_steps, int) or window_steps <= 0:
13231 raise ValueError(
13232 "solution_monitoring.convergence.statistical_steady.window_steps must be a positive integer."
13233 )
13234 normalized["statistical_steady"] = {"window_steps": window_steps}
13235 elif statistical_cfg is not None:
13236 raise ValueError(
13237 "solution_monitoring.convergence.statistical_steady is only valid for statistical_steady mode."
13238 )
13239 return {"convergence": normalized}
13240
13241
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_solution_monitoring_flags()

dict picurv_cli.core.resolve_solution_monitoring_flags ( dict  monitor_cfg)

Translate solution-monitoring YAML into the existing C convergence flags.

Parameters
[in]monitor_cfgParsed monitor.yml mapping.
Returns
Mapping of convergence options to explicit runtime values.

Definition at line 13242 of file core.py.

13242def resolve_solution_monitoring_flags(monitor_cfg: dict) -> dict:
13243 """!
13244 @brief Translate solution-monitoring YAML into the existing C convergence flags.
13245 @param[in] monitor_cfg Parsed monitor.yml mapping.
13246 @return Mapping of convergence options to explicit runtime values.
13247 """
13248 convergence = normalize_solution_monitoring_config(monitor_cfg)["convergence"]
13249 flags = {
13250 "-solution_convergence_enabled": "true" if convergence["enabled"] else "false",
13251 "-solution_convergence_mode": f'"{normalize_solution_convergence_mode(convergence["mode"])}"',
13252 }
13253 if "periodic_deterministic" in convergence:
13254 flags["-solution_convergence_period_steps"] = convergence["periodic_deterministic"]["period_steps"]
13255 if "statistical_steady" in convergence:
13256 flags["-solution_convergence_window_steps"] = convergence["statistical_steady"]["window_steps"]
13257 return flags
13258
13259
Here is the call graph for this function:
Here is the caller graph for this function:

◆ prepare_monitor_files()

dict picurv_cli.core.prepare_monitor_files ( str  run_dir,
str  run_id,
dict  monitor_cfg,
dict  source_files,
str   config_dir = None 
)

Generate monitor sidecar files and resolve profiling reporting behavior.

Parameters
[in]run_dirArgument passed to prepare_monitor_files().
[in]run_idArgument passed to prepare_monitor_files().
[in]monitor_cfgArgument passed to prepare_monitor_files().
[in]source_filesArgument passed to prepare_monitor_files().
[in]config_dirOptional configuration revision directory.
Returns
Value returned by prepare_monitor_files().

Definition at line 13260 of file core.py.

13261 config_dir: str = None) -> dict:
13262 """!
13263 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
13264 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
13265 @param[in] run_id Argument passed to `prepare_monitor_files()`.
13266 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
13267 @param[in] source_files Argument passed to `prepare_monitor_files()`.
13268 @param[in] config_dir Optional configuration revision directory.
13269 @return Value returned by `prepare_monitor_files()`.
13270 """
13271 print("[INFO] Generating monitoring files...")
13272
13273 whitelist_path = None
13274 if has_explicit_monitor_whitelist(monitor_cfg):
13275 enabled = normalize_monitor_function_names(
13276 monitor_cfg["logging"]["enabled_functions"], "monitor.logging.enabled_functions")
13277 whitelist_path = generate_simple_list_file(
13278 run_dir, run_id, {"logging": {"enabled_functions": enabled}}, "logging", "enabled_functions",
13279 "whitelist.run", source_files, config_dir=config_dir,
13280 )
13281 else:
13282 print("[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
13283
13284 profiling_cfg = resolve_profiling_config(monitor_cfg)
13285
13286 profile_path = None
13287 if profiling_cfg["mode"] == "selected":
13288 profile_path = generate_simple_list_file(
13289 run_dir,
13290 run_id,
13291 {"profiling": {"selected_functions": profiling_cfg["functions"]}},
13292 "profiling",
13293 "selected_functions",
13294 "profile.run",
13295 source_files,
13296 config_dir=config_dir,
13297 )
13298 else:
13299 print(f"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
13300
13301 return {
13302 "whitelist": whitelist_path,
13303 "profile": profile_path,
13304 "profiling": profiling_cfg,
13305 }
13306
Here is the call graph for this function:
Here is the caller graph for this function:

◆ generate_multi_block_bcs()

list picurv_cli.core.generate_multi_block_bcs ( str  run_dir,
str  run_id,
dict  case_cfg,
dict  source_files,
str   config_dir = None 
)

Parses multi-block BCs from YAML, generates a .run file for each block, and returns a list of their absolute paths.

Handles both simple list format (for single-block cases) and a list-of-lists (for multi-block cases) for boundary conditions.

Parameters
[in]run_dirThe path to the main run directory.
[in]run_idThe unique identifier for the run.
[in]case_cfgThe parsed case.yml configuration dictionary.
[in]source_filesA dictionary of source files for the header.
[in]config_dirOptional configuration revision directory.
Returns
A list of absolute paths to the generated BC files.
Exceptions
ValueErrorif the number of BC definitions does not match the number of blocks.

Definition at line 13307 of file core.py.

13308 config_dir: str = None) -> list:
13309 """!
13310 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
13311 and returns a list of their absolute paths.
13312 @details Handles both simple list format (for single-block cases) and a
13313 list-of-lists (for multi-block cases) for boundary conditions.
13314 @param[in] run_dir The path to the main run directory.
13315 @param[in] run_id The unique identifier for the run.
13316 @param[in] case_cfg The parsed case.yml configuration dictionary.
13317 @param[in] source_files A dictionary of source files for the header.
13318 @param[in] config_dir Optional configuration revision directory.
13319 @return A list of absolute paths to the generated BC files.
13320 @throws ValueError if the number of BC definitions does not match the number of blocks.
13321 """
13322 print("[INFO] Generating boundary condition files...")
13323 config_dir = config_dir or os.path.join(run_dir, "config")
13324 os.makedirs(config_dir, exist_ok=True)
13325 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
13326 os.makedirs(profile_dir, exist_ok=True)
13327 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
13328 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
13329 case_path = source_files.get("Case") if source_files else None
13330 profile_grid_dims = None
13331 # Every profile source stages a physical velocity PICSLICE.
13332 profile_velocity_scale = reference_scale(BC_PARAM_QUANTITIES["source"][0], resolve_reference_scales(case_cfg))
13333
13334 if any(bc.get("handler") == "prescribed_flow" for block in prepared_blocks for bc in block):
13335 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
13336
13337 generated_files = []
13338 generated_profile_summaries = []
13339 generated_target_grid = None
13340 field_slice_target_grid = None
13341 for i, block_bcs_list in enumerate(prepared_blocks):
13342 file_name = "bcs.run" if num_blocks == 1 else f"bcs_block{i}.run"
13343 bcs_file_path = os.path.join(config_dir, file_name)
13344 bcs_lines = [generate_header(run_id, source_files)]
13345
13346 for bc in block_bcs_list:
13347 face, bc_type, handler = bc['face'], bc['type'], bc['handler']
13348 params = dict(bc.get('params') or {})
13349 if handler == "prescribed_flow":
13350 source = params.pop("source")
13351 expected_dims = _bc_profile_expected_dims(face, profile_grid_dims[i])
13352 staged_name = f"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
13353 staged_path = os.path.join(profile_dir, staged_name)
13354 if os.path.isfile(staged_path):
13355 with open(staged_path, "r", encoding="utf-8") as stream:
13356 header = [line.strip() for line in stream if line.strip() and not line.lstrip().startswith("#")]
13357 if len(header) < 3 or header[0] != "PICSLICE":
13358 raise ValueError(f"Locked inlet profile is not a PICSLICE file: {staged_path}")
13359 try:
13360 actual_dims = tuple(int(token) for token in header[2].split())
13361 except ValueError as exc:
13362 raise ValueError(f"Locked inlet profile has invalid dimensions: {staged_path}") from exc
13363 if actual_dims != tuple(expected_dims):
13364 raise ValueError(
13365 f"Locked inlet profile {staged_path} has dimensions {actual_dims}; "
13366 f"expected {tuple(expected_dims)}."
13367 )
13368 print(
13369 f"[INFO] Reusing locked prescribed_flow profile for block {i}, "
13370 f"face {face}: {os.path.relpath(staged_path)}"
13371 )
13372 params["source_file"] = os.path.abspath(staged_path)
13373 source = None
13374 elif source["type"] == "file":
13375 source_path = _resolve_case_relative_path(
13376 source["path"], os.path.dirname(os.path.abspath(case_path))
13377 )
13378 elif source["type"] == "generated":
13379 source_path = os.path.join(
13380 profile_dir,
13381 f"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.dimensional.picslice",
13382 )
13383 if os.path.abspath(source_path) == os.path.abspath(staged_path):
13384 raise ValueError(
13385 f"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
13386 )
13387 if source["generator"] == "square_duct_poiseuille":
13388 if generated_target_grid is None:
13389 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
13390 summary = generate_square_duct_poiseuille_picslice(
13391 source_path,
13392 expected_dims,
13393 source["params"],
13394 target_grid=generated_target_grid,
13395 target_block=i,
13396 target_face=face,
13397 script=source.get("script"),
13398 case_path=case_path,
13399 )
13400 else:
13401 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
13402 summary.update({"block": i, "face": face})
13403 generated_profile_summaries.append(summary)
13404 elif source["type"] == "field_slice":
13405 source_path = os.path.join(
13406 profile_dir,
13407 f"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.dimensional.picslice",
13408 )
13409 if os.path.abspath(source_path) == os.path.abspath(staged_path):
13410 raise ValueError(
13411 f"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
13412 )
13413 if field_slice_target_grid is None:
13414 field_slice_target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
13415 summary = generate_field_slice_picslice(
13416 source_path,
13417 expected_dims,
13418 source,
13419 field_slice_target_grid,
13420 face,
13421 i,
13422 case_path,
13423 )
13424 summary.update({"block": i, "face": face})
13425 generated_profile_summaries.append(summary)
13426 elif source is not None:
13427 raise ValueError(f"Unsupported prescribed_flow source type '{source.get('type')}'.")
13428 if source is not None:
13429 summary = validate_and_nondimensionalize_picslice(source_path, staged_path, profile_velocity_scale, expected_dims)
13430 print(
13431 f"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
13432 f"{os.path.relpath(staged_path)} dims={summary['dims']}"
13433 )
13434 params["source_file"] = os.path.abspath(staged_path)
13435 params_str = ""
13436 if params:
13437 parts = []
13438 for k, v in params.items():
13439 if isinstance(v, bool):
13440 value_str = "true" if v else "false"
13441 else:
13442 value_str = str(v)
13443 parts.append(f"{k}={value_str}")
13444 params_str = " ".join(parts)
13445 bcs_lines.append(f"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
13446
13447 with open(bcs_file_path, "w") as f: f.write("\n".join(bcs_lines))
13448
13449 print(f"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
13450 generated_files.append(os.path.abspath(bcs_file_path))
13451
13452 if generated_profile_summaries:
13453 info_path = write_profile_info(profile_dir, generated_profile_summaries)
13454 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
13455
13456 return generated_files
13457
Here is the call graph for this function:
Here is the caller graph for this function:

◆ format_flag_value()

picurv_cli.core.format_flag_value (   value)

Converts Python types to C-style command-line flag values.

Parameters
[in]valueThe Python object to convert (bool, list, or other).
Returns
A string representation suitable for a C command-line parser.

Definition at line 13458 of file core.py.

13458def format_flag_value(value):
13459 """!
13460 @brief Converts Python types to C-style command-line flag values.
13461 @param[in] value The Python object to convert (bool, list, or other).
13462 @return A string representation suitable for a C command-line parser.
13463 """
13464 if isinstance(value, bool):
13465 return "1" if value else "0"
13466 if isinstance(value, list):
13467 return ",".join(map(str, value))
13468 return str(value)
13469
Here is the caller graph for this function:

◆ translate_programmatic_grid_settings()

dict picurv_cli.core.translate_programmatic_grid_settings ( dict  grid_settings)

Return programmatic-grid settings translated to the C node-count contract.

Parameters
[in]grid_settingsArgument passed to translate_programmatic_grid_settings().
Returns
Value returned by translate_programmatic_grid_settings().

Definition at line 13470 of file core.py.

13470def translate_programmatic_grid_settings(grid_settings: dict) -> dict:
13471 """!
13472 @brief Return programmatic-grid settings translated to the C node-count contract.
13473 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
13474 @return Value returned by `translate_programmatic_grid_settings()`.
13475 """
13476 translated = dict(grid_settings)
13477 for dim_key in ("im", "jm", "km"):
13478 if dim_key in translated:
13479 raw_val = translated[dim_key]
13480 if not isinstance(raw_val, int) or raw_val <= 0:
13481 raise ValueError(
13482 f"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
13483 f"(got {raw_val!r})."
13484 )
13485 translated[dim_key] = raw_val + 1
13486 return translated
13487
13488
Here is the caller graph for this function:

◆ validate_programmatic_bridge_grid_settings()

None picurv_cli.core.validate_programmatic_bridge_grid_settings ( dict  raw_settings)

Validate the scalar programmatic grid settings the bridge PICGRID is built from.

A Python generator - an initial condition or a generated inlet profile - runs as a separate process and reads the programmatic grid from that bridge, which supports one block with scalar settings.

Parameters
[in]raw_settingsprogrammatic_settings dict from case.yml.
Exceptions
ValueErrorwhen required scalar settings are missing or invalid.

Definition at line 13496 of file core.py.

13496def validate_programmatic_bridge_grid_settings(raw_settings: dict) -> None:
13497 """!
13498 @brief Validate the scalar programmatic grid settings the bridge PICGRID is built from.
13499 @details A Python generator - an initial condition or a generated inlet profile - runs
13500 as a separate process and reads the programmatic grid from that bridge, which
13501 supports one block with scalar settings.
13502 @param[in] raw_settings programmatic_settings dict from case.yml.
13503 @throws ValueError when required scalar settings are missing or invalid.
13504 """
13505 if not isinstance(raw_settings, dict):
13506 raise ValueError(
13507 "grid.programmatic_settings must be a mapping when a Python generator reads the grid."
13508 )
13509
13510 missing = [key for key in PROGRAMMATIC_BRIDGE_GRID_KEYS if key not in raw_settings]
13511 if missing:
13512 raise ValueError(
13513 "grid.programmatic_settings must include "
13514 f"{missing} when grid.mode is 'programmatic_c' and a Python generator reads the grid."
13515 )
13516
13517 for key in ("im", "jm", "km"):
13518 value = raw_settings[key]
13519 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
13520 raise ValueError(
13521 f"grid.programmatic_settings.{key} must be a positive scalar integer cell count "
13522 "when a Python generator reads the programmatic_c grid."
13523 )
13524
13525 for key in ("xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs", "rxs", "rys", "rzs"):
13526 value = raw_settings[key]
13527 if isinstance(value, (list, tuple, dict, bool)):
13528 raise ValueError(
13529 f"grid.programmatic_settings.{key} must be a scalar numeric value "
13530 "when a Python generator reads the programmatic_c grid."
13531 )
13532 try:
13533 numeric = float(value)
13534 except (TypeError, ValueError):
13535 raise ValueError(
13536 f"grid.programmatic_settings.{key} must be a scalar numeric value "
13537 "when a Python generator reads the programmatic_c grid."
13538 )
13539 if not math.isfinite(numeric):
13540 raise ValueError(
13541 f"grid.programmatic_settings.{key} must be finite "
13542 "when a Python generator reads the programmatic_c grid."
13543 )
13544 if key in {"rxs", "rys", "rzs"} and numeric <= 0.0:
13545 raise ValueError(
13546 f"grid.programmatic_settings.{key} must be positive "
13547 "when a Python generator reads the programmatic_c grid."
13548 )
13549
13550
Here is the caller graph for this function:

◆ generate_picgrid_from_programmatic_settings()

dict picurv_cli.core.generate_picgrid_from_programmatic_settings ( dict  raw_settings,
str  dest_path,
float  L_ref 
)

Generate a canonical PICGRID file from programmatic Cartesian grid settings.

Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c. im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.

Parameters
[in]raw_settingsprogrammatic_settings dict from case.yml.
[in]dest_pathDestination PICGRID file path.
[in]L_refReference length for nondimensionalization (must be non-zero).
Returns
Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.

Definition at line 13551 of file core.py.

13551def generate_picgrid_from_programmatic_settings(raw_settings: dict, dest_path: str, L_ref: float) -> dict:
13552 """!
13553 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
13554 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
13555 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
13556 @param[in] raw_settings programmatic_settings dict from case.yml.
13557 @param[in] dest_path Destination PICGRID file path.
13558 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
13559 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
13560 """
13561 validate_programmatic_bridge_grid_settings(raw_settings)
13562 if L_ref == 0.0:
13563 raise ValueError("length_ref must be non-zero for programmatic grid generation.")
13564 IM = int(raw_settings.get("im", 0)) + 1
13565 JM = int(raw_settings.get("jm", 0)) + 1
13566 KM = int(raw_settings.get("km", 0)) + 1
13567 if IM < 2 or JM < 2 or KM < 2:
13568 raise ValueError(
13569 f"programmatic_settings im/jm/km must each be >= 1 "
13570 f"(got im={IM-1}, jm={JM-1}, km={KM-1})."
13571 )
13572 x_min = float(raw_settings.get("xMins", 0.0))
13573 x_max = float(raw_settings.get("xMaxs", 1.0))
13574 y_min = float(raw_settings.get("yMins", 0.0))
13575 y_max = float(raw_settings.get("yMaxs", 1.0))
13576 z_min = float(raw_settings.get("zMins", 0.0))
13577 z_max = float(raw_settings.get("zMaxs", 1.0))
13578 rx = float(raw_settings.get("rxs", 1.0))
13579 ry = float(raw_settings.get("rys", 1.0))
13580 rz = float(raw_settings.get("rzs", 1.0))
13581
13582 def _stretched(idx, N, length, r):
13583 """!
13584 @brief Mirror of ComputeStretchedCoord from src/grid.c.
13585 @param[in] idx Node index along the axis.
13586 @param[in] N Total node count along the axis.
13587 @param[in] length Physical length of the axis.
13588 @param[in] r Geometric stretching ratio.
13589 @return Coordinate offset from the axis minimum.
13590 """
13591 frac = idx / (N - 1.0)
13592 if abs(r - 1.0) < 1.0e-9:
13593 return length * frac
13594 return length * (r ** frac - 1.0) / (r - 1.0)
13595
13596 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
13597 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
13598 with open(dest_path, "w") as fout:
13599 fout.write("PICGRID\n1\n")
13600 fout.write(f"{IM} {JM} {KM}\n")
13601 for k in range(KM):
13602 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
13603 for j in range(JM):
13604 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
13605 for i in range(IM):
13606 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
13607 fout.write(
13608 f"{format_picgrid_coordinate(x)} {format_picgrid_coordinate(y)} "
13609 f"{format_picgrid_coordinate(z)}\n"
13610 )
13611 total_nodes = IM * JM * KM
13612 return {"nblk": 1, "dims": [(IM, JM, KM)], "total_nodes": total_nodes}
13613
13614
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_grid_da_processor_layout()

dict picurv_cli.core.resolve_grid_da_processor_layout ( dict  grid_cfg)

Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.

Parameters
[in]grid_cfgArgument passed to resolve_grid_da_processor_layout().
Returns
Value returned by resolve_grid_da_processor_layout().

Definition at line 13618 of file core.py.

13618def resolve_grid_da_processor_layout(grid_cfg: dict) -> dict:
13619 """!
13620 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
13621 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
13622 @return Value returned by `resolve_grid_da_processor_layout()`.
13623 """
13624 top_level = {}
13625 legacy = {}
13626
13627 for key in GRID_DA_PROCESSOR_KEYS:
13628 value = grid_cfg.get(key)
13629 if isinstance(value, (list, tuple)):
13630 raise ValueError(
13631 f"grid.{key} must be a scalar integer. "
13632 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
13633 )
13634 if value is not None:
13635 if not isinstance(value, int) or value <= 0:
13636 raise ValueError(f"grid.{key} must be a positive integer when provided (got {value}).")
13637 top_level[key] = value
13638
13639 legacy_settings = grid_cfg.get("programmatic_settings")
13640 if isinstance(legacy_settings, dict):
13641 for key in GRID_DA_PROCESSOR_KEYS:
13642 value = legacy_settings.get(key)
13643 if isinstance(value, (list, tuple)):
13644 raise ValueError(
13645 f"grid.programmatic_settings.{key} must be a scalar integer. "
13646 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
13647 )
13648 if value is not None:
13649 if not isinstance(value, int) or value <= 0:
13650 raise ValueError(
13651 f"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
13652 )
13653 legacy[key] = value
13654
13655 resolved = {}
13656 for key in GRID_DA_PROCESSOR_KEYS:
13657 top_value = top_level.get(key)
13658 legacy_value = legacy.get(key)
13659 if top_value is not None and legacy_value is not None and top_value != legacy_value:
13660 raise ValueError(
13661 f"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
13662 "define the processor layout in only one place."
13663 )
13664 if top_value is not None:
13665 resolved[key] = top_value
13666 elif legacy_value is not None:
13667 resolved[key] = legacy_value
13668
13669 return resolved
13670
13671
Here is the caller graph for this function:

◆ resolve_grid_node_dims()

"list | None" picurv_cli.core.resolve_grid_node_dims ( dict  case_cfg,
str  case_path,
"str | None"   staged_grid = None 
)

Per-block (IM, JM, KM) node counts the solver will read, without building the grid.

A staged PICGRID, when one exists, is authoritative. Otherwise a file grid's header is read, a programmatic grid's cell counts are converted to nodes, and a grid_gen grid's cell counts are resolved by grid.gen's own parameter precedence without generating any coordinates.

Parameters
[in]case_cfgParsed case configuration.
[in]case_pathPath to case.yml, for case-relative paths.
[in]staged_gridAlready staged inputs/grid/grid.run, if any.
Returns
List of node-count tuples, or None when the dimensions cannot be determined.

Definition at line 13672 of file core.py.

13673 staged_grid: "str | None" = None) -> "list | None":
13674 """!
13675 @brief Per-block (IM, JM, KM) node counts the solver will read, without building the grid.
13676 @details A staged PICGRID, when one exists, is authoritative. Otherwise a file grid's
13677 header is read, a programmatic grid's cell counts are converted to nodes,
13678 and a `grid_gen` grid's cell counts are resolved by grid.gen's own
13679 parameter precedence without generating any coordinates.
13680 @param[in] case_cfg Parsed case configuration.
13681 @param[in] case_path Path to case.yml, for case-relative paths.
13682 @param[in] staged_grid Already staged `inputs/grid/grid.run`, if any.
13683 @return List of node-count tuples, or None when the dimensions cannot be determined.
13684 """
13685 grid_cfg = case_cfg.get("grid") or {}
13686 expected_nblk = int(((case_cfg.get("models") or {}).get("domain") or {}).get("blocks", 1))
13687 case_dir = os.path.dirname(os.path.abspath(case_path))
13688 mode = grid_cfg.get("mode")
13689 try:
13690 if staged_grid and os.path.isfile(staged_grid):
13691 return read_picgrid_header_dimensions(staged_grid, expected_nblk)
13692 if mode == "file":
13693 return read_picgrid_header_dimensions(
13694 _resolve_case_relative_path(grid_cfg["source_file"], case_dir), expected_nblk)
13695 if mode == "programmatic_c":
13696 settings = translate_programmatic_grid_settings(grid_cfg.get("programmatic_settings") or {})
13697 return [(settings["im"], settings["jm"], settings["km"])]
13698 if mode == "grid_gen":
13699 generator = grid_cfg.get("generator") or {}
13700 if generator.get("script"):
13701 return None
13702 tokens = ["-c", _resolve_case_relative_path(generator["config_file"], case_dir)]
13703 if generator.get("grid_type"):
13704 tokens.append(str(generator["grid_type"]))
13705 tokens.extend(str(token) for token in (generator.get("cli_args") or []))
13706 grid_gen = load_generator_module("grid.gen", "picurv_grid_generator")
13707 with contextlib.redirect_stdout(StringIO()), contextlib.redirect_stderr(StringIO()):
13708 _, params = grid_gen.resolve_grid_parameters(tokens)
13709 return [(params.ncells_i + 1, params.ncells_j + 1, params.ncells_k + 1)]
13710 except (SystemExit, Exception):
13711 # Staging reports a malformed grid in its own terms; this check only adds to it.
13712 return None
13713 return None
13714
13715
Here is the call graph for this function:
Here is the caller graph for this function:

◆ case_declares_periodic_axis()

bool picurv_cli.core.case_declares_periodic_axis ( dict  case_cfg)

Whether any face of any block is PERIODIC, which sets the DMDA stencil width.

Parameters
[in]case_cfgParsed case configuration.
Returns
True when grid setup will use stencil width 3 on every axis.

Definition at line 13716 of file core.py.

13716def case_declares_periodic_axis(case_cfg: dict) -> bool:
13717 """!
13718 @brief Whether any face of any block is PERIODIC, which sets the DMDA stencil width.
13719 @param[in] case_cfg Parsed case configuration.
13720 @return True when grid setup will use stencil width 3 on every axis.
13721 """
13722 try:
13723 blocks = validate_and_prepare_boundary_conditions(case_cfg)
13724 except ValueError:
13725 return False
13726 return any(entry.get("type") == "PERIODIC" for block in blocks for entry in block)
13727
13728
Here is the call graph for this function:
Here is the caller graph for this function:

◆ da_processor_layout_problems()

list picurv_cli.core.da_processor_layout_problems ( dict  grid_cfg,
"int | None"  num_procs,
"list | None"   block_dims = None,
"dict | None"   solver_cfg = None,
bool   any_periodic = False,
"str | None"   source = None 
)

Problems with a user-set DMDA layout that would abort grid setup.

Mirrors InitializeAllGridDMs() in src/grid.c: each coarser level holds (IM + 1) / 2 nodes on an axis that is not semi-coarsened, the DMDA carries IM + 1 points, and PETSc's default split gives the smallest rank points // ranks of them. Grid setup requires that to reach the stencil width, which is 3 on every axis when any face is periodic and 2 otherwise. Only the coarsest level can fail, because every finer level is aligned to twice its widths.

Parameters
[in]grid_cfgThe case's grid block.
[in]num_procsMPI processes the solve will launch, or None when unknown (validate without a cluster profile), which skips only the product check.
[in]block_dimsPer-block node counts, or None to skip the multigrid check.
[in]solver_cfgParsed solver.yml, for the multigrid levels and semi-coarsening.
[in]any_periodicWhether any face is PERIODIC.
[in]sourceWhere the layout was given, as --ni/--nj/--nk or cluster.yml execution.decomposition; None for case.yml's own keys.
Returns
List of messages; empty when the layout is usable or not set.

Definition at line 13729 of file core.py.

13731 any_periodic: bool = False, source: "str | None" = None) -> list:
13732 """!
13733 @brief Problems with a user-set DMDA layout that would abort grid setup.
13734 @details Mirrors `InitializeAllGridDMs()` in src/grid.c: each coarser level holds
13735 (IM + 1) / 2 nodes on an axis that is not semi-coarsened, the DMDA carries
13736 IM + 1 points, and PETSc's default split gives the smallest rank
13737 points // ranks of them. Grid setup requires that to reach the stencil
13738 width, which is 3 on every axis when any face is periodic and 2 otherwise.
13739 Only the coarsest level can fail, because every finer level is aligned to
13740 twice its widths.
13741 @param[in] grid_cfg The case's grid block.
13742 @param[in] num_procs MPI processes the solve will launch, or None when unknown (validate
13743 without a cluster profile), which skips only the product check.
13744 @param[in] block_dims Per-block node counts, or None to skip the multigrid check.
13745 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels and semi-coarsening.
13746 @param[in] any_periodic Whether any face is PERIODIC.
13747 @param[in] source Where the layout was given, as `--ni/--nj/--nk` or
13748 `cluster.yml execution.decomposition`; None for case.yml's own keys.
13749 @return List of messages; empty when the layout is usable or not set.
13750 """
13751 layout = resolve_grid_da_processor_layout(grid_cfg)
13752 if source == "--ni/--nj/--nk":
13753 names = dict(zip(GRID_DA_PROCESSOR_KEYS, ("--ni", "--nj", "--nk")))
13754 together = "--ni x --nj x --nk"
13755 elif source:
13756 names = {key: f"{source}.{axis}" for key, axis in zip(GRID_DA_PROCESSOR_KEYS, "ijk")}
13757 together = f"{source} (i, j, k)"
13758 else:
13759 names = {key: f"grid.{key}" for key in GRID_DA_PROCESSOR_KEYS}
13760 together = "grid.da_processors_x/y/z"
13761 if not layout or (num_procs is not None and num_procs <= 1):
13762 return []
13763 problems = []
13764 if num_procs is not None and all(layout.get(key) is not None for key in GRID_DA_PROCESSOR_KEYS):
13765 total_layout = 1
13766 for key in GRID_DA_PROCESSOR_KEYS:
13767 total_layout *= layout[key]
13768 if total_layout != num_procs:
13769 printable = " x ".join(str(layout[key]) for key in GRID_DA_PROCESSOR_KEYS)
13770 problems.append(
13771 "DMDA processor layout mismatch: "
13772 f"{together} is {printable} (product {total_layout}), "
13773 f"but this run requests {num_procs} MPI processes. "
13774 "Give values whose product equals the requested MPI process count, or "
13775 "remove all three settings to let PICurv choose the layout."
13776 )
13777 if not block_dims:
13778 return problems
13779 multigrid = (((solver_cfg or {}).get("poisson_solver") or {}).get("multigrid") or {})
13780 levels = int(multigrid.get("levels", 3))
13781 semi = multigrid.get("semi_coarsening") or {}
13782 stencil = 3 if any_periodic else 2
13783 for block, dims in enumerate(block_dims):
13784 for axis, key, nodes in zip("ijk", GRID_DA_PROCESSOR_KEYS, dims):
13785 ranks = layout.get(key)
13786 if ranks is None:
13787 continue
13788 coarse = nodes
13789 if str(semi.get(axis, False)).strip().lower() not in ("true", "1", "yes"):
13790 for _ in range(levels - 1):
13791 coarse = (coarse + 1) // 2
13792 points = coarse + 1
13793 if points // ranks < stencil:
13794 problems.append(
13795 f"{names[key]} = {ranks} is too many ranks for block {block}'s "
13796 f"{axis} axis at {levels} multigrid levels: the coarsest level has "
13797 f"{points} points there, so a rank gets {points // ranks}, below the "
13798 f"stencil width {stencil}"
13799 + (" (3 on every axis because a face is PERIODIC)" if any_periodic else "")
13800 + f". Grid setup would abort with 'Local width ... is smaller than "
13801 f"stencil width'. Use at most {max(points // stencil, 1)} rank(s) on "
13802 "this axis, or lower poisson_solver.multigrid.levels."
13803 )
13804 return problems
13805
13806
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_decomposition_args()

None picurv_cli.core.normalize_decomposition_args (   args)

Resolve -n and the optional --ni/--nj/--nk of picurv run into one rank count.

The three counts go together. Given them, -n defaults to their product and must equal it when also given; without them, -n defaults to 1. Sets args.num_procs and args.decomposition (a tuple or None).

Parameters
[in,out]argsParsed picurv run arguments.

Definition at line 13807 of file core.py.

13807def normalize_decomposition_args(args) -> None:
13808 """!
13809 @brief Resolve `-n` and the optional `--ni/--nj/--nk` of `picurv run` into one rank count.
13810 @details The three counts go together. Given them, `-n` defaults to their product and
13811 must equal it when also given; without them, `-n` defaults to 1. Sets
13812 `args.num_procs` and `args.decomposition` (a tuple or None).
13813 @param[in,out] args Parsed `picurv run` arguments.
13814 """
13815 parts = [getattr(args, key, None) for key in ("ni", "nj", "nk")]
13816 args.decomposition = None
13817 if any(part is not None for part in parts):
13818 if any(part is None or part <= 0 for part in parts):
13819 fail_cli_usage("--ni, --nj and --nk are given together, as positive integers.")
13820 product = parts[0] * parts[1] * parts[2]
13821 if args.num_procs is None:
13822 args.num_procs = product
13823 elif args.num_procs != product:
13824 fail_cli_usage(f"--ni x --nj x --nk is {parts[0]} x {parts[1]} x {parts[2]} = {product} ranks, "
13825 f"but -n is {args.num_procs}; give one or make them agree.")
13826 args.decomposition = tuple(parts)
13827 if args.num_procs is None:
13828 args.num_procs = 1
13829 if args.num_procs < 1:
13830 fail_cli_usage("-n must be a positive integer.")
13831
13832
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _decomposition_layout()

dict picurv_cli.core._decomposition_layout (   layout)
protected

Express an (ni, nj, nk) tuple as the da_processors mapping the layout checks read.

Parameters
[in]layoutRank counts along i, j, k.
Returns
{"da_processors_x": ni, "da_processors_y": nj, "da_processors_z": nk}.

Definition at line 13833 of file core.py.

13833def _decomposition_layout(layout) -> dict:
13834 """!
13835 @brief Express an (ni, nj, nk) tuple as the da_processors mapping the layout checks read.
13836 @param[in] layout Rank counts along i, j, k.
13837 @return {"da_processors_x": ni, "da_processors_y": nj, "da_processors_z": nk}.
13838 """
13839 return dict(zip(GRID_DA_PROCESSOR_KEYS, layout))
13840
13841
Here is the caller graph for this function:

◆ choose_decomposition()

picurv_cli.core.choose_decomposition ( int  num_procs,
list  block_dims,
"dict | None"  solver_cfg,
bool  any_periodic 
)

Choose the MPI decomposition of a structured grid for a rank count.

Every ordered (ni, nj, nk) with product num_procs that grid setup accepts at the multigrid levels in use (da_processor_layout_problems) is a candidate. The one cutting the least grid area between ranks wins, so a thin axis, such as the i axis of a 2D case, is not split while another can be; ties go to the most even split, then to more ranks on k, then on j.

Parameters
[in]num_procsMPI ranks.
[in]block_dimsPer-block node counts.
[in]solver_cfgParsed solver.yml, for the multigrid levels and semi-coarsening.
[in]any_periodicWhether any face is PERIODIC, which sets the stencil width.
Returns
(ni, nj, nk), or None when no decomposition is feasible.

Definition at line 13842 of file core.py.

13843 any_periodic: bool):
13844 """!
13845 @brief Choose the MPI decomposition of a structured grid for a rank count.
13846 @details Every ordered (ni, nj, nk) with product `num_procs` that grid setup accepts at
13847 the multigrid levels in use (da_processor_layout_problems) is a candidate. The
13848 one cutting the least grid area between ranks wins, so a thin axis, such as the
13849 i axis of a 2D case, is not split while another can be; ties go to the most even
13850 split, then to more ranks on k, then on j.
13851 @param[in] num_procs MPI ranks.
13852 @param[in] block_dims Per-block node counts.
13853 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels and semi-coarsening.
13854 @param[in] any_periodic Whether any face is PERIODIC, which sets the stencil width.
13855 @return (ni, nj, nk), or None when no decomposition is feasible.
13856 """
13857 best = None
13858 for ni in range(1, num_procs + 1):
13859 if num_procs % ni:
13860 continue
13861 for nj in range(1, num_procs // ni + 1):
13862 if (num_procs // ni) % nj:
13863 continue
13864 layout = (ni, nj, num_procs // ni // nj)
13865 if da_processor_layout_problems(_decomposition_layout(layout), num_procs, block_dims,
13866 solver_cfg, any_periodic):
13867 continue
13868 cut = imbalance = 0.0
13869 for dims in block_dims:
13870 cells = [max(d - 1, 1) for d in dims]
13871 cut += sum((layout[a] - 1) * cells[(a + 1) % 3] * cells[(a + 2) % 3] for a in range(3))
13872 largest = 1.0
13873 for a in range(3):
13874 largest *= -(-cells[a] // layout[a])
13875 imbalance = max(imbalance, largest * num_procs / (cells[0] * cells[1] * cells[2]))
13876 key = (cut, imbalance, -layout[2], -layout[1])
13877 if best is None or key < best[0]:
13878 best = (key, layout)
13879 return best[1] if best else None
13880
13881
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_decomposition()

dict picurv_cli.core.resolve_decomposition ( int  num_procs,
dict  grid_cfg,
"list | None"  block_dims,
"dict | None"  solver_cfg,
bool  any_periodic,
  explicit = None,
"dict | None"   cluster_cfg = None 
)

Resolve the MPI decomposition a stage launches with.

In order of precedence: --ni/--nj/--nk, cluster.yml execution.decomposition, the deprecated case.yml grid.da_processors_*, and otherwise choose_decomposition(). A set layout must multiply to the rank count and pass the multigrid check. When the grid's size cannot be known before the run, PETSc chooses.

Parameters
[in]num_procsMPI ranks of the stage.
[in]grid_cfgThe case's grid block.
[in]block_dimsPer-block node counts, or None when unknown.
[in]solver_cfgParsed solver.yml.
[in]any_periodicWhether any face is PERIODIC.
[in]explicit(ni, nj, nk) from the command line, or None.
[in]cluster_cfgParsed cluster.yml, or None.
Returns
{"layout": (ni, nj, nk) or None, "source": str, "problems": [str]}.

Definition at line 13882 of file core.py.

13884 explicit=None, cluster_cfg: "dict | None" = None) -> dict:
13885 """!
13886 @brief Resolve the MPI decomposition a stage launches with.
13887 @details In order of precedence: `--ni/--nj/--nk`, cluster.yml
13888 `execution.decomposition`, the deprecated case.yml `grid.da_processors_*`, and
13889 otherwise choose_decomposition(). A set layout must multiply to the rank count
13890 and pass the multigrid check. When the grid's size cannot be known before the
13891 run, PETSc chooses.
13892 @param[in] num_procs MPI ranks of the stage.
13893 @param[in] grid_cfg The case's grid block.
13894 @param[in] block_dims Per-block node counts, or None when unknown.
13895 @param[in] solver_cfg Parsed solver.yml.
13896 @param[in] any_periodic Whether any face is PERIODIC.
13897 @param[in] explicit (ni, nj, nk) from the command line, or None.
13898 @param[in] cluster_cfg Parsed cluster.yml, or None.
13899 @return {"layout": (ni, nj, nk) or None, "source": str, "problems": [str]}.
13900 """
13901 if num_procs <= 1:
13902 return {"layout": (1, 1, 1), "source": "serial", "problems": []}
13903 layout, source = None, None
13904 if explicit:
13905 layout, source = tuple(explicit), "--ni/--nj/--nk"
13906 elif isinstance(((cluster_cfg or {}).get("execution") or {}).get("decomposition"), dict):
13907 spec = cluster_cfg["execution"]["decomposition"]
13908 if not all(isinstance(spec.get(axis), int) and spec.get(axis) > 0 for axis in "ijk"):
13909 return {"layout": None, "source": "cluster.yml",
13910 "problems": ["cluster.yml execution.decomposition needs positive integers i, j and k."]}
13911 layout, source = (spec["i"], spec["j"], spec["k"]), "cluster.yml execution.decomposition"
13912 else:
13913 legacy = resolve_grid_da_processor_layout(grid_cfg or {})
13914 if legacy:
13915 print("[WARNING] case.yml grid.da_processors_x/y/z is deprecated; give the decomposition "
13916 "with --ni/--nj/--nk or cluster.yml execution.decomposition.", file=sys.stderr)
13917 if len(legacy) == 3:
13918 layout, source = tuple(legacy[key] for key in GRID_DA_PROCESSOR_KEYS), "case.yml (deprecated)"
13919 else:
13920 problems = da_processor_layout_problems(grid_cfg, num_procs, block_dims, solver_cfg, any_periodic)
13921 return {"layout": None, "partial": legacy, "source": "case.yml (deprecated, partial)",
13922 "problems": problems}
13923 if layout is not None:
13924 return {"layout": layout, "source": source,
13925 "problems": da_processor_layout_problems(
13926 _decomposition_layout(layout), num_procs, block_dims, solver_cfg, any_periodic,
13927 None if source.startswith("case.yml") else source)}
13928 if not block_dims:
13929 return {"layout": None, "source": "PETSc (grid size unknown before the run)", "problems": []}
13930 layout = choose_decomposition(num_procs, block_dims, solver_cfg, any_periodic)
13931 if layout is None:
13932 feasible = [n for n in range(max(num_procs - 16, 2), num_procs + 17)
13933 if choose_decomposition(n, block_dims, solver_cfg, any_periodic)]
13934 nearest = sorted(feasible, key=lambda n: (abs(n - num_procs), n))[:3]
13935 return {"layout": None, "source": "auto", "problems": [
13936 f"No decomposition of {num_procs} ranks fits this grid at its multigrid levels "
13937 f"(each rank needs at least the stencil width of points on the coarsest level). "
13938 + (f"Rank counts that fit nearby: {', '.join(map(str, sorted(nearest)))}." if nearest
13939 else "Lower poisson_solver.multigrid.levels or refine the grid.")]}
13940 return {"layout": layout, "source": "auto", "problems": []}
13941
13942
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _decomposition_record()

"dict | None" picurv_cli.core._decomposition_record ( "dict | None"  resolved)
protected

The JSON-serializable record of a resolved decomposition for run metadata.

Parameters
[in]resolvedResult of resolve_decomposition(), or None.
Returns
{"layout": [ni, nj, nk] or None, "source": str}, or None.

Definition at line 13943 of file core.py.

13943def _decomposition_record(resolved: "dict | None") -> "dict | None":
13944 """!
13945 @brief The JSON-serializable record of a resolved decomposition for run metadata.
13946 @param[in] resolved Result of resolve_decomposition(), or None.
13947 @return {"layout": [ni, nj, nk] or None, "source": str}, or None.
13948 """
13949 if not resolved:
13950 return None
13951 return {"layout": list(resolved["layout"]) if resolved.get("layout") else None,
13952 "source": resolved["source"]}
13953
13954
Here is the caller graph for this function:

◆ describe_decomposition()

str picurv_cli.core.describe_decomposition ( dict  resolved,
int  num_procs 
)

One line stating the decomposition a stage launches with.

Parameters
[in]resolvedResult of resolve_decomposition().
[in]num_procsMPI ranks of the stage.
Returns
Human-readable description.

Definition at line 13955 of file core.py.

13955def describe_decomposition(resolved: dict, num_procs: int) -> str:
13956 """!
13957 @brief One line stating the decomposition a stage launches with.
13958 @param[in] resolved Result of resolve_decomposition().
13959 @param[in] num_procs MPI ranks of the stage.
13960 @return Human-readable description.
13961 """
13962 if resolved.get("problems"):
13963 return f"MPI decomposition: none usable for {num_procs} rank(s) ({resolved['source']})."
13964 if resolved.get("layout") is None:
13965 return f"MPI decomposition: chosen by PETSc for {num_procs} rank(s) ({resolved['source']})."
13966 ni, nj, nk = resolved["layout"]
13967 return f"MPI decomposition: {ni} x {nj} x {nk} (i x j x k) for {num_procs} rank(s), from {resolved['source']}."
13968
13969
Here is the caller graph for this function:

◆ append_grid_da_processor_layout()

dict picurv_cli.core.append_grid_da_processor_layout ( list  control_lines,
dict  grid_cfg,
int  num_procs,
"list | None"   block_dims = None,
"dict | None"   solver_cfg = None,
bool   any_periodic = False,
  explicit = None,
"dict | None"   cluster_cfg = None 
)

Resolve the solver's MPI decomposition, report it, and append its flags.

Parameters
[in]control_linesControl-file lines to extend.
[in]grid_cfgThe case's grid block.
[in]num_procsMPI ranks of the solve.
[in]block_dimsPer-block node counts, for the multigrid check and the choice.
[in]solver_cfgParsed solver.yml, for the multigrid levels.
[in]any_periodicWhether any face is PERIODIC.
[in]explicit(ni, nj, nk) from the command line, or None.
[in]cluster_cfgParsed cluster.yml, or None.
Returns
The resolved decomposition (resolve_decomposition()).
Exceptions
ValueErrorwhen the decomposition would abort grid setup or none fits.

Definition at line 13970 of file core.py.

13974 explicit=None, cluster_cfg: "dict | None" = None) -> dict:
13975 """!
13976 @brief Resolve the solver's MPI decomposition, report it, and append its flags.
13977 @param[in] control_lines Control-file lines to extend.
13978 @param[in] grid_cfg The case's grid block.
13979 @param[in] num_procs MPI ranks of the solve.
13980 @param[in] block_dims Per-block node counts, for the multigrid check and the choice.
13981 @param[in] solver_cfg Parsed solver.yml, for the multigrid levels.
13982 @param[in] any_periodic Whether any face is PERIODIC.
13983 @param[in] explicit (ni, nj, nk) from the command line, or None.
13984 @param[in] cluster_cfg Parsed cluster.yml, or None.
13985 @return The resolved decomposition (resolve_decomposition()).
13986 @throws ValueError when the decomposition would abort grid setup or none fits.
13987 """
13988 resolved = resolve_decomposition(num_procs, grid_cfg, block_dims, solver_cfg, any_periodic,
13989 explicit, cluster_cfg)
13990 if resolved["problems"]:
13991 raise ValueError(" ".join(resolved["problems"]))
13992 print(f"[INFO] {describe_decomposition(resolved, num_procs)}")
13993 layout = resolved.get("layout")
13994 if num_procs > 1 and layout is not None:
13995 control_lines.extend(f"-{key} {value}" for key, value in zip(GRID_DA_PROCESSOR_KEYS, layout))
13996 elif num_procs > 1 and resolved.get("partial"):
13997 control_lines.extend(f"-{key} {value}" for key, value in resolved["partial"].items())
13998 return resolved
13999
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_momentum_solver_type()

str picurv_cli.core.normalize_momentum_solver_type ( str  value)

Maps canonical user-facing momentum solver names to C-enum CLI values.

Parameters
[in]valueCanonical momentum solver string from YAML.
Returns
Canonical value accepted by -mom_solver_type.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 14000 of file core.py.

14000def normalize_momentum_solver_type(value: str) -> str:
14001 """!
14002 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
14003 @param[in] value Canonical momentum solver string from YAML.
14004 @return Canonical value accepted by -mom_solver_type.
14005 @throws ValueError if the input cannot be mapped.
14006 """
14007 # Only implemented YAML values belong here. Extend only with matching C enum/parser/dispatch support.
14008 if value is None:
14009 raise ValueError("momentum solver type cannot be None")
14010
14011 raw = str(value).strip()
14012 mapped = {
14013 "Explicit RK4": "EXPLICIT_RK",
14014 "Dual Time Picard Jameson RK": "DUALTIME_PICARD_JAMESON_RK",
14015 "Dual Time Picard RK4": "DUALTIME_PICARD_JAMESON_RK",
14016 "Newton Krylov": "newton_krylov",
14017 }.get(raw)
14018 if mapped is None:
14019 raise ValueError(
14020 f"Unknown momentum solver '{value}'. Use one of: "
14021 "'Explicit RK4', 'Dual Time Picard Jameson RK', 'Newton Krylov'."
14022 )
14023 return mapped
14024
14025
Here is the caller graph for this function:

◆ validate_newton_krylov_config()

dict picurv_cli.core.validate_newton_krylov_config ( dict  cfg)

Validate and normalize the structured Newton–Krylov solver block.

Parameters
[in]cfgStructured momentum_solver.newton_krylov mapping.
Returns
Normalized copy containing only supported structured fields.

Definition at line 14026 of file core.py.

14026def validate_newton_krylov_config(cfg: dict) -> dict:
14027 """!
14028 @brief Validate and normalize the structured Newton--Krylov solver block.
14029 @param[in] cfg Structured `momentum_solver.newton_krylov` mapping.
14030 @return Normalized copy containing only supported structured fields.
14031 """
14032 root = "momentum_solver.newton_krylov"
14033 if not isinstance(cfg, dict):
14034 raise ValueError(f"{root} must be a mapping.")
14035
14036 unknown = sorted(set(cfg) - {
14037 "jacobian", "preconditioner", "nonlinear_solver", "linear_solver",
14038 })
14039 if unknown:
14040 raise ValueError(f"{root} has unsupported key(s): {unknown}.")
14041
14042 normalized = {}
14043
14044 def _mapping(parent: dict, key: str, path: str) -> dict:
14045 """!
14046 @brief Read and validate one optional nested Newton mapping.
14047 @param[in] parent Parent mapping.
14048 @param[in] key Nested key to read.
14049 @param[in] path User-facing YAML path for errors.
14050 @return Nested mapping, or an empty mapping when omitted.
14051 """
14052 value = parent.get(key, {})
14053 if value is None or not isinstance(value, dict):
14054 raise ValueError(f"{path} must be a mapping when provided.")
14055 return value
14056
14057 def _method(value, path: str) -> str:
14058 """!
14059 @brief Normalize one nonempty PETSc solver/type token.
14060 @param[in] value YAML token value.
14061 @param[in] path User-facing YAML path for errors.
14062 @return Lowercase PETSc token.
14063 """
14064 if not isinstance(value, str) or not value.strip():
14065 raise ValueError(f"{path} must be a non-empty string.")
14066 return value.strip().lower()
14067
14068 def _tolerance(value, path: str):
14069 """!
14070 @brief Validate one finite nonnegative tolerance.
14071 @param[in] value YAML tolerance value.
14072 @param[in] path User-facing YAML path for errors.
14073 @return Original validated value.
14074 """
14075 if isinstance(value, bool):
14076 raise ValueError(f"{path} must be numeric and nonnegative.")
14077 try:
14078 numeric = float(value)
14079 except (TypeError, ValueError) as exc:
14080 raise ValueError(f"{path} must be numeric and nonnegative.") from exc
14081 if not math.isfinite(numeric) or numeric < 0.0:
14082 raise ValueError(f"{path} must be numeric and nonnegative.")
14083 return value
14084
14085 def _positive_integer(value, path: str):
14086 """!
14087 @brief Validate one positive integer count.
14088 @param[in] value YAML count value.
14089 @param[in] path User-facing YAML path for errors.
14090 @return Original validated integer.
14091 """
14092 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
14093 raise ValueError(f"{path} must be a positive integer.")
14094 return value
14095
14096 jacobian_path = f"{root}.jacobian"
14097 if "jacobian" not in cfg:
14098 normalized["jacobian"] = {
14099 "type": "finite_difference",
14100 "finite_difference": {"mode": "matrix_free"},
14101 }
14102 else:
14103 jacobian = _mapping(cfg, "jacobian", jacobian_path)
14104 unknown = sorted(set(jacobian) - {"type", "finite_difference"})
14105 if unknown:
14106 raise ValueError(f"{jacobian_path} has unsupported key(s): {unknown}.")
14107 if "type" not in jacobian:
14108 raise ValueError(f"{jacobian_path}.type is required when {jacobian_path} is provided.")
14109 jacobian_type = _method(jacobian["type"], f"{jacobian_path}.type")
14110 if jacobian_type == "frozen_momentum_approximation":
14111 raise ValueError(
14112 f"{jacobian_path}.type 'frozen_momentum_approximation' is not implemented."
14113 )
14114 if jacobian_type != "finite_difference":
14115 raise ValueError(
14116 f"{jacobian_path}.type currently supports only 'finite_difference' "
14117 f"(got '{jacobian_type}')."
14118 )
14119 finite_difference_path = f"{jacobian_path}.finite_difference"
14120 if "finite_difference" not in jacobian:
14121 raise ValueError(
14122 f"{finite_difference_path} is required when {jacobian_path}.type is "
14123 "'finite_difference'."
14124 )
14125 finite_difference = _mapping(
14126 jacobian, "finite_difference", finite_difference_path
14127 )
14128 unknown = sorted(set(finite_difference) - {"mode"})
14129 if unknown:
14130 raise ValueError(f"{finite_difference_path} has unsupported key(s): {unknown}.")
14131 if "mode" not in finite_difference:
14132 raise ValueError(f"{finite_difference_path}.mode is required.")
14133 finite_difference_mode = _method(
14134 finite_difference["mode"], f"{finite_difference_path}.mode"
14135 )
14136 if finite_difference_mode == "colored_sparse":
14137 raise ValueError(
14138 f"{finite_difference_path}.mode 'colored_sparse' is not implemented."
14139 )
14140 if finite_difference_mode != "matrix_free":
14141 raise ValueError(
14142 f"{finite_difference_path}.mode currently supports only 'matrix_free' "
14143 f"(got '{finite_difference_mode}')."
14144 )
14145 normalized["jacobian"] = {
14146 "type": jacobian_type,
14147 "finite_difference": {"mode": finite_difference_mode},
14148 }
14149
14150 preconditioner = _mapping(
14151 cfg, "preconditioner", f"{root}.preconditioner"
14152 ) if "preconditioner" in cfg else {}
14153 preconditioner_path = f"{root}.preconditioner"
14154 unknown = sorted(set(preconditioner) - {"model", "structure"})
14155 if unknown:
14156 raise ValueError(f"{preconditioner_path} has unsupported key(s): {unknown}.")
14157 if "preconditioner" in cfg and "model" not in preconditioner:
14158 raise ValueError(
14159 f"{preconditioner_path}.model is required when {preconditioner_path} is provided."
14160 )
14161 model = _method(preconditioner.get("model", "none"), f"{preconditioner_path}.model")
14162 if model not in NEWTON_KRYLOV_PRECONDITIONER_MODELS:
14163 raise ValueError(
14164 f"{preconditioner_path}.model supports only 'none' or 'frozen_momentum_jacobian'."
14165 )
14166 structure = _mapping(
14167 preconditioner, "structure", f"{preconditioner_path}.structure"
14168 ) if "structure" in preconditioner else {}
14169 unknown = sorted(set(structure) - {"type"})
14170 if unknown:
14171 raise ValueError(f"{preconditioner_path}.structure has unsupported key(s): {unknown}.")
14172 structure_type = _method(
14173 structure.get("type", "none"), f"{preconditioner_path}.structure.type"
14174 )
14175 if structure_type not in NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES:
14176 raise ValueError(
14177 f"{preconditioner_path}.structure.type supports only 'none' or 'point_block'."
14178 )
14179 if model == "none" and structure:
14180 raise ValueError(f"{preconditioner_path}.model 'none' does not accept a matrix structure.")
14181 if model == "frozen_momentum_jacobian" and structure_type != "point_block":
14182 raise ValueError(
14183 f"{preconditioner_path}.model 'frozen_momentum_jacobian' requires "
14184 f"{preconditioner_path}.structure.type 'point_block'."
14185 )
14186 normalized["preconditioner"] = {
14187 "model": model,
14188 "structure": {"type": structure_type},
14189 }
14190
14191 nonlinear = _mapping(cfg, "nonlinear_solver", f"{root}.nonlinear_solver")
14192 nonlinear_path = f"{root}.nonlinear_solver"
14193 unknown = sorted(set(nonlinear) - {
14194 "method", "absolute_tolerance", "relative_tolerance", "step_tolerance",
14195 "max_iterations", "line_search", "eisenstat_walker",
14196 })
14197 if unknown:
14198 raise ValueError(f"{nonlinear_path} has unsupported key(s): {unknown}.")
14199 nonlinear_out = {}
14200 if "method" in nonlinear:
14201 nonlinear_out["method"] = _method(nonlinear["method"], f"{nonlinear_path}.method")
14202 for key in ("absolute_tolerance", "relative_tolerance", "step_tolerance"):
14203 if key in nonlinear:
14204 nonlinear_out[key] = _tolerance(nonlinear[key], f"{nonlinear_path}.{key}")
14205 if "max_iterations" in nonlinear:
14206 nonlinear_out["max_iterations"] = _positive_integer(
14207 nonlinear["max_iterations"], f"{nonlinear_path}.max_iterations"
14208 )
14209 if "line_search" in nonlinear:
14210 line_search = _mapping(nonlinear, "line_search", f"{nonlinear_path}.line_search")
14211 unknown = sorted(set(line_search) - {"type"})
14212 if unknown:
14213 raise ValueError(f"{nonlinear_path}.line_search has unsupported key(s): {unknown}.")
14214 nonlinear_out["line_search"] = {}
14215 if "type" in line_search:
14216 nonlinear_out["line_search"]["type"] = _method(
14217 line_search["type"], f"{nonlinear_path}.line_search.type"
14218 )
14219 if "eisenstat_walker" in nonlinear:
14220 ew_path = f"{nonlinear_path}.eisenstat_walker"
14221 ew = _mapping(nonlinear, "eisenstat_walker", ew_path)
14222 ew_keys = {
14223 "enabled", "version", "initial_relative_tolerance",
14224 "maximum_relative_tolerance", "gamma", "exponent",
14225 "safeguard_exponent", "safeguard_threshold",
14226 }
14227 unknown = sorted(set(ew) - ew_keys)
14228 if unknown:
14229 raise ValueError(f"{ew_path} has unsupported key(s): {unknown}.")
14230 enabled = ew.get("enabled", True)
14231 if not isinstance(enabled, bool):
14232 raise ValueError(f"{ew_path}.enabled must be boolean.")
14233 if not enabled and set(ew) - {"enabled"}:
14234 raise ValueError(f"{ew_path} parameters require enabled: true.")
14235 ew_out = {"enabled": enabled}
14236 if "version" in ew:
14237 version = ew["version"]
14238 if isinstance(version, bool) or not isinstance(version, int) or version not in {1, 2, 3, 4}:
14239 raise ValueError(f"{ew_path}.version must be one of 1, 2, 3, or 4.")
14240 ew_out["version"] = version
14241 bounds = {
14242 "initial_relative_tolerance": (0.0, 1.0, False, True),
14243 "maximum_relative_tolerance": (0.0, 1.0, False, True),
14244 "gamma": (0.0, 1.0, False, False),
14245 "exponent": (1.0, 2.0, True, False),
14246 "safeguard_threshold": (0.0, 1.0, True, True),
14247 }
14248 for key, (lower, upper, lower_open, upper_open) in bounds.items():
14249 if key not in ew:
14250 continue
14251 value = _tolerance(ew[key], f"{ew_path}.{key}")
14252 numeric = float(value)
14253 valid_lower = numeric > lower if lower_open else numeric >= lower
14254 valid_upper = numeric < upper if upper_open else numeric <= upper
14255 if not (valid_lower and valid_upper):
14256 brackets = ("(" if lower_open else "[") + f"{lower}, {upper}" + (")" if upper_open else "]")
14257 raise ValueError(f"{ew_path}.{key} must be in {brackets}.")
14258 ew_out[key] = value
14259 if "safeguard_exponent" in ew:
14260 ew_out["safeguard_exponent"] = _tolerance(
14261 ew["safeguard_exponent"], f"{ew_path}.safeguard_exponent"
14262 )
14263 nonlinear_out["eisenstat_walker"] = ew_out
14264 normalized["nonlinear_solver"] = nonlinear_out
14265
14266 linear = _mapping(cfg, "linear_solver", f"{root}.linear_solver")
14267 linear_path = f"{root}.linear_solver"
14268 unknown = sorted(set(linear) - {
14269 "method", "absolute_tolerance", "relative_tolerance", "max_iterations",
14270 "gmres", "preconditioner",
14271 })
14272 if unknown:
14273 raise ValueError(f"{linear_path} has unsupported key(s): {unknown}.")
14274 linear_out = {}
14275 method = "gmres"
14276 if "method" in linear:
14277 method = _method(linear["method"], f"{linear_path}.method")
14278 linear_out["method"] = method
14279 for key in ("absolute_tolerance", "relative_tolerance"):
14280 if key in linear:
14281 linear_out[key] = _tolerance(linear[key], f"{linear_path}.{key}")
14282 if "max_iterations" in linear:
14283 linear_out["max_iterations"] = _positive_integer(
14284 linear["max_iterations"], f"{linear_path}.max_iterations"
14285 )
14286 if "gmres" in linear:
14287 gmres = _mapping(linear, "gmres", f"{linear_path}.gmres")
14288 unknown = sorted(set(gmres) - {"restart"})
14289 if unknown:
14290 raise ValueError(f"{linear_path}.gmres has unsupported key(s): {unknown}.")
14291 linear_out["gmres"] = {}
14292 if "restart" in gmres:
14293 if method not in GMRES_RESTART_METHODS:
14294 raise ValueError(
14295 f"{linear_path}.gmres.restart is valid only when {linear_path}.method "
14296 "is one of 'gmres', 'fgmres', or 'lgmres'."
14297 )
14298 linear_out["gmres"]["restart"] = _positive_integer(
14299 gmres["restart"], f"{linear_path}.gmres.restart"
14300 )
14301 if "preconditioner" in linear:
14302 compatibility_pc = _mapping(linear, "preconditioner", f"{linear_path}.preconditioner")
14303 unknown = sorted(set(compatibility_pc) - {"type"})
14304 if unknown:
14305 raise ValueError(f"{linear_path}.preconditioner has unsupported key(s): {unknown}.")
14306 if "type" in compatibility_pc:
14307 pc_type = _method(compatibility_pc["type"], f"{linear_path}.preconditioner.type")
14308 if pc_type != "none":
14309 raise ValueError(
14310 f"{linear_path}.preconditioner.type is a deprecated compatibility alias "
14311 "and supports only 'none'."
14312 )
14313 if "preconditioner" in cfg and model != "none":
14314 raise ValueError(
14315 f"{linear_path}.preconditioner.type 'none' conflicts with "
14316 f"{preconditioner_path}.model '{model}'."
14317 )
14318 warnings.warn(
14319 f"{linear_path}.preconditioner.type is deprecated; use "
14320 f"{preconditioner_path}.model: none.",
14321 FutureWarning,
14322 stacklevel=3,
14323 )
14324 normalized["linear_solver"] = linear_out
14325 return normalized
14326
Here is the caller graph for this function:

◆ normalize_solution_convergence_mode()

str picurv_cli.core.normalize_solution_convergence_mode ( str  value)

Normalizes the solution-convergence mode selector to the C-side canonical string.

Parameters
[in]valueHuman-readable solution-convergence mode selector.
Returns
Canonical string accepted by -solution_convergence_mode.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 14327 of file core.py.

14327def normalize_solution_convergence_mode(value: str) -> str:
14328 """!
14329 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
14330 @param[in] value Human-readable solution-convergence mode selector.
14331 @return Canonical string accepted by `-solution_convergence_mode`.
14332 @throws ValueError if the input cannot be mapped.
14333 """
14334 if value is None:
14335 raise ValueError("solution_convergence.mode cannot be None")
14336
14337 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
14338 aliases = {
14339 "steady_deterministic": "STEADY_DETERMINISTIC",
14340 "periodic_deterministic": "PERIODIC_DETERMINISTIC",
14341 "statistical_steady": "STATISTICAL_STEADY",
14342 "transient": "TRANSIENT",
14343 }
14344 mapped = aliases.get(normalized)
14345 if mapped is None:
14346 raise ValueError(
14347 f"Unknown solution_convergence.mode '{value}'. Use one of: "
14348 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
14349 )
14350 return mapped
14351
Here is the caller graph for this function:

◆ normalize_field_init_mode()

int picurv_cli.core.normalize_field_init_mode ( str  value)

Maps canonical field init mode names to C enum/int codes (-finit).

Parameters
[in]valueCanonical field initialization mode.
Returns
Canonical integer code accepted by -finit.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 14352 of file core.py.

14352def normalize_field_init_mode(value: str) -> int:
14353 """!
14354 @brief Maps canonical field init mode names to C enum/int codes (-finit).
14355 @param[in] value Canonical field initialization mode.
14356 @return Canonical integer code accepted by -finit.
14357 @throws ValueError if the input cannot be mapped.
14358 """
14359 # Canonical selector map for field initialization modes.
14360 if value is None:
14361 raise ValueError("field initialization mode cannot be None")
14362
14363 mapped = {
14364 "Zero": 0,
14365 "Constant": 1,
14366 "Poiseuille": 2,
14367 }.get(str(value).strip())
14368 if mapped is None:
14369 raise ValueError(
14370 f"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
14371 )
14372 return mapped
14373
Here is the caller graph for this function:

◆ normalize_initial_condition_field()

"tuple[str, int]" picurv_cli.core.normalize_initial_condition_field ( str  value)

Normalize a file IC field selector to its staged basename and C enum value.

Parameters
[in]valueUser-facing Ucat or Ucont selector.
Returns
Tuple of staged field basename and C enum value.

Definition at line 14374 of file core.py.

14374def normalize_initial_condition_field(value: str) -> "tuple[str, int]":
14375 """!
14376 @brief Normalize a file IC field selector to its staged basename and C enum value.
14377 @param[in] value User-facing Ucat or Ucont selector.
14378 @return Tuple of staged field basename and C enum value.
14379 """
14380 normalized = str(value or "").strip().lower()
14381 if normalized == "ucat":
14382 return "ufield", 0
14383 if normalized == "ucont":
14384 return "vfield", 1
14385 raise ValueError("initial_conditions.field must be 'Ucat' or 'Ucont'.")
14386
14387
14388#: Physical dimension of a staged initial-condition payload, by C field code.
Here is the caller graph for this function:

◆ resolve_ic_payload_provenance()

dict picurv_cli.core.resolve_ic_payload_provenance ( dict  ic,
int  field_code 
)

Resolve which scales a file-backed initial condition was written in.

A payload is physical unless it declares otherwise. A field saved by an earlier run is in that run's solver units, so it names the case that wrote it (source_case) or gives its scales directly, the same way a field_slice inlet profile does.

Parameters
[in]icInitial-condition YAML mapping.
[in]field_codeC field code: 0 for Ucat, 1 for Ucont.
Returns
Mapping with source_case, or velocity_scale and length_scale, or empty.
Exceptions
ValueErroron a conflicting, incomplete, or invalid declaration.

Definition at line 14392 of file core.py.

14392def resolve_ic_payload_provenance(ic: dict, field_code: int) -> dict:
14393 """!
14394 @brief Resolve which scales a file-backed initial condition was written in.
14395 @details A payload is physical unless it declares otherwise. A field saved by an
14396 earlier run is in that run's solver units, so it names the case that wrote
14397 it (`source_case`) or gives its scales directly, the same way a
14398 `field_slice` inlet profile does.
14399 @param[in] ic Initial-condition YAML mapping.
14400 @param[in] field_code C field code: 0 for Ucat, 1 for Ucont.
14401 @return Mapping with `source_case`, or `velocity_scale` and `length_scale`, or empty.
14402 @throws ValueError on a conflicting, incomplete, or invalid declaration.
14403 """
14404 source_case = ic.get("source_case")
14405 has_scale = any(key in ic for key in ("velocity_scale", "length_scale"))
14406 if source_case is not None:
14407 if has_scale:
14408 raise ValueError("initial_conditions: give source_case or velocity_scale/length_scale, not both.")
14409 if not isinstance(source_case, str) or not source_case.strip():
14410 raise ValueError("initial_conditions.source_case must be a non-empty path.")
14411 return {"source_case": source_case.strip()}
14412 if not has_scale:
14413 return {}
14414 if "velocity_scale" not in ic:
14415 raise ValueError("initial_conditions.length_scale requires velocity_scale.")
14416 provenance = {"velocity_scale": _to_finite_float(ic["velocity_scale"], "initial_conditions.velocity_scale"),
14417 "length_scale": 1.0}
14418 if field_code == 1:
14419 if "length_scale" not in ic:
14420 raise ValueError("initial_conditions.length_scale is required for a Ucont payload in solver units.")
14421 provenance["length_scale"] = _to_finite_float(ic["length_scale"], "initial_conditions.length_scale")
14422 elif "length_scale" in ic:
14423 raise ValueError("initial_conditions.length_scale applies only to a Ucont payload.")
14424 if provenance["velocity_scale"] <= 0.0 or provenance["length_scale"] <= 0.0:
14425 raise ValueError("initial_conditions.velocity_scale and length_scale must be positive.")
14426 return provenance
14427
14428
Here is the call graph for this function:
Here is the caller graph for this function:

◆ ic_payload_divisor()

float picurv_cli.core.ic_payload_divisor ( dict  resolved_ic,
str  case_dir 
)

Return the factor a file-backed initial condition is divided by when staged.

A physical payload is divided by its dimension's reference scale. A payload in another run's solver units is first returned to physical units by that run's scale, so the two factors combine into one ratio.

Parameters
[in]resolved_icResolved mode: file initial condition.
[in]case_dirDirectory that relative source_case paths resolve against.
Returns
Positive divisor; 1.0 when the payload already matches this case's solver units.

Definition at line 14429 of file core.py.

14429def ic_payload_divisor(resolved_ic: dict, case_dir: str) -> float:
14430 """!
14431 @brief Return the factor a file-backed initial condition is divided by when staged.
14432 @details A physical payload is divided by its dimension's reference scale. A payload in
14433 another run's solver units is first returned to physical units by that run's
14434 scale, so the two factors combine into one ratio.
14435 @param[in] resolved_ic Resolved `mode: file` initial condition.
14436 @param[in] case_dir Directory that relative `source_case` paths resolve against.
14437 @return Positive divisor; 1.0 when the payload already matches this case's solver units.
14438 """
14439 dimension = IC_PAYLOAD_DIMENSIONS[resolved_ic["field_code"]]
14440 provenance = resolved_ic.get("payload_provenance") or {}
14441 source_scales = {"length_ref": 1.0, "velocity_ref": 1.0}
14442 if "source_case" in provenance:
14443 source_path = _resolve_case_relative_path(provenance["source_case"], case_dir)
14444 source_scales = resolve_reference_scales(read_yaml_file(source_path))
14445 elif provenance:
14446 source_scales = {"length_ref": provenance["length_scale"], "velocity_ref": provenance["velocity_scale"]}
14447 return reference_scale(dimension, resolved_ic["scales"]) / reference_scale(dimension, source_scales)
14448
Here is the call graph for this function:
Here is the caller graph for this function:

◆ is_generated_ic_provider()

bool picurv_cli.core.is_generated_ic_provider ( dict  resolved_ic)

Return whether a resolved IC is backed by a registered file generator.

Parameters
[in]resolved_icResolved initial-condition contract.
Returns
True for a registered generated-file provider.

Definition at line 14470 of file core.py.

14470def is_generated_ic_provider(resolved_ic: dict) -> bool:
14471 """!
14472 @brief Return whether a resolved IC is backed by a registered file generator.
14473 @param[in] resolved_ic Resolved initial-condition contract.
14474 @return True for a registered generated-file provider.
14475 """
14476 return resolved_ic.get("kind") in GENERATED_IC_PROVIDERS
14477
14478
Here is the caller graph for this function:

◆ resolve_fluid_scaling()

dict picurv_cli.core.resolve_fluid_scaling ( dict  case_cfg)

Resolve the shared physical and nondimensional fluid scaling contract.

Parameters
[in]case_cfgParsed case configuration.
Returns
Resolved scaling, viscosity, and Reynolds-number quantities.

Definition at line 14479 of file core.py.

14479def resolve_fluid_scaling(case_cfg: dict) -> dict:
14480 """!
14481 @brief Resolve the shared physical and nondimensional fluid scaling contract.
14482 @param[in] case_cfg Parsed case configuration.
14483 @return Resolved scaling, viscosity, and Reynolds-number quantities.
14484 """
14485 properties = case_cfg["properties"]
14486 scaling = properties["scaling"]
14487 fluid = properties["fluid"]
14488 length_ref = _to_finite_float(scaling["length_ref"], "properties.scaling.length_ref")
14489 velocity_ref = _to_finite_float(scaling["velocity_ref"], "properties.scaling.velocity_ref")
14490 density = _to_finite_float(fluid["density"], "properties.fluid.density")
14491 dynamic_viscosity = _to_finite_float(fluid["viscosity"], "properties.fluid.viscosity")
14492 if length_ref <= 0.0 or velocity_ref <= 0.0 or density <= 0.0 or dynamic_viscosity < 0.0:
14493 raise ValueError("length_ref, velocity_ref, and density must be positive; viscosity must be non-negative.")
14494 reynolds = density*velocity_ref*length_ref/dynamic_viscosity if dynamic_viscosity else float("inf")
14495 return {
14496 "length_ref": length_ref, "velocity_ref": velocity_ref, "density": density,
14497 "dynamic_viscosity": dynamic_viscosity, "reynolds": reynolds,
14498 "physical_kinematic_viscosity": dynamic_viscosity/density,
14499 "nondimensional_kinematic_viscosity": dynamic_viscosity/(density*velocity_ref*length_ref),
14500 }
14501
14502
Here is the call graph for this function:
Here is the caller graph for this function:

◆ load_generator_module()

picurv_cli.core.load_generator_module ( str  script_name,
str  module_name 
)

Load one of the extensionless scripts under generators/ as a module.

Parameters
[in]script_nameFile name under generators/, e.g. ic.gen.
[in]module_nameName to register the loaded module under.
Returns
Loaded module.

Definition at line 14503 of file core.py.

14503def load_generator_module(script_name: str, module_name: str):
14504 """!
14505 @brief Load one of the extensionless scripts under `generators/` as a module.
14506 @param[in] script_name File name under `generators/`, e.g. `ic.gen`.
14507 @param[in] module_name Name to register the loaded module under.
14508 @return Loaded module.
14509 """
14510 from importlib.machinery import SourceFileLoader
14511 loader = SourceFileLoader(module_name, os.path.join(GENERATORS_PATH, script_name))
14512 spec = importlib.util.spec_from_loader(loader.name, loader)
14513 module = importlib.util.module_from_spec(spec)
14514 loader.exec_module(module)
14515 return module
14516
14517
Here is the caller graph for this function:

◆ load_ic_generator_module()

picurv_cli.core.load_ic_generator_module ( )

Load generators/ic.gen, which owns the expression language and the IC providers.

Returns
Loaded module.

Definition at line 14518 of file core.py.

14518def load_ic_generator_module():
14519 """!
14520 @brief Load `generators/ic.gen`, which owns the expression language and the IC providers.
14521 @return Loaded module.
14522 """
14523 return load_generator_module("ic.gen", "picurv_ic_generator")
14524
14525
14526#: Particle fields a case may give an initial value, with their component counts: the
14527#: fields the particle carries (catalog capability `USER_INITIALIZE`). Every other particle
14528#: field is re-derived from the Eulerian fields or from particle location, so a configured
14529#: value would not survive. `tests/test_particle_fields.py` checks this against the catalog.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_particle_fields()

list picurv_cli.core.normalize_particle_fields (   fields_cfg)

Validate models.physics.particles.fields and lower each value to expressions.

A value is a number, an expression, or a mapping with params and either value or background with regions; a field with several components takes a list of values, one per component. Expressions use physical coordinates x y z, domain-normalized xn yn zn, pid, and physical time t, and may draw with uniform()/normal().

Parameters
[in]fields_cfgThe fields mapping.
Returns
List of {"name", "expressions"} in configuration order.
Exceptions
ValueErroron an unknown field, a wrong component count, or an invalid value.

Definition at line 14533 of file core.py.

14533def normalize_particle_fields(fields_cfg) -> list:
14534 """!
14535 @brief Validate `models.physics.particles.fields` and lower each value to expressions.
14536 @details A value is a number, an expression, or a mapping with `params` and either
14537 `value` or `background` with `regions`; a field with several components takes a
14538 list of values, one per component. Expressions use physical coordinates
14539 `x y z`, domain-normalized `xn yn zn`, `pid`, and physical time `t`, and may
14540 draw with `uniform()`/`normal()`.
14541 @param[in] fields_cfg The `fields` mapping.
14542 @return List of `{"name", "expressions"}` in configuration order.
14543 @throws ValueError on an unknown field, a wrong component count, or an invalid value.
14544 """
14545 if not isinstance(fields_cfg, dict) or not fields_cfg:
14546 raise ValueError("models.physics.particles.fields must be a non-empty mapping of field names to values.")
14547 ic_gen = load_ic_generator_module()
14548 normalized = []
14549 for name, spec in fields_cfg.items():
14550 if name not in PARTICLE_SETTABLE_FIELDS:
14551 raise ValueError(
14552 f"models.physics.particles.fields.{name}: not a field a case can set; the runtime "
14553 f"re-derives it. Settable fields: {sorted(PARTICLE_SETTABLE_FIELDS)}.")
14554 components = PARTICLE_SETTABLE_FIELDS[name]
14555 specs = spec if components > 1 else [spec]
14556 if not isinstance(specs, list) or len(specs) != components:
14557 raise ValueError(f"models.physics.particles.fields.{name} takes {components} component values.")
14558 expressions = [
14559 ic_gen.lower_value(item, ic_gen.PARTICLE_EXPRESSION_NAMES, random=True,
14560 where=f"models.physics.particles.fields.{name}")
14561 for item in specs
14562 ]
14563 normalized.append({"name": name, "expressions": expressions})
14564 return normalized
14565
14566
Here is the call graph for this function:
Here is the caller graph for this function:

◆ ic_provider_params_to_solver_units()

dict picurv_cli.core.ic_provider_params_to_solver_units ( str  generator,
dict  params,
dict  scales 
)

Convert a provider's validated physical parameters to solver units.

Providers work on the staged nondimensional grid, so every dimensional parameter recorded in IC_PARAM_QUANTITIES is converted before the provider runs. Nested mappings are addressed by dotted name.

Parameters
[in]generatorProvider identifier.
[in]paramsValidated parameter mapping in physical units.
[in]scalesReference scales, as returned by resolve_reference_scales().
Returns
A copy of the mapping with dimensional values in solver units.

Definition at line 14567 of file core.py.

14567def ic_provider_params_to_solver_units(generator: str, params: dict, scales: dict) -> dict:
14568 """!
14569 @brief Convert a provider's validated physical parameters to solver units.
14570 @details Providers work on the staged nondimensional grid, so every dimensional
14571 parameter recorded in `IC_PARAM_QUANTITIES` is converted before the provider
14572 runs. Nested mappings are addressed by dotted name.
14573 @param[in] generator Provider identifier.
14574 @param[in] params Validated parameter mapping in physical units.
14575 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
14576 @return A copy of the mapping with dimensional values in solver units.
14577 """
14578 converted = copy.deepcopy(params)
14579 for name, quantity in IC_PARAM_QUANTITIES[generator].items():
14580 if quantity[1] != "cli":
14581 continue
14582 *parents, leaf = name.split(".")
14583 holder = converted
14584 for parent in parents:
14585 holder = holder.get(parent) if isinstance(holder, dict) else None
14586 if isinstance(holder, dict) and leaf in holder:
14587 holder[leaf] = quantity_to_solver_units(
14588 holder[leaf], quantity, scales, f"initial_conditions.params.{name}")
14589 return converted
14590
14591
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_resampled_velocity_params()

dict picurv_cli.core.resolve_resampled_velocity_params ( dict  params,
  prepared_blocks,
dict  scales,
  provider_context = None 
)

Validate resampled_velocity parameters and convert them to the provider contract.

The provider brings an external triply periodic velocity field onto the case's grid. Its box must be the case's box; the source's container, layout, and sample positions are declared because no standard format records them all.

Parameters
[in]paramsRaw provider parameters, physical units.
[in]prepared_blocksNormalized boundary-condition blocks.
[in]scalesReference scales.
[in]provider_contextOptional conductor-derived provider context.
Returns
Resolved initial-condition contract; source_file stays as written and is resolved against the workspace when the provider runs.

Definition at line 14592 of file core.py.

14592def resolve_resampled_velocity_params(params: dict, prepared_blocks, scales: dict, provider_context=None) -> dict:
14593 """!
14594 @brief Validate `resampled_velocity` parameters and convert them to the provider contract.
14595 @details The provider brings an external triply periodic velocity field onto the case's
14596 grid. Its box must be the case's box; the source's container, layout, and sample
14597 positions are declared because no standard format records them all.
14598 @param[in] params Raw provider parameters, physical units.
14599 @param[in] prepared_blocks Normalized boundary-condition blocks.
14600 @param[in] scales Reference scales.
14601 @param[in] provider_context Optional conductor-derived provider context.
14602 @return Resolved initial-condition contract; `source_file` stays as written and is
14603 resolved against the workspace when the provider runs.
14604 """
14605 name = "resampled_velocity"
14606 if prepared_blocks and len(prepared_blocks) != 1:
14607 raise ValueError(f"{name} requires exactly one grid block.")
14608 if not prepared_blocks or len(prepared_blocks[0]) != 6 or any(
14609 bc.get("type") != "PERIODIC" or bc.get("handler") != "geometric" for bc in prepared_blocks[0]
14610 ):
14611 raise ValueError(f"{name} requires PERIODIC/geometric boundaries on all six faces; "
14612 "wall-bounded sources are not supported yet.")
14613 unknown = sorted(set(params) - RESAMPLED_VELOCITY_PARAMS)
14614 if unknown:
14615 raise ValueError(f"{name} has unsupported params: {unknown}.")
14616 # Ucont is the default: face-centre samples projected with PICurv's face-difference
14617 # operator keep the most energy through the runtime's reconstruction and need the
14618 # smallest projection; Ucat stages cell centres like the synthetic providers do.
14619 field_name, field_code = normalize_initial_condition_field(params.get("field", "Ucont"))
14620 source_file = params.get("source_file")
14621 if not isinstance(source_file, str) or not source_file.strip():
14622 raise ValueError(f"{name} requires params.source_file, the field to import.")
14623 fmt = str(params.get("format", "")).lower()
14624 if fmt not in RESAMPLED_VELOCITY_LAYOUT_KEYS:
14625 raise ValueError(f"{name} params.format must be one of {sorted(RESAMPLED_VELOCITY_LAYOUT_KEYS)}.")
14626 cells = params.get("cells")
14627 if cells is not None:
14628 if (not isinstance(cells, list) or len(cells) != 3
14629 or any(isinstance(c, bool) or not isinstance(c, int) or c < 4 for c in cells)):
14630 raise ValueError(f"{name} params.cells must list the source's sample counts along x, y, z, "
14631 "each an integer of at least 4.")
14632 elif fmt == "raw":
14633 raise ValueError(f"{name} with format: raw requires params.cells, the sample counts along x, y, z.")
14634 box = params.get("box_length")
14635 if not isinstance(box, list) or len(box) != 3:
14636 raise ValueError(f"{name} requires params.box_length: the source's periods along x, y, z, "
14637 "in physical length.")
14638 box = [_to_finite_float(value, f"initial_conditions.params.box_length[{index}]")
14639 for index, value in enumerate(box)]
14640 if any(value <= 0 for value in box):
14641 raise ValueError(f"{name} params.box_length values must be positive.")
14642 offset = params.get("sample_offset", 0.0)
14643 offset = offset if isinstance(offset, list) else [offset, offset, offset]
14644 if len(offset) != 3:
14645 raise ValueError(f"{name} params.sample_offset is one number or three, along x, y, z.")
14646 offset = [_to_finite_float(value, f"initial_conditions.params.sample_offset[{index}]")
14647 for index, value in enumerate(offset)]
14648 if any(value < 0.0 or value >= 1.0 for value in offset):
14649 raise ValueError(f"{name} params.sample_offset is a fraction of the source spacing in [0, 1): "
14650 "0 for samples on the box origin, 0.5 for cell centres.")
14651 layout = params.get("layout", {})
14652 if not isinstance(layout, dict):
14653 raise ValueError(f"{name} params.layout must be a mapping.")
14654 extra = sorted(set(layout) - RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt])
14655 if extra:
14656 raise ValueError(f"{name} layout keys {extra} do not apply to format: {fmt}; "
14657 f"it accepts {sorted(RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt])}.")
14658 normalized_layout = {"fastest_axis": str(layout.get("fastest_axis", "x")).lower(),
14659 "components": str(layout.get("components", "interleaved")).lower()}
14660 if normalized_layout["fastest_axis"] not in RESAMPLED_VELOCITY_FASTEST_AXES:
14661 raise ValueError(f"{name} layout.fastest_axis must be x (x varies fastest) or z.")
14662 if normalized_layout["components"] not in RESAMPLED_VELOCITY_COMPONENT_LAYOUTS:
14663 raise ValueError(f"{name} layout.components must be interleaved (u, v, w at each point) "
14664 "or separate (all u, then all v, then all w).")
14665 if fmt == "raw":
14666 dtype = str(layout.get("dtype", "")).lower()
14667 if dtype not in RESAMPLED_VELOCITY_RAW_DTYPES:
14668 raise ValueError(f"{name} format: raw requires layout.dtype: float32 or float64.")
14669 byte_order = str(layout.get("byte_order", "native")).lower()
14670 if byte_order not in RESAMPLED_VELOCITY_BYTE_ORDERS:
14671 raise ValueError(f"{name} layout.byte_order must be little, big, or native.")
14672 header = layout.get("header_bytes", 0)
14673 if isinstance(header, bool) or not isinstance(header, int) or header < 0:
14674 raise ValueError(f"{name} layout.header_bytes must be a non-negative integer.")
14675 normalized_layout.update({"dtype": dtype, "byte_order": byte_order, "header_bytes": header})
14676 elif "datasets" in RESAMPLED_VELOCITY_LAYOUT_KEYS[fmt]:
14677 datasets = layout.get("datasets")
14678 if (not isinstance(datasets, list) or len(datasets) not in (1, 3)
14679 or any(not isinstance(item, str) or not item for item in datasets)):
14680 raise ValueError(f"{name} format: {fmt} requires layout.datasets: one array name, "
14681 "or three for u, v, w.")
14682 normalized_layout["datasets"] = list(datasets)
14683 filter_cfg = params.get("filter", {"type": "none"})
14684 if not isinstance(filter_cfg, dict):
14685 raise ValueError(f"{name} params.filter must be a mapping.")
14686 filter_type = str(filter_cfg.get("type", "none")).lower()
14687 expected = {"none": set(), "sharp": {"cutoff"}, "gaussian": {"width"}, "box": {"width"}}
14688 if filter_type not in expected:
14689 raise ValueError(f"{name} filter.type must be one of {sorted(expected)}.")
14690 if set(filter_cfg) - {"type"} != expected[filter_type]:
14691 raise ValueError(f"{name} filter.type: {filter_type} takes exactly "
14692 f"{sorted(expected[filter_type]) or 'no parameters'}.")
14693 normalized_filter = {"type": filter_type}
14694 for key in expected[filter_type]:
14695 value = _to_finite_float(filter_cfg[key], f"initial_conditions.params.filter.{key}")
14696 if value <= 0:
14697 raise ValueError(f"{name} filter.{key} must be positive.")
14698 normalized_filter[key] = value
14699 projection = str(params.get("projection", "picurv_discrete")).lower()
14700 if projection not in RESAMPLED_VELOCITY_PROJECTIONS:
14701 raise ValueError(f"{name} params.projection must be picurv_discrete, continuum, or none.")
14702 remove_mean = params.get("remove_mean", True)
14703 if not isinstance(remove_mean, bool):
14704 raise ValueError(f"{name} params.remove_mean must be boolean.")
14705 normalized = {"field": "Ucont" if field_code == 1 else "Ucat", "source_file": source_file,
14706 "format": fmt, "box_length": box,
14707 "sample_offset": offset, "layout": normalized_layout, "filter": normalized_filter,
14708 "projection": projection, "remove_mean": remove_mean}
14709 if cells is not None:
14710 normalized["cells"] = list(cells)
14711 resolved = ic_provider_params_to_solver_units(name, normalized, scales)
14712 resolved["velocity_divisor"] = float(scales["velocity_ref"])
14713 return {"finit": 4, "cli_params": {}, "kind": name, "label": name,
14714 "field_name": field_name, "field_code": field_code,
14715 "provider_context": dict(provider_context or {}), "params": resolved}
14716
14717
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_ic_initial_spectra()

picurv_cli.core.normalize_ic_initial_spectra (   requested,
  excluded_axes,
  generator,
  required 
)

Validate the spectra a structured IC provider measures on its own output.

Parameters
[in]requestedparams.initial_spectra as written, or None.
[in]excluded_axesAxes the spectra must not transform (walls, cross-stream).
[in]generatorProvider identifier, for messages.
[in]requiredWhether a nonempty list is mandatory.
Returns
Normalized spectrum tasks (an empty list when optional and absent).
Exceptions
ValueErroron an absent required list or an unsupported task.

Definition at line 14718 of file core.py.

14718def normalize_ic_initial_spectra(requested, excluded_axes, generator, required):
14719 """!
14720 @brief Validate the spectra a structured IC provider measures on its own output.
14721 @param[in] requested `params.initial_spectra` as written, or None.
14722 @param[in] excluded_axes Axes the spectra must not transform (walls, cross-stream).
14723 @param[in] generator Provider identifier, for messages.
14724 @param[in] required Whether a nonempty list is mandatory.
14725 @return Normalized spectrum tasks (an empty list when optional and absent).
14726 @throws ValueError on an absent required list or an unsupported task.
14727 """
14728 if requested is None and not required:
14729 return []
14730 if not isinstance(requested, list) or not requested:
14731 raise ValueError(f"{generator} requires params.initial_spectra: a nonempty list of "
14732 "plane_spectrum or line_spectrum tasks over the periodic axes.")
14733 spectra = normalize_post_spectra_config({"spectra": {"tasks": requested}})
14734 for task in spectra["tasks"]:
14735 if task["task"] == "shell_spectrum" or set(task["axes"]) & set(excluded_axes):
14736 raise ValueError(f"initial spectra must transform only periodic axes of the {generator} IC.")
14737 if task["block"] != 0 or task["subtract_mean"].startswith("window:") or task["reference"]:
14738 raise ValueError("initial spectra require block 0, no window mean, and no reference.")
14739 return spectra["tasks"]
14740
14741
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_initial_condition_config()

dict picurv_cli.core.resolve_initial_condition_config ( dict  ic,
  prepared_blocks,
dict  scales,
  provider_context = None 
)

Resolve legacy and structured initial-condition YAML into one launcher contract.

Every value the user writes is physical; the resolved contract carries solver units, or the scales a provider or the staging step needs to reach them.

Parameters
[in]icInitial-condition YAML mapping.
[in]prepared_blocksNormalized boundary-condition blocks.
[in]scalesReference scales, as returned by resolve_reference_scales().
[in]provider_contextOptional conductor-derived provider context.
Returns
Normalized launcher initial-condition contract.

Definition at line 14742 of file core.py.

14742def resolve_initial_condition_config(ic: dict, prepared_blocks, scales: dict, provider_context=None) -> dict:
14743 """!
14744 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
14745 @details Every value the user writes is physical; the resolved contract carries solver
14746 units, or the scales a provider or the staging step needs to reach them.
14747 @param[in] ic Initial-condition YAML mapping.
14748 @param[in] prepared_blocks Normalized boundary-condition blocks.
14749 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
14750 @param[in] provider_context Optional conductor-derived provider context.
14751 @return Normalized launcher initial-condition contract.
14752 """
14753 if not isinstance(ic, dict):
14754 raise ValueError("properties.initial_conditions must be a mapping.")
14755 mode = str(ic.get("mode", "")).strip()
14756
14757 # Backward-compatible legacy spelling.
14758 if mode in LEGACY_FIELD_INIT_SPELLINGS:
14759 finit_code = normalize_field_init_mode(mode)
14760 params = resolve_ic_cli_params(ic, finit_code, prepared_blocks, scales)
14761 if finit_code == 1 and params.pop("ic_coordinate_system", 0) == 1:
14762 finit_code = 3
14763 return {"finit": finit_code, "cli_params": params, "kind": "builtin", "label": mode}
14764
14765 normalized_mode = mode.lower().replace("-", "_").replace(" ", "_")
14766 if normalized_mode == "file":
14767 if prepared_blocks and len(prepared_blocks) > 1:
14768 raise ValueError("File-backed initial conditions currently support single-block cases only.")
14769 source_file = ic.get("source_file")
14770 if not isinstance(source_file, str) or not source_file.strip():
14771 raise ValueError("initial_conditions.source_file is required when mode is 'file'.")
14772 field_name, field_code = normalize_initial_condition_field(ic.get("field"))
14773 return {
14774 "finit": 4, "cli_params": {}, "kind": "file", "label": "file",
14775 "source_file": source_file.strip(), "field_name": field_name, "field_code": field_code,
14776 "payload_provenance": resolve_ic_payload_provenance(ic, field_code),
14777 "scales": dict(scales),
14778 }
14779 if normalized_mode != "generated":
14780 raise ValueError("initial_conditions.mode must be 'generated' or 'file'.")
14781
14782 generator = str(ic.get("generator", "")).strip().lower().replace("-", "_").replace(" ", "_")
14783 params = ic.get("params", {})
14784 if not isinstance(params, dict):
14785 raise ValueError("initial_conditions.params must be a mapping.")
14786 for key in RETIRED_INITIAL_CONDITION_DESTINATION_KEYS:
14787 if key in params:
14788 raise ValueError(retired_destination_message("initial_conditions.params", key))
14789 if generator == "ic_gen":
14790 if prepared_blocks and len(prepared_blocks) > 1:
14791 raise ValueError("File-backed initial conditions currently support single-block cases only.")
14792 script = params.get("script")
14793 if script is not None and (not isinstance(script, str) or not script.strip()):
14794 raise ValueError("initial_conditions.params.script must be a non-empty path when provided.")
14795 field_name, field_code = normalize_initial_condition_field(params.get("field"))
14796 config_file = params.get("config_file")
14797 if not isinstance(config_file, str) or not config_file.strip():
14798 raise ValueError("initial_conditions.params.config_file is required for generator 'ic_gen'.")
14799 cli_args = params.get("cli_args", [])
14800 if cli_args is None:
14801 cli_args = []
14802 if not isinstance(cli_args, list):
14803 raise ValueError("initial_conditions.params.cli_args must be a list.")
14804 return {
14805 "finit": 4, "cli_params": {}, "kind": "ic_gen", "label": "ic_gen",
14806 "field_name": field_name, "field_code": field_code,
14807 "config_file": config_file.strip(),
14808 "script": script.strip() if script is not None else None,
14809 "cli_args": cli_args,
14810 "scales": dict(scales),
14811 }
14812
14813 if generator in ("channel_spectral_velocity", "duct_spectral_velocity"):
14814 normalized = load_ic_generator_module().validate_wall_spectral_params(params, generator)
14815 if not prepared_blocks or len(prepared_blocks) != 1:
14816 raise ValueError("wall spectral IC requires exactly one block with resolved boundaries.")
14817 face_axes = {"Xi": "i", "Eta": "j", "Zeta": "k"}
14818 faces = prepared_blocks[0]
14819 if len(faces) != 6:
14820 raise ValueError("wall spectral IC requires all six boundary faces.")
14821 for bc in faces:
14822 axis = face_axes[bc["face"][1:]]
14823 if axis in normalized["wall_axes"]:
14824 # The perturbation vanishes at the wall, which every wall handler admits.
14825 valid = bc["type"] == "WALL" and "WALL" in BC_HANDLER_SPECS.get(bc["handler"], {}).get("types", ())
14826 else:
14827 valid = bc["type"] == "PERIODIC" and bc["handler"] in ("geometric", "constant_flux", "initial_flux")
14828 if axis != normalized["streamwise_axis"]:
14829 valid = valid and bc["handler"] == "geometric"
14830 if not valid:
14831 raise ValueError("wall spectral IC requires wall pairs (no-slip or free-slip) and periodic remaining axes, driven only streamwise.")
14832 normalized["initial_spectra"] = normalize_ic_initial_spectra(
14833 normalized.get("initial_spectra"), normalized["wall_axes"], generator, required=True)
14834 return {"finit": 4, "cli_params": {}, "kind": generator, "label": generator,
14835 "field_name": "ufield", "field_code": 0,
14836 "params": ic_provider_params_to_solver_units(generator, normalized, scales),
14837 "provider_context": dict(provider_context or {})}
14838
14839 if generator == "stability_mode_velocity":
14840 normalized = load_ic_generator_module().validate_stability_mode_params(params)
14841 if not prepared_blocks or len(prepared_blocks) != 1:
14842 raise ValueError("stability_mode_velocity requires exactly one block with resolved boundaries.")
14843 face_axes = {"Xi": "i", "Eta": "j", "Zeta": "k"}
14844 faces = prepared_blocks[0]
14845 if len(faces) != 6:
14846 raise ValueError("stability_mode_velocity requires all six boundary faces.")
14847 for bc in faces:
14848 cross = face_axes[bc["face"][1:]] == normalized["cross_axis"]
14849 if cross == (bc["type"] == "PERIODIC") or (not cross and bc["handler"] != "geometric"):
14850 raise ValueError("stability_mode_velocity requires geometric periodic streamwise and normal axes "
14851 "and non-periodic cross-stream faces.")
14852 if "initial_spectra" in normalized:
14853 normalized["initial_spectra"] = normalize_ic_initial_spectra(
14854 normalized["initial_spectra"], [normalized["cross_axis"]], generator, required=False)
14855 context = dict(provider_context or {})
14856 # The base profile is an expression of physical quantities; the provider applies these.
14857 context.update({"length_ref": scales["length_ref"], "velocity_ref": scales["velocity_ref"]})
14858 return {"finit": 4, "cli_params": {}, "kind": generator, "label": generator,
14859 "field_name": "ufield", "field_code": 0,
14860 "params": ic_provider_params_to_solver_units(generator, normalized, scales),
14861 "provider_context": context}
14862
14863 if generator == "resampled_velocity":
14864 return resolve_resampled_velocity_params(params, prepared_blocks, scales, provider_context)
14865
14866 if generator == "spectral_random_velocity":
14867 if prepared_blocks and len(prepared_blocks) != 1:
14868 raise ValueError("spectral_random_velocity requires exactly one grid block.")
14869 if not prepared_blocks or len(prepared_blocks[0]) != 6 or any(
14870 bc.get("type") != "PERIODIC" or bc.get("handler") != "geometric"
14871 for bc in prepared_blocks[0]
14872 ):
14873 raise ValueError("spectral_random_velocity requires PERIODIC/geometric boundaries on all six faces.")
14874 unknown = sorted(set(params) - SPECTRAL_RANDOM_VELOCITY_PARAMS)
14875 if unknown:
14876 raise ValueError(f"spectral_random_velocity has unsupported params: {unknown}.")
14877 field_name, field_code = normalize_initial_condition_field(params.get("field", "Ucat"))
14878 if field_code != 0:
14879 raise ValueError("spectral_random_velocity supports only params.field: Ucat.")
14880 seed = params.get("seed", 12345)
14881 if isinstance(seed, bool) or not isinstance(seed, int):
14882 raise ValueError("spectral_random_velocity params.seed must be an integer.")
14883 random_cfg = params.get("random", {})
14884 if not isinstance(random_cfg, dict) or set(random_cfg) - {"distribution", "mean"}:
14885 raise ValueError("spectral_random_velocity params.random supports only distribution and mean.")
14886 distribution = str(random_cfg.get("distribution", "gaussian")).lower()
14887 if distribution != "gaussian":
14888 raise ValueError("spectral_random_velocity supports only random.distribution: gaussian.")
14889 mean = random_cfg.get("mean", [0.0, 0.0, 0.0])
14890 if not isinstance(mean, list) or len(mean) != 3:
14891 raise ValueError("spectral_random_velocity random.mean must be a three-component list.")
14892 mean = [_to_finite_float(value, f"initial_conditions.params.random.mean[{index}]")
14893 for index, value in enumerate(mean)]
14894 spectrum = params.get("spectrum", {})
14895 if not isinstance(spectrum, dict):
14896 raise ValueError("spectral_random_velocity params.spectrum must be a mapping.")
14897 spectrum_type = str(spectrum.get("type", "white")).lower()
14898 if spectrum_type == "white":
14899 if set(spectrum) - {"type"}:
14900 raise ValueError("white spectrum supports only type.")
14901 normalized_spectrum = {"type": "white"}
14902 elif spectrum_type == "k4_exponential":
14903 if set(spectrum) - {"type", "k0", "k_cut"} or "k0" not in spectrum or "k_cut" not in spectrum:
14904 raise ValueError("k4_exponential spectrum requires only type, k0, and k_cut.")
14905 normalized_spectrum = {"type": "k4_exponential",
14906 "k0": _to_finite_float(spectrum["k0"], "initial_conditions.params.spectrum.k0"),
14907 "k_cut": _to_finite_float(spectrum["k_cut"], "initial_conditions.params.spectrum.k_cut")}
14908 else:
14909 raise ValueError("spectrum.type must be 'white' or 'k4_exponential'.")
14910 if any(value <= 0 for key, value in normalized_spectrum.items() if key != "type"):
14911 raise ValueError("spectrum k0 and k_cut values must be positive.")
14912 projection = params.get("projection", {"type": "none"})
14913 if not isinstance(projection, dict) or set(projection) - {"type", "operator"}:
14914 raise ValueError("projection supports only type and operator.")
14915 projection_type = str(projection.get("type", "none")).lower()
14916 if projection_type == "none" and "operator" not in projection:
14917 normalized_projection = {"type": "none"}
14918 elif projection_type == "solenoidal" and str(projection.get("operator", "")).lower() in PROJECTION_OPERATORS:
14919 normalized_projection = {"type": "solenoidal", "operator": str(projection["operator"]).lower()}
14920 else:
14921 raise ValueError("projection must be none, or solenoidal with continuum/picurv_discrete operator.")
14922 normalization = params.get("normalization", {"type": "none"})
14923 if not isinstance(normalization, dict) or set(normalization) - {"type", "target"}:
14924 raise ValueError("normalization supports only type and target.")
14925 normalization_type = str(normalization.get("type", "none")).lower()
14926 if normalization_type == "none" and "target" not in normalization:
14927 normalized_normalization = {"type": "none"}
14928 elif normalization_type == "component_rms" and "target" in normalization:
14929 target = _to_finite_float(normalization["target"], "initial_conditions.params.normalization.target")
14930 if target <= 0:
14931 raise ValueError("component_rms normalization.target must be positive.")
14932 normalized_normalization = {"type": "component_rms", "target": target}
14933 else:
14934 raise ValueError("normalization must be none or component_rms with target.")
14935 remove_mean = params.get("remove_mean", True)
14936 if not isinstance(remove_mean, bool):
14937 raise ValueError("spectral_random_velocity remove_mean must be boolean.")
14938 context = dict(provider_context or {})
14939 return {
14940 "finit": 4, "cli_params": {}, "kind": "spectral_random_velocity", "label": "spectral_random_velocity",
14941 "field_name": field_name, "field_code": field_code, "provider_context": context,
14942 "params": ic_provider_params_to_solver_units(
14943 "spectral_random_velocity",
14944 {"field": "Ucat", "seed": seed,
14945 "random": {"distribution": distribution, "mean": mean},
14946 "spectrum": normalized_spectrum, "projection": normalized_projection,
14947 "normalization": normalized_normalization, "remove_mean": remove_mean},
14948 scales),
14949 }
14950
14951 generator_modes = {
14952 "zero": (0, "Zero"),
14953 "constant": (1, "Constant"),
14954 "streamwise_constant": (3, "Constant"),
14955 "poiseuille": (2, "Poiseuille"),
14956 }
14957 if generator not in generator_modes:
14958 raise ValueError(
14959 "initial_conditions.generator must be one of: zero, constant, "
14960 "streamwise_constant, poiseuille, ic_gen, spectral_random_velocity, resampled_velocity, "
14961 "channel_spectral_velocity, duct_spectral_velocity, stability_mode_velocity."
14962 )
14963 finit_code, legacy_mode = generator_modes[generator]
14964 legacy_ic = dict(params)
14965 legacy_ic["mode"] = legacy_mode
14966 cli_params = resolve_ic_cli_params(
14967 legacy_ic,
14968 1 if finit_code == 3 else finit_code,
14969 prepared_blocks,
14970 scales,
14971 )
14972 cli_params.pop("ic_coordinate_system", None)
14973 return {"finit": finit_code, "cli_params": cli_params, "kind": "builtin", "label": generator}
14974
Here is the call graph for this function:
Here is the caller graph for this function:

◆ runtime_petsc_index_bytes()

int picurv_cli.core.runtime_petsc_index_bytes ( )

Bytes of a PetscInt in the PETSc the solver this conductor drives was built on.

PETSc binary files store their sizes as PetscInt and do not record the width, so a file staged for the solver must use the solver's (PETSc's own PetscBinaryIO reads it from the build configuration the same way). An unbuilt or unstamped solver counts as 32-bit, PETSc's default.

Returns
4 or 8.

Definition at line 14975 of file core.py.

14975def runtime_petsc_index_bytes() -> int:
14976 """!
14977 @brief Bytes of a PetscInt in the PETSc the solver this conductor drives was built on.
14978 @details PETSc binary files store their sizes as PetscInt and do not record the width, so
14979 a file staged for the solver must use the solver's (PETSc's own PetscBinaryIO
14980 reads it from the build configuration the same way). An unbuilt or unstamped
14981 solver counts as 32-bit, PETSc's default.
14982 @return 4 or 8.
14983 """
14984 if _SELECTED_PETSC_INDEX_BYTES is not None:
14985 return _SELECTED_PETSC_INDEX_BYTES
14986 petsc = read_binary_petsc_stamp(resolve_runtime_executable("simulator")) or {}
14987 return PETSC_INDEX_BYTES.get(petsc.get("indices", "int32"), 4)
14988
14989
14990#: PetscInt widths, in bits, a PICurv build can use (`make INDEX_BITS=...`, `--petsc-index-bits`).
Here is the call graph for this function:
Here is the caller graph for this function:

◆ requested_petsc_index_bytes()

"int | None" picurv_cli.core.requested_petsc_index_bytes (   args = None)

The index width a command was told to use: --petsc-index-bits, else PICURV_PETSC_INDEX_BITS.

Parameters
[in]argsParsed command arguments, or None.
Returns
4, 8, or None when neither names one.

Definition at line 15001 of file core.py.

15001def requested_petsc_index_bytes(args=None) -> "int | None":
15002 """!
15003 @brief The index width a command was told to use: `--petsc-index-bits`, else `PICURV_PETSC_INDEX_BITS`.
15004 @param[in] args Parsed command arguments, or None.
15005 @return 4, 8, or None when neither names one.
15006 """
15007 bits = getattr(args, "petsc_index_bits", None) if args is not None else None
15008 bits = bits or os.environ.get("PICURV_PETSC_INDEX_BITS") or None
15009 if bits is None:
15010 return None
15011 if str(bits).strip() not in PETSC_INDEX_BITS_CHOICES:
15012 raise ValueError(f"PETSc index bits must be 32 or 64, not {bits!r}.")
15013 return 4 if str(bits).strip() == "32" else 8
15014
15015
Here is the caller graph for this function:

◆ required_petsc_index_bytes()

"tuple[int, str]" picurv_cli.core.required_petsc_index_bytes ( dict  case_cfg,
str  case_path,
"dict | None"  monitor_cfg,
"str | None"   staged_grid = None 
)

The PetscInt width a new run needs, from its particle count and grid size.

One PETSc object of more than PETSC_MAX_INT_32BIT entries needs 64-bit indices: a particle field (particles x 3) or a grid field (DA nodes x 3, or x 6 with field statistics). The solver checks the same limits when it creates them, so an underestimate here is refused at setup rather than run.

Parameters
[in]case_cfgParsed case configuration.
[in]case_pathPath to case.yml.
[in]monitor_cfgParsed monitor configuration, for field statistics.
[in]staged_gridStaged PICGRID, if any.
Returns
(4 or 8, reason).

Definition at line 15016 of file core.py.

15017 staged_grid: "str | None" = None) -> "tuple[int, str]":
15018 """!
15019 @brief The PetscInt width a new run needs, from its particle count and grid size.
15020 @details One PETSc object of more than PETSC_MAX_INT_32BIT entries needs 64-bit indices:
15021 a particle field (particles x 3) or a grid field (DA nodes x 3, or x 6 with field
15022 statistics). The solver checks the same limits when it creates them, so an
15023 underestimate here is refused at setup rather than run.
15024 @param[in] case_cfg Parsed case configuration.
15025 @param[in] case_path Path to case.yml.
15026 @param[in] monitor_cfg Parsed monitor configuration, for field statistics.
15027 @param[in] staged_grid Staged PICGRID, if any.
15028 @return (4 or 8, reason).
15029 """
15030 particles = (((case_cfg.get("models") or {}).get("physics") or {}).get("particles") or {})
15031 particle_entries = int(particles.get("count", 0) or 0) * PARTICLE_FIELD_MAX_COMPONENTS
15032 statistics = bool(monitor_cfg) and normalize_field_statistics_config(monitor_cfg, case_cfg)["enabled"]
15033 components = STATISTICS_FIELD_COMPONENTS if statistics else GRID_FIELD_MAX_COMPONENTS
15034 grid_entries = max([(im + 1) * (jm + 1) * (km + 1) * components
15035 for im, jm, km in resolve_grid_node_dims(case_cfg, case_path, staged_grid) or []] or [0])
15036 if particle_entries > PETSC_MAX_INT_32BIT:
15037 return 8, f"{particle_entries:,} particle-field entries exceed the 32-bit limit {PETSC_MAX_INT_32BIT:,}"
15038 if grid_entries > PETSC_MAX_INT_32BIT:
15039 return 8, f"{grid_entries:,} grid-field entries exceed the 32-bit limit {PETSC_MAX_INT_32BIT:,}"
15040 return 4, "particle and grid fields fit 32-bit indices"
15041
15042
Here is the call graph for this function:
Here is the caller graph for this function:

◆ checkpoint_source_petsc_index_bytes()

"int | None" picurv_cli.core.checkpoint_source_petsc_index_bytes ( str  source_dir)

PetscInt width the committed checkpoints under a run output root were written with.

Every checkpoint of a run shares its build's width; one written before the width was recorded is 32-bit.

Parameters
[in]source_dirRun output root holding checkpoints/.
Returns
4 or 8, or None when it holds no committed checkpoint.

Definition at line 15043 of file core.py.

15043def checkpoint_source_petsc_index_bytes(source_dir: str) -> "int | None":
15044 """!
15045 @brief PetscInt width the committed checkpoints under a run output root were written with.
15046 @details Every checkpoint of a run shares its build's width; one written before the width
15047 was recorded is 32-bit.
15048 @param[in] source_dir Run output root holding `checkpoints/`.
15049 @return 4 or 8, or None when it holds no committed checkpoint.
15050 """
15051 root = os.path.join(os.path.abspath(source_dir), "checkpoints")
15052 for name in sorted(os.listdir(root), reverse=True) if os.path.isdir(root) else []:
15053 metadata = os.path.join(root, name, "checkpoint.meta")
15054 if os.path.isfile(os.path.join(root, name, "COMMITTED")) and os.path.isfile(metadata):
15055 return int(_read_checkpoint_options(metadata).get("checkpoint_petsc_index_bytes", "4"))
15056 return None
15057
15058
Here is the call graph for this function:
Here is the caller graph for this function:

◆ select_petsc_index_bytes()

int picurv_cli.core.select_petsc_index_bytes ( int  index_bytes,
str  reason 
)

Make the current command drive the build of one PetscInt width.

resolve_runtime_executable() and runtime_petsc_index_bytes() follow it, so the executables staged, pinned and launched, and every PETSc file the conductor writes or validates for them, agree. Refuses a width whose executables are not built.

Parameters
[in]index_bytes4 or 8.
[in]reasonWhy, for the message.
Returns
index_bytes.

Definition at line 15059 of file core.py.

15059def select_petsc_index_bytes(index_bytes: int, reason: str) -> int:
15060 """!
15061 @brief Make the current command drive the build of one PetscInt width.
15062 @details `resolve_runtime_executable()` and `runtime_petsc_index_bytes()` follow it, so the
15063 executables staged, pinned and launched, and every PETSc file the conductor writes
15064 or validates for them, agree. Refuses a width whose executables are not built.
15065 @param[in] index_bytes 4 or 8.
15066 @param[in] reason Why, for the message.
15067 @return index_bytes.
15068 """
15069 global _SELECTED_PETSC_INDEX_BYTES
15070 _SELECTED_PETSC_INDEX_BYTES = index_bytes
15071 if index_bytes == 8:
15072 missing = [path for path in (resolve_runtime_executable(name) for name in RUN_EXECUTABLE_NAMES)
15073 if not os.path.isfile(path)]
15074 if missing:
15075 raise ValueError(
15076 f"This run needs the 64-bit-index build ({reason}), which is not built: missing "
15077 f"{', '.join(missing)}. Build it with `make INDEX_BITS=64 PETSC_DIR=<64-bit-index PETSc> "
15078 f"[PETSC_ARCH=...] all`.")
15079 # stderr, so a dry-run's JSON plan on stdout stays parseable.
15080 print(f"[INFO] Using the 64-bit-index build: {reason}.", file=sys.stderr)
15081 return index_bytes
15082
15083
Here is the call graph for this function:
Here is the caller graph for this function:

◆ select_run_petsc_index_bytes()

int picurv_cli.core.select_run_petsc_index_bytes (   args,
dict  case_cfg,
str  case_path,
"dict | None"  monitor_cfg,
"str | None"   restart_source_dir = None 
)

Choose and select the PetscInt width a run's solve stage needs.

A restart or continuation keeps the width its source checkpoint was written with; a new run uses 32-bit unless a particle or grid field outgrows it. An explicit --petsc-index-bits or PICURV_PETSC_INDEX_BITS wins, except against a checkpoint.

Parameters
[in]argsParsed command arguments.
[in]case_cfgParsed case configuration.
[in]case_pathPath to case.yml.
[in]monitor_cfgParsed monitor configuration.
[in]restart_source_dirRestart source output root, if any.
Returns
4 or 8.

Definition at line 15084 of file core.py.

15085 restart_source_dir: "str | None" = None) -> int:
15086 """!
15087 @brief Choose and select the PetscInt width a run's solve stage needs.
15088 @details A restart or continuation keeps the width its source checkpoint was written with;
15089 a new run uses 32-bit unless a particle or grid field outgrows it. An explicit
15090 `--petsc-index-bits` or `PICURV_PETSC_INDEX_BITS` wins, except against a checkpoint.
15091 @param[in] args Parsed command arguments.
15092 @param[in] case_cfg Parsed case configuration.
15093 @param[in] case_path Path to case.yml.
15094 @param[in] monitor_cfg Parsed monitor configuration.
15095 @param[in] restart_source_dir Restart source output root, if any.
15096 @return 4 or 8.
15097 """
15098 requested = requested_petsc_index_bytes(args)
15099 if restart_source_dir:
15100 recorded = checkpoint_source_petsc_index_bytes(restart_source_dir)
15101 if recorded is not None:
15102 if requested is not None and requested != recorded:
15103 raise ValueError(
15104 f"--petsc-index-bits {8 * requested} conflicts with the restart source {restart_source_dir}, "
15105 f"whose checkpoints were written with {8 * recorded}-bit indices.")
15106 return select_petsc_index_bytes(recorded, f"its restart source was written with {8 * recorded}-bit indices")
15107 needed, reason = required_petsc_index_bytes(case_cfg, case_path, monitor_cfg)
15108 if requested == 4 and needed == 8:
15109 raise ValueError(f"--petsc-index-bits 32 cannot run this case: {reason}.")
15110 if requested is not None:
15111 return select_petsc_index_bytes(requested, f"{8 * requested}-bit indices were requested")
15112 return select_petsc_index_bytes(needed, reason)
15113
15114
Here is the call graph for this function:
Here is the caller graph for this function:

◆ select_existing_run_petsc_index_bytes()

int picurv_cli.core.select_existing_run_petsc_index_bytes ( str  run_dir,
  args = None 
)

Select the PetscInt width an existing run was staged with, for its later stages.

The width staging recorded in config/active.json, else its checkpoints' width, else 32-bit. A conflicting explicit request is refused.

Parameters
[in]run_dirRun directory.
[in]argsParsed command arguments, or None.
Returns
4 or 8.

Definition at line 15115 of file core.py.

15115def select_existing_run_petsc_index_bytes(run_dir: str, args=None) -> int:
15116 """!
15117 @brief Select the PetscInt width an existing run was staged with, for its later stages.
15118 @details The width staging recorded in `config/active.json`, else its checkpoints' width,
15119 else 32-bit. A conflicting explicit request is refused.
15120 @param[in] run_dir Run directory.
15121 @param[in] args Parsed command arguments, or None.
15122 @return 4 or 8.
15123 """
15124 active = _read_json_if_exists(os.path.join(os.path.abspath(run_dir), "config", "active.json")) or {}
15125 recorded = active.get("petsc_index_bytes")
15126 if recorded is None:
15127 recorded = checkpoint_source_petsc_index_bytes(os.path.join(run_dir, CANONICAL_RUN_PATHS["output"]))
15128 requested = requested_petsc_index_bytes(args)
15129 if recorded is not None and requested is not None and requested != int(recorded):
15130 raise ValueError(f"--petsc-index-bits {8 * requested} conflicts with {run_dir}, staged with "
15131 f"{8 * int(recorded)}-bit indices.")
15132 width = int(recorded) if recorded is not None else (requested or 4)
15133 return select_petsc_index_bytes(width, f"{run_dir} was staged with {8 * width}-bit indices")
15134
15135
Here is the call graph for this function:
Here is the caller graph for this function:

◆ record_run_petsc_index_bytes()

None picurv_cli.core.record_run_petsc_index_bytes ( str  run_dir)

Record in config/active.json the PetscInt width the current command selected.

Parameters
[in]run_dirRun directory.
Returns
None.

Definition at line 15136 of file core.py.

15136def record_run_petsc_index_bytes(run_dir: str) -> None:
15137 """!
15138 @brief Record in `config/active.json` the PetscInt width the current command selected.
15139 @param[in] run_dir Run directory.
15140 @return None.
15141 """
15142 active_path = os.path.join(os.path.abspath(run_dir), "config", "active.json")
15143 active = _read_json_if_exists(active_path)
15144 if isinstance(active, dict):
15145 active["petsc_index_bytes"] = _SELECTED_PETSC_INDEX_BYTES or 4
15146 write_json_file(active_path, active)
15147
15148
Here is the call graph for this function:
Here is the caller graph for this function:

◆ payload_petsc_index_bytes()

int picurv_cli.core.payload_petsc_index_bytes ( str  path)

PetscInt width of a PETSc payload: as its checkpoint recorded it, else the driven solver's.

A committed checkpoint's checkpoint.meta records the width it was written with (-checkpoint_petsc_index_bytes; absent in checkpoints written before it existed, which are 32-bit). A payload outside any checkpoint is read as the driven solver would read it.

Parameters
[in]pathPayload path.
Returns
4 or 8.

Definition at line 15149 of file core.py.

15149def payload_petsc_index_bytes(path: str) -> int:
15150 """!
15151 @brief PetscInt width of a PETSc payload: as its checkpoint recorded it, else the driven solver's.
15152 @details A committed checkpoint's `checkpoint.meta` records the width it was written with
15153 (`-checkpoint_petsc_index_bytes`; absent in checkpoints written before it existed,
15154 which are 32-bit). A payload outside any checkpoint is read as the driven solver
15155 would read it.
15156 @param[in] path Payload path.
15157 @return 4 or 8.
15158 """
15159 directory = os.path.dirname(os.path.abspath(path))
15160 for _ in range(4):
15161 metadata = os.path.join(directory, "checkpoint.meta")
15162 if os.path.isfile(metadata):
15163 return int(_read_checkpoint_options(metadata).get("checkpoint_petsc_index_bytes", "4"))
15164 directory = os.path.dirname(directory)
15165 return runtime_petsc_index_bytes()
15166
15167
Here is the call graph for this function:
Here is the caller graph for this function:

◆ petsc_index_arguments()

list picurv_cli.core.petsc_index_arguments ( int  index_bytes)

Generator arguments naming a PetscInt width; none for PETSc's default 32-bit.

Kept empty at 4 bytes so a user's replacement generator script that predates the option keeps working for every 32-bit build.

Parameters
[in]index_bytes4 or 8.
Returns
Argument list.

Definition at line 15168 of file core.py.

15168def petsc_index_arguments(index_bytes: int) -> list:
15169 """!
15170 @brief Generator arguments naming a PetscInt width; none for PETSc's default 32-bit.
15171 @details Kept empty at 4 bytes so a user's replacement generator script that predates the
15172 option keeps working for every 32-bit build.
15173 @param[in] index_bytes 4 or 8.
15174 @return Argument list.
15175 """
15176 return [] if index_bytes == 4 else ["--petsc-index-bytes", str(index_bytes)]
15177
15178
Here is the caller graph for this function:

◆ validate_petsc_vec_binary()

dict picurv_cli.core.validate_petsc_vec_binary ( str  path,
"int | None"   index_bytes = None 
)

Validate the basic PETSc binary VecView envelope used by ReadFieldData.

Parameters
[in]pathPETSc binary vector path.
[in]index_bytesPetscInt width of the reading build; the driven solver's when None.
Returns
Summary containing the absolute path, scalar count and index width.

Definition at line 15179 of file core.py.

15179def validate_petsc_vec_binary(path: str, index_bytes: "int | None" = None) -> dict:
15180 """!
15181 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
15182 @param[in] path PETSc binary vector path.
15183 @param[in] index_bytes PetscInt width of the reading build; the driven solver's when None.
15184 @return Summary containing the absolute path, scalar count and index width.
15185 """
15186 import struct
15187 index_bytes = index_bytes or runtime_petsc_index_bytes()
15188 header_bytes = 2 * index_bytes
15189 with open(path, "rb") as fin:
15190 header = fin.read(header_bytes)
15191 if len(header) != header_bytes:
15192 raise ValueError(f"PETSc Vec file is too short: {path}")
15193 class_id, scalar_count = struct.unpack(">ii" if index_bytes == 4 else ">qq", header)
15194 payload = fin.read()
15195 if class_id != 1211214 or scalar_count < 0 or len(payload) != scalar_count * 8:
15196 raise ValueError(
15197 f"Invalid PETSc Vec file {path} for a PETSc with {index_bytes}-byte indices; a file "
15198 f"written for the other index width (or truncated) does not fit its header.")
15199 return {"path": os.path.abspath(path), "scalar_count": scalar_count, "index_bytes": index_bytes}
15200
15201
Here is the call graph for this function:
Here is the caller graph for this function:

◆ write_scaled_petsc_vec_binary()

dict picurv_cli.core.write_scaled_petsc_vec_binary ( str  source,
str  destination,
float  divisor 
)

Copy a PETSc binary Vec, dividing every scalar by one factor.

Used to stage a physical payload in solver units. The envelope is the one validate_petsc_vec_binary() accepts: a big-endian class id and count of the solver's index width, followed by big-endian doubles.

Parameters
[in]sourceValidated PETSc binary vector path.
[in]destinationPath to write the scaled vector to.
[in]divisorReference scale the payload is divided by.
Returns
Summary of the destination, as validate_petsc_vec_binary() reports it.

Definition at line 15202 of file core.py.

15202def write_scaled_petsc_vec_binary(source: str, destination: str, divisor: float) -> dict:
15203 """!
15204 @brief Copy a PETSc binary Vec, dividing every scalar by one factor.
15205 @details Used to stage a physical payload in solver units. The envelope is the one
15206 `validate_petsc_vec_binary()` accepts: a big-endian class id and count of the
15207 solver's index width, followed by big-endian doubles.
15208 @param[in] source Validated PETSc binary vector path.
15209 @param[in] destination Path to write the scaled vector to.
15210 @param[in] divisor Reference scale the payload is divided by.
15211 @return Summary of the destination, as `validate_petsc_vec_binary()` reports it.
15212 """
15213 from array import array
15214 index_bytes = validate_petsc_vec_binary(source)["index_bytes"]
15215 with open(source, "rb") as fin:
15216 header = fin.read(2 * index_bytes)
15217 values = array("d")
15218 values.frombytes(fin.read())
15219 if sys.byteorder == "little":
15220 values.byteswap()
15221 for index, value in enumerate(values):
15222 values[index] = value / divisor
15223 if sys.byteorder == "little":
15224 values.byteswap()
15225 with open(destination, "wb") as fout:
15226 fout.write(header)
15227 fout.write(values.tobytes())
15228 return validate_petsc_vec_binary(destination, index_bytes)
15229
Here is the call graph for this function:
Here is the caller graph for this function:

◆ initial_condition_diagnostic_paths()

picurv_cli.core.initial_condition_diagnostic_paths (   run_dir,
  resolved_ic 
)

Resolve spectral IC artifacts through the canonical run layout, also for dry runs.

Parameters
[in]run_dirRun or asset-build root.
[in]resolved_icNormalized file-backed IC provider.
Returns
Canonical diagnostic artifact paths, including each sample CSV and JSON.

Definition at line 15230 of file core.py.

15230def initial_condition_diagnostic_paths(run_dir, resolved_ic):
15231 """!
15232 @brief Resolve spectral IC artifacts through the canonical run layout, also for dry runs.
15233 @param[in] run_dir Run or asset-build root.
15234 @param[in] resolved_ic Normalized file-backed IC provider.
15235 @return Canonical diagnostic artifact paths, including each sample CSV and JSON.
15236 """
15237 if resolved_ic["kind"] not in SPECTRAL_IC_PROVIDERS:
15238 return []
15239 paths = [os.path.join(run_dir, CANONICAL_RUN_PATHS["metrics"], "initial_condition_summary.json")]
15240 if resolved_ic["kind"] in SHELL_SPECTRUM_IC_PROVIDERS:
15241 return paths + [os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH)]
15242 for task in resolved_ic["params"].get("initial_spectra", []):
15243 base = post_spectra_task_basename(task, "initial_condition")
15244 paths.extend([os.path.join(run_dir, CANONICAL_RUN_PATHS["spectra"], base+suffix) for suffix in (".csv", ".json")])
15245 return paths
15246
15247
Here is the call graph for this function:
Here is the caller graph for this function:

◆ run_initial_spectrum_generator()

str picurv_cli.core.run_initial_spectrum_generator ( str  field_path,
str  staged_grid,
str  spectrum_path,
str  case_dir,
  task_cfg = None 
)

Measure the shell-averaged spectrum of a staged initial condition.

The spectrum has a single implementation in generators/spectra.gen, so the conductor measures the generated field rather than asking the initial-condition generator to report a spectrum it would have to bin itself.

Parameters
[in]field_pathGenerated PETSc binary Ucat path.
[in]staged_gridStaged canonical PICGRID path.
[in]spectrum_pathDestination k,energy CSV path.
[in]case_dirWorking directory for the subprocess.
[in]task_cfgOptional normalized plane/line task; None retains DIT shell measurement.
Returns
Absolute path to the written spectrum CSV.
Exceptions
ValueErrorwhen the generator is missing or fails.

Definition at line 15248 of file core.py.

15249 spectrum_path: str, case_dir: str, task_cfg=None) -> str:
15250 """!
15251 @brief Measure the shell-averaged spectrum of a staged initial condition.
15252
15253 The spectrum has a single implementation in `generators/spectra.gen`, so the
15254 conductor measures the generated field rather than asking the initial-condition
15255 generator to report a spectrum it would have to bin itself.
15256
15257 @param[in] field_path Generated PETSc binary Ucat path.
15258 @param[in] staged_grid Staged canonical PICGRID path.
15259 @param[in] spectrum_path Destination `k,energy` CSV path.
15260 @param[in] case_dir Working directory for the subprocess.
15261 @param[in] task_cfg Optional normalized plane/line task; None retains DIT shell measurement.
15262 @return Absolute path to the written spectrum CSV.
15263 @throws ValueError when the generator is missing or fails.
15264 """
15265 script = os.path.join(GENERATORS_PATH, "spectra.gen")
15266 if not os.path.isfile(script):
15267 raise ValueError(f"spectra.gen script not found: {script}")
15268 command = task_cfg["task"].replace("_", "-") if task_cfg else "shell-spectrum"
15269 cmd = [sys.executable, script, command,
15270 "--field-file", field_path, "--source-grid", staged_grid,
15271 "--spectrum-csv", spectrum_path, *petsc_index_arguments(runtime_petsc_index_bytes())]
15272 if task_cfg:
15273 cmd.extend(["--axes", *task_cfg["axes"], "--fixed-indices", json.dumps(task_cfg["fixed_indices"]),
15274 "--subtract-mean", task_cfg["subtract_mean"], "--summary-json", os.path.splitext(spectrum_path)[0]+".json"])
15275 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
15276 if result.returncode != 0:
15277 details = (result.stderr or result.stdout or "").strip()
15278 raise ValueError(
15279 f"initial-condition spectrum failed with exit code {result.returncode}. Details:\n{details}"
15280 )
15281 return os.path.abspath(spectrum_path)
15282
15283
Here is the call graph for this function:
Here is the caller graph for this function:

◆ run_initial_condition_generator()

str picurv_cli.core.run_initial_condition_generator ( str  case_path,
str  run_dir,
dict  resolved_ic 
)

Run the repository IC generator.

Parameters
[in]case_pathSource case YAML path.
[in]run_dirRun or precompute output directory.
[in]resolved_icNormalized external-generator contract.
Returns
Generated PETSc vector path.

Definition at line 15284 of file core.py.

15284def run_initial_condition_generator(case_path: str, run_dir: str, resolved_ic: dict) -> str:
15285 """!
15286 @brief Run the repository IC generator.
15287 @param[in] case_path Source case YAML path.
15288 @param[in] run_dir Run or precompute output directory.
15289 @param[in] resolved_ic Normalized external-generator contract.
15290 @return Generated PETSc vector path.
15291 """
15292 case_dir = os.path.dirname(os.path.abspath(case_path))
15293 if resolved_ic["kind"] in SPECTRAL_IC_PROVIDERS:
15294 script = os.path.join(GENERATORS_PATH, "ic.gen")
15295 output_path = os.path.join(run_dir, "inputs", "initial_condition", "initial_condition.generated.dat")
15296 os.makedirs(os.path.dirname(output_path), exist_ok=True)
15297 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
15298 if not os.path.isfile(staged_grid):
15299 raise ValueError(f"{resolved_ic['kind']} requires a staged PICGRID at inputs/grid/grid.run.")
15300 summary_path = os.path.join(run_dir, "output", "analysis", "metrics", "initial_condition_summary.json")
15301 spectrum_path = os.path.join(run_dir, INITIAL_CONDITION_SPECTRUM_RELPATH)
15302 os.makedirs(os.path.dirname(summary_path), exist_ok=True)
15303 os.makedirs(os.path.dirname(spectrum_path), exist_ok=True)
15304 provider_params = resolved_ic["params"]
15305 provider_context = dict(resolved_ic.get("provider_context", {}))
15306 measured_field = output_path
15307 if resolved_ic["kind"] == "resampled_velocity" and provider_params.get("field") == "Ucont":
15308 # Staged fluxes are not a velocity; the spectrum is measured on the velocity the
15309 # runtime reconstructs from them, written beside the output and removed after.
15310 measured_field = os.path.join(os.path.dirname(output_path), ".runtime_ucat_for_spectrum.dat")
15311 provider_context["runtime_ucat_output"] = measured_field
15312 if resolved_ic["kind"] == "resampled_velocity":
15313 # The source is a workspace input, resolved here so the provider is handed one
15314 # absolute path; a reference-mode import resolves to its external target.
15315 provider_params = dict(provider_params)
15316 provider_params["source_file"] = resolve_workspace_path(case_path, provider_params["source_file"])
15317 if not os.path.isfile(provider_params["source_file"]):
15318 raise ValueError(f"resampled_velocity source file not found: {provider_params['source_file']}")
15319 cmd = [sys.executable, script, "--generator", resolved_ic["kind"],
15320 "--grid", staged_grid, "--output", output_path,
15321 "--params-json", json.dumps(provider_params, sort_keys=True),
15322 "--context-json", json.dumps(provider_context, sort_keys=True),
15323 "--summary-json", summary_path, *petsc_index_arguments(runtime_petsc_index_bytes())]
15324 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
15325 if result.returncode != 0:
15326 details = (result.stderr or result.stdout or "").strip()
15327 raise ValueError(f"{resolved_ic['kind']} failed with exit code {result.returncode}. Details:\n{details}")
15328 validate_petsc_vec_binary(output_path)
15329 if resolved_ic["kind"] in SHELL_SPECTRUM_IC_PROVIDERS:
15330 try:
15331 run_initial_spectrum_generator(measured_field, staged_grid, spectrum_path, case_dir)
15332 finally:
15333 if measured_field != output_path and os.path.exists(measured_field):
15334 os.remove(measured_field)
15335 else:
15336 paths = initial_condition_diagnostic_paths(run_dir, resolved_ic)
15337 for task, path in zip(resolved_ic["params"].get("initial_spectra", []), paths[1::2]):
15338 run_initial_spectrum_generator(output_path, staged_grid, path, case_dir, task)
15339 return output_path
15340 script = _resolve_generator_script(resolved_ic.get("script"), case_path, "ic.gen")
15341 config_file = resolved_ic["config_file"]
15342 config_file = _resolve_case_relative_path(config_file, case_dir)
15343 if not os.path.isfile(script):
15344 raise ValueError(f"ic.gen script not found: {script}")
15345 if not os.path.isfile(config_file):
15346 raise ValueError(f"initial-condition generator config file not found: {config_file}")
15347 output_path = os.path.join(run_dir, "inputs", "initial_condition", "initial_condition.generated.dat")
15348 os.makedirs(os.path.dirname(output_path), exist_ok=True)
15349 cmd = [sys.executable, script, "-c", config_file, "--field",
15350 "Ucat" if resolved_ic["field_code"] == 0 else "Ucont",
15351 "--output", output_path, *petsc_index_arguments(runtime_petsc_index_bytes())]
15352 staged_grid = os.path.join(run_dir, "inputs", "grid", "grid.run")
15353 if os.path.isfile(staged_grid):
15354 cmd.extend(["--grid", staged_grid])
15355 # Expressions are written in physical units; the generator is given the scales and
15356 # writes solver units, like every other initial-condition provider.
15357 cmd.extend(["--length-ref", repr(float(resolved_ic["scales"]["length_ref"])),
15358 "--velocity-ref", repr(float(resolved_ic["scales"]["velocity_ref"]))])
15359 cmd.extend(str(token) for token in resolved_ic.get("cli_args", []))
15360 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
15361 if result.returncode != 0:
15362 details = (result.stderr or result.stdout or "").strip()
15363 raise ValueError(f"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
15364 validate_petsc_vec_binary(output_path)
15365 return output_path
15366
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _check_file_ic_matches_grid()

None picurv_cli.core._check_file_ic_matches_grid ( dict  payload,
str  staged_grid,
dict  resolved_ic 
)
protected

Refuse a mode: file payload sized for another grid, and say what imports one.

mode: file reads its vector verbatim, so it must already be laid out for this grid: one value per component per cell including one dummy layer. The runtime would refuse a mismatch at startup; staging refuses it first and names resampled_velocity, which brings a field from another grid onto this one. Programmatic grids are not staged here, so they keep the runtime check.

Parameters
[in]payloadHeader summary from validate_petsc_vec_binary().
[in]staged_gridStaged PICGRID path, if the grid is file-backed.
[in]resolved_icNormalized file-backed IC contract.

Definition at line 15367 of file core.py.

15367def _check_file_ic_matches_grid(payload: dict, staged_grid: str, resolved_ic: dict) -> None:
15368 """!
15369 @brief Refuse a `mode: file` payload sized for another grid, and say what imports one.
15370 @details `mode: file` reads its vector verbatim, so it must already be laid out for this
15371 grid: one value per component per cell including one dummy layer. The runtime
15372 would refuse a mismatch at startup; staging refuses it first and names
15373 `resampled_velocity`, which brings a field from another grid onto this one.
15374 Programmatic grids are not staged here, so they keep the runtime check.
15375 @param[in] payload Header summary from `validate_petsc_vec_binary()`.
15376 @param[in] staged_grid Staged PICGRID path, if the grid is file-backed.
15377 @param[in] resolved_ic Normalized file-backed IC contract.
15378 """
15379 if not os.path.isfile(staged_grid):
15380 return
15381 dims = read_picgrid_header_dimensions(staged_grid)
15382 if len(dims) != 1:
15383 return
15384 im, jm, km = dims[0]
15385 expected = (im + 1) * (jm + 1) * (km + 1) * 3
15386 if payload["scalar_count"] != expected:
15387 raise ValueError(
15388 f"Initial-condition file {payload['path']} holds {payload['scalar_count']} values; this grid "
15389 f"({im - 1}x{jm - 1}x{km - 1} cells) needs {expected} for {resolved_ic.get('field_name', 'the field')}. "
15390 "mode: file reads a field already laid out for this grid. To bring in a field from another "
15391 "grid or format, use generator: resampled_velocity."
15392 )
15393
15394
Here is the call graph for this function:
Here is the caller graph for this function:

◆ stage_initial_condition_file()

dict picurv_cli.core.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.

Parameters
[in]run_dirRun or precompute output directory.
[in]case_pathSource case YAML path.
[in]resolved_icNormalized file-backed IC contract.
Returns
Source, staged path, and staging-directory summary.

Definition at line 15395 of file core.py.

15395def stage_initial_condition_file(run_dir: str, case_path: str, resolved_ic: dict) -> dict:
15396 """!
15397 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
15398 @param[in] run_dir Run or precompute output directory.
15399 @param[in] case_path Source case YAML path.
15400 @param[in] resolved_ic Normalized file-backed IC contract.
15401 @return Source, staged path, and staging-directory summary.
15402 """
15403 stage_dir = os.path.join(run_dir, "inputs", "initial_condition")
15404 os.makedirs(stage_dir, exist_ok=True)
15405 staged_path = os.path.join(stage_dir, f"{resolved_ic['field_name']}00000_0.dat")
15406 if os.path.isfile(staged_path):
15407 validate_petsc_vec_binary(staged_path)
15408 summary = {
15409 "source": os.path.abspath(staged_path),
15410 "staged": os.path.abspath(staged_path),
15411 "directory": os.path.abspath(stage_dir),
15412 "reused": True,
15413 }
15414 if resolved_ic["kind"] in SPECTRAL_IC_PROVIDERS:
15415 summary["diagnostics"] = initial_condition_diagnostic_paths(run_dir, resolved_ic)
15416 return summary
15417 divisor = 1.0
15418 if is_generated_ic_provider(resolved_ic):
15419 # Providers are handed the scales they need and write solver units.
15420 source_path = run_initial_condition_generator(case_path, run_dir, resolved_ic)
15421 else:
15422 case_dir = os.path.dirname(os.path.abspath(case_path))
15423 source_path = _resolve_case_relative_path(resolved_ic["source_file"], case_dir)
15424 if not os.path.isfile(source_path):
15425 raise ValueError(f"Initial-condition source file not found: {source_path}")
15426 payload = validate_petsc_vec_binary(source_path)
15427 _check_file_ic_matches_grid(payload, os.path.join(run_dir, "inputs", "grid", "grid.run"), resolved_ic)
15428 divisor = ic_payload_divisor(resolved_ic, case_dir)
15429 if divisor != 1.0:
15430 write_scaled_petsc_vec_binary(source_path, staged_path, divisor)
15431 elif os.path.abspath(source_path) != os.path.abspath(staged_path):
15432 shutil.copy2(source_path, staged_path)
15433 summary = {"source": os.path.abspath(source_path), "staged": os.path.abspath(staged_path),
15434 "directory": os.path.abspath(stage_dir), "payload_divisor": divisor}
15435 if resolved_ic["kind"] in SPECTRAL_IC_PROVIDERS:
15436 summary["diagnostics"] = initial_condition_diagnostic_paths(run_dir, resolved_ic)
15437 return summary
15438
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_flow_direction_token()

int picurv_cli.core.normalize_flow_direction_token ( str  value)

Maps a face-token flow direction string to the C FlowDirection enum integer.

Parameters
[in]valueOne of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
Returns
Integer 0-5 matching the FlowDirection enum.
Exceptions
ValueErroron unknown value.

Definition at line 15439 of file core.py.

15439def normalize_flow_direction_token(value: str) -> int:
15440 """!
15441 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
15442 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
15443 @return Integer 0-5 matching the FlowDirection enum.
15444 @throws ValueError on unknown value.
15445 """
15446 mapped = {
15447 "+Xi": 0, "-Xi": 1,
15448 "+Eta": 2, "-Eta": 3,
15449 "+Zeta": 4, "-Zeta": 5,
15450 }.get(str(value).strip())
15451 if mapped is None:
15452 raise ValueError(
15453 f"Unknown initial_conditions.flow_direction '{value}'. "
15454 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
15455 )
15456 return mapped
15457
Here is the caller graph for this function:

◆ _ic_has_inlet()

bool picurv_cli.core._ic_has_inlet (   prepared_blocks)
protected

Return True if any prepared BC block contains an INLET face.

Parameters
[in]prepared_blocksList of prepared BC lists (one per domain block).
Returns
True if at least one INLET entry exists across all blocks.

Definition at line 15458 of file core.py.

15458def _ic_has_inlet(prepared_blocks) -> bool:
15459 """!
15460 @brief Return True if any prepared BC block contains an INLET face.
15461 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
15462 @return True if at least one INLET entry exists across all blocks.
15463 """
15464 if not prepared_blocks:
15465 return False
15466 for block_bcs in prepared_blocks:
15467 for entry in block_bcs:
15468 if entry.get("type") == "INLET":
15469 return True
15470 return False
15471
Here is the caller graph for this function:

◆ resolve_ic_cli_params()

dict picurv_cli.core.resolve_ic_cli_params ( dict  ic,
int  finit_code,
  prepared_blocks,
dict  scales 
)

Resolve all IC parameters and return a dict of PETSc option values.

Parameters
[in]icThe properties.initial_conditions mapping.
[in]finit_codeNormalized -finit integer code.
[in]prepared_blocksNormalized BC blocks (may be None).
[in]scalesReference scales, as returned by resolve_reference_scales().
Returns
Dict with keys matching PETSc option names (without leading dash).
Exceptions
KeyErrorif a required key is absent.
ValueErroron invalid combinations or values.

Definition at line 15472 of file core.py.

15472def resolve_ic_cli_params(ic: dict, finit_code: int, prepared_blocks, scales: dict) -> dict:
15473 """!
15474 @brief Resolve all IC parameters and return a dict of PETSc option values.
15475 @param[in] ic The properties.initial_conditions mapping.
15476 @param[in] finit_code Normalized -finit integer code.
15477 @param[in] prepared_blocks Normalized BC blocks (may be None).
15478 @param[in] scales Reference scales, as returned by `resolve_reference_scales()`.
15479 @return Dict with keys matching PETSc option names (without leading dash).
15480 @throws KeyError if a required key is absent.
15481 @throws ValueError on invalid combinations or values.
15482 """
15483 result = {}
15484
15485 if finit_code == 0:
15486 return result
15487
15488 if finit_code == 1: # Constant
15489 has_cartesian = any(k in ic for k in ("u_physical", "v_physical", "w_physical"))
15490 has_curvilinear = "velocity_physical" in ic
15491
15492 if has_cartesian and has_curvilinear:
15493 raise ValueError(
15494 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
15495 "velocity_physical (curvilinear) — use one or the other."
15496 )
15497
15498 if has_curvilinear: # curvilinear: scalar speed along flow_direction axis
15499 cs_code = 1
15500 result["ic_coordinate_system"] = cs_code
15501 try:
15502 vel_phys = float(ic["velocity_physical"])
15503 except (TypeError, ValueError) as exc:
15504 raise ValueError(
15505 f"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
15506 "Expected a numeric value."
15507 ) from exc
15508 result["ic_velocity_physical"] = to_solver_units(
15509 vel_phys, ("case", "properties", "initial_conditions", "velocity_physical"), scales)
15510
15511 if "flow_direction" in ic:
15512 result["flow_direction"] = normalize_flow_direction_token(ic["flow_direction"])
15513 elif not _ic_has_inlet(prepared_blocks):
15514 raise ValueError(
15515 "initial_conditions.flow_direction is required for curvilinear Constant IC "
15516 "when no INLET face exists."
15517 )
15518
15519 else: # cartesian: u/v/w_physical → Cart2Contra (default when no velocity_physical)
15520 if "flow_direction" in ic:
15521 raise ValueError(
15522 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
15523 "Use velocity_physical + flow_direction for curvilinear mode."
15524 )
15525 cs_code = 0
15526 result["ic_coordinate_system"] = cs_code
15527 u, v, w = parse_initial_velocity_components(ic, finit_code, require_explicit=True)
15528 ic_path = ("case", "properties", "initial_conditions")
15529 result["ucont_x"] = to_solver_units(u, ic_path + ("u_physical",), scales)
15530 result["ucont_y"] = to_solver_units(v, ic_path + ("v_physical",), scales)
15531 result["ucont_z"] = to_solver_units(w, ic_path + ("w_physical",), scales)
15532
15533 elif finit_code == 2: # Poiseuille
15534 if any(k in ic for k in ("u_physical", "v_physical", "w_physical")):
15535 raise ValueError(
15536 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
15537 )
15538 if "velocity_physical" in ic:
15539 raise ValueError(
15540 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
15541 )
15542 if "peak_velocity_physical" not in ic:
15543 raise KeyError("peak_velocity_physical")
15544 try:
15545 peak = float(ic["peak_velocity_physical"])
15546 except (TypeError, ValueError) as exc:
15547 raise ValueError(
15548 f"Invalid value for initial_conditions.peak_velocity_physical: "
15549 f"{ic['peak_velocity_physical']!r}. Expected a numeric value."
15550 ) from exc
15551 result["ic_velocity_physical"] = to_solver_units(
15552 peak, ("case", "properties", "initial_conditions", "peak_velocity_physical"), scales)
15553
15554 if "flow_direction" in ic:
15555 fd_int = normalize_flow_direction_token(ic["flow_direction"])
15556 # Cross-check: explicit flow_direction must align with INLET face if one exists
15557 if _ic_has_inlet(prepared_blocks):
15558 inlet_axis = infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks)
15559 fd_axis_name = {0: "x", 1: "y", 2: "z"}.get(fd_int // 2, "?")
15560 if inlet_axis and fd_axis_name != inlet_axis:
15561 token = ic["flow_direction"]
15562 raise ValueError(
15563 f"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
15564 f"does not match INLET face axis '{inlet_axis}'."
15565 )
15566 result["flow_direction"] = fd_int
15567 elif not _ic_has_inlet(prepared_blocks):
15568 raise ValueError(
15569 "initial_conditions.flow_direction is required for Poiseuille IC "
15570 "when no INLET face exists."
15571 )
15572
15573 return result
15574
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_eulerian_field_source()

str picurv_cli.core.normalize_eulerian_field_source ( str  value)

Normalizes the Eulerian field source selector to the C-side canonical string.

Parameters
[in]valueHuman-readable or enum-like Eulerian field source.
Returns
Canonical string accepted by -euler_field_source.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15575 of file core.py.

15575def normalize_eulerian_field_source(value: str) -> str:
15576 """!
15577 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
15578 @param[in] value Human-readable or enum-like Eulerian field source.
15579 @return Canonical string accepted by `-euler_field_source`.
15580 @throws ValueError if the input cannot be mapped.
15581 """
15582 if value is None:
15583 raise ValueError("eulerian_field_source cannot be None")
15584
15585 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15586 aliases = {
15587 "solve": "solve",
15588 "load": "load",
15589 "analytical": "analytical",
15590 }
15591 mapped = aliases.get(normalized)
15592 if mapped is None:
15593 raise ValueError(
15594 f"Unknown operation_mode.eulerian_field_source '{value}'. "
15595 "Use one of: 'solve', 'load', 'analytical'."
15596 )
15597 return mapped
15598
15599
Here is the caller graph for this function:

◆ initial_condition_is_authoritative()

bool picurv_cli.core.initial_condition_is_authoritative ( dict  case_cfg,
dict  solver_cfg 
)

Whether the configured initial condition is the field the solver starts from.

A restart (start_step > 0) starts from saved state, and the load and analytical Eulerian sources supply their own field, so in each of those the configured initial condition is never read.

Parameters
[in]case_cfgParsed case configuration.
[in]solver_cfgParsed solver configuration.
Returns
True only for a fresh solve.

Definition at line 15600 of file core.py.

15600def initial_condition_is_authoritative(case_cfg: dict, solver_cfg: dict) -> bool:
15601 """!
15602 @brief Whether the configured initial condition is the field the solver starts from.
15603 @details A restart (start_step > 0) starts from saved state, and the load and
15604 analytical Eulerian sources supply their own field, so in each of those the
15605 configured initial condition is never read.
15606 @param[in] case_cfg Parsed case configuration.
15607 @param[in] solver_cfg Parsed solver configuration.
15608 @return True only for a fresh solve.
15609 """
15610 source = normalize_eulerian_field_source(
15611 ((solver_cfg or {}).get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
15612 )
15613 start_step = int(((case_cfg or {}).get("run_control", {}) or {}).get("start_step", 0) or 0)
15614 return source == "solve" and start_step == 0
15615
15616
Here is the call graph for this function:
Here is the caller graph for this function:

◆ unused_asset_kinds()

tuple picurv_cli.core.unused_asset_kinds ( dict  case_cfg,
dict  solver_cfg 
)

Asset kinds a run with these configurations would build but never read.

Parameters
[in]case_cfgParsed case configuration.
[in]solver_cfgParsed solver configuration.
Returns
Kinds to leave out of the run's asset materialization.

Definition at line 15617 of file core.py.

15617def unused_asset_kinds(case_cfg: dict, solver_cfg: dict) -> tuple:
15618 """!
15619 @brief Asset kinds a run with these configurations would build but never read.
15620 @param[in] case_cfg Parsed case configuration.
15621 @param[in] solver_cfg Parsed solver configuration.
15622 @return Kinds to leave out of the run's asset materialization.
15623 """
15624 return () if initial_condition_is_authoritative(case_cfg, solver_cfg) else ("initial-condition",)
15625
15626
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_analytical_type()

str picurv_cli.core.normalize_analytical_type ( str  value)

Normalizes the analytical solution selector to the C-side canonical string.

Parameters
[in]valueHuman-readable analytical solution selector.
Returns
Canonical string accepted by -analytical_type.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15627 of file core.py.

15627def normalize_analytical_type(value: str) -> str:
15628 """!
15629 @brief Normalizes the analytical solution selector to the C-side canonical string.
15630 @param[in] value Human-readable analytical solution selector.
15631 @return Canonical string accepted by `-analytical_type`.
15632 @throws ValueError if the input cannot be mapped.
15633 """
15634 # Canonical selector map for analytical solution selectors.
15635 if value is None:
15636 raise ValueError("analytical_type cannot be None")
15637
15638 normalized = str(value).strip().upper().replace("-", "_").replace(" ", "_")
15639 if normalized not in ANALYTICAL_SOLUTION_TYPES:
15640 raise ValueError(
15641 f"Unknown operation_mode.analytical_type '{value}'. "
15642 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
15643 )
15644 return normalized
15645
Here is the caller graph for this function:

◆ parse_initial_velocity_components()

"tuple[float, float, float]" picurv_cli.core.parse_initial_velocity_components ( dict  initial_conditions,
int  finit_code,
*bool  require_explicit 
)

Parse initial-condition velocity components with mode-aware defaults.

Parameters
[in]initial_conditionsThe properties.initial_conditions mapping from case.yml.
[in]finit_codeNormalized -finit integer code.
[in]require_explicitIf True, all three component keys must be present.
Returns
Tuple (u, v, w) in physical units.
Exceptions
KeyErrorif a required component key is missing.
ValueErrorif a component cannot be converted to float.

Definition at line 15646 of file core.py.

15646def parse_initial_velocity_components(initial_conditions: dict, finit_code: int, *, require_explicit: bool) -> "tuple[float, float, float]":
15647 """!
15648 @brief Parse initial-condition velocity components with mode-aware defaults.
15649 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
15650 @param[in] finit_code Normalized `-finit` integer code.
15651 @param[in] require_explicit If True, all three component keys must be present.
15652 @return Tuple `(u, v, w)` in physical units.
15653 @throws KeyError if a required component key is missing.
15654 @throws ValueError if a component cannot be converted to float.
15655 """
15656 component_keys = ("u_physical", "v_physical", "w_physical")
15657 components = []
15658 for key in component_keys:
15659 if key not in initial_conditions:
15660 if require_explicit:
15661 raise KeyError(key)
15662 raw_value = 0.0
15663 else:
15664 raw_value = initial_conditions[key]
15665 try:
15666 components.append(float(raw_value))
15667 except (TypeError, ValueError) as exc:
15668 raise ValueError(
15669 f"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
15670 ) from exc
15671 return tuple(components)
15672
Here is the caller graph for this function:

◆ infer_unique_inlet_axis_from_prepared_bcs()

"str | None" picurv_cli.core.infer_unique_inlet_axis_from_prepared_bcs ( list  prepared_blocks)

Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.

Parameters
[in]prepared_blocksNormalized BC blocks from validate_and_prepare_boundary_conditions.
Returns
One of "x", "y", "z" if unique, None if no inlet exists.
Exceptions
ValueErrorif different blocks imply different inlet axes.

Definition at line 15673 of file core.py.

15673def infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks: list) -> "str | None":
15674 """!
15675 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
15676 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
15677 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
15678 @throws ValueError if different blocks imply different inlet axes.
15679 """
15680 face_order = ("-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta")
15681 face_axis = {
15682 "-Xi": "x", "+Xi": "x",
15683 "-Eta": "y", "+Eta": "y",
15684 "-Zeta": "z", "+Zeta": "z",
15685 }
15686
15687 inlet_axes = set()
15688 for block_bcs in prepared_blocks:
15689 face_map = {entry["face"]: entry for entry in block_bcs}
15690 for face in face_order:
15691 entry = face_map.get(face)
15692 if entry and entry["type"] == "INLET":
15693 inlet_axes.add(face_axis[face])
15694 break
15695
15696 if not inlet_axes:
15697 return None
15698 if len(inlet_axes) != 1:
15699 raise ValueError(
15700 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
15701 f"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
15702 )
15703 return next(iter(inlet_axes))
15704
Here is the caller graph for this function:

◆ normalize_particle_init_mode()

int picurv_cli.core.normalize_particle_init_mode ( str  value)

Maps canonical particle init mode names to C enum/int codes (-pinit).

Parameters
[in]valueCanonical particle initialization mode.
Returns
Canonical integer code accepted by -pinit.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15705 of file core.py.

15705def normalize_particle_init_mode(value: str) -> int:
15706 """!
15707 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
15708 @param[in] value Canonical particle initialization mode.
15709 @return Canonical integer code accepted by -pinit.
15710 @throws ValueError if the input cannot be mapped.
15711 """
15712 # Canonical selector map for particle initialization modes.
15713 if value is None:
15714 raise ValueError("particle init mode cannot be None")
15715
15716 name = str(value).strip()
15717 # SurfaceEdges was a diagnostic pattern whose placement depended on the rank layout.
15718 if name == "SurfaceEdges":
15719 raise ValueError(
15720 "particle init_mode 'SurfaceEdges' is retired: use 'Surface' for inlet seeding, "
15721 "or 'PointSource' to place particles at a chosen point."
15722 )
15723 mapped = {
15724 "Surface": 0,
15725 "Volume": 1,
15726 "PointSource": 2,
15727 }.get(name)
15728 if mapped is None:
15729 raise ValueError(
15730 f"Unknown particle init_mode '{value}'. Use one of: "
15731 "'Surface', 'Volume', 'PointSource'."
15732 )
15733 return mapped
15734
Here is the caller graph for this function:

◆ normalize_interpolation_method()

int picurv_cli.core.normalize_interpolation_method ( str  value)

Maps interpolation method names to C enum/int codes (-interpolation_method).

Parameters
[in]valueCanonical interpolation method name.
Returns
Integer code accepted by -interpolation_method.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15735 of file core.py.

15735def normalize_interpolation_method(value: str) -> int:
15736 """!
15737 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
15738 @param[in] value Canonical interpolation method name.
15739 @return Integer code accepted by -interpolation_method.
15740 @throws ValueError if the input cannot be mapped.
15741 """
15742 if value is None:
15743 raise ValueError("interpolation method cannot be None")
15744
15745 mapped = {
15746 "Trilinear": 0,
15747 "CornerAveraged": 1,
15748 "FaceFlux": 2,
15749 }.get(str(value).strip())
15750 if mapped is None:
15751 raise ValueError(
15752 f"Unknown interpolation_method '{value}'. Use one of: "
15753 "'Trilinear', 'CornerAveraged', 'FaceFlux'."
15754 )
15755 return mapped
15756
15757#: Canonical spelling of each LES model code, for diagnostics.
Here is the caller graph for this function:

◆ normalize_les_model()

int picurv_cli.core.normalize_les_model (   value)

Maps LES model selectors to C enum/int codes (-les).

Parameters
[in]valueLES selector name or legacy integer/bool value.
Returns
Integer code accepted by -les.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15777 of file core.py.

15777def normalize_les_model(value) -> int:
15778 """!
15779 @brief Maps LES model selectors to C enum/int codes (-les).
15780 @param[in] value LES selector name or legacy integer/bool value.
15781 @return Integer code accepted by -les.
15782 @throws ValueError if the input cannot be mapped.
15783 """
15784 if isinstance(value, bool):
15785 return 1 if value else 0
15786 if isinstance(value, int):
15787 if value in (0, 1, 2, 3, 4):
15788 return value
15789 raise ValueError("models.physics.turbulence.les must be 0-4, false/true, or a supported model block.")
15790 if value is None:
15791 raise ValueError("LES model cannot be None")
15792
15793 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15794 mapped = {
15795 "none": 0,
15796 "off": 0,
15797 "disabled": 0,
15798 "no_les": 0,
15799 "constant": 1,
15800 "constant_smagorinsky": 1,
15801 "smagorinsky": 1,
15802 "dynamic": 2,
15803 "dynamic_smagorinsky": 2,
15804 "vreman": 3,
15805 "wale": 4,
15806 }.get(key)
15807 if mapped is None:
15808 raise ValueError(
15809 f"Unknown LES model '{value}'. Use one of: 'none', "
15810 "'constant_smagorinsky', 'dynamic_smagorinsky', 'vreman', 'wale'."
15811 )
15812 return mapped
15813
Here is the caller graph for this function:

◆ normalize_les_test_filter()

int picurv_cli.core.normalize_les_test_filter (   value)

Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.

Parameters
[in]valueTest-filter selector name or integer code.
Returns
0 for the volume-weighted box filter, 1 for the i/k Simpson filter.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15814 of file core.py.

15814def normalize_les_test_filter(value) -> int:
15815 """!
15816 @brief Maps LES test-filter kernel names to the C -les_test_filter_kernel flag.
15817 @param[in] value Test-filter selector name or integer code.
15818 @return 0 for the volume-weighted box filter, 1 for the i/k Simpson filter.
15819 @throws ValueError if the input cannot be mapped.
15820 """
15821 if isinstance(value, bool):
15822 raise ValueError("models.physics.turbulence.les.test_filter.kernel must name a filter, not a boolean.")
15823 if isinstance(value, int):
15824 if value in (0, 1):
15825 return value
15826 raise ValueError("models.physics.turbulence.les.test_filter.kernel must be 0, 1, or a supported filter name.")
15827 if value is None:
15828 raise ValueError("LES test_filter.kernel cannot be None")
15829
15830 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15831 mapped = {
15832 "volume_weighted_box": 0,
15833 "box": 0,
15834 "simpson_ik": 1,
15835 }.get(key)
15836 if mapped is None:
15837 raise ValueError(
15838 f"Unknown LES test filter kernel '{value}'. Use one of: "
15839 "'volume_weighted_box', 'simpson_ik'."
15840 )
15841 return mapped
15842
Here is the caller graph for this function:

◆ normalize_les_filter_width()

int picurv_cli.core.normalize_les_filter_width (   value)

Maps LES grid-filter-width model names to the C -les_filter_width flag.

Parameters
[in]valueFilter-width model name or integer code.
Returns
0 for cube-root volume, 1 for the geometric mean of the cell extents, 2 for the longest cell extent, 3 for the Scotti aspect-ratio correction.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15843 of file core.py.

15843def normalize_les_filter_width(value) -> int:
15844 """!
15845 @brief Maps LES grid-filter-width model names to the C -les_filter_width flag.
15846 @param[in] value Filter-width model name or integer code.
15847 @return 0 for cube-root volume, 1 for the geometric mean of the cell extents,
15848 2 for the longest cell extent, 3 for the Scotti aspect-ratio correction.
15849 @throws ValueError if the input cannot be mapped.
15850 """
15851 if isinstance(value, bool):
15852 raise ValueError("models.physics.turbulence.les.filter_width must name a model, not a boolean.")
15853 if isinstance(value, int):
15854 if value in (0, 1, 2, 3):
15855 return value
15856 raise ValueError("models.physics.turbulence.les.filter_width must be 0, 1, 2, 3, or a supported model name.")
15857 if value is None:
15858 raise ValueError("LES filter_width cannot be None")
15859
15860 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15861 mapped = {
15862 "cube_root_volume": 0,
15863 "geometric_mean": 1,
15864 "max_edge": 2,
15865 "scotti": 3,
15866 }.get(key)
15867 if mapped is None:
15868 raise ValueError(
15869 f"Unknown LES filter width model '{value}'. Use one of: "
15870 "'cube_root_volume', 'geometric_mean', 'max_edge', 'scotti'."
15871 )
15872 return mapped
15873

◆ normalize_les_averaging_mode()

int picurv_cli.core.normalize_les_averaging_mode (   value)

Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.

Parameters
[in]valueAveraging mode name or integer code.
Returns
0 for pointwise local averaging, 1 for homogeneous directions, 2 for the whole block.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15874 of file core.py.

15874def normalize_les_averaging_mode(value) -> int:
15875 """!
15876 @brief Maps LES coefficient-averaging mode names to the C -les_averaging_mode flag.
15877 @param[in] value Averaging mode name or integer code.
15878 @return 0 for pointwise local averaging, 1 for homogeneous directions, 2 for the
15879 whole block.
15880 @throws ValueError if the input cannot be mapped.
15881 """
15882 if isinstance(value, bool):
15883 raise ValueError("models.physics.turbulence.les.averaging.mode must name a mode, not a boolean.")
15884 if isinstance(value, int):
15885 if value in (0, 1, 2):
15886 return value
15887 raise ValueError("models.physics.turbulence.les.averaging.mode must be 0, 1, 2, or a supported mode name.")
15888 if value is None:
15889 raise ValueError("LES averaging.mode cannot be None")
15890
15891 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15892 mapped = {
15893 "local": 0,
15894 "homogeneous": 1,
15895 "global": 2,
15896 }.get(key)
15897 if mapped is None:
15898 raise ValueError(
15899 f"Unknown LES averaging mode '{value}'. Use one of: "
15900 "'local', 'homogeneous', 'global'."
15901 )
15902 return mapped
15903
Here is the caller graph for this function:

◆ normalize_les_clip_mode()

int picurv_cli.core.normalize_les_clip_mode (   value)

Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.

Parameters
[in]valueClipping mode name or integer code.
Returns
0 to clamp into [0, max_cs^2], 1 to discard negatives only, 2 to keep the signed coefficient so backscatter survives.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15904 of file core.py.

15904def normalize_les_clip_mode(value) -> int:
15905 """!
15906 @brief Maps LES coefficient-limiting mode names to the C -les_clip_mode flag.
15907 @param[in] value Clipping mode name or integer code.
15908 @return 0 to clamp into [0, max_cs^2], 1 to discard negatives only, 2 to keep the
15909 signed coefficient so backscatter survives.
15910 @throws ValueError if the input cannot be mapped.
15911 """
15912 if isinstance(value, bool):
15913 raise ValueError("models.physics.turbulence.les.clipping.mode must name a mode, not a boolean.")
15914 if isinstance(value, int):
15915 if value in (0, 1, 2):
15916 return value
15917 raise ValueError("models.physics.turbulence.les.clipping.mode must be 0, 1, 2, or a supported mode name.")
15918 if value is None:
15919 raise ValueError("LES clipping.mode cannot be None")
15920
15921 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15922 mapped = {
15923 "clamp": 0,
15924 "clip_negative": 1,
15925 "none": 2,
15926 }.get(key)
15927 if mapped is None:
15928 raise ValueError(
15929 f"Unknown LES clipping mode '{value}'. Use one of: "
15930 "'clamp', 'clip_negative', 'none'."
15931 )
15932 return mapped
15933
15934#: The logical grid directions an LES averaging set may span, in canonical order.
Here is the caller graph for this function:

◆ normalize_les_averaging_directions()

str picurv_cli.core.normalize_les_averaging_directions (   value)

Maps a list of homogeneous logical directions to the C flag's string form.

Parameters
[in]valueList or string naming a subset of the i, j, and k directions.
Returns
The selected directions as a canonically ordered subset of "ijk".
Exceptions
ValueErrorif a direction is unknown or repeated.

Definition at line 15937 of file core.py.

15937def normalize_les_averaging_directions(value) -> str:
15938 """!
15939 @brief Maps a list of homogeneous logical directions to the C flag's string form.
15940 @param[in] value List or string naming a subset of the i, j, and k directions.
15941 @return The selected directions as a canonically ordered subset of "ijk".
15942 @throws ValueError if a direction is unknown or repeated.
15943 """
15944 if value is None:
15945 return ""
15946 if isinstance(value, str):
15947 tokens = [token for token in value.strip().lower()]
15948 elif isinstance(value, (list, tuple)):
15949 tokens = [str(token).strip().lower() for token in value]
15950 else:
15951 raise ValueError(
15952 "models.physics.turbulence.les.averaging.directions must be a list such as [i, k]."
15953 )
15954
15955 selected = []
15956 for token in tokens:
15957 if token not in LES_AVERAGING_DIRECTION_AXES:
15958 raise ValueError(
15959 f"Unknown LES averaging direction '{token}'. Use a subset of ['i', 'j', 'k']."
15960 )
15961 if token in selected:
15962 raise ValueError(
15963 f"LES averaging direction '{token}' is repeated; list each direction once."
15964 )
15965 selected.append(token)
15966 return "".join(axis for axis in LES_AVERAGING_DIRECTION_AXES if axis in selected)
15967
Here is the caller graph for this function:

◆ normalize_wall_function_model()

int picurv_cli.core.normalize_wall_function_model (   value)

Maps wall-function model selectors to the C -wallfunction flag.

Parameters
[in]valueWall-function selector name, or None for the default.
Returns
Integer code accepted by -wallfunction; 1 log law, 2 Werner-Wengle, 3 Cabot.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 15968 of file core.py.

15968def normalize_wall_function_model(value) -> int:
15969 """!
15970 @brief Maps wall-function model selectors to the C -wallfunction flag.
15971 @param[in] value Wall-function selector name, or None for the default.
15972 @return Integer code accepted by -wallfunction; 1 log law, 2 Werner-Wengle, 3 Cabot.
15973 @throws ValueError if the input cannot be mapped.
15974 """
15975 if value is None:
15976 return 1
15977 if isinstance(value, bool):
15978 raise ValueError("models.physics.turbulence.wall_function.model must name a model, not a boolean.")
15979 if isinstance(value, int):
15980 if value in (1, 2, 3):
15981 return value
15982 raise ValueError("models.physics.turbulence.wall_function.model must be 1, 2, 3, or a supported model name.")
15983 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
15984 mapped = {
15985 "log_law": 1,
15986 "loglaw": 1,
15987 "werner": 2,
15988 "werner_wengle": 2,
15989 "cabot": 3,
15990 }.get(key)
15991 if mapped is None:
15992 raise ValueError(
15993 "Unknown wall_function model '%s'. Use one of: 'log_law', 'werner', 'cabot'." % value)
15994 return mapped
15995
Here is the caller graph for this function:

◆ resolve_enabled_flag()

bool picurv_cli.core.resolve_enabled_flag ( dict  cfg,
str  path,
bool   default = True 
)

Resolves a structured enabled flag and rejects non-boolean values.

Parameters
[in]cfgMapping that may contain enabled.
[in]pathHuman-readable config path for diagnostics.
[in]defaultValue used when enabled is omitted.
Returns
Boolean enabled state.
Exceptions
ValueErrorif enabled is not a YAML boolean.

Definition at line 15996 of file core.py.

15996def resolve_enabled_flag(cfg: dict, path: str, default: bool = True) -> bool:
15997 """!
15998 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
15999 @param[in] cfg Mapping that may contain `enabled`.
16000 @param[in] path Human-readable config path for diagnostics.
16001 @param[in] default Value used when `enabled` is omitted.
16002 @return Boolean enabled state.
16003 @throws ValueError if `enabled` is not a YAML boolean.
16004 """
16005 if 'enabled' not in cfg:
16006 return default
16007 if not isinstance(cfg['enabled'], bool):
16008 raise ValueError(f"{path}.enabled must be true or false.")
16009 return cfg['enabled']
16010
Here is the caller graph for this function:

◆ append_les_parameter_flags()

picurv_cli.core.append_les_parameter_flags ( dict  les_cfg,
list  control_lines 
)

Appends the LES closure parameter flags from a structured les block.

Parameters
[in]les_cfgParsed models.physics.turbulence.les mapping.
[out]control_linesA list of strings to which C-flags will be appended.
Exceptions
ValueErrorif a selector name or nested block shape is unsupported.

Definition at line 16011 of file core.py.

16011def append_les_parameter_flags(les_cfg: dict, control_lines: list):
16012 """!
16013 @brief Appends the LES closure parameter flags from a structured les block.
16014 @param[in] les_cfg Parsed `models.physics.turbulence.les` mapping.
16015 @param[out] control_lines A list of strings to which C-flags will be appended.
16016 @throws ValueError if a selector name or nested block shape is unsupported.
16017 """
16018 if 'constant_cs' in les_cfg:
16019 control_lines.append(f"-les_constant_cs {format_flag_value(les_cfg['constant_cs'])}")
16020 if 'vreman_coefficient' in les_cfg:
16021 control_lines.append(f"-les_vreman_coefficient {format_flag_value(les_cfg['vreman_coefficient'])}")
16022 if 'wale_coefficient' in les_cfg:
16023 control_lines.append(f"-les_wale_coefficient {format_flag_value(les_cfg['wale_coefficient'])}")
16024 if 'dynamic_frequency' in les_cfg:
16025 control_lines.append(f"-les_dynamic_frequency {format_flag_value(les_cfg['dynamic_frequency'])}")
16026 if 'filter_width' in les_cfg:
16027 control_lines.append(f"-les_filter_width {normalize_les_filter_width(les_cfg['filter_width'])}")
16028
16029 # A bare string is accepted as shorthand for naming only the kernel, matching the
16030 # way the les block itself accepts either a scalar or a mapping.
16031 test_filter = les_cfg.get('test_filter')
16032 if test_filter is not None:
16033 if not isinstance(test_filter, dict):
16034 test_filter = {'kernel': test_filter}
16035 if 'kernel' in test_filter:
16036 control_lines.append(
16037 f"-les_test_filter_kernel {normalize_les_test_filter(test_filter['kernel'])}")
16038 if 'width_ratio' in test_filter:
16039 control_lines.append(
16040 f"-les_test_filter_width_ratio {format_flag_value(test_filter['width_ratio'])}")
16041
16042 averaging = les_cfg.get('averaging')
16043 if averaging is not None:
16044 if not isinstance(averaging, dict):
16045 averaging = {'mode': averaging}
16046 if 'mode' in averaging:
16047 control_lines.append(
16048 f"-les_averaging_mode {normalize_les_averaging_mode(averaging['mode'])}")
16049 if 'directions' in averaging:
16050 directions = normalize_les_averaging_directions(averaging['directions'])
16051 if directions:
16052 control_lines.append(f"-les_averaging_directions {directions}")
16053
16054 clipping = les_cfg.get('clipping')
16055 if clipping is not None:
16056 if not isinstance(clipping, dict):
16057 clipping = {'mode': clipping}
16058 if 'mode' in clipping:
16059 control_lines.append(f"-les_clip_mode {normalize_les_clip_mode(clipping['mode'])}")
16060 if 'max_cs' in clipping:
16061 control_lines.append(f"-les_clip_max_cs {format_flag_value(clipping['max_cs'])}")
16062 if 'min_viscosity_ratio' in clipping:
16063 control_lines.append(
16064 f"-les_min_viscosity_ratio {format_flag_value(clipping['min_viscosity_ratio'])}")
16065
16066 gradient_model = les_cfg.get('gradient_model')
16067 if gradient_model is not None:
16068 if not isinstance(gradient_model, dict):
16069 gradient_model = {'enabled': gradient_model}
16070 enabled = resolve_enabled_flag(gradient_model,
16071 "models.physics.turbulence.les.gradient_model")
16072 control_lines.append(f"-les_gradient_model {1 if enabled else 0}")
16073
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_les_diagnostics()

"dict | None" picurv_cli.core.resolve_les_diagnostics ( dict  case_cfg,
dict  monitor_cfg,
bool   warn = False 
)

Resolve the LES diagnostic log settings from monitor.yml or their deprecated location.

The log is observation, not physics, so it is configured in monitor.yml diagnostics.les; case.yml models.physics.turbulence.les.diagnostics is read as a deprecated alias. Setting both is refused. yoshizawa_ci only scales the subgrid kinetic energy the log reports, so it belongs with the log.

Parameters
[in]case_cfgParsed case configuration.
[in]monitor_cfgParsed monitor configuration.
[in]warnPrint a deprecation warning when the case.yml location is used.
Returns
{"enabled", optional "cadence", optional "yoshizawa_ci"}, or None when unset.
Exceptions
ValueErrorfor a duplicated, malformed, or out-of-range setting.

Definition at line 16074 of file core.py.

16074def resolve_les_diagnostics(case_cfg: dict, monitor_cfg: dict, warn: bool = False) -> "dict | None":
16075 """!
16076 @brief Resolve the LES diagnostic log settings from monitor.yml or their deprecated location.
16077 @details The log is observation, not physics, so it is configured in monitor.yml
16078 `diagnostics.les`; case.yml `models.physics.turbulence.les.diagnostics` is read as a
16079 deprecated alias. Setting both is refused. `yoshizawa_ci` only scales the subgrid
16080 kinetic energy the log reports, so it belongs with the log.
16081 @param[in] case_cfg Parsed case configuration.
16082 @param[in] monitor_cfg Parsed monitor configuration.
16083 @param[in] warn Print a deprecation warning when the case.yml location is used.
16084 @return {"enabled", optional "cadence", optional "yoshizawa_ci"}, or None when unset.
16085 @throws ValueError for a duplicated, malformed, or out-of-range setting.
16086 """
16087 monitor_value = (((monitor_cfg or {}).get("diagnostics") or {}).get("les")
16088 if isinstance((monitor_cfg or {}).get("diagnostics"), dict) else None)
16089 les_cfg = ((((case_cfg or {}).get("models") or {}).get("physics") or {}).get("turbulence") or {}).get("les")
16090 case_value = les_cfg.get("diagnostics") if isinstance(les_cfg, dict) else None
16091 if monitor_value is not None and case_value is not None:
16092 raise ValueError("LES diagnostics are set in monitor.yml (diagnostics.les) and in their deprecated "
16093 "case.yml location (models.physics.turbulence.les.diagnostics); keep only monitor.yml.")
16094 value, label = ((monitor_value, "monitor.yml diagnostics.les") if monitor_value is not None
16095 else (case_value, "case.yml models.physics.turbulence.les.diagnostics"))
16096 if value is None:
16097 return None
16098 if not isinstance(value, dict):
16099 value = {"enabled": value}
16100 unknown = sorted(set(value) - {"enabled", "cadence", "yoshizawa_ci"})
16101 if unknown:
16102 raise ValueError(f"{label} has unsupported key(s): {unknown}. Use enabled, cadence, yoshizawa_ci.")
16103 resolved = {"enabled": resolve_enabled_flag(value, label)}
16104 if "cadence" in value:
16105 if isinstance(value["cadence"], bool) or not isinstance(value["cadence"], int) or value["cadence"] <= 0:
16106 raise ValueError(f"{label}.cadence must be a positive integer.")
16107 resolved["cadence"] = value["cadence"]
16108 if "yoshizawa_ci" in value:
16109 try:
16110 ci = _to_finite_float(value["yoshizawa_ci"], f"{label}.yoshizawa_ci")
16111 except (TypeError, ValueError) as exc:
16112 raise ValueError(f"{label}.yoshizawa_ci must be numeric.") from exc
16113 if ci < 0.0:
16114 raise ValueError(f"{label}.yoshizawa_ci must be non-negative.")
16115 resolved["yoshizawa_ci"] = ci
16116 if monitor_value is None and warn:
16117 print("[WARNING] case.yml models.physics.turbulence.les.diagnostics is deprecated; "
16118 "set it in monitor.yml as diagnostics.les.", file=sys.stderr)
16119 return resolved
16120
16121
Here is the call graph for this function:
Here is the caller graph for this function:

◆ append_les_diagnostics_flags()

picurv_cli.core.append_les_diagnostics_flags ( "dict | None"  diagnostics,
list  control_lines 
)

Append the LES diagnostic log flags resolved by resolve_les_diagnostics().

Parameters
[in]diagnosticsResolved settings, or None to append nothing.
[out]control_linesA list of strings to which C-flags will be appended.

Definition at line 16122 of file core.py.

16122def append_les_diagnostics_flags(diagnostics: "dict | None", control_lines: list):
16123 """!
16124 @brief Append the LES diagnostic log flags resolved by resolve_les_diagnostics().
16125 @param[in] diagnostics Resolved settings, or None to append nothing.
16126 @param[out] control_lines A list of strings to which C-flags will be appended.
16127 """
16128 if diagnostics is None:
16129 return
16130 control_lines.append(f"-les_diagnostics {'true' if diagnostics['enabled'] else 'false'}")
16131 if "cadence" in diagnostics:
16132 control_lines.append(f"-les_diagnostics_cadence {format_flag_value(diagnostics['cadence'])}")
16133 if "yoshizawa_ci" in diagnostics:
16134 control_lines.append(f"-les_yoshizawa_ci {format_flag_value(diagnostics['yoshizawa_ci'])}")
16135
Here is the caller graph for this function:

◆ append_turbulence_flags()

picurv_cli.core.append_turbulence_flags ( dict  models,
list  control_lines,
dict   scales = None 
)

Appends turbulence model flags from legacy or structured case.yml blocks.

Parameters
[in]modelsParsed case.yml models mapping.
[out]control_linesA list of strings to which C-flags will be appended.
[in]scalesThe case's reference scales. Required only when a physical roughness height is set.
Exceptions
RuntimeErrorwhen a roughness height is set and no scales were supplied.

Definition at line 16136 of file core.py.

16136def append_turbulence_flags(models: dict, control_lines: list, scales: dict = None):
16137 """!
16138 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
16139 @param[in] models Parsed case.yml `models` mapping.
16140 @param[out] control_lines A list of strings to which C-flags will be appended.
16141 @param[in] scales The case's reference scales. Required only when a physical
16142 roughness height is set.
16143 @throws RuntimeError when a roughness height is set and no scales were supplied.
16144 """
16145 turbulence_cfg = models.get('physics', {}).get('turbulence', {})
16146 if not turbulence_cfg:
16147 return
16148 if not isinstance(turbulence_cfg, dict):
16149 raise ValueError("models.physics.turbulence must be a mapping.")
16150
16151 les_cfg = turbulence_cfg.get('les')
16152 rans_cfg = turbulence_cfg.get('rans')
16153 wall_cfg = turbulence_cfg.get('wall_function')
16154 les_code = None
16155
16156 if isinstance(les_cfg, dict):
16157 enabled = resolve_enabled_flag(les_cfg, "models.physics.turbulence.les")
16158 model_value = les_cfg.get('model', 'constant_smagorinsky')
16159 les_code = normalize_les_model(model_value) if enabled else 0
16160 control_lines.append(f"-les {les_code}")
16161 append_les_parameter_flags(les_cfg, control_lines)
16162 elif les_cfg is not None:
16163 les_code = normalize_les_model(les_cfg)
16164 control_lines.append(f"-les {les_code}")
16165
16166 if rans_cfg is not None:
16167 raise ValueError(RANS_PLANNED_REFUSAL)
16168
16169 if isinstance(wall_cfg, dict):
16170 enabled = resolve_enabled_flag(wall_cfg, "models.physics.turbulence.wall_function")
16171 # The flag carries the model, with zero meaning disabled, so a selector change
16172 # reaches the runtime instead of being validated and dropped.
16173 wall_model = normalize_wall_function_model(wall_cfg.get('model'))
16174 control_lines.append(f"-wallfunction {wall_model if enabled else 0}")
16175 if 'roughness_height' in wall_cfg:
16176 if scales is None:
16177 raise RuntimeError("wall_function.roughness_height is a physical length and needs the case reference scales.")
16178 roughness = to_solver_units(
16179 wall_cfg['roughness_height'],
16180 ("case", "models", "physics", "turbulence", "wall_function", "roughness_height"), scales)
16181 control_lines.append(f"-wall_roughness {format_flag_value(roughness)}")
16182 elif wall_cfg is not None:
16183 control_lines.append(f"-wallfunction {format_flag_value(wall_cfg)}")
16184
Here is the call graph for this function:
Here is the caller graph for this function:

◆ control_value()

picurv_cli.core.control_value (   value,
str  context 
)

Guard a value that is written verbatim into the generated control file.

PETSc reads the control file line by line, so a value containing a newline writes additional option lines - which defeats every key-based check by smuggling a whole new flag. Rejecting the value is the only safe handling; there is no quoting that makes a multi-line option meaningful.

Parameters
[in]valueConfigured value destined for a control line.
[in]contextYAML path, for the error message.
Returns
The value unchanged when it is safe to emit.
Exceptions
SystemExitwhen the value would inject an option line.

Definition at line 16185 of file core.py.

16185def control_value(value, context: str):
16186 """!
16187 @brief Guard a value that is written verbatim into the generated control file.
16188
16189 @details PETSc reads the control file line by line, so a value containing a newline
16190 writes additional option lines - which defeats every key-based check by
16191 smuggling a whole new flag. Rejecting the value is the only safe handling;
16192 there is no quoting that makes a multi-line option meaningful.
16193 @param[in] value Configured value destined for a control line.
16194 @param[in] context YAML path, for the error message.
16195 @return The value unchanged when it is safe to emit.
16196 @throws SystemExit when the value would inject an option line.
16197 """
16198 text = str(value)
16199 if any(character in text for character in ("\n", "\r")):
16200 print(
16201 f"[FATAL] {context} contains a newline. Values written to the generated control "
16202 f"file must occupy a single line; a multi-line value would inject additional "
16203 f"PETSc options.",
16204 file=sys.stderr,
16205 )
16206 sys.exit(1)
16207 return value
16208
16209
Here is the caller graph for this function:

◆ append_passthrough_flags()

picurv_cli.core.append_passthrough_flags ( list  control_lines,
dict  options 
)

Appends raw CLI flags to the control list from a {flag: value} dict.

Boolean true is emitted as a switch with no value. Boolean false is skipped. All other values are emitted as "<flag> <value>".

Parameters
[out]control_linesThe destination list of control-file lines.
[in]optionsMapping of raw CLI flags to values.

Definition at line 16210 of file core.py.

16210def append_passthrough_flags(control_lines: list, options: dict):
16211 """!
16212 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
16213 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
16214 is skipped. All other values are emitted as "<flag> <value>".
16215 @param[out] control_lines The destination list of control-file lines.
16216 @param[in] options Mapping of raw CLI flags to values.
16217 """
16218 if not options:
16219 return
16220 for flag, value in options.items():
16221 if isinstance(flag, str) and flag.strip() in RESERVED_DIRECTORY_FLAGS:
16222 # Defense in depth: validation rejects these, but the emitter must never
16223 # write a run-directory flag from an unvalidated passthrough surface.
16224 raise ValueError(
16225 f"Refusing to emit reserved directory flag '{flag.strip()}' from passthrough "
16226 "options; run directories are fixed by the workspace contract."
16227 )
16228 if isinstance(value, bool):
16229 if value:
16230 control_lines.append(str(flag))
16231 continue
16232 control_lines.append(
16233 f"{flag} {control_value(format_flag_value(value), f'passthrough option {flag}')}"
16234 )
16235
16236
Here is the caller graph for this function:

◆ resolve_solver_monitoring_flags()

dict picurv_cli.core.resolve_solver_monitoring_flags ( dict  monitor_cfg)

Resolve human-readable solver monitoring YAML to raw control flags.

Parameters
[in]monitor_cfgParsed monitor.yml mapping.
Returns
Mapping of raw C/PETSc flags to values.

Definition at line 16253 of file core.py.

16253def resolve_solver_monitoring_flags(monitor_cfg: dict) -> dict:
16254 """!
16255 @brief Resolve human-readable solver monitoring YAML to raw control flags.
16256 @param[in] monitor_cfg Parsed monitor.yml mapping.
16257 @return Mapping of raw C/PETSc flags to values.
16258 """
16259 solver_mon_cfg = monitor_cfg.get("solver_monitoring", {}) if isinstance(monitor_cfg, dict) else {}
16260 if solver_mon_cfg is None:
16261 return {}
16262 if not isinstance(solver_mon_cfg, dict):
16263 raise ValueError("monitor.solver_monitoring must be a mapping when provided.")
16264
16265 flags = {}
16266
16267 momentum_cfg = solver_mon_cfg.get("momentum", {})
16268 if momentum_cfg is None:
16269 momentum_cfg = {}
16270 if not isinstance(momentum_cfg, dict):
16271 raise ValueError("monitor.solver_monitoring.momentum must be a mapping when provided.")
16272 unknown_momentum = sorted(set(momentum_cfg.keys()) - set(SOLVER_MONITORING_MOMENTUM_FLAG_MAP.keys()))
16273 if unknown_momentum:
16274 raise ValueError(f"monitor.solver_monitoring.momentum has unsupported key(s): {unknown_momentum}.")
16275 for key, flag in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.items():
16276 if key in momentum_cfg:
16277 value = momentum_cfg[key]
16278 if not isinstance(value, bool):
16279 raise ValueError(f"monitor.solver_monitoring.momentum.{key} must be boolean.")
16280 flags[flag] = value
16281
16282 poisson_cfg = solver_mon_cfg.get("poisson", {})
16283 if poisson_cfg is None:
16284 poisson_cfg = {}
16285 if not isinstance(poisson_cfg, dict):
16286 raise ValueError("monitor.solver_monitoring.poisson must be a mapping when provided.")
16287 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
16288 if unknown_poisson:
16289 raise ValueError(f"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
16290 for key, flag in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
16291 if key in poisson_cfg:
16292 value = poisson_cfg[key]
16293 if not isinstance(value, bool):
16294 raise ValueError(f"monitor.solver_monitoring.poisson.{key} must be boolean.")
16295 flags[flag] = value
16296
16297 passthrough = solver_mon_cfg.get("petsc_passthrough_options", {})
16298 if passthrough is None:
16299 passthrough = {}
16300 if not isinstance(passthrough, dict):
16301 raise ValueError("monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
16302 flags.update(passthrough)
16303
16304 legacy_raw = {
16305 key: value
16306 for key, value in solver_mon_cfg.items()
16307 if isinstance(key, str) and key.startswith("-")
16308 }
16309 flags.update(legacy_raw)
16310
16311 unknown_top = sorted(
16312 key
16313 for key in solver_mon_cfg.keys()
16314 if key not in {"momentum", "poisson", "petsc_passthrough_options"} and not (isinstance(key, str) and key.startswith("-"))
16315 )
16316 if unknown_top:
16317 raise ValueError(
16318 "monitor.solver_monitoring has unsupported key(s): "
16319 f"{unknown_top}. Use 'momentum'/'poisson' for structured monitors or "
16320 "'petsc_passthrough_options' for raw PETSc flags."
16321 )
16322
16323 return flags
16324
16325
Here is the caller graph for this function:

◆ resolve_traced_particle_ids()

list picurv_cli.core.resolve_traced_particle_ids ( dict  monitor_cfg)

Return the sorted, distinct particle IDs of logging.trace_particles.

Parameters
[in]monitor_cfgParsed monitor configuration.
Returns
Sorted list of non-negative integer IDs; empty when tracing is off.
Exceptions
ValueErrorWhen the value is not a list of at most 64 non-negative integers.

Definition at line 16329 of file core.py.

16329def resolve_traced_particle_ids(monitor_cfg: dict) -> list:
16330 """!
16331 @brief Return the sorted, distinct particle IDs of `logging.trace_particles`.
16332 @param[in] monitor_cfg Parsed monitor configuration.
16333 @return Sorted list of non-negative integer IDs; empty when tracing is off.
16334 @throws ValueError When the value is not a list of at most 64 non-negative integers.
16335 """
16336 logging_cfg = monitor_cfg.get('logging') if isinstance(monitor_cfg, dict) else None
16337 if not isinstance(logging_cfg, dict) or logging_cfg.get('trace_particles') is None:
16338 return []
16339 raw = logging_cfg['trace_particles']
16340 if not isinstance(raw, list) or any(isinstance(pid, bool) or not isinstance(pid, int) or pid < 0 for pid in raw):
16341 raise ValueError("'logging.trace_particles' must be a list of non-negative integer particle IDs "
16342 f"(got {raw!r}).")
16343 traced = sorted(set(raw))
16344 if len(traced) > MAX_TRACED_PARTICLES:
16345 raise ValueError(f"'logging.trace_particles' accepts at most {MAX_TRACED_PARTICLES} particle IDs "
16346 f"(got {len(traced)}).")
16347 return traced
16348
16349
Here is the caller graph for this function:

◆ resolve_particle_console_output_frequency()

"int | None" picurv_cli.core.resolve_particle_console_output_frequency ( dict  io_cfg)

Return the effective particle-console snapshot cadence from monitor.yml.

Parameters
[in]io_cfgArgument passed to resolve_particle_console_output_frequency().
Returns
Value returned by resolve_particle_console_output_frequency().

Definition at line 16350 of file core.py.

16350def resolve_particle_console_output_frequency(io_cfg: dict) -> "int | None":
16351 """!
16352 @brief Return the effective particle-console snapshot cadence from monitor.yml.
16353 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
16354 @return Value returned by `resolve_particle_console_output_frequency()`.
16355 """
16356 if 'particle_console_output_frequency' in io_cfg:
16357 return io_cfg['particle_console_output_frequency']
16358 return io_cfg.get('data_output_frequency')
16359
Here is the caller graph for this function:

◆ parse_and_add_model_flags()

picurv_cli.core.parse_and_add_model_flags ( dict  case_cfg,
list  control_lines 
)

Parses the 'models' section of case.yml and adds corresponding C-solver flags.

Parameters
[in]case_cfgThe parsed case.yml configuration dictionary.
[out]control_linesA list of strings to which C-flags will be appended.

Definition at line 16360 of file core.py.

16360def parse_and_add_model_flags(case_cfg: dict, control_lines: list):
16361 """!
16362 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
16363 @param[in] case_cfg The parsed case.yml configuration dictionary.
16364 @param[out] control_lines A list of strings to which C-flags will be appended.
16365 """
16366 models = case_cfg.get('models', {})
16367 FLAG_MAP = {
16368 'domain': {'blocks': '-nblk'},
16369 'physics.fsi': {'immersed': '-imm', 'moving_fsi': '-fsi'},
16370 'physics.particles': {'count': '-numParticles', 'random_seed': '-particle_random_seed'},
16371 }
16372 for section_path, flags in FLAG_MAP.items():
16373 current_level = models
16374 try:
16375 for key in section_path.split('.'): current_level = current_level[key]
16376 for yaml_key, flag in flags.items():
16377 if yaml_key in current_level:
16378 control_lines.append(f"{flag} {format_flag_value(current_level[yaml_key])}")
16379 except KeyError: continue
16380
16381 wall_cfg = (((models.get('physics') or {}).get('turbulence') or {}).get('wall_function'))
16382 needs_scales = isinstance(wall_cfg, dict) and 'roughness_height' in wall_cfg
16383 append_turbulence_flags(models, control_lines,
16384 resolve_reference_scales(case_cfg) if needs_scales else None)
16385
16386 if models.get('physics', {}).get('dimensionality') == '2D':
16387 control_lines.append("-TwoD 1")
16388
16389 particles_cfg = models.get('physics', {}).get('particles', {})
16390 p_init_mode_str = particles_cfg.get('init_mode', 'Surface')
16391 pinit_code = normalize_particle_init_mode(p_init_mode_str)
16392 control_lines.append(f"-pinit {pinit_code}")
16393 print(f" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
16394
16395 if pinit_code == 2:
16396 point_cfg = particles_cfg.get('point_source', {})
16397 if not isinstance(point_cfg, dict):
16398 raise ValueError("models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
16399 scales = resolve_reference_scales(case_cfg)
16400 point_path = ("case", "models", "physics", "particles", "point_source")
16401 try:
16402 psrc = [to_solver_units(point_cfg[axis], point_path + (axis,), scales) for axis in ("x", "y", "z")]
16403 except (KeyError, TypeError, ValueError):
16404 raise ValueError("PointSource init_mode requires numeric point_source.{x,y,z} values.")
16405 for axis, value in zip(("x", "y", "z"), psrc):
16406 control_lines.append(f"-psrc_{axis} {value}")
16407 print(f" - Particle Point Source: ({point_cfg['x']}, {point_cfg['y']}, {point_cfg['z']}) "
16408 f"-> solver units ({psrc[0]}, {psrc[1]}, {psrc[2]})")
16409
16410 if particles_cfg.get('fields'):
16411 particle_fields = normalize_particle_fields(particles_cfg['fields'])
16412 control_lines.append(f"-particle_fields_count {len(particle_fields)}")
16413 for index, entry in enumerate(particle_fields):
16414 control_lines.append(f"-particle_fields_{index}_name {entry['name']}")
16415 for component, expression in enumerate(entry["expressions"]):
16416 text = control_value(expression, f"models.physics.particles.fields.{entry['name']}")
16417 control_lines.append(f"-particle_fields_{index}_expr_{component} \"{text}\"")
16418 print(f" - Particle Field Initial Value: {entry['name']} = {' ; '.join(entry['expressions'])}")
16419
16420 p_restart_mode = particles_cfg.get('restart_mode')
16421 if p_restart_mode:
16422 p_restart_mode_normalized = str(p_restart_mode).lower()
16423 if p_restart_mode_normalized not in PARTICLE_RESTART_MODES:
16424 raise ValueError(f"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
16425 control_lines.append(f"-particle_restart_mode \"{p_restart_mode}\"")
16426
16427# PETSc KSP types whose iteration adapts to the input vector. Any of these used as
16428# the multigrid coarse solve (level_0) makes the MG preconditioner a nonlinear
16429# operator; see docs/pages/25_Pressure_Poisson_GMRES_Multigrid.md.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ parse_solver_config()

dict picurv_cli.core.parse_solver_config ( dict  solver_cfg,
dict   scales = None,
dict   case_cfg = None 
)

Parses the structured solver.yml into a flat dictionary of {flag: value}.

Parameters
[in]solver_cfgThe parsed solver.yml configuration dictionary.
[in]scalesThe case's reference scales. Required only when solver.yml sets a dimensional value, which is physical and is converted here.
[in]case_cfgThe parsed case.yml, which holds the scalar-transport parameters.
Returns
A dictionary where keys are C-solver flags and values are the corresponding settings.

Definition at line 16436 of file core.py.

16436def parse_solver_config(solver_cfg: dict, scales: dict = None, case_cfg: dict = None) -> dict:
16437 """!
16438 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
16439 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
16440 @param[in] scales The case's reference scales. Required only when solver.yml sets a
16441 dimensional value, which is physical and is converted here.
16442 @param[in] case_cfg The parsed case.yml, which holds the scalar-transport parameters.
16443 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
16444 """
16445 flags = {}
16446
16447 def solver_value(path: tuple, value) -> float:
16448 """!
16449 @brief Convert one solver.yml value by its recorded dimension.
16450 @param[in] path Schema path under solver.yml.
16451 @param[in] value Value as written.
16452 @return The value in solver units.
16453 @throws RuntimeError when the value is dimensional and no scales were supplied; that
16454 is a caller error, kept distinct from the ValueError of an invalid value.
16455 """
16456 full_path = ("solver",) + path
16457 if input_quantity(full_path)[0] == DIMENSIONLESS:
16458 return _to_finite_float(value, ".".join(path))
16459 if scales is None:
16460 raise RuntimeError(f"{'.'.join(path)} is a physical value and needs the case reference scales.")
16461 return to_solver_units(value, full_path, scales)
16462 if 'operation_mode' in solver_cfg and isinstance(solver_cfg['operation_mode'], dict):
16463 op_mode = solver_cfg['operation_mode']
16464 if 'eulerian_field_source' in op_mode:
16465 normalized_source = normalize_eulerian_field_source(op_mode.get('eulerian_field_source'))
16466 flags['-euler_field_source'] = f"\"{normalized_source}\""
16467 if 'analytical_type' in op_mode and op_mode.get('analytical_type') is not None:
16468 normalized_analytical_type = normalize_analytical_type(op_mode.get('analytical_type'))
16469 flags['-analytical_type'] = f"\"{normalized_analytical_type}\""
16470 if normalized_analytical_type == "UNIFORM_FLOW":
16471 uniform_flow_cfg = op_mode.get('uniform_flow', {})
16472 if not isinstance(uniform_flow_cfg, dict):
16473 raise ValueError("operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
16474 try:
16475 for component in ("u", "v", "w"):
16476 flags[f'-analytical_uniform_{component}'] = solver_value(
16477 ("operation_mode", "uniform_flow", component), uniform_flow_cfg[component])
16478 except KeyError as exc:
16479 raise ValueError(f"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.") from exc
16480 except (TypeError, ValueError) as exc:
16481 raise ValueError("operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.") from exc
16482
16483 verification_cfg = solver_cfg.get('verification', {})
16484 if verification_cfg:
16485 if not isinstance(verification_cfg, dict):
16486 raise ValueError("verification must be a mapping when provided.")
16487 sources_cfg = verification_cfg.get('sources', {})
16488 if not isinstance(sources_cfg, dict):
16489 raise ValueError("verification.sources must be a mapping when provided.")
16490 diff_cfg = sources_cfg.get('diffusivity')
16491 if diff_cfg is not None:
16492 if not isinstance(diff_cfg, dict):
16493 raise ValueError("verification.sources.diffusivity must be a mapping.")
16494 try:
16495 flags['-verification_diffusivity_mode'] = f"\"{str(diff_cfg['mode']).strip().lower()}\""
16496 flags['-verification_diffusivity_profile'] = f"\"{str(diff_cfg['profile']).strip().upper()}\""
16497 for key in ("gamma0", "slope_x"):
16498 flags[f'-verification_diffusivity_{key}'] = solver_value(
16499 ("verification", "sources", "diffusivity", key), diff_cfg[key])
16500 except KeyError as exc:
16501 raise ValueError(f"verification.sources.diffusivity.{exc.args[0]} is required.") from exc
16502 except (TypeError, ValueError) as exc:
16503 raise ValueError("verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.") from exc
16504
16505 scalar_cfg = sources_cfg.get('scalar')
16506 if scalar_cfg is not None:
16507 if not isinstance(scalar_cfg, dict):
16508 raise ValueError("verification.sources.scalar must be a mapping.")
16509 try:
16510 flags['-verification_scalar_mode'] = f"\"{str(scalar_cfg['mode']).strip().lower()}\""
16511 flags['-verification_scalar_profile'] = f"\"{str(scalar_cfg['profile']).strip().upper()}\""
16512 except KeyError as exc:
16513 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
16514
16515 scalar_numeric_keys = {
16516 'CONSTANT': ('value',),
16517 'LINEAR_X': ('phi0', 'slope_x'),
16518 'SIN_PRODUCT': ('amplitude', 'kx', 'ky', 'kz'),
16519 }
16520 profile = str(scalar_cfg.get('profile', '')).strip().upper()
16521 for key in scalar_numeric_keys.get(profile, ()):
16522 try:
16523 flags[f'-verification_scalar_{key}'] = solver_value(
16524 ("verification", "sources", "scalar", key), scalar_cfg[key])
16525 except KeyError as exc:
16526 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
16527 except (TypeError, ValueError) as exc:
16528 raise ValueError(f"verification.sources.scalar.{key} must be numeric.") from exc
16529
16530 for key, value in resolve_scalar_transport(case_cfg, solver_cfg).items():
16531 flags[SCALAR_TRANSPORT_FLAGS[key]] = value
16532
16533 selected_solver = None
16534 if 'strategy' in solver_cfg:
16535 s = solver_cfg['strategy']
16536 if 'central_diff' in s:
16537 flags['-central'] = format_flag_value(s['central_diff'])
16538 # Preferred selector.
16539 if 'momentum_solver' in s:
16540 selected_solver = normalize_momentum_solver_type(s['momentum_solver'])
16541 elif 'implicit' in s:
16542 raise ValueError("Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
16543
16544 def _warn_inactive_absolute_tol(cfg: dict, where: str):
16545 """!
16546 @brief Warn when absolute_tol is set but cannot affect convergence.
16547
16548 absolute_tol bounds the velocity update. Since |dU| ~ dtau*|R|, bounding it
16549 absolutely is a disguised, step-size-dependent residual bound, so it takes no
16550 part once a residual tolerance is active -- which is now the default. Setting it
16551 therefore has no effect, and silently ineffective knobs are what this warning
16552 exists to prevent. See docs/pages/24_Dual_Time_Picard_Jameson_RK.md.
16553 @param[in] cfg Tolerance mapping to inspect for `absolute_tol` and the residual keys.
16554 @param[in] where Config path used to locate the offending key in the warning text.
16555 @return None; emits a warning on stderr when `absolute_tol` cannot take effect.
16556 """
16557 if 'absolute_tol' not in cfg:
16558 return
16559 # Residual convergence is enabled unless BOTH residual tolerances are explicitly
16560 # non-positive; their defaults (1e-8 / 1e-3) are positive.
16561 def _off(key):
16562 """!
16563 @brief Report whether a residual tolerance is explicitly disabled.
16564 @param[in] key Residual tolerance key to inspect.
16565 @return True when the key is present and non-positive.
16566 """
16567 v = cfg.get(key, None)
16568 try:
16569 return v is not None and float(v) <= 0.0
16570 except (TypeError, ValueError):
16571 return False
16572 if _off('residual_absolute_tol') and _off('residual_relative_tol'):
16573 return
16574 print(
16575 f"[WARNING] {where}.absolute_tol is set but takes no part in convergence while a "
16576 "residual tolerance is active (the default). It is retained only for the legacy "
16577 "update-only branch, which can converge falsely when dtau collapses. To control "
16578 "accuracy use residual_relative_tol / residual_absolute_tol. See "
16579 "docs/pages/24_Dual_Time_Picard_Jameson_RK.md.",
16580 file=sys.stderr,
16581 )
16582
16583 ms = solver_cfg.get('momentum_solver', {})
16584 if selected_solver is None:
16585 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
16586 flags['-mom_solver_type'] = f"\"{selected_solver}\""
16587
16588 if 'tolerances' in solver_cfg:
16589 t = solver_cfg['tolerances']
16590 tol_map = {
16591 'max_iterations': '-mom_max_pseudo_steps',
16592 'absolute_tol': '-mom_atol',
16593 'relative_tol': '-mom_rtol',
16594 'residual_absolute_tol': '-mom_resid_atol',
16595 'residual_relative_tol': '-mom_resid_rtol',
16596 'step_tol': '-imp_stol'
16597 }
16598 for key, flag in tol_map.items():
16599 if key in t:
16600 flags[flag] = t[key]
16601 _warn_inactive_absolute_tol(t, "tolerances")
16602
16603 def _append_dualtime_options(cfg: dict):
16604 """!
16605 @brief Append dualtime options.
16606 @param[in] cfg Argument passed to `_append_dualtime_options()`.
16607 """
16608 if 'max_pseudo_steps' in cfg:
16609 flags['-mom_max_pseudo_steps'] = cfg['max_pseudo_steps']
16610 if 'absolute_tol' in cfg:
16611 flags['-mom_atol'] = cfg['absolute_tol']
16612 _warn_inactive_absolute_tol(
16613 {**solver_cfg.get('tolerances', {}), **cfg},
16614 "momentum_solver.dual_time_picard_jameson_rk")
16615 if 'relative_tol' in cfg:
16616 flags['-mom_rtol'] = cfg['relative_tol']
16617 if 'step_tol' in cfg:
16618 flags['-imp_stol'] = cfg['step_tol']
16619 if 'pseudo_cfl' in cfg:
16620 pcfl = cfg['pseudo_cfl']
16621 if 'initial' in pcfl:
16622 flags['-pseudo_cfl'] = pcfl['initial']
16623 if 'minimum' in pcfl:
16624 flags['-min_pseudo_cfl'] = pcfl['minimum']
16625 if 'maximum' in pcfl:
16626 flags['-max_pseudo_cfl'] = pcfl['maximum']
16627 if 'growth_factor' in pcfl:
16628 flags['-pseudo_cfl_growth_factor'] = pcfl['growth_factor']
16629 if 'reduction_factor' in pcfl:
16630 flags['-pseudo_cfl_reduction_factor'] = pcfl['reduction_factor']
16631 if 'jameson_residual_noise_allowance_factor' in cfg:
16632 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['jameson_residual_noise_allowance_factor']
16633 elif 'rk4_residual_noise_allowance_factor' in cfg:
16634 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['rk4_residual_noise_allowance_factor']
16635 if 'ratio_ema_alpha' in cfg:
16636 flags['-mom_ratio_ema_alpha'] = cfg['ratio_ema_alpha']
16637
16638 def _append_newton_krylov_options(cfg: dict):
16639 """!
16640 @brief Append validated structured Newton--Krylov PETSc options.
16641 @param[in] cfg Structured Newton--Krylov mapping.
16642 """
16643 cfg = validate_newton_krylov_config(cfg)
16644 jacobian = cfg["jacobian"]
16645 flags["-mom_nk_jacobian_type"] = jacobian["type"]
16646 flags["-mom_nk_jacobian_fd_mode"] = jacobian["finite_difference"]["mode"]
16647 preconditioner = cfg["preconditioner"]
16648 flags["-mom_nk_preconditioner_model"] = preconditioner["model"]
16649 flags["-mom_nk_preconditioner_structure"] = preconditioner["structure"]["type"]
16650 nonlinear = cfg["nonlinear_solver"]
16651 nonlinear_map = {
16652 "method": "-mom_nk_snes_type",
16653 "absolute_tolerance": "-mom_nk_snes_atol",
16654 "relative_tolerance": "-mom_nk_snes_rtol",
16655 "step_tolerance": "-mom_nk_snes_stol",
16656 "max_iterations": "-mom_nk_snes_max_it",
16657 }
16658 for key, flag in nonlinear_map.items():
16659 if key in nonlinear:
16660 flags[flag] = nonlinear[key]
16661 line_search = nonlinear.get("line_search", {})
16662 if "type" in line_search:
16663 flags["-mom_nk_snes_linesearch_type"] = line_search["type"]
16664 ew = nonlinear.get("eisenstat_walker")
16665 if ew:
16666 flags["-mom_nk_snes_ksp_ew"] = ew["enabled"]
16667 if ew["enabled"]:
16668 ew_map = {
16669 "version": "-mom_nk_snes_ksp_ew_version",
16670 "initial_relative_tolerance": "-mom_nk_snes_ksp_ew_rtol0",
16671 "maximum_relative_tolerance": "-mom_nk_snes_ksp_ew_rtolmax",
16672 "gamma": "-mom_nk_snes_ksp_ew_gamma",
16673 "exponent": "-mom_nk_snes_ksp_ew_alpha",
16674 "safeguard_exponent": "-mom_nk_snes_ksp_ew_alpha2",
16675 "safeguard_threshold": "-mom_nk_snes_ksp_ew_threshold",
16676 }
16677 for key, flag in ew_map.items():
16678 if key in ew:
16679 flags[flag] = ew[key]
16680
16681 linear = cfg["linear_solver"]
16682 linear_map = {
16683 "method": "-mom_nk_ksp_type",
16684 "absolute_tolerance": "-mom_nk_ksp_atol",
16685 "relative_tolerance": "-mom_nk_ksp_rtol",
16686 "max_iterations": "-mom_nk_ksp_max_it",
16687 }
16688 for key, flag in linear_map.items():
16689 if key in linear:
16690 flags[flag] = linear[key]
16691 gmres = linear.get("gmres", {})
16692 if "restart" in gmres:
16693 flags["-mom_nk_ksp_gmres_restart"] = gmres["restart"]
16694
16695 if isinstance(ms, dict):
16696 allowed_ms_keys = {'type', 'dual_time_picard_jameson_rk', 'dual_time_picard_rk4', 'newton_krylov'}
16697 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
16698 if unknown_ms_keys:
16699 raise ValueError(
16700 f"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
16701 "Currently supported blocks: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
16702 )
16703
16704 if 'dual_time_picard_jameson_rk' in ms and 'dual_time_picard_rk4' in ms:
16705 raise ValueError(
16706 "Use only momentum_solver.dual_time_picard_jameson_rk; "
16707 "do not also set its deprecated dual_time_picard_rk4 alias."
16708 )
16709 dt_picard_cfg = ms.get('dual_time_picard_jameson_rk', ms.get('dual_time_picard_rk4'))
16710 if dt_picard_cfg is not None:
16711 if selected_solver != "DUALTIME_PICARD_JAMESON_RK":
16712 raise ValueError(
16713 f"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
16714 )
16715 if not isinstance(dt_picard_cfg, dict):
16716 raise ValueError("momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
16717 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
16718 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
16719 raise ValueError(
16720 "Use only jameson_residual_noise_allowance_factor; "
16721 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
16722 )
16723 _append_dualtime_options(dt_picard_cfg)
16724 newton_cfg = ms.get('newton_krylov')
16725 if newton_cfg is not None:
16726 if selected_solver != "newton_krylov":
16727 raise ValueError(
16728 f"momentum_solver.newton_krylov is set but selected solver is {selected_solver}."
16729 )
16730 _append_newton_krylov_options(newton_cfg)
16731 def _normalize_poisson_method(value) -> str:
16732 """!
16733 @brief Normalize a user-facing Poisson linear-solver method name.
16734 @param[in] value Method value from the solver YAML.
16735 @return Lowercase PETSc KSP method token.
16736 """
16737 method = str(value).strip().lower()
16738 if not method:
16739 raise ValueError("poisson_solver.method cannot be empty.")
16740 if method in POISSON_KSP_REFUSED:
16741 raise ValueError(
16742 f"poisson_solver.method '{method}' {POISSON_KSP_REFUSED[method]}: PICurv's "
16743 "multigrid preconditioner is not a single fixed linear operator, and with it "
16744 f"'{method}' reports convergence while the true residual stalls, so the "
16745 "projection uses the wrong pressure. Use 'fgmres' (default) or 'cg'."
16746 )
16747 if method not in POISSON_KSP_METHODS:
16748 raise ValueError(
16749 f"poisson_solver.method '{method}' is not one of {sorted(POISSON_KSP_METHODS)}, "
16750 "the methods verified with the multigrid preconditioner. Another PETSc KSP type "
16751 "can still be set through petsc_passthrough_options (-ps_ksp_type), unverified."
16752 )
16753 return method
16754
16755 def _normalize_poisson_preconditioner(value) -> str:
16756 """!
16757 @brief Normalize and validate the outer Poisson preconditioner name.
16758 @param[in] value Preconditioner value from the solver YAML.
16759 @return PETSc PC token for the supported outer preconditioner.
16760 """
16761 pc = str(value).strip().lower()
16762 pc = POISSON_PRECONDITIONER_SPELLINGS.get(pc, pc)
16763 if pc not in POISSON_PRECONDITIONER_TYPES:
16764 raise ValueError(
16765 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
16766 "The runtime Poisson solver still assumes PETSc PCMG setup."
16767 )
16768 return "mg"
16769
16770 def _warn_if_krylov_coarse_solver(ksp_type, source_key: str):
16771 """!
16772 @brief Warn when the coarsest multigrid level is given a Krylov solver.
16773 @param[in] ksp_type PETSc KSP token configured for `level_0`.
16774 @param[in] source_key Name of the source YAML block, used in the message.
16775 @details level_0 is the coarse solve at the base of the V-cycle, not a
16776 smoother. A Krylov method there makes the multigrid
16777 preconditioner a nonlinear operator, which decouples the outer
16778 KSP's tracked residual from the true residual b-Ax. This stays a
16779 warning rather than an error because it remains legitimate at
16780 large scale when tolerances are set against the true residual.
16781 """
16782 if str(ksp_type).strip().lower() not in KRYLOV_KSP_TYPES:
16783 return
16784 print(
16785 f"[WARNING] {source_key}.multigrid.level_solvers.level_0.method = '{ksp_type}' "
16786 "is a Krylov method. level_0 is the multigrid COARSE SOLVE, not a smoother, "
16787 "so a Krylov method there makes the preconditioner nonlinear and the outer "
16788 "KSP's tracked residual can stop matching the true residual b-Ax. "
16789 "Prefer {method: preonly, preconditioner: redundant}. If this is deliberate, "
16790 "enable solver_monitoring.poisson.pic_true_residual and set tolerances against "
16791 "the true residual. See docs/pages/25_Pressure_Poisson_GMRES_Multigrid.md.",
16792 file=sys.stderr,
16793 )
16794
16795 def _poisson_level_number(level_name) -> int:
16796 """!
16797 @brief Extract the numeric suffix from a `level_N` multigrid level key.
16798 @param[in] level_name YAML level key supplied by the user.
16799 @return Numeric level suffix.
16800 """
16801 text = str(level_name).strip()
16802 match = re.fullmatch(r"level_(\d+)", text)
16803 if not match:
16804 raise ValueError(f"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
16805 return int(match.group(1))
16806
16807 def _append_poisson_solver_flags(ps: dict, source_key: str):
16808 """!
16809 @brief Append structured Poisson solver options to the flat PETSc flag map.
16810 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
16811 @param[in] source_key Name of the source YAML block, used in error messages.
16812 """
16813 if not isinstance(ps, dict):
16814 raise ValueError(f"{source_key} must be a mapping when provided.")
16815
16816 method = None
16817 if 'method' in ps:
16818 method = _normalize_poisson_method(ps['method'])
16819 flags['-ps_ksp_type'] = method
16820 flags.update(POISSON_KSP_METHODS[method])
16821 if 'absolute_tolerance' in ps:
16822 flags['-ps_ksp_atol'] = ps['absolute_tolerance']
16823 if 'relative_tolerance' in ps:
16824 flags['-ps_ksp_rtol'] = ps['relative_tolerance']
16825 if 'max_iterations' in ps:
16826 flags['-ps_ksp_max_it'] = ps['max_iterations']
16827 if 'tolerance' in ps:
16828 raise ValueError(
16829 f"{source_key}.tolerance is not a solver control: the runtime read it and never "
16830 "used it. Use absolute_tolerance (-> -ps_ksp_atol) and relative_tolerance "
16831 "(-> -ps_ksp_rtol), which the Poisson KSP applies."
16832 )
16833
16834 gmres_cfg = ps.get('gmres', {})
16835 if gmres_cfg is not None:
16836 if not isinstance(gmres_cfg, dict):
16837 raise ValueError(f"{source_key}.gmres must be a mapping when provided.")
16838 if 'restart' in gmres_cfg:
16839 if method is None:
16840 method = _normalize_poisson_method(ps.get('method', 'fgmres'))
16841 flags.setdefault('-ps_ksp_type', method)
16842 if method not in GMRES_RESTART_METHODS:
16843 raise ValueError(
16844 f"{source_key}.gmres.restart is valid only when {source_key}.method "
16845 "is one of 'gmres', 'fgmres', or 'lgmres'."
16846 )
16847 flags['-ps_ksp_gmres_restart'] = gmres_cfg['restart']
16848
16849 preconditioner_cfg = ps.get('preconditioner', {})
16850 if preconditioner_cfg:
16851 if not isinstance(preconditioner_cfg, dict):
16852 raise ValueError(f"{source_key}.preconditioner must be a mapping when provided.")
16853 if 'type' in preconditioner_cfg:
16854 flags['-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg['type'])
16855
16856 if 'multigrid' in ps:
16857 mg = ps['multigrid']
16858 if not isinstance(mg, dict):
16859 raise ValueError(f"{source_key}.multigrid must be a mapping when provided.")
16860 mg_map = {'levels': '-mg_level', 'pre_sweeps': '-mg_pre_it', 'post_sweeps': '-mg_post_it'}
16861 for key, flag in mg_map.items():
16862 if key in mg: flags[flag] = mg[key]
16863 if 'cycle' in mg:
16864 cycle = str(mg['cycle']).strip().lower()
16865 if cycle not in {"v"}:
16866 raise ValueError(f"{source_key}.multigrid.cycle currently supports only 'v'.")
16867 if 'mode' in mg:
16868 mode = str(mg['mode']).strip().lower()
16869 if mode not in {"multiplicative"}:
16870 raise ValueError(f"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
16871 if 'semi_coarsening' in mg:
16872 sc = mg['semi_coarsening']
16873 if not isinstance(sc, dict):
16874 raise ValueError(f"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
16875 if 'i' in sc: flags['-mg_i_semi'] = format_flag_value(sc['i'])
16876 if 'j' in sc: flags['-mg_j_semi'] = format_flag_value(sc['j'])
16877 if 'k' in sc: flags['-mg_k_semi'] = format_flag_value(sc['k'])
16878 if 'level_solvers' in mg:
16879 level_solvers = mg['level_solvers']
16880 if not isinstance(level_solvers, dict):
16881 raise ValueError(f"{source_key}.multigrid.level_solvers must be a mapping when provided.")
16882 for level_name, settings in level_solvers.items():
16883 if not isinstance(settings, dict):
16884 raise ValueError(f"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
16885 level_num = _poisson_level_number(level_name)
16886 for key, value in settings.items():
16887 # PETSc prefixes every per-level KSP control with ksp_; emitted
16888 # bare, max_it/rtol/atol were left unused and silently ignored.
16889 mapped_key = {
16890 'method': 'ksp_type', 'preconditioner': 'pc_type',
16891 'max_it': 'ksp_max_it', 'rtol': 'ksp_rtol', 'atol': 'ksp_atol',
16892 }.get(key, key)
16893 # PETSc names the coarsest solver separately from positive levels.
16894 if level_num == 0:
16895 prefix = "-ps_mg_coarse_"
16896 if mapped_key == 'ksp_type':
16897 _warn_if_krylov_coarse_solver(value, source_key)
16898 else:
16899 prefix = f"-ps_mg_levels_{level_num}_"
16900 flags[f"{prefix}{mapped_key}"] = format_flag_value(value)
16901
16902 if 'poisson_solver' in solver_cfg and 'pressure_solver' in solver_cfg:
16903 if solver_cfg['poisson_solver'] != solver_cfg['pressure_solver']:
16904 raise ValueError(
16905 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
16906 "Use 'poisson_solver' only, or make the legacy alias identical."
16907 )
16908 poisson_cfg = solver_cfg.get('poisson_solver', solver_cfg.get('pressure_solver'))
16909 if poisson_cfg is not None:
16910 source_key = 'poisson_solver' if 'poisson_solver' in solver_cfg else 'pressure_solver'
16911 _append_poisson_solver_flags(poisson_cfg, source_key)
16912 interp_cfg = solver_cfg.get('interpolation', {})
16913 if isinstance(interp_cfg, dict):
16914 interp_method_str = interp_cfg.get('method', 'Trilinear')
16915 else:
16916 interp_method_str = 'Trilinear'
16917 interp_code = normalize_interpolation_method(interp_method_str)
16918 flags['-interpolation_method'] = interp_code
16919 print(f" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
16920
16921 if 'petsc_passthrough_options' in solver_cfg:
16922 passthrough = solver_cfg['petsc_passthrough_options']
16923 if passthrough is None:
16924 passthrough = {}
16925 if not isinstance(passthrough, dict):
16926 raise ValueError("petsc_passthrough_options must be a mapping when provided.")
16927 if passthrough:
16928 for key, value in passthrough.items():
16929 if str(key).strip() == "-ps_mg_coarse_ksp_type":
16930 _warn_if_krylov_coarse_solver(value, "petsc_passthrough_options")
16931 flags[key] = value if isinstance(value, bool) else format_flag_value(value)
16932 summary_bits = []
16933 if '-ps_ksp_type' in flags:
16934 summary_bits.append(f"method={flags['-ps_ksp_type']}")
16935 if '-ps_ksp_atol' in flags:
16936 summary_bits.append(f"atol={flags['-ps_ksp_atol']}")
16937 if '-ps_ksp_rtol' in flags:
16938 summary_bits.append(f"rtol={flags['-ps_ksp_rtol']}")
16939 if '-ps_ksp_max_it' in flags:
16940 summary_bits.append(f"max_it={flags['-ps_ksp_max_it']}")
16941 if '-mg_level' in flags:
16942 summary_bits.append(f"mg_levels={flags['-mg_level']}")
16943 if summary_bits:
16944 print(f" - Poisson Solver: {', '.join(summary_bits)}")
16945 if selected_solver == "DUALTIME_PICARD_JAMESON_RK":
16946 dualtime_bits = []
16947 for label, flag in (
16948 ("initial_pseudo_cfl", "-pseudo_cfl"),
16949 ("pseudo_cfl_range", None),
16950 ("max_pseudo_steps", "-mom_max_pseudo_steps"),
16951 ):
16952 if flag and flag in flags:
16953 dualtime_bits.append(f"{label}={flags[flag]}")
16954 elif label == "pseudo_cfl_range" and "-min_pseudo_cfl" in flags and "-max_pseudo_cfl" in flags:
16955 dualtime_bits.append(f"pseudo_cfl_range=[{flags['-min_pseudo_cfl']}, {flags['-max_pseudo_cfl']}]")
16956 print(" - Momentum Solver: Dual Time Picard Jameson RK" +
16957 (f" ({', '.join(dualtime_bits)})" if dualtime_bits else ""))
16958 elif selected_solver == "newton_krylov":
16959 newton_bits = []
16960 for label, flag in (
16961 ("jacobian", "-mom_nk_jacobian_type"),
16962 ("nonlinear", "-mom_nk_snes_type"),
16963 ("linear", "-mom_nk_ksp_type"),
16964 ("preconditioner", "-mom_nk_preconditioner_model"),
16965 ):
16966 if flag in flags:
16967 newton_bits.append(f"{label}={flags[flag]}")
16968 print(" - Momentum Solver: Newton Krylov" +
16969 (f" ({', '.join(newton_bits)})" if newton_bits else " (PETSc defaults)"))
16970 else:
16971 print(" - Momentum Solver: Explicit RK (no pseudo-time controller)")
16972 return flags
16973
Here is the call graph for this function:
Here is the caller graph for this function:

◆ generate_solver_control_file()

picurv_cli.core.generate_solver_control_file (   run_dir,
  run_id,
  configs,
  num_procs,
  monitor_files,
  restart_source_dir = None,
  continue_mode = False,
str   config_dir = None 
)

Generates the main .control file for the C-solver.

Orchestrates the conversion of all YAML configurations (case, solver, monitor) into a single, machine-readable file of command-line flags.

Parameters
[in]run_dirArgument passed to generate_solver_control_file().
[in]run_idArgument passed to generate_solver_control_file().
[in]configsArgument passed to generate_solver_control_file().
[in]num_procsArgument passed to generate_solver_control_file().
[in]monitor_filesArgument passed to generate_solver_control_file().
[in]restart_source_dirArgument passed to generate_solver_control_file().
[in]continue_modeIf True, appends -continue_mode flag for the C solver.
[in]config_dirOptional configuration revision directory.
Returns
Value returned by generate_solver_control_file().

Definition at line 16974 of file core.py.

16976 config_dir: str = None):
16977 """!
16978 @brief Generates the main .control file for the C-solver.
16979 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
16980 into a single, machine-readable file of command-line flags.
16981 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
16982 @param[in] run_id Argument passed to `generate_solver_control_file()`.
16983 @param[in] configs Argument passed to `generate_solver_control_file()`.
16984 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
16985 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
16986 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
16987 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
16988 @param[in] config_dir Optional configuration revision directory.
16989 @return Value returned by `generate_solver_control_file()`.
16990 """
16991 print("[INFO] Generating master solver control file...")
16992 case_cfg, solver_cfg, monitor_cfg = configs['case'], configs['solver'], configs['monitor']
16993 source_files = {'Case': configs['case_path'], 'Solver': configs['solver_path'], 'Monitor': configs['monitor_path']}
16994
16995 control_lines = []
16996 try:
16997 props, run_ctrl = case_cfg['properties'], case_cfg['run_control']
16998 scales, fluid, ic = props['scaling'], props['fluid'], props['initial_conditions']
16999 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
17000 fluid_scaling = resolve_fluid_scaling(case_cfg)
17001 L_ref = fluid_scaling["length_ref"]
17002 U_ref = fluid_scaling["velocity_ref"]
17003 rho = fluid_scaling["density"]
17004 reynolds = fluid_scaling["reynolds"]
17005 dt_nondim = to_solver_units(run_ctrl['dt_physical'], ("case", "run_control", "dt_physical"), fluid_scaling)
17006 grid_length_scale = input_reference_scale(("case", "grid", "source_file"), fluid_scaling)
17007 print(f" - Reynolds Number (Re) = {reynolds:.4f}")
17008 print(f" - Non-Dimensional dt* = {dt_nondim:.6f}")
17009 eulerian_source = normalize_eulerian_field_source(
17010 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
17011 )
17012 start_step = int(run_ctrl.get("start_step", 0) or 0)
17013 ic_is_authoritative = eulerian_source == "solve" and start_step == 0
17014 ic_cli = []
17015 if ic_is_authoritative:
17016 resolved_ic = resolve_initial_condition_config(
17017 ic, prepared_blocks, fluid_scaling,
17018 provider_context={"kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]},
17019 )
17020 finit_mode_str = resolved_ic["label"]
17021 finit_code = resolved_ic["finit"]
17022 ic_params = resolved_ic["cli_params"]
17023 print(f" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
17024 if "ucont_x" in ic_params:
17025 ic_cli.extend([
17026 f"-ucont_x {ic_params['ucont_x']}",
17027 f"-ucont_y {ic_params['ucont_y']}",
17028 f"-ucont_z {ic_params['ucont_z']}",
17029 ])
17030 if "ic_velocity_physical" in ic_params:
17031 ic_cli.append(f"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
17032 if "flow_direction" in ic_params:
17033 ic_cli.append(f"-flow_direction {ic_params['flow_direction']}")
17034 else:
17035 source = (f"the run starts from the checkpoint at step {start_step}" if start_step > 0
17036 else f"eulerian_field_source={eulerian_source!r} reads stored fields")
17037 print(f"[INFO] Initial condition not applied: {source}.")
17038 finit_code = 0
17039 resolved_ic = None
17040 control_lines.extend([
17041 f"-start_step {run_ctrl['start_step']}", f"-totalsteps {run_ctrl['total_steps']}",
17042 f"-ren {reynolds}", f"-dt {dt_nondim}", f"-finit {finit_code}",
17043 *ic_cli,
17044 f"-scaling_L_ref {L_ref}", f"-scaling_U_ref {U_ref}", f"-scaling_rho_ref {rho}"
17045 ])
17046 except (KeyError, TypeError, ZeroDivisionError, ValueError) as e:
17047 print(f"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
17048 sys.exit(1)
17049
17050 # --- CORRECTED: Add paths for whitelist and profile files ---
17051 if monitor_files.get("whitelist"):
17052 control_lines.append(f"-whitelist_config_file {monitor_files['whitelist']}")
17053 if monitor_files.get("profile"):
17054 control_lines.append(f"-profile_config_file {monitor_files['profile']}")
17055 append_passthrough_flags(control_lines, resolve_solution_monitoring_flags(monitor_cfg))
17056 control_lines.extend(resolve_field_statistics_flags(monitor_cfg, case_cfg))
17057 profiling_cfg = monitor_files.get("profiling", {})
17058 control_lines.append(f"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
17059 control_lines.append(f"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
17060 if profiling_cfg.get("helpers"):
17061 control_lines.append(f"-profiling_helpers {','.join(profiling_cfg['helpers'])}")
17062 diagnostics_cfg = resolve_diagnostics_config(monitor_cfg)
17063 memory_log_cfg = diagnostics_cfg["runtime_memory_log"]
17064 control_lines.append(f"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
17065
17066 walltime_guard_policy = configs.get("walltime_guard_policy")
17067 if walltime_guard_policy is not None:
17068 control_lines.extend(
17069 [
17070 f"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
17071 f"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
17072 f"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
17073 f"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
17074 f"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
17075 ]
17076 )
17077
17078 grid_cfg = case_cfg.get('grid', {})
17079 grid_mode = grid_cfg.get('mode')
17080 expected_nblk = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
17081
17082 if grid_mode == 'file':
17083 print("[INFO] Grid Mode: Using external file...")
17084 case_file_dir = os.path.dirname(configs['case_path'])
17085 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
17086 if os.path.isfile(nondim_grid_path):
17087 print(f"[INFO] Reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
17088 control_lines.append(f"-grid_file {nondim_grid_path}")
17089 else:
17090 source_grid = _resolve_case_relative_path(grid_cfg['source_file'], case_file_dir)
17091 grid_for_validation = source_grid
17092 try:
17093 summary = validate_and_nondimensionalize_picgrid(
17094 grid_for_validation, nondim_grid_path, grid_length_scale, expected_nblk=expected_nblk
17095 )
17096 print(
17097 f"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
17098 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
17099 )
17100 control_lines.append(f"-grid_file {nondim_grid_path}")
17101 except Exception as e:
17102 print(f"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
17103 sys.exit(1)
17104 elif grid_mode == 'grid_gen':
17105 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
17106 if os.path.isfile(nondim_grid_path):
17107 print(f"[INFO] Grid Mode: grid.gen, reusing locked grid asset: {os.path.relpath(nondim_grid_path)}")
17108 control_lines.append(f"-grid_file {nondim_grid_path}")
17109 else:
17110 print("[INFO] Grid Mode: Generating external grid via grid.gen...")
17111 try:
17112 # grid.gen already converts ncells_* inputs into node-count PICGRID dims.
17113 generated_grid = run_grid_generator(
17114 configs['case_path'], run_dir, grid_cfg, case_cfg=case_cfg)
17115 summary = validate_and_nondimensionalize_picgrid(
17116 generated_grid, nondim_grid_path, grid_length_scale, expected_nblk=expected_nblk
17117 )
17118 print(
17119 f"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
17120 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
17121 )
17122 control_lines.append(f"-grid_file {nondim_grid_path}")
17123 except Exception as e:
17124 print(f"[FATAL] Grid generation failed: {e}", file=sys.stderr)
17125 sys.exit(1)
17126 elif grid_mode == 'programmatic_c':
17127 print("[INFO] Grid Mode: Programmatic C...")
17128 grid_settings = translate_programmatic_grid_settings(grid_cfg.get('programmatic_settings', {}))
17129 control_lines.append("-grid")
17130 for p_key in GRID_DA_PROCESSOR_KEYS:
17131 grid_settings.pop(p_key, None)
17132 for key, value in grid_settings.items(): control_lines.append(f"-{key} {format_flag_value(value)}")
17133 if resolved_ic and is_generated_ic_provider(resolved_ic) and ic_is_authoritative:
17134 # The simulator builds this grid itself and never reads a file for it; this
17135 # bridge exists only so the Python IC generator (a separate process) can see
17136 # the same node coordinates. generate_picgrid_from_programmatic_settings uses
17137 # the identical formula as ComputeStretchedCoord in src/grid.c, so the two are
17138 # guaranteed to agree. Nothing above is changed: -grid_file is never emitted,
17139 # so the solver still builds its own grid exactly as before.
17140 nondim_grid_path = os.path.join(run_dir, "inputs", "grid", "grid.run")
17141 if not os.path.isfile(nondim_grid_path):
17142 try:
17143 generate_picgrid_from_programmatic_settings(
17144 grid_cfg.get('programmatic_settings', {}), nondim_grid_path,
17145 input_reference_scale(("case", "grid", "programmatic_settings", "xMins"), fluid_scaling),
17146 )
17147 print(
17148 "[SUCCESS] Materialized a bridge grid for the Python initial-condition "
17149 f"provider: {os.path.relpath(nondim_grid_path)}"
17150 )
17151 except Exception as e:
17152 print(f"[FATAL] Failed to materialize bridge grid for initial-condition generator: {e}",
17153 file=sys.stderr)
17154 sys.exit(1)
17155 else:
17156 raise ValueError(f"Unknown or missing grid mode '{grid_mode}' in case.yml.")
17157
17158 if resolved_ic and (resolved_ic["kind"] == "file" or is_generated_ic_provider(resolved_ic)):
17159 try:
17160 staged_ic = stage_initial_condition_file(run_dir, configs["case_path"], resolved_ic)
17161 except Exception as e:
17162 print(f"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
17163 sys.exit(1)
17164 control_lines.extend([
17165 f"-ic_field {resolved_ic['field_code']}",
17166 f"-ic_dir {staged_ic['directory']}",
17167 ])
17168 print(f" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
17169
17170 try:
17171 bcs_files = generate_multi_block_bcs(
17172 run_dir, run_id, case_cfg, source_files, config_dir=config_dir
17173 )
17174 except ValueError as e:
17175 print(f"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
17176 sys.exit(1)
17177 control_lines.append(f"-bcs_files \"{','.join(bcs_files)}\"")
17178
17179 configs["decomposition_resolved"] = append_grid_da_processor_layout(
17180 control_lines, grid_cfg, num_procs,
17181 block_dims=resolve_grid_node_dims(
17182 case_cfg, configs["case_path"], os.path.join(run_dir, "inputs", "grid", "grid.run")),
17183 solver_cfg=solver_cfg, any_periodic=case_declares_periodic_axis(case_cfg),
17184 explicit=configs.get("decomposition"), cluster_cfg=configs.get("cluster_cfg"))
17185
17186 parse_and_add_model_flags(case_cfg, control_lines)
17187 append_les_diagnostics_flags(resolve_les_diagnostics(case_cfg, configs.get('monitor') or {}), control_lines)
17188
17189 if 'solver_parameters' in case_cfg:
17190 params = case_cfg['solver_parameters']
17191 if params:
17192 for key, value in params.items():
17193 control_lines.append(f"{key} {format_flag_value(value)}")
17194
17195 try:
17196 solver_flags = parse_solver_config(solver_cfg, resolve_reference_scales(case_cfg), case_cfg)
17197 except ValueError as e:
17198 print(f"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
17199 sys.exit(1)
17200 append_passthrough_flags(control_lines, solver_flags)
17201
17202 try:
17203 solver_monitoring_flags = resolve_solver_monitoring_flags(monitor_cfg)
17204 except ValueError as e:
17205 print(f"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
17206 sys.exit(1)
17207 # Monitors for a solver that does not run would hand PETSc options nothing reads: the
17208 # momentum ones are Newton-Krylov's, and neither momentum nor Poisson solves when the
17209 # Eulerian field is loaded or analytical.
17210 solves_flow = solver_flags.get('-euler_field_source', '"solve"') == '"solve"'
17211 if not solves_flow:
17212 inactive = [flag for flag in (*SOLVER_MONITORING_MOMENTUM_FLAG_MAP.values(),
17213 *SOLVER_MONITORING_POISSON_FLAG_MAP.values())
17214 if solver_monitoring_flags.pop(flag, None) is not None]
17215 if inactive:
17216 print("[INFO] monitor.yml solver_monitoring applies to solved flow only; not passed "
17217 "with a loaded or analytical Eulerian field.")
17218 elif solver_flags.get('-mom_solver_type') != '"newton_krylov"':
17219 inactive = [flag for flag in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.values()
17220 if solver_monitoring_flags.pop(flag, None) is not None]
17221 if inactive:
17222 print("[INFO] monitor.yml solver_monitoring.momentum applies to Newton-Krylov only; "
17223 "not passed to this momentum solver.")
17224 append_passthrough_flags(control_lines, solver_monitoring_flags)
17225
17226 io_cfg = monitor_cfg.get('io', {})
17227 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
17228 if 'data_output_frequency' in io_cfg: control_lines.append(f"-tio {io_cfg['data_output_frequency']}")
17229 # The run's first and last states are checkpointed unless turned off; cadence and
17230 # signal checkpoints do not depend on these.
17231 for key in ('write_initial_checkpoint', 'write_final_checkpoint'):
17232 if key in io_cfg:
17233 control_lines.append(f"-{key} {str(bool(io_cfg[key])).lower()}")
17234 if particle_console_output_freq is not None:
17235 control_lines.append(f"-particle_console_output_freq {particle_console_output_freq}")
17236 statistics_console_output_freq = resolve_statistics_console_output_frequency(io_cfg)
17237 if statistics_console_output_freq is not None:
17238 control_lines.append(f"-statistics_console_output_freq {statistics_console_output_freq}")
17239 if 'particle_log_interval' in io_cfg: control_lines.append(f"-logfreq {io_cfg['particle_log_interval']}")
17240 traced = resolve_traced_particle_ids(monitor_cfg)
17241 if traced:
17242 control_lines.append(f"-picurv_trace_particles {','.join(str(pid) for pid in traced)}")
17243 if restart_source_dir:
17244 expected_restart = os.path.abspath(
17245 os.path.join(run_dir, CANONICAL_RUN_PATHS["restart"])
17246 )
17247 if os.path.abspath(restart_source_dir) != expected_restart:
17248 raise ValueError(
17249 "Restart data was not materialized in the canonical run input: "
17250 f"expected {expected_restart}, got {restart_source_dir}."
17251 )
17252 if continue_mode:
17253 control_lines.append("-continue_mode true")
17254 elif str(configs.get("statistics_state", "reset")).lower() == "carry":
17255 control_lines.append("-field_statistics_continue true")
17256
17257 # Emitted last, and always: PETSc takes the final occurrence of an option, so a
17258 # validated directory written here wins over anything earlier in the file. Writing
17259 # them unconditionally also removes the "omitted default" gap, where an absent flag
17260 # let an environment variable or PETSc configuration choose the directory instead.
17261 control_lines.append("")
17262 control_lines.append("# Canonical run-owned directories are fixed by the workspace contract.")
17263 control_lines.append(f"-output_dir {CANONICAL_RUN_PATHS['output']}")
17264 control_lines.append(f"-restart_dir {CANONICAL_RUN_PATHS['restart']}")
17265 control_lines.append(f"-log_dir {CANONICAL_RUN_PATHS['logs']}")
17266 control_lines.append(f"-analysis_dir {CANONICAL_RUN_PATHS['metrics']}")
17267
17268 final_content = generate_header(run_id, source_files) + "\n".join(control_lines)
17269 config_dir = config_dir or os.path.join(run_dir, "config")
17270 os.makedirs(config_dir, exist_ok=True)
17271 control_file_path = os.path.join(config_dir, f"{run_id}.control")
17272 with open(control_file_path, "w") as f: f.write(final_content)
17273 print(f"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
17274 return os.path.abspath(control_file_path)
17275
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_post_decomposition()

dict picurv_cli.core.resolve_post_decomposition ( str  run_dir,
int  num_procs,
  explicit = None,
"dict | None"   cluster_cfg = None 
)

Resolve the postprocessor's MPI decomposition from the run's staged configuration.

The postprocessor builds the same grid hierarchy as the solve, from the run's active case and solver files and its staged grid, so its decomposition is resolved like the solve's, for its own rank count.

Parameters
[in]run_dirRun directory.
[in]num_procsMPI ranks of the post-processing stage.
[in]explicit(ni, nj, nk) from the command line, or None.
[in]cluster_cfgParsed cluster.yml, or None.
Returns
The resolved decomposition (resolve_decomposition()).

Definition at line 17276 of file core.py.

17277 cluster_cfg: "dict | None" = None) -> dict:
17278 """!
17279 @brief Resolve the postprocessor's MPI decomposition from the run's staged configuration.
17280 @details The postprocessor builds the same grid hierarchy as the solve, from the run's
17281 active case and solver files and its staged grid, so its decomposition is
17282 resolved like the solve's, for its own rank count.
17283 @param[in] run_dir Run directory.
17284 @param[in] num_procs MPI ranks of the post-processing stage.
17285 @param[in] explicit (ni, nj, nk) from the command line, or None.
17286 @param[in] cluster_cfg Parsed cluster.yml, or None.
17287 @return The resolved decomposition (resolve_decomposition()).
17288 """
17289 active = load_active_run_configuration(run_dir)
17290 case_cfg = read_yaml_file(active["case"]) if active.get("case") and os.path.isfile(active["case"]) else {}
17291 solver_cfg = read_yaml_file(active["solver"]) if active.get("solver") and os.path.isfile(active["solver"]) else {}
17292 block_dims = resolve_grid_node_dims(case_cfg, active.get("case") or os.path.join(run_dir, "config", "case.yml"),
17293 os.path.join(run_dir, "inputs", "grid", "grid.run"))
17294 return resolve_decomposition(num_procs, case_cfg.get("grid") or {}, block_dims, solver_cfg,
17295 case_declares_periodic_axis(case_cfg), explicit, cluster_cfg)
17296
17297
Here is the call graph for this function:
Here is the caller graph for this function:

◆ generate_post_recipe_file()

str picurv_cli.core.generate_post_recipe_file ( str  run_dir,
str  run_id,
dict  post_cfg,
dict  source_files,
  monitor_cfg = None,
"dict | None"   decomposition = None 
)

Generates a key=value config file (post.run) for the C post-processor.

Translates the structured post-processing YAML into the specific flat key-value format required by the C executable, including complex, semicolon-separated pipeline strings.

Parameters
[in]run_dirThe path to the main run directory.
[in]run_idThe unique identifier for the run.
[in]post_cfgThe parsed post-profile YAML configuration dictionary.
[in]source_filesA dictionary of source files for the header.
[in]monitor_cfgOptional parsed monitor YAML configuration dictionary.
[in]decompositionThe postprocessor's resolved decomposition (resolve_post_decomposition()), or None to let PETSc decide.
Returns
The absolute path to the generated post.run recipe file.

Definition at line 17298 of file core.py.

17299 decomposition: "dict | None" = None) -> str:
17300 """!
17301 @brief Generates a key=value config file (post.run) for the C post-processor.
17302 @details Translates the structured post-processing YAML into the specific flat
17303 key-value format required by the C executable, including complex,
17304 semicolon-separated pipeline strings.
17305 @param[in] run_dir The path to the main run directory.
17306 @param[in] run_id The unique identifier for the run.
17307 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
17308 @param[in] source_files A dictionary of source files for the header.
17309 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
17310 @param[in] decomposition The postprocessor's resolved decomposition (resolve_post_decomposition()),
17311 or None to let PETSc decide.
17312 @return The absolute path to the generated post.run recipe file.
17313 """
17314 print("[INFO] Generating post-processor recipe file (post.run)...")
17315 if not isinstance((post_cfg or {}).get("_picurv_paths"), dict):
17316 post_cfg, _ = apply_canonical_post_paths(post_cfg, run_dir)
17317 config_dir = get_post_recipe_root(run_dir, post_cfg)
17318 os.makedirs(config_dir, exist_ok=True)
17319 post_recipe_path = os.path.join(config_dir, "post.run")
17320
17321 lines = [generate_header(run_id, source_files)]
17322 c_config = build_post_recipe_config(post_cfg, monitor_cfg)
17323
17324 for key, value in c_config.items():
17325 if value is not None and str(value) != "":
17326 lines.append(f"{key} = {value}")
17327 # The steps themselves are decided when the job starts (prepare_post_job()).
17328 lines.append(f"step_list_file = {get_post_step_list_path(run_dir, post_cfg)}")
17329 if decomposition and decomposition.get("layout") and decomposition["layout"] != (1, 1, 1):
17330 lines.append("da_processors = " + " ".join(str(v) for v in decomposition["layout"]))
17331
17332 with open(post_recipe_path, "w") as f:
17333 f.write("\n".join(lines))
17334 print(f"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
17335 return os.path.abspath(post_recipe_path)
17336
Here is the call graph for this function:
Here is the caller graph for this function:

◆ execute_command()

picurv_cli.core.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.

... If None, the process inherits the parent's environment directly.

Parameters
[in]commandArgument passed to execute_command().
[in]run_dirArgument passed to execute_command().
[in]log_filenameArgument passed to execute_command().
[in]monitor_cfgArgument passed to execute_command().

Definition at line 17337 of file core.py.

17337def execute_command(command: list, run_dir: str, log_filename: str, monitor_cfg: dict = None):
17338 """!
17339 @brief Executes a command, streaming its output to the console and a log file.
17340 @details ...
17341 If None, the process inherits the parent's environment directly.
17342 @param[in] command Argument passed to `execute_command()`.
17343 @param[in] run_dir Argument passed to `execute_command()`.
17344 @param[in] log_filename Argument passed to `execute_command()`.
17345 @param[in] monitor_cfg Argument passed to `execute_command()`.
17346 """
17347 log_path = resolve_command_log_path(run_dir, log_filename)
17348 os.makedirs(os.path.dirname(log_path), exist_ok=True)
17349
17350 print(f"[INFO] Launching Command...\n > {format_command_for_display(command)}")
17351 print(f" Log file: {os.path.relpath(log_path)}")
17352 print("-" * 60)
17353
17354 # --- Environment Handling ---
17355 popen_kwargs = {
17356 "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT,
17357 "cwd": run_dir, "bufsize": 1, "universal_newlines": True,
17358 "encoding": 'utf-8', "errors": 'replace'
17359 }
17360
17361 run_env = os.environ.copy()
17362 if monitor_cfg:
17363 verbosity = monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()
17364 run_env['LOG_LEVEL'] = verbosity
17365 print(f"[INFO] Log level: {verbosity}")
17366 # The launched executable's own PETSc first, as job scripts do (petsc_library_path_lines()).
17367 for token in command:
17368 library_dir = executable_petsc_library_dir(str(token)) if os.path.isabs(str(token)) else None
17369 if library_dir:
17370 run_env["LD_LIBRARY_PATH"] = library_dir + (":" + run_env["LD_LIBRARY_PATH"]
17371 if run_env.get("LD_LIBRARY_PATH") else "")
17372 break
17373 popen_kwargs['env'] = run_env
17374
17375 print("-" * 60)
17376 try:
17377 # Pass the constructed keyword arguments dictionary to Popen
17378 process = subprocess.Popen(command, **popen_kwargs)
17379
17380 with open(log_path, "w") as log_file:
17381 for line in process.stdout:
17382 sys.stdout.write(line)
17383 log_file.write(line)
17384 process.wait()
17385 return_code = process.returncode
17386 print("-" * 60)
17387 if return_code == 0:
17388 print(f"[SUCCESS] Execution finished successfully.")
17389 else:
17390 print(f"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
17391 sys.exit(return_code)
17392 except FileNotFoundError:
17393 print(f"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
17394 print(" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
17395 sys.exit(1)
17396 except Exception as e:
17397 print(f"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
17398 sys.exit(1)
17399
17400
Here is the call graph for this function:
Here is the caller graph for this function:

◆ format_command_for_display()

str picurv_cli.core.format_command_for_display ( list  command)

Render a shell-safe command string for console and log output.

Parameters
[in]commandArgument passed to format_command_for_display().
Returns
Value returned by format_command_for_display().

Definition at line 17401 of file core.py.

17401def format_command_for_display(command: list) -> str:
17402 """!
17403 @brief Render a shell-safe command string for console and log output.
17404 @param[in] command Argument passed to `format_command_for_display()`.
17405 @return Value returned by `format_command_for_display()`.
17406 """
17407 return " ".join(shlex.quote(str(part)) for part in command)
17408
17409
Here is the caller graph for this function:

◆ resolve_command_log_path()

str picurv_cli.core.resolve_command_log_path ( str  run_dir,
str  log_filename 
)

Resolve a command log filename relative to the run directory.

Parameters
[in]run_dirArgument passed to resolve_command_log_path().
[in]log_filenameArgument passed to resolve_command_log_path().
Returns
Value returned by resolve_command_log_path().

Definition at line 17410 of file core.py.

17410def resolve_command_log_path(run_dir: str, log_filename: str) -> str:
17411 """!
17412 @brief Resolve a command log filename relative to the run directory.
17413 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
17414 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
17415 @return Value returned by `resolve_command_log_path()`.
17416 """
17417 if os.path.dirname(log_filename):
17418 return os.path.join(run_dir, log_filename)
17419 return os.path.join(run_dir, "logs", log_filename)
17420
17421
Here is the caller graph for this function:

◆ _run_captured_command()

subprocess.CompletedProcess picurv_cli.core._run_captured_command ( list  command,
str  run_dir 
)
protected

Run a command and capture combined stdout/stderr details for later inspection.

Parameters
[in]commandArgument passed to _run_captured_command().
[in]run_dirArgument passed to _run_captured_command().
Returns
Value returned by _run_captured_command().

Definition at line 17449 of file core.py.

17449def _run_captured_command(command: list, run_dir: str) -> subprocess.CompletedProcess:
17450 """!
17451 @brief Run a command and capture combined stdout/stderr details for later inspection.
17452 @param[in] command Argument passed to `_run_captured_command()`.
17453 @param[in] run_dir Argument passed to `_run_captured_command()`.
17454 @return Value returned by `_run_captured_command()`.
17455 """
17456 try:
17457 return subprocess.run(
17458 command,
17459 cwd=run_dir,
17460 text=True,
17461 capture_output=True,
17462 check=False,
17463 encoding="utf-8",
17464 errors="replace",
17465 )
17466 except FileNotFoundError as exc:
17467 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
17468
17469
Here is the caller graph for this function:

◆ _require_successful_command()

picurv_cli.core._require_successful_command ( list  command,
subprocess.CompletedProcess  result 
)
protected

Raise CommandExecutionError when a captured command failed.

Parameters
[in]commandArgument passed to _require_successful_command().
[in]resultArgument passed to _require_successful_command().

Definition at line 17470 of file core.py.

17470def _require_successful_command(command: list, result: subprocess.CompletedProcess):
17471 """!
17472 @brief Raise `CommandExecutionError` when a captured command failed.
17473 @param[in] command Argument passed to `_require_successful_command()`.
17474 @param[in] result Argument passed to `_require_successful_command()`.
17475 """
17476 if result.returncode == 0:
17477 return
17478 details = (result.stderr or result.stdout).strip()
17479 raise CommandExecutionError(command, result.returncode, details or None)
17480
17481
Here is the caller graph for this function:

◆ _capture_command_stdout()

str picurv_cli.core._capture_command_stdout ( list  command,
str  run_dir 
)
protected

Run a command, require success, and return stripped stdout text.

Parameters
[in]commandArgument passed to _capture_command_stdout().
[in]run_dirArgument passed to _capture_command_stdout().
Returns
Value returned by _capture_command_stdout().

Definition at line 17482 of file core.py.

17482def _capture_command_stdout(command: list, run_dir: str) -> str:
17483 """!
17484 @brief Run a command, require success, and return stripped stdout text.
17485 @param[in] command Argument passed to `_capture_command_stdout()`.
17486 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
17487 @return Value returned by `_capture_command_stdout()`.
17488 """
17489 result = _run_captured_command(command, run_dir)
17490 _require_successful_command(command, result)
17491 return result.stdout.strip()
17492
17493
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _stream_command_to_console_and_log()

picurv_cli.core._stream_command_to_console_and_log ( list  command,
str  run_dir,
  log_file 
)
protected

Stream command output to stdout and an already-open log file.

Parameters
[in]commandArgument passed to _stream_command_to_console_and_log().
[in]run_dirArgument passed to _stream_command_to_console_and_log().
[in]log_fileArgument passed to _stream_command_to_console_and_log().

Definition at line 17494 of file core.py.

17494def _stream_command_to_console_and_log(command: list, run_dir: str, log_file):
17495 """!
17496 @brief Stream command output to stdout and an already-open log file.
17497 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
17498 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
17499 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
17500 """
17501 display = format_command_for_display(command)
17502 print(f"[INFO] Running: {display}")
17503 log_file.write(f"$ {display}\n")
17504 log_file.flush()
17505
17506 popen_kwargs = {
17507 "stdout": subprocess.PIPE,
17508 "stderr": subprocess.STDOUT,
17509 "cwd": run_dir,
17510 "bufsize": 1,
17511 "universal_newlines": True,
17512 "encoding": "utf-8",
17513 "errors": "replace",
17514 }
17515
17516 try:
17517 process = subprocess.Popen(command, **popen_kwargs)
17518 except FileNotFoundError as exc:
17519 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
17520
17521 with process:
17522 for line in process.stdout:
17523 sys.stdout.write(line)
17524 log_file.write(line)
17525 return_code = process.wait()
17526 log_file.write("\n")
17527 log_file.flush()
17528 if return_code != 0:
17529 raise CommandExecutionError(command, return_code)
17530
17531
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _get_git_head_state()

dict picurv_cli.core._get_git_head_state ( str  run_dir)
protected

Capture the current git HEAD branch name and commit hash.

Parameters
[in]run_dirArgument passed to _get_git_head_state().
Returns
Value returned by _get_git_head_state().

Definition at line 17532 of file core.py.

17532def _get_git_head_state(run_dir: str) -> dict:
17533 """!
17534 @brief Capture the current git HEAD branch name and commit hash.
17535 @param[in] run_dir Argument passed to `_get_git_head_state()`.
17536 @return Value returned by `_get_git_head_state()`.
17537 """
17538 head_commit = _capture_command_stdout(["git", "rev-parse", "--verify", "HEAD"], run_dir)
17539 branch_result = _run_captured_command(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], run_dir)
17540 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
17541 return {"branch": branch_name, "commit": head_commit}
17542
17543
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _get_local_branches_with_upstreams()

"list[tuple[str, str | None]]" picurv_cli.core._get_local_branches_with_upstreams ( str  run_dir)
protected

Return local branch names plus their configured upstreams.

Parameters
[in]run_dirArgument passed to _get_local_branches_with_upstreams().
Returns
Value returned by _get_local_branches_with_upstreams().

Definition at line 17544 of file core.py.

17544def _get_local_branches_with_upstreams(run_dir: str) -> "list[tuple[str, str | None]]":
17545 """!
17546 @brief Return local branch names plus their configured upstreams.
17547 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
17548 @return Value returned by `_get_local_branches_with_upstreams()`.
17549 """
17550 output = _capture_command_stdout(
17551 ["git", "for-each-ref", "--sort=refname", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
17552 run_dir,
17553 )
17554 branches = []
17555 for line in output.splitlines():
17556 if not line.strip():
17557 continue
17558 branch_name, _, upstream_name = line.partition("\t")
17559 branches.append((branch_name, upstream_name or None))
17560 return branches
17561
17562
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _working_tree_has_tracked_changes()

bool picurv_cli.core._working_tree_has_tracked_changes ( str  run_dir)
protected

Return True when the repository has staged or unstaged tracked changes.

Parameters
[in]run_dirArgument passed to _working_tree_has_tracked_changes().
Returns
Value returned by _working_tree_has_tracked_changes().

Definition at line 17563 of file core.py.

17563def _working_tree_has_tracked_changes(run_dir: str) -> bool:
17564 """!
17565 @brief Return `True` when the repository has staged or unstaged tracked changes.
17566 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
17567 @return Value returned by `_working_tree_has_tracked_changes()`.
17568 """
17569 command = ["git", "status", "--porcelain", "--untracked-files=no"]
17570 result = _run_captured_command(command, run_dir)
17571 _require_successful_command(command, result)
17572 return bool(result.stdout.strip())
17573
17574
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _attempt_pull_cleanup()

picurv_cli.core._attempt_pull_cleanup ( str  run_dir,
bool  rebase,
  log_file 
)
protected

Best-effort cleanup after a failed git pull so the original branch can be restored.

Parameters
[in]run_dirArgument passed to _attempt_pull_cleanup().
[in]rebaseArgument passed to _attempt_pull_cleanup().
[in]log_fileArgument passed to _attempt_pull_cleanup().

Definition at line 17575 of file core.py.

17575def _attempt_pull_cleanup(run_dir: str, rebase: bool, log_file):
17576 """!
17577 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
17578 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
17579 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
17580 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
17581 """
17582 cleanup_command = ["git", "rebase", "--abort"] if rebase else ["git", "merge", "--abort"]
17583 result = _run_captured_command(cleanup_command, run_dir)
17584 if result.returncode == 0:
17585 print(f"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
17586 log_file.write(f"$ {format_command_for_display(cleanup_command)}\n")
17587 if result.stdout:
17588 sys.stdout.write(result.stdout)
17589 log_file.write(result.stdout)
17590 if result.stderr:
17591 sys.stderr.write(result.stderr)
17592 log_file.write(result.stderr)
17593 log_file.write("\n")
17594 log_file.flush()
17595 return
17596
17597 details = (result.stderr or result.stdout).strip()
17598 if details:
17599 message = (
17600 f"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
17601 )
17602 print(message, file=sys.stderr)
17603 log_file.write(message + "\n")
17604 log_file.flush()
17605
17606
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _restore_git_head()

picurv_cli.core._restore_git_head ( str  run_dir,
dict  original_head,
  log_file 
)
protected

Restore the repository back to the branch or detached commit it started on.

Parameters
[in]run_dirArgument passed to _restore_git_head().
[in]original_headArgument passed to _restore_git_head().
[in]log_fileArgument passed to _restore_git_head().

Definition at line 17607 of file core.py.

17607def _restore_git_head(run_dir: str, original_head: dict, log_file):
17608 """!
17609 @brief Restore the repository back to the branch or detached commit it started on.
17610 @param[in] run_dir Argument passed to `_restore_git_head()`.
17611 @param[in] original_head Argument passed to `_restore_git_head()`.
17612 @param[in] log_file Argument passed to `_restore_git_head()`.
17613 """
17614 current_state = _get_git_head_state(run_dir)
17615 if original_head["branch"]:
17616 if current_state["branch"] == original_head["branch"]:
17617 return
17618 _stream_command_to_console_and_log(["git", "checkout", original_head["branch"]], run_dir, log_file)
17619 return
17620
17621 if current_state["branch"] is None and current_state["commit"] == original_head["commit"]:
17622 return
17623 _stream_command_to_console_and_log(["git", "checkout", "--detach", original_head["commit"]], run_dir, log_file)
17624
17625
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _refuse_detached_source_checkout()

None picurv_cli.core._refuse_detached_source_checkout ( str  source_project_root,
str  action 
)
protected

Refuse a branch pull when the source checkout is pinned to a detached commit.

versions install and versions activate leave the checkout detached on the version they built. A branch pull there either fails inside Git or, in the multi-branch path, updates the branches and then restores the detached commit, so the active code never changes while the command reports success. A directory that is not a Git checkout is left for the Git command to report.

Parameters
[in]source_project_rootPICurv source checkout to inspect.
[in]actionUser-facing command name.
Exceptions
ValueErrorwhen HEAD is detached.

Definition at line 17626 of file core.py.

17626def _refuse_detached_source_checkout(source_project_root: str, action: str) -> None:
17627 """!
17628 @brief Refuse a branch pull when the source checkout is pinned to a detached commit.
17629
17630 @details `versions install` and `versions activate` leave the checkout detached on
17631 the version they built. A branch pull there either fails inside Git or, in
17632 the multi-branch path, updates the branches and then restores the detached
17633 commit, so the active code never changes while the command reports success.
17634 A directory that is not a Git checkout is left for the Git command to report.
17635 @param[in] source_project_root PICurv source checkout to inspect.
17636 @param[in] action User-facing command name.
17637 @throws ValueError when HEAD is detached.
17638 """
17639 branch = _run_captured_command(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], source_project_root)
17640 if branch.returncode == 0:
17641 return
17642 commit = _run_captured_command(["git", "rev-parse", "--short=12", "--verify", "HEAD"], source_project_root)
17643 if commit.returncode != 0:
17644 return
17645 raise ValueError(
17646 f"{action}: the source checkout at {source_project_root} is pinned to commit "
17647 f"{commit.stdout.strip()}, not a branch, so a branch pull would not change the code "
17648 "that runs.\n"
17649 " Install a newer version with 'picurv versions install <tag-or-commit>', "
17650 "or check out a branch (for example 'git checkout main') to follow it."
17651 )
17652
17653
Here is the call graph for this function:
Here is the caller graph for this function:

◆ pull_all_source_branches()

picurv_cli.core.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.

Parameters
[in]run_dirArgument passed to pull_all_source_branches().
[in]log_filenameArgument passed to pull_all_source_branches().
[in]rebaseArgument passed to pull_all_source_branches().

Definition at line 17654 of file core.py.

17654def pull_all_source_branches(run_dir: str, log_filename: str, rebase: bool = True):
17655 """!
17656 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
17657 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
17658 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
17659 @param[in] rebase Argument passed to `pull_all_source_branches()`.
17660 """
17661 log_path = resolve_command_log_path(run_dir, log_filename)
17662 os.makedirs(os.path.dirname(log_path), exist_ok=True)
17663
17664 print("\n" + "="*23 + " PULL SOURCE STAGE " + "="*22)
17665 print("[INFO] Refreshing all local source branches that track an upstream.")
17666 print(f" Log file: {os.path.relpath(log_path)}")
17667 print("-" * 60)
17668
17669 try:
17670 original_head = _get_git_head_state(run_dir)
17671 if _working_tree_has_tracked_changes(run_dir):
17672 raise RuntimeError(
17673 "Multi-branch pull requires a clean tracked working tree in the source repository. "
17674 "Commit or stash those changes first, or rerun with --current-branch-only."
17675 )
17676 branches = _get_local_branches_with_upstreams(run_dir)
17677 except (CommandExecutionError, RuntimeError) as exc:
17678 print(f"[FATAL] {exc}", file=sys.stderr)
17679 sys.exit(getattr(exc, "returncode", 1))
17680
17681 if not branches:
17682 print("[FATAL] No local branches were found in the source repository.", file=sys.stderr)
17683 sys.exit(1)
17684
17685 if original_head["branch"]:
17686 branches = [item for item in branches if item[0] != original_head["branch"]] + [
17687 item for item in branches if item[0] == original_head["branch"]
17688 ]
17689
17690 skipped_branches = []
17691 current_operation = None
17692 pull_error = None
17693 restore_error = None
17694
17695 with open(log_path, "w", encoding="utf-8") as log_file:
17696 log_file.write(f"# PICurv pull-source all-branch sync\n")
17697 log_file.write(f"# repository: {os.path.abspath(run_dir)}\n")
17698 log_file.write(f"# started: {datetime.now().isoformat()}\n")
17699 log_file.write(
17700 f"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
17701 )
17702
17703 try:
17704 for branch_name, upstream_name in branches:
17705 if not upstream_name:
17706 warning = f"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
17707 print(warning, file=sys.stderr)
17708 log_file.write(warning + "\n")
17709 skipped_branches.append(branch_name)
17710 continue
17711
17712 print(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
17713 log_file.write(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
17714
17715 current_operation = f"checkout:{branch_name}"
17716 _stream_command_to_console_and_log(["git", "checkout", branch_name], run_dir, log_file)
17717
17718 pull_command = ["git", "pull"]
17719 if rebase:
17720 pull_command.append("--rebase")
17721 current_operation = f"pull:{branch_name}"
17722 _stream_command_to_console_and_log(pull_command, run_dir, log_file)
17723 current_operation = None
17724 except CommandExecutionError as exc:
17725 pull_error = exc
17726 if current_operation and current_operation.startswith("pull:"):
17727 _attempt_pull_cleanup(run_dir, rebase, log_file)
17728 finally:
17729 try:
17730 _restore_git_head(run_dir, original_head, log_file)
17731 except CommandExecutionError as exc:
17732 restore_error = exc
17733
17734 print("-" * 60)
17735 if pull_error:
17736 if restore_error:
17737 print(
17738 f"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
17739 f"Check log: {os.path.relpath(log_path)}",
17740 file=sys.stderr,
17741 )
17742 sys.exit(restore_error.returncode)
17743 print(
17744 f"[FATAL] Multi-branch pull failed. Original branch restored. "
17745 f"Check log: {os.path.relpath(log_path)}",
17746 file=sys.stderr,
17747 )
17748 sys.exit(pull_error.returncode)
17749
17750 if restore_error:
17751 print(
17752 f"[FATAL] Branch updates completed, but the original branch could not be restored. "
17753 f"Check log: {os.path.relpath(log_path)}",
17754 file=sys.stderr,
17755 )
17756 sys.exit(restore_error.returncode)
17757
17758 if skipped_branches:
17759 print(f"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
17760 print("[SUCCESS] All local tracking branches are up to date.")
17761
Here is the call graph for this function:
Here is the caller graph for this function:

◆ auto_identify_run_inputs()

picurv_cli.core.auto_identify_run_inputs ( str  config_dir)

Auto-detect case.yml, monitor.yml, and *.control in a run config directory.

Parameters
[in]config_dirArgument passed to auto_identify_run_inputs().
Returns
Value returned by auto_identify_run_inputs().

Definition at line 17762 of file core.py.

17762def auto_identify_run_inputs(config_dir: str):
17763 """!
17764 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
17765 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
17766 @return Value returned by `auto_identify_run_inputs()`.
17767 """
17768 run_dir = os.path.dirname(os.path.abspath(config_dir))
17769 active = load_active_run_configuration(run_dir)
17770 if active:
17771 case_path = active.get("case")
17772 monitor_path = active.get("monitor")
17773 solver_control_path = active.get("control")
17774 if all(path and os.path.isfile(path) for path in (case_path, monitor_path, solver_control_path)):
17775 return case_path, monitor_path, solver_control_path
17776 all_yml_files = glob.glob(os.path.join(config_dir, "*.yml"))
17777 case_path, monitor_path = None, None
17778 for f_path in all_yml_files:
17779 try:
17780 content = read_yaml_file(f_path)
17781 if not isinstance(content, dict):
17782 continue
17783 if 'models' in content and 'boundary_conditions' in content:
17784 case_path = f_path
17785 elif 'io' in content and 'logging' in content:
17786 monitor_path = f_path
17787 except Exception as e:
17788 print(f"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
17789 try:
17790 solver_control_path = glob.glob(os.path.join(config_dir, "*.control"))[0]
17791 except IndexError:
17792 solver_control_path = None
17793 return case_path, monitor_path, solver_control_path
17794
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_post_source_directory()

str picurv_cli.core.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.

Parameters
[in]run_dirArgument passed to resolve_post_source_directory().
[in]monitor_cfgArgument passed to resolve_post_source_directory().
[in]post_cfgArgument passed to resolve_post_source_directory().
[in]strictArgument passed to resolve_post_source_directory().
Returns
Value returned by resolve_post_source_directory().

Definition at line 17795 of file core.py.

17795def resolve_post_source_directory(run_dir: str, monitor_cfg: dict, post_cfg: dict, strict: bool = True) -> str:
17796 """!
17797 @brief Resolve post source directory token and optionally enforce existence.
17798 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
17799 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
17800 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
17801 @param[in] strict Argument passed to `resolve_post_source_directory()`.
17802 @return Value returned by `resolve_post_source_directory()`.
17803 """
17804 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
17805 source_dir_template = get_post_source_directory_template(post_cfg)
17806 if source_dir_template == '<solver_output_dir>':
17807 resolved_source_dir = solver_output_dir_abs
17808 print(f"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
17809 else:
17810 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
17811 print(f"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
17812
17813 if strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
17814 print(
17815 f"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
17816 file=sys.stderr
17817 )
17818 sys.exit(1)
17819 if not strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
17820 print("[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
17821 return resolved_source_dir
17822
Here is the call graph for this function:
Here is the caller graph for this function:

◆ render_slurm_array_stage_script()

picurv_cli.core.render_slurm_array_stage_script ( str  script_path,
str  job_name,
dict  cluster_cfg,
str  array_spec,
str  case_index_tsv,
str  stage,
str  solver_exe,
str  post_exe,
str  stdout_path,
str  stderr_path,
"str | None"   expected_version_line = None 
)

Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.

Parameters
[in]script_pathArgument passed to render_slurm_array_stage_script().
[in]job_nameArgument passed to render_slurm_array_stage_script().
[in]cluster_cfgArgument passed to render_slurm_array_stage_script().
[in]array_specArgument passed to render_slurm_array_stage_script().
[in]case_index_tsvArgument passed to render_slurm_array_stage_script().
[in]stageArgument passed to render_slurm_array_stage_script().
[in]solver_exeArgument passed to render_slurm_array_stage_script().
[in]post_exeArgument passed to render_slurm_array_stage_script().
[in]stdout_pathArgument passed to render_slurm_array_stage_script().
[in]stderr_pathArgument passed to render_slurm_array_stage_script().
[in]expected_version_line--version output the stage's executable reported at staging; checked before launch in every task.
Returns
Value returned by render_slurm_array_stage_script().

Definition at line 17823 of file core.py.

17835):
17836 """!
17837 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
17838 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
17839 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
17840 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
17841 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
17842 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
17843 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
17844 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
17845 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
17846 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
17847 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
17848 @param[in] expected_version_line `--version` output the stage's executable reported
17849 at staging; checked before launch in every task.
17850 @return Value returned by `render_slurm_array_stage_script()`.
17851 """
17852 effective_cluster_cfg = cluster_cfg
17853 resources = effective_cluster_cfg.get("resources", {})
17854 notifications = effective_cluster_cfg.get("notifications", {}) or {}
17855 execution = effective_cluster_cfg.get("execution", {}) or {}
17856 module_setup = execution.get("module_setup", []) or []
17857 extra_sbatch = execution.get("extra_sbatch")
17858
17859 lines = [
17860 "#!/bin/bash",
17861 f"#SBATCH --job-name={job_name}",
17862 f"#SBATCH --nodes={resources['nodes']}",
17863 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
17864 f"#SBATCH --mem={resources['mem']}",
17865 f"#SBATCH --time={resources['time']}",
17866 f"#SBATCH --output={stdout_path}",
17867 f"#SBATCH --error={stderr_path}",
17868 f"#SBATCH --account={resources['account']}",
17869 f"#SBATCH --array={array_spec}",
17870 ]
17871 partition = resources.get("partition")
17872 if partition:
17873 lines.append(f"#SBATCH --partition={partition}")
17874 mail_user = notifications.get("mail_user")
17875 mail_type = notifications.get("mail_type")
17876 if mail_user:
17877 lines.append(f"#SBATCH --mail-user={mail_user}")
17878 if mail_type:
17879 lines.append(f"#SBATCH --mail-type={mail_type}")
17880 if isinstance(extra_sbatch, dict):
17881 for key, value in extra_sbatch.items():
17882 flag = str(key)
17883 if not flag.startswith("--"):
17884 flag = f"--{flag}"
17885 if isinstance(value, bool):
17886 if value:
17887 lines.append(f"#SBATCH {flag}")
17888 elif value is not None:
17889 lines.append(f"#SBATCH {flag}={value}")
17890 elif isinstance(extra_sbatch, list):
17891 for token in extra_sbatch:
17892 lines.append(f"#SBATCH {token}")
17893
17894 lines.extend([
17895 "",
17896 "set -euo pipefail",
17897 "",
17898 f'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
17899 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
17900 'if [ -z "$LINE" ]; then',
17901 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
17902 ' exit 1',
17903 "fi",
17904 "IFS=$'\\t' read -r CASE_INDEX CASE_ID RUN_DIR CONTROL_FILE POST_RECIPE_FILE LOG_LEVEL POST_PREFIX SOLVE_DIAGNOSTIC_ARGS POST_DIAGNOSTIC_ARGS <<< \"$LINE\"",
17905 'cd "$RUN_DIR"',
17906 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
17907 ])
17908
17909 if stage == "solve":
17910 walltime_guard_exports = build_walltime_guard_exports(effective_cluster_cfg)
17911 for key, value in walltime_guard_exports.items():
17912 lines.append(f"export {key}={value}")
17913
17914 lines.append('export LOG_LEVEL="${LOG_LEVEL}"')
17915
17916 for setup_line in module_setup:
17917 lines.append(str(setup_line))
17918
17919 stage_exe = solver_exe if stage == "solve" else post_exe
17920 # A pinned `$RUN_DIR/...` copy is the installation's binary, so its PETSc is the installation's.
17921 lines.extend(petsc_library_path_lines(executable_petsc_library_dir(
17922 stage_exe if not stage_exe.startswith("$") else
17923 resolve_runtime_executable("simulator" if stage == "solve" else "postprocessor"))))
17924 lines.extend(executable_identity_check_lines(
17925 stage_exe, expected_version_line, pinned=stage_exe.startswith("$RUN_DIR/")
17926 ))
17927
17928 if stage == "solve":
17929 cmd = build_cluster_launch_command(
17930 effective_cluster_cfg,
17931 solver_exe,
17932 ["-control_file", "$CONTROL_FILE"]
17933 )
17934 else:
17935 cmd = build_cluster_launch_command(
17936 effective_cluster_cfg,
17937 post_exe,
17938 ["-control_file", "$CONTROL_FILE", "-postprocessing_config_file", "$POST_RECIPE_FILE"],
17939 force_num_procs=get_cluster_total_tasks(effective_cluster_cfg),
17940 )
17941
17942 # Keep shell variables unresolved inside sbatch script.
17943 def _token(tok: str) -> str:
17944 """!
17945 @brief Preserve shell-variable tokens while safely quoting literal command arguments for an sbatch script.
17946 @param[in] tok Argument passed to `_token()`.
17947 @return Value returned by `_token()`.
17948 """
17949 if tok.startswith("$"):
17950 return tok
17951 return shlex.quote(str(tok))
17952
17953 diag_var = "${SOLVE_DIAGNOSTIC_ARGS}" if stage == "solve" else "${POST_DIAGNOSTIC_ARGS}"
17954 command_text = " ".join(_token(t) for t in cmd)
17955 executable_token = _token(solver_exe if stage == "solve" else post_exe)
17956 # Diagnostic args must be executable args, not launcher args. Insert them
17957 # immediately before the executable's normal control/recipe options.
17958 if executable_token and command_text.count(executable_token) == 1:
17959 command_text = command_text.replace(f"{executable_token} ", f"{executable_token} {diag_var} ", 1)
17960 if stage == "post":
17961 # A member's post runs under the same lock wrapper as a standalone run, which
17962 # plans the steps as the task starts, so a continued study skips every member
17963 # step whose output is up to date.
17964 lock_paths = get_post_lock_paths("$RUN_DIR")
17965 lines.append(
17966 f"{shlex.quote(sys.executable)} \"{lock_paths['wrapper_path']}\" "
17967 f"--lock-file \"{lock_paths['lock_file']}\" "
17968 f"--metadata-file \"{lock_paths['metadata_file']}\" "
17969 f"--run-dir \"$RUN_DIR\" --post-recipe \"$RUN_DIR/config/post.yml\" "
17970 f"--postprocessor {executable_token} -- {command_text}"
17971 )
17972 # Study members are ordinary root runs, but their post recipes may request the
17973 # same run-local physical-time PVD product and reducers as standalone runs.
17974 # Keep this serial and downstream of the MPI postprocessor.
17975 lines.append(
17976 f"{shlex.quote(sys.executable)} -c {shlex.quote(_post_finalize_python_source())} "
17977 '"$RUN_DIR" "$RUN_DIR/config/post.yml" reducers'
17978 )
17979 else:
17980 lines.append(f"exec {command_text}")
17981
17982 os.makedirs(os.path.dirname(script_path), exist_ok=True)
17983 with open(script_path, "w") as f:
17984 f.write("\n".join(lines) + "\n")
17985 os.chmod(script_path, 0o755)
17986
17987
Here is the call graph for this function:
Here is the caller graph for this function:

◆ render_metrics_aggregate_script()

picurv_cli.core.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.

Parameters
[in]script_pathPath to write the sbatch script.
[in]job_nameSlurm job name.
[in]cluster_cfgParsed cluster YAML dictionary.
[in]study_dirAbsolute path to the study directory.
[in]picurv_pathAbsolute path to the picurv script.

Definition at line 17988 of file core.py.

17994):
17995 """!
17996 @brief Generate a single-node sbatch script that runs metrics aggregation.
17997 @param[in] script_path Path to write the sbatch script.
17998 @param[in] job_name Slurm job name.
17999 @param[in] cluster_cfg Parsed cluster YAML dictionary.
18000 @param[in] study_dir Absolute path to the study directory.
18001 @param[in] picurv_path Absolute path to the picurv script.
18002 """
18003 resources = cluster_cfg.get("resources", {})
18004 notifications = cluster_cfg.get("notifications", {}) or {}
18005 execution = cluster_cfg.get("execution", {}) or {}
18006 module_setup = execution.get("module_setup", []) or []
18007
18008 scheduler_dir = os.path.join(study_dir, "scheduler")
18009 lines = [
18010 "#!/bin/bash",
18011 f"#SBATCH --job-name={job_name}",
18012 "#SBATCH --nodes=1",
18013 "#SBATCH --ntasks-per-node=1",
18014 "#SBATCH --mem=4G",
18015 "#SBATCH --time=00:10:00",
18016 f"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
18017 f"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
18018 f"#SBATCH --account={resources['account']}",
18019 ]
18020 partition = resources.get("partition")
18021 if partition:
18022 lines.append(f"#SBATCH --partition={partition}")
18023 mail_user = notifications.get("mail_user")
18024 mail_type = notifications.get("mail_type")
18025 if mail_user:
18026 lines.append(f"#SBATCH --mail-user={mail_user}")
18027 if mail_type:
18028 lines.append(f"#SBATCH --mail-type={mail_type}")
18029
18030 lines.extend([
18031 "",
18032 "set -euo pipefail",
18033 'echo "[$(date)] Running metrics aggregation"',
18034 "",
18035 ])
18036 for setup_line in module_setup:
18037 lines.append(str(setup_line))
18038
18039 lines.append(
18040 f"exec {shlex.quote(picurv_path)} sweep --reaggregate"
18041 f" --study-dir {shlex.quote(study_dir)}"
18042 )
18043
18044 os.makedirs(os.path.dirname(script_path), exist_ok=True)
18045 with open(script_path, "w") as f:
18046 f.write("\n".join(lines) + "\n")
18047 os.chmod(script_path, 0o755)
18048
18049
Here is the caller graph for this function:

◆ reduce_metric_values()

picurv_cli.core.reduce_metric_values (   values,
str  reduction 
)

Reduce a metric series to one scalar according to the requested reducer.

Parameters
[in]valuesSequence of numeric values.
[in]reductionReduction keyword.
Returns
Value returned by reduce_metric_values().

Definition at line 18050 of file core.py.

18050def reduce_metric_values(values, reduction: str):
18051 """!
18052 @brief Reduce a metric series to one scalar according to the requested reducer.
18053 @param[in] values Sequence of numeric values.
18054 @param[in] reduction Reduction keyword.
18055 @return Value returned by `reduce_metric_values()`.
18056 """
18057 if not values:
18058 return None
18059 np = require_numpy()
18060 reduction = str(reduction).lower()
18061 if reduction == "mean":
18062 return float(np.mean(values))
18063 if reduction == "min":
18064 return float(np.min(values))
18065 if reduction == "max":
18066 return float(np.max(values))
18067 if reduction == "p95":
18068 return float(np.percentile(values, 95.0))
18069 return float(values[-1])
18070
18071
Here is the call graph for this function:
Here is the caller graph for this function:

◆ extract_metric_from_csv()

picurv_cli.core.extract_metric_from_csv ( str  case_dir,
dict  spec 
)

Extract a scalar metric from a CSV source.

Parameters
[in]case_dirArgument passed to extract_metric_from_csv().
[in]specArgument passed to extract_metric_from_csv().
Returns
Value returned by extract_metric_from_csv().

Definition at line 18072 of file core.py.

18072def extract_metric_from_csv(case_dir: str, spec: dict):
18073 """!
18074 @brief Extract a scalar metric from a CSV source.
18075 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
18076 @param[in] spec Argument passed to `extract_metric_from_csv()`.
18077 @return Value returned by `extract_metric_from_csv()`.
18078 """
18079 file_glob = spec.get("file_glob", "**/*_msd.csv")
18080 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
18081 if not candidates:
18082 return None
18083 csv_path = candidates[0]
18084 rows = []
18085 with open(csv_path, "r", newline="") as f:
18086 reader = csv.DictReader(f)
18087 if reader.fieldnames:
18088 for row in reader:
18089 rows.append(row)
18090 if not rows:
18091 return None
18092 column = spec.get("column")
18093 numerator_column = spec.get("numerator_column")
18094 denominator_column = spec.get("denominator_column")
18095 denominator_floor = float(spec.get("denominator_floor", 0.0) or 0.0)
18096 if not column and not numerator_column:
18097 for name in reversed(reader.fieldnames):
18098 if name and name.lower() not in {"step", "time", "timestep", "physical_time"}:
18099 column = name
18100 break
18101 if not column and not numerator_column:
18102 return None
18103 values = []
18104 for row in rows:
18105 try:
18106 if numerator_column:
18107 numerator = float(row[numerator_column])
18108 denominator = float(row[denominator_column])
18109 denominator = max(denominator_floor, denominator)
18110 if denominator == 0.0:
18111 continue
18112 values.append(numerator / denominator)
18113 else:
18114 values.append(float(row[column]))
18115 except Exception:
18116 continue
18117 else:
18118 return None
18119 return reduce_metric_values(values, spec.get("reduction", "last"))
18120
18121
Here is the call graph for this function:
Here is the caller graph for this function:

◆ extract_metric_from_log()

picurv_cli.core.extract_metric_from_log ( str  case_dir,
dict  spec 
)

Extract a scalar metric from a log file using regex.

Parameters
[in]case_dirArgument passed to extract_metric_from_log().
[in]specArgument passed to extract_metric_from_log().
Returns
Value returned by extract_metric_from_log().

Definition at line 18122 of file core.py.

18122def extract_metric_from_log(case_dir: str, spec: dict):
18123 """!
18124 @brief Extract a scalar metric from a log file using regex.
18125 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
18126 @param[in] spec Argument passed to `extract_metric_from_log()`.
18127 @return Value returned by `extract_metric_from_log()`.
18128 """
18129 file_glob = spec.get("file_glob", "logs/*.log")
18130 regex = spec.get("regex")
18131 if not regex:
18132 return None
18133 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
18134 if not candidates:
18135 return None
18136 pattern = re.compile(regex)
18137 values = []
18138 for path in candidates:
18139 try:
18140 with open(path, "r", encoding="utf-8", errors="replace") as f:
18141 for line in f:
18142 m = pattern.search(line)
18143 if m:
18144 try:
18145 values.append(float(m.group(1)))
18146 except Exception:
18147 pass
18148 except OSError:
18149 continue
18150 return reduce_metric_values(values, spec.get("reduction", "last"))
18151
18152
Here is the call graph for this function:
Here is the caller graph for this function:

◆ normalize_metric_spec()

picurv_cli.core.normalize_metric_spec (   metric)

Normalize study metric definitions to a common dictionary form.

Parameters
[in]metricArgument passed to normalize_metric_spec().
Returns
Value returned by normalize_metric_spec().

Definition at line 18153 of file core.py.

18153def normalize_metric_spec(metric):
18154 """!
18155 @brief Normalize study metric definitions to a common dictionary form.
18156 @param[in] metric Argument passed to `normalize_metric_spec()`.
18157 @return Value returned by `normalize_metric_spec()`.
18158 """
18159 if isinstance(metric, str):
18160 if metric.lower() in {"msd", "msd_final"}:
18161 return {
18162 "name": "msd_final",
18163 "source": "statistics_csv",
18164 "file_glob": "**/*_msd.csv",
18165 # Named, not left to the last-column fallback, which is frac_3sigma_pct.
18166 "column": "MSD_total",
18167 "reduction": "last",
18168 }
18169 return {"name": metric, "source": "log_regex", "regex": metric}
18170 return dict(metric)
18171
Here is the caller graph for this function:

◆ _read_previous_metric_rows()

dict picurv_cli.core._read_previous_metric_rows ( str  results_dir)
protected

Read the metrics table an earlier aggregation wrote, keyed by case id.

Parameters
[in]results_dirStudy analysis directory holding metrics_table.csv.
Returns
Mapping of case id to its previously recorded row, empty when absent.

Definition at line 18172 of file core.py.

18172def _read_previous_metric_rows(results_dir: str) -> dict:
18173 """!
18174 @brief Read the metrics table an earlier aggregation wrote, keyed by case id.
18175 @param[in] results_dir Study analysis directory holding metrics_table.csv.
18176 @return Mapping of case id to its previously recorded row, empty when absent.
18177 """
18178 path = os.path.join(results_dir, "metrics_table.csv")
18179 if not os.path.isfile(path):
18180 return {}
18181 try:
18182 with open(path, "r", encoding="utf-8", newline="") as stream:
18183 return {
18184 row["case_id"]: row for row in csv.DictReader(stream) if row.get("case_id")
18185 }
18186 except (OSError, ValueError):
18187 return {}
18188
18189
Here is the caller graph for this function:

◆ aggregate_study_metrics()

str picurv_cli.core.aggregate_study_metrics ( dict  study_cfg,
list  cases,
str  results_dir 
)

Collect metric values from generated case directories into one CSV.

Parameters
[in]study_cfgArgument passed to aggregate_study_metrics().
[in]casesArgument passed to aggregate_study_metrics().
[in]results_dirArgument passed to aggregate_study_metrics().
Returns
Value returned by aggregate_study_metrics().

Definition at line 18190 of file core.py.

18190def aggregate_study_metrics(study_cfg: dict, cases: list, results_dir: str) -> str:
18191 """!
18192 @brief Collect metric values from generated case directories into one CSV.
18193 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
18194 @param[in] cases Argument passed to `aggregate_study_metrics()`.
18195 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
18196 @return Value returned by `aggregate_study_metrics()`.
18197 """
18198 metrics = study_cfg.get("metrics", [])
18199 if not metrics:
18200 metrics = ["msd_final"]
18201 normalized_specs = [normalize_metric_spec(m) for m in metrics]
18202
18203 # A member whose payload was archived cannot be re-measured. Its previously
18204 # aggregated values are the correct answer for it, so they are carried forward
18205 # rather than overwritten with blanks or the whole table refused.
18206 preserved = _read_previous_metric_rows(results_dir)
18207 rows = []
18208 for case in cases:
18209 row = {"case_id": case["case_id"]}
18210 flat_parameters = flatten_study_parameters(case.get("parameters", {}))
18211 for p_key, p_val in flat_parameters.items():
18212 row[p_key] = p_val
18213 if is_artifact_cold(case["run_dir"]):
18214 retained = preserved.get(case["case_id"])
18215 if retained:
18216 for spec in normalized_specs:
18217 name = spec.get("name", "metric")
18218 row[name] = retained.get(name)
18219 row["_source"] = "retained"
18220 rows.append(row)
18221 continue
18222 print(
18223 f"[WARNING] {case['case_id']} is in cold storage and no previous metrics "
18224 "row was found; its values are reported as unavailable.",
18225 file=sys.stderr,
18226 )
18227 for spec in normalized_specs:
18228 row[spec.get("name", "metric")] = None
18229 rows.append(row)
18230 continue
18231 for spec in normalized_specs:
18232 name = spec.get("name", "metric")
18233 source = str(spec.get("source", "")).lower()
18234 if source in METRIC_SOURCE_KINDS[:2]:
18235 value = extract_metric_from_csv(case["run_dir"], spec)
18236 elif source in METRIC_SOURCE_KINDS[2:]:
18237 value = extract_metric_from_log(case["run_dir"], spec)
18238 else:
18239 value = None
18240
18241 normalize_key = spec.get("normalize_by_parameter")
18242 if value is not None and normalize_key:
18243 denom = flat_parameters.get(normalize_key)
18244 try:
18245 denom = float(denom)
18246 except Exception:
18247 denom = None
18248 if denom not in (None, 0.0):
18249 value = float(value) / denom
18250 else:
18251 value = None
18252
18253 row[name] = value
18254 rows.append(row)
18255
18256 if not rows:
18257 return None
18258
18259 all_keys = []
18260 seen = set()
18261 for row in rows:
18262 for k in row.keys():
18263 if k not in seen:
18264 seen.add(k)
18265 all_keys.append(k)
18266
18267 os.makedirs(results_dir, exist_ok=True)
18268 out_csv = os.path.join(results_dir, "metrics_table.csv")
18269 with open(out_csv, "w", newline="") as f:
18270 writer = csv.DictWriter(f, fieldnames=all_keys)
18271 writer.writeheader()
18272 writer.writerows(rows)
18273 metric_names = [spec.get("name", "metric") for spec in normalized_specs]
18274 if any(row.get(name) is not None for row in rows for name in metric_names):
18275 print(f"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
18276 else:
18277 # At submission no member has run; the metrics job fills the table later.
18278 print(f"[INFO] Initialized metrics table (no member has produced metrics yet): "
18279 f"{os.path.relpath(out_csv)}")
18280 return out_csv
18281
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _study_parameter_label()

str picurv_cli.core._study_parameter_label ( str  key)
protected

Return a concise report label for a study parameter path.

Parameters
[in]keyDotted study parameter path.
Returns
Report-facing axis or legend label.

Definition at line 18288 of file core.py.

18288def _study_parameter_label(key: str) -> str:
18289 """!
18290 @brief Return a concise report label for a study parameter path.
18291 @param[in] key Dotted study parameter path.
18292 @return Report-facing axis or legend label.
18293 """
18294 exact = {
18295 "case.run_control.dt_physical": "Physical timestep, Δt",
18296 "case.models.physics.particles.count": "Particle count",
18297 "solver.operation_mode.uniform_flow.u": "Uniform-flow velocity, u",
18298 "case.grid.programmatic_settings.im": "Grid nodes in i, Nᵢ",
18299 "case.grid.programmatic_settings.jm": "Grid nodes in j, Nⱼ",
18300 "case.grid.programmatic_settings.km": "Grid nodes in k, Nₖ",
18301 }
18302 return exact.get(key, _humanize_plot_identifier(key))
18303
18304
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _study_metric_label()

str picurv_cli.core._study_metric_label ( dict  study_cfg,
str  metric 
)
protected

Resolve an optional configured metric label or humanize its name.

Parameters
[in]study_cfgParsed study configuration.
[in]metricMetric column name.
Returns
Report-facing metric label.

Definition at line 18305 of file core.py.

18305def _study_metric_label(study_cfg: dict, metric: str) -> str:
18306 """!
18307 @brief Resolve an optional configured metric label or humanize its name.
18308 @param[in] study_cfg Parsed study configuration.
18309 @param[in] metric Metric column name.
18310 @return Report-facing metric label.
18311 """
18312 for raw_spec in study_cfg.get("metrics", []) or []:
18313 spec = normalize_metric_spec(raw_spec)
18314 if spec.get("name") != metric:
18315 continue
18316 label = spec.get("plot_label") or spec.get("label")
18317 units = spec.get("units")
18318 base = str(label) if label else _humanize_plot_identifier(metric)
18319 return f"{base} ({units})" if units else base
18320 return _humanize_plot_identifier(metric)
18321
18322
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _numeric_study_column()

"list | None" picurv_cli.core._numeric_study_column ( list  rows,
str  key 
)
protected

Parse one complete, finite numeric study-table column.

Parameters
[in]rowsMetrics-table rows.
[in]keyColumn name.
Returns
Numeric values, or None when the column is incomplete or nonnumeric.

Definition at line 18323 of file core.py.

18323def _numeric_study_column(rows: list, key: str) -> "list | None":
18324 """!
18325 @brief Parse one complete, finite numeric study-table column.
18326 @param[in] rows Metrics-table rows.
18327 @param[in] key Column name.
18328 @return Numeric values, or None when the column is incomplete or nonnumeric.
18329 """
18330 values = []
18331 for row in rows:
18332 try:
18333 value = float(row[key])
18334 except (KeyError, TypeError, ValueError):
18335 return None
18336 if not math.isfinite(value):
18337 return None
18338 values.append(value)
18339 return values
18340
18341
Here is the caller graph for this function:

◆ _infer_study_plot_axis()

"dict | None" picurv_cli.core._infer_study_plot_axis ( dict  study_cfg,
list  rows 
)
protected

Infer the scientifically meaningful independent variable of a study.

Parameters
[in]study_cfgParsed study configuration.
[in]rowsMetrics-table rows.
Returns
Axis metadata, or None when no numeric independent variable exists.

Definition at line 18342 of file core.py.

18342def _infer_study_plot_axis(study_cfg: dict, rows: list) -> "dict | None":
18343 """!
18344 @brief Infer the scientifically meaningful independent variable of a study.
18345 @param[in] study_cfg Parsed study configuration.
18346 @param[in] rows Metrics-table rows.
18347 @return Axis metadata, or None when no numeric independent variable exists.
18348 """
18349 params = [key for key in get_study_parameter_keys(study_cfg) if rows and key in rows[0]]
18350 if not params or not rows:
18351 return None
18352
18353 grid_keys = (
18354 "case.grid.programmatic_settings.im",
18355 "case.grid.programmatic_settings.jm",
18356 "case.grid.programmatic_settings.km",
18357 )
18358 if study_cfg.get("study_type") == "grid_independence" and all(key in params for key in grid_keys):
18359 columns = [_numeric_study_column(rows, key) for key in grid_keys]
18360 if all(column is not None for column in columns):
18361 values = [
18362 (columns[0][index] * columns[1][index] * columns[2][index]) ** (1.0 / 3.0)
18363 for index in range(len(rows))
18364 ]
18365 return {
18366 "key": "characteristic_grid_resolution",
18367 "label": "Characteristic grid resolution, (NᵢNⱼNₖ)¹⁄³",
18368 "slug": "characteristic_grid_resolution",
18369 "values": values,
18370 "contributors": set(grid_keys),
18371 }
18372
18373 preferred = []
18374 if study_cfg.get("study_type") == "timestep_independence":
18375 preferred = [key for key in params if key.endswith(".dt_physical")]
18376 numeric = []
18377 for key in preferred + [key for key in params if key not in preferred]:
18378 values = _numeric_study_column(rows, key)
18379 if values is not None:
18380 numeric.append((key, values, len(set(values))))
18381 if not numeric:
18382 return None
18383 varied = [candidate for candidate in numeric if candidate[2] > 1]
18384 key, values, _unique = (varied or numeric)[0]
18385 return {
18386 "key": key,
18387 "label": _study_parameter_label(key),
18388 "slug": re.sub(r"[^A-Za-z0-9_.-]+", "_", key),
18389 "values": values,
18390 "contributors": {key},
18391 }
18392
18393
Here is the call graph for this function:
Here is the caller graph for this function:

◆ infer_plot_x_axis()

picurv_cli.core.infer_plot_x_axis ( dict  study_cfg,
list  rows 
)

Infer x-axis key/values for study plots.

Parameters
[in]study_cfgArgument passed to infer_plot_x_axis().
[in]rowsArgument passed to infer_plot_x_axis().
Returns
Value returned by infer_plot_x_axis().

Definition at line 18394 of file core.py.

18394def infer_plot_x_axis(study_cfg: dict, rows: list):
18395 """!
18396 @brief Infer x-axis key/values for study plots.
18397 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
18398 @param[in] rows Argument passed to `infer_plot_x_axis()`.
18399 @return Value returned by `infer_plot_x_axis()`.
18400 """
18401 axis = _infer_study_plot_axis(study_cfg, rows)
18402 if axis is None:
18403 return None, None
18404 return axis["label"], axis["values"]
18405
18406
Here is the call graph for this function:

◆ _format_study_group_value()

str picurv_cli.core._format_study_group_value (   value)
protected

Format a secondary study parameter compactly for a legend.

Parameters
[in]valueParameter value.
Returns
Compact report string.

Definition at line 18407 of file core.py.

18407def _format_study_group_value(value) -> str:
18408 """!
18409 @brief Format a secondary study parameter compactly for a legend.
18410 @param[in] value Parameter value.
18411 @return Compact report string.
18412 """
18413 try:
18414 number = float(value)
18415 except (TypeError, ValueError):
18416 return str(value)
18417 if number.is_integer():
18418 return f"{int(number):,}"
18419 return f"{number:g}"
18420
18421

◆ _study_plot_groups()

list picurv_cli.core._study_plot_groups ( dict  study_cfg,
list  rows,
dict  axis,
str  metric 
)
protected

Group metric points by any secondary varied study parameters.

Parameters
[in]study_cfgParsed study configuration.
[in]rowsMetrics-table rows.
[in]axisInferred independent-axis metadata.
[in]metricMetric column name.
Returns
Labeled point groups.

Definition at line 18422 of file core.py.

18422def _study_plot_groups(study_cfg: dict, rows: list, axis: dict, metric: str) -> list:
18423 """!
18424 @brief Group metric points by any secondary varied study parameters.
18425 @param[in] study_cfg Parsed study configuration.
18426 @param[in] rows Metrics-table rows.
18427 @param[in] axis Inferred independent-axis metadata.
18428 @param[in] metric Metric column name.
18429 @return Labeled point groups.
18430 """
18431 param_keys = [key for key in get_study_parameter_keys(study_cfg) if rows and key in rows[0]]
18432 secondary = []
18433 for key in param_keys:
18434 if key in axis["contributors"]:
18435 continue
18436 values = {str(row.get(key)) for row in rows}
18437 # A control coupled one-to-one with x (for example total_steps paired
18438 # with dt to keep physical duration fixed, or particle count paired with
18439 # grid size to keep PPC fixed) describes the same study trajectory. It
18440 # must not split that trajectory into one-point pseudo-series.
18441 if 1 < len(values) < len(rows):
18442 secondary.append(key)
18443
18444 groups = {}
18445 for row_index, row in enumerate(rows):
18446 try:
18447 y_value = float(row[metric])
18448 except (KeyError, TypeError, ValueError):
18449 continue
18450 if not math.isfinite(y_value):
18451 continue
18452 identity = tuple(row.get(key) for key in secondary)
18453 groups.setdefault(identity, []).append([axis["values"][row_index], y_value])
18454
18455 result = []
18456 for identity, points in groups.items():
18457 points.sort(key=lambda point: point[0])
18458 label = ", ".join(
18459 f"{_study_parameter_label(key)} = {_format_study_group_value(value)}"
18460 for key, value in zip(secondary, identity)
18461 )
18462 result.append({"label": label or "Study cases", "points": points})
18463 return result
18464
18465
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _study_use_log_scale()

bool picurv_cli.core._study_use_log_scale ( list  values,
bool   semantic_hint = False 
)
protected

Use log scaling only for positive data spanning a meaningful range.

Parameters
[in]valuesCandidate axis values.
[in]semantic_hintWhether the quantity is conventionally read logarithmically.
Returns
True when logarithmic scaling improves interpretation.

Definition at line 18466 of file core.py.

18466def _study_use_log_scale(values: list, semantic_hint: bool = False) -> bool:
18467 """!
18468 @brief Use log scaling only for positive data spanning a meaningful range.
18469 @param[in] values Candidate axis values.
18470 @param[in] semantic_hint Whether the quantity is conventionally read logarithmically.
18471 @return True when logarithmic scaling improves interpretation.
18472 """
18473 if not values or any(value <= 0.0 for value in values):
18474 return False
18475 ratio = max(values) / min(values)
18476 return ratio >= (20.0 if semantic_hint else 100.0)
18477
18478
Here is the caller graph for this function:

◆ _study_linear_y_limits()

"tuple[float, float] | None" picurv_cli.core._study_linear_y_limits ( list  values)
protected

Build padded linear limits that include zero for non-negative metrics.

Parameters
[in]valuesFinite metric values.
Returns
Lower and upper limits, or None for an empty sequence.

Definition at line 18479 of file core.py.

18479def _study_linear_y_limits(values: list) -> "tuple[float, float] | None":
18480 """!
18481 @brief Build padded linear limits that include zero for non-negative metrics.
18482 @param[in] values Finite metric values.
18483 @return Lower and upper limits, or None for an empty sequence.
18484 """
18485 if not values:
18486 return None
18487 lower, upper = min(values), max(values)
18488 if lower >= 0.0:
18489 lower = 0.0
18490 span = upper - lower
18491 padding = max(span * 0.06, abs(upper) * 0.02, 1.0e-12)
18492 return (lower, upper + padding) if lower == 0.0 else (lower - padding, upper + padding)
18493
Here is the caller graph for this function:

◆ generate_study_plots()

picurv_cli.core.generate_study_plots ( dict  study_cfg,
str  metrics_csv,
str  plots_dir 
)

Generate metric-vs-parameter plots for completed studies.

Parameters
[in]study_cfgArgument passed to generate_study_plots().
[in]metrics_csvArgument passed to generate_study_plots().
[in]plots_dirArgument passed to generate_study_plots().
Returns
Value returned by generate_study_plots().

Definition at line 18494 of file core.py.

18494def generate_study_plots(study_cfg: dict, metrics_csv: str, plots_dir: str):
18495 """!
18496 @brief Generate metric-vs-parameter plots for completed studies.
18497 @param[in] study_cfg Argument passed to `generate_study_plots()`.
18498 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
18499 @param[in] plots_dir Argument passed to `generate_study_plots()`.
18500 @return Value returned by `generate_study_plots()`.
18501 """
18502 plotting_cfg = study_cfg.get("plotting", {}) or {}
18503 if plotting_cfg.get("enabled", True) is False:
18504 print("[INFO] Plotting disabled by study.yml.")
18505 return []
18506 plt = optional_matplotlib_pyplot()
18507 if plt is None:
18508 print("[WARNING] matplotlib not available; skipping plot generation.")
18509 return []
18510 if not metrics_csv or not os.path.isfile(metrics_csv):
18511 return []
18512
18513 with open(metrics_csv, "r", newline="") as f:
18514 reader = csv.DictReader(f)
18515 rows = list(reader)
18516 if not rows:
18517 return []
18518
18519 axis = _infer_study_plot_axis(study_cfg, rows)
18520 if axis is None:
18521 print("[WARNING] Could not infer numeric x-axis for plots; skipping.")
18522 return []
18523
18524 configured_metrics = study_cfg.get("metrics", []) or ["msd_final"]
18525 metric_keys = [
18526 normalize_metric_spec(metric).get("name", "metric") for metric in configured_metrics
18527 if normalize_metric_spec(metric).get("name", "metric") in rows[0]
18528 ]
18529
18530 out_format = plotting_cfg.get("output_format", "png")
18531 os.makedirs(plots_dir, exist_ok=True)
18532 generated = []
18533 for metric in metric_keys:
18534 groups = _study_plot_groups(study_cfg, rows, axis, metric)
18535 if not groups:
18536 continue
18537 all_x = [point[0] for group in groups for point in group["points"]]
18538 all_y = [point[1] for group in groups for point in group["points"]]
18539 metric_label = _study_metric_label(study_cfg, metric)
18540 metric_hint = metric.lower()
18541 y_log = _study_use_log_scale(
18542 all_y,
18543 any(token in metric_hint for token in ("error", "residual", "drift", "msd")),
18544 )
18545 x_log = _study_use_log_scale(all_x)
18546 grid_cross_product = (
18547 study_cfg.get("study_type") == "grid_independence"
18548 and bool(study_cfg.get("parameters")) and len(axis["contributors"]) == 3
18549 and sum(
18550 len(set(float(row[key]) for row in rows)) > 1
18551 for key in axis["contributors"]
18552 ) > 1
18553 )
18554
18555 plt.figure(figsize=(9.2, 5.5), facecolor="white")
18556 for index, group in enumerate(groups):
18557 x_values = [point[0] for point in group["points"]]
18558 y_values = [point[1] for point in group["points"]]
18559 repeated_x = len(set(x_values)) != len(x_values)
18560 plt.plot(
18561 x_values, y_values,
18562 color=_STUDY_REPORT_COLORS[index % len(_STUDY_REPORT_COLORS)],
18563 marker="o", markersize=5.2, markeredgewidth=0.7,
18564 linewidth=0.0 if repeated_x or grid_cross_product else 1.8,
18565 linestyle="none" if repeated_x or grid_cross_product else "-",
18566 label=group["label"],
18567 )
18568 plt.xlabel(axis["label"], fontsize=11)
18569 plt.ylabel(metric_label, fontsize=11)
18570 study_label = {
18571 "grid_independence": "Grid-independence study",
18572 "timestep_independence": "Timestep-independence study",
18573 "sensitivity": "Sensitivity study",
18574 }.get(study_cfg.get("study_type"), "Parameter study")
18575 plt.title(f"{metric_label} vs. {axis['label']}\n{study_label}", fontsize=13, loc="left", pad=12)
18576 if x_log and hasattr(plt, "xscale"):
18577 plt.xscale("log")
18578 if y_log:
18579 plt.yscale("log")
18580 elif hasattr(plt, "ylim"):
18581 limits = _study_linear_y_limits(all_y)
18582 if limits:
18583 plt.ylim(*limits)
18584 if hasattr(plt, "tick_params"):
18585 plt.tick_params(axis="both", which="major", labelsize=9.5)
18586 plt.grid(True, alpha=0.24, which="major", linewidth=0.7)
18587 if x_log or y_log:
18588 plt.grid(True, alpha=0.08, which="minor", linewidth=0.5)
18589 if len(groups) > 1:
18590 plt.legend(
18591 loc="upper left", bbox_to_anchor=(1.01, 1.0), borderaxespad=0.0,
18592 frameon=False, title="Fixed parameters", fontsize=9, title_fontsize=9.5,
18593 )
18594 plt.tight_layout(rect=(0.0, 0.0, 0.76, 1.0))
18595 else:
18596 plt.tight_layout()
18597 safe_metric = re.sub(r"[^A-Za-z0-9_.-]+", "_", metric)
18598 out_path = os.path.join(plots_dir, f"{safe_metric}_vs_{axis['slug']}.{out_format}")
18599 plt.savefig(out_path, dpi=240, bbox_inches="tight", facecolor="white")
18600 plt.close()
18601 generated.append(out_path)
18602 if generated:
18603 print(f"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
18604 return generated
18605
18606
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _command_to_string()

str picurv_cli.core._command_to_string ( list  command_tokens)
protected

Render a command list as a shell-safe display string.

Parameters
[in]command_tokensArgument passed to _command_to_string().
Returns
Value returned by _command_to_string().

Definition at line 18607 of file core.py.

18607def _command_to_string(command_tokens: list) -> str:
18608 """!
18609 @brief Render a command list as a shell-safe display string.
18610 @param[in] command_tokens Argument passed to `_command_to_string()`.
18611 @return Value returned by `_command_to_string()`.
18612 """
18613 return " ".join(shlex.quote(str(tok)) for tok in command_tokens)
18614
18615
Here is the caller graph for this function:

◆ _resolve_post_source_directory_preview()

str picurv_cli.core._resolve_post_source_directory_preview ( str  run_dir,
dict  monitor_cfg,
dict  post_cfg 
)
protected

Resolve post source directory without side effects or stdout/stderr output.

Parameters
[in]run_dirArgument passed to _resolve_post_source_directory_preview().
[in]monitor_cfgArgument passed to _resolve_post_source_directory_preview().
[in]post_cfgArgument passed to _resolve_post_source_directory_preview().
Returns
Value returned by _resolve_post_source_directory_preview().

Definition at line 18616 of file core.py.

18616def _resolve_post_source_directory_preview(run_dir: str, monitor_cfg: dict, post_cfg: dict) -> str:
18617 """!
18618 @brief Resolve post source directory without side effects or stdout/stderr output.
18619 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
18620 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
18621 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
18622 @return Value returned by `_resolve_post_source_directory_preview()`.
18623 """
18624 solver_output_dir_abs = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
18625 source_dir_template = get_post_source_directory_template(post_cfg)
18626 if source_dir_template == '<solver_output_dir>':
18627 return solver_output_dir_abs
18628 return os.path.abspath(os.path.join(run_dir, source_dir_template))
18629
18630
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_run_dry_plan()

dict picurv_cli.core.build_run_dry_plan (   args)

Build a no-write execution plan for run --dry-run.

Parameters
[in]argsCommand-line style argument list supplied to the function.
Returns
Value returned by build_run_dry_plan().

Definition at line 18631 of file core.py.

18631def build_run_dry_plan(args) -> dict:
18632 """!
18633 @brief Build a no-write execution plan for `run --dry-run`.
18634 @param[in] args Command-line style argument list supplied to the function.
18635 @return Value returned by `build_run_dry_plan()`.
18636 """
18637 plan = {
18638 "mode": "dry-run",
18639 "created_at": datetime.now().isoformat(),
18640 "warnings": [],
18641 "inputs": {},
18642 "stages": {},
18643 "artifacts": [],
18644 }
18645
18646 if args.dry_run and args.no_submit:
18647 plan["warnings"].append("--dry-run takes precedence over --no-submit; no files will be written.")
18648
18649 cluster_mode = bool(getattr(args, "cluster", None))
18650 cluster_cfg = None
18651 cluster_path = None
18652 solver_num_procs_effective = args.num_procs
18653 post_num_procs_effective = args.num_procs
18654 run_id = None
18655 run_dir = None
18656 solver_control_path = None
18657 loaded_case_cfg = None
18658 loaded_monitor_cfg = None
18659 resolved_restart_source_dir = None
18660
18661 if cluster_mode:
18662 cluster_path = os.path.abspath(args.cluster)
18663 cluster_cfg = read_yaml_file(cluster_path)
18664 validate_cluster_config(cluster_cfg, cluster_path)
18665 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
18666 if args.scheduler and args.scheduler.lower() != scheduler_type:
18667 emit_structured_error(
18668 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18669 key="scheduler.type",
18670 file_path=cluster_path,
18671 message=f"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
18672 )
18673 sys.exit(1)
18674 if scheduler_type != "slurm":
18675 emit_structured_error(
18676 ERROR_CODE_CFG_INVALID_VALUE,
18677 key="scheduler.type",
18678 file_path=cluster_path,
18679 message=f"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported.",
18680 )
18681 sys.exit(1)
18682 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
18683 if (args.solve or args.post_process) and args.num_procs not in (1, cluster_tasks):
18684 emit_structured_error(
18685 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18686 key="resources.ntasks_per_node",
18687 file_path=cluster_path,
18688 message=(
18689 "--num-procs must be 1 (auto) or "
18690 f"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
18691 ),
18692 )
18693 sys.exit(1)
18694 if args.solve:
18695 solver_num_procs_effective = cluster_tasks
18696 if args.post_process:
18697 post_num_procs_effective = cluster_tasks
18698 plan["launch_mode"] = "slurm"
18699 plan["inputs"]["cluster"] = cluster_path
18700 else:
18701 if getattr(args, "scheduler", None):
18702 fail_cli_usage("--scheduler requires --cluster in this version.")
18703 plan["launch_mode"] = "local"
18704
18705 # --- Guard: restart flags without --solve ---
18706 if not args.solve:
18707 if getattr(args, 'restart_from', None):
18708 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
18709 if getattr(args, 'continue_run', False):
18710 print("[WARNING] --continue only extends a solve in place and is ignored here. "
18711 "Post-processing always skips steps whose output is up to date; drop "
18712 "--continue from post-only commands.", file=sys.stderr)
18713
18714 if args.solve:
18715 case_path = os.path.abspath(args.case)
18716 workspace_root = find_workspace_root(case_path, args.solver, args.monitor, os.getcwd())
18717 if workspace_root:
18718 enforce_workspace_version(workspace_root)
18719 enforce_reproducibility_policy(workspace_root)
18720 solver_path = os.path.abspath(args.solver)
18721 monitor_path = os.path.abspath(args.monitor)
18722 loaded_case_cfg = read_yaml_file(case_path)
18723 solver_cfg = read_yaml_file(solver_path)
18724 loaded_monitor_cfg = read_yaml_file(monitor_path)
18725 validate_simulation_configs(
18726 loaded_case_cfg, solver_cfg, loaded_monitor_cfg, case_path, solver_path, monitor_path,
18727 restart_source_given=bool(getattr(args, 'restart_from', None) or getattr(args, 'continue_run', False)),
18728 )
18729
18730 continue_mode = getattr(args, 'continue_run', False)
18731
18732 if continue_mode:
18733 # --continue reuses existing run directory
18734 if not args.run_dir:
18735 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
18736 run_dir = os.path.abspath(args.run_dir)
18737 if not os.path.isdir(run_dir):
18738 emit_structured_error(
18739 ERROR_CODE_CFG_FILE_NOT_FOUND,
18740 key="run-dir",
18741 file_path=run_dir,
18742 message="Specified run directory not found.",
18743 )
18744 sys.exit(1)
18745 adopt_relocated_artifact(run_dir, dry_run=True)
18746 run_id = os.path.basename(run_dir)
18747 else:
18748 runs_root = workspace_artifact_root(workspace_root, "runs")
18749 run_id = allocate_generated_run_id(runs_root, loaded_case_cfg, case_path)
18750 run_dir = os.path.join(runs_root, run_id)
18751
18752 try:
18753 resolved_restart_source_dir, is_continue, planned_lineage = resolve_restart_source(
18754 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir,
18755 materialize=False,
18756 )
18757 plan["lineage"] = planned_lineage or {"relationship": "root"}
18758 planned_index_bytes = select_run_petsc_index_bytes(
18759 args, loaded_case_cfg, case_path, loaded_monitor_cfg, resolved_restart_source_dir)
18760 except ValueError as e:
18761 emit_structured_error(
18762 ERROR_CODE_CFG_INCONSISTENT_COMBO,
18763 key="restart",
18764 file_path=case_path,
18765 message=str(e),
18766 hint=_STORAGE_RESTORE_HINT if isinstance(e.__cause__, StorageError) else _RESTART_HINT,
18767 )
18768 sys.exit(1)
18769
18770 config_dir = os.path.join(run_dir, "config")
18771 scheduler_dir = os.path.join(run_dir, "scheduler")
18772 # Report the directories the run will ACTUALLY use, not the defaults. A plan
18773 # that hides a configured path cannot warn about where output really lands -
18774 # and the fresh-run log directory is deleted recursively by the C runtime.
18775 _plan_dirs = {}
18776 logs_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"])
18777 planned_output_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS["output"])
18778 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
18779 profile_path = os.path.join(config_dir, "profile.run")
18780 profiling_preview = resolve_profiling_config(loaded_monitor_cfg)
18781
18782 plan["run_id_preview"] = run_id
18783 plan["run_dir_preview"] = run_dir
18784 # A layout the coarsest multigrid level cannot hold aborts grid setup on the
18785 # cluster, after the queue wait; it is decidable here from the grid's size.
18786 block_dims = resolve_grid_node_dims(
18787 loaded_case_cfg, case_path,
18788 os.path.join(run_dir, "inputs", "grid", "grid.run") if continue_mode else None)
18789 decomposition = resolve_decomposition(
18790 solver_num_procs_effective, loaded_case_cfg.get("grid") or {}, block_dims, solver_cfg,
18791 case_declares_periodic_axis(loaded_case_cfg), getattr(args, "decomposition", None),
18792 cluster_cfg if cluster_mode else None)
18793 plan.setdefault("blocking", []).extend(decomposition["problems"])
18794 plan["decomposition"] = {"layout": list(decomposition["layout"]) if decomposition.get("layout") else None,
18795 "source": decomposition["source"],
18796 "summary": describe_decomposition(decomposition, solver_num_procs_effective)}
18797 if block_dims is None and solver_num_procs_effective > 1:
18798 plan["warnings"].append(
18799 "Could not determine the grid's dimensions without building it, so the "
18800 "decomposition was not checked against the multigrid levels.")
18801 # A plan that omits what a real run would refuse is not a plan. The run
18802 # directory does not exist yet, but `realpath` still normalizes it, so the
18803 # run-root and ancestor verdicts are decidable here.
18804 _authorized, _ = resolve_unsafe_paths_override(_plan_dirs, "monitor.yml")
18805 plan.setdefault("blocking", [])
18806 # A run resumed into a directory that grew an unrouted peer would be refused,
18807 # so the plan has to say so rather than promising a launch that will not happen.
18808 structure_errors, structure_warnings = validate_run_directory_structure(run_dir)
18809 plan["blocking"].extend(structure_errors)
18810 plan["warnings"].extend(structure_warnings)
18811 for _key, _verdict, _message in classify_physical_containment(
18812 run_dir, effective_run_directories(_plan_dirs)):
18813 if _authorized and _verdict in WAIVABLE_PHYSICAL_VERDICTS:
18814 plan["warnings"].append(f"{_message} Allowed only because "
18815 f"'allow_unsafe_paths: true' is set.")
18816 else:
18817 plan["blocking"].append(_message)
18818 plan["inputs"].update({"case": case_path, "solver": solver_path, "monitor": monitor_path})
18819 asset_plan = plan_run_assets(
18820 loaded_case_cfg, case_path, unused_asset_kinds(loaded_case_cfg, solver_cfg)
18821 )
18822 plan["asset_actions"] = [
18823 {"kind": item["kind"], "provider": item["provider"], "action": item["action"]}
18824 for item in asset_plan["actions"]
18825 ]
18826 runtime_kinds = {
18827 item["kind"] for item in asset_plan["actions"]
18828 if item["execution"] == "runtime-c"
18829 }
18830 for item in asset_plan["actions"]:
18831 blocked_dependencies = runtime_kinds.intersection(item.get("dependencies", []))
18832 if item["execution"] == "precomputable" and blocked_dependencies:
18833 plan["blocking"].append(
18834 f"{item['kind']}={item['provider']} requires a file-backed "
18835 f"{', '.join(sorted(blocked_dependencies))}, but that dependency is generated "
18836 "only inside the simulator. Select a precomputable provider for the dependency."
18837 )
18838 plan["artifacts"].extend(
18839 [
18840 run_dir,
18841 *(os.path.join(run_dir, *relative.split("/"))
18842 for relative in RUN_DIRECTORY_LAYOUT),
18843 os.path.join(config_dir, "case.yml"),
18844 os.path.join(config_dir, "solver.yml"),
18845 os.path.join(config_dir, "monitor.yml"),
18846 os.path.join(config_dir, "active.json"),
18847 os.path.join(run_dir, CANONICAL_RUN_PATHS["inputs"], "assets.lock.yml"),
18848 solver_control_path,
18849 os.path.join(run_dir, "manifest.json"),
18850 ]
18851 )
18852 add_planned_grid_artifacts(plan, loaded_case_cfg, run_dir)
18853 add_planned_profile_artifacts(plan, loaded_case_cfg, run_dir)
18854 add_planned_initial_condition_artifacts(plan, loaded_case_cfg, solver_cfg, run_dir)
18855 if has_explicit_monitor_whitelist(loaded_monitor_cfg):
18856 plan["artifacts"].append(os.path.join(config_dir, "whitelist.run"))
18857 if profiling_preview["mode"] == "selected":
18858 plan["artifacts"].append(profile_path)
18859 solve_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "Solver")
18860 plan["artifacts"].extend(solve_diagnostics["artifacts"])
18861 if cluster_mode:
18862 plan["artifacts"].append(os.path.join(config_dir, "cluster.yml"))
18863 plan["artifacts"].append(os.path.join(scheduler_dir, "submission.json"))
18864
18865 executable_plan = plan_run_executable_paths(run_dir, args, workspace_root)
18866 solver_exe = executable_plan["paths"]["simulator"]
18867 if executable_plan["pinning"] == "new":
18868 plan["artifacts"].extend(executable_plan["paths"].values())
18869 solver_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "Solver") + ["-control_file", solver_control_path]
18870 if cluster_mode:
18871 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
18872 solver_cmd = build_cluster_launch_command(
18873 cluster_cfg,
18874 solver_exe,
18875 solver_args,
18876 config_search_anchor=case_path,
18877 extra_search_anchors=[cluster_path],
18878 )
18879 plan["artifacts"].append(solver_script)
18880 plan["stages"]["solve"] = {
18881 "mode": "slurm",
18882 "script": solver_script,
18883 "num_procs_effective": solver_num_procs_effective,
18884 "launch_command": solver_cmd,
18885 "launch_command_string": _command_to_string(solver_cmd),
18886 }
18887 else:
18888 solver_cmd = build_local_launch_command(
18889 solver_exe,
18890 solver_args,
18891 solver_num_procs_effective,
18892 config_search_anchor=case_path,
18893 )
18894 solver_stream_log = os.path.join(scheduler_dir, f"{run_id}_solver.log")
18895 plan["artifacts"].append(solver_stream_log)
18896 plan["stages"]["solve"] = {
18897 "mode": "local",
18898 "num_procs_effective": solver_num_procs_effective,
18899 "stream_log": solver_stream_log,
18900 "launch_command": solver_cmd,
18901 "launch_command_string": _command_to_string(solver_cmd),
18902 }
18903 plan["stages"]["solve"]["executable_pinning"] = executable_plan["pinning"]
18904 plan["stages"]["solve"]["petsc_index_bits"] = 8 * planned_index_bytes
18905 planned_run_control = loaded_case_cfg.get("run_control") or {}
18906 plan["stages"]["solve"]["start_step"] = int(planned_run_control.get("start_step", 0) or 0)
18907 plan["stages"]["solve"]["end_step"] = (plan["stages"]["solve"]["start_step"]
18908 + int(planned_run_control.get("total_steps", 0) or 0))
18909 if resolved_restart_source_dir:
18910 plan["stages"]["solve"]["restart_source_directory"] = resolved_restart_source_dir
18911 if is_continue:
18912 plan["stages"]["solve"]["continue_mode"] = True
18913
18914 if args.post_process:
18915 post_path = os.path.abspath(args.post)
18916 plan["inputs"]["post"] = post_path
18917 post_cfg = read_yaml_file(post_path)
18918 validate_post_config(post_cfg, post_path, loaded_monitor_cfg, loaded_case_cfg)
18919
18920 if args.run_dir:
18921 run_dir = os.path.abspath(args.run_dir)
18922 if not os.path.isdir(run_dir):
18923 emit_structured_error(
18924 ERROR_CODE_CFG_FILE_NOT_FOUND,
18925 key="run-dir",
18926 file_path=run_dir,
18927 message="Specified run directory not found.",
18928 )
18929 sys.exit(1)
18930 adopt_relocated_artifact(run_dir, dry_run=True)
18931 run_id = os.path.basename(run_dir)
18932 elif not args.solve:
18933 fail_cli_usage("--post-process requires --run-dir when not used with --solve.")
18934
18935 if args.run_dir:
18936 config_dir = os.path.join(run_dir, "config")
18937 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
18938 if not all([case_path, monitor_path, solver_control_path]):
18939 emit_structured_error(
18940 ERROR_CODE_CFG_MISSING_KEY,
18941 key="run_dir.config",
18942 file_path=config_dir,
18943 message=(
18944 "Could not auto-identify required run inputs "
18945 "(case.yml/monitor.yml/*.control) in run config directory."
18946 ),
18947 )
18948 sys.exit(1)
18949 loaded_case_cfg = read_yaml_file(case_path)
18950 loaded_monitor_cfg = read_yaml_file(monitor_path)
18951 else:
18952 config_dir = os.path.join(run_dir, "config")
18953 case_path = os.path.join(config_dir, "case.yml")
18954 monitor_path = os.path.join(config_dir, "monitor.yml")
18955 if solver_control_path is None:
18956 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
18957
18958 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
18959 allow_source_frontier_scan = not args.solve
18960 recompute = bool(getattr(args, 'recompute_post', False))
18961 if args.solve:
18962 post_exe = executable_plan["paths"]["postprocessor"]
18963 else:
18964 try:
18965 select_existing_run_petsc_index_bytes(run_dir, args)
18966 except ValueError as e:
18967 emit_structured_error(ERROR_CODE_CFG_INCONSISTENT_COMBO, key="petsc_index_bits",
18968 file_path=run_dir, message=str(e))
18969 sys.exit(1)
18970 post_exe = run_executable_path(run_dir, "postprocessor")
18971 post_plan = build_post_execution_plan(
18972 run_dir,
18973 run_id,
18974 loaded_case_cfg,
18975 loaded_monitor_cfg,
18976 post_cfg,
18977 recompute=recompute,
18978 allow_source_frontier_scan=allow_source_frontier_scan,
18979 postprocessor_sha=_file_sha256(post_exe),
18980 )
18981
18982 if args.solve:
18983 post_decomposition = resolve_decomposition(
18984 post_num_procs_effective, loaded_case_cfg.get("grid") or {}, block_dims, solver_cfg,
18985 case_declares_periodic_axis(loaded_case_cfg), getattr(args, "decomposition", None),
18986 cluster_cfg if cluster_mode else None)
18987 else:
18988 post_decomposition = resolve_post_decomposition(
18989 run_dir, post_num_procs_effective, getattr(args, "decomposition", None),
18990 cluster_cfg if cluster_mode else None)
18991 plan.setdefault("blocking", []).extend(
18992 problem for problem in post_decomposition["problems"] if problem not in plan["blocking"])
18993 plan["post_decomposition"] = {
18994 "layout": list(post_decomposition["layout"]) if post_decomposition.get("layout") else None,
18995 "source": post_decomposition["source"],
18996 "summary": describe_decomposition(post_decomposition, post_num_procs_effective)}
18997
18998 post_recipe_path = os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.run")
18999 archived_post_path = os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.yml")
19000 post_finalize_command = build_post_finalize_command(run_dir, archived_post_path, post_cfg)
19001 output_dir_rel = post_cfg.get("io", {}).get("output_directory")
19002 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix")
19003 if not output_prefix:
19004 emit_structured_error(
19005 ERROR_CODE_CFG_MISSING_KEY,
19006 key="io.output_filename_prefix",
19007 file_path=post_path,
19008 message="Missing required post output filename prefix.",
19009 )
19010 sys.exit(1)
19011 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
19012 statistics_output_paths = get_post_statistics_output_artifacts(post_cfg, run_dir, loaded_monitor_cfg)
19013 post_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "PostProcessor")
19014 plan["artifacts"].extend(post_diagnostics["artifacts"])
19015 post_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "PostProcessor") + [
19016 "-control_file",
19017 solver_control_path,
19018 "-postprocessing_config_file",
19019 post_recipe_path,
19020 ]
19021 plan["artifacts"].extend([
19022 post_recipe_path,
19023 archived_post_path,
19024 output_dir_abs,
19025 post_plan["resume_state_path"],
19026 post_plan["lock_paths"]["wrapper_path"],
19027 post_plan["lock_paths"]["lock_file"],
19028 post_plan["lock_paths"]["metadata_file"],
19029 ])
19030 plan["artifacts"].extend(statistics_output_paths)
19031 if post_finalize_command:
19032 io_cfg = post_cfg.get("io", {}) or {}
19033 if _post_requests_eulerian_output(post_cfg):
19034 plan["artifacts"].append(os.path.join(
19035 output_dir_abs, f"{io_cfg.get('output_filename_prefix', 'Field')}.pvd"
19036 ))
19037 if _post_requests_particle_output(post_cfg):
19038 plan["artifacts"].append(os.path.join(
19039 output_dir_abs, f"{io_cfg.get('particle_filename_prefix', 'Particle')}.pvd"
19040 ))
19041
19042 stage_meta = {
19043 "source_data_directory": post_plan["source_data_directory"],
19044 "requested_start_step": post_plan["requested_start_step"],
19045 "requested_end_step": post_plan["requested_end_step"],
19046 "step_interval": post_plan["step_interval"],
19047 "recompute": recompute,
19048 "steps_to_process": post_plan["steps_to_process"],
19049 "steps_current": post_plan["steps_current"],
19050 "steps_other_build": post_plan["steps_other_build"],
19051 "steps_unavailable": post_plan["steps_unavailable"],
19052 "process_reasons": post_plan["process_reasons"],
19053 "source_frontier_step": post_plan["source_frontier_step"],
19054 "source_frontier_diagnostic": post_plan["source_frontier_diagnostic"],
19055 "source_frontier_deferred": post_plan["source_frontier_deferred"],
19056 "effective_start_step": post_plan["effective_start_step"],
19057 "effective_end_step": post_plan["effective_end_step"],
19058 "skip_reason": post_plan["skip_reason"],
19059 "post_skipped_as_complete": post_plan["skip_reason"] == "already-complete-window",
19060 "recipe_fingerprint": post_plan["recipe_fingerprint"],
19061 "num_procs_effective": post_num_procs_effective,
19062 "finalize_command": post_finalize_command,
19063 }
19064
19065 if post_plan["skip_reason"] is None:
19066 if cluster_mode:
19067 scheduler_dir = os.path.join(run_dir, "scheduler")
19068 post_script = os.path.join(scheduler_dir, "post.sbatch")
19069 post_cluster_cfg = cluster_cfg
19070 raw_post_cmd = build_cluster_launch_command(
19071 post_cluster_cfg,
19072 post_exe,
19073 post_args,
19074 config_search_anchor=case_path,
19075 extra_search_anchors=[cluster_path],
19076 force_num_procs=post_num_procs_effective,
19077 )
19078 post_cmd, _ = build_post_locked_command(
19079 run_dir,
19080 post_plan["recipe_fingerprint"],
19081 raw_post_cmd,
19082 create_wrapper=False,
19083 success_command=post_finalize_command,
19084 post_recipe=archived_post_path,
19085 postprocessor=post_exe,
19086 recompute=recompute,
19087 )
19088 plan["artifacts"].append(post_script)
19089 stage_meta.update({
19090 "mode": "slurm",
19091 "script": post_script,
19092 "launch_command": post_cmd,
19093 "launch_command_string": _command_to_string(post_cmd),
19094 })
19095 else:
19096 raw_post_cmd = build_local_launch_command(
19097 post_exe,
19098 post_args,
19099 post_num_procs_effective,
19100 config_search_anchor=case_path,
19101 allow_single_rank_launcher_override=True,
19102 force_num_procs=post_num_procs_effective,
19103 )
19104 post_cmd, _ = build_post_locked_command(
19105 run_dir,
19106 post_plan["recipe_fingerprint"],
19107 raw_post_cmd,
19108 create_wrapper=False,
19109 success_command=post_finalize_command,
19110 post_recipe=archived_post_path,
19111 postprocessor=post_exe,
19112 recompute=recompute,
19113 )
19114 post_stream_log = os.path.join(run_dir, "scheduler", f"{run_id}_{output_prefix}.log")
19115 plan["artifacts"].append(post_stream_log)
19116 stage_meta.update({
19117 "mode": "local",
19118 "stream_log": post_stream_log,
19119 "launch_command": post_cmd,
19120 "launch_command_string": _command_to_string(post_cmd),
19121 })
19122 else:
19123 stage_meta.update({
19124 "mode": "slurm" if cluster_mode else "local",
19125 "launch_command": [],
19126 "launch_command_string": "",
19127 })
19128
19129 plan["stages"]["post-process"] = stage_meta
19130
19131 # Preserve insertion order while removing duplicates.
19132 deduped = []
19133 seen = set()
19134 for item in plan["artifacts"]:
19135 if item not in seen:
19136 seen.add(item)
19137 deduped.append(item)
19138 plan["artifacts"] = deduped
19139 if run_id and "run_id_preview" not in plan:
19140 plan["run_id_preview"] = run_id
19141 if run_dir and "run_dir_preview" not in plan:
19142 plan["run_dir_preview"] = run_dir
19143 plan["solver_num_procs_effective"] = solver_num_procs_effective
19144 plan["post_num_procs_effective"] = post_num_procs_effective
19145 plan["num_procs_effective"] = solver_num_procs_effective
19146 return plan
19147
19148
Here is the call graph for this function:
Here is the caller graph for this function:

◆ add_planned_grid_artifacts()

None picurv_cli.core.add_planned_grid_artifacts ( dict  plan,
dict  case_cfg,
str  run_dir 
)

Add grid-mode-specific staged artifacts to a dry-run plan.

Parameters
[in,out]planDry-run plan to update.
[in]case_cfgParsed case configuration.
[in]run_dirPreview run directory for relative artifact resolution.

Definition at line 19149 of file core.py.

19149def add_planned_grid_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
19150 """!
19151 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
19152 @param[in,out] plan Dry-run plan to update.
19153 @param[in] case_cfg Parsed case configuration.
19154 @param[in] run_dir Preview run directory for relative artifact resolution.
19155 """
19156 grid_cfg = case_cfg.get("grid", {})
19157 if not isinstance(grid_cfg, dict):
19158 return
19159
19160 mode = grid_cfg.get("mode")
19161 grid_dir = os.path.join(run_dir, "inputs", "grid")
19162
19163 if mode == "file":
19164 plan["artifacts"].append(os.path.join(grid_dir, "grid.run"))
19165 elif mode == "grid_gen":
19166 generator = grid_cfg.get("generator", {})
19167 if not isinstance(generator, dict):
19168 return
19169 plan["artifacts"].extend([
19170 os.path.join(grid_dir, "grid.run"),
19171 os.path.join(grid_dir, "grid.generated.picgrid"),
19172 os.path.join(run_dir, "output", "analysis", "metrics", "grid.info"),
19173 os.path.join(run_dir, "output", "visualization", "precompute", "grid.vts"),
19174 ])
19175
Here is the caller graph for this function:

◆ add_planned_profile_artifacts()

None picurv_cli.core.add_planned_profile_artifacts ( dict  plan,
dict  case_cfg,
str  run_dir 
)

Add generated prescribed-flow profile artifacts to a dry-run plan.

Parameters
[in,out]planDry-run plan to update.
[in]case_cfgParsed case configuration.
[in]run_dirPreview run directory.

Definition at line 19176 of file core.py.

19176def add_planned_profile_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
19177 """!
19178 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
19179 @param[in,out] plan Dry-run plan to update.
19180 @param[in] case_cfg Parsed case configuration.
19181 @param[in] run_dir Preview run directory.
19182 """
19183 try:
19184 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
19185 except ValueError:
19186 return
19187 profile_dir = os.path.join(run_dir, "inputs", "inlet_profiles")
19188 has_generated = False
19189 for block_idx, block in enumerate(prepared_blocks):
19190 for bc in block:
19191 if bc.get("handler") != "prescribed_flow":
19192 continue
19193 source = (bc.get("params") or {}).get("source", {})
19194 if source.get("type") not in PRESCRIBED_FLOW_SOURCE_TYPES[1:]:
19195 continue
19196 has_generated = True
19197 face_token = _face_artifact_token(bc["face"])
19198 suffix = "generated" if source.get("type") == "generated" else "sliced"
19199 generated_path = os.path.join(
19200 profile_dir, f"inlet_profile_block{block_idx}_{face_token}.{suffix}.dimensional.picslice"
19201 )
19202 staged_path = os.path.join(profile_dir, f"inlet_profile_block{block_idx}_{face_token}.picslice")
19203 plan["artifacts"].append(generated_path)
19204 plan["artifacts"].append(staged_path)
19205 if has_generated:
19206 plan["artifacts"].append(os.path.join(profile_dir, "profile.info"))
19207
Here is the call graph for this function:
Here is the caller graph for this function:

◆ add_planned_initial_condition_artifacts()

None picurv_cli.core.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.

Parameters
[in,out]planDry-run plan receiving artifact paths.
[in]case_cfgParsed case configuration.
[in]solver_cfgParsed solver configuration.
[in]run_dirPlanned run directory.

Definition at line 19208 of file core.py.

19208def add_planned_initial_condition_artifacts(plan: dict, case_cfg: dict, solver_cfg: dict, run_dir: str) -> None:
19209 """!
19210 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
19211 @param[in,out] plan Dry-run plan receiving artifact paths.
19212 @param[in] case_cfg Parsed case configuration.
19213 @param[in] solver_cfg Parsed solver configuration.
19214 @param[in] run_dir Planned run directory.
19215 """
19216 source = normalize_eulerian_field_source(
19217 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
19218 )
19219 start_step = int((case_cfg.get("run_control", {}) or {}).get("start_step", 0) or 0)
19220 if source != "solve" or start_step != 0:
19221 return
19222 try:
19223 fluid_scaling = resolve_fluid_scaling(case_cfg)
19224 resolved = resolve_initial_condition_config(
19225 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
19226 validate_and_prepare_boundary_conditions(case_cfg),
19227 scales=fluid_scaling,
19228 provider_context={"kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]},
19229 )
19230 except (KeyError, ValueError):
19231 return
19232 if resolved["kind"] != "file" and not is_generated_ic_provider(resolved):
19233 return
19234 initial_dir = os.path.join(run_dir, "inputs", "initial_condition")
19235 plan["artifacts"].append(
19236 os.path.join(initial_dir, f"{resolved['field_name']}00000_0.dat")
19237 )
19238 if is_generated_ic_provider(resolved):
19239 if (case_cfg.get("grid", {}) or {}).get("mode") == "programmatic_c":
19240 plan["artifacts"].append(os.path.join(run_dir, "inputs", "grid", "grid.run"))
19241 plan["artifacts"].append(os.path.join(initial_dir, "initial_condition.generated.dat"))
19242 plan["artifacts"].extend(initial_condition_diagnostic_paths(run_dir, resolved))
19243
19244
Here is the call graph for this function:
Here is the caller graph for this function:

◆ render_run_dry_plan()

picurv_cli.core.render_run_dry_plan ( dict  plan,
str   output_format = "text" 
)

Render dry-run plan in human or JSON format.

Parameters
[in]planDry-run plan produced by build_run_dry_plan().
[in]output_formatEither "text" or "json".
Returns
1 when the plan carries blocking findings, 0 otherwise.

Definition at line 19245 of file core.py.

19245def render_run_dry_plan(plan: dict, output_format: str = "text"):
19246 """!
19247 @brief Render dry-run plan in human or JSON format.
19248 @param[in] plan Dry-run plan produced by build_run_dry_plan().
19249 @param[in] output_format Either "text" or "json".
19250 @return 1 when the plan carries blocking findings, 0 otherwise.
19251 """
19252 if output_format == "json":
19253 print(json.dumps(plan, indent=2, sort_keys=True))
19254 return 1 if plan.get("blocking") else 0
19255
19256 for message in plan.get("warnings", []):
19257 print(f"[WARNING] {message}", file=sys.stderr)
19258 if plan.get("blocking"):
19259 print("[FATAL] This configuration would be refused. The plan below is what the "
19260 "run WOULD do; it will not get that far:", file=sys.stderr)
19261 for message in plan["blocking"]:
19262 print(f" {message}", file=sys.stderr)
19263
19264 print("\n" + "=" * 60)
19265 print(" DRY-RUN PLAN")
19266 print("=" * 60)
19267 print(f" Launch mode : {plan.get('launch_mode')}")
19268 print(f" Created at : {plan.get('created_at')}")
19269 if plan.get("run_id_preview"):
19270 print(f" Run ID preview : {plan.get('run_id_preview')}")
19271 if plan.get("run_dir_preview"):
19272 print(f" Run dir preview : {plan.get('run_dir_preview')}")
19273 lineage = plan.get("lineage") or {}
19274 if lineage.get("relationship") == "branch":
19275 print(f" Branched from : {lineage.get('parent_run_id')} "
19276 f"@ step {lineage.get('checkpoint_step')} "
19277 f"(statistics: {lineage.get('statistics_state')})")
19278 planned_stages = plan.get("stages") or {}
19279 if "solve" in planned_stages:
19280 print(f" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
19281 if "start_step" in planned_stages["solve"]:
19282 print(f" Solver steps : {planned_stages['solve']['start_step']} -> "
19283 f"{planned_stages['solve']['end_step']}")
19284 if "post-process" in planned_stages:
19285 print(f" Post MPI procs : {plan.get('post_num_procs_effective')}")
19286 for key, label in (("decomposition", "Solver"), ("post_decomposition", "Post")):
19287 if plan.get(key):
19288 print(f" {label} {plan[key]['summary']}")
19289 if plan.get("warnings"):
19290 print(" Warnings :")
19291 for warning in plan["warnings"]:
19292 print(f" - {warning}")
19293
19294 if plan.get("inputs"):
19295 print("\n Inputs:")
19296 for key, value in plan["inputs"].items():
19297 print(f" - {key}: {value}")
19298
19299 if plan.get("stages"):
19300 print("\n Planned stage commands:")
19301 for stage, details in plan["stages"].items():
19302 print(f" - {stage} ({details.get('mode')}):")
19303 if details.get('skip_reason'):
19304 print(f" skipped: {details.get('skip_reason')}")
19305 else:
19306 print(f" {details.get('launch_command_string')}")
19307
19308 if plan.get("asset_actions"):
19309 print("\n Asset resolution:")
19310 for item in plan["asset_actions"]:
19311 print(f" - {item['kind']}: {item['action']} ({item['provider']})")
19312
19313 diagnostics_artifacts = [item for item in plan.get("artifacts", []) if "PETSc_" in os.path.basename(str(item)) or os.path.basename(str(item)) == RUNTIME_MEMORY_FILE]
19314 if diagnostics_artifacts:
19315 print("\n Diagnostics artifacts:")
19316 for artifact in diagnostics_artifacts:
19317 print(f" - {artifact}")
19318
19319 print("\n Planned artifacts (no files created in dry-run):")
19320 for artifact in plan.get("artifacts", []):
19321 print(f" - {artifact}")
19322 print("=" * 60)
19323
19324
Here is the caller graph for this function:

◆ validate_workflow()

picurv_cli.core.validate_workflow (   args)

Implements picurv validate without launching solver/post workflows.

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 19325 of file core.py.

19325def validate_workflow(args):
19326 """!
19327 @brief Implements `picurv validate` without launching solver/post workflows.
19328 @param[in] args Command-line style argument list supplied to the function.
19329 """
19330 checked = []
19331 solver_group_selected = any([args.case, args.solver, args.monitor])
19332 any_group_selected = solver_group_selected or any([args.post, args.cluster, args.study])
19333 case_path = None
19334 cluster_path = None
19335
19336 if getattr(args, 'continue_run', False) and not getattr(args, 'run_dir', None):
19337 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
19338 if not any_group_selected:
19339 fail_cli_usage(
19340 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
19341 hint="Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
19342 )
19343
19344 if solver_group_selected and not all([args.case, args.solver, args.monitor]):
19345 fail_cli_usage("When solver validation is requested, --case, --solver, and --monitor are all required.")
19346
19347 # --- Guard: restart flags without solver group ---
19348 restart_from = getattr(args, 'restart_from', None)
19349 continue_run = getattr(args, 'continue_run', False)
19350 run_dir_val = getattr(args, 'run_dir', None)
19351 if not solver_group_selected:
19352 if restart_from:
19353 print("[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
19354 if continue_run:
19355 print("[WARNING] --continue validates a solve continued in place and needs "
19356 "--case/--solver/--monitor; it is ignored here.", file=sys.stderr)
19357
19358 monitor_cfg = None
19359 case_cfg = None
19360 if solver_group_selected:
19361 case_path = os.path.abspath(args.case)
19362 solver_path = os.path.abspath(args.solver)
19363 monitor_path = os.path.abspath(args.monitor)
19364 case_cfg = read_yaml_file(case_path)
19365 solver_cfg = read_yaml_file(solver_path)
19366 monitor_cfg = read_yaml_file(monitor_path)
19367 validate_simulation_configs(
19368 case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path,
19369 restart_source_given=bool(restart_from or continue_run),
19370 )
19371 checked.extend([case_path, solver_path, monitor_path])
19372
19373 # Validate restart flags if provided
19374 if restart_from or continue_run:
19375 target_run_dir = os.path.abspath(run_dir_val) if run_dir_val else os.path.abspath("runs/_validate_dummy")
19376 try:
19377 resolve_restart_source(args, case_cfg, solver_cfg, monitor_cfg, target_run_dir)
19378 print("[SUCCESS] Restart source validation passed.")
19379 except ValueError as e:
19380 print(f"[FATAL] Restart validation failed: {e}", file=sys.stderr)
19381 sys.exit(1)
19382
19383 post_cfg = None
19384 if args.post:
19385 post_path = os.path.abspath(args.post)
19386 post_cfg = read_yaml_file(post_path)
19387 validate_post_config(post_cfg, post_path, monitor_cfg, case_cfg)
19388 checked.append(post_path)
19389
19390 cluster_cfg = None
19391 if args.cluster:
19392 cluster_path = os.path.abspath(args.cluster)
19393 cluster_cfg = read_yaml_file(cluster_path)
19394 validate_cluster_config(cluster_cfg, cluster_path)
19395 checked.append(cluster_path)
19396
19397 # The rank layout that run --dry-run checks; without a cluster profile the process
19398 # count is unknown, so only each axis is checked against the coarsest multigrid level.
19399 if solver_group_selected:
19400 grid_cfg = case_cfg.get("grid") or {}
19401 block_dims = resolve_grid_node_dims(case_cfg, case_path)
19402 if cluster_cfg:
19403 tasks = get_cluster_total_tasks(cluster_cfg)
19404 decomposition = resolve_decomposition(tasks, grid_cfg, block_dims, solver_cfg,
19405 case_declares_periodic_axis(case_cfg), None, cluster_cfg)
19406 layout_problems = decomposition["problems"]
19407 if not layout_problems:
19408 print(f"[INFO] {describe_decomposition(decomposition, tasks)}")
19409 else:
19410 layout_problems = da_processor_layout_problems(
19411 grid_cfg, None, block_dims, solver_cfg, case_declares_periodic_axis(case_cfg))
19412 if layout_problems:
19413 emit_structured_error(
19414 ERROR_CODE_CFG_INCONSISTENT_COMBO,
19415 key="grid.da_processors",
19416 file_path=case_path,
19417 message=" ".join(layout_problems),
19418 )
19419 sys.exit(1)
19420 if block_dims is None and resolve_grid_da_processor_layout(grid_cfg):
19421 print(f"[WARNING] {case_path}: could not determine the grid's dimensions without building "
19422 "it, so the da_processors layout was not checked against the multigrid levels.",
19423 file=sys.stderr)
19424
19425 try:
19426 runtime_execution_path, _ = load_runtime_execution_config(
19427 case_path,
19428 extra_search_anchors=[cluster_path] if cluster_path else None,
19429 )
19430 except ValueError as exc:
19431 emit_structured_error(
19432 ERROR_CODE_CFG_INVALID_VALUE,
19433 key="runtime_execution",
19434 file_path=case_path or cluster_path or os.getcwd(),
19435 message=str(exc),
19436 )
19437 sys.exit(1)
19438 if runtime_execution_path:
19439 checked.append(runtime_execution_path)
19440
19441 study_cfg = None
19442 if args.study:
19443 study_path = os.path.abspath(args.study)
19444 study_cfg = read_yaml_file(study_path)
19445 validate_study_config(study_cfg, study_path)
19446 checked.append(study_path)
19447
19448 if post_cfg is not None and run_dir_val:
19449 post_path = os.path.abspath(args.post)
19450 validate_run_dir = os.path.abspath(run_dir_val)
19451 if os.path.isdir(validate_run_dir):
19452 monitor_for_post = monitor_cfg if solver_group_selected else None
19453 if monitor_for_post is None:
19454 config_dir_candidate = os.path.join(validate_run_dir, "config")
19455 monitor_candidate = os.path.join(config_dir_candidate, "monitor.yml")
19456 if os.path.isfile(monitor_candidate):
19457 monitor_for_post = read_yaml_file(monitor_candidate)
19458 if monitor_for_post is not None:
19459 resolved_source = _resolve_post_source_directory_preview(validate_run_dir, monitor_for_post, post_cfg)
19460 if os.path.isdir(resolved_source) and os.listdir(resolved_source):
19461 print(f"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
19462 else:
19463 print(f"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
19464
19465 if post_cfg is not None:
19466 # apply_canonical_post_paths always reads the run's own output, so a configured
19467 # directory is never used; say so instead of checking that it exists.
19468 source_dir = (post_cfg.get("source_data") or {}).get("directory")
19469 if source_dir and source_dir != "<solver_output_dir>":
19470 print(f"[WARNING] {os.path.abspath(args.post)}: source_data.directory '{source_dir}' is "
19471 "ignored; post-processing always reads the run's own output. Remove the key.",
19472 file=sys.stderr)
19473
19474 if args.strict and study_cfg is not None:
19475 study_path = os.path.abspath(args.study)
19476 base_cfgs = study_cfg.get("base_configs", {})
19477 if isinstance(base_cfgs, dict):
19478 base_case_path = resolve_path(study_path, base_cfgs.get("case"))
19479 base_solver_path = resolve_path(study_path, base_cfgs.get("solver"))
19480 base_monitor_path = resolve_path(study_path, base_cfgs.get("monitor"))
19481 base_post_path = resolve_path(study_path, base_cfgs.get("post"))
19482 if all([base_case_path, base_solver_path, base_monitor_path]):
19483 validate_simulation_configs(
19484 read_yaml_file(base_case_path),
19485 read_yaml_file(base_solver_path),
19486 read_yaml_file(base_monitor_path),
19487 base_case_path,
19488 base_solver_path,
19489 base_monitor_path,
19490 )
19491 if base_post_path:
19492 validate_post_config(read_yaml_file(base_post_path), base_post_path,
19493 read_yaml_file(base_monitor_path) if base_monitor_path else None,
19494 read_yaml_file(base_case_path) if base_case_path else None)
19495
19496 print(f"[SUCCESS] Validation completed for {len(checked)} file(s).")
19497 for path in checked:
19498 print(f" - {path}")
19499
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _asset_file_sha256()

str picurv_cli.core._asset_file_sha256 ( str  path)
protected

Hash an asset source or payload without loading it into memory.

Parameters
[in]pathFile to hash.
Returns
Lowercase SHA-256 digest.

Definition at line 19507 of file core.py.

19507def _asset_file_sha256(path: str) -> str:
19508 """!
19509 @brief Hash an asset source or payload without loading it into memory.
19510 @param[in] path File to hash.
19511 @return Lowercase SHA-256 digest.
19512 """
19513 digest = hashlib.sha256()
19514 with open(path, "rb") as stream:
19515 for block in iter(lambda: stream.read(8 * 1024 * 1024), b""):
19516 digest.update(block)
19517 return digest.hexdigest()
19518
19519
Here is the caller graph for this function:

◆ _stable_mapping_sha256()

str picurv_cli.core._stable_mapping_sha256 (   payload)
protected

Hash a JSON-compatible value with deterministic serialization.

Parameters
[in]payloadValue to hash.
Returns
Lowercase SHA-256 digest.

Definition at line 19520 of file core.py.

19520def _stable_mapping_sha256(payload) -> str:
19521 """!
19522 @brief Hash a JSON-compatible value with deterministic serialization.
19523 @param[in] payload Value to hash.
19524 @return Lowercase SHA-256 digest.
19525 """
19526 encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
19527 return hashlib.sha256(encoded).hexdigest()
19528
19529
Here is the caller graph for this function:

◆ _provider_source_fingerprints()

dict picurv_cli.core._provider_source_fingerprints (   value,
str  case_path,
str   key = "" 
)
protected

Hash every existing file explicitly referenced by an asset provider.

Parameters
[in]valueProvider subtree to inspect.
[in]case_pathOwning case configuration path.
[in]keyCurrent dotted key for diagnostics.
Returns
Mapping of dotted path identity to path/content fingerprint.

Definition at line 19530 of file core.py.

19530def _provider_source_fingerprints(value, case_path: str, key: str = "") -> dict:
19531 """!
19532 @brief Hash every existing file explicitly referenced by an asset provider.
19533 @param[in] value Provider subtree to inspect.
19534 @param[in] case_path Owning case configuration path.
19535 @param[in] key Current dotted key for diagnostics.
19536 @return Mapping of dotted path identity to path/content fingerprint.
19537 """
19538 result = {}
19539 if isinstance(value, dict):
19540 for child_key, child in value.items():
19541 dotted = f"{key}.{child_key}" if key else str(child_key)
19542 result.update(_provider_source_fingerprints(child, case_path, dotted))
19543 return result
19544 if isinstance(value, list):
19545 for index, child in enumerate(value):
19546 result.update(_provider_source_fingerprints(child, case_path, f"{key}[{index}]"))
19547 return result
19548 basename = key.rsplit(".", 1)[-1]
19549 if basename not in _ASSET_SOURCE_REFERENCE_KEYS or not isinstance(value, str) or not value.strip():
19550 return result
19551 try:
19552 resolved = resolve_workspace_path(case_path, value)
19553 except ValueError:
19554 return result
19555 if os.path.isfile(resolved):
19556 workspace_root = find_workspace_root(case_path)
19557 display = (
19558 os.path.relpath(resolved, workspace_root).replace(os.sep, "/")
19559 if workspace_root and os.path.commonpath([workspace_root, resolved]) == workspace_root
19560 else resolved
19561 )
19562 result[key] = {"path": display, "sha256": _asset_file_sha256(resolved)}
19563 return result
19564
19565
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_case_asset_graph()

dict picurv_cli.core.build_case_asset_graph ( dict  case_cfg,
str  case_path 
)

Classify case inputs into precomputable or simulator-runtime providers.

Parameters
[in]case_cfgParsed case configuration.
[in]case_pathOwning case YAML path.
Returns
Provider graph with stable identities and explicit dependencies.

Definition at line 19566 of file core.py.

19566def build_case_asset_graph(case_cfg: dict, case_path: str) -> dict:
19567 """!
19568 @brief Classify case inputs into precomputable or simulator-runtime providers.
19569 @param[in] case_cfg Parsed case configuration.
19570 @param[in] case_path Owning case YAML path.
19571 @return Provider graph with stable identities and explicit dependencies.
19572 """
19573 providers = []
19574 grid_cfg = copy.deepcopy(case_cfg.get("grid", {}) or {})
19575 grid_mode = str(grid_cfg.get("mode", "")).strip().lower()
19576 if isinstance(grid_cfg.get("generator"), dict):
19577 grid_cfg["generator"].pop("output_file", None)
19578 grid_cfg["generator"].pop("vts_file", None)
19579 grid_cfg["generator"].pop("stats_file", None)
19580 grid_provider = {
19581 "kind": "grid",
19582 "provider": grid_mode or "missing",
19583 "execution": "runtime-c" if grid_mode == "programmatic_c" else "precomputable",
19584 "dependencies": [],
19585 "spec": grid_cfg,
19586 }
19587 providers.append(grid_provider)
19588
19589 initial = copy.deepcopy(
19590 ((case_cfg.get("properties") or {}).get("initial_conditions") or {})
19591 )
19592 initial_mode = str(initial.get("mode", "generated")).strip().lower()
19593 initial_generator = str(initial.get("generator", "constant")).strip().lower()
19594 generated_python = (
19595 initial_mode == "generated"
19596 and initial_generator in _PYTHON_INITIAL_CONDITION_PROVIDERS
19597 )
19598 initial_provider = {
19599 "kind": "initial-condition",
19600 "provider": "file" if initial_mode == "file" else initial_generator,
19601 "execution": "precomputable" if initial_mode == "file" or generated_python else "runtime-c",
19602 "dependencies": ["grid"] if generated_python else [],
19603 "spec": initial,
19604 }
19605 providers.append(initial_provider)
19606
19607 inlet_specs = []
19608 raw_blocks = case_cfg.get("boundary_conditions") or []
19609 if raw_blocks and isinstance(raw_blocks[0], dict):
19610 raw_blocks = [raw_blocks]
19611 for block_index, block in enumerate(raw_blocks):
19612 for entry in block or []:
19613 if not isinstance(entry, dict) or str(entry.get("handler", "")).strip().lower() != "prescribed_flow":
19614 continue
19615 source = copy.deepcopy(((entry.get("params") or {}).get("source") or {}))
19616 inlet_specs.append({"block": block_index, "face": entry.get("face"), "source": source})
19617 if inlet_specs:
19618 field_slice = any((item.get("source") or {}).get("type") == "field_slice" for item in inlet_specs)
19619 providers.append({
19620 "kind": "inlet-profiles",
19621 "provider": "prescribed-flow",
19622 "execution": "precomputable",
19623 "dependencies": ["grid"] if field_slice or grid_mode in _FILE_BACKED_GRID_VALUES else [],
19624 "spec": inlet_specs,
19625 })
19626
19627 # A provider's identity has to cover every case value its build reads, not only its
19628 # own configuration subtree. The grid build nondimensionalizes by
19629 # properties.scaling.length_ref and validates against the declared block count;
19630 # both are outside `grid:`, and a change to either produces different payload bytes
19631 # from an identical provider spec. Left out, the staleness check reports `reuse`
19632 # and the solver silently receives geometry scaled by the wrong reference length.
19633 scaling = (case_cfg.get("properties") or {}).get("scaling") or {}
19634 fluid = (case_cfg.get("properties") or {}).get("fluid") or {}
19635 domain_blocks = (case_cfg.get("models") or {}).get("domain", {}).get("blocks", 1)
19636 build_contexts = {
19637 "grid": {
19638 "length_ref": scaling.get("length_ref"),
19639 "blocks": domain_blocks,
19640 },
19641 # The IC build resolves fluid scaling and the prepared boundary conditions.
19642 "initial-condition": {
19643 "length_ref": scaling.get("length_ref"),
19644 "velocity_ref": scaling.get("velocity_ref"),
19645 "density": fluid.get("density"),
19646 "viscosity": fluid.get("viscosity"),
19647 "boundary_conditions": case_cfg.get("boundary_conditions"),
19648 },
19649 # Profile generation dimensionalizes against the same scaling contract. On a
19650 # programmatic_c grid, which is not an asset the profile can depend on, it samples
19651 # the bridge built from programmatic_settings, so those settings are read too.
19652 "inlet-profiles": {
19653 "length_ref": scaling.get("length_ref"),
19654 "velocity_ref": scaling.get("velocity_ref"),
19655 "blocks": domain_blocks,
19656 "programmatic_settings": (
19657 grid_cfg.get("programmatic_settings") if grid_mode == "programmatic_c" else None
19658 ),
19659 },
19660 }
19661
19662 # Hash in dependency order so a provider built on top of another re-identifies when
19663 # the thing it was built from changes.
19664 by_kind = {provider["kind"]: provider for provider in providers}
19665 resolved_order = sorted(
19666 providers, key=lambda provider: len(provider.get("dependencies") or [])
19667 )
19668 for provider in resolved_order:
19669 provider["build_context"] = build_contexts.get(provider["kind"], {})
19670 provider["source_files"] = _provider_source_fingerprints(provider["spec"], case_path)
19671 provider["software"] = {
19672 "release_version": PICURV_RELEASE_VERSION,
19673 "git_commit": picurv_build().get("git_commit"),
19674 }
19675 provider["spec_sha256"] = _stable_mapping_sha256({
19676 "kind": provider["kind"],
19677 "provider": provider["provider"],
19678 "spec": provider["spec"],
19679 "build_context": provider["build_context"],
19680 "source_files": provider["source_files"],
19681 "software": provider["software"],
19682 "dependencies": {
19683 name: by_kind[name].get("spec_sha256")
19684 for name in (provider.get("dependencies") or [])
19685 if name in by_kind
19686 },
19687 })
19688 return {
19689 "case_sha256": _stable_mapping_sha256(case_cfg),
19690 "providers": providers,
19691 }
19692
19693
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _asset_selection()

list picurv_cli.core._asset_selection ( dict  graph,
  requested = None,
*bool   precomputable_only = False 
)
protected

Resolve requested asset kinds plus dependency closure.

Parameters
[in]graphProvider graph returned by build_case_asset_graph.
[in]requestedOptional iterable of requested kind names.
[in]precomputable_onlyExclude runtime-C providers for normal-run staging.
Returns
Ordered provider list.

Definition at line 19694 of file core.py.

19694def _asset_selection(graph: dict, requested=None, *, precomputable_only: bool = False) -> list:
19695 """!
19696 @brief Resolve requested asset kinds plus dependency closure.
19697 @param[in] graph Provider graph returned by build_case_asset_graph.
19698 @param[in] requested Optional iterable of requested kind names.
19699 @param[in] precomputable_only Exclude runtime-C providers for normal-run staging.
19700 @return Ordered provider list.
19701 """
19702 by_kind = {item["kind"]: item for item in graph["providers"]}
19703 requested_set = set(requested or by_kind)
19704 unknown = requested_set - set(by_kind)
19705 if unknown:
19706 raise ValueError(
19707 f"No configured provider exists for asset kind(s): {sorted(unknown)}. "
19708 f"Configured kinds: {sorted(by_kind)}."
19709 )
19710 closure = set()
19711
19712 def add(kind):
19713 """!
19714 @brief Add one requested asset kind and its dependency closure.
19715 @param[in] kind Asset kind to include.
19716 @return None.
19717 """
19718 if kind in closure:
19719 return
19720 closure.add(kind)
19721 for dependency in by_kind[kind].get("dependencies", []):
19722 if dependency in by_kind:
19723 add(dependency)
19724
19725 for kind in requested_set:
19726 add(kind)
19727 selected = [item for item in graph["providers"] if item["kind"] in closure]
19728 if precomputable_only:
19729 selected = [item for item in selected if item["execution"] == "precomputable"]
19730 return selected
19731
19732
Here is the caller graph for this function:

◆ _asset_payload_files()

list picurv_cli.core._asset_payload_files ( str  build_root,
str  kind 
)
protected

Enumerate canonical files belonging to one asset kind in a build tree.

Parameters
[in]build_rootTemporary run-like build root.
[in]kindAsset kind.
Returns
Absolute payload file paths.

Definition at line 19733 of file core.py.

19733def _asset_payload_files(build_root: str, kind: str) -> list:
19734 """!
19735 @brief Enumerate canonical files belonging to one asset kind in a build tree.
19736 @param[in] build_root Temporary run-like build root.
19737 @param[in] kind Asset kind.
19738 @return Absolute payload file paths.
19739 """
19740 roots = {
19741 "grid": ["inputs/grid", "output/analysis/metrics", "output/visualization/precompute"],
19742 "initial-condition": ["inputs/initial_condition", "output/analysis/spectra", "output/analysis/metrics"],
19743 "inlet-profiles": ["inputs/inlet_profiles"],
19744 }[kind]
19745 result = []
19746 for relative in roots:
19747 root = Path(build_root, *relative.split("/"))
19748 if root.is_dir():
19749 result.extend(str(path) for path in sorted(root.rglob("*")) if path.is_file())
19750 intermediate_names = {
19751 "grid": {"grid.generated.picgrid", "grid.converted.picgrid"},
19752 "initial-condition": {"initial_condition.generated.dat"},
19753 "inlet-profiles": set(),
19754 }[kind]
19755 return [
19756 path for path in dict.fromkeys(result)
19757 if os.path.basename(path) not in intermediate_names
19758 ]
19759
19760
Here is the caller graph for this function:

◆ _build_selected_asset_payloads()

dict picurv_cli.core._build_selected_asset_payloads ( str  build_root,
dict  case_cfg,
str  case_path,
list  selected 
)
protected

Execute existing generators into one isolated run-like build tree.

Parameters
[in]build_rootTemporary output root.
[in]case_cfgParsed case config.
[in]case_pathOwning case path.
[in]selectedOrdered selected providers.
Returns
Asset-kind to generated file list.

Definition at line 19761 of file core.py.

19762 selected: list) -> dict:
19763 """!
19764 @brief Execute existing generators into one isolated run-like build tree.
19765 @param[in] build_root Temporary output root.
19766 @param[in] case_cfg Parsed case config.
19767 @param[in] case_path Owning case path.
19768 @param[in] selected Ordered selected providers.
19769 @return Asset-kind to generated file list.
19770 """
19771 ensure_run_layout(build_root)
19772 selected_kinds = {item["kind"] for item in selected}
19773 payloads = {}
19774
19775 def fingerprint(kind):
19776 """!
19777 @brief Snapshot current payload checksums for one asset kind.
19778 @param[in] kind Asset kind to inspect.
19779 @return Absolute-path to checksum mapping.
19780 """
19781 return {
19782 path: _asset_file_sha256(path)
19783 for path in _asset_payload_files(build_root, kind)
19784 }
19785
19786 def record_changes(kind, before):
19787 """!
19788 @brief Record files created or changed by one provider stage.
19789 @param[in] kind Asset kind whose build just completed.
19790 @param[in] before Pre-build checksum mapping.
19791 @return None.
19792 """
19793 after = fingerprint(kind)
19794 payloads[kind] = [
19795 path for path, digest in after.items()
19796 if before.get(path) != digest
19797 ]
19798
19799 grid_cfg = case_cfg.get("grid", {}) or {}
19800 expected_nblk = int((case_cfg.get("models", {}) or {}).get("domain", {}).get("blocks", 1))
19801 staged_grid = os.path.join(build_root, "inputs", "grid", "grid.run")
19802 if "grid" in selected_kinds:
19803 before = fingerprint("grid")
19804 if grid_cfg.get("mode") in _FILE_BACKED_GRID_VALUES:
19805 grid_length_scale = input_reference_scale(("case", "grid", "source_file"),
19806 resolve_reference_scales(case_cfg))
19807 if grid_cfg.get("mode") == "grid_gen":
19808 generated = run_grid_generator(case_path, build_root, grid_cfg, case_cfg=case_cfg)
19809 validate_and_nondimensionalize_picgrid(
19810 generated, staged_grid, grid_length_scale, expected_nblk=expected_nblk
19811 )
19812 elif grid_cfg.get("mode") == "file":
19813 source = _resolve_case_relative_path(
19814 grid_cfg.get("source_file"), os.path.dirname(os.path.abspath(case_path))
19815 )
19816 validate_and_nondimensionalize_picgrid(
19817 source, staged_grid, grid_length_scale, expected_nblk=expected_nblk
19818 )
19819 record_changes("grid", before)
19820
19821 if "inlet-profiles" in selected_kinds:
19822 before = fingerprint("inlet-profiles")
19823 generate_multi_block_bcs(
19824 build_root, "asset-build", case_cfg,
19825 {"Case": case_path},
19826 )
19827 record_changes("inlet-profiles", before)
19828
19829 if "initial-condition" in selected_kinds:
19830 before = fingerprint("initial-condition")
19831 fluid_scaling = resolve_fluid_scaling(case_cfg)
19832 resolved_ic = resolve_initial_condition_config(
19833 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
19834 validate_and_prepare_boundary_conditions(case_cfg),
19835 scales=fluid_scaling,
19836 provider_context={
19837 "kinematic_viscosity": fluid_scaling["nondimensional_kinematic_viscosity"]
19838 },
19839 )
19840 if (grid_cfg.get("mode") == "programmatic_c" and is_generated_ic_provider(resolved_ic)
19841 and not os.path.isfile(staged_grid)):
19842 # "grid" is excluded from this closure when the caller passed
19843 # precomputable_only=True (materialize_run_assets, staging before a solve): a
19844 # runtime-C grid is never itself precomputed. But the Python IC generator run
19845 # below is a separate process from the solver and needs real coordinates on
19846 # disk regardless. This bridge uses the identical formula as
19847 # ComputeStretchedCoord in src/grid.c, so it is guaranteed to match what the
19848 # solver builds moments later; it is not published as its own asset, since a
19849 # programmatic_c grid is never a persisted asset in its own right.
19850 generate_picgrid_from_programmatic_settings(
19851 grid_cfg.get("programmatic_settings", {}), staged_grid,
19852 input_reference_scale(("case", "grid", "programmatic_settings", "xMins"), fluid_scaling),
19853 )
19854 stage_initial_condition_file(build_root, case_path, resolved_ic)
19855 record_changes("initial-condition", before)
19856 return payloads
19857
19858
19859#: Largest grid, in nodes, for which a published asset carries an inline VTS preview.
19860#: Beyond this the ASCII payload costs more than the inspection is worth, and
19861#: validation.json records that it was skipped rather than silently omitting it.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _picgrid_geometry_summary()

dict picurv_cli.core._picgrid_geometry_summary ( str  path)
protected

Read a canonical PICGRID and summarize what a user would want to check.

Parameters
[in]pathStaged PICGRID file.
Returns
Mapping of block dimensions, bounds, and spacing extremes.

Definition at line 19865 of file core.py.

19865def _picgrid_geometry_summary(path: str) -> dict:
19866 """!
19867 @brief Read a canonical PICGRID and summarize what a user would want to check.
19868 @param[in] path Staged PICGRID file.
19869 @return Mapping of block dimensions, bounds, and spacing extremes.
19870 """
19871 dims = read_picgrid_header_dimensions(path)
19872 bounds = [[float("inf")] * 3, [float("-inf")] * 3]
19873 nodes = 0
19874 with open(path, "r", encoding="utf-8", errors="replace") as stream:
19875 iterator = _iter_nonempty_noncomment_lines(stream)
19876 # Header token, block count, and one dimension row per block.
19877 for _ in range(2 + len(dims)):
19878 next(iterator, None)
19879 for _lineno, line in iterator:
19880 parts = line.split()
19881 if len(parts) != 3:
19882 continue
19883 try:
19884 point = [float(value) for value in parts]
19885 except ValueError:
19886 continue
19887 nodes += 1
19888 for axis in range(3):
19889 bounds[0][axis] = min(bounds[0][axis], point[axis])
19890 bounds[1][axis] = max(bounds[1][axis], point[axis])
19891 extent = [bounds[1][axis] - bounds[0][axis] for axis in range(3)] if nodes else [0.0, 0.0, 0.0]
19892 return {
19893 "blocks": len(dims),
19894 "dimensions": [list(item) for item in dims],
19895 "total_nodes": nodes,
19896 "bounds_min": bounds[0] if nodes else None,
19897 "bounds_max": bounds[1] if nodes else None,
19898 "extent": extent,
19899 }
19900
19901
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _write_structured_grid_preview()

bool picurv_cli.core._write_structured_grid_preview ( str  picgrid_path,
str  destination,
  dims 
)
protected

Write a single-block ASCII VTS preview of a staged PICGRID.

Only the first block is written: the preview exists so a user can confirm the shape they configured, not to reproduce the solver's view of a multi-block domain.

Parameters
[in]picgrid_pathStaged PICGRID file.
[in]destinationPreview path to write.
[in]dimsPer-block dimension triples.
Returns
True when a preview was written.

Definition at line 19902 of file core.py.

19902def _write_structured_grid_preview(picgrid_path: str, destination: str, dims) -> bool:
19903 """!
19904 @brief Write a single-block ASCII VTS preview of a staged PICGRID.
19905
19906 @details Only the first block is written: the preview exists so a user can confirm
19907 the shape they configured, not to reproduce the solver's view of a
19908 multi-block domain.
19909 @param[in] picgrid_path Staged PICGRID file.
19910 @param[in] destination Preview path to write.
19911 @param[in] dims Per-block dimension triples.
19912 @return True when a preview was written.
19913 """
19914 if not dims:
19915 return False
19916 im, jm, km = (int(value) for value in dims[0])
19917 if im * jm * km > ASSET_PREVIEW_NODE_LIMIT:
19918 return False
19919 coordinates = []
19920 with open(picgrid_path, "r", encoding="utf-8", errors="replace") as stream:
19921 iterator = _iter_nonempty_noncomment_lines(stream)
19922 for _ in range(2 + len(dims)):
19923 next(iterator, None)
19924 for _lineno, line in iterator:
19925 parts = line.split()
19926 if len(parts) == 3:
19927 coordinates.append(line)
19928 if len(coordinates) >= im * jm * km:
19929 break
19930 if len(coordinates) < im * jm * km:
19931 return False
19932 os.makedirs(os.path.dirname(destination), exist_ok=True)
19933 with open(destination, "w", encoding="utf-8") as out:
19934 extent = f"0 {im - 1} 0 {jm - 1} 0 {km - 1}"
19935 out.write('<?xml version="1.0"?>\n')
19936 out.write('<VTKFile type="StructuredGrid" version="1.0" byte_order="LittleEndian">\n')
19937 out.write(f' <StructuredGrid WholeExtent="{extent}">\n')
19938 out.write(f' <Piece Extent="{extent}">\n')
19939 out.write(' <Points>\n')
19940 out.write(' <DataArray type="Float64" NumberOfComponents="3" format="ascii">\n')
19941 for row in coordinates:
19942 out.write(f" {row}\n")
19943 out.write(' </DataArray>\n </Points>\n')
19944 out.write(' </Piece>\n </StructuredGrid>\n</VTKFile>\n')
19945 return True
19946
19947
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _build_asset_inspection()

dict picurv_cli.core._build_asset_inspection ( str  kind,
str  build_root,
dict  provider,
list  payload_files 
)
protected

Produce the inspection material published beside an asset's payload.

Precompute exists so a user can look at a grid, field, or profile and change it before committing a solve. An asset that carries only opaque solver input cannot serve that purpose, so every published object gets a validation record and, where it is affordable, a preview.

Parameters
[in]kindAsset kind.
[in]build_rootTemporary build tree.
[in]providerProvider metadata for the asset.
[in]payload_filesAbsolute payload paths produced for this asset.
Returns
Mapping of published inspection filename to its absolute source path.

Definition at line 19948 of file core.py.

19949 payload_files: list) -> dict:
19950 """!
19951 @brief Produce the inspection material published beside an asset's payload.
19952
19953 @details Precompute exists so a user can look at a grid, field, or profile and
19954 change it before committing a solve. An asset that carries only opaque
19955 solver input cannot serve that purpose, so every published object gets a
19956 validation record and, where it is affordable, a preview.
19957 @param[in] kind Asset kind.
19958 @param[in] build_root Temporary build tree.
19959 @param[in] provider Provider metadata for the asset.
19960 @param[in] payload_files Absolute payload paths produced for this asset.
19961 @return Mapping of published inspection filename to its absolute source path.
19962 """
19963 inspection_dir = os.path.join(build_root, ".inspection")
19964 os.makedirs(inspection_dir, exist_ok=True)
19965 published = {}
19966 validation = {
19967 "asset_kind": kind,
19968 "provider": provider["provider"],
19969 "generated_at": datetime.now().astimezone().isoformat(),
19970 "files": [os.path.relpath(path, build_root).replace(os.sep, "/") for path in payload_files],
19971 }
19972
19973 if kind == "grid":
19974 staged = os.path.join(build_root, "inputs", "grid", "grid.run")
19975 if os.path.isfile(staged):
19976 try:
19977 geometry = _picgrid_geometry_summary(staged)
19978 validation["geometry"] = geometry
19979 preview = os.path.join(inspection_dir, "preview.vts")
19980 if _write_structured_grid_preview(staged, preview, geometry["dimensions"]):
19981 published["preview.vts"] = preview
19982 else:
19983 validation["preview"] = (
19984 "skipped: the first block exceeds "
19985 f"{ASSET_PREVIEW_NODE_LIMIT} nodes"
19986 )
19987 except (OSError, ValueError, StopIteration) as exc:
19988 validation["geometry_error"] = str(exc)
19989 generator_preview = os.path.join(
19990 build_root, "output", "visualization", "precompute", "grid.vts"
19991 )
19992 if os.path.isfile(generator_preview):
19993 published["preview.vts"] = generator_preview
19994 validation.pop("preview", None)
19995 info = os.path.join(build_root, "output", "analysis", "metrics", "grid.info")
19996 if os.path.isfile(info):
19997 published["grid.info"] = info
19998
19999 elif kind == "initial-condition":
20000 # The generators name these for the run tree they were written into; the asset
20001 # publishes them under the names the object contract uses.
20002 for published_name, suffix in (
20003 ("summary.json", "_summary.json"), ("spectrum.csv", "_spectrum.csv"),
20004 ):
20005 for candidate in payload_files:
20006 base = os.path.basename(candidate)
20007 if base == published_name or base.endswith(suffix):
20008 published[published_name] = candidate
20009 break
20010 for candidate in payload_files:
20011 base = os.path.basename(candidate)
20012 if base.startswith(("initial_condition_plane_spectrum_", "initial_condition_line_spectrum_")) and base.endswith((".csv", ".json")):
20013 published[base] = candidate
20014 validation["fields"] = sorted(
20015 os.path.basename(path) for path in payload_files if path.endswith(".dat")
20016 )
20017 summary_source = published.get("summary.json")
20018 if summary_source:
20019 try:
20020 with open(summary_source, "r", encoding="utf-8") as stream:
20021 validation["summary"] = json.load(stream)
20022 except (OSError, ValueError):
20023 pass
20024
20025 elif kind == "inlet-profiles":
20026 for candidate in payload_files:
20027 if os.path.basename(candidate) == "profile.info":
20028 published["profile.info"] = candidate
20029 validation["profiles"] = sorted(
20030 os.path.basename(path) for path in payload_files
20031 if path.endswith(".picslice")
20032 )
20033
20034 validation_path = os.path.join(inspection_dir, "validation.json")
20035 write_json_file(validation_path, validation)
20036 published["validation.json"] = validation_path
20037 return published
20038
20039
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _publish_asset_object()

dict picurv_cli.core._publish_asset_object ( str  workspace_root,
str  build_root,
dict  provider,
list  payload_files 
)
protected

Publish one immutable content-addressed asset object atomically.

Parameters
[in]workspace_rootOwning workspace.
[in]build_rootTemporary build root.
[in]providerProvider metadata.
[in]payload_filesFiles produced for the provider.
Returns
Asset reference written to the asset set.

Definition at line 20040 of file core.py.

20041 payload_files: list) -> dict:
20042 """!
20043 @brief Publish one immutable content-addressed asset object atomically.
20044 @param[in] workspace_root Owning workspace.
20045 @param[in] build_root Temporary build root.
20046 @param[in] provider Provider metadata.
20047 @param[in] payload_files Files produced for the provider.
20048 @return Asset reference written to the asset set.
20049 """
20050 file_inventory = []
20051 for path in payload_files:
20052 relative = os.path.relpath(path, build_root).replace(os.sep, "/")
20053 file_inventory.append({
20054 "path": relative,
20055 "bytes": os.path.getsize(path),
20056 "sha256": _asset_file_sha256(path),
20057 })
20058 asset_id = _stable_mapping_sha256({
20059 "provider_spec_sha256": provider["spec_sha256"],
20060 "files": file_inventory,
20061 })
20062 # Identity is the payload's; inspection material describes it and must not change
20063 # which object a run resolves to.
20064 inspection = _build_asset_inspection(
20065 provider["kind"], build_root, provider, payload_files
20066 )
20067 kind_dir = ASSET_KIND_DIRECTORIES[provider["kind"]]
20068 object_root = os.path.join(workspace_root, "assets", "objects", kind_dir, asset_id)
20069 if not os.path.isdir(object_root):
20070 parent = os.path.dirname(object_root)
20071 os.makedirs(parent, exist_ok=True)
20072 temporary = tempfile.mkdtemp(prefix=f".{asset_id[:12]}-", dir=parent)
20073 try:
20074 for item, source in zip(file_inventory, payload_files):
20075 destination = os.path.join(temporary, "payload", *item["path"].split("/"))
20076 os.makedirs(os.path.dirname(destination), exist_ok=True)
20077 shutil.copy2(source, destination)
20078 for name, source in sorted(inspection.items()):
20079 shutil.copy2(source, os.path.join(temporary, name))
20080 manifest = {
20081 "schema_version": ASSET_MANIFEST_SCHEMA_VERSION,
20082 "asset_id": asset_id,
20083 "kind": provider["kind"],
20084 "provider": provider["provider"],
20085 "provider_execution": provider["execution"],
20086 "provider_spec_sha256": provider["spec_sha256"],
20087 "provider_spec": provider["spec"],
20088 "source_files": provider["source_files"],
20089 "software": provider["software"],
20090 "created_at": datetime.now().astimezone().isoformat(),
20091 "files": file_inventory,
20092 "inspection": sorted(inspection),
20093 }
20094 write_json_file(os.path.join(temporary, "asset.json"), manifest)
20095 try:
20096 os.replace(temporary, object_root)
20097 except OSError as exc:
20098 if exc.errno != errno.ENOTEMPTY and not os.path.isdir(object_root):
20099 raise
20100 finally:
20101 if os.path.isdir(temporary):
20102 shutil.rmtree(temporary, ignore_errors=True)
20103 return {
20104 "asset_id": asset_id,
20105 "kind": provider["kind"],
20106 "provider": provider["provider"],
20107 "provider_spec_sha256": provider["spec_sha256"],
20108 "object": os.path.relpath(object_root, workspace_root).replace(os.sep, "/"),
20109 "files": file_inventory,
20110 "inspection": sorted(inspection),
20111 }
20112
20113
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _workspace_asset_set_name()

str picurv_cli.core._workspace_asset_set_name ( str  workspace_root,
str  case_path 
)
protected

Return a collision-free, readable mutable asset-set name.

Parameters
[in]workspace_rootOwning initialized workspace.
[in]case_pathSource case configuration path.
Returns
Stable asset-set filename stem.

Definition at line 20114 of file core.py.

20114def _workspace_asset_set_name(workspace_root: str, case_path: str) -> str:
20115 """!
20116 @brief Return a collision-free, readable mutable asset-set name.
20117 @param[in] workspace_root Owning initialized workspace.
20118 @param[in] case_path Source case configuration path.
20119 @return Stable asset-set filename stem.
20120 """
20121 stem = re.sub(r"[^A-Za-z0-9_.-]+", "-", Path(case_path).stem).strip("-") or "case"
20122 relative = _relative_to_workspace(case_path, workspace_root) or os.path.abspath(case_path)
20123 digest = hashlib.sha256(str(relative).encode("utf-8")).hexdigest()[:10]
20124 return f"{stem}-{digest}"
20125
20126
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _write_workspace_asset_set()

str picurv_cli.core._write_workspace_asset_set ( str  workspace_root,
str  case_path,
dict  graph,
dict  references 
)
protected

Atomically update the named asset set and workspace asset catalog.

Parameters
[in]workspace_rootOwning workspace.
[in]case_pathSource case YAML.
[in]graphComplete provider graph.
[in]referencesNewly published or reused asset references.
Returns
Asset-set YAML path.

Definition at line 20127 of file core.py.

20128 references: dict) -> str:
20129 """!
20130 @brief Atomically update the named asset set and workspace asset catalog.
20131 @param[in] workspace_root Owning workspace.
20132 @param[in] case_path Source case YAML.
20133 @param[in] graph Complete provider graph.
20134 @param[in] references Newly published or reused asset references.
20135 @return Asset-set YAML path.
20136 """
20137 name = _workspace_asset_set_name(workspace_root, case_path)
20138 set_path = os.path.join(workspace_root, "assets", "sets", f"{name}.yml")
20139 existing = read_yaml_file(set_path) if os.path.isfile(set_path) else {}
20140 assets = dict(existing.get("assets") or {})
20141 assets.update(references)
20142 payload = {
20143 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20144 "name": name,
20145 "case": os.path.relpath(case_path, workspace_root).replace(os.sep, "/"),
20146 "case_sha256": graph["case_sha256"],
20147 "updated_at": datetime.now().astimezone().isoformat(),
20148 "assets": assets,
20149 "runtime_providers": {
20150 item["kind"]: {
20151 "provider": item["provider"],
20152 "provider_spec_sha256": item["spec_sha256"],
20153 }
20154 for item in graph["providers"] if item["execution"] == "runtime-c"
20155 },
20156 }
20157 write_yaml_file(set_path, payload)
20158 catalog_path = os.path.join(workspace_root, "assets", "catalog.yml")
20159 catalog = read_yaml_file(catalog_path) if os.path.isfile(catalog_path) else {
20160 "schema_version": 1, "objects": {}
20161 }
20162 objects = catalog.setdefault("objects", {})
20163 for reference in references.values():
20164 objects[reference["asset_id"]] = {
20165 "kind": reference["kind"],
20166 "provider": reference["provider"],
20167 "object": reference["object"],
20168 }
20169 write_yaml_file(catalog_path, catalog)
20170 return set_path
20171
20172
Here is the call graph for this function:
Here is the caller graph for this function:

◆ precompute_case_assets()

dict picurv_cli.core.precompute_case_assets ( str  workspace_root,
dict  case_cfg,
str  case_path,
  requested = None,
bool   precomputable_only = False 
)

Build and publish a selected deterministic asset dependency closure.

Parameters
[in]workspace_rootOwning workspace.
[in]case_cfgParsed case configuration.
[in]case_pathSource case path.
[in]requestedRequested asset kinds, or all configured providers.
[in]precomputable_onlyDrop runtime-C dependencies from the closure instead of refusing. For internal run-staging only: a run legitimately depends on a runtime-C grid that its own solver builds moments later, so its presence in the dependency closure is expected there, not an error. The standalone picurv precompute command leaves this False, since nothing will ever build a runtime-C provider in that context.
Returns
Provider graph, selected providers, references, and asset-set path.

Definition at line 20173 of file core.py.

20174 requested=None, precomputable_only: bool = False) -> dict:
20175 """!
20176 @brief Build and publish a selected deterministic asset dependency closure.
20177 @param[in] workspace_root Owning workspace.
20178 @param[in] case_cfg Parsed case configuration.
20179 @param[in] case_path Source case path.
20180 @param[in] requested Requested asset kinds, or all configured providers.
20181 @param[in] precomputable_only Drop runtime-C dependencies from the closure instead of
20182 refusing. For internal run-staging only: a run legitimately
20183 depends on a runtime-C grid that its own solver builds
20184 moments later, so its presence in the dependency closure is
20185 expected there, not an error. The standalone `picurv
20186 precompute` command leaves this False, since nothing will
20187 ever build a runtime-C provider in that context.
20188 @return Provider graph, selected providers, references, and asset-set path.
20189 """
20190 enforce_workspace_version(workspace_root)
20191 graph = build_case_asset_graph(case_cfg, case_path)
20192 selected = _asset_selection(graph, requested, precomputable_only=precomputable_only)
20193 if not precomputable_only:
20194 runtime = [item for item in selected if item["execution"] == "runtime-c"]
20195 if runtime:
20196 details = ", ".join(f"{item['kind']}={item['provider']}" for item in runtime)
20197 raise ValueError(
20198 "Precompute is atomic and cannot execute simulator-runtime providers. "
20199 f"Selected dependency graph requires C generation: {details}. "
20200 "Use --only to select an independent precomputable subset, or run the case; "
20201 "the simulator will report each runtime provider before generation."
20202 )
20203 if not selected:
20204 raise ValueError("The selected case has no precomputable providers.")
20205 staging_parent = os.path.join(workspace_root, "assets")
20206 os.makedirs(staging_parent, exist_ok=True)
20207 build_root = tempfile.mkdtemp(prefix=".precompute-", dir=staging_parent)
20208 references = {}
20209 try:
20210 payloads = _build_selected_asset_payloads(build_root, case_cfg, case_path, selected)
20211 for provider in selected:
20212 files = payloads.get(provider["kind"], [])
20213 if not files:
20214 raise ValueError(
20215 f"Provider {provider['kind']}={provider['provider']} produced no files."
20216 )
20217 references[provider["kind"]] = _publish_asset_object(
20218 workspace_root, build_root, provider, files
20219 )
20220 finally:
20221 shutil.rmtree(build_root, ignore_errors=True)
20222 set_path = _write_workspace_asset_set(workspace_root, case_path, graph, references)
20223 return {"graph": graph, "selected": selected, "assets": references, "set_path": set_path}
20224
20225
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _workspace_asset_set_path()

str picurv_cli.core._workspace_asset_set_path ( str  workspace_root,
str  case_path 
)
protected

Return the mutable asset-set pointer associated with a case config name.

Parameters
[in]workspace_rootOwning workspace.
[in]case_pathSource case path.
Returns
Absolute asset-set YAML path.

Definition at line 20226 of file core.py.

20226def _workspace_asset_set_path(workspace_root: str, case_path: str) -> str:
20227 """!
20228 @brief Return the mutable asset-set pointer associated with a case config name.
20229 @param[in] workspace_root Owning workspace.
20230 @param[in] case_path Source case path.
20231 @return Absolute asset-set YAML path.
20232 """
20233 name = _workspace_asset_set_name(workspace_root, case_path)
20234 return os.path.join(workspace_root, "assets", "sets", f"{name}.yml")
20235
20236
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _find_stored_asset()

picurv_cli.core._find_stored_asset ( str  workspace_root,
dict  provider 
)
protected

Find a published object built from exactly this provider's inputs.

Asset sets are named after case files, so they miss an object another case file already built from identical inputs. The provider identity is what the object was built from, so any published object carrying it is reusable; the lowest asset id is taken so repeated lookups agree. Payload checksums are verified when the object is materialized, not here.

Parameters
[in]workspace_rootOwning workspace.
[in]providerProvider entry from build_case_asset_graph().
Returns
Asset reference in the shape _publish_asset_object() returns, or None.

Definition at line 20237 of file core.py.

20237def _find_stored_asset(workspace_root: str, provider: dict):
20238 """!
20239 @brief Find a published object built from exactly this provider's inputs.
20240 @details Asset sets are named after case files, so they miss an object another case
20241 file already built from identical inputs. The provider identity is what the
20242 object was built from, so any published object carrying it is reusable; the
20243 lowest asset id is taken so repeated lookups agree. Payload checksums are
20244 verified when the object is materialized, not here.
20245 @param[in] workspace_root Owning workspace.
20246 @param[in] provider Provider entry from build_case_asset_graph().
20247 @return Asset reference in the shape _publish_asset_object() returns, or None.
20248 """
20249 kind_dir = ASSET_KIND_DIRECTORIES.get(provider["kind"])
20250 if not kind_dir:
20251 return None
20252 objects_root = os.path.join(workspace_root, "assets", "objects", kind_dir)
20253 if not os.path.isdir(objects_root):
20254 return None
20255 for asset_id in sorted(os.listdir(objects_root)):
20256 object_root = os.path.join(objects_root, asset_id)
20257 manifest = _read_json_if_exists(os.path.join(object_root, "asset.json"))
20258 if not (isinstance(manifest, dict)
20259 and manifest.get("schema_version") == ASSET_MANIFEST_SCHEMA_VERSION
20260 and manifest.get("asset_id") == asset_id
20261 and manifest.get("kind") == provider["kind"]
20262 and manifest.get("provider_spec_sha256") == provider["spec_sha256"]):
20263 continue
20264 return {
20265 "asset_id": asset_id,
20266 "kind": provider["kind"],
20267 "provider": manifest.get("provider", provider["provider"]),
20268 "provider_spec_sha256": provider["spec_sha256"],
20269 "object": os.path.relpath(object_root, workspace_root).replace(os.sep, "/"),
20270 "files": manifest.get("files", []),
20271 "inspection": manifest.get("inspection", []),
20272 }
20273 return None
20274
20275
Here is the call graph for this function:
Here is the caller graph for this function:

◆ plan_run_assets()

dict picurv_cli.core.plan_run_assets ( dict  case_cfg,
str  case_path,
  skip_kinds = () 
)

Plan reuse/build/runtime actions for all configured run providers.

Parameters
[in]case_cfgParsed case configuration.
[in]case_pathSource case path.
[in]skip_kindsKinds the run will not read; planned as unused, never built.
Returns
Workspace, graph, set path, and per-provider actions.

Definition at line 20276 of file core.py.

20276def plan_run_assets(case_cfg: dict, case_path: str, skip_kinds=()) -> dict:
20277 """!
20278 @brief Plan reuse/build/runtime actions for all configured run providers.
20279 @param[in] case_cfg Parsed case configuration.
20280 @param[in] case_path Source case path.
20281 @param[in] skip_kinds Kinds the run will not read; planned as `unused`, never built.
20282 @return Workspace, graph, set path, and per-provider actions.
20283 """
20284 workspace_root = find_workspace_root(case_path)
20285 graph = build_case_asset_graph(case_cfg, case_path)
20286 asset_set = {}
20287 set_path = None
20288 if workspace_root:
20289 set_path = _workspace_asset_set_path(workspace_root, case_path)
20290 if os.path.isfile(set_path):
20291 asset_set = read_yaml_file(set_path)
20292 refs = asset_set.get("assets", {}) if isinstance(asset_set, dict) else {}
20293 actions = []
20294 for provider in graph["providers"]:
20295 reference = refs.get(provider["kind"]) if isinstance(refs, dict) else None
20296 matches = bool(
20297 isinstance(reference, dict)
20298 and reference.get("provider_spec_sha256") == provider["spec_sha256"]
20299 and workspace_root
20300 and os.path.isfile(os.path.join(
20301 workspace_root, reference.get("object", ""), "asset.json"
20302 ))
20303 )
20304 adopted = False
20305 if provider["kind"] in skip_kinds:
20306 actions.append({**provider, "action": "unused", "reference": None, "adopted": False})
20307 continue
20308 if not matches and workspace_root and provider["execution"] != "runtime-c":
20309 # The asset set is named after the case file, so a copied or renamed case
20310 # has none; identity is the provider's, so an object built for another case
20311 # file with the same inputs is the same asset.
20312 reference = _find_stored_asset(workspace_root, provider)
20313 matches = adopted = reference is not None
20314 if provider["execution"] == "runtime-c":
20315 action = "runtime-c"
20316 elif matches:
20317 action = "reuse"
20318 else:
20319 action = "build"
20320 actions.append({**provider, "action": action, "reference": reference if matches else None,
20321 "adopted": adopted})
20322 return {
20323 "workspace_root": workspace_root,
20324 "graph": graph,
20325 "asset_set_path": set_path,
20326 "actions": actions,
20327 }
20328
20329
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _materialize_asset_file()

str picurv_cli.core._materialize_asset_file ( str  source,
str  destination 
)
protected

Expose one immutable shared-asset file through reflink, hardlink, or copy.

Parameters
[in]sourceImmutable asset payload file.
[in]destinationRun-local destination.
Returns
Materialization mode used.

Definition at line 20330 of file core.py.

20330def _materialize_asset_file(source: str, destination: str) -> str:
20331 """!
20332 @brief Expose one immutable shared-asset file through reflink, hardlink, or copy.
20333 @param[in] source Immutable asset payload file.
20334 @param[in] destination Run-local destination.
20335 @return Materialization mode used.
20336 """
20337 os.makedirs(os.path.dirname(destination), exist_ok=True)
20338 temporary = f"{destination}.tmp.{os.getpid()}"
20339 try:
20340 cp = shutil.which("cp")
20341 if cp:
20342 result = subprocess.run(
20343 [cp, "--reflink=always", "--preserve=mode,timestamps", source, temporary],
20344 text=True, capture_output=True, check=False,
20345 )
20346 if result.returncode == 0:
20347 os.replace(temporary, destination)
20348 return "reflink"
20349 if os.path.lexists(temporary):
20350 os.remove(temporary)
20351 try:
20352 os.link(source, temporary)
20353 os.replace(temporary, destination)
20354 return "hardlink"
20355 except OSError:
20356 if os.path.lexists(temporary):
20357 os.remove(temporary)
20358 shutil.copy2(source, temporary)
20359 os.replace(temporary, destination)
20360 return "copy"
20361 finally:
20362 if os.path.lexists(temporary):
20363 os.remove(temporary)
20364
20365
Here is the caller graph for this function:

◆ materialize_run_assets()

dict picurv_cli.core.materialize_run_assets ( str  run_dir,
dict  case_cfg,
str  case_path,
bool   require_precomputed = False,
bool   fetch_missing = False,
  skip_kinds = () 
)

Resolve/build workspace assets and write the exact run input lock.

Parameters
[in]run_dirRun receiving immutable input exposures.
[in]case_cfgParsed case config.
[in]case_pathSource case path.
[in]require_precomputedRefuse any missing deterministic asset.
[in]fetch_missingRequest remote fetch before local build.
[in]skip_kindsKinds the run will not read (see unused_asset_kinds()).
Returns
Written lock mapping, or a standalone-provider summary outside a workspace.

Definition at line 20366 of file core.py.

20368 fetch_missing: bool = False, skip_kinds=()) -> dict:
20369 """!
20370 @brief Resolve/build workspace assets and write the exact run input lock.
20371 @param[in] run_dir Run receiving immutable input exposures.
20372 @param[in] case_cfg Parsed case config.
20373 @param[in] case_path Source case path.
20374 @param[in] require_precomputed Refuse any missing deterministic asset.
20375 @param[in] fetch_missing Request remote fetch before local build.
20376 @param[in] skip_kinds Kinds the run will not read (see unused_asset_kinds()).
20377 @return Written lock mapping, or a standalone-provider summary outside a workspace.
20378 """
20379 plan = plan_run_assets(case_cfg, case_path, skip_kinds)
20380 workspace_root = plan["workspace_root"]
20381 if not workspace_root:
20382 return {
20383 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20384 "workspace": None,
20385 "assets": {},
20386 "runtime_providers": {
20387 item["kind"]: item["provider"] for item in plan["actions"]
20388 if item["execution"] == "runtime-c"
20389 },
20390 }
20391 enforce_workspace_version(workspace_root)
20392 missing = [item for item in plan["actions"] if item["action"] == "build"]
20393 if missing and fetch_missing:
20394 # Storage imports this module indirectly, so remote asset recovery is routed
20395 # through a late import that cannot create a module cycle at startup.
20396 try:
20397 from .storage import restore_missing_workspace_assets
20398 except ImportError:
20399 restore_missing_workspace_assets = getattr(
20400 _storage_module, "restore_missing_workspace_assets", None
20401 )
20402 restored = restore_missing_workspace_assets(
20403 workspace_root, [item["spec_sha256"] for item in missing]
20404 ) if restore_missing_workspace_assets else {}
20405 if restored:
20406 recovered_refs = {
20407 item["kind"]: restored[item["spec_sha256"]]
20408 for item in missing if item["spec_sha256"] in restored
20409 }
20410 if recovered_refs:
20411 _write_workspace_asset_set(
20412 workspace_root, case_path, plan["graph"], recovered_refs
20413 )
20414 plan = plan_run_assets(case_cfg, case_path, skip_kinds)
20415 missing = [item for item in plan["actions"] if item["action"] == "build"]
20416 if missing and require_precomputed:
20417 names = ", ".join(f"{item['kind']}={item['provider']}" for item in missing)
20418 raise ValueError(
20419 f"Required precomputed asset(s) are missing or stale: {names}. "
20420 f"Run 'picurv precompute --case {case_path} --only "
20421 + ",".join(item["kind"] for item in missing) + "'."
20422 )
20423 if missing:
20424 precompute_case_assets(
20425 workspace_root, case_cfg, case_path,
20426 requested=[item["kind"] for item in missing],
20427 precomputable_only=True,
20428 )
20429 plan = plan_run_assets(case_cfg, case_path, skip_kinds)
20430 still_missing = [item for item in plan["actions"] if item["action"] == "build"]
20431 if still_missing:
20432 raise ValueError(
20433 "Asset generation completed without satisfying: "
20434 + ", ".join(item["kind"] for item in still_missing)
20435 )
20436
20437 adopted = {
20438 item["kind"]: item["reference"] for item in plan["actions"] if item.get("adopted")
20439 }
20440 if adopted:
20441 # Found by provider identity rather than through this case's own set; record it
20442 # there so the next run of this case file resolves it directly.
20443 _write_workspace_asset_set(workspace_root, case_path, plan["graph"], adopted)
20444 assets = {}
20445 for item in plan["actions"]:
20446 reference = item.get("reference")
20447 if item["action"] != "reuse" or not isinstance(reference, dict):
20448 continue
20449 object_root = os.path.join(workspace_root, reference["object"])
20450 manifest = _read_json_if_exists(os.path.join(object_root, "asset.json"))
20451 if not isinstance(manifest, dict) or manifest.get("asset_id") != reference.get("asset_id"):
20452 raise ValueError(f"Asset object is missing or invalid: {object_root}")
20453 exposed = []
20454 for file_info in manifest.get("files", []):
20455 relative = file_info["path"]
20456 source = os.path.join(object_root, "payload", *relative.split("/"))
20457 if _asset_file_sha256(source) != file_info.get("sha256"):
20458 raise ValueError(f"Asset payload checksum mismatch: {source}")
20459 destination = os.path.join(run_dir, *relative.split("/"))
20460 mode = _materialize_asset_file(source, destination)
20461 exposed.append({"path": relative, "mode": mode, "sha256": file_info["sha256"]})
20462 assets[item["kind"]] = {**reference, "exposed": exposed}
20463 print(f"[INFO] Asset {item['kind']}: reuse {reference['asset_id']}")
20464 runtime_providers = {
20465 item["kind"]: {
20466 "provider": item["provider"],
20467 "provider_spec_sha256": item["spec_sha256"],
20468 }
20469 for item in plan["actions"]
20470 if item["execution"] == "runtime-c" and item["action"] != "unused"
20471 }
20472 for kind, provider in runtime_providers.items():
20473 print(f"[INFO] Runtime provider {kind}: {provider['provider']} (generated by simulator)")
20474 lock = {
20475 "schema_version": ASSET_LOCK_SCHEMA_VERSION,
20476 "workspace": workspace_root,
20477 "case_sha256": plan["graph"]["case_sha256"],
20478 "created_at": datetime.now().astimezone().isoformat(),
20479 "assets": assets,
20480 "runtime_providers": runtime_providers,
20481 }
20482 write_yaml_file(os.path.join(run_dir, "inputs", "assets.lock.yml"), lock)
20483 write_software_lock(run_dir)
20484 return lock
20485
20486
Here is the call graph for this function:
Here is the caller graph for this function:

◆ precompute_workflow()

picurv_cli.core.precompute_workflow (   args)

Resolve, preflight, and atomically publish reusable workspace assets.

Parameters
[in]argsParsed precompute command arguments.

Definition at line 20487 of file core.py.

20487def precompute_workflow(args):
20488 """!
20489 @brief Resolve, preflight, and atomically publish reusable workspace assets.
20490 @param[in] args Parsed precompute command arguments.
20491 """
20492 case_path = os.path.abspath(args.case)
20493 workspace_root = find_workspace_root(case_path, os.getcwd())
20494 if not workspace_root:
20495 raise ValueError(
20496 "Precompute requires an initialized PICurv workspace. Run 'picurv init ...', "
20497 "then use its config/case.yml."
20498 )
20499 case_cfg = read_yaml_file(case_path)
20500 raw_only = getattr(args, "only", None)
20501 requested = None
20502 if raw_only and str(raw_only).strip().lower() != "all":
20503 requested = [token.strip() for token in str(raw_only).split(",") if token.strip()]
20504 graph = build_case_asset_graph(case_cfg, case_path)
20505 selected = _asset_selection(graph, requested)
20506 print(f"[INFO] Workspace : {workspace_root}")
20507 print(f"[INFO] Case : {os.path.relpath(case_path, workspace_root)}")
20508 print("[INFO] Asset dependency plan:")
20509 for provider in selected:
20510 dependencies = ",".join(provider.get("dependencies", [])) or "none"
20511 print(
20512 f" - {provider['kind']}: {provider['provider']} "
20513 f"[{provider['execution']}], dependencies={dependencies}"
20514 )
20515 result = precompute_case_assets(workspace_root, case_cfg, case_path, requested=requested)
20516 for kind, reference in sorted(result["assets"].items()):
20517 print(f"[SUCCESS] {kind}: {reference['asset_id']} ({reference['object']})")
20518 print(f"[SUCCESS] Asset set: {os.path.relpath(result['set_path'], workspace_root)}")
20519
Here is the call graph for this function:
Here is the caller graph for this function:

◆ run_workflow()

picurv_cli.core.run_workflow (   args)

Main orchestrator for the 'run' command (local and Slurm modes).

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 20520 of file core.py.

20520def run_workflow(args):
20521 """!
20522 @brief Main orchestrator for the 'run' command (local and Slurm modes).
20523 @param[in] args Command-line style argument list supplied to the function.
20524 """
20525 if getattr(args, "dry_run", False):
20526 plan = build_run_dry_plan(args)
20527 render_run_dry_plan(plan, output_format=getattr(args, "output_format", "text"))
20528 if plan.get("blocking"):
20529 sys.exit(1)
20530 return
20531
20532 run_dir = None
20533 run_id = None
20534 output_dir_abs = None
20535 # A post-only invocation never resolves a restart source, so this stays None and
20536 # `build_run_manifest()` preserves whatever lineage the run was created with.
20537 run_lineage = None
20538 statistics_output_paths = []
20539 workflow_start = time.time()
20540 stages_completed = []
20541 configs = None
20542 submission_meta = {"launch_mode": "local", "no_submit": bool(args.no_submit), "stages": {}}
20543
20544 cluster_mode = bool(getattr(args, "cluster", None))
20545 cluster_cfg = None
20546 cluster_path = None
20547 solver_num_procs_effective = args.num_procs
20548 post_num_procs_effective = args.num_procs
20549
20550 if cluster_mode:
20551 cluster_path = os.path.abspath(args.cluster)
20552 cluster_cfg = read_yaml_file(cluster_path)
20553 validate_cluster_config(cluster_cfg, cluster_path)
20554 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
20555 if args.scheduler and args.scheduler.lower() != scheduler_type:
20556 emit_structured_error(
20557 ERROR_CODE_CFG_INCONSISTENT_COMBO, key="scheduler.type", file_path=cluster_path,
20558 message=f"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
20559 hint="Omit --scheduler, or give the scheduler cluster.yml names.",
20560 )
20561 sys.exit(1)
20562 if scheduler_type != "slurm":
20563 emit_structured_error(
20564 ERROR_CODE_CFG_INVALID_VALUE, key="scheduler.type", file_path=cluster_path,
20565 message=f"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported.",
20566 hint="Set scheduler.type: slurm in cluster.yml.",
20567 )
20568 sys.exit(1)
20569 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
20570 if (args.solve or args.post_process) and args.num_procs not in (1, cluster_tasks):
20571 fail_cli_usage(
20572 "In cluster mode, --num-procs must be "
20573 f"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
20574 hint="Omit -n to use the task count cluster.yml sets.",
20575 )
20576 if args.solve:
20577 solver_num_procs_effective = cluster_tasks
20578 if args.post_process:
20579 post_num_procs_effective = cluster_tasks
20580 submission_meta["launch_mode"] = "slurm"
20581 submission_meta["cluster_config"] = cluster_path
20582 submission_meta["no_submit"] = bool(args.no_submit)
20583 if args.solve and args.post_process:
20584 print(
20585 f"[INFO] Cluster mode enabled (Slurm). Solver and post stages use "
20586 f"{solver_num_procs_effective} MPI tasks from cluster.yml."
20587 )
20588 elif args.solve:
20589 print(f"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks from cluster.yml.")
20590 else:
20591 print(f"[INFO] Cluster mode enabled (Slurm). Post stage uses {post_num_procs_effective} MPI tasks from cluster.yml.")
20592
20593 # --- Guard: restart flags without --solve ---
20594 if not args.solve:
20595 if getattr(args, 'restart_from', None):
20596 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
20597 if getattr(args, 'continue_run', False):
20598 print("[WARNING] --continue only extends a solve in place and is ignored here. "
20599 "Post-processing always skips steps whose output is up to date; drop "
20600 "--continue from post-only commands.", file=sys.stderr)
20601
20602 # --- Stage 1: Solver (if requested) ---
20603 if args.solve:
20604 case_input_path = os.path.abspath(args.case)
20605 workspace_root = find_workspace_root(case_input_path, args.solver, args.monitor, os.getcwd())
20606 if workspace_root:
20607 enforce_workspace_version(workspace_root)
20608 enforce_reproducibility_policy(workspace_root)
20609 walltime_guard_policy = resolve_walltime_guard_policy(cluster_cfg) if cluster_mode else None
20610 configs = {
20611 'case': read_yaml_file(args.case), 'case_path': case_input_path,
20612 'solver': read_yaml_file(args.solver), 'solver_path': os.path.abspath(args.solver),
20613 'monitor': read_yaml_file(args.monitor), 'monitor_path': os.path.abspath(args.monitor),
20614 'walltime_guard_policy': walltime_guard_policy,
20615 'statistics_state': getattr(args, "statistics_state", None) or "reset",
20616 'decomposition': getattr(args, "decomposition", None),
20617 'cluster_cfg': cluster_cfg if cluster_mode else None,
20618 }
20619
20620 print("\n[INFO] Validating configuration files...")
20621 validate_simulation_configs(
20622 configs['case'], configs['solver'], configs['monitor'],
20623 args.case, args.solver, args.monitor,
20624 restart_source_given=bool(getattr(args, 'restart_from', None) or getattr(args, 'continue_run', False)),
20625 )
20626 print("[SUCCESS] All configuration files passed validation.\n")
20627
20628 continue_mode = getattr(args, 'continue_run', False)
20629
20630 if continue_mode:
20631 if not args.run_dir:
20632 fail_cli_usage(RESTART_RUN_DIR_REQUIRED_MESSAGE)
20633 run_dir = os.path.abspath(args.run_dir)
20634 if not os.path.isdir(run_dir):
20635 emit_structured_error(
20636 ERROR_CODE_CFG_FILE_NOT_FOUND,
20637 key="run-dir",
20638 file_path=run_dir,
20639 message="Specified run directory not found.",
20640 )
20641 sys.exit(1)
20642 adopt_relocated_artifact(run_dir)
20643 run_id = os.path.basename(run_dir)
20644 else:
20645 runs_root = workspace_artifact_root(workspace_root, "runs")
20646 run_id = allocate_generated_run_id(
20647 runs_root, configs["case"], configs["case_path"]
20648 )
20649 run_dir = os.path.join(runs_root, run_id)
20650
20651 if workspace_root and os.path.commonpath([os.path.abspath(run_dir), workspace_root]) != workspace_root:
20652 fail_cli_usage("A workspace run directory must remain below the owning workspace.")
20653 if not continue_mode and os.path.exists(run_dir):
20654 raise ValueError(f"Generated run directory already exists: {run_dir}")
20655 ensure_run_layout(run_dir)
20656 # Checked after the skeleton exists so a resumed run is judged on what is
20657 # actually there, and before any stage writes into it.
20658 enforce_run_directory_structure(run_dir)
20659
20660 # Captured before restart resolution, which materializes the checkpoint it reads.
20661 staged_records = (capture_continuation_records(run_dir, resolve_run_restart_dir(run_dir, configs["monitor"]))
20662 if continue_mode else None)
20663 try:
20664 resolved_restart_source_dir, is_continue, run_lineage = resolve_restart_source(
20665 args, configs["case"], configs["solver"], configs["monitor"], run_dir
20666 )
20667 select_run_petsc_index_bytes(args, configs["case"], configs["case_path"], configs["monitor"],
20668 resolved_restart_source_dir)
20669 except ValueError as e:
20670 # Restart resolution is the first thing that can refuse a run, and the
20671 # skeleton is already on disk. Leaving it behind puts an empty directory in
20672 # runs/ that is indistinguishable from a real run to `ls`, to storage
20673 # status, and to anyone browsing the workspace, once per refused attempt.
20674 discard_unused_run_directory(run_dir, created=not continue_mode)
20675 emit_structured_error(
20676 ERROR_CODE_CFG_INCONSISTENT_COMBO,
20677 key="restart",
20678 file_path=args.case,
20679 message=str(e),
20680 hint=_STORAGE_RESTORE_HINT if isinstance(e.__cause__, StorageError) else _RESTART_HINT,
20681 )
20682 sys.exit(1)
20683
20684 config_dir = os.path.join(run_dir, "config")
20685 if continue_mode:
20686 print(f"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
20687 else:
20688 print(f"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
20689
20690 # Read before staging, which rewrites a continuation's own grid inputs.
20691 source_run_dir = run_dir if is_continue else (
20692 os.path.join(workspace_root or "", run_lineage["parent_path"]) if run_lineage else None)
20693 source_grid_inputs = None
20694 if source_run_dir and os.path.isdir(source_run_dir):
20695 source_case_path = load_active_run_configuration(source_run_dir).get("case")
20696 if source_case_path and os.path.isfile(source_case_path):
20697 source_grid_inputs = restart_grid_inputs(source_run_dir, read_yaml_file(source_case_path))
20698 active_config = snapshot_run_configuration(
20699 run_dir,
20700 {
20701 "case": args.case,
20702 "solver": args.solver,
20703 "monitor": args.monitor,
20704 "cluster": cluster_path if cluster_mode else None,
20705 },
20706 continuation=continue_mode,
20707 )
20708 # Pinned before the asset lock and manifest are written, so both record the
20709 # executables this run will launch rather than the installation's.
20710 had_pinned_executables = bool(load_run_executables(run_dir))
20711 record_run_petsc_index_bytes(run_dir)
20712 try:
20713 apply_run_executable_pinning(run_dir, active_config["revision"], args, workspace_root)
20714 except ValueError as exc:
20715 discard_unused_run_directory(run_dir, created=not continue_mode)
20716 print(f"[FATAL] {exc}", file=sys.stderr)
20717 sys.exit(1)
20718 try:
20719 asset_lock = materialize_run_assets(
20720 run_dir,
20721 configs["case"],
20722 configs["case_path"],
20723 require_precomputed=bool(getattr(args, "require_precomputed", False)),
20724 fetch_missing=bool(getattr(args, "fetch_missing", False)),
20725 skip_kinds=unused_asset_kinds(configs["case"], configs["solver"]),
20726 )
20727 except ValueError as exc:
20728 discard_unused_run_directory(run_dir, created=not continue_mode)
20729 print(f"[FATAL] {exc}", file=sys.stderr)
20730 sys.exit(1)
20731 # Publish identity and lineage before either executable starts. Besides making
20732 # a staged run self-describing, this lets a dependent post job resolve branch
20733 # ownership even though the interactive conductor has already exited.
20734 write_json_file(
20735 os.path.join(run_dir, "manifest.json"),
20736 build_run_manifest(
20737 run_dir,
20738 run_id,
20739 workspace_root=workspace_root,
20740 launch_mode="slurm" if cluster_mode else "local",
20741 num_procs=solver_num_procs_effective,
20742 post_num_procs=post_num_procs_effective,
20743 stages_requested={"solve": True, "post_process": bool(args.post_process)},
20744 inputs={
20745 "case": _relative_to_workspace(args.case, workspace_root),
20746 "solver": _relative_to_workspace(args.solver, workspace_root),
20747 "monitor": _relative_to_workspace(args.monitor, workspace_root),
20748 },
20749 asset_lock=asset_lock,
20750 lineage=run_lineage,
20751 ),
20752 )
20753
20754 print("\n" + "="*25 + " SOLVER STAGE " + "="*25)
20755 source_files = {'Case': args.case, 'Solver': args.solver, 'Monitor': args.monitor}
20756 generated_config_dir = (
20757 config_dir if active_config["revision"] == "initial"
20758 else os.path.join(config_dir, "history", active_config["revision"])
20759 )
20760 monitor_files = prepare_monitor_files(
20761 run_dir, run_id, configs['monitor'], source_files,
20762 config_dir=generated_config_dir,
20763 )
20764 if resolved_restart_source_dir:
20765 print(f"[INFO] Restart source: {resolved_restart_source_dir}")
20766 if is_continue:
20767 print("[INFO] Continue mode: logs will be appended, not overwritten.")
20768 control_file = generate_solver_control_file(
20769 run_dir,
20770 run_id,
20771 configs,
20772 solver_num_procs_effective,
20773 monitor_files,
20774 restart_source_dir=resolved_restart_source_dir,
20775 continue_mode=is_continue,
20776 config_dir=generated_config_dir,
20777 )
20778 active_config = archive_active_generated_configuration(
20779 run_dir,
20780 [
20781 control_file,
20782 monitor_files.get("whitelist"),
20783 monitor_files.get("profile"),
20784 *glob.glob(os.path.join(generated_config_dir, "bcs*.run")),
20785 ],
20786 )
20787 control_file = load_active_run_configuration(run_dir).get("control", control_file)
20788
20789 solver_exe = run_executable_path(run_dir, "simulator")
20790 # Staging is the last point before the binary's identity becomes the one written
20791 # into this run's checkpoints, so a stale build is worth saying out loud here.
20792 # A run kept on its earlier pin is meant to differ from the source, so it is not.
20793 if not had_pinned_executables or getattr(args, "pin_executables", None) is True:
20794 warn_on_stale_runtime_binaries(runtime_build_identities(run_dir=run_dir))
20795 solver_args = build_petsc_diagnostics_args(configs["monitor"], run_dir, "Solver") + ["-control_file", control_file]
20796 if source_grid_inputs is not None:
20797 warn_on_restart_grid_change(source_grid_inputs, restart_grid_inputs(run_dir, configs["case"]))
20798 carries_statistics = is_continue or str(configs.get("statistics_state", "reset")).lower() == "carry"
20799 if carries_statistics and normalize_field_statistics_config(configs["monitor"])["enabled"]:
20800 refusal = check_restart_continuation(solver_exe, solver_args, run_dir,
20801 config_search_anchor=configs["case_path"])
20802 if refusal:
20803 if continue_mode:
20804 restore_continuation_records(run_dir, staged_records, active_config["revision"])
20805 undone = f"{os.path.relpath(run_dir)} is left as it was"
20806 else:
20807 discard_unused_run_directory(run_dir, created=True, staged=True)
20808 undone = "the branch run directory was removed"
20809 print(f"[FATAL] This restart cannot continue its statistics windows: {refusal}\n"
20810 f" Nothing was launched or submitted, and {undone}.", file=sys.stderr)
20811 sys.exit(1)
20812 if cluster_mode:
20813 scheduler_dir = os.path.join(run_dir, "scheduler")
20814 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
20815 solver_log = os.path.join(scheduler_dir, "solver_%j.out")
20816 solver_err = os.path.join(scheduler_dir, "solver_%j.err")
20817 solver_cmd = build_cluster_launch_command(
20818 cluster_cfg,
20819 solver_exe,
20820 solver_args,
20821 config_search_anchor=args.case,
20822 extra_search_anchors=[cluster_path],
20823 )
20824 render_slurm_script(
20825 solver_script,
20826 f"{run_id}_solve",
20827 cluster_cfg,
20828 solver_cmd,
20829 run_dir,
20830 solver_log,
20831 solver_err,
20832 env_vars={"LOG_LEVEL": configs['monitor'].get('logging', {}).get('verbosity', 'INFO').upper()},
20833 shell_env_vars=build_walltime_guard_exports(cluster_cfg),
20834 identity_check=(solver_exe, read_binary_build_identity(solver_exe).get("version_line"),
20835 "simulator" in load_run_executables(run_dir)),
20836 )
20837 submission_meta["stages"]["solve"] = {
20838 "script": solver_script,
20839 "submitted": False,
20840 "num_procs_effective": solver_num_procs_effective,
20841 "decomposition": _decomposition_record(configs.get("decomposition_resolved")),
20842 }
20843 print(f"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
20844 if not args.no_submit:
20845 submit_info = submit_sbatch(solver_script)
20846 submission_meta["stages"]["solve"].update(submit_info)
20847 submission_meta["stages"]["solve"]["submitted"] = True
20848 print(f"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
20849 stages_completed.append('solve')
20850 else:
20851 command = build_local_launch_command(
20852 solver_exe,
20853 solver_args,
20854 solver_num_procs_effective,
20855 config_search_anchor=configs["case_path"],
20856 )
20857 solver_log = os.path.join("scheduler", f"{run_id}_solver.log")
20858 submission_meta["stages"]["solve"] = {
20859 "command": command,
20860 "command_string": format_command_for_display(command),
20861 "log_file": solver_log,
20862 "submitted": False,
20863 "num_procs_effective": solver_num_procs_effective,
20864 "decomposition": _decomposition_record(configs.get("decomposition_resolved")),
20865 }
20866 if args.no_submit:
20867 print(f"[SUCCESS] Staged local solve stage; run it with "
20868 f"'picurv submit --run-dir {os.path.relpath(run_dir)}'.")
20869 else:
20870 try:
20871 with runtime_stage_lock(run_dir, "solver"):
20872 execute_command(command, run_dir, solver_log, configs['monitor'])
20873 except StorageError as exc:
20874 print(f"[FATAL] {exc}", file=sys.stderr)
20875 sys.exit(1)
20876 submission_meta["stages"]["solve"]["submitted"] = True
20877 submission_meta["stages"]["solve"]["executed"] = True
20878 submission_meta["stages"]["solve"]["completed_at"] = datetime.now().isoformat()
20879 stages_completed.append('solve')
20880
20881 # --- Stage 2: Post-Processing (if requested) ---
20882 if args.post_process:
20883 if not args.solve and getattr(args, "pin_executables", None) is not None:
20884 fail_cli_usage(
20885 "--pin-executables and --no-pin-executables apply when the solve stage is staged.",
20886 hint="Post-processing launches the postprocessor the run is pinned to, or the "
20887 "installation's when the run is not pinned.",
20888 )
20889 if args.run_dir:
20890 run_dir = os.path.abspath(args.run_dir)
20891 if not os.path.isdir(run_dir):
20892 emit_structured_error(ERROR_CODE_CFG_FILE_NOT_FOUND, key="run_dir", file_path=run_dir,
20893 message="Run directory not found.")
20894 sys.exit(1)
20895 print(f"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
20896 adopt_relocated_artifact(run_dir)
20897 enforce_run_directory_structure(run_dir)
20898 run_id = read_artifact_identity(run_dir)["run_id"]
20899
20900 print("\n" + "="*20 + " POST-PROCESSING STAGE " + "="*20)
20901 config_dir = os.path.join(run_dir, "config")
20902 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
20903
20904 if not all([case_path, monitor_path, solver_control_path]):
20905 print(f"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
20906 if not case_path:
20907 print(" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
20908 if not monitor_path:
20909 print(" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
20910 if not solver_control_path:
20911 print(" - No '.control' file found.", file=sys.stderr)
20912 sys.exit(1)
20913
20914 print(f"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
20915 print(f"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
20916
20917 case_cfg = read_yaml_file(case_path)
20918 monitor_cfg = read_yaml_file(monitor_path)
20919 post_cfg = read_yaml_file(args.post)
20920
20921 print("[INFO] Validating post-processing configuration...")
20922 validate_post_config(post_cfg, args.post, monitor_cfg, case_cfg)
20923 print("[SUCCESS] Post-processing configuration passed validation.\n")
20924
20925 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
20926 for relative in (
20927 post_cfg["_picurv_paths"]["visualization"],
20928 post_cfg["_picurv_paths"]["statistics"],
20929 post_cfg["_picurv_paths"]["spectra"],
20930 ):
20931 os.makedirs(os.path.join(run_dir, relative), exist_ok=True)
20932 archived_post_path = os.path.join(get_post_recipe_root(run_dir, post_cfg), "post.yml")
20933 os.makedirs(os.path.dirname(archived_post_path), exist_ok=True)
20934 if os.path.abspath(args.post) != os.path.abspath(archived_post_path):
20935 shutil.copy2(args.post, archived_post_path)
20936
20937 solver_sources_deferred = bool(args.solve and (cluster_mode or args.no_submit))
20938 recompute = bool(getattr(args, 'recompute_post', False))
20939 post_stages = resolve_post_stage_selection(getattr(args, 'only', None))
20940 try:
20941 if not args.solve:
20942 select_existing_run_petsc_index_bytes(run_dir, args)
20943 post_exe = run_executable_path(run_dir, "postprocessor")
20944 except ValueError as exc:
20945 post_exe = None
20946 post_exe_error = exc
20947 post_plan = build_post_execution_plan(
20948 run_dir,
20949 run_id,
20950 case_cfg,
20951 monitor_cfg,
20952 post_cfg,
20953 recompute=recompute,
20954 allow_source_frontier_scan=not solver_sources_deferred,
20955 postprocessor_sha=_file_sha256(post_exe) if post_exe else None,
20956 )
20957 # Only the checkpoints of steps that need work must be local: a step whose output
20958 # is up to date is not read, so an offload that archived it does not block the run.
20959 needed_steps = post_plan['steps_to_process'] + post_plan['steps_unavailable']
20960 if 'fields' in post_stages and not solver_sources_deferred and needed_steps:
20961 try:
20962 require_storage_payload_local(
20963 run_dir, "post-processing", checkpoints=needed_steps,
20964 )
20965 except StorageError as exc:
20966 emit_structured_error(
20967 ERROR_CODE_CFG_FILE_NOT_FOUND,
20968 key="storage",
20969 file_path=run_dir,
20970 message=str(exc),
20971 hint=_STORAGE_RESTORE_HINT,
20972 )
20973 sys.exit(1)
20974
20975 post_finalize_command = (
20976 build_post_finalize_command(run_dir, archived_post_path, post_cfg, post_stages)
20977 if 'fields' in post_stages else []
20978 )
20979 if post_stages != set(POST_STAGE_NAMES):
20980 print(f"[INFO] Post stages selected: {','.join(sorted(post_stages))}")
20981 # A stage the recipe gives no tasks has nothing to do; say so rather than
20982 # report success for work that was never possible.
20983 for stage, tasks in (("spectra", normalize_post_spectra_config(post_cfg)["tasks"]),
20984 ("reducers", normalize_post_reducers_config(post_cfg)["tasks"])):
20985 if stage in post_stages and not tasks:
20986 print(f"[WARNING] --only selects {stage}, but {args.post} defines no {stage} "
20987 "tasks; nothing is measured for it.", file=sys.stderr)
20988
20989 print(f"[INFO] Post recipe: {recipe_id}")
20990 print(f"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
20991 if post_plan['source_frontier_deferred']:
20992 print("[INFO] Source checkpoints: not scanned; the solver stage writes them before post starts.")
20993 elif post_plan['source_latest_step'] is not None:
20994 print(f"[INFO] Source checkpoints: {post_plan['source_committed_count']} committed, "
20995 f"latest at step {post_plan['source_latest_step']}.")
20996 else:
20997 print("[INFO] Source checkpoints: none committed yet.")
20998
20999 # A local launch plans again under the post lock and reports that plan, so only an
21000 # invocation that stages the work, or finds none, reports this one.
21001 executes_fields_now = (
21002 'fields' in post_stages and post_plan['skip_reason'] is None
21003 and not cluster_mode and not args.no_submit
21004 )
21005 if 'fields' in post_stages:
21006 if not executes_fields_now:
21007 report_post_plan(post_plan)
21008 warn_overlapping_post_recipes(run_dir, post_cfg, post_plan, post_stages)
21009 persist_post_resume_state(run_dir, post_plan, steps=post_plan['adopted_records'])
21010
21011 # Spectra run in the conductor rather than in the submitted job, so they can
21012 # only be measured when this invocation is the one doing the work. Under a
21013 # scheduler, with --no-submit, or before the solver has written anything,
21014 # the sources do not exist yet and the measurement is deferred instead.
21015 spectra_execute_now = (
21016 not cluster_mode
21017 and not args.no_submit
21018 and not post_plan['source_frontier_deferred']
21019 )
21020 fields_job_measures_spectra = (
21021 cluster_mode and 'fields' in post_stages and post_plan['skip_reason'] is None
21022 )
21023 if 'spectra' in post_stages and spectra_execute_now:
21024 spectra_summary = run_post_spectra_stage(
21025 run_dir,
21026 post_plan['resolved_post_cfg'],
21027 monitor_cfg,
21028 post_plan['source_data_directory'],
21029 _iter_post_steps(post_plan['owned_start_step'], post_plan['requested_end_step'],
21030 post_plan['step_interval']),
21031 plan=post_plan,
21032 recompute=recompute,
21033 )
21034 for artifact in spectra_summary['artifacts']:
21035 print(f"[INFO] Spectra output: {os.path.relpath(artifact)}")
21036 elif 'spectra' in post_stages and not fields_job_measures_spectra:
21037 reason = ("the solver stage has not produced output yet"
21038 if post_plan['source_frontier_deferred'] else
21039 "this invocation only stages work")
21040 print(f"[INFO] Spectra deferred: {reason}. Measure them once the run has "
21041 f"checkpoints with:")
21042 print(f"[INFO] picurv run --post-process --only spectra "
21043 f"--run-dir {os.path.relpath(run_dir)} --post {args.post}")
21044
21045 # Without the field stage nothing finalizes, so selected reducers run here, on
21046 # the profiles earlier field stages wrote.
21047 if 'reducers' in post_stages and 'fields' not in post_stages:
21048 try:
21049 run_post_reducers_stage(run_dir, post_plan['resolved_post_cfg'])
21050 except ValueError as exc:
21051 print(f"[FATAL] {exc}", file=sys.stderr)
21052 sys.exit(1)
21053
21054 if 'fields' not in post_stages:
21055 # --only selected a subset that excludes the field post-processor.
21056 print("[INFO] Skipping the field post-processor (--only "
21057 f"{','.join(sorted(post_stages))}).")
21058 stages_completed.append('post-process')
21059 elif post_plan['skip_reason'] is not None:
21060 diagnostic = post_plan.get('source_frontier_diagnostic') or {}
21061 first_incomplete = diagnostic.get('first_incomplete_step')
21062 if post_plan['skip_reason'] == 'already-complete-window':
21063 print("[INFO] Every requested step is up to date; the postprocessor is not launched.")
21064 elif post_plan['skip_reason'] == 'already-caught-up-to-current-source-frontier':
21065 print("[INFO] Every requested step with a committed checkpoint is up to date; "
21066 "the postprocessor is not launched.")
21067 else:
21068 print("[INFO] No requested step has a committed checkpoint yet; the "
21069 "postprocessor is not launched.")
21070 missing_files = diagnostic.get('missing_files_for_first_incomplete_step') or []
21071 if first_incomplete is not None and missing_files:
21072 print(f"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
21073 if first_incomplete is not None:
21074 print(
21075 "[INFO] Closest committed source steps: "
21076 f"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
21077 f"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
21078 )
21079 if post_finalize_command and post_plan['steps_current']:
21080 run_post_finalize_locked(
21081 run_dir, post_plan['recipe_fingerprint'], post_finalize_command
21082 )
21083 else:
21084 post_effective_cfg = post_plan['effective_post_cfg']
21085 post_io_cfg = post_effective_cfg.get('io', {})
21086 try:
21087 output_dir_rel = post_io_cfg['output_directory']
21088 output_prefix = post_io_cfg['output_filename_prefix']
21089 except KeyError as e:
21090 print(f"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
21091 sys.exit(1)
21092
21093 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
21094 os.makedirs(output_dir_abs, exist_ok=True)
21095 print(f"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
21096 statistics_output_paths = get_post_statistics_output_artifacts(post_effective_cfg, run_dir, monitor_cfg)
21097 for stats_path in statistics_output_paths:
21098 print(f"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
21099
21100 source_files_post = {'Case': case_path, 'Post-Profile': args.post}
21101 post_decomposition = resolve_post_decomposition(
21102 run_dir, post_num_procs_effective, getattr(args, "decomposition", None),
21103 cluster_cfg if cluster_mode else None)
21104 if post_decomposition["problems"]:
21105 print(f"[FATAL] {' '.join(post_decomposition['problems'])}", file=sys.stderr)
21106 sys.exit(1)
21107 print(f"[INFO] Post-processing {describe_decomposition(post_decomposition, post_num_procs_effective)}")
21108 post_recipe_file = generate_post_recipe_file(run_dir, run_id, post_effective_cfg, source_files_post,
21109 monitor_cfg, post_decomposition)
21110 if post_exe is None:
21111 print(f"[FATAL] {post_exe_error}", file=sys.stderr)
21112 sys.exit(1)
21113 post_args = build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor") + [
21114 "-control_file",
21115 solver_control_path,
21116 "-postprocessing_config_file",
21117 post_recipe_file,
21118 ]
21119 post_stage_meta = {
21120 "num_procs_effective": post_num_procs_effective,
21121 "decomposition": _decomposition_record(post_decomposition),
21122 "steps_planned": len(post_plan['steps_to_process']),
21123 "steps_current": post_plan['steps_current'],
21124 "recompute": recompute,
21125 "source_frontier_step": post_plan['source_frontier_step'],
21126 "source_frontier_deferred": post_plan['source_frontier_deferred'],
21127 "recipe_fingerprint": post_plan['recipe_fingerprint'],
21128 "finalize_command": post_finalize_command,
21129 "submitted": False,
21130 }
21131 if cluster_mode:
21132 scheduler_dir = os.path.join(run_dir, "scheduler")
21133 os.makedirs(scheduler_dir, exist_ok=True)
21134 post_script = os.path.join(scheduler_dir, "post.sbatch")
21135 post_log = os.path.join(scheduler_dir, "post_%j.out")
21136 post_err = os.path.join(scheduler_dir, "post_%j.err")
21137 post_cluster_cfg = cluster_cfg
21138 raw_post_cmd = build_cluster_launch_command(
21139 post_cluster_cfg,
21140 post_exe,
21141 post_args,
21142 config_search_anchor=case_path,
21143 extra_search_anchors=[cluster_path],
21144 force_num_procs=post_num_procs_effective,
21145 )
21146 post_cmd, _ = build_post_locked_command(
21147 run_dir,
21148 post_plan['recipe_fingerprint'],
21149 raw_post_cmd,
21150 create_wrapper=True,
21151 success_command=post_finalize_command,
21152 post_recipe=archived_post_path,
21153 postprocessor=post_exe,
21154 recompute=recompute,
21155 )
21156 spectra_follow = []
21157 if 'spectra' in post_stages:
21158 follow = build_spectra_follow_command(run_dir, args.post, post_effective_cfg,
21159 recompute=recompute)
21160 if follow:
21161 spectra_follow = [follow]
21162 print("[INFO] Spectra will be measured in the post job, after "
21163 "the field post-processor completes.")
21164 render_slurm_script(
21165 post_script,
21166 f"{run_id}_post",
21167 post_cluster_cfg,
21168 post_cmd,
21169 run_dir,
21170 post_log,
21171 post_err,
21172 env_vars={"LOG_LEVEL": monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()},
21173 follow_commands=spectra_follow,
21174 identity_check=(post_exe, read_binary_build_identity(post_exe).get("version_line"),
21175 "postprocessor" in load_run_executables(run_dir)),
21176 )
21177 submission_meta["stages"]["post-process"] = {"script": post_script, **post_stage_meta}
21178 print(f"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
21179
21180 if not args.no_submit:
21181 dependency_job = None
21182 if args.solve:
21183 dependency_job = submission_meta.get("stages", {}).get("solve", {}).get("job_id")
21184 submit_info = submit_sbatch(post_script, dependency=dependency_job)
21185 submission_meta["stages"]["post-process"].update(submit_info)
21186 submission_meta["stages"]["post-process"]["submitted"] = True
21187 if dependency_job:
21188 submission_meta["stages"]["post-process"]["dependency"] = f"afterok:{dependency_job}"
21189 print(f"[SUCCESS] Submitted post job: {submit_info['job_id']}")
21190 stages_completed.append('post-process')
21191 else:
21192 raw_command = build_local_launch_command(
21193 post_exe,
21194 post_args,
21195 post_num_procs_effective,
21196 config_search_anchor=case_path,
21197 allow_single_rank_launcher_override=True,
21198 force_num_procs=post_num_procs_effective,
21199 )
21200 command, _ = build_post_locked_command(
21201 run_dir,
21202 post_plan['recipe_fingerprint'],
21203 raw_command,
21204 create_wrapper=True,
21205 success_command=post_finalize_command,
21206 post_recipe=archived_post_path,
21207 postprocessor=post_exe,
21208 recompute=recompute,
21209 )
21210 post_log = os.path.join("scheduler", f"{run_id}_{output_prefix}.log")
21211 submission_meta["stages"]["post-process"] = {
21212 "command": command,
21213 "command_string": format_command_for_display(command),
21214 "log_file": post_log,
21215 **post_stage_meta,
21216 }
21217 if args.no_submit:
21218 print(f"[SUCCESS] Staged local post command: {post_log}")
21219 else:
21220 execute_command(command, run_dir, post_log, monitor_cfg)
21221 submission_meta["stages"]["post-process"]["submitted"] = True
21222 submission_meta["stages"]["post-process"]["executed"] = True
21223 submission_meta["stages"]["post-process"]["completed_at"] = datetime.now().isoformat()
21224 stages_completed.append('post-process')
21225
21226 if run_dir:
21227 workspace_root = find_workspace_root(run_dir)
21228 manifest_inputs = {}
21229 if args.solve:
21230 manifest_inputs["case"] = _relative_to_workspace(args.case, workspace_root)
21231 manifest_inputs["solver"] = _relative_to_workspace(args.solver, workspace_root)
21232 manifest_inputs["monitor"] = _relative_to_workspace(args.monitor, workspace_root)
21233 if args.post_process:
21234 manifest_inputs["post"] = _relative_to_workspace(args.post, workspace_root)
21235 if cluster_mode:
21236 manifest_inputs["cluster"] = _relative_to_workspace(cluster_path, workspace_root)
21237 if submission_meta.get("stages"):
21238 # A stage this invocation did not stage keeps its record (a continued solve
21239 # leaves the run's earlier post job visible to cancel and summarize).
21240 submission_path = os.path.join(run_dir, "scheduler", "submission.json")
21241 for stage_name, stage_meta in ((_read_json_if_exists(submission_path) or {}).get("stages") or {}).items():
21242 submission_meta["stages"].setdefault(stage_name, stage_meta)
21243 write_json_file(submission_path, submission_meta)
21244 asset_lock = read_yaml_file(os.path.join(run_dir, "inputs", "assets.lock.yml")) \
21245 if os.path.isfile(os.path.join(run_dir, "inputs", "assets.lock.yml")) else {}
21246 manifest = build_run_manifest(
21247 run_dir,
21248 run_id,
21249 workspace_root=workspace_root,
21250 launch_mode="slurm" if cluster_mode else "local",
21251 num_procs=solver_num_procs_effective,
21252 post_num_procs=post_num_procs_effective,
21253 stages_requested={"solve": bool(args.solve), "post_process": bool(args.post_process)},
21254 stages_completed=stages_completed,
21255 inputs=manifest_inputs,
21256 asset_lock=asset_lock,
21257 submission=submission_meta,
21258 lineage=run_lineage,
21259 )
21260 write_json_file(os.path.join(run_dir, "manifest.json"), manifest)
21261
21262 if stages_completed:
21263 elapsed = time.time() - workflow_start
21264 mins, secs = divmod(int(elapsed), 60)
21265 hrs, mins = divmod(mins, 60)
21266 if hrs > 0:
21267 time_str = f"{hrs}h {mins}m {secs}s"
21268 elif mins > 0:
21269 time_str = f"{mins}m {secs}s"
21270 else:
21271 time_str = f"{secs}s"
21272
21273 print("\n" + "=" * 60)
21274 print(" RUN SUMMARY")
21275 print("=" * 60)
21276 print(f" Run ID : {run_id}")
21277 print(f" Run directory : {os.path.relpath(run_dir)}")
21278 # A staged or Slurm-submitted stage has not run yet when this prints.
21279 staged = bool(cluster_mode or args.no_submit)
21280 print(f" Wall-clock : {time_str}")
21281 stages_label = "Stages staged" if args.no_submit else "Stages submitted" if staged else "Stages"
21282 print(f" {stages_label:<16}: {', '.join(stages_completed)}")
21283 print(f" Launch mode : {'slurm' if cluster_mode else 'local'}")
21284 if args.solve:
21285 print(f" Solver MPI procs: {solver_num_procs_effective}")
21286 if args.post_process:
21287 print(f" Post MPI procs : {post_num_procs_effective}")
21288 if args.solve and configs:
21289 total_steps = configs['case'].get('run_control', {}).get('total_steps', '?')
21290 result_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS['output'])
21291 print(f" {'Steps planned' if staged else 'Steps run':<16}: {total_steps}")
21292 print(f" Solver output : {os.path.relpath(result_dir)}")
21293 if 'post-process' in stages_completed and output_dir_abs:
21294 print(f" Post output : {os.path.relpath(output_dir_abs)}")
21295 for stats_path in statistics_output_paths:
21296 print(f" Stats output : {os.path.relpath(stats_path)}")
21297 # Report the configured log directory, not the default. An authorized external
21298 # path is exactly the case where the two differ, and it is the case where the
21299 # reader most needs to know where the logs actually went.
21300 log_display = os.path.join(run_dir, CANONICAL_RUN_PATHS['logs'])
21301 relative_log = os.path.relpath(log_display)
21302 # A relative path that climbs out of the working directory is harder to read
21303 # than the absolute one it stands for.
21304 print(f" Logs : "
21305 f"{log_display if relative_log.startswith('..') else relative_log}")
21306 if cluster_mode or submission_meta.get("stages"):
21307 submission_file = os.path.join(run_dir, "scheduler", "submission.json")
21308 print(f" Submission meta : {os.path.relpath(submission_file)}")
21309 print("=" * 60)
21310
21311
Here is the call graph for this function:
Here is the caller graph for this function:

◆ parse_case_index_tsv()

list picurv_cli.core.parse_case_index_tsv ( str  tsv_path)

Parse a case_index.tsv file back into a list of case entry dicts.

Parameters
[in]tsv_pathPath to the case_index.tsv file.
Returns
List of dicts with keys: index, case_id, run_dir, control_file, post_recipe_file, log_level, post_prefix.

Definition at line 21312 of file core.py.

21312def parse_case_index_tsv(tsv_path: str) -> list:
21313 """!
21314 @brief Parse a case_index.tsv file back into a list of case entry dicts.
21315 @param[in] tsv_path Path to the case_index.tsv file.
21316 @return List of dicts with keys: index, case_id, run_dir, control_file,
21317 post_recipe_file, log_level, post_prefix.
21318 """
21319 entries = []
21320 with open(tsv_path) as f:
21321 for line in f:
21322 line = line.strip()
21323 if not line:
21324 continue
21325 parts = line.split("\t")
21326 entries.append({
21327 "index": int(parts[0]),
21328 "case_id": parts[1],
21329 "run_dir": parts[2],
21330 "control_file": parts[3],
21331 "post_recipe_file": parts[4],
21332 "log_level": parts[5],
21333 "post_prefix": parts[6],
21334 })
21335 return entries
21336
21337
Here is the caller graph for this function:

◆ resolve_sweep_stage_executables()

dict picurv_cli.core.resolve_sweep_stage_executables ( list  run_dirs)

Choose the executables an array stage launches for every member of a study.

Array tasks share one script, which reads each member's RUN_DIR from the case index, so pinned members launch their own copy through the same $RUN_DIR-relative path. Members must agree: a study half pinned, or pinned at different paths, has no single command that launches each member's build.

Parameters
[in]run_dirsMember run directories the stage covers.
Returns
Mapping of executable name to (path token, staged --version line or None).
Exceptions
ValueErrorwhen members disagree about pinning.

Definition at line 21338 of file core.py.

21338def resolve_sweep_stage_executables(run_dirs: list) -> dict:
21339 """!
21340 @brief Choose the executables an array stage launches for every member of a study.
21341
21342 @details Array tasks share one script, which reads each member's `RUN_DIR` from the
21343 case index, so pinned members launch their own copy through the same
21344 `$RUN_DIR`-relative path. Members must agree: a study half pinned, or pinned
21345 at different paths, has no single command that launches each member's build.
21346 @param[in] run_dirs Member run directories the stage covers.
21347 @return Mapping of executable name to `(path token, staged --version line or None)`.
21348 @throws ValueError when members disagree about pinning.
21349 """
21350 pins = [load_run_executables(run_dir) for run_dir in run_dirs]
21351 chosen = {}
21352 for name in RUN_EXECUTABLE_NAMES:
21353 entries = [pin.get(name) for pin in pins]
21354 if not any(entries):
21355 path = resolve_runtime_executable(name)
21356 chosen[name] = (path, read_binary_build_identity(path).get("version_line"))
21357 continue
21358 relatives = {
21359 os.path.relpath(entry["path"], run_dir).replace(os.sep, "/") if entry else None
21360 for entry, run_dir in zip(entries, run_dirs)
21361 }
21362 lines = {entry.get("version_line") if entry else None for entry in entries}
21363 if None in relatives or len(relatives) != 1:
21364 raise ValueError(
21365 f"Study members disagree about the {name} they are pinned to; one array "
21366 "script cannot launch each member's build. Restage the study."
21367 )
21368 chosen[name] = (f"$RUN_DIR/{relatives.pop()}", lines.pop() if len(lines) == 1 else None)
21369 return chosen
21370
21371
Here is the call graph for this function:
Here is the caller graph for this function:

◆ sweep_workflow()

picurv_cli.core.sweep_workflow (   args)

Study/sweep orchestration using Slurm job arrays.

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 21372 of file core.py.

21372def sweep_workflow(args):
21373 """!
21374 @brief Study/sweep orchestration using Slurm job arrays.
21375 @param[in] args Command-line style argument list supplied to the function.
21376 """
21377 study_path = os.path.abspath(args.study)
21378 cluster_path = os.path.abspath(args.cluster)
21379 workspace_root = find_workspace_root(study_path, cluster_path, os.getcwd())
21380 if workspace_root:
21381 enforce_workspace_version(workspace_root)
21382 enforce_reproducibility_policy(workspace_root)
21383
21384 study_cfg = read_yaml_file(study_path)
21385 cluster_cfg = read_yaml_file(cluster_path)
21386 validate_study_config(study_cfg, study_path)
21387 validate_cluster_config(cluster_cfg, cluster_path)
21388
21389 study_name = case_run_label(study_cfg, study_path)
21390 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
21391 study_id = f"{study_name}_{timestamp}"
21392 study_dir = os.path.join(workspace_artifact_root(workspace_root, "studies"), study_id)
21393 cases_dir = os.path.join(study_dir, "cases")
21394 scheduler_dir = os.path.join(study_dir, "scheduler")
21395 results_dir = os.path.join(study_dir, "output", "analysis")
21396 for path in [cases_dir, scheduler_dir, results_dir, os.path.join(study_dir, "logs")]:
21397 os.makedirs(path, exist_ok=True)
21398
21399 print(f"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
21400 base_cfgs = study_cfg["base_configs"]
21401 base_paths = {k: resolve_path(study_path, v) for k, v in base_cfgs.items()}
21402 base_snapshot_dir = os.path.join(study_dir, "base_configs")
21403 os.makedirs(base_snapshot_dir, exist_ok=True)
21404 portable_study_cfg = copy.deepcopy(study_cfg)
21405 portable_study_cfg["base_configs"] = {}
21406 for role in ("case", "solver", "monitor", "post"):
21407 snapshot_name = f"{role}.yml"
21408 snapshot_path = os.path.join(base_snapshot_dir, snapshot_name)
21409 shutil.copy2(base_paths[role], snapshot_path)
21410 portable_study_cfg["base_configs"][role] = os.path.join("base_configs", snapshot_name)
21411 shutil.copy2(study_path, os.path.join(study_dir, "study.source.yml"))
21412 write_yaml_file(os.path.join(study_dir, "study.yml"), portable_study_cfg)
21413 shutil.copy2(cluster_path, os.path.join(study_dir, "cluster.yml"))
21414
21415 base_case = read_yaml_file(base_paths["case"])
21416 base_solver = read_yaml_file(base_paths["solver"])
21417 base_monitor = read_yaml_file(base_paths["monitor"])
21418 base_post = read_yaml_file(base_paths["post"])
21419 validate_simulation_configs(base_case, base_solver, base_monitor, base_paths["case"], base_paths["solver"], base_paths["monitor"])
21420 validate_post_config(base_post, base_paths["post"], base_monitor, base_case)
21421
21422 combinations = expand_study_parameter_combinations(study_cfg)
21423 if not combinations:
21424 print("[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
21425 sys.exit(1)
21426 print(f"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
21427
21428 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
21429 case_entries = []
21430 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
21431
21432 # One array script launches every member, so the study runs on one PetscInt width: the
21433 # widest any member needs, unless one was requested.
21434 member_widths = []
21435 for combo in combinations:
21436 member = {"case": copy.deepcopy(base_case), "monitor": copy.deepcopy(base_monitor),
21437 "solver": {}, "post": {}}
21438 for full_key, value in combo.items():
21439 root, nested = full_key.split(".", 1)
21440 _deep_set(member[root], nested, value)
21441 absolutize_case_external_paths(member["case"], base_paths["case"])
21442 member_widths.append(required_petsc_index_bytes(member["case"], base_paths["case"], member["monitor"]))
21443 try:
21444 requested = requested_petsc_index_bytes(args)
21445 widest = max(member_widths, default=(4, "particle and grid fields fit 32-bit indices"))
21446 if requested == 4 and widest[0] == 8:
21447 raise ValueError(f"--petsc-index-bits 32 cannot run every study member: {widest[1]}.")
21448 if requested is not None:
21449 select_petsc_index_bytes(requested, f"{8 * requested}-bit indices were requested")
21450 else:
21451 select_petsc_index_bytes(*widest)
21452 except ValueError as exc:
21453 print(f"[FATAL] {exc}", file=sys.stderr)
21454 sys.exit(1)
21455
21456 for idx, combo in enumerate(combinations):
21457 case_id = f"case_{idx:04d}"
21458 run_dir = os.path.join(cases_dir, case_id)
21459 config_dir = os.path.join(run_dir, "config")
21460 ensure_run_layout(run_dir)
21461
21462 case_cfg = copy.deepcopy(base_case)
21463 solver_cfg = copy.deepcopy(base_solver)
21464 monitor_cfg = copy.deepcopy(base_monitor)
21465 post_cfg = copy.deepcopy(base_post)
21466 target_map = {"case": case_cfg, "solver": solver_cfg, "monitor": monitor_cfg, "post": post_cfg}
21467 for full_key, value in combo.items():
21468 root, nested = full_key.split(".", 1)
21469 _deep_set(target_map[root], nested, value)
21470
21471 # Preserve file-based/grid-gen workflows when study cases are materialized
21472 # into new directories by rewriting external paths as absolute.
21473 absolutize_case_external_paths(case_cfg, base_paths["case"])
21474
21475 case_path = os.path.join(config_dir, "case.yml")
21476 solver_path = os.path.join(config_dir, "solver.yml")
21477 monitor_path = os.path.join(config_dir, "monitor.yml")
21478 post_path = os.path.join(config_dir, "post.yml")
21479 write_yaml_file(case_path, case_cfg)
21480 write_yaml_file(solver_path, solver_cfg)
21481 write_yaml_file(monitor_path, monitor_cfg)
21482 write_yaml_file(post_path, post_cfg)
21483 write_json_file(os.path.join(config_dir, "active.json"), {
21484 "schema_version": 1,
21485 "revision": "initial",
21486 "updated_at": datetime.now().astimezone().isoformat(),
21487 "files": {
21488 "case": "config/case.yml",
21489 "solver": "config/solver.yml",
21490 "monitor": "config/monitor.yml",
21491 },
21492 })
21493 record_run_petsc_index_bytes(run_dir)
21494 if resolve_executable_pinning(args, workspace_root):
21495 try:
21496 pin_run_executables(run_dir, "initial")
21497 except ValueError as exc:
21498 print(f"[FATAL] {exc}", file=sys.stderr)
21499 sys.exit(1)
21500
21501 validate_simulation_configs(case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path)
21502 validate_post_config(post_cfg, post_path, monitor_cfg, case_cfg)
21503
21504 try:
21505 asset_lock = materialize_run_assets(
21506 run_dir, case_cfg, case_path, skip_kinds=unused_asset_kinds(case_cfg, solver_cfg)
21507 )
21508 except ValueError as exc:
21509 print(f"[FATAL] Study case {case_id}: {exc}", file=sys.stderr)
21510 sys.exit(1)
21511
21512 source_files = {'Case': case_path, 'Solver': solver_path, 'Monitor': monitor_path}
21513 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
21514 configs = {
21515 "case": case_cfg, "case_path": case_path,
21516 "solver": solver_cfg, "solver_path": solver_path,
21517 "monitor": monitor_cfg, "monitor_path": monitor_path,
21518 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
21519 "cluster_cfg": cluster_cfg,
21520 }
21521 control_file = generate_solver_control_file(run_dir, case_id, configs, cluster_tasks, monitor_files)
21522 archive_active_generated_configuration(
21523 run_dir,
21524 [control_file, monitor_files.get("whitelist"), monitor_files.get("profile"),
21525 *glob.glob(os.path.join(config_dir, "bcs*.run"))],
21526 )
21527
21528 source_dir = resolve_post_source_directory(run_dir, monitor_cfg, post_cfg, strict=False)
21529 if not isinstance(post_cfg.get('source_data'), dict):
21530 post_cfg['source_data'] = {}
21531 post_cfg['source_data']['directory'] = source_dir
21532 post_cfg, recipe_id = apply_canonical_post_paths(post_cfg, run_dir)
21533 for relative in (
21534 post_cfg["_picurv_paths"]["visualization"],
21535 post_cfg["_picurv_paths"]["statistics"],
21536 post_cfg["_picurv_paths"]["spectra"],
21537 ):
21538 os.makedirs(os.path.join(run_dir, relative), exist_ok=True)
21539 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix", "post")
21540 post_decomposition = resolve_post_decomposition(run_dir, cluster_tasks, None, cluster_cfg)
21541 if post_decomposition["problems"]:
21542 print(f"[FATAL] Study case {case_id}: {' '.join(post_decomposition['problems'])}", file=sys.stderr)
21543 sys.exit(1)
21544 post_recipe = generate_post_recipe_file(run_dir, case_id, post_cfg, {'Case': case_path, 'Post-Profile': post_path},
21545 monitor_cfg, post_decomposition)
21546 ensure_post_lock_wrapper(run_dir)
21547
21548 case_entries.append({
21549 "index": idx,
21550 "case_id": case_id,
21551 "run_dir": os.path.abspath(run_dir),
21552 "control_file": control_file,
21553 "post_recipe_file": post_recipe,
21554 "log_level": str(monitor_cfg.get("logging", {}).get("verbosity", "INFO")).upper(),
21555 "post_prefix": output_prefix,
21556 "solve_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "Solver")),
21557 "post_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor")),
21558 "parameters": combo,
21559 })
21560 write_json_file(
21561 os.path.join(run_dir, "manifest.json"),
21562 build_run_manifest(
21563 run_dir, case_id, workspace_root=workspace_root, launch_mode="slurm",
21564 artifact_type="study-case", study_id=study_id, case_id=case_id,
21565 num_procs=cluster_tasks, post_num_procs=cluster_tasks,
21566 stages_requested={"solve": True, "post_process": True},
21567 inputs={
21568 "case": _relative_to_workspace(case_path, workspace_root),
21569 "solver": _relative_to_workspace(solver_path, workspace_root),
21570 "monitor": _relative_to_workspace(monitor_path, workspace_root),
21571 "post": _relative_to_workspace(post_path, workspace_root),
21572 },
21573 asset_lock=asset_lock,
21574 ),
21575 )
21576
21577 with open(case_index_file, "w") as f:
21578 for entry in case_entries:
21579 f.write(
21580 "\t".join(
21581 [
21582 str(entry["index"]),
21583 entry["case_id"],
21584 entry["run_dir"],
21585 entry["control_file"],
21586 entry["post_recipe_file"],
21587 entry["log_level"],
21588 entry["post_prefix"],
21589 entry["solve_diagnostic_args"],
21590 entry["post_diagnostic_args"],
21591 ]
21592 ) + "\n"
21593 )
21594 print(f"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
21595
21596 max_idx = len(case_entries) - 1
21597 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
21598 array_spec = f"0-{max_idx}"
21599 if max_conc:
21600 array_spec = f"{array_spec}%{max_conc}"
21601
21602 stage_executables = resolve_sweep_stage_executables([entry["run_dir"] for entry in case_entries])
21603 solver_exe, solver_version_line = stage_executables["simulator"]
21604 post_exe, post_version_line = stage_executables["postprocessor"]
21605 solver_array_script = os.path.join(scheduler_dir, "solver_array.sbatch")
21606 post_array_script = os.path.join(scheduler_dir, "post_array.sbatch")
21607 render_slurm_array_stage_script(
21608 solver_array_script,
21609 f"{study_id}_solve",
21610 cluster_cfg,
21611 array_spec,
21612 case_index_file,
21613 "solve",
21614 solver_exe,
21615 post_exe,
21616 os.path.join(scheduler_dir, "solver_%A_%a.out"),
21617 os.path.join(scheduler_dir, "solver_%A_%a.err"),
21618 expected_version_line=solver_version_line,
21619 )
21620 render_slurm_array_stage_script(
21621 post_array_script,
21622 f"{study_id}_post",
21623 cluster_cfg,
21624 array_spec,
21625 case_index_file,
21626 "post",
21627 solver_exe,
21628 post_exe,
21629 os.path.join(scheduler_dir, "post_%A_%a.out"),
21630 os.path.join(scheduler_dir, "post_%A_%a.err"),
21631 expected_version_line=post_version_line,
21632 )
21633 print(f"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
21634
21635 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
21636 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_aggregate.sbatch")
21637 render_metrics_aggregate_script(
21638 metrics_aggregate_script,
21639 f"{study_id}_metrics",
21640 cluster_cfg,
21641 study_dir,
21642 picurv_path,
21643 )
21644 print(f"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
21645
21646 submission = {
21647 "launch_mode": "slurm",
21648 "study_id": study_id,
21649 "solver_array": {"script": solver_array_script, "submitted": False},
21650 "post_array": {"script": post_array_script, "submitted": False},
21651 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
21652 "no_submit": bool(args.no_submit),
21653 }
21654 if not args.no_submit:
21655 solver_submit = submit_sbatch(solver_array_script)
21656 submission["solver_array"].update(solver_submit)
21657 submission["solver_array"]["submitted"] = True
21658 post_submit = submit_sbatch(post_array_script, dependency=solver_submit["job_id"])
21659 submission["post_array"].update(post_submit)
21660 submission["post_array"]["submitted"] = True
21661 submission["post_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
21662 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
21663 submission["metrics_aggregate"].update(metrics_submit)
21664 submission["metrics_aggregate"]["submitted"] = True
21665 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
21666 print(f"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
21667 print(f"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
21668 print(f"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
21669
21670 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
21671 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
21672
21673 summary = {
21674 "schema_version": 2,
21675 "artifact_type": "study",
21676 "study_id": study_id,
21677 "created_at": datetime.now().isoformat(),
21678 "software": dict(picurv_build()),
21679 "study_type": study_cfg.get("study_type"),
21680 "num_cases": len(case_entries),
21681 "paths": {
21682 "study_dir": study_dir,
21683 "case_index": case_index_file,
21684 "solver_array_script": solver_array_script,
21685 "post_array_script": post_array_script,
21686 "metrics_table": metrics_csv,
21687 "plots_dir": os.path.join(results_dir, "plots"),
21688 },
21689 "submission": submission,
21690 }
21691 write_json_file(os.path.join(scheduler_dir, "submission.json"), submission)
21692 write_json_file(os.path.join(study_dir, "study_manifest.json"), summary)
21693 write_json_file(os.path.join(results_dir, "summary.json"), {"study_id": study_id, "metrics_csv": metrics_csv, "plots": plots})
21694
21695 print("\n" + "=" * 60)
21696 print(" STUDY SUMMARY")
21697 print("=" * 60)
21698 print(f" Study ID : {study_id}")
21699 print(f" Study directory : {os.path.relpath(study_dir)}")
21700 print(f" Cases generated : {len(case_entries)}")
21701 print(f" Array spec : {array_spec}")
21702 print(f" Solver script : {os.path.relpath(solver_array_script)}")
21703 print(f" Post script : {os.path.relpath(post_array_script)}")
21704 if args.no_submit:
21705 print(f" Submit with : picurv submit --study-dir {os.path.relpath(study_dir)}")
21706 if metrics_csv:
21707 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
21708 if plots:
21709 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
21710 print("=" * 60)
21711
21712
Here is the call graph for this function:
Here is the caller graph for this function:

◆ sweep_continue_workflow()

picurv_cli.core.sweep_continue_workflow (   args)

Continue a partially-completed Slurm parameter sweep study.

Detects incomplete cases, prepares them for continuation (updating start_step, populating restart directories, regenerating control files), and submits new solver/post/metrics Slurm jobs. If all cases are already complete, performs metrics aggregation automatically.

Parameters
[in]argsParsed CLI arguments with study_dir and optional cluster override.

Definition at line 21713 of file core.py.

21713def sweep_continue_workflow(args):
21714 """!
21715 @brief Continue a partially-completed Slurm parameter sweep study.
21716 @details Detects incomplete cases, prepares them for continuation (updating
21717 start_step, populating restart directories, regenerating control files),
21718 and submits new solver/post/metrics Slurm jobs. If all cases are already
21719 complete, performs metrics aggregation automatically.
21720 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
21721 """
21722 study_dir = os.path.abspath(args.study_dir)
21723 manifest_path = os.path.join(study_dir, "study_manifest.json")
21724 if not os.path.isfile(manifest_path):
21725 emit_structured_error(ERROR_CODE_CFG_FILE_NOT_FOUND, key="study_dir", file_path=manifest_path,
21726 message="Study manifest not found.", hint=_STUDY_DIR_HINT)
21727 sys.exit(1)
21728 adopt_relocated_artifact(study_dir, kind="study")
21729 manifest = _read_json_if_exists(manifest_path)
21730 study_id = manifest["study_id"]
21731
21732 study_path = os.path.join(study_dir, "study.yml")
21733 cluster_path = os.path.abspath(args.cluster) if args.cluster else os.path.join(study_dir, "cluster.yml")
21734 study_cfg = read_yaml_file(study_path)
21735 cluster_cfg = read_yaml_file(cluster_path)
21736 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
21737 validate_cluster_config(cluster_cfg, cluster_path)
21738
21739 if args.cluster:
21740 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir, "cluster.yml"))
21741 print(f"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
21742
21743 scheduler_dir = os.path.join(study_dir, "scheduler")
21744 cases_dir = os.path.join(study_dir, "cases")
21745 results_dir = os.path.join(study_dir, "output", "analysis")
21746 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
21747 if not os.path.isfile(case_index_file):
21748 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
21749 sys.exit(1)
21750
21751 parsed_entries = parse_case_index_tsv(case_index_file)
21752
21753 cold_cases = cold_study_members(study_dir)
21754 if cold_cases and getattr(args, "auto_fetch", False):
21755 print(
21756 "[INFO] --auto-fetch: restoring cold-storage member(s) before continuing: "
21757 + ", ".join(cold_cases)
21758 )
21759 try:
21760 restore_cold_study_members(study_dir, cold_cases)
21761 except StorageError as exc:
21762 print(f"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
21763 sys.exit(1)
21764 cold_cases = cold_study_members(study_dir)
21765 if cold_cases:
21766 print(
21767 "[FATAL] Study continuation requires payload from cold-storage member(s): "
21768 + ", ".join(cold_cases),
21769 file=sys.stderr,
21770 )
21771 for case_id in cold_cases:
21772 state = storage_state_summary(os.path.join(cases_dir, case_id))
21773 print(
21774 f" Restore {case_id} with: picurv storage restore --archive-id "
21775 f"{state.get('archive_id') or '<archive-id>'}",
21776 file=sys.stderr,
21777 )
21778 print(
21779 " Or pass --auto-fetch to restore them automatically.",
21780 file=sys.stderr,
21781 )
21782 sys.exit(1)
21783
21784 # Staging writes these as snapshots inside the study (base_configs/<role>.yml), so
21785 # they resolve against the study directory, not the workspace that holds it.
21786 base_cfgs = study_cfg["base_configs"]
21787 base_paths = {k: os.path.join(study_dir, v) for k, v in base_cfgs.items()}
21788 base_case = read_yaml_file(base_paths["case"])
21789
21790 combinations = expand_study_parameter_combinations(study_cfg)
21791 if len(combinations) != len(parsed_entries):
21792 print(
21793 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
21794 f"case_index.tsv ({len(parsed_entries)} entries).",
21795 file=sys.stderr,
21796 )
21797 sys.exit(1)
21798
21799 print(f"\n[INFO] Study: {study_id}")
21800 print(f"[INFO] Scanning {len(combinations)} case(s) for completion status...")
21801
21802 incomplete_indices = []
21803 all_case_entries = []
21804 for idx, combo in enumerate(combinations):
21805 case_id = f"case_{idx:04d}"
21806 entry = parsed_entries[idx]
21807 entry["parameters"] = combo
21808 run_dir = entry["run_dir"]
21809
21810 effective_case = copy.deepcopy(base_case)
21811 for full_key, value in combo.items():
21812 root, nested = full_key.split(".", 1)
21813 if root == "case":
21814 _deep_set(effective_case, nested, value)
21815 try:
21816 eff_start = int(effective_case.get("run_control", {}).get("start_step", 0) or 0)
21817 except (TypeError, ValueError):
21818 eff_start = 0
21819 eff_total = int(effective_case["run_control"]["total_steps"])
21820 target = eff_start + eff_total
21821
21822 monitor_cfg = read_yaml_file(os.path.join(run_dir, "config", "monitor.yml"))
21823 status = detect_case_completion_status(run_dir, monitor_cfg, target)
21824 entry["_status"] = status
21825
21826 if status["status"] == "complete":
21827 print(f" {case_id}: complete (step {status['last_step']}/{target})")
21828 elif status["status"] == "partial":
21829 print(f" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
21830 incomplete_indices.append(idx)
21831 else:
21832 print(f" {case_id}: no checkpoint — will re-run from scratch")
21833 incomplete_indices.append(idx)
21834
21835 all_case_entries.append(entry)
21836
21837 if not incomplete_indices:
21838 print("\n[INFO] All cases are complete. Running metrics aggregation...")
21839 metrics_csv = aggregate_study_metrics(study_cfg, all_case_entries, results_dir)
21840 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
21841 print("\n" + "=" * 60)
21842 print(" STUDY CONTINUATION SUMMARY")
21843 print("=" * 60)
21844 print(f" Study ID : {study_id}")
21845 print(f" Status : ALL COMPLETE")
21846 if metrics_csv:
21847 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
21848 if plots:
21849 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
21850 print("=" * 60)
21851 return
21852
21853 print(f"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
21854
21855 skipped = []
21856 for idx in incomplete_indices:
21857 entry = all_case_entries[idx]
21858 status = entry["_status"]
21859 if status["status"] == "partial":
21860 prepare_case_for_continuation(
21861 entry["run_dir"], entry["case_id"],
21862 status["last_step"], status["target_step"],
21863 cluster_cfg,
21864 )
21865 elif status["status"] == "empty":
21866 print(f"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
21867
21868 solver_array_spec = ",".join(str(i) for i in incomplete_indices)
21869 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
21870 if max_conc:
21871 solver_array_spec = f"{solver_array_spec}%{max_conc}"
21872
21873 max_idx = len(combinations) - 1
21874 post_array_spec = f"0-{max_idx}"
21875 if max_conc:
21876 post_array_spec = f"{post_array_spec}%{max_conc}"
21877
21878 try:
21879 stage_executables = resolve_sweep_stage_executables(
21880 [entry["run_dir"] for entry in all_case_entries]
21881 )
21882 except ValueError as exc:
21883 print(f"[FATAL] {exc}", file=sys.stderr)
21884 sys.exit(1)
21885 solver_exe, solver_version_line = stage_executables["simulator"]
21886 post_exe, post_version_line = stage_executables["postprocessor"]
21887 # The post array relaunches every member through the lock wrapper; a study staged
21888 # by an earlier version holds a wrapper without the launch-time planning it needs.
21889 for entry in all_case_entries:
21890 ensure_post_lock_wrapper(entry["run_dir"])
21891
21892 solver_continue_script = os.path.join(scheduler_dir, "solver_continue_array.sbatch")
21893 post_continue_script = os.path.join(scheduler_dir, "post_continue_array.sbatch")
21894 render_slurm_array_stage_script(
21895 solver_continue_script,
21896 f"{study_id}_solve_cont",
21897 cluster_cfg,
21898 solver_array_spec,
21899 case_index_file,
21900 "solve",
21901 solver_exe, post_exe,
21902 os.path.join(scheduler_dir, "solver_cont_%A_%a.out"),
21903 os.path.join(scheduler_dir, "solver_cont_%A_%a.err"),
21904 expected_version_line=solver_version_line,
21905 )
21906 render_slurm_array_stage_script(
21907 post_continue_script,
21908 f"{study_id}_post_cont",
21909 cluster_cfg,
21910 post_array_spec,
21911 case_index_file,
21912 "post",
21913 solver_exe, post_exe,
21914 os.path.join(scheduler_dir, "post_cont_%A_%a.out"),
21915 os.path.join(scheduler_dir, "post_cont_%A_%a.err"),
21916 expected_version_line=post_version_line,
21917 )
21918
21919 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
21920 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_continue_aggregate.sbatch")
21921 render_metrics_aggregate_script(
21922 metrics_aggregate_script,
21923 f"{study_id}_metrics_cont",
21924 cluster_cfg,
21925 study_dir,
21926 picurv_path,
21927 )
21928 print(f"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
21929
21930 submission = {
21931 "launch_mode": "slurm",
21932 "study_id": study_id,
21933 "continuation": True,
21934 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
21935 "solver_continue_array": {"script": solver_continue_script, "submitted": False},
21936 "post_continue_array": {"script": post_continue_script, "submitted": False},
21937 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
21938 "no_submit": bool(args.no_submit),
21939 }
21940 if not args.no_submit:
21941 solver_submit = submit_sbatch(solver_continue_script)
21942 submission["solver_continue_array"].update(solver_submit)
21943 submission["solver_continue_array"]["submitted"] = True
21944 post_submit = submit_sbatch(post_continue_script, dependency=solver_submit["job_id"])
21945 submission["post_continue_array"].update(post_submit)
21946 submission["post_continue_array"]["submitted"] = True
21947 submission["post_continue_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
21948 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
21949 submission["metrics_aggregate"].update(metrics_submit)
21950 submission["metrics_aggregate"]["submitted"] = True
21951 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
21952 print(f"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
21953 print(f"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
21954 print(f"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
21955
21956 write_json_file(os.path.join(scheduler_dir, "submission_continue.json"), submission)
21957
21958 manifest["continuation"] = {
21959 "continued_at": datetime.now().isoformat(),
21960 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
21961 "submission": submission,
21962 }
21963 write_json_file(manifest_path, manifest)
21964
21965 print("\n" + "=" * 60)
21966 print(" STUDY CONTINUATION SUMMARY")
21967 print("=" * 60)
21968 print(f" Study ID : {study_id}")
21969 print(f" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
21970 print(f" Solver array spec : {solver_array_spec}")
21971 print(f" Post array spec : {post_array_spec}")
21972 print(f" Solver script : {os.path.relpath(solver_continue_script)}")
21973 print(f" Post script : {os.path.relpath(post_continue_script)}")
21974 print(f" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
21975 if not args.no_submit:
21976 print(f" [Metrics aggregation will run automatically after post-processing]")
21977 else:
21978 print(f" [--no-submit] Scripts generated but not submitted. Submit them with:")
21979 print(f" picurv submit --study-dir {os.path.relpath(study_dir)}")
21980 print("=" * 60)
21981
21982
Here is the call graph for this function:
Here is the caller graph for this function:

◆ sweep_reaggregate_workflow()

picurv_cli.core.sweep_reaggregate_workflow (   args)

Re-run metrics aggregation and plot generation for an existing study.

Parameters
[in]argsParsed CLI arguments with study_dir.

Definition at line 21983 of file core.py.

21983def sweep_reaggregate_workflow(args):
21984 """!
21985 @brief Re-run metrics aggregation and plot generation for an existing study.
21986 @param[in] args Parsed CLI arguments with study_dir.
21987 """
21988 study_dir = os.path.abspath(args.study_dir)
21989 study_path = os.path.join(study_dir, "study.yml")
21990 if not os.path.isfile(study_path):
21991 emit_structured_error(ERROR_CODE_CFG_FILE_NOT_FOUND, key="study_dir", file_path=study_path,
21992 message="Study config not found.", hint=_STUDY_DIR_HINT)
21993 sys.exit(1)
21994 adopt_relocated_artifact(study_dir, kind="study")
21995 study_cfg = read_yaml_file(study_path)
21996 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
21997
21998 case_index_file = os.path.join(study_dir, "scheduler", "case_index.tsv")
21999 if not os.path.isfile(case_index_file):
22000 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
22001 sys.exit(1)
22002
22003 parsed_entries = parse_case_index_tsv(case_index_file)
22004 cold_cases = cold_study_members(study_dir)
22005 if cold_cases and getattr(args, "auto_fetch", False):
22006 print(
22007 "[INFO] --auto-fetch: restoring cold-storage member(s) before aggregating: "
22008 + ", ".join(cold_cases)
22009 )
22010 try:
22011 restore_cold_study_members(study_dir, cold_cases)
22012 except StorageError as exc:
22013 print(f"[FATAL] Automatic restore failed: {exc}", file=sys.stderr)
22014 sys.exit(1)
22015 cold_cases = cold_study_members(study_dir)
22016 if cold_cases:
22017 print(
22018 "[INFO] Cold-storage member(s) cannot be re-measured; their previously "
22019 "aggregated values are carried forward: " + ", ".join(cold_cases)
22020 )
22021 combinations = expand_study_parameter_combinations(study_cfg)
22022 if len(combinations) != len(parsed_entries):
22023 print(
22024 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
22025 f"case_index.tsv ({len(parsed_entries)} entries).",
22026 file=sys.stderr,
22027 )
22028 sys.exit(1)
22029
22030 case_entries = []
22031 for idx, combo in enumerate(combinations):
22032 entry = parsed_entries[idx]
22033 entry["parameters"] = combo
22034 case_entries.append(entry)
22035
22036 results_dir = os.path.join(study_dir, "output", "analysis")
22037 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
22038 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
22039
22040 print("\n" + "=" * 60)
22041 print(" REAGGREGATION SUMMARY")
22042 print("=" * 60)
22043 if metrics_csv:
22044 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
22045 if plots:
22046 print(f" Plots generated : {len(plots)}")
22047 print("=" * 60)
22048
22049
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _read_yaml_if_exists()

picurv_cli.core._read_yaml_if_exists ( str  filepath)
protected

Read YAML when present, otherwise return None.

Parameters
[in]filepathArgument passed to _read_yaml_if_exists().
Returns
Value returned by _read_yaml_if_exists().

Definition at line 22053 of file core.py.

22053def _read_yaml_if_exists(filepath: str):
22054 """!
22055 @brief Read YAML when present, otherwise return None.
22056 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
22057 @return Value returned by `_read_yaml_if_exists()`.
22058 """
22059 if not filepath or not os.path.isfile(filepath):
22060 return None
22061 try:
22062 with open(filepath, "r", encoding="utf-8") as f:
22063 return yaml.safe_load(f)
22064 except yaml.YAMLError:
22065 return None
22066
22067
Here is the caller graph for this function:

◆ _read_json_if_exists()

picurv_cli.core._read_json_if_exists ( str  filepath)
protected

Read JSON when present, otherwise return None.

Parameters
[in]filepathArgument passed to _read_json_if_exists().
Returns
Value returned by _read_json_if_exists().

Definition at line 22068 of file core.py.

22068def _read_json_if_exists(filepath: str):
22069 """!
22070 @brief Read JSON when present, otherwise return None.
22071 @param[in] filepath Argument passed to `_read_json_if_exists()`.
22072 @return Value returned by `_read_json_if_exists()`.
22073 """
22074 if not filepath or not os.path.isfile(filepath):
22075 return None
22076 with open(filepath, "r", encoding="utf-8") as f:
22077 return json.load(f)
22078
22079
Here is the caller graph for this function:

◆ _parse_int_loose()

picurv_cli.core._parse_int_loose (   value)
protected

Best-effort integer parsing for summary extraction.

Parameters
[in]valueArgument passed to _parse_int_loose().
Returns
Value returned by _parse_int_loose().

Definition at line 22080 of file core.py.

22080def _parse_int_loose(value):
22081 """!
22082 @brief Best-effort integer parsing for summary extraction.
22083 @param[in] value Argument passed to `_parse_int_loose()`.
22084 @return Value returned by `_parse_int_loose()`.
22085 """
22086 if value is None:
22087 return None
22088 text = str(value).strip()
22089 if not text:
22090 return None
22091 try:
22092 return int(text)
22093 except ValueError:
22094 try:
22095 return int(float(text))
22096 except ValueError:
22097 return None
22098
22099
Here is the caller graph for this function:

◆ _parse_float_loose()

picurv_cli.core._parse_float_loose (   value)
protected

Best-effort float parsing for summary extraction.

Parameters
[in]valueArgument passed to _parse_float_loose().
Returns
Value returned by _parse_float_loose().

Definition at line 22100 of file core.py.

22100def _parse_float_loose(value):
22101 """!
22102 @brief Best-effort float parsing for summary extraction.
22103 @param[in] value Argument passed to `_parse_float_loose()`.
22104 @return Value returned by `_parse_float_loose()`.
22105 """
22106 if value is None:
22107 return None
22108 text = str(value).strip()
22109 if not text:
22110 return None
22111 try:
22112 return float(text)
22113 except ValueError:
22114 return None
22115
22116
Here is the caller graph for this function:

◆ _extract_numeric_tuple()

picurv_cli.core._extract_numeric_tuple ( str  text)
protected

Extract a numeric tuple from a string like '(1, 2, 3)'.

Parameters
[in]textArgument passed to _extract_numeric_tuple().
Returns
Value returned by _extract_numeric_tuple().

Definition at line 22117 of file core.py.

22117def _extract_numeric_tuple(text: str):
22118 """!
22119 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
22120 @param[in] text Argument passed to `_extract_numeric_tuple()`.
22121 @return Value returned by `_extract_numeric_tuple()`.
22122 """
22123 if not text:
22124 return []
22125 return [float(token) for token in _SUMMARY_NUMERIC_RE.findall(text)]
22126
22127
Here is the caller graph for this function:

◆ _build_summary_context()

dict picurv_cli.core._build_summary_context ( str  run_dir)
protected

Resolve run-local config and artifact paths for summarize.

Parameters
[in]run_dirArgument passed to _build_summary_context().
Returns
Value returned by _build_summary_context().

Definition at line 22128 of file core.py.

22128def _build_summary_context(run_dir: str) -> dict:
22129 """!
22130 @brief Resolve run-local config and artifact paths for summarize.
22131 @param[in] run_dir Argument passed to `_build_summary_context()`.
22132 @return Value returned by `_build_summary_context()`.
22133 """
22134 run_dir = os.path.abspath(run_dir)
22135 if not os.path.isdir(run_dir):
22136 emit_structured_error(
22137 ERROR_CODE_CFG_FILE_NOT_FOUND,
22138 key="run_dir",
22139 file_path=run_dir,
22140 message="Run directory not found.",
22141 )
22142 sys.exit(1)
22143
22144 config_dir = os.path.join(run_dir, "config")
22145 config_paths = {
22146 "case": os.path.join(config_dir, "case.yml"),
22147 "solver": os.path.join(config_dir, "solver.yml"),
22148 "monitor": os.path.join(config_dir, "monitor.yml"),
22149 }
22150 monitor_cfg = _read_yaml_if_exists(config_paths["monitor"]) or {}
22151 case_cfg = _read_yaml_if_exists(config_paths["case"]) or {}
22152 solver_cfg = _read_yaml_if_exists(config_paths["solver"]) or {}
22153 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
22154
22155 io_cfg = monitor_cfg.get("io", {}) if isinstance(monitor_cfg, dict) else {}
22156 scheduler_dir = os.path.join(run_dir, "scheduler")
22157
22158 profiling_cfg = {"mode": "off", "functions": [], "final_summary_enabled": True, "helpers": []}
22159 if monitor_cfg:
22160 profiling_cfg = resolve_profiling_config(monitor_cfg)
22161
22162 particle_console_output_freq = None
22163 particle_log_interval = None
22164 if monitor_cfg:
22165 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
22166 particle_log_interval = io_cfg.get("particle_log_interval")
22167
22168 particle_count_cfg = None
22169 if case_cfg:
22170 particle_count_cfg = (
22171 case_cfg.get("models", {})
22172 .get("physics", {})
22173 .get("particles", {})
22174 .get("count")
22175 )
22176
22177 return {
22178 "run_dir": run_dir,
22179 "config_dir": config_dir,
22180 "log_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS["logs"]),
22181 "metrics_dir": os.path.join(run_dir, CANONICAL_RUN_PATHS["metrics"]),
22182 "scheduler_dir": scheduler_dir,
22183 "monitor_cfg": monitor_cfg,
22184 "case_cfg": case_cfg,
22185 "solver_cfg": solver_cfg,
22186 "config_paths": config_paths,
22187 "manifest": manifest,
22188 "profiling_cfg": profiling_cfg,
22189 "particle_console_output_freq": particle_console_output_freq,
22190 "particle_log_interval": particle_log_interval,
22191 "particle_count_cfg": particle_count_cfg,
22192 }
22193
22194
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _require_summary_config()

dict picurv_cli.core._require_summary_config ( dict  context,
str  name 
)
protected

Return one explicitly requested copied config or fail with a structured error.

Parameters
[in]contextSummary context returned by _build_summary_context().
[in]nameConfig selector name.
Returns
Parsed config mapping.

Definition at line 22195 of file core.py.

22195def _require_summary_config(context: dict, name: str) -> dict:
22196 """!
22197 @brief Return one explicitly requested copied config or fail with a structured error.
22198 @param[in] context Summary context returned by `_build_summary_context()`.
22199 @param[in] name Config selector name.
22200 @return Parsed config mapping.
22201 """
22202 path = context["config_paths"][name]
22203 cfg = context.get(f"{name}_cfg")
22204 if not os.path.isfile(path):
22205 emit_structured_error(
22206 ERROR_CODE_CFG_FILE_NOT_FOUND,
22207 key=name,
22208 file_path=path,
22209 message=f"Copied run config '{name}.yml' was not found.",
22210 hint="Use a staged run directory containing the requested copied config.",
22211 )
22212 sys.exit(1)
22213 if not isinstance(cfg, dict) or not cfg:
22214 emit_structured_error(
22215 ERROR_CODE_CFG_INVALID_VALUE,
22216 key=name,
22217 file_path=path,
22218 message=f"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
22219 )
22220 sys.exit(1)
22221 return cfg
22222
22223
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _build_run_overview()

dict picurv_cli.core._build_run_overview ( dict  context)
protected

Build timestep-independent run metadata for summarize.

Parameters
[in]contextSummary context returned by _build_summary_context().
Returns
Curated run metadata mapping.

Definition at line 22224 of file core.py.

22224def _build_run_overview(context: dict) -> dict:
22225 """!
22226 @brief Build timestep-independent run metadata for summarize.
22227 @param[in] context Summary context returned by `_build_summary_context()`.
22228 @return Curated run metadata mapping.
22229 """
22230 manifest = context["manifest"]
22231 software = manifest.get("software") if isinstance(manifest.get("software"), dict) else {}
22232 return {
22233 "run_id": manifest.get("run_id", os.path.basename(context["run_dir"])),
22234 "run_dir": context["run_dir"],
22235 "created_at": manifest.get("created_at"),
22236 "launch_mode": manifest.get("launch_mode"),
22237 "release_version": software.get("release_version"),
22238 "build_id": software.get("build_id"),
22239 "git_commit": software.get("git_commit", manifest.get("git_commit")),
22240 "solver_num_procs": manifest.get("solver_num_procs", manifest.get("num_procs")),
22241 "post_num_procs": manifest.get("post_num_procs"),
22242 "stages_requested": manifest.get("stages_requested"),
22243 "stages_completed_or_submitted": manifest.get("stages_completed_or_submitted"),
22244 }
22245
22246
Here is the caller graph for this function:

◆ _summarize_turbulence()

dict picurv_cli.core._summarize_turbulence ( dict  turbulence_cfg)
protected

Build compact turbulence and wall-model selections.

Parameters
[in]turbulence_cfgCase turbulence configuration mapping.
Returns
Curated turbulence and wall-model mapping.

Definition at line 22247 of file core.py.

22247def _summarize_turbulence(turbulence_cfg: dict) -> dict:
22248 """!
22249 @brief Build compact turbulence and wall-model selections.
22250 @param[in] turbulence_cfg Case turbulence configuration mapping.
22251 @return Curated turbulence and wall-model mapping.
22252 """
22253 result = {}
22254 for key in ("les", "wall_function"):
22255 value = turbulence_cfg.get(key)
22256 if isinstance(value, dict):
22257 result[key] = {
22258 "enabled": value.get("enabled", True),
22259 "model": value.get("model"),
22260 **{k: v for k, v in value.items() if k not in {"enabled", "model"}},
22261 }
22262 elif value is not None:
22263 result[key] = value
22264 return result
22265
22266
Here is the caller graph for this function:

◆ _build_case_overview()

dict picurv_cli.core._build_case_overview ( dict  context)
protected

Build a curated case.yml summary with useful derived quantities.

Parameters
[in]contextSummary context returned by _build_summary_context().
Returns
Curated case configuration mapping.

Definition at line 22267 of file core.py.

22267def _build_case_overview(context: dict) -> dict:
22268 """!
22269 @brief Build a curated case.yml summary with useful derived quantities.
22270 @param[in] context Summary context returned by `_build_summary_context()`.
22271 @return Curated case configuration mapping.
22272 """
22273 cfg = _require_summary_config(context, "case")
22274 props = cfg.get("properties", {})
22275 scaling = props.get("scaling", {})
22276 fluid = props.get("fluid", {})
22277 run = cfg.get("run_control", {})
22278 grid = cfg.get("grid", {})
22279 models = cfg.get("models", {})
22280 domain = models.get("domain", {})
22281 physics = models.get("physics", {})
22282 particles = physics.get("particles", {})
22283 start = int(run.get("start_step", 0))
22284 total = int(run.get("total_steps", 0))
22285 dt = float(run.get("dt_physical", 0.0))
22286 length_ref = float(scaling.get("length_ref"))
22287 velocity_ref = float(scaling.get("velocity_ref"))
22288 density = float(fluid.get("density"))
22289 viscosity = float(fluid.get("viscosity"))
22290 prepared_bcs = validate_and_prepare_boundary_conditions(cfg)
22291 first_block_faces = {row["face"]: row for row in prepared_bcs[0]}
22292 periodic_axes = {
22293 "i": first_block_faces["-Xi"]["type"] == "PERIODIC",
22294 "j": first_block_faces["-Eta"]["type"] == "PERIODIC",
22295 "k": first_block_faces["-Zeta"]["type"] == "PERIODIC",
22296 }
22297 bc_blocks = []
22298 for block_idx, block in enumerate(prepared_bcs):
22299 bc_blocks.append(
22300 {
22301 "block": block_idx,
22302 "faces": [
22303 {"face": row["face"], "type": row["type"], "handler": row["handler"]}
22304 for row in block
22305 ],
22306 }
22307 )
22308 return {
22309 "run_control": {
22310 "start_step": start,
22311 "total_steps": total,
22312 "end_step": start + total,
22313 "dt_physical": dt,
22314 "duration_physical": total * dt,
22315 "dt_nondimensional": to_solver_units(
22316 dt, ("case", "run_control", "dt_physical"),
22317 {"length_ref": length_ref, "velocity_ref": velocity_ref}),
22318 },
22319 "properties": {
22320 "length_ref": length_ref,
22321 "velocity_ref": velocity_ref,
22322 "density": density,
22323 "viscosity": viscosity,
22324 "reynolds_number": density * velocity_ref * length_ref / viscosity if viscosity else None,
22325 "initial_conditions": props.get("initial_conditions", {}),
22326 },
22327 "grid": {
22328 "mode": grid.get("mode"),
22329 "processor_layout": resolve_grid_da_processor_layout(grid),
22330 "programmatic_settings": grid.get("programmatic_settings") if grid.get("mode") == "programmatic_c" else None,
22331 "source_file": grid.get("source_file"),
22332 },
22333 "domain": {
22334 "blocks": domain.get("blocks", 1),
22335 "dimensionality": physics.get("dimensionality", "3D"),
22336 "periodic": periodic_axes,
22337 },
22338 "physics": {
22339 "fsi": physics.get("fsi", {}),
22340 "particles": particles,
22341 "turbulence": _summarize_turbulence(physics.get("turbulence", {}) or {}),
22342 },
22343 "boundary_conditions": bc_blocks,
22344 }
22345
22346
Here is the call graph for this function:

◆ _summary_scalar_transport()

dict picurv_cli.core._summary_scalar_transport ( dict  context,
dict  solver_cfg 
)
protected

Scalar-transport parameters as the run resolves them, wherever they are set.

Parameters
[in]contextSummary context returned by _build_summary_context().
[in]solver_cfgParsed solver configuration.
Returns
Mapping of parameter name to value; empty when unresolvable.

Definition at line 22347 of file core.py.

22347def _summary_scalar_transport(context: dict, solver_cfg: dict) -> dict:
22348 """!
22349 @brief Scalar-transport parameters as the run resolves them, wherever they are set.
22350 @param[in] context Summary context returned by `_build_summary_context()`.
22351 @param[in] solver_cfg Parsed solver configuration.
22352 @return Mapping of parameter name to value; empty when unresolvable.
22353 """
22354 try:
22355 return resolve_scalar_transport(context.get("case_cfg") or {}, solver_cfg)
22356 except ValueError:
22357 return {}
22358
22359
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _build_solver_overview()

dict picurv_cli.core._build_solver_overview ( dict  context)
protected

Build a curated solver.yml summary with normalized selections.

Parameters
[in]contextSummary context returned by _build_summary_context().
Returns
Curated solver configuration mapping.

Definition at line 22360 of file core.py.

22360def _build_solver_overview(context: dict) -> dict:
22361 """!
22362 @brief Build a curated solver.yml summary with normalized selections.
22363 @param[in] context Summary context returned by `_build_summary_context()`.
22364 @return Curated solver configuration mapping.
22365 """
22366 cfg = _require_summary_config(context, "solver")
22367 strategy = cfg.get("strategy", {}) or {}
22368 selected = normalize_momentum_solver_type(strategy.get("momentum_solver", "Dual Time Picard Jameson RK"))
22369 momentum_cfg = cfg.get("momentum_solver", {}) or {}
22370 dualtime = momentum_cfg.get("dual_time_picard_jameson_rk", momentum_cfg.get("dual_time_picard_rk4", {})) or {}
22371 newton_krylov = momentum_cfg.get("newton_krylov", {}) or {}
22372 poisson = cfg.get("poisson_solver", cfg.get("pressure_solver", {})) or {}
22373 operation_mode = cfg.get("operation_mode", {}) or {}
22374 operation_mode = {
22375 **operation_mode,
22376 "eulerian_field_source": normalize_eulerian_field_source(operation_mode.get("eulerian_field_source", "solve")),
22377 }
22378 if operation_mode.get("analytical_type") is not None:
22379 operation_mode["analytical_type"] = normalize_analytical_type(operation_mode["analytical_type"])
22380 passthrough = cfg.get("petsc_passthrough_options", {}) or {}
22381 return {
22382 "operation_mode": operation_mode,
22383 "momentum": {
22384 "type": selected,
22385 "central_diff": bool(strategy.get("central_diff", False)),
22386 "tolerances": cfg.get("tolerances", {}),
22387 "controls": newton_krylov if selected == "newton_krylov" else dualtime,
22388 },
22389 "poisson": poisson,
22390 "interpolation": cfg.get("interpolation", {"method": "Trilinear"}),
22391 "scalar_transport": _summary_scalar_transport(context, cfg),
22392 "verification": cfg.get("verification", {}),
22393 "petsc_passthrough": {"count": len(passthrough), "options": sorted(passthrough.keys())},
22394 }
22395
22396
Here is the call graph for this function:

◆ _build_monitor_overview()

dict picurv_cli.core._build_monitor_overview ( dict  context)
protected

Build a curated monitor.yml summary with resolved defaults.

Parameters
[in]contextSummary context returned by _build_summary_context().
Returns
Curated monitor configuration mapping.

Definition at line 22397 of file core.py.

22397def _build_monitor_overview(context: dict) -> dict:
22398 """!
22399 @brief Build a curated monitor.yml summary with resolved defaults.
22400 @param[in] context Summary context returned by `_build_summary_context()`.
22401 @return Curated monitor configuration mapping.
22402 """
22403 cfg = _require_summary_config(context, "monitor")
22404 logging_cfg = cfg.get("logging", {}) or {}
22405 io_cfg = cfg.get("io", {}) or {}
22406 diagnostics = resolve_diagnostics_config(cfg, context["run_dir"], "Solver")
22407 monitoring_flags = resolve_solver_monitoring_flags(cfg)
22408 solution_monitoring = normalize_solution_monitoring_config(cfg)
22409 enabled_petsc = sorted(key for key, value in diagnostics["petsc"].items() if value not in (False, None))
22410 return {
22411 "logging": {
22412 "verbosity": logging_cfg.get("verbosity", "WARNING"),
22413 "enabled_functions": logging_cfg.get("enabled_functions", []),
22414 },
22415 "profiling": resolve_profiling_config(cfg),
22416 "diagnostics": {
22417 "enabled_petsc": enabled_petsc,
22418 "petsc": diagnostics["petsc"],
22419 "runtime_memory_log": diagnostics["runtime_memory_log"],
22420 },
22421 "io": {
22422 "data_output_frequency": io_cfg.get("data_output_frequency"),
22423 "particle_console_output_frequency": resolve_particle_console_output_frequency(io_cfg),
22424 "particle_log_interval": io_cfg.get("particle_log_interval"),
22425 "directories": {
22426 "output": CANONICAL_RUN_PATHS["output"],
22427 "restart": CANONICAL_RUN_PATHS["restart"],
22428 "logs": CANONICAL_RUN_PATHS["logs"],
22429 "analysis": CANONICAL_RUN_PATHS["analysis"],
22430 },
22431 },
22432 "solver_monitoring": {
22433 "enabled_flags": sorted(flag for flag, value in monitoring_flags.items() if value not in (False, None)),
22434 "flags": monitoring_flags,
22435 },
22436 "solution_monitoring": solution_monitoring,
22437 }
22438
22439
Here is the call graph for this function:

◆ _parse_continuity_metrics_log()

"tuple[dict, list[int]]" picurv_cli.core._parse_continuity_metrics_log ( str  log_dir)
protected

Read the continuity record into rows by step (one per block) plus observed order.

Parameters
[in]log_dirRun log directory.
Returns
Mapping step -> list of block rows, and the order steps first appeared.

Definition at line 22440 of file core.py.

22440def _parse_continuity_metrics_log(log_dir: str) -> "tuple[dict, list[int]]":
22441 """!
22442 @brief Read the continuity record into rows by step (one per block) plus observed order.
22443 @param[in] log_dir Run log directory.
22444 @return Mapping step -> list of block rows, and the order steps first appeared.
22445 """
22446 rows_by_step, step_order = {}, []
22447 for _, row in _continuity_rows(log_dir)[1]:
22448 step = row["step"]
22449 if step not in rows_by_step:
22450 step_order.append(step)
22451 rows_by_step[step] = {}
22452 rows_by_step[step][row["block"]] = {key: value for key, value in row.items() if key != "step"}
22453 return {step: list(blocks.values()) for step, blocks in rows_by_step.items()}, step_order
22454
22455
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _parse_particle_metrics_log()

"tuple[dict, list[int]]" picurv_cli.core._parse_particle_metrics_log ( str  log_dir,
str  metrics_dir 
)
protected

Read the per-step particle record into rows by step plus observed order.

Parameters
[in]log_dirRun log directory.
[in]metrics_dirRun analysis metrics directory.
Returns
Mapping step -> row, and the steps in file order.

Definition at line 22456 of file core.py.

22456def _parse_particle_metrics_log(log_dir: str, metrics_dir: str) -> "tuple[dict, list[int]]":
22457 """!
22458 @brief Read the per-step particle record into rows by step plus observed order.
22459 @param[in] log_dir Run log directory.
22460 @param[in] metrics_dir Run analysis metrics directory.
22461 @return Mapping step -> row, and the steps in file order.
22462 """
22463 rows_by_step, step_order = {}, []
22464 for _, row in _particle_step_rows(log_dir, metrics_dir)[1]:
22465 rows_by_step[row["step"]] = {key: value for key, value in row.items() if key != "step"}
22466 step_order.append(row["step"])
22467 return rows_by_step, step_order
22468
22469
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _parse_momentum_convergence_logs()

"tuple[dict, dict, list[int]]" picurv_cli.core._parse_momentum_convergence_logs ( str  log_dir)
protected

Read the per-block momentum records (Picard history, Newton summary) into rows by step.

Parameters
[in]log_dirRun log directory.
Returns
Rows by step and block, source path per block, and step order.

Definition at line 22470 of file core.py.

22470def _parse_momentum_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
22471 """!
22472 @brief Read the per-block momentum records (Picard history, Newton summary) into rows by step.
22473 @param[in] log_dir Run log directory.
22474 @return Rows by step and block, source path per block, and step order.
22475 """
22476 rows_by_step, sources, step_order = {}, {}, []
22477 # Per (step, block): accepted/rejected trial counts and the last committed state.
22478 state = {}
22479 for block, path, rows in _picard_history_rows(log_dir):
22480 sources[block] = path
22481 for _, row in rows:
22482 step = row["step"]
22483 step_order.append(step)
22484 entry = state.setdefault((step, block), {"accepted_count": 0, "rejected_count": 0,
22485 "last_accepted": None, "last_rejected": None})
22486 display = {key: value for key, value in row.items() if key != "step"}
22487 if row.get("status") == "accepted":
22488 entry["accepted_count"] += 1
22489 entry["last_accepted"] = display
22490 else:
22491 entry["rejected_count"] += 1
22492 entry["last_rejected"] = display
22493 for (step, block), entry in state.items():
22494 display_row = entry["last_accepted"] or entry["last_rejected"]
22495 if display_row:
22496 rows_by_step.setdefault(step, {})[block] = {
22497 **display_row,
22498 "accepted_count": entry["accepted_count"],
22499 "rejected_count": entry["rejected_count"],
22500 }
22501 for file_block, path, rows in _newton_summary_rows(log_dir):
22502 sources[file_block] = path
22503 for _, row in rows:
22504 step_order.append(row["step"])
22505 rows_by_step.setdefault(row["step"], {})[row["block"]] = {
22506 key: value for key, value in row.items() if key != "step"}
22507 return rows_by_step, sources, step_order
22508
22509
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _parse_poisson_convergence_logs()

"tuple[dict, dict, list[int]]" picurv_cli.core._parse_poisson_convergence_logs ( str  log_dir)
protected

Read the per-block Poisson histories into each step's last iteration.

Parameters
[in]log_dirRun log directory.
Returns
Rows by step and block, source path per block, and step order.

Definition at line 22510 of file core.py.

22510def _parse_poisson_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
22511 """!
22512 @brief Read the per-block Poisson histories into each step's last iteration.
22513 @param[in] log_dir Run log directory.
22514 @return Rows by step and block, source path per block, and step order.
22515 """
22516 rows_by_step, sources, step_order = {}, {}, []
22517 for block, path, rows in _poisson_history_rows(log_dir):
22518 sources[block] = path
22519 for _, row in rows:
22520 step_order.append(row["step"])
22521 rows_by_step.setdefault(row["step"], {})[block] = {
22522 key: value for key, value in row.items() if key not in ("step",)}
22523 return rows_by_step, sources, step_order
22524
22525
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _parse_profiling_timestep_csv()

"tuple[dict, list[int]]" picurv_cli.core._parse_profiling_timestep_csv ( str  filepath)
protected

Parse profiling timestep CSV into latest rows by step plus observed order.

Parameters
[in]filepathArgument passed to _parse_profiling_timestep_csv().
Returns
Value returned by _parse_profiling_timestep_csv().

Definition at line 22526 of file core.py.

22526def _parse_profiling_timestep_csv(filepath: str) -> "tuple[dict, list[int]]":
22527 """!
22528 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
22529 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
22530 @return Value returned by `_parse_profiling_timestep_csv()`.
22531 """
22532 rows_by_step = {}
22533 step_order = []
22534 active_step = None
22535 if not os.path.isfile(filepath):
22536 return rows_by_step, step_order
22537
22538 with open(filepath, "r", encoding="utf-8", errors="replace", newline="") as f:
22539 reader = csv.DictReader(f)
22540 for row in reader:
22541 step = _parse_int_loose(row.get("step"))
22542 if step is None:
22543 continue
22544 if step != active_step:
22545 active_step = step
22546 step_order.append(step)
22547 rows_by_step[step] = []
22548 rows_by_step.setdefault(step, []).append(
22549 {
22550 "function": row.get("function"),
22551 "calls": _parse_int_loose(row.get("calls")),
22552 "step_time_s": _parse_float_loose(row.get("step_time_s")),
22553 }
22554 )
22555 return rows_by_step, step_order
22556
22557
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _parse_runtime_memory_log()

"tuple[dict, list[int], dict]" picurv_cli.core._parse_runtime_memory_log ( str  filepath)
protected

Read the runtime memory record into rows by step and its final status.

Parameters
[in]filepathRuntime memory log path.
Returns
Tuple of rows by step, observed step order, and final/shutdown metadata.

Definition at line 22558 of file core.py.

22558def _parse_runtime_memory_log(filepath: str) -> "tuple[dict, list[int], dict]":
22559 """!
22560 @brief Read the runtime memory record into rows by step and its final status.
22561 @param[in] filepath Runtime memory log path.
22562 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
22563 """
22564 rows_by_step, step_order = {}, []
22565 final_row = latest_sample_row = max_process_change_mb = None
22566 step_walls = []
22567 if not os.path.isfile(filepath):
22568 return rows_by_step, step_order, {"available": False}
22569 for _, row in _runtime_memory_rows(filepath):
22570 if row.get("step_wall_s") is not None:
22571 step_walls.append(row["step_wall_s"])
22572 change = row.get("process_change_mb_max")
22573 if change is not None:
22574 max_process_change_mb = change if max_process_change_mb is None else max(max_process_change_mb, change)
22575 if row["event"] in {"Step", "Post"}:
22576 rows_by_step[row["step"]] = row
22577 step_order.append(row["step"])
22578 latest_sample_row = row
22579 elif row["event"] in {"Shutdown", "Final"}:
22580 final_row = row
22581 meta = {
22582 "available": bool(rows_by_step or final_row),
22583 "source": filepath,
22584 "final_event": final_row.get("event") if final_row else None,
22585 "final_reason": final_row.get("reason") if final_row else None,
22586 "max_process_change_mb": max_process_change_mb,
22587 "mean_step_wall_s": (sum(step_walls) / len(step_walls)) if step_walls else None,
22588 "latest_sample_row": latest_sample_row,
22589 "final_row": final_row,
22590 }
22591 return rows_by_step, step_order, meta
22592
22593
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _parse_solution_convergence_log()

"tuple[dict, list[int]]" picurv_cli.core._parse_solution_convergence_log ( str  log_dir)
protected

Read the solution-convergence record into rows by step plus observed order.

Parameters
[in]log_dirRun log directory.
Returns
Mapping step -> row of every column, and the steps in file order.

Definition at line 22594 of file core.py.

22594def _parse_solution_convergence_log(log_dir: str) -> "tuple[dict, list[int]]":
22595 """!
22596 @brief Read the solution-convergence record into rows by step plus observed order.
22597 @param[in] log_dir Run log directory.
22598 @return Mapping step -> row of every column, and the steps in file order.
22599 """
22600 rows_by_step, step_order = {}, []
22601 for _, row in _solution_convergence_rows(log_dir)[1]:
22602 rows_by_step[row["step"]] = row
22603 step_order.append(row["step"])
22604 return rows_by_step, step_order
22605
22606
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _find_solver_stream_log_candidates()

"list[str]" picurv_cli.core._find_solver_stream_log_candidates ( str  run_dir,
str  log_dir 
)
protected

Return plausible solver stream logs for local and Slurm runs.

Parameters
[in]run_dirArgument passed to _find_solver_stream_log_candidates().
[in]log_dirArgument passed to _find_solver_stream_log_candidates().
Returns
Value returned by _find_solver_stream_log_candidates().

Definition at line 22607 of file core.py.

22607def _find_solver_stream_log_candidates(run_dir: str, log_dir: str) -> "list[str]":
22608 """!
22609 @brief Return plausible solver stream logs for local and Slurm runs.
22610 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
22611 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
22612 @return Value returned by `_find_solver_stream_log_candidates()`.
22613 """
22614 patterns = [
22615 os.path.join(run_dir, "scheduler", "*_solver.log"),
22616 os.path.join(run_dir, "scheduler", "solver_*.out"),
22617 os.path.join(log_dir, "*_solver.log"),
22618 ]
22619 found = []
22620 seen = set()
22621 for pattern in patterns:
22622 for path in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True):
22623 if path not in seen:
22624 seen.add(path)
22625 found.append(path)
22626 return found
22627
22628
Here is the caller graph for this function:

◆ _parse_particle_snapshot_file()

dict picurv_cli.core._parse_particle_snapshot_file ( str  filepath)
protected

Parse sampled particle snapshots from a solver stream log.

Parameters
[in]filepathArgument passed to _parse_particle_snapshot_file().
Returns
Value returned by _parse_particle_snapshot_file().

Definition at line 22629 of file core.py.

22629def _parse_particle_snapshot_file(filepath: str) -> dict:
22630 """!
22631 @brief Parse sampled particle snapshots from a solver stream log.
22632 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
22633 @return Value returned by `_parse_particle_snapshot_file()`.
22634 """
22635 snapshots = {}
22636 if not os.path.isfile(filepath):
22637 return snapshots
22638
22639 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
22640 lines = f.readlines()
22641
22642 idx = 0
22643 while idx < len(lines):
22644 match = re.search(r"Particle states at step\s+(\d+):", lines[idx])
22645 if not match:
22646 idx += 1
22647 continue
22648 step = int(match.group(1))
22649 idx += 1
22650 rows = []
22651 while idx < len(lines):
22652 stripped = lines[idx].strip()
22653 if re.search(r"Particle states at step\s+\d+:", lines[idx]):
22654 break
22655 if stripped.startswith("|"):
22656 parts = [part.strip() for part in stripped.split("|")[1:-1]]
22657 if len(parts) >= 6 and parts[0] != "Rank":
22658 velocity = _extract_numeric_tuple(parts[4])
22659 rows.append(
22660 {
22661 "rank": _parse_int_loose(parts[0]),
22662 "pid": _parse_int_loose(parts[1]),
22663 "cell": [int(value) for value in _extract_numeric_tuple(parts[2])],
22664 "position": _extract_numeric_tuple(parts[3]),
22665 "velocity": velocity,
22666 "weights": _extract_numeric_tuple(parts[5]),
22667 "sample_speed": math.sqrt(sum(component * component for component in velocity)) if len(velocity) == 3 else None,
22668 }
22669 )
22670 elif rows and (not stripped or stripped.startswith("Progress:")):
22671 break
22672 idx += 1
22673 if rows:
22674 snapshots[step] = rows
22675 else:
22676 snapshots.setdefault(step, [])
22677 return snapshots
22678
22679
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _find_previous_snapshot_step()

"int | None" picurv_cli.core._find_previous_snapshot_step ( "list[int]"  snapshot_steps,
int  step 
)
protected

Return the nearest earlier snapshot step when available.

Parameters
[in]snapshot_stepsArgument passed to _find_previous_snapshot_step().
[in]stepArgument passed to _find_previous_snapshot_step().
Returns
Value returned by _find_previous_snapshot_step().

Definition at line 22680 of file core.py.

22680def _find_previous_snapshot_step(snapshot_steps: "list[int]", step: int) -> "int | None":
22681 """!
22682 @brief Return the nearest earlier snapshot step when available.
22683 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
22684 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
22685 @return Value returned by `_find_previous_snapshot_step()`.
22686 """
22687 earlier_steps = [candidate for candidate in snapshot_steps if candidate < step]
22688 if not earlier_steps:
22689 return None
22690 return max(earlier_steps)
22691
22692
Here is the caller graph for this function:

◆ _compute_particle_snapshot_delta()

dict picurv_cli.core._compute_particle_snapshot_delta ( "list[dict]"  current_rows,
"list[dict]"  previous_rows 
)
protected

Compute sampled deltas between two particle snapshot samples.

Parameters
[in]current_rowsArgument passed to _compute_particle_snapshot_delta().
[in]previous_rowsArgument passed to _compute_particle_snapshot_delta().
Returns
Value returned by _compute_particle_snapshot_delta().

Definition at line 22693 of file core.py.

22693def _compute_particle_snapshot_delta(current_rows: "list[dict]", previous_rows: "list[dict]") -> dict:
22694 """!
22695 @brief Compute sampled deltas between two particle snapshot samples.
22696 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
22697 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
22698 @return Value returned by `_compute_particle_snapshot_delta()`.
22699 """
22700 np = require_numpy()
22701 previous_by_pid = {
22702 row.get("pid"): row
22703 for row in previous_rows
22704 if row.get("pid") is not None
22705 }
22706 current_by_pid = {
22707 row.get("pid"): row
22708 for row in current_rows
22709 if row.get("pid") is not None
22710 }
22711 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
22712 if not matched_pids:
22713 return {"available": False}
22714
22715 displacements = []
22716 rank_migrations = 0
22717 cell_changes = 0
22718 speed_changes = []
22719 for pid in matched_pids:
22720 current_row = current_by_pid[pid]
22721 previous_row = previous_by_pid[pid]
22722 current_pos = current_row.get("position") or []
22723 previous_pos = previous_row.get("position") or []
22724 if len(current_pos) == len(previous_pos) and current_pos:
22725 displacements.append(
22726 math.sqrt(
22727 sum(
22728 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
22729 for idx in range(len(current_pos))
22730 )
22731 )
22732 )
22733 current_speed = current_row.get("sample_speed")
22734 previous_speed = previous_row.get("sample_speed")
22735 if current_speed is not None and previous_speed is not None:
22736 speed_changes.append(float(current_speed) - float(previous_speed))
22737 if current_row.get("rank") is not None and previous_row.get("rank") is not None:
22738 if current_row["rank"] != previous_row["rank"]:
22739 rank_migrations += 1
22740 if current_row.get("cell") and previous_row.get("cell"):
22741 if current_row["cell"] != previous_row["cell"]:
22742 cell_changes += 1
22743
22744 payload = {
22745 "available": True,
22746 "matched_pids": len(matched_pids),
22747 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
22748 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
22749 "rank_migrations": rank_migrations,
22750 "cell_changes": cell_changes,
22751 }
22752 if displacements:
22753 payload["mean_displacement"] = float(np.mean(displacements))
22754 payload["max_displacement"] = float(np.max(displacements))
22755 if speed_changes:
22756 payload["mean_speed_change"] = float(np.mean(speed_changes))
22757 payload["max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
22758 return payload
22759
22760
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _build_particle_snapshot_summary()

dict picurv_cli.core._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 
)
protected

Build sampled diagnostics for one particle console snapshot.

Parameters
[in]sourceArgument passed to _build_particle_snapshot_summary().
[in]stepArgument passed to _build_particle_snapshot_summary().
[in]rowsArgument passed to _build_particle_snapshot_summary().
[in]preview_rowsArgument passed to _build_particle_snapshot_summary().
[in]particle_console_output_freqArgument passed to _build_particle_snapshot_summary().
[in]particle_log_intervalArgument passed to _build_particle_snapshot_summary().
[in]previous_stepArgument passed to _build_particle_snapshot_summary().
[in]previous_rowsArgument passed to _build_particle_snapshot_summary().
Returns
Value returned by _build_particle_snapshot_summary().

Definition at line 22761 of file core.py.

22770) -> dict:
22771 """!
22772 @brief Build sampled diagnostics for one particle console snapshot.
22773 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
22774 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
22775 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
22776 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
22777 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
22778 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
22779 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
22780 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
22781 @return Value returned by `_build_particle_snapshot_summary()`.
22782 """
22783 np = require_numpy()
22784 payload = {
22785 "available": True,
22786 "sampled": True,
22787 "source": source,
22788 "step": step,
22789 "sampled_rows": len(rows),
22790 "preview_rows": rows[:preview_rows],
22791 "cadence": {
22792 "particle_console_output_frequency": particle_console_output_freq,
22793 "particle_log_interval": particle_log_interval,
22794 },
22795 }
22796 if not rows:
22797 return payload
22798
22799 rank_counts = {}
22800 duplicate_pid_count = 0
22801 duplicate_cell_count = 0
22802 nan_count = 0
22803 inf_count = 0
22804 zero_weight_count = 0
22805 negative_weight_count = 0
22806 unique_pid_count = 0
22807
22808 seen_pids = set()
22809 cell_counter = {}
22810 position_components = [[], [], []]
22811 weight_components = {}
22812 speeds = []
22813
22814 for row in rows:
22815 pid = row.get("pid")
22816 if pid is not None:
22817 if pid in seen_pids:
22818 duplicate_pid_count += 1
22819 else:
22820 seen_pids.add(pid)
22821 rank = row.get("rank")
22822 if rank is not None:
22823 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
22824
22825 cell = row.get("cell") or []
22826 if cell:
22827 key = tuple(cell)
22828 cell_counter[key] = cell_counter.get(key, 0) + 1
22829
22830 position = row.get("position") or []
22831 for idx, value in enumerate(position[:3]):
22832 if not np.isfinite(value):
22833 if np.isnan(value):
22834 nan_count += 1
22835 else:
22836 inf_count += 1
22837 continue
22838 position_components[idx].append(float(value))
22839
22840 velocity = row.get("velocity") or []
22841 if any(not np.isfinite(value) for value in velocity):
22842 for value in velocity:
22843 if not np.isfinite(value):
22844 if np.isnan(value):
22845 nan_count += 1
22846 else:
22847 inf_count += 1
22848 speed = row.get("sample_speed")
22849 if speed is not None and np.isfinite(speed):
22850 speeds.append(float(speed))
22851
22852 weights = row.get("weights") or []
22853 for idx, value in enumerate(weights):
22854 if not np.isfinite(value):
22855 if np.isnan(value):
22856 nan_count += 1
22857 else:
22858 inf_count += 1
22859 continue
22860 numeric = float(value)
22861 weight_components.setdefault(idx, []).append(numeric)
22862 if abs(numeric) <= 1.0e-15:
22863 zero_weight_count += 1
22864 if numeric < 0.0:
22865 negative_weight_count += 1
22866
22867 unique_pid_count = len(seen_pids)
22868 duplicate_cell_count = sum(1 for count in cell_counter.values() if count > 1)
22869
22870 payload["sampled_distribution"] = {
22871 "unique_cells": len(cell_counter),
22872 "duplicate_cells": duplicate_cell_count,
22873 "rank_counts": rank_counts,
22874 "unique_pids": unique_pid_count,
22875 }
22876 payload["checks"] = {
22877 "duplicate_pid_count": duplicate_pid_count,
22878 "nan_count": nan_count,
22879 "inf_count": inf_count,
22880 "zero_weight_count": zero_weight_count,
22881 "negative_weight_count": negative_weight_count,
22882 }
22883
22884 if speeds:
22885 payload["speed"] = {
22886 "min": float(np.min(speeds)),
22887 "mean": float(np.mean(speeds)),
22888 "max": float(np.max(speeds)),
22889 "std": float(np.std(speeds)),
22890 "stagnant_count": sum(1 for speed in speeds if abs(speed) < 1.0e-6),
22891 }
22892
22893 fastest_rows = sorted(
22894 [row for row in rows if row.get("sample_speed") is not None],
22895 key=lambda row: row["sample_speed"],
22896 reverse=True,
22897 )[:3]
22898 payload["top_speeds"] = [
22899 {
22900 "pid": row.get("pid"),
22901 "rank": row.get("rank"),
22902 "speed": row.get("sample_speed"),
22903 "cell": row.get("cell"),
22904 }
22905 for row in fastest_rows
22906 ]
22907
22908 if any(position_components):
22909 axes = ["x", "y", "z"]
22910 payload["position_bounds"] = {}
22911 centroid = []
22912 for idx, axis in enumerate(axes):
22913 values = position_components[idx]
22914 if values:
22915 payload["position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
22916 centroid.append(float(np.mean(values)))
22917 else:
22918 centroid.append(None)
22919 payload["position_centroid"] = centroid
22920
22921 if weight_components:
22922 payload["weights"] = {}
22923 for idx, values in sorted(weight_components.items()):
22924 payload["weights"][f"component_{idx}"] = {
22925 "min": float(np.min(values)),
22926 "max": float(np.max(values)),
22927 }
22928
22929 delta_summary = {"available": False}
22930 if previous_step is not None and previous_rows:
22931 delta_summary = _compute_particle_snapshot_delta(rows, previous_rows)
22932 if delta_summary.get("available"):
22933 delta_summary["previous_step"] = previous_step
22934 payload["delta_from_previous_snapshot"] = delta_summary
22935 return payload
22936
22937
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _find_particle_snapshot_for_step()

dict picurv_cli.core._find_particle_snapshot_for_step ( str  run_dir,
str  log_dir,
int  step,
int  preview_rows,
  particle_console_output_freq,
  particle_log_interval 
)
protected

Locate and summarize a particle console snapshot for one step.

Parameters
[in]run_dirArgument passed to _find_particle_snapshot_for_step().
[in]log_dirArgument passed to _find_particle_snapshot_for_step().
[in]stepArgument passed to _find_particle_snapshot_for_step().
[in]preview_rowsArgument passed to _find_particle_snapshot_for_step().
[in]particle_console_output_freqArgument passed to _find_particle_snapshot_for_step().
[in]particle_log_intervalArgument passed to _find_particle_snapshot_for_step().
Returns
Value returned by _find_particle_snapshot_for_step().

Definition at line 22938 of file core.py.

22945) -> dict:
22946 """!
22947 @brief Locate and summarize a particle console snapshot for one step.
22948 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
22949 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
22950 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
22951 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
22952 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
22953 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
22954 @return Value returned by `_find_particle_snapshot_for_step()`.
22955 """
22956 best = None
22957 for path in _find_solver_stream_log_candidates(run_dir, log_dir):
22958 snapshots = _parse_particle_snapshot_file(path)
22959 rows = snapshots.get(step)
22960 if not rows:
22961 continue
22962 if best is None or len(rows) > len(best["rows"]):
22963 best = {"source": path, "rows": rows, "snapshots": snapshots}
22964
22965 if not best:
22966 return {"available": False}
22967
22968 previous_step = _find_previous_snapshot_step(list(best["snapshots"].keys()), step)
22969 previous_rows = best["snapshots"].get(previous_step, []) if previous_step is not None else None
22970 return _build_particle_snapshot_summary(
22971 best["source"],
22972 step,
22973 best["rows"],
22974 preview_rows,
22975 particle_console_output_freq=particle_console_output_freq,
22976 particle_log_interval=particle_log_interval,
22977 previous_step=previous_step,
22978 previous_rows=previous_rows,
22979 )
22980
22981
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _resolve_summary_step()

picurv_cli.core._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" 
)
protected

Select a step to summarize from available metric artifacts.

Parameters
[in]requested_stepArgument passed to _resolve_summary_step().
[in]continuity_rowsArgument passed to _resolve_summary_step().
[in]particle_rowsArgument passed to _resolve_summary_step().
[in]momentum_rowsArgument passed to _resolve_summary_step().
[in]poisson_rowsArgument passed to _resolve_summary_step().
[in]profiling_rowsArgument passed to _resolve_summary_step().
[in]memory_rowsArgument passed to _resolve_summary_step().
[in]convergence_rowsArgument passed to _resolve_summary_step().
[in]step_ordersArgument passed to _resolve_summary_step().
[in]selection_modeArgument passed to _resolve_summary_step().
Returns
Value returned by _resolve_summary_step().

Definition at line 22982 of file core.py.

22993):
22994 """!
22995 @brief Select a step to summarize from available metric artifacts.
22996 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
22997 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
22998 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
22999 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
23000 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
23001 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
23002 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
23003 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
23004 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
23005 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
23006 @return Value returned by `_resolve_summary_step()`.
23007 """
23008 if memory_rows is None:
23009 memory_rows = {}
23010 if convergence_rows is None:
23011 convergence_rows = {}
23012 if step_orders is None:
23013 step_orders = []
23014 available_steps = (
23015 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
23016 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
23017 )
23018 if not available_steps:
23019 return None, []
23020
23021 if requested_step is not None:
23022 return requested_step, sorted(available_steps)
23023
23024 if selection_mode == "max_step":
23025 return max(available_steps), sorted(available_steps)
23026
23027 for order in step_orders:
23028 if order:
23029 return order[-1], sorted(available_steps)
23030 return max(available_steps), sorted(available_steps)
23031
23032
Here is the caller graph for this function:

◆ _format_summary_float()

str picurv_cli.core._format_summary_float (   value,
str   spec = ".6e",
str   missing = "n/a" 
)
protected

Format optional numeric values for summary text output.

Parameters
[in]valueArgument passed to _format_summary_float().
[in]specArgument passed to _format_summary_float().
[in]missingArgument passed to _format_summary_float().
Returns
Value returned by _format_summary_float().

Definition at line 23033 of file core.py.

23033def _format_summary_float(value, spec: str = ".6e", missing: str = "n/a") -> str:
23034 """!
23035 @brief Format optional numeric values for summary text output.
23036 @param[in] value Argument passed to `_format_summary_float()`.
23037 @param[in] spec Argument passed to `_format_summary_float()`.
23038 @param[in] missing Argument passed to `_format_summary_float()`.
23039 @return Value returned by `_format_summary_float()`.
23040 """
23041 if value is None:
23042 return missing
23043 return format(value, spec)
23044
23045

◆ _summary_source_mtime()

float picurv_cli.core._summary_source_mtime (   paths)
protected

Return the newest modification time among one or more summary sources.

Parameters
[in]pathsPath string, iterable of paths, or mapping of paths.
Returns
Newest modification time, or -1.0 when no source exists.

Definition at line 23046 of file core.py.

23046def _summary_source_mtime(paths) -> float:
23047 """!
23048 @brief Return the newest modification time among one or more summary sources.
23049 @param[in] paths Path string, iterable of paths, or mapping of paths.
23050 @return Newest modification time, or -1.0 when no source exists.
23051 """
23052 if isinstance(paths, dict):
23053 paths = paths.values()
23054 elif isinstance(paths, str):
23055 paths = [paths]
23056 newest = -1.0
23057 for path in paths or []:
23058 if path and os.path.isfile(path):
23059 newest = max(newest, os.path.getmtime(path))
23060 return newest
23061
23062
Here is the caller graph for this function:

◆ _order_summary_step_orders()

"list[list[int]]" picurv_cli.core._order_summary_step_orders ( "list[tuple[list[int], object]]"  sources)
protected

Order observed step sequences by the recency of their source files.

Parameters
[in]sourcesPairs of observed steps and filesystem source path(s).
Returns
Step-order lists sorted so active append sources are considered first.

Definition at line 23063 of file core.py.

23063def _order_summary_step_orders(sources: "list[tuple[list[int], object]]") -> "list[list[int]]":
23064 """!
23065 @brief Order observed step sequences by the recency of their source files.
23066 @param[in] sources Pairs of observed steps and filesystem source path(s).
23067 @return Step-order lists sorted so active append sources are considered first.
23068 """
23069 ranked = []
23070 for priority, (order, paths) in enumerate(sources):
23071 if order:
23072 ranked.append((_summary_source_mtime(paths), priority, order))
23073 ranked.sort(key=lambda item: (-item[0], item[1]))
23074 return [order for _, _, order in ranked]
23075
23076
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_run_summary_payload()

dict picurv_cli.core.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.

Parameters
[in]run_dirArgument passed to build_run_summary_payload().
[in]stepArgument passed to build_run_summary_payload().
[in]snapshot_rowsArgument passed to build_run_summary_payload().
[in]selection_modeArgument passed to build_run_summary_payload().
Returns
Value returned by build_run_summary_payload().

Definition at line 23077 of file core.py.

23077def build_run_summary_payload(run_dir: str, step: "int | None" = None, snapshot_rows: int = 5, selection_mode: str = "latest") -> dict:
23078 """!
23079 @brief Build a read-only run-step summary from existing PICurv artifacts.
23080 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
23081 @param[in] step Argument passed to `build_run_summary_payload()`.
23082 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
23083 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
23084 @return Value returned by `build_run_summary_payload()`.
23085 """
23086 context = _build_summary_context(run_dir)
23087 log_dir = context["log_dir"]
23088 metrics_dir = context["metrics_dir"]
23089 continuity_path = (_log_family_path(log_dir, "Continuity_Metrics")
23090 or os.path.join(log_dir, "Continuity_Metrics.csv"))
23091 particle_metrics_path = (_particle_step_rows(log_dir, metrics_dir)[0]
23092 or os.path.join(metrics_dir, "search_metrics.csv"))
23093
23094 continuity_rows, continuity_order = _parse_continuity_metrics_log(log_dir)
23095 particle_rows, particle_order = _parse_particle_metrics_log(log_dir, metrics_dir)
23096 momentum_rows, momentum_sources, momentum_order = _parse_momentum_convergence_logs(log_dir)
23097 poisson_rows, poisson_sources, poisson_order = _parse_poisson_convergence_logs(log_dir)
23098
23099 profiling_rows = {}
23100 profiling_order = []
23101 profiling_path = os.path.join(log_dir, PROFILING_TIMESTEP_FILE)
23102 if context["profiling_cfg"].get("mode") != "off":
23103 profiling_rows, profiling_order = _parse_profiling_timestep_csv(profiling_path)
23104
23105 memory_path = _log_family_path(log_dir, "Runtime_Memory") or os.path.join(log_dir, RUNTIME_MEMORY_FILE)
23106 memory_rows, memory_order, memory_meta = _parse_runtime_memory_log(memory_path)
23107
23108 convergence_log_path = (_log_family_path(log_dir, "solution_convergence")
23109 or os.path.join(log_dir, "solution_convergence.csv"))
23110 convergence_rows, convergence_order = _parse_solution_convergence_log(log_dir)
23111 step_orders = _order_summary_step_orders(
23112 [
23113 (continuity_order, continuity_path),
23114 (particle_order, particle_metrics_path),
23115 (convergence_order, convergence_log_path),
23116 (profiling_order, profiling_path),
23117 (memory_order, memory_path),
23118 (momentum_order, momentum_sources),
23119 (poisson_order, poisson_sources),
23120 ]
23121 )
23122
23123 resolved_step, available_steps = _resolve_summary_step(
23124 step,
23125 continuity_rows,
23126 particle_rows,
23127 momentum_rows,
23128 poisson_rows,
23129 profiling_rows,
23130 convergence_rows,
23131 memory_rows,
23132 step_orders=step_orders,
23133 selection_mode=selection_mode,
23134 )
23135 if resolved_step is None:
23136 emit_structured_error(
23137 ERROR_CODE_CFG_FILE_NOT_FOUND,
23138 key="summary",
23139 file_path=log_dir,
23140 message="No summary-capable run artifacts were found under the run log directory.",
23141 hint="Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
23142 )
23143 sys.exit(1)
23144
23145 if step is not None and step not in set(available_steps):
23146 emit_structured_error(
23147 ERROR_CODE_CFG_INVALID_VALUE,
23148 key="step",
23149 file_path=context["run_dir"],
23150 message=f"Requested step {step} is not present in the available summary artifacts.",
23151 hint=f"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
23152 )
23153 sys.exit(1)
23154
23155 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=lambda row: row["block"])
23156 continuity_summary = {"available": bool(continuity_step_rows), "blocks": continuity_step_rows}
23157 if continuity_step_rows:
23158 divergence_values = [
23159 abs(row["max_divergence"])
23160 for row in continuity_step_rows
23161 if row["max_divergence"] is not None
23162 ]
23163 continuity_summary["max_abs_divergence"] = max(divergence_values) if divergence_values else None
23164 continuity_summary["net_flux"] = continuity_step_rows[0].get("net_flux")
23165 continuity_summary["flux_in"] = continuity_step_rows[0].get("flux_in")
23166 continuity_summary["flux_out"] = continuity_step_rows[0].get("flux_out")
23167
23168 momentum_step_rows = [row for _, row in sorted(momentum_rows.get(resolved_step, {}).items())]
23169 momentum_summary = {"available": bool(momentum_step_rows), "blocks": momentum_step_rows}
23170
23171 poisson_step_rows = [row for _, row in sorted(poisson_rows.get(resolved_step, {}).items())]
23172 poisson_summary = {"available": bool(poisson_step_rows), "blocks": poisson_step_rows}
23173
23174 particle_summary = {"available": resolved_step in particle_rows}
23175 if resolved_step in particle_rows:
23176 particle_summary.update(particle_rows[resolved_step])
23177
23178 profiling_summary = {"available": resolved_step in profiling_rows}
23179 if resolved_step in profiling_rows:
23180 functions = sorted(
23181 profiling_rows[resolved_step],
23182 key=lambda row: (row.get("step_time_s") or 0.0),
23183 reverse=True,
23184 )
23185 profiling_summary["functions"] = functions
23186 profiling_summary["total_logged_step_time_s"] = sum(
23187 row.get("step_time_s") or 0.0 for row in functions
23188 )
23189
23190 memory_summary = {"available": resolved_step in memory_rows}
23191 if resolved_step in memory_rows:
23192 memory_summary.update(memory_rows[resolved_step])
23193 memory_summary["source"] = memory_path
23194 memory_summary["max_process_change_mb"] = memory_meta.get("max_process_change_mb")
23195 memory_summary["mean_step_wall_s"] = memory_meta.get("mean_step_wall_s")
23196 memory_summary["final_event"] = memory_meta.get("final_event")
23197 memory_summary["final_reason"] = memory_meta.get("final_reason")
23198 memory_summary["selected_step"] = resolved_step
23199 memory_summary["step_match"] = True
23200 elif memory_meta.get("available"):
23201 latest_sample_row = memory_meta.get("latest_sample_row")
23202 if latest_sample_row:
23203 memory_summary.update(latest_sample_row)
23204 memory_summary.update(memory_meta)
23205 memory_summary["selected_step"] = resolved_step
23206 memory_summary["step_match"] = False
23207
23208 snapshot_summary = {"available": False}
23209 if context["particle_console_output_freq"] and context["particle_console_output_freq"] > 0:
23210 snapshot_summary = _find_particle_snapshot_for_step(
23211 context["run_dir"],
23212 log_dir,
23213 resolved_step,
23214 preview_rows=max(1, snapshot_rows),
23215 particle_console_output_freq=context["particle_console_output_freq"],
23216 particle_log_interval=context["particle_log_interval"],
23217 )
23218
23219 monitor_info = {
23220 "profiling_timestep_mode": context["profiling_cfg"].get("mode"),
23221 "profiling_timestep_file": PROFILING_TIMESTEP_FILE,
23222 "particle_console_output_frequency": context["particle_console_output_freq"],
23223 "particle_log_interval": context["particle_log_interval"],
23224 }
23225
23226 return {
23227 "run_id": context["manifest"].get("run_id", os.path.basename(context["run_dir"])),
23228 "run_dir": context["run_dir"],
23229 "step": resolved_step,
23230 "selected_via": "explicit" if step is not None else ("max_step" if selection_mode == "max_step" else "latest_available"),
23231 "available_steps": available_steps,
23232 "launch_mode": context["manifest"].get("launch_mode"),
23233 "created_at": context["manifest"].get("created_at"),
23234 "monitor": monitor_info,
23235 "particles_configured": context["particle_count_cfg"],
23236 "sources": {
23237 "continuity_log": continuity_path if os.path.isfile(continuity_path) else None,
23238 "particle_metrics_log": particle_metrics_path if os.path.isfile(particle_metrics_path) else None,
23239 "momentum_logs": momentum_sources,
23240 "poisson_logs": poisson_sources,
23241 "profiling_timestep_csv": profiling_path if os.path.isfile(profiling_path) else None,
23242 "solution_convergence_log": convergence_log_path if os.path.isfile(convergence_log_path) else None,
23243 "runtime_memory_log": memory_path if os.path.isfile(memory_path) else None,
23244 },
23245 "continuity": continuity_summary,
23246 "momentum": momentum_summary,
23247 "poisson": poisson_summary,
23248 "particles": particle_summary,
23249 "particle_snapshot": snapshot_summary,
23250 "profiling": profiling_summary,
23251 "memory": memory_summary,
23252 "convergence": convergence_rows.get(resolved_step) if convergence_rows else None,
23253 }
23254
23255
Here is the call graph for this function:
Here is the caller graph for this function:

◆ render_run_summary()

picurv_cli.core.render_run_summary ( dict  payload,
str   output_format = "text" 
)

Render a run-step summary in human or JSON form.

Parameters
[in]payloadArgument passed to render_run_summary().
[in]output_formatArgument passed to render_run_summary().

Definition at line 23256 of file core.py.

23256def render_run_summary(payload: dict, output_format: str = "text"):
23257 """!
23258 @brief Render a run-step summary in human or JSON form.
23259 @param[in] payload Argument passed to `render_run_summary()`.
23260 @param[in] output_format Argument passed to `render_run_summary()`.
23261 """
23262 if output_format == "json":
23263 print(json.dumps(payload, indent=2, sort_keys=True))
23264 return
23265
23266 print("\n" + "=" * 60)
23267 print(" RUN STEP SUMMARY")
23268 print("=" * 60)
23269 print(f" Run ID : {payload.get('run_id')}")
23270 print(f" Run directory : {os.path.relpath(payload.get('run_dir'))}")
23271 print(f" Step : {payload.get('step')} ({payload.get('selected_via')})")
23272 if payload.get("launch_mode"):
23273 print(f" Launch mode : {payload.get('launch_mode')}")
23274 if payload.get("created_at"):
23275 print(f" Created at : {payload.get('created_at')}")
23276
23277 continuity = payload.get("continuity", {})
23278 print("\n Continuity:")
23279 if continuity.get("available"):
23280 if continuity.get("max_abs_divergence") is not None:
23281 print(f" max |div| : {continuity['max_abs_divergence']:.6e}")
23282 if continuity.get("net_flux") is not None:
23283 print(f" net flux : {continuity['net_flux']:.6e}")
23284 for row in continuity.get("blocks", []):
23285 print(
23286 " "
23287 f"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
23288 f"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
23289 )
23290 else:
23291 print(" unavailable")
23292
23293 momentum = payload.get("momentum", {})
23294 print("\n Momentum:")
23295 if momentum.get("available"):
23296 for row in momentum.get("blocks", []):
23297 if row.get("solver") == "Newton Krylov":
23298 print(f" block {row['block']}: solver=Newton Krylov")
23299 print(
23300 f" newton={row.get('newton_iterations')} "
23301 f"krylov={row.get('krylov_iterations')} "
23302 f"evals={row.get('residual_evaluations')}"
23303 )
23304 print(
23305 f" final={_format_summary_float(row.get('final_norm'))} "
23306 f"reason={row.get('reason')} state={row.get('state')}"
23307 )
23308 continue
23309 status = row.get("status") or "unknown"
23310 accepted = row.get("accepted_count")
23311 rejected = row.get("rejected_count")
23312 counts_str = f" accepted={accepted} rejected={rejected}" if accepted is not None else ""
23313 # Phase 3+: logged as dtau (physical time) + cfl_eff (dimensionless Courant number).
23314 dtau_val = row.get("dtau")
23315 cfl_in = row.get("cfl_eff")
23316 cfl_out = row.get("cfl_eff_after")
23317 dtau_out = row.get("dtau_after")
23318 if cfl_in is not None and cfl_out is not None:
23319 cfl_str = f"cfl_eff {cfl_in:.4f}->{cfl_out:.4f} dtau {_format_summary_float(dtau_val)}->{_format_summary_float(dtau_out)}"
23320 elif cfl_in is not None:
23321 cfl_str = f"cfl_eff={_format_summary_float(cfl_in, '.4f')} dtau={_format_summary_float(dtau_val)}"
23322 else:
23323 cfl_str = "cfl_eff=n/a"
23324 ratio = row.get("trial_ratio")
23325 smoothed = row.get("smoothed_ratio")
23326 if ratio is not None and smoothed is not None:
23327 ratio_str = f" ratio={_format_summary_float(ratio)} (ema={_format_summary_float(smoothed)})"
23328 elif ratio is not None:
23329 ratio_str = f" ratio={_format_summary_float(ratio)}"
23330 else:
23331 ratio_str = ""
23332 print(f" block {row['block']} [{status}]:{counts_str} {cfl_str}{ratio_str}")
23333 print(
23334 f" resid={_format_summary_float(row.get('residual_norm'))}"
23335 f" delta={_format_summary_float(row.get('delta_norm'))}"
23336 )
23337 else:
23338 print(" unavailable")
23339
23340 poisson = payload.get("poisson", {})
23341 print("\n Poisson:")
23342 if poisson.get("available"):
23343 for row in poisson.get("blocks", []):
23344 print(
23345 " "
23346 f"block {row['block']}: iter={row['iterations']} "
23347 f"true={_format_summary_float(row.get('true_norm'))} "
23348 f"rel={_format_summary_float(row.get('relative_norm'))}"
23349 )
23350 else:
23351 print(" unavailable")
23352
23353 convergence = payload.get("convergence")
23354 print("\n Solution Convergence:")
23355 if convergence is not None:
23356 mode = convergence.get("mode", "unknown")
23357 ref = convergence.get("ref")
23358 print(f" mode : {mode} (ref={'yes' if ref else 'no'})")
23359 if mode in ("steady_deterministic", "transient"):
23360 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
23361 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
23362 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
23363 elif mode == "periodic_deterministic":
23364 ph = convergence.get("ph")
23365 per = convergence.get("per")
23366 print(f" phase : {ph}/{per}")
23367 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
23368 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
23369 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
23370 elif mode == "statistical_steady":
23371 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} win={_format_summary_float(convergence.get('spd_win'))} win_drift={_format_summary_float(convergence.get('spd_win_abs'))}")
23372 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} win={_format_summary_float(convergence.get('ke_win'))} win_drift={_format_summary_float(convergence.get('ke_win_abs'))}")
23373 else:
23374 print(" unavailable")
23375
23376 particles = payload.get("particles", {})
23377 print("\n Particles:")
23378 if particles.get("available"):
23379 loss_summary = f"lost={particles.get('lost_particles')}"
23380 if particles.get("lost_particles_cumulative") is not None:
23381 loss_summary = (
23382 f"lost(step/total)={particles.get('lost_particles')}/"
23383 f"{particles.get('lost_particles_cumulative')}"
23384 )
23385 if particles.get("exited_particles_cumulative") is not None:
23386 loss_summary += (
23387 f" exited(step/total)={particles.get('exited_particles')}/"
23388 f"{particles.get('exited_particles_cumulative')}"
23389 )
23390 print(
23391 " "
23392 f"total={particles.get('total_particles')} {loss_summary} "
23393 f"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
23394 f"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
23395 )
23396 else:
23397 print(" unavailable")
23398
23399 memory = payload.get("memory", {})
23400 print("\n Runtime Memory:")
23401 if memory.get("available"):
23402 if memory.get("source"):
23403 print(f" source : {os.path.relpath(memory.get('source'))}")
23404 if memory.get("step") is not None and not memory.get("step_match", True):
23405 print(f" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
23406 if memory.get("event"):
23407 print(f" event : {memory.get('event')} reason={memory.get('reason', '-')}")
23408 print(f" process max : {_format_summary_float(memory.get('process_current_mb_max'), '.3f')} MB current, {_format_summary_float(memory.get('process_peak_mb_max'), '.3f')} MB peak")
23409 print(f" PETSc max : {_format_summary_float(memory.get('petsc_allocated_mb_max'), '.3f')} MB allocated, {_format_summary_float(memory.get('petsc_peak_allocated_mb_max'), '.3f')} MB peak")
23410 print(f" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
23411 if memory.get("step_wall_s") is not None or memory.get("mean_step_wall_s") is not None:
23412 print(f" step time : {_format_summary_float(memory.get('step_wall_s'), '.4g')} s this step, "
23413 f"{_format_summary_float(memory.get('mean_step_wall_s'), '.4g')} s mean (slowest rank)")
23414 if memory.get("final_reason"):
23415 print(f" final reason : {memory.get('final_reason')}")
23416 else:
23417 print(" final reason : none recorded (still running, or stopped without a final row)")
23418 else:
23419 print(" unavailable")
23420
23421 snapshot = payload.get("particle_snapshot", {})
23422 if snapshot.get("available"):
23423 print("\n Particle Snapshot (sampled):")
23424 print(f" source : {os.path.relpath(snapshot.get('source'))}")
23425 cadence = snapshot.get("cadence", {})
23426 print(
23427 " "
23428 f"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
23429 f"row interval {cadence.get('particle_log_interval', 'n/a')}"
23430 )
23431 print(f" sampled rows : {snapshot.get('sampled_rows')}")
23432 speed = snapshot.get("speed", {})
23433 if speed:
23434 print(
23435 " "
23436 f"sampled speeds: min={_format_summary_float(speed.get('min'))} "
23437 f"mean={_format_summary_float(speed.get('mean'))} "
23438 f"max={_format_summary_float(speed.get('max'))} "
23439 f"std={_format_summary_float(speed.get('std'))} "
23440 f"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
23441 )
23442 bounds = snapshot.get("position_bounds", {})
23443 centroid = snapshot.get("position_centroid")
23444 if bounds:
23445 bound_parts = []
23446 for axis in ("x", "y", "z"):
23447 if axis in bounds:
23448 bound_parts.append(
23449 f"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
23450 )
23451 print(f" sampled bounds: {' '.join(bound_parts)}")
23452 if centroid:
23453 print(
23454 " "
23455 f"sampled center: ({_format_summary_float(centroid[0])}, "
23456 f"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
23457 )
23458 distribution = snapshot.get("sampled_distribution", {})
23459 if distribution:
23460 print(
23461 " "
23462 f"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
23463 f"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
23464 f"unique_pids={distribution.get('unique_pids', 'n/a')} "
23465 f"ranks={distribution.get('rank_counts', {})}"
23466 )
23467 weights = snapshot.get("weights", {})
23468 if weights:
23469 weight_parts = []
23470 for component, summary in sorted(weights.items()):
23471 weight_parts.append(
23472 f"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
23473 )
23474 print(f" sampled weights: {' '.join(weight_parts)}")
23475 checks = snapshot.get("checks", {})
23476 if checks:
23477 print(
23478 " "
23479 f"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
23480 f"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
23481 f"zero_weight={checks.get('zero_weight_count', 0)} "
23482 f"negative_weight={checks.get('negative_weight_count', 0)}"
23483 )
23484 top_speeds = snapshot.get("top_speeds", [])
23485 if top_speeds:
23486 summary = ", ".join(
23487 f"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
23488 for row in top_speeds
23489 )
23490 print(f" top speeds : {summary}")
23491 delta_summary = snapshot.get("delta_from_previous_snapshot", {})
23492 if delta_summary.get("available"):
23493 print(
23494 " "
23495 f"vs prev snap : step={delta_summary.get('previous_step')} "
23496 f"matched_pids={delta_summary.get('matched_pids')} "
23497 f"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
23498 f"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
23499 f"rank_moves={delta_summary.get('rank_migrations')} "
23500 f"cell_changes={delta_summary.get('cell_changes')} "
23501 f"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
23502 )
23503 print(" preview rows :")
23504 for row in snapshot.get("preview_rows", []):
23505 print(
23506 " "
23507 f"pid={row.get('pid')} rank={row.get('rank')} "
23508 f"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
23509 )
23510
23511 profiling = payload.get("profiling", {})
23512 print("\n Profiling:")
23513 if profiling.get("available"):
23514 print(f" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
23515 for row in profiling.get("functions", [])[:5]:
23516 print(
23517 " "
23518 f"{row.get('function')}: calls={row.get('calls')} "
23519 f"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
23520 )
23521 else:
23522 print(" unavailable")
23523 print("=" * 60)
23524
23525
Here is the caller graph for this function:

◆ _summary_display_value()

str picurv_cli.core._summary_display_value (   value)
protected

Format one configuration-summary value for compact text output.

Parameters
[in]valueValue to format.
Returns
Compact human-readable value.

Definition at line 23529 of file core.py.

23529def _summary_display_value(value) -> str:
23530 """!
23531 @brief Format one configuration-summary value for compact text output.
23532 @param[in] value Value to format.
23533 @return Compact human-readable value.
23534 """
23535 if value is None:
23536 return "-"
23537 if isinstance(value, bool):
23538 return "enabled" if value else "disabled"
23539 if isinstance(value, float):
23540 return f"{value:.6g}"
23541 if isinstance(value, (list, tuple)):
23542 return ", ".join(_summary_display_value(item) for item in value) if value else "none"
23543 if isinstance(value, dict):
23544 if not value:
23545 return "none"
23546 return ", ".join(f"{key}={_summary_display_value(item)}" for key, item in value.items())
23547 return str(value)
23548
23549
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _print_config_header()

picurv_cli.core._print_config_header ( str  title,
"str | None"   subtitle = None 
)
protected

Print a strong dashboard-style configuration summary header.

Parameters
[in]titleSection title.
[in]subtitleOptional one-line section subtitle.

Definition at line 23550 of file core.py.

23550def _print_config_header(title: str, subtitle: "str | None" = None):
23551 """!
23552 @brief Print a strong dashboard-style configuration summary header.
23553 @param[in] title Section title.
23554 @param[in] subtitle Optional one-line section subtitle.
23555 """
23556 print("\n" + "=" * _CONFIG_SUMMARY_WIDTH)
23557 print(f"{title:^78}")
23558 if subtitle:
23559 print(f"{subtitle:^78}")
23560 print("=" * _CONFIG_SUMMARY_WIDTH)
23561
23562
Here is the caller graph for this function:

◆ _print_config_group()

picurv_cli.core._print_config_group ( str  title,
list  rows 
)
protected

Print an aligned configuration-summary field group.

Parameters
[in]titleGroup title.
[in]rowsSequence of (label, value) pairs.

Definition at line 23563 of file core.py.

23563def _print_config_group(title: str, rows: list):
23564 """!
23565 @brief Print an aligned configuration-summary field group.
23566 @param[in] title Group title.
23567 @param[in] rows Sequence of `(label, value)` pairs.
23568 """
23569 visible_rows = [(label, value) for label, value in rows if value is not None]
23570 if not visible_rows:
23571 return
23572 print(f"\n {title}")
23573 print(f" {'-' * (len(title) + 1)}")
23574 for label, value in visible_rows:
23575 print(f" {label:<32} {_summary_display_value(value)}")
23576
23577
Here is the caller graph for this function:

◆ _flatten_summary_mapping()

list picurv_cli.core._flatten_summary_mapping ( dict  mapping,
str   prefix = "" 
)
protected

Flatten nested summary mappings into readable dotted field rows.

Parameters
[in]mappingMapping to flatten.
[in]prefixOptional parent-field prefix.
Returns
Sequence of (field, value) pairs.

Definition at line 23578 of file core.py.

23578def _flatten_summary_mapping(mapping: dict, prefix: str = "") -> list:
23579 """!
23580 @brief Flatten nested summary mappings into readable dotted field rows.
23581 @param[in] mapping Mapping to flatten.
23582 @param[in] prefix Optional parent-field prefix.
23583 @return Sequence of `(field, value)` pairs.
23584 """
23585 rows = []
23586 for key, value in mapping.items():
23587 label = f"{prefix}.{key}" if prefix else str(key)
23588 if isinstance(value, dict) and value:
23589 rows.extend(_flatten_summary_mapping(value, label))
23590 else:
23591 rows.append((label, value))
23592 return rows
23593
23594
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _render_run_overview_text()

picurv_cli.core._render_run_overview_text ( dict  summary)
protected

Render run metadata as a compact dashboard.

Parameters
[in]summaryCurated run overview mapping.

Definition at line 23595 of file core.py.

23595def _render_run_overview_text(summary: dict):
23596 """!
23597 @brief Render run metadata as a compact dashboard.
23598 @param[in] summary Curated run overview mapping.
23599 """
23600 _print_config_header("RUN OVERVIEW", summary.get("run_id"))
23601 _print_config_group(
23602 "Identity",
23603 [
23604 ("Run directory", os.path.relpath(summary.get("run_dir")) if summary.get("run_dir") else None),
23605 ("Created", summary.get("created_at")),
23606 ("Launch mode", summary.get("launch_mode")),
23607 ("PICurv release", summary.get("release_version")),
23608 ("Build", summary.get("build_id")),
23609 ("Git commit", summary.get("git_commit")),
23610 ],
23611 )
23612 _print_config_group(
23613 "Execution",
23614 [
23615 ("Solver MPI processes", summary.get("solver_num_procs")),
23616 ("Post MPI processes", summary.get("post_num_procs")),
23617 ("Stages requested", summary.get("stages_requested")),
23618 ("Stages ready/completed", summary.get("stages_completed_or_submitted")),
23619 ],
23620 )
23621
23622
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _render_case_summary_text()

picurv_cli.core._render_case_summary_text ( dict  summary)
protected

Render the case summary as a glanceable simulation dashboard.

Parameters
[in]summaryCurated case configuration mapping.

Definition at line 23623 of file core.py.

23623def _render_case_summary_text(summary: dict):
23624 """!
23625 @brief Render the case summary as a glanceable simulation dashboard.
23626 @param[in] summary Curated case configuration mapping.
23627 """
23628 run = summary.get("run_control", {})
23629 props = summary.get("properties", {})
23630 grid = summary.get("grid", {})
23631 domain = summary.get("domain", {})
23632 physics = summary.get("physics", {})
23633 subtitle = (
23634 f"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
23635 f"Re={_summary_display_value(props.get('reynolds_number'))}"
23636 )
23637 _print_config_header("CASE SUMMARY", subtitle)
23638 _print_config_group(
23639 "Simulation",
23640 [
23641 ("Step range", f"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
23642 ("Physical timestep", run.get("dt_physical")),
23643 ("Nondimensional timestep", run.get("dt_nondimensional")),
23644 ("Physical duration", run.get("duration_physical")),
23645 ("Initial conditions", props.get("initial_conditions")),
23646 ],
23647 )
23648 _print_config_group(
23649 "Fluid And Scaling",
23650 [
23651 ("Reynolds number", props.get("reynolds_number")),
23652 ("Reference length", props.get("length_ref")),
23653 ("Reference velocity", props.get("velocity_ref")),
23654 ("Density", props.get("density")),
23655 ("Viscosity", props.get("viscosity")),
23656 ],
23657 )
23658 _print_config_group(
23659 "Domain And Grid",
23660 [
23661 ("Grid mode", grid.get("mode")),
23662 ("Blocks", domain.get("blocks")),
23663 ("Dimensionality", domain.get("dimensionality")),
23664 ("Periodic axes", domain.get("periodic")),
23665 ("MPI grid layout", grid.get("processor_layout")),
23666 ("Grid source", grid.get("source_file")),
23667 ],
23668 )
23669 if grid.get("programmatic_settings"):
23670 _print_config_group("Programmatic Grid", _flatten_summary_mapping(grid["programmatic_settings"]))
23671 _print_config_group(
23672 "Physics",
23673 [
23674 ("Particles", physics.get("particles")),
23675 ("FSI", physics.get("fsi")),
23676 ("Turbulence", physics.get("turbulence")),
23677 ("Statistics", physics.get("statistics")),
23678 ],
23679 )
23680 boundary_blocks = summary.get("boundary_conditions", [])
23681 if boundary_blocks:
23682 print("\n Boundary Conditions")
23683 print(" --------------------")
23684 print(f" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
23685 print(f" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
23686 for block in boundary_blocks:
23687 for face in block.get("faces", []):
23688 print(
23689 f" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
23690 f"{face.get('type', '-'):<12} {face.get('handler', '-')}"
23691 )
23692
23693
Here is the call graph for this function:

◆ _render_solver_summary_text()

picurv_cli.core._render_solver_summary_text ( dict  summary)
protected

Render the solver summary as a glanceable numerical-method dashboard.

Parameters
[in]summaryCurated solver configuration mapping.

Definition at line 23694 of file core.py.

23694def _render_solver_summary_text(summary: dict):
23695 """!
23696 @brief Render the solver summary as a glanceable numerical-method dashboard.
23697 @param[in] summary Curated solver configuration mapping.
23698 """
23699 momentum = summary.get("momentum", {})
23700 poisson = summary.get("poisson", {})
23701 operation = summary.get("operation_mode", {})
23702 subtitle = (
23703 f"Field: {operation.get('eulerian_field_source', '-')} | "
23704 f"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
23705 )
23706 _print_config_header("SOLVER SUMMARY", subtitle)
23707 _print_config_group("Operation", _flatten_summary_mapping(operation))
23708 _print_config_group(
23709 "Primary Methods",
23710 [
23711 ("Momentum solver", momentum.get("type")),
23712 ("Central differencing", momentum.get("central_diff")),
23713 ("Poisson method", poisson.get("method")),
23714 ("Interpolation", summary.get("interpolation")),
23715 ("Convergence mode", summary.get("solution_convergence", {}).get("mode")),
23716 ],
23717 )
23718 _print_config_group("Momentum Tolerances", _flatten_summary_mapping(momentum.get("tolerances", {})))
23719 control_heading = (
23720 "Newton--Krylov Controls" if momentum.get("type") == "newton_krylov"
23721 else "Dual-Time Pseudo-Time Controls" if momentum.get("type") == "DUALTIME_PICARD_JAMESON_RK"
23722 else "Momentum Controls"
23723 )
23724 _print_config_group(control_heading, _flatten_summary_mapping(momentum.get("controls", {})))
23725 _print_config_group("Poisson Configuration", _flatten_summary_mapping(poisson))
23726 _print_config_group("Solution Convergence", _flatten_summary_mapping(summary.get("solution_convergence", {})))
23727 _print_config_group("Scalar Transport", _flatten_summary_mapping(summary.get("scalar_transport", {})))
23728 _print_config_group("Verification Sources", _flatten_summary_mapping(summary.get("verification", {})))
23729 passthrough = summary.get("petsc_passthrough", {})
23730 _print_config_group(
23731 "Advanced PETSc Options",
23732 [("Option count", passthrough.get("count")), ("Option names", passthrough.get("options"))],
23733 )
23734
23735
Here is the call graph for this function:

◆ _render_monitor_summary_text()

picurv_cli.core._render_monitor_summary_text ( dict  summary)
protected

Render the monitor summary as a glanceable observability dashboard.

Parameters
[in]summaryCurated monitor configuration mapping.

Definition at line 23736 of file core.py.

23736def _render_monitor_summary_text(summary: dict):
23737 """!
23738 @brief Render the monitor summary as a glanceable observability dashboard.
23739 @param[in] summary Curated monitor configuration mapping.
23740 """
23741 logging_cfg = summary.get("logging", {})
23742 profiling = summary.get("profiling", {})
23743 diagnostics = summary.get("diagnostics", {})
23744 io_cfg = summary.get("io", {})
23745 memory_log = diagnostics.get("runtime_memory_log", {})
23746 subtitle = (
23747 f"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
23748 f"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
23749 )
23750 _print_config_header("MONITOR SUMMARY", subtitle)
23751 _print_config_group(
23752 "Logging",
23753 [
23754 ("Verbosity", logging_cfg.get("verbosity")),
23755 ("Enabled functions", logging_cfg.get("enabled_functions")),
23756 ],
23757 )
23758 _print_config_group("Profiling", _flatten_summary_mapping(profiling))
23759 _print_config_group(
23760 "Output Cadence",
23761 [
23762 ("Field output", io_cfg.get("data_output_frequency")),
23763 ("Particle snapshots", io_cfg.get("particle_console_output_frequency")),
23764 ("Particle row interval", io_cfg.get("particle_log_interval")),
23765 ],
23766 )
23767 _print_config_group("Output Directories", _flatten_summary_mapping(io_cfg.get("directories", {})))
23768 _print_config_group(
23769 "Diagnostics",
23770 [
23771 ("Enabled PETSc diagnostics", diagnostics.get("enabled_petsc")),
23772 ("Runtime memory log", memory_log.get("enabled")),
23773 ],
23774 )
23775 _print_config_group("PETSc Diagnostic Settings", _flatten_summary_mapping(diagnostics.get("petsc", {})))
23776 solver_monitoring = summary.get("solver_monitoring", {})
23777 _print_config_group(
23778 "Solver Monitoring",
23779 [
23780 ("Enabled flags", solver_monitoring.get("enabled_flags")),
23781 ("All flags", solver_monitoring.get("flags")),
23782 ],
23783 )
23784
23785
Here is the call graph for this function:

◆ render_selected_summary()

picurv_cli.core.render_selected_summary ( dict  payload,
str   output_format = "text" 
)

Render selected timestep-independent config views and optional health.

Parameters
[in]payloadCombined selected summary payload.
[in]output_formatOutput format.

Definition at line 23786 of file core.py.

23786def render_selected_summary(payload: dict, output_format: str = "text"):
23787 """!
23788 @brief Render selected timestep-independent config views and optional health.
23789 @param[in] payload Combined selected summary payload.
23790 @param[in] output_format Output format.
23791 """
23792 if output_format == "json":
23793 json_payload = {key: value for key, value in payload.items() if key != "_health_requested"}
23794 print(json.dumps(json_payload, indent=2, sort_keys=True))
23795 return
23796
23797 if payload.get("run_overview") is not None:
23798 _render_run_overview_text(payload["run_overview"])
23799 renderers = {
23800 "case": _render_case_summary_text,
23801 "solver": _render_solver_summary_text,
23802 "monitor": _render_monitor_summary_text,
23803 }
23804 for key in ("case", "solver", "monitor"):
23805 if key in payload.get("configuration", {}):
23806 renderers[key](payload["configuration"][key])
23807 storage = payload.get("storage")
23808 if isinstance(storage, dict):
23809 print("\nSTORAGE")
23810 print("=" * 78)
23811 print(f" State : {storage.get('state', 'LOCAL')}")
23812 if storage.get("archive_id"):
23813 print(f" Archive ID : {storage['archive_id']}")
23814 if storage.get("label"):
23815 print(f" Label : {storage['label']}")
23816 if payload.get("_health_requested"):
23817 health_payload = {
23818 key: value for key, value in payload.items()
23819 if key not in {"run_overview", "configuration", "storage", "_health_requested"}
23820 }
23821 render_run_summary(health_payload, output_format="text")
23822
23823
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _humanize_plot_identifier()

str picurv_cli.core._humanize_plot_identifier ( str  value)
protected

Convert one machine-oriented identifier into a readable plot label.

Parameters
[in]valueDotted path or snake-case identifier.
Returns
Human-readable label.

Definition at line 23936 of file core.py.

23936def _humanize_plot_identifier(value: str) -> str:
23937 """!
23938 @brief Convert one machine-oriented identifier into a readable plot label.
23939 @param[in] value Dotted path or snake-case identifier.
23940 @return Human-readable label.
23941 """
23942 text = str(value).split(".")[-1].replace("_", " ").strip()
23943 replacements = {
23944 "l2": "L²", "linf": "L∞", "msd": "mean-squared displacement",
23945 "pct": "percentage", "p95": "95th percentile", "cfl": "CFL",
23946 "ke": "kinetic energy", "rms": "RMS",
23947 }
23948 words = [replacements.get(word.lower(), word) for word in text.split()]
23949 return " ".join(words[:1]).capitalize() + (" " + " ".join(words[1:]) if len(words) > 1 else "")
23950
23951
Here is the caller graph for this function:

◆ _summary_field_label()

str picurv_cli.core._summary_field_label ( str  field)
protected

Return the report-facing label for one logged scalar field.

Parameters
[in]fieldLogged scalar field name.
Returns
Report-facing label.

Definition at line 23952 of file core.py.

23952def _summary_field_label(field: str) -> str:
23953 """!
23954 @brief Return the report-facing label for one logged scalar field.
23955 @param[in] field Logged scalar field name.
23956 @return Report-facing label.
23957 """
23958 return _SUMMARY_PLOT_FIELD_LABELS.get(field, _humanize_plot_identifier(field))
23959
23960
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _summary_physical_time()

"float | None" picurv_cli.core._summary_physical_time ( dict  context,
dict  record 
)
protected

Resolve a record's physical time from its artifact or copied case configuration.

Parameters
[in]contextSummary context with copied case configuration.
[in]recordCollected plot record.
Returns
Physical time, or None when it cannot be resolved.

Definition at line 23961 of file core.py.

23961def _summary_physical_time(context: dict, record: dict) -> "float | None":
23962 """!
23963 @brief Resolve a record's physical time from its artifact or copied case configuration.
23964 @param[in] context Summary context with copied case configuration.
23965 @param[in] record Collected plot record.
23966 @return Physical time, or None when it cannot be resolved.
23967 """
23968 explicit = record.get("coordinates", {}).get("time")
23969 if explicit is not None:
23970 return float(explicit)
23971 try:
23972 dt = float((context.get("case_cfg") or {}).get("run_control", {}).get("dt_physical"))
23973 except (TypeError, ValueError):
23974 return None
23975 return float(record["step"]) * dt if math.isfinite(dt) and dt > 0.0 else None
23976
23977
Here is the caller graph for this function:

◆ _read_runtime_diagnostics_csv()

picurv_cli.core._read_runtime_diagnostics_csv (   path)
protected

Yields (segment, row) for each data line of one of the solver's CSV records.

Every per-step file the solver writes shares one shape: a header (the first line that is not a comment) written once, data rows appended across the run, # comment lines, and a # Continuation from step N marker at each seam where a continuation resumed. A row with fewer fields than the header is skipped rather than guessed at; one with more keeps the header's columns, since columns are only ever appended. segment counts the continuations seen so far, so a caller can keep restarts as separate series instead of drawing a line across the seam.

Parameters
pathCSV to read.
Returns
Generator of (segment_index, {column: text}) pairs.

Definition at line 23978 of file core.py.

23978def _read_runtime_diagnostics_csv(path):
23979 """!
23980 @brief Yields `(segment, row)` for each data line of one of the solver's CSV records.
23981
23982 Every per-step file the solver writes shares one shape: a header (the first line that is
23983 not a comment) written once, data rows appended across the run, `#` comment lines, and a
23984 `# Continuation from step N` marker at each seam where a continuation resumed. A row with
23985 fewer fields than the header is skipped rather than guessed at; one with more keeps the
23986 header's columns, since columns are only ever appended. `segment` counts the
23987 continuations seen so far, so a caller can keep restarts as separate series instead of
23988 drawing a line across the seam.
23989
23990 @param path CSV to read.
23991 @return Generator of `(segment_index, {column: text})` pairs.
23992 """
23993 segment = 0
23994 header = []
23995 with open(path, "r", encoding="utf-8", errors="replace", newline="") as handle:
23996 for raw_line in handle:
23997 if _is_summary_plot_continuation_marker(raw_line):
23998 segment += 1
23999 continue
24000 stripped = raw_line.strip()
24001 if not stripped or stripped.startswith("#"):
24002 continue
24003 parts = [part.strip() for part in stripped.split(",")]
24004 if not header:
24005 header = parts
24006 continue
24007 if len(parts) < len(header):
24008 continue
24009 yield segment, dict(zip(header, parts))
24010
24011
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _log_family_path()

"str | None" picurv_cli.core._log_family_path ( str  log_dir,
str  base 
)
protected

Return a solver record's CSV, or the text log an older run wrote instead.

Parameters
[in]log_dirRun log directory.
[in]baseFile name without extension, such as Continuity_Metrics.
Returns
Existing path, preferring .csv; None when neither exists.

Definition at line 24012 of file core.py.

24012def _log_family_path(log_dir: str, base: str) -> "str | None":
24013 """!
24014 @brief Return a solver record's CSV, or the text log an older run wrote instead.
24015 @param[in] log_dir Run log directory.
24016 @param[in] base File name without extension, such as `Continuity_Metrics`.
24017 @return Existing path, preferring `.csv`; None when neither exists.
24018 """
24019 for extension in (".csv", ".log"):
24020 path = os.path.join(log_dir, base + extension)
24021 if os.path.isfile(path):
24022 return path
24023 return None
24024
24025
Here is the caller graph for this function:

◆ _block_family_paths()

"list[tuple[int, str]]" picurv_cli.core._block_family_paths ( str  log_dir,
*str  bases 
)
protected

Return (block, path) for each per-block solver record, CSV preferred per block.

Parameters
[in]log_dirRun log directory.
[in]basesFile-name stems before _Block_<n>, newest naming first.
Returns
Sorted (block, path) pairs.

Definition at line 24026 of file core.py.

24026def _block_family_paths(log_dir: str, *bases: str) -> "list[tuple[int, str]]":
24027 """!
24028 @brief Return `(block, path)` for each per-block solver record, CSV preferred per block.
24029 @param[in] log_dir Run log directory.
24030 @param[in] bases File-name stems before `_Block_<n>`, newest naming first.
24031 @return Sorted `(block, path)` pairs.
24032 """
24033 chosen = {}
24034 for base in bases:
24035 for extension in (".csv", ".log"):
24036 for path in glob.glob(os.path.join(log_dir, f"{base}_Block_*{extension}")):
24037 match = re.search(r"_Block_(\d+)\.(csv|log)$", path)
24038 if match and int(match.group(1)) not in chosen:
24039 chosen[int(match.group(1))] = path
24040 return sorted(chosen.items())
24041
24042
Here is the caller graph for this function:

◆ _continuity_rows()

"tuple[str | None, list]" picurv_cli.core._continuity_rows ( str  log_dir)
protected

Return the continuity record's path and its (segment, row) pairs.

Parameters
[in]log_dirRun log directory.
Returns
Path (or None) and rows keyed step, block, max_divergence, max_divergence_location, rhs_sum, flux_in, flux_out, net_flux.

Definition at line 24043 of file core.py.

24043def _continuity_rows(log_dir: str) -> "tuple[str | None, list]":
24044 """!
24045 @brief Return the continuity record's path and its `(segment, row)` pairs.
24046 @param[in] log_dir Run log directory.
24047 @return Path (or None) and rows keyed step, block, max_divergence, max_divergence_location,
24048 rhs_sum, flux_in, flux_out, net_flux.
24049 """
24050 path = _log_family_path(log_dir, "Continuity_Metrics")
24051 rows = []
24052 if path is None:
24053 return None, rows
24054 if path.endswith(".csv"):
24055 for segment, row in _read_runtime_diagnostics_csv(path):
24056 step, block = _parse_int_loose(row.get("step")), _parse_int_loose(row.get("block"))
24057 if step is None or block is None:
24058 continue
24059 rows.append((segment, {
24060 "step": step, "block": block,
24061 "max_divergence": _parse_float_loose(row.get("max_divergence")),
24062 "max_divergence_location": "([{}][{}][{}] = {})".format(
24063 row.get("max_divergence_k"), row.get("max_divergence_j"),
24064 row.get("max_divergence_i"), row.get("max_divergence_index")),
24065 "rhs_sum": _parse_float_loose(row.get("poisson_source_imbalance")),
24066 "flux_in": _parse_float_loose(row.get("flux_in")),
24067 "flux_out": _parse_float_loose(row.get("flux_out")),
24068 "net_flux": _parse_float_loose(row.get("net_flux")),
24069 }))
24070 return path, rows
24071 segment = 0
24072 with open(path, "r", encoding="utf-8", errors="replace") as f:
24073 for raw_line in f:
24074 if _is_summary_plot_continuation_marker(raw_line):
24075 segment += 1
24076 continue
24077 line = raw_line.strip()
24078 if not line or line.startswith("-") or line.startswith("Timestep"):
24079 continue
24080 parts = [part.strip() for part in raw_line.split("|")]
24081 if len(parts) < 8:
24082 continue
24083 step, block = _parse_int_loose(parts[0]), _parse_int_loose(parts[1])
24084 if step is None or block is None:
24085 continue
24086 rows.append((segment, {
24087 "step": step, "block": block,
24088 "max_divergence": _parse_float_loose(parts[2]),
24089 "max_divergence_location": parts[3],
24090 "rhs_sum": _parse_float_loose(parts[4]),
24091 "flux_in": _parse_float_loose(parts[5]),
24092 "flux_out": _parse_float_loose(parts[6]),
24093 "net_flux": _parse_float_loose(parts[7]),
24094 }))
24095 return path, rows
24096
24097
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _particle_step_rows()

"tuple[str | None, list]" picurv_cli.core._particle_step_rows ( str  log_dir,
str  metrics_dir 
)
protected

Return the per-step particle record's path and its (segment, row) pairs.

search_metrics.csv carries the step's particle counts; a run that predates it in that role wrote Particle_Metrics.log, read when the CSV is absent.

Parameters
[in]log_dirRun log directory.
[in]metrics_dirRun analysis metrics directory.
Returns
Path (or None) and rows keyed step, total_particles, lost_particles, lost_particles_cumulative, migrated_particles, occupied_cells, load_imbalance, migration_passes, exited_particles, exited_particles_cumulative.

Definition at line 24098 of file core.py.

24098def _particle_step_rows(log_dir: str, metrics_dir: str) -> "tuple[str | None, list]":
24099 """!
24100 @brief Return the per-step particle record's path and its `(segment, row)` pairs.
24101
24102 `search_metrics.csv` carries the step's particle counts; a run that predates it in that
24103 role wrote `Particle_Metrics.log`, read when the CSV is absent.
24104
24105 @param[in] log_dir Run log directory.
24106 @param[in] metrics_dir Run analysis metrics directory.
24107 @return Path (or None) and rows keyed step, total_particles, lost_particles,
24108 lost_particles_cumulative, migrated_particles, occupied_cells, load_imbalance,
24109 migration_passes, exited_particles, exited_particles_cumulative.
24110 """
24111 rows = []
24112 csv_path = os.path.join(metrics_dir, "search_metrics.csv") if metrics_dir else None
24113 if csv_path and os.path.isfile(csv_path):
24114 for segment, row in _read_runtime_diagnostics_csv(csv_path):
24115 step = _parse_int_loose(row.get("step"))
24116 if step is None:
24117 continue
24118 rows.append((segment, {
24119 "step": step,
24120 "total_particles": _parse_int_loose(row.get("total_particles")),
24121 "lost_particles": _parse_int_loose(row.get("lost")),
24122 "lost_particles_cumulative": _parse_int_loose(row.get("lost_cumulative")),
24123 "migrated_particles": _parse_int_loose(row.get("migrated")),
24124 "occupied_cells": _parse_int_loose(row.get("occupied_cells")),
24125 "load_imbalance": _parse_float_loose(row.get("load_imbalance")),
24126 "migration_passes": _parse_int_loose(row.get("migration_passes")),
24127 "exited_particles": _parse_int_loose(row.get("exited")),
24128 "exited_particles_cumulative": _parse_int_loose(row.get("exited_cumulative")),
24129 }))
24130 return csv_path, rows
24131 path = os.path.join(log_dir, "Particle_Metrics.log")
24132 if not os.path.isfile(path):
24133 return None, rows
24134 segment = 0
24135 with open(path, "r", encoding="utf-8", errors="replace") as f:
24136 for raw_line in f:
24137 if _is_summary_plot_continuation_marker(raw_line):
24138 segment += 1
24139 continue
24140 line = raw_line.strip()
24141 if not line or line.startswith("-") or line.startswith("Stage"):
24142 continue
24143 parts = [part.strip() for part in raw_line.split("|")]
24144 if len(parts) < 8:
24145 continue
24146 step = _parse_int_loose(parts[1])
24147 if step is None:
24148 continue
24149 offset = 1 if len(parts) >= 9 else 0
24150 rows.append((segment, {
24151 "step": step,
24152 "total_particles": _parse_int_loose(parts[2]),
24153 "lost_particles": _parse_int_loose(parts[3]),
24154 "lost_particles_cumulative": _parse_int_loose(parts[4]) if offset else None,
24155 "migrated_particles": _parse_int_loose(parts[4 + offset]),
24156 "occupied_cells": _parse_int_loose(parts[5 + offset]),
24157 "load_imbalance": _parse_float_loose(parts[6 + offset]),
24158 "migration_passes": _parse_int_loose(parts[7 + offset]),
24159 "exited_particles": _parse_int_loose(parts[9]) if len(parts) >= 11 else None,
24160 "exited_particles_cumulative": _parse_int_loose(parts[10]) if len(parts) >= 11 else None,
24161 }))
24162 return path, rows
24163
24164
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _picard_history_rows()

"list[tuple[int, str, list]]" picurv_cli.core._picard_history_rows ( str  log_dir)
protected

Return (block, path, [(segment, row)]) for each Picard momentum history.

Parameters
[in]log_dirRun log directory.
Returns
Rows keyed step, block, pseudo_iterations, dtau, cfl_eff, delta_norm, delta_rel, residual_norm, residual_rel, trial_ratio, smoothed_ratio, status, dtau_after, cfl_eff_after; dtau is solver time and cfl_eff = dtau * lambda_max.

Definition at line 24178 of file core.py.

24178def _picard_history_rows(log_dir: str) -> "list[tuple[int, str, list]]":
24179 """!
24180 @brief Return `(block, path, [(segment, row)])` for each Picard momentum history.
24181 @param[in] log_dir Run log directory.
24182 @return Rows keyed step, block, pseudo_iterations, dtau, cfl_eff, delta_norm, delta_rel,
24183 residual_norm, residual_rel, trial_ratio, smoothed_ratio, status, dtau_after,
24184 cfl_eff_after; dtau is solver time and cfl_eff = dtau * lambda_max.
24185 """
24186 out = []
24187 for block, path in _block_family_paths(log_dir, "Momentum_Solver_DualTime_Picard_Jameson_RK_History",
24188 "Momentum_Solver_Convergence_History"):
24189 rows = []
24190 if path.endswith(".csv"):
24191 for segment, row in _read_runtime_diagnostics_csv(path):
24192 step = _parse_int_loose(row.get("step"))
24193 if step is None:
24194 continue
24195 rows.append((segment, {
24196 "step": step, "block": block,
24197 "pseudo_iterations": _parse_int_loose(row.get("pseudo_iter")),
24198 "dtau": _parse_float_loose(row.get("dtau")),
24199 "cfl_eff": _parse_float_loose(row.get("cfl_eff")),
24200 "delta_norm": _parse_float_loose(row.get("delta_norm")),
24201 "delta_rel": _parse_float_loose(row.get("delta_rel")),
24202 "residual_norm": _parse_float_loose(row.get("residual_norm")),
24203 "residual_rel": _parse_float_loose(row.get("residual_rel")),
24204 "trial_ratio": _parse_float_loose(row.get("trial_ratio")),
24205 "smoothed_ratio": _parse_float_loose(row.get("smoothed_ratio")),
24206 "status": row.get("status") or None,
24207 "dtau_after": _parse_float_loose(row.get("dtau_after")),
24208 "cfl_eff_after": _parse_float_loose(row.get("cfl_eff_after")),
24209 }))
24210 else:
24211 segment = 0
24212 with open(path, "r", encoding="utf-8", errors="replace") as f:
24213 for raw_line in f:
24214 if _is_summary_plot_continuation_marker(raw_line):
24215 segment += 1
24216 continue
24217 match = _LEGACY_PICARD_REGEX.search(raw_line)
24218 if not match:
24219 continue
24220 rows.append((segment, {
24221 "step": int(match.group("step")), "block": block,
24222 "pseudo_iterations": int(match.group("pseudo_iter")),
24223 "dtau": _parse_float_loose(match.group("dtau")),
24224 "cfl_eff": _parse_float_loose(match.group("cfl_eff")),
24225 "delta_norm": float(match.group("delta")),
24226 "delta_rel": float(match.group("delta_rel")),
24227 "residual_norm": float(match.group("resid")),
24228 "residual_rel": float(match.group("resid_rel")),
24229 "trial_ratio": _parse_float_loose(match.group("trial_ratio")),
24230 "smoothed_ratio": _parse_float_loose(match.group("smoothed_ratio")),
24231 "status": match.group("status"),
24232 "dtau_after": _parse_float_loose(match.group("dtau_after")),
24233 "cfl_eff_after": _parse_float_loose(match.group("cfl_eff_after")),
24234 }))
24235 out.append((block, path, rows))
24236 return out
24237
24238
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _newton_history_rows()

"list[tuple[int, str, list]]" picurv_cli.core._newton_history_rows ( str  log_dir)
protected

Return (block, path, [(segment, row)]) for each Newton iteration history.

Parameters
[in]log_dirRun log directory.
Returns
Rows keyed step, block, newton_iterations, residual_norm.

Definition at line 24255 of file core.py.

24255def _newton_history_rows(log_dir: str) -> "list[tuple[int, str, list]]":
24256 """!
24257 @brief Return `(block, path, [(segment, row)])` for each Newton iteration history.
24258 @param[in] log_dir Run log directory.
24259 @return Rows keyed step, block, newton_iterations, residual_norm.
24260 """
24261 out = []
24262 for block, path in _block_family_paths(log_dir, "Momentum_Solver_Newton_Krylov_History"):
24263 rows = []
24264 if path.endswith(".csv"):
24265 for segment, row in _read_runtime_diagnostics_csv(path):
24266 step = _parse_int_loose(row.get("step"))
24267 if step is not None:
24268 rows.append((segment, {"step": step, "block": _parse_int_loose(row.get("block")),
24269 "newton_iterations": _parse_int_loose(row.get("newton")),
24270 "residual_norm": _parse_float_loose(row.get("nonlinear_norm"))}))
24271 else:
24272 segment = 0
24273 with open(path, "r", encoding="utf-8", errors="replace") as f:
24274 for raw_line in f:
24275 if _is_summary_plot_continuation_marker(raw_line):
24276 segment += 1
24277 continue
24278 match = _LEGACY_NEWTON_HISTORY_REGEX.search(raw_line)
24279 if match:
24280 rows.append((segment, {"step": int(match.group("step")), "block": int(match.group("block")),
24281 "newton_iterations": int(match.group("newton")),
24282 "residual_norm": float(match.group("norm"))}))
24283 out.append((block, path, rows))
24284 return out
24285
24286
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _newton_summary_rows()

"list[tuple[int, str, list]]" picurv_cli.core._newton_summary_rows ( str  log_dir)
protected

Return (block, path, [(segment, row)]) for each Newton per-step summary.

Parameters
[in]log_dirRun log directory.
Returns
Rows keyed step, block, solver, reason, reason_code, newton_iterations, residual_evaluations, krylov_iterations, initial_norm, final_norm, state.

Definition at line 24287 of file core.py.

24287def _newton_summary_rows(log_dir: str) -> "list[tuple[int, str, list]]":
24288 """!
24289 @brief Return `(block, path, [(segment, row)])` for each Newton per-step summary.
24290 @param[in] log_dir Run log directory.
24291 @return Rows keyed step, block, solver, reason, reason_code, newton_iterations,
24292 residual_evaluations, krylov_iterations, initial_norm, final_norm, state.
24293 """
24294 out = []
24295 for block, path in _block_family_paths(log_dir, "Momentum_Solver_Newton_Krylov_Summary"):
24296 rows = []
24297 if path.endswith(".csv"):
24298 for segment, row in _read_runtime_diagnostics_csv(path):
24299 step = _parse_int_loose(row.get("step"))
24300 if step is None:
24301 continue
24302 rows.append((segment, {
24303 "step": step, "block": _parse_int_loose(row.get("block")),
24304 "solver": row.get("solver"), "reason": row.get("reason"),
24305 "reason_code": _parse_int_loose(row.get("reason_code")),
24306 "newton_iterations": _parse_int_loose(row.get("newton")),
24307 "residual_evaluations": _parse_int_loose(row.get("evals")),
24308 "krylov_iterations": _parse_int_loose(row.get("krylov")),
24309 "initial_norm": _parse_float_loose(row.get("initial_norm")),
24310 "final_norm": _parse_float_loose(row.get("final_norm")),
24311 "state": row.get("state"),
24312 }))
24313 else:
24314 segment = 0
24315 with open(path, "r", encoding="utf-8", errors="replace") as f:
24316 for raw_line in f:
24317 if _is_summary_plot_continuation_marker(raw_line):
24318 segment += 1
24319 continue
24320 match = _LEGACY_NEWTON_SUMMARY_REGEX.search(raw_line)
24321 if not match:
24322 continue
24323 initial_text = match.group("initial")
24324 rows.append((segment, {
24325 "step": int(match.group("step")), "block": int(match.group("block")),
24326 "solver": match.group("solver").strip(), "reason": match.group("reason"),
24327 "reason_code": int(match.group("reason_code")),
24328 "newton_iterations": int(match.group("newton")),
24329 "residual_evaluations": int(match.group("evals")),
24330 "krylov_iterations": int(match.group("krylov")),
24331 "initial_norm": None if initial_text == "unavailable" else float(initial_text),
24332 "final_norm": float(match.group("final")),
24333 "state": match.group("state"),
24334 }))
24335 out.append((block, path, rows))
24336 return out
24337
24338
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _poisson_history_rows()

"list[tuple[int, str, list]]" picurv_cli.core._poisson_history_rows ( str  log_dir)
protected

Return (block, path, [(segment, row)]) for each Poisson iteration history.

Parameters
[in]log_dirRun log directory.
Returns
Rows keyed step, block, iterations, unpreconditioned_norm, true_norm, relative_norm; true_norm and relative_norm are None unless the run computed the true residual.

Definition at line 24346 of file core.py.

24346def _poisson_history_rows(log_dir: str) -> "list[tuple[int, str, list]]":
24347 """!
24348 @brief Return `(block, path, [(segment, row)])` for each Poisson iteration history.
24349 @param[in] log_dir Run log directory.
24350 @return Rows keyed step, block, iterations, unpreconditioned_norm, true_norm, relative_norm;
24351 true_norm and relative_norm are None unless the run computed the true residual.
24352 """
24353 out = []
24354 for block, path in _block_family_paths(log_dir, "Poisson_Solver_Convergence_History"):
24355 rows = []
24356 if path.endswith(".csv"):
24357 for segment, row in _read_runtime_diagnostics_csv(path):
24358 step = _parse_int_loose(row.get("step"))
24359 if step is not None:
24360 rows.append((segment, {"step": step, "block": _parse_int_loose(row.get("block")),
24361 "iterations": _parse_int_loose(row.get("iter")),
24362 "unpreconditioned_norm": _parse_float_loose(row.get("unpreconditioned_norm")),
24363 "true_norm": _parse_float_loose(row.get("true_norm")),
24364 "relative_norm": _parse_float_loose(row.get("relative_norm"))}))
24365 else:
24366 segment = 0
24367 with open(path, "r", encoding="utf-8", errors="replace") as f:
24368 for raw_line in f:
24369 if _is_summary_plot_continuation_marker(raw_line):
24370 segment += 1
24371 continue
24372 match = _LEGACY_POISSON_REGEX.search(raw_line)
24373 if match:
24374 rows.append((segment, {"step": int(match.group("step")), "block": int(match.group("block")),
24375 "iterations": int(match.group("iter")),
24376 "unpreconditioned_norm": float(match.group("unpre")),
24377 "true_norm": float(match.group("true")),
24378 "relative_norm": _parse_float_loose(match.group("rel"))}))
24379 out.append((block, path, rows))
24380 return out
24381
24382
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _runtime_memory_rows()

list picurv_cli.core._runtime_memory_rows ( str  path)
protected

Return the runtime memory record's (segment, row) pairs (CSV, or an older run's text log).

Parameters
[in]pathRuntime memory log path as configured.
Returns
Rows keyed step, event, process_current_mb_max, process_peak_mb_max, petsc_allocated_mb_max, petsc_peak_allocated_mb_max, process_change_mb_max, reason.

Definition at line 24383 of file core.py.

24383def _runtime_memory_rows(path: str) -> list:
24384 """!
24385 @brief Return the runtime memory record's `(segment, row)` pairs (CSV, or an older run's text log).
24386 @param[in] path Runtime memory log path as configured.
24387 @return Rows keyed step, event, process_current_mb_max, process_peak_mb_max,
24388 petsc_allocated_mb_max, petsc_peak_allocated_mb_max, process_change_mb_max, reason.
24389 """
24390 rows = []
24391 if not path or not os.path.isfile(path):
24392 return rows
24393 keys = ("process_current_mb_max", "process_peak_mb_max", "petsc_allocated_mb_max",
24394 "petsc_peak_allocated_mb_max", "process_change_mb_max")
24395 with open(path, "r", encoding="utf-8", errors="replace") as f:
24396 first = ""
24397 for line in f:
24398 if line.strip() and not line.lstrip().startswith("#"):
24399 first = line
24400 break
24401 if first.startswith("step,"):
24402 for segment, row in _read_runtime_diagnostics_csv(path):
24403 step = _parse_int_loose(row.get("step"))
24404 if step is None:
24405 continue
24406 rows.append((segment, {"step": step, "event": row.get("event"), "reason": row.get("reason"),
24407 "step_wall_s": _parse_float_loose(row.get("step_wall_s")),
24408 **{key: _parse_float_loose(row.get(key)) for key in keys}}))
24409 return rows
24410 segment = 0
24411 with open(path, "r", encoding="utf-8", errors="replace") as f:
24412 for raw_line in f:
24413 if _is_summary_plot_continuation_marker(raw_line):
24414 segment += 1
24415 continue
24416 line = raw_line.strip()
24417 if not line or line.startswith("#") or line.startswith("Step"):
24418 continue
24419 parts = line.split()
24420 step = _parse_int_loose(parts[0]) if parts else None
24421 if len(parts) < 8 or step is None:
24422 continue
24423 rows.append((segment, {"step": step, "event": parts[1], "reason": parts[7],
24424 **{key: _parse_float_loose(parts[2 + i]) for i, key in enumerate(keys)}}))
24425 return rows
24426
24427
24428# Columns that identify a plot record (its step and line) rather than carry a value; text
24429# columns such as a status or an event are dropped by _append_summary_plot_record() itself.
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _typed_csv_value()

picurv_cli.core._typed_csv_value ( str  text)
protected

Return a CSV cell as a float (written with a point or exponent), an int, or the text.

Parameters
[in]textCell text.
Returns
Parsed value.

Definition at line 24433 of file core.py.

24433def _typed_csv_value(text: str):
24434 """!
24435 @brief Return a CSV cell as a float (written with a point or exponent), an int, or the text.
24436 @param[in] text Cell text.
24437 @return Parsed value.
24438 """
24439 float_val = _parse_float_loose(text)
24440 int_val = _parse_int_loose(text)
24441 if float_val is not None and ("." in text or "e" in text.lower()):
24442 return float_val
24443 if int_val is not None:
24444 return int_val
24445 return text
24446
24447
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _solution_convergence_rows()

"tuple[str | None, list]" picurv_cli.core._solution_convergence_rows ( str  log_dir)
protected

Return the solution-convergence record's path and its (segment, row) pairs.

Parameters
[in]log_dirRun log directory.
Returns
Path (or None) and rows with every column, numbers parsed and mode set.

Definition at line 24448 of file core.py.

24448def _solution_convergence_rows(log_dir: str) -> "tuple[str | None, list]":
24449 """!
24450 @brief Return the solution-convergence record's path and its `(segment, row)` pairs.
24451 @param[in] log_dir Run log directory.
24452 @return Path (or None) and rows with every column, numbers parsed and `mode` set.
24453 """
24454 path = _log_family_path(log_dir, "solution_convergence")
24455 rows = []
24456 if path is None:
24457 return None, rows
24458
24459 if path.endswith(".csv"):
24460 for segment, row in _read_runtime_diagnostics_csv(path):
24461 step = _parse_int_loose(row.get("step"))
24462 if step is None:
24463 continue
24464 values = {name: _typed_csv_value(text) for name, text in row.items() if name}
24465 values["step"] = step
24466 rows.append((segment, values))
24467 return path, rows
24468 mode, col_names, segment = None, None, 0
24469 with open(path, "r", encoding="utf-8", errors="replace") as f:
24470 for raw_line in f:
24471 line = raw_line.strip()
24472 if _is_summary_plot_continuation_marker(line):
24473 segment += 1
24474 continue
24475 if not line:
24476 continue
24477 if line.startswith("="):
24478 found = re.search(r"\[mode:\s*([\w_]+)", line)
24479 if found:
24480 mode = found.group(1)
24481 continue
24482 if line.startswith("-"):
24483 continue
24484 if col_names is None:
24485 col_names = [p.strip() for p in raw_line.split("|")]
24486 continue
24487 parts = [p.strip() for p in raw_line.split("|")]
24488 step = _parse_int_loose(parts[0]) if col_names[0] == "step" and len(parts) >= 4 else None
24489 if step is None:
24490 continue
24491 values = {"mode": mode}
24492 values.update({name: _typed_csv_value(text) for name, text in zip(col_names, parts) if name})
24493 values["step"] = step
24494 rows.append((segment, values))
24495 return path, rows
24496
24497
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _append_summary_plot_record()

picurv_cli.core._append_summary_plot_record ( list  records,
str  source,
  step,
str  line,
dict  values,
str  source_path,
int   segment = 0,
dict   coordinates = None 
)
protected

Append one numeric append-ordered record for summarize plotting.

Parameters
[out]recordsDestination record list.
[in]sourceQualified source prefix.
[in]stepLogged timestep.
[in]lineHuman-readable line identity.
[in]valuesCandidate field mapping.
[in]source_pathSource artifact path.
[in]segmentZero-based continuation segment within the source artifact.
[in]coordinatesOptional independent variables carried by the source row.

Definition at line 24498 of file core.py.

24499 source_path: str, segment: int = 0, coordinates: dict = None):
24500 """!
24501 @brief Append one numeric append-ordered record for summarize plotting.
24502 @param[out] records Destination record list.
24503 @param[in] source Qualified source prefix.
24504 @param[in] step Logged timestep.
24505 @param[in] line Human-readable line identity.
24506 @param[in] values Candidate field mapping.
24507 @param[in] source_path Source artifact path.
24508 @param[in] segment Zero-based continuation segment within the source artifact.
24509 @param[in] coordinates Optional independent variables carried by the source row.
24510 """
24511 numeric = {
24512 key: value
24513 for key, value in values.items()
24514 if isinstance(value, (int, float)) and not isinstance(value, bool)
24515 }
24516 if step is not None and numeric:
24517 records.append({
24518 "source": source,
24519 "step": int(step),
24520 "line": line,
24521 "values": numeric,
24522 "source_path": source_path,
24523 "segment": int(segment),
24524 "coordinates": {
24525 key: value for key, value in (coordinates or {}).items()
24526 if isinstance(value, (int, float)) and not isinstance(value, bool)
24527 },
24528 })
24529
24530
Here is the caller graph for this function:

◆ _is_summary_plot_continuation_marker()

bool picurv_cli.core._is_summary_plot_continuation_marker ( str  line)
protected

Return whether a log line starts a new continuation segment.

Parameters
[in]lineCandidate raw or stripped log line.
Returns
True for the shared continuation marker syntax.

Definition at line 24531 of file core.py.

24531def _is_summary_plot_continuation_marker(line: str) -> bool:
24532 """!
24533 @brief Return whether a log line starts a new continuation segment.
24534 @param[in] line Candidate raw or stripped log line.
24535 @return True for the shared continuation marker syntax.
24536 """
24537 return bool(re.match(r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
24538
24539
Here is the caller graph for this function:

◆ _collect_summary_plot_records()

list picurv_cli.core._collect_summary_plot_records ( dict  context)
protected

Collect append-ordered numeric records from summarize-supported scalar logs.

Parameters
[in]contextSummary context returned by _build_summary_context().
Returns
Append-ordered plot record list.

Definition at line 24540 of file core.py.

24540def _collect_summary_plot_records(context: dict) -> list:
24541 """!
24542 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
24543 @param[in] context Summary context returned by `_build_summary_context()`.
24544 @return Append-ordered plot record list.
24545 """
24546 records = []
24547 log_dir = context["log_dir"]
24548 # The C runtime writes its per-step diagnostics CSVs to the analysis metrics home,
24549 # not beside the text logs; PicurvOpenDiagnosticsCsv() owns that placement.
24550 metrics_dir = context["metrics_dir"]
24551
24552 continuity_path, continuity_rows = _continuity_rows(log_dir)
24553 for segment, row in continuity_rows:
24554 _append_summary_plot_record(
24555 records, "continuity", row["step"], f"block {row['block']}",
24556 {key: row[key] for key in ("max_divergence", "rhs_sum", "flux_in", "flux_out", "net_flux")},
24557 continuity_path, segment,
24558 )
24559
24560 particle_path, particle_rows = _particle_step_rows(log_dir, metrics_dir)
24561 for segment, row in particle_rows:
24562 _append_summary_plot_record(
24563 records, "particles", row["step"], "particles",
24564 {key: value for key, value in row.items() if key != "step"},
24565 particle_path, segment,
24566 )
24567
24568 # dtau is solver time; cfl_eff = dtau * lambda_max, the Courant number pseudo_cfl.* controls.
24569 for block, path, rows in _picard_history_rows(log_dir):
24570 for segment, row in rows:
24571 _append_summary_plot_record(
24572 records, "momentum", row["step"], f"block {block}",
24573 {key: value for key, value in row.items() if key not in _PLOT_ROW_IDENTITY_KEYS},
24574 path, segment,
24575 coordinates={"solver_iteration": row["pseudo_iterations"]},
24576 )
24577
24578 for block, path, rows in _newton_history_rows(log_dir):
24579 for segment, row in rows:
24580 _append_summary_plot_record(
24581 records, "momentum", row["step"], f"block {row['block']}",
24582 {"newton_iterations": row["newton_iterations"], "residual_norm": row["residual_norm"]},
24583 path, segment,
24584 coordinates={"solver_iteration": row["newton_iterations"]},
24585 )
24586
24587 for block, path, rows in _poisson_history_rows(log_dir):
24588 for segment, row in rows:
24589 _append_summary_plot_record(
24590 records, "poisson", row["step"], f"block {row['block']}",
24591 {key: row[key] for key in ("iterations", "unpreconditioned_norm", "true_norm", "relative_norm")},
24592 path, segment,
24593 coordinates={"solver_iteration": row["iterations"]},
24594 )
24595
24596 # The LES coefficient history. cs_effective is the curve an LES run is judged on:
24597 # on the HOM02 benchmark at 64^3 it settles at 0.18-0.19 (page 72, section 10).
24598 les_path = os.path.join(metrics_dir, "les_coefficient.csv")
24599 if os.path.isfile(les_path):
24600 for segment, row in _read_runtime_diagnostics_csv(les_path):
24601 _append_summary_plot_record(
24602 records, "les", _parse_int_loose(row.get("step")), "coefficient",
24603 {
24604 "cs_effective": _parse_float_loose(row.get("cs_effective")),
24605 "cs_mean": _parse_float_loose(row.get("cs_mean")),
24606 "coefficient_rms": _parse_float_loose(row.get("coefficient_rms")),
24607 "coefficient_min": _parse_float_loose(row.get("coefficient_min")),
24608 "coefficient_max": _parse_float_loose(row.get("coefficient_max")),
24609 "nu_t_mean": _parse_float_loose(row.get("nu_t_mean")),
24610 "nu_t_max": _parse_float_loose(row.get("nu_t_max")),
24611 "nu_t_over_nu_mean": _parse_float_loose(row.get("nu_t_over_nu_mean")),
24612 "k_sgs_mean": _parse_float_loose(row.get("k_sgs_mean")),
24613 "backscatter_fraction": _parse_float_loose(row.get("backscatter_fraction")),
24614 "limited_fraction": _parse_float_loose(row.get("limited_fraction")),
24615 },
24616 les_path, segment,
24617 )
24618
24619 # The wall-model history. y_plus_mean is what says whether the first cell sits where
24620 # the selected law is valid; u_tau is what a channel run is scored against.
24621 wall_path = os.path.join(metrics_dir, "wall_model.csv")
24622 if os.path.isfile(wall_path):
24623 for segment, row in _read_runtime_diagnostics_csv(wall_path):
24624 _append_summary_plot_record(
24625 records, "wall_model", _parse_int_loose(row.get("step")), "near wall",
24626 {
24627 "u_tau_mean": _parse_float_loose(row.get("u_tau_mean")),
24628 "u_tau_rms": _parse_float_loose(row.get("u_tau_rms")),
24629 "u_tau_min": _parse_float_loose(row.get("u_tau_min")),
24630 "u_tau_max": _parse_float_loose(row.get("u_tau_max")),
24631 "y_plus_mean": _parse_float_loose(row.get("y_plus_mean")),
24632 "y_plus_max": _parse_float_loose(row.get("y_plus_max")),
24633 "wall_distance_mean": _parse_float_loose(row.get("wall_distance_mean")),
24634 "nu_wall_over_nu_mean": _parse_float_loose(row.get("nu_wall_over_nu_mean")),
24635 "wall_cells": _parse_int_loose(row.get("wall_cells")),
24636 },
24637 wall_path, segment,
24638 )
24639
24640 # The driven-flow controller history. driving_acceleration is the force per unit mass
24641 # the flow received; in a stationary driven channel its time average is u_tau^2 / h.
24642 driven_path = os.path.join(metrics_dir, "driven_flow.csv")
24643 if os.path.isfile(driven_path):
24644 for segment, row in _read_runtime_diagnostics_csv(driven_path):
24645 _append_summary_plot_record(
24646 records, "driven_flow", _parse_int_loose(row.get("step")), "controller",
24647 {
24648 "target_flux": _parse_float_loose(row.get("target_flux")),
24649 "measured_flux": _parse_float_loose(row.get("measured_flux")),
24650 "bulk_velocity": _parse_float_loose(row.get("bulk_velocity")),
24651 "bulk_velocity_correction": _parse_float_loose(row.get("bulk_velocity_correction")),
24652 "driving_acceleration": _parse_float_loose(row.get("driving_acceleration")),
24653 },
24654 driven_path, segment,
24655 )
24656
24657 profiling_path = os.path.join(log_dir, PROFILING_TIMESTEP_FILE)
24658 if os.path.isfile(profiling_path):
24659 segment = 0
24660 columns = None
24661 with open(profiling_path, "r", encoding="utf-8", errors="replace", newline="") as f:
24662 for raw_line in f:
24663 if _is_summary_plot_continuation_marker(raw_line):
24664 segment += 1
24665 continue
24666 values = next(csv.reader([raw_line]))
24667 if not values:
24668 continue
24669 if columns is None:
24670 columns = values
24671 continue
24672 row = dict(zip(columns, values))
24673 _append_summary_plot_record(
24674 records, "profiling", _parse_int_loose(row.get("step")), row.get("function") or "unknown",
24675 {"calls": _parse_int_loose(row.get("calls")), "step_time_s": _parse_float_loose(row.get("step_time_s"))},
24676 profiling_path, segment,
24677 )
24678
24679 memory_path = _log_family_path(log_dir, "Runtime_Memory") or os.path.join(log_dir, RUNTIME_MEMORY_FILE)
24680 for segment, row in _runtime_memory_rows(memory_path):
24681 if row["event"] in {"Step", "Post"}:
24682 _append_summary_plot_record(
24683 records, "memory", row["step"], "memory",
24684 {key: value for key, value in row.items() if key not in _PLOT_ROW_IDENTITY_KEYS},
24685 memory_path, segment,
24686 )
24687
24688 convergence_path, convergence_rows = _solution_convergence_rows(log_dir)
24689 for segment, row in convergence_rows:
24690 _append_summary_plot_record(
24691 records, "convergence", row["step"], "convergence",
24692 {name: value for name, value in row.items()
24693 if name not in {"step", "time", "mode", "ref", "ph", "per", "win"}},
24694 convergence_path, segment,
24695 coordinates={"time": row.get("time") if isinstance(row.get("time"), (int, float)) else None},
24696 )
24697
24698 records.extend(_collect_spectra_plot_records(context))
24699 records.extend(_collect_profile_plot_records(context))
24700 return records
24701
24702
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _collect_spectra_plot_records()

list picurv_cli.core._collect_spectra_plot_records ( dict  context)
protected

Collect the per-step scalar histories written by the spectra post stage.

Each spectra task keeps its own history file, so one task becomes one plotted line and several tasks compare directly on the same axes.

Parameters
[in]contextSummary context returned by _build_summary_context().
Returns
Append-ordered plot record list; empty when no spectra were measured.

Definition at line 24703 of file core.py.

24703def _collect_spectra_plot_records(context: dict) -> list:
24704 """!
24705 @brief Collect the per-step scalar histories written by the spectra post stage.
24706
24707 @details Each spectra task keeps its own history file, so one task becomes one
24708 plotted line and several tasks compare directly on the same axes.
24709
24710 @param[in] context Summary context returned by `_build_summary_context()`.
24711 @return Append-ordered plot record list; empty when no spectra were measured.
24712 """
24713 records = []
24714 run_dir = context.get("run_dir")
24715 if not run_dir:
24716 return records
24717 spectra_root = os.path.join(
24718 run_dir, resolve_post_spectra_output_dir(context.get("monitor_cfg") or {})
24719 )
24720 for root, _dirs, files in os.walk(spectra_root):
24721 for filename in sorted(files):
24722 if not filename.endswith("_history.csv"):
24723 continue
24724 history_path = os.path.join(root, filename)
24725 line = filename[: -len("_history.csv")]
24726 try:
24727 with open(history_path, "r", encoding="utf-8", errors="replace") as stream:
24728 for row in csv.DictReader(stream):
24729 step = _parse_int_loose(row.get("step"))
24730 if step is None:
24731 continue
24732 values = {}
24733 for column in POST_SPECTRA_SCALAR_COLUMNS:
24734 parsed = _parse_float_loose(row.get(column))
24735 if parsed is not None:
24736 values[column] = parsed
24737 if values:
24738 _append_summary_plot_record(
24739 records, "spectra", step, line, values, history_path, 0,
24740 coordinates={"time": _parse_float_loose(row.get("time"))},
24741 )
24742 except OSError:
24743 # A history a concurrent post stage is still writing is simply not
24744 # listed yet; summarize is read-only and must never fail on it.
24745 continue
24746 return records
24747
24748
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _collect_profile_plot_records()

list picurv_cli.core._collect_profile_plot_records ( dict  context)
protected

Collect the profile histories the profile_series reducer wrote.

Each <prefix>_profile_<name>_history.csv holds one row per step and one column per <output column>.<functional>, which becomes the series profile.<column>.<functional>, one line per history file. The functionals are the reducer's (generators/reduce.gen profile-series); this only reads.

Parameters
[in]contextSummary context returned by _build_summary_context().
Returns
Append-ordered plot record list; empty when no history was written.

Definition at line 24749 of file core.py.

24749def _collect_profile_plot_records(context: dict) -> list:
24750 """!
24751 @brief Collect the profile histories the `profile_series` reducer wrote.
24752
24753 @details Each `<prefix>_profile_<name>_history.csv` holds one row per step and one
24754 column per `<output column>.<functional>`, which becomes the series
24755 `profile.<column>.<functional>`, one line per history file. The functionals
24756 are the reducer's (`generators/reduce.gen profile-series`); this only reads.
24757
24758 @param[in] context Summary context returned by `_build_summary_context()`.
24759 @return Append-ordered plot record list; empty when no history was written.
24760 """
24761 records = []
24762 run_dir = context.get("run_dir")
24763 if not run_dir:
24764 return records
24765 root_dir = os.path.join(run_dir, CANONICAL_RUN_PATHS["statistics"])
24766 for root, _dirs, files in os.walk(root_dir):
24767 for filename in sorted(files):
24768 if not re.match(r"^.+_profile_.+_history\.csv$", filename):
24769 continue
24770 path = os.path.join(root, filename)
24771 line = os.path.relpath(path, root_dir)[: -len("_history.csv")]
24772 try:
24773 with open(path, "r", encoding="utf-8", errors="replace") as stream:
24774 for row in csv.DictReader(stream):
24775 step = _parse_int_loose(row.pop("step", None))
24776 time = _parse_float_loose(row.pop("time", None))
24777 values = {key: _parse_float_loose(value) for key, value in row.items()}
24778 values = {key: value for key, value in values.items() if value is not None}
24779 if step is not None and values:
24780 _append_summary_plot_record(records, "profile", step, line, values, path, 0,
24781 coordinates={"time": time})
24782 except OSError:
24783 continue
24784 return records
24785
24786
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _build_summary_plot_catalog()

list picurv_cli.core._build_summary_plot_catalog ( list  records)
protected

Build available qualified-series metadata from plot records.

Parameters
[in]recordsAppend-ordered plot record list.
Returns
Available series catalog.

Definition at line 24787 of file core.py.

24787def _build_summary_plot_catalog(records: list) -> list:
24788 """!
24789 @brief Build available qualified-series metadata from plot records.
24790 @param[in] records Append-ordered plot record list.
24791 @return Available series catalog.
24792 """
24793 catalog = {}
24794 for record in records:
24795 for field in record["values"]:
24796 name = f"{record['source']}.{field}"
24797 item = catalog.setdefault(name, {"series": name, "lines": {}, "source_paths": set(), "sample_count": 0})
24798 item["lines"][record["line"]] = item["lines"].get(record["line"], 0) + 1
24799 item["source_paths"].add(record["source_path"])
24800 item["sample_count"] += 1
24801 return [
24802 {
24803 **item,
24804 "lines": [{"label": label, "sample_count": count} for label, count in sorted(item["lines"].items())],
24805 "source_paths": sorted(item["source_paths"]),
24806 }
24807 for _, item in sorted(catalog.items())
24808 ]
24809
24810
Here is the caller graph for this function:

◆ _representative_indices()

list picurv_cli.core._representative_indices ( int  count,
int   maximum = 6 
)
protected

Select evenly distributed indices while always retaining both endpoints.

Parameters
[in]countNumber of available ordered states.
[in]maximumMaximum states to retain.
Returns
Sorted unique zero-based indices.

Definition at line 24811 of file core.py.

24811def _representative_indices(count: int, maximum: int = 6) -> list:
24812 """!
24813 @brief Select evenly distributed indices while always retaining both endpoints.
24814 @param[in] count Number of available ordered states.
24815 @param[in] maximum Maximum states to retain.
24816 @return Sorted unique zero-based indices.
24817 """
24818 if count <= maximum:
24819 return list(range(count))
24820 return sorted({round(index * (count - 1) / (maximum - 1)) for index in range(maximum)})
24821
24822
Here is the caller graph for this function:

◆ _build_spectrum_plot_request()

dict picurv_cli.core._build_spectrum_plot_request ( dict  context,
str  task,
bool  reference,
bool  linear_y,
"str | None"  output_path 
)
protected

Build a plot.gen request drawing representative measured spectra.

A decaying flow has a different spectrum at every step, so states are never averaged. Up to six evenly spaced states are retained, including the first and last, to show evolution without an unreadable legend.

Parameters
[in]contextSummary context returned by _build_summary_context().
[in]taskTask basename, or a unique substring of one.
[in]referenceOverlay the staged initial-condition spectrum when available.
[in]linear_yForce linear axes instead of the log-log default.
[in]output_pathOptional explicit output path.
Returns
Versioned normalized plot request.
Exceptions
ValueErrorwhen no matching spectrum file exists.

Definition at line 24823 of file core.py.

24824 linear_y: bool, output_path: "str | None") -> dict:
24825 """!
24826 @brief Build a plot.gen request drawing representative measured spectra.
24827
24828 @details A decaying flow has a different spectrum at every step, so states are
24829 never averaged. Up to six evenly spaced states are retained, including
24830 the first and last, to show evolution without an unreadable legend.
24831
24832 @param[in] context Summary context returned by `_build_summary_context()`.
24833 @param[in] task Task basename, or a unique substring of one.
24834 @param[in] reference Overlay the staged initial-condition spectrum when available.
24835 @param[in] linear_y Force linear axes instead of the log-log default.
24836 @param[in] output_path Optional explicit output path.
24837 @return Versioned normalized plot request.
24838 @throws ValueError when no matching spectrum file exists.
24839 """
24840 spectra_dir = os.path.join(
24841 context["run_dir"],
24842 resolve_post_spectra_output_dir(context.get("monitor_cfg") or {}),
24843 )
24844 candidates = []
24845 if os.path.isdir(spectra_dir):
24846 # The staged initial-condition spectrum shares this directory but is the
24847 # reference overlay, never a task a user can select. Post recipes write into
24848 # their own subdirectory (resolve_recipe_spectra_output_dir), so one level is
24849 # searched too; a candidate is named by its path under the spectra directory.
24850 reference_name = os.path.basename(INITIAL_CONDITION_SPECTRUM_RELPATH)
24851 for root, _dirs, files in os.walk(spectra_dir):
24852 if os.path.relpath(root, spectra_dir).count(os.sep) > 0:
24853 continue
24854 for name in files:
24855 if (name.endswith(".csv") and not name.endswith("_history.csv")
24856 and name != reference_name):
24857 candidates.append(os.path.relpath(os.path.join(root, name), spectra_dir))
24858 candidates.sort()
24859 if not candidates:
24860 raise ValueError(
24861 "No spectra were found for this run. Measure them with a post.yml that defines "
24862 "spectra tasks: 'picurv run --post-process --only spectra --run-dir <run> --post <post.yml>'."
24863 )
24864 matches = [name for name in candidates if task in name] if task else candidates
24865 if len(matches) != 1:
24866 available = ", ".join(name[: -len(".csv")] for name in candidates)
24867 raise ValueError(
24868 f"Spectrum selector {task!r} matched {len(matches)} files. Available: {available}."
24869 )
24870 spectrum_path = os.path.join(spectra_dir, matches[0])
24871 if "plane_spectrum" in matches[0]:
24872 raise ValueError("Plane spectra have two wavenumber axes; use the spectrum CSV for a 2D plot.")
24873
24874 by_step = {}
24875 times = {}
24876 with open(spectrum_path, "r", encoding="utf-8", errors="replace") as stream:
24877 for row in csv.DictReader(stream):
24878 step = _parse_int_loose(row.get("step"))
24879 wavenumber = _parse_float_loose(row.get("k"))
24880 energy = _parse_float_loose(row.get("energy"))
24881 if step is None or wavenumber is None or energy is None:
24882 continue
24883 # A log-log plot cannot carry the zero wavenumber or an empty shell.
24884 if wavenumber <= 0.0 or (not linear_y and energy <= 0.0):
24885 continue
24886 by_step.setdefault(step, []).append([wavenumber, energy])
24887 times.setdefault(step, _parse_float_loose(row.get("time")))
24888 if not by_step:
24889 raise ValueError(f"{os.path.relpath(spectrum_path)} carries no plottable spectrum rows.")
24890
24891 lines = []
24892 if reference:
24893 reference_path = os.path.join(context["run_dir"], INITIAL_CONDITION_SPECTRUM_RELPATH)
24894 if os.path.isfile(reference_path):
24895 points = []
24896 with open(reference_path, "r", encoding="utf-8", errors="replace") as stream:
24897 for row in csv.DictReader(stream):
24898 wavenumber = _parse_float_loose(row.get("k"))
24899 energy = _parse_float_loose(row.get("energy"))
24900 if wavenumber is None or energy is None:
24901 continue
24902 if wavenumber <= 0.0 or (not linear_y and energy <= 0.0):
24903 continue
24904 points.append([wavenumber, energy])
24905 if points:
24906 lines.append({
24907 "label": "Initial condition", "points": sorted(points),
24908 "role": "reference", "color": "#333333", "line_style": "--",
24909 })
24910
24911 ordered_steps = sorted(by_step)
24912 selected_steps = [ordered_steps[index] for index in _representative_indices(len(ordered_steps))]
24913 spectrum_colors = ("#440154", "#414487", "#2A788E", "#22A884", "#7AD151", "#DCE319")
24914 for index, step in enumerate(selected_steps):
24915 time = times.get(step)
24916 label = f"Step {step}" if time is None else f"t = {time:g} (step {step})"
24917 lines.append({
24918 "label": label,
24919 "points": sorted(by_step[step]),
24920 "role": "latest" if step == ordered_steps[-1] else "snapshot",
24921 "color": spectrum_colors[round(index * (len(spectrum_colors) - 1) / max(1, len(selected_steps) - 1))],
24922 "line_width": 2.4 if step == ordered_steps[-1] else 1.65,
24923 })
24924
24925 name = os.path.basename(matches[0])[: -len(".csv")]
24926 fallback = os.path.join(
24927 context["run_dir"], CANONICAL_RUN_PATHS["plots"], f"{name}.png"
24928 )
24929 task_match = re.search(r"_(?P<field>[^_]+)_block(?P<block>\d+)_(?P<symbol>[^_]+)$", name)
24930 subtitle = None
24931 if task_match:
24932 subtitle = (
24933 f"Field {task_match.group('field')} · block {int(task_match.group('block'))} "
24934 f"· {task_match.group('symbol')} wavenumber"
24935 )
24936 return {
24937 "schema_version": 1,
24938 "plot_type": "spectrum",
24939 "series": name,
24940 "title": "Turbulent kinetic-energy spectrum",
24941 "subtitle": subtitle,
24942 "x_label": "Wavenumber, k",
24943 "y_label": "Energy spectrum, E(k)",
24944 "x_scale": "linear" if linear_y else "log",
24945 "y_scale": "linear" if linear_y else "log",
24946 "legend_title": "Snapshot",
24947 "show_markers": False,
24948 "window": {
24949 "mode": "representative", "last": None,
24950 "available_steps": len(ordered_steps), "selected_steps": selected_steps,
24951 },
24952 "lines": lines,
24953 "output_path": os.path.abspath(output_path) if output_path else fallback,
24954 }
24955
24956
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _build_summary_plot_request()

dict picurv_cli.core._build_summary_plot_request ( dict  context,
list  records,
str  series,
"int | None"  last_n,
bool  linear_y,
"str | None"  output_path 
)
protected

Build one normalized plot.gen request from collected summarize records.

Parameters
[in]contextSummary context returned by _build_summary_context().
[in]recordsAppend-ordered plot record list.
[in]seriesQualified series name.
[in]last_nOptional last-N records per plotted line.
[in]linear_yWhether to force linear scaling.
[in]output_pathOptional explicit output path.
Returns
Versioned normalized plot request.

Definition at line 24957 of file core.py.

24957def _build_summary_plot_request(context: dict, records: list, series: str, last_n: "int | None", linear_y: bool, output_path: "str | None") -> dict:
24958 """!
24959 @brief Build one normalized plot.gen request from collected summarize records.
24960 @param[in] context Summary context returned by `_build_summary_context()`.
24961 @param[in] records Append-ordered plot record list.
24962 @param[in] series Qualified series name.
24963 @param[in] last_n Optional last-N records per plotted line.
24964 @param[in] linear_y Whether to force linear scaling.
24965 @param[in] output_path Optional explicit output path.
24966 @return Versioned normalized plot request.
24967 """
24968 source, separator, field = series.partition(".")
24969 if not separator:
24970 raise ValueError("plot series must be qualified as '<source>.<field>'")
24971 matching = [record for record in records if record["source"] == source and field in record["values"]]
24972 if not matching:
24973 raise ValueError(f"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
24974 latest_segments = {}
24975 for record in matching:
24976 source_path = record["source_path"]
24977 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get("segment", 0))
24978 matching = [
24979 record for record in matching
24980 if record.get("segment", 0) == latest_segments[record["source_path"]]
24981 ]
24982 is_iteration_history = (
24983 source in {"momentum", "poisson"} and field in _SUMMARY_ITERATION_HISTORY_FIELDS
24984 and any("solver_iteration" in record.get("coordinates", {}) for record in matching)
24985 )
24986 grouped = {}
24987 selected_steps = set()
24988 if is_iteration_history:
24989 histories = {}
24990 for record in matching:
24991 iteration = record.get("coordinates", {}).get("solver_iteration")
24992 if iteration is None:
24993 continue
24994 histories.setdefault((record["source_path"], record["line"]), []).append(record)
24995 for (_source_path, label), history in histories.items():
24996 latest_step = max(record["step"] for record in history)
24997 selected_steps.add(latest_step)
24998 points = [
24999 [record["coordinates"]["solver_iteration"], record["values"][field]]
25000 for record in history if record["step"] == latest_step
25001 ]
25002 if last_n is not None:
25003 points = points[-last_n:]
25004 grouped.setdefault(f"{label} · step {latest_step}", []).extend(points)
25005 x_label = "Nonlinear iteration" if source == "momentum" else "Linear iteration"
25006 x_kind = "integer"
25007 else:
25008 uses_physical_time = all(_summary_physical_time(context, record) is not None for record in matching)
25009 for record in matching:
25010 x_value = _summary_physical_time(context, record) if uses_physical_time else record["step"]
25011 grouped.setdefault(record["line"], []).append([x_value, record["values"][field]])
25012 # Iteration-count fields occur on every nonlinear-history row. Reduce them
25013 # to the final effort per physical step instead of drawing a y=x staircase.
25014 if field in {"pseudo_iterations", "newton_iterations", "iterations"}:
25015 grouped = {
25016 label: [list(item) for item in {
25017 point[0]: max(candidate[1] for candidate in points if candidate[0] == point[0])
25018 for point in points
25019 }.items()]
25020 for label, points in grouped.items()
25021 }
25022 for label, points in grouped.items():
25023 points.sort(key=lambda point: point[0])
25024 if last_n is not None:
25025 grouped[label] = points[-last_n:]
25026 x_label = "Physical time" if uses_physical_time else "Physical timestep"
25027 x_kind = "continuous" if uses_physical_time else "integer"
25028
25029 grouped = {label: points for label, points in grouped.items() if points}
25030 if not grouped:
25031 raise ValueError(f"Plot series '{series}' has no plottable points in the selected window.")
25032 if is_iteration_history and len(selected_steps) == 1:
25033 grouped = {
25034 label.rsplit(" · step ", 1)[0]: points
25035 for label, points in grouped.items()
25036 }
25037 all_values = [point[1] for points in grouped.values() for point in points]
25038 use_log = not linear_y and field in _SUMMARY_PLOT_LOG_SCALE_FIELDS and all(value > 0 for value in all_values)
25039 window_token = f"last-{last_n}" if last_n is not None else "full"
25040 safe_series = re.sub(r"[^A-Za-z0-9_.-]+", "_", series)
25041 fallback = os.path.join(
25042 context["run_dir"], CANONICAL_RUN_PATHS["plots"],
25043 f"{safe_series}_{window_token}.png",
25044 )
25045 field_label = _summary_field_label(field)
25046 source_title = _SUMMARY_PLOT_SOURCE_TITLES.get(source, _humanize_plot_identifier(source))
25047 title = f"{source_title}: {field_label}"
25048 if is_iteration_history:
25049 title += " convergence"
25050 subtitle_bits = []
25051 if selected_steps:
25052 ordered = sorted(selected_steps)
25053 subtitle_bits.append(
25054 f"Latest physical step {ordered[0]}" if len(ordered) == 1
25055 else f"Latest available physical steps {ordered[0]}–{ordered[-1]}"
25056 )
25057 if last_n is not None:
25058 subtitle_bits.append(f"Last {last_n} samples per curve")
25059 legend_title = {
25060 "continuity": "Block",
25061 "momentum": "Block",
25062 "poisson": "Block",
25063 "profiling": "Function",
25064 "spectra": "Spectrum task",
25065 }.get(source, "Series")
25066 only_label = next(iter(grouped)) if len(grouped) == 1 else None
25067 show_legend = len(grouped) > 1 or bool(
25068 only_label and (only_label.lower().startswith("block ") or source == "profiling")
25069 )
25070 return {
25071 "schema_version": 1,
25072 "plot_type": "iteration_history" if is_iteration_history else "time_history",
25073 "series": series,
25074 "title": title,
25075 "subtitle": " · ".join(subtitle_bits) or None,
25076 "x_label": x_label,
25077 "x_kind": x_kind,
25078 "y_label": field_label,
25079 "y_scale": "log" if use_log else "linear",
25080 "include_zero_y": not use_log and all(value >= 0.0 for value in all_values),
25081 "show_markers": not is_iteration_history,
25082 "show_legend": show_legend,
25083 "legend_title": legend_title,
25084 "window": {"mode": "last" if last_n is not None else "full", "last": last_n},
25085 "lines": [{"label": label, "points": points} for label, points in sorted(grouped.items())],
25086 "output_path": os.path.abspath(output_path) if output_path else None,
25087 "fallback_output_path": fallback,
25088 }
25089
25090
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _render_summary_plot_catalog()

picurv_cli.core._render_summary_plot_catalog ( list  catalog,
str  output_format 
)
protected

Render available summarize plot-series metadata.

Parameters
[in]catalogAvailable series catalog.
[in]output_formatText or JSON output format.

Definition at line 25091 of file core.py.

25091def _render_summary_plot_catalog(catalog: list, output_format: str):
25092 """!
25093 @brief Render available summarize plot-series metadata.
25094 @param[in] catalog Available series catalog.
25095 @param[in] output_format Text or JSON output format.
25096 """
25097 if output_format == "json":
25098 print(json.dumps({"available_series": catalog}, indent=2, sort_keys=True))
25099 return
25100 print("\nAVAILABLE PLOT SERIES")
25101 print("=" * 78)
25102 for item in catalog:
25103 labels = ", ".join(line["label"] for line in item["lines"])
25104 print(f" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
25105 print(f" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
25106
25107
25108# The run's CSV records `summarize --table` prints, by name: (directory key, file name).
25109# Per-block solver histories take a `:<block>` suffix (block 0 by default).
Here is the caller graph for this function:

◆ _summary_table_path()

str picurv_cli.core._summary_table_path ( dict  context,
str  name 
)
protected

Resolve a summarize --table name to the CSV it prints.

Parameters
[in]contextSummary context returned by _build_summary_context().
[in]nameTable name from SUMMARY_TABLES, optionally :<block>.
Returns
Path to the CSV (it may not exist).
Exceptions
ValueErrorWhen the name is unknown or the block is not an integer.

Definition at line 25132 of file core.py.

25132def _summary_table_path(context: dict, name: str) -> str:
25133 """!
25134 @brief Resolve a `summarize --table` name to the CSV it prints.
25135 @param[in] context Summary context returned by `_build_summary_context()`.
25136 @param[in] name Table name from SUMMARY_TABLES, optionally `:<block>`.
25137 @return Path to the CSV (it may not exist).
25138 @throws ValueError When the name is unknown or the block is not an integer.
25139 """
25140 base, _, block = name.partition(":")
25141 if base not in SUMMARY_TABLES:
25142 raise ValueError(f"Unknown table '{base}'. Known tables: {', '.join(sorted(SUMMARY_TABLES))}.")
25143 if block and not block.isdigit():
25144 raise ValueError(f"Table block '{block}' must be a non-negative integer.")
25145 directory_key, file_name = SUMMARY_TABLES[base]
25146 if "{block}" in file_name:
25147 file_name = file_name.format(block=int(block or 0))
25148 elif block:
25149 raise ValueError(f"Table '{base}' is not per-block; drop ':{block}'.")
25150 return os.path.join(context[directory_key], file_name)
25151
25152
Here is the caller graph for this function:

◆ _list_summary_tables()

picurv_cli.core._list_summary_tables ( dict  context,
str  output_format 
)
protected

Print the summarize --table names whose CSV this run has, with row counts.

Parameters
[in]contextSummary context returned by _build_summary_context().
[in]output_formatText or JSON output format.

Definition at line 25153 of file core.py.

25153def _list_summary_tables(context: dict, output_format: str):
25154 """!
25155 @brief Print the `summarize --table` names whose CSV this run has, with row counts.
25156 @param[in] context Summary context returned by `_build_summary_context()`.
25157 @param[in] output_format Text or JSON output format.
25158 """
25159 found = []
25160 for name in sorted(SUMMARY_TABLES):
25161 names = [name]
25162 if "{block}" in (SUMMARY_TABLES[name][1] or ""):
25163 pattern = os.path.join(context["log_dir"], SUMMARY_TABLES[name][1].format(block="*"))
25164 blocks = sorted(int(re.search(r"_Block_(\d+)\.csv$", path).group(1)) for path in glob.glob(pattern))
25165 names = [name if b == 0 else f"{name}:{b}" for b in blocks]
25166 for table in names:
25167 path = _summary_table_path(context, table)
25168 if os.path.isfile(path):
25169 rows = list(_read_runtime_diagnostics_csv(path))
25170 columns = len(rows[0][1]) if rows else 0
25171 found.append({"table": table, "rows": len(rows), "columns": columns, "path": path})
25172 if output_format == "json":
25173 print(json.dumps({"available_tables": found}, indent=2, sort_keys=True))
25174 return
25175 if not found:
25176 raise ValueError("No CSV records were found in the run (a run older than the CSV records keeps text logs).")
25177 print("\nAVAILABLE TABLES")
25178 print("=" * 78)
25179 for item in found:
25180 print(f" {item['table']:<26} rows={item['rows']:<7} columns={item['columns']:<3} {os.path.relpath(item['path'])}")
25181
25182
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _render_summary_table()

picurv_cli.core._render_summary_table ( dict  context,
str  name,
  last_n,
  columns,
str  output_format 
)
protected

Print the last rows of one of the run's CSV records as aligned columns (or JSON).

Parameters
[in]contextSummary context returned by _build_summary_context().
[in]nameTable name (summarize --list-tables).
[in]last_nRows to show from the end; None shows the last 20.
[in]columnsComma-separated column names to keep, or None for all.
[in]output_formatText or JSON output format.
Exceptions
ValueErrorWhen the table is unknown, absent, or a requested column is missing.

Definition at line 25183 of file core.py.

25183def _render_summary_table(context: dict, name: str, last_n, columns, output_format: str):
25184 """!
25185 @brief Print the last rows of one of the run's CSV records as aligned columns (or JSON).
25186 @param[in] context Summary context returned by `_build_summary_context()`.
25187 @param[in] name Table name (`summarize --list-tables`).
25188 @param[in] last_n Rows to show from the end; None shows the last 20.
25189 @param[in] columns Comma-separated column names to keep, or None for all.
25190 @param[in] output_format Text or JSON output format.
25191 @throws ValueError When the table is unknown, absent, or a requested column is missing.
25192 """
25193 path = _summary_table_path(context, name)
25194 if not os.path.isfile(path):
25195 raise ValueError(f"Table '{name}' has no file at {os.path.relpath(path)}; --list-tables names the ones this run has.")
25196 rows = list(_read_runtime_diagnostics_csv(path))
25197 if not rows:
25198 raise ValueError(f"Table '{name}' has no data rows yet.")
25199 header = list(rows[0][1].keys())
25200 if columns:
25201 wanted = [column.strip() for column in columns.split(",") if column.strip()]
25202 missing = [column for column in wanted if column not in header]
25203 if missing:
25204 raise ValueError(f"Table '{name}' has no column(s) {', '.join(missing)}; its columns are: {', '.join(header)}.")
25205 keep = ([header[0]] if header[0] not in wanted else []) + wanted
25206 else:
25207 keep = header
25208 shown = rows[-(last_n or 20):]
25209 if output_format == "json":
25210 print(json.dumps({"table": name, "path": path, "columns": keep,
25211 "rows": [{"segment": segment, **{key: row.get(key) for key in keep}} for segment, row in shown]},
25212 indent=2))
25213 return
25214 widths = {key: max(len(key), *(len(row.get(key) or "") for _, row in shown)) for key in keep}
25215 print(f"\n{name}: {os.path.relpath(path)} (last {len(shown)} of {len(rows)} rows)")
25216 print(" ".join(key.rjust(widths[key]) for key in keep))
25217 print(" ".join("-" * widths[key] for key in keep))
25218 previous_segment = shown[0][0]
25219 for segment, row in shown:
25220 if segment != previous_segment:
25221 print(f"# continuation segment {segment}")
25222 previous_segment = segment
25223 print(" ".join((row.get(key) or "").rjust(widths[key]) for key in keep))
25224
25225
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _invoke_plot_gen()

picurv_cli.core._invoke_plot_gen ( dict  request)
protected

Invoke standalone plot.gen with one normalized request over stdin.

Parameters
[in]requestVersioned normalized plot request.

Definition at line 25226 of file core.py.

25226def _invoke_plot_gen(request: dict):
25227 """!
25228 @brief Invoke standalone plot.gen with one normalized request over stdin.
25229 @param[in] request Versioned normalized plot request.
25230 """
25231 plotgen_path = os.path.join(GENERATORS_PATH, "plot.gen")
25232 if not os.path.isfile(plotgen_path):
25233 raise ValueError(f"plot.gen script not found: {plotgen_path}")
25234 result = subprocess.run(
25235 [sys.executable, plotgen_path, "--input", "-"],
25236 input=json.dumps(request),
25237 text=True,
25238 capture_output=True,
25239 check=False,
25240 )
25241 if result.stdout:
25242 print(result.stdout.rstrip())
25243 if result.returncode != 0:
25244 details = (result.stderr or result.stdout or "unknown plotting error").strip()
25245 if result.returncode == 3:
25246 raise PlotDependencyError(details)
25247 raise ValueError(f"plot.gen failed with exit code {result.returncode}: {details}")
25248
25249
Here is the caller graph for this function:

◆ summarize_workflow()

picurv_cli.core.summarize_workflow (   args)

Build and render a read-only health summary for a run step.

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 25250 of file core.py.

25250def summarize_workflow(args):
25251 """!
25252 @brief Build and render a read-only health summary for a run step.
25253 @param[in] args Command-line style argument list supplied to the function.
25254 """
25255 if args.step is not None and args.step < 0:
25256 fail_cli_usage("--step must be a non-negative integer.")
25257 if args.snapshot_rows < 1:
25258 fail_cli_usage("--snapshot-rows must be at least 1.")
25259 plot_series = getattr(args, "plot_series", None)
25260 list_plot_series = bool(getattr(args, "list_plot_series", False))
25261 plot_spectrum = getattr(args, "plot_spectrum", None)
25262 last_n = getattr(args, "last_n", None)
25263 plot_output = getattr(args, "plot_output", None)
25264 linear_y = bool(getattr(args, "linear_y", False))
25265 table = getattr(args, "table", None)
25266 list_tables = bool(getattr(args, "list_tables", False))
25267 table_columns = getattr(args, "table_columns", None)
25268 plot_mode = bool(plot_series or list_plot_series or plot_spectrum is not None or table or list_tables)
25269 existing_selectors = any(
25270 [
25271 getattr(args, "overview", False),
25272 getattr(args, "case", False),
25273 getattr(args, "solver", False),
25274 getattr(args, "monitor", False),
25275 args.step is not None,
25276 getattr(args, "latest", False),
25277 getattr(args, "max_step", False),
25278 ]
25279 )
25280 if plot_mode and existing_selectors:
25281 fail_cli_usage("Plot discovery and --plot cannot be combined with config or selected-step selectors.")
25282 if not plot_series and not table and last_n is not None:
25283 fail_cli_usage("--last requires --plot or --table.")
25284 if table_columns and not table:
25285 fail_cli_usage("--columns requires --table.")
25286 if not plot_series and plot_spectrum is None and (plot_output or linear_y):
25287 fail_cli_usage("--plot-output and --linear-y require --plot or --plot-spectrum.")
25288 if last_n is not None and last_n < 1:
25289 fail_cli_usage("--last must be a positive integer.")
25290 if plot_series and args.output_format == "json":
25291 fail_cli_usage("--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
25292 if table or list_tables:
25293 context = _build_summary_context(args.run_dir)
25294 try:
25295 if list_tables:
25296 _list_summary_tables(context, args.output_format)
25297 else:
25298 _render_summary_table(context, table, last_n, table_columns, args.output_format)
25299 except ValueError as exc:
25300 emit_structured_error(
25301 ERROR_CODE_CFG_INVALID_VALUE,
25302 key="table",
25303 file_path=context["log_dir"],
25304 message=str(exc),
25305 hint="--list-tables names the CSV records this run has.",
25306 )
25307 sys.exit(1)
25308 return
25309 if plot_mode:
25310 context = _build_summary_context(args.run_dir)
25311 try:
25312 records = _collect_summary_plot_records(context)
25313 catalog = _build_summary_plot_catalog(records)
25314 if list_plot_series:
25315 if not catalog:
25316 raise ValueError("No plottable scalar histories were found in the run logs.")
25317 _render_summary_plot_catalog(catalog, args.output_format)
25318 return
25319 if plot_spectrum is not None:
25320 request = _build_spectrum_plot_request(
25321 context, plot_spectrum, True, linear_y, plot_output
25322 )
25323 else:
25324 request = _build_summary_plot_request(
25325 context, records, plot_series, last_n, linear_y, plot_output
25326 )
25327 _invoke_plot_gen(request)
25328 return
25329 except PlotDependencyError as exc:
25330 emit_structured_error(
25331 ERROR_CODE_DEPENDENCY_MISSING,
25332 key="plotting",
25333 file_path=sys.executable,
25334 message=str(exc),
25335 )
25336 sys.exit(1)
25337 except ValueError as exc:
25338 emit_structured_error(
25339 ERROR_CODE_CFG_INVALID_VALUE,
25340 key="plot",
25341 file_path=context["log_dir"],
25342 message=str(exc),
25343 hint=("--list-plot-series names the scalar histories this run recorded."
25344 if plot_spectrum is None else
25345 "Spectra come from the spectra tasks of a post.yml (see the post-processing reference)."),
25346 )
25347 sys.exit(1)
25348
25349 selected_configs = {
25350 name
25351 for name in ("case", "solver", "monitor")
25352 if bool(getattr(args, name, False))
25353 }
25354 if getattr(args, "overview", False):
25355 selected_configs.update({"case", "solver", "monitor"})
25356 explicit_health = args.step is not None or bool(getattr(args, "latest", False)) or bool(getattr(args, "max_step", False))
25357 health_requested = explicit_health or (not selected_configs and not getattr(args, "overview", False))
25358
25359 context = None
25360 combined = {"storage": storage_state_summary(args.run_dir)}
25361 if selected_configs or getattr(args, "overview", False):
25362 context = _build_summary_context(args.run_dir)
25363 if getattr(args, "overview", False):
25364 combined["run_overview"] = _build_run_overview(context)
25365 combined["configuration"] = {}
25366 builders = {
25367 "case": _build_case_overview,
25368 "solver": _build_solver_overview,
25369 "monitor": _build_monitor_overview,
25370 }
25371 for name in ("case", "solver", "monitor"):
25372 if name in selected_configs:
25373 try:
25374 combined["configuration"][name] = builders[name](context)
25375 except (KeyError, TypeError, ValueError, ZeroDivisionError) as exc:
25376 emit_structured_error(
25377 ERROR_CODE_CFG_INVALID_VALUE,
25378 key=name,
25379 file_path=context["config_paths"][name],
25380 message=f"Could not summarize copied {name}.yml: {exc}",
25381 )
25382 sys.exit(1)
25383
25384 if not health_requested:
25385 combined["_health_requested"] = False
25386 render_selected_summary(combined, output_format=args.output_format)
25387 return
25388
25389 requested_step = args.step
25390 if requested_step is None and getattr(args, "latest", False):
25391 requested_step = None
25392 selection_mode = "max_step" if getattr(args, "max_step", False) else "latest"
25393 health_payload = build_run_summary_payload(
25394 args.run_dir,
25395 step=requested_step,
25396 snapshot_rows=args.snapshot_rows,
25397 selection_mode=selection_mode,
25398 )
25399 if set(combined) == {"storage"}:
25400 combined = {**health_payload, **combined, "_health_requested": True}
25401 render_selected_summary(combined, output_format=args.output_format)
25402 return
25403 combined = {**health_payload, **combined, "_health_requested": True}
25404 render_selected_summary(combined, output_format=args.output_format)
25405
25406
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _resolve_submission_target()

dict picurv_cli.core._resolve_submission_target ( str   run_dir = None,
str   study_dir = None 
)
protected

Resolve a run/study submission target from explicit directory flags.

Parameters
[in]run_dirArgument passed to _resolve_submission_target().
[in]study_dirArgument passed to _resolve_submission_target().
Returns
Value returned by _resolve_submission_target().

Definition at line 25407 of file core.py.

25407def _resolve_submission_target(run_dir: str = None, study_dir: str = None) -> dict:
25408 """!
25409 @brief Resolve a run/study submission target from explicit directory flags.
25410 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
25411 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
25412 @return Value returned by `_resolve_submission_target()`.
25413 """
25414 has_run_dir = bool(run_dir)
25415 has_study_dir = bool(study_dir)
25416 if has_run_dir == has_study_dir:
25417 fail_cli_usage("submit requires exactly one of --run-dir or --study-dir.")
25418
25419 target_kind = "run" if has_run_dir else "study"
25420 target_key = "run_dir" if target_kind == "run" else "study_dir"
25421 root_dir = os.path.abspath(run_dir if has_run_dir else study_dir)
25422 if not os.path.isdir(root_dir):
25423 emit_structured_error(
25424 ERROR_CODE_CFG_FILE_NOT_FOUND,
25425 key=target_key,
25426 file_path=root_dir,
25427 message=f"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
25428 )
25429 sys.exit(1)
25430
25431 scheduler_dir = os.path.join(root_dir, "scheduler")
25432 submission_path = os.path.join(scheduler_dir, "submission.json")
25433 continuation = False
25434 if target_kind == "study":
25435 # `sweep --continue` stages its scripts in a set of their own; the set staged
25436 # last is the one to submit.
25437 continue_path = os.path.join(scheduler_dir, "submission_continue.json")
25438 if os.path.isfile(continue_path) and (
25439 not os.path.isfile(submission_path)
25440 or os.path.getmtime(continue_path) >= os.path.getmtime(submission_path)):
25441 submission_path, continuation = continue_path, True
25442 submission_meta = _read_json_if_exists(submission_path)
25443 if not isinstance(submission_meta, dict):
25444 emit_structured_error(
25445 ERROR_CODE_CFG_FILE_NOT_FOUND,
25446 key="scheduler.submission",
25447 file_path=submission_path,
25448 message="Target directory does not contain scheduler submission metadata.",
25449 hint="Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
25450 )
25451 sys.exit(1)
25452
25453 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
25454 if launch_mode == "local" and target_kind != "run":
25455 emit_structured_error(
25456 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25457 key="scheduler.launch_mode",
25458 file_path=submission_path,
25459 message="Local staged submission is supported for run directories only.",
25460 hint="Use --run-dir for local staged execution; study submit remains Slurm-only.",
25461 )
25462 sys.exit(1)
25463 if launch_mode not in {"slurm", "local"}:
25464 emit_structured_error(
25465 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25466 key="scheduler.launch_mode",
25467 file_path=submission_path,
25468 message=f"Target launch_mode={launch_mode or 'unknown'} is not supported.",
25469 hint="Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
25470 )
25471 sys.exit(1)
25472
25473 if target_kind == "run":
25474 script_map = {
25475 "solve": os.path.join(scheduler_dir, "solver.sbatch"),
25476 "post-process": os.path.join(scheduler_dir, "post.sbatch"),
25477 }
25478 display_label = "Run directory"
25479 manifest_path = None
25480 else:
25481 stage_keys = ({"solve": "solver_continue_array", "post-process": "post_continue_array",
25482 "metrics": "metrics_aggregate"} if continuation else
25483 {"solve": "solver_array", "post-process": "post_array",
25484 "metrics": "metrics_aggregate"})
25485 default_scripts = ({"solve": "solver_continue_array.sbatch", "post-process": "post_continue_array.sbatch",
25486 "metrics": "metrics_continue_aggregate.sbatch"} if continuation else
25487 {"solve": "solver_array.sbatch", "post-process": "post_array.sbatch",
25488 "metrics": "metrics_aggregate.sbatch"})
25489 script_map = {
25490 stage: ((submission_meta.get(key) or {}).get("script")
25491 or os.path.join(scheduler_dir, default_scripts[stage]))
25492 for stage, key in stage_keys.items()
25493 }
25494 display_label = "Study continuation" if continuation else "Study directory"
25495 manifest_path = os.path.join(root_dir, "study_manifest.json")
25496
25497 return {
25498 "target_kind": target_kind,
25499 "target_key": target_key,
25500 "root_dir": root_dir,
25501 "scheduler_dir": scheduler_dir,
25502 "submission_path": submission_path,
25503 "submission_meta": submission_meta,
25504 "launch_mode": launch_mode,
25505 "script_map": script_map,
25506 "display_label": display_label,
25507 "manifest_path": manifest_path,
25508 "continuation": continuation,
25509 "stage_keys": stage_keys if target_kind == "study" else None,
25510 }
25511
25512
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _get_submission_stage_metadata()

dict picurv_cli.core._get_submission_stage_metadata ( dict  target_context,
str  stage_name 
)
protected

Return stored metadata for one staged submission target.

Parameters
[in]target_contextArgument passed to _get_submission_stage_metadata().
[in]stage_nameArgument passed to _get_submission_stage_metadata().
Returns
Value returned by _get_submission_stage_metadata().

Definition at line 25513 of file core.py.

25513def _get_submission_stage_metadata(target_context: dict, stage_name: str) -> dict:
25514 """!
25515 @brief Return stored metadata for one staged submission target.
25516 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
25517 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
25518 @return Value returned by `_get_submission_stage_metadata()`.
25519 """
25520 submission_meta = target_context["submission_meta"]
25521 if target_context["target_kind"] == "run":
25522 stages = submission_meta.get("stages", {})
25523 if not isinstance(stages, dict):
25524 return {}
25525 stage_meta = stages.get(stage_name)
25526 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
25527
25528 stage_meta = submission_meta.get(target_context["stage_keys"][stage_name])
25529 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
25530
25531
Here is the caller graph for this function:

◆ _get_recorded_submission_stages()

list picurv_cli.core._get_recorded_submission_stages ( dict  target_context)
protected

Return stage names explicitly recorded in scheduler submission metadata.

Parameters
[in]target_contextArgument passed to _get_recorded_submission_stages().
Returns
Value returned by _get_recorded_submission_stages().

Definition at line 25532 of file core.py.

25532def _get_recorded_submission_stages(target_context: dict) -> list:
25533 """!
25534 @brief Return stage names explicitly recorded in scheduler submission metadata.
25535 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
25536 @return Value returned by `_get_recorded_submission_stages()`.
25537 """
25538 submission_meta = target_context["submission_meta"]
25539 recorded = []
25540 if target_context["target_kind"] == "run":
25541 stages = submission_meta.get("stages", {})
25542 if isinstance(stages, dict):
25543 for stage_name in ["solve", "post-process"]:
25544 if isinstance(stages.get(stage_name), dict):
25545 recorded.append(stage_name)
25546 return recorded
25547
25548 for stage_name in ("solve", "post-process"):
25549 if isinstance(submission_meta.get(target_context["stage_keys"][stage_name]), dict):
25550 recorded.append(stage_name)
25551 return recorded
25552
25553
Here is the caller graph for this function:

◆ _build_submit_missing_stage_hint()

str picurv_cli.core._build_submit_missing_stage_hint ( dict  target_context,
str  requested_stage,
list  selected_stages 
)
protected

Build an actionable hint for requested submit stages missing from metadata.

Parameters
[in]target_contextArgument passed to _build_submit_missing_stage_hint().
[in]requested_stageArgument passed to _build_submit_missing_stage_hint().
[in]selected_stagesArgument passed to _build_submit_missing_stage_hint().
Returns
Value returned by _build_submit_missing_stage_hint().

Definition at line 25554 of file core.py.

25554def _build_submit_missing_stage_hint(target_context: dict, requested_stage: str, selected_stages: list) -> str:
25555 """!
25556 @brief Build an actionable hint for requested submit stages missing from metadata.
25557 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
25558 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
25559 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
25560 @return Value returned by `_build_submit_missing_stage_hint()`.
25561 """
25562 recorded_stages = _get_recorded_submission_stages(target_context)
25563 recorded_set = set(recorded_stages)
25564 selected_set = set(selected_stages)
25565 solve_stage_command = (
25566 "picurv run --solve ... --no-submit"
25567 if target_context["target_kind"] == "run"
25568 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25569 )
25570 post_stage_command = (
25571 "picurv run --post-process --post <post.yml> ... --no-submit"
25572 if target_context["target_kind"] == "run"
25573 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25574 )
25575 solve_post_command = (
25576 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
25577 if target_context["target_kind"] == "run"
25578 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
25579 )
25580
25581 if requested_stage == "all" and not recorded_set:
25582 return (
25583 "--stage all submits every staged stage, but submission metadata records none. "
25584 f"Re-stage with `{solve_stage_command}` or `{post_stage_command}`."
25585 )
25586
25587 if selected_set == {"solve"} and "solve" not in recorded_set:
25588 return (
25589 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
25590 f"Re-stage with `{solve_stage_command}`."
25591 )
25592 if selected_set == {"post-process"} and "post-process" not in recorded_set:
25593 return (
25594 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
25595 f"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
25596 )
25597
25598 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
25599
25600
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _set_submission_stage_metadata()

picurv_cli.core._set_submission_stage_metadata ( dict  target_context,
str  stage_name,
dict  stage_meta 
)
protected

Persist one stage's metadata back into the submission payload.

Parameters
[in]target_contextArgument passed to _set_submission_stage_metadata().
[in]stage_nameArgument passed to _set_submission_stage_metadata().
[in]stage_metaArgument passed to _set_submission_stage_metadata().

Definition at line 25601 of file core.py.

25601def _set_submission_stage_metadata(target_context: dict, stage_name: str, stage_meta: dict):
25602 """!
25603 @brief Persist one stage's metadata back into the submission payload.
25604 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
25605 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
25606 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
25607 """
25608 submission_meta = target_context["submission_meta"]
25609 if target_context["target_kind"] == "run":
25610 stages = submission_meta.get("stages")
25611 if not isinstance(stages, dict):
25612 stages = {}
25613 submission_meta["stages"] = stages
25614 stages[stage_name] = stage_meta
25615 return
25616
25617 submission_meta[target_context["stage_keys"][stage_name]] = stage_meta
25618
25619
Here is the caller graph for this function:

◆ _write_submission_target_metadata()

picurv_cli.core._write_submission_target_metadata ( dict  target_context)
protected

Write updated submission metadata back to disk.

Parameters
[in]target_contextArgument passed to _write_submission_target_metadata().

Definition at line 25620 of file core.py.

25620def _write_submission_target_metadata(target_context: dict):
25621 """!
25622 @brief Write updated submission metadata back to disk.
25623 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
25624 """
25625 write_json_file(target_context["submission_path"], target_context["submission_meta"])
25626
25627 manifest_path = target_context.get("manifest_path")
25628 if manifest_path and os.path.isfile(manifest_path):
25629 manifest_payload = _read_json_if_exists(manifest_path)
25630 if isinstance(manifest_payload, dict):
25631 if target_context.get("continuation"):
25632 manifest_payload.setdefault("continuation", {})["submission"] = target_context["submission_meta"]
25633 else:
25634 manifest_payload["submission"] = target_context["submission_meta"]
25635 write_json_file(manifest_path, manifest_payload)
25636
25637
Here is the call graph for this function:
Here is the caller graph for this function:

◆ staged_control_directories()

tuple picurv_cli.core.staged_control_directories ( str  control_path)

Read run-owned directory values from a staged control file.

Tokenizes with shlex because PETSc's options-file parser treats a double-quoted span as a single token. Splitting on whitespace would read -log_dir "/tmp/VICTIM DIR" as the value "/tmp/VICTIM, which looks relative and contained while PETSc would use the absolute path.

Malformed quoting is reported rather than skipped: a line the parser cannot interpret is exactly the case where preflight must not assume the run is safe.

Parameters
[in]control_pathPath to a generated .control file.
Returns
Tuple of (values, parse_errors).

Definition at line 25638 of file core.py.

25638def staged_control_directories(control_path: str) -> tuple:
25639 """!
25640 @brief Read run-owned directory values from a staged control file.
25641
25642 @details Tokenizes with `shlex` because PETSc's options-file parser treats a
25643 double-quoted span as a single token. Splitting on whitespace would read
25644 `-log_dir "/tmp/VICTIM DIR"` as the value `"/tmp/VICTIM`, which looks
25645 relative and contained while PETSc would use the absolute path.
25646
25647 Malformed quoting is reported rather than skipped: a line the parser
25648 cannot interpret is exactly the case where preflight must not assume the
25649 run is safe.
25650 @param[in] control_path Path to a generated `.control` file.
25651 @return Tuple of (values, parse_errors).
25652 """
25653 flag_to_key = {
25654 "-log_dir": "log",
25655 "-output_dir": "output",
25656 "-restart_dir": "restart",
25657 "-analysis_dir": "analysis",
25658 }
25659 values: dict = {}
25660 parse_errors: list = []
25661 try:
25662 lines = read_text_file_lines(control_path)
25663 except OSError as exc:
25664 return values, [f"could not be read ({exc})"]
25665 for number, line in enumerate(lines, start=1):
25666 if not line.strip():
25667 continue
25668 try:
25669 tokens = shlex.split(line, comments=True)
25670 except ValueError as exc:
25671 parse_errors.append(
25672 f"line {number} has malformed quoting and cannot be interpreted ({exc}); "
25673 f"refusing to assume it is safe"
25674 )
25675 continue
25676 if tokens and tokens[0] in RESERVED_INDIRECTION_FLAGS:
25677 parse_errors.append(
25678 f"line {number} uses '{tokens[0]}', which PETSc expands itself; its contents "
25679 f"cannot be checked here and could set a run directory. Re-stage without it"
25680 )
25681 continue
25682 if len(tokens) >= 2 and tokens[0] in flag_to_key:
25683 values[flag_to_key[tokens[0]]] = tokens[1]
25684 return values, parse_errors
25685
25686
Here is the call graph for this function:
Here is the caller graph for this function:

◆ preflight_config_directories()

list picurv_cli.core.preflight_config_directories ( str  root_dir)

Every config directory under a run or study root that may hold a control file.

A study keeps its controls under cases/<member>/config, not at the study root, so a preflight that only looked at <root>/config was empty for every study. This walks the tree so members and nested runs are covered.

Parameters
[in]root_dirRun or study directory.
Returns
Sorted config directory paths.

Definition at line 25687 of file core.py.

25687def preflight_config_directories(root_dir: str) -> list:
25688 """!
25689 @brief Every config directory under a run or study root that may hold a control file.
25690
25691 @details A study keeps its controls under `cases/<member>/config`, not at the study
25692 root, so a preflight that only looked at `<root>/config` was empty for every
25693 study. This walks the tree so members and nested runs are covered.
25694 @param[in] root_dir Run or study directory.
25695 @return Sorted config directory paths.
25696 """
25697 found: set = set()
25698 root = os.path.abspath(root_dir)
25699 direct = os.path.join(root, "config")
25700 if os.path.isdir(direct):
25701 found.add(direct)
25702 for current, dirnames, _ in os.walk(root):
25703 dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(current, d))]
25704 if os.path.basename(current) == "config" and glob.glob(os.path.join(current, "*.control")):
25705 found.add(current)
25706 return sorted(found)
25707
25708
Here is the caller graph for this function:

◆ preflight_staged_run_directories()

tuple picurv_cli.core.preflight_staged_run_directories ( str  root_dir)

Re-check run-directory safety against an already-staged run or study.

Staging validates the configuration it is given, but a staged run can be edited, or produced by an older version, before submission. This re-reads the effective staged control files - across study members and nested runs - and applies the same rules as configuration validation, plus a physical containment check that a symlink cannot slip past.

Parameters
[in]root_dirRun or study directory being submitted.
Returns
Tuple of (errors, warnings).

Definition at line 25709 of file core.py.

25709def preflight_staged_run_directories(root_dir: str) -> tuple:
25710 """!
25711 @brief Re-check run-directory safety against an already-staged run or study.
25712
25713 @details Staging validates the configuration it is given, but a staged run can be
25714 edited, or produced by an older version, before submission. This re-reads the
25715 effective staged control files - across study members and nested runs - and
25716 applies the same rules as configuration validation, plus a physical
25717 containment check that a symlink cannot slip past.
25718 @param[in] root_dir Run or study directory being submitted.
25719 @return Tuple of (errors, warnings).
25720 """
25721 errors: list = []
25722 warnings: list = []
25723 config_dirs = preflight_config_directories(root_dir)
25724 if not config_dirs:
25725 return errors, warnings
25726 canonical = {
25727 "log": CANONICAL_RUN_PATHS["logs"],
25728 "output": CANONICAL_RUN_PATHS["output"],
25729 "restart": CANONICAL_RUN_PATHS["restart"],
25730 "analysis": CANONICAL_RUN_PATHS["metrics"],
25731 }
25732 for config_dir in config_dirs:
25733 run_root = os.path.dirname(config_dir)
25734 for control in sorted(glob.glob(os.path.join(config_dir, "*.control"))):
25735 staged, parse_errors = staged_control_directories(control)
25736 label = os.path.relpath(control)
25737 errors.extend(f" {label}: {message}" for message in parse_errors)
25738 if not staged:
25739 continue
25740 for key, expected in canonical.items():
25741 actual = staged.get(key)
25742 if actual is not None and normalized_run_directory(actual) != expected:
25743 errors.append(
25744 f" {label}: -{key}_dir is {actual!r}; the canonical value is "
25745 f"{expected!r}. Re-stage the run instead of editing its path flags."
25746 )
25747 effective = effective_run_directories(staged)
25748 control_errors, control_warnings = evaluate_run_directories(
25749 effective, False, explicit=set(staged)
25750 )
25751 errors.extend(f" {label}: {message}" for message in control_errors)
25752 warnings.extend(f" {label}: {message}" for message in control_warnings)
25753 for _, verdict, message in classify_physical_containment(run_root, effective):
25754 errors.append(f" {label}: {message}")
25755 return errors, warnings
25756
25757
Here is the call graph for this function:
Here is the caller graph for this function:

◆ read_text_file_lines()

list picurv_cli.core.read_text_file_lines ( str  path)

Read a text file into a list of lines.

Parameters
[in]pathFile to read.
Returns
List of lines.

Definition at line 25758 of file core.py.

25758def read_text_file_lines(path: str) -> list:
25759 """!
25760 @brief Read a text file into a list of lines.
25761 @param[in] path File to read.
25762 @return List of lines.
25763 """
25764 with open(path, "r", encoding="utf-8", errors="replace") as handle:
25765 return handle.readlines()
25766
25767
Here is the caller graph for this function:

◆ submit_staged_jobs()

picurv_cli.core.submit_staged_jobs (   args)

Submit previously staged Slurm artifacts from an existing run/study directory.

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 25768 of file core.py.

25768def submit_staged_jobs(args):
25769 """!
25770 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
25771 @param[in] args Command-line style argument list supplied to the function.
25772 """
25773 if getattr(args, "run_dir", None) and os.path.isdir(args.run_dir):
25774 adopt_relocated_artifact(args.run_dir)
25775 elif getattr(args, "study_dir", None) and os.path.isdir(args.study_dir):
25776 adopt_relocated_artifact(args.study_dir, kind="study")
25777 target_context = _resolve_submission_target(
25778 run_dir=getattr(args, "run_dir", None),
25779 study_dir=getattr(args, "study_dir", None),
25780 )
25781 try:
25782 require_storage_payload_local(target_context["root_dir"], "submission")
25783 except StorageError as exc:
25784 print(f"[FATAL] {exc}", file=sys.stderr)
25785 sys.exit(1)
25786 preflight_errors, preflight_warnings = preflight_staged_run_directories(
25787 target_context["root_dir"]
25788 )
25789 for warning in preflight_warnings:
25790 print(f"[WARNING] {warning.strip()}", file=sys.stderr)
25791 if preflight_errors:
25792 print(
25793 "[FATAL] Submission preflight failed: the staged run has an unsafe run-directory "
25794 "configuration.",
25795 file=sys.stderr,
25796 )
25797 for violation in preflight_errors:
25798 print(violation, file=sys.stderr)
25799 print(
25800 " Re-stage the run so PICurv regenerates its canonical path flags.",
25801 file=sys.stderr,
25802 )
25803 sys.exit(1)
25804 if target_context["target_kind"] == "study":
25805 cold_cases = cold_study_members(target_context["root_dir"])
25806 if cold_cases:
25807 print(
25808 "[FATAL] Submission requires cold-storage study member(s): " + ", ".join(cold_cases),
25809 file=sys.stderr,
25810 )
25811 sys.exit(1)
25812 stage_order = ["solve", "post-process"]
25813 requested_stage = args.stage
25814 # `all` is every staged stage not yet submitted; when each was, every staged stage, so the
25815 # refusal names them; with none staged, both, so the error says how to stage one.
25816 recorded_stages = _get_recorded_submission_stages(target_context)
25817 pending_stages = [stage for stage in recorded_stages
25818 if not _get_submission_stage_metadata(target_context, stage).get("submitted")]
25819 selected_stages = ([requested_stage] if requested_stage != "all"
25820 else pending_stages or recorded_stages or stage_order)
25821
25822 print(f"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
25823 print(f"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
25824 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
25825
25826 if target_context.get("launch_mode") == "local":
25827 submit_staged_local_run(args, target_context, selected_stages)
25828 return
25829
25830 stage_plans = []
25831 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
25832 solve_existing_job_id = str(solve_existing_meta.get("job_id", "")).strip()
25833
25834 for stage_name in selected_stages:
25835 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
25836 script_path = target_context["script_map"][stage_name]
25837 missing_stage_hint = _build_submit_missing_stage_hint(target_context, requested_stage, selected_stages)
25838 if not existing_meta:
25839 emit_structured_error(
25840 ERROR_CODE_CFG_MISSING_KEY,
25841 key=f"scheduler.{stage_name}.metadata",
25842 file_path=target_context["submission_path"],
25843 message=f"Submission metadata does not record stage '{stage_name}'.",
25844 hint=missing_stage_hint,
25845 )
25846 sys.exit(1)
25847
25848 if not os.path.isfile(script_path):
25849 emit_structured_error(
25850 ERROR_CODE_CFG_FILE_NOT_FOUND,
25851 key=f"scheduler.{stage_name}.script",
25852 file_path=script_path,
25853 message=f"Required {stage_name} sbatch artifact is missing.",
25854 hint=missing_stage_hint,
25855 )
25856 sys.exit(1)
25857
25858 if existing_meta.get("submitted") and not args.force:
25859 emit_structured_error(
25860 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25861 key=f"scheduler.{stage_name}.submitted",
25862 file_path=target_context["submission_path"],
25863 message=f"Stage '{stage_name}' is already recorded as submitted.",
25864 hint="Use --force to resubmit this stage intentionally.",
25865 )
25866 sys.exit(1)
25867
25868 dependency = None
25869 if stage_name == "post-process":
25870 if "solve" in selected_stages:
25871 dependency = "__NEW_SOLVE_JOB_ID__"
25872 elif solve_existing_meta.get("submitted") and solve_existing_job_id:
25873 dependency = solve_existing_job_id
25874 end, state = slurm_job_end_state(solve_existing_job_id)
25875 if end == "completed":
25876 # Slurm rejects a dependency on a job it has forgotten, and the post
25877 # job plans from committed checkpoints when it starts, so a solve that
25878 # already succeeded leaves nothing to wait for.
25879 print(f"[INFO] Solve job {solve_existing_job_id} has completed; the post job is "
25880 "submitted without a dependency.")
25881 dependency = None
25882 elif end == "failed" and not args.force:
25883 emit_structured_error(
25884 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25885 key="scheduler.post-process.dependency",
25886 file_path=target_context["submission_path"],
25887 message=f"Solve job {solve_existing_job_id} ended {state}, so a post job waiting on its success would never start.",
25888 hint="Resubmit the solve, or use --force to post-process the checkpoints it committed.",
25889 )
25890 sys.exit(1)
25891 elif end != "active" and end != "unchecked":
25892 # Failed with --force, or ended in a state sacct cannot report.
25893 print(f"[WARNING] Solve job {solve_existing_job_id} is no longer queued "
25894 f"({state or 'end state unavailable from sacct'}); the post job is submitted "
25895 "without a dependency and processes what was committed.", file=sys.stderr)
25896 dependency = None
25897 elif solve_existing_meta and not args.force:
25898 # A post staged on its own has no solve to wait for: it reads a run solved
25899 # earlier and plans its steps from what is committed when it starts.
25900 emit_structured_error(
25901 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25902 key="scheduler.post-process.dependency",
25903 file_path=target_context["submission_path"],
25904 message="This run's staged solve has not been submitted, so its post-process job would have nothing to read.",
25905 hint="Submit --stage solve or --stage all first, or use --force to submit the post job without a dependency.",
25906 )
25907 sys.exit(1)
25908
25909 stage_plans.append(
25910 {
25911 "stage": stage_name,
25912 "script": script_path,
25913 "dependency": dependency,
25914 "existing_meta": existing_meta,
25915 }
25916 )
25917
25918 # A study aggregates its metrics after its post array, as `sweep` chains it, whether
25919 # or not every member succeeded.
25920 if target_context["target_kind"] == "study" and "post-process" in selected_stages:
25921 metrics_meta = _get_submission_stage_metadata(target_context, "metrics")
25922 metrics_script = target_context["script_map"]["metrics"]
25923 if metrics_meta and os.path.isfile(metrics_script) and (not metrics_meta.get("submitted") or args.force):
25924 stage_plans.append({
25925 "stage": "metrics",
25926 "script": metrics_script,
25927 "dependency": "__NEW_POST_JOB_ID__",
25928 "dependency_type": "afterany",
25929 "existing_meta": metrics_meta,
25930 })
25931
25932 if args.dry_run:
25933 for plan in stage_plans:
25934 cmd = ["sbatch"]
25935 dependency = plan["dependency"]
25936 dependency_type = plan.get("dependency_type", "afterok")
25937 if dependency == "__NEW_SOLVE_JOB_ID__":
25938 cmd.append("--dependency=afterok:<new solve job id>")
25939 elif dependency == "__NEW_POST_JOB_ID__":
25940 cmd.append(f"--dependency={dependency_type}:<new post job id>")
25941 elif dependency:
25942 cmd.append(f"--dependency={dependency_type}:{dependency}")
25943 cmd.append(plan["script"])
25944 print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
25945 print("[INFO] Dry-run only. No jobs were submitted.")
25946 return
25947
25948 new_job_ids = {}
25949 for plan in stage_plans:
25950 dependency = plan["dependency"]
25951 dependency_type = plan.get("dependency_type", "afterok")
25952 if dependency == "__NEW_SOLVE_JOB_ID__":
25953 dependency = new_job_ids.get("solve")
25954 elif dependency == "__NEW_POST_JOB_ID__":
25955 dependency = new_job_ids.get("post-process")
25956
25957 submit_info = submit_sbatch(plan["script"], dependency=dependency, dependency_type=dependency_type)
25958 stage_meta = copy.deepcopy(plan["existing_meta"])
25959 stage_meta.update(submit_info)
25960 stage_meta["script"] = plan["script"]
25961 stage_meta["submitted"] = True
25962 if dependency:
25963 stage_meta["dependency"] = f"{dependency_type}:{dependency}"
25964 else:
25965 stage_meta.pop("dependency", None)
25966
25967 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
25968 print(f"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
25969 new_job_ids[plan["stage"]] = submit_info["job_id"]
25970
25971 _write_submission_target_metadata(target_context)
25972
25973
Here is the call graph for this function:
Here is the caller graph for this function:

◆ submit_staged_local_run()

picurv_cli.core.submit_staged_local_run (   args,
dict  target_context,
list  selected_stages 
)

Execute previously staged local run commands from scheduler/submission.json.

Parameters
[in]argsCommand-line style argument list supplied to the function.
[in]target_contextResolved submission target context.
[in]selected_stagesOrdered stage names selected by the user.

Definition at line 25974 of file core.py.

25974def submit_staged_local_run(args, target_context: dict, selected_stages: list):
25975 """!
25976 @brief Execute previously staged local run commands from scheduler/submission.json.
25977 @param[in] args Command-line style argument list supplied to the function.
25978 @param[in] target_context Resolved submission target context.
25979 @param[in] selected_stages Ordered stage names selected by the user.
25980 """
25981 if target_context["target_kind"] != "run":
25982 emit_structured_error(
25983 ERROR_CODE_CFG_INCONSISTENT_COMBO,
25984 key="scheduler.launch_mode",
25985 file_path=target_context["submission_path"],
25986 message="Local staged execution is supported for run directories only.",
25987 hint="Use --run-dir for local staged execution.",
25988 )
25989 sys.exit(1)
25990
25991 stage_plans = []
25992 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
25993 # Only a solve staged in this same set can still be pending; a post staged on its own
25994 # reads a run solved earlier and plans its steps from what is committed when it starts.
25995 solve_pending = bool(solve_existing_meta) and not bool(
25996 solve_existing_meta.get("submitted") or solve_existing_meta.get("executed"))
25997
25998 for stage_name in selected_stages:
25999 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
26000 command = existing_meta.get("command")
26001 if not isinstance(command, list) or not command:
26002 if existing_meta:
26003 hint = "Re-stage the run with picurv run --no-submit before calling picurv submit."
26004 else:
26005 hint = _build_submit_missing_stage_hint(target_context, args.stage, selected_stages)
26006 emit_structured_error(
26007 ERROR_CODE_CFG_MISSING_KEY,
26008 key=f"scheduler.{stage_name}.command",
26009 file_path=target_context["submission_path"],
26010 message=f"Required local command metadata for stage '{stage_name}' is missing.",
26011 hint=hint,
26012 )
26013 sys.exit(1)
26014
26015 if existing_meta.get("submitted") and not args.force:
26016 emit_structured_error(
26017 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26018 key=f"scheduler.{stage_name}.submitted",
26019 file_path=target_context["submission_path"],
26020 message=f"Stage '{stage_name}' is already recorded as submitted.",
26021 hint="Use --force to execute this stage again intentionally.",
26022 )
26023 sys.exit(1)
26024
26025 if stage_name == "post-process" and "solve" not in selected_stages and not args.force and solve_pending:
26026 emit_structured_error(
26027 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26028 key="scheduler.post-process.dependency",
26029 file_path=target_context["submission_path"],
26030 message="This run's staged solve has not run yet, so its post-process stage has nothing to read.",
26031 hint="Submit --stage solve or --stage all first, or use --force to run the post stage anyway.",
26032 )
26033 sys.exit(1)
26034
26035 log_file = existing_meta.get("log_file")
26036 if not isinstance(log_file, str) or not log_file.strip():
26037 log_file = os.path.join("scheduler", f"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
26038
26039 stage_plans.append(
26040 {
26041 "stage": stage_name,
26042 "command": [str(token) for token in command],
26043 "log_file": log_file,
26044 "existing_meta": existing_meta,
26045 }
26046 )
26047
26048 if args.dry_run:
26049 for plan in stage_plans:
26050 print(f"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
26051 print(f"[DRY-RUN] Log file : {plan['log_file']}")
26052 print("[INFO] Dry-run only. No local commands were executed.")
26053 return
26054
26055 monitor_cfg = None
26056 monitor_path = os.path.join(target_context["root_dir"], "config", "monitor.yml")
26057 if os.path.isfile(monitor_path):
26058 monitor_cfg = read_yaml_file(monitor_path)
26059
26060 for plan in stage_plans:
26061 if plan["stage"] == "solve":
26062 try:
26063 with runtime_stage_lock(target_context["root_dir"], "solver"):
26064 execute_command(plan["command"], target_context["root_dir"], plan["log_file"], monitor_cfg)
26065 except StorageError as exc:
26066 print(f"[FATAL] {exc}", file=sys.stderr)
26067 sys.exit(1)
26068 else:
26069 execute_command(plan["command"], target_context["root_dir"], plan["log_file"], monitor_cfg)
26070 stage_meta = copy.deepcopy(plan["existing_meta"])
26071 stage_meta["command"] = plan["command"]
26072 stage_meta["command_string"] = format_command_for_display(plan["command"])
26073 stage_meta["log_file"] = plan["log_file"]
26074 stage_meta["submitted"] = True
26075 stage_meta["executed"] = True
26076 stage_meta["completed_at"] = datetime.now().isoformat()
26077 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
26078 print(f"[SUCCESS] Executed local {plan['stage']} stage.")
26079
26080 _write_submission_target_metadata(target_context)
26081
26082
Here is the call graph for this function:
Here is the caller graph for this function:

◆ cancel_run_jobs()

picurv_cli.core.cancel_run_jobs (   args)

Cancel Slurm-submitted jobs for an existing run directory.

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 26083 of file core.py.

26083def cancel_run_jobs(args):
26084 """!
26085 @brief Cancel Slurm-submitted jobs for an existing run directory.
26086 @param[in] args Command-line style argument list supplied to the function.
26087 """
26088 run_dir = os.path.abspath(args.run_dir)
26089 if not os.path.isdir(run_dir):
26090 emit_structured_error(
26091 ERROR_CODE_CFG_FILE_NOT_FOUND,
26092 key="run_dir",
26093 file_path=run_dir,
26094 message="Run directory not found.",
26095 )
26096 sys.exit(1)
26097
26098 submission_path = os.path.join(run_dir, "scheduler", "submission.json")
26099 submission_meta = _read_json_if_exists(submission_path)
26100 if not isinstance(submission_meta, dict):
26101 emit_structured_error(
26102 ERROR_CODE_CFG_FILE_NOT_FOUND,
26103 key="scheduler.submission",
26104 file_path=submission_path,
26105 message="Run directory does not contain scheduler submission metadata.",
26106 hint="Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
26107 )
26108 sys.exit(1)
26109
26110 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
26111 if launch_mode != "slurm":
26112 emit_structured_error(
26113 ERROR_CODE_CFG_INCONSISTENT_COMBO,
26114 key="scheduler.launch_mode",
26115 file_path=submission_path,
26116 message=f"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
26117 hint="picurv cancel currently supports Slurm-submitted runs only.",
26118 )
26119 sys.exit(1)
26120
26121 stage_order = ["solve", "post-process"]
26122 requested_stage = args.stage
26123 selected_stages = stage_order if requested_stage == "all" else [requested_stage]
26124 recorded_stages = submission_meta.get("stages", {})
26125 if not isinstance(recorded_stages, dict):
26126 recorded_stages = {}
26127
26128 job_to_stages = {}
26129 skipped = []
26130 for stage_name in selected_stages:
26131 stage_meta = recorded_stages.get(stage_name)
26132 if not isinstance(stage_meta, dict):
26133 skipped.append((stage_name, "no stage metadata recorded"))
26134 continue
26135
26136 job_id = str(stage_meta.get("job_id", "")).strip()
26137 if not stage_meta.get("submitted"):
26138 skipped.append((stage_name, "job was generated but not submitted"))
26139 continue
26140 if not job_id:
26141 skipped.append((stage_name, "submitted stage is missing a recorded job id"))
26142 continue
26143
26144 job_to_stages.setdefault(job_id, []).append(stage_name)
26145
26146 if not job_to_stages:
26147 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
26148 print(f"[INFO] Submission metadata : {os.path.relpath(submission_path)}")
26149 for stage_name, reason in skipped:
26150 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
26151 print("[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
26152 sys.exit(1)
26153
26154 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
26155 print(f"[INFO] Submission metadata : {os.path.relpath(submission_path)}")
26156 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
26157
26158 if skipped:
26159 for stage_name, reason in skipped:
26160 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
26161
26162 graceful = bool(getattr(args, "graceful", False))
26163 failures = []
26164 for job_id, stage_names in job_to_stages.items():
26165 joined_stage_names = ", ".join(stage_names)
26166 use_graceful_signal = graceful and "solve" in stage_names
26167 scancel_cmd = ["scancel"]
26168 if use_graceful_signal:
26169 # A directly launched ``mpirun`` replaces the batch shell (via ``exec``)
26170 # and is therefore not necessarily a Slurm job step. ``--full`` also
26171 # signals the batch process and its children, so the MPI launcher can
26172 # forward SIGUSR1 to the solver ranks.
26173 scancel_cmd.extend(["--signal=USR1", "--full"])
26174 scancel_cmd.append(job_id)
26175
26176 if args.dry_run:
26177 print(f"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
26178 continue
26179
26180 # scancel exits 0 for a job that already ended, so ask Slurm before claiming a cancel.
26181 status, end_state = slurm_job_end_state(job_id)
26182 # Every status but "active" with a recorded end state is a job Slurm has finished.
26183 if end_state and status != "active":
26184 print(f"[INFO] Slurm job {job_id} for stage(s) {joined_stage_names} already ended "
26185 f"({end_state}); nothing to cancel.")
26186 continue
26187
26188 result = subprocess.run(scancel_cmd, text=True, capture_output=True, check=False)
26189 stderr_text = (result.stderr or "").strip()
26190 stdout_text = (result.stdout or "").strip()
26191 if result.returncode == 0:
26192 if use_graceful_signal:
26193 print(
26194 f"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
26195 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
26196 )
26197 else:
26198 print(f"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
26199 continue
26200
26201 detail = stderr_text or stdout_text or "unknown scancel failure"
26202 failures.append((job_id, joined_stage_names, detail, result.returncode))
26203 print(
26204 f"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
26205 file=sys.stderr,
26206 )
26207
26208 if args.dry_run:
26209 print("[INFO] Dry-run only. No jobs were canceled.")
26210 return
26211
26212 if failures:
26213 sys.exit(1)
26214
26215
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _workspace_yaml_role()

picurv_cli.core._workspace_yaml_role ( str  path)
protected

Infer the owned workspace role of one copied YAML file.

Parameters
[in]pathYAML file copied from an example template.
Returns
One of case, solver, monitor, post, cluster, study, or None.

Definition at line 26216 of file core.py.

26216def _workspace_yaml_role(path: str):
26217 """!
26218 @brief Infer the owned workspace role of one copied YAML file.
26219 @param[in] path YAML file copied from an example template.
26220 @return One of case, solver, monitor, post, cluster, study, or None.
26221 """
26222 try:
26223 payload = read_yaml_file(path)
26224 except (OSError, ValueError):
26225 return None
26226 keys = set(payload)
26227 if {"grid", "properties", "run_control"} <= keys:
26228 return "case"
26229 if "base_configs" in keys and ("study_type" in keys or "parameters" in keys or "parameter_sets" in keys):
26230 return "study"
26231 if "scheduler" in keys and "resources" in keys:
26232 return "cluster"
26233 # Any key only a post recipe owns; a spectra- or statistics-only recipe has no
26234 # pipeline, and source_data is optional. run_control is shared with case.yml.
26235 post_only = _POST_SCHEMA[()] - _MONITOR_SCHEMA[()] - {"run_control"}
26236 if keys & post_only or ("run_control" in keys and not keys & _CASE_SCHEMA[()] - {"run_control", "title"}):
26237 return "post"
26238 if "io" in keys and ("logging" in keys or "profiling" in keys or "diagnostics" in keys):
26239 return "monitor"
26240 if "momentum_solver" in keys or "poisson_solver" in keys or "operation_mode" in keys:
26241 return "solver"
26242 return None
26243
26244
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _rewrite_workspace_path_values()

picurv_cli.core._rewrite_workspace_path_values (   value,
dict  replacements 
)
protected

Rewrite copied template path scalars to workspace-root-relative homes.

Parameters
[in]valueYAML subtree to rewrite.
[in]replacementsOld relative/basename paths mapped to new workspace paths.
Returns
Rewritten YAML subtree.

Definition at line 26245 of file core.py.

26245def _rewrite_workspace_path_values(value, replacements: dict):
26246 """!
26247 @brief Rewrite copied template path scalars to workspace-root-relative homes.
26248 @param[in] value YAML subtree to rewrite.
26249 @param[in] replacements Old relative/basename paths mapped to new workspace paths.
26250 @return Rewritten YAML subtree.
26251 """
26252 if isinstance(value, dict):
26253 return {key: _rewrite_workspace_path_values(item, replacements) for key, item in value.items()}
26254 if isinstance(value, list):
26255 return [_rewrite_workspace_path_values(item, replacements) for item in value]
26256 if not isinstance(value, str):
26257 return value
26258 normalized = value.replace("\\", "/").lstrip("./")
26259 return replacements.get(normalized, replacements.get(os.path.basename(normalized), value))
26260
26261
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _choose_primary_workspace_role()

picurv_cli.core._choose_primary_workspace_role ( list  candidates,
str  role,
str  template_name 
)
protected

Select the canonical role file from a template that may carry variants.

Parameters
[in]candidatesCandidate absolute YAML paths.
[in]roleConfig role being selected.
[in]template_nameSource example directory name.
Returns
Selected absolute path or None.

Definition at line 26262 of file core.py.

26262def _choose_primary_workspace_role(candidates: list, role: str, template_name: str):
26263 """!
26264 @brief Select the canonical role file from a template that may carry variants.
26265 @param[in] candidates Candidate absolute YAML paths.
26266 @param[in] role Config role being selected.
26267 @param[in] template_name Source example directory name.
26268 @return Selected absolute path or None.
26269 """
26270 if not candidates:
26271 return None
26272 preferred = {
26273 "case": ("case.yml", f"{template_name}.yml", "master_case.yml"),
26274 "solver": ("solver.yml", "Imp-MG-Standard.yml", "master_solver.yml"),
26275 "monitor": ("monitor.yml", "Standard_Output.yml", "master_monitor.yml"),
26276 "post": ("post.yml", "standard_analysis.yml", "master_postprocessor.yml"),
26277 "cluster": ("cluster.yml", "slurm_cluster.yml", "master_cluster.yml"),
26278 }.get(role, ())
26279 by_name = {os.path.basename(path): path for path in candidates}
26280 for name in preferred:
26281 if name in by_name:
26282 return by_name[name]
26283 return sorted(candidates)[0]
26284
26285
Here is the caller graph for this function:

◆ organize_initialized_workspace()

dict picurv_cli.core.organize_initialized_workspace ( str  workspace_root,
str  template_name,
str   source_template_root = None 
)

Convert a copied example into the canonical editable workspace layout.

Parameters
[in]workspace_rootNewly copied workspace root.
[in]template_nameExample template identity.
[in]source_template_rootOptional original template root used to vendor references.
Returns
Mapping of canonical config roles and relocated imported inputs.

Definition at line 26286 of file core.py.

26287 source_template_root: str = None) -> dict:
26288 """!
26289 @brief Convert a copied example into the canonical editable workspace layout.
26290 @param[in] workspace_root Newly copied workspace root.
26291 @param[in] template_name Example template identity.
26292 @param[in] source_template_root Optional original template root used to vendor references.
26293 @return Mapping of canonical config roles and relocated imported inputs.
26294 """
26295 workspace_root = os.path.abspath(workspace_root)
26296 ensure_workspace_layout(workspace_root)
26297 # Skip the workspace's own runs/ and studies/ trees, judged inside the workspace: a
26298 # workspace that itself sits under a directory named runs/ must still be organized.
26299 yaml_files = [
26300 str(path)
26301 for pattern in ("*.yml", "*.yaml")
26302 for path in Path(workspace_root).rglob(pattern)
26303 if path.name not in {RUNTIME_EXECUTION_EXAMPLE_FILENAME, RUNTIME_EXECUTION_CONFIG_FILENAME}
26304 and not {"runs", "studies"} & set(path.relative_to(workspace_root).parts)
26305 ]
26306 vendored_replacements = {}
26307 if source_template_root:
26308 source_template_root = os.path.abspath(source_template_root)
26309
26310 def vendor_references(value, source_yaml: str, dotted: str = ""):
26311 """!
26312 @brief Vendor referenced generator inputs into canonical workspace homes.
26313 @param[in] value YAML subtree to inspect.
26314 @param[in] source_yaml Original template YAML path.
26315 @param[in] dotted Current dotted key path.
26316 @return None.
26317 """
26318 if isinstance(value, dict):
26319 for key, child in value.items():
26320 vendor_references(child, source_yaml, f"{dotted}.{key}" if dotted else str(key))
26321 return
26322 if isinstance(value, list):
26323 for index, child in enumerate(value):
26324 vendor_references(child, source_yaml, f"{dotted}[{index}]")
26325 return
26326 key = dotted.rsplit(".", 1)[-1]
26327 if key not in _VENDORABLE_CONFIG_REFERENCE_KEYS or not isinstance(value, str) or not value.strip():
26328 return
26329 origin = os.path.abspath(os.path.join(os.path.dirname(source_yaml), value))
26330 if not os.path.isfile(origin):
26331 return
26332 if key == "script":
26333 home = "config/generators"
26334 elif "grid" in dotted:
26335 home = "config/grids"
26336 elif "initial_condition" in dotted:
26337 home = "config/initial_conditions"
26338 elif "inlet" in dotted or "boundary_conditions" in dotted:
26339 home = "config/inlet_profiles"
26340 else:
26341 home = "config"
26342 destination = os.path.join(workspace_root, *home.split("/"), os.path.basename(origin))
26343 os.makedirs(os.path.dirname(destination), exist_ok=True)
26344 if not os.path.exists(destination):
26345 shutil.copy2(origin, destination)
26346 relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
26347 vendored_replacements[value.replace("\\", "/").lstrip("./")] = relative
26348 vendored_replacements[os.path.basename(value)] = relative
26349
26350 for copied_yaml in sorted(set(yaml_files)):
26351 relative = os.path.relpath(copied_yaml, workspace_root)
26352 source_yaml = os.path.join(source_template_root, relative)
26353 if not os.path.isfile(source_yaml):
26354 continue
26355 try:
26356 vendor_references(read_yaml_file(copied_yaml), source_yaml)
26357 except (OSError, ValueError):
26358 continue
26359 roles = {}
26360 for path in sorted(set(yaml_files)):
26361 role = _workspace_yaml_role(path)
26362 if role:
26363 roles.setdefault(role, []).append(path)
26364
26365 selected = {
26366 role: _choose_primary_workspace_role(paths, role, template_name)
26367 for role, paths in roles.items() if role != "study"
26368 }
26369 destinations = {}
26370 occupied = set()
26371 for role, paths in roles.items():
26372 for source in paths:
26373 if role == "study":
26374 relative = os.path.join("config", "studies", os.path.basename(source))
26375 elif source == selected.get(role):
26376 relative = os.path.join("config", f"{role}.yml")
26377 else:
26378 relative = os.path.join("config", os.path.basename(source))
26379 destination = os.path.join(workspace_root, relative)
26380 if os.path.abspath(source) == os.path.abspath(destination):
26381 destinations[source] = relative.replace(os.sep, "/")
26382 occupied.add(os.path.abspath(destination))
26383 continue
26384 if os.path.abspath(destination) in occupied or os.path.exists(destination):
26385 original_relative = os.path.relpath(source, workspace_root)
26386 relative = os.path.join("config", "variants", original_relative)
26387 destination = os.path.join(workspace_root, relative)
26388 counter = 2
26389 while os.path.abspath(destination) in occupied or os.path.exists(destination):
26390 stem, suffix = os.path.splitext(relative)
26391 destination = os.path.join(workspace_root, f"{stem}-{counter}{suffix}")
26392 counter += 1
26393 os.makedirs(os.path.dirname(destination), exist_ok=True)
26394 shutil.move(source, destination)
26395 destinations[source] = relative.replace(os.sep, "/")
26396 occupied.add(os.path.abspath(destination))
26397
26398 input_extensions = {
26399 ".picgrid": "inputs/grids",
26400 ".vts": "inputs/grids",
26401 ".picslice": "inputs/inlet_profiles",
26402 ".dat": "inputs/initial_conditions",
26403 }
26404 input_moves = {}
26405 for path in sorted(Path(workspace_root).rglob("*")):
26406 if not path.is_file() or path.suffix.lower() not in input_extensions:
26407 continue
26408 if any(part in _WORKSPACE_MANAGED_PATHS for part in path.relative_to(workspace_root).parts):
26409 continue
26410 destination_dir = os.path.join(workspace_root, *input_extensions[path.suffix.lower()].split("/"))
26411 destination = os.path.join(destination_dir, path.name)
26412 if os.path.exists(destination):
26413 continue
26414 old_relative = path.relative_to(workspace_root).as_posix()
26415 shutil.move(str(path), destination)
26416 new_relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
26417 input_moves[old_relative] = new_relative
26418 input_moves[path.name] = new_relative
26419
26420 config_replacements = {}
26421 basename_counts = {}
26422 for source in destinations:
26423 basename = os.path.basename(source)
26424 basename_counts[basename] = basename_counts.get(basename, 0) + 1
26425 for source, relative in destinations.items():
26426 old_relative = os.path.relpath(source, workspace_root).replace(os.sep, "/")
26427 config_replacements[old_relative] = relative
26428 if basename_counts[os.path.basename(source)] == 1:
26429 config_replacements[os.path.basename(source)] = relative
26430 replacements = {**config_replacements, **input_moves, **vendored_replacements}
26431 for path in sorted(Path(workspace_root, "config").rglob("*.yml")):
26432 payload = read_yaml_file(str(path))
26433 local_replacements = dict(replacements)
26434 original_source = next(
26435 (
26436 source for source, relative in destinations.items()
26437 if os.path.abspath(os.path.join(workspace_root, relative)) == os.path.abspath(path)
26438 ),
26439 None,
26440 )
26441 if original_source:
26442 original_parent = os.path.dirname(original_source)
26443 for candidate_source, candidate_relative in destinations.items():
26444 relative_reference = os.path.relpath(candidate_source, original_parent).replace(os.sep, "/")
26445 local_replacements[relative_reference] = candidate_relative
26446 if os.path.dirname(candidate_source) == original_parent:
26447 local_replacements[os.path.basename(candidate_source)] = candidate_relative
26448 rewritten = _rewrite_workspace_path_values(payload, local_replacements)
26449 role = _workspace_yaml_role(str(path))
26450 if role == "monitor" and isinstance(rewritten.get("io"), dict):
26451 rewritten["io"].pop("directories", None)
26452 if role == "case" and path == Path(workspace_root, "config", "case.yml"):
26453 rewritten.setdefault("title", template_name)
26454 if role == "post":
26455 if isinstance(rewritten.get("source_data"), dict):
26456 rewritten["source_data"].pop("directory", None)
26457 if isinstance(rewritten.get("io"), dict):
26458 rewritten["io"].pop("output_directory", None)
26459 if role == "study" and isinstance(rewritten.get("base_configs"), dict):
26460 for role in ("case", "solver", "monitor", "post"):
26461 canonical = os.path.join("config", f"{role}.yml").replace(os.sep, "/")
26462 if os.path.isfile(os.path.join(workspace_root, canonical)):
26463 rewritten["base_configs"][role] = canonical
26464 write_yaml_file(str(path), rewritten)
26465
26466 return {
26467 "workspace_config": initialize_workspace_root(workspace_root, template_name),
26468 "canonical_roles": {
26469 role: os.path.join(workspace_root, "config", f"{role}.yml")
26470 for role in ("case", "solver", "monitor", "post", "cluster")
26471 if os.path.isfile(os.path.join(workspace_root, "config", f"{role}.yml"))
26472 },
26473 "input_moves": input_moves,
26474 }
26475
26476
Here is the call graph for this function:
Here is the caller graph for this function:

◆ import_workspace_input()

dict picurv_cli.core.import_workspace_input ( str  workspace_root,
str  kind,
str  source,
str   name = None,
str   mode = "copy" 
)

Explicitly import or register one workspace input.

Parameters
[in]workspace_rootInitialized workspace root.
[in]kindSemantic input kind.
[in]sourceExisting source file path.
[in]nameOptional destination basename.
[in]modeCopy, reflink, hardlink, or external-reference mode.
Returns
Catalog entry describing the durable input identity.

Definition at line 26485 of file core.py.

26486 name: str = None, mode: str = "copy") -> dict:
26487 """!
26488 @brief Explicitly import or register one workspace input.
26489 @param[in] workspace_root Initialized workspace root.
26490 @param[in] kind Semantic input kind.
26491 @param[in] source Existing source file path.
26492 @param[in] name Optional destination basename.
26493 @param[in] mode Copy, reflink, hardlink, or external-reference mode.
26494 @return Catalog entry describing the durable input identity.
26495 """
26496 workspace_root = os.path.abspath(workspace_root)
26497 load_workspace_config(workspace_root)
26498 ensure_workspace_layout(workspace_root)
26499 if kind not in WORKSPACE_INPUT_DIRECTORIES:
26500 raise ValueError(f"Unsupported input kind: {kind}")
26501 source = os.path.abspath(os.path.expanduser(source))
26502 if not os.path.isfile(source):
26503 raise ValueError(f"Input source is not a file: {source}")
26504 basename = name or os.path.basename(source)
26505 if basename != os.path.basename(basename) or basename in _PLAIN_FILENAME_SENTINELS:
26506 raise ValueError("--name must be a plain filename without directory traversal.")
26507 target_dir = os.path.join(workspace_root, *WORKSPACE_INPUT_DIRECTORIES[kind].split("/"))
26508 os.makedirs(target_dir, exist_ok=True)
26509 if mode == "reference":
26510 destination = os.path.join(target_dir, basename + ".reference.yml")
26511 write_yaml_file(destination, {
26512 "schema_version": 1,
26513 "picurv_external_reference": source,
26514 "sha256_at_registration": _asset_file_sha256(source),
26515 "bytes_at_registration": os.path.getsize(source),
26516 })
26517 else:
26518 destination = os.path.join(target_dir, basename)
26519 if os.path.exists(destination):
26520 raise ValueError(f"Workspace input already exists: {destination}")
26521 temporary = f"{destination}.tmp.{os.getpid()}"
26522 try:
26523 if mode == "copy":
26524 shutil.copy2(source, temporary)
26525 elif mode == "hardlink":
26526 os.link(source, temporary)
26527 elif mode == "reflink":
26528 cp = shutil.which("cp")
26529 if not cp:
26530 raise ValueError("reflink mode requires the 'cp' command.")
26531 result = subprocess.run(
26532 [cp, "--reflink=always", "--preserve=mode,timestamps", source, temporary],
26533 text=True, capture_output=True, check=False,
26534 )
26535 if result.returncode != 0:
26536 raise ValueError((result.stderr or "reflink copy failed").strip())
26537 else:
26538 raise ValueError(f"Unsupported import mode: {mode}")
26539 os.replace(temporary, destination)
26540 finally:
26541 if os.path.lexists(temporary):
26542 os.remove(temporary)
26543 relative = os.path.relpath(destination, workspace_root).replace(os.sep, "/")
26544 entry_id = _stable_mapping_sha256({"kind": kind, "path": relative, "source": source})[:16]
26545 catalog_path = os.path.join(workspace_root, "inputs", "catalog.yml")
26546 catalog = read_yaml_file(catalog_path) if os.path.isfile(catalog_path) else {
26547 "schema_version": 1, "inputs": {}
26548 }
26549 catalog.setdefault("inputs", {})[entry_id] = {
26550 "kind": kind,
26551 "path": relative,
26552 "mode": mode,
26553 "sha256": _asset_file_sha256(source),
26554 "bytes": os.path.getsize(source),
26555 "source": source if mode == "reference" else None,
26556 "registered_at": datetime.now().astimezone().isoformat(),
26557 }
26558 write_yaml_file(catalog_path, catalog)
26559 return {"id": entry_id, **catalog["inputs"][entry_id]}
26560
26561
Here is the call graph for this function:
Here is the caller graph for this function:

◆ inputs_workflow()

picurv_cli.core.inputs_workflow (   args)

Handle explicit workspace input management.

Parameters
[in]argsParsed inputs command arguments.
Returns
None.

Definition at line 26562 of file core.py.

26562def inputs_workflow(args):
26563 """!
26564 @brief Handle explicit workspace input management.
26565 @param[in] args Parsed inputs command arguments.
26566 @return None.
26567 """
26568 workspace_root = os.path.abspath(args.workspace) if args.workspace else find_workspace_root(os.getcwd())
26569 if not workspace_root:
26570 raise ValueError("No initialized workspace found; run picurv init first or pass --workspace.")
26571 if args.inputs_action != "import":
26572 raise ValueError(f"Unsupported inputs action: {args.inputs_action}")
26573 entry = import_workspace_input(
26574 workspace_root, args.kind, args.source, name=args.name, mode=args.mode
26575 )
26576 print(f"[SUCCESS] Registered input {entry['id']}: {entry['path']}")
26577 if entry["mode"] == "reference":
26578 print("[WARNING] This is an external reference. Storage will record it but will not copy or prune its target.")
26579
26580
Here is the call graph for this function:
Here is the caller graph for this function:

◆ version_workflow()

picurv_cli.core.version_workflow (   args)

Report, and for the status action validate, the shared build identity.

Bare picurv version reports and always succeeds; it is an informational surface that scripts and documentation already depend on. picurv version status additionally exits non-zero when the conductor, the executables, and the workspace requirement do not agree, so a job script can refuse to launch a run whose provenance would be incoherent.

Parameters
[in]argsParsed version command arguments.
Returns
None.

Definition at line 26581 of file core.py.

26581def version_workflow(args):
26582 """!
26583 @brief Report, and for the `status` action validate, the shared build identity.
26584
26585 @details Bare `picurv version` reports and always succeeds; it is an informational
26586 surface that scripts and documentation already depend on. `picurv version
26587 status` additionally exits non-zero when the conductor, the executables,
26588 and the workspace requirement do not agree, so a job script can refuse to
26589 launch a run whose provenance would be incoherent.
26590 @param[in] args Parsed version command arguments.
26591 @return None.
26592 """
26593 workspace_root = find_workspace_root(os.getcwd())
26594 payload = dict(picurv_build())
26595 payload["source_root"] = PACKAGE_PROJECT_ROOT
26596 payload["workspace"] = workspace_root
26597 payload["workspace_requirement"] = None
26598 if workspace_root:
26599 software = load_workspace_config(workspace_root).get("software") or {}
26600 payload["workspace_requirement"] = software.get("picurv") if isinstance(software, dict) else None
26601 payload["binaries"] = runtime_build_identities()
26602 for identity in payload["binaries"].values():
26603 library = resolved_petsc_library(identity["path"]) if identity.get("path") else None
26604 identity["petsc_library"] = library
26605 identity["petsc_library_mismatch"] = petsc_library_mismatch(identity.get("petsc"), library)
26606 validating = getattr(args, "version_action", None) == "status"
26607 problems = build_identity_problems(payload["binaries"], payload["workspace_requirement"])
26608 payload["coherent"] = not problems
26609 payload["problems"] = problems
26610 if getattr(args, "output_format", "text") == "json":
26611 print(json.dumps(payload, indent=2, sort_keys=True))
26612 if validating and problems:
26613 sys.exit(1)
26614 return
26615 print(f"PICurv release : {payload['release_version']}")
26616 print(f"Build identity : {payload['build_id']}")
26617 print(f"Git commit : {payload.get('git_commit') or 'unavailable'}")
26618 print(f"Dirty tree : {payload.get('dirty') if payload.get('dirty') is not None else 'unknown'}")
26619 if workspace_root:
26620 print(f"Workspace : {workspace_root}")
26621 print(f"Requirement : {payload['workspace_requirement'] or 'latest active version'}")
26622 print("\nNative executables")
26623 for name, identity in sorted(payload["binaries"].items()):
26624 if not identity.get("available"):
26625 print(f" {name:<15}: unavailable ({identity.get('reason', 'unknown')})")
26626 else:
26627 agreement = "matches source" if identity["matches_source"] else "STALE - rebuild"
26628 print(f" {name:<15}: {identity['build_id']} ({agreement})")
26629 _print_petsc_build(identity)
26630 if not problems:
26631 print("\nBuild identity is coherent.")
26632 return
26633 print("\nBuild identity is NOT coherent:", file=sys.stderr)
26634 for problem in problems:
26635 print(f" - {problem}", file=sys.stderr)
26636 if validating:
26637 sys.exit(1)
26638
26639
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _print_petsc_build()

None picurv_cli.core._print_petsc_build ( dict  identity)
protected

Print the PETSc an executable was built against and the library it would load.

Parameters
[in]identityOne executable's entry from runtime_build_identities(), with petsc_library and petsc_library_mismatch filled in.
Returns
None.

Definition at line 26640 of file core.py.

26640def _print_petsc_build(identity: dict) -> None:
26641 """!
26642 @brief Print the PETSc an executable was built against and the library it would load.
26643 @param[in] identity One executable's entry from `runtime_build_identities()`, with
26644 `petsc_library` and `petsc_library_mismatch` filled in.
26645 @return None.
26646 """
26647 petsc = identity.get("petsc")
26648 if not petsc:
26649 print(f" {'':<15} PETSc: not recorded (built before PETSc stamping)")
26650 return
26651 location = ", ".join(part for part in (petsc.get("arch"), petsc.get("dir")) if part)
26652 bits = 8 * PETSC_INDEX_BYTES[petsc.get("indices", "int32")]
26653 print(f" {'':<15} PETSc: {petsc['version']} {petsc['mode']}, {bits}-bit indices ({location})")
26654 library = identity.get("petsc_library")
26655 if library:
26656 flag = (" <- NOT the PETSc it was built against; load that PETSc's module, "
26657 "or rebuild under this one") if identity["petsc_library_mismatch"] else ""
26658 print(f" {'':<15} loads: {library}{flag}")
26659
26660
Here is the caller graph for this function:

◆ _require_clean_source_checkout()

None picurv_cli.core._require_clean_source_checkout ( str  action)
protected

Refuse version-changing Git operations in a dirty source checkout.

Parameters
[in]actionUser-facing action name.
Returns
None.

Definition at line 26661 of file core.py.

26661def _require_clean_source_checkout(action: str) -> None:
26662 """!
26663 @brief Refuse version-changing Git operations in a dirty source checkout.
26664 @param[in] action User-facing action name.
26665 @return None.
26666 """
26667 result = subprocess.run(
26668 ["git", "status", "--porcelain"], cwd=PACKAGE_PROJECT_ROOT,
26669 text=True, capture_output=True, check=False,
26670 )
26671 if result.returncode != 0:
26672 raise ValueError(f"{action}: cannot inspect the PICurv Git checkout.")
26673 if result.stdout.strip():
26674 raise ValueError(
26675 f"{action}: the PICurv source checkout has uncommitted changes. "
26676 "Commit or stash them before changing versions."
26677 )
26678
26679
Here is the caller graph for this function:

◆ _git_source_command()

subprocess.CompletedProcess picurv_cli.core._git_source_command ( list  arguments)
protected

Run a checked Git command against the active PICurv source checkout.

Parameters
[in]argumentsGit arguments excluding the executable.
Returns
Completed successful Git process.

Definition at line 26680 of file core.py.

26680def _git_source_command(arguments: list) -> subprocess.CompletedProcess:
26681 """!
26682 @brief Run a checked Git command against the active PICurv source checkout.
26683 @param[in] arguments Git arguments excluding the executable.
26684 @return Completed successful Git process.
26685 """
26686 result = subprocess.run(
26687 ["git", *arguments], cwd=PACKAGE_PROJECT_ROOT,
26688 text=True, capture_output=True, check=False,
26689 )
26690 if result.returncode != 0:
26691 raise ValueError((result.stderr or result.stdout or "Git command failed.").strip())
26692 return result
26693
26694
Here is the caller graph for this function:

◆ source_workflow()

picurv_cli.core.source_workflow (   args)

Fetch source history without silently changing the active code.

Parameters
[in]argsParsed source command arguments.
Returns
None.

Definition at line 26695 of file core.py.

26695def source_workflow(args):
26696 """!
26697 @brief Fetch source history without silently changing the active code.
26698 @param[in] args Parsed source command arguments.
26699 @return None.
26700 """
26701 if args.source_action != "update":
26702 raise ValueError(f"Unsupported source action: {args.source_action}")
26703 _git_source_command(["fetch", "--tags", "--prune", args.remote])
26704 print(f"[SUCCESS] Fetched branches and tags from {args.remote}; active checkout was not changed.")
26705
26706
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _workspace_requested_version()

picurv_cli.core._workspace_requested_version ( str  workspace_root)
protected

Resolve an exact version requested by an initialized workspace.

Parameters
[in]workspace_rootInitialized workspace root.
Returns
Exact version or tag text.

Definition at line 26707 of file core.py.

26707def _workspace_requested_version(workspace_root: str):
26708 """!
26709 @brief Resolve an exact version requested by an initialized workspace.
26710 @param[in] workspace_root Initialized workspace root.
26711 @return Exact version or tag text.
26712 """
26713 software = load_workspace_config(workspace_root).get("software") or {}
26714 requirement = software.get("picurv") if isinstance(software, dict) else None
26715 if not requirement:
26716 raise ValueError(
26717 f"{workspace_root}/{WORKSPACE_CONFIG_FILENAME} has no software.picurv pin; "
26718 "name a version explicitly."
26719 )
26720 if any(token in str(requirement) for token in "<>=!~,*"):
26721 raise ValueError(
26722 "Automatic activation needs an exact release/tag, not a version range. "
26723 f"Pass the desired version explicitly (workspace requires {requirement!r})."
26724 )
26725 return str(requirement)
26726
26727
Here is the call graph for this function:
Here is the caller graph for this function:

◆ versions_workflow()

picurv_cli.core.versions_workflow (   args)

List or activate a release using the existing source/build owners.

Parameters
[in]argsParsed versions command arguments.
Returns
None.

Definition at line 26728 of file core.py.

26728def versions_workflow(args):
26729 """!
26730 @brief List or activate a release using the existing source/build owners.
26731 @param[in] args Parsed versions command arguments.
26732 @return None.
26733 """
26734 action = args.versions_action
26735 if action == "list":
26736 result = _git_source_command(["tag", "--list", "--sort=-version:refname"])
26737 print(f"Active: {picurv_build()['build_id']}")
26738 tags = [line for line in result.stdout.splitlines() if line.strip()]
26739 if tags:
26740 print("Installed/available tags:")
26741 for tag in tags:
26742 print(f" {tag}")
26743 else:
26744 print("No version tags are present in this checkout.")
26745 return
26746 version = getattr(args, "version", None)
26747 make_args = list(getattr(args, "make_args", None) or [])
26748 if version and (_MAKE_ASSIGNMENT_PATTERN.match(str(version)) or str(version).startswith("-")):
26749 # `versions activate SYSTEM=cluster` and `versions activate -- -j8` bind the
26750 # make argument to the optional version positional. No tag or commit contains
26751 # '=' or starts with '-', so either is a make argument; handing '-j8' to git as
26752 # a ref printed git's usage instead.
26753 make_args.insert(0, str(version))
26754 version = None
26755 if make_args_include_explicit_goal(make_args):
26756 raise ValueError(
26757 f"versions {action} builds the default target; pass only make variables and "
26758 f"options (for example SYSTEM=cluster), not a target: {' '.join(make_args)}"
26759 )
26760 if action == "activate" and not version:
26761 workspace_root = os.path.abspath(args.workspace) if args.workspace else find_workspace_root(os.getcwd())
26762 if not workspace_root:
26763 raise ValueError("No workspace found and no version was named.")
26764 version = _workspace_requested_version(workspace_root)
26765 if not version:
26766 raise ValueError(f"versions {action}: name the tag or commit to install.")
26767 if action not in _VERSION_BUILD_ACTIONS:
26768 raise ValueError(f"Unsupported versions action: {action}")
26769 _require_clean_source_checkout(f"versions {action}")
26770 # There is one installation, and this rewrites it in place. Anything resolving
26771 # executables from it - other workspaces, and any running job that did not pin its
26772 # binaries - is changed by this too, so say what is about to move before it moves.
26773 print(
26774 f"[WARNING] {PACKAGE_PROJECT_ROOT} is a single shared installation. Building "
26775 f"{version!r} here re-points every workspace that resolves executables from it, "
26776 "including any job already running against them.",
26777 file=sys.stderr,
26778 )
26779 print(
26780 " Runs staged with --pin-executables keep their own copies and are unaffected; "
26781 "unpinned queued jobs stop at their job-start identity check.",
26782 file=sys.stderr,
26783 )
26784 _git_source_command(["fetch", "--tags", "origin"])
26785 ref = _resolve_version_ref(str(version))
26786 _git_source_command(["checkout", "--detach", ref])
26787 run_project_make(PACKAGE_PROJECT_ROOT, make_args)
26788 # picurv_build() was computed when this process started, before the checkout moved,
26789 # so the source identity the new binaries must match is read again here.
26790 source_identity = _source_build_identity(_read_release_version())
26791 problems = build_identity_problems(
26792 runtime_build_identities(source_identity), source_identity=source_identity
26793 )
26794 if problems:
26795 raise ValueError(
26796 f"Built {ref!r}, but the executables do not carry its identity "
26797 f"({source_identity['build_id']}):\n - " + "\n - ".join(problems)
26798 )
26799 print(f"[SUCCESS] Activated and built PICurv {ref} ({source_identity['build_id']}).")
26800
26801
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _resolve_version_ref()

str picurv_cli.core._resolve_version_ref ( str  version)
protected

Resolve a user-named version to a Git ref that exists in the source checkout.

Release tags are spelled v<release>, while VERSION and workspace software.picurv pins carry the bare release. The name is tried as given first, so a commit, branch, or already-prefixed tag is never rewritten.

Parameters
[in]versionTag, bare release, or commit named by the user or workspace pin.
Returns
Ref accepted by git checkout --detach.
Exceptions
ValueErrorwhen neither spelling resolves to a commit.

Definition at line 26802 of file core.py.

26802def _resolve_version_ref(version: str) -> str:
26803 """!
26804 @brief Resolve a user-named version to a Git ref that exists in the source checkout.
26805 @details Release tags are spelled `v<release>`, while `VERSION` and workspace
26806 `software.picurv` pins carry the bare release. The name is tried as given
26807 first, so a commit, branch, or already-prefixed tag is never rewritten.
26808 @param[in] version Tag, bare release, or commit named by the user or workspace pin.
26809 @return Ref accepted by `git checkout --detach`.
26810 @throws ValueError when neither spelling resolves to a commit.
26811 """
26812 candidates = [version]
26813 if not version.startswith("v"):
26814 candidates.append(f"v{version}")
26815 for candidate in candidates:
26816 result = subprocess.run(
26817 ["git", "rev-parse", "--verify", "--quiet", f"{candidate}^{{commit}}"],
26818 cwd=PACKAGE_PROJECT_ROOT, text=True, capture_output=True, check=False,
26819 )
26820 if result.returncode == 0:
26821 return candidate
26822 raise ValueError(
26823 f"No tag or commit named {' or '.join(repr(c) for c in candidates)} exists in "
26824 f"{PACKAGE_PROJECT_ROOT}; 'picurv versions list' shows the available tags."
26825 )
26826
26827
Here is the caller graph for this function:

◆ init_case()

picurv_cli.core.init_case (   args)

Implements the 'init' command.

Creates a new case study directory by copying a template. Runtime binaries are resolved from the project bin/ directory via PATH; pass –pin-binaries to pin specific versions locally.

Parameters
[in]argsThe command-line arguments parsed by argparse.

Definition at line 26828 of file core.py.

26828def init_case(args):
26829 """!
26830 @brief Implements the 'init' command.
26831 @details Creates a new case study directory by copying a template.
26832 Runtime binaries are resolved from the project bin/ directory
26833 via PATH; pass --pin-binaries to pin specific versions locally.
26834 @param[in] args The command-line arguments parsed by argparse.
26835 """
26836 context = resolve_case_origin_context(source_root_override=getattr(args, "source_root", None))
26837 try:
26838 source_project_root = require_project_root(context["source_project_root"], "init")
26839 template_path = resolve_template_directory(source_project_root, args.template_name)
26840 except ValueError as exc:
26841 print(f"[FATAL] {exc}", file=sys.stderr)
26842 sys.exit(1)
26843
26844 # The destination path is relative to the current working directory.
26845 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name if args.dest_name else args.template_name))
26846
26847 if os.path.exists(dest_path):
26848 print(f"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
26849 sys.exit(1)
26850
26851 print(f"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
26852
26853 try:
26854 workspace_layout = render_template_workspace(template_path, args.template_name, dest_path)
26855 template_managed_files = list_rendered_template_files(dest_path)
26856 print(f"[SUCCESS] Copied template files to: {dest_path}")
26857 print(f"[INFO] Wrote workspace identity: {os.path.relpath(workspace_layout['workspace_config'])}")
26858 if workspace_layout["canonical_roles"]:
26859 print("[INFO] Canonical editable configurations:")
26860 for role, path in sorted(workspace_layout["canonical_roles"].items()):
26861 print(f" - {role}: {os.path.relpath(path)}")
26862 except Exception as exc:
26863 print(f"[FATAL] Failed to create canonical workspace layout: {exc}", file=sys.stderr)
26864 shutil.rmtree(dest_path, ignore_errors=True)
26865 sys.exit(1)
26866
26867 try:
26868 runtime_result = ensure_case_runtime_execution_config(dest_path, source_project_root, overwrite=True)
26869 print(f"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
26870 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
26871 print(" Seeded from repo-local '.picurv-execution.yml'.")
26872 print(" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
26873 except Exception as e:
26874 print(f"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
26875
26876 try:
26877 metadata_path, _ = write_case_origin_metadata(
26878 dest_path,
26879 source_project_root,
26880 template_name=args.template_name,
26881 template_managed_files=template_managed_files,
26882 )
26883 print(f"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
26884 except Exception as e:
26885 print(f"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
26886
26887 cluster_profile_candidates = sorted(
26888 {
26889 os.path.basename(path)
26890 for pattern in ("*cluster*.yml", "*cluster*.yaml")
26891 for search_root in (dest_path, os.path.join(dest_path, "config"))
26892 for path in glob.glob(os.path.join(search_root, pattern))
26893 }
26894 )
26895 if cluster_profile_candidates:
26896 print("[INFO] Cluster profile sample(s) copied with this case:")
26897 for profile_name in cluster_profile_candidates:
26898 print(f" - {profile_name}")
26899 print(" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
26900
26901 if getattr(args, "pin_binaries", False):
26902 print("[INFO] Pinning runtime binaries into case directory...")
26903 try:
26904 copied_binaries = sync_case_binaries(dest_path, source_project_root)
26905 for dest_file_path in copied_binaries:
26906 print(f" - Pinned '{os.path.basename(dest_file_path)}'")
26907 print("[SUCCESS] Case directory is ready with pinned binaries.")
26908 print(" These local copies will be used instead of bin/ originals.")
26909 except ValueError as exc:
26910 print(f"[WARNING] {exc}", file=sys.stderr)
26911 print(" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
26912 else:
26913 print("[SUCCESS] Case directory is ready.")
26914 print(" Runtime binaries (simulator, postprocessor) are resolved from the active PICurv installation.")
26915 print(" Pin software.picurv in .picurv-workspace.yml only when this workspace needs a release constraint.")
26916 print(" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
26917
26918
Here is the call graph for this function:
Here is the caller graph for this function:

◆ sync_case_config_command()

picurv_cli.core.sync_case_config_command (   args)

Refresh template-managed config/docs files in a case directory.

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 26919 of file core.py.

26919def sync_case_config_command(args):
26920 """!
26921 @brief Refresh template-managed config/docs files in a case directory.
26922 @param[in] args Command-line style argument list supplied to the function.
26923 """
26924 try:
26925 context = resolve_case_origin_context(
26926 case_dir_hint=getattr(args, "case_dir", None),
26927 source_root_override=getattr(args, "source_root", None),
26928 template_name_override=getattr(args, "template_name", None),
26929 )
26930 source_project_root = require_project_root(context["source_project_root"], "sync-config")
26931 case_dir = require_existing_case_dir(context["case_dir"], "sync-config", source_project_root)
26932 template_name = context.get("template_name")
26933 template_dir = resolve_template_directory(source_project_root, template_name)
26934 existing_managed = context.get("metadata", {}).get("template_managed_files")
26935 if not isinstance(existing_managed, list):
26936 existing_managed = None
26937 summary = sync_case_template_files(
26938 case_dir,
26939 template_dir,
26940 overwrite=getattr(args, "overwrite", False),
26941 prune=getattr(args, "prune", False),
26942 managed_rel_paths=existing_managed,
26943 template_name=template_name,
26944 )
26945 metadata_path, _ = write_case_origin_metadata(
26946 case_dir,
26947 source_project_root,
26948 template_name=template_name,
26949 existing=context.get("metadata"),
26950 template_managed_files=summary["template_managed_files"],
26951 )
26952 runtime_result = ensure_case_runtime_execution_config(case_dir, source_project_root, overwrite=False)
26953 except ValueError as exc:
26954 print(f"[FATAL] {exc}", file=sys.stderr)
26955 sys.exit(1)
26956
26957 print(f"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
26958 print(f"[INFO] Copied new files : {len(summary['copied'])}")
26959 print(f"[INFO] Overwritten files : {len(summary['overwritten'])}")
26960 print(f"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
26961 print(f"[INFO] Already unchanged : {len(summary['unchanged'])}")
26962 print(f"[INFO] Pruned stale files : {len(summary['pruned'])}")
26963 if runtime_result["created"]:
26964 print(f"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
26965 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
26966 print("[INFO] Seed source : repo-local .picurv-execution.yml")
26967 if summary.get("prune_requested_without_tracking"):
26968 print("[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
26969 print(f"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
26970
26971
Here is the call graph for this function:
Here is the caller graph for this function:

◆ pull_source_repo()

picurv_cli.core.pull_source_repo (   args)

Refresh source branches in the repository resolved from a case directory.

Parameters
[in]argsCommand-line style argument list supplied to the function.

Definition at line 26972 of file core.py.

26972def pull_source_repo(args):
26973 """!
26974 @brief Refresh source branches in the repository resolved from a case directory.
26975 @param[in] args Command-line style argument list supplied to the function.
26976 """
26977 try:
26978 context = resolve_case_origin_context(
26979 case_dir_hint=getattr(args, "case_dir", None),
26980 source_root_override=getattr(args, "source_root", None),
26981 )
26982 source_project_root = require_project_root(context["source_project_root"], "pull-source")
26983 except ValueError as exc:
26984 print(f"[FATAL] {exc}", file=sys.stderr)
26985 sys.exit(1)
26986
26987 try:
26988 _refuse_detached_source_checkout(source_project_root, "pull-source")
26989 except ValueError as exc:
26990 print(f"[FATAL] {exc}", file=sys.stderr)
26991 sys.exit(1)
26992
26993 rebase = not getattr(args, "no_rebase", False)
26994 remote = getattr(args, "remote", None)
26995 branch = getattr(args, "branch", None)
26996 current_branch_only = (
26997 getattr(args, "current_branch_only", False)
26998 or remote is not None
26999 or branch is not None
27000 )
27001
27002 if not current_branch_only:
27003 pull_all_source_branches(source_project_root, "pull-source.log", rebase=rebase)
27004 return
27005
27006 command = ["git", "pull"]
27007 if rebase:
27008 command.append("--rebase")
27009 if remote:
27010 command.append(remote)
27011 if branch:
27012 command.append(branch)
27013 elif branch:
27014 command.extend(["origin", branch])
27015
27016 execute_command(command, source_project_root, "pull-source.log", {})
27017
Here is the call graph for this function:
Here is the caller graph for this function:

◆ build_project()

picurv_cli.core.build_project (   args)

Implements the 'build' command.

Executes the top-level Makefile directly, passing through any additional arguments to make. This allows for building, cleaning, and other Makefile targets via the orchestrator without maintaining a separate build wrapper script.

Parameters
[in]argsThe command-line arguments parsed by argparse.

Definition at line 27018 of file core.py.

27018def build_project(args):
27019 """!
27020 @brief Implements the 'build' command.
27021 @details Executes the top-level Makefile directly, passing through any
27022 additional arguments to `make`. This allows for building,
27023 cleaning, and other Makefile targets via the orchestrator
27024 without maintaining a separate build wrapper script.
27025 @param[in] args The command-line arguments parsed by argparse.
27026 """
27027
27028 print("\n" + "="*27 + " BUILD STAGE " + "="*27)
27029 try:
27030 context = resolve_case_origin_context(
27031 case_dir_hint=getattr(args, "case_dir", None),
27032 source_root_override=getattr(args, "source_root", None),
27033 )
27034 source_project_root = require_project_root(context["source_project_root"], "build")
27035 except ValueError as exc:
27036 print(f"[FATAL] {exc}", file=sys.stderr)
27037 sys.exit(1)
27038
27039 run_project_make(source_project_root, list(args.make_args or []))
27040
27041
Here is the call graph for this function:
Here is the caller graph for this function:

◆ run_project_make()

None picurv_cli.core.run_project_make ( str  source_project_root,
"list[str]"  make_args 
)

Run the project Makefile with caller-supplied arguments, defaulting to all.

Shared by picurv build and picurv versions install/activate, so a site setting such as SYSTEM=cluster reaches make the same way from both.

Parameters
[in]source_project_rootPICurv source checkout containing the Makefile.
[in]make_argsArguments passed through to make.
Returns
None. Exits non-zero when the Makefile is missing or make fails.

Definition at line 27042 of file core.py.

27042def run_project_make(source_project_root: str, make_args: "list[str]") -> None:
27043 """!
27044 @brief Run the project Makefile with caller-supplied arguments, defaulting to `all`.
27045 @details Shared by `picurv build` and `picurv versions install/activate`, so a site
27046 setting such as `SYSTEM=cluster` reaches `make` the same way from both.
27047 @param[in] source_project_root PICurv source checkout containing the Makefile.
27048 @param[in] make_args Arguments passed through to `make`.
27049 @return None. Exits non-zero when the Makefile is missing or `make` fails.
27050 """
27051 makefile_path = os.path.join(source_project_root, "Makefile")
27052
27053 if not os.path.isfile(makefile_path):
27054 print(f"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
27055 print(" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
27056 sys.exit(1)
27057
27058 if make_args_include_explicit_goal(make_args):
27059 command = ["make"] + make_args
27060 else:
27061 command = ["make", "all"] + make_args
27062 print("[INFO] No explicit make target supplied; defaulting to 'all'.")
27063 print(" Use 'picurv build clean-project ...' or another target when you want a non-build make action.")
27064 # For the build process, we don't have a monitor.yml, so we pass an empty
27065 # dict to execute_command. The command should be run in the project root.
27066 execute_command(command, source_project_root, "build.log", {})
27067
27068
27069
27070
27071# ==============================================================================
Here is the call graph for this function:
Here is the caller graph for this function:

Variable Documentation

◆ _package_parent

picurv_cli.core._package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
protected

Definition at line 64 of file core.py.

◆ _storage_module

picurv_cli.core._storage_module = importlib.import_module("picurv_cli.storage")
protected

Definition at line 67 of file core.py.

◆ StorageError

picurv_cli.core.StorageError = _storage_module.StorageError

Definition at line 68 of file core.py.

◆ cold_study_members

picurv_cli.core.cold_study_members = _storage_module.cold_study_members

Definition at line 69 of file core.py.

◆ restore_cold_study_members

picurv_cli.core.restore_cold_study_members = _storage_module.restore_cold_study_members

Definition at line 70 of file core.py.

◆ is_artifact_cold

picurv_cli.core.is_artifact_cold = _storage_module.is_artifact_cold

Definition at line 71 of file core.py.

◆ read_artifact_identity

picurv_cli.core.read_artifact_identity = _storage_module.read_artifact_identity

Definition at line 72 of file core.py.

◆ STORAGE_LOCK_FILENAME

picurv_cli.core.STORAGE_LOCK_FILENAME = _storage_module.STORAGE_LOCK_FILENAME

Definition at line 73 of file core.py.

◆ STORAGE_STATE_FILENAME

picurv_cli.core.STORAGE_STATE_FILENAME = _storage_module.STORAGE_STATE_FILENAME

Definition at line 74 of file core.py.

◆ require_storage_payload_local

picurv_cli.core.require_storage_payload_local = _storage_module.require_storage_payload_local

Definition at line 75 of file core.py.

◆ runtime_stage_lock

picurv_cli.core.runtime_stage_lock = _storage_module.runtime_stage_lock

Definition at line 76 of file core.py.

◆ storage_state_summary

picurv_cli.core.storage_state_summary = _storage_module.storage_state_summary

Definition at line 77 of file core.py.

◆ _rebase_restored_text_paths

picurv_cli.core._rebase_restored_text_paths = _storage_module._rebase_restored_text_paths
protected

Definition at line 78 of file core.py.

◆ _NUMPY_MODULE

picurv_cli.core._NUMPY_MODULE = None
protected

Definition at line 80 of file core.py.

◆ _MATPLOTLIB_PYPLOT

picurv_cli.core._MATPLOTLIB_PYPLOT = None
protected

Definition at line 81 of file core.py.

◆ np

picurv_cli.core.np = _LazyNumpyProxy()

Definition at line 98 of file core.py.

◆ PACKAGE_PATH

picurv_cli.core.PACKAGE_PATH = os.path.dirname(os.path.realpath(__file__))

Definition at line 188 of file core.py.

◆ PACKAGE_PROJECT_ROOT

picurv_cli.core.PACKAGE_PROJECT_ROOT = os.path.dirname(PACKAGE_PATH)

Definition at line 189 of file core.py.

◆ INVOKED_SCRIPT_DIR

picurv_cli.core.INVOKED_SCRIPT_DIR
Initial value:
1= os.environ.get(
2 "_PICURV_INVOKED_SCRIPT_DIR",
3 PACKAGE_PATH,
4)

Definition at line 190 of file core.py.

◆ SCRIPT_PATH

picurv_cli.core.SCRIPT_PATH
Initial value:
1= os.environ.get(
2 "_PICURV_SCRIPT_PATH",
3 PACKAGE_PATH,
4)

Definition at line 194 of file core.py.

◆ PROJECT_ROOT

picurv_cli.core.PROJECT_ROOT = os.path.dirname(SCRIPT_PATH)

Definition at line 198 of file core.py.

◆ GENERATORS_PATH

picurv_cli.core.GENERATORS_PATH = os.path.join(PACKAGE_PROJECT_ROOT, "generators")

Definition at line 199 of file core.py.

◆ DEFAULT_BIN_DIR

picurv_cli.core.DEFAULT_BIN_DIR = SCRIPT_PATH

Definition at line 201 of file core.py.

◆ VERSION_FILE

picurv_cli.core.VERSION_FILE = os.path.join(PACKAGE_PROJECT_ROOT, "VERSION")

Definition at line 205 of file core.py.

◆ PICURV_RELEASE_VERSION

str picurv_cli.core.PICURV_RELEASE_VERSION = _read_release_version()

Definition at line 301 of file core.py.

◆ CASE_ORIGIN_METADATA_FILENAME

str picurv_cli.core.CASE_ORIGIN_METADATA_FILENAME = ".picurv-origin.json"

Definition at line 302 of file core.py.

◆ WORKSPACE_CONFIG_FILENAME

str picurv_cli.core.WORKSPACE_CONFIG_FILENAME = ".picurv-workspace.yml"

Definition at line 303 of file core.py.

◆ WORKSPACE_SCHEMA_VERSION

int picurv_cli.core.WORKSPACE_SCHEMA_VERSION = 1

Definition at line 304 of file core.py.

◆ RUN_MANIFEST_SCHEMA_VERSION

int picurv_cli.core.RUN_MANIFEST_SCHEMA_VERSION = 3

Definition at line 305 of file core.py.

◆ ASSET_MANIFEST_SCHEMA_VERSION

int picurv_cli.core.ASSET_MANIFEST_SCHEMA_VERSION = 1

Definition at line 306 of file core.py.

◆ ASSET_LOCK_SCHEMA_VERSION

int picurv_cli.core.ASSET_LOCK_SCHEMA_VERSION = 1

Definition at line 307 of file core.py.

◆ RUNTIME_EXECUTION_CONFIG_FILENAME

str picurv_cli.core.RUNTIME_EXECUTION_CONFIG_FILENAME = ".picurv-execution.yml"

Definition at line 308 of file core.py.

◆ LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME

str picurv_cli.core.LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME = ".picurv-local.yml"

Definition at line 309 of file core.py.

◆ RUNTIME_EXECUTION_EXAMPLE_FILENAME

str picurv_cli.core.RUNTIME_EXECUTION_EXAMPLE_FILENAME = "execution.example.yml"

Definition at line 310 of file core.py.

◆ RUNTIME_EXECUTION_CONFIG_FILENAMES

tuple picurv_cli.core.RUNTIME_EXECUTION_CONFIG_FILENAMES
Initial value:
1= (
2 RUNTIME_EXECUTION_CONFIG_FILENAME,
3 LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME,
4)

Definition at line 311 of file core.py.

◆ DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE

str picurv_cli.core.DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE
Initial value:
1= """# Optional shared runtime launcher overrides.
2# This file is safe to leave unchanged on ordinary local machines.
3# Edit it only when your site needs custom MPI launcher tokens.
4#
5# Precedence:
6# - local/login-node runs: local_execution -> default_execution -> built-in mpiexec
7# - generated cluster jobs: cluster.yml.execution -> cluster_execution -> default_execution -> built-in srun
8#
9# Example override:
10# default_execution:
11# launcher: "mpirun"
12# launcher_args:
13# - --bind-to
14# - none
15default_execution: {}
16
17local_execution: {}
18
19cluster_execution: {}
20"""

Definition at line 316 of file core.py.

◆ CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT

str picurv_cli.core.CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT = "my_project_account"

Definition at line 337 of file core.py.

◆ CLUSTER_TEMPLATE_PLACEHOLDER_MAIL

str picurv_cli.core.CLUSTER_TEMPLATE_PLACEHOLDER_MAIL = "user@example.edu"

Definition at line 338 of file core.py.

◆ DEFAULT_WALLTIME_GUARD_POLICY

dict picurv_cli.core.DEFAULT_WALLTIME_GUARD_POLICY
Initial value:
1= {
2 "enabled": True,
3 "warmup_steps": 10,
4 "multiplier": 2.0,
5 "min_seconds": 60.0,
6 "estimator_alpha": 0.35,
7}

Definition at line 340 of file core.py.

◆ WALLTIME_GUARD_ENV_JOB_START_EPOCH

str picurv_cli.core.WALLTIME_GUARD_ENV_JOB_START_EPOCH = "PICURV_JOB_START_EPOCH"

Definition at line 347 of file core.py.

◆ WALLTIME_GUARD_ENV_LIMIT_SECONDS

str picurv_cli.core.WALLTIME_GUARD_ENV_LIMIT_SECONDS = "PICURV_WALLTIME_LIMIT_SECONDS"

Definition at line 348 of file core.py.

◆ POST_RESUME_STATE_FILENAME

str picurv_cli.core.POST_RESUME_STATE_FILENAME = "post.resume.json"

Definition at line 349 of file core.py.

◆ POST_LOCK_FILENAME

str picurv_cli.core.POST_LOCK_FILENAME = "post.lock"

Definition at line 350 of file core.py.

◆ POST_LOCK_METADATA_FILENAME

str picurv_cli.core.POST_LOCK_METADATA_FILENAME = "post.lock.json"

Definition at line 351 of file core.py.

◆ POST_LOCK_WRAPPER_FILENAME

str picurv_cli.core.POST_LOCK_WRAPPER_FILENAME = "post_lock_wrapper.py"

Definition at line 352 of file core.py.

◆ POST_RESUME_SCHEMA_VERSION

int picurv_cli.core.POST_RESUME_SCHEMA_VERSION = 2

Definition at line 353 of file core.py.

◆ POST_RECIPE_SIGNATURE_EXCLUDED_KEYS

dict picurv_cli.core.POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {"startTime", "endTime", "timeStep", "source_directory"}

Definition at line 356 of file core.py.

◆ CHECKPOINT_FORMAT

str picurv_cli.core.CHECKPOINT_FORMAT = "picurv-checkpoint"

Definition at line 357 of file core.py.

◆ CHECKPOINT_VERSION

int picurv_cli.core.CHECKPOINT_VERSION = 1

Definition at line 358 of file core.py.

◆ CHECKPOINT_STEP_WIDTH

int picurv_cli.core.CHECKPOINT_STEP_WIDTH = 12

Definition at line 359 of file core.py.

◆ CHECKPOINT_REQUIRED_EULERIAN_FIELDS

dict picurv_cli.core.CHECKPOINT_REQUIRED_EULERIAN_FIELDS = {"Ucat", "Ucont", "Ucont_rm1", "P", "Nvert"}

Definition at line 360 of file core.py.

◆ WORKSPACE_DIRECTORY_LAYOUT

tuple picurv_cli.core.WORKSPACE_DIRECTORY_LAYOUT
Initial value:
1= (
2 "config",
3 "config/studies",
4 "config/grids",
5 "config/initial_conditions",
6 "config/inlet_profiles",
7 "inputs",
8 "inputs/grids",
9 "inputs/initial_conditions",
10 "inputs/inlet_profiles",
11 "inputs/reference_fields",
12 "assets",
13 "assets/objects",
14 "assets/objects/grids",
15 "assets/objects/initial_conditions",
16 "assets/objects/inlet_profiles",
17 "assets/sets",
18 "runs",
19 "studies",
20)

Definition at line 362 of file core.py.

◆ RUN_DIRECTORY_LAYOUT

tuple picurv_cli.core.RUN_DIRECTORY_LAYOUT
Initial value:
1= (
2 "config",
3 "config/history",
4 "config/post-recipes",
5 "inputs",
6 "inputs/grid",
7 "inputs/initial_condition",
8 "inputs/inlet_profiles",
9 "inputs/restart",
10 "output",
11 "output/checkpoints",
12 "output/analysis",
13 "output/analysis/metrics",
14 "output/analysis/statistics",
15 "output/analysis/spectra",
16 "output/analysis/plots",
17 "output/visualization",
18 "logs",
19 "scheduler",
20)

Definition at line 383 of file core.py.

◆ CANONICAL_RUN_PATHS

dict picurv_cli.core.CANONICAL_RUN_PATHS
Initial value:
1= {
2 "config": "config",
3 "inputs": "inputs",
4 "restart": "inputs/restart",
5 "output": "output",
6 "checkpoints": "output/checkpoints",
7 "analysis": "output/analysis",
8 "metrics": "output/analysis/metrics",
9 "statistics": "output/analysis/statistics",
10 "spectra": "output/analysis/spectra",
11 "plots": "output/analysis/plots",
12 "visualization": "output/visualization",
13 "logs": "logs",
14 "scheduler": "scheduler",
15}

Definition at line 404 of file core.py.

◆ INITIAL_CONDITION_SPECTRUM_RELPATH

picurv_cli.core.INITIAL_CONDITION_SPECTRUM_RELPATH
Initial value:
1= os.path.join(
2 CANONICAL_RUN_PATHS["spectra"], "initial_condition_spectrum.csv"
3)

Definition at line 422 of file core.py.

◆ _WORKSPACE_ARTIFACT_ROOT_VALUES

dict picurv_cli.core._WORKSPACE_ARTIFACT_ROOT_VALUES = {"runs", "studies"}
protected

Definition at line 426 of file core.py.

◆ _ASSET_SOURCE_REFERENCE_KEYS

dict picurv_cli.core._ASSET_SOURCE_REFERENCE_KEYS
protected
Initial value:
1= {
2 "source_file", "path", "config_file", "field_file", "grid_file", "source_case", "script"
3}

Definition at line 427 of file core.py.

◆ _PYTHON_INITIAL_CONDITION_PROVIDERS

dict picurv_cli.core._PYTHON_INITIAL_CONDITION_PROVIDERS
protected
Initial value:
1= {"ic_gen", "spectral_random_velocity", "resampled_velocity",
2 "channel_spectral_velocity", "duct_spectral_velocity",
3 "stability_mode_velocity"}

Definition at line 430 of file core.py.

◆ SPECTRAL_IC_PROVIDERS

tuple picurv_cli.core.SPECTRAL_IC_PROVIDERS
Initial value:
1= ("spectral_random_velocity", "resampled_velocity",
2 "channel_spectral_velocity", "duct_spectral_velocity", "stability_mode_velocity")

Definition at line 434 of file core.py.

◆ SHELL_SPECTRUM_IC_PROVIDERS

tuple picurv_cli.core.SHELL_SPECTRUM_IC_PROVIDERS = ("spectral_random_velocity", "resampled_velocity")

Definition at line 437 of file core.py.

◆ SPECTRAL_RANDOM_VELOCITY_PARAMS

picurv_cli.core.SPECTRAL_RANDOM_VELOCITY_PARAMS
Initial value:
1= frozenset(
2 {"field", "seed", "random", "spectrum", "projection", "normalization", "remove_mean"}
3)

Definition at line 439 of file core.py.

◆ RESAMPLED_VELOCITY_PARAMS

picurv_cli.core.RESAMPLED_VELOCITY_PARAMS
Initial value:
1= frozenset(
2 {"field", "source_file", "format", "cells", "box_length", "sample_offset", "layout", "filter",
3 "projection", "remove_mean"}
4)

Definition at line 443 of file core.py.

◆ RESAMPLED_VELOCITY_FASTEST_AXES

tuple picurv_cli.core.RESAMPLED_VELOCITY_FASTEST_AXES = ("x", "z")

Definition at line 448 of file core.py.

◆ RESAMPLED_VELOCITY_COMPONENT_LAYOUTS

tuple picurv_cli.core.RESAMPLED_VELOCITY_COMPONENT_LAYOUTS = ("interleaved", "separate")

Definition at line 449 of file core.py.

◆ RESAMPLED_VELOCITY_RAW_DTYPES

tuple picurv_cli.core.RESAMPLED_VELOCITY_RAW_DTYPES = ("float32", "float64")

Definition at line 450 of file core.py.

◆ RESAMPLED_VELOCITY_BYTE_ORDERS

tuple picurv_cli.core.RESAMPLED_VELOCITY_BYTE_ORDERS = ("little", "big", "native")

Definition at line 451 of file core.py.

◆ RESAMPLED_VELOCITY_PROJECTIONS

tuple picurv_cli.core.RESAMPLED_VELOCITY_PROJECTIONS = ("picurv_discrete", "continuum", "none")

Definition at line 452 of file core.py.

◆ RESAMPLED_VELOCITY_LAYOUT_KEYS

dict picurv_cli.core.RESAMPLED_VELOCITY_LAYOUT_KEYS
Initial value:
1= {
2 "raw": {"fastest_axis", "components", "dtype", "byte_order", "header_bytes"},
3 "npy": {"fastest_axis", "components"},
4 "npz": {"fastest_axis", "components", "datasets"},
5 "hdf5": {"fastest_axis", "components", "datasets"},
6}

Definition at line 454 of file core.py.

◆ _FILE_BACKED_GRID_VALUES

dict picurv_cli.core._FILE_BACKED_GRID_VALUES = {"file", "grid_gen"}
protected

Definition at line 460 of file core.py.

◆ _WORKSPACE_MANAGED_PATHS

dict picurv_cli.core._WORKSPACE_MANAGED_PATHS = {"assets", "inputs", "runs", "studies"}
protected

Definition at line 461 of file core.py.

◆ _VENDORABLE_CONFIG_REFERENCE_KEYS

dict picurv_cli.core._VENDORABLE_CONFIG_REFERENCE_KEYS = {"config_file", "script"}
protected

Definition at line 462 of file core.py.

◆ _PLAIN_FILENAME_SENTINELS

dict picurv_cli.core._PLAIN_FILENAME_SENTINELS = {"", ".", ".."}
protected

Definition at line 463 of file core.py.

◆ _VERSION_BUILD_ACTIONS

dict picurv_cli.core._VERSION_BUILD_ACTIONS = {"install", "activate"}
protected

Definition at line 464 of file core.py.

◆ RUN_DIRECTORY_ROOTS

picurv_cli.core.RUN_DIRECTORY_ROOTS
Initial value:
1= frozenset(
2 relative.split("/")[0] for relative in RUN_DIRECTORY_LAYOUT
3)

Definition at line 707 of file core.py.

◆ RUN_ROOT_ALLOWED_FILES

picurv_cli.core.RUN_ROOT_ALLOWED_FILES = frozenset({"manifest.json"})

Definition at line 713 of file core.py.

◆ CONTINUATION_STAGED_RECORDS

tuple picurv_cli.core.CONTINUATION_STAGED_RECORDS
Initial value:
1= ("config/active.json", "manifest.json",
2 "inputs/assets.lock.yml", "inputs/software.lock.json")

Definition at line 1032 of file core.py.

◆ RUN_EXECUTABLE_NAMES

tuple picurv_cli.core.RUN_EXECUTABLE_NAMES = ("simulator", "postprocessor")

Definition at line 1104 of file core.py.

◆ STORAGE_OFFLOADED_STATES

picurv_cli.core.STORAGE_OFFLOADED_STATES = frozenset({"COLD", "PARTIAL"})

Definition at line 1312 of file core.py.

◆ RUN_COMPONENT_STATES

tuple picurv_cli.core.RUN_COMPONENT_STATES
Initial value:
1= (
2 "not_requested", "planned", "running", "partial", "complete", "failed", "offloaded",
3)

Definition at line 1315 of file core.py.

◆ RUN_COMPONENT_LAYOUT

tuple picurv_cli.core.RUN_COMPONENT_LAYOUT
Initial value:
1= (
2 ("configuration", "config", "essential"),
3 ("inputs", "inputs", "essential"),
4 ("checkpoints", "output/checkpoints", "policy"),
5 ("analysis", "output/analysis", "derived"),
6 ("field_statistics", "output/analysis/statistics", "derived"),
7 ("spectra", "output/analysis/spectra", "derived"),
8 ("visualization", "output/visualization", "derived"),
9 ("logs", "logs", "essential"),
10 ("scheduler", "scheduler", "essential"),
11)

Definition at line 1320 of file core.py.

◆ _SELECTED_PETSC_INDEX_BYTES

picurv_cli.core._SELECTED_PETSC_INDEX_BYTES = None
protected

Definition at line 1832 of file core.py.

◆ _BINARY_VERSION_PATTERN

picurv_cli.core._BINARY_VERSION_PATTERN
protected
Initial value:
1= re.compile(
2 r"^(?P<name>\S+)\s+(?P<version>(?P<release>[^+\s]+?)(?:\.dev\d+)?)\+g(?P<commit>[0-9a-f]+)(?P<dirty>\.dirty)?\s*$"
3)

Definition at line 1853 of file core.py.

◆ _BINARY_PETSC_PATTERN

picurv_cli.core._BINARY_PETSC_PATTERN
protected
Initial value:
1= re.compile(
2 r"^petsc (?P<version>\S+) (?P<mode>debug|optimized)(?: (?P<indices>int32|int64))? (?P<arch>\S*) (?P<dir>.*)$"
3)

Definition at line 1857 of file core.py.

◆ PETSC_INDEX_BYTES

dict picurv_cli.core.PETSC_INDEX_BYTES = {"int32": 4, "int64": 8}

Definition at line 1861 of file core.py.

◆ _BINARY_PETSC_STAMP_MARKER

str picurv_cli.core._BINARY_PETSC_STAMP_MARKER = b"PICURV_PETSC_BUILD:"
protected

Definition at line 1863 of file core.py.

◆ _BINARY_BUILD_STAMP_MARKER

str picurv_cli.core._BINARY_BUILD_STAMP_MARKER = b"PICURV_BUILD:"
protected

Definition at line 1865 of file core.py.

◆ ERROR_CODE_CLI_USAGE_INVALID

str picurv_cli.core.ERROR_CODE_CLI_USAGE_INVALID = "CLI_USAGE_INVALID"

Definition at line 2181 of file core.py.

◆ ERROR_CODE_CFG_MISSING_SECTION

str picurv_cli.core.ERROR_CODE_CFG_MISSING_SECTION = "CFG_MISSING_SECTION"

Definition at line 2182 of file core.py.

◆ ERROR_CODE_CFG_MISSING_KEY

str picurv_cli.core.ERROR_CODE_CFG_MISSING_KEY = "CFG_MISSING_KEY"

Definition at line 2183 of file core.py.

◆ ERROR_CODE_CFG_INVALID_TYPE

str picurv_cli.core.ERROR_CODE_CFG_INVALID_TYPE = "CFG_INVALID_TYPE"

Definition at line 2184 of file core.py.

◆ ERROR_CODE_CFG_INVALID_VALUE

str picurv_cli.core.ERROR_CODE_CFG_INVALID_VALUE = "CFG_INVALID_VALUE"

Definition at line 2185 of file core.py.

◆ ERROR_CODE_CFG_FILE_NOT_FOUND

str picurv_cli.core.ERROR_CODE_CFG_FILE_NOT_FOUND = "CFG_FILE_NOT_FOUND"

Definition at line 2186 of file core.py.

◆ ERROR_CODE_CFG_GRID_PARSE

str picurv_cli.core.ERROR_CODE_CFG_GRID_PARSE = "CFG_GRID_PARSE"

Definition at line 2187 of file core.py.

◆ ERROR_CODE_CFG_INCONSISTENT_COMBO

str picurv_cli.core.ERROR_CODE_CFG_INCONSISTENT_COMBO = "CFG_INCONSISTENT_COMBO"

Definition at line 2188 of file core.py.

◆ ERROR_CODE_DEPENDENCY_MISSING

str picurv_cli.core.ERROR_CODE_DEPENDENCY_MISSING = "DEPENDENCY_MISSING"

Definition at line 2189 of file core.py.

◆ RESTART_RUN_DIR_REQUIRED_MESSAGE

tuple picurv_cli.core.RESTART_RUN_DIR_REQUIRED_MESSAGE
Initial value:
1= (
2 "--continue and --restart-from both require an existing run directory. "
3 "Use --continue --run-dir <run_dir> to resume in place, or "
4 "--restart-from <run_dir> to create a new run."
5)

Definition at line 2191 of file core.py.

◆ _ERROR_HINTS

dict picurv_cli.core._ERROR_HINTS
protected
Initial value:
1= {
2 ERROR_CODE_CLI_USAGE_INVALID: "Run 'picurv <command> --help' to see valid argument combinations.",
3 ERROR_CODE_CFG_MISSING_SECTION: "Add the missing section using examples/master_template/*.yml as reference.",
4 ERROR_CODE_CFG_MISSING_KEY: "Add the missing key in the referenced YAML file.",
5 ERROR_CODE_CFG_INVALID_TYPE: "Fix the value type to match the documented schema in docs/pages/14_Config_Contract.md.",
6 ERROR_CODE_CFG_INVALID_VALUE: "Adjust the value to a supported range/enum from the config reference pages.",
7 ERROR_CODE_CFG_FILE_NOT_FOUND: "Fix the path or create the missing file before running again.",
8 ERROR_CODE_CFG_GRID_PARSE: "Validate grid file format and numeric payload (block count, dims, coordinates).",
9 ERROR_CODE_CFG_INCONSISTENT_COMBO: "Fix conflicting options/keys so the configuration is internally consistent.",
10 ERROR_CODE_DEPENDENCY_MISSING: "Install the named optional dependency for the Python interpreter used by picurv.",
11}

Definition at line 2197 of file core.py.

◆ _RESTART_HINT

tuple picurv_cli.core._RESTART_HINT
protected
Initial value:
1= ("Check --restart-from, --continue and --run-dir against the source run's committed "
2 "checkpoints and run_control.start_step in case.yml.")

Definition at line 2209 of file core.py.

◆ _STORAGE_RESTORE_HINT

tuple picurv_cli.core._STORAGE_RESTORE_HINT
protected
Initial value:
1= ("Run the restore command in the message; 'picurv storage status --run-dir "
2 "<run>' shows what is local.")

Definition at line 2212 of file core.py.

◆ _STUDY_DIR_HINT

str picurv_cli.core._STUDY_DIR_HINT = "Pass a study directory that 'picurv sweep' created (studies/<study_id>)."
protected

Definition at line 2215 of file core.py.

◆ _MAKE_ASSIGNMENT_PATTERN

picurv_cli.core._MAKE_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*[:+?]?=.*$")
protected

Definition at line 2814 of file core.py.

◆ POST_RUN_CONTROL_ALIASES

dict picurv_cli.core.POST_RUN_CONTROL_ALIASES
Initial value:
1= {
2 "start_step": ("start_step", "startTime"),
3 "end_step": ("end_step", "endTime"),
4 "step_interval": ("step_interval", "timeStep"),
5}

Definition at line 3289 of file core.py.

◆ GRID_GENERATOR_HYPHEN_KEY_HINTS

dict picurv_cli.core.GRID_GENERATOR_HYPHEN_KEY_HINTS
Initial value:
1= {
2 "config-file": "config_file",
3 "grid-type": "grid_type",
4 "cli-args": "cli_args",
5}

Definition at line 3296 of file core.py.

◆ RETIRED_GENERATOR_DESTINATION_KEYS

tuple picurv_cli.core.RETIRED_GENERATOR_DESTINATION_KEYS
Initial value:
1= ("output_file", "stats_file", "vts_file",
2 "output-file", "stats-file", "vts-file")

Definition at line 3305 of file core.py.

◆ RETIRED_INITIAL_CONDITION_DESTINATION_KEYS

tuple picurv_cli.core.RETIRED_INITIAL_CONDITION_DESTINATION_KEYS = ("output_file", "summary_json", "spectrum_csv")

Definition at line 3356 of file core.py.

◆ POST_SPECTRA_TASKS

dict picurv_cli.core.POST_SPECTRA_TASKS
Initial value:
1= {
2 "plane_spectrum": {"requires_single_block": True, "fields": ("Ucat",)},
3 "line_spectrum": {"requires_single_block": True, "fields": ("Ucat",)},
4 "shell_spectrum": {
5 "requires_uniform_cartesian": True,
6 "requires_periodic_geometric": True,
7 "requires_single_block": True,
8 "fields": ("Ucat",),
9 },
10}

Definition at line 3542 of file core.py.

◆ POST_SPECTRA_SYMBOLS

tuple picurv_cli.core.POST_SPECTRA_SYMBOLS = ("continuum", "discrete")

Definition at line 3554 of file core.py.

◆ POST_SPECTRA_MEAN_MODES

tuple picurv_cli.core.POST_SPECTRA_MEAN_MODES = ("none", "domain", "sample")

Definition at line 3558 of file core.py.

◆ POST_FIELD_STATISTICS_OUTPUTS

tuple picurv_cli.core.POST_FIELD_STATISTICS_OUTPUTS = ("mean", "reynolds_stress", "rms", "tke", "flux", "point_variance")

Definition at line 3562 of file core.py.

◆ POST_FIELD_STATISTICS_DEFAULT_OUTPUTS

tuple picurv_cli.core.POST_FIELD_STATISTICS_DEFAULT_OUTPUTS = POST_FIELD_STATISTICS_OUTPUTS[:-1]

Definition at line 3565 of file core.py.

◆ POST_FIELD_STATISTICS_FORMATS

tuple picurv_cli.core.POST_FIELD_STATISTICS_FORMATS = ("vtk", "csv")

Definition at line 3569 of file core.py.

◆ GRID_MODES

tuple picurv_cli.core.GRID_MODES = ("file", "programmatic_c", "grid_gen")

Definition at line 3581 of file core.py.

◆ DOMAIN_DIMENSIONALITY_MODES

tuple picurv_cli.core.DOMAIN_DIMENSIONALITY_MODES = ("3D", "2D")

Definition at line 3585 of file core.py.

◆ GRID_GENERATOR_TYPES

tuple picurv_cli.core.GRID_GENERATOR_TYPES = ("box", "sweep")

Definition at line 3588 of file core.py.

◆ GRID_CROSS_SECTION_KINDS

tuple picurv_cli.core.GRID_CROSS_SECTION_KINDS = ("rectangle", "circle")

Definition at line 3594 of file core.py.

◆ GRID_WALL_SEGMENT_KINDS

tuple picurv_cli.core.GRID_WALL_SEGMENT_KINDS = ("flat", "step", "ramp", "arc", "sine", "gaussian", "hill")

Definition at line 3598 of file core.py.

◆ GRID_PATH_SEGMENT_KINDS

tuple picurv_cli.core.GRID_PATH_SEGMENT_KINDS = ("straight", "arc")

Definition at line 3601 of file core.py.

◆ GRID_TRANSFORM_KINDS

tuple picurv_cli.core.GRID_TRANSFORM_KINDS = ("anchor", "translate", "scale", "rotate", "mirror", "permute", "reverse")

Definition at line 3604 of file core.py.

◆ PARTICLE_RESTART_MODES

tuple picurv_cli.core.PARTICLE_RESTART_MODES = ("init", "load")

Definition at line 3607 of file core.py.

◆ POST_EULERIAN_PIPELINE_TASKS

tuple picurv_cli.core.POST_EULERIAN_PIPELINE_TASKS = ("q_criterion", "normalize_field", "nodal_average")

Definition at line 3610 of file core.py.

◆ POST_LAGRANGIAN_PIPELINE_TASKS

tuple picurv_cli.core.POST_LAGRANGIAN_PIPELINE_TASKS = ("specific_ke",)

Definition at line 3613 of file core.py.

◆ STUDY_TYPES

tuple picurv_cli.core.STUDY_TYPES = ("grid_independence", "timestep_independence", "sensitivity")

Definition at line 3616 of file core.py.

◆ NEWTON_KRYLOV_PRECONDITIONER_MODELS

tuple picurv_cli.core.NEWTON_KRYLOV_PRECONDITIONER_MODELS = ("none", "frozen_momentum_jacobian")

Definition at line 3619 of file core.py.

◆ NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES

tuple picurv_cli.core.NEWTON_KRYLOV_PRECONDITIONER_STRUCTURES = ("none", "point_block")

Definition at line 3624 of file core.py.

◆ POISSON_PRECONDITIONER_TYPES

tuple picurv_cli.core.POISSON_PRECONDITIONER_TYPES = ("multigrid",)

Definition at line 3628 of file core.py.

◆ POISSON_PRECONDITIONER_SPELLINGS

dict picurv_cli.core.POISSON_PRECONDITIONER_SPELLINGS = {"mg": "multigrid", "pcmg": "multigrid"}

Definition at line 3631 of file core.py.

◆ PRESCRIBED_FLOW_SOURCE_TYPES

tuple picurv_cli.core.PRESCRIBED_FLOW_SOURCE_TYPES = ("file", "generated", "field_slice")

Definition at line 3637 of file core.py.

◆ VERIFICATION_SCALAR_PROFILES

tuple picurv_cli.core.VERIFICATION_SCALAR_PROFILES = ("CONSTANT", "LINEAR_X", "SIN_PRODUCT")

Definition at line 3640 of file core.py.

◆ SCALAR_TRANSPORT_FLAGS

dict picurv_cli.core.SCALAR_TRANSPORT_FLAGS
Initial value:
1= {
2 "schmidt_number": "-schmidt_number",
3 "turbulent_schmidt_number": "-turb_schmidt_number",
4 "iem_constant": "-iem_constant",
5}

Definition at line 3646 of file core.py.

◆ SCALAR_TRANSPORT_CASE_PATHS

dict picurv_cli.core.SCALAR_TRANSPORT_CASE_PATHS
Initial value:
1= {
2 "schmidt_number": ("properties", "fluid"),
3 "turbulent_schmidt_number": ("models", "physics", "turbulence"),
4 "iem_constant": ("models", "physics", "particles"),
5}

Definition at line 3655 of file core.py.

◆ STUDY_PLOT_FORMATS

tuple picurv_cli.core.STUDY_PLOT_FORMATS = ("png", "pdf", "svg")

Definition at line 3709 of file core.py.

◆ PROFILING_TIMESTEP_MODES

tuple picurv_cli.core.PROFILING_TIMESTEP_MODES = ("off", "selected", "all")

Definition at line 3712 of file core.py.

◆ RENAMED_MONITOR_FUNCTIONS

dict picurv_cli.core.RENAMED_MONITOR_FUNCTIONS
Initial value:
1= {
2 "PoissonSolver_MG": "PoissonSolver_Multigrid",
3 "Projection": "ProjectVelocity",
4 "PoissonLHSNew": "AssemblePoissonOperator",
5 "PoissonRHS": "ComputePoissonRHS",
6}

Definition at line 3716 of file core.py.

◆ REMOVED_MONITOR_FUNCTIONS

tuple picurv_cli.core.REMOVED_MONITOR_FUNCTIONS
Initial value:
1= (
2 "CorrectChannelFluxProfile", "VolumeFlux", "VolumeFlux_rev", "FullyBlocked",
3 "MyNvertRestriction", "MyRestriction", "MyInterpolation", "PoissonNullSpaceFunction",
4 "GridRestriction", "SetupSolverParameters",
5)

Definition at line 3724 of file core.py.

◆ GMRES_RESTART_METHODS

tuple picurv_cli.core.GMRES_RESTART_METHODS = ("gmres", "fgmres", "lgmres")

Definition at line 3731 of file core.py.

◆ POISSON_KSP_METHODS

dict picurv_cli.core.POISSON_KSP_METHODS
Initial value:
1= {
2 "fgmres": {},
3 "cg": {"-ps_ksp_norm_type": "unpreconditioned"},
4}

Definition at line 3740 of file core.py.

◆ POISSON_KSP_REFUSED

dict picurv_cli.core.POISSON_KSP_REFUSED
Initial value:
1= {
2 "gmres": "is not flexible",
3 "lgmres": "is not flexible",
4 "bcgs": "is not flexible",
5}

Definition at line 3746 of file core.py.

◆ ANALYTICAL_SOLUTION_TYPES

tuple picurv_cli.core.ANALYTICAL_SOLUTION_TYPES = ("TGV3D", "ZERO_FLOW", "UNIFORM_FLOW")

Definition at line 3753 of file core.py.

◆ LEGACY_FIELD_INIT_SPELLINGS

tuple picurv_cli.core.LEGACY_FIELD_INIT_SPELLINGS = ("Zero", "Constant", "Poiseuille")

Definition at line 3757 of file core.py.

◆ PROJECTION_OPERATORS

tuple picurv_cli.core.PROJECTION_OPERATORS = ("continuum", "picurv_discrete")

Definition at line 3760 of file core.py.

◆ METRIC_SOURCE_KINDS

tuple picurv_cli.core.METRIC_SOURCE_KINDS = ("statistics_csv", "csv", "log_regex", "log")

Definition at line 3763 of file core.py.

◆ LAUNCH_MODES

tuple picurv_cli.core.LAUNCH_MODES = ("local", "cluster")

Definition at line 3766 of file core.py.

◆ POST_SPECTRA_SCALAR_COLUMNS

tuple picurv_cli.core.POST_SPECTRA_SCALAR_COLUMNS
Initial value:
1= (
2 "resolved_kinetic_energy", "spectrum_total_energy", "parseval_residual",
3 "spectrum_peak_k", "zero_mode_energy", "integral_length_scale",
4 "taylor_microscale", "dissipation_over_viscosity",
5)

Definition at line 4023 of file core.py.

◆ POST_STAGE_NAMES

tuple picurv_cli.core.POST_STAGE_NAMES = ("fields", "spectra", "reducers")

Definition at line 4031 of file core.py.

◆ POST_REDUCER_TASKS

dict picurv_cli.core.POST_REDUCER_TASKS
Initial value:
1= {
2 "wall_profile": {"command": "wall-profile", "retains": 1, "axis_key": "wall_axis", "default_axis": "j"},
3 "cross_section": {"command": "cross-section", "retains": 2, "axis_key": "stream_axis", "default_axis": "k"},
4 "profile_series": {"command": "profile-series", "retains": 1, "axis_key": None, "default_axis": None},
5}

Definition at line 4242 of file core.py.

◆ POST_REDUCER_FRICTION_SOURCES

dict picurv_cli.core.POST_REDUCER_FRICTION_SOURCES
Initial value:
1= {
2 "first_cell": {"needs": None},
3 "driven_flow": {"needs": "window"},
4 "wall_model": {"needs": "window"},
5 "body_force": {"needs": "value"},
6 "u_tau": {"needs": "value"},
7}

Definition at line 4251 of file core.py.

◆ _REDUCER_AXIS_TOKENS

dict picurv_cli.core._REDUCER_AXIS_TOKENS = {"i": "Xi", "j": "Eta", "k": "Zeta"}
protected

Definition at line 4260 of file core.py.

◆ POST_PARAVIEW_SERIES_SCOPES

dict picurv_cli.core.POST_PARAVIEW_SERIES_SCOPES = {"run", "lineage"}

Definition at line 5069 of file core.py.

◆ POST_PROCESS_REASON_LABELS

dict picurv_cli.core.POST_PROCESS_REASON_LABELS
Initial value:
1= {
2 "missing": "no output yet",
3 "recipe": "recipe changed",
4 "checkpoint": "checkpoint changed",
5 "recompute": "--recompute",
6}

Definition at line 6291 of file core.py.

◆ PICGRID_FLOAT_FORMAT

str picurv_cli.core.PICGRID_FLOAT_FORMAT = ".17e"

Definition at line 7143 of file core.py.

◆ GENERATED_PROFILE_GENERATORS

dict picurv_cli.core.GENERATED_PROFILE_GENERATORS = {"square_duct_poiseuille"}

Definition at line 7486 of file core.py.

◆ GRID_GENERATOR_CHOICE_FLAGS

dict picurv_cli.core.GRID_GENERATOR_CHOICE_FLAGS
Initial value:
1= {
2 "--cross-section": GRID_CROSS_SECTION_KINDS,
3}

Definition at line 7909 of file core.py.

◆ GRID_GENERATOR_SPEC_FLAGS

dict picurv_cli.core.GRID_GENERATOR_SPEC_FLAGS
Initial value:
1= {
2 "--wall-j-lo": GRID_WALL_SEGMENT_KINDS,
3 "--wall-j-hi": GRID_WALL_SEGMENT_KINDS,
4 "--wall-j-lo-span": GRID_WALL_SEGMENT_KINDS,
5 "--wall-j-hi-span": GRID_WALL_SEGMENT_KINDS,
6 "--cross-section-scale": GRID_WALL_SEGMENT_KINDS,
7 "--path": GRID_PATH_SEGMENT_KINDS,
8 "--transforms": GRID_TRANSFORM_KINDS,
9}

Definition at line 7914 of file core.py.

◆ BC_FACE_MAP

dict picurv_cli.core.BC_FACE_MAP
Initial value:
1= {
2 "-xi": "-Xi",
3 "+xi": "+Xi",
4 "-eta": "-Eta",
5 "+eta": "+Eta",
6 "-zeta": "-Zeta",
7 "+zeta": "+Zeta",
8}

Definition at line 8065 of file core.py.

◆ BC_TYPE_MAP

dict picurv_cli.core.BC_TYPE_MAP
Initial value:
1= {
2 "wall": "WALL",
3 "inlet": "INLET",
4 "outlet": "OUTLET",
5 "periodic": "PERIODIC",
6}

Definition at line 8075 of file core.py.

◆ PARTICLE_BOUNDARY_POLICIES

tuple picurv_cli.core.PARTICLE_BOUNDARY_POLICIES = ("reflect", "remove", "wrap")

Definition at line 8085 of file core.py.

◆ BC_HANDLER_SPECS

dict picurv_cli.core.BC_HANDLER_SPECS

Definition at line 8087 of file core.py.

◆ _NUMERIC_BC_PARAMS

dict picurv_cli.core._NUMERIC_BC_PARAMS = {"vx", "vy", "vz", "v_max", "target_flux"}
protected

Definition at line 8147 of file core.py.

◆ _BOOL_BC_PARAMS

dict picurv_cli.core._BOOL_BC_PARAMS = {"enforce_seam_flux", "apply_trim"}
protected

Definition at line 8148 of file core.py.

◆ _DEPRECATED_BC_PARAM_ALIASES

dict picurv_cli.core._DEPRECATED_BC_PARAM_ALIASES = {"apply_trim": "enforce_seam_flux"}
protected

Definition at line 8154 of file core.py.

◆ RANS_PLANNED_REFUSAL

tuple picurv_cli.core.RANS_PLANNED_REFUSAL
Initial value:
1= (
2 "models.physics.turbulence.rans is not accepted: RANS closures are planned, not "
3 "implemented. No transport equation was ever solved for them, and the selector and "
4 "its dead hooks were removed. Remove the block; use models.physics.turbulence.les, "
5 "or run laminar."
6)

Definition at line 8459 of file core.py.

◆ _CASE_SCHEMA

dict picurv_cli.core._CASE_SCHEMA
protected

Definition at line 9001 of file core.py.

◆ _SOLVER_SCHEMA

dict picurv_cli.core._SOLVER_SCHEMA
protected

Definition at line 9065 of file core.py.

◆ _MONITOR_SCHEMA

dict picurv_cli.core._MONITOR_SCHEMA
protected

Definition at line 9157 of file core.py.

◆ STATISTICS_ELIGIBLE_FIELDS

dict picurv_cli.core.STATISTICS_ELIGIBLE_FIELDS
Initial value:
1= {
2 "Ucat": {"components": 3, "requires": None},
3 "P": {"components": 1, "requires": None},
4 "Nvert": {"components": 1, "requires": None},
5 "Phi": {"components": 1, "requires": None},
6 "Psi": {"components": 1, "requires": "particles"},
7 "ParticleCount": {"components": 1, "requires": "particles"},
8 "Nu_t": {"components": 1, "requires": "turbulence"},
9 "CS": {"components": 1, "requires": "les"},
10}

Definition at line 9208 of file core.py.

◆ STATISTICS_PARTICLE_FIELDS

dict picurv_cli.core.STATISTICS_PARTICLE_FIELDS
Initial value:
1= {
2 "Psi": {"components": 1, "requires": "particles", "target": "Psi"},
3 "velocity": {"components": 3, "requires": "particles", "target": "Ucat"},
4}

Definition at line 9221 of file core.py.

◆ POST_STATISTICS_ENSEMBLE_SOURCES

tuple picurv_cli.core.POST_STATISTICS_ENSEMBLE_SOURCES = ("particles", "eulerian")

Definition at line 9228 of file core.py.

◆ STATISTICS_MOMENT_NAMES

tuple picurv_cli.core.STATISTICS_MOMENT_NAMES = ("first", "second")

Definition at line 9243 of file core.py.

◆ STATISTICS_WEIGHTING_MODES

tuple picurv_cli.core.STATISTICS_WEIGHTING_MODES = ("sample", "physical_time")

Definition at line 9246 of file core.py.

◆ _POST_SCHEMA

dict picurv_cli.core._POST_SCHEMA
protected

Definition at line 9249 of file core.py.

◆ _CLUSTER_SCHEMA

dict picurv_cli.core._CLUSTER_SCHEMA
protected
Initial value:
1= {
2 (): {"scheduler", "resources", "notifications", "execution"},
3 ("scheduler",): {"type"},
4 ("resources",): {"account", "partition", "nodes", "ntasks_per_node", "mem", "time"},
5 ("notifications",): {"mail_user", "mail_type"},
6 ("execution",): {
7 "module_setup", "launcher", "launcher_args", "extra_sbatch", "walltime_guard", "decomposition",
8 },
9 ("execution", "decomposition"): {"i", "j", "k"},
10 ("execution", "extra_sbatch"): None,
11 ("execution", "walltime_guard"): {
12 "enabled", "warmup_steps", "multiplier", "min_seconds", "estimator_alpha",
13 },
14}

Definition at line 9287 of file core.py.

◆ _STUDY_SCHEMA

dict picurv_cli.core._STUDY_SCHEMA
protected
Initial value:
1= {
2 (): {
3 "title", "base_configs", "study_type", "parameters", "parameter_sets", "metrics", "plotting", "execution",
4 },
5 ("base_configs",): {"case", "solver", "monitor", "post"},
6 ("parameters",): None,
7 ("parameter_sets", "[]"): None,
8 ("metrics", "[]"): {
9 "name", "source", "file_glob", "column", "regex", "reduction", "normalize_by_parameter",
10 "numerator_column", "denominator_column", "denominator_floor",
11 "plot_label", "label", "units",
12 },
13 ("plotting",): {"enabled", "output_format"},
14 ("execution",): {"max_concurrent_array_tasks"},
15}

Definition at line 9303 of file core.py.

◆ _WORKSPACE_SCHEMA

dict picurv_cli.core._WORKSPACE_SCHEMA
protected
Initial value:
1= {
2 (): {"schema_version", "workspace", "software", "paths", "reproducibility"},
3 ("workspace",): {"id", "template", "created_at"},
4 ("software",): {"picurv"},
5 ("paths",): {"config", "inputs", "assets", "runs", "studies"},
6 ("reproducibility",): {"require_clean_release", "pin_executables"},
7}

Definition at line 9320 of file core.py.

◆ DIMENSIONLESS

tuple picurv_cli.core.DIMENSIONLESS = (0, 0, 0)

Definition at line 9338 of file core.py.

◆ LENGTH

tuple picurv_cli.core.LENGTH = (1, 0, 0)

Definition at line 9339 of file core.py.

◆ VELOCITY

tuple picurv_cli.core.VELOCITY = (0, 1, 0)

Definition at line 9340 of file core.py.

◆ TIME

tuple picurv_cli.core.TIME = (1, -1, 0)

Definition at line 9341 of file core.py.

◆ WAVENUMBER

tuple picurv_cli.core.WAVENUMBER = (-1, 0, 0)

Definition at line 9342 of file core.py.

◆ VOLUME_FLUX

tuple picurv_cli.core.VOLUME_FLUX = (2, 1, 0)

Definition at line 9343 of file core.py.

◆ DIFFUSIVITY

tuple picurv_cli.core.DIFFUSIVITY = (1, 1, 0)

Definition at line 9344 of file core.py.

◆ PRESSURE

tuple picurv_cli.core.PRESSURE = (0, 2, 1)

Definition at line 9345 of file core.py.

◆ DENSITY

tuple picurv_cli.core.DENSITY = (0, 0, 1)

Definition at line 9346 of file core.py.

◆ DYNAMIC_VISCOSITY

tuple picurv_cli.core.DYNAMIC_VISCOSITY = (1, 1, 1)

Definition at line 9347 of file core.py.

◆ INPUT_CONVERSION_SITES

tuple picurv_cli.core.INPUT_CONVERSION_SITES = ("cli", "c", "staging", "provider", "evaluator", "reference", "passthrough", "")

Definition at line 9357 of file core.py.

◆ NOT_A_QUANTITY

tuple picurv_cli.core.NOT_A_QUANTITY = (None, "")

Definition at line 9359 of file core.py.

◆ UNITLESS

tuple picurv_cli.core.UNITLESS = (DIMENSIONLESS, "")

Definition at line 9360 of file core.py.

◆ INPUT_QUANTITIES

dict picurv_cli.core.INPUT_QUANTITIES

Definition at line 9370 of file core.py.

◆ BC_PARAM_QUANTITIES

dict picurv_cli.core.BC_PARAM_QUANTITIES
Initial value:
1= {
2 "vx": (VELOCITY, "cli"),
3 "vy": (VELOCITY, "cli"),
4 "vz": (VELOCITY, "cli"),
5 "v_max": (VELOCITY, "cli"),
6 "target_flux": (VOLUME_FLUX, "cli"),
7 "enforce_seam_flux": NOT_A_QUANTITY,
8 "apply_trim": NOT_A_QUANTITY,
9 # Every profile source stages a velocity PICSLICE: a file or generated profile is
10 # physical, and a field_slice declares the scales it was written in.
11 "source": (VELOCITY, "staging"),
12}

Definition at line 9497 of file core.py.

◆ IC_PARAM_QUANTITIES

dict picurv_cli.core.IC_PARAM_QUANTITIES
Initial value:
1= {
2 "zero": {},
3 "constant": {
4 "u_physical": (VELOCITY, "cli"), "v_physical": (VELOCITY, "cli"),
5 "w_physical": (VELOCITY, "cli"), "velocity_physical": (VELOCITY, "cli"),
6 "flow_direction": NOT_A_QUANTITY,
7 },
8 "streamwise_constant": {
9 "velocity_physical": (VELOCITY, "cli"), "flow_direction": NOT_A_QUANTITY,
10 },
11 "poiseuille": {
12 "peak_velocity_physical": (VELOCITY, "cli"), "flow_direction": NOT_A_QUANTITY,
13 },
14 "ic_gen": {
15 "field": NOT_A_QUANTITY, "script": NOT_A_QUANTITY, "cli_args": NOT_A_QUANTITY,
16 # Expressions are written in physical coordinates and physical values.
17 "config_file": (None, "provider"),
18 },
19 "spectral_random_velocity": {
20 "field": NOT_A_QUANTITY, "seed": NOT_A_QUANTITY, "remove_mean": NOT_A_QUANTITY,
21 "random.distribution": NOT_A_QUANTITY, "random.mean": (VELOCITY, "cli"),
22 "spectrum.type": NOT_A_QUANTITY,
23 "spectrum.k0": (WAVENUMBER, "cli"), "spectrum.k_cut": (WAVENUMBER, "cli"),
24 "projection.type": NOT_A_QUANTITY, "projection.operator": NOT_A_QUANTITY,
25 "normalization.type": NOT_A_QUANTITY, "normalization.target": (VELOCITY, "cli"),
26 },
27}

Definition at line 9512 of file core.py.

◆ RUN_OWNED_DIRECTORY_KEYS

tuple picurv_cli.core.RUN_OWNED_DIRECTORY_KEYS = ("log", "output")

Definition at line 9669 of file core.py.

◆ UNSAFE_PATHS_OVERRIDE_KEY

str picurv_cli.core.UNSAFE_PATHS_OVERRIDE_KEY = "allow_unsafe_paths"

Definition at line 9670 of file core.py.

◆ RESERVED_RUN_DIRECTORY_NAMES

tuple picurv_cli.core.RESERVED_RUN_DIRECTORY_NAMES = ("config", "scheduler", "checkpoints", "visualization")

Definition at line 9676 of file core.py.

◆ RUN_DIRECTORY_DEFAULTS

dict picurv_cli.core.RUN_DIRECTORY_DEFAULTS = {"log": "logs", "output": "output"}

Definition at line 9680 of file core.py.

◆ RESERVED_DIRECTORY_FLAGS

tuple picurv_cli.core.RESERVED_DIRECTORY_FLAGS
Initial value:
1= (
2 "-log_dir", "-output_dir", "-restart_dir", "-analysis_dir",
3 "-allow_unsafe_log_dir",
4)

Definition at line 9684 of file core.py.

◆ RESERVED_INDIRECTION_FLAGS

tuple picurv_cli.core.RESERVED_INDIRECTION_FLAGS = ("-options_file", "-options_file_yaml", "-alias")

Definition at line 9692 of file core.py.

◆ UNSAFE_DIRECTORY_CHARACTERS

tuple picurv_cli.core.UNSAFE_DIRECTORY_CHARACTERS = ('"', "'", "#", "\n", "\r", "\t")

Definition at line 9699 of file core.py.

◆ PHYSICAL_VERDICT_CONTAINED

str picurv_cli.core.PHYSICAL_VERDICT_CONTAINED = "contained"

Definition at line 9935 of file core.py.

◆ PHYSICAL_VERDICT_RUN_ROOT

str picurv_cli.core.PHYSICAL_VERDICT_RUN_ROOT = "run_root"

Definition at line 9936 of file core.py.

◆ PHYSICAL_VERDICT_ANCESTOR

str picurv_cli.core.PHYSICAL_VERDICT_ANCESTOR = "ancestor"

Definition at line 9937 of file core.py.

◆ PHYSICAL_VERDICT_RELATIVE_ESCAPE

str picurv_cli.core.PHYSICAL_VERDICT_RELATIVE_ESCAPE = "relative_escape"

Definition at line 9938 of file core.py.

◆ PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE

str picurv_cli.core.PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE = "external_absolute"

Definition at line 9939 of file core.py.

◆ WAIVABLE_PHYSICAL_VERDICTS

picurv_cli.core.WAIVABLE_PHYSICAL_VERDICTS = frozenset({PHYSICAL_VERDICT_EXTERNAL_ABSOLUTE})

Definition at line 9945 of file core.py.

◆ _SLURM_FAILED_END_STATES

dict picurv_cli.core._SLURM_FAILED_END_STATES
protected
Initial value:
1= {
2 "FAILED", "CANCELLED", "TIMEOUT", "OUT_OF_MEMORY", "NODE_FAIL", "PREEMPTED",
3 "BOOT_FAIL", "DEADLINE",
4}

Definition at line 12395 of file core.py.

◆ RUNTIME_MEMORY_FILE

str picurv_cli.core.RUNTIME_MEMORY_FILE = "Runtime_Memory.csv"

Definition at line 12581 of file core.py.

◆ PROFILING_TIMESTEP_FILE

str picurv_cli.core.PROFILING_TIMESTEP_FILE = "Profiling_Timestep_Summary.csv"

Definition at line 12582 of file core.py.

◆ DIAGNOSTICS_PETSC_KEYS

dict picurv_cli.core.DIAGNOSTICS_PETSC_KEYS
Initial value:
1= {
2 "info",
3 "malloc_debug",
4 "malloc_test",
5 "malloc_dump",
6 "malloc_view",
7 "malloc_view_threshold",
8 "memory_view",
9 "log_view",
10 "log_view_memory",
11 "log_all",
12 "log_trace",
13 "objects_dump",
14 "options_left",
15}

Definition at line 12650 of file core.py.

◆ PROGRAMMATIC_BRIDGE_GRID_KEYS

tuple picurv_cli.core.PROGRAMMATIC_BRIDGE_GRID_KEYS
Initial value:
1= (
2 "im", "jm", "km",
3 "xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs",
4 "rxs", "rys", "rzs",
5)

Definition at line 13489 of file core.py.

◆ GRID_DA_PROCESSOR_KEYS

tuple picurv_cli.core.GRID_DA_PROCESSOR_KEYS = ("da_processors_x", "da_processors_y", "da_processors_z")

Definition at line 13615 of file core.py.

◆ IC_PAYLOAD_DIMENSIONS

dict picurv_cli.core.IC_PAYLOAD_DIMENSIONS = {0: VELOCITY, 1: VOLUME_FLUX}

Definition at line 14389 of file core.py.

◆ GENERATED_IC_PROVIDERS

dict picurv_cli.core.GENERATED_IC_PROVIDERS
Initial value:
1= {
2 "channel_spectral_velocity": {"requires_grid": True, "requires_fresh_3d": True},
3 "duct_spectral_velocity": {"requires_grid": True, "requires_fresh_3d": True},
4 "stability_mode_velocity": {"requires_grid": True},
5 "ic_gen": {
6 "requires_grid": True,
7 "diagnostic_artifacts": (),
8 },
9 "spectral_random_velocity": {
10 "requires_grid": True,
11 "requires_periodic_geometric": True,
12 "requires_fresh_3d": True,
13 "diagnostic_artifacts": (
14 ("summary_json", os.path.join(CANONICAL_RUN_PATHS["metrics"], "initial_condition_summary.json")),
15 ("spectrum_csv", INITIAL_CONDITION_SPECTRUM_RELPATH),
16 ),
17 },
18}

Definition at line 14449 of file core.py.

◆ PARTICLE_SETTABLE_FIELDS

dict picurv_cli.core.PARTICLE_SETTABLE_FIELDS = {"Psi": 1}

Definition at line 14530 of file core.py.

◆ PETSC_INDEX_BITS_CHOICES

tuple picurv_cli.core.PETSC_INDEX_BITS_CHOICES = ("32", "64")

Definition at line 14991 of file core.py.

◆ PETSC_MAX_INT_32BIT

int picurv_cli.core.PETSC_MAX_INT_32BIT = 2**31 - 1

Definition at line 14993 of file core.py.

◆ PARTICLE_FIELD_MAX_COMPONENTS

int picurv_cli.core.PARTICLE_FIELD_MAX_COMPONENTS = 3

Definition at line 14996 of file core.py.

◆ GRID_FIELD_MAX_COMPONENTS

int picurv_cli.core.GRID_FIELD_MAX_COMPONENTS = 3

Definition at line 14997 of file core.py.

◆ STATISTICS_FIELD_COMPONENTS

int picurv_cli.core.STATISTICS_FIELD_COMPONENTS = 6

Definition at line 14998 of file core.py.

◆ LES_MODEL_NAMES

dict picurv_cli.core.LES_MODEL_NAMES = {1: "constant_smagorinsky", 2: "dynamic_smagorinsky", 3: "vreman", 4: "wale"}

Definition at line 15758 of file core.py.

◆ LES_MODEL_OWNED_KEYS

dict picurv_cli.core.LES_MODEL_OWNED_KEYS
Initial value:
1= {
2 1: ("constant_cs",),
3 2: ("dynamic_frequency", "test_filter", "averaging", "clipping"),
4 3: ("vreman_coefficient",),
5 4: ("wale_coefficient",),
6}

Definition at line 15761 of file core.py.

◆ LES_MODEL_KEY_OWNER_DESCRIPTIONS

dict picurv_cli.core.LES_MODEL_KEY_OWNER_DESCRIPTIONS
Initial value:
1= {
2 1: "the constant Smagorinsky coefficient",
3 2: "the dynamic procedure",
4 3: "the Vreman model",
5 4: "the WALE model",
6}

Definition at line 15769 of file core.py.

◆ LES_AVERAGING_DIRECTION_AXES

tuple picurv_cli.core.LES_AVERAGING_DIRECTION_AXES = ("i", "j", "k")

Definition at line 15935 of file core.py.

◆ SOLVER_MONITORING_POISSON_FLAG_MAP

dict picurv_cli.core.SOLVER_MONITORING_POISSON_FLAG_MAP
Initial value:
1= {
2 "pic_true_residual": "-ps_ksp_pic_monitor_true_residual",
3 "true_residual": "-ps_ksp_monitor_true_residual",
4 "converged_reason": "-ps_ksp_converged_reason",
5 "view": "-ps_ksp_view",
6}

Definition at line 16237 of file core.py.

◆ SOLVER_MONITORING_MOMENTUM_FLAG_MAP

dict picurv_cli.core.SOLVER_MONITORING_MOMENTUM_FLAG_MAP
Initial value:
1= {
2 "newton_krylov_history": "-mom_nk_pic_monitor",
3 "snes_monitor": "-mom_nk_snes_monitor",
4 "snes_converged_reason": "-mom_nk_snes_converged_reason",
5 "ksp_monitor": "-mom_nk_ksp_monitor",
6 "ksp_converged_reason": "-mom_nk_ksp_converged_reason",
7}

Definition at line 16244 of file core.py.

◆ MAX_TRACED_PARTICLES

int picurv_cli.core.MAX_TRACED_PARTICLES = 64

Definition at line 16326 of file core.py.

◆ KRYLOV_KSP_TYPES

dict picurv_cli.core.KRYLOV_KSP_TYPES
Initial value:
1= {
2 "gmres", "fgmres", "lgmres", "dgmres", "pgmres", "gcr",
3 "cg", "cgne", "cgs", "bcgs", "ibcgs", "fbcgs", "fbcgsr", "bcgsl",
4 "tfqmr", "tcqmr", "minres", "symmlq", "cr", "lsqr", "pipecg", "pipefgmres",
5}

Definition at line 16430 of file core.py.

◆ _STUDY_REPORT_COLORS

tuple picurv_cli.core._STUDY_REPORT_COLORS
protected
Initial value:
1= (
2 "#0072B2", "#D55E00", "#009E73", "#CC79A7",
3 "#E69F00", "#56B4E9", "#332288", "#999999",
4)

Definition at line 18282 of file core.py.

◆ ASSET_KIND_DIRECTORIES

dict picurv_cli.core.ASSET_KIND_DIRECTORIES
Initial value:
1= {
2 "grid": "grids",
3 "initial-condition": "initial_conditions",
4 "inlet-profiles": "inlet_profiles",
5}

Definition at line 19500 of file core.py.

◆ ASSET_PREVIEW_NODE_LIMIT

int picurv_cli.core.ASSET_PREVIEW_NODE_LIMIT = 2_000_000

Definition at line 19862 of file core.py.

◆ _SUMMARY_NUMERIC_RE

picurv_cli.core._SUMMARY_NUMERIC_RE = re.compile(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
protected

Definition at line 22050 of file core.py.

◆ _CONFIG_SUMMARY_WIDTH

int picurv_cli.core._CONFIG_SUMMARY_WIDTH = 78
protected

Definition at line 23526 of file core.py.

◆ _SUMMARY_PLOT_LOG_SCALE_FIELDS

dict picurv_cli.core._SUMMARY_PLOT_LOG_SCALE_FIELDS
protected
Initial value:
1= {
2 "max_divergence", "delta_norm", "delta_rel", "residual_norm", "residual_rel",
3 "unpreconditioned_norm", "true_norm", "relative_norm",
4 "u_abs_l2", "u_rel_l2", "p_abs_l2", "p_rel_l2",
5 "spd_abs", "spd_rel", "ke_abs", "ke_rel",
6 "spd_win_abs", "spd_win_rel", "spd_rms_abs", "spd_rms_rel",
7 "ke_win_abs", "ke_win_rel", "ke_rms_abs", "ke_rms_rel",
8 "parseval_residual", "zero_mode_energy",
9}

Definition at line 23824 of file core.py.

◆ _SUMMARY_PLOT_FIELD_LABELS

dict picurv_cli.core._SUMMARY_PLOT_FIELD_LABELS
protected

Definition at line 23835 of file core.py.

◆ _SUMMARY_PLOT_SOURCE_TITLES

dict picurv_cli.core._SUMMARY_PLOT_SOURCE_TITLES
protected
Initial value:
1= {
2 "continuity": "Continuity",
3 "particles": "Particle transport",
4 "momentum": "Momentum solver",
5 "poisson": "Pressure Poisson solver",
6 "profiling": "Runtime profile",
7 "memory": "Runtime memory",
8 "convergence": "Solution convergence",
9 "spectra": "Turbulence spectrum",
10}

Definition at line 23910 of file core.py.

◆ _SUMMARY_ITERATION_HISTORY_FIELDS

dict picurv_cli.core._SUMMARY_ITERATION_HISTORY_FIELDS
protected
Initial value:
1= {
2 "dtau", "dtau_after", "cfl_eff", "cfl_eff_after", "delta_norm", "delta_rel",
3 "residual_norm", "residual_rel", "trial_ratio", "smoothed_ratio",
4 "unpreconditioned_norm", "true_norm", "relative_norm",
5}

Definition at line 23922 of file core.py.

◆ _SUMMARY_COUNT_FIELDS

dict picurv_cli.core._SUMMARY_COUNT_FIELDS
protected
Initial value:
1= {
2 "total_particles", "lost_particles", "lost_particles_cumulative",
3 "exited_particles", "exited_particles_cumulative", "migrated_particles", "occupied_cells", "migration_passes", "calls",
4 "pseudo_iterations", "newton_iterations", "iterations",
5}

Definition at line 23929 of file core.py.

◆ _LEGACY_PICARD_REGEX

picurv_cli.core._LEGACY_PICARD_REGEX
protected
Initial value:
1= re.compile(
2 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\‍(k\‍):\s*(?P<pseudo_iter>\d+)\s*\|"
3 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
4 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
5 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
6 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
7 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
8 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
9 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
10 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
11)

Definition at line 24165 of file core.py.

◆ _LEGACY_NEWTON_HISTORY_REGEX

picurv_cli.core._LEGACY_NEWTON_HISTORY_REGEX
protected
Initial value:
1= re.compile(
2 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
3 r"\s*newton:\s*(?P<newton>\d+)\s*\|\s*nonlinear_norm:\s*(?P<norm>[-+0-9.eE]+)"
4)

Definition at line 24239 of file core.py.

◆ _LEGACY_NEWTON_SUMMARY_REGEX

picurv_cli.core._LEGACY_NEWTON_SUMMARY_REGEX
protected
Initial value:
1= re.compile(
2 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
3 r"\s*solver:\s*(?P<solver>[^|]+?)\s*\|"
4 r"(?:\s*Jacobian:\s*[^|]+?\s*\|\s*Preconditioner:\s*[^|]+?\s*\|)?"
5 r"\s*reason:\s*(?P<reason>\S+)\s*\|"
6 r"\s*reason_code:\s*(?P<reason_code>-?\d+)\s*\|\s*newton:\s*(?P<newton>\d+)\s*\|"
7 r"\s*evals:\s*(?P<evals>\d+)\s*\|\s*krylov:\s*(?P<krylov>\d+)\s*\|"
8 r"\s*initial:\s*(?P<initial>[-+0-9.eE]+|unavailable)\s*\|"
9 r"\s*final:\s*(?P<final>[-+0-9.eE]+)\s*\|\s*state:\s*(?P<state>\w+)"
10)

Definition at line 24243 of file core.py.

◆ _LEGACY_POISSON_REGEX

picurv_cli.core._LEGACY_POISSON_REGEX
protected
Initial value:
1= re.compile(
2 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
3 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
4 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
5)

Definition at line 24339 of file core.py.

◆ _PLOT_ROW_IDENTITY_KEYS

tuple picurv_cli.core._PLOT_ROW_IDENTITY_KEYS = ("step", "block")
protected

Definition at line 24430 of file core.py.

◆ SUMMARY_TABLES

dict picurv_cli.core.SUMMARY_TABLES
Initial value:
1= {
2 "continuity": ("log_dir", "Continuity_Metrics.csv"),
3 "convergence": ("log_dir", "solution_convergence.csv"),
4 "memory": ("log_dir", RUNTIME_MEMORY_FILE),
5 "profiling": ("log_dir", "ProfilingSummary_Solver.csv"),
6 "profiling-post": ("log_dir", "ProfilingSummary_PostProcessor.csv"),
7 "profiling-steps": ("log_dir", PROFILING_TIMESTEP_FILE),
8 "picard": ("log_dir", "Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_{block}.csv"),
9 "newton": ("log_dir", "Momentum_Solver_Newton_Krylov_Summary_Block_{block}.csv"),
10 "newton-history": ("log_dir", "Momentum_Solver_Newton_Krylov_History_Block_{block}.csv"),
11 "newton-linear": ("log_dir", "Momentum_Solver_Newton_Krylov_Linear_History_Block_{block}.csv"),
12 "poisson": ("log_dir", "Poisson_Solver_Convergence_History_Block_{block}.csv"),
13 "search": ("metrics_dir", "search_metrics.csv"),
14 "scatter": ("metrics_dir", "scatter_metrics.csv"),
15 "interpolation-error": ("metrics_dir", "interpolation_error.csv"),
16 "les": ("metrics_dir", "les_coefficient.csv"),
17 "wall-model": ("metrics_dir", "wall_model.csv"),
18 "driven-flow": ("metrics_dir", "driven_flow.csv"),
19 "particle-initial-fields": ("metrics_dir", "particle_initial_fields.csv"),
20}

Definition at line 25110 of file core.py.

◆ WORKSPACE_INPUT_DIRECTORIES

dict picurv_cli.core.WORKSPACE_INPUT_DIRECTORIES
Initial value:
1= {
2 "grid": "inputs/grids",
3 "initial-condition": "inputs/initial_conditions",
4 "inlet-profile": "inputs/inlet_profiles",
5 "reference-field": "inputs/reference_fields",
6}

Definition at line 26477 of file core.py.