tempo: a vote carries a fading offset, not an integral; integrate only on advanced readings; window identity within a minute
The integral no longer back-calculates to the voted rate (unbounded, it parked the controller near the vote's burn after release): nothing integrates while voted, and the gap between the voted rate (inside the beat bounds) and the controller's own answer is a carry that fades over a poll period after release. A reading whose observed_at has not advanced is not evidence: the integral steps only on an advanced reading, at most one longest beat. Two resets_at within a minute are one window. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f21e306a33
commit
05345a855f
2 changed files with 252 additions and 34 deletions
|
|
@ -20,6 +20,13 @@ pub const FALLBACK_MAX_S: u32 = 3600;
|
|||
|
||||
const WINDOW_MS: i64 = 7 * 24 * 3_600_000;
|
||||
const HOUR_MS: f64 = 3_600_000.0;
|
||||
/// Two `resets_at` values this close name one window: the connector reports
|
||||
/// whole unix seconds, and a new window starts a week on.
|
||||
const RESETS_JITTER_MS: u64 = 60_000;
|
||||
/// How fast, in hours, the controller takes the wheel back from a released
|
||||
/// vote: the sensor's own poll period, since the vote's effect cannot show in a
|
||||
/// reading sooner.
|
||||
const CARRY_FADE_H: f64 = 0.25;
|
||||
/// 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;
|
||||
|
|
@ -53,13 +60,17 @@ pub const GAINS: Gains = Gains {
|
|||
/// 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.
|
||||
/// rate, and the controller turns the gap between that and its own answer into
|
||||
/// `carry_per_h`, which fades after release. `last_step_ms` is when the integral
|
||||
/// last took a step.
|
||||
#[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 last_step_ms: i64,
|
||||
pub carry_per_h: f64,
|
||||
pub window_resets_at_ms: i64,
|
||||
pub rate_per_h: f64,
|
||||
}
|
||||
|
|
@ -67,10 +78,13 @@ pub struct Fallback {
|
|||
/// 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).
|
||||
/// between distinct observations; the integral steps only on a reading that
|
||||
/// advanced, at most one longest beat each, and not against a beat limit.
|
||||
/// While `voted` nothing integrates and the controller notes how far the voted
|
||||
/// beat (held inside the beat bounds) is from its own answer, so release starts
|
||||
/// at the voted beat and the controller takes the wheel back within a poll
|
||||
/// period or two. A new window clears the integral and the derivative memory;
|
||||
/// with no usable reading the last beat holds (`DEFAULT_BEAT_S` before any).
|
||||
pub fn fallback_beat(
|
||||
state: &mut Fallback,
|
||||
reading: Option<&Reading>,
|
||||
|
|
@ -109,7 +123,7 @@ fn control(
|
|||
target_permille: u32,
|
||||
voted: bool,
|
||||
) -> u32 {
|
||||
let dt_h = if state.last_now_ms > 0 {
|
||||
let call_h = if state.last_now_ms > 0 {
|
||||
(now_ms - state.last_now_ms).max(0) as f64 / HOUR_MS
|
||||
} else {
|
||||
0.0
|
||||
|
|
@ -118,14 +132,16 @@ fn control(
|
|||
let Some(r) = reading
|
||||
.filter(|r| now_ms < r.resets_at_ms && r.observed_at_ms >= r.resets_at_ms - WINDOW_MS)
|
||||
else {
|
||||
state.last_step_ms = now_ms;
|
||||
return held(state);
|
||||
};
|
||||
if r.resets_at_ms != state.window_resets_at_ms {
|
||||
state.window_resets_at_ms = r.resets_at_ms;
|
||||
if r.resets_at_ms.abs_diff(state.window_resets_at_ms) > RESETS_JITTER_MS {
|
||||
state.integral = 0.0;
|
||||
state.last_used_permille = r.used_permille;
|
||||
state.last_observed_at_ms = r.observed_at_ms;
|
||||
state.last_step_ms = now_ms;
|
||||
}
|
||||
state.window_resets_at_ms = r.resets_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);
|
||||
|
|
@ -140,25 +156,41 @@ fn control(
|
|||
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;
|
||||
if voted {
|
||||
// Nothing integrates. The carry is the gap between the voted rate (held
|
||||
// inside the beat bounds, which are all the controller can answer) and
|
||||
// what the controller would answer now, so release starts at the voted
|
||||
// beat; it fades once the vote is gone.
|
||||
state.last_step_ms = now_ms;
|
||||
state.carry_per_h = if state.rate_per_h > 0.0 {
|
||||
state.rate_per_h.clamp(low, high) - rate(state.integral)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
return held(state);
|
||||
}
|
||||
let beat = beat_of(rate(state.integral));
|
||||
let before = rate(state.integral) + state.carry_per_h;
|
||||
let winding = (before >= high && error > 0.0) || (before <= low && error < 0.0);
|
||||
if fresh {
|
||||
// Only a reading that advanced is evidence; one step covers at most the
|
||||
// longest beat, so a late reading does not repay the silence.
|
||||
if !winding {
|
||||
let step_h = (now_ms - state.last_step_ms).clamp(0, i64::from(FALLBACK_MAX_S) * 1000)
|
||||
as f64
|
||||
/ HOUR_MS;
|
||||
state.integral += error * step_h;
|
||||
}
|
||||
state.last_step_ms = now_ms;
|
||||
}
|
||||
let beat = beat_of(rate(state.integral) + state.carry_per_h);
|
||||
state.rate_per_h = 3600.0 / f64::from(beat);
|
||||
state.carry_per_h *= (-call_h / CARRY_FADE_H).exp();
|
||||
beat
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ fn the_score_is_the_wait_in_beats_times_ten_to_the_urgency_in_orders() {
|
|||
assert_eq!(score(&standing, 900), 2.0, "two beats waited, no urgency");
|
||||
assert_eq!(score(&standing, 600), 3.0);
|
||||
standing.self_permille = 1000;
|
||||
assert_eq!(score(&standing, 900), 20.0, "full urgency is worth one order of magnitude");
|
||||
assert_eq!(
|
||||
score(&standing, 900),
|
||||
20.0,
|
||||
"full urgency is worth one order of magnitude"
|
||||
);
|
||||
standing.self_permille = 500;
|
||||
assert!((score(&standing, 900) - 2.0 * 10f64.sqrt()).abs() < 1e-9);
|
||||
assert_eq!(score(&mind("never", None), 900), f64::INFINITY);
|
||||
|
|
@ -55,15 +59,34 @@ fn the_tally_is_the_lower_median_and_one_outlier_cannot_move_it() {
|
|||
fn silence_is_abstention_and_only_cast_votes_are_counted() {
|
||||
let mut eight = vec![None; 8];
|
||||
eight[0] = Some(60);
|
||||
assert_eq!(tally(&eight), 60, "one voter among eight silent Minds decides");
|
||||
assert_eq!(tally(&[None; 8]), DEFAULT_BEAT_S, "none cast is the default");
|
||||
assert_eq!(tally(&[Some(3600), None, Some(60)]), 60, "two cast: the lower median");
|
||||
assert_eq!(tally(&[Some(3600), Some(1800), None, None, Some(60)]), 1800, "abstainers do not dilute the median");
|
||||
assert_eq!(
|
||||
tally(&eight),
|
||||
60,
|
||||
"one voter among eight silent Minds decides"
|
||||
);
|
||||
assert_eq!(
|
||||
tally(&[None; 8]),
|
||||
DEFAULT_BEAT_S,
|
||||
"none cast is the default"
|
||||
);
|
||||
assert_eq!(
|
||||
tally(&[Some(3600), None, Some(60)]),
|
||||
60,
|
||||
"two cast: the lower median"
|
||||
);
|
||||
assert_eq!(
|
||||
tally(&[Some(3600), Some(1800), None, None, Some(60)]),
|
||||
1800,
|
||||
"abstainers do not dilute the median"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tally_does_not_depend_on_the_order_the_votes_came_in() {
|
||||
assert_eq!(tally(&[Some(900), Some(60), None, Some(600)]), tally(&[None, Some(60), Some(600), Some(900)]));
|
||||
assert_eq!(
|
||||
tally(&[Some(900), Some(60), None, Some(600)]),
|
||||
tally(&[None, Some(60), Some(600), Some(900)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -258,10 +281,10 @@ fn saturated_output_does_not_wind_up() {
|
|||
};
|
||||
let day5 = RESETS - 2 * 24 * HOUR;
|
||||
let mut s = Fallback::default();
|
||||
let behind = read(0, day5);
|
||||
for i in 0..100 {
|
||||
let now = day5 + i * 60_000;
|
||||
assert_eq!(
|
||||
control(&g, &mut s, Some(&behind), day5 + i * 60_000, 900, false),
|
||||
control(&g, &mut s, Some(&read(0, now)), now, 900, false),
|
||||
FALLBACK_MIN_S
|
||||
);
|
||||
}
|
||||
|
|
@ -269,20 +292,26 @@ fn saturated_output_does_not_wind_up() {
|
|||
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);
|
||||
let now = day5 + i * 60_000;
|
||||
control(&g, &mut s, Some(&read(600, now)), now, 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,
|
||||
last_used_permille: 400,
|
||||
last_observed_at_ms: MID - 60_000,
|
||||
last_now_ms: MID - 60_000,
|
||||
last_step_ms: MID - 60_000,
|
||||
window_resets_at_ms: RESETS,
|
||||
..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_eq!(
|
||||
control(&g, &mut s, Some(&read(400, MID)), MID, 900, false),
|
||||
FALLBACK_MAX_S
|
||||
);
|
||||
assert!(s.integral > -1000.0);
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +350,7 @@ fn rollover_resets_memory() {
|
|||
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);
|
||||
control(&T, &mut s, Some(&read(100, now)), now, 900, false);
|
||||
}
|
||||
assert!(s.integral > 100.0);
|
||||
let new = Reading {
|
||||
|
|
@ -346,11 +375,12 @@ fn no_reading_holds() {
|
|||
DEFAULT_BEAT_S,
|
||||
"never set"
|
||||
);
|
||||
let live = read(400, MID);
|
||||
control(&T, &mut s, Some(&live), MID, 900, false);
|
||||
let live = read(400, MID + 60_000);
|
||||
control(&T, &mut s, Some(&read(400, MID)), 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!(integral > 0.0, "a fresh reading integrates");
|
||||
assert_eq!(
|
||||
control(&T, &mut s, None, MID + 2 * 60_000, 900, false),
|
||||
held
|
||||
|
|
@ -471,3 +501,159 @@ fn a_window_that_ends_this_instant_is_expired() {
|
|||
held
|
||||
);
|
||||
}
|
||||
|
||||
/// A state mid-window with some integral, as after a quiet hour of fresh readings.
|
||||
fn warm(gains: &Gains) -> Fallback {
|
||||
let mut s = Fallback::default();
|
||||
for i in 0..7 {
|
||||
let now = MID + i * 10 * 60_000;
|
||||
control(gains, &mut s, Some(&read(300, now)), now, 900, false);
|
||||
}
|
||||
assert!(s.integral > 0.0);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frozen_reading_does_not_wind_up() {
|
||||
let mut s = Fallback::default();
|
||||
let frozen = read(300, MID);
|
||||
for i in 0..=288 {
|
||||
control(&T, &mut s, Some(&frozen), MID + i * 10 * 60_000, 900, false);
|
||||
}
|
||||
assert_eq!(
|
||||
s.integral, 0.0,
|
||||
"a reading that never advanced is no evidence"
|
||||
);
|
||||
// The first reading that does advance is accountable for at most the
|
||||
// longest beat, not the silence before it.
|
||||
let now = MID + 48 * HOUR;
|
||||
control(&T, &mut s, Some(&read(300, now)), now, 900, false);
|
||||
assert!(s.integral > 100.0 && s.integral < 450.0, "{}", s.integral);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_millisecond_of_resets_jitter_is_the_same_window() {
|
||||
let (mut steady, mut jittery) = (Fallback::default(), Fallback::default());
|
||||
for i in 0..60 {
|
||||
let now = MID + i * 60_000;
|
||||
let r = read(300, now);
|
||||
control(&T, &mut steady, Some(&r), now, 900, false);
|
||||
let jitter = Reading {
|
||||
resets_at_ms: RESETS + i % 2,
|
||||
..r
|
||||
};
|
||||
control(&T, &mut jittery, Some(&jitter), now, 900, false);
|
||||
}
|
||||
assert!(steady.integral > 100.0);
|
||||
assert_eq!(jittery.integral, steady.integral);
|
||||
// A window a week on is not the same window.
|
||||
let next = Reading {
|
||||
resets_at_ms: RESETS + WINDOW_MS - 1_000,
|
||||
..read(5, MID + 61 * 60_000)
|
||||
};
|
||||
control(&T, &mut jittery, Some(&next), MID + 61 * 60_000, 900, false);
|
||||
assert!(jittery.integral < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vote_beyond_the_beat_bounds_leaves_the_integral_alone() {
|
||||
for voted_beat in [7200.0, 86_400.0] {
|
||||
let mut s = warm(&T);
|
||||
let integral = s.integral;
|
||||
s.rate_per_h = 3600.0 / voted_beat;
|
||||
let t = MID + 3 * HOUR;
|
||||
control(&T, &mut s, Some(&read(300, t)), t, 900, true);
|
||||
assert_eq!(s.integral, integral, "a vote integrates nothing");
|
||||
// The controller can answer no more than its longest beat.
|
||||
let t = t + 60_000;
|
||||
assert_eq!(
|
||||
control(&T, &mut s, Some(&read(300, t)), t, 900, false),
|
||||
FALLBACK_MAX_S
|
||||
);
|
||||
// And takes the wheel back: well behind the trajectory, two hours on, the
|
||||
// beat is its own again.
|
||||
let t = t + 2 * HOUR;
|
||||
assert!(control(&T, &mut s, Some(&read(300, t)), t, 900, false) < 700);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_vote_while_ahead_does_not_outlive_itself() {
|
||||
let mut s = Fallback::default();
|
||||
let ahead = read(600, MID);
|
||||
control(&T, &mut s, Some(&ahead), MID, 900, false);
|
||||
s.rate_per_h = 60.0;
|
||||
let t = MID + HOUR;
|
||||
control(&T, &mut s, Some(&ahead), t, 900, true);
|
||||
near(
|
||||
control(&T, &mut s, Some(&ahead), t + 60_000, 900, false),
|
||||
60,
|
||||
);
|
||||
// 150 permille ahead of the trajectory: two hours on it is back to the
|
||||
// slowest beat or near it, not the 60 s it was voted.
|
||||
for i in 2..=121 {
|
||||
control(&T, &mut s, Some(&ahead), t + i * 60_000, 900, false);
|
||||
}
|
||||
assert!(control(&T, &mut s, Some(&ahead), t + 122 * 60_000, 900, false) > 3300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_vote_carry_needs_no_integral_gain_and_a_set_rate() {
|
||||
// ki = 0: nothing to divide by; release is still the voted beat.
|
||||
let g = Gains { ki: 0.0, ..T };
|
||||
let mut s = warm(&g);
|
||||
s.rate_per_h = 12.0;
|
||||
let t = MID + 3 * HOUR;
|
||||
control(&g, &mut s, Some(&read(300, t)), t, 900, true);
|
||||
near(
|
||||
control(
|
||||
&g,
|
||||
&mut s,
|
||||
Some(&read(300, t + 60_000)),
|
||||
t + 60_000,
|
||||
900,
|
||||
false,
|
||||
),
|
||||
300,
|
||||
);
|
||||
// A vote cast before the caller set any rate is no carry at all.
|
||||
let (mut voted, mut plain) = (Fallback::default(), Fallback::default());
|
||||
let r = read(300, MID);
|
||||
control(&T, &mut voted, Some(&r), MID, 900, true);
|
||||
let after = read(300, MID + 60_000);
|
||||
assert_eq!(
|
||||
control(&T, &mut voted, Some(&after), MID + 60_000, 900, false),
|
||||
control(&T, &mut plain, Some(&after), MID + 60_000, 900, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_that_steps_back_integrates_and_fades_nothing_backwards() {
|
||||
let mut s = warm(&T);
|
||||
let integral = s.integral;
|
||||
let t = MID + HOUR;
|
||||
control(&T, &mut s, Some(&read(300, t)), t, 900, false);
|
||||
let after_forward = s.integral;
|
||||
assert!(after_forward > integral);
|
||||
control(
|
||||
&T,
|
||||
&mut s,
|
||||
Some(&read(300, t + 60_000)),
|
||||
t - HOUR,
|
||||
900,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.integral, after_forward, "no negative step");
|
||||
s.rate_per_h = 12.0;
|
||||
control(&T, &mut s, Some(&read(300, t + 120_000)), t, 900, true);
|
||||
let carry = s.carry_per_h;
|
||||
control(
|
||||
&T,
|
||||
&mut s,
|
||||
Some(&read(300, t + 180_000)),
|
||||
t - HOUR,
|
||||
900,
|
||||
false,
|
||||
);
|
||||
assert!(s.carry_per_h.abs() <= carry.abs(), "the carry only fades");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue