Skip to content

Log state coordinates

Available in OP System 0.9.0. This feature applies to deterministic expression and transition RHS evaluation, including supported named fields and nonlocal expression reductions.

Declare state_metadata: {x: {scale: log}} to request z = log(x) for every expanded component of state base x. Other states use identity coordinates. Model expressions, initial physical values and observable expressions continue to describe physical quantities. Build an explicit model.coordinate_view() once, outside the solver callback. The original compiled model remains in its natural coordinates.

StateDeclaration.scale defaults to identity. The record now has five fields; existing four-argument construction remains valid, but code unpacking four tuple items needs to use named attributes or include the new field.

import numpy as np
from op_system import compile_spec

model = compile_spec({
    "kind": "expr",
    "state": ["x", "collected"],
    "state_metadata": {"x": {"scale": "log"}},
    "equations": {"x": "-rate*x", "collected": "rate*x"},
    "observables": {"total": {"expr": "x+collected"}},
})
view = model.coordinate_view()
z0 = view.encode(np.array([1.0, 0.0]))
rhs = view.bind({"rate": 2.0})
np.testing.assert_allclose(rhs(0, z0), [-2.0, 2.0])
np.testing.assert_allclose(view.observables["total"].eval_fn(0, z0), 1.0)

The flat callback takes (n_state,) arrays. Adapt that layout to the solver's state layout explicitly. The flepimop2 providers do not automatically select the coordinate view from metadata. Decode results before reporting physical trajectories or applying output-state diagnostics. For an (output_time, state, ...) array use view.decode_native(trajectory, state_axis=1) and inspect its valid flag. Initial-state expressions and provider initialization remain in natural units; encode the evaluated initial state explicitly. The view exposes shaped pytree_eval_fn and independent block_pytree_eval_fn when the source model supports them. Its PyTree encoders accept complete state dictionaries, including buffers with an independent block axis already stripped.

OP Engine core example

OP Engine's ModelCore retains a subgroup axis, including for this single-group example. With OP System 0.9.0 and OP Engine 0.8.0, the following adapter connects the flat callback above to its deterministic CoreSolver interface:

from op_engine import CoreSolver, ModelCore

core = ModelCore(n_states=2, n_subgroups=1, time_grid=np.linspace(0.0, 1.0, 21))
core.set_initial_state(z0[:, None])


def engine_rhs(time, state):
    return rhs(time, state[:, 0])[:, None]


CoreSolver(core).run(engine_rhs)
decoded = view.decode_native(core.state_array, state_axis=1)
assert bool(decoded.valid)
assert decoded.state.shape == (21, 2, 1)
np.testing.assert_allclose(decoded.state[-1, 0, 0], np.exp(-2.0))

The callback removes the single subgroup axis before evaluating the compiled RHS and restores it on the derivative. This is an adapter for the example's layout; do not discard axes from a different model without matching its compiled state order and template shapes. The known exponential remaining-state value checks the coordinate/solver connection, not the accuracy of the collected state or its numerical total.

Equation and domain

For a selected component, the view compiles F_i(x)/x_i and evaluates it at x = exp(z). It cancels syntactically exact multiplicative factors before division where supported. For dx/dt = -rate*x, this gives dz/dt = -rate even if exp(z) underflows. This simplification is not a general symbolic limit calculation: an additive source b-rate*x requires b/x-rate and can overflow when x is extremely small. Aliases, total-state helpers, time tables and observables receive decoded physical state.

view.encode rejects nonfinite state values and zero or negative selected components before evaluating logarithms. There is no implicit floor or clamp. Select log coordinates only for states whose intended deterministic trajectory stays strictly positive; a cumulative counter starting at zero usually needs identity coordinates. The metadata is a coordinate request, not a proof that the equations preserve positivity.

NumPy, JAX and CPU Torch transforms retain the source namespace and floating dtype. Use encode_native, decode_native, encode_pytree_native and decode_pytree_native inside JIT, vmap or automatic differentiation. They return CoordinateResult(state, valid) without extracting host scalars. Invalid native inputs can produce nonfinite outputs. Decoding can underflow to zero or overflow; the scalar native flag reports either failure, and decode_checked checks it eagerly. decode is the unchecked native value operation used in callbacks. Flags describe domain and representability, not solver accuracy. Signed identity states are masked before logarithms/exponentials so inactive branches do not contaminate derivatives.

Numerical behavior and exported structure

The checked example uses a deliberately large Euler step for dx/dt = -2*x, d(collected)/dt = 2*x, starting at (1, 0). Its independently known solution at time one gives the following values.

Quantity at time one Direct Euler Log Euler, decoded Exact
Remaining x -1.000000 0.135335 0.135335
Collected 2.000000 2.000000 0.864665
Total 1.000000 2.135335 1.000000

Log Euler is exact for this particular remaining-state equation. The collected state is inaccurate, and the decoded discrete total is not conserved. Smaller steps, error controls and independent solution checks still matter. A change of coordinates also changes the meaning of a solver's error norm and tolerances.

Natural-scale reaction balance laws remain applicable to the continuous physical equations after decoding. Their original linear coefficients do not become linear laws of log coordinates. The view therefore does not export reaction artifacts, balances or the source model's derivative and affinity metadata as coordinate-space claims. Obtain coordinate Jacobians by differentiating the view evaluator; nonlinear changes can alter sparsity and affine dependence. The retained view.natural model still exports its original artifacts. Stochastic SSA consumes those original physical states and integer jumps; the log view transforms deterministic drift only.

Separate operator descriptors and history callbacks require a transformed composition contract and currently reject log-view creation. This restriction does not exclude named-field or nonlocal dynamics already represented in the instantaneous expression RHS.

For gauge-fixed models, first encode original parameters and initial physical state with model.gauge_fix.encode. Then log-encode that representative with the coordinate view. To recover original units, decode log coordinates first and then apply the retained gauge inverse. Gauge anchors stay bound across coordinate RHS and observable callbacks.