use crate::portable_math::PortableFloat;
use serde::Serialize;
use super::optim::{grid_search, nelder_mead_bounded, NmOptions};
pub const MIN_SPAN_DB: f64 = 8.0;
pub const MAX_ABS_CORR: f64 = 0.999;
pub fn model_cn0_dbhz(c0_dbhz: f64, js_db: f64, q: f64, delta_db: f64, chip_rate_hz: f64) -> f64 {
crate::jamming::effective_cn0_dbhz(c0_dbhz, js_db + delta_db, q, chip_rate_hz)
}
pub fn threshold_js_db(
c0_dbhz: f64,
c_thr_dbhz: f64,
q: f64,
delta_db: f64,
chip_rate_hz: f64,
) -> Option<f64> {
if !(c_thr_dbhz.is_finite() && c0_dbhz.is_finite()) {
return None;
}
let lin = |x: f64| 10f64.ppowf(x / 10.0);
let a = q * chip_rate_hz * (1.0 / lin(c_thr_dbhz) - 1.0 / lin(c0_dbhz));
(a > 0.0).then(|| 10.0 * a.plog10() - delta_db)
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct Cn0Point {
pub c0_dbhz: f64,
pub js_db: f64,
pub cn0_dbhz: f64,
pub weight: f64,
pub chip_rate_hz: f64,
}
impl Cn0Point {
pub fn residual(&self, q: f64, delta_db: f64) -> f64 {
self.cn0_dbhz - model_cn0_dbhz(self.c0_dbhz, self.js_db, q, delta_db, self.chip_rate_hz)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum QMode {
Fitted,
FixedStated,
}
impl QMode {
pub fn as_str(self) -> &'static str {
match self {
QMode::Fitted => "fitted",
QMode::FixedStated => "fixed-stated",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Cn0FitCfg {
pub q_bounds: [f64; 2],
pub delta_bounds_db: [f64; 2],
pub q_stated: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct Cn0Fit {
pub q: f64,
pub delta_db: f64,
pub mode: QMode,
pub corr_q_delta: Option<f64>,
pub span_db: f64,
pub residual_rms_db: f64,
pub n_points: usize,
}
pub fn jacobian_corr(points: &[Cn0Point], q: f64, delta_db: f64) -> Option<f64> {
let (mut a, mut b, mut c) = (0.0, 0.0, 0.0);
for p in points {
let lin = |x: f64| 10f64.ppowf(x / 10.0);
let jt = lin(p.js_db + delta_db) / (q * p.chip_rate_hz);
let s = jt / (1.0 / lin(p.c0_dbhz) + jt);
let (jq, jd) = (s, -s);
a += p.weight * jq * jq;
b += p.weight * jq * jd;
c += p.weight * jd * jd;
}
(a > 0.0 && c > 0.0).then(|| (-b / (a * c).sqrt()).clamp(-1.0, 1.0))
}
fn wsse(points: &[Cn0Point], q: f64, delta: f64) -> f64 {
points
.iter()
.map(|p| p.weight * p.residual(q, delta).ppowi(2))
.sum()
}
fn opts() -> NmOptions {
NmOptions {
max_evals: 4000,
ftol: 1e-13,
xtol: 1e-10,
initial_step: 0.5,
max_restarts: 3,
}
}
fn fit_delta(points: &[Cn0Point], q: f64, cfg: &Cn0FitCfg) -> f64 {
let [lo, hi] = cfg.delta_bounds_db;
let f = |x: &[f64]| wsse(points, q, x[0]);
let start = grid_search(f, &[lo], &[hi], 41)
.first()
.map_or(0.0, |(x, _)| x[0]);
nelder_mead_bounded(f, &[start], &[lo], &[hi], &opts()).x[0]
}
fn fit_both(points: &[Cn0Point], cfg: &Cn0FitCfg) -> (f64, f64) {
let qlo = 10.0 * cfg.q_bounds[0].plog10();
let qhi = 10.0 * cfg.q_bounds[1].plog10();
let [dlo, dhi] = cfg.delta_bounds_db;
let f = |x: &[f64]| wsse(points, 10f64.ppowf(x[0] / 10.0), x[1]);
let lo = [qlo, dlo];
let hi = [qhi, dhi];
let mut best: Option<(Vec<f64>, f64)> = None;
for (x, _) in grid_search(f, &lo, &hi, 21).into_iter().take(3) {
let r = nelder_mead_bounded(f, &x, &lo, &hi, &opts());
if best.as_ref().is_none_or(|b| r.f < b.1) {
best = Some((r.x, r.f));
}
}
let x = best.map_or(vec![10.0 * cfg.q_stated.plog10(), 0.0], |b| b.0);
(10f64.ppowf(x[0] / 10.0), x[1])
}
pub fn delta_eff_db(f: &Cn0Fit) -> f64 {
f.delta_db - 10.0 * f.q.plog10()
}
pub fn span_db(points: &[Cn0Point]) -> f64 {
let lo = points.iter().map(|p| p.js_db).fold(f64::INFINITY, f64::min);
let hi = points
.iter()
.map(|p| p.js_db)
.fold(f64::NEG_INFINITY, f64::max);
if hi >= lo {
hi - lo
} else {
0.0
}
}
pub fn choose_mode(points: &[Cn0Point], cfg: &Cn0FitCfg) -> (QMode, Option<f64>) {
let corr = jacobian_corr(points, cfg.q_stated, 0.0);
let degenerate = corr.is_none_or(|c| c.abs() >= MAX_ABS_CORR);
if span_db(points) < MIN_SPAN_DB || degenerate {
(QMode::FixedStated, corr)
} else {
(QMode::Fitted, corr)
}
}
pub fn fit_in_mode(points: &[Cn0Point], cfg: &Cn0FitCfg, mode: QMode) -> Option<Cn0Fit> {
if points.is_empty() || points.iter().all(|p| p.weight <= 0.0) {
return None;
}
let (q, delta_db) = match mode {
QMode::FixedStated => (cfg.q_stated, fit_delta(points, cfg.q_stated, cfg)),
QMode::Fitted => fit_both(points, cfg),
};
let wsum: f64 = points.iter().map(|p| p.weight).sum();
Some(Cn0Fit {
q,
delta_db,
mode,
corr_q_delta: jacobian_corr(points, cfg.q_stated, 0.0),
span_db: span_db(points),
residual_rms_db: (wsse(points, q, delta_db) / wsum).sqrt(),
n_points: points.len(),
})
}
pub fn fit(points: &[Cn0Point], cfg: &Cn0FitCfg) -> Option<Cn0Fit> {
let (mode, _) = choose_mode(points, cfg);
fit_in_mode(points, cfg, mode)
}
#[cfg(test)]
mod tests {
use super::*;
const RC: f64 = 1.023e6;
fn pts(q: f64, delta: f64, js: &[f64]) -> Vec<Cn0Point> {
js.iter()
.map(|&j| Cn0Point {
c0_dbhz: 45.0,
js_db: j,
cn0_dbhz: model_cn0_dbhz(45.0, j, q, delta, RC),
weight: 100.0,
chip_rate_hz: RC,
})
.collect()
}
fn cfg(q: f64) -> Cn0FitCfg {
Cn0FitCfg {
q_bounds: [0.1, 10.0],
delta_bounds_db: [-10.0, 10.0],
q_stated: q,
}
}
#[test]
fn q_and_delta_are_exactly_degenerate() {
for j in [0.0, 15.0, 30.0] {
let a = model_cn0_dbhz(45.0, j, 1.0, 0.0, RC);
let b = model_cn0_dbhz(45.0, j, 2.0, 10.0 * 2f64.plog10(), RC);
assert!((a - b).abs() < 1e-12);
}
let p = pts(2.0, 1.5, &[0.0, 10.0, 20.0, 30.0, 40.0]);
let c = jacobian_corr(&p, 2.0, 0.0).unwrap();
assert!((c - 1.0).abs() < 1e-12, "corr {c}");
let (mode, _) = choose_mode(&p, &cfg(2.0));
assert_eq!(mode, QMode::FixedStated);
}
#[test]
fn delta_is_recovered_at_the_stated_q() {
let p = pts(2.0, 1.5, &[10.0, 15.0, 20.0, 25.0, 30.0]);
let f = fit(&p, &cfg(2.0)).unwrap();
assert_eq!(f.mode, QMode::FixedStated);
assert!((f.delta_db - 1.5).abs() < 1e-6, "{f:?}");
assert!(f.residual_rms_db < 1e-6);
let f = fit(&p, &cfg(1.0)).unwrap();
assert!((f.delta_db - (1.5 - 10.0 * 2f64.plog10())).abs() < 1e-6);
}
#[test]
fn the_closed_form_threshold_inverts_the_model() {
let (q, d) = (2.0, -1.0);
let thr = threshold_js_db(45.0, 28.0, q, d, RC).unwrap();
assert!((model_cn0_dbhz(45.0, thr, q, d, RC) - 28.0).abs() < 1e-9);
assert!(threshold_js_db(45.0, 46.0, q, d, RC).is_none());
}
}