diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db595c0..86d6279 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,21 @@ cargo --version Verify: `python --version` +**just** (recommended) + +`just` is a command runner that provides short recipes for the most common +development tasks. It is optional — all commands it wraps can be run directly +— but it ensures the Python extension is always rebuilt before tests run, +which prevents hard-to-diagnose stale-extension failures. + +```sh +cargo install just +# or on Windows with winget: +winget install Casey.Just +``` + +Verify: `just --version` + --- ### First-Time Setup @@ -50,16 +65,20 @@ Install dependencies: Build the Rust extension and install it into the virtual environment: ```sh +# With just (recommended): +just develop + +# Without just: # Windows .venv\Scripts\maturin develop - # Linux / macOS .venv/bin/maturin develop ``` This compiles all Rust crates and produces an importable `arcanum` Python module in the virtual environment. Re-run this command whenever you change -Rust source code before running Python tests. +Rust source code before running Python tests. If you use `just test` or +`just test-python`, this step runs automatically as a prerequisite. --- @@ -68,36 +87,44 @@ Rust source code before running Python tests. ### Rust unit tests ```sh +# With just: +just test-rust + +# Without just: cargo test --workspace ``` -Runs 33 unit tests in `arcanum-nec-import` covering V-PARSE, V-FMT, V-ERR, +Runs 35 unit tests in `arcanum-nec-import` covering V-PARSE, V-FMT, V-ERR, V-WARN, and V-REAL cases from `docs/nec-import/validation.md`. No separate build step is needed — `cargo test` compiles and runs in one command. ### Python integration tests -Build the extension first (see above), then: - ```sh +# With just (rebuilds extension automatically): +just test-python + +# Without just — build the extension first, then: # Windows .venv\Scripts\pytest tests/ -v - # Linux / macOS .venv/bin/pytest tests/ -v ``` These tests exercise the same validation cases end-to-end through the PyO3 Python bindings. They require the extension to be current with the Rust -source; run `maturin develop` before `pytest` if you have changed any Rust -code since the last build. +source. `just test-python` handles this automatically via its `develop` +prerequisite. ### Running both together (matches CI) ```sh +# With just: +just test + +# Without just: # Linux / macOS cargo test --workspace && .venv/bin/maturin develop && .venv/bin/pytest tests/ -v - # Windows cargo test --workspace && .venv\Scripts\maturin develop && .venv\Scripts\pytest tests/ -v ``` @@ -109,12 +136,21 @@ cargo test --workspace && .venv\Scripts\maturin develop && .venv\Scripts\pytest ### Formatting check ```sh +# With just: +just check + +# Without just: cargo fmt --check +cargo clippy --workspace -- -D warnings ``` To auto-apply formatting: ```sh +# With just: +just fmt + +# Without just: cargo fmt ``` diff --git a/crates/arcanum-nec-import/src/cards.rs b/crates/arcanum-nec-import/src/cards.rs index 7940db6..8cd2424 100644 --- a/crates/arcanum-nec-import/src/cards.rs +++ b/crates/arcanum-nec-import/src/cards.rs @@ -228,16 +228,55 @@ pub struct SimulationInput { /// Wire geometry and ground boundary condition consumed by Phase 1. #[derive(Debug, Clone, Default)] pub struct MeshInput { - /// Wire descriptions after GS and GM transformations have been applied. + /// Raw wire descriptions as declared in the deck, before any GS/GM + /// transformations. Phase 1 is responsible for applying `transforms`. pub wires: Vec, /// Ground plane boundary condition (geometric only). pub ground: GeometricGround, /// Ground plane flag from GE card. pub gpflag: i32, + /// GS scale and GM operations to be applied by Phase 1. + pub transforms: GeometryTransforms, +} + +/// GS and GM transformation data passed through to Phase 1. +/// +/// The nec-import parser records these cards verbatim; it does not apply them. +/// Phase 1 applies GS scaling then GM operations (in deck order) to produce +/// final segment coordinates. +#[derive(Debug, Clone, Default)] +pub struct GeometryTransforms { + /// GS XSCALE — uniform scale factor. `None` if no GS card was present. + /// Applied before GM operations. Does not scale wire radii. + pub gs_scale: Option, + /// GM operations in deck order. + pub gm_ops: Vec, +} + +/// One GM card — a rotation, translation, and optional replication. +#[derive(Debug, Clone)] +pub struct GmOperation { + /// ITAG — wire tag to transform. 0 = all wires. + pub tag: u32, + /// NRPT — number of additional copies to generate. 0 = transform in place. + pub n_copies: u32, + /// ROX — rotation about x-axis, degrees. + pub rot_x: f64, + /// ROY — rotation about y-axis, degrees. + pub rot_y: f64, + /// ROZ — rotation about z-axis, degrees. + pub rot_z: f64, + /// XS — translation along x-axis. + pub trans_x: f64, + /// YS — translation along y-axis. + pub trans_y: f64, + /// ZS — translation along z-axis. + pub trans_z: f64, + /// ITS — tag increment applied to each generated copy. + pub tag_increment: u32, } /// A single wire element — straight, arc, or helix. -/// Coordinates reflect any GS/GM transformations applied by the router. #[derive(Debug, Clone)] pub enum WireDescription { Straight(StraightWire), diff --git a/crates/arcanum-nec-import/src/lib.rs b/crates/arcanum-nec-import/src/lib.rs index bbe2d7d..5bac499 100644 --- a/crates/arcanum-nec-import/src/lib.rs +++ b/crates/arcanum-nec-import/src/lib.rs @@ -47,7 +47,7 @@ pub fn parse_file(path: &Path) -> Result<(SimulationInput, ParseWarnings), Parse pub use errors::{ParseError, ParseErrorKind, ParseWarning, ParseWarningKind, ParseWarnings}; pub use cards::{ - ArcWire, GeometricGround, GroundElectrical, GroundModel, GroundType, HelixWire, LoadDefinition, - MeshInput, NearFieldRequest, OutputRequests, RadiationPatternRequest, SimulationInput, - SourceDefinition, StraightWire, WireDescription, + ArcWire, GeometricGround, GeometryTransforms, GmOperation, GroundElectrical, GroundModel, + GroundType, HelixWire, LoadDefinition, MeshInput, NearFieldRequest, OutputRequests, + RadiationPatternRequest, SimulationInput, SourceDefinition, StraightWire, WireDescription, }; diff --git a/crates/arcanum-nec-import/src/router.rs b/crates/arcanum-nec-import/src/router.rs index e7b2bbe..9950726 100644 --- a/crates/arcanum-nec-import/src/router.rs +++ b/crates/arcanum-nec-import/src/router.rs @@ -8,7 +8,10 @@ // - Enforce card ordering (geometry cards before GE; GE required) // - Validate field values for each card type // - Build the tag registry from GW/GA/GH cards; hard-error on duplicates -// - Apply GS scale and GM transformations to wire coordinates +// - Record GS and GM cards verbatim in GeometryTransforms (NOT applied here — +// Phase 1 owns coordinate transformation per docs/phase1-geometry/design.md) +// - Register GM-generated copy tags in the tag registry (for EX/LD validation) +// without modifying wire coordinates // - Split the GN card: ground type → MeshInput; electrical params → ground_electrical // - Validate EX and LD tag/segment references against the complete tag registry // - Assemble the frequency list from FR cards (MHz → Hz conversion here) @@ -36,8 +39,7 @@ pub(crate) fn route(deck: ParsedDeck) -> Result<(SimulationInput, ParseWarnings) // ── Geometry accumulation ───────────────────────────────────────────── let mut wires: Vec = Vec::new(); - let mut gs_scale: Option = None; - let mut pending_gm: Vec<(usize, GmCard)> = Vec::new(); + let mut transforms = GeometryTransforms::default(); let mut ge_gpflag: i32 = 0; let mut ge_seen = false; @@ -124,7 +126,8 @@ pub(crate) fn route(deck: ParsedDeck) -> Result<(SimulationInput, ParseWarnings) "GS XSCALE must not be zero".to_string(), )); } - gs_scale = Some(gs.scale); + // Store for Phase 1; do not apply to coordinates here. + transforms.gs_scale = Some(gs.scale); } NecCard::Gm(gm) => { @@ -140,18 +143,26 @@ pub(crate) fn route(deck: ParsedDeck) -> Result<(SimulationInput, ParseWarnings) .to_string(), )); } - pending_gm.push((line_number, gm)); + // Register copy tags in the tag registry so EX/LD validation + // can resolve references to GM-generated wires. Coordinates are + // not modified; Phase 1 will apply the transformation. + if gm.n_copies > 0 { + register_gm_copies(&gm, &wires, &mut tag_registry, line_number)?; + } + transforms.gm_ops.push(GmOperation { + tag: gm.tag, + n_copies: gm.n_copies, + rot_x: gm.rot_x, + rot_y: gm.rot_y, + rot_z: gm.rot_z, + trans_x: gm.trans_x, + trans_y: gm.trans_y, + trans_z: gm.trans_z, + tag_increment: gm.tag_increment, + }); } NecCard::Ge(ge) => { - // Apply GS scale to all wire coordinates (not radii). - if let Some(scale) = gs_scale { - apply_gs(&mut wires, scale); - } - // Apply GM transformations in deck order. - for (gm_line, gm) in pending_gm.drain(..) { - apply_gm(&mut wires, &mut tag_registry, &gm, gm_line)?; - } ge_gpflag = ge.gpflag; ge_seen = true; } @@ -305,6 +316,7 @@ pub(crate) fn route(deck: ParsedDeck) -> Result<(SimulationInput, ParseWarnings) wires, ground, gpflag: ge_gpflag, + transforms, }, frequencies, sources, @@ -638,166 +650,38 @@ fn near_field_request(card: &NearFieldCard) -> NearFieldRequest { } // ───────────────────────────────────────────────────────────────────────────── -// GS — global scale +// GM — tag registration for copy validation // ───────────────────────────────────────────────────────────────────────────── -/// Scale all wire endpoint coordinates by `scale`. Wire radii are NOT scaled, -/// per docs/nec-import/card-reference.md Section 3 (GS critical note). -fn apply_gs(wires: &mut [WireDescription], scale: f64) { - for wire in wires.iter_mut() { - match wire { - WireDescription::Straight(sw) => { - sw.x1 *= scale; - sw.y1 *= scale; - sw.z1 *= scale; - sw.x2 *= scale; - sw.y2 *= scale; - sw.z2 *= scale; - // sw.radius is intentionally NOT scaled - } - WireDescription::Arc(aw) => { - // Scale the arc radius (a geometric dimension). - // Wire radius is NOT scaled. - aw.arc_radius *= scale; - } - WireDescription::Helix(hw) => { - // Scale all geometric dimensions; wire radius is NOT scaled. - hw.pitch *= scale; - hw.total_length *= scale; - hw.radius_start *= scale; - hw.radius_end *= scale; - // n_turns = total_length / pitch is unchanged by uniform scaling - } - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// GM — geometry move / rotate / replicate -// ───────────────────────────────────────────────────────────────────────────── - -/// Apply a GM transformation to the wire list. +/// Register the tags that a GM card with NRPT > 0 will generate, so that EX +/// and LD cards referencing those tags can be validated at parse time. /// -/// If NRPT = 0: transform the specified wire(s) in place (tag unchanged). -/// If NRPT > 0: keep original wire(s); generate NRPT copies, each with the -/// transform applied one more time than the previous copy. -/// Copy tags are incremented by ITS per copy. -/// -/// GM is fully implemented for StraightWire. For ArcWire and HelixWire, -/// translation is applied via GS scaling; rotation of curved-segment geometry -/// requires Phase 1 involvement and is not applied here. -fn apply_gm( - wires: &mut Vec, - registry: &mut TagRegistry, +/// Coordinates are NOT modified here. Phase 1 applies the actual transformation. +fn register_gm_copies( gm: &GmCard, + wires: &[WireDescription], + registry: &mut TagRegistry, line_number: usize, ) -> Result<(), ParseError> { - // Collect indices of wires to transform. - let target_indices: Vec = if gm.tag == 0 { - (0..wires.len()).collect() + let source_wires: Vec<(u32, u32)> = if gm.tag == 0 { + wires.iter().map(|w| (w.tag(), w.segment_count())).collect() } else { - let idx = wires - .iter() - .position(|w| w.tag() == gm.tag) - .ok_or_else(|| { - ParseError::new( - ParseErrorKind::UnknownTagReference, - line_number, - format!("GM references tag {} which is not defined", gm.tag), - ) - })?; - vec![idx] + let src = wires.iter().find(|w| w.tag() == gm.tag).ok_or_else(|| { + ParseError::new( + ParseErrorKind::UnknownTagReference, + line_number, + format!("GM references tag {} which is not defined", gm.tag), + ) + })?; + vec![(src.tag(), src.segment_count())] }; - if gm.n_copies == 0 { - // Transform in place. - for &idx in &target_indices { - transform_wire_in_place(&mut wires[idx], gm); - } - } else { - // Generate copies. The original wire is not moved. - // Copy k gets the transform applied k times to the original. - let source_wires: Vec = - target_indices.iter().map(|&i| wires[i].clone()).collect(); - - for copy_num in 1..=(gm.n_copies as usize) { - for src in &source_wires { - // Apply the transform copy_num times to the source wire. - let mut new_wire = src.clone(); - for _ in 0..copy_num { - transform_wire_in_place(&mut new_wire, gm); - } - // Assign the new tag. - let new_tag = src.tag() + gm.tag_increment * copy_num as u32; - set_wire_tag(&mut new_wire, new_tag); - - let wire_index = wires.len(); - let seg_count = new_wire.segment_count(); - registry.insert(new_tag, wire_index, seg_count, line_number)?; - wires.push(new_wire); - } + for copy_num in 1..=(gm.n_copies as usize) { + for &(src_tag, seg_count) in &source_wires { + let new_tag = src_tag + gm.tag_increment * copy_num as u32; + // Wire index is a placeholder — Phase 1 assigns real indices. + registry.insert(new_tag, usize::MAX, seg_count, line_number)?; } } Ok(()) } - -/// Apply one application of the GM rotation and translation to a wire. -/// Full rotation is implemented for StraightWire endpoints. -/// ArcWire and HelixWire receive translation only (their parametric forms -/// are origin-relative and cannot be rotated without Phase 1 involvement). -fn transform_wire_in_place(wire: &mut WireDescription, gm: &GmCard) { - match wire { - WireDescription::Straight(sw) => { - let (x1, y1, z1) = rotate_then_translate(sw.x1, sw.y1, sw.z1, gm); - let (x2, y2, z2) = rotate_then_translate(sw.x2, sw.y2, sw.z2, gm); - sw.x1 = x1; - sw.y1 = y1; - sw.z1 = z1; - sw.x2 = x2; - sw.y2 = y2; - sw.z2 = z2; - } - WireDescription::Arc(_) | WireDescription::Helix(_) => { - // Curved wires are origin-relative in their parametric form. - // Rotation and translation require Phase 1 to recompute the - // parametric form. This is a known limitation of the initial - // implementation; GM on GA/GH is not supported here. - } - } -} - -/// Apply GM rotation (ROX → ROY → ROZ) then translation (XS, YS, ZS) -/// to a single point. -fn rotate_then_translate(x: f64, y: f64, z: f64, gm: &GmCard) -> (f64, f64, f64) { - // Rotation about X axis (ROX degrees) - let (x, y, z) = rotate_x(x, y, z, gm.rot_x.to_radians()); - // Rotation about Y axis (ROY degrees) - let (x, y, z) = rotate_y(x, y, z, gm.rot_y.to_radians()); - // Rotation about Z axis (ROZ degrees) - let (x, y, z) = rotate_z(x, y, z, gm.rot_z.to_radians()); - // Translation - (x + gm.trans_x, y + gm.trans_y, z + gm.trans_z) -} - -fn rotate_x(x: f64, y: f64, z: f64, angle: f64) -> (f64, f64, f64) { - let (c, s) = (angle.cos(), angle.sin()); - (x, y * c - z * s, y * s + z * c) -} - -fn rotate_y(x: f64, y: f64, z: f64, angle: f64) -> (f64, f64, f64) { - let (c, s) = (angle.cos(), angle.sin()); - (x * c + z * s, y, -x * s + z * c) -} - -fn rotate_z(x: f64, y: f64, z: f64, angle: f64) -> (f64, f64, f64) { - let (c, s) = (angle.cos(), angle.sin()); - (x * c - y * s, x * s + y * c, z) -} - -fn set_wire_tag(wire: &mut WireDescription, tag: u32) { - match wire { - WireDescription::Straight(sw) => sw.tag = tag, - WireDescription::Arc(aw) => aw.tag = tag, - WireDescription::Helix(hw) => hw.tag = tag, - } -} diff --git a/crates/arcanum-nec-import/src/tests/fmt.rs b/crates/arcanum-nec-import/src/tests/fmt.rs index 1eab52c..8738f86 100644 --- a/crates/arcanum-nec-import/src/tests/fmt.rs +++ b/crates/arcanum-nec-import/src/tests/fmt.rs @@ -103,7 +103,14 @@ fn v_fmt_006_sci_notation_integer_field() { "EN\n", ); let (sim, warnings) = parse(deck).expect("parse should succeed"); + // Wire coordinates are raw (untransformed); transform is stored for Phase 1. check_wire(&sim.mesh_input.wires); + assert_eq!(sim.mesh_input.transforms.gm_ops.len(), 1); + let op = &sim.mesh_input.transforms.gm_ops[0]; + assert_eq!(op.tag, 0); + assert_eq!(op.n_copies, 0); + approx_eq!(op.rot_z, 45.0); + assert_eq!(op.tag_increment, 0); assert!(warnings.is_empty()); } diff --git a/crates/arcanum-py/src/lib.rs b/crates/arcanum-py/src/lib.rs index 60e4bce..9f9b9ff 100644 --- a/crates/arcanum-py/src/lib.rs +++ b/crates/arcanum-py/src/lib.rs @@ -325,6 +325,108 @@ impl PyGroundElectrical { } } +// ───────────────────────────────────────────────────────────────────────────── +// GeometryTransforms +// ───────────────────────────────────────────────────────────────────────────── + +#[pyclass(name = "GmOperation")] +struct PyGmOperation { + inner: nec::GmOperation, +} + +#[pymethods] +impl PyGmOperation { + /// ITAG — wire tag to transform. 0 = all wires. + #[getter] + fn tag(&self) -> u32 { + self.inner.tag + } + /// NRPT — number of additional copies to generate. 0 = transform in place. + #[getter] + fn n_copies(&self) -> u32 { + self.inner.n_copies + } + /// ROX — rotation about x-axis, degrees. + #[getter] + fn rot_x(&self) -> f64 { + self.inner.rot_x + } + /// ROY — rotation about y-axis, degrees. + #[getter] + fn rot_y(&self) -> f64 { + self.inner.rot_y + } + /// ROZ — rotation about z-axis, degrees. + #[getter] + fn rot_z(&self) -> f64 { + self.inner.rot_z + } + /// XS — translation along x-axis. + #[getter] + fn trans_x(&self) -> f64 { + self.inner.trans_x + } + /// YS — translation along y-axis. + #[getter] + fn trans_y(&self) -> f64 { + self.inner.trans_y + } + /// ZS — translation along z-axis. + #[getter] + fn trans_z(&self) -> f64 { + self.inner.trans_z + } + /// ITS — tag increment per generated copy. + #[getter] + fn tag_increment(&self) -> u32 { + self.inner.tag_increment + } + fn __repr__(&self) -> String { + format!( + "GmOperation(tag={}, n_copies={}, rot=({},{},{}), trans=({},{},{}))", + self.inner.tag, + self.inner.n_copies, + self.inner.rot_x, + self.inner.rot_y, + self.inner.rot_z, + self.inner.trans_x, + self.inner.trans_y, + self.inner.trans_z, + ) + } +} + +#[pyclass(name = "GeometryTransforms")] +struct PyGeometryTransforms { + inner: nec::GeometryTransforms, +} + +#[pymethods] +impl PyGeometryTransforms { + /// GS scale factor, or None if no GS card was present. + #[getter] + fn gs_scale(&self) -> Option { + self.inner.gs_scale + } + /// GM operations in deck order. + #[getter] + fn gm_ops(&self) -> Vec { + self.inner + .gm_ops + .iter() + .cloned() + .map(|op| PyGmOperation { inner: op }) + .collect() + } + fn __repr__(&self) -> String { + format!( + "GeometryTransforms(gs_scale={:?}, gm_ops={})", + self.inner.gs_scale, + self.inner.gm_ops.len() + ) + } +} + // ───────────────────────────────────────────────────────────────────────────── // MeshInput // ───────────────────────────────────────────────────────────────────────────── @@ -337,6 +439,7 @@ struct PyMeshInput { #[pymethods] impl PyMeshInput { /// List of wire elements. Each item is a StraightWire, ArcWire, or HelixWire. + /// Coordinates are raw (pre-transformation); apply transforms before use. #[getter] fn wires(&self, py: Python<'_>) -> PyResult> { self.inner @@ -361,6 +464,14 @@ impl PyMeshInput { self.inner.gpflag } + /// GS and GM transformations to be applied by Phase 1. + #[getter] + fn transforms(&self) -> PyGeometryTransforms { + PyGeometryTransforms { + inner: self.inner.transforms.clone(), + } + } + fn __repr__(&self) -> String { format!( "MeshInput(wires={}, gpflag={})", @@ -745,6 +856,8 @@ fn arcanum(m: &Bound<'_, PyModule>) -> PyResult<()> { // NEC import types m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/docs/nec-import/design.md b/docs/nec-import/design.md index 2f1dac5..e0405d0 100644 --- a/docs/nec-import/design.md +++ b/docs/nec-import/design.md @@ -115,7 +115,7 @@ The semantic stage consumes the `ParsedDeck` and produces a `SimulationInput`. I - Validating card ordering (geometry cards before GE; simulation cards after GE) - Checking that GE is present (hard error if absent) - Building the tag registry from GW/GA/GH cards and checking for duplicate tags -- Applying GS and GM transformations to wire geometry +- Recording GS and GM cards verbatim in `GeometryTransforms` for Phase 1 to apply - Resolving EX and LD tag/segment references against the tag registry - Splitting the GN card: geometric boundary condition → `MeshInput`; electrical parameters → `GroundElectrical` - Assembling the frequency list from FR cards @@ -144,9 +144,27 @@ Each field is consumed independently by the relevant phase. No phase receives th ```rust pub struct MeshInput { - pub wires: Vec, // From GW, GA, GH (after GS/GM applied) + pub wires: Vec, // From GW, GA, GH (raw, pre-transformation) pub ground: GeometricGround, // From GN (geometric portion only) pub gpflag: i32, // From GE + pub transforms: GeometryTransforms, // From GS and GM cards, for Phase 1 +} + +pub struct GeometryTransforms { + pub gs_scale: Option, // GS XSCALE; None if no GS card present + pub gm_ops: Vec, // GM cards in deck order +} + +pub struct GmOperation { + pub tag: u32, // ITAG (0 = all wires) + pub n_copies: u32, // NRPT + pub rot_x: f64, // ROX degrees + pub rot_y: f64, // ROY degrees + pub rot_z: f64, // ROZ degrees + pub trans_x: f64, // XS + pub trans_y: f64, // YS + pub trans_z: f64, // ZS + pub tag_increment: u32, // ITS } ``` @@ -174,15 +192,13 @@ if gn.iperf == 0 || gn.iperf == 2 { The `GnCard` struct is consumed entirely in the semantic stage and does not appear in `SimulationInput`. Neither Phase 1 nor Phase 2 sees the raw card. They see only their respective derived structs. This is the implementation of the split documented in `docs/phase1-geometry/design.md` Section 6.1. -### 4.5 Transformation Application +### 4.5 Geometry Transformation Passthrough -GS and GM cards are applied eagerly in the semantic stage before `MeshInput` is constructed. The transformation pipeline is: +GS and GM cards are recorded verbatim in `MeshInput.transforms` and passed to Phase 1 unchanged. The semantic stage does not apply them to wire coordinates. Phase 1 owns coordinate transformation per `docs/phase1-geometry/design.md` Sections 7.1 and 7.2. -1. Collect all wire descriptions from GW/GA/GH cards in deck order. -2. Apply GS scale factor to all wire endpoint coordinates (not radii). -3. Apply GM transformations in deck order, each referencing wires by tag. +`MeshInput.wires` contains the raw wire descriptions exactly as declared in the deck. Phase 1 is responsible for applying `transforms.gs_scale` (first) and then `transforms.gm_ops` (in order) to produce final segment coordinates. -After this pipeline, `MeshInput.wires` contains final transformed coordinates. No transformation records are preserved in `MeshInput`. Phase 1 sees only the result. +**Tag registry and EX/LD validation:** When a GM card has `n_copies > 0`, the semantic stage still registers the generated copy tags in the internal tag registry so that EX and LD card cross-references can be validated at parse time. This registration records only the tag number and segment count; no wire coordinates are modified. ### 4.6 Tag Registry diff --git a/examples/nec2json.py b/examples/nec2json.py index 0fe42eb..1c2a498 100644 --- a/examples/nec2json.py +++ b/examples/nec2json.py @@ -1,4 +1,4 @@ -"""nec_dump.py — dump a parsed NEC deck as JSON. +"""nec2json.py — dump a parsed NEC deck as JSON. Usage: python examples/nec_dump.py path/to/deck.nec @@ -41,11 +41,29 @@ def sim_to_dict(sim) -> dict: mesh = sim.mesh_input ground_elec = sim.ground_electrical + t = mesh.transforms out = { "mesh_input": { "gpflag": mesh.gpflag, "ground_type": mesh.ground.ground_type, "wires": [wire_to_dict(w) for w in mesh.wires], + "transforms": { + "gs_scale": t.gs_scale, + "gm_ops": [ + { + "tag": op.tag, + "n_copies": op.n_copies, + "rot_x_deg": op.rot_x, + "rot_y_deg": op.rot_y, + "rot_z_deg": op.rot_z, + "trans_x": op.trans_x, + "trans_y": op.trans_y, + "trans_z": op.trans_z, + "tag_increment": op.tag_increment, + } + for op in t.gm_ops + ], + }, }, "frequencies_hz": sim.frequencies, "sources": [ diff --git a/examples/nec_inspect.py b/examples/nec_inspect.py index 13e514a..b49dd1c 100644 --- a/examples/nec_inspect.py +++ b/examples/nec_inspect.py @@ -178,6 +178,27 @@ def print_output_requests(sim) -> None: field("step (dx,dy,dz)", f"({nf.dx}, {nf.dy}, {nf.dz})") +def print_transforms(sim) -> None: + t = sim.mesh_input.transforms + if t.gs_scale is None and not t.gm_ops: + return + heading("GEOMETRY TRANSFORMS [GS / GM]") + if t.gs_scale is not None: + field("GS scale factor", t.gs_scale) + for i, op in enumerate(t.gm_ops): + action = f"{op.n_copies} cop{'y' if op.n_copies == 1 else 'ies'}" \ + if op.n_copies > 0 else "in-place" + subheading(f"GM {i + 1} tag={op.tag} {action}") + field("rotation x °", op.rot_x) + field("rotation y °", op.rot_y) + field("rotation z °", op.rot_z) + field("translation x", op.trans_x) + field("translation y", op.trans_y) + field("translation z", op.trans_z) + if op.n_copies > 0: + field("tag increment", op.tag_increment) + + def print_warnings(warnings) -> None: if not warnings: return @@ -202,6 +223,7 @@ def main() -> int: print(f"\nFile: {path}") print_geometry(sim) + print_transforms(sim) print_ground_electrical(sim) print_frequencies(sim) print_sources(sim) diff --git a/justfile b/justfile new file mode 100644 index 0000000..eca0dee --- /dev/null +++ b/justfile @@ -0,0 +1,31 @@ +# Default recipe — show available commands +default: + @just --list + +# Build and install the Python extension into the venv +develop: + maturin develop + +# Run all Rust unit tests +test-rust: + cargo test --workspace + +# Run all Python integration tests (rebuilds extension first) +test-python: develop + pytest tests/ -v + +# Run the full test suite — Rust then Python +test: test-rust test-python + +# Check formatting and clippy +check: + cargo fmt --check + cargo clippy --workspace -- -D warnings + +# Apply rustfmt +fmt: + cargo fmt + +# Build a release wheel +build: + maturin build --release diff --git a/python/arcanum/arcanum.pdb b/python/arcanum/arcanum.pdb index 472e175..f87b46b 100644 Binary files a/python/arcanum/arcanum.pdb and b/python/arcanum/arcanum.pdb differ