kshana 0.35.0

Open, reproducible PNT-resilience simulator with quantum-sensor performance models
Documentation
// SPDX-License-Identifier: AGPL-3.0-only
//! The instructor log: what was injected when, plus the true track against what the
//! receiver reported, as JSON and as readable text.

use super::clock::iso;
use super::config::{EventCfg, EventKind, TrainingScenario};
use serde::Serialize;

/// Warning carried by every generated file and log.
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.";

/// One scripted event as injected.
#[derive(Clone, Debug, Serialize)]
pub struct EventRecord {
    /// 1-based id used in the timeline.
    pub id: usize,
    /// Plain-language description with the parameters.
    pub description: String,
    /// Onset as UTC.
    pub start_utc: String,
    /// The parameters exactly as written in the scenario.
    pub parameters: EventCfg,
    /// drag-off: the direction of the drag as applied, degrees true (a relative bearing
    /// is fixed against the vessel's course at onset).
    pub resolved_bearing_deg: Option<f64>,
    /// drag-off: peak speed the drag adds to the false track, m/s.
    pub peak_drag_speed_mps: Option<f64>,
    /// drag-off: peak acceleration the drag adds to the false track, m/s squared.
    pub peak_drag_accel_mps2: Option<f64>,
}

/// Peak speed and acceleration of the true and the reported track over the run.
#[derive(Clone, Debug, Default, Serialize)]
pub struct TrackSummary {
    /// Highest true speed over ground, knots.
    pub true_peak_sog_kn: f64,
    /// Largest true change of velocity per second, m/s squared.
    pub true_peak_accel_mps2: f64,
    /// Highest reported speed over ground, knots.
    pub reported_peak_sog_kn: f64,
    /// Largest reported change of velocity per second between consecutive fixes, m/s
    /// squared.
    pub reported_peak_accel_mps2: f64,
}

/// One moment worth debriefing.
#[derive(Clone, Debug, Serialize)]
pub struct TimelineEntry {
    /// Seconds from the start of the run.
    pub t_s: f64,
    /// True UTC at that moment.
    pub utc: String,
    /// Event id the entry belongs to, or null for receiver-state changes.
    pub event: Option<usize>,
    /// `onset`, `full-effect`, `recovery-begins`, `recovered`, `fix-lost`, `fix-regained`.
    pub what: String,
    /// Explanation.
    pub text: String,
}

/// True track against the reported one at one instant.
#[derive(Clone, Debug, Serialize)]
pub struct TrackRow {
    /// Seconds from the start of the run.
    pub t_s: f64,
    /// True UTC.
    pub utc: String,
    /// True latitude, degrees.
    pub true_lat_deg: f64,
    /// True longitude, degrees.
    pub true_lon_deg: f64,
    /// True speed over ground, knots.
    pub true_sog_kn: f64,
    /// True course over ground, degrees.
    pub true_cog_deg: f64,
    /// True heading, degrees.
    pub true_heading_deg: f64,
    /// Whether the receiver reported a fix.
    pub fix_valid: bool,
    /// Reported latitude, degrees.
    pub reported_lat_deg: Option<f64>,
    /// Reported longitude, degrees.
    pub reported_lon_deg: Option<f64>,
    /// Reported speed over ground, knots.
    pub reported_sog_kn: Option<f64>,
    /// Reported course over ground, degrees.
    pub reported_cog_deg: Option<f64>,
    /// Horizontal distance from true to reported position, m.
    pub position_error_m: Option<f64>,
    /// Reported UTC minus true UTC, seconds.
    pub time_offset_s: f64,
    /// Satellites used in the fix.
    pub n_used: usize,
    /// Satellites tracked.
    pub n_tracked: usize,
    /// Mean C/N0 of tracked satellites, dB-Hz.
    pub mean_cn0_dbhz: Option<f64>,
    /// Ids of events active at this instant.
    pub active_events: Vec<usize>,
}

/// Everything the trainer needs for the debrief.
#[derive(Clone, Debug, Serialize)]
pub struct InstructorLog {
    /// Log schema tag.
    pub schema: &'static str,
    /// The not-for-navigation statement.
    pub warning: &'static str,
    /// Scenario name.
    pub scenario: String,
    /// Scenario description.
    pub description: String,
    /// Trainer note from the scenario.
    pub trainer_note: String,
    /// Seed.
    pub seed: u64,
    /// UTC start.
    pub start_utc: String,
    /// Run length, s.
    pub duration_s: f64,
    /// Scripted events.
    pub events: Vec<EventRecord>,
    /// Peak speed and acceleration, true and reported.
    pub summary: TrackSummary,
    /// Debrief timeline.
    pub timeline: Vec<TimelineEntry>,
    /// True and reported track rows.
    pub track: Vec<TrackRow>,
}

/// Peak speed and acceleration a drag-off adds, from its smoothstep ramps: a ramp of
/// `T` seconds over `D` metres peaks at `1.5 D / T` and `6 D / T^2`. A step adds none
/// (the position jumps; the velocity does not).
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))
}

/// Plain-language description of an event with its parameters.
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)
        ),
    };
    match &e.label {
        Some(l) => format!("{l}: {body} {when}"),
        None => format!("{body} {when}"),
    }
}

impl InstructorLog {
    /// Create an empty log for a scenario.
    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(),
        }
    }

    /// Pretty JSON.
    pub fn to_json(&self) -> String {
        let mut s = serde_json::to_string_pretty(self).expect("log serialises");
        s.push('\n');
        s
    }

    /// Readable text for the debrief.
    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
    }
}