fix: expose physical aliasing and delayed arrivals
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
export const MAX_PRECISE_ANALOG_FREQUENCY_HZ = 1_000_000;
|
||||
export const MAX_EXPLICIT_FOLD_SEGMENTS = 513;
|
||||
|
||||
function requireSampleRate(sampleRate) {
|
||||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
|
||||
throw new RangeError("sampleRate must be a positive finite number.");
|
||||
}
|
||||
return sampleRate;
|
||||
}
|
||||
|
||||
function requireFrequency(frequencyHz, label = "frequencyHz") {
|
||||
if (
|
||||
!Number.isFinite(frequencyHz) ||
|
||||
frequencyHz < 0 ||
|
||||
frequencyHz > MAX_PRECISE_ANALOG_FREQUENCY_HZ
|
||||
) {
|
||||
throw new RangeError(
|
||||
`${label} must be between 0 and ${MAX_PRECISE_ANALOG_FREQUENCY_HZ} Hz.`,
|
||||
);
|
||||
}
|
||||
return frequencyHz;
|
||||
}
|
||||
|
||||
function positiveModulo(value, modulus) {
|
||||
const remainder = value % modulus;
|
||||
return remainder < 0 ? remainder + modulus : remainder;
|
||||
}
|
||||
|
||||
function normalizedZero(value) {
|
||||
return Object.is(value, -0) ? 0 : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the frequency that an ideal real-valued sampler records.
|
||||
*
|
||||
* No anti-alias filter is implied. A negative signed ADC frequency means that
|
||||
* the positive-frequency spectrum is mirrored when represented on 0..Nyquist.
|
||||
*/
|
||||
export function describeFrequencyAliasing(frequencyHz, sampleRate) {
|
||||
requireFrequency(frequencyHz);
|
||||
requireSampleRate(sampleRate);
|
||||
const nyquistHz = sampleRate / 2;
|
||||
const wrappedHz = positiveModulo(frequencyHz, sampleRate);
|
||||
const signedAdcFrequencyHz =
|
||||
wrappedHz <= nyquistHz ? wrappedHz : wrappedHz - sampleRate;
|
||||
const adcFrequencyHz = Math.abs(signedAdcFrequencyHz);
|
||||
const dcEdge = adcFrequencyHz === 0;
|
||||
const nyquistEdge = adcFrequencyHz === nyquistHz;
|
||||
return {
|
||||
model: "ideal-real-sampler-no-antialias-filter",
|
||||
sampleRate,
|
||||
nyquistHz,
|
||||
analogFrequencyHz: frequencyHz,
|
||||
adcFrequencyHz: normalizedZero(adcFrequencyHz),
|
||||
signedAdcFrequencyHz: normalizedZero(signedAdcFrequencyHz),
|
||||
aliased: frequencyHz > nyquistHz,
|
||||
spectralInversion: signedAdcFrequencyHz < 0,
|
||||
dcEdge,
|
||||
nyquistEdge,
|
||||
phaseDegenerate: dcEdge || nyquistEdge,
|
||||
};
|
||||
}
|
||||
|
||||
export function foldFrequencyToNyquist(frequencyHz, sampleRate) {
|
||||
return describeFrequencyAliasing(frequencyHz, sampleRate).adcFrequencyHz;
|
||||
}
|
||||
|
||||
function containsResidue(first, last, residue, modulus) {
|
||||
const firstResidue = positiveModulo(first, modulus);
|
||||
const offset = positiveModulo(residue - firstResidue, modulus);
|
||||
return first + offset <= last;
|
||||
}
|
||||
|
||||
function contiguousBinRanges(bins, binWidthHz) {
|
||||
const ranges = [];
|
||||
let firstBin = bins[0];
|
||||
let lastBin = firstBin;
|
||||
for (let index = 1; index <= bins.length; index += 1) {
|
||||
const bin = bins[index];
|
||||
if (bin === lastBin + 1) {
|
||||
lastBin = bin;
|
||||
continue;
|
||||
}
|
||||
ranges.push({
|
||||
firstBin,
|
||||
lastBin,
|
||||
firstBinCenterHz: firstBin * binWidthHz,
|
||||
lastBinCenterHz: lastBin * binWidthHz,
|
||||
});
|
||||
firstBin = bin;
|
||||
lastBin = bin;
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function foldBandSegments(minHz, maxHz, sampleRate) {
|
||||
const nyquistHz = sampleRate / 2;
|
||||
const firstZoneIndex = Math.floor(minHz / nyquistHz);
|
||||
const lastZoneIndex = Math.ceil(maxHz / nyquistHz) - 1;
|
||||
const segmentCount = lastZoneIndex - firstZoneIndex + 1;
|
||||
if (
|
||||
!Number.isSafeInteger(firstZoneIndex) ||
|
||||
!Number.isSafeInteger(lastZoneIndex) ||
|
||||
!Number.isSafeInteger(segmentCount) ||
|
||||
segmentCount < 1 ||
|
||||
segmentCount > MAX_EXPLICIT_FOLD_SEGMENTS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`band crosses ${segmentCount} Nyquist zones; the explicit limit is ` +
|
||||
`${MAX_EXPLICIT_FOLD_SEGMENTS}.`,
|
||||
);
|
||||
}
|
||||
const segments = [];
|
||||
let analogStartHz = minHz;
|
||||
while (analogStartHz < maxHz) {
|
||||
const zoneIndex = Math.floor(analogStartHz / nyquistHz);
|
||||
const nyquistZone = zoneIndex + 1;
|
||||
const nextBoundaryHz = nyquistZone * nyquistHz;
|
||||
const analogEndHz = Math.min(maxHz, nextBoundaryHz);
|
||||
if (!(analogEndHz > analogStartHz)) {
|
||||
throw new RangeError("band is too large to retain Nyquist-zone precision.");
|
||||
}
|
||||
const inverted = nyquistZone % 2 === 0;
|
||||
const signedAdcStartHz = inverted
|
||||
? analogStartHz - nyquistZone * nyquistHz
|
||||
: analogStartHz - (nyquistZone - 1) * nyquistHz;
|
||||
const signedAdcEndHz = inverted
|
||||
? analogEndHz - nyquistZone * nyquistHz
|
||||
: analogEndHz - (nyquistZone - 1) * nyquistHz;
|
||||
segments.push({
|
||||
analogMinHz: analogStartHz,
|
||||
analogMaxHz: analogEndHz,
|
||||
signedAdcStartHz: normalizedZero(signedAdcStartHz),
|
||||
signedAdcEndHz: normalizedZero(signedAdcEndHz),
|
||||
inverted,
|
||||
aliasOrder: Math.floor(nyquistZone / 2),
|
||||
nyquistZone,
|
||||
});
|
||||
analogStartHz = analogEndHz;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function hasContinuousFoldOverlap(segments) {
|
||||
const adcRanges = segments.map((segment) => ({
|
||||
minHz: Math.min(
|
||||
Math.abs(segment.signedAdcStartHz),
|
||||
Math.abs(segment.signedAdcEndHz),
|
||||
),
|
||||
maxHz: Math.max(
|
||||
Math.abs(segment.signedAdcStartHz),
|
||||
Math.abs(segment.signedAdcEndHz),
|
||||
),
|
||||
})).sort((left, right) => left.minHz - right.minHz || left.maxHz - right.maxHz);
|
||||
let maximumEndHz = -Infinity;
|
||||
for (const range of adcRanges) {
|
||||
if (range.minHz < maximumEndHz) return true;
|
||||
maximumEndHz = Math.max(maximumEndHz, range.maxHz);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function adcBinSetKey(sampleRate, fftSize, ranges) {
|
||||
const rangeKey = ranges
|
||||
.map(({ firstBin, lastBin }) =>
|
||||
firstBin === lastBin ? String(firstBin) : `${firstBin}-${lastBin}`)
|
||||
.join(",");
|
||||
return `fs:${sampleRate}|fft:${fftSize}|bins:${rangeKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a requested pre-sampling frequency interval to the observable one-sided
|
||||
* real FFT bins. Every matching bin is retained; this function does not choose
|
||||
* an alias order or steer with the original carrier.
|
||||
*/
|
||||
export function observeFrequencyBand(band, sampleRate, fftSize) {
|
||||
requireSampleRate(sampleRate);
|
||||
if (!Number.isInteger(fftSize) || fftSize < 2 || fftSize % 2 !== 0) {
|
||||
throw new RangeError("fftSize must be a positive even integer.");
|
||||
}
|
||||
if (!band || typeof band !== "object") {
|
||||
throw new TypeError("band must be an object.");
|
||||
}
|
||||
requireFrequency(band.minHz, "band.minHz");
|
||||
requireFrequency(band.maxHz, "band.maxHz");
|
||||
if (band.minHz >= band.maxHz) {
|
||||
throw new RangeError("band.minHz must be lower than band.maxHz.");
|
||||
}
|
||||
|
||||
const binWidthHz = sampleRate / fftSize;
|
||||
const firstAnalogBin = Math.ceil(band.minHz / binWidthHz);
|
||||
const lastAnalogBin = Math.floor(band.maxHz / binWidthHz);
|
||||
if (
|
||||
!Number.isSafeInteger(firstAnalogBin) ||
|
||||
!Number.isSafeInteger(lastAnalogBin)
|
||||
) {
|
||||
throw new RangeError("band frequency is too large for safe FFT-bin mapping.");
|
||||
}
|
||||
if (firstAnalogBin > lastAnalogBin) {
|
||||
throw new Error(
|
||||
`${band.minHz}–${band.maxHz} Hz contains no FFT-grid frequency at ${binWidthHz} Hz spacing.`,
|
||||
);
|
||||
}
|
||||
|
||||
const bins = [];
|
||||
const halfSize = fftSize / 2;
|
||||
for (let adcBin = 0; adcBin <= halfSize; adcBin += 1) {
|
||||
const positiveResidue = adcBin;
|
||||
const negativeResidue = adcBin === 0 || adcBin === halfSize
|
||||
? adcBin
|
||||
: fftSize - adcBin;
|
||||
if (
|
||||
containsResidue(firstAnalogBin, lastAnalogBin, positiveResidue, fftSize) ||
|
||||
containsResidue(firstAnalogBin, lastAnalogBin, negativeResidue, fftSize)
|
||||
) {
|
||||
bins.push(adcBin);
|
||||
}
|
||||
}
|
||||
if (bins.length === 0) {
|
||||
throw new Error(`${band.minHz}–${band.maxHz} Hz maps to no observable FFT bin.`);
|
||||
}
|
||||
|
||||
const nyquistHz = sampleRate / 2;
|
||||
const adcBinCenterRanges = contiguousBinRanges(bins, binWidthHz);
|
||||
const foldSegments = foldBandSegments(band.minHz, band.maxHz, sampleRate);
|
||||
const analogBinCount = lastAnalogBin - firstAnalogBin + 1;
|
||||
const continuousManyToOne = hasContinuousFoldOverlap(foldSegments);
|
||||
const gridManyToOne = analogBinCount > bins.length;
|
||||
return {
|
||||
model: "ideal-real-sampler-no-antialias-filter",
|
||||
sampleRate,
|
||||
nyquistHz,
|
||||
fftSize,
|
||||
binWidthHz,
|
||||
requestedMinHz: band.minHz,
|
||||
requestedMaxHz: band.maxHz,
|
||||
aliased: band.maxHz > nyquistHz,
|
||||
crossesFirstNyquist: band.minHz < nyquistHz && band.maxHz > nyquistHz,
|
||||
localizationInputModel: "observed-one-sided-real-fft-bins",
|
||||
selectionModel: "fft-bin-center-membership",
|
||||
originalCarrierUsed: false,
|
||||
foldBoundaryCount: Math.max(0, foldSegments.length - 1),
|
||||
continuousManyToOne,
|
||||
gridManyToOne,
|
||||
foldSegments,
|
||||
bins,
|
||||
adcBinCenterRanges,
|
||||
adcBinSetKey: adcBinSetKey(sampleRate, fftSize, adcBinCenterRanges),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user