Course module · Real-time hybrid simulation

SOL‑OSI: a loop that measures its own distance to instability

A servo-hydraulic control loop is running an experiment at 2048 samples per second. Somewhere inside it, eight times a second, a small algorithm answers one question: how close are we to going unstable — right now? This module derives that algorithm, SOL‑OSI, equation by equation, from the loop you already know to the number it publishes.

The frequency response of the real loop in this codebase (PID × column plant H_dc_dm, 2–100 Hz), drawn in the complex plane. The red × is the critical point −1. SOL‑OSI is, at heart, a live measurement of the gap between this curve and that point.
audience · graduate loop rate · 2048 Hz update rate · 8 / s source · sol_osi.m

01 Setting

The experiment that must not break

In a real-time hybrid simulation (RTHS), half of a structure exists as software and half as hardware. A finite-element model of a building computes, in real time, how a floor should move during an earthquake. A hydraulic actuator then imposes that motion on a physical column specimen in the lab, measures the force the column pushes back with, and feeds that force into the model. Software and steel take turns, thousands of times per second.

What exactly is “hybrid” about it?
A pure simulation trusts a numerical model of the column; a pure shake-table test needs the whole building built. RTHS splits the difference: the hard to model part (a column that may crack, yield, or wear) is physical, and everything else is numerical. The cost is that the two halves are joined by a feedback loop that runs through real actuators with real dynamics — and that loop can go unstable even when both halves are individually fine.

Inside the machinery sits an ordinary servo loop. A controller receives a displacement command and drives the actuator so that the measured displacement tracks it. Two recorded signals are all SOL‑OSI ever sees:

u command (dd) + − e C(s) PID controller H(s) actuator + column y measured (dm) unity feedback
The inner servo loop. SOL-OSI taps two signals: the drive u (called dd in the code) and the measured displacement y (called dm).

Here is the danger. The column specimen changes during the test — that is the point of testing it. Damage shifts its stiffness and damping, which shifts the loop’s dynamics, which erodes its stability margin. If the loop crosses into instability, the actuator oscillates violently and the specimen — possibly months of preparation — is destroyed by the test equipment rather than tested by it.

So we want an online stability indicator (OSI): a number b, recomputed every 0.125 s from u and y alone, that measures how far the loop currently is from instability. Large b: comfortable. Falling b: the loop is drifting toward the edge, and a supervisor should gently turn the experiment down before physics turns it off. SOL‑OSI is the estimator of b; the supervisor is the authority regulator we meet at the end.

The whole contract, in code

Everything this module derives lives in one function with a three-call interface — configure, initialize, then step it forward with 256 fresh samples of each signal (0.125 s at 2048 Hz):

1function [out, state, info] = sol_osi(varargin)
2%SOL_OSI Healthy-reference two-branch NCF-margin estimator.
3%   cfg = sol_osi('config', dt, initial_loop_tf, override)
4%   state = sol_osi('state', cfg)
5%   [b, state, info] = sol_osi(dd, dm, cfg, state)

File: control_and_HS/OSI/sol_osi.m. The returned b is the stability margin; info carries diagnostics we will meet along the way.

02 The model

The loop on paper

Start from what you know. Each block in the diagram is a transfer function: the ratio of output to input in the Laplace domain, G(s) = Y(s)/U(s). Evaluating it on the imaginary axis, s = jω, gives the frequency response — a complex number per frequency whose magnitude is gain and whose angle is phase shift.

The controller in this rig is the classic PID with a filtered derivative, with the exact constants used on the hardware:

C(s) = P + Is + D  N ss + N P = 0.15,  I = 20,  D = 0.0015,  N = 512

The plant H(s) — hydraulic actuator, servo valve, and the column itself, from displacement command to measured displacement — is not written down from first principles. It was identified from measurements on the rig and stored in the codebase as H_dc_dm (the 7k column model). Multiplying the two blocks that the signal passes through gives the open-loop transfer function, the single object the whole module revolves around:

The object of study L0(jω) = C(jω) H(jω)

the loop as designed, while the specimen is still healthy. The subscript 0 marks this as the reference loop — SOL-OSI carries it as prior knowledge.

SOL-OSI never works with a formula for L0. At configuration time it samples the frequency response on a fixed grid: the band 2–100 Hz in steps of 2 Hz — 50 complex numbers. Two sub-bands get special treatment: 58–62 Hz is masked out (mains electrical interference lives there, and a stability estimator should not be fooled by the power grid), and 28–44 Hz is reserved as the residual fit band for Section 07. Below is the actual magnitude data from the rig:

Measured frequency response — this rig
|H| plant |C| PID |L₀| = |C·H|
Magnitudes of H, C and L0 on SOL-OSI’s 2 Hz grid. Shaded strip: the rejected 58–62 Hz mains band. Dotted strip: the 28–44 Hz residual band. Hover for values. Note the resonance near 30 Hz — the column’s dominant mode, where the loop gain peaks.
Where the grid and L₀ are built
62f = (0:cfg.segment_n/2)'*cfg.fs/cfg.segment_n;
63cfg.bin = find(f >= cfg.band_hz(1) & f <= cfg.band_hz(2));
64cfg.freq_hz = f(cfg.bin);
65cfg.keep = ~(cfg.freq_hz >= cfg.line_reject_hz(1) & ...
66    cfg.freq_hz <= cfg.line_reject_hz(2));
72cfg.L0 = squeeze(freqresp(initial_loop_tf, 2*pi*cfg.freq_hz));

Config constants: band_hz = [2 100], line_reject_hz = [58 62], residual_band_hz = [28 44]. The caller passes initial_loop_tf = C*H_dc_dm — see prepare_gpt5_6_runtime.m, line 45.

03 Two lines of algebra

Closing the loop

The loop is closed, so the signals we record are closed-loop signals. Before we can measure anything we need the map from command u to measurement y. Follow the block diagram around once — each line below is one substitution:

  1. The error is what enters the controller: command minus measurement. e = u − y
  2. The measurement is the error pushed through both blocks, C then H — that product is L. y = C H e = L e = L (u − y)
  3. Collect the y terms on the left. (1 + L) y = L u
  4. Divide. The closed-loop transfer function from command to measurement: T = yu = L1 + L
  5. And the error itself obeys a twin formula — divide line 3’s logic into line 1: S = eu = 11 + L ,  S + T = 1

T is the complementary sensitivity — how faithfully y tracks u. S is the sensitivity — how much of the command leaks into the error, and, just as important for us, how much the loop amplifies disturbances injected at the output. Both formulas are evaluated frequency by frequency: plug in the complex number L(jω), get complex T(jω) and S(jω).

Why “sensitivity”? And why do we care about 1 + L?

The name is historical: S measures how sensitive the closed loop is to small changes in the plant. But look at the denominator shared by both formulas: 1 + L(jω). If at some frequency L(jω) = −1, that denominator is zero — infinite amplification. The loop rings at that frequency without any input: instability.

So all questions about stability become questions about how close L(jω) comes to the point −1 in the complex plane. Keep that thought — it is the entire next section.

Check yourself · 1

At 30 Hz the loop gain of this rig peaks. If damage makes the column softer and the loop gain at 30 Hz grows, the denominator 1 + L(jω) there…

1 + L is a complex sum. A bigger |L| moves the point further from the origin, but whether it moves toward or away from −1 depends on its phase. Gain growth at a phase near 180° is exactly the dangerous direction.

You command u at a frequency where |L(jω)| is very large. The tracking T = L/(1+L) is then…

For |L| ≫ 1, L/(1+L) ≈ 1: high loop gain means tight tracking. Correspondingly S = 1/(1+L) ≈ 0 — almost no error. That trade is what the controller gain buys.

04 The central idea

Distance to instability

Section 03 ended with the observation that instability means L(jω) touching −1. The most natural margin, then, is simply a distance in the complex plane, computed at every grid frequency:

d(ω) = 1 + L(jω)

This is a fine start — it is exactly the distance from the Nyquist curve to the critical point, and its reciprocal is the peak of |S|. But it has a flaw for a monitor: it treats all parts of the plane unequally. Out where |L| is huge, the curve can swing by whole units of distance while the loop’s behavior barely changes; near the origin, a small step is a big deal. A margin that will be compared against one fixed threshold for the whole test needs a distance that weighs every neighborhood fairly.

The fix is to measure distance on a normalized version of the plane. Divide out the size of the points involved:

  1. The chordal distance between two complex numbers z₁ and z₂ shrinks each leg by its own magnitude scale: c(z1, z2) = z1 − z2 1 + z121 + z22
  2. Set z₁ = L(jω) and z₂ = −1. The second root becomes √2, a constant: c(L, −1) = 1 + L(jω) 2(1 + L(jω)2)
  3. This is SOL-OSI’s per-frequency margin, capped at its own largest possible value: b(ω) = min( 1 + L 2 (1 + L2),  12 )
