#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DisciplineConfig {
pub makestep_threshold: Option<f64>,
pub makestep_limit: u32,
pub max_slew_ppm: f64,
pub max_freq_ppm: f64,
pub min_poll: i8,
pub max_poll: i8,
pub iburst: bool,
pub freq_integral_gain: f64,
pub poll_down_noise_ratio: f64,
pub poll_up_streak: u32,
pub weight_floor_ratio: f64,
pub offset_weight_floor_ratio: f64,
pub offset_age_halflife_s: f64,
pub offset_weight_dispersion_k: f64,
pub slope_density_weighting: bool,
pub corr_time_s: f64,
pub corr_time_ratio: f64,
}
impl Default for DisciplineConfig {
fn default() -> Self {
DisciplineConfig {
makestep_threshold: Some(1.0),
makestep_limit: 3,
max_slew_ppm: 83_333.0,
max_freq_ppm: 500.0,
min_poll: 6,
max_poll: 10,
iburst: true,
freq_integral_gain: FREQ_INTEGRAL_GAIN,
poll_down_noise_ratio: POLL_DOWN_NOISE_RATIO,
poll_up_streak: POLL_UP_STREAK,
weight_floor_ratio: crate::filter::WEIGHT_FLOOR_RATIO,
offset_weight_floor_ratio: crate::filter::OFFSET_WEIGHT_FLOOR_RATIO,
offset_age_halflife_s: f64::INFINITY,
offset_weight_dispersion_k: 0.0,
slope_density_weighting: false,
corr_time_s: 0.0,
corr_time_ratio: 0.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ClockCommand {
Step { add_seconds: f64 },
Slew {
freq_ppm: f64,
drain_offset: f64,
drain_rate_ppm: f64,
},
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Plan {
pub command: ClockCommand,
pub next_poll_s: f64,
pub reset_register: bool,
}
const IBURST_COUNT: u32 = 4;
const IBURST_SPACING_S: f64 = 2.0;
const CORR_TIME_RATIO: f64 = 1.0;
const ACQUIRE_CORR_RATIO: f64 = 1.0;
const ACQUIRE_NOISE_MULTIPLE: f64 = 10.0;
const ACQUIRE_UPDATES: u32 = 8;
const ACQUIRE_SLEW_SHARE: f64 = 0.25;
const ACQUIRE_FULL_SPEED_CONFIDENCE: f64 = 10_000.0;
const FREQ_TRUST_SLEW_SHARE: f64 = 0.25;
const MAX_ACQUIRE_BURST: u32 = 16;
const FREQ_INTEGRAL_GAIN: f64 = 0.0;
const POLL_DOWN_NOISE_RATIO: f64 = 10.0;
const POLL_UP_STREAK: u32 = 3;
const OFFSET_EWMA_ALPHA: f64 = 0.25;
const ACQUIRE_DONE_S: f64 = 1e-3;
#[derive(Clone, Debug)]
pub struct Discipline {
cfg: DisciplineConfig,
freq_ppm: f64,
updates: u32,
poll: i8,
stable_streak: u32,
iburst_left: u32,
last_drain_share: f64,
burst_used: u32,
offset_ewma: f64,
ewma_seeded: bool,
}
impl Discipline {
pub fn new(cfg: DisciplineConfig) -> Self {
let iburst_left = if cfg.iburst { IBURST_COUNT } else { 0 };
Discipline {
cfg,
freq_ppm: 0.0,
updates: 0,
poll: cfg.min_poll,
stable_streak: 0,
iburst_left,
last_drain_share: 0.0,
burst_used: 0,
offset_ewma: 0.0,
ewma_seeded: false,
}
}
fn acquire_share(&self, offset: f64, noise: f64) -> f64 {
let confidence = offset.abs() / noise.max(1e-9);
if confidence >= ACQUIRE_FULL_SPEED_CONFIDENCE {
1.0
} else {
ACQUIRE_SLEW_SHARE
}
}
pub fn freq_ppm(&self) -> f64 {
self.freq_ppm
}
pub fn poll_log2(&self) -> i8 {
self.poll
}
pub fn on_estimate(&mut self, offset: f64, freq_ppm_meas: Option<f64>, offset_sd: f64) -> Plan {
self.updates += 1;
if let Some(threshold) = self.cfg.makestep_threshold
&& offset.abs() > threshold
&& self.updates <= self.cfg.makestep_limit
{
self.stable_streak = 0;
return Plan {
command: ClockCommand::Step {
add_seconds: offset,
},
next_poll_s: self.take_poll_interval(),
reset_register: true,
};
}
let hauling = self.last_drain_share > FREQ_TRUST_SLEW_SHARE;
if let Some(fm) = freq_ppm_meas
&& !hauling
{
self.freq_ppm =
(self.freq_ppm + fm).clamp(-self.cfg.max_freq_ppm, self.cfg.max_freq_ppm);
}
let noise = offset_sd.max(1e-7);
if self.ewma_seeded {
self.offset_ewma =
(1.0 - OFFSET_EWMA_ALPHA) * self.offset_ewma + OFFSET_EWMA_ALPHA * offset;
} else {
self.offset_ewma = offset;
self.ewma_seeded = true;
}
if self.cfg.freq_integral_gain != 0.0
&& self.updates > ACQUIRE_UPDATES
&& self.offset_ewma.abs() > noise
{
let poll_now = self.peek_poll_interval();
let implied_freq_ppm = (self.offset_ewma / (CORR_TIME_RATIO * poll_now)) * 1e6;
self.freq_ppm = (self.freq_ppm + self.cfg.freq_integral_gain * implied_freq_ppm)
.clamp(-self.cfg.max_freq_ppm, self.cfg.max_freq_ppm);
}
if offset.abs() < 2.0 * noise {
self.stable_streak += 1;
if self.stable_streak >= self.cfg.poll_up_streak && self.poll < self.cfg.max_poll {
self.poll += 1;
self.stable_streak = 0;
}
} else {
self.stable_streak = 0;
if offset.abs() > self.cfg.poll_down_noise_ratio * noise
&& self.poll > self.cfg.min_poll
{
self.poll -= 1;
}
}
if self.iburst_left == 0
&& self.cfg.iburst
&& self.burst_used < MAX_ACQUIRE_BURST
&& offset.abs() > ACQUIRE_DONE_S.max(2.0 * noise)
{
self.iburst_left = 1;
}
let poll_s = self.peek_poll_interval();
let acquiring = self.updates <= ACQUIRE_UPDATES;
let wanted_rate_ppm = if acquiring && offset.abs() > ACQUIRE_NOISE_MULTIPLE * noise {
((offset.abs() / (ACQUIRE_CORR_RATIO * poll_s)) * 1e6)
.min(self.cfg.max_slew_ppm * self.acquire_share(offset, noise))
} else {
let ratio = if self.cfg.corr_time_ratio > 0.0 {
self.cfg.corr_time_ratio
} else {
CORR_TIME_RATIO
};
let corr_time = if self.cfg.corr_time_s > 0.0 {
self.cfg.corr_time_s
} else {
ratio * poll_s
};
(offset.abs() / corr_time) * 1e6
};
let drain_rate_ppm = wanted_rate_ppm.min(self.cfg.max_slew_ppm);
self.last_drain_share = if self.cfg.max_slew_ppm > 0.0 {
drain_rate_ppm / self.cfg.max_slew_ppm
} else {
0.0
};
Plan {
command: ClockCommand::Slew {
freq_ppm: self.freq_ppm,
drain_offset: offset,
drain_rate_ppm,
},
next_poll_s: self.take_poll_interval(),
reset_register: false,
}
}
pub fn retry_interval_s(&self) -> f64 {
self.peek_poll_interval()
}
fn peek_poll_interval(&self) -> f64 {
if self.iburst_left > 0 {
IBURST_SPACING_S
} else {
2f64.powi(self.poll as i32)
}
}
fn take_poll_interval(&mut self) -> f64 {
if self.iburst_left > 0 {
self.iburst_left -= 1;
self.burst_used += 1;
IBURST_SPACING_S
} else {
2f64.powi(self.poll as i32)
}
}
}
#[cfg(test)]
mod acquisition_tests {
use super::*;
fn acquiring() -> Discipline {
Discipline::new(DisciplineConfig {
makestep_threshold: None,
min_poll: 4, iburst: true,
..DisciplineConfig::default()
})
}
#[test]
fn the_burst_continues_while_a_correction_is_outstanding() {
let mut d = acquiring();
let mut plan = None;
for _ in 0..IBURST_COUNT + 3 {
plan = Some(d.on_estimate(0.010, None, 1e-6));
}
let next = plan.expect("a plan").next_poll_s;
assert!(
next <= IBURST_SPACING_S,
"burst ended with 10 ms still outstanding: next poll {next} s"
);
}
#[test]
fn the_burst_ends_once_the_offset_is_small() {
let mut d = acquiring();
let mut plan = None;
for _ in 0..IBURST_COUNT + 3 {
plan = Some(d.on_estimate(1e-6, None, 1e-6));
}
let next = plan.expect("a plan").next_poll_s;
assert!(
next > IBURST_SPACING_S,
"burst kept running on a converged clock: next poll {next} s"
);
}
#[test]
fn the_extended_burst_is_bounded() {
let mut d = acquiring();
let mut plan = None;
for _ in 0..MAX_ACQUIRE_BURST * 3 {
plan = Some(d.on_estimate(0.010, None, 1e-6));
}
let next = plan.expect("a plan").next_poll_s;
assert!(
next > IBURST_SPACING_S,
"burst never backed off despite never converging: next poll {next} s"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_initial_offset_is_stepped() {
let mut d = Discipline::new(DisciplineConfig::default());
let plan = d.on_estimate(120.0, None, 1e-4);
assert!(matches!(
plan.command,
ClockCommand::Step { add_seconds } if (add_seconds - 120.0).abs() < 1e-9
));
assert!(plan.reset_register);
}
#[test]
fn step_window_closes() {
let mut d = Discipline::new(DisciplineConfig::default());
for _ in 0..3 {
let _ = d.on_estimate(0.0001, None, 1e-4);
}
let plan = d.on_estimate(5.0, None, 1e-4);
assert!(matches!(plan.command, ClockCommand::Slew { .. }));
}
#[test]
fn freq_accumulates_and_clamps() {
let mut d = Discipline::new(DisciplineConfig::default());
let _ = d.on_estimate(1e-4, Some(100.0), 1e-4);
assert!((d.freq_ppm() - 100.0).abs() < 1e-9);
let _ = d.on_estimate(1e-4, Some(1000.0), 1e-4);
assert!((d.freq_ppm() - 500.0).abs() < 1e-9, "clamped at max_freq");
}
#[test]
fn iburst_then_normal_cadence() {
let mut d = Discipline::new(DisciplineConfig::default());
let mut intervals = Vec::new();
for _ in 0..6 {
let plan = d.on_estimate(1e-5, None, 1e-4);
intervals.push(plan.next_poll_s);
}
assert!(intervals[..4].iter().all(|&i| i == 2.0), "{intervals:?}");
assert!(intervals[4] >= 64.0, "{intervals:?}");
}
#[test]
fn closed_loop_converges() {
let mut d = Discipline::new(DisciplineConfig {
iburst: false,
makestep_threshold: None,
..DisciplineConfig::default()
});
let mut clock_err_s = 0.030_f64; let base_freq_ppm = 40.0;
let mut t = 0.0;
for _ in 0..60 {
let offset = -clock_err_s;
let slope_ppm = -(base_freq_ppm + d.freq_ppm());
let plan = d.on_estimate(offset, Some(slope_ppm), 1e-5);
let dt = plan.next_poll_s;
if let ClockCommand::Slew {
freq_ppm,
drain_offset,
drain_rate_ppm,
} = plan.command
{
let drift = (base_freq_ppm + freq_ppm) * 1e-6 * dt;
let max_drain = drain_rate_ppm * 1e-6 * dt;
let drain = drain_offset.abs().min(max_drain) * drain_offset.signum();
clock_err_s += drift + drain;
}
t += dt;
}
assert!(
clock_err_s.abs() < 1e-4,
"did not converge: err {clock_err_s} at t {t}"
);
assert!(
(d.freq_ppm() + 40.0).abs() < 2.0,
"freq not learned (want ~-40): {}",
d.freq_ppm()
);
}
}