Course module · Real-time hybrid simulation
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.
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.01 Setting
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.
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:
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.
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
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 = 512The 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 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:
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
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:
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ω).
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.
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
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:
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:
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:
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)).
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
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:
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:
Δ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:
uu ← uu + α(pu − uu) yy ← yy + α(py − yy) yu ← yu + α(cyu − yu)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.
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
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 = yuuu(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.
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?”
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:
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):
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.
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).
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
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.
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.
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 ρ L02SOL-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:
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.
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:
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 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).
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
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.
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.
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).
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.
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);
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
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:
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.
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.
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
Every quantity in this module, its home in
sol_osi.m / gpt5_6_4_auth.m, and where it was introduced:
| symbol | code | meaning | § |
|---|---|---|---|
| u, y | dd, dm | drive command and measured displacement | 01 |
| L₀ | cfg.L0 | healthy loop C·H_dc_dm on the 2 Hz grid | 02 |
| T, S | — | closed-loop tracking and sensitivity | 03 |
| b(ω), b | chordal(), state.b | chordal margin per bin; published minimum | 04 |
| c(z₁,z₂) | — | chordal distance; cap = 1/√2 | 04 |
| S̄uu, S̄yy, S̄yu | state.Suu/Syy/Syu | EWMA auto/cross spectra, τ = 0.25 s | 05 |
| α = 1−e^(−Δt/τ) | cfg.*_alpha | EWMA gains for all time constants | 05 |
| activity | activity | soft drive gate, (rms u / 0.06)⁴ clipped | 05 |
| γ² | gamma2 | coherence — per-bin trust in T̂ | 06 |
| leverage | leverage | relative input power above 30 dB floor | 06 |
| T̂, L̂ | That, Lhat | H1 estimate; blended loop estimate | 06 |
| b_st | b_st | coherent-branch margin | 06 |
| q_obs, q_ref | qobs, state.q_ref | shape-corrected noise floor; healthy reference | 07 |
| ρ, ρ_best | rho, rho_best | damage loop-gain factor; grid fit | 07 |
| ρ_drive, kick | rho_drive, kick | projection detector and 2-window edge kick | 07 |
| b_res | b_res | quiet-branch margin from a·ρ·L₀ | 07 |
| w_res, b_evid | res_weight, b_evid | blindness-weighted downward veto | 08 |
| w_mem, b_idle | w_mem, b_idle | ×32 damage-memory weight; idle margin | 08 |
| a | state.a_cmd / state.a | authority — loop gain knob, [0.2, 1] | 09 |
| b_ref, e | b_ref, e | margin reference; deadbanded asymmetric error | 09 |
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.