Part 8 — Closed-Loop Airbrake Control
Part 8: Closed-Loop Airbrake Control — Bang-Bang, PID, and Predictive
Post-game write-up, now covering all three planned control algorithms for the ARC 2026 airbrake problem: bang-bang, PID (live-gain), and predictive (model-inversion via bisection). Section 8.6 documents the diagnostics chart built partway through the predictive work, which turned out to be valuable enough — for predictive AND for re-diagnosing PID's D-term — that Section 8.9 flags it as its own lesson for how this whole chapter's development process should change going forward.
8.1 The Problem: Why Airbrakes At All
A model rocket motor is certified to burn exactly one way, every time — you don't get to throttle it, restart it, or shut it down early. That means the only lever you have over how high the rocket goes is how much drag it experiences during coast. More drag during coast → lower apogee. Less drag → higher apogee.
The ARC (American Rocketry Challenge) scoring problem is to hit a precise target altitude — not "as high as possible," not "close enough," but a specific number, 228m (750 ft) in our case — plus a target flight duration. Motors, by design, have some performance margin built in (you don't want to certify a motor that might underperform and never leave the rod), which means most flights naturally overshoot the target if left alone.
Aerobrakes solve this by deploying drag-increasing surfaces during coast, after burnout, to bleed off exactly enough excess energy to land on target. They don't add thrust and they don't change the ballistic trajectory before burnout — they only ever subtract energy, and only during the coast phase.
This means every algorithm in this chapter is really answering one question, asked over and over, many times a second, during the few seconds between burnout and apogee:
"Given where I am right now, how much more drag do I need, right now, to land on target?"
The three algorithms in this project — bang-bang, PID, and predictive/model-based — are three different strategies for answering that question. They form a natural teaching progression: each one fixes a real, demonstrated weakness in the one before it, and each one is more sophisticated because the simpler one genuinely wasn't good enough — not because sophistication is inherently better.
8.2 Shared Foundation: predictApogeeNoBrake()
All three algorithms lean on the same piece of physics: a closed-form estimate of how much higher the rocket will coast, given its current altitude, velocity, mass, and drag coefficient, if the brakes were fully retracted starting right now:
yc = (M / 2k) · ln( (M·g + k·v²) / (M·g) )
where k = ½·ρ·Cd_base·A is the drag term with brakes removed. This is the
Fehskens-Malewicki coast-phase equation, and it's the single most-used function
in the whole plugin — every algorithm calls it, every frame, to answer "how much
more altitude is already baked in from here, if I do nothing more?"
The key trick that makes this reusable everywhere: Cd_base = Cd - cd_adj, where
cd_adj is whatever drag the airbrakes are currently contributing. Subtracting
it out means this function always answers "what if brakes retract now," regardless
of what they're doing at this exact instant — which is exactly the counterfactual
every algorithm needs to reason about.
Validation: this closed-form estimate was checked against the full RK4 integration (the "ground truth" physics engine) at the end of a real flight and matched to within 5mm on a ~272m trajectory. This is a genuinely trustworthy model, not just a convenient approximation.
8.3 Algorithm 1: Bang-Bang
How it works
The simplest possible controller. Every frame:
if predicted_apogee > target:
angle = max_deploy_angle (full brakes)
else:
angle = min_deploy_angle (brakes retracted)
No gains to tune, no history, no memory of previous frames. It just asks "am I still going to overshoot if I do nothing more?" and slams the brakes fully open or fully closed based on the answer.
Strengths
- Dead simple, dead reliable. No tuning, no derivation, nothing to get wrong. It's the correct first thing to build, both because it works and because it establishes a baseline every fancier algorithm has to beat.
- Front-loads correction while it matters most. Drag force scales with
v². A degree of brake deployed at high velocity (early in coast) does dramatically more work than the same degree deployed late, once velocity has bled off. Bang-bang's "always full brakes while overshooting" behavior happens to front-load correction into exactly the window where each degree of deployment is most effective — without ever being told to. - No oscillation. In every bang-bang test run this session, deployment went to max, stayed there, then snapped to zero, cleanly. No chatter, no hunting.
Weaknesses
- No graceful approach to the target. It has exactly two states. It cannot ease off as it gets close — it's either all-in or all-out, which means real hardware sees an actuator commanded to snap between two extremes rather than move smoothly.
- No way to account for how much correction is needed — only whether more is needed. A rocket that's 5m over target and one that's 50m over target get the identical command: full brakes.
- No mechanism to handle noisy or uncertain state. A single bad altitude reading right at the crossing point could flip the decision on that frame.
Verdict
Bang-bang is the right first algorithm to build and a genuinely useful teaching tool — but it's not going to win precision-landing points on its own. It's the baseline everything else is measured against.
8.4 Algorithm 2: PID (Live-Gain, Physically-Derived)
This is where almost all of the real engineering work in this project happened, and it's worth walking through why each piece exists, in the order it was actually needed — because every piece was added to fix a specific, demonstrated failure of the piece before it, not added speculatively.
8.4.1 Why not a textbook fixed-gain PID?
The very first attempt used a single, fixed Kp, derived once from the
burnout state via finite-difference (perturbing Cd slightly, seeing how much
predictApogeeNoBrake moved, then chaining that through the airbrake geometry
to get degrees-per-meter-of-error).
This failed in a specific, instructive way: the fixed gain gave a
reasonable initial correction, but as the flight progressed, the sensitivity
of apogee to brake angle collapsed — both because the brake geometry itself is
least effective near 0° and 90° (a sin(θ) term), and because drag effectiveness
in general falls off as velocity drops. A gain that was well-calibrated at
burnout became badly undersized by mid-coast. The controller converged to a
steady-state offset well short of target — a textbook proportional-control
symptom, and a good one to have seen firsthand rather than just read about.
8.4.2 Fix: make the gain live — computeSensitivity()
Instead of deriving Kp once, recompute the sensitivity every frame, from
the rocket's current state:
Kp_live = -1 / d(apogee)/d(angle)
where d(apogee)/d(angle) is itself a chained finite-difference: perturb Cd
slightly to get d(apogee)/d(Cd), perturb angle slightly (via the known
brake-geometry formula) to get d(Cd)/d(angle), multiply them together.
This fixed the steady-state offset — but introduced the next problem.
8.4.3 The 90° singularity
The brake-geometry formula for projected area includes a sin(θ) term, whose
true derivative (cos θ) goes to exactly zero at θ = 90°. Once the
controller commanded full deployment (which happens often, especially early in
coast), computeSensitivity() was evaluating a derivative at exactly the point
where it collapses to zero — and Kp_live = -1/0 blew up toward infinity,
producing commanded angles in the millions of degrees (harmlessly clamped, but
numerically nonsensical).
Fix: never linearize exactly at the geometric edge. computeSensitivity()
clamps its reference angle to stay a small margin (5°) away from 0° and 90°
before taking the finite difference. Cheap, physically motivated (a real servo
has finite resolution near its end-stops anyway), and it fully resolved the
blow-up at 90°.
8.4.4 A second singularity, later in flight
Fixing the 90° edge case wasn't the whole story. As velocity approaches zero
near apogee, d(apogee)/d(Cd) also collapses toward zero — a real physical
effect: with almost no kinetic energy left, no amount of added drag can move
the predicted apogee anymore. Kp_live blows up a second time, later in the
flight, for a genuinely different physical reason than the 90° case.
Fix: Kp_live is explicitly capped. The cap isn't an arbitrary round
number — it's derived from the same physical threshold used to decide "no
useful authority remains" (see 8.4.6 below): the largest gain that still does
useful work before that boundary is reached.
Kp_live_max = max_deploy_angle / authority_diff_threshold (= 90° / 3m = 30)
8.4.5 Deriving Ki, live, at Coast Begin — not guessed
Rather than hand-tune an integral gain, it's derived automatically the moment coast begins, from that flight's own burnout telemetry:
- Predict the no-brake apogee and the resulting error at burnout.
- Use the same sensitivity chain to estimate how much additional angle would be needed to close that error in one shot.
- Estimate remaining coast time (ballistic, no-drag estimate — conservative, since real coast with drag runs a little longer).
Ki = additional_angle_needed / (residual_error × coast_time_estimate)
This is the answer to "how is this generic across different rockets/flights?"
— nothing about Kp or Ki is typed in ahead of time. Both are computed from
whatever flight is actually happening, using only rocket-specific geometry
constants (tube diameter, brake panel size) as fixed inputs.
8.4.6 Integral windup near authority collapse — isTargetAchievable()
Once live gains were working, a new failure appeared near apogee: with velocity nearly zero, the brakes genuinely can't move the predicted apogee anymore (same physics as 8.4.4) — but the integral term didn't know that, and kept accumulating against an error it could no longer correct, driving the commanded angle to climb continuously for no benefit.
Fix: isTargetAchievable() compares the no-brake prediction against the
best-case, full-brake prediction. When that gap shrinks below a threshold
(3m, later tightened to 1.5m — see 8.4.9), real control authority is gone, and
both the integral accumulation and the Kp_live update freeze. This
achievability check does double duty — it also identifies genuinely
unreachable targets (a rocket that simply doesn't have enough excess energy
left to correct in the remaining time), which is valuable information in its
own right, independent of windup.
8.4.7 Actuator dynamics matter — rate limiting
An early "successful" run showed the commanded angle swinging from 90° to ~3° in 0.1 seconds — a transition no real servo can execute. The controller's own math was internally consistent, but it was implicitly assuming an actuator that could teleport.
Fix: grounded the actuator in a real datasheet spec (Savox SH-0257MG, metal-gear digital micro servo — worst-case 0.13s/60° at 4.8V, giving ~461°/s max slew rate) and clamped the change in commanded angle per frame to what that servo can physically produce:
max_angle_delta = max_slew_rate_deg_per_sec × dt
angle = clamp(angle, prev_angle − max_angle_delta, prev_angle + max_angle_delta)
This is real, and it matters beyond the sim — any physical implementation needs this constraint regardless of what the pure control math wants.
8.4.8 Adding Kd — and why it had to wait
Classical PID theory says add a derivative term to damp oscillation. It was deliberately not added until real oscillation was actually observed in data — adding a damping term for a symptom that hasn't appeared is tuning against noise, not against the system.
Once Kd was justified (see 8.4.9), it was built with two things this project
had already learned the hard way: (1) differentiate a low-pass-filtered
version of the error, not the raw error, since raw sensor noise differentiated
directly produces spikes; (2) guard the very first control-loop frame, where
dt can legitimately be zero, from a 0/0 division.
Kd's magnitude is tied to Kp_live × Kd_Td, where Kd_Td is the actuator's
own full-sweep time (max_deploy_angle / max_slew_rate_deg_per_sec) — another
number derived from real hardware, not guessed.
8.4.9 Realistic sensor noise, and the real reason it mattered
Real flight hardware (BMP180 barometer, LSM6DSOX IMU) doesn't report perfect altitude and velocity. Noise was modeled from the actual datasheet specs (0.25m RMS altitude noise; accelerometer noise density converted to a velocity random-walk term) and applied only to the copy of the frame the controller sees — the true simulated physics stays clean, exactly mirroring how a real sensor's noise doesn't change the real world, only the flight computer's perception of it.
With noise active, the controller reliably landed within about a meter of
target and — after tightening authority_diff_threshold from 3m to 1.5m —
repeatably within 0.07m across six independent noisy runs
(228.87m–228.94m against a 228m target).
8.4.10 Kd, Revisited: Chart-Driven Isolation, and the Decision to Disable It
Section 8.4.8 justified adding Kd once oscillation was actually observed —
but at the time, "observed" meant reading console-logged angles and inferring
a pattern by eye. Once the diagnostics chart existed (Section 8.6), the same
question could be asked properly: does Kd actually help, or does it just
look justified?
The chart made the answer obvious in a way the logs alone hadn't. At full
Kd, the angle trace showed a large, sustained oscillation — swinging the
full 28°–90° range, ringing for the entire second half of coast — and,
critically, it kicked in almost exactly where Authority Margin collapsed
toward zero. That's the signature of a derivative term amplifying sensor
noise once the true signal (the error) gets small, not of it damping a real
overshoot.
Three isolation runs, same rocket, only Kd changed, each one charted:
Kd |
Angle trace | 5-run apogee spread |
|---|---|---|
Full (Kp_live × Kd_Td) |
Sustained full-range oscillation from ~4s onward | Not measured — visibly unstable |
| 10% of full | Smaller oscillation, same character, still present | Not separately measured |
| 0 (disabled) | Clean, smooth decay; no sustained oscillation | 227.85–227.97m |
The 10%-Kd run was the deciding data point: the oscillation shrank
proportionally with the gain rather than persisting at a fixed size. That
rules out a structural resonance (which wouldn't scale away with a smaller
gain) and confirms straightforward noise amplification — the fix is a gain
question, not a redesign.
But the more important finding is the comparison against Kd = 0, which
wasn't just "less bad" — it was the best of all three, both visually
(a clean chart, no jitter after initial settle) and numerically (a 0.12m
5-run spread, tighter than any Kd > 0 configuration, and tighter even than
the predictive algorithm's own 0.67m spread — Section 8.5.6).
Decision: Kd is implemented, was tested rigorously, and is disabled by
default. The original formula is kept in the code, commented, with the
finding documented inline — not because D-terms are wrong in general, but
because this specific system (a rate-limited actuator, a noisy velocity
estimate, and an authority margin that collapses fast near apogee) has no
overshoot problem for D to solve, and differentiating near-zero-signal noise
only hurts. This is a more honest and more useful result for the book than
"we tuned D and it worked" would have been — it's a real, tested,
system-specific finding, not a textbook recitation.
This also reframes the six-run, 228.87–228.94m PID result quoted just above:
that run predates this isolation testing, and its exact Kd configuration
wasn't recorded precisely enough to say whether it matches "full," "10%," or
"0." Treat the 227.85–227.97m, Kd=0 result above as the current
best-characterized PID configuration going forward, and the six-run figure
as an earlier, less-isolated data point worth revisiting if its exact
historical Kd value can be reconstructed.
Strengths
- Adapts gain continuously to the flight's actual, changing physics rather than a burnout snapshot.
- Every gain is derived, not guessed, and every derivation is traceable to either physics (sensitivity chains) or real hardware specs (actuator rate, sensor noise).
- Demonstrated tight, repeatable precision under realistic sensor noise.
- The achievability check gives a genuinely useful secondary signal (windup prevention and unreachable-target detection) for free.
Weaknesses
- Structurally more fragile than bang-bang. A live gain built around
-1/sensitivityis, by construction, sitting near a mathematical singularity for parts of every flight. That's not a bug to eliminate — it's an inherent property of this architecture, and it demands real care (margins, caps, achievability gating) that a simpler controller doesn't need. - Linearized, not global. Every gain is a local linear approximation around the current state. It's re-derived every frame, which helps, but it's still fundamentally reactive — it doesn't re-solve the whole remaining trajectory the way a model-based approach would.
- More moving parts to validate. Bang-bang has one branch. This algorithm has live-gain scheduling, an achievability gate, rate limiting, and a filtered derivative term — each individually justified, but collectively a lot more surface area for something to go quietly wrong.
Verdict
A genuinely strong, precision controller once fully derived and grounded — but it earned every piece of its complexity by fixing a real, demonstrated failure mode, in order. That order is the lesson: none of this complexity would be justified without the data that motivated each step.
8.5 Algorithm 3: Predictive / Model-Based
8.5.1 How it works: direct model inversion, not gain scheduling
Bang-bang answers "should I add drag." PID answers "how far off am I, and how hard should I correct." Predictive asks a fundamentally different question: "what angle, right now, makes the predicted apogee exactly equal target?" — and then solves for it directly, every frame, rather than approaching it through a gain.
The physics is the same closed-form coast equation every algorithm in this
chapter shares (Section 8.2). The obstacle is that it can't be algebraically
inverted for angle — the drag term k depends on Cd, which depends on
angle, and it sits both inside and outside a logarithm. So "predictive"
really means "numerically solve for the angle that zeroes the error," via
bisection between min_deploy_angle and max_deploy_angle:
apogeeAtAngle(θ) = alt + predictApogeeNoBrake(alt, v, mass, cd + adj_cd_at_angle(θ))
This function is monotonic — more angle → more drag → lower predicted apogee
— so a standard bisection search converges reliably, and if the target falls
outside the reachable range at either end (apogee_hi > target or
apogee_lo < target), that's detected immediately rather than found by
accident.
No integral, no accumulated error, no linearized gain — in principle, no steady-state offset to chase, because it isn't doing linear correction at all. That was the theoretical appeal going in; getting there in practice took three more rounds of chart-driven (and, before the chart existed, log-driven) debugging.
8.5.2 First failure: chatter that looked like noise sensitivity, but wasn't
The first working version, wired in and run for real, produced apogee
results close to target (five runs: 227.97, 228.12, 228.03, 233.24, 228.10)
— but the commanded-angle log was alarming: frame-to-frame jumps of ~23°,
repeatedly, throughout mid-coast. The natural first hypothesis: velocity
noise, amplified through a numerically sensitive inversion (drag scales with
v², so small v errors can matter a lot), re-solved fresh from scratch
every frame with nothing to damp it.
That hypothesis led to a real fix — input-side low-pass filtering of the
noisy alt/v before they reached the solver, reusing the same
alpha = dt/(Kd_Td + dt) filter already built for PID's derivative term
(Section 8.4.8). It's a physically sound idea — filter noise before it hits a
sensitive calculation, the same thing a real sensor-fusion layer would need
to do regardless of which algorithm is running.
It didn't work. Re-run with filtering active, the chatter was essentially unchanged in magnitude — still ~23° jumps, still every few frames. This is the valuable part of the story: a plausible, well-reasoned fix that measurably fails is worth more than a lucky guess that happens to work, because it forces the next question to be asked properly instead of declaring victory on a hunch.
8.5.3 Root cause: quantized bisection, not noise
The actual clue was hiding in a second logged value: the solver's iteration count. Frames with large angle jumps consistently showed low iteration counts (2–3), while smoothly-converging frames showed high counts (16–21).
The mechanism: solveAngleForTarget() always restarted bisection from the
same full [0°, 90°] bracket and stopped the instant
|predicted − target| < tolerance was satisfied. Because bisection always
visits the same fixed set of candidate angles in the same order (45° first,
then 22.5° or 67.5°, and so on), where it happens to stop is a function of
whether the target lands close to one of those specific coarse points early
in the search — not a continuous function of the input. Two frames with
nearly-identical (even filtered) alt/v could have their target sit on
opposite sides of an early coarse split, producing wildly different reported
angles even though the true answer barely moved. Filtering the input
couldn't fix this, because the discontinuity was in the solver's stopping
rule, not in the input noise.
8.5.4 Fix: tighten the tolerance
Tightening tolerance_m from 0.5m to 0.03m forced bisection deeper on every
frame, before it could exit onto the coarse, sparse grid points. Re-run: the
mid-coast angle trace became a smooth ~1° ripple — 51.3° → 51.9° → 52.9° →
53.4° → 54.1° → … — instead of a sawtooth. The chatter was gone.
8.5.5 Second failure: flailing near apogee — a different problem entirely
Tightening tolerance fixed mid-coast, but the tail of the same log — the last handful of frames before apogee — got worse if anything: full-range swings between 0° and 90°, iteration counts back down to 2–3, no obvious pattern. This isn't the same bug reappearing; it's a genuinely different physical effect.
Drag scales with v². As velocity collapses toward zero near apogee,
apogeeAtAngle(0°) and apogeeAtAngle(90°) converge toward each other —
there's barely any velocity left for extra drag to act on. When that gap
gets small, almost every angle satisfies the tolerance, and which one
bisection happens to land on is effectively arbitrary, flipping with each
frame's noise draw. This is a genuine loss-of-authority condition, not a
solver bug — the same physical effect isTargetAchievable() (Section 8.4.6)
was built to catch for PID, just showing up at the opposite end: not "can't
reach the target at all" but "brake angle barely matters anymore, don't
trust the solve."
8.5.6 Fix: authority-collapse guard
Inside solveAngleForTarget(), before attempting to bisect, check the width
of the achievable range:
if (apogee_lo - apogee_hi) < authority_diff_threshold:
return { angle: current_deploy_angle, authority_collapsed: true }
If the brake's total commandable spread is smaller than the same physical threshold PID already uses to detect authority loss, the right move is to hold current position, not chase an ill-conditioned "solution." Re-run: the tail of the log went from wild 0°–90° flailing to a tight ~10° band that settled cleanly and held flat through the end of coast — exactly the behavior the guard was designed to produce.
With both fixes in place, five repeat runs landed at 227.55, 228.18, 228.10, 227.96, 228.22 — a 0.67m spread, centered almost exactly on the 228m target, with the earlier 233.24m outlier gone. No steady-state bias (unlike early fixed-gain PID, Section 8.4.1) — direct model inversion really does sidestep that failure mode structurally, once the solver itself is solid.
Strengths
- No steady-state offset by construction. It's solving the exact nonlinear equation every frame, not applying a linearized correction — so there's no gain-decay mechanism to produce a bias in the first place.
- Conceptually simpler control law than PID — no gain scheduling, no integral windup to guard against, no derivative-noise problem. The complexity that PID puts into tuning (Sections 8.4.1–8.4.5), Predictive puts into solving — and the solver's failure modes turned out to be more tractable to fix (tolerance, authority guard) than PID's gain singularities were.
- Authority-margin concept ports over exactly from PID's
isTargetAchievable()— same underlying bracket calculation, reused almost verbatim, and later generalized into a shared achievability classification for all three algorithms (Section 8.9).
Weaknesses
- A numerical solver has its own failure modes, distinct from a gain controller's. Naive bisection with early-exit tolerance is quantized — its resolution depends on how many iterations it happens to run, not just on the tolerance value nominally set. This isn't obvious from the math; it only showed up by correlating iteration count against jump size in the log.
- Costs more per frame than PID — a bisection loop (up to ~15–20 iterations to hit 0.03m tolerance) versus PID's handful of arithmetic operations. Irrelevant at simulation scale; worth knowing before porting to the RP2040 flight computer, where compute budget is real.
- Same authority-collapse physics as PID, independently discovered. Not really a weakness of Predictive specifically — it's a property of the rocket near apogee, encountered by every algorithm that reasons about brake authority. Worth stating plainly rather than implying Predictive is more fragile than it is.
Verdict
A genuinely strong controller, and the cleanest of the three once fully debugged — but "direct model inversion" turned out not to mean "no failure modes," just different ones than PID's. The lesson (Section 8.7) isn't "predictive is better," it's "every control strategy trades one class of failure mode for another, and you don't know which ones until you chart the data."
8.6 Building the Diagnostics Chart: From Table to Visualization
This section exists because of a direct piece of feedback worth recording verbatim: "Looking back, this would have been a great teaching tool. Would have helped me visualize what you were trying to tell me verbally." The chart wasn't built until partway through debugging Predictive — Sections 8.5.2–8.5.6 above were diagnosed largely from console-logged numbers read by eye, correlating jump sizes against iteration counts across dozens of log lines. It worked, but it was slow, and every "here's why the angle is doing that" explanation was verbal and abstract until this existed.
8.6.1 From table to arrays
The project already had a live per-frame diagnostics table (UI.logAerobrakeFrame(),
built earlier in this project specifically to support debugging PID). Charting
needed almost nothing new — the same per-frame data already being computed and
logged to the table just also gets pushed into parallel arrays (ab_t,
ab_angle, ab_apogee, ab_v, ab_margin) inside that same logging call,
keeping the "encapsulate the UI interaction in one place" pattern already
established for the table.
8.6.2 What to chart, and why each series earns its place
Four series, chosen specifically to explain — not just display — the failure modes already being chased by eye:
- Commanded Angle — the thing every debugging conversation kept describing verbally ("it jumped," "it's chattering," "it settled"). Seeing it as a line made every one of those descriptions immediately checkable.
- Predicted Apogee, with a target line — the actual objective. Without it, an angle change can't be judged as "correcting toward target" versus "reacting to nothing."
- Velocity — the physical root cause behind two separate effects
discovered this session: why noise gets amplified early in coast (
v²scaling) and why authority collapses near apogee (v → 0). Plotting it turns those from claims into something visibly self-evident. - Authority Margin (
apogee_at_min − apogee_at_max) — the exact quantity the collapse guard (Section 8.5.6) checks against threshold. Charting it turns "the solver held position because authority collapsed" from an assertion into something you can watch cross the threshold in real time.
Built on the project's existing Plotly convention (multi-trace, multiple y-axes, already used for the flight-profile chart), so no new charting library or pattern was introduced.
8.6.3 The payoff arrived somewhere unexpected
The chart was built to help finish Predictive — and it did (Sections 8.5.5–8.5.6
were diagnosed directly from it: the authority margin trace crossing threshold
lined up exactly with the angle trace going flat). But its more dramatic payoff
was retroactive: re-running PID with the chart active, purely as practice,
immediately showed something the earlier log-reading approach had never
surfaced — a sustained, full-range angle oscillation late in coast, lining up
visually with Authority Margin approaching zero. That observation led directly
to the Kd isolation testing and disablement documented in Section 8.4.10.
The general lesson (restated in Section 8.7 as a cross-cutting item):
build the visualization before the second algorithm, not after the third.
The verbal, log-reading approach to debugging Bang-Bang and PID worked, but
it worked harder than it needed to — several of the same conclusions that
took paragraphs of log-correlation to establish for Predictive were
immediately, visually obvious once charted, and the same tool then found a
real bug (Kd's oscillation) in an algorithm that had already been
considered "done."
8.7 Cross-Cutting Lessons Learned
These apply across all three algorithms and are worth internalizing independent of which controller you're building.
1. A working simulation doesn't mean a working design — it means a working model of a design. The bang-bang and early-PID results looked clean and convincing in the sim. Several of the "failures" chased later in this project turned out to be genuine physics (windup, singularities, actuator limits) that a clean noise-free sim simply never exercises. Add noise, add actuator limits, add real hardware constraints before trusting a sim result.
2. Fixed-gain control breaks down whenever the system's sensitivity to control input changes significantly during the maneuver. This is a general control-theory point, not specific to rockets — anywhere a system's response to your actuator changes a lot over the timescale of the control action (shrinking coast velocity, in this case), a fixed gain will either under- or over-correct somewhere along the way.
3. Live/adaptive gains fix that — but introduce their own numerical
fragility, especially near mathematical singularities. This is a real
trade-off, not a free upgrade. Anywhere a control law includes a 1/x term,
ask what happens as x → 0, and build the guard rails (margins, caps,
authority checks) before they're needed, not after a mysterious failure.
4. Anti-windup isn't optional once you have an integral term and any possibility of saturated or exhausted control authority. The failure mode (commanding ever-larger corrections for zero benefit) is subtle in a log and obvious in a plot — watch for angle climbing while predicted outcome stays flat.
5. Real actuators are not instantaneous, and pretending they are hides real constraints. A control law that's internally consistent can still be physically unrealizable. Ground actuator behavior in a real datasheet spec as early as practical, not as an afterthought.
6. Derive gains from physics and hardware specs wherever possible, not from guessing-and-checking. Every gain in the final PID controller traces back to either a finite-difference sensitivity calculation or a real component datasheet. This is what makes the controller generic — portable to a different rocket without re-tuning by hand — and it's also what made every piece of this debuggable, because a wrong number could be checked against its derivation rather than just re-guessed.
7. Validate the whole signal path, not just the math. The single most
time-consuming issue of this entire session was not a physics bug or a
tuning problem — it was a missing return statement in a wrapper function,
which silently discarded every commanded correction before it ever reached
the simulated rocket. The control math had been correct for a long time
before this was found; the actuator link was the broken part. The lesson:
when a well-reasoned fix doesn't produce the expected result, check that the
signal is actually reaching its destination before doubting the reasoning
itself. A few hours were spent this session chasing floating-point
precision and sensor-noise theories for a problem that was, in the end, a
disconnected wire. That's a genuinely common and easy-to-miss class of bug in
any layered system, and it's worth teaching explicitly: verify the plumbing
before re-deriving the physics.
8. A chart earns its keep the moment it turns a verbal argument into
something checkable. Every "the angle is chattering because of noise" or
"it's holding because authority collapsed" explanation in Sections 8.4–8.5
was, at the time, a claim backed by log-reading, not something the person
being told could independently verify. Once the diagnostics chart existed,
those same claims became visible facts, and — more importantly — it caught a
real bug (Kd's oscillation, Section 8.4.10) that had gone unnoticed through
an algorithm considered already finished. Build the visualization before
debugging the second algorithm, not after the third — see Section 8.6.3
and Section 8.9 for what a future revision of this chapter's development
process should do differently.
9. A textbook control term isn't owed a place in the design just because
theory says it should help. Kd was implemented correctly, justified by a
real, observed oscillation (Section 8.4.8), and then removed once
chart-driven isolation testing (Section 8.4.10) showed the underlying P+I
system had no overshoot problem for it to solve — only noise for it to
amplify. Rigorously testing a component and disabling it with data is a
stronger engineering result than tuning it until the numbers look
acceptable.
8.8 Algorithm Comparison Summary
| Bang-Bang | PID (live-gain, Kd disabled) |
Predictive (bisection) | |
|---|---|---|---|
| Tuning required | None | Derived from physics + hardware specs | None (direct solve); solver tolerance chosen empirically (8.5.4) |
| Smooth approach to target | No — two states only | Yes | Yes, once quantization (8.5.3) and authority-collapse (8.5.5) were fixed |
| Precision (5-run apogee spread, 228m target) | Hits target but with abrupt transitions | 227.85–227.97m (Kd=0, Section 8.4.10) |
227.55–228.22m (Section 8.5.6) |
| Handles collapsing control authority near apogee | Naturally (all-or-nothing) | Explicit achievability gating (8.4.6) | Explicit authority-collapse guard (8.5.6) — same underlying bracket calc |
| Numerically fragile near singularities | No | Yes — live gain requires margins/caps (8.4.3–8.4.4) | Yes, differently — early-exit bisection is quantized, not singular (8.5.3) |
| Actuator-realistic | Requires rate limiting to be added | Rate limiting built in | Rate limiting built in |
| Complexity / moving parts | Minimal | High — gain scheduling, windup guard, rate limit, (disabled) derivative filter | Moderate — bisection solver, tolerance, authority guard; no gain scheduling |
All three algorithms now also share a single ACHIEVABILITY classification
(REACHABLE / UNREACHABLE_HIGH / UNREACHABLE_LOW), computed generically
from the same min/max-angle apogee bracket regardless of which controller is
active — see Section 8.9.
8.9 Next Steps
- ~~Build and validate the predictive/model-based controller.~~ Done — Section 8.5.
- ~~Fill in Section 8.5 with its derivation, strengths, and weaknesses.~~ Done.
- ~~Update the comparison table with real numbers.~~ Done — Section 8.8.
- ~~Consider whether the achievability-window concept generalizes to
predictive.~~ Done, and further generalized:
classifyAchievability()now runs generically for all three algorithms (not just predictive), producing a sharedUNREACHABLE_HIGH/UNREACHABLE_LOW/REACHABLEflag logged and displayed for every frame regardless of which controller is active. - New: a dedicated chapter — probably its own Part, not just a subsection
here — on how each algorithm was actually built up, step by step, told
through the diagnostics chart rather than through prose description of log
output. This chapter (Part 8, as currently structured) already narrates
that progression in words: naive attempt → chart/log shows a specific
failure → one targeted fix → re-test → repeat until the failure mode
stops appearing. That structure is good. What it's missing is the charts
themselves for most of it, because they didn't exist yet for Bang-Bang and
most of PID's development — they were only built partway through
Predictive (Section 8.6), and by the time they existed, several of
Predictive's own failures (Sections 8.5.2–8.5.6) had already been
diagnosed the slow way, by reading logs. Once the chart existed, the same
"before / after" story became something a reader could see rather than
take on faith from a description — compare how quickly Section 8.4.10's
Kdfinding was established once charted, versus how many paragraphs Sections 8.5.2–8.5.3 needed to explain a chatter pattern in words. The explicit note prompting this item: "this would have been a great teaching tool — would have helped visualize what you were trying to tell me verbally." For the book, this means: regenerate (or reconstruct approximately) a naive/broken-version chart for Bang-Bang and early PID where practical, so the whole chapter can show the not-working → iterate → working progression visually, algorithm by algorithm, rather than only Predictive and theKdretrofit having real before/after charts. - Consider whether this chapter should be split into three sub-sections (or three short chapters) under one "Part 8" umbrella now that all three algorithms are final — the charting section (8.6) argues for keeping them together, since its main point is a cross-algorithm one.