Skip to content

Named latent observables

Optional observables define instantaneous quantities separately from the RHS. They work with expression and transition models. CompiledRhs.observables is a read-only mapping of names to CompiledObservable records. Each contains a definition, concrete output shape and coordinate labels, direct state and parameter dependencies, explicit time use, applied physical measures, and native flat/PyTree evaluators. Records and evaluator protocols are also exported from op_system.observables and the package root.

kind: expr
axes:
  - name: x
    type: continuous
    coords: [0.05, 0.25, 0.7]
    measure: {kind: cell, weights: [0.1, 0.3, 0.6], units: m}
state: ["rho[x]", "mass[x]", counter]
equations:
  rho[x]: "0"
  mass[x]: "0"
  counter: "-k*counter"
state_metadata:
  rho: {representation: density, units: items/m, measure_axes: [x]}
  mass: {representation: cell_mass, measure_axes: []}
  counter: {representation: cumulative}
parameters:
  gain: {support: positive}
observables:
  physical_total:
    expr: "gain*rho[x]"
    reduction: {kind: integral, axes: [x]}
    units: items
  extensive_total:
    expr: "mass[x]"
    reduction: {kind: sum, axes: [x]}
  counter_value:
    expr: counter
  center_sample:
    expr: "rho[0.25]"
  field:
    expr: "gain*rho[x]"
    axes: [x]

The counter equation here illustrates that its representation annotation does not establish monotonicity. Choose the scientific counter dynamics explicitly.

Definitions require expr. Optional axes give output axes after reduction; omission means scalar output. Optional reduction has exactly kind (sum or integral) and a nonempty, unique list of axes. Output and reduction axes must be disjoint and declared by the model. units and description are optional nonempty strings. Unknown fields, unresolved axes/coordinates and incompatible parameter layouts fail before returning a compiled artifact. An empty mapping or omitted observables leaves the existing RHS interface unchanged.

The initial pointwise subset is +, -, *, /, **, unary +/-, and unary np.exp. State arrays require named subscripts, such as rho[x]; scalar states use bare names. A literal coordinate selects an existing declared node, such as rho[0.25]. Named categorical selection also works, such as rho[region:left,x]. This selection does not interpolate. Bare nonstate symbols are inferred as scalar parameters; subscripted nonstate symbols define shaped parameter buffers. Shaped parameters use complete arrays under their base names and must have exactly their exported shapes. Expanded scalar parameter keywords are not an observation-buffer interface.

sum applies unit weights even on continuous axes. integral uses only the explicit axis measure from state representations, once per reduced axis. Intensive state dependencies must declare density or concentration and measure_axes; extensive counts, cell masses and cumulative counters reject physical weighting. Partial spatial integration retains output axes while applying only the requested measures. Multiple physical axes use separable products of the declared weights. Extra noncounting measure axes must be required by at least one referenced intensive state. Parameter-only constant integrands are allowed with explicitly declared measures.

These checks distinguish known extensive states and physical measures. They do not establish dimensions, geometry, or the units of arbitrary products and parameter fields; supplied output units remain annotations. Legacy equation reducers and their continuous-axis deltas are unchanged. Nested reducers, aliases, observer-to-observer references, history, time-varying parameter buffers, partial/duplicate state-base layouts, and other pointwise functions are outside this first subset and fail explicitly.

For the example, rho(x)=1+2*x has exact cell-average values [1.1,1.5,2.4]. Declared widths give integral 2, while the unweighted density sum is 5. The extensive masses [0.11,0.45,1.44] sum directly to 2.

import numpy as np
from op_system import compile_spec

compiled = compile_spec({
    "state": ["x"],
    "equations": {"x": "-k*x"},
    "parameters": {"gain": {"support": "positive"}},
    "observables": {"scaled": {"expr": "gain*x+t", "units": "declared-output"}},
})
y = np.array([2.0])
rhs_value = compiled.eval_fn(3.0, y, k=0.5)  # gain is not an RHS requirement
observable = compiled.observables["scaled"]
observed = observable.eval_fn(3.0, y, gain=4.0)  # scalar native value: 11
assert np.asarray(observed).shape == observable.output_shape == ()
assert observable.parameter_shapes == (("gain", ()),)

Observable-only parameter declarations are accepted in parameters, without adding them to the RHS param_names or equation binding requirements. validate_spec's consumed parameter report continues to describe dynamics; observable requirements are exported independently in parameter_shapes. State/parameter dependencies describe the instantaneous expression, not all indirect solution sensitivities. time_dependent specifically indicates an explicit t reference; a state-dependent observable can evolve in time even without one.

eval_fn(t,y,**params) consumes the complete flat (len(state_names),) state. pytree_eval_fn consumes all state bases with their natural shapes, including zero-dimensional scalar arrays. States must share a namespace and real floating dtype. Array parameters must use that namespace; host scalar/list constants are converted and native arrays are cast without detaching derivatives. Results use the state dtype and declared shape. Evaluators perform functional operations and may return views, so treat inputs and outputs as immutable. Trusted model pickle round trips rebuild these evaluators with their detached definitions.

The required qualification runner includes independent nonuniform-grid values, axis reordering, shaped parameters, counters and declared-node samples. NumPy checks analytic values; JAX checks float64 JIT/vmap and state/parameter/time derivatives; CPU Torch checks those derivatives in float64 and float32. Gradient checks use the initial arithmetic/exp subset and static measures. They do not qualify mesh-coordinate/measure derivatives, Torch compiler transforms, GPUs, discontinuities or generic future operations.

Numerical interpolation, time windows and integrals, trajectory storage and mesh transfer belong to consuming engines. Noise, censoring and missingness belong to statistical measurement models. Global observations stay on the full compiled model even when dynamics have a per-block evaluator; summing independent dynamic blocks can still couple a statistical objective.

Point quadrature integrates point samples over their coordinate span, whereas cell measures apply the discretization owner's widths/volumes to cell averages. For the point-rule reference see SciPy trapezoid. The compiler evaluates the supplied measures with native products and reductions so it does not introduce a SciPy runtime or assume a geometry from cell centers.