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
32 changes: 30 additions & 2 deletions docs/math.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions src/app/advise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<f64>() / 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<TransformerOptimizerCandidate> = TRANSFORMER_OPTIMIZER_CANDIDATES
.iter()
.copied()
Expand Down
36 changes: 28 additions & 8 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<f64>()
/ 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);
Expand Down Expand Up @@ -1712,16 +1723,22 @@ pub fn trap_dipole_guidance_view(results: &AppResults) -> Option<TrapDipoleGuida
let f_upper = upper.freq_center_mhz;
let f_lower = lower.freq_center_mhz;

// Quarter-wave inner leg (upper-band driven element per side).
// Inner leg: the upper-band element inboard of the trap. Cut to a
// (bare-wire) quarter-wave for the upper band — 71.58/f m ≈ 234.85/f ft,
// the standard quarter-wave design length. See docs/math.md §11.
let inner_leg_m = (71.58 / f_upper) * vf;
// Trap-dipole total leg per side for the lower band (uses TRAP_DIPOLE_COEFF_M/2).
// Total leg (per side) resonant on the lower band: 68.58/f m ≈ 225/f ft.
// This is ~4% shorter than a plain quarter-wave (71.32/f) because the
// trap's inductance electrically lengthens the outer section, so less
// physical wire is needed to reach lower-band resonance.
let total_leg_m = (68.58 / f_lower) * vf;
let outer_section_m = (total_leg_m - inner_leg_m).max(0.0);
let full_span_m = total_leg_m * 2.0;

// Trap resonant frequency = upper-band centre.
// The trap is parallel-resonant at the upper band, isolating the outer
// section there so the inner leg acts as a stand-alone upper-band dipole.
let trap_freq_mhz = f_upper;
// L·C product in μH·pF: L[μH] × C[pF] = 25 330 / f[MHz]²
// Resonance f = 1/(2π√(LC)) ⇒ L[μH]·C[pF] = 1e6/(4π²·f[MHz]²) = 25 330/f².
let lc_product = 25_330.0 / (trap_freq_mhz * trap_freq_mhz);
let cap_values = select_trap_cap_examples(lc_product);
let component_examples: Vec<TrapDipoleComponentExample> = cap_values
Expand Down Expand Up @@ -3149,23 +3166,26 @@ fn assumed_feedpoint_impedance_ohm(
mode: CalcMode,
antenna_model: Option<AntennaModel>,
antenna_height_m: f64,
freq_mhz: f64,
ground_class: GroundClass,
) -> f64 {
match antenna_model {
// Use NEC-calibrated height/ground-aware feedpoint resistance for dipole types.
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,
},
}
Expand Down
112 changes: 85 additions & 27 deletions src/calculations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 Ω
Expand All @@ -509,33 +508,62 @@ 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,
GroundClass::Average => 1.51,
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)
}

Expand Down Expand Up @@ -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).
Expand Down
Loading