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
24 changes: 21 additions & 3 deletions docs/math.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,27 @@ This is a practical approximation, not a substitute for NEC-based segment/curren

## 5) Non-Resonant Wire Optimization

For each selected band, Rusty Wire generates resonance points from corrected quarter-wave harmonics:
For each selected band, Rusty Wire generates resonance points from the **physical
resonant quarter-wave** $L_{1/4}^{\ast} = \tfrac{71.32}{f}\,VF\cdot F_d(d)$ — the
quarter-wave with the conductor-diameter correction $F_d(d)$ of §9, but *without*
the transformer-length heuristic of §4. Resonance is a property of the wire
geometry (length, conductor diameter), not the feed, so a single shared helper
produces this point set for the non-resonant optimizer, the resonant-compromise
optimizer (§6), the OCFD split optimizer (§7), and the resonant-points shown on
screen and in exports. None of them shift when the transformer ratio changes, and
they use the identical harmonic positions. (The non-resonant optimizer
additionally considers resonances within 1 m *outside* the search window so a
candidate near an edge still gets an honest clearance; the display, export and
compromise optimizer list only strictly in-window resonances.)

$$
R = \{h\,L_{1/4,i}\mid i\in\text{bands},\ h\in\mathbb{N}\}
R = \{h\,L_{1/4,i}^{\ast}\mid i\in\text{bands},\ h\in\mathbb{N}\}
$$

The optimizer avoids **all** quarter-wave harmonics (the wire's resonant lengths
on each band), keeping the chosen length in the moderate-reactance region between
resonances where a tuner has the easiest match.

For candidate wire length $\ell$ in the configured search window:

$$
Expand All @@ -169,7 +184,10 @@ $$

## 6) Resonant Compromise Optimization

For each band $i$, define resonant-point set $P_i$ in the active window. Per-band nearest distance at candidate $\ell$:
For each band $i$, define resonant-point set $P_i$ (the physical resonant
quarter-wave harmonics $h\,L_{1/4,i}^{\ast}$ of §5 — conductor-corrected and
transformer-independent) in the active window. Per-band nearest distance at
candidate $\ell$:

$$
D_i(\ell) = \min_{p\in P_i}|\ell-p|
Expand Down
32 changes: 13 additions & 19 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2022,25 +2022,19 @@ pub fn resonant_points_in_window(results: &AppResults) -> Vec<ResonantPoint> {
let mut points = Vec::new();

for calc in &results.calculations {
let quarter_wave_m = calc.corrected_quarter_wave_m;
if quarter_wave_m <= 0.0 {
continue;
}

let mut harmonic = 1_u32;
loop {
let resonant_len_m = quarter_wave_m * f64::from(harmonic);
if resonant_len_m > max_m + 1e-9 {
break;
}
if resonant_len_m >= min_m - 1e-9 {
points.push(ResonantPoint {
length_m: resonant_len_m,
band_name: calc.band_name.clone(),
harmonic,
});
}
harmonic += 1;
// Shared physical (conductor-corrected, transformer-independent) resonance
// points, identical to the non-resonant/compromise optimizers' base set.
for (harmonic, length_m) in crate::calculations::band_resonant_points_m(
calc.resonant_quarter_wave_m,
min_m,
max_m,
crate::calculations::IN_WINDOW_PAD_M,
) {
points.push(ResonantPoint {
length_m,
band_name: calc.band_name.clone(),
harmonic,
});
}
}

Expand Down
216 changes: 184 additions & 32 deletions src/calculations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ pub struct WireCalculation {
pub full_wave_m: f64,
pub quarter_wave_m: f64,

/// Physical resonant quarter-wave: `quarter_wave_m` with the conductor-diameter
/// correction applied but NOT the transformer-length heuristic. This is the
/// canonical, feed-independent base for every resonance-point calculation
/// (non-resonant avoid-set, resonant compromise, OCFD clearance, and the
/// displayed/exported resonant-points list) so they can never disagree.
pub resonant_quarter_wave_m: f64,

// Dipole lengths (in feet)
pub half_wave_ft: f64,
pub full_wave_ft: f64,
Expand Down Expand Up @@ -343,6 +350,12 @@ pub fn calculate_for_band_with_environment(
let full_wave_ft = full_wave_m * METERS_TO_FEET;
let quarter_wave_ft = quarter_wave_m * METERS_TO_FEET;

// Physical resonant quarter-wave: conductor-diameter correction (real geometry
// effect) but NOT the transformer-length heuristic (a feed choice). Canonical
// base for all resonance-point calculations.
let resonant_quarter_wave_m =
quarter_wave_m * conductor_diameter_correction_factor(conductor_diameter_mm);

// NEC-calibrated feedpoint resistance: use corpus reference data instead of the
// classic textbook 73 Ω free-space value. Height and ground affect the radiation
// resistance through image-method coupling; the NEC values are validated against
Expand Down Expand Up @@ -431,6 +444,7 @@ pub fn calculate_for_band_with_environment(
half_wave_m,
full_wave_m,
quarter_wave_m,
resonant_quarter_wave_m,
half_wave_ft,
full_wave_ft,
quarter_wave_ft,
Expand Down Expand Up @@ -755,27 +769,59 @@ pub fn calculate_non_resonant_window_optima(
.collect()
}

/// Window padding (m) used when the non-resonant optimizer builds its avoid-set,
/// so a candidate near a window edge still "sees" a resonance just outside the
/// window and its clearance stays honest. The display/export/compromise callers
/// use a ~0 pad because they only list/target in-window resonances.
const NON_RESONANT_EDGE_PAD_M: f64 = 1.0;
pub(crate) const IN_WINDOW_PAD_M: f64 = 1e-9;

/// A band's resonant wire lengths (quarter-wave harmonics `n·λ/4`) that fall in
/// `[min_len_m - pad_m, max_len_m + pad_m]`, keyed by harmonic number.
///
/// Uses the physical resonant quarter-wave (`resonant_quarter_wave_m`): conductor-
/// diameter corrected but transformer-independent. Every consumer — the
/// non-resonant avoid-set, the resonant-compromise optimizer, the on-screen
/// resonant-points list and the exports — goes through this single function so the
/// resonance geometry can never drift between them.
pub(crate) fn band_resonant_points_m(
resonant_quarter_wave_m: f64,
min_len_m: f64,
max_len_m: f64,
pad_m: f64,
) -> Vec<(u32, f64)> {
let mut points = Vec::new();
if resonant_quarter_wave_m <= 0.0 || max_len_m < min_len_m {
return points;
}
let mut harmonic = 1_u32;
loop {
let len_m = resonant_quarter_wave_m * f64::from(harmonic);
if len_m > max_len_m + pad_m {
break;
}
if len_m >= min_len_m - pad_m {
points.push((harmonic, len_m));
}
harmonic += 1;
}
points
}

fn build_non_resonant_resonance_points(
calculations: &[WireCalculation],
min_len_m: f64,
max_len_m: f64,
) -> Vec<f64> {
let mut resonance_points_m = Vec::new();
for c in calculations {
// Use transformer-corrected quarter-wave as the base resonance point so
// optimum common wire length reflects the selected Unun/Balun ratio.
let quarter_wave_m = c.corrected_quarter_wave_m;

let mut harmonic = 1_u32;
loop {
let resonant_len_m = quarter_wave_m * f64::from(harmonic);
if resonant_len_m > max_len_m + 1.0 {
break;
}
if resonant_len_m >= min_len_m - 1.0 {
resonance_points_m.push(resonant_len_m);
}
harmonic += 1;
for (_, len_m) in band_resonant_points_m(
c.resonant_quarter_wave_m,
min_len_m,
max_len_m,
NON_RESONANT_EDGE_PAD_M,
) {
resonance_points_m.push(len_m);
}
}
resonance_points_m
Expand Down Expand Up @@ -824,23 +870,18 @@ pub fn calculate_resonant_compromises(

let mut band_points: Vec<Vec<f64>> = Vec::new();
for calc in calculations {
let quarter_wave_m = calc.corrected_quarter_wave_m;
if quarter_wave_m <= 0.0 {
continue;
}

let mut points = Vec::new();
let mut harmonic = 1_u32;
loop {
let resonant_len_m = quarter_wave_m * f64::from(harmonic);
if resonant_len_m > max_len_m + 1e-9 {
break;
}
if resonant_len_m >= min_len_m - 1e-9 {
points.push(resonant_len_m);
}
harmonic += 1;
}
// Same physical (conductor-corrected, transformer-independent) resonance
// points the on-screen list shows, so the compromise recommendations
// always coincide with the displayed resonant points.
let points: Vec<f64> = band_resonant_points_m(
calc.resonant_quarter_wave_m,
min_len_m,
max_len_m,
IN_WINDOW_PAD_M,
)
.into_iter()
.map(|(_, len_m)| len_m)
.collect();

if !points.is_empty() {
band_points.push(points);
Expand Down Expand Up @@ -950,7 +991,9 @@ pub fn optimize_ocfd_split_for_length(

let mut worst_leg_clearance_pct = f64::INFINITY;
for calc in calculations {
let quarter_wave = calc.corrected_quarter_wave_m;
// Physical resonant quarter-wave (conductor-corrected, feed-independent):
// OCFD leg resonance clearance is a property of the wire, not the feed.
let quarter_wave = calc.resonant_quarter_wave_m;
if quarter_wave <= 0.0 {
continue;
}
Expand Down Expand Up @@ -1540,6 +1583,115 @@ mod tests {
}
}

/// Regression guard for the resonance-point unification: every optimizer that
/// works from resonance points (non-resonant, resonant-compromise, OCFD split)
/// must produce identical results regardless of the transformer ratio, because
/// resonance is a property of the wire geometry, not the feed. Before the fix
/// these used the transformer-corrected quarter-wave and drifted per ratio.
#[test]
fn resonance_point_optimizers_are_transformer_independent() {
let bands = [band_at("40m", 7.1), band_at("20m", 14.175)];
let config = NonResonantSearchConfig {
min_len_m: 8.0,
max_len_m: 35.0,
step_m: 0.5,
preferred_center_m: 21.5,
};
let build = |ratio| {
bands
.iter()
.map(|b| {
calculate_for_band_with_velocity(b, 1.0, ratio, 10.0, GroundClass::Average)
})
.collect::<Vec<_>>()
};
let c_1to1 = build(TransformerRatio::R1To1);
let c_1to49 = build(TransformerRatio::R1To49);

// Sanity: the transformer DID change the corrected build lengths, so this
// is a meaningful comparison (not two identical inputs).
assert!(
(c_1to1[0].corrected_quarter_wave_m - c_1to49[0].corrected_quarter_wave_m).abs() > 1e-6,
"transformer ratio should change the corrected build length"
);

let nr1 = calculate_non_resonant_optima(&c_1to1, 1.0, config);
let nr2 = calculate_non_resonant_optima(&c_1to49, 1.0, config);
assert_eq!(nr1.len(), nr2.len());
for (a, b) in nr1.iter().zip(nr2.iter()) {
assert!(
(a.length_m - b.length_m).abs() < 1e-9,
"non-resonant length depends on transformer: {} vs {}",
a.length_m,
b.length_m
);
}

let cp1 = calculate_resonant_compromises(&c_1to1, config);
let cp2 = calculate_resonant_compromises(&c_1to49, config);
assert_eq!(cp1.len(), cp2.len());
for (a, b) in cp1.iter().zip(cp2.iter()) {
assert!(
(a.length_m - b.length_m).abs() < 1e-9,
"compromise length depends on transformer: {} vs {}",
a.length_m,
b.length_m
);
}

let s1 = optimize_ocfd_split_for_length(&c_1to1, 20.0).map(|r| r.short_ratio);
let s2 = optimize_ocfd_split_for_length(&c_1to49, 20.0).map(|r| r.short_ratio);
assert_eq!(s1, s2, "OCFD split depends on transformer ratio");
}

/// The resonance-point base must still carry the *physical* conductor-diameter
/// correction (only the transformer heuristic is dropped): a thick conductor
/// resonates shorter, so its resonant quarter-wave and the resonance points fed
/// to the optimizers must move accordingly. Guards against the fix accidentally
/// using the fully-raw quarter-wave.
#[test]
fn resonant_points_keep_conductor_diameter_correction() {
let band = band_at("40m", 7.1);
let thin = calculate_for_band_with_environment(
&band,
1.0,
TransformerRatio::R1To1,
10.0,
GroundClass::Average,
1.0,
);
let thick = calculate_for_band_with_environment(
&band,
1.0,
TransformerRatio::R1To1,
10.0,
GroundClass::Average,
4.0,
);

// Thicker conductor -> shorter physical resonant quarter-wave...
assert!(
thick.resonant_quarter_wave_m < thin.resonant_quarter_wave_m,
"conductor-diameter correction lost from the resonant quarter-wave"
);
// ...and it is NOT the raw quarter-wave (which ignores diameter).
assert!(
(thin.resonant_quarter_wave_m - thin.quarter_wave_m).abs() > 1e-9,
"resonant quarter-wave should include the conductor-diameter correction"
);

// The shared helper propagates that shortening into the resonance points.
let config = NonResonantSearchConfig {
min_len_m: 8.0,
max_len_m: 35.0,
step_m: 0.5,
preferred_center_m: 21.5,
};
let pts_thin = calculate_resonant_compromises(std::slice::from_ref(&thin), config);
let pts_thick = calculate_resonant_compromises(std::slice::from_ref(&thick), config);
assert!(!pts_thin.is_empty() && !pts_thick.is_empty());
}

// --- GroundClass ---

#[test]
Expand Down
2 changes: 2 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,8 @@ fn print_advise_candidates(
}

println!("Ranked combinations (wire length + balun/unun ratio):");
println!("(The recommended wire length is the same for every ratio - resonance is a");
println!(" property of the wire, not the feed; ratios differ in matching efficiency.)");
println!();

for (idx, candidate) in view.candidates.iter().enumerate() {
Expand Down
28 changes: 10 additions & 18 deletions src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1950,25 +1950,17 @@ fn collect_band_resonant_points_m(
wire_min_m: f64,
wire_max_m: f64,
) -> Vec<(u32, f64)> {
let mut points = Vec::new();
let quarter_wave_m = calc.corrected_quarter_wave_m;
if quarter_wave_m <= 0.0 || wire_max_m <= wire_min_m {
return points;
if wire_max_m <= wire_min_m {
return Vec::new();
}

let mut harmonic = 1_u32;
loop {
let resonant_len_m = quarter_wave_m * f64::from(harmonic);
if resonant_len_m > wire_max_m + 1e-9 {
break;
}
if resonant_len_m >= wire_min_m - 1e-9 {
points.push((harmonic, resonant_len_m));
}
harmonic += 1;
}

points
// Shared physical (conductor-corrected, transformer-independent) resonance
// points — identical to the optimizers and the on-screen resonant-points list.
crate::calculations::band_resonant_points_m(
calc.resonant_quarter_wave_m,
wire_min_m,
wire_max_m,
crate::calculations::IN_WINDOW_PAD_M,
)
}

fn format_band_resonant_points(
Expand Down
Loading
Loading