tempo: a reading older than twice the longest beat is no evidence (holds the last beat); integrate per call again
Replaces the integrate-only-on-advance step: it changed the sampling the gains were tuned against for no gain over an age horizon, and left a replayed reading steering for as long as its age stayed inside it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
98ac63bba8
commit
ae937d1430
2 changed files with 43 additions and 41 deletions
|
|
@ -20,6 +20,9 @@ pub const FALLBACK_MAX_S: u32 = 3600;
|
|||
|
||||
const WINDOW_MS: i64 = 7 * 24 * 3_600_000;
|
||||
const HOUR_MS: f64 = 3_600_000.0;
|
||||
/// A reading observed longer ago than this is no evidence of where usage is now:
|
||||
/// twice the longest beat, since every turn refreshes it.
|
||||
const STALE_AFTER_MS: i64 = 2 * FALLBACK_MAX_S as i64 * 1000;
|
||||
/// 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;
|
||||
|
|
@ -61,15 +64,13 @@ pub const GAINS: Gains = Gains {
|
|||
/// 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, 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.
|
||||
/// `carry_per_h`, which fades after release.
|
||||
#[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,
|
||||
|
|
@ -78,13 +79,15 @@ 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; the integral steps only on a reading that
|
||||
/// advanced, at most one longest beat each, and not against a beat limit.
|
||||
/// between distinct observations; conditional integration against the beat
|
||||
/// limits.
|
||||
/// 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).
|
||||
/// with no usable reading (none, an expired window, or one observed more than
|
||||
/// `STALE_AFTER_MS` ago, which a connector replaying its last answer keeps
|
||||
/// producing) the last beat holds (`DEFAULT_BEAT_S` before any).
|
||||
pub fn fallback_beat(
|
||||
state: &mut Fallback,
|
||||
reading: Option<&Reading>,
|
||||
|
|
@ -123,23 +126,23 @@ fn control(
|
|||
target_permille: u32,
|
||||
voted: bool,
|
||||
) -> u32 {
|
||||
let call_h = if state.last_now_ms > 0 {
|
||||
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 {
|
||||
state.last_step_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
|
||||
&& now_ms - r.observed_at_ms <= STALE_AFTER_MS
|
||||
}) else {
|
||||
return held(state);
|
||||
};
|
||||
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;
|
||||
|
|
@ -167,7 +170,6 @@ fn control(
|
|||
// 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 {
|
||||
|
|
@ -177,20 +179,12 @@ fn control(
|
|||
}
|
||||
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;
|
||||
if !winding {
|
||||
state.integral += error * dt_h;
|
||||
}
|
||||
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();
|
||||
state.carry_per_h *= (-dt_h / CARRY_FADE_H).exp();
|
||||
beat
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -304,7 +304,6 @@ fn saturated_output_does_not_wind_up() {
|
|||
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()
|
||||
};
|
||||
|
|
@ -515,20 +514,24 @@ fn warm(gains: &Gains) -> Fallback {
|
|||
|
||||
#[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;
|
||||
let at = |s: &mut Fallback, hours: i64| {
|
||||
control(&T, s, Some(&frozen), MID + hours * HOUR, 900, false)
|
||||
};
|
||||
let mut s = Fallback::default();
|
||||
at(&mut s, 0);
|
||||
// Inside the horizon it is live; past it, the last beat holds and nothing
|
||||
// integrates, however long the connector keeps replaying it.
|
||||
at(&mut s, 1);
|
||||
assert!(s.integral > 100.0 && s.integral < 200.0, "{}", s.integral);
|
||||
let (held, integral) = (at(&mut s, 3), s.integral);
|
||||
assert_eq!(at(&mut s, 48), held);
|
||||
assert_eq!(at(&mut s, 80), held);
|
||||
assert_eq!(s.integral, integral);
|
||||
// A reading that advances takes over again.
|
||||
let now = MID + 82 * HOUR;
|
||||
control(&T, &mut s, Some(&read(300, now)), now, 900, false);
|
||||
assert!(s.integral > 100.0 && s.integral < 450.0, "{}", s.integral);
|
||||
assert_ne!(s.rate_per_h, 3600.0 / f64::from(held));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -547,11 +550,12 @@ fn one_millisecond_of_resets_jitter_is_the_same_window() {
|
|||
assert!(steady.integral > 100.0);
|
||||
assert!((jittery.integral - steady.integral).abs() < 1e-3);
|
||||
// A window a week on is not the same window.
|
||||
let now = RESETS + HOUR;
|
||||
let next = Reading {
|
||||
resets_at_ms: RESETS + WINDOW_MS - 1_000,
|
||||
..read(5, MID + 61 * 60_000)
|
||||
..read(5, now)
|
||||
};
|
||||
control(&T, &mut jittery, Some(&next), MID + 61 * 60_000, 900, false);
|
||||
control(&T, &mut jittery, Some(&next), now, 900, false);
|
||||
assert!(jittery.integral < 1.0);
|
||||
}
|
||||
|
||||
|
|
@ -569,8 +573,12 @@ fn a_vote_beyond_the_beat_bounds_leaves_the_integral_alone() {
|
|||
assert!(control(&T, &mut s, Some(&read(300, t)), t, 900, false) > 3500);
|
||||
// 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);
|
||||
let mut beat = 0;
|
||||
for i in 1..=12 {
|
||||
let t = t + i * 10 * 60_000;
|
||||
beat = control(&T, &mut s, Some(&read(300, t)), t, 900, false);
|
||||
}
|
||||
assert!(beat < 700, "{beat}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue