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
package/src/riseset.ts ADDED
@@ -0,0 +1,229 @@
1
+ import { constants as Constants } from 'sweph';
2
+ import type { EphemerisCalculator } from './ephemeris.js';
3
+ import { logger } from './logger.js';
4
+ import { PLANET_NAMES, type RiseSetTime } from './types.js';
5
+
6
+ /**
7
+ * Calculator for rise, set, and meridian transit times
8
+ *
9
+ * @remarks
10
+ * Calculates when celestial bodies rise above and set below the horizon,
11
+ * plus upper and lower meridian transits. Handles circumpolar objects
12
+ * and atmospheric refraction corrections.
13
+ */
14
+ export class RiseSetCalculator {
15
+ /** Ephemeris calculator instance */
16
+ private ephem: EphemerisCalculator;
17
+
18
+ /**
19
+ * Create a new rise/set calculator
20
+ *
21
+ * @param ephem - Initialized ephemeris calculator
22
+ * @throws Error if ephemeris is not initialized
23
+ *
24
+ * @remarks
25
+ * The ephemeris calculator must be initialized before passing
26
+ * to the RiseSetCalculator constructor.
27
+ */
28
+ constructor(ephem: EphemerisCalculator) {
29
+ this.ephem = ephem;
30
+ }
31
+
32
+ /**
33
+ * Calculate rise, set, and meridian transit times for a celestial body
34
+ *
35
+ * Uses standard astronomical definitions:
36
+ * - Rise/Set: Upper limb of disc with atmospheric refraction considered
37
+ * - Atmospheric pressure: Estimated from altitude (sea level if altitude=0)
38
+ * - Temperature: 0°C (default assumption)
39
+ * - Upper meridian: Highest point in sky (culmination)
40
+ * - Lower meridian: Lowest point in sky (anti-culmination)
41
+ *
42
+ * Swiss Ephemeris return codes:
43
+ * - 0 or positive: Event found successfully
44
+ * - -1: Calculation error (hard failure)
45
+ * - -2: No event exists (circumpolar object)
46
+ *
47
+ * @param julianDay - Julian Day to start search from (typically midnight of target date)
48
+ * @param planetId - Swiss Ephemeris planet ID
49
+ * @param latitude - Observer latitude in degrees (-90 to 90)
50
+ * @param longitude - Observer longitude in degrees
51
+ * @param altitude - Observer altitude in meters (default: 0 = sea level)
52
+ * @returns Rise/set/transit times, or undefined fields if event doesn't occur
53
+ * @throws {Error} If ephemeris not initialized, invalid inputs, or hard calculation error
54
+ */
55
+ calculateRiseSet(
56
+ julianDay: number,
57
+ planetId: number,
58
+ latitude: number,
59
+ longitude: number,
60
+ altitude: number = 0
61
+ ): RiseSetTime {
62
+ if (!this.ephem.eph) {
63
+ throw new Error('Ephemeris not initialized');
64
+ }
65
+
66
+ // Input validation
67
+ if (!Number.isFinite(julianDay)) {
68
+ throw new Error('Invalid Julian Day: must be a finite number');
69
+ }
70
+ if (latitude < -90 || latitude > 90) {
71
+ throw new Error(`Invalid latitude: ${latitude} (must be -90 to 90)`);
72
+ }
73
+ if (!Number.isFinite(longitude)) {
74
+ throw new Error('Invalid longitude: must be a finite number');
75
+ }
76
+ if (!Number.isFinite(altitude)) {
77
+ throw new Error('Invalid altitude: must be a finite number');
78
+ }
79
+
80
+ const planetName = PLANET_NAMES[planetId];
81
+ if (!planetName) {
82
+ throw new Error(`Unknown planet ID: ${planetId}`);
83
+ }
84
+
85
+ const result: RiseSetTime = {
86
+ planet: planetName,
87
+ };
88
+
89
+ // Helper to call rise_trans and handle return codes
90
+ const calculateEvent = (eventType: number, eventName: string): Date | undefined => {
91
+ const eventResult = this.ephem.eph!.rise_trans(
92
+ julianDay,
93
+ planetId,
94
+ null,
95
+ Constants.SEFLG_SWIEPH,
96
+ eventType,
97
+ [longitude, latitude, altitude],
98
+ 0, // atpress: 0 = auto-estimate from altitude
99
+ 0 // attemp: 0°C
100
+ );
101
+
102
+ if (eventResult.flag === -1) {
103
+ throw new Error(
104
+ `${eventName} calculation failed for ${planetName}: ${eventResult.error || 'Unknown error'}`
105
+ );
106
+ } else if (eventResult.flag === -2) {
107
+ logger.debug(`No ${eventName} for ${planetName} (circumpolar or no event)`, {
108
+ planet: planetName,
109
+ latitude,
110
+ });
111
+ return undefined;
112
+ } else if (Number.isFinite(eventResult.data)) {
113
+ return this.ephem.julianDayToDate(eventResult.data);
114
+ }
115
+ return undefined;
116
+ };
117
+
118
+ result.rise = calculateEvent(Constants.SE_CALC_RISE, 'rise');
119
+ result.set = calculateEvent(Constants.SE_CALC_SET, 'set');
120
+ result.upperMeridianTransit = calculateEvent(
121
+ Constants.SE_CALC_MTRANSIT,
122
+ 'upper meridian transit'
123
+ );
124
+ result.lowerMeridianTransit = calculateEvent(
125
+ Constants.SE_CALC_ITRANSIT,
126
+ 'lower meridian transit'
127
+ );
128
+
129
+ return result;
130
+ }
131
+
132
+ /**
133
+ * Get rise/set times for all planets for a given date
134
+ *
135
+ * @param date - Date/time to use as search anchor (typically current instant or midnight of target date)
136
+ * @param latitude - Observer latitude in degrees (-90 to 90)
137
+ * @param longitude - Observer longitude in degrees
138
+ * @param altitude - Observer altitude in meters (default: 0)
139
+ * @returns Array of rise/set times for all planets
140
+ * @throws Error if ephemeris not initialized or invalid inputs
141
+ *
142
+ * @remarks
143
+ * Calculates for Sun through Pluto. Some fields may be undefined
144
+ * for circumpolar objects at extreme latitudes.
145
+ *
146
+ * Swiss Ephemeris searches for the NEXT event after the given instant,
147
+ * so to get events for a specific civil date, pass midnight of that date.
148
+ */
149
+ async getAllRiseSet(
150
+ date: Date,
151
+ latitude: number,
152
+ longitude: number,
153
+ altitude: number = 0
154
+ ): Promise<RiseSetTime[]> {
155
+ // Validate shared inputs once - these are configuration errors, not planet-specific failures
156
+ if (!this.ephem.eph) {
157
+ throw new Error('Ephemeris not initialized');
158
+ }
159
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
160
+ throw new Error('Invalid date');
161
+ }
162
+ if (latitude < -90 || latitude > 90) {
163
+ throw new Error(`Invalid latitude: ${latitude} (must be -90 to 90)`);
164
+ }
165
+ if (!Number.isFinite(longitude)) {
166
+ throw new Error('Invalid longitude: must be a finite number');
167
+ }
168
+ if (!Number.isFinite(altitude)) {
169
+ throw new Error('Invalid altitude: must be a finite number');
170
+ }
171
+
172
+ const jd = this.ephem.dateToJulianDay(date);
173
+ const results: RiseSetTime[] = [];
174
+
175
+ // Calculate for Sun through Pluto (0-9)
176
+ // Only catch planet-specific computation failures (e.g., circumpolar edge cases)
177
+ for (let planetId = 0; planetId <= 9; planetId++) {
178
+ try {
179
+ const riseSet = this.calculateRiseSet(jd, planetId, latitude, longitude, altitude);
180
+ results.push(riseSet);
181
+ } catch (error) {
182
+ // Planet-specific calculation failure - log and continue with other planets
183
+ logger.warn(`Failed to calculate rise/set for planet ${planetId}`, {
184
+ error: error instanceof Error ? error.message : String(error),
185
+ });
186
+ }
187
+ }
188
+
189
+ return results;
190
+ }
191
+
192
+ /**
193
+ * Get Sun rise/set times for the current instant
194
+ *
195
+ * @param latitude - Observer latitude in degrees (-90 to 90)
196
+ * @param longitude - Observer longitude in degrees
197
+ * @param altitude - Observer altitude in meters (default: 0)
198
+ * @returns Rise/set times for the Sun
199
+ * @throws Error if ephemeris not initialized or invalid inputs
200
+ *
201
+ * @remarks
202
+ * Searches for the next sunrise/sunset after the current instant.
203
+ * If called in the afternoon, sunrise will be tomorrow.
204
+ * For events on a specific civil date, use calculateRiseSet with midnight JD.
205
+ */
206
+ async getSunRiseSet(
207
+ latitude: number,
208
+ longitude: number,
209
+ altitude: number = 0
210
+ ): Promise<RiseSetTime> {
211
+ // Validate inputs before calculation
212
+ if (!this.ephem.eph) {
213
+ throw new Error('Ephemeris not initialized');
214
+ }
215
+ if (latitude < -90 || latitude > 90) {
216
+ throw new Error(`Invalid latitude: ${latitude} (must be -90 to 90)`);
217
+ }
218
+ if (!Number.isFinite(longitude)) {
219
+ throw new Error('Invalid longitude: must be a finite number');
220
+ }
221
+ if (!Number.isFinite(altitude)) {
222
+ throw new Error('Invalid altitude: must be a finite number');
223
+ }
224
+
225
+ const now = new Date();
226
+ const jd = this.ephem.dateToJulianDay(now);
227
+ return this.calculateRiseSet(jd, 0, latitude, longitude, altitude); // Sun is planet ID 0
228
+ }
229
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Time conversion utilities for astrology calculations
3
+ *
4
+ * Provides centralized time handling to ensure consistent conversion
5
+ * between local time and UTC across the entire codebase.
6
+ *
7
+ * DST Policy:
8
+ * - Nonexistent times (spring-forward gap): Use 'compatible' by default,
9
+ * which shifts forward. Use 'reject' for birth times to surface ambiguity.
10
+ * - Ambiguous times (fall-back overlap): Use 'compatible' by default,
11
+ * which prefers the earlier occurrence. Use 'reject' for birth times.
12
+ * - Offset sign convention: Positive = east of UTC, Negative = west of UTC
13
+ * (e.g., America/Los_Angeles = -480 winter, -420 summer; Asia/Tokyo = 540)
14
+ */
15
+
16
+ import { Temporal } from '@js-temporal/polyfill';
17
+
18
+ export interface LocalDateTime {
19
+ year: number;
20
+ month: number; // 1-12 (not 0-indexed)
21
+ day: number;
22
+ hour: number; // 0-23
23
+ minute: number;
24
+ second?: number;
25
+ }
26
+
27
+ export type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
28
+
29
+ /**
30
+ * Convert local time to UTC using timezone information
31
+ *
32
+ * @param local - Local date/time components
33
+ * @param timezone - IANA timezone string (e.g., 'America/New_York')
34
+ * @param disambiguation - How to handle DST ambiguity ('compatible' default, 'reject' for birth times)
35
+ * @returns UTC Date object
36
+ * @throws Error if timezone is invalid or time is ambiguous/nonexistent with 'reject'
37
+ */
38
+ export function localToUTC(
39
+ local: LocalDateTime,
40
+ timezone: string,
41
+ disambiguation: Disambiguation = 'compatible'
42
+ ): Date {
43
+ // Validate timezone first
44
+ if (!isValidTimezone(timezone)) {
45
+ throw new Error(`Invalid timezone: ${timezone}`);
46
+ }
47
+
48
+ // Build Temporal.PlainDateTime from LocalDateTime
49
+ const plainDateTime = Temporal.PlainDateTime.from({
50
+ year: local.year,
51
+ month: local.month,
52
+ day: local.day,
53
+ hour: local.hour,
54
+ minute: local.minute,
55
+ second: local.second ?? 0,
56
+ });
57
+
58
+ // Convert to ZonedDateTime in the target timezone
59
+ const zonedDateTime = plainDateTime.toZonedDateTime(timezone, { disambiguation });
60
+
61
+ // Return as Date
62
+ return new Date(zonedDateTime.epochMilliseconds);
63
+ }
64
+
65
+ /**
66
+ * Convert UTC time to local time in specified timezone
67
+ *
68
+ * @param utc - UTC Date object
69
+ * @param timezone - IANA timezone string
70
+ * @returns Local date/time components
71
+ */
72
+ export function utcToLocal(utc: Date, timezone: string): LocalDateTime {
73
+ // Convert Date to Temporal.Instant
74
+ const instant = Temporal.Instant.fromEpochMilliseconds(utc.getTime());
75
+
76
+ // Convert to ZonedDateTime in target timezone
77
+ const zonedDateTime = instant.toZonedDateTimeISO(timezone);
78
+
79
+ // Return numeric components
80
+ return {
81
+ year: zonedDateTime.year,
82
+ month: zonedDateTime.month,
83
+ day: zonedDateTime.day,
84
+ hour: zonedDateTime.hour,
85
+ minute: zonedDateTime.minute,
86
+ second: zonedDateTime.second,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Validate if a timezone string is valid
92
+ *
93
+ * @param timezone - Timezone string to validate
94
+ * @returns true if valid IANA timezone or UTC, false otherwise
95
+ */
96
+ export function isValidTimezone(timezone: string): boolean {
97
+ if (!timezone || timezone.length === 0) {
98
+ return false;
99
+ }
100
+
101
+ try {
102
+ // Validate by attempting to create a ZonedDateTime
103
+ // This accepts any valid IANA timezone identifier
104
+ const testDate = Temporal.PlainDateTime.from({
105
+ year: 2000,
106
+ month: 1,
107
+ day: 1,
108
+ hour: 0,
109
+ minute: 0,
110
+ });
111
+ testDate.toZonedDateTime(timezone);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Get timezone offset in minutes for a specific date
120
+ * Handles DST transitions correctly
121
+ *
122
+ * @param date - Date to get offset for
123
+ * @param timezone - IANA timezone string
124
+ * @returns Offset in minutes (positive = east of UTC, negative = west of UTC)
125
+ * Examples: America/Los_Angeles winter = -480, summer = -420; Asia/Tokyo = 540
126
+ */
127
+ export function getTimezoneOffset(date: Date, timezone: string): number {
128
+ // Convert Date to Temporal.Instant
129
+ const instant = Temporal.Instant.fromEpochMilliseconds(date.getTime());
130
+
131
+ // Convert to ZonedDateTime in target timezone
132
+ const zonedDateTime = instant.toZonedDateTimeISO(timezone);
133
+
134
+ // Get offset from the ZonedDateTime itself
135
+ // offsetNanoseconds is positive for east, negative for west
136
+ const offsetMinutes = zonedDateTime.offsetNanoseconds / (1000 * 1000 * 1000 * 60);
137
+
138
+ return offsetMinutes;
139
+ }
140
+
141
+ /**
142
+ * Add calendar days to a local date in a specific timezone
143
+ * Properly handles month/year rollovers and DST transitions
144
+ *
145
+ * @param local - Starting local date/time
146
+ * @param timezone - IANA timezone string
147
+ * @param days - Number of days to add (can be negative)
148
+ * @returns UTC Date representing the new local date/time
149
+ */
150
+ export function addLocalDays(local: LocalDateTime, timezone: string, days: number): Date {
151
+ // Convert to Temporal for proper calendar math
152
+ const plainDateTime = Temporal.PlainDateTime.from({
153
+ year: local.year,
154
+ month: local.month,
155
+ day: local.day,
156
+ hour: local.hour,
157
+ minute: local.minute,
158
+ second: local.second ?? 0,
159
+ });
160
+
161
+ // Add days using Temporal's calendar-aware addition
162
+ const newPlainDateTime = plainDateTime.add({ days });
163
+
164
+ // Convert back to UTC via the timezone
165
+ const zonedDateTime = newPlainDateTime.toZonedDateTime(timezone);
166
+ return new Date(zonedDateTime.epochMilliseconds);
167
+ }