diff --git a/docs/math.md b/docs/math.md index 914b9ba..73f2b5b 100644 --- a/docs/math.md +++ b/docs/math.md @@ -257,7 +257,7 @@ Higher score ranks earlier. Current models are intentionally lightweight and fast: - Partial mitigation implemented: standardized antenna-height presets (7 m, 10 m, 12 m) now apply a first-order skip-distance scaling model. -- No explicit R/X feedpoint sweep vs height/ground/conductor diameter. +- Feedpoint **resistance** is estimated frequency-aware from NEC corpus anchors interpolated on height-in-wavelengths (`nec_calibrated_dipole_r`, §4); the **reactance** ($X$) is not modelled and there is no full R/X sweep vs conductor diameter. - No common-mode choke model or ferrite core loss/thermal derating. - No full current-distribution solver in the optimization loop. @@ -306,7 +306,35 @@ The $450/f$ rule is a rule-of-thumb starting point, **not** a cut length. A real trap dipole's element lengths depend on the trap inductance/capacitance and the specific band pair, which this lightweight model does not solve. Treat the output as an initial wire estimate and finalise element lengths against the trap -manufacturer's data or a NEC model. +manufacturer's data or a NEC model. The **band-pair guidance** of §11 is the more +useful design output. + +## 11) Trap Dipole Guidance (Per Band Pair) + +When two or more bands are selected, Rusty Wire emits a per-band-pair guidance +section (upper band $f_u$, lower band $f_l$, $f_u > f_l$). For each adjacent pair: + +- **Inner leg** (per side) — the upper-band element inboard of the trap, cut to a + quarter-wave for $f_u$: +$$ +L_{\mathrm{inner}} = \frac{71.58}{f_u}\,VF \quad(\approx 234.85/f_u\ \mathrm{ft}) +$$ +- **Total leg** (per side) — resonant on the lower band. It is ~4 % shorter than a + plain quarter-wave because the trap's inductance electrically lengthens the + outer section, so less physical wire reaches $f_l$ resonance: +$$ +L_{\mathrm{leg}} = \frac{68.58}{f_l}\,VF \quad(\approx 225/f_l\ \mathrm{ft}),\qquad +L_{\mathrm{outer}} = \max(L_{\mathrm{leg}} - L_{\mathrm{inner}},\,0) +$$ +- **Trap** — parallel-resonant at $f_u$, isolating the outer section on the upper + band. From $f = 1/(2\pi\sqrt{LC})$ the required $L\text{–}C$ product is +$$ +L[\mu\mathrm{H}]\cdot C[\mathrm{pF}] = \frac{10^6}{4\pi^2\,f_u^2} = \frac{25{,}330}{f_u^2} +$$ + and Rusty Wire lists a few practical $(C, L)$ component pairs satisfying it. + +This is a first-order single-trap-per-side model; multi-trap and mutual-coupling +effects are not solved. ## References diff --git a/src/app/advise.rs b/src/app/advise.rs index 63a0c6b..66248e2 100644 --- a/src/app/advise.rs +++ b/src/app/advise.rs @@ -143,16 +143,24 @@ pub struct AdviseView { } pub fn optimize_transformer_candidates(config: &AppConfig) -> TransformerOptimizerView { + let baseline = build_optimizer_calculations(config, TransformerRatio::R1To1); + let baseline_avg_half_wave = mean_half_wave_m(&baseline); + + // Representative frequency for the height-in-λ feedpoint-R model: the mean of + // the selected bands' centres (falls back to the 7.1 MHz calibration if empty). + let repr_freq_mhz = if baseline.is_empty() { + 7.1 + } else { + baseline.iter().map(|c| c.frequency_mhz).sum::() / baseline.len() as f64 + }; let source_impedance = super::assumed_feedpoint_impedance_ohm( config.mode, config.antenna_model, config.antenna_height_m, + repr_freq_mhz, config.ground_class, ); - let baseline = build_optimizer_calculations(config, TransformerRatio::R1To1); - let baseline_avg_half_wave = mean_half_wave_m(&baseline); - let mut candidates: Vec = TRANSFORMER_OPTIMIZER_CANDIDATES .iter() .copied() diff --git a/src/app/mod.rs b/src/app/mod.rs index 401f57d..c71a05d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1160,10 +1160,21 @@ pub fn results_overview_view(results: &AppResults) -> ResultsOverviewView { ]; if results.config.antenna_model == Some(AntennaModel::EndFedHalfWave) { + let repr_freq_mhz = if results.calculations.is_empty() { + 7.1 + } else { + results + .calculations + .iter() + .map(|c| c.frequency_mhz) + .sum::() + / results.calculations.len() as f64 + }; let feedpoint_r = assumed_feedpoint_impedance_ohm( results.config.mode, results.config.antenna_model, results.config.antenna_height_m, + repr_freq_mhz, results.config.ground_class, ); let cmp = compare_efhw_transformers(feedpoint_r); @@ -1712,16 +1723,22 @@ pub fn trap_dipole_guidance_view(results: &AppResults) -> Option = cap_values @@ -3149,6 +3166,7 @@ fn assumed_feedpoint_impedance_ohm( mode: CalcMode, antenna_model: Option, antenna_height_m: f64, + freq_mhz: f64, ground_class: GroundClass, ) -> f64 { match antenna_model { @@ -3156,16 +3174,18 @@ fn assumed_feedpoint_impedance_ohm( Some(AntennaModel::Dipole) | Some(AntennaModel::InvertedVDipole) | Some(AntennaModel::HybridMultiSection) => { - crate::calculations::nec_calibrated_dipole_r(antenna_height_m, ground_class) + crate::calculations::nec_calibrated_dipole_r(antenna_height_m, freq_mhz, ground_class) } Some(AntennaModel::TrapDipole) => 65.0, Some(AntennaModel::FullWaveLoop) => 100.0, Some(AntennaModel::EndFedHalfWave) => 2800.0, Some(AntennaModel::OffCenterFedDipole) => 200.0, None => match mode { - CalcMode::Resonant => { - crate::calculations::nec_calibrated_dipole_r(antenna_height_m, ground_class) - } + CalcMode::Resonant => crate::calculations::nec_calibrated_dipole_r( + antenna_height_m, + freq_mhz, + ground_class, + ), CalcMode::NonResonant => 450.0, }, } diff --git a/src/calculations.rs b/src/calculations.rs index f803286..400ac95 100644 --- a/src/calculations.rs +++ b/src/calculations.rs @@ -360,7 +360,7 @@ pub fn calculate_for_band_with_environment( // classic textbook 73 Ω free-space value. Height and ground affect the radiation // resistance through image-method coupling; the NEC values are validated against // fnec-rust Hallén solver output (see corpus/reference-results.json). - let nominal_feedpoint_r = nec_calibrated_dipole_r(antenna_height_m, ground_class); + let nominal_feedpoint_r = nec_calibrated_dipole_r(antenna_height_m, freq, ground_class); // Use the NEC-calibrated nominal for all transformer-ratio length corrections. let corrected_half_wave_m = impedance_corrected_length_m( @@ -491,13 +491,12 @@ pub fn calculate_for_band_with_environment( /// 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). /// -/// CAVEAT: the anchor data is measured only at 7.1 MHz (40 m), and interpolation -/// is on height in *metres*. Radiation resistance actually tracks height in -/// wavelengths, so 10 m AGL is ~0.24 λ at 40 m but ~0.96 λ at 10 m — these values -/// are trustworthy near the 40 m band and are a coarse approximation elsewhere. -/// The model also reports only R (the corpus reactance is not applied). A -/// height-in-λ reparameterisation is tracked as future work once multi-frequency -/// NEC sweep data is committed. +/// 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). /// /// Reference data (7.1 MHz, 2 mm wire, 51 segments, bare-wire ~0.95 end-effect cut): /// free space: R ≈ 62.94 Ω @@ -509,13 +508,55 @@ pub fn calculate_for_band_with_environment( /// /// 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. -pub fn nec_calibrated_dipole_r(antenna_height_m: f64, ground_class: GroundClass) -> f64 { - // Anchor table: resistance at each height with "good" ground (from NEC corpus). - const H7_GOOD: f64 = 73.03; // 7 m AGL, good soil - const H10_GOOD: f64 = 52.84; // 10 m AGL, good soil - const H12_GOOD: f64 = 45.56; // 12 m AGL, good soil +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 + ]; + + let h_over_lambda = if freq_mhz > 0.0 { + antenna_height_m * freq_mhz / 299.792_458 + } else { + 10.0 / LAMBDA_CAL_M + }; - // Ground-class correction deltas at 10 m AGL (NEC corpus deltas relative to "good"): + // Piecewise-linear interpolation on h/λ; hold the end anchors beyond the range. + let h_good = 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 + } else { + anchors + .windows(2) + .find(|w| h_over_lambda >= w[0].0 && h_over_lambda <= w[1].0) + .map(|w| { + let ((x0, y0), (x1, y1)) = (w[0], w[1]); + y0 + (h_over_lambda - x0) / (x1 - x0) * (y1 - y0) + }) + .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, @@ -523,19 +564,6 @@ pub fn nec_calibrated_dipole_r(antenna_height_m: f64, ground_class: GroundClass) GroundClass::Poor => 3.54, }; - // Interpolate the height-dependent "good" baseline. - let h_good = if antenna_height_m <= 7.0 { - H7_GOOD - } else if antenna_height_m >= 12.0 { - H12_GOOD - } else if antenna_height_m <= 10.0 { - let t = (antenna_height_m - 7.0) / (10.0 - 7.0); - H7_GOOD + t * (H10_GOOD - H7_GOOD) - } else { - let t = (antenna_height_m - 10.0) / (12.0 - 10.0); - H10_GOOD + t * (H12_GOOD - H10_GOOD) - }; - (h_good + ground_delta).clamp(30.0, 90.0) } @@ -1787,6 +1815,36 @@ mod tests { assert!(!pts_thin.is_empty() && !pts_thick.is_empty()); } + #[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. + 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})"); + + // High band / high antenna relaxes to the free-space asymptote (~62.94 Ω). + let r_10m_band = nec_calibrated_dipole_r(10.0, 28.5, GroundClass::Good); + assert!( + (r_10m_band - 62.94).abs() < 0.5, + "R should approach free space ({r_10m_band})" + ); + + // Ground-class ordering preserved at a fixed height/frequency. + assert!( + nec_calibrated_dipole_r(10.0, 7.1, GroundClass::Poor) + > nec_calibrated_dipole_r(10.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); + } + // ── Invariant / property sweeps ─────────────────────────────────────────── /// Resonant lengths must decrease strictly as frequency rises (L ∝ 1/f).