Skip to content

Checks at requested output times

RunConfig(state_checks=OutputStateCheckConfig(...)) records nonnegativity, finiteness and optional monotone-counter checks during a deterministic solve or frozen-mesh replay. The checks leave the numerical solution unchanged and are disabled by default. Read the record from solver.last_state_diagnostics; the existing return value of run and replay remains unchanged, including nonlinear diagnostics for SDIRK2.

"""Retain per-member output-state flags without changing numerical paths."""

from __future__ import annotations

import json

import numpy as np

from op_engine import (
    CoreSolver,
    ModelCore,
    OutputStateCheckConfig,
    diagnose_trajectory,
)
from op_engine.core_solver import RunConfig
from op_engine.model_core import ModelCoreOptions


def main() -> None:
    """Check a coarse Euler bank and a decreasing positive cumulative counter.

    Raises:
        RuntimeError: If the requested diagnostics are missing.
    """
    core = ModelCore(
        1,
        2,
        np.asarray([0.0, 1.0, 2.0]),
        options=ModelCoreOptions(store_history=False),
    )
    core.set_initial_state(np.ones((1, 2)))
    solver = CoreSolver(core)
    solver.run(
        lambda _time, state: np.multiply(state, [[-0.1, -2.0]]),
        config=RunConfig(method="euler", state_checks=OutputStateCheckConfig()),
    )
    result = solver.last_state_diagnostics
    if result is None:
        msg = "Output-state checks did not produce a record"
        raise RuntimeError(msg)
    np.testing.assert_allclose(result.min_state, [0.81, -1.0])
    np.testing.assert_array_equal(result.n_negative_outputs, [0, 1])
    np.testing.assert_array_equal(result.valid, [True, False])

    cumulative = diagnose_trajectory(
        np.asarray([[[5.0]], [[4.0]], [[3.0]]]),
        OutputStateCheckConfig(monotone_indices=(0,)),
    )
    np.testing.assert_array_equal(cumulative.n_negative_outputs, [0])
    np.testing.assert_array_equal(cumulative.n_decreasing_intervals, [2])
    print(  # noqa: T201 - command-line example output
        json.dumps(
            {
                "bank_min_state": np.asarray(result.min_state).tolist(),
                "bank_negative_outputs": np.asarray(result.n_negative_outputs).tolist(),
                "bank_output_flags": np.asarray(result.valid).tolist(),
                "cumulative_decreasing_intervals": np.asarray(
                    cumulative.n_decreasing_intervals
                ).tolist(),
            },
            indent=2,
        )
    )


if __name__ == "__main__":
    main()

The example deliberately uses a coarse Euler step. The member with rate 0.1 has output values 1, 0.9, 0.81; the member with rate 2 has 1, -1, 1. The latter is flagged even though its final value is positive. In a separate example, a cumulative counter has values 5, 4, 3: every state is positive, but its two negative increments are flagged.

Scope and interpretation

These checks inspect the requested output grid, including the initial state. They do not inspect internal accepted substeps, rejected attempts or RK stages. For example, the two Euler updates 1 → -1 → 1 would escape a nonnegativity flag if only the first and last values were requested outputs. A successful output record therefore does not prove positivity throughout a solve, numerical accuracy or physical validity. Use a suitable solver, step size or state representation for the model.

nonnegative_indices selects components on the leading state axis; None checks all components. Use a subset when signed states, such as velocities, can legitimately be negative. Checks apply to the coordinates stored by the solver. For log coordinates or other transformed states, decode the stored trajectory before checking physical-state nonnegativity. monotone_indices selects cumulative components whose output increments should be nonnegative. A model's observation transformation may need separate checks; the solver cannot infer which derived observations should be monotone.

All remaining subgroup and field axes are retained in the diagnostic arrays. A simulation bank can therefore keep a flag per member without collapsing failures across the whole bank. Counts flag each output or interval once if any selected component fails; they do not count failing components.

Field Meaning
n_outputs Number of inspected output records, including the initial state
complete Every output on the solve's grid was inspected; false for a partial failed run
min_state Minimum selected state over those outputs; NaNs propagate
n_negative_outputs Output records with any selected state below the negative tolerance
n_nonfinite_outputs Output records with any nonfinite component, including unselected components
min_increment Minimum selected cumulative increment; None when disabled, positive infinity when no interval exists
n_decreasing_intervals Output intervals with a cumulative decrement; None when disabled
valid Native flags for a complete, finite record with no threshold violations on the checked outputs

negative_tolerance and monotone_tolerance are finite, nonnegative absolute thresholds. Equality at minus the tolerance is accepted. They change the flags, not the recorded minima or trajectory; no value is clamped or projected. The increment tolerance is an amount per output interval, not a rate scaled by its duration.

Storage and backends

The eager driver collects running minima and counts, including when ModelCoreOptions(store_history=False) discards output history. Whole-grid scan drivers reduce diagnostics over their output trajectory with native array operations, rather than unrolling a diagnostic loop. NumPy, JAX and CPU PyTorch have independent count checks; JAX JIT/vmap and state gradients are covered. Flags stay in the state's array namespace; the checks do not extract host scalars inside a compiled solve.

diagnose_trajectory(trajectory, config) performs the same output checks on an existing real floating array shaped (output, state, *other_axes), including stored stochastic paths or provider outputs. It checks the supplied records; the standalone function cannot determine whether a larger solve completed. The core option above applies to CoreSolver; provider prepared runners and stochastic solver configurations have their own interfaces.

Retain these records with the simulation results when choosing how to handle flagged draws. Rejecting parameter draws changes the simulated distribution; use a stated acceptance rule consistently for fitting and calibration.