From 687726771b53b2b93a36fe7f7e34f35615e7a0d5 Mon Sep 17 00:00:00 2001 From: "Simon Keimer (DC0SK)" Date: Wed, 8 Jul 2026 22:23:32 +0200 Subject: [PATCH] refactor(app): extract sweeps and band-selection into submodules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/app/mod.rs had grown to ~4.8k lines mixing the config/orchestration core with self-contained display and parsing layers (project-review finding). Extract two cohesive, pure sub-layers with no behaviour change: - src/app/sweeps.rs — velocity/transformer sweep view models + formatting; - src/app/band_select.rs — band token/range parsing, labels, listing views. Both are re-exported (`pub use`) so the public `app::*` paths are unchanged. mod.rs drops from 4777 to 4283 lines (-10%). Further display-layer decomposition remains a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/band_select.rs | 162 +++++++++++++ src/app/mod.rs | 502 +---------------------------------------- src/app/sweeps.rs | 344 ++++++++++++++++++++++++++++ 3 files changed, 510 insertions(+), 498 deletions(-) create mode 100644 src/app/band_select.rs create mode 100644 src/app/sweeps.rs diff --git a/src/app/band_select.rs b/src/app/band_select.rs new file mode 100644 index 0000000..f113b8f --- /dev/null +++ b/src/app/band_select.rs @@ -0,0 +1,162 @@ +//! Band-selection parsing, labelling, and listing views. +//! +//! User-facing band token/range parsing and the band-listing view model, +//! extracted from `app/mod.rs`. + +use super::*; + +pub fn parse_band_selection(selection: &str, region: ITURegion) -> Result, AppError> { + let mut parsed = Vec::new(); + let mut seen = HashSet::new(); + + for token in selection.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + + if let Some((start, end)) = token.split_once('-') { + let start_idx = parse_single_band_token(start.trim(), region)?; + let end_idx = parse_single_band_token(end.trim(), region)?; + + let ordered = ordered_band_indices_for_region(region); + let start_pos = ordered + .iter() + .position(|idx| *idx == start_idx) + .ok_or_else(|| { + AppError::InvalidBandSelection(format!( + "unknown range start '{}'.", + start.trim() + )) + })?; + let end_pos = ordered + .iter() + .position(|idx| *idx == end_idx) + .ok_or_else(|| { + AppError::InvalidBandSelection(format!("unknown range end '{}'.", end.trim())) + })?; + + if start_pos <= end_pos { + for idx in &ordered[start_pos..=end_pos] { + if seen.insert(*idx) { + parsed.push(*idx); + } + } + } else { + for idx in ordered[end_pos..=start_pos].iter().rev() { + if seen.insert(*idx) { + parsed.push(*idx); + } + } + } + + continue; + } + + let idx = parse_single_band_token(token, region)?; + if seen.insert(idx) { + parsed.push(idx); + } + } + + if parsed.is_empty() { + return Err(AppError::EmptyBandSelection); + } + + Ok(parsed) +} + +pub fn parse_single_band_token(token: &str, region: ITURegion) -> Result { + let token = token.trim(); + if token.is_empty() { + return Err(AppError::InvalidBandSelection( + "empty band token".to_string(), + )); + } + + let aliases = band_alias_to_index(region); + let key = token.to_ascii_lowercase(); + aliases + .get(&key) + .copied() + .ok_or_else(|| AppError::InvalidBandSelection(format!("unknown band '{token}'."))) +} + +pub fn band_label_for_index(index: usize, region: ITURegion) -> String { + let zero_based = match index.checked_sub(1) { + Some(v) => v, + None => return index.to_string(), + }; + + for (idx, band) in crate::bands::get_bands_for_region(region) { + if idx == zero_based { + return band + .name + .split_whitespace() + .next() + .unwrap_or(band.name) + .to_string(); + } + } + + index.to_string() +} + +fn ordered_band_indices_for_region(region: ITURegion) -> Vec { + crate::bands::get_bands_for_region(region) + .into_iter() + .map(|(idx, _)| idx + 1) + .collect() +} + +fn band_alias_to_index(region: ITURegion) -> HashMap { + let mut aliases = HashMap::new(); + + for (idx, band) in crate::bands::get_bands_for_region(region) { + let one_based = idx + 1; + let full_name = band.name.to_ascii_lowercase(); + aliases.insert(full_name.clone(), one_based); + + if let Some(short_name) = full_name.split_whitespace().next() { + aliases.insert(short_name.to_string(), one_based); + } + } + + aliases +} + +/// Build a pure view model for the band listing of a given ITU region. +/// +/// Pure function; performs no I/O. +pub fn band_listing_view(region: ITURegion) -> BandListingView { + let rows = crate::bands::get_bands_for_region(region) + .into_iter() + .map(|(idx, band)| BandListingRow { + index: idx + 1, + display: format!("{band}"), + }) + .collect(); + BandListingView { + region_short_name: region.short_name().to_string(), + region_long_name: region.long_name().to_string(), + rows, + } +} + +/// Render a `BandListingView` to display lines (no I/O). +pub fn band_listing_display_lines(view: &BandListingView) -> Vec { + let mut lines = Vec::new(); + lines.push(String::new()); + lines.push(format!( + "Available bands in Region {} ({} total):", + view.region_short_name, + view.rows.len() + )); + lines.push(format!(" ({})", view.region_long_name)); + lines.push("------------------------------------------------------------".to_string()); + for row in &view.rows { + lines.push(format!("{:2}. {}", row.index, row.display)); + } + lines.push(String::new()); + lines +} diff --git a/src/app/mod.rs b/src/app/mod.rs index c71a05d..866f955 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -116,9 +116,13 @@ use std::fmt; use std::str::FromStr; pub mod advise; +pub mod band_select; pub mod state; +pub mod sweeps; pub use advise::*; +pub use band_select::*; pub use state::*; +pub use sweeps::*; pub const FEET_TO_METERS: f64 = 0.3048; pub const DEFAULT_BAND_SELECTION: [usize; 7] = [4, 5, 6, 7, 8, 9, 10]; @@ -940,162 +944,6 @@ pub fn resolve_wire_window_inputs( Ok(resolved) } -pub fn parse_band_selection(selection: &str, region: ITURegion) -> Result, AppError> { - let mut parsed = Vec::new(); - let mut seen = HashSet::new(); - - for token in selection.split(',') { - let token = token.trim(); - if token.is_empty() { - continue; - } - - if let Some((start, end)) = token.split_once('-') { - let start_idx = parse_single_band_token(start.trim(), region)?; - let end_idx = parse_single_band_token(end.trim(), region)?; - - let ordered = ordered_band_indices_for_region(region); - let start_pos = ordered - .iter() - .position(|idx| *idx == start_idx) - .ok_or_else(|| { - AppError::InvalidBandSelection(format!( - "unknown range start '{}'.", - start.trim() - )) - })?; - let end_pos = ordered - .iter() - .position(|idx| *idx == end_idx) - .ok_or_else(|| { - AppError::InvalidBandSelection(format!("unknown range end '{}'.", end.trim())) - })?; - - if start_pos <= end_pos { - for idx in &ordered[start_pos..=end_pos] { - if seen.insert(*idx) { - parsed.push(*idx); - } - } - } else { - for idx in ordered[end_pos..=start_pos].iter().rev() { - if seen.insert(*idx) { - parsed.push(*idx); - } - } - } - - continue; - } - - let idx = parse_single_band_token(token, region)?; - if seen.insert(idx) { - parsed.push(idx); - } - } - - if parsed.is_empty() { - return Err(AppError::EmptyBandSelection); - } - - Ok(parsed) -} - -pub fn parse_single_band_token(token: &str, region: ITURegion) -> Result { - let token = token.trim(); - if token.is_empty() { - return Err(AppError::InvalidBandSelection( - "empty band token".to_string(), - )); - } - - let aliases = band_alias_to_index(region); - let key = token.to_ascii_lowercase(); - aliases - .get(&key) - .copied() - .ok_or_else(|| AppError::InvalidBandSelection(format!("unknown band '{token}'."))) -} - -pub fn band_label_for_index(index: usize, region: ITURegion) -> String { - let zero_based = match index.checked_sub(1) { - Some(v) => v, - None => return index.to_string(), - }; - - for (idx, band) in crate::bands::get_bands_for_region(region) { - if idx == zero_based { - return band - .name - .split_whitespace() - .next() - .unwrap_or(band.name) - .to_string(); - } - } - - index.to_string() -} - -fn ordered_band_indices_for_region(region: ITURegion) -> Vec { - crate::bands::get_bands_for_region(region) - .into_iter() - .map(|(idx, _)| idx + 1) - .collect() -} - -fn band_alias_to_index(region: ITURegion) -> HashMap { - let mut aliases = HashMap::new(); - - for (idx, band) in crate::bands::get_bands_for_region(region) { - let one_based = idx + 1; - let full_name = band.name.to_ascii_lowercase(); - aliases.insert(full_name.clone(), one_based); - - if let Some(short_name) = full_name.split_whitespace().next() { - aliases.insert(short_name.to_string(), one_based); - } - } - - aliases -} - -/// Build a pure view model for the band listing of a given ITU region. -/// -/// Pure function; performs no I/O. -pub fn band_listing_view(region: ITURegion) -> BandListingView { - let rows = crate::bands::get_bands_for_region(region) - .into_iter() - .map(|(idx, band)| BandListingRow { - index: idx + 1, - display: format!("{band}"), - }) - .collect(); - BandListingView { - region_short_name: region.short_name().to_string(), - region_long_name: region.long_name().to_string(), - rows, - } -} - -/// Render a `BandListingView` to display lines (no I/O). -pub fn band_listing_display_lines(view: &BandListingView) -> Vec { - let mut lines = Vec::new(); - lines.push(String::new()); - lines.push(format!( - "Available bands in Region {} ({} total):", - view.region_short_name, - view.rows.len() - )); - lines.push(format!(" ({})", view.region_long_name)); - lines.push("------------------------------------------------------------".to_string()); - for row in &view.rows { - lines.push(format!("{:2}. {}", row.index, row.display)); - } - lines.push(String::new()); - lines -} - /// Validate and execute a calculation run. /// /// This is the preferred API for front-ends that need structured error @@ -2165,348 +2013,6 @@ pub fn resonant_points_view(results: &AppResults) -> ResonantPointsView { } } -// --------------------------------------------------------------------------- -// Velocity sweep views -// --------------------------------------------------------------------------- - -/// One row in a velocity-sweep comparison table. -#[derive(Debug, Clone)] -pub struct VelocitySweepRow { - pub velocity_factor: f64, - /// Non-resonant mode: the recommended wire length (None when no recommendation exists). - pub non_resonant_length_m: Option, - pub non_resonant_length_ft: Option, - pub non_resonant_clearance_pct: Option, - /// Resonant mode: per-band (band_name, half_wave_m, half_wave_ft). - pub resonant_band_lengths: Vec<(String, f64, f64)>, -} - -#[derive(Debug, Clone)] -pub struct VelocitySweepView { - pub mode: CalcMode, - /// Human-readable comma-joined band list from the first result set. - pub bands_label: String, - pub itu_region_label: String, - pub rows: Vec, -} - -/// Build a pure view model for a velocity sweep. -/// -/// `results_by_vf` is a slice of `(velocity_factor, AppResults)` pairs in -/// sweep order. The order is preserved in the returned view. -pub fn velocity_sweep_view(results_by_vf: &[(f64, AppResults)]) -> Option { - let (_, first) = results_by_vf.first()?; - let mode = first.config.mode; - let bands_label = first - .calculations - .iter() - .map(|c| c.band_name.as_str()) - .collect::>() - .join(", "); - let itu_region_label = first.config.itu_region.short_name().to_string(); - - let rows = results_by_vf - .iter() - .map(|(vf, res)| match mode { - CalcMode::NonResonant => VelocitySweepRow { - velocity_factor: *vf, - non_resonant_length_m: res.recommendation.as_ref().map(|r| r.length_m), - non_resonant_length_ft: res.recommendation.as_ref().map(|r| r.length_ft), - non_resonant_clearance_pct: res - .recommendation - .as_ref() - .map(|r| r.min_resonance_clearance_pct), - resonant_band_lengths: Vec::new(), - }, - CalcMode::Resonant => VelocitySweepRow { - velocity_factor: *vf, - non_resonant_length_m: None, - non_resonant_length_ft: None, - non_resonant_clearance_pct: None, - resonant_band_lengths: res - .calculations - .iter() - .map(|c| (c.band_name.clone(), c.half_wave_m, c.half_wave_ft)) - .collect(), - }, - }) - .collect(); - - Some(VelocitySweepView { - mode, - bands_label, - itu_region_label, - rows, - }) -} - -/// Render a `VelocitySweepView` to display lines (no I/O). -pub fn velocity_sweep_display_lines(view: &VelocitySweepView, units: UnitSystem) -> Vec { - let mode_label = match view.mode { - CalcMode::Resonant => "resonant", - CalcMode::NonResonant => "non-resonant", - }; - let mut lines = vec![ - String::new(), - format!( - "Velocity sweep \u{2014} {mode_label} | {} | Region {}:", - view.bands_label, view.itu_region_label - ), - ]; - - match view.mode { - CalcMode::NonResonant => { - lines.push(format!(" {:<6} {:<24} {}", "VF", "Length", "Clearance")); - lines.push(format!(" {}", "\u{2500}".repeat(46))); - for row in &view.rows { - let len_str = match (row.non_resonant_length_m, row.non_resonant_length_ft) { - (Some(m), Some(ft)) => match units { - UnitSystem::Metric => format!("{:.2} m", m), - UnitSystem::Imperial => format!("{:.1} ft", ft), - UnitSystem::Both => format!("{:.2} m / {:.1} ft", m, ft), - }, - _ => "\u{2014}".to_string(), - }; - let clearance_str = row - .non_resonant_clearance_pct - .map(|p| format!("{:.1}%", p)) - .unwrap_or_else(|| "\u{2014}".to_string()); - lines.push(format!( - " {:<6.2} {:<24} {}", - row.velocity_factor, len_str, clearance_str - )); - } - } - CalcMode::Resonant => { - for row in &view.rows { - let parts: Vec = row - .resonant_band_lengths - .iter() - .map(|(name, m, ft)| { - let len_str = match units { - UnitSystem::Metric => format!("{:.2} m", m), - UnitSystem::Imperial => format!("{:.1} ft", ft), - UnitSystem::Both => format!("{:.2} m / {:.1} ft", m, ft), - }; - format!("{} = {}", name, len_str) - }) - .collect(); - lines.push(format!( - " VF {:.2}: {}", - row.velocity_factor, - parts.join(" ") - )); - } - } - } - - lines.push(String::new()); - lines -} - -/// Validate that every velocity factor in a sweep is within 0.50–1.00. -pub fn validate_velocity_sweep(velocities: &[f64]) -> Result<(), AppError> { - for &vf in velocities { - if !(0.5..=1.0).contains(&vf) { - return Err(AppError::InvalidVelocitySweep(vf)); - } - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// Transformer sweep -// --------------------------------------------------------------------------- - -/// One row in a transformer sweep table — one transformer ratio and its match metrics. -#[derive(Debug, Clone)] -pub struct TransformerSweepRow { - pub ratio: TransformerRatio, - pub target_z_ohm: f64, - pub swr: f64, - pub efficiency_pct: f64, - pub mismatch_loss_db: f64, - /// Non-resonant mode: the recommended wire length (None when no recommendation exists). - pub non_resonant_length_m: Option, - pub non_resonant_length_ft: Option, - pub non_resonant_clearance_pct: Option, - /// Resonant mode: per-band (band_name, half_wave_m, half_wave_ft). - pub resonant_band_lengths: Vec<(String, f64, f64)>, -} - -/// View model for a transformer ratio sweep. -#[derive(Debug, Clone)] -pub struct TransformerSweepView { - pub mode: CalcMode, - pub assumed_feedpoint_ohm: f64, - pub bands_label: String, - pub itu_region_label: String, - pub rows: Vec, -} - -/// Build a transformer sweep view from a pre-computed set of `(ratio, results)` pairs. -/// -/// Returns `None` if the input is empty. -pub fn transformer_sweep_view( - results_by_ratio: &[(TransformerRatio, AppResults)], - assumed_feedpoint_ohm: f64, -) -> Option { - let (_, first) = results_by_ratio.first()?; - let mode = first.config.mode; - let bands_label = first - .calculations - .iter() - .map(|c| c.band_name.as_str()) - .collect::>() - .join(", "); - let itu_region_label = first.config.itu_region.short_name().to_string(); - - let rows = results_by_ratio - .iter() - .map(|(ratio, res)| { - let target_z = 50.0 * ratio.impedance_ratio(); - let gamma = if assumed_feedpoint_ohm > 0.0 { - ((target_z - assumed_feedpoint_ohm).abs() / (target_z + assumed_feedpoint_ohm)) - .clamp(0.0, 0.999_999) - } else { - 0.0 - }; - let efficiency_pct = (1.0 - gamma * gamma) * 100.0; - let mismatch_loss_db = -10.0 * (1.0 - gamma * gamma).log10(); - let swr = if assumed_feedpoint_ohm > 0.0 { - assumed_feedpoint_ohm.max(target_z) / assumed_feedpoint_ohm.min(target_z) - } else { - 1.0 - }; - - match mode { - CalcMode::NonResonant => TransformerSweepRow { - ratio: *ratio, - target_z_ohm: target_z, - swr, - efficiency_pct, - mismatch_loss_db, - non_resonant_length_m: res.recommendation.as_ref().map(|r| r.length_m), - non_resonant_length_ft: res.recommendation.as_ref().map(|r| r.length_ft), - non_resonant_clearance_pct: res - .recommendation - .as_ref() - .map(|r| r.min_resonance_clearance_pct), - resonant_band_lengths: Vec::new(), - }, - CalcMode::Resonant => TransformerSweepRow { - ratio: *ratio, - target_z_ohm: target_z, - swr, - efficiency_pct, - mismatch_loss_db, - non_resonant_length_m: None, - non_resonant_length_ft: None, - non_resonant_clearance_pct: None, - resonant_band_lengths: res - .calculations - .iter() - .map(|c| (c.band_name.clone(), c.half_wave_m, c.half_wave_ft)) - .collect(), - }, - } - }) - .collect(); - - Some(TransformerSweepView { - mode, - assumed_feedpoint_ohm, - bands_label, - itu_region_label, - rows, - }) -} - -/// Render a `TransformerSweepView` to display lines (no I/O). -pub fn transformer_sweep_display_lines( - view: &TransformerSweepView, - units: UnitSystem, -) -> Vec { - let mode_label = match view.mode { - CalcMode::Resonant => "resonant", - CalcMode::NonResonant => "non-resonant", - }; - let mut lines = vec![ - String::new(), - format!( - "Transformer sweep \u{2014} {mode_label} | {} | Region {} | feedpoint R: {:.0} \u{03a9}:", - view.bands_label, view.itu_region_label, view.assumed_feedpoint_ohm - ), - ]; - - match view.mode { - CalcMode::NonResonant => { - lines.push(format!( - " {:<5} {:<7} {:<6} {:<11} {:<8} {:<24} {}", - "Ratio", "Target Z", "SWR", "Efficiency", "Loss", "Length", "Clearance" - )); - lines.push(format!(" {}", "\u{2500}".repeat(78))); - for row in &view.rows { - let len_str = match (row.non_resonant_length_m, row.non_resonant_length_ft) { - (Some(m), Some(ft)) => match units { - UnitSystem::Metric => format!("{:.2} m", m), - UnitSystem::Imperial => format!("{:.1} ft", ft), - UnitSystem::Both => format!("{:.2} m / {:.1} ft", m, ft), - }, - _ => "\u{2014}".to_string(), - }; - let clearance_str = row - .non_resonant_clearance_pct - .map(|p| format!("{:.1}%", p)) - .unwrap_or_else(|| "\u{2014}".to_string()); - lines.push(format!( - " {:<5} {:>5.0} \u{03a9} {:>4.2}:1 {:>9.2}% {:.3} dB {:<24} {}", - row.ratio.as_label(), - row.target_z_ohm, - row.swr, - row.efficiency_pct, - row.mismatch_loss_db, - len_str, - clearance_str - )); - } - } - CalcMode::Resonant => { - lines.push(format!( - " {:<5} {:<7} {:<6} {:<11} {:<8} {}", - "Ratio", "Target Z", "SWR", "Efficiency", "Loss", "Per-band lengths" - )); - lines.push(format!(" {}", "\u{2500}".repeat(78))); - for row in &view.rows { - let band_parts: Vec = row - .resonant_band_lengths - .iter() - .map(|(name, m, ft)| { - let len_str = match units { - UnitSystem::Metric => format!("{:.2} m", m), - UnitSystem::Imperial => format!("{:.1} ft", ft), - UnitSystem::Both => format!("{:.2} m / {:.1} ft", m, ft), - }; - format!("{}={}", name, len_str) - }) - .collect(); - lines.push(format!( - " {:<5} {:>5.0} \u{03a9} {:>4.2}:1 {:>9.2}% {:.3} dB {}", - row.ratio.as_label(), - row.target_z_ohm, - row.swr, - row.efficiency_pct, - row.mismatch_loss_db, - band_parts.join(" ") - )); - } - } - } - - lines.push(String::new()); - lines -} - // --------------------------------------------------------------------------- // Quiet summary // --------------------------------------------------------------------------- diff --git a/src/app/sweeps.rs b/src/app/sweeps.rs new file mode 100644 index 0000000..9551f13 --- /dev/null +++ b/src/app/sweeps.rs @@ -0,0 +1,344 @@ +//! Velocity- and transformer-sweep view models and display formatting. +//! +//! Pure view/formatting helpers over pre-computed `AppResults` sets; extracted +//! from `app/mod.rs` to keep that module focused on the config/orchestration core. + +use super::*; + +/// One row in a velocity-sweep comparison table. +#[derive(Debug, Clone)] +pub struct VelocitySweepRow { + pub velocity_factor: f64, + /// Non-resonant mode: the recommended wire length (None when no recommendation exists). + pub non_resonant_length_m: Option, + pub non_resonant_length_ft: Option, + pub non_resonant_clearance_pct: Option, + /// Resonant mode: per-band (band_name, half_wave_m, half_wave_ft). + pub resonant_band_lengths: Vec<(String, f64, f64)>, +} + +#[derive(Debug, Clone)] +pub struct VelocitySweepView { + pub mode: CalcMode, + /// Human-readable comma-joined band list from the first result set. + pub bands_label: String, + pub itu_region_label: String, + pub rows: Vec, +} + +/// Build a pure view model for a velocity sweep. +/// +/// `results_by_vf` is a slice of `(velocity_factor, AppResults)` pairs in +/// sweep order. The order is preserved in the returned view. +pub fn velocity_sweep_view(results_by_vf: &[(f64, AppResults)]) -> Option { + let (_, first) = results_by_vf.first()?; + let mode = first.config.mode; + let bands_label = first + .calculations + .iter() + .map(|c| c.band_name.as_str()) + .collect::>() + .join(", "); + let itu_region_label = first.config.itu_region.short_name().to_string(); + + let rows = results_by_vf + .iter() + .map(|(vf, res)| match mode { + CalcMode::NonResonant => VelocitySweepRow { + velocity_factor: *vf, + non_resonant_length_m: res.recommendation.as_ref().map(|r| r.length_m), + non_resonant_length_ft: res.recommendation.as_ref().map(|r| r.length_ft), + non_resonant_clearance_pct: res + .recommendation + .as_ref() + .map(|r| r.min_resonance_clearance_pct), + resonant_band_lengths: Vec::new(), + }, + CalcMode::Resonant => VelocitySweepRow { + velocity_factor: *vf, + non_resonant_length_m: None, + non_resonant_length_ft: None, + non_resonant_clearance_pct: None, + resonant_band_lengths: res + .calculations + .iter() + .map(|c| (c.band_name.clone(), c.half_wave_m, c.half_wave_ft)) + .collect(), + }, + }) + .collect(); + + Some(VelocitySweepView { + mode, + bands_label, + itu_region_label, + rows, + }) +} + +/// Render a `VelocitySweepView` to display lines (no I/O). +pub fn velocity_sweep_display_lines(view: &VelocitySweepView, units: UnitSystem) -> Vec { + let mode_label = match view.mode { + CalcMode::Resonant => "resonant", + CalcMode::NonResonant => "non-resonant", + }; + let mut lines = vec![ + String::new(), + format!( + "Velocity sweep \u{2014} {mode_label} | {} | Region {}:", + view.bands_label, view.itu_region_label + ), + ]; + + match view.mode { + CalcMode::NonResonant => { + lines.push(format!(" {:<6} {:<24} {}", "VF", "Length", "Clearance")); + lines.push(format!(" {}", "\u{2500}".repeat(46))); + for row in &view.rows { + let len_str = match (row.non_resonant_length_m, row.non_resonant_length_ft) { + (Some(m), Some(ft)) => match units { + UnitSystem::Metric => format!("{:.2} m", m), + UnitSystem::Imperial => format!("{:.1} ft", ft), + UnitSystem::Both => format!("{:.2} m / {:.1} ft", m, ft), + }, + _ => "\u{2014}".to_string(), + }; + let clearance_str = row + .non_resonant_clearance_pct + .map(|p| format!("{:.1}%", p)) + .unwrap_or_else(|| "\u{2014}".to_string()); + lines.push(format!( + " {:<6.2} {:<24} {}", + row.velocity_factor, len_str, clearance_str + )); + } + } + CalcMode::Resonant => { + for row in &view.rows { + let parts: Vec = row + .resonant_band_lengths + .iter() + .map(|(name, m, ft)| { + let len_str = match units { + UnitSystem::Metric => format!("{:.2} m", m), + UnitSystem::Imperial => format!("{:.1} ft", ft), + UnitSystem::Both => format!("{:.2} m / {:.1} ft", m, ft), + }; + format!("{} = {}", name, len_str) + }) + .collect(); + lines.push(format!( + " VF {:.2}: {}", + row.velocity_factor, + parts.join(" ") + )); + } + } + } + + lines.push(String::new()); + lines +} + +/// Validate that every velocity factor in a sweep is within 0.50–1.00. +pub fn validate_velocity_sweep(velocities: &[f64]) -> Result<(), AppError> { + for &vf in velocities { + if !(0.5..=1.0).contains(&vf) { + return Err(AppError::InvalidVelocitySweep(vf)); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Transformer sweep +// --------------------------------------------------------------------------- + +/// One row in a transformer sweep table — one transformer ratio and its match metrics. +#[derive(Debug, Clone)] +pub struct TransformerSweepRow { + pub ratio: TransformerRatio, + pub target_z_ohm: f64, + pub swr: f64, + pub efficiency_pct: f64, + pub mismatch_loss_db: f64, + /// Non-resonant mode: the recommended wire length (None when no recommendation exists). + pub non_resonant_length_m: Option, + pub non_resonant_length_ft: Option, + pub non_resonant_clearance_pct: Option, + /// Resonant mode: per-band (band_name, half_wave_m, half_wave_ft). + pub resonant_band_lengths: Vec<(String, f64, f64)>, +} + +/// View model for a transformer ratio sweep. +#[derive(Debug, Clone)] +pub struct TransformerSweepView { + pub mode: CalcMode, + pub assumed_feedpoint_ohm: f64, + pub bands_label: String, + pub itu_region_label: String, + pub rows: Vec, +} + +/// Build a transformer sweep view from a pre-computed set of `(ratio, results)` pairs. +/// +/// Returns `None` if the input is empty. +pub fn transformer_sweep_view( + results_by_ratio: &[(TransformerRatio, AppResults)], + assumed_feedpoint_ohm: f64, +) -> Option { + let (_, first) = results_by_ratio.first()?; + let mode = first.config.mode; + let bands_label = first + .calculations + .iter() + .map(|c| c.band_name.as_str()) + .collect::>() + .join(", "); + let itu_region_label = first.config.itu_region.short_name().to_string(); + + let rows = results_by_ratio + .iter() + .map(|(ratio, res)| { + let target_z = 50.0 * ratio.impedance_ratio(); + let gamma = if assumed_feedpoint_ohm > 0.0 { + ((target_z - assumed_feedpoint_ohm).abs() / (target_z + assumed_feedpoint_ohm)) + .clamp(0.0, 0.999_999) + } else { + 0.0 + }; + let efficiency_pct = (1.0 - gamma * gamma) * 100.0; + let mismatch_loss_db = -10.0 * (1.0 - gamma * gamma).log10(); + let swr = if assumed_feedpoint_ohm > 0.0 { + assumed_feedpoint_ohm.max(target_z) / assumed_feedpoint_ohm.min(target_z) + } else { + 1.0 + }; + + match mode { + CalcMode::NonResonant => TransformerSweepRow { + ratio: *ratio, + target_z_ohm: target_z, + swr, + efficiency_pct, + mismatch_loss_db, + non_resonant_length_m: res.recommendation.as_ref().map(|r| r.length_m), + non_resonant_length_ft: res.recommendation.as_ref().map(|r| r.length_ft), + non_resonant_clearance_pct: res + .recommendation + .as_ref() + .map(|r| r.min_resonance_clearance_pct), + resonant_band_lengths: Vec::new(), + }, + CalcMode::Resonant => TransformerSweepRow { + ratio: *ratio, + target_z_ohm: target_z, + swr, + efficiency_pct, + mismatch_loss_db, + non_resonant_length_m: None, + non_resonant_length_ft: None, + non_resonant_clearance_pct: None, + resonant_band_lengths: res + .calculations + .iter() + .map(|c| (c.band_name.clone(), c.half_wave_m, c.half_wave_ft)) + .collect(), + }, + } + }) + .collect(); + + Some(TransformerSweepView { + mode, + assumed_feedpoint_ohm, + bands_label, + itu_region_label, + rows, + }) +} + +/// Render a `TransformerSweepView` to display lines (no I/O). +pub fn transformer_sweep_display_lines( + view: &TransformerSweepView, + units: UnitSystem, +) -> Vec { + let mode_label = match view.mode { + CalcMode::Resonant => "resonant", + CalcMode::NonResonant => "non-resonant", + }; + let mut lines = vec![ + String::new(), + format!( + "Transformer sweep \u{2014} {mode_label} | {} | Region {} | feedpoint R: {:.0} \u{03a9}:", + view.bands_label, view.itu_region_label, view.assumed_feedpoint_ohm + ), + ]; + + match view.mode { + CalcMode::NonResonant => { + lines.push(format!( + " {:<5} {:<7} {:<6} {:<11} {:<8} {:<24} {}", + "Ratio", "Target Z", "SWR", "Efficiency", "Loss", "Length", "Clearance" + )); + lines.push(format!(" {}", "\u{2500}".repeat(78))); + for row in &view.rows { + let len_str = match (row.non_resonant_length_m, row.non_resonant_length_ft) { + (Some(m), Some(ft)) => match units { + UnitSystem::Metric => format!("{:.2} m", m), + UnitSystem::Imperial => format!("{:.1} ft", ft), + UnitSystem::Both => format!("{:.2} m / {:.1} ft", m, ft), + }, + _ => "\u{2014}".to_string(), + }; + let clearance_str = row + .non_resonant_clearance_pct + .map(|p| format!("{:.1}%", p)) + .unwrap_or_else(|| "\u{2014}".to_string()); + lines.push(format!( + " {:<5} {:>5.0} \u{03a9} {:>4.2}:1 {:>9.2}% {:.3} dB {:<24} {}", + row.ratio.as_label(), + row.target_z_ohm, + row.swr, + row.efficiency_pct, + row.mismatch_loss_db, + len_str, + clearance_str + )); + } + } + CalcMode::Resonant => { + lines.push(format!( + " {:<5} {:<7} {:<6} {:<11} {:<8} {}", + "Ratio", "Target Z", "SWR", "Efficiency", "Loss", "Per-band lengths" + )); + lines.push(format!(" {}", "\u{2500}".repeat(78))); + for row in &view.rows { + let band_parts: Vec = row + .resonant_band_lengths + .iter() + .map(|(name, m, ft)| { + let len_str = match units { + UnitSystem::Metric => format!("{:.2} m", m), + UnitSystem::Imperial => format!("{:.1} ft", ft), + UnitSystem::Both => format!("{:.2} m / {:.1} ft", m, ft), + }; + format!("{}={}", name, len_str) + }) + .collect(); + lines.push(format!( + " {:<5} {:>5.0} \u{03a9} {:>4.2}:1 {:>9.2}% {:.3} dB {}", + row.ratio.as_label(), + row.target_z_ohm, + row.swr, + row.efficiency_pct, + row.mismatch_loss_db, + band_parts.join(" ") + )); + } + } + } + + lines.push(String::new()); + lines +}