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.
 
None validate_programmatic_ic_gen_grid_settings (dict raw_settings)
 Validate scalar programmatic grid settings needed to materialize grid.run for ic_gen.
 
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.
 
dict validate_newton_krylov_config (dict cfg)
 Validate and normalize the structured Newton–Krylov solver block.
 
str normalize_solution_convergence_mode (str value)
 Normalizes the solution-convergence mode selector to the C-side canonical string.
 
int normalize_field_init_mode (str value)
 Maps canonical field init mode names to C enum/int codes (-finit).
 
"tuple[str, int]" normalize_initial_condition_field (str value)
 Normalize a file IC field selector to its staged basename and C enum value.
 
dict resolve_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 PROGRAMMATIC_IC_GEN_GRID_KEYS
 
tuple GRID_DA_PROCESSOR_KEYS = ("da_processors_x", "da_processors_y", "da_processors_z")
 
dict SOLVER_MONITORING_POISSON_FLAG_MAP
 
dict SOLVER_MONITORING_MOMENTUM_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 continue_run and start_step <= 0:
2588 raise ValueError(
2589 "--continue with --solve requires run_control.start_step > 0; "
2590 "start_step=0 is a fresh start. Omit --continue to start a fresh run."
2591 )
2592
2593 if restart_from:
2594 # === MODE 1: New run, restart from another run ===
2595 source_run = os.path.abspath(restart_from)
2596 if not os.path.isdir(source_run):
2597 raise ValueError(f"--restart-from run directory does not exist: {source_run}")
2598 source_monitor = read_monitor_from_run(source_run)
2599 source_output = resolve_run_output_dir(source_run, source_monitor)
2600 if not os.path.isdir(source_output):
2601 raise ValueError(f"Source output directory does not exist: {source_output}")
2602
2603 if not requires_source:
2604 # R7: analytical + init — warn that --restart-from is unused
2605 print(
2606 "[WARN] --restart-from specified but no data will be read "
2607 "(analytical + init does not need restart data).",
2608 file=sys.stderr,
2609 )
2610 return None, False
2611
2612 if eulerian_source == "load":
2613 # R3/R4/R5: Direct reference to source output
2614 validate_load_mode_step_range(source_output, start_step, total_steps, source_monitor)
2615 if particle_needs:
2616 validate_particle_checkpoint(source_output, start_step, source_monitor)
2617 return source_output, False
2618 else:
2619 # R1/R2/R6: Copy checkpoint to new run's restart/
2620 target_restart = resolve_run_restart_dir(run_dir, monitor_cfg)
2621 populate_restart_directory(source_output, target_restart, start_step, monitor_cfg)
2622 if particle_needs:
2623 validate_particle_checkpoint(target_restart, start_step, monitor_cfg)
2624 return target_restart, False
2625
2626 elif continue_run:
2627 # === MODE 2: Continue in-place ===
2628 continue_run_dir = getattr(args, 'run_dir', None)
2629 if not continue_run_dir:
2630 raise ValueError("--continue requires --run-dir.")
2631 continue_run_dir = os.path.abspath(continue_run_dir)
2632 if not os.path.isdir(continue_run_dir):
2633 raise ValueError(f"--run-dir does not exist: {continue_run_dir}")
2634
2635 source_output = resolve_run_output_dir(continue_run_dir, monitor_cfg)
2636 target_restart = resolve_run_restart_dir(continue_run_dir, monitor_cfg)
2637
2638 # Warn if start_step != last checkpoint
2639 last_step = detect_last_checkpoint_step(source_output, euler_sub)
2640 if last_step is not None and last_step != start_step:
2641 print(
2642 f"[WARN] start_step={start_step} but last checkpoint in output is step {last_step}.",
2643 file=sys.stderr,
2644 )
2645
2646 if eulerian_source == "load":
2647 # C3/C4: Direct reference to output/ (all euler steps there, can't copy)
2648 validate_load_mode_step_range(source_output, start_step, total_steps, monitor_cfg)
2649 if particle_needs:
2650 validate_particle_checkpoint(source_output, start_step, monitor_cfg)
2651 return source_output, True
2652 elif not requires_source:
2653 # C6: analytical + init — only log-append behavior, no data needed
2654 return None, True
2655 else:
2656 # C1/C2/C5: Smart resolution for solve/analytical
2657 euler_needs = (eulerian_source == "solve" and start_step > 0)
2658
2659 step_str = f"{start_step:05d}"
2660 needed_subs = []
2661 if euler_needs:
2662 needed_subs.append(euler_sub)
2663 if particle_needs:
2664 needed_subs.append(particle_sub)
2665
2666 # Check restart/ for EACH needed component
2667 restart_has = {}
2668 if os.path.isdir(target_restart):
2669 for sub in needed_subs:
2670 restart_has[sub] = bool(
2671 glob.glob(os.path.join(target_restart, sub, f"*{step_str}_0.*"))
2672 )
2673
2674 all_in_restart = needed_subs and all(restart_has.get(s, False) for s in needed_subs)
2675 some_in_restart = any(restart_has.get(s, False) for s in needed_subs)
2676
2677 if all_in_restart:
2678 # Fully curated restart/ (warm-up-and-discard workflow)
2679 print(f"[INFO] Using curated restart directory: {target_restart}")
2680 return target_restart, True
2681 elif os.path.isdir(source_output):
2682 # Auto-populate missing components from output/ into restart/
2683 if some_in_restart:
2684 # Partial curate: only copy components NOT already in restart/
2685 missing = [s for s in needed_subs if not restart_has.get(s, False)]
2686 os.makedirs(target_restart, exist_ok=True)
2687 for sub in missing:
2688 src = os.path.join(source_output, sub)
2689 dst = os.path.join(target_restart, sub)
2690 os.makedirs(dst, exist_ok=True)
2691 if os.path.isdir(src):
2692 for f_name in glob.glob(os.path.join(src, f"*{step_str}_0.*")):
2693 shutil.copy2(f_name, dst)
2694 filled = glob.glob(os.path.join(target_restart, "**", f"*{step_str}_0.*"), recursive=True)
2695 print(f"[INFO] Merged curated restart/ with {len(missing)} component(s) from output/: {target_restart}")
2696 if not filled:
2697 raise ValueError(
2698 f"After merge, no checkpoint files found for step {start_step} in {target_restart}"
2699 )
2700 return target_restart, True
2701 else:
2702 # Nothing curated — full populate from output/
2703 populate_restart_directory(source_output, target_restart, start_step, monitor_cfg)
2704 return target_restart, True
2705 else:
2706 raise ValueError(
2707 f"Neither restart/ nor output/ contain data for step {start_step}. "
2708 f"Checked: {target_restart}, {source_output}"
2709 )
2710
2711 elif requires_source:
2712 raise ValueError(
2713 "Restart data required but no source specified. Use:\n"
2714 " --restart-from <run_dir> (new run from another run's data)\n"
2715 " --continue --run-dir <run_dir> (resume in same directory)"
2716 )
2717
2718 return None, False
2719
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 2720 of file core.py.

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

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

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

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

2875def _iter_nonempty_noncomment_lines(file_obj):
2876 """!
2877 @brief Yield (lineno, stripped_line) for non-empty, non-comment lines.
2878 @param[in] file_obj Argument passed to `_iter_nonempty_noncomment_lines()`.
2879 """
2880 for lineno, raw in enumerate(file_obj, start=1):
2881 line = raw.strip()
2882 if not line or line.startswith("#"):
2883 continue
2884 yield lineno, line
2885
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 2886 of file core.py.

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

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

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

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

3154 default_to_config_dir: bool = False) -> str:
3155 """!
3156 @brief Resolve a run artifact path with run-dir-relative defaults.
3157 @param[in] run_dir Run/precompute directory root.
3158 @param[in] configured_path Optional user-provided artifact path.
3159 @param[in] default_path Default path relative to run_dir.
3160 @param[in] default_to_config_dir If true, bare relative names are placed under config/.
3161 @return Absolute artifact path.
3162 """
3163 path = configured_path if configured_path else default_path
3164 if not isinstance(path, str) or not path.strip():
3165 raise ValueError("generated profile output_file must be a non-empty path when provided.")
3166 path = path.strip()
3167 if os.path.isabs(path):
3168 return os.path.abspath(path)
3169 if default_to_config_dir and os.path.dirname(path) == "":
3170 path = os.path.join("config", path)
3171 return os.path.abspath(os.path.join(run_dir, path))
3172
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 3173 of file core.py.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4353def _validate_yaml_schema_keys(cfg, schema: dict, file_path: str, errors: list, path: tuple = ()) -> None:
4354 """!
4355 @brief Reject unsupported YAML keys before they can be silently ignored by staging.
4356 @param[in] cfg Parsed YAML node.
4357 @param[in] schema Role schema mapping.
4358 @param[in] file_path Source file path for diagnostics.
4359 @param[in,out] errors Validation error accumulator.
4360 @param[in] path Current internal YAML path tuple.
4361 """
4362 if isinstance(cfg, dict):
4363 allowed = _lookup_allowed_schema_keys(schema, path)
4364 if allowed is not False and allowed is not None:
4365 unknown = sorted(str(key) for key in cfg.keys() if key not in allowed)
4366 for key in unknown:
4367 hint = _schema_key_hint(schema, path, key, allowed)
4368 hint_text = f" {hint}" if hint else ""
4369 errors.append(
4370 f" {file_path}: unsupported key at {_schema_path_text(path)}: '{key}'. "
4371 f"Allowed keys: {sorted(allowed)}.{hint_text}"
4372 )
4373 if allowed is None:
4374 return
4375 for key, value in cfg.items():
4376 _validate_yaml_schema_keys(value, schema, file_path, errors, path + (key,))
4377 elif isinstance(cfg, list):
4378 for item in cfg:
4379 _validate_yaml_schema_keys(item, schema, file_path, errors, path + ("[]",))
4380
4381
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 4602 of file core.py.

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

5364def validate_post_config(post_cfg: dict, post_path: str):
5365 """!
5366 @brief Validates the post-processing config before running the post-processor.
5367 @param[in] post_cfg Parsed post-processing YAML dictionary.
5368 @param[in] post_path Path to post file (for error messages).
5369 @throws SystemExit on validation failure.
5370 """
5371 errors = []
5372
5373 _validate_yaml_schema_keys(post_cfg, _POST_SCHEMA, post_path, errors)
5374
5375 if not isinstance(post_cfg, dict) or not post_cfg:
5376 errors.append(f" {post_path}: post-processing config is empty or not a valid YAML mapping.")
5377 _print_validation_errors(errors)
5378
5379 # --- run_control ---
5380 if 'run_control' not in post_cfg:
5381 errors.append(f" {post_path}: missing required section 'run_control'.")
5382 else:
5383 rc = post_cfg.get('run_control', {})
5384 if not isinstance(rc, dict):
5385 errors.append(f" {post_path}: 'run_control' must be a mapping.")
5386 else:
5387 for canonical_key, aliases in POST_RUN_CONTROL_ALIASES.items():
5388 if not any(alias in rc for alias in aliases):
5389 alias_list = "', '".join(aliases)
5390 errors.append(
5391 f" {post_path}: missing required key 'run_control.{canonical_key}' "
5392 f"(accepted aliases: '{alias_list}')."
5393 )
5394 continue
5395 raw_value = _mapping_value_with_aliases(rc, *aliases)
5396 try:
5397 int(raw_value)
5398 except (TypeError, ValueError):
5399 alias_name = next((alias for alias in aliases if alias in rc), canonical_key)
5400 errors.append(
5401 f" {post_path}: 'run_control.{alias_name}' must be an integer-compatible value."
5402 )
5403
5404 # --- io section ---
5405 io_cfg = post_cfg.get('io', {})
5406 source_cfg = post_cfg.get('source_data')
5407 if source_cfg is not None and not isinstance(source_cfg, dict):
5408 errors.append(f" {post_path}: 'source_data' must be a mapping when provided.")
5409 global_ops = post_cfg.get('global_operations')
5410 if global_ops is not None:
5411 if not isinstance(global_ops, dict):
5412 errors.append(f" {post_path}: 'global_operations' must be a mapping when provided.")
5413 elif 'dimensionalize' in global_ops and not isinstance(global_ops.get('dimensionalize'), bool):
5414 errors.append(f" {post_path}: 'global_operations.dimensionalize' must be a boolean.")
5415 if not io_cfg:
5416 errors.append(f" {post_path}: missing required section 'io'.")
5417 elif not isinstance(io_cfg, dict):
5418 errors.append(f" {post_path}: 'io' must be a mapping.")
5419 else:
5420 for k in ['output_directory', 'output_filename_prefix']:
5421 if k not in io_cfg:
5422 errors.append(f" {post_path}: missing required key 'io.{k}'.")
5423 for key_name in ('output_directory', 'output_filename_prefix', 'particle_filename_prefix'):
5424 if key_name in io_cfg and not isinstance(io_cfg.get(key_name), str):
5425 errors.append(f" {post_path}: 'io.{key_name}' must be a string when provided.")
5426 if 'output_particles' in io_cfg and not isinstance(io_cfg.get('output_particles'), bool):
5427 errors.append(f" {post_path}: 'io.output_particles' must be a boolean when provided.")
5428 particle_subsampling_frequency = io_cfg.get('particle_subsampling_frequency')
5429 if particle_subsampling_frequency is not None:
5430 if not isinstance(particle_subsampling_frequency, int) or particle_subsampling_frequency <= 0:
5431 errors.append(
5432 f" {post_path}: 'io.particle_subsampling_frequency' must be a positive integer when provided."
5433 )
5434 input_extensions = io_cfg.get('input_extensions')
5435 source_input_extensions = get_post_source_data(post_cfg).get('input_extensions')
5436 if input_extensions is not None:
5437 if not isinstance(input_extensions, dict):
5438 errors.append(f" {post_path}: 'io.input_extensions' must be a mapping when provided.")
5439 else:
5440 for ext_key in ('eulerian', 'particle'):
5441 ext_val = input_extensions.get(ext_key)
5442 if ext_val is not None and not isinstance(ext_val, str):
5443 errors.append(f" {post_path}: 'io.input_extensions.{ext_key}' must be a string extension.")
5444 if source_input_extensions is not None:
5445 if not isinstance(source_input_extensions, dict):
5446 errors.append(f" {post_path}: 'source_data.input_extensions' must be a mapping when provided.")
5447 else:
5448 for ext_key in ('eulerian', 'particle'):
5449 ext_val = source_input_extensions.get(ext_key)
5450 if ext_val is not None and not isinstance(ext_val, str):
5451 errors.append(
5452 f" {post_path}: 'source_data.input_extensions.{ext_key}' must be a string extension."
5453 )
5454
5455 averaged_fields = io_cfg.get('eulerian_fields_averaged')
5456 if averaged_fields is not None and not isinstance(averaged_fields, list):
5457 errors.append(f" {post_path}: 'io.eulerian_fields_averaged' must be a list when provided.")
5458 for list_key in ('eulerian_fields', 'particle_fields'):
5459 list_val = io_cfg.get(list_key)
5460 if list_val is not None and not isinstance(list_val, list):
5461 errors.append(f" {post_path}: 'io.{list_key}' must be a list when provided.")
5462
5463 # --- Check eulerian_pipeline entries have 'task' key ---
5464 eulerian_pipeline = post_cfg.get('eulerian_pipeline', [])
5465 if eulerian_pipeline is not None and not isinstance(eulerian_pipeline, list):
5466 errors.append(f" {post_path}: 'eulerian_pipeline' must be a list when provided.")
5467 eulerian_pipeline = []
5468 for i, entry in enumerate(eulerian_pipeline):
5469 if not isinstance(entry, dict) or 'task' not in entry:
5470 errors.append(f" {post_path}: 'eulerian_pipeline[{i}]' is missing the 'task' key. "
5471 "Check YAML indentation (each entry needs '- task: ...' with proper spacing).")
5472 continue
5473 task_name = entry.get('task')
5474 if task_name == 'q_criterion':
5475 continue
5476 if task_name == 'nodal_average':
5477 in_field = entry.get('input_field')
5478 out_field = entry.get('output_field')
5479 if not isinstance(in_field, str) or not in_field.strip():
5480 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].input_field' must be a non-empty string.")
5481 if not isinstance(out_field, str) or not out_field.strip():
5482 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].output_field' must be a non-empty string.")
5483 if isinstance(in_field, str) and isinstance(out_field, str) and in_field == out_field:
5484 errors.append(
5485 f" {post_path}: 'eulerian_pipeline[{i}]' nodal_average input and output fields must differ."
5486 )
5487 continue
5488 if task_name == 'normalize_field':
5489 field = entry.get('field', 'P')
5490 if not isinstance(field, str) or not field.strip():
5491 errors.append(f" {post_path}: 'eulerian_pipeline[{i}].field' must be a non-empty string.")
5492 elif field != 'P':
5493 errors.append(
5494 f" {post_path}: 'eulerian_pipeline[{i}].field' currently only supports 'P' "
5495 f"(got '{field}')."
5496 )
5497 reference_point = entry.get('reference_point', [1, 1, 1])
5498 if not isinstance(reference_point, (list, tuple)) or len(reference_point) != 3:
5499 errors.append(
5500 f" {post_path}: 'eulerian_pipeline[{i}].reference_point' must be a 3-item list."
5501 )
5502 else:
5503 for rp_idx, coord in enumerate(reference_point):
5504 try:
5505 int(coord)
5506 except (TypeError, ValueError):
5507 errors.append(
5508 f" {post_path}: 'eulerian_pipeline[{i}].reference_point[{rp_idx}]' "
5509 "must be integer-compatible."
5510 )
5511 continue
5512 errors.append(
5513 f" {post_path}: unsupported eulerian task '{task_name}' at eulerian_pipeline[{i}]."
5514 )
5515
5516 # --- Check lagrangian_pipeline entries have 'task' key ---
5517 lagrangian_pipeline = post_cfg.get('lagrangian_pipeline', [])
5518 if lagrangian_pipeline is not None and not isinstance(lagrangian_pipeline, list):
5519 errors.append(f" {post_path}: 'lagrangian_pipeline' must be a list when provided.")
5520 lagrangian_pipeline = []
5521 for i, entry in enumerate(lagrangian_pipeline):
5522 if not isinstance(entry, dict) or 'task' not in entry:
5523 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}]' is missing the 'task' key.")
5524 continue
5525 task_name = entry.get('task')
5526 if task_name == 'specific_ke':
5527 in_field = entry.get('input_field')
5528 out_field = entry.get('output_field')
5529 if not isinstance(in_field, str) or not in_field.strip():
5530 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].input_field' must be a non-empty string.")
5531 if not isinstance(out_field, str) or not out_field.strip():
5532 errors.append(f" {post_path}: 'lagrangian_pipeline[{i}].output_field' must be a non-empty string.")
5533 continue
5534 errors.append(
5535 f" {post_path}: unsupported lagrangian task '{task_name}' at lagrangian_pipeline[{i}]."
5536 )
5537
5538 # --- Check statistics pipeline entries ---
5539 stats_cfg = post_cfg.get('statistics_pipeline')
5540 stats_entries = []
5541 if stats_cfg is not None:
5542 if isinstance(stats_cfg, list):
5543 stats_entries = stats_cfg
5544 elif isinstance(stats_cfg, dict):
5545 stats_entries = stats_cfg.get('tasks', [])
5546 if not isinstance(stats_entries, list):
5547 errors.append(f" {post_path}: 'statistics_pipeline.tasks' must be a list.")
5548 stats_output_prefix = stats_cfg.get('output_prefix')
5549 if stats_output_prefix is not None and not isinstance(stats_output_prefix, str):
5550 errors.append(f" {post_path}: 'statistics_pipeline.output_prefix' must be a string.")
5551 else:
5552 errors.append(
5553 f" {post_path}: 'statistics_pipeline' must be either a list of tasks or a mapping with a 'tasks' list."
5554 )
5555 for i, entry in enumerate(stats_entries):
5556 if isinstance(entry, str):
5557 task_name = entry
5558 elif isinstance(entry, dict) and 'task' in entry:
5559 task_name = entry.get('task')
5560 else:
5561 errors.append(
5562 f" {post_path}: statistics task entry {i} must be either a string or a mapping with key 'task'."
5563 )
5564 continue
5565 try:
5566 normalize_statistics_task(task_name)
5567 except ValueError as e:
5568 errors.append(f" {post_path}: {e}")
5569
5570 legacy_stats_output_prefix = post_cfg.get('statistics_output_prefix')
5571 if legacy_stats_output_prefix is not None and not isinstance(legacy_stats_output_prefix, str):
5572 errors.append(f" {post_path}: 'statistics_output_prefix' must be a string when provided.")
5573
5574 if errors:
5575 _print_validation_errors(errors)
5576
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 5577 of file core.py.

5577def validate_cluster_config(cluster_cfg: dict, cluster_path: str):
5578 """!
5579 @brief Validate Slurm scheduler configuration from cluster.yml.
5580 @param[in] cluster_cfg Argument passed to `validate_cluster_config()`.
5581 @param[in] cluster_path Argument passed to `validate_cluster_config()`.
5582 """
5583 errors = []
5584 warnings = []
5585 _validate_yaml_schema_keys(cluster_cfg, _CLUSTER_SCHEMA, cluster_path, errors)
5586 if not isinstance(cluster_cfg, dict) or not cluster_cfg:
5587 errors.append(f" {cluster_path}: cluster config is empty or not a valid YAML mapping.")
5588 _print_validation_errors(errors)
5589
5590 scheduler = cluster_cfg.get("scheduler", {})
5591 if not isinstance(scheduler, dict):
5592 errors.append(f" {cluster_path}: 'scheduler' must be a mapping.")
5593 else:
5594 scheduler_type = scheduler.get("type", "slurm")
5595 if str(scheduler_type).lower() != "slurm":
5596 errors.append(f" {cluster_path}: scheduler.type must be 'slurm' in v1 (got '{scheduler_type}').")
5597
5598 resources = cluster_cfg.get("resources", {})
5599 if not isinstance(resources, dict):
5600 errors.append(f" {cluster_path}: 'resources' must be a mapping.")
5601 else:
5602 for req in ("account", "nodes", "ntasks_per_node", "mem", "time"):
5603 if req not in resources:
5604 errors.append(f" {cluster_path}: missing required key 'resources.{req}'.")
5605 for int_key in ("nodes", "ntasks_per_node"):
5606 if int_key in resources:
5607 val = resources.get(int_key)
5608 if not isinstance(val, int) or val <= 0:
5609 errors.append(f" {cluster_path}: resources.{int_key} must be a positive integer (got {val}).")
5610 for str_key in ("account", "mem", "time", "partition"):
5611 if str_key in resources and resources.get(str_key) is not None:
5612 if not isinstance(resources.get(str_key), str):
5613 errors.append(f" {cluster_path}: resources.{str_key} must be a string when provided.")
5614 if isinstance(resources.get("time"), str):
5615 try:
5616 parse_slurm_time_limit_to_seconds(resources["time"])
5617 except ValueError as exc:
5618 errors.append(
5619 f" {cluster_path}: resources.time must be a supported finite Slurm time string ({exc})."
5620 )
5621 account = resources.get("account")
5622 if account == CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT:
5623 warnings.append(
5624 f"{cluster_path}: resources.account still uses the sample placeholder "
5625 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_ACCOUNT}'. Edit the cluster profile before submission."
5626 )
5627
5628 notifications = cluster_cfg.get("notifications", {})
5629 if notifications is not None and not isinstance(notifications, dict):
5630 errors.append(f" {cluster_path}: 'notifications' must be a mapping when provided.")
5631 elif isinstance(notifications, dict):
5632 mail_user = notifications.get("mail_user")
5633 if mail_user is not None and not is_valid_email(mail_user):
5634 errors.append(f" {cluster_path}: notifications.mail_user is not a valid email '{mail_user}'.")
5635 if mail_user == CLUSTER_TEMPLATE_PLACEHOLDER_MAIL:
5636 warnings.append(
5637 f"{cluster_path}: notifications.mail_user still uses the sample placeholder "
5638 f"'{CLUSTER_TEMPLATE_PLACEHOLDER_MAIL}'. Edit the cluster profile before submission."
5639 )
5640 mail_type = notifications.get("mail_type")
5641 if mail_type is not None and not isinstance(mail_type, str):
5642 errors.append(f" {cluster_path}: notifications.mail_type must be a string when provided.")
5643
5644 execution = cluster_cfg.get("execution", {})
5645 if execution is not None and not isinstance(execution, dict):
5646 errors.append(f" {cluster_path}: 'execution' must be a mapping when provided.")
5647 elif isinstance(execution, dict):
5648 module_setup = execution.get("module_setup", [])
5649 if module_setup is not None and not isinstance(module_setup, list):
5650 errors.append(f" {cluster_path}: execution.module_setup must be a list of shell lines.")
5651 elif isinstance(module_setup, list):
5652 for i, line in enumerate(module_setup):
5653 if not isinstance(line, str):
5654 errors.append(f" {cluster_path}: execution.module_setup[{i}] must be a string.")
5655
5656 launcher = execution.get("launcher")
5657 if launcher is not None and not isinstance(launcher, str):
5658 errors.append(f" {cluster_path}: execution.launcher must be a string when provided.")
5659 launcher_args = execution.get("launcher_args")
5660 if launcher_args is not None and not isinstance(launcher_args, list):
5661 errors.append(f" {cluster_path}: execution.launcher_args must be a list of CLI tokens.")
5662 elif isinstance(launcher_args, list):
5663 for i, token in enumerate(launcher_args):
5664 if not isinstance(token, (str, int, float)):
5665 errors.append(f" {cluster_path}: execution.launcher_args[{i}] must be a scalar CLI token.")
5666 elif _launcher_arg_contains_whitespace(token):
5667 errors.append(
5668 f" {cluster_path}: execution.launcher_args[{i}] must be a single CLI token; "
5669 "split whitespace-separated arguments into separate list items."
5670 )
5671 if (launcher is None or isinstance(launcher, str)) and (launcher_args is None or isinstance(launcher_args, list)):
5672 try:
5673 normalize_cluster_launcher(execution)
5674 except ValueError as exc:
5675 errors.append(f" {cluster_path}: {exc}.")
5676
5677 extra_sbatch = execution.get("extra_sbatch")
5678 if extra_sbatch is not None and not isinstance(extra_sbatch, (dict, list)):
5679 errors.append(f" {cluster_path}: execution.extra_sbatch must be a mapping or list when provided.")
5680
5681 walltime_guard = execution.get("walltime_guard")
5682 if walltime_guard is not None and not isinstance(walltime_guard, dict):
5683 errors.append(f" {cluster_path}: execution.walltime_guard must be a mapping when provided.")
5684 elif isinstance(walltime_guard, dict):
5685 enabled = walltime_guard.get("enabled")
5686 if enabled is not None and not isinstance(enabled, bool):
5687 errors.append(f" {cluster_path}: execution.walltime_guard.enabled must be boolean when provided.")
5688
5689 warmup_steps = walltime_guard.get("warmup_steps")
5690 if warmup_steps is not None and (not isinstance(warmup_steps, int) or isinstance(warmup_steps, bool) or warmup_steps <= 0):
5691 errors.append(
5692 f" {cluster_path}: execution.walltime_guard.warmup_steps must be a positive integer when provided."
5693 )
5694
5695 multiplier = walltime_guard.get("multiplier")
5696 if multiplier is not None:
5697 if isinstance(multiplier, bool) or not isinstance(multiplier, (int, float)) or multiplier <= 0.0:
5698 errors.append(
5699 f" {cluster_path}: execution.walltime_guard.multiplier must be a positive number when provided."
5700 )
5701 elif float(multiplier) > 5.0:
5702 errors.append(
5703 f" {cluster_path}: execution.walltime_guard.multiplier must be <= 5.0 (got {multiplier})."
5704 )
5705
5706 min_seconds = walltime_guard.get("min_seconds")
5707 if min_seconds is not None and (
5708 isinstance(min_seconds, bool) or not isinstance(min_seconds, (int, float)) or float(min_seconds) <= 0.0
5709 ):
5710 errors.append(
5711 f" {cluster_path}: execution.walltime_guard.min_seconds must be a positive number when provided."
5712 )
5713
5714 estimator_alpha = walltime_guard.get("estimator_alpha")
5715 if estimator_alpha is not None:
5716 if isinstance(estimator_alpha, bool) or not isinstance(estimator_alpha, (int, float)):
5717 errors.append(
5718 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be a number in (0, 1] when provided."
5719 )
5720 elif float(estimator_alpha) <= 0.0 or float(estimator_alpha) > 1.0:
5721 errors.append(
5722 f" {cluster_path}: execution.walltime_guard.estimator_alpha must be in (0, 1] (got {estimator_alpha})."
5723 )
5724
5725 if warnings:
5726 for warning in warnings:
5727 print(f"[WARN] {warning}", file=sys.stderr)
5728
5729 if errors:
5730 _print_validation_errors(errors)
5731
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 5732 of file core.py.

5732def validate_study_config(study_cfg: dict, study_path: str, skip_base_file_check: bool = False):
5733 """!
5734 @brief Validate sweep/study specification from study.yml.
5735 @param[in] study_cfg Argument passed to `validate_study_config()`.
5736 @param[in] study_path Argument passed to `validate_study_config()`.
5737 @param[in] skip_base_file_check When True, skip file-existence check for base_configs paths.
5738 """
5739 errors = []
5740 _validate_yaml_schema_keys(study_cfg, _STUDY_SCHEMA, study_path, errors)
5741 if not isinstance(study_cfg, dict) or not study_cfg:
5742 errors.append(f" {study_path}: study config is empty or not a valid YAML mapping.")
5743 _print_validation_errors(errors)
5744
5745 base_cfgs = study_cfg.get("base_configs")
5746 if not isinstance(base_cfgs, dict):
5747 errors.append(f" {study_path}: missing required mapping 'base_configs'.")
5748 else:
5749 for req in ("case", "solver", "monitor", "post"):
5750 path_val = base_cfgs.get(req)
5751 if not path_val or not isinstance(path_val, str):
5752 errors.append(f" {study_path}: base_configs.{req} must be a path string.")
5753 elif not skip_base_file_check:
5754 resolved = resolve_path(study_path, path_val)
5755 if not os.path.isfile(resolved):
5756 errors.append(f" {study_path}: base_configs.{req} does not exist: {resolved}")
5757
5758 study_type = study_cfg.get("study_type")
5759 allowed_types = {"grid_independence", "timestep_independence", "sensitivity"}
5760 if study_type not in allowed_types:
5761 errors.append(
5762 f" {study_path}: study_type must be one of {sorted(allowed_types)} (got '{study_type}')."
5763 )
5764
5765 parameters = study_cfg.get("parameters")
5766 parameter_sets = study_cfg.get("parameter_sets")
5767 allowed_roots = {"case", "solver", "monitor", "post"}
5768 if bool(parameters) == bool(parameter_sets):
5769 errors.append(f" {study_path}: provide exactly one of 'parameters' or 'parameter_sets'.")
5770 elif parameter_sets:
5771 if not isinstance(parameter_sets, list) or not parameter_sets:
5772 errors.append(f" {study_path}: 'parameter_sets' must be a non-empty list of key->value mappings.")
5773 else:
5774 for set_index, param_set in enumerate(parameter_sets):
5775 if not isinstance(param_set, dict) or not param_set:
5776 errors.append(
5777 f" {study_path}: parameter_sets[{set_index}] must be a non-empty mapping of key->value overrides."
5778 )
5779 continue
5780 for key, value in param_set.items():
5781 if not isinstance(key, str) or "." not in key:
5782 errors.append(
5783 f" {study_path}: parameter_sets[{set_index}] key '{key}' must use '<target>.<yaml.path>' format."
5784 )
5785 continue
5786 root = key.split(".", 1)[0]
5787 if root not in allowed_roots:
5788 errors.append(
5789 f" {study_path}: parameter_sets[{set_index}] key '{key}' must start with one of {sorted(allowed_roots)}."
5790 )
5791 if isinstance(value, (dict, list)):
5792 errors.append(
5793 f" {study_path}: parameter_sets[{set_index}] value for '{key}' must be a scalar, not {type(value).__name__}."
5794 )
5795 else:
5796 if not isinstance(parameters, dict) or not parameters:
5797 errors.append(f" {study_path}: 'parameters' must be a non-empty mapping of key->list.")
5798 else:
5799 for key, values in parameters.items():
5800 if not isinstance(key, str) or "." not in key:
5801 errors.append(
5802 f" {study_path}: parameter key '{key}' must use '<target>.<yaml.path>' format."
5803 )
5804 continue
5805 root = key.split(".", 1)[0]
5806 if root not in allowed_roots:
5807 errors.append(
5808 f" {study_path}: parameter key '{key}' must start with one of {sorted(allowed_roots)}."
5809 )
5810 if not isinstance(values, list) or len(values) == 0:
5811 errors.append(f" {study_path}: parameters.{key} must be a non-empty list.")
5812
5813 metrics = study_cfg.get("metrics", [])
5814 if metrics is not None and not isinstance(metrics, list):
5815 errors.append(f" {study_path}: 'metrics' must be a list when provided.")
5816 elif isinstance(metrics, list):
5817 for i, metric in enumerate(metrics):
5818 if isinstance(metric, str):
5819 continue
5820 if not isinstance(metric, dict):
5821 errors.append(
5822 f" {study_path}: metrics[{i}] must be a string or mapping."
5823 )
5824 continue
5825 if "name" not in metric:
5826 errors.append(f" {study_path}: metrics[{i}] missing required key 'name'.")
5827 if "source" not in metric:
5828 errors.append(f" {study_path}: metrics[{i}] missing required key 'source'.")
5829
5830 plotting = study_cfg.get("plotting", {})
5831 if plotting is not None and not isinstance(plotting, dict):
5832 errors.append(f" {study_path}: 'plotting' must be a mapping when provided.")
5833 elif isinstance(plotting, dict):
5834 enabled = plotting.get("enabled")
5835 if enabled is not None and not isinstance(enabled, bool):
5836 errors.append(f" {study_path}: plotting.enabled must be boolean when provided.")
5837 output_format = plotting.get("output_format")
5838 if output_format is not None and output_format not in {"png", "pdf", "svg"}:
5839 errors.append(f" {study_path}: plotting.output_format must be one of ['png','pdf','svg'].")
5840
5841 execution = study_cfg.get("execution", {})
5842 if execution is not None and not isinstance(execution, dict):
5843 errors.append(f" {study_path}: 'execution' must be a mapping when provided.")
5844 elif isinstance(execution, dict):
5845 max_conc = execution.get("max_concurrent_array_tasks")
5846 if max_conc is not None and (not isinstance(max_conc, int) or max_conc <= 0):
5847 errors.append(
5848 f" {study_path}: execution.max_concurrent_array_tasks must be a positive integer when provided."
5849 )
5850
5851 if errors:
5852 _print_validation_errors(errors)
5853
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 5854 of file core.py.

5854def _deep_set(container: dict, dotted_path: str, value):
5855 """!
5856 @brief Set nested dictionary value, creating intermediate maps when needed.
5857 @param[in] container Argument passed to `_deep_set()`.
5858 @param[in] dotted_path Argument passed to `_deep_set()`.
5859 @param[in] value Argument passed to `_deep_set()`.
5860 """
5861 keys = dotted_path.split(".")
5862 current = container
5863 for key in keys[:-1]:
5864 if key not in current or not isinstance(current[key], dict):
5865 current[key] = {}
5866 current = current[key]
5867 current[keys[-1]] = value
5868
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 5869 of file core.py.

5869def expand_parameter_matrix(parameters: dict) -> list:
5870 """!
5871 @brief Expand study parameter lists into cartesian-product combinations.
5872 @param[in] parameters Argument passed to `expand_parameter_matrix()`.
5873 @return Value returned by `expand_parameter_matrix()`.
5874 """
5875 param_keys = list(parameters.keys())
5876 all_values = [parameters[k] for k in param_keys]
5877 combos = []
5878 for combo in itertools.product(*all_values):
5879 combos.append(dict(zip(param_keys, combo)))
5880 return combos
5881
5882
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 5883 of file core.py.

5883def expand_study_parameter_combinations(study_cfg: dict) -> list:
5884 """!
5885 @brief Expand either cartesian-study parameters or explicit parameter sets.
5886 @param[in] study_cfg Argument passed to `expand_study_parameter_combinations()`.
5887 @return Value returned by `expand_study_parameter_combinations()`.
5888 """
5889 parameter_sets = study_cfg.get("parameter_sets")
5890 if parameter_sets:
5891 return [dict(param_set) for param_set in parameter_sets]
5892 return expand_parameter_matrix(study_cfg.get("parameters") or {})
5893
5894
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 5895 of file core.py.

5895def get_study_parameter_keys(study_cfg: dict) -> list:
5896 """!
5897 @brief Collect ordered parameter keys from either cross-product parameter expansions or explicit parameter sets.
5898 @param[in] study_cfg Argument passed to `get_study_parameter_keys()`.
5899 @return Value returned by `get_study_parameter_keys()`.
5900 """
5901 parameters = study_cfg.get("parameters")
5902 if isinstance(parameters, dict) and parameters:
5903 return list(parameters.keys())
5904
5905 keys = []
5906 parameter_sets = study_cfg.get("parameter_sets") or []
5907 for param_set in parameter_sets:
5908 if not isinstance(param_set, dict):
5909 continue
5910 for key in param_set.keys():
5911 if key not in keys:
5912 keys.append(key)
5913 return keys
5914
5915
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 5916 of file core.py.

5916def get_cluster_total_tasks(cluster_cfg: dict) -> int:
5917 """!
5918 @brief Return cluster total tasks.
5919 @param[in] cluster_cfg Argument passed to `get_cluster_total_tasks()`.
5920 @return Value returned by `get_cluster_total_tasks()`.
5921 """
5922 resources = cluster_cfg.get("resources", {})
5923 return int(resources.get("nodes", 1)) * int(resources.get("ntasks_per_node", 1))
5924
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 5925 of file core.py.

5925def normalize_extension(ext: str) -> str:
5926 """!
5927 @brief Normalize extension.
5928 @param[in] ext Argument passed to `normalize_extension()`.
5929 @return Value returned by `normalize_extension()`.
5930 """
5931 if ext is None:
5932 return None
5933 return str(ext).strip().lstrip(".")
5934

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

5946):
5947 """!
5948 @brief Render a Slurm batch script for a single command.
5949 @param[in] script_path Argument passed to `render_slurm_script()`.
5950 @param[in] job_name Argument passed to `render_slurm_script()`.
5951 @param[in] cluster_cfg Argument passed to `render_slurm_script()`.
5952 @param[in] command Argument passed to `render_slurm_script()`.
5953 @param[in] workdir Argument passed to `render_slurm_script()`.
5954 @param[in] stdout_path Argument passed to `render_slurm_script()`.
5955 @param[in] stderr_path Argument passed to `render_slurm_script()`.
5956 @param[in] env_vars Argument passed to `render_slurm_script()`.
5957 @param[in] shell_env_vars Argument passed to `render_slurm_script()`.
5958 @param[in] array_spec Argument passed to `render_slurm_script()`.
5959 """
5960 resources = cluster_cfg.get("resources", {})
5961 notifications = cluster_cfg.get("notifications", {}) or {}
5962 execution = cluster_cfg.get("execution", {}) or {}
5963 extra_sbatch = execution.get("extra_sbatch")
5964 module_setup = execution.get("module_setup", []) or []
5965
5966 if stderr_path is None:
5967 stderr_path = stdout_path.replace(".out", ".err")
5968
5969 lines = [
5970 "#!/bin/bash",
5971 f"#SBATCH --job-name={job_name}",
5972 f"#SBATCH --nodes={resources['nodes']}",
5973 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
5974 f"#SBATCH --mem={resources['mem']}",
5975 f"#SBATCH --time={resources['time']}",
5976 f"#SBATCH --output={stdout_path}",
5977 f"#SBATCH --error={stderr_path}",
5978 f"#SBATCH --account={resources['account']}",
5979 ]
5980 partition = resources.get("partition")
5981 if partition:
5982 lines.append(f"#SBATCH --partition={partition}")
5983 if array_spec:
5984 lines.append(f"#SBATCH --array={array_spec}")
5985 mail_user = notifications.get("mail_user")
5986 mail_type = notifications.get("mail_type")
5987 if mail_user:
5988 lines.append(f"#SBATCH --mail-user={mail_user}")
5989 if mail_type:
5990 lines.append(f"#SBATCH --mail-type={mail_type}")
5991
5992 if isinstance(extra_sbatch, dict):
5993 for key, value in extra_sbatch.items():
5994 flag = str(key)
5995 if not flag.startswith("--"):
5996 flag = f"--{flag}"
5997 if isinstance(value, bool):
5998 if value:
5999 lines.append(f"#SBATCH {flag}")
6000 elif value is not None:
6001 lines.append(f"#SBATCH {flag}={value}")
6002 elif isinstance(extra_sbatch, list):
6003 for token in extra_sbatch:
6004 lines.append(f"#SBATCH {token}")
6005
6006 lines.extend(
6007 [
6008 "",
6009 "set -euo pipefail",
6010 "",
6011 f"cd {shlex.quote(workdir)}",
6012 'echo "[$(date)] Starting job ${SLURM_JOB_NAME} (${SLURM_JOB_ID})"',
6013 'echo "[$(date)] Working directory: $PWD"',
6014 ]
6015 )
6016
6017 if shell_env_vars:
6018 for key, value in shell_env_vars.items():
6019 lines.append(f"export {key}={value}")
6020
6021 for setup_line in module_setup:
6022 lines.append(str(setup_line))
6023
6024 if env_vars:
6025 for key, value in env_vars.items():
6026 lines.append(f"export {key}={shlex.quote(str(value))}")
6027
6028 cmd = " ".join(shlex.quote(str(tok)) for tok in command)
6029 lines.append(f"exec {cmd}")
6030
6031 os.makedirs(os.path.dirname(script_path), exist_ok=True)
6032 with open(script_path, "w") as f:
6033 f.write("\n".join(lines) + "\n")
6034 os.chmod(script_path, 0o755)
6035
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 6036 of file core.py.

6040) -> "tuple[str | None, list[str]]":
6041 """!
6042 @brief Canonicalize launcher config into executable token plus argv-style flags.
6043 @param[in] launcher Argument passed to `split_launcher_tokens()`.
6044 @param[in] launcher_args Argument passed to `split_launcher_tokens()`.
6045 @param[in] label Argument passed to `split_launcher_tokens()`.
6046 @return Value returned by `split_launcher_tokens()`.
6047 """
6048 normalized_args = [str(x) for x in (launcher_args or [])]
6049
6050 if launcher is None:
6051 return None, normalized_args
6052
6053 try:
6054 launcher_tokens = shlex.split(str(launcher))
6055 except ValueError as exc:
6056 raise ValueError(f"{label} is not shell-parseable: {exc}") from exc
6057
6058 if not launcher_tokens:
6059 return None, normalized_args
6060
6061 return launcher_tokens[0], launcher_tokens[1:] + normalized_args
6062
6063
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 6064 of file core.py.

6064def normalize_cluster_launcher(execution: dict) -> "tuple[str | None, list[str]]":
6065 """!
6066 @brief Canonicalize cluster launcher config into executable token plus argv-style flags.
6067 @param[in] execution Argument passed to `normalize_cluster_launcher()`.
6068 @return Value returned by `normalize_cluster_launcher()`.
6069 """
6070 return split_launcher_tokens(
6071 execution.get("launcher"),
6072 execution.get("launcher_args") or [],
6073 label="execution.launcher",
6074 )
6075
6076
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 6077 of file core.py.

6077def strip_launcher_size_flags(launcher_name: str, launcher_args: "list[str]") -> "list[str]":
6078 """!
6079 @brief Remove explicit MPI task-count flags from known launchers.
6080 @param[in] launcher_name Basename-normalized launcher executable.
6081 @param[in] launcher_args Launcher argument list.
6082 @return Filtered launcher arguments with explicit size flags removed.
6083 """
6084 filtered = []
6085 idx = 0
6086 while idx < len(launcher_args):
6087 token = str(launcher_args[idx])
6088
6089 if launcher_name == "srun":
6090 if token in {"-n", "--ntasks"}:
6091 idx += 2
6092 continue
6093 if token.startswith("--ntasks="):
6094 idx += 1
6095 continue
6096 elif launcher_name in {"mpiexec", "mpirun"}:
6097 if token in {"-n", "-np"}:
6098 idx += 2
6099 continue
6100 if token.startswith("-n=") or token.startswith("-np="):
6101 idx += 1
6102 continue
6103
6104 filtered.append(token)
6105 idx += 1
6106
6107 return filtered
6108
6109
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 6110 of file core.py.

6110def build_serial_post_cluster_config(cluster_cfg: dict, num_procs: int = 1) -> dict:
6111 """!
6112 @brief Clone cluster config and force a single-node post stage task layout.
6113 @param[in] cluster_cfg Base cluster configuration.
6114 @param[in] num_procs Number of post tasks to request.
6115 @return Cluster configuration specialized for the post stage.
6116 """
6117 post_cluster_cfg = copy.deepcopy(cluster_cfg)
6118 post_cluster_cfg.setdefault("resources", {})
6119 post_cluster_cfg["resources"]["nodes"] = 1
6120 post_cluster_cfg["resources"]["ntasks_per_node"] = int(num_procs)
6121 return post_cluster_cfg
6122
6123
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 6124 of file core.py.

6131) -> list:
6132 """!
6133 @brief Build local launcher command, allowing env or shared config overrides for login-node MPI quirks.
6134 @param[in] executable Argument passed to `build_local_launch_command()`.
6135 @param[in] executable_args Argument passed to `build_local_launch_command()`.
6136 @param[in] num_procs Argument passed to `build_local_launch_command()`.
6137 @param[in] config_search_anchor Argument passed to `build_local_launch_command()`.
6138 @param[in] allow_single_rank_launcher_override When true, explicit launcher overrides also apply to 1-rank commands.
6139 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
6140 @return Value returned by `build_local_launch_command()`.
6141 """
6142 target_num_procs = force_num_procs if force_num_procs is not None else num_procs
6143 command = [executable] + executable_args
6144 if target_num_procs <= 1 and not allow_single_rank_launcher_override:
6145 return command
6146
6147 launcher_override = os.environ.get("PICURV_MPI_LAUNCHER")
6148 if launcher_override is None:
6149 launcher_override = os.environ.get("MPI_LAUNCHER")
6150
6151 try:
6152 if launcher_override is not None:
6153 explicit_launcher_config = True
6154 launcher, launcher_args = split_launcher_tokens(
6155 launcher_override,
6156 label="local MPI launcher override",
6157 )
6158 else:
6159 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor)
6160 local_execution = resolve_runtime_execution_context(runtime_execution_cfg, "local")
6161 configured_launcher = local_execution.get("launcher")
6162 configured_args = local_execution.get("launcher_args") or []
6163 explicit_launcher_config = configured_launcher is not None or bool(configured_args)
6164 if target_num_procs <= 1 and not explicit_launcher_config:
6165 return command
6166 launcher, launcher_args = split_launcher_tokens(
6167 configured_launcher if configured_launcher is not None else "mpiexec",
6168 configured_args,
6169 label="local_execution.launcher",
6170 )
6171 except ValueError as exc:
6172 print(f"[FATAL] {exc}", file=sys.stderr)
6173 sys.exit(1)
6174
6175 if not launcher:
6176 return command
6177
6178 launcher_name = os.path.basename(launcher).lower()
6179 if force_num_procs is not None:
6180 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
6181 prefix = [launcher] + launcher_args
6182
6183 if launcher_name == "srun":
6184 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
6185 if not has_n:
6186 prefix += ["-n", str(target_num_procs)]
6187 elif launcher_name in {"mpiexec", "mpirun"}:
6188 has_n = any(token in {"-n", "-np"} for token in launcher_args)
6189 if not has_n:
6190 prefix += ["-n", str(target_num_procs)]
6191
6192 return prefix + command
6193
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 6194 of file core.py.

6194def resolve_cluster_execution(cluster_cfg: dict, config_search_anchor: str = None, extra_search_anchors=None) -> dict:
6195 """!
6196 @brief Resolve cluster execution launcher settings from shared runtime config plus cluster.yml overrides.
6197 @param[in] cluster_cfg Argument passed to `resolve_cluster_execution()`.
6198 @param[in] config_search_anchor Argument passed to `resolve_cluster_execution()`.
6199 @param[in] extra_search_anchors Argument passed to `resolve_cluster_execution()`.
6200 @return Value returned by `resolve_cluster_execution()`.
6201 """
6202 _, runtime_execution_cfg = load_runtime_execution_config(config_search_anchor, extra_search_anchors=extra_search_anchors)
6203 shared_cluster_execution = resolve_runtime_execution_context(runtime_execution_cfg, "cluster")
6204 execution = cluster_cfg.get("execution", {}) or {}
6205 cluster_override = {
6206 "launcher": execution.get("launcher") if "launcher" in execution else None,
6207 "launcher_args": execution.get("launcher_args") if "launcher_args" in execution else None,
6208 }
6209 return merge_execution_overrides(shared_cluster_execution, cluster_override)
6210
6211
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 6212 of file core.py.

6219) -> list:
6220 """!
6221 @brief Build scheduler launcher command from cluster config plus optional shared execution defaults.
6222 @param[in] cluster_cfg Argument passed to `build_cluster_launch_command()`.
6223 @param[in] executable Argument passed to `build_cluster_launch_command()`.
6224 @param[in] executable_args Argument passed to `build_cluster_launch_command()`.
6225 @param[in] config_search_anchor Argument passed to `build_cluster_launch_command()`.
6226 @param[in] extra_search_anchors Argument passed to `build_cluster_launch_command()`.
6227 @param[in] force_num_procs Optional explicit MPI rank count override applied after stripping conflicting launcher size flags.
6228 @return Value returned by `build_cluster_launch_command()`.
6229 """
6230 try:
6231 execution = resolve_cluster_execution(
6232 cluster_cfg,
6233 config_search_anchor=config_search_anchor,
6234 extra_search_anchors=extra_search_anchors,
6235 )
6236 launcher, launcher_args = split_launcher_tokens(
6237 execution.get("launcher") if execution.get("launcher") is not None else "srun",
6238 execution.get("launcher_args") or [],
6239 label="cluster execution launcher",
6240 )
6241 except ValueError as exc:
6242 print(f"[FATAL] {exc}", file=sys.stderr)
6243 sys.exit(1)
6244
6245 ntasks = int(force_num_procs) if force_num_procs is not None else get_cluster_total_tasks(cluster_cfg)
6246 launcher_name = launcher.lower() if launcher else ""
6247 if force_num_procs is not None:
6248 launcher_args = strip_launcher_size_flags(launcher_name, launcher_args)
6249
6250 if launcher and launcher_name == "srun":
6251 has_n = any(token in {"-n", "--ntasks"} for token in launcher_args)
6252 cmd = ["srun"] + launcher_args
6253 if not has_n:
6254 cmd += ["-n", str(ntasks)]
6255 return cmd + [executable] + executable_args
6256
6257 if launcher and launcher_name == "mpirun":
6258 has_np = any(token in {"-np", "-n"} for token in launcher_args)
6259 cmd = ["mpirun"] + launcher_args
6260 if not has_np:
6261 cmd += ["-np", str(ntasks)]
6262 return cmd + [executable] + executable_args
6263
6264 if launcher and launcher_name == "mpiexec":
6265 has_np = any(token in {"-np", "-n"} for token in launcher_args)
6266 cmd = ["mpiexec"] + launcher_args
6267 if not has_np:
6268 cmd += ["-np", str(ntasks)]
6269 return cmd + [executable] + executable_args
6270
6271 # Custom launcher or no launcher.
6272 cmd = []
6273 if launcher:
6274 cmd.append(str(launcher))
6275 cmd += launcher_args
6276 cmd += [executable] + executable_args
6277 return cmd
6278
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 6279 of file core.py.

6279def parse_slurm_job_id(sbatch_output: str) -> str:
6280 """!
6281 @brief Extract numeric job id from standard sbatch output.
6282 @param[in] sbatch_output Argument passed to `parse_slurm_job_id()`.
6283 @return Value returned by `parse_slurm_job_id()`.
6284 """
6285 match = re.search(r"Submitted batch job\s+(\d+)", sbatch_output or "")
6286 return match.group(1) if match else None
6287
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 6288 of file core.py.

6288def submit_sbatch(script_path: str, dependency: str = None, dependency_type: str = "afterok") -> dict:
6289 """!
6290 @brief Submit sbatch script and return submission metadata.
6291 @param[in] script_path Argument passed to `submit_sbatch()`.
6292 @param[in] dependency Argument passed to `submit_sbatch()`.
6293 @param[in] dependency_type Slurm dependency type (default: afterok). Common values: afterok, afterany.
6294 @return Value returned by `submit_sbatch()`.
6295 """
6296 cmd = ["sbatch"]
6297 if dependency:
6298 cmd.append(f"--dependency={dependency_type}:{dependency}")
6299 cmd.append(script_path)
6300 result = subprocess.run(cmd, text=True, capture_output=True, check=False)
6301 metadata = {
6302 "command": cmd,
6303 "returncode": result.returncode,
6304 "stdout": (result.stdout or "").strip(),
6305 "stderr": (result.stderr or "").strip(),
6306 "script": script_path,
6307 }
6308 if result.returncode != 0:
6309 print(f"[FATAL] sbatch submission failed for {script_path}\n{metadata['stderr']}", file=sys.stderr)
6310 sys.exit(result.returncode)
6311 metadata["job_id"] = parse_slurm_job_id(metadata["stdout"])
6312 if not metadata["job_id"]:
6313 print(
6314 f"[FATAL] Could not parse Slurm job id from sbatch output: {metadata['stdout']}",
6315 file=sys.stderr
6316 )
6317 sys.exit(1)
6318 return metadata
6319
6320
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 6321 of file core.py.

6321def _print_validation_errors(errors: list):
6322 """!
6323 @brief Prints validation errors and exits.
6324 @param[in] errors List of error message strings.
6325 """
6326 print(f"\n[FATAL] Configuration validation failed with {len(errors)} issue(s):", file=sys.stderr)
6327 for raw_error in errors:
6328 file_path, message = _split_error_file_and_message(raw_error)
6329 key_path = _extract_key_path(message)
6330 code = _classify_error_code(message)
6331 emit_structured_error(code, key=key_path, file_path=file_path, message=message)
6332 print(
6333 "\nHint: See examples/master_template/ for valid config structure and "
6334 "docs/pages/14_Config_Contract.md for key-level contract details.",
6335 file=sys.stderr,
6336 )
6337 sys.exit(1)
6338
6339
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 6340 of file core.py.

6340def generate_header(run_id: str, source_files: dict) -> str:
6341 """!
6342 @brief Creates a standard header block for all generated files.
6343 @param[in] run_id The unique identifier for the current simulation run.
6344 @param[in] source_files A dictionary of source profile files used.
6345 @return A formatted string containing the header.
6346 """
6347 header_parts = [
6348 "# ==============================================================================",
6349 "# AUTO-GENERATED CONFIGURATION FILE",
6350 "# ------------------------------------------------------------------------------",
6351 f"# Run ID: {run_id}",
6352 f"# Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
6353 "#",
6354 "# Source Configuration:"
6355 ]
6356 for name, path in source_files.items():
6357 header_parts.append(f"# - {name:<12}: {os.path.basename(path)}")
6358 header_parts.extend([
6359 "#",
6360 "# DO NOT EDIT THIS FILE MANUALLY. IT IS A MACHINE-READABLE ARTIFACT.",
6361 "# ==============================================================================\n"
6362 ])
6363 return "\n".join(header_parts)
6364
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 6365 of file core.py.

6365def generate_simple_list_file(run_dir: str, run_id: str, cfg: dict, section: str, key: str, filename: str, header_sources: dict) -> str:
6366 """!
6367 @brief Generic function to create a file containing a simple list of strings.
6368 @param[in] run_dir The path to the main run directory.
6369 @param[in] run_id The unique identifier for the run.
6370 @param[in] cfg The dictionary containing the configuration data.
6371 @param[in] section The top-level key in the cfg dictionary.
6372 @param[in] key The second-level key whose value is the list of strings.
6373 @param[in] filename The name of the file to generate (e.g., 'whitelist.run').
6374 @param[in] header_sources A dictionary of source files for the header.
6375 @return The absolute path to the generated file.
6376 """
6377 print(f"[INFO] Generating {filename}...")
6378 config_dir = os.path.join(run_dir, "config")
6379 file_path = os.path.join(config_dir, filename)
6380
6381 lines = [generate_header(run_id, header_sources)]
6382 items = cfg.get(section, {}).get(key, [])
6383 lines.extend(items)
6384
6385 with open(file_path, "w") as f: f.write("\n".join(lines))
6386 print(f"[SUCCESS] Generated {filename}: {os.path.relpath(file_path)}")
6387 return os.path.abspath(file_path)
6388
6389
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 6390 of file core.py.

6390def has_explicit_monitor_whitelist(monitor_cfg: dict) -> bool:
6391 """!
6392 @brief Return True when logging.enabled_functions contains at least one entry.
6393 @param[in] monitor_cfg Argument passed to `has_explicit_monitor_whitelist()`.
6394 @return Value returned by `has_explicit_monitor_whitelist()`.
6395 """
6396 items = monitor_cfg.get("logging", {}).get("enabled_functions", [])
6397 return bool(items)
6398
6399
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 6400 of file core.py.

6400def resolve_profiling_config(monitor_cfg: dict) -> dict:
6401 """!
6402 @brief Resolve profiling reporting config from monitor.yml.
6403 @param[in] monitor_cfg Argument passed to `resolve_profiling_config()`.
6404 @return Value returned by `resolve_profiling_config()`.
6405 """
6406 profiling_cfg = monitor_cfg.get("profiling", {}) or {}
6407 timestep_cfg = profiling_cfg.get("timestep_output")
6408 final_cfg = profiling_cfg.get("final_summary")
6409
6410 if timestep_cfg is None:
6411 mode = "off"
6412 functions = []
6413 timestep_file = "Profiling_Timestep_Summary.csv"
6414 else:
6415 if not isinstance(timestep_cfg, dict):
6416 raise ValueError("monitor.profiling.timestep_output must be a mapping when provided.")
6417 mode = str(timestep_cfg.get("mode", "off")).lower()
6418 functions = timestep_cfg.get("functions", [])
6419 timestep_file = str(timestep_cfg.get("file", "Profiling_Timestep_Summary.csv"))
6420
6421 if mode not in {"off", "selected", "all"}:
6422 raise ValueError("monitor.profiling.timestep_output.mode must be one of ['off', 'selected', 'all'].")
6423 if functions is None:
6424 functions = []
6425 if not isinstance(functions, list):
6426 raise ValueError("monitor.profiling.timestep_output.functions must be a list of function names.")
6427 if not all(isinstance(item, str) and item.strip() for item in functions):
6428 raise ValueError("monitor.profiling.timestep_output.functions entries must be non-empty strings.")
6429 if mode == "selected" and not functions:
6430 raise ValueError("monitor.profiling.timestep_output.functions must be non-empty when mode is 'selected'.")
6431 if mode != "selected" and functions:
6432 raise ValueError("monitor.profiling.timestep_output.functions is only valid when mode is 'selected'.")
6433 if not timestep_file:
6434 raise ValueError("monitor.profiling.timestep_output.file must be a non-empty string.")
6435
6436 if final_cfg is None:
6437 final_enabled = True
6438 elif isinstance(final_cfg, dict):
6439 final_enabled = bool(final_cfg.get("enabled", True))
6440 else:
6441 raise ValueError("monitor.profiling.final_summary must be a mapping when provided.")
6442
6443 return {
6444 "mode": mode,
6445 "functions": functions,
6446 "timestep_file": timestep_file,
6447 "final_summary_enabled": final_enabled,
6448 }
6449
6450
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 6467 of file core.py.

6467def _diagnostic_bool_or_path(value, key: str):
6468 """!
6469 @brief Validate a diagnostics value that can be false, true, or a path/viewer string.
6470 @param[in] value Candidate value.
6471 @param[in] key Diagnostics key used in error messages.
6472 @return Normalized value.
6473 """
6474 if isinstance(value, bool) or value is None:
6475 return value
6476 if isinstance(value, str) and value.strip():
6477 return value.strip()
6478 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or a non-empty string.")
6479
6480
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 6481 of file core.py.

6481def _diagnostic_bool(value, key: str) -> bool:
6482 """!
6483 @brief Validate a diagnostics boolean value.
6484 @param[in] value Candidate value.
6485 @param[in] key Diagnostics key used in error messages.
6486 @return Boolean value.
6487 """
6488 if isinstance(value, bool):
6489 return value
6490 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean.")
6491
6492
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 6493 of file core.py.

6493def _diagnostic_bool_or_all(value, key: str):
6494 """!
6495 @brief Validate a diagnostics value that can be false, true, or "all".
6496 @param[in] value Candidate value.
6497 @param[in] key Diagnostics key used in error messages.
6498 @return Normalized value.
6499 """
6500 if isinstance(value, bool) or value is None:
6501 return value
6502 if isinstance(value, str) and value.strip().lower() == "all":
6503 return "all"
6504 raise ValueError(f"monitor.diagnostics.petsc.{key} must be boolean, null, or 'all'.")
6505
6506
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 6507 of file core.py.

6507def _diagnostic_default_file(run_dir: str, filename: str) -> str:
6508 """!
6509 @brief Return an absolute run-local diagnostics file path.
6510 @param[in] run_dir Run directory.
6511 @param[in] filename Diagnostics filename.
6512 @return Absolute diagnostics path under the run logs directory.
6513 """
6514 return os.path.abspath(os.path.join(run_dir, "logs", filename))
6515
6516
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 6517 of file core.py.

6517def _diagnostic_resolve_path_or_default(value, run_dir: str, default_filename: str):
6518 """!
6519 @brief Resolve true/string diagnostics values to a concrete file path.
6520 @param[in] value Boolean/string diagnostics value.
6521 @param[in] run_dir Run directory.
6522 @param[in] default_filename Default file name when value is true.
6523 @return False, or an absolute/explicit path string.
6524 """
6525 if value is True:
6526 return _diagnostic_default_file(run_dir, default_filename)
6527 if isinstance(value, str):
6528 if os.path.isabs(value) or value.startswith(":"):
6529 return value
6530 return os.path.abspath(os.path.join(run_dir, "logs", value))
6531 return False
6532
6533
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 6534 of file core.py.

6534def resolve_diagnostics_config(monitor_cfg: dict, run_dir: "str | None" = None, stage_label: str = "Solver") -> dict:
6535 """!
6536 @brief Resolve monitor diagnostics config and default run-local log paths.
6537 @param[in] monitor_cfg Parsed monitor.yml mapping.
6538 @param[in] run_dir Optional run directory for default artifact paths.
6539 @param[in] stage_label Solver/PostProcessor suffix used for PETSc output defaults.
6540 @return Normalized diagnostics config.
6541 """
6542 diagnostics_cfg = (monitor_cfg.get("diagnostics", {}) or {}) if isinstance(monitor_cfg, dict) else {}
6543 if not isinstance(diagnostics_cfg, dict):
6544 raise ValueError("monitor.diagnostics must be a mapping when provided.")
6545
6546 petsc_raw = diagnostics_cfg.get("petsc", {}) or {}
6547 if not isinstance(petsc_raw, dict):
6548 raise ValueError("monitor.diagnostics.petsc must be a mapping when provided.")
6549 unknown = sorted(set(petsc_raw.keys()) - DIAGNOSTICS_PETSC_KEYS)
6550 if unknown:
6551 raise ValueError(f"monitor.diagnostics.petsc has unsupported key(s): {unknown}.")
6552
6553 petsc = {
6554 "malloc_debug": _diagnostic_bool(petsc_raw.get("malloc_debug", False), "malloc_debug"),
6555 "malloc_test": _diagnostic_bool(petsc_raw.get("malloc_test", False), "malloc_test"),
6556 "malloc_dump": _diagnostic_bool(petsc_raw.get("malloc_dump", False), "malloc_dump"),
6557 "malloc_view": _diagnostic_bool_or_path(petsc_raw.get("malloc_view", False), "malloc_view"),
6558 "malloc_view_threshold": petsc_raw.get("malloc_view_threshold"),
6559 "memory_view": _diagnostic_bool(petsc_raw.get("memory_view", False), "memory_view"),
6560 "log_view": _diagnostic_bool_or_path(petsc_raw.get("log_view", False), "log_view"),
6561 "log_view_memory": _diagnostic_bool(petsc_raw.get("log_view_memory", False), "log_view_memory"),
6562 "log_all": _diagnostic_bool(petsc_raw.get("log_all", False), "log_all"),
6563 "log_trace": _diagnostic_bool_or_path(petsc_raw.get("log_trace", False), "log_trace"),
6564 "objects_dump": _diagnostic_bool_or_all(petsc_raw.get("objects_dump", False), "objects_dump"),
6565 "options_left": petsc_raw.get("options_left"),
6566 }
6567 if petsc["malloc_view_threshold"] is not None and not isinstance(petsc["malloc_view_threshold"], (int, float)):
6568 raise ValueError("monitor.diagnostics.petsc.malloc_view_threshold must be numeric or null.")
6569 if petsc["options_left"] is not None and not isinstance(petsc["options_left"], bool):
6570 raise ValueError("monitor.diagnostics.petsc.options_left must be boolean or null.")
6571
6572 memory_raw = diagnostics_cfg.get("runtime_memory_log", {}) or {}
6573 if not isinstance(memory_raw, dict):
6574 raise ValueError("monitor.diagnostics.runtime_memory_log must be a mapping when provided.")
6575 memory_unknown = sorted(set(memory_raw.keys()) - {"enabled", "file"})
6576 if memory_unknown:
6577 raise ValueError(f"monitor.diagnostics.runtime_memory_log has unsupported key(s): {memory_unknown}.")
6578 memory_enabled = memory_raw.get("enabled", True)
6579 if not isinstance(memory_enabled, bool):
6580 raise ValueError("monitor.diagnostics.runtime_memory_log.enabled must be boolean.")
6581 memory_file = str(memory_raw.get("file", "Runtime_Memory.log")).strip()
6582 if not memory_file:
6583 raise ValueError("monitor.diagnostics.runtime_memory_log.file must be a non-empty string.")
6584
6585 resolved_petsc = dict(petsc)
6586 artifacts = []
6587 if run_dir:
6588 suffix = "PostProcessor" if stage_label == "PostProcessor" else "Solver"
6589 defaults = {
6590 "malloc_view": f"PETSc_MallocView_{suffix}.log",
6591 "log_view": f"PETSc_LogView_{suffix}.log",
6592 "log_trace": f"PETSc_LogTrace_{suffix}.log",
6593 }
6594 for key, default_name in defaults.items():
6595 resolved_value = _diagnostic_resolve_path_or_default(petsc[key], run_dir, default_name)
6596 if key == "log_view" and resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
6597 resolved_value = f":{resolved_value}"
6598 resolved_petsc[key] = resolved_value
6599 if resolved_value and isinstance(resolved_value, str) and not resolved_value.startswith(":"):
6600 artifacts.append(resolved_value)
6601 elif resolved_value and isinstance(resolved_value, str) and resolved_value.startswith(":"):
6602 artifacts.append(resolved_value[1:])
6603 if memory_enabled:
6604 artifacts.append(os.path.abspath(os.path.join(run_dir, "logs", memory_file)))
6605
6606 return {
6607 "petsc": resolved_petsc,
6608 "runtime_memory_log": {"enabled": memory_enabled, "file": memory_file},
6609 "artifacts": artifacts,
6610 }
6611
6612
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 6613 of file core.py.

6613def build_petsc_diagnostics_args(monitor_cfg: dict, run_dir: str, stage_label: str) -> list:
6614 """!
6615 @brief Build PETSc diagnostics command-line arguments for a run stage.
6616 @param[in] monitor_cfg Parsed monitor.yml mapping.
6617 @param[in] run_dir Run directory used to resolve default diagnostics files.
6618 @param[in] stage_label Stage label for default output names.
6619 @return List of executable arguments.
6620 """
6621 diagnostics = resolve_diagnostics_config(monitor_cfg, run_dir, stage_label)
6622 petsc = diagnostics["petsc"]
6623 args = []
6624 if petsc["malloc_debug"]:
6625 args.append("-malloc_debug")
6626 if petsc["malloc_test"]:
6627 args.append("-malloc_test")
6628 for key, flag in (
6629 ("malloc_dump", "-malloc_dump"),
6630 ("malloc_view", "-malloc_view"),
6631 ("memory_view", "-memory_view"),
6632 ("log_view", "-log_view"),
6633 ("log_trace", "-log_trace"),
6634 ("objects_dump", "-objects_dump"),
6635 ):
6636 value = petsc.get(key)
6637 if value is True:
6638 args.append(flag)
6639 elif value:
6640 args.extend([flag, str(value)])
6641 if petsc["malloc_view_threshold"] is not None:
6642 args.extend(["-malloc_view_threshold", str(petsc["malloc_view_threshold"])])
6643 if petsc["log_view_memory"]:
6644 args.append("-log_view_memory")
6645 if petsc["log_all"]:
6646 args.append("-log_all")
6647 if petsc["options_left"] is not None:
6648 args.extend(["-options_left", "true" if petsc["options_left"] else "false"])
6649 return args
6650
6651
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 6652 of file core.py.

6652def prepare_monitor_files(run_dir: str, run_id: str, monitor_cfg: dict, source_files: dict) -> dict:
6653 """!
6654 @brief Generate monitor sidecar files and resolve profiling reporting behavior.
6655 @param[in] run_dir Argument passed to `prepare_monitor_files()`.
6656 @param[in] run_id Argument passed to `prepare_monitor_files()`.
6657 @param[in] monitor_cfg Argument passed to `prepare_monitor_files()`.
6658 @param[in] source_files Argument passed to `prepare_monitor_files()`.
6659 @return Value returned by `prepare_monitor_files()`.
6660 """
6661 print("[INFO] Generating monitoring files...")
6662
6663 whitelist_path = None
6664 if has_explicit_monitor_whitelist(monitor_cfg):
6665 whitelist_path = generate_simple_list_file(
6666 run_dir, run_id, monitor_cfg, "logging", "enabled_functions", "whitelist.run", source_files
6667 )
6668 else:
6669 print("[INFO] logging.enabled_functions is empty; omitting whitelist.run so the C runtime uses its default allow-list.")
6670
6671 profiling_cfg = resolve_profiling_config(monitor_cfg)
6672
6673 profile_path = None
6674 if profiling_cfg["mode"] == "selected":
6675 profile_path = generate_simple_list_file(
6676 run_dir,
6677 run_id,
6678 {"profiling": {"selected_functions": profiling_cfg["functions"]}},
6679 "profiling",
6680 "selected_functions",
6681 "profile.run",
6682 source_files,
6683 )
6684 else:
6685 print(f"[INFO] profiling.timestep_output.mode is '{profiling_cfg['mode']}'; no profile.run function list is needed.")
6686
6687 return {"whitelist": whitelist_path, "profile": profile_path, "profiling": profiling_cfg}
6688
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 6689 of file core.py.

6689def generate_multi_block_bcs(run_dir: str, run_id: str, case_cfg: dict, source_files: dict) -> list:
6690 """!
6691 @brief Parses multi-block BCs from YAML, generates a .run file for each block,
6692 and returns a list of their absolute paths.
6693 @details Handles both simple list format (for single-block cases) and a
6694 list-of-lists (for multi-block cases) for boundary conditions.
6695 @param[in] run_dir The path to the main run directory.
6696 @param[in] run_id The unique identifier for the run.
6697 @param[in] case_cfg The parsed case.yml configuration dictionary.
6698 @param[in] source_files A dictionary of source files for the header.
6699 @return A list of absolute paths to the generated BC files.
6700 @throws ValueError if the number of BC definitions does not match the number of blocks.
6701 """
6702 print("[INFO] Generating boundary condition files...")
6703 config_dir = os.path.join(run_dir, "config")
6704 num_blocks = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
6705 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
6706 case_path = source_files.get("Case") if source_files else None
6707 profile_grid_dims = None
6708 scales = case_cfg.get('properties', {}).get('scaling', {})
6709 U_ref = _to_float(scales.get('velocity_ref'), "properties.scaling.velocity_ref")
6710 if U_ref == 0.0:
6711 raise ValueError("properties.scaling.velocity_ref must be non-zero for prescribed_flow profile staging.")
6712
6713 if any(bc.get("handler") == "prescribed_flow" for block in prepared_blocks for bc in block):
6714 profile_grid_dims = resolve_grid_block_dimensions_for_profiles(case_cfg, case_path, run_dir)
6715
6716 generated_files = []
6717 generated_profile_summaries = []
6718 generated_target_grid = None
6719 field_slice_target_grid = None
6720 for i, block_bcs_list in enumerate(prepared_blocks):
6721 file_name = "bcs.run" if num_blocks == 1 else f"bcs_block{i}.run"
6722 bcs_file_path = os.path.join(config_dir, file_name)
6723 bcs_lines = [generate_header(run_id, source_files)]
6724
6725 for bc in block_bcs_list:
6726 face, bc_type, handler = bc['face'], bc['type'], bc['handler']
6727 params = dict(bc.get('params') or {})
6728 if handler == "prescribed_flow":
6729 source = params.pop("source")
6730 expected_dims = _bc_profile_expected_dims(face, profile_grid_dims[i])
6731 staged_name = f"inlet_profile_block{i}_{face.replace('+', 'pos').replace('-', 'neg')}.picslice"
6732 staged_path = os.path.join(config_dir, staged_name)
6733 if source["type"] == "file":
6734 source_path = source["path"]
6735 if case_path and not os.path.isabs(source_path):
6736 source_path = os.path.abspath(os.path.join(os.path.dirname(case_path), source_path))
6737 elif source["type"] == "generated":
6738 default_output = os.path.join(
6739 "config",
6740 f"inlet_profile_block{i}_{_face_artifact_token(face)}.generated.picslice",
6741 )
6742 source_path = _resolve_run_artifact_path(
6743 run_dir,
6744 source.get("output_file"),
6745 default_output,
6746 default_to_config_dir=True,
6747 )
6748 if os.path.abspath(source_path) == os.path.abspath(staged_path):
6749 raise ValueError(
6750 f"Generated profile output_file for block {i}, face {face} must differ from staged solver profile."
6751 )
6752 if source["generator"] == "square_duct_poiseuille":
6753 if generated_target_grid is None:
6754 generated_target_grid = resolve_target_grid_for_generated_profile(case_cfg, case_path, run_dir)
6755 summary = generate_square_duct_poiseuille_picslice(
6756 source_path,
6757 expected_dims,
6758 source["params"],
6759 target_grid=generated_target_grid,
6760 target_block=i,
6761 target_face=face,
6762 script=source.get("script"),
6763 case_path=case_path,
6764 )
6765 else:
6766 raise ValueError(f"Unsupported generated profile generator '{source['generator']}'.")
6767 summary.update({"block": i, "face": face})
6768 generated_profile_summaries.append(summary)
6769 elif source["type"] == "field_slice":
6770 default_output = os.path.join(
6771 "config",
6772 f"inlet_profile_block{i}_{_face_artifact_token(face)}.sliced.picslice",
6773 )
6774 source_path = _resolve_run_artifact_path(
6775 run_dir,
6776 source.get("output_file"),
6777 default_output,
6778 default_to_config_dir=True,
6779 )
6780 if os.path.abspath(source_path) == os.path.abspath(staged_path):
6781 raise ValueError(
6782 f"field_slice output_file for block {i}, face {face} must differ from staged solver profile."
6783 )
6784 if field_slice_target_grid is None:
6785 field_slice_target_grid = resolve_target_grid_for_field_slice(case_cfg, case_path, run_dir)
6786 summary = generate_field_slice_picslice(
6787 source_path,
6788 expected_dims,
6789 source,
6790 field_slice_target_grid,
6791 face,
6792 i,
6793 case_path,
6794 )
6795 summary.update({"block": i, "face": face})
6796 generated_profile_summaries.append(summary)
6797 else:
6798 raise ValueError(f"Unsupported prescribed_flow source type '{source.get('type')}'.")
6799 summary = validate_and_nondimensionalize_picslice(source_path, staged_path, U_ref, expected_dims)
6800 print(
6801 f"[SUCCESS] Staged prescribed_flow profile for block {i}, face {face}: "
6802 f"{os.path.relpath(staged_path)} dims={summary['dims']}"
6803 )
6804 params["source_file"] = os.path.abspath(staged_path)
6805 params_str = ""
6806 if params:
6807 parts = []
6808 for k, v in params.items():
6809 if isinstance(v, bool):
6810 value_str = "true" if v else "false"
6811 else:
6812 value_str = str(v)
6813 parts.append(f"{k}={value_str}")
6814 params_str = " ".join(parts)
6815 bcs_lines.append(f"{face:<20s} {bc_type:<12s} {handler:<20s} {params_str}")
6816
6817 with open(bcs_file_path, "w") as f: f.write("\n".join(bcs_lines))
6818
6819 print(f"[SUCCESS] Generated BCs for Block {i}: {os.path.relpath(bcs_file_path)}")
6820 generated_files.append(os.path.abspath(bcs_file_path))
6821
6822 if generated_profile_summaries:
6823 info_path = write_profile_info(config_dir, generated_profile_summaries)
6824 print(f"[SUCCESS] Wrote generated profile summary: {os.path.relpath(info_path)}")
6825
6826 return generated_files
6827
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 6828 of file core.py.

6828def format_flag_value(value):
6829 """!
6830 @brief Converts Python types to C-style command-line flag values.
6831 @param[in] value The Python object to convert (bool, list, or other).
6832 @return A string representation suitable for a C command-line parser.
6833 """
6834 if isinstance(value, bool):
6835 return "1" if value else "0"
6836 if isinstance(value, list):
6837 return ",".join(map(str, value))
6838 return str(value)
6839
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 6840 of file core.py.

6840def translate_programmatic_grid_settings(grid_settings: dict) -> dict:
6841 """!
6842 @brief Return programmatic-grid settings translated to the C node-count contract.
6843 @param[in] grid_settings Argument passed to `translate_programmatic_grid_settings()`.
6844 @return Value returned by `translate_programmatic_grid_settings()`.
6845 """
6846 translated = dict(grid_settings)
6847 for dim_key in ("im", "jm", "km"):
6848 if dim_key in translated:
6849 raw_val = translated[dim_key]
6850 if not isinstance(raw_val, int) or raw_val <= 0:
6851 raise ValueError(
6852 f"grid.programmatic_settings.{dim_key} must be a positive integer cell count "
6853 f"(got {raw_val!r})."
6854 )
6855 translated[dim_key] = raw_val + 1
6856 return translated
6857
6858
Here is the caller graph for this function:

◆ validate_programmatic_ic_gen_grid_settings()

None picurv_cli.core.validate_programmatic_ic_gen_grid_settings ( dict  raw_settings)

Validate scalar programmatic grid settings needed to materialize grid.run for ic_gen.

Parameters
[in]raw_settingsprogrammatic_settings dict from case.yml.
Exceptions
ValueErrorwhen required scalar settings are missing or invalid.

Definition at line 6866 of file core.py.

6866def validate_programmatic_ic_gen_grid_settings(raw_settings: dict) -> None:
6867 """!
6868 @brief Validate scalar programmatic grid settings needed to materialize grid.run for ic_gen.
6869 @param[in] raw_settings programmatic_settings dict from case.yml.
6870 @throws ValueError when required scalar settings are missing or invalid.
6871 """
6872 if not isinstance(raw_settings, dict):
6873 raise ValueError(
6874 "grid.programmatic_settings must be a mapping for programmatic_c with generator 'ic_gen'."
6875 )
6876
6877 missing = [key for key in PROGRAMMATIC_IC_GEN_GRID_KEYS if key not in raw_settings]
6878 if missing:
6879 raise ValueError(
6880 "grid.programmatic_settings must include "
6881 f"{missing} when grid.mode is 'programmatic_c' and initial_conditions.generator is 'ic_gen'."
6882 )
6883
6884 for key in ("im", "jm", "km"):
6885 value = raw_settings[key]
6886 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
6887 raise ValueError(
6888 f"grid.programmatic_settings.{key} must be a positive scalar integer cell count "
6889 "for programmatic_c with generator 'ic_gen'."
6890 )
6891
6892 for key in ("xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs", "rxs", "rys", "rzs"):
6893 value = raw_settings[key]
6894 if isinstance(value, (list, tuple, dict, bool)):
6895 raise ValueError(
6896 f"grid.programmatic_settings.{key} must be a scalar numeric value "
6897 "for programmatic_c with generator 'ic_gen'."
6898 )
6899 try:
6900 numeric = float(value)
6901 except (TypeError, ValueError):
6902 raise ValueError(
6903 f"grid.programmatic_settings.{key} must be a scalar numeric value "
6904 "for programmatic_c with generator 'ic_gen'."
6905 )
6906 if not math.isfinite(numeric):
6907 raise ValueError(
6908 f"grid.programmatic_settings.{key} must be finite "
6909 "for programmatic_c with generator 'ic_gen'."
6910 )
6911 if key in {"rxs", "rys", "rzs"} and numeric <= 0.0:
6912 raise ValueError(
6913 f"grid.programmatic_settings.{key} must be positive "
6914 "for programmatic_c with generator 'ic_gen'."
6915 )
6916
6917
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 6918 of file core.py.

6918def generate_picgrid_from_programmatic_settings(raw_settings: dict, dest_path: str, L_ref: float) -> dict:
6919 """!
6920 @brief Generate a canonical PICGRID file from programmatic Cartesian grid settings.
6921 @details Implements the same coordinate formula as ComputeStretchedCoord in src/grid.c.
6922 im/jm/km in raw_settings are cell counts; node counts are im+1, jm+1, km+1.
6923 @param[in] raw_settings programmatic_settings dict from case.yml.
6924 @param[in] dest_path Destination PICGRID file path.
6925 @param[in] L_ref Reference length for nondimensionalization (must be non-zero).
6926 @return Summary dict: nblk, dims [(IM, JM, KM)], total_nodes.
6927 """
6928 validate_programmatic_ic_gen_grid_settings(raw_settings)
6929 if L_ref == 0.0:
6930 raise ValueError("length_ref must be non-zero for programmatic grid generation.")
6931 IM = int(raw_settings.get("im", 0)) + 1
6932 JM = int(raw_settings.get("jm", 0)) + 1
6933 KM = int(raw_settings.get("km", 0)) + 1
6934 if IM < 2 or JM < 2 or KM < 2:
6935 raise ValueError(
6936 f"programmatic_settings im/jm/km must each be >= 1 "
6937 f"(got im={IM-1}, jm={JM-1}, km={KM-1})."
6938 )
6939 x_min = float(raw_settings.get("xMins", 0.0))
6940 x_max = float(raw_settings.get("xMaxs", 1.0))
6941 y_min = float(raw_settings.get("yMins", 0.0))
6942 y_max = float(raw_settings.get("yMaxs", 1.0))
6943 z_min = float(raw_settings.get("zMins", 0.0))
6944 z_max = float(raw_settings.get("zMaxs", 1.0))
6945 rx = float(raw_settings.get("rxs", 1.0))
6946 ry = float(raw_settings.get("rys", 1.0))
6947 rz = float(raw_settings.get("rzs", 1.0))
6948
6949 def _stretched(idx, N, length, r):
6950 """!
6951 @brief Mirror of ComputeStretchedCoord from src/grid.c.
6952 @param[in] idx Node index along the axis.
6953 @param[in] N Total node count along the axis.
6954 @param[in] length Physical length of the axis.
6955 @param[in] r Geometric stretching ratio.
6956 @return Coordinate offset from the axis minimum.
6957 """
6958 frac = idx / (N - 1.0)
6959 if abs(r - 1.0) < 1.0e-9:
6960 return length * frac
6961 return length * (r ** frac - 1.0) / (r - 1.0)
6962
6963 Lx, Ly, Lz = x_max - x_min, y_max - y_min, z_max - z_min
6964 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
6965 with open(dest_path, "w") as fout:
6966 fout.write("PICGRID\n1\n")
6967 fout.write(f"{IM} {JM} {KM}\n")
6968 for k in range(KM):
6969 z = (z_min + _stretched(k, KM, Lz, rz)) / L_ref
6970 for j in range(JM):
6971 y = (y_min + _stretched(j, JM, Ly, ry)) / L_ref
6972 for i in range(IM):
6973 x = (x_min + _stretched(i, IM, Lx, rx)) / L_ref
6974 fout.write(f"{x:.8e} {y:.8e} {z:.8e}\n")
6975 total_nodes = IM * JM * KM
6976 return {"nblk": 1, "dims": [(IM, JM, KM)], "total_nodes": total_nodes}
6977
6978
Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_grid_da_processor_layout()

dict picurv_cli.core.resolve_grid_da_processor_layout ( dict  grid_cfg)

Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.

Parameters
[in]grid_cfgArgument passed to resolve_grid_da_processor_layout().
Returns
Value returned by resolve_grid_da_processor_layout().

Definition at line 6982 of file core.py.

6982def resolve_grid_da_processor_layout(grid_cfg: dict) -> dict:
6983 """!
6984 @brief Resolve optional global DMDA layout, preferring grid-level keys over legacy nested keys.
6985 @param[in] grid_cfg Argument passed to `resolve_grid_da_processor_layout()`.
6986 @return Value returned by `resolve_grid_da_processor_layout()`.
6987 """
6988 top_level = {}
6989 legacy = {}
6990
6991 for key in GRID_DA_PROCESSOR_KEYS:
6992 value = grid_cfg.get(key)
6993 if isinstance(value, (list, tuple)):
6994 raise ValueError(
6995 f"grid.{key} must be a scalar integer. "
6996 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
6997 )
6998 if value is not None:
6999 if not isinstance(value, int) or value <= 0:
7000 raise ValueError(f"grid.{key} must be a positive integer when provided (got {value}).")
7001 top_level[key] = value
7002
7003 legacy_settings = grid_cfg.get("programmatic_settings")
7004 if isinstance(legacy_settings, dict):
7005 for key in GRID_DA_PROCESSOR_KEYS:
7006 value = legacy_settings.get(key)
7007 if isinstance(value, (list, tuple)):
7008 raise ValueError(
7009 f"grid.programmatic_settings.{key} must be a scalar integer. "
7010 "Per-block MPI decomposition is not implemented on the C side; DMDA layout is global."
7011 )
7012 if value is not None:
7013 if not isinstance(value, int) or value <= 0:
7014 raise ValueError(
7015 f"grid.programmatic_settings.{key} must be a positive integer when provided (got {value})."
7016 )
7017 legacy[key] = value
7018
7019 resolved = {}
7020 for key in GRID_DA_PROCESSOR_KEYS:
7021 top_value = top_level.get(key)
7022 legacy_value = legacy.get(key)
7023 if top_value is not None and legacy_value is not None and top_value != legacy_value:
7024 raise ValueError(
7025 f"grid.{key} conflicts with legacy grid.programmatic_settings.{key}; "
7026 "define the processor layout in only one place."
7027 )
7028 if top_value is not None:
7029 resolved[key] = top_value
7030 elif legacy_value is not None:
7031 resolved[key] = legacy_value
7032
7033 return resolved
7034
7035
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 7036 of file core.py.

7036def append_grid_da_processor_layout(control_lines: list, grid_cfg: dict, num_procs: int) -> None:
7037 """!
7038 @brief Append optional global DMDA layout flags for any grid mode.
7039 @param[in] control_lines Argument passed to `append_grid_da_processor_layout()`.
7040 @param[in] grid_cfg Argument passed to `append_grid_da_processor_layout()`.
7041 @param[in] num_procs Argument passed to `append_grid_da_processor_layout()`.
7042 """
7043 layout = resolve_grid_da_processor_layout(grid_cfg)
7044 if not layout:
7045 if num_procs > 1:
7046 print("[INFO] Letting PETSc automatically determine processor layout.")
7047 return
7048
7049 if num_procs == 1:
7050 print("[INFO] Serial run, ignoring da_processors layout.")
7051 return
7052
7053 if all(layout.get(key) is not None for key in GRID_DA_PROCESSOR_KEYS):
7054 total_layout = 1
7055 for key in GRID_DA_PROCESSOR_KEYS:
7056 total_layout *= layout[key]
7057 if total_layout != num_procs:
7058 raise ValueError(f"Processor layout mismatch: product ({total_layout}) != processes ({num_procs}).")
7059 print(f"[INFO] Applying user-defined processor layout for {num_procs} processes.")
7060 else:
7061 printable = " x ".join(str(layout.get(key, "PETSC_DECIDE")) for key in GRID_DA_PROCESSOR_KEYS)
7062 print(f"[INFO] Applying partial processor layout: {printable}.")
7063
7064 for key in GRID_DA_PROCESSOR_KEYS:
7065 value = layout.get(key)
7066 if value is not None:
7067 control_lines.append(f"-{key} {value}")
7068
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 7069 of file core.py.

7069def normalize_momentum_solver_type(value: str) -> str:
7070 """!
7071 @brief Maps canonical user-facing momentum solver names to C-enum CLI values.
7072 @param[in] value Canonical momentum solver string from YAML.
7073 @return Canonical value accepted by -mom_solver_type.
7074 @throws ValueError if the input cannot be mapped.
7075 """
7076 # Only implemented YAML values belong here. Extend only with matching C enum/parser/dispatch support.
7077 if value is None:
7078 raise ValueError("momentum solver type cannot be None")
7079
7080 raw = str(value).strip()
7081 mapped = {
7082 "Explicit RK4": "EXPLICIT_RK",
7083 "Dual Time Picard Jameson RK": "DUALTIME_PICARD_JAMESON_RK",
7084 "Dual Time Picard RK4": "DUALTIME_PICARD_JAMESON_RK",
7085 "Newton Krylov": "newton_krylov",
7086 }.get(raw)
7087 if mapped is None:
7088 raise ValueError(
7089 f"Unknown momentum solver '{value}'. Use one of: "
7090 "'Explicit RK4', 'Dual Time Picard Jameson RK', 'Newton Krylov'."
7091 )
7092 return mapped
7093
7094
Here is the caller graph for this function:

◆ validate_newton_krylov_config()

dict picurv_cli.core.validate_newton_krylov_config ( dict  cfg)

Validate and normalize the structured Newton–Krylov solver block.

Parameters
[in]cfgStructured momentum_solver.newton_krylov mapping.
Returns
Normalized copy containing only supported structured fields.

Definition at line 7095 of file core.py.

7095def validate_newton_krylov_config(cfg: dict) -> dict:
7096 """!
7097 @brief Validate and normalize the structured Newton--Krylov solver block.
7098 @param[in] cfg Structured `momentum_solver.newton_krylov` mapping.
7099 @return Normalized copy containing only supported structured fields.
7100 """
7101 root = "momentum_solver.newton_krylov"
7102 if not isinstance(cfg, dict):
7103 raise ValueError(f"{root} must be a mapping.")
7104
7105 unknown = sorted(set(cfg) - {"nonlinear_solver", "linear_solver"})
7106 if unknown:
7107 raise ValueError(f"{root} has unsupported key(s): {unknown}.")
7108
7109 normalized = {}
7110
7111 def _mapping(parent: dict, key: str, path: str) -> dict:
7112 """!
7113 @brief Read and validate one optional nested Newton mapping.
7114 @param[in] parent Parent mapping.
7115 @param[in] key Nested key to read.
7116 @param[in] path User-facing YAML path for errors.
7117 @return Nested mapping, or an empty mapping when omitted.
7118 """
7119 value = parent.get(key, {})
7120 if value is None or not isinstance(value, dict):
7121 raise ValueError(f"{path} must be a mapping when provided.")
7122 return value
7123
7124 def _method(value, path: str) -> str:
7125 """!
7126 @brief Normalize one nonempty PETSc solver/type token.
7127 @param[in] value YAML token value.
7128 @param[in] path User-facing YAML path for errors.
7129 @return Lowercase PETSc token.
7130 """
7131 if not isinstance(value, str) or not value.strip():
7132 raise ValueError(f"{path} must be a non-empty string.")
7133 return value.strip().lower()
7134
7135 def _tolerance(value, path: str):
7136 """!
7137 @brief Validate one finite nonnegative tolerance.
7138 @param[in] value YAML tolerance value.
7139 @param[in] path User-facing YAML path for errors.
7140 @return Original validated value.
7141 """
7142 if isinstance(value, bool):
7143 raise ValueError(f"{path} must be numeric and nonnegative.")
7144 try:
7145 numeric = float(value)
7146 except (TypeError, ValueError) as exc:
7147 raise ValueError(f"{path} must be numeric and nonnegative.") from exc
7148 if not math.isfinite(numeric) or numeric < 0.0:
7149 raise ValueError(f"{path} must be numeric and nonnegative.")
7150 return value
7151
7152 def _positive_integer(value, path: str):
7153 """!
7154 @brief Validate one positive integer count.
7155 @param[in] value YAML count value.
7156 @param[in] path User-facing YAML path for errors.
7157 @return Original validated integer.
7158 """
7159 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
7160 raise ValueError(f"{path} must be a positive integer.")
7161 return value
7162
7163 nonlinear = _mapping(cfg, "nonlinear_solver", f"{root}.nonlinear_solver")
7164 nonlinear_path = f"{root}.nonlinear_solver"
7165 unknown = sorted(set(nonlinear) - {
7166 "method", "absolute_tolerance", "relative_tolerance", "step_tolerance",
7167 "max_iterations", "line_search",
7168 })
7169 if unknown:
7170 raise ValueError(f"{nonlinear_path} has unsupported key(s): {unknown}.")
7171 nonlinear_out = {}
7172 if "method" in nonlinear:
7173 nonlinear_out["method"] = _method(nonlinear["method"], f"{nonlinear_path}.method")
7174 for key in ("absolute_tolerance", "relative_tolerance", "step_tolerance"):
7175 if key in nonlinear:
7176 nonlinear_out[key] = _tolerance(nonlinear[key], f"{nonlinear_path}.{key}")
7177 if "max_iterations" in nonlinear:
7178 nonlinear_out["max_iterations"] = _positive_integer(
7179 nonlinear["max_iterations"], f"{nonlinear_path}.max_iterations"
7180 )
7181 if "line_search" in nonlinear:
7182 line_search = _mapping(nonlinear, "line_search", f"{nonlinear_path}.line_search")
7183 unknown = sorted(set(line_search) - {"type"})
7184 if unknown:
7185 raise ValueError(f"{nonlinear_path}.line_search has unsupported key(s): {unknown}.")
7186 nonlinear_out["line_search"] = {}
7187 if "type" in line_search:
7188 nonlinear_out["line_search"]["type"] = _method(
7189 line_search["type"], f"{nonlinear_path}.line_search.type"
7190 )
7191 normalized["nonlinear_solver"] = nonlinear_out
7192
7193 linear = _mapping(cfg, "linear_solver", f"{root}.linear_solver")
7194 linear_path = f"{root}.linear_solver"
7195 unknown = sorted(set(linear) - {
7196 "method", "absolute_tolerance", "relative_tolerance", "max_iterations",
7197 "gmres", "preconditioner",
7198 })
7199 if unknown:
7200 raise ValueError(f"{linear_path} has unsupported key(s): {unknown}.")
7201 linear_out = {}
7202 method = "gmres"
7203 if "method" in linear:
7204 method = _method(linear["method"], f"{linear_path}.method")
7205 linear_out["method"] = method
7206 for key in ("absolute_tolerance", "relative_tolerance"):
7207 if key in linear:
7208 linear_out[key] = _tolerance(linear[key], f"{linear_path}.{key}")
7209 if "max_iterations" in linear:
7210 linear_out["max_iterations"] = _positive_integer(
7211 linear["max_iterations"], f"{linear_path}.max_iterations"
7212 )
7213 if "gmres" in linear:
7214 gmres = _mapping(linear, "gmres", f"{linear_path}.gmres")
7215 unknown = sorted(set(gmres) - {"restart"})
7216 if unknown:
7217 raise ValueError(f"{linear_path}.gmres has unsupported key(s): {unknown}.")
7218 linear_out["gmres"] = {}
7219 if "restart" in gmres:
7220 if method not in {"gmres", "fgmres", "lgmres"}:
7221 raise ValueError(
7222 f"{linear_path}.gmres.restart is valid only when {linear_path}.method "
7223 "is one of 'gmres', 'fgmres', or 'lgmres'."
7224 )
7225 linear_out["gmres"]["restart"] = _positive_integer(
7226 gmres["restart"], f"{linear_path}.gmres.restart"
7227 )
7228 if "preconditioner" in linear:
7229 preconditioner = _mapping(linear, "preconditioner", f"{linear_path}.preconditioner")
7230 unknown = sorted(set(preconditioner) - {"type"})
7231 if unknown:
7232 raise ValueError(f"{linear_path}.preconditioner has unsupported key(s): {unknown}.")
7233 linear_out["preconditioner"] = {}
7234 if "type" in preconditioner:
7235 pc_type = _method(preconditioner["type"], f"{linear_path}.preconditioner.type")
7236 if pc_type != "none":
7237 raise ValueError(
7238 f"{linear_path}.preconditioner.type currently supports only 'none'."
7239 )
7240 linear_out["preconditioner"]["type"] = pc_type
7241 normalized["linear_solver"] = linear_out
7242 return normalized
7243
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 7244 of file core.py.

7244def normalize_solution_convergence_mode(value: str) -> str:
7245 """!
7246 @brief Normalizes the solution-convergence mode selector to the C-side canonical string.
7247 @param[in] value Human-readable solution-convergence mode selector.
7248 @return Canonical string accepted by `-solution_convergence_mode`.
7249 @throws ValueError if the input cannot be mapped.
7250 """
7251 if value is None:
7252 raise ValueError("solution_convergence.mode cannot be None")
7253
7254 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7255 aliases = {
7256 "steady_deterministic": "STEADY_DETERMINISTIC",
7257 "periodic_deterministic": "PERIODIC_DETERMINISTIC",
7258 "statistical_steady": "STATISTICAL_STEADY",
7259 "transient": "TRANSIENT",
7260 }
7261 mapped = aliases.get(normalized)
7262 if mapped is None:
7263 raise ValueError(
7264 f"Unknown solution_convergence.mode '{value}'. Use one of: "
7265 "'steady_deterministic', 'periodic_deterministic', 'statistical_steady', 'transient'."
7266 )
7267 return mapped
7268
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 7269 of file core.py.

7269def normalize_field_init_mode(value: str) -> int:
7270 """!
7271 @brief Maps canonical field init mode names to C enum/int codes (-finit).
7272 @param[in] value Canonical field initialization mode.
7273 @return Canonical integer code accepted by -finit.
7274 @throws ValueError if the input cannot be mapped.
7275 """
7276 # Canonical selector map for field initialization modes.
7277 if value is None:
7278 raise ValueError("field initialization mode cannot be None")
7279
7280 mapped = {
7281 "Zero": 0,
7282 "Constant": 1,
7283 "Poiseuille": 2,
7284 }.get(str(value).strip())
7285 if mapped is None:
7286 raise ValueError(
7287 f"Unknown initial_conditions mode '{value}'. Use one of: 'Zero', 'Constant', 'Poiseuille'."
7288 )
7289 return mapped
7290
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 7291 of file core.py.

7291def normalize_initial_condition_field(value: str) -> "tuple[str, int]":
7292 """!
7293 @brief Normalize a file IC field selector to its staged basename and C enum value.
7294 @param[in] value User-facing Ucat or Ucont selector.
7295 @return Tuple of staged field basename and C enum value.
7296 """
7297 normalized = str(value or "").strip().lower()
7298 if normalized == "ucat":
7299 return "ufield", 0
7300 if normalized == "ucont":
7301 return "vfield", 1
7302 raise ValueError("initial_conditions.field must be 'Ucat' or 'Ucont'.")
7303
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 7304 of file core.py.

7304def resolve_initial_condition_config(ic: dict, prepared_blocks, U_ref: float) -> dict:
7305 """!
7306 @brief Resolve legacy and structured initial-condition YAML into one launcher contract.
7307 @param[in] ic Initial-condition YAML mapping.
7308 @param[in] prepared_blocks Normalized boundary-condition blocks.
7309 @param[in] U_ref Physical reference velocity.
7310 @return Normalized launcher initial-condition contract.
7311 """
7312 if not isinstance(ic, dict):
7313 raise ValueError("properties.initial_conditions must be a mapping.")
7314 mode = str(ic.get("mode", "")).strip()
7315
7316 # Backward-compatible legacy spelling.
7317 if mode in {"Zero", "Constant", "Poiseuille"}:
7318 finit_code = normalize_field_init_mode(mode)
7319 params = resolve_ic_cli_params(ic, finit_code, prepared_blocks, U_ref)
7320 if finit_code == 1 and params.pop("ic_coordinate_system", 0) == 1:
7321 finit_code = 3
7322 return {"finit": finit_code, "cli_params": params, "kind": "builtin", "label": mode}
7323
7324 normalized_mode = mode.lower().replace("-", "_").replace(" ", "_")
7325 if normalized_mode == "file":
7326 if prepared_blocks and len(prepared_blocks) > 1:
7327 raise ValueError("File-backed initial conditions currently support single-block cases only.")
7328 source_file = ic.get("source_file")
7329 if not isinstance(source_file, str) or not source_file.strip():
7330 raise ValueError("initial_conditions.source_file is required when mode is 'file'.")
7331 field_name, field_code = normalize_initial_condition_field(ic.get("field"))
7332 return {
7333 "finit": 4, "cli_params": {}, "kind": "file", "label": "file",
7334 "source_file": source_file.strip(), "field_name": field_name, "field_code": field_code,
7335 }
7336 if normalized_mode != "generated":
7337 raise ValueError("initial_conditions.mode must be 'generated' or 'file'.")
7338
7339 generator = str(ic.get("generator", "")).strip().lower().replace("-", "_").replace(" ", "_")
7340 params = ic.get("params", {})
7341 if not isinstance(params, dict):
7342 raise ValueError("initial_conditions.params must be a mapping.")
7343 if generator == "ic_gen":
7344 if prepared_blocks and len(prepared_blocks) > 1:
7345 raise ValueError("File-backed initial conditions currently support single-block cases only.")
7346 script = params.get("script")
7347 if script is not None and (not isinstance(script, str) or not script.strip()):
7348 raise ValueError("initial_conditions.params.script must be a non-empty path when provided.")
7349 field_name, field_code = normalize_initial_condition_field(params.get("field"))
7350 config_file = params.get("config_file")
7351 if not isinstance(config_file, str) or not config_file.strip():
7352 raise ValueError("initial_conditions.params.config_file is required for generator 'ic_gen'.")
7353 cli_args = params.get("cli_args", [])
7354 if cli_args is None:
7355 cli_args = []
7356 if not isinstance(cli_args, list):
7357 raise ValueError("initial_conditions.params.cli_args must be a list.")
7358 return {
7359 "finit": 4, "cli_params": {}, "kind": "ic_gen", "label": "ic_gen",
7360 "field_name": field_name, "field_code": field_code,
7361 "config_file": config_file.strip(),
7362 "script": script.strip() if script is not None else None,
7363 "output_file": params.get("output_file"),
7364 "cli_args": cli_args,
7365 }
7366
7367 generator_modes = {
7368 "zero": (0, "Zero"),
7369 "constant": (1, "Constant"),
7370 "streamwise_constant": (3, "Constant"),
7371 "poiseuille": (2, "Poiseuille"),
7372 }
7373 if generator not in generator_modes:
7374 raise ValueError(
7375 "initial_conditions.generator must be one of: zero, constant, "
7376 "streamwise_constant, poiseuille, ic_gen."
7377 )
7378 finit_code, legacy_mode = generator_modes[generator]
7379 legacy_ic = dict(params)
7380 legacy_ic["mode"] = legacy_mode
7381 cli_params = resolve_ic_cli_params(
7382 legacy_ic,
7383 1 if finit_code == 3 else finit_code,
7384 prepared_blocks,
7385 U_ref,
7386 )
7387 cli_params.pop("ic_coordinate_system", None)
7388 return {"finit": finit_code, "cli_params": cli_params, "kind": "builtin", "label": generator}
7389
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 7390 of file core.py.

7390def validate_petsc_vec_binary(path: str) -> dict:
7391 """!
7392 @brief Validate the basic PETSc binary VecView envelope used by ReadFieldData.
7393 @param[in] path PETSc binary vector path.
7394 @return Summary containing the absolute path and scalar count.
7395 """
7396 import struct
7397 with open(path, "rb") as fin:
7398 header = fin.read(8)
7399 if len(header) != 8:
7400 raise ValueError(f"PETSc Vec file is too short: {path}")
7401 class_id, scalar_count = struct.unpack(">ii", header)
7402 if class_id != 1211214 or scalar_count < 0:
7403 raise ValueError(f"Invalid PETSc Vec header in {path}.")
7404 payload = fin.read()
7405 if len(payload) != scalar_count * 8:
7406 raise ValueError(
7407 f"PETSc Vec payload size mismatch in {path}: expected {scalar_count * 8} bytes, found {len(payload)}."
7408 )
7409 return {"path": os.path.abspath(path), "scalar_count": scalar_count}
7410
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 7411 of file core.py.

7411def run_initial_condition_generator(case_path: str, run_dir: str, resolved_ic: dict) -> str:
7412 """!
7413 @brief Run the repository IC generator.
7414 @param[in] case_path Source case YAML path.
7415 @param[in] run_dir Run or precompute output directory.
7416 @param[in] resolved_ic Normalized external-generator contract.
7417 @return Generated PETSc vector path.
7418 """
7419 case_dir = os.path.dirname(os.path.abspath(case_path))
7420 script = _resolve_generator_script(resolved_ic.get("script"), case_path, "ic.gen")
7421 config_file = resolved_ic["config_file"]
7422 config_file = config_file if os.path.isabs(config_file) else os.path.abspath(os.path.join(case_dir, config_file))
7423 if not os.path.isfile(script):
7424 raise ValueError(f"ic.gen script not found: {script}")
7425 if not os.path.isfile(config_file):
7426 raise ValueError(f"initial-condition generator config file not found: {config_file}")
7427 default_output = os.path.join("config", "initial_condition.generated.dat")
7428 output_path = _resolve_run_artifact_path(
7429 run_dir, resolved_ic.get("output_file"), default_output, default_to_config_dir=True
7430 )
7431 os.makedirs(os.path.dirname(output_path), exist_ok=True)
7432 cmd = [sys.executable, script, "-c", config_file, "--field",
7433 "Ucat" if resolved_ic["field_code"] == 0 else "Ucont",
7434 "--output", output_path]
7435 staged_grid = os.path.join(run_dir, "config", "grid.run")
7436 if os.path.isfile(staged_grid):
7437 cmd.extend(["--grid", staged_grid])
7438 cmd.extend(str(token) for token in resolved_ic.get("cli_args", []))
7439 result = subprocess.run(cmd, cwd=case_dir, text=True, capture_output=True)
7440 if result.returncode != 0:
7441 details = (result.stderr or result.stdout or "").strip()
7442 raise ValueError(f"ic.gen failed with exit code {result.returncode}. Details:\n{details}")
7443 validate_petsc_vec_binary(output_path)
7444 return output_path
7445
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 7446 of file core.py.

7446def stage_initial_condition_file(run_dir: str, case_path: str, resolved_ic: dict) -> dict:
7447 """!
7448 @brief Materialize and stage one file-backed IC in ReadFieldData's expected layout.
7449 @param[in] run_dir Run or precompute output directory.
7450 @param[in] case_path Source case YAML path.
7451 @param[in] resolved_ic Normalized file-backed IC contract.
7452 @return Source, staged path, and staging-directory summary.
7453 """
7454 if resolved_ic["kind"] == "ic_gen":
7455 source_path = run_initial_condition_generator(case_path, run_dir, resolved_ic)
7456 else:
7457 source_path = resolved_ic["source_file"]
7458 if not os.path.isabs(source_path):
7459 source_path = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(case_path)), source_path))
7460 if not os.path.isfile(source_path):
7461 raise ValueError(f"Initial-condition source file not found: {source_path}")
7462 validate_petsc_vec_binary(source_path)
7463 stage_dir = os.path.join(run_dir, "config", "initial_condition")
7464 os.makedirs(stage_dir, exist_ok=True)
7465 staged_path = os.path.join(stage_dir, f"{resolved_ic['field_name']}00000_0.dat")
7466 if os.path.abspath(source_path) != os.path.abspath(staged_path):
7467 shutil.copy2(source_path, staged_path)
7468 return {"source": os.path.abspath(source_path), "staged": os.path.abspath(staged_path), "directory": os.path.abspath(stage_dir)}
7469
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 7470 of file core.py.

7470def normalize_flow_direction_token(value: str) -> int:
7471 """!
7472 @brief Maps a face-token flow direction string to the C FlowDirection enum integer.
7473 @param[in] value One of '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'.
7474 @return Integer 0-5 matching the FlowDirection enum.
7475 @throws ValueError on unknown value.
7476 """
7477 mapped = {
7478 "+Xi": 0, "-Xi": 1,
7479 "+Eta": 2, "-Eta": 3,
7480 "+Zeta": 4, "-Zeta": 5,
7481 }.get(str(value).strip())
7482 if mapped is None:
7483 raise ValueError(
7484 f"Unknown initial_conditions.flow_direction '{value}'. "
7485 "Use one of: '+Xi', '-Xi', '+Eta', '-Eta', '+Zeta', '-Zeta'."
7486 )
7487 return mapped
7488
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 7489 of file core.py.

7489def _ic_has_inlet(prepared_blocks) -> bool:
7490 """!
7491 @brief Return True if any prepared BC block contains an INLET face.
7492 @param[in] prepared_blocks List of prepared BC lists (one per domain block).
7493 @return True if at least one INLET entry exists across all blocks.
7494 """
7495 if not prepared_blocks:
7496 return False
7497 for block_bcs in prepared_blocks:
7498 for entry in block_bcs:
7499 if entry.get("type") == "INLET":
7500 return True
7501 return False
7502
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 7503 of file core.py.

7503def resolve_ic_cli_params(ic: dict, finit_code: int, prepared_blocks, U_ref: float) -> dict:
7504 """!
7505 @brief Resolve all IC parameters and return a dict of PETSc option values.
7506 @param[in] ic The properties.initial_conditions mapping.
7507 @param[in] finit_code Normalized -finit integer code.
7508 @param[in] prepared_blocks Normalized BC blocks (may be None).
7509 @param[in] U_ref Reference velocity for non-dimensionalization.
7510 @return Dict with keys matching PETSc option names (without leading dash).
7511 @throws KeyError if a required key is absent.
7512 @throws ValueError on invalid combinations or values.
7513 """
7514 result = {}
7515
7516 if finit_code == 0:
7517 return result
7518
7519 if finit_code == 1: # Constant
7520 has_cartesian = any(k in ic for k in ("u_physical", "v_physical", "w_physical"))
7521 has_curvilinear = "velocity_physical" in ic
7522
7523 if has_cartesian and has_curvilinear:
7524 raise ValueError(
7525 "initial_conditions: cannot mix u/v/w_physical (cartesian) and "
7526 "velocity_physical (curvilinear) — use one or the other."
7527 )
7528
7529 if has_curvilinear: # curvilinear: scalar speed along flow_direction axis
7530 cs_code = 1
7531 result["ic_coordinate_system"] = cs_code
7532 try:
7533 vel_phys = float(ic["velocity_physical"])
7534 except (TypeError, ValueError) as exc:
7535 raise ValueError(
7536 f"Invalid value for initial_conditions.velocity_physical: {ic['velocity_physical']!r}. "
7537 "Expected a numeric value."
7538 ) from exc
7539 result["ic_velocity_physical"] = vel_phys / U_ref if U_ref != 0 else 0.0
7540
7541 if "flow_direction" in ic:
7542 result["flow_direction"] = normalize_flow_direction_token(ic["flow_direction"])
7543 elif not _ic_has_inlet(prepared_blocks):
7544 raise ValueError(
7545 "initial_conditions.flow_direction is required for curvilinear Constant IC "
7546 "when no INLET face exists."
7547 )
7548
7549 else: # cartesian: u/v/w_physical → Cart2Contra (default when no velocity_physical)
7550 if "flow_direction" in ic:
7551 raise ValueError(
7552 "initial_conditions.flow_direction is not valid for cartesian Constant IC. "
7553 "Use velocity_physical + flow_direction for curvilinear mode."
7554 )
7555 cs_code = 0
7556 result["ic_coordinate_system"] = cs_code
7557 u, v, w = parse_initial_velocity_components(ic, finit_code, require_explicit=True)
7558 scale = 1.0 / U_ref if U_ref != 0 else 0.0
7559 result["ucont_x"] = u * scale
7560 result["ucont_y"] = v * scale
7561 result["ucont_z"] = w * scale
7562
7563 elif finit_code == 2: # Poiseuille
7564 if any(k in ic for k in ("u_physical", "v_physical", "w_physical")):
7565 raise ValueError(
7566 "For Poiseuille mode, use peak_velocity_physical, not u_physical/v_physical/w_physical."
7567 )
7568 if "velocity_physical" in ic:
7569 raise ValueError(
7570 "For Poiseuille mode, use peak_velocity_physical, not velocity_physical."
7571 )
7572 if "peak_velocity_physical" not in ic:
7573 raise KeyError("peak_velocity_physical")
7574 try:
7575 peak = float(ic["peak_velocity_physical"])
7576 except (TypeError, ValueError) as exc:
7577 raise ValueError(
7578 f"Invalid value for initial_conditions.peak_velocity_physical: "
7579 f"{ic['peak_velocity_physical']!r}. Expected a numeric value."
7580 ) from exc
7581 result["ic_velocity_physical"] = peak / U_ref if U_ref != 0 else 0.0
7582
7583 if "flow_direction" in ic:
7584 fd_int = normalize_flow_direction_token(ic["flow_direction"])
7585 # Cross-check: explicit flow_direction must align with INLET face if one exists
7586 if _ic_has_inlet(prepared_blocks):
7587 inlet_axis = infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks)
7588 fd_axis_name = {0: "x", 1: "y", 2: "z"}.get(fd_int // 2, "?")
7589 if inlet_axis and fd_axis_name != inlet_axis:
7590 token = ic["flow_direction"]
7591 raise ValueError(
7592 f"initial_conditions.flow_direction '{token}' (axis '{fd_axis_name}') "
7593 f"does not match INLET face axis '{inlet_axis}'."
7594 )
7595 result["flow_direction"] = fd_int
7596 elif not _ic_has_inlet(prepared_blocks):
7597 raise ValueError(
7598 "initial_conditions.flow_direction is required for Poiseuille IC "
7599 "when no INLET face exists."
7600 )
7601
7602 return result
7603
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 7604 of file core.py.

7604def normalize_eulerian_field_source(value: str) -> str:
7605 """!
7606 @brief Normalizes the Eulerian field source selector to the C-side canonical string.
7607 @param[in] value Human-readable or enum-like Eulerian field source.
7608 @return Canonical string accepted by `-euler_field_source`.
7609 @throws ValueError if the input cannot be mapped.
7610 """
7611 if value is None:
7612 raise ValueError("eulerian_field_source cannot be None")
7613
7614 normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7615 aliases = {
7616 "solve": "solve",
7617 "load": "load",
7618 "analytical": "analytical",
7619 }
7620 mapped = aliases.get(normalized)
7621 if mapped is None:
7622 raise ValueError(
7623 f"Unknown operation_mode.eulerian_field_source '{value}'. "
7624 "Use one of: 'solve', 'load', 'analytical'."
7625 )
7626 return mapped
7627
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 7628 of file core.py.

7628def normalize_analytical_type(value: str) -> str:
7629 """!
7630 @brief Normalizes the analytical solution selector to the C-side canonical string.
7631 @param[in] value Human-readable analytical solution selector.
7632 @return Canonical string accepted by `-analytical_type`.
7633 @throws ValueError if the input cannot be mapped.
7634 """
7635 # Canonical selector map for analytical solution selectors.
7636 if value is None:
7637 raise ValueError("analytical_type cannot be None")
7638
7639 normalized = str(value).strip().upper().replace("-", "_").replace(" ", "_")
7640 if normalized not in {"TGV3D", "ZERO_FLOW", "UNIFORM_FLOW"}:
7641 raise ValueError(
7642 f"Unknown operation_mode.analytical_type '{value}'. "
7643 "Use one of: 'TGV3D', 'ZERO_FLOW', 'UNIFORM_FLOW'."
7644 )
7645 return normalized
7646
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 7647 of file core.py.

7647def parse_initial_velocity_components(initial_conditions: dict, finit_code: int, *, require_explicit: bool) -> "tuple[float, float, float]":
7648 """!
7649 @brief Parse initial-condition velocity components with mode-aware defaults.
7650 @param[in] initial_conditions The `properties.initial_conditions` mapping from case.yml.
7651 @param[in] finit_code Normalized `-finit` integer code.
7652 @param[in] require_explicit If True, all three component keys must be present.
7653 @return Tuple `(u, v, w)` in physical units.
7654 @throws KeyError if a required component key is missing.
7655 @throws ValueError if a component cannot be converted to float.
7656 """
7657 component_keys = ("u_physical", "v_physical", "w_physical")
7658 components = []
7659 for key in component_keys:
7660 if key not in initial_conditions:
7661 if require_explicit:
7662 raise KeyError(key)
7663 raw_value = 0.0
7664 else:
7665 raw_value = initial_conditions[key]
7666 try:
7667 components.append(float(raw_value))
7668 except (TypeError, ValueError) as exc:
7669 raise ValueError(
7670 f"Invalid value for properties.initial_conditions.{key}: {raw_value!r}. Expected a numeric value."
7671 ) from exc
7672 return tuple(components)
7673
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 7674 of file core.py.

7674def infer_unique_inlet_axis_from_prepared_bcs(prepared_blocks: list) -> "str | None":
7675 """!
7676 @brief Infer the unique inlet axis across all blocks using C-side "primary inlet" ordering.
7677 @param[in] prepared_blocks Normalized BC blocks from `validate_and_prepare_boundary_conditions`.
7678 @return One of `"x"`, `"y"`, `"z"` if unique, `None` if no inlet exists.
7679 @throws ValueError if different blocks imply different inlet axes.
7680 """
7681 face_order = ("-Xi", "+Xi", "-Eta", "+Eta", "-Zeta", "+Zeta")
7682 face_axis = {
7683 "-Xi": "x", "+Xi": "x",
7684 "-Eta": "y", "+Eta": "y",
7685 "-Zeta": "z", "+Zeta": "z",
7686 }
7687
7688 inlet_axes = set()
7689 for block_bcs in prepared_blocks:
7690 face_map = {entry["face"]: entry for entry in block_bcs}
7691 for face in face_order:
7692 entry = face_map.get(face)
7693 if entry and entry["type"] == "INLET":
7694 inlet_axes.add(face_axis[face])
7695 break
7696
7697 if not inlet_axes:
7698 return None
7699 if len(inlet_axes) != 1:
7700 raise ValueError(
7701 "properties.initial_conditions.peak_velocity_physical requires all blocks to have a primary INLET "
7702 f"on the same axis. Found axes: {sorted(inlet_axes)}. Use u_physical/v_physical/w_physical instead."
7703 )
7704 return next(iter(inlet_axes))
7705
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 7706 of file core.py.

7706def normalize_particle_init_mode(value: str) -> int:
7707 """!
7708 @brief Maps canonical particle init mode names to C enum/int codes (-pinit).
7709 @param[in] value Canonical particle initialization mode.
7710 @return Canonical integer code accepted by -pinit.
7711 @throws ValueError if the input cannot be mapped.
7712 """
7713 # Canonical selector map for particle initialization modes.
7714 if value is None:
7715 raise ValueError("particle init mode cannot be None")
7716
7717 mapped = {
7718 "Surface": 0,
7719 "Volume": 1,
7720 "PointSource": 2,
7721 "SurfaceEdges": 3,
7722 }.get(str(value).strip())
7723 if mapped is None:
7724 raise ValueError(
7725 f"Unknown particle init_mode '{value}'. Use one of: "
7726 "'Surface', 'Volume', 'PointSource', 'SurfaceEdges'."
7727 )
7728 return mapped
7729
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 7730 of file core.py.

7730def normalize_interpolation_method(value: str) -> int:
7731 """!
7732 @brief Maps interpolation method names to C enum/int codes (-interpolation_method).
7733 @param[in] value Canonical interpolation method name.
7734 @return Integer code accepted by -interpolation_method.
7735 @throws ValueError if the input cannot be mapped.
7736 """
7737 if value is None:
7738 raise ValueError("interpolation method cannot be None")
7739
7740 mapped = {
7741 "Trilinear": 0,
7742 "CornerAveraged": 1,
7743 }.get(str(value).strip())
7744 if mapped is None:
7745 raise ValueError(
7746 f"Unknown interpolation_method '{value}'. Use one of: "
7747 "'Trilinear', 'CornerAveraged'."
7748 )
7749 return mapped
7750
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 7751 of file core.py.

7751def normalize_les_model(value) -> int:
7752 """!
7753 @brief Maps LES model selectors to C enum/int codes (-les).
7754 @param[in] value LES selector name or legacy integer/bool value.
7755 @return Integer code accepted by -les.
7756 @throws ValueError if the input cannot be mapped.
7757 """
7758 if isinstance(value, bool):
7759 return 1 if value else 0
7760 if isinstance(value, int):
7761 if value in (0, 1, 2):
7762 return value
7763 raise ValueError("models.physics.turbulence.les must be 0, 1, 2, false/true, or a supported model block.")
7764 if value is None:
7765 raise ValueError("LES model cannot be None")
7766
7767 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7768 mapped = {
7769 "none": 0,
7770 "off": 0,
7771 "disabled": 0,
7772 "no_les": 0,
7773 "constant": 1,
7774 "constant_smagorinsky": 1,
7775 "smagorinsky": 1,
7776 "dynamic": 2,
7777 "dynamic_smagorinsky": 2,
7778 }.get(key)
7779 if mapped is None:
7780 raise ValueError(
7781 f"Unknown LES model '{value}'. Use one of: 'none', "
7782 "'constant_smagorinsky', 'dynamic_smagorinsky'."
7783 )
7784 return mapped
7785
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 7786 of file core.py.

7786def normalize_les_test_filter(value) -> int:
7787 """!
7788 @brief Maps LES test-filter names to the C -testfilter_ik flag.
7789 @param[in] value Test-filter selector name or legacy integer/bool value.
7790 @return 0 for volume-weighted box, 1 for homogeneous i/k Simpson filtering.
7791 @throws ValueError if the input cannot be mapped.
7792 """
7793 if isinstance(value, bool):
7794 return 1 if value else 0
7795 if isinstance(value, int):
7796 if value in (0, 1):
7797 return value
7798 raise ValueError("models.physics.turbulence.les.test_filter must be 0, 1, or a supported filter name.")
7799 if value is None:
7800 raise ValueError("LES test_filter cannot be None")
7801
7802 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7803 mapped = {
7804 "volume_weighted_box": 0,
7805 "box": 0,
7806 "general_box": 0,
7807 "homogeneous_ik": 1,
7808 "ik_homogeneous": 1,
7809 "simpson_ik": 1,
7810 }.get(key)
7811 if mapped is None:
7812 raise ValueError(
7813 f"Unknown LES test_filter '{value}'. Use one of: "
7814 "'volume_weighted_box', 'homogeneous_ik'."
7815 )
7816 return mapped
7817

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

7818def normalize_rans_model(value) -> int:
7819 """!
7820 @brief Maps RANS model selectors to the current C -rans switch.
7821 @param[in] value RANS selector name or legacy integer/bool value.
7822 @return Integer code accepted by -rans.
7823 @throws ValueError if the input cannot be mapped.
7824 """
7825 if isinstance(value, bool):
7826 return 1 if value else 0
7827 if isinstance(value, int):
7828 if value in (0, 1):
7829 return value
7830 raise ValueError("models.physics.turbulence.rans must be 0, 1, false/true, or a supported model block.")
7831 if value is None:
7832 raise ValueError("RANS model cannot be None")
7833
7834 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7835 mapped = {
7836 "none": 0,
7837 "off": 0,
7838 "disabled": 0,
7839 "k_omega": 1,
7840 "komega": 1,
7841 }.get(key)
7842 if mapped is None:
7843 raise ValueError(f"Unknown RANS model '{value}'. Use one of: 'none', 'k_omega'.")
7844 return mapped
7845
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 7846 of file core.py.

7846def normalize_wall_function_model(value) -> str:
7847 """!
7848 @brief Validates wall-function model selectors exposed in YAML.
7849 @param[in] value Wall-function selector name.
7850 @return Canonical wall-function model name.
7851 @throws ValueError if the input cannot be mapped.
7852 """
7853 if value is None:
7854 return "log_law"
7855 key = str(value).strip().lower().replace("-", "_").replace(" ", "_")
7856 if key in {"log_law", "loglaw"}:
7857 return "log_law"
7858 raise ValueError("Unknown wall_function model '%s'. Use: 'log_law'." % value)
7859
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 7860 of file core.py.

7860def resolve_enabled_flag(cfg: dict, path: str, default: bool = True) -> bool:
7861 """!
7862 @brief Resolves a structured `enabled` flag and rejects non-boolean values.
7863 @param[in] cfg Mapping that may contain `enabled`.
7864 @param[in] path Human-readable config path for diagnostics.
7865 @param[in] default Value used when `enabled` is omitted.
7866 @return Boolean enabled state.
7867 @throws ValueError if `enabled` is not a YAML boolean.
7868 """
7869 if 'enabled' not in cfg:
7870 return default
7871 if not isinstance(cfg['enabled'], bool):
7872 raise ValueError(f"{path}.enabled must be true or false.")
7873 return cfg['enabled']
7874
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 7875 of file core.py.

7875def append_turbulence_flags(models: dict, control_lines: list):
7876 """!
7877 @brief Appends turbulence model flags from legacy or structured case.yml blocks.
7878 @param[in] models Parsed case.yml `models` mapping.
7879 @param[out] control_lines A list of strings to which C-flags will be appended.
7880 """
7881 turbulence_cfg = models.get('physics', {}).get('turbulence', {})
7882 if not turbulence_cfg:
7883 return
7884 if not isinstance(turbulence_cfg, dict):
7885 raise ValueError("models.physics.turbulence must be a mapping.")
7886
7887 les_cfg = turbulence_cfg.get('les')
7888 rans_cfg = turbulence_cfg.get('rans')
7889 wall_cfg = turbulence_cfg.get('wall_function')
7890 les_code = None
7891 rans_code = None
7892
7893 if isinstance(les_cfg, dict):
7894 enabled = resolve_enabled_flag(les_cfg, "models.physics.turbulence.les")
7895 model_value = les_cfg.get('model', 'constant_smagorinsky')
7896 les_code = normalize_les_model(model_value) if enabled else 0
7897 control_lines.append(f"-les {les_code}")
7898 if 'constant_cs' in les_cfg:
7899 control_lines.append(f"-const_cs {format_flag_value(les_cfg['constant_cs'])}")
7900 if 'max_cs' in les_cfg:
7901 control_lines.append(f"-max_cs {format_flag_value(les_cfg['max_cs'])}")
7902 if 'dynamic_frequency' in les_cfg:
7903 control_lines.append(f"-dynamic_freq {format_flag_value(les_cfg['dynamic_frequency'])}")
7904 if 'test_filter' in les_cfg:
7905 control_lines.append(f"-testfilter_ik {normalize_les_test_filter(les_cfg['test_filter'])}")
7906 elif les_cfg is not None:
7907 les_code = normalize_les_model(les_cfg)
7908 control_lines.append(f"-les {les_code}")
7909
7910 if isinstance(rans_cfg, dict):
7911 enabled = resolve_enabled_flag(rans_cfg, "models.physics.turbulence.rans")
7912 model_value = rans_cfg.get('model', 'k_omega')
7913 rans_code = normalize_rans_model(model_value) if enabled else 0
7914 control_lines.append(f"-rans {rans_code}")
7915 elif rans_cfg is not None:
7916 rans_code = normalize_rans_model(rans_cfg)
7917 control_lines.append(f"-rans {rans_code}")
7918
7919 if les_code and rans_code:
7920 raise ValueError("models.physics.turbulence cannot enable both LES and RANS in the same case.")
7921
7922 if isinstance(wall_cfg, dict):
7923 enabled = resolve_enabled_flag(wall_cfg, "models.physics.turbulence.wall_function")
7924 normalize_wall_function_model(wall_cfg.get('model'))
7925 control_lines.append(f"-wallfunction {1 if enabled else 0}")
7926 if 'roughness_height' in wall_cfg:
7927 control_lines.append(f"-wall_roughness {format_flag_value(wall_cfg['roughness_height'])}")
7928 elif wall_cfg is not None:
7929 control_lines.append(f"-wallfunction {format_flag_value(wall_cfg)}")
7930
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 7931 of file core.py.

7931def append_passthrough_flags(control_lines: list, options: dict):
7932 """!
7933 @brief Appends raw CLI flags to the control list from a {flag: value} dict.
7934 @details Boolean `true` is emitted as a switch with no value. Boolean `false`
7935 is skipped. All other values are emitted as "<flag> <value>".
7936 @param[out] control_lines The destination list of control-file lines.
7937 @param[in] options Mapping of raw CLI flags to values.
7938 """
7939 if not options:
7940 return
7941 for flag, value in options.items():
7942 if isinstance(value, bool):
7943 if value:
7944 control_lines.append(str(flag))
7945 continue
7946 control_lines.append(f"{flag} {format_flag_value(value)}")
7947
7948
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 7965 of file core.py.

7965def resolve_solver_monitoring_flags(monitor_cfg: dict) -> dict:
7966 """!
7967 @brief Resolve human-readable solver monitoring YAML to raw control flags.
7968 @param[in] monitor_cfg Parsed monitor.yml mapping.
7969 @return Mapping of raw C/PETSc flags to values.
7970 """
7971 solver_mon_cfg = monitor_cfg.get("solver_monitoring", {}) if isinstance(monitor_cfg, dict) else {}
7972 if solver_mon_cfg is None:
7973 return {}
7974 if not isinstance(solver_mon_cfg, dict):
7975 raise ValueError("monitor.solver_monitoring must be a mapping when provided.")
7976
7977 flags = {}
7978
7979 momentum_cfg = solver_mon_cfg.get("momentum", {})
7980 if momentum_cfg is None:
7981 momentum_cfg = {}
7982 if not isinstance(momentum_cfg, dict):
7983 raise ValueError("monitor.solver_monitoring.momentum must be a mapping when provided.")
7984 unknown_momentum = sorted(set(momentum_cfg.keys()) - set(SOLVER_MONITORING_MOMENTUM_FLAG_MAP.keys()))
7985 if unknown_momentum:
7986 raise ValueError(f"monitor.solver_monitoring.momentum has unsupported key(s): {unknown_momentum}.")
7987 for key, flag in SOLVER_MONITORING_MOMENTUM_FLAG_MAP.items():
7988 if key in momentum_cfg:
7989 value = momentum_cfg[key]
7990 if not isinstance(value, bool):
7991 raise ValueError(f"monitor.solver_monitoring.momentum.{key} must be boolean.")
7992 flags[flag] = value
7993
7994 poisson_cfg = solver_mon_cfg.get("poisson", {})
7995 if poisson_cfg is None:
7996 poisson_cfg = {}
7997 if not isinstance(poisson_cfg, dict):
7998 raise ValueError("monitor.solver_monitoring.poisson must be a mapping when provided.")
7999 unknown_poisson = sorted(set(poisson_cfg.keys()) - set(SOLVER_MONITORING_POISSON_FLAG_MAP.keys()))
8000 if unknown_poisson:
8001 raise ValueError(f"monitor.solver_monitoring.poisson has unsupported key(s): {unknown_poisson}.")
8002 for key, flag in SOLVER_MONITORING_POISSON_FLAG_MAP.items():
8003 if key in poisson_cfg:
8004 value = poisson_cfg[key]
8005 if not isinstance(value, bool):
8006 raise ValueError(f"monitor.solver_monitoring.poisson.{key} must be boolean.")
8007 flags[flag] = value
8008
8009 passthrough = solver_mon_cfg.get("petsc_passthrough_options", {})
8010 if passthrough is None:
8011 passthrough = {}
8012 if not isinstance(passthrough, dict):
8013 raise ValueError("monitor.solver_monitoring.petsc_passthrough_options must be a mapping when provided.")
8014 flags.update(passthrough)
8015
8016 legacy_raw = {
8017 key: value
8018 for key, value in solver_mon_cfg.items()
8019 if isinstance(key, str) and key.startswith("-")
8020 }
8021 flags.update(legacy_raw)
8022
8023 unknown_top = sorted(
8024 key
8025 for key in solver_mon_cfg.keys()
8026 if key not in {"momentum", "poisson", "petsc_passthrough_options"} and not (isinstance(key, str) and key.startswith("-"))
8027 )
8028 if unknown_top:
8029 raise ValueError(
8030 "monitor.solver_monitoring has unsupported key(s): "
8031 f"{unknown_top}. Use 'momentum'/'poisson' for structured monitors or "
8032 "'petsc_passthrough_options' for raw PETSc flags."
8033 )
8034
8035 return flags
8036
8037
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 8038 of file core.py.

8038def resolve_particle_console_output_frequency(io_cfg: dict) -> "int | None":
8039 """!
8040 @brief Return the effective particle-console snapshot cadence from monitor.yml.
8041 @param[in] io_cfg Argument passed to `resolve_particle_console_output_frequency()`.
8042 @return Value returned by `resolve_particle_console_output_frequency()`.
8043 """
8044 if 'particle_console_output_frequency' in io_cfg:
8045 return io_cfg['particle_console_output_frequency']
8046 return io_cfg.get('data_output_frequency')
8047
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 8048 of file core.py.

8048def parse_and_add_model_flags(case_cfg: dict, control_lines: list):
8049 """!
8050 @brief Parses the 'models' section of case.yml and adds corresponding C-solver flags.
8051 @param[in] case_cfg The parsed case.yml configuration dictionary.
8052 @param[out] control_lines A list of strings to which C-flags will be appended.
8053 """
8054 models = case_cfg.get('models', {})
8055 FLAG_MAP = {
8056 'domain': {'blocks': '-nblk'},
8057 'physics.fsi': {'immersed': '-imm', 'moving_fsi': '-fsi'},
8058 'physics.particles': {'count': '-numParticles'},
8059 'statistics': {'time_averaging': '-averaging'}
8060 }
8061 for section_path, flags in FLAG_MAP.items():
8062 current_level = models
8063 try:
8064 for key in section_path.split('.'): current_level = current_level[key]
8065 for yaml_key, flag in flags.items():
8066 if yaml_key in current_level:
8067 control_lines.append(f"{flag} {format_flag_value(current_level[yaml_key])}")
8068 except KeyError: continue
8069
8070 append_turbulence_flags(models, control_lines)
8071
8072 if models.get('physics', {}).get('dimensionality') == '2D':
8073 control_lines.append("-TwoD 1")
8074
8075 particles_cfg = models.get('physics', {}).get('particles', {})
8076 p_init_mode_str = particles_cfg.get('init_mode', 'Surface')
8077 pinit_code = normalize_particle_init_mode(p_init_mode_str)
8078 control_lines.append(f"-pinit {pinit_code}")
8079 print(f" - Particle Initialization Mode: {p_init_mode_str} (Code: {pinit_code})")
8080
8081 if pinit_code == 2:
8082 point_cfg = particles_cfg.get('point_source', {})
8083 if not isinstance(point_cfg, dict):
8084 raise ValueError("models.physics.particles.point_source must be a mapping when init_mode is PointSource.")
8085 try:
8086 psrc_x = float(point_cfg['x'])
8087 psrc_y = float(point_cfg['y'])
8088 psrc_z = float(point_cfg['z'])
8089 except (KeyError, TypeError, ValueError):
8090 raise ValueError("PointSource init_mode requires numeric point_source.{x,y,z} values.")
8091 control_lines.append(f"-psrc_x {psrc_x}")
8092 control_lines.append(f"-psrc_y {psrc_y}")
8093 control_lines.append(f"-psrc_z {psrc_z}")
8094 print(f" - Particle Point Source: ({psrc_x}, {psrc_y}, {psrc_z})")
8095
8096 p_restart_mode = particles_cfg.get('restart_mode')
8097 if p_restart_mode:
8098 p_restart_mode_normalized = str(p_restart_mode).lower()
8099 if p_restart_mode_normalized not in {"init", "load"}:
8100 raise ValueError(f"Unknown particle restart_mode '{p_restart_mode}'. Options are 'init' or 'load'.")
8101 control_lines.append(f"-particle_restart_mode \"{p_restart_mode}\"")
8102
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 8103 of file core.py.

8103def parse_solver_config(solver_cfg: dict) -> dict:
8104 """!
8105 @brief Parses the structured solver.yml into a flat dictionary of {flag: value}.
8106 @param[in] solver_cfg The parsed solver.yml configuration dictionary.
8107 @return A dictionary where keys are C-solver flags and values are the corresponding settings.
8108 """
8109 flags = {}
8110 if 'operation_mode' in solver_cfg and isinstance(solver_cfg['operation_mode'], dict):
8111 op_mode = solver_cfg['operation_mode']
8112 if 'eulerian_field_source' in op_mode:
8113 normalized_source = normalize_eulerian_field_source(op_mode.get('eulerian_field_source'))
8114 flags['-euler_field_source'] = f"\"{normalized_source}\""
8115 if 'analytical_type' in op_mode and op_mode.get('analytical_type') is not None:
8116 normalized_analytical_type = normalize_analytical_type(op_mode.get('analytical_type'))
8117 flags['-analytical_type'] = f"\"{normalized_analytical_type}\""
8118 if normalized_analytical_type == "UNIFORM_FLOW":
8119 uniform_flow_cfg = op_mode.get('uniform_flow', {})
8120 if not isinstance(uniform_flow_cfg, dict):
8121 raise ValueError("operation_mode.uniform_flow must be a mapping when analytical_type is 'UNIFORM_FLOW'.")
8122 try:
8123 flags['-analytical_uniform_u'] = float(uniform_flow_cfg['u'])
8124 flags['-analytical_uniform_v'] = float(uniform_flow_cfg['v'])
8125 flags['-analytical_uniform_w'] = float(uniform_flow_cfg['w'])
8126 except KeyError as exc:
8127 raise ValueError(f"operation_mode.uniform_flow.{exc.args[0]} is required when analytical_type is 'UNIFORM_FLOW'.") from exc
8128 except (TypeError, ValueError) as exc:
8129 raise ValueError("operation_mode.uniform_flow.{u,v,w} must be numeric when analytical_type is 'UNIFORM_FLOW'.") from exc
8130
8131 verification_cfg = solver_cfg.get('verification', {})
8132 if verification_cfg:
8133 if not isinstance(verification_cfg, dict):
8134 raise ValueError("verification must be a mapping when provided.")
8135 sources_cfg = verification_cfg.get('sources', {})
8136 if not isinstance(sources_cfg, dict):
8137 raise ValueError("verification.sources must be a mapping when provided.")
8138 diff_cfg = sources_cfg.get('diffusivity')
8139 if diff_cfg is not None:
8140 if not isinstance(diff_cfg, dict):
8141 raise ValueError("verification.sources.diffusivity must be a mapping.")
8142 try:
8143 flags['-verification_diffusivity_mode'] = f"\"{str(diff_cfg['mode']).strip().lower()}\""
8144 flags['-verification_diffusivity_profile'] = f"\"{str(diff_cfg['profile']).strip().upper()}\""
8145 flags['-verification_diffusivity_gamma0'] = float(diff_cfg['gamma0'])
8146 flags['-verification_diffusivity_slope_x'] = float(diff_cfg['slope_x'])
8147 except KeyError as exc:
8148 raise ValueError(f"verification.sources.diffusivity.{exc.args[0]} is required.") from exc
8149 except (TypeError, ValueError) as exc:
8150 raise ValueError("verification.sources.diffusivity.{gamma0,slope_x} must be numeric and mode/profile must be scalar strings.") from exc
8151
8152 scalar_cfg = sources_cfg.get('scalar')
8153 if scalar_cfg is not None:
8154 if not isinstance(scalar_cfg, dict):
8155 raise ValueError("verification.sources.scalar must be a mapping.")
8156 try:
8157 flags['-verification_scalar_mode'] = f"\"{str(scalar_cfg['mode']).strip().lower()}\""
8158 flags['-verification_scalar_profile'] = f"\"{str(scalar_cfg['profile']).strip().upper()}\""
8159 except KeyError as exc:
8160 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
8161
8162 scalar_numeric_keys = {
8163 'CONSTANT': ('value',),
8164 'LINEAR_X': ('phi0', 'slope_x'),
8165 'SIN_PRODUCT': ('amplitude', 'kx', 'ky', 'kz'),
8166 }
8167 profile = str(scalar_cfg.get('profile', '')).strip().upper()
8168 for key in scalar_numeric_keys.get(profile, ()):
8169 try:
8170 flags[f'-verification_scalar_{key}'] = float(scalar_cfg[key])
8171 except KeyError as exc:
8172 raise ValueError(f"verification.sources.scalar.{exc.args[0]} is required.") from exc
8173 except (TypeError, ValueError) as exc:
8174 raise ValueError(f"verification.sources.scalar.{key} must be numeric.") from exc
8175
8176 transport_cfg = solver_cfg.get('scalar_transport', {})
8177 if transport_cfg:
8178 if not isinstance(transport_cfg, dict):
8179 raise ValueError("scalar_transport must be a mapping when provided.")
8180 transport_map = {
8181 'schmidt_number': '-schmidt_number',
8182 'turbulent_schmidt_number': '-turb_schmidt_number',
8183 }
8184 unknown_transport_keys = sorted(set(transport_cfg.keys()) - set(transport_map.keys()))
8185 if unknown_transport_keys:
8186 raise ValueError(
8187 f"scalar_transport has unsupported key(s): {unknown_transport_keys}. "
8188 "Use 'schmidt_number' or 'turbulent_schmidt_number'."
8189 )
8190 for key, flag in transport_map.items():
8191 if key in transport_cfg:
8192 try:
8193 value = float(transport_cfg[key])
8194 except (TypeError, ValueError) as exc:
8195 raise ValueError(f"scalar_transport.{key} must be numeric.") from exc
8196 if value <= 0.0:
8197 raise ValueError(f"scalar_transport.{key} must be positive.")
8198 flags[flag] = value
8199
8200 selected_solver = None
8201 if 'strategy' in solver_cfg:
8202 s = solver_cfg['strategy']
8203 if 'central_diff' in s:
8204 flags['-central'] = format_flag_value(s['central_diff'])
8205 # Preferred selector.
8206 if 'momentum_solver' in s:
8207 selected_solver = normalize_momentum_solver_type(s['momentum_solver'])
8208 elif 'implicit' in s:
8209 raise ValueError("Legacy key 'strategy.implicit' is not supported. Use 'strategy.momentum_solver'.")
8210
8211 ms = solver_cfg.get('momentum_solver', {})
8212 if selected_solver is None:
8213 selected_solver = "DUALTIME_PICARD_JAMESON_RK"
8214 flags['-mom_solver_type'] = f"\"{selected_solver}\""
8215
8216 if 'tolerances' in solver_cfg:
8217 t = solver_cfg['tolerances']
8218 tol_map = {
8219 'max_iterations': '-mom_max_pseudo_steps',
8220 'absolute_tol': '-mom_atol',
8221 'relative_tol': '-mom_rtol',
8222 'residual_absolute_tol': '-mom_resid_atol',
8223 'residual_relative_tol': '-mom_resid_rtol',
8224 'step_tol': '-imp_stol'
8225 }
8226 for key, flag in tol_map.items():
8227 if key in t:
8228 flags[flag] = t[key]
8229
8230 def _append_dualtime_options(cfg: dict):
8231 """!
8232 @brief Append dualtime options.
8233 @param[in] cfg Argument passed to `_append_dualtime_options()`.
8234 """
8235 if 'max_pseudo_steps' in cfg:
8236 flags['-mom_max_pseudo_steps'] = cfg['max_pseudo_steps']
8237 if 'absolute_tol' in cfg:
8238 flags['-mom_atol'] = cfg['absolute_tol']
8239 if 'relative_tol' in cfg:
8240 flags['-mom_rtol'] = cfg['relative_tol']
8241 if 'step_tol' in cfg:
8242 flags['-imp_stol'] = cfg['step_tol']
8243 if 'pseudo_cfl' in cfg:
8244 pcfl = cfg['pseudo_cfl']
8245 if 'initial' in pcfl:
8246 flags['-pseudo_cfl'] = pcfl['initial']
8247 if 'minimum' in pcfl:
8248 flags['-min_pseudo_cfl'] = pcfl['minimum']
8249 if 'maximum' in pcfl:
8250 flags['-max_pseudo_cfl'] = pcfl['maximum']
8251 if 'growth_factor' in pcfl:
8252 flags['-pseudo_cfl_growth_factor'] = pcfl['growth_factor']
8253 if 'reduction_factor' in pcfl:
8254 flags['-pseudo_cfl_reduction_factor'] = pcfl['reduction_factor']
8255 if 'jameson_residual_noise_allowance_factor' in cfg:
8256 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['jameson_residual_noise_allowance_factor']
8257 elif 'rk4_residual_noise_allowance_factor' in cfg:
8258 flags['-mom_dt_jameson_residual_norm_noise_allowance_factor'] = cfg['rk4_residual_noise_allowance_factor']
8259 if 'ratio_ema_alpha' in cfg:
8260 flags['-mom_ratio_ema_alpha'] = cfg['ratio_ema_alpha']
8261
8262 def _append_newton_krylov_options(cfg: dict):
8263 """!
8264 @brief Append validated structured Newton--Krylov PETSc options.
8265 @param[in] cfg Structured Newton--Krylov mapping.
8266 """
8267 cfg = validate_newton_krylov_config(cfg)
8268 nonlinear = cfg["nonlinear_solver"]
8269 nonlinear_map = {
8270 "method": "-mom_nk_snes_type",
8271 "absolute_tolerance": "-mom_nk_snes_atol",
8272 "relative_tolerance": "-mom_nk_snes_rtol",
8273 "step_tolerance": "-mom_nk_snes_stol",
8274 "max_iterations": "-mom_nk_snes_max_it",
8275 }
8276 for key, flag in nonlinear_map.items():
8277 if key in nonlinear:
8278 flags[flag] = nonlinear[key]
8279 line_search = nonlinear.get("line_search", {})
8280 if "type" in line_search:
8281 flags["-mom_nk_snes_linesearch_type"] = line_search["type"]
8282
8283 linear = cfg["linear_solver"]
8284 linear_map = {
8285 "method": "-mom_nk_ksp_type",
8286 "absolute_tolerance": "-mom_nk_ksp_atol",
8287 "relative_tolerance": "-mom_nk_ksp_rtol",
8288 "max_iterations": "-mom_nk_ksp_max_it",
8289 }
8290 for key, flag in linear_map.items():
8291 if key in linear:
8292 flags[flag] = linear[key]
8293 gmres = linear.get("gmres", {})
8294 if "restart" in gmres:
8295 flags["-mom_nk_ksp_gmres_restart"] = gmres["restart"]
8296 preconditioner = linear.get("preconditioner", {})
8297 if "type" in preconditioner:
8298 flags["-mom_nk_pc_type"] = preconditioner["type"]
8299
8300 if isinstance(ms, dict):
8301 allowed_ms_keys = {'type', 'dual_time_picard_jameson_rk', 'dual_time_picard_rk4', 'newton_krylov'}
8302 unknown_ms_keys = sorted(set(ms.keys()) - allowed_ms_keys)
8303 if unknown_ms_keys:
8304 raise ValueError(
8305 f"Unsupported momentum_solver keys/blocks: {unknown_ms_keys}. "
8306 "Currently supported blocks: 'dual_time_picard_jameson_rk' and 'newton_krylov'."
8307 )
8308
8309 if 'dual_time_picard_jameson_rk' in ms and 'dual_time_picard_rk4' in ms:
8310 raise ValueError(
8311 "Use only momentum_solver.dual_time_picard_jameson_rk; "
8312 "do not also set its deprecated dual_time_picard_rk4 alias."
8313 )
8314 dt_picard_cfg = ms.get('dual_time_picard_jameson_rk', ms.get('dual_time_picard_rk4'))
8315 if dt_picard_cfg is not None:
8316 if selected_solver != "DUALTIME_PICARD_JAMESON_RK":
8317 raise ValueError(
8318 f"momentum_solver.dual_time_picard_jameson_rk is set but selected solver is {selected_solver}."
8319 )
8320 if not isinstance(dt_picard_cfg, dict):
8321 raise ValueError("momentum_solver.dual_time_picard_jameson_rk must be a mapping.")
8322 if ('jameson_residual_noise_allowance_factor' in dt_picard_cfg and
8323 'rk4_residual_noise_allowance_factor' in dt_picard_cfg):
8324 raise ValueError(
8325 "Use only jameson_residual_noise_allowance_factor; "
8326 "do not also set its deprecated rk4_residual_noise_allowance_factor alias."
8327 )
8328 _append_dualtime_options(dt_picard_cfg)
8329 newton_cfg = ms.get('newton_krylov')
8330 if newton_cfg is not None:
8331 if selected_solver != "newton_krylov":
8332 raise ValueError(
8333 f"momentum_solver.newton_krylov is set but selected solver is {selected_solver}."
8334 )
8335 _append_newton_krylov_options(newton_cfg)
8336 solution_convergence_cfg = solver_cfg.get('solution_convergence', {})
8337 if solution_convergence_cfg is not None:
8338 if not isinstance(solution_convergence_cfg, dict):
8339 raise ValueError("solution_convergence must be a mapping when provided.")
8340 if solution_convergence_cfg and solution_convergence_cfg.get('enabled', True):
8341 mode = normalize_solution_convergence_mode(solution_convergence_cfg.get('mode', 'steady_deterministic'))
8342 flags['-solution_convergence_mode'] = f"\"{mode}\""
8343 if mode == "PERIODIC_DETERMINISTIC":
8344 periodic_cfg = solution_convergence_cfg.get('periodic_deterministic')
8345 if not isinstance(periodic_cfg, dict):
8346 raise ValueError("solution_convergence.periodic_deterministic must be a mapping when mode is 'periodic_deterministic'.")
8347 flags['-solution_convergence_period_steps'] = periodic_cfg['period_steps']
8348 if mode == "STATISTICAL_STEADY":
8349 statistical_cfg = solution_convergence_cfg.get('statistical_steady')
8350 if not isinstance(statistical_cfg, dict):
8351 raise ValueError("solution_convergence.statistical_steady must be a mapping when mode is 'statistical_steady'.")
8352 flags['-solution_convergence_window_steps'] = statistical_cfg['window_steps']
8353 def _normalize_poisson_method(value) -> str:
8354 """!
8355 @brief Normalize a user-facing Poisson linear-solver method name.
8356 @param[in] value Method value from the solver YAML.
8357 @return Lowercase PETSc KSP method token.
8358 """
8359 method = str(value).strip().lower()
8360 if not method:
8361 raise ValueError("poisson_solver.method cannot be empty.")
8362 return method
8363
8364 def _normalize_poisson_preconditioner(value) -> str:
8365 """!
8366 @brief Normalize and validate the outer Poisson preconditioner name.
8367 @param[in] value Preconditioner value from the solver YAML.
8368 @return PETSc PC token for the supported outer preconditioner.
8369 """
8370 pc = str(value).strip().lower()
8371 aliases = {"mg": "multigrid", "pcmg": "multigrid"}
8372 pc = aliases.get(pc, pc)
8373 if pc != "multigrid":
8374 raise ValueError(
8375 "poisson_solver.preconditioner.type currently supports only 'multigrid'. "
8376 "The runtime Poisson solver still assumes PETSc PCMG setup."
8377 )
8378 return "mg"
8379
8380 def _poisson_level_number(level_name) -> str:
8381 """!
8382 @brief Extract the numeric suffix from a `level_N` multigrid level key.
8383 @param[in] level_name YAML level key supplied by the user.
8384 @return Numeric level suffix as a string.
8385 """
8386 text = str(level_name).strip()
8387 match = re.fullmatch(r"level_(\d+)", text)
8388 if not match:
8389 raise ValueError(f"Invalid Poisson multigrid level name '{level_name}'. Expected 'level_N'.")
8390 return match.group(1)
8391
8392 def _append_poisson_solver_flags(ps: dict, source_key: str):
8393 """!
8394 @brief Append structured Poisson solver options to the flat PETSc flag map.
8395 @param[in] ps The `poisson_solver` or legacy `pressure_solver` mapping.
8396 @param[in] source_key Name of the source YAML block, used in error messages.
8397 """
8398 if not isinstance(ps, dict):
8399 raise ValueError(f"{source_key} must be a mapping when provided.")
8400
8401 method = None
8402 if 'method' in ps:
8403 method = _normalize_poisson_method(ps['method'])
8404 flags['-ps_ksp_type'] = method
8405 if 'absolute_tolerance' in ps:
8406 flags['-ps_ksp_atol'] = ps['absolute_tolerance']
8407 flags['-poisson_tol'] = ps['absolute_tolerance']
8408 if 'relative_tolerance' in ps:
8409 flags['-ps_ksp_rtol'] = ps['relative_tolerance']
8410 if 'max_iterations' in ps:
8411 flags['-ps_ksp_max_it'] = ps['max_iterations']
8412 if 'tolerance' in ps:
8413 flags['-poisson_tol'] = ps['tolerance']
8414
8415 gmres_cfg = ps.get('gmres', {})
8416 if gmres_cfg is not None:
8417 if not isinstance(gmres_cfg, dict):
8418 raise ValueError(f"{source_key}.gmres must be a mapping when provided.")
8419 if 'restart' in gmres_cfg:
8420 if method is None:
8421 method = _normalize_poisson_method(ps.get('method', 'fgmres'))
8422 flags.setdefault('-ps_ksp_type', method)
8423 if method not in {"gmres", "fgmres", "lgmres"}:
8424 raise ValueError(
8425 f"{source_key}.gmres.restart is valid only when {source_key}.method "
8426 "is one of 'gmres', 'fgmres', or 'lgmres'."
8427 )
8428 flags['-ps_ksp_gmres_restart'] = gmres_cfg['restart']
8429
8430 preconditioner_cfg = ps.get('preconditioner', {})
8431 if preconditioner_cfg:
8432 if not isinstance(preconditioner_cfg, dict):
8433 raise ValueError(f"{source_key}.preconditioner must be a mapping when provided.")
8434 if 'type' in preconditioner_cfg:
8435 flags['-ps_pc_type'] = _normalize_poisson_preconditioner(preconditioner_cfg['type'])
8436
8437 if 'multigrid' in ps:
8438 mg = ps['multigrid']
8439 if not isinstance(mg, dict):
8440 raise ValueError(f"{source_key}.multigrid must be a mapping when provided.")
8441 mg_map = {'levels': '-mg_level', 'pre_sweeps': '-mg_pre_it', 'post_sweeps': '-mg_post_it'}
8442 for key, flag in mg_map.items():
8443 if key in mg: flags[flag] = mg[key]
8444 if 'cycle' in mg:
8445 cycle = str(mg['cycle']).strip().lower()
8446 if cycle not in {"v"}:
8447 raise ValueError(f"{source_key}.multigrid.cycle currently supports only 'v'.")
8448 if 'mode' in mg:
8449 mode = str(mg['mode']).strip().lower()
8450 if mode not in {"multiplicative"}:
8451 raise ValueError(f"{source_key}.multigrid.mode currently supports only 'multiplicative'.")
8452 if 'semi_coarsening' in mg:
8453 sc = mg['semi_coarsening']
8454 if not isinstance(sc, dict):
8455 raise ValueError(f"{source_key}.multigrid.semi_coarsening must be a mapping when provided.")
8456 if 'i' in sc: flags['-mg_i_semi'] = format_flag_value(sc['i'])
8457 if 'j' in sc: flags['-mg_j_semi'] = format_flag_value(sc['j'])
8458 if 'k' in sc: flags['-mg_k_semi'] = format_flag_value(sc['k'])
8459 if 'level_solvers' in mg:
8460 level_solvers = mg['level_solvers']
8461 if not isinstance(level_solvers, dict):
8462 raise ValueError(f"{source_key}.multigrid.level_solvers must be a mapping when provided.")
8463 for level_name, settings in level_solvers.items():
8464 if not isinstance(settings, dict):
8465 raise ValueError(f"{source_key}.multigrid.level_solvers.{level_name} must be a mapping.")
8466 level_num = _poisson_level_number(level_name)
8467 for key, value in settings.items():
8468 mapped_key = {'method': 'ksp_type', 'preconditioner': 'pc_type'}.get(key, key)
8469 flags[f"-ps_mg_levels_{level_num}_{mapped_key}"] = format_flag_value(value)
8470
8471 if 'poisson_solver' in solver_cfg and 'pressure_solver' in solver_cfg:
8472 if solver_cfg['poisson_solver'] != solver_cfg['pressure_solver']:
8473 raise ValueError(
8474 "Both 'poisson_solver' and legacy 'pressure_solver' are present with different values. "
8475 "Use 'poisson_solver' only, or make the legacy alias identical."
8476 )
8477 poisson_cfg = solver_cfg.get('poisson_solver', solver_cfg.get('pressure_solver'))
8478 if poisson_cfg is not None:
8479 source_key = 'poisson_solver' if 'poisson_solver' in solver_cfg else 'pressure_solver'
8480 _append_poisson_solver_flags(poisson_cfg, source_key)
8481 interp_cfg = solver_cfg.get('interpolation', {})
8482 if isinstance(interp_cfg, dict):
8483 interp_method_str = interp_cfg.get('method', 'Trilinear')
8484 else:
8485 interp_method_str = 'Trilinear'
8486 interp_code = normalize_interpolation_method(interp_method_str)
8487 flags['-interpolation_method'] = interp_code
8488 print(f" - Interpolation Method: {interp_method_str} (Code: {interp_code})")
8489
8490 if 'petsc_passthrough_options' in solver_cfg:
8491 passthrough = solver_cfg['petsc_passthrough_options']
8492 if passthrough is None:
8493 passthrough = {}
8494 if not isinstance(passthrough, dict):
8495 raise ValueError("petsc_passthrough_options must be a mapping when provided.")
8496 if passthrough:
8497 for key, value in passthrough.items():
8498 flags[key] = value if isinstance(value, bool) else format_flag_value(value)
8499 summary_bits = []
8500 if '-ps_ksp_type' in flags:
8501 summary_bits.append(f"method={flags['-ps_ksp_type']}")
8502 if '-ps_ksp_atol' in flags:
8503 summary_bits.append(f"atol={flags['-ps_ksp_atol']}")
8504 if '-ps_ksp_rtol' in flags:
8505 summary_bits.append(f"rtol={flags['-ps_ksp_rtol']}")
8506 if '-ps_ksp_max_it' in flags:
8507 summary_bits.append(f"max_it={flags['-ps_ksp_max_it']}")
8508 if '-mg_level' in flags:
8509 summary_bits.append(f"mg_levels={flags['-mg_level']}")
8510 if summary_bits:
8511 print(f" - Poisson Solver: {', '.join(summary_bits)}")
8512 return flags
8513
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 8514 of file core.py.

8514def generate_solver_control_file(run_dir, run_id, configs, num_procs, monitor_files, restart_source_dir=None, continue_mode=False):
8515 """!
8516 @brief Generates the main .control file for the C-solver.
8517 @details Orchestrates the conversion of all YAML configurations (case, solver, monitor)
8518 into a single, machine-readable file of command-line flags.
8519 @param[in] run_dir Argument passed to `generate_solver_control_file()`.
8520 @param[in] run_id Argument passed to `generate_solver_control_file()`.
8521 @param[in] configs Argument passed to `generate_solver_control_file()`.
8522 @param[in] num_procs Argument passed to `generate_solver_control_file()`.
8523 @param[in] monitor_files Argument passed to `generate_solver_control_file()`.
8524 @param[in] restart_source_dir Argument passed to `generate_solver_control_file()`.
8525 @param[in] continue_mode If True, appends -continue_mode flag for the C solver.
8526 @return Value returned by `generate_solver_control_file()`.
8527 """
8528 print("[INFO] Generating master solver control file...")
8529 case_cfg, solver_cfg, monitor_cfg = configs['case'], configs['solver'], configs['monitor']
8530 source_files = {'Case': configs['case_path'], 'Solver': configs['solver_path'], 'Monitor': configs['monitor_path']}
8531
8532 control_lines = []
8533 try:
8534 props, run_ctrl = case_cfg['properties'], case_cfg['run_control']
8535 scales, fluid, ic = props['scaling'], props['fluid'], props['initial_conditions']
8536 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
8537 L_ref, U_ref, rho, mu = float(scales['length_ref']), float(scales['velocity_ref']), float(fluid['density']), float(fluid['viscosity'])
8538 reynolds = (rho * U_ref * L_ref) / mu if mu != 0 else float('inf')
8539 dt_phys = float(run_ctrl['dt_physical'])
8540 T_ref = L_ref / U_ref if U_ref != 0 else float('inf')
8541 dt_nondim = dt_phys / T_ref if T_ref != float('inf') else 0.0
8542 resolved_ic = resolve_initial_condition_config(ic, prepared_blocks, U_ref)
8543 finit_mode_str = resolved_ic["label"]
8544 finit_code = resolved_ic["finit"]
8545 ic_params = resolved_ic["cli_params"]
8546 print(f" - Reynolds Number (Re) = {reynolds:.4f}")
8547 print(f" - Non-Dimensional dt* = {dt_nondim:.6f}")
8548 eulerian_source = normalize_eulerian_field_source(
8549 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
8550 )
8551 start_step = int(run_ctrl.get("start_step", 0) or 0)
8552 ic_is_authoritative = eulerian_source == "solve" and start_step == 0
8553 ic_cli = []
8554 if ic_is_authoritative:
8555 print(f" - Initial Condition: {finit_mode_str} (Code: {finit_code})")
8556 if "ucont_x" in ic_params:
8557 ic_cli.extend([
8558 f"-ucont_x {ic_params['ucont_x']}",
8559 f"-ucont_y {ic_params['ucont_y']}",
8560 f"-ucont_z {ic_params['ucont_z']}",
8561 ])
8562 if "ic_velocity_physical" in ic_params:
8563 ic_cli.append(f"-ic_velocity_physical {ic_params['ic_velocity_physical']}")
8564 if "flow_direction" in ic_params:
8565 ic_cli.append(f"-flow_direction {ic_params['flow_direction']}")
8566 else:
8567 print(
8568 f"[WARN] Ignoring configured initial condition '{finit_mode_str}' because "
8569 f"eulerian_field_source={eulerian_source!r} and start_step={start_step} select another source.",
8570 file=sys.stderr,
8571 )
8572 finit_code = 0
8573 control_lines.extend([
8574 f"-start_step {run_ctrl['start_step']}", f"-totalsteps {run_ctrl['total_steps']}",
8575 f"-ren {reynolds}", f"-dt {dt_nondim}", f"-finit {finit_code}",
8576 *ic_cli,
8577 f"-scaling_L_ref {L_ref}", f"-scaling_U_ref {U_ref}", f"-scaling_rho_ref {rho}"
8578 ])
8579 except (KeyError, TypeError, ZeroDivisionError, ValueError) as e:
8580 print(f"[FATAL] Error processing case.yml: {e}", file=sys.stderr)
8581 sys.exit(1)
8582
8583 # --- CORRECTED: Add paths for whitelist and profile files ---
8584 if monitor_files.get("whitelist"):
8585 control_lines.append(f"-whitelist_config_file {monitor_files['whitelist']}")
8586 if monitor_files.get("profile"):
8587 control_lines.append(f"-profile_config_file {monitor_files['profile']}")
8588 profiling_cfg = monitor_files.get("profiling", {})
8589 control_lines.append(f"-profiling_timestep_mode {profiling_cfg.get('mode', 'off')}")
8590 if profiling_cfg.get("mode") != "off":
8591 control_lines.append(f"-profiling_timestep_file {profiling_cfg.get('timestep_file', 'Profiling_Timestep_Summary.csv')}")
8592 control_lines.append(f"-profiling_final_summary {str(bool(profiling_cfg.get('final_summary_enabled', True))).lower()}")
8593 diagnostics_cfg = resolve_diagnostics_config(monitor_cfg)
8594 memory_log_cfg = diagnostics_cfg["runtime_memory_log"]
8595 control_lines.append(f"-runtime_memory_log_enabled {str(bool(memory_log_cfg.get('enabled', True))).lower()}")
8596 control_lines.append(f"-runtime_memory_log_file {memory_log_cfg.get('file', 'Runtime_Memory.log')}")
8597
8598 walltime_guard_policy = configs.get("walltime_guard_policy")
8599 if walltime_guard_policy is not None:
8600 control_lines.extend(
8601 [
8602 f"-walltime_guard_enabled {str(bool(walltime_guard_policy.get('enabled', False))).lower()}",
8603 f"-walltime_guard_warmup_steps {int(walltime_guard_policy.get('warmup_steps', DEFAULT_WALLTIME_GUARD_POLICY['warmup_steps']))}",
8604 f"-walltime_guard_multiplier {float(walltime_guard_policy.get('multiplier', DEFAULT_WALLTIME_GUARD_POLICY['multiplier']))}",
8605 f"-walltime_guard_min_seconds {float(walltime_guard_policy.get('min_seconds', DEFAULT_WALLTIME_GUARD_POLICY['min_seconds']))}",
8606 f"-walltime_guard_estimator_alpha {float(walltime_guard_policy.get('estimator_alpha', DEFAULT_WALLTIME_GUARD_POLICY['estimator_alpha']))}",
8607 ]
8608 )
8609
8610 grid_cfg = case_cfg.get('grid', {})
8611 grid_mode = grid_cfg.get('mode')
8612 expected_nblk = int(case_cfg.get('models', {}).get('domain', {}).get('blocks', 1))
8613
8614 if grid_mode == 'file':
8615 print("[INFO] Grid Mode: Using external file...")
8616 case_file_dir = os.path.dirname(configs['case_path'])
8617 source_grid = grid_cfg['source_file']
8618 if not os.path.isabs(source_grid):
8619 source_grid = os.path.abspath(os.path.join(case_file_dir, source_grid))
8620 grid_for_validation = source_grid
8621 legacy_cfg = grid_cfg.get("legacy_conversion")
8622 if isinstance(legacy_cfg, dict):
8623 if legacy_cfg.get("enabled", True):
8624 print("[INFO] Grid file legacy conversion enabled; converting with grid.gen...")
8625 grid_for_validation = convert_legacy_grid_with_gridgen(
8626 configs['case_path'],
8627 run_dir,
8628 grid_cfg,
8629 source_grid,
8630 )
8631 nondim_grid_path = os.path.join(run_dir, "config", "grid.run")
8632 try:
8633 summary = validate_and_nondimensionalize_picgrid(
8634 grid_for_validation, nondim_grid_path, L_ref, expected_nblk=expected_nblk
8635 )
8636 print(
8637 f"[SUCCESS] Validated and non-dimensionalized grid: {os.path.relpath(nondim_grid_path)} "
8638 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8639 )
8640 control_lines.append(f"-grid_file {nondim_grid_path}")
8641 except Exception as e:
8642 print(f"[FATAL] Failed to process grid file '{source_grid}': {e}", file=sys.stderr)
8643 sys.exit(1)
8644 elif grid_mode == 'grid_gen':
8645 print("[INFO] Grid Mode: Generating external grid via grid.gen...")
8646 nondim_grid_path = os.path.join(run_dir, "config", "grid.run")
8647 if continue_mode and os.path.isfile(nondim_grid_path):
8648 print(f"[INFO] Continue mode: reusing staged grid: {os.path.relpath(nondim_grid_path)}")
8649 control_lines.append(f"-grid_file {nondim_grid_path}")
8650 else:
8651 try:
8652 # grid.gen already converts ncells_* inputs into node-count PICGRID dims.
8653 generated_grid = run_grid_generator(configs['case_path'], run_dir, grid_cfg)
8654 summary = validate_and_nondimensionalize_picgrid(
8655 generated_grid, nondim_grid_path, L_ref, expected_nblk=expected_nblk
8656 )
8657 print(
8658 f"[SUCCESS] grid.gen output validated and non-dimensionalized: {os.path.relpath(nondim_grid_path)} "
8659 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8660 )
8661 control_lines.append(f"-grid_file {nondim_grid_path}")
8662 except Exception as e:
8663 print(f"[FATAL] Grid generation failed: {e}", file=sys.stderr)
8664 sys.exit(1)
8665 elif grid_mode == 'programmatic_c':
8666 print("[INFO] Grid Mode: Programmatic C...")
8667 grid_settings = translate_programmatic_grid_settings(grid_cfg.get('programmatic_settings', {}))
8668 control_lines.append("-grid")
8669 for p_key in GRID_DA_PROCESSOR_KEYS:
8670 grid_settings.pop(p_key, None)
8671 for key, value in grid_settings.items(): control_lines.append(f"-{key} {format_flag_value(value)}")
8672 if resolved_ic["kind"] == "ic_gen" and ic_is_authoritative:
8673 nondim_grid_path = os.path.join(run_dir, "config", "grid.run")
8674 try:
8675 summary = generate_picgrid_from_programmatic_settings(
8676 grid_cfg.get('programmatic_settings', {}), nondim_grid_path, L_ref
8677 )
8678 print(
8679 f"[INFO] Materialized grid.run for ic_gen: {os.path.relpath(nondim_grid_path)} "
8680 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
8681 )
8682 except Exception as e:
8683 print(f"[FATAL] Failed to generate grid.run for ic_gen: {e}", file=sys.stderr)
8684 sys.exit(1)
8685 else:
8686 raise ValueError(f"Unknown or missing grid mode '{grid_mode}' in case.yml.")
8687
8688 if resolved_ic["kind"] in {"file", "ic_gen"}:
8689 if ic_is_authoritative:
8690 try:
8691 staged_ic = stage_initial_condition_file(run_dir, configs["case_path"], resolved_ic)
8692 except Exception as e:
8693 print(f"[FATAL] Failed to stage initial condition: {e}", file=sys.stderr)
8694 sys.exit(1)
8695 control_lines.extend([
8696 f"-ic_field {resolved_ic['field_code']}",
8697 f"-ic_dir {staged_ic['directory']}",
8698 ])
8699 print(f" - Staged initial condition: {os.path.relpath(staged_ic['staged'])}")
8700
8701 try:
8702 bcs_files = generate_multi_block_bcs(run_dir, run_id, case_cfg, source_files)
8703 except ValueError as e:
8704 print(f"[FATAL] Invalid boundary_conditions in case.yml: {e}", file=sys.stderr)
8705 sys.exit(1)
8706 control_lines.append(f"-bcs_files \"{','.join(bcs_files)}\"")
8707
8708 append_grid_da_processor_layout(control_lines, grid_cfg, num_procs)
8709
8710 parse_and_add_model_flags(case_cfg, control_lines)
8711
8712 if 'solver_parameters' in case_cfg:
8713 params = case_cfg['solver_parameters']
8714 if params:
8715 for key, value in params.items():
8716 control_lines.append(f"{key} {format_flag_value(value)}")
8717
8718 try:
8719 solver_flags = parse_solver_config(solver_cfg)
8720 except ValueError as e:
8721 print(f"[FATAL] Invalid solver.yml settings: {e}", file=sys.stderr)
8722 sys.exit(1)
8723 append_passthrough_flags(control_lines, solver_flags)
8724
8725 try:
8726 solver_monitoring_flags = resolve_solver_monitoring_flags(monitor_cfg)
8727 except ValueError as e:
8728 print(f"[FATAL] Invalid monitor.yml solver_monitoring settings: {e}", file=sys.stderr)
8729 sys.exit(1)
8730 append_passthrough_flags(control_lines, solver_monitoring_flags)
8731
8732 io_cfg = monitor_cfg.get('io', {})
8733 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
8734 if 'data_output_frequency' in io_cfg: control_lines.append(f"-tio {io_cfg['data_output_frequency']}")
8735 if particle_console_output_freq is not None:
8736 control_lines.append(f"-particle_console_output_freq {particle_console_output_freq}")
8737 if 'particle_log_interval' in io_cfg: control_lines.append(f"-logfreq {io_cfg['particle_log_interval']}")
8738 if 'directories' in io_cfg:
8739 dirs = io_cfg['directories']
8740 if 'output' in dirs: control_lines.append(f"-output_dir {dirs['output']}")
8741 if 'restart' in dirs and not restart_source_dir:
8742 control_lines.append(f"-restart_dir {dirs['restart']}")
8743 if 'log' in dirs: control_lines.append(f"-log_dir {dirs['log']}")
8744 if 'eulerian_subdir' in dirs: control_lines.append(f"-euler_subdir {dirs['eulerian_subdir']}")
8745 if 'particle_subdir' in dirs: control_lines.append(f"-particle_subdir {dirs['particle_subdir']}")
8746 if restart_source_dir:
8747 control_lines.append(f"-restart_dir {restart_source_dir}")
8748 if continue_mode:
8749 control_lines.append("-continue_mode true")
8750
8751 final_content = generate_header(run_id, source_files) + "\n".join(control_lines)
8752 control_file_path = os.path.join(run_dir, "config", f"{run_id}.control")
8753 with open(control_file_path, "w") as f: f.write(final_content)
8754 print(f"[SUCCESS] Generated solver control file: {os.path.relpath(control_file_path)}")
8755 return os.path.abspath(control_file_path)
8756
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 8757 of file core.py.

8757def generate_post_recipe_file(run_dir: str, run_id: str, post_cfg: dict, source_files: dict, monitor_cfg=None) -> str:
8758 """!
8759 @brief Generates a key=value config file (post.run) for the C post-processor.
8760 @details Translates the structured post-processing YAML into the specific flat
8761 key-value format required by the C executable, including complex,
8762 semicolon-separated pipeline strings.
8763 @param[in] run_dir The path to the main run directory.
8764 @param[in] run_id The unique identifier for the run.
8765 @param[in] post_cfg The parsed post-profile YAML configuration dictionary.
8766 @param[in] source_files A dictionary of source files for the header.
8767 @param[in] monitor_cfg Optional parsed monitor YAML configuration dictionary.
8768 @return The absolute path to the generated post.run recipe file.
8769 """
8770 print("[INFO] Generating post-processor recipe file (post.run)...")
8771 config_dir = os.path.join(run_dir, "config")
8772 post_recipe_path = os.path.join(config_dir, "post.run")
8773
8774 lines = [generate_header(run_id, source_files)]
8775 c_config = build_post_recipe_config(post_cfg, monitor_cfg)
8776
8777 for key, value in c_config.items():
8778 if value is not None and str(value) != "":
8779 lines.append(f"{key} = {value}")
8780
8781 with open(post_recipe_path, "w") as f:
8782 f.write("\n".join(lines))
8783 print(f"[SUCCESS] Generated post-processor recipe: {os.path.relpath(post_recipe_path)}")
8784 return os.path.abspath(post_recipe_path)
8785
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 8786 of file core.py.

8786def execute_command(command: list, run_dir: str, log_filename: str, monitor_cfg: dict = None):
8787 """!
8788 @brief Executes a command, streaming its output to the console and a log file.
8789 @details ...
8790 If None, the process inherits the parent's environment directly.
8791 @param[in] command Argument passed to `execute_command()`.
8792 @param[in] run_dir Argument passed to `execute_command()`.
8793 @param[in] log_filename Argument passed to `execute_command()`.
8794 @param[in] monitor_cfg Argument passed to `execute_command()`.
8795 """
8796 log_path = resolve_command_log_path(run_dir, log_filename)
8797 os.makedirs(os.path.dirname(log_path), exist_ok=True)
8798
8799 print(f"[INFO] Launching Command...\n > {format_command_for_display(command)}")
8800 print(f" Log file: {os.path.relpath(log_path)}")
8801 print("-" * 60)
8802
8803 # --- Environment Handling ---
8804 popen_kwargs = {
8805 "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT,
8806 "cwd": run_dir, "bufsize": 1, "universal_newlines": True,
8807 "encoding": 'utf-8', "errors": 'replace'
8808 }
8809
8810 if monitor_cfg:
8811 print("[INFO] Creating custom environment to set LOG_LEVEL.")
8812 run_env = os.environ.copy()
8813 verbosity = monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()
8814 run_env['LOG_LEVEL'] = verbosity
8815 print(f"[INFO] Setting LOG_LEVEL={verbosity} for C executable.")
8816 popen_kwargs['env'] = run_env
8817 else:
8818 print("[INFO] Using inherited environment for process.")
8819
8820 print("-" * 60)
8821 try:
8822 # Pass the constructed keyword arguments dictionary to Popen
8823 process = subprocess.Popen(command, **popen_kwargs)
8824
8825 with open(log_path, "w") as log_file:
8826 for line in process.stdout:
8827 sys.stdout.write(line)
8828 log_file.write(line)
8829 process.wait()
8830 return_code = process.returncode
8831 print("-" * 60)
8832 if return_code == 0:
8833 print(f"[SUCCESS] Execution finished successfully.")
8834 else:
8835 print(f"[FATAL] Execution failed with exit code {return_code}. Check log: {os.path.relpath(log_path)}", file=sys.stderr)
8836 sys.exit(return_code)
8837 except FileNotFoundError:
8838 print(f"[FATAL] Command not found or is not executable: '{command[0]}'", file=sys.stderr)
8839 print(" Please check that the path is correct and the file has execute permissions.", file=sys.stderr)
8840 sys.exit(1)
8841 except Exception as e:
8842 print(f"[FATAL] An unexpected error occurred during execution: {e}", file=sys.stderr)
8843 sys.exit(1)
8844
8845
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 8846 of file core.py.

8846def format_command_for_display(command: list) -> str:
8847 """!
8848 @brief Render a shell-safe command string for console and log output.
8849 @param[in] command Argument passed to `format_command_for_display()`.
8850 @return Value returned by `format_command_for_display()`.
8851 """
8852 return " ".join(shlex.quote(str(part)) for part in command)
8853
8854
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 8855 of file core.py.

8855def resolve_command_log_path(run_dir: str, log_filename: str) -> str:
8856 """!
8857 @brief Resolve a command log filename relative to the run directory.
8858 @param[in] run_dir Argument passed to `resolve_command_log_path()`.
8859 @param[in] log_filename Argument passed to `resolve_command_log_path()`.
8860 @return Value returned by `resolve_command_log_path()`.
8861 """
8862 if os.path.dirname(log_filename):
8863 return os.path.join(run_dir, log_filename)
8864 return os.path.join(run_dir, "logs", log_filename)
8865
8866
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 8894 of file core.py.

8894def _run_captured_command(command: list, run_dir: str) -> subprocess.CompletedProcess:
8895 """!
8896 @brief Run a command and capture combined stdout/stderr details for later inspection.
8897 @param[in] command Argument passed to `_run_captured_command()`.
8898 @param[in] run_dir Argument passed to `_run_captured_command()`.
8899 @return Value returned by `_run_captured_command()`.
8900 """
8901 try:
8902 return subprocess.run(
8903 command,
8904 cwd=run_dir,
8905 text=True,
8906 capture_output=True,
8907 check=False,
8908 encoding="utf-8",
8909 errors="replace",
8910 )
8911 except FileNotFoundError as exc:
8912 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
8913
8914
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 8915 of file core.py.

8915def _require_successful_command(command: list, result: subprocess.CompletedProcess):
8916 """!
8917 @brief Raise `CommandExecutionError` when a captured command failed.
8918 @param[in] command Argument passed to `_require_successful_command()`.
8919 @param[in] result Argument passed to `_require_successful_command()`.
8920 """
8921 if result.returncode == 0:
8922 return
8923 details = (result.stderr or result.stdout).strip()
8924 raise CommandExecutionError(command, result.returncode, details or None)
8925
8926
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 8927 of file core.py.

8927def _capture_command_stdout(command: list, run_dir: str) -> str:
8928 """!
8929 @brief Run a command, require success, and return stripped stdout text.
8930 @param[in] command Argument passed to `_capture_command_stdout()`.
8931 @param[in] run_dir Argument passed to `_capture_command_stdout()`.
8932 @return Value returned by `_capture_command_stdout()`.
8933 """
8934 result = _run_captured_command(command, run_dir)
8935 _require_successful_command(command, result)
8936 return result.stdout.strip()
8937
8938
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 8939 of file core.py.

8939def _stream_command_to_console_and_log(command: list, run_dir: str, log_file):
8940 """!
8941 @brief Stream command output to stdout and an already-open log file.
8942 @param[in] command Argument passed to `_stream_command_to_console_and_log()`.
8943 @param[in] run_dir Argument passed to `_stream_command_to_console_and_log()`.
8944 @param[in] log_file Argument passed to `_stream_command_to_console_and_log()`.
8945 """
8946 display = format_command_for_display(command)
8947 print(f"[INFO] Running: {display}")
8948 log_file.write(f"$ {display}\n")
8949 log_file.flush()
8950
8951 popen_kwargs = {
8952 "stdout": subprocess.PIPE,
8953 "stderr": subprocess.STDOUT,
8954 "cwd": run_dir,
8955 "bufsize": 1,
8956 "universal_newlines": True,
8957 "encoding": "utf-8",
8958 "errors": "replace",
8959 }
8960
8961 try:
8962 process = subprocess.Popen(command, **popen_kwargs)
8963 except FileNotFoundError as exc:
8964 raise CommandExecutionError(command, 1, f"Command not found or is not executable: '{command[0]}'") from exc
8965
8966 with process:
8967 for line in process.stdout:
8968 sys.stdout.write(line)
8969 log_file.write(line)
8970 return_code = process.wait()
8971 log_file.write("\n")
8972 log_file.flush()
8973 if return_code != 0:
8974 raise CommandExecutionError(command, return_code)
8975
8976
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 8977 of file core.py.

8977def _get_git_head_state(run_dir: str) -> dict:
8978 """!
8979 @brief Capture the current git HEAD branch name and commit hash.
8980 @param[in] run_dir Argument passed to `_get_git_head_state()`.
8981 @return Value returned by `_get_git_head_state()`.
8982 """
8983 head_commit = _capture_command_stdout(["git", "rev-parse", "--verify", "HEAD"], run_dir)
8984 branch_result = _run_captured_command(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], run_dir)
8985 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
8986 return {"branch": branch_name, "commit": head_commit}
8987
8988
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 8989 of file core.py.

8989def _get_local_branches_with_upstreams(run_dir: str) -> "list[tuple[str, str | None]]":
8990 """!
8991 @brief Return local branch names plus their configured upstreams.
8992 @param[in] run_dir Argument passed to `_get_local_branches_with_upstreams()`.
8993 @return Value returned by `_get_local_branches_with_upstreams()`.
8994 """
8995 output = _capture_command_stdout(
8996 ["git", "for-each-ref", "--sort=refname", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
8997 run_dir,
8998 )
8999 branches = []
9000 for line in output.splitlines():
9001 if not line.strip():
9002 continue
9003 branch_name, _, upstream_name = line.partition("\t")
9004 branches.append((branch_name, upstream_name or None))
9005 return branches
9006
9007
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 9008 of file core.py.

9008def _working_tree_has_tracked_changes(run_dir: str) -> bool:
9009 """!
9010 @brief Return `True` when the repository has staged or unstaged tracked changes.
9011 @param[in] run_dir Argument passed to `_working_tree_has_tracked_changes()`.
9012 @return Value returned by `_working_tree_has_tracked_changes()`.
9013 """
9014 command = ["git", "status", "--porcelain", "--untracked-files=no"]
9015 result = _run_captured_command(command, run_dir)
9016 _require_successful_command(command, result)
9017 return bool(result.stdout.strip())
9018
9019
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 9020 of file core.py.

9020def _attempt_pull_cleanup(run_dir: str, rebase: bool, log_file):
9021 """!
9022 @brief Best-effort cleanup after a failed `git pull` so the original branch can be restored.
9023 @param[in] run_dir Argument passed to `_attempt_pull_cleanup()`.
9024 @param[in] rebase Argument passed to `_attempt_pull_cleanup()`.
9025 @param[in] log_file Argument passed to `_attempt_pull_cleanup()`.
9026 """
9027 cleanup_command = ["git", "rebase", "--abort"] if rebase else ["git", "merge", "--abort"]
9028 result = _run_captured_command(cleanup_command, run_dir)
9029 if result.returncode == 0:
9030 print(f"[INFO] Cleaned up the interrupted {'rebase' if rebase else 'merge'} state.")
9031 log_file.write(f"$ {format_command_for_display(cleanup_command)}\n")
9032 if result.stdout:
9033 sys.stdout.write(result.stdout)
9034 log_file.write(result.stdout)
9035 if result.stderr:
9036 sys.stderr.write(result.stderr)
9037 log_file.write(result.stderr)
9038 log_file.write("\n")
9039 log_file.flush()
9040 return
9041
9042 details = (result.stderr or result.stdout).strip()
9043 if details:
9044 message = (
9045 f"[WARNING] Could not clean up a failed {'rebase' if rebase else 'merge'} automatically: {details}"
9046 )
9047 print(message, file=sys.stderr)
9048 log_file.write(message + "\n")
9049 log_file.flush()
9050
9051
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 9052 of file core.py.

9052def _restore_git_head(run_dir: str, original_head: dict, log_file):
9053 """!
9054 @brief Restore the repository back to the branch or detached commit it started on.
9055 @param[in] run_dir Argument passed to `_restore_git_head()`.
9056 @param[in] original_head Argument passed to `_restore_git_head()`.
9057 @param[in] log_file Argument passed to `_restore_git_head()`.
9058 """
9059 current_state = _get_git_head_state(run_dir)
9060 if original_head["branch"]:
9061 if current_state["branch"] == original_head["branch"]:
9062 return
9063 _stream_command_to_console_and_log(["git", "checkout", original_head["branch"]], run_dir, log_file)
9064 return
9065
9066 if current_state["branch"] is None and current_state["commit"] == original_head["commit"]:
9067 return
9068 _stream_command_to_console_and_log(["git", "checkout", "--detach", original_head["commit"]], run_dir, log_file)
9069
9070
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 9071 of file core.py.

9071def pull_all_source_branches(run_dir: str, log_filename: str, rebase: bool = True):
9072 """!
9073 @brief Refresh every local tracking branch in the source repository, then restore the starting branch.
9074 @param[in] run_dir Argument passed to `pull_all_source_branches()`.
9075 @param[in] log_filename Argument passed to `pull_all_source_branches()`.
9076 @param[in] rebase Argument passed to `pull_all_source_branches()`.
9077 """
9078 log_path = resolve_command_log_path(run_dir, log_filename)
9079 os.makedirs(os.path.dirname(log_path), exist_ok=True)
9080
9081 print("\n" + "="*23 + " PULL SOURCE STAGE " + "="*22)
9082 print("[INFO] Refreshing all local source branches that track an upstream.")
9083 print(f" Log file: {os.path.relpath(log_path)}")
9084 print("-" * 60)
9085
9086 try:
9087 original_head = _get_git_head_state(run_dir)
9088 if _working_tree_has_tracked_changes(run_dir):
9089 raise RuntimeError(
9090 "Multi-branch pull requires a clean tracked working tree in the source repository. "
9091 "Commit or stash those changes first, or rerun with --current-branch-only."
9092 )
9093 branches = _get_local_branches_with_upstreams(run_dir)
9094 except (CommandExecutionError, RuntimeError) as exc:
9095 print(f"[FATAL] {exc}", file=sys.stderr)
9096 sys.exit(getattr(exc, "returncode", 1))
9097
9098 if not branches:
9099 print("[FATAL] No local branches were found in the source repository.", file=sys.stderr)
9100 sys.exit(1)
9101
9102 if original_head["branch"]:
9103 branches = [item for item in branches if item[0] != original_head["branch"]] + [
9104 item for item in branches if item[0] == original_head["branch"]
9105 ]
9106
9107 skipped_branches = []
9108 current_operation = None
9109 pull_error = None
9110 restore_error = None
9111
9112 with open(log_path, "w", encoding="utf-8") as log_file:
9113 log_file.write(f"# PICurv pull-source all-branch sync\n")
9114 log_file.write(f"# repository: {os.path.abspath(run_dir)}\n")
9115 log_file.write(f"# started: {datetime.now().isoformat()}\n")
9116 log_file.write(
9117 f"# original head: {original_head['branch'] if original_head['branch'] else original_head['commit']}\n\n"
9118 )
9119
9120 try:
9121 for branch_name, upstream_name in branches:
9122 if not upstream_name:
9123 warning = f"[WARNING] Skipping branch '{branch_name}' because it has no configured upstream."
9124 print(warning, file=sys.stderr)
9125 log_file.write(warning + "\n")
9126 skipped_branches.append(branch_name)
9127 continue
9128
9129 print(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.")
9130 log_file.write(f"[INFO] Refreshing branch '{branch_name}' from '{upstream_name}'.\n")
9131
9132 current_operation = f"checkout:{branch_name}"
9133 _stream_command_to_console_and_log(["git", "checkout", branch_name], run_dir, log_file)
9134
9135 pull_command = ["git", "pull"]
9136 if rebase:
9137 pull_command.append("--rebase")
9138 current_operation = f"pull:{branch_name}"
9139 _stream_command_to_console_and_log(pull_command, run_dir, log_file)
9140 current_operation = None
9141 except CommandExecutionError as exc:
9142 pull_error = exc
9143 if current_operation and current_operation.startswith("pull:"):
9144 _attempt_pull_cleanup(run_dir, rebase, log_file)
9145 finally:
9146 try:
9147 _restore_git_head(run_dir, original_head, log_file)
9148 except CommandExecutionError as exc:
9149 restore_error = exc
9150
9151 print("-" * 60)
9152 if pull_error:
9153 if restore_error:
9154 print(
9155 f"[FATAL] Multi-branch pull failed and the original branch could not be restored. "
9156 f"Check log: {os.path.relpath(log_path)}",
9157 file=sys.stderr,
9158 )
9159 sys.exit(restore_error.returncode)
9160 print(
9161 f"[FATAL] Multi-branch pull failed. Original branch restored. "
9162 f"Check log: {os.path.relpath(log_path)}",
9163 file=sys.stderr,
9164 )
9165 sys.exit(pull_error.returncode)
9166
9167 if restore_error:
9168 print(
9169 f"[FATAL] Branch updates completed, but the original branch could not be restored. "
9170 f"Check log: {os.path.relpath(log_path)}",
9171 file=sys.stderr,
9172 )
9173 sys.exit(restore_error.returncode)
9174
9175 if skipped_branches:
9176 print(f"[WARNING] Skipped branches with no upstream: {', '.join(skipped_branches)}", file=sys.stderr)
9177 print("[SUCCESS] All local tracking branches are up to date.")
9178
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 9179 of file core.py.

9179def auto_identify_run_inputs(config_dir: str):
9180 """!
9181 @brief Auto-detect case.yml, monitor.yml, and *.control in a run config directory.
9182 @param[in] config_dir Argument passed to `auto_identify_run_inputs()`.
9183 @return Value returned by `auto_identify_run_inputs()`.
9184 """
9185 all_yml_files = glob.glob(os.path.join(config_dir, "*.yml"))
9186 case_path, monitor_path = None, None
9187 for f_path in all_yml_files:
9188 try:
9189 content = read_yaml_file(f_path)
9190 if not isinstance(content, dict):
9191 continue
9192 if 'models' in content and 'boundary_conditions' in content:
9193 case_path = f_path
9194 elif 'io' in content and 'logging' in content:
9195 monitor_path = f_path
9196 except Exception as e:
9197 print(f"[WARNING] Could not parse or inspect '{f_path}': {e}", file=sys.stderr)
9198 try:
9199 solver_control_path = glob.glob(os.path.join(config_dir, "*.control"))[0]
9200 except IndexError:
9201 solver_control_path = None
9202 return case_path, monitor_path, solver_control_path
9203
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 9204 of file core.py.

9204def resolve_post_source_directory(run_dir: str, monitor_cfg: dict, post_cfg: dict, strict: bool = True) -> str:
9205 """!
9206 @brief Resolve post source directory token and optionally enforce existence.
9207 @param[in] run_dir Argument passed to `resolve_post_source_directory()`.
9208 @param[in] monitor_cfg Argument passed to `resolve_post_source_directory()`.
9209 @param[in] post_cfg Argument passed to `resolve_post_source_directory()`.
9210 @param[in] strict Argument passed to `resolve_post_source_directory()`.
9211 @return Value returned by `resolve_post_source_directory()`.
9212 """
9213 solver_output_dir_rel = monitor_cfg.get('io', {}).get('directories', {}).get('output', 'output')
9214 solver_output_dir_abs = os.path.join(run_dir, solver_output_dir_rel)
9215 source_dir_template = get_post_source_directory_template(post_cfg)
9216 if source_dir_template == '<solver_output_dir>':
9217 resolved_source_dir = solver_output_dir_abs
9218 print(f"[INFO] Post-processor source data: {os.path.relpath(resolved_source_dir)}")
9219 else:
9220 resolved_source_dir = os.path.abspath(os.path.join(run_dir, source_dir_template))
9221 print(f"[INFO] Post-processor source data (user-defined): {os.path.relpath(resolved_source_dir)}")
9222
9223 if strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
9224 print(
9225 f"[FATAL] Source data directory for post-processing not found or empty: {os.path.relpath(resolved_source_dir)}",
9226 file=sys.stderr
9227 )
9228 sys.exit(1)
9229 if not strict and (not os.path.isdir(resolved_source_dir) or not os.listdir(resolved_source_dir)):
9230 print("[WARNING] Source data directory is not available yet; keeping deferred path for scheduled post job.")
9231 return resolved_source_dir
9232
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 9233 of file core.py.

9244):
9245 """!
9246 @brief Render array script that maps SLURM_ARRAY_TASK_ID to per-case run artifacts.
9247 @param[in] script_path Argument passed to `render_slurm_array_stage_script()`.
9248 @param[in] job_name Argument passed to `render_slurm_array_stage_script()`.
9249 @param[in] cluster_cfg Argument passed to `render_slurm_array_stage_script()`.
9250 @param[in] array_spec Argument passed to `render_slurm_array_stage_script()`.
9251 @param[in] case_index_tsv Argument passed to `render_slurm_array_stage_script()`.
9252 @param[in] stage Argument passed to `render_slurm_array_stage_script()`.
9253 @param[in] solver_exe Argument passed to `render_slurm_array_stage_script()`.
9254 @param[in] post_exe Argument passed to `render_slurm_array_stage_script()`.
9255 @param[in] stdout_path Argument passed to `render_slurm_array_stage_script()`.
9256 @param[in] stderr_path Argument passed to `render_slurm_array_stage_script()`.
9257 @return Value returned by `render_slurm_array_stage_script()`.
9258 """
9259 effective_cluster_cfg = build_serial_post_cluster_config(cluster_cfg) if stage == "post" else cluster_cfg
9260 resources = effective_cluster_cfg.get("resources", {})
9261 notifications = effective_cluster_cfg.get("notifications", {}) or {}
9262 execution = effective_cluster_cfg.get("execution", {}) or {}
9263 module_setup = execution.get("module_setup", []) or []
9264 extra_sbatch = execution.get("extra_sbatch")
9265
9266 lines = [
9267 "#!/bin/bash",
9268 f"#SBATCH --job-name={job_name}",
9269 f"#SBATCH --nodes={resources['nodes']}",
9270 f"#SBATCH --ntasks-per-node={resources['ntasks_per_node']}",
9271 f"#SBATCH --mem={resources['mem']}",
9272 f"#SBATCH --time={resources['time']}",
9273 f"#SBATCH --output={stdout_path}",
9274 f"#SBATCH --error={stderr_path}",
9275 f"#SBATCH --account={resources['account']}",
9276 f"#SBATCH --array={array_spec}",
9277 ]
9278 partition = resources.get("partition")
9279 if partition:
9280 lines.append(f"#SBATCH --partition={partition}")
9281 mail_user = notifications.get("mail_user")
9282 mail_type = notifications.get("mail_type")
9283 if mail_user:
9284 lines.append(f"#SBATCH --mail-user={mail_user}")
9285 if mail_type:
9286 lines.append(f"#SBATCH --mail-type={mail_type}")
9287 if isinstance(extra_sbatch, dict):
9288 for key, value in extra_sbatch.items():
9289 flag = str(key)
9290 if not flag.startswith("--"):
9291 flag = f"--{flag}"
9292 if isinstance(value, bool):
9293 if value:
9294 lines.append(f"#SBATCH {flag}")
9295 elif value is not None:
9296 lines.append(f"#SBATCH {flag}={value}")
9297 elif isinstance(extra_sbatch, list):
9298 for token in extra_sbatch:
9299 lines.append(f"#SBATCH {token}")
9300
9301 lines.extend([
9302 "",
9303 "set -euo pipefail",
9304 "",
9305 f'CASE_INDEX_FILE={shlex.quote(case_index_tsv)}',
9306 'LINE=$(sed -n "$((SLURM_ARRAY_TASK_ID + 1))p" "$CASE_INDEX_FILE")',
9307 'if [ -z "$LINE" ]; then',
9308 ' echo "No case entry for array index ${SLURM_ARRAY_TASK_ID}" >&2',
9309 ' exit 1',
9310 "fi",
9311 "IFS=$'\\t' read -r CASE_INDEX CASE_ID RUN_DIR CONTROL_FILE POST_RECIPE_FILE LOG_LEVEL POST_PREFIX SOLVE_DIAGNOSTIC_ARGS POST_DIAGNOSTIC_ARGS <<< \"$LINE\"",
9312 'cd "$RUN_DIR"',
9313 'echo "[$(date)] Starting case ${CASE_ID} (array index ${SLURM_ARRAY_TASK_ID})"',
9314 ])
9315
9316 if stage == "solve":
9317 walltime_guard_exports = build_walltime_guard_exports(effective_cluster_cfg)
9318 for key, value in walltime_guard_exports.items():
9319 lines.append(f"export {key}={value}")
9320
9321 lines.append('export LOG_LEVEL="${LOG_LEVEL}"')
9322
9323 for setup_line in module_setup:
9324 lines.append(str(setup_line))
9325
9326 if stage == "solve":
9327 cmd = build_cluster_launch_command(
9328 effective_cluster_cfg,
9329 solver_exe,
9330 ["-control_file", "$CONTROL_FILE"]
9331 )
9332 else:
9333 cmd = build_cluster_launch_command(
9334 effective_cluster_cfg,
9335 post_exe,
9336 ["-control_file", "$CONTROL_FILE", "-postprocessing_config_file", "$POST_RECIPE_FILE"],
9337 force_num_procs=1,
9338 )
9339
9340 # Keep shell variables unresolved inside sbatch script.
9341 def _token(tok: str) -> str:
9342 """!
9343 @brief Perform token.
9344 @param[in] tok Argument passed to `_token()`.
9345 @return Value returned by `_token()`.
9346 """
9347 if tok.startswith("$"):
9348 return tok
9349 return shlex.quote(str(tok))
9350
9351 diag_var = "${SOLVE_DIAGNOSTIC_ARGS}" if stage == "solve" else "${POST_DIAGNOSTIC_ARGS}"
9352 command_text = " ".join(_token(t) for t in cmd)
9353 executable_token = _token(solver_exe if stage == "solve" else post_exe)
9354 # Diagnostic args must be executable args, not launcher args. Insert them
9355 # immediately before the executable's normal control/recipe options.
9356 if executable_token and command_text.count(executable_token) == 1:
9357 command_text = command_text.replace(f"{executable_token} ", f"{executable_token} {diag_var} ", 1)
9358 lines.append(f"exec {command_text}")
9359
9360 os.makedirs(os.path.dirname(script_path), exist_ok=True)
9361 with open(script_path, "w") as f:
9362 f.write("\n".join(lines) + "\n")
9363 os.chmod(script_path, 0o755)
9364
9365
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 9366 of file core.py.

9372):
9373 """!
9374 @brief Generate a single-node sbatch script that runs metrics aggregation.
9375 @param[in] script_path Path to write the sbatch script.
9376 @param[in] job_name Slurm job name.
9377 @param[in] cluster_cfg Parsed cluster YAML dictionary.
9378 @param[in] study_dir Absolute path to the study directory.
9379 @param[in] picurv_path Absolute path to the picurv script.
9380 """
9381 resources = cluster_cfg.get("resources", {})
9382 notifications = cluster_cfg.get("notifications", {}) or {}
9383 execution = cluster_cfg.get("execution", {}) or {}
9384 module_setup = execution.get("module_setup", []) or []
9385
9386 scheduler_dir = os.path.join(study_dir, "scheduler")
9387 lines = [
9388 "#!/bin/bash",
9389 f"#SBATCH --job-name={job_name}",
9390 "#SBATCH --nodes=1",
9391 "#SBATCH --ntasks-per-node=1",
9392 "#SBATCH --mem=4G",
9393 "#SBATCH --time=00:10:00",
9394 f"#SBATCH --output={os.path.join(scheduler_dir, 'metrics_%j.out')}",
9395 f"#SBATCH --error={os.path.join(scheduler_dir, 'metrics_%j.err')}",
9396 f"#SBATCH --account={resources['account']}",
9397 ]
9398 partition = resources.get("partition")
9399 if partition:
9400 lines.append(f"#SBATCH --partition={partition}")
9401 mail_user = notifications.get("mail_user")
9402 mail_type = notifications.get("mail_type")
9403 if mail_user:
9404 lines.append(f"#SBATCH --mail-user={mail_user}")
9405 if mail_type:
9406 lines.append(f"#SBATCH --mail-type={mail_type}")
9407
9408 lines.extend([
9409 "",
9410 "set -euo pipefail",
9411 'echo "[$(date)] Running metrics aggregation"',
9412 "",
9413 ])
9414 for setup_line in module_setup:
9415 lines.append(str(setup_line))
9416
9417 lines.append(
9418 f"exec {shlex.quote(picurv_path)} sweep --reaggregate"
9419 f" --study-dir {shlex.quote(study_dir)}"
9420 )
9421
9422 os.makedirs(os.path.dirname(script_path), exist_ok=True)
9423 with open(script_path, "w") as f:
9424 f.write("\n".join(lines) + "\n")
9425 os.chmod(script_path, 0o755)
9426
9427
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 9428 of file core.py.

9428def reduce_metric_values(values, reduction: str):
9429 """!
9430 @brief Reduce a metric series to one scalar according to the requested reducer.
9431 @param[in] values Sequence of numeric values.
9432 @param[in] reduction Reduction keyword.
9433 @return Value returned by `reduce_metric_values()`.
9434 """
9435 if not values:
9436 return None
9437 np = require_numpy()
9438 reduction = str(reduction).lower()
9439 if reduction == "mean":
9440 return float(np.mean(values))
9441 if reduction == "min":
9442 return float(np.min(values))
9443 if reduction == "max":
9444 return float(np.max(values))
9445 if reduction == "p95":
9446 return float(np.percentile(values, 95.0))
9447 return float(values[-1])
9448
9449
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 9450 of file core.py.

9450def extract_metric_from_csv(case_dir: str, spec: dict):
9451 """!
9452 @brief Extract a scalar metric from a CSV source.
9453 @param[in] case_dir Argument passed to `extract_metric_from_csv()`.
9454 @param[in] spec Argument passed to `extract_metric_from_csv()`.
9455 @return Value returned by `extract_metric_from_csv()`.
9456 """
9457 file_glob = spec.get("file_glob", "**/*_msd.csv")
9458 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
9459 if not candidates:
9460 return None
9461 csv_path = candidates[0]
9462 rows = []
9463 with open(csv_path, "r", newline="") as f:
9464 reader = csv.DictReader(f)
9465 if reader.fieldnames:
9466 for row in reader:
9467 rows.append(row)
9468 if not rows:
9469 return None
9470 column = spec.get("column")
9471 numerator_column = spec.get("numerator_column")
9472 denominator_column = spec.get("denominator_column")
9473 denominator_floor = float(spec.get("denominator_floor", 0.0) or 0.0)
9474 if not column and not numerator_column:
9475 for name in reversed(reader.fieldnames):
9476 if name and name.lower() not in {"step", "time", "timestep"}:
9477 column = name
9478 break
9479 if not column and not numerator_column:
9480 return None
9481 values = []
9482 for row in rows:
9483 try:
9484 if numerator_column:
9485 numerator = float(row[numerator_column])
9486 denominator = float(row[denominator_column])
9487 denominator = max(denominator_floor, denominator)
9488 if denominator == 0.0:
9489 continue
9490 values.append(numerator / denominator)
9491 else:
9492 values.append(float(row[column]))
9493 except Exception:
9494 continue
9495 else:
9496 return None
9497 return reduce_metric_values(values, spec.get("reduction", "last"))
9498
9499
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 9500 of file core.py.

9500def extract_metric_from_log(case_dir: str, spec: dict):
9501 """!
9502 @brief Extract a scalar metric from a log file using regex.
9503 @param[in] case_dir Argument passed to `extract_metric_from_log()`.
9504 @param[in] spec Argument passed to `extract_metric_from_log()`.
9505 @return Value returned by `extract_metric_from_log()`.
9506 """
9507 file_glob = spec.get("file_glob", "logs/*.log")
9508 regex = spec.get("regex")
9509 if not regex:
9510 return None
9511 candidates = sorted(glob.glob(os.path.join(case_dir, file_glob), recursive=True))
9512 if not candidates:
9513 return None
9514 pattern = re.compile(regex)
9515 values = []
9516 for path in candidates:
9517 try:
9518 with open(path, "r", encoding="utf-8", errors="replace") as f:
9519 for line in f:
9520 m = pattern.search(line)
9521 if m:
9522 try:
9523 values.append(float(m.group(1)))
9524 except Exception:
9525 pass
9526 except OSError:
9527 continue
9528 return reduce_metric_values(values, spec.get("reduction", "last"))
9529
9530
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 9531 of file core.py.

9531def normalize_metric_spec(metric):
9532 """!
9533 @brief Normalize study metric definitions to a common dictionary form.
9534 @param[in] metric Argument passed to `normalize_metric_spec()`.
9535 @return Value returned by `normalize_metric_spec()`.
9536 """
9537 if isinstance(metric, str):
9538 if metric.lower() in {"msd", "msd_final"}:
9539 return {
9540 "name": "msd_final",
9541 "source": "statistics_csv",
9542 "file_glob": "**/*_msd.csv",
9543 "reduction": "last",
9544 }
9545 return {"name": metric, "source": "log_regex", "regex": metric}
9546 return dict(metric)
9547
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 9548 of file core.py.

9548def aggregate_study_metrics(study_cfg: dict, cases: list, results_dir: str) -> str:
9549 """!
9550 @brief Collect metric values from generated case directories into one CSV.
9551 @param[in] study_cfg Argument passed to `aggregate_study_metrics()`.
9552 @param[in] cases Argument passed to `aggregate_study_metrics()`.
9553 @param[in] results_dir Argument passed to `aggregate_study_metrics()`.
9554 @return Value returned by `aggregate_study_metrics()`.
9555 """
9556 metrics = study_cfg.get("metrics", [])
9557 if not metrics:
9558 metrics = ["msd_final"]
9559 normalized_specs = [normalize_metric_spec(m) for m in metrics]
9560
9561 rows = []
9562 for case in cases:
9563 row = {"case_id": case["case_id"]}
9564 for p_key, p_val in case["parameters"].items():
9565 row[p_key] = p_val
9566 for spec in normalized_specs:
9567 name = spec.get("name", "metric")
9568 source = str(spec.get("source", "")).lower()
9569 if source in {"statistics_csv", "csv"}:
9570 value = extract_metric_from_csv(case["run_dir"], spec)
9571 elif source in {"log_regex", "log"}:
9572 value = extract_metric_from_log(case["run_dir"], spec)
9573 else:
9574 value = None
9575
9576 normalize_key = spec.get("normalize_by_parameter")
9577 if value is not None and normalize_key:
9578 denom = case.get("parameters", {}).get(normalize_key)
9579 try:
9580 denom = float(denom)
9581 except Exception:
9582 denom = None
9583 if denom not in (None, 0.0):
9584 value = float(value) / denom
9585 else:
9586 value = None
9587
9588 row[name] = value
9589 rows.append(row)
9590
9591 if not rows:
9592 return None
9593
9594 all_keys = []
9595 seen = set()
9596 for row in rows:
9597 for k in row.keys():
9598 if k not in seen:
9599 seen.add(k)
9600 all_keys.append(k)
9601
9602 os.makedirs(results_dir, exist_ok=True)
9603 out_csv = os.path.join(results_dir, "metrics_table.csv")
9604 with open(out_csv, "w", newline="") as f:
9605 writer = csv.DictWriter(f, fieldnames=all_keys)
9606 writer.writeheader()
9607 writer.writerows(rows)
9608 print(f"[SUCCESS] Aggregated metrics table: {os.path.relpath(out_csv)}")
9609 return out_csv
9610
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 9611 of file core.py.

9611def infer_plot_x_axis(study_cfg: dict, rows: list):
9612 """!
9613 @brief Infer x-axis key/values for study plots.
9614 @param[in] study_cfg Argument passed to `infer_plot_x_axis()`.
9615 @param[in] rows Argument passed to `infer_plot_x_axis()`.
9616 @return Value returned by `infer_plot_x_axis()`.
9617 """
9618 params = get_study_parameter_keys(study_cfg)
9619 if not params or not rows:
9620 return None, None
9621
9622 study_type = study_cfg.get("study_type")
9623 if study_type == "grid_independence":
9624 has_im = "case.grid.programmatic_settings.im" in params
9625 has_jm = "case.grid.programmatic_settings.jm" in params
9626 has_km = "case.grid.programmatic_settings.km" in params
9627 if has_im and has_jm and has_km:
9628 xs = []
9629 for row in rows:
9630 try:
9631 im = float(row["case.grid.programmatic_settings.im"])
9632 jm = float(row["case.grid.programmatic_settings.jm"])
9633 km = float(row["case.grid.programmatic_settings.km"])
9634 xs.append((im * jm * km) ** (1.0 / 3.0))
9635 except Exception:
9636 return None, None
9637 return "N^(1/3)", xs
9638
9639 primary = params[0]
9640 xs = []
9641 for row in rows:
9642 try:
9643 xs.append(float(row[primary]))
9644 except Exception:
9645 return None, None
9646 return primary, xs
9647
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 9648 of file core.py.

9648def generate_study_plots(study_cfg: dict, metrics_csv: str, plots_dir: str):
9649 """!
9650 @brief Generate metric-vs-parameter plots for completed studies.
9651 @param[in] study_cfg Argument passed to `generate_study_plots()`.
9652 @param[in] metrics_csv Argument passed to `generate_study_plots()`.
9653 @param[in] plots_dir Argument passed to `generate_study_plots()`.
9654 @return Value returned by `generate_study_plots()`.
9655 """
9656 plotting_cfg = study_cfg.get("plotting", {}) or {}
9657 if plotting_cfg.get("enabled", True) is False:
9658 print("[INFO] Plotting disabled by study.yml.")
9659 return []
9660 plt = optional_matplotlib_pyplot()
9661 if plt is None:
9662 print("[WARNING] matplotlib not available; skipping plot generation.")
9663 return []
9664 if not metrics_csv or not os.path.isfile(metrics_csv):
9665 return []
9666
9667 with open(metrics_csv, "r", newline="") as f:
9668 reader = csv.DictReader(f)
9669 rows = list(reader)
9670 if not rows:
9671 return []
9672
9673 x_name, x_values = infer_plot_x_axis(study_cfg, rows)
9674 if not x_name or x_values is None:
9675 print("[WARNING] Could not infer numeric x-axis for plots; skipping.")
9676 return []
9677
9678 metric_keys = []
9679 param_keys = get_study_parameter_keys(study_cfg)
9680 for key in rows[0].keys():
9681 if key in {"case_id"}:
9682 continue
9683 if key in param_keys:
9684 continue
9685 metric_keys.append(key)
9686
9687 out_format = plotting_cfg.get("output_format", "png")
9688 os.makedirs(plots_dir, exist_ok=True)
9689 generated = []
9690 for metric in metric_keys:
9691 y_values = []
9692 ok = True
9693 for row in rows:
9694 try:
9695 y_values.append(float(row[metric]))
9696 except Exception:
9697 ok = False
9698 break
9699 if not ok:
9700 continue
9701 plt.figure(figsize=(7.0, 4.2))
9702 plt.plot(x_values, y_values, marker="o", linewidth=1.5)
9703 plt.xlabel(x_name)
9704 plt.ylabel(metric)
9705 plt.title(f"{metric} vs {x_name}")
9706 plt.grid(True, alpha=0.3)
9707 out_path = os.path.join(plots_dir, f"{metric}_vs_{x_name.replace('/', '_')}.{out_format}")
9708 plt.tight_layout()
9709 plt.savefig(out_path, dpi=150)
9710 plt.close()
9711 generated.append(out_path)
9712 if generated:
9713 print(f"[SUCCESS] Generated {len(generated)} plot(s) in {os.path.relpath(plots_dir)}")
9714 return generated
9715
9716
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 9717 of file core.py.

9717def _command_to_string(command_tokens: list) -> str:
9718 """!
9719 @brief Render a command list as a shell-safe display string.
9720 @param[in] command_tokens Argument passed to `_command_to_string()`.
9721 @return Value returned by `_command_to_string()`.
9722 """
9723 return " ".join(shlex.quote(str(tok)) for tok in command_tokens)
9724
9725
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 9726 of file core.py.

9726def _resolve_post_source_directory_preview(run_dir: str, monitor_cfg: dict, post_cfg: dict) -> str:
9727 """!
9728 @brief Resolve post source directory without side effects or stdout/stderr output.
9729 @param[in] run_dir Argument passed to `_resolve_post_source_directory_preview()`.
9730 @param[in] monitor_cfg Argument passed to `_resolve_post_source_directory_preview()`.
9731 @param[in] post_cfg Argument passed to `_resolve_post_source_directory_preview()`.
9732 @return Value returned by `_resolve_post_source_directory_preview()`.
9733 """
9734 solver_output_dir_rel = monitor_cfg.get('io', {}).get('directories', {}).get('output', 'output')
9735 solver_output_dir_abs = os.path.join(run_dir, solver_output_dir_rel)
9736 source_dir_template = get_post_source_directory_template(post_cfg)
9737 if source_dir_template == '<solver_output_dir>':
9738 return solver_output_dir_abs
9739 return os.path.abspath(os.path.join(run_dir, source_dir_template))
9740
9741
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 9742 of file core.py.

9742def build_run_dry_plan(args) -> dict:
9743 """!
9744 @brief Build a no-write execution plan for `run --dry-run`.
9745 @param[in] args Command-line style argument list supplied to the function.
9746 @return Value returned by `build_run_dry_plan()`.
9747 """
9748 plan = {
9749 "mode": "dry-run",
9750 "created_at": datetime.now().isoformat(),
9751 "warnings": [],
9752 "inputs": {},
9753 "stages": {},
9754 "artifacts": [],
9755 }
9756
9757 if args.dry_run and args.no_submit:
9758 plan["warnings"].append("--dry-run takes precedence over --no-submit; no files will be written.")
9759
9760 cluster_mode = bool(getattr(args, "cluster", None))
9761 cluster_cfg = None
9762 cluster_path = None
9763 solver_num_procs_effective = args.num_procs
9764 post_num_procs_effective = 1
9765 run_id = None
9766 run_dir = None
9767 solver_control_path = None
9768 loaded_case_cfg = None
9769 loaded_monitor_cfg = None
9770 resolved_restart_source_dir = None
9771
9772 if cluster_mode:
9773 cluster_path = os.path.abspath(args.cluster)
9774 cluster_cfg = read_yaml_file(cluster_path)
9775 validate_cluster_config(cluster_cfg, cluster_path)
9776 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
9777 if args.scheduler and args.scheduler.lower() != scheduler_type:
9778 emit_structured_error(
9779 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9780 key="scheduler.type",
9781 file_path=cluster_path,
9782 message=f"--scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
9783 )
9784 sys.exit(1)
9785 if scheduler_type != "slurm":
9786 emit_structured_error(
9787 ERROR_CODE_CFG_INVALID_VALUE,
9788 key="scheduler.type",
9789 file_path=cluster_path,
9790 message=f"Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.",
9791 )
9792 sys.exit(1)
9793 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
9794 if args.solve and args.num_procs not in (1, cluster_tasks):
9795 emit_structured_error(
9796 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9797 key="resources.ntasks_per_node",
9798 file_path=cluster_path,
9799 message=(
9800 "--num-procs applies to the solver stage and must be 1 (auto) or "
9801 f"exactly nodes*ntasks_per_node ({cluster_tasks}) in cluster mode."
9802 ),
9803 )
9804 sys.exit(1)
9805 if args.solve:
9806 solver_num_procs_effective = cluster_tasks
9807 plan["launch_mode"] = "slurm"
9808 plan["inputs"]["cluster"] = cluster_path
9809 else:
9810 if getattr(args, "scheduler", None):
9811 fail_cli_usage("--scheduler requires --cluster in this version.")
9812 plan["launch_mode"] = "local"
9813
9814 # --- Guard: restart flags without --solve ---
9815 if not args.solve:
9816 if getattr(args, 'restart_from', None):
9817 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
9818 if getattr(args, 'continue_run', False) and not args.post_process:
9819 print("[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
9820
9821 if args.solve:
9822 case_path = os.path.abspath(args.case)
9823 solver_path = os.path.abspath(args.solver)
9824 monitor_path = os.path.abspath(args.monitor)
9825 loaded_case_cfg = read_yaml_file(case_path)
9826 solver_cfg = read_yaml_file(solver_path)
9827 loaded_monitor_cfg = read_yaml_file(monitor_path)
9828 validate_solver_configs(loaded_case_cfg, solver_cfg, loaded_monitor_cfg, case_path, solver_path, monitor_path)
9829
9830 continue_mode = getattr(args, 'continue_run', False)
9831
9832 if continue_mode:
9833 # --continue reuses existing run directory
9834 if not args.run_dir:
9835 fail_cli_usage("--continue requires --run-dir.")
9836 run_dir = os.path.abspath(args.run_dir)
9837 if not os.path.isdir(run_dir):
9838 emit_structured_error(
9839 ERROR_CODE_CFG_FILE_NOT_FOUND,
9840 key="run-dir",
9841 file_path=run_dir,
9842 message="Specified run directory not found.",
9843 )
9844 sys.exit(1)
9845 run_id = os.path.basename(run_dir)
9846 else:
9847 case_name = os.path.splitext(os.path.basename(case_path))[0]
9848 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
9849 run_id = f"{case_name}_{timestamp}"
9850 run_dir = os.path.abspath(os.path.join("runs", run_id))
9851
9852 try:
9853 resolved_restart_source_dir, is_continue = resolve_restart_source(
9854 args, loaded_case_cfg, solver_cfg, loaded_monitor_cfg, run_dir
9855 )
9856 except ValueError as e:
9857 emit_structured_error(
9858 ERROR_CODE_CFG_INCONSISTENT_COMBO,
9859 key="restart",
9860 file_path=case_path,
9861 message=str(e),
9862 )
9863 sys.exit(1)
9864
9865 config_dir = os.path.join(run_dir, "config")
9866 scheduler_dir = os.path.join(run_dir, "scheduler")
9867 logs_dir = os.path.join(run_dir, "logs")
9868 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
9869 profile_path = os.path.join(config_dir, "profile.run")
9870 profiling_preview = resolve_profiling_config(loaded_monitor_cfg)
9871
9872 plan["run_id_preview"] = run_id
9873 plan["run_dir_preview"] = run_dir
9874 plan["inputs"].update({"case": case_path, "solver": solver_path, "monitor": monitor_path})
9875 plan["artifacts"].extend(
9876 [
9877 run_dir,
9878 config_dir,
9879 logs_dir,
9880 os.path.join(run_dir, "output"),
9881 scheduler_dir,
9882 os.path.join(config_dir, "case.yml"),
9883 os.path.join(config_dir, "solver.yml"),
9884 os.path.join(config_dir, "monitor.yml"),
9885 solver_control_path,
9886 os.path.join(run_dir, "manifest.json"),
9887 ]
9888 )
9889 add_planned_grid_artifacts(plan, loaded_case_cfg, run_dir)
9890 add_planned_profile_artifacts(plan, loaded_case_cfg, run_dir)
9891 add_planned_initial_condition_artifacts(plan, loaded_case_cfg, solver_cfg, run_dir)
9892 if has_explicit_monitor_whitelist(loaded_monitor_cfg):
9893 plan["artifacts"].append(os.path.join(config_dir, "whitelist.run"))
9894 if profiling_preview["mode"] == "selected":
9895 plan["artifacts"].append(profile_path)
9896 solve_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "Solver")
9897 plan["artifacts"].extend(solve_diagnostics["artifacts"])
9898 if cluster_mode:
9899 plan["artifacts"].append(os.path.join(config_dir, "cluster.yml"))
9900 plan["artifacts"].append(os.path.join(scheduler_dir, "submission.json"))
9901
9902 solver_exe = resolve_runtime_executable("simulator")
9903 solver_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "Solver") + ["-control_file", solver_control_path]
9904 if cluster_mode:
9905 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
9906 solver_cmd = build_cluster_launch_command(
9907 cluster_cfg,
9908 solver_exe,
9909 solver_args,
9910 config_search_anchor=case_path,
9911 extra_search_anchors=[cluster_path],
9912 )
9913 plan["artifacts"].append(solver_script)
9914 plan["stages"]["solve"] = {
9915 "mode": "slurm",
9916 "script": solver_script,
9917 "num_procs_effective": solver_num_procs_effective,
9918 "launch_command": solver_cmd,
9919 "launch_command_string": _command_to_string(solver_cmd),
9920 }
9921 else:
9922 solver_cmd = build_local_launch_command(
9923 solver_exe,
9924 solver_args,
9925 solver_num_procs_effective,
9926 config_search_anchor=case_path,
9927 )
9928 solver_stream_log = os.path.join(scheduler_dir, f"{run_id}_solver.log")
9929 plan["artifacts"].append(solver_stream_log)
9930 plan["stages"]["solve"] = {
9931 "mode": "local",
9932 "num_procs_effective": solver_num_procs_effective,
9933 "stream_log": solver_stream_log,
9934 "launch_command": solver_cmd,
9935 "launch_command_string": _command_to_string(solver_cmd),
9936 }
9937 if resolved_restart_source_dir:
9938 plan["stages"]["solve"]["restart_source_directory"] = resolved_restart_source_dir
9939 if is_continue:
9940 plan["stages"]["solve"]["continue_mode"] = True
9941
9942 if args.post_process:
9943 post_path = os.path.abspath(args.post)
9944 plan["inputs"]["post"] = post_path
9945 post_cfg = read_yaml_file(post_path)
9946 validate_post_config(post_cfg, post_path)
9947
9948 if args.run_dir:
9949 run_dir = os.path.abspath(args.run_dir)
9950 if not os.path.isdir(run_dir):
9951 emit_structured_error(
9952 ERROR_CODE_CFG_FILE_NOT_FOUND,
9953 key="run-dir",
9954 file_path=run_dir,
9955 message="Specified run directory not found.",
9956 )
9957 sys.exit(1)
9958 run_id = os.path.basename(run_dir)
9959 elif not args.solve:
9960 fail_cli_usage("--post-process requires --run-dir when not used with --solve.")
9961
9962 if args.run_dir:
9963 config_dir = os.path.join(run_dir, "config")
9964 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
9965 if not all([case_path, monitor_path, solver_control_path]):
9966 emit_structured_error(
9967 ERROR_CODE_CFG_MISSING_KEY,
9968 key="run_dir.config",
9969 file_path=config_dir,
9970 message=(
9971 "Could not auto-identify required run inputs "
9972 "(case.yml/monitor.yml/*.control) in run config directory."
9973 ),
9974 )
9975 sys.exit(1)
9976 loaded_case_cfg = read_yaml_file(case_path)
9977 loaded_monitor_cfg = read_yaml_file(monitor_path)
9978 else:
9979 config_dir = os.path.join(run_dir, "config")
9980 case_path = os.path.join(config_dir, "case.yml")
9981 monitor_path = os.path.join(config_dir, "monitor.yml")
9982 if solver_control_path is None:
9983 solver_control_path = os.path.join(config_dir, f"{run_id}.control")
9984
9985 allow_source_frontier_scan = not args.solve
9986 post_plan = build_post_execution_plan(
9987 run_dir,
9988 run_id,
9989 loaded_case_cfg,
9990 loaded_monitor_cfg,
9991 post_cfg,
9992 continue_requested=getattr(args, 'continue_run', False),
9993 allow_source_frontier_scan=allow_source_frontier_scan,
9994 )
9995
9996 post_recipe_path = os.path.join(config_dir, "post.run")
9997 output_dir_rel = post_cfg.get("io", {}).get("output_directory")
9998 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix")
9999 if not output_dir_rel or not output_prefix:
10000 emit_structured_error(
10001 ERROR_CODE_CFG_MISSING_KEY,
10002 key="io.output_directory/io.output_filename_prefix",
10003 file_path=post_path,
10004 message="Missing required post IO keys.",
10005 )
10006 sys.exit(1)
10007 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
10008 statistics_output_paths = get_post_statistics_output_artifacts(post_cfg, run_dir, loaded_monitor_cfg)
10009 post_exe = resolve_runtime_executable("postprocessor")
10010 post_diagnostics = resolve_diagnostics_config(loaded_monitor_cfg, run_dir, "PostProcessor")
10011 plan["artifacts"].extend(post_diagnostics["artifacts"])
10012 post_args = build_petsc_diagnostics_args(loaded_monitor_cfg, run_dir, "PostProcessor") + [
10013 "-control_file",
10014 solver_control_path,
10015 "-postprocessing_config_file",
10016 post_recipe_path,
10017 ]
10018 plan["artifacts"].extend([
10019 post_recipe_path,
10020 output_dir_abs,
10021 post_plan["resume_state_path"],
10022 post_plan["lock_paths"]["wrapper_path"],
10023 post_plan["lock_paths"]["lock_file"],
10024 post_plan["lock_paths"]["metadata_file"],
10025 ])
10026 plan["artifacts"].extend(statistics_output_paths)
10027
10028 stage_meta = {
10029 "source_data_directory": post_plan["source_data_directory"],
10030 "requested_start_step": post_plan["requested_start_step"],
10031 "requested_end_step": post_plan["requested_end_step"],
10032 "step_interval": post_plan["step_interval"],
10033 "resume_applied": bool(post_plan["continue_requested"] and post_plan["resume_recipe_match"]),
10034 "resume_recipe_match": post_plan["resume_recipe_match"],
10035 "resume_bootstrapped": post_plan["resume_bootstrapped"],
10036 "resume_match_source": post_plan["resume_match_source"],
10037 "completed_frontier_step": post_plan["completed_frontier_step"],
10038 "source_frontier_step": post_plan["source_frontier_step"],
10039 "source_frontier_diagnostic": post_plan["source_frontier_diagnostic"],
10040 "source_frontier_deferred": post_plan["source_frontier_deferred"],
10041 "effective_start_step": post_plan["effective_start_step"],
10042 "effective_end_step": post_plan["effective_end_step"],
10043 "skip_reason": post_plan["skip_reason"],
10044 "post_skipped_as_complete": post_plan["skip_reason"] == "already-complete-window",
10045 "recipe_fingerprint": post_plan["recipe_fingerprint"],
10046 "num_procs_effective": post_num_procs_effective,
10047 }
10048
10049 if post_plan["skip_reason"] is None:
10050 if cluster_mode:
10051 scheduler_dir = os.path.join(run_dir, "scheduler")
10052 post_script = os.path.join(scheduler_dir, "post.sbatch")
10053 post_cluster_cfg = build_serial_post_cluster_config(cluster_cfg, post_num_procs_effective)
10054 raw_post_cmd = build_cluster_launch_command(
10055 post_cluster_cfg,
10056 post_exe,
10057 post_args,
10058 config_search_anchor=case_path,
10059 extra_search_anchors=[cluster_path],
10060 force_num_procs=post_num_procs_effective,
10061 )
10062 post_cmd, _ = build_post_locked_command(
10063 run_dir,
10064 post_plan["recipe_fingerprint"],
10065 raw_post_cmd,
10066 create_wrapper=False,
10067 )
10068 plan["artifacts"].append(post_script)
10069 stage_meta.update({
10070 "mode": "slurm",
10071 "script": post_script,
10072 "launch_command": post_cmd,
10073 "launch_command_string": _command_to_string(post_cmd),
10074 })
10075 else:
10076 raw_post_cmd = build_local_launch_command(
10077 post_exe,
10078 post_args,
10079 post_num_procs_effective,
10080 config_search_anchor=case_path,
10081 allow_single_rank_launcher_override=True,
10082 force_num_procs=post_num_procs_effective,
10083 )
10084 post_cmd, _ = build_post_locked_command(
10085 run_dir,
10086 post_plan["recipe_fingerprint"],
10087 raw_post_cmd,
10088 create_wrapper=False,
10089 )
10090 post_stream_log = os.path.join(run_dir, "scheduler", f"{run_id}_{output_prefix}.log")
10091 plan["artifacts"].append(post_stream_log)
10092 stage_meta.update({
10093 "mode": "local",
10094 "stream_log": post_stream_log,
10095 "launch_command": post_cmd,
10096 "launch_command_string": _command_to_string(post_cmd),
10097 })
10098 else:
10099 stage_meta.update({
10100 "mode": "slurm" if cluster_mode else "local",
10101 "launch_command": [],
10102 "launch_command_string": "",
10103 })
10104
10105 plan["stages"]["post-process"] = stage_meta
10106
10107 # Preserve insertion order while removing duplicates.
10108 deduped = []
10109 seen = set()
10110 for item in plan["artifacts"]:
10111 if item not in seen:
10112 seen.add(item)
10113 deduped.append(item)
10114 plan["artifacts"] = deduped
10115 if run_id and "run_id_preview" not in plan:
10116 plan["run_id_preview"] = run_id
10117 if run_dir and "run_dir_preview" not in plan:
10118 plan["run_dir_preview"] = run_dir
10119 plan["solver_num_procs_effective"] = solver_num_procs_effective
10120 plan["post_num_procs_effective"] = post_num_procs_effective
10121 plan["num_procs_effective"] = solver_num_procs_effective
10122 return plan
10123
10124
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 10125 of file core.py.

10125def add_planned_grid_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
10126 """!
10127 @brief Add grid-mode-specific staged artifacts to a dry-run plan.
10128 @param[in,out] plan Dry-run plan to update.
10129 @param[in] case_cfg Parsed case configuration.
10130 @param[in] run_dir Preview run directory for relative artifact resolution.
10131 """
10132 grid_cfg = case_cfg.get("grid", {})
10133 if not isinstance(grid_cfg, dict):
10134 return
10135
10136 mode = grid_cfg.get("mode")
10137 config_dir = os.path.join(run_dir, "config")
10138
10139 if mode == "file":
10140 plan["artifacts"].append(os.path.join(config_dir, "grid.run"))
10141 legacy_cfg = grid_cfg.get("legacy_conversion")
10142 if isinstance(legacy_cfg, dict) and legacy_cfg.get("enabled", True):
10143 output_file = legacy_cfg.get("output_file", os.path.join("config", "grid.converted.picgrid"))
10144 if isinstance(output_file, str) and output_file.strip():
10145 if not os.path.isabs(output_file):
10146 output_file = os.path.abspath(os.path.join(run_dir, output_file))
10147 plan["artifacts"].append(output_file)
10148 elif mode == "grid_gen":
10149 generator = grid_cfg.get("generator", {})
10150 if not isinstance(generator, dict):
10151 return
10152 plan["artifacts"].append(os.path.join(config_dir, "grid.run"))
10153 for key, default in (
10154 ("output_file", os.path.join("config", "grid.generated.picgrid")),
10155 ("stats_file", None),
10156 ("vts_file", None),
10157 ):
10158 artifact_path = generator.get(key, default)
10159 if isinstance(artifact_path, str) and artifact_path.strip():
10160 if not os.path.isabs(artifact_path):
10161 artifact_path = os.path.abspath(os.path.join(run_dir, artifact_path))
10162 plan["artifacts"].append(artifact_path)
10163
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 10164 of file core.py.

10164def add_planned_profile_artifacts(plan: dict, case_cfg: dict, run_dir: str) -> None:
10165 """!
10166 @brief Add generated prescribed-flow profile artifacts to a dry-run plan.
10167 @param[in,out] plan Dry-run plan to update.
10168 @param[in] case_cfg Parsed case configuration.
10169 @param[in] run_dir Preview run directory.
10170 """
10171 try:
10172 prepared_blocks = validate_and_prepare_boundary_conditions(case_cfg)
10173 except ValueError:
10174 return
10175 config_dir = os.path.join(run_dir, "config")
10176 has_generated = False
10177 for block_idx, block in enumerate(prepared_blocks):
10178 for bc in block:
10179 if bc.get("handler") != "prescribed_flow":
10180 continue
10181 source = (bc.get("params") or {}).get("source", {})
10182 if source.get("type") not in {"generated", "field_slice"}:
10183 continue
10184 has_generated = True
10185 face_token = _face_artifact_token(bc["face"])
10186 suffix = "generated" if source.get("type") == "generated" else "sliced"
10187 default_output = os.path.join(
10188 "config", f"inlet_profile_block{block_idx}_{face_token}.{suffix}.picslice"
10189 )
10190 generated_path = _resolve_run_artifact_path(
10191 run_dir,
10192 source.get("output_file"),
10193 default_output,
10194 default_to_config_dir=True,
10195 )
10196 staged_path = os.path.join(config_dir, f"inlet_profile_block{block_idx}_{face_token}.picslice")
10197 plan["artifacts"].append(generated_path)
10198 plan["artifacts"].append(staged_path)
10199 if has_generated:
10200 plan["artifacts"].append(os.path.join(config_dir, "profile.info"))
10201
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 10202 of file core.py.

10202def add_planned_initial_condition_artifacts(plan: dict, case_cfg: dict, solver_cfg: dict, run_dir: str) -> None:
10203 """!
10204 @brief Add authoritative file-backed initial-condition artifacts to a dry-run plan.
10205 @param[in,out] plan Dry-run plan receiving artifact paths.
10206 @param[in] case_cfg Parsed case configuration.
10207 @param[in] solver_cfg Parsed solver configuration.
10208 @param[in] run_dir Planned run directory.
10209 """
10210 source = normalize_eulerian_field_source(
10211 (solver_cfg.get("operation_mode", {}) or {}).get("eulerian_field_source", "solve")
10212 )
10213 start_step = int((case_cfg.get("run_control", {}) or {}).get("start_step", 0) or 0)
10214 if source != "solve" or start_step != 0:
10215 return
10216 try:
10217 resolved = resolve_initial_condition_config(
10218 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
10219 validate_and_prepare_boundary_conditions(case_cfg),
10220 U_ref=1.0,
10221 )
10222 except (KeyError, ValueError):
10223 return
10224 if resolved["kind"] not in {"file", "ic_gen"}:
10225 return
10226 config_dir = os.path.join(run_dir, "config")
10227 plan["artifacts"].append(
10228 os.path.join(config_dir, "initial_condition", f"{resolved['field_name']}00000_0.dat")
10229 )
10230 if resolved["kind"] == "ic_gen":
10231 if (case_cfg.get("grid", {}) or {}).get("mode") == "programmatic_c":
10232 plan["artifacts"].append(os.path.join(config_dir, "grid.run"))
10233 plan["artifacts"].append(_resolve_run_artifact_path(
10234 run_dir, resolved.get("output_file"), os.path.join("config", "initial_condition.generated.dat"),
10235 default_to_config_dir=True,
10236 ))
10237
10238
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 10239 of file core.py.

10239def render_run_dry_plan(plan: dict, output_format: str = "text"):
10240 """!
10241 @brief Render dry-run plan in human or JSON format.
10242 @param[in] plan Argument passed to `render_run_dry_plan()`.
10243 @param[in] output_format Argument passed to `render_run_dry_plan()`.
10244 """
10245 if output_format == "json":
10246 print(json.dumps(plan, indent=2, sort_keys=True))
10247 return
10248
10249 print("\n" + "=" * 60)
10250 print(" DRY-RUN PLAN")
10251 print("=" * 60)
10252 print(f" Launch mode : {plan.get('launch_mode')}")
10253 print(f" Created at : {plan.get('created_at')}")
10254 if plan.get("run_id_preview"):
10255 print(f" Run ID preview : {plan.get('run_id_preview')}")
10256 if plan.get("run_dir_preview"):
10257 print(f" Run dir preview: {plan.get('run_dir_preview')}")
10258 print(f" Solver MPI procs: {plan.get('solver_num_procs_effective')}")
10259 print(f" Post MPI procs : {plan.get('post_num_procs_effective')}")
10260 if plan.get("warnings"):
10261 print(" Warnings :")
10262 for warning in plan["warnings"]:
10263 print(f" - {warning}")
10264
10265 if plan.get("inputs"):
10266 print("\n Inputs:")
10267 for key, value in plan["inputs"].items():
10268 print(f" - {key}: {value}")
10269
10270 if plan.get("stages"):
10271 print("\n Planned stage commands:")
10272 for stage, details in plan["stages"].items():
10273 print(f" - {stage} ({details.get('mode')}):")
10274 if details.get('skip_reason'):
10275 print(f" skipped: {details.get('skip_reason')}")
10276 else:
10277 print(f" {details.get('launch_command_string')}")
10278
10279 diagnostics_artifacts = [item for item in plan.get("artifacts", []) if "PETSc_" in os.path.basename(str(item)) or os.path.basename(str(item)) == "Runtime_Memory.log"]
10280 if diagnostics_artifacts:
10281 print("\n Diagnostics artifacts:")
10282 for artifact in diagnostics_artifacts:
10283 print(f" - {artifact}")
10284
10285 print("\n Planned artifacts (no files created in dry-run):")
10286 for artifact in plan.get("artifacts", []):
10287 print(f" - {artifact}")
10288 print("=" * 60)
10289
10290
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 10291 of file core.py.

10291def validate_workflow(args):
10292 """!
10293 @brief Implements `picurv validate` without launching solver/post workflows.
10294 @param[in] args Command-line style argument list supplied to the function.
10295 """
10296 checked = []
10297 solver_group_selected = any([args.case, args.solver, args.monitor])
10298 any_group_selected = solver_group_selected or any([args.post, args.cluster, args.study])
10299 case_path = None
10300 cluster_path = None
10301
10302 if not any_group_selected:
10303 fail_cli_usage(
10304 "validate requires at least one config group. Provide solver trio and/or --post/--cluster/--study.",
10305 hint="Example: picurv validate --case case.yml --solver solver.yml --monitor monitor.yml --post post.yml",
10306 )
10307
10308 if solver_group_selected and not all([args.case, args.solver, args.monitor]):
10309 fail_cli_usage("When solver validation is requested, --case, --solver, and --monitor are all required.")
10310
10311 # --- Guard: restart flags without solver group ---
10312 restart_from = getattr(args, 'restart_from', None)
10313 continue_run = getattr(args, 'continue_run', False)
10314 run_dir_val = getattr(args, 'run_dir', None)
10315 if not solver_group_selected:
10316 if restart_from:
10317 print("[WARNING] --restart-from has no effect without --case/--solver/--monitor and will be ignored.", file=sys.stderr)
10318 if continue_run and not args.post:
10319 print("[WARNING] --continue has no effect without solver configs or --post and will be ignored.", file=sys.stderr)
10320 if continue_run and not run_dir_val:
10321 fail_cli_usage("--continue requires --run-dir.")
10322
10323 if solver_group_selected:
10324 case_path = os.path.abspath(args.case)
10325 solver_path = os.path.abspath(args.solver)
10326 monitor_path = os.path.abspath(args.monitor)
10327 case_cfg = read_yaml_file(case_path)
10328 solver_cfg = read_yaml_file(solver_path)
10329 monitor_cfg = read_yaml_file(monitor_path)
10330 validate_solver_configs(case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path)
10331 checked.extend([case_path, solver_path, monitor_path])
10332
10333 # Validate restart flags if provided
10334 if restart_from or continue_run:
10335 target_run_dir = os.path.abspath(run_dir_val) if run_dir_val else os.path.abspath("runs/_validate_dummy")
10336 try:
10337 resolve_restart_source(args, case_cfg, solver_cfg, monitor_cfg, target_run_dir)
10338 print("[SUCCESS] Restart source validation passed.")
10339 except ValueError as e:
10340 print(f"[ERROR] Restart validation failed: {e}", file=sys.stderr)
10341 sys.exit(1)
10342
10343 post_cfg = None
10344 if args.post:
10345 post_path = os.path.abspath(args.post)
10346 post_cfg = read_yaml_file(post_path)
10347 validate_post_config(post_cfg, post_path)
10348 checked.append(post_path)
10349
10350 cluster_cfg = None
10351 if args.cluster:
10352 cluster_path = os.path.abspath(args.cluster)
10353 cluster_cfg = read_yaml_file(cluster_path)
10354 validate_cluster_config(cluster_cfg, cluster_path)
10355 checked.append(cluster_path)
10356
10357 try:
10358 runtime_execution_path, _ = load_runtime_execution_config(
10359 case_path,
10360 extra_search_anchors=[cluster_path] if cluster_path else None,
10361 )
10362 except ValueError as exc:
10363 emit_structured_error(
10364 ERROR_CODE_CFG_INVALID_VALUE,
10365 key="runtime_execution",
10366 file_path=case_path or cluster_path or os.getcwd(),
10367 message=str(exc),
10368 )
10369 sys.exit(1)
10370 if runtime_execution_path:
10371 checked.append(runtime_execution_path)
10372
10373 study_cfg = None
10374 if args.study:
10375 study_path = os.path.abspath(args.study)
10376 study_cfg = read_yaml_file(study_path)
10377 validate_study_config(study_cfg, study_path)
10378 checked.append(study_path)
10379
10380 if post_cfg is not None and run_dir_val:
10381 post_path = os.path.abspath(args.post)
10382 validate_run_dir = os.path.abspath(run_dir_val)
10383 if os.path.isdir(validate_run_dir):
10384 monitor_for_post = monitor_cfg if solver_group_selected else None
10385 if monitor_for_post is None:
10386 config_dir_candidate = os.path.join(validate_run_dir, "config")
10387 monitor_candidate = os.path.join(config_dir_candidate, "monitor.yml")
10388 if os.path.isfile(monitor_candidate):
10389 monitor_for_post = read_yaml_file(monitor_candidate)
10390 if monitor_for_post is not None:
10391 resolved_source = _resolve_post_source_directory_preview(validate_run_dir, monitor_for_post, post_cfg)
10392 if os.path.isdir(resolved_source) and os.listdir(resolved_source):
10393 print(f"[SUCCESS] Post-processor source data directory exists: {resolved_source}")
10394 else:
10395 print(f"[WARNING] Post-processor source data directory is missing or empty: {resolved_source}", file=sys.stderr)
10396
10397 if args.strict and post_cfg is not None:
10398 post_path = os.path.abspath(args.post)
10399 source_dir = post_cfg.get("source_data", {}).get("directory")
10400 if source_dir and source_dir != "<solver_output_dir>":
10401 resolved = resolve_path(post_path, source_dir)
10402 if not os.path.isdir(resolved):
10403 emit_structured_error(
10404 ERROR_CODE_CFG_FILE_NOT_FOUND,
10405 key="source_data.directory",
10406 file_path=post_path,
10407 message=f"strict mode: source_data.directory resolves to missing directory '{resolved}'.",
10408 )
10409 sys.exit(1)
10410
10411 if args.strict and study_cfg is not None:
10412 study_path = os.path.abspath(args.study)
10413 base_cfgs = study_cfg.get("base_configs", {})
10414 if isinstance(base_cfgs, dict):
10415 base_case_path = resolve_path(study_path, base_cfgs.get("case"))
10416 base_solver_path = resolve_path(study_path, base_cfgs.get("solver"))
10417 base_monitor_path = resolve_path(study_path, base_cfgs.get("monitor"))
10418 base_post_path = resolve_path(study_path, base_cfgs.get("post"))
10419 if all([base_case_path, base_solver_path, base_monitor_path]):
10420 validate_solver_configs(
10421 read_yaml_file(base_case_path),
10422 read_yaml_file(base_solver_path),
10423 read_yaml_file(base_monitor_path),
10424 base_case_path,
10425 base_solver_path,
10426 base_monitor_path,
10427 )
10428 if base_post_path:
10429 validate_post_config(read_yaml_file(base_post_path), base_post_path)
10430
10431 print(f"[SUCCESS] Validation completed for {len(checked)} file(s).")
10432 for path in checked:
10433 print(f" - {path}")
10434
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 10435 of file core.py.

10435def precompute_workflow(args):
10436 """!
10437 @brief Generate deterministic case artifacts without launching solver/post stages.
10438 @param[in] args Parsed precompute command arguments.
10439 """
10440 case_path = os.path.abspath(args.case)
10441 case_cfg = read_yaml_file(case_path)
10442 case_name = os.path.splitext(os.path.basename(case_path))[0]
10443 output_dir = args.output_dir or os.path.join("precomputed", case_name)
10444 output_dir = os.path.abspath(output_dir)
10445 config_dir = os.path.join(output_dir, "config")
10446 os.makedirs(config_dir, exist_ok=True)
10447
10448 print(f"[INFO] Precomputing deterministic artifacts for case: {case_path}")
10449 print(f"[INFO] Output directory: {output_dir}")
10450 validate_and_prepare_boundary_conditions(case_cfg)
10451
10452 artifacts = []
10453 grid_cfg = case_cfg.get("grid", {}) or {}
10454 grid_mode = grid_cfg.get("mode")
10455 scaling = (case_cfg.get("properties", {}) or {}).get("scaling", {}) or {}
10456 length_ref = float(scaling.get("length_ref", 1.0))
10457 expected_nblk = int((case_cfg.get("models", {}) or {}).get("domain", {}).get("blocks", 1))
10458 staged_grid = os.path.join(config_dir, "grid.run")
10459 if grid_mode == "grid_gen":
10460 print("[INFO] Precomputing grid via grid.gen...")
10461 generated_grid = run_grid_generator(case_path, output_dir, grid_cfg)
10462 artifacts.append(os.path.abspath(generated_grid))
10463 validate_and_nondimensionalize_picgrid(generated_grid, staged_grid, length_ref, expected_nblk=expected_nblk)
10464 artifacts.append(os.path.abspath(staged_grid))
10465 elif grid_mode == "file":
10466 source_grid = _resolve_case_relative_path(grid_cfg.get("source_file"), os.path.dirname(case_path))
10467 if isinstance(grid_cfg.get("legacy_conversion"), dict):
10468 source_grid = convert_legacy_grid_with_gridgen(case_path, output_dir, grid_cfg, source_grid)
10469 validate_and_nondimensionalize_picgrid(source_grid, staged_grid, length_ref, expected_nblk=expected_nblk)
10470 artifacts.append(os.path.abspath(staged_grid))
10471 print(f"[INFO] Precomputed validated file grid: {staged_grid}")
10472 elif grid_mode == "programmatic_c":
10473 print(f"[INFO] Grid mode '{grid_mode}' does not require precomputed grid generation.")
10474 else:
10475 raise ValueError(f"Unsupported grid.mode '{grid_mode}' for precompute.")
10476
10477 profile_summaries = materialize_generated_prescribed_flow_profiles(output_dir, case_cfg, case_path)
10478 artifacts.extend(summary["path"] for summary in profile_summaries)
10479 if profile_summaries:
10480 artifacts.append(os.path.join(config_dir, "profile.info"))
10481
10482 initial_condition = None
10483 resolved_ic = resolve_initial_condition_config(
10484 (case_cfg.get("properties", {}) or {}).get("initial_conditions", {}),
10485 validate_and_prepare_boundary_conditions(case_cfg),
10486 U_ref=float((case_cfg.get("properties", {}).get("scaling", {}) or {}).get("velocity_ref", 1.0)),
10487 )
10488 if resolved_ic["kind"] == "ic_gen":
10489 if grid_mode == "programmatic_c":
10490 try:
10491 summary = generate_picgrid_from_programmatic_settings(
10492 grid_cfg.get('programmatic_settings', {}), staged_grid, length_ref
10493 )
10494 artifacts.append(os.path.abspath(staged_grid))
10495 print(
10496 f"[INFO] Materialized programmatic grid.run for ic_gen: {staged_grid} "
10497 f"(nblk={summary['nblk']}, total_nodes={summary['total_nodes']})"
10498 )
10499 except Exception as e:
10500 raise RuntimeError(f"Failed to generate grid.run for ic_gen: {e}") from e
10501 initial_condition = stage_initial_condition_file(output_dir, case_path, resolved_ic)
10502 artifacts.extend([initial_condition["source"], initial_condition["staged"]])
10503 elif resolved_ic["kind"] == "file":
10504 print("[INFO] File initial condition does not require generated precompute output.")
10505 else:
10506 print(f"[INFO] Built-in initial-condition generator '{resolved_ic['label']}' runs in the C solver.")
10507
10508 manifest = {
10509 "case": case_path,
10510 "output_dir": output_dir,
10511 "grid_mode": grid_mode,
10512 "artifacts": artifacts,
10513 "profiles": profile_summaries,
10514 "initial_condition": initial_condition,
10515 }
10516 manifest_path = os.path.join(config_dir, "precompute.manifest.json")
10517 write_json_file(manifest_path, manifest)
10518 print(f"[SUCCESS] Wrote precompute manifest: {os.path.relpath(manifest_path)}")
10519 print(f"[SUCCESS] Precompute completed with {len(artifacts)} artifact(s).")
10520
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 10521 of file core.py.

10521def run_workflow(args):
10522 """!
10523 @brief Main orchestrator for the 'run' command (local and Slurm modes).
10524 @param[in] args Command-line style argument list supplied to the function.
10525 """
10526 if getattr(args, "dry_run", False):
10527 plan = build_run_dry_plan(args)
10528 render_run_dry_plan(plan, output_format=getattr(args, "output_format", "text"))
10529 return
10530
10531 run_dir = None
10532 run_id = None
10533 output_dir_abs = None
10534 statistics_output_paths = []
10535 workflow_start = time.time()
10536 stages_completed = []
10537 configs = None
10538 submission_meta = {"launch_mode": "local", "no_submit": bool(args.no_submit), "stages": {}}
10539
10540 cluster_mode = bool(getattr(args, "cluster", None))
10541 cluster_cfg = None
10542 cluster_path = None
10543 solver_num_procs_effective = args.num_procs
10544 post_num_procs_effective = 1
10545
10546 if cluster_mode:
10547 cluster_path = os.path.abspath(args.cluster)
10548 cluster_cfg = read_yaml_file(cluster_path)
10549 validate_cluster_config(cluster_cfg, cluster_path)
10550 scheduler_type = str(cluster_cfg.get("scheduler", {}).get("type", "slurm")).lower()
10551 if args.scheduler and args.scheduler.lower() != scheduler_type:
10552 print(
10553 f"[FATAL] --scheduler={args.scheduler} does not match cluster.yml scheduler.type={scheduler_type}.",
10554 file=sys.stderr
10555 )
10556 sys.exit(1)
10557 if scheduler_type != "slurm":
10558 print(f"[FATAL] Unsupported scheduler '{scheduler_type}'. Only Slurm is supported in v1.", file=sys.stderr)
10559 sys.exit(1)
10560 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
10561 if args.solve and args.num_procs not in (1, cluster_tasks):
10562 print(
10563 "[FATAL] In cluster mode, --num-procs applies to the solver stage and must be "
10564 f"1 (auto) or exactly nodes*ntasks_per_node ({cluster_tasks}).",
10565 file=sys.stderr
10566 )
10567 sys.exit(1)
10568 if args.solve:
10569 solver_num_procs_effective = cluster_tasks
10570 submission_meta["launch_mode"] = "slurm"
10571 submission_meta["cluster_config"] = cluster_path
10572 submission_meta["no_submit"] = bool(args.no_submit)
10573 if args.solve:
10574 print(
10575 f"[INFO] Cluster mode enabled (Slurm). Solver uses {solver_num_procs_effective} MPI tasks "
10576 f"from cluster.yml; post stage defaults to {post_num_procs_effective} task."
10577 )
10578 else:
10579 print(f"[INFO] Cluster mode enabled (Slurm). Post stage defaults to {post_num_procs_effective} task.")
10580 elif getattr(args, "scheduler", None):
10581 print("[FATAL] --scheduler requires --cluster in this version.", file=sys.stderr)
10582 sys.exit(1)
10583
10584 # --- Guard: restart flags without --solve ---
10585 if not args.solve:
10586 if getattr(args, 'restart_from', None):
10587 print("[WARNING] --restart-from has no effect without --solve and will be ignored.", file=sys.stderr)
10588 if getattr(args, 'continue_run', False) and not args.post_process:
10589 print("[WARNING] --continue has no effect without --solve or --post-process and will be ignored.", file=sys.stderr)
10590
10591 # --- Stage 1: Solver (if requested) ---
10592 if args.solve:
10593 walltime_guard_policy = resolve_walltime_guard_policy(cluster_cfg) if cluster_mode else None
10594 configs = {
10595 'case': read_yaml_file(args.case), 'case_path': os.path.abspath(args.case),
10596 'solver': read_yaml_file(args.solver), 'solver_path': os.path.abspath(args.solver),
10597 'monitor': read_yaml_file(args.monitor), 'monitor_path': os.path.abspath(args.monitor),
10598 'walltime_guard_policy': walltime_guard_policy,
10599 }
10600
10601 print("\n[INFO] Validating configuration files...")
10602 validate_solver_configs(
10603 configs['case'], configs['solver'], configs['monitor'],
10604 args.case, args.solver, args.monitor
10605 )
10606 print("[SUCCESS] All configuration files passed validation.\n")
10607
10608 continue_mode = getattr(args, 'continue_run', False)
10609
10610 if continue_mode:
10611 if not args.run_dir:
10612 fail_cli_usage("--continue requires --run-dir.")
10613 run_dir = os.path.abspath(args.run_dir)
10614 if not os.path.isdir(run_dir):
10615 emit_structured_error(
10616 ERROR_CODE_CFG_FILE_NOT_FOUND,
10617 key="run-dir",
10618 file_path=run_dir,
10619 message="Specified run directory not found.",
10620 )
10621 sys.exit(1)
10622 run_id = os.path.basename(run_dir)
10623 else:
10624 case_name = os.path.splitext(os.path.basename(args.case))[0]
10625 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
10626 run_id = f"{case_name}_{timestamp}"
10627 run_dir = os.path.abspath(os.path.join("runs", run_id))
10628
10629 try:
10630 resolved_restart_source_dir, is_continue = resolve_restart_source(
10631 args, configs["case"], configs["solver"], configs["monitor"], run_dir
10632 )
10633 except ValueError as e:
10634 emit_structured_error(
10635 ERROR_CODE_CFG_INCONSISTENT_COMBO,
10636 key="restart",
10637 file_path=args.case,
10638 message=str(e),
10639 )
10640 sys.exit(1)
10641
10642 config_dir = os.path.join(run_dir, "config")
10643 if not continue_mode:
10644 for d in [config_dir, os.path.join(run_dir, "scheduler")]:
10645 os.makedirs(d, exist_ok=True)
10646 else:
10647 os.makedirs(config_dir, exist_ok=True)
10648 if continue_mode:
10649 print(f"[INFO] Continuing in existing run directory: {os.path.relpath(run_dir)}")
10650 else:
10651 print(f"[INFO] Created new self-contained run directory: {os.path.relpath(run_dir)}")
10652
10653 shutil.copy(args.case, os.path.join(config_dir, "case.yml"))
10654 shutil.copy(args.solver, os.path.join(config_dir, "solver.yml"))
10655 shutil.copy(args.monitor, os.path.join(config_dir, "monitor.yml"))
10656 if cluster_mode:
10657 shutil.copy(cluster_path, os.path.join(config_dir, "cluster.yml"))
10658
10659 print("\n" + "="*25 + " SOLVER STAGE " + "="*25)
10660 source_files = {'Case': args.case, 'Solver': args.solver, 'Monitor': args.monitor}
10661 monitor_files = prepare_monitor_files(run_dir, run_id, configs['monitor'], source_files)
10662 if resolved_restart_source_dir:
10663 print(f"[INFO] Restart source: {resolved_restart_source_dir}")
10664 if is_continue:
10665 print("[INFO] Continue mode: logs will be appended, not overwritten.")
10666 control_file = generate_solver_control_file(
10667 run_dir,
10668 run_id,
10669 configs,
10670 solver_num_procs_effective,
10671 monitor_files,
10672 restart_source_dir=resolved_restart_source_dir,
10673 continue_mode=is_continue,
10674 )
10675
10676 solver_exe = resolve_runtime_executable("simulator")
10677 solver_args = build_petsc_diagnostics_args(configs["monitor"], run_dir, "Solver") + ["-control_file", control_file]
10678 if cluster_mode:
10679 scheduler_dir = os.path.join(run_dir, "scheduler")
10680 solver_script = os.path.join(scheduler_dir, "solver.sbatch")
10681 solver_log = os.path.join(scheduler_dir, "solver_%j.out")
10682 solver_err = os.path.join(scheduler_dir, "solver_%j.err")
10683 solver_cmd = build_cluster_launch_command(
10684 cluster_cfg,
10685 solver_exe,
10686 solver_args,
10687 config_search_anchor=args.case,
10688 extra_search_anchors=[cluster_path],
10689 )
10690 render_slurm_script(
10691 solver_script,
10692 f"{run_id}_solve",
10693 cluster_cfg,
10694 solver_cmd,
10695 run_dir,
10696 solver_log,
10697 solver_err,
10698 env_vars={"LOG_LEVEL": configs['monitor'].get('logging', {}).get('verbosity', 'INFO').upper()},
10699 shell_env_vars=build_walltime_guard_exports(cluster_cfg),
10700 )
10701 submission_meta["stages"]["solve"] = {
10702 "script": solver_script,
10703 "submitted": False,
10704 "num_procs_effective": solver_num_procs_effective,
10705 }
10706 print(f"[SUCCESS] Generated solver Slurm script: {os.path.relpath(solver_script)}")
10707 if not args.no_submit:
10708 submit_info = submit_sbatch(solver_script)
10709 submission_meta["stages"]["solve"].update(submit_info)
10710 submission_meta["stages"]["solve"]["submitted"] = True
10711 print(f"[SUCCESS] Submitted solver job: {submit_info['job_id']}")
10712 stages_completed.append('solve')
10713 else:
10714 command = build_local_launch_command(
10715 solver_exe,
10716 solver_args,
10717 solver_num_procs_effective,
10718 config_search_anchor=configs["case_path"],
10719 )
10720 solver_log = os.path.join("scheduler", f"{run_id}_solver.log")
10721 submission_meta["stages"]["solve"] = {
10722 "command": command,
10723 "command_string": format_command_for_display(command),
10724 "log_file": solver_log,
10725 "submitted": False,
10726 "num_procs_effective": solver_num_procs_effective,
10727 }
10728 if args.no_submit:
10729 print(f"[SUCCESS] Staged local solver command: {solver_log}")
10730 else:
10731 execute_command(command, run_dir, solver_log, configs['monitor'])
10732 submission_meta["stages"]["solve"]["submitted"] = True
10733 submission_meta["stages"]["solve"]["executed"] = True
10734 submission_meta["stages"]["solve"]["completed_at"] = datetime.now().isoformat()
10735 stages_completed.append('solve')
10736
10737 # --- Stage 2: Post-Processing (if requested) ---
10738 if args.post_process:
10739 if args.run_dir:
10740 run_dir = os.path.abspath(args.run_dir)
10741 if not os.path.isdir(run_dir):
10742 print(f"[FATAL] Specified run directory not found: {run_dir}", file=sys.stderr)
10743 sys.exit(1)
10744 print(f"[INFO] Operating on existing run directory: {os.path.relpath(run_dir)}")
10745 run_id = os.path.basename(run_dir)
10746 elif not args.solve:
10747 print("[FATAL] --post-process requires --run-dir when not used with --solve.", file=sys.stderr)
10748 sys.exit(1)
10749
10750 print("\n" + "="*20 + " POST-PROCESSING STAGE " + "="*20)
10751 config_dir = os.path.join(run_dir, "config")
10752 case_path, monitor_path, solver_control_path = auto_identify_run_inputs(config_dir)
10753
10754 if not all([case_path, monitor_path, solver_control_path]):
10755 print(f"[FATAL] Could not automatically identify required config files in {config_dir}", file=sys.stderr)
10756 if not case_path:
10757 print(" - No 'case' file found (expected 'models' + 'boundary_conditions').", file=sys.stderr)
10758 if not monitor_path:
10759 print(" - No 'monitor' file found (expected 'io' + 'logging').", file=sys.stderr)
10760 if not solver_control_path:
10761 print(" - No '.control' file found.", file=sys.stderr)
10762 sys.exit(1)
10763
10764 print(f"[INFO] Auto-identified Case file: {os.path.basename(case_path)}")
10765 print(f"[INFO] Auto-identified Monitor file: {os.path.basename(monitor_path)}")
10766
10767 case_cfg = read_yaml_file(case_path)
10768 monitor_cfg = read_yaml_file(monitor_path)
10769 post_cfg = read_yaml_file(args.post)
10770
10771 print("[INFO] Validating post-processing configuration...")
10772 validate_post_config(post_cfg, args.post)
10773 print("[SUCCESS] Post-processing configuration passed validation.\n")
10774
10775 solver_sources_deferred = bool(args.solve and (cluster_mode or args.no_submit))
10776 allow_source_frontier_scan = not solver_sources_deferred
10777 post_plan = build_post_execution_plan(
10778 run_dir,
10779 run_id,
10780 case_cfg,
10781 monitor_cfg,
10782 post_cfg,
10783 continue_requested=getattr(args, 'continue_run', False),
10784 allow_source_frontier_scan=allow_source_frontier_scan,
10785 )
10786
10787 source_template = get_post_source_directory_template(post_cfg)
10788 if source_template == '<solver_output_dir>':
10789 print(f"[INFO] Post-processor source data: {os.path.relpath(post_plan['source_data_directory'])}")
10790 else:
10791 print(f"[INFO] Post-processor source data (user-defined): {os.path.relpath(post_plan['source_data_directory'])}")
10792
10793 if getattr(args, 'continue_run', False):
10794 if post_plan['resume_recipe_match']:
10795 print(f"[INFO] Post resume recipe match: yes ({post_plan['resume_match_source']}).")
10796 else:
10797 print("[INFO] Post resume recipe match: no. Using the configured start_step for this recipe.")
10798 if post_plan['completed_frontier_step'] is not None:
10799 print(f"[INFO] Completed post frontier: step {post_plan['completed_frontier_step']}")
10800 else:
10801 print("[INFO] Completed post frontier: none")
10802 if post_plan['source_frontier_deferred']:
10803 print("[INFO] Source availability frontier: deferred because the solver stage will populate the requested window before post starts.")
10804 elif post_plan['source_frontier_step'] is not None:
10805 print(f"[INFO] Current source availability frontier: step {post_plan['source_frontier_step']}")
10806 else:
10807 print("[INFO] Current source availability frontier: none")
10808
10809 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['completed_frontier_step'])
10810
10811 if post_plan['skip_reason'] == 'already-complete-window':
10812 print("[INFO] Requested post window is already complete; skipping postprocessor launch.")
10813 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['requested_end_step'])
10814 elif post_plan['skip_reason'] == 'already-caught-up-to-current-source-frontier':
10815 print("[INFO] Post outputs are already caught up to the current fully available source frontier; nothing new to launch right now.")
10816 diagnostic = post_plan.get('source_frontier_diagnostic') or {}
10817 first_incomplete = diagnostic.get('first_incomplete_step')
10818 if first_incomplete is not None:
10819 print(f"[INFO] First incomplete requested source step: {first_incomplete}")
10820 print(
10821 "[INFO] Closest complete source steps: "
10822 f"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
10823 f"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
10824 )
10825 elif post_plan['skip_reason'] == 'nothing-available-yet':
10826 diagnostic = post_plan.get('source_frontier_diagnostic') or {}
10827 first_incomplete = diagnostic.get('first_incomplete_step')
10828 if first_incomplete is not None:
10829 print(
10830 f"[INFO] First requested source step {first_incomplete} is incomplete; "
10831 "skipping postprocessor launch for now."
10832 )
10833 print(
10834 "[INFO] Closest complete source steps: "
10835 f"near start={_format_optional_step(diagnostic.get('closest_complete_step_to_start'))}, "
10836 f"near end={_format_optional_step(diagnostic.get('closest_complete_step_to_end'))}"
10837 )
10838 missing_files = diagnostic.get('missing_files_for_first_incomplete_step') or []
10839 if missing_files:
10840 print(f"[INFO] Missing files for step {first_incomplete}: {', '.join(missing_files[:4])}")
10841 else:
10842 print("[INFO] No fully available source steps exist yet in the requested window; skipping postprocessor launch for now.")
10843 else:
10844 print(
10845 f"[INFO] Effective post window: {post_plan['effective_start_step']}..{post_plan['effective_end_step']} "
10846 f"(stride {post_plan['step_interval']})"
10847 )
10848
10849 post_effective_cfg = post_plan['effective_post_cfg']
10850 post_io_cfg = post_effective_cfg.get('io', {})
10851 try:
10852 output_dir_rel = post_io_cfg['output_directory']
10853 output_prefix = post_io_cfg['output_filename_prefix']
10854 except KeyError as e:
10855 print(f"[FATAL] Missing required key '{e.args[0]}' in the 'io' section of {args.post}", file=sys.stderr)
10856 sys.exit(1)
10857
10858 output_dir_abs = os.path.abspath(os.path.join(run_dir, output_dir_rel))
10859 os.makedirs(output_dir_abs, exist_ok=True)
10860 print(f"[INFO] Post-processor output directory: {os.path.relpath(output_dir_abs)}")
10861 statistics_output_paths = get_post_statistics_output_artifacts(post_effective_cfg, run_dir, monitor_cfg)
10862 for stats_path in statistics_output_paths:
10863 print(f"[INFO] Statistics CSV output: {os.path.relpath(stats_path)}")
10864
10865 source_files_post = {'Case': case_path, 'Post-Profile': args.post}
10866 post_recipe_file = generate_post_recipe_file(run_dir, run_id, post_effective_cfg, source_files_post, monitor_cfg)
10867
10868 post_exe = resolve_runtime_executable("postprocessor")
10869 post_args = build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor") + [
10870 "-control_file",
10871 solver_control_path,
10872 "-postprocessing_config_file",
10873 post_recipe_file,
10874 ]
10875 if cluster_mode:
10876 scheduler_dir = os.path.join(run_dir, "scheduler")
10877 os.makedirs(scheduler_dir, exist_ok=True)
10878 post_script = os.path.join(scheduler_dir, "post.sbatch")
10879 post_log = os.path.join(scheduler_dir, "post_%j.out")
10880 post_err = os.path.join(scheduler_dir, "post_%j.err")
10881 post_cluster_cfg = build_serial_post_cluster_config(cluster_cfg, post_num_procs_effective)
10882 raw_post_cmd = build_cluster_launch_command(
10883 post_cluster_cfg,
10884 post_exe,
10885 post_args,
10886 config_search_anchor=case_path,
10887 extra_search_anchors=[cluster_path],
10888 force_num_procs=post_num_procs_effective,
10889 )
10890 post_cmd, _ = build_post_locked_command(
10891 run_dir,
10892 post_plan['recipe_fingerprint'],
10893 raw_post_cmd,
10894 create_wrapper=True,
10895 )
10896 render_slurm_script(
10897 post_script,
10898 f"{run_id}_post",
10899 post_cluster_cfg,
10900 post_cmd,
10901 run_dir,
10902 post_log,
10903 post_err,
10904 env_vars={"LOG_LEVEL": monitor_cfg.get('logging', {}).get('verbosity', 'INFO').upper()},
10905 )
10906 submission_meta["stages"]["post-process"] = {
10907 "script": post_script,
10908 "submitted": False,
10909 "num_procs_effective": post_num_procs_effective,
10910 "resume_recipe_match": post_plan['resume_recipe_match'],
10911 "resume_bootstrapped": post_plan['resume_bootstrapped'],
10912 "resume_match_source": post_plan['resume_match_source'],
10913 "effective_start_step": post_plan['effective_start_step'],
10914 "effective_end_step": post_plan['effective_end_step'],
10915 "completed_frontier_step": post_plan['completed_frontier_step'],
10916 "source_frontier_step": post_plan['source_frontier_step'],
10917 "source_frontier_deferred": post_plan['source_frontier_deferred'],
10918 "recipe_fingerprint": post_plan['recipe_fingerprint'],
10919 }
10920 print(f"[SUCCESS] Generated post Slurm script: {os.path.relpath(post_script)}")
10921
10922 if not args.no_submit:
10923 dependency_job = None
10924 if args.solve:
10925 dependency_job = submission_meta.get("stages", {}).get("solve", {}).get("job_id")
10926 submit_info = submit_sbatch(post_script, dependency=dependency_job)
10927 submission_meta["stages"]["post-process"].update(submit_info)
10928 submission_meta["stages"]["post-process"]["submitted"] = True
10929 if dependency_job:
10930 submission_meta["stages"]["post-process"]["dependency"] = f"afterok:{dependency_job}"
10931 print(f"[SUCCESS] Submitted post job: {submit_info['job_id']}")
10932 stages_completed.append('post-process')
10933 else:
10934 raw_command = build_local_launch_command(
10935 post_exe,
10936 post_args,
10937 post_num_procs_effective,
10938 config_search_anchor=case_path,
10939 allow_single_rank_launcher_override=True,
10940 force_num_procs=post_num_procs_effective,
10941 )
10942 command, _ = build_post_locked_command(
10943 run_dir,
10944 post_plan['recipe_fingerprint'],
10945 raw_command,
10946 create_wrapper=True,
10947 )
10948 post_log = os.path.join("scheduler", f"{run_id}_{output_prefix}.log")
10949 submission_meta["stages"]["post-process"] = {
10950 "command": command,
10951 "command_string": format_command_for_display(command),
10952 "log_file": post_log,
10953 "submitted": False,
10954 "num_procs_effective": post_num_procs_effective,
10955 "resume_recipe_match": post_plan['resume_recipe_match'],
10956 "resume_bootstrapped": post_plan['resume_bootstrapped'],
10957 "resume_match_source": post_plan['resume_match_source'],
10958 "effective_start_step": post_plan['effective_start_step'],
10959 "effective_end_step": post_plan['effective_end_step'],
10960 "completed_frontier_step": post_plan['completed_frontier_step'],
10961 "source_frontier_step": post_plan['source_frontier_step'],
10962 "source_frontier_deferred": post_plan['source_frontier_deferred'],
10963 "recipe_fingerprint": post_plan['recipe_fingerprint'],
10964 }
10965 if args.no_submit:
10966 print(f"[SUCCESS] Staged local post command: {post_log}")
10967 else:
10968 execute_command(command, run_dir, post_log, monitor_cfg)
10969 persist_post_resume_state(run_dir, post_plan, last_successful_requested_end_step=post_plan['effective_end_step'])
10970 submission_meta["stages"]["post-process"]["submitted"] = True
10971 submission_meta["stages"]["post-process"]["executed"] = True
10972 submission_meta["stages"]["post-process"]["completed_at"] = datetime.now().isoformat()
10973 stages_completed.append('post-process')
10974
10975 if run_dir:
10976 manifest = {
10977 "run_id": run_id,
10978 "created_at": datetime.now().isoformat(),
10979 "launch_mode": "slurm" if cluster_mode else "local",
10980 "git_commit": get_git_commit(),
10981 "num_procs": solver_num_procs_effective,
10982 "solver_num_procs": solver_num_procs_effective,
10983 "post_num_procs": post_num_procs_effective,
10984 "stages_requested": {"solve": bool(args.solve), "post_process": bool(args.post_process)},
10985 "stages_completed_or_submitted": stages_completed,
10986 "inputs": {},
10987 }
10988 if args.solve:
10989 manifest["inputs"]["case"] = os.path.abspath(args.case)
10990 manifest["inputs"]["solver"] = os.path.abspath(args.solver)
10991 manifest["inputs"]["monitor"] = os.path.abspath(args.monitor)
10992 if args.post_process:
10993 manifest["inputs"]["post"] = os.path.abspath(args.post)
10994 if cluster_mode:
10995 manifest["inputs"]["cluster"] = cluster_path
10996 if submission_meta.get("stages"):
10997 write_json_file(os.path.join(run_dir, "scheduler", "submission.json"), submission_meta)
10998 write_json_file(os.path.join(run_dir, "manifest.json"), manifest)
10999
11000 if stages_completed:
11001 elapsed = time.time() - workflow_start
11002 mins, secs = divmod(int(elapsed), 60)
11003 hrs, mins = divmod(mins, 60)
11004 if hrs > 0:
11005 time_str = f"{hrs}h {mins}m {secs}s"
11006 elif mins > 0:
11007 time_str = f"{mins}m {secs}s"
11008 else:
11009 time_str = f"{secs}s"
11010
11011 print("\n" + "=" * 60)
11012 print(" RUN SUMMARY")
11013 print("=" * 60)
11014 print(f" Run ID : {run_id}")
11015 print(f" Run directory : {os.path.relpath(run_dir)}")
11016 print(f" Wall-clock : {time_str}")
11017 print(f" Stages : {', '.join(stages_completed)}")
11018 print(f" Launch mode : {'slurm' if cluster_mode else 'local'}")
11019 if args.solve:
11020 print(f" Solver MPI procs: {solver_num_procs_effective}")
11021 if args.post_process:
11022 print(f" Post MPI procs : {post_num_procs_effective}")
11023 if args.solve and configs:
11024 total_steps = configs['case'].get('run_control', {}).get('total_steps', '?')
11025 result_dir = os.path.join(run_dir, configs['monitor'].get('io', {}).get('directories', {}).get('output', 'output'))
11026 print(f" Steps run : {total_steps}")
11027 print(f" Solver output : {os.path.relpath(result_dir)}")
11028 if 'post-process' in stages_completed and output_dir_abs:
11029 print(f" Post output : {os.path.relpath(output_dir_abs)}")
11030 for stats_path in statistics_output_paths:
11031 print(f" Stats output : {os.path.relpath(stats_path)}")
11032 print(f" Logs : {os.path.relpath(os.path.join(run_dir, 'logs'))}")
11033 if cluster_mode or submission_meta.get("stages"):
11034 submission_file = os.path.join(run_dir, "scheduler", "submission.json")
11035 print(f" Submission meta: {os.path.relpath(submission_file)}")
11036 print("=" * 60)
11037
11038
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 11039 of file core.py.

11039def parse_case_index_tsv(tsv_path: str) -> list:
11040 """!
11041 @brief Parse a case_index.tsv file back into a list of case entry dicts.
11042 @param[in] tsv_path Path to the case_index.tsv file.
11043 @return List of dicts with keys: index, case_id, run_dir, control_file,
11044 post_recipe_file, log_level, post_prefix.
11045 """
11046 entries = []
11047 with open(tsv_path) as f:
11048 for line in f:
11049 line = line.strip()
11050 if not line:
11051 continue
11052 parts = line.split("\t")
11053 entries.append({
11054 "index": int(parts[0]),
11055 "case_id": parts[1],
11056 "run_dir": parts[2],
11057 "control_file": parts[3],
11058 "post_recipe_file": parts[4],
11059 "log_level": parts[5],
11060 "post_prefix": parts[6],
11061 })
11062 return entries
11063
11064
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 11065 of file core.py.

11065def sweep_workflow(args):
11066 """!
11067 @brief Study/sweep orchestration using Slurm job arrays.
11068 @param[in] args Command-line style argument list supplied to the function.
11069 """
11070 study_path = os.path.abspath(args.study)
11071 cluster_path = os.path.abspath(args.cluster)
11072
11073 study_cfg = read_yaml_file(study_path)
11074 cluster_cfg = read_yaml_file(cluster_path)
11075 validate_study_config(study_cfg, study_path)
11076 validate_cluster_config(cluster_cfg, cluster_path)
11077
11078 study_name = os.path.splitext(os.path.basename(study_path))[0]
11079 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
11080 study_id = f"{study_name}_{timestamp}"
11081 study_dir = os.path.abspath(os.path.join("studies", study_id))
11082 cases_dir = os.path.join(study_dir, "cases")
11083 scheduler_dir = os.path.join(study_dir, "scheduler")
11084 results_dir = os.path.join(study_dir, "results")
11085 for path in [cases_dir, scheduler_dir, results_dir]:
11086 os.makedirs(path, exist_ok=True)
11087
11088 print(f"[INFO] Creating study directory: {os.path.relpath(study_dir)}")
11089 shutil.copy(study_path, os.path.join(study_dir, "study.yml"))
11090 shutil.copy(cluster_path, os.path.join(study_dir, "cluster.yml"))
11091
11092 base_cfgs = study_cfg["base_configs"]
11093 base_paths = {k: resolve_path(study_path, v) for k, v in base_cfgs.items()}
11094 base_case = read_yaml_file(base_paths["case"])
11095 base_solver = read_yaml_file(base_paths["solver"])
11096 base_monitor = read_yaml_file(base_paths["monitor"])
11097 base_post = read_yaml_file(base_paths["post"])
11098 validate_solver_configs(base_case, base_solver, base_monitor, base_paths["case"], base_paths["solver"], base_paths["monitor"])
11099 validate_post_config(base_post, base_paths["post"])
11100
11101 combinations = expand_study_parameter_combinations(study_cfg)
11102 if not combinations:
11103 print("[FATAL] Study parameter matrix expanded to zero cases.", file=sys.stderr)
11104 sys.exit(1)
11105 print(f"[INFO] Expanded sweep matrix to {len(combinations)} case(s).")
11106
11107 cluster_tasks = get_cluster_total_tasks(cluster_cfg)
11108 case_entries = []
11109 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
11110
11111 for idx, combo in enumerate(combinations):
11112 case_id = f"case_{idx:04d}"
11113 run_dir = os.path.join(cases_dir, case_id)
11114 config_dir = os.path.join(run_dir, "config")
11115 os.makedirs(config_dir, exist_ok=True)
11116 os.makedirs(os.path.join(run_dir, "logs"), exist_ok=True)
11117 os.makedirs(os.path.join(run_dir, "output"), exist_ok=True)
11118
11119 case_cfg = copy.deepcopy(base_case)
11120 solver_cfg = copy.deepcopy(base_solver)
11121 monitor_cfg = copy.deepcopy(base_monitor)
11122 post_cfg = copy.deepcopy(base_post)
11123 target_map = {"case": case_cfg, "solver": solver_cfg, "monitor": monitor_cfg, "post": post_cfg}
11124 for full_key, value in combo.items():
11125 root, nested = full_key.split(".", 1)
11126 _deep_set(target_map[root], nested, value)
11127
11128 # Preserve file-based/grid-gen workflows when study cases are materialized
11129 # into new directories by rewriting external paths as absolute.
11130 absolutize_case_external_paths(case_cfg, base_paths["case"])
11131
11132 case_path = os.path.join(config_dir, "case.yml")
11133 solver_path = os.path.join(config_dir, "solver.yml")
11134 monitor_path = os.path.join(config_dir, "monitor.yml")
11135 post_path = os.path.join(config_dir, "post.yml")
11136 write_yaml_file(case_path, case_cfg)
11137 write_yaml_file(solver_path, solver_cfg)
11138 write_yaml_file(monitor_path, monitor_cfg)
11139 write_yaml_file(post_path, post_cfg)
11140
11141 validate_solver_configs(case_cfg, solver_cfg, monitor_cfg, case_path, solver_path, monitor_path)
11142 validate_post_config(post_cfg, post_path)
11143
11144 source_files = {'Case': case_path, 'Solver': solver_path, 'Monitor': monitor_path}
11145 monitor_files = prepare_monitor_files(run_dir, case_id, monitor_cfg, source_files)
11146 configs = {
11147 "case": case_cfg, "case_path": case_path,
11148 "solver": solver_cfg, "solver_path": solver_path,
11149 "monitor": monitor_cfg, "monitor_path": monitor_path,
11150 "walltime_guard_policy": resolve_walltime_guard_policy(cluster_cfg),
11151 }
11152 control_file = generate_solver_control_file(run_dir, case_id, configs, cluster_tasks, monitor_files)
11153
11154 source_dir = resolve_post_source_directory(run_dir, monitor_cfg, post_cfg, strict=False)
11155 if not isinstance(post_cfg.get('source_data'), dict):
11156 post_cfg['source_data'] = {}
11157 post_cfg['source_data']['directory'] = source_dir
11158 output_prefix = post_cfg.get("io", {}).get("output_filename_prefix", "post")
11159 post_recipe = generate_post_recipe_file(run_dir, case_id, post_cfg, {'Case': case_path, 'Post-Profile': post_path}, monitor_cfg)
11160
11161 case_entries.append({
11162 "index": idx,
11163 "case_id": case_id,
11164 "run_dir": os.path.abspath(run_dir),
11165 "control_file": control_file,
11166 "post_recipe_file": post_recipe,
11167 "log_level": str(monitor_cfg.get("logging", {}).get("verbosity", "INFO")).upper(),
11168 "post_prefix": output_prefix,
11169 "solve_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "Solver")),
11170 "post_diagnostic_args": shlex.join(build_petsc_diagnostics_args(monitor_cfg, run_dir, "PostProcessor")),
11171 "parameters": combo,
11172 })
11173
11174 with open(case_index_file, "w") as f:
11175 for entry in case_entries:
11176 f.write(
11177 "\t".join(
11178 [
11179 str(entry["index"]),
11180 entry["case_id"],
11181 entry["run_dir"],
11182 entry["control_file"],
11183 entry["post_recipe_file"],
11184 entry["log_level"],
11185 entry["post_prefix"],
11186 entry["solve_diagnostic_args"],
11187 entry["post_diagnostic_args"],
11188 ]
11189 ) + "\n"
11190 )
11191 print(f"[SUCCESS] Wrote sweep case index: {os.path.relpath(case_index_file)}")
11192
11193 max_idx = len(case_entries) - 1
11194 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
11195 array_spec = f"0-{max_idx}"
11196 if max_conc:
11197 array_spec = f"{array_spec}%{max_conc}"
11198
11199 solver_exe = resolve_runtime_executable("simulator")
11200 post_exe = resolve_runtime_executable("postprocessor")
11201 solver_array_script = os.path.join(scheduler_dir, "solver_array.sbatch")
11202 post_array_script = os.path.join(scheduler_dir, "post_array.sbatch")
11203 render_slurm_array_stage_script(
11204 solver_array_script,
11205 f"{study_id}_solve",
11206 cluster_cfg,
11207 array_spec,
11208 case_index_file,
11209 "solve",
11210 solver_exe,
11211 post_exe,
11212 os.path.join(scheduler_dir, "solver_%A_%a.out"),
11213 os.path.join(scheduler_dir, "solver_%A_%a.err")
11214 )
11215 render_slurm_array_stage_script(
11216 post_array_script,
11217 f"{study_id}_post",
11218 cluster_cfg,
11219 array_spec,
11220 case_index_file,
11221 "post",
11222 solver_exe,
11223 post_exe,
11224 os.path.join(scheduler_dir, "post_%A_%a.out"),
11225 os.path.join(scheduler_dir, "post_%A_%a.err")
11226 )
11227 print(f"[SUCCESS] Generated Slurm array scripts in {os.path.relpath(scheduler_dir)}")
11228
11229 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
11230 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_aggregate.sbatch")
11231 render_metrics_aggregate_script(
11232 metrics_aggregate_script,
11233 f"{study_id}_metrics",
11234 cluster_cfg,
11235 study_dir,
11236 picurv_path,
11237 )
11238 print(f"[SUCCESS] Generated metrics aggregation script: {os.path.relpath(metrics_aggregate_script)}")
11239
11240 submission = {
11241 "launch_mode": "slurm",
11242 "study_id": study_id,
11243 "solver_array": {"script": solver_array_script, "submitted": False},
11244 "post_array": {"script": post_array_script, "submitted": False},
11245 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
11246 "no_submit": bool(args.no_submit),
11247 }
11248 if not args.no_submit:
11249 solver_submit = submit_sbatch(solver_array_script)
11250 submission["solver_array"].update(solver_submit)
11251 submission["solver_array"]["submitted"] = True
11252 post_submit = submit_sbatch(post_array_script, dependency=solver_submit["job_id"])
11253 submission["post_array"].update(post_submit)
11254 submission["post_array"]["submitted"] = True
11255 submission["post_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
11256 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
11257 submission["metrics_aggregate"].update(metrics_submit)
11258 submission["metrics_aggregate"]["submitted"] = True
11259 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
11260 print(f"[SUCCESS] Submitted solver array job: {solver_submit['job_id']}")
11261 print(f"[SUCCESS] Submitted post array job: {post_submit['job_id']}")
11262 print(f"[SUCCESS] Submitted metrics agg. job: {metrics_submit['job_id']}")
11263
11264 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
11265 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
11266
11267 summary = {
11268 "study_id": study_id,
11269 "created_at": datetime.now().isoformat(),
11270 "git_commit": get_git_commit(),
11271 "study_type": study_cfg.get("study_type"),
11272 "num_cases": len(case_entries),
11273 "paths": {
11274 "study_dir": study_dir,
11275 "case_index": case_index_file,
11276 "solver_array_script": solver_array_script,
11277 "post_array_script": post_array_script,
11278 "metrics_table": metrics_csv,
11279 "plots_dir": os.path.join(results_dir, "plots"),
11280 },
11281 "submission": submission,
11282 }
11283 write_json_file(os.path.join(scheduler_dir, "submission.json"), submission)
11284 write_json_file(os.path.join(study_dir, "study_manifest.json"), summary)
11285 write_json_file(os.path.join(results_dir, "summary.json"), {"study_id": study_id, "metrics_csv": metrics_csv, "plots": plots})
11286
11287 print("\n" + "=" * 60)
11288 print(" STUDY SUMMARY")
11289 print("=" * 60)
11290 print(f" Study ID : {study_id}")
11291 print(f" Study directory : {os.path.relpath(study_dir)}")
11292 print(f" Cases generated : {len(case_entries)}")
11293 print(f" Array spec : {array_spec}")
11294 print(f" Solver script : {os.path.relpath(solver_array_script)}")
11295 print(f" Post script : {os.path.relpath(post_array_script)}")
11296 if metrics_csv:
11297 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
11298 if plots:
11299 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
11300 print("=" * 60)
11301
11302
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 11303 of file core.py.

11303def sweep_continue_workflow(args):
11304 """!
11305 @brief Continue a partially-completed Slurm parameter sweep study.
11306 @details Detects incomplete cases, prepares them for continuation (updating
11307 start_step, populating restart directories, regenerating control files),
11308 and submits new solver/post/metrics Slurm jobs. If all cases are already
11309 complete, performs metrics aggregation automatically.
11310 @param[in] args Parsed CLI arguments with study_dir and optional cluster override.
11311 """
11312 study_dir = os.path.abspath(args.study_dir)
11313 manifest_path = os.path.join(study_dir, "study_manifest.json")
11314 if not os.path.isfile(manifest_path):
11315 print(f"[FATAL] Study manifest not found: {manifest_path}", file=sys.stderr)
11316 sys.exit(1)
11317 manifest = _read_json_if_exists(manifest_path)
11318 study_id = manifest["study_id"]
11319
11320 study_path = os.path.join(study_dir, "study.yml")
11321 cluster_path = os.path.abspath(args.cluster) if args.cluster else os.path.join(study_dir, "cluster.yml")
11322 study_cfg = read_yaml_file(study_path)
11323 cluster_cfg = read_yaml_file(cluster_path)
11324 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
11325 validate_cluster_config(cluster_cfg, cluster_path)
11326
11327 if args.cluster:
11328 shutil.copy(os.path.abspath(args.cluster), os.path.join(study_dir, "cluster.yml"))
11329 print(f"[INFO] Updated study cluster config from: {os.path.relpath(args.cluster)}")
11330
11331 scheduler_dir = os.path.join(study_dir, "scheduler")
11332 cases_dir = os.path.join(study_dir, "cases")
11333 results_dir = os.path.join(study_dir, "results")
11334 case_index_file = os.path.join(scheduler_dir, "case_index.tsv")
11335 if not os.path.isfile(case_index_file):
11336 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
11337 sys.exit(1)
11338
11339 parsed_entries = parse_case_index_tsv(case_index_file)
11340
11341 base_cfgs = study_cfg["base_configs"]
11342 base_paths = {k: resolve_path(study_path, v) for k, v in base_cfgs.items()}
11343 base_case = read_yaml_file(base_paths["case"])
11344
11345 combinations = expand_study_parameter_combinations(study_cfg)
11346 if len(combinations) != len(parsed_entries):
11347 print(
11348 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
11349 f"case_index.tsv ({len(parsed_entries)} entries).",
11350 file=sys.stderr,
11351 )
11352 sys.exit(1)
11353
11354 print(f"\n[INFO] Study: {study_id}")
11355 print(f"[INFO] Scanning {len(combinations)} case(s) for completion status...")
11356
11357 incomplete_indices = []
11358 all_case_entries = []
11359 for idx, combo in enumerate(combinations):
11360 case_id = f"case_{idx:04d}"
11361 entry = parsed_entries[idx]
11362 entry["parameters"] = combo
11363 run_dir = entry["run_dir"]
11364
11365 effective_case = copy.deepcopy(base_case)
11366 for full_key, value in combo.items():
11367 root, nested = full_key.split(".", 1)
11368 if root == "case":
11369 _deep_set(effective_case, nested, value)
11370 try:
11371 eff_start = int(effective_case.get("run_control", {}).get("start_step", 0) or 0)
11372 except (TypeError, ValueError):
11373 eff_start = 0
11374 eff_total = int(effective_case["run_control"]["total_steps"])
11375 target = eff_start + eff_total
11376
11377 monitor_cfg = read_yaml_file(os.path.join(run_dir, "config", "monitor.yml"))
11378 status = detect_case_completion_status(run_dir, monitor_cfg, target)
11379 entry["_status"] = status
11380
11381 if status["status"] == "complete":
11382 print(f" {case_id}: complete (step {status['last_step']}/{target})")
11383 elif status["status"] == "partial":
11384 print(f" {case_id}: incomplete (step {status['last_step']}/{target}) — will continue")
11385 incomplete_indices.append(idx)
11386 else:
11387 print(f" {case_id}: no checkpoint — will re-run from scratch")
11388 incomplete_indices.append(idx)
11389
11390 all_case_entries.append(entry)
11391
11392 if not incomplete_indices:
11393 print("\n[INFO] All cases are complete. Running metrics aggregation...")
11394 metrics_csv = aggregate_study_metrics(study_cfg, all_case_entries, results_dir)
11395 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
11396 print("\n" + "=" * 60)
11397 print(" STUDY CONTINUATION SUMMARY")
11398 print("=" * 60)
11399 print(f" Study ID : {study_id}")
11400 print(f" Status : ALL COMPLETE")
11401 if metrics_csv:
11402 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
11403 if plots:
11404 print(f" Plots : {os.path.relpath(os.path.join(results_dir, 'plots'))}")
11405 print("=" * 60)
11406 return
11407
11408 print(f"\n[INFO] {len(incomplete_indices)} incomplete case(s) to continue/re-run.")
11409
11410 skipped = []
11411 for idx in incomplete_indices:
11412 entry = all_case_entries[idx]
11413 status = entry["_status"]
11414 if status["status"] == "partial":
11415 prepare_case_for_continuation(
11416 entry["run_dir"], entry["case_id"],
11417 status["last_step"], status["target_step"],
11418 cluster_cfg,
11419 )
11420 elif status["status"] == "empty":
11421 print(f"[INFO] {entry['case_id']}: re-running from scratch (no control file changes)")
11422
11423 solver_array_spec = ",".join(str(i) for i in incomplete_indices)
11424 max_conc = study_cfg.get("execution", {}).get("max_concurrent_array_tasks")
11425 if max_conc:
11426 solver_array_spec = f"{solver_array_spec}%{max_conc}"
11427
11428 max_idx = len(combinations) - 1
11429 post_array_spec = f"0-{max_idx}"
11430 if max_conc:
11431 post_array_spec = f"{post_array_spec}%{max_conc}"
11432
11433 solver_exe = resolve_runtime_executable("simulator")
11434 post_exe = resolve_runtime_executable("postprocessor")
11435
11436 solver_continue_script = os.path.join(scheduler_dir, "solver_continue_array.sbatch")
11437 post_continue_script = os.path.join(scheduler_dir, "post_continue_array.sbatch")
11438 render_slurm_array_stage_script(
11439 solver_continue_script,
11440 f"{study_id}_solve_cont",
11441 cluster_cfg,
11442 solver_array_spec,
11443 case_index_file,
11444 "solve",
11445 solver_exe, post_exe,
11446 os.path.join(scheduler_dir, "solver_cont_%A_%a.out"),
11447 os.path.join(scheduler_dir, "solver_cont_%A_%a.err"),
11448 )
11449 render_slurm_array_stage_script(
11450 post_continue_script,
11451 f"{study_id}_post_cont",
11452 cluster_cfg,
11453 post_array_spec,
11454 case_index_file,
11455 "post",
11456 solver_exe, post_exe,
11457 os.path.join(scheduler_dir, "post_cont_%A_%a.out"),
11458 os.path.join(scheduler_dir, "post_cont_%A_%a.err"),
11459 )
11460
11461 picurv_path = os.path.abspath(os.path.join(INVOKED_SCRIPT_DIR, "picurv"))
11462 metrics_aggregate_script = os.path.join(scheduler_dir, "metrics_continue_aggregate.sbatch")
11463 render_metrics_aggregate_script(
11464 metrics_aggregate_script,
11465 f"{study_id}_metrics_cont",
11466 cluster_cfg,
11467 study_dir,
11468 picurv_path,
11469 )
11470 print(f"[SUCCESS] Generated continuation scripts in {os.path.relpath(scheduler_dir)}")
11471
11472 submission = {
11473 "launch_mode": "slurm",
11474 "study_id": study_id,
11475 "continuation": True,
11476 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
11477 "solver_continue_array": {"script": solver_continue_script, "submitted": False},
11478 "post_continue_array": {"script": post_continue_script, "submitted": False},
11479 "metrics_aggregate": {"script": metrics_aggregate_script, "submitted": False},
11480 "no_submit": bool(args.no_submit),
11481 }
11482 if not args.no_submit:
11483 solver_submit = submit_sbatch(solver_continue_script)
11484 submission["solver_continue_array"].update(solver_submit)
11485 submission["solver_continue_array"]["submitted"] = True
11486 post_submit = submit_sbatch(post_continue_script, dependency=solver_submit["job_id"])
11487 submission["post_continue_array"].update(post_submit)
11488 submission["post_continue_array"]["submitted"] = True
11489 submission["post_continue_array"]["dependency"] = f"afterok:{solver_submit['job_id']}"
11490 metrics_submit = submit_sbatch(metrics_aggregate_script, dependency=post_submit["job_id"], dependency_type="afterany")
11491 submission["metrics_aggregate"].update(metrics_submit)
11492 submission["metrics_aggregate"]["submitted"] = True
11493 submission["metrics_aggregate"]["dependency"] = f"afterany:{post_submit['job_id']}"
11494 print(f"[SUCCESS] Submitted continuation solver array: {solver_submit['job_id']}")
11495 print(f"[SUCCESS] Submitted continuation post array: {post_submit['job_id']}")
11496 print(f"[SUCCESS] Submitted metrics aggregation job: {metrics_submit['job_id']}")
11497
11498 write_json_file(os.path.join(scheduler_dir, "submission_continue.json"), submission)
11499
11500 manifest["continuation"] = {
11501 "continued_at": datetime.now().isoformat(),
11502 "incomplete_cases": [all_case_entries[i]["case_id"] for i in incomplete_indices],
11503 "submission": submission,
11504 }
11505 write_json_file(manifest_path, manifest)
11506
11507 print("\n" + "=" * 60)
11508 print(" STUDY CONTINUATION SUMMARY")
11509 print("=" * 60)
11510 print(f" Study ID : {study_id}")
11511 print(f" Incomplete cases : {len(incomplete_indices)}/{len(combinations)}")
11512 print(f" Solver array spec : {solver_array_spec}")
11513 print(f" Post array spec : {post_array_spec}")
11514 print(f" Solver script : {os.path.relpath(solver_continue_script)}")
11515 print(f" Post script : {os.path.relpath(post_continue_script)}")
11516 print(f" Metrics script : {os.path.relpath(metrics_aggregate_script)}")
11517 if not args.no_submit:
11518 print(f" [Metrics aggregation will run automatically after post-processing]")
11519 else:
11520 print(f" [--no-submit] Scripts generated but not submitted.")
11521 print(f" After manual submission and completion, run:")
11522 print(f" picurv sweep --reaggregate --study-dir {os.path.relpath(study_dir)}")
11523 print("=" * 60)
11524
11525
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 11526 of file core.py.

11526def sweep_reaggregate_workflow(args):
11527 """!
11528 @brief Re-run metrics aggregation and plot generation for an existing study.
11529 @param[in] args Parsed CLI arguments with study_dir.
11530 """
11531 study_dir = os.path.abspath(args.study_dir)
11532 study_path = os.path.join(study_dir, "study.yml")
11533 if not os.path.isfile(study_path):
11534 print(f"[FATAL] Study config not found: {study_path}", file=sys.stderr)
11535 sys.exit(1)
11536 study_cfg = read_yaml_file(study_path)
11537 validate_study_config(study_cfg, study_path, skip_base_file_check=True)
11538
11539 case_index_file = os.path.join(study_dir, "scheduler", "case_index.tsv")
11540 if not os.path.isfile(case_index_file):
11541 print(f"[FATAL] Case index not found: {case_index_file}", file=sys.stderr)
11542 sys.exit(1)
11543
11544 parsed_entries = parse_case_index_tsv(case_index_file)
11545 combinations = expand_study_parameter_combinations(study_cfg)
11546 if len(combinations) != len(parsed_entries):
11547 print(
11548 f"[FATAL] Parameter matrix ({len(combinations)} cases) does not match "
11549 f"case_index.tsv ({len(parsed_entries)} entries).",
11550 file=sys.stderr,
11551 )
11552 sys.exit(1)
11553
11554 case_entries = []
11555 for idx, combo in enumerate(combinations):
11556 entry = parsed_entries[idx]
11557 entry["parameters"] = combo
11558 case_entries.append(entry)
11559
11560 results_dir = os.path.join(study_dir, "results")
11561 metrics_csv = aggregate_study_metrics(study_cfg, case_entries, results_dir)
11562 plots = generate_study_plots(study_cfg, metrics_csv, os.path.join(results_dir, "plots"))
11563
11564 print("\n" + "=" * 60)
11565 print(" REAGGREGATION SUMMARY")
11566 print("=" * 60)
11567 if metrics_csv:
11568 print(f" Metrics table : {os.path.relpath(metrics_csv)}")
11569 if plots:
11570 print(f" Plots generated : {len(plots)}")
11571 print("=" * 60)
11572
11573
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 11577 of file core.py.

11577def _read_yaml_if_exists(filepath: str):
11578 """!
11579 @brief Read YAML when present, otherwise return None.
11580 @param[in] filepath Argument passed to `_read_yaml_if_exists()`.
11581 @return Value returned by `_read_yaml_if_exists()`.
11582 """
11583 if not filepath or not os.path.isfile(filepath):
11584 return None
11585 try:
11586 with open(filepath, "r", encoding="utf-8") as f:
11587 return yaml.safe_load(f)
11588 except yaml.YAMLError:
11589 return None
11590
11591
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 11592 of file core.py.

11592def _read_json_if_exists(filepath: str):
11593 """!
11594 @brief Read JSON when present, otherwise return None.
11595 @param[in] filepath Argument passed to `_read_json_if_exists()`.
11596 @return Value returned by `_read_json_if_exists()`.
11597 """
11598 if not filepath or not os.path.isfile(filepath):
11599 return None
11600 with open(filepath, "r", encoding="utf-8") as f:
11601 return json.load(f)
11602
11603
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 11604 of file core.py.

11604def _parse_int_loose(value):
11605 """!
11606 @brief Best-effort integer parsing for summary extraction.
11607 @param[in] value Argument passed to `_parse_int_loose()`.
11608 @return Value returned by `_parse_int_loose()`.
11609 """
11610 if value is None:
11611 return None
11612 text = str(value).strip()
11613 if not text:
11614 return None
11615 try:
11616 return int(text)
11617 except ValueError:
11618 try:
11619 return int(float(text))
11620 except ValueError:
11621 return None
11622
11623
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 11624 of file core.py.

11624def _parse_float_loose(value):
11625 """!
11626 @brief Best-effort float parsing for summary extraction.
11627 @param[in] value Argument passed to `_parse_float_loose()`.
11628 @return Value returned by `_parse_float_loose()`.
11629 """
11630 if value is None:
11631 return None
11632 text = str(value).strip()
11633 if not text:
11634 return None
11635 try:
11636 return float(text)
11637 except ValueError:
11638 return None
11639
11640
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 11641 of file core.py.

11641def _extract_numeric_tuple(text: str):
11642 """!
11643 @brief Extract a numeric tuple from a string like '(1, 2, 3)'.
11644 @param[in] text Argument passed to `_extract_numeric_tuple()`.
11645 @return Value returned by `_extract_numeric_tuple()`.
11646 """
11647 if not text:
11648 return []
11649 return [float(token) for token in _SUMMARY_NUMERIC_RE.findall(text)]
11650
11651
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 11652 of file core.py.

11652def _build_summary_context(run_dir: str) -> dict:
11653 """!
11654 @brief Resolve run-local config and artifact paths for summarize.
11655 @param[in] run_dir Argument passed to `_build_summary_context()`.
11656 @return Value returned by `_build_summary_context()`.
11657 """
11658 run_dir = os.path.abspath(run_dir)
11659 if not os.path.isdir(run_dir):
11660 emit_structured_error(
11661 ERROR_CODE_CFG_FILE_NOT_FOUND,
11662 key="run_dir",
11663 file_path=run_dir,
11664 message="Run directory not found.",
11665 )
11666 sys.exit(1)
11667
11668 config_dir = os.path.join(run_dir, "config")
11669 config_paths = {
11670 "case": os.path.join(config_dir, "case.yml"),
11671 "solver": os.path.join(config_dir, "solver.yml"),
11672 "monitor": os.path.join(config_dir, "monitor.yml"),
11673 }
11674 monitor_cfg = _read_yaml_if_exists(config_paths["monitor"]) or {}
11675 case_cfg = _read_yaml_if_exists(config_paths["case"]) or {}
11676 solver_cfg = _read_yaml_if_exists(config_paths["solver"]) or {}
11677 manifest = _read_json_if_exists(os.path.join(run_dir, "manifest.json")) or {}
11678
11679 io_cfg = monitor_cfg.get("io", {}) if isinstance(monitor_cfg, dict) else {}
11680 io_dirs = io_cfg.get("directories", {}) if isinstance(io_cfg, dict) else {}
11681 log_dir_name = io_dirs.get("log", "logs")
11682 scheduler_dir = os.path.join(run_dir, "scheduler")
11683
11684 profiling_cfg = {"mode": "off", "functions": [], "timestep_file": "Profiling_Timestep_Summary.csv", "final_summary_enabled": True}
11685 if monitor_cfg:
11686 profiling_cfg = resolve_profiling_config(monitor_cfg)
11687
11688 particle_console_output_freq = None
11689 particle_log_interval = None
11690 if monitor_cfg:
11691 particle_console_output_freq = resolve_particle_console_output_frequency(io_cfg)
11692 particle_log_interval = io_cfg.get("particle_log_interval")
11693
11694 particle_count_cfg = None
11695 if case_cfg:
11696 particle_count_cfg = (
11697 case_cfg.get("models", {})
11698 .get("physics", {})
11699 .get("particles", {})
11700 .get("count")
11701 )
11702
11703 return {
11704 "run_dir": run_dir,
11705 "config_dir": config_dir,
11706 "log_dir": os.path.join(run_dir, log_dir_name),
11707 "scheduler_dir": scheduler_dir,
11708 "monitor_cfg": monitor_cfg,
11709 "case_cfg": case_cfg,
11710 "solver_cfg": solver_cfg,
11711 "config_paths": config_paths,
11712 "manifest": manifest,
11713 "profiling_cfg": profiling_cfg,
11714 "particle_console_output_freq": particle_console_output_freq,
11715 "particle_log_interval": particle_log_interval,
11716 "particle_count_cfg": particle_count_cfg,
11717 }
11718
11719
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 11720 of file core.py.

11720def _require_summary_config(context: dict, name: str) -> dict:
11721 """!
11722 @brief Return one explicitly requested copied config or fail with a structured error.
11723 @param[in] context Summary context returned by `_build_summary_context()`.
11724 @param[in] name Config selector name.
11725 @return Parsed config mapping.
11726 """
11727 path = context["config_paths"][name]
11728 cfg = context.get(f"{name}_cfg")
11729 if not os.path.isfile(path):
11730 emit_structured_error(
11731 ERROR_CODE_CFG_FILE_NOT_FOUND,
11732 key=name,
11733 file_path=path,
11734 message=f"Copied run config '{name}.yml' was not found.",
11735 hint="Use a staged run directory containing the requested copied config.",
11736 )
11737 sys.exit(1)
11738 if not isinstance(cfg, dict) or not cfg:
11739 emit_structured_error(
11740 ERROR_CODE_CFG_INVALID_VALUE,
11741 key=name,
11742 file_path=path,
11743 message=f"Copied run config '{name}.yml' is empty or is not a YAML mapping.",
11744 )
11745 sys.exit(1)
11746 return cfg
11747
11748
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 11749 of file core.py.

11749def _build_run_overview(context: dict) -> dict:
11750 """!
11751 @brief Build timestep-independent run metadata for summarize.
11752 @param[in] context Summary context returned by `_build_summary_context()`.
11753 @return Curated run metadata mapping.
11754 """
11755 manifest = context["manifest"]
11756 return {
11757 "run_id": manifest.get("run_id", os.path.basename(context["run_dir"])),
11758 "run_dir": context["run_dir"],
11759 "created_at": manifest.get("created_at"),
11760 "launch_mode": manifest.get("launch_mode"),
11761 "git_commit": manifest.get("git_commit"),
11762 "solver_num_procs": manifest.get("solver_num_procs", manifest.get("num_procs")),
11763 "post_num_procs": manifest.get("post_num_procs"),
11764 "stages_requested": manifest.get("stages_requested"),
11765 "stages_completed_or_submitted": manifest.get("stages_completed_or_submitted"),
11766 }
11767
11768
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 11769 of file core.py.

11769def _summarize_turbulence(turbulence_cfg: dict) -> dict:
11770 """!
11771 @brief Build compact turbulence and wall-model selections.
11772 @param[in] turbulence_cfg Case turbulence configuration mapping.
11773 @return Curated turbulence and wall-model mapping.
11774 """
11775 result = {}
11776 for key in ("les", "rans", "wall_function"):
11777 value = turbulence_cfg.get(key)
11778 if isinstance(value, dict):
11779 result[key] = {
11780 "enabled": value.get("enabled", True),
11781 "model": value.get("model"),
11782 **{k: v for k, v in value.items() if k not in {"enabled", "model"}},
11783 }
11784 elif value is not None:
11785 result[key] = value
11786 return result
11787
11788
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 11789 of file core.py.

11789def _build_case_overview(context: dict) -> dict:
11790 """!
11791 @brief Build a curated case.yml summary with useful derived quantities.
11792 @param[in] context Summary context returned by `_build_summary_context()`.
11793 @return Curated case configuration mapping.
11794 """
11795 cfg = _require_summary_config(context, "case")
11796 props = cfg.get("properties", {})
11797 scaling = props.get("scaling", {})
11798 fluid = props.get("fluid", {})
11799 run = cfg.get("run_control", {})
11800 grid = cfg.get("grid", {})
11801 models = cfg.get("models", {})
11802 domain = models.get("domain", {})
11803 physics = models.get("physics", {})
11804 particles = physics.get("particles", {})
11805 start = int(run.get("start_step", 0))
11806 total = int(run.get("total_steps", 0))
11807 dt = float(run.get("dt_physical", 0.0))
11808 length_ref = float(scaling.get("length_ref"))
11809 velocity_ref = float(scaling.get("velocity_ref"))
11810 density = float(fluid.get("density"))
11811 viscosity = float(fluid.get("viscosity"))
11812 prepared_bcs = validate_and_prepare_boundary_conditions(cfg)
11813 first_block_faces = {row["face"]: row for row in prepared_bcs[0]}
11814 periodic_axes = {
11815 "i": first_block_faces["-Xi"]["type"] == "PERIODIC",
11816 "j": first_block_faces["-Eta"]["type"] == "PERIODIC",
11817 "k": first_block_faces["-Zeta"]["type"] == "PERIODIC",
11818 }
11819 bc_blocks = []
11820 for block_idx, block in enumerate(prepared_bcs):
11821 bc_blocks.append(
11822 {
11823 "block": block_idx,
11824 "faces": [
11825 {"face": row["face"], "type": row["type"], "handler": row["handler"]}
11826 for row in block
11827 ],
11828 }
11829 )
11830 return {
11831 "run_control": {
11832 "start_step": start,
11833 "total_steps": total,
11834 "end_step": start + total,
11835 "dt_physical": dt,
11836 "duration_physical": total * dt,
11837 "dt_nondimensional": dt * velocity_ref / length_ref,
11838 },
11839 "properties": {
11840 "length_ref": length_ref,
11841 "velocity_ref": velocity_ref,
11842 "density": density,
11843 "viscosity": viscosity,
11844 "reynolds_number": density * velocity_ref * length_ref / viscosity if viscosity else None,
11845 "initial_conditions": props.get("initial_conditions", {}),
11846 },
11847 "grid": {
11848 "mode": grid.get("mode"),
11849 "processor_layout": resolve_grid_da_processor_layout(grid),
11850 "programmatic_settings": grid.get("programmatic_settings") if grid.get("mode") == "programmatic_c" else None,
11851 "source_file": grid.get("source_file"),
11852 },
11853 "domain": {
11854 "blocks": domain.get("blocks", 1),
11855 "dimensionality": physics.get("dimensionality", "3D"),
11856 "periodic": periodic_axes,
11857 },
11858 "physics": {
11859 "fsi": physics.get("fsi", {}),
11860 "particles": particles,
11861 "turbulence": _summarize_turbulence(physics.get("turbulence", {}) or {}),
11862 "statistics": models.get("statistics", {}),
11863 },
11864 "boundary_conditions": bc_blocks,
11865 }
11866
11867
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 11868 of file core.py.

11868def _build_solver_overview(context: dict) -> dict:
11869 """!
11870 @brief Build a curated solver.yml summary with normalized selections.
11871 @param[in] context Summary context returned by `_build_summary_context()`.
11872 @return Curated solver configuration mapping.
11873 """
11874 cfg = _require_summary_config(context, "solver")
11875 strategy = cfg.get("strategy", {}) or {}
11876 selected = normalize_momentum_solver_type(strategy.get("momentum_solver", "Dual Time Picard Jameson RK"))
11877 momentum_cfg = cfg.get("momentum_solver", {}) or {}
11878 dualtime = momentum_cfg.get("dual_time_picard_jameson_rk", momentum_cfg.get("dual_time_picard_rk4", {})) or {}
11879 newton_krylov = momentum_cfg.get("newton_krylov", {}) or {}
11880 poisson = cfg.get("poisson_solver", cfg.get("pressure_solver", {})) or {}
11881 convergence = cfg.get("solution_convergence", {}) or {}
11882 convergence_mode = normalize_solution_convergence_mode(convergence.get("mode", "steady_deterministic"))
11883 operation_mode = cfg.get("operation_mode", {}) or {}
11884 operation_mode = {
11885 **operation_mode,
11886 "eulerian_field_source": normalize_eulerian_field_source(operation_mode.get("eulerian_field_source", "solve")),
11887 }
11888 if operation_mode.get("analytical_type") is not None:
11889 operation_mode["analytical_type"] = normalize_analytical_type(operation_mode["analytical_type"])
11890 passthrough = cfg.get("petsc_passthrough_options", {}) or {}
11891 return {
11892 "operation_mode": operation_mode,
11893 "momentum": {
11894 "type": selected,
11895 "central_diff": bool(strategy.get("central_diff", False)),
11896 "tolerances": cfg.get("tolerances", {}),
11897 "controls": newton_krylov if selected == "newton_krylov" else dualtime,
11898 },
11899 "poisson": poisson,
11900 "interpolation": cfg.get("interpolation", {"method": "Trilinear"}),
11901 "solution_convergence": {**convergence, "mode": convergence_mode},
11902 "scalar_transport": cfg.get("scalar_transport", {}),
11903 "verification": cfg.get("verification", {}),
11904 "petsc_passthrough": {"count": len(passthrough), "options": sorted(passthrough.keys())},
11905 }
11906
11907
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 11908 of file core.py.

11908def _build_monitor_overview(context: dict) -> dict:
11909 """!
11910 @brief Build a curated monitor.yml summary with resolved defaults.
11911 @param[in] context Summary context returned by `_build_summary_context()`.
11912 @return Curated monitor configuration mapping.
11913 """
11914 cfg = _require_summary_config(context, "monitor")
11915 logging_cfg = cfg.get("logging", {}) or {}
11916 io_cfg = cfg.get("io", {}) or {}
11917 diagnostics = resolve_diagnostics_config(cfg, context["run_dir"], "Solver")
11918 monitoring_flags = resolve_solver_monitoring_flags(cfg)
11919 enabled_petsc = sorted(key for key, value in diagnostics["petsc"].items() if value not in (False, None))
11920 return {
11921 "logging": {
11922 "verbosity": logging_cfg.get("verbosity", "WARNING"),
11923 "enabled_functions": logging_cfg.get("enabled_functions", []),
11924 },
11925 "profiling": resolve_profiling_config(cfg),
11926 "diagnostics": {
11927 "enabled_petsc": enabled_petsc,
11928 "petsc": diagnostics["petsc"],
11929 "runtime_memory_log": diagnostics["runtime_memory_log"],
11930 },
11931 "io": {
11932 "data_output_frequency": io_cfg.get("data_output_frequency"),
11933 "particle_console_output_frequency": resolve_particle_console_output_frequency(io_cfg),
11934 "particle_log_interval": io_cfg.get("particle_log_interval"),
11935 "directories": io_cfg.get("directories", {}),
11936 },
11937 "solver_monitoring": {
11938 "enabled_flags": sorted(flag for flag, value in monitoring_flags.items() if value not in (False, None)),
11939 "flags": monitoring_flags,
11940 },
11941 }
11942
11943
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 11944 of file core.py.

11944def _parse_continuity_metrics_log(filepath: str) -> "tuple[dict, list[int]]":
11945 """!
11946 @brief Parse Continuity_Metrics.log into latest rows by step plus observed order.
11947 @param[in] filepath Argument passed to `_parse_continuity_metrics_log()`.
11948 @return Value returned by `_parse_continuity_metrics_log()`.
11949 """
11950 rows_by_step = {}
11951 step_order = []
11952 active_step = None
11953 if not os.path.isfile(filepath):
11954 return rows_by_step, step_order
11955
11956 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
11957 for raw_line in f:
11958 line = raw_line.strip()
11959 if not line or line.startswith("-") or line.startswith("Timestep"):
11960 continue
11961 parts = [part.strip() for part in raw_line.split("|")]
11962 if len(parts) < 8:
11963 continue
11964 step = _parse_int_loose(parts[0])
11965 block = _parse_int_loose(parts[1])
11966 max_div = _parse_float_loose(parts[2])
11967 rhs_sum = _parse_float_loose(parts[4])
11968 flux_in = _parse_float_loose(parts[5])
11969 flux_out = _parse_float_loose(parts[6])
11970 net_flux = _parse_float_loose(parts[7])
11971 if step is None or block is None:
11972 continue
11973 if step != active_step:
11974 active_step = step
11975 step_order.append(step)
11976 rows_by_step[step] = {}
11977 rows_by_step.setdefault(step, {})[block] = {
11978 "block": block,
11979 "max_divergence": max_div,
11980 "max_divergence_location": parts[3],
11981 "rhs_sum": rhs_sum,
11982 "flux_in": flux_in,
11983 "flux_out": flux_out,
11984 "net_flux": net_flux,
11985 }
11986 return {step: list(block_rows.values()) for step, block_rows in rows_by_step.items()}, step_order
11987
11988
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 11989 of file core.py.

11989def _parse_particle_metrics_log(filepath: str) -> "tuple[dict, list[int]]":
11990 """!
11991 @brief Parse Particle_Metrics.log into latest rows by step plus observed order.
11992 @param[in] filepath Argument passed to `_parse_particle_metrics_log()`.
11993 @return Value returned by `_parse_particle_metrics_log()`.
11994 """
11995 rows_by_step = {}
11996 step_order = []
11997 if not os.path.isfile(filepath):
11998 return rows_by_step, step_order
11999
12000 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
12001 for raw_line in f:
12002 line = raw_line.strip()
12003 if not line or line.startswith("-") or line.startswith("Stage"):
12004 continue
12005 parts = [part.strip() for part in raw_line.split("|")]
12006 if len(parts) < 8:
12007 continue
12008 step = _parse_int_loose(parts[1])
12009 if step is None:
12010 continue
12011 row = {
12012 "stage": parts[0],
12013 "total_particles": _parse_int_loose(parts[2]),
12014 "lost_particles": _parse_int_loose(parts[3]),
12015 "lost_particles_cumulative": None,
12016 "migrated_particles": None,
12017 "occupied_cells": None,
12018 "load_imbalance": None,
12019 "migration_passes": None,
12020 }
12021 if len(parts) >= 9:
12022 row.update(
12023 {
12024 "lost_particles_cumulative": _parse_int_loose(parts[4]),
12025 "migrated_particles": _parse_int_loose(parts[5]),
12026 "occupied_cells": _parse_int_loose(parts[6]),
12027 "load_imbalance": _parse_float_loose(parts[7]),
12028 "migration_passes": _parse_int_loose(parts[8]),
12029 }
12030 )
12031 else:
12032 row.update(
12033 {
12034 "migrated_particles": _parse_int_loose(parts[4]),
12035 "occupied_cells": _parse_int_loose(parts[5]),
12036 "load_imbalance": _parse_float_loose(parts[6]),
12037 "migration_passes": _parse_int_loose(parts[7]),
12038 }
12039 )
12040 rows_by_step[step] = row
12041 step_order.append(step)
12042 return rows_by_step, step_order
12043
12044
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 12045 of file core.py.

12045def _parse_momentum_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
12046 """!
12047 @brief Parse per-block momentum convergence logs.
12048 @param[in] log_dir Argument passed to `_parse_momentum_convergence_logs()`.
12049 @return Value returned by `_parse_momentum_convergence_logs()`.
12050 """
12051 rows_by_step = {}
12052 sources = {}
12053 step_order = []
12054 patterns = [
12055 os.path.join(log_dir, "Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_*.log"),
12056 os.path.join(log_dir, "Momentum_Solver_Convergence_History_Block_*.log"),
12057 ]
12058 # Log format (Phase 3+): dtau [physical time] and cfl_eff [dimensionless Courant number].
12059 # cfl_eff = dtau * lambda_max; this is the value controlled by pseudo_cfl.* YAML keys.
12060 regex = re.compile(
12061 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\‍(k\‍):\s*(?P<pseudo_iter>\d+)\s*\|"
12062 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
12063 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
12064 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
12065 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
12066 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
12067 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
12068 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
12069 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
12070 )
12071
12072 # Per (step, block): track accepted/rejected counts and last committed state.
12073 _state = {}
12074
12075 for path in sorted(path for pattern in patterns for path in glob.glob(pattern)):
12076 block_match = re.search(r"Block_(\d+)\.log$", path)
12077 if not block_match:
12078 continue
12079 block = int(block_match.group(1))
12080 sources[block] = path
12081 with open(path, "r", encoding="utf-8", errors="replace") as f:
12082 for raw_line in f:
12083 match = regex.search(raw_line)
12084 if not match:
12085 continue
12086 step = int(match.group("step"))
12087 step_order.append(step)
12088 key = (step, block)
12089 if key not in _state:
12090 _state[key] = {"accepted_count": 0, "rejected_count": 0, "last_accepted": None, "last_rejected": None}
12091 entry = _state[key]
12092 status = match.group("status")
12093 row = {
12094 "block": block,
12095 "pseudo_iterations": int(match.group("pseudo_iter")),
12096 "dtau": _parse_float_loose(match.group("dtau")),
12097 "cfl_eff": _parse_float_loose(match.group("cfl_eff")),
12098 "dtau_after": _parse_float_loose(match.group("dtau_after")),
12099 "cfl_eff_after": _parse_float_loose(match.group("cfl_eff_after")),
12100 "delta_norm": float(match.group("delta")),
12101 "delta_rel": float(match.group("delta_rel")),
12102 "residual_norm": float(match.group("resid")),
12103 "residual_rel": float(match.group("resid_rel")),
12104 "trial_ratio": _parse_float_loose(match.group("trial_ratio")),
12105 "smoothed_ratio": _parse_float_loose(match.group("smoothed_ratio")),
12106 "status": status,
12107 }
12108 if status == "accepted":
12109 entry["accepted_count"] += 1
12110 entry["last_accepted"] = row
12111 else:
12112 entry["rejected_count"] += 1
12113 entry["last_rejected"] = row
12114
12115 for (step, block), entry in _state.items():
12116 display_row = entry["last_accepted"] or entry["last_rejected"]
12117 if display_row:
12118 rows_by_step.setdefault(step, {})[block] = {
12119 **display_row,
12120 "accepted_count": entry["accepted_count"],
12121 "rejected_count": entry["rejected_count"],
12122 }
12123
12124 newton_pattern = os.path.join(log_dir, "Momentum_Solver_Newton_Krylov_Summary_Block_*.log")
12125 newton_regex = re.compile(
12126 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
12127 r"\s*solver:\s*(?P<solver>[^|]+?)\s*\|\s*reason:\s*(?P<reason>\S+)\s*\|"
12128 r"\s*reason_code:\s*(?P<reason_code>-?\d+)\s*\|\s*newton:\s*(?P<newton>\d+)\s*\|"
12129 r"\s*evals:\s*(?P<evals>\d+)\s*\|\s*krylov:\s*(?P<krylov>\d+)\s*\|"
12130 r"\s*initial:\s*(?P<initial>[-+0-9.eE]+|unavailable)\s*\|"
12131 r"\s*final:\s*(?P<final>[-+0-9.eE]+)\s*\|\s*state:\s*(?P<state>\w+)"
12132 )
12133 for path in sorted(glob.glob(newton_pattern)):
12134 block_match = re.search(r"Block_(\d+)\.log$", path)
12135 if not block_match:
12136 continue
12137 file_block = int(block_match.group(1))
12138 sources[file_block] = path
12139 with open(path, "r", encoding="utf-8", errors="replace") as f:
12140 for raw_line in f:
12141 match = newton_regex.search(raw_line)
12142 if not match:
12143 continue
12144 step = int(match.group("step"))
12145 block = int(match.group("block"))
12146 initial_text = match.group("initial")
12147 step_order.append(step)
12148 rows_by_step.setdefault(step, {})[block] = {
12149 "block": block,
12150 "solver": match.group("solver").strip(),
12151 "reason": match.group("reason"),
12152 "reason_code": int(match.group("reason_code")),
12153 "newton_iterations": int(match.group("newton")),
12154 "residual_evaluations": int(match.group("evals")),
12155 "krylov_iterations": int(match.group("krylov")),
12156 "initial_norm": None if initial_text == "unavailable" else float(initial_text),
12157 "final_norm": float(match.group("final")),
12158 "state": match.group("state"),
12159 }
12160
12161 return rows_by_step, sources, step_order
12162
12163
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _parse_poisson_convergence_logs()

"tuple[dict, dict, list[int]]" picurv_cli.core._parse_poisson_convergence_logs ( str  log_dir)
protected

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

12164def _parse_poisson_convergence_logs(log_dir: str) -> "tuple[dict, dict, list[int]]":
12165 """!
12166 @brief Parse per-block Poisson convergence logs.
12167 @param[in] log_dir Argument passed to `_parse_poisson_convergence_logs()`.
12168 @return Value returned by `_parse_poisson_convergence_logs()`.
12169 """
12170 rows_by_step = {}
12171 sources = {}
12172 step_order = []
12173 pattern = os.path.join(log_dir, "Poisson_Solver_Convergence_History_Block_*.log")
12174 regex = re.compile(
12175 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
12176 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
12177 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
12178 )
12179
12180 for path in sorted(glob.glob(pattern)):
12181 block_match = re.search(r"Block_(\d+)\.log$", path)
12182 if not block_match:
12183 continue
12184 block = int(block_match.group(1))
12185 sources[block] = path
12186 with open(path, "r", encoding="utf-8", errors="replace") as f:
12187 for raw_line in f:
12188 match = regex.search(raw_line)
12189 if not match:
12190 continue
12191 step = int(match.group("step"))
12192 step_order.append(step)
12193 rows_by_step.setdefault(step, {})[block] = {
12194 "block": block,
12195 "iterations": int(match.group("iter")),
12196 "unpreconditioned_norm": float(match.group("unpre")),
12197 "true_norm": float(match.group("true")),
12198 "relative_norm": _parse_float_loose(match.group("rel")),
12199 }
12200 return rows_by_step, sources, step_order
12201
12202
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 12203 of file core.py.

12203def _parse_profiling_timestep_csv(filepath: str) -> "tuple[dict, list[int]]":
12204 """!
12205 @brief Parse profiling timestep CSV into latest rows by step plus observed order.
12206 @param[in] filepath Argument passed to `_parse_profiling_timestep_csv()`.
12207 @return Value returned by `_parse_profiling_timestep_csv()`.
12208 """
12209 rows_by_step = {}
12210 step_order = []
12211 active_step = None
12212 if not os.path.isfile(filepath):
12213 return rows_by_step, step_order
12214
12215 with open(filepath, "r", encoding="utf-8", errors="replace", newline="") as f:
12216 reader = csv.DictReader(f)
12217 for row in reader:
12218 step = _parse_int_loose(row.get("step"))
12219 if step is None:
12220 continue
12221 if step != active_step:
12222 active_step = step
12223 step_order.append(step)
12224 rows_by_step[step] = []
12225 rows_by_step.setdefault(step, []).append(
12226 {
12227 "function": row.get("function"),
12228 "calls": _parse_int_loose(row.get("calls")),
12229 "step_time_s": _parse_float_loose(row.get("step_time_s")),
12230 }
12231 )
12232 return rows_by_step, step_order
12233
12234
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 12235 of file core.py.

12235def _parse_runtime_memory_log(filepath: str) -> "tuple[dict, list[int], dict]":
12236 """!
12237 @brief Parse Runtime_Memory.log into latest rows by step and final status.
12238 @param[in] filepath Runtime memory log path.
12239 @return Tuple of rows by step, observed step order, and final/shutdown metadata.
12240 """
12241 rows_by_step = {}
12242 step_order = []
12243 final_row = None
12244 latest_sample_row = None
12245 max_process_change_mb = None
12246 if not os.path.isfile(filepath):
12247 return rows_by_step, step_order, {"available": False}
12248
12249 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
12250 for raw_line in f:
12251 line = raw_line.strip()
12252 if not line or line.startswith("#") or line.startswith("Step"):
12253 continue
12254 parts = line.split()
12255 if len(parts) < 8:
12256 continue
12257 step = _parse_int_loose(parts[0])
12258 if step is None:
12259 continue
12260 row = {
12261 "step": step,
12262 "event": parts[1],
12263 "process_current_mb_max": _parse_float_loose(parts[2]),
12264 "process_peak_mb_max": _parse_float_loose(parts[3]),
12265 "petsc_allocated_mb_max": _parse_float_loose(parts[4]),
12266 "petsc_peak_allocated_mb_max": _parse_float_loose(parts[5]),
12267 "process_change_mb_max": _parse_float_loose(parts[6]),
12268 "reason": parts[7],
12269 }
12270 if row["process_change_mb_max"] is not None:
12271 max_process_change_mb = (
12272 row["process_change_mb_max"]
12273 if max_process_change_mb is None
12274 else max(max_process_change_mb, row["process_change_mb_max"])
12275 )
12276 if row["event"] in {"Step", "Post"}:
12277 rows_by_step[step] = row
12278 step_order.append(step)
12279 latest_sample_row = row
12280 elif row["event"] in {"Shutdown", "Final"}:
12281 final_row = row
12282
12283 meta = {
12284 "available": bool(rows_by_step or final_row),
12285 "source": filepath,
12286 "final_event": final_row.get("event") if final_row else None,
12287 "final_reason": final_row.get("reason") if final_row else None,
12288 "max_process_change_mb": max_process_change_mb,
12289 "latest_sample_row": latest_sample_row,
12290 "final_row": final_row,
12291 }
12292 return rows_by_step, step_order, meta
12293
12294
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 12295 of file core.py.

12295def _parse_solution_convergence_log(filepath: str) -> "tuple[dict, list[int]]":
12296 """!
12297 @brief Parse solution_convergence.log into latest rows by step plus observed order.
12298
12299 The log format uses pipe-delimited aligned columns. The first line of the
12300 file is a banner (starts with '=') containing the mode tag; the second line
12301 is the column header; the third line is a separator (starts with '-').
12302 Subsequent lines are one data row per timestep.
12303
12304 @param[in] filepath Path to solution_convergence.log.
12305 @return Mapping of step number to a dict of column values.
12306 """
12307 rows_by_step = {}
12308 step_order = []
12309 if not os.path.isfile(filepath):
12310 return rows_by_step, step_order
12311
12312 mode = None
12313 col_names = None
12314
12315 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
12316 for raw_line in f:
12317 line = raw_line.strip()
12318 if not line:
12319 continue
12320 if line.startswith("="):
12321 m = re.search(r"\[mode:\s*([\w_]+)", line)
12322 if m:
12323 mode = m.group(1)
12324 continue
12325 if line.startswith("-"):
12326 continue
12327 if col_names is None:
12328 col_names = [p.strip() for p in raw_line.split("|")]
12329 continue
12330 parts = [p.strip() for p in raw_line.split("|")]
12331 if len(parts) < 4 or col_names is None:
12332 continue
12333 step = _parse_int_loose(parts[0]) if col_names[0] == "step" else None
12334 if step is None:
12335 continue
12336 step_order.append(step)
12337 row = {"mode": mode}
12338 for name, val in zip(col_names, parts):
12339 if not name:
12340 continue
12341 float_val = _parse_float_loose(val)
12342 int_val = _parse_int_loose(val)
12343 if float_val is not None and ("." in val or "e" in val.lower()):
12344 row[name] = float_val
12345 elif int_val is not None:
12346 row[name] = int_val
12347 else:
12348 row[name] = val
12349 rows_by_step[step] = row
12350 return rows_by_step, step_order
12351
12352
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 12353 of file core.py.

12353def _find_solver_stream_log_candidates(run_dir: str, log_dir: str) -> "list[str]":
12354 """!
12355 @brief Return plausible solver stream logs for local and Slurm runs.
12356 @param[in] run_dir Argument passed to `_find_solver_stream_log_candidates()`.
12357 @param[in] log_dir Argument passed to `_find_solver_stream_log_candidates()`.
12358 @return Value returned by `_find_solver_stream_log_candidates()`.
12359 """
12360 patterns = [
12361 os.path.join(run_dir, "scheduler", "*_solver.log"),
12362 os.path.join(run_dir, "scheduler", "solver_*.out"),
12363 os.path.join(log_dir, "*_solver.log"),
12364 ]
12365 found = []
12366 seen = set()
12367 for pattern in patterns:
12368 for path in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True):
12369 if path not in seen:
12370 seen.add(path)
12371 found.append(path)
12372 return found
12373
12374
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 12375 of file core.py.

12375def _parse_particle_snapshot_file(filepath: str) -> dict:
12376 """!
12377 @brief Parse sampled particle snapshots from a solver stream log.
12378 @param[in] filepath Argument passed to `_parse_particle_snapshot_file()`.
12379 @return Value returned by `_parse_particle_snapshot_file()`.
12380 """
12381 snapshots = {}
12382 if not os.path.isfile(filepath):
12383 return snapshots
12384
12385 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
12386 lines = f.readlines()
12387
12388 idx = 0
12389 while idx < len(lines):
12390 match = re.search(r"Particle states at step\s+(\d+):", lines[idx])
12391 if not match:
12392 idx += 1
12393 continue
12394 step = int(match.group(1))
12395 idx += 1
12396 rows = []
12397 while idx < len(lines):
12398 stripped = lines[idx].strip()
12399 if re.search(r"Particle states at step\s+\d+:", lines[idx]):
12400 break
12401 if stripped.startswith("|"):
12402 parts = [part.strip() for part in stripped.split("|")[1:-1]]
12403 if len(parts) >= 6 and parts[0] != "Rank":
12404 velocity = _extract_numeric_tuple(parts[4])
12405 rows.append(
12406 {
12407 "rank": _parse_int_loose(parts[0]),
12408 "pid": _parse_int_loose(parts[1]),
12409 "cell": [int(value) for value in _extract_numeric_tuple(parts[2])],
12410 "position": _extract_numeric_tuple(parts[3]),
12411 "velocity": velocity,
12412 "weights": _extract_numeric_tuple(parts[5]),
12413 "sample_speed": math.sqrt(sum(component * component for component in velocity)) if len(velocity) == 3 else None,
12414 }
12415 )
12416 elif rows and (not stripped or stripped.startswith("Progress:")):
12417 break
12418 idx += 1
12419 if rows:
12420 snapshots[step] = rows
12421 else:
12422 snapshots.setdefault(step, [])
12423 return snapshots
12424
12425
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 12426 of file core.py.

12426def _find_previous_snapshot_step(snapshot_steps: "list[int]", step: int) -> "int | None":
12427 """!
12428 @brief Return the nearest earlier snapshot step when available.
12429 @param[in] snapshot_steps Argument passed to `_find_previous_snapshot_step()`.
12430 @param[in] step Argument passed to `_find_previous_snapshot_step()`.
12431 @return Value returned by `_find_previous_snapshot_step()`.
12432 """
12433 earlier_steps = [candidate for candidate in snapshot_steps if candidate < step]
12434 if not earlier_steps:
12435 return None
12436 return max(earlier_steps)
12437
12438
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 12439 of file core.py.

12439def _compute_particle_snapshot_delta(current_rows: "list[dict]", previous_rows: "list[dict]") -> dict:
12440 """!
12441 @brief Compute sampled deltas between two particle snapshot samples.
12442 @param[in] current_rows Argument passed to `_compute_particle_snapshot_delta()`.
12443 @param[in] previous_rows Argument passed to `_compute_particle_snapshot_delta()`.
12444 @return Value returned by `_compute_particle_snapshot_delta()`.
12445 """
12446 np = require_numpy()
12447 previous_by_pid = {
12448 row.get("pid"): row
12449 for row in previous_rows
12450 if row.get("pid") is not None
12451 }
12452 current_by_pid = {
12453 row.get("pid"): row
12454 for row in current_rows
12455 if row.get("pid") is not None
12456 }
12457 matched_pids = sorted(set(previous_by_pid) & set(current_by_pid))
12458 if not matched_pids:
12459 return {"available": False}
12460
12461 displacements = []
12462 rank_migrations = 0
12463 cell_changes = 0
12464 speed_changes = []
12465 for pid in matched_pids:
12466 current_row = current_by_pid[pid]
12467 previous_row = previous_by_pid[pid]
12468 current_pos = current_row.get("position") or []
12469 previous_pos = previous_row.get("position") or []
12470 if len(current_pos) == len(previous_pos) and current_pos:
12471 displacements.append(
12472 math.sqrt(
12473 sum(
12474 (float(current_pos[idx]) - float(previous_pos[idx])) ** 2
12475 for idx in range(len(current_pos))
12476 )
12477 )
12478 )
12479 current_speed = current_row.get("sample_speed")
12480 previous_speed = previous_row.get("sample_speed")
12481 if current_speed is not None and previous_speed is not None:
12482 speed_changes.append(float(current_speed) - float(previous_speed))
12483 if current_row.get("rank") is not None and previous_row.get("rank") is not None:
12484 if current_row["rank"] != previous_row["rank"]:
12485 rank_migrations += 1
12486 if current_row.get("cell") and previous_row.get("cell"):
12487 if current_row["cell"] != previous_row["cell"]:
12488 cell_changes += 1
12489
12490 payload = {
12491 "available": True,
12492 "matched_pids": len(matched_pids),
12493 "new_count": len(set(current_by_pid) - set(previous_by_pid)),
12494 "gone_count": len(set(previous_by_pid) - set(current_by_pid)),
12495 "rank_migrations": rank_migrations,
12496 "cell_changes": cell_changes,
12497 }
12498 if displacements:
12499 payload["mean_displacement"] = float(np.mean(displacements))
12500 payload["max_displacement"] = float(np.max(displacements))
12501 if speed_changes:
12502 payload["mean_speed_change"] = float(np.mean(speed_changes))
12503 payload["max_abs_speed_change"] = float(np.max(np.abs(speed_changes)))
12504 return payload
12505
12506
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 12507 of file core.py.

12516) -> dict:
12517 """!
12518 @brief Build sampled diagnostics for one particle console snapshot.
12519 @param[in] source Argument passed to `_build_particle_snapshot_summary()`.
12520 @param[in] step Argument passed to `_build_particle_snapshot_summary()`.
12521 @param[in] rows Argument passed to `_build_particle_snapshot_summary()`.
12522 @param[in] preview_rows Argument passed to `_build_particle_snapshot_summary()`.
12523 @param[in] particle_console_output_freq Argument passed to `_build_particle_snapshot_summary()`.
12524 @param[in] particle_log_interval Argument passed to `_build_particle_snapshot_summary()`.
12525 @param[in] previous_step Argument passed to `_build_particle_snapshot_summary()`.
12526 @param[in] previous_rows Argument passed to `_build_particle_snapshot_summary()`.
12527 @return Value returned by `_build_particle_snapshot_summary()`.
12528 """
12529 np = require_numpy()
12530 payload = {
12531 "available": True,
12532 "sampled": True,
12533 "source": source,
12534 "step": step,
12535 "sampled_rows": len(rows),
12536 "preview_rows": rows[:preview_rows],
12537 "cadence": {
12538 "particle_console_output_frequency": particle_console_output_freq,
12539 "particle_log_interval": particle_log_interval,
12540 },
12541 }
12542 if not rows:
12543 return payload
12544
12545 rank_counts = {}
12546 duplicate_pid_count = 0
12547 duplicate_cell_count = 0
12548 nan_count = 0
12549 inf_count = 0
12550 zero_weight_count = 0
12551 negative_weight_count = 0
12552 unique_pid_count = 0
12553
12554 seen_pids = set()
12555 cell_counter = {}
12556 position_components = [[], [], []]
12557 weight_components = {}
12558 speeds = []
12559
12560 for row in rows:
12561 pid = row.get("pid")
12562 if pid is not None:
12563 if pid in seen_pids:
12564 duplicate_pid_count += 1
12565 else:
12566 seen_pids.add(pid)
12567 rank = row.get("rank")
12568 if rank is not None:
12569 rank_counts[str(rank)] = rank_counts.get(str(rank), 0) + 1
12570
12571 cell = row.get("cell") or []
12572 if cell:
12573 key = tuple(cell)
12574 cell_counter[key] = cell_counter.get(key, 0) + 1
12575
12576 position = row.get("position") or []
12577 for idx, value in enumerate(position[:3]):
12578 if not np.isfinite(value):
12579 if np.isnan(value):
12580 nan_count += 1
12581 else:
12582 inf_count += 1
12583 continue
12584 position_components[idx].append(float(value))
12585
12586 velocity = row.get("velocity") or []
12587 if any(not np.isfinite(value) for value in velocity):
12588 for value in velocity:
12589 if not np.isfinite(value):
12590 if np.isnan(value):
12591 nan_count += 1
12592 else:
12593 inf_count += 1
12594 speed = row.get("sample_speed")
12595 if speed is not None and np.isfinite(speed):
12596 speeds.append(float(speed))
12597
12598 weights = row.get("weights") or []
12599 for idx, value in enumerate(weights):
12600 if not np.isfinite(value):
12601 if np.isnan(value):
12602 nan_count += 1
12603 else:
12604 inf_count += 1
12605 continue
12606 numeric = float(value)
12607 weight_components.setdefault(idx, []).append(numeric)
12608 if abs(numeric) <= 1.0e-15:
12609 zero_weight_count += 1
12610 if numeric < 0.0:
12611 negative_weight_count += 1
12612
12613 unique_pid_count = len(seen_pids)
12614 duplicate_cell_count = sum(1 for count in cell_counter.values() if count > 1)
12615
12616 payload["sampled_distribution"] = {
12617 "unique_cells": len(cell_counter),
12618 "duplicate_cells": duplicate_cell_count,
12619 "rank_counts": rank_counts,
12620 "unique_pids": unique_pid_count,
12621 }
12622 payload["checks"] = {
12623 "duplicate_pid_count": duplicate_pid_count,
12624 "nan_count": nan_count,
12625 "inf_count": inf_count,
12626 "zero_weight_count": zero_weight_count,
12627 "negative_weight_count": negative_weight_count,
12628 }
12629
12630 if speeds:
12631 payload["speed"] = {
12632 "min": float(np.min(speeds)),
12633 "mean": float(np.mean(speeds)),
12634 "max": float(np.max(speeds)),
12635 "std": float(np.std(speeds)),
12636 "stagnant_count": sum(1 for speed in speeds if abs(speed) < 1.0e-6),
12637 }
12638
12639 fastest_rows = sorted(
12640 [row for row in rows if row.get("sample_speed") is not None],
12641 key=lambda row: row["sample_speed"],
12642 reverse=True,
12643 )[:3]
12644 payload["top_speeds"] = [
12645 {
12646 "pid": row.get("pid"),
12647 "rank": row.get("rank"),
12648 "speed": row.get("sample_speed"),
12649 "cell": row.get("cell"),
12650 }
12651 for row in fastest_rows
12652 ]
12653
12654 if any(position_components):
12655 axes = ["x", "y", "z"]
12656 payload["position_bounds"] = {}
12657 centroid = []
12658 for idx, axis in enumerate(axes):
12659 values = position_components[idx]
12660 if values:
12661 payload["position_bounds"][axis] = [float(np.min(values)), float(np.max(values))]
12662 centroid.append(float(np.mean(values)))
12663 else:
12664 centroid.append(None)
12665 payload["position_centroid"] = centroid
12666
12667 if weight_components:
12668 payload["weights"] = {}
12669 for idx, values in sorted(weight_components.items()):
12670 payload["weights"][f"component_{idx}"] = {
12671 "min": float(np.min(values)),
12672 "max": float(np.max(values)),
12673 }
12674
12675 delta_summary = {"available": False}
12676 if previous_step is not None and previous_rows:
12677 delta_summary = _compute_particle_snapshot_delta(rows, previous_rows)
12678 if delta_summary.get("available"):
12679 delta_summary["previous_step"] = previous_step
12680 payload["delta_from_previous_snapshot"] = delta_summary
12681 return payload
12682
12683
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 12684 of file core.py.

12691) -> dict:
12692 """!
12693 @brief Locate and summarize a particle console snapshot for one step.
12694 @param[in] run_dir Argument passed to `_find_particle_snapshot_for_step()`.
12695 @param[in] log_dir Argument passed to `_find_particle_snapshot_for_step()`.
12696 @param[in] step Argument passed to `_find_particle_snapshot_for_step()`.
12697 @param[in] preview_rows Argument passed to `_find_particle_snapshot_for_step()`.
12698 @param[in] particle_console_output_freq Argument passed to `_find_particle_snapshot_for_step()`.
12699 @param[in] particle_log_interval Argument passed to `_find_particle_snapshot_for_step()`.
12700 @return Value returned by `_find_particle_snapshot_for_step()`.
12701 """
12702 best = None
12703 for path in _find_solver_stream_log_candidates(run_dir, log_dir):
12704 snapshots = _parse_particle_snapshot_file(path)
12705 rows = snapshots.get(step)
12706 if not rows:
12707 continue
12708 if best is None or len(rows) > len(best["rows"]):
12709 best = {"source": path, "rows": rows, "snapshots": snapshots}
12710
12711 if not best:
12712 return {"available": False}
12713
12714 previous_step = _find_previous_snapshot_step(list(best["snapshots"].keys()), step)
12715 previous_rows = best["snapshots"].get(previous_step, []) if previous_step is not None else None
12716 return _build_particle_snapshot_summary(
12717 best["source"],
12718 step,
12719 best["rows"],
12720 preview_rows,
12721 particle_console_output_freq=particle_console_output_freq,
12722 particle_log_interval=particle_log_interval,
12723 previous_step=previous_step,
12724 previous_rows=previous_rows,
12725 )
12726
12727
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 12728 of file core.py.

12739):
12740 """!
12741 @brief Select a step to summarize from available metric artifacts.
12742 @param[in] requested_step Argument passed to `_resolve_summary_step()`.
12743 @param[in] continuity_rows Argument passed to `_resolve_summary_step()`.
12744 @param[in] particle_rows Argument passed to `_resolve_summary_step()`.
12745 @param[in] momentum_rows Argument passed to `_resolve_summary_step()`.
12746 @param[in] poisson_rows Argument passed to `_resolve_summary_step()`.
12747 @param[in] profiling_rows Argument passed to `_resolve_summary_step()`.
12748 @param[in] memory_rows Argument passed to `_resolve_summary_step()`.
12749 @param[in] convergence_rows Argument passed to `_resolve_summary_step()`.
12750 @param[in] step_orders Argument passed to `_resolve_summary_step()`.
12751 @param[in] selection_mode Argument passed to `_resolve_summary_step()`.
12752 @return Value returned by `_resolve_summary_step()`.
12753 """
12754 if memory_rows is None:
12755 memory_rows = {}
12756 if convergence_rows is None:
12757 convergence_rows = {}
12758 if step_orders is None:
12759 step_orders = []
12760 available_steps = (
12761 set(continuity_rows) | set(particle_rows) | set(momentum_rows)
12762 | set(poisson_rows) | set(profiling_rows) | set(memory_rows) | set(convergence_rows)
12763 )
12764 if not available_steps:
12765 return None, []
12766
12767 if requested_step is not None:
12768 return requested_step, sorted(available_steps)
12769
12770 if selection_mode == "max_step":
12771 return max(available_steps), sorted(available_steps)
12772
12773 for order in step_orders:
12774 if order:
12775 return order[-1], sorted(available_steps)
12776 return max(available_steps), sorted(available_steps)
12777
12778
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 12779 of file core.py.

12779def _format_summary_float(value, spec: str = ".6e", missing: str = "n/a") -> str:
12780 """!
12781 @brief Format optional numeric values for summary text output.
12782 @param[in] value Argument passed to `_format_summary_float()`.
12783 @param[in] spec Argument passed to `_format_summary_float()`.
12784 @param[in] missing Argument passed to `_format_summary_float()`.
12785 @return Value returned by `_format_summary_float()`.
12786 """
12787 if value is None:
12788 return missing
12789 return format(value, spec)
12790
12791

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

12792def _summary_source_mtime(paths) -> float:
12793 """!
12794 @brief Return the newest modification time among one or more summary sources.
12795 @param[in] paths Path string, iterable of paths, or mapping of paths.
12796 @return Newest modification time, or -1.0 when no source exists.
12797 """
12798 if isinstance(paths, dict):
12799 paths = paths.values()
12800 elif isinstance(paths, str):
12801 paths = [paths]
12802 newest = -1.0
12803 for path in paths or []:
12804 if path and os.path.isfile(path):
12805 newest = max(newest, os.path.getmtime(path))
12806 return newest
12807
12808
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 12809 of file core.py.

12809def _order_summary_step_orders(sources: "list[tuple[list[int], object]]") -> "list[list[int]]":
12810 """!
12811 @brief Order observed step sequences by the recency of their source files.
12812 @param[in] sources Pairs of observed steps and filesystem source path(s).
12813 @return Step-order lists sorted so active append sources are considered first.
12814 """
12815 ranked = []
12816 for priority, (order, paths) in enumerate(sources):
12817 if order:
12818 ranked.append((_summary_source_mtime(paths), priority, order))
12819 ranked.sort(key=lambda item: (-item[0], item[1]))
12820 return [order for _, _, order in ranked]
12821
12822
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 12823 of file core.py.

12823def build_run_summary_payload(run_dir: str, step: "int | None" = None, snapshot_rows: int = 5, selection_mode: str = "latest") -> dict:
12824 """!
12825 @brief Build a read-only run-step summary from existing PICurv artifacts.
12826 @param[in] run_dir Argument passed to `build_run_summary_payload()`.
12827 @param[in] step Argument passed to `build_run_summary_payload()`.
12828 @param[in] snapshot_rows Argument passed to `build_run_summary_payload()`.
12829 @param[in] selection_mode Argument passed to `build_run_summary_payload()`.
12830 @return Value returned by `build_run_summary_payload()`.
12831 """
12832 context = _build_summary_context(run_dir)
12833 log_dir = context["log_dir"]
12834 continuity_path = os.path.join(log_dir, "Continuity_Metrics.log")
12835 particle_metrics_path = os.path.join(log_dir, "Particle_Metrics.log")
12836
12837 continuity_rows, continuity_order = _parse_continuity_metrics_log(continuity_path)
12838 particle_rows, particle_order = _parse_particle_metrics_log(particle_metrics_path)
12839 momentum_rows, momentum_sources, momentum_order = _parse_momentum_convergence_logs(log_dir)
12840 poisson_rows, poisson_sources, poisson_order = _parse_poisson_convergence_logs(log_dir)
12841
12842 profiling_rows = {}
12843 profiling_order = []
12844 profiling_path = os.path.join(log_dir, context["profiling_cfg"].get("timestep_file", "Profiling_Timestep_Summary.csv"))
12845 if context["profiling_cfg"].get("mode") != "off":
12846 profiling_rows, profiling_order = _parse_profiling_timestep_csv(profiling_path)
12847
12848 diagnostics_cfg = resolve_diagnostics_config(context["monitor_cfg"])
12849 memory_log_file = diagnostics_cfg["runtime_memory_log"].get("file", "Runtime_Memory.log")
12850 memory_path = os.path.join(log_dir, memory_log_file)
12851 memory_rows, memory_order, memory_meta = _parse_runtime_memory_log(memory_path)
12852
12853 convergence_log_path = os.path.join(log_dir, "solution_convergence.log")
12854 convergence_rows, convergence_order = _parse_solution_convergence_log(convergence_log_path)
12855 step_orders = _order_summary_step_orders(
12856 [
12857 (continuity_order, continuity_path),
12858 (particle_order, particle_metrics_path),
12859 (convergence_order, convergence_log_path),
12860 (profiling_order, profiling_path),
12861 (memory_order, memory_path),
12862 (momentum_order, momentum_sources),
12863 (poisson_order, poisson_sources),
12864 ]
12865 )
12866
12867 resolved_step, available_steps = _resolve_summary_step(
12868 step,
12869 continuity_rows,
12870 particle_rows,
12871 momentum_rows,
12872 poisson_rows,
12873 profiling_rows,
12874 convergence_rows,
12875 memory_rows,
12876 step_orders=step_orders,
12877 selection_mode=selection_mode,
12878 )
12879 if resolved_step is None:
12880 emit_structured_error(
12881 ERROR_CODE_CFG_FILE_NOT_FOUND,
12882 key="summary",
12883 file_path=log_dir,
12884 message="No summary-capable run artifacts were found under the run log directory.",
12885 hint="Run the solver first, then retry summarize on a run directory that contains continuity or solver convergence logs.",
12886 )
12887 sys.exit(1)
12888
12889 if step is not None and step not in set(available_steps):
12890 emit_structured_error(
12891 ERROR_CODE_CFG_INVALID_VALUE,
12892 key="step",
12893 file_path=context["run_dir"],
12894 message=f"Requested step {step} is not present in the available summary artifacts.",
12895 hint=f"Available steps include: {available_steps[:10]}{'...' if len(available_steps) > 10 else ''}",
12896 )
12897 sys.exit(1)
12898
12899 continuity_step_rows = sorted(continuity_rows.get(resolved_step, []), key=lambda row: row["block"])
12900 continuity_summary = {"available": bool(continuity_step_rows), "blocks": continuity_step_rows}
12901 if continuity_step_rows:
12902 divergence_values = [
12903 abs(row["max_divergence"])
12904 for row in continuity_step_rows
12905 if row["max_divergence"] is not None
12906 ]
12907 continuity_summary["max_abs_divergence"] = max(divergence_values) if divergence_values else None
12908 continuity_summary["net_flux"] = continuity_step_rows[0].get("net_flux")
12909 continuity_summary["flux_in"] = continuity_step_rows[0].get("flux_in")
12910 continuity_summary["flux_out"] = continuity_step_rows[0].get("flux_out")
12911
12912 momentum_step_rows = [row for _, row in sorted(momentum_rows.get(resolved_step, {}).items())]
12913 momentum_summary = {"available": bool(momentum_step_rows), "blocks": momentum_step_rows}
12914
12915 poisson_step_rows = [row for _, row in sorted(poisson_rows.get(resolved_step, {}).items())]
12916 poisson_summary = {"available": bool(poisson_step_rows), "blocks": poisson_step_rows}
12917
12918 particle_summary = {"available": resolved_step in particle_rows}
12919 if resolved_step in particle_rows:
12920 particle_summary.update(particle_rows[resolved_step])
12921
12922 profiling_summary = {"available": resolved_step in profiling_rows}
12923 if resolved_step in profiling_rows:
12924 functions = sorted(
12925 profiling_rows[resolved_step],
12926 key=lambda row: (row.get("step_time_s") or 0.0),
12927 reverse=True,
12928 )
12929 profiling_summary["functions"] = functions
12930 profiling_summary["total_logged_step_time_s"] = sum(
12931 row.get("step_time_s") or 0.0 for row in functions
12932 )
12933
12934 memory_summary = {"available": resolved_step in memory_rows}
12935 if resolved_step in memory_rows:
12936 memory_summary.update(memory_rows[resolved_step])
12937 memory_summary["source"] = memory_path
12938 memory_summary["max_process_change_mb"] = memory_meta.get("max_process_change_mb")
12939 memory_summary["final_event"] = memory_meta.get("final_event")
12940 memory_summary["final_reason"] = memory_meta.get("final_reason")
12941 memory_summary["selected_step"] = resolved_step
12942 memory_summary["step_match"] = True
12943 elif memory_meta.get("available"):
12944 latest_sample_row = memory_meta.get("latest_sample_row")
12945 if latest_sample_row:
12946 memory_summary.update(latest_sample_row)
12947 memory_summary.update(memory_meta)
12948 memory_summary["selected_step"] = resolved_step
12949 memory_summary["step_match"] = False
12950
12951 snapshot_summary = {"available": False}
12952 if context["particle_console_output_freq"] and context["particle_console_output_freq"] > 0:
12953 snapshot_summary = _find_particle_snapshot_for_step(
12954 context["run_dir"],
12955 log_dir,
12956 resolved_step,
12957 preview_rows=max(1, snapshot_rows),
12958 particle_console_output_freq=context["particle_console_output_freq"],
12959 particle_log_interval=context["particle_log_interval"],
12960 )
12961
12962 monitor_info = {
12963 "profiling_timestep_mode": context["profiling_cfg"].get("mode"),
12964 "profiling_timestep_file": context["profiling_cfg"].get("timestep_file"),
12965 "particle_console_output_frequency": context["particle_console_output_freq"],
12966 "particle_log_interval": context["particle_log_interval"],
12967 }
12968
12969 return {
12970 "run_id": context["manifest"].get("run_id", os.path.basename(context["run_dir"])),
12971 "run_dir": context["run_dir"],
12972 "step": resolved_step,
12973 "selected_via": "explicit" if step is not None else ("max_step" if selection_mode == "max_step" else "latest_available"),
12974 "available_steps": available_steps,
12975 "launch_mode": context["manifest"].get("launch_mode"),
12976 "created_at": context["manifest"].get("created_at"),
12977 "monitor": monitor_info,
12978 "particles_configured": context["particle_count_cfg"],
12979 "sources": {
12980 "continuity_log": continuity_path if os.path.isfile(continuity_path) else None,
12981 "particle_metrics_log": particle_metrics_path if os.path.isfile(particle_metrics_path) else None,
12982 "momentum_logs": momentum_sources,
12983 "poisson_logs": poisson_sources,
12984 "profiling_timestep_csv": profiling_path if os.path.isfile(profiling_path) else None,
12985 "solution_convergence_log": convergence_log_path if os.path.isfile(convergence_log_path) else None,
12986 "runtime_memory_log": memory_path if os.path.isfile(memory_path) else None,
12987 },
12988 "continuity": continuity_summary,
12989 "momentum": momentum_summary,
12990 "poisson": poisson_summary,
12991 "particles": particle_summary,
12992 "particle_snapshot": snapshot_summary,
12993 "profiling": profiling_summary,
12994 "memory": memory_summary,
12995 "convergence": convergence_rows.get(resolved_step) if convergence_rows else None,
12996 }
12997
12998
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 12999 of file core.py.

12999def render_run_summary(payload: dict, output_format: str = "text"):
13000 """!
13001 @brief Render a run-step summary in human or JSON form.
13002 @param[in] payload Argument passed to `render_run_summary()`.
13003 @param[in] output_format Argument passed to `render_run_summary()`.
13004 """
13005 if output_format == "json":
13006 print(json.dumps(payload, indent=2, sort_keys=True))
13007 return
13008
13009 print("\n" + "=" * 60)
13010 print(" RUN STEP SUMMARY")
13011 print("=" * 60)
13012 print(f" Run ID : {payload.get('run_id')}")
13013 print(f" Run directory : {os.path.relpath(payload.get('run_dir'))}")
13014 print(f" Step : {payload.get('step')} ({payload.get('selected_via')})")
13015 if payload.get("launch_mode"):
13016 print(f" Launch mode : {payload.get('launch_mode')}")
13017 if payload.get("created_at"):
13018 print(f" Created at : {payload.get('created_at')}")
13019
13020 continuity = payload.get("continuity", {})
13021 print("\n Continuity:")
13022 if continuity.get("available"):
13023 if continuity.get("max_abs_divergence") is not None:
13024 print(f" max |div| : {continuity['max_abs_divergence']:.6e}")
13025 if continuity.get("net_flux") is not None:
13026 print(f" net flux : {continuity['net_flux']:.6e}")
13027 for row in continuity.get("blocks", []):
13028 print(
13029 " "
13030 f"block {row['block']}: div={_format_summary_float(row.get('max_divergence'))} "
13031 f"rhs={_format_summary_float(row.get('rhs_sum'))} location={row['max_divergence_location']}"
13032 )
13033 else:
13034 print(" unavailable")
13035
13036 momentum = payload.get("momentum", {})
13037 print("\n Momentum:")
13038 if momentum.get("available"):
13039 for row in momentum.get("blocks", []):
13040 if row.get("solver") == "Newton Krylov":
13041 print(f" block {row['block']}: solver=Newton Krylov")
13042 print(
13043 f" newton={row.get('newton_iterations')} "
13044 f"krylov={row.get('krylov_iterations')} "
13045 f"evals={row.get('residual_evaluations')}"
13046 )
13047 print(
13048 f" final={_format_summary_float(row.get('final_norm'))} "
13049 f"reason={row.get('reason')} state={row.get('state')}"
13050 )
13051 continue
13052 status = row.get("status") or "unknown"
13053 accepted = row.get("accepted_count")
13054 rejected = row.get("rejected_count")
13055 counts_str = f" accepted={accepted} rejected={rejected}" if accepted is not None else ""
13056 # Phase 3+: logged as dtau (physical time) + cfl_eff (dimensionless Courant number).
13057 dtau_val = row.get("dtau")
13058 cfl_in = row.get("cfl_eff")
13059 cfl_out = row.get("cfl_eff_after")
13060 dtau_out = row.get("dtau_after")
13061 if cfl_in is not None and cfl_out is not None:
13062 cfl_str = f"cfl_eff {cfl_in:.4f}->{cfl_out:.4f} dtau {_format_summary_float(dtau_val)}->{_format_summary_float(dtau_out)}"
13063 elif cfl_in is not None:
13064 cfl_str = f"cfl_eff={_format_summary_float(cfl_in, '.4f')} dtau={_format_summary_float(dtau_val)}"
13065 else:
13066 cfl_str = "cfl_eff=n/a"
13067 ratio = row.get("trial_ratio")
13068 smoothed = row.get("smoothed_ratio")
13069 if ratio is not None and smoothed is not None:
13070 ratio_str = f" ratio={_format_summary_float(ratio)} (ema={_format_summary_float(smoothed)})"
13071 elif ratio is not None:
13072 ratio_str = f" ratio={_format_summary_float(ratio)}"
13073 else:
13074 ratio_str = ""
13075 print(f" block {row['block']} [{status}]:{counts_str} {cfl_str}{ratio_str}")
13076 print(
13077 f" resid={_format_summary_float(row.get('residual_norm'))}"
13078 f" delta={_format_summary_float(row.get('delta_norm'))}"
13079 )
13080 else:
13081 print(" unavailable")
13082
13083 poisson = payload.get("poisson", {})
13084 print("\n Poisson:")
13085 if poisson.get("available"):
13086 for row in poisson.get("blocks", []):
13087 print(
13088 " "
13089 f"block {row['block']}: iter={row['iterations']} "
13090 f"true={_format_summary_float(row.get('true_norm'))} "
13091 f"rel={_format_summary_float(row.get('relative_norm'))}"
13092 )
13093 else:
13094 print(" unavailable")
13095
13096 convergence = payload.get("convergence")
13097 print("\n Solution Convergence:")
13098 if convergence is not None:
13099 mode = convergence.get("mode", "unknown")
13100 ref = convergence.get("ref")
13101 print(f" mode : {mode} (ref={'yes' if ref else 'no'})")
13102 if mode in ("steady_deterministic", "transient"):
13103 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
13104 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
13105 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
13106 elif mode == "periodic_deterministic":
13107 ph = convergence.get("ph")
13108 per = convergence.get("per")
13109 print(f" phase : {ph}/{per}")
13110 print(f" u_abs_l2 : {_format_summary_float(convergence.get('u_abs_l2'))}")
13111 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} drift={_format_summary_float(convergence.get('spd_abs'))}")
13112 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} drift={_format_summary_float(convergence.get('ke_abs'))}")
13113 elif mode == "statistical_steady":
13114 print(f" mean_speed : {_format_summary_float(convergence.get('mean_speed'))} win={_format_summary_float(convergence.get('spd_win'))} win_drift={_format_summary_float(convergence.get('spd_win_abs'))}")
13115 print(f" mean_ke : {_format_summary_float(convergence.get('mean_ke'))} win={_format_summary_float(convergence.get('ke_win'))} win_drift={_format_summary_float(convergence.get('ke_win_abs'))}")
13116 else:
13117 print(" unavailable")
13118
13119 particles = payload.get("particles", {})
13120 print("\n Particles:")
13121 if particles.get("available"):
13122 loss_summary = f"lost={particles.get('lost_particles')}"
13123 if particles.get("lost_particles_cumulative") is not None:
13124 loss_summary = (
13125 f"lost(step/total)={particles.get('lost_particles')}/"
13126 f"{particles.get('lost_particles_cumulative')}"
13127 )
13128 print(
13129 " "
13130 f"total={particles.get('total_particles')} {loss_summary} "
13131 f"migrated={particles.get('migrated_particles')} occupied={particles.get('occupied_cells')} "
13132 f"imbalance={_format_summary_float(particles.get('load_imbalance'), '.2f')}"
13133 )
13134 else:
13135 print(" unavailable")
13136
13137 memory = payload.get("memory", {})
13138 print("\n Runtime Memory:")
13139 if memory.get("available"):
13140 if memory.get("source"):
13141 print(f" source : {os.path.relpath(memory.get('source'))}")
13142 if memory.get("step") is not None and not memory.get("step_match", True):
13143 print(f" memory step : {memory.get('step')} (latest memory row; selected step {memory.get('selected_step')} has no row yet)")
13144 if memory.get("event"):
13145 print(f" event : {memory.get('event')} reason={memory.get('reason', '-')}")
13146 print(f" process max : {_format_summary_float(memory.get('process_current_mb_max'), '.3f')} MB current, {_format_summary_float(memory.get('process_peak_mb_max'), '.3f')} MB peak")
13147 print(f" PETSc max : {_format_summary_float(memory.get('petsc_allocated_mb_max'), '.3f')} MB allocated, {_format_summary_float(memory.get('petsc_peak_allocated_mb_max'), '.3f')} MB peak")
13148 print(f" max change : {_format_summary_float(memory.get('max_process_change_mb'), '.3f')} MB")
13149 if memory.get("final_reason"):
13150 print(f" final reason : {memory.get('final_reason')}")
13151 else:
13152 print(" unavailable")
13153
13154 snapshot = payload.get("particle_snapshot", {})
13155 if snapshot.get("available"):
13156 print("\n Particle Snapshot (sampled):")
13157 print(f" source : {os.path.relpath(snapshot.get('source'))}")
13158 cadence = snapshot.get("cadence", {})
13159 print(
13160 " "
13161 f"cadence : every {cadence.get('particle_console_output_frequency', 'n/a')} steps, "
13162 f"row interval {cadence.get('particle_log_interval', 'n/a')}"
13163 )
13164 print(f" sampled rows : {snapshot.get('sampled_rows')}")
13165 speed = snapshot.get("speed", {})
13166 if speed:
13167 print(
13168 " "
13169 f"sampled speeds: min={_format_summary_float(speed.get('min'))} "
13170 f"mean={_format_summary_float(speed.get('mean'))} "
13171 f"max={_format_summary_float(speed.get('max'))} "
13172 f"std={_format_summary_float(speed.get('std'))} "
13173 f"stagnant(<1e-6)={speed.get('stagnant_count', 0)}"
13174 )
13175 bounds = snapshot.get("position_bounds", {})
13176 centroid = snapshot.get("position_centroid")
13177 if bounds:
13178 bound_parts = []
13179 for axis in ("x", "y", "z"):
13180 if axis in bounds:
13181 bound_parts.append(
13182 f"{axis}=[{_format_summary_float(bounds[axis][0])}, { _format_summary_float(bounds[axis][1])}]"
13183 )
13184 print(f" sampled bounds: {' '.join(bound_parts)}")
13185 if centroid:
13186 print(
13187 " "
13188 f"sampled center: ({_format_summary_float(centroid[0])}, "
13189 f"{_format_summary_float(centroid[1])}, {_format_summary_float(centroid[2])})"
13190 )
13191 distribution = snapshot.get("sampled_distribution", {})
13192 if distribution:
13193 print(
13194 " "
13195 f"sampled spread: unique_cells={distribution.get('unique_cells', 'n/a')} "
13196 f"duplicate_cells={distribution.get('duplicate_cells', 'n/a')} "
13197 f"unique_pids={distribution.get('unique_pids', 'n/a')} "
13198 f"ranks={distribution.get('rank_counts', {})}"
13199 )
13200 weights = snapshot.get("weights", {})
13201 if weights:
13202 weight_parts = []
13203 for component, summary in sorted(weights.items()):
13204 weight_parts.append(
13205 f"{component}[min/max]=[{_format_summary_float(summary.get('min'))}, { _format_summary_float(summary.get('max'))}]"
13206 )
13207 print(f" sampled weights: {' '.join(weight_parts)}")
13208 checks = snapshot.get("checks", {})
13209 if checks:
13210 print(
13211 " "
13212 f"checks : duplicate_pid={checks.get('duplicate_pid_count', 0)} "
13213 f"nan={checks.get('nan_count', 0)} inf={checks.get('inf_count', 0)} "
13214 f"zero_weight={checks.get('zero_weight_count', 0)} "
13215 f"negative_weight={checks.get('negative_weight_count', 0)}"
13216 )
13217 top_speeds = snapshot.get("top_speeds", [])
13218 if top_speeds:
13219 summary = ", ".join(
13220 f"pid={row.get('pid')} {_format_summary_float(row.get('speed'))}"
13221 for row in top_speeds
13222 )
13223 print(f" top speeds : {summary}")
13224 delta_summary = snapshot.get("delta_from_previous_snapshot", {})
13225 if delta_summary.get("available"):
13226 print(
13227 " "
13228 f"vs prev snap : step={delta_summary.get('previous_step')} "
13229 f"matched_pids={delta_summary.get('matched_pids')} "
13230 f"mean_disp={_format_summary_float(delta_summary.get('mean_displacement'))} "
13231 f"max_disp={_format_summary_float(delta_summary.get('max_displacement'))} "
13232 f"rank_moves={delta_summary.get('rank_migrations')} "
13233 f"cell_changes={delta_summary.get('cell_changes')} "
13234 f"new={delta_summary.get('new_count')} gone={delta_summary.get('gone_count')}"
13235 )
13236 print(" preview rows :")
13237 for row in snapshot.get("preview_rows", []):
13238 print(
13239 " "
13240 f"pid={row.get('pid')} rank={row.get('rank')} "
13241 f"cell={row.get('cell')} pos={row.get('position')} vel={row.get('velocity')}"
13242 )
13243
13244 profiling = payload.get("profiling", {})
13245 print("\n Profiling:")
13246 if profiling.get("available"):
13247 print(f" total logged step time: {profiling.get('total_logged_step_time_s', 0.0):.6f}s")
13248 for row in profiling.get("functions", [])[:5]:
13249 print(
13250 " "
13251 f"{row.get('function')}: calls={row.get('calls')} "
13252 f"time={_format_summary_float(row.get('step_time_s'), '.6f', '0.000000')}s"
13253 )
13254 else:
13255 print(" unavailable")
13256 print("=" * 60)
13257
13258
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 13262 of file core.py.

13262def _summary_display_value(value) -> str:
13263 """!
13264 @brief Format one configuration-summary value for compact text output.
13265 @param[in] value Value to format.
13266 @return Compact human-readable value.
13267 """
13268 if value is None:
13269 return "-"
13270 if isinstance(value, bool):
13271 return "enabled" if value else "disabled"
13272 if isinstance(value, float):
13273 return f"{value:.6g}"
13274 if isinstance(value, (list, tuple)):
13275 return ", ".join(_summary_display_value(item) for item in value) if value else "none"
13276 if isinstance(value, dict):
13277 if not value:
13278 return "none"
13279 return ", ".join(f"{key}={_summary_display_value(item)}" for key, item in value.items())
13280 return str(value)
13281
13282
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 13283 of file core.py.

13283def _print_config_header(title: str, subtitle: "str | None" = None):
13284 """!
13285 @brief Print a strong dashboard-style configuration summary header.
13286 @param[in] title Section title.
13287 @param[in] subtitle Optional one-line section subtitle.
13288 """
13289 print("\n" + "=" * _CONFIG_SUMMARY_WIDTH)
13290 print(f"{title:^78}")
13291 if subtitle:
13292 print(f"{subtitle:^78}")
13293 print("=" * _CONFIG_SUMMARY_WIDTH)
13294
13295
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 13296 of file core.py.

13296def _print_config_group(title: str, rows: list):
13297 """!
13298 @brief Print an aligned configuration-summary field group.
13299 @param[in] title Group title.
13300 @param[in] rows Sequence of `(label, value)` pairs.
13301 """
13302 visible_rows = [(label, value) for label, value in rows if value is not None]
13303 if not visible_rows:
13304 return
13305 print(f"\n {title}")
13306 print(f" {'-' * (len(title) + 1)}")
13307 for label, value in visible_rows:
13308 print(f" {label:<32} {_summary_display_value(value)}")
13309
13310
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 13311 of file core.py.

13311def _flatten_summary_mapping(mapping: dict, prefix: str = "") -> list:
13312 """!
13313 @brief Flatten nested summary mappings into readable dotted field rows.
13314 @param[in] mapping Mapping to flatten.
13315 @param[in] prefix Optional parent-field prefix.
13316 @return Sequence of `(field, value)` pairs.
13317 """
13318 rows = []
13319 for key, value in mapping.items():
13320 label = f"{prefix}.{key}" if prefix else str(key)
13321 if isinstance(value, dict) and value:
13322 rows.extend(_flatten_summary_mapping(value, label))
13323 else:
13324 rows.append((label, value))
13325 return rows
13326
13327
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 13328 of file core.py.

13328def _render_run_overview_text(summary: dict):
13329 """!
13330 @brief Render run metadata as a compact dashboard.
13331 @param[in] summary Curated run overview mapping.
13332 """
13333 _print_config_header("RUN OVERVIEW", summary.get("run_id"))
13334 _print_config_group(
13335 "Identity",
13336 [
13337 ("Run directory", os.path.relpath(summary.get("run_dir")) if summary.get("run_dir") else None),
13338 ("Created", summary.get("created_at")),
13339 ("Launch mode", summary.get("launch_mode")),
13340 ("Git commit", summary.get("git_commit")),
13341 ],
13342 )
13343 _print_config_group(
13344 "Execution",
13345 [
13346 ("Solver MPI processes", summary.get("solver_num_procs")),
13347 ("Post MPI processes", summary.get("post_num_procs")),
13348 ("Stages requested", summary.get("stages_requested")),
13349 ("Stages ready/completed", summary.get("stages_completed_or_submitted")),
13350 ],
13351 )
13352
13353
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 13354 of file core.py.

13354def _render_case_summary_text(summary: dict):
13355 """!
13356 @brief Render the case summary as a glanceable simulation dashboard.
13357 @param[in] summary Curated case configuration mapping.
13358 """
13359 run = summary.get("run_control", {})
13360 props = summary.get("properties", {})
13361 grid = summary.get("grid", {})
13362 domain = summary.get("domain", {})
13363 physics = summary.get("physics", {})
13364 subtitle = (
13365 f"{domain.get('dimensionality', '-')} | {domain.get('blocks', '-')} block(s) | "
13366 f"Re={_summary_display_value(props.get('reynolds_number'))}"
13367 )
13368 _print_config_header("CASE SUMMARY", subtitle)
13369 _print_config_group(
13370 "Simulation",
13371 [
13372 ("Step range", f"{run.get('start_step')} -> {run.get('end_step')} ({run.get('total_steps')} steps)"),
13373 ("Physical timestep", run.get("dt_physical")),
13374 ("Nondimensional timestep", run.get("dt_nondimensional")),
13375 ("Physical duration", run.get("duration_physical")),
13376 ("Initial conditions", props.get("initial_conditions")),
13377 ],
13378 )
13379 _print_config_group(
13380 "Fluid And Scaling",
13381 [
13382 ("Reynolds number", props.get("reynolds_number")),
13383 ("Reference length", props.get("length_ref")),
13384 ("Reference velocity", props.get("velocity_ref")),
13385 ("Density", props.get("density")),
13386 ("Viscosity", props.get("viscosity")),
13387 ],
13388 )
13389 _print_config_group(
13390 "Domain And Grid",
13391 [
13392 ("Grid mode", grid.get("mode")),
13393 ("Blocks", domain.get("blocks")),
13394 ("Dimensionality", domain.get("dimensionality")),
13395 ("Periodic axes", domain.get("periodic")),
13396 ("MPI grid layout", grid.get("processor_layout")),
13397 ("Grid source", grid.get("source_file")),
13398 ],
13399 )
13400 if grid.get("programmatic_settings"):
13401 _print_config_group("Programmatic Grid", _flatten_summary_mapping(grid["programmatic_settings"]))
13402 _print_config_group(
13403 "Physics",
13404 [
13405 ("Particles", physics.get("particles")),
13406 ("FSI", physics.get("fsi")),
13407 ("Turbulence", physics.get("turbulence")),
13408 ("Statistics", physics.get("statistics")),
13409 ],
13410 )
13411 boundary_blocks = summary.get("boundary_conditions", [])
13412 if boundary_blocks:
13413 print("\n Boundary Conditions")
13414 print(" --------------------")
13415 print(f" {'Block':<7} {'Face':<8} {'Type':<12} Handler")
13416 print(f" {'-' * 7} {'-' * 8} {'-' * 12} {'-' * 20}")
13417 for block in boundary_blocks:
13418 for face in block.get("faces", []):
13419 print(
13420 f" {block.get('block', '-')!s:<7} {face.get('face', '-'):<8} "
13421 f"{face.get('type', '-'):<12} {face.get('handler', '-')}"
13422 )
13423
13424
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 13425 of file core.py.

13425def _render_solver_summary_text(summary: dict):
13426 """!
13427 @brief Render the solver summary as a glanceable numerical-method dashboard.
13428 @param[in] summary Curated solver configuration mapping.
13429 """
13430 momentum = summary.get("momentum", {})
13431 poisson = summary.get("poisson", {})
13432 operation = summary.get("operation_mode", {})
13433 subtitle = (
13434 f"Field: {operation.get('eulerian_field_source', '-')} | "
13435 f"Momentum: {momentum.get('type', '-')} | Poisson: {poisson.get('method', '-')}"
13436 )
13437 _print_config_header("SOLVER SUMMARY", subtitle)
13438 _print_config_group("Operation", _flatten_summary_mapping(operation))
13439 _print_config_group(
13440 "Primary Methods",
13441 [
13442 ("Momentum solver", momentum.get("type")),
13443 ("Central differencing", momentum.get("central_diff")),
13444 ("Poisson method", poisson.get("method")),
13445 ("Interpolation", summary.get("interpolation")),
13446 ("Convergence mode", summary.get("solution_convergence", {}).get("mode")),
13447 ],
13448 )
13449 _print_config_group("Momentum Tolerances", _flatten_summary_mapping(momentum.get("tolerances", {})))
13450 _print_config_group("Momentum Controls", _flatten_summary_mapping(momentum.get("controls", {})))
13451 _print_config_group("Poisson Configuration", _flatten_summary_mapping(poisson))
13452 _print_config_group("Solution Convergence", _flatten_summary_mapping(summary.get("solution_convergence", {})))
13453 _print_config_group("Scalar Transport", _flatten_summary_mapping(summary.get("scalar_transport", {})))
13454 _print_config_group("Verification Sources", _flatten_summary_mapping(summary.get("verification", {})))
13455 passthrough = summary.get("petsc_passthrough", {})
13456 _print_config_group(
13457 "Advanced PETSc Options",
13458 [("Option count", passthrough.get("count")), ("Option names", passthrough.get("options"))],
13459 )
13460
13461
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 13462 of file core.py.

13462def _render_monitor_summary_text(summary: dict):
13463 """!
13464 @brief Render the monitor summary as a glanceable observability dashboard.
13465 @param[in] summary Curated monitor configuration mapping.
13466 """
13467 logging_cfg = summary.get("logging", {})
13468 profiling = summary.get("profiling", {})
13469 diagnostics = summary.get("diagnostics", {})
13470 io_cfg = summary.get("io", {})
13471 memory_log = diagnostics.get("runtime_memory_log", {})
13472 subtitle = (
13473 f"Verbosity: {logging_cfg.get('verbosity', '-')} | Profiling: {profiling.get('mode', '-')} | "
13474 f"Output every {_summary_display_value(io_cfg.get('data_output_frequency'))} steps"
13475 )
13476 _print_config_header("MONITOR SUMMARY", subtitle)
13477 _print_config_group(
13478 "Logging",
13479 [
13480 ("Verbosity", logging_cfg.get("verbosity")),
13481 ("Enabled functions", logging_cfg.get("enabled_functions")),
13482 ],
13483 )
13484 _print_config_group("Profiling", _flatten_summary_mapping(profiling))
13485 _print_config_group(
13486 "Output Cadence",
13487 [
13488 ("Field output", io_cfg.get("data_output_frequency")),
13489 ("Particle snapshots", io_cfg.get("particle_console_output_frequency")),
13490 ("Particle row interval", io_cfg.get("particle_log_interval")),
13491 ],
13492 )
13493 _print_config_group("Output Directories", _flatten_summary_mapping(io_cfg.get("directories", {})))
13494 _print_config_group(
13495 "Diagnostics",
13496 [
13497 ("Enabled PETSc diagnostics", diagnostics.get("enabled_petsc")),
13498 ("Runtime memory log", memory_log.get("enabled")),
13499 ("Runtime memory file", memory_log.get("file")),
13500 ],
13501 )
13502 _print_config_group("PETSc Diagnostic Settings", _flatten_summary_mapping(diagnostics.get("petsc", {})))
13503 solver_monitoring = summary.get("solver_monitoring", {})
13504 _print_config_group(
13505 "Solver Monitoring",
13506 [
13507 ("Enabled flags", solver_monitoring.get("enabled_flags")),
13508 ("All flags", solver_monitoring.get("flags")),
13509 ],
13510 )
13511
13512
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 13513 of file core.py.

13513def render_selected_summary(payload: dict, output_format: str = "text"):
13514 """!
13515 @brief Render selected timestep-independent config views and optional health.
13516 @param[in] payload Combined selected summary payload.
13517 @param[in] output_format Output format.
13518 """
13519 if output_format == "json":
13520 json_payload = {key: value for key, value in payload.items() if key != "_health_requested"}
13521 print(json.dumps(json_payload, indent=2, sort_keys=True))
13522 return
13523
13524 if payload.get("run_overview") is not None:
13525 _render_run_overview_text(payload["run_overview"])
13526 renderers = {
13527 "case": _render_case_summary_text,
13528 "solver": _render_solver_summary_text,
13529 "monitor": _render_monitor_summary_text,
13530 }
13531 for key in ("case", "solver", "monitor"):
13532 if key in payload.get("configuration", {}):
13533 renderers[key](payload["configuration"][key])
13534 if payload.get("_health_requested"):
13535 health_payload = {key: value for key, value in payload.items() if key not in {"run_overview", "configuration", "_health_requested"}}
13536 render_run_summary(health_payload, output_format="text")
13537
13538
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 13546 of file core.py.

13546def _append_summary_plot_record(records: list, source: str, step, line: str, values: dict, source_path: str, segment: int = 0):
13547 """!
13548 @brief Append one numeric append-ordered record for summarize plotting.
13549 @param[out] records Destination record list.
13550 @param[in] source Qualified source prefix.
13551 @param[in] step Logged timestep.
13552 @param[in] line Human-readable line identity.
13553 @param[in] values Candidate field mapping.
13554 @param[in] source_path Source artifact path.
13555 @param[in] segment Zero-based continuation segment within the source artifact.
13556 """
13557 numeric = {
13558 key: value
13559 for key, value in values.items()
13560 if isinstance(value, (int, float)) and not isinstance(value, bool)
13561 }
13562 if step is not None and numeric:
13563 records.append({
13564 "source": source,
13565 "step": int(step),
13566 "line": line,
13567 "values": numeric,
13568 "source_path": source_path,
13569 "segment": int(segment),
13570 })
13571
13572
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 13573 of file core.py.

13573def _is_summary_plot_continuation_marker(line: str) -> bool:
13574 """!
13575 @brief Return whether a log line starts a new continuation segment.
13576 @param[in] line Candidate raw or stripped log line.
13577 @return True for the shared continuation marker syntax.
13578 """
13579 return bool(re.match(r"^\s*#?\s*=*\s*Continuation from step\s+\d+", line, re.IGNORECASE))
13580
13581
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 13582 of file core.py.

13582def _collect_summary_plot_records(context: dict) -> list:
13583 """!
13584 @brief Collect append-ordered numeric records from summarize-supported scalar logs.
13585 @param[in] context Summary context returned by `_build_summary_context()`.
13586 @return Append-ordered plot record list.
13587 """
13588 records = []
13589 log_dir = context["log_dir"]
13590
13591 continuity_path = os.path.join(log_dir, "Continuity_Metrics.log")
13592 if os.path.isfile(continuity_path):
13593 segment = 0
13594 with open(continuity_path, "r", encoding="utf-8", errors="replace") as f:
13595 for raw_line in f:
13596 if _is_summary_plot_continuation_marker(raw_line):
13597 segment += 1
13598 continue
13599 parts = [part.strip() for part in raw_line.split("|")]
13600 if len(parts) < 8:
13601 continue
13602 step, block = _parse_int_loose(parts[0]), _parse_int_loose(parts[1])
13603 _append_summary_plot_record(
13604 records, "continuity", step, f"block {block}",
13605 {
13606 "max_divergence": _parse_float_loose(parts[2]),
13607 "rhs_sum": _parse_float_loose(parts[4]),
13608 "flux_in": _parse_float_loose(parts[5]),
13609 "flux_out": _parse_float_loose(parts[6]),
13610 "net_flux": _parse_float_loose(parts[7]),
13611 },
13612 continuity_path, segment,
13613 )
13614
13615 particle_path = os.path.join(log_dir, "Particle_Metrics.log")
13616 if os.path.isfile(particle_path):
13617 segment = 0
13618 with open(particle_path, "r", encoding="utf-8", errors="replace") as f:
13619 for raw_line in f:
13620 if _is_summary_plot_continuation_marker(raw_line):
13621 segment += 1
13622 continue
13623 parts = [part.strip() for part in raw_line.split("|")]
13624 if len(parts) < 8:
13625 continue
13626 step = _parse_int_loose(parts[1])
13627 offset = 1 if len(parts) >= 9 else 0
13628 _append_summary_plot_record(
13629 records, "particles", step, "particles",
13630 {
13631 "total_particles": _parse_int_loose(parts[2]),
13632 "lost_particles": _parse_int_loose(parts[3]),
13633 "lost_particles_cumulative": _parse_int_loose(parts[4]) if offset else None,
13634 "migrated_particles": _parse_int_loose(parts[4 + offset]),
13635 "occupied_cells": _parse_int_loose(parts[5 + offset]),
13636 "load_imbalance": _parse_float_loose(parts[6 + offset]),
13637 "migration_passes": _parse_int_loose(parts[7 + offset]),
13638 },
13639 particle_path, segment,
13640 )
13641
13642 # Phase 3+: dtau [physical time] + cfl_eff [dimensionless Courant number].
13643 momentum_regex = re.compile(
13644 r"Step:\s*(?P<step>\d+)\s*\|\s*PseudoIter\‍(k\‍):\s*(?P<pseudo_iter>\d+)\s*\|"
13645 r"\s*dtau:\s*(?P<dtau>[-+0-9.eE]+)\s*\|\s*cfl_eff:\s*(?P<cfl_eff>[-+0-9.eE]+)\s*\|"
13646 r"\s*\|dUk\|:\s*(?P<delta>[-+0-9.eE]+)\s*\|"
13647 r"\s*\|dUk\|/\|dU0\|:\s*(?P<delta_rel>[-+0-9.eE]+)\s*\|\s*\|Rk\|:\s*(?P<resid>[-+0-9.eE]+)\s*\|"
13648 r"\s*\|Rk\|/\|R0\|:\s*(?P<resid_rel>[-+0-9.eE]+)"
13649 r"(?:\s*\|\s*trial_ratio:\s*(?P<trial_ratio>[-+0-9.eE]+)"
13650 r"(?:\s*\|\s*smoothed_ratio:\s*(?P<smoothed_ratio>[-+0-9.eE]+))?"
13651 r"\s*\|\s*status:\s*(?P<status>\w+)\s*\|\s*dtau_after:\s*(?P<dtau_after>[-+0-9.eE]+)"
13652 r"(?:\s*\|\s*cfl_eff_after:\s*(?P<cfl_eff_after>[-+0-9.eE]+))?)?"
13653 )
13654 jameson_patterns = [
13655 os.path.join(log_dir, "Momentum_Solver_DualTime_Picard_Jameson_RK_History_Block_*.log"),
13656 os.path.join(log_dir, "Momentum_Solver_Convergence_History_Block_*.log"),
13657 ]
13658 for path in sorted(path for pattern in jameson_patterns for path in glob.glob(pattern)):
13659 block_match = re.search(r"Block_(\d+)\.log$", path)
13660 if not block_match:
13661 continue
13662 segment = 0
13663 with open(path, "r", encoding="utf-8", errors="replace") as f:
13664 for raw_line in f:
13665 if _is_summary_plot_continuation_marker(raw_line):
13666 segment += 1
13667 continue
13668 match = momentum_regex.search(raw_line)
13669 if match:
13670 _append_summary_plot_record(
13671 records, "momentum", int(match.group("step")), f"block {block_match.group(1)}",
13672 {
13673 "pseudo_iterations": int(match.group("pseudo_iter")),
13674 "dtau": _parse_float_loose(match.group("dtau")),
13675 "cfl_eff": _parse_float_loose(match.group("cfl_eff")),
13676 "delta_norm": float(match.group("delta")),
13677 "delta_rel": float(match.group("delta_rel")),
13678 "residual_norm": float(match.group("resid")),
13679 "residual_rel": float(match.group("resid_rel")),
13680 "trial_ratio": _parse_float_loose(match.group("trial_ratio")),
13681 "smoothed_ratio": _parse_float_loose(match.group("smoothed_ratio")),
13682 "dtau_after": _parse_float_loose(match.group("dtau_after")),
13683 "cfl_eff_after": _parse_float_loose(match.group("cfl_eff_after")),
13684 },
13685 path, segment,
13686 )
13687
13688 newton_history_regex = re.compile(
13689 r"step:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|"
13690 r"\s*newton:\s*(?P<newton>\d+)\s*\|\s*nonlinear_norm:\s*(?P<norm>[-+0-9.eE]+)"
13691 )
13692 for path in sorted(glob.glob(os.path.join(log_dir, "Momentum_Solver_Newton_Krylov_History_Block_*.log"))):
13693 segment = 0
13694 with open(path, "r", encoding="utf-8", errors="replace") as f:
13695 for raw_line in f:
13696 if _is_summary_plot_continuation_marker(raw_line):
13697 segment += 1
13698 continue
13699 match = newton_history_regex.search(raw_line)
13700 if match:
13701 _append_summary_plot_record(
13702 records, "momentum", int(match.group("step")), f"block {match.group('block')}",
13703 {
13704 "newton_iterations": int(match.group("newton")),
13705 "residual_norm": float(match.group("norm")),
13706 },
13707 path, segment,
13708 )
13709
13710 poisson_regex = re.compile(
13711 r"ts:\s*(?P<step>\d+)\s*\|\s*block:\s*(?P<block>\d+)\s*\|\s*iter:\s*(?P<iter>\d+)\s*\|"
13712 r"\s*Unprecond Norm:\s*(?P<unpre>[-+0-9.eE]+)\s*\|\s*True Norm:\s*(?P<true>[-+0-9.eE]+)"
13713 r"(?:\s*\|\s*Rel Norm:\s*(?P<rel>[-+0-9.eE]+))?"
13714 )
13715 for path in sorted(glob.glob(os.path.join(log_dir, "Poisson_Solver_Convergence_History_Block_*.log"))):
13716 segment = 0
13717 with open(path, "r", encoding="utf-8", errors="replace") as f:
13718 for raw_line in f:
13719 if _is_summary_plot_continuation_marker(raw_line):
13720 segment += 1
13721 continue
13722 match = poisson_regex.search(raw_line)
13723 if match:
13724 _append_summary_plot_record(
13725 records, "poisson", int(match.group("step")), f"block {match.group('block')}",
13726 {
13727 "iterations": int(match.group("iter")),
13728 "unpreconditioned_norm": float(match.group("unpre")),
13729 "true_norm": float(match.group("true")),
13730 "relative_norm": _parse_float_loose(match.group("rel")),
13731 },
13732 path, segment,
13733 )
13734
13735 profiling_path = os.path.join(log_dir, context["profiling_cfg"].get("timestep_file", "Profiling_Timestep_Summary.csv"))
13736 if os.path.isfile(profiling_path):
13737 segment = 0
13738 columns = None
13739 with open(profiling_path, "r", encoding="utf-8", errors="replace", newline="") as f:
13740 for raw_line in f:
13741 if _is_summary_plot_continuation_marker(raw_line):
13742 segment += 1
13743 continue
13744 values = next(csv.reader([raw_line]))
13745 if not values:
13746 continue
13747 if columns is None:
13748 columns = values
13749 continue
13750 row = dict(zip(columns, values))
13751 _append_summary_plot_record(
13752 records, "profiling", _parse_int_loose(row.get("step")), row.get("function") or "unknown",
13753 {"calls": _parse_int_loose(row.get("calls")), "step_time_s": _parse_float_loose(row.get("step_time_s"))},
13754 profiling_path, segment,
13755 )
13756
13757 diagnostics = resolve_diagnostics_config(context["monitor_cfg"])
13758 memory_path = os.path.join(log_dir, diagnostics["runtime_memory_log"].get("file", "Runtime_Memory.log"))
13759 if os.path.isfile(memory_path):
13760 segment = 0
13761 with open(memory_path, "r", encoding="utf-8", errors="replace") as f:
13762 for raw_line in f:
13763 if _is_summary_plot_continuation_marker(raw_line):
13764 segment += 1
13765 continue
13766 parts = raw_line.split()
13767 if len(parts) >= 8 and parts[1] in {"Step", "Post"}:
13768 _append_summary_plot_record(
13769 records, "memory", _parse_int_loose(parts[0]), "memory",
13770 {
13771 "process_current_mb_max": _parse_float_loose(parts[2]),
13772 "process_peak_mb_max": _parse_float_loose(parts[3]),
13773 "petsc_allocated_mb_max": _parse_float_loose(parts[4]),
13774 "petsc_peak_allocated_mb_max": _parse_float_loose(parts[5]),
13775 "process_change_mb_max": _parse_float_loose(parts[6]),
13776 },
13777 memory_path, segment,
13778 )
13779
13780 convergence_path = os.path.join(log_dir, "solution_convergence.log")
13781 if os.path.isfile(convergence_path):
13782 columns = None
13783 segment = 0
13784 with open(convergence_path, "r", encoding="utf-8", errors="replace") as f:
13785 for raw_line in f:
13786 line = raw_line.strip()
13787 if _is_summary_plot_continuation_marker(line):
13788 segment += 1
13789 continue
13790 if not line or line.startswith(("=", "-")):
13791 continue
13792 if columns is None:
13793 columns = [part.strip() for part in raw_line.split("|")]
13794 continue
13795 parts = [part.strip() for part in raw_line.split("|")]
13796 step = _parse_int_loose(parts[0])
13797 values = {name: _parse_float_loose(value) for name, value in zip(columns, parts) if name not in {"step", "mode", "ref"}}
13798 _append_summary_plot_record(records, "convergence", step, "convergence", values, convergence_path, segment)
13799 return records
13800
13801
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 13802 of file core.py.

13802def _build_summary_plot_catalog(records: list) -> list:
13803 """!
13804 @brief Build available qualified-series metadata from plot records.
13805 @param[in] records Append-ordered plot record list.
13806 @return Available series catalog.
13807 """
13808 catalog = {}
13809 for record in records:
13810 for field in record["values"]:
13811 name = f"{record['source']}.{field}"
13812 item = catalog.setdefault(name, {"series": name, "lines": {}, "source_paths": set(), "sample_count": 0})
13813 item["lines"][record["line"]] = item["lines"].get(record["line"], 0) + 1
13814 item["source_paths"].add(record["source_path"])
13815 item["sample_count"] += 1
13816 return [
13817 {
13818 **item,
13819 "lines": [{"label": label, "sample_count": count} for label, count in sorted(item["lines"].items())],
13820 "source_paths": sorted(item["source_paths"]),
13821 }
13822 for _, item in sorted(catalog.items())
13823 ]
13824
13825
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 13826 of file core.py.

13826def _build_summary_plot_request(context: dict, records: list, series: str, last_n: "int | None", linear_y: bool, output_path: "str | None") -> dict:
13827 """!
13828 @brief Build one normalized plot.gen request from collected summarize records.
13829 @param[in] context Summary context returned by `_build_summary_context()`.
13830 @param[in] records Append-ordered plot record list.
13831 @param[in] series Qualified series name.
13832 @param[in] last_n Optional last-N records per plotted line.
13833 @param[in] linear_y Whether to force linear scaling.
13834 @param[in] output_path Optional explicit output path.
13835 @return Versioned normalized plot request.
13836 """
13837 source, separator, field = series.partition(".")
13838 if not separator:
13839 raise ValueError("plot series must be qualified as '<source>.<field>'")
13840 matching = [record for record in records if record["source"] == source and field in record["values"]]
13841 if not matching:
13842 raise ValueError(f"Plot series '{series}' is unavailable. Use --list-plot-series to inspect available series.")
13843 latest_segments = {}
13844 for record in matching:
13845 source_path = record["source_path"]
13846 latest_segments[source_path] = max(latest_segments.get(source_path, 0), record.get("segment", 0))
13847 matching = [
13848 record for record in matching
13849 if record.get("segment", 0) == latest_segments[record["source_path"]]
13850 ]
13851 grouped = {}
13852 for record in matching:
13853 grouped.setdefault(record["line"], []).append([record["step"], record["values"][field]])
13854 if last_n is not None:
13855 grouped = {label: points[-last_n:] for label, points in grouped.items()}
13856 all_values = [point[1] for points in grouped.values() for point in points]
13857 use_log = not linear_y and field in _SUMMARY_PLOT_LOG_SCALE_FIELDS and all(value > 0 for value in all_values)
13858 window_token = f"last-{last_n}" if last_n is not None else "full"
13859 safe_series = re.sub(r"[^A-Za-z0-9_.-]+", "_", series)
13860 fallback = os.path.join(context["run_dir"], "summary", "plots", f"{safe_series}_{window_token}.png")
13861 return {
13862 "schema_version": 1,
13863 "plot_type": "time_history",
13864 "series": series,
13865 "title": f"{series} time history",
13866 "x_label": "Timestep",
13867 "y_label": series,
13868 "y_scale": "log" if use_log else "linear",
13869 "window": {"mode": "last" if last_n is not None else "full", "last": last_n},
13870 "lines": [{"label": label, "points": points} for label, points in sorted(grouped.items())],
13871 "output_path": os.path.abspath(output_path) if output_path else None,
13872 "fallback_output_path": fallback,
13873 }
13874
13875
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 13876 of file core.py.

13876def _render_summary_plot_catalog(catalog: list, output_format: str):
13877 """!
13878 @brief Render available summarize plot-series metadata.
13879 @param[in] catalog Available series catalog.
13880 @param[in] output_format Text or JSON output format.
13881 """
13882 if output_format == "json":
13883 print(json.dumps({"available_series": catalog}, indent=2, sort_keys=True))
13884 return
13885 print("\nAVAILABLE TIME-HISTORY SERIES")
13886 print("=" * 78)
13887 for item in catalog:
13888 labels = ", ".join(line["label"] for line in item["lines"])
13889 print(f" {item['series']:<42} samples={item['sample_count']:<5} lines={labels}")
13890 print(f" source: {', '.join(os.path.relpath(path) for path in item['source_paths'])}")
13891
13892
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 13893 of file core.py.

13893def _invoke_plot_gen(request: dict):
13894 """!
13895 @brief Invoke standalone plot.gen with one normalized request over stdin.
13896 @param[in] request Versioned normalized plot request.
13897 """
13898 plotgen_path = os.path.join(GENERATORS_PATH, "plot.gen")
13899 if not os.path.isfile(plotgen_path):
13900 raise ValueError(f"plot.gen script not found: {plotgen_path}")
13901 result = subprocess.run(
13902 [sys.executable, plotgen_path, "--input", "-"],
13903 input=json.dumps(request),
13904 text=True,
13905 capture_output=True,
13906 check=False,
13907 )
13908 if result.stdout:
13909 print(result.stdout.rstrip())
13910 if result.returncode != 0:
13911 details = (result.stderr or result.stdout or "unknown plotting error").strip()
13912 if result.returncode == 3:
13913 raise PlotDependencyError(details)
13914 raise ValueError(f"plot.gen failed with exit code {result.returncode}: {details}")
13915
13916
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 13917 of file core.py.

13917def summarize_workflow(args):
13918 """!
13919 @brief Build and render a read-only health summary for a run step.
13920 @param[in] args Command-line style argument list supplied to the function.
13921 """
13922 if args.step is not None and args.step < 0:
13923 fail_cli_usage("--step must be a non-negative integer.")
13924 if args.snapshot_rows < 1:
13925 fail_cli_usage("--snapshot-rows must be at least 1.")
13926 plot_series = getattr(args, "plot_series", None)
13927 list_plot_series = bool(getattr(args, "list_plot_series", False))
13928 last_n = getattr(args, "last_n", None)
13929 plot_output = getattr(args, "plot_output", None)
13930 linear_y = bool(getattr(args, "linear_y", False))
13931 plot_mode = bool(plot_series or list_plot_series)
13932 existing_selectors = any(
13933 [
13934 getattr(args, "overview", False),
13935 getattr(args, "case", False),
13936 getattr(args, "solver", False),
13937 getattr(args, "monitor", False),
13938 args.step is not None,
13939 getattr(args, "latest", False),
13940 getattr(args, "max_step", False),
13941 ]
13942 )
13943 if plot_mode and existing_selectors:
13944 fail_cli_usage("Plot discovery and --plot cannot be combined with config or selected-step selectors.")
13945 if not plot_series and (last_n is not None or plot_output or linear_y):
13946 fail_cli_usage("--last, --plot-output, and --linear-y require --plot.")
13947 if last_n is not None and last_n < 1:
13948 fail_cli_usage("--last must be a positive integer.")
13949 if plot_series and args.output_format == "json":
13950 fail_cli_usage("--plot does not support --format json; use --list-plot-series --format json for structured discovery.")
13951 if plot_mode:
13952 context = _build_summary_context(args.run_dir)
13953 try:
13954 records = _collect_summary_plot_records(context)
13955 catalog = _build_summary_plot_catalog(records)
13956 if list_plot_series:
13957 if not catalog:
13958 raise ValueError("No plottable scalar histories were found in the run logs.")
13959 _render_summary_plot_catalog(catalog, args.output_format)
13960 return
13961 request = _build_summary_plot_request(context, records, plot_series, last_n, linear_y, plot_output)
13962 _invoke_plot_gen(request)
13963 return
13964 except PlotDependencyError as exc:
13965 emit_structured_error(
13966 ERROR_CODE_DEPENDENCY_MISSING,
13967 key="plotting",
13968 file_path=sys.executable,
13969 message=str(exc),
13970 )
13971 sys.exit(1)
13972 except ValueError as exc:
13973 emit_structured_error(
13974 ERROR_CODE_CFG_INVALID_VALUE,
13975 key="plot",
13976 file_path=context["log_dir"],
13977 message=str(exc),
13978 )
13979 sys.exit(1)
13980
13981 selected_configs = {
13982 name
13983 for name in ("case", "solver", "monitor")
13984 if bool(getattr(args, name, False))
13985 }
13986 if getattr(args, "overview", False):
13987 selected_configs.update({"case", "solver", "monitor"})
13988 explicit_health = args.step is not None or bool(getattr(args, "latest", False)) or bool(getattr(args, "max_step", False))
13989 health_requested = explicit_health or (not selected_configs and not getattr(args, "overview", False))
13990
13991 context = None
13992 combined = {}
13993 if selected_configs or getattr(args, "overview", False):
13994 context = _build_summary_context(args.run_dir)
13995 if getattr(args, "overview", False):
13996 combined["run_overview"] = _build_run_overview(context)
13997 combined["configuration"] = {}
13998 builders = {
13999 "case": _build_case_overview,
14000 "solver": _build_solver_overview,
14001 "monitor": _build_monitor_overview,
14002 }
14003 for name in ("case", "solver", "monitor"):
14004 if name in selected_configs:
14005 try:
14006 combined["configuration"][name] = builders[name](context)
14007 except (KeyError, TypeError, ValueError, ZeroDivisionError) as exc:
14008 emit_structured_error(
14009 ERROR_CODE_CFG_INVALID_VALUE,
14010 key=name,
14011 file_path=context["config_paths"][name],
14012 message=f"Could not summarize copied {name}.yml: {exc}",
14013 )
14014 sys.exit(1)
14015
14016 if not health_requested:
14017 combined["_health_requested"] = False
14018 render_selected_summary(combined, output_format=args.output_format)
14019 return
14020
14021 requested_step = args.step
14022 if requested_step is None and getattr(args, "latest", False):
14023 requested_step = None
14024 selection_mode = "max_step" if getattr(args, "max_step", False) else "latest"
14025 health_payload = build_run_summary_payload(
14026 args.run_dir,
14027 step=requested_step,
14028 snapshot_rows=args.snapshot_rows,
14029 selection_mode=selection_mode,
14030 )
14031 if not combined:
14032 render_run_summary(health_payload, output_format=args.output_format)
14033 return
14034 combined = {**health_payload, **combined, "_health_requested": True}
14035 render_selected_summary(combined, output_format=args.output_format)
14036
14037
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 14038 of file core.py.

14038def _resolve_submission_target(run_dir: str = None, study_dir: str = None) -> dict:
14039 """!
14040 @brief Resolve a run/study submission target from explicit directory flags.
14041 @param[in] run_dir Argument passed to `_resolve_submission_target()`.
14042 @param[in] study_dir Argument passed to `_resolve_submission_target()`.
14043 @return Value returned by `_resolve_submission_target()`.
14044 """
14045 has_run_dir = bool(run_dir)
14046 has_study_dir = bool(study_dir)
14047 if has_run_dir == has_study_dir:
14048 fail_cli_usage("submit requires exactly one of --run-dir or --study-dir.")
14049
14050 target_kind = "run" if has_run_dir else "study"
14051 target_key = "run_dir" if target_kind == "run" else "study_dir"
14052 root_dir = os.path.abspath(run_dir if has_run_dir else study_dir)
14053 if not os.path.isdir(root_dir):
14054 emit_structured_error(
14055 ERROR_CODE_CFG_FILE_NOT_FOUND,
14056 key=target_key,
14057 file_path=root_dir,
14058 message=f"{'Run' if target_kind == 'run' else 'Study'} directory not found.",
14059 )
14060 sys.exit(1)
14061
14062 scheduler_dir = os.path.join(root_dir, "scheduler")
14063 submission_path = os.path.join(scheduler_dir, "submission.json")
14064 submission_meta = _read_json_if_exists(submission_path)
14065 if not isinstance(submission_meta, dict):
14066 emit_structured_error(
14067 ERROR_CODE_CFG_FILE_NOT_FOUND,
14068 key="scheduler.submission",
14069 file_path=submission_path,
14070 message="Target directory does not contain scheduler submission metadata.",
14071 hint="Use a Slurm-staged run/study directory with scheduler/submission.json, or submit the script manually.",
14072 )
14073 sys.exit(1)
14074
14075 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
14076 if launch_mode == "local" and target_kind != "run":
14077 emit_structured_error(
14078 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14079 key="scheduler.launch_mode",
14080 file_path=submission_path,
14081 message="Local staged submission is supported for run directories only.",
14082 hint="Use --run-dir for local staged execution; study submit remains Slurm-only.",
14083 )
14084 sys.exit(1)
14085 if launch_mode not in {"slurm", "local"}:
14086 emit_structured_error(
14087 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14088 key="scheduler.launch_mode",
14089 file_path=submission_path,
14090 message=f"Target launch_mode={launch_mode or 'unknown'} is not supported.",
14091 hint="Use a staged run/study directory with launch_mode 'slurm' or a run directory with launch_mode 'local'.",
14092 )
14093 sys.exit(1)
14094
14095 if target_kind == "run":
14096 script_map = {
14097 "solve": os.path.join(scheduler_dir, "solver.sbatch"),
14098 "post-process": os.path.join(scheduler_dir, "post.sbatch"),
14099 }
14100 display_label = "Run directory"
14101 manifest_path = None
14102 else:
14103 script_map = {
14104 "solve": os.path.join(scheduler_dir, "solver_array.sbatch"),
14105 "post-process": os.path.join(scheduler_dir, "post_array.sbatch"),
14106 }
14107 display_label = "Study directory"
14108 manifest_path = os.path.join(root_dir, "study_manifest.json")
14109
14110 return {
14111 "target_kind": target_kind,
14112 "target_key": target_key,
14113 "root_dir": root_dir,
14114 "scheduler_dir": scheduler_dir,
14115 "submission_path": submission_path,
14116 "submission_meta": submission_meta,
14117 "launch_mode": launch_mode,
14118 "script_map": script_map,
14119 "display_label": display_label,
14120 "manifest_path": manifest_path,
14121 }
14122
14123
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 14124 of file core.py.

14124def _get_submission_stage_metadata(target_context: dict, stage_name: str) -> dict:
14125 """!
14126 @brief Return stored metadata for one staged submission target.
14127 @param[in] target_context Argument passed to `_get_submission_stage_metadata()`.
14128 @param[in] stage_name Argument passed to `_get_submission_stage_metadata()`.
14129 @return Value returned by `_get_submission_stage_metadata()`.
14130 """
14131 submission_meta = target_context["submission_meta"]
14132 if target_context["target_kind"] == "run":
14133 stages = submission_meta.get("stages", {})
14134 if not isinstance(stages, dict):
14135 return {}
14136 stage_meta = stages.get(stage_name)
14137 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
14138
14139 key = "solver_array" if stage_name == "solve" else "post_array"
14140 stage_meta = submission_meta.get(key)
14141 return copy.deepcopy(stage_meta) if isinstance(stage_meta, dict) else {}
14142
14143
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 14144 of file core.py.

14144def _get_recorded_submission_stages(target_context: dict) -> list:
14145 """!
14146 @brief Return stage names explicitly recorded in scheduler submission metadata.
14147 @param[in] target_context Argument passed to `_get_recorded_submission_stages()`.
14148 @return Value returned by `_get_recorded_submission_stages()`.
14149 """
14150 submission_meta = target_context["submission_meta"]
14151 recorded = []
14152 if target_context["target_kind"] == "run":
14153 stages = submission_meta.get("stages", {})
14154 if isinstance(stages, dict):
14155 for stage_name in ["solve", "post-process"]:
14156 if isinstance(stages.get(stage_name), dict):
14157 recorded.append(stage_name)
14158 return recorded
14159
14160 if isinstance(submission_meta.get("solver_array"), dict):
14161 recorded.append("solve")
14162 if isinstance(submission_meta.get("post_array"), dict):
14163 recorded.append("post-process")
14164 return recorded
14165
14166
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 14167 of file core.py.

14167def _format_stage_list(stage_names: list) -> str:
14168 """!
14169 @brief Format a human-readable stage list for submit diagnostics.
14170 @param[in] stage_names Argument passed to `_format_stage_list()`.
14171 @return Value returned by `_format_stage_list()`.
14172 """
14173 return ", ".join(stage_names) if stage_names else "none"
14174
14175

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

14176def _build_submit_missing_stage_hint(target_context: dict, requested_stage: str, selected_stages: list) -> str:
14177 """!
14178 @brief Build an actionable hint for requested submit stages missing from metadata.
14179 @param[in] target_context Argument passed to `_build_submit_missing_stage_hint()`.
14180 @param[in] requested_stage Argument passed to `_build_submit_missing_stage_hint()`.
14181 @param[in] selected_stages Argument passed to `_build_submit_missing_stage_hint()`.
14182 @return Value returned by `_build_submit_missing_stage_hint()`.
14183 """
14184 recorded_stages = _get_recorded_submission_stages(target_context)
14185 recorded_set = set(recorded_stages)
14186 selected_set = set(selected_stages)
14187 target_flag = "--run-dir" if target_context["target_kind"] == "run" else "--study-dir"
14188 target_path = os.path.relpath(target_context["root_dir"])
14189 submit_prefix = f"picurv submit {target_flag} {target_path}"
14190 solve_stage_command = (
14191 "picurv run --solve ... --no-submit"
14192 if target_context["target_kind"] == "run"
14193 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
14194 )
14195 post_stage_command = (
14196 "picurv run --post-process --post <post.yml> ... --no-submit"
14197 if target_context["target_kind"] == "run"
14198 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
14199 )
14200 solve_post_command = (
14201 "picurv run --solve --post-process --post <post.yml> ... --no-submit"
14202 if target_context["target_kind"] == "run"
14203 else "picurv sweep --cluster <cluster.yml> ... --no-submit"
14204 )
14205
14206 if requested_stage == "all":
14207 if recorded_set == {"solve"}:
14208 return (
14209 "--stage all requests solve and post-process, but this target records only solve. "
14210 f"Use `{submit_prefix} --stage solve`, or re-stage with post-processing enabled "
14211 f"(`{solve_post_command}`)."
14212 )
14213 if recorded_set == {"post-process"}:
14214 return (
14215 "--stage all requests solve and post-process, but this target records only post-process. "
14216 f"Use `{submit_prefix} --stage post-process`, or re-stage including the solve stage "
14217 f"(`{solve_stage_command}`)."
14218 )
14219 missing = [stage for stage in selected_stages if stage not in recorded_set]
14220 if missing:
14221 return (
14222 "--stage all requests solve and post-process, but submission metadata records "
14223 f"{_format_stage_list(recorded_stages)}. Re-stage the missing stage(s): "
14224 f"{_format_stage_list(missing)}."
14225 )
14226
14227 if selected_set == {"solve"} and "solve" not in recorded_set:
14228 return (
14229 "The solve stage was requested, but submission metadata does not record a staged solve command/script. "
14230 f"Re-stage with `{solve_stage_command}`."
14231 )
14232 if selected_set == {"post-process"} and "post-process" not in recorded_set:
14233 return (
14234 "The post-process stage was requested, but submission metadata does not record a staged post-process command/script. "
14235 f"Re-stage with post-processing enabled (`{post_stage_command}`, or `{solve_post_command}`)."
14236 )
14237
14238 return "Re-stage the requested stage(s) with picurv run/sweep --no-submit before calling picurv submit."
14239
14240
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 14241 of file core.py.

14241def _set_submission_stage_metadata(target_context: dict, stage_name: str, stage_meta: dict):
14242 """!
14243 @brief Persist one stage's metadata back into the submission payload.
14244 @param[in] target_context Argument passed to `_set_submission_stage_metadata()`.
14245 @param[in] stage_name Argument passed to `_set_submission_stage_metadata()`.
14246 @param[in] stage_meta Argument passed to `_set_submission_stage_metadata()`.
14247 """
14248 submission_meta = target_context["submission_meta"]
14249 if target_context["target_kind"] == "run":
14250 stages = submission_meta.get("stages")
14251 if not isinstance(stages, dict):
14252 stages = {}
14253 submission_meta["stages"] = stages
14254 stages[stage_name] = stage_meta
14255 return
14256
14257 key = "solver_array" if stage_name == "solve" else "post_array"
14258 submission_meta[key] = stage_meta
14259
14260
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 14261 of file core.py.

14261def _write_submission_target_metadata(target_context: dict):
14262 """!
14263 @brief Write updated submission metadata back to disk.
14264 @param[in] target_context Argument passed to `_write_submission_target_metadata()`.
14265 """
14266 write_json_file(target_context["submission_path"], target_context["submission_meta"])
14267
14268 manifest_path = target_context.get("manifest_path")
14269 if manifest_path and os.path.isfile(manifest_path):
14270 manifest_payload = _read_json_if_exists(manifest_path)
14271 if isinstance(manifest_payload, dict):
14272 manifest_payload["submission"] = target_context["submission_meta"]
14273 write_json_file(manifest_path, manifest_payload)
14274
14275
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 14276 of file core.py.

14276def submit_staged_jobs(args):
14277 """!
14278 @brief Submit previously staged Slurm artifacts from an existing run/study directory.
14279 @param[in] args Command-line style argument list supplied to the function.
14280 """
14281 target_context = _resolve_submission_target(
14282 run_dir=getattr(args, "run_dir", None),
14283 study_dir=getattr(args, "study_dir", None),
14284 )
14285 stage_order = ["solve", "post-process"]
14286 requested_stage = args.stage
14287 selected_stages = stage_order if requested_stage == "all" else [requested_stage]
14288
14289 print(f"[INFO] {target_context['display_label']:<20}: {os.path.relpath(target_context['root_dir'])}")
14290 print(f"[INFO] Submission metadata : {os.path.relpath(target_context['submission_path'])}")
14291 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
14292
14293 if target_context.get("launch_mode") == "local":
14294 submit_staged_local_run(args, target_context, selected_stages)
14295 return
14296
14297 stage_plans = []
14298 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
14299 solve_existing_job_id = str(solve_existing_meta.get("job_id", "")).strip()
14300
14301 for stage_name in selected_stages:
14302 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
14303 script_path = target_context["script_map"][stage_name]
14304 missing_stage_hint = _build_submit_missing_stage_hint(target_context, requested_stage, selected_stages)
14305 if not existing_meta:
14306 emit_structured_error(
14307 ERROR_CODE_CFG_MISSING_KEY,
14308 key=f"scheduler.{stage_name}.metadata",
14309 file_path=target_context["submission_path"],
14310 message=f"Submission metadata does not record stage '{stage_name}'.",
14311 hint=missing_stage_hint,
14312 )
14313 sys.exit(1)
14314
14315 if not os.path.isfile(script_path):
14316 emit_structured_error(
14317 ERROR_CODE_CFG_FILE_NOT_FOUND,
14318 key=f"scheduler.{stage_name}.script",
14319 file_path=script_path,
14320 message=f"Required {stage_name} sbatch artifact is missing.",
14321 hint=missing_stage_hint,
14322 )
14323 sys.exit(1)
14324
14325 if existing_meta.get("submitted") and not args.force:
14326 emit_structured_error(
14327 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14328 key=f"scheduler.{stage_name}.submitted",
14329 file_path=target_context["submission_path"],
14330 message=f"Stage '{stage_name}' is already recorded as submitted.",
14331 hint="Use --force to resubmit this stage intentionally.",
14332 )
14333 sys.exit(1)
14334
14335 dependency = None
14336 if stage_name == "post-process":
14337 if "solve" in selected_stages:
14338 dependency = "__NEW_SOLVE_JOB_ID__"
14339 else:
14340 if not (solve_existing_meta.get("submitted") and solve_existing_job_id):
14341 emit_structured_error(
14342 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14343 key="scheduler.post-process.dependency",
14344 file_path=target_context["submission_path"],
14345 message="Post-process submission requires a recorded solve job id when solve is not being submitted in the same command.",
14346 hint="Submit --stage solve or --stage all first, or use --force only after solve metadata exists.",
14347 )
14348 sys.exit(1)
14349 dependency = solve_existing_job_id
14350
14351 stage_plans.append(
14352 {
14353 "stage": stage_name,
14354 "script": script_path,
14355 "dependency": dependency,
14356 "existing_meta": existing_meta,
14357 }
14358 )
14359
14360 if args.dry_run:
14361 for plan in stage_plans:
14362 cmd = ["sbatch"]
14363 dependency = plan["dependency"]
14364 if dependency == "__NEW_SOLVE_JOB_ID__":
14365 cmd.append("--dependency=afterok:<new solve job id>")
14366 elif dependency:
14367 cmd.append(f"--dependency=afterok:{dependency}")
14368 cmd.append(plan["script"])
14369 print(f"[DRY-RUN] Would run: {' '.join(cmd)}")
14370 print("[INFO] Dry-run only. No jobs were submitted.")
14371 return
14372
14373 latest_solve_job_id = None
14374 for plan in stage_plans:
14375 dependency = plan["dependency"]
14376 if dependency == "__NEW_SOLVE_JOB_ID__":
14377 dependency = latest_solve_job_id
14378
14379 submit_info = submit_sbatch(plan["script"], dependency=dependency)
14380 stage_meta = copy.deepcopy(plan["existing_meta"])
14381 stage_meta.update(submit_info)
14382 stage_meta["script"] = plan["script"]
14383 stage_meta["submitted"] = True
14384 if dependency:
14385 stage_meta["dependency"] = f"afterok:{dependency}"
14386 else:
14387 stage_meta.pop("dependency", None)
14388
14389 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
14390 print(f"[SUCCESS] Submitted {plan['stage']} job: {submit_info['job_id']}")
14391
14392 if plan["stage"] == "solve":
14393 latest_solve_job_id = submit_info["job_id"]
14394
14395 _write_submission_target_metadata(target_context)
14396
14397
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 14398 of file core.py.

14398def submit_staged_local_run(args, target_context: dict, selected_stages: list):
14399 """!
14400 @brief Execute previously staged local run commands from scheduler/submission.json.
14401 @param[in] args Command-line style argument list supplied to the function.
14402 @param[in] target_context Resolved submission target context.
14403 @param[in] selected_stages Ordered stage names selected by the user.
14404 """
14405 if target_context["target_kind"] != "run":
14406 emit_structured_error(
14407 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14408 key="scheduler.launch_mode",
14409 file_path=target_context["submission_path"],
14410 message="Local staged execution is supported for run directories only.",
14411 hint="Use --run-dir for local staged execution.",
14412 )
14413 sys.exit(1)
14414
14415 stage_plans = []
14416 solve_existing_meta = _get_submission_stage_metadata(target_context, "solve")
14417 solve_already_done = bool(solve_existing_meta.get("submitted") or solve_existing_meta.get("executed"))
14418
14419 for stage_name in selected_stages:
14420 existing_meta = _get_submission_stage_metadata(target_context, stage_name)
14421 command = existing_meta.get("command")
14422 if not isinstance(command, list) or not command:
14423 if existing_meta:
14424 hint = "Re-stage the run with picurv run --no-submit before calling picurv submit."
14425 else:
14426 hint = _build_submit_missing_stage_hint(target_context, args.stage, selected_stages)
14427 emit_structured_error(
14428 ERROR_CODE_CFG_MISSING_KEY,
14429 key=f"scheduler.{stage_name}.command",
14430 file_path=target_context["submission_path"],
14431 message=f"Required local command metadata for stage '{stage_name}' is missing.",
14432 hint=hint,
14433 )
14434 sys.exit(1)
14435
14436 if existing_meta.get("submitted") and not args.force:
14437 emit_structured_error(
14438 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14439 key=f"scheduler.{stage_name}.submitted",
14440 file_path=target_context["submission_path"],
14441 message=f"Stage '{stage_name}' is already recorded as submitted.",
14442 hint="Use --force to execute this stage again intentionally.",
14443 )
14444 sys.exit(1)
14445
14446 if stage_name == "post-process" and "solve" not in selected_stages and not args.force and not solve_already_done:
14447 emit_structured_error(
14448 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14449 key="scheduler.post-process.dependency",
14450 file_path=target_context["submission_path"],
14451 message="Post-process local execution requires a recorded completed solve stage when solve is not being executed in the same command.",
14452 hint="Submit --stage solve or --stage all first, or use --force after confirming source data exists.",
14453 )
14454 sys.exit(1)
14455
14456 log_file = existing_meta.get("log_file")
14457 if not isinstance(log_file, str) or not log_file.strip():
14458 log_file = os.path.join("scheduler", f"{os.path.basename(target_context['root_dir'])}_{stage_name}.log")
14459
14460 stage_plans.append(
14461 {
14462 "stage": stage_name,
14463 "command": [str(token) for token in command],
14464 "log_file": log_file,
14465 "existing_meta": existing_meta,
14466 }
14467 )
14468
14469 if args.dry_run:
14470 for plan in stage_plans:
14471 print(f"[DRY-RUN] Would run: {format_command_for_display(plan['command'])}")
14472 print(f"[DRY-RUN] Log file : {plan['log_file']}")
14473 print("[INFO] Dry-run only. No local commands were executed.")
14474 return
14475
14476 monitor_cfg = None
14477 monitor_path = os.path.join(target_context["root_dir"], "config", "monitor.yml")
14478 if os.path.isfile(monitor_path):
14479 monitor_cfg = read_yaml_file(monitor_path)
14480
14481 for plan in stage_plans:
14482 execute_command(plan["command"], target_context["root_dir"], plan["log_file"], monitor_cfg)
14483 stage_meta = copy.deepcopy(plan["existing_meta"])
14484 stage_meta["command"] = plan["command"]
14485 stage_meta["command_string"] = format_command_for_display(plan["command"])
14486 stage_meta["log_file"] = plan["log_file"]
14487 stage_meta["submitted"] = True
14488 stage_meta["executed"] = True
14489 stage_meta["completed_at"] = datetime.now().isoformat()
14490 _set_submission_stage_metadata(target_context, plan["stage"], stage_meta)
14491 print(f"[SUCCESS] Executed local {plan['stage']} stage.")
14492
14493 _write_submission_target_metadata(target_context)
14494
14495
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 14496 of file core.py.

14496def cancel_run_jobs(args):
14497 """!
14498 @brief Cancel Slurm-submitted jobs for an existing run directory.
14499 @param[in] args Command-line style argument list supplied to the function.
14500 """
14501 run_dir = os.path.abspath(args.run_dir)
14502 if not os.path.isdir(run_dir):
14503 emit_structured_error(
14504 ERROR_CODE_CFG_FILE_NOT_FOUND,
14505 key="run_dir",
14506 file_path=run_dir,
14507 message="Run directory not found.",
14508 )
14509 sys.exit(1)
14510
14511 submission_path = os.path.join(run_dir, "scheduler", "submission.json")
14512 submission_meta = _read_json_if_exists(submission_path)
14513 if not isinstance(submission_meta, dict):
14514 emit_structured_error(
14515 ERROR_CODE_CFG_FILE_NOT_FOUND,
14516 key="scheduler.submission",
14517 file_path=submission_path,
14518 message="Run directory does not contain scheduler submission metadata.",
14519 hint="Use a Slurm-submitted run directory with scheduler/submission.json, or cancel the job manually.",
14520 )
14521 sys.exit(1)
14522
14523 launch_mode = str(submission_meta.get("launch_mode", "")).lower()
14524 if launch_mode != "slurm":
14525 emit_structured_error(
14526 ERROR_CODE_CFG_INCONSISTENT_COMBO,
14527 key="scheduler.launch_mode",
14528 file_path=submission_path,
14529 message=f"Run directory launch_mode={launch_mode or 'unknown'} is not Slurm.",
14530 hint="picurv cancel currently supports Slurm-submitted runs only.",
14531 )
14532 sys.exit(1)
14533
14534 stage_order = ["solve", "post-process"]
14535 requested_stage = args.stage
14536 selected_stages = stage_order if requested_stage == "all" else [requested_stage]
14537 recorded_stages = submission_meta.get("stages", {})
14538 if not isinstance(recorded_stages, dict):
14539 recorded_stages = {}
14540
14541 job_to_stages = {}
14542 skipped = []
14543 for stage_name in selected_stages:
14544 stage_meta = recorded_stages.get(stage_name)
14545 if not isinstance(stage_meta, dict):
14546 skipped.append((stage_name, "no stage metadata recorded"))
14547 continue
14548
14549 job_id = str(stage_meta.get("job_id", "")).strip()
14550 if not stage_meta.get("submitted"):
14551 skipped.append((stage_name, "job was generated but not submitted"))
14552 continue
14553 if not job_id:
14554 skipped.append((stage_name, "submitted stage is missing a recorded job id"))
14555 continue
14556
14557 job_to_stages.setdefault(job_id, []).append(stage_name)
14558
14559 if not job_to_stages:
14560 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
14561 print(f"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
14562 for stage_name, reason in skipped:
14563 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
14564 print("[FATAL] No submitted Slurm job IDs were found for the requested stage selection.", file=sys.stderr)
14565 sys.exit(1)
14566
14567 print(f"[INFO] Run directory : {os.path.relpath(run_dir)}")
14568 print(f"[INFO] Submission metadata: {os.path.relpath(submission_path)}")
14569 print(f"[INFO] Requested stages : {', '.join(selected_stages)}")
14570
14571 if skipped:
14572 for stage_name, reason in skipped:
14573 print(f"[INFO] Skipping stage '{stage_name}': {reason}")
14574
14575 graceful = bool(getattr(args, "graceful", False))
14576 failures = []
14577 for job_id, stage_names in job_to_stages.items():
14578 joined_stage_names = ", ".join(stage_names)
14579 use_graceful_signal = graceful and "solve" in stage_names
14580 scancel_cmd = ["scancel"]
14581 if use_graceful_signal:
14582 scancel_cmd.append("--signal=USR1")
14583 scancel_cmd.append(job_id)
14584
14585 if args.dry_run:
14586 print(f"[DRY-RUN] Would run: {' '.join(scancel_cmd)} # stage(s): {joined_stage_names}")
14587 continue
14588
14589 result = subprocess.run(scancel_cmd, text=True, capture_output=True, check=False)
14590 stderr_text = (result.stderr or "").strip()
14591 stdout_text = (result.stdout or "").strip()
14592 if result.returncode == 0:
14593 if use_graceful_signal:
14594 print(
14595 f"[SUCCESS] Requested graceful shutdown for Slurm job {job_id} for stage(s): {joined_stage_names}. "
14596 "Solver jobs trap SIGUSR1 and write the latest safe off-cadence step at the next checkpoint."
14597 )
14598 else:
14599 print(f"[SUCCESS] Canceled Slurm job {job_id} for stage(s): {joined_stage_names}")
14600 continue
14601
14602 detail = stderr_text or stdout_text or "unknown scancel failure"
14603 failures.append((job_id, joined_stage_names, detail, result.returncode))
14604 print(
14605 f"[ERROR] Failed to cancel Slurm job {job_id} for stage(s) {joined_stage_names}: {detail}",
14606 file=sys.stderr,
14607 )
14608
14609 if args.dry_run:
14610 print("[INFO] Dry-run only. No jobs were canceled.")
14611 return
14612
14613 if failures:
14614 sys.exit(1)
14615
14616
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 14617 of file core.py.

14617def init_case(args):
14618 """!
14619 @brief Implements the 'init' command.
14620 @details Creates a new case study directory by copying a template.
14621 Runtime binaries are resolved from the project bin/ directory
14622 via PATH; use 'sync-binaries' to pin specific versions locally.
14623 @param[in] args The command-line arguments parsed by argparse.
14624 """
14625 context = resolve_case_origin_context(source_root_override=getattr(args, "source_root", None))
14626 try:
14627 source_project_root = require_project_root(context["source_project_root"], "init")
14628 template_path = resolve_template_directory(source_project_root, args.template_name)
14629 except ValueError as exc:
14630 print(f"[FATAL] {exc}", file=sys.stderr)
14631 sys.exit(1)
14632
14633 # The destination path is relative to the current working directory.
14634 dest_path = os.path.abspath(os.path.join(os.getcwd(), args.dest_name if args.dest_name else args.template_name))
14635
14636 if os.path.exists(dest_path):
14637 print(f"[FATAL] Destination directory '{dest_path}' already exists.", file=sys.stderr)
14638 sys.exit(1)
14639
14640 print(f"[INFO] Initializing new case '{os.path.basename(dest_path)}' from template '{args.template_name}'...")
14641
14642 shutil.copytree(template_path, dest_path)
14643 print(f"[SUCCESS] Copied template files to: {dest_path}")
14644
14645 copied_runtime_example = os.path.join(dest_path, RUNTIME_EXECUTION_EXAMPLE_FILENAME)
14646 if os.path.isfile(copied_runtime_example):
14647 os.remove(copied_runtime_example)
14648
14649 try:
14650 runtime_result = ensure_case_runtime_execution_config(dest_path, source_project_root, overwrite=True)
14651 print(f"[INFO] Wrote optional runtime launcher config: {os.path.relpath(runtime_result['path'])}")
14652 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
14653 print(" Seeded from repo-local '.picurv-execution.yml'.")
14654 print(" Leave it unchanged for ordinary local runs; edit it only if your site needs custom MPI launcher tokens.")
14655 except Exception as e:
14656 print(f"[ERROR] Failed to write runtime execution config: {e}", file=sys.stderr)
14657
14658 try:
14659 metadata_path, _ = write_case_origin_metadata(
14660 dest_path,
14661 source_project_root,
14662 template_name=args.template_name,
14663 template_managed_files=list_template_relative_files(
14664 template_path,
14665 excluded_rel_paths={RUNTIME_EXECUTION_EXAMPLE_FILENAME},
14666 ),
14667 )
14668 print(f"[INFO] Wrote case origin metadata: {os.path.relpath(metadata_path)}")
14669 except Exception as e:
14670 print(f"[ERROR] Failed to write case origin metadata: {e}", file=sys.stderr)
14671
14672 cluster_profile_candidates = sorted(
14673 {
14674 os.path.basename(path)
14675 for pattern in ("*cluster*.yml", "*cluster*.yaml")
14676 for path in glob.glob(os.path.join(dest_path, pattern))
14677 }
14678 )
14679 if cluster_profile_candidates:
14680 print("[INFO] Cluster profile sample(s) copied with this case:")
14681 for profile_name in cluster_profile_candidates:
14682 print(f" - {profile_name}")
14683 print(" Edit account/partition/module_setup and any batch-specific launcher overrides before using --cluster.")
14684
14685 if getattr(args, "pin_binaries", False):
14686 print("[INFO] Pinning runtime binaries into case directory...")
14687 try:
14688 copied_binaries = sync_case_binaries(dest_path, source_project_root)
14689 for dest_file_path in copied_binaries:
14690 print(f" - Pinned '{os.path.basename(dest_file_path)}'")
14691 print("[SUCCESS] Case directory is ready with pinned binaries.")
14692 print(" These local copies will be used instead of bin/ originals.")
14693 except ValueError as exc:
14694 print(f"[WARNING] {exc}", file=sys.stderr)
14695 print(" No binaries were pinned. Run 'picurv build' first.", file=sys.stderr)
14696 else:
14697 print("[SUCCESS] Case directory is ready.")
14698 print(" Runtime binaries (simulator, postprocessor) are resolved from bin/ automatically.")
14699 print(" To pin specific binary versions, re-run with --pin-binaries or use: picurv sync-binaries")
14700 print(" Ensure 'picurv' is on your PATH (source etc/picurv.sh) to run from any directory.")
14701
14702
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 14703 of file core.py.

14703def sync_case_binaries_command(args):
14704 """!
14705 @brief Refresh case-local executables from the source repository bin directory.
14706 @param[in] args Command-line style argument list supplied to the function.
14707 """
14708 try:
14709 context = resolve_case_origin_context(
14710 case_dir_hint=getattr(args, "case_dir", None),
14711 source_root_override=getattr(args, "source_root", None),
14712 )
14713 source_project_root = require_project_root(context["source_project_root"], "sync-binaries")
14714 case_dir = require_existing_case_dir(context["case_dir"], "sync-binaries", source_project_root)
14715 copied = sync_case_binaries(case_dir, source_project_root)
14716 metadata_path, metadata = write_case_origin_metadata(
14717 case_dir,
14718 source_project_root,
14719 template_name=context.get("template_name"),
14720 existing=context.get("metadata"),
14721 )
14722 except ValueError as exc:
14723 print(f"[FATAL] {exc}", file=sys.stderr)
14724 sys.exit(1)
14725
14726 print(f"[SUCCESS] Refreshed {len(copied)} binaries in: {case_dir}")
14727 for dest_path in copied:
14728 print(f" - {os.path.basename(dest_path)}")
14729 print(f"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
14730 if metadata.get("last_known_source_git_commit"):
14731 print(f"[INFO] Source commit recorded: {metadata['last_known_source_git_commit']}")
14732
14733
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 14734 of file core.py.

14734def sync_case_config_command(args):
14735 """!
14736 @brief Refresh template-managed config/docs files in a case directory.
14737 @param[in] args Command-line style argument list supplied to the function.
14738 """
14739 try:
14740 context = resolve_case_origin_context(
14741 case_dir_hint=getattr(args, "case_dir", None),
14742 source_root_override=getattr(args, "source_root", None),
14743 template_name_override=getattr(args, "template_name", None),
14744 )
14745 source_project_root = require_project_root(context["source_project_root"], "sync-config")
14746 case_dir = require_existing_case_dir(context["case_dir"], "sync-config", source_project_root)
14747 template_name = context.get("template_name")
14748 template_dir = resolve_template_directory(source_project_root, template_name)
14749 existing_managed = context.get("metadata", {}).get("template_managed_files")
14750 if not isinstance(existing_managed, list):
14751 existing_managed = None
14752 summary = sync_case_template_files(
14753 case_dir,
14754 template_dir,
14755 overwrite=getattr(args, "overwrite", False),
14756 prune=getattr(args, "prune", False),
14757 managed_rel_paths=existing_managed,
14758 )
14759 metadata_path, _ = write_case_origin_metadata(
14760 case_dir,
14761 source_project_root,
14762 template_name=template_name,
14763 existing=context.get("metadata"),
14764 template_managed_files=summary["template_managed_files"],
14765 )
14766 runtime_result = ensure_case_runtime_execution_config(case_dir, source_project_root, overwrite=False)
14767 except ValueError as exc:
14768 print(f"[FATAL] {exc}", file=sys.stderr)
14769 sys.exit(1)
14770
14771 print(f"[SUCCESS] Synced template files from '{template_name}' into: {case_dir}")
14772 print(f"[INFO] Copied new files : {len(summary['copied'])}")
14773 print(f"[INFO] Overwritten files : {len(summary['overwritten'])}")
14774 print(f"[INFO] Skipped modified : {len(summary['skipped_modified'])}")
14775 print(f"[INFO] Already unchanged : {len(summary['unchanged'])}")
14776 print(f"[INFO] Pruned stale files : {len(summary['pruned'])}")
14777 if runtime_result["created"]:
14778 print(f"[INFO] Created runtime launcher config: {os.path.relpath(runtime_result['path'])}")
14779 if runtime_result["seed_source"] and os.path.basename(runtime_result["seed_source"]) == RUNTIME_EXECUTION_CONFIG_FILENAME:
14780 print("[INFO] Seed source : repo-local .picurv-execution.yml")
14781 if summary.get("prune_requested_without_tracking"):
14782 print("[WARNING] Prune tracking unavailable for this case; no removed template files were deleted.", file=sys.stderr)
14783 print(f"[INFO] Case origin metadata refreshed: {os.path.relpath(metadata_path)}")
14784
14785
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 14786 of file core.py.

14786def pull_source_repo(args):
14787 """!
14788 @brief Refresh source branches in the repository resolved from a case directory.
14789 @param[in] args Command-line style argument list supplied to the function.
14790 """
14791 try:
14792 context = resolve_case_origin_context(
14793 case_dir_hint=getattr(args, "case_dir", None),
14794 source_root_override=getattr(args, "source_root", None),
14795 )
14796 source_project_root = require_project_root(context["source_project_root"], "pull-source")
14797 except ValueError as exc:
14798 print(f"[FATAL] {exc}", file=sys.stderr)
14799 sys.exit(1)
14800
14801 rebase = not getattr(args, "no_rebase", False)
14802 remote = getattr(args, "remote", None)
14803 branch = getattr(args, "branch", None)
14804 current_branch_only = (
14805 getattr(args, "current_branch_only", False)
14806 or remote is not None
14807 or branch is not None
14808 )
14809
14810 if not current_branch_only:
14811 pull_all_source_branches(source_project_root, "pull-source.log", rebase=rebase)
14812 return
14813
14814 command = ["git", "pull"]
14815 if rebase:
14816 command.append("--rebase")
14817 if remote:
14818 command.append(remote)
14819 if branch:
14820 command.append(branch)
14821 elif branch:
14822 command.extend(["origin", branch])
14823
14824 execute_command(command, source_project_root, "pull-source.log", {})
14825
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 14826 of file core.py.

14826def build_project(args):
14827 """!
14828 @brief Implements the 'build' command.
14829 @details Executes the top-level Makefile directly, passing through any
14830 additional arguments to `make`. This allows for building,
14831 cleaning, and other Makefile targets via the orchestrator
14832 without maintaining a separate build wrapper script.
14833 @param[in] args The command-line arguments parsed by argparse.
14834 """
14835
14836 print("\n" + "="*27 + " BUILD STAGE " + "="*27)
14837 try:
14838 context = resolve_case_origin_context(
14839 case_dir_hint=getattr(args, "case_dir", None),
14840 source_root_override=getattr(args, "source_root", None),
14841 )
14842 source_project_root = require_project_root(context["source_project_root"], "build")
14843 except ValueError as exc:
14844 print(f"[FATAL] {exc}", file=sys.stderr)
14845 sys.exit(1)
14846
14847 makefile_path = os.path.join(source_project_root, "Makefile")
14848
14849 if not os.path.isfile(makefile_path):
14850 print(f"[FATAL] Makefile not found at expected location: {makefile_path}", file=sys.stderr)
14851 print(" Please ensure the project root contains a valid Makefile.", file=sys.stderr)
14852 sys.exit(1)
14853
14854 make_args = list(args.make_args or [])
14855 if make_args_include_explicit_goal(make_args):
14856 command = ["make"] + make_args
14857 else:
14858 command = ["make", "all"] + make_args
14859 print("[INFO] No explicit make target supplied; defaulting to 'all'.")
14860 print(" Use 'picurv build clean-project ...' or another target when you want a non-build make action.")
14861 # For the build process, we don't have a monitor.yml, so we pass an empty
14862 # dict to execute_command. The command should be run in the project root.
14863 execute_command(command, source_project_root, "build.log", {})
14864
14865
14866
14867
14868# ==============================================================================
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 3216 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 3803 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 3813 of file core.py.

◆ BC_HANDLER_SPECS

dict picurv_cli.core.BC_HANDLER_SPECS

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

◆ _BOOL_BC_PARAMS

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

Definition at line 3861 of file core.py.

◆ _CASE_SCHEMA

dict picurv_cli.core._CASE_SCHEMA
protected

Definition at line 4382 of file core.py.

◆ _SOLVER_SCHEMA

dict picurv_cli.core._SOLVER_SCHEMA
protected

Definition at line 4434 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",): {"momentum", "poisson", "petsc_passthrough_options"},
20 ("solver_monitoring", "momentum"): {
21 "newton_krylov_history", "snes_monitor", "snes_converged_reason",
22 "ksp_monitor", "ksp_converged_reason",
23 },
24 ("solver_monitoring", "poisson"): {"pic_true_residual", "true_residual", "converged_reason", "view"},
25 ("solver_monitoring", "petsc_passthrough_options"): None,
26}

Definition at line 4519 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 4547 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 4571 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 4586 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 6451 of file core.py.

◆ PROGRAMMATIC_IC_GEN_GRID_KEYS

tuple picurv_cli.core.PROGRAMMATIC_IC_GEN_GRID_KEYS
Initial value:
1= (
2 "im", "jm", "km",
3 "xMins", "xMaxs", "yMins", "yMaxs", "zMins", "zMaxs",
4 "rxs", "rys", "rzs",
5)

Definition at line 6859 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 6979 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 7949 of file core.py.

◆ SOLVER_MONITORING_MOMENTUM_FLAG_MAP

dict picurv_cli.core.SOLVER_MONITORING_MOMENTUM_FLAG_MAP
Initial value:
1= {
2 "newton_krylov_history": "-mom_nk_pic_monitor",
3 "snes_monitor": "-mom_nk_snes_monitor",
4 "snes_converged_reason": "-mom_nk_snes_converged_reason",
5 "ksp_monitor": "-mom_nk_ksp_monitor",
6 "ksp_converged_reason": "-mom_nk_ksp_converged_reason",
7}

Definition at line 7956 of file core.py.

◆ _SUMMARY_NUMERIC_RE

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

Definition at line 11574 of file core.py.

◆ _CONFIG_SUMMARY_WIDTH

int picurv_cli.core._CONFIG_SUMMARY_WIDTH = 78
protected

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