diff --git a/src/app/mod.rs b/src/app/mod.rs index 7f06afd..401f57d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, @@ -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 { + 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 { + 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. @@ -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()); @@ -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); diff --git a/src/calculations.rs b/src/calculations.rs index a224474..f803286 100644 --- a/src/calculations.rs +++ b/src/calculations.rs @@ -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]