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  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.
 
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.
 
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)
 Split '<file>: <message>' style validation strings 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.
 
 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)
 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.
 
 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 build_post_recipe_config (dict post_cfg, monitor_cfg=None)
 Build the flat key=value mapping consumed by the C post-processor.
 
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.
 
 parse_post_recipe_file (str post_recipe_path)
 Parse an existing generated post.run file into a key/value mapping.
 
str get_post_resume_state_path (str run_dir)
 Return the JSON resume metadata path for a run directory.
 
dict get_post_lock_paths (str run_dir)
 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.
 
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.
 
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.
 
"set[int]" _scan_post_vtk_steps (str prefix_path, str extension)
 Scan VTK output files matching '<prefix>_<step>.
 
"set[int]" _scan_post_statistics_csv_steps (str csv_path)
 Scan step ids from the first CSV column of a statistics artifact.
 
"list[set[int]]" collect_post_completion_families (str run_dir, dict post_cfg, monitor_cfg=None)
 Collect per-family completed-step sets for the current post recipe.
 
dict detect_post_completed_frontier (str run_dir, dict post_cfg, monitor_cfg, int start_step, int end_step, int step_interval)
 Detect the highest contiguous fully completed post step for the current recipe.
 
 _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.
 
 persist_post_resume_state (str run_dir, dict plan, last_successful_requested_end_step=None)
 Persist post resume lineage metadata for future –continue runs.
 
str _build_post_lock_wrapper_source ()
 Return the Python wrapper used to hold an exclusive post-stage 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)
 Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
 
dict build_post_execution_plan (str run_dir, str run_id, dict case_cfg, dict monitor_cfg, dict post_cfg, bool continue_requested=False, bool allow_source_frontier_scan=True)
 Resolve post resume/source-availability behavior into one execution plan.
 
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.
 
 populate_restart_directory (str source_output, str target_restart, int start_step, dict monitor_cfg)
 Copy checkpoint files for a specific step from source output to target restart.
 
 detect_last_checkpoint_step (str output_dir, str euler_subdir="eulerian", str particle_subdir="particles")
 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.
 
 resolve_restart_source (args, dict case_cfg, dict solver_cfg, dict monitor_cfg, str run_dir)
 Resolve the restart source directory based on –restart-from or –continue CLI flags.
 
 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.
 
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 an optional 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.
 
str run_grid_generator (str case_path, str run_dir, dict grid_cfg)
 Runs generators/grid.gen to produce a PICGRID file for this run.
 
str convert_legacy_grid_with_gridgen (str case_path, str run_dir, dict grid_cfg, str source_grid)
 Optionally convert a legacy file-grid payload to canonical PICGRID using grid.gen.
 
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.
 
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.
 
 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.
 
 validate_solver_configs (dict case_cfg, dict solver_cfg, dict monitor_cfg, str case_path, str solver_path, str monitor_path)
 Validates all solver input configs before any work is done.
 
 validate_post_config (dict post_cfg, str post_path)
 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.
 
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)
 Normalize extension.
 
 render_slurm_script (str script_path, str job_name, dict cluster_cfg, list command, str workdir, str stdout_path, str stderr_path=None, dict env_vars=None, dict shell_env_vars=None, str array_spec=None)
 Render a Slurm batch script for a single command.
 
"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.
 
dict build_serial_post_cluster_config (dict cluster_cfg, int num_procs=1)
 Clone cluster config and force a single-node post stage task layout.
 
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 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.
 
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)
 Generic function to create a file containing a simple list of strings.
 
bool has_explicit_monitor_whitelist (dict monitor_cfg)
 Return True when logging.enabled_functions contains at least one entry.
 
dict resolve_profiling_config (dict monitor_cfg)
 Resolve profiling reporting config from monitor.yml.
 
 _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.
 
dict prepare_monitor_files (str run_dir, str run_id, dict monitor_cfg, dict source_files)
 Generate monitor sidecar files and resolve profiling reporting behavior.
 
list generate_multi_block_bcs (str run_dir, str run_id, dict case_cfg, dict source_files)
 Parses multi-block BCs from YAML, generates a .run file for each block, and returns a list of their 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.
 
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.
 
None append_grid_da_processor_layout (list control_lines, dict grid_cfg, int num_procs)
 Append optional global DMDA layout flags for any grid mode.
 
str normalize_momentum_solver_type (str value)
 Maps canonical user-facing momentum solver names to C-enum CLI values.
 
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_initial_condition_config (dict ic, prepared_blocks, float U_ref)
 Resolve legacy and structured initial-condition YAML into one launcher contract.
 
dict validate_petsc_vec_binary (str path)
 Validate the basic PETSc binary VecView envelope used by ReadFieldData.
 
str run_initial_condition_generator (str case_path, str run_dir, dict resolved_ic)
 Run the repository IC generator.
 
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, float U_ref)
 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.
 
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 names to the C -testfilter_ik flag.
 
int normalize_rans_model (value)
 Maps RANS model selectors to the current C -rans switch.
 
str normalize_wall_function_model (value)
 Validates wall-function model selectors exposed in YAML.
 
bool resolve_enabled_flag (dict cfg, str path, bool default=True)
 Resolves a structured enabled flag and rejects non-boolean values.
 
 append_turbulence_flags (dict models, list control_lines)
 Appends turbulence model flags from legacy or structured case.yml blocks.
 
 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.
 
"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)
 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)
 Generates the main .control file for the C-solver.
 
str generate_post_recipe_file (str run_dir, str run_id, dict post_cfg, dict source_files, monitor_cfg=None)
 Generates a key=value config file (post.run) for the C post-processor.
 
 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.
 
 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)
 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.
 
str aggregate_study_metrics (dict study_cfg, list cases, str results_dir)
 Collect metric values from generated case directories into one CSV.
 
 infer_plot_x_axis (dict study_cfg, list rows)
 Infer x-axis key/values for study plots.
 
 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.
 
 precompute_workflow (args)
 Generate deterministic case artifacts without launching solver/post stages.
 
 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.
 
 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 _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 filepath)
 Parse Continuity_Metrics.log into latest rows by step plus observed order.
 
"tuple[dict, list[int]]" _parse_particle_metrics_log (str filepath)
 Parse Particle_Metrics.log into latest rows by step plus observed order.
 
"tuple[dict, dict, list[int]]" _parse_momentum_convergence_logs (str log_dir)
 Parse per-block momentum convergence logs.
 
"tuple[dict, dict, list[int]]" _parse_poisson_convergence_logs (str log_dir)
 Parse per-block Poisson convergence logs.
 
"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)
 Parse Runtime_Memory.log into latest rows by step and final status.
 
"tuple[dict, list[int]]" _parse_solution_convergence_log (str filepath)
 Parse solution_convergence.log into latest 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.
 
 _append_summary_plot_record (list records, str source, step, str line, dict values, str source_path, int segment=0)
 Append one numeric append-ordered record for summarize plotting.
 
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 _build_summary_plot_catalog (list records)
 Build available qualified-series metadata from plot records.
 
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.
 
 _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 _format_stage_list (list stage_names)
 Format a human-readable stage list for submit diagnostics.
 
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.
 
 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.
 
 init_case (args)
 Implements the 'init' command.
 
 sync_case_binaries_command (args)
 Refresh case-local executables from the source repository bin directory.
 
 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.
 

Variables

 _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
 
str PICURV_VERSION = "0.1.0"
 
str CASE_ORIGIN_METADATA_FILENAME = ".picurv-origin.json"
 
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 = 1
 
dict POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {"startTime", "endTime"}
 
tuple POST_REQUIRED_EULERIAN_SOURCE_BASENAMES = ("ufield", "vfield", "pfield", "nvfield")
 
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"
 
dict _ERROR_HINTS
 
dict POST_RUN_CONTROL_ALIASES
 
dict GRID_GENERATOR_HYPHEN_KEY_HINTS
 
dict GENERATED_PROFILE_GENERATORS = {"square_duct_poiseuille"}
 
dict BC_FACE_MAP
 
dict BC_TYPE_MAP
 
dict BC_HANDLER_SPECS
 
dict _NUMERIC_BC_PARAMS = {"vx", "vy", "vz", "v_max", "target_flux"}
 
dict _BOOL_BC_PARAMS = {"apply_trim"}
 
dict _CASE_SCHEMA
 
dict _SOLVER_SCHEMA
 
dict _MONITOR_SCHEMA
 
dict _POST_SCHEMA
 
dict _CLUSTER_SCHEMA
 
dict _STUDY_SCHEMA
 
dict DIAGNOSTICS_PETSC_KEYS
 
tuple GRID_DA_PROCESSOR_KEYS = ("da_processors_x", "da_processors_y", "da_processors_z")
 
dict SOLVER_MONITORING_POISSON_FLAG_MAP
 
 _SUMMARY_NUMERIC_RE = re.compile(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
 
int _CONFIG_SUMMARY_WIDTH = 78
 
dict _SUMMARY_PLOT_LOG_SCALE_FIELDS
 

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 58 of file core.py.

58def _prune_incompatible_python_site_paths(paths):
59 """!
60 @brief Remove site-package paths for a different Python major/minor version.
61 @param[in] paths Candidate sys.path entries.
62 @return Filtered path list.
63 """
64 current = (sys.version_info[0], sys.version_info[1])
65 pattern = re.compile(r"python(?:-)?(\d+)\.(\d+)", re.IGNORECASE)
66 filtered = []
67 for path in paths:
68 text = str(path)
69 match = pattern.search(text)
70 if match:
71 path_version = (int(match.group(1)), int(match.group(2)))
72 if path_version != current and ("site-packages" in text or "dist-packages" in text):
73 continue
74 filtered.append(path)
75 return filtered
76
77
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 78 of file core.py.

78def _drop_imported_package(package_name: str):
79 """!
80 @brief Remove a failed/partial import package tree from sys.modules.
81 @param[in] package_name Top-level package name.
82 """
83 prefix = package_name + "."
84 for module_name in list(sys.modules):
85 if module_name == package_name or module_name.startswith(prefix):
86 sys.modules.pop(module_name, None)
87
88
Head of a generic C-style linked list.
Definition variables.h:443
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 89 of file core.py.

89def require_numpy():
90 """!
91 @brief Import NumPy only for commands that need numeric reductions.
92 @return Imported NumPy module.
93 """
94 global _NUMPY_MODULE
95 if _NUMPY_MODULE is not None:
96 return _NUMPY_MODULE
97 try:
98 import numpy
99 except Exception as exc:
100 first_error = exc
101 original_path = list(sys.path)
102 try:
103 _drop_imported_package("numpy")
104 sys.path = _prune_incompatible_python_site_paths(original_path)
105 import numpy
106 except Exception as retry_exc:
107 raise RuntimeError(
108 "NumPy is required for this operation, but no compatible NumPy "
109 "could be imported for this Python interpreter. PICurv ignored "
110 "site-packages paths for other Python versions and retried. "
111 f"First error: {first_error}. Retry error: {retry_exc}"
112 ) from retry_exc
113 finally:
114 sys.path = original_path
115 _NUMPY_MODULE = numpy
116 return _NUMPY_MODULE
117
118
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 119 of file core.py.

119def optional_matplotlib_pyplot():
120 """!
121 @brief Import matplotlib.pyplot lazily for study plot generation.
122 @return matplotlib.pyplot when available, otherwise None.
123 """
124 global _MATPLOTLIB_PYPLOT
125 if _MATPLOTLIB_PYPLOT is not None:
126 return _MATPLOTLIB_PYPLOT
127 original_path = list(sys.path)
128 try:
129 import matplotlib.pyplot as pyplot
130 except Exception:
131 try:
132 _drop_imported_package("matplotlib")
133 sys.path = _prune_incompatible_python_site_paths(original_path)
134 import matplotlib.pyplot as pyplot
135 except Exception:
136 return None
137 finally:
138 sys.path = original_path
139 _MATPLOTLIB_PYPLOT = pyplot
140 return _MATPLOTLIB_PYPLOT
141
142# --- Global Path Definitions ---
143# The implementation package and source-tree entrypoint live in picurv_cli/,
144# while generators/ owns standalone generators.
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 214 of file core.py.

214def parse_slurm_time_limit_to_seconds(time_text: str) -> int:
215 """!
216 @brief Parse a Slurm time-limit string into total seconds.
217 @param[in] time_text Argument passed to `parse_slurm_time_limit_to_seconds()`.
218 @return Value returned by `parse_slurm_time_limit_to_seconds()`.
219 """
220 text = str(time_text).strip()
221 if not text:
222 raise ValueError("time limit cannot be empty")
223
224 days = 0
225 clock_text = text
226 if "-" in text:
227 day_text, clock_text = text.split("-", 1)
228 if not day_text.isdigit():
229 raise ValueError(f"invalid day field '{day_text}'")
230 days = int(day_text)
231 if not clock_text:
232 raise ValueError("missing time portion after day field")
233
234 parts = clock_text.split(":")
235 if len(parts) > 3:
236 raise ValueError(f"unsupported time format '{time_text}'")
237 if any(part == "" for part in parts):
238 raise ValueError(f"malformed time field '{time_text}'")
239 if any(not part.isdigit() for part in parts):
240 raise ValueError(f"non-numeric time field '{time_text}'")
241
242 nums = [int(part) for part in parts]
243 if days > 0:
244 if len(nums) == 1:
245 hours, minutes, seconds = nums[0], 0, 0
246 elif len(nums) == 2:
247 hours, minutes = nums
248 seconds = 0
249 else:
250 hours, minutes, seconds = nums
251 else:
252 if len(nums) == 1:
253 hours, minutes, seconds = 0, nums[0], 0
254 elif len(nums) == 2:
255 hours = 0
256 minutes, seconds = nums
257 else:
258 hours, minutes, seconds = nums
259
260 if minutes >= 60 or seconds >= 60:
261 raise ValueError(f"minutes and seconds must be < 60 in '{time_text}'")
262 if days == 0 and len(nums) == 3 and hours < 0:
263 raise ValueError(f"hours must be non-negative in '{time_text}'")
264
265 total_seconds = (((days * 24) + hours) * 60 + minutes) * 60 + seconds
266 if total_seconds <= 0:
267 raise ValueError("time limit must be positive")
268 return total_seconds
269
270
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 271 of file core.py.

271def resolve_walltime_guard_policy(cluster_cfg: "dict | None") -> "dict | None":
272 """!
273 @brief Resolve the effective Slurm walltime-guard policy for generated solver jobs.
274 @param[in] cluster_cfg Argument passed to `resolve_walltime_guard_policy()`.
275 @return Value returned by `resolve_walltime_guard_policy()`.
276 """
277 if not isinstance(cluster_cfg, dict):
278 return None
279
280 scheduler = cluster_cfg.get("scheduler", {}) or {}
281 if str(scheduler.get("type", "slurm")).lower() != "slurm":
282 return None
283
284 execution = cluster_cfg.get("execution", {}) or {}
285 guard_cfg = execution.get("walltime_guard")
286 if guard_cfg is None:
287 guard_cfg = {}
288 elif not isinstance(guard_cfg, dict):
289 raise ValueError("execution.walltime_guard must be a mapping when provided")
290
291 policy = copy.deepcopy(DEFAULT_WALLTIME_GUARD_POLICY)
292 policy.update(guard_cfg)
293 policy["enabled"] = bool(policy["enabled"])
294 policy["warmup_steps"] = int(policy["warmup_steps"])
295 policy["multiplier"] = float(policy["multiplier"])
296 policy["min_seconds"] = float(policy["min_seconds"])
297 policy["estimator_alpha"] = float(policy["estimator_alpha"])
298 return policy
299
300
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 301 of file core.py.

301def build_walltime_guard_exports(cluster_cfg: "dict | None") -> dict:
302 """!
303 @brief Build shell-evaluated environment exports for the runtime walltime guard.
304 @param[in] cluster_cfg Argument passed to `build_walltime_guard_exports()`.
305 @return Value returned by `build_walltime_guard_exports()`.
306 """
307 policy = resolve_walltime_guard_policy(cluster_cfg)
308 if not policy or not policy.get("enabled", False):
309 return {}
310 walltime_limit_seconds = parse_slurm_time_limit_to_seconds(cluster_cfg.get("resources", {}).get("time", ""))
311 return {
312 WALLTIME_GUARD_ENV_JOB_START_EPOCH: "$(date +%s)",
313 WALLTIME_GUARD_ENV_LIMIT_SECONDS: str(walltime_limit_seconds),
314 }
315
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.

Parameters
[in]executable_nameArgument passed to resolve_runtime_executable().
Returns
Value returned by resolve_runtime_executable().

Definition at line 316 of file core.py.

316def resolve_runtime_executable(executable_name: str) -> str:
317 """!
318 @brief Resolve solver/post executable path, preferring local sibling binaries.
319 @param[in] executable_name Argument passed to `resolve_runtime_executable()`.
320 @return Value returned by `resolve_runtime_executable()`.
321 """
322 local_candidate = os.path.join(INVOKED_SCRIPT_DIR, executable_name)
323 if os.path.isfile(local_candidate):
324 return os.path.abspath(local_candidate)
325 return os.path.join(DEFAULT_BIN_DIR, executable_name)
326
327# 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 351 of file core.py.

351def _sanitize_error_field(value) -> str:
352 """!
353 @brief Normalize error fields into a single-line string.
354 @param[in] value Argument passed to `_sanitize_error_field()`.
355 @return Value returned by `_sanitize_error_field()`.
356 """
357 if value is None:
358 return "-"
359 text = str(value).strip()
360 if not text:
361 return "-"
362 return " ".join(text.splitlines())
363
364

◆ 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 365 of file core.py.

366 message: str = "", hint: str = None, stream=None):
367 """!
368 @brief Emit one standardized error line for tooling and users.
369 @param[in] code Argument passed to `emit_structured_error()`.
370 @param[in] key Argument passed to `emit_structured_error()`.
371 @param[in] file_path Argument passed to `emit_structured_error()`.
372 @param[in] message Argument passed to `emit_structured_error()`.
373 @param[in] hint Argument passed to `emit_structured_error()`.
374 @param[in] stream Argument passed to `emit_structured_error()`.
375 """
376 if stream is None:
377 stream = sys.stderr
378 resolved_hint = hint if hint is not None else _ERROR_HINTS.get(code, "-")
379 print(
380 f"ERROR {_sanitize_error_field(code)} | "
381 f"key={_sanitize_error_field(key)} | "
382 f"file={_sanitize_error_field(file_path)} | "
383 f"message={_sanitize_error_field(message)} | "
384 f"hint={_sanitize_error_field(resolved_hint)}",
385 file=stream,
386 )
387
388
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.

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

Definition at line 389 of file core.py.

389def fail_cli_usage(message: str, hint: str = None):
390 """!
391 @brief Emit a structured CLI usage error and exit with code 2.
392 @param[in] message Argument passed to `fail_cli_usage()`.
393 @param[in] hint Argument passed to `fail_cli_usage()`.
394 """
395 emit_structured_error(
396 ERROR_CODE_CLI_USAGE_INVALID,
397 key="-",
398 file_path="-",
399 message=message,
400 hint=hint or _ERROR_HINTS[ERROR_CODE_CLI_USAGE_INVALID],
401 )
402 sys.exit(2)
403
404
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

Split '<file>: <message>' style validation strings when possible.

Parameters
[in]raw_errorArgument passed to _split_error_file_and_message().
Returns
Value returned by _split_error_file_and_message().

Definition at line 405 of file core.py.

405def _split_error_file_and_message(raw_error: str):
406 """!
407 @brief Split '<file>: <message>' style validation strings when possible.
408 @param[in] raw_error Argument passed to `_split_error_file_and_message()`.
409 @return Value returned by `_split_error_file_and_message()`.
410 """
411 text = str(raw_error).strip()
412 match = re.match(r"^(?P<file>[^:]+):\s*(?P<msg>.+)$", text)
413 if not match:
414 return "-", text
415 file_candidate = match.group("file").strip()
416 msg = match.group("msg").strip()
417 known_suffixes = (".yml", ".yaml", ".cfg", ".picgrid", ".control", ".run", ".txt")
418 if "/" in file_candidate or file_candidate.endswith(known_suffixes):
419 return file_candidate, msg
420 return "-", text
421
422
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 423 of file core.py.

423def _extract_key_path(message: str) -> str:
424 """!
425 @brief Best-effort key-path extraction from free-form validation messages.
426 @param[in] message Argument passed to `_extract_key_path()`.
427 @return Value returned by `_extract_key_path()`.
428 """
429 dotted = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_\[\]-]+)+)\b", message)
430 if dotted:
431 return dotted.group(1)
432
433 bracketed = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*\[[^\]]+\](?:\[[^\]]+\])*)\b", message)
434 if bracketed:
435 return bracketed.group(1)
436
437 quoted = re.findall(r"'([A-Za-z0-9_.\[\]-]+)'", message)
438 for token in quoted:
439 if "." in token or "[" in token or token.isidentifier():
440 return token
441 return "-"
442
443
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 444 of file core.py.

444def _classify_error_code(message: str) -> str:
445 """!
446 @brief Map existing validation/error messages to the standardized code set.
447 @param[in] message Argument passed to `_classify_error_code()`.
448 @return Value returned by `_classify_error_code()`.
449 """
450 msg = message.lower()
451 if "missing required section" in msg:
452 return ERROR_CODE_CFG_MISSING_SECTION
453 if "missing required key" in msg or "missing key" in msg:
454 return ERROR_CODE_CFG_MISSING_KEY
455 if "not found" in msg or "does not exist" in msg:
456 return ERROR_CODE_CFG_FILE_NOT_FOUND
457 if "invalid dimensions line" in msg or "invalid coordinate row" in msg or "grid file" in msg:
458 return ERROR_CODE_CFG_GRID_PARSE
459 if (
460 "must both be periodic" in msg
461 or "inconsistent periodicity" in msg
462 or "mismatch" in msg
463 or "requires --" in msg
464 or "must be 1 (auto) or exactly" in msg
465 ):
466 return ERROR_CODE_CFG_INCONSISTENT_COMBO
467 if (
468 "must be a mapping" in msg
469 or "must be a list" in msg
470 or "must be a string" in msg
471 or "must be a boolean" in msg
472 or "must be either" in msg
473 ):
474 return ERROR_CODE_CFG_INVALID_TYPE
475 if "unsupported key" in msg or "unsupported top-level section" in msg:
476 return ERROR_CODE_CFG_INVALID_VALUE
477 return ERROR_CODE_CFG_INVALID_VALUE
478
479# ==============================================================================
480# HELPER FUNCTIONS
481# ==============================================================================
482
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 483 of file core.py.

483def read_yaml_file(filepath: str) -> dict:
484 """!
485 @brief Safely reads a YAML file and returns its content.
486 @param[in] filepath Path to the YAML file.
487 @return A dictionary containing the parsed YAML content.
488 @throws SystemExit if the file is not found or cannot be parsed.
489 """
490 if not os.path.exists(filepath):
491 emit_structured_error(
492 ERROR_CODE_CFG_FILE_NOT_FOUND,
493 key="-",
494 file_path=filepath,
495 message="Configuration file not found.",
496 )
497 sys.exit(1)
498 try:
499 with open(filepath, 'r') as f:
500 return yaml.safe_load(f)
501 except yaml.YAMLError as e:
502 emit_structured_error(
503 ERROR_CODE_CFG_INVALID_VALUE,
504 key="-",
505 file_path=filepath,
506 message=f"YAML parse error: {e}",
507 hint="Fix YAML syntax/indentation and retry validation.",
508 )
509 sys.exit(1)
510
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 511 of file core.py.

511def write_yaml_file(filepath: str, data: dict):
512 """!
513 @brief Write YAML with stable ordering for generated study artifacts.
514 @param[in] filepath Argument passed to `write_yaml_file()`.
515 @param[in] data Argument passed to `write_yaml_file()`.
516 """
517 os.makedirs(os.path.dirname(filepath), exist_ok=True)
518 with open(filepath, "w") as f:
519 yaml.safe_dump(data, f, sort_keys=False)
520
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 521 of file core.py.

521def write_json_file(filepath: str, payload: dict):
522 """!
523 @brief Write JSON metadata/manifests with a stable, readable format.
524 @param[in] filepath Argument passed to `write_json_file()`.
525 @param[in] payload Argument passed to `write_json_file()`.
526 """
527 os.makedirs(os.path.dirname(filepath), exist_ok=True)
528 with open(filepath, "w") as f:
529 json.dump(payload, f, indent=2, sort_keys=True)
530 f.write("\n")
531
532
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 533 of file core.py.

533def write_runtime_execution_file(filepath: str, template_source_path: str = None) -> str:
534 """!
535 @brief Write a default runtime execution config, copying a source template when available.
536 @param[in] filepath Argument passed to `write_runtime_execution_file()`.
537 @param[in] template_source_path Argument passed to `write_runtime_execution_file()`.
538 @return Value returned by `write_runtime_execution_file()`.
539 """
540 os.makedirs(os.path.dirname(filepath), exist_ok=True)
541
542 if template_source_path and os.path.isfile(template_source_path):
543 shutil.copy2(template_source_path, filepath)
544 return filepath
545
546 with open(filepath, "w", encoding="utf-8") as f:
547 f.write(DEFAULT_RUNTIME_EXECUTION_CONFIG_TEMPLATE)
548 return filepath
549
550
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 551 of file core.py.

551def _launcher_arg_contains_whitespace(token) -> bool:
552 """!
553 @brief Return True when a launcher arg token contains embedded whitespace and should be split.
554 @param[in] token Argument passed to `_launcher_arg_contains_whitespace()`.
555 @return Value returned by `_launcher_arg_contains_whitespace()`.
556 """
557 return isinstance(token, str) and any(ch.isspace() for ch in token.strip())
558
559
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 560 of file core.py.

560def resolve_runtime_execution_seed_source(source_project_root: str) -> "str | None":
561 """!
562 @brief Prefer repo-local ignored runtime config, then tracked example, then built-in defaults.
563 @param[in] source_project_root Argument passed to `resolve_runtime_execution_seed_source()`.
564 @return Value returned by `resolve_runtime_execution_seed_source()`.
565 """
566 source_root_abs = os.path.abspath(source_project_root)
567 repo_local_runtime = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
568 if os.path.isfile(repo_local_runtime):
569 return repo_local_runtime
570
571 tracked_example = os.path.join(
572 source_root_abs,
573 "config",
574 "runtime",
575 RUNTIME_EXECUTION_EXAMPLE_FILENAME,
576 )
577 if os.path.isfile(tracked_example):
578 return tracked_example
579 return None
580
581
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 582 of file core.py.

582def ensure_case_runtime_execution_config(case_dir: str, source_project_root: str, overwrite: bool = False) -> dict:
583 """!
584 @brief Create case-local runtime execution config if missing, seeded from repo-local config when available.
585 @param[in] case_dir Argument passed to `ensure_case_runtime_execution_config()`.
586 @param[in] source_project_root Argument passed to `ensure_case_runtime_execution_config()`.
587 @param[in] overwrite Argument passed to `ensure_case_runtime_execution_config()`.
588 @return Value returned by `ensure_case_runtime_execution_config()`.
589 """
590 case_dir_abs = os.path.abspath(case_dir)
591 dest_path = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
592 if os.path.exists(dest_path) and not overwrite:
593 return {
594 "path": dest_path,
595 "created": False,
596 "seed_source": None,
597 }
598
599 seed_source = resolve_runtime_execution_seed_source(source_project_root)
600 write_runtime_execution_file(dest_path, seed_source)
601 return {
602 "path": dest_path,
603 "created": True,
604 "seed_source": seed_source,
605 }
606
607
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 608 of file core.py.

608def is_project_root(candidate: str) -> bool:
609 """!
610 @brief Return True when a directory looks like the PICurv source repository root.
611 @param[in] candidate Argument passed to `is_project_root()`.
612 @return Value returned by `is_project_root()`.
613 """
614 if not candidate:
615 return False
616 candidate_abs = os.path.abspath(candidate)
617 return (
618 os.path.isfile(os.path.join(candidate_abs, "Makefile"))
619 and os.path.isdir(os.path.join(candidate_abs, "src"))
620 and os.path.isdir(os.path.join(candidate_abs, "include"))
621 and os.path.isdir(os.path.join(candidate_abs, "picurv_cli"))
622 )
623
624
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 625 of file core.py.

625def _iter_parent_dirs(start_path: str):
626 """!
627 @brief Yield a path and all of its parents up to filesystem root.
628 @param[in] start_path Argument passed to `_iter_parent_dirs()`.
629 """
630 current = os.path.abspath(start_path)
631 if os.path.isfile(current):
632 current = os.path.dirname(current)
633 while True:
634 yield current
635 parent = os.path.dirname(current)
636 if parent == current:
637 break
638 current = parent
639
640
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 641 of file core.py.

641def find_project_root_upwards(start_path: str):
642 """!
643 @brief Search upward from an anchor and return the first matching project root.
644 @param[in] start_path Argument passed to `find_project_root_upwards()`.
645 @return Value returned by `find_project_root_upwards()`.
646 """
647 if not start_path:
648 return None
649 for directory in _iter_parent_dirs(start_path):
650 if is_project_root(directory):
651 return directory
652 return None
653
654
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 655 of file core.py.

655def discover_local_project_root(*extra_anchors):
656 """!
657 @brief Best-effort source repo discovery from runtime anchors.
658 @param[in] extra_anchors Argument passed to `discover_local_project_root()`.
659 @return Value returned by `discover_local_project_root()`.
660 """
661 anchors = list(extra_anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR, SCRIPT_PATH, PROJECT_ROOT]
662 seen = set()
663 for anchor in anchors:
664 if not anchor:
665 continue
666 anchor_abs = os.path.abspath(anchor)
667 if anchor_abs in seen:
668 continue
669 seen.add(anchor_abs)
670 project_root = find_project_root_upwards(anchor_abs)
671 if project_root:
672 return project_root
673 return None
674
675
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.

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

Definition at line 676 of file core.py.

676def find_case_origin_metadata_file(case_dir_hint: str = None):
677 """!
678 @brief Find the nearest case-origin metadata file from known runtime anchors.
679 @param[in] case_dir_hint Argument passed to `find_case_origin_metadata_file()`.
680 @return Value returned by `find_case_origin_metadata_file()`.
681 """
682 search_roots = []
683 for candidate in (case_dir_hint, os.getcwd(), INVOKED_SCRIPT_DIR):
684 if not candidate:
685 continue
686 abs_candidate = os.path.abspath(candidate)
687 if abs_candidate not in search_roots:
688 search_roots.append(abs_candidate)
689
690 for root in search_roots:
691 for directory in _iter_parent_dirs(root):
692 metadata_path = os.path.join(directory, CASE_ORIGIN_METADATA_FILENAME)
693 if os.path.isfile(metadata_path):
694 return metadata_path
695 return None
696
697
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 698 of file core.py.

698def load_case_origin_metadata(case_dir_hint: str = None):
699 """!
700 @brief Load case-origin metadata if present, returning (case_dir, metadata_path, payload).
701 @param[in] case_dir_hint Argument passed to `load_case_origin_metadata()`.
702 @return Value returned by `load_case_origin_metadata()`.
703 """
704 metadata_path = find_case_origin_metadata_file(case_dir_hint)
705 if not metadata_path:
706 return None, None, None
707 try:
708 with open(metadata_path, "r", encoding="utf-8") as f:
709 payload = json.load(f)
710 if not isinstance(payload, dict):
711 raise ValueError("Case origin metadata must be a JSON object.")
712 except Exception as exc:
713 raise ValueError(f"Failed to read case origin metadata at {metadata_path}: {exc}") from exc
714 return os.path.dirname(metadata_path), metadata_path, payload
715
716
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 717 of file core.py.

717def find_runtime_execution_config_file(*anchors):
718 """!
719 @brief Find the nearest optional execution config from runtime/case anchors.
720 @param[in] anchors Argument passed to `find_runtime_execution_config_file()`.
721 @return Value returned by `find_runtime_execution_config_file()`.
722 """
723 seen = set()
724 search_roots = []
725 for candidate in list(anchors) + [os.getcwd(), INVOKED_SCRIPT_DIR]:
726 if not candidate:
727 continue
728 current = os.path.abspath(candidate)
729 if os.path.isfile(current):
730 current = os.path.dirname(current)
731 if current in seen:
732 continue
733 seen.add(current)
734 search_roots.append(current)
735
736 seen_dirs = set()
737 for root in search_roots:
738 for directory in _iter_parent_dirs(root):
739 if directory in seen_dirs:
740 continue
741 seen_dirs.add(directory)
742 for filename in RUNTIME_EXECUTION_CONFIG_FILENAMES:
743 config_path = os.path.join(directory, filename)
744 if os.path.isfile(config_path):
745 return config_path
746 return None
747
748
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 749 of file core.py.

749def _normalize_execution_override_section(payload: dict, section_name: str, config_path: str, config_label: str) -> dict:
750 """!
751 @brief Validate one execution override section while preserving missing-vs-empty semantics.
752 @param[in] payload Argument passed to `_normalize_execution_override_section()`.
753 @param[in] section_name Argument passed to `_normalize_execution_override_section()`.
754 @param[in] config_path Argument passed to `_normalize_execution_override_section()`.
755 @param[in] config_label Argument passed to `_normalize_execution_override_section()`.
756 @return Value returned by `_normalize_execution_override_section()`.
757 """
758 section = payload.get(section_name)
759 if section is None:
760 return {"launcher": None, "launcher_args": None}
761 if not isinstance(section, dict):
762 raise ValueError(f"{config_label} at {config_path}: {section_name} must be a mapping.")
763
764 launcher = section.get("launcher")
765 if launcher is not None and not isinstance(launcher, str):
766 raise ValueError(f"{config_label} at {config_path}: {section_name}.launcher must be a string.")
767
768 launcher_args = None
769 if "launcher_args" in section:
770 launcher_args = section.get("launcher_args", [])
771 if launcher_args is None:
772 launcher_args = []
773 if not isinstance(launcher_args, list):
774 raise ValueError(f"{config_label} at {config_path}: {section_name}.launcher_args must be a list.")
775 for i, token in enumerate(launcher_args):
776 if not isinstance(token, (str, int, float)):
777 raise ValueError(
778 f"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
779 "must be a scalar CLI token."
780 )
781 if _launcher_arg_contains_whitespace(token):
782 raise ValueError(
783 f"{config_label} at {config_path}: {section_name}.launcher_args[{i}] "
784 "must be a single CLI token; split whitespace-separated arguments into separate list items."
785 )
786 launcher_args = [str(x) for x in launcher_args]
787
788 return {
789 "launcher": launcher,
790 "launcher_args": launcher_args,
791 }
792
793
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 794 of file core.py.

794def load_runtime_execution_config(config_search_anchor: str = None, extra_search_anchors=None):
795 """!
796 @brief Load optional shared execution launcher config from the nearest runtime config file.
797 @param[in] config_search_anchor Argument passed to `load_runtime_execution_config()`.
798 @param[in] extra_search_anchors Argument passed to `load_runtime_execution_config()`.
799 @return Value returned by `load_runtime_execution_config()`.
800 """
801 anchors = []
802 if config_search_anchor is not None:
803 anchors.append(config_search_anchor)
804 if extra_search_anchors:
805 anchors.extend(extra_search_anchors)
806
807 config_path = find_runtime_execution_config_file(*anchors)
808 if not config_path:
809 return None, {}
810
811 try:
812 with open(config_path, "r", encoding="utf-8") as f:
813 payload = yaml.safe_load(f) or {}
814 except yaml.YAMLError as exc:
815 raise ValueError(f"{os.path.basename(config_path)} YAML parse error at {config_path}: {exc}") from exc
816
817 if not isinstance(payload, dict):
818 raise ValueError(f"{os.path.basename(config_path)} at {config_path} must be a YAML mapping.")
819
820 return config_path, {
821 "default_execution": _normalize_execution_override_section(
822 payload,
823 "default_execution",
824 config_path,
825 os.path.basename(config_path),
826 ),
827 "local_execution": _normalize_execution_override_section(
828 payload,
829 "local_execution",
830 config_path,
831 os.path.basename(config_path),
832 ),
833 "cluster_execution": _normalize_execution_override_section(
834 payload,
835 "cluster_execution",
836 config_path,
837 os.path.basename(config_path),
838 ),
839 }
840
841
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 842 of file core.py.

842def merge_execution_overrides(base: "dict | None", override: "dict | None") -> dict:
843 """!
844 @brief Merge execution overrides, letting explicit override values win key-by-key.
845 @param[in] base Argument passed to `merge_execution_overrides()`.
846 @param[in] override Argument passed to `merge_execution_overrides()`.
847 @return Value returned by `merge_execution_overrides()`.
848 """
849 base = base or {}
850 override = override or {}
851
852 launcher = override.get("launcher")
853 if launcher is None:
854 launcher = base.get("launcher")
855
856 launcher_args = override.get("launcher_args")
857 if launcher_args is None:
858 launcher_args = base.get("launcher_args")
859
860 return {
861 "launcher": launcher,
862 "launcher_args": None if launcher_args is None else [str(x) for x in launcher_args],
863 }
864
865
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 866 of file core.py.

866def resolve_runtime_execution_context(runtime_execution_cfg: dict, context: str) -> dict:
867 """!
868 @brief Resolve default plus context-specific execution overrides.
869 @param[in] runtime_execution_cfg Argument passed to `resolve_runtime_execution_context()`.
870 @param[in] context Argument passed to `resolve_runtime_execution_context()`.
871 @return Value returned by `resolve_runtime_execution_context()`.
872 """
873 if context not in {"local", "cluster"}:
874 raise ValueError(f"Unsupported execution context '{context}'.")
875 return merge_execution_overrides(
876 runtime_execution_cfg.get("default_execution"),
877 runtime_execution_cfg.get(f"{context}_execution"),
878 )
879
880
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 881 of file core.py.

881def get_git_commit(repo_root: str = None) -> str:
882 """!
883 @brief Best-effort git commit lookup for run/study manifests and case metadata.
884 @param[in] repo_root Argument passed to `get_git_commit()`.
885 @return Value returned by `get_git_commit()`.
886 """
887 cwd = repo_root or PROJECT_ROOT
888 try:
889 result = subprocess.run(
890 ["git", "rev-parse", "HEAD"],
891 cwd=cwd,
892 text=True,
893 capture_output=True,
894 check=False
895 )
896 if result.returncode == 0:
897 return result.stdout.strip()
898 except Exception:
899 pass
900 return None
901
902
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 903 of file core.py.

904 existing: dict = None, template_managed_files=None):
905 """!
906 @brief Create or refresh case-origin metadata for repo-aware case maintenance commands.
907 @param[in] case_dir Argument passed to `write_case_origin_metadata()`.
908 @param[in] source_project_root Argument passed to `write_case_origin_metadata()`.
909 @param[in] template_name Argument passed to `write_case_origin_metadata()`.
910 @param[in] existing Argument passed to `write_case_origin_metadata()`.
911 @param[in] template_managed_files Argument passed to `write_case_origin_metadata()`.
912 @return Value returned by `write_case_origin_metadata()`.
913 """
914 payload = dict(existing or {})
915 if "initialized_at" not in payload:
916 payload["initialized_at"] = datetime.now().isoformat()
917 payload["source_repo_root"] = os.path.abspath(source_project_root)
918 if template_name:
919 payload["template_name"] = template_name
920 if template_managed_files is not None:
921 payload["template_managed_files"] = sorted(set(str(p) for p in template_managed_files))
922 payload["last_known_source_git_commit"] = get_git_commit(source_project_root)
923 metadata_path = os.path.join(os.path.abspath(case_dir), CASE_ORIGIN_METADATA_FILENAME)
924 write_json_file(metadata_path, payload)
925 return metadata_path, payload
926
927
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 928 of file core.py.

928def make_args_include_explicit_goal(make_args: "list[str]") -> bool:
929 """!
930 @brief Return True when make args contain an explicit target rather than only options/assignments.
931 @param[in] make_args Argument passed to `make_args_include_explicit_goal()`.
932 @return Value returned by `make_args_include_explicit_goal()`.
933 """
934 if not make_args:
935 return False
936
937 options_with_value = {
938 "-C", "-f", "-I", "-j", "-l", "-o", "-W",
939 "--directory", "--file", "--makefile", "--include-dir", "--jobs",
940 "--load-average", "--max-load", "--old-file", "--assume-old",
941 "--what-if", "--new-file", "--assume-new",
942 }
943 assignment_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*[:+?]?=.*$")
944
945 skip_next = False
946 for token in make_args:
947 if skip_next:
948 skip_next = False
949 continue
950 if token in options_with_value:
951 skip_next = True
952 continue
953 if token.startswith("-"):
954 continue
955 if assignment_pattern.match(token):
956 continue
957 return True
958 return False
959
960
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 961 of file core.py.

961def resolve_case_origin_context(case_dir_hint: str = None, source_root_override: str = None, template_name_override: str = None):
962 """!
963 @brief Resolve case directory, source repo root, and optional template metadata.
964 @param[in] case_dir_hint Argument passed to `resolve_case_origin_context()`.
965 @param[in] source_root_override Argument passed to `resolve_case_origin_context()`.
966 @param[in] template_name_override Argument passed to `resolve_case_origin_context()`.
967 @return Value returned by `resolve_case_origin_context()`.
968 """
969 metadata_case_dir, metadata_path, metadata = load_case_origin_metadata(case_dir_hint)
970
971 if metadata_case_dir:
972 case_dir = metadata_case_dir
973 else:
974 case_dir = os.path.abspath(case_dir_hint or os.getcwd())
975
976 source_project_root = source_root_override
977 if source_project_root:
978 source_project_root = os.path.abspath(source_project_root)
979 elif isinstance(metadata, dict) and isinstance(metadata.get("source_repo_root"), str):
980 source_project_root = os.path.abspath(metadata["source_repo_root"])
981 else:
982 source_project_root = discover_local_project_root(case_dir)
983
984 template_name = template_name_override
985 if not template_name and isinstance(metadata, dict):
986 template_name = metadata.get("template_name")
987
988 return {
989 "case_dir": case_dir,
990 "metadata_path": metadata_path,
991 "metadata": metadata or {},
992 "source_project_root": source_project_root,
993 "template_name": template_name,
994 }
995
996
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 997 of file core.py.

997def require_project_root(candidate: str, purpose: str):
998 """!
999 @brief Validate that a source repo root was resolved and is structurally valid.
1000 @param[in] candidate Argument passed to `require_project_root()`.
1001 @param[in] purpose Argument passed to `require_project_root()`.
1002 @return Value returned by `require_project_root()`.
1003 """
1004 if not candidate:
1005 raise ValueError(
1006 f"Could not determine the PICurv source repository for {purpose}. "
1007 "Run this command from an initialized case directory or pass --source-root."
1008 )
1009 candidate_abs = os.path.abspath(candidate)
1010 if not is_project_root(candidate_abs):
1011 raise ValueError(
1012 f"Resolved source repository for {purpose} is not a valid PICurv root: {candidate_abs}"
1013 )
1014 return candidate_abs
1015
1016
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 1017 of file core.py.

1017def require_existing_case_dir(case_dir: str, purpose: str, source_project_root: str = None):
1018 """!
1019 @brief Validate that a target case directory exists and is not the source repo root.
1020 @param[in] case_dir Argument passed to `require_existing_case_dir()`.
1021 @param[in] purpose Argument passed to `require_existing_case_dir()`.
1022 @param[in] source_project_root Argument passed to `require_existing_case_dir()`.
1023 @return Value returned by `require_existing_case_dir()`.
1024 """
1025 if not case_dir:
1026 raise ValueError(f"Could not determine the case directory for {purpose}. Pass --case-dir.")
1027 case_dir_abs = os.path.abspath(case_dir)
1028 if not os.path.isdir(case_dir_abs):
1029 raise ValueError(f"Case directory for {purpose} does not exist: {case_dir_abs}")
1030 if source_project_root and os.path.abspath(source_project_root) == case_dir_abs:
1031 raise ValueError(
1032 f"Refusing to run {purpose} against the source repository root itself: {case_dir_abs}"
1033 )
1034 return case_dir_abs
1035
1036
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 1037 of file core.py.

1037def resolve_template_directory(source_project_root: str, template_name: str):
1038 """!
1039 @brief Resolve an example template directory inside the source repository.
1040 @param[in] source_project_root Argument passed to `resolve_template_directory()`.
1041 @param[in] template_name Argument passed to `resolve_template_directory()`.
1042 @return Value returned by `resolve_template_directory()`.
1043 """
1044 if not template_name:
1045 raise ValueError(
1046 "Template name is required for config sync. Re-run with --template-name or from a case initialized by current picurv."
1047 )
1048 template_dir = os.path.join(source_project_root, "examples", template_name)
1049 if not os.path.isdir(template_dir):
1050 raise ValueError(f"Case template '{template_name}' not found at '{template_dir}'")
1051 return template_dir
1052
1053
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 1054 of file core.py.

1054def list_template_relative_files(template_dir: str, excluded_rel_paths=None):
1055 """!
1056 @brief List all files in a template directory as case-relative paths.
1057 @param[in] template_dir Argument passed to `list_template_relative_files()`.
1058 @param[in] excluded_rel_paths Argument passed to `list_template_relative_files()`.
1059 @return Value returned by `list_template_relative_files()`.
1060 """
1061 template_dir_abs = os.path.abspath(template_dir)
1062 if not os.path.isdir(template_dir_abs):
1063 raise ValueError(f"Template directory not found: {template_dir_abs}")
1064 excluded = set(excluded_rel_paths or [])
1065 relative_paths = []
1066 for root, _, files in os.walk(template_dir_abs):
1067 rel_root = os.path.relpath(root, template_dir_abs)
1068 for filename in sorted(files):
1069 rel_path = filename if rel_root == "." else os.path.join(rel_root, filename)
1070 if rel_path in excluded:
1071 continue
1072 relative_paths.append(rel_path)
1073 return relative_paths
1074
1075
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 1076 of file core.py.

1076def list_source_binaries(source_project_root: str):
1077 """!
1078 @brief List binary artifacts currently available in the source repo bin directory.
1079 @param[in] source_project_root Argument passed to `list_source_binaries()`.
1080 @return Value returned by `list_source_binaries()`.
1081 """
1082 source_bin_dir = os.path.join(os.path.abspath(source_project_root), "bin")
1083 if not os.path.isdir(source_bin_dir):
1084 raise ValueError(f"Source bin directory not found: {source_bin_dir}. Run 'picurv build' first.")
1085 binaries = sorted(
1086 f for f in os.listdir(source_bin_dir)
1087 if os.path.isfile(os.path.join(source_bin_dir, f)) and f != "picurv"
1088 )
1089 if not binaries:
1090 raise ValueError(f"Source bin directory contains no files: {source_bin_dir}")
1091 return source_bin_dir, binaries
1092
1093
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 1094 of file core.py.

1094def sync_case_binaries(case_dir: str, source_project_root: str):
1095 """!
1096 @brief Copy current source-repo binaries into a case directory for version-pinning.
1097 @param[in] case_dir Argument passed to `sync_case_binaries()`.
1098 @param[in] source_project_root Argument passed to `sync_case_binaries()`.
1099 @return Value returned by `sync_case_binaries()`.
1100 """
1101 case_dir_abs = os.path.abspath(case_dir)
1102 os.makedirs(case_dir_abs, exist_ok=True)
1103 source_bin_dir, binaries = list_source_binaries(source_project_root)
1104 copied = []
1105 for binary_name in binaries:
1106 source_path = os.path.join(source_bin_dir, binary_name)
1107 dest_path = os.path.join(case_dir_abs, binary_name)
1108 shutil.copy2(source_path, dest_path)
1109 copied.append(dest_path)
1110 return copied
1111
1112
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 
)

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().
Returns
Value returned by sync_case_template_files().

Definition at line 1113 of file core.py.

1114 prune: bool = False, managed_rel_paths=None):
1115 """!
1116 @brief Sync template files into a case directory, preserving modified files unless overwrite is requested.
1117 @param[in] case_dir Argument passed to `sync_case_template_files()`.
1118 @param[in] template_dir Argument passed to `sync_case_template_files()`.
1119 @param[in] overwrite Argument passed to `sync_case_template_files()`.
1120 @param[in] prune Argument passed to `sync_case_template_files()`.
1121 @param[in] managed_rel_paths Argument passed to `sync_case_template_files()`.
1122 @return Value returned by `sync_case_template_files()`.
1123 """
1124 case_dir_abs = os.path.abspath(case_dir)
1125 template_dir_abs = os.path.abspath(template_dir)
1126 if not os.path.isdir(template_dir_abs):
1127 raise ValueError(f"Template directory not found: {template_dir_abs}")
1128
1129 summary = {
1130 "copied": [],
1131 "overwritten": [],
1132 "skipped_modified": [],
1133 "unchanged": [],
1134 "pruned": [],
1135 "prune_requested_without_tracking": False,
1136 }
1137 excluded_rel_paths = {RUNTIME_EXECUTION_EXAMPLE_FILENAME}
1138 current_template_files = list_template_relative_files(
1139 template_dir_abs,
1140 excluded_rel_paths=excluded_rel_paths,
1141 )
1142 current_template_set = set(current_template_files)
1143
1144 for root, _, files in os.walk(template_dir_abs):
1145 rel_root = os.path.relpath(root, template_dir_abs)
1146 for filename in sorted(files):
1147 src_path = os.path.join(root, filename)
1148 rel_path = filename if rel_root == "." else os.path.join(rel_root, filename)
1149 if rel_path in excluded_rel_paths:
1150 continue
1151 dest_path = os.path.join(case_dir_abs, rel_path)
1152 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
1153
1154 if not os.path.exists(dest_path):
1155 shutil.copy2(src_path, dest_path)
1156 summary["copied"].append(dest_path)
1157 continue
1158
1159 if filecmp.cmp(src_path, dest_path, shallow=False):
1160 summary["unchanged"].append(dest_path)
1161 continue
1162
1163 if overwrite:
1164 shutil.copy2(src_path, dest_path)
1165 summary["overwritten"].append(dest_path)
1166 else:
1167 summary["skipped_modified"].append(dest_path)
1168
1169 managed_set = set(managed_rel_paths or [])
1170 if prune:
1171 if not managed_set:
1172 summary["prune_requested_without_tracking"] = True
1173 for rel_path in sorted(managed_set - current_template_set):
1174 dest_path = os.path.join(case_dir_abs, rel_path)
1175 if os.path.isfile(dest_path):
1176 os.remove(dest_path)
1177 summary["pruned"].append(dest_path)
1178
1179 summary["template_managed_files"] = current_template_files
1180 return summary
1181
1182
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 1183 of file core.py.

1183def compute_case_source_status(case_dir: str, source_project_root: str, template_name: str = None, metadata: dict = None):
1184 """!
1185 @brief Compute source/case drift across commits, binaries, and template-managed files.
1186 @param[in] case_dir Argument passed to `compute_case_source_status()`.
1187 @param[in] source_project_root Argument passed to `compute_case_source_status()`.
1188 @param[in] template_name Argument passed to `compute_case_source_status()`.
1189 @param[in] metadata Argument passed to `compute_case_source_status()`.
1190 @return Value returned by `compute_case_source_status()`.
1191 """
1192 case_dir_abs = os.path.abspath(case_dir)
1193 source_root_abs = os.path.abspath(source_project_root)
1194 metadata = metadata or {}
1195 status = {
1196 "case_dir": case_dir_abs,
1197 "source_repo_root": source_root_abs,
1198 "metadata_present": bool(metadata),
1199 "template_name": template_name,
1200 "last_known_source_git_commit": metadata.get("last_known_source_git_commit"),
1201 "current_source_git_commit": get_git_commit(source_root_abs),
1202 }
1203 status["source_commit_changed"] = (
1204 bool(status["last_known_source_git_commit"])
1205 and bool(status["current_source_git_commit"])
1206 and status["last_known_source_git_commit"] != status["current_source_git_commit"]
1207 )
1208
1209 binary_status = {
1210 "source_bin_present": False,
1211 "source_bin_missing": [],
1212 "case_bin_missing": [],
1213 "case_bin_different": [],
1214 "case_bin_current": [],
1215 }
1216 try:
1217 source_bin_dir, binaries = list_source_binaries(source_root_abs)
1218 binary_status["source_bin_present"] = True
1219 for binary_name in binaries:
1220 source_path = os.path.join(source_bin_dir, binary_name)
1221 case_path = os.path.join(case_dir_abs, binary_name)
1222 if not os.path.isfile(case_path):
1223 binary_status["case_bin_missing"].append(binary_name)
1224 elif filecmp.cmp(source_path, case_path, shallow=False):
1225 binary_status["case_bin_current"].append(binary_name)
1226 else:
1227 binary_status["case_bin_different"].append(binary_name)
1228 except ValueError as exc:
1229 binary_status["source_bin_missing"].append(str(exc))
1230 status["binaries"] = binary_status
1231
1232 config_status = {
1233 "template_available": False,
1234 "template_files": [],
1235 "case_missing_files": [],
1236 "case_modified_files": [],
1237 "case_current_files": [],
1238 "template_removed_since_last_sync": [],
1239 "tracking_available": isinstance(metadata.get("template_managed_files"), list),
1240 }
1241 if template_name:
1242 try:
1243 template_dir = resolve_template_directory(source_root_abs, template_name)
1244 template_files = list_template_relative_files(
1245 template_dir,
1246 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
1247 )
1248 config_status["template_available"] = True
1249 config_status["template_files"] = template_files
1250 for rel_path in template_files:
1251 src_path = os.path.join(template_dir, rel_path)
1252 case_path = os.path.join(case_dir_abs, rel_path)
1253 if not os.path.isfile(case_path):
1254 config_status["case_missing_files"].append(rel_path)
1255 elif filecmp.cmp(src_path, case_path, shallow=False):
1256 config_status["case_current_files"].append(rel_path)
1257 else:
1258 config_status["case_modified_files"].append(rel_path)
1259 managed_files = metadata.get("template_managed_files")
1260 if isinstance(managed_files, list):
1261 config_status["template_removed_since_last_sync"] = sorted(set(managed_files) - set(template_files))
1262 except ValueError:
1263 pass
1264 status["config"] = config_status
1265
1266 case_runtime_cfg = os.path.join(case_dir_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
1267 repo_runtime_seed = os.path.join(source_root_abs, RUNTIME_EXECUTION_CONFIG_FILENAME)
1268 runtime_status = {
1269 "case_config_present": os.path.isfile(case_runtime_cfg),
1270 "repo_seed_present": os.path.isfile(repo_runtime_seed),
1271 "case_matches_repo_seed": False,
1272 }
1273 if runtime_status["case_config_present"] and runtime_status["repo_seed_present"]:
1274 runtime_status["case_matches_repo_seed"] = filecmp.cmp(
1275 case_runtime_cfg,
1276 repo_runtime_seed,
1277 shallow=False,
1278 )
1279 status["runtime_execution"] = runtime_status
1280 return status
1281
1282
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 1283 of file core.py.

1283def print_case_source_status(status: dict):
1284 """!
1285 @brief Render human-readable source/case drift details.
1286 @param[in] status Argument passed to `print_case_source_status()`.
1287 """
1288 print(f"[INFO] Case directory : {status['case_dir']}")
1289 print(f"[INFO] Source repo : {status['source_repo_root']}")
1290 print(f"[INFO] Template : {status.get('template_name') or '(unknown)'}")
1291 if status.get("last_known_source_git_commit"):
1292 print(f"[INFO] Last synced commit : {status['last_known_source_git_commit']}")
1293 if status.get("current_source_git_commit"):
1294 print(f"[INFO] Current src commit : {status['current_source_git_commit']}")
1295 print(f"[INFO] Source changed : {'yes' if status.get('source_commit_changed') else 'no'}")
1296
1297 binaries = status["binaries"]
1298 if binaries["source_bin_present"]:
1299 print(
1300 f"[INFO] Binaries : current={len(binaries['case_bin_current'])} "
1301 f"changed={len(binaries['case_bin_different'])} missing={len(binaries['case_bin_missing'])}"
1302 )
1303 else:
1304 print("[INFO] Binaries : source bin/ unavailable")
1305
1306 config = status["config"]
1307 if config["template_available"]:
1308 print(
1309 f"[INFO] Template files : current={len(config['case_current_files'])} "
1310 f"modified={len(config['case_modified_files'])} missing={len(config['case_missing_files'])}"
1311 )
1312 if config["tracking_available"]:
1313 print(f"[INFO] Prune candidates : {len(config['template_removed_since_last_sync'])}")
1314 else:
1315 print("[INFO] Prune candidates : tracking unavailable")
1316 elif status.get("template_name"):
1317 print("[INFO] Template files : template unavailable in source repo")
1318
1319 runtime_cfg = status.get("runtime_execution", {})
1320 print(
1321 f"[INFO] Runtime config : case={'yes' if runtime_cfg.get('case_config_present') else 'no'} "
1322 f"repo-seed={'yes' if runtime_cfg.get('repo_seed_present') else 'no'} "
1323 f"matches-repo-seed={'yes' if runtime_cfg.get('case_matches_repo_seed') else 'no'}"
1324 )
1325
1326
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 1327 of file core.py.

1327def status_source_command(args):
1328 """!
1329 @brief Report source/case drift for an initialized case directory.
1330 @param[in] args Command-line style argument list supplied to the function.
1331 """
1332 try:
1333 context = resolve_case_origin_context(
1334 case_dir_hint=getattr(args, "case_dir", None),
1335 source_root_override=getattr(args, "source_root", None),
1336 template_name_override=getattr(args, "template_name", None),
1337 )
1338 source_project_root = require_project_root(context["source_project_root"], "status-source")
1339 case_dir = require_existing_case_dir(context["case_dir"], "status-source", source_project_root)
1340 status = compute_case_source_status(
1341 case_dir,
1342 source_project_root,
1343 template_name=context.get("template_name"),
1344 metadata=context.get("metadata"),
1345 )
1346 except ValueError as exc:
1347 print(f"[FATAL] {exc}", file=sys.stderr)
1348 sys.exit(1)
1349
1350 if getattr(args, "output_format", "text") == "json":
1351 print(json.dumps(status, indent=2, sort_keys=True))
1352 return
1353 print_case_source_status(status)
1354
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 1355 of file core.py.

1355def resolve_path(anchor_file: str, candidate: str) -> str:
1356 """!
1357 @brief Resolve a potentially relative path against a source YAML file path.
1358 @param[in] anchor_file Argument passed to `resolve_path()`.
1359 @param[in] candidate Argument passed to `resolve_path()`.
1360 @return Value returned by `resolve_path()`.
1361 """
1362 if candidate is None:
1363 return None
1364 if os.path.isabs(candidate):
1365 return os.path.abspath(candidate)
1366 return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(anchor_file)), candidate))
1367
1368
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 1386 of file core.py.

1386def _mapping_value_with_aliases(mapping: dict, *keys, default=None):
1387 """!
1388 @brief Return the first defined value from a mapping across alias keys.
1389 @param[in] mapping Argument passed to `_mapping_value_with_aliases()`.
1390 @param[in] default Argument passed to `_mapping_value_with_aliases()`.
1391 @param[in] keys Argument passed to `_mapping_value_with_aliases()`.
1392 @return Value returned by `_mapping_value_with_aliases()`.
1393 """
1394 if not isinstance(mapping, dict):
1395 return default
1396 for key in keys:
1397 if key in mapping:
1398 return mapping.get(key)
1399 return default
1400
1401
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 1402 of file core.py.

1402def get_post_run_control_value(post_cfg: dict, canonical_key: str, default=None):
1403 """!
1404 @brief Resolve post run_control values with backwards-compatible legacy aliases.
1405 @param[in] post_cfg Argument passed to `get_post_run_control_value()`.
1406 @param[in] canonical_key Argument passed to `get_post_run_control_value()`.
1407 @param[in] default Argument passed to `get_post_run_control_value()`.
1408 @return Value returned by `get_post_run_control_value()`.
1409 """
1410 aliases = POST_RUN_CONTROL_ALIASES.get(canonical_key, (canonical_key,))
1411 rc = post_cfg.get("run_control", {})
1412 return _mapping_value_with_aliases(rc, *aliases, default=default)
1413
1414
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 1415 of file core.py.

1415def warn_on_grid_generator_hyphen_keys(generator: dict, case_path: str, warnings: list) -> None:
1416 """!
1417 @brief Warn when grid.generator uses unsupported hyphenated wrapper keys.
1418 @param[in] generator grid.generator mapping from case.yml.
1419 @param[in] case_path Case file path for diagnostics.
1420 @param[in,out] warnings Warning list to append to.
1421 """
1422 if not isinstance(generator, dict):
1423 return
1424 for bad_key, expected_key in GRID_GENERATOR_HYPHEN_KEY_HINTS.items():
1425 if bad_key in generator and bad_key != expected_key:
1426 warnings.append(
1427 f"{case_path}: grid.generator.{bad_key} is ignored; use grid.generator.{expected_key}."
1428 )
1429
1430
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 1431 of file core.py.

1431def get_post_source_data(post_cfg: dict):
1432 """!
1433 @brief Return source_data as a mapping when valid, else an empty mapping.
1434 @param[in] post_cfg Argument passed to `get_post_source_data()`.
1435 @return Value returned by `get_post_source_data()`.
1436 """
1437 source_cfg = post_cfg.get("source_data", {})
1438 if isinstance(source_cfg, dict):
1439 return source_cfg
1440 return {}
1441
1442
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 1443 of file core.py.

1443def get_post_source_directory_template(post_cfg: dict, default: str = "<solver_output_dir>") -> str:
1444 """!
1445 @brief Resolve the source directory template from source_data with a safe default.
1446 @param[in] post_cfg Argument passed to `get_post_source_directory_template()`.
1447 @param[in] default Argument passed to `get_post_source_directory_template()`.
1448 @return Value returned by `get_post_source_directory_template()`.
1449 """
1450 return get_post_source_data(post_cfg).get("directory", default)
1451
1452
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 1453 of file core.py.

1453def get_post_input_extensions(post_cfg: dict):
1454 """!
1455 @brief Return post input_extensions, preferring io.* and tolerating legacy source_data.* placement.
1456 @param[in] post_cfg Argument passed to `get_post_input_extensions()`.
1457 @return Value returned by `get_post_input_extensions()`.
1458 """
1459 io_cfg = post_cfg.get("io", {})
1460 io_ext = io_cfg.get("input_extensions") if isinstance(io_cfg, dict) else None
1461 if isinstance(io_ext, dict):
1462 return io_ext
1463
1464 source_ext = get_post_source_data(post_cfg).get("input_extensions")
1465 if isinstance(source_ext, dict):
1466 return source_ext
1467
1468 return {}
1469
1470
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 1471 of file core.py.

1471def get_post_statistics_task_tokens(post_cfg: dict):
1472 """!
1473 @brief Return normalized statistics pipeline tokens that will be written into post.run.
1474 @param[in] post_cfg Argument passed to `get_post_statistics_task_tokens()`.
1475 @return Value returned by `get_post_statistics_task_tokens()`.
1476 """
1477 stats_cfg = post_cfg.get("statistics_pipeline")
1478 stats_entries = []
1479 if isinstance(stats_cfg, list):
1480 stats_entries = stats_cfg
1481 elif isinstance(stats_cfg, dict):
1482 stats_entries = stats_cfg.get("tasks", [])
1483
1484 tokens = []
1485 for entry in stats_entries:
1486 if isinstance(entry, str):
1487 task_name = entry
1488 elif isinstance(entry, dict):
1489 task_name = entry.get("task")
1490 else:
1491 continue
1492 try:
1493 tokens.append(normalize_statistics_task(task_name))
1494 except ValueError:
1495 continue
1496 return tokens
1497
1498
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 1499 of file core.py.

1499def get_monitor_output_directory(monitor_cfg: dict, default: str = "output") -> str:
1500 """!
1501 @brief Resolve the solver output root from monitor.yml, preserving the default layout.
1502 @param[in] monitor_cfg Argument passed to `get_monitor_output_directory()`.
1503 @param[in] default Argument passed to `get_monitor_output_directory()`.
1504 @return Value returned by `get_monitor_output_directory()`.
1505 """
1506 io_cfg = monitor_cfg.get("io")
1507 if isinstance(io_cfg, dict):
1508 directories = io_cfg.get("directories")
1509 if isinstance(directories, dict):
1510 output_dir = directories.get("output")
1511 if isinstance(output_dir, str) and output_dir.strip():
1512 return output_dir.strip()
1513
1514 return default
1515
1516
Here is the caller graph for this function:

◆ 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 1517 of file core.py.

1517def get_post_statistics_output_prefix(post_cfg: dict, default: str = "Stats") -> str:
1518 """!
1519 @brief Resolve the statistics CSV prefix, preserving legacy top-level override support.
1520 @param[in] post_cfg Argument passed to `get_post_statistics_output_prefix()`.
1521 @param[in] default Argument passed to `get_post_statistics_output_prefix()`.
1522 @return Value returned by `get_post_statistics_output_prefix()`.
1523 """
1524 stats_cfg = post_cfg.get("statistics_pipeline")
1525 if isinstance(stats_cfg, dict):
1526 prefix = stats_cfg.get("output_prefix")
1527 if isinstance(prefix, str) and prefix.strip():
1528 return prefix.strip()
1529
1530 legacy_prefix = post_cfg.get("statistics_output_prefix")
1531 if isinstance(legacy_prefix, str) and legacy_prefix.strip():
1532 return legacy_prefix.strip()
1533
1534 return default
1535
1536
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 1537 of file core.py.

1537def resolve_post_statistics_output_prefix(post_cfg: dict, monitor_cfg=None, default: str = "Stats") -> str:
1538 """!
1539 @brief Resolve the runtime statistics prefix, routing bare basenames under the monitor output root.
1540 @param[in] post_cfg Argument passed to `resolve_post_statistics_output_prefix()`.
1541 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
1542 @param[in] default Argument passed to `resolve_post_statistics_output_prefix()`.
1543 @return Value returned by `resolve_post_statistics_output_prefix()`.
1544 """
1545 prefix = get_post_statistics_output_prefix(post_cfg, default=default).strip()
1546 if os.path.isabs(prefix):
1547 return prefix
1548
1549 if os.path.dirname(prefix):
1550 return prefix
1551
1552 monitor_output_dir = get_monitor_output_directory(monitor_cfg or {})
1553 return os.path.join(monitor_output_dir, "statistics", prefix)
1554
1555
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 1556 of file core.py.

1556def get_post_statistics_output_artifacts(post_cfg: dict, run_dir: str, monitor_cfg=None):
1557 """!
1558 @brief Predict statistics CSV output paths relative to the postprocessor runtime cwd.
1559 @param[in] post_cfg Argument passed to `get_post_statistics_output_artifacts()`.
1560 @param[in] run_dir Argument passed to `get_post_statistics_output_artifacts()`.
1561 @param[in] monitor_cfg Optional monitor configuration used to anchor the default statistics home.
1562 @return Value returned by `get_post_statistics_output_artifacts()`.
1563 """
1564 token_to_suffix = {
1565 "ComputeMSD": "_msd.csv",
1566 }
1567 prefix = resolve_post_statistics_output_prefix(post_cfg, monitor_cfg)
1568 if os.path.isabs(prefix):
1569 base_path = os.path.abspath(prefix)
1570 else:
1571 base_path = os.path.abspath(os.path.join(run_dir, prefix))
1572
1573 output_paths = []
1574 for token in get_post_statistics_task_tokens(post_cfg):
1575 suffix = token_to_suffix.get(token)
1576 if suffix:
1577 output_paths.append(base_path + suffix)
1578
1579 return list(dict.fromkeys(output_paths))
1580
1581
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 1582 of file core.py.

1582def build_post_recipe_config(post_cfg: dict, monitor_cfg=None) -> dict:
1583 """!
1584 @brief Build the flat key=value mapping consumed by the C post-processor.
1585 @param[in] post_cfg Argument passed to `build_post_recipe_config()`.
1586 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
1587 @return Value returned by `build_post_recipe_config()`.
1588 """
1589 c_config = {}
1590
1591 c_config['startTime'] = get_post_run_control_value(post_cfg, 'start_step', 0)
1592 c_config['endTime'] = get_post_run_control_value(post_cfg, 'end_step', 0)
1593 c_config['timeStep'] = get_post_run_control_value(post_cfg, 'step_interval', 1)
1594
1595 eulerian_pipeline_parts = []
1596 if post_cfg.get('global_operations', {}).get('dimensionalize', False):
1597 eulerian_pipeline_parts.append('DimensionalizeAllLoadedFields')
1598
1599 for task in post_cfg.get('eulerian_pipeline', []):
1600 task_name = task.get('task')
1601 if task_name == 'q_criterion':
1602 eulerian_pipeline_parts.append('ComputeQCriterion')
1603 elif task_name == 'normalize_field':
1604 field = task.get('field', 'P')
1605 eulerian_pipeline_parts.append(f'NormalizeRelativeField:{field}')
1606 ref_point = task.get('reference_point', [1, 1, 1])
1607 c_config['reference_ip'] = ref_point[0]
1608 c_config['reference_jp'] = ref_point[1]
1609 c_config['reference_kp'] = ref_point[2]
1610 elif task_name == 'nodal_average':
1611 in_field = task.get('input_field')
1612 out_field = task.get('output_field')
1613 if in_field and out_field:
1614 eulerian_pipeline_parts.append(f'CellToNodeAverage:{in_field}>{out_field}')
1615
1616 if eulerian_pipeline_parts:
1617 c_config['process_pipeline'] = ";".join(eulerian_pipeline_parts)
1618
1619 lagrangian_pipeline_parts = []
1620 for task in post_cfg.get('lagrangian_pipeline', []):
1621 task_name = task.get('task')
1622 if task_name == 'specific_ke':
1623 in_field = task.get('input_field')
1624 out_field = task.get('output_field')
1625 if in_field and out_field:
1626 lagrangian_pipeline_parts.append(f'ComputeSpecificKE:{in_field}>{out_field}')
1627 if lagrangian_pipeline_parts:
1628 c_config['particle_pipeline'] = ";".join(lagrangian_pipeline_parts)
1629
1630 statistics_pipeline_parts = get_post_statistics_task_tokens(post_cfg)
1631 statistics_output_prefix = None
1632 stats_cfg = post_cfg.get('statistics_pipeline')
1633 if isinstance(stats_cfg, dict):
1634 statistics_output_prefix = stats_cfg.get('output_prefix')
1635
1636 if statistics_pipeline_parts:
1637 c_config['statistics_pipeline'] = ";".join(statistics_pipeline_parts)
1638 statistics_output_prefix = resolve_post_statistics_output_prefix(post_cfg, monitor_cfg)
1639 elif statistics_output_prefix is None:
1640 statistics_output_prefix = post_cfg.get('statistics_output_prefix')
1641 if statistics_output_prefix:
1642 c_config['statistics_output_prefix'] = statistics_output_prefix
1643
1644 io = post_cfg.get('io', {})
1645 c_config['output_prefix'] = io.get('output_directory', 'viz') + '/' + io.get('output_filename_prefix', 'Field')
1646 c_config['particle_output_prefix'] = io.get('output_directory', 'viz') + '/' + io.get('particle_filename_prefix', 'Particle')
1647 c_config['output_particles'] = io.get('output_particles', False)
1648 c_config['particle_output_freq'] = io.get('particle_subsampling_frequency', 1)
1649 c_config['output_fields_instantaneous'] = ",".join(io.get('eulerian_fields', []))
1650 c_config['output_fields_averaged'] = ",".join(io.get('eulerian_fields_averaged', []))
1651 c_config['particle_fields_instantaneous'] = ",".join(io.get('particle_fields', []))
1652 input_extensions = get_post_input_extensions(post_cfg)
1653 if isinstance(input_extensions, dict):
1654 e_ext = input_extensions.get('eulerian')
1655 p_ext = input_extensions.get('particle')
1656 if e_ext:
1657 c_config['eulerianExt'] = str(e_ext).strip().lstrip('.')
1658 if p_ext:
1659 c_config['particleExt'] = str(p_ext).strip().lstrip('.')
1660
1661 source_directory = get_post_source_directory_template(post_cfg, default=None)
1662 if source_directory is not None:
1663 c_config['source_directory'] = source_directory
1664
1665 return c_config
1666
1667
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 1668 of file core.py.

1668def normalize_post_recipe_signature(recipe_cfg: dict) -> dict:
1669 """!
1670 @brief Normalize post recipe settings into a stable signature mapping.
1671 @param[in] recipe_cfg Argument passed to `normalize_post_recipe_signature()`.
1672 @return Value returned by `normalize_post_recipe_signature()`.
1673 """
1674 signature = {}
1675 for key, value in (recipe_cfg or {}).items():
1676 if key in POST_RECIPE_SIGNATURE_EXCLUDED_KEYS or value is None:
1677 continue
1678 if isinstance(value, bool):
1679 text = 'true' if value else 'false'
1680 else:
1681 text = str(value).strip()
1682 if text.lower() in {'true', 'false'}:
1683 text = text.lower()
1684 if text:
1685 signature[str(key)] = text
1686 return signature
1687
1688
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 1689 of file core.py.

1689def compute_post_recipe_fingerprint(recipe_cfg: dict) -> "tuple[dict, str]":
1690 """!
1691 @brief Return normalized recipe signature plus SHA-256 fingerprint.
1692 @param[in] recipe_cfg Argument passed to `compute_post_recipe_fingerprint()`.
1693 @return Value returned by `compute_post_recipe_fingerprint()`.
1694 """
1695 signature = normalize_post_recipe_signature(recipe_cfg)
1696 payload = json.dumps(signature, sort_keys=True, separators=(',', ':')).encode('utf-8')
1697 return signature, hashlib.sha256(payload).hexdigest()
1698
1699
Here is the call graph for this function:
Here is the caller graph for this function:

◆ parse_post_recipe_file()

picurv_cli.core.parse_post_recipe_file ( str  post_recipe_path)

Parse an existing generated post.run file into a key/value mapping.

Parameters
[in]post_recipe_pathArgument passed to parse_post_recipe_file().
Returns
Value returned by parse_post_recipe_file().

Definition at line 1700 of file core.py.

1700def parse_post_recipe_file(post_recipe_path: str):
1701 """!
1702 @brief Parse an existing generated post.run file into a key/value mapping.
1703 @param[in] post_recipe_path Argument passed to `parse_post_recipe_file()`.
1704 @return Value returned by `parse_post_recipe_file()`.
1705 """
1706 if not post_recipe_path or not os.path.isfile(post_recipe_path):
1707 return None
1708 recipe_cfg = {}
1709 with open(post_recipe_path, 'r', encoding='utf-8', errors='replace') as f:
1710 for raw_line in f:
1711 line = raw_line.strip()
1712 if not line or line.startswith('#') or '=' not in line:
1713 continue
1714 key, value = line.split('=', 1)
1715 recipe_cfg[key.strip()] = value.strip()
1716 return recipe_cfg
1717
1718
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)

Return the JSON resume metadata path for a run directory.

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

Definition at line 1719 of file core.py.

1719def get_post_resume_state_path(run_dir: str) -> str:
1720 """!
1721 @brief Return the JSON resume metadata path for a run directory.
1722 @param[in] run_dir Argument passed to `get_post_resume_state_path()`.
1723 @return Value returned by `get_post_resume_state_path()`.
1724 """
1725 return os.path.join(run_dir, 'config', POST_RESUME_STATE_FILENAME)
1726
1727
Here is the caller graph for this function:

◆ get_post_lock_paths()

dict picurv_cli.core.get_post_lock_paths ( str  run_dir)

Return lock-wrapper related paths for a run directory.

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

Definition at line 1728 of file core.py.

1728def get_post_lock_paths(run_dir: str) -> dict:
1729 """!
1730 @brief Return lock-wrapper related paths for a run directory.
1731 @param[in] run_dir Argument passed to `get_post_lock_paths()`.
1732 @return Value returned by `get_post_lock_paths()`.
1733 """
1734 scheduler_dir = os.path.join(run_dir, 'scheduler')
1735 return {
1736 'lock_file': os.path.join(scheduler_dir, POST_LOCK_FILENAME),
1737 'metadata_file': os.path.join(scheduler_dir, POST_LOCK_METADATA_FILENAME),
1738 'wrapper_path': os.path.join(scheduler_dir, POST_LOCK_WRAPPER_FILENAME),
1739 }
1740
1741
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 1742 of file core.py.

1742def _post_output_directory_abs(run_dir: str, post_cfg: dict) -> str:
1743 """!
1744 @brief Resolve the absolute post output directory for the current recipe.
1745 @param[in] run_dir Argument passed to `_post_output_directory_abs()`.
1746 @param[in] post_cfg Argument passed to `_post_output_directory_abs()`.
1747 @return Value returned by `_post_output_directory_abs()`.
1748 """
1749 io_cfg = post_cfg.get('io', {}) or {}
1750 return os.path.abspath(os.path.join(run_dir, io_cfg.get('output_directory', 'viz')))
1751
1752
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 1753 of file core.py.

1753def _post_requests_eulerian_output(post_cfg: dict) -> bool:
1754 """!
1755 @brief Return whether the current post recipe expects Eulerian VTK output artifacts.
1756 @param[in] post_cfg Argument passed to `_post_requests_eulerian_output()`.
1757 @return Value returned by `_post_requests_eulerian_output()`.
1758 """
1759 io_cfg = post_cfg.get('io', {}) or {}
1760 return bool(io_cfg.get('eulerian_fields'))
1761
1762
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 1763 of file core.py.

1763def _post_requests_particle_output(post_cfg: dict) -> bool:
1764 """!
1765 @brief Return whether the current post recipe expects particle VTP output artifacts.
1766 @param[in] post_cfg Argument passed to `_post_requests_particle_output()`.
1767 @return Value returned by `_post_requests_particle_output()`.
1768 """
1769 io_cfg = post_cfg.get('io', {}) or {}
1770 return bool(io_cfg.get('output_particles')) and bool(io_cfg.get('particle_fields'))
1771
1772
Here is the caller 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 1773 of file core.py.

1773def _post_requests_statistics(post_cfg: dict) -> bool:
1774 """!
1775 @brief Return whether the current post recipe expects statistics CSV artifacts.
1776 @param[in] post_cfg Argument passed to `_post_requests_statistics()`.
1777 @return Value returned by `_post_requests_statistics()`.
1778 """
1779 return bool(get_post_statistics_task_tokens(post_cfg))
1780
1781
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 1782 of file core.py.

1782def _post_needs_particle_source(post_cfg: dict) -> bool:
1783 """!
1784 @brief Return whether the current post recipe requires particle source files to be present.
1785 @param[in] post_cfg Argument passed to `_post_needs_particle_source()`.
1786 @return Value returned by `_post_needs_particle_source()`.
1787 """
1788 io_cfg = post_cfg.get('io', {}) or {}
1789 return bool(io_cfg.get('output_particles')) or bool(post_cfg.get('lagrangian_pipeline')) or _post_requests_statistics(post_cfg)
1790
1791
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 1792 of file core.py.

1792def _iter_post_steps(start_step: int, end_step: int, step_interval: int):
1793 """!
1794 @brief Yield configured post-processing steps inclusively.
1795 @param[in] start_step Argument passed to `_iter_post_steps()`.
1796 @param[in] end_step Argument passed to `_iter_post_steps()`.
1797 @param[in] step_interval Argument passed to `_iter_post_steps()`.
1798 """
1799 if step_interval <= 0 or end_step < start_step:
1800 return
1801 step = start_step
1802 while step <= end_step:
1803 yield step
1804 step += step_interval
1805
1806
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 1807 of file core.py.

1807def resolve_post_requested_window(post_cfg: dict, case_cfg: dict = None) -> "tuple[int, int, int]":
1808 """!
1809 @brief Resolve post requested start/end/interval, expanding end=-1 via case.yml when available.
1810 @param[in] post_cfg Argument passed to `resolve_post_requested_window()`.
1811 @param[in] case_cfg Optional case configuration for end-step expansion.
1812 @return Value returned by `resolve_post_requested_window()`.
1813 """
1814 start_step = int(get_post_run_control_value(post_cfg, 'start_step', 0) or 0)
1815 end_step = int(get_post_run_control_value(post_cfg, 'end_step', 0) or 0)
1816 step_interval = int(get_post_run_control_value(post_cfg, 'step_interval', 1) or 1)
1817 if end_step < 0 and case_cfg:
1818 case_run = case_cfg.get('run_control', {}) or {}
1819 case_start = int(case_run.get('start_step', 0) or 0)
1820 case_total = int(case_run.get('total_steps', 0) or 0)
1821 end_step = case_start + case_total
1822 return start_step, end_step, step_interval
1823
1824
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 1825 of file core.py.

1825def prepare_effective_post_config(post_cfg: dict, resolved_source_dir: str, start_step: int = None, end_step: int = None) -> dict:
1826 """!
1827 @brief Return a copy of post_cfg with resolved source dir and optional effective bounds.
1828 @param[in] post_cfg Argument passed to `prepare_effective_post_config()`.
1829 @param[in] resolved_source_dir Argument passed to `prepare_effective_post_config()`.
1830 @param[in] start_step Argument passed to `prepare_effective_post_config()`.
1831 @param[in] end_step Argument passed to `prepare_effective_post_config()`.
1832 @return Value returned by `prepare_effective_post_config()`.
1833 """
1834 effective_cfg = copy.deepcopy(post_cfg)
1835 if not isinstance(effective_cfg.get('source_data'), dict):
1836 effective_cfg['source_data'] = {}
1837 effective_cfg['source_data']['directory'] = resolved_source_dir
1838 rc = effective_cfg.setdefault('run_control', {})
1839 if start_step is not None:
1840 rc['start_step'] = int(start_step)
1841 if end_step is not None:
1842 rc['end_step'] = int(end_step)
1843 return effective_cfg
1844
1845
Here is the caller graph for this function:

◆ _scan_post_vtk_steps()

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

Scan VTK output files matching '<prefix>_<step>.

<extension>'.

Parameters
[in]prefix_pathArgument passed to _scan_post_vtk_steps().
[in]extensionArgument passed to _scan_post_vtk_steps().
Returns
Value returned by _scan_post_vtk_steps().

Definition at line 1846 of file core.py.

1846def _scan_post_vtk_steps(prefix_path: str, extension: str) -> "set[int]":
1847 """!
1848 @brief Scan VTK output files matching '<prefix>_<step>.<extension>'.
1849 @param[in] prefix_path Argument passed to `_scan_post_vtk_steps()`.
1850 @param[in] extension Argument passed to `_scan_post_vtk_steps()`.
1851 @return Value returned by `_scan_post_vtk_steps()`.
1852 """
1853 directory = os.path.dirname(prefix_path)
1854 if not os.path.isdir(directory):
1855 return set()
1856 basename = os.path.basename(prefix_path)
1857 pattern = re.compile(rf'^{re.escape(basename)}_(\d+)\.{re.escape(extension)}$')
1858 steps = set()
1859 for name in os.listdir(directory):
1860 match = pattern.match(name)
1861 if match:
1862 steps.add(int(match.group(1)))
1863 return steps
1864
1865
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 1866 of file core.py.

1866def _scan_post_statistics_csv_steps(csv_path: str) -> "set[int]":
1867 """!
1868 @brief Scan step ids from the first CSV column of a statistics artifact.
1869 @param[in] csv_path Argument passed to `_scan_post_statistics_csv_steps()`.
1870 @return Value returned by `_scan_post_statistics_csv_steps()`.
1871 """
1872 if not os.path.isfile(csv_path):
1873 return set()
1874 steps = set()
1875 with open(csv_path, 'r', encoding='utf-8', errors='replace', newline='') as f:
1876 reader = csv.reader(f)
1877 for row in reader:
1878 if not row:
1879 continue
1880 head = str(row[0]).strip().lower()
1881 if head in {'step', 'timestep', 'time_step'}:
1882 continue
1883 step_val = _parse_int_loose(row[0])
1884 if step_val is not None:
1885 steps.add(step_val)
1886 return steps
1887
1888
Here is the call graph for this function:
Here is the caller graph for this function:

◆ collect_post_completion_families()

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

Collect per-family completed-step sets for the current post recipe.

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
Value returned by collect_post_completion_families().

Definition at line 1889 of file core.py.

1889def collect_post_completion_families(run_dir: str, post_cfg: dict, monitor_cfg=None) -> "list[set[int]]":
1890 """!
1891 @brief Collect per-family completed-step sets for the current post recipe.
1892 @param[in] run_dir Argument passed to `collect_post_completion_families()`.
1893 @param[in] post_cfg Argument passed to `collect_post_completion_families()`.
1894 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
1895 @return Value returned by `collect_post_completion_families()`.
1896 """
1897 io_cfg = post_cfg.get('io', {}) or {}
1898 output_dir_abs = _post_output_directory_abs(run_dir, post_cfg)
1899 families = []
1900
1901 if _post_requests_eulerian_output(post_cfg):
1902 prefix = os.path.join(output_dir_abs, io_cfg.get('output_filename_prefix', 'Field'))
1903 families.append(_scan_post_vtk_steps(prefix, 'vts'))
1904
1905 if _post_requests_particle_output(post_cfg):
1906 prefix = os.path.join(output_dir_abs, io_cfg.get('particle_filename_prefix', 'Particle'))
1907 families.append(_scan_post_vtk_steps(prefix, 'vtp'))
1908
1909 for stats_path in get_post_statistics_output_artifacts(post_cfg, run_dir, monitor_cfg):
1910 families.append(_scan_post_statistics_csv_steps(stats_path))
1911
1912 return families
1913
1914
Here is the call graph for this function:
Here is the caller graph for this function:

◆ detect_post_completed_frontier()

dict picurv_cli.core.detect_post_completed_frontier ( str  run_dir,
dict  post_cfg,
  monitor_cfg,
int  start_step,
int  end_step,
int  step_interval 
)

Detect the highest contiguous fully completed post step for the current recipe.

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

Definition at line 1915 of file core.py.

1915def detect_post_completed_frontier(run_dir: str, post_cfg: dict, monitor_cfg, start_step: int, end_step: int, step_interval: int) -> dict:
1916 """!
1917 @brief Detect the highest contiguous fully completed post step for the current recipe.
1918 @param[in] run_dir Argument passed to `detect_post_completed_frontier()`.
1919 @param[in] post_cfg Argument passed to `detect_post_completed_frontier()`.
1920 @param[in] monitor_cfg Argument passed to `detect_post_completed_frontier()`.
1921 @param[in] start_step Argument passed to `detect_post_completed_frontier()`.
1922 @param[in] end_step Argument passed to `detect_post_completed_frontier()`.
1923 @param[in] step_interval Argument passed to `detect_post_completed_frontier()`.
1924 @return Value returned by `detect_post_completed_frontier()`.
1925 """
1926 families = collect_post_completion_families(run_dir, post_cfg, monitor_cfg)
1927 frontier = None
1928 if families:
1929 for step in _iter_post_steps(start_step, end_step, step_interval):
1930 if all(step in family for family in families):
1931 frontier = step
1932 else:
1933 break
1934 return {
1935 'frontier_step': frontier,
1936 'artifact_family_count': len(families),
1937 }
1938
1939
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 1940 of file core.py.

1940def _nearest_step(steps: "set[int]", target: int):
1941 """!
1942 @brief Return the complete source step nearest to a target step.
1943 @param[in] steps Candidate step numbers.
1944 @param[in] target Target step number.
1945 @return Nearest candidate, or None when no candidates exist.
1946 """
1947 if not steps:
1948 return None
1949 return min(steps, key=lambda step: (abs(step - target), step))
1950
1951
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 1952 of file core.py.

1952def _format_optional_step(step) -> str:
1953 """!
1954 @brief Format an optional step number for user-facing diagnostics.
1955 @param[in] step Step number or None.
1956 @return Printable step text.
1957 """
1958 return 'none' if step is None else str(step)
1959
1960

◆ _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 1961 of file core.py.

1961def _scan_complete_source_steps(source_dir: str, monitor_cfg: dict, post_cfg: dict) -> "tuple[set[int], dict]":
1962 """!
1963 @brief Scan source artifacts and return steps with every file required by the recipe.
1964 @param[in] source_dir Source output root directory.
1965 @param[in] monitor_cfg Parsed monitor configuration.
1966 @param[in] post_cfg Parsed post-processing configuration.
1967 @return Tuple of complete source steps and source path metadata.
1968 """
1969 dirs = (monitor_cfg.get('io', {}) or {}).get('directories', {}) or {}
1970 euler_subdir = dirs.get('eulerian_subdir', 'eulerian')
1971 particle_subdir = dirs.get('particle_subdir', 'particles')
1972 euler_dir = os.path.join(source_dir, euler_subdir)
1973 particle_dir = os.path.join(source_dir, particle_subdir)
1974
1975 input_extensions = get_post_input_extensions(post_cfg)
1976 euler_ext = str((input_extensions.get('eulerian') or 'dat')).strip().lstrip('.')
1977 particle_ext = str((input_extensions.get('particle') or 'dat')).strip().lstrip('.')
1978
1979 families = []
1980 for basename in POST_REQUIRED_EULERIAN_SOURCE_BASENAMES:
1981 steps = set()
1982 if os.path.isdir(euler_dir):
1983 pattern = re.compile(rf'^{re.escape(basename)}(\d{{5}})_0\.{re.escape(euler_ext)}$')
1984 for name in os.listdir(euler_dir):
1985 match = pattern.match(name)
1986 if match:
1987 steps.add(int(match.group(1)))
1988 families.append(steps)
1989
1990 if _post_needs_particle_source(post_cfg):
1991 steps = set()
1992 if os.path.isdir(particle_dir):
1993 pattern = re.compile(rf'^position(\d{{5}})_0\.{re.escape(particle_ext)}$')
1994 for name in os.listdir(particle_dir):
1995 match = pattern.match(name)
1996 if match:
1997 steps.add(int(match.group(1)))
1998 families.append(steps)
1999
2000 complete_steps = set.intersection(*families) if families else set()
2001 return complete_steps, {
2002 'euler_dir': euler_dir,
2003 'particle_dir': particle_dir,
2004 'euler_ext': euler_ext,
2005 'particle_ext': particle_ext,
2006 }
2007
2008
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 2009 of file core.py.

2009def _expected_source_paths_for_step(step: int, source_scan: dict, post_cfg: dict) -> "list[str]":
2010 """!
2011 @brief Build required source file paths for a single post-processing step.
2012 @param[in] step Requested step number.
2013 @param[in] source_scan Metadata returned by `_scan_complete_source_steps()`.
2014 @param[in] post_cfg Parsed post-processing configuration.
2015 @return Required source artifact paths.
2016 """
2017 paths = [
2018 os.path.join(source_scan['euler_dir'], f'{basename}{step:05d}_0.{source_scan["euler_ext"]}')
2019 for basename in POST_REQUIRED_EULERIAN_SOURCE_BASENAMES
2020 ]
2021 if _post_needs_particle_source(post_cfg):
2022 paths.append(os.path.join(source_scan['particle_dir'], f'position{step:05d}_0.{source_scan["particle_ext"]}'))
2023 return paths
2024
2025
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 2026 of file core.py.

2026def detect_post_source_frontier(source_dir: str, monitor_cfg: dict, post_cfg: dict, start_step: int, end_step: int, step_interval: int) -> dict:
2027 """!
2028 @brief Detect the highest contiguous fully available source step for live post-processing.
2029 @param[in] source_dir Argument passed to `detect_post_source_frontier()`.
2030 @param[in] monitor_cfg Argument passed to `detect_post_source_frontier()`.
2031 @param[in] post_cfg Argument passed to `detect_post_source_frontier()`.
2032 @param[in] start_step Argument passed to `detect_post_source_frontier()`.
2033 @param[in] end_step Argument passed to `detect_post_source_frontier()`.
2034 @param[in] step_interval Argument passed to `detect_post_source_frontier()`.
2035 @return Value returned by `detect_post_source_frontier()`.
2036 """
2037 diagnostic = {
2038 'first_requested_step': start_step,
2039 'first_incomplete_step': None,
2040 'missing_files_for_first_incomplete_step': [],
2041 'closest_complete_step_to_start': None,
2042 'closest_complete_step_to_end': None,
2043 }
2044 if step_interval <= 0 or end_step < start_step or not os.path.isdir(source_dir):
2045 return {
2046 'frontier_step': None,
2047 'diagnostic': diagnostic,
2048 }
2049
2050 complete_steps, source_scan = _scan_complete_source_steps(source_dir, monitor_cfg, post_cfg)
2051 diagnostic['closest_complete_step_to_start'] = _nearest_step(complete_steps, start_step)
2052 diagnostic['closest_complete_step_to_end'] = _nearest_step(complete_steps, end_step)
2053
2054 frontier = None
2055 for step in _iter_post_steps(start_step, end_step, step_interval):
2056 expected_paths = _expected_source_paths_for_step(step, source_scan, post_cfg)
2057 if not all(os.path.isfile(path) for path in expected_paths):
2058 diagnostic['first_incomplete_step'] = step
2059 diagnostic['missing_files_for_first_incomplete_step'] = [
2060 os.path.relpath(path, source_dir) for path in expected_paths if not os.path.isfile(path)
2061 ]
2062 break
2063 frontier = step
2064 return {
2065 'frontier_step': frontier,
2066 'diagnostic': diagnostic,
2067 }
2068
2069
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,
  last_successful_requested_end_step = None 
)

Persist post resume lineage metadata for future –continue runs.

Parameters
[in]run_dirArgument passed to persist_post_resume_state().
[in]planArgument passed to persist_post_resume_state().
[in]last_successful_requested_end_stepArgument passed to persist_post_resume_state().
Returns
Value returned by persist_post_resume_state().

Definition at line 2070 of file core.py.

2070def persist_post_resume_state(run_dir: str, plan: dict, last_successful_requested_end_step=None):
2071 """!
2072 @brief Persist post resume lineage metadata for future --continue runs.
2073 @param[in] run_dir Argument passed to `persist_post_resume_state()`.
2074 @param[in] plan Argument passed to `persist_post_resume_state()`.
2075 @param[in] last_successful_requested_end_step Argument passed to `persist_post_resume_state()`.
2076 @return Value returned by `persist_post_resume_state()`.
2077 """
2078 state_path = get_post_resume_state_path(run_dir)
2079 payload = {
2080 'schema_version': POST_RESUME_SCHEMA_VERSION,
2081 'run_id': plan.get('run_id'),
2082 'recipe_fingerprint': plan.get('recipe_fingerprint'),
2083 'recipe_signature': plan.get('recipe_signature'),
2084 'requested_start_step': plan.get('requested_start_step'),
2085 'requested_end_step': plan.get('requested_end_step'),
2086 'step_interval': plan.get('step_interval'),
2087 'source_directory': plan.get('source_data_directory'),
2088 'resume_match_source': plan.get('resume_match_source'),
2089 'last_successful_requested_end_step': last_successful_requested_end_step,
2090 'updated_at': datetime.now().isoformat(),
2091 }
2092 write_json_file(state_path, payload)
2093 return state_path
2094
2095
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 used to hold an exclusive post-stage lock.

Returns
Value returned by _build_post_lock_wrapper_source().

Definition at line 2096 of file core.py.

2096def _build_post_lock_wrapper_source() -> str:
2097 """!
2098 @brief Return the Python wrapper used to hold an exclusive post-stage lock.
2099 @return Value returned by `_build_post_lock_wrapper_source()`.
2100 """
2101 return """#!/usr/bin/env python3
2102import argparse
2103import fcntl
2104import json
2105import os
2106import socket
2107import subprocess
2108import sys
2109import time
2110
2111
2112def main():
2113 parser = argparse.ArgumentParser(description='PICurv post-stage lock wrapper')
2114 parser.add_argument('--lock-file', required=True)
2115 parser.add_argument('--metadata-file', required=True)
2116 parser.add_argument('--run-dir', required=True)
2117 parser.add_argument('--recipe-fingerprint', required=True)
2118 parser.add_argument('command', nargs=argparse.REMAINDER)
2119 args = parser.parse_args()
2120
2121 command = list(args.command or [])
2122 if not command or command[0] != '--':
2123 parser.error("expected '-- <command ...>' after wrapper arguments")
2124 command = command[1:]
2125
2126 os.makedirs(os.path.dirname(args.lock_file), exist_ok=True)
2127 fd = os.open(args.lock_file, os.O_RDWR | os.O_CREAT, 0o644)
2128 try:
2129 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
2130 except BlockingIOError:
2131 owner = None
2132 try:
2133 with open(args.metadata_file, 'r', encoding='utf-8') as handle:
2134 owner = json.load(handle)
2135 except Exception:
2136 owner = None
2137 if owner:
2138 print(
2139 f"[FATAL] Post stage already active for {args.run_dir} "
2140 f"(pid={owner.get('pid')}, host={owner.get('host')}, started_at={owner.get('started_at')}).",
2141 file=sys.stderr,
2142 )
2143 else:
2144 print(f"[FATAL] Post stage already active for {args.run_dir}.", file=sys.stderr)
2145 return 2
2146
2147 metadata = {
2148 'pid': os.getpid(),
2149 'host': socket.gethostname(),
2150 'started_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
2151 'run_dir': args.run_dir,
2152 'recipe_fingerprint': args.recipe_fingerprint,
2153 'command': command,
2154 }
2155 with open(args.metadata_file, 'w', encoding='utf-8') as handle:
2156 json.dump(metadata, handle, indent=2, sort_keys=True)
2157 handle.write('\\n')
2158
2159 try:
2160 result = subprocess.run(command)
2161 return int(result.returncode)
2162 finally:
2163 try:
2164 os.remove(args.metadata_file)
2165 except FileNotFoundError:
2166 pass
2167 os.close(fd)
2168
2169
2170if __name__ == '__main__':
2171 raise SystemExit(main())
2172"""
2173
2174
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 2175 of file core.py.

2175def ensure_post_lock_wrapper(run_dir: str) -> str:
2176 """!
2177 @brief Ensure the lock wrapper exists for a run directory and return its path.
2178 @param[in] run_dir Argument passed to `ensure_post_lock_wrapper()`.
2179 @return Value returned by `ensure_post_lock_wrapper()`.
2180 """
2181 paths = get_post_lock_paths(run_dir)
2182 wrapper_path = paths['wrapper_path']
2183 content = _build_post_lock_wrapper_source()
2184 existing_content = None
2185 os.makedirs(os.path.dirname(wrapper_path), exist_ok=True)
2186 if os.path.isfile(wrapper_path):
2187 with open(wrapper_path, 'r', encoding='utf-8', errors='replace') as f:
2188 existing_content = f.read()
2189 if existing_content != content:
2190 with open(wrapper_path, 'w', encoding='utf-8') as f:
2191 f.write(content)
2192 os.chmod(wrapper_path, 0o755)
2193 return wrapper_path
2194
2195
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 
)

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().
Returns
Value returned by build_post_locked_command().

Definition at line 2196 of file core.py.

2196def build_post_locked_command(run_dir: str, recipe_fingerprint: str, wrapped_command: list, create_wrapper: bool = True) -> "tuple[list, dict]":
2197 """!
2198 @brief Wrap a postprocessor command behind the run-dir-scoped lock wrapper.
2199 @param[in] run_dir Argument passed to `build_post_locked_command()`.
2200 @param[in] recipe_fingerprint Argument passed to `build_post_locked_command()`.
2201 @param[in] wrapped_command Argument passed to `build_post_locked_command()`.
2202 @param[in] create_wrapper Argument passed to `build_post_locked_command()`.
2203 @return Value returned by `build_post_locked_command()`.
2204 """
2205 lock_paths = get_post_lock_paths(run_dir)
2206 wrapper_path = ensure_post_lock_wrapper(run_dir) if create_wrapper else lock_paths['wrapper_path']
2207 command = [
2208 wrapper_path,
2209 '--lock-file', lock_paths['lock_file'],
2210 '--metadata-file', lock_paths['metadata_file'],
2211 '--run-dir', run_dir,
2212 '--recipe-fingerprint', recipe_fingerprint,
2213 '--',
2214 ] + list(wrapped_command)
2215 return command, lock_paths
2216
2217
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   continue_requested = False,
bool   allow_source_frontier_scan = True 
)

Resolve post resume/source-availability behavior into one execution plan.

Parameters
[in]run_dirArgument passed to build_post_execution_plan().
[in]run_idArgument passed to build_post_execution_plan().
[in]case_cfgArgument passed to build_post_execution_plan().
[in]monitor_cfgArgument passed to build_post_execution_plan().
[in]post_cfgArgument passed to build_post_execution_plan().
[in]continue_requestedArgument passed to build_post_execution_plan().
[in]allow_source_frontier_scanArgument passed to build_post_execution_plan().
Returns
Value returned by build_post_execution_plan().

Definition at line 2218 of file core.py.

2226) -> dict:
2227 """!
2228 @brief Resolve post resume/source-availability behavior into one execution plan.
2229 @param[in] run_dir Argument passed to `build_post_execution_plan()`.
2230 @param[in] run_id Argument passed to `build_post_execution_plan()`.
2231 @param[in] case_cfg Argument passed to `build_post_execution_plan()`.
2232 @param[in] monitor_cfg Argument passed to `build_post_execution_plan()`.
2233 @param[in] post_cfg Argument passed to `build_post_execution_plan()`.
2234 @param[in] continue_requested Argument passed to `build_post_execution_plan()`.
2235 @param[in] allow_source_frontier_scan Argument passed to `build_post_execution_plan()`.
2236 @return Value returned by `build_post_execution_plan()`.
2237 """
2238 requested_start_step, requested_end_step, step_interval = resolve_post_requested_window(post_cfg, case_cfg)
2239 resolved_source_dir = _resolve_post_source_directory_preview(run_dir, monitor_cfg, post_cfg)
2240 resolved_post_cfg = prepare_effective_post_config(post_cfg, resolved_source_dir)
2241 recipe_cfg = build_post_recipe_config(resolved_post_cfg, monitor_cfg)
2242 recipe_signature, recipe_fingerprint = compute_post_recipe_fingerprint(recipe_cfg)
2243
2244 state_path = get_post_resume_state_path(run_dir)
2245 state_payload = _read_json_if_exists(state_path)
2246 state_match = bool(isinstance(state_payload, dict) and state_payload.get('recipe_fingerprint') == recipe_fingerprint)
2247
2248 legacy_post_run_path = os.path.join(run_dir, 'config', 'post.run')
2249 legacy_recipe_cfg = parse_post_recipe_file(legacy_post_run_path)
2250 legacy_recipe_signature = normalize_post_recipe_signature(legacy_recipe_cfg or {}) if legacy_recipe_cfg else None
2251 legacy_match = bool(legacy_recipe_signature and legacy_recipe_signature == recipe_signature)
2252
2253 resume_recipe_match = False
2254 resume_match_source = None
2255 resume_bootstrapped = False
2256 if continue_requested:
2257 if state_match:
2258 resume_recipe_match = True
2259 resume_match_source = 'state'
2260 elif not state_payload and legacy_match:
2261 resume_recipe_match = True
2262 resume_match_source = 'legacy_post_run'
2263 resume_bootstrapped = True
2264
2265 completion_info = detect_post_completed_frontier(
2266 run_dir,
2267 resolved_post_cfg,
2268 monitor_cfg,
2269 requested_start_step,
2270 requested_end_step,
2271 step_interval,
2272 )
2273 completed_frontier_step = completion_info['frontier_step']
2274 if completion_info['artifact_family_count'] == 0 and state_match:
2275 completed_frontier_step = _parse_int_loose(state_payload.get('last_successful_requested_end_step'))
2276
2277 if continue_requested and resume_recipe_match and completed_frontier_step is not None:
2278 effective_start_step = completed_frontier_step + step_interval
2279 else:
2280 effective_start_step = requested_start_step
2281
2282 source_frontier_step = None
2283 source_frontier_diagnostic = None
2284 source_frontier_deferred = not allow_source_frontier_scan
2285 skip_reason = None
2286 if effective_start_step > requested_end_step:
2287 skip_reason = 'already-complete-window'
2288 effective_end_step = requested_end_step
2289 elif allow_source_frontier_scan:
2290 source_frontier_info = detect_post_source_frontier(
2291 resolved_source_dir,
2292 monitor_cfg,
2293 resolved_post_cfg,
2294 effective_start_step,
2295 requested_end_step,
2296 step_interval,
2297 )
2298 source_frontier_step = source_frontier_info['frontier_step']
2299 source_frontier_diagnostic = source_frontier_info['diagnostic']
2300 if source_frontier_step is None or source_frontier_step < effective_start_step:
2301 if continue_requested and resume_recipe_match and completed_frontier_step is not None:
2302 skip_reason = 'already-caught-up-to-current-source-frontier'
2303 else:
2304 skip_reason = 'nothing-available-yet'
2305 effective_end_step = None
2306 else:
2307 effective_end_step = min(requested_end_step, source_frontier_step)
2308 else:
2309 effective_end_step = requested_end_step
2310
2311 effective_post_cfg = None
2312 if skip_reason is None:
2313 effective_post_cfg = prepare_effective_post_config(
2314 post_cfg,
2315 resolved_source_dir,
2316 start_step=effective_start_step,
2317 end_step=effective_end_step,
2318 )
2319
2320 return {
2321 'run_id': run_id,
2322 'continue_requested': bool(continue_requested),
2323 'requested_start_step': requested_start_step,
2324 'requested_end_step': requested_end_step,
2325 'step_interval': step_interval,
2326 'source_data_directory': resolved_source_dir,
2327 'recipe_config': recipe_cfg,
2328 'recipe_signature': recipe_signature,
2329 'recipe_fingerprint': recipe_fingerprint,
2330 'resume_state_path': state_path,
2331 'resume_state_payload': state_payload,
2332 'resume_recipe_match': resume_recipe_match,
2333 'resume_match_source': resume_match_source,
2334 'resume_bootstrapped': resume_bootstrapped,
2335 'completed_frontier_step': completed_frontier_step,
2336 'source_frontier_step': source_frontier_step,
2337 'source_frontier_diagnostic': source_frontier_diagnostic,
2338 'source_frontier_deferred': source_frontier_deferred,
2339 'effective_start_step': effective_start_step,
2340 'effective_end_step': effective_end_step,
2341 'skip_reason': skip_reason,
2342 'resolved_post_cfg': resolved_post_cfg,
2343 'effective_post_cfg': effective_post_cfg,
2344 'lock_paths': get_post_lock_paths(run_dir),
2345 }
2346
2347
Here is the call graph for this function:
Here is the caller 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 2348 of file core.py.

2348def needs_restart_source(case_cfg: dict, solver_cfg: dict) -> bool:
2349 """!
2350 @brief Return True when the solver requires restart data from disk.
2351 @details Correctly identifies that analytical + init + start_step > 0 does NOT
2352 need a restart source (C code never reads from restart_dir in that case).
2353 @param[in] case_cfg Parsed case YAML dictionary.
2354 @param[in] solver_cfg Parsed solver YAML dictionary.
2355 @return True if a restart source (--restart-from or --continue) is required.
2356 """
2357 try:
2358 start_step = int(case_cfg.get("run_control", {}).get("start_step", 0) or 0)
2359 except (TypeError, ValueError):
2360 start_step = 0
2361 eulerian_source = str(
2362 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
2363 ).strip().lower()
2364 particle_restart_mode = str(
2365 (case_cfg.get("models", {}).get("physics", {}).get("particles", {}) or {}).get("restart_mode", "init")
2366 ).strip().lower()
2367 euler_needs = (eulerian_source == "load") or (eulerian_source == "solve" and start_step > 0)
2368 particle_needs = (particle_restart_mode == "load")
2369 return euler_needs or particle_needs
2370
2371
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 2372 of file core.py.

2372def resolve_run_output_dir(run_dir: str, monitor_cfg: dict) -> str:
2373 """!
2374 @brief Resolve the output data directory within a run directory.
2375 @param[in] run_dir Path to the run directory.
2376 @param[in] monitor_cfg Parsed monitor YAML dictionary.
2377 @return Absolute path to the output directory.
2378 """
2379 dirs = (monitor_cfg.get("io", {}) or {}).get("directories", {}) or {}
2380 output_rel = dirs.get("output", "output")
2381 return os.path.abspath(os.path.join(run_dir, output_rel))
2382
2383
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 2384 of file core.py.

2384def resolve_run_restart_dir(run_dir: str, monitor_cfg: dict) -> str:
2385 """!
2386 @brief Resolve the restart staging directory within a run directory.
2387 @param[in] run_dir Path to the run directory.
2388 @param[in] monitor_cfg Parsed monitor YAML dictionary.
2389 @return Absolute path to the restart directory.
2390 """
2391 dirs = (monitor_cfg.get("io", {}) or {}).get("directories", {}) or {}
2392 restart_rel = dirs.get("restart", "restart")
2393 return os.path.abspath(os.path.join(run_dir, restart_rel))
2394
2395
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 
)

Copy checkpoint files for a specific step from source output to target restart.

Parameters
[in]source_outputPath to the source output directory containing checkpoint data.
[in]target_restartPath to the target restart directory to populate.
[in]start_stepThe step number whose checkpoint files should be copied.
[in]monitor_cfgParsed monitor YAML dictionary (for subdirectory names).

Definition at line 2396 of file core.py.

2396def populate_restart_directory(source_output: str, target_restart: str, start_step: int, monitor_cfg: dict):
2397 """!
2398 @brief Copy checkpoint files for a specific step from source output to target restart.
2399 @param[in] source_output Path to the source output directory containing checkpoint data.
2400 @param[in] target_restart Path to the target restart directory to populate.
2401 @param[in] start_step The step number whose checkpoint files should be copied.
2402 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
2403 """
2404 dirs = (monitor_cfg.get("io", {}) or {}).get("directories", {}) or {}
2405 euler_sub = dirs.get("eulerian_subdir", "eulerian")
2406 particle_sub = dirs.get("particle_subdir", "particles")
2407
2408 if os.path.exists(target_restart):
2409 shutil.rmtree(target_restart)
2410 os.makedirs(os.path.join(target_restart, euler_sub), exist_ok=True)
2411 os.makedirs(os.path.join(target_restart, particle_sub), exist_ok=True)
2412
2413 step_str = f"{start_step:05d}"
2414 for subdir in [euler_sub, particle_sub]:
2415 src = os.path.join(source_output, subdir)
2416 dst = os.path.join(target_restart, subdir)
2417 if not os.path.isdir(src):
2418 continue
2419 for f_name in glob.glob(os.path.join(src, f"*{step_str}_0.*")):
2420 shutil.copy2(f_name, dst)
2421
2422 copied = glob.glob(os.path.join(target_restart, "**", f"*{step_str}_0.*"), recursive=True)
2423 if not copied:
2424 raise ValueError(f"No checkpoint files found for step {start_step} in {source_output}")
2425 print(f"[INFO] Populated restart directory with {len(copied)} file(s) for step {start_step}: {target_restart}")
2426
2427
Here is the caller graph for this function:

◆ detect_last_checkpoint_step()

picurv_cli.core.detect_last_checkpoint_step ( str  output_dir,
str   euler_subdir = "eulerian",
str   particle_subdir = "particles" 
)

Scan output directory for the highest step number available.

Checks eulerian files first (ufield), then falls back to particle files (position) for analytical-mode cases that have no eulerian output.

Parameters
[in]output_dirPath to the output directory.
[in]euler_subdirName of the eulerian subdirectory.
[in]particle_subdirName of the particle subdirectory.
Returns
The highest step number found, or None if no checkpoints exist.

Definition at line 2428 of file core.py.

2428def detect_last_checkpoint_step(output_dir: str, euler_subdir: str = "eulerian", particle_subdir: str = "particles"):
2429 """!
2430 @brief Scan output directory for the highest step number available.
2431 @details Checks eulerian files first (ufield), then falls back to particle
2432 files (position) for analytical-mode cases that have no eulerian output.
2433 @param[in] output_dir Path to the output directory.
2434 @param[in] euler_subdir Name of the eulerian subdirectory.
2435 @param[in] particle_subdir Name of the particle subdirectory.
2436 @return The highest step number found, or None if no checkpoints exist.
2437 """
2438 import re as _re
2439 euler_path = os.path.join(output_dir, euler_subdir)
2440 if os.path.isdir(euler_path):
2441 pattern = _re.compile(r"ufield(\d{5})_0\.dat")
2442 steps = []
2443 for fname in os.listdir(euler_path):
2444 match = pattern.match(fname)
2445 if match:
2446 steps.append(int(match.group(1)))
2447 if steps:
2448 return max(steps)
2449 particle_path = os.path.join(output_dir, particle_subdir)
2450 if os.path.isdir(particle_path):
2451 pattern = _re.compile(r"position(\d{5})_0\.dat")
2452 steps = []
2453 for fname in os.listdir(particle_path):
2454 match = pattern.match(fname)
2455 if match:
2456 steps.append(int(match.group(1)))
2457 if steps:
2458 return max(steps)
2459 return None
2460
2461
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 2462 of file core.py.

2462def detect_case_completion_status(run_dir: str, monitor_cfg: dict, target_final_step: int) -> dict:
2463 """!
2464 @brief Determine whether a study case is complete, partially complete, or empty.
2465 @param[in] run_dir Path to the case run directory.
2466 @param[in] monitor_cfg Parsed monitor YAML dictionary.
2467 @param[in] target_final_step The step number the case should reach for completion.
2468 @return Dictionary with keys 'last_step' (int or None), 'target_step' (int),
2469 and 'status' ('complete', 'partial', or 'empty').
2470 """
2471 dirs = (monitor_cfg.get("io", {}) or {}).get("directories", {}) or {}
2472 euler_sub = dirs.get("eulerian_subdir", "eulerian")
2473 particle_sub = dirs.get("particle_subdir", "particles")
2474 output_dir = resolve_run_output_dir(run_dir, monitor_cfg)
2475 last_step = detect_last_checkpoint_step(output_dir, euler_sub, particle_sub)
2476 if last_step is not None and last_step >= target_final_step:
2477 status = "complete"
2478 elif last_step is not None:
2479 status = "partial"
2480 else:
2481 status = "empty"
2482 return {"last_step": last_step, "target_step": target_final_step, "status": status}
2483
2484
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 2485 of file core.py.

2485def validate_load_mode_step_range(source_output: str, start_step: int, total_steps: int, monitor_cfg: dict):
2486 """!
2487 @brief Validate that all required eulerian step files exist for "load" mode.
2488 @details Checks that ufield files exist for every step from start_step through
2489 start_step + total_steps (inclusive). Reports missing steps clearly.
2490 @param[in] source_output Path to the output directory containing eulerian data.
2491 @param[in] start_step First step that will be loaded.
2492 @param[in] total_steps Number of steps to run.
2493 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
2494 """
2495 dirs = (monitor_cfg.get("io", {}) or {}).get("directories", {}) or {}
2496 euler_sub = dirs.get("eulerian_subdir", "eulerian")
2497 euler_path = os.path.join(source_output, euler_sub)
2498
2499 missing = []
2500 for step in range(start_step, start_step + total_steps + 1):
2501 expected = os.path.join(euler_path, f"ufield{step:05d}_0.dat")
2502 if not os.path.isfile(expected):
2503 missing.append(step)
2504
2505 if missing:
2506 sample = missing[:3] + (["..."] if len(missing) > 6 else []) + missing[-3:]
2507 raise ValueError(
2508 f"Eulerian 'load' mode: {len(missing)} step file(s) missing in {euler_path}. "
2509 f"Missing steps include: {sample}"
2510 )
2511
2512
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).

Definition at line 2513 of file core.py.

2513def validate_particle_checkpoint(source_dir: str, start_step: int, monitor_cfg: dict):
2514 """!
2515 @brief Validate that particle checkpoint files exist for the given step.
2516 @details Checks that at least a position file exists at the expected step in
2517 the particle subdirectory.
2518 @param[in] source_dir Path to the directory containing the particle subdirectory.
2519 @param[in] start_step The step number whose particle checkpoint is expected.
2520 @param[in] monitor_cfg Parsed monitor YAML dictionary (for subdirectory names).
2521 """
2522 dirs = (monitor_cfg.get("io", {}) or {}).get("directories", {}) or {}
2523 particle_sub = dirs.get("particle_subdir", "particles")
2524 particle_path = os.path.join(source_dir, particle_sub)
2525 expected = os.path.join(particle_path, f"position{start_step:05d}_0.dat")
2526 if not os.path.isfile(expected):
2527 raise ValueError(
2528 f"Particle restart_mode='load' but checkpoint not found: {expected}"
2529 )
2530
2531
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 2532 of file core.py.

2532def read_monitor_from_run(run_dir: str) -> dict:
2533 """!
2534 @brief Read the monitor.yml from a run directory's config/ subdirectory.
2535 @param[in] run_dir Path to the run directory.
2536 @return Parsed monitor YAML dictionary.
2537 """
2538 monitor_path = os.path.join(run_dir, "config", "monitor.yml")
2539 if not os.path.isfile(monitor_path):
2540 raise ValueError(f"Run directory is missing config/monitor.yml: {monitor_path}")
2541 return read_yaml_file(monitor_path)
2542
2543
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 
)

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.
Returns
Tuple of (restart_source_dir, continue_mode) where restart_source_dir is the resolved path (or None) and continue_mode is a boolean.

Definition at line 2544 of file core.py.

2544def resolve_restart_source(args, case_cfg: dict, solver_cfg: dict, monitor_cfg: dict, run_dir: str):
2545 """!
2546 @brief Resolve the restart source directory based on --restart-from or --continue CLI flags.
2547 @details Implements the full restart resolution logic including smart resolution for
2548 --continue (checks restart/ first for user-curated data, falls back to output/)
2549 and direct reference for eulerian "load" mode.
2550 @param[in] args Parsed CLI arguments (must have restart_from, continue_run, run_dir attrs).
2551 @param[in] case_cfg Parsed case YAML dictionary.
2552 @param[in] solver_cfg Parsed solver YAML dictionary.
2553 @param[in] monitor_cfg Parsed monitor YAML dictionary.
2554 @param[in] run_dir Path to the current run directory.
2555 @return Tuple of (restart_source_dir, continue_mode) where restart_source_dir is the
2556 resolved path (or None) and continue_mode is a boolean.
2557 """
2558 try:
2559 start_step = int(case_cfg.get("run_control", {}).get("start_step", 0) or 0)
2560 except (TypeError, ValueError):
2561 start_step = 0
2562 try:
2563 total_steps = int(case_cfg.get("run_control", {}).get("total_steps", 0) or 0)
2564 except (TypeError, ValueError):
2565 total_steps = 0
2566
2567 eulerian_source = str(
2568 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
2569 ).strip().lower()
2570
2571 dirs = (monitor_cfg.get("io", {}) or {}).get("directories", {}) or {}
2572 euler_sub = dirs.get("eulerian_subdir", "eulerian")
2573 particle_sub = dirs.get("particle_subdir", "particles")
2574
2575 particle_restart_mode = str(
2576 (case_cfg.get("models", {}).get("physics", {}).get("particles", {}) or {}).get("restart_mode", "init")
2577 ).strip().lower()
2578 particle_needs = (particle_restart_mode == "load")
2579
2580 requires_source = needs_restart_source(case_cfg, solver_cfg)
2581 restart_from = getattr(args, 'restart_from', None)
2582 continue_run = getattr(args, 'continue_run', False)
2583
2584 if restart_from and continue_run:
2585 raise ValueError("--restart-from and --continue are mutually exclusive.")
2586
2587 if restart_from:
2588 # === MODE 1: New run, restart from another run ===
2589 source_run = os.path.abspath(restart_from)
2590 if not os.path.isdir(source_run):
2591 raise ValueError(f"--restart-from run directory does not exist: {source_run}")
2592 source_monitor = read_monitor_from_run(source_run)
2593 source_output = resolve_run_output_dir(source_run, source_monitor)
2594 if not os.path.isdir(source_output):
2595 raise ValueError(f"Source output directory does not exist: {source_output}")
2596
2597 if not requires_source:
2598 # R7: analytical + init — warn that --restart-from is unused
2599 print(
2600 "[WARN] --restart-from specified but no data will be read "
2601 "(analytical + init does not need restart data).",
2602 file=sys.stderr,
2603 )
2604 return None, False
2605
2606 if eulerian_source == "load":
2607 # R3/R4/R5: Direct reference to source output
2608 validate_load_mode_step_range(source_output, start_step, total_steps, source_monitor)
2609 if particle_needs:
2610 validate_particle_checkpoint(source_output, start_step, source_monitor)
2611 return source_output, False
2612 else:
2613 # R1/R2/R6: Copy checkpoint to new run's restart/
2614 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
2615 populate_restart_directory(source_output, target_restart, start_step, monitor_cfg)
2616 if particle_needs:
2617 validate_particle_checkpoint(target_restart, start_step, monitor_cfg)
2618 return target_restart, False
2619
2620 elif continue_run:
2621 # === MODE 2: Continue in-place ===
2622 continue_run_dir = getattr(args, 'run_dir', None)
2623 if not continue_run_dir:
2624 raise ValueError("--continue requires --run-dir.")
2625 continue_run_dir = os.path.abspath(continue_run_dir)
2626 if not os.path.isdir(continue_run_dir):
2627 raise ValueError(f"--run-dir does not exist: {continue_run_dir}")
2628
2629 source_output = resolve_run_output_dir(continue_run_dir, monitor_cfg)
2630 target_restart = resolve_run_restart_dir(continue_run_dir, monitor_cfg)
2631
2632 # Warn if start_step != last checkpoint
2633 last_step = detect_last_checkpoint_step(source_output, euler_sub)
2634 if last_step is not None and last_step != start_step:
2635 print(
2636 f"[WARN] start_step={start_step} but last checkpoint in output is step {last_step}.",
2637 file=sys.stderr,
2638 )
2639
2640 if eulerian_source == "load":
2641 # C3/C4: Direct reference to output/ (all euler steps there, can't copy)
2642 validate_load_mode_step_range(source_output, start_step, total_steps, monitor_cfg)
2643 if particle_needs:
2644 validate_particle_checkpoint(source_output, start_step, monitor_cfg)
2645 return source_output, True
2646 elif not requires_source:
2647 # C6: analytical + init — only log-append behavior, no data needed
2648 return None, True
2649 else:
2650 # C1/C2/C5: Smart resolution for solve/analytical
2651 euler_needs = (eulerian_source == "solve" and start_step > 0)
2652
2653 step_str = f"{start_step:05d}"
2654 needed_subs = []
2655 if euler_needs:
2656 needed_subs.append(euler_sub)
2657 if particle_needs:
2658 needed_subs.append(particle_sub)
2659
2660 # Check restart/ for EACH needed component
2661 restart_has = {}
2662 if os.path.isdir(target_restart):
2663 for sub in needed_subs:
2664 restart_has[sub] = bool(
2665 glob.glob(os.path.join(target_restart, sub, f"*{step_str}_0.*"))
2666 )
2667
2668 all_in_restart = needed_subs and all(restart_has.get(s, False) for s in needed_subs)
2669 some_in_restart = any(restart_has.get(s, False) for s in needed_subs)
2670
2671 if all_in_restart:
2672 # Fully curated restart/ (warm-up-and-discard workflow)
2673 print(f"[INFO] Using curated restart directory: {target_restart}")
2674 return target_restart, True
2675 elif os.path.isdir(source_output):
2676 # Auto-populate missing components from output/ into restart/
2677 if some_in_restart:
2678 # Partial curate: only copy components NOT already in restart/
2679 missing = [s for s in needed_subs if not restart_has.get(s, False)]
2680 os.makedirs(target_restart, exist_ok=True)
2681 for sub in missing:
2682 src = os.path.join(source_output, sub)
2683 dst = os.path.join(target_restart, sub)
2684 os.makedirs(dst, exist_ok=True)
2685 if os.path.isdir(src):
2686 for f_name in glob.glob(os.path.join(src, f"*{step_str}_0.*")):
2687 shutil.copy2(f_name, dst)
2688 filled = glob.glob(os.path.join(target_restart, "**", f"*{step_str}_0.*"), recursive=True)
2689 print(f"[INFO] Merged curated restart/ with {len(missing)} component(s) from output/: {target_restart}")
2690 if not filled:
2691 raise ValueError(
2692 f"After merge, no checkpoint files found for step {start_step} in {target_restart}"
2693 )
2694 return target_restart, True
2695 else:
2696 # Nothing curated — full populate from output/
2697 populate_restart_directory(source_output, target_restart, start_step, monitor_cfg)
2698 return target_restart, True
2699 else:
2700 raise ValueError(
2701 f"Neither restart/ nor output/ contain data for step {start_step}. "
2702 f"Checked: {target_restart}, {source_output}"
2703 )
2704
2705 elif requires_source:
2706 raise ValueError(
2707 "Restart data required but no source specified. Use:\n"
2708 " --restart-from <run_dir> (new run from another run's data)\n"
2709 " --continue --run-dir <run_dir> (resume in same directory)"
2710 )
2711
2712 return None, False
2713
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 2714 of file core.py.

2714def absolutize_case_external_paths(case_cfg: dict, case_anchor_path: str):
2715 """!
2716 @brief Convert external grid/generator paths in case config to absolute paths.
2717 @param[in] case_cfg Argument passed to `absolutize_case_external_paths()`.
2718 @param[in] case_anchor_path Argument passed to `absolutize_case_external_paths()`.
2719 """
2720 grid_cfg = case_cfg.get("grid", {})
2721 if not isinstance(grid_cfg, dict):
2722 return
2723 mode = grid_cfg.get("mode")
2724 if mode == "file":
2725 source_file = grid_cfg.get("source_file")
2726 if isinstance(source_file, str):
2727 grid_cfg["source_file"] = resolve_path(case_anchor_path, source_file)
2728 legacy_cfg = grid_cfg.get("legacy_conversion", {})
2729 if isinstance(legacy_cfg, dict):
2730 script_path = legacy_cfg.get("script")
2731 if isinstance(script_path, str):
2732 legacy_cfg["script"] = resolve_path(case_anchor_path, script_path)
2733 elif mode == "grid_gen":
2734 gen = grid_cfg.get("generator", {})
2735 if isinstance(gen, dict):
2736 for key in ("script", "config_file"):
2737 val = gen.get(key)
2738 if isinstance(val, str):
2739 gen[key] = resolve_path(case_anchor_path, val)
2740 ic = (case_cfg.get("properties", {}) or {}).get("initial_conditions", {})
2741 if isinstance(ic, dict):
2742 if str(ic.get("mode", "")).strip().lower() == "file":
2743 source_file = ic.get("source_file")
2744 if isinstance(source_file, str):
2745 ic["source_file"] = resolve_path(case_anchor_path, source_file)
2746 elif str(ic.get("generator", "")).strip().lower() == "ic_gen":
2747 params = ic.get("params", {})
2748 if isinstance(params, dict):
2749 for key in ("script", "config_file"):
2750 value = params.get(key)
2751 if isinstance(value, str):
2752 params[key] = resolve_path(case_anchor_path, value)
2753 boundary_conditions = case_cfg.get("boundary_conditions", [])
2754 blocks = boundary_conditions if boundary_conditions and isinstance(boundary_conditions[0], list) else [boundary_conditions]
2755 for block in blocks:
2756 if not isinstance(block, list):
2757 continue
2758 for bc in block:
2759 if not isinstance(bc, dict) or str(bc.get("handler", "")).strip().lower() != "prescribed_flow":
2760 continue
2761 source = ((bc.get("params") or {}).get("source") or {})
2762 if not isinstance(source, dict):
2763 continue
2764 source_type = str(source.get("type", "")).strip().lower()
2765 if source_type == "file":
2766 keys = ("path",)
2767 elif source_type == "generated":
2768 keys = ("script",)
2769 elif source_type == "field_slice":
2770 keys = ("script", "field_file", "grid_file", "source_case")
2771 else:
2772 keys = ()
2773 for key in keys:
2774 value = source.get(key)
2775 if isinstance(value, str):
2776 source[key] = resolve_path(case_anchor_path, value)
2777
2778
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 2779 of file core.py.

2780 target_final_step: int, cluster_cfg: dict):
2781 """!
2782 @brief Set up a partially-completed study case for continuation in-place.
2783 @details Updates the case config with new start_step/total_steps, sets particle
2784 restart_mode to 'load' if checkpoint exists, populates the restart
2785 directory, and regenerates the solver control file with continue_mode.
2786 Delegates all restart resolution to resolve_restart_source().
2787 @param[in] run_dir Path to the case run directory.
2788 @param[in] case_id The case identifier (e.g. 'case_0002').
2789 @param[in] last_step The last checkpoint step found in the output directory.
2790 @param[in] target_final_step The step number the case should reach for completion.
2791 @param[in] cluster_cfg Parsed cluster YAML dictionary (for num_procs, walltime guard).
2792 @return The absolute path to the regenerated control file.
2793 """
2794 config_dir = os.path.join(run_dir, "config")
2795 case_cfg = read_yaml_file(os.path.join(config_dir, "case.yml"))
2796 solver_cfg = read_yaml_file(os.path.join(config_dir, "solver.yml"))
2797 monitor_cfg = read_yaml_file(os.path.join(config_dir, "monitor.yml"))
2798
2799 remaining = target_final_step - last_step
2800 case_cfg["run_control"]["start_step"] = last_step
2801 case_cfg["run_control"]["total_steps"] = remaining
2802 print(f"[INFO] {case_id}: updating start_step={last_step}, total_steps={remaining}")
2803
2804 dirs = (monitor_cfg.get("io", {}) or {}).get("directories", {}) or {}
2805 particle_sub = dirs.get("particle_subdir", "particles")
2806 output_dir = resolve_run_output_dir(run_dir, monitor_cfg)
2807 particle_ckpt = os.path.join(output_dir, particle_sub, f"position{last_step:05d}_0.dat")
2808 particles_cfg = (case_cfg.get("models", {}).get("physics", {}) or {}).get("particles")
2809 if particles_cfg is not None and os.path.isfile(particle_ckpt):
2810 current_mode = str(particles_cfg.get("restart_mode", "init")).strip().lower()
2811 if current_mode != "load":
2812 particles_cfg["restart_mode"] = "load"
2813 print(f"[INFO] {case_id}: setting particle restart_mode='load' (checkpoint found)")
2814
2815 write_yaml_file(os.path.join(config_dir, "case.yml"), case_cfg)
2816
2817 mock_args = argparse.Namespace(restart_from=None, continue_run=True, run_dir=run_dir)
2818 restart_source_dir, continue_mode = resolve_restart_source(
2819 mock_args, case_cfg, solver_cfg, monitor_cfg, run_dir
2820 )
2821
2822 source_files = {
2823 'Case': os.path.join(config_dir, "case.yml"),
2824 'Solver': os.path.join(config_dir, "solver.yml"),
2825 'Monitor': os.path.join(config_dir, "monitor.yml"),
2826 }
2827 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
2828 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
2829 configs = {
2830 "case": case_cfg, "case_path": source_files['Case'],
2831 "solver": solver_cfg, "solver_path": source_files['Solver'],
2832 "monitor": monitor_cfg, "monitor_path": source_files['Monitor'],
2833 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
2834 }
2835 control_file = generate_solver_control_file(
2836 run_dir, case_id, configs, cluster_tasks, monitor_files,
2837 restart_source_dir=restart_source_dir, continue_mode=continue_mode,
2838 )
2839 print(f"[SUCCESS] {case_id}: regenerated control file for continuation")
2840 return control_file
2841
2842
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 2843 of file core.py.

2843def is_valid_email(email: str) -> bool:
2844 """!
2845 @brief Lightweight email validation for scheduler notifications.
2846 @param[in] email Argument passed to `is_valid_email()`.
2847 @return Value returned by `is_valid_email()`.
2848 """
2849 if not isinstance(email, str):
2850 return False
2851 pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
2852 return re.match(pattern, email.strip()) is not None
2853
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 2854 of file core.py.

2854def normalize_statistics_task(task_name: str) -> str:
2855 """!
2856 @brief Normalizes user-facing statistics task names to C pipeline keywords.
2857 @param[in] task_name Task name from YAML.
2858 @return Canonical keyword accepted by C statistics pipeline.
2859 @throws ValueError if task is unsupported.
2860 """
2861 # Only implemented statistics kernels belong here.
2862 if task_name is None:
2863 raise ValueError("statistics task cannot be None")
2864 normalized = str(task_name).strip().lower().replace("-", "_").replace(" ", "_")
2865 if normalized != "msd":
2866 raise ValueError(f"Unsupported statistics task '{task_name}'. Currently supported: 'msd'.")
2867 return "ComputeMSD"
2868
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 2869 of file core.py.

2869def _iter_nonempty_noncomment_lines(file_obj):
2870 """!
2871 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
2872 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
2873 """
2874 for lineno, raw in enumerate(file_obj, start=1):
2875 line = raw.strip()
2876 if not line or line.startswith("#"):
2877 continue
2878 yield lineno, line
2879
Here is the caller graph for this function:

◆ 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 2880 of file core.py.

2880def validate_and_nondimensionalize_picgrid(source_grid: str, dest_grid: str, L_ref: float, expected_nblk: int = None) -> dict:
2881 """!
2882 @brief Validates PICGRID payload and writes a non-dimensionalized copy.
2883 @details Requires canonical PICGRID input with leading "PICGRID" token.
2884 Output is always written in canonical PICGRID format with header and per-block dims.
2885 @param[in] source_grid Input grid file path.
2886 @param[in] dest_grid Output grid file path.
2887 @param[in] L_ref Reference length for non-dimensionalization.
2888 @param[in] expected_nblk Optional expected block count.
2889 @return Summary dictionary with nblk, dims, and total_nodes.
2890 @throws ValueError on malformed grid.
2891 """
2892 if L_ref == 0.0:
2893 raise ValueError("length_ref must be non-zero when processing grid coordinates.")
2894 if not os.path.isfile(source_grid):
2895 raise ValueError(f"Grid file not found: {source_grid}")
2896
2897 with open(source_grid, "r") as fin:
2898 line_iter = _iter_nonempty_noncomment_lines(fin)
2899 try:
2900 _, first_token = next(line_iter)
2901 except StopIteration:
2902 raise ValueError(f"Grid file '{source_grid}' is empty.")
2903
2904 if first_token != "PICGRID":
2905 raise ValueError(
2906 f"Grid file '{source_grid}' must begin with the canonical PICGRID header token."
2907 )
2908 try:
2909 _, nblk_line = next(line_iter)
2910 except StopIteration:
2911 raise ValueError(f"Grid file '{source_grid}' missing block count after PICGRID header.")
2912
2913 try:
2914 nblk = int(nblk_line)
2915 except ValueError:
2916 raise ValueError(f"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
2917 if nblk <= 0:
2918 raise ValueError(f"Grid file '{source_grid}' has non-positive block count: {nblk}.")
2919 if expected_nblk is not None and nblk != expected_nblk:
2920 raise ValueError(
2921 f"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}."
2922 )
2923
2924 dims = []
2925 for bi in range(nblk):
2926 try:
2927 lineno, dim_line = next(line_iter)
2928 except StopIteration:
2929 raise ValueError(f"Grid file '{source_grid}' missing dimensions for block {bi}.")
2930 parts = dim_line.split()
2931 if len(parts) != 3:
2932 raise ValueError(
2933 f"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
2934 )
2935 try:
2936 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
2937 except ValueError:
2938 raise ValueError(
2939 f"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
2940 )
2941 if im <= 0 or jm <= 0 or km <= 0:
2942 raise ValueError(
2943 f"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
2944 )
2945 dims.append((im, jm, km))
2946
2947 total_nodes_expected = sum(im * jm * km for (im, jm, km) in dims)
2948 os.makedirs(os.path.dirname(dest_grid), exist_ok=True)
2949 with open(dest_grid, "w") as fout:
2950 fout.write("PICGRID\n")
2951 fout.write(f"{nblk}\n")
2952 for (im, jm, km) in dims:
2953 fout.write(f"{im} {jm} {km}\n")
2954
2955 total_nodes_seen = 0
2956 for lineno, coord_line in line_iter:
2957 parts = coord_line.split()
2958 if len(parts) != 3:
2959 raise ValueError(
2960 f"Invalid coordinate row at {source_grid}:{lineno}. Expected 3 floats, got: '{coord_line}'."
2961 )
2962 try:
2963 x = float(parts[0]) / L_ref
2964 y = float(parts[1]) / L_ref
2965 z = float(parts[2]) / L_ref
2966 except ValueError:
2967 raise ValueError(
2968 f"Invalid coordinate row at {source_grid}:{lineno}. Non-numeric values: '{coord_line}'."
2969 )
2970 total_nodes_seen += 1
2971 if total_nodes_seen > total_nodes_expected:
2972 raise ValueError(
2973 f"Grid file '{source_grid}' has more coordinates ({total_nodes_seen}) than expected ({total_nodes_expected})."
2974 )
2975 fout.write(f"{x:.8e} {y:.8e} {z:.8e}\n")
2976
2977 if total_nodes_seen != total_nodes_expected:
2978 raise ValueError(
2979 f"Grid file '{source_grid}' has {total_nodes_seen} coordinates, expected {total_nodes_expected} from header."
2980 )
2981
2982 return {"nblk": nblk, "dims": dims, "total_nodes": total_nodes_expected}
2983
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 2984 of file core.py.

2984def read_picgrid_header_dimensions(source_grid: str, expected_nblk: int = None) -> list:
2985 """!
2986 @brief Read only the canonical PICGRID header dimensions.
2987 @param[in] source_grid Input grid file path.
2988 @param[in] expected_nblk Optional expected block count.
2989 @return List of (IM, JM, KM) node-count tuples.
2990 @throws ValueError on malformed header.
2991 """
2992 if not os.path.isfile(source_grid):
2993 raise ValueError(f"Grid file not found: {source_grid}")
2994
2995 with open(source_grid, "r") as fin:
2996 line_iter = _iter_nonempty_noncomment_lines(fin)
2997 try:
2998 _, first_token = next(line_iter)
2999 except StopIteration:
3000 raise ValueError(f"Grid file '{source_grid}' is empty.")
3001 if first_token != "PICGRID":
3002 raise ValueError(f"Grid file '{source_grid}' must begin with the canonical PICGRID header token.")
3003
3004 try:
3005 _, nblk_line = next(line_iter)
3006 nblk = int(nblk_line)
3007 except StopIteration:
3008 raise ValueError(f"Grid file '{source_grid}' missing block count after PICGRID header.")
3009 except ValueError:
3010 raise ValueError(f"Invalid block count '{nblk_line}' in grid file '{source_grid}'.")
3011 if nblk <= 0:
3012 raise ValueError(f"Grid file '{source_grid}' has non-positive block count: {nblk}.")
3013 if expected_nblk is not None and nblk != expected_nblk:
3014 raise ValueError(f"Grid file block count mismatch: case expects {expected_nblk}, grid contains {nblk}.")
3015
3016 dims = []
3017 for bi in range(nblk):
3018 try:
3019 lineno, dim_line = next(line_iter)
3020 except StopIteration:
3021 raise ValueError(f"Grid file '{source_grid}' missing dimensions for block {bi}.")
3022 parts = dim_line.split()
3023 if len(parts) != 3:
3024 raise ValueError(
3025 f"Invalid dimensions line at {source_grid}:{lineno}. Expected 3 integers, got: '{dim_line}'."
3026 )
3027 try:
3028 im, jm, km = (int(parts[0]), int(parts[1]), int(parts[2]))
3029 except ValueError:
3030 raise ValueError(
3031 f"Invalid dimensions line at {source_grid}:{lineno}. Non-integer values: '{dim_line}'."
3032 )
3033 if im <= 0 or jm <= 0 or km <= 0:
3034 raise ValueError(
3035 f"Invalid block dimensions at {source_grid}:{lineno}: ({im}, {jm}, {km}). Must be > 0."
3036 )
3037 dims.append((im, jm, km))
3038
3039 return dims
3040
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 3041 of file core.py.

3042 expected_dims: tuple = None) -> dict:
3043 """!
3044 @brief Validate a canonical PICSLICE payload and write a solver-scale copy.
3045 @param[in] source_slice Input PICSLICE path.
3046 @param[in] dest_slice Output staged PICSLICE path.
3047 @param[in] U_ref Reference velocity for non-dimensionalization.
3048 @param[in] expected_dims Optional expected (n1, n2) slice dimensions.
3049 @return Summary dictionary with frame_count, dims, value_count, min_speed, max_speed.
3050 @throws ValueError on malformed slice.
3051 """
3052 if U_ref == 0.0:
3053 raise ValueError("velocity_ref must be non-zero when processing PICSLICE speeds.")
3054 if not os.path.isfile(source_slice):
3055 raise ValueError(f"PICSLICE file not found: {source_slice}")
3056
3057 with open(source_slice, "r") as fin:
3058 line_iter = _iter_nonempty_noncomment_lines(fin)
3059 try:
3060 _, first_token = next(line_iter)
3061 except StopIteration:
3062 raise ValueError(f"PICSLICE file '{source_slice}' is empty.")
3063 if first_token != "PICSLICE":
3064 raise ValueError(f"PICSLICE file '{source_slice}' must begin with the canonical PICSLICE header token.")
3065
3066 try:
3067 _, frame_line = next(line_iter)
3068 frame_count = int(frame_line)
3069 except StopIteration:
3070 raise ValueError(f"PICSLICE file '{source_slice}' missing frame count after PICSLICE header.")
3071 except ValueError:
3072 raise ValueError(f"Invalid frame count '{frame_line}' in PICSLICE file '{source_slice}'.")
3073 if frame_count != 1:
3074 raise ValueError(
3075 f"PICSLICE file '{source_slice}' has frame count {frame_count}; Phase 1 supports exactly 1."
3076 )
3077
3078 try:
3079 lineno, dim_line = next(line_iter)
3080 except StopIteration:
3081 raise ValueError(f"PICSLICE file '{source_slice}' missing slice dimensions.")
3082 parts = dim_line.split()
3083 if len(parts) != 2:
3084 raise ValueError(
3085 f"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Expected 2 integers, got: '{dim_line}'."
3086 )
3087 try:
3088 n1, n2 = (int(parts[0]), int(parts[1]))
3089 except ValueError:
3090 raise ValueError(
3091 f"Invalid PICSLICE dimensions at {source_slice}:{lineno}. Non-integer values: '{dim_line}'."
3092 )
3093 if n1 <= 0 or n2 <= 0:
3094 raise ValueError(f"Invalid PICSLICE dimensions at {source_slice}:{lineno}: ({n1}, {n2}). Must be > 0.")
3095 if expected_dims is not None and (n1, n2) != tuple(expected_dims):
3096 raise ValueError(
3097 f"PICSLICE dimension mismatch for '{source_slice}': expected {tuple(expected_dims)}, found {(n1, n2)}."
3098 )
3099
3100 values = []
3101 for lineno, value_line in line_iter:
3102 parts = value_line.split()
3103 if len(parts) != 1:
3104 raise ValueError(
3105 f"Invalid PICSLICE value row at {source_slice}:{lineno}. Expected 1 float, got: '{value_line}'."
3106 )
3107 try:
3108 value = float(parts[0])
3109 except ValueError:
3110 raise ValueError(f"Invalid PICSLICE value at {source_slice}:{lineno}: '{value_line}'.")
3111 if not math.isfinite(value):
3112 raise ValueError(f"PICSLICE value at {source_slice}:{lineno} must be finite.")
3113 if value < 0.0:
3114 raise ValueError(f"PICSLICE value at {source_slice}:{lineno} must be nonnegative.")
3115 values.append(value)
3116
3117 expected_count = n1 * n2
3118 if len(values) != expected_count:
3119 raise ValueError(
3120 f"PICSLICE file '{source_slice}' has {len(values)} values, expected {expected_count} from dimensions {(n1, n2)}."
3121 )
3122
3123 os.makedirs(os.path.dirname(dest_slice), exist_ok=True)
3124 with open(dest_slice, "w") as fout:
3125 fout.write("PICSLICE\n")
3126 fout.write("1\n")
3127 fout.write(f"{n1} {n2}\n")
3128 for value in values:
3129 fout.write(f"{value / U_ref:.8e}\n")
3130
3131 return {
3132 "frame_count": frame_count,
3133 "dims": (n1, n2),
3134 "value_count": len(values),
3135 "min_speed": min(values) if values else 0.0,
3136 "max_speed": max(values) if values else 0.0,
3137 }
3138
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 3139 of file core.py.

3139def _face_artifact_token(face: str) -> str:
3140 """!
3141 @brief Convert a BC face token into a filesystem-friendly artifact token.
3142 @param[in] face Canonical face token such as -Zeta.
3143 @return Filesystem-friendly face token.
3144 """
3145 return face.replace("+", "pos").replace("-", "neg")
3146
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 3147 of file core.py.

3148 default_to_config_dir: bool = False) -> str:
3149 """!
3150 @brief Resolve a run artifact path with run-dir-relative defaults.
3151 @param[in] run_dir Run/precompute directory root.
3152 @param[in] configured_path Optional user-provided artifact path.
3153 @param[in] default_path Default path relative to run_dir.
3154 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
3155 @return Absolute artifact path.
3156 """
3157 path = configured_path if configured_path else default_path
3158 if not isinstance(path, str) or not path.strip():
3159 raise ValueError("generated profile output_file must be a non-empty path when provided.")
3160 path = path.strip()
3161 if os.path.isabs(path):
3162 return os.path.abspath(path)
3163 if default_to_config_dir and os.path.dirname(path) == "":
3164 path = os.path.join("config", path)
3165 return os.path.abspath(os.path.join(run_dir, path))
3166
Here is the caller graph for this function:

◆ _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 3167 of file core.py.

3167def _resolve_generator_script(configured_script: str, case_path: str, default_name: str) -> str:
3168 """!
3169 @brief Resolve an optional generator script override or repository default.
3170 @param[in] configured_script Optional absolute or case-relative script path.
3171 @param[in] case_path Current case.yml path used to anchor relative overrides.
3172 @param[in] default_name Repository generator filename under GENERATORS_PATH.
3173 @return Absolute generator script path.
3174 """
3175 if configured_script is None:
3176 return os.path.join(GENERATORS_PATH, default_name)
3177 if not isinstance(configured_script, str) or not configured_script.strip():
3178 raise ValueError(f"Generator script override for {default_name} must be a non-empty path.")
3179 script = configured_script.strip()
3180 if os.path.isabs(script):
3181 return os.path.abspath(script)
3182 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
3183 return os.path.abspath(os.path.join(case_dir, script))
3184
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 3185 of file core.py.

3185def _normalize_square_duct_poiseuille_params(params, field_name: str) -> dict:
3186 """!
3187 @brief Validate square-duct Poiseuille generator parameters.
3188 @param[in] params Generator params mapping.
3189 @param[in] field_name Human-readable YAML field name for diagnostics.
3190 @return Normalized params.
3191 """
3192 if params is None:
3193 params = {}
3194 if not isinstance(params, dict):
3195 raise ValueError(f"{field_name}.params must be a mapping when provided.")
3196 unknown = sorted(set(params.keys()) - {"bulk_velocity", "n_terms"})
3197 if unknown:
3198 raise ValueError(f"Unknown keys in {field_name}.params: {unknown}. Allowed: ['bulk_velocity', 'n_terms'].")
3199 bulk_velocity = _to_float(params.get("bulk_velocity", 1.0), f"{field_name}.params.bulk_velocity")
3200 if bulk_velocity <= 0.0:
3201 raise ValueError(f"{field_name}.params.bulk_velocity must be positive.")
3202 try:
3203 n_terms = int(params.get("n_terms", 101))
3204 except (TypeError, ValueError):
3205 raise ValueError(f"{field_name}.params.n_terms must be a positive odd integer.")
3206 if n_terms <= 0 or n_terms % 2 == 0:
3207 raise ValueError(f"{field_name}.params.n_terms must be a positive odd integer.")
3208 return {"bulk_velocity": bulk_velocity, "n_terms": n_terms}
3209
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 3212 of file core.py.

3212def _normalize_field_slice_source(source, field_name: str) -> dict:
3213 """!
3214 @brief Validate a prescribed_flow field_slice source block.
3215 @param[in] source Source mapping from case.yml.
3216 @param[in] field_name Human-readable YAML path for diagnostics.
3217 @return Normalized source mapping.
3218 """
3219 allowed = {
3220 "type",
3221 "script",
3222 "field_file",
3223 "grid_file",
3224 "source_case",
3225 "velocity_scale",
3226 "source_block",
3227 "output_file",
3228 "slice",
3229 }
3230 unknown = sorted(set(source.keys()) - allowed)
3231 if unknown:
3232 raise ValueError(f"Unknown keys in {field_name}: {unknown}. Allowed: {sorted(allowed)}.")
3233 field_file = source.get("field_file")
3234 grid_file = source.get("grid_file")
3235 if not isinstance(field_file, str) or not field_file.strip():
3236 raise ValueError(f"{field_name}.field_file must be a non-empty path.")
3237 if not isinstance(grid_file, str) or not grid_file.strip():
3238 raise ValueError(f"{field_name}.grid_file must be a non-empty path.")
3239 if source.get("source_case") is None and source.get("velocity_scale") is None:
3240 raise ValueError(f"{field_name} requires source_case or velocity_scale.")
3241
3242 normalized = {
3243 "type": "field_slice",
3244 "field_file": field_file.strip(),
3245 "grid_file": grid_file.strip(),
3246 "slice": _normalize_field_slice_selector(source.get("slice"), f"{field_name}.slice"),
3247 }
3248 if source.get("script") is not None:
3249 script = source.get("script")
3250 if not isinstance(script, str) or not script.strip():
3251 raise ValueError(f"{field_name}.script must be a non-empty path when provided.")
3252 normalized["script"] = script.strip()
3253 if source.get("source_case") is not None:
3254 source_case = source.get("source_case")
3255 if not isinstance(source_case, str) or not source_case.strip():
3256 raise ValueError(f"{field_name}.source_case must be a non-empty path when provided.")
3257 normalized["source_case"] = source_case.strip()
3258 if source.get("velocity_scale") is not None:
3259 velocity_scale = _to_float(source.get("velocity_scale"), f"{field_name}.velocity_scale")
3260 if velocity_scale <= 0.0:
3261 raise ValueError(f"{field_name}.velocity_scale must be positive.")
3262 normalized["velocity_scale"] = velocity_scale
3263 if source.get("source_block") is not None:
3264 try:
3265 source_block = int(source.get("source_block"))
3266 except (TypeError, ValueError):
3267 raise ValueError(f"{field_name}.source_block must be a non-negative integer.")
3268 if source_block < 0:
3269 raise ValueError(f"{field_name}.source_block must be a non-negative integer.")
3270 normalized["source_block"] = source_block
3271 if source.get("output_file") is not None:
3272 output_file = source.get("output_file")
3273 if not isinstance(output_file, str) or not output_file.strip():
3274 raise ValueError(f"{field_name}.output_file must be a non-empty path when provided.")
3275 normalized["output_file"] = output_file.strip()
3276 return normalized
3277
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 3278 of file core.py.

3278def _normalize_field_slice_selector(slice_cfg, field_name: str) -> dict:
3279 """!
3280 @brief Validate the field_slice slice selector.
3281 @param[in] slice_cfg Slice selector mapping.
3282 @param[in] field_name Human-readable YAML path for diagnostics.
3283 @return Normalized selector mapping.
3284 """
3285 if not isinstance(slice_cfg, dict):
3286 raise ValueError(f"{field_name} must be a mapping.")
3287 orientation = str(slice_cfg.get("orientation", "opposite")).strip().lower()
3288 if orientation not in {"opposite", "same"}:
3289 raise ValueError(f"{field_name}.orientation must be 'opposite' or 'same'.")
3290 normal_tolerance = _to_float(slice_cfg.get("normal_tolerance", 0.99), f"{field_name}.normal_tolerance")
3291 if normal_tolerance <= 0.0 or normal_tolerance > 1.0:
3292 raise ValueError(f"{field_name}.normal_tolerance must be in the range (0, 1].")
3293
3294 if slice_cfg.get("face") is not None:
3295 unknown = sorted(set(slice_cfg.keys()) - {"face", "orientation", "normal_tolerance"})
3296 if unknown:
3297 raise ValueError(
3298 f"Unknown keys in {field_name}: {unknown}. "
3299 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
3300 )
3301 face = str(slice_cfg.get("face", "")).strip()
3302 if face.lower() not in BC_FACE_MAP:
3303 raise ValueError(f"{field_name}.face must be one of {sorted(BC_FACE_MAP.values())}.")
3304 return {
3305 "face": BC_FACE_MAP[face.lower()],
3306 "orientation": orientation,
3307 "normal_tolerance": normal_tolerance,
3308 }
3309
3310 required = {"axis", "index", "normal"}
3311 missing = sorted(key for key in required if slice_cfg.get(key) is None)
3312 if missing:
3313 raise ValueError(f"{field_name} requires either face or axis/index/normal; missing {missing}.")
3314 unknown = sorted(set(slice_cfg.keys()) - {"axis", "index", "normal", "orientation", "normal_tolerance"})
3315 if unknown:
3316 raise ValueError(
3317 f"Unknown keys in {field_name}: {unknown}. "
3318 "Use either face or axis/index/normal, plus orientation/normal_tolerance."
3319 )
3320 axis = str(slice_cfg.get("axis", "")).strip()
3321 axis_map = {"xi": "Xi", "eta": "Eta", "zeta": "Zeta"}
3322 if axis.lower() not in axis_map:
3323 raise ValueError(f"{field_name}.axis must be one of Xi, Eta, Zeta.")
3324 normal = str(slice_cfg.get("normal", "")).strip()
3325 if normal.lower() not in BC_FACE_MAP:
3326 raise ValueError(f"{field_name}.normal must be one of {sorted(BC_FACE_MAP.values())}.")
3327 normal = BC_FACE_MAP[normal.lower()]
3328 if normal[1:].lower() != axis.lower():
3329 raise ValueError(f"{field_name}.normal must use the same axis as {field_name}.axis.")
3330 try:
3331 index = int(slice_cfg.get("index"))
3332 except (TypeError, ValueError):
3333 raise ValueError(f"{field_name}.index must be an integer.")
3334 if index < 0:
3335 raise ValueError(f"{field_name}.index must be non-negative.")
3336 return {
3337 "axis": axis_map[axis.lower()],
3338 "index": index,
3339 "normal": normal,
3340 "orientation": orientation,
3341 "normal_tolerance": normal_tolerance,
3342 }
3343
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 3344 of file core.py.

3347 case_path: str = None) -> dict:
3348 """!
3349 @brief Generate a dimensional canonical PICSLICE for square-duct Poiseuille flow.
3350 @param[in] output_path Path to write.
3351 @param[in] dims PICSLICE dimensions in face storage order (n1, n2).
3352 @param[in] params Normalized generator params.
3353 @param[in] target_grid Optional canonical target PICGRID for grid-aware sampling.
3354 @param[in] target_block Target block index when `target_grid` is provided.
3355 @param[in] target_face Target inlet face when `target_grid` is provided.
3356 @param[in] script Optional profile.gen-compatible script override.
3357 @param[in] case_path Current case.yml path used to anchor relative script overrides.
3358 @return Summary dictionary.
3359 """
3360 n1, n2 = tuple(dims)
3361 profilegen_script = _resolve_generator_script(script, case_path, "profile.gen")
3362 if not os.path.isfile(profilegen_script):
3363 raise ValueError(f"profile.gen script not found: {profilegen_script}")
3364 cmd = [
3365 sys.executable,
3366 profilegen_script,
3367 "square_duct_poiseuille",
3368 "--output",
3369 output_path,
3370 "--dims",
3371 str(n1),
3372 str(n2),
3373 "--bulk-velocity",
3374 str(float(params["bulk_velocity"])),
3375 "--n-terms",
3376 str(int(params["n_terms"])),
3377 ]
3378 if target_grid:
3379 cmd.extend([
3380 "--target-grid",
3381 target_grid,
3382 "--target-block",
3383 str(int(target_block)),
3384 f"--target-face={target_face}",
3385 ])
3386 result = subprocess.run(cmd, text=True, capture_output=True)
3387 if result.returncode != 0:
3388 details = (result.stderr or result.stdout or "").strip()
3389 raise ValueError(f"profile.gen failed with exit code {result.returncode}. Details:\n{details}")
3390 try:
3391 summary = json.loads((result.stdout or "").strip().splitlines()[-1])
3392 except (IndexError, json.JSONDecodeError) as exc:
3393 raise ValueError(f"profile.gen did not emit valid JSON summary. Output:\n{result.stdout}") from exc
3394 summary["dims"] = tuple(summary["dims"])
3395 return summary
3396
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 3397 of file core.py.

3399 case_path: str) -> dict:
3400 """!
3401 @brief Invoke profile.gen to extract a field_slice PICSLICE artifact.
3402 @param[in] output_path Path to write.
3403 @param[in] expected_dims Expected PICSLICE dimensions.
3404 @param[in] source Normalized field_slice source mapping.
3405 @param[in] target_grid Target canonical PICGRID path.
3406 @param[in] target_face Target inlet face token.
3407 @param[in] target_block Target block index.
3408 @param[in] case_path Path to current case.yml for relative source resolution.
3409 @return Summary dictionary from profile.gen.
3410 """
3411 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
3412 field_file = _resolve_case_relative_path(source["field_file"], case_dir)
3413 source_grid = _resolve_case_relative_path(source["grid_file"], case_dir)
3414 velocity_scale = _resolve_field_slice_velocity_scale(source, case_dir)
3415 profilegen_script = _resolve_generator_script(source.get("script"), case_path, "profile.gen")
3416 if not os.path.isfile(profilegen_script):
3417 raise ValueError(f"profile.gen script not found: {profilegen_script}")
3418 n1, n2 = tuple(expected_dims)
3419 slice_cfg = source["slice"]
3420 cmd = [
3421 sys.executable,
3422 profilegen_script,
3423 "field-slice",
3424 "--output",
3425 output_path,
3426 "--field-file",
3427 field_file,
3428 "--source-grid",
3429 source_grid,
3430 "--target-grid",
3431 target_grid,
3432 "--source-block",
3433 str(int(source.get("source_block", 0))),
3434 "--target-block",
3435 str(int(target_block)),
3436 f"--target-face={target_face}",
3437 "--orientation",
3438 slice_cfg["orientation"],
3439 "--normal-tolerance",
3440 str(float(slice_cfg["normal_tolerance"])),
3441 "--velocity-scale",
3442 str(float(velocity_scale)),
3443 "--expected-dims",
3444 str(n1),
3445 str(n2),
3446 ]
3447 if "face" in slice_cfg:
3448 cmd.append(f"--slice-face={slice_cfg['face']}")
3449 else:
3450 cmd.extend([
3451 "--slice-axis",
3452 slice_cfg["axis"],
3453 "--slice-index",
3454 str(int(slice_cfg["index"])),
3455 f"--slice-normal={slice_cfg['normal']}",
3456 ])
3457 result = subprocess.run(cmd, text=True, capture_output=True)
3458 if result.returncode != 0:
3459 details = (result.stderr or result.stdout or "").strip()
3460 raise ValueError(f"profile.gen field-slice failed with exit code {result.returncode}. Details:\n{details}")
3461 try:
3462 summary = json.loads((result.stdout or "").strip().splitlines()[-1])
3463 except (IndexError, json.JSONDecodeError) as exc:
3464 raise ValueError(f"profile.gen field-slice did not emit valid JSON summary. Output:\n{result.stdout}") from exc
3465 summary["dims"] = tuple(summary["dims"])
3466 return summary
3467
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 3468 of file core.py.

3468def _resolve_case_relative_path(path_value: str, case_dir: str) -> str:
3469 """!
3470 @brief Resolve a path relative to the current case directory.
3471 @param[in] path_value Path from case.yml.
3472 @param[in] case_dir Current case directory.
3473 @return Absolute path.
3474 """
3475 if not isinstance(path_value, str) or not path_value.strip():
3476 raise ValueError("path value must be a non-empty string.")
3477 if os.path.isabs(path_value):
3478 return os.path.abspath(path_value)
3479 return os.path.abspath(os.path.join(case_dir, path_value))
3480
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 3481 of file core.py.

3481def _resolve_field_slice_velocity_scale(source: dict, case_dir: str) -> float:
3482 """!
3483 @brief Resolve field_slice dimensional velocity scale.
3484 @param[in] source Normalized field_slice source mapping.
3485 @param[in] case_dir Current case directory.
3486 @return Positive velocity scale.
3487 """
3488 if source.get("velocity_scale") is not None:
3489 return float(source["velocity_scale"])
3490 source_case = _resolve_case_relative_path(source["source_case"], case_dir)
3491 source_case_cfg = read_yaml_file(source_case)
3492 try:
3493 velocity_scale = _to_float(
3494 source_case_cfg.get("properties", {}).get("scaling", {}).get("velocity_ref"),
3495 "source_case.properties.scaling.velocity_ref",
3496 )
3497 except AttributeError as exc:
3498 raise ValueError("source_case must contain properties.scaling.velocity_ref.") from exc
3499 if velocity_scale <= 0.0:
3500 raise ValueError("source_case.properties.scaling.velocity_ref must be positive.")
3501 return velocity_scale
3502
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 3503 of file core.py.

3503def resolve_target_grid_for_field_slice(case_cfg: dict, case_path: str, run_dir: str) -> str:
3504 """!
3505 @brief Resolve the target canonical PICGRID path needed for field_slice normals.
3506 @param[in] case_cfg Parsed current case config.
3507 @param[in] case_path Current case.yml path.
3508 @param[in] run_dir Current run/precompute directory.
3509 @return Absolute target PICGRID path.
3510 """
3511 grid_cfg = case_cfg.get("grid", {}) or {}
3512 grid_mode = grid_cfg.get("mode")
3513 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
3514 if grid_mode == "file":
3515 source_grid = grid_cfg.get("source_file")
3516 if not isinstance(source_grid, str) or not source_grid.strip():
3517 raise ValueError("grid.source_file is required for field_slice target-grid normals.")
3518 source_grid = _resolve_case_relative_path(source_grid, case_dir)
3519 if isinstance(grid_cfg.get("legacy_conversion"), dict) and run_dir:
3520 source_grid = convert_legacy_grid_with_gridgen(case_path, run_dir, grid_cfg, source_grid)
3521 return source_grid
3522 if grid_mode == "grid_gen":
3523 generator = grid_cfg.get("generator", {})
3524 output_file = generator.get("output_file", os.path.join("config", "grid.generated.picgrid"))
3525 candidate = output_file if os.path.isabs(output_file) else os.path.abspath(os.path.join(run_dir, output_file))
3526 if os.path.isfile(candidate):
3527 return candidate
3528 staged = os.path.join(run_dir, "config", "grid.run")
3529 if os.path.isfile(staged):
3530 return staged
3531 raise ValueError("field_slice requires the generated target PICGRID to exist before profile extraction.")
3532 raise ValueError(
3533 f"field_slice requires grid.mode 'file' or 'grid_gen' for target-grid normals; got '{grid_mode}'."
3534 )
3535
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 an optional target canonical PICGRID for generated profile sampling.

Parameters
[in]case_cfgParsed current case config.
[in]case_pathCurrent case.yml path.
[in]run_dirCurrent run/precompute directory.
Returns
Absolute target PICGRID path, or None when no canonical grid is available yet.

Definition at line 3536 of file core.py.

3536def resolve_target_grid_for_generated_profile(case_cfg: dict, case_path: str, run_dir: str) -> str:
3537 """!
3538 @brief Resolve an optional target canonical PICGRID for generated profile sampling.
3539 @param[in] case_cfg Parsed current case config.
3540 @param[in] case_path Current case.yml path.
3541 @param[in] run_dir Current run/precompute directory.
3542 @return Absolute target PICGRID path, or None when no canonical grid is available yet.
3543 """
3544 grid_mode = (case_cfg.get("grid", {}) or {}).get("mode")
3545 if grid_mode == "programmatic_c":
3546 return None
3547 return resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
3548
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 3549 of file core.py.

3549def write_profile_info(config_dir: str, summaries: list) -> str:
3550 """!
3551 @brief Write a profile.info summary for generated inlet profiles.
3552 @param[in] config_dir Run/precompute config directory.
3553 @param[in] summaries Generated profile summaries.
3554 @return Path to profile.info.
3555 """
3556 info_path = os.path.join(config_dir, "profile.info")
3557 os.makedirs(config_dir, exist_ok=True)
3558 with open(info_path, "w") as fout:
3559 fout.write("# PICurv generated profile summary\n")
3560 fout.write(f"profile_count = {len(summaries)}\n\n")
3561 for idx, summary in enumerate(summaries):
3562 dims = summary.get("dims", (0, 0))
3563 fout.write(f"[profile_{idx}]\n")
3564 fout.write(f"generator = {summary.get('generator')}\n")
3565 fout.write(f"block = {summary.get('block')}\n")
3566 fout.write(f"face = {summary.get('face')}\n")
3567 fout.write(f"dimensions = {dims[0]} {dims[1]}\n")
3568 if summary.get("bulk_velocity") is not None:
3569 fout.write(f"bulk_velocity = {summary.get('bulk_velocity'):.16e}\n")
3570 if summary.get("n_terms") is not None:
3571 fout.write(f"n_terms = {summary.get('n_terms')}\n")
3572 fout.write(f"mean_speed = {summary.get('mean_speed'):.16e}\n")
3573 if "area_mean_speed" in summary:
3574 fout.write(f"area_mean_speed = {summary.get('area_mean_speed'):.16e}\n")
3575 if "discrete_mean_speed" in summary:
3576 fout.write(f"discrete_mean_speed = {summary.get('discrete_mean_speed'):.16e}\n")
3577 fout.write(f"min_speed = {summary.get('min_speed'):.16e}\n")
3578 fout.write(f"max_speed = {summary.get('max_speed'):.16e}\n")
3579 if summary.get("umax_over_ubulk") is not None:
3580 fout.write(f"umax_over_ubulk = {summary.get('umax_over_ubulk'):.16e}\n")
3581 for key in (
3582 "normalization",
3583 "sampling",
3584 "area_weighted_mean_before_normalization",
3585 "area_weighted_mean_after_normalization",
3586 "total_inlet_area",
3587 "face_area_min",
3588 "face_area_max",
3589 "source_field",
3590 "source_grid",
3591 "target_grid",
3592 "source_block",
3593 "target_block",
3594 "target_face",
3595 "source_slice",
3596 "orientation",
3597 "normal_tolerance",
3598 "normal_dot",
3599 "velocity_scale",
3600 ):
3601 if key in summary:
3602 fout.write(f"{key} = {summary.get(key)}\n")
3603 fout.write(f"output_file = {summary.get('path')}\n\n")
3604 return info_path
3605
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 
)

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.
Returns
Absolute path to generated dimensional PICGRID file.
Exceptions
ValueErroron invalid config or generator failure.

Definition at line 3606 of file core.py.

3606def run_grid_generator(case_path: str, run_dir: str, grid_cfg: dict) -> str:
3607 """!
3608 @brief Runs generators/grid.gen to produce a PICGRID file for this run.
3609 @param[in] case_path Path to case.yml (used for relative path resolution).
3610 @param[in] run_dir Run directory path.
3611 @param[in] grid_cfg The grid config section from case.yml.
3612 @return Absolute path to generated dimensional PICGRID file.
3613 @throws ValueError on invalid config or generator failure.
3614 """
3615 generator = grid_cfg.get("generator", {})
3616 if not isinstance(generator, dict):
3617 raise ValueError("grid.generator must be a mapping when grid.mode is 'grid_gen'.")
3618
3619 case_dir = os.path.dirname(os.path.abspath(case_path))
3620 gridgen_script = generator.get("script", os.path.join(GENERATORS_PATH, "grid.gen"))
3621 if not os.path.isabs(gridgen_script):
3622 gridgen_script = os.path.abspath(os.path.join(case_dir, gridgen_script))
3623 if not os.path.isfile(gridgen_script):
3624 raise ValueError(f"grid.gen script not found: {gridgen_script}")
3625
3626 config_file = generator.get("config_file")
3627 if not config_file:
3628 raise ValueError("grid.generator.config_file is required when grid.mode is 'grid_gen'.")
3629 if not os.path.isabs(config_file):
3630 config_file = os.path.abspath(os.path.join(case_dir, config_file))
3631 if not os.path.isfile(config_file):
3632 raise ValueError(f"grid.generator.config_file not found: {config_file}")
3633
3634 output_file = generator.get("output_file", os.path.join("config", "grid.generated.picgrid"))
3635 if not os.path.isabs(output_file):
3636 output_file = os.path.abspath(os.path.join(run_dir, output_file))
3637 os.makedirs(os.path.dirname(output_file), exist_ok=True)
3638
3639 grid_type = generator.get("grid_type")
3640 cli_args = generator.get("cli_args", [])
3641 if cli_args is None:
3642 cli_args = []
3643 if not isinstance(cli_args, list):
3644 raise ValueError("grid.generator.cli_args must be a list of CLI tokens.")
3645
3646 cmd = [sys.executable, gridgen_script, "-c", config_file]
3647 if grid_type:
3648 cmd.append(str(grid_type))
3649 cmd.extend([str(token) for token in cli_args])
3650 cmd.extend(["--output", output_file])
3651
3652 vts_file = generator.get("vts_file")
3653 if vts_file:
3654 if not os.path.isabs(vts_file):
3655 vts_file = os.path.abspath(os.path.join(run_dir, vts_file))
3656 os.makedirs(os.path.dirname(vts_file), exist_ok=True)
3657 cmd.extend(["--vts", vts_file])
3658
3659 stats_file = generator.get("stats_file")
3660 if stats_file:
3661 if not os.path.isabs(stats_file):
3662 stats_file = os.path.abspath(os.path.join(run_dir, stats_file))
3663 os.makedirs(os.path.dirname(stats_file), exist_ok=True)
3664 cmd.extend(["--stats-file", stats_file])
3665
3666 print(f"[INFO] Grid generator command: {' '.join(cmd)}")
3667 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
3668 if result.returncode != 0:
3669 stderr = (result.stderr or "").strip()
3670 stdout = (result.stdout or "").strip()
3671 details = stderr if stderr else stdout
3672 raise ValueError(
3673 f"grid.gen failed with exit code {result.returncode}. Details:\n{details}"
3674 )
3675 if result.stdout:
3676 print(result.stdout.strip())
3677 if result.stderr:
3678 print(result.stderr.strip())
3679
3680 if not os.path.isfile(output_file):
3681 raise ValueError(f"grid.gen did not produce expected output file: {output_file}")
3682
3683 return output_file
3684
3685
Here is the caller graph for this function:

◆ convert_legacy_grid_with_gridgen()

str picurv_cli.core.convert_legacy_grid_with_gridgen ( str  case_path,
str  run_dir,
dict  grid_cfg,
str  source_grid 
)

Optionally convert a legacy file-grid payload to canonical PICGRID using grid.gen.

Activated only when grid.legacy_conversion.enabled is true in case.yml. The converted output remains dimensional; standard nondimensionalization still occurs via validate_and_nondimensionalize_picgrid().

Parameters
[in]case_pathPath to case.yml (for relative path resolution).
[in]run_dirCurrent run directory.
[in]grid_cfgGrid section from case.yml.
[in]source_gridAbsolute or relative path to the original grid file.
Returns
Grid path that should be fed into validate_and_nondimensionalize_picgrid().
Exceptions
ValueErroron invalid converter settings or failed conversion.

Definition at line 3686 of file core.py.

3686def convert_legacy_grid_with_gridgen(case_path: str, run_dir: str, grid_cfg: dict, source_grid: str) -> str:
3687 """!
3688 @brief Optionally convert a legacy file-grid payload to canonical PICGRID using grid.gen.
3689 @details Activated only when `grid.legacy_conversion.enabled` is true in case.yml.
3690 The converted output remains dimensional; standard nondimensionalization still
3691 occurs via validate_and_nondimensionalize_picgrid().
3692 @param[in] case_path Path to case.yml (for relative path resolution).
3693 @param[in] run_dir Current run directory.
3694 @param[in] grid_cfg Grid section from case.yml.
3695 @param[in] source_grid Absolute or relative path to the original grid file.
3696 @return Grid path that should be fed into validate_and_nondimensionalize_picgrid().
3697 @throws ValueError on invalid converter settings or failed conversion.
3698 """
3699 legacy_cfg = grid_cfg.get("legacy_conversion")
3700 if not isinstance(legacy_cfg, dict):
3701 return source_grid
3702
3703 enabled = legacy_cfg.get("enabled", True)
3704 if enabled is False:
3705 return source_grid
3706 if not isinstance(enabled, bool):
3707 raise ValueError("grid.legacy_conversion.enabled must be a boolean.")
3708
3709 raw_format = str(legacy_cfg.get("format", "legacy1d")).strip().lower()
3710 format_aliases = {
3711 "legacy1d": "legacy1d",
3712 "legacy_1d": "legacy1d",
3713 "les_flat_1d": "legacy1d",
3714 "les-flat-1d": "legacy1d",
3715 }
3716 command = format_aliases.get(raw_format)
3717 if command is None:
3718 raise ValueError(
3719 "grid.legacy_conversion.format must be one of "
3720 "['legacy1d', 'legacy_1d', 'les_flat_1d', 'les-flat-1d']."
3721 )
3722
3723 case_dir = os.path.dirname(os.path.abspath(case_path))
3724 gridgen_script = legacy_cfg.get("script", os.path.join(GENERATORS_PATH, "grid.gen"))
3725 if not isinstance(gridgen_script, str) or not gridgen_script.strip():
3726 raise ValueError("grid.legacy_conversion.script must be a non-empty string when provided.")
3727 if not os.path.isabs(gridgen_script):
3728 gridgen_script = os.path.abspath(os.path.join(case_dir, gridgen_script))
3729 if not os.path.isfile(gridgen_script):
3730 raise ValueError(f"grid.legacy_conversion.script not found: {gridgen_script}")
3731
3732 output_file = legacy_cfg.get("output_file", os.path.join("config", "grid.converted.picgrid"))
3733 if not isinstance(output_file, str) or not output_file.strip():
3734 raise ValueError("grid.legacy_conversion.output_file must be a non-empty string when provided.")
3735 if not os.path.isabs(output_file):
3736 output_file = os.path.abspath(os.path.join(run_dir, output_file))
3737 os.makedirs(os.path.dirname(output_file), exist_ok=True)
3738
3739 axis_columns = legacy_cfg.get("axis_columns", [0, 1, 2])
3740 if not isinstance(axis_columns, list) or len(axis_columns) != 3:
3741 raise ValueError("grid.legacy_conversion.axis_columns must be a 3-item list of non-negative integers.")
3742 try:
3743 axis_columns = [int(v) for v in axis_columns]
3744 except (TypeError, ValueError) as exc:
3745 raise ValueError("grid.legacy_conversion.axis_columns must contain integers.") from exc
3746 if any(v < 0 for v in axis_columns):
3747 raise ValueError("grid.legacy_conversion.axis_columns values must be >= 0.")
3748
3749 strict_trailing = legacy_cfg.get("strict_trailing", True)
3750 if not isinstance(strict_trailing, bool):
3751 raise ValueError("grid.legacy_conversion.strict_trailing must be a boolean.")
3752
3753 cli_args = legacy_cfg.get("cli_args", [])
3754 if cli_args is None:
3755 cli_args = []
3756 if not isinstance(cli_args, list):
3757 raise ValueError("grid.legacy_conversion.cli_args must be a list of CLI tokens.")
3758
3759 cmd = [
3760 sys.executable,
3761 gridgen_script,
3762 command,
3763 "--input",
3764 source_grid,
3765 "--output",
3766 output_file,
3767 "--axis-columns",
3768 str(axis_columns[0]),
3769 str(axis_columns[1]),
3770 str(axis_columns[2]),
3771 "--no-write-vtk",
3772 ]
3773 if strict_trailing:
3774 cmd.append("--strict-trailing")
3775 else:
3776 cmd.append("--allow-trailing")
3777 cmd.extend(str(token) for token in cli_args)
3778
3779 print(f"[INFO] Legacy grid conversion command: {' '.join(cmd)}")
3780 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
3781 if result.returncode != 0:
3782 stderr = (result.stderr or "").strip()
3783 stdout = (result.stdout or "").strip()
3784 details = stderr if stderr else stdout
3785 raise ValueError(
3786 f"legacy grid conversion failed with exit code {result.returncode}. Details:\n{details}"
3787 )
3788 if result.stdout:
3789 print(result.stdout.strip())
3790 if result.stderr:
3791 print(result.stderr.strip())
3792 if not os.path.isfile(output_file):
3793 raise ValueError(f"legacy grid conversion did not produce expected output file: {output_file}")
3794
3795 return output_file
3796
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 3857 of file core.py.

3857def _normalize_prescribed_flow_source(source, field_name: str) -> dict:
3858 """!
3859 @brief Validate the structured source block for prescribed_flow BCs.
3860 @param[in] source Source mapping from case.yml.
3861 @param[in] field_name Human-readable YAML path for diagnostics.
3862 @return Normalized source mapping.
3863 @throws ValueError on invalid source contract.
3864 """
3865 if not isinstance(source, dict):
3866 raise ValueError(f"{field_name} must be a mapping with type: file, generated, or field_slice.")
3867 source_type = str(source.get("type", "")).strip().lower()
3868 if source_type == "file":
3869 path = source.get("path")
3870 if not isinstance(path, str) or not path.strip():
3871 raise ValueError(f"{field_name}.path must be a non-empty file path.")
3872 unknown = sorted(set(source.keys()) - {"type", "path"})
3873 if unknown:
3874 raise ValueError(f"Unknown keys in {field_name}: {unknown}. Allowed: ['path', 'type'].")
3875 return {"type": "file", "path": path.strip()}
3876
3877 if source_type == "generated":
3878 generator = str(source.get("generator", "")).strip().lower()
3879 if generator not in GENERATED_PROFILE_GENERATORS:
3880 raise ValueError(
3881 f"{field_name}.generator must be one of {sorted(GENERATED_PROFILE_GENERATORS)} "
3882 f"(got '{source.get('generator')}')."
3883 )
3884 unknown = sorted(set(source.keys()) - {"type", "generator", "script", "output_file", "params"})
3885 if unknown:
3886 raise ValueError(
3887 f"Unknown keys in {field_name}: {unknown}. "
3888 "Allowed: ['generator', 'output_file', 'params', 'script', 'type']."
3889 )
3890 normalized = {
3891 "type": "generated",
3892 "generator": generator,
3893 "params": _normalize_square_duct_poiseuille_params(source.get("params", {}), field_name),
3894 }
3895 output_file = source.get("output_file")
3896 if output_file is not None:
3897 if not isinstance(output_file, str) or not output_file.strip():
3898 raise ValueError(f"{field_name}.output_file must be a non-empty path when provided.")
3899 normalized["output_file"] = output_file.strip()
3900 script = source.get("script")
3901 if script is not None:
3902 if not isinstance(script, str) or not script.strip():
3903 raise ValueError(f"{field_name}.script must be a non-empty path when provided.")
3904 normalized["script"] = script.strip()
3905 return normalized
3906
3907 if source_type == "field_slice":
3908 return _normalize_field_slice_source(source, field_name)
3909
3910 raise ValueError(f"{field_name}.type must be 'file', 'generated', or 'field_slice'.")
3911
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 3912 of file core.py.

3912def _bc_profile_expected_dims(face: str, block_dims: tuple) -> tuple:
3913 """!
3914 @brief Return expected PICSLICE dimensions for a face and block node dimensions.
3915 @param[in] face Canonical BC face token.
3916 @param[in] block_dims (IM, JM, KM) node counts.
3917 @return (n1, n2) dimensions in profile storage order.
3918 """
3919 im, jm, km = block_dims
3920 if min(im, jm, km) < 2:
3921 raise ValueError(
3922 f"Block dimensions {block_dims} are too small for an inlet profile; each axis needs at least 2 nodes."
3923 )
3924 if face in {"-Xi", "+Xi"}:
3925 return (km - 1, jm - 1)
3926 if face in {"-Eta", "+Eta"}:
3927 return (km - 1, im - 1)
3928 if face in {"-Zeta", "+Zeta"}:
3929 return (jm - 1, im - 1)
3930 raise ValueError(f"Unsupported face '{face}' for prescribed_flow profile dimensions.")
3931
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 3932 of file core.py.

3932def resolve_grid_block_dimensions_for_profiles(case_cfg: dict, case_path: str, run_dir: str = None) -> list:
3933 """!
3934 @brief Resolve per-block node dimensions for prescribed inlet profile validation.
3935 @param[in] case_cfg Parsed case.yml configuration.
3936 @param[in] case_path Path to case.yml for relative path resolution.
3937 @param[in] run_dir Current run directory, used for optional generated grid outputs.
3938 @return List of (IM, JM, KM) node-count tuples.
3939 @throws ValueError when dimensions cannot be resolved.
3940 """
3941 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
3942 grid_cfg = case_cfg.get("grid", {})
3943 grid_mode = grid_cfg.get("mode")
3944 case_dir = os.path.dirname(os.path.abspath(case_path)) if case_path else os.getcwd()
3945
3946 if grid_mode == "programmatic_c":
3947 settings = grid_cfg.get("programmatic_settings", {})
3948 dims_by_axis = []
3949 for key in ("im", "jm", "km"):
3950 raw = settings.get(key)
3951 if raw is None:
3952 raise ValueError(f"grid.programmatic_settings.{key} is required for prescribed_flow profiles.")
3953 if isinstance(raw, list):
3954 if len(raw) != num_blocks:
3955 raise ValueError(
3956 f"grid.programmatic_settings.{key} has {len(raw)} entries, expected {num_blocks} blocks."
3957 )
3958 values = raw
3959 else:
3960 values = [raw] * num_blocks
3961 try:
3962 values = [int(v) + 1 for v in values]
3963 except (TypeError, ValueError):
3964 raise ValueError(f"grid.programmatic_settings.{key} values must be positive integer cell counts.")
3965 if any(v <= 1 for v in values):
3966 raise ValueError(f"grid.programmatic_settings.{key} values must be positive integer cell counts.")
3967 dims_by_axis.append(values)
3968 return list(zip(dims_by_axis[0], dims_by_axis[1], dims_by_axis[2]))
3969
3970 if grid_mode == "file":
3971 source_grid = grid_cfg.get("source_file")
3972 if not isinstance(source_grid, str) or not source_grid.strip():
3973 raise ValueError("grid.source_file is required for file-grid prescribed_flow profile validation.")
3974 if not os.path.isabs(source_grid):
3975 source_grid = os.path.abspath(os.path.join(case_dir, source_grid))
3976 if isinstance(grid_cfg.get("legacy_conversion"), dict) and run_dir:
3977 source_grid = convert_legacy_grid_with_gridgen(case_path, run_dir, grid_cfg, source_grid)
3978 return read_picgrid_header_dimensions(source_grid, expected_nblk=num_blocks)
3979
3980 if grid_mode == "grid_gen":
3981 generator = grid_cfg.get("generator", {})
3982 output_file = generator.get("output_file", os.path.join("config", "grid.generated.picgrid"))
3983 candidates = []
3984 if run_dir:
3985 candidates.append(output_file if os.path.isabs(output_file) else os.path.abspath(os.path.join(run_dir, output_file)))
3986 candidates.append(os.path.join(run_dir, "config", "grid.run"))
3987 for candidate in candidates:
3988 if os.path.isfile(candidate):
3989 return read_picgrid_header_dimensions(candidate, expected_nblk=num_blocks)
3990 raise ValueError(
3991 "prescribed_flow profile dimension validation for grid.mode='grid_gen' requires an existing generated "
3992 "PICGRID output. Run or stage the grid first, or use grid.mode='file' with the generated .picgrid."
3993 )
3994
3995 raise ValueError(f"Unsupported grid.mode '{grid_mode}' for prescribed_flow profile validation.")
3996
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 3997 of file core.py.

3998 profile_grid_dims: list = None) -> list:
3999 """!
4000 @brief Generate dimensional PICSLICE artifacts for generated/field_slice prescribed_flow sources.
4001 @param[in] run_dir Run/precompute directory root.
4002 @param[in] case_cfg Parsed case.yml.
4003 @param[in] case_path Path to case.yml for relative grid/source resolution.
4004 @param[in] profile_grid_dims Optional pre-resolved block node dimensions.
4005 @return List of generated profile summaries.
4006 """
4007 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
4008 if not any(
4009 bc.get("handler") == "prescribed_flow"
4010 and ((bc.get("params") or {}).get("source") or {}).get("type") in {"generated", "field_slice"}
4011 for block in prepared_blocks for bc in block
4012 ):
4013 return []
4014 if profile_grid_dims is None:
4015 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
4016
4017 config_dir = os.path.join(run_dir, "config")
4018 target_grid = None
4019 generated_target_grid = None
4020 summaries = []
4021 for block_idx, block in enumerate(prepared_blocks):
4022 for bc in block:
4023 if bc.get("handler") != "prescribed_flow":
4024 continue
4025 source = (bc.get("params") or {}).get("source", {})
4026 if source.get("type") not in {"generated", "field_slice"}:
4027 continue
4028 face = bc["face"]
4029 dims = _bc_profile_expected_dims(face, profile_grid_dims[block_idx])
4030 face_token = _face_artifact_token(face)
4031 suffix = "generated" if source.get("type") == "generated" else "sliced"
4032 default_output = os.path.join(
4033 "config", f"inlet_profile_block{block_idx}_{face_token}.{suffix}.picslice"
4034 )
4035 output_path = _resolve_run_artifact_path(
4036 run_dir,
4037 source.get("output_file"),
4038 default_output,
4039 default_to_config_dir=True,
4040 )
4041 if source.get("type") == "generated" and source["generator"] == "square_duct_poiseuille":
4042 if generated_target_grid is None:
4043 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
4044 summary = generate_square_duct_poiseuille_picslice(
4045 output_path,
4046 dims,
4047 source["params"],
4048 target_grid=generated_target_grid,
4049 target_block=block_idx,
4050 target_face=face,
4051 script=source.get("script"),
4052 case_path=case_path,
4053 )
4054 elif source.get("type") == "generated":
4055 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
4056 else:
4057 if target_grid is None:
4058 target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
4059 summary = generate_field_slice_picslice(
4060 output_path,
4061 dims,
4062 source,
4063 target_grid,
4064 face,
4065 block_idx,
4066 case_path,
4067 )
4068 summary.update({"block": block_idx, "face": face})
4069 summaries.append(summary)
4070 print(
4071 f"[SUCCESS] Materialized prescribed_flow profile for block {block_idx}, face {face}: "
4072 f"{os.path.relpath(output_path)} dims={summary['dims']}"
4073 )
4074
4075 if summaries:
4076 info_path = write_profile_info(config_dir, summaries)
4077 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
4078 return summaries
4079
Here is the call graph for this function:
Here is the caller 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 4080 of file core.py.

4080def _to_float(value, field_name: str) -> float:
4081 """!
4082 @brief Convert a YAML scalar to float with a clear error message.
4083 @param[in] value Argument passed to `_to_float()`.
4084 @param[in] field_name Argument passed to `_to_float()`.
4085 @return Value returned by `_to_float()`.
4086 """
4087 try:
4088 return float(value)
4089 except (TypeError, ValueError):
4090 raise ValueError(f"'{field_name}' must be numeric (got {value!r}).")
4091
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 4092 of file core.py.

4092def _to_bool(value, field_name: str) -> bool:
4093 """!
4094 @brief Convert a YAML scalar/string to bool with a clear error message.
4095 @param[in] value Argument passed to `_to_bool()`.
4096 @param[in] field_name Argument passed to `_to_bool()`.
4097 @return Value returned by `_to_bool()`.
4098 """
4099 if isinstance(value, bool):
4100 return value
4101 if isinstance(value, str):
4102 raw = value.strip().lower()
4103 if raw in {"true", "1", "yes"}:
4104 return True
4105 if raw in {"false", "0", "no"}:
4106 return False
4107 raise ValueError(f"'{field_name}' must be boolean (got {value!r}).")
4108
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 4109 of file core.py.

4109def normalize_boundary_conditions_layout(all_blocks_bcs, num_blocks: int):
4110 """!
4111 @brief Normalize boundary_conditions to list-of-lists form and validate block count.
4112 @param[in] all_blocks_bcs Argument passed to `normalize_boundary_conditions_layout()`.
4113 @param[in] num_blocks Argument passed to `normalize_boundary_conditions_layout()`.
4114 @return Value returned by `normalize_boundary_conditions_layout()`.
4115 """
4116 if not all_blocks_bcs:
4117 raise ValueError("The 'boundary_conditions' section in case.yml is empty.")
4118
4119 is_simple_list = isinstance(all_blocks_bcs[0], dict)
4120 if num_blocks == 1 and is_simple_list:
4121 all_blocks_bcs = [all_blocks_bcs]
4122 elif is_simple_list and num_blocks > 1:
4123 raise ValueError(
4124 f"case.yml declares {num_blocks} blocks but boundary_conditions is a single face-list. "
4125 "Use a list-of-lists, one inner list per block."
4126 )
4127
4128 if len(all_blocks_bcs) != num_blocks:
4129 raise ValueError(
4130 f"Mismatch: case.yml declares {num_blocks} block(s) but found {len(all_blocks_bcs)} BC definitions."
4131 )
4132 return all_blocks_bcs
4133
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 4134 of file core.py.

4134def validate_and_prepare_boundary_conditions(case_cfg: dict):
4135 """!
4136 @brief Validate BC entries against currently supported C-side handlers/types and
4137 @details return normalized entries ready for bcs.run generation.
4138 @param[in] case_cfg Argument passed to `validate_and_prepare_boundary_conditions()`.
4139 @return Value returned by `validate_and_prepare_boundary_conditions()`.
4140 """
4141 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
4142 scales = case_cfg.get('properties', {}).get('scaling', {})
4143 L_ref = _to_float(scales.get('length_ref'), "properties.scaling.length_ref")
4144 U_ref = _to_float(scales.get('velocity_ref'), "properties.scaling.velocity_ref")
4145 if U_ref == 0.0:
4146 raise ValueError("properties.scaling.velocity_ref must be non-zero for non-dimensionalization.")
4147 if L_ref == 0.0:
4148 raise ValueError("properties.scaling.length_ref must be non-zero for non-dimensionalization.")
4149
4150 all_blocks_bcs = normalize_boundary_conditions_layout(case_cfg.get('boundary_conditions', []), num_blocks)
4151 prepared_blocks = []
4152
4153 expected_faces = {"-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta"}
4154 axis_pairs = [("-Xi", "+Xi"), ("-Eta", "+Eta"), ("-Zeta", "+Zeta")]
4155
4156 for bi, block_bcs in enumerate(all_blocks_bcs):
4157 if not isinstance(block_bcs, list):
4158 raise ValueError(f"boundary_conditions[{bi}] must be a list of face configs.")
4159
4160 prepared_block = []
4161 seen_faces = {}
4162
4163 for idx, bc in enumerate(block_bcs):
4164 if not isinstance(bc, dict):
4165 raise ValueError(f"boundary_conditions[{bi}][{idx}] must be a mapping.")
4166
4167 for req in ("face", "type", "handler"):
4168 if req not in bc:
4169 raise ValueError(f"boundary_conditions[{bi}][{idx}] missing required key '{req}'.")
4170
4171 face_raw = str(bc["face"]).strip()
4172 face_key = face_raw.lower()
4173 face = BC_FACE_MAP.get(face_key)
4174 if face is None:
4175 raise ValueError(
4176 f"Unsupported BC face '{face_raw}' at boundary_conditions[{bi}][{idx}]. "
4177 f"Supported: {sorted(expected_faces)}."
4178 )
4179 if face in seen_faces:
4180 raise ValueError(f"Duplicate face '{face}' in boundary_conditions[{bi}] (entries {seen_faces[face]} and {idx}).")
4181 seen_faces[face] = idx
4182
4183 bc_type_raw = str(bc["type"]).strip()
4184 bc_type = BC_TYPE_MAP.get(bc_type_raw.lower())
4185 if bc_type is None:
4186 raise ValueError(
4187 f"Unsupported BC type '{bc_type_raw}' for face {face} in block {bi}. "
4188 f"Supported: {sorted(set(BC_TYPE_MAP.values()))}."
4189 )
4190
4191 handler = str(bc["handler"]).strip().lower()
4192 handler_spec = BC_HANDLER_SPECS.get(handler)
4193 if handler_spec is None:
4194 raise ValueError(
4195 f"Unsupported BC handler '{bc['handler']}' for face {face} in block {bi}. "
4196 f"Supported now: {sorted(BC_HANDLER_SPECS.keys())}."
4197 )
4198 if bc_type not in handler_spec["types"]:
4199 raise ValueError(
4200 f"Invalid BC combination on block {bi}, face {face}: type '{bc_type}' cannot use handler '{handler}'."
4201 )
4202
4203 params = bc.get("params", {})
4204 if params is None:
4205 params = {}
4206 if not isinstance(params, dict):
4207 raise ValueError(f"'params' for block {bi}, face {face} must be a mapping.")
4208
4209 # Reject unsupported older structured keys explicitly.
4210 if "vector" in params or "velocity" in params:
4211 raise ValueError(
4212 f"Unsupported older params key ('vector'/'velocity') found on block {bi}, face {face}. "
4213 "Use scalar keys 'vx', 'vy', 'vz'."
4214 )
4215
4216 required = handler_spec["required_params"]
4217 optional = handler_spec["optional_params"]
4218 allowed = required | optional
4219
4220 missing = sorted(required - set(params.keys()))
4221 if missing:
4222 raise ValueError(
4223 f"Missing required params for handler '{handler}' on block {bi}, face {face}: {missing}."
4224 )
4225 unknown = sorted(set(params.keys()) - allowed)
4226 if unknown:
4227 raise ValueError(
4228 f"Unknown params for handler '{handler}' on block {bi}, face {face}: {unknown}. "
4229 f"Allowed: {sorted(allowed)}."
4230 )
4231
4232 converted_params = {}
4233 for key, value in params.items():
4234 if key in _NUMERIC_BC_PARAMS:
4235 numeric = _to_float(value, f"boundary_conditions[{bi}][{idx}].params.{key}")
4236 if key in {"vx", "vy", "vz", "v_max"}:
4237 converted_params[key] = numeric / U_ref
4238 elif key == "target_flux":
4239 converted_params[key] = numeric / (U_ref * (L_ref ** 2))
4240 elif key in _BOOL_BC_PARAMS:
4241 converted_params[key] = _to_bool(value, f"boundary_conditions[{bi}][{idx}].params.{key}")
4242 elif handler == "prescribed_flow" and key == "source":
4243 converted_params[key] = _normalize_prescribed_flow_source(
4244 value, f"boundary_conditions[{bi}][{idx}].params.source"
4245 )
4246 else:
4247 # Defensive fallback; should not happen due unknown-key gate above.
4248 converted_params[key] = value
4249
4250 prepared_block.append({
4251 "face": face,
4252 "type": bc_type,
4253 "handler": handler,
4254 "params": converted_params,
4255 })
4256
4257 missing_faces = sorted(expected_faces - set(seen_faces.keys()))
4258 if missing_faces:
4259 raise ValueError(
4260 f"boundary_conditions[{bi}] is incomplete. Missing faces: {missing_faces}. "
4261 "Provide all six faces explicitly."
4262 )
4263
4264 # Pairwise periodic consistency checks.
4265 face_map = {entry["face"]: entry for entry in prepared_block}
4266 for neg_face, pos_face in axis_pairs:
4267 neg = face_map[neg_face]
4268 pos = face_map[pos_face]
4269 neg_periodic = (neg["type"] == "PERIODIC")
4270 pos_periodic = (pos["type"] == "PERIODIC")
4271 if neg_periodic != pos_periodic:
4272 raise ValueError(
4273 f"Inconsistent periodicity in block {bi}: {neg_face} and {pos_face} must both be PERIODIC or neither."
4274 )
4275
4276 driven_handlers = {"constant_flux"}
4277 if (neg["handler"] in driven_handlers) or (pos["handler"] in driven_handlers):
4278 if neg["handler"] != pos["handler"]:
4279 raise ValueError(
4280 f"In block {bi}, driven periodic handlers on {neg_face}/{pos_face} must match exactly."
4281 )
4282 if not (neg_periodic and pos_periodic):
4283 raise ValueError(
4284 f"In block {bi}, driven periodic handler '{neg['handler']}' requires PERIODIC type on both faces."
4285 )
4286
4287 prepared_blocks.append(prepared_block)
4288
4289 return prepared_blocks
4290
4291
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 4292 of file core.py.

4292def _schema_path_text(path: tuple) -> str:
4293 """!
4294 @brief Render an internal schema path tuple as a user-facing YAML path.
4295 @param[in] path Internal path tuple.
4296 @return Dotted YAML path.
4297 """
4298 return ".".join(part for part in path if part != "[]") or "<root>"
4299
4300
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 4301 of file core.py.

4301def _lookup_allowed_schema_keys(schema: dict, path: tuple):
4302 """!
4303 @brief Return allowed keys for a path, honoring '*' dynamic mapping entries.
4304 @param[in] schema Role schema mapping.
4305 @param[in] path Internal path tuple.
4306 @return Allowed key set, None for free-form mappings, or False when path is not schema-checked.
4307 """
4308 if path in schema:
4309 return schema[path]
4310 for idx, part in enumerate(path):
4311 if part == "[]":
4312 continue
4313 candidate = path[:idx] + ("*",) + path[idx + 1:]
4314 if candidate in schema:
4315 return schema[candidate]
4316 return False
4317
4318
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 4319 of file core.py.

4319def _schema_key_hint(schema: dict, path: tuple, key: str, allowed: set) -> str:
4320 """!
4321 @brief Build a concise typo or hierarchy hint for an unsupported YAML key.
4322 @param[in] schema Role schema mapping.
4323 @param[in] path Current internal YAML path tuple.
4324 @param[in] key Unsupported YAML key.
4325 @param[in] allowed Allowed keys at the current path.
4326 @return Optional hint string.
4327 """
4328 hints = []
4329 allowed_strings = sorted(str(item) for item in allowed)
4330 lower_matches = [item for item in allowed_strings if item.lower() == key.lower()]
4331 close_matches = lower_matches or difflib.get_close_matches(key, allowed_strings, n=1, cutoff=0.80)
4332 if close_matches:
4333 hints.append(f"Did you mean '{close_matches[0]}'?")
4334
4335 valid_paths = []
4336 for schema_path, schema_allowed in schema.items():
4337 if schema_path == path or not schema_allowed:
4338 continue
4339 if key in schema_allowed:
4340 valid_paths.append(_schema_path_text(schema_path))
4341 if valid_paths:
4342 hints.append(f"This key is valid at: {', '.join(sorted(valid_paths))}.")
4343
4344 return " ".join(hints)
4345
4346
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 4347 of file core.py.

4347def _validate_yaml_schema_keys(cfg, schema: dict, file_path: str, errors: list, path: tuple = ()) -> None:
4348 """!
4349 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
4350 @param[in] cfg Parsed YAML node.
4351 @param[in] schema Role schema mapping.
4352 @param[in] file_path Source file path for diagnostics.
4353 @param[in,out] errors Validation error accumulator.
4354 @param[in] path Current internal YAML path tuple.
4355 """
4356 if isinstance(cfg, dict):
4357 allowed = _lookup_allowed_schema_keys(schema, path)
4358 if allowed is not False and allowed is not None:
4359 unknown = sorted(str(key) for key in cfg.keys() if key not in allowed)
4360 for key in unknown:
4361 hint = _schema_key_hint(schema, path, key, allowed)
4362 hint_text = f" {hint}" if hint else ""
4363 errors.append(
4364 f" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
4365 f"Allowed keys: {sorted(allowed)}.{hint_text}"
4366 )
4367 if allowed is None:
4368 return
4369 for key, value in cfg.items():
4370 _validate_yaml_schema_keys(value, schema, file_path, errors, path + (key,))
4371 elif isinstance(cfg, list):
4372 for item in cfg:
4373 _validate_yaml_schema_keys(item, schema, file_path, errors, path + ("[]",))
4374
4375
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_solver_configs()

picurv_cli.core.validate_solver_configs ( dict  case_cfg,
dict  solver_cfg,
dict  monitor_cfg,
str  case_path,
str  solver_path,
str  monitor_path 
)

Validates all solver input configs before any work is done.

Checks for required sections, required keys, and physical sanity. Exits with a clear error message on the first problem found.

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).
Exceptions
SystemExiton validation failure.

Definition at line 4576 of file core.py.

4577 case_path: str, solver_path: str, monitor_path: str):
4578 """!
4579 @brief Validates all solver input configs before any work is done.
4580 @details Checks for required sections, required keys, and physical sanity.
4581 Exits with a clear error message on the first problem found.
4582 @param[in] case_cfg Parsed case YAML dictionary.
4583 @param[in] solver_cfg Parsed solver YAML dictionary.
4584 @param[in] monitor_cfg Parsed monitor YAML dictionary.
4585 @param[in] case_path Path to case file (for error messages).
4586 @param[in] solver_path Path to solver file (for error messages).
4587 @param[in] monitor_path Path to monitor file (for error messages).
4588 @throws SystemExit on validation failure.
4589 """
4590 errors = []
4591 warnings = []
4592 eulerian_source_mode = "solve"
4593
4594 _validate_yaml_schema_keys(case_cfg, _CASE_SCHEMA, case_path, errors)
4595 _validate_yaml_schema_keys(solver_cfg, _SOLVER_SCHEMA, solver_path, errors)
4596 _validate_yaml_schema_keys(monitor_cfg, _MONITOR_SCHEMA, monitor_path, errors)
4597
4598 # --- case.yml: required top-level sections ---
4599 required_case_sections = ['properties', 'run_control', 'grid', 'models', 'boundary_conditions']
4600 for section in required_case_sections:
4601 if section not in case_cfg:
4602 errors.append(f" {case_path}: missing required section '{section}'.")
4603
4604 if errors:
4605 _print_validation_errors(errors)
4606
4607 # --- case.yml: properties sub-keys ---
4608 props = case_cfg.get('properties', {})
4609 for group, keys in [('scaling', ['length_ref', 'velocity_ref']),
4610 ('fluid', ['density', 'viscosity'])]:
4611 sub = props.get(group, {})
4612 if not sub:
4613 errors.append(f" {case_path}: missing 'properties.{group}' section.")
4614 else:
4615 for k in keys:
4616 if k not in sub:
4617 errors.append(f" {case_path}: missing key 'properties.{group}.{k}'.")
4618
4619 # --- case.yml: run_control sub-keys ---
4620 rc = case_cfg.get('run_control', {})
4621 for k in ['start_step', 'total_steps', 'dt_physical']:
4622 if k not in rc:
4623 errors.append(f" {case_path}: missing key 'run_control.{k}'.")
4624
4625 # --- Physical sanity checks ---
4626 try:
4627 density = float(props.get('fluid', {}).get('density', 0))
4628 viscosity = float(props.get('fluid', {}).get('viscosity', 0))
4629 dt = float(rc.get('dt_physical', 0))
4630 if density <= 0:
4631 errors.append(f" {case_path}: 'properties.fluid.density' must be positive (got {density}).")
4632 if viscosity < 0:
4633 errors.append(f" {case_path}: 'properties.fluid.viscosity' must be non-negative (got {viscosity}).")
4634 if dt <= 0:
4635 errors.append(f" {case_path}: 'run_control.dt_physical' must be positive (got {dt}).")
4636 except (TypeError, ValueError):
4637 pass # Will be caught later during processing
4638
4639 # --- case.yml: grid mode ---
4640 grid_cfg = case_cfg.get('grid', {})
4641 grid_mode = grid_cfg.get('mode')
4642 valid_grid_modes = ['file', 'programmatic_c', 'grid_gen']
4643 if grid_mode not in valid_grid_modes:
4644 errors.append(f" {case_path}: 'grid.mode' must be one of {valid_grid_modes} (got '{grid_mode}').")
4645 elif grid_mode == 'file':
4646 source_file = grid_cfg.get('source_file')
4647 if not source_file:
4648 errors.append(f" {case_path}: 'grid.source_file' is required when grid.mode is 'file'.")
4649 else:
4650 source_abs = source_file if os.path.isabs(source_file) else os.path.abspath(os.path.join(os.path.dirname(case_path), source_file))
4651 if not os.path.isfile(source_abs):
4652 errors.append(f" {case_path}: grid.source_file does not exist: {source_abs}")
4653
4654 legacy_cfg = grid_cfg.get("legacy_conversion")
4655 if legacy_cfg is not None:
4656 if not isinstance(legacy_cfg, dict):
4657 errors.append(f" {case_path}: grid.legacy_conversion must be a mapping when provided.")
4658 else:
4659 enabled = legacy_cfg.get("enabled", True)
4660 if not isinstance(enabled, bool):
4661 errors.append(f" {case_path}: grid.legacy_conversion.enabled must be a boolean.")
4662
4663 fmt = legacy_cfg.get("format")
4664 if fmt is not None:
4665 normalized_fmt = str(fmt).strip().lower()
4666 allowed_formats = {"legacy1d", "legacy_1d", "les_flat_1d", "les-flat-1d"}
4667 if normalized_fmt not in allowed_formats:
4668 errors.append(
4669 f" {case_path}: grid.legacy_conversion.format must be one of "
4670 f"{sorted(allowed_formats)} (got '{fmt}')."
4671 )
4672
4673 script_path = legacy_cfg.get("script")
4674 if script_path is not None:
4675 if not isinstance(script_path, str) or not script_path.strip():
4676 errors.append(f" {case_path}: grid.legacy_conversion.script must be a non-empty string.")
4677 else:
4678 script_abs = script_path if os.path.isabs(script_path) else os.path.abspath(os.path.join(os.path.dirname(case_path), script_path))
4679 if not os.path.isfile(script_abs):
4680 errors.append(f" {case_path}: grid.legacy_conversion.script does not exist: {script_abs}")
4681
4682 output_file = legacy_cfg.get("output_file")
4683 if output_file is not None and (not isinstance(output_file, str) or not output_file.strip()):
4684 errors.append(f" {case_path}: grid.legacy_conversion.output_file must be a non-empty string when provided.")
4685
4686 axis_columns = legacy_cfg.get("axis_columns")
4687 if axis_columns is not None:
4688 if not isinstance(axis_columns, list) or len(axis_columns) != 3:
4689 errors.append(f" {case_path}: grid.legacy_conversion.axis_columns must be a 3-item integer list.")
4690 else:
4691 for idx, value in enumerate(axis_columns):
4692 if not isinstance(value, int) or value < 0:
4693 errors.append(
4694 f" {case_path}: grid.legacy_conversion.axis_columns[{idx}] must be a non-negative integer (got {value})."
4695 )
4696
4697 strict_trailing = legacy_cfg.get("strict_trailing")
4698 if strict_trailing is not None and not isinstance(strict_trailing, bool):
4699 errors.append(f" {case_path}: grid.legacy_conversion.strict_trailing must be a boolean when provided.")
4700
4701 cli_args = legacy_cfg.get("cli_args")
4702 if cli_args is not None and not isinstance(cli_args, list):
4703 errors.append(f" {case_path}: grid.legacy_conversion.cli_args must be a list of CLI tokens.")
4704 elif grid_mode == 'programmatic_c':
4705 grid_settings = grid_cfg.get('programmatic_settings')
4706 if not grid_settings:
4707 errors.append(f" {case_path}: 'grid.programmatic_settings' is required when grid.mode is 'programmatic_c'.")
4708 elif not isinstance(grid_settings, dict):
4709 errors.append(f" {case_path}: 'grid.programmatic_settings' must be a mapping.")
4710 elif grid_mode == 'grid_gen':
4711 gen_cfg = grid_cfg.get('generator')
4712 if not isinstance(gen_cfg, dict):
4713 errors.append(f" {case_path}: 'grid.generator' must be a mapping when grid.mode is 'grid_gen'.")
4714 else:
4715 warn_on_grid_generator_hyphen_keys(gen_cfg, case_path, warnings)
4716
4717 config_file = gen_cfg.get('config_file')
4718 if not config_file:
4719 errors.append(f" {case_path}: 'grid.generator.config_file' is required for grid.mode='grid_gen'.")
4720 else:
4721 config_abs = config_file if os.path.isabs(config_file) else os.path.abspath(os.path.join(os.path.dirname(case_path), config_file))
4722 if not os.path.isfile(config_abs):
4723 errors.append(f" {case_path}: grid.generator.config_file does not exist: {config_abs}")
4724
4725 grid_type = gen_cfg.get('grid_type')
4726 if grid_type is not None and str(grid_type) not in {'cpipe', 'pipe', 'warp'}:
4727 errors.append(f" {case_path}: grid.generator.grid_type must be one of ['cpipe','pipe','warp'] (got '{grid_type}').")
4728
4729 cli_args = gen_cfg.get('cli_args', [])
4730 if cli_args is not None and not isinstance(cli_args, list):
4731 errors.append(f" {case_path}: grid.generator.cli_args must be a list of CLI tokens.")
4732 try:
4733 resolve_grid_da_processor_layout(grid_cfg)
4734 except ValueError as e:
4735 errors.append(f" {case_path}: {e}")
4736
4737 # --- case.yml: boundary_conditions strict validation ---
4738 prepared_blocks = None
4739 try:
4740 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
4741 except ValueError as e:
4742 errors.append(f" {case_path}: {e}")
4743
4744 # --- case.yml: initial_conditions mode-aware validation ---
4745 ic = props.get('initial_conditions', {})
4746 if not ic:
4747 errors.append(f" {case_path}: missing 'properties.initial_conditions' section.")
4748 elif not isinstance(ic, dict):
4749 errors.append(f" {case_path}: 'properties.initial_conditions' must be a mapping.")
4750 elif 'mode' not in ic:
4751 errors.append(
4752 f" {case_path}: missing key 'properties.initial_conditions.mode'. "
4753 "Specify 'generated' or 'file' explicitly."
4754 )
4755 else:
4756 try:
4757 resolve_initial_condition_config(ic, prepared_blocks, U_ref=1.0)
4758 except KeyError as e:
4759 errors.append(f" {case_path}: missing key 'properties.initial_conditions.{e.args[0]}'.")
4760 except ValueError as e:
4761 errors.append(f" {case_path}: {e}")
4762
4763 # --- case.yml: particle initialization validation ---
4764 particles_cfg = case_cfg.get('models', {}).get('physics', {}).get('particles', {})
4765 if particles_cfg and not isinstance(particles_cfg, dict):
4766 errors.append(f" {case_path}: 'models.physics.particles' must be a mapping.")
4767 elif isinstance(particles_cfg, dict):
4768 init_mode_raw = particles_cfg.get('init_mode', 'Surface')
4769 try:
4770 pinit_code = normalize_particle_init_mode(init_mode_raw)
4771 except ValueError as e:
4772 errors.append(f" {case_path}: {e}")
4773 pinit_code = None
4774
4775 restart_mode = particles_cfg.get('restart_mode')
4776 if restart_mode is not None and str(restart_mode).lower() not in {"init", "load"}:
4777 errors.append(
4778 f" {case_path}: models.physics.particles.restart_mode must be 'init' or 'load' (got '{restart_mode}')."
4779 )
4780 elif 'restart_mode' not in particles_cfg:
4781 try:
4782 start_step = int(rc.get('start_step', 0))
4783 particle_count = int(particles_cfg.get('count', 0) or 0)
4784 except (TypeError, ValueError):
4785 start_step = 0
4786 particle_count = 0
4787 if start_step > 0 and particle_count > 0:
4788 warnings.append(
4789 f"{case_path}: models.physics.particles.restart_mode is omitted for a particle restart "
4790 "(run_control.start_step > 0, count > 0). C will default to 'load'."
4791 )
4792
4793 if pinit_code == 2:
4794 point_cfg = particles_cfg.get('point_source', {})
4795 if not isinstance(point_cfg, dict):
4796 errors.append(f" {case_path}: models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
4797 else:
4798 for coord in ('x', 'y', 'z'):
4799 if coord not in point_cfg:
4800 errors.append(
4801 f" {case_path}: models.physics.particles.point_source.{coord} is required when init_mode is PointSource."
4802 )
4803
4804 # --- case.yml: turbulence model validation ---
4805 turbulence_cfg = case_cfg.get('models', {}).get('physics', {}).get('turbulence', {})
4806 if turbulence_cfg is not None and not isinstance(turbulence_cfg, dict):
4807 errors.append(f" {case_path}: 'models.physics.turbulence' must be a mapping.")
4808 elif isinstance(turbulence_cfg, dict) and turbulence_cfg:
4809 try:
4810 append_turbulence_flags(case_cfg.get('models', {}), [])
4811 except ValueError as e:
4812 errors.append(f" {case_path}: {e}")
4813
4814 les_cfg = turbulence_cfg.get('les')
4815 rans_cfg = turbulence_cfg.get('rans')
4816 wall_cfg = turbulence_cfg.get('wall_function')
4817
4818 if isinstance(les_cfg, dict):
4819 for key in ('enabled',):
4820 if key in les_cfg and not isinstance(les_cfg[key], bool):
4821 errors.append(f" {case_path}: models.physics.turbulence.les.{key} must be true or false.")
4822 for key in ('constant_cs', 'max_cs'):
4823 if key in les_cfg:
4824 try:
4825 value = float(les_cfg[key])
4826 if value < 0.0:
4827 errors.append(f" {case_path}: models.physics.turbulence.les.{key} must be nonnegative.")
4828 except (TypeError, ValueError):
4829 errors.append(f" {case_path}: models.physics.turbulence.les.{key} must be numeric.")
4830 if 'dynamic_frequency' in les_cfg:
4831 try:
4832 value = int(les_cfg['dynamic_frequency'])
4833 if value <= 0:
4834 errors.append(f" {case_path}: models.physics.turbulence.les.dynamic_frequency must be positive.")
4835 except (TypeError, ValueError):
4836 errors.append(f" {case_path}: models.physics.turbulence.les.dynamic_frequency must be an integer.")
4837
4838 if isinstance(rans_cfg, dict):
4839 if 'enabled' in rans_cfg and not isinstance(rans_cfg['enabled'], bool):
4840 errors.append(f" {case_path}: models.physics.turbulence.rans.enabled must be true or false.")
4841 try:
4842 rans_enabled = bool(rans_cfg.get('enabled', True)) and normalize_rans_model(rans_cfg.get('model', 'k_omega')) != 0
4843 except ValueError:
4844 rans_enabled = False
4845 if rans_enabled:
4846 warnings.append(
4847 f"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
4848 )
4849 elif rans_cfg:
4850 warnings.append(
4851 f"{case_path}: models.physics.turbulence.rans is accepted, but the k-omega runtime update is currently incomplete."
4852 )
4853
4854 if isinstance(wall_cfg, dict):
4855 if 'enabled' in wall_cfg and not isinstance(wall_cfg['enabled'], bool):
4856 errors.append(f" {case_path}: models.physics.turbulence.wall_function.enabled must be true or false.")
4857 if 'roughness_height' in wall_cfg:
4858 try:
4859 value = float(wall_cfg['roughness_height'])
4860 if value < 0.0:
4861 errors.append(f" {case_path}: models.physics.turbulence.wall_function.roughness_height must be nonnegative.")
4862 except (TypeError, ValueError):
4863 errors.append(f" {case_path}: models.physics.turbulence.wall_function.roughness_height must be numeric.")
4864
4865 # --- solver.yml: basic structure ---
4866 if not isinstance(solver_cfg, dict) or not solver_cfg:
4867 errors.append(f" {solver_path}: solver config is empty or not a valid YAML mapping.")
4868 else:
4869 strategy_cfg = solver_cfg.get('strategy', {})
4870 if not isinstance(strategy_cfg, dict):
4871 errors.append(f" {solver_path}: 'strategy' must be a mapping.")
4872 elif 'implicit' in strategy_cfg:
4873 errors.append(
4874 f" {solver_path}: unsupported old key 'strategy.implicit' is not supported. "
4875 "Use 'strategy.momentum_solver' with named solver values."
4876 )
4877 if isinstance(strategy_cfg, dict) and 'momentum_solver' in strategy_cfg:
4878 try:
4879 normalize_momentum_solver_type(strategy_cfg['momentum_solver'])
4880 except ValueError as e:
4881 errors.append(f" {solver_path}: {e}")
4882
4883 op_mode_cfg = solver_cfg.get('operation_mode', {})
4884 if op_mode_cfg is not None and not isinstance(op_mode_cfg, dict):
4885 errors.append(f" {solver_path}: 'operation_mode' must be a mapping when provided.")
4886 elif isinstance(op_mode_cfg, dict):
4887 eulerian_source_mode = None
4888 normalized_analytical_type = None
4889 if 'eulerian_field_source' in op_mode_cfg:
4890 try:
4891 eulerian_source_mode = normalize_eulerian_field_source(op_mode_cfg.get('eulerian_field_source'))
4892 except ValueError as e:
4893 errors.append(f" {solver_path}: {e}")
4894
4895 analytical_type = op_mode_cfg.get('analytical_type')
4896 if analytical_type is not None:
4897 try:
4898 normalized_analytical_type = normalize_analytical_type(analytical_type)
4899 except ValueError as e:
4900 errors.append(f" {solver_path}: {e}")
4901 else:
4902 uniform_flow_cfg = op_mode_cfg.get('uniform_flow')
4903 if uniform_flow_cfg is not None and not isinstance(uniform_flow_cfg, dict):
4904 errors.append(f" {solver_path}: 'operation_mode.uniform_flow' must be a mapping when provided.")
4905 elif normalized_analytical_type == "UNIFORM_FLOW":
4906 if not isinstance(uniform_flow_cfg, dict):
4907 errors.append(
4908 f" {solver_path}: operation_mode.uniform_flow is required when "
4909 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
4910 )
4911 else:
4912 for coord in ("u", "v", "w"):
4913 if coord not in uniform_flow_cfg:
4914 errors.append(
4915 f" {solver_path}: operation_mode.uniform_flow.{coord} is required for UNIFORM_FLOW."
4916 )
4917 else:
4918 try:
4919 float(uniform_flow_cfg[coord])
4920 except (TypeError, ValueError):
4921 errors.append(
4922 f" {solver_path}: operation_mode.uniform_flow.{coord} must be numeric."
4923 )
4924 elif uniform_flow_cfg is not None:
4925 errors.append(
4926 f" {solver_path}: operation_mode.uniform_flow is only valid when "
4927 "operation_mode.analytical_type is 'UNIFORM_FLOW'."
4928 )
4929
4930 if eulerian_source_mode == "analytical":
4931 effective_analytical_type = normalized_analytical_type or "TGV3D"
4932 if effective_analytical_type == "TGV3D":
4933 if grid_mode != 'programmatic_c':
4934 errors.append(
4935 f" {case_path}: analytical type '{effective_analytical_type}' requires grid.mode "
4936 "'programmatic_c'. File-backed analytical ingestion is only supported for "
4937 "ZERO_FLOW and UNIFORM_FLOW."
4938 )
4939 elif isinstance(grid_cfg.get('programmatic_settings'), dict):
4940 missing_dims = [key for key in ('im', 'jm', 'km') if key not in grid_cfg['programmatic_settings']]
4941 if missing_dims:
4942 errors.append(
4943 f" {case_path}: grid.programmatic_settings must include {missing_dims} when "
4944 f"operation_mode.analytical_type resolves to '{effective_analytical_type}'."
4945 )
4946 else:
4947 if grid_mode not in {'programmatic_c', 'file'}:
4948 errors.append(
4949 f" {case_path}: grid.mode '{grid_mode}' is not supported when "
4950 f"operation_mode.analytical_type is '{effective_analytical_type}'. "
4951 "Use 'programmatic_c' or 'file'."
4952 )
4953 elif grid_mode == 'programmatic_c' and isinstance(grid_cfg.get('programmatic_settings'), dict):
4954 missing_dims = [key for key in ('im', 'jm', 'km') if key not in grid_cfg['programmatic_settings']]
4955 if missing_dims:
4956 errors.append(
4957 f" {case_path}: grid.programmatic_settings must include {missing_dims} when "
4958 f"operation_mode.analytical_type is '{effective_analytical_type}' and "
4959 "grid.mode is 'programmatic_c'."
4960 )
4961
4962 verification_cfg = solver_cfg.get('verification', {})
4963 if verification_cfg is not None and not isinstance(verification_cfg, dict):
4964 errors.append(f" {solver_path}: 'verification' must be a mapping when provided.")
4965 elif isinstance(verification_cfg, dict) and verification_cfg:
4966 sources_cfg = verification_cfg.get('sources', {})
4967 if sources_cfg is not None and not isinstance(sources_cfg, dict):
4968 errors.append(f" {solver_path}: 'verification.sources' must be a mapping when provided.")
4969 elif isinstance(sources_cfg, dict) and sources_cfg:
4970 diff_cfg = sources_cfg.get('diffusivity')
4971 scalar_cfg = sources_cfg.get('scalar')
4972
4973 if diff_cfg is not None:
4974 if not isinstance(diff_cfg, dict):
4975 errors.append(f" {solver_path}: 'verification.sources.diffusivity' must be a mapping.")
4976 else:
4977 if eulerian_source_mode != "analytical":
4978 errors.append(
4979 f" {solver_path}: verification.sources.diffusivity is only valid when "
4980 "operation_mode.eulerian_field_source is 'analytical'."
4981 )
4982 mode = diff_cfg.get('mode')
4983 profile = diff_cfg.get('profile')
4984 if str(mode).strip().lower() != "analytical":
4985 errors.append(
4986 f" {solver_path}: verification.sources.diffusivity.mode must be 'analytical'."
4987 )
4988 if str(profile).strip().upper() != "LINEAR_X":
4989 errors.append(
4990 f" {solver_path}: verification.sources.diffusivity.profile must be 'LINEAR_X'."
4991 )
4992 for key in ("gamma0", "slope_x"):
4993 if key not in diff_cfg:
4994 errors.append(
4995 f" {solver_path}: verification.sources.diffusivity.{key} is required."
4996 )
4997 else:
4998 try:
4999 float(diff_cfg[key])
5000 except (TypeError, ValueError):
5001 errors.append(
5002 f" {solver_path}: verification.sources.diffusivity.{key} must be numeric."
5003 )
5004
5005 if scalar_cfg is not None:
5006 if not isinstance(scalar_cfg, dict):
5007 errors.append(f" {solver_path}: 'verification.sources.scalar' must be a mapping.")
5008 else:
5009 if eulerian_source_mode != "analytical":
5010 errors.append(
5011 f" {solver_path}: verification.sources.scalar is only valid when "
5012 "operation_mode.eulerian_field_source is 'analytical'."
5013 )
5014 mode = scalar_cfg.get('mode')
5015 profile = str(scalar_cfg.get('profile', '')).strip().upper()
5016 if str(mode).strip().lower() != "analytical":
5017 errors.append(
5018 f" {solver_path}: verification.sources.scalar.mode must be 'analytical'."
5019 )
5020 if profile not in {"CONSTANT", "LINEAR_X", "SIN_PRODUCT"}:
5021 errors.append(
5022 f" {solver_path}: verification.sources.scalar.profile must be one of CONSTANT, LINEAR_X, SIN_PRODUCT."
5023 )
5024 required_scalar_keys = {
5025 "CONSTANT": ("value",),
5026 "LINEAR_X": ("phi0", "slope_x"),
5027 "SIN_PRODUCT": ("amplitude", "kx", "ky", "kz"),
5028 }.get(profile, ())
5029 for key in required_scalar_keys:
5030 if key not in scalar_cfg:
5031 errors.append(
5032 f" {solver_path}: verification.sources.scalar.{key} is required for profile '{profile}'."
5033 )
5034 else:
5035 try:
5036 float(scalar_cfg[key])
5037 except (TypeError, ValueError):
5038 errors.append(
5039 f" {solver_path}: verification.sources.scalar.{key} must be numeric."
5040 )
5041
5042 unknown_source_keys = sorted(set(sources_cfg.keys()) - {"diffusivity", "scalar"})
5043 if unknown_source_keys:
5044 errors.append(
5045 f" {solver_path}: unsupported verification.sources entries: {unknown_source_keys}. "
5046 "Currently supported: 'diffusivity', 'scalar'."
5047 )
5048 unknown_verification_keys = sorted(set(verification_cfg.keys()) - {"sources"})
5049 if unknown_verification_keys:
5050 errors.append(
5051 f" {solver_path}: unsupported verification keys: {unknown_verification_keys}. "
5052 "Currently supported: 'sources'."
5053 )
5054
5055 transport_cfg = solver_cfg.get('scalar_transport', {})
5056 if transport_cfg is not None and not isinstance(transport_cfg, dict):
5057 errors.append(f" {solver_path}: 'scalar_transport' must be a mapping when provided.")
5058 elif isinstance(transport_cfg, dict):
5059 unknown_transport_keys = sorted(set(transport_cfg.keys()) - {"schmidt_number", "turbulent_schmidt_number"})
5060 if unknown_transport_keys:
5061 errors.append(
5062 f" {solver_path}: unsupported scalar_transport entries: {unknown_transport_keys}. "
5063 "Currently supported: 'schmidt_number', 'turbulent_schmidt_number'."
5064 )
5065 for key in ("schmidt_number", "turbulent_schmidt_number"):
5066 if key in transport_cfg:
5067 try:
5068 value = float(transport_cfg[key])
5069 if value <= 0.0:
5070 errors.append(f" {solver_path}: scalar_transport.{key} must be positive.")
5071 except (TypeError, ValueError):
5072 errors.append(f" {solver_path}: scalar_transport.{key} must be numeric.")
5073
5074 tolerances_cfg = solver_cfg.get('tolerances', {})
5075 if tolerances_cfg is not None and not isinstance(tolerances_cfg, dict):
5076 errors.append(f" {solver_path}: 'tolerances' must be a mapping when provided.")
5077 elif isinstance(tolerances_cfg, dict):
5078 for key in ("absolute_tol", "relative_tol", "residual_absolute_tol", "residual_relative_tol"):
5079 if key in tolerances_cfg:
5080 try:
5081 float(tolerances_cfg[key])
5082 except (TypeError, ValueError):
5083 errors.append(f" {solver_path}: tolerances.{key} must be numeric.")
5084
5085 ms_cfg = solver_cfg.get('momentum_solver', {})
5086 if ms_cfg is not None and not isinstance(ms_cfg, dict):
5087 errors.append(f" {solver_path}: 'momentum_solver' must be a mapping when provided.")
5088 elif isinstance(ms_cfg, dict):
5089 unsupported_flat_keys = {
5090 'max_pseudo_steps', 'absolute_tol', 'relative_tol', 'step_tol',
5091 'pseudo_cfl', 'jameson_residual_noise_allowance_factor',
5092 'rk4_residual_noise_allowance_factor'
5093 }
5094 present_unsupported = sorted(unsupported_flat_keys.intersection(ms_cfg.keys()))
5095 if present_unsupported:
5096 errors.append(
5097 f" {solver_path}: unsupported flat keys in 'momentum_solver' are not supported: {present_unsupported}. "
5098 "Use solver-specific sub-blocks (e.g., momentum_solver.dual_time_picard_jameson_rk)."
5099 )
5100
5101 allowed_ms_keys = {'dual_time_picard_jameson_rk', 'dual_time_picard_rk4'}
5102 unknown_ms_keys = sorted(set(ms_cfg.keys()) - allowed_ms_keys)
5103 if unknown_ms_keys:
5104 errors.append(
5105 f" {solver_path}: unsupported momentum_solver blocks/keys: {unknown_ms_keys}. "
5106 "Currently supported: 'dual_time_picard_jameson_rk'."
5107 )
5108 if 'dual_time_picard_jameson_rk' in ms_cfg and 'dual_time_picard_rk4' in ms_cfg:
5109 errors.append(
5110 f" {solver_path}: use only momentum_solver.dual_time_picard_jameson_rk; "
5111 "do not also set its deprecated dual_time_picard_rk4 alias."
5112 )
5113
5114 selected_solver = None
5115 if isinstance(strategy_cfg, dict) and 'momentum_solver' in strategy_cfg:
5116 try:
5117 selected_solver = normalize_momentum_solver_type(strategy_cfg['momentum_solver'])
5118 except ValueError:
5119 pass
5120 if selected_solver is None:
5121 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
5122
5123 has_dualtime_block = (
5124 'dual_time_picard_jameson_rk' in ms_cfg or 'dual_time_picard_rk4' in ms_cfg
5125 )
5126 if selected_solver != "DUALTIME_PICARD_JAMESON_RK" and has_dualtime_block:
5127 errors.append(
5128 f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk is set but selected solver is "
5129 f"{selected_solver}. Solver-specific blocks must match the selected solver."
5130 )
5131
5132 dt_picard_cfg = ms_cfg.get('dual_time_picard_jameson_rk', ms_cfg.get('dual_time_picard_rk4'))
5133 if dt_picard_cfg is not None:
5134 if not isinstance(dt_picard_cfg, dict):
5135 errors.append(f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
5136 else:
5137 allowed_dt_keys = {
5138 'max_pseudo_steps', 'absolute_tol', 'relative_tol', 'step_tol',
5139 'pseudo_cfl', 'jameson_residual_noise_allowance_factor',
5140 'rk4_residual_noise_allowance_factor'
5141 }
5142 unknown_dt_keys = sorted(set(dt_picard_cfg.keys()) - allowed_dt_keys)
5143 if unknown_dt_keys:
5144 errors.append(
5145 f" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk: {unknown_dt_keys}."
5146 )
5147 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
5148 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
5149 errors.append(
5150 f" {solver_path}: use only jameson_residual_noise_allowance_factor; "
5151 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
5152 )
5153 if 'pseudo_cfl' in dt_picard_cfg:
5154 pcfl_cfg = dt_picard_cfg['pseudo_cfl']
5155 if not isinstance(pcfl_cfg, dict):
5156 errors.append(f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl must be a mapping.")
5157 else:
5158 allowed_pcfl_keys = {'initial', 'minimum', 'maximum', 'growth_factor', 'reduction_factor'}
5159 unknown_pcfl_keys = sorted(set(pcfl_cfg.keys()) - allowed_pcfl_keys)
5160 if unknown_pcfl_keys:
5161 errors.append(
5162 f" {solver_path}: unsupported keys in momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl: {unknown_pcfl_keys}."
5163 )
5164 numeric_pcfl = {}
5165 for key in allowed_pcfl_keys:
5166 if key in pcfl_cfg:
5167 try:
5168 numeric_pcfl[key] = float(pcfl_cfg[key])
5169 except (TypeError, ValueError):
5170 errors.append(
5171 f" {solver_path}: momentum_solver.dual_time_picard_jameson_rk.pseudo_cfl.{key} must be numeric."
5172 )
5173 if numeric_pcfl.get('minimum', 1.0) <= 0.0:
5174 errors.append(f" {solver_path}: pseudo_cfl.minimum must be positive.")
5175 if numeric_pcfl.get('growth_factor', 1.0) < 1.0:
5176 errors.append(f" {solver_path}: pseudo_cfl.growth_factor must be at least 1.")
5177 reduction = numeric_pcfl.get('reduction_factor', 1.0)
5178 if reduction <= 0.0 or reduction >= 1.0:
5179 errors.append(f" {solver_path}: pseudo_cfl.reduction_factor must be in (0, 1).")
5180 if all(key in numeric_pcfl for key in ('minimum', 'initial', 'maximum')):
5181 if not numeric_pcfl['minimum'] <= numeric_pcfl['initial'] <= numeric_pcfl['maximum']:
5182 errors.append(f" {solver_path}: pseudo_cfl requires minimum <= initial <= maximum.")
5183 noise_key = (
5184 'jameson_residual_noise_allowance_factor'
5185 if 'jameson_residual_noise_allowance_factor' in dt_picard_cfg
5186 else 'rk4_residual_noise_allowance_factor'
5187 )
5188 if noise_key in dt_picard_cfg:
5189 try:
5190 if float(dt_picard_cfg[noise_key]) < 1.0:
5191 errors.append(f" {solver_path}: {noise_key} must be at least 1.")
5192 except (TypeError, ValueError):
5193 errors.append(f" {solver_path}: {noise_key} must be numeric.")
5194
5195 solution_convergence_cfg = solver_cfg.get('solution_convergence', {})
5196 if solution_convergence_cfg is not None and not isinstance(solution_convergence_cfg, dict):
5197 errors.append(f" {solver_path}: 'solution_convergence' must be a mapping when provided.")
5198 elif isinstance(solution_convergence_cfg, dict) and solution_convergence_cfg:
5199 allowed_solution_convergence_keys = {
5200 'enabled', 'mode', 'periodic_deterministic', 'statistical_steady'
5201 }
5202 unknown_solution_convergence_keys = sorted(set(solution_convergence_cfg.keys()) - allowed_solution_convergence_keys)
5203 if unknown_solution_convergence_keys:
5204 errors.append(
5205 f" {solver_path}: unsupported solution_convergence keys: {unknown_solution_convergence_keys}."
5206 )
5207
5208 mode = solution_convergence_cfg.get('mode', 'steady_deterministic')
5209 try:
5210 normalized_solution_mode = normalize_solution_convergence_mode(mode)
5211 except ValueError as e:
5212 errors.append(f" {solver_path}: {e}")
5213 normalized_solution_mode = None
5214
5215 periodic_cfg = solution_convergence_cfg.get('periodic_deterministic')
5216 statistical_cfg = solution_convergence_cfg.get('statistical_steady')
5217 if periodic_cfg is not None and not isinstance(periodic_cfg, dict):
5218 errors.append(f" {solver_path}: solution_convergence.periodic_deterministic must be a mapping when provided.")
5219 if statistical_cfg is not None and not isinstance(statistical_cfg, dict):
5220 errors.append(f" {solver_path}: solution_convergence.statistical_steady must be a mapping when provided.")
5221
5222 if isinstance(periodic_cfg, dict):
5223 unknown_periodic_keys = sorted(set(periodic_cfg.keys()) - {'period_steps'})
5224 if unknown_periodic_keys:
5225 errors.append(
5226 f" {solver_path}: unsupported keys in solution_convergence.periodic_deterministic: {unknown_periodic_keys}."
5227 )
5228 period_steps = periodic_cfg.get('period_steps')
5229 if period_steps is not None and (not isinstance(period_steps, int) or period_steps <= 0):
5230 errors.append(f" {solver_path}: solution_convergence.periodic_deterministic.period_steps must be a positive integer.")
5231
5232 if isinstance(statistical_cfg, dict):
5233 unknown_statistical_keys = sorted(set(statistical_cfg.keys()) - {'window_steps'})
5234 if unknown_statistical_keys:
5235 errors.append(
5236 f" {solver_path}: unsupported keys in solution_convergence.statistical_steady: {unknown_statistical_keys}."
5237 )
5238 window_steps = statistical_cfg.get('window_steps')
5239 if window_steps is not None and (not isinstance(window_steps, int) or window_steps <= 0):
5240 errors.append(f" {solver_path}: solution_convergence.statistical_steady.window_steps must be a positive integer.")
5241
5242 if normalized_solution_mode == "PERIODIC_DETERMINISTIC":
5243 if not isinstance(periodic_cfg, dict) or 'period_steps' not in periodic_cfg:
5244 errors.append(
5245 f" {solver_path}: solution_convergence.periodic_deterministic.period_steps is required when mode is 'periodic_deterministic'."
5246 )
5247 elif periodic_cfg is not None:
5248 errors.append(
5249 f" {solver_path}: solution_convergence.periodic_deterministic is only valid when mode is 'periodic_deterministic'."
5250 )
5251
5252 if normalized_solution_mode == "STATISTICAL_STEADY":
5253 if not isinstance(statistical_cfg, dict) or 'window_steps' not in statistical_cfg:
5254 errors.append(
5255 f" {solver_path}: solution_convergence.statistical_steady.window_steps is required when mode is 'statistical_steady'."
5256 )
5257 elif statistical_cfg is not None:
5258 errors.append(
5259 f" {solver_path}: solution_convergence.statistical_steady is only valid when mode is 'statistical_steady'."
5260 )
5261
5262 # --- solver.yml: interpolation section ---
5263 interp_cfg = solver_cfg.get('interpolation', {}) if isinstance(solver_cfg, dict) else {}
5264 if interp_cfg is not None and not isinstance(interp_cfg, dict):
5265 errors.append(f" {solver_path}: 'interpolation' must be a mapping when provided.")
5266 elif isinstance(interp_cfg, dict) and 'method' in interp_cfg:
5267 try:
5268 normalize_interpolation_method(interp_cfg['method'])
5269 except ValueError as e:
5270 errors.append(f" {solver_path}: {e}")
5271
5272 # --- monitor.yml: basic structure ---
5273 if not isinstance(monitor_cfg, dict) or not monitor_cfg:
5274 errors.append(f" {monitor_path}: monitor config is empty or not a valid YAML mapping.")
5275 else:
5276 io_cfg = monitor_cfg.get('io', {})
5277 freq = io_cfg.get('data_output_frequency')
5278 if freq is not None and (not isinstance(freq, int) or freq <= 0):
5279 errors.append(f" {monitor_path}: 'io.data_output_frequency' must be a positive integer (got {freq}).")
5280 particle_console_freq = io_cfg.get('particle_console_output_frequency')
5281 if particle_console_freq is not None and (not isinstance(particle_console_freq, int) or particle_console_freq < 0):
5282 errors.append(
5283 f" {monitor_path}: 'io.particle_console_output_frequency' must be a non-negative integer "
5284 f"(got {particle_console_freq})."
5285 )
5286 try:
5287 resolve_profiling_config(monitor_cfg)
5288 except ValueError as e:
5289 errors.append(f" {monitor_path}: {e}")
5290 try:
5291 resolve_diagnostics_config(monitor_cfg)
5292 except ValueError as e:
5293 errors.append(f" {monitor_path}: {e}")
5294 try:
5295 resolve_solver_monitoring_flags(monitor_cfg)
5296 except ValueError as e:
5297 errors.append(f" {monitor_path}: {e}")
5298
5299 if not errors:
5300 if needs_restart_source(case_cfg, solver_cfg):
5301 warnings.append(
5302 f"{case_path}: This configuration requires restart data (start_step > 0, "
5303 "eulerian_field_source='load', or particle restart_mode='load'). "
5304 "Use --restart-from or --continue when running."
5305 )
5306
5307 if errors:
5308 _print_validation_errors(errors)
5309 for warning in warnings:
5310 print(f"[WARN] {warning}", file=sys.stderr)
5311
5312
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 
)

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).
Exceptions
SystemExiton validation failure.

Definition at line 5313 of file core.py.

5313def validate_post_config(post_cfg: dict, post_path: str):
5314 """!
5315 @brief Validates the post-processing config before running the post-processor.
5316 @param[in] post_cfg Parsed post-processing YAML dictionary.
5317 @param[in] post_path Path to post file (for error messages).
5318 @throws SystemExit on validation failure.
5319 """
5320 errors = []
5321
5322 _validate_yaml_schema_keys(post_cfg, _POST_SCHEMA, post_path, errors)
5323
5324 if not isinstance(post_cfg, dict) or not post_cfg:
5325 errors.append(f" {post_path}: post-processing config is empty or not a valid YAML mapping.")
5326 _print_validation_errors(errors)
5327
5328 # --- run_control ---
5329 if 'run_control' not in post_cfg:
5330 errors.append(f" {post_path}: missing required section 'run_control'.")
5331 else:
5332 rc = post_cfg.get('run_control', {})
5333 if not isinstance(rc, dict):
5334 errors.append(f" {post_path}: 'run_control' must be a mapping.")
5335 else:
5336 for canonical_key, aliases in POST_RUN_CONTROL_ALIASES.items():
5337 if not any(alias in rc for alias in aliases):
5338 alias_list = "', '".join(aliases)
5339 errors.append(
5340 f" {post_path}: missing required key 'run_control.{canonical_key}' "
5341 f"(accepted aliases: '{alias_list}')."
5342 )
5343 continue
5344 raw_value = _mapping_value_with_aliases(rc, *aliases)
5345 try:
5346 int(raw_value)
5347 except (TypeError, ValueError):
5348 alias_name = next((alias for alias in aliases if alias in rc), canonical_key)
5349 errors.append(
5350 f" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
5351 )
5352
5353 # --- io section ---
5354 io_cfg = post_cfg.get('io', {})
5355 source_cfg = post_cfg.get('source_data')
5356 if source_cfg is not None and not isinstance(source_cfg, dict):
5357 errors.append(f" {post_path}: 'source_data' must be a mapping when provided.")
5358 global_ops = post_cfg.get('global_operations')
5359 if global_ops is not None:
5360 if not isinstance(global_ops, dict):
5361 errors.append(f" {post_path}: 'global_operations' must be a mapping when provided.")
5362 elif 'dimensionalize' in global_ops and not isinstance(global_ops.get('dimensionalize'), bool):
5363 errors.append(f" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
5364 if not io_cfg:
5365 errors.append(f" {post_path}: missing required section 'io'.")
5366 elif not isinstance(io_cfg, dict):
5367 errors.append(f" {post_path}: 'io' must be a mapping.")
5368 else:
5369 for k in ['output_directory', 'output_filename_prefix']:
5370 if k not in io_cfg:
5371 errors.append(f" {post_path}: missing required key 'io.{k}'.")
5372 for key_name in ('output_directory', 'output_filename_prefix', 'particle_filename_prefix'):
5373 if key_name in io_cfg and not isinstance(io_cfg.get(key_name), str):
5374 errors.append(f" {post_path}: 'io.{key_name}' must be a string when provided.")
5375 if 'output_particles' in io_cfg and not isinstance(io_cfg.get('output_particles'), bool):
5376 errors.append(f" {post_path}: 'io.output_particles' must be a boolean when provided.")
5377 particle_subsampling_frequency = io_cfg.get('particle_subsampling_frequency')
5378 if particle_subsampling_frequency is not None:
5379 if not isinstance(particle_subsampling_frequency, int) or particle_subsampling_frequency <= 0:
5380 errors.append(
5381 f" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
5382 )
5383 input_extensions = io_cfg.get('input_extensions')
5384 source_input_extensions = get_post_source_data(post_cfg).get('input_extensions')
5385 if input_extensions is not None:
5386 if not isinstance(input_extensions, dict):
5387 errors.append(f" {post_path}: 'io.input_extensions' must be a mapping when provided.")
5388 else:
5389 for ext_key in ('eulerian', 'particle'):
5390 ext_val = input_extensions.get(ext_key)
5391 if ext_val is not None and not isinstance(ext_val, str):
5392 errors.append(f" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
5393 if source_input_extensions is not None:
5394 if not isinstance(source_input_extensions, dict):
5395 errors.append(f" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
5396 else:
5397 for ext_key in ('eulerian', 'particle'):
5398 ext_val = source_input_extensions.get(ext_key)
5399 if ext_val is not None and not isinstance(ext_val, str):
5400 errors.append(
5401 f" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
5402 )
5403
5404 averaged_fields = io_cfg.get('eulerian_fields_averaged')
5405 if averaged_fields is not None and not isinstance(averaged_fields, list):
5406 errors.append(f" {post_path}: 'io.eulerian_fields_averaged' must be a list when provided.")
5407 for list_key in ('eulerian_fields', 'particle_fields'):
5408 list_val = io_cfg.get(list_key)
5409 if list_val is not None and not isinstance(list_val, list):
5410 errors.append(f" {post_path}: 'io.{list_key}' must be a list when provided.")
5411
5412 # --- Check eulerian_pipeline entries have 'task' key ---
5413 eulerian_pipeline = post_cfg.get('eulerian_pipeline', [])
5414 if eulerian_pipeline is not None and not isinstance(eulerian_pipeline, list):
5415 errors.append(f" {post_path}: 'eulerian_pipeline' must be a list when provided.")
5416 eulerian_pipeline = []
5417 for i, entry in enumerate(eulerian_pipeline):
5418 if not isinstance(entry, dict) or 'task' not in entry:
5419 errors.append(f" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
5420 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
5421 continue
5422 task_name = entry.get('task')
5423 if task_name == 'q_criterion':
5424 continue
5425 if task_name == 'nodal_average':
5426 in_field = entry.get('input_field')
5427 out_field = entry.get('output_field')
5428 if not isinstance(in_field, str) or not in_field.strip():
5429 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
5430 if not isinstance(out_field, str) or not out_field.strip():
5431 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
5432 if isinstance(in_field, str) and isinstance(out_field, str) and in_field == out_field:
5433 errors.append(
5434 f" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
5435 )
5436 continue
5437 if task_name == 'normalize_field':
5438 field = entry.get('field', 'P')
5439 if not isinstance(field, str) or not field.strip():
5440 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
5441 elif field != 'P':
5442 errors.append(
5443 f" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
5444 f"(got '{field}')."
5445 )
5446 reference_point = entry.get('reference_point', [1, 1, 1])
5447 if not isinstance(reference_point, (list, tuple)) or len(reference_point) != 3:
5448 errors.append(
5449 f" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
5450 )
5451 else:
5452 for rp_idx, coord in enumerate(reference_point):
5453 try:
5454 int(coord)
5455 except (TypeError, ValueError):
5456 errors.append(
5457 f" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
5458 "must be integer-compatible."
5459 )
5460 continue
5461 errors.append(
5462 f" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]."
5463 )
5464
5465 # --- Check lagrangian_pipeline entries have 'task' key ---
5466 lagrangian_pipeline = post_cfg.get('lagrangian_pipeline', [])
5467 if lagrangian_pipeline is not None and not isinstance(lagrangian_pipeline, list):
5468 errors.append(f" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
5469 lagrangian_pipeline = []
5470 for i, entry in enumerate(lagrangian_pipeline):
5471 if not isinstance(entry, dict) or 'task' not in entry:
5472 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
5473 continue
5474 task_name = entry.get('task')
5475 if task_name == 'specific_ke':
5476 in_field = entry.get('input_field')
5477 out_field = entry.get('output_field')
5478 if not isinstance(in_field, str) or not in_field.strip():
5479 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
5480 if not isinstance(out_field, str) or not out_field.strip():
5481 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
5482 continue
5483 errors.append(
5484 f" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
5485 )
5486
5487 # --- Check statistics pipeline entries ---
5488 stats_cfg = post_cfg.get('statistics_pipeline')
5489 stats_entries = []
5490 if stats_cfg is not None:
5491 if isinstance(stats_cfg, list):
5492 stats_entries = stats_cfg
5493 elif isinstance(stats_cfg, dict):
5494 stats_entries = stats_cfg.get('tasks', [])
5495 if not isinstance(stats_entries, list):
5496 errors.append(f" {post_path}: 'statistics_pipeline.tasks' must be a list.")
5497 stats_output_prefix = stats_cfg.get('output_prefix')
5498 if stats_output_prefix is not None and not isinstance(stats_output_prefix, str):
5499 errors.append(f" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
5500 else:
5501 errors.append(
5502 f" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
5503 )
5504 for i, entry in enumerate(stats_entries):
5505 if isinstance(entry, str):
5506 task_name = entry
5507 elif isinstance(entry, dict) and 'task' in entry:
5508 task_name = entry.get('task')
5509 else:
5510 errors.append(
5511 f" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
5512 )
5513 continue
5514 try:
5515 normalize_statistics_task(task_name)
5516 except ValueError as e:
5517 errors.append(f" {post_path}: {e}")
5518
5519 legacy_stats_output_prefix = post_cfg.get('statistics_output_prefix')
5520 if legacy_stats_output_prefix is not None and not isinstance(legacy_stats_output_prefix, str):
5521 errors.append(f" {post_path}: 'statistics_output_prefix' must be a string when provided.")
5522
5523 if errors:
5524 _print_validation_errors(errors)
5525
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 5526 of file core.py.

5526def validate_cluster_config(cluster_cfg: dict, cluster_path: str):
5527 """!
5528 @brief Validate Slurm scheduler configuration from cluster.yml.
5529 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
5530 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
5531 """
5532 errors = []
5533 warnings = []
5534 _validate_yaml_schema_keys(cluster_cfg, _CLUSTER_SCHEMA, cluster_path, errors)
5535 if not isinstance(cluster_cfg, dict) or not cluster_cfg:
5536 errors.append(f" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
5537 _print_validation_errors(errors)
5538
5539 scheduler = cluster_cfg.get("scheduler", {})
5540 if not isinstance(scheduler, dict):
5541 errors.append(f" {cluster_path}: 'scheduler' must be a mapping.")
5542 else:
5543 scheduler_type = scheduler.get("type", "slurm")
5544 if str(scheduler_type).lower() != "slurm":
5545 errors.append(f" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
5546
5547 resources = cluster_cfg.get("resources", {})
5548 if not isinstance(resources, dict):
5549 errors.append(f" {cluster_path}: 'resources' must be a mapping.")
5550 else:
5551 for req in ("account", "nodes", "ntasks_per_node", "mem", "time"):
5552 if req not in resources:
5553 errors.append(f" {cluster_path}: missing required key 'resources.{req}'.")
5554 for int_key in ("nodes", "ntasks_per_node"):
5555 if int_key in resources:
5556 val = resources.get(int_key)
5557 if not isinstance(val, int) or val <= 0:
5558 errors.append(f" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
5559 for str_key in ("account", "mem", "time", "partition"):
5560 if str_key in resources and resources.get(str_key) is not None:
5561 if not isinstance(resources.get(str_key), str):
5562 errors.append(f" {cluster_path}: resources.{str_key} must be a string when provided.")
5563 if isinstance(resources.get("time"), str):
5564 try:
5565 parse_slurm_time_limit_to_seconds(resources["time"])
5566 except ValueError as exc:
5567 errors.append(
5568 f" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
5569 )
5570 account = resources.get("account")
5571 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
5572 warnings.append(
5573 f"{cluster_path}: resources.account still uses the sample placeholder "
5574 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
5575 )
5576
5577 notifications = cluster_cfg.get("notifications", {})
5578 if notifications is not None and not isinstance(notifications, dict):
5579 errors.append(f" {cluster_path}: 'notifications' must be a mapping when provided.")
5580 elif isinstance(notifications, dict):
5581 mail_user = notifications.get("mail_user")
5582 if mail_user is not None and not is_valid_email(mail_user):
5583 errors.append(f" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
5584 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
5585 warnings.append(
5586 f"{cluster_path}: notifications.mail_user still uses the sample placeholder "
5587 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
5588 )
5589 mail_type = notifications.get("mail_type")
5590 if mail_type is not None and not isinstance(mail_type, str):
5591 errors.append(f" {cluster_path}: notifications.mail_type must be a string when provided.")
5592
5593 execution = cluster_cfg.get("execution", {})
5594 if execution is not None and not isinstance(execution, dict):
5595 errors.append(f" {cluster_path}: 'execution' must be a mapping when provided.")
5596 elif isinstance(execution, dict):
5597 module_setup = execution.get("module_setup", [])
5598 if module_setup is not None and not isinstance(module_setup, list):
5599 errors.append(f" {cluster_path}: execution.module_setup must be a list of shell lines.")
5600 elif isinstance(module_setup, list):
5601 for i, line in enumerate(module_setup):
5602 if not isinstance(line, str):
5603 errors.append(f" {cluster_path}: execution.module_setup[{i}] must be a string.")
5604
5605 launcher = execution.get("launcher")
5606 if launcher is not None and not isinstance(launcher, str):
5607 errors.append(f" {cluster_path}: execution.launcher must be a string when provided.")
5608 launcher_args = execution.get("launcher_args")
5609 if launcher_args is not None and not isinstance(launcher_args, list):
5610 errors.append(f" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
5611 elif isinstance(launcher_args, list):
5612 for i, token in enumerate(launcher_args):
5613 if not isinstance(token, (str, int, float)):
5614 errors.append(f" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
5615 elif _launcher_arg_contains_whitespace(token):
5616 errors.append(
5617 f" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
5618 "split whitespace-separated arguments into separate list items."
5619 )
5620 if (launcher is None or isinstance(launcher, str)) and (launcher_args is None or isinstance(launcher_args, list)):
5621 try:
5622 normalize_cluster_launcher(execution)
5623 except ValueError as exc:
5624 errors.append(f" {cluster_path}: {exc}.")
5625
5626 extra_sbatch = execution.get("extra_sbatch")
5627 if extra_sbatch is not None and not isinstance(extra_sbatch, (dict, list)):
5628 errors.append(f" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
5629
5630 walltime_guard = execution.get("walltime_guard")
5631 if walltime_guard is not None and not isinstance(walltime_guard, dict):
5632 errors.append(f" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
5633 elif isinstance(walltime_guard, dict):
5634 enabled = walltime_guard.get("enabled")
5635 if enabled is not None and not isinstance(enabled, bool):
5636 errors.append(f" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
5637
5638 warmup_steps = walltime_guard.get("warmup_steps")
5639 if warmup_steps is not None and (not isinstance(warmup_steps, int) or isinstance(warmup_steps, bool) or warmup_steps <= 0):
5640 errors.append(
5641 f" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
5642 )
5643
5644 multiplier = walltime_guard.get("multiplier")
5645 if multiplier is not None:
5646 if isinstance(multiplier, bool) or not isinstance(multiplier, (int, float)) or multiplier <= 0.0:
5647 errors.append(
5648 f" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
5649 )
5650 elif float(multiplier) > 5.0:
5651 errors.append(
5652 f" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
5653 )
5654
5655 min_seconds = walltime_guard.get("min_seconds")
5656 if min_seconds is not None and (
5657 isinstance(min_seconds, bool) or not isinstance(min_seconds, (int, float)) or float(min_seconds) <= 0.0
5658 ):
5659 errors.append(
5660 f" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
5661 )
5662
5663 estimator_alpha = walltime_guard.get("estimator_alpha")
5664 if estimator_alpha is not None:
5665 if isinstance(estimator_alpha, bool) or not isinstance(estimator_alpha, (int, float)):
5666 errors.append(
5667 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
5668 )
5669 elif float(estimator_alpha) <= 0.0 or float(estimator_alpha) > 1.0:
5670 errors.append(
5671 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
5672 )
5673
5674 if warnings:
5675 for warning in warnings:
5676 print(f"[WARN] {warning}", file=sys.stderr)
5677
5678 if errors:
5679 _print_validation_errors(errors)
5680
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 5681 of file core.py.

5681def validate_study_config(study_cfg: dict, study_path: str, skip_base_file_check: bool = False):
5682 """!
5683 @brief Validate sweep/study specification from study.yml.
5684 @param[in] study_cfg Argument passed to `validate_study_config()`.
5685 @param[in] study_path Argument passed to `validate_study_config()`.
5686 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
5687 """
5688 errors = []
5689 _validate_yaml_schema_keys(study_cfg, _STUDY_SCHEMA, study_path, errors)
5690 if not isinstance(study_cfg, dict) or not study_cfg:
5691 errors.append(f" {study_path}: study config is empty or not a valid YAML mapping.")
5692 _print_validation_errors(errors)
5693
5694 base_cfgs = study_cfg.get("base_configs")
5695 if not isinstance(base_cfgs, dict):
5696 errors.append(f" {study_path}: missing required mapping 'base_configs'.")
5697 else:
5698 for req in ("case", "solver", "monitor", "post"):
5699 path_val = base_cfgs.get(req)
5700 if not path_val or not isinstance(path_val, str):
5701 errors.append(f" {study_path}: base_configs.{req} must be a path string.")
5702 elif not skip_base_file_check:
5703 resolved = resolve_path(study_path, path_val)
5704 if not os.path.isfile(resolved):
5705 errors.append(f" {study_path}: base_configs.{req} does not exist: {resolved}")
5706
5707 study_type = study_cfg.get("study_type")
5708 allowed_types = {"grid_independence", "timestep_independence", "sensitivity"}
5709 if study_type not in allowed_types:
5710 errors.append(
5711 f" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
5712 )
5713
5714 parameters = study_cfg.get("parameters")
5715 parameter_sets = study_cfg.get("parameter_sets")
5716 allowed_roots = {"case", "solver", "monitor", "post"}
5717 if bool(parameters) == bool(parameter_sets):
5718 errors.append(f" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
5719 elif parameter_sets:
5720 if not isinstance(parameter_sets, list) or not parameter_sets:
5721 errors.append(f" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
5722 else:
5723 for set_index, param_set in enumerate(parameter_sets):
5724 if not isinstance(param_set, dict) or not param_set:
5725 errors.append(
5726 f" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
5727 )
5728 continue
5729 for key, value in param_set.items():
5730 if not isinstance(key, str) or "." not in key:
5731 errors.append(
5732 f" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
5733 )
5734 continue
5735 root = key.split(".", 1)[0]
5736 if root not in allowed_roots:
5737 errors.append(
5738 f" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
5739 )
5740 if isinstance(value, (dict, list)):
5741 errors.append(
5742 f" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
5743 )
5744 else:
5745 if not isinstance(parameters, dict) or not parameters:
5746 errors.append(f" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
5747 else:
5748 for key, values in parameters.items():
5749 if not isinstance(key, str) or "." not in key:
5750 errors.append(
5751 f" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
5752 )
5753 continue
5754 root = key.split(".", 1)[0]
5755 if root not in allowed_roots:
5756 errors.append(
5757 f" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
5758 )
5759 if not isinstance(values, list) or len(values) == 0:
5760 errors.append(f" {study_path}: parameters.{key} must be a non-empty list.")
5761
5762 metrics = study_cfg.get("metrics", [])
5763 if metrics is not None and not isinstance(metrics, list):
5764 errors.append(f" {study_path}: 'metrics' must be a list when provided.")
5765 elif isinstance(metrics, list):
5766 for i, metric in enumerate(metrics):
5767 if isinstance(metric, str):
5768 continue
5769 if not isinstance(metric, dict):
5770 errors.append(
5771 f" {study_path}: metrics[{i}] must be a string or mapping."
5772 )
5773 continue
5774 if "name" not in metric:
5775 errors.append(f" {study_path}: metrics[{i}] missing required key 'name'.")
5776 if "source" not in metric:
5777 errors.append(f" {study_path}: metrics[{i}] missing required key 'source'.")
5778
5779 plotting = study_cfg.get("plotting", {})
5780 if plotting is not None and not isinstance(plotting, dict):
5781 errors.append(f" {study_path}: 'plotting' must be a mapping when provided.")
5782 elif isinstance(plotting, dict):
5783 enabled = plotting.get("enabled")
5784 if enabled is not None and not isinstance(enabled, bool):
5785 errors.append(f" {study_path}: plotting.enabled must be boolean when provided.")
5786 output_format = plotting.get("output_format")
5787 if output_format is not None and output_format not in {"png", "pdf", "svg"}:
5788 errors.append(f" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
5789
5790 execution = study_cfg.get("execution", {})
5791 if execution is not None and not isinstance(execution, dict):
5792 errors.append(f" {study_path}: 'execution' must be a mapping when provided.")
5793 elif isinstance(execution, dict):
5794 max_conc = execution.get("max_concurrent_array_tasks")
5795 if max_conc is not None and (not isinstance(max_conc, int) or max_conc <= 0):
5796 errors.append(
5797 f" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
5798 )
5799
5800 if errors:
5801 _print_validation_errors(errors)
5802
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 5803 of file core.py.

5803def _deep_set(container: dict, dotted_path: str, value):
5804 """!
5805 @brief Set nested dictionary value, creating intermediate maps when needed.
5806 @param[in] container Argument passed to `_deep_set()`.
5807 @param[in] dotted_path Argument passed to `_deep_set()`.
5808 @param[in] value Argument passed to `_deep_set()`.
5809 """
5810 keys = dotted_path.split(".")
5811 current = container
5812 for key in keys[:-1]:
5813 if key not in current or not isinstance(current[key], dict):
5814 current[key] = {}
5815 current = current[key]
5816 current[keys[-1]] = value
5817
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 5818 of file core.py.

5818def expand_parameter_matrix(parameters: dict) -> list:
5819 """!
5820 @brief Expand study parameter lists into cartesian-product combinations.
5821 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
5822 @return Value returned by `expand_parameter_matrix()`.
5823 """
5824 param_keys = list(parameters.keys())
5825 all_values = [parameters[k] for k in param_keys]
5826 combos = []
5827 for combo in itertools.product(*all_values):
5828 combos.append(dict(zip(param_keys, combo)))
5829 return combos
5830
5831
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 5832 of file core.py.

5832def expand_study_parameter_combinations(study_cfg: dict) -> list:
5833 """!
5834 @brief Expand either cartesian-study parameters or explicit parameter sets.
5835 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
5836 @return Value returned by `expand_study_parameter_combinations()`.
5837 """
5838 parameter_sets = study_cfg.get("parameter_sets")
5839 if parameter_sets:
5840 return [dict(param_set) for param_set in parameter_sets]
5841 return expand_parameter_matrix(study_cfg.get("parameters") or {})
5842
5843
Here is the call graph for this function:
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 5844 of file core.py.

5844def get_study_parameter_keys(study_cfg: dict) -> list:
5845 """!
5846 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
5847 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
5848 @return Value returned by `get_study_parameter_keys()`.
5849 """
5850 parameters = study_cfg.get("parameters")
5851 if isinstance(parameters, dict) and parameters:
5852 return list(parameters.keys())
5853
5854 keys = []
5855 parameter_sets = study_cfg.get("parameter_sets") or []
5856 for param_set in parameter_sets:
5857 if not isinstance(param_set, dict):
5858 continue
5859 for key in param_set.keys():
5860 if key not in keys:
5861 keys.append(key)
5862 return keys
5863
5864
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 5865 of file core.py.

5865def get_cluster_total_tasks(cluster_cfg: dict) -> int:
5866 """!
5867 @brief Return cluster total tasks.
5868 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
5869 @return Value returned by `get_cluster_total_tasks()`.
5870 """
5871 resources = cluster_cfg.get("resources", {})
5872 return int(resources.get("nodes", 1)) * int(resources.get("ntasks_per_node", 1))
5873
Here is the caller graph for this function:

◆ normalize_extension()

str picurv_cli.core.normalize_extension ( str  ext)

Normalize extension.

Parameters
[in]extArgument passed to normalize_extension().
Returns
Value returned by normalize_extension().

Definition at line 5874 of file core.py.

5874def normalize_extension(ext: str) -> str:
5875 """!
5876 @brief Normalize extension.
5877 @param[in] ext Argument passed to `normalize_extension()`.
5878 @return Value returned by `normalize_extension()`.
5879 """
5880 if ext is None:
5881 return None
5882 return str(ext).strip().lstrip(".")
5883

◆ 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 
)

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().

Definition at line 5884 of file core.py.

5895):
5896 """!
5897 @brief Render a Slurm batch script for a single command.
5898 @param[in] script_path Argument passed to `render_slurm_script()`.
5899 @param[in] job_name Argument passed to `render_slurm_script()`.
5900 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
5901 @param[in] command Argument passed to `render_slurm_script()`.
5902 @param[in] workdir Argument passed to `render_slurm_script()`.
5903 @param[in] stdout_path Argument passed to `render_slurm_script()`.
5904 @param[in] stderr_path Argument passed to `render_slurm_script()`.
5905 @param[in] env_vars Argument passed to `render_slurm_script()`.
5906 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
5907 @param[in] array_spec Argument passed to `render_slurm_script()`.
5908 """
5909 resources = cluster_cfg.get("resources", {})
5910 notifications = cluster_cfg.get("notifications", {}) or {}
5911 execution = cluster_cfg.get("execution", {}) or {}
5912 extra_sbatch = execution.get("extra_sbatch")
5913 module_setup = execution.get("module_setup", []) or []
5914
5915 if stderr_path is None:
5916 stderr_path = stdout_path.replace(".out", ".err")
5917
5918 lines = [
5919 "#!/bin/bash",
5920 f"#SBATCH --job-name={job_name}",
5921 f"#SBATCH --nodes={resources['nodes']}",
5922 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
5923 f"#SBATCH --mem={resources['mem']}",
5924 f"#SBATCH --time={resources['time']}",
5925 f"#SBATCH --output={stdout_path}",
5926 f"#SBATCH --error={stderr_path}",
5927 f"#SBATCH --account={resources['account']}",
5928 ]
5929 partition = resources.get("partition")
5930 if partition:
5931 lines.append(f"#SBATCH --partition={partition}")
5932 if array_spec:
5933 lines.append(f"#SBATCH --array={array_spec}")
5934 mail_user = notifications.get("mail_user")
5935 mail_type = notifications.get("mail_type")
5936 if mail_user:
5937 lines.append(f"#SBATCH --mail-user={mail_user}")
5938 if mail_type:
5939 lines.append(f"#SBATCH --mail-type={mail_type}")
5940
5941 if isinstance(extra_sbatch, dict):
5942 for key, value in extra_sbatch.items():
5943 flag = str(key)
5944 if not flag.startswith("--"):
5945 flag = f"--{flag}"
5946 if isinstance(value, bool):
5947 if value:
5948 lines.append(f"#SBATCH {flag}")
5949 elif value is not None:
5950 lines.append(f"#SBATCH {flag}={value}")
5951 elif isinstance(extra_sbatch, list):
5952 for token in extra_sbatch:
5953 lines.append(f"#SBATCH {token}")
5954
5955 lines.extend(
5956 [
5957 "",
5958 "set -euo pipefail",
5959 "",
5960 f"cd {shlex.quote(workdir)}",
5961 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
5962 'echo "[$(date)] Working directory: $PWD"',
5963 ]
5964 )
5965
5966 if shell_env_vars:
5967 for key, value in shell_env_vars.items():
5968 lines.append(f"export {key}={value}")
5969
5970 for setup_line in module_setup:
5971 lines.append(str(setup_line))
5972
5973 if env_vars:
5974 for key, value in env_vars.items():
5975 lines.append(f"export {key}={shlex.quote(str(value))}")
5976
5977 cmd = " ".join(shlex.quote(str(tok)) for tok in command)
5978 lines.append(f"exec {cmd}")
5979
5980 os.makedirs(os.path.dirname(script_path), exist_ok=True)
5981 with open(script_path, "w") as f:
5982 f.write("\n".join(lines) + "\n")
5983 os.chmod(script_path, 0o755)
5984
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 5985 of file core.py.

5989) -> "tuple[str | None, list[str]]":
5990 """!
5991 @brief Canonicalize launcher config into executable token plus argv-style flags.
5992 @param[in] launcher Argument passed to `split_launcher_tokens()`.
5993 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
5994 @param[in] label Argument passed to `split_launcher_tokens()`.
5995 @return Value returned by `split_launcher_tokens()`.
5996 """
5997 normalized_args = [str(x) for x in (launcher_args or [])]
5998
5999 if launcher is None:
6000 return None, normalized_args
6001
6002 try:
6003 launcher_tokens = shlex.split(str(launcher))
6004 except ValueError as exc:
6005 raise ValueError(f"{label} is not shell-parseable: {exc}") from exc
6006
6007 if not launcher_tokens:
6008 return None, normalized_args
6009
6010 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
6011
6012
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 6013 of file core.py.

6013def normalize_cluster_launcher(execution: dict) -> "tuple[str | None, list[str]]":
6014 """!
6015 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
6016 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
6017 @return Value returned by `normalize_cluster_launcher()`.
6018 """
6019 return split_launcher_tokens(
6020 execution.get("launcher"),
6021 execution.get("launcher_args") or [],
6022 label="execution.launcher",
6023 )
6024
6025
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 6026 of file core.py.

6026def strip_launcher_size_flags(launcher_name: str, launcher_args: "list[str]") -> "list[str]":
6027 """!
6028 @brief Remove explicit MPI task-count flags from known launchers.
6029 @param[in] launcher_name Basename-normalized launcher executable.
6030 @param[in] launcher_args Launcher argument list.
6031 @return Filtered launcher arguments with explicit size flags removed.
6032 """
6033 filtered = []
6034 idx = 0
6035 while idx < len(launcher_args):
6036 token = str(launcher_args[idx])
6037
6038 if launcher_name == "srun":
6039 if token in {"-n", "--ntasks"}:
6040 idx += 2
6041 continue
6042 if token.startswith("--ntasks="):
6043 idx += 1
6044 continue
6045 elif launcher_name in {"mpiexec", "mpirun"}:
6046 if token in {"-n", "-np"}:
6047 idx += 2
6048 continue
6049 if token.startswith("-n=") or token.startswith("-np="):
6050 idx += 1
6051 continue
6052
6053 filtered.append(token)
6054 idx += 1
6055
6056 return filtered
6057
6058
Here is the caller graph for this function:

◆ build_serial_post_cluster_config()

dict picurv_cli.core.build_serial_post_cluster_config ( dict  cluster_cfg,
int   num_procs = 1 
)

Clone cluster config and force a single-node post stage task layout.

Parameters
[in]cluster_cfgBase cluster configuration.
[in]num_procsNumber of post tasks to request.
Returns
Cluster configuration specialized for the post stage.

Definition at line 6059 of file core.py.

6059def build_serial_post_cluster_config(cluster_cfg: dict, num_procs: int = 1) -> dict:
6060 """!
6061 @brief Clone cluster config and force a single-node post stage task layout.
6062 @param[in] cluster_cfg Base cluster configuration.
6063 @param[in] num_procs Number of post tasks to request.
6064 @return Cluster configuration specialized for the post stage.
6065 """
6066 post_cluster_cfg = copy.deepcopy(cluster_cfg)
6067 post_cluster_cfg.setdefault("resources", {})
6068 post_cluster_cfg["resources"]["nodes"] = 1
6069 post_cluster_cfg["resources"]["ntasks_per_node"] = int(num_procs)
6070 return post_cluster_cfg
6071
6072
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 6073 of file core.py.

6080) -> list:
6081 """!
6082 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
6083 @param[in] executable Argument passed to `build_local_launch_command()`.
6084 @param[in] executable_args Argument passed to `build_local_launch_command()`.
6085 @param[in] num_procs Argument passed to `build_local_launch_command()`.
6086 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
6087 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
6088 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
6089 @return Value returned by `build_local_launch_command()`.
6090 """
6091 target_num_procs = force_num_procs if force_num_procs is not None else num_procs
6092 command = [executable] + executable_args
6093 if target_num_procs <= 1 and not allow_single_rank_launcher_override:
6094 return command
6095
6096 launcher_override = os.environ.get("PICURV_MPI_LAUNCHER")
6097 if launcher_override is None:
6098 launcher_override = os.environ.get("MPI_LAUNCHER")
6099
6100 try:
6101 if launcher_override is not None:
6102 explicit_launcher_config = True
6103 launcher, launcher_args = split_launcher_tokens(
6104 launcher_override,
6105 label="local MPI launcher override",
6106 )
6107 else:
6108 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor)
6109 local_execution = resolve_runtime_execution_context(runtime_execution_cfg, "local")
6110 configured_launcher = local_execution.get("launcher")
6111 configured_args = local_execution.get("launcher_args") or []
6112 explicit_launcher_config = configured_launcher is not None or bool(configured_args)
6113 if target_num_procs <= 1 and not explicit_launcher_config:
6114 return command
6115 launcher, launcher_args = split_launcher_tokens(
6116 configured_launcher if configured_launcher is not None else "mpiexec",
6117 configured_args,
6118 label="local_execution.launcher",
6119 )
6120 except ValueError as exc:
6121 print(f"[FATAL] {exc}", file=sys.stderr)
6122 sys.exit(1)
6123
6124 if not launcher:
6125 return command
6126
6127 launcher_name = os.path.basename(launcher).lower()
6128 if force_num_procs is not None:
6129 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
6130 prefix = [launcher] + launcher_args
6131
6132 if launcher_name == "srun":
6133 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
6134 if not has_n:
6135 prefix += ["-n", str(target_num_procs)]
6136 elif launcher_name in {"mpiexec", "mpirun"}:
6137 has_n = any(token in {"-n", "-np"} for token in launcher_args)
6138 if not has_n:
6139 prefix += ["-n", str(target_num_procs)]
6140
6141 return prefix + command
6142
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 6143 of file core.py.

6143def resolve_cluster_execution(cluster_cfg: dict, config_search_anchor: str = None, extra_search_anchors=None) -> dict:
6144 """!
6145 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
6146 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
6147 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
6148 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
6149 @return Value returned by `resolve_cluster_execution()`.
6150 """
6151 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor, extra_search_anchors=extra_search_anchors)
6152 shared_cluster_execution = resolve_runtime_execution_context(runtime_execution_cfg, "cluster")
6153 execution = cluster_cfg.get("execution", {}) or {}
6154 cluster_override = {
6155 "launcher": execution.get("launcher") if "launcher" in execution else None,
6156 "launcher_args": execution.get("launcher_args") if "launcher_args" in execution else None,
6157 }
6158 return merge_execution_overrides(shared_cluster_execution, cluster_override)
6159
6160
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 6161 of file core.py.

6168) -> list:
6169 """!
6170 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
6171 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
6172 @param[in] executable Argument passed to `build_cluster_launch_command()`.
6173 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
6174 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
6175 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
6176 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
6177 @return Value returned by `build_cluster_launch_command()`.
6178 """
6179 try:
6180 execution = resolve_cluster_execution(
6181 cluster_cfg,
6182 config_search_anchor=config_search_anchor,
6183 extra_search_anchors=extra_search_anchors,
6184 )
6185 launcher, launcher_args = split_launcher_tokens(
6186 execution.get("launcher") if execution.get("launcher") is not None else "srun",
6187 execution.get("launcher_args") or [],
6188 label="cluster execution launcher",
6189 )
6190 except ValueError as exc:
6191 print(f"[FATAL] {exc}", file=sys.stderr)
6192 sys.exit(1)
6193
6194 ntasks = int(force_num_procs) if force_num_procs is not None else get_cluster_total_tasks(cluster_cfg)
6195 launcher_name = launcher.lower() if launcher else ""
6196 if force_num_procs is not None:
6197 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
6198
6199 if launcher and launcher_name == "srun":
6200 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
6201 cmd = ["srun"] + launcher_args
6202 if not has_n:
6203 cmd += ["-n", str(ntasks)]
6204 return cmd + [executable] + executable_args
6205
6206 if launcher and launcher_name == "mpirun":
6207 has_np = any(token in {"-np", "-n"} for token in launcher_args)
6208 cmd = ["mpirun"] + launcher_args
6209 if not has_np:
6210 cmd += ["-np", str(ntasks)]
6211 return cmd + [executable] + executable_args
6212
6213 if launcher and launcher_name == "mpiexec":
6214 has_np = any(token in {"-np", "-n"} for token in launcher_args)
6215 cmd = ["mpiexec"] + launcher_args
6216 if not has_np:
6217 cmd += ["-np", str(ntasks)]
6218 return cmd + [executable] + executable_args
6219
6220 # Custom launcher or no launcher.
6221 cmd = []
6222 if launcher:
6223 cmd.append(str(launcher))
6224 cmd += launcher_args
6225 cmd += [executable] + executable_args
6226 return cmd
6227
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 6228 of file core.py.

6228def parse_slurm_job_id(sbatch_output: str) -> str:
6229 """!
6230 @brief Extract numeric job id from standard sbatch output.
6231 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
6232 @return Value returned by `parse_slurm_job_id()`.
6233 """
6234 match = re.search(r"Submitted batch job\s+(\d+)", sbatch_output or "")
6235 return match.group(1) if match else None
6236
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 6237 of file core.py.

6237def submit_sbatch(script_path: str, dependency: str = None, dependency_type: str = "afterok") -> dict:
6238 """!
6239 @brief Submit sbatch script and return submission metadata.
6240 @param[in] script_path Argument passed to `submit_sbatch()`.
6241 @param[in] dependency Argument passed to `submit_sbatch()`.
6242 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
6243 @return Value returned by `submit_sbatch()`.
6244 """
6245 cmd = ["sbatch"]
6246 if dependency:
6247 cmd.append(f"--dependency={dependency_type}:{dependency}")
6248 cmd.append(script_path)
6249 result = subprocess.run(cmd, text=True, capture_output=True, check=False)
6250 metadata = {
6251 "command": cmd,
6252 "returncode": result.returncode,
6253 "stdout": (result.stdout or "").strip(),
6254 "stderr": (result.stderr or "").strip(),
6255 "script": script_path,
6256 }
6257 if result.returncode != 0:
6258 print(f"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
6259 sys.exit(result.returncode)
6260 metadata["job_id"] = parse_slurm_job_id(metadata["stdout"])
6261 if not metadata["job_id"]:
6262 print(
6263 f"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
6264 file=sys.stderr
6265 )
6266 sys.exit(1)
6267 return metadata
6268
6269
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 6270 of file core.py.

6270def _print_validation_errors(errors: list):
6271 """!
6272 @brief Prints validation errors and exits.
6273 @param[in] errors List of error message strings.
6274 """
6275 print(f"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
6276 for raw_error in errors:
6277 file_path, message = _split_error_file_and_message(raw_error)
6278 key_path = _extract_key_path(message)
6279 code = _classify_error_code(message)
6280 emit_structured_error(code, key=key_path, file_path=file_path, message=message)
6281 print(
6282 "\nHint: See examples/master_template/ for valid config structure and "
6283 "docs/pages/14_Config_Contract.md for key-level contract details.",
6284 file=sys.stderr,
6285 )
6286 sys.exit(1)
6287
6288
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 6289 of file core.py.

6289def generate_header(run_id: str, source_files: dict) -> str:
6290 """!
6291 @brief Creates a standard header block for all generated files.
6292 @param[in] run_id The unique identifier for the current simulation run.
6293 @param[in] source_files A dictionary of source profile files used.
6294 @return A formatted string containing the header.
6295 """
6296 header_parts = [
6297 "# ==============================================================================",
6298 "# AUTO-GENERATED CONFIGURATION FILE",
6299 "# ------------------------------------------------------------------------------",
6300 f"# Run ID: {run_id}",
6301 f"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
6302 "#",
6303 "# Source Configuration:"
6304 ]
6305 for name, path in source_files.items():
6306 header_parts.append(f"# - {name:<12}: {os.path.basename(path)}")
6307 header_parts.extend([
6308 "#",
6309 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
6310 "# ==============================================================================\n"
6311 ])
6312 return "\n".join(header_parts)
6313
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 
)

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.
Returns
The absolute path to the generated file.

Definition at line 6314 of file core.py.

6314def generate_simple_list_file(run_dir: str, run_id: str, cfg: dict, section: str, key: str, filename: str, header_sources: dict) -> str:
6315 """!
6316 @brief Generic function to create a file containing a simple list of strings.
6317 @param[in] run_dir The path to the main run directory.
6318 @param[in] run_id The unique identifier for the run.
6319 @param[in] cfg The dictionary containing the configuration data.
6320 @param[in] section The top-level key in the cfg dictionary.
6321 @param[in] key The second-level key whose value is the list of strings.
6322 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
6323 @param[in] header_sources A dictionary of source files for the header.
6324 @return The absolute path to the generated file.
6325 """
6326 print(f"[INFO] Generating {filename}...")
6327 config_dir = os.path.join(run_dir, "config")
6328 file_path = os.path.join(config_dir, filename)
6329
6330 lines = [generate_header(run_id, header_sources)]
6331 items = cfg.get(section, {}).get(key, [])
6332 lines.extend(items)
6333
6334 with open(file_path, "w") as f: f.write("\n".join(lines))
6335 print(f"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
6336 return os.path.abspath(file_path)
6337
6338
Here is the call graph for this function:
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 6339 of file core.py.

6339def has_explicit_monitor_whitelist(monitor_cfg: dict) -> bool:
6340 """!
6341 @brief Return True when logging.enabled_functions contains at least one entry.
6342 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
6343 @return Value returned by `has_explicit_monitor_whitelist()`.
6344 """
6345 items = monitor_cfg.get("logging", {}).get("enabled_functions", [])
6346 return bool(items)
6347
6348
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 6349 of file core.py.

6349def resolve_profiling_config(monitor_cfg: dict) -> dict:
6350 """!
6351 @brief Resolve profiling reporting config from monitor.yml.
6352 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
6353 @return Value returned by `resolve_profiling_config()`.
6354 """
6355 profiling_cfg = monitor_cfg.get("profiling", {}) or {}
6356 timestep_cfg = profiling_cfg.get("timestep_output")
6357 final_cfg = profiling_cfg.get("final_summary")
6358
6359 if timestep_cfg is None:
6360 mode = "off"
6361 functions = []
6362 timestep_file = "Profiling_Timestep_Summary.csv"
6363 else:
6364 if not isinstance(timestep_cfg, dict):
6365 raise ValueError("monitor.profiling.timestep_output must be a mapping when provided.")
6366 mode = str(timestep_cfg.get("mode", "off")).lower()
6367 functions = timestep_cfg.get("functions", [])
6368 timestep_file = str(timestep_cfg.get("file", "Profiling_Timestep_Summary.csv"))
6369
6370 if mode not in {"off", "selected", "all"}:
6371 raise ValueError("monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
6372 if functions is None:
6373 functions = []
6374 if not isinstance(functions, list):
6375 raise ValueError("monitor.profiling.timestep_output.functions must be a list of function names.")
6376 if not all(isinstance(item, str) and item.strip() for item in functions):
6377 raise ValueError("monitor.profiling.timestep_output.functions entries must be non-empty strings.")
6378 if mode == "selected" and not functions:
6379 raise ValueError("monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
6380 if mode != "selected" and functions:
6381 raise ValueError("monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
6382 if not timestep_file:
6383 raise ValueError("monitor.profiling.timestep_output.file must be a non-empty string.")
6384
6385 if final_cfg is None:
6386 final_enabled = True
6387 elif isinstance(final_cfg, dict):
6388 final_enabled = bool(final_cfg.get("enabled", True))
6389 else:
6390 raise ValueError("monitor.profiling.final_summary must be a mapping when provided.")
6391
6392 return {
6393 "mode": mode,
6394 "functions": functions,
6395 "timestep_file": timestep_file,
6396 "final_summary_enabled": final_enabled,
6397 }
6398
6399
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 6416 of file core.py.

6416def _diagnostic_bool_or_path(value, key: str):
6417 """!
6418 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
6419 @param[in] value Candidate value.
6420 @param[in] key Diagnostics key used in error messages.
6421 @return Normalized value.
6422 """
6423 if isinstance(value, bool) or value is None:
6424 return value
6425 if isinstance(value, str) and value.strip():
6426 return value.strip()
6427 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
6428
6429
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 6430 of file core.py.

6430def _diagnostic_bool(value, key: str) -> bool:
6431 """!
6432 @brief Validate a diagnostics boolean value.
6433 @param[in] value Candidate value.
6434 @param[in] key Diagnostics key used in error messages.
6435 @return Boolean value.
6436 """
6437 if isinstance(value, bool):
6438 return value
6439 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean.")
6440
6441
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 6442 of file core.py.

6442def _diagnostic_bool_or_all(value, key: str):
6443 """!
6444 @brief Validate a diagnostics value that can be false, true, or "all".
6445 @param[in] value Candidate value.
6446 @param[in] key Diagnostics key used in error messages.
6447 @return Normalized value.
6448 """
6449 if isinstance(value, bool) or value is None:
6450 return value
6451 if isinstance(value, str) and value.strip().lower() == "all":
6452 return "all"
6453 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
6454
6455
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 6456 of file core.py.

6456def _diagnostic_default_file(run_dir: str, filename: str) -> str:
6457 """!
6458 @brief Return an absolute run-local diagnostics file path.
6459 @param[in] run_dir Run directory.
6460 @param[in] filename Diagnostics filename.
6461 @return Absolute diagnostics path under the run logs directory.
6462 """
6463 return os.path.abspath(os.path.join(run_dir, "logs", filename))
6464
6465
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 6466 of file core.py.

6466def _diagnostic_resolve_path_or_default(value, run_dir: str, default_filename: str):
6467 """!
6468 @brief Resolve true/string diagnostics values to a concrete file path.
6469 @param[in] value Boolean/string diagnostics value.
6470 @param[in] run_dir Run directory.
6471 @param[in] default_filename Default file name when value is true.
6472 @return False, or an absolute/explicit path string.
6473 """
6474 if value is True:
6475 return _diagnostic_default_file(run_dir, default_filename)
6476 if isinstance(value, str):
6477 if os.path.isabs(value) or value.startswith(":"):
6478 return value
6479 return os.path.abspath(os.path.join(run_dir, "logs", value))
6480 return False
6481
6482
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 6483 of file core.py.

6483def resolve_diagnostics_config(monitor_cfg: dict, run_dir: "str | None" = None, stage_label: str = "Solver") -> dict:
6484 """!
6485 @brief Resolve monitor diagnostics config and default run-local log paths.
6486 @param[in] monitor_cfg Parsed monitor.yml mapping.
6487 @param[in] run_dir Optional run directory for default artifact paths.
6488 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
6489 @return Normalized diagnostics config.
6490 """
6491 diagnostics_cfg = (monitor_cfg.get("diagnostics", {}) or {}) if isinstance(monitor_cfg, dict) else {}
6492 if not isinstance(diagnostics_cfg, dict):
6493 raise ValueError("monitor.diagnostics must be a mapping when provided.")
6494
6495 petsc_raw = diagnostics_cfg.get("petsc", {}) or {}
6496 if not isinstance(petsc_raw, dict):
6497 raise ValueError("monitor.diagnostics.petsc must be a mapping when provided.")
6498 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
6499 if unknown:
6500 raise ValueError(f"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
6501
6502 petsc = {
6503 "malloc_debug": _diagnostic_bool(petsc_raw.get("malloc_debug", False), "malloc_debug"),
6504 "malloc_test": _diagnostic_bool(petsc_raw.get("malloc_test", False), "malloc_test"),
6505 "malloc_dump": _diagnostic_bool(petsc_raw.get("malloc_dump", False), "malloc_dump"),
6506 "malloc_view": _diagnostic_bool_or_path(petsc_raw.get("malloc_view", False), "malloc_view"),
6507 "malloc_view_threshold": petsc_raw.get("malloc_view_threshold"),
6508 "memory_view": _diagnostic_bool(petsc_raw.get("memory_view", False), "memory_view"),
6509 "log_view": _diagnostic_bool_or_path(petsc_raw.get("log_view", False), "log_view"),
6510 "log_view_memory": _diagnostic_bool(petsc_raw.get("log_view_memory", False), "log_view_memory"),
6511 "log_all": _diagnostic_bool(petsc_raw.get("log_all", False), "log_all"),
6512 "log_trace": _diagnostic_bool_or_path(petsc_raw.get("log_trace", False), "log_trace"),
6513 "objects_dump": _diagnostic_bool_or_all(petsc_raw.get("objects_dump", False), "objects_dump"),
6514 "options_left": petsc_raw.get("options_left"),
6515 }
6516 if petsc["malloc_view_threshold"] is not None and not isinstance(petsc["malloc_view_threshold"], (int, float)):
6517 raise ValueError("monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
6518 if petsc["options_left"] is not None and not isinstance(petsc["options_left"], bool):
6519 raise ValueError("monitor.diagnostics.petsc.options_left must be boolean or null.")
6520
6521 memory_raw = diagnostics_cfg.get("runtime_memory_log", {}) or {}
6522 if not isinstance(memory_raw, dict):
6523 raise ValueError("monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
6524 memory_unknown = sorted(set(memory_raw.keys()) - {"enabled", "file"})
6525 if memory_unknown:
6526 raise ValueError(f"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
6527 memory_enabled = memory_raw.get("enabled", True)
6528 if not isinstance(memory_enabled, bool):
6529 raise ValueError("monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
6530 memory_file = str(memory_raw.get("file", "Runtime_Memory.log")).strip()
6531 if not memory_file:
6532 raise ValueError("monitor.diagnostics.runtime_memory_log.file must be a non-empty string.")
6533
6534 resolved_petsc = dict(petsc)
6535 artifacts = []
6536 if run_dir:
6537 suffix = "PostProcessor" if stage_label == "PostProcessor" else "Solver"
6538 defaults = {
6539 "malloc_view": f"PETSc_MallocView_{suffix}.log",
6540 "log_view": f"PETSc_LogView_{suffix}.log",
6541 "log_trace": f"PETSc_LogTrace_{suffix}.log",
6542 }
6543 for key, default_name in defaults.items():
6544 resolved_value = _diagnostic_resolve_path_or_default(petsc[key], run_dir, default_name)
6545 if key == "log_view" and resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
6546 resolved_value = f":{resolved_value}"
6547 resolved_petsc[key] = resolved_value
6548 if resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
6549 artifacts.append(resolved_value)
6550 elif resolved_value and isinstance(resolved_value, str) and resolved_value.startswith(":"):
6551 artifacts.append(resolved_value[1:])
6552 if memory_enabled:
6553 artifacts.append(os.path.abspath(os.path.join(run_dir, "logs", memory_file)))
6554
6555 return {
6556 "petsc": resolved_petsc,
6557 "runtime_memory_log": {"enabled": memory_enabled, "file": memory_file},
6558 "artifacts": artifacts,
6559 }
6560
6561
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 6562 of file core.py.

6562def build_petsc_diagnostics_args(monitor_cfg: dict, run_dir: str, stage_label: str) -> list:
6563 """!
6564 @brief Build PETSc diagnostics command-line arguments for a run stage.
6565 @param[in] monitor_cfg Parsed monitor.yml mapping.
6566 @param[in] run_dir Run directory used to resolve default diagnostics files.
6567 @param[in] stage_label Stage label for default output names.
6568 @return List of executable arguments.
6569 """
6570 diagnostics = resolve_diagnostics_config(monitor_cfg, run_dir, stage_label)
6571 petsc = diagnostics["petsc"]
6572 args = []
6573 if petsc["malloc_debug"]:
6574 args.append("-malloc_debug")
6575 if petsc["malloc_test"]:
6576 args.append("-malloc_test")
6577 for key, flag in (
6578 ("malloc_dump", "-malloc_dump"),
6579 ("malloc_view", "-malloc_view"),
6580 ("memory_view", "-memory_view"),
6581 ("log_view", "-log_view"),
6582 ("log_trace", "-log_trace"),
6583 ("objects_dump", "-objects_dump"),
6584 ):
6585 value = petsc.get(key)
6586 if value is True:
6587 args.append(flag)
6588 elif value:
6589 args.extend([flag, str(value)])
6590 if petsc["malloc_view_threshold"] is not None:
6591 args.extend(["-malloc_view_threshold", str(petsc["malloc_view_threshold"])])
6592 if petsc["log_view_memory"]:
6593 args.append("-log_view_memory")
6594 if petsc["log_all"]:
6595 args.append("-log_all")
6596 if petsc["options_left"] is not None:
6597 args.extend(["-options_left", "true" if petsc["options_left"] else "false"])
6598 return args
6599
6600
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 
)

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().
Returns
Value returned by prepare_monitor_files().

Definition at line 6601 of file core.py.

6601def prepare_monitor_files(run_dir: str, run_id: str, monitor_cfg: dict, source_files: dict) -> dict:
6602 """!
6603 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
6604 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
6605 @param[in] run_id Argument passed to `prepare_monitor_files()`.
6606 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
6607 @param[in] source_files Argument passed to `prepare_monitor_files()`.
6608 @return Value returned by `prepare_monitor_files()`.
6609 """
6610 print("[INFO] Generating monitoring files...")
6611
6612 whitelist_path = None
6613 if has_explicit_monitor_whitelist(monitor_cfg):
6614 whitelist_path = generate_simple_list_file(
6615 run_dir, run_id, monitor_cfg, "logging", "enabled_functions", "whitelist.run", source_files
6616 )
6617 else:
6618 print("[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
6619
6620 profiling_cfg = resolve_profiling_config(monitor_cfg)
6621
6622 profile_path = None
6623 if profiling_cfg["mode"] == "selected":
6624 profile_path = generate_simple_list_file(
6625 run_dir,
6626 run_id,
6627 {"profiling": {"selected_functions": profiling_cfg["functions"]}},
6628 "profiling",
6629 "selected_functions",
6630 "profile.run",
6631 source_files,
6632 )
6633 else:
6634 print(f"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
6635
6636 return {"whitelist": whitelist_path, "profile": profile_path, "profiling": profiling_cfg}
6637
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 
)

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.
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 6638 of file core.py.

6638def generate_multi_block_bcs(run_dir: str, run_id: str, case_cfg: dict, source_files: dict) -> list:
6639 """!
6640 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
6641 and returns a list of their absolute paths.
6642 @details Handles both simple list format (for single-block cases) and a
6643 list-of-lists (for multi-block cases) for boundary conditions.
6644 @param[in] run_dir The path to the main run directory.
6645 @param[in] run_id The unique identifier for the run.
6646 @param[in] case_cfg The parsed case.yml configuration dictionary.
6647 @param[in] source_files A dictionary of source files for the header.
6648 @return A list of absolute paths to the generated BC files.
6649 @throws ValueError if the number of BC definitions does not match the number of blocks.
6650 """
6651 print("[INFO] Generating boundary condition files...")
6652 config_dir = os.path.join(run_dir, "config")
6653 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
6654 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
6655 case_path = source_files.get("Case") if source_files else None
6656 profile_grid_dims = None
6657 scales = case_cfg.get('properties', {}).get('scaling', {})
6658 U_ref = _to_float(scales.get('velocity_ref'), "properties.scaling.velocity_ref")
6659 if U_ref == 0.0:
6660 raise ValueError("properties.scaling.velocity_ref must be non-zero for prescribed_flow profile staging.")
6661
6662 if any(bc.get("handler") == "prescribed_flow" for block in prepared_blocks for bc in block):
6663 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
6664
6665 generated_files = []
6666 generated_profile_summaries = []
6667 generated_target_grid = None
6668 field_slice_target_grid = None
6669 for i, block_bcs_list in enumerate(prepared_blocks):
6670 file_name = "bcs.run" if num_blocks == 1 else f"bcs_block{i}.run"
6671 bcs_file_path = os.path.join(config_dir, file_name)
6672 bcs_lines = [generate_header(run_id, source_files)]
6673
6674 for bc in block_bcs_list:
6675 face, bc_type, handler = bc['face'], bc['type'], bc['handler']
6676 params = dict(bc.get('params') or {})
6677 if handler == "prescribed_flow":
6678 source = params.pop("source")
6679 expected_dims = _bc_profile_expected_dims(face, profile_grid_dims[i])
6680 staged_name = f"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
6681 staged_path = os.path.join(config_dir, staged_name)
6682 if source["type"] == "file":
6683 source_path = source["path"]
6684 if case_path and not os.path.isabs(source_path):
6685 source_path = os.path.abspath(os.path.join(os.path.dirname(case_path), source_path))
6686 elif source["type"] == "generated":
6687 default_output = os.path.join(
6688 "config",
6689 f"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.picslice",
6690 )
6691 source_path = _resolve_run_artifact_path(
6692 run_dir,
6693 source.get("output_file"),
6694 default_output,
6695 default_to_config_dir=True,
6696 )
6697 if os.path.abspath(source_path) == os.path.abspath(staged_path):
6698 raise ValueError(
6699 f"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
6700 )
6701 if source["generator"] == "square_duct_poiseuille":
6702 if generated_target_grid is None:
6703 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
6704 summary = generate_square_duct_poiseuille_picslice(
6705 source_path,
6706 expected_dims,
6707 source["params"],
6708 target_grid=generated_target_grid,
6709 target_block=i,
6710 target_face=face,
6711 script=source.get("script"),
6712 case_path=case_path,
6713 )
6714 else:
6715 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
6716 summary.update({"block": i, "face": face})
6717 generated_profile_summaries.append(summary)
6718 elif source["type"] == "field_slice":
6719 default_output = os.path.join(
6720 "config",
6721 f"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.picslice",
6722 )
6723 source_path = _resolve_run_artifact_path(
6724 run_dir,
6725 source.get("output_file"),
6726 default_output,
6727 default_to_config_dir=True,
6728 )
6729 if os.path.abspath(source_path) == os.path.abspath(staged_path):
6730 raise ValueError(
6731 f"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
6732 )
6733 if field_slice_target_grid is None:
6734 field_slice_target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
6735 summary = generate_field_slice_picslice(
6736 source_path,
6737 expected_dims,
6738 source,
6739 field_slice_target_grid,
6740 face,
6741 i,
6742 case_path,
6743 )
6744 summary.update({"block": i, "face": face})
6745 generated_profile_summaries.append(summary)
6746 else:
6747 raise ValueError(f"Unsupported prescribed_flow source type '{source.get('type')}'.")
6748 summary = validate_and_nondimensionalize_picslice(source_path, staged_path, U_ref, expected_dims)
6749 print(
6750 f"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
6751 f"{os.path.relpath(staged_path)} dims={summary['dims']}"
6752 )
6753 params["source_file"] = os.path.abspath(staged_path)
6754 params_str = ""
6755 if params:
6756 parts = []
6757 for k, v in params.items():
6758 if isinstance(v, bool):
6759 value_str = "true" if v else "false"
6760 else:
6761 value_str = str(v)
6762 parts.append(f"{k}={value_str}")
6763 params_str = " ".join(parts)
6764 bcs_lines.append(f"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
6765
6766 with open(bcs_file_path, "w") as f: f.write("\n".join(bcs_lines))
6767
6768 print(f"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
6769 generated_files.append(os.path.abspath(bcs_file_path))
6770
6771 if generated_profile_summaries:
6772 info_path = write_profile_info(config_dir, generated_profile_summaries)
6773 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
6774
6775 return generated_files
6776
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 6777 of file core.py.

6777def format_flag_value(value):
6778 """!
6779 @brief Converts Python types to C-style command-line flag values.
6780 @param[in] value The Python object to convert (bool, list, or other).
6781 @return A string representation suitable for a C command-line parser.
6782 """
6783 if isinstance(value, bool):
6784 return "1" if value else "0"
6785 if isinstance(value, list):
6786 return ",".join(map(str, value))
6787 return str(value)
6788
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 6789 of file core.py.

6789def translate_programmatic_grid_settings(grid_settings: dict) -> dict:
6790 """!
6791 @brief Return programmatic-grid settings translated to the C node-count contract.
6792 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
6793 @return Value returned by `translate_programmatic_grid_settings()`.
6794 """
6795 translated = dict(grid_settings)
6796 for dim_key in ("im", "jm", "km"):
6797 if dim_key in translated:
6798 raw_val = translated[dim_key]
6799 if not isinstance(raw_val, int) or raw_val <= 0:
6800 raise ValueError(
6801 f"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
6802 f"(got {raw_val!r})."
6803 )
6804 translated[dim_key] = raw_val + 1
6805 return translated
6806
6807
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 6808 of file core.py.

6808def generate_picgrid_from_programmatic_settings(raw_settings: dict, dest_path: str, L_ref: float) -> dict:
6809 """!
6810 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
6811 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
6812 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
6813 @param[in] raw_settings programmatic_settings dict from case.yml.
6814 @param[in] dest_path Destination PICGRID file path.
6815 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
6816 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
6817 """
6818 if L_ref == 0.0:
6819 raise ValueError("length_ref must be non-zero for programmatic grid generation.")
6820 IM = int(raw_settings.get("im", 0)) + 1
6821 JM = int(raw_settings.get("jm", 0)) + 1
6822 KM = int(raw_settings.get("km", 0)) + 1
6823 if IM < 2 or JM < 2 or KM < 2:
6824 raise ValueError(
6825 f"programmatic_settings im/jm/km must each be >= 1 "
6826 f"(got im={IM-1}, jm={JM-1}, km={KM-1})."
6827 )
6828 x_min = float(raw_settings.get("xMins", 0.0))
6829 x_max = float(raw_settings.get("xMaxs", 1.0))
6830 y_min = float(raw_settings.get("yMins", 0.0))
6831 y_max = float(raw_settings.get("yMaxs", 1.0))
6832 z_min = float(raw_settings.get("zMins", 0.0))
6833 z_max = float(raw_settings.get("zMaxs", 1.0))
6834 rx = float(raw_settings.get("rxs", 1.0))
6835 ry = float(raw_settings.get("rys", 1.0))
6836 rz = float(raw_settings.get("rzs", 1.0))
6837
6838 def _stretched(idx, N, length, r):
6839 """!
6840 @brief Mirror of ComputeStretchedCoord from src/grid.c.
6841 @param[in] idx Node index along the axis.
6842 @param[in] N Total node count along the axis.
6843 @param[in] length Physical length of the axis.
6844 @param[in] r Geometric stretching ratio.
6845 @return Coordinate offset from the axis minimum.
6846 """
6847 frac = idx / (N - 1.0)
6848 if abs(r - 1.0) < 1.0e-9:
6849 return length * frac
6850 return length * (r ** frac - 1.0) / (r - 1.0)
6851
6852 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
6853 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
6854 with open(dest_path, "w") as fout:
6855 fout.write("PICGRID\n1\n")
6856 fout.write(f"{IM} {JM} {KM}\n")
6857 for k in range(KM):
6858 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
6859 for j in range(JM):
6860 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
6861 for i in range(IM):
6862 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
6863 fout.write(f"{x:.8e} {y:.8e} {z:.8e}\n")
6864 total_nodes = IM * JM * KM
6865 return {"nblk": 1, "dims": [(IM, JM, KM)], "total_nodes": total_nodes}
6866
6867
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 6871 of file core.py.

6871def resolve_grid_da_processor_layout(grid_cfg: dict) -> dict:
6872 """!
6873 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
6874 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
6875 @return Value returned by `resolve_grid_da_processor_layout()`.
6876 """
6877 top_level = {}
6878 legacy = {}
6879
6880 for key in GRID_DA_PROCESSOR_KEYS:
6881 value = grid_cfg.get(key)
6882 if isinstance(value, (list, tuple)):
6883 raise ValueError(
6884 f"grid.{key} must be a scalar integer. "
6885 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
6886 )
6887 if value is not None:
6888 if not isinstance(value, int) or value <= 0:
6889 raise ValueError(f"grid.{key} must be a positive integer when provided (got {value}).")
6890 top_level[key] = value
6891
6892 legacy_settings = grid_cfg.get("programmatic_settings")
6893 if isinstance(legacy_settings, dict):
6894 for key in GRID_DA_PROCESSOR_KEYS:
6895 value = legacy_settings.get(key)
6896 if isinstance(value, (list, tuple)):
6897 raise ValueError(
6898 f"grid.programmatic_settings.{key} must be a scalar integer. "
6899 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
6900 )
6901 if value is not None:
6902 if not isinstance(value, int) or value <= 0:
6903 raise ValueError(
6904 f"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
6905 )
6906 legacy[key] = value
6907
6908 resolved = {}
6909 for key in GRID_DA_PROCESSOR_KEYS:
6910 top_value = top_level.get(key)
6911 legacy_value = legacy.get(key)
6912 if top_value is not None and legacy_value is not None and top_value != legacy_value:
6913 raise ValueError(
6914 f"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
6915 "define the processor layout in only one place."
6916 )
6917 if top_value is not None:
6918 resolved[key] = top_value
6919 elif legacy_value is not None:
6920 resolved[key] = legacy_value
6921
6922 return resolved
6923
6924
Here is the caller graph for this function:

◆ append_grid_da_processor_layout()

None picurv_cli.core.append_grid_da_processor_layout ( list  control_lines,
dict  grid_cfg,
int  num_procs 
)

Append optional global DMDA layout flags for any grid mode.

Parameters
[in]control_linesArgument passed to append_grid_da_processor_layout().
[in]grid_cfgArgument passed to append_grid_da_processor_layout().
[in]num_procsArgument passed to append_grid_da_processor_layout().

Definition at line 6925 of file core.py.

6925def append_grid_da_processor_layout(control_lines: list, grid_cfg: dict, num_procs: int) -> None:
6926 """!
6927 @brief Append optional global DMDA layout flags for any grid mode.
6928 @param[in] control_lines Argument passed to `append_grid_da_processor_layout()`.
6929 @param[in] grid_cfg Argument passed to `append_grid_da_processor_layout()`.
6930 @param[in] num_procs Argument passed to `append_grid_da_processor_layout()`.
6931 """
6932 layout = resolve_grid_da_processor_layout(grid_cfg)
6933 if not layout:
6934 if num_procs > 1:
6935 print("[INFO] Letting PETSc automatically determine processor layout.")
6936 return
6937
6938 if num_procs == 1:
6939 print("[INFO] Serial run, ignoring da_processors layout.")
6940 return
6941
6942 if all(layout.get(key) is not None for key in GRID_DA_PROCESSOR_KEYS):
6943 total_layout = 1
6944 for key in GRID_DA_PROCESSOR_KEYS:
6945 total_layout *= layout[key]
6946 if total_layout != num_procs:
6947 raise ValueError(f"Processor layout mismatch: product ({total_layout}) != processes ({num_procs}).")
6948 print(f"[INFO] Applying user-defined processor layout for {num_procs} processes.")
6949 else:
6950 printable = " x ".join(str(layout.get(key, "PETSC_DECIDE")) for key in GRID_DA_PROCESSOR_KEYS)
6951 print(f"[INFO] Applying partial processor layout: {printable}.")
6952
6953 for key in GRID_DA_PROCESSOR_KEYS:
6954 value = layout.get(key)
6955 if value is not None:
6956 control_lines.append(f"-{key} {value}")
6957
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 6958 of file core.py.

6958def normalize_momentum_solver_type(value: str) -> str:
6959 """!
6960 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
6961 @param[in] value Canonical momentum solver string from YAML.
6962 @return Canonical value accepted by -mom_solver_type.
6963 @throws ValueError if the input cannot be mapped.
6964 """
6965 # Only implemented YAML values belong here. Extend only with matching C enum/parser/dispatch support.
6966 if value is None:
6967 raise ValueError("momentum solver type cannot be None")
6968
6969 raw = str(value).strip()
6970 mapped = {
6971 "Explicit RK4": "EXPLICIT_RK",
6972 "Dual Time Picard Jameson RK": "DUALTIME_PICARD_JAMESON_RK",
6973 "Dual Time Picard RK4": "DUALTIME_PICARD_JAMESON_RK",
6974 }.get(raw)
6975 if mapped is None:
6976 raise ValueError(
6977 f"Unknown momentum solver '{value}'. Use one of: "
6978 "'Explicit RK4', 'Dual Time Picard Jameson RK'."
6979 )
6980 return mapped
6981
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 6982 of file core.py.

6982def normalize_solution_convergence_mode(value: str) -> str:
6983 """!
6984 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
6985 @param[in] value Human-readable solution-convergence mode selector.
6986 @return Canonical string accepted by `-solution_convergence_mode`.
6987 @throws ValueError if the input cannot be mapped.
6988 """
6989 if value is None:
6990 raise ValueError("solution_convergence.mode cannot be None")
6991
6992 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
6993 aliases = {
6994 "steady_deterministic": "STEADY_DETERMINISTIC",
6995 "periodic_deterministic": "PERIODIC_DETERMINISTIC",
6996 "statistical_steady": "STATISTICAL_STEADY",
6997 "transient": "TRANSIENT",
6998 }
6999 mapped = aliases.get(normalized)
7000 if mapped is None:
7001 raise ValueError(
7002 f"Unknown solution_convergence.mode '{value}'. Use one of: "
7003 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
7004 )
7005 return mapped
7006
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 7007 of file core.py.

7007def normalize_field_init_mode(value: str) -> int:
7008 """!
7009 @brief Maps canonical field init mode names to C enum/int codes (-finit).
7010 @param[in] value Canonical field initialization mode.
7011 @return Canonical integer code accepted by -finit.
7012 @throws ValueError if the input cannot be mapped.
7013 """
7014 # Canonical selector map for field initialization modes.
7015 if value is None:
7016 raise ValueError("field initialization mode cannot be None")
7017
7018 mapped = {
7019 "Zero": 0,
7020 "Constant": 1,
7021 "Poiseuille": 2,
7022 }.get(str(value).strip())
7023 if mapped is None:
7024 raise ValueError(
7025 f"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
7026 )
7027 return mapped
7028
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 7029 of file core.py.

7029def normalize_initial_condition_field(value: str) -> "tuple[str, int]":
7030 """!
7031 @brief Normalize a file IC field selector to its staged basename and C enum value.
7032 @param[in] value User-facing Ucat or Ucont selector.
7033 @return Tuple of staged field basename and C enum value.
7034 """
7035 normalized = str(value or "").strip().lower()
7036 if normalized == "ucat":
7037 return "ufield", 0
7038 if normalized == "ucont":
7039 return "vfield", 1
7040 raise ValueError("initial_conditions.field must be 'Ucat' or 'Ucont'.")
7041
Here is the caller graph for this function:

◆ resolve_initial_condition_config()

dict picurv_cli.core.resolve_initial_condition_config ( dict  ic,
  prepared_blocks,
float  U_ref 
)

Resolve legacy and structured initial-condition YAML into one launcher contract.

Parameters
[in]icInitial-condition YAML mapping.
[in]prepared_blocksNormalized boundary-condition blocks.
[in]U_refPhysical reference velocity.
Returns
Normalized launcher initial-condition contract.

Definition at line 7042 of file core.py.

7042def resolve_initial_condition_config(ic: dict, prepared_blocks, U_ref: float) -> dict:
7043 """!
7044 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
7045 @param[in] ic Initial-condition YAML mapping.
7046 @param[in] prepared_blocks Normalized boundary-condition blocks.
7047 @param[in] U_ref Physical reference velocity.
7048 @return Normalized launcher initial-condition contract.
7049 """
7050 if not isinstance(ic, dict):
7051 raise ValueError("properties.initial_conditions must be a mapping.")
7052 mode = str(ic.get("mode", "")).strip()
7053
7054 # Backward-compatible legacy spelling.
7055 if mode in {"Zero", "Constant", "Poiseuille"}:
7056 finit_code = normalize_field_init_mode(mode)
7057 params = resolve_ic_cli_params(ic, finit_code, prepared_blocks, U_ref)
7058 if finit_code == 1 and params.pop("ic_coordinate_system", 0) == 1:
7059 finit_code = 3
7060 return {"finit": finit_code, "cli_params": params, "kind": "builtin", "label": mode}
7061
7062 normalized_mode = mode.lower().replace("-", "_").replace(" ", "_")
7063 if normalized_mode == "file":
7064 if prepared_blocks and len(prepared_blocks) > 1:
7065 raise ValueError("File-backed initial conditions currently support single-block cases only.")
7066 source_file = ic.get("source_file")
7067 if not isinstance(source_file, str) or not source_file.strip():
7068 raise ValueError("initial_conditions.source_file is required when mode is 'file'.")
7069 field_name, field_code = normalize_initial_condition_field(ic.get("field"))
7070 return {
7071 "finit": 4, "cli_params": {}, "kind": "file", "label": "file",
7072 "source_file": source_file.strip(), "field_name": field_name, "field_code": field_code,
7073 }
7074 if normalized_mode != "generated":
7075 raise ValueError("initial_conditions.mode must be 'generated' or 'file'.")
7076
7077 generator = str(ic.get("generator", "")).strip().lower().replace("-", "_").replace(" ", "_")
7078 params = ic.get("params", {})
7079 if not isinstance(params, dict):
7080 raise ValueError("initial_conditions.params must be a mapping.")
7081 if generator == "ic_gen":
7082 if prepared_blocks and len(prepared_blocks) > 1:
7083 raise ValueError("File-backed initial conditions currently support single-block cases only.")
7084 script = params.get("script")
7085 if script is not None and (not isinstance(script, str) or not script.strip()):
7086 raise ValueError("initial_conditions.params.script must be a non-empty path when provided.")
7087 field_name, field_code = normalize_initial_condition_field(params.get("field"))
7088 config_file = params.get("config_file")
7089 if not isinstance(config_file, str) or not config_file.strip():
7090 raise ValueError("initial_conditions.params.config_file is required for generator 'ic_gen'.")
7091 cli_args = params.get("cli_args", [])
7092 if cli_args is None:
7093 cli_args = []
7094 if not isinstance(cli_args, list):
7095 raise ValueError("initial_conditions.params.cli_args must be a list.")
7096 return {
7097 "finit": 4, "cli_params": {}, "kind": "ic_gen", "label": "ic_gen",
7098 "field_name": field_name, "field_code": field_code,
7099 "config_file": config_file.strip(),
7100 "script": script.strip() if script is not None else None,
7101 "output_file": params.get("output_file"),
7102 "cli_args": cli_args,
7103 }
7104
7105 generator_modes = {
7106 "zero": (0, "Zero"),
7107 "constant": (1, "Constant"),
7108 "streamwise_constant": (3, "Constant"),
7109 "poiseuille": (2, "Poiseuille"),
7110 }
7111 if generator not in generator_modes:
7112 raise ValueError(
7113 "initial_conditions.generator must be one of: zero, constant, "
7114 "streamwise_constant, poiseuille, ic_gen."
7115 )
7116 finit_code, legacy_mode = generator_modes[generator]
7117 legacy_ic = dict(params)
7118 legacy_ic["mode"] = legacy_mode
7119 cli_params = resolve_ic_cli_params(
7120 legacy_ic,
7121 1 if finit_code == 3 else finit_code,
7122 prepared_blocks,
7123 U_ref,
7124 )
7125 cli_params.pop("ic_coordinate_system", None)
7126 return {"finit": finit_code, "cli_params": cli_params, "kind": "builtin", "label": generator}
7127
Here is the call graph for this function:
Here is the caller graph for this function:

◆ validate_petsc_vec_binary()

dict picurv_cli.core.validate_petsc_vec_binary ( str  path)

Validate the basic PETSc binary VecView envelope used by ReadFieldData.

Parameters
[in]pathPETSc binary vector path.
Returns
Summary containing the absolute path and scalar count.

Definition at line 7128 of file core.py.

7128def validate_petsc_vec_binary(path: str) -> dict:
7129 """!
7130 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
7131 @param[in] path PETSc binary vector path.
7132 @return Summary containing the absolute path and scalar count.
7133 """
7134 import struct
7135 with open(path, "rb") as fin:
7136 header = fin.read(8)
7137 if len(header) != 8:
7138 raise ValueError(f"PETSc Vec file is too short: {path}")
7139 class_id, scalar_count = struct.unpack(">ii", header)
7140 if class_id != 1211214 or scalar_count < 0:
7141 raise ValueError(f"Invalid PETSc Vec header in {path}.")
7142 payload = fin.read()
7143 if len(payload) != scalar_count * 8:
7144 raise ValueError(
7145 f"PETSc Vec payload size mismatch in {path}: expected {scalar_count * 8} bytes, found {len(payload)}."
7146 )
7147 return {"path": os.path.abspath(path), "scalar_count": scalar_count}
7148
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 7149 of file core.py.

7149def run_initial_condition_generator(case_path: str, run_dir: str, resolved_ic: dict) -> str:
7150 """!
7151 @brief Run the repository IC generator.
7152 @param[in] case_path Source case YAML path.
7153 @param[in] run_dir Run or precompute output directory.
7154 @param[in] resolved_ic Normalized external-generator contract.
7155 @return Generated PETSc vector path.
7156 """
7157 case_dir = os.path.dirname(os.path.abspath(case_path))
7158 script = _resolve_generator_script(resolved_ic.get("script"), case_path, "ic.gen")
7159 config_file = resolved_ic["config_file"]
7160 config_file = config_file if os.path.isabs(config_file) else os.path.abspath(os.path.join(case_dir, config_file))
7161 if not os.path.isfile(script):
7162 raise ValueError(f"ic.gen script not found: {script}")
7163 if not os.path.isfile(config_file):
7164 raise ValueError(f"initial-condition generator config file not found: {config_file}")
7165 default_output = os.path.join("config", "initial_condition.generated.dat")
7166 output_path = _resolve_run_artifact_path(
7167 run_dir, resolved_ic.get("output_file"), default_output, default_to_config_dir=True
7168 )
7169 os.makedirs(os.path.dirname(output_path), exist_ok=True)
7170 cmd = [sys.executable, script, "-c", config_file, "--field",
7171 "Ucat" if resolved_ic["field_code"] == 0 else "Ucont",
7172 "--output", output_path]
7173 staged_grid = os.path.join(run_dir, "config", "grid.run")
7174 if os.path.isfile(staged_grid):
7175 cmd.extend(["--grid", staged_grid])
7176 cmd.extend(str(token) for token in resolved_ic.get("cli_args", []))
7177 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
7178 if result.returncode != 0:
7179 details = (result.stderr or result.stdout or "").strip()
7180 raise ValueError(f"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
7181 validate_petsc_vec_binary(output_path)
7182 return output_path
7183
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 7184 of file core.py.

7184def stage_initial_condition_file(run_dir: str, case_path: str, resolved_ic: dict) -> dict:
7185 """!
7186 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
7187 @param[in] run_dir Run or precompute output directory.
7188 @param[in] case_path Source case YAML path.
7189 @param[in] resolved_ic Normalized file-backed IC contract.
7190 @return Source, staged path, and staging-directory summary.
7191 """
7192 if resolved_ic["kind"] == "ic_gen":
7193 source_path = run_initial_condition_generator(case_path, run_dir, resolved_ic)
7194 else:
7195 source_path = resolved_ic["source_file"]
7196 if not os.path.isabs(source_path):
7197 source_path = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(case_path)), source_path))
7198 if not os.path.isfile(source_path):
7199 raise ValueError(f"Initial-condition source file not found: {source_path}")
7200 validate_petsc_vec_binary(source_path)
7201 stage_dir = os.path.join(run_dir, "config", "initial_condition")
7202 os.makedirs(stage_dir, exist_ok=True)
7203 staged_path = os.path.join(stage_dir, f"{resolved_ic['field_name']}00000_0.dat")
7204 if os.path.abspath(source_path) != os.path.abspath(staged_path):
7205 shutil.copy2(source_path, staged_path)
7206 return {"source": os.path.abspath(source_path), "staged": os.path.abspath(staged_path), "directory": os.path.abspath(stage_dir)}
7207
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 7208 of file core.py.

7208def normalize_flow_direction_token(value: str) -> int:
7209 """!
7210 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
7211 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
7212 @return Integer 0-5 matching the FlowDirection enum.
7213 @throws ValueError on unknown value.
7214 """
7215 mapped = {
7216 "+Xi": 0, "-Xi": 1,
7217 "+Eta": 2, "-Eta": 3,
7218 "+Zeta": 4, "-Zeta": 5,
7219 }.get(str(value).strip())
7220 if mapped is None:
7221 raise ValueError(
7222 f"Unknown initial_conditions.flow_direction '{value}'. "
7223 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
7224 )
7225 return mapped
7226
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 7227 of file core.py.

7227def _ic_has_inlet(prepared_blocks) -> bool:
7228 """!
7229 @brief Return True if any prepared BC block contains an INLET face.
7230 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
7231 @return True if at least one INLET entry exists across all blocks.
7232 """
7233 if not prepared_blocks:
7234 return False
7235 for block_bcs in prepared_blocks:
7236 for entry in block_bcs:
7237 if entry.get("type") == "INLET":
7238 return True
7239 return False
7240
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,
float  U_ref 
)

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]U_refReference velocity for non-dimensionalization.
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 7241 of file core.py.

7241def resolve_ic_cli_params(ic: dict, finit_code: int, prepared_blocks, U_ref: float) -> dict:
7242 """!
7243 @brief Resolve all IC parameters and return a dict of PETSc option values.
7244 @param[in] ic The properties.initial_conditions mapping.
7245 @param[in] finit_code Normalized -finit integer code.
7246 @param[in] prepared_blocks Normalized BC blocks (may be None).
7247 @param[in] U_ref Reference velocity for non-dimensionalization.
7248 @return Dict with keys matching PETSc option names (without leading dash).
7249 @throws KeyError if a required key is absent.
7250 @throws ValueError on invalid combinations or values.
7251 """
7252 result = {}
7253
7254 if finit_code == 0:
7255 return result
7256
7257 if finit_code == 1: # Constant
7258 has_cartesian = any(k in ic for k in ("u_physical", "v_physical", "w_physical"))
7259 has_curvilinear = "velocity_physical" in ic
7260
7261 if has_cartesian and has_curvilinear:
7262 raise ValueError(
7263 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
7264 "velocity_physical (curvilinear) — use one or the other."
7265 )
7266
7267 if has_curvilinear: # curvilinear: scalar speed along flow_direction axis
7268 cs_code = 1
7269 result["ic_coordinate_system"] = cs_code
7270 try:
7271 vel_phys = float(ic["velocity_physical"])
7272 except (TypeError, ValueError) as exc:
7273 raise ValueError(
7274 f"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
7275 "Expected a numeric value."
7276 ) from exc
7277 result["ic_velocity_physical"] = vel_phys / U_ref if U_ref != 0 else 0.0
7278
7279 if "flow_direction" in ic:
7280 result["flow_direction"] = normalize_flow_direction_token(ic["flow_direction"])
7281 elif not _ic_has_inlet(prepared_blocks):
7282 raise ValueError(
7283 "initial_conditions.flow_direction is required for curvilinear Constant IC "
7284 "when no INLET face exists."
7285 )
7286
7287 else: # cartesian: u/v/w_physical → Cart2Contra (default when no velocity_physical)
7288 if "flow_direction" in ic:
7289 raise ValueError(
7290 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
7291 "Use velocity_physical + flow_direction for curvilinear mode."
7292 )
7293 cs_code = 0
7294 result["ic_coordinate_system"] = cs_code
7295 u, v, w = parse_initial_velocity_components(ic, finit_code, require_explicit=True)
7296 scale = 1.0 / U_ref if U_ref != 0 else 0.0
7297 result["ucont_x"] = u * scale
7298 result["ucont_y"] = v * scale
7299 result["ucont_z"] = w * scale
7300
7301 elif finit_code == 2: # Poiseuille
7302 if any(k in ic for k in ("u_physical", "v_physical", "w_physical")):
7303 raise ValueError(
7304 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
7305 )
7306 if "velocity_physical" in ic:
7307 raise ValueError(
7308 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
7309 )
7310 if "peak_velocity_physical" not in ic:
7311 raise KeyError("peak_velocity_physical")
7312 try:
7313 peak = float(ic["peak_velocity_physical"])
7314 except (TypeError, ValueError) as exc:
7315 raise ValueError(
7316 f"Invalid value for initial_conditions.peak_velocity_physical: "
7317 f"{ic['peak_velocity_physical']!r}. Expected a numeric value."
7318 ) from exc
7319 result["ic_velocity_physical"] = peak / U_ref if U_ref != 0 else 0.0
7320
7321 if "flow_direction" in ic:
7322 fd_int = normalize_flow_direction_token(ic["flow_direction"])
7323 # Cross-check: explicit flow_direction must align with INLET face if one exists
7324 if _ic_has_inlet(prepared_blocks):
7325 inlet_axis = infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks)
7326 fd_axis_name = {0: "x", 1: "y", 2: "z"}.get(fd_int // 2, "?")
7327 if inlet_axis and fd_axis_name != inlet_axis:
7328 token = ic["flow_direction"]
7329 raise ValueError(
7330 f"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
7331 f"does not match INLET face axis '{inlet_axis}'."
7332 )
7333 result["flow_direction"] = fd_int
7334 elif not _ic_has_inlet(prepared_blocks):
7335 raise ValueError(
7336 "initial_conditions.flow_direction is required for Poiseuille IC "
7337 "when no INLET face exists."
7338 )
7339
7340 return result
7341
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 7342 of file core.py.

7342def normalize_eulerian_field_source(value: str) -> str:
7343 """!
7344 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
7345 @param[in] value Human-readable or enum-like Eulerian field source.
7346 @return Canonical string accepted by `-euler_field_source`.
7347 @throws ValueError if the input cannot be mapped.
7348 """
7349 if value is None:
7350 raise ValueError("eulerian_field_source cannot be None")
7351
7352 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7353 aliases = {
7354 "solve": "solve",
7355 "load": "load",
7356 "analytical": "analytical",
7357 }
7358 mapped = aliases.get(normalized)
7359 if mapped is None:
7360 raise ValueError(
7361 f"Unknown operation_mode.eulerian_field_source '{value}'. "
7362 "Use one of: 'solve', 'load', 'analytical'."
7363 )
7364 return mapped
7365
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 7366 of file core.py.

7366def normalize_analytical_type(value: str) -> str:
7367 """!
7368 @brief Normalizes the analytical solution selector to the C-side canonical string.
7369 @param[in] value Human-readable analytical solution selector.
7370 @return Canonical string accepted by `-analytical_type`.
7371 @throws ValueError if the input cannot be mapped.
7372 """
7373 # Canonical selector map for analytical solution selectors.
7374 if value is None:
7375 raise ValueError("analytical_type cannot be None")
7376
7377 normalized = str(value).strip().upper().replace("-", "_").replace(" ", "_")
7378 if normalized not in {"TGV3D", "ZERO_FLOW", "UNIFORM_FLOW"}:
7379 raise ValueError(
7380 f"Unknown operation_mode.analytical_type '{value}'. "
7381 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
7382 )
7383 return normalized
7384
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 7385 of file core.py.

7385def parse_initial_velocity_components(initial_conditions: dict, finit_code: int, *, require_explicit: bool) -> "tuple[float, float, float]":
7386 """!
7387 @brief Parse initial-condition velocity components with mode-aware defaults.
7388 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
7389 @param[in] finit_code Normalized `-finit` integer code.
7390 @param[in] require_explicit If True, all three component keys must be present.
7391 @return Tuple `(u, v, w)` in physical units.
7392 @throws KeyError if a required component key is missing.
7393 @throws ValueError if a component cannot be converted to float.
7394 """
7395 component_keys = ("u_physical", "v_physical", "w_physical")
7396 components = []
7397 for key in component_keys:
7398 if key not in initial_conditions:
7399 if require_explicit:
7400 raise KeyError(key)
7401 raw_value = 0.0
7402 else:
7403 raw_value = initial_conditions[key]
7404 try:
7405 components.append(float(raw_value))
7406 except (TypeError, ValueError) as exc:
7407 raise ValueError(
7408 f"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
7409 ) from exc
7410 return tuple(components)
7411
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 7412 of file core.py.

7412def infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks: list) -> "str | None":
7413 """!
7414 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
7415 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
7416 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
7417 @throws ValueError if different blocks imply different inlet axes.
7418 """
7419 face_order = ("-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta")
7420 face_axis = {
7421 "-Xi": "x", "+Xi": "x",
7422 "-Eta": "y", "+Eta": "y",
7423 "-Zeta": "z", "+Zeta": "z",
7424 }
7425
7426 inlet_axes = set()
7427 for block_bcs in prepared_blocks:
7428 face_map = {entry["face"]: entry for entry in block_bcs}
7429 for face in face_order:
7430 entry = face_map.get(face)
7431 if entry and entry["type"] == "INLET":
7432 inlet_axes.add(face_axis[face])
7433 break
7434
7435 if not inlet_axes:
7436 return None
7437 if len(inlet_axes) != 1:
7438 raise ValueError(
7439 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
7440 f"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
7441 )
7442 return next(iter(inlet_axes))
7443
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 7444 of file core.py.

7444def normalize_particle_init_mode(value: str) -> int:
7445 """!
7446 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
7447 @param[in] value Canonical particle initialization mode.
7448 @return Canonical integer code accepted by -pinit.
7449 @throws ValueError if the input cannot be mapped.
7450 """
7451 # Canonical selector map for particle initialization modes.
7452 if value is None:
7453 raise ValueError("particle init mode cannot be None")
7454
7455 mapped = {
7456 "Surface": 0,
7457 "Volume": 1,
7458 "PointSource": 2,
7459 "SurfaceEdges": 3,
7460 }.get(str(value).strip())
7461 if mapped is None:
7462 raise ValueError(
7463 f"Unknown particle init_mode '{value}'. Use one of: "
7464 "'Surface', 'Volume', 'PointSource', 'SurfaceEdges'."
7465 )
7466 return mapped
7467
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 7468 of file core.py.

7468def normalize_interpolation_method(value: str) -> int:
7469 """!
7470 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
7471 @param[in] value Canonical interpolation method name.
7472 @return Integer code accepted by -interpolation_method.
7473 @throws ValueError if the input cannot be mapped.
7474 """
7475 if value is None:
7476 raise ValueError("interpolation method cannot be None")
7477
7478 mapped = {
7479 "Trilinear": 0,
7480 "CornerAveraged": 1,
7481 }.get(str(value).strip())
7482 if mapped is None:
7483 raise ValueError(
7484 f"Unknown interpolation_method '{value}'. Use one of: "
7485 "'Trilinear', 'CornerAveraged'."
7486 )
7487 return mapped
7488
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 7489 of file core.py.

7489def normalize_les_model(value) -> int:
7490 """!
7491 @brief Maps LES model selectors to C enum/int codes (-les).
7492 @param[in] value LES selector name or legacy integer/bool value.
7493 @return Integer code accepted by -les.
7494 @throws ValueError if the input cannot be mapped.
7495 """
7496 if isinstance(value, bool):
7497 return 1 if value else 0
7498 if isinstance(value, int):
7499 if value in (0, 1, 2):
7500 return value
7501 raise ValueError("models.physics.turbulence.les must be 0, 1, 2, false/true, or a supported model block.")
7502 if value is None:
7503 raise ValueError("LES model cannot be None")
7504
7505 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7506 mapped = {
7507 "none": 0,
7508 "off": 0,
7509 "disabled": 0,
7510 "no_les": 0,
7511 "constant": 1,
7512 "constant_smagorinsky": 1,
7513 "smagorinsky": 1,
7514 "dynamic": 2,
7515 "dynamic_smagorinsky": 2,
7516 }.get(key)
7517 if mapped is None:
7518 raise ValueError(
7519 f"Unknown LES model '{value}'. Use one of: 'none', "
7520 "'constant_smagorinsky', 'dynamic_smagorinsky'."
7521 )
7522 return mapped
7523
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 names to the C -testfilter_ik flag.

Parameters
[in]valueTest-filter selector name or legacy integer/bool value.
Returns
0 for volume-weighted box, 1 for homogeneous i/k Simpson filtering.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 7524 of file core.py.

7524def normalize_les_test_filter(value) -> int:
7525 """!
7526 @brief Maps LES test-filter names to the C -testfilter_ik flag.
7527 @param[in] value Test-filter selector name or legacy integer/bool value.
7528 @return 0 for volume-weighted box, 1 for homogeneous i/k Simpson filtering.
7529 @throws ValueError if the input cannot be mapped.
7530 """
7531 if isinstance(value, bool):
7532 return 1 if value else 0
7533 if isinstance(value, int):
7534 if value in (0, 1):
7535 return value
7536 raise ValueError("models.physics.turbulence.les.test_filter must be 0, 1, or a supported filter name.")
7537 if value is None:
7538 raise ValueError("LES test_filter cannot be None")
7539
7540 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7541 mapped = {
7542 "volume_weighted_box": 0,
7543 "box": 0,
7544 "general_box": 0,
7545 "homogeneous_ik": 1,
7546 "ik_homogeneous": 1,
7547 "simpson_ik": 1,
7548 }.get(key)
7549 if mapped is None:
7550 raise ValueError(
7551 f"Unknown LES test_filter '{value}'. Use one of: "
7552 "'volume_weighted_box', 'homogeneous_ik'."
7553 )
7554 return mapped
7555

◆ normalize_rans_model()

int picurv_cli.core.normalize_rans_model (   value)

Maps RANS model selectors to the current C -rans switch.

Parameters
[in]valueRANS selector name or legacy integer/bool value.
Returns
Integer code accepted by -rans.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 7556 of file core.py.

7556def normalize_rans_model(value) -> int:
7557 """!
7558 @brief Maps RANS model selectors to the current C -rans switch.
7559 @param[in] value RANS selector name or legacy integer/bool value.
7560 @return Integer code accepted by -rans.
7561 @throws ValueError if the input cannot be mapped.
7562 """
7563 if isinstance(value, bool):
7564 return 1 if value else 0
7565 if isinstance(value, int):
7566 if value in (0, 1):
7567 return value
7568 raise ValueError("models.physics.turbulence.rans must be 0, 1, false/true, or a supported model block.")
7569 if value is None:
7570 raise ValueError("RANS model cannot be None")
7571
7572 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7573 mapped = {
7574 "none": 0,
7575 "off": 0,
7576 "disabled": 0,
7577 "k_omega": 1,
7578 "komega": 1,
7579 }.get(key)
7580 if mapped is None:
7581 raise ValueError(f"Unknown RANS model '{value}'. Use one of: 'none', 'k_omega'.")
7582 return mapped
7583
Here is the caller graph for this function:

◆ normalize_wall_function_model()

str picurv_cli.core.normalize_wall_function_model (   value)

Validates wall-function model selectors exposed in YAML.

Parameters
[in]valueWall-function selector name.
Returns
Canonical wall-function model name.
Exceptions
ValueErrorif the input cannot be mapped.

Definition at line 7584 of file core.py.

7584def normalize_wall_function_model(value) -> str:
7585 """!
7586 @brief Validates wall-function model selectors exposed in YAML.
7587 @param[in] value Wall-function selector name.
7588 @return Canonical wall-function model name.
7589 @throws ValueError if the input cannot be mapped.
7590 """
7591 if value is None:
7592 return "log_law"
7593 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7594 if key in {"log_law", "loglaw"}:
7595 return "log_law"
7596 raise ValueError("Unknown wall_function model '%s'. Use: 'log_law'." % value)
7597
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 7598 of file core.py.

7598def resolve_enabled_flag(cfg: dict, path: str, default: bool = True) -> bool:
7599 """!
7600 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
7601 @param[in] cfg Mapping that may contain `enabled`.
7602 @param[in] path Human-readable config path for diagnostics.
7603 @param[in] default Value used when `enabled` is omitted.
7604 @return Boolean enabled state.
7605 @throws ValueError if `enabled` is not a YAML boolean.
7606 """
7607 if 'enabled' not in cfg:
7608 return default
7609 if not isinstance(cfg['enabled'], bool):
7610 raise ValueError(f"{path}.enabled must be true or false.")
7611 return cfg['enabled']
7612
Here is the caller graph for this function:

◆ append_turbulence_flags()

picurv_cli.core.append_turbulence_flags ( dict  models,
list  control_lines 
)

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.

Definition at line 7613 of file core.py.

7613def append_turbulence_flags(models: dict, control_lines: list):
7614 """!
7615 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
7616 @param[in] models Parsed case.yml `models` mapping.
7617 @param[out] control_lines A list of strings to which C-flags will be appended.
7618 """
7619 turbulence_cfg = models.get('physics', {}).get('turbulence', {})
7620 if not turbulence_cfg:
7621 return
7622 if not isinstance(turbulence_cfg, dict):
7623 raise ValueError("models.physics.turbulence must be a mapping.")
7624
7625 les_cfg = turbulence_cfg.get('les')
7626 rans_cfg = turbulence_cfg.get('rans')
7627 wall_cfg = turbulence_cfg.get('wall_function')
7628 les_code = None
7629 rans_code = None
7630
7631 if isinstance(les_cfg, dict):
7632 enabled = resolve_enabled_flag(les_cfg, "models.physics.turbulence.les")
7633 model_value = les_cfg.get('model', 'constant_smagorinsky')
7634 les_code = normalize_les_model(model_value) if enabled else 0
7635 control_lines.append(f"-les {les_code}")
7636 if 'constant_cs' in les_cfg:
7637 control_lines.append(f"-const_cs {format_flag_value(les_cfg['constant_cs'])}")
7638 if 'max_cs' in les_cfg:
7639 control_lines.append(f"-max_cs {format_flag_value(les_cfg['max_cs'])}")
7640 if 'dynamic_frequency' in les_cfg:
7641 control_lines.append(f"-dynamic_freq {format_flag_value(les_cfg['dynamic_frequency'])}")
7642 if 'test_filter' in les_cfg:
7643 control_lines.append(f"-testfilter_ik {normalize_les_test_filter(les_cfg['test_filter'])}")
7644 elif les_cfg is not None:
7645 les_code = normalize_les_model(les_cfg)
7646 control_lines.append(f"-les {les_code}")
7647
7648 if isinstance(rans_cfg, dict):
7649 enabled = resolve_enabled_flag(rans_cfg, "models.physics.turbulence.rans")
7650 model_value = rans_cfg.get('model', 'k_omega')
7651 rans_code = normalize_rans_model(model_value) if enabled else 0
7652 control_lines.append(f"-rans {rans_code}")
7653 elif rans_cfg is not None:
7654 rans_code = normalize_rans_model(rans_cfg)
7655 control_lines.append(f"-rans {rans_code}")
7656
7657 if les_code and rans_code:
7658 raise ValueError("models.physics.turbulence cannot enable both LES and RANS in the same case.")
7659
7660 if isinstance(wall_cfg, dict):
7661 enabled = resolve_enabled_flag(wall_cfg, "models.physics.turbulence.wall_function")
7662 normalize_wall_function_model(wall_cfg.get('model'))
7663 control_lines.append(f"-wallfunction {1 if enabled else 0}")
7664 if 'roughness_height' in wall_cfg:
7665 control_lines.append(f"-wall_roughness {format_flag_value(wall_cfg['roughness_height'])}")
7666 elif wall_cfg is not None:
7667 control_lines.append(f"-wallfunction {format_flag_value(wall_cfg)}")
7668
Here is the call graph for this function:
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 7669 of file core.py.

7669def append_passthrough_flags(control_lines: list, options: dict):
7670 """!
7671 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
7672 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
7673 is skipped. All other values are emitted as "<flag> <value>".
7674 @param[out] control_lines The destination list of control-file lines.
7675 @param[in] options Mapping of raw CLI flags to values.
7676 """
7677 if not options:
7678 return
7679 for flag, value in options.items():
7680 if isinstance(value, bool):
7681 if value:
7682 control_lines.append(str(flag))
7683 continue
7684 control_lines.append(f"{flag} {format_flag_value(value)}")
7685
7686
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 7695 of file core.py.

7695def resolve_solver_monitoring_flags(monitor_cfg: dict) -> dict:
7696 """!
7697 @brief Resolve human-readable solver monitoring YAML to raw control flags.
7698 @param[in] monitor_cfg Parsed monitor.yml mapping.
7699 @return Mapping of raw C/PETSc flags to values.
7700 """
7701 solver_mon_cfg = monitor_cfg.get("solver_monitoring", {}) if isinstance(monitor_cfg, dict) else {}
7702 if solver_mon_cfg is None:
7703 return {}
7704 if not isinstance(solver_mon_cfg, dict):
7705 raise ValueError("monitor.solver_monitoring must be a mapping when provided.")
7706
7707 flags = {}
7708
7709 poisson_cfg = solver_mon_cfg.get("poisson", {}) or {}
7710 if not isinstance(poisson_cfg, dict):
7711 raise ValueError("monitor.solver_monitoring.poisson must be a mapping when provided.")
7712 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
7713 if unknown_poisson:
7714 raise ValueError(f"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
7715 for key, flag in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
7716 if key in poisson_cfg:
7717 value = poisson_cfg[key]
7718 if not isinstance(value, bool):
7719 raise ValueError(f"monitor.solver_monitoring.poisson.{key} must be boolean.")
7720 flags[flag] = value
7721
7722 passthrough = solver_mon_cfg.get("petsc_passthrough_options", {}) or {}
7723 if not isinstance(passthrough, dict):
7724 raise ValueError("monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
7725 flags.update(passthrough)
7726
7727 legacy_raw = {
7728 key: value
7729 for key, value in solver_mon_cfg.items()
7730 if isinstance(key, str) and key.startswith("-")
7731 }
7732 flags.update(legacy_raw)
7733
7734 unknown_top = sorted(
7735 key
7736 for key in solver_mon_cfg.keys()
7737 if key not in {"poisson", "petsc_passthrough_options"} and not (isinstance(key, str) and key.startswith("-"))
7738 )
7739 if unknown_top:
7740 raise ValueError(
7741 "monitor.solver_monitoring has unsupported key(s): "
7742 f"{unknown_top}. Use 'poisson' for structured monitors or "
7743 "'petsc_passthrough_options' for raw PETSc flags."
7744 )
7745
7746 return flags
7747
7748
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 7749 of file core.py.

7749def resolve_particle_console_output_frequency(io_cfg: dict) -> "int | None":
7750 """!
7751 @brief Return the effective particle-console snapshot cadence from monitor.yml.
7752 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
7753 @return Value returned by `resolve_particle_console_output_frequency()`.
7754 """
7755 if 'particle_console_output_frequency' in io_cfg:
7756 return io_cfg['particle_console_output_frequency']
7757 return io_cfg.get('data_output_frequency')
7758
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 7759 of file core.py.

7759def parse_and_add_model_flags(case_cfg: dict, control_lines: list):
7760 """!
7761 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
7762 @param[in] case_cfg The parsed case.yml configuration dictionary.
7763 @param[out] control_lines A list of strings to which C-flags will be appended.
7764 """
7765 models = case_cfg.get('models', {})
7766 FLAG_MAP = {
7767 'domain': {'blocks': '-nblk'},
7768 'physics.fsi': {'immersed': '-imm', 'moving_fsi': '-fsi'},
7769 'physics.particles': {'count': '-numParticles'},
7770 'statistics': {'time_averaging': '-averaging'}
7771 }
7772 for section_path, flags in FLAG_MAP.items():
7773 current_level = models
7774 try:
7775 for key in section_path.split('.'): current_level = current_level[key]
7776 for yaml_key, flag in flags.items():
7777 if yaml_key in current_level:
7778 control_lines.append(f"{flag} {format_flag_value(current_level[yaml_key])}")
7779 except KeyError: continue
7780
7781 append_turbulence_flags(models, control_lines)
7782
7783 if models.get('physics', {}).get('dimensionality') == '2D':
7784 control_lines.append("-TwoD 1")
7785
7786 particles_cfg = models.get('physics', {}).get('particles', {})
7787 p_init_mode_str = particles_cfg.get('init_mode', 'Surface')
7788 pinit_code = normalize_particle_init_mode(p_init_mode_str)
7789 control_lines.append(f"-pinit {pinit_code}")
7790 print(f" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
7791
7792 if pinit_code == 2:
7793 point_cfg = particles_cfg.get('point_source', {})
7794 if not isinstance(point_cfg, dict):
7795 raise ValueError("models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
7796 try:
7797 psrc_x = float(point_cfg['x'])
7798 psrc_y = float(point_cfg['y'])
7799 psrc_z = float(point_cfg['z'])
7800 except (KeyError, TypeError, ValueError):
7801 raise ValueError("PointSource init_mode requires numeric point_source.{x,y,z} values.")
7802 control_lines.append(f"-psrc_x {psrc_x}")
7803 control_lines.append(f"-psrc_y {psrc_y}")
7804 control_lines.append(f"-psrc_z {psrc_z}")
7805 print(f" - Particle Point Source: ({psrc_x}, {psrc_y}, {psrc_z})")
7806
7807 p_restart_mode = particles_cfg.get('restart_mode')
7808 if p_restart_mode:
7809 p_restart_mode_normalized = str(p_restart_mode).lower()
7810 if p_restart_mode_normalized not in {"init", "load"}:
7811 raise ValueError(f"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
7812 control_lines.append(f"-particle_restart_mode \"{p_restart_mode}\"")
7813
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)

Parses the structured solver.yml into a flat dictionary of {flag: value}.

Parameters
[in]solver_cfgThe parsed solver.yml configuration dictionary.
Returns
A dictionary where keys are C-solver flags and values are the corresponding settings.

Definition at line 7814 of file core.py.

7814def parse_solver_config(solver_cfg: dict) -> dict:
7815 """!
7816 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
7817 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
7818 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
7819 """
7820 flags = {}
7821 if 'operation_mode' in solver_cfg and isinstance(solver_cfg['operation_mode'], dict):
7822 op_mode = solver_cfg['operation_mode']
7823 if 'eulerian_field_source' in op_mode:
7824 normalized_source = normalize_eulerian_field_source(op_mode.get('eulerian_field_source'))
7825 flags['-euler_field_source'] = f"\"{normalized_source}\""
7826 if 'analytical_type' in op_mode and op_mode.get('analytical_type') is not None:
7827 normalized_analytical_type = normalize_analytical_type(op_mode.get('analytical_type'))
7828 flags['-analytical_type'] = f"\"{normalized_analytical_type}\""
7829 if normalized_analytical_type == "UNIFORM_FLOW":
7830 uniform_flow_cfg = op_mode.get('uniform_flow', {})
7831 if not isinstance(uniform_flow_cfg, dict):
7832 raise ValueError("operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
7833 try:
7834 flags['-analytical_uniform_u'] = float(uniform_flow_cfg['u'])
7835 flags['-analytical_uniform_v'] = float(uniform_flow_cfg['v'])
7836 flags['-analytical_uniform_w'] = float(uniform_flow_cfg['w'])
7837 except KeyError as exc:
7838 raise ValueError(f"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.") from exc
7839 except (TypeError, ValueError) as exc:
7840 raise ValueError("operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.") from exc
7841
7842 verification_cfg = solver_cfg.get('verification', {})
7843 if verification_cfg:
7844 if not isinstance(verification_cfg, dict):
7845 raise ValueError("verification must be a mapping when provided.")
7846 sources_cfg = verification_cfg.get('sources', {})
7847 if not isinstance(sources_cfg, dict):
7848 raise ValueError("verification.sources must be a mapping when provided.")
7849 diff_cfg = sources_cfg.get('diffusivity')
7850 if diff_cfg is not None:
7851 if not isinstance(diff_cfg, dict):
7852 raise ValueError("verification.sources.diffusivity must be a mapping.")
7853 try:
7854 flags['-verification_diffusivity_mode'] = f"\"{str(diff_cfg['mode']).strip().lower()}\""
7855 flags['-verification_diffusivity_profile'] = f"\"{str(diff_cfg['profile']).strip().upper()}\""
7856 flags['-verification_diffusivity_gamma0'] = float(diff_cfg['gamma0'])
7857 flags['-verification_diffusivity_slope_x'] = float(diff_cfg['slope_x'])
7858 except KeyError as exc:
7859 raise ValueError(f"verification.sources.diffusivity.{exc.args[0]} is required.") from exc
7860 except (TypeError, ValueError) as exc:
7861 raise ValueError("verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.") from exc
7862
7863 scalar_cfg = sources_cfg.get('scalar')
7864 if scalar_cfg is not None:
7865 if not isinstance(scalar_cfg, dict):
7866 raise ValueError("verification.sources.scalar must be a mapping.")
7867 try:
7868 flags['-verification_scalar_mode'] = f"\"{str(scalar_cfg['mode']).strip().lower()}\""
7869 flags['-verification_scalar_profile'] = f"\"{str(scalar_cfg['profile']).strip().upper()}\""
7870 except KeyError as exc:
7871 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
7872
7873 scalar_numeric_keys = {
7874 'CONSTANT': ('value',),
7875 'LINEAR_X': ('phi0', 'slope_x'),
7876 'SIN_PRODUCT': ('amplitude', 'kx', 'ky', 'kz'),
7877 }
7878 profile = str(scalar_cfg.get('profile', '')).strip().upper()
7879 for key in scalar_numeric_keys.get(profile, ()):
7880 try:
7881 flags[f'-verification_scalar_{key}'] = float(scalar_cfg[key])
7882 except KeyError as exc:
7883 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
7884 except (TypeError, ValueError) as exc:
7885 raise ValueError(f"verification.sources.scalar.{key} must be numeric.") from exc
7886
7887 transport_cfg = solver_cfg.get('scalar_transport', {})
7888 if transport_cfg:
7889 if not isinstance(transport_cfg, dict):
7890 raise ValueError("scalar_transport must be a mapping when provided.")
7891 transport_map = {
7892 'schmidt_number': '-schmidt_number',
7893 'turbulent_schmidt_number': '-turb_schmidt_number',
7894 }
7895 unknown_transport_keys = sorted(set(transport_cfg.keys()) - set(transport_map.keys()))
7896 if unknown_transport_keys:
7897 raise ValueError(
7898 f"scalar_transport has unsupported key(s): {unknown_transport_keys}. "
7899 "Use 'schmidt_number' or 'turbulent_schmidt_number'."
7900 )
7901 for key, flag in transport_map.items():
7902 if key in transport_cfg:
7903 try:
7904 value = float(transport_cfg[key])
7905 except (TypeError, ValueError) as exc:
7906 raise ValueError(f"scalar_transport.{key} must be numeric.") from exc
7907 if value <= 0.0:
7908 raise ValueError(f"scalar_transport.{key} must be positive.")
7909 flags[flag] = value
7910
7911 selected_solver = None
7912 if 'strategy' in solver_cfg:
7913 s = solver_cfg['strategy']
7914 if 'central_diff' in s:
7915 flags['-central'] = format_flag_value(s['central_diff'])
7916 # Preferred selector.
7917 if 'momentum_solver' in s:
7918 selected_solver = normalize_momentum_solver_type(s['momentum_solver'])
7919 elif 'implicit' in s:
7920 raise ValueError("Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
7921
7922 ms = solver_cfg.get('momentum_solver', {})
7923 if selected_solver is None:
7924 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
7925 flags['-mom_solver_type'] = f"\"{selected_solver}\""
7926
7927 if 'tolerances' in solver_cfg:
7928 t = solver_cfg['tolerances']
7929 tol_map = {
7930 'max_iterations': '-mom_max_pseudo_steps',
7931 'absolute_tol': '-mom_atol',
7932 'relative_tol': '-mom_rtol',
7933 'residual_absolute_tol': '-mom_resid_atol',
7934 'residual_relative_tol': '-mom_resid_rtol',
7935 'step_tol': '-imp_stol'
7936 }
7937 for key, flag in tol_map.items():
7938 if key in t:
7939 flags[flag] = t[key]
7940
7941 def _append_dualtime_options(cfg: dict):
7942 """!
7943 @brief Append dualtime options.
7944 @param[in] cfg Argument passed to `_append_dualtime_options()`.
7945 """
7946 if 'max_pseudo_steps' in cfg:
7947 flags['-mom_max_pseudo_steps'] = cfg['max_pseudo_steps']
7948 if 'absolute_tol' in cfg:
7949 flags['-mom_atol'] = cfg['absolute_tol']
7950 if 'relative_tol' in cfg:
7951 flags['-mom_rtol'] = cfg['relative_tol']
7952 if 'step_tol' in cfg:
7953 flags['-imp_stol'] = cfg['step_tol']
7954 if 'pseudo_cfl' in cfg:
7955 pcfl = cfg['pseudo_cfl']
7956 if 'initial' in pcfl:
7957 flags['-pseudo_cfl'] = pcfl['initial']
7958 if 'minimum' in pcfl:
7959 flags['-min_pseudo_cfl'] = pcfl['minimum']
7960 if 'maximum' in pcfl:
7961 flags['-max_pseudo_cfl'] = pcfl['maximum']
7962 if 'growth_factor' in pcfl:
7963 flags['-pseudo_cfl_growth_factor'] = pcfl['growth_factor']
7964 if 'reduction_factor' in pcfl:
7965 flags['-pseudo_cfl_reduction_factor'] = pcfl['reduction_factor']
7966 if 'jameson_residual_noise_allowance_factor' in cfg:
7967 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['jameson_residual_noise_allowance_factor']
7968 elif 'rk4_residual_noise_allowance_factor' in cfg:
7969 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['rk4_residual_noise_allowance_factor']
7970
7971 if isinstance(ms, dict):
7972 allowed_ms_keys = {'type', 'dual_time_picard_jameson_rk', 'dual_time_picard_rk4'}
7973 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
7974 if unknown_ms_keys:
7975 raise ValueError(
7976 f"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
7977 "Currently supported block: 'dual_time_picard_jameson_rk'."
7978 )
7979
7980 if 'dual_time_picard_jameson_rk' in ms and 'dual_time_picard_rk4' in ms:
7981 raise ValueError(
7982 "Use only momentum_solver.dual_time_picard_jameson_rk; "
7983 "do not also set its deprecated dual_time_picard_rk4 alias."
7984 )
7985 dt_picard_cfg = ms.get('dual_time_picard_jameson_rk', ms.get('dual_time_picard_rk4'))
7986 if dt_picard_cfg is not None:
7987 if selected_solver != "DUALTIME_PICARD_JAMESON_RK":
7988 raise ValueError(
7989 f"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
7990 )
7991 if not isinstance(dt_picard_cfg, dict):
7992 raise ValueError("momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
7993 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
7994 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
7995 raise ValueError(
7996 "Use only jameson_residual_noise_allowance_factor; "
7997 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
7998 )
7999 _append_dualtime_options(dt_picard_cfg)
8000 solution_convergence_cfg = solver_cfg.get('solution_convergence', {})
8001 if solution_convergence_cfg is not None:
8002 if not isinstance(solution_convergence_cfg, dict):
8003 raise ValueError("solution_convergence must be a mapping when provided.")
8004 if solution_convergence_cfg and solution_convergence_cfg.get('enabled', True):
8005 mode = normalize_solution_convergence_mode(solution_convergence_cfg.get('mode', 'steady_deterministic'))
8006 flags['-solution_convergence_mode'] = f"\"{mode}\""
8007 if mode == "PERIODIC_DETERMINISTIC":
8008 periodic_cfg = solution_convergence_cfg.get('periodic_deterministic')
8009 if not isinstance(periodic_cfg, dict):
8010 raise ValueError("solution_convergence.periodic_deterministic must be a mapping when mode is 'periodic_deterministic'.")
8011 flags['-solution_convergence_period_steps'] = periodic_cfg['period_steps']
8012 if mode == "STATISTICAL_STEADY":
8013 statistical_cfg = solution_convergence_cfg.get('statistical_steady')
8014 if not isinstance(statistical_cfg, dict):
8015 raise ValueError("solution_convergence.statistical_steady must be a mapping when mode is 'statistical_steady'.")
8016 flags['-solution_convergence_window_steps'] = statistical_cfg['window_steps']
8017 def _normalize_poisson_method(value) -> str:
8018 """!
8019 @brief Normalize a user-facing Poisson linear-solver method name.
8020 @param[in] value Method value from the solver YAML.
8021 @return Lowercase PETSc KSP method token.
8022 """
8023 method = str(value).strip().lower()
8024 if not method:
8025 raise ValueError("poisson_solver.method cannot be empty.")
8026 return method
8027
8028 def _normalize_poisson_preconditioner(value) -> str:
8029 """!
8030 @brief Normalize and validate the outer Poisson preconditioner name.
8031 @param[in] value Preconditioner value from the solver YAML.
8032 @return PETSc PC token for the supported outer preconditioner.
8033 """
8034 pc = str(value).strip().lower()
8035 aliases = {"mg": "multigrid", "pcmg": "multigrid"}
8036 pc = aliases.get(pc, pc)
8037 if pc != "multigrid":
8038 raise ValueError(
8039 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
8040 "The runtime Poisson solver still assumes PETSc PCMG setup."
8041 )
8042 return "mg"
8043
8044 def _poisson_level_number(level_name) -> str:
8045 """!
8046 @brief Extract the numeric suffix from a `level_N` multigrid level key.
8047 @param[in] level_name YAML level key supplied by the user.
8048 @return Numeric level suffix as a string.
8049 """
8050 text = str(level_name).strip()
8051 match = re.fullmatch(r"level_(\d+)", text)
8052 if not match:
8053 raise ValueError(f"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
8054 return match.group(1)
8055
8056 def _append_poisson_solver_flags(ps: dict, source_key: str):
8057 """!
8058 @brief Append structured Poisson solver options to the flat PETSc flag map.
8059 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
8060 @param[in] source_key Name of the source YAML block, used in error messages.
8061 """
8062 if not isinstance(ps, dict):
8063 raise ValueError(f"{source_key} must be a mapping when provided.")
8064
8065 method = None
8066 if 'method' in ps:
8067 method = _normalize_poisson_method(ps['method'])
8068 flags['-ps_ksp_type'] = method
8069 if 'absolute_tolerance' in ps:
8070 flags['-ps_ksp_atol'] = ps['absolute_tolerance']
8071 flags['-poisson_tol'] = ps['absolute_tolerance']
8072 if 'relative_tolerance' in ps:
8073 flags['-ps_ksp_rtol'] = ps['relative_tolerance']
8074 if 'max_iterations' in ps:
8075 flags['-ps_ksp_max_it'] = ps['max_iterations']
8076 if 'tolerance' in ps:
8077 flags['-poisson_tol'] = ps['tolerance']
8078
8079 gmres_cfg = ps.get('gmres', {})
8080 if gmres_cfg is not None:
8081 if not isinstance(gmres_cfg, dict):
8082 raise ValueError(f"{source_key}.gmres must be a mapping when provided.")
8083 if 'restart' in gmres_cfg:
8084 if method is None:
8085 method = _normalize_poisson_method(ps.get('method', 'fgmres'))
8086 flags.setdefault('-ps_ksp_type', method)
8087 if method not in {"gmres", "fgmres", "lgmres"}:
8088 raise ValueError(
8089 f"{source_key}.gmres.restart is valid only when {source_key}.method "
8090 "is one of 'gmres', 'fgmres', or 'lgmres'."
8091 )
8092 flags['-ps_ksp_gmres_restart'] = gmres_cfg['restart']
8093
8094 preconditioner_cfg = ps.get('preconditioner', {})
8095 if preconditioner_cfg:
8096 if not isinstance(preconditioner_cfg, dict):
8097 raise ValueError(f"{source_key}.preconditioner must be a mapping when provided.")
8098 if 'type' in preconditioner_cfg:
8099 flags['-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg['type'])
8100
8101 if 'multigrid' in ps:
8102 mg = ps['multigrid']
8103 if not isinstance(mg, dict):
8104 raise ValueError(f"{source_key}.multigrid must be a mapping when provided.")
8105 mg_map = {'levels': '-mg_level', 'pre_sweeps': '-mg_pre_it', 'post_sweeps': '-mg_post_it'}
8106 for key, flag in mg_map.items():
8107 if key in mg: flags[flag] = mg[key]
8108 if 'cycle' in mg:
8109 cycle = str(mg['cycle']).strip().lower()
8110 if cycle not in {"v"}:
8111 raise ValueError(f"{source_key}.multigrid.cycle currently supports only 'v'.")
8112 if 'mode' in mg:
8113 mode = str(mg['mode']).strip().lower()
8114 if mode not in {"multiplicative"}:
8115 raise ValueError(f"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
8116 if 'semi_coarsening' in mg:
8117 sc = mg['semi_coarsening']
8118 if not isinstance(sc, dict):
8119 raise ValueError(f"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
8120 if 'i' in sc: flags['-mg_i_semi'] = format_flag_value(sc['i'])
8121 if 'j' in sc: flags['-mg_j_semi'] = format_flag_value(sc['j'])
8122 if 'k' in sc: flags['-mg_k_semi'] = format_flag_value(sc['k'])
8123 if 'level_solvers' in mg:
8124 level_solvers = mg['level_solvers']
8125 if not isinstance(level_solvers, dict):
8126 raise ValueError(f"{source_key}.multigrid.level_solvers must be a mapping when provided.")
8127 for level_name, settings in level_solvers.items():
8128 if not isinstance(settings, dict):
8129 raise ValueError(f"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
8130 level_num = _poisson_level_number(level_name)
8131 for key, value in settings.items():
8132 mapped_key = {'method': 'ksp_type', 'preconditioner': 'pc_type'}.get(key, key)
8133 flags[f"-ps_mg_levels_{level_num}_{mapped_key}"] = format_flag_value(value)
8134
8135 if 'poisson_solver' in solver_cfg and 'pressure_solver' in solver_cfg:
8136 if solver_cfg['poisson_solver'] != solver_cfg['pressure_solver']:
8137 raise ValueError(
8138 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
8139 "Use 'poisson_solver' only, or make the legacy alias identical."
8140 )
8141 poisson_cfg = solver_cfg.get('poisson_solver', solver_cfg.get('pressure_solver'))
8142 if poisson_cfg is not None:
8143 source_key = 'poisson_solver' if 'poisson_solver' in solver_cfg else 'pressure_solver'
8144 _append_poisson_solver_flags(poisson_cfg, source_key)
8145 interp_cfg = solver_cfg.get('interpolation', {})
8146 if isinstance(interp_cfg, dict):
8147 interp_method_str = interp_cfg.get('method', 'Trilinear')
8148 else:
8149 interp_method_str = 'Trilinear'
8150 interp_code = normalize_interpolation_method(interp_method_str)
8151 flags['-interpolation_method'] = interp_code
8152 print(f" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
8153
8154 if 'petsc_passthrough_options' in solver_cfg:
8155 passthrough = solver_cfg['petsc_passthrough_options']
8156 if passthrough:
8157 for key, value in passthrough.items():
8158 flags[key] = format_flag_value(value)
8159 summary_bits = []
8160 if '-ps_ksp_type' in flags:
8161 summary_bits.append(f"method={flags['-ps_ksp_type']}")
8162 if '-ps_ksp_atol' in flags:
8163 summary_bits.append(f"atol={flags['-ps_ksp_atol']}")
8164 if '-ps_ksp_rtol' in flags:
8165 summary_bits.append(f"rtol={flags['-ps_ksp_rtol']}")
8166 if '-ps_ksp_max_it' in flags:
8167 summary_bits.append(f"max_it={flags['-ps_ksp_max_it']}")
8168 if '-mg_level' in flags:
8169 summary_bits.append(f"mg_levels={flags['-mg_level']}")
8170 if summary_bits:
8171 print(f" - Poisson Solver: {', '.join(summary_bits)}")
8172 return flags
8173
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 
)

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.
Returns
Value returned by generate_solver_control_file().

Definition at line 8174 of file core.py.

8174def generate_solver_control_file(run_dir, run_id, configs, num_procs, monitor_files, restart_source_dir=None, continue_mode=False):
8175 """!
8176 @brief Generates the main .control file for the C-solver.
8177 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
8178 into a single, machine-readable file of command-line flags.
8179 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
8180 @param[in] run_id Argument passed to `generate_solver_control_file()`.
8181 @param[in] configs Argument passed to `generate_solver_control_file()`.
8182 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
8183 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
8184 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
8185 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
8186 @return Value returned by `generate_solver_control_file()`.
8187 """
8188 print("[INFO] Generating master solver control file...")
8189 case_cfg, solver_cfg, monitor_cfg = configs['case'], configs['solver'], configs['monitor']
8190 source_files = {'Case': configs['case_path'], 'Solver': configs['solver_path'], 'Monitor': configs['monitor_path']}
8191
8192 control_lines = []
8193 try:
8194 props, run_ctrl = case_cfg['properties'], case_cfg['run_control']
8195 scales, fluid, ic = props['scaling'], props['fluid'], props['initial_conditions']
8196 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
8197 L_ref, U_ref, rho, mu = float(scales['length_ref']), float(scales['velocity_ref']), float(fluid['density']), float(fluid['viscosity'])
8198 reynolds = (rho * U_ref * L_ref) / mu if mu != 0 else float('inf')
8199 dt_phys = float(run_ctrl['dt_physical'])
8200 T_ref = L_ref / U_ref if U_ref != 0 else float('inf')
8201 dt_nondim = dt_phys / T_ref if T_ref != float('inf') else 0.0
8202 resolved_ic = resolve_initial_condition_config(ic, prepared_blocks, U_ref)
8203 finit_mode_str = resolved_ic["label"]
8204 finit_code = resolved_ic["finit"]
8205 ic_params = resolved_ic["cli_params"]
8206 print(f" - Reynolds Number (Re) = {reynolds:.4f}")
8207 print(f" - Non-Dimensional dt* = {dt_nondim:.6f}")
8208 eulerian_source = normalize_eulerian_field_source(
8209 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
8210 )
8211 start_step = int(run_ctrl.get("start_step", 0) or 0)
8212 ic_is_authoritative = eulerian_source == "solve" and start_step == 0
8213 ic_cli = []
8214 if ic_is_authoritative:
8215 print(f" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
8216 if "ucont_x" in ic_params:
8217 ic_cli.extend([
8218 f"-ucont_x {ic_params['ucont_x']}",
8219 f"-ucont_y {ic_params['ucont_y']}",
8220 f"-ucont_z {ic_params['ucont_z']}",
8221 ])
8222 if "ic_velocity_physical" in ic_params:
8223 ic_cli.append(f"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
8224 if "flow_direction" in ic_params:
8225 ic_cli.append(f"-flow_direction {ic_params['flow_direction']}")
8226 else:
8227 print(
8228 f"[WARN] Ignoring configured initial condition '{finit_mode_str}' because "
8229 f"eulerian_field_source={eulerian_source!r} and start_step={start_step} select another source.",
8230 file=sys.stderr,
8231 )
8232 finit_code = 0
8233 control_lines.extend([
8234 f"-start_step {run_ctrl['start_step']}", f"-totalsteps {run_ctrl['total_steps']}",
8235 f"-ren {reynolds}", f"-dt {dt_nondim}", f"-finit {finit_code}",
8236 *ic_cli,
8237 f"-scaling_L_ref {L_ref}", f"-scaling_U_ref {U_ref}", f"-scaling_rho_ref {rho}"
8238 ])
8239 except (KeyError, TypeError, ZeroDivisionError, ValueError) as e:
8240 print(f"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
8241 sys.exit(1)
8242
8243 # --- CORRECTED: Add paths for whitelist and profile files ---
8244 if monitor_files.get("whitelist"):
8245 control_lines.append(f"-whitelist_config_file {monitor_files['whitelist']}")
8246 if monitor_files.get("profile"):
8247 control_lines.append(f"-profile_config_file {monitor_files['profile']}")
8248 profiling_cfg = monitor_files.get("profiling", {})
8249 control_lines.append(f"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
8250 if profiling_cfg.get("mode") != "off":
8251 control_lines.append(f"-profiling_timestep_file {profiling_cfg.get('timestep_file', 'Profiling_Timestep_Summary.csv')}")
8252 control_lines.append(f"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
8253 diagnostics_cfg = resolve_diagnostics_config(monitor_cfg)
8254 memory_log_cfg = diagnostics_cfg["runtime_memory_log"]
8255 control_lines.append(f"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
8256 control_lines.append(f"-runtime_memory_log_file {memory_log_cfg.get('file', 'Runtime_Memory.log')}")
8257
8258 walltime_guard_policy = configs.get("walltime_guard_policy")
8259 if walltime_guard_policy is not None:
8260 control_lines.extend(
8261 [
8262 f"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
8263 f"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
8264 f"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
8265 f"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
8266 f"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
8267 ]
8268 )
8269
8270 grid_cfg = case_cfg.get('grid', {})
8271 grid_mode = grid_cfg.get('mode')
8272 expected_nblk = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
8273
8274 if grid_mode == 'file':
8275 print("[INFO] Grid Mode: Using external file...")
8276 case_file_dir = os.path.dirname(configs['case_path'])
8277 source_grid = grid_cfg['source_file']
8278 if not os.path.isabs(source_grid):
8279 source_grid = os.path.abspath(os.path.join(case_file_dir, source_grid))
8280 grid_for_validation = source_grid
8281 legacy_cfg = grid_cfg.get("legacy_conversion")
8282 if isinstance(legacy_cfg, dict):
8283 if legacy_cfg.get("enabled", True):
8284 print("[INFO] Grid file legacy conversion enabled; converting with grid.gen...")
8285 grid_for_validation = convert_legacy_grid_with_gridgen(
8286 configs['case_path'],
8287 run_dir,
8288 grid_cfg,
8289 source_grid,
8290 )
8291 nondim_grid_path = os.path.join(run_dir, "config", "grid.run")
8292 try:
8293 summary = validate_and_nondimensionalize_picgrid(
8294 grid_for_validation, nondim_grid_path, L_ref, expected_nblk=expected_nblk
8295 )
8296 print(
8297 f"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
8298 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8299 )
8300 control_lines.append(f"-grid_file {nondim_grid_path}")
8301 except Exception as e:
8302 print(f"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
8303 sys.exit(1)
8304 elif grid_mode == 'grid_gen':
8305 print("[INFO] Grid Mode: Generating external grid via grid.gen...")
8306 nondim_grid_path = os.path.join(run_dir, "config", "grid.run")
8307 if continue_mode and os.path.isfile(nondim_grid_path):
8308 print(f"[INFO] Continue mode: reusing staged grid: {os.path.relpath(nondim_grid_path)}")
8309 control_lines.append(f"-grid_file {nondim_grid_path}")
8310 else:
8311 try:
8312 # grid.gen already converts ncells_* inputs into node-count PICGRID dims.
8313 generated_grid = run_grid_generator(configs['case_path'], run_dir, grid_cfg)
8314 summary = validate_and_nondimensionalize_picgrid(
8315 generated_grid, nondim_grid_path, L_ref, expected_nblk=expected_nblk
8316 )
8317 print(
8318 f"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
8319 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8320 )
8321 control_lines.append(f"-grid_file {nondim_grid_path}")
8322 except Exception as e:
8323 print(f"[FATAL] Grid generation failed: {e}", file=sys.stderr)
8324 sys.exit(1)
8325 elif grid_mode == 'programmatic_c':
8326 print("[INFO] Grid Mode: Programmatic C...")
8327 grid_settings = translate_programmatic_grid_settings(grid_cfg.get('programmatic_settings', {}))
8328 control_lines.append("-grid")
8329 for p_key in GRID_DA_PROCESSOR_KEYS:
8330 grid_settings.pop(p_key, None)
8331 for key, value in grid_settings.items(): control_lines.append(f"-{key} {format_flag_value(value)}")
8332 if resolved_ic["kind"] == "ic_gen" and ic_is_authoritative:
8333 nondim_grid_path = os.path.join(run_dir, "config", "grid.run")
8334 try:
8335 summary = generate_picgrid_from_programmatic_settings(
8336 grid_cfg.get('programmatic_settings', {}), nondim_grid_path, L_ref
8337 )
8338 print(
8339 f"[INFO] Materialized grid.run for ic_gen: {os.path.relpath(nondim_grid_path)} "
8340 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8341 )
8342 except Exception as e:
8343 print(f"[FATAL] Failed to generate grid.run for ic_gen: {e}", file=sys.stderr)
8344 sys.exit(1)
8345 else:
8346 raise ValueError(f"Unknown or missing grid mode '{grid_mode}' in case.yml.")
8347
8348 if resolved_ic["kind"] in {"file", "ic_gen"}:
8349 if ic_is_authoritative:
8350 try:
8351 staged_ic = stage_initial_condition_file(run_dir, configs["case_path"], resolved_ic)
8352 except Exception as e:
8353 print(f"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
8354 sys.exit(1)
8355 control_lines.extend([
8356 f"-ic_field {resolved_ic['field_code']}",
8357 f"-ic_dir {staged_ic['directory']}",
8358 ])
8359 print(f" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
8360
8361 try:
8362 bcs_files = generate_multi_block_bcs(run_dir, run_id, case_cfg, source_files)
8363 except ValueError as e:
8364 print(f"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
8365 sys.exit(1)
8366 control_lines.append(f"-bcs_files \"{','.join(bcs_files)}\"")
8367
8368 append_grid_da_processor_layout(control_lines, grid_cfg, num_procs)
8369
8370 parse_and_add_model_flags(case_cfg, control_lines)
8371
8372 if 'solver_parameters' in case_cfg:
8373 params = case_cfg['solver_parameters']
8374 if params:
8375 for key, value in params.items():
8376 control_lines.append(f"{key} {format_flag_value(value)}")
8377
8378 try:
8379 solver_flags = parse_solver_config(solver_cfg)
8380 except ValueError as e:
8381 print(f"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
8382 sys.exit(1)
8383 for flag, value in solver_flags.items(): control_lines.append(f"{flag} {value}")
8384
8385 try:
8386 solver_monitoring_flags = resolve_solver_monitoring_flags(monitor_cfg)
8387 except ValueError as e:
8388 print(f"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
8389 sys.exit(1)
8390 append_passthrough_flags(control_lines, solver_monitoring_flags)
8391
8392 io_cfg = monitor_cfg.get('io', {})
8393 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
8394 if 'data_output_frequency' in io_cfg: control_lines.append(f"-tio {io_cfg['data_output_frequency']}")
8395 if particle_console_output_freq is not None:
8396 control_lines.append(f"-particle_console_output_freq {particle_console_output_freq}")
8397 if 'particle_log_interval' in io_cfg: control_lines.append(f"-logfreq {io_cfg['particle_log_interval']}")
8398 if 'directories' in io_cfg:
8399 dirs = io_cfg['directories']
8400 if 'output' in dirs: control_lines.append(f"-output_dir {dirs['output']}")
8401 if 'restart' in dirs and not restart_source_dir:
8402 control_lines.append(f"-restart_dir {dirs['restart']}")
8403 if 'log' in dirs: control_lines.append(f"-log_dir {dirs['log']}")
8404 if 'eulerian_subdir' in dirs: control_lines.append(f"-euler_subdir {dirs['eulerian_subdir']}")
8405 if 'particle_subdir' in dirs: control_lines.append(f"-particle_subdir {dirs['particle_subdir']}")
8406 if restart_source_dir:
8407 control_lines.append(f"-restart_dir {restart_source_dir}")
8408 if continue_mode:
8409 control_lines.append("-continue_mode true")
8410
8411 final_content = generate_header(run_id, source_files) + "\n".join(control_lines)
8412 control_file_path = os.path.join(run_dir, "config", f"{run_id}.control")
8413 with open(control_file_path, "w") as f: f.write(final_content)
8414 print(f"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
8415 return os.path.abspath(control_file_path)
8416
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 
)

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.
Returns
The absolute path to the generated post.run recipe file.

Definition at line 8417 of file core.py.

8417def generate_post_recipe_file(run_dir: str, run_id: str, post_cfg: dict, source_files: dict, monitor_cfg=None) -> str:
8418 """!
8419 @brief Generates a key=value config file (post.run) for the C post-processor.
8420 @details Translates the structured post-processing YAML into the specific flat
8421 key-value format required by the C executable, including complex,
8422 semicolon-separated pipeline strings.
8423 @param[in] run_dir The path to the main run directory.
8424 @param[in] run_id The unique identifier for the run.
8425 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
8426 @param[in] source_files A dictionary of source files for the header.
8427 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
8428 @return The absolute path to the generated post.run recipe file.
8429 """
8430 print("[INFO] Generating post-processor recipe file (post.run)...")
8431 config_dir = os.path.join(run_dir, "config")
8432 post_recipe_path = os.path.join(config_dir, "post.run")
8433
8434 lines = [generate_header(run_id, source_files)]
8435 c_config = build_post_recipe_config(post_cfg, monitor_cfg)
8436
8437 for key, value in c_config.items():
8438 if value is not None and str(value) != "":
8439 lines.append(f"{key} = {value}")
8440
8441 with open(post_recipe_path, "w") as f:
8442 f.write("\n".join(lines))
8443 print(f"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
8444 return os.path.abspath(post_recipe_path)
8445
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 8446 of file core.py.

8446def execute_command(command: list, run_dir: str, log_filename: str, monitor_cfg: dict = None):
8447 """!
8448 @brief Executes a command, streaming its output to the console and a log file.
8449 @details ...
8450 If None, the process inherits the parent's environment directly.
8451 @param[in] command Argument passed to `execute_command()`.
8452 @param[in] run_dir Argument passed to `execute_command()`.
8453 @param[in] log_filename Argument passed to `execute_command()`.
8454 @param[in] monitor_cfg Argument passed to `execute_command()`.
8455 """
8456 log_path = resolve_command_log_path(run_dir, log_filename)
8457 os.makedirs(os.path.dirname(log_path), exist_ok=True)
8458
8459 print(f"[INFO] Launching Command...\n > {format_command_for_display(command)}")
8460 print(f" Log file: {os.path.relpath(log_path)}")
8461 print("-" * 60)
8462
8463 # --- Environment Handling ---
8464 popen_kwargs = {
8465 "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT,
8466 "cwd": run_dir, "bufsize": 1, "universal_newlines": True,
8467 "encoding": 'utf-8', "errors": 'replace'
8468 }
8469
8470 if monitor_cfg:
8471 print("[INFO] Creating custom environment to set LOG_LEVEL.")
8472 run_env = os.environ.copy()
8473 verbosity = monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()
8474 run_env['LOG_LEVEL'] = verbosity
8475 print(f"[INFO] Setting LOG_LEVEL={verbosity} for C executable.")
8476 popen_kwargs['env'] = run_env
8477 else:
8478 print("[INFO] Using inherited environment for process.")
8479
8480 print("-" * 60)
8481 try:
8482 # Pass the constructed keyword arguments dictionary to Popen
8483 process = subprocess.Popen(command, **popen_kwargs)
8484
8485 with open(log_path, "w") as log_file:
8486 for line in process.stdout:
8487 sys.stdout.write(line)
8488 log_file.write(line)
8489 process.wait()
8490 return_code = process.returncode
8491 print("-" * 60)
8492 if return_code == 0:
8493 print(f"[SUCCESS] Execution finished successfully.")
8494 else:
8495 print(f"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
8496 sys.exit(return_code)
8497 except FileNotFoundError:
8498 print(f"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
8499 print(" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
8500 sys.exit(1)
8501 except Exception as e:
8502 print(f"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
8503 sys.exit(1)
8504
8505
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 8506 of file core.py.

8506def format_command_for_display(command: list) -> str:
8507 """!
8508 @brief Render a shell-safe command string for console and log output.
8509 @param[in] command Argument passed to `format_command_for_display()`.
8510 @return Value returned by `format_command_for_display()`.
8511 """
8512 return " ".join(shlex.quote(str(part)) for part in command)
8513
8514
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 8515 of file core.py.

8515def resolve_command_log_path(run_dir: str, log_filename: str) -> str:
8516 """!
8517 @brief Resolve a command log filename relative to the run directory.
8518 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
8519 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
8520 @return Value returned by `resolve_command_log_path()`.
8521 """
8522 if os.path.dirname(log_filename):
8523 return os.path.join(run_dir, log_filename)
8524 return os.path.join(run_dir, "logs", log_filename)
8525
8526
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 8554 of file core.py.

8554def _run_captured_command(command: list, run_dir: str) -> subprocess.CompletedProcess:
8555 """!
8556 @brief Run a command and capture combined stdout/stderr details for later inspection.
8557 @param[in] command Argument passed to `_run_captured_command()`.
8558 @param[in] run_dir Argument passed to `_run_captured_command()`.
8559 @return Value returned by `_run_captured_command()`.
8560 """
8561 try:
8562 return subprocess.run(
8563 command,
8564 cwd=run_dir,
8565 text=True,
8566 capture_output=True,
8567 check=False,
8568 encoding="utf-8",
8569 errors="replace",
8570 )
8571 except FileNotFoundError as exc:
8572 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
8573
8574
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 8575 of file core.py.

8575def _require_successful_command(command: list, result: subprocess.CompletedProcess):
8576 """!
8577 @brief Raise `CommandExecutionError` when a captured command failed.
8578 @param[in] command Argument passed to `_require_successful_command()`.
8579 @param[in] result Argument passed to `_require_successful_command()`.
8580 """
8581 if result.returncode == 0:
8582 return
8583 details = (result.stderr or result.stdout).strip()
8584 raise CommandExecutionError(command, result.returncode, details or None)
8585
8586
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 8587 of file core.py.

8587def _capture_command_stdout(command: list, run_dir: str) -> str:
8588 """!
8589 @brief Run a command, require success, and return stripped stdout text.
8590 @param[in] command Argument passed to `_capture_command_stdout()`.
8591 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
8592 @return Value returned by `_capture_command_stdout()`.
8593 """
8594 result = _run_captured_command(command, run_dir)
8595 _require_successful_command(command, result)
8596 return result.stdout.strip()
8597
8598
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 8599 of file core.py.

8599def _stream_command_to_console_and_log(command: list, run_dir: str, log_file):
8600 """!
8601 @brief Stream command output to stdout and an already-open log file.
8602 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
8603 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
8604 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
8605 """
8606 display = format_command_for_display(command)
8607 print(f"[INFO] Running: {display}")
8608 log_file.write(f"$ {display}\n")
8609 log_file.flush()
8610
8611 popen_kwargs = {
8612 "stdout": subprocess.PIPE,
8613 "stderr": subprocess.STDOUT,
8614 "cwd": run_dir,
8615 "bufsize": 1,
8616 "universal_newlines": True,
8617 "encoding": "utf-8",
8618 "errors": "replace",
8619 }
8620
8621 try:
8622 process = subprocess.Popen(command, **popen_kwargs)
8623 except FileNotFoundError as exc:
8624 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
8625
8626 with process:
8627 for line in process.stdout:
8628 sys.stdout.write(line)
8629 log_file.write(line)
8630 return_code = process.wait()
8631 log_file.write("\n")
8632 log_file.flush()
8633 if return_code != 0:
8634 raise CommandExecutionError(command, return_code)
8635
8636
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 8637 of file core.py.

8637def _get_git_head_state(run_dir: str) -> dict:
8638 """!
8639 @brief Capture the current git HEAD branch name and commit hash.
8640 @param[in] run_dir Argument passed to `_get_git_head_state()`.
8641 @return Value returned by `_get_git_head_state()`.
8642 """
8643 head_commit = _capture_command_stdout(["git", "rev-parse", "--verify", "HEAD"], run_dir)
8644 branch_result = _run_captured_command(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], run_dir)
8645 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
8646 return {"branch": branch_name, "commit": head_commit}
8647
8648
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 8649 of file core.py.

8649def _get_local_branches_with_upstreams(run_dir: str) -> "list[tuple[str, str | None]]":
8650 """!
8651 @brief Return local branch names plus their configured upstreams.
8652 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
8653 @return Value returned by `_get_local_branches_with_upstreams()`.
8654 """
8655 output = _capture_command_stdout(
8656 ["git", "for-each-ref", "--sort=refname", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
8657 run_dir,
8658 )
8659 branches = []
8660 for line in output.splitlines():
8661 if not line.strip():
8662 continue
8663 branch_name, _, upstream_name = line.partition("\t")
8664 branches.append((branch_name, upstream_name or None))
8665 return branches
8666
8667
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 8668 of file core.py.

8668def _working_tree_has_tracked_changes(run_dir: str) -> bool:
8669 """!
8670 @brief Return `True` when the repository has staged or unstaged tracked changes.
8671 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
8672 @return Value returned by `_working_tree_has_tracked_changes()`.
8673 """
8674 command = ["git", "status", "--porcelain", "--untracked-files=no"]
8675 result = _run_captured_command(command, run_dir)
8676 _require_successful_command(command, result)
8677 return bool(result.stdout.strip())
8678
8679
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 8680 of file core.py.

8680def _attempt_pull_cleanup(run_dir: str, rebase: bool, log_file):
8681 """!
8682 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
8683 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
8684 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
8685 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
8686 """
8687 cleanup_command = ["git", "rebase", "--abort"] if rebase else ["git", "merge", "--abort"]
8688 result = _run_captured_command(cleanup_command, run_dir)
8689 if result.returncode == 0:
8690 print(f"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
8691 log_file.write(f"$ {format_command_for_display(cleanup_command)}\n")
8692 if result.stdout:
8693 sys.stdout.write(result.stdout)
8694 log_file.write(result.stdout)
8695 if result.stderr:
8696 sys.stderr.write(result.stderr)
8697 log_file.write(result.stderr)
8698 log_file.write("\n")
8699 log_file.flush()
8700 return
8701
8702 details = (result.stderr or result.stdout).strip()
8703 if details:
8704 message = (
8705 f"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
8706 )
8707 print(message, file=sys.stderr)
8708 log_file.write(message + "\n")
8709 log_file.flush()
8710
8711
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 8712 of file core.py.

8712def _restore_git_head(run_dir: str, original_head: dict, log_file):
8713 """!
8714 @brief Restore the repository back to the branch or detached commit it started on.
8715 @param[in] run_dir Argument passed to `_restore_git_head()`.
8716 @param[in] original_head Argument passed to `_restore_git_head()`.
8717 @param[in] log_file Argument passed to `_restore_git_head()`.
8718 """
8719 current_state = _get_git_head_state(run_dir)
8720 if original_head["branch"]:
8721 if current_state["branch"] == original_head["branch"]:
8722 return
8723 _stream_command_to_console_and_log(["git", "checkout", original_head["branch"]], run_dir, log_file)
8724 return
8725
8726 if current_state["branch"] is None and current_state["commit"] == original_head["commit"]:
8727 return
8728 _stream_command_to_console_and_log(["git", "checkout", "--detach", original_head["commit"]], run_dir, log_file)
8729
8730
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 8731 of file core.py.

8731def pull_all_source_branches(run_dir: str, log_filename: str, rebase: bool = True):
8732 """!
8733 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
8734 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
8735 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
8736 @param[in] rebase Argument passed to `pull_all_source_branches()`.
8737 """
8738 log_path = resolve_command_log_path(run_dir, log_filename)
8739 os.makedirs(os.path.dirname(log_path), exist_ok=True)
8740
8741 print("\n" + "="*23 + " PULL SOURCE STAGE " + "="*22)
8742 print("[INFO] Refreshing all local source branches that track an upstream.")
8743 print(f" Log file: {os.path.relpath(log_path)}")
8744 print("-" * 60)
8745
8746 try:
8747 original_head = _get_git_head_state(run_dir)
8748 if _working_tree_has_tracked_changes(run_dir):
8749 raise RuntimeError(
8750 "Multi-branch pull requires a clean tracked working tree in the source repository. "
8751 "Commit or stash those changes first, or rerun with --current-branch-only."
8752 )
8753 branches = _get_local_branches_with_upstreams(run_dir)
8754 except (CommandExecutionError, RuntimeError) as exc:
8755 print(f"[FATAL] {exc}", file=sys.stderr)
8756 sys.exit(getattr(exc, "returncode", 1))
8757
8758 if not branches:
8759 print("[FATAL] No local branches were found in the source repository.", file=sys.stderr)
8760 sys.exit(1)
8761
8762 if original_head["branch"]:
8763 branches = [item for item in branches if item[0] != original_head["branch"]] + [
8764 item for item in branches if item[0] == original_head["branch"]
8765 ]
8766
8767 skipped_branches = []
8768 current_operation = None
8769 pull_error = None
8770 restore_error = None
8771
8772 with open(log_path, "w", encoding="utf-8") as log_file:
8773 log_file.write(f"# PICurv pull-source all-branch sync\n")
8774 log_file.write(f"# repository: {os.path.abspath(run_dir)}\n")
8775 log_file.write(f"# started: {datetime.now().isoformat()}\n")
8776 log_file.write(
8777 f"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
8778 )
8779
8780 try:
8781 for branch_name, upstream_name in branches:
8782 if not upstream_name:
8783 warning = f"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
8784 print(warning, file=sys.stderr)
8785 log_file.write(warning + "\n")
8786 skipped_branches.append(branch_name)
8787 continue
8788
8789 print(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
8790 log_file.write(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
8791
8792 current_operation = f"checkout:{branch_name}"
8793 _stream_command_to_console_and_log(["git", "checkout", branch_name], run_dir, log_file)
8794
8795 pull_command = ["git", "pull"]
8796 if rebase:
8797 pull_command.append("--rebase")
8798 current_operation = f"pull:{branch_name}"
8799 _stream_command_to_console_and_log(pull_command, run_dir, log_file)
8800 current_operation = None
8801 except CommandExecutionError as exc:
8802 pull_error = exc
8803 if current_operation and current_operation.startswith("pull:"):
8804 _attempt_pull_cleanup(run_dir, rebase, log_file)
8805 finally:
8806 try:
8807 _restore_git_head(run_dir, original_head, log_file)
8808 except CommandExecutionError as exc:
8809 restore_error = exc
8810
8811 print("-" * 60)
8812 if pull_error:
8813 if restore_error:
8814 print(
8815 f"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
8816 f"Check log: {os.path.relpath(log_path)}",
8817 file=sys.stderr,
8818 )
8819 sys.exit(restore_error.returncode)
8820 print(
8821 f"[FATAL] Multi-branch pull failed. Original branch restored. "
8822 f"Check log: {os.path.relpath(log_path)}",
8823 file=sys.stderr,
8824 )
8825 sys.exit(pull_error.returncode)
8826
8827 if restore_error:
8828 print(
8829 f"[FATAL] Branch updates completed, but the original branch could not be restored. "
8830 f"Check log: {os.path.relpath(log_path)}",
8831 file=sys.stderr,
8832 )
8833 sys.exit(restore_error.returncode)
8834
8835 if skipped_branches:
8836 print(f"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
8837 print("[SUCCESS] All local tracking branches are up to date.")
8838
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 8839 of file core.py.

8839def auto_identify_run_inputs(config_dir: str):
8840 """!
8841 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
8842 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
8843 @return Value returned by `auto_identify_run_inputs()`.
8844 """
8845 all_yml_files = glob.glob(os.path.join(config_dir, "*.yml"))
8846 case_path, monitor_path = None, None
8847 for f_path in all_yml_files:
8848 try:
8849 content = read_yaml_file(f_path)
8850 if not isinstance(content, dict):
8851 continue
8852 if 'models' in content and 'boundary_conditions' in content:
8853 case_path = f_path
8854 elif 'io' in content and 'logging' in content:
8855 monitor_path = f_path
8856 except Exception as e:
8857 print(f"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
8858 try:
8859 solver_control_path = glob.glob(os.path.join(config_dir, "*.control"))[0]
8860 except IndexError:
8861 solver_control_path = None
8862 return case_path, monitor_path, solver_control_path
8863
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 8864 of file core.py.

8864def resolve_post_source_directory(run_dir: str, monitor_cfg: dict, post_cfg: dict, strict: bool = True) -> str:
8865 """!
8866 @brief Resolve post source directory token and optionally enforce existence.
8867 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
8868 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
8869 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
8870 @param[in] strict Argument passed to `resolve_post_source_directory()`.
8871 @return Value returned by `resolve_post_source_directory()`.
8872 """
8873 solver_output_dir_rel = monitor_cfg.get('io', {}).get('directories', {}).get('output', 'output')
8874 solver_output_dir_abs = os.path.join(run_dir, solver_output_dir_rel)
8875 source_dir_template = get_post_source_directory_template(post_cfg)
8876 if source_dir_template == '<solver_output_dir>':
8877 resolved_source_dir = solver_output_dir_abs
8878 print(f"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
8879 else:
8880 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
8881 print(f"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
8882
8883 if strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
8884 print(
8885 f"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
8886 file=sys.stderr
8887 )
8888 sys.exit(1)
8889 if not strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
8890 print("[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
8891 return resolved_source_dir
8892
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 
)

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().
Returns
Value returned by render_slurm_array_stage_script().

Definition at line 8893 of file core.py.

8904):
8905 """!
8906 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
8907 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
8908 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
8909 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
8910 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
8911 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
8912 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
8913 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
8914 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
8915 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
8916 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
8917 @return Value returned by `render_slurm_array_stage_script()`.
8918 """
8919 effective_cluster_cfg = build_serial_post_cluster_config(cluster_cfg) if stage == "post" else cluster_cfg
8920 resources = effective_cluster_cfg.get("resources", {})
8921 notifications = effective_cluster_cfg.get("notifications", {}) or {}
8922 execution = effective_cluster_cfg.get("execution", {}) or {}
8923 module_setup = execution.get("module_setup", []) or []
8924 extra_sbatch = execution.get("extra_sbatch")
8925
8926 lines = [
8927 "#!/bin/bash",
8928 f"#SBATCH --job-name={job_name}",
8929 f"#SBATCH --nodes={resources['nodes']}",
8930 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
8931 f"#SBATCH --mem={resources['mem']}",
8932 f"#SBATCH --time={resources['time']}",
8933 f"#SBATCH --output={stdout_path}",
8934 f"#SBATCH --error={stderr_path}",
8935 f"#SBATCH --account={resources['account']}",
8936 f"#SBATCH --array={array_spec}",
8937 ]
8938 partition = resources.get("partition")
8939 if partition:
8940 lines.append(f"#SBATCH --partition={partition}")
8941 mail_user = notifications.get("mail_user")
8942 mail_type = notifications.get("mail_type")
8943 if mail_user:
8944 lines.append(f"#SBATCH --mail-user={mail_user}")
8945 if mail_type:
8946 lines.append(f"#SBATCH --mail-type={mail_type}")
8947 if isinstance(extra_sbatch, dict):
8948 for key, value in extra_sbatch.items():
8949 flag = str(key)
8950 if not flag.startswith("--"):
8951 flag = f"--{flag}"
8952 if isinstance(value, bool):
8953 if value:
8954 lines.append(f"#SBATCH {flag}")
8955 elif value is not None:
8956 lines.append(f"#SBATCH {flag}={value}")
8957 elif isinstance(extra_sbatch, list):
8958 for token in extra_sbatch:
8959 lines.append(f"#SBATCH {token}")
8960
8961 lines.extend([
8962 "",
8963 "set -euo pipefail",
8964 "",
8965 f'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
8966 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
8967 'if [ -z "$LINE" ]; then',
8968 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
8969 ' exit 1',
8970 "fi",
8971 "IFS=$'\\t' read -r CASE_INDEX CASE_ID RUN_DIR CONTROL_FILE POST_RECIPE_FILE LOG_LEVEL POST_PREFIX SOLVE_DIAGNOSTIC_ARGS POST_DIAGNOSTIC_ARGS <<< \"$LINE\"",
8972 'cd "$RUN_DIR"',
8973 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
8974 ])
8975
8976 if stage == "solve":
8977 walltime_guard_exports = build_walltime_guard_exports(effective_cluster_cfg)
8978 for key, value in walltime_guard_exports.items():
8979 lines.append(f"export {key}={value}")
8980
8981 lines.append('export LOG_LEVEL="${LOG_LEVEL}"')
8982
8983 for setup_line in module_setup:
8984 lines.append(str(setup_line))
8985
8986 if stage == "solve":
8987 cmd = build_cluster_launch_command(
8988 effective_cluster_cfg,
8989 solver_exe,
8990 ["-control_file", "$CONTROL_FILE"]
8991 )
8992 else:
8993 cmd = build_cluster_launch_command(
8994 effective_cluster_cfg,
8995 post_exe,
8996 ["-control_file", "$CONTROL_FILE", "-postprocessing_config_file", "$POST_RECIPE_FILE"],
8997 force_num_procs=1,
8998 )
8999
9000 # Keep shell variables unresolved inside sbatch script.
9001 def _token(tok: str) -> str:
9002 """!
9003 @brief Perform token.
9004 @param[in] tok Argument passed to `_token()`.
9005 @return Value returned by `_token()`.
9006 """
9007 if tok.startswith("$"):
9008 return tok
9009 return shlex.quote(str(tok))
9010
9011 diag_var = "${SOLVE_DIAGNOSTIC_ARGS}" if stage == "solve" else "${POST_DIAGNOSTIC_ARGS}"
9012 command_text = " ".join(_token(t) for t in cmd)
9013 executable_token = _token(solver_exe if stage == "solve" else post_exe)
9014 # Diagnostic args must be executable args, not launcher args. Insert them
9015 # immediately before the executable's normal control/recipe options.
9016 if executable_token and command_text.count(executable_token) == 1:
9017 command_text = command_text.replace(f"{executable_token} ", f"{executable_token} {diag_var} ", 1)
9018 lines.append(f"exec {command_text}")
9019
9020 os.makedirs(os.path.dirname(script_path), exist_ok=True)
9021 with open(script_path, "w") as f:
9022 f.write("\n".join(lines) + "\n")
9023 os.chmod(script_path, 0o755)
9024
9025
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 9026 of file core.py.

9032):
9033 """!
9034 @brief Generate a single-node sbatch script that runs metrics aggregation.
9035 @param[in] script_path Path to write the sbatch script.
9036 @param[in] job_name Slurm job name.
9037 @param[in] cluster_cfg Parsed cluster YAML dictionary.
9038 @param[in] study_dir Absolute path to the study directory.
9039 @param[in] picurv_path Absolute path to the picurv script.
9040 """
9041 resources = cluster_cfg.get("resources", {})
9042 notifications = cluster_cfg.get("notifications", {}) or {}
9043 execution = cluster_cfg.get("execution", {}) or {}
9044 module_setup = execution.get("module_setup", []) or []
9045
9046 scheduler_dir = os.path.join(study_dir, "scheduler")
9047 lines = [
9048 "#!/bin/bash",
9049 f"#SBATCH --job-name={job_name}",
9050 "#SBATCH --nodes=1",
9051 "#SBATCH --ntasks-per-node=1",
9052 "#SBATCH --mem=4G",
9053 "#SBATCH --time=00:10:00",
9054 f"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
9055 f"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
9056 f"#SBATCH --account={resources['account']}",
9057 ]
9058 partition = resources.get("partition")
9059 if partition:
9060 lines.append(f"#SBATCH --partition={partition}")
9061 mail_user = notifications.get("mail_user")
9062 mail_type = notifications.get("mail_type")
9063 if mail_user:
9064 lines.append(f"#SBATCH --mail-user={mail_user}")
9065 if mail_type:
9066 lines.append(f"#SBATCH --mail-type={mail_type}")
9067
9068 lines.extend([
9069 "",
9070 "set -euo pipefail",
9071 'echo "[$(date)] Running metrics aggregation"',
9072 "",
9073 ])
9074 for setup_line in module_setup:
9075 lines.append(str(setup_line))
9076
9077 lines.append(
9078 f"exec {shlex.quote(picurv_path)} sweep --reaggregate"
9079 f" --study-dir {shlex.quote(study_dir)}"
9080 )
9081
9082 os.makedirs(os.path.dirname(script_path), exist_ok=True)
9083 with open(script_path, "w") as f:
9084 f.write("\n".join(lines) + "\n")
9085 os.chmod(script_path, 0o755)
9086
9087
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 9088 of file core.py.

9088def reduce_metric_values(values, reduction: str):
9089 """!
9090 @brief Reduce a metric series to one scalar according to the requested reducer.
9091 @param[in] values Sequence of numeric values.
9092 @param[in] reduction Reduction keyword.
9093 @return Value returned by `reduce_metric_values()`.
9094 """
9095 if not values:
9096 return None
9097 np = require_numpy()
9098 reduction = str(reduction).lower()
9099 if reduction == "mean":
9100 return float(np.mean(values))
9101 if reduction == "min":
9102 return float(np.min(values))
9103 if reduction == "max":
9104 return float(np.max(values))
9105 if reduction == "p95":
9106 return float(np.percentile(values, 95.0))
9107 return float(values[-1])
9108
9109
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 9110 of file core.py.

9110def extract_metric_from_csv(case_dir: str, spec: dict):
9111 """!
9112 @brief Extract a scalar metric from a CSV source.
9113 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
9114 @param[in] spec Argument passed to `extract_metric_from_csv()`.
9115 @return Value returned by `extract_metric_from_csv()`.
9116 """
9117 file_glob = spec.get("file_glob", "**/*_msd.csv")
9118 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
9119 if not candidates:
9120 return None
9121 csv_path = candidates[0]
9122 rows = []
9123 with open(csv_path, "r", newline="") as f:
9124 reader = csv.DictReader(f)
9125 if reader.fieldnames:
9126 for row in reader:
9127 rows.append(row)
9128 if not rows:
9129 return None
9130 column = spec.get("column")
9131 numerator_column = spec.get("numerator_column")
9132 denominator_column = spec.get("denominator_column")
9133 denominator_floor = float(spec.get("denominator_floor", 0.0) or 0.0)
9134 if not column and not numerator_column:
9135 for name in reversed(reader.fieldnames):
9136 if name and name.lower() not in {"step", "time", "timestep"}:
9137 column = name
9138 break
9139 if not column and not numerator_column:
9140 return None
9141 values = []
9142 for row in rows:
9143 try:
9144 if numerator_column:
9145 numerator = float(row[numerator_column])
9146 denominator = float(row[denominator_column])
9147 denominator = max(denominator_floor, denominator)
9148 if denominator == 0.0:
9149 continue
9150 values.append(numerator / denominator)
9151 else:
9152 values.append(float(row[column]))
9153 except Exception:
9154 continue
9155 else:
9156 return None
9157 return reduce_metric_values(values, spec.get("reduction", "last"))
9158
9159
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 9160 of file core.py.

9160def extract_metric_from_log(case_dir: str, spec: dict):
9161 """!
9162 @brief Extract a scalar metric from a log file using regex.
9163 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
9164 @param[in] spec Argument passed to `extract_metric_from_log()`.
9165 @return Value returned by `extract_metric_from_log()`.
9166 """
9167 file_glob = spec.get("file_glob", "logs/*.log")
9168 regex = spec.get("regex")
9169 if not regex:
9170 return None
9171 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
9172 if not candidates:
9173 return None
9174 pattern = re.compile(regex)
9175 values = []
9176 for path in candidates:
9177 try:
9178 with open(path, "r", encoding="utf-8", errors="replace") as f:
9179 for line in f:
9180 m = pattern.search(line)
9181 if m:
9182 try:
9183 values.append(float(m.group(1)))
9184 except Exception:
9185 pass
9186 except OSError:
9187 continue
9188 return reduce_metric_values(values, spec.get("reduction", "last"))
9189
9190
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 9191 of file core.py.

9191def normalize_metric_spec(metric):
9192 """!
9193 @brief Normalize study metric definitions to a common dictionary form.
9194 @param[in] metric Argument passed to `normalize_metric_spec()`.
9195 @return Value returned by `normalize_metric_spec()`.
9196 """
9197 if isinstance(metric, str):
9198 if metric.lower() in {"msd", "msd_final"}:
9199 return {
9200 "name": "msd_final",
9201 "source": "statistics_csv",
9202 "file_glob": "**/*_msd.csv",
9203 "reduction": "last",
9204 }
9205 return {"name": metric, "source": "log_regex", "regex": metric}
9206 return dict(metric)
9207
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 9208 of file core.py.

9208def aggregate_study_metrics(study_cfg: dict, cases: list, results_dir: str) -> str:
9209 """!
9210 @brief Collect metric values from generated case directories into one CSV.
9211 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
9212 @param[in] cases Argument passed to `aggregate_study_metrics()`.
9213 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
9214 @return Value returned by `aggregate_study_metrics()`.
9215 """
9216 metrics = study_cfg.get("metrics", [])
9217 if not metrics:
9218 metrics = ["msd_final"]
9219 normalized_specs = [normalize_metric_spec(m) for m in metrics]
9220
9221 rows = []
9222 for case in cases:
9223 row = {"case_id": case["case_id"]}
9224 for p_key, p_val in case["parameters"].items():
9225 row[p_key] = p_val
9226 for spec in normalized_specs:
9227 name = spec.get("name", "metric")
9228 source = str(spec.get("source", "")).lower()
9229 if source in {"statistics_csv", "csv"}:
9230 value = extract_metric_from_csv(case["run_dir"], spec)
9231 elif source in {"log_regex", "log"}:
9232 value = extract_metric_from_log(case["run_dir"], spec)
9233 else:
9234 value = None
9235
9236 normalize_key = spec.get("normalize_by_parameter")
9237 if value is not None and normalize_key:
9238 denom = case.get("parameters", {}).get(normalize_key)
9239 try:
9240 denom = float(denom)
9241 except Exception:
9242 denom = None
9243 if denom not in (None, 0.0):
9244 value = float(value) / denom
9245 else:
9246 value = None
9247
9248 row[name] = value
9249 rows.append(row)
9250
9251 if not rows:
9252 return None
9253
9254 all_keys = []
9255 seen = set()
9256 for row in rows:
9257 for k in row.keys():
9258 if k not in seen:
9259 seen.add(k)
9260 all_keys.append(k)
9261
9262 os.makedirs(results_dir, exist_ok=True)
9263 out_csv = os.path.join(results_dir, "metrics_table.csv")
9264 with open(out_csv, "w", newline="") as f:
9265 writer = csv.DictWriter(f, fieldnames=all_keys)
9266 writer.writeheader()
9267 writer.writerows(rows)
9268 print(f"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
9269 return out_csv
9270
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 9271 of file core.py.

9271def infer_plot_x_axis(study_cfg: dict, rows: list):
9272 """!
9273 @brief Infer x-axis key/values for study plots.
9274 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
9275 @param[in] rows Argument passed to `infer_plot_x_axis()`.
9276 @return Value returned by `infer_plot_x_axis()`.
9277 """
9278 params = get_study_parameter_keys(study_cfg)
9279 if not params or not rows:
9280 return None, None
9281
9282 study_type = study_cfg.get("study_type")
9283 if study_type == "grid_independence":
9284 has_im = "case.grid.programmatic_settings.im" in params
9285 has_jm = "case.grid.programmatic_settings.jm" in params
9286 has_km = "case.grid.programmatic_settings.km" in params
9287 if has_im and has_jm and has_km:
9288 xs = []
9289 for row in rows:
9290 try:
9291 im = float(row["case.grid.programmatic_settings.im"])
9292 jm = float(row["case.grid.programmatic_settings.jm"])
9293 km = float(row["case.grid.programmatic_settings.km"])
9294 xs.append((im * jm * km) ** (1.0 / 3.0))
9295 except Exception:
9296 return None, None
9297 return "N^(1/3)", xs
9298
9299 primary = params[0]
9300 xs = []
9301 for row in rows:
9302 try:
9303 xs.append(float(row[primary]))
9304 except Exception:
9305 return None, None
9306 return primary, xs
9307
Here is the call graph for this function:
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 9308 of file core.py.

9308def generate_study_plots(study_cfg: dict, metrics_csv: str, plots_dir: str):
9309 """!
9310 @brief Generate metric-vs-parameter plots for completed studies.
9311 @param[in] study_cfg Argument passed to `generate_study_plots()`.
9312 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
9313 @param[in] plots_dir Argument passed to `generate_study_plots()`.
9314 @return Value returned by `generate_study_plots()`.
9315 """
9316 plotting_cfg = study_cfg.get("plotting", {}) or {}
9317 if plotting_cfg.get("enabled", True) is False:
9318 print("[INFO] Plotting disabled by study.yml.")
9319 return []
9320 plt = optional_matplotlib_pyplot()
9321 if plt is None:
9322 print("[WARNING] matplotlib not available; skipping plot generation.")
9323 return []
9324 if not metrics_csv or not os.path.isfile(metrics_csv):
9325 return []
9326
9327 with open(metrics_csv, "r", newline="") as f:
9328 reader = csv.DictReader(f)
9329 rows = list(reader)
9330 if not rows:
9331 return []
9332
9333 x_name, x_values = infer_plot_x_axis(study_cfg, rows)
9334 if not x_name or x_values is None:
9335 print("[WARNING] Could not infer numeric x-axis for plots; skipping.")
9336 return []
9337
9338 metric_keys = []
9339 param_keys = get_study_parameter_keys(study_cfg)
9340 for key in rows[0].keys():
9341 if key in {"case_id"}:
9342 continue
9343 if key in param_keys:
9344 continue
9345 metric_keys.append(key)
9346
9347 out_format = plotting_cfg.get("output_format", "png")
9348 os.makedirs(plots_dir, exist_ok=True)
9349 generated = []
9350 for metric in metric_keys:
9351 y_values = []
9352 ok = True
9353 for row in rows:
9354 try:
9355 y_values.append(float(row[metric]))
9356 except Exception:
9357 ok = False
9358 break
9359 if not ok:
9360 continue
9361 plt.figure(figsize=(7.0, 4.2))
9362 plt.plot(x_values, y_values, marker="o", linewidth=1.5)
9363 plt.xlabel(x_name)
9364 plt.ylabel(metric)
9365 plt.title(f"{metric} vs {x_name}")
9366 plt.grid(True, alpha=0.3)
9367 out_path = os.path.join(plots_dir, f"{metric}_vs_{x_name.replace('/', '_')}.{out_format}")
9368 plt.tight_layout()
9369 plt.savefig(out_path, dpi=150)
9370 plt.close()
9371 generated.append(out_path)
9372 if generated:
9373 print(f"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
9374 return generated
9375
9376
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 9377 of file core.py.

9377def _command_to_string(command_tokens: list) -> str:
9378 """!
9379 @brief Render a command list as a shell-safe display string.
9380 @param[in] command_tokens Argument passed to `_command_to_string()`.
9381 @return Value returned by `_command_to_string()`.
9382 """
9383 return " ".join(shlex.quote(str(tok)) for tok in command_tokens)
9384
9385
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 9386 of file core.py.

9386def _resolve_post_source_directory_preview(run_dir: str, monitor_cfg: dict, post_cfg: dict) -> str:
9387 """!
9388 @brief Resolve post source directory without side effects or stdout/stderr output.
9389 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
9390 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
9391 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
9392 @return Value returned by `_resolve_post_source_directory_preview()`.
9393 """
9394 solver_output_dir_rel = monitor_cfg.get('io', {}).get('directories', {}).get('output', 'output')
9395 solver_output_dir_abs = os.path.join(run_dir, solver_output_dir_rel)
9396 source_dir_template = get_post_source_directory_template(post_cfg)
9397 if source_dir_template == '<solver_output_dir>':
9398 return solver_output_dir_abs
9399 return os.path.abspath(os.path.join(run_dir, source_dir_template))
9400
9401
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 9402 of file core.py.

9402def build_run_dry_plan(args) -> dict:
9403 """!
9404 @brief Build a no-write execution plan for `run --dry-run`.
9405 @param[in] args Command-line style argument list supplied to the function.
9406 @return Value returned by `build_run_dry_plan()`.
9407 """
9408 plan = {
9409 "mode": "dry-run",
9410 "created_at": datetime.now().isoformat(),
9411 "warnings": [],
9412 "inputs": {},
9413 "stages": {},
9414 "artifacts": [],
9415 }
9416
9417 if args.dry_run and args.no_submit:
9418 plan["warnings"].append("--dry-run takes precedence over --no-submit; no files will be written.")
9419
9420 cluster_mode = bool(getattr(args, "cluster", None))
9421 cluster_cfg = None
9422 cluster_path = None
9423 solver_num_procs_effective = args.num_procs
9424 post_num_procs_effective = 1
9425 run_id = None
9426 run_dir = None
9427 solver_control_path = None
9428 loaded_case_cfg = None
9429 loaded_monitor_cfg = None
9430 resolved_restart_source_dir = None
9431
9432 if cluster_mode:
9433 cluster_path = os.path.abspath(args.cluster)
9434 cluster_cfg = read_yaml_file(cluster_path)
9435 validate_cluster_config(cluster_cfg, cluster_path)
9436 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
9437 if args.scheduler and args.scheduler.lower() != scheduler_type:
9438 emit_structured_error(
9439 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9440 key="scheduler.type",
9441 file_path=cluster_path,
9442 message=f"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
9443 )
9444 sys.exit(1)
9445 if scheduler_type != "slurm":
9446 emit_structured_error(
9447 ERROR_CODE_CFG_INVALID_VALUE,
9448 key="scheduler.type",
9449 file_path=cluster_path,
9450 message=f"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.",
9451 )
9452 sys.exit(1)
9453 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
9454 if args.solve and args.num_procs not in (1, cluster_tasks):
9455 emit_structured_error(
9456 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9457 key="resources.ntasks_per_node",
9458 file_path=cluster_path,
9459 message=(
9460 "--num-procs applies to the solver stage and must be 1 (auto) or "
9461 f"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
9462 ),
9463 )
9464 sys.exit(1)
9465 if args.solve:
9466 solver_num_procs_effective = cluster_tasks
9467 plan["launch_mode"] = "slurm"
9468 plan["inputs"]["cluster"] = cluster_path
9469 else:
9470 if getattr(args, "scheduler", None):
9471 fail_cli_usage("--scheduler requires --cluster in this version.")
9472 plan["launch_mode"] = "local"
9473
9474 # --- Guard: restart flags without --solve ---
9475 if not args.solve:
9476 if getattr(args, 'restart_from', None):
9477 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
9478 if getattr(args, 'continue_run', False) and not args.post_process:
9479 print("[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
9480
9481 if args.solve:
9482 case_path = os.path.abspath(args.case)
9483 solver_path = os.path.abspath(args.solver)
9484 monitor_path = os.path.abspath(args.monitor)
9485 loaded_case_cfg = read_yaml_file(case_path)
9486 solver_cfg = read_yaml_file(solver_path)
9487 loaded_monitor_cfg = read_yaml_file(monitor_path)
9488 validate_solver_configs(loaded_case_cfg, solver_cfg, loaded_monitor_cfg, case_path, solver_path, monitor_path)
9489
9490 continue_mode = getattr(args, 'continue_run', False)
9491
9492 if continue_mode:
9493 # --continue reuses existing run directory
9494 if not args.run_dir:
9495 fail_cli_usage("--continue requires --run-dir.")
9496 run_dir = os.path.abspath(args.run_dir)
9497 if not os.path.isdir(run_dir):
9498 emit_structured_error(
9499 ERROR_CODE_CFG_FILE_NOT_FOUND,
9500 key="run-dir",
9501 file_path=run_dir,
9502 message="Specified run directory not found.",
9503 )
9504 sys.exit(1)
9505 run_id = os.path.basename(run_dir)
9506 else:
9507 case_name = os.path.splitext(os.path.basename(case_path))[0]
9508 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
9509 run_id = f"{case_name}_{timestamp}"
9510 run_dir = os.path.abspath(os.path.join("runs", run_id))
9511
9512 try:
9513 resolved_restart_source_dir, is_continue = resolve_restart_source(
9514 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir
9515 )
9516 except ValueError as e:
9517 emit_structured_error(
9518 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9519 key="restart",
9520 file_path=case_path,
9521 message=str(e),
9522 )
9523 sys.exit(1)
9524
9525 config_dir = os.path.join(run_dir, "config")
9526 scheduler_dir = os.path.join(run_dir, "scheduler")
9527 logs_dir = os.path.join(run_dir, "logs")
9528 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
9529 profile_path = os.path.join(config_dir, "profile.run")
9530 profiling_preview = resolve_profiling_config(loaded_monitor_cfg)
9531
9532 plan["run_id_preview"] = run_id
9533 plan["run_dir_preview"] = run_dir
9534 plan["inputs"].update({"case": case_path, "solver": solver_path, "monitor": monitor_path})
9535 plan["artifacts"].extend(
9536 [
9537 run_dir,
9538 config_dir,
9539 logs_dir,
9540 os.path.join(run_dir, "output"),
9541 scheduler_dir,
9542 os.path.join(config_dir, "case.yml"),
9543 os.path.join(config_dir, "solver.yml"),
9544 os.path.join(config_dir, "monitor.yml"),
9545 solver_control_path,
9546 os.path.join(run_dir, "manifest.json"),
9547 ]
9548 )
9549 add_planned_grid_artifacts(plan, loaded_case_cfg, run_dir)
9550 add_planned_profile_artifacts(plan, loaded_case_cfg, run_dir)
9551 add_planned_initial_condition_artifacts(plan, loaded_case_cfg, solver_cfg, run_dir)
9552 if has_explicit_monitor_whitelist(loaded_monitor_cfg):
9553 plan["artifacts"].append(os.path.join(config_dir, "whitelist.run"))
9554 if profiling_preview["mode"] == "selected":
9555 plan["artifacts"].append(profile_path)
9556 solve_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "Solver")
9557 plan["artifacts"].extend(solve_diagnostics["artifacts"])
9558 if cluster_mode:
9559 plan["artifacts"].append(os.path.join(config_dir, "cluster.yml"))
9560 plan["artifacts"].append(os.path.join(scheduler_dir, "submission.json"))
9561
9562 solver_exe = resolve_runtime_executable("simulator")
9563 solver_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "Solver") + ["-control_file", solver_control_path]
9564 if cluster_mode:
9565 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
9566 solver_cmd = build_cluster_launch_command(
9567 cluster_cfg,
9568 solver_exe,
9569 solver_args,
9570 config_search_anchor=case_path,
9571 extra_search_anchors=[cluster_path],
9572 )
9573 plan["artifacts"].append(solver_script)
9574 plan["stages"]["solve"] = {
9575 "mode": "slurm",
9576 "script": solver_script,
9577 "num_procs_effective": solver_num_procs_effective,
9578 "launch_command": solver_cmd,
9579 "launch_command_string": _command_to_string(solver_cmd),
9580 }
9581 else:
9582 solver_cmd = build_local_launch_command(
9583 solver_exe,
9584 solver_args,
9585 solver_num_procs_effective,
9586 config_search_anchor=case_path,
9587 )
9588 solver_stream_log = os.path.join(scheduler_dir, f"{run_id}_solver.log")
9589 plan["artifacts"].append(solver_stream_log)
9590 plan["stages"]["solve"] = {
9591 "mode": "local",
9592 "num_procs_effective": solver_num_procs_effective,
9593 "stream_log": solver_stream_log,
9594 "launch_command": solver_cmd,
9595 "launch_command_string": _command_to_string(solver_cmd),
9596 }
9597 if resolved_restart_source_dir:
9598 plan["stages"]["solve"]["restart_source_directory"] = resolved_restart_source_dir
9599 if is_continue:
9600 plan["stages"]["solve"]["continue_mode"] = True
9601
9602 if args.post_process:
9603 post_path = os.path.abspath(args.post)
9604 plan["inputs"]["post"] = post_path
9605 post_cfg = read_yaml_file(post_path)
9606 validate_post_config(post_cfg, post_path)
9607
9608 if args.run_dir:
9609 run_dir = os.path.abspath(args.run_dir)
9610 if not os.path.isdir(run_dir):
9611 emit_structured_error(
9612 ERROR_CODE_CFG_FILE_NOT_FOUND,
9613 key="run-dir",
9614 file_path=run_dir,
9615 message="Specified run directory not found.",
9616 )
9617 sys.exit(1)
9618 run_id = os.path.basename(run_dir)
9619 elif not args.solve:
9620 fail_cli_usage("--post-process requires --run-dir when not used with --solve.")
9621
9622 if args.run_dir:
9623 config_dir = os.path.join(run_dir, "config")
9624 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
9625 if not all([case_path, monitor_path, solver_control_path]):
9626 emit_structured_error(
9627 ERROR_CODE_CFG_MISSING_KEY,
9628 key="run_dir.config",
9629 file_path=config_dir,
9630 message=(
9631 "Could not auto-identify required run inputs "
9632 "(case.yml/monitor.yml/*.control) in run config directory."
9633 ),
9634 )
9635 sys.exit(1)
9636 loaded_case_cfg = read_yaml_file(case_path)
9637 loaded_monitor_cfg = read_yaml_file(monitor_path)
9638 else:
9639 config_dir = os.path.join(run_dir, "config")
9640 case_path = os.path.join(config_dir, "case.yml")
9641 monitor_path = os.path.join(config_dir, "monitor.yml")
9642 if solver_control_path is None:
9643 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
9644
9645 allow_source_frontier_scan = not args.solve
9646 post_plan = build_post_execution_plan(
9647 run_dir,
9648 run_id,
9649 loaded_case_cfg,
9650 loaded_monitor_cfg,
9651 post_cfg,
9652 continue_requested=getattr(args, 'continue_run', False),
9653 allow_source_frontier_scan=allow_source_frontier_scan,
9654 )
9655
9656 post_recipe_path = os.path.join(config_dir, "post.run")
9657 output_dir_rel = post_cfg.get("io", {}).get("output_directory")
9658 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix")
9659 if not output_dir_rel or not output_prefix:
9660 emit_structured_error(
9661 ERROR_CODE_CFG_MISSING_KEY,
9662 key="io.output_directory/io.output_filename_prefix",
9663 file_path=post_path,
9664 message="Missing required post IO keys.",
9665 )
9666 sys.exit(1)
9667 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
9668 statistics_output_paths = get_post_statistics_output_artifacts(post_cfg, run_dir, loaded_monitor_cfg)
9669 post_exe = resolve_runtime_executable("postprocessor")
9670 post_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "PostProcessor")
9671 plan["artifacts"].extend(post_diagnostics["artifacts"])
9672 post_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "PostProcessor") + [
9673 "-control_file",
9674 solver_control_path,
9675 "-postprocessing_config_file",
9676 post_recipe_path,
9677 ]
9678 plan["artifacts"].extend([
9679 post_recipe_path,
9680 output_dir_abs,
9681 post_plan["resume_state_path"],
9682 post_plan["lock_paths"]["wrapper_path"],
9683 post_plan["lock_paths"]["lock_file"],
9684 post_plan["lock_paths"]["metadata_file"],
9685 ])
9686 plan["artifacts"].extend(statistics_output_paths)
9687
9688 stage_meta = {
9689 "source_data_directory": post_plan["source_data_directory"],
9690 "requested_start_step": post_plan["requested_start_step"],
9691 "requested_end_step": post_plan["requested_end_step"],
9692 "step_interval": post_plan["step_interval"],
9693 "resume_applied": bool(post_plan["continue_requested"] and post_plan["resume_recipe_match"]),
9694 "resume_recipe_match": post_plan["resume_recipe_match"],
9695 "resume_bootstrapped": post_plan["resume_bootstrapped"],
9696 "resume_match_source": post_plan["resume_match_source"],
9697 "completed_frontier_step": post_plan["completed_frontier_step"],
9698 "source_frontier_step": post_plan["source_frontier_step"],
9699 "source_frontier_diagnostic": post_plan["source_frontier_diagnostic"],
9700 "source_frontier_deferred": post_plan["source_frontier_deferred"],
9701 "effective_start_step": post_plan["effective_start_step"],
9702 "effective_end_step": post_plan["effective_end_step"],
9703 "skip_reason": post_plan["skip_reason"],
9704 "post_skipped_as_complete": post_plan["skip_reason"] == "already-complete-window",
9705 "recipe_fingerprint": post_plan["recipe_fingerprint"],
9706 "num_procs_effective": post_num_procs_effective,
9707 }
9708
9709 if post_plan["skip_reason"] is None:
9710 if cluster_mode:
9711 scheduler_dir = os.path.join(run_dir, "scheduler")
9712 post_script = os.path.join(scheduler_dir, "post.sbatch")
9713 post_cluster_cfg = build_serial_post_cluster_config(cluster_cfg, post_num_procs_effective)
9714 raw_post_cmd = build_cluster_launch_command(
9715 post_cluster_cfg,
9716 post_exe,
9717 post_args,
9718 config_search_anchor=case_path,
9719 extra_search_anchors=[cluster_path],
9720 force_num_procs=post_num_procs_effective,
9721 )
9722 post_cmd, _ = build_post_locked_command(
9723 run_dir,
9724 post_plan["recipe_fingerprint"],
9725 raw_post_cmd,
9726 create_wrapper=False,
9727 )
9728 plan["artifacts"].append(post_script)
9729 stage_meta.update({
9730 "mode": "slurm",
9731 "script": post_script,
9732 "launch_command": post_cmd,
9733 "launch_command_string": _command_to_string(post_cmd),
9734 })
9735 else:
9736 raw_post_cmd = build_local_launch_command(
9737 post_exe,
9738 post_args,
9739 post_num_procs_effective,
9740 config_search_anchor=case_path,
9741 allow_single_rank_launcher_override=True,
9742 force_num_procs=post_num_procs_effective,
9743 )
9744 post_cmd, _ = build_post_locked_command(
9745 run_dir,
9746 post_plan["recipe_fingerprint"],
9747 raw_post_cmd,
9748 create_wrapper=False,
9749 )
9750 post_stream_log = os.path.join(run_dir, "scheduler", f"{run_id}_{output_prefix}.log")
9751 plan["artifacts"].append(post_stream_log)
9752 stage_meta.update({
9753 "mode": "local",
9754 "stream_log": post_stream_log,
9755 "launch_command": post_cmd,
9756 "launch_command_string": _command_to_string(post_cmd),
9757 })
9758 else:
9759 stage_meta.update({
9760 "mode": "slurm" if cluster_mode else "local",
9761 "launch_command": [],
9762 "launch_command_string": "",
9763 })
9764
9765 plan["stages"]["post-process"] = stage_meta
9766
9767 # Preserve insertion order while removing duplicates.
9768 deduped = []
9769 seen = set()
9770 for item in plan["artifacts"]:
9771 if item not in seen:
9772 seen.add(item)
9773 deduped.append(item)
9774 plan["artifacts"] = deduped
9775 if run_id and "run_id_preview" not in plan:
9776 plan["run_id_preview"] = run_id
9777 if run_dir and "run_dir_preview" not in plan:
9778 plan["run_dir_preview"] = run_dir
9779 plan["solver_num_procs_effective"] = solver_num_procs_effective
9780 plan["post_num_procs_effective"] = post_num_procs_effective
9781 plan["num_procs_effective"] = solver_num_procs_effective
9782 return plan
9783
9784
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 9785 of file core.py.

9785def add_planned_grid_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
9786 """!
9787 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
9788 @param[in,out] plan Dry-run plan to update.
9789 @param[in] case_cfg Parsed case configuration.
9790 @param[in] run_dir Preview run directory for relative artifact resolution.
9791 """
9792 grid_cfg = case_cfg.get("grid", {})
9793 if not isinstance(grid_cfg, dict):
9794 return
9795
9796 mode = grid_cfg.get("mode")
9797 config_dir = os.path.join(run_dir, "config")
9798
9799 if mode == "file":
9800 plan["artifacts"].append(os.path.join(config_dir, "grid.run"))
9801 legacy_cfg = grid_cfg.get("legacy_conversion")
9802 if isinstance(legacy_cfg, dict) and legacy_cfg.get("enabled", True):
9803 output_file = legacy_cfg.get("output_file", os.path.join("config", "grid.converted.picgrid"))
9804 if isinstance(output_file, str) and output_file.strip():
9805 if not os.path.isabs(output_file):
9806 output_file = os.path.abspath(os.path.join(run_dir, output_file))
9807 plan["artifacts"].append(output_file)
9808 elif mode == "grid_gen":
9809 generator = grid_cfg.get("generator", {})
9810 if not isinstance(generator, dict):
9811 return
9812 plan["artifacts"].append(os.path.join(config_dir, "grid.run"))
9813 for key, default in (
9814 ("output_file", os.path.join("config", "grid.generated.picgrid")),
9815 ("stats_file", None),
9816 ("vts_file", None),
9817 ):
9818 artifact_path = generator.get(key, default)
9819 if isinstance(artifact_path, str) and artifact_path.strip():
9820 if not os.path.isabs(artifact_path):
9821 artifact_path = os.path.abspath(os.path.join(run_dir, artifact_path))
9822 plan["artifacts"].append(artifact_path)
9823
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 9824 of file core.py.

9824def add_planned_profile_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
9825 """!
9826 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
9827 @param[in,out] plan Dry-run plan to update.
9828 @param[in] case_cfg Parsed case configuration.
9829 @param[in] run_dir Preview run directory.
9830 """
9831 try:
9832 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
9833 except ValueError:
9834 return
9835 config_dir = os.path.join(run_dir, "config")
9836 has_generated = False
9837 for block_idx, block in enumerate(prepared_blocks):
9838 for bc in block:
9839 if bc.get("handler") != "prescribed_flow":
9840 continue
9841 source = (bc.get("params") or {}).get("source", {})
9842 if source.get("type") not in {"generated", "field_slice"}:
9843 continue
9844 has_generated = True
9845 face_token = _face_artifact_token(bc["face"])
9846 suffix = "generated" if source.get("type") == "generated" else "sliced"
9847 default_output = os.path.join(
9848 "config", f"inlet_profile_block{block_idx}_{face_token}.{suffix}.picslice"
9849 )
9850 generated_path = _resolve_run_artifact_path(
9851 run_dir,
9852 source.get("output_file"),
9853 default_output,
9854 default_to_config_dir=True,
9855 )
9856 staged_path = os.path.join(config_dir, f"inlet_profile_block{block_idx}_{face_token}.picslice")
9857 plan["artifacts"].append(generated_path)
9858 plan["artifacts"].append(staged_path)
9859 if has_generated:
9860 plan["artifacts"].append(os.path.join(config_dir, "profile.info"))
9861
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 9862 of file core.py.

9862def add_planned_initial_condition_artifacts(plan: dict, case_cfg: dict, solver_cfg: dict, run_dir: str) -> None:
9863 """!
9864 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
9865 @param[in,out] plan Dry-run plan receiving artifact paths.
9866 @param[in] case_cfg Parsed case configuration.
9867 @param[in] solver_cfg Parsed solver configuration.
9868 @param[in] run_dir Planned run directory.
9869 """
9870 source = normalize_eulerian_field_source(
9871 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
9872 )
9873 start_step = int((case_cfg.get("run_control", {}) or {}).get("start_step", 0) or 0)
9874 if source != "solve" or start_step != 0:
9875 return
9876 try:
9877 resolved = resolve_initial_condition_config(
9878 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
9879 validate_and_prepare_boundary_conditions(case_cfg),
9880 U_ref=1.0,
9881 )
9882 except (KeyError, ValueError):
9883 return
9884 if resolved["kind"] not in {"file", "ic_gen"}:
9885 return
9886 config_dir = os.path.join(run_dir, "config")
9887 plan["artifacts"].append(
9888 os.path.join(config_dir, "initial_condition", f"{resolved['field_name']}00000_0.dat")
9889 )
9890 if resolved["kind"] == "ic_gen":
9891 plan["artifacts"].append(_resolve_run_artifact_path(
9892 run_dir, resolved.get("output_file"), os.path.join("config", "initial_condition.generated.dat"),
9893 default_to_config_dir=True,
9894 ))
9895
9896
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]planArgument passed to render_run_dry_plan().
[in]output_formatArgument passed to render_run_dry_plan().

Definition at line 9897 of file core.py.

9897def render_run_dry_plan(plan: dict, output_format: str = "text"):
9898 """!
9899 @brief Render dry-run plan in human or JSON format.
9900 @param[in] plan Argument passed to `render_run_dry_plan()`.
9901 @param[in] output_format Argument passed to `render_run_dry_plan()`.
9902 """
9903 if output_format == "json":
9904 print(json.dumps(plan, indent=2, sort_keys=True))
9905 return
9906
9907 print("\n" + "=" * 60)
9908 print(" DRY-RUN PLAN")
9909 print("=" * 60)
9910 print(f" Launch mode : {plan.get('launch_mode')}")
9911 print(f" Created at : {plan.get('created_at')}")
9912 if plan.get("run_id_preview"):
9913 print(f" Run ID preview : {plan.get('run_id_preview')}")
9914 if plan.get("run_dir_preview"):
9915 print(f" Run dir preview: {plan.get('run_dir_preview')}")
9916 print(f" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
9917 print(f" Post MPI procs : {plan.get('post_num_procs_effective')}")
9918 if plan.get("warnings"):
9919 print(" Warnings :")
9920 for warning in plan["warnings"]:
9921 print(f" - {warning}")
9922
9923 if plan.get("inputs"):
9924 print("\n Inputs:")
9925 for key, value in plan["inputs"].items():
9926 print(f" - {key}: {value}")
9927
9928 if plan.get("stages"):
9929 print("\n Planned stage commands:")
9930 for stage, details in plan["stages"].items():
9931 print(f" - {stage} ({details.get('mode')}):")
9932 if details.get('skip_reason'):
9933 print(f" skipped: {details.get('skip_reason')}")
9934 else:
9935 print(f" {details.get('launch_command_string')}")
9936
9937 diagnostics_artifacts = [item for item in plan.get("artifacts", []) if "PETSc_" in os.path.basename(str(item)) or os.path.basename(str(item)) == "Runtime_Memory.log"]
9938 if diagnostics_artifacts:
9939 print("\n Diagnostics artifacts:")
9940 for artifact in diagnostics_artifacts:
9941 print(f" - {artifact}")
9942
9943 print("\n Planned artifacts (no files created in dry-run):")
9944 for artifact in plan.get("artifacts", []):
9945 print(f" - {artifact}")
9946 print("=" * 60)
9947
9948
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 9949 of file core.py.

9949def validate_workflow(args):
9950 """!
9951 @brief Implements `picurv validate` without launching solver/post workflows.
9952 @param[in] args Command-line style argument list supplied to the function.
9953 """
9954 checked = []
9955 solver_group_selected = any([args.case, args.solver, args.monitor])
9956 any_group_selected = solver_group_selected or any([args.post, args.cluster, args.study])
9957 case_path = None
9958 cluster_path = None
9959
9960 if not any_group_selected:
9961 fail_cli_usage(
9962 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
9963 hint="Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
9964 )
9965
9966 if solver_group_selected and not all([args.case, args.solver, args.monitor]):
9967 fail_cli_usage("When solver validation is requested, --case, --solver, and --monitor are all required.")
9968
9969 # --- Guard: restart flags without solver group ---
9970 restart_from = getattr(args, 'restart_from', None)
9971 continue_run = getattr(args, 'continue_run', False)
9972 run_dir_val = getattr(args, 'run_dir', None)
9973 if not solver_group_selected:
9974 if restart_from:
9975 print("[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
9976 if continue_run and not args.post:
9977 print("[WARNING] --continue has no effect without solver configs or --post and will be ignored.", file=sys.stderr)
9978 if continue_run and not run_dir_val:
9979 fail_cli_usage("--continue requires --run-dir.")
9980
9981 if solver_group_selected:
9982 case_path = os.path.abspath(args.case)
9983 solver_path = os.path.abspath(args.solver)
9984 monitor_path = os.path.abspath(args.monitor)
9985 case_cfg = read_yaml_file(case_path)
9986 solver_cfg = read_yaml_file(solver_path)
9987 monitor_cfg = read_yaml_file(monitor_path)
9988 validate_solver_configs(case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path)
9989 checked.extend([case_path, solver_path, monitor_path])
9990
9991 # Validate restart flags if provided
9992 if restart_from or continue_run:
9993 target_run_dir = os.path.abspath(run_dir_val) if run_dir_val else os.path.abspath("runs/_validate_dummy")
9994 try:
9995 resolve_restart_source(args, case_cfg, solver_cfg, monitor_cfg, target_run_dir)
9996 print("[SUCCESS] Restart source validation passed.")
9997 except ValueError as e:
9998 print(f"[ERROR] Restart validation failed: {e}", file=sys.stderr)
9999 sys.exit(1)
10000
10001 post_cfg = None
10002 if args.post:
10003 post_path = os.path.abspath(args.post)
10004 post_cfg = read_yaml_file(post_path)
10005 validate_post_config(post_cfg, post_path)
10006 checked.append(post_path)
10007
10008 cluster_cfg = None
10009 if args.cluster:
10010 cluster_path = os.path.abspath(args.cluster)
10011 cluster_cfg = read_yaml_file(cluster_path)
10012 validate_cluster_config(cluster_cfg, cluster_path)
10013 checked.append(cluster_path)
10014
10015 try:
10016 runtime_execution_path, _ = load_runtime_execution_config(
10017 case_path,
10018 extra_search_anchors=[cluster_path] if cluster_path else None,
10019 )
10020 except ValueError as exc:
10021 emit_structured_error(
10022 ERROR_CODE_CFG_INVALID_VALUE,
10023 key="runtime_execution",
10024 file_path=case_path or cluster_path or os.getcwd(),
10025 message=str(exc),
10026 )
10027 sys.exit(1)
10028 if runtime_execution_path:
10029 checked.append(runtime_execution_path)
10030
10031 study_cfg = None
10032 if args.study:
10033 study_path = os.path.abspath(args.study)
10034 study_cfg = read_yaml_file(study_path)
10035 validate_study_config(study_cfg, study_path)
10036 checked.append(study_path)
10037
10038 if post_cfg is not None and run_dir_val:
10039 post_path = os.path.abspath(args.post)
10040 validate_run_dir = os.path.abspath(run_dir_val)
10041 if os.path.isdir(validate_run_dir):
10042 monitor_for_post = monitor_cfg if solver_group_selected else None
10043 if monitor_for_post is None:
10044 config_dir_candidate = os.path.join(validate_run_dir, "config")
10045 monitor_candidate = os.path.join(config_dir_candidate, "monitor.yml")
10046 if os.path.isfile(monitor_candidate):
10047 monitor_for_post = read_yaml_file(monitor_candidate)
10048 if monitor_for_post is not None:
10049 resolved_source = _resolve_post_source_directory_preview(validate_run_dir, monitor_for_post, post_cfg)
10050 if os.path.isdir(resolved_source) and os.listdir(resolved_source):
10051 print(f"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
10052 else:
10053 print(f"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
10054
10055 if args.strict and post_cfg is not None:
10056 post_path = os.path.abspath(args.post)
10057 source_dir = post_cfg.get("source_data", {}).get("directory")
10058 if source_dir and source_dir != "<solver_output_dir>":
10059 resolved = resolve_path(post_path, source_dir)
10060 if not os.path.isdir(resolved):
10061 emit_structured_error(
10062 ERROR_CODE_CFG_FILE_NOT_FOUND,
10063 key="source_data.directory",
10064 file_path=post_path,
10065 message=f"strict mode: source_data.directory resolves to missing directory '{resolved}'.",
10066 )
10067 sys.exit(1)
10068
10069 if args.strict and study_cfg is not None:
10070 study_path = os.path.abspath(args.study)
10071 base_cfgs = study_cfg.get("base_configs", {})
10072 if isinstance(base_cfgs, dict):
10073 base_case_path = resolve_path(study_path, base_cfgs.get("case"))
10074 base_solver_path = resolve_path(study_path, base_cfgs.get("solver"))
10075 base_monitor_path = resolve_path(study_path, base_cfgs.get("monitor"))
10076 base_post_path = resolve_path(study_path, base_cfgs.get("post"))
10077 if all([base_case_path, base_solver_path, base_monitor_path]):
10078 validate_solver_configs(
10079 read_yaml_file(base_case_path),
10080 read_yaml_file(base_solver_path),
10081 read_yaml_file(base_monitor_path),
10082 base_case_path,
10083 base_solver_path,
10084 base_monitor_path,
10085 )
10086 if base_post_path:
10087 validate_post_config(read_yaml_file(base_post_path), base_post_path)
10088
10089 print(f"[SUCCESS] Validation completed for {len(checked)} file(s).")
10090 for path in checked:
10091 print(f" - {path}")
10092
Here is the call graph for this function:
Here is the caller graph for this function:

◆ precompute_workflow()

picurv_cli.core.precompute_workflow (   args)

Generate deterministic case artifacts without launching solver/post stages.

Parameters
[in]argsParsed precompute command arguments.

Definition at line 10093 of file core.py.

10093def precompute_workflow(args):
10094 """!
10095 @brief Generate deterministic case artifacts without launching solver/post stages.
10096 @param[in] args Parsed precompute command arguments.
10097 """
10098 case_path = os.path.abspath(args.case)
10099 case_cfg = read_yaml_file(case_path)
10100 case_name = os.path.splitext(os.path.basename(case_path))[0]
10101 output_dir = args.output_dir or os.path.join("precomputed", case_name)
10102 output_dir = os.path.abspath(output_dir)
10103 config_dir = os.path.join(output_dir, "config")
10104 os.makedirs(config_dir, exist_ok=True)
10105
10106 print(f"[INFO] Precomputing deterministic artifacts for case: {case_path}")
10107 print(f"[INFO] Output directory: {output_dir}")
10108 validate_and_prepare_boundary_conditions(case_cfg)
10109
10110 artifacts = []
10111 grid_cfg = case_cfg.get("grid", {}) or {}
10112 grid_mode = grid_cfg.get("mode")
10113 scaling = (case_cfg.get("properties", {}) or {}).get("scaling", {}) or {}
10114 length_ref = float(scaling.get("length_ref", 1.0))
10115 expected_nblk = int((case_cfg.get("models", {}) or {}).get("domain", {}).get("blocks", 1))
10116 staged_grid = os.path.join(config_dir, "grid.run")
10117 if grid_mode == "grid_gen":
10118 print("[INFO] Precomputing grid via grid.gen...")
10119 generated_grid = run_grid_generator(case_path, output_dir, grid_cfg)
10120 artifacts.append(os.path.abspath(generated_grid))
10121 validate_and_nondimensionalize_picgrid(generated_grid, staged_grid, length_ref, expected_nblk=expected_nblk)
10122 artifacts.append(os.path.abspath(staged_grid))
10123 elif grid_mode == "file":
10124 source_grid = _resolve_case_relative_path(grid_cfg.get("source_file"), os.path.dirname(case_path))
10125 if isinstance(grid_cfg.get("legacy_conversion"), dict):
10126 source_grid = convert_legacy_grid_with_gridgen(case_path, output_dir, grid_cfg, source_grid)
10127 validate_and_nondimensionalize_picgrid(source_grid, staged_grid, length_ref, expected_nblk=expected_nblk)
10128 artifacts.append(os.path.abspath(staged_grid))
10129 print(f"[INFO] Precomputed validated file grid: {staged_grid}")
10130 elif grid_mode == "programmatic_c":
10131 print(f"[INFO] Grid mode '{grid_mode}' does not require precomputed grid generation.")
10132 else:
10133 raise ValueError(f"Unsupported grid.mode '{grid_mode}' for precompute.")
10134
10135 profile_summaries = materialize_generated_prescribed_flow_profiles(output_dir, case_cfg, case_path)
10136 artifacts.extend(summary["path"] for summary in profile_summaries)
10137 if profile_summaries:
10138 artifacts.append(os.path.join(config_dir, "profile.info"))
10139
10140 initial_condition = None
10141 resolved_ic = resolve_initial_condition_config(
10142 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
10143 validate_and_prepare_boundary_conditions(case_cfg),
10144 U_ref=float((case_cfg.get("properties", {}).get("scaling", {}) or {}).get("velocity_ref", 1.0)),
10145 )
10146 if resolved_ic["kind"] == "ic_gen":
10147 if grid_mode == "programmatic_c":
10148 try:
10149 summary = generate_picgrid_from_programmatic_settings(
10150 grid_cfg.get('programmatic_settings', {}), staged_grid, length_ref
10151 )
10152 artifacts.append(os.path.abspath(staged_grid))
10153 print(
10154 f"[INFO] Materialized programmatic grid.run for ic_gen: {staged_grid} "
10155 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
10156 )
10157 except Exception as e:
10158 raise RuntimeError(f"Failed to generate grid.run for ic_gen: {e}") from e
10159 initial_condition = stage_initial_condition_file(output_dir, case_path, resolved_ic)
10160 artifacts.extend([initial_condition["source"], initial_condition["staged"]])
10161 elif resolved_ic["kind"] == "file":
10162 print("[INFO] File initial condition does not require generated precompute output.")
10163 else:
10164 print(f"[INFO] Built-in initial-condition generator '{resolved_ic['label']}' runs in the C solver.")
10165
10166 manifest = {
10167 "case": case_path,
10168 "output_dir": output_dir,
10169 "grid_mode": grid_mode,
10170 "artifacts": artifacts,
10171 "profiles": profile_summaries,
10172 "initial_condition": initial_condition,
10173 }
10174 manifest_path = os.path.join(config_dir, "precompute.manifest.json")
10175 write_json_file(manifest_path, manifest)
10176 print(f"[SUCCESS] Wrote precompute manifest: {os.path.relpath(manifest_path)}")
10177 print(f"[SUCCESS] Precompute completed with {len(artifacts)} artifact(s).")
10178
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 10179 of file core.py.

10179def run_workflow(args):
10180 """!
10181 @brief Main orchestrator for the 'run' command (local and Slurm modes).
10182 @param[in] args Command-line style argument list supplied to the function.
10183 """
10184 if getattr(args, "dry_run", False):
10185 plan = build_run_dry_plan(args)
10186 render_run_dry_plan(plan, output_format=getattr(args, "output_format", "text"))
10187 return
10188
10189 run_dir = None
10190 run_id = None
10191 output_dir_abs = None
10192 statistics_output_paths = []
10193 workflow_start = time.time()
10194 stages_completed = []
10195 configs = None
10196 submission_meta = {"launch_mode": "local", "no_submit": bool(args.no_submit), "stages": {}}
10197
10198 cluster_mode = bool(getattr(args, "cluster", None))
10199 cluster_cfg = None
10200 cluster_path = None
10201 solver_num_procs_effective = args.num_procs
10202 post_num_procs_effective = 1
10203
10204 if cluster_mode:
10205 cluster_path = os.path.abspath(args.cluster)
10206 cluster_cfg = read_yaml_file(cluster_path)
10207 validate_cluster_config(cluster_cfg, cluster_path)
10208 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
10209 if args.scheduler and args.scheduler.lower() != scheduler_type:
10210 print(
10211 f"[FATAL] --scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
10212 file=sys.stderr
10213 )
10214 sys.exit(1)
10215 if scheduler_type != "slurm":
10216 print(f"[FATAL] Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.", file=sys.stderr)
10217 sys.exit(1)
10218 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
10219 if args.solve and args.num_procs not in (1, cluster_tasks):
10220 print(
10221 "[FATAL] In cluster mode, --num-procs applies to the solver stage and must be "
10222 f"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
10223 file=sys.stderr
10224 )
10225 sys.exit(1)
10226 if args.solve:
10227 solver_num_procs_effective = cluster_tasks
10228 submission_meta["launch_mode"] = "slurm"
10229 submission_meta["cluster_config"] = cluster_path
10230 submission_meta["no_submit"] = bool(args.no_submit)
10231 if args.solve:
10232 print(
10233 f"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks "
10234 f"from cluster.yml; post stage defaults to {post_num_procs_effective} task."
10235 )
10236 else:
10237 print(f"[INFO] Cluster mode enabled (Slurm). Post stage defaults to {post_num_procs_effective} task.")
10238 elif getattr(args, "scheduler", None):
10239 print("[FATAL] --scheduler requires --cluster in this version.", file=sys.stderr)
10240 sys.exit(1)
10241
10242 # --- Guard: restart flags without --solve ---
10243 if not args.solve:
10244 if getattr(args, 'restart_from', None):
10245 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
10246 if getattr(args, 'continue_run', False) and not args.post_process:
10247 print("[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
10248
10249 # --- Stage 1: Solver (if requested) ---
10250 if args.solve:
10251 walltime_guard_policy = resolve_walltime_guard_policy(cluster_cfg) if cluster_mode else None
10252 configs = {
10253 'case': read_yaml_file(args.case), 'case_path': os.path.abspath(args.case),
10254 'solver': read_yaml_file(args.solver), 'solver_path': os.path.abspath(args.solver),
10255 'monitor': read_yaml_file(args.monitor), 'monitor_path': os.path.abspath(args.monitor),
10256 'walltime_guard_policy': walltime_guard_policy,
10257 }
10258
10259 print("\n[INFO] Validating configuration files...")
10260 validate_solver_configs(
10261 configs['case'], configs['solver'], configs['monitor'],
10262 args.case, args.solver, args.monitor
10263 )
10264 print("[SUCCESS] All configuration files passed validation.\n")
10265
10266 continue_mode = getattr(args, 'continue_run', False)
10267
10268 if continue_mode:
10269 if not args.run_dir:
10270 fail_cli_usage("--continue requires --run-dir.")
10271 run_dir = os.path.abspath(args.run_dir)
10272 if not os.path.isdir(run_dir):
10273 emit_structured_error(
10274 ERROR_CODE_CFG_FILE_NOT_FOUND,
10275 key="run-dir",
10276 file_path=run_dir,
10277 message="Specified run directory not found.",
10278 )
10279 sys.exit(1)
10280 run_id = os.path.basename(run_dir)
10281 else:
10282 case_name = os.path.splitext(os.path.basename(args.case))[0]
10283 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
10284 run_id = f"{case_name}_{timestamp}"
10285 run_dir = os.path.abspath(os.path.join("runs", run_id))
10286
10287 try:
10288 resolved_restart_source_dir, is_continue = resolve_restart_source(
10289 args, configs["case"], configs["solver"], configs["monitor"], run_dir
10290 )
10291 except ValueError as e:
10292 emit_structured_error(
10293 ERROR_CODE_CFG_INCONSISTENT_COMBO,
10294 key="restart",
10295 file_path=args.case,
10296 message=str(e),
10297 )
10298 sys.exit(1)
10299
10300 config_dir = os.path.join(run_dir, "config")
10301 if not continue_mode:
10302 for d in [config_dir, os.path.join(run_dir, "scheduler")]:
10303 os.makedirs(d, exist_ok=True)
10304 else:
10305 os.makedirs(config_dir, exist_ok=True)
10306 if continue_mode:
10307 print(f"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
10308 else:
10309 print(f"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
10310
10311 shutil.copy(args.case, os.path.join(config_dir, "case.yml"))
10312 shutil.copy(args.solver, os.path.join(config_dir, "solver.yml"))
10313 shutil.copy(args.monitor, os.path.join(config_dir, "monitor.yml"))
10314 if cluster_mode:
10315 shutil.copy(cluster_path, os.path.join(config_dir, "cluster.yml"))
10316
10317 print("\n" + "="*25 + " SOLVER STAGE " + "="*25)
10318 source_files = {'Case': args.case, 'Solver': args.solver, 'Monitor': args.monitor}
10319 monitor_files = prepare_monitor_files(run_dir, run_id, configs['monitor'], source_files)
10320 if resolved_restart_source_dir:
10321 print(f"[INFO] Restart source: {resolved_restart_source_dir}")
10322 if is_continue:
10323 print("[INFO] Continue mode: logs will be appended, not overwritten.")
10324 control_file = generate_solver_control_file(
10325 run_dir,
10326 run_id,
10327 configs,
10328 solver_num_procs_effective,
10329 monitor_files,
10330 restart_source_dir=resolved_restart_source_dir,
10331 continue_mode=is_continue,
10332 )
10333
10334 solver_exe = resolve_runtime_executable("simulator")
10335 solver_args = build_petsc_diagnostics_args(configs["monitor"], run_dir, "Solver") + ["-control_file", control_file]
10336 if cluster_mode:
10337 scheduler_dir = os.path.join(run_dir, "scheduler")
10338 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
10339 solver_log = os.path.join(scheduler_dir, "solver_%j.out")
10340 solver_err = os.path.join(scheduler_dir, "solver_%j.err")
10341 solver_cmd = build_cluster_launch_command(
10342 cluster_cfg,
10343 solver_exe,
10344 solver_args,
10345 config_search_anchor=args.case,
10346 extra_search_anchors=[cluster_path],
10347 )
10348 render_slurm_script(
10349 solver_script,
10350 f"{run_id}_solve",
10351 cluster_cfg,
10352 solver_cmd,
10353 run_dir,
10354 solver_log,
10355 solver_err,
10356 env_vars={"LOG_LEVEL": configs['monitor'].get('logging', {}).get('verbosity', 'INFO').upper()},
10357 shell_env_vars=build_walltime_guard_exports(cluster_cfg),
10358 )
10359 submission_meta["stages"]["solve"] = {
10360 "script": solver_script,
10361 "submitted": False,
10362 "num_procs_effective": solver_num_procs_effective,
10363 }
10364 print(f"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
10365 if not args.no_submit:
10366 submit_info = submit_sbatch(solver_script)
10367 submission_meta["stages"]["solve"].update(submit_info)
10368 submission_meta["stages"]["solve"]["submitted"] = True
10369 print(f"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
10370 stages_completed.append('solve')
10371 else:
10372 command = build_local_launch_command(
10373 solver_exe,
10374 solver_args,
10375 solver_num_procs_effective,
10376 config_search_anchor=configs["case_path"],
10377 )
10378 solver_log = os.path.join("scheduler", f"{run_id}_solver.log")
10379 submission_meta["stages"]["solve"] = {
10380 "command": command,
10381 "command_string": format_command_for_display(command),
10382 "log_file": solver_log,
10383 "submitted": False,
10384 "num_procs_effective": solver_num_procs_effective,
10385 }
10386 if args.no_submit:
10387 print(f"[SUCCESS] Staged local solver command: {solver_log}")
10388 else:
10389 execute_command(command, run_dir, solver_log, configs['monitor'])
10390 submission_meta["stages"]["solve"]["submitted"] = True
10391 submission_meta["stages"]["solve"]["executed"] = True
10392 submission_meta["stages"]["solve"]["completed_at"] = datetime.now().isoformat()
10393 stages_completed.append('solve')
10394
10395 # --- Stage 2: Post-Processing (if requested) ---
10396 if args.post_process:
10397 if args.run_dir:
10398 run_dir = os.path.abspath(args.run_dir)
10399 if not os.path.isdir(run_dir):
10400 print(f"[FATAL] Specified run directory not found: {run_dir}", file=sys.stderr)
10401 sys.exit(1)
10402 print(f"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
10403 run_id = os.path.basename(run_dir)
10404 elif not args.solve:
10405 print("[FATAL] --post-process requires --run-dir when not used with --solve.", file=sys.stderr)
10406 sys.exit(1)
10407
10408 print("\n" + "="*20 + " POST-PROCESSING STAGE " + "="*20)
10409 config_dir = os.path.join(run_dir, "config")
10410 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
10411
10412 if not all([case_path, monitor_path, solver_control_path]):
10413 print(f"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
10414 if not case_path:
10415 print(" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
10416 if not monitor_path:
10417 print(" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
10418 if not solver_control_path:
10419 print(" - No '.control' file found.", file=sys.stderr)
10420 sys.exit(1)
10421
10422 print(f"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
10423 print(f"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
10424
10425 case_cfg = read_yaml_file(case_path)
10426 monitor_cfg = read_yaml_file(monitor_path)
10427 post_cfg = read_yaml_file(args.post)
10428
10429 print("[INFO] Validating post-processing configuration...")
10430 validate_post_config(post_cfg, args.post)
10431 print("[SUCCESS] Post-processing configuration passed validation.\n")
10432
10433 solver_sources_deferred = bool(args.solve and (cluster_mode or args.no_submit))
10434 allow_source_frontier_scan = not solver_sources_deferred
10435 post_plan = build_post_execution_plan(
10436 run_dir,
10437 run_id,
10438 case_cfg,
10439 monitor_cfg,
10440 post_cfg,
10441 continue_requested=getattr(args, 'continue_run', False),
10442 allow_source_frontier_scan=allow_source_frontier_scan,
10443 )
10444
10445 source_template = get_post_source_directory_template(post_cfg)
10446 if source_template == '<solver_output_dir>':
10447 print(f"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
10448 else:
10449 print(f"[INFO] Post-processor source data (user-defined): {os.path.relpath(post_plan['source_data_directory'])}")
10450
10451 if getattr(args, 'continue_run', False):
10452 if post_plan['resume_recipe_match']:
10453 print(f"[INFO] Post resume recipe match: yes ({post_plan['resume_match_source']}).")
10454 else:
10455 print("[INFO] Post resume recipe match: no. Using the configured start_step for this recipe.")
10456 if post_plan['completed_frontier_step'] is not None:
10457 print(f"[INFO] Completed post frontier: step {post_plan['completed_frontier_step']}")
10458 else:
10459 print("[INFO] Completed post frontier: none")
10460 if post_plan['source_frontier_deferred']:
10461 print("[INFO] Source availability frontier: deferred because the solver stage will populate the requested window before post starts.")
10462 elif post_plan['source_frontier_step'] is not None:
10463 print(f"[INFO] Current source availability frontier: step {post_plan['source_frontier_step']}")
10464 else:
10465 print("[INFO] Current source availability frontier: none")
10466
10467 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['completed_frontier_step'])
10468
10469 if post_plan['skip_reason'] == 'already-complete-window':
10470 print("[INFO] Requested post window is already complete; skipping postprocessor launch.")
10471 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['requested_end_step'])
10472 elif post_plan['skip_reason'] == 'already-caught-up-to-current-source-frontier':
10473 print("[INFO] Post outputs are already caught up to the current fully available source frontier; nothing new to launch right now.")
10474 diagnostic = post_plan.get('source_frontier_diagnostic') or {}
10475 first_incomplete = diagnostic.get('first_incomplete_step')
10476 if first_incomplete is not None:
10477 print(f"[INFO] First incomplete requested source step: {first_incomplete}")
10478 print(
10479 "[INFO] Closest complete source steps: "
10480 f"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
10481 f"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
10482 )
10483 elif post_plan['skip_reason'] == 'nothing-available-yet':
10484 diagnostic = post_plan.get('source_frontier_diagnostic') or {}
10485 first_incomplete = diagnostic.get('first_incomplete_step')
10486 if first_incomplete is not None:
10487 print(
10488 f"[INFO] First requested source step {first_incomplete} is incomplete; "
10489 "skipping postprocessor launch for now."
10490 )
10491 print(
10492 "[INFO] Closest complete source steps: "
10493 f"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
10494 f"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
10495 )
10496 missing_files = diagnostic.get('missing_files_for_first_incomplete_step') or []
10497 if missing_files:
10498 print(f"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
10499 else:
10500 print("[INFO] No fully available source steps exist yet in the requested window; skipping postprocessor launch for now.")
10501 else:
10502 print(
10503 f"[INFO] Effective post window: {post_plan['effective_start_step']}..{post_plan['effective_end_step']} "
10504 f"(stride {post_plan['step_interval']})"
10505 )
10506
10507 post_effective_cfg = post_plan['effective_post_cfg']
10508 post_io_cfg = post_effective_cfg.get('io', {})
10509 try:
10510 output_dir_rel = post_io_cfg['output_directory']
10511 output_prefix = post_io_cfg['output_filename_prefix']
10512 except KeyError as e:
10513 print(f"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
10514 sys.exit(1)
10515
10516 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
10517 os.makedirs(output_dir_abs, exist_ok=True)
10518 print(f"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
10519 statistics_output_paths = get_post_statistics_output_artifacts(post_effective_cfg, run_dir, monitor_cfg)
10520 for stats_path in statistics_output_paths:
10521 print(f"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
10522
10523 source_files_post = {'Case': case_path, 'Post-Profile': args.post}
10524 post_recipe_file = generate_post_recipe_file(run_dir, run_id, post_effective_cfg, source_files_post, monitor_cfg)
10525
10526 post_exe = resolve_runtime_executable("postprocessor")
10527 post_args = build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor") + [
10528 "-control_file",
10529 solver_control_path,
10530 "-postprocessing_config_file",
10531 post_recipe_file,
10532 ]
10533 if cluster_mode:
10534 scheduler_dir = os.path.join(run_dir, "scheduler")
10535 os.makedirs(scheduler_dir, exist_ok=True)
10536 post_script = os.path.join(scheduler_dir, "post.sbatch")
10537 post_log = os.path.join(scheduler_dir, "post_%j.out")
10538 post_err = os.path.join(scheduler_dir, "post_%j.err")
10539 post_cluster_cfg = build_serial_post_cluster_config(cluster_cfg, post_num_procs_effective)
10540 raw_post_cmd = build_cluster_launch_command(
10541 post_cluster_cfg,
10542 post_exe,
10543 post_args,
10544 config_search_anchor=case_path,
10545 extra_search_anchors=[cluster_path],
10546 force_num_procs=post_num_procs_effective,
10547 )
10548 post_cmd, _ = build_post_locked_command(
10549 run_dir,
10550 post_plan['recipe_fingerprint'],
10551 raw_post_cmd,
10552 create_wrapper=True,
10553 )
10554 render_slurm_script(
10555 post_script,
10556 f"{run_id}_post",
10557 post_cluster_cfg,
10558 post_cmd,
10559 run_dir,
10560 post_log,
10561 post_err,
10562 env_vars={"LOG_LEVEL": monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()},
10563 )
10564 submission_meta["stages"]["post-process"] = {
10565 "script": post_script,
10566 "submitted": False,
10567 "num_procs_effective": post_num_procs_effective,
10568 "resume_recipe_match": post_plan['resume_recipe_match'],
10569 "resume_bootstrapped": post_plan['resume_bootstrapped'],
10570 "resume_match_source": post_plan['resume_match_source'],
10571 "effective_start_step": post_plan['effective_start_step'],
10572 "effective_end_step": post_plan['effective_end_step'],
10573 "completed_frontier_step": post_plan['completed_frontier_step'],
10574 "source_frontier_step": post_plan['source_frontier_step'],
10575 "source_frontier_deferred": post_plan['source_frontier_deferred'],
10576 "recipe_fingerprint": post_plan['recipe_fingerprint'],
10577 }
10578 print(f"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
10579
10580 if not args.no_submit:
10581 dependency_job = None
10582 if args.solve:
10583 dependency_job = submission_meta.get("stages", {}).get("solve", {}).get("job_id")
10584 submit_info = submit_sbatch(post_script, dependency=dependency_job)
10585 submission_meta["stages"]["post-process"].update(submit_info)
10586 submission_meta["stages"]["post-process"]["submitted"] = True
10587 if dependency_job:
10588 submission_meta["stages"]["post-process"]["dependency"] = f"afterok:{dependency_job}"
10589 print(f"[SUCCESS] Submitted post job: {submit_info['job_id']}")
10590 stages_completed.append('post-process')
10591 else:
10592 raw_command = build_local_launch_command(
10593 post_exe,
10594 post_args,
10595 post_num_procs_effective,
10596 config_search_anchor=case_path,
10597 allow_single_rank_launcher_override=True,
10598 force_num_procs=post_num_procs_effective,
10599 )
10600 command, _ = build_post_locked_command(
10601 run_dir,
10602 post_plan['recipe_fingerprint'],
10603 raw_command,
10604 create_wrapper=True,
10605 )
10606 post_log = os.path.join("scheduler", f"{run_id}_{output_prefix}.log")
10607 submission_meta["stages"]["post-process"] = {
10608 "command": command,
10609 "command_string": format_command_for_display(command),
10610 "log_file": post_log,
10611 "submitted": False,
10612 "num_procs_effective": post_num_procs_effective,
10613 "resume_recipe_match": post_plan['resume_recipe_match'],
10614 "resume_bootstrapped": post_plan['resume_bootstrapped'],
10615 "resume_match_source": post_plan['resume_match_source'],
10616 "effective_start_step": post_plan['effective_start_step'],
10617 "effective_end_step": post_plan['effective_end_step'],
10618 "completed_frontier_step": post_plan['completed_frontier_step'],
10619 "source_frontier_step": post_plan['source_frontier_step'],
10620 "source_frontier_deferred": post_plan['source_frontier_deferred'],
10621 "recipe_fingerprint": post_plan['recipe_fingerprint'],
10622 }
10623 if args.no_submit:
10624 print(f"[SUCCESS] Staged local post command: {post_log}")
10625 else:
10626 execute_command(command, run_dir, post_log, monitor_cfg)
10627 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['effective_end_step'])
10628 submission_meta["stages"]["post-process"]["submitted"] = True
10629 submission_meta["stages"]["post-process"]["executed"] = True
10630 submission_meta["stages"]["post-process"]["completed_at"] = datetime.now().isoformat()
10631 stages_completed.append('post-process')
10632
10633 if run_dir:
10634 manifest = {
10635 "run_id": run_id,
10636 "created_at": datetime.now().isoformat(),
10637 "launch_mode": "slurm" if cluster_mode else "local",
10638 "git_commit": get_git_commit(),
10639 "num_procs": solver_num_procs_effective,
10640 "solver_num_procs": solver_num_procs_effective,
10641 "post_num_procs": post_num_procs_effective,
10642 "stages_requested": {"solve": bool(args.solve), "post_process": bool(args.post_process)},
10643 "stages_completed_or_submitted": stages_completed,
10644 "inputs": {},
10645 }
10646 if args.solve:
10647 manifest["inputs"]["case"] = os.path.abspath(args.case)
10648 manifest["inputs"]["solver"] = os.path.abspath(args.solver)
10649 manifest["inputs"]["monitor"] = os.path.abspath(args.monitor)
10650 if args.post_process:
10651 manifest["inputs"]["post"] = os.path.abspath(args.post)
10652 if cluster_mode:
10653 manifest["inputs"]["cluster"] = cluster_path
10654 if submission_meta.get("stages"):
10655 write_json_file(os.path.join(run_dir, "scheduler", "submission.json"), submission_meta)
10656 write_json_file(os.path.join(run_dir, "manifest.json"), manifest)
10657
10658 if stages_completed:
10659 elapsed = time.time() - workflow_start
10660 mins, secs = divmod(int(elapsed), 60)
10661 hrs, mins = divmod(mins, 60)
10662 if hrs > 0:
10663 time_str = f"{hrs}h {mins}m {secs}s"
10664 elif mins > 0:
10665 time_str = f"{mins}m {secs}s"
10666 else:
10667 time_str = f"{secs}s"
10668
10669 print("\n" + "=" * 60)
10670 print(" RUN SUMMARY")
10671 print("=" * 60)
10672 print(f" Run ID : {run_id}")
10673 print(f" Run directory : {os.path.relpath(run_dir)}")
10674 print(f" Wall-clock : {time_str}")
10675 print(f" Stages : {', '.join(stages_completed)}")
10676 print(f" Launch mode : {'slurm' if cluster_mode else 'local'}")
10677 if args.solve:
10678 print(f" Solver MPI procs: {solver_num_procs_effective}")
10679 if args.post_process:
10680 print(f" Post MPI procs : {post_num_procs_effective}")
10681 if args.solve and configs:
10682 total_steps = configs['case'].get('run_control', {}).get('total_steps', '?')
10683 result_dir = os.path.join(run_dir, configs['monitor'].get('io', {}).get('directories', {}).get('output', 'output'))
10684 print(f" Steps run : {total_steps}")
10685 print(f" Solver output : {os.path.relpath(result_dir)}")
10686 if 'post-process' in stages_completed and output_dir_abs:
10687 print(f" Post output : {os.path.relpath(output_dir_abs)}")
10688 for stats_path in statistics_output_paths:
10689 print(f" Stats output : {os.path.relpath(stats_path)}")
10690 print(f" Logs : {os.path.relpath(os.path.join(run_dir, 'logs'))}")
10691 if cluster_mode or submission_meta.get("stages"):
10692 submission_file = os.path.join(run_dir, "scheduler", "submission.json")
10693 print(f" Submission meta: {os.path.relpath(submission_file)}")
10694 print("=" * 60)
10695
10696
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 10697 of file core.py.

10697def parse_case_index_tsv(tsv_path: str) -> list:
10698 """!
10699 @brief Parse a case_index.tsv file back into a list of case entry dicts.
10700 @param[in] tsv_path Path to the case_index.tsv file.
10701 @return List of dicts with keys: index, case_id, run_dir, control_file,
10702 post_recipe_file, log_level, post_prefix.
10703 """
10704 entries = []
10705 with open(tsv_path) as f:
10706 for line in f:
10707 line = line.strip()
10708 if not line:
10709 continue
10710 parts = line.split("\t")
10711 entries.append({
10712 "index": int(parts[0]),
10713 "case_id": parts[1],
10714 "run_dir": parts[2],
10715 "control_file": parts[3],
10716 "post_recipe_file": parts[4],
10717 "log_level": parts[5],
10718 "post_prefix": parts[6],
10719 })
10720 return entries
10721
10722
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 10723 of file core.py.

10723def sweep_workflow(args):
10724 """!
10725 @brief Study/sweep orchestration using Slurm job arrays.
10726 @param[in] args Command-line style argument list supplied to the function.
10727 """
10728 study_path = os.path.abspath(args.study)
10729 cluster_path = os.path.abspath(args.cluster)
10730
10731 study_cfg = read_yaml_file(study_path)
10732 cluster_cfg = read_yaml_file(cluster_path)
10733 validate_study_config(study_cfg, study_path)
10734 validate_cluster_config(cluster_cfg, cluster_path)
10735
10736 study_name = os.path.splitext(os.path.basename(study_path))[0]
10737 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
10738 study_id = f"{study_name}_{timestamp}"
10739 study_dir = os.path.abspath(os.path.join("studies", study_id))
10740 cases_dir = os.path.join(study_dir, "cases")
10741 scheduler_dir = os.path.join(study_dir, "scheduler")
10742 results_dir = os.path.join(study_dir, "results")
10743 for path in [cases_dir, scheduler_dir, results_dir]:
10744 os.makedirs(path, exist_ok=True)
10745
10746 print(f"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
10747 shutil.copy(study_path, os.path.join(study_dir, "study.yml"))
10748 shutil.copy(cluster_path, os.path.join(study_dir, "cluster.yml"))
10749
10750 base_cfgs = study_cfg["base_configs"]
10751 base_paths = {k: resolve_path(study_path, v) for k, v in base_cfgs.items()}
10752 base_case = read_yaml_file(base_paths["case"])
10753 base_solver = read_yaml_file(base_paths["solver"])
10754 base_monitor = read_yaml_file(base_paths["monitor"])
10755 base_post = read_yaml_file(base_paths["post"])
10756 validate_solver_configs(base_case, base_solver, base_monitor, base_paths["case"], base_paths["solver"], base_paths["monitor"])
10757 validate_post_config(base_post, base_paths["post"])
10758
10759 combinations = expand_study_parameter_combinations(study_cfg)
10760 if not combinations:
10761 print("[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
10762 sys.exit(1)
10763 print(f"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
10764
10765 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
10766 case_entries = []
10767 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
10768
10769 for idx, combo in enumerate(combinations):
10770 case_id = f"case_{idx:04d}"
10771 run_dir = os.path.join(cases_dir, case_id)
10772 config_dir = os.path.join(run_dir, "config")
10773 os.makedirs(config_dir, exist_ok=True)
10774 os.makedirs(os.path.join(run_dir, "logs"), exist_ok=True)
10775 os.makedirs(os.path.join(run_dir, "output"), exist_ok=True)
10776
10777 case_cfg = copy.deepcopy(base_case)
10778 solver_cfg = copy.deepcopy(base_solver)
10779 monitor_cfg = copy.deepcopy(base_monitor)
10780 post_cfg = copy.deepcopy(base_post)
10781 target_map = {"case": case_cfg, "solver": solver_cfg, "monitor": monitor_cfg, "post": post_cfg}
10782 for full_key, value in combo.items():
10783 root, nested = full_key.split(".", 1)
10784 _deep_set(target_map[root], nested, value)
10785
10786 # Preserve file-based/grid-gen workflows when study cases are materialized
10787 # into new directories by rewriting external paths as absolute.
10788 absolutize_case_external_paths(case_cfg, base_paths["case"])
10789
10790 case_path = os.path.join(config_dir, "case.yml")
10791 solver_path = os.path.join(config_dir, "solver.yml")
10792 monitor_path = os.path.join(config_dir, "monitor.yml")
10793 post_path = os.path.join(config_dir, "post.yml")
10794 write_yaml_file(case_path, case_cfg)
10795 write_yaml_file(solver_path, solver_cfg)
10796 write_yaml_file(monitor_path, monitor_cfg)
10797 write_yaml_file(post_path, post_cfg)
10798
10799 validate_solver_configs(case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path)
10800 validate_post_config(post_cfg, post_path)
10801
10802 source_files = {'Case': case_path, 'Solver': solver_path, 'Monitor': monitor_path}
10803 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
10804 configs = {
10805 "case": case_cfg, "case_path": case_path,
10806 "solver": solver_cfg, "solver_path": solver_path,
10807 "monitor": monitor_cfg, "monitor_path": monitor_path,
10808 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
10809 }
10810 control_file = generate_solver_control_file(run_dir, case_id, configs, cluster_tasks, monitor_files)
10811
10812 source_dir = resolve_post_source_directory(run_dir, monitor_cfg, post_cfg, strict=False)
10813 if not isinstance(post_cfg.get('source_data'), dict):
10814 post_cfg['source_data'] = {}
10815 post_cfg['source_data']['directory'] = source_dir
10816 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix", "post")
10817 post_recipe = generate_post_recipe_file(run_dir, case_id, post_cfg, {'Case': case_path, 'Post-Profile': post_path}, monitor_cfg)
10818
10819 case_entries.append({
10820 "index": idx,
10821 "case_id": case_id,
10822 "run_dir": os.path.abspath(run_dir),
10823 "control_file": control_file,
10824 "post_recipe_file": post_recipe,
10825 "log_level": str(monitor_cfg.get("logging", {}).get("verbosity", "INFO")).upper(),
10826 "post_prefix": output_prefix,
10827 "solve_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "Solver")),
10828 "post_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor")),
10829 "parameters": combo,
10830 })
10831
10832 with open(case_index_file, "w") as f:
10833 for entry in case_entries:
10834 f.write(
10835 "\t".join(
10836 [
10837 str(entry["index"]),
10838 entry["case_id"],
10839 entry["run_dir"],
10840 entry["control_file"],
10841 entry["post_recipe_file"],
10842 entry["log_level"],
10843 entry["post_prefix"],
10844 entry["solve_diagnostic_args"],
10845 entry["post_diagnostic_args"],
10846 ]
10847 ) + "\n"
10848 )
10849 print(f"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
10850
10851 max_idx = len(case_entries) - 1
10852 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
10853 array_spec = f"0-{max_idx}"
10854 if max_conc:
10855 array_spec = f"{array_spec}%{max_conc}"
10856
10857 solver_exe = resolve_runtime_executable("simulator")
10858 post_exe = resolve_runtime_executable("postprocessor")
10859 solver_array_script = os.path.join(scheduler_dir, "solver_array.sbatch")
10860 post_array_script = os.path.join(scheduler_dir, "post_array.sbatch")
10861 render_slurm_array_stage_script(
10862 solver_array_script,
10863 f"{study_id}_solve",
10864 cluster_cfg,
10865 array_spec,
10866 case_index_file,
10867 "solve",
10868 solver_exe,
10869 post_exe,
10870 os.path.join(scheduler_dir, "solver_%A_%a.out"),
10871 os.path.join(scheduler_dir, "solver_%A_%a.err")
10872 )
10873 render_slurm_array_stage_script(
10874 post_array_script,
10875 f"{study_id}_post",
10876 cluster_cfg,
10877 array_spec,
10878 case_index_file,
10879 "post",
10880 solver_exe,
10881 post_exe,
10882 os.path.join(scheduler_dir, "post_%A_%a.out"),
10883 os.path.join(scheduler_dir, "post_%A_%a.err")
10884 )
10885 print(f"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
10886
10887 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
10888 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_aggregate.sbatch")
10889 render_metrics_aggregate_script(
10890 metrics_aggregate_script,
10891 f"{study_id}_metrics",
10892 cluster_cfg,
10893 study_dir,
10894 picurv_path,
10895 )
10896 print(f"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
10897
10898 submission = {
10899 "launch_mode": "slurm",
10900 "study_id": study_id,
10901 "solver_array": {"script": solver_array_script, "submitted": False},
10902 "post_array": {"script": post_array_script, "submitted": False},
10903 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
10904 "no_submit": bool(args.no_submit),
10905 }
10906 if not args.no_submit:
10907 solver_submit = submit_sbatch(solver_array_script)
10908 submission["solver_array"].update(solver_submit)
10909 submission["solver_array"]["submitted"] = True
10910 post_submit = submit_sbatch(post_array_script, dependency=solver_submit["job_id"])
10911 submission["post_array"].update(post_submit)
10912 submission["post_array"]["submitted"] = True
10913 submission["post_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
10914 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
10915 submission["metrics_aggregate"].update(metrics_submit)
10916 submission["metrics_aggregate"]["submitted"] = True
10917 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
10918 print(f"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
10919 print(f"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
10920 print(f"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
10921
10922 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
10923 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
10924
10925 summary = {
10926 "study_id": study_id,
10927 "created_at": datetime.now().isoformat(),
10928 "git_commit": get_git_commit(),
10929 "study_type": study_cfg.get("study_type"),
10930 "num_cases": len(case_entries),
10931 "paths": {
10932 "study_dir": study_dir,
10933 "case_index": case_index_file,
10934 "solver_array_script": solver_array_script,
10935 "post_array_script": post_array_script,
10936 "metrics_table": metrics_csv,
10937 "plots_dir": os.path.join(results_dir, "plots"),
10938 },
10939 "submission": submission,
10940 }
10941 write_json_file(os.path.join(scheduler_dir, "submission.json"), submission)
10942 write_json_file(os.path.join(study_dir, "study_manifest.json"), summary)
10943 write_json_file(os.path.join(results_dir, "summary.json"), {"study_id": study_id, "metrics_csv": metrics_csv, "plots": plots})
10944
10945 print("\n" + "=" * 60)
10946 print(" STUDY SUMMARY")
10947 print("=" * 60)
10948 print(f" Study ID : {study_id}")
10949 print(f" Study directory : {os.path.relpath(study_dir)}")
10950 print(f" Cases generated : {len(case_entries)}")
10951 print(f" Array spec : {array_spec}")
10952 print(f" Solver script : {os.path.relpath(solver_array_script)}")
10953 print(f" Post script : {os.path.relpath(post_array_script)}")
10954 if metrics_csv:
10955 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
10956 if plots:
10957 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
10958 print("=" * 60)
10959
10960
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 10961 of file core.py.

10961def sweep_continue_workflow(args):
10962 """!
10963 @brief Continue a partially-completed Slurm parameter sweep study.
10964 @details Detects incomplete cases, prepares them for continuation (updating
10965 start_step, populating restart directories, regenerating control files),
10966 and submits new solver/post/metrics Slurm jobs. If all cases are already
10967 complete, performs metrics aggregation automatically.
10968 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
10969 """
10970 study_dir = os.path.abspath(args.study_dir)
10971 manifest_path = os.path.join(study_dir, "study_manifest.json")
10972 if not os.path.isfile(manifest_path):
10973 print(f"[FATAL] Study manifest not found: {manifest_path}", file=sys.stderr)
10974 sys.exit(1)
10975 manifest = _read_json_if_exists(manifest_path)
10976 study_id = manifest["study_id"]
10977
10978 study_path = os.path.join(study_dir, "study.yml")
10979 cluster_path = os.path.abspath(args.cluster) if args.cluster else os.path.join(study_dir, "cluster.yml")
10980 study_cfg = read_yaml_file(study_path)
10981 cluster_cfg = read_yaml_file(cluster_path)
10982 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
10983 validate_cluster_config(cluster_cfg, cluster_path)
10984
10985 if args.cluster:
10986 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir, "cluster.yml"))
10987 print(f"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
10988
10989 scheduler_dir = os.path.join(study_dir, "scheduler")
10990 cases_dir = os.path.join(study_dir, "cases")
10991 results_dir = os.path.join(study_dir, "results")
10992 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
10993 if not os.path.isfile(case_index_file):
10994 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
10995 sys.exit(1)
10996
10997 parsed_entries = parse_case_index_tsv(case_index_file)
10998
10999 base_cfgs = study_cfg["base_configs"]
11000 base_paths = {k: resolve_path(study_path, v) for k, v in base_cfgs.items()}
11001 base_case = read_yaml_file(base_paths["case"])
11002
11003 combinations = expand_study_parameter_combinations(study_cfg)
11004 if len(combinations) != len(parsed_entries):
11005 print(
11006 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
11007 f"case_index.tsv ({len(parsed_entries)} entries).",
11008 file=sys.stderr,
11009 )
11010 sys.exit(1)
11011
11012 print(f"\n[INFO] Study: {study_id}")
11013 print(f"[INFO] Scanning {len(combinations)} case(s) for completion status...")
11014
11015 incomplete_indices = []
11016 all_case_entries = []
11017 for idx, combo in enumerate(combinations):
11018 case_id = f"case_{idx:04d}"
11019 entry = parsed_entries[idx]
11020 entry["parameters"] = combo
11021 run_dir = entry["run_dir"]
11022
11023 effective_case = copy.deepcopy(base_case)
11024 for full_key, value in combo.items():
11025 root, nested = full_key.split(".", 1)
11026 if root == "case":
11027 _deep_set(effective_case, nested, value)
11028 try:
11029 eff_start = int(effective_case.get("run_control", {}).get("start_step", 0) or 0)
11030 except (TypeError, ValueError):
11031 eff_start = 0
11032 eff_total = int(effective_case["run_control"]["total_steps"])
11033 target = eff_start + eff_total
11034
11035 monitor_cfg = read_yaml_file(os.path.join(run_dir, "config", "monitor.yml"))
11036 status = detect_case_completion_status(run_dir, monitor_cfg, target)
11037 entry["_status"] = status
11038
11039 if status["status"] == "complete":
11040 print(f" {case_id}: complete (step {status['last_step']}/{target})")
11041 elif status["status"] == "partial":
11042 print(f" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
11043 incomplete_indices.append(idx)
11044 else:
11045 print(f" {case_id}: no checkpoint — will re-run from scratch")
11046 incomplete_indices.append(idx)
11047
11048 all_case_entries.append(entry)
11049
11050 if not incomplete_indices:
11051 print("\n[INFO] All cases are complete. Running metrics aggregation...")
11052 metrics_csv = aggregate_study_metrics(study_cfg, all_case_entries, results_dir)
11053 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
11054 print("\n" + "=" * 60)
11055 print(" STUDY CONTINUATION SUMMARY")
11056 print("=" * 60)
11057 print(f" Study ID : {study_id}")
11058 print(f" Status : ALL COMPLETE")
11059 if metrics_csv:
11060 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
11061 if plots:
11062 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
11063 print("=" * 60)
11064 return
11065
11066 print(f"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
11067
11068 skipped = []
11069 for idx in incomplete_indices:
11070 entry = all_case_entries[idx]
11071 status = entry["_status"]
11072 if status["status"] == "partial":
11073 prepare_case_for_continuation(
11074 entry["run_dir"], entry["case_id"],
11075 status["last_step"], status["target_step"],
11076 cluster_cfg,
11077 )
11078 elif status["status"] == "empty":
11079 print(f"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
11080
11081 solver_array_spec = ",".join(str(i) for i in incomplete_indices)
11082 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
11083 if max_conc:
11084 solver_array_spec = f"{solver_array_spec}%{max_conc}"
11085
11086 max_idx = len(combinations) - 1
11087 post_array_spec = f"0-{max_idx}"
11088 if max_conc:
11089 post_array_spec = f"{post_array_spec}%{max_conc}"
11090
11091 solver_exe = resolve_runtime_executable("simulator")
11092 post_exe = resolve_runtime_executable("postprocessor")
11093
11094 solver_continue_script = os.path.join(scheduler_dir, "solver_continue_array.sbatch")
11095 post_continue_script = os.path.join(scheduler_dir, "post_continue_array.sbatch")
11096 render_slurm_array_stage_script(
11097 solver_continue_script,
11098 f"{study_id}_solve_cont",
11099 cluster_cfg,
11100 solver_array_spec,
11101 case_index_file,
11102 "solve",
11103 solver_exe, post_exe,
11104 os.path.join(scheduler_dir, "solver_cont_%A_%a.out"),
11105 os.path.join(scheduler_dir, "solver_cont_%A_%a.err"),
11106 )
11107 render_slurm_array_stage_script(
11108 post_continue_script,
11109 f"{study_id}_post_cont",
11110 cluster_cfg,
11111 post_array_spec,
11112 case_index_file,
11113 "post",
11114 solver_exe, post_exe,
11115 os.path.join(scheduler_dir, "post_cont_%A_%a.out"),
11116 os.path.join(scheduler_dir, "post_cont_%A_%a.err"),
11117 )
11118
11119 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
11120 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_continue_aggregate.sbatch")
11121 render_metrics_aggregate_script(
11122 metrics_aggregate_script,
11123 f"{study_id}_metrics_cont",
11124 cluster_cfg,
11125 study_dir,
11126 picurv_path,
11127 )
11128 print(f"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
11129
11130 submission = {
11131 "launch_mode": "slurm",
11132 "study_id": study_id,
11133 "continuation": True,
11134 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
11135 "solver_continue_array": {"script": solver_continue_script, "submitted": False},
11136 "post_continue_array": {"script": post_continue_script, "submitted": False},
11137 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
11138 "no_submit": bool(args.no_submit),
11139 }
11140 if not args.no_submit:
11141 solver_submit = submit_sbatch(solver_continue_script)
11142 submission["solver_continue_array"].update(solver_submit)
11143 submission["solver_continue_array"]["submitted"] = True
11144 post_submit = submit_sbatch(post_continue_script, dependency=solver_submit["job_id"])
11145 submission["post_continue_array"].update(post_submit)
11146 submission["post_continue_array"]["submitted"] = True
11147 submission["post_continue_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
11148 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
11149 submission["metrics_aggregate"].update(metrics_submit)
11150 submission["metrics_aggregate"]["submitted"] = True
11151 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
11152 print(f"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
11153 print(f"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
11154 print(f"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
11155
11156 write_json_file(os.path.join(scheduler_dir, "submission_continue.json"), submission)
11157
11158 manifest["continuation"] = {
11159 "continued_at": datetime.now().isoformat(),
11160 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
11161 "submission": submission,
11162 }
11163 write_json_file(manifest_path, manifest)
11164
11165 print("\n" + "=" * 60)
11166 print(" STUDY CONTINUATION SUMMARY")
11167 print("=" * 60)
11168 print(f" Study ID : {study_id}")
11169 print(f" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
11170 print(f" Solver array spec : {solver_array_spec}")
11171 print(f" Post array spec : {post_array_spec}")
11172 print(f" Solver script : {os.path.relpath(solver_continue_script)}")
11173 print(f" Post script : {os.path.relpath(post_continue_script)}")
11174 print(f" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
11175 if not args.no_submit:
11176 print(f" [Metrics aggregation will run automatically after post-processing]")
11177 else:
11178 print(f" [--no-submit] Scripts generated but not submitted.")
11179 print(f" After manual submission and completion, run:")
11180 print(f" picurv sweep --reaggregate --study-dir {os.path.relpath(study_dir)}")
11181 print("=" * 60)
11182
11183
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 11184 of file core.py.

11184def sweep_reaggregate_workflow(args):
11185 """!
11186 @brief Re-run metrics aggregation and plot generation for an existing study.
11187 @param[in] args Parsed CLI arguments with study_dir.
11188 """
11189 study_dir = os.path.abspath(args.study_dir)
11190 study_path = os.path.join(study_dir, "study.yml")
11191 if not os.path.isfile(study_path):
11192 print(f"[FATAL] Study config not found: {study_path}", file=sys.stderr)
11193 sys.exit(1)
11194 study_cfg = read_yaml_file(study_path)
11195 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
11196
11197 case_index_file = os.path.join(study_dir, "scheduler", "case_index.tsv")
11198 if not os.path.isfile(case_index_file):
11199 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
11200 sys.exit(1)
11201
11202 parsed_entries = parse_case_index_tsv(case_index_file)
11203 combinations = expand_study_parameter_combinations(study_cfg)
11204 if len(combinations) != len(parsed_entries):
11205 print(
11206 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
11207 f"case_index.tsv ({len(parsed_entries)} entries).",
11208 file=sys.stderr,
11209 )
11210 sys.exit(1)
11211
11212 case_entries = []
11213 for idx, combo in enumerate(combinations):
11214 entry = parsed_entries[idx]
11215 entry["parameters"] = combo
11216 case_entries.append(entry)
11217
11218 results_dir = os.path.join(study_dir, "results")
11219 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
11220 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
11221
11222 print("\n" + "=" * 60)
11223 print(" REAGGREGATION SUMMARY")
11224 print("=" * 60)
11225 if metrics_csv:
11226 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
11227 if plots:
11228 print(f" Plots generated : {len(plots)}")
11229 print("=" * 60)
11230
11231
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 11235 of file core.py.

11235def _read_yaml_if_exists(filepath: str):
11236 """!
11237 @brief Read YAML when present, otherwise return None.
11238 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
11239 @return Value returned by `_read_yaml_if_exists()`.
11240 """
11241 if not filepath or not os.path.isfile(filepath):
11242 return None
11243 try:
11244 with open(filepath, "r", encoding="utf-8") as f:
11245 return yaml.safe_load(f)
11246 except yaml.YAMLError:
11247 return None
11248
11249
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 11250 of file core.py.

11250def _read_json_if_exists(filepath: str):
11251 """!
11252 @brief Read JSON when present, otherwise return None.
11253 @param[in] filepath Argument passed to `_read_json_if_exists()`.
11254 @return Value returned by `_read_json_if_exists()`.
11255 """
11256 if not filepath or not os.path.isfile(filepath):
11257 return None
11258 with open(filepath, "r", encoding="utf-8") as f:
11259 return json.load(f)
11260
11261
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 11262 of file core.py.

11262def _parse_int_loose(value):
11263 """!
11264 @brief Best-effort integer parsing for summary extraction.
11265 @param[in] value Argument passed to `_parse_int_loose()`.
11266 @return Value returned by `_parse_int_loose()`.
11267 """
11268 if value is None:
11269 return None
11270 text = str(value).strip()
11271 if not text:
11272 return None
11273 try:
11274 return int(text)
11275 except ValueError:
11276 try:
11277 return int(float(text))
11278 except ValueError:
11279 return None
11280
11281
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 11282 of file core.py.

11282def _parse_float_loose(value):
11283 """!
11284 @brief Best-effort float parsing for summary extraction.
11285 @param[in] value Argument passed to `_parse_float_loose()`.
11286 @return Value returned by `_parse_float_loose()`.
11287 """
11288 if value is None:
11289 return None
11290 text = str(value).strip()
11291 if not text:
11292 return None
11293 try:
11294 return float(text)
11295 except ValueError:
11296 return None
11297
11298
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 11299 of file core.py.

11299def _extract_numeric_tuple(text: str):
11300 """!
11301 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
11302 @param[in] text Argument passed to `_extract_numeric_tuple()`.
11303 @return Value returned by `_extract_numeric_tuple()`.
11304 """
11305 if not text:
11306 return []
11307 return [float(token) for token in _SUMMARY_NUMERIC_RE.findall(text)]
11308
11309
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 11310 of file core.py.

11310def _build_summary_context(run_dir: str) -> dict:
11311 """!
11312 @brief Resolve run-local config and artifact paths for summarize.
11313 @param[in] run_dir Argument passed to `_build_summary_context()`.
11314 @return Value returned by `_build_summary_context()`.
11315 """
11316 run_dir = os.path.abspath(run_dir)
11317 if not os.path.isdir(run_dir):
11318 emit_structured_error(
11319 ERROR_CODE_CFG_FILE_NOT_FOUND,
11320 key="run_dir",
11321 file_path=run_dir,
11322 message="Run directory not found.",
11323 )
11324 sys.exit(1)
11325
11326 config_dir = os.path.join(run_dir, "config")
11327 config_paths = {
11328 "case": os.path.join(config_dir, "case.yml"),
11329 "solver": os.path.join(config_dir, "solver.yml"),
11330 "monitor": os.path.join(config_dir, "monitor.yml"),
11331 }
11332 monitor_cfg = _read_yaml_if_exists(config_paths["monitor"]) or {}
11333 case_cfg = _read_yaml_if_exists(config_paths["case"]) or {}
11334 solver_cfg = _read_yaml_if_exists(config_paths["solver"]) or {}
11335 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
11336
11337 io_cfg = monitor_cfg.get("io", {}) if isinstance(monitor_cfg, dict) else {}
11338 io_dirs = io_cfg.get("directories", {}) if isinstance(io_cfg, dict) else {}
11339 log_dir_name = io_dirs.get("log", "logs")
11340 scheduler_dir = os.path.join(run_dir, "scheduler")
11341
11342 profiling_cfg = {"mode": "off", "functions": [], "timestep_file": "Profiling_Timestep_Summary.csv", "final_summary_enabled": True}
11343 if monitor_cfg:
11344 profiling_cfg = resolve_profiling_config(monitor_cfg)
11345
11346 particle_console_output_freq = None
11347 particle_log_interval = None
11348 if monitor_cfg:
11349 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
11350 particle_log_interval = io_cfg.get("particle_log_interval")
11351
11352 particle_count_cfg = None
11353 if case_cfg:
11354 particle_count_cfg = (
11355 case_cfg.get("models", {})
11356 .get("physics", {})
11357 .get("particles", {})
11358 .get("count")
11359 )
11360
11361 return {
11362 "run_dir": run_dir,
11363 "config_dir": config_dir,
11364 "log_dir": os.path.join(run_dir, log_dir_name),
11365 "scheduler_dir": scheduler_dir,
11366 "monitor_cfg": monitor_cfg,
11367 "case_cfg": case_cfg,
11368 "solver_cfg": solver_cfg,
11369 "config_paths": config_paths,
11370 "manifest": manifest,
11371 "profiling_cfg": profiling_cfg,
11372 "particle_console_output_freq": particle_console_output_freq,
11373 "particle_log_interval": particle_log_interval,
11374 "particle_count_cfg": particle_count_cfg,
11375 }
11376
11377
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 11378 of file core.py.

11378def _require_summary_config(context: dict, name: str) -> dict:
11379 """!
11380 @brief Return one explicitly requested copied config or fail with a structured error.
11381 @param[in] context Summary context returned by `_build_summary_context()`.
11382 @param[in] name Config selector name.
11383 @return Parsed config mapping.
11384 """
11385 path = context["config_paths"][name]
11386 cfg = context.get(f"{name}_cfg")
11387 if not os.path.isfile(path):
11388 emit_structured_error(
11389 ERROR_CODE_CFG_FILE_NOT_FOUND,
11390 key=name,
11391 file_path=path,
11392 message=f"Copied run config '{name}.yml' was not found.",
11393 hint="Use a staged run directory containing the requested copied config.",
11394 )
11395 sys.exit(1)
11396 if not isinstance(cfg, dict) or not cfg:
11397 emit_structured_error(
11398 ERROR_CODE_CFG_INVALID_VALUE,
11399 key=name,
11400 file_path=path,
11401 message=f"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
11402 )
11403 sys.exit(1)
11404 return cfg
11405
11406
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 11407 of file core.py.

11407def _build_run_overview(context: dict) -> dict:
11408 """!
11409 @brief Build timestep-independent run metadata for summarize.
11410 @param[in] context Summary context returned by `_build_summary_context()`.
11411 @return Curated run metadata mapping.
11412 """
11413 manifest = context["manifest"]
11414 return {
11415 "run_id": manifest.get("run_id", os.path.basename(context["run_dir"])),
11416 "run_dir": context["run_dir"],
11417 "created_at": manifest.get("created_at"),
11418 "launch_mode": manifest.get("launch_mode"),
11419 "git_commit": manifest.get("git_commit"),
11420 "solver_num_procs": manifest.get("solver_num_procs", manifest.get("num_procs")),
11421 "post_num_procs": manifest.get("post_num_procs"),
11422 "stages_requested": manifest.get("stages_requested"),
11423 "stages_completed_or_submitted": manifest.get("stages_completed_or_submitted"),
11424 }
11425
11426
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 11427 of file core.py.

11427def _summarize_turbulence(turbulence_cfg: dict) -> dict:
11428 """!
11429 @brief Build compact turbulence and wall-model selections.
11430 @param[in] turbulence_cfg Case turbulence configuration mapping.
11431 @return Curated turbulence and wall-model mapping.
11432 """
11433 result = {}
11434 for key in ("les", "rans", "wall_function"):
11435 value = turbulence_cfg.get(key)
11436 if isinstance(value, dict):
11437 result[key] = {
11438 "enabled": value.get("enabled", True),
11439 "model": value.get("model"),
11440 **{k: v for k, v in value.items() if k not in {"enabled", "model"}},
11441 }
11442 elif value is not None:
11443 result[key] = value
11444 return result
11445
11446
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 11447 of file core.py.

11447def _build_case_overview(context: dict) -> dict:
11448 """!
11449 @brief Build a curated case.yml summary with useful derived quantities.
11450 @param[in] context Summary context returned by `_build_summary_context()`.
11451 @return Curated case configuration mapping.
11452 """
11453 cfg = _require_summary_config(context, "case")
11454 props = cfg.get("properties", {})
11455 scaling = props.get("scaling", {})
11456 fluid = props.get("fluid", {})
11457 run = cfg.get("run_control", {})
11458 grid = cfg.get("grid", {})
11459 models = cfg.get("models", {})
11460 domain = models.get("domain", {})
11461 physics = models.get("physics", {})
11462 particles = physics.get("particles", {})
11463 start = int(run.get("start_step", 0))
11464 total = int(run.get("total_steps", 0))
11465 dt = float(run.get("dt_physical", 0.0))
11466 length_ref = float(scaling.get("length_ref"))
11467 velocity_ref = float(scaling.get("velocity_ref"))
11468 density = float(fluid.get("density"))
11469 viscosity = float(fluid.get("viscosity"))
11470 prepared_bcs = validate_and_prepare_boundary_conditions(cfg)
11471 first_block_faces = {row["face"]: row for row in prepared_bcs[0]}
11472 periodic_axes = {
11473 "i": first_block_faces["-Xi"]["type"] == "PERIODIC",
11474 "j": first_block_faces["-Eta"]["type"] == "PERIODIC",
11475 "k": first_block_faces["-Zeta"]["type"] == "PERIODIC",
11476 }
11477 bc_blocks = []
11478 for block_idx, block in enumerate(prepared_bcs):
11479 bc_blocks.append(
11480 {
11481 "block": block_idx,
11482 "faces": [
11483 {"face": row["face"], "type": row["type"], "handler": row["handler"]}
11484 for row in block
11485 ],
11486 }
11487 )
11488 return {
11489 "run_control": {
11490 "start_step": start,
11491 "total_steps": total,
11492 "end_step": start + total,
11493 "dt_physical": dt,
11494 "duration_physical": total * dt,
11495 "dt_nondimensional": dt * velocity_ref / length_ref,
11496 },
11497 "properties": {
11498 "length_ref": length_ref,
11499 "velocity_ref": velocity_ref,
11500 "density": density,
11501 "viscosity": viscosity,
11502 "reynolds_number": density * velocity_ref * length_ref / viscosity if viscosity else None,
11503 "initial_conditions": props.get("initial_conditions", {}),
11504 },
11505 "grid": {
11506 "mode": grid.get("mode"),
11507 "processor_layout": resolve_grid_da_processor_layout(grid),
11508 "programmatic_settings": grid.get("programmatic_settings") if grid.get("mode") == "programmatic_c" else None,
11509 "source_file": grid.get("source_file"),
11510 },
11511 "domain": {
11512 "blocks": domain.get("blocks", 1),
11513 "dimensionality": physics.get("dimensionality", "3D"),
11514 "periodic": periodic_axes,
11515 },
11516 "physics": {
11517 "fsi": physics.get("fsi", {}),
11518 "particles": particles,
11519 "turbulence": _summarize_turbulence(physics.get("turbulence", {}) or {}),
11520 "statistics": models.get("statistics", {}),
11521 },
11522 "boundary_conditions": bc_blocks,
11523 }
11524
11525
Here is the call 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 11526 of file core.py.

11526def _build_solver_overview(context: dict) -> dict:
11527 """!
11528 @brief Build a curated solver.yml summary with normalized selections.
11529 @param[in] context Summary context returned by `_build_summary_context()`.
11530 @return Curated solver configuration mapping.
11531 """
11532 cfg = _require_summary_config(context, "solver")
11533 strategy = cfg.get("strategy", {}) or {}
11534 selected = normalize_momentum_solver_type(strategy.get("momentum_solver", "Dual Time Picard Jameson RK"))
11535 momentum_cfg = cfg.get("momentum_solver", {}) or {}
11536 dualtime = momentum_cfg.get("dual_time_picard_jameson_rk", momentum_cfg.get("dual_time_picard_rk4", {})) or {}
11537 poisson = cfg.get("poisson_solver", cfg.get("pressure_solver", {})) or {}
11538 convergence = cfg.get("solution_convergence", {}) or {}
11539 convergence_mode = normalize_solution_convergence_mode(convergence.get("mode", "steady_deterministic"))
11540 operation_mode = cfg.get("operation_mode", {}) or {}
11541 operation_mode = {
11542 **operation_mode,
11543 "eulerian_field_source": normalize_eulerian_field_source(operation_mode.get("eulerian_field_source", "solve")),
11544 }
11545 if operation_mode.get("analytical_type") is not None:
11546 operation_mode["analytical_type"] = normalize_analytical_type(operation_mode["analytical_type"])
11547 passthrough = cfg.get("petsc_passthrough_options", {}) or {}
11548 return {
11549 "operation_mode": operation_mode,
11550 "momentum": {
11551 "type": selected,
11552 "central_diff": bool(strategy.get("central_diff", False)),
11553 "tolerances": cfg.get("tolerances", {}),
11554 "controls": dualtime,
11555 },
11556 "poisson": poisson,
11557 "interpolation": cfg.get("interpolation", {"method": "Trilinear"}),
11558 "solution_convergence": {**convergence, "mode": convergence_mode},
11559 "scalar_transport": cfg.get("scalar_transport", {}),
11560 "verification": cfg.get("verification", {}),
11561 "petsc_passthrough": {"count": len(passthrough), "options": sorted(passthrough.keys())},
11562 }
11563
11564
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 11565 of file core.py.

11565def _build_monitor_overview(context: dict) -> dict:
11566 """!
11567 @brief Build a curated monitor.yml summary with resolved defaults.
11568 @param[in] context Summary context returned by `_build_summary_context()`.
11569 @return Curated monitor configuration mapping.
11570 """
11571 cfg = _require_summary_config(context, "monitor")
11572 logging_cfg = cfg.get("logging", {}) or {}
11573 io_cfg = cfg.get("io", {}) or {}
11574 diagnostics = resolve_diagnostics_config(cfg, context["run_dir"], "Solver")
11575 monitoring_flags = resolve_solver_monitoring_flags(cfg)
11576 enabled_petsc = sorted(key for key, value in diagnostics["petsc"].items() if value not in (False, None))
11577 return {
11578 "logging": {
11579 "verbosity": logging_cfg.get("verbosity", "WARNING"),
11580 "enabled_functions": logging_cfg.get("enabled_functions", []),
11581 },
11582 "profiling": resolve_profiling_config(cfg),
11583 "diagnostics": {
11584 "enabled_petsc": enabled_petsc,
11585 "petsc": diagnostics["petsc"],
11586 "runtime_memory_log": diagnostics["runtime_memory_log"],
11587 },
11588 "io": {
11589 "data_output_frequency": io_cfg.get("data_output_frequency"),
11590 "particle_console_output_frequency": resolve_particle_console_output_frequency(io_cfg),
11591 "particle_log_interval": io_cfg.get("particle_log_interval"),
11592 "directories": io_cfg.get("directories", {}),
11593 },
11594 "solver_monitoring": {
11595 "enabled_flags": sorted(flag for flag, value in monitoring_flags.items() if value not in (False, None)),
11596 "flags": monitoring_flags,
11597 },
11598 }
11599
11600
Here is the call graph for this function:

◆ _parse_continuity_metrics_log()

"tuple[dict, list[int]]" picurv_cli.core._parse_continuity_metrics_log ( str  filepath)
protected

Parse Continuity_Metrics.log into latest rows by step plus observed order.

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

Definition at line 11601 of file core.py.

11601def _parse_continuity_metrics_log(filepath: str) -> "tuple[dict, list[int]]":
11602 """!
11603 @brief Parse Continuity_Metrics.log into latest rows by step plus observed order.
11604 @param[in] filepath Argument passed to `_parse_continuity_metrics_log()`.
11605 @return Value returned by `_parse_continuity_metrics_log()`.
11606 """
11607 rows_by_step = {}
11608 step_order = []
11609 active_step = None
11610 if not os.path.isfile(filepath):
11611 return rows_by_step, step_order
11612
11613 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
11614 for raw_line in f:
11615 line = raw_line.strip()
11616 if not line or line.startswith("-") or line.startswith("Timestep"):
11617 continue
11618 parts = [part.strip() for part in raw_line.split("|")]
11619 if len(parts) < 8:
11620 continue
11621 step = _parse_int_loose(parts[0])
11622 block = _parse_int_loose(parts[1])
11623 max_div = _parse_float_loose(parts[2])
11624 rhs_sum = _parse_float_loose(parts[4])
11625 flux_in = _parse_float_loose(parts[5])
11626 flux_out = _parse_float_loose(parts[6])
11627 net_flux = _parse_float_loose(parts[7])
11628 if step is None or block is None:
11629 continue
11630 if step != active_step:
11631 active_step = step
11632 step_order.append(step)
11633 rows_by_step[step] = {}
11634 rows_by_step.setdefault(step, {})[block] = {
11635 "block": block,
11636 "max_divergence": max_div,
11637 "max_divergence_location": parts[3],
11638 "rhs_sum": rhs_sum,
11639 "flux_in": flux_in,
11640 "flux_out": flux_out,
11641 "net_flux": net_flux,
11642 }
11643 return {step: list(block_rows.values()) for step, block_rows in rows_by_step.items()}, step_order
11644
11645
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  filepath)
protected

Parse Particle_Metrics.log into latest rows by step plus observed order.

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

Definition at line 11646 of file core.py.

11646def _parse_particle_metrics_log(filepath: str) -> "tuple[dict, list[int]]":
11647 """!
11648 @brief Parse Particle_Metrics.log into latest rows by step plus observed order.
11649 @param[in] filepath Argument passed to `_parse_particle_metrics_log()`.
11650 @return Value returned by `_parse_particle_metrics_log()`.
11651 """
11652 rows_by_step = {}
11653 step_order = []
11654 if not os.path.isfile(filepath):
11655 return rows_by_step, step_order
11656
11657 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
11658 for raw_line in f:
11659 line = raw_line.strip()
11660 if not line or line.startswith("-") or line.startswith("Stage"):
11661 continue
11662 parts = [part.strip() for part in raw_line.split("|")]
11663 if len(parts) < 8:
11664 continue
11665 step = _parse_int_loose(parts[1])
11666 if step is None:
11667 continue
11668 row = {
11669 "stage": parts[0],
11670 "total_particles": _parse_int_loose(parts[2]),
11671 "lost_particles": _parse_int_loose(parts[3]),
11672 "lost_particles_cumulative": None,
11673 "migrated_particles": None,
11674 "occupied_cells": None,
11675 "load_imbalance": None,
11676 "migration_passes": None,
11677 }
11678 if len(parts) >= 9:
11679 row.update(
11680 {
11681 "lost_particles_cumulative": _parse_int_loose(parts[4]),
11682 "migrated_particles": _parse_int_loose(parts[5]),
11683 "occupied_cells": _parse_int_loose(parts[6]),
11684 "load_imbalance": _parse_float_loose(parts[7]),
11685 "migration_passes": _parse_int_loose(parts[8]),
11686 }
11687 )
11688 else:
11689 row.update(
11690 {
11691 "migrated_particles": _parse_int_loose(parts[4]),
11692 "occupied_cells": _parse_int_loose(parts[5]),
11693 "load_imbalance": _parse_float_loose(parts[6]),
11694 "migration_passes": _parse_int_loose(parts[7]),
11695 }
11696 )
11697 rows_by_step[step] = row
11698 step_order.append(step)
11699 return rows_by_step, step_order
11700
11701
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

Parse per-block momentum convergence logs.

Parameters
[in]log_dirArgument passed to _parse_momentum_convergence_logs().
Returns
Value returned by _parse_momentum_convergence_logs().

Definition at line 11702 of file core.py.

11702def _parse_momentum_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
11703 """!
11704 @brief Parse per-block momentum convergence logs.
11705 @param[in] log_dir Argument passed to `_parse_momentum_convergence_logs()`.
11706 @return Value returned by `_parse_momentum_convergence_logs()`.
11707 """
11708 rows_by_step = {}
11709 sources = {}
11710 step_order = []
11711 pattern = os.path.join(log_dir, "Momentum_Solver_Convergence_History_Block_*.log")
11712 regex = re.compile(
11713 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\‍(k\‍):\s*(?P<pseudo_iter>\d+)\|\s*\|"
11714 r"\s*Pseudo-cfl:\s*(?P<pseudo_cfl>[-+0-9.eE]+)\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
11715 r"\s*\|dUk\|/\|dUprev\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
11716 r"\s*\|Rk\|/\|Rprev\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
11717 )
11718
11719 for path in sorted(glob.glob(pattern)):
11720 block_match = re.search(r"Block_(\d+)\.log$", path)
11721 if not block_match:
11722 continue
11723 block = int(block_match.group(1))
11724 sources[block] = path
11725 with open(path, "r", encoding="utf-8", errors="replace") as f:
11726 for raw_line in f:
11727 match = regex.search(raw_line)
11728 if not match:
11729 continue
11730 step = int(match.group("step"))
11731 step_order.append(step)
11732 rows_by_step.setdefault(step, {})[block] = {
11733 "block": block,
11734 "pseudo_iterations": int(match.group("pseudo_iter")),
11735 "pseudo_cfl": float(match.group("pseudo_cfl")),
11736 "delta_norm": float(match.group("delta")),
11737 "delta_rel": float(match.group("delta_rel")),
11738 "residual_norm": float(match.group("resid")),
11739 "residual_rel": float(match.group("resid_rel")),
11740 }
11741 return rows_by_step, sources, step_order
11742
11743
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

Parse per-block Poisson convergence logs.

Parameters
[in]log_dirArgument passed to _parse_poisson_convergence_logs().
Returns
Value returned by _parse_poisson_convergence_logs().

Definition at line 11744 of file core.py.

11744def _parse_poisson_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
11745 """!
11746 @brief Parse per-block Poisson convergence logs.
11747 @param[in] log_dir Argument passed to `_parse_poisson_convergence_logs()`.
11748 @return Value returned by `_parse_poisson_convergence_logs()`.
11749 """
11750 rows_by_step = {}
11751 sources = {}
11752 step_order = []
11753 pattern = os.path.join(log_dir, "Poisson_Solver_Convergence_History_Block_*.log")
11754 regex = re.compile(
11755 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
11756 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
11757 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
11758 )
11759
11760 for path in sorted(glob.glob(pattern)):
11761 block_match = re.search(r"Block_(\d+)\.log$", path)
11762 if not block_match:
11763 continue
11764 block = int(block_match.group(1))
11765 sources[block] = path
11766 with open(path, "r", encoding="utf-8", errors="replace") as f:
11767 for raw_line in f:
11768 match = regex.search(raw_line)
11769 if not match:
11770 continue
11771 step = int(match.group("step"))
11772 step_order.append(step)
11773 rows_by_step.setdefault(step, {})[block] = {
11774 "block": block,
11775 "iterations": int(match.group("iter")),
11776 "unpreconditioned_norm": float(match.group("unpre")),
11777 "true_norm": float(match.group("true")),
11778 "relative_norm": _parse_float_loose(match.group("rel")),
11779 }
11780 return rows_by_step, sources, step_order
11781
11782
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 11783 of file core.py.

11783def _parse_profiling_timestep_csv(filepath: str) -> "tuple[dict, list[int]]":
11784 """!
11785 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
11786 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
11787 @return Value returned by `_parse_profiling_timestep_csv()`.
11788 """
11789 rows_by_step = {}
11790 step_order = []
11791 active_step = None
11792 if not os.path.isfile(filepath):
11793 return rows_by_step, step_order
11794
11795 with open(filepath, "r", encoding="utf-8", errors="replace", newline="") as f:
11796 reader = csv.DictReader(f)
11797 for row in reader:
11798 step = _parse_int_loose(row.get("step"))
11799 if step is None:
11800 continue
11801 if step != active_step:
11802 active_step = step
11803 step_order.append(step)
11804 rows_by_step[step] = []
11805 rows_by_step.setdefault(step, []).append(
11806 {
11807 "function": row.get("function"),
11808 "calls": _parse_int_loose(row.get("calls")),
11809 "step_time_s": _parse_float_loose(row.get("step_time_s")),
11810 }
11811 )
11812 return rows_by_step, step_order
11813
11814
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

Parse Runtime_Memory.log into latest rows by step and final status.

Parameters
[in]filepathRuntime memory log path.
Returns
Tuple of rows by step, observed step order, and final/shutdown metadata.

Definition at line 11815 of file core.py.

11815def _parse_runtime_memory_log(filepath: str) -> "tuple[dict, list[int], dict]":
11816 """!
11817 @brief Parse Runtime_Memory.log into latest rows by step and final status.
11818 @param[in] filepath Runtime memory log path.
11819 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
11820 """
11821 rows_by_step = {}
11822 step_order = []
11823 final_row = None
11824 latest_sample_row = None
11825 max_process_change_mb = None
11826 if not os.path.isfile(filepath):
11827 return rows_by_step, step_order, {"available": False}
11828
11829 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
11830 for raw_line in f:
11831 line = raw_line.strip()
11832 if not line or line.startswith("#") or line.startswith("Step"):
11833 continue
11834 parts = line.split()
11835 if len(parts) < 8:
11836 continue
11837 step = _parse_int_loose(parts[0])
11838 if step is None:
11839 continue
11840 row = {
11841 "step": step,
11842 "event": parts[1],
11843 "process_current_mb_max": _parse_float_loose(parts[2]),
11844 "process_peak_mb_max": _parse_float_loose(parts[3]),
11845 "petsc_allocated_mb_max": _parse_float_loose(parts[4]),
11846 "petsc_peak_allocated_mb_max": _parse_float_loose(parts[5]),
11847 "process_change_mb_max": _parse_float_loose(parts[6]),
11848 "reason": parts[7],
11849 }
11850 if row["process_change_mb_max"] is not None:
11851 max_process_change_mb = (
11852 row["process_change_mb_max"]
11853 if max_process_change_mb is None
11854 else max(max_process_change_mb, row["process_change_mb_max"])
11855 )
11856 if row["event"] in {"Step", "Post"}:
11857 rows_by_step[step] = row
11858 step_order.append(step)
11859 latest_sample_row = row
11860 elif row["event"] in {"Shutdown", "Final"}:
11861 final_row = row
11862
11863 meta = {
11864 "available": bool(rows_by_step or final_row),
11865 "source": filepath,
11866 "final_event": final_row.get("event") if final_row else None,
11867 "final_reason": final_row.get("reason") if final_row else None,
11868 "max_process_change_mb": max_process_change_mb,
11869 "latest_sample_row": latest_sample_row,
11870 "final_row": final_row,
11871 }
11872 return rows_by_step, step_order, meta
11873
11874
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  filepath)
protected

Parse solution_convergence.log into latest rows by step plus observed order.

The log format uses pipe-delimited aligned columns. The first line of the file is a banner (starts with '=') containing the mode tag; the second line is the column header; the third line is a separator (starts with '-'). Subsequent lines are one data row per timestep.

Parameters
[in]filepathPath to solution_convergence.log.
Returns
Mapping of step number to a dict of column values.

Definition at line 11875 of file core.py.

11875def _parse_solution_convergence_log(filepath: str) -> "tuple[dict, list[int]]":
11876 """!
11877 @brief Parse solution_convergence.log into latest rows by step plus observed order.
11878
11879 The log format uses pipe-delimited aligned columns. The first line of the
11880 file is a banner (starts with '=') containing the mode tag; the second line
11881 is the column header; the third line is a separator (starts with '-').
11882 Subsequent lines are one data row per timestep.
11883
11884 @param[in] filepath Path to solution_convergence.log.
11885 @return Mapping of step number to a dict of column values.
11886 """
11887 rows_by_step = {}
11888 step_order = []
11889 if not os.path.isfile(filepath):
11890 return rows_by_step, step_order
11891
11892 mode = None
11893 col_names = None
11894
11895 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
11896 for raw_line in f:
11897 line = raw_line.strip()
11898 if not line:
11899 continue
11900 if line.startswith("="):
11901 m = re.search(r"\[mode:\s*([\w_]+)", line)
11902 if m:
11903 mode = m.group(1)
11904 continue
11905 if line.startswith("-"):
11906 continue
11907 if col_names is None:
11908 col_names = [p.strip() for p in raw_line.split("|")]
11909 continue
11910 parts = [p.strip() for p in raw_line.split("|")]
11911 if len(parts) < 4 or col_names is None:
11912 continue
11913 step = _parse_int_loose(parts[0]) if col_names[0] == "step" else None
11914 if step is None:
11915 continue
11916 step_order.append(step)
11917 row = {"mode": mode}
11918 for name, val in zip(col_names, parts):
11919 if not name:
11920 continue
11921 float_val = _parse_float_loose(val)
11922 int_val = _parse_int_loose(val)
11923 if float_val is not None and ("." in val or "e" in val.lower()):
11924 row[name] = float_val
11925 elif int_val is not None:
11926 row[name] = int_val
11927 else:
11928 row[name] = val
11929 rows_by_step[step] = row
11930 return rows_by_step, step_order
11931
11932
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 11933 of file core.py.

11933def _find_solver_stream_log_candidates(run_dir: str, log_dir: str) -> "list[str]":
11934 """!
11935 @brief Return plausible solver stream logs for local and Slurm runs.
11936 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
11937 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
11938 @return Value returned by `_find_solver_stream_log_candidates()`.
11939 """
11940 patterns = [
11941 os.path.join(run_dir, "scheduler", "*_solver.log"),
11942 os.path.join(run_dir, "scheduler", "solver_*.out"),
11943 os.path.join(log_dir, "*_solver.log"),
11944 ]
11945 found = []
11946 seen = set()
11947 for pattern in patterns:
11948 for path in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True):
11949 if path not in seen:
11950 seen.add(path)
11951 found.append(path)
11952 return found
11953
11954
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 11955 of file core.py.

11955def _parse_particle_snapshot_file(filepath: str) -> dict:
11956 """!
11957 @brief Parse sampled particle snapshots from a solver stream log.
11958 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
11959 @return Value returned by `_parse_particle_snapshot_file()`.
11960 """
11961 snapshots = {}
11962 if not os.path.isfile(filepath):
11963 return snapshots
11964
11965 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
11966 lines = f.readlines()
11967
11968 idx = 0
11969 while idx < len(lines):
11970 match = re.search(r"Particle states at step\s+(\d+):", lines[idx])
11971 if not match:
11972 idx += 1
11973 continue
11974 step = int(match.group(1))
11975 idx += 1
11976 rows = []
11977 while idx < len(lines):
11978 stripped = lines[idx].strip()
11979 if re.search(r"Particle states at step\s+\d+:", lines[idx]):
11980 break
11981 if stripped.startswith("|"):
11982 parts = [part.strip() for part in stripped.split("|")[1:-1]]
11983 if len(parts) >= 6 and parts[0] != "Rank":
11984 velocity = _extract_numeric_tuple(parts[4])
11985 rows.append(
11986 {
11987 "rank": _parse_int_loose(parts[0]),
11988 "pid": _parse_int_loose(parts[1]),
11989 "cell": [int(value) for value in _extract_numeric_tuple(parts[2])],
11990 "position": _extract_numeric_tuple(parts[3]),
11991 "velocity": velocity,
11992 "weights": _extract_numeric_tuple(parts[5]),
11993 "sample_speed": math.sqrt(sum(component * component for component in velocity)) if len(velocity) == 3 else None,
11994 }
11995 )
11996 elif rows and (not stripped or stripped.startswith("Progress:")):
11997 break
11998 idx += 1
11999 if rows:
12000 snapshots[step] = rows
12001 else:
12002 snapshots.setdefault(step, [])
12003 return snapshots
12004
12005
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 12006 of file core.py.

12006def _find_previous_snapshot_step(snapshot_steps: "list[int]", step: int) -> "int | None":
12007 """!
12008 @brief Return the nearest earlier snapshot step when available.
12009 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
12010 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
12011 @return Value returned by `_find_previous_snapshot_step()`.
12012 """
12013 earlier_steps = [candidate for candidate in snapshot_steps if candidate < step]
12014 if not earlier_steps:
12015 return None
12016 return max(earlier_steps)
12017
12018
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 12019 of file core.py.

12019def _compute_particle_snapshot_delta(current_rows: "list[dict]", previous_rows: "list[dict]") -> dict:
12020 """!
12021 @brief Compute sampled deltas between two particle snapshot samples.
12022 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
12023 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
12024 @return Value returned by `_compute_particle_snapshot_delta()`.
12025 """
12026 np = require_numpy()
12027 previous_by_pid = {
12028 row.get("pid"): row
12029 for row in previous_rows
12030 if row.get("pid") is not None
12031 }
12032 current_by_pid = {
12033 row.get("pid"): row
12034 for row in current_rows
12035 if row.get("pid") is not None
12036 }
12037 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
12038 if not matched_pids:
12039 return {"available": False}
12040
12041 displacements = []
12042 rank_migrations = 0
12043 cell_changes = 0
12044 speed_changes = []
12045 for pid in matched_pids:
12046 current_row = current_by_pid[pid]
12047 previous_row = previous_by_pid[pid]
12048 current_pos = current_row.get("position") or []
12049 previous_pos = previous_row.get("position") or []
12050 if len(current_pos) == len(previous_pos) and current_pos:
12051 displacements.append(
12052 math.sqrt(
12053 sum(
12054 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
12055 for idx in range(len(current_pos))
12056 )
12057 )
12058 )
12059 current_speed = current_row.get("sample_speed")
12060 previous_speed = previous_row.get("sample_speed")
12061 if current_speed is not None and previous_speed is not None:
12062 speed_changes.append(float(current_speed) - float(previous_speed))
12063 if current_row.get("rank") is not None and previous_row.get("rank") is not None:
12064 if current_row["rank"] != previous_row["rank"]:
12065 rank_migrations += 1
12066 if current_row.get("cell") and previous_row.get("cell"):
12067 if current_row["cell"] != previous_row["cell"]:
12068 cell_changes += 1
12069
12070 payload = {
12071 "available": True,
12072 "matched_pids": len(matched_pids),
12073 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
12074 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
12075 "rank_migrations": rank_migrations,
12076 "cell_changes": cell_changes,
12077 }
12078 if displacements:
12079 payload["mean_displacement"] = float(np.mean(displacements))
12080 payload["max_displacement"] = float(np.max(displacements))
12081 if speed_changes:
12082 payload["mean_speed_change"] = float(np.mean(speed_changes))
12083 payload["max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
12084 return payload
12085
12086
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 12087 of file core.py.

12096) -> dict:
12097 """!
12098 @brief Build sampled diagnostics for one particle console snapshot.
12099 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
12100 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
12101 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
12102 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
12103 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
12104 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
12105 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
12106 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
12107 @return Value returned by `_build_particle_snapshot_summary()`.
12108 """
12109 np = require_numpy()
12110 payload = {
12111 "available": True,
12112 "sampled": True,
12113 "source": source,
12114 "step": step,
12115 "sampled_rows": len(rows),
12116 "preview_rows": rows[:preview_rows],
12117 "cadence": {
12118 "particle_console_output_frequency": particle_console_output_freq,
12119 "particle_log_interval": particle_log_interval,
12120 },
12121 }
12122 if not rows:
12123 return payload
12124
12125 rank_counts = {}
12126 duplicate_pid_count = 0
12127 duplicate_cell_count = 0
12128 nan_count = 0
12129 inf_count = 0
12130 zero_weight_count = 0
12131 negative_weight_count = 0
12132 unique_pid_count = 0
12133
12134 seen_pids = set()
12135 cell_counter = {}
12136 position_components = [[], [], []]
12137 weight_components = {}
12138 speeds = []
12139
12140 for row in rows:
12141 pid = row.get("pid")
12142 if pid is not None:
12143 if pid in seen_pids:
12144 duplicate_pid_count += 1
12145 else:
12146 seen_pids.add(pid)
12147 rank = row.get("rank")
12148 if rank is not None:
12149 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
12150
12151 cell = row.get("cell") or []
12152 if cell:
12153 key = tuple(cell)
12154 cell_counter[key] = cell_counter.get(key, 0) + 1
12155
12156 position = row.get("position") or []
12157 for idx, value in enumerate(position[:3]):
12158 if not np.isfinite(value):
12159 if np.isnan(value):
12160 nan_count += 1
12161 else:
12162 inf_count += 1
12163 continue
12164 position_components[idx].append(float(value))
12165
12166 velocity = row.get("velocity") or []
12167 if any(not np.isfinite(value) for value in velocity):
12168 for value in velocity:
12169 if not np.isfinite(value):
12170 if np.isnan(value):
12171 nan_count += 1
12172 else:
12173 inf_count += 1
12174 speed = row.get("sample_speed")
12175 if speed is not None and np.isfinite(speed):
12176 speeds.append(float(speed))
12177
12178 weights = row.get("weights") or []
12179 for idx, value in enumerate(weights):
12180 if not np.isfinite(value):
12181 if np.isnan(value):
12182 nan_count += 1
12183 else:
12184 inf_count += 1
12185 continue
12186 numeric = float(value)
12187 weight_components.setdefault(idx, []).append(numeric)
12188 if abs(numeric) <= 1.0e-15:
12189 zero_weight_count += 1
12190 if numeric < 0.0:
12191 negative_weight_count += 1
12192
12193 unique_pid_count = len(seen_pids)
12194 duplicate_cell_count = sum(1 for count in cell_counter.values() if count > 1)
12195
12196 payload["sampled_distribution"] = {
12197 "unique_cells": len(cell_counter),
12198 "duplicate_cells": duplicate_cell_count,
12199 "rank_counts": rank_counts,
12200 "unique_pids": unique_pid_count,
12201 }
12202 payload["checks"] = {
12203 "duplicate_pid_count": duplicate_pid_count,
12204 "nan_count": nan_count,
12205 "inf_count": inf_count,
12206 "zero_weight_count": zero_weight_count,
12207 "negative_weight_count": negative_weight_count,
12208 }
12209
12210 if speeds:
12211 payload["speed"] = {
12212 "min": float(np.min(speeds)),
12213 "mean": float(np.mean(speeds)),
12214 "max": float(np.max(speeds)),
12215 "std": float(np.std(speeds)),
12216 "stagnant_count": sum(1 for speed in speeds if abs(speed) < 1.0e-6),
12217 }
12218
12219 fastest_rows = sorted(
12220 [row for row in rows if row.get("sample_speed") is not None],
12221 key=lambda row: row["sample_speed"],
12222 reverse=True,
12223 )[:3]
12224 payload["top_speeds"] = [
12225 {
12226 "pid": row.get("pid"),
12227 "rank": row.get("rank"),
12228 "speed": row.get("sample_speed"),
12229 "cell": row.get("cell"),
12230 }
12231 for row in fastest_rows
12232 ]
12233
12234 if any(position_components):
12235 axes = ["x", "y", "z"]
12236 payload["position_bounds"] = {}
12237 centroid = []
12238 for idx, axis in enumerate(axes):
12239 values = position_components[idx]
12240 if values:
12241 payload["position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
12242 centroid.append(float(np.mean(values)))
12243 else:
12244 centroid.append(None)
12245 payload["position_centroid"] = centroid
12246
12247 if weight_components:
12248 payload["weights"] = {}
12249 for idx, values in sorted(weight_components.items()):
12250 payload["weights"][f"component_{idx}"] = {
12251 "min": float(np.min(values)),
12252 "max": float(np.max(values)),
12253 }
12254
12255 delta_summary = {"available": False}
12256 if previous_step is not None and previous_rows:
12257 delta_summary = _compute_particle_snapshot_delta(rows, previous_rows)
12258 if delta_summary.get("available"):
12259 delta_summary["previous_step"] = previous_step
12260 payload["delta_from_previous_snapshot"] = delta_summary
12261 return payload
12262
12263
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 12264 of file core.py.

12271) -> dict:
12272 """!
12273 @brief Locate and summarize a particle console snapshot for one step.
12274 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
12275 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
12276 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
12277 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
12278 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
12279 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
12280 @return Value returned by `_find_particle_snapshot_for_step()`.
12281 """
12282 best = None
12283 for path in _find_solver_stream_log_candidates(run_dir, log_dir):
12284 snapshots = _parse_particle_snapshot_file(path)
12285 rows = snapshots.get(step)
12286 if not rows:
12287 continue
12288 if best is None or len(rows) > len(best["rows"]):
12289 best = {"source": path, "rows": rows, "snapshots": snapshots}
12290
12291 if not best:
12292 return {"available": False}
12293
12294 previous_step = _find_previous_snapshot_step(list(best["snapshots"].keys()), step)
12295 previous_rows = best["snapshots"].get(previous_step, []) if previous_step is not None else None
12296 return _build_particle_snapshot_summary(
12297 best["source"],
12298 step,
12299 best["rows"],
12300 preview_rows,
12301 particle_console_output_freq=particle_console_output_freq,
12302 particle_log_interval=particle_log_interval,
12303 previous_step=previous_step,
12304 previous_rows=previous_rows,
12305 )
12306
12307
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 12308 of file core.py.

12319):
12320 """!
12321 @brief Select a step to summarize from available metric artifacts.
12322 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
12323 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
12324 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
12325 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
12326 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
12327 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
12328 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
12329 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
12330 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
12331 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
12332 @return Value returned by `_resolve_summary_step()`.
12333 """
12334 if memory_rows is None:
12335 memory_rows = {}
12336 if convergence_rows is None:
12337 convergence_rows = {}
12338 if step_orders is None:
12339 step_orders = []
12340 available_steps = (
12341 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
12342 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
12343 )
12344 if not available_steps:
12345 return None, []
12346
12347 if requested_step is not None:
12348 return requested_step, sorted(available_steps)
12349
12350 if selection_mode == "max_step":
12351 return max(available_steps), sorted(available_steps)
12352
12353 for order in step_orders:
12354 if order:
12355 return order[-1], sorted(available_steps)
12356 return max(available_steps), sorted(available_steps)
12357
12358
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 12359 of file core.py.

12359def _format_summary_float(value, spec: str = ".6e", missing: str = "n/a") -> str:
12360 """!
12361 @brief Format optional numeric values for summary text output.
12362 @param[in] value Argument passed to `_format_summary_float()`.
12363 @param[in] spec Argument passed to `_format_summary_float()`.
12364 @param[in] missing Argument passed to `_format_summary_float()`.
12365 @return Value returned by `_format_summary_float()`.
12366 """
12367 if value is None:
12368 return missing
12369 return format(value, spec)
12370
12371

◆ _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 12372 of file core.py.

12372def _summary_source_mtime(paths) -> float:
12373 """!
12374 @brief Return the newest modification time among one or more summary sources.
12375 @param[in] paths Path string, iterable of paths, or mapping of paths.
12376 @return Newest modification time, or -1.0 when no source exists.
12377 """
12378 if isinstance(paths, dict):
12379 paths = paths.values()
12380 elif isinstance(paths, str):
12381 paths = [paths]
12382 newest = -1.0
12383 for path in paths or []:
12384 if path and os.path.isfile(path):
12385 newest = max(newest, os.path.getmtime(path))
12386 return newest
12387
12388
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 12389 of file core.py.

12389def _order_summary_step_orders(sources: "list[tuple[list[int], object]]") -> "list[list[int]]":
12390 """!
12391 @brief Order observed step sequences by the recency of their source files.
12392 @param[in] sources Pairs of observed steps and filesystem source path(s).
12393 @return Step-order lists sorted so active append sources are considered first.
12394 """
12395 ranked = []
12396 for priority, (order, paths) in enumerate(sources):
12397 if order:
12398 ranked.append((_summary_source_mtime(paths), priority, order))
12399 ranked.sort(key=lambda item: (-item[0], item[1]))
12400 return [order for _, _, order in ranked]
12401
12402
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 12403 of file core.py.

12403def build_run_summary_payload(run_dir: str, step: "int | None" = None, snapshot_rows: int = 5, selection_mode: str = "latest") -> dict:
12404 """!
12405 @brief Build a read-only run-step summary from existing PICurv artifacts.
12406 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
12407 @param[in] step Argument passed to `build_run_summary_payload()`.
12408 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
12409 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
12410 @return Value returned by `build_run_summary_payload()`.
12411 """
12412 context = _build_summary_context(run_dir)
12413 log_dir = context["log_dir"]
12414 continuity_path = os.path.join(log_dir, "Continuity_Metrics.log")
12415 particle_metrics_path = os.path.join(log_dir, "Particle_Metrics.log")
12416
12417 continuity_rows, continuity_order = _parse_continuity_metrics_log(continuity_path)
12418 particle_rows, particle_order = _parse_particle_metrics_log(particle_metrics_path)
12419 momentum_rows, momentum_sources, momentum_order = _parse_momentum_convergence_logs(log_dir)
12420 poisson_rows, poisson_sources, poisson_order = _parse_poisson_convergence_logs(log_dir)
12421
12422 profiling_rows = {}
12423 profiling_order = []
12424 profiling_path = os.path.join(log_dir, context["profiling_cfg"].get("timestep_file", "Profiling_Timestep_Summary.csv"))
12425 if context["profiling_cfg"].get("mode") != "off":
12426 profiling_rows, profiling_order = _parse_profiling_timestep_csv(profiling_path)
12427
12428 diagnostics_cfg = resolve_diagnostics_config(context["monitor_cfg"])
12429 memory_log_file = diagnostics_cfg["runtime_memory_log"].get("file", "Runtime_Memory.log")
12430 memory_path = os.path.join(log_dir, memory_log_file)
12431 memory_rows, memory_order, memory_meta = _parse_runtime_memory_log(memory_path)
12432
12433 convergence_log_path = os.path.join(log_dir, "solution_convergence.log")
12434 convergence_rows, convergence_order = _parse_solution_convergence_log(convergence_log_path)
12435 step_orders = _order_summary_step_orders(
12436 [
12437 (continuity_order, continuity_path),
12438 (particle_order, particle_metrics_path),
12439 (convergence_order, convergence_log_path),
12440 (profiling_order, profiling_path),
12441 (memory_order, memory_path),
12442 (momentum_order, momentum_sources),
12443 (poisson_order, poisson_sources),
12444 ]
12445 )
12446
12447 resolved_step, available_steps = _resolve_summary_step(
12448 step,
12449 continuity_rows,
12450 particle_rows,
12451 momentum_rows,
12452 poisson_rows,
12453 profiling_rows,
12454 convergence_rows,
12455 memory_rows,
12456 step_orders=step_orders,
12457 selection_mode=selection_mode,
12458 )
12459 if resolved_step is None:
12460 emit_structured_error(
12461 ERROR_CODE_CFG_FILE_NOT_FOUND,
12462 key="summary",
12463 file_path=log_dir,
12464 message="No summary-capable run artifacts were found under the run log directory.",
12465 hint="Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
12466 )
12467 sys.exit(1)
12468
12469 if step is not None and step not in set(available_steps):
12470 emit_structured_error(
12471 ERROR_CODE_CFG_INVALID_VALUE,
12472 key="step",
12473 file_path=context["run_dir"],
12474 message=f"Requested step {step} is not present in the available summary artifacts.",
12475 hint=f"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
12476 )
12477 sys.exit(1)
12478
12479 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=lambda row: row["block"])
12480 continuity_summary = {"available": bool(continuity_step_rows), "blocks": continuity_step_rows}
12481 if continuity_step_rows:
12482 divergence_values = [
12483 abs(row["max_divergence"])
12484 for row in continuity_step_rows
12485 if row["max_divergence"] is not None
12486 ]
12487 continuity_summary["max_abs_divergence"] = max(divergence_values) if divergence_values else None
12488 continuity_summary["net_flux"] = continuity_step_rows[0].get("net_flux")
12489 continuity_summary["flux_in"] = continuity_step_rows[0].get("flux_in")
12490 continuity_summary["flux_out"] = continuity_step_rows[0].get("flux_out")
12491
12492 momentum_step_rows = [row for _, row in sorted(momentum_rows.get(resolved_step, {}).items())]
12493 momentum_summary = {"available": bool(momentum_step_rows), "blocks": momentum_step_rows}
12494
12495 poisson_step_rows = [row for _, row in sorted(poisson_rows.get(resolved_step, {}).items())]
12496 poisson_summary = {"available": bool(poisson_step_rows), "blocks": poisson_step_rows}
12497
12498 particle_summary = {"available": resolved_step in particle_rows}
12499 if resolved_step in particle_rows:
12500 particle_summary.update(particle_rows[resolved_step])
12501
12502 profiling_summary = {"available": resolved_step in profiling_rows}
12503 if resolved_step in profiling_rows:
12504 functions = sorted(
12505 profiling_rows[resolved_step],
12506 key=lambda row: (row.get("step_time_s") or 0.0),
12507 reverse=True,
12508 )
12509 profiling_summary["functions"] = functions
12510 profiling_summary["total_logged_step_time_s"] = sum(
12511 row.get("step_time_s") or 0.0 for row in functions
12512 )
12513
12514 memory_summary = {"available": resolved_step in memory_rows}
12515 if resolved_step in memory_rows:
12516 memory_summary.update(memory_rows[resolved_step])
12517 memory_summary["source"] = memory_path
12518 memory_summary["max_process_change_mb"] = memory_meta.get("max_process_change_mb")
12519 memory_summary["final_event"] = memory_meta.get("final_event")
12520 memory_summary["final_reason"] = memory_meta.get("final_reason")
12521 memory_summary["selected_step"] = resolved_step
12522 memory_summary["step_match"] = True
12523 elif memory_meta.get("available"):
12524 latest_sample_row = memory_meta.get("latest_sample_row")
12525 if latest_sample_row:
12526 memory_summary.update(latest_sample_row)
12527 memory_summary.update(memory_meta)
12528 memory_summary["selected_step"] = resolved_step
12529 memory_summary["step_match"] = False
12530
12531 snapshot_summary = {"available": False}
12532 if context["particle_console_output_freq"] and context["particle_console_output_freq"] > 0:
12533 snapshot_summary = _find_particle_snapshot_for_step(
12534 context["run_dir"],
12535 log_dir,
12536 resolved_step,
12537 preview_rows=max(1, snapshot_rows),
12538 particle_console_output_freq=context["particle_console_output_freq"],
12539 particle_log_interval=context["particle_log_interval"],
12540 )
12541
12542 monitor_info = {
12543 "profiling_timestep_mode": context["profiling_cfg"].get("mode"),
12544 "profiling_timestep_file": context["profiling_cfg"].get("timestep_file"),
12545 "particle_console_output_frequency": context["particle_console_output_freq"],
12546 "particle_log_interval": context["particle_log_interval"],
12547 }
12548
12549 return {
12550 "run_id": context["manifest"].get("run_id", os.path.basename(context["run_dir"])),
12551 "run_dir": context["run_dir"],
12552 "step": resolved_step,
12553 "selected_via": "explicit" if step is not None else ("max_step" if selection_mode == "max_step" else "latest_available"),
12554 "available_steps": available_steps,
12555 "launch_mode": context["manifest"].get("launch_mode"),
12556 "created_at": context["manifest"].get("created_at"),
12557 "monitor": monitor_info,
12558 "particles_configured": context["particle_count_cfg"],
12559 "sources": {
12560 "continuity_log": continuity_path if os.path.isfile(continuity_path) else None,
12561 "particle_metrics_log": particle_metrics_path if os.path.isfile(particle_metrics_path) else None,
12562 "momentum_logs": momentum_sources,
12563 "poisson_logs": poisson_sources,
12564 "profiling_timestep_csv": profiling_path if os.path.isfile(profiling_path) else None,
12565 "solution_convergence_log": convergence_log_path if os.path.isfile(convergence_log_path) else None,
12566 "runtime_memory_log": memory_path if os.path.isfile(memory_path) else None,
12567 },
12568 "continuity": continuity_summary,
12569 "momentum": momentum_summary,
12570 "poisson": poisson_summary,
12571 "particles": particle_summary,
12572 "particle_snapshot": snapshot_summary,
12573 "profiling": profiling_summary,
12574 "memory": memory_summary,
12575 "convergence": convergence_rows.get(resolved_step) if convergence_rows else None,
12576 }
12577
12578
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 12579 of file core.py.

12579def render_run_summary(payload: dict, output_format: str = "text"):
12580 """!
12581 @brief Render a run-step summary in human or JSON form.
12582 @param[in] payload Argument passed to `render_run_summary()`.
12583 @param[in] output_format Argument passed to `render_run_summary()`.
12584 """
12585 if output_format == "json":
12586 print(json.dumps(payload, indent=2, sort_keys=True))
12587 return
12588
12589 print("\n" + "=" * 60)
12590 print(" RUN STEP SUMMARY")
12591 print("=" * 60)
12592 print(f" Run ID : {payload.get('run_id')}")
12593 print(f" Run directory : {os.path.relpath(payload.get('run_dir'))}")
12594 print(f" Step : {payload.get('step')} ({payload.get('selected_via')})")
12595 if payload.get("launch_mode"):
12596 print(f" Launch mode : {payload.get('launch_mode')}")
12597 if payload.get("created_at"):
12598 print(f" Created at : {payload.get('created_at')}")
12599
12600 continuity = payload.get("continuity", {})
12601 print("\n Continuity:")
12602 if continuity.get("available"):
12603 if continuity.get("max_abs_divergence") is not None:
12604 print(f" max |div| : {continuity['max_abs_divergence']:.6e}")
12605 if continuity.get("net_flux") is not None:
12606 print(f" net flux : {continuity['net_flux']:.6e}")
12607 for row in continuity.get("blocks", []):
12608 print(
12609 " "
12610 f"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
12611 f"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
12612 )
12613 else:
12614 print(" unavailable")
12615
12616 momentum = payload.get("momentum", {})
12617 print("\n Momentum:")
12618 if momentum.get("available"):
12619 for row in momentum.get("blocks", []):
12620 print(
12621 " "
12622 f"block {row['block']}: pseudo_iter={row['pseudo_iterations']} "
12623 f"cfl={_format_summary_float(row.get('pseudo_cfl'), '.4f')} "
12624 f"resid={_format_summary_float(row.get('residual_norm'))} "
12625 f"rel={_format_summary_float(row.get('residual_rel'))}"
12626 )
12627 else:
12628 print(" unavailable")
12629
12630 poisson = payload.get("poisson", {})
12631 print("\n Poisson:")
12632 if poisson.get("available"):
12633 for row in poisson.get("blocks", []):
12634 print(
12635 " "
12636 f"block {row['block']}: iter={row['iterations']} "
12637 f"true={_format_summary_float(row.get('true_norm'))} "
12638 f"rel={_format_summary_float(row.get('relative_norm'))}"
12639 )
12640 else:
12641 print(" unavailable")
12642
12643 convergence = payload.get("convergence")
12644 print("\n Solution Convergence:")
12645 if convergence is not None:
12646 mode = convergence.get("mode", "unknown")
12647 ref = convergence.get("ref")
12648 print(f" mode : {mode} (ref={'yes' if ref else 'no'})")
12649 if mode in ("steady_deterministic", "transient"):
12650 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
12651 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
12652 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
12653 elif mode == "periodic_deterministic":
12654 ph = convergence.get("ph")
12655 per = convergence.get("per")
12656 print(f" phase : {ph}/{per}")
12657 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
12658 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
12659 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
12660 elif mode == "statistical_steady":
12661 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} win={_format_summary_float(convergence.get('spd_win'))} win_drift={_format_summary_float(convergence.get('spd_win_abs'))}")
12662 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} win={_format_summary_float(convergence.get('ke_win'))} win_drift={_format_summary_float(convergence.get('ke_win_abs'))}")
12663 else:
12664 print(" unavailable")
12665
12666 particles = payload.get("particles", {})
12667 print("\n Particles:")
12668 if particles.get("available"):
12669 loss_summary = f"lost={particles.get('lost_particles')}"
12670 if particles.get("lost_particles_cumulative") is not None:
12671 loss_summary = (
12672 f"lost(step/total)={particles.get('lost_particles')}/"
12673 f"{particles.get('lost_particles_cumulative')}"
12674 )
12675 print(
12676 " "
12677 f"total={particles.get('total_particles')} {loss_summary} "
12678 f"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
12679 f"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
12680 )
12681 else:
12682 print(" unavailable")
12683
12684 memory = payload.get("memory", {})
12685 print("\n Runtime Memory:")
12686 if memory.get("available"):
12687 if memory.get("source"):
12688 print(f" source : {os.path.relpath(memory.get('source'))}")
12689 if memory.get("step") is not None and not memory.get("step_match", True):
12690 print(f" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
12691 if memory.get("event"):
12692 print(f" event : {memory.get('event')} reason={memory.get('reason', '-')}")
12693 print(f" process max : {_format_summary_float(memory.get('process_current_mb_max'), '.3f')} MB current, {_format_summary_float(memory.get('process_peak_mb_max'), '.3f')} MB peak")
12694 print(f" PETSc max : {_format_summary_float(memory.get('petsc_allocated_mb_max'), '.3f')} MB allocated, {_format_summary_float(memory.get('petsc_peak_allocated_mb_max'), '.3f')} MB peak")
12695 print(f" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
12696 if memory.get("final_reason"):
12697 print(f" final reason : {memory.get('final_reason')}")
12698 else:
12699 print(" unavailable")
12700
12701 snapshot = payload.get("particle_snapshot", {})
12702 if snapshot.get("available"):
12703 print("\n Particle Snapshot (sampled):")
12704 print(f" source : {os.path.relpath(snapshot.get('source'))}")
12705 cadence = snapshot.get("cadence", {})
12706 print(
12707 " "
12708 f"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
12709 f"row interval {cadence.get('particle_log_interval', 'n/a')}"
12710 )
12711 print(f" sampled rows : {snapshot.get('sampled_rows')}")
12712 speed = snapshot.get("speed", {})
12713 if speed:
12714 print(
12715 " "
12716 f"sampled speeds: min={_format_summary_float(speed.get('min'))} "
12717 f"mean={_format_summary_float(speed.get('mean'))} "
12718 f"max={_format_summary_float(speed.get('max'))} "
12719 f"std={_format_summary_float(speed.get('std'))} "
12720 f"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
12721 )
12722 bounds = snapshot.get("position_bounds", {})
12723 centroid = snapshot.get("position_centroid")
12724 if bounds:
12725 bound_parts = []
12726 for axis in ("x", "y", "z"):
12727 if axis in bounds:
12728 bound_parts.append(
12729 f"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
12730 )
12731 print(f" sampled bounds: {' '.join(bound_parts)}")
12732 if centroid:
12733 print(
12734 " "
12735 f"sampled center: ({_format_summary_float(centroid[0])}, "
12736 f"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
12737 )
12738 distribution = snapshot.get("sampled_distribution", {})
12739 if distribution:
12740 print(
12741 " "
12742 f"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
12743 f"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
12744 f"unique_pids={distribution.get('unique_pids', 'n/a')} "
12745 f"ranks={distribution.get('rank_counts', {})}"
12746 )
12747 weights = snapshot.get("weights", {})
12748 if weights:
12749 weight_parts = []
12750 for component, summary in sorted(weights.items()):
12751 weight_parts.append(
12752 f"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
12753 )
12754 print(f" sampled weights: {' '.join(weight_parts)}")
12755 checks = snapshot.get("checks", {})
12756 if checks:
12757 print(
12758 " "
12759 f"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
12760 f"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
12761 f"zero_weight={checks.get('zero_weight_count', 0)} "
12762 f"negative_weight={checks.get('negative_weight_count', 0)}"
12763 )
12764 top_speeds = snapshot.get("top_speeds", [])
12765 if top_speeds:
12766 summary = ", ".join(
12767 f"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
12768 for row in top_speeds
12769 )
12770 print(f" top speeds : {summary}")
12771 delta_summary = snapshot.get("delta_from_previous_snapshot", {})
12772 if delta_summary.get("available"):
12773 print(
12774 " "
12775 f"vs prev snap : step={delta_summary.get('previous_step')} "
12776 f"matched_pids={delta_summary.get('matched_pids')} "
12777 f"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
12778 f"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
12779 f"rank_moves={delta_summary.get('rank_migrations')} "
12780 f"cell_changes={delta_summary.get('cell_changes')} "
12781 f"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
12782 )
12783 print(" preview rows :")
12784 for row in snapshot.get("preview_rows", []):
12785 print(
12786 " "
12787 f"pid={row.get('pid')} rank={row.get('rank')} "
12788 f"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
12789 )
12790
12791 profiling = payload.get("profiling", {})
12792 print("\n Profiling:")
12793 if profiling.get("available"):
12794 print(f" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
12795 for row in profiling.get("functions", [])[:5]:
12796 print(
12797 " "
12798 f"{row.get('function')}: calls={row.get('calls')} "
12799 f"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
12800 )
12801 else:
12802 print(" unavailable")
12803 print("=" * 60)
12804
12805
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 12809 of file core.py.

12809def _summary_display_value(value) -> str:
12810 """!
12811 @brief Format one configuration-summary value for compact text output.
12812 @param[in] value Value to format.
12813 @return Compact human-readable value.
12814 """
12815 if value is None:
12816 return "-"
12817 if isinstance(value, bool):
12818 return "enabled" if value else "disabled"
12819 if isinstance(value, float):
12820 return f"{value:.6g}"
12821 if isinstance(value, (list, tuple)):
12822 return ", ".join(_summary_display_value(item) for item in value) if value else "none"
12823 if isinstance(value, dict):
12824 if not value:
12825 return "none"
12826 return ", ".join(f"{key}={_summary_display_value(item)}" for key, item in value.items())
12827 return str(value)
12828
12829
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 12830 of file core.py.

12830def _print_config_header(title: str, subtitle: "str | None" = None):
12831 """!
12832 @brief Print a strong dashboard-style configuration summary header.
12833 @param[in] title Section title.
12834 @param[in] subtitle Optional one-line section subtitle.
12835 """
12836 print("\n" + "=" * _CONFIG_SUMMARY_WIDTH)
12837 print(f"{title:^78}")
12838 if subtitle:
12839 print(f"{subtitle:^78}")
12840 print("=" * _CONFIG_SUMMARY_WIDTH)
12841
12842
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 12843 of file core.py.

12843def _print_config_group(title: str, rows: list):
12844 """!
12845 @brief Print an aligned configuration-summary field group.
12846 @param[in] title Group title.
12847 @param[in] rows Sequence of `(label, value)` pairs.
12848 """
12849 visible_rows = [(label, value) for label, value in rows if value is not None]
12850 if not visible_rows:
12851 return
12852 print(f"\n {title}")
12853 print(f" {'-' * (len(title) + 1)}")
12854 for label, value in visible_rows:
12855 print(f" {label:<32} {_summary_display_value(value)}")
12856
12857
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 12858 of file core.py.

12858def _flatten_summary_mapping(mapping: dict, prefix: str = "") -> list:
12859 """!
12860 @brief Flatten nested summary mappings into readable dotted field rows.
12861 @param[in] mapping Mapping to flatten.
12862 @param[in] prefix Optional parent-field prefix.
12863 @return Sequence of `(field, value)` pairs.
12864 """
12865 rows = []
12866 for key, value in mapping.items():
12867 label = f"{prefix}.{key}" if prefix else str(key)
12868 if isinstance(value, dict) and value:
12869 rows.extend(_flatten_summary_mapping(value, label))
12870 else:
12871 rows.append((label, value))
12872 return rows
12873
12874
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 12875 of file core.py.

12875def _render_run_overview_text(summary: dict):
12876 """!
12877 @brief Render run metadata as a compact dashboard.
12878 @param[in] summary Curated run overview mapping.
12879 """
12880 _print_config_header("RUN OVERVIEW", summary.get("run_id"))
12881 _print_config_group(
12882 "Identity",
12883 [
12884 ("Run directory", os.path.relpath(summary.get("run_dir")) if summary.get("run_dir") else None),
12885 ("Created", summary.get("created_at")),
12886 ("Launch mode", summary.get("launch_mode")),
12887 ("Git commit", summary.get("git_commit")),
12888 ],
12889 )
12890 _print_config_group(
12891 "Execution",
12892 [
12893 ("Solver MPI processes", summary.get("solver_num_procs")),
12894 ("Post MPI processes", summary.get("post_num_procs")),
12895 ("Stages requested", summary.get("stages_requested")),
12896 ("Stages ready/completed", summary.get("stages_completed_or_submitted")),
12897 ],
12898 )
12899
12900
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 12901 of file core.py.

12901def _render_case_summary_text(summary: dict):
12902 """!
12903 @brief Render the case summary as a glanceable simulation dashboard.
12904 @param[in] summary Curated case configuration mapping.
12905 """
12906 run = summary.get("run_control", {})
12907 props = summary.get("properties", {})
12908 grid = summary.get("grid", {})
12909 domain = summary.get("domain", {})
12910 physics = summary.get("physics", {})
12911 subtitle = (
12912 f"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
12913 f"Re={_summary_display_value(props.get('reynolds_number'))}"
12914 )
12915 _print_config_header("CASE SUMMARY", subtitle)
12916 _print_config_group(
12917 "Simulation",
12918 [
12919 ("Step range", f"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
12920 ("Physical timestep", run.get("dt_physical")),
12921 ("Nondimensional timestep", run.get("dt_nondimensional")),
12922 ("Physical duration", run.get("duration_physical")),
12923 ("Initial conditions", props.get("initial_conditions")),
12924 ],
12925 )
12926 _print_config_group(
12927 "Fluid And Scaling",
12928 [
12929 ("Reynolds number", props.get("reynolds_number")),
12930 ("Reference length", props.get("length_ref")),
12931 ("Reference velocity", props.get("velocity_ref")),
12932 ("Density", props.get("density")),
12933 ("Viscosity", props.get("viscosity")),
12934 ],
12935 )
12936 _print_config_group(
12937 "Domain And Grid",
12938 [
12939 ("Grid mode", grid.get("mode")),
12940 ("Blocks", domain.get("blocks")),
12941 ("Dimensionality", domain.get("dimensionality")),
12942 ("Periodic axes", domain.get("periodic")),
12943 ("MPI grid layout", grid.get("processor_layout")),
12944 ("Grid source", grid.get("source_file")),
12945 ],
12946 )
12947 if grid.get("programmatic_settings"):
12948 _print_config_group("Programmatic Grid", _flatten_summary_mapping(grid["programmatic_settings"]))
12949 _print_config_group(
12950 "Physics",
12951 [
12952 ("Particles", physics.get("particles")),
12953 ("FSI", physics.get("fsi")),
12954 ("Turbulence", physics.get("turbulence")),
12955 ("Statistics", physics.get("statistics")),
12956 ],
12957 )
12958 boundary_blocks = summary.get("boundary_conditions", [])
12959 if boundary_blocks:
12960 print("\n Boundary Conditions")
12961 print(" --------------------")
12962 print(f" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
12963 print(f" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
12964 for block in boundary_blocks:
12965 for face in block.get("faces", []):
12966 print(
12967 f" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
12968 f"{face.get('type', '-'):<12} {face.get('handler', '-')}"
12969 )
12970
12971
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 12972 of file core.py.

12972def _render_solver_summary_text(summary: dict):
12973 """!
12974 @brief Render the solver summary as a glanceable numerical-method dashboard.
12975 @param[in] summary Curated solver configuration mapping.
12976 """
12977 momentum = summary.get("momentum", {})
12978 poisson = summary.get("poisson", {})
12979 operation = summary.get("operation_mode", {})
12980 subtitle = (
12981 f"Field: {operation.get('eulerian_field_source', '-')} | "
12982 f"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
12983 )
12984 _print_config_header("SOLVER SUMMARY", subtitle)
12985 _print_config_group("Operation", _flatten_summary_mapping(operation))
12986 _print_config_group(
12987 "Primary Methods",
12988 [
12989 ("Momentum solver", momentum.get("type")),
12990 ("Central differencing", momentum.get("central_diff")),
12991 ("Poisson method", poisson.get("method")),
12992 ("Interpolation", summary.get("interpolation")),
12993 ("Convergence mode", summary.get("solution_convergence", {}).get("mode")),
12994 ],
12995 )
12996 _print_config_group("Momentum Tolerances", _flatten_summary_mapping(momentum.get("tolerances", {})))
12997 _print_config_group("Momentum Controls", _flatten_summary_mapping(momentum.get("controls", {})))
12998 _print_config_group("Poisson Configuration", _flatten_summary_mapping(poisson))
12999 _print_config_group("Solution Convergence", _flatten_summary_mapping(summary.get("solution_convergence", {})))
13000 _print_config_group("Scalar Transport", _flatten_summary_mapping(summary.get("scalar_transport", {})))
13001 _print_config_group("Verification Sources", _flatten_summary_mapping(summary.get("verification", {})))
13002 passthrough = summary.get("petsc_passthrough", {})
13003 _print_config_group(
13004 "Advanced PETSc Options",
13005 [("Option count", passthrough.get("count")), ("Option names", passthrough.get("options"))],
13006 )
13007
13008
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 13009 of file core.py.

13009def _render_monitor_summary_text(summary: dict):
13010 """!
13011 @brief Render the monitor summary as a glanceable observability dashboard.
13012 @param[in] summary Curated monitor configuration mapping.
13013 """
13014 logging_cfg = summary.get("logging", {})
13015 profiling = summary.get("profiling", {})
13016 diagnostics = summary.get("diagnostics", {})
13017 io_cfg = summary.get("io", {})
13018 memory_log = diagnostics.get("runtime_memory_log", {})
13019 subtitle = (
13020 f"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
13021 f"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
13022 )
13023 _print_config_header("MONITOR SUMMARY", subtitle)
13024 _print_config_group(
13025 "Logging",
13026 [
13027 ("Verbosity", logging_cfg.get("verbosity")),
13028 ("Enabled functions", logging_cfg.get("enabled_functions")),
13029 ],
13030 )
13031 _print_config_group("Profiling", _flatten_summary_mapping(profiling))
13032 _print_config_group(
13033 "Output Cadence",
13034 [
13035 ("Field output", io_cfg.get("data_output_frequency")),
13036 ("Particle snapshots", io_cfg.get("particle_console_output_frequency")),
13037 ("Particle row interval", io_cfg.get("particle_log_interval")),
13038 ],
13039 )
13040 _print_config_group("Output Directories", _flatten_summary_mapping(io_cfg.get("directories", {})))
13041 _print_config_group(
13042 "Diagnostics",
13043 [
13044 ("Enabled PETSc diagnostics", diagnostics.get("enabled_petsc")),
13045 ("Runtime memory log", memory_log.get("enabled")),
13046 ("Runtime memory file", memory_log.get("file")),
13047 ],
13048 )
13049 _print_config_group("PETSc Diagnostic Settings", _flatten_summary_mapping(diagnostics.get("petsc", {})))
13050 solver_monitoring = summary.get("solver_monitoring", {})
13051 _print_config_group(
13052 "Solver Monitoring",
13053 [
13054 ("Enabled flags", solver_monitoring.get("enabled_flags")),
13055 ("All flags", solver_monitoring.get("flags")),
13056 ],
13057 )
13058
13059
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 13060 of file core.py.

13060def render_selected_summary(payload: dict, output_format: str = "text"):
13061 """!
13062 @brief Render selected timestep-independent config views and optional health.
13063 @param[in] payload Combined selected summary payload.
13064 @param[in] output_format Output format.
13065 """
13066 if output_format == "json":
13067 json_payload = {key: value for key, value in payload.items() if key != "_health_requested"}
13068 print(json.dumps(json_payload, indent=2, sort_keys=True))
13069 return
13070
13071 if payload.get("run_overview") is not None:
13072 _render_run_overview_text(payload["run_overview"])
13073 renderers = {
13074 "case": _render_case_summary_text,
13075 "solver": _render_solver_summary_text,
13076 "monitor": _render_monitor_summary_text,
13077 }
13078 for key in ("case", "solver", "monitor"):
13079 if key in payload.get("configuration", {}):
13080 renderers[key](payload["configuration"][key])
13081 if payload.get("_health_requested"):
13082 health_payload = {key: value for key, value in payload.items() if key not in {"run_overview", "configuration", "_health_requested"}}
13083 render_run_summary(health_payload, output_format="text")
13084
13085
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 
)
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.

Definition at line 13093 of file core.py.

13093def _append_summary_plot_record(records: list, source: str, step, line: str, values: dict, source_path: str, segment: int = 0):
13094 """!
13095 @brief Append one numeric append-ordered record for summarize plotting.
13096 @param[out] records Destination record list.
13097 @param[in] source Qualified source prefix.
13098 @param[in] step Logged timestep.
13099 @param[in] line Human-readable line identity.
13100 @param[in] values Candidate field mapping.
13101 @param[in] source_path Source artifact path.
13102 @param[in] segment Zero-based continuation segment within the source artifact.
13103 """
13104 numeric = {
13105 key: value
13106 for key, value in values.items()
13107 if isinstance(value, (int, float)) and not isinstance(value, bool)
13108 }
13109 if step is not None and numeric:
13110 records.append({
13111 "source": source,
13112 "step": int(step),
13113 "line": line,
13114 "values": numeric,
13115 "source_path": source_path,
13116 "segment": int(segment),
13117 })
13118
13119
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 13120 of file core.py.

13120def _is_summary_plot_continuation_marker(line: str) -> bool:
13121 """!
13122 @brief Return whether a log line starts a new continuation segment.
13123 @param[in] line Candidate raw or stripped log line.
13124 @return True for the shared continuation marker syntax.
13125 """
13126 return bool(re.match(r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
13127
13128
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 13129 of file core.py.

13129def _collect_summary_plot_records(context: dict) -> list:
13130 """!
13131 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
13132 @param[in] context Summary context returned by `_build_summary_context()`.
13133 @return Append-ordered plot record list.
13134 """
13135 records = []
13136 log_dir = context["log_dir"]
13137
13138 continuity_path = os.path.join(log_dir, "Continuity_Metrics.log")
13139 if os.path.isfile(continuity_path):
13140 segment = 0
13141 with open(continuity_path, "r", encoding="utf-8", errors="replace") as f:
13142 for raw_line in f:
13143 if _is_summary_plot_continuation_marker(raw_line):
13144 segment += 1
13145 continue
13146 parts = [part.strip() for part in raw_line.split("|")]
13147 if len(parts) < 8:
13148 continue
13149 step, block = _parse_int_loose(parts[0]), _parse_int_loose(parts[1])
13150 _append_summary_plot_record(
13151 records, "continuity", step, f"block {block}",
13152 {
13153 "max_divergence": _parse_float_loose(parts[2]),
13154 "rhs_sum": _parse_float_loose(parts[4]),
13155 "flux_in": _parse_float_loose(parts[5]),
13156 "flux_out": _parse_float_loose(parts[6]),
13157 "net_flux": _parse_float_loose(parts[7]),
13158 },
13159 continuity_path, segment,
13160 )
13161
13162 particle_path = os.path.join(log_dir, "Particle_Metrics.log")
13163 if os.path.isfile(particle_path):
13164 segment = 0
13165 with open(particle_path, "r", encoding="utf-8", errors="replace") as f:
13166 for raw_line in f:
13167 if _is_summary_plot_continuation_marker(raw_line):
13168 segment += 1
13169 continue
13170 parts = [part.strip() for part in raw_line.split("|")]
13171 if len(parts) < 8:
13172 continue
13173 step = _parse_int_loose(parts[1])
13174 offset = 1 if len(parts) >= 9 else 0
13175 _append_summary_plot_record(
13176 records, "particles", step, "particles",
13177 {
13178 "total_particles": _parse_int_loose(parts[2]),
13179 "lost_particles": _parse_int_loose(parts[3]),
13180 "lost_particles_cumulative": _parse_int_loose(parts[4]) if offset else None,
13181 "migrated_particles": _parse_int_loose(parts[4 + offset]),
13182 "occupied_cells": _parse_int_loose(parts[5 + offset]),
13183 "load_imbalance": _parse_float_loose(parts[6 + offset]),
13184 "migration_passes": _parse_int_loose(parts[7 + offset]),
13185 },
13186 particle_path, segment,
13187 )
13188
13189 momentum_regex = re.compile(
13190 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\‍(k\‍):\s*(?P<pseudo_iter>\d+)\|\s*\|"
13191 r"\s*Pseudo-cfl:\s*(?P<pseudo_cfl>[-+0-9.eE]+)\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
13192 r"\s*\|dUk\|/\|dUprev\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
13193 r"\s*\|Rk\|/\|Rprev\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
13194 )
13195 for path in sorted(glob.glob(os.path.join(log_dir, "Momentum_Solver_Convergence_History_Block_*.log"))):
13196 block_match = re.search(r"Block_(\d+)\.log$", path)
13197 if not block_match:
13198 continue
13199 segment = 0
13200 with open(path, "r", encoding="utf-8", errors="replace") as f:
13201 for raw_line in f:
13202 if _is_summary_plot_continuation_marker(raw_line):
13203 segment += 1
13204 continue
13205 match = momentum_regex.search(raw_line)
13206 if match:
13207 _append_summary_plot_record(
13208 records, "momentum", int(match.group("step")), f"block {block_match.group(1)}",
13209 {
13210 "pseudo_iterations": int(match.group("pseudo_iter")),
13211 "pseudo_cfl": float(match.group("pseudo_cfl")),
13212 "delta_norm": float(match.group("delta")),
13213 "delta_rel": float(match.group("delta_rel")),
13214 "residual_norm": float(match.group("resid")),
13215 "residual_rel": float(match.group("resid_rel")),
13216 },
13217 path, segment,
13218 )
13219
13220 poisson_regex = re.compile(
13221 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
13222 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
13223 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
13224 )
13225 for path in sorted(glob.glob(os.path.join(log_dir, "Poisson_Solver_Convergence_History_Block_*.log"))):
13226 segment = 0
13227 with open(path, "r", encoding="utf-8", errors="replace") as f:
13228 for raw_line in f:
13229 if _is_summary_plot_continuation_marker(raw_line):
13230 segment += 1
13231 continue
13232 match = poisson_regex.search(raw_line)
13233 if match:
13234 _append_summary_plot_record(
13235 records, "poisson", int(match.group("step")), f"block {match.group('block')}",
13236 {
13237 "iterations": int(match.group("iter")),
13238 "unpreconditioned_norm": float(match.group("unpre")),
13239 "true_norm": float(match.group("true")),
13240 "relative_norm": _parse_float_loose(match.group("rel")),
13241 },
13242 path, segment,
13243 )
13244
13245 profiling_path = os.path.join(log_dir, context["profiling_cfg"].get("timestep_file", "Profiling_Timestep_Summary.csv"))
13246 if os.path.isfile(profiling_path):
13247 segment = 0
13248 columns = None
13249 with open(profiling_path, "r", encoding="utf-8", errors="replace", newline="") as f:
13250 for raw_line in f:
13251 if _is_summary_plot_continuation_marker(raw_line):
13252 segment += 1
13253 continue
13254 values = next(csv.reader([raw_line]))
13255 if not values:
13256 continue
13257 if columns is None:
13258 columns = values
13259 continue
13260 row = dict(zip(columns, values))
13261 _append_summary_plot_record(
13262 records, "profiling", _parse_int_loose(row.get("step")), row.get("function") or "unknown",
13263 {"calls": _parse_int_loose(row.get("calls")), "step_time_s": _parse_float_loose(row.get("step_time_s"))},
13264 profiling_path, segment,
13265 )
13266
13267 diagnostics = resolve_diagnostics_config(context["monitor_cfg"])
13268 memory_path = os.path.join(log_dir, diagnostics["runtime_memory_log"].get("file", "Runtime_Memory.log"))
13269 if os.path.isfile(memory_path):
13270 segment = 0
13271 with open(memory_path, "r", encoding="utf-8", errors="replace") as f:
13272 for raw_line in f:
13273 if _is_summary_plot_continuation_marker(raw_line):
13274 segment += 1
13275 continue
13276 parts = raw_line.split()
13277 if len(parts) >= 8 and parts[1] in {"Step", "Post"}:
13278 _append_summary_plot_record(
13279 records, "memory", _parse_int_loose(parts[0]), "memory",
13280 {
13281 "process_current_mb_max": _parse_float_loose(parts[2]),
13282 "process_peak_mb_max": _parse_float_loose(parts[3]),
13283 "petsc_allocated_mb_max": _parse_float_loose(parts[4]),
13284 "petsc_peak_allocated_mb_max": _parse_float_loose(parts[5]),
13285 "process_change_mb_max": _parse_float_loose(parts[6]),
13286 },
13287 memory_path, segment,
13288 )
13289
13290 convergence_path = os.path.join(log_dir, "solution_convergence.log")
13291 if os.path.isfile(convergence_path):
13292 columns = None
13293 segment = 0
13294 with open(convergence_path, "r", encoding="utf-8", errors="replace") as f:
13295 for raw_line in f:
13296 line = raw_line.strip()
13297 if _is_summary_plot_continuation_marker(line):
13298 segment += 1
13299 continue
13300 if not line or line.startswith(("=", "-")):
13301 continue
13302 if columns is None:
13303 columns = [part.strip() for part in raw_line.split("|")]
13304 continue
13305 parts = [part.strip() for part in raw_line.split("|")]
13306 step = _parse_int_loose(parts[0])
13307 values = {name: _parse_float_loose(value) for name, value in zip(columns, parts) if name not in {"step", "mode", "ref"}}
13308 _append_summary_plot_record(records, "convergence", step, "convergence", values, convergence_path, segment)
13309 return records
13310
13311
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 13312 of file core.py.

13312def _build_summary_plot_catalog(records: list) -> list:
13313 """!
13314 @brief Build available qualified-series metadata from plot records.
13315 @param[in] records Append-ordered plot record list.
13316 @return Available series catalog.
13317 """
13318 catalog = {}
13319 for record in records:
13320 for field in record["values"]:
13321 name = f"{record['source']}.{field}"
13322 item = catalog.setdefault(name, {"series": name, "lines": {}, "source_paths": set(), "sample_count": 0})
13323 item["lines"][record["line"]] = item["lines"].get(record["line"], 0) + 1
13324 item["source_paths"].add(record["source_path"])
13325 item["sample_count"] += 1
13326 return [
13327 {
13328 **item,
13329 "lines": [{"label": label, "sample_count": count} for label, count in sorted(item["lines"].items())],
13330 "source_paths": sorted(item["source_paths"]),
13331 }
13332 for _, item in sorted(catalog.items())
13333 ]
13334
13335
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 13336 of file core.py.

13336def _build_summary_plot_request(context: dict, records: list, series: str, last_n: "int | None", linear_y: bool, output_path: "str | None") -> dict:
13337 """!
13338 @brief Build one normalized plot.gen request from collected summarize records.
13339 @param[in] context Summary context returned by `_build_summary_context()`.
13340 @param[in] records Append-ordered plot record list.
13341 @param[in] series Qualified series name.
13342 @param[in] last_n Optional last-N records per plotted line.
13343 @param[in] linear_y Whether to force linear scaling.
13344 @param[in] output_path Optional explicit output path.
13345 @return Versioned normalized plot request.
13346 """
13347 source, separator, field = series.partition(".")
13348 if not separator:
13349 raise ValueError("plot series must be qualified as '<source>.<field>'")
13350 matching = [record for record in records if record["source"] == source and field in record["values"]]
13351 if not matching:
13352 raise ValueError(f"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
13353 latest_segments = {}
13354 for record in matching:
13355 source_path = record["source_path"]
13356 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get("segment", 0))
13357 matching = [
13358 record for record in matching
13359 if record.get("segment", 0) == latest_segments[record["source_path"]]
13360 ]
13361 grouped = {}
13362 for record in matching:
13363 grouped.setdefault(record["line"], []).append([record["step"], record["values"][field]])
13364 if last_n is not None:
13365 grouped = {label: points[-last_n:] for label, points in grouped.items()}
13366 all_values = [point[1] for points in grouped.values() for point in points]
13367 use_log = not linear_y and field in _SUMMARY_PLOT_LOG_SCALE_FIELDS and all(value > 0 for value in all_values)
13368 window_token = f"last-{last_n}" if last_n is not None else "full"
13369 safe_series = re.sub(r"[^A-Za-z0-9_.-]+", "_", series)
13370 fallback = os.path.join(context["run_dir"], "summary", "plots", f"{safe_series}_{window_token}.png")
13371 return {
13372 "schema_version": 1,
13373 "plot_type": "time_history",
13374 "series": series,
13375 "title": f"{series} time history",
13376 "x_label": "Timestep",
13377 "y_label": series,
13378 "y_scale": "log" if use_log else "linear",
13379 "window": {"mode": "last" if last_n is not None else "full", "last": last_n},
13380 "lines": [{"label": label, "points": points} for label, points in sorted(grouped.items())],
13381 "output_path": os.path.abspath(output_path) if output_path else None,
13382 "fallback_output_path": fallback,
13383 }
13384
13385
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 13386 of file core.py.

13386def _render_summary_plot_catalog(catalog: list, output_format: str):
13387 """!
13388 @brief Render available summarize plot-series metadata.
13389 @param[in] catalog Available series catalog.
13390 @param[in] output_format Text or JSON output format.
13391 """
13392 if output_format == "json":
13393 print(json.dumps({"available_series": catalog}, indent=2, sort_keys=True))
13394 return
13395 print("\nAVAILABLE TIME-HISTORY SERIES")
13396 print("=" * 78)
13397 for item in catalog:
13398 labels = ", ".join(line["label"] for line in item["lines"])
13399 print(f" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
13400 print(f" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
13401
13402
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 13403 of file core.py.

13403def _invoke_plot_gen(request: dict):
13404 """!
13405 @brief Invoke standalone plot.gen with one normalized request over stdin.
13406 @param[in] request Versioned normalized plot request.
13407 """
13408 plotgen_path = os.path.join(GENERATORS_PATH, "plot.gen")
13409 if not os.path.isfile(plotgen_path):
13410 raise ValueError(f"plot.gen script not found: {plotgen_path}")
13411 result = subprocess.run(
13412 [sys.executable, plotgen_path, "--input", "-"],
13413 input=json.dumps(request),
13414 text=True,
13415 capture_output=True,
13416 check=False,
13417 )
13418 if result.stdout:
13419 print(result.stdout.rstrip())
13420 if result.returncode != 0:
13421 details = (result.stderr or result.stdout or "unknown plotting error").strip()
13422 if result.returncode == 3:
13423 raise PlotDependencyError(details)
13424 raise ValueError(f"plot.gen failed with exit code {result.returncode}: {details}")
13425
13426
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 13427 of file core.py.

13427def summarize_workflow(args):
13428 """!
13429 @brief Build and render a read-only health summary for a run step.
13430 @param[in] args Command-line style argument list supplied to the function.
13431 """
13432 if args.step is not None and args.step < 0:
13433 fail_cli_usage("--step must be a non-negative integer.")
13434 if args.snapshot_rows < 1:
13435 fail_cli_usage("--snapshot-rows must be at least 1.")
13436 plot_series = getattr(args, "plot_series", None)
13437 list_plot_series = bool(getattr(args, "list_plot_series", False))
13438 last_n = getattr(args, "last_n", None)
13439 plot_output = getattr(args, "plot_output", None)
13440 linear_y = bool(getattr(args, "linear_y", False))
13441 plot_mode = bool(plot_series or list_plot_series)
13442 existing_selectors = any(
13443 [
13444 getattr(args, "overview", False),
13445 getattr(args, "case", False),
13446 getattr(args, "solver", False),
13447 getattr(args, "monitor", False),
13448 args.step is not None,
13449 getattr(args, "latest", False),
13450 getattr(args, "max_step", False),
13451 ]
13452 )
13453 if plot_mode and existing_selectors:
13454 fail_cli_usage("Plot discovery and --plot cannot be combined with config or selected-step selectors.")
13455 if not plot_series and (last_n is not None or plot_output or linear_y):
13456 fail_cli_usage("--last, --plot-output, and --linear-y require --plot.")
13457 if last_n is not None and last_n < 1:
13458 fail_cli_usage("--last must be a positive integer.")
13459 if plot_series and args.output_format == "json":
13460 fail_cli_usage("--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
13461 if plot_mode:
13462 context = _build_summary_context(args.run_dir)
13463 try:
13464 records = _collect_summary_plot_records(context)
13465 catalog = _build_summary_plot_catalog(records)
13466 if list_plot_series:
13467 if not catalog:
13468 raise ValueError("No plottable scalar histories were found in the run logs.")
13469 _render_summary_plot_catalog(catalog, args.output_format)
13470 return
13471 request = _build_summary_plot_request(context, records, plot_series, last_n, linear_y, plot_output)
13472 _invoke_plot_gen(request)
13473 return
13474 except PlotDependencyError as exc:
13475 emit_structured_error(
13476 ERROR_CODE_DEPENDENCY_MISSING,
13477 key="plotting",
13478 file_path=sys.executable,
13479 message=str(exc),
13480 )
13481 sys.exit(1)
13482 except ValueError as exc:
13483 emit_structured_error(
13484 ERROR_CODE_CFG_INVALID_VALUE,
13485 key="plot",
13486 file_path=context["log_dir"],
13487 message=str(exc),
13488 )
13489 sys.exit(1)
13490
13491 selected_configs = {
13492 name
13493 for name in ("case", "solver", "monitor")
13494 if bool(getattr(args, name, False))
13495 }
13496 if getattr(args, "overview", False):
13497 selected_configs.update({"case", "solver", "monitor"})
13498 explicit_health = args.step is not None or bool(getattr(args, "latest", False)) or bool(getattr(args, "max_step", False))
13499 health_requested = explicit_health or (not selected_configs and not getattr(args, "overview", False))
13500
13501 context = None
13502 combined = {}
13503 if selected_configs or getattr(args, "overview", False):
13504 context = _build_summary_context(args.run_dir)
13505 if getattr(args, "overview", False):
13506 combined["run_overview"] = _build_run_overview(context)
13507 combined["configuration"] = {}
13508 builders = {
13509 "case": _build_case_overview,
13510 "solver": _build_solver_overview,
13511 "monitor": _build_monitor_overview,
13512 }
13513 for name in ("case", "solver", "monitor"):
13514 if name in selected_configs:
13515 try:
13516 combined["configuration"][name] = builders[name](context)
13517 except (KeyError, TypeError, ValueError, ZeroDivisionError) as exc:
13518 emit_structured_error(
13519 ERROR_CODE_CFG_INVALID_VALUE,
13520 key=name,
13521 file_path=context["config_paths"][name],
13522 message=f"Could not summarize copied {name}.yml: {exc}",
13523 )
13524 sys.exit(1)
13525
13526 if not health_requested:
13527 combined["_health_requested"] = False
13528 render_selected_summary(combined, output_format=args.output_format)
13529 return
13530
13531 requested_step = args.step
13532 if requested_step is None and getattr(args, "latest", False):
13533 requested_step = None
13534 selection_mode = "max_step" if getattr(args, "max_step", False) else "latest"
13535 health_payload = build_run_summary_payload(
13536 args.run_dir,
13537 step=requested_step,
13538 snapshot_rows=args.snapshot_rows,
13539 selection_mode=selection_mode,
13540 )
13541 if not combined:
13542 render_run_summary(health_payload, output_format=args.output_format)
13543 return
13544 combined = {**health_payload, **combined, "_health_requested": True}
13545 render_selected_summary(combined, output_format=args.output_format)
13546
13547
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 13548 of file core.py.

13548def _resolve_submission_target(run_dir: str = None, study_dir: str = None) -> dict:
13549 """!
13550 @brief Resolve a run/study submission target from explicit directory flags.
13551 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
13552 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
13553 @return Value returned by `_resolve_submission_target()`.
13554 """
13555 has_run_dir = bool(run_dir)
13556 has_study_dir = bool(study_dir)
13557 if has_run_dir == has_study_dir:
13558 fail_cli_usage("submit requires exactly one of --run-dir or --study-dir.")
13559
13560 target_kind = "run" if has_run_dir else "study"
13561 target_key = "run_dir" if target_kind == "run" else "study_dir"
13562 root_dir = os.path.abspath(run_dir if has_run_dir else study_dir)
13563 if not os.path.isdir(root_dir):
13564 emit_structured_error(
13565 ERROR_CODE_CFG_FILE_NOT_FOUND,
13566 key=target_key,
13567 file_path=root_dir,
13568 message=f"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
13569 )
13570 sys.exit(1)
13571
13572 scheduler_dir = os.path.join(root_dir, "scheduler")
13573 submission_path = os.path.join(scheduler_dir, "submission.json")
13574 submission_meta = _read_json_if_exists(submission_path)
13575 if not isinstance(submission_meta, dict):
13576 emit_structured_error(
13577 ERROR_CODE_CFG_FILE_NOT_FOUND,
13578 key="scheduler.submission",
13579 file_path=submission_path,
13580 message="Target directory does not contain scheduler submission metadata.",
13581 hint="Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
13582 )
13583 sys.exit(1)
13584
13585 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
13586 if launch_mode == "local" and target_kind != "run":
13587 emit_structured_error(
13588 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13589 key="scheduler.launch_mode",
13590 file_path=submission_path,
13591 message="Local staged submission is supported for run directories only.",
13592 hint="Use --run-dir for local staged execution; study submit remains Slurm-only.",
13593 )
13594 sys.exit(1)
13595 if launch_mode not in {"slurm", "local"}:
13596 emit_structured_error(
13597 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13598 key="scheduler.launch_mode",
13599 file_path=submission_path,
13600 message=f"Target launch_mode={launch_mode or 'unknown'} is not supported.",
13601 hint="Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
13602 )
13603 sys.exit(1)
13604
13605 if target_kind == "run":
13606 script_map = {
13607 "solve": os.path.join(scheduler_dir, "solver.sbatch"),
13608 "post-process": os.path.join(scheduler_dir, "post.sbatch"),
13609 }
13610 display_label = "Run directory"
13611 manifest_path = None
13612 else:
13613 script_map = {
13614 "solve": os.path.join(scheduler_dir, "solver_array.sbatch"),
13615 "post-process": os.path.join(scheduler_dir, "post_array.sbatch"),
13616 }
13617 display_label = "Study directory"
13618 manifest_path = os.path.join(root_dir, "study_manifest.json")
13619
13620 return {
13621 "target_kind": target_kind,
13622 "target_key": target_key,
13623 "root_dir": root_dir,
13624 "scheduler_dir": scheduler_dir,
13625 "submission_path": submission_path,
13626 "submission_meta": submission_meta,
13627 "launch_mode": launch_mode,
13628 "script_map": script_map,
13629 "display_label": display_label,
13630 "manifest_path": manifest_path,
13631 }
13632
13633
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 13634 of file core.py.

13634def _get_submission_stage_metadata(target_context: dict, stage_name: str) -> dict:
13635 """!
13636 @brief Return stored metadata for one staged submission target.
13637 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
13638 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
13639 @return Value returned by `_get_submission_stage_metadata()`.
13640 """
13641 submission_meta = target_context["submission_meta"]
13642 if target_context["target_kind"] == "run":
13643 stages = submission_meta.get("stages", {})
13644 if not isinstance(stages, dict):
13645 return {}
13646 stage_meta = stages.get(stage_name)
13647 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
13648
13649 key = "solver_array" if stage_name == "solve" else "post_array"
13650 stage_meta = submission_meta.get(key)
13651 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
13652
13653
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 13654 of file core.py.

13654def _get_recorded_submission_stages(target_context: dict) -> list:
13655 """!
13656 @brief Return stage names explicitly recorded in scheduler submission metadata.
13657 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
13658 @return Value returned by `_get_recorded_submission_stages()`.
13659 """
13660 submission_meta = target_context["submission_meta"]
13661 recorded = []
13662 if target_context["target_kind"] == "run":
13663 stages = submission_meta.get("stages", {})
13664 if isinstance(stages, dict):
13665 for stage_name in ["solve", "post-process"]:
13666 if isinstance(stages.get(stage_name), dict):
13667 recorded.append(stage_name)
13668 return recorded
13669
13670 if isinstance(submission_meta.get("solver_array"), dict):
13671 recorded.append("solve")
13672 if isinstance(submission_meta.get("post_array"), dict):
13673 recorded.append("post-process")
13674 return recorded
13675
13676
Here is the caller graph for this function:

◆ _format_stage_list()

str picurv_cli.core._format_stage_list ( list  stage_names)
protected

Format a human-readable stage list for submit diagnostics.

Parameters
[in]stage_namesArgument passed to _format_stage_list().
Returns
Value returned by _format_stage_list().

Definition at line 13677 of file core.py.

13677def _format_stage_list(stage_names: list) -> str:
13678 """!
13679 @brief Format a human-readable stage list for submit diagnostics.
13680 @param[in] stage_names Argument passed to `_format_stage_list()`.
13681 @return Value returned by `_format_stage_list()`.
13682 """
13683 return ", ".join(stage_names) if stage_names else "none"
13684
13685

◆ _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 13686 of file core.py.

13686def _build_submit_missing_stage_hint(target_context: dict, requested_stage: str, selected_stages: list) -> str:
13687 """!
13688 @brief Build an actionable hint for requested submit stages missing from metadata.
13689 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
13690 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
13691 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
13692 @return Value returned by `_build_submit_missing_stage_hint()`.
13693 """
13694 recorded_stages = _get_recorded_submission_stages(target_context)
13695 recorded_set = set(recorded_stages)
13696 selected_set = set(selected_stages)
13697 target_flag = "--run-dir" if target_context["target_kind"] == "run" else "--study-dir"
13698 target_path = os.path.relpath(target_context["root_dir"])
13699 submit_prefix = f"picurv submit {target_flag} {target_path}"
13700 solve_stage_command = (
13701 "picurv run --solve ... --no-submit"
13702 if target_context["target_kind"] == "run"
13703 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
13704 )
13705 post_stage_command = (
13706 "picurv run --post-process --post <post.yml> ... --no-submit"
13707 if target_context["target_kind"] == "run"
13708 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
13709 )
13710 solve_post_command = (
13711 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
13712 if target_context["target_kind"] == "run"
13713 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
13714 )
13715
13716 if requested_stage == "all":
13717 if recorded_set == {"solve"}:
13718 return (
13719 "--stage all requests solve and post-process, but this target records only solve. "
13720 f"Use `{submit_prefix} --stage solve`, or re-stage with post-processing enabled "
13721 f"(`{solve_post_command}`)."
13722 )
13723 if recorded_set == {"post-process"}:
13724 return (
13725 "--stage all requests solve and post-process, but this target records only post-process. "
13726 f"Use `{submit_prefix} --stage post-process`, or re-stage including the solve stage "
13727 f"(`{solve_stage_command}`)."
13728 )
13729 missing = [stage for stage in selected_stages if stage not in recorded_set]
13730 if missing:
13731 return (
13732 "--stage all requests solve and post-process, but submission metadata records "
13733 f"{_format_stage_list(recorded_stages)}. Re-stage the missing stage(s): "
13734 f"{_format_stage_list(missing)}."
13735 )
13736
13737 if selected_set == {"solve"} and "solve" not in recorded_set:
13738 return (
13739 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
13740 f"Re-stage with `{solve_stage_command}`."
13741 )
13742 if selected_set == {"post-process"} and "post-process" not in recorded_set:
13743 return (
13744 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
13745 f"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
13746 )
13747
13748 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
13749
13750
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 13751 of file core.py.

13751def _set_submission_stage_metadata(target_context: dict, stage_name: str, stage_meta: dict):
13752 """!
13753 @brief Persist one stage's metadata back into the submission payload.
13754 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
13755 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
13756 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
13757 """
13758 submission_meta = target_context["submission_meta"]
13759 if target_context["target_kind"] == "run":
13760 stages = submission_meta.get("stages")
13761 if not isinstance(stages, dict):
13762 stages = {}
13763 submission_meta["stages"] = stages
13764 stages[stage_name] = stage_meta
13765 return
13766
13767 key = "solver_array" if stage_name == "solve" else "post_array"
13768 submission_meta[key] = stage_meta
13769
13770
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 13771 of file core.py.

13771def _write_submission_target_metadata(target_context: dict):
13772 """!
13773 @brief Write updated submission metadata back to disk.
13774 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
13775 """
13776 write_json_file(target_context["submission_path"], target_context["submission_meta"])
13777
13778 manifest_path = target_context.get("manifest_path")
13779 if manifest_path and os.path.isfile(manifest_path):
13780 manifest_payload = _read_json_if_exists(manifest_path)
13781 if isinstance(manifest_payload, dict):
13782 manifest_payload["submission"] = target_context["submission_meta"]
13783 write_json_file(manifest_path, manifest_payload)
13784
13785
Here is the call graph for this function:
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 13786 of file core.py.

13786def submit_staged_jobs(args):
13787 """!
13788 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
13789 @param[in] args Command-line style argument list supplied to the function.
13790 """
13791 target_context = _resolve_submission_target(
13792 run_dir=getattr(args, "run_dir", None),
13793 study_dir=getattr(args, "study_dir", None),
13794 )
13795 stage_order = ["solve", "post-process"]
13796 requested_stage = args.stage
13797 selected_stages = stage_order if requested_stage == "all" else [requested_stage]
13798
13799 print(f"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
13800 print(f"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
13801 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
13802
13803 if target_context.get("launch_mode") == "local":
13804 submit_staged_local_run(args, target_context, selected_stages)
13805 return
13806
13807 stage_plans = []
13808 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
13809 solve_existing_job_id = str(solve_existing_meta.get("job_id", "")).strip()
13810
13811 for stage_name in selected_stages:
13812 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
13813 script_path = target_context["script_map"][stage_name]
13814 missing_stage_hint = _build_submit_missing_stage_hint(target_context, requested_stage, selected_stages)
13815 if not existing_meta:
13816 emit_structured_error(
13817 ERROR_CODE_CFG_MISSING_KEY,
13818 key=f"scheduler.{stage_name}.metadata",
13819 file_path=target_context["submission_path"],
13820 message=f"Submission metadata does not record stage '{stage_name}'.",
13821 hint=missing_stage_hint,
13822 )
13823 sys.exit(1)
13824
13825 if not os.path.isfile(script_path):
13826 emit_structured_error(
13827 ERROR_CODE_CFG_FILE_NOT_FOUND,
13828 key=f"scheduler.{stage_name}.script",
13829 file_path=script_path,
13830 message=f"Required {stage_name} sbatch artifact is missing.",
13831 hint=missing_stage_hint,
13832 )
13833 sys.exit(1)
13834
13835 if existing_meta.get("submitted") and not args.force:
13836 emit_structured_error(
13837 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13838 key=f"scheduler.{stage_name}.submitted",
13839 file_path=target_context["submission_path"],
13840 message=f"Stage '{stage_name}' is already recorded as submitted.",
13841 hint="Use --force to resubmit this stage intentionally.",
13842 )
13843 sys.exit(1)
13844
13845 dependency = None
13846 if stage_name == "post-process":
13847 if "solve" in selected_stages:
13848 dependency = "__NEW_SOLVE_JOB_ID__"
13849 else:
13850 if not (solve_existing_meta.get("submitted") and solve_existing_job_id):
13851 emit_structured_error(
13852 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13853 key="scheduler.post-process.dependency",
13854 file_path=target_context["submission_path"],
13855 message="Post-process submission requires a recorded solve job id when solve is not being submitted in the same command.",
13856 hint="Submit --stage solve or --stage all first, or use --force only after solve metadata exists.",
13857 )
13858 sys.exit(1)
13859 dependency = solve_existing_job_id
13860
13861 stage_plans.append(
13862 {
13863 "stage": stage_name,
13864 "script": script_path,
13865 "dependency": dependency,
13866 "existing_meta": existing_meta,
13867 }
13868 )
13869
13870 if args.dry_run:
13871 for plan in stage_plans:
13872 cmd = ["sbatch"]
13873 dependency = plan["dependency"]
13874 if dependency == "__NEW_SOLVE_JOB_ID__":
13875 cmd.append("--dependency=afterok:<new solve job id>")
13876 elif dependency:
13877 cmd.append(f"--dependency=afterok:{dependency}")
13878 cmd.append(plan["script"])
13879 print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
13880 print("[INFO] Dry-run only. No jobs were submitted.")
13881 return
13882
13883 latest_solve_job_id = None
13884 for plan in stage_plans:
13885 dependency = plan["dependency"]
13886 if dependency == "__NEW_SOLVE_JOB_ID__":
13887 dependency = latest_solve_job_id
13888
13889 submit_info = submit_sbatch(plan["script"], dependency=dependency)
13890 stage_meta = copy.deepcopy(plan["existing_meta"])
13891 stage_meta.update(submit_info)
13892 stage_meta["script"] = plan["script"]
13893 stage_meta["submitted"] = True
13894 if dependency:
13895 stage_meta["dependency"] = f"afterok:{dependency}"
13896 else:
13897 stage_meta.pop("dependency", None)
13898
13899 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
13900 print(f"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
13901
13902 if plan["stage"] == "solve":
13903 latest_solve_job_id = submit_info["job_id"]
13904
13905 _write_submission_target_metadata(target_context)
13906
13907
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 13908 of file core.py.

13908def submit_staged_local_run(args, target_context: dict, selected_stages: list):
13909 """!
13910 @brief Execute previously staged local run commands from scheduler/submission.json.
13911 @param[in] args Command-line style argument list supplied to the function.
13912 @param[in] target_context Resolved submission target context.
13913 @param[in] selected_stages Ordered stage names selected by the user.
13914 """
13915 if target_context["target_kind"] != "run":
13916 emit_structured_error(
13917 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13918 key="scheduler.launch_mode",
13919 file_path=target_context["submission_path"],
13920 message="Local staged execution is supported for run directories only.",
13921 hint="Use --run-dir for local staged execution.",
13922 )
13923 sys.exit(1)
13924
13925 stage_plans = []
13926 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
13927 solve_already_done = bool(solve_existing_meta.get("submitted") or solve_existing_meta.get("executed"))
13928
13929 for stage_name in selected_stages:
13930 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
13931 command = existing_meta.get("command")
13932 if not isinstance(command, list) or not command:
13933 if existing_meta:
13934 hint = "Re-stage the run with picurv run --no-submit before calling picurv submit."
13935 else:
13936 hint = _build_submit_missing_stage_hint(target_context, args.stage, selected_stages)
13937 emit_structured_error(
13938 ERROR_CODE_CFG_MISSING_KEY,
13939 key=f"scheduler.{stage_name}.command",
13940 file_path=target_context["submission_path"],
13941 message=f"Required local command metadata for stage '{stage_name}' is missing.",
13942 hint=hint,
13943 )
13944 sys.exit(1)
13945
13946 if existing_meta.get("submitted") and not args.force:
13947 emit_structured_error(
13948 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13949 key=f"scheduler.{stage_name}.submitted",
13950 file_path=target_context["submission_path"],
13951 message=f"Stage '{stage_name}' is already recorded as submitted.",
13952 hint="Use --force to execute this stage again intentionally.",
13953 )
13954 sys.exit(1)
13955
13956 if stage_name == "post-process" and "solve" not in selected_stages and not args.force and not solve_already_done:
13957 emit_structured_error(
13958 ERROR_CODE_CFG_INCONSISTENT_COMBO,
13959 key="scheduler.post-process.dependency",
13960 file_path=target_context["submission_path"],
13961 message="Post-process local execution requires a recorded completed solve stage when solve is not being executed in the same command.",
13962 hint="Submit --stage solve or --stage all first, or use --force after confirming source data exists.",
13963 )
13964 sys.exit(1)
13965
13966 log_file = existing_meta.get("log_file")
13967 if not isinstance(log_file, str) or not log_file.strip():
13968 log_file = os.path.join("scheduler", f"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
13969
13970 stage_plans.append(
13971 {
13972 "stage": stage_name,
13973 "command": [str(token) for token in command],
13974 "log_file": log_file,
13975 "existing_meta": existing_meta,
13976 }
13977 )
13978
13979 if args.dry_run:
13980 for plan in stage_plans:
13981 print(f"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
13982 print(f"[DRY-RUN] Log file : {plan['log_file']}")
13983 print("[INFO] Dry-run only. No local commands were executed.")
13984 return
13985
13986 monitor_cfg = None
13987 monitor_path = os.path.join(target_context["root_dir"], "config", "monitor.yml")
13988 if os.path.isfile(monitor_path):
13989 monitor_cfg = read_yaml_file(monitor_path)
13990
13991 for plan in stage_plans:
13992 execute_command(plan["command"], target_context["root_dir"], plan["log_file"], monitor_cfg)
13993 stage_meta = copy.deepcopy(plan["existing_meta"])
13994 stage_meta["command"] = plan["command"]
13995 stage_meta["command_string"] = format_command_for_display(plan["command"])
13996 stage_meta["log_file"] = plan["log_file"]
13997 stage_meta["submitted"] = True
13998 stage_meta["executed"] = True
13999 stage_meta["completed_at"] = datetime.now().isoformat()
14000 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
14001 print(f"[SUCCESS] Executed local {plan['stage']} stage.")
14002
14003 _write_submission_target_metadata(target_context)
14004
14005
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 14006 of file core.py.

14006def cancel_run_jobs(args):
14007 """!
14008 @brief Cancel Slurm-submitted jobs for an existing run directory.
14009 @param[in] args Command-line style argument list supplied to the function.
14010 """
14011 run_dir = os.path.abspath(args.run_dir)
14012 if not os.path.isdir(run_dir):
14013 emit_structured_error(
14014 ERROR_CODE_CFG_FILE_NOT_FOUND,
14015 key="run_dir",
14016 file_path=run_dir,
14017 message="Run directory not found.",
14018 )
14019 sys.exit(1)
14020
14021 submission_path = os.path.join(run_dir, "scheduler", "submission.json")
14022 submission_meta = _read_json_if_exists(submission_path)
14023 if not isinstance(submission_meta, dict):
14024 emit_structured_error(
14025 ERROR_CODE_CFG_FILE_NOT_FOUND,
14026 key="scheduler.submission",
14027 file_path=submission_path,
14028 message="Run directory does not contain scheduler submission metadata.",
14029 hint="Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
14030 )
14031 sys.exit(1)
14032
14033 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
14034 if launch_mode != "slurm":
14035 emit_structured_error(
14036 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14037 key="scheduler.launch_mode",
14038 file_path=submission_path,
14039 message=f"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
14040 hint="picurv cancel currently supports Slurm-submitted runs only.",
14041 )
14042 sys.exit(1)
14043
14044 stage_order = ["solve", "post-process"]
14045 requested_stage = args.stage
14046 selected_stages = stage_order if requested_stage == "all" else [requested_stage]
14047 recorded_stages = submission_meta.get("stages", {})
14048 if not isinstance(recorded_stages, dict):
14049 recorded_stages = {}
14050
14051 job_to_stages = {}
14052 skipped = []
14053 for stage_name in selected_stages:
14054 stage_meta = recorded_stages.get(stage_name)
14055 if not isinstance(stage_meta, dict):
14056 skipped.append((stage_name, "no stage metadata recorded"))
14057 continue
14058
14059 job_id = str(stage_meta.get("job_id", "")).strip()
14060 if not stage_meta.get("submitted"):
14061 skipped.append((stage_name, "job was generated but not submitted"))
14062 continue
14063 if not job_id:
14064 skipped.append((stage_name, "submitted stage is missing a recorded job id"))
14065 continue
14066
14067 job_to_stages.setdefault(job_id, []).append(stage_name)
14068
14069 if not job_to_stages:
14070 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
14071 print(f"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
14072 for stage_name, reason in skipped:
14073 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
14074 print("[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
14075 sys.exit(1)
14076
14077 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
14078 print(f"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
14079 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
14080
14081 if skipped:
14082 for stage_name, reason in skipped:
14083 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
14084
14085 graceful = bool(getattr(args, "graceful", False))
14086 failures = []
14087 for job_id, stage_names in job_to_stages.items():
14088 joined_stage_names = ", ".join(stage_names)
14089 use_graceful_signal = graceful and "solve" in stage_names
14090 scancel_cmd = ["scancel"]
14091 if use_graceful_signal:
14092 scancel_cmd.append("--signal=USR1")
14093 scancel_cmd.append(job_id)
14094
14095 if args.dry_run:
14096 print(f"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
14097 continue
14098
14099 result = subprocess.run(scancel_cmd, text=True, capture_output=True, check=False)
14100 stderr_text = (result.stderr or "").strip()
14101 stdout_text = (result.stdout or "").strip()
14102 if result.returncode == 0:
14103 if use_graceful_signal:
14104 print(
14105 f"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
14106 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
14107 )
14108 else:
14109 print(f"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
14110 continue
14111
14112 detail = stderr_text or stdout_text or "unknown scancel failure"
14113 failures.append((job_id, joined_stage_names, detail, result.returncode))
14114 print(
14115 f"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
14116 file=sys.stderr,
14117 )
14118
14119 if args.dry_run:
14120 print("[INFO] Dry-run only. No jobs were canceled.")
14121 return
14122
14123 if failures:
14124 sys.exit(1)
14125
14126
Here is the call graph for this function:
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; use 'sync-binaries' to pin specific versions locally.

Parameters
[in]argsThe command-line arguments parsed by argparse.

Definition at line 14127 of file core.py.

14127def init_case(args):
14128 """!
14129 @brief Implements the 'init' command.
14130 @details Creates a new case study directory by copying a template.
14131 Runtime binaries are resolved from the project bin/ directory
14132 via PATH; use 'sync-binaries' to pin specific versions locally.
14133 @param[in] args The command-line arguments parsed by argparse.
14134 """
14135 context = resolve_case_origin_context(source_root_override=getattr(args, "source_root", None))
14136 try:
14137 source_project_root = require_project_root(context["source_project_root"], "init")
14138 template_path = resolve_template_directory(source_project_root, args.template_name)
14139 except ValueError as exc:
14140 print(f"[FATAL] {exc}", file=sys.stderr)
14141 sys.exit(1)
14142
14143 # The destination path is relative to the current working directory.
14144 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name if args.dest_name else args.template_name))
14145
14146 if os.path.exists(dest_path):
14147 print(f"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
14148 sys.exit(1)
14149
14150 print(f"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
14151
14152 shutil.copytree(template_path, dest_path)
14153 print(f"[SUCCESS] Copied template files to: {dest_path}")
14154
14155 copied_runtime_example = os.path.join(dest_path, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
14156 if os.path.isfile(copied_runtime_example):
14157 os.remove(copied_runtime_example)
14158
14159 try:
14160 runtime_result = ensure_case_runtime_execution_config(dest_path, source_project_root, overwrite=True)
14161 print(f"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
14162 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
14163 print(" Seeded from repo-local '.picurv-execution.yml'.")
14164 print(" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
14165 except Exception as e:
14166 print(f"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
14167
14168 try:
14169 metadata_path, _ = write_case_origin_metadata(
14170 dest_path,
14171 source_project_root,
14172 template_name=args.template_name,
14173 template_managed_files=list_template_relative_files(
14174 template_path,
14175 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
14176 ),
14177 )
14178 print(f"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
14179 except Exception as e:
14180 print(f"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
14181
14182 cluster_profile_candidates = sorted(
14183 {
14184 os.path.basename(path)
14185 for pattern in ("*cluster*.yml", "*cluster*.yaml")
14186 for path in glob.glob(os.path.join(dest_path, pattern))
14187 }
14188 )
14189 if cluster_profile_candidates:
14190 print("[INFO] Cluster profile sample(s) copied with this case:")
14191 for profile_name in cluster_profile_candidates:
14192 print(f" - {profile_name}")
14193 print(" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
14194
14195 if getattr(args, "pin_binaries", False):
14196 print("[INFO] Pinning runtime binaries into case directory...")
14197 try:
14198 copied_binaries = sync_case_binaries(dest_path, source_project_root)
14199 for dest_file_path in copied_binaries:
14200 print(f" - Pinned '{os.path.basename(dest_file_path)}'")
14201 print("[SUCCESS] Case directory is ready with pinned binaries.")
14202 print(" These local copies will be used instead of bin/ originals.")
14203 except ValueError as exc:
14204 print(f"[WARNING] {exc}", file=sys.stderr)
14205 print(" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
14206 else:
14207 print("[SUCCESS] Case directory is ready.")
14208 print(" Runtime binaries (simulator, postprocessor) are resolved from bin/ automatically.")
14209 print(" To pin specific binary versions, re-run with --pin-binaries or use: picurv sync-binaries")
14210 print(" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
14211
14212
Here is the call graph for this function:
Here is the caller graph for this function:

◆ sync_case_binaries_command()

picurv_cli.core.sync_case_binaries_command (   args)

Refresh case-local executables from the source repository bin directory.

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

Definition at line 14213 of file core.py.

14213def sync_case_binaries_command(args):
14214 """!
14215 @brief Refresh case-local executables from the source repository bin directory.
14216 @param[in] args Command-line style argument list supplied to the function.
14217 """
14218 try:
14219 context = resolve_case_origin_context(
14220 case_dir_hint=getattr(args, "case_dir", None),
14221 source_root_override=getattr(args, "source_root", None),
14222 )
14223 source_project_root = require_project_root(context["source_project_root"], "sync-binaries")
14224 case_dir = require_existing_case_dir(context["case_dir"], "sync-binaries", source_project_root)
14225 copied = sync_case_binaries(case_dir, source_project_root)
14226 metadata_path, metadata = write_case_origin_metadata(
14227 case_dir,
14228 source_project_root,
14229 template_name=context.get("template_name"),
14230 existing=context.get("metadata"),
14231 )
14232 except ValueError as exc:
14233 print(f"[FATAL] {exc}", file=sys.stderr)
14234 sys.exit(1)
14235
14236 print(f"[SUCCESS] Refreshed {len(copied)} binaries in: {case_dir}")
14237 for dest_path in copied:
14238 print(f" - {os.path.basename(dest_path)}")
14239 print(f"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
14240 if metadata.get("last_known_source_git_commit"):
14241 print(f"[INFO] Source commit recorded: {metadata['last_known_source_git_commit']}")
14242
14243
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 14244 of file core.py.

14244def sync_case_config_command(args):
14245 """!
14246 @brief Refresh template-managed config/docs files in a case directory.
14247 @param[in] args Command-line style argument list supplied to the function.
14248 """
14249 try:
14250 context = resolve_case_origin_context(
14251 case_dir_hint=getattr(args, "case_dir", None),
14252 source_root_override=getattr(args, "source_root", None),
14253 template_name_override=getattr(args, "template_name", None),
14254 )
14255 source_project_root = require_project_root(context["source_project_root"], "sync-config")
14256 case_dir = require_existing_case_dir(context["case_dir"], "sync-config", source_project_root)
14257 template_name = context.get("template_name")
14258 template_dir = resolve_template_directory(source_project_root, template_name)
14259 existing_managed = context.get("metadata", {}).get("template_managed_files")
14260 if not isinstance(existing_managed, list):
14261 existing_managed = None
14262 summary = sync_case_template_files(
14263 case_dir,
14264 template_dir,
14265 overwrite=getattr(args, "overwrite", False),
14266 prune=getattr(args, "prune", False),
14267 managed_rel_paths=existing_managed,
14268 )
14269 metadata_path, _ = write_case_origin_metadata(
14270 case_dir,
14271 source_project_root,
14272 template_name=template_name,
14273 existing=context.get("metadata"),
14274 template_managed_files=summary["template_managed_files"],
14275 )
14276 runtime_result = ensure_case_runtime_execution_config(case_dir, source_project_root, overwrite=False)
14277 except ValueError as exc:
14278 print(f"[FATAL] {exc}", file=sys.stderr)
14279 sys.exit(1)
14280
14281 print(f"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
14282 print(f"[INFO] Copied new files : {len(summary['copied'])}")
14283 print(f"[INFO] Overwritten files : {len(summary['overwritten'])}")
14284 print(f"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
14285 print(f"[INFO] Already unchanged : {len(summary['unchanged'])}")
14286 print(f"[INFO] Pruned stale files : {len(summary['pruned'])}")
14287 if runtime_result["created"]:
14288 print(f"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
14289 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
14290 print("[INFO] Seed source : repo-local .picurv-execution.yml")
14291 if summary.get("prune_requested_without_tracking"):
14292 print("[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
14293 print(f"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
14294
14295
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 14296 of file core.py.

14296def pull_source_repo(args):
14297 """!
14298 @brief Refresh source branches in the repository resolved from a case directory.
14299 @param[in] args Command-line style argument list supplied to the function.
14300 """
14301 try:
14302 context = resolve_case_origin_context(
14303 case_dir_hint=getattr(args, "case_dir", None),
14304 source_root_override=getattr(args, "source_root", None),
14305 )
14306 source_project_root = require_project_root(context["source_project_root"], "pull-source")
14307 except ValueError as exc:
14308 print(f"[FATAL] {exc}", file=sys.stderr)
14309 sys.exit(1)
14310
14311 rebase = not getattr(args, "no_rebase", False)
14312 remote = getattr(args, "remote", None)
14313 branch = getattr(args, "branch", None)
14314 current_branch_only = (
14315 getattr(args, "current_branch_only", False)
14316 or remote is not None
14317 or branch is not None
14318 )
14319
14320 if not current_branch_only:
14321 pull_all_source_branches(source_project_root, "pull-source.log", rebase=rebase)
14322 return
14323
14324 command = ["git", "pull"]
14325 if rebase:
14326 command.append("--rebase")
14327 if remote:
14328 command.append(remote)
14329 if branch:
14330 command.append(branch)
14331 elif branch:
14332 command.extend(["origin", branch])
14333
14334 execute_command(command, source_project_root, "pull-source.log", {})
14335
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 14336 of file core.py.

14336def build_project(args):
14337 """!
14338 @brief Implements the 'build' command.
14339 @details Executes the top-level Makefile directly, passing through any
14340 additional arguments to `make`. This allows for building,
14341 cleaning, and other Makefile targets via the orchestrator
14342 without maintaining a separate build wrapper script.
14343 @param[in] args The command-line arguments parsed by argparse.
14344 """
14345
14346 print("\n" + "="*27 + " BUILD STAGE " + "="*27)
14347 try:
14348 context = resolve_case_origin_context(
14349 case_dir_hint=getattr(args, "case_dir", None),
14350 source_root_override=getattr(args, "source_root", None),
14351 )
14352 source_project_root = require_project_root(context["source_project_root"], "build")
14353 except ValueError as exc:
14354 print(f"[FATAL] {exc}", file=sys.stderr)
14355 sys.exit(1)
14356
14357 makefile_path = os.path.join(source_project_root, "Makefile")
14358
14359 if not os.path.isfile(makefile_path):
14360 print(f"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
14361 print(" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
14362 sys.exit(1)
14363
14364 make_args = list(args.make_args or [])
14365 if make_args_include_explicit_goal(make_args):
14366 command = ["make"] + make_args
14367 else:
14368 command = ["make", "all"] + make_args
14369 print("[INFO] No explicit make target supplied; defaulting to 'all'.")
14370 print(" Use 'picurv build clean-project ...' or another target when you want a non-build make action.")
14371 # For the build process, we don't have a monitor.yml, so we pass an empty
14372 # dict to execute_command. The command should be run in the project root.
14373 execute_command(command, source_project_root, "build.log", {})
14374
14375
14376
14377
14378# ==============================================================================
Here is the call graph for this function:
Here is the caller graph for this function:

Variable Documentation

◆ _NUMPY_MODULE

picurv_cli.core._NUMPY_MODULE = None
protected

Definition at line 37 of file core.py.

◆ _MATPLOTLIB_PYPLOT

picurv_cli.core._MATPLOTLIB_PYPLOT = None
protected

Definition at line 38 of file core.py.

◆ np

picurv_cli.core.np = _LazyNumpyProxy()

Definition at line 55 of file core.py.

◆ PACKAGE_PATH

picurv_cli.core.PACKAGE_PATH = os.path.dirname(os.path.realpath(__file__))

Definition at line 145 of file core.py.

◆ PACKAGE_PROJECT_ROOT

picurv_cli.core.PACKAGE_PROJECT_ROOT = os.path.dirname(PACKAGE_PATH)

Definition at line 146 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 147 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 151 of file core.py.

◆ PROJECT_ROOT

picurv_cli.core.PROJECT_ROOT = os.path.dirname(SCRIPT_PATH)

Definition at line 155 of file core.py.

◆ GENERATORS_PATH

picurv_cli.core.GENERATORS_PATH = os.path.join(PACKAGE_PROJECT_ROOT, "generators")

Definition at line 156 of file core.py.

◆ DEFAULT_BIN_DIR

picurv_cli.core.DEFAULT_BIN_DIR = SCRIPT_PATH

Definition at line 158 of file core.py.

◆ PICURV_VERSION

str picurv_cli.core.PICURV_VERSION = "0.1.0"

Definition at line 162 of file core.py.

◆ CASE_ORIGIN_METADATA_FILENAME

str picurv_cli.core.CASE_ORIGIN_METADATA_FILENAME = ".picurv-origin.json"

Definition at line 163 of file core.py.

◆ RUNTIME_EXECUTION_CONFIG_FILENAME

str picurv_cli.core.RUNTIME_EXECUTION_CONFIG_FILENAME = ".picurv-execution.yml"

Definition at line 164 of file core.py.

◆ LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME

str picurv_cli.core.LEGACY_LOCAL_RUNTIME_CONFIG_FILENAME = ".picurv-local.yml"

Definition at line 165 of file core.py.

◆ RUNTIME_EXECUTION_EXAMPLE_FILENAME

str picurv_cli.core.RUNTIME_EXECUTION_EXAMPLE_FILENAME = "execution.example.yml"

Definition at line 166 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 167 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 172 of file core.py.

◆ CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT

str picurv_cli.core.CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT = "my_project_account"

Definition at line 193 of file core.py.

◆ CLUSTER_TEMPLATE_PLACEHOLDER_MAIL

str picurv_cli.core.CLUSTER_TEMPLATE_PLACEHOLDER_MAIL = "user@example.edu"

Definition at line 194 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 196 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 203 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 204 of file core.py.

◆ POST_RESUME_STATE_FILENAME

str picurv_cli.core.POST_RESUME_STATE_FILENAME = "post.resume.json"

Definition at line 205 of file core.py.

◆ POST_LOCK_FILENAME

str picurv_cli.core.POST_LOCK_FILENAME = "post.lock"

Definition at line 206 of file core.py.

◆ POST_LOCK_METADATA_FILENAME

str picurv_cli.core.POST_LOCK_METADATA_FILENAME = "post.lock.json"

Definition at line 207 of file core.py.

◆ POST_LOCK_WRAPPER_FILENAME

str picurv_cli.core.POST_LOCK_WRAPPER_FILENAME = "post_lock_wrapper.py"

Definition at line 208 of file core.py.

◆ POST_RESUME_SCHEMA_VERSION

int picurv_cli.core.POST_RESUME_SCHEMA_VERSION = 1

Definition at line 209 of file core.py.

◆ POST_RECIPE_SIGNATURE_EXCLUDED_KEYS

dict picurv_cli.core.POST_RECIPE_SIGNATURE_EXCLUDED_KEYS = {"startTime", "endTime"}

Definition at line 210 of file core.py.

◆ POST_REQUIRED_EULERIAN_SOURCE_BASENAMES

tuple picurv_cli.core.POST_REQUIRED_EULERIAN_SOURCE_BASENAMES = ("ufield", "vfield", "pfield", "nvfield")

Definition at line 211 of file core.py.

◆ ERROR_CODE_CLI_USAGE_INVALID

str picurv_cli.core.ERROR_CODE_CLI_USAGE_INVALID = "CLI_USAGE_INVALID"

Definition at line 328 of file core.py.

◆ ERROR_CODE_CFG_MISSING_SECTION

str picurv_cli.core.ERROR_CODE_CFG_MISSING_SECTION = "CFG_MISSING_SECTION"

Definition at line 329 of file core.py.

◆ ERROR_CODE_CFG_MISSING_KEY

str picurv_cli.core.ERROR_CODE_CFG_MISSING_KEY = "CFG_MISSING_KEY"

Definition at line 330 of file core.py.

◆ ERROR_CODE_CFG_INVALID_TYPE

str picurv_cli.core.ERROR_CODE_CFG_INVALID_TYPE = "CFG_INVALID_TYPE"

Definition at line 331 of file core.py.

◆ ERROR_CODE_CFG_INVALID_VALUE

str picurv_cli.core.ERROR_CODE_CFG_INVALID_VALUE = "CFG_INVALID_VALUE"

Definition at line 332 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 333 of file core.py.

◆ ERROR_CODE_CFG_GRID_PARSE

str picurv_cli.core.ERROR_CODE_CFG_GRID_PARSE = "CFG_GRID_PARSE"

Definition at line 334 of file core.py.

◆ ERROR_CODE_CFG_INCONSISTENT_COMBO

str picurv_cli.core.ERROR_CODE_CFG_INCONSISTENT_COMBO = "CFG_INCONSISTENT_COMBO"

Definition at line 335 of file core.py.

◆ ERROR_CODE_DEPENDENCY_MISSING

str picurv_cli.core.ERROR_CODE_DEPENDENCY_MISSING = "DEPENDENCY_MISSING"

Definition at line 336 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 338 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 1369 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 "output-file": "output_file",
6 "stats-file": "stats_file",
7 "vts-file": "vts_file",
8}

Definition at line 1376 of file core.py.

◆ GENERATED_PROFILE_GENERATORS

dict picurv_cli.core.GENERATED_PROFILE_GENERATORS = {"square_duct_poiseuille"}

Definition at line 3210 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 3797 of file core.py.

◆ BC_TYPE_MAP

dict picurv_cli.core.BC_TYPE_MAP
Initial value:
1= {
2 "wall": "WALL",
3 "symmetry": "SYMMETRY",
4 "inlet": "INLET",
5 "outlet": "OUTLET",
6 "periodic": "PERIODIC",
7}

Definition at line 3807 of file core.py.

◆ BC_HANDLER_SPECS

dict picurv_cli.core.BC_HANDLER_SPECS

Definition at line 3815 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 3854 of file core.py.

◆ _BOOL_BC_PARAMS

dict picurv_cli.core._BOOL_BC_PARAMS = {"apply_trim"}
protected

Definition at line 3855 of file core.py.

◆ _CASE_SCHEMA

dict picurv_cli.core._CASE_SCHEMA
protected

Definition at line 4376 of file core.py.

◆ _SOLVER_SCHEMA

dict picurv_cli.core._SOLVER_SCHEMA
protected

Definition at line 4428 of file core.py.

◆ _MONITOR_SCHEMA

dict picurv_cli.core._MONITOR_SCHEMA
protected
Initial value:
1= {
2 (): {"logging", "profiling", "diagnostics", "io", "solver_monitoring"},
3 ("logging",): {"verbosity", "enabled_functions"},
4 ("profiling",): {"timestep_output", "final_summary"},
5 ("profiling", "timestep_output"): {"mode", "functions", "file"},
6 ("profiling", "final_summary"): {"enabled"},
7 ("diagnostics",): {"petsc", "runtime_memory_log"},
8 ("diagnostics", "petsc"): {
9 "malloc_debug", "malloc_test", "malloc_dump", "malloc_view", "malloc_view_threshold",
10 "memory_view", "log_view", "log_view_memory", "log_all", "log_trace",
11 "objects_dump", "options_left",
12 },
13 ("diagnostics", "runtime_memory_log"): {"enabled", "file"},
14 ("io",): {
15 "data_output_frequency", "particle_console_output_frequency", "particle_log_interval",
16 "directories",
17 },
18 ("io", "directories"): {"output", "restart", "log", "eulerian_subdir", "particle_subdir"},
19 ("solver_monitoring",): {"poisson", "petsc_passthrough_options"},
20 ("solver_monitoring", "poisson"): {"pic_true_residual", "true_residual", "converged_reason", "view"},
21 ("solver_monitoring", "petsc_passthrough_options"): None,
22}

Definition at line 4497 of file core.py.

◆ _POST_SCHEMA

dict picurv_cli.core._POST_SCHEMA
protected
Initial value:
1= {
2 (): {
3 "run_control", "source_data", "global_operations", "eulerian_pipeline",
4 "lagrangian_pipeline", "statistics_pipeline", "statistics_output_prefix", "io",
5 },
6 ("run_control",): {
7 "start_step", "end_step", "step_interval", "startTime", "endTime", "timeStep",
8 },
9 ("source_data",): {"directory", "input_extensions"},
10 ("source_data", "input_extensions"): {"eulerian", "particle"},
11 ("global_operations",): {"dimensionalize"},
12 ("eulerian_pipeline", "[]"): {"task", "input_field", "output_field", "field", "reference_point"},
13 ("lagrangian_pipeline", "[]"): {"task", "input_field", "output_field"},
14 ("statistics_pipeline",): {"output_prefix", "tasks"},
15 ("statistics_pipeline", "tasks", "[]"): {"task"},
16 ("io",): {
17 "output_directory", "output_filename_prefix", "particle_filename_prefix", "output_particles",
18 "particle_subsampling_frequency", "input_extensions", "eulerian_fields_averaged",
19 "eulerian_fields", "particle_fields",
20 },
21 ("io", "input_extensions"): {"eulerian", "particle"},
22}

Definition at line 4521 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",
8 },
9 ("execution", "extra_sbatch"): None,
10 ("execution", "walltime_guard"): {
11 "enabled", "warmup_steps", "multiplier", "min_seconds", "estimator_alpha",
12 },
13}

Definition at line 4545 of file core.py.

◆ _STUDY_SCHEMA

dict picurv_cli.core._STUDY_SCHEMA
protected
Initial value:
1= {
2 (): {
3 "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", "reduction", "normalize_by_parameter",
10 "numerator_column", "denominator_column", "denominator_floor",
11 },
12 ("plotting",): {"enabled", "output_format"},
13 ("execution",): {"max_concurrent_array_tasks"},
14}

Definition at line 4560 of file core.py.

◆ DIAGNOSTICS_PETSC_KEYS

dict picurv_cli.core.DIAGNOSTICS_PETSC_KEYS
Initial value:
1= {
2 "malloc_debug",
3 "malloc_test",
4 "malloc_dump",
5 "malloc_view",
6 "malloc_view_threshold",
7 "memory_view",
8 "log_view",
9 "log_view_memory",
10 "log_all",
11 "log_trace",
12 "objects_dump",
13 "options_left",
14}

Definition at line 6400 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 6868 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 7687 of file core.py.

◆ _SUMMARY_NUMERIC_RE

picurv_cli.core._SUMMARY_NUMERIC_RE = re.compile(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
protected

Definition at line 11232 of file core.py.

◆ _CONFIG_SUMMARY_WIDTH

int picurv_cli.core._CONFIG_SUMMARY_WIDTH = 78
protected

Definition at line 12806 of file core.py.

◆ _SUMMARY_PLOT_LOG_SCALE_FIELDS

dict picurv_cli.core._SUMMARY_PLOT_LOG_SCALE_FIELDS
protected
Initial value:
1= {
2 "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}

Definition at line 13086 of file core.py.