ether-to-astro 1.0.0

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.
Files changed (138) hide show
  1. package/.env.example +13 -0
  2. package/.github/pull_request_template.md +16 -0
  3. package/.github/workflows/release.yml +35 -0
  4. package/.github/workflows/test.yml +32 -0
  5. package/AGENTS.md +99 -0
  6. package/LICENSE +18 -0
  7. package/NOTICE.md +45 -0
  8. package/README.md +301 -0
  9. package/SETUP.md +70 -0
  10. package/TESTING_SUMMARY.md +238 -0
  11. package/TEST_SUITE_STATUS.md +218 -0
  12. package/biome.json +48 -0
  13. package/dist/astro-service.d.ts +98 -0
  14. package/dist/astro-service.js +496 -0
  15. package/dist/chart-types.d.ts +52 -0
  16. package/dist/chart-types.js +51 -0
  17. package/dist/charts.d.ts +125 -0
  18. package/dist/charts.js +324 -0
  19. package/dist/cli.d.ts +7 -0
  20. package/dist/cli.js +472 -0
  21. package/dist/constants.d.ts +81 -0
  22. package/dist/constants.js +76 -0
  23. package/dist/eclipses.d.ts +85 -0
  24. package/dist/eclipses.js +184 -0
  25. package/dist/ephemeris.d.ts +120 -0
  26. package/dist/ephemeris.js +379 -0
  27. package/dist/formatter.d.ts +2 -0
  28. package/dist/formatter.js +22 -0
  29. package/dist/houses.d.ts +82 -0
  30. package/dist/houses.js +169 -0
  31. package/dist/index.d.ts +14 -0
  32. package/dist/index.js +150 -0
  33. package/dist/loader.d.ts +2 -0
  34. package/dist/loader.js +31 -0
  35. package/dist/logger.d.ts +25 -0
  36. package/dist/logger.js +73 -0
  37. package/dist/profile-store.d.ts +48 -0
  38. package/dist/profile-store.js +156 -0
  39. package/dist/riseset.d.ts +82 -0
  40. package/dist/riseset.js +185 -0
  41. package/dist/storage.d.ts +10 -0
  42. package/dist/storage.js +40 -0
  43. package/dist/time-utils.d.ts +68 -0
  44. package/dist/time-utils.js +136 -0
  45. package/dist/tool-registry.d.ts +35 -0
  46. package/dist/tool-registry.js +307 -0
  47. package/dist/tool-result.d.ts +175 -0
  48. package/dist/tool-result.js +188 -0
  49. package/dist/transits.d.ts +108 -0
  50. package/dist/transits.js +263 -0
  51. package/dist/types.d.ts +450 -0
  52. package/dist/types.js +161 -0
  53. package/example-usage.md +131 -0
  54. package/natal-chart.json +187 -0
  55. package/package.json +61 -0
  56. package/scripts/download-ephemeris.js +115 -0
  57. package/setup.sh +21 -0
  58. package/src/astro-service.ts +710 -0
  59. package/src/chart-types.ts +125 -0
  60. package/src/charts.ts +399 -0
  61. package/src/cli.ts +694 -0
  62. package/src/constants.ts +89 -0
  63. package/src/eclipses.ts +226 -0
  64. package/src/ephemeris.ts +437 -0
  65. package/src/formatter.ts +25 -0
  66. package/src/houses.ts +202 -0
  67. package/src/index.ts +170 -0
  68. package/src/loader.ts +36 -0
  69. package/src/logger.ts +104 -0
  70. package/src/profile-store.ts +285 -0
  71. package/src/riseset.ts +229 -0
  72. package/src/time-utils.ts +167 -0
  73. package/src/tool-registry.ts +357 -0
  74. package/src/tool-result.ts +283 -0
  75. package/src/transits.ts +352 -0
  76. package/src/types.ts +547 -0
  77. package/tests/README.md +173 -0
  78. package/tests/TESTING_STRATEGY.md +178 -0
  79. package/tests/fixtures/bowen-yang-chart.ts +69 -0
  80. package/tests/fixtures/calculate-expected.ts +81 -0
  81. package/tests/fixtures/expected-results.ts +117 -0
  82. package/tests/fixtures/generate-expected-simple.ts +94 -0
  83. package/tests/helpers/date-fixtures.ts +15 -0
  84. package/tests/helpers/ephem.ts +11 -0
  85. package/tests/helpers/temp.ts +9 -0
  86. package/tests/setup.ts +11 -0
  87. package/tests/unit/astro-service.test.ts +323 -0
  88. package/tests/unit/chart-types.test.ts +18 -0
  89. package/tests/unit/charts-errors.test.ts +42 -0
  90. package/tests/unit/charts.test.ts +157 -0
  91. package/tests/unit/cli-commands.test.ts +82 -0
  92. package/tests/unit/cli-profiles.test.ts +128 -0
  93. package/tests/unit/cli.test.ts +191 -0
  94. package/tests/unit/constants.test.ts +26 -0
  95. package/tests/unit/correctness-critical.test.ts +408 -0
  96. package/tests/unit/eclipses.test.ts +108 -0
  97. package/tests/unit/ephemeris.test.ts +213 -0
  98. package/tests/unit/error-handling.test.ts +116 -0
  99. package/tests/unit/formatter.test.ts +29 -0
  100. package/tests/unit/houses-errors.test.ts +27 -0
  101. package/tests/unit/houses-validation.test.ts +164 -0
  102. package/tests/unit/houses.test.ts +205 -0
  103. package/tests/unit/profile-store.test.ts +163 -0
  104. package/tests/unit/real-user-charts.test.ts +148 -0
  105. package/tests/unit/riseset.test.ts +106 -0
  106. package/tests/unit/solver-edges.test.ts +197 -0
  107. package/tests/unit/time-utils-temporal.test.ts +303 -0
  108. package/tests/unit/time-utils.test.ts +173 -0
  109. package/tests/unit/tool-registry.test.ts +222 -0
  110. package/tests/unit/tool-result.test.ts +45 -0
  111. package/tests/unit/transit-correctness.test.ts +78 -0
  112. package/tests/unit/transits.test.ts +238 -0
  113. package/tests/validation/README.md +32 -0
  114. package/tests/validation/adapters/astrolog.ts +306 -0
  115. package/tests/validation/adapters/internal.ts +184 -0
  116. package/tests/validation/compare/eclipses.ts +47 -0
  117. package/tests/validation/compare/houses.ts +76 -0
  118. package/tests/validation/compare/positions.ts +104 -0
  119. package/tests/validation/compare/riseSet.ts +48 -0
  120. package/tests/validation/compare/roots.ts +90 -0
  121. package/tests/validation/compare/transits.ts +69 -0
  122. package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
  123. package/tests/validation/fixtures/eclipses/core.ts +14 -0
  124. package/tests/validation/fixtures/houses/core.ts +47 -0
  125. package/tests/validation/fixtures/positions/core.ts +159 -0
  126. package/tests/validation/fixtures/rise-set/core.ts +20 -0
  127. package/tests/validation/fixtures/roots/core.ts +47 -0
  128. package/tests/validation/fixtures/transits/core.ts +61 -0
  129. package/tests/validation/fixtures/transits/dst.ts +21 -0
  130. package/tests/validation/oracle.spec.ts +129 -0
  131. package/tests/validation/utils/denseRootOracle.ts +269 -0
  132. package/tests/validation/utils/fixtureTypes.ts +146 -0
  133. package/tests/validation/utils/report.ts +60 -0
  134. package/tests/validation/utils/tolerances.ts +23 -0
  135. package/tests/validation/validation.spec.ts +836 -0
  136. package/tools/color-picker.html +388 -0
  137. package/tsconfig.json +17 -0
  138. package/vitest.config.ts +31 -0
@@ -0,0 +1,352 @@
1
+ import type { EphemerisCalculator } from './ephemeris.js';
2
+ import {
3
+ ASPECTS,
4
+ type NatalChart,
5
+ PLANETS,
6
+ type PlanetPosition,
7
+ type Transit,
8
+ type TransitData,
9
+ type TransitResponse,
10
+ } from './types.js';
11
+
12
+ // Constants for transit calculations
13
+ const EXACT_TIME_ORB_THRESHOLD = 2; // degrees - only calculate exact times within this orb (inclusive)
14
+ const EXACT_TIME_SEARCH_WINDOW = 5; // days - minimum solver search window for root finding
15
+ const PREVIEW_HORIZON_DAYS = 90; // days - product preview horizon for exposing exactTime
16
+
17
+ /**
18
+ * Deduplicate transits by keeping the best hit per aspect
19
+ * Priority: exact time > smallest orb > earliest exact time > deterministic tiebreakers
20
+ *
21
+ * @param transits - Array of transits to deduplicate
22
+ * @returns Deduplicated array with one transit per unique aspect key
23
+ *
24
+ * @remarks
25
+ * This is the production dedupe logic used by get_transits when collecting
26
+ * transits over multiple days. The key is: transitingPlanet + natalPlanet + aspect.
27
+ * Final tiebreakers use longitude and planet names for deterministic ordering.
28
+ */
29
+ export function deduplicateTransits(transits: Transit[]): Transit[] {
30
+ const bestTransits = new Map<string, Transit>();
31
+
32
+ for (const t of transits) {
33
+ const key = `${t.transitingPlanet}-${t.natalPlanet}-${t.aspect}`;
34
+ const existing = bestTransits.get(key);
35
+
36
+ if (!existing) {
37
+ bestTransits.set(key, t);
38
+ } else {
39
+ // Priority: exact > smallest orb > earliest exact time > deterministic tiebreakers
40
+ const shouldReplace =
41
+ (t.exactTime && !existing.exactTime) ||
42
+ (!!t.exactTime === !!existing.exactTime && t.orb < existing.orb) ||
43
+ (t.orb === existing.orb &&
44
+ t.exactTime &&
45
+ existing.exactTime &&
46
+ t.exactTime < existing.exactTime) ||
47
+ (t.orb === existing.orb &&
48
+ !t.exactTime &&
49
+ !existing.exactTime &&
50
+ t.transitLongitude < existing.transitLongitude) ||
51
+ (t.orb === existing.orb &&
52
+ !t.exactTime &&
53
+ !existing.exactTime &&
54
+ t.transitLongitude === existing.transitLongitude &&
55
+ t.natalLongitude < existing.natalLongitude) ||
56
+ (t.orb === existing.orb &&
57
+ !t.exactTime &&
58
+ !existing.exactTime &&
59
+ t.transitLongitude === existing.transitLongitude &&
60
+ t.natalLongitude === existing.natalLongitude &&
61
+ t.transitingPlanet < existing.transitingPlanet) ||
62
+ (t.orb === existing.orb &&
63
+ !t.exactTime &&
64
+ !existing.exactTime &&
65
+ t.transitLongitude === existing.transitLongitude &&
66
+ t.natalLongitude === existing.natalLongitude &&
67
+ t.transitingPlanet === existing.transitingPlanet &&
68
+ t.natalPlanet < existing.natalPlanet);
69
+
70
+ if (shouldReplace) {
71
+ bestTransits.set(key, t);
72
+ }
73
+ }
74
+ }
75
+
76
+ return Array.from(bestTransits.values());
77
+ }
78
+
79
+ /**
80
+ * Calculator for astrological transits and aspects
81
+ *
82
+ * @remarks
83
+ * Analyzes relationships between current planetary positions (transits)
84
+ * and natal chart positions. Calculates aspects, orbs, and exact timing
85
+ * when aspects become perfect.
86
+ */
87
+ export class TransitCalculator {
88
+ /** Ephemeris calculator instance */
89
+ private ephem: EphemerisCalculator;
90
+ private readonly exactTimeSupportedPlanetIds = new Set<number>(Object.values(PLANETS));
91
+
92
+ /**
93
+ * Create a new transit calculator
94
+ *
95
+ * @param ephem - Initialized ephemeris calculator
96
+ * @throws Error if ephemeris is not initialized
97
+ *
98
+ * @remarks
99
+ * The ephemeris calculator must be initialized before passing
100
+ * to the TransitCalculator constructor.
101
+ */
102
+ constructor(ephem: EphemerisCalculator) {
103
+ this.ephem = ephem;
104
+ }
105
+
106
+ /**
107
+ * Find all active transits between two sets of planets
108
+ *
109
+ * @param transitingPlanets - Current planetary positions
110
+ * @param natalPlanets - Birth chart planetary positions
111
+ * @param currentJD - Current Julian Day
112
+ * @returns Array of active transits with aspect details
113
+ *
114
+ * @remarks
115
+ * Checks all combinations of transiting and natal planets against
116
+ * all defined aspects. Includes exact time resolution for close aspects.
117
+ *
118
+ * Exact-time policy:
119
+ * - Solver computes candidate roots in a bounded interval, capped to PREVIEW_HORIZON_DAYS.
120
+ * - Product layer exposes exactTime only when root is within PREVIEW_HORIZON_DAYS.
121
+ * - exactTimeStatus communicates why exactTime may be omitted.
122
+ * - exactTimeStatus is only set when exact-time resolution is attempted
123
+ * (i.e. orb <= EXACT_TIME_ORB_THRESHOLD). When orb is wider, exactTimeStatus is undefined.
124
+ */
125
+ findTransits(
126
+ transitingPlanets: PlanetPosition[],
127
+ natalPlanets: PlanetPosition[],
128
+ currentJD: number
129
+ ): Transit[] {
130
+ const transits: Transit[] = [];
131
+
132
+ for (const transitPlanet of transitingPlanets) {
133
+ for (const natalPlanet of natalPlanets) {
134
+ const angle = this.ephem.calculateAspectAngle(
135
+ transitPlanet.longitude,
136
+ natalPlanet.longitude
137
+ );
138
+
139
+ for (const aspect of ASPECTS) {
140
+ const orb = Math.abs(angle - aspect.angle);
141
+
142
+ if (orb <= aspect.orb) {
143
+ const heuristicApplying = this.isApplying(
144
+ transitPlanet.longitude,
145
+ natalPlanet.longitude,
146
+ transitPlanet.speed,
147
+ aspect.angle
148
+ );
149
+
150
+ let exactTime: Date | undefined;
151
+ let exactTimeStatus: Transit['exactTimeStatus'];
152
+ let isApplying = heuristicApplying;
153
+ if (orb <= EXACT_TIME_ORB_THRESHOLD) {
154
+ const exactResult = this.calculateExactTransitTime(
155
+ transitPlanet,
156
+ natalPlanet,
157
+ aspect,
158
+ currentJD,
159
+ heuristicApplying
160
+ );
161
+ exactTime = exactResult.exactTime;
162
+ exactTimeStatus = exactResult.status;
163
+
164
+ // Strong applying/separating policy:
165
+ // selected root in future => applying, past => separating
166
+ if (exactResult.selectedRoot != null) {
167
+ isApplying = exactResult.selectedRoot >= currentJD;
168
+ }
169
+ }
170
+
171
+ transits.push({
172
+ transitingPlanet: transitPlanet.planet,
173
+ natalPlanet: natalPlanet.planet,
174
+ aspect: aspect.name,
175
+ orb,
176
+ exactTime,
177
+ exactTimeStatus,
178
+ isApplying,
179
+ transitLongitude: transitPlanet.longitude,
180
+ natalLongitude: natalPlanet.longitude,
181
+ });
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ return transits.sort((a, b) => a.orb - b.orb);
188
+ }
189
+
190
+ /**
191
+ * Calculate exact time when a transit aspect becomes perfect
192
+ *
193
+ * @param transitingPlanet - Current transiting planet position
194
+ * @param natalPlanet - Natal planet position
195
+ * @param aspect - Aspect configuration
196
+ * @param currentJD - Current Julian Day
197
+ * @param heuristicApplying - Applying/separating estimate used as fallback selector
198
+ * @returns Exact-time resolution result including status and selected root
199
+ *
200
+ * @remarks
201
+ * Solver and product concerns are intentionally separated:
202
+ * - Solver: find candidate roots in [currentJD - searchWindow, currentJD + searchWindow]
203
+ * - Product: expose exactTime only when selected root is within PREVIEW_HORIZON_DAYS
204
+ *
205
+ * Status semantics:
206
+ * - within_preview: root found and exactTime exposed
207
+ * - outside_preview: root found but exactTime hidden by product policy
208
+ * - not_found: no root found in solver interval
209
+ * - unsupported_body: exact-time solver not supported for the transiting body
210
+ */
211
+ private calculateExactTransitTime(
212
+ transitingPlanet: PlanetPosition,
213
+ natalPlanet: PlanetPosition,
214
+ aspect: { name: string; angle: number; orb: number },
215
+ currentJD: number,
216
+ heuristicApplying: boolean
217
+ ): {
218
+ exactTime?: Date;
219
+ status: NonNullable<Transit['exactTimeStatus']>;
220
+ selectedRoot: number | null;
221
+ } {
222
+ // For non-conjunction/opposition aspects, there are 2 possible target longitudes
223
+ const target1 = (natalPlanet.longitude + aspect.angle) % 360;
224
+ const target2 = (natalPlanet.longitude - aspect.angle + 360) % 360;
225
+
226
+ const planetId = transitingPlanet.planetId;
227
+
228
+ // Skip exact time calculation for unsupported bodies
229
+ if (!this.exactTimeSupportedPlanetIds.has(planetId)) {
230
+ return { status: 'unsupported_body', selectedRoot: null };
231
+ }
232
+
233
+ // Calculate dynamic search window based on planet speed
234
+ // Slow movers (Saturn/Uranus/Neptune/Pluto) need wider windows,
235
+ // but solver horizon is capped to product preview horizon for bounded compute and aligned policy.
236
+ const speed = Math.abs(transitingPlanet.speed);
237
+ const daysToMove2Deg = speed > 0 ? 2 / speed : 30;
238
+ const searchWindow = Math.min(
239
+ Math.max(daysToMove2Deg, EXACT_TIME_SEARCH_WINDOW),
240
+ PREVIEW_HORIZON_DAYS
241
+ );
242
+
243
+ // Search both targets (for conjunction/opposition, they're the same or opposite)
244
+ // Solver returns all roots sorted earliest-first
245
+ const roots1 = this.ephem.findExactTransitTimes(
246
+ planetId,
247
+ target1,
248
+ currentJD - searchWindow,
249
+ currentJD + searchWindow
250
+ );
251
+ const roots2 =
252
+ aspect.angle !== 0 && aspect.angle !== 180
253
+ ? this.ephem.findExactTransitTimes(
254
+ planetId,
255
+ target2,
256
+ currentJD - searchWindow,
257
+ currentJD + searchWindow
258
+ )
259
+ : [];
260
+
261
+ // Combine all roots from both targets and sort
262
+ const allRoots = [...roots1, ...roots2].sort((a, b) => a - b);
263
+
264
+ // Split into past and future roots
265
+ const futureRoots = allRoots.filter((jd) => jd >= currentJD);
266
+ const pastRoots = allRoots.filter((jd) => jd < currentJD);
267
+
268
+ // Select root based on applying/separating:
269
+ // - Applying: pick earliest future root (approaching exact)
270
+ // - Separating: pick latest past root (just passed exact), fallback to earliest future
271
+ const selectedRoot = heuristicApplying
272
+ ? (futureRoots[0] ?? null)
273
+ : (pastRoots[pastRoots.length - 1] ?? futureRoots[0] ?? null);
274
+
275
+ if (selectedRoot === null) {
276
+ return { status: 'not_found', selectedRoot: null };
277
+ }
278
+
279
+ const daysUntilExact = selectedRoot - currentJD;
280
+
281
+ // Product policy: only show exact time if within preview horizon
282
+ // For outer planets, it's normal to be within 2° orb but months from exact
283
+ if (Math.abs(daysUntilExact) > PREVIEW_HORIZON_DAYS) {
284
+ return { status: 'outside_preview', selectedRoot };
285
+ }
286
+
287
+ return {
288
+ exactTime: this.ephem.julianDayToDate(selectedRoot),
289
+ status: 'within_preview',
290
+ selectedRoot,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Determine if an aspect is applying or separating
296
+ *
297
+ * @param transitLon - Transiting planet longitude
298
+ * @param natalLon - Natal planet longitude
299
+ * @param transitSpeed - Transiting planet's daily motion
300
+ * @param aspectAngle - Target aspect angle
301
+ * @returns true if applying, false if separating
302
+ *
303
+ * @remarks
304
+ * Applying: Aspect getting stronger (closer to exact)
305
+ * Separating: Aspect getting weaker (moving away from exact)
306
+ */
307
+ private isApplying(
308
+ transitLon: number,
309
+ natalLon: number,
310
+ transitSpeed: number,
311
+ aspectAngle: number
312
+ ): boolean {
313
+ if (transitSpeed === 0) return false;
314
+
315
+ const currentAngle = this.ephem.calculateAspectAngle(transitLon, natalLon);
316
+ const futureAngle = this.ephem.calculateAspectAngle(transitLon + transitSpeed, natalLon);
317
+
318
+ return Math.abs(futureAngle - aspectAngle) < Math.abs(currentAngle - aspectAngle);
319
+ }
320
+
321
+ /**
322
+ * Get all transits for a specific date
323
+ *
324
+ * @param natalChart - Birth chart data
325
+ * @param date - Date for transit calculation (interpreted as-is, no timezone conversion)
326
+ * @returns TransitResponse with all active transits
327
+ * @throws Error if natal chart is invalid
328
+ *
329
+ * @remarks
330
+ * Internal UTC primitive: calculates transits for the provided date/time as-is.
331
+ * Caller is responsible for any user-facing timezone semantics.
332
+ */
333
+ async getTransitsForDate(date: Date, natalChart: NatalChart): Promise<TransitResponse> {
334
+ const jd = this.ephem.dateToJulianDay(date);
335
+ // Get all major planets (Sun through Pluto)
336
+ const planetIds = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
337
+ const transitingPlanets = this.ephem.getAllPlanets(jd, planetIds);
338
+ const transits = this.findTransits(transitingPlanets, natalChart.planets || [], jd);
339
+
340
+ // Convert Transit[] to TransitData[] (serialize Date to ISO string)
341
+ const transitData: TransitData[] = transits.map((t) => ({
342
+ ...t,
343
+ exactTime: t.exactTime?.toISOString(),
344
+ }));
345
+
346
+ return {
347
+ date: date.toISOString().split('T')[0],
348
+ timezone: 'UTC',
349
+ transits: transitData,
350
+ };
351
+ }
352
+ }