use std::collections::BTreeMap;
use std::path::Path;
use serde::Serialize;
use crate::iq::campaign::conditions::{Interpolation, PowerProfile};
use crate::iq::campaign::hash::{sha256_file, sha256_hex};
use crate::iq::campaign::runner::cell_path;
use crate::iq::campaign::score::reference_q;
use crate::iq::campaign::{CellResult, Plan};
use crate::jamming::Q_CW;
pub const MIN_LOCKED_FRACTION: f64 = 0.9;
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Bin {
pub js_db: f64,
pub n: u64,
pub measured_cn0_dbhz: Option<f64>,
pub locked_fraction: f64,
pub censored: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SatEvent {
pub sat: i64,
pub event_id: String,
pub onset_s: f64,
pub offset_s: f64,
pub baseline_cn0_dbhz: Option<f64>,
pub bins: Vec<Bin>,
pub locked_at_onset: bool,
pub lost: bool,
pub time_to_loss_s: Option<f64>,
pub js_at_loss_db: Option<f64>,
pub reacquired: bool,
pub reacq_time_s: Option<f64>,
pub run_end_s: Option<f64>,
#[serde(skip)]
pub power: Option<PowerProfile>,
}
impl SatEvent {
pub fn js_at(&self, t_s: f64) -> Option<f64> {
self.power.as_ref().map(|p| p.js_db_at(t_s))
}
pub fn peak_js_db(&self) -> Option<f64> {
let p = self.power.as_ref()?;
let mut m = p.js_db_at(self.onset_s);
for pt in &p.points {
if pt[0] >= self.onset_s && pt[0] < self.offset_s {
m = m.max(p.js_db_at(pt[0]));
}
}
Some(m)
}
pub fn ramp_db_per_s(&self) -> f64 {
let Some(p) = &self.power else { return 0.0 };
let j0 = p.js_db_at(self.onset_s);
let mut best = (0.0, self.onset_s);
for pt in &p.points {
if pt[0] > self.onset_s && pt[0] <= self.offset_s {
let j = p.js_db_at(pt[0]) - j0;
if j > best.0 {
best = (j, pt[0]);
}
}
}
if p.interpolation == Interpolation::Step || best.1 <= self.onset_s {
0.0
} else {
best.0 / (best.1 - self.onset_s)
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct UnitRecording {
pub recording: String,
pub cell_key: String,
pub events: Vec<SatEvent>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Named {
pub name: String,
pub hash: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct FitUnit {
pub signal: String,
pub design: Named,
pub frontend: Named,
pub jammer_type: String,
pub chip_rate_hz: f64,
pub q_stated: f64,
pub q_source: String,
pub recordings: Vec<UnitRecording>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct CampaignData {
pub digest: String,
pub campaign_json_sha256: String,
pub cells: Vec<(String, String)>,
pub test_conditions: Vec<(String, String)>,
pub reacq_grace_s: f64,
pub units: Vec<FitUnit>,
}
pub fn stated_q(ev: &crate::iq::campaign::conditions::Event) -> (f64, String) {
match reference_q(ev) {
Some((q, s)) => (q, s.to_string()),
None => (
Q_CW,
format!(
"most damaging stated Q (cw): no table value for type `{}`",
ev.event_type.name()
),
),
}
}
fn bin_times(p: &PowerProfile, js: f64, w: f64, a: f64, b: f64, locked_end: f64) -> (f64, f64) {
if b <= a {
return (0.0, 0.0);
}
let n = (((b - a) / 1e-3).ceil() as usize).clamp(2000, 2_000_000);
let dt = (b - a) / n as f64;
let k = (js / w).round() as i64;
let (mut tot, mut locked) = (0.0, 0.0);
for i in 0..n {
let t = a + (i as f64 + 0.5) * dt;
let j = p.js_db_at(t);
if j.is_finite() && (j / w).round() as i64 == k {
tot += dt;
if t < locked_end {
locked += dt;
}
}
}
(tot, locked)
}
fn chip_rate(signal: &str, id: i64) -> Option<f64> {
use crate::iq::SpreadingCode as _;
crate::iq::cli::build_code(signal, id)
.ok()
.map(|c| c.chip_rate_hz())
}
pub fn load(out_dir: &Path) -> Result<CampaignData, String> {
let plan_path = out_dir.join("campaign.json");
let plan_bytes =
std::fs::read(&plan_path).map_err(|e| format!("{}: {e}", plan_path.display()))?;
let plan: Plan =
serde_json::from_slice(&plan_bytes).map_err(|e| format!("{}: {e}", plan_path.display()))?;
let digest = crate::iq::campaign::report::digest(out_dir)?
.ok_or_else(|| format!("{}: the campaign is not complete", out_dir.display()))?;
if let Ok(on_disk) = std::fs::read_to_string(out_dir.join("DIGEST")) {
if on_disk.trim() != digest {
return Err(format!(
"{}: DIGEST {} does not match the cells ({digest})",
out_dir.display(),
on_disk.trim()
));
}
}
let mut tc_hashes = Vec::new();
for r in &plan.recordings {
let h = r.conditions.hash().to_string();
if h != r.conditions_hash {
return Err(format!(
"recording `{}`: test conditions hash {h} does not match the recorded {}",
r.id, r.conditions_hash
));
}
tc_hashes.push((r.id.clone(), h));
}
let grace = plan.scoring.reacq_grace_s;
let bin_w = plan.scoring.js_bin_db;
let mut cells = Vec::new();
type Key = (String, Named, Named, String);
let mut units: BTreeMap<Key, FitUnit> = BTreeMap::new();
for pc in &plan.cells {
let path = cell_path(out_dir, &pc.key);
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let cell: CellResult =
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
cells.push((pc.key.clone(), sha256_file(&path)?.to_string()));
let rec = &plan.recordings[pc.recording];
let tc = &rec.conditions;
let design = Named {
name: cell.design.name.clone(),
hash: cell.design.hash.clone(),
};
let frontend = Named {
name: cell.frontend.name.clone(),
hash: cell.frontend.hash.clone(),
};
for cs in &cell.satellites {
let s = &cs.score;
for e in &s.events {
let Some(ev) = tc.events.iter().find(|x| x.id == e.event_id) else {
continue;
};
if ev.power.is_none() {
continue;
}
let jt = ev.event_type.name().to_string();
let (q_stated, q_source) = stated_q(ev);
let Some(rc) = e
.modelled
.as_ref()
.map(|m| m.chip_rate_hz)
.or_else(|| chip_rate(&s.signal, s.id))
else {
return Err(format!("signal `{}`: unknown chip rate", s.signal));
};
let locked_end = match (e.locked_at_onset, e.time_to_loss_s) {
(false, _) => e.onset_s,
(true, Some(t)) => e.onset_s + t,
(true, None) => e.offset_s,
};
let p = ev.power.clone().expect("checked above");
let bins = e
.cn0_curve
.iter()
.map(|b| {
let (tot, locked) =
bin_times(&p, b.js_db, bin_w, e.onset_s, e.offset_s, locked_end);
let frac = if tot > 0.0 { locked / tot } else { 0.0 };
Bin {
js_db: b.js_db,
n: b.n,
measured_cn0_dbhz: b.measured_cn0_dbhz,
locked_fraction: frac,
censored: frac < MIN_LOCKED_FRACTION
|| b.measured_cn0_dbhz.is_none()
|| b.n == 0,
}
})
.collect();
let key = (
s.signal.clone(),
design.clone(),
frontend.clone(),
jt.clone(),
);
let unit = units.entry(key).or_insert_with(|| FitUnit {
signal: s.signal.clone(),
design: design.clone(),
frontend: frontend.clone(),
jammer_type: jt.clone(),
chip_rate_hz: rc,
q_stated,
q_source: q_source.clone(),
recordings: Vec::new(),
});
if unit.q_stated != q_stated {
if q_stated < unit.q_stated {
unit.q_stated = q_stated;
unit.q_source = q_source.clone();
}
if !unit.q_source.contains("differs between events") {
unit.q_source.push_str(
"; stated Q differs between events: the smallest (most damaging) is used",
);
}
}
let se = SatEvent {
sat: s.id,
event_id: e.event_id.clone(),
onset_s: e.onset_s,
offset_s: e.offset_s,
baseline_cn0_dbhz: e.baseline_cn0_dbhz,
bins,
locked_at_onset: e.locked_at_onset,
lost: e.lost,
time_to_loss_s: e.time_to_loss_s,
js_at_loss_db: e.js_at_loss_db,
reacquired: e.reacquired,
reacq_time_s: e.reacq_time_s,
run_end_s: Some(tc.recording.settle_s + s.whole_run.scored_s),
power: Some(p),
};
match unit.recordings.iter_mut().find(|r| r.recording == rec.id) {
Some(r) => r.events.push(se),
None => unit.recordings.push(UnitRecording {
recording: rec.id.clone(),
cell_key: pc.key.clone(),
events: vec![se],
}),
}
}
}
}
let mut units: Vec<FitUnit> = units.into_values().collect();
for u in &mut units {
u.recordings.sort_by(|a, b| a.recording.cmp(&b.recording));
for r in &mut u.recordings {
r.events
.sort_by(|a, b| (a.sat, &a.event_id).cmp(&(b.sat, &b.event_id)));
}
}
cells.sort();
Ok(CampaignData {
digest,
campaign_json_sha256: sha256_hex(&plan_bytes).to_string(),
cells,
test_conditions: tc_hashes,
reacq_grace_s: grace,
units,
})
}