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); + } + }); +});