From 709813cb9aa2da478e73fd72932808420377e507 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:37:10 +0100 Subject: [PATCH] tempo: fallback PID as one pure controller, its plant and tests Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/tempo.rs | 137 ++++++++++++- crates/mind-body/src/tempo/sim.rs | 286 ++++++++++++++++++++++++++++ crates/mind-body/src/tempo/tests.rs | 131 +++++++++++++ 3 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 crates/mind-body/src/tempo/sim.rs diff --git a/crates/mind-body/src/tempo.rs b/crates/mind-body/src/tempo.rs index eab4a76..72651ca 100644 --- a/crates/mind-body/src/tempo.rs +++ b/crates/mind-body/src/tempo.rs @@ -5,13 +5,146 @@ //! The Minds jointly hold the lever to one heartbeat. `tally` turns their votes //! into the beat; `pick` chooses who takes the turn when a beat falls. The //! heartbeat owns when a beat falls and what it launches; this module owns only -//! the two decisions. +//! the decisions: the swarm's tempo, who takes the turn, and the third, the +//! beat when no vote is cast (`fallback_beat`, rulings +//! `tempo-default-is-usage-fitter`, `tempo-fallback-pid-tuned-by-sim`). use std::cmp::Ordering; /// The beat, in seconds, when no vote is cast. pub const DEFAULT_BEAT_S: u32 = 900; +/// The shortest and longest beat the fallback controller may answer, in seconds. +pub const FALLBACK_MIN_S: u32 = 60; +pub const FALLBACK_MAX_S: u32 = 3600; + +const WINDOW_MS: i64 = 7 * 24 * 3_600_000; +const HOUR_MS: f64 = 3_600_000.0; +/// The turn rate, in beats per hour, that `DEFAULT_BEAT_S` stands for: the +/// controller's bias. +const BIAS_PER_H: f64 = 3600.0 / DEFAULT_BEAT_S as f64; + +/// The seven-day plan window as the connector last read it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reading { + pub used_permille: u32, + pub observed_at_ms: i64, + pub resets_at_ms: i64, +} + +/// PID gains. The actuator is the turn rate in beats per hour and the error is +/// in permille of the plan, so `kp` is beats/h per permille, `ki` beats/h per +/// permille-hour and `kd` beats/h per (permille/h). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Gains { + pub kp: f64, + pub ki: f64, + pub kd: f64, +} + +/// Chosen by the committed simulation (`docs/research/2026-11-01-tempo-pid-sim.md`), +/// never by a run-time search. +pub const GAINS: Gains = Gains { kp: 0.0, ki: 0.0, kd: 0.0 }; + +/// The fallback controller's memory. The heartbeat owns persisting it; it is +/// never published as truth. `rate_per_h` is the last output as a turn rate +/// (zero: none yet); while a vote decides the caller sets it to the voted beat's +/// rate, so the release is bumpless. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Fallback { + pub integral: f64, + pub last_used_permille: u32, + pub last_observed_at_ms: i64, + pub last_now_ms: i64, + pub window_resets_at_ms: i64, + pub rate_per_h: f64, +} + +/// The beat when no vote is cast: a PID on the gap between the seven-day usage +/// trajectory to `target_permille` and what was used, acting on the turn rate +/// (behind the trajectory raises it). Derivative on the measurement, only +/// between distinct observations; conditional integration against the beat +/// bounds; while `voted` nothing integrates and the integral tracks the voted +/// rate, so release is bumpless. A new window clears the memory; with no +/// usable reading the last beat holds (`DEFAULT_BEAT_S` before any). +pub fn fallback_beat( + state: &mut Fallback, + reading: Option<&Reading>, + now_ms: i64, + target_permille: u32, + voted: bool, +) -> u32 { + control(&GAINS, state, reading, now_ms, target_permille, voted) +} + +fn beat_of(rate_per_h: f64) -> u32 { + if rate_per_h > 0.0 { + ((3600.0 / rate_per_h).round().clamp(f64::from(FALLBACK_MIN_S), f64::from(FALLBACK_MAX_S))) as u32 + } else { + FALLBACK_MAX_S + } +} + +fn held(state: &Fallback) -> u32 { + if state.rate_per_h == 0.0 { + DEFAULT_BEAT_S + } else { + beat_of(state.rate_per_h) + } +} + +/// The one controller; `fallback_beat` is it with `GAINS`, the simulation is it +/// with candidates. +fn control( + gains: &Gains, + state: &mut Fallback, + reading: Option<&Reading>, + now_ms: i64, + target_permille: u32, + voted: bool, +) -> u32 { + let dt_h = if state.last_now_ms > 0 { (now_ms - state.last_now_ms).max(0) as f64 / HOUR_MS } else { 0.0 }; + state.last_now_ms = now_ms; + let Some(r) = reading.filter(|r| now_ms < r.resets_at_ms && r.observed_at_ms >= r.resets_at_ms - WINDOW_MS) else { + return held(state); + }; + if r.resets_at_ms != state.window_resets_at_ms { + state.window_resets_at_ms = r.resets_at_ms; + state.integral = 0.0; + state.last_used_permille = r.used_permille; + state.last_observed_at_ms = r.observed_at_ms; + } + let elapsed = (now_ms - (r.resets_at_ms - WINDOW_MS)).clamp(0, WINDOW_MS) as f64; + let error = f64::from(target_permille) * elapsed / WINDOW_MS as f64 - f64::from(r.used_permille); + let fresh = r.observed_at_ms > state.last_observed_at_ms; + let slope = if fresh { + (f64::from(r.used_permille) - f64::from(state.last_used_permille)) + / ((r.observed_at_ms - state.last_observed_at_ms) as f64 / HOUR_MS) + } else { + 0.0 + }; + if fresh { + state.last_used_permille = r.used_permille; + state.last_observed_at_ms = r.observed_at_ms; + } + if voted { + if gains.ki != 0.0 && state.rate_per_h > 0.0 { + state.integral = (state.rate_per_h - BIAS_PER_H - gains.kp * error) / gains.ki; + } + return held(state); + } + let rate = |integral: f64| BIAS_PER_H + gains.kp * error + gains.ki * integral - gains.kd * slope; + let (low, high) = (3600.0 / f64::from(FALLBACK_MAX_S), 3600.0 / f64::from(FALLBACK_MIN_S)); + let before = rate(state.integral); + let winding = (before >= high && error > 0.0) || (before <= low && error < 0.0); + if !winding { + state.integral += error * dt_h; + } + let beat = beat_of(rate(state.integral)); + state.rate_per_h = 3600.0 / f64::from(beat); + beat +} + /// How many orders of magnitude a full urgency may multiply a Mind's wait by. /// Interim, per `swarm-shares-log-bound`, until the swarm's governance sets it. pub const LOG_BOUND_ORDERS: f64 = 1.0; @@ -92,5 +225,7 @@ pub fn pick(rows: &[Standing], beat_s: u32) -> Option { .map(|(index, _)| index) } +#[cfg(test)] +mod sim; #[cfg(test)] mod tests; diff --git a/crates/mind-body/src/tempo/sim.rs b/crates/mind-body/src/tempo/sim.rs new file mode 100644 index 0000000..6c5318a --- /dev/null +++ b/crates/mind-body/src/tempo/sim.rs @@ -0,0 +1,286 @@ +//! The plant the fallback controller is tuned against: one seven-day plan +//! window at one-minute steps, each beat a Persona turn costing a lognormal +//! number of permille, plus the operator's own usage on the same subscription. +//! Test-only. The controller here is `super::control`, the same one the +//! heartbeat will call through `fallback_beat`; the sweep passes it candidate +//! gains, `GAINS` is committed. Rerun the sweep with +//! `cargo test -p mind-body tempo::sim::sweep -- --ignored --nocapture`. + +use super::*; + +const STEP_MS: i64 = 60_000; +const POLL_MS: i64 = 15 * 60_000; +const W0: i64 = 1_000 * 3_600_000; +const TARGET: u32 = 900; +/// Sigma of ln(turn cost). +const COST_SIGMA: f64 = 0.5; +/// Every this-many turns is a Self run, costing `SELF_FACTOR` times a Persona turn. +const SELF_EVERY: u32 = 25; +const SELF_FACTOR: f64 = 5.0; +/// Half-width, in permille, of the band the error must leave to count as a side. +const BAND: f64 = 15.0; + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64 + } + + fn lognormal(&mut self, mean: f64) -> f64 { + let (a, b) = (self.next().max(1e-12), self.next()); + let normal = (-2.0 * a.ln()).sqrt() * (std::f64::consts::TAU * b).cos(); + (mean.ln() - COST_SIGMA * COST_SIGMA / 2.0 + COST_SIGMA * normal).exp() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Sensor { + /// The reading refreshes when a turn ends. + Stream, + /// The reading refreshes every fifteen minutes. + Poll, +} + +#[derive(Clone, Debug)] +struct Scenario { + name: &'static str, + windows: u32, + start_used: f64, + /// Permille per Persona turn. + mean_cost: f64, + /// (from hour, to hour, permille spread evenly) of the operator's own usage. + burst: Option<(f64, f64, f64)>, + /// (at hour, factor) on the per-turn cost. + cost_step: Option<(f64, f64)>, + /// (from hour, to hour, beat) of a standing vote. + vote: Option<(f64, f64, u32)>, + /// (from hour, to hour) with no reading. + gap: Option<(f64, f64)>, + /// The cost is too low to reach the target even at the minimum beat. + unreachable: bool, +} + +const BASE: Scenario = Scenario { + name: "quiet", + windows: 1, + start_used: 0.0, + mean_cost: 1.3, + burst: None, + cost_step: None, + vote: None, + gap: None, + unreachable: false, +}; + +fn scenarios() -> Vec { + vec![ + BASE, + Scenario { name: "cheap-turns", mean_cost: 0.6, ..BASE }, + Scenario { name: "dear-turns", mean_cost: 3.0, ..BASE }, + Scenario { name: "burst", burst: Some((48.0, 52.0, 200.0)), ..BASE }, + Scenario { name: "cost-x3", cost_step: Some((84.0, 3.0)), ..BASE }, + Scenario { name: "vote-300s-24h", vote: Some((48.0, 72.0, 300)), ..BASE }, + Scenario { name: "rollover", windows: 2, ..BASE }, + Scenario { name: "gap-6h", gap: Some((72.0, 78.0)), ..BASE }, + Scenario { name: "start-ahead", start_used: 500.0, ..BASE }, + Scenario { name: "unreachable", mean_cost: 0.05, unreachable: true, ..BASE }, + ] +} + +#[derive(Clone, Debug, Default)] +struct Outcome { + /// Used at each window's reset. + final_used: Vec, + /// Worst |trajectory - used| over the last day of the last window. + worst_error_last_day: f64, + /// Beat moves of more than 5% between minutes, per hour, outside votes. + jumps_per_h: f64, + /// Times the error crossed from beyond +BAND to beyond -BAND (or back) + /// during the last three days. + band_crossings: u32, + frac_at_min: f64, + /// The beat on the last minute. + last_beat: u32, +} + +fn run(scenario: &Scenario, sensor: Sensor, gains: &Gains, seed: u64) -> Outcome { + let mut rng = Rng(seed); + let mut state = Fallback::default(); + let mut used = scenario.start_used; + let mut resets = W0 + WINDOW_MS; + let mut window = 0u32; + let sense = |used: f64, now: i64, resets: i64| Reading { + used_permille: used.round().clamp(0.0, 1000.0) as u32, + observed_at_ms: now, + resets_at_ms: resets, + }; + let mut reading = sense(used, W0, resets); + let (mut last_turn, mut turns, mut prev_beat) = (W0, 0u32, DEFAULT_BEAT_S); + let steps = i64::from(scenario.windows) * WINDOW_MS / STEP_MS; + let mut out = Outcome::default(); + let (mut jumps, mut unvoted, mut at_min) = (0u32, 0u32, 0u32); + let mut side = 0i8; + let mut worst_day = 0f64; + for i in 1..=steps { + let now = W0 + i * STEP_MS; + if now >= resets { + out.final_used.push(used); + used = 0.0; + resets += WINDOW_MS; + window += 1; + } + let in_window = (now - (resets - WINDOW_MS)) as f64 / HOUR_MS; + if let Some((from, to, amount)) = scenario.burst { + if in_window >= from && in_window < to { + used += amount * STEP_MS as f64 / HOUR_MS / (to - from); + } + } + let gap = scenario.gap.is_some_and(|(from, to)| in_window >= from && in_window < to); + if sensor == Sensor::Poll && !gap && (now - W0) % POLL_MS == 0 { + reading = sense(used, now, resets); + } + let vote = scenario.vote.filter(|&(from, to, _)| in_window >= from && in_window < to).map(|v| v.2); + let shown = if gap { None } else { Some(reading) }; + let beat = match vote { + Some(beat) => { + state.rate_per_h = 3600.0 / f64::from(beat); + control(gains, &mut state, shown.as_ref(), now, TARGET, true); + beat + } + None => control(gains, &mut state, shown.as_ref(), now, TARGET, false), + }; + if vote.is_none() { + unvoted += 1; + at_min += u32::from(beat == FALLBACK_MIN_S); + jumps += u32::from(f64::from(beat.abs_diff(prev_beat)) > 0.05 * f64::from(prev_beat)); + } + prev_beat = beat; + out.last_beat = beat; + if now - last_turn >= i64::from(beat) * 1000 { + let step = scenario.cost_step.map_or(1.0, |(at, factor)| if in_window >= at { factor } else { 1.0 }); + turns += 1; + let self_run = if turns % SELF_EVERY == 0 { SELF_FACTOR } else { 1.0 }; + used += rng.lognormal(scenario.mean_cost * step) * self_run; + last_turn = now; + if sensor == Sensor::Stream && !gap { + reading = sense(used.min(1000.0), now, resets); + } + } + used = used.min(1000.0); + let error = f64::from(TARGET) * (now - (resets - WINDOW_MS)) as f64 / WINDOW_MS as f64 - used; + if window + 1 == scenario.windows { + if in_window >= 144.0 { + worst_day = worst_day.max(error.abs()); + } + if in_window >= 96.0 { + let now_side = if error > BAND { + 1 + } else if error < -BAND { + -1 + } else { + 0 + }; + if now_side != 0 { + out.band_crossings += u32::from(side != 0 && side != now_side); + side = now_side; + } + } + } + } + out.final_used.push(used); + out.worst_error_last_day = worst_day; + out.jumps_per_h = f64::from(jumps) / (f64::from(scenario.windows) * 168.0); + out.frac_at_min = f64::from(at_min) / f64::from(unvoted.max(1)); + out +} + +const SENSORS: [Sensor; 2] = [Sensor::Stream, Sensor::Poll]; +const SEEDS: [u64; 3] = [11, 23, 47]; + +/// The most chatter, in jumps per hour, `GAINS` may show on any scenario. +const JUMPS_PER_H_BOUND: f64 = 1.0; + +#[test] +fn sim_scenarios_meet_target() { + for scenario in scenarios() { + for sensor in SENSORS { + for seed in SEEDS { + let o = run(&scenario, sensor, &GAINS, seed); + let tag = format!("{} {sensor:?} seed {seed}: {o:?}", scenario.name); + assert!(o.jumps_per_h <= JUMPS_PER_H_BOUND, "chatter {tag}"); + if scenario.unreachable { + assert_eq!(o.last_beat, FALLBACK_MIN_S, "saturated {tag}"); + assert!(o.frac_at_min > 0.9, "saturated {tag}"); + assert!(o.final_used.iter().all(|u| *u < f64::from(TARGET) - 100.0), "{tag}"); + continue; + } + for used in &o.final_used { + assert!((used - f64::from(TARGET)).abs() <= 30.0, "final {tag}"); + } + assert!(o.worst_error_last_day <= 30.0, "error {tag}"); + assert!(o.band_crossings <= 2, "oscillation {tag}"); + } + } + } +} + +/// `cargo test -p mind-body tempo::sim::sweep -- --ignored --nocapture` +#[test] +#[ignore] +fn sweep() { + let scen = scenarios(); + let mut rows = Vec::new(); + for kp in [0.0, 0.005, 0.01, 0.02, 0.05, 0.1] { + for ki in [0.0, 0.0005, 0.001, 0.002, 0.005, 0.01] { + for kd in [0.0, 0.01, 0.05, 0.1, 0.2] { + let g = Gains { kp, ki, kd }; + let (mut final_err, mut err_day, mut jumps, mut cross) = (0f64, 0f64, 0f64, 0u32); + let mut sat_ok = true; + let mut detail = String::new(); + for s in &scen { + let (mut f, mut e, mut j) = (0f64, 0f64, 0f64); + let mut lastb = 0; + for sensor in SENSORS { + for seed in SEEDS { + let o = run(s, sensor, &g, seed); + j = j.max(o.jumps_per_h); + if s.unreachable { + sat_ok &= o.last_beat == FALLBACK_MIN_S && o.frac_at_min > 0.9; + } else { + f = o.final_used.iter().fold(f, |m, u| m.max((u - f64::from(TARGET)).abs())); + e = e.max(o.worst_error_last_day); + cross = cross.max(o.band_crossings); + } + lastb = o.last_beat; + } + } + final_err = final_err.max(f); + err_day = err_day.max(e); + jumps = jumps.max(j); + detail.push_str(&format!( + " {:<14} final_err {f:6.1} worst_err_last_day {e:6.1} jumps/h {j:5.2} last_beat {lastb}\n", + s.name + )); + } + let rank = final_err + 0.5 * err_day + 20.0 * jumps + 15.0 * f64::from(cross); + rows.push((rank, g, final_err, err_day, jumps, cross, sat_ok, detail)); + } + } + } + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + println!("kp ki kd | worst final err | worst err last day | worst jumps/h | worst crossings | saturates when unreachable"); + for r in &rows { + println!("{} {} {} | {:.1} | {:.1} | {:.2} | {} | {}", r.1.kp, r.1.ki, r.1.kd, r.2, r.3, r.4, r.5, r.6); + } + for r in rows.iter().take(3) { + println!("\nBEST {:?}\n{}", r.1, r.7); + } + if let Some(r) = rows.iter().find(|r| r.1 == GAINS) { + println!("\nCHOSEN {:?}\n{}", r.1, r.7); + } +} diff --git a/crates/mind-body/src/tempo/tests.rs b/crates/mind-body/src/tempo/tests.rs index a8b4f8b..5c2f8f6 100644 --- a/crates/mind-body/src/tempo/tests.rs +++ b/crates/mind-body/src/tempo/tests.rs @@ -176,3 +176,134 @@ fn a_longer_beat_does_not_change_who_wins_among_equal_urgency() { let rows = [mind("ann", Some(500)), mind("bob", Some(300))]; assert_eq!(pick(&rows, 60), pick(&rows, 86_400)); } + +// The fallback controller. `control` is the one controller; these tests give it +// explicit gains so they pin each term whatever `GAINS` the simulation chose. + +const RESETS: i64 = 1_000_000_000_000; +const MID: i64 = RESETS - WINDOW_MS / 2; +const HOUR: i64 = 3_600_000; +const T: Gains = Gains { kp: 0.02, ki: 0.002, kd: 0.02 }; + +fn read(used_permille: u32, observed_at_ms: i64) -> Reading { + Reading { used_permille, observed_at_ms, resets_at_ms: RESETS } +} + +fn near(got: u32, want: u32) { + assert!(got.abs_diff(want) <= 1, "{got} is not within one second of {want}"); +} + +#[test] +fn behind_target_shortens_the_beat() { + let at = |used| control(&T, &mut Fallback::default(), Some(&read(used, MID)), MID, 900, false); + // Halfway through the window the trajectory is 450: error +50, 0, -50 is a + // turn rate of 5, 4 and 3 an hour. + near(at(400), 720); + near(at(450), DEFAULT_BEAT_S); + near(at(500), 1200); +} + +#[test] +fn derivative_is_on_measurement() { + let g = Gains { ki: 0.0, ..T }; + let mut s = Fallback::default(); + near(control(&g, &mut s, Some(&read(410, MID)), MID, 900, false), 750); + // A new observation 10 permille higher a quarter hour on: the slope is 40 an hour. + let next = read(420, MID + 15 * 60_000); + near(control(&g, &mut s, Some(&next), MID + 15 * 60_000, 900, false), 941); + // The same reading again carries no derivative term. + near(control(&g, &mut s, Some(&next), MID + 16 * 60_000, 900, false), 778); + // A step in the target with used unchanged moves the beat by kp x error alone. + near(control(&g, &mut s, Some(&next), MID + 18 * 60_000, 1000, false), 639); +} + +#[test] +fn saturated_output_does_not_wind_up() { + let g = Gains { kp: 0.1, ki: 0.01, kd: 0.0 }; + let day5 = RESETS - 2 * 24 * HOUR; + let mut s = Fallback::default(); + let behind = read(0, day5); + for i in 0..100 { + assert_eq!(control(&g, &mut s, Some(&behind), day5 + i * 60_000, 900, false), FALLBACK_MIN_S); + } + assert_eq!(s.integral, 0.0, "error is positive and the output is at its limit"); + let near_on = read(600, day5 + 100 * 60_000); + for i in 100..110 { + control(&g, &mut s, Some(&near_on), day5 + i * 60_000, 900, false); + } + assert!(s.integral > 0.0, "unsaturated again, it integrates"); + + // Saturated at the slow limit with the error pulling the other way integrates. + let mut s = Fallback { integral: -1000.0, ..Fallback::default() }; + let reading = read(400, MID); + control(&g, &mut s, Some(&reading), MID, 900, false); + control(&g, &mut s, Some(&reading), MID + HOUR, 900, false); + assert!(s.integral > -1000.0); +} + +#[test] +fn vote_freezes_and_release_is_bumpless() { + let reading = read(300, MID); + let mut s = Fallback::default(); + for i in 0..5 { + control(&T, &mut s, Some(&reading), MID + i * 60_000, 900, false); + } + s.rate_per_h = 3600.0 / 300.0; + let (mut short, mut long) = (s.clone(), s.clone()); + assert_eq!(control(&T, &mut short, Some(&reading), MID + 6 * 60_000, 900, true), 300); + assert_eq!(control(&T, &mut long, Some(&reading), MID + 6 * 60_000 + HOUR, 900, true), 300); + assert!((short.integral - long.integral).abs() < 1e-6, "a vote integrates nothing"); + near(control(&T, &mut short, Some(&reading), MID + 7 * 60_000, 900, false), 300); +} + +#[test] +fn rollover_resets_memory() { + let mut s = Fallback::default(); + let before = RESETS - 2 * HOUR; + for i in 0..12 { + let now = before + i * 10 * 60_000; + control(&T, &mut s, Some(&read(100, before)), now, 900, false); + } + assert!(s.integral > 100.0); + let new = Reading { used_permille: 5, observed_at_ms: RESETS + HOUR, resets_at_ms: RESETS + WINDOW_MS }; + // Used fell from 100 to 5 across the reset: no derivative kick, no old integral. + near(control(&T, &mut s, Some(&new), RESETS + HOUR, 900, false), 898); + assert!(s.integral < 2.0); + assert_eq!(s.window_resets_at_ms, RESETS + WINDOW_MS); +} + +#[test] +fn no_reading_holds() { + let mut s = Fallback::default(); + assert_eq!(control(&T, &mut s, None, MID, 900, false), DEFAULT_BEAT_S, "never set"); + let live = read(400, MID); + control(&T, &mut s, Some(&live), MID, 900, false); + let held = control(&T, &mut s, Some(&live), MID + 60_000, 900, false); + assert_ne!(held, DEFAULT_BEAT_S); + let integral = s.integral; + assert_eq!(control(&T, &mut s, None, MID + 2 * 60_000, 900, false), held); + let stale = Reading { observed_at_ms: RESETS - WINDOW_MS - 1, ..live }; + assert_eq!(control(&T, &mut s, Some(&stale), MID + 3 * 60_000, 900, false), held); + let expired = Reading { resets_at_ms: MID + 4 * HOUR, observed_at_ms: MID + 4 * HOUR - WINDOW_MS, ..live }; + assert_eq!(control(&T, &mut s, Some(&expired), MID + 6 * HOUR, 900, false), held); + assert_eq!(s.integral, integral, "nothing integrated while held"); + // The six hours without a reading are not integrated when it comes back. + control(&T, &mut s, Some(&read(400, MID + 6 * HOUR + 60_000)), MID + 6 * HOUR + 60_000, 900, false); + assert!(s.integral - integral < 2.0, "{} then {}", integral, s.integral); +} + +#[test] +fn beat_is_bounded() { + let wild = Gains { kp: 1.0, ki: 0.0, kd: 0.0 }; + let late = RESETS - 12 * HOUR; + assert_eq!(control(&wild, &mut Fallback::default(), Some(&read(0, late)), late, 900, false), FALLBACK_MIN_S); + assert_eq!(control(&wild, &mut Fallback::default(), Some(&read(1000, MID)), MID, 900, false), FALLBACK_MAX_S); + for used in [0, 1, 450, 900, 1000, 5000] { + for target in [0, 900, 1000] { + for gains in [wild, T, GAINS] { + let beat = control(&gains, &mut Fallback::default(), Some(&read(used, MID)), MID, target, false); + assert!((FALLBACK_MIN_S..=FALLBACK_MAX_S).contains(&beat), "{beat}"); + } + } + } +}