Where does the chordal formula come from? (the sphere picture)

Imagine a unit sphere resting on the complex plane and project every point of the plane onto it from the north pole (stereographic projection). Distant points crowd together near the pole; points near the origin map near the south pole almost undistorted. The chordal distance c(z1, z2) is the straight-line chord between the two projected points — which is exactly why enormous |L| swings stop mattering: on the sphere they barely move.

This same metric underlies the ν-gap and the normalized-coprime-factor (NCF) robustness theory of Vinnicombe — the reason the codebase calls b an “NCF margin.” For this module you only need the operational meaning: chordal distance from the loop to −1, so that one threshold is fair at every frequency.

Two consequences of the formula are worth pausing on. First, the cap: when the loop is switched off entirely, L = 0, the distance to −1 is 1/2 ≈ 0.707 — no loop can ever be “safer” than no loop, so b lives in [0, 0.707]. Second, the loop is only as safe as its worst frequency, so the published margin takes the minimum across the grid, skipping the masked mains bins:

The number SOL-OSI publishes b = minω ∈ keep  b(ω)

For this rig’s healthy loop the value is b0 = 0.3447, at the worst bin near the column resonance. Watch it move below.

Everything after this section is bookkeeping in service of one question: what is the actual, current L(jω) to feed into this formula? Before that, get a feel for the geometry. The explorer below applies two knobs to the real loop: a gain a (the “authority” — Section 09 will turn this knob automatically) and a factor ρ standing for damage-induced loop-gain change:

Explore · the chordal margin of the real loop  a·ρ·L₀
margin b0.345
worst bin38 Hz
|1+L| there0.55
Top: the loop’s locus in the complex plane; the dashed segment is the gap between the worst-frequency point and −1. Bottom: the margin b(ω) across the band; its minimum is the published b. Push ρ up and watch the resonance bulge reach toward −1; pull authority a down and watch the whole locus contract toward the origin — margin recovered by turning the loop down.
The margin, in four lines
293function b = chordal(L, cap)
294b = min(real(abs(1+L)./sqrt(2*(1+abs(L).^2))), cap);
295end
297function y = minMasked(x, mask)
298y = min(x(mask));
299end

With cap = 1/sqrt(2) from the config. The healthy reference value is precomputed at line 75: cfg.nominal = min(cfg.nominal_curve.*cfg.keep + cfg.cap*(~cfg.keep)).

Check yourself · 2

Why does SOL-OSI use the chordal distance instead of the plain distance |1 + L|?

Both are computable; the chordal version is |1+L| divided by a size normalization. That normalization is what lets a single threshold (and a single target for the regulator) serve the entire frequency band and the whole range of loop gains.

In the explorer, reducing the authority a always increased the margin b. Why?

a multiplies L. As a → 0 the locus contracts to the origin, where the chordal distance to −1 is the cap 1/√2 ≈ 0.707. That monotone lever is exactly what makes authority a usable safety valve.

05 Measurement

Listening to the loop

The formula for b wants L(jω) — the loop as it is now, damage included. That must come from the two live signals. SOL-OSI processes them in small blocks: every 0.125 s it receives 256 fresh samples and updates a sliding 0.5 s buffer (1024 samples) of each signal. The buffer, not the block, is what gets analyzed — a 0.5 s window is what sets the 2 Hz frequency grid you have already seen (Δf = 1/0.5 s = 2 Hz).

Each analysis starts by tapering the buffer with a Hann window and taking the FFT of both signals:

  1. The window fades the buffer in and out so the FFT does not see false jumps at the buffer edges (spectral leakage): w[n] = 0.5 − 0.5 cos (2πnN − 1) ,  N = 1024
  2. Transform the windowed signals; keep only the 2–100 Hz bins: Uk = FFT{w · u} |k ,  Yk = FFT{w · y} |k
  3. From the pair, form three real-time statistics per bin — two powers and one cross term: pu = Uk2 ,  py = Yk2 ,  cyu = Yk Uk*
What is the cross-spectrum cyu really measuring?

cyu = YU* multiplies the output bin by the conjugate of the input bin. Its magnitude is large only when both signals have energy at that frequency; its angle is the phase of Y relative to U. So it is the raw material of a transfer-function measurement: “how much of Y lines up with U, and at what phase.” Noise in Y that is unrelated to U points in random directions and averages toward zero — which is exactly the property the next steps exploit.

One 0.5 s window is far too noisy to trust. The classical remedy is to average many windows; the real-time remedy is an exponentially weighted moving average (EWMA) — a one-line filter that keeps a running mean which gently forgets the past:

The workhorse update, used seven times in SOL-OSI x ← x + α (xnew − x) ,  α = 1 − e−Δt/τ

Δt = 0.125 s is the update interval; the memory time constant τ sets the personality: small τ reacts fast but stays noisy, large τ is calm but late.

Applied to the three statistics with τ = 0.25 s (so α = 1 − e−0.5 = 0.393), this yields the smoothed auto-spectra and cross-spectrum that every later formula consumes:

Suu ← Suu + α(pu − Suu) Syy ← Syy + α(py − Syy) Syu ← Syu + α(cyu − Syu)
Explore · one knob, one personality
A noisy measurement steps up at t = 1 s (gray dots). The EWMA (cobalt) chases it with time constant τ. SOL-OSI runs several of these at once, each tuned to its job: spectra τ = 0.25 s, the published margin τ = 0.15 s, the damage memory τ = 0.5 s, slow context τ = 2 s, idle forgetting τ = 10 s.

One more scalar is computed per block, and it will gate almost everything downstream. The estimator asks: is there any excitation to learn from at all?

activity = min(1,  (rms(u)0.06)4 )

The fourth power makes this a soft switch: drive comfortably above the quiet threshold 0.06 and activity saturates at 1; drive below it and activity plunges toward 0. When the room is quiet, spectral estimates are meaningless — and SOL-OSI will refuse to update its beliefs from them.

Buffers, FFT, smoothing, activity
121state.u = [state.u(n+1:end); dd];
122state.y = [state.y(n+1:end); dm];
126activity = min(1, (rms(state.u)/cfg.quiet_rms)^4);
141Uall = fft(state.u.*cfg.window);
142Yall = fft(state.y.*cfg.window);
145pu = abs(U).^2;
146py = abs(Y).^2;
147cyu = Y.*conj(U);
160    q = cfg.spectral_alpha;
161    state.Suu = state.Suu+q*(pu-state.Suu);
162    state.Syy = state.Syy+q*(py-state.Syy);
163    state.Syu = state.Syu+q*(cyu-state.Syu);

All the α values are precomputed in config (lines 78–82), e.g. cfg.spectral_alpha = 1-exp(-cfg.step_s/cfg.memory_s). The quiet threshold is cfg.quiet_rms = 0.06.

06 The coherent branch

How much do we trust what we hear?

With smoothed spectra in hand, the closed-loop transfer function has a classical estimator — divide the cross-spectrum by the input power, bin by bin:

T̂k = SyuSuu

(Statisticians call this the H1 estimator; it is the least-squares fit of Y on U.) The trouble is that T̂ is only as good as the data behind it. At a frequency where the drive has no energy, or where the output is mostly noise, T̂ is garbage — and one garbage bin, pushed through the min in Section 04, could fake an emergency. SOL-OSI therefore scores every bin before using it.

Score 1 — coherence: does y actually follow u?

γ2k = Syu2 Suu Syy  ∈ [0, 1]
Why does γ² measure trust?

Coherence is the frequency-domain cousin of a squared correlation coefficient. If y at bin k is purely a filtered copy of u, the cross terms line up perfectly and γ² = 1. If y at that bin is dominated by something else — sensor noise, mains hum, the specimen creaking on its own — the cross-spectrum averages toward zero while the auto-spectra stay large, and γ² falls toward 0. It is a per-frequency answer to “is this bin of T̂ a measurement or a hallucination?”

Score 2 — leverage: was there drive there at all?

prel = Suumaxk Suu ,  leverage = prelprel + 10−3

Bins more than 30 dB below the strongest bin (the power_floor) earn leverage near 0. Combining both scores with the block-level activity gate gives each bin its confidence:

confidencek = activity · γ2k · leveragek  ∈ [0, 1]

Believing data exactly as much as it deserves

Here is the move that makes SOL-OSI robust rather than merely optimistic. It refuses to choose between measurement and prior knowledge — it blends them, per bin, by confidence. The prior is the healthy loop scaled by the current authority, Lprior = a L0, pushed through Section 03’s formula to get Tprior = Lprior/(1+Lprior):

  1. Confidence-weighted blend — full trust uses the measurement, zero trust falls back to the prior: Tuse = confidence · T̂ + (1 − confidence) · Tprior
  2. Invert Section 03 to recover the loop itself (guarding the denominator away from zero, |1 − T| ≥ 0.02): L̂ = Tuse1 − Tuse
  3. Feed it to the chordal margin; a 3-bin moving median knocks out single-bin outliers before the min: bst = minkeep  movmedian3( c(L̂, −1))

