diff --git a/Cargo.lock b/Cargo.lock index eeab13a..5b3afc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,9 +69,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "atomic" diff --git a/README.md b/README.md index 829e963..1ef046c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ planning sessions. | End-fed half-wave | `efhw` | Total EFHW wire length | | Full-wave loop | `loop` | Circumference and square-side estimate | | Off-center-fed dipole | `ocfd` | 33/67 and 20/80 leg splits, optimised feedpoint | -| Trap dipole | `trap-dipole` | Trap total/per-element plus build guidance notes | +| Trap dipole | `trap-dipole` | Trap total/per-element **wire-budget estimate** plus build guidance notes | Omit `--antenna` to show all six models at once. @@ -72,7 +72,9 @@ equal-tie support. - `--conductor-mm <0.5..4.0>` — set conductor diameter in millimeters for first-order impedance/length correction (default: `2.0`). - `--step ` — control non-resonant search resolution (default 0.05 m). -- `--velocity ` — velocity factor from 0.50 to 1.00 (default 0.95). +- `--velocity ` — velocity factor from 0.50 to 1.00 (default `1.0`, bare + wire). The length rules already include the ~0.95 bare-wire end effect, so this + is an *additional* multiplier for insulated wire (typ. 0.90–0.95). - `--transformer recommended|1:1|1:4|1:9|1:49|1:56|...` — auto-resolved per mode and antenna model by default. - `--units m|ft|both` — metric, imperial, or both systems in one run. diff --git a/docs/architecture.md b/docs/architecture.md index 370c7a4..e78d9e2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,7 @@ struct Cli { region -- CliITURegion (default: 1) mode -- CliCalcMode (resonant | non-resonant) bands -- Option (e.g. "40m,20m,10m-15m") - velocity -- f64 (default 0.95) + velocity -- f64 (default 1.0 = bare wire; see docs/math.md §1) transformer -- CliTransformerSelection (recommended | 1:1 | 1:9 | ...) antenna -- Option wire_min/max -- Option meters or feet @@ -229,7 +229,7 @@ RF physics and optimization. I/O-free. - OCFD 33/67 split - trap dipole total and per-leg (`trap_dipole_total_m`, `trap_dipole_leg_m`, `trap_dipole_total_ft`, `trap_dipole_leg_ft`) -Trap dipole formula: `total_ft = (450.0 / freq_mhz) * velocity_factor`, then converted to metric; `leg = total / 2`. +Trap dipole formula: `total_ft = (450.0 / freq_mhz) * velocity_factor`, then converted to metric; `leg = total / 2`. This `450/f` value is a coarse wire-budget *estimate*, not a cut length (see docs/math.md §10). **Transformer ratios available:** 1:1 | 1:2 | 1:4 | 1:5 | 1:6 | 1:9 | 1:16 | 1:49 | 1:56 | 1:64 diff --git a/docs/math.md b/docs/math.md index 138258e..2283aae 100644 --- a/docs/math.md +++ b/docs/math.md @@ -23,6 +23,17 @@ $$ with $f$ in Hz and $c \approx 299{,}792{,}458\ \mathrm{m/s}$. +**Velocity-factor convention (important).** The metric coefficients below are the +classic imperial handbook rules (468/936/234/1005 ft) expressed in meters. These +rules *already include* the ~0.95 bare-wire end-effect shortening relative to the +free-space half/quarter/full wavelength — e.g. $142.65/f \approx 0.9516 \cdot (c/2f)$. +Therefore $VF$ here is **not** the end-effect factor; it is an *additional* +multiplier for insulated wire and defaults to **$VF = 1.0$ (bare wire)**. Typical +insulated wire uses $VF \approx 0.90\text{–}0.95$. Do **not** set $VF = 0.95$ for +bare wire — that double-counts the end effect and yields lengths ~5 % short +(a 40 m dipole would come out ~19.1 m and resonate above the band). At $VF = 1.0$, +$142.65/7.1 \approx 20.1$ m, matching the NEC reference decks in `corpus/`. + Practical ham formulas (with $f_{\mathrm{MHz}}$ in MHz, lengths in meters): $$ @@ -112,9 +123,14 @@ $$ Rusty Wire currently applies a bounded logarithmic correction for non-1:1 transformer selections: $$ -r = \max\left(0.01,\frac{Z_t}{Z_{\mathrm{ref}}}\right),\quad Z_{\mathrm{ref}}=73\ \Omega +r = \max\left(0.01,\frac{Z_t}{Z_{\mathrm{ref}}}\right) $$ +where $Z_{\mathrm{ref}}$ is the **NEC-calibrated** nominal feedpoint resistance for +the current height/ground (`nec_calibrated_dipole_r`, ~45–77 Ω), *not* a fixed +73 Ω. At the default 1:1 ratio this correction is a no-op, so it does not affect +default resonant lengths. + $$ C = \mathrm{clamp}\left(1 + 0.03\log_{10}(r),\ 0.85,\ 1.15\right) $$ @@ -248,6 +264,20 @@ For mission-critical designs, use Rusty Wire results as initial conditions and v For the practical calibration workflow (data format and fitting script), see [nec-calibration.md](nec-calibration.md). +## 10) Trap Dipole Wire Budget (Estimate Only) + +The trap-dipole total is a coarse whole-wire *budget* estimate: + +$$ +L_{\mathrm{trap},\mathrm{m}} = \frac{137.16}{f_{\mathrm{MHz}}}\,VF \quad(\equiv 450/f\ \mathrm{ft}),\qquad L_{\mathrm{leg}} = \frac{L_{\mathrm{trap}}}{2} +$$ + +The $450/f$ rule is a rule-of-thumb starting point, **not** a cut length. A real +trap dipole's element lengths depend on the trap inductance/capacitance and the +specific band pair, which this lightweight model does not solve. Treat the output +as an initial wire estimate and finalise element lengths against the trap +manufacturer's data or a NEC model. + ## References 1. ARRL Antenna Book (latest editions): practical dipole, loop, EFHW, OCFD, and inverted-V design constants. diff --git a/src/app/mod.rs b/src/app/mod.rs index 50e5bd6..c5a4ab7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -323,7 +323,10 @@ impl Default for AppConfig { fn default() -> Self { Self { band_indices: DEFAULT_BAND_SELECTION.to_vec(), - velocity_factor: 0.95, + // 1.0 = bare wire; the length coefficients already include the + // ~0.95 bare-wire end effect, so VF is an additional insulated-wire + // multiplier only (see docs/math.md §1). + velocity_factor: 1.0, mode: CalcMode::Resonant, wire_min_m: DEFAULT_NON_RESONANT_CONFIG.min_len_m, wire_max_m: DEFAULT_NON_RESONANT_CONFIG.max_len_m, @@ -3219,7 +3222,7 @@ mod tests { let config = AppConfig::default(); assert_eq!(config.mode, CalcMode::Resonant); - assert_eq!(config.velocity_factor, 0.95); + assert_eq!(config.velocity_factor, 1.0); assert_eq!(config.itu_region, ITURegion::Region1); assert_eq!(config.transformer_ratio, TransformerRatio::R1To1); assert_eq!(config.antenna_model, None); diff --git a/src/calculations.rs b/src/calculations.rs index be702fd..f7d35ef 100644 --- a/src/calculations.rs +++ b/src/calculations.rs @@ -281,12 +281,21 @@ impl fmt::Display for WireCalculation { } } -const METERS_TO_FEET: f64 = 3.28084; -const HALF_WAVE_COEFF_M: f64 = 142.646_544_192; -const FULL_WAVE_COEFF_M: f64 = 285.293_088_384; -const QUARTER_WAVE_COEFF_M: f64 = 71.323_272_096; -const FULL_LOOP_COEFF_M: f64 = 306.324_462_672; -const TRAP_DIPOLE_COEFF_M: f64 = 137.16; +// 1 foot = 0.3048 m exactly; keep the metre→foot factor as the exact reciprocal +// so metric and imperial outputs stay perfectly consistent. +const METERS_TO_FEET: f64 = 1.0 / 0.3048; +// Metric forms of the classic imperial handbook rules (468/936/234/1005 ft). +// These ALREADY embed the ~0.95 bare-wire end-effect shortening, so the caller's +// velocity_factor is an additional insulated-wire multiplier only (default 1.0). +// See docs/math.md §1. +const HALF_WAVE_COEFF_M: f64 = 142.646_544_192; // 468/f ft +const FULL_WAVE_COEFF_M: f64 = 285.293_088_384; // 936/f ft +const QUARTER_WAVE_COEFF_M: f64 = 71.323_272_096; // 234/f ft +const FULL_LOOP_COEFF_M: f64 = 306.324_462_672; // 1005/f ft (loop: ~2% longer than λ) + // 450/f ft: a rough whole-wire *budget* estimate for a two-band trap dipole. + // Real trap-dipole element lengths depend on the trap L/C and band pair; treat + // this as a starting estimate, not a cut length. See docs/math.md §10. +const TRAP_DIPOLE_COEFF_M: f64 = 137.16; // 450/f ft /// Calculate resonant dipole wire lengths for a given frequency /// @@ -468,7 +477,15 @@ pub fn calculate_for_band_with_environment( /// for a half-wave dipole at the given height and ground, derived from fnec-rust /// Hallén solver corpus sweeps (corpus/reference-results.json, v2.9.0). /// -/// Reference data (7.1 MHz, 2 mm wire, 51 segments, 0.95 VF cut): +/// CAVEAT: the anchor data is measured only at 7.1 MHz (40 m), and interpolation +/// is on height in *metres*. Radiation resistance actually tracks height in +/// wavelengths, so 10 m AGL is ~0.24 λ at 40 m but ~0.96 λ at 10 m — these values +/// are trustworthy near the 40 m band and are a coarse approximation elsewhere. +/// The model also reports only R (the corpus reactance is not applied). A +/// height-in-λ reparameterisation is tracked as future work once multi-frequency +/// NEC sweep data is committed. +/// +/// Reference data (7.1 MHz, 2 mm wire, 51 segments, bare-wire ~0.95 end-effect cut): /// free space: R ≈ 62.94 Ω /// 7 m AGL, good: R ≈ 73.03 Ω /// 10 m AGL, poor: R ≈ 56.38 Ω @@ -616,7 +633,9 @@ pub fn calculate_non_resonant_optima( let mut best_clearance_m = -1.0_f64; let mut len = min_len_m; - while len <= max_len_m { + // +1e-9 guards against float accumulation dropping the final candidate, + // matching the sibling window/compromise sampling loops. + while len <= max_len_m + 1e-9 { let nearest = resonance_points_m .iter() .map(|r| (len - r).abs()) @@ -1076,6 +1095,76 @@ mod tests { assert!(result.quarter_wave_m < result.half_wave_m); } + /// Build a bare HF band at a given center frequency for absolute-length checks. + fn band_at(name: &'static str, center_mhz: f64) -> Band { + Band { + name, + band_type: crate::bands::BandType::HF, + freq_low_mhz: center_mhz - 0.1, + freq_high_mhz: center_mhz + 0.1, + freq_center_mhz: center_mhz, + typical_skip_km: (150.0, 800.0), + regions: &[crate::bands::ITURegion::Region1], + } + } + + /// Absolute regression guard: at the default bare-wire VF = 1.0 the core + /// lengths must match the classic handbook rules (468/936/234/1005 over f). + /// Relative tests alone let the double-counted-VF regression slip through + /// (a 40 m dipole was coming out 19.09 m instead of ~20.09 m); these pin the + /// absolute values so a constant/VF regression fails loudly. + #[test] + fn resonant_lengths_match_handbook_absolute_values() { + // 40 m band at 7.1 MHz, bare wire (VF = 1.0), 1:1, default 2 mm conductor. + let b40 = band_at("40m", 7.1); + let r40 = calculate_for_band_with_velocity( + &b40, + 1.0, + TransformerRatio::R1To1, + 10.0, + GroundClass::Average, + ); + // 468/7.1 ft ≈ 20.09 m — matches the corpus NEC decks (±10.035 m legs). + assert!( + (r40.half_wave_m - 20.091).abs() < 0.20, + "40 m half-wave = {:.3} m, expected ≈ 20.09 m (468/f)", + r40.half_wave_m + ); + assert!( + (r40.quarter_wave_m - 10.046).abs() < 0.10, + "40 m quarter-wave = {:.3} m, expected ≈ 10.05 m (234/f)", + r40.quarter_wave_m + ); + assert!( + (r40.full_wave_loop_circumference_m - 43.144).abs() < 0.40, + "40 m loop = {:.3} m, expected ≈ 43.14 m (1005/f)", + r40.full_wave_loop_circumference_m + ); + + // 20 m band at 14.175 MHz. + let b20 = band_at("20m", 14.175); + let r20 = calculate_for_band_with_velocity( + &b20, + 1.0, + TransformerRatio::R1To1, + 10.0, + GroundClass::Average, + ); + assert!( + (r20.half_wave_m - 10.063).abs() < 0.10, + "20 m half-wave = {:.3} m, expected ≈ 10.06 m (468/f)", + r20.half_wave_m + ); + + // Guard against the double-counted end effect returning: bare-wire VF 1.0 + // must NOT reproduce the old ~19.09 m short length. + assert!( + r40.half_wave_m > 19.6, + "40 m half-wave {:.3} m looks like a double-counted VF regression", + r40.half_wave_m + ); + } + #[test] fn calculate_for_band_velocity_factor_effect() { let band = sample_band(); @@ -1161,7 +1250,7 @@ mod tests { GroundClass::Average, ); - let m_to_ft = 3.28084; + let m_to_ft = METERS_TO_FEET; assert!((result.half_wave_ft - result.half_wave_m * m_to_ft).abs() < 0.01); assert!((result.full_wave_ft - result.full_wave_m * m_to_ft).abs() < 0.01); } @@ -1176,7 +1265,7 @@ mod tests { 10.0, GroundClass::Average, ); - let m_to_ft = 3.28084; + let m_to_ft = METERS_TO_FEET; assert!((result.end_fed_half_wave_m - result.corrected_half_wave_m).abs() < 1e-9); assert!((result.end_fed_half_wave_ft - result.corrected_half_wave_ft).abs() < 1e-9); diff --git a/src/cli.rs b/src/cli.rs index c5a390a..2942357 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -86,8 +86,11 @@ struct Cli { #[arg(long)] bands_config: Option, - /// Velocity factor (0.50-1.00) - #[arg(short, long, default_value_t = 0.95)] + /// Velocity factor (0.50-1.00). Default 1.0 = bare wire. The base length + /// coefficients (468/f etc.) already include the ~0.95 end-effect shortening + /// for bare wire, so this factor is an *additional* multiplier for insulated + /// wire (typ. 0.90-0.95) and must not double-count the end effect. + #[arg(short, long, default_value_t = 1.0)] velocity: f64, /// Antenna height in meters (standard presets: 7, 10, 12) @@ -1406,17 +1409,17 @@ fn prompt_velocity_factor_with_default( ) -> f64 { let prompt_str = match default { Some(v) => format!("Enter velocity factor (0.5-1.0) [{v:.2}]: "), - None => "Enter velocity factor (0.5-1.0) [0.95]: ".to_string(), + None => "Enter velocity factor (0.5-1.0) [1.00]: ".to_string(), }; prompt(output, &prompt_str); let line = read_line(input, "failed to read velocity factor"); let trimmed = line.trim(); if trimmed.is_empty() { - return default.unwrap_or(0.95); + return default.unwrap_or(1.0); } match trimmed.parse::() { Ok(v) if (0.5..=1.0).contains(&v) => v, - _ => default.unwrap_or(0.95), + _ => default.unwrap_or(1.0), } } diff --git a/src/fnec_validation.rs b/src/fnec_validation.rs index 210926c..892baeb 100644 --- a/src/fnec_validation.rs +++ b/src/fnec_validation.rs @@ -139,20 +139,23 @@ fn generate_dipole_nec_deck( // Calculate segment count: aim for ~lambda/20 segments let wavelength_m = 299.792458 / frequency_mhz; let min_segments = ((length_m / wavelength_m) * 20.0).ceil() as i32; - let segment_count = min_segments.clamp(21, 100); // Clamp between 21 and 100 + let mut segment_count = min_segments.clamp(21, 99); // Clamp between 21 and 99 + if segment_count % 2 == 0 { + // Force an odd count so the feed at (n/2)+1 sits on the exact center segment. + segment_count += 1; + } let half_length = length_m / 2.0; let wire_radius = wire_radius_mm / 1000.0; // Convert mm to meters // GW card: wire geometry // Format: GW tag nseg x1 y1 z1 x2 y2 z2 rad - // Position dipole horizontally at given height, centered at origin + // Horizontal dipole along the X axis at height z = height_m, centered at x = 0 + // (matches the corpus/*.nec decks and nec_export.rs). Free-space model below, + // so the z offset is translation-invariant and does not change the result. let gw_card = format!( - "GW 1 {} 0 0 {:.4} 0 0 {:.4} {:.6}", - segment_count, - height_m - half_length, - height_m + half_length, - wire_radius + "GW 1 {} {:.4} 0 {:.4} {:.4} 0 {:.4} {:.6}", + segment_count, -half_length, height_m, half_length, height_m, wire_radius ); // GE: geometry end @@ -289,7 +292,8 @@ fn extract_impedance_imag(output: &str) -> Option { /// Calculate mismatch factor between calculated and expected impedance. /// Range 0.0 (perfect match) to 1.0 (worst match). fn calculate_mismatch_factor(z_real: f64, z_imag: f64) -> f64 { - // Typical dipole feedpoint impedance is ~70 ohms + reactance + // Typical resonant-dipole feedpoint resistance is ~70 Ω; keep this as the + // cross-check reference so the pass/warn/reject thresholds stay calibrated. let target_real = 70.0; let delta_real = (z_real - target_real).abs(); let delta_imag = z_imag.abs(); diff --git a/tests/corpus_validation.rs b/tests/corpus_validation.rs index d9894f9..f65ab23 100644 --- a/tests/corpus_validation.rs +++ b/tests/corpus_validation.rs @@ -172,10 +172,10 @@ fn corpus_resonant_dipole_40m_nec() { let nec_z_im = -69.28_f64; // Imaginary part (Ω) — capacitive; see notes in nec-requirements.md // Sanity checks on rusty-wire's resonant dipole length - // At 7.1 MHz: λ/2 ≈ 21.1 m; with velocity factor ~0.95 → ~20.1 m + // At 7.1 MHz: free-space λ/2 ≈ 21.1 m; the 468/f bare-wire rule ≈ 20.09 m assert!( - (19.0..=21.5).contains(&rw_dipole_len), - "resonant dipole length {rw_dipole_len} m should be near 20 m (λ/2 at 7.1 MHz)" + (19.5..=20.6).contains(&rw_dipole_len), + "resonant dipole length {rw_dipole_len} m should be near 20.1 m (468/f at 7.1 MHz)" ); // Monotonicity check: dipole length increases with decreasing frequency @@ -253,11 +253,11 @@ fn corpus_nec_dipole_10m_good_ground() { .and_then(|s| s.parse().ok()) .expect("failed to parse dipole length"); - // NEC reference: dipole at 10m AGL, good soil — same wire length (20.07 m) as free-space + // NEC reference: dipole at 10m AGL, good soil — same wire length (~20.07 m) as free-space // case; ground effects change impedance but not the recommended cut length. - // rusty-wire length should remain near λ/2 × 0.95 ≈ 20.1 m - check_tolerance(rw_len, 19.09, 0.02, 0.5) - .expect("dipole length should be within 2% of 19.09 m (λ/2 at 7.1 MHz, 2mm wire)"); + // rusty-wire (bare wire, VF=1.0) = 468/f ≈ 20.09 m, matching the corpus decks. + check_tolerance(rw_len, 20.09, 0.02, 0.5) + .expect("dipole length should be within 2% of 20.09 m (468/f at 7.1 MHz, 2mm bare wire)"); println!("Corpus case NEC dipole-10m-ground-good:"); println!(" rusty-wire length = {rw_len:.2} m"); @@ -306,7 +306,7 @@ fn corpus_nec_dipole_7m_good_ground() { .and_then(|s| s.parse().ok()) .expect("failed to parse dipole length"); - check_tolerance(rw_len, 19.09, 0.02, 0.5) + check_tolerance(rw_len, 20.09, 0.02, 0.5) .expect("dipole length at 7m AGL should still be within 2% of resonant length"); println!("Corpus case NEC dipole-7m-ground-good:"); @@ -356,7 +356,7 @@ fn corpus_nec_dipole_12m_good_ground() { .and_then(|s| s.parse().ok()) .expect("failed to parse dipole length"); - check_tolerance(rw_len, 19.09, 0.02, 0.5) + check_tolerance(rw_len, 20.09, 0.02, 0.5) .expect("dipole length at 12m AGL should be within 2% of resonant length"); println!("Corpus case NEC dipole-12m-ground-good:"); @@ -408,8 +408,8 @@ fn corpus_nec_efhw_40m() { .and_then(|s| s.parse().ok()) .expect("failed to parse EFHW length"); - // NEC deck uses 20.07m (λ/2 × 0.95 VF). rusty-wire recommends ~19.99m (slightly - // different VF coefficient). Tolerance: ±2% relative or ±0.5m absolute. + // NEC deck uses ~20.07m; rusty-wire (bare wire, 468/f) recommends ~20.09m for the + // EFHW half-wave element. Tolerance: keep a wide sanity band around 20 m. assert!( (18.0..=22.0).contains(&rw_len), "EFHW length {rw_len:.2} m should be near 20 m (λ/2 at 7.1 MHz)" @@ -837,7 +837,7 @@ fn corpus_non_resonant_multi_band_40m_20m() { let stdout = String::from_utf8_lossy(&output.stdout); // Extract half-wave lengths from the two band sections. - // Pattern: " Half-wave: 19.54 m (base: ...)" + // Pattern: " Half-wave: 20.64 m (base: 20.09 m)" (displayed = non-resonant nudge) let half_wave_lengths: Vec = stdout .lines() .filter(|l| l.trim_start().starts_with("Half-wave:")) @@ -881,15 +881,15 @@ fn corpus_non_resonant_multi_band_40m_20m() { got 40m={hw_40m:.2} m, 20m={hw_20m:.2} m" ); - // Tolerance checks against expected model values - check_tolerance(hw_40m, 19.54, 0.03, 0.20) + // Tolerance checks against expected model values (bare wire, VF=1.0) + check_tolerance(hw_40m, 20.64, 0.03, 0.20) .expect("40m half-wave tolerance breach (GAP-010, non-resonant multi-band)"); - check_tolerance(hw_20m, 9.79, 0.03, 0.20) + check_tolerance(hw_20m, 10.34, 0.03, 0.20) .expect("20m half-wave tolerance breach (GAP-010, non-resonant multi-band)"); println!("Corpus case PASSED (non-resonant 40m+20m multi-band)"); - println!(" 40m half-wave = {hw_40m:.2} m (ref 19.54 m)"); - println!(" 20m half-wave = {hw_20m:.2} m (ref 9.79 m)"); + println!(" 40m half-wave = {hw_40m:.2} m (ref 20.64 m)"); + println!(" 20m half-wave = {hw_20m:.2} m (ref 10.34 m)"); println!(" ratio = {ratio:.3} (expected ≈ 2.0)"); } diff --git a/tests/export_format_contract.rs b/tests/export_format_contract.rs index 4727af6..3724639 100644 --- a/tests/export_format_contract.rs +++ b/tests/export_format_contract.rs @@ -447,14 +447,26 @@ fn json_export_numeric_precision() { "frequency should be numeric value" ); - // Check for typical wire length patterns with 2 decimal places - let has_typical_length_format = content.contains(".00,") - || content.contains(".25,") - || content.contains(".50,") - || content.contains(".75,"); - assert!( - has_typical_length_format, - "should have numeric wire lengths with decimal precision" + // Verify a wire-length field specifically (not just any number) is formatted + // to exactly 2 decimal places, e.g. "half_wave_m": 20.09. + let field = "\"half_wave_m\":"; + let start = content + .find(field) + .expect("json should contain a half_wave_m field") + + field.len(); + let value_tok = content[start..] + .trim_start() + .split([',', '\n', '}']) + .next() + .expect("half_wave_m should have a value") + .trim(); + let decimals = value_tok + .split_once('.') + .map(|(_, frac)| frac.len()) + .unwrap_or(0); + assert_eq!( + decimals, 2, + "half_wave_m ({value_tok}) should be formatted to exactly 2 decimal places" ); }