TVC · Linux timing and control simulation
Measured closed-loop control at 500 Hz
A C++20 controller and Python simulator for one-axis thrust-vector control, with deterministic acceptance tests and measured timing on bare-metal Linux.
Artifacts: View sourceRead the results
Measured on Linux · September 15, 2026
Deterministic simulation
400/400 Deterministic acceptance cases passed 200 seeds at each of two actuator delays. Figure 11
Abstract
TVC pairs a C++20 controller with a Python simulator for one-axis thrust-vector control over UDP. A Linux timing investigation traced delayed wakeups to timer placement on another CPU. Disabling timer migration reduced p99.9 wakeup jitter from 380 µs to 16.6 µs in 15,000-cycle diagnostic runs (Figure 6). Here p99.9 is the value that 999 of every 1,000 samples in a run stayed at or below; it is not the run’s maximum.
A separate closed-loop campaign on September 15, 2026 recorded median per-run p99.9 wakeup jitter of 18.0 µs across eight 300,000-cycle runs. Per-run p99.9 served sensor-to-actuator latency ranged from 401 to 433 µs. Served latency includes only cycles using fresh sensor data.
Separately, in deterministic lockstep simulation, all 400 predeclared synthetic acceptance cases passed, and a complete rebuild and rerun reproduced all 1,600 artifact files byte for byte. The results establish behavior on the tested simulation and machine configuration, not a hard real-time guarantee or validation on a physical vehicle.
Simulation output · recorded lockstep run S5-overgust, seed 1, D=0
1System design
TVC simulates how a vehicle controls its pitch by changing the direction of thrust. I built it to understand how control software behaves when communication and scheduling affect the inputs it receives.
TVC uses two closed-loop modes. Lockstep checks behavior in simulated time. Free-run measures execution on independent schedules. A separate timing harness provides the comparison workload.1
Figure 1. Two processes and two packets.
Schematic of the closed loop, drawn from simulation output: the recorded lockstep runs S5-overgust and S6-blackout (seed 1, actuator delay D=0), replayed when the page's script runs and otherwise held at S5-overgust tick 1173. The Python simulator, on the left, owns the plant, the sensor and the actuator FIFO; its stand draws the one-axis model it integrates. The C++20 controller, on the right, owns admission, the episode policy and PID. Each tube is a UDP link and each canister one datagram: sensor frames S[k] go up in blue and actuator commands A[k] come back solid. Orange marks a frame with no sample and the SENSOR_LOST end of an episode. Tick, θ, ω, δ and staleness are the run's recorded values at that tick and the dials settle on them; the gust torque comes from the scenario. Lockstep runs carry no wall-clock timing (a tick is 2 ms of simulated time), so only the order of events inside a tick is drawn. The record ring's fill and motion are schematic, and the timing harness is not drawn.
Each sensor frame carries the pitch angle θ and pitch rate ω for one tick. Each actuator command carries the requested nozzle angle δ, computed by PID control: a proportional, an integral and a derivative term on the pitch error. The controller writes one control record per cycle into a bounded ring that a separate drain thread empties, so recording never sits on the control path.
1.1Two ways to advance the loop
Lockstep. The simulator and controller finish one sensor-and-command exchange before the next simulation step. Each completed transaction advances simulated time by 2 ms. Retries resend cached responses without another controller update.
Free-run. The simulator and controller follow independent 500 Hz schedules. Each controller deadline starts a scheduled cycle. Packet arrival supplies observations; it does not create an extra controller update.
(a) Locksteptransaction order · 2 ms of simulated time per tick
(b) Free-runwall clock · one period is 2 ms
1.2Missing input
When fresh input stops arriving, the controller holds its previous command, then requests a neutral nozzle angle. Continued staleness ends the episode.
SENSOR_LOST at 21.SourceMissing-input policystaleness 0
Fresh. PID runs once.
staleness 1 to 8
Hold. The controller holds its previous command.
staleness 9 to 20
Neutral. δ = 0.
staleness 21
SENSOR_LOST. The episode ends.
2Method
On September 15, 2026, I ran eight interleaved repeats of three configurations on the qualified machine, meaning the documented hardware and software configuration that was tested (§2.2). Each run recorded 300,000 cycles after 5,000 warmup cycles at 500 Hz. All 24 runs completed with no missed deadlines, reruns or replacements.[3]
The configurations are levels on the project’s measurement ladder, L0 to L8, where each level adds one change to the one before. Table 2 describes the three used here.
| Level | Configuration | Role in the campaign |
|---|---|---|
| L5 | timing harness with telemetry disabled | gated against the August 29 reference and its limit |
| L7 | timing harness with control recording | paired with L8 in every repeat |
| L8 | free-run closed loop with control recording | the closed loop, gated on latency, coverage and integrity |
2.1Definitions
- Wakeup jitter
- Wakeup jitter is how late the control thread resumed after its deadline.[6]
- Served latency
- Served latency runs from the simulator’s sensor-frame timestamp to the controller’s local completion of the actuator-command send. Cycles that reuse a held command remain in the coverage denominator and are excluded from the served-latency distribution. Simulator receipt and actuator application are separate events.
- Served coverage
- Served coverage is the served count over the 300,000 recorded cycles; coast cycles held the previous command because no fresh frame had arrived, and count toward coverage only.
2.2Platform
Vehicle on CPU 7 under SCHED_FIFO 80 with locked memory and absolute deadlines; the L8 simulator peer pinned to CPU 11 under SCHED_OTHER, running scenario S1-hold at zero loss with the one-tick actuator FIFO and the 400 µs phase offset. Idle states were disabled on CPUs 6, 7 and 11 for the whole session; timer migration off; performance governor; kernel 7.0.0-30-generic on an HP ProBook 465 G11 (Ryzen 7 7735U).[7]2
2.3Acceptance criteria
Declared budgets: p99.9 budget 1,000 µs, maximum-latency budget 2,000 µs, served coverage at or above 99%.
| Criterion | Threshold | Observed | Outcome |
|---|---|---|---|
| L5 gate: median p99.9 wakeup jitter | ≤ 24.8 µs | 19.1 µs | met |
| Served latency p99.9, every L8 run | ≤ 1,000 µs | largest 432.9 µs | met |
| Maximum served latency, every L8 run | ≤ 2,000 µs | largest 484.9 µs | met |
| Served coverage, every L8 run | ≥ 99% | lowest 99.990% | met |
| Integrity checks, every L8 run | declared | passed | met |
| Median paired difference, L8 minus L7 | ≤ +2.0 µs | −0.32 µs | met |
3Timing results
3.1Wakeup jitter
The L5 median was 19.1 µs against a 16.5 µs historical reference and a 24.8 µs acceptance limit.3
3.2Sensor-to-actuator response
Across the eight closed-loop runs, served latency p99.9 ranged from 401.4 to 432.9 µs. The largest observed served latency was 484.9 µs. Every run passed the declared latency, coverage and integrity checks.4
(a) Against the declared budgets
to 484.9
(b) Per run
3.3The timing cost of closing the loop
Each repeat paired the control-recording harness with the free-run closed loop. All eight L8 minus L7 differences in p99.9 wakeup jitter were negative. The median paired difference was −0.32 µs, within the predeclared +2.0 µs acceptance margin.5
Each pair compares run-level percentiles. Cycle samples were not pooled across runs.
The comparison is scoped to this campaign: the closed loop’s wakeup tail did not exceed the harness’s on the same machine and afternoon. It says nothing general about networking or feedback making software faster, and no cause is assigned to the consistent negative direction.
Notes
- The reference session disabled idle states on two CPUs; this session used three.
- One run, L8.r4, sits about 22 µs above the other seven at p50 and 25 µs at p99.9 with its own wake and compute figures unchanged, consistent with a peer-side shift; no cause is assigned.
- The sign test on the eight paired differences (8 negative, 0 positive, two-sided p = 0.0078) is supplemental and decides nothing; the median difference is the rule.
4The timing investigation
4.1Why pinning the thread did not pin its wakeup timer
Pinning the control thread did not always keep its wakeup timer on the same CPU. The loop sleeps with clock_nanosleep, which arms an unpinned kernel timer, and with timer migration enabled the kernel could move that timer to another CPU: six samples of /proc/timer_list found it on CPU 4 twice and on CPU 7 once. A wakeup delivered from another CPU then waited on that CPU’s exit from idle.
In a 15,000-cycle diagnostic pair, disabling timer migration reduced p99.9 wakeup jitter from 380.415 to 16.591 µs.
4.2Historical timing-harness campaign
The subsequent August campaign measured a 16.5 µs median p99.9 at L5 across three ten-minute runs. Those results describe the earlier timing harness and its stand-in workload.6
4.3Aligning the simulator and controller
The first free-run calibration exposed a scheduling mismatch: the measured response included an extra simulation period. I shifted the simulator’s schedule and kept the intended one-tick control delay in the actuator FIFO.[8]
A second calibration showed that simulator scheduling still limited coverage. The final configuration placed the simulator on a declared housekeeping CPU with idle states disabled.
SourceSimulator peer CPU
4.4Selecting the phase offset
The final calibration compared 200, 400 and 800 µs offsets in three interleaved runs each. Every 200 µs run exceeded the 2,000 µs maximum-latency limit. Both 400 and 800 µs passed. The predeclared rule selected 400 µs as the smallest qualifying offset.[4]7
The selected configuration was then evaluated in the separate 24-run qualified campaign.
Notes
- HP ProBook 465 G11, Ryzen 7 7735U, Ubuntu kernel 7.0.0-30-generic. Timer migration was disabled; idle states were disabled on CPUs 6 and 7. All nine pinned runs recorded no deadline misses; all three telemetry runs reported zero ring drops.
- The two earlier calibration attempts on September 14 stay retained on the measurement machine and are not committed, so they are not drawn here.
5Controller behavior
The recorded runs show two different responses: recovery from a gust that saturates the nozzle command, and termination after sensor input remains unavailable. Recorded lockstep runs: a gust response (S5-overgust) and a sensor blackout (S6-blackout), seed 1, actuator delay D=0. These are simulation outputs, separate from the Linux timing measurements.8
1327–1573
| Tick | t, s | Event | θ, rad | δ, rad |
|---|---|---|---|---|
| 1000 | 2.000 | Gust torque 4.05 N·m begins | +0.0000 | +0.0000 |
| 1023 | 2.046 | δ saturates at +0.12 rad for 217 ticks | +0.0092 | +0.1200 |
| 1100 | 2.200 | Gust torque ends | +0.0937 | +0.1200 |
| 1173 | 2.346 | Peak pitch, 0.1514 rad (8.67°) | +0.1514 | +0.1200 |
| 1574 | 3.148 | |θ| stays inside 0.005 rad from here | −0.0050 | −0.0004 |
| 1999 | 3.998 | Excerpt ends; the run continues to tick 4999 | −0.0002 | +0.0000 |
| Tick | t, s | Event | θ, rad | δ, rad |
|---|---|---|---|---|
| 1000 | 2.000 | Uplink blackout begins; staleness 1, policy holds the last δ | +0.0000 | +0.0000 |
| 1008 | 2.016 | Staleness 9, policy commands δ = 0 (neutral) | +0.0000 | +0.0000 |
| 1020 | 2.040 | Staleness 21, episode ends SENSOR_LOST | +0.0000 | +0.0000 |
SENSOR_LOST at 21. S6 is the complete run.SourcesScenario S6-blackout · Missing-input policyNotes
- The page’s copy of both runs was extracted at commit a3a2d105; the golden CSVs and scenario files are byte-identical at v0.2b.
6Deterministic acceptance
The S2-gust scenario starts from a 0.05 rad pitch angle while rotating at 0.5 rad/s and declares three gusts. Its campaign covered 200 seeds at each of two actuator delays, with 30% modeled loss in each direction over 10,000 ticks per case. All 400 cases passed the declared checks for termination, peak angle, settling, logical cadence and finite values.[5]
ScenarioS2-gust, three gusts
Seeds200 per delay
Modeled loss30% each direction
Length10,000 ticks per case
| Declared check | Delay 0 | Delay 1 |
|---|---|---|
| Termination | 200/200 | 200/200 |
| Peak angle | 200/200 | 200/200 |
| Settling | 200/200 | 200/200 |
| Logical cadence | 200/200 | 200/200 |
| Finite values | 200/200 | 200/200 |
| All five checks | 200/200 | 200/200 |
1,600/1,600retained artifact files reproduced by a complete rebuild and rerun
Byte for bytethe committed campaign manifest, reproduced by the final release run
400/400cases also passed by the Python reference
The final release run reproduced the committed campaign manifest byte for byte, and a complete rebuild and rerun reproduced all 1,600 retained artifact files. The Python reference also passed all 400 cases. Because it shares the plant model, that agreement checks implementation behavior without independently validating the physics.
Controlled stalls and packet bursts test bounded receive work, cycle accounting, future-frame storage and shutdown. Recording audits reconcile the reported measurements with the events that produced them. The separate bare-metal campaign establishes the timing results shown above.
SourcesScheduling diagnostics · Runtime and terminal tests · Recording audit
7Limitations
What the evidence covers
- The timing results cover the documented Linux configuration and the zero-loss S1-hold workload, which holds attitude from a 0.02 rad starting pitch angle with no gusts.
- The physical model covers one rotational axis with ideal sensors and an idealized actuator.
- The deterministic acceptance results cover the declared synthetic loss matrix.
Not established
- A hard real-time bound has not been established.
- TVC has not been flight-tested.
- Long-duration free-run operation under loss remains unmeasured.
8Artifacts
Release v0.2b includes the implementation, acceptance manifests, compact measurement baselines and methodology.
- 1Source at v0.2bCommit 3d3854c6, the publication of the released evidence.
- 2Release notesWhat changed and the evidence boundary.
- 3Qualified campaign24 runs, per-run summaries, gate outputs and the hash inventory of the raw recordings.
- 4Phase calibrationNine runs and the predeclared selection rule.
- 5Deterministic acceptanceThe 400-case manifest with artifact digests.
- 6Measurement methodologyWhat wakeup jitter and served latency mean and how the campaign was run.
- 7Platform configurationThe machine, kernel, isolation and discipline every number comes from.
- 8Phase-alignment decisionWhere the one-tick control delay lives in free-run, and why.
The repository contains compact evidence and artifact inventories. Full raw recordings are retained separately. This page uses the recorded results; no campaign was rerun for the website. Historical measurements stay pinned to commits that carry them unchanged: measurement code 0fafb7c, publication 851d61c.
APer-run values
| Repeat | L5 | L7 | L8 | L8 minus L7 |
|---|---|---|---|---|
| r1 | 19.583 | 18.159 | 17.983 | −0.176 |
| r2 | 19.567 | 18.207 | 17.823 | −0.384 |
| r3 | 19.231 | 18.431 | 17.807 | −0.624 |
| r4 | 19.007 | 18.335 | 18.079 | −0.256 |
| r5 | 18.751 | 18.127 | 18.079 | −0.048 |
| r6 | 19.119 | 18.287 | 17.887 | −0.400 |
| r7 | 19.087 | 18.287 | 18.127 | −0.160 |
| r8 | 19.087 | 18.559 | 17.935 | −0.624 |
| Median | 19.1 | 18.3 | 18.0 | −0.32 |
| Run | p50 | p99.9 | Maximum | Coverage | Coast |
|---|---|---|---|---|---|
| L8.r1 | 396.543 | 407.551 | 455.679 | 0.99997 | 9 |
| L8.r2 | 401.151 | 409.087 | 452.351 | 0.99996 | 13 |
| L8.r3 | 401.919 | 410.367 | 452.607 | 0.99995 | 16 |
| L8.r4 | 419.327 | 432.895 | 475.135 | 0.99991 | 27 |
| L8.r5 | 396.543 | 403.967 | 455.423 | 0.99990 | 30 |
| L8.r6 | 394.751 | 401.407 | 484.863 | 0.99993 | 22 |
| L8.r7 | 400.639 | 408.063 | 473.599 | 0.99992 | 23 |
| L8.r8 | 397.567 | 404.223 | 465.663 | 0.99994 | 17 |
| Level | Adds to the previous level | R1 | R2 | R3 | Median |
|---|---|---|---|---|---|
| L1 | Absolute deadlines | 402.175 | 331.263 | 323.071 | 331.3 |
| L2 | Lock and prefault memory | 93.695 | 407.551 | 408.063 | 407.6 |
| L3 | FIFO priority 80 | 8.575 | 400.639 | 430.847 | 400.6 |
| L4 | Pin thread to CPU 7 | 15.991 | 16.495 | 16.367 | 16.4 |
| L5 | Remove allocating log | 16.351 | 16.543 | 16.831 | 16.5 |
| L6 | Enable telemetry | 17.615 | 17.263 | 16.655 | 17.3 |
| Timer migration | p99.9 jitter | Maximum |
|---|---|---|
| Enabled | 380.415 µs | 893.951 µs |
| Disabled | 16.591 µs | 27.151 µs |
| Run | p50 | p99.9 | Maximum | Coverage | Coast | Verdict |
|---|---|---|---|---|---|---|
| 200 µs, r1 | 201.215 | 207.743 | 2146.3 | 0.99965 | 104 | fails: maximum above 2,000 µs |
| 200 µs, r2 | 199.423 | 206.335 | 2103.3 | 0.99980 | 60 | fails: maximum above 2,000 µs |
| 200 µs, r3 | 198.399 | 207.359 | 2132.0 | 0.99982 | 54 | fails: maximum above 2,000 µs |
| 400 µs, r1 | 496.383 | 503.551 | 549.9 | 0.99997 | 8 | passes |
| 400 µs, r2 | 394.751 | 403.199 | 454.9 | 0.99995 | 14 | passes |
| 400 µs, r3 | 399.615 | 407.039 | 446.5 | 0.99997 | 9 | passes |
| 800 µs, r1 | 803.327 | 811.519 | 872.4 | 0.99996 | 11 | passes |
| 800 µs, r2 | 801.279 | 811.519 | 845.8 | 0.99997 | 9 | passes |
| 800 µs, r3 | 798.719 | 808.447 | 870.9 | 0.99996 | 11 | passes |