Skip to content

Doppler Shift

Calculating Doppler shift for satellite communications, tracking frequency changes during a pass, and applying Doppler corrections. Use this when predicting the received frequency of a satellite downlink or planning transceiver tuning for a ground station.

ts
import {
  Degrees,
  dopplerFactor,
  GroundStation,
  Kilometers,
  Satellite,
  TleLine1,
  TleLine2,
} from 'ootk';

Run it

bash
npm run build
npx tsx ./examples/doppler.ts

Setup

A GroundStation acts as the observer and a Satellite (built from a TLE) as the transmitter. The carrier frequency is plain Hz; there is no branded frequency type.

ts
// Create the ground station (the observer) and the ISS (the transmitter).
const groundStation = new GroundStation({
  lat: 41.754785 as Degrees,
  lon: -70.539151 as Degrees,
  alt: 0.060966 as Kilometers,
  name: 'Cape Cod Ground Station',
});

const iss = new Satellite({
  tle1: '1 25544U 98067A   24028.54545847  .00031576  00000-0  57240-3 0  9991' as TleLine1,
  tle2: '2 25544  51.6418 292.2590 0002595 167.5319 252.0460 15.49326324436741' as TleLine2,
});

const transmitFreq = 437.8e6; // 437.8 MHz (UHF amateur radio frequency)
const date = new Date('2024-01-28T12:00:00.000Z');

console.log(`Ground Station: ${groundStation.name}`);
console.log(`Transmit Frequency: ${(transmitFreq / 1e6).toFixed(1)} MHz`);
console.log(`Time: ${date.toISOString()}\n`);

Basic Doppler

Satellite.dopplerFactor(observer, date) returns the multiplicative factor (1 minus range-rate over c), and applyDoppler(freq, observer, date) multiplies a carrier by it. Both return null if SGP4 propagation fails at the requested time, so guard the results. A factor below 1 means the satellite is receding (negative shift).

ts
console.log('=== Example 1: Basic Doppler Shift Calculation ===\n');

// Satellite.dopplerFactor() and applyDoppler() return null if propagation
// fails, so guard the results before using them.
const doppler = iss.dopplerFactor(groundStation, date);
const receivedFreq = iss.applyDoppler(transmitFreq, groundStation, date);

if (doppler === null || receivedFreq === null) {
  throw new Error('Failed to propagate satellite for Doppler calculation');
}

const freqShift = receivedFreq - transmitFreq;

console.log('Doppler Calculations:');
console.log(`  Doppler Factor: ${doppler.toFixed(8)}`);
console.log(`  Transmit Frequency: ${(transmitFreq / 1e6).toFixed(4)} MHz`);
console.log(`  Received Frequency: ${(receivedFreq / 1e6).toFixed(4)} MHz`);
console.log(`  Frequency Shift: ${(freqShift / 1e3).toFixed(2)} kHz`);

// Get look angles for context
const rae = groundStation.rae(iss, date);

if (rae) {
  console.log('\nSatellite Position:');
  console.log(`  Azimuth: ${rae.az.toFixed(1)}°`);
  console.log(`  Elevation: ${rae.el.toFixed(1)}°`);
  console.log(`  Range: ${rae.rng.toFixed(1)} km`);
}

Doppler during a pass

Sampling the factor over time shows the classic S-curve: the shift sweeps from positive (approaching) through zero (closest approach) to negative (receding). Here the satellite is below the horizon, but the geometry-driven trend is the same.

ts
console.log('\n=== Example 2: Doppler Shift During a Pass ===\n');

// Simulate a pass over 10 minutes
const passStart = new Date('2024-01-28T12:00:00.000Z');
const interval = 60; // seconds

console.log('Time      El    Range    Doppler      Freq Shift');
console.log('────────  ───  ────────  ──────────  ────────────');

for (let i = 0; i <= 10; i++) {
  const time = new Date(passStart.getTime() + i * interval * 1000);
  const timeStr = time.toISOString().substring(11, 19);

  const passRae = groundStation.rae(iss, time);
  const passDoppler = iss.dopplerFactor(groundStation, time);
  const passReceivedFreq = iss.applyDoppler(transmitFreq, groundStation, time);

  if (!passRae || passDoppler === null || passReceivedFreq === null) {
    continue;
  }

  const passFreqShift = passReceivedFreq - transmitFreq;

  const elStr = passRae.el.toFixed(1).padStart(5);
  const rngStr = passRae.rng.toFixed(1).padStart(8);
  const dopplerStr = passDoppler.toFixed(8);
  const shiftStr = (passFreqShift / 1e3).toFixed(2).padStart(9);

  console.log(`${timeStr}  ${elStr}° ${rngStr} km  ${dopplerStr}  ${shiftStr} kHz`);
}

