mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
feat: add EWMA clock delta and adjtimex nudge controls
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
This commit is contained in:
parent
6a45660e03
commit
cc782fcd7e
7 changed files with 152 additions and 59 deletions
19
src/api.rs
19
src/api.rs
|
|
@ -59,7 +59,7 @@ async fn get_status(data: web::Data<AppState>) -> impl Responder {
|
||||||
now_local.timestamp_subsec_millis(),
|
now_local.timestamp_subsec_millis(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let avg_delta = state.average_clock_delta();
|
let avg_delta = state.get_ewma_clock_delta();
|
||||||
let mut delta_frames = 0;
|
let mut delta_frames = 0;
|
||||||
if let Some(frame) = &state.latest {
|
if let Some(frame) = &state.latest {
|
||||||
let frame_ms = 1000.0 / frame.frame_rate;
|
let frame_ms = 1000.0 / frame.frame_rate;
|
||||||
|
|
@ -121,6 +121,20 @@ async fn get_logs(data: web::Data<AppState>) -> impl Responder {
|
||||||
HttpResponse::Ok().json(&*logs)
|
HttpResponse::Ok().json(&*logs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NudgeRequest {
|
||||||
|
microseconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/nudge_clock")]
|
||||||
|
async fn nudge_clock(req: web::Json<NudgeRequest>) -> impl Responder {
|
||||||
|
if system::nudge_clock(req.microseconds).is_ok() {
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Clock nudge command issued." }))
|
||||||
|
} else {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Clock nudge command failed." }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/api/config")]
|
#[post("/api/config")]
|
||||||
async fn update_config(
|
async fn update_config(
|
||||||
data: web::Data<AppState>,
|
data: web::Data<AppState>,
|
||||||
|
|
@ -161,6 +175,7 @@ pub async fn start_api_server(
|
||||||
.service(get_config)
|
.service(get_config)
|
||||||
.service(update_config)
|
.service(update_config)
|
||||||
.service(get_logs)
|
.service(get_logs)
|
||||||
|
.service(nudge_clock)
|
||||||
// Serve frontend static files
|
// Serve frontend static files
|
||||||
.service(fs::Files::new("/", "static/").index_file("index.html"))
|
.service(fs::Files::new("/", "static/").index_file("index.html"))
|
||||||
})
|
})
|
||||||
|
|
@ -194,7 +209,7 @@ mod tests {
|
||||||
lock_count: 10,
|
lock_count: 10,
|
||||||
free_count: 1,
|
free_count: 1,
|
||||||
offset_history: VecDeque::from(vec![1, 2, 3]),
|
offset_history: VecDeque::from(vec![1, 2, 3]),
|
||||||
clock_delta_history: VecDeque::from(vec![4, 5, 6]),
|
ewma_clock_delta: Some(5.0),
|
||||||
last_match_status: "IN SYNC".to_string(),
|
last_match_status: "IN SYNC".to_string(),
|
||||||
last_match_check: Utc::now().timestamp(),
|
last_match_check: Utc::now().timestamp(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ pub struct Config {
|
||||||
pub hardware_offset_ms: i64,
|
pub hardware_offset_ms: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub timeturner_offset: TimeturnerOffset,
|
pub timeturner_offset: TimeturnerOffset,
|
||||||
|
#[serde(default = "default_nudge_ms")]
|
||||||
|
pub default_nudge_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_nudge_ms() -> i64 {
|
||||||
|
2 // Default nudge is 2ms
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|
@ -57,6 +63,7 @@ impl Default for Config {
|
||||||
Self {
|
Self {
|
||||||
hardware_offset_ms: 0,
|
hardware_offset_ms: 0,
|
||||||
timeturner_offset: TimeturnerOffset::default(),
|
timeturner_offset: TimeturnerOffset::default(),
|
||||||
|
default_nudge_ms: default_nudge_ms(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/main.rs
19
src/main.rs
|
|
@ -13,6 +13,7 @@ use crate::config::watch_config;
|
||||||
use crate::serial_input::start_serial_thread;
|
use crate::serial_input::start_serial_thread;
|
||||||
use crate::sync_logic::LtcState;
|
use crate::sync_logic::LtcState;
|
||||||
use crate::ui::start_ui;
|
use crate::ui::start_ui;
|
||||||
|
use chrono::TimeZone;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use daemonize::Daemonize;
|
use daemonize::Daemonize;
|
||||||
|
|
||||||
|
|
@ -42,6 +43,9 @@ const DEFAULT_CONFIG: &str = r#"
|
||||||
# Hardware offset in milliseconds for correcting capture latency.
|
# Hardware offset in milliseconds for correcting capture latency.
|
||||||
hardwareOffsetMs: 20
|
hardwareOffsetMs: 20
|
||||||
|
|
||||||
|
# Default nudge in milliseconds for adjtimex control.
|
||||||
|
defaultNudgeMs: 2
|
||||||
|
|
||||||
# Time-turning offsets. All values are added to the incoming LTC time.
|
# Time-turning offsets. All values are added to the incoming LTC time.
|
||||||
# These can be positive or negative.
|
# These can be positive or negative.
|
||||||
timeturnerOffset:
|
timeturnerOffset:
|
||||||
|
|
@ -153,9 +157,22 @@ async fn main() {
|
||||||
|
|
||||||
// 8️⃣ Main logic loop: process frames from serial and update state
|
// 8️⃣ Main logic loop: process frames from serial and update state
|
||||||
let loop_state = ltc_state.clone();
|
let loop_state = ltc_state.clone();
|
||||||
|
let loop_config = config.clone();
|
||||||
let logic_task = task::spawn_blocking(move || {
|
let logic_task = task::spawn_blocking(move || {
|
||||||
for frame in rx {
|
for frame in rx {
|
||||||
loop_state.lock().unwrap().update(frame);
|
let mut state = loop_state.lock().unwrap();
|
||||||
|
let config = loop_config.lock().unwrap();
|
||||||
|
|
||||||
|
// Only calculate delta for LOCK frames
|
||||||
|
if frame.status == "LOCK" {
|
||||||
|
let target_time = system::calculate_target_time(&frame, &config);
|
||||||
|
let arrival_time_local: chrono::DateTime<chrono::Local> =
|
||||||
|
frame.timestamp.with_timezone(&chrono::Local);
|
||||||
|
let delta = arrival_time_local.signed_duration_since(target_time);
|
||||||
|
state.record_and_update_ewma_clock_delta(delta.num_milliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.update(frame);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ use chrono::{DateTime, Local, Timelike, Utc};
|
||||||
use regex::Captures;
|
use regex::Captures;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
const EWMA_ALPHA: f64 = 0.1;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct LtcFrame {
|
pub struct LtcFrame {
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
|
@ -42,8 +44,8 @@ pub struct LtcState {
|
||||||
pub free_count: u32,
|
pub free_count: u32,
|
||||||
/// Stores the last up-to-20 raw offset measurements in ms.
|
/// Stores the last up-to-20 raw offset measurements in ms.
|
||||||
pub offset_history: VecDeque<i64>,
|
pub offset_history: VecDeque<i64>,
|
||||||
/// Stores the last up-to-20 timecode Δ measurements in ms.
|
/// EWMA of clock delta.
|
||||||
pub clock_delta_history: VecDeque<i64>,
|
pub ewma_clock_delta: Option<f64>,
|
||||||
pub last_match_status: String,
|
pub last_match_status: String,
|
||||||
pub last_match_check: i64,
|
pub last_match_check: i64,
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +57,7 @@ impl LtcState {
|
||||||
lock_count: 0,
|
lock_count: 0,
|
||||||
free_count: 0,
|
free_count: 0,
|
||||||
offset_history: VecDeque::with_capacity(20),
|
offset_history: VecDeque::with_capacity(20),
|
||||||
clock_delta_history: VecDeque::with_capacity(20),
|
ewma_clock_delta: None,
|
||||||
last_match_status: "UNKNOWN".into(),
|
last_match_status: "UNKNOWN".into(),
|
||||||
last_match_check: 0,
|
last_match_check: 0,
|
||||||
}
|
}
|
||||||
|
|
@ -69,12 +71,14 @@ impl LtcState {
|
||||||
self.offset_history.push_back(offset_ms);
|
self.offset_history.push_back(offset_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record one timecode Δ in ms.
|
/// Update EWMA of clock delta.
|
||||||
pub fn record_clock_delta(&mut self, delta_ms: i64) {
|
pub fn record_and_update_ewma_clock_delta(&mut self, delta_ms: i64) {
|
||||||
if self.clock_delta_history.len() == 20 {
|
let new_delta = delta_ms as f64;
|
||||||
self.clock_delta_history.pop_front();
|
if let Some(current_ewma) = self.ewma_clock_delta {
|
||||||
|
self.ewma_clock_delta = Some(EWMA_ALPHA * new_delta + (1.0 - EWMA_ALPHA) * current_ewma);
|
||||||
|
} else {
|
||||||
|
self.ewma_clock_delta = Some(new_delta);
|
||||||
}
|
}
|
||||||
self.clock_delta_history.push_back(delta_ms);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all stored jitter measurements.
|
/// Clear all stored jitter measurements.
|
||||||
|
|
@ -82,11 +86,6 @@ impl LtcState {
|
||||||
self.offset_history.clear();
|
self.offset_history.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all stored timecode Δ measurements.
|
|
||||||
pub fn clear_clock_deltas(&mut self) {
|
|
||||||
self.clock_delta_history.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update LOCK/FREE counts and timecode-match status every 5 s.
|
/// Update LOCK/FREE counts and timecode-match status every 5 s.
|
||||||
pub fn update(&mut self, frame: LtcFrame) {
|
pub fn update(&mut self, frame: LtcFrame) {
|
||||||
match frame.status.as_str() {
|
match frame.status.as_str() {
|
||||||
|
|
@ -108,7 +107,7 @@ impl LtcState {
|
||||||
"FREE" => {
|
"FREE" => {
|
||||||
self.free_count += 1;
|
self.free_count += 1;
|
||||||
self.clear_offsets();
|
self.clear_offsets();
|
||||||
self.clear_clock_deltas();
|
self.ewma_clock_delta = None;
|
||||||
self.last_match_status = "UNKNOWN".into();
|
self.last_match_status = "UNKNOWN".into();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -137,23 +136,9 @@ impl LtcState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Median timecode Δ over stored history, in ms.
|
/// Get EWMA of clock delta, in ms.
|
||||||
pub fn average_clock_delta(&self) -> i64 {
|
pub fn get_ewma_clock_delta(&self) -> i64 {
|
||||||
if self.clock_delta_history.is_empty() {
|
self.ewma_clock_delta.map_or(0, |v| v.round() as i64)
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sorted_deltas: Vec<i64> = self.clock_delta_history.iter().cloned().collect();
|
|
||||||
sorted_deltas.sort_unstable();
|
|
||||||
|
|
||||||
let mid = sorted_deltas.len() / 2;
|
|
||||||
if sorted_deltas.len() % 2 == 0 {
|
|
||||||
// Even number of elements, average the two middle ones
|
|
||||||
(sorted_deltas[mid - 1] + sorted_deltas[mid]) / 2
|
|
||||||
} else {
|
|
||||||
// Odd number of elements, return the middle one
|
|
||||||
sorted_deltas[mid]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percentage of samples seen in LOCK state versus total.
|
/// Percentage of samples seen in LOCK state versus total.
|
||||||
|
|
@ -326,35 +311,28 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_average_clock_delta_is_median() {
|
fn test_ewma_clock_delta() {
|
||||||
let mut state = LtcState::new();
|
let mut state = LtcState::new();
|
||||||
|
assert_eq!(state.get_ewma_clock_delta(), 0);
|
||||||
|
|
||||||
// Establish a stable set of values
|
// First value initializes the EWMA
|
||||||
for _ in 0..19 {
|
state.record_and_update_ewma_clock_delta(100);
|
||||||
state.record_clock_delta(2);
|
assert_eq!(state.get_ewma_clock_delta(), 100);
|
||||||
}
|
|
||||||
state.record_clock_delta(100); // Add an outlier
|
|
||||||
|
|
||||||
// With 19 `2`s and one `100`, the median should still be `2`.
|
// Second value moves it
|
||||||
// The simple average would be (19*2 + 100) / 20 = 138 / 20 = 6.
|
state.record_and_update_ewma_clock_delta(200);
|
||||||
assert_eq!(
|
// 0.1 * 200 + 0.9 * 100 = 20 + 90 = 110
|
||||||
state.average_clock_delta(),
|
assert_eq!(state.get_ewma_clock_delta(), 110);
|
||||||
2,
|
|
||||||
"Median should ignore the outlier"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test with an even number of elements
|
// Third value
|
||||||
state.clear_clock_deltas();
|
state.record_and_update_ewma_clock_delta(100);
|
||||||
state.record_clock_delta(1);
|
// 0.1 * 100 + 0.9 * 110 = 10 + 99 = 109
|
||||||
state.record_clock_delta(2);
|
assert_eq!(state.get_ewma_clock_delta(), 109);
|
||||||
state.record_clock_delta(3);
|
|
||||||
state.record_clock_delta(100);
|
// Reset on FREE frame
|
||||||
// sorted: [1, 2, 3, 100]. mid two are 2, 3. average is (2+3)/2 = 2.
|
state.update(get_test_frame("FREE", 0, 0, 0));
|
||||||
assert_eq!(
|
assert_eq!(state.get_ewma_clock_delta(), 0);
|
||||||
state.average_clock_delta(),
|
assert!(state.ewma_clock_delta.is_none());
|
||||||
2,
|
|
||||||
"Median of even numbers should be correct"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,33 @@ pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result<String, ()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn nudge_clock(microseconds: i64) -> Result<(), ()> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let success = Command::new("sudo")
|
||||||
|
.arg("adjtimex")
|
||||||
|
.arg("--singleshot")
|
||||||
|
.arg(microseconds.to_string())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if success {
|
||||||
|
log::info!("Nudged clock by {} us", microseconds);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
log::error!("Failed to nudge clock with adjtimex");
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let _ = microseconds;
|
||||||
|
log::warn!("Clock nudging is only supported on Linux.");
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -179,4 +206,10 @@ mod tests {
|
||||||
assert_eq!(target_time.second(), 20);
|
assert_eq!(target_time.second(), 20);
|
||||||
assert_eq!(target_time.nanosecond(), 0);
|
assert_eq!(target_time.nanosecond(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nudge_clock_on_non_linux() {
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
assert!(nudge_clock(1000).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,13 @@
|
||||||
<button id="manual-sync">Manual Sync</button>
|
<button id="manual-sync">Manual Sync</button>
|
||||||
<span id="sync-message"></span>
|
<span id="sync-message"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Nudge Clock (ms):</label>
|
||||||
|
<button id="nudge-down">-</button>
|
||||||
|
<input type="number" id="nudge-value" style="width: 60px;">
|
||||||
|
<button id="nudge-up">+</button>
|
||||||
|
<span id="nudge-message"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs -->
|
<!-- Logs -->
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const manualSyncButton = document.getElementById('manual-sync');
|
const manualSyncButton = document.getElementById('manual-sync');
|
||||||
const syncMessage = document.getElementById('sync-message');
|
const syncMessage = document.getElementById('sync-message');
|
||||||
|
|
||||||
|
const nudgeDownButton = document.getElementById('nudge-down');
|
||||||
|
const nudgeUpButton = document.getElementById('nudge-up');
|
||||||
|
const nudgeValueInput = document.getElementById('nudge-value');
|
||||||
|
const nudgeMessage = document.getElementById('nudge-message');
|
||||||
|
|
||||||
function updateStatus(data) {
|
function updateStatus(data) {
|
||||||
statusElements.ltcStatus.textContent = data.ltc_status;
|
statusElements.ltcStatus.textContent = data.ltc_status;
|
||||||
statusElements.ltcTimecode.textContent = data.ltc_timecode;
|
statusElements.ltcTimecode.textContent = data.ltc_timecode;
|
||||||
|
|
@ -79,6 +84,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
offsetInputs.m.value = data.timeturnerOffset.minutes;
|
offsetInputs.m.value = data.timeturnerOffset.minutes;
|
||||||
offsetInputs.s.value = data.timeturnerOffset.seconds;
|
offsetInputs.s.value = data.timeturnerOffset.seconds;
|
||||||
offsetInputs.f.value = data.timeturnerOffset.frames;
|
offsetInputs.f.value = data.timeturnerOffset.frames;
|
||||||
|
nudgeValueInput.value = data.defaultNudgeMs;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching config:', error);
|
console.error('Error fetching config:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +93,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
const config = {
|
const config = {
|
||||||
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
|
hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0,
|
||||||
|
defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0,
|
||||||
timeturnerOffset: {
|
timeturnerOffset: {
|
||||||
hours: parseInt(offsetInputs.h.value, 10) || 0,
|
hours: parseInt(offsetInputs.h.value, 10) || 0,
|
||||||
minutes: parseInt(offsetInputs.m.value, 10) || 0,
|
minutes: parseInt(offsetInputs.m.value, 10) || 0,
|
||||||
|
|
@ -140,8 +147,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function nudgeClock(ms) {
|
||||||
|
nudgeMessage.textContent = 'Nudging clock...';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/nudge_clock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ microseconds: ms * 1000 }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
nudgeMessage.textContent = `Success: ${data.message}`;
|
||||||
|
} else {
|
||||||
|
nudgeMessage.textContent = `Error: ${data.message}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error nudging clock:', error);
|
||||||
|
nudgeMessage.textContent = 'Failed to send nudge command.';
|
||||||
|
}
|
||||||
|
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
saveConfigButton.addEventListener('click', saveConfig);
|
saveConfigButton.addEventListener('click', saveConfig);
|
||||||
manualSyncButton.addEventListener('click', triggerManualSync);
|
manualSyncButton.addEventListener('click', triggerManualSync);
|
||||||
|
nudgeDownButton.addEventListener('click', () => {
|
||||||
|
const ms = parseInt(nudgeValueInput.value, 10) || 0;
|
||||||
|
nudgeClock(-ms);
|
||||||
|
});
|
||||||
|
nudgeUpButton.addEventListener('click', () => {
|
||||||
|
const ms = parseInt(nudgeValueInput.value, 10) || 0;
|
||||||
|
nudgeClock(ms);
|
||||||
|
});
|
||||||
|
|
||||||
// Initial data load
|
// Initial data load
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue