Source code for gkx.workflows.runtime.artifacts

"""Runtime artifact writers, display, restart, and checkpoint handoff policies."""

from __future__ import annotations

from dataclasses import dataclass, replace
from pathlib import Path
import time
from typing import Any, Callable, Mapping, Sequence

import numpy as np

from gkx.artifacts.io import (
    _condense_diagnostics_for_netcdf_output,
    _is_netcdf_output_target,
    _netcdf_bundle_base,
    _nonlinear_summary,
    _resolve_restart_path,
    _write_json,
    load_nonlinear_netcdf_diagnostics,
    validate_finite_runtime_result,
    write_quasilinear_artifacts as write_quasilinear_artifacts,
    write_runtime_linear_artifacts as write_runtime_linear_artifacts,
    write_runtime_linear_scan_artifacts as write_runtime_linear_scan_artifacts,
    write_runtime_nonlinear_table_artifacts,
)
from gkx.artifacts.nonlinear_netcdf import _write_nonlinear_netcdf_outputs
from gkx.diagnostics import SimulationDiagnostics
from gkx.workflows.nonlinear import run_runtime_nonlinear
from gkx.workflows.runtime.diagnostic_arrays import concat_runtime_diagnostics
from gkx.workflows.runtime.results import RuntimeNonlinearResult


COMMAND_LINEAR_ARTIFACT_DISPLAY_KEYS = (
    "summary",
    "timeseries",
    "eigenfunction",
    "state",
    "quasilinear_summary",
    "quasilinear_species",
)
COMMAND_SCAN_ARTIFACT_DISPLAY_KEYS = ("summary", "scan", "quasilinear_spectrum")
COMMAND_QUASILINEAR_ARTIFACT_DISPLAY_KEYS = (
    "quasilinear_summary",
    "quasilinear_species",
)
COMMAND_NONLINEAR_ARTIFACT_DISPLAY_KEYS = (
    "summary",
    "diagnostics",
    "state",
    "out",
    "big",
    "restart",
)