Frequency bands

The Doppler shift in Hz scales linearly with the carrier frequency, so an X-band link sees roughly 19x the absolute shift of a VHF link for the same geometry. The theoretical worst case uses the full orbital velocity as the radial component.

ts
console.log('\n=== Example 3: Doppler Shift Across Different Bands ===\n');

// Doppler shift scales linearly with carrier frequency, so higher bands
// see proportionally larger absolute shifts.
const frequencies = [
  { band: 'VHF', freq: 145.8e6, name: '145.8 MHz' },
  { band: 'UHF', freq: 437.8e6, name: '437.8 MHz' },
  { band: 'L-band', freq: 1575.42e6, name: '1575.42 MHz (GPS L1)' },
  { band: 'S-band', freq: 2200e6, name: '2.2 GHz' },
  { band: 'X-band', freq: 8400e6, name: '8.4 GHz' },
  { band: 'Ku-band', freq: 12e9, name: '12 GHz' },
];

const bandCheckTime = new Date('2024-01-28T12:05:00.000Z');

console.log(`Time: ${bandCheckTime.toISOString()}\n`);
console.log('Band       Frequency       Received Freq    Shift');
console.log('────────  ──────────────  ──────────────  ─────────');

frequencies.forEach((f) => {
  const bandReceivedFreq = iss.applyDoppler(f.freq, groundStation, bandCheckTime);

  if (bandReceivedFreq === null) {
    return;
  }

  const bandFreqShift = bandReceivedFreq - f.freq;

  const bandStr = f.band.padEnd(8);
  const freqStr = f.name.padEnd(14);
  const recvStr = formatFrequency(bandReceivedFreq).padEnd(14);
  const shiftStr = formatFrequency(Math.abs(bandFreqShift)).padStart(9);

  console.log(`${bandStr}  ${freqStr}  ${recvStr}  ${bandFreqShift >= 0 ? '+' : '-'}${shiftStr}`);
});

// Theoretical maximum: worst case is the full orbital velocity along the
// line of sight (~7.66 km/s for the ISS).
const orbitalVelocity = 7.66; // km/s
const speedOfLight = 299792.458; // km/s
const maxDopplerFactor = orbitalVelocity / speedOfLight;

console.log(`\nISS Orbital Velocity: ~${orbitalVelocity} km/s`);
console.log(`Max Doppler Factor: ±${(maxDopplerFactor * 100).toFixed(6)}%`);

console.log('\nTheoretical Maximum Frequency Shifts:');

frequencies.slice(0, 4).forEach((f) => {
  const maxShift = f.freq * maxDopplerFactor;

  console.log(`  ${f.name}: ±${formatFrequency(maxShift)}`);
});

Doppler rate

Differencing the received frequency over a short interval gives the Doppler rate (Hz/s), which drives how fast a receiver must retune during a pass.

ts
console.log('\n=== Example 4: Doppler Rate of Change ===\n');

const rateStart = new Date('2024-01-28T12:00:00.000Z');
const deltaTime = 10; // seconds

console.log('Measuring Doppler rate of change over time:\n');

for (let i = 0; i < 5; i++) {
  const t1 = new Date(rateStart.getTime() + i * 60 * 1000);
  const t2 = new Date(t1.getTime() + deltaTime * 1000);

  const freq1 = iss.applyDoppler(transmitFreq, groundStation, t1);
  const freq2 = iss.applyDoppler(transmitFreq, groundStation, t2);

  if (freq1 === null || freq2 === null) {
    continue;
  }

  const freqChange = freq2 - freq1;
  const rateOfChange = freqChange / deltaTime; // Hz per second

  const timeStr = t1.toISOString().substring(11, 19);

  console.log(`At ${timeStr}:`);
  console.log(`  Frequency: ${(freq1 / 1e6).toFixed(4)} MHz`);
  console.log(`  Rate of change: ${rateOfChange.toFixed(2)} Hz/s`);
  console.log('');
}

Manual dopplerFactor

The standalone dopplerFactor(location, position, velocity) utility is what the Satellite method uses internally: observer ECI position (from GroundObject.eci(date), which returns a bare {x, y, z} vector), plus the satellite's ECI position and velocity from Satellite.eci(date). The results match the method exactly.

ts
console.log('=== Example 5: Using dopplerFactor Utility Function ===\n');

// The dopplerFactor(location, position, velocity) utility is what the
// Satellite method uses internally: observer ECI position, satellite ECI
// position, and satellite ECI velocity.
const observerEci = groundStation.eci(date);
const satelliteState = iss.eci(date);

