Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions corpus/nec2c-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
3 changes: 2 additions & 1 deletion docs/math.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 71 additions & 61 deletions src/calculations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 ───────────────────────────────────────────
Expand Down
34 changes: 34 additions & 0 deletions tests/corpus_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading