Skip to content

Commit a9dec07

Browse files
MiguelPuntoEsclaude
andcommitted
feat: satellite visibility & DOP prediction (computeVisibility)
Given broadcast ephemerides, a fixed receiver location and a time window, computeVisibility returns per-satellite elevation timelines, per-epoch visible count and PDOP, and discrete rise/peak/set passes — the computation behind a session-planning tool. Reuses computeAllPositions/computeDop. Validated against ABMF over a 6 h window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7d314a7 commit a9dec07

4 files changed

Lines changed: 191 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased (1.3.0)
4+
5+
### New features
6+
7+
- **`computeVisibility(ephemerides, rxPos, startMs, endMs, stepSec, maskDeg)`** in `gnss-js/orbit` — satellite visibility and DOP prediction over a time window for a fixed location: per-satellite elevation timelines, visible-count and PDOP per epoch, and discrete rise/peak/set passes. Foundation for session planning.
8+
39
## Unreleased (1.2.0)
410

511
### Fixed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ const { distance } = vincenty(
5151
// distance in meters, angles in radians
5252
```
5353

54+
### Visibility / pass prediction
55+
56+
```ts
57+
import { computeVisibility } from 'gnss-js/orbit';
58+
59+
const vis = computeVisibility(ephemerides, rxEcef, startMs, endMs, 300, 10);
60+
// vis.passes: rise/peak/set per satellite; vis.pdop / vis.visibleCount
61+
// per epoch; vis.elevation[prn]: elevation timeline for plotting.
62+
```
63+
5464
### Single-point positioning
5565

5666
```ts

src/orbit/index.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,3 +886,121 @@ export function computeLiveSkyPositions(
886886

887887
return result;
888888
}
889+
890+
/* ================================================================== */
891+
/* Visibility / pass prediction */
892+
/* ================================================================== */
893+
894+
/** A single above-mask visibility interval for one satellite. */
895+
export interface VisibilityPass {
896+
prn: string;
897+
system: string;
898+
/** Rise time (Unix ms) — first sample at or above the mask. */
899+
rise: number;
900+
/** Set time (Unix ms) — last sample at or above the mask. */
901+
set: number;
902+
/** Time of peak elevation (Unix ms). */
903+
peakTime: number;
904+
/** Peak elevation (radians). */
905+
peakEl: number;
906+
}
907+
908+
export interface VisibilityResult {
909+
/** Sample epochs (Unix ms). */
910+
times: number[];
911+
/** Elevation (radians) per PRN per epoch; null when below −0.05 rad / no eph. */
912+
elevation: Record<string, (number | null)[]>;
913+
/** Number of satellites at or above the mask, per epoch. */
914+
visibleCount: number[];
915+
/** PDOP per epoch (null when < 4 satellites above the mask). */
916+
pdop: (number | null)[];
917+
/** Discrete above-mask passes, sorted by rise time. */
918+
passes: VisibilityPass[];
919+
}
920+
921+
/**
922+
* Predict satellite visibility and DOP over a time window for a fixed
923+
* receiver location.
924+
*
925+
* @param ephemerides Broadcast ephemerides covering the window.
926+
* @param rxPos Receiver ECEF position (meters).
927+
* @param startMs Window start (Unix ms).
928+
* @param endMs Window end (Unix ms).
929+
* @param stepSec Sample spacing in seconds (default 300).
930+
* @param elevationMaskDeg Elevation mask in degrees (default 10).
931+
*/
932+
export function computeVisibility(
933+
ephemerides: Ephemeris[],
934+
rxPos: [number, number, number],
935+
startMs: number,
936+
endMs: number,
937+
stepSec = 300,
938+
elevationMaskDeg = 10
939+
): VisibilityResult {
940+
const stepMs = stepSec * 1000;
941+
const times: number[] = [];
942+
for (let t = startMs; t <= endMs; t += stepMs) times.push(t);
943+
944+
const maskRad = (elevationMaskDeg * Math.PI) / 180;
945+
const all = computeAllPositions(ephemerides, times, rxPos);
946+
947+
const elevation: Record<string, (number | null)[]> = {};
948+
const visibleCount = new Array<number>(times.length).fill(0);
949+
const pdop = new Array<number | null>(times.length).fill(null);
950+
951+
// Per-epoch visible az/el for DOP
952+
const visiblePerEpoch: { az: number; el: number }[][] = times.map(() => []);
953+
954+
for (const prn of all.prns) {
955+
const series = all.positions[prn]!;
956+
elevation[prn] = series.map((p) => (p ? p.el : null));
957+
for (let i = 0; i < series.length; i++) {
958+
const p = series[i];
959+
if (p && p.el >= maskRad) {
960+
visibleCount[i]!++;
961+
visiblePerEpoch[i]!.push({ az: p.az, el: p.el });
962+
}
963+
}
964+
}
965+
966+
for (let i = 0; i < times.length; i++) {
967+
const dop = computeDop(visiblePerEpoch[i]!);
968+
pdop[i] = dop ? dop.pdop : null;
969+
}
970+
971+
// Extract passes: contiguous runs at or above the mask per PRN
972+
const passes: VisibilityPass[] = [];
973+
for (const prn of all.prns) {
974+
const els = elevation[prn]!;
975+
let start = -1;
976+
let peakEl = -Infinity;
977+
let peakTime = 0;
978+
for (let i = 0; i <= els.length; i++) {
979+
const el = i < els.length ? els[i] : null;
980+
const above = el !== null && el !== undefined && el >= maskRad;
981+
if (above) {
982+
if (start === -1) {
983+
start = i;
984+
peakEl = -Infinity;
985+
}
986+
if (el! > peakEl) {
987+
peakEl = el!;
988+
peakTime = times[i]!;
989+
}
990+
} else if (start !== -1) {
991+
passes.push({
992+
prn,
993+
system: prn[0]!,
994+
rise: times[start]!,
995+
set: times[i - 1]!,
996+
peakTime,
997+
peakEl,
998+
});
999+
start = -1;
1000+
}
1001+
}
1002+
}
1003+
passes.sort((a, b) => a.rise - b.rise);
1004+
1005+
return { times, elevation, visibleCount, pdop, passes };
1006+
}

test/visibility.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readFileSync, existsSync } from 'fs';
3+
import { join } from 'path';
4+
import { parseNavFile } from '../src/rinex';
5+
import { computeVisibility } from '../src/orbit';
6+
7+
const DIR = join(__dirname, '../test-fixtures');
8+
const HAS_DATA = existsSync(join(DIR, 'BRDC.nav'));
9+
10+
// ABMF (Guadeloupe) ECEF, from the obs header used elsewhere in the suite.
11+
const ABMF: [number, number, number] = [2919785.712, -5383745.067, 1774604.692];
12+
const START = Date.UTC(2024, 0, 1, 0, 0, 0);
13+
const END = Date.UTC(2024, 0, 1, 6, 0, 0);
14+
15+
describe.skipIf(!HAS_DATA)('computeVisibility (ABMF, 6 h)', () => {
16+
const nav = parseNavFile(readFileSync(join(DIR, 'BRDC.nav'), 'utf-8'));
17+
const vis = computeVisibility(nav.ephemerides, ABMF, START, END, 300, 10);
18+
19+
it('samples the whole window at the requested step', () => {
20+
expect(vis.times[0]).toBe(START);
21+
expect(vis.times[vis.times.length - 1]).toBe(END);
22+
expect(vis.times[1]! - vis.times[0]!).toBe(300_000);
23+
});
24+
25+
it('keeps a healthy sky in view with usable DOP', () => {
26+
const mid = Math.floor(vis.times.length / 2);
27+
expect(vis.visibleCount[mid]).toBeGreaterThan(6);
28+
expect(vis.pdop[mid]).toBeGreaterThan(0.5);
29+
expect(vis.pdop[mid]).toBeLessThan(10);
30+
});
31+
32+
it('produces well-formed passes', () => {
33+
expect(vis.passes.length).toBeGreaterThan(5);
34+
for (const p of vis.passes) {
35+
expect(p.set).toBeGreaterThanOrEqual(p.rise);
36+
expect(p.peakTime).toBeGreaterThanOrEqual(p.rise);
37+
expect(p.peakTime).toBeLessThanOrEqual(p.set);
38+
expect(p.peakEl).toBeGreaterThan((10 * Math.PI) / 180);
39+
expect(p.peakEl).toBeLessThanOrEqual(Math.PI / 2 + 1e-6);
40+
}
41+
});
42+
43+
it('a higher elevation mask never increases visibility', () => {
44+
const strict = computeVisibility(
45+
nav.ephemerides,
46+
ABMF,
47+
START,
48+
END,
49+
300,
50+
30
51+
);
52+
const mid = Math.floor(vis.times.length / 2);
53+
expect(strict.visibleCount[mid]!).toBeLessThanOrEqual(
54+
vis.visibleCount[mid]!
55+
);
56+
});
57+
});

0 commit comments

Comments
 (0)