Skip to content

PID V&V Plan

This plan takes Pid to grade A under the building block standard. It lists where the controller stands against each requirement, the animations that illustrate its checks, the evidence ledger, and a sequence of PRs.

Scope. Pid is a discrete algorithm: its control law is its specification, so there is no continuous model whose discretization it has to converge to. Verification checks that it computes that law, holds its invariants, and behaves as its contracts say in closed loop. Whether its gains suit a vehicle is the business of the loops built from it, the cascade above all, and their V&V.

Consumers. The cascade’s rate loop (three Pids at 1 kHz, output in rad/s²) and its velocity loop (three Pids, output in m/s²), in racing-drone’s B0 baseline.

Assessment on 2026-10-27, against commit aaafbb9 with M1’s tests added.

ReqStatusEvidence today / gap
S1 spec✅Control law, data model, I1–I4, and pre/postconditions on every operation in pid.hpp
S2 envelope◐The non-goals are implicit: no setpoint weighting, feed-forward, gain scheduling, or bumpless transfer. Not written down as an envelope
S3 parameters✅pid_config_valid rejects each violation; units on every field. The defaults are a neutral normalized command, not tuned gains; tuned gains come from cascade_config_from(), traced to RP-1
S4 scalar types◐The control law and a closed loop run in double, float, posit<32,2>, posit<16,1>, and cfloat<32,8>, with tolerances by precision (tests/sdk/pid.cpp). The minimum precision is not stated
S5 resource✅noexcept, no allocation, constant time, stated in the header; constexpr use tested. A fixed operation count per update, 10 or 12 unfiltered and 15 or 17 filtered (unprimed or primed), and none on a rejected dt, over 2000 generated updates that reach both output limits, both integral bounds, and an anti-windup freeze in each direction (tests/sdk/pid_resource.cpp)
T1 unit✅Each term, each limit, anti-windup in both directions, external saturation, the derivative filter, reset, the dt fault; three closed-loop tests. The validator rejects a negative k_p, k_i, k_d, or τ, and either limit pair inverted
T2 matrix✅GCC/Clang Debug+Release, MSVC
T3 sanitizers❌No sanitizer preset
T4 properties❌—
T5 fuzzing❌—
T6 coverage❌Not measured
T7 mutation❌—
V1 analytic◐The P and I terms, the filter’s first step and its steady state, by closed form. Closed-loop offsets checked against closed form only in the trace scenarios (below), not in the test suite
V2 orderN/ANo discretization of a continuous law: the discrete law is the specification
V3 structure✅The invariants I2–I4 (the integral and the output within their bounds, an unprimed rate of zero) are asserted after every update in Debug builds
V4 metamorphic❌Sign symmetry (negating the setpoint and the measurement negates the output, with symmetric limits) and gain scaling are not tested
V5 derivativesN/ANo consumer linearizes Pid
V6 code-to-code❌No comparison with an independent PID, such as PX4’s PID library
V7 stabilityN/AThe contract has no stability bound; loop stability belongs to the loops built from Pid
C1–C5◐bench/pid_bench gives ns per update on x86; nothing else
D1–D3◐Six trace scenarios in tools/trace/scenarios/pid/, regenerated and compared in CI (ANI-3); no scenario runner, monitor, or MCAP
L1–L5N/APid has no physical counterpart. The closed-loop validation of its gains is the cascade’s (BB-I3)
BB-I1–I6◐Closed-loop tests on simple plants, and the cascade’s closed-loop tests on the quadrotor model. No sim/ harness
R1✅release-please semver
R4◐The committed traces pin six scenarios’ numerical output, and CI fails when one changes; no golden test in tests/
R2, R3❌—

Grade today: C. S1, S3, S5, T1, and T2 are met, with bench/pid_bench as the benchmark. B needs S2, S4, T3, T4, V1–V4, V7, and R4; V7 is N/A here.

These animations illustrate two of the requirements above. Each plays a trace from a scenario in tools/trace/scenarios/pid/, and each scenario computes the check shown under its player. They are not the evidence for the requirement (ANI-4): the tests are, and the status table follows them.

A PD loop on z¨=u+d\ddot z = u + d settles where the P term holds up the offset: e=−d/Kpe = -d/K_p, so z=r+d/Kpz = r + d/K_p. The P-only loop oscillates about the same point, undamped.

Does the PD loop settle at the closed-form offset? 1× real time
Height [m] P PD PID r + d/k_p
Height near the offset (zoomed) [m] PD r + d/k_p
t = 0.000 s / 10.000 s

Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed

Three copies of a height loop, z'' = u + d with d = −3 m/s², fly from 0 to a 1 m setpoint at 1 kHz for 10 s: P (k_p = 16), PD (k_d = 8), and PID (k_i = 8). The P run swings about 0.8125 m and never settles. The PD run settles on 0.8125 m = r + d/k_p (dashed) within about 2 s. The PID run's integral removes the offset. Pass criteria: the PD height at 10 s within 1 mm of r + d/k_p (BB-V1), and the PID height within 1 mm of the setpoint (BB-T1, the tolerance of the closed-loop test on this plant). The PD height lands within 10⁻¹³ m: the loop's slowest mode has decayed to roundoff.

  1. setpoint 1 m; the vehicle is 3 m/s² heavier than the controller believes
  • ✓ passBB-V1 pd_height_error_vs_closed_form_offset = 1.22e-14 m (<= 0.001), computed by the scenario harness
  • ✓ passBB-T1 pid_steady_state_error = 2.22e-4 m (<= 0.001), computed by the scenario harness

The closed-loop test anti-windup recovers quickly from an unreachable setpoint holds a first-order plant at its actuator limit for 2 s and then asks for a reachable setpoint. The scenario flies the same loop, beside a copy whose limits are set wide open, so that its anti-windup never engages.

Does the loop recover at once when the setpoint comes back within reach? 1× real time
Plant output anti-windup no anti-windup setpoint
Integral term anti-windup no anti-windup
t = 0.000 s / 8.000 s

Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed

Two copies of T·x' = −x + u (T = 0.1 s), with the actuator saturating at |u| ≤ 1, under PI control (k_p = 1, k_i = 10). The setpoint is 1.5, beyond reach, for 2 s, and then 0.5. The run with anti-windup parks its integral at 0.49 and comes within 0.02 of 0.5 0.09 s after the drop. The run without it winds its integral up to 11, and needs 2.40 s. Pass criterion, as in the test: the anti-windup run first comes within 0.02 (4 %) of 0.5 at most 0.2 s after the drop. The criterion measures recovery from saturation, not settling: the anti-windup run then dips to 0.43, the PI loop's own undershoot, and stays within 0.01 from 0.5 s.

  1. setpoint 1.5, beyond the actuator's reach
  2. setpoint 0.5, within reach
  3. anti-windup: within 0.02 of 0.5, 0.09 s after the drop
  4. no anti-windup: within 0.02 of 0.5, 2.40 s after the drop
  • ✓ passBB-T1 recovery_time_to_within_0.02 = 0.088 s (<= 0.2), computed by the scenario harness
ClaimEvidenceHarnessGate
Each term computes its part of the lawP, I, D, and filter cases with closed-form valuestests/sdk/pidPR
The output and the integral stay within their limitsSaturation and clamp cases; I2–I4 asserted after every updatetests/sdk/pidPR
Anti-windup freezes the integral only while it would wind upBoth directions, own and external saturationtests/sdk/pid, tests/sdk/cascadePR
A non-positive dt holds the output and stateContract case; trace replaytests/sdk/pid, pid/timing_faultPR
Zero steady-state error and bounded overshoot in closed loopFirst-order plant, double integrator with offsettests/sdk/pid_closed_loopPR
Recovery from saturation within 0.2 s, to within 0.02Closed-loop test; trace checktests/sdk/pid_closed_loop, pid/windupPR
Same law across scalar typesClosed loop within 1e-5 (32-bit types) and 1e-2 (posit<16,1>) of doubletests/sdk/pidPR
The traces match the codeRegenerated and compared under regenDocs traces CI jobPR
The live widgets run the same codeWasm against native under wasm, at the defaultsnpm run wasm-agreePR
A fixed operation count per updateCounted-type test, per filter and primed statetests/sdk/pid_resourcePR
Symmetry and scaling relations holdMetamorphic tests (M2)tests/verification/pid_metamorphicPR

The last row is planned.

#PR scopeRequirementsGrade after
M1 ✅tests/sdk/pid_resource.cpp: operation count per update with Counted, for each combination of the filter switch and the primed flag; validator cases for a negative k_i and k_dS5, T1C
M2Envelope and non-goals in the header; minimum precision stated; metamorphic tests; closed-loop offsets against closed form in the test suite; sanitizer presetS2, S4, V1, V4, T3—
M3Property tests: for generated configurations, inputs, and dt, I2–I4 and the output bound hold; golden traces in tests/T4, R4B
M4Fuzz target for update(); coverage report; comparison with PX4’s PID library on shared scenariosT5, T6, V6B
M5Datasheet: step and frequency response of the filtered derivative, numeric-type error budget, cost per type and compiler; scenario runner and monitorC1–C4, D1–D3B
M6Pid in the sim/ scenario suite through the cascadeBB-I1, BB-I2, BB-I4A

M1 is done, and gives Pid the grade the cascade built from it already has. M2 and M3 reach B. M6 shares the sim/ harness with the quadrotor model’s M9, so the two land together.