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.
- package/.env.example +13 -0
- package/.github/pull_request_template.md +16 -0
- package/.github/workflows/release.yml +35 -0
- package/.github/workflows/test.yml +32 -0
- package/AGENTS.md +99 -0
- package/LICENSE +18 -0
- package/NOTICE.md +45 -0
- package/README.md +301 -0
- package/SETUP.md +70 -0
- package/TESTING_SUMMARY.md +238 -0
- package/TEST_SUITE_STATUS.md +218 -0
- package/biome.json +48 -0
- package/dist/astro-service.d.ts +98 -0
- package/dist/astro-service.js +496 -0
- package/dist/chart-types.d.ts +52 -0
- package/dist/chart-types.js +51 -0
- package/dist/charts.d.ts +125 -0
- package/dist/charts.js +324 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +472 -0
- package/dist/constants.d.ts +81 -0
- package/dist/constants.js +76 -0
- package/dist/eclipses.d.ts +85 -0
- package/dist/eclipses.js +184 -0
- package/dist/ephemeris.d.ts +120 -0
- package/dist/ephemeris.js +379 -0
- package/dist/formatter.d.ts +2 -0
- package/dist/formatter.js +22 -0
- package/dist/houses.d.ts +82 -0
- package/dist/houses.js +169 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +150 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +31 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +73 -0
- package/dist/profile-store.d.ts +48 -0
- package/dist/profile-store.js +156 -0
- package/dist/riseset.d.ts +82 -0
- package/dist/riseset.js +185 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +40 -0
- package/dist/time-utils.d.ts +68 -0
- package/dist/time-utils.js +136 -0
- package/dist/tool-registry.d.ts +35 -0
- package/dist/tool-registry.js +307 -0
- package/dist/tool-result.d.ts +175 -0
- package/dist/tool-result.js +188 -0
- package/dist/transits.d.ts +108 -0
- package/dist/transits.js +263 -0
- package/dist/types.d.ts +450 -0
- package/dist/types.js +161 -0
- package/example-usage.md +131 -0
- package/natal-chart.json +187 -0
- package/package.json +61 -0
- package/scripts/download-ephemeris.js +115 -0
- package/setup.sh +21 -0
- package/src/astro-service.ts +710 -0
- package/src/chart-types.ts +125 -0
- package/src/charts.ts +399 -0
- package/src/cli.ts +694 -0
- package/src/constants.ts +89 -0
- package/src/eclipses.ts +226 -0
- package/src/ephemeris.ts +437 -0
- package/src/formatter.ts +25 -0
- package/src/houses.ts +202 -0
- package/src/index.ts +170 -0
- package/src/loader.ts +36 -0
- package/src/logger.ts +104 -0
- package/src/profile-store.ts +285 -0
- package/src/riseset.ts +229 -0
- package/src/time-utils.ts +167 -0
- package/src/tool-registry.ts +357 -0
- package/src/tool-result.ts +283 -0
- package/src/transits.ts +352 -0
- package/src/types.ts +547 -0
- package/tests/README.md +173 -0
- package/tests/TESTING_STRATEGY.md +178 -0
- package/tests/fixtures/bowen-yang-chart.ts +69 -0
- package/tests/fixtures/calculate-expected.ts +81 -0
- package/tests/fixtures/expected-results.ts +117 -0
- package/tests/fixtures/generate-expected-simple.ts +94 -0
- package/tests/helpers/date-fixtures.ts +15 -0
- package/tests/helpers/ephem.ts +11 -0
- package/tests/helpers/temp.ts +9 -0
- package/tests/setup.ts +11 -0
- package/tests/unit/astro-service.test.ts +323 -0
- package/tests/unit/chart-types.test.ts +18 -0
- package/tests/unit/charts-errors.test.ts +42 -0
- package/tests/unit/charts.test.ts +157 -0
- package/tests/unit/cli-commands.test.ts +82 -0
- package/tests/unit/cli-profiles.test.ts +128 -0
- package/tests/unit/cli.test.ts +191 -0
- package/tests/unit/constants.test.ts +26 -0
- package/tests/unit/correctness-critical.test.ts +408 -0
- package/tests/unit/eclipses.test.ts +108 -0
- package/tests/unit/ephemeris.test.ts +213 -0
- package/tests/unit/error-handling.test.ts +116 -0
- package/tests/unit/formatter.test.ts +29 -0
- package/tests/unit/houses-errors.test.ts +27 -0
- package/tests/unit/houses-validation.test.ts +164 -0
- package/tests/unit/houses.test.ts +205 -0
- package/tests/unit/profile-store.test.ts +163 -0
- package/tests/unit/real-user-charts.test.ts +148 -0
- package/tests/unit/riseset.test.ts +106 -0
- package/tests/unit/solver-edges.test.ts +197 -0
- package/tests/unit/time-utils-temporal.test.ts +303 -0
- package/tests/unit/time-utils.test.ts +173 -0
- package/tests/unit/tool-registry.test.ts +222 -0
- package/tests/unit/tool-result.test.ts +45 -0
- package/tests/unit/transit-correctness.test.ts +78 -0
- package/tests/unit/transits.test.ts +238 -0
- package/tests/validation/README.md +32 -0
- package/tests/validation/adapters/astrolog.ts +306 -0
- package/tests/validation/adapters/internal.ts +184 -0
- package/tests/validation/compare/eclipses.ts +47 -0
- package/tests/validation/compare/houses.ts +76 -0
- package/tests/validation/compare/positions.ts +104 -0
- package/tests/validation/compare/riseSet.ts +48 -0
- package/tests/validation/compare/roots.ts +90 -0
- package/tests/validation/compare/transits.ts +69 -0
- package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
- package/tests/validation/fixtures/eclipses/core.ts +14 -0
- package/tests/validation/fixtures/houses/core.ts +47 -0
- package/tests/validation/fixtures/positions/core.ts +159 -0
- package/tests/validation/fixtures/rise-set/core.ts +20 -0
- package/tests/validation/fixtures/roots/core.ts +47 -0
- package/tests/validation/fixtures/transits/core.ts +61 -0
- package/tests/validation/fixtures/transits/dst.ts +21 -0
- package/tests/validation/oracle.spec.ts +129 -0
- package/tests/validation/utils/denseRootOracle.ts +269 -0
- package/tests/validation/utils/fixtureTypes.ts +146 -0
- package/tests/validation/utils/report.ts +60 -0
- package/tests/validation/utils/tolerances.ts +23 -0
- package/tests/validation/validation.spec.ts +836 -0
- package/tools/color-picker.html +388 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +31 -0
package/src/ephemeris.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import sweph, { constants as Constants } from 'sweph';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
import { PLANET_NAMES, type PlanetPosition, ZODIAC_SIGNS } from './types.js';
|
|
7
|
+
|
|
8
|
+
// Constants for exact transit time calculation
|
|
9
|
+
const DEFAULT_EXACT_TIME_TOLERANCE = 0.01; // degrees
|
|
10
|
+
const MAX_EXACT_TIME_ITERATIONS = 50;
|
|
11
|
+
const ROOT_DEDUP_EPSILON_DAYS = 1 / 1440; // 1 minute
|
|
12
|
+
const COARSE_SCAN_MAX_STEP_DAYS = 1;
|
|
13
|
+
const MAX_COARSE_SCAN_SAMPLES = 500;
|
|
14
|
+
const TANGENTIAL_ROOT_SCAN_FACTOR = 20; // candidate threshold in tolerance multiples
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ephemeris calculator wrapper for native Swiss Ephemeris (sweph)
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* Provides a high-level interface for planetary calculations using the
|
|
21
|
+
* Swiss Ephemeris Node bindings. Handles initialization,
|
|
22
|
+
* coordinate conversions, and common astrological calculations.
|
|
23
|
+
*
|
|
24
|
+
* All longitudes are tropical (not sidereal) and geocentric.
|
|
25
|
+
*/
|
|
26
|
+
export class EphemerisCalculator {
|
|
27
|
+
/** Native sweph module instance */
|
|
28
|
+
public eph: typeof sweph | null = null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Initialize the Swiss Ephemeris native module
|
|
32
|
+
*
|
|
33
|
+
* @returns Promise that resolves when initialization is complete
|
|
34
|
+
* @throws Error if module setup fails
|
|
35
|
+
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* Must be called before any other methods. Loads the Swiss Ephemeris
|
|
38
|
+
* data files and prepares the calculation engine.
|
|
39
|
+
*/
|
|
40
|
+
async init(): Promise<void> {
|
|
41
|
+
if (!this.eph) {
|
|
42
|
+
this.eph = sweph;
|
|
43
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const projectRoot = join(__dirname, '..');
|
|
45
|
+
const ephePath = join(projectRoot, 'data', 'ephemeris');
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
logger.info('Loading ephemeris files from filesystem', { ephePath });
|
|
49
|
+
const files = await readdir(ephePath);
|
|
50
|
+
const se1Files = files.filter((f) => f.endsWith('.se1'));
|
|
51
|
+
logger.info(`Found ${se1Files.length} .se1 files on filesystem`);
|
|
52
|
+
|
|
53
|
+
// Basic readability sanity check; native sweph reads files by path.
|
|
54
|
+
for (const filename of se1Files) {
|
|
55
|
+
const filePath = join(ephePath, filename);
|
|
56
|
+
const buffer = await readFile(filePath);
|
|
57
|
+
logger.info(`Detected ${filename} (${(buffer.length / 1024).toFixed(2)}KB)`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.eph.set_ephe_path(ephePath);
|
|
61
|
+
logger.info(`✅ Ephemeris path configured for native sweph: ${ephePath}`);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
logger.warn('⚠️ Failed to verify ephemeris files - continuing with sweph defaults', {
|
|
64
|
+
error: error instanceof Error ? error.message : String(error),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convert a JavaScript Date to Julian Day
|
|
72
|
+
*
|
|
73
|
+
* @param date - Date to convert (should be in UTC)
|
|
74
|
+
* @returns Julian Day number
|
|
75
|
+
* @throws Error if ephemeris not initialized
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* Julian Day is a continuous count of days since noon Universal Time
|
|
79
|
+
* on January 1, 4713 BCE. It's the standard time system for astronomical
|
|
80
|
+
* calculations.
|
|
81
|
+
*/
|
|
82
|
+
dateToJulianDay(date: Date): number {
|
|
83
|
+
if (!this.eph) throw new Error('Ephemeris not initialized');
|
|
84
|
+
|
|
85
|
+
const year = date.getUTCFullYear();
|
|
86
|
+
const month = date.getUTCMonth() + 1;
|
|
87
|
+
const day = date.getUTCDate();
|
|
88
|
+
const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
|
89
|
+
|
|
90
|
+
return this.eph.julday(year, month, day, hour, Constants.SE_GREG_CAL);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Normalize angle to 0-360 degree range
|
|
95
|
+
*
|
|
96
|
+
* @param angle - Angle in degrees (may be negative or > 360)
|
|
97
|
+
* @returns Normalized angle in degrees (0-360)
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* Uses modulo arithmetic to handle negative angles correctly.
|
|
101
|
+
* Example: -10° becomes 350°, 370° becomes 10°.
|
|
102
|
+
*/
|
|
103
|
+
private normalizeAngle(angle: number): number {
|
|
104
|
+
return ((angle % 360) + 360) % 360;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get position of a single planet at a specific time
|
|
109
|
+
*
|
|
110
|
+
* @param planetId - Swiss Ephemeris planet ID (from PLANETS constant)
|
|
111
|
+
* @param jd - Julian Day for the calculation
|
|
112
|
+
* @returns Planet position with all relevant data
|
|
113
|
+
* @throws Error if ephemeris not initialized or invalid planet ID
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* Returns tropical, geocentric coordinates. Includes zodiac sign
|
|
117
|
+
* calculation and retrograde status.
|
|
118
|
+
*/
|
|
119
|
+
getPlanetPosition(planetId: number, jd: number): PlanetPosition {
|
|
120
|
+
if (!this.eph) throw new Error('Ephemeris not initialized');
|
|
121
|
+
if (!Number.isInteger(planetId) || !Number.isFinite(planetId)) {
|
|
122
|
+
throw new Error(`Invalid planet ID: ${planetId} (must be a finite integer)`);
|
|
123
|
+
}
|
|
124
|
+
if (!Number.isFinite(jd)) {
|
|
125
|
+
throw new Error(`Invalid Julian Day: ${jd} (must be finite)`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = this.eph.calc_ut(jd, planetId, Constants.SEFLG_SPEED);
|
|
129
|
+
|
|
130
|
+
// Swiss Ephemeris puts warnings in error field even on success
|
|
131
|
+
// Log warnings but only throw if we don't have valid data
|
|
132
|
+
if (result.error) {
|
|
133
|
+
logger.ephemerisWarning(result.error);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!result.data || result.data.length < 4) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Failed to calculate position for planet ${planetId}: ${result.error || 'No data returned'}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const longitude = result.data[0];
|
|
143
|
+
const latitude = result.data[1];
|
|
144
|
+
const distance = result.data[2];
|
|
145
|
+
const speed = result.data[3];
|
|
146
|
+
|
|
147
|
+
const normalizedLon = this.normalizeAngle(longitude);
|
|
148
|
+
const signIndex = Math.floor(normalizedLon / 30);
|
|
149
|
+
const degreeInSign = normalizedLon % 30;
|
|
150
|
+
|
|
151
|
+
const planetName = PLANET_NAMES[planetId];
|
|
152
|
+
if (!planetName) {
|
|
153
|
+
throw new Error(`Unknown planet ID: ${planetId}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
planetId,
|
|
158
|
+
planet: planetName,
|
|
159
|
+
longitude: normalizedLon,
|
|
160
|
+
latitude,
|
|
161
|
+
distance,
|
|
162
|
+
speed,
|
|
163
|
+
sign: ZODIAC_SIGNS[signIndex],
|
|
164
|
+
degree: degreeInSign,
|
|
165
|
+
isRetrograde: speed < 0,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get positions for multiple planets at a specific time
|
|
171
|
+
*
|
|
172
|
+
* @param planetIds - Array of Swiss Ephemeris planet IDs
|
|
173
|
+
* @param jd - Julian Day for the calculation
|
|
174
|
+
* @returns Array of planet positions in the same order as planetIds
|
|
175
|
+
* @throws Error if ephemeris not initialized
|
|
176
|
+
*
|
|
177
|
+
* @remarks
|
|
178
|
+
* Convenience wrapper that maps over planetIds and calls getPlanetPosition for each.
|
|
179
|
+
*/
|
|
180
|
+
getAllPlanets(jd: number, planetIds: number[]): PlanetPosition[] {
|
|
181
|
+
return planetIds.map((id) => this.getPlanetPosition(id, jd));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Calculate angular distance between two planets
|
|
186
|
+
*
|
|
187
|
+
* @param lon1 - First planet's longitude
|
|
188
|
+
* @param lon2 - Second planet's longitude
|
|
189
|
+
* @returns Angular distance in degrees (0-180)
|
|
190
|
+
*
|
|
191
|
+
* @remarks
|
|
192
|
+
* Always returns the shorter arc between the two planets.
|
|
193
|
+
* For example, 350° and 10° have a distance of 20°, not 340°.
|
|
194
|
+
*/
|
|
195
|
+
calculateAspectAngle(lon1: number, lon2: number): number {
|
|
196
|
+
let diff = Math.abs(lon1 - lon2);
|
|
197
|
+
if (diff > 180) {
|
|
198
|
+
diff = 360 - diff;
|
|
199
|
+
}
|
|
200
|
+
return diff;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Find all exact times when planet reaches a specific longitude
|
|
205
|
+
*
|
|
206
|
+
* @param planetId - Swiss Ephemeris planet ID
|
|
207
|
+
* @param targetLongitude - Target longitude in degrees (will be normalized to 0-360)
|
|
208
|
+
* @param startJD - Start of search window (Julian Day)
|
|
209
|
+
* @param endJD - End of search window (Julian Day)
|
|
210
|
+
* @param tolerance - Desired precision in degrees (default: 0.01°)
|
|
211
|
+
* @returns Array of Julian Days where crossings occur, sorted earliest-first, or empty array if none
|
|
212
|
+
* @throws Error if ephemeris not initialized or invalid inputs
|
|
213
|
+
*
|
|
214
|
+
* @remarks
|
|
215
|
+
* Uses multi-stage search: coarse scan for root detection, then bracket/minimum refinement.
|
|
216
|
+
* Endpoint-near-zero cases are collected directly as candidate roots.
|
|
217
|
+
* Only sign-change intervals are refined via bisection.
|
|
218
|
+
* Local minima of |diff| are refined to catch tangential no-sign-change roots.
|
|
219
|
+
* Returns all detected crossings in the interval, deduplicated within 1 minute,
|
|
220
|
+
* and sorted earliest-first. No guarantees are made outside the searched interval.
|
|
221
|
+
*/
|
|
222
|
+
findExactTransitTimes(
|
|
223
|
+
planetId: number,
|
|
224
|
+
targetLongitude: number,
|
|
225
|
+
startJD: number,
|
|
226
|
+
endJD: number,
|
|
227
|
+
tolerance: number = DEFAULT_EXACT_TIME_TOLERANCE
|
|
228
|
+
): number[] {
|
|
229
|
+
// Validate inputs
|
|
230
|
+
if (!Number.isFinite(startJD) || !Number.isFinite(endJD)) {
|
|
231
|
+
throw new Error('Invalid Julian Day: must be finite numbers');
|
|
232
|
+
}
|
|
233
|
+
if (startJD >= endJD) {
|
|
234
|
+
throw new Error(`Invalid interval: startJD (${startJD}) must be < endJD (${endJD})`);
|
|
235
|
+
}
|
|
236
|
+
if (!Number.isFinite(tolerance) || tolerance <= 0) {
|
|
237
|
+
throw new Error(`Invalid tolerance: ${tolerance} (must be > 0)`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Normalize target longitude to 0-360
|
|
241
|
+
targetLongitude = this.normalizeAngle(targetLongitude);
|
|
242
|
+
|
|
243
|
+
// Helper: calculate signed shortest-angle difference
|
|
244
|
+
const signedDiff = (lon: number): number => {
|
|
245
|
+
let diff = lon - targetLongitude;
|
|
246
|
+
if (diff > 180) diff -= 360;
|
|
247
|
+
if (diff < -180) diff += 360;
|
|
248
|
+
return diff;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Stage 1: Coarse scan for root detection
|
|
252
|
+
// Resolution is driven by max step size, with a hard cap for bounded compute.
|
|
253
|
+
const windowDays = endJD - startJD;
|
|
254
|
+
const rawSamples = Math.ceil(windowDays / COARSE_SCAN_MAX_STEP_DAYS);
|
|
255
|
+
const numSamples = Math.max(1, Math.min(rawSamples, MAX_COARSE_SCAN_SAMPLES));
|
|
256
|
+
const step = windowDays / numSamples;
|
|
257
|
+
|
|
258
|
+
const samples: Array<{ jd: number; diff: number }> = [];
|
|
259
|
+
for (let i = 0; i <= numSamples; i++) {
|
|
260
|
+
const jd = startJD + i * step;
|
|
261
|
+
const pos = this.getPlanetPosition(planetId, jd);
|
|
262
|
+
const diff = signedDiff(pos.longitude);
|
|
263
|
+
samples.push({ jd, diff });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Root detection outputs:
|
|
267
|
+
// - candidateRoots for near-zero samples
|
|
268
|
+
// - brackets for sign-change intervals (to be refined via bisection)
|
|
269
|
+
// - tangentialIntervals for local minima in |diff| (to catch no-sign-change roots)
|
|
270
|
+
const candidateRoots: number[] = [];
|
|
271
|
+
const brackets: Array<{ start: number; end: number }> = [];
|
|
272
|
+
const tangentialIntervals: Array<{ start: number; end: number }> = [];
|
|
273
|
+
const absDiffs = samples.map((s) => Math.abs(s.diff));
|
|
274
|
+
|
|
275
|
+
for (let i = 0; i < samples.length; i++) {
|
|
276
|
+
if (Math.abs(samples[i].diff) < tolerance * 5) {
|
|
277
|
+
candidateRoots.push(samples[i].jd);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (let i = 0; i < samples.length - 1; i++) {
|
|
282
|
+
const curr = samples[i];
|
|
283
|
+
const next = samples[i + 1];
|
|
284
|
+
|
|
285
|
+
// Only true sign-change intervals are refined with bisection
|
|
286
|
+
if ((curr.diff > 0 && next.diff < 0) || (curr.diff < 0 && next.diff > 0)) {
|
|
287
|
+
brackets.push({ start: curr.jd, end: next.jd });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Detect local minima in |diff| for tangential roots that do not change sign.
|
|
292
|
+
for (let i = 1; i < samples.length - 1; i++) {
|
|
293
|
+
const prevAbs = absDiffs[i - 1];
|
|
294
|
+
const currAbs = absDiffs[i];
|
|
295
|
+
const nextAbs = absDiffs[i + 1];
|
|
296
|
+
const prevDiff = samples[i - 1].diff;
|
|
297
|
+
const nextDiff = samples[i + 1].diff;
|
|
298
|
+
|
|
299
|
+
const isLocalMin =
|
|
300
|
+
currAbs <= prevAbs && currAbs <= nextAbs && (currAbs < prevAbs || currAbs < nextAbs);
|
|
301
|
+
|
|
302
|
+
if (!isLocalMin) continue;
|
|
303
|
+
const hasSignChangeAcrossWindow =
|
|
304
|
+
(prevDiff < 0 && nextDiff > 0) || (prevDiff > 0 && nextDiff < 0);
|
|
305
|
+
if (hasSignChangeAcrossWindow) continue;
|
|
306
|
+
|
|
307
|
+
const dipProminence = Math.max(prevAbs, nextAbs) - currAbs;
|
|
308
|
+
const looksPromising =
|
|
309
|
+
currAbs < tolerance * TANGENTIAL_ROOT_SCAN_FACTOR || dipProminence > tolerance;
|
|
310
|
+
|
|
311
|
+
if (looksPromising) {
|
|
312
|
+
tangentialIntervals.push({
|
|
313
|
+
start: samples[i - 1].jd,
|
|
314
|
+
end: samples[i + 1].jd,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Stage 2a: Bisection refinement on sign-change brackets
|
|
320
|
+
const roots: number[] = [...candidateRoots];
|
|
321
|
+
|
|
322
|
+
for (const bracket of brackets) {
|
|
323
|
+
let jd1 = bracket.start;
|
|
324
|
+
let jd2 = bracket.end;
|
|
325
|
+
let iteration = 0;
|
|
326
|
+
|
|
327
|
+
while (iteration < MAX_EXACT_TIME_ITERATIONS) {
|
|
328
|
+
const jdMid = (jd1 + jd2) / 2;
|
|
329
|
+
|
|
330
|
+
// Stop if interval is tiny (< 1 minute)
|
|
331
|
+
if (jd2 - jd1 < ROOT_DEDUP_EPSILON_DAYS) {
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const posMid = this.getPlanetPosition(planetId, jdMid);
|
|
336
|
+
const diffMid = signedDiff(posMid.longitude);
|
|
337
|
+
|
|
338
|
+
// Check if close enough
|
|
339
|
+
if (Math.abs(diffMid) < tolerance) {
|
|
340
|
+
roots.push(jdMid);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Narrow the interval based on which half brackets the target
|
|
345
|
+
const posStart = this.getPlanetPosition(planetId, jd1);
|
|
346
|
+
const diffStart = signedDiff(posStart.longitude);
|
|
347
|
+
|
|
348
|
+
// Pick the half that brackets the target (sign change)
|
|
349
|
+
if ((diffStart > 0 && diffMid > 0) || (diffStart < 0 && diffMid < 0)) {
|
|
350
|
+
jd1 = jdMid;
|
|
351
|
+
} else {
|
|
352
|
+
jd2 = jdMid;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
iteration++;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// If we didn't converge within tolerance, check final midpoint
|
|
359
|
+
if (iteration === MAX_EXACT_TIME_ITERATIONS || jd2 - jd1 < ROOT_DEDUP_EPSILON_DAYS) {
|
|
360
|
+
const finalMid = (jd1 + jd2) / 2;
|
|
361
|
+
const finalPos = this.getPlanetPosition(planetId, finalMid);
|
|
362
|
+
const finalDiff = signedDiff(finalPos.longitude);
|
|
363
|
+
|
|
364
|
+
if (Math.abs(finalDiff) < tolerance * 2) {
|
|
365
|
+
roots.push(finalMid);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Stage 2b: Minimum refinement on tangential intervals (no sign change required)
|
|
371
|
+
for (const interval of tangentialIntervals) {
|
|
372
|
+
let left = interval.start;
|
|
373
|
+
let right = interval.end;
|
|
374
|
+
let iteration = 0;
|
|
375
|
+
|
|
376
|
+
while (iteration < MAX_EXACT_TIME_ITERATIONS && right - left > ROOT_DEDUP_EPSILON_DAYS) {
|
|
377
|
+
const m1 = left + (right - left) / 3;
|
|
378
|
+
const m2 = right - (right - left) / 3;
|
|
379
|
+
|
|
380
|
+
const d1 = Math.abs(signedDiff(this.getPlanetPosition(planetId, m1).longitude));
|
|
381
|
+
const d2 = Math.abs(signedDiff(this.getPlanetPosition(planetId, m2).longitude));
|
|
382
|
+
|
|
383
|
+
if (d1 <= d2) {
|
|
384
|
+
right = m2;
|
|
385
|
+
} else {
|
|
386
|
+
left = m1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
iteration++;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const minJD = (left + right) / 2;
|
|
393
|
+
const minAbs = Math.abs(signedDiff(this.getPlanetPosition(planetId, minJD).longitude));
|
|
394
|
+
if (minAbs < tolerance * 2) {
|
|
395
|
+
roots.push(minJD);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Sort all roots chronologically
|
|
400
|
+
roots.sort((a, b) => a - b);
|
|
401
|
+
|
|
402
|
+
// Deduplicate roots with epsilon (adjacent brackets can converge to same crossing)
|
|
403
|
+
// Use 1 minute threshold to avoid duplicates
|
|
404
|
+
const deduped: number[] = [];
|
|
405
|
+
for (const root of roots) {
|
|
406
|
+
const last = deduped[deduped.length - 1];
|
|
407
|
+
if (last == null || Math.abs(root - last) > ROOT_DEDUP_EPSILON_DAYS) {
|
|
408
|
+
deduped.push(root);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return deduped;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Convert Julian Day to JavaScript Date
|
|
417
|
+
*
|
|
418
|
+
* @param jd - Julian Day number
|
|
419
|
+
* @returns JavaScript Date in UTC
|
|
420
|
+
* @throws Error if ephemeris not initialized
|
|
421
|
+
*
|
|
422
|
+
* @remarks
|
|
423
|
+
* The returned Date is always in UTC regardless of the original
|
|
424
|
+
* timezone of the calculation.
|
|
425
|
+
*/
|
|
426
|
+
julianDayToDate(jd: number): Date {
|
|
427
|
+
if (!this.eph) throw new Error('Ephemeris not initialized');
|
|
428
|
+
|
|
429
|
+
const result = this.eph.revjul(jd, Constants.SE_GREG_CAL);
|
|
430
|
+
|
|
431
|
+
// Convert fractional hour to milliseconds from midnight and round once.
|
|
432
|
+
// Adding to midnight timestamp naturally handles overflow carries.
|
|
433
|
+
const msFromMidnight = Math.round(result.hour * 3600 * 1000);
|
|
434
|
+
const midnightUtcMs = Date.UTC(result.year, result.month - 1, result.day, 0, 0, 0, 0);
|
|
435
|
+
return new Date(midnightUtcMs + msFromMidnight);
|
|
436
|
+
}
|
|
437
|
+
}
|
package/src/formatter.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function formatInTimezone(date: Date, timezone: string): string {
|
|
2
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
3
|
+
timeZone: timezone,
|
|
4
|
+
year: 'numeric',
|
|
5
|
+
month: 'short',
|
|
6
|
+
day: 'numeric',
|
|
7
|
+
hour: 'numeric',
|
|
8
|
+
minute: '2-digit',
|
|
9
|
+
hour12: true,
|
|
10
|
+
timeZoneName: 'short',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
return new Intl.DateTimeFormat('en-US', options).format(date);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatDateOnly(date: Date, timezone: string): string {
|
|
17
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
18
|
+
timeZone: timezone,
|
|
19
|
+
year: 'numeric',
|
|
20
|
+
month: 'short',
|
|
21
|
+
day: 'numeric',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return new Intl.DateTimeFormat('en-US', options).format(date);
|
|
25
|
+
}
|
package/src/houses.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { EphemerisCalculator } from './ephemeris.js';
|
|
2
|
+
import { type HouseData, type HouseSystem, ZODIAC_SIGNS } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculator for astrological houses, Ascendant, and Midheaven
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Calculates house cusps using various house systems. Handles polar
|
|
9
|
+
* latitude edge cases by falling back to Whole Sign when needed.
|
|
10
|
+
* Uses Swiss Ephemeris 1-based indexing for cusps array.
|
|
11
|
+
*/
|
|
12
|
+
export class HouseCalculator {
|
|
13
|
+
/** Ephemeris calculator instance */
|
|
14
|
+
private ephem: EphemerisCalculator;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a new house calculator
|
|
18
|
+
*
|
|
19
|
+
* @param ephem - Initialized ephemeris calculator
|
|
20
|
+
* @throws Error if ephemeris is not initialized
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* The ephemeris calculator must be initialized before passing
|
|
24
|
+
* to the HouseCalculator constructor.
|
|
25
|
+
*/
|
|
26
|
+
constructor(ephem: EphemerisCalculator) {
|
|
27
|
+
this.ephem = ephem;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Calculate house cusps, Ascendant, and Midheaven for a given time and location
|
|
32
|
+
*
|
|
33
|
+
* Supported house systems:
|
|
34
|
+
* - P: Placidus (most common, fails >66° latitude)
|
|
35
|
+
* - W: Whole Sign (works at all latitudes)
|
|
36
|
+
* - K: Koch
|
|
37
|
+
* - E: Equal
|
|
38
|
+
* - O: Porphyry
|
|
39
|
+
* - R: Regiomontanus
|
|
40
|
+
* - C: Campanus
|
|
41
|
+
* - A: Equal (MC)
|
|
42
|
+
* - V: Vehlow Equal
|
|
43
|
+
* - X: Axial Rotation
|
|
44
|
+
* - H: Azimuthal/Horizontal
|
|
45
|
+
* - T: Polich/Page (Topocentric)
|
|
46
|
+
* - B: Alcabitus
|
|
47
|
+
*
|
|
48
|
+
* Polar latitude handling:
|
|
49
|
+
* - For latitudes >66°, Placidus/Koch/etc may fail mathematically
|
|
50
|
+
* - Automatically falls back to Whole Sign if requested system fails
|
|
51
|
+
* - Returns actual system used in result.system field
|
|
52
|
+
*
|
|
53
|
+
* Cusp array format:
|
|
54
|
+
* - Swiss Ephemeris 1-based indexing: cusps[0] is unused, cusps[1..12] are houses 1-12
|
|
55
|
+
* - This preserves the original Swiss Ephemeris convention
|
|
56
|
+
*
|
|
57
|
+
* @param julianDay - Julian Day for calculation
|
|
58
|
+
* @param latitude - Observer latitude in degrees
|
|
59
|
+
* @param longitude - Observer longitude in degrees
|
|
60
|
+
* @param houseSystem - Single-character house system code (default: 'P')
|
|
61
|
+
* @returns House data with cusps, ascendant, MC, and actual system used
|
|
62
|
+
* @throws {Error} If house calculation fails or invalid system specified
|
|
63
|
+
*/
|
|
64
|
+
calculateHouses(
|
|
65
|
+
julianDay: number,
|
|
66
|
+
latitude: number,
|
|
67
|
+
longitude: number,
|
|
68
|
+
houseSystem: string = 'P'
|
|
69
|
+
): HouseData {
|
|
70
|
+
if (!this.ephem.eph) {
|
|
71
|
+
throw new Error('Ephemeris not initialized');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate and normalize house system
|
|
75
|
+
const normalized = this.normalizeHouseSystem(houseSystem);
|
|
76
|
+
|
|
77
|
+
const isPolar = Math.abs(latitude) > 66;
|
|
78
|
+
let systemToUse = normalized as HouseSystem;
|
|
79
|
+
|
|
80
|
+
// Try requested system
|
|
81
|
+
const result = this.ephem.eph.houses_ex2(julianDay, 0, latitude, longitude, systemToUse);
|
|
82
|
+
|
|
83
|
+
// Handle polar latitude failure with real fallback
|
|
84
|
+
if (result.flag < 0 && isPolar && systemToUse !== 'W') {
|
|
85
|
+
// Retry with Whole Sign (works at all latitudes)
|
|
86
|
+
systemToUse = 'W';
|
|
87
|
+
const fallbackResult = this.ephem.eph.houses_ex2(
|
|
88
|
+
julianDay,
|
|
89
|
+
0,
|
|
90
|
+
latitude,
|
|
91
|
+
longitude,
|
|
92
|
+
systemToUse
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (fallbackResult.flag < 0) {
|
|
96
|
+
// Even Whole Sign failed - this should never happen
|
|
97
|
+
throw new Error(
|
|
98
|
+
`House calculation failed even with Whole Sign fallback at latitude ${latitude.toFixed(1)}°`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Return fallback result with actual system used
|
|
103
|
+
return {
|
|
104
|
+
ascendant: fallbackResult.data.points[0],
|
|
105
|
+
mc: fallbackResult.data.points[1],
|
|
106
|
+
cusps: [0, ...Array.from(fallbackResult.data.houses)], // Swiss 1-based: [0] unused, [1..12] houses
|
|
107
|
+
system: systemToUse, // Return 'W', not original requested system
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For non-polar failures, throw error (don't return fake data)
|
|
112
|
+
if (result.flag < 0) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`House calculation failed for ${systemToUse} system at latitude ${latitude.toFixed(1)}°`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Success - return actual data
|
|
119
|
+
return {
|
|
120
|
+
ascendant: result.data.points[0],
|
|
121
|
+
mc: result.data.points[1],
|
|
122
|
+
cusps: [0, ...Array.from(result.data.houses)], // Swiss 1-based: [0] unused, [1..12] houses
|
|
123
|
+
system: systemToUse,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Normalize house system code
|
|
129
|
+
*
|
|
130
|
+
* @param system - House system code (single character or name)
|
|
131
|
+
* @returns Normalized single-character code
|
|
132
|
+
* @throws Error if invalid system
|
|
133
|
+
*
|
|
134
|
+
* @remarks
|
|
135
|
+
* Accepts both single-letter codes and full names.
|
|
136
|
+
* Validates against supported systems.
|
|
137
|
+
*/
|
|
138
|
+
private normalizeHouseSystem(system: string): HouseSystem {
|
|
139
|
+
const upperSystem = system.toUpperCase().trim();
|
|
140
|
+
|
|
141
|
+
// Map common names to single-letter codes
|
|
142
|
+
const nameMap: { [key: string]: string } = {
|
|
143
|
+
PLACIDUS: 'P',
|
|
144
|
+
'WHOLE SIGN': 'W',
|
|
145
|
+
KOCH: 'K',
|
|
146
|
+
EQUAL: 'E',
|
|
147
|
+
PORPHYRY: 'O',
|
|
148
|
+
REGIOMONTANUS: 'R',
|
|
149
|
+
CAMPANUS: 'C',
|
|
150
|
+
'EQUAL MC': 'A',
|
|
151
|
+
'VEHLOW EQUAL': 'V',
|
|
152
|
+
'AXIAL ROTATION': 'X',
|
|
153
|
+
AZIMUTHAL: 'H',
|
|
154
|
+
HORIZONTAL: 'H',
|
|
155
|
+
TOPOCENTRIC: 'T',
|
|
156
|
+
POLICH: 'T',
|
|
157
|
+
PAGE: 'T',
|
|
158
|
+
ALCABITUS: 'B',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const normalized = nameMap[upperSystem] || upperSystem;
|
|
162
|
+
|
|
163
|
+
// Validate against allowed systems
|
|
164
|
+
const validSystems: HouseSystem[] = [
|
|
165
|
+
'P',
|
|
166
|
+
'W',
|
|
167
|
+
'K',
|
|
168
|
+
'E',
|
|
169
|
+
'O',
|
|
170
|
+
'R',
|
|
171
|
+
'C',
|
|
172
|
+
'A',
|
|
173
|
+
'V',
|
|
174
|
+
'X',
|
|
175
|
+
'H',
|
|
176
|
+
'T',
|
|
177
|
+
'B',
|
|
178
|
+
];
|
|
179
|
+
if (!validSystems.includes(normalized as HouseSystem)) {
|
|
180
|
+
throw new Error(`Invalid house system: ${system}. Valid systems: ${validSystems.join(', ')}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return normalized as HouseSystem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Format house position as readable string
|
|
188
|
+
*
|
|
189
|
+
* @param longitude - House cusp longitude in degrees
|
|
190
|
+
* @returns Formatted string like "15.30° Aries"
|
|
191
|
+
*
|
|
192
|
+
* @remarks
|
|
193
|
+
* Normalizes longitude to 0-360° range and determines zodiac sign.
|
|
194
|
+
*/
|
|
195
|
+
formatHousePosition(longitude: number): string {
|
|
196
|
+
// Normalize longitude to 0-360 range
|
|
197
|
+
const normalizedLon = ((longitude % 360) + 360) % 360;
|
|
198
|
+
const signIndex = Math.floor(normalizedLon / 30);
|
|
199
|
+
const degree = normalizedLon % 30;
|
|
200
|
+
return `${degree.toFixed(2)}° ${ZODIAC_SIGNS[signIndex]}`;
|
|
201
|
+
}
|
|
202
|
+
}
|