mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +00:00
feat(nvsim): scaffold + scene + frame [nvsim:pass1]
Pass 1 of the NV-diamond magnetometer pipeline simulator per docs/research/quantum-sensing/15-nvsim-implementation-plan.md. Standalone leaf crate at v2/crates/nvsim — deliberately NO internal RuView dependencies. RuView ecosystem integrations (wifi-densepose-core frame alignment, ruvector trace compression) are tracked as Optional Integrations in README and land behind feature flags after the core simulator ships. Surfaces shipped: - scene::Scene — aggregate ground-truth scene (dipoles, current loops, ferrous objects, eddy-current discs, sensor positions, ambient field) - scene::DipoleSource — point magnetic dipole, SI units - scene::CurrentLoop — planar current loop with 64-segment default Biot–Savart discretisation - scene::FerrousObject — linearly-induced moment from ambient field (χ_steel ≈ 5000 default per Cullity & Graham 2e §2) - scene::EddyCurrent — Faraday + Ohm eddy-current disc primitive - frame::MagFrame — 60-byte fixed-layout binary record, magic 0xC51A_6E70 (distinct from ADR-018 CSI 0xC51F... and ADR-084 sketch 0xC511_0084) - frame::flag::* — bit-set constants (saturation, ADC clip, heavy attenuation, shot-noise-disabled). Raw u16 to avoid pulling bitflags as a workspace dep. - NvsimError — typed errors for parse / serialisation failures - MU_0, GAMMA_E, D_GS — shared physics constants 12 unit tests covering: - scene JSON round-trip preserves all primitive types - magic locked to documented value (0xC51A_6E70) - frame size fixed at 60 bytes - frame round-trip is byte-exact - frame deserialiser rejects short / bad-magic / bad-version inputs - byte-order determinism across repeated serialisations - flag set/check helpers Acceptance per plan §3 Pass 1: - cargo check -p nvsim --no-default-features → clean - cargo test -p nvsim --no-default-features → 12 passed (target ≥6) - Workspace test count 1,575 → 1,587 (+12) - ESP32-S3 on COM7 unaffected (cb #625100, alive) Two research documents committed alongside: - 14-nv-diamond-sensor-simulator.md (469 lines, SOTA + verdict) - 15-nvsim-implementation-plan.md (268 lines, 6-pass build spec) Status: Pass 1 only. Passes 2-6 (source, propagation, sensor, digitiser+pipeline, proof+bench) ship in subsequent commits per the implementation plan. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Generated
+11
@@ -3887,6 +3887,17 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||
|
||||
[[package]]
|
||||
name = "nvsim"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"approx 0.5.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
|
||||
@@ -19,6 +19,7 @@ members = [
|
||||
"crates/wifi-densepose-desktop",
|
||||
"crates/wifi-densepose-pointcloud",
|
||||
"crates/wifi-densepose-geo",
|
||||
"crates/nvsim",
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "nvsim"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "Deterministic NV-diamond magnetometer pipeline simulator (source -> propagation -> NV ensemble -> ADC + lockin demod)"
|
||||
repository.workspace = true
|
||||
keywords = ["nv-diamond", "magnetometer", "simulator", "physics", "biot-savart"]
|
||||
categories = ["science", "simulation"]
|
||||
readme = "README.md"
|
||||
|
||||
# `nvsim` is a standalone leaf crate. It deliberately has NO internal RuView
|
||||
# dependencies — see `docs/research/quantum-sensing/15-nvsim-implementation-plan.md`
|
||||
# §1.1 for the rationale. RuView integration (frame format alignment with
|
||||
# `wifi-densepose-core::FrameKind`, ruvector trace compression, etc.) is
|
||||
# tracked as Optional Integrations in a follow-up section of the README and
|
||||
# lands behind feature flags after the core simulator is shipping.
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5"
|
||||
@@ -0,0 +1,72 @@
|
||||
# nvsim
|
||||
|
||||
Deterministic Rust simulator for NV-diamond ensemble magnetometer pipelines.
|
||||
|
||||
`nvsim` models a forward-only magnetic sensing path:
|
||||
|
||||
```
|
||||
scene
|
||||
→ magnetic source synthesis
|
||||
→ material attenuation
|
||||
→ NV-ensemble response
|
||||
→ digitisation
|
||||
→ binary magnetic feature frames
|
||||
→ deterministic SHA-256 witness
|
||||
```
|
||||
|
||||
It is designed for ferrous-anomaly modeling, eddy-current sanity checks,
|
||||
synthetic magnetic traces, sensor education, and regression testing.
|
||||
|
||||
It is **not** a hardware-control stack, microscope simulator, full Hamiltonian
|
||||
solver, or claim of fT-level sensitivity. This crate does not control lasers,
|
||||
microwave sources, ADC hardware, or real NV sensors.
|
||||
|
||||
Deterministic in the strong sense: a simulator with explicit physics
|
||||
approximations, conjectural propagation defaults that are documented as
|
||||
such, a linear NV-ensemble readout proxy validated by Barry et al.
|
||||
*Rev. Mod. Phys.* 92, 015004 (2020) §III.A, and **no hidden mocks**.
|
||||
|
||||
## Quick start
|
||||
|
||||
```rust
|
||||
use nvsim::scene::{Scene, DipoleSource};
|
||||
use nvsim::frame::{MagFrame, MAG_FRAME_MAGIC};
|
||||
|
||||
let mut scene = Scene::new();
|
||||
scene.add_dipole(DipoleSource::new([0.0, 0.0, 0.5], [0.0, 0.0, 1e-6]));
|
||||
scene.add_sensor([0.0, 0.0, 0.0]);
|
||||
|
||||
// Pass 2+ adds source synthesis, propagation, sensor, digitiser, pipeline.
|
||||
```
|
||||
|
||||
## Acceptance commitments (per implementation plan §5)
|
||||
|
||||
- **Pipeline throughput**: ≥ 1 kHz simulated samples per second of wall-clock on a Cortex-A53-class CPU.
|
||||
- **Determinism**: same `(scene, seed)` produces byte-identical proof-bundle output across runs and machines.
|
||||
- **Noise floor reproduction**: simulator with shot-noise OFF reproduces the analytical Biot–Savart result to ≤ 0.1% RMS.
|
||||
- **Lockin SNR floor**: 1 nT @ 1 kHz vs 100 pT/√Hz floor → SNR ≥ 10 in 1 s.
|
||||
|
||||
Pass 1 (this build) ships only the scaffold + scene types + binary frame
|
||||
shape; the four acceptance numbers come online over Passes 2–6 per the plan.
|
||||
|
||||
## Physics primary sources
|
||||
|
||||
- Jackson, *Classical Electrodynamics* 3e (1999), §5.4–5.8 — Biot–Savart, dipole field.
|
||||
- Doherty et al., *Phys. Rep.* 528, 1 (2013) — NV ground-state Hamiltonian, ODMR transition.
|
||||
- Barry et al., *Rev. Mod. Phys.* 92, 015004 (2020) — NV-ensemble sensitivity, Lorentzian lineshape.
|
||||
- Wolf et al., *Phys. Rev. X* 5, 041001 (2015) — bulk-diamond pT/√Hz reference floor.
|
||||
- Ortner & Bandeira, *SoftwareX* 11, 100466 (2020) — Magpylib reference implementation.
|
||||
|
||||
See `docs/research/quantum-sensing/14-nv-diamond-sensor-simulator.md` for context
|
||||
and `15-nvsim-implementation-plan.md` for the build spec.
|
||||
|
||||
## Optional integrations
|
||||
|
||||
`nvsim` is a standalone leaf crate. RuView ecosystem integrations
|
||||
(`wifi-densepose-core` frame alignment, `ruvector-core` trace compression,
|
||||
etc.) land behind feature flags in follow-up passes once the core simulator
|
||||
ships. None are required to use this crate.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0 (matches workspace default).
|
||||
@@ -0,0 +1,249 @@
|
||||
//! `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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! NV-diamond magnetometer pipeline simulator — deterministic, no hidden mocks.
|
||||
//!
|
||||
//! `nvsim` is a standalone leaf crate. It models a forward-only magnetic
|
||||
//! sensing path — scene → source synthesis → material attenuation → NV
|
||||
//! ensemble → digitiser → binary frames + SHA-256 witness — using explicit
|
||||
//! physics approximations validated against published primary sources.
|
||||
//!
|
||||
//! It is **not** a hardware-control stack, microscope simulator, full
|
||||
//! Hamiltonian solver, or claim of fT-level sensitivity. This crate does
|
||||
//! not control lasers, microwave sources, ADC hardware, or real NV sensors.
|
||||
//!
|
||||
//! # Implementation plan
|
||||
//!
|
||||
//! See `docs/research/quantum-sensing/15-nvsim-implementation-plan.md` for
|
||||
//! the six-pass build spec. This release ships **Pass 1 only**: crate
|
||||
//! scaffold, [`scene`] types, and the [`frame::MagFrame`] binary record.
|
||||
//!
|
||||
//! # Pass 1 surface
|
||||
//!
|
||||
//! - [`scene::Scene`], [`scene::DipoleSource`], [`scene::CurrentLoop`],
|
||||
//! [`scene::FerrousObject`], [`scene::EddyCurrent`]
|
||||
//! - [`frame::MagFrame`] + [`frame::MAG_FRAME_MAGIC`] (`0xC51A_6E70`)
|
||||
//! - [`NvsimError`] — top-level error type for parse / serialisation failures
|
||||
//!
|
||||
//! Subsequent passes add `source`, `propagation`, `sensor`, `digitiser`,
|
||||
//! `pipeline`, and `proof` modules.
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod frame;
|
||||
pub mod scene;
|
||||
|
||||
pub use frame::{MagFrame, MAG_FRAME_MAGIC, MAG_FRAME_VERSION};
|
||||
pub use scene::{CurrentLoop, DipoleSource, EddyCurrent, FerrousObject, Scene};
|
||||
|
||||
/// Top-level simulator error type.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NvsimError {
|
||||
/// JSON serialisation / parsing failed for a scene or frame.
|
||||
#[error("serde error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
/// Magic-number mismatch on frame parse.
|
||||
#[error("magic mismatch: got 0x{got:08X}, expected 0x{expected:08X}")]
|
||||
MagicMismatch {
|
||||
/// Magic value received.
|
||||
got: u32,
|
||||
/// Magic value expected.
|
||||
expected: u32,
|
||||
},
|
||||
|
||||
/// Frame buffer length disagrees with the fixed v1 layout.
|
||||
#[error("frame length mismatch: got {got} bytes, expected {expected}")]
|
||||
FrameLengthMismatch {
|
||||
/// Bytes received.
|
||||
got: usize,
|
||||
/// Bytes expected for this version.
|
||||
expected: usize,
|
||||
},
|
||||
|
||||
/// Frame version is not supported by this build.
|
||||
#[error("unsupported frame version: got {got}, this build supports {supported}")]
|
||||
UnsupportedVersion {
|
||||
/// Version received.
|
||||
got: u16,
|
||||
/// Highest version this build understands.
|
||||
supported: u16,
|
||||
},
|
||||
|
||||
/// A configuration value is out of the supported range.
|
||||
#[error("invalid config: {0}")]
|
||||
InvalidConfig(String),
|
||||
}
|
||||
|
||||
/// Permeability of free space (T·m/A). Jackson 3e §5.6.
|
||||
pub const MU_0: f64 = 4.0 * std::f64::consts::PI * 1.0e-7;
|
||||
|
||||
/// NV electronic gyromagnetic ratio (Hz/T). Doherty 2013 §3.
|
||||
pub const GAMMA_E: f64 = 28.0e9;
|
||||
|
||||
/// NV zero-field-splitting transition (Hz). Doherty 2013 §3.
|
||||
pub const D_GS: f64 = 2.87e9;
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Scene types — ground-truth magnetic sources and ferrous-object distortion.
|
||||
//!
|
||||
//! Per `docs/research/quantum-sensing/15-nvsim-implementation-plan.md` §1.3
|
||||
//! and §2.1. All coordinates SI (metres, A·m², A); all moments are 3-vectors
|
||||
//! in the simulator's global frame. Sign convention: right-hand rule.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 3-vector position / moment / direction. SI units.
|
||||
pub type Vec3 = [f64; 3];
|
||||
|
||||
/// A point magnetic dipole in SI units. The dominant primitive — used for
|
||||
/// far-field approximations of permanent magnets, current loops at distance,
|
||||
/// and the linearised induced moment of ferrous objects.
|
||||
///
|
||||
/// Field at `r` (relative to dipole):
|
||||
/// `B = (μ₀ / 4π r³) · [3(m·r̂)r̂ − m]` (Jackson 3e §5.6).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DipoleSource {
|
||||
/// Position in metres.
|
||||
pub position: Vec3,
|
||||
/// Magnetic moment in A·m².
|
||||
pub moment: Vec3,
|
||||
}
|
||||
|
||||
impl DipoleSource {
|
||||
/// Construct a dipole source.
|
||||
pub const fn new(position: Vec3, moment: Vec3) -> Self {
|
||||
Self { position, moment }
|
||||
}
|
||||
}
|
||||
|
||||
/// A planar circular current loop, discretised at sample time into `n_segments`
|
||||
/// straight segments for numerical Biot–Savart integration. The loop's normal
|
||||
/// vector follows the right-hand rule on `current` (positive current produces
|
||||
/// a moment along `+normal`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CurrentLoop {
|
||||
/// Centre of the loop (m).
|
||||
pub centre: Vec3,
|
||||
/// Unit normal vector (right-hand rule on current).
|
||||
pub normal: Vec3,
|
||||
/// Loop radius (m).
|
||||
pub radius: f64,
|
||||
/// Steady-state current (A).
|
||||
pub current: f64,
|
||||
/// Number of straight-segment chords for Biot–Savart integration. Default 64.
|
||||
#[serde(default = "default_segments")]
|
||||
pub n_segments: u32,
|
||||
}
|
||||
|
||||
const fn default_segments() -> u32 {
|
||||
64
|
||||
}
|
||||
|
||||
impl CurrentLoop {
|
||||
/// Construct a loop with the default 64-segment discretisation.
|
||||
pub fn new(centre: Vec3, normal: Vec3, radius: f64, current: f64) -> Self {
|
||||
Self {
|
||||
centre,
|
||||
normal,
|
||||
radius,
|
||||
current,
|
||||
n_segments: default_segments(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ferrous (high-χ) object that picks up a linearly-induced moment from the
|
||||
/// ambient field and re-radiates as a dipole. Linear approximation —
|
||||
/// `m_induced = χ · V · H_ambient` — valid in low-field, unsaturated regime
|
||||
/// (Cullity & Graham 2e §2). For RuView geometry this is the dominant
|
||||
/// "metallic-object detection" signal.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FerrousObject {
|
||||
/// Centre of mass / centroid (m).
|
||||
pub position: Vec3,
|
||||
/// Volume (m³).
|
||||
pub volume: f64,
|
||||
/// Magnetic susceptibility (dimensionless). 5000 ≈ low-carbon steel.
|
||||
pub susceptibility: f64,
|
||||
}
|
||||
|
||||
impl FerrousObject {
|
||||
/// Construct a steel-default ferrous object (χ ≈ 5000).
|
||||
pub fn steel(position: Vec3, volume: f64) -> Self {
|
||||
Self {
|
||||
position,
|
||||
volume,
|
||||
susceptibility: 5000.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple eddy-current loop — a planar conductor that generates an opposing
|
||||
/// dipole moment per Faraday's law when the ambient flux changes. Faraday +
|
||||
/// Ohm: `I(t) = -(σ A / L) · dΦ/dt`. Geometry simplified to "thin disc with
|
||||
/// scalar inductance" — see plan §2.1: no primary source for arbitrary
|
||||
/// geometry, so this primitive is intentionally approximate.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EddyCurrent {
|
||||
/// Centre of the disc (m).
|
||||
pub position: Vec3,
|
||||
/// Disc area (m²).
|
||||
pub area: f64,
|
||||
/// Conductivity (S/m). Copper ≈ 5.96e7.
|
||||
pub conductivity: f64,
|
||||
/// Disc inductance (H). Caller-supplied scalar.
|
||||
pub inductance: f64,
|
||||
/// Disc-normal unit vector.
|
||||
pub normal: Vec3,
|
||||
}
|
||||
|
||||
/// Aggregate ground-truth scene — a list of every magnetic primitive plus a
|
||||
/// list of sensor positions where the simulator should sample the field.
|
||||
///
|
||||
/// `Scene` is the canonical input to [`crate::Pipeline`]. Two scenes that
|
||||
/// serialise to the same JSON produce the same `(simulator, seed)` proof
|
||||
/// bundle.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Scene {
|
||||
/// Dipole sources (point moments).
|
||||
pub dipoles: Vec<DipoleSource>,
|
||||
/// Current-carrying loops.
|
||||
pub loops: Vec<CurrentLoop>,
|
||||
/// Ferrous objects (linearly-induced dipoles).
|
||||
pub ferrous: Vec<FerrousObject>,
|
||||
/// Eddy-current discs (Faraday + Ohm).
|
||||
pub eddy: Vec<EddyCurrent>,
|
||||
/// Sensor positions (one MagFrame per sensor per timestep).
|
||||
pub sensors: Vec<Vec3>,
|
||||
/// Ambient field at infinity (T) — drives ferrous induced-moment
|
||||
/// computation. Zero by default.
|
||||
#[serde(default)]
|
||||
pub ambient_field: Vec3,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Construct an empty scene with no sources and no sensors.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Append a dipole source.
|
||||
pub fn add_dipole(&mut self, dipole: DipoleSource) -> &mut Self {
|
||||
self.dipoles.push(dipole);
|
||||
self
|
||||
}
|
||||
|
||||
/// Append a current loop.
|
||||
pub fn add_loop(&mut self, l: CurrentLoop) -> &mut Self {
|
||||
self.loops.push(l);
|
||||
self
|
||||
}
|
||||
|
||||
/// Append a ferrous object.
|
||||
pub fn add_ferrous(&mut self, ferrous: FerrousObject) -> &mut Self {
|
||||
self.ferrous.push(ferrous);
|
||||
self
|
||||
}
|
||||
|
||||
/// Append a sensor location.
|
||||
pub fn add_sensor(&mut self, position: Vec3) -> &mut Self {
|
||||
self.sensors.push(position);
|
||||
self
|
||||
}
|
||||
|
||||
/// Total source count across all primitives.
|
||||
pub fn n_sources(&self) -> usize {
|
||||
self.dipoles.len() + self.loops.len() + self.ferrous.len() + self.eddy.len()
|
||||
}
|
||||
|
||||
/// Canonical JSON representation. Used by the proof bundle for content
|
||||
/// addressing — two scenes with the same JSON produce the same witness.
|
||||
pub fn to_canonical_json(&self) -> Result<String, serde_json::Error> {
|
||||
// serde_json::to_string is deterministic for serde-derived types when
|
||||
// the underlying field order is stable, which it is here.
|
||||
serde_json::to_string(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dipole_construction_round_trip_via_json() {
|
||||
let d = DipoleSource::new([1.0, 2.0, 3.0], [0.1, 0.2, 0.3]);
|
||||
let s = serde_json::to_string(&d).unwrap();
|
||||
let d2: DipoleSource = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(d, d2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_loop_default_n_segments_is_64() {
|
||||
let l = CurrentLoop::new([0.0; 3], [0.0, 0.0, 1.0], 0.05, 1.5);
|
||||
assert_eq!(l.n_segments, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scene_is_default_and_serialises() {
|
||||
let s = Scene::new();
|
||||
assert_eq!(s.n_sources(), 0);
|
||||
assert_eq!(s.sensors.len(), 0);
|
||||
let _ = s.to_canonical_json().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_round_trip_via_json_preserves_all_primitives() {
|
||||
let mut s = Scene::new();
|
||||
s.add_dipole(DipoleSource::new([0.0; 3], [1e-6, 0.0, 0.0]));
|
||||
s.add_loop(CurrentLoop::new([0.0; 3], [0.0, 0.0, 1.0], 0.1, 0.5));
|
||||
s.add_ferrous(FerrousObject::steel([0.5; 3], 1e-3));
|
||||
s.add_sensor([1.0, 0.0, 0.0]);
|
||||
let json = s.to_canonical_json().unwrap();
|
||||
let s2: Scene = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(s, s2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user