use super::{round_dp, ExportError, ExportFile, UtcEpoch};
use crate::frames::{ecef_to_geodetic, geodetic_to_ecef, Geodetic, Vec3};
use crate::portable_math::PortableFloat;
use crate::scenario::GnssState;
pub const DEFAULT_EPOCH: (i32, u32, u32, u32, u32, f64) = (2024, 1, 1, 0, 0, 0.0);
pub const CSV_POSITION_TOL_M: f64 = 1.0e-4;
pub const CSV_ANGLE_DEG_TOL: f64 = 1.0e-9;
pub const CSV_VELOCITY_TOL_M_S: f64 = 1.0e-4;
pub const CSV_ATTITUDE_DEG_TOL: f64 = 1.0e-6;
pub const NMEA_HORIZONTAL_TOL_M: f64 = 5.0e-3;
pub const NMEA_HEIGHT_TOL_M: f64 = 1.0e-3;
pub const NMEA_SPEED_TOL_KN: f64 = 1.0e-3;
pub const NMEA_COURSE_TOL_DEG: f64 = 1.0e-3;
const KNOTS_PER_M_S: f64 = 3600.0 / 1852.0;
const COURSE_MIN_SPEED_M_S: f64 = 0.05;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MotionSample {
pub t_s: f64,
pub lat_deg: f64,
pub lon_deg: f64,
pub h_m: f64,
pub ecef_m: Vec3,
pub v_ecef_m_s: Vec3,
pub heading_deg: f64,
pub pitch_deg: f64,
pub roll_deg: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EventKind {
Jamming,
Spoofing,
Other,
}
impl EventKind {
pub fn as_str(self) -> &'static str {
match self {
EventKind::Jamming => "jamming",
EventKind::Spoofing => "spoofing",
EventKind::Other => "other",
}
}
fn parse(s: &str) -> Option<EventKind> {
match s {
"jamming" => Some(EventKind::Jamming),
"spoofing" => Some(EventKind::Spoofing),
"other" => Some(EventKind::Other),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MotionEvent {
pub label: String,
pub kind: EventKind,
pub onset_s: f64,
pub end_s: f64,
pub description: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Trajectory {
pub kind: String,
pub scenario_hash: String,
pub epoch: UtcEpoch,
pub note: String,
pub samples: Vec<MotionSample>,
pub events: Vec<MotionEvent>,
}
pub fn default_epoch() -> UtcEpoch {
let (y, mo, d, h, mi, s) = DEFAULT_EPOCH;
UtcEpoch::from_calendar(y, mo, d, h, mi, s)
}
fn ned_to_ecef(lat_rad: f64, lon_rad: f64, v: Vec3) -> Vec3 {
let (sl, cl) = lat_rad.psin_cos();
let (so, co) = lon_rad.psin_cos();
let n = [-sl * co, -sl * so, cl];
let e = [-so, co, 0.0];
let d = [-cl * co, -cl * so, -sl];
[
n[0] * v[0] + e[0] * v[1] + d[0] * v[2],
n[1] * v[0] + e[1] * v[1] + d[1] * v[2],
n[2] * v[0] + e[2] * v[1] + d[2] * v[2],
]
}
fn euler_deg(c: &[[f64; 3]; 3]) -> (f64, f64, f64) {
let yaw = c[1][0].patan2(c[0][0]).to_degrees();
let pitch = (-c[2][0]).clamp(-1.0, 1.0).pasin().to_degrees();
let roll = c[2][1].patan2(c[2][2]).to_degrees();
(yaw.rem_euclid(360.0), pitch, roll)
}
fn sample_of(
t_s: f64,
g: Geodetic,
v_ned: Vec3,
heading_deg: f64,
pitch_deg: f64,
roll_deg: f64,
) -> MotionSample {
MotionSample {
t_s,
lat_deg: g.lat_rad.to_degrees(),
lon_deg: g.lon_rad.to_degrees(),
h_m: g.alt_m,
ecef_m: geodetic_to_ecef(g),
v_ecef_m_s: ned_to_ecef(g.lat_rad, g.lon_rad, v_ned),
heading_deg,
pitch_deg,
roll_deg,
}
}
fn outage_events(states: &[(f64, GnssState)]) -> Vec<MotionEvent> {
let mut out: Vec<MotionEvent> = Vec::new();
let mut open: Option<(f64, GnssState)> = None;
for (i, &(t, s)) in states.iter().enumerate() {
match (open, s) {
(None, GnssState::Nominal) => {}
(None, s) => open = Some((t, s)),
(Some((t0, s0)), s1) if s1 != s0 => {
push_outage(&mut out, t0, t, s0);
open = if s1 == GnssState::Nominal {
None
} else {
Some((t, s1))
};
}
(Some(_), _) => {}
}
if i + 1 == states.len() {
if let Some((t0, s0)) = open.take() {
push_outage(&mut out, t0, t, s0);
}
}
}
out
}
fn push_outage(out: &mut Vec<MotionEvent>, t0: f64, t1: f64, s: GnssState) {
let (word, what) = match s {
GnssState::Denied => (
"gnss-denied",
"the scenario's navigation-state timeline gives no GNSS here (an explicit denied \
window, or no window covering the time)",
),
_ => (
"gnss-degraded",
"the scenario's navigation-state timeline marks GNSS degraded here",
),
};
let n = out.iter().filter(|e| e.label.starts_with(word)).count() + 1;
out.push(MotionEvent {
label: format!("{word}-{n}"),
kind: EventKind::Other,
onset_s: t0,
end_s: t1,
description: what.into(),
});
}
pub fn trajectory_of(src: &str, epoch: Option<UtcEpoch>) -> Result<Trajectory, ExportError> {
use crate::api::ScenarioKind;
let kind = ScenarioKind::classify(src).map_err(|e| ExportError::Failed(e.to_string()))?;
let epoch = epoch.unwrap_or_else(default_epoch);
let bad = |e: toml::de::Error| {
ExportError::Failed(format!("invalid {} scenario: {e}", kind.as_str()))
};
let hash = super::scene::short_hash(src);
match kind {
ScenarioKind::GnssIns => {
let scn: crate::fusion::pack::GnssInsScenario = toml::from_str(src).map_err(bad)?;
scn.time.validate().map_err(ExportError::Failed)?;
let truth = crate::fusion::pack::truth_trajectory(&scn);
let samples: Vec<MotionSample> = truth
.iter()
.map(|(t, s)| {
let (h, p, r) = euler_deg(&s.q.to_dcm());
sample_of(*t, s.p_llh, s.v_ned, h, p, r)
})
.collect();
let states: Vec<(f64, GnssState)> = truth
.iter()
.map(|(t, _)| (*t, scn.gnss.state_at(*t)))
.collect();
Ok(Trajectory {
kind: kind.as_str().into(),
scenario_hash: hash,
epoch,
note: "the gnss-ins kind's true driving profile (forward acceleration and yaw \
square waves) flown from the scenario's tangent-plane origin, stepped as \
the kind steps its truth; attitude is the profile's, events are the \
navigation-state outages"
.into(),
samples,
events: outage_events(&states),
})
}
ScenarioKind::Jamming | ScenarioKind::GnssSim => {
let (time, lat, lon, alt, jammer) = if kind == ScenarioKind::Jamming {
let s: crate::jamming::JammingScenario = toml::from_str(src).map_err(bad)?;
let j = s.jammer.as_ref().map(|j| {
format!(
"{} jammer, {} dBW transmit power, {} dBi antenna gain (a parameter of \
the scenario's link budget, not a signal)",
j.jammer_type, j.power_dbw, j.gain_dbi
)
});
(
s.time,
s.receiver.lat_deg,
s.receiver.lon_deg,
s.receiver.alt_m,
j,
)
} else {
let s: crate::gnss_sim::GnssSimScenario = toml::from_str(src).map_err(bad)?;
(
s.time,
s.receiver.lat_deg,
s.receiver.lon_deg,
s.receiver.alt_m,
None,
)
};
time.validate().map_err(ExportError::Failed)?;
let n = (time.duration_s / time.step_s).round() as usize;
let g = Geodetic {
lat_rad: lat.to_radians(),
lon_rad: lon.to_radians(),
alt_m: alt,
};
let samples: Vec<MotionSample> = (0..=n)
.map(|i| sample_of(i as f64 * time.step_s, g, [0.0; 3], 0.0, 0.0, 0.0))
.collect();
let events = jammer
.map(|d| {
vec![MotionEvent {
label: "jammer".into(),
kind: EventKind::Jamming,
onset_s: 0.0,
end_s: n as f64 * time.step_s,
description: d,
}]
})
.unwrap_or_default();
Ok(Trajectory {
kind: kind.as_str().into(),
scenario_hash: hash,
epoch,
note: "a stationary receiver at the scenario's receiver position for its whole \
duration, heading, pitch and roll zero"
.into(),
samples,
events,
})
}
other => Err(ExportError::NotApplicable(format!(
"`{}` has no vehicle trajectory to export; the test-bench export covers the \
gnss-ins, jamming and gnss-sim kinds",
other.as_str()
))),
}
}
pub const MOTION_COLUMNS: [&str; 15] = [
"time_s",
"x_m",
"y_m",
"z_m",
"vx_m_s",
"vy_m_s",
"vz_m_s",
"lat_deg",
"lon_deg",
"h_m",
"heading_deg",
"pitch_deg",
"roll_deg",
"utc",
"kshana_row",
];
pub fn write_motion_csv(t: &Trajectory) -> String {
let mut s = MOTION_COLUMNS.join(",");
s.push('\n');
for (i, m) in t.samples.iter().enumerate() {
let f = |v: f64, dp: usize| format!("{:.dp$}", round_dp(v, dp as i32));
s.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
f(m.t_s, 6),
f(m.ecef_m[0], 4),
f(m.ecef_m[1], 4),
f(m.ecef_m[2], 4),
f(m.v_ecef_m_s[0], 4),
f(m.v_ecef_m_s[1], 4),
f(m.v_ecef_m_s[2], 4),
f(m.lat_deg, 9),
f(m.lon_deg, 9),
f(m.h_m, 4),
f(m.heading_deg, 6),
f(m.pitch_deg, 6),
f(m.roll_deg, 6),
t.epoch.iso(m.t_s),
i
));
}
s
}
pub fn read_motion_csv(text: &str) -> Result<Vec<MotionSample>, String> {
let mut lines = text.lines();
let head = lines.next().ok_or("empty motion CSV")?;
if head.trim() != MOTION_COLUMNS.join(",") {
return Err(format!(
"motion CSV header is not the expected columns: {head:?}"
));
}
let mut out = Vec::new();
for (n, line) in lines.enumerate() {
if line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.split(',').collect();
if f.len() != MOTION_COLUMNS.len() {
return Err(format!(
"row {}: {} fields, expected {}",
n + 1,
f.len(),
MOTION_COLUMNS.len()
));
}
let num = |i: usize| -> Result<f64, String> {
f[i].trim()
.parse::<f64>()
.ok()
.filter(|v| v.is_finite())
.ok_or_else(|| {
format!(
"row {}: column {} is not a number",
n + 1,
MOTION_COLUMNS[i]
)
})
};
out.push(MotionSample {
t_s: num(0)?,
ecef_m: [num(1)?, num(2)?, num(3)?],
v_ecef_m_s: [num(4)?, num(5)?, num(6)?],
lat_deg: num(7)?,
lon_deg: num(8)?,
h_m: num(9)?,
heading_deg: num(10)?,
pitch_deg: num(11)?,
roll_deg: num(12)?,
});
}
Ok(out)
}
pub fn write_motion_meta(t: &Trajectory) -> String {
let doc = serde_json::json!({
"format": "kshana-motion-csv",
"format_version": 1,
"scenario_kind": t.kind,
"scenario_hash": t.scenario_hash,
"epoch_utc": t.epoch.iso(0.0),
"epoch_note": "time_s is seconds after epoch_utc; leap seconds are not modelled",
"samples": t.samples.len(),
"source": t.note,
"columns": MOTION_COLUMNS,
"units": {
"time_s": "s", "x_m": "m", "y_m": "m", "z_m": "m",
"vx_m_s": "m/s", "vy_m_s": "m/s", "vz_m_s": "m/s",
"lat_deg": "deg", "lon_deg": "deg", "h_m": "m",
"heading_deg": "deg", "pitch_deg": "deg", "roll_deg": "deg",
"utc": "ISO 8601 UTC", "kshana_row": "row index from 0"
},
"frames": {
"position": "WGS 84 Earth-fixed (ECEF); lat/lon/h are the same position geodetically, h above the ellipsoid",
"velocity": "Earth-fixed axes, relative to the Earth, not inertial",
"attitude": "heading clockwise from true north in [0,360), pitch nose up positive, roll right wing down positive; yaw-pitch-roll (3-2-1) from local north-east-down to body axes x forward, y right, z down"
},
"heading_note": "heading_deg is the body yaw, not the direction of travel; for gnss-ins the two can differ by tens of degrees. The course over ground (what the NMEA RMC course carries) is the direction of the velocity vector: atan2(v_east, v_north) of the Earth-fixed velocity rotated to north-east-down",
"height_note": "h_m is the scenario's own motion; for gnss-ins it follows the kind's tangent-plane stepping and is not held constant",
"round_trip_tolerance": {
"position_m": CSV_POSITION_TOL_M,
"lat_lon_deg": CSV_ANGLE_DEG_TOL,
"velocity_m_s": CSV_VELOCITY_TOL_M_S,
"attitude_deg": CSV_ATTITUDE_DEG_TOL
},
"carries_signals": false,
});
let mut s = serde_json::to_string_pretty(&doc).unwrap_or_default();
s.push('\n');
s
}
pub fn nmea_checksum(body: &str) -> u8 {
body.bytes().fold(0u8, |a, b| a ^ b)
}
fn nmea_sentence(body: &str) -> String {
format!("${body}*{:02X}\r\n", nmea_checksum(body))
}
fn nmea_angle(deg: f64, width: usize, pos: char, neg: char) -> (String, char) {
let hemi = if deg < 0.0 { neg } else { pos };
let a = deg.abs();
let mut d = a.floor();
let mut m = round_dp((a - d) * 60.0, 6);
if m >= 60.0 {
m = 0.0;
d += 1.0;
}
(format!("{:0w$}{:09.6}", d as u32, m, w = width), hemi)
}
pub fn write_nmea(t: &Trajectory) -> String {
let mut s = String::new();
for m in &t.samples {
let (tm, date) = t.epoch.nmea(m.t_s);
let (lat, ns) = nmea_angle(m.lat_deg, 2, 'N', 'S');
let (lon, ew) = nmea_angle(m.lon_deg, 3, 'E', 'W');
s.push_str(&nmea_sentence(&format!(
"GPGGA,{tm},{lat},{ns},{lon},{ew},1,12,1.0,{:.3},M,0.000,M,,",
round_dp(m.h_m, 3)
)));
let (sl, cl) = m.lat_deg.to_radians().psin_cos();
let (so, co) = m.lon_deg.to_radians().psin_cos();
let v = m.v_ecef_m_s;
let vn = -sl * co * v[0] - sl * so * v[1] + cl * v[2];
let ve = -so * v[0] + co * v[1];
let speed = vn.phypot(ve);
let course = if speed >= COURSE_MIN_SPEED_M_S {
format!(
"{:.3}",
round_dp(ve.patan2(vn).to_degrees().rem_euclid(360.0), 3)
)
} else {
String::new()
};
s.push_str(&nmea_sentence(&format!(
"GPRMC,{tm},A,{lat},{ns},{lon},{ew},{:.3},{course},{date},,,A",
round_dp(speed * KNOTS_PER_M_S, 3)
)));
}
s
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NmeaFix {
pub t_s: f64,
pub lat_deg: f64,
pub lon_deg: f64,
pub h_m: f64,
pub speed_kn: f64,
pub course_deg: Option<f64>,
}
fn parse_angle(field: &str, hemi: &str, deg_digits: usize) -> Option<f64> {
if field.len() < deg_digits + 2 {
return None;
}
let d: f64 = field.get(..deg_digits)?.parse().ok()?;
let m: f64 = field.get(deg_digits..)?.parse().ok()?;
let v = d + m / 60.0;
match hemi {
"N" | "E" => Some(v),
"S" | "W" => Some(-v),
_ => None,
}
}
pub fn read_nmea(text: &str, epoch: &UtcEpoch) -> Result<Vec<NmeaFix>, String> {
let mut out: Vec<NmeaFix> = Vec::new();
let mut pending: Option<(String, f64, f64, f64)> = None; for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let body = line
.strip_prefix('$')
.ok_or_else(|| format!("line {}: no `$`", n + 1))?;
let (body, sum) = body
.split_once('*')
.ok_or_else(|| format!("line {}: no checksum", n + 1))?;
let want = u8::from_str_radix(sum.trim(), 16)
.map_err(|_| format!("line {}: bad checksum field", n + 1))?;
if nmea_checksum(body) != want {
return Err(format!("line {}: checksum mismatch", n + 1));
}
let f: Vec<&str> = body.split(',').collect();
match f[0] {
"GPGGA" => {
if f.len() < 12 {
return Err(format!("line {}: short GGA", n + 1));
}
let lat =
parse_angle(f[2], f[3], 2).ok_or(format!("line {}: bad latitude", n + 1))?;
let lon =
parse_angle(f[4], f[5], 3).ok_or(format!("line {}: bad longitude", n + 1))?;
let alt: f64 = f[9]
.parse()
.map_err(|_| format!("line {}: bad altitude", n + 1))?;
let sep: f64 = f[11]
.parse()
.map_err(|_| format!("line {}: bad separation", n + 1))?;
pending = Some((f[1].to_string(), lat, lon, alt + sep));
}
"GPRMC" => {
if f.len() < 10 {
return Err(format!("line {}: short RMC", n + 1));
}
let (tf, lat, lon, h) = pending
.take()
.ok_or_else(|| format!("line {}: RMC with no preceding GGA", n + 1))?;
if tf != f[1] {
return Err(format!("line {}: GGA and RMC times differ", n + 1));
}
let speed: f64 = f[7]
.parse()
.map_err(|_| format!("line {}: bad speed", n + 1))?;
let course = if f[8].is_empty() {
None
} else {
Some(
f[8].parse::<f64>()
.map_err(|_| format!("line {}: bad course", n + 1))?,
)
};
let num = |s: &str| -> Option<u32> { s.parse().ok() };
let (hh, mi, ss) = (
num(tf.get(0..2).unwrap_or("")),
num(tf.get(2..4).unwrap_or("")),
tf.get(4..).and_then(|x| x.parse::<f64>().ok()),
);
let d = f[9];
let (dd, mo, yy) = (
num(d.get(0..2).unwrap_or("")),
num(d.get(2..4).unwrap_or("")),
num(d.get(4..6).unwrap_or("")),
);
let (Some(hh), Some(mi), Some(ss), Some(dd), Some(mo), Some(yy)) =
(hh, mi, ss, dd, mo, yy)
else {
return Err(format!("line {}: bad time or date", n + 1));
};
let t_s = epoch.offset_to_calendar(2000 + yy as i32, mo, dd, hh, mi, ss);
out.push(NmeaFix {
t_s,
lat_deg: lat,
lon_deg: lon,
h_m: h,
speed_kn: speed,
course_deg: course,
});
}
_ => {}
}
}
if pending.is_some() {
return Err("a GGA with no following RMC".into());
}
Ok(out)
}
pub const WAYPOINT_ANGLE_DEG_TOL: f64 = 1.0e-9;
pub const WAYPOINT_HEIGHT_TOL_M: f64 = 1.0e-3;
pub fn write_waypoints(t: &Trajectory) -> Result<String, String> {
if t.samples.len() < 2 {
return Err("a waypoint file needs at least two samples".into());
}
let step = t.samples[1].t_s - t.samples[0].t_s;
let ms = step * 1000.0;
if ms.is_nan() || ms < 1.0 || (ms - ms.round()).abs() > 1e-6 {
return Err(format!(
"the sample step {step} s is not a whole number of milliseconds"
));
}
for (i, w) in t.samples.windows(2).enumerate() {
let dt = w[1].t_s - w[0].t_s;
if (dt - step).abs() > 1e-6 {
return Err(format!(
"the samples are not equally spaced: interval {} is {dt} s, the first is {step} s",
i + 1
));
}
}
let mut s = format!("RESOLUTION: {}\n", ms.round() as u64);
for m in &t.samples {
s.push_str(&format!(
"{:.9},{:.9},{:.3}\n",
round_dp(m.lon_deg, 9),
round_dp(m.lat_deg, 9),
round_dp(m.h_m, 3)
));
}
Ok(s)
}
pub type Waypoint = (f64, f64, f64);
pub fn read_waypoints(text: &str) -> Result<(u64, Vec<Waypoint>), String> {
let mut lines = text.lines();
let head = lines.next().ok_or("empty waypoint file")?;
let ms: u64 = head
.trim()
.strip_prefix("RESOLUTION:")
.ok_or("first line is not `RESOLUTION: <ms>`")?
.trim()
.parse()
.map_err(|_| "bad resolution")?;
let mut rows = Vec::new();
for (n, line) in lines.enumerate() {
if line.trim().is_empty() {
continue;
}
let f: Vec<f64> = line
.split(',')
.map(|x| x.trim().parse::<f64>())
.collect::<Result<_, _>>()
.map_err(|_| format!("waypoint {}: not three numbers", n + 1))?;
if f.len() != 3 {
return Err(format!(
"waypoint {}: {} fields, expected 3",
n + 1,
f.len()
));
}
rows.push((f[0], f[1], f[2]));
}
Ok((ms, rows))
}
pub fn write_events_csv(t: &Trajectory) -> String {
let mut s = String::from("label,kind,onset_s,end_s,description\n");
for e in &t.events {
let d: String = e
.description
.chars()
.map(|c| {
if c == ',' || c == '"' || c == '\n' {
';'
} else {
c
}
})
.collect();
s.push_str(&format!(
"{},{},{:.3},{:.3},{}\n",
e.label,
e.kind.as_str(),
round_dp(e.onset_s, 3),
round_dp(e.end_s, 3),
d
));
}
s
}
pub fn read_events_csv(text: &str) -> Result<Vec<MotionEvent>, String> {
let mut lines = text.lines();
if lines.next().map(str::trim) != Some("label,kind,onset_s,end_s,description") {
return Err("events CSV header is not label,kind,onset_s,end_s,description".into());
}
let mut out = Vec::new();
for (n, line) in lines.enumerate() {
if line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.splitn(5, ',').collect();
if f.len() != 5 {
return Err(format!("event row {}: expected 5 fields", n + 1));
}
out.push(MotionEvent {
label: f[0].to_string(),
kind: EventKind::parse(f[1])
.ok_or_else(|| format!("event row {}: unknown kind {:?}", n + 1, f[1]))?,
onset_s: f[2]
.parse()
.map_err(|_| format!("event row {}: bad onset", n + 1))?,
end_s: f[3]
.parse()
.map_err(|_| format!("event row {}: bad end", n + 1))?,
description: f[4].to_string(),
});
}
Ok(out)
}
pub fn write_events_toml(t: &Trajectory) -> String {
let mut s = String::from(
"# Events for a `receiver-trust` scenario. Onsets are seconds after the motion file's\n\
# first sample; add the offset to the receiver log's first epoch if they differ.\n",
);
for e in &t.events {
s.push_str(&format!(
"\n[[events]]\nlabel = {:?}\nkind = {:?}\nonset_s = {:.3}\nend_s = {:.3}\n",
e.label,
e.kind.as_str(),
round_dp(e.onset_s, 3),
round_dp(e.end_s, 3)
));
}
s
}
pub fn export(src: &str, epoch: Option<UtcEpoch>) -> Result<Vec<ExportFile>, ExportError> {
export_with_notes(src, epoch).map(|(files, _)| files)
}
pub fn export_with_notes(
src: &str,
epoch: Option<UtcEpoch>,
) -> Result<(Vec<ExportFile>, Vec<String>), ExportError> {
let t = trajectory_of(src, epoch)?;
Ok(files_and_notes_of(&t))
}
pub fn files_of(t: &Trajectory) -> Vec<ExportFile> {
files_and_notes_of(t).0
}
pub fn files_and_notes_of(t: &Trajectory) -> (Vec<ExportFile>, Vec<String>) {
let mk = |suffix: &str, body: String| ExportFile {
suffix: suffix.into(),
bytes: body.into_bytes(),
};
let mut notes = Vec::new();
let mut files = vec![
mk(".motion.csv", write_motion_csv(t)),
mk(".motion.json", write_motion_meta(t)),
mk(".nmea", write_nmea(t)),
];
match write_waypoints(t) {
Ok(w) => files.push(mk(".waypoints.txt", w)),
Err(e) => notes.push(format!("no .waypoints.txt written: {e}")),
}
files.push(mk(".events.csv", write_events_csv(t)));
files.push(mk(".events.toml", write_events_toml(t)));
(files, notes)
}
pub fn geodetic_of_ecef(m: &MotionSample) -> Geodetic {
ecef_to_geodetic(m.ecef_m)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inertial::attitude::Quaternion;
fn q_of(yaw: f64, pitch: f64, roll: f64) -> Quaternion {
let qz = Quaternion::from_axis_angle([0.0, 0.0, 1.0], yaw.to_radians());
let qy = Quaternion::from_axis_angle([0.0, 1.0, 0.0], pitch.to_radians());
let qx = Quaternion::from_axis_angle([1.0, 0.0, 0.0], roll.to_radians());
qz.mul(&qy).mul(&qx)
}
#[test]
fn euler_deg_follows_the_stated_sign_convention_for_known_quaternions() {
let q = q_of(90.0, 0.0, 0.0);
let x = q.rotate([1.0, 0.0, 0.0]);
assert!(x[0].abs() < 1e-12 && (x[1] - 1.0).abs() < 1e-12, "{x:?}");
let (h, p, r) = euler_deg(&q.to_dcm());
assert!(
(h - 90.0).abs() < 1e-9 && p.abs() < 1e-9 && r.abs() < 1e-9,
"{h} {p} {r}"
);
let q = q_of(0.0, 20.0, 0.0);
assert!(q.rotate([1.0, 0.0, 0.0])[2] < 0.0);
let (_, p, _) = euler_deg(&q.to_dcm());
assert!((p - 20.0).abs() < 1e-9, "{p}");
let q = q_of(0.0, 0.0, 30.0);
assert!(q.rotate([0.0, 1.0, 0.0])[2] > 0.0);
let (_, _, r) = euler_deg(&q.to_dcm());
assert!((r - 30.0).abs() < 1e-9, "{r}");
let (h, p, r) = euler_deg(&q_of(250.0, 20.0, -35.0).to_dcm());
assert!(
(h - 250.0).abs() < 1e-9 && (p - 20.0).abs() < 1e-9 && (r + 35.0).abs() < 1e-9,
"{h} {p} {r}"
);
let (h, _, _) = euler_deg(&q_of(-10.0, 0.0, 0.0).to_dcm());
assert!((h - 350.0).abs() < 1e-9, "{h}");
}
}