diff --git a/docs/math.md b/docs/math.md index 8f1964c..914b9ba 100644 --- a/docs/math.md +++ b/docs/math.md @@ -151,18 +151,30 @@ 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.) +they use the identical harmonic positions. (The display, export and compromise +optimizer list only strictly in-window resonances; the non-resonant optimizer +pads its avoid-set outward — see below — so near-edge clearance stays honest.) + +Each harmonic $h$ has an **impedance class** set by its parity, because the feed +sees a current maximum at odd multiples and a voltage maximum at even ones: + +- **low-Z** ($h$ odd: $\lambda/4, 3\lambda/4, \dots$) — current-fed, ~35–50 Ω, + near 50 Ω and easy for a tuner or even a direct feed; +- **high-Z** ($h$ even $=$ half-wave multiples $\lambda/2, \lambda, \dots$) — + voltage-fed, hundreds to thousands of ohms, genuinely hard to match. + +The non-resonant optimizer avoids only the **high-Z** set — the lengths a tuner +struggles with — while the desirable low-Z lengths are left available: $$ -R = \{h\,L_{1/4,i}^{\ast}\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},\ h\ \text{even}\} $$ -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. +The on-screen and exported resonant-points lists show **every** resonance tagged +`low-Z`/`high-Z`, so the recommended length (which may sit near a low-Z point) is +always reconcilable against the listed points. To keep near-edge clearance +honest, the optimizer's avoid-set for each band is padded by one half-wave so the +nearest high-Z point just outside the window is still counted. For candidate wire length $\ell$ in the configured search window: diff --git a/src/app/advise.rs b/src/app/advise.rs index df9f767..63a0c6b 100644 --- a/src/app/advise.rs +++ b/src/app/advise.rs @@ -230,12 +230,12 @@ fn generate_tradeoff_note( if swr_at_target < 1.15 { if min_resonance_clearance_pct >= 15.0 { return format!( - "Best match: SWR ≈ {:.1}:1 into {:.0} Ω, wide resonance clearance ({:.0}%).", + "Best match: SWR ≈ {:.1}:1 into {:.0} Ω, wide high-Z clearance ({:.0}%).", swr_at_target, target_z, min_resonance_clearance_pct ); } return format!( - "Best match: SWR ≈ {:.1}:1 into {:.0} Ω; check resonance clearance ({:.0}%).", + "Best match: SWR ≈ {:.1}:1 into {:.0} Ω; check high-Z clearance ({:.0}%).", swr_at_target, target_z, min_resonance_clearance_pct ); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 9795d32..7f06afd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -537,6 +537,9 @@ pub struct ResonantPoint { pub length_m: f64, pub band_name: String, pub harmonic: u32, + /// Whether this resonance is a low-Z (easy match) or high-Z (hard to match) + /// point. The non-resonant optimizer avoids only the high-Z points. + pub impedance_class: crate::calculations::ImpedanceClass, } #[derive(Debug, Clone)] @@ -1370,15 +1373,15 @@ pub fn non_resonant_recommendation_view(results: &AppResults) -> NonResonantReco let recommended_line = rec.map(|r| match units { UnitSystem::Metric => format!( - " {:.2} m, resonance clearance: {:.2}%", + " {:.2} m, high-Z clearance: {:.2}%", r.length_m, r.min_resonance_clearance_pct ), UnitSystem::Imperial => format!( - " {:.2} ft, resonance clearance: {:.2}%", + " {:.2} ft, high-Z clearance: {:.2}%", r.length_ft, r.min_resonance_clearance_pct ), UnitSystem::Both => format!( - " {:.2} m ({:.2} ft), resonance clearance: {:.2}%", + " {:.2} m ({:.2} ft), high-Z clearance: {:.2}%", r.length_m, r.length_ft, r.min_resonance_clearance_pct ), }); @@ -1500,6 +1503,10 @@ pub fn non_resonant_recommendation_display_lines(results: &AppResults) -> Vec Vec { length_m, band_name: calc.band_name.clone(), harmonic, + impedance_class: crate::calculations::ImpedanceClass::from_harmonic(harmonic), }); } } @@ -2063,24 +2071,27 @@ pub fn resonant_points_view(results: &AppResults) -> ResonantPointsView { let point_lines = points .into_iter() - .map(|point| match results.config.units { - UnitSystem::Metric => format!( - " - {}: {}x quarter-wave = {:.2} m", - point.band_name, point.harmonic, point.length_m - ), - UnitSystem::Imperial => format!( - " - {}: {}x quarter-wave = {:.2} ft", - point.band_name, - point.harmonic, - point.length_m / FEET_TO_METERS - ), - UnitSystem::Both => format!( - " - {}: {}x quarter-wave = {:.2} m ({:.2} ft)", - point.band_name, - point.harmonic, - point.length_m, - point.length_m / FEET_TO_METERS - ), + .map(|point| { + let z = point.impedance_class.as_label(); + match results.config.units { + UnitSystem::Metric => format!( + " - {}: {}x quarter-wave = {:.2} m [{z}]", + point.band_name, point.harmonic, point.length_m + ), + UnitSystem::Imperial => format!( + " - {}: {}x quarter-wave = {:.2} ft [{z}]", + point.band_name, + point.harmonic, + point.length_m / FEET_TO_METERS + ), + UnitSystem::Both => format!( + " - {}: {}x quarter-wave = {:.2} m ({:.2} ft) [{z}]", + point.band_name, + point.harmonic, + point.length_m, + point.length_m / FEET_TO_METERS + ), + } }) .collect(); @@ -2465,6 +2476,13 @@ pub fn resonant_points_display_lines(results: &AppResults) -> Vec { lines.push(view.empty_message.to_string()); } else { lines.extend(view.point_lines); + lines.push( + " [low-Z] = odd quarter-wave, current-fed (~35-50 ohm, easy to match)".to_string(), + ); + lines.push( + " [high-Z] = half-wave multiple, voltage-fed (high ohm); non-resonant mode avoids these" + .to_string(), + ); } lines @@ -3954,6 +3972,42 @@ mod tests { assert!(points .windows(2) .all(|pair| pair[0].length_m <= pair[1].length_m)); + + // Each point is classified by harmonic parity: odd -> low-Z, even -> high-Z. + for p in &points { + let expected = if p.harmonic.is_multiple_of(2) { + crate::calculations::ImpedanceClass::High + } else { + crate::calculations::ImpedanceClass::Low + }; + assert_eq!(p.impedance_class, expected); + } + } + + #[test] + fn resonant_points_display_lines_tag_impedance_class_and_legend() { + let config = AppConfig { + mode: CalcMode::Resonant, + band_indices: vec![3, 5], // 40m, 20m -> both low-Z and high-Z points in window + ..AppConfig::default() + }; + let results = run_calculation(config); + let lines = resonant_points_display_lines(&results); + let blob = lines.join("\n"); + + assert!( + blob.contains("[low-Z]"), + "expected a low-Z tagged point:\n{blob}" + ); + assert!( + blob.contains("[high-Z]"), + "expected a high-Z tagged point:\n{blob}" + ); + // Legend present and explains the non-resonant avoidance policy. + assert!( + blob.contains("non-resonant mode avoids these"), + "expected the impedance-class legend:\n{blob}" + ); } #[test] diff --git a/src/calculations.rs b/src/calculations.rs index bb1ac1c..a224474 100644 --- a/src/calculations.rs +++ b/src/calculations.rs @@ -769,13 +769,47 @@ 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; +/// Padding (m) for the display/export/compromise callers, which list or target +/// only strictly in-window resonances. (The non-resonant optimizer pads by one +/// half-wave per band instead — see `build_non_resonant_resonance_points`.) pub(crate) const IN_WINDOW_PAD_M: f64 = 1e-9; +/// Impedance class of a resonance point, from the quarter-wave harmonic number. +/// +/// A wire is resonant (X ≈ 0) at every quarter-wave multiple. The feedpoint sees +/// a current maximum at **odd** multiples (λ/4, 3λ/4, …) → low impedance +/// (~35–50 Ω, near 50 Ω and easy for a tuner), and a voltage maximum at **even** +/// multiples (= half-wave multiples λ/2, λ, …) → high impedance (hundreds to +/// thousands of Ω, the lengths that are genuinely hard to match). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImpedanceClass { + /// Odd quarter-wave multiple — current-fed, low impedance (easy match). + Low, + /// Even quarter-wave multiple (half-wave multiple) — voltage-fed, high impedance. + High, +} + +impl ImpedanceClass { + pub fn from_harmonic(harmonic: u32) -> Self { + if harmonic.is_multiple_of(2) { + ImpedanceClass::High + } else { + ImpedanceClass::Low + } + } + + pub fn as_label(self) -> &'static str { + match self { + ImpedanceClass::Low => "low-Z", + ImpedanceClass::High => "high-Z", + } + } + + pub fn is_high(self) -> bool { + matches!(self, ImpedanceClass::High) + } +} + /// 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. /// @@ -815,13 +849,18 @@ fn build_non_resonant_resonance_points( ) -> Vec { let mut resonance_points_m = Vec::new(); for c in calculations { - 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); + // Avoid only the HIGH-impedance resonances (even quarter-wave harmonics = + // half-wave multiples); the odd-harmonic low-Z lengths (~35-50 Ω) are easy + // to match and need not be avoided. Pad by one half-wave (2 x quarter-wave) + // so the nearest high-Z point just outside each window edge is always + // included, keeping near-edge candidates' clearance honest. + let pad_m = 2.0 * c.resonant_quarter_wave_m; + for (harmonic, len_m) in + band_resonant_points_m(c.resonant_quarter_wave_m, min_len_m, max_len_m, pad_m) + { + if ImpedanceClass::from_harmonic(harmonic).is_high() { + resonance_points_m.push(len_m); + } } } resonance_points_m @@ -1644,6 +1683,62 @@ mod tests { assert_eq!(s1, s2, "OCFD split depends on transformer ratio"); } + #[test] + fn impedance_class_follows_harmonic_parity() { + assert_eq!(ImpedanceClass::from_harmonic(1), ImpedanceClass::Low); + assert_eq!(ImpedanceClass::from_harmonic(2), ImpedanceClass::High); + assert_eq!(ImpedanceClass::from_harmonic(3), ImpedanceClass::Low); + assert_eq!(ImpedanceClass::from_harmonic(4), ImpedanceClass::High); + assert!(ImpedanceClass::High.is_high()); + assert!(!ImpedanceClass::Low.is_high()); + assert_eq!(ImpedanceClass::Low.as_label(), "low-Z"); + assert_eq!(ImpedanceClass::High.as_label(), "high-Z"); + } + + #[test] + fn non_resonant_avoids_only_high_z_resonances() { + // 40 m: resonant quarter-wave ~10.05 m. In an 8-35 m window the high-Z + // (half-wave) resonances are 2x (~20.1 m) and 4x (~40 m, just outside); + // the low-Z odd multiples 1x (~10.05 m) and 3x (~30.2 m) must NOT be avoided. + let band = band_at("40m", 7.1); + let calc = calculate_for_band_with_velocity( + &band, + 1.0, + TransformerRatio::R1To1, + 10.0, + GroundClass::Average, + ); + let q = calc.resonant_quarter_wave_m; + let avoid = build_non_resonant_resonance_points(std::slice::from_ref(&calc), 8.0, 35.0); + + assert!( + avoid.iter().any(|p| (p - 2.0 * q).abs() < 1e-6), + "high-Z half-wave (2x) should be in the avoid-set" + ); + assert!( + !avoid.iter().any(|p| (p - q).abs() < 1e-6), + "low-Z 1x (current-fed, easy match) must not be avoided" + ); + assert!( + !avoid.iter().any(|p| (p - 3.0 * q).abs() < 1e-6), + "low-Z 3x (current-fed, easy match) must not be avoided" + ); + // Every avoided point is an even (half-wave) multiple of the quarter-wave. + for p in &avoid { + let n = (p / q).round() as u32; + assert!( + n.is_multiple_of(2), + "avoided point {p} is not a half-wave multiple" + ); + } + // Edge padding: the first high-Z point just beyond the window (4x ~40 m) is + // included so a candidate near 35 m gets an honest clearance. + assert!( + avoid.iter().any(|p| (p - 4.0 * q).abs() < 1e-6), + "the first high-Z point beyond the window edge should be included" + ); + } + /// 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 diff --git a/src/export.rs b/src/export.rs index d08e8d9..e1e73d0 100644 --- a/src/export.rs +++ b/src/export.rs @@ -1974,6 +1974,11 @@ fn format_band_resonant_points( return "none".to_string(); } + // NOTE: keep the `resonant_points_in_window` CSV/text value format stable + // (`{h}x={len}m`) — it is a documented v1 export-contract field parsed by + // downstream tools. The high-Z/low-Z class is derivable from the harmonic + // parity here, is a first-class field in the JSON export, and is shown tagged + // in the human-readable results view. points .into_iter() .map(|(harmonic, len_m)| match units { @@ -1998,16 +2003,21 @@ fn format_band_resonant_points_json( let items = points .into_iter() - .map(|(harmonic, len_m)| match units { - UnitSystem::Metric => format!("{{\"harmonic\": {harmonic}, \"length_m\": {len_m:.2}}}"), - UnitSystem::Imperial => format!( - "{{\"harmonic\": {harmonic}, \"length_ft\": {:.2}}}", - len_m / 0.3048 - ), - UnitSystem::Both => format!( - "{{\"harmonic\": {harmonic}, \"length_m\": {len_m:.2}, \"length_ft\": {:.2}}}", - len_m / 0.3048 - ), + .map(|(harmonic, len_m)| { + let z = crate::calculations::ImpedanceClass::from_harmonic(harmonic).as_label(); + match units { + UnitSystem::Metric => format!( + "{{\"harmonic\": {harmonic}, \"length_m\": {len_m:.2}, \"impedance_class\": \"{z}\"}}" + ), + UnitSystem::Imperial => format!( + "{{\"harmonic\": {harmonic}, \"length_ft\": {:.2}, \"impedance_class\": \"{z}\"}}", + len_m / 0.3048 + ), + UnitSystem::Both => format!( + "{{\"harmonic\": {harmonic}, \"length_m\": {len_m:.2}, \"length_ft\": {:.2}, \"impedance_class\": \"{z}\"}}", + len_m / 0.3048 + ), + } }) .collect::>() .join(", "); diff --git a/tests/export_format_contract.rs b/tests/export_format_contract.rs index 3724639..c1a2ff9 100644 --- a/tests/export_format_contract.rs +++ b/tests/export_format_contract.rs @@ -261,6 +261,14 @@ fn csv_export_data_row_format_and_precision() { length_str ); } + + // Contract guard: the resonant_points_in_window value keeps its stable + // `{h}x={len}{unit}` form and must NOT carry the human-facing impedance-class + // tag (that lives in the JSON export field and the on-screen view only). + assert!( + !content.contains("(low-Z)") && !content.contains("(high-Z)"), + "CSV resonant_points_in_window must not embed the impedance-class tag:\n{content}" + ); } #[test]