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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <meters>` — control non-resonant search resolution (default 0.05 m).
- `--velocity <value>` — velocity factor from 0.50 to 1.00 (default 0.95).
- `--velocity <value>` — 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ struct Cli {
region -- CliITURegion (default: 1)
mode -- CliCalcMode (resonant | non-resonant)
bands -- Option<String> (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<CliAntennaModel>
wire_min/max -- Option<f64> meters or feet
Expand Down Expand Up @@ -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

Expand Down
32 changes: 31 additions & 1 deletion docs/math.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

$$
Expand Down Expand Up @@ -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)
$$
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
109 changes: 99 additions & 10 deletions src/calculations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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 Ω
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down
13 changes: 8 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ struct Cli {
#[arg(long)]
bands_config: Option<String>,

/// 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)
Expand Down Expand Up @@ -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::<f64>() {
Ok(v) if (0.5..=1.0).contains(&v) => v,
_ => default.unwrap_or(0.95),
_ => default.unwrap_or(1.0),
}
}

Expand Down
20 changes: 12 additions & 8 deletions src/fnec_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -289,7 +292,8 @@ fn extract_impedance_imag(output: &str) -> Option<f64> {
/// 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();
Expand Down
Loading
Loading