if (!satelliteState) {
  throw new Error('Failed to propagate satellite state');
}

console.log('Observer ECI Position (km):');
console.log(`  [${observerEci.x.toFixed(2)}, ${observerEci.y.toFixed(2)}, ${observerEci.z.toFixed(2)}]`);

console.log('\nSatellite ECI Position (km):');
console.log(
  `  [${satelliteState.position.x.toFixed(2)}, ${satelliteState.position.y.toFixed(2)}, ` +
  `${satelliteState.position.z.toFixed(2)}]`,
);

console.log('\nSatellite ECI Velocity (km/s):');
console.log(
  `  [${satelliteState.velocity.x.toFixed(4)}, ${satelliteState.velocity.y.toFixed(4)}, ` +
  `${satelliteState.velocity.z.toFixed(4)}]`,
);

const manualDoppler = dopplerFactor(observerEci, satelliteState.position, satelliteState.velocity);

console.log(`\nCalculated Doppler Factor: ${manualDoppler.toFixed(8)}`);
console.log(`Satellite method result: ${doppler.toFixed(8)}`);

Helpers

ts
// Helper function to format frequencies
function formatFrequency(freq: number): string {
  if (freq >= 1e9) {
    return `${(freq / 1e9).toFixed(4)} GHz`;
  } else if (freq >= 1e6) {
    return `${(freq / 1e6).toFixed(4)} MHz`;
  } else if (freq >= 1e3) {
    return `${(freq / 1e3).toFixed(2)} kHz`;
  }

  return `${freq.toFixed(2)} Hz`;
}

Output

txt
Ground Station: Cape Cod Ground Station
Transmit Frequency: 437.8 MHz
Time: 2024-01-28T12:00:00.000Z

=== Example 1: Basic Doppler Shift Calculation ===

Doppler Calculations:
  Doppler Factor: 0.99999592
  Transmit Frequency: 437.8000 MHz
  Received Frequency: 437.7982 MHz
  Frequency Shift: -1.79 kHz

Satellite Position:
  Azimuth: 309.5°
  Elevation: -54.7°
  Range: 10918.1 km

=== Example 2: Doppler Shift During a Pass ===

Time      El    Range    Doppler      Freq Shift
────────  ───  ────────  ──────────  ────────────
12:00:00  -54.7°  10918.1 km  0.99999592      -1.79 kHz
12:01:00  -55.2°  10987.5 km  0.99999628      -1.63 kHz
12:02:00  -55.8°  11050.3 km  0.99999666      -1.46 kHz
12:03:00  -56.2°  11106.1 km  0.99999705      -1.29 kHz
12:04:00  -56.7°  11154.8 km  0.99999745      -1.12 kHz
12:05:00  -57.0°  11196.1 km  0.99999787      -0.93 kHz
12:06:00  -57.3°  11229.8 km  0.99999829      -0.75 kHz
12:07:00  -57.6°  11255.8 km  0.99999873      -0.56 kHz
12:08:00  -57.7°  11273.9 km  0.99999917      -0.36 kHz
12:09:00  -57.8°  11284.0 km  0.99999962      -0.17 kHz
12:10:00  -57.9°  11285.9 km  1.00000008       0.03 kHz

=== Example 3: Doppler Shift Across Different Bands ===

Time: 2024-01-28T12:05:00.000Z

Band       Frequency       Received Freq    Shift
────────  ──────────────  ──────────────  ─────────
VHF       145.8 MHz       145.7997 MHz    -311.06 Hz
UHF       437.8 MHz       437.7991 MHz    -934.04 Hz
L-band    1575.42 MHz (GPS L1)  1.5754 GHz      - 3.36 kHz
S-band    2.2 GHz         2.2000 GHz      - 4.69 kHz
X-band    8.4 GHz         8.4000 GHz      -17.92 kHz
Ku-band   12 GHz          12.0000 GHz     -25.60 kHz

ISS Orbital Velocity: ~7.66 km/s
Max Doppler Factor: ±0.002555%

Theoretical Maximum Frequency Shifts:
  145.8 MHz: ±3.73 kHz
  437.8 MHz: ±11.19 kHz
  1575.42 MHz (GPS L1): ±40.25 kHz
  2.2 GHz: ±56.21 kHz

... (truncated)

=== Example 5: Using dopplerFactor Utility Function ===

Observer ECI Position (km):
  [-2608.11, -3973.30, 4242.78]

Satellite ECI Position (km):
  [-1574.82, 6481.63, 1292.52]

Satellite ECI Velocity (km/s):
  [-4.9744, -0.0382, -5.8304]

Calculated Doppler Factor: 0.99999592
Satellite method result: 0.99999592

Released under the AGPL-3.0 License.