diff --git a/app/src/components/calculators/HamCalculators.js b/app/src/components/calculators/HamCalculators.js
index 4b037fe7..7685a429 100644
--- a/app/src/components/calculators/HamCalculators.js
+++ b/app/src/components/calculators/HamCalculators.js
@@ -5,69 +5,231 @@ import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {roundDigits} from "../Common";
import {ColoredInput} from "../Apps";
-function convertFeetToFeetAndInches(decimalFeet) {
- // Extract the integer part for feet
- const feet = Math.floor(decimalFeet);
- // Calculate the remainder in feet and convert it to inches (1 foot = 12 inches)
- const inches = Math.round((decimalFeet - feet) * 12);
+// ---------------------------------------------------------------------------
+// Pure antenna math
+//
+// Wire antennas are shorter than free-space wavelength by ~5% (end effect).
+// The ARRL / ham-radio constants are:
+// full-wave length (ft) = 936 / f(MHz)
+// half-wave dipole (ft) = 468 / f(MHz) = full * 0.5
+// quarter-wave (ft) = 234 / f(MHz) = full * 0.25
+// 5/8-wave (ft) = 585 / f(MHz) = full * 0.625
+// Free-space wavelength uses the exact speed of light and is shown separately.
+// ---------------------------------------------------------------------------
+/** Speed of light in free space (m/s). */
+export const SPEED_OF_LIGHT = 299792458;
+
+/**
+ * Feet of wire for one full electrical wavelength, including end-effect.
+ * Free-space equivalent is ~984 / f; 936/984 ≈ 0.95 velocity factor.
+ */
+export const FULL_WAVE_FEET_PER_MHZ = 936;
+
+/** Common antenna fractions shown in the calculator. */
+export const ANTENNA_FRACTIONS = [
+ {name: '½ Antenna Length', multiplier: 0.5},
+ {name: '¼ Antenna Length', multiplier: 0.25},
+ {name: '⅝ Antenna Length', multiplier: 0.625},
+];
+
+/**
+ * Convert a decimal foot length into whole feet and inches.
+ * Rounds to the nearest inch and carries 12 inches into an extra foot
+ * (e.g. 3.96 ft → 4 ft 0 in, never "3 ft 12 in").
+ *
+ * @param {number} decimalFeet
+ * @returns {[number, number]} [feet, inches]
+ */
+export function convertFeetToFeetAndInches(decimalFeet) {
+ if (!Number.isFinite(decimalFeet) || decimalFeet < 0) {
+ return [0, 0];
+ }
+ const totalInches = Math.round(decimalFeet * 12);
+ const feet = Math.floor(totalInches / 12);
+ const inches = totalInches % 12;
return [feet, inches];
}
+/**
+ * Full-wave wire-antenna length in feet for a given frequency in MHz.
+ * Returns null when frequency is not a positive finite number.
+ *
+ * @param {number} mhz
+ * @returns {number|null}
+ */
+export function fullWaveFeet(mhz) {
+ if (!Number.isFinite(mhz) || !(mhz > 0)) {
+ return null;
+ }
+ return FULL_WAVE_FEET_PER_MHZ / mhz;
+}
+
+/**
+ * Free-space wavelength in meters for a given frequency in MHz.
+ * λ = c / f. Returns null when frequency is not positive.
+ *
+ * @param {number} mhz
+ * @returns {number|null}
+ */
+export function freeSpaceWavelengthMeters(mhz) {
+ if (!Number.isFinite(mhz) || !(mhz > 0)) {
+ return null;
+ }
+ return SPEED_OF_LIGHT / (mhz * 1e6);
+}
+
+/**
+ * Frequency in MHz from a free-space wavelength in meters.
+ * f = c / λ. Returns null when wavelength is not positive.
+ *
+ * @param {number} meters
+ * @returns {number|null}
+ */
+export function frequencyMhzFromWavelengthMeters(meters) {
+ if (!Number.isFinite(meters) || !(meters > 0)) {
+ return null;
+ }
+ return SPEED_OF_LIGHT / meters / 1e6;
+}
+
+/**
+ * Convert a full-wave length in feet into the unit set used by the UI.
+ *
+ * @param {number} feetValue - full-wave length in feet
+ * @returns {{feet: number, inches: number, meters: number, cm: number}}
+ */
+export function lengthUnitsFromFeet(feetValue) {
+ const meters = feetValue * 0.3048;
+ return {
+ feet: feetValue,
+ inches: feetValue * 12,
+ meters,
+ cm: meters * 100,
+ };
+}
+
+/**
+ * Scale a length-unit set by a fraction (0.5 half-wave, 0.25 quarter, etc.).
+ *
+ * @param {{feet: number, inches: number, meters: number, cm: number}} units
+ * @param {number} multiplier
+ * @returns {{feet: number, inches: number, meters: number, cm: number}}
+ */
+export function scaleLengthUnits(units, multiplier) {
+ return {
+ feet: units.feet * multiplier,
+ inches: units.inches * multiplier,
+ meters: units.meters * multiplier,
+ cm: units.cm * multiplier,
+ };
+}
+
+/**
+ * All length units for the full-wave wire antenna at a given frequency.
+ * Returns null when frequency is invalid.
+ *
+ * @param {number} mhz
+ * @returns {{feet: number, inches: number, meters: number, cm: number}|null}
+ */
+export function fullWaveLengthUnits(mhz) {
+ const feet = fullWaveFeet(mhz);
+ if (feet === null) {
+ return null;
+ }
+ return lengthUnitsFromFeet(feet);
+}
+
+/**
+ * Antenna total length and per-leg length for a fraction of a full wave.
+ * "Leg length" is half the total (center-fed dipole legs).
+ *
+ * @param {number} mhz
+ * @param {number} multiplier - e.g. 0.5 for half-wave
+ * @returns {{
+ * total: {feet: number, inches: number, meters: number, cm: number, feetInches: [number, number]},
+ * leg: {feet: number, inches: number, meters: number, cm: number, feetInches: [number, number]},
+ * }|null}
+ */
+export function antennaFractionLengths(mhz, multiplier) {
+ const full = fullWaveLengthUnits(mhz);
+ if (full === null || !Number.isFinite(multiplier)) {
+ return null;
+ }
+ const total = scaleLengthUnits(full, multiplier);
+ const leg = scaleLengthUnits(total, 0.5);
+ return {
+ total: {
+ ...total,
+ feetInches: convertFeetToFeetAndInches(total.feet),
+ },
+ leg: {
+ ...leg,
+ feetInches: convertFeetToFeetAndInches(leg.feet),
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// UI
+// ---------------------------------------------------------------------------
+
const defaultMhzInputValue = '144.2';
export function DipoleAntennaCalculator() {
const [mhz, setMhz] = React.useState(144.2);
- const [wave, setWave] = React.useState(2.08);
const [mhzInputValue, setMhzInputValue] = React.useState(defaultMhzInputValue);
const [waveInputValue, setWaveInputValue] = React.useState(0);
const [feet, setFeet] = React.useState(0);
const [inches, setInches] = React.useState(0);
const [meters, setMeters] = React.useState(0);
const [cm, setCM] = React.useState(0);
- const c = 299792458;
-
- // Reduce the full wavelength by the multiplier.
- const lengths = [
- {name: '½ Antenna Length', multiplier: 0.5},
- {name: '¼ Antenna Length', multiplier: 0.25},
- {name: '⅝ Antenna Length', multiplier: 0.625},
- ];
React.useEffect(() => {
- // Calculate full wavelength.
- const newFeet = 936 / mhz;
- setFeet(newFeet);
- setInches(newFeet * 12);
- setMeters(newFeet * 0.3048);
- setCM(newFeet * 0.3048 * 100);
- setWaveInputValue(roundDigits(c / (mhz * 1e6)));
- }, [mhz]);
-
- React.useEffect(() => {
- if (wave && parseFloat(wave) > 0) {
- setMhzInputValue(roundDigits(c / parseFloat(wave) / 1e6));
+ const units = fullWaveLengthUnits(mhz);
+ if (!units) {
+ setFeet(0);
+ setInches(0);
+ setMeters(0);
+ setCM(0);
+ setWaveInputValue('');
+ return;
}
- }, [wave])
+ setFeet(units.feet);
+ setInches(units.inches);
+ setMeters(units.meters);
+ setCM(units.cm);
+ setWaveInputValue(roundDigits(freeSpaceWavelengthMeters(mhz)));
+ }, [mhz]);
const mhzStorageKey = 'dipole_mhz';
const storageMhzValue = localStorage.getItem(mhzStorageKey);
React.useEffect(() => {
if (storageMhzValue) {
- setMhz(parseFloat(storageMhzValue));
- setMhzInputValue(storageMhzValue);
+ const parsed = parseFloat(storageMhzValue);
+ if (parsed > 0) {
+ setMhz(parsed);
+ setMhzInputValue(storageMhzValue);
+ }
}
}, []);
const handleMhzChange = (e, {value}) => {
setMhzInputValue(value);
- setMhz(value && parseFloat(value) > 0 ? parseFloat(value) : 0);
+ const parsed = parseFloat(value);
+ setMhz(value && parsed > 0 ? parsed : 0);
localStorage.setItem(mhzStorageKey, value);
}
const handleWaveChange = (e, {value}) => {
setWaveInputValue(value);
- setWave(value && parseFloat(value) > 0 ? parseFloat(value) : 0);
+ const parsed = parseFloat(value);
+ if (value && parsed > 0) {
+ const newMhz = frequencyMhzFromWavelengthMeters(parsed);
+ setMhz(newMhz);
+ setMhzInputValue(roundDigits(newMhz));
+ localStorage.setItem(mhzStorageKey, String(roundDigits(newMhz)));
+ }
}
const [mhzSelected, setMhzSelected] = React.useState(true);
@@ -107,7 +269,7 @@ export function DipoleAntennaCalculator() {
- {lengths.map(i => {
+ {ANTENNA_FRACTIONS.map(i => {
const [feet_, inches_] = convertFeetToFeetAndInches(feet * i.multiplier);
const [half_feet, half_inches] = convertFeetToFeetAndInches(feet * i.multiplier / 2);
return
@@ -155,4 +317,4 @@ export function DipoleAntennaCalculator() {
})}
>
-}
\ No newline at end of file
+}
diff --git a/app/src/components/calculators/HamCalculators.test.js b/app/src/components/calculators/HamCalculators.test.js
new file mode 100644
index 00000000..b0c38ebd
--- /dev/null
+++ b/app/src/components/calculators/HamCalculators.test.js
@@ -0,0 +1,350 @@
+import {
+ ANTENNA_FRACTIONS,
+ antennaFractionLengths,
+ convertFeetToFeetAndInches,
+ freeSpaceWavelengthMeters,
+ frequencyMhzFromWavelengthMeters,
+ FULL_WAVE_FEET_PER_MHZ,
+ fullWaveFeet,
+ fullWaveLengthUnits,
+ lengthUnitsFromFeet,
+ scaleLengthUnits,
+ SPEED_OF_LIGHT,
+} from "./HamCalculators";
+
+// ---------------------------------------------------------------------------
+// Constants and formula relationships
+// ---------------------------------------------------------------------------
+
+describe('antenna constants', () => {
+ test('speed of light matches the SI definition', () => {
+ expect(SPEED_OF_LIGHT).toBe(299792458);
+ });
+
+ test('full-wave constant is the standard ARRL 936 ft·MHz value', () => {
+ expect(FULL_WAVE_FEET_PER_MHZ).toBe(936);
+ });
+
+ test('936 implies the classic half/quarter/5-8 constants', () => {
+ // Half-wave dipole: 468 / f
+ expect(FULL_WAVE_FEET_PER_MHZ * 0.5).toBe(468);
+ // Quarter-wave: 234 / f
+ expect(FULL_WAVE_FEET_PER_MHZ * 0.25).toBe(234);
+ // 5/8-wave: 585 / f
+ expect(FULL_WAVE_FEET_PER_MHZ * 0.625).toBe(585);
+ });
+
+ test('end-effect velocity factor is about 0.95 vs free-space 984', () => {
+ // Free-space full-wave feet ≈ 984 / f (c ≈ 983.57 ft/µs).
+ // Wire antennas use 936 / f → VF ≈ 0.951.
+ expect(FULL_WAVE_FEET_PER_MHZ / 984).toBeCloseTo(0.95, 2);
+ });
+
+ test('ANTENNA_FRACTIONS cover half, quarter, and 5/8 wave', () => {
+ const multipliers = ANTENNA_FRACTIONS.map(f => f.multiplier).sort();
+ expect(multipliers).toEqual([0.25, 0.5, 0.625]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// convertFeetToFeetAndInches
+// ---------------------------------------------------------------------------
+
+describe('convertFeetToFeetAndInches', () => {
+ test('splits whole feet with zero inches', () => {
+ expect(convertFeetToFeetAndInches(10)).toEqual([10, 0]);
+ expect(convertFeetToFeetAndInches(0)).toEqual([0, 0]);
+ });
+
+ test('converts fractional feet to nearest inch', () => {
+ // 3.245 ft * 12 = 38.94 in → 3 ft 3 in
+ expect(convertFeetToFeetAndInches(3.245)).toEqual([3, 3]);
+ // 1.6227 ft * 12 = 19.47 in → 1 ft 7 in
+ expect(convertFeetToFeetAndInches(1.6227)).toEqual([1, 7]);
+ // 32.958 ft * 12 = 395.5 in → 32 ft 11 in (rounds half up via Math.round)
+ expect(convertFeetToFeetAndInches(32.958)).toEqual([32, 11]);
+ });
+
+ test('never reports 12 inches — rolls over to the next foot', () => {
+ // Values whose fractional part * 12 rounds to 12 (was a bug: "3 ft 12 in").
+ expect(convertFeetToFeetAndInches(3.9584)).toEqual([4, 0]);
+ expect(convertFeetToFeetAndInches(3.999)).toEqual([4, 0]);
+ expect(convertFeetToFeetAndInches(399.99999999999994)).toEqual([400, 0]);
+ // Exactly 11.5/12 feet fractional part rounds to 12 inches → +1 foot.
+ expect(convertFeetToFeetAndInches(5 + 11.5 / 12)).toEqual([6, 0]);
+ });
+
+ test('handles just-under and just-over an inch boundary', () => {
+ // 1/12 ft = 1 in exactly
+ expect(convertFeetToFeetAndInches(1 / 12)).toEqual([0, 1]);
+ // Slightly under half an inch rounds down
+ expect(convertFeetToFeetAndInches(0.04)).toEqual([0, 0]); // 0.48 in
+ // Slightly over half an inch rounds up
+ expect(convertFeetToFeetAndInches(0.05)).toEqual([0, 1]); // 0.60 in
+ });
+
+ test('returns [0, 0] for non-positive or non-finite input', () => {
+ expect(convertFeetToFeetAndInches(-1)).toEqual([0, 0]);
+ expect(convertFeetToFeetAndInches(NaN)).toEqual([0, 0]);
+ expect(convertFeetToFeetAndInches(Infinity)).toEqual([0, 0]);
+ expect(convertFeetToFeetAndInches(-Infinity)).toEqual([0, 0]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// fullWaveFeet / free-space wavelength / frequency conversion
+// ---------------------------------------------------------------------------
+
+describe('fullWaveFeet', () => {
+ test('returns 936 / f for valid frequencies', () => {
+ expect(fullWaveFeet(1)).toBe(936);
+ expect(fullWaveFeet(144.2)).toBeCloseTo(936 / 144.2, 10);
+ expect(fullWaveFeet(14.2)).toBeCloseTo(936 / 14.2, 10);
+ });
+
+ test('returns null for invalid frequencies', () => {
+ expect(fullWaveFeet(0)).toBeNull();
+ expect(fullWaveFeet(-7)).toBeNull();
+ expect(fullWaveFeet(NaN)).toBeNull();
+ expect(fullWaveFeet(Infinity)).toBeNull();
+ });
+});
+
+describe('freeSpaceWavelengthMeters', () => {
+ test('uses λ = c / f', () => {
+ // 300 MHz → exactly 0.999308 m with the exact speed of light
+ expect(freeSpaceWavelengthMeters(300)).toBeCloseTo(SPEED_OF_LIGHT / 3e8, 10);
+ // 144.2 MHz 2 m band — free-space wavelength is a bit over 2 m
+ expect(freeSpaceWavelengthMeters(144.2)).toBeCloseTo(2.0790, 3);
+ // 14.2 MHz 20 m band
+ expect(freeSpaceWavelengthMeters(14.2)).toBeCloseTo(21.1121, 3);
+ });
+
+ test('matches the common 300/f approximation within 0.1%', () => {
+ // Amateur radio often approximates c as 300 m/µs.
+ for (const mhz of [7.15, 14.2, 146, 440]) {
+ const exact = freeSpaceWavelengthMeters(mhz);
+ const approx = 300 / mhz;
+ expect(Math.abs(exact - approx) / exact).toBeLessThan(0.001);
+ }
+ });
+
+ test('returns null for invalid frequencies', () => {
+ expect(freeSpaceWavelengthMeters(0)).toBeNull();
+ expect(freeSpaceWavelengthMeters(-1)).toBeNull();
+ expect(freeSpaceWavelengthMeters(NaN)).toBeNull();
+ });
+});
+
+describe('frequencyMhzFromWavelengthMeters', () => {
+ test('is the inverse of freeSpaceWavelengthMeters', () => {
+ for (const mhz of [1.9, 7.15, 14.2, 28.5, 50.1, 144.2, 146, 440, 446]) {
+ const lambda = freeSpaceWavelengthMeters(mhz);
+ expect(frequencyMhzFromWavelengthMeters(lambda)).toBeCloseTo(mhz, 8);
+ }
+ });
+
+ test('round-trips a known free-space wavelength', () => {
+ // 2 m free-space → 149.896 MHz
+ expect(frequencyMhzFromWavelengthMeters(2)).toBeCloseTo(SPEED_OF_LIGHT / 2e6, 8);
+ });
+
+ test('returns null for invalid wavelengths', () => {
+ expect(frequencyMhzFromWavelengthMeters(0)).toBeNull();
+ expect(frequencyMhzFromWavelengthMeters(-2)).toBeNull();
+ expect(frequencyMhzFromWavelengthMeters(NaN)).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Unit conversion helpers
+// ---------------------------------------------------------------------------
+
+describe('lengthUnitsFromFeet / scaleLengthUnits', () => {
+ test('converts feet to inches, meters, and cm with standard factors', () => {
+ const units = lengthUnitsFromFeet(10);
+ expect(units.feet).toBe(10);
+ expect(units.inches).toBe(120);
+ expect(units.meters).toBeCloseTo(3.048, 10);
+ expect(units.cm).toBeCloseTo(304.8, 10);
+ });
+
+ test('scaleLengthUnits multiplies every unit by the same factor', () => {
+ const full = lengthUnitsFromFeet(100);
+ const half = scaleLengthUnits(full, 0.5);
+ expect(half.feet).toBe(50);
+ expect(half.inches).toBe(600);
+ expect(half.meters).toBeCloseTo(15.24, 10);
+ expect(half.cm).toBeCloseTo(1524, 10);
+ });
+
+ test('scaling is linear across the standard antenna fractions', () => {
+ const full = lengthUnitsFromFeet(936); // 1 MHz full-wave
+ for (const {multiplier} of ANTENNA_FRACTIONS) {
+ const scaled = scaleLengthUnits(full, multiplier);
+ expect(scaled.feet).toBeCloseTo(936 * multiplier, 10);
+ expect(scaled.inches).toBeCloseTo(full.inches * multiplier, 10);
+ expect(scaled.meters).toBeCloseTo(full.meters * multiplier, 10);
+ expect(scaled.cm).toBeCloseTo(full.cm * multiplier, 10);
+ }
+ });
+});
+
+describe('fullWaveLengthUnits', () => {
+ test('returns consistent unit set for a valid frequency', () => {
+ const units = fullWaveLengthUnits(144.2);
+ expect(units.feet).toBeCloseTo(936 / 144.2, 10);
+ expect(units.inches).toBeCloseTo(units.feet * 12, 10);
+ expect(units.meters).toBeCloseTo(units.feet * 0.3048, 10);
+ expect(units.cm).toBeCloseTo(units.meters * 100, 10);
+ });
+
+ test('returns null for invalid frequency', () => {
+ expect(fullWaveLengthUnits(0)).toBeNull();
+ expect(fullWaveLengthUnits(-14.2)).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Half / quarter / 5/8 wave lengths (the UI tables)
+// ---------------------------------------------------------------------------
+
+describe('antennaFractionLengths — half-wave dipole (468/f)', () => {
+ // Published / well-known half-wave dipole lengths.
+ test.each([
+ // [MHz, expected total feet, expected leg feet]
+ [1.9, 468 / 1.9, 234 / 1.9], // 160 m
+ [3.8, 468 / 3.8, 234 / 3.8], // 80 m
+ [7.15, 468 / 7.15, 234 / 7.15], // 40 m
+ [14.2, 468 / 14.2, 234 / 14.2], // 20 m
+ [21.2, 468 / 21.2, 234 / 21.2], // 15 m
+ [28.5, 468 / 28.5, 234 / 28.5], // 10 m
+ [50.1, 468 / 50.1, 234 / 50.1], // 6 m
+ [144.2, 468 / 144.2, 234 / 144.2], // 2 m (default in UI)
+ [146, 468 / 146, 234 / 146], // 2 m simplex
+ [440, 468 / 440, 234 / 440], // 70 cm
+ ])('half-wave at %s MHz is 468/f total and 234/f per leg', (mhz, totalFeet, legFeet) => {
+ const result = antennaFractionLengths(mhz, 0.5);
+ expect(result.total.feet).toBeCloseTo(totalFeet, 8);
+ expect(result.leg.feet).toBeCloseTo(legFeet, 8);
+ // Leg is exactly half the total.
+ expect(result.leg.feet).toBeCloseTo(result.total.feet / 2, 10);
+ });
+
+ test('default 144.2 MHz half-wave displays as 3 ft 3 in total, 1 ft 7 in per leg', () => {
+ const result = antennaFractionLengths(144.2, 0.5);
+ expect(result.total.feetInches).toEqual([3, 3]);
+ expect(result.leg.feetInches).toEqual([1, 7]);
+ });
+
+ test('14.2 MHz half-wave is about 32 ft 11 in total', () => {
+ const result = antennaFractionLengths(14.2, 0.5);
+ expect(result.total.feetInches).toEqual([32, 11]);
+ });
+});
+
+describe('antennaFractionLengths — quarter-wave (234/f)', () => {
+ test.each([
+ [146, 234 / 146],
+ [144.2, 234 / 144.2],
+ [14.2, 234 / 14.2],
+ [7.15, 234 / 7.15],
+ ])('quarter-wave at %s MHz is 234/f', (mhz, expectedFeet) => {
+ const result = antennaFractionLengths(mhz, 0.25);
+ expect(result.total.feet).toBeCloseTo(expectedFeet, 8);
+ // "Leg" is still half of the shown total (center-fed construction).
+ expect(result.leg.feet).toBeCloseTo(expectedFeet / 2, 8);
+ });
+});
+
+describe('antennaFractionLengths — 5/8-wave (585/f)', () => {
+ test.each([
+ [146, 585 / 146],
+ [144.2, 585 / 144.2],
+ [50.1, 585 / 50.1],
+ [440, 585 / 440],
+ ])('5/8-wave at %s MHz is 585/f', (mhz, expectedFeet) => {
+ const result = antennaFractionLengths(mhz, 0.625);
+ expect(result.total.feet).toBeCloseTo(expectedFeet, 8);
+ expect(result.leg.feet).toBeCloseTo(expectedFeet / 2, 8);
+ });
+});
+
+describe('antennaFractionLengths — metric consistency', () => {
+ test('inches, meters, and cm are consistent with feet for every fraction', () => {
+ for (const {multiplier} of ANTENNA_FRACTIONS) {
+ const result = antennaFractionLengths(14.2, multiplier);
+ for (const part of [result.total, result.leg]) {
+ expect(part.inches).toBeCloseTo(part.feet * 12, 8);
+ expect(part.meters).toBeCloseTo(part.feet * 0.3048, 8);
+ expect(part.cm).toBeCloseTo(part.meters * 100, 8);
+ // feetInches display matches the decimal feet value.
+ const [ft, inch] = part.feetInches;
+ expect(ft * 12 + inch).toBeCloseTo(Math.round(part.feet * 12), 0);
+ }
+ }
+ });
+
+ test('returns null for invalid frequency or multiplier', () => {
+ expect(antennaFractionLengths(0, 0.5)).toBeNull();
+ expect(antennaFractionLengths(-14, 0.5)).toBeNull();
+ expect(antennaFractionLengths(NaN, 0.5)).toBeNull();
+ expect(antennaFractionLengths(144.2, NaN)).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Cross-checks against free-space physics
+// ---------------------------------------------------------------------------
+
+describe('end-effect relationship to free space', () => {
+ test('wire half-wave is ~95% of free-space half-wavelength in feet', () => {
+ for (const mhz of [7.15, 14.2, 28.5, 144.2, 440]) {
+ const freeSpaceMeters = freeSpaceWavelengthMeters(mhz);
+ const freeSpaceHalfFeet = (freeSpaceMeters / 2) / 0.3048;
+ const wireHalfFeet = antennaFractionLengths(mhz, 0.5).total.feet;
+ // 468/f vs 492/f ≈ 0.951 (same VF as 936/984)
+ expect(wireHalfFeet / freeSpaceHalfFeet).toBeCloseTo(0.95, 2);
+ }
+ });
+
+ test('wire lengths are always shorter than free-space equivalents', () => {
+ for (const mhz of [3.8, 14.2, 146]) {
+ const freeSpaceFeet = freeSpaceWavelengthMeters(mhz) / 0.3048;
+ const wireFullFeet = fullWaveFeet(mhz);
+ expect(wireFullFeet).toBeLessThan(freeSpaceFeet);
+ expect(wireFullFeet).toBeCloseTo(freeSpaceFeet * (936 / (SPEED_OF_LIGHT * 3.280839895 / 1e6)), 2);
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Sanity across the HF / VHF / UHF amateur bands
+// ---------------------------------------------------------------------------
+
+describe('band sanity checks', () => {
+ test('higher frequency always yields shorter antennas', () => {
+ const bands = [1.9, 3.8, 7.15, 14.2, 21.2, 28.5, 50.1, 144.2, 440];
+ for (let i = 1; i < bands.length; i++) {
+ const lower = fullWaveFeet(bands[i - 1]);
+ const higher = fullWaveFeet(bands[i]);
+ expect(higher).toBeLessThan(lower);
+ }
+ });
+
+ test('half-wave is exactly twice quarter-wave at every frequency', () => {
+ for (const mhz of [7.15, 14.2, 146, 440]) {
+ const half = antennaFractionLengths(mhz, 0.5).total.feet;
+ const quarter = antennaFractionLengths(mhz, 0.25).total.feet;
+ expect(half).toBeCloseTo(quarter * 2, 10);
+ }
+ });
+
+ test('5/8-wave is 1.25× half-wave at every frequency', () => {
+ for (const mhz of [50.1, 146, 440]) {
+ const half = antennaFractionLengths(mhz, 0.5).total.feet;
+ const fiveEighths = antennaFractionLengths(mhz, 0.625).total.feet;
+ expect(fiveEighths).toBeCloseTo(half * 1.25, 10);
+ }
+ });
+});