wifi-densepose/v2/crates/nvsim/src/scene.rs

220 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}
}