use serde::Serialize;
#[derive(Clone, Copy, Debug)]
pub struct IotReceiver {
pub active_power_mw: f64,
pub sleep_power_uw: f64,
pub battery_mwh: f64,
pub detection_snr_db: f64,
pub max_coherent_s: f64,
pub parallel_code_search: bool,
pub correlators: usize,
pub fix_compute_s: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct IotSignal {
pub cn0_dbhz: f64,
pub code_length_chips: f64,
pub doppler_uncertainty_hz: f64,
pub doppler_rate_hz_s: f64,
pub data_rate_bps: f64,
pub cold_start_bits: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct FixBudget {
pub coherent_s: f64,
pub noncoherent_sums: u64,
pub doppler_bins: u64,
pub acquisition_s: f64,
pub message_s: f64,
pub ttff_s: f64,
pub energy_per_fix_mj: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct DutyPoint {
pub fix_interval_s: f64,
pub duty_cycle: f64,
pub average_power_mw: f64,
pub battery_life_days: f64,
}
pub fn fix_budget(sig: &IotSignal, rx: &IotReceiver, cold: bool) -> FixBudget {
let rate = sig.doppler_rate_hz_s.abs().max(1e-9);
let t = rx.max_coherent_s.min((0.5 / rate).sqrt()).max(1e-4);
let snr_coh = 10f64.powf(sig.cn0_dbhz / 10.0) * t;
let snr_req = 10f64.powf(rx.detection_snr_db / 10.0);
let n_nc = if snr_coh >= snr_req {
1
} else {
((snr_req / snr_coh).powi(2)).ceil().min(1e9) as u64
};
let dwell = n_nc as f64 * t;
let bins = (4.0 * sig.doppler_uncertainty_hz * t).ceil().max(1.0) as u64;
let code_cells = 2.0 * sig.code_length_chips.max(1.0);
let acq = if rx.parallel_code_search {
bins as f64 * dwell
} else {
bins as f64 * (code_cells / rx.correlators.max(1) as f64).ceil() * dwell
};
let msg = if cold && sig.data_rate_bps > 0.0 {
sig.cold_start_bits / sig.data_rate_bps
} else {
0.0
};
let ttff = acq + msg + rx.fix_compute_s;
FixBudget {
coherent_s: t,
noncoherent_sums: n_nc,
doppler_bins: bins,
acquisition_s: acq,
message_s: msg,
ttff_s: ttff,
energy_per_fix_mj: rx.active_power_mw * ttff,
}
}
pub fn duty_curve(fix: &FixBudget, rx: &IotReceiver, intervals_s: &[f64]) -> Vec<DutyPoint> {
intervals_s
.iter()
.map(|&tau| {
let tau = tau.max(1e-3);
let duty = (fix.ttff_s / tau).min(1.0);
let p = rx.sleep_power_uw * 1e-3 * (1.0 - duty) + rx.active_power_mw * duty;
DutyPoint {
fix_interval_s: tau,
duty_cycle: duty,
average_power_mw: p,
battery_life_days: rx.battery_mwh / p / 24.0,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn rx() -> IotReceiver {
IotReceiver {
active_power_mw: 20.0,
sleep_power_uw: 5.0,
battery_mwh: 2000.0,
detection_snr_db: 16.0,
max_coherent_s: 0.02,
parallel_code_search: true,
correlators: 64,
fix_compute_s: 0.1,
}
}
#[test]
fn stronger_signals_and_smaller_doppler_windows_fix_faster() {
let base = IotSignal {
cn0_dbhz: 30.0,
code_length_chips: 1023.0,
doppler_uncertainty_hz: 40_000.0,
doppler_rate_hz_s: 500.0,
data_rate_bps: 500.0,
cold_start_bits: 1000.0,
};
let a = fix_budget(&base, &rx(), false);
let b = fix_budget(
&IotSignal {
cn0_dbhz: 55.0,
..base
},
&rx(),
false,
);
let c = fix_budget(
&IotSignal {
doppler_uncertainty_hz: 500.0,
..base
},
&rx(),
false,
);
assert!(b.ttff_s < a.ttff_s && c.ttff_s < a.ttff_s);
let cold = fix_budget(&base, &rx(), true);
assert!((cold.ttff_s - a.ttff_s - 2.0).abs() < 1e-9);
assert!((a.energy_per_fix_mj - 20.0 * a.ttff_s).abs() < 1e-9);
}
#[test]
fn the_doppler_rate_caps_the_coherent_time() {
let s = IotSignal {
cn0_dbhz: 45.0,
code_length_chips: 1023.0,
doppler_uncertainty_hz: 1000.0,
doppler_rate_hz_s: 2000.0,
data_rate_bps: 0.0,
cold_start_bits: 0.0,
};
let f = fix_budget(&s, &rx(), true);
assert!((f.coherent_s - (0.5f64 / 2000.0).sqrt()).abs() < 1e-12);
assert_eq!(f.message_s, 0.0);
}
#[test]
fn battery_life_grows_with_the_fix_interval_toward_the_sleep_floor() {
let f = FixBudget {
coherent_s: 0.02,
noncoherent_sums: 1,
doppler_bins: 10,
acquisition_s: 1.0,
message_s: 0.0,
ttff_s: 1.0,
energy_per_fix_mj: 20.0,
};
let c = duty_curve(&f, &rx(), &[0.5, 10.0, 3600.0]);
assert_eq!(c[0].duty_cycle, 1.0);
assert!(c[1].battery_life_days < c[2].battery_life_days);
let floor = 2000.0 / 5e-3 / 24.0;
assert!(c[2].battery_life_days < floor);
}
}