@openwaters/almanac 0.0.0-bootstrap

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ // L2 transform layer: Greenwich sidereal time, topocentric parallax (the
2
+ // observer's geocentric position via WGS-84 flattening), and atmospheric
3
+ // refraction — turning L1's geocentric equatorial vectors into what an
4
+ // observer on Earth's surface sees.
5
+ //
6
+ // Translated from the cosinekitty/astronomy upstream (pinned sha
7
+ // 865d3da7d8112bbc7911238052c6af4aaf877181, source/js/astronomy.ts):
8
+ // SiderealTime (era ~2014 + sidereal_time ~2031); the observer's geocentric
9
+ // position (terra ~2147, geo_pos ~2236), which the topocentric path inside
10
+ // Equator(body, date, observer, ofdate=true, aberration=true) (~2803: body
11
+ // EQJ vector minus observer EQJ vector, then gyrate the difference to date)
12
+ // consumes; Horizon (~2565), stopping short of its post-refraction ra/dec
13
+ // refinement since this layer only needs az/alt; Refraction 'normal' (~7176).
14
+ //
15
+ // INTERNAL: not part of the curated public API (see index.ts) except
16
+ // sunAltAz/moonAltAz.
17
+ import { assertObserver, assertSupported } from './types.js';
18
+ import { ttDaysFromUt, utDays } from './time.js';
19
+ import { DEG2RAD, RAD2DEG, EARTH_EQUATORIAL_RADIUS_KM, EARTH_FLATTENING, EARTH_FLATTENING_SQUARED, KM_PER_AU, PrecessDirection, earthTilt, equatorialFromVector, gyration } from './nutation.js';
20
+ import { sunGeoVectorEqj } from './sun.js';
21
+ import { moonGeoVectorEqj } from './moon.js';
22
+ /** UPSTREAM: `era` (Earth Rotation Angle), astronomy.ts ~2014. */
23
+ function earthRotationAngleDeg(ut) {
24
+ const thet1 = 0.7790572732640 + 0.00273781191135448 * ut;
25
+ const thet3 = ut % 1;
26
+ let theta = 360 * ((thet1 + thet3) % 1);
27
+ if (theta < 0)
28
+ theta += 360;
29
+ return theta;
30
+ }
31
+ /**
32
+ * UPSTREAM: `sidereal_time`, astronomy.ts ~2031 — Greenwich Apparent Sidereal
33
+ * Time (GAST). Returned in **degrees** [0, 360) rather than upstream's hours:
34
+ * every caller in this port (terra/geo_pos, Horizon's spin angle) only ever
35
+ * uses `15 * gastHours`, so upstream's final `/15` and each caller's `*15`
36
+ * are folded into one multiply here — same value, one division dropped.
37
+ */
38
+ export function siderealDeg(ut) {
39
+ const tt = ttDaysFromUt(ut);
40
+ const t = tt / 36525;
41
+ const eqeq = 15 * earthTilt(tt).ee; // equation of the equinoxes
42
+ const theta = earthRotationAngleDeg(ut);
43
+ const st = eqeq + 0.014506 +
44
+ ((((-0.0000000368 * t
45
+ - 0.000029956) * t
46
+ - 0.00000044) * t
47
+ + 1.3915817) * t
48
+ + 4612.156534) * t;
49
+ let gastDeg = (st / 3600 + theta) % 360;
50
+ if (gastDeg < 0)
51
+ gastDeg += 360;
52
+ return gastDeg;
53
+ }
54
+ /**
55
+ * UPSTREAM: `terra` (astronomy.ts ~2147 — position only; its velocity output
56
+ * is for observer diurnal aberration, which this parallax-only path doesn't
57
+ * need). Upstream's `geo_pos` (~2236) then gyrates this into EQJ so that
58
+ * `Equator` can subtract it from the body's EQJ vector and gyrate the
59
+ * difference back to date. Gyration is a rotation, so
60
+ * `R(body − observer) === R·body − R·observer`, and `R` applied to the
61
+ * observer's own inverse-gyration is the vector below: one precession and one
62
+ * nutation per evaluation drop out of the inner loop of every event search.
63
+ *
64
+ * @returns geocentric position of the observer, in AU, true equator of date.
65
+ */
66
+ function observerGeoVectorOfDate(gastDeg, observer) {
67
+ const phi = observer.latitudeDeg * DEG2RAD;
68
+ const sinphi = Math.sin(phi);
69
+ const cosphi = Math.cos(phi);
70
+ const c = 1 / Math.hypot(cosphi, EARTH_FLATTENING * sinphi);
71
+ const s = EARTH_FLATTENING_SQUARED * c;
72
+ const htKm = (observer.elevationM ?? 0) / 1000;
73
+ const ach = EARTH_EQUATORIAL_RADIUS_KM * c + htKm;
74
+ const ash = EARTH_EQUATORIAL_RADIUS_KM * s + htKm;
75
+ const stlocl = (gastDeg + observer.longitudeDeg) * DEG2RAD;
76
+ const sinst = Math.sin(stlocl);
77
+ const cosst = Math.cos(stlocl);
78
+ return {
79
+ x: ach * cosphi * cosst / KM_PER_AU,
80
+ y: ach * cosphi * sinst / KM_PER_AU,
81
+ z: ash * sinphi / KM_PER_AU
82
+ };
83
+ }
84
+ /** UPSTREAM: `spin`, astronomy.ts ~2518 — rotate a horizontal-frame unit vector by sidereal angle. */
85
+ function spin(angleDeg, pos) {
86
+ const angr = angleDeg * DEG2RAD;
87
+ const c = Math.cos(angr);
88
+ const s = Math.sin(angr);
89
+ return { x: c * pos.x + s * pos.y, y: c * pos.y - s * pos.x, z: pos.z };
90
+ }
91
+ /**
92
+ * UPSTREAM: the topocentric path composed of `Equator(body, date, observer,
93
+ * ofdate=true, aberration=true)` (astronomy.ts ~2803) followed by `Horizon`
94
+ * (astronomy.ts ~2565), through its az/zd computation only — Horizon's
95
+ * post-refraction ra/dec refinement branch produces topocentric ra/dec, which
96
+ * this layer's callers (sunAltAz/moonAltAz) don't need.
97
+ *
98
+ * @param bodyEqj geocentric position of the body in AU, J2000 mean equator
99
+ * (EQJ) — i.e. `GeoVector`'s return, matching {@link sunGeoVectorEqj} /
100
+ * {@link moonGeoVectorEqj}, NOT the already-of-date vector
101
+ * {@link sunApparentAtTT}/{@link moonApparentAtTT} consume.
102
+ * @param ut days since J2000 in UT — the event layer iterates in days, and
103
+ * `Date` round-trips are pure overhead on a search's inner loop.
104
+ * @returns unrefracted topocentric azimuth/altitude, plus the local hour angle
105
+ * and topocentric distance the same evaluation already produced.
106
+ */
107
+ export function topoAltAzUnrefracted(bodyEqj, ut, observer) {
108
+ const tt = ttDaysFromUt(ut);
109
+ const gast = siderealDeg(ut);
110
+ const gcObserver = observerGeoVectorOfDate(gast, observer);
111
+ const bodyOfDate = gyration(bodyEqj, tt, PrecessDirection.From2000);
112
+ const datevect = {
113
+ x: bodyOfDate.x - gcObserver.x,
114
+ y: bodyOfDate.y - gcObserver.y,
115
+ z: bodyOfDate.z - gcObserver.z
116
+ };
117
+ const { raDeg, decDeg, distanceAu } = equatorialFromVector(datevect);
118
+ const sinlat = Math.sin(observer.latitudeDeg * DEG2RAD);
119
+ const coslat = Math.cos(observer.latitudeDeg * DEG2RAD);
120
+ const sinlon = Math.sin(observer.longitudeDeg * DEG2RAD);
121
+ const coslon = Math.cos(observer.longitudeDeg * DEG2RAD);
122
+ const sindc = Math.sin(decDeg * DEG2RAD);
123
+ const cosdc = Math.cos(decDeg * DEG2RAD);
124
+ // upstream's ra*HOUR2RAD === raDeg*DEG2RAD under this port's degrees-only RA convention.
125
+ const sinra = Math.sin(raDeg * DEG2RAD);
126
+ const cosra = Math.cos(raDeg * DEG2RAD);
127
+ const uze = { x: coslat * coslon, y: coslat * sinlon, z: sinlat };
128
+ const une = { x: -sinlat * coslon, y: -sinlat * sinlon, z: coslat };
129
+ const uwe = { x: sinlon, y: -coslon, z: 0 };
130
+ const spinAngle = -gast;
131
+ const uz = spin(spinAngle, uze);
132
+ const un = spin(spinAngle, une);
133
+ const uw = spin(spinAngle, uwe);
134
+ const p = { x: cosdc * cosra, y: cosdc * sinra, z: sindc };
135
+ const pz = p.x * uz.x + p.y * uz.y + p.z * uz.z;
136
+ const pn = p.x * un.x + p.y * un.y + p.z * un.z;
137
+ const pw = p.x * uw.x + p.y * uw.y + p.z * uw.z;
138
+ const proj = Math.hypot(pn, pw);
139
+ let az;
140
+ if (proj > 0) {
141
+ az = -RAD2DEG * Math.atan2(pw, pn);
142
+ if (az < 0)
143
+ az += 360;
144
+ }
145
+ else {
146
+ az = 0;
147
+ }
148
+ const zd = RAD2DEG * Math.atan2(proj, pz);
149
+ let hourAngleDeg = (gast + observer.longitudeDeg - raDeg) % 360;
150
+ if (hourAngleDeg < 0)
151
+ hourAngleDeg += 360;
152
+ return { azDeg: az, altDeg: 90 - zd, hourAngleDeg, distanceAu };
153
+ }
154
+ /**
155
+ * UPSTREAM: `Refraction('normal', altitude)`, astronomy.ts ~7176 — the
156
+ * Saemundsson/Meeus atmospheric-lift formula (JPL Horizons' algorithm per
157
+ * upstream's comment), gradually reduced toward the nadir so a refracted
158
+ * altitude never drops below -90.
159
+ */
160
+ export function refractionDeg(altitudeDeg) {
161
+ if (altitudeDeg < -90.0 || altitudeDeg > 90.0)
162
+ return 0.0;
163
+ let hd = altitudeDeg;
164
+ if (hd < -1.0)
165
+ hd = -1.0;
166
+ let refr = (1.02 / Math.tan((hd + 10.3 / (hd + 5.11)) * DEG2RAD)) / 60.0;
167
+ if (altitudeDeg < -1.0) {
168
+ // Gradually reduce refraction toward the nadir so we never get an
169
+ // altitude angle less than -90 degrees.
170
+ refr *= (altitudeDeg + 90.0) / 89.0;
171
+ }
172
+ return refr;
173
+ }
174
+ function refract(unrefracted) {
175
+ return { azDeg: unrefracted.azDeg, altDeg: unrefracted.altDeg + refractionDeg(unrefracted.altDeg) };
176
+ }
177
+ /**
178
+ * Topocentric azimuth/altitude of the Sun — parallax (via the observer's
179
+ * geocentric position, WGS-84 flattening, elevation) and atmospheric
180
+ * refraction (upstream `'normal'` model) both applied.
181
+ */
182
+ export function sunAltAz(time, observer) {
183
+ assertSupported(time);
184
+ assertObserver(observer);
185
+ const ut = utDays(time);
186
+ return refract(topoAltAzUnrefracted(sunGeoVectorEqj(ttDaysFromUt(ut)), ut, observer));
187
+ }
188
+ /**
189
+ * Topocentric azimuth/altitude of the Moon — parallax (via the observer's
190
+ * geocentric position, WGS-84 flattening, elevation) and atmospheric
191
+ * refraction (upstream `'normal'` model) both applied.
192
+ */
193
+ export function moonAltAz(time, observer) {
194
+ assertSupported(time);
195
+ assertObserver(observer);
196
+ const ut = utDays(time);
197
+ return refract(topoAltAzUnrefracted(moonGeoVectorEqj(ttDaysFromUt(ut)), ut, observer));
198
+ }
@@ -0,0 +1,16 @@
1
+ export interface Observer {
2
+ latitudeDeg: number;
3
+ longitudeDeg: number;
4
+ elevationM?: number;
5
+ }
6
+ export declare class AlmanacOutOfRangeError extends RangeError {
7
+ constructor(msg?: string);
8
+ }
9
+ export declare const SUPPORTED_MIN: number;
10
+ export declare const SUPPORTED_MAX: number;
11
+ /** Point in time: [min, max). */
12
+ export declare function assertSupported(d: Date): void;
13
+ /** Window END: [min, max] — the exact full-range window [min, max) is legal. */
14
+ export declare function assertSupportedWindowEnd(d: Date): void;
15
+ /** Cross-port rule: all instants are integer epoch ms (JS Dates already are). */
16
+ export declare function assertObserver(o: Observer): void;
package/dist/types.js ADDED
@@ -0,0 +1,31 @@
1
+ export class AlmanacOutOfRangeError extends RangeError {
2
+ constructor(msg = 'time outside supported interval 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z') { super(msg); this.name = 'AlmanacOutOfRangeError'; }
3
+ }
4
+ export const SUPPORTED_MIN = Date.UTC(1950, 0, 1);
5
+ export const SUPPORTED_MAX = Date.UTC(2101, 0, 1);
6
+ /** Point in time: [min, max). */
7
+ export function assertSupported(d) {
8
+ const t = d.getTime();
9
+ if (!Number.isFinite(t))
10
+ throw new RangeError('invalid Date'); // validation, not out-of-range
11
+ if (t < SUPPORTED_MIN || t >= SUPPORTED_MAX)
12
+ throw new AlmanacOutOfRangeError();
13
+ }
14
+ /** Window END: [min, max] — the exact full-range window [min, max) is legal. */
15
+ export function assertSupportedWindowEnd(d) {
16
+ const t = d.getTime();
17
+ if (!Number.isFinite(t))
18
+ throw new RangeError('invalid Date');
19
+ if (t < SUPPORTED_MIN || t > SUPPORTED_MAX)
20
+ throw new AlmanacOutOfRangeError();
21
+ }
22
+ /** Cross-port rule: all instants are integer epoch ms (JS Dates already are). */
23
+ export function assertObserver(o) {
24
+ const { latitudeDeg: lat, longitudeDeg: lon, elevationM: elev = 0 } = o;
25
+ if (!(lat >= -90 && lat <= 90))
26
+ throw new RangeError(`latitudeDeg out of range: ${lat}`);
27
+ if (!(lon >= -180 && lon <= 180))
28
+ throw new RangeError(`longitudeDeg out of range: ${lon}`);
29
+ if (!(elev >= -500 && elev <= 10000))
30
+ throw new RangeError(`elevationM out of range: ${elev}`);
31
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@openwaters/almanac",
3
+ "version": "0.0.0-bootstrap",
4
+ "description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar eclipses.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/openwatersio/almanac.git",
9
+ "directory": "typescript"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "NOTICE"
23
+ ],
24
+ "//": "index.ts is a curated allow-list: ONLY the spec's public API table is exported — no guards, no L0/L1 internals",
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "test": "vitest run",
28
+ "prepack": "npm run build"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "^5.5.0",
32
+ "vitest": "^2.0.0"
33
+ }
34
+ }