This bst is the coherent branch: the best margin estimate available when the loop is well excited. Its failure mode is equally clear — under weak drive, confidence collapses everywhere, Tuse degenerates to the prior, and bst can only repeat what it already believed. Damage that whispers instead of shouting needs a different listener.

Explore · the confidence blend at one frequency sweep
|T̂| raw estimate (noisy) |T_prior| from a·L₀ |T_use| blended
A synthetic noisy measurement of |T| over the band (the true loop here is ρ = 1.6, while the prior still believes ρ = 1). At high confidence the blend follows the measurement and discovers the damage; slide confidence down and watch the estimate surrender to the prior — the honest thing to do when the data is uninformative.
Coherence → confidence → blend → margin
167gamma2 = min(1, abs(state.Syu).^2./max(state.Suu.*state.Syy, eps));
168p_rel = state.Suu/max(max(state.Suu), eps);
169leverage = p_rel./(p_rel+cfg.power_floor);
170confidence = activity*gamma2.*leverage;
171That = state.Syu./max(state.Suu, eps);
172Tuse = confidence.*That+(1-confidence).*Tprior;
173den = 1-Tuse;
174small = abs(den) < 0.02;
175den(small) = 0.02*exp(1i*angle(den(small)+eps));
176Lhat = Tuse./den;
177b_st = minMasked(movmedian(chordal(Lhat, cfg.cap), 3), cfg.keep);

The prior comes from lines 128–130: a_now = min(max(state.a_cmd, 0.02), 1), Lprior = a_now*cfg.L0, Tprior = Lprior./(1+Lprior).

Check yourself · 3

At bin k the drive has strong energy and γ²ₖ = 0.95, but the experiment has just gone quiet, so activity = 0.05. The blend T_use at that bin is…

Confidence is a product of three gates. A high γ² computed from stale, fading spectra should not be trusted, and the activity factor is what enforces that: any single gate near zero pulls the whole product down.

Why does the coherent branch take a 3-bin moving median of the chordal curve before the minimum?

min is the most outlier-sensitive statistic there is — it hands the microphone to the single worst bin. The median-of-3 requires at least two neighboring bins to agree before a dip can become the minimum.

07 The quiet branch

Hearing damage in the noise floor

Suppose the drive is weak — the earthquake record is between pulses, activity is low, the coherent branch has gone deaf. The loop is still running, and its output still contains a faint hiss: sensor noise, ambient vibration, tiny disturbances entering everywhere. That hiss is not useless. Section 03 told us that disturbances at the output are shaped by the sensitivity — the noise floor of y carries the fingerprint of 1/(1 + L). As the loop creeps toward instability, 1 + L shrinks at the dangerous frequencies and the noise floor there bulges. The quiet branch is built to hear that bulge.

Step 1 — normalize away the healthy shape

  1. Divide the measured output spectrum by the healthy sensitivity shape, precomputed from L₀: qobs = Syy 1/(1 + L0)2
  2. Calibrate against this specimen’s own healthy noise: the median of the first three half-second windows becomes a per-bin reference, and everything after is measured relative to it: qobs ← qobsqref ,  qref = median of first 3 windows

If the loop still matches its healthy self, qobs ≈ 1 at every bin. Any systematic deviation is evidence. The branch summarizes the level in the 28–44 Hz fit band (chosen around the column resonance, where sensitivity changes show first) as the reference_ratio — you will meet it again as a safety interlock.

Step 2 — ask which damage would explain it

Now the model from the explorer earns its keep. Suppose damage acts as a loop-gain factor ρ, so the true loop is a ρ L0. Then the noise floor should have risen by the ratio of old to new sensitivity:

qmod(ρ) = 1 + L02 1 + a ρ L02

SOL-OSI evaluates this on a fixed grid of 25 candidate ρ values, logarithmically spaced from 0.5 to 6, and scores each against the observation with a deliberately unglamorous loss — the median absolute log-error over the fit band:

The fit ρbest = arg minρ ∈ grid  medianfit band log qobs − log qmod(ρ)

Median, not mean: a few contaminated bins cannot drag the answer. Log, not linear: a noise floor 2× too high and one 2× too low are equally wrong.

Explore · fitting ρ to a noisy noise floor
The loss over the 25-point ρ grid, for a synthetic q_obs generated at ρ* and corrupted with noise. The marked minimum is ρ_best. Notice how the valley stays anchored near ρ* even with heavy noise — the median’s doing — and how it flattens as ρ* → 1: small damage is genuinely harder to localize.

Step 3 — believe it slowly, forget it slowly

A raw ρbest every 0.125 s would be jumpy, so the branch keeps a damage memory: a smoothed log-domain state log ρ that moves toward the fit only in proportion to how much evidence the block carried, and leaks back toward health when there is no evidence at all:

  1. Pull toward the fitted target (shape_weight ∈ [0,1] scores how decisively the evidence points at a gain change — it grows with the coherent branch’s own drive estimate and with extreme level growth): log ρ ← log ρ + activity αres  (log ρtarget − log ρ)
  2. Blind forgiveness — with no drive there is no evidence either way, so the memory decays toward ρ = 1 with a 10 s time constant: log ρ ← log ρ  (1 − αleak(1 − activity))
  3. The working damage estimate, including a fast “kick” detector explained below: ρ = elog ρ · drive_ratio
  4. And the branch’s margin: the healthy loop, re-scaled by what we currently believe about authority and damage, through the same chordal formula: bres = minkeep  movmedian3( c(a ρ L0, −1))
What is the “kick” (drive_ratio), and why gate it on two windows?

Besides the noise-floor fit, the branch projects the coherent branch’s L̂ onto the model direction to get an instantaneous gain read, ρdrive = Re Σw Lmodel*L̂ / Σw Lmodel2 with weights w = confidence2. A sudden jump of this read above its own slow average is an edge — damage arriving right now. The kick multiplies ρ by (1 + excursion) immediately, without waiting for the slow memory.

But a single noisy 0.125 s window could fake such a jump. So the code takes the minimum of the current and previous excursion: a real edge persists for two consecutive windows and passes with one update of extra latency; a one-window fluke is zeroed by the min. The kick is also clamped (detector_max = 2) so no single detector can more than double ρ.

The healthy-reference interlock (release / re-engage)

The reference_ratio from Step 1 doubles as a hysteresis switch. If corrected quiet power falls below 0.6× the healthy reference, the specimen is convincingly quiet again and the amplified idle memory (next section) is released; it re-engages only after power climbs back above 1.4×. The wide gap is deliberate: strong evidence to forgive, strong evidence to re-accuse — no flapping in between. On top of this, when quiet power exceeds 4× healthy, the fit is allowed to weigh absolute level (not just shape) more heavily, by raising the fit floor smoothly by up to 0.28 (level_fit_gain).

The quiet branch, condensed
184qobs = state.Syy./cfg.shape;
186    qobs = qobs./max(state.q_ref, eps);
187    state.reference_ratio = median(qobs(cfg.res_keep));
202qmod = abs((1+cfg.L0)./(1+a_now*cfg.rho_grid(k)*cfg.L0)).^2;
209loss(k) = median(abs(log(max(qobs(cfg.res_keep), eps))- ...
210    log(max(qmod(cfg.res_keep), eps))));
212[~, kbest] = min(loss);
216rho_drive = real(sum(weights.*conj(Lmodel).*Lhat))/ ...
217    max(sum(weights.*abs(Lmodel).^2), eps);
223raw_kick = max(rho_drive/state.rho_drive-1, 0);
224kick = min(min(raw_kick, state.ratio_prev), cfg.detector_max-1);
238state.log_rho = state.log_rho+alpha_rho*dlog_rho;
243state.log_rho = state.log_rho*(1-cfg.leak_alpha*(1-activity));
244rho = exp(state.log_rho)*drive_ratio;
245b_res = minMasked(movmedian(chordal(a_now*rho*cfg.L0, cfg.cap), 3), ...
246    cfg.keep);

cfg.shape = max(abs(1./(1+cfg.L0)).^2, eps) (line 76); cfg.rho_grid = logspace(log10(0.5), log10(6), 25) (line 77). The healthy reference q_ref is collected at lines 148–154.

08 Fusion

One number

SOL-OSI now holds two opinions: bst, sharp but drive-hungry, and bres, coarse but awake in the quiet. The fusion logic asks one question — who is in a position to know? — and answers it differently for driven and idle moments.

