use std::fmt;
use std::time::{Duration, Instant};
const MAX_NODES: usize = 16;
const DEFAULT_GUARD_US: u64 = 1_000;
const DEFAULT_PROCESSING_MS: u64 = 30;
const DEFAULT_SLOT_MS: u64 = 4;
const CRYSTAL_DRIFT_PPM: f64 = 10.0;
#[derive(Debug, Clone, PartialEq)]
pub enum TdmError {
InvalidNodeCount { count: usize, max: usize },
SlotIndexOutOfBounds { index: usize, num_slots: usize },
UnknownNode { node_id: u8 },
GuardIntervalTooLarge { guard_us: u64, slot_us: u64 },
CycleTooShort { needed_us: u64, available_us: u64 },
DriftExceedsGuard { drift_us: f64, guard_us: u64 },
}
impl fmt::Display for TdmError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TdmError::InvalidNodeCount { count, max } => {
write!(f, "Invalid node count: {} (max {})", count, max)
}
TdmError::SlotIndexOutOfBounds { index, num_slots } => {
write!(
f,
"Slot index {} out of bounds (schedule has {} slots)",
index, num_slots
)
}
TdmError::UnknownNode { node_id } => {
write!(f, "Unknown node ID: {}", node_id)
}
TdmError::GuardIntervalTooLarge { guard_us, slot_us } => {
write!(
f,
"Guard interval {} us exceeds slot duration {} us",
guard_us, slot_us
)
}
TdmError::CycleTooShort {
needed_us,
available_us,
} => {
write!(
f,
"Cycle too short: need {} us, have {} us",
needed_us, available_us
)
}
TdmError::DriftExceedsGuard { drift_us, guard_us } => {
write!(
f,
"Drift {:.1} us exceeds guard interval {} us",
drift_us, guard_us
)
}
}
}
}
impl std::error::Error for TdmError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TdmSlot {
pub index: usize,
pub tx_node_id: u8,
pub duration: Duration,
pub guard_interval: Duration,
}
impl TdmSlot {
pub fn total_duration(&self) -> Duration {
self.duration + self.guard_interval
}
pub fn start_offset(slots: &[TdmSlot], index: usize) -> Option<Duration> {
if index >= slots.len() {
return None;
}
let mut offset = Duration::ZERO;
for slot in &slots[..index] {
offset += slot.total_duration();
}
Some(offset)
}
}
#[derive(Debug, Clone)]
pub struct TdmSchedule {
slots: Vec<TdmSlot>,
processing_window: Duration,
cycle_period: Duration,
}
impl TdmSchedule {
pub fn uniform(
node_ids: &[u8],
slot_duration: Duration,
guard_interval: Duration,
processing_window: Duration,
) -> Result<Self, TdmError> {
if node_ids.is_empty() || node_ids.len() > MAX_NODES {
return Err(TdmError::InvalidNodeCount {
count: node_ids.len(),
max: MAX_NODES,
});
}
let slot_us = slot_duration.as_micros() as u64;
let guard_us = guard_interval.as_micros() as u64;
if guard_us >= slot_us {
return Err(TdmError::GuardIntervalTooLarge { guard_us, slot_us });
}
let slots: Vec<TdmSlot> = node_ids
.iter()
.enumerate()
.map(|(i, &node_id)| TdmSlot {
index: i,
tx_node_id: node_id,
duration: slot_duration,
guard_interval,
})
.collect();
let tx_total: Duration = slots.iter().map(|s| s.total_duration()).sum();
let cycle_period = tx_total + processing_window;
Ok(Self {
slots,
processing_window,
cycle_period,
})
}
pub fn default_4node() -> Self {
Self::uniform(
&[0, 1, 2, 3],
Duration::from_millis(DEFAULT_SLOT_MS),
Duration::from_micros(DEFAULT_GUARD_US),
Duration::from_millis(DEFAULT_PROCESSING_MS),
)
.expect("default 4-node schedule is always valid")
}
pub fn node_count(&self) -> usize {
self.slots.len()
}
pub fn cycle_period(&self) -> Duration {
self.cycle_period
}
pub fn update_rate_hz(&self) -> f64 {
1.0 / self.cycle_period.as_secs_f64()
}
pub fn processing_window(&self) -> Duration {
self.processing_window
}
pub fn slot(&self, index: usize) -> Option<&TdmSlot> {
self.slots.get(index)
}
pub fn slot_for_node(&self, node_id: u8) -> Option<&TdmSlot> {
self.slots.iter().find(|s| s.tx_node_id == node_id)
}
pub fn slots(&self) -> &[TdmSlot] {
&self.slots
}
pub fn max_drift_us(&self) -> f64 {
CRYSTAL_DRIFT_PPM * 1e-6 * self.cycle_period.as_secs_f64() * 1e6
}
pub fn drift_within_guard(&self) -> bool {
let drift = self.max_drift_us();
let guard = self
.slots
.first()
.map_or(0, |s| s.guard_interval.as_micros() as u64);
drift < guard as f64
}
}
#[derive(Debug, Clone)]
pub struct TdmSlotCompleted {
pub cycle_id: u64,
pub slot_index: usize,
pub tx_node_id: u8,
pub capture_quality: f32,
pub completed_at: Instant,
}
#[derive(Debug, Clone)]
pub struct SyncBeacon {
pub cycle_id: u64,
pub cycle_period: Duration,
pub drift_correction_us: i16,
pub generated_at: Instant,
}
impl SyncBeacon {
pub fn to_bytes(&self) -> [u8; 16] {
let mut buf = [0u8; 16];
buf[0..8].copy_from_slice(&self.cycle_id.to_le_bytes());
let period_us = self.cycle_period.as_micros() as u32;
buf[8..12].copy_from_slice(&period_us.to_le_bytes());
buf[12..14].copy_from_slice(&self.drift_correction_us.to_le_bytes());
buf
}
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
if buf.len() < 16 {
return None;
}
let cycle_id = u64::from_le_bytes([
buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7],
]);
let period_us = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let drift_correction_us = i16::from_le_bytes([buf[12], buf[13]]);
Some(Self {
cycle_id,
cycle_period: Duration::from_micros(period_us as u64),
drift_correction_us,
generated_at: Instant::now(),
})
}
}
#[derive(Debug)]
pub struct TdmCoordinator {
schedule: TdmSchedule,
cycle_id: u64,
next_slot: usize,
cycle_active: bool,
received: Vec<bool>,
cumulative_drift_us: f64,
last_cycle_start: Option<Instant>,
}
impl TdmCoordinator {
pub fn new(schedule: TdmSchedule) -> Self {
let n = schedule.node_count();
Self {
schedule,
cycle_id: 0,
next_slot: 0,
cycle_active: false,
received: vec![false; n],
cumulative_drift_us: 0.0,
last_cycle_start: None,
}
}
pub fn begin_cycle(&mut self) -> SyncBeacon {
if self.cycle_active {
self.cycle_active = false;
}
if self.last_cycle_start.is_some() {
self.cycle_id += 1;
}
self.next_slot = 0;
self.cycle_active = true;
for flag in &mut self.received {
*flag = false;
}
let now = Instant::now();
if let Some(prev) = self.last_cycle_start {
let actual_us = now.duration_since(prev).as_micros() as f64;
let expected_us = self.schedule.cycle_period().as_micros() as f64;
let drift = actual_us - expected_us;
self.cumulative_drift_us += drift;
}
self.last_cycle_start = Some(now);
let correction = (-self.cumulative_drift_us)
.round()
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
SyncBeacon {
cycle_id: self.cycle_id,
cycle_period: self.schedule.cycle_period(),
drift_correction_us: correction,
generated_at: now,
}
}
pub fn complete_slot(&mut self, slot_index: usize, capture_quality: f32) -> TdmSlotCompleted {
let quality = capture_quality.clamp(0.0, 1.0);
let tx_node_id = self
.schedule
.slot(slot_index)
.map(|s| s.tx_node_id)
.unwrap_or(0);
if slot_index < self.received.len() {
self.received[slot_index] = true;
}
if slot_index == self.next_slot {
self.next_slot += 1;
}
TdmSlotCompleted {
cycle_id: self.cycle_id,
slot_index,
tx_node_id,
capture_quality: quality,
completed_at: Instant::now(),
}
}
pub fn is_cycle_complete(&self) -> bool {
self.received.iter().all(|&r| r)
}
pub fn completed_slot_count(&self) -> usize {
self.received.iter().filter(|&&r| r).count()
}
pub fn cycle_id(&self) -> u64 {
self.cycle_id
}
pub fn is_active(&self) -> bool {
self.cycle_active
}
pub fn schedule(&self) -> &TdmSchedule {
&self.schedule
}
pub fn cumulative_drift_us(&self) -> f64 {
self.cumulative_drift_us
}
pub fn max_single_cycle_drift_us(&self) -> f64 {
self.schedule.max_drift_us()
}
pub fn current_beacon(&self) -> SyncBeacon {
let correction = (-self.cumulative_drift_us)
.round()
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
SyncBeacon {
cycle_id: self.cycle_id,
cycle_period: self.schedule.cycle_period(),
drift_correction_us: correction,
generated_at: Instant::now(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_4node_schedule() {
let schedule = TdmSchedule::default_4node();
assert_eq!(schedule.node_count(), 4);
assert_eq!(schedule.cycle_period().as_millis(), 50);
assert_eq!(schedule.update_rate_hz(), 20.0);
assert!(schedule.drift_within_guard());
}
#[test]
fn test_uniform_schedule_timing() {
let schedule = TdmSchedule::uniform(
&[10, 20, 30],
Duration::from_millis(5),
Duration::from_micros(500),
Duration::from_millis(20),
)
.unwrap();
assert_eq!(schedule.node_count(), 3);
let expected_us: u64 = 3 * (5_000 + 500) + 20_000;
assert_eq!(schedule.cycle_period().as_micros() as u64, expected_us);
}
#[test]
fn test_slot_for_node() {
let schedule = TdmSchedule::uniform(
&[5, 10, 15],
Duration::from_millis(4),
Duration::from_micros(1_000),
Duration::from_millis(30),
)
.unwrap();
let slot = schedule.slot_for_node(10).unwrap();
assert_eq!(slot.index, 1);
assert_eq!(slot.tx_node_id, 10);
assert!(schedule.slot_for_node(99).is_none());
}
#[test]
fn test_slot_start_offset() {
let schedule = TdmSchedule::uniform(
&[0, 1, 2, 3],
Duration::from_millis(4),
Duration::from_micros(1_000),
Duration::from_millis(30),
)
.unwrap();
let offset0 = TdmSlot::start_offset(schedule.slots(), 0).unwrap();
assert_eq!(offset0, Duration::ZERO);
let offset1 = TdmSlot::start_offset(schedule.slots(), 1).unwrap();
assert_eq!(offset1.as_micros(), 5_000);
let offset2 = TdmSlot::start_offset(schedule.slots(), 2).unwrap();
assert_eq!(offset2.as_micros(), 10_000);
assert!(TdmSlot::start_offset(schedule.slots(), 10).is_none());
}
#[test]
fn test_empty_node_list_rejected() {
let result = TdmSchedule::uniform(
&[],
Duration::from_millis(4),
Duration::from_micros(1_000),
Duration::from_millis(30),
);
assert_eq!(
result.unwrap_err(),
TdmError::InvalidNodeCount {
count: 0,
max: MAX_NODES
}
);
}
#[test]
fn test_too_many_nodes_rejected() {
let ids: Vec<u8> = (0..=MAX_NODES as u8).collect();
let result = TdmSchedule::uniform(
&ids,
Duration::from_millis(4),
Duration::from_micros(1_000),
Duration::from_millis(30),
);
assert!(matches!(result, Err(TdmError::InvalidNodeCount { .. })));
}
#[test]
fn test_guard_interval_too_large() {
let result = TdmSchedule::uniform(
&[0, 1],
Duration::from_millis(1), Duration::from_millis(2), Duration::from_millis(30),
);
assert!(matches!(
result,
Err(TdmError::GuardIntervalTooLarge { .. })
));
}
#[test]
fn test_max_drift_calculation() {
let schedule = TdmSchedule::default_4node();
let drift = schedule.max_drift_us();
assert!((drift - 0.5).abs() < 0.01);
}
#[test]
fn test_sync_beacon_roundtrip() {
let beacon = SyncBeacon {
cycle_id: 42,
cycle_period: Duration::from_millis(50),
drift_correction_us: -3,
generated_at: Instant::now(),
};
let bytes = beacon.to_bytes();
assert_eq!(bytes.len(), 16);
let decoded = SyncBeacon::from_bytes(&bytes).unwrap();
assert_eq!(decoded.cycle_id, 42);
assert_eq!(decoded.cycle_period, Duration::from_millis(50));
assert_eq!(decoded.drift_correction_us, -3);
}
#[test]
fn test_sync_beacon_short_buffer() {
assert!(SyncBeacon::from_bytes(&[0u8; 10]).is_none());
}
#[test]
fn test_sync_beacon_zero_drift() {
let beacon = SyncBeacon {
cycle_id: 0,
cycle_period: Duration::from_millis(50),
drift_correction_us: 0,
generated_at: Instant::now(),
};
let bytes = beacon.to_bytes();
let decoded = SyncBeacon::from_bytes(&bytes).unwrap();
assert_eq!(decoded.drift_correction_us, 0);
}
#[test]
fn test_coordinator_begin_cycle() {
let schedule = TdmSchedule::default_4node();
let mut coord = TdmCoordinator::new(schedule);
let beacon = coord.begin_cycle();
assert_eq!(beacon.cycle_id, 0);
assert!(coord.is_active());
assert!(!coord.is_cycle_complete());
assert_eq!(coord.completed_slot_count(), 0);
}
#[test]
fn test_coordinator_complete_all_slots() {
let schedule = TdmSchedule::default_4node();
let mut coord = TdmCoordinator::new(schedule);
coord.begin_cycle();
for i in 0..4 {
assert!(!coord.is_cycle_complete());
let event = coord.complete_slot(i, 0.95);
assert_eq!(event.cycle_id, 0);
assert_eq!(event.slot_index, i);
}
assert!(coord.is_cycle_complete());
assert_eq!(coord.completed_slot_count(), 4);
}
#[test]
fn test_coordinator_cycle_id_increments() {
let schedule = TdmSchedule::default_4node();
let mut coord = TdmCoordinator::new(schedule);
let b0 = coord.begin_cycle();
assert_eq!(b0.cycle_id, 0);
for i in 0..4 {
coord.complete_slot(i, 1.0);
}
let b1 = coord.begin_cycle();
assert_eq!(b1.cycle_id, 1);
for i in 0..4 {
coord.complete_slot(i, 1.0);
}
let b2 = coord.begin_cycle();
assert_eq!(b2.cycle_id, 2);
}
#[test]
fn test_coordinator_capture_quality_clamped() {
let schedule = TdmSchedule::default_4node();
let mut coord = TdmCoordinator::new(schedule);
coord.begin_cycle();
let event = coord.complete_slot(0, 1.5);
assert_eq!(event.capture_quality, 1.0);
let event = coord.complete_slot(1, -0.5);
assert_eq!(event.capture_quality, 0.0);
}
#[test]
fn test_coordinator_current_beacon() {
let schedule = TdmSchedule::default_4node();
let mut coord = TdmCoordinator::new(schedule);
coord.begin_cycle();
let beacon = coord.current_beacon();
assert_eq!(beacon.cycle_id, 0);
assert_eq!(beacon.cycle_period.as_millis(), 50);
}
#[test]
fn test_coordinator_drift_starts_at_zero() {
let schedule = TdmSchedule::default_4node();
let coord = TdmCoordinator::new(schedule);
assert_eq!(coord.cumulative_drift_us(), 0.0);
}
#[test]
fn test_coordinator_max_single_cycle_drift() {
let schedule = TdmSchedule::default_4node();
let coord = TdmCoordinator::new(schedule);
let drift = coord.max_single_cycle_drift_us();
assert!((drift - 0.5).abs() < 0.01);
}
}