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

Expand Down
4 changes: 2 additions & 2 deletions src/app/advise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Expand Down
96 changes: 75 additions & 21 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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
),
});
Expand Down Expand Up @@ -1500,6 +1503,10 @@ pub fn non_resonant_recommendation_display_lines(results: &AppResults) -> Vec<St
};

let mut lines = vec![view.heading.to_string(), view.window_line, rec_line];
lines.push(" (high-Z clearance = distance to the nearest hard-to-match half-wave".to_string());
lines.push(
" resonance; low-Z current-fed resonances are easy to match and allowed)".to_string(),
);

if let Some(heading) = view.equal_optima_heading {
lines.push(heading.to_string());
Expand Down Expand Up @@ -2034,6 +2041,7 @@ pub fn resonant_points_in_window(results: &AppResults) -> Vec<ResonantPoint> {
length_m,
band_name: calc.band_name.clone(),
harmonic,
impedance_class: crate::calculations::ImpedanceClass::from_harmonic(harmonic),
});
}
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -2465,6 +2476,13 @@ pub fn resonant_points_display_lines(results: &AppResults) -> Vec<String> {
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
Expand Down Expand Up @@ -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]
Expand Down
119 changes: 107 additions & 12 deletions src/calculations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -815,13 +849,18 @@ fn build_non_resonant_resonance_points(
) -> Vec<f64> {
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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 20 additions & 10 deletions src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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::<Vec<String>>()
.join(", ");
Expand Down
Loading
Loading