diff --git a/corpus/nec2c-reference.json b/corpus/nec2c-reference.json index b87ad88..9d46e7c 100644 --- a/corpus/nec2c-reference.json +++ b/corpus/nec2c-reference.json @@ -34,6 +34,21 @@ "apex_height_m": 12.0, "feedpoint": { "real_ohm": 33.345, "imag_ohm": -113.000 }, "feedpoint_real_range_ohm": [25.0, 50.0] + }, + "dipole-40m-ground-R": { + "description": "Horizontal dipole feedpoint R (ohm) over finite ground, 40m (7.1 MHz), L=20.09 m, GN 2. Calibrates nec_calibrated_dipole_r(). Validated: nec2c free-space resonant R = 72.1 ohm (textbook ~73). R RISES with height (image theory), unlike the earlier fnec anchors.", + "frequency_mhz": 7.1, + "ground_params_sigma_epsr": { + "poor": [0.001, 5.0], + "average": [0.005, 13.0], + "good": [0.030, 20.0] + }, + "feedpoint_real_ohm": { + "poor": { "7m": 65.8, "10m": 73.7, "12m": 76.6 }, + "average": { "7m": 62.1, "10m": 76.7, "12m": 81.9 }, + "good": { "7m": 57.6, "10m": 78.9, "12m": 86.5 } + }, + "free_space_real_ohm": 67.4 } } } diff --git a/docs/math.md b/docs/math.md index 73f2b5b..96a9913 100644 --- a/docs/math.md +++ b/docs/math.md @@ -127,7 +127,8 @@ r = \max\left(0.01,\frac{Z_t}{Z_{\mathrm{ref}}}\right) $$ where $Z_{\mathrm{ref}}$ is the **NEC-calibrated** nominal feedpoint resistance for -the current height/ground (`nec_calibrated_dipole_r`, ~45–77 Ω), *not* a fixed +the current height/ground/frequency (`nec_calibrated_dipole_r`, ~58–87 Ω over +ground, interpolated on height-in-wavelengths from nec2c solves), *not* a fixed 73 Ω. At the default 1:1 ratio this correction is a no-op, so it does not affect default resonant lengths. diff --git a/docs/requirements.md b/docs/requirements.md index d20ed35..7fda783 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -292,6 +292,8 @@ Owner: unassigned Resolution: Baseline extended with a second reference solver, **nec2c** (NEC-2, double precision), which unlike the fnec Hallén solver supports finite ground and multi-wire geometry. `scripts/nec-reference.sh` regenerates the reference values, committed in `corpus/nec2c-reference.json` (CI validates against the committed data; nec2c is not required in CI). Two nec2c-backed CI-gated tests are now active: `corpus_nec2c_dipole_resonant_length_within_tolerance` (dipole length vs nec2c free-space resonance) and `corpus_nec2c_inverted_v_geometry_and_feedpoint` (the multi-wire inverted-V that fnec could not solve). Remaining work: ground/height-aware decks, EFHW, and loop/trap (GAP-006) — the pipeline is now in place to add them. Notes: A physical finding was recorded — a bare thin wire resonates ~2% **longer** in idealised NEC free space than the practical 468/f rule rusty-wire uses (nec2c: 20.54 m vs 20.09 m at 7.1 MHz). The 468/f rule is the accepted length for real installations, so COMP-001 NEC length gates use a realistic ±3% tolerance rather than ±1%. Decision recorded: 2026-07-08. +Follow-up (2026-07-08): the dipole **feedpoint-R model** (`nec_calibrated_dipole_r`, GAP item 3c) was re-calibrated against nec2c after nec2c reproduced the textbook free-space half-wave R (72.1 Ω) while the earlier fnec-rust anchors were physically implausible (R *falling* with height and ~−95 Ω reactance for a resonant dipole). The model now interpolates a full nec2c ground matrix (poor/average/good × 7/10/12 m; R correctly *rises* with height toward the ~0.3 λ peak). CI-gated by `corpus_nec2c_dipole_ground_feedpoint_r_matrix`. Reactance is still not modelled. + --- ## Traceability Matrix diff --git a/src/calculations.rs b/src/calculations.rs index 400ac95..4a09340 100644 --- a/src/calculations.rs +++ b/src/calculations.rs @@ -488,51 +488,54 @@ pub fn calculate_for_band_with_environment( } /// Return the NEC-calibrated radiation resistance (real part of feedpoint impedance) -/// for a half-wave dipole at the given height and ground, derived from fnec-rust -/// Hallén solver corpus sweeps (corpus/reference-results.json, v2.9.0). +/// for a horizontal half-wave dipole at the given height, frequency and ground, +/// derived from **nec2c** (NEC-2) solves (corpus/nec2c-reference.json). /// -/// The interpolation is on height-in-**wavelengths** (`h/λ = height·f/c`), so the -/// estimate is frequency-aware and reproduces the 40 m calibration exactly. The -/// underlying anchor magnitudes are still measured only at 7.1 MHz, and only R is -/// modelled (corpus reactance is not applied), so values away from 40 m remain a -/// coarse first-order estimate until a full multi-frequency NEC sweep is -/// committed (GAP-011). +/// A horizontal dipole's radiation resistance rises with height in wavelengths +/// toward a peak near ~0.3 λ, then relaxes back toward the free-space value. The +/// anchors are nec2c solves at 7/10/12 m AGL over poor/average/good ground at +/// 7.1 MHz (h/λ = 0.166/0.237/0.284) plus the free-space value at 0.5 λ, so the +/// estimate is frequency-aware (interpolated on `h/λ = height·f/c`). nec2c is +/// validated against the textbook free-space half-wave R (~72 Ω). /// -/// Reference data (7.1 MHz, 2 mm wire, 51 segments, bare-wire ~0.95 end-effect cut): -/// free space: R ≈ 62.94 Ω -/// 7 m AGL, good: R ≈ 73.03 Ω -/// 10 m AGL, poor: R ≈ 56.38 Ω -/// 10 m AGL, average: R ≈ 54.35 Ω -/// 10 m AGL, good: R ≈ 52.84 Ω -/// 12 m AGL, good: R ≈ 45.56 Ω +/// This supersedes earlier fnec-rust anchors (a *falling* R-vs-height trend with +/// ~−95 Ω reactance for a resonant dipole — physically implausible). Only R is +/// modelled; reactance is not. /// -/// For heights other than 7/10/12 m the value is linearly interpolated. -/// Ground-class offset at 10 m is applied as a calibrated delta. +/// nec2c reference R (7.1 MHz, 20.09 m, 1 mm radius, 51 segments): +/// 7 m 10 m 12 m +/// poor 65.8 73.7 76.6 +/// average 62.1 76.7 81.9 +/// good 57.6 78.9 86.5 (free space ≈ 67.4) pub fn nec_calibrated_dipole_r( antenna_height_m: f64, freq_mhz: f64, ground_class: GroundClass, ) -> f64 { - // A horizontal dipole's radiation resistance tracks its height in WAVELENGTHS - // (image-method ground coupling), not raw metres. Re-express the NEC corpus - // anchors — measured at 7.1 MHz, 7/10/12 m AGL, "good" ground — as height-in-λ, - // then interpolate on the actual band's h/λ. This reproduces the 40 m - // calibration exactly (10 m @ 7.1 MHz → 0.237 λ → 52.84 Ω) while making the - // estimate frequency-aware: at 20 m a 10 m mast is ~0.47 λ, not ~0.24 λ, and - // gets a correspondingly higher R. A free-space anchor at 0.5 λ lets high - // antennas / high bands relax toward the measured free-space value instead of - // clamping to the 12 m figure. Still a coarse first-order model until a full - // multi-frequency NEC sweep is committed (GAP-011). const LAMBDA_CAL_M: f64 = 299.792_458 / 7.1; // wavelength at the calibration freq - const FREE_SPACE_R: f64 = 62.94; // NEC corpus free-space R (high-antenna asymptote) - - // (height-in-λ, R at "good" ground), ascending in h/λ. - let anchors: [(f64, f64); 4] = [ - (7.0 / LAMBDA_CAL_M, 73.03), // 0.166 λ (7 m @ 7.1 MHz) - (10.0 / LAMBDA_CAL_M, 52.84), // 0.237 λ (10 m @ 7.1 MHz) - (12.0 / LAMBDA_CAL_M, 45.56), // 0.284 λ (12 m @ 7.1 MHz) - (0.5, FREE_SPACE_R), // half-wave height: ground coupling weakens - ]; + const FREE_SPACE_R: f64 = 67.4; // nec2c free-space R (high-antenna asymptote) + + // (height-in-λ, R) anchors for this ground class, ascending in h/λ. + let anchors: [(f64, f64); 4] = match ground_class { + GroundClass::Poor => [ + (7.0 / LAMBDA_CAL_M, 65.8), + (10.0 / LAMBDA_CAL_M, 73.7), + (12.0 / LAMBDA_CAL_M, 76.6), + (0.5, FREE_SPACE_R), + ], + GroundClass::Average => [ + (7.0 / LAMBDA_CAL_M, 62.1), + (10.0 / LAMBDA_CAL_M, 76.7), + (12.0 / LAMBDA_CAL_M, 81.9), + (0.5, FREE_SPACE_R), + ], + GroundClass::Good => [ + (7.0 / LAMBDA_CAL_M, 57.6), + (10.0 / LAMBDA_CAL_M, 78.9), + (12.0 / LAMBDA_CAL_M, 86.5), + (0.5, FREE_SPACE_R), + ], + }; let h_over_lambda = if freq_mhz > 0.0 { antenna_height_m * freq_mhz / 299.792_458 @@ -541,7 +544,7 @@ pub fn nec_calibrated_dipole_r( }; // Piecewise-linear interpolation on h/λ; hold the end anchors beyond the range. - let h_good = if h_over_lambda <= anchors[0].0 { + let r = if h_over_lambda <= anchors[0].0 { anchors[0].1 } else if h_over_lambda >= anchors[anchors.len() - 1].0 { anchors[anchors.len() - 1].1 @@ -556,15 +559,7 @@ pub fn nec_calibrated_dipole_r( .unwrap_or(FREE_SPACE_R) }; - // Ground-class deltas (NEC corpus, at 10 m / 7.1 MHz; small, applied across bands): - // average - good ≈ +1.51 Ω; poor - good ≈ +3.54 Ω - let ground_delta: f64 = match ground_class { - GroundClass::Good => 0.0, - GroundClass::Average => 1.51, - GroundClass::Poor => 3.54, - }; - - (h_good + ground_delta).clamp(30.0, 90.0) + r.clamp(30.0, 95.0) } fn height_skip_factor(antenna_height_m: f64) -> f64 { @@ -1816,33 +1811,48 @@ mod tests { } #[test] - fn nec_calibrated_r_is_frequency_aware_and_preserves_40m_calibration() { - // 40 m calibration reproduced exactly (h/λ hits the measured anchors). - assert!((nec_calibrated_dipole_r(7.0, 7.1, GroundClass::Good) - 73.03).abs() < 0.05); - assert!((nec_calibrated_dipole_r(10.0, 7.1, GroundClass::Good) - 52.84).abs() < 0.05); - assert!((nec_calibrated_dipole_r(12.0, 7.1, GroundClass::Good) - 45.56).abs() < 0.05); - - // The same 10 m mast is a larger fraction of a wavelength on 20 m, so R - // rises toward free space relative to the 40 m case. + fn nec_calibrated_r_matches_nec2c_anchors_and_is_frequency_aware() { + // nec2c good-ground anchors at 7.1 MHz (corpus/nec2c-reference.json). + assert!((nec_calibrated_dipole_r(7.0, 7.1, GroundClass::Good) - 57.6).abs() < 0.1); + assert!((nec_calibrated_dipole_r(10.0, 7.1, GroundClass::Good) - 78.9).abs() < 0.1); + assert!((nec_calibrated_dipole_r(12.0, 7.1, GroundClass::Good) - 86.5).abs() < 0.1); + + // R rises with height over 0.166→0.284 λ (physical horizontal-dipole trend). + assert!( + nec_calibrated_dipole_r(7.0, 7.1, GroundClass::Good) + < nec_calibrated_dipole_r(12.0, 7.1, GroundClass::Good), + "R should rise with height toward the ~0.3 λ peak" + ); + + // Frequency-aware: the same 10 m mast on 20 m is ~0.47 λ (past the peak), + // so R falls back toward free space relative to the 40 m (0.24 λ) case. let r_40 = nec_calibrated_dipole_r(10.0, 7.1, GroundClass::Good); let r_20 = nec_calibrated_dipole_r(10.0, 14.175, GroundClass::Good); - assert!(r_20 > r_40, "R should rise on 20 m ({r_20} vs {r_40})"); + assert!( + r_20 < r_40, + "past the peak R should fall ({r_20} vs {r_40})" + ); - // High band / high antenna relaxes to the free-space asymptote (~62.94 Ω). + // High band / high antenna relaxes to the free-space asymptote (~67.4 Ω). let r_10m_band = nec_calibrated_dipole_r(10.0, 28.5, GroundClass::Good); assert!( - (r_10m_band - 62.94).abs() < 0.5, + (r_10m_band - 67.4).abs() < 0.5, "R should approach free space ({r_10m_band})" ); - // Ground-class ordering preserved at a fixed height/frequency. + // Ground-class effect is height-dependent (nec2c): poor < good at 10 m but + // poor > good at 7 m. assert!( nec_calibrated_dipole_r(10.0, 7.1, GroundClass::Poor) - > nec_calibrated_dipole_r(10.0, 7.1, GroundClass::Good) + < nec_calibrated_dipole_r(10.0, 7.1, GroundClass::Good) + ); + assert!( + nec_calibrated_dipole_r(7.0, 7.1, GroundClass::Poor) + > nec_calibrated_dipole_r(7.0, 7.1, GroundClass::Good) ); - // Degenerate frequency falls back to the calibration point. - assert!((nec_calibrated_dipole_r(10.0, 0.0, GroundClass::Good) - 52.84).abs() < 0.05); + // Degenerate frequency falls back to the 10 m calibration point. + assert!((nec_calibrated_dipole_r(10.0, 0.0, GroundClass::Good) - 78.9).abs() < 0.1); } // ── Invariant / property sweeps ─────────────────────────────────────────── diff --git a/tests/corpus_validation.rs b/tests/corpus_validation.rs index 020351f..8d2b937 100644 --- a/tests/corpus_validation.rs +++ b/tests/corpus_validation.rs @@ -988,6 +988,40 @@ fn corpus_nec2c_inverted_v_geometry_and_feedpoint() { ); } +/// COMP-001 (dipole feedpoint R over ground): `nec_calibrated_dipole_r` reproduces +/// the nec2c finite-ground matrix (corpus/nec2c-reference.json) at 7/10/12 m over +/// each ground class at 7.1 MHz. Guards the nec2c re-calibration (R rises with +/// height) against regression back to the old fnec anchors. +#[test] +fn corpus_nec2c_dipole_ground_feedpoint_r_matrix() { + use rusty_wire::calculations::{nec_calibrated_dipole_r, GroundClass}; + // (ground, height_m, nec2c reference R in ohms) + let matrix = [ + (GroundClass::Poor, 7.0, 65.8), + (GroundClass::Poor, 10.0, 73.7), + (GroundClass::Poor, 12.0, 76.6), + (GroundClass::Average, 7.0, 62.1), + (GroundClass::Average, 10.0, 76.7), + (GroundClass::Average, 12.0, 81.9), + (GroundClass::Good, 7.0, 57.6), + (GroundClass::Good, 10.0, 78.9), + (GroundClass::Good, 12.0, 86.5), + ]; + for (ground, height, r_ref) in matrix { + let r = nec_calibrated_dipole_r(height, 7.1, ground); + assert!( + (r - r_ref).abs() < 0.2, + "{ground:?} {height} m: model {r:.1} Ω vs nec2c {r_ref} Ω" + ); + } + // The physical trend the fnec anchors got wrong: R rises with height. + assert!( + nec_calibrated_dipole_r(7.0, 7.1, GroundClass::Good) + < nec_calibrated_dipole_r(12.0, 7.1, GroundClass::Good) + ); + println!("nec2c dipole-40m-ground-R matrix: nec_calibrated_dipole_r matches all 9 cells"); +} + // --------------------------------------------------------------------------- // Corpus Validation Summary // ---------------------------------------------------------------------------