use super::clock::iso;
use super::config::{EventCfg, EventKind, TrainingScenario};
use serde::Serialize;
pub const NOT_FOR_NAVIGATION: &str = "Synthetic training data generated by Kshana. For training \
and testing only: never feed this stream to a vessel's live navigation systems. \
Advisory, not type-approved navigation equipment.";
#[derive(Clone, Debug, Serialize)]
pub struct EventRecord {
pub id: usize,
pub description: String,
pub start_utc: String,
pub parameters: EventCfg,
pub resolved_bearing_deg: Option<f64>,
pub peak_drag_speed_mps: Option<f64>,
pub peak_drag_accel_mps2: Option<f64>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct TrackSummary {
pub true_peak_sog_kn: f64,
pub true_peak_accel_mps2: f64,
pub reported_peak_sog_kn: f64,
pub reported_peak_accel_mps2: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct TimelineEntry {
pub t_s: f64,
pub utc: String,
pub event: Option<usize>,
pub what: String,
pub text: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct TrackRow {
pub t_s: f64,
pub utc: String,
pub true_lat_deg: f64,
pub true_lon_deg: f64,
pub true_sog_kn: f64,
pub true_cog_deg: f64,
pub true_heading_deg: f64,
pub fix_valid: bool,
pub reported_lat_deg: Option<f64>,
pub reported_lon_deg: Option<f64>,
pub reported_sog_kn: Option<f64>,
pub reported_cog_deg: Option<f64>,
pub position_error_m: Option<f64>,
pub time_offset_s: f64,
pub n_used: usize,
pub n_tracked: usize,
pub mean_cn0_dbhz: Option<f64>,
pub active_events: Vec<usize>,
}
#[derive(Clone, Debug, Serialize)]
pub struct InstructorLog {
pub schema: &'static str,
pub warning: &'static str,
pub scenario: String,
pub description: String,
pub trainer_note: String,
pub seed: u64,
pub start_utc: String,
pub duration_s: f64,
pub events: Vec<EventRecord>,
pub summary: TrackSummary,
pub timeline: Vec<TimelineEntry>,
pub track: Vec<TrackRow>,
}
pub fn drag_peaks(e: &EventCfg) -> (f64, f64) {
let d = e.final_offset_m.unwrap_or(0.0);
let one = |t: f64| {
if t > 0.0 {
(1.5 * d / t, 6.0 * d / (t * t))
} else {
(0.0, 0.0)
}
};
let (a, b) = (one(e.ramp_s), one(e.recovery_s));
(a.0.max(b.0), a.1.max(b.1))
}
pub fn describe(e: &EventCfg, bearing: Option<f64>) -> String {
let when = format!(
"from T+{:.0} s for {:.0} s (onset ramp {:.0} s, recovery ramp {:.0} s)",
e.start_s, e.duration_s, e.ramp_s, e.recovery_s
);
let cf = match e.counterfeit_cn0_dbhz {
Some(c) => format!("; every tracked satellite shows {c:.0} dB-Hz while it lasts"),
None => "; the genuine C/N0 pattern is kept".to_string(),
};
let body = match e.kind {
EventKind::Jamming => format!(
"jamming: received C/N0 falls by up to {:.0} dB (per-satellite spread {:.0} dB); \
satellites drop out and the fix is lost when fewer than four remain usable",
e.cn0_drop_db.unwrap_or(0.0),
e.spread_db.unwrap_or(6.0)
),
EventKind::DragOff => {
let dir = match (e.bearing_deg, e.relative_bearing_deg, bearing) {
(Some(b), _, _) => format!("towards {b:.0} deg true"),
(_, Some(r), Some(t)) => format!(
"towards {t:.0} deg true ({r:.0} deg relative to the course at onset, then \
held)"
),
_ => String::new(),
};
let (pv, pa) = drag_peaks(e);
format!(
"position drag-off: the reported position walks {:.0} m away {dir} while the \
receiver keeps a valid fix (false track peaks at {pv:.2} m/s and \
{pa:.4} m/s^2){cf}",
e.final_offset_m.unwrap_or(0.0)
)
}
EventKind::TimeSpoof => format!(
"time spoof: reported UTC moves {:+.1} s from true; position stays true{cf}",
e.offset_s.unwrap_or(0.0)
),
EventKind::ReplayDelay => format!(
"replay delay (meaconing style): position{} lags the truth by {:.1} s{cf}",
if e.affect_time.unwrap_or(true) {
" and time"
} else {
""
},
e.delay_s.unwrap_or(0.0)
),
EventKind::NaturalDegradation => format!(
"natural degradation: the sky closes behind a stated skyline ({}); satellites \
behind it stay listed with no SNR and are not used, the C/N0 of the rest is \
unchanged, and nothing is counterfeit",
e.skyline
.as_ref()
.map_or_else(|| "none".to_string(), |k| k.source())
),
};
match &e.label {
Some(l) => format!("{l}: {body} {when}"),
None => format!("{body} {when}"),
}
}
impl InstructorLog {
pub fn new(scn: &TrainingScenario, start_ms: i64, bearings: &[Option<f64>]) -> Self {
let m = &scn.scenario;
Self {
schema: "kshana-nmea-training/1",
warning: NOT_FOR_NAVIGATION,
scenario: m.name.clone(),
description: m.description.clone(),
trainer_note: m.trainer_note.clone(),
seed: m.seed,
start_utc: iso(start_ms),
duration_s: m.duration_s,
events: scn
.events
.iter()
.enumerate()
.map(|(i, e)| EventRecord {
id: i + 1,
description: describe(e, bearings.get(i).copied().flatten()),
start_utc: iso(start_ms + (e.start_s * 1000.0).round() as i64),
parameters: e.clone(),
resolved_bearing_deg: bearings.get(i).copied().flatten(),
peak_drag_speed_mps: (e.kind == EventKind::DragOff).then(|| drag_peaks(e).0),
peak_drag_accel_mps2: (e.kind == EventKind::DragOff).then(|| drag_peaks(e).1),
})
.collect(),
summary: TrackSummary::default(),
timeline: Vec::new(),
track: Vec::new(),
}
}
pub fn to_json(&self) -> String {
let mut s = serde_json::to_string_pretty(self).expect("log serialises");
s.push('\n');
s
}
pub fn to_text(&self) -> String {
let mut o = String::new();
o.push_str(&format!("INSTRUCTOR LOG: {}\n", self.scenario));
o.push_str(&format!("{}\n\n", self.warning));
if !self.description.is_empty() {
o.push_str(&format!("{}\n\n", self.description));
}
if !self.trainer_note.is_empty() {
o.push_str(&format!("Trainer note: {}\n\n", self.trainer_note));
}
o.push_str(&format!(
"Start {} duration {:.0} s seed {}\n\n",
self.start_utc, self.duration_s, self.seed
));
o.push_str("EVENTS INJECTED\n");
if self.events.is_empty() {
o.push_str(" none (clean run)\n");
}
for e in &self.events {
o.push_str(&format!(
" [{}] {} (onset {})\n",
e.id, e.description, e.start_utc
));
}
let s = &self.summary;
o.push_str(&format!(
"\nPEAKS true: {:.1} kn, {:.4} m/s^2 reported: {:.1} kn, {:.4} m/s^2\n",
s.true_peak_sog_kn,
s.true_peak_accel_mps2,
s.reported_peak_sog_kn,
s.reported_peak_accel_mps2
));
o.push_str("\nTIMELINE\n");
for t in &self.timeline {
let id = t.event.map_or(" ".to_string(), |i| format!("[{i}]"));
o.push_str(&format!(
" T+{:>6.0} s {} {id} {}: {}\n",
t.t_s,
t.utc.get(11..19).unwrap_or(&t.utc),
t.what,
t.text
));
}
o.push_str(
"\nTRUE TRACK AGAINST REPORTED\n \
T+s true lat true lon SOG COG fix used C/N0 pos err m time off s events\n",
);
for r in &self.track {
let ev = r
.active_events
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
o.push_str(&format!(
" {:>5.0} {:>10.5} {:>10.5} {:>5.1} {:>5.1} {:<4} {:>3} {:>5} {:>9} {:>10.1} {}\n",
r.t_s,
r.true_lat_deg,
r.true_lon_deg,
r.true_sog_kn,
r.true_cog_deg,
if r.fix_valid { "yes" } else { "NO" },
r.n_used,
r.mean_cn0_dbhz.map_or("-".to_string(), |c| format!("{c:.1}")),
r.position_error_m.map_or("-".to_string(), |c| format!("{c:.1}")),
r.time_offset_s,
ev
));
}
o
}
}