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
89 changes: 87 additions & 2 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,9 @@ pub fn results_display_document(results: &AppResults) -> ResultsDisplayDocument
if let Some(ref mw) = mismatch {
warning_lines.push(mw.message());
}
if let Some(cw) = compromise_unreachable_bands_warning(results) {
warning_lines.push(cw);
}
ResultsDisplayDocument {
overview_heading: overview.heading,
overview_header_lines: overview.header_lines,
Expand All @@ -1296,6 +1299,48 @@ pub fn results_display_document(results: &AppResults) -> ResultsDisplayDocument
}
}

/// Bands whose resonances all fall outside the search window, so the resonant-
/// compromise optimizer silently excludes them from its objective. Empty in any
/// non-resonant mode. Pure function.
pub fn compromise_unreachable_bands(results: &AppResults) -> Vec<String> {
if results.config.mode != CalcMode::Resonant {
return Vec::new();
}
let min_m = results.config.wire_min_m;
let max_m = results.config.wire_max_m;
results
.calculations
.iter()
.filter(|c| {
crate::calculations::band_resonant_points_m(
c.resonant_quarter_wave_m,
min_m,
max_m,
crate::calculations::IN_WINDOW_PAD_M,
)
.is_empty()
})
.map(|c| c.band_name.clone())
.collect()
}

/// One-line warning naming the bands the compromise recommendation ignores
/// because they have no resonance in the search window, or `None` if all
/// selected bands are reachable.
pub fn compromise_unreachable_bands_warning(results: &AppResults) -> Option<String> {
let bands = compromise_unreachable_bands(results);
if bands.is_empty() {
return None;
}
Some(format!(
"Compromise ignores {} — no resonance in the {:.0}-{:.0} m search window; widen the window to include {}.",
bands.join(", "),
results.config.wire_min_m,
results.config.wire_max_m,
if bands.len() == 1 { "it" } else { "them" },
))
}

/// Return per-band skip details for all bands excluded from this run.
///
/// Pure function; performs no I/O.
Expand Down Expand Up @@ -3736,6 +3781,44 @@ mod tests {
assert!(doc.warning_lines.is_empty());
}

#[test]
fn compromise_warns_about_bands_with_no_in_window_resonance() {
// 160m (index 0): resonant quarter-wave ~37.5 m, outside the default
// 8-35 m window; 40m (index 3) is reachable.
let config = AppConfig {
mode: CalcMode::Resonant,
band_indices: vec![1, 4],
..AppConfig::default()
};
let results = run_calculation(config);

let unreachable = compromise_unreachable_bands(&results);
assert!(
unreachable.iter().any(|b| b.contains("160")),
"160m should be flagged as unreachable, got {unreachable:?}"
);
let warn = compromise_unreachable_bands_warning(&results).expect("a warning is expected");
assert!(warn.contains("Compromise ignores"));
assert!(results_display_document(&results)
.warning_lines
.iter()
.any(|l| l.contains("Compromise ignores")));

// Default selection (all bands resonate in-window) → no such warning.
let ok = run_calculation(AppConfig::default());
assert!(compromise_unreachable_bands_warning(&ok).is_none());

// Non-resonant mode never emits it.
let nr = run_calculation(AppConfig {
mode: CalcMode::NonResonant,
band_indices: vec![1, 4],
wire_min_m: 8.0,
wire_max_m: 35.0,
..AppConfig::default()
});
assert!(compromise_unreachable_bands(&nr).is_empty());
}

