253 lines
9.8 KiB
Rust
253 lines
9.8 KiB
Rust
//! `MagFrame` — fixed-layout binary frame emitted per sensor per timestep.
|
||
//!
|
||
//! Per implementation plan §1.4: magic `0xC51A_6E70` (`C51` lineage / `A`
|
||
//! for Anomaly / `6E70` ASCII "np" for NV-pipeline). 60-byte payload —
|
||
//! fixed for v1.
|
||
//!
|
||
//! Layout (little-endian, packed):
|
||
//!
|
||
//! | Offset | Field | Width | Notes |
|
||
//! |--------|-------------------|-------|---------------------------------------|
|
||
//! | 0 | `magic` | u32 | [`MAG_FRAME_MAGIC`] |
|
||
//! | 4 | `version` | u16 | [`MAG_FRAME_VERSION`] |
|
||
//! | 6 | `flags` | u16 | bit-set (see [`flag`] constants) |
|
||
//! | 8 | `sensor_id` | u16 | which sensor in `Scene::sensors` |
|
||
//! | 10 | `_reserved` | u16 | zero in v1 |
|
||
//! | 12 | `t_us` | u64 | sample timestamp, μs since pipeline |
|
||
//! | 20 | `bx, by, bz` | 3×f32 | demodulated B in pT (post-lockin) |
|
||
//! | 32 | `sigma_x,y,z` | 3×f32 | per-axis 1σ noise estimate, pT |
|
||
//! | 44 | `noise_floor` | f32 | shot-noise δB pT/√Hz at this sample |
|
||
//! | 48 | `temperature_k` | f32 | sensor temperature K (default 295) |
|
||
//! | 52 | `_pad` | 8 B | zero in v1, future-proofing |
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Frame magic. Distinct from ADR-018 CSI (`0xC51F...`) and ADR-084 sketch
|
||
/// (`0xC511_0084`). See implementation plan §1.4.
|
||
pub const MAG_FRAME_MAGIC: u32 = 0xC51A_6E70;
|
||
|
||
/// Wire-format schema version. Bumped on any field reordering or addition.
|
||
pub const MAG_FRAME_VERSION: u16 = 1;
|
||
|
||
/// Total payload size in bytes for v1.
|
||
pub const MAG_FRAME_BYTES: usize = 60;
|
||
|
||
/// Per-frame status flag bits. Combined into `MagFrame::flags` as a `u16`
|
||
/// bit-set; see [`MagFrame::has_flag`] for ergonomic reads.
|
||
pub mod flag {
|
||
/// Sensor near-field saturation (source < 1 mm away). Plan §2.1.
|
||
pub const SATURATION_NEAR_FIELD: u16 = 1 << 0;
|
||
/// ADC saturated on at least one axis at this sample.
|
||
pub const ADC_SATURATED: u16 = 1 << 1;
|
||
/// Reinforced-concrete-grade attenuation flagged on LoS.
|
||
pub const HEAVY_ATTENUATION: u16 = 1 << 2;
|
||
/// Pipeline ran with shot-noise disabled (analytic mode).
|
||
pub const SHOT_NOISE_DISABLED: u16 = 1 << 3;
|
||
}
|
||
|
||
/// Decoded `rv_mag_feature_state_t` frame.
|
||
///
|
||
/// Round-trips through `to_bytes` / `from_bytes` byte-exact; the
|
||
/// deserialiser validates magic + version + length and never panics on
|
||
/// malformed input.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct MagFrame {
|
||
/// Per-frame status bit-set ([`flag`] constants).
|
||
pub flags: u16,
|
||
/// Sensor index in `Scene::sensors`.
|
||
pub sensor_id: u16,
|
||
/// Sample timestamp, μs since pipeline start.
|
||
pub t_us: u64,
|
||
/// Demodulated 3-axis B field (pT).
|
||
pub b_pt: [f32; 3],
|
||
/// Per-axis 1σ noise estimate (pT).
|
||
pub sigma_pt: [f32; 3],
|
||
/// Shot-noise floor (pT/√Hz) at this sample.
|
||
pub noise_floor_pt_sqrt_hz: f32,
|
||
/// Sensor temperature (K). Default 295.
|
||
pub temperature_k: f32,
|
||
}
|
||
|
||
impl MagFrame {
|
||
/// Construct a zero-filled frame at room temperature for the given sensor.
|
||
pub fn empty(sensor_id: u16) -> Self {
|
||
Self {
|
||
flags: 0,
|
||
sensor_id,
|
||
t_us: 0,
|
||
b_pt: [0.0; 3],
|
||
sigma_pt: [0.0; 3],
|
||
noise_floor_pt_sqrt_hz: 0.0,
|
||
temperature_k: 295.0,
|
||
}
|
||
}
|
||
|
||
/// True iff `flag_bit` is set in `self.flags`.
|
||
#[inline]
|
||
pub fn has_flag(&self, flag_bit: u16) -> bool {
|
||
self.flags & flag_bit != 0
|
||
}
|
||
|
||
/// Set `flag_bit` in `self.flags`.
|
||
#[inline]
|
||
pub fn set_flag(&mut self, flag_bit: u16) {
|
||
self.flags |= flag_bit;
|
||
}
|
||
|
||
/// Serialise to the fixed-layout 60-byte buffer.
|
||
pub fn to_bytes(&self) -> [u8; MAG_FRAME_BYTES] {
|
||
let mut buf = [0u8; MAG_FRAME_BYTES];
|
||
buf[0..4].copy_from_slice(&MAG_FRAME_MAGIC.to_le_bytes());
|
||
buf[4..6].copy_from_slice(&MAG_FRAME_VERSION.to_le_bytes());
|
||
buf[6..8].copy_from_slice(&self.flags.to_le_bytes());
|
||
buf[8..10].copy_from_slice(&self.sensor_id.to_le_bytes());
|
||
// [10..12] reserved, stays zero.
|
||
buf[12..20].copy_from_slice(&self.t_us.to_le_bytes());
|
||
buf[20..24].copy_from_slice(&self.b_pt[0].to_le_bytes());
|
||
buf[24..28].copy_from_slice(&self.b_pt[1].to_le_bytes());
|
||
buf[28..32].copy_from_slice(&self.b_pt[2].to_le_bytes());
|
||
buf[32..36].copy_from_slice(&self.sigma_pt[0].to_le_bytes());
|
||
buf[36..40].copy_from_slice(&self.sigma_pt[1].to_le_bytes());
|
||
buf[40..44].copy_from_slice(&self.sigma_pt[2].to_le_bytes());
|
||
buf[44..48].copy_from_slice(&self.noise_floor_pt_sqrt_hz.to_le_bytes());
|
||
buf[48..52].copy_from_slice(&self.temperature_k.to_le_bytes());
|
||
// [52..60] padding stays zero.
|
||
buf
|
||
}
|
||
|
||
/// Deserialise from a byte buffer. Validates magic, version, and
|
||
/// length; rejects any payload that doesn't match v1's exact 60-byte
|
||
/// shape with a typed [`crate::NvsimError`].
|
||
pub fn from_bytes(buf: &[u8]) -> Result<Self, crate::NvsimError> {
|
||
if buf.len() != MAG_FRAME_BYTES {
|
||
return Err(crate::NvsimError::FrameLengthMismatch {
|
||
got: buf.len(),
|
||
expected: MAG_FRAME_BYTES,
|
||
});
|
||
}
|
||
let magic = u32::from_le_bytes(buf[0..4].try_into().expect("4-byte slice"));
|
||
if magic != MAG_FRAME_MAGIC {
|
||
return Err(crate::NvsimError::MagicMismatch {
|
||
got: magic,
|
||
expected: MAG_FRAME_MAGIC,
|
||
});
|
||
}
|
||
let version = u16::from_le_bytes(buf[4..6].try_into().expect("2-byte slice"));
|
||
if version != MAG_FRAME_VERSION {
|
||
return Err(crate::NvsimError::UnsupportedVersion {
|
||
got: version,
|
||
supported: MAG_FRAME_VERSION,
|
||
});
|
||
}
|
||
let flags = u16::from_le_bytes(buf[6..8].try_into().expect("2-byte slice"));
|
||
let sensor_id = u16::from_le_bytes(buf[8..10].try_into().expect("2-byte slice"));
|
||
let t_us = u64::from_le_bytes(buf[12..20].try_into().expect("8-byte slice"));
|
||
let bx = f32::from_le_bytes(buf[20..24].try_into().expect("4-byte slice"));
|
||
let by = f32::from_le_bytes(buf[24..28].try_into().expect("4-byte slice"));
|
||
let bz = f32::from_le_bytes(buf[28..32].try_into().expect("4-byte slice"));
|
||
let sx = f32::from_le_bytes(buf[32..36].try_into().expect("4-byte slice"));
|
||
let sy = f32::from_le_bytes(buf[36..40].try_into().expect("4-byte slice"));
|
||
let sz = f32::from_le_bytes(buf[40..44].try_into().expect("4-byte slice"));
|
||
let noise_floor = f32::from_le_bytes(buf[44..48].try_into().expect("4-byte slice"));
|
||
let temperature = f32::from_le_bytes(buf[48..52].try_into().expect("4-byte slice"));
|
||
Ok(Self {
|
||
flags,
|
||
sensor_id,
|
||
t_us,
|
||
b_pt: [bx, by, bz],
|
||
sigma_pt: [sx, sy, sz],
|
||
noise_floor_pt_sqrt_hz: noise_floor,
|
||
temperature_k: temperature,
|
||
})
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn magic_is_locked_to_documented_value() {
|
||
// Plan §1.4 commits to 0xC51A_6E70. Any change must update the plan.
|
||
assert_eq!(MAG_FRAME_MAGIC, 0xC51A_6E70);
|
||
}
|
||
|
||
#[test]
|
||
fn frame_round_trip_byte_exact() {
|
||
let mut f = MagFrame::empty(7);
|
||
f.set_flag(flag::ADC_SATURATED);
|
||
f.set_flag(flag::SHOT_NOISE_DISABLED);
|
||
f.t_us = 123_456_789;
|
||
f.b_pt = [1.5, -2.5, 3.5];
|
||
f.sigma_pt = [0.1, 0.2, 0.3];
|
||
f.noise_floor_pt_sqrt_hz = 100.0;
|
||
f.temperature_k = 295.0;
|
||
|
||
let bytes = f.to_bytes();
|
||
assert_eq!(bytes.len(), MAG_FRAME_BYTES);
|
||
let f2 = MagFrame::from_bytes(&bytes).unwrap();
|
||
assert_eq!(f, f2);
|
||
assert!(f2.has_flag(flag::ADC_SATURATED));
|
||
assert!(f2.has_flag(flag::SHOT_NOISE_DISABLED));
|
||
assert!(!f2.has_flag(flag::SATURATION_NEAR_FIELD));
|
||
}
|
||
|
||
#[test]
|
||
fn frame_size_is_fixed_60_bytes() {
|
||
let f = MagFrame::empty(0);
|
||
assert_eq!(f.to_bytes().len(), 60);
|
||
}
|
||
|
||
#[test]
|
||
fn frame_rejects_short_buffer() {
|
||
let err = MagFrame::from_bytes(&[0u8; 10]).unwrap_err();
|
||
assert!(matches!(err, crate::NvsimError::FrameLengthMismatch { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn frame_rejects_bad_magic() {
|
||
let mut bytes = MagFrame::empty(0).to_bytes();
|
||
bytes[0..4].copy_from_slice(&0xDEAD_BEEF_u32.to_le_bytes());
|
||
let err = MagFrame::from_bytes(&bytes).unwrap_err();
|
||
assert!(matches!(err, crate::NvsimError::MagicMismatch { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn frame_rejects_unsupported_version() {
|
||
let mut bytes = MagFrame::empty(0).to_bytes();
|
||
bytes[4..6].copy_from_slice(&99_u16.to_le_bytes());
|
||
let err = MagFrame::from_bytes(&bytes).unwrap_err();
|
||
assert!(matches!(
|
||
err,
|
||
crate::NvsimError::UnsupportedVersion { got: 99, .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn frame_byte_order_is_deterministic() {
|
||
// Identical input must produce identical bytes — no allocator
|
||
// randomisation, no hashmap iteration order, no time-of-day field.
|
||
let f = MagFrame {
|
||
flags: 0,
|
||
sensor_id: 42,
|
||
t_us: 999,
|
||
b_pt: [1.0, 2.0, 3.0],
|
||
sigma_pt: [0.1, 0.2, 0.3],
|
||
noise_floor_pt_sqrt_hz: 50.0,
|
||
temperature_k: 295.0,
|
||
};
|
||
let bytes_a = f.to_bytes();
|
||
let bytes_b = f.to_bytes();
|
||
assert_eq!(bytes_a, bytes_b);
|
||
}
|
||
|
||
#[test]
|
||
fn flag_helpers_set_and_check() {
|
||
let mut f = MagFrame::empty(0);
|
||
assert!(!f.has_flag(flag::ADC_SATURATED));
|
||
f.set_flag(flag::ADC_SATURATED);
|
||
assert!(f.has_flag(flag::ADC_SATURATED));
|
||
assert!(!f.has_flag(flag::HEAVY_ATTENUATION));
|
||
}
|
||
}
|