mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Antenna calculator: extract pure math, fix bugs, add tests.
Verify ARRL wire-antenna formulas (936/468/234/585), fix feet/inches
rollover ("N ft 12 in"), make wavelength edits update lengths, and cover
the math with 50 unit tests.
This commit is contained in:
parent
bad16684a4
commit
2fbf0a9b0f
2 changed files with 545 additions and 33 deletions
|
|
@ -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() {
|
|||
</Grid.Row>
|
||||
</Grid>
|
||||
|
||||
{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 <Table key={i.name} size='large' striped unstackable>
|
||||
|
|
@ -155,4 +317,4 @@ export function DipoleAntennaCalculator() {
|
|||
</Table>
|
||||
})}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
350
app/src/components/calculators/HamCalculators.test.js
Normal file
350
app/src/components/calculators/HamCalculators.test.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue