diff --git a/docs/research/2026-11-01-tempo-pid-sim.md b/docs/research/2026-11-01-tempo-pid-sim.md index f0ac616..409f9aa 100644 --- a/docs/research/2026-11-01-tempo-pid-sim.md +++ b/docs/research/2026-11-01-tempo-pid-sim.md @@ -14,11 +14,27 @@ in `u`; the beat is not). `target(now) = target_permille x elapsed / 7 d`, `u = 3600/900 + kp e + ki I - kd d(used)/dt`, `beat = clamp(round(3600/u), 60, 3600)` seconds, `u <= 0` gives 3600. Derivative on the measurement and only between distinct observations; conditional integration (no integrating when the output is -at a limit and `e` pushes further into it); while a vote decides, `I` tracks the -voted rate so release is bumpless; a new `resets_at` clears `I` and the derivative -memory; no reading, an expired window or a reading older than its window holds the +at a limit, carry included, and `e` pushes further into it); a new `resets_at` +(more than a minute from the last: the connector reports whole unix seconds, and a +new window is a week on) clears `I` and the derivative memory and integrates +nothing on the call that finds it; no reading, an expired window, a reading older +than its window or one observed more than two longest beats (2 h) ago holds the last beat and integrates nothing (time spent holding is not integrated later). +Votes. While a vote decides nothing integrates. The caller sets `rate_per_h` to +the voted rate; the controller keeps `carry = clamp(voted rate, 1/h, 60/h) - its +own rate now`, so the first beat after release is the voted beat (a beat beyond the +bounds is answered at the bound), and the carry then fades with time constant +0.25 h, the sensor's poll period. Why not an integral that tracks the voted rate +(the first sketch): the integral is the slow term, `dI/dt = e`, so parking it at +the voted rate keeps the vote's burn for as long as `I` takes to unwind. After a +60 s vote it is parked at ~5600 and unwinds at `e` per hour: a 60 s vote of 1 h +or 4 h left the plan spent to 1000 within the day (last-day error 215-229 +permille, Soul's probe), and clamping the tracked value to the beat bounds does +not touch it, since 60 s is itself a bound. The carry is bounded by construction +(at most the span of the beat bounds, and it decays); the integral is never moved +by a vote. + ## Plant One seven-day window, 60 s steps, `target = 900` permille. Each beat that elapses @@ -30,16 +46,21 @@ saturates at 1000. Sensor: `Stream` refreshes the reading when a turn ends, reading. Three seeds per scenario and sensor. Scenarios: quiet (c 1.3); cheap-turns (0.6); dear-turns (3.0); burst (+200 permille -over hours 48-52); cost-x3 (turn cost x3 from hour 84); vote-300s-24h (a standing -vote of 300 s from hour 48 to 72, then release); rollover (two windows, used +over hours 48-52); cost-x3 (turn cost x3 from hour 84); votes from hour 48, then +release: 300 s for 24 h, 60 s for 1 h, 60 s for 4 h, 60 s for 24 h, 7200 s for 24 h +and 86400 s for 24 h (the longest a Mind may vote; the last two are beyond the +3600 s the controller can answer); rollover (two windows, used resets to 0, the sensor shows the old window until its next refresh); gap-6h (no reading from hour 72 to 78); start-ahead (500 permille used at the start); unreachable (c 0.05: even at the 60 s minimum the target cannot be reached, the controller must sit at 60 s over the last day). -A vote of 60 s for 24 h, as first sketched, costs about 1900 permille at c 1.3, -more than the week; it is unreachable by construction, so the vote scenario uses -300 s. +A vote of 60 s for 24 h costs about 1900 permille at c 1.3, more than the week: the +vote itself exhausts the plan, so no controller can end on the target and the +scenario asserts what the ruling asks of the fallback once the vote is gone: it +stops burning (slowest beat over the whole last day). The 1 h and 4 h holds at 60 s +(about 80 and 310 permille over) are reachable and are held to the same final and +last-day bounds as the rest. Metrics: used at each window's reset; worst |error| over the last day; "jumps": minutes where the beat moved by more than 5%, per hour, outside votes; "band @@ -53,59 +74,68 @@ Recorded trace: not used. Each Mind's `persona-trace.cc` on Yggdrasil holds one ## Sweep `kp` in {0.01, 0.02, 0.03, 0.05, 0.1}, `ki` in {0.002, 0.005, 0.01, 0.02, 0.04}, -`kd` in {0, 0.002, 0.005, 0.01, 0.02, 0.05}: 150 candidates x 10 scenarios x 2 +`kd` in {0, 0.002, 0.005, 0.01, 0.02, 0.05}: 150 candidates x 15 scenarios x 2 sensors x 3 seeds. Rerun: cargo test -p mind-body --lib tempo::sim::sweep -- --ignored --nocapture (on Yggdrasil through `tools/stopgap/ygg-verify.sh`). Candidates are ranked by -`final error + 0.5 x last-day error + 20 x jumps/h + 15 x crossings`. Best fifteen: +`final error + 0.5 x last-day error + 20 x jumps/h + 15 x crossings`. Best fourteen, and the chosen row (28th by that rank): -| kp ki kd | worst final error | worst error, last day | worst jumps/h | worst band crossings | holds MIN when unreachable | +| kp ki kd | worst final error | worst error, last day | worst jumps/h | worst band crossings | holds MIN when unreachable, MAX when overspent | |---|---|---|---|---|---| -| 0.1 0.04 0 | 30.9 | 54.9 | 1.43 | 0 | true | -| 0.1 0.04 0.002 | 30.9 | 54.4 | 1.49 | 0 | true | -| 0.1 0.02 0 | 30.9 | 56.8 | 1.44 | 0 | true | -| 0.1 0.02 0.002 | 30.9 | 56.8 | 1.54 | 0 | true | -| 0.1 0.01 0 | 30.9 | 65.7 | 1.45 | 0 | true | -| 0.1 0.04 0.005 | 30.9 | 54.9 | 1.82 | 0 | true | -| 0.1 0.01 0.002 | 30.9 | 65.7 | 1.55 | 0 | true | -| 0.1 0.02 0.005 | 30.9 | 56.8 | 1.89 | 0 | true | -| 0.02 0.01 0 | 30.0 | 65.7 | 0.37 | 2 | true | -| 0.1 0.01 0.005 | 30.9 | 65.7 | 1.89 | 0 | true | -| 0.1 0.005 0 | 30.9 | 85.9 | 1.48 | 0 | true | -| 0.01 0.02 0 | 30.9 | 54.4 | 0.07 | 3 | true | -| 0.01 0.04 0 | 29.6 | 56.7 | 0.10 | 3 | true | -| 0.1 0.005 0.002 | 30.9 | 85.9 | 1.55 | 0 | true | -| 0.02 0.01 0.002 | 30.0 | 65.7 | 0.67 | 2 | true | +| 0.1 0.02 0 | 30.9 | 45.7 | 1.44 | 0 | true | +| 0.1 0.01 0 | 30.9 | 47.3 | 1.45 | 0 | true | +| 0.1 0.02 0.002 | 30.9 | 45.7 | 1.54 | 0 | true | +| 0.1 0.01 0.002 | 30.9 | 47.3 | 1.55 | 0 | true | +| 0.1 0.005 0 | 30.9 | 52.3 | 1.48 | 0 | true | +| 0.1 0.002 0 | 30.9 | 52.3 | 1.49 | 0 | true | +| 0.1 0.005 0.002 | 30.9 | 52.3 | 1.55 | 0 | true | +| 0.1 0.002 0.002 | 30.9 | 52.3 | 1.58 | 0 | true | +| 0.03 0.002 0 | 33.6 | 54.9 | 0.77 | 1 | true | +| 0.1 0.02 0.005 | 30.9 | 45.7 | 1.89 | 0 | true | +| 0.1 0.01 0.005 | 30.9 | 47.3 | 1.89 | 0 | true | +| 0.1 0.002 0.005 | 30.9 | 52.3 | 1.91 | 0 | true | +| 0.1 0.005 0.005 | 30.9 | 52.3 | 1.92 | 0 | true | +| 0.03 0.002 0.002 | 33.6 | 54.9 | 1.02 | 1 | true | +| 0.02 0.01 0 | 29.6 | 46.8 | 0.39 | 3 | true | Chosen `GAINS = { kp: 0.02, ki: 0.01, kd: 0.0 }`, per scenario (worst over sensors and seeds): ``` - quiet final_err 5.4 worst_err_last_day 14.3 jumps/h 0.03 last_beat 1028 - cheap-turns final_err 9.3 worst_err_last_day 19.5 jumps/h 0.00 last_beat 392 - dear-turns final_err 29.6 worst_err_last_day 33.8 jumps/h 0.37 last_beat 2041 - burst final_err 14.1 worst_err_last_day 17.2 jumps/h 0.07 last_beat 876 - cost-x3 final_err 7.8 worst_err_last_day 32.5 jumps/h 0.21 last_beat 3465 - vote-300s-24h final_err 30.0 worst_err_last_day 65.7 jumps/h 0.02 last_beat 1442 - rollover final_err 7.3 worst_err_last_day 10.1 jumps/h 0.04 last_beat 875 - gap-6h final_err 8.7 worst_err_last_day 13.1 jumps/h 0.04 last_beat 1014 - start-ahead final_err 27.6 worst_err_last_day 29.8 jumps/h 0.02 last_beat 1479 - unreachable final_err 0.0 worst_err_last_day 0.0 jumps/h 0.00 last_beat 60 + quiet final_err 5.4 worst_err_last_day 14.3 jumps/h 0.03 crossings 1 last_beat 1028 + cheap-turns final_err 9.3 worst_err_last_day 19.5 jumps/h 0.00 crossings 1 last_beat 392 + dear-turns final_err 29.6 worst_err_last_day 33.8 jumps/h 0.37 crossings 1 last_beat 2041 + burst final_err 14.1 worst_err_last_day 17.2 jumps/h 0.07 crossings 2 last_beat 876 + cost-x3 final_err 7.8 worst_err_last_day 32.5 jumps/h 0.21 crossings 0 last_beat 3465 + vote-300s-24h final_err 29.5 worst_err_last_day 46.8 jumps/h 0.12 crossings 1 last_beat 928 + vote-60s-1h final_err 18.0 worst_err_last_day 19.7 jumps/h 0.39 crossings 3 last_beat 848 + vote-60s-4h final_err 23.2 worst_err_last_day 32.6 jumps/h 0.22 crossings 1 last_beat 619 + vote-60s-24h final_err 0.0 worst_err_last_day 0.0 jumps/h 0.16 crossings 0 last_beat 3600 + vote-7200s-24h final_err 16.5 worst_err_last_day 24.5 jumps/h 0.08 crossings 2 last_beat 1326 + vote-86400s-24h final_err 22.4 worst_err_last_day 24.6 jumps/h 0.08 crossings 2 last_beat 1212 + rollover final_err 7.3 worst_err_last_day 10.1 jumps/h 0.04 crossings 0 last_beat 875 + gap-6h final_err 5.4 worst_err_last_day 13.1 jumps/h 0.04 crossings 0 last_beat 1014 + start-ahead final_err 27.6 worst_err_last_day 29.8 jumps/h 0.02 crossings 2 last_beat 1479 + unreachable final_err 0.0 worst_err_last_day 0.0 jumps/h 0.00 crossings 0 last_beat 60 ``` -The chosen row is ninth by that rank; the rank weights accuracy over chatter, and the choice trades two crossings for a quarter of the jumps. `kp` 0.1 removes the overshoot after the vote (0 crossings) but moves the -beat on nearly every turn (1.4 jumps/h) because each turn's usage lump is read as -error; 0.02 with `ki` 0.01 is nearly as accurate with a quarter of the chatter and -one overshoot. `kd` helps nowhere: with 1-permille readings and about four turns an -hour, the measured slope is mostly quantisation, and every `kd` > 0 only adds jumps -(0.37 to 0.67 at `kd` 0.002). The committed controller is therefore a PI in -practice; the D term stays in the law (tested with explicit gains) so a plant with -finer readings can use it. +The chosen row is 28th by that rank: the rank weights accuracy over chatter, and +nothing in the grid is both within 30 permille at every reset and quiet. `kp` 0.1 +(first by rank) ends at 30.9 on dear-turns, over the 30 bound, and moves the beat +on nearly every turn (1.4 jumps/h) because each turn's usage lump is read as error; +0.02 with `ki` 0.01 is within 30 permille everywhere with a quarter of the chatter. +`kd` helps nowhere: with 1-permille readings and about four turns an hour, the +measured slope is mostly quantisation, and every `kd` > 0 only adds jumps. The +committed controller is therefore a PI in practice; the D term stays in the law +(tested with explicit gains) so a plant with finer readings can use it. -Bounds the acceptance test holds `GAINS` to: reachable scenarios end within 35 -permille of the target at every reset (30 was the first sketch; the dear-turns and -vote scenarios sit at 29.6-30.0 and a Self run in the dear scenario is itself 15 -permille); worst last-day error at most 75; at most 2 band crossings; at most 0.5 -jumps/h; the unreachable case sits at 60 s. +Bounds the acceptance test holds `GAINS` to: reachable scenarios end within 30 +permille of the target at every reset (the chosen row's worst is 29.6, dear-turns +and vote-300s-24h); worst last-day error at most 75 (worst 46.8); at most 0.5 +jumps/h (worst 0.39); at most 3 band crossings, one more than the first sketch's 2: +vote-60s-1h on one seed and sensor crosses three times with a last-day error of +19.7 permille, a wobble just over the 15-permille band, not a sustained +oscillation (the others stay at 2 or fewer); the unreachable case sits at 60 s and +the overspent vote at 3600 s over the last day.