Skip to content

Commit e65a3ed

Browse files
committed
feat: upgrade sun ephemeris to Meeus Ch.25 and add sun/moon tracking
Upgrade solar position from simplified formula to full Meeus Ch.25 algorithm (~0.01° accuracy) in new sun-core.ts. All consumers now import sunDirectionECI from sun-core directly instead of through eclipse.ts wrapper. Add sun/moon as tracking targets for antenna rotators: - beamStore.lockToBody('sun'|'moon') with toggle untrack - Tracker computes az/el from ephemeris when locked to a body - Radar markers (gold sun, silver moon) with hover tooltips and click-to-track/untrack action hints - Persisted "Sun / Moon" toggle in rotator Visual section, force-enabled when tracking a celestial body - Track/Untrack buttons in Observer window sky section Rotator interaction improvements: - Block reticle dragging when locked to prevent aim fighting - Auto-slew follows manual reticle placement when unlocked - Debounced nudge (500ms) sends immediate command on reticle click without spamming during drag - Status bar uses tolerance threshold for delta display instead of hysteresis, fixing false "ON TARGET" on initial move
1 parent 2ab43da commit e65a3ed

16 files changed

Lines changed: 430 additions & 152 deletions

src/app.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ import { PassPredictor } from './passes/pass-predictor';
4545
import { getAzEl, renderToEci } from './astro/az-el';
4646
import { propagate } from 'satellite.js';
4747
import { epochToUnix, epochToGmst } from './astro/epoch';
48-
import { sunDirectionECI, earthShadowFactor, isSolarEclipsed, solarEclipsePossible } from './astro/eclipse';
48+
import { sunDirectionECI } from './astro/sun-core';
49+
import { earthShadowFactor, isSolarEclipsed, solarEclipsePossible } from './astro/eclipse';
4950
import { moonPositionECI } from './astro/moon-observer';
5051
import { computePhaseAngle, observerEci, slantRange, estimateVisualMagnitude } from './astro/magnitude';
5152
import { loadElevation, getElevation, isElevationLoaded } from './astro/elevation';

src/astro/eclipse.ts

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,51 +4,8 @@
44
* No Three.js dependency so it can run in the pass Web Worker.
55
*/
66
import { DEG2RAD, RAD2DEG, EARTH_RADIUS_KM, MOON_RADIUS_KM } from '../constants';
7-
import { epochToJulianDateTT, normalizeEpoch } from './epoch';
87
import { getAzEl } from './az-el';
9-
10-
/**
11-
* Compute unit sun direction in **standard ECI** (Earth-Centered Inertial) coordinates.
12-
* NOT render coords — use calculateSunPosition() from sun.ts for render coords.
13-
* Same low-precision solar ephemeris as sun.ts but returns plain {x,y,z}
14-
* instead of THREE.Vector3 for Web Worker compatibility.
15-
*
16-
* Reference: Meeus, "Astronomical Algorithms" — low-precision solar position.
17-
*/
18-
export function sunDirectionECI(epoch: number): { x: number; y: number; z: number } {
19-
epoch = normalizeEpoch(epoch);
20-
const jd = epochToJulianDateTT(epoch);
21-
22-
// Days since J2000.0 epoch (2000-01-01 12:00 TT)
23-
const n = jd - 2451545.0;
24-
25-
// Mean longitude of the Sun (degrees), moves ~0.986°/day
26-
let L = (280.460 + 0.9856474 * n) % 360.0;
27-
if (L < 0) L += 360.0;
28-
29-
// Mean anomaly of the Sun (degrees), ~0.986°/day from perihelion
30-
let g = (357.528 + 0.9856003 * n) % 360.0;
31-
if (g < 0) g += 360.0;
32-
33-
// Ecliptic longitude: mean longitude + equation of center (1st & 2nd harmonic)
34-
// 1.915° and 0.020° are amplitudes of Earth's orbital eccentricity correction
35-
const lambda = L + 1.915 * Math.sin(g * DEG2RAD) + 0.020 * Math.sin(2.0 * g * DEG2RAD);
36-
37-
// Obliquity of the ecliptic (axial tilt), ~23.44° with slow drift
38-
const epsilon = 23.439 - 0.0000004 * n;
39-
40-
// Ecliptic to ECI rotation (sun is at distance 1 AU, we only need direction)
41-
const xEcl = Math.cos(lambda * DEG2RAD);
42-
const yEcl = Math.sin(lambda * DEG2RAD);
43-
44-
// Rotate from ecliptic plane to equatorial (ECI) by obliquity angle
45-
const x = xEcl;
46-
const y = yEcl * Math.cos(epsilon * DEG2RAD);
47-
const z = yEcl * Math.sin(epsilon * DEG2RAD);
48-
49-
const len = Math.sqrt(x * x + y * y + z * z);
50-
return { x: x / len, y: y / len, z: z / len };
51-
}
8+
import { sunDirectionECI } from './sun-core';
529

5310
// Sun angular radius as seen from Earth (radians)
5411
const SUN_DISTANCE_KM = 149597870.7;
@@ -148,7 +105,7 @@ export function solarEclipsePossible(
148105
* Compute Sun elevation angle (degrees) at the observer's location.
149106
* Positive = above horizon, negative = below.
150107
*
151-
* Uses the same low-precision ephemeris as sunDirectionECI(), scaled to a
108+
* Uses Meeus Ch.25 ephemeris via sunDirectionECI(), scaled to a
152109
* large distance so getAzEl()'s range math works correctly.
153110
*/
154111
export function sunAltitude(

src/astro/moon-observer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
import { DEG2RAD } from '../constants';
66
import { computeMoonEcliptic } from './moon-core';
7-
import { sunDirectionECI } from './eclipse';
7+
import { sunDirectionECI } from './sun-core';
88

99
/**
1010
* Compute Moon position in standard ECI (km).

src/astro/sun-core.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Solar ephemeris — Meeus, "Astronomical Algorithms" Ch. 25.
3+
* Computes apparent ecliptic longitude, apparent obliquity, and distance.
4+
* Accurate to ~0.01°.
5+
* No THREE.js dependency — safe for Web Workers.
6+
*/
7+
import { DEG2RAD } from '../constants';
8+
import { epochToJulianDateTT, normalizeEpoch } from './epoch';
9+
10+
export interface SunEcliptic {
11+
/** Apparent ecliptic longitude (radians), includes nutation + aberration */
12+
lambdaApp: number;
13+
/** Apparent obliquity of the ecliptic (radians), includes nutation */
14+
epsilonApp: number;
15+
/** Distance from Earth (AU) */
16+
rAU: number;
17+
}
18+
19+
export function computeSunEcliptic(epoch: number): SunEcliptic {
20+
epoch = normalizeEpoch(epoch);
21+
const jd = epochToJulianDateTT(epoch);
22+
const T = (jd - 2451545.0) / 36525.0; // Julian centuries from J2000.0
23+
const T2 = T * T;
24+
const T3 = T2 * T;
25+
26+
// Geometric mean longitude of the Sun (degrees) — Meeus eq. 25.2
27+
let L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T2;
28+
L0 = ((L0 % 360) + 360) % 360;
29+
30+
// Mean anomaly of the Sun (degrees) — Meeus eq. 25.3
31+
let M = 357.52911 + 35999.05029 * T - 0.0001537 * T2;
32+
M = ((M % 360) + 360) % 360;
33+
const Mrad = M * DEG2RAD;
34+
35+
// Eccentricity of Earth's orbit — Meeus eq. 25.4
36+
const e = 0.016708634 - 0.000042037 * T - 0.0000001267 * T2;
37+
38+
// Sun's equation of center (degrees) — Meeus p. 164
39+
const C = (1.9146 - 0.004817 * T - 0.000014 * T2) * Math.sin(Mrad)
40+
+ (0.019993 - 0.000101 * T) * Math.sin(2 * Mrad)
41+
+ 0.000289 * Math.sin(3 * Mrad);
42+
43+
// Sun's true longitude and true anomaly (degrees)
44+
const theta = L0 + C;
45+
const nu = M + C;
46+
const nuRad = nu * DEG2RAD;
47+
48+
// Sun's radius vector in AU — Meeus eq. 25.5
49+
const rAU = (1.000001018 * (1 - e * e)) / (1 + e * Math.cos(nuRad));
50+
51+
// Longitude of the ascending node of the Moon's mean orbit (degrees)
52+
// Used for nutation correction — Meeus eq. 25.8 (low-precision nutation)
53+
const omega = 125.04 - 1934.136 * T;
54+
const omegaRad = omega * DEG2RAD;
55+
56+
// Apparent longitude (degrees) — corrected for nutation and aberration
57+
// Meeus eq. 25.8: -0.00569° for aberration, -0.00478°·sin(Ω) for nutation
58+
const lambdaApp = (theta - 0.00569 - 0.00478 * Math.sin(omegaRad)) * DEG2RAD;
59+
60+
// Mean obliquity of the ecliptic — Meeus eq. 22.2 (in degrees)
61+
// 23°26'21.448" = 23.4392911°
62+
const eps0 = 23.4392911 - (46.8150 / 3600) * T - (0.00059 / 3600) * T2 + (0.001813 / 3600) * T3;
63+
64+
// Apparent obliquity (corrected for nutation) — Meeus eq. 25.8
65+
const epsilonApp = (eps0 + 0.00256 * Math.cos(omegaRad)) * DEG2RAD;
66+
67+
return { lambdaApp, epsilonApp, rAU };
68+
}
69+
70+
/**
71+
* Compute unit sun direction in **standard ECI** (Earth-Centered Inertial) coordinates.
72+
* NOT render coords — use calculateSunPosition() from sun.ts for render coords.
73+
* No THREE.js dependency — safe for Web Workers.
74+
*/
75+
export function sunDirectionECI(epoch: number): { x: number; y: number; z: number } {
76+
const { lambdaApp, epsilonApp } = computeSunEcliptic(epoch);
77+
78+
// Ecliptic to equatorial (ECI). Sun's ecliptic latitude β ≈ 0.
79+
const cosLam = Math.cos(lambdaApp);
80+
const sinLam = Math.sin(lambdaApp);
81+
const cosEps = Math.cos(epsilonApp);
82+
const sinEps = Math.sin(epsilonApp);
83+
84+
const x = cosLam;
85+
const y = sinLam * cosEps;
86+
const z = sinLam * sinEps;
87+
88+
const len = Math.sqrt(x * x + y * y + z * z);
89+
return { x: x / len, y: y / len, z: z / len };
90+
}

src/astro/sun.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,9 @@
11
import * as THREE from 'three';
2-
import { DEG2RAD } from '../constants';
3-
import { epochToJulianDateTT, normalizeEpoch } from './epoch';
2+
import { sunDirectionECI } from './sun-core';
43

54
/** Sun direction in **render coords** (x=eci.x, y=eci.z, z=-eci.y). NOT standard ECI. */
65
export function calculateSunPosition(currentEpoch: number): THREE.Vector3 {
7-
currentEpoch = normalizeEpoch(currentEpoch);
8-
const jd = epochToJulianDateTT(currentEpoch);
9-
const n = jd - 2451545.0;
10-
11-
let L = (280.460 + 0.9856474 * n) % 360.0;
12-
if (L < 0) L += 360.0;
13-
14-
let g = (357.528 + 0.9856003 * n) % 360.0;
15-
if (g < 0) g += 360.0;
16-
17-
const lambda = L + 1.915 * Math.sin(g * DEG2RAD) + 0.020 * Math.sin(2.0 * g * DEG2RAD);
18-
const epsilon = 23.439 - 0.0000004 * n;
19-
20-
const xEcl = Math.cos(lambda * DEG2RAD);
21-
const yEcl = Math.sin(lambda * DEG2RAD);
22-
23-
const xEci = xEcl;
24-
const yEci = yEcl * Math.cos(epsilon * DEG2RAD);
25-
const zEci = yEcl * Math.sin(epsilon * DEG2RAD);
26-
6+
const { x, y, z } = sunDirectionECI(currentEpoch);
277
// ECI to render coords: x=eci.x, y=eci.z, z=-eci.y
28-
return new THREE.Vector3(xEci, zEci, -yEci).normalize();
8+
return new THREE.Vector3(x, z, -y);
299
}

src/passes/pass-worker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import { twoline2satrec, json2satrec, propagate } from 'satellite.js';
66
import { normalizeEpoch, epochToUnix, epochToGmst } from '../astro/epoch';
77
import { getAzEl } from '../astro/az-el';
8-
import { sunDirectionECI, isEclipsed, earthShadowFactor, isSolarEclipsed, solarEclipsePossible, sunAltitude, solarElongation } from '../astro/eclipse';
8+
import { sunDirectionECI } from '../astro/sun-core';
9+
import { isEclipsed, earthShadowFactor, isSolarEclipsed, solarEclipsePossible, sunAltitude, solarElongation } from '../astro/eclipse';
910
import { moonPositionECI } from '../astro/moon-observer';
1011
import { computePhaseAngle, observerEci, slantRange, estimateVisualMagnitude } from '../astro/magnitude';
1112
import type { PassRequest, PassResponse, PassPartial, SatellitePass, PassProgress } from './pass-types';

src/scene/map-renderer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { computeFootprintGrid } from '../astro/footprint';
88
import { getMapCoordinates } from '../astro/coordinates';
99
import { calculatePosition } from '../astro/propagator';
1010
import { epochToGmst } from '../astro/epoch';
11-
import { sunDirectionECI, earthShadowFactor, isSolarEclipsed, solarEclipsePossible } from '../astro/eclipse';
11+
import { sunDirectionECI } from '../astro/sun-core';
12+
import { earthShadowFactor, isSolarEclipsed, solarEclipsePossible } from '../astro/eclipse';
1213
import { moonPositionECI } from '../astro/moon-observer';
1314
import { calculateSunPosition } from '../astro/sun';
1415
import { calculateMoonPosition } from '../astro/moon';

src/scene/orbit-renderer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { DRAW_SCALE, EARTH_RADIUS_KM, TWO_PI, MU, ORBIT_RECOMPUTE_INTERVAL_S, sa
99
import { parseHexColor } from '../config';
1010
import { calculatePosition, getCorrectedElements } from '../astro/propagator';
1111
import { epochToUnix } from '../astro/epoch';
12-
import { sunDirectionECI, earthShadowFactor, isSolarEclipsed, solarEclipsePossible } from '../astro/eclipse';
12+
import { sunDirectionECI } from '../astro/sun-core';
13+
import { earthShadowFactor, isSolarEclipsed, solarEclipsePossible } from '../astro/eclipse';
1314
import { moonPositionECI } from '../astro/moon-observer';
1415
import { uiStore } from '../stores/ui.svelte';
1516
import { observerStore } from '../stores/observer.svelte';

src/stores/beam.svelte.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface BeamTrackingState {
1616
beamWidth: number;
1717
locked: boolean;
1818
lockedNoradId: number | null;
19+
lockedBodyType: 'satellite' | 'sun' | 'moon';
1920
lockedSatName: string;
2021
trackAz: number | null;
2122
trackEl: number | null;
@@ -46,6 +47,7 @@ class BeamStore {
4647
// Lock-to-selected tracking
4748
locked = $state(false);
4849
lockedNoradId = $state<number | null>(null);
50+
lockedBodyType = $state<'satellite' | 'sun' | 'moon'>('satellite');
4951
lockedSatName = $state('');
5052

5153
// Live tracking output (updated each radar cycle when locked)
@@ -78,9 +80,17 @@ class BeamStore {
7880
lockToSatellite(noradId: number, name: string) {
7981
this.locked = true;
8082
this.lockedNoradId = noradId;
83+
this.lockedBodyType = 'satellite';
8184
this.lockedSatName = name;
8285
}
8386

87+
lockToBody(body: 'sun' | 'moon') {
88+
this.locked = true;
89+
this.lockedNoradId = null;
90+
this.lockedBodyType = body;
91+
this.lockedSatName = body === 'sun' ? 'Sun' : 'Moon';
92+
}
93+
8494
setConeVisible(v: boolean) {
8595
this.coneVisible = v;
8696
localStorage.setItem(PREFIX + 'coneVisible', String(v));
@@ -89,6 +99,7 @@ class BeamStore {
8999
unlock() {
90100
this.locked = false;
91101
this.lockedNoradId = null;
102+
this.lockedBodyType = 'satellite';
92103
this.lockedSatName = '';
93104
this.trackAz = null;
94105
this.trackEl = null;
@@ -126,6 +137,7 @@ class BeamStore {
126137
beamWidth: this.beamWidth,
127138
locked: this.locked,
128139
lockedNoradId: this.lockedNoradId,
140+
lockedBodyType: this.lockedBodyType,
129141
lockedSatName: this.lockedSatName,
130142
trackAz: this.trackAz,
131143
trackEl: this.trackEl,

src/stores/rotator.svelte.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { RotatorDriver, RotatorMode, SerialProtocol } from '../rotator/protocol';
22
import type { BeamTrackingState } from './beam.svelte';
3+
import { beamStore } from './beam.svelte';
34
import { uiStore } from './ui.svelte';
45
import { timeStore } from './time.svelte';
56

@@ -409,8 +410,29 @@ class RotatorStore {
409410
}
410411
}
411412

413+
private _nudgeTimer: ReturnType<typeof setTimeout> | null = null;
414+
415+
/** Debounced immediate track command (e.g. after manual reticle move). */
416+
nudge(): void {
417+
if (!this.autoTrack || !this.driver?.connected || beamStore.locked) return;
418+
this.targetAz = beamStore.aimAz;
419+
this.targetEl = beamStore.aimEl;
420+
if (this._nudgeTimer) return; // already scheduled
421+
this._nudgeTimer = setTimeout(() => {
422+
this._nudgeTimer = null;
423+
this.targetAz = beamStore.aimAz;
424+
this.targetEl = beamStore.aimEl;
425+
this.tickTrack();
426+
}, 500);
427+
}
428+
412429
private async tickTrack(): Promise<void> {
413430
if (!this.autoTrack || !this.driver?.connected) return;
431+
// When not locked to a target, follow the beam reticle for manual positioning
432+
if (!beamStore.locked && this.nextAosEpoch === 0) {
433+
this.targetAz = beamStore.aimAz;
434+
this.targetEl = beamStore.aimEl;
435+
}
414436
if (this.targetAz === null || this.targetEl === null) return;
415437

416438
// Skip if target hasn't moved since last command (within 0.05°)

0 commit comments

Comments
 (0)