[docs] def write_command_outputs( out_path: str | Path | None, payload: Any | None, *, writer: Callable[[str | Path, Any], dict[str, str]], display_keys: Sequence[str], ) -> dict[str, str]: """Write command artifacts when both destination and payload exist.""" if out_path is None or payload is None: return {} paths = writer(out_path, payload) print_saved_paths(paths, display_keys) return paths
[docs] def write_linear_runtime_command_outputs( *, linear_out_path: str | Path | None, quasilinear_out_path: str | Path | None, result: Any, linear_writer: Callable[[str | Path, Any], dict[str, str]], quasilinear_writer: Callable[[str | Path, Any], dict[str, str]], ) -> dict[str, dict[str, str]]: """Write all optional artifacts produced by one linear runtime command.""" linear_paths = write_command_outputs( linear_out_path, result, writer=linear_writer, display_keys=COMMAND_LINEAR_ARTIFACT_DISPLAY_KEYS, ) ql_paths = write_command_outputs( quasilinear_out_path, getattr(result, "quasilinear", None), writer=quasilinear_writer, display_keys=COMMAND_QUASILINEAR_ARTIFACT_DISPLAY_KEYS, ) return {"linear": linear_paths, "quasilinear": ql_paths}
[docs] def write_scan_runtime_command_outputs( out_path: str | Path | None, scan: Any, *, writer: Callable[[str | Path, Any], dict[str, str]], ) -> dict[str, str]: """Write optional artifacts produced by one linear-scan runtime command.""" return write_command_outputs( out_path, scan, writer=writer, display_keys=COMMAND_SCAN_ARTIFACT_DISPLAY_KEYS, )
def _format_saturation_summary(saturation: Mapping[str, Any]) -> str: """Format the measured saturation window for the executable summary.""" if saturation.get("mean") is None: reasons = ",".join(saturation.get("reasons", ())) or "no_window" return f"saturation: no measurable window ({reasons})" label = ( "stopped at saturation" if saturation.get("saturated") else "not saturated by the time horizon" ) return ( f"saturation: {label} " f"window=[{saturation['window_tmin']:.6g},{saturation['window_tmax']:.6g}] " f"heat_flux={saturation['mean']:.6g}+/-{saturation['sem']:.6g} " f"rel_sem={saturation['rel_sem']:.6g} " f"tau_ac={saturation['tau_ac']:.6g}" )
[docs] @dataclass(frozen=True) class NonlinearArtifactPolicy: """Resolved nonlinear artifact/restart policy for a single handoff.""" out_path: Path | None netcdf_output_target: bool diagnostics_on: bool restart_from: Path | None restart_to: Path | None resume_requested: bool remaining_steps: int | None checkpoint_steps: int | None
@dataclass(frozen=True) class _NonlinearArtifactRunOptions: """Nonlinear runtime options forwarded unchanged to each checkpoint chunk.""" ky_target: float kx_target: float | None Nl: int | None Nm: int | None dt: float | None method: str | None sample_stride: int | None diagnostics_stride: int | None laguerre_mode: str | None diagnostics: bool | None show_progress: bool status_callback: Any
[docs] def resolve_nonlinear_artifact_policy( cfg: Any, *, out: str | Path | None, diagnostics: bool | None, steps: int | None, dt: float | None, ) -> NonlinearArtifactPolicy: """Resolve nonlinear output, restart, and checkpoint policy.""" out_path = None if out is None else Path(out) netcdf_output_target = out_path is not None and _is_netcdf_output_target(out_path) diagnostics_on = bool(cfg.time.diagnostics if diagnostics is None else diagnostics) restart_from = None restart_to = None if netcdf_output_target: assert out_path is not None restart_from = _resolve_restart_path(out_path, cfg, for_write=False) restart_to = _resolve_restart_path(out_path, cfg, for_write=True) resume_requested = ( bool(getattr(cfg.output, "restart", False)) or cfg.init.init_file is not None ) if steps is not None: remaining_steps: int | None = int(steps) elif bool(cfg.time.fixed_dt): remaining_steps = int( round(float(cfg.time.t_max) / float(cfg.time.dt if dt is None else dt)) ) else: remaining_steps = None checkpoint_steps: int | None = None if ( netcdf_output_target and remaining_steps is not None and bool(getattr(cfg.output, "save_for_restart", True)) ): if ( getattr(cfg.time, "nstep_restart", None) is not None and int(cfg.time.nstep_restart) > 0 ): checkpoint_steps = int(cfg.time.nstep_restart) elif int(getattr(cfg.output, "nsave", 0)) > 0: checkpoint_steps = int(cfg.output.nsave) return NonlinearArtifactPolicy( out_path=out_path, netcdf_output_target=netcdf_output_target, diagnostics_on=diagnostics_on, restart_from=restart_from, restart_to=restart_to, resume_requested=resume_requested, remaining_steps=remaining_steps, checkpoint_steps=checkpoint_steps, )
def _restart_init_mode(cfg: Any) -> str: return ( "add" if bool(getattr(cfg.output, "restart_with_perturb", False)) else "replace" ) def _apply_restart_input(cfg_run: Any, cfg: Any, restart_from: Path) -> Any: return replace( cfg_run, init=replace( cfg_run.init, init_file=str(restart_from), init_file_scale=float(getattr(cfg.output, "restart_scale", 1.0)), init_file_mode=_restart_init_mode(cfg), ), ) def _resolve_restart_run_config( cfg: Any, policy: NonlinearArtifactPolicy, ) -> tuple[Any, bool]: """Return the run config after restart/resume policy has been applied.""" cfg_run = cfg resume_requested = policy.resume_requested if policy.netcdf_output_target and cfg.init.init_file is None: if ( bool(getattr(cfg.output, "restart_if_exists", False)) and policy.restart_from is not None and policy.restart_from.exists() ): resume_requested = True cfg_run = _apply_restart_input(cfg_run, cfg, policy.restart_from) elif ( bool(getattr(cfg.output, "restart", False)) and policy.restart_from is not None ): if not policy.restart_from.exists(): raise FileNotFoundError( f"restart file not found: {policy.restart_from}" ) cfg_run = _apply_restart_input(cfg_run, cfg, policy.restart_from) elif cfg.init.init_file is not None and bool( getattr(cfg.output, "restart_with_perturb", False) ): cfg_run = replace( cfg_run, init=replace( cfg_run.init, init_file_scale=float(getattr(cfg.output, "restart_scale", 1.0)), init_file_mode="add", ), ) return cfg_run, resume_requested def _load_restart_history( cfg: Any, policy: NonlinearArtifactPolicy, *, resume_requested: bool, ) -> tuple[SimulationDiagnostics | None, bool]: """Load existing nonlinear diagnostic history when append-on-restart applies.""" if not ( policy.netcdf_output_target and resume_requested and bool(getattr(cfg.output, "append_on_restart", True)) ): return None, False assert policy.out_path is not None history_path = Path(f"{_netcdf_bundle_base(policy.out_path)}.out.nc") if not history_path.exists(): return None, False return load_nonlinear_netcdf_diagnostics(history_path), True def _next_runtime_chunk_steps( *, remaining_steps: int | None, checkpoint_steps: int | None, ) -> int | None: """Return the next nonlinear chunk length from restart/checkpoint policy.""" if checkpoint_steps is None: return remaining_steps if remaining_steps is None: return checkpoint_steps return min(int(remaining_steps), checkpoint_steps) def _advance_restart_run_config(cfg: Any, restart_to: Path | None) -> Any: """Return the config for the next checkpoint chunk.""" if restart_to is None: return cfg return replace( cfg, init=replace( cfg.init, init_file=str(restart_to), init_file_scale=1.0, init_file_mode="replace", ), ) def _merge_chunk_diagnostics( result_chunk: RuntimeNonlinearResult, *, cumulative_diag: SimulationDiagnostics | None, time_offset: float, history_from_file: bool, ny_full: int | None = None, ) -> tuple[RuntimeNonlinearResult, SimulationDiagnostics | None, float]: """Merge one chunk's diagnostics with loaded/runtime history.""" if result_chunk.diagnostics is None: return result_chunk, cumulative_diag, time_offset diag_chunk = result_chunk.diagnostics if history_from_file: # ``ny_full`` is the deck's Ny, not the chunk's ky row count: the # loaded history sits on the dealiased block of the two-sided axis, # and a half-spectrum chunk has to be condensed onto the same block. diag_chunk = _condense_diagnostics_for_netcdf_output( diag_chunk, ny_full=ny_full ) if time_offset != 0.0: diag_chunk = replace(diag_chunk, t=np.asarray(diag_chunk.t) + time_offset) cumulative_diag = ( diag_chunk if cumulative_diag is None else concat_runtime_diagnostics([cumulative_diag, diag_chunk]) ) time_offset = ( float(np.asarray(cumulative_diag.t)[-1]) if np.asarray(cumulative_diag.t).size else time_offset ) return ( replace( result_chunk, diagnostics=cumulative_diag, t=np.asarray(cumulative_diag.t), ), cumulative_diag, time_offset, ) def _checkpoint_loop_done( *, policy: NonlinearArtifactPolicy, result_effective: RuntimeNonlinearResult, remaining_steps: int | None, time_offset: float, cfg: Any, ) -> bool: """Return whether checkpointed nonlinear handoff has completed.""" if policy.checkpoint_steps is None: return True if remaining_steps is not None and remaining_steps <= 0: return True return bool( remaining_steps is None and ( result_effective.diagnostics is None or time_offset >= float(cfg.time.t_max) - 1.0e-12 ) ) def _run_runtime_nonlinear_chunk( cfg_run: Any, *, policy: NonlinearArtifactPolicy, options: _NonlinearArtifactRunOptions, chunk_steps: int | None, ) -> RuntimeNonlinearResult: """Run one chunk and include compilation in its measured wall time.""" wall_start = time.perf_counter() result_chunk = run_runtime_nonlinear( cfg_run, ky_target=options.ky_target, kx_target=options.kx_target, Nl=options.Nl, Nm=options.Nm, dt=options.dt, steps=chunk_steps, method=options.method, sample_stride=options.sample_stride, diagnostics_stride=options.diagnostics_stride, laguerre_mode=options.laguerre_mode, diagnostics=options.diagnostics, resolved_diagnostics=bool(cfg_run.output.resolved_diagnostics), return_state=policy.netcdf_output_target, show_progress=options.show_progress, status_callback=options.status_callback, ) wall_seconds = max(time.perf_counter() - wall_start, 0.0) validate_finite_runtime_result(result_chunk, label="nonlinear runtime chunk") return replace(result_chunk, wall_seconds=wall_seconds) def _write_nonlinear_artifacts_if_requested( policy: NonlinearArtifactPolicy, result_effective: RuntimeNonlinearResult, cfg: Any, ) -> dict[str, str]: """Write nonlinear artifacts when an output target was requested.""" if policy.out_path is None: return {} return write_runtime_nonlinear_artifacts(policy.out_path, result_effective, cfg) def _advance_checkpoint_or_stop( cfg: Any, *, policy: NonlinearArtifactPolicy, result_effective: RuntimeNonlinearResult, remaining_steps: int | None, time_offset: float, ) -> tuple[bool, Any]: """Return whether checkpointing is complete and the next run config.""" if policy.checkpoint_steps is None: return True, cfg if _checkpoint_loop_done( policy=policy, result_effective=result_effective, remaining_steps=remaining_steps, time_offset=time_offset, cfg=cfg, ): return True, cfg if policy.restart_to is None: return True, cfg return False, _advance_restart_run_config(cfg, policy.restart_to) def _run_artifact_checkpoint_loop( cfg: Any, cfg_run: Any, *, policy: NonlinearArtifactPolicy, options: _NonlinearArtifactRunOptions, cumulative_diag: SimulationDiagnostics | None, history_from_file: bool, ) -> tuple[RuntimeNonlinearResult, dict[str, str]]: """Run all nonlinear chunks needed by the resolved artifact policy.""" remaining_steps = policy.remaining_steps time_offset = 0.0 if cumulative_diag is not None and np.asarray(cumulative_diag.t).size: time_offset = float(np.asarray(cumulative_diag.t)[-1]) result_final: RuntimeNonlinearResult | None = None paths: dict[str, str] = {} wall_total = 0.0 while True: chunk_steps = _next_runtime_chunk_steps( remaining_steps=remaining_steps, checkpoint_steps=policy.checkpoint_steps, ) result_chunk = _run_runtime_nonlinear_chunk( cfg_run, policy=policy, options=options, chunk_steps=chunk_steps, ) wall_total += float(result_chunk.wall_seconds or 0.0) result_effective, cumulative_diag, time_offset = _merge_chunk_diagnostics( result_chunk, cumulative_diag=cumulative_diag, time_offset=time_offset, history_from_file=history_from_file, ny_full=int(cfg.grid.Ny), ) result_effective = replace(result_effective, wall_seconds=wall_total) result_final = result_effective paths = _write_nonlinear_artifacts_if_requested(policy, result_effective, cfg) if remaining_steps is not None: assert chunk_steps is not None remaining_steps -= int(chunk_steps) stop, cfg_run = _advance_checkpoint_or_stop( cfg, policy=policy, result_effective=result_effective, remaining_steps=remaining_steps, time_offset=time_offset, ) saturation = getattr(result_chunk, "saturation", None) if stop or bool(saturation and saturation.get("saturated")): break if result_final is None: raise RuntimeError("nonlinear runtime produced no result") return result_final, paths
[docs] def run_runtime_nonlinear_with_artifacts( cfg: Any, *, out: str | Path | None, ky_target: float, kx_target: float | None = None, Nl: int | None = None, Nm: int | None = None, dt: float | None = None, steps: int | None = None, method: str | None = None, sample_stride: int | None = None, diagnostics_stride: int | None = None, laguerre_mode: str | None = None, diagnostics: bool | None = None, show_progress: bool = False, status_callback: Any = None, ) -> tuple[RuntimeNonlinearResult, dict[str, str]]: """Run nonlinear runtime chunks and hand results to artifact writers.""" options = _NonlinearArtifactRunOptions( ky_target=ky_target, kx_target=kx_target, Nl=Nl, Nm=Nm, dt=dt, method=method, sample_stride=sample_stride, diagnostics_stride=diagnostics_stride, laguerre_mode=laguerre_mode, diagnostics=diagnostics, show_progress=show_progress, status_callback=status_callback, ) policy = resolve_nonlinear_artifact_policy( cfg, out=out, diagnostics=diagnostics, steps=steps, dt=dt, ) if policy.netcdf_output_target and not policy.diagnostics_on: raise ValueError("NetCDF nonlinear output artifacts require diagnostics output") cfg_run, resume_requested = _resolve_restart_run_config(cfg, policy) cumulative_diag, history_from_file = _load_restart_history( cfg, policy, resume_requested=resume_requested, ) return _run_artifact_checkpoint_loop( cfg, cfg_run, policy=policy, options=options, cumulative_diag=cumulative_diag, history_from_file=history_from_file, )
[docs] def write_runtime_nonlinear_artifacts( out: str | Path, result: Any, cfg: Any | None = None ) -> dict[str, str]: """Write summary/diagnostics/state artifacts for a nonlinear runtime run.""" out_path = Path(out) if _is_netcdf_output_target(out_path): if cfg is None: raise ValueError( "cfg is required to write nonlinear NetCDF output artifacts" ) paths = _write_nonlinear_netcdf_outputs(out_path, result, cfg) # The bundle is the parity format and stores no measured saturation # window, so a NetCDF run used to leave nothing a person -- or a later # ``gkx --plot`` -- could read the window and <Q> +/- SEM back out of. # The same sidecar the prefix form writes costs one file and restores it. summary_path = Path(f"{_netcdf_bundle_base(out_path)}.summary.json") _write_json(summary_path, _nonlinear_summary(result)) paths["summary"] = str(summary_path) return paths return write_runtime_nonlinear_table_artifacts(out_path, result)
__all__ = [ "COMMAND_LINEAR_ARTIFACT_DISPLAY_KEYS", "COMMAND_NONLINEAR_ARTIFACT_DISPLAY_KEYS", "COMMAND_QUASILINEAR_ARTIFACT_DISPLAY_KEYS", "COMMAND_SCAN_ARTIFACT_DISPLAY_KEYS", "NonlinearArtifactPolicy", "print_nonlinear_command_outputs", "print_saved_paths", "resolve_nonlinear_artifact_policy", "run_runtime_nonlinear_with_artifacts", "write_runtime_nonlinear_artifacts", "write_command_outputs", "write_linear_runtime_command_outputs", "write_scan_runtime_command_outputs", ]