#[test]
fn results_display_document_includes_skipped_band_warning_lines() {
let mut results = run_calculation(AppConfig::default());
Expand Down Expand Up @@ -4148,8 +4231,10 @@ mod tests {
#[test]
fn results_display_document_skipped_band_details_populated_when_bands_skipped() {
let mut config = AppConfig::default();
// Band index 999 does not exist in any region — will be skipped
config.band_indices = vec![1, 999];
// idx 4 = 40m (resonates in the default window); index 999 does not exist
// in any region and is skipped. 40m avoids the compromise unreachable-band
// warning so this test isolates the skipped-band warning.
config.band_indices = vec![4, 999];
let results = run_calculation(config);
let doc = results_display_document(&results);

Expand Down
131 changes: 131 additions & 0 deletions src/calculations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1787,6 +1787,137 @@ mod tests {
assert!(!pts_thin.is_empty() && !pts_thick.is_empty());
}

// ── Invariant / property sweeps ───────────────────────────────────────────

/// Resonant lengths must decrease strictly as frequency rises (L ∝ 1/f).
#[test]
fn invariant_resonant_length_strictly_decreases_with_frequency() {
let freqs = [1.8, 3.65, 5.35, 7.1, 10.1, 14.175, 18.1, 21.2, 24.9, 28.5];
let mut prev_half = f64::INFINITY;
let mut prev_quarter = f64::INFINITY;
for f in freqs {
let band = band_at("sweep", f);
let c = calculate_for_band_with_velocity(
&band,
1.0,
TransformerRatio::R1To1,
10.0,
GroundClass::Average,
);
assert!(
c.half_wave_m < prev_half,
"half-wave not strictly decreasing at {f} MHz"
);
assert!(
c.quarter_wave_m < prev_quarter,
"quarter-wave not strictly decreasing at {f} MHz"
);
assert!(c.resonant_quarter_wave_m > 0.0);
prev_half = c.half_wave_m;
prev_quarter = c.quarter_wave_m;
}
}

/// An OCFD split must partition the whole wire (ratios/legs sum) and stay
/// inside the searched short-leg band [0.20, 0.45] (long leg [0.55, 0.80]).
#[test]
fn invariant_ocfd_split_partitions_and_stays_in_bounds() {
let bands = [
band_at("40m", 7.1),
band_at("20m", 14.175),
band_at("10m", 28.5),
];
let calcs: Vec<_> = bands
.iter()
.map(|b| {
calculate_for_band_with_velocity(
b,
1.0,
TransformerRatio::R1To1,
10.0,
GroundClass::Average,
)
})
.collect();
for total in [12.0, 18.0, 20.1, 27.0, 40.2] {
let rec = optimize_ocfd_split_for_length(&calcs, total).expect("a split should exist");
assert!(
(rec.short_ratio + rec.long_ratio - 1.0).abs() < 1e-9,
"ratios do not sum to 1 at total {total}"
);
assert!(
(0.20..=0.45).contains(&rec.short_ratio),
"short ratio {} out of searched range",
rec.short_ratio
);
assert!(
(0.55..=0.80).contains(&rec.long_ratio),
"long ratio {} out of searched range",
rec.long_ratio
);
assert!(
(rec.short_leg_m + rec.long_leg_m - total).abs() < 1e-9,
"legs do not sum to the total wire at {total}"
);
assert!(rec.short_leg_m <= rec.long_leg_m + 1e-9);
}
}

/// Every non-resonant window optimum must be a local maximum of resonance
/// clearance: its distance to the nearest avoid-point is ≥ that of its
/// step-neighbours. This is the defining property of the optimizer's output.
#[test]
fn invariant_non_resonant_optima_are_local_clearance_maxima() {
let bands = [band_at("40m", 7.1), band_at("20m", 14.175)];
let calcs: Vec<_> = bands
.iter()
.map(|b| {
calculate_for_band_with_velocity(
b,
1.0,
TransformerRatio::R1To1,
10.0,
GroundClass::Average,
)
})
.collect();
let config = NonResonantSearchConfig {
min_len_m: 8.0,
max_len_m: 35.0,
step_m: 0.1,
preferred_center_m: 21.5,
};
let points =
build_non_resonant_resonance_points(&calcs, config.min_len_m, config.max_len_m);
let nearest = |len: f64| {
points
.iter()
.map(|r| (len - r).abs())
.fold(f64::INFINITY, f64::min)
};

let optima = calculate_non_resonant_window_optima(&calcs, 1.0, config);
assert!(!optima.is_empty());
for o in &optima {
let here = nearest(o.length_m);
let step = config.step_m;
if o.length_m - step >= config.min_len_m - 1e-9 {
assert!(
here + 1e-9 >= nearest(o.length_m - step),
"optimum {} is not a local max on the left",
o.length_m
);
}
if o.length_m + step <= config.max_len_m + 1e-9 {
assert!(
here + 1e-9 >= nearest(o.length_m + step),
"optimum {} is not a local max on the right",
o.length_m
);
}
}
}

// --- GroundClass ---

#[test]
Expand Down
Loading