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:
ruv
2026-04-26 15:57:58 -04:00
parent 905b680747
commit 9c95bfac0c
9 changed files with 1397 additions and 0 deletions
Generated
+11
View File
@@ -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"
+1
View File
@@ -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`.
+26
View File
@@ -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"
+72
View File
@@ -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 BiotSavart 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 26 per the plan.
## Physics primary sources
- Jackson, *Classical Electrodynamics* 3e (1999), §5.45.8 — BiotSavart, 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).
+249
View File
@@ -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));
}
}
+82
View File
@@ -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;
+219
View File
@@ -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 BiotSavart 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 BiotSavart 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);
}
}