While driven: let the pessimist veto the optimist

  1. How blind is the coherent branch inside the residual band right now? (drive is the slow-averaged activity) wres = drive ·  (1 − meanfit band leverage)2
  2. The residual branch may only pull the margin DOWN, and only by as much as the coherent branch is blind: bevid = bst − wres  max(bst − bres, 0)

Note the asymmetry: bres can never raise the margin above the coherent estimate. A safety monitor accepts bad news on thin evidence and good news only on strong evidence.

While idle: report what memory justifies

wmem = clip[0,1] (32 (ρ − 1)) bidle = 12 − wmem  (12 − bres)

With no drive and no damage memory (ρ ≈ 1), the honest idle answer is the cap — an unexcited loop shows no evidence of danger. But the gain of 32 makes the memory extraordinarily sticky: a remembered ρ only 3% above 1 already pins bidle fully to bres. Damage seen once is not forgotten just because the room went quiet — unless the healthy-reference interlock from Section 07 has released it (then the gain drops to 1).

Blend, smooth, clamp

The published margin braw = activity · bevid + (1 − activity) · bidle b ← b + αmargin (braw − b) ,  b = clip[0, 0.707] b

One last EWMA (τ = 0.15 s — the fastest in the estimator, because this is the safety output) and the number is on the wire, 8 times per second.

Fusion, verbatim
248res_weight = state.drive*(1-mean(leverage(cfg.res_keep)))^2;
249b_evid = b_st-res_weight*max(b_st-b_res, 0);
250memory_gain = cfg.residual_memory_gain;
251if state.memory_released
252    memory_gain = 1;
253end
254w_mem = min(max(memory_gain*(rho-1), 0), 1);
255b_idle = cfg.cap-w_mem*(cfg.cap-b_res);
256b_raw = activity*b_evid+(1-activity)*b_idle;
257state.b = state.b+cfg.margin_alpha*(b_raw-state.b);
258b = min(max(state.b, 0), cfg.cap);
Check yourself · 4

The coherent branch says b_st = 0.40; the quiet branch says b_res = 0.25; the coherent branch has excellent leverage in the fit band (mean ≈ 1). What does b_evid do?

The veto weight w_res = drive·(1 − mean leverage)² measures the coherent branch’s blindness. When the coherent branch can genuinely see the fit band, its opinion stands; the quiet branch only takes over territory the coherent branch cannot patrol.

Why does the idle margin default to the cap 1/√2 rather than to the last driven estimate?

The design splits “no evidence” from “evidence of damage.” Pure quiet with a clean memory reads as safe; quiet with a damage memory reads as exactly the remembered damage. The ×32 gain makes the second case dominate at the slightest remembered ρ.

09 Closing the outer loop

Acting on it: the authority regulator

A margin nobody acts on is a seismograph in an empty room. The companion function gpt5_6_4_auth.m turns b into the knob you already used in the explorer: the authority a ∈ [0.2, 1], a gain applied to the loop. This is itself a feedback controller — a slow, deliberate outer loop wrapped around the estimator — and its target is a margin, not a position:

  1. The reference margin, nudged up when the quiet branch is doing the talking (rw = residual weight from Section 08): bref = 0.55 + 0.02 (1 + rw)
  2. The error, with a deadband so the authority does not fidget over noise-sized deviations: em = sign(b − bref) ·  max(b − bref − 0.008(1 + rw), 0)
  3. Asymmetry: shedding authority (e < 0) acts at full strength; restoring it (e > 0) is scaled by ≈ 0.65 — fast to retreat, careful to advance: e = min(em, 0) + (bref2)2  max(em, 0)
  4. Integrate in the log domain — so a step from 1.0 to 0.9 and from 0.4 to 0.36 are the same size move — then clamp the range and the per-update slew: log a ← log a + 5a Δt e Δa ≤ 0.05 a ∈ [0.2, 1]

And here the story closes on itself: the new a is handed back to the estimator, which uses it in the prior Lprior = a L0, in the damage model qmod, and in bres. The estimator knows about the regulator’s own moves — otherwise it would diagnose its own medicine as a change in the patient.

