chc.delay¶
Stability tier: evolving.
Delayed dynamics as an ordinary chc vector field: the linear-chain delay line.
A discrete delay x(t - tau) is not a finite-dimensional vector field -- the history is a
function, so a delay differential equation lives in an infinite-dimensional state space and cannot
be a Dynamics. The m-stage linear chain is one:
``x' = f(t, x, b_m, u)``, ``b_1' = (x - b_1) m/tau``, ``b_i' = (b_{i-1} - b_i) m/tau``
Each stage is a first-order lag of time constant tau/m; equivalently the buffer is a first-order
upwind discretisation of transport along the delay line. The point of paying that price is that the
result is an ordinary vector field on z = [x, b_1, ..., b_m], so the entire rest of the
library -- rollout, control_gradient_adjoint, projected_gradient_control,
pessimistic_control, causal_plan, certify_safety, mpc_control -- runs on a delayed
plant with no change at all. A method-of-steps DDE solver would have needed every one of them
rebuilt against it, and diffrax (already a dependency) does not solve DDEs.
What the approximation actually costs, derived rather than asserted (validation/
delay_chain.mac; the chain's transfer function (1 + s tau/m)^-m is the Laplace transform of
its kernel, so the moments come straight off it):
- The kernel is Erlang
(m, m/tau): mass 1 and mean exactlytau, but variancetau^2/m. The applied delay is smeared, with relative spread1/sqrt(m)-- 10% atm = 100. - On the scalar loop
x' = -a x(t - tau)the chain's Hopf boundary isa tau = m tan(pi/2m) sec^m(pi/2m), exactly (characteristic residual 0 in Maxima), which tends to the true discrete-delay valuepi/2from above, with relative excesspi^2/(8m).
The direction of that second error is the one that matters and is why the margin is not read off
this object: the chain is optimistic about stability. It is the right tool for simulating a
delayed plant, and the wrong tool for certifying one -- chc.delay supplies the plant, and the
delay margin is computed from the exact discrete-delay characteristic equation.
The two errors also converge at different speeds -- O(1/m) for the margin against
O(1/sqrt m) for the kernel shape -- so a stability question needs far fewer stages than a
reproduce-the-waveform question. Use stages_for_spread to size the second.
Stages are not free to add, and the binding constraint is not the one the eigenvalues suggest.
The buffer block is defective -- a single Jordan block with the one eigenvalue -m/tau repeated
m times -- so its spectrum says almost nothing about what an explicit integrator does to it.
Read it as advection instead: the symbol of first-order upwind covers a disc of radius m/tau
centred at -m/tau, so it reaches -2m/tau, twice as far out as the eigenvalue, and the
constraint is a CFL number m dt/tau <= 1.3926 rather than the RK4 real-axis limit
2.7853. Using the eigenvalue would permit exactly twice as many stages as are safe. Measured:
at tau/dt = 50 the buffer is bounded through m = 75 and reaches 2.4e1 at m = 80 and
8.8e30 at m = 100 -- silent, then catastrophic, which is why max_stages is derived
rather than tuned into.
DelayedDynamics
¶
Bases: Module
x' = core(t, x, x(t - tau), u) presented as a plain Dynamics.
The augmented state is z = [x, b_1, ..., b_stages] of width state_dim * (stages + 1);
b_stages is the delayed signal the core sees. Build the initial z with
augment_state (constant history, the usual DDE initial condition) and read results back
with state_of / state_trajectory.
Only the state is delayed. A delayed control is the same object with the control appended to
the delayed signal, and is deliberately not special-cased: in an MPC loop the control history is
already known to the caller, so delaying it is a shift of us, not a change of plant.
DelayMarginCertificate
dataclass
¶
Evidence that the delay margin is where the closed form says it is, on both sides.
DelayBall
dataclass
¶
The set of delay estimates that still stabilise the true plant. It is a half-line.
ratio_floor is the smallest tau_hat/tau that stabilises; there is no ceiling, so the
field is named for what it is rather than paired with an absent ratio_ceiling. In absolute
terms the admissible estimates are (shortest_safe_estimate, inf).
Contrast the ball in dynamics error that Result 44 gives, which is symmetric and pairs with a regret quadratic in its radius. Neither property survives the move to delay space, and both fail for the same reason: the decay-optimal design sits at a defective characteristic root.
RobustDelayDesign
dataclass
¶
The delay to design for, given only an interval containing the true one.
DelayBallCertificate
dataclass
¶
Evidence that the delay ball's floor is where the derivation says, and that the loss is too.
largest_unstable and smallest_stable are multiples of the derived floor, so they bracket
where the loop actually changes behaviour: the derivation is confirmed when the bracket
straddles 1. Result 44's ball came from a Lyapunov sufficient condition and ran 15.5x
conservative; this one comes from the exact characteristic equation, so the bracket is tight to
the sweep grid and there is no slack to report.
max_stages(tau, dt)
¶
Largest chain length RK4 can integrate at step dt -- a CFL cap, not a spectral one.
m dt/tau <= 1.3926, from the upwind symbol rather than from the buffer's (defective)
eigenvalue; see the module docstring for why the two differ by a factor of two.
Deliberately the conservative side of the observed cliff: this is the von Neumann bound for the
periodic problem, and a finite chain with an inflow boundary tolerates a little more (onset
measured near 1.50 at 4000 steps, against the 1.3926 returned here). Conservative is the
right direction when the failure mode is a buffer that looks fine for hundreds of steps and then
reaches 1e30.
stages_for_spread(relative_spread)
¶
Chain length whose applied delay has standard deviation relative_spread * tau.
Inverts the Erlang variance tau^2/m. Sizing by the kernel is the conservative choice: the
stability boundary converges as 1/m and so is already far more accurate at the same m.
augment_state(x0, stages)
¶
Initial augmented state for a constant history x(s) = x0 for all s <= 0.
The constant history is the standard DDE initial condition and the only one a caller holding a
single x0 can honestly supply. Pass a non-constant history by building z directly:
jnp.concatenate([x0, *history]) with the oldest slot last.
state_of(z, state_dim)
¶
The undelayed state x out of an augmented z.
delayed_of(z, state_dim)
¶
The delayed signal x(t - tau) out of an augmented z -- the last buffer slot.
state_trajectory(zs, state_dim)
¶
The x columns of an augmented rollout (H + 1, state_dim * (stages + 1)).
lift_cost(cost, stages)
¶
Embed a cost on x into one on the augmented z, charging nothing for the buffer.
Without this the "nothing downstream changes" claim would be hollow: every solver takes a
QuadraticCost whose Q is sized to the state it is given. The buffer
carries no cost because it is bookkeeping, not a physical coordinate -- penalising it would
penalise the plant's own history.
exact_delayed_rollout(core, x0, us, dt, lag, t0=0.0)
¶
Reference rollout with an exact integer lag, by carrying the history explicitly.
This is the oracle the chain is priced against, not a shipping path: it is exact for a delay of
lag * dt under explicit Euler, but it is a discrete map rather than a vector field, so
nothing else in the library can consume it. Used by the tests to measure what
DelayedDynamics costs, and by callers who want the discrete-delay ground truth.
The history before t0 is constant at x0, matching augment_state.
delay_margin(pole, gain)
¶
Largest measurement delay the loop x' = pole*x - gain*x(t - tau) tolerates.
tau_c = arccos(pole/gain) / sqrt(gain^2 - pole^2), from the imaginary-axis crossing of
lambda - pole + gain exp(-lambda tau) (validation/delay_margin.mac; both real and
imaginary residuals are exactly 0). At pole = 0 this is pi/(2 gain), the textbook
K tau = pi/2.
Computed from the exact characteristic equation, deliberately not from the delay line of
DelayedDynamics: the chain's own boundary sits above this one by pi^2/(8m),
so reading a margin off the simulator would call a loop safe past the point where it is not.
gain <= pole raises: with pole > 0 a gain that small does not stabilise the loop even
at zero delay, so "the largest tolerable delay" names nothing. The formula also carries its own
fundamental limit -- tau_c -> 1/pole as gain -> pole+, and it decreases in gain
from there, so an unstable pole admits no gain past tau = 1/pole.
delay_margin_certificate(pole=0.0, gain=1.0, dt=0.002, ratios=(0.5, 0.8, 0.95, 1.05, 1.3, 2.0), horizon=400.0)
¶
Sweep tau across delay_margin and check the loop really changes behaviour there.
Simulated with exact_delayed_rollout rather than the chain, for two reasons: the lag is
exact at tau/dt steps, and explicit Euler errs conservative -- its own boundary sits
1/(2m) below the continuous one, against the chain's +pi^2/(8m) above. A
conservative simulator that still shows instability past tau_c is evidence; an optimistic
one showing stability just inside it would not be.
That leaves a band of width ~1/(2m) around tau_c where the discretisation, not the
plant, decides the verdict, so ratios deliberately steps over it rather than into it.
optimal_delay_gain(tau)
¶
Gain maximising the decay rate of x' = -K x(t - tau): K* = 1/(e tau).
The decay rate it achieves is sigma* = 1/tau, and no gain does better.
At this gain the characteristic root s = -1 (in s = lambda tau) is double. That is
the source of every asymmetry in delay_ball and delay_design_loss: perturbing a
parameter away from a defective root moves it like a square root, not linearly.
delay_ball(tau)
¶
Which delay estimates keep the true tau-plant stable under the decay-optimal design.
Designing K_hat = 1/(e tau_hat) and running it against the true delay tau puts the loop
gain at kappa = K_hat tau = 1/(e r) with r = tau_hat/tau. The exact boundary is
kappa = pi/2 (delay_margin at a = 0), and kappa is antitone in r, so the
admissible set is r > 2/(pi e) = 0.2342: under-estimating the delay by more than 76% of
it destabilises, and over-estimating never does, at any magnitude.
That asymmetry is not a modelling choice -- it follows from the boundary being an upper bound on
loop gain while the design's gain is inversely proportional to the assumed delay. Its practical
reading is uncomfortable: the safe direction for stability is the expensive one for performance
(see delay_design_loss), so "be conservative about the delay" is not free advice.
No Lyapunov-Krasovskii functional is constructed, deliberately. An LKF would deliver a sufficient condition with an unquantified gap; the characteristic equation gives the exact boundary, so an LKF here would be strictly weaker evidence, not stronger.
delay_design_loss(ratio)
¶
Decay rate given up by designing for tau_hat when the true delay is tau.
ratio is tau_hat/tau; the return is tau * (sigma* - sigma), dimensionless, zero at
ratio = 1 and 1.0 at the stability boundary. Computed from the exact rightmost
characteristic root, not from the expansion -- the expansion is what explains the shape, the
exact root is what a caller should be given.
The two sides are not the same order. Writing eps = (ratio - 1)/ratio, the root's real
part near the optimum is sqrt(2 eps) - 2 eps/3 for an over-estimate and -2 eps/3 for an
under-estimate (validation/delay_ball.mac, matched to the exact root to 1e-3 at
|eps| = 0.05). Over-estimating opens as a square root, under-estimating only linearly,
so at |eps| = 0.05 -- the same absolute error either way -- an over-estimate costs 0.287
against an under-estimate's 0.033, 8.8 times as much. No symmetric radius can describe both,
which is why DelayBall carries a floor and no ceiling and this is not a norm of
eps.
So the two failure directions want opposite things: stability wants tau_hat large, the decay
rate wants it small. robust_delay_design resolves that against an interval.
robust_delay_design(lo, hi)
¶
Minimax delay design over an interval: which tau_hat to build the controller for.
Feed it the ends of a delay interval -- chc.irf.DelayEstimate.lo and .hi are exactly
that -- and it returns the tau_hat minimising the worst-case delay_design_loss over
every true delay the interval allows. Because the loss is unimodal in tau_hat/tau, the worst
case is attained at an end of the interval, and the minimax equalises the two ends.
The answer is not the centre of the interval. Over-estimating costs a square root and
under-estimating only a linear term, so equalising the two ends pushes the design down: on
[0.8, 1.25] the minimax lands at 0.837 against a geometric mean of 1.0, a 16% shift,
and it halves the worst-case loss, 0.528 to 0.270. Designing for the middle of a delay
interval is a symmetric answer to an asymmetric problem.
The shift is downward only while the interval is informative. The ratio depends on hi/lo
alone (the problem is scale-free in tau), it deepens to 0.754 of the geometric mean near
hi/lo = 3.2, then relaxes and crosses back above at hi/lo = 13.25: past there the low
end is close to the stabilising floor, its loss saturates near 1, and it dominates instead. A
13x-wide delay interval is barely information, so the rule holds wherever it is worth applying
-- but it is a regime, not a law, and the crossover is where it ends.
The direction is worth naming because it opposes the safety instinct: a longer assumed delay
buys stability margin (delay_ball), a shorter one buys decay rate. Both are exact, and
only the interval decides the trade.
Do not pass a censored estimate. chc.irf.DelayEstimate.censored means the peak sat on
the edge of the horizon, so hi bounds nothing -- the true delay may be far larger, and that
is the direction delay_ball says is unsafe to under-shoot.
delay_ball_certificate(tau=1.0, dt=0.001, ratios=(0.25, 0.6, 0.9, 1.1, 1.6, 3.0, 6.0), loss_ratios=(0.8, 0.9, 0.95, 1.05, 1.1, 1.25), horizon=60.0)
¶
Sweep the delay estimate across delay_ball's floor and check both halves of it.
ratios are multiples of the floor 2/(pi e), so they run from a quarter of it to six
times; loss_ratios are values of tau_hat/tau near 1, where the derived
delay_design_loss is checked against the decay rate the simulation actually shows.
Simulated with exact_delayed_rollout for the same reason as
delay_margin_certificate: explicit Euler's own boundary sits below the continuous one,
so instability it reports past the floor is evidence rather than discretisation.
The certificate can fail in four independent ways: no ratio destabilises, no ratio stabilises, the floor does not separate them, or the measured loss departs from the closed form by more than the discretisation can account for.