Processes

A process turns a distribution into a time series: a value that evolves over n_steps with serial structure, so that path-dependent quantities built downstream — running totals, drawdowns, worst-of-day maxima, threshold excursions — have non-trivial distributions that independent draws could never produce. Every process is itself a Distribution over whole paths: its log_prob scores an observed (batch, n_steps) path (so it calibrates from data like any distribution), and its sample draws paths by reparameterization (so gradients flow to every parameter).

Autoregressive structure

Autoregressive is the core building block:

\[x_t = \mathrm{mean} + \phi\,(x_{t-1} - \mathrm{mean}) + \varepsilon_t, \qquad \varepsilon_t \sim \text{innovation}.\]

It defines recursion structure only — no probability formula of its own. The per-step density is exactly the innovation Distribution’s log_prob at the affine residual, and sampling is the innovation’s quantile applied to per-step uniforms. Swap the innovation family and the process becomes heavy-tailed, skewed, or bimodal with no other change:

import prophys as prp

# Gaussian innovations: an Ornstein–Uhlenbeck-like mean-reverting series.
price = prp.Autoregressive(init=50.0, mean=50.0, phi=0.9,
                           innovation=prp.Gaussian(0.0, 3.0), n_steps=24)

# A two-regime (bimodal) step distribution — usable as an innovation
# precisely because a Mixture's quantile is derived from its CDF.
regime = prp.Mixture([prp.Gaussian(-4.0, 1.0), prp.Gaussian(4.0, 1.0)],
                     logits=[0.0, 0.0])
jumpy = prp.Autoregressive(0.0, 0.0, 0.7, innovation=regime, n_steps=24)

RandomWalk is the phi = 1 boundary (pure accumulation of innovations), and ornstein_uhlenbeck() is a factory that picks the phi and innovation scale giving the exact discretization of a continuous-time OU process on a regular grid.

Circular state: the geometry of a value space

The autoregressive recursion touches its state’s geometry in exactly two places: it differences the state from a reference (to scale by phi), and it places an updated value back into the state space. On the real line both are ordinary arithmetic; on a circle — a wind or wave direction, a compass heading, a phase, a time-of-day — the difference must be the shortest signed arc and the placement must wrap into range.

A Geometry captures exactly those two operations. LinearGeometry (the default) is the real line; Circular is the circle of a given period. Passing a geometry is all it takes to make the recursion correct for angular state — circular_autoregressive() is the convenience factory:

# Wind direction in degrees, reverting to a prevailing 250°, with a
# bimodal backing/veering step. Reversion follows the short arc across
# the 0/360 seam, and each state stays in [0, 360).
direction = prp.circular_autoregressive(
    init=250.0, mean=250.0, phi=0.85,
    innovation=prp.Mixture([prp.Gaussian(-6.0, 4.0), prp.Gaussian(6.0, 4.0)],
                          logits=[0.0, 0.0]),
    n_steps=24, period=360.0,
)

Without the circular geometry a linear recursion near the seam reverts the long way around (a state at 359° pulled toward a mean of 1° would swing almost all the way round the circle) and reports spurious ramps. The innovation itself always lives in the Euclidean difference space, so any real-valued innovation distribution composes with either geometry.

The same geometry makes step-differencing seam-safe. time_diff() takes an optional geometry, so a directional first difference from 350° to 010° reads as +20, not -340:

from prophys.processes import Circular
shift = prp.time_diff(direction_path, geometry=Circular(360.0))

Coupling processes

JointProcess runs several autoregressive processes with instantaneously coupled innovations — a Gaussian copula on the per-step innovation residuals — so their dependence survives Monte-Carlo marginalization. A wind model, for instance, couples a mean-reverting speed process with a circular direction process so that gusty hours and shifting hours co-occur:

speed = prp.ornstein_uhlenbeck(init=8.0, mean=8.0, theta=0.5, sigma=2.5, n_steps=24)
wind = prp.JointProcess([speed, direction], corr_raw=[0.35])

Because the coupling acts on the (Euclidean) innovations, a circular direction process joins a linear speed process with no special handling. See Example: Runway Crosswind & Wind-Shift Operability for this exact model driving an operability decision.

General recurrences

When the step map is richer than an affine update — a battery state of charge, an inventory, a wear accumulator — Recurrence scans an arbitrary differentiable step function over driver paths, carrying state between steps, still fully reparameterized and differentiable.