Simulate · a damage event, end to end
A 40 s scenario at the real update rate (8/s), starting from the regulator’s healthy equilibrium: even undamaged, this loop at full gain only reaches b = 0.345, so the regulator permanently trades some authority (a ≈ 0.5) to hold the target margin. Damage ρ ramps in at t = 8 s, holds, and is repaired at t = 25 s. Top: the margin b (cobalt) versus the regulator’s reference b_ref (dashed) — when damage drags b below the reference, the regulator sheds further until the margin is restored. Bottom: the authority a. Note the asymmetry: the retreat is fast; after the repair, a climbs back with visible caution (log-domain rate × 0.65 recovery weight, 0.05 slew cap). The margin here is the idealized b = min c(a·ρ·L₀, −1) smoothed by the estimator’s τ = 0.15 s filter.
The regulator, verbatim
30b_ref = prm.target+prm.guard*(1+residual_weight);
31e_raw = b-b_ref;
32deadband = prm.deadband*(1+residual_weight);
33e_margin = sign(e_raw)*max(abs(e_raw)-deadband, 0);
34recovery_weight = (b_ref*sqrt(2))^2;
35e = min(e_margin, 0)+recovery_weight*max(e_margin, 0);
36rate = prm.rate*sqrt(a0);
37log_a = log(a0)+rate*prm.dt_update*e;
38a_free = exp(min(max(log_a, log(prm.a_min)), log(prm.a_max)));
39a_next = a0+min(max(a_free-a0, -prm.max_step), prm.max_step);

File: control_and_HS/OSI/gpt5_6_4_auth.m. Defaults: target = 0.55, guard = 0.02, deadband = 0.008, rate = 5, max_step = 0.05, a ∈ [0.2, 1]. Deployments may override the target — the outer RTHS configuration in this repo uses target_outer_ncf = 0.45.

Check yourself · 5

Why must the estimator be told the current authority a (via L_prior = a·L₀) instead of always comparing against the original L₀?

A shed to a = 0.5 halves the apparent loop gain. An estimator ignorant of that would fit ρ ≈ 0.5, misread the sensitivity shape, and its margin would no longer describe the loop actually running. Self-awareness of the actuator’s own moves is what keeps the outer loop honest.

The regulator integrates in log a with rate 5√a. Compared with a plain linear integrator on a, this choice mainly buys…

d(log a) = da/a — equal steps in log a are equal relative changes. The clamp to [0.2, 1] and the 0.05 slew cap then bound the absolute move. (And exp(log a) can never go negative, which is a nice bonus.)

10 Reference

Symbols ↔ code

Every quantity in this module, its home in sol_osi.m / gpt5_6_4_auth.m, and where it was introduced:

symbolcodemeaning§
u, ydd, dmdrive command and measured displacement01
L₀cfg.L0healthy loop C·H_dc_dm on the 2 Hz grid02
T, S—closed-loop tracking and sensitivity03
b(ω), bchordal(), state.bchordal margin per bin; published minimum04
c(z₁,z₂)—chordal distance; cap = 1/√204
S̄uu, S̄yy, S̄yustate.Suu/Syy/SyuEWMA auto/cross spectra, τ = 0.25 s05
α = 1−e^(−Δt/τ)cfg.*_alphaEWMA gains for all time constants05
activityactivitysoft drive gate, (rms u / 0.06)⁴ clipped05
γ²gamma2coherence — per-bin trust in T̂06
leverageleveragerelative input power above 30 dB floor06
T̂, L̂That, LhatH1 estimate; blended loop estimate06
b_stb_stcoherent-branch margin06
q_obs, q_refqobs, state.q_refshape-corrected noise floor; healthy reference07
ρ, ρ_bestrho, rho_bestdamage loop-gain factor; grid fit07
ρ_drive, kickrho_drive, kickprojection detector and 2-window edge kick07
b_resb_resquiet-branch margin from a·ρ·L₀07
w_res, b_evidres_weight, b_evidblindness-weighted downward veto08
w_mem, b_idlew_mem, b_idle×32 damage-memory weight; idle margin08
astate.a_cmd / state.aauthority — loop gain knob, [0.2, 1]09
b_ref, eb_ref, emargin reference; deadbanded asymmetric error09

Provenance

The frequency-response data embedded in this page (all charts) was exported from the repository with MATLAB: C = pid(0.15, 20, 0.0015, 1/512, dt) discretized ForwardEuler at dt = 1/2048 s, multiplied by the identified plant H_dc_dm from the canonical record saved_results/tests/gpt5_6_tests/20260710_1845_…mat, sampled on cfg.freq_hz exactly as sol_osi('config', …) does. Raw export: l0_data.json beside this file. The implementation lives at control_and_HS/OSI/sol_osi.m (estimator) and control_and_HS/OSI/gpt5_6_4_auth.m (authority); the benchmark and validation notes are in research/gpt5_6_4/sol_osi/README.md.