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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { EphemerisCalculator } from './ephemeris.js';
|
|
2
|
+
import { type NatalChart, type PlanetPosition, type Transit, type TransitResponse } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Deduplicate transits by keeping the best hit per aspect
|
|
5
|
+
* Priority: exact time > smallest orb > earliest exact time > deterministic tiebreakers
|
|
6
|
+
*
|
|
7
|
+
* @param transits - Array of transits to deduplicate
|
|
8
|
+
* @returns Deduplicated array with one transit per unique aspect key
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* This is the production dedupe logic used by get_transits when collecting
|
|
12
|
+
* transits over multiple days. The key is: transitingPlanet + natalPlanet + aspect.
|
|
13
|
+
* Final tiebreakers use longitude and planet names for deterministic ordering.
|
|
14
|
+
*/
|
|
15
|
+
export declare function deduplicateTransits(transits: Transit[]): Transit[];
|
|
16
|
+
/**
|
|
17
|
+
* Calculator for astrological transits and aspects
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* Analyzes relationships between current planetary positions (transits)
|
|
21
|
+
* and natal chart positions. Calculates aspects, orbs, and exact timing
|
|
22
|
+
* when aspects become perfect.
|
|
23
|
+
*/
|
|
24
|
+
export declare class TransitCalculator {
|
|
25
|
+
/** Ephemeris calculator instance */
|
|
26
|
+
private ephem;
|
|
27
|
+
private readonly exactTimeSupportedPlanetIds;
|
|
28
|
+
/**
|
|
29
|
+
* Create a new transit calculator
|
|
30
|
+
*
|
|
31
|
+
* @param ephem - Initialized ephemeris calculator
|
|
32
|
+
* @throws Error if ephemeris is not initialized
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* The ephemeris calculator must be initialized before passing
|
|
36
|
+
* to the TransitCalculator constructor.
|
|
37
|
+
*/
|
|
38
|
+
constructor(ephem: EphemerisCalculator);
|
|
39
|
+
/**
|
|
40
|
+
* Find all active transits between two sets of planets
|
|
41
|
+
*
|
|
42
|
+
* @param transitingPlanets - Current planetary positions
|
|
43
|
+
* @param natalPlanets - Birth chart planetary positions
|
|
44
|
+
* @param currentJD - Current Julian Day
|
|
45
|
+
* @returns Array of active transits with aspect details
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* Checks all combinations of transiting and natal planets against
|
|
49
|
+
* all defined aspects. Includes exact time resolution for close aspects.
|
|
50
|
+
*
|
|
51
|
+
* Exact-time policy:
|
|
52
|
+
* - Solver computes candidate roots in a bounded interval, capped to PREVIEW_HORIZON_DAYS.
|
|
53
|
+
* - Product layer exposes exactTime only when root is within PREVIEW_HORIZON_DAYS.
|
|
54
|
+
* - exactTimeStatus communicates why exactTime may be omitted.
|
|
55
|
+
* - exactTimeStatus is only set when exact-time resolution is attempted
|
|
56
|
+
* (i.e. orb <= EXACT_TIME_ORB_THRESHOLD). When orb is wider, exactTimeStatus is undefined.
|
|
57
|
+
*/
|
|
58
|
+
findTransits(transitingPlanets: PlanetPosition[], natalPlanets: PlanetPosition[], currentJD: number): Transit[];
|
|
59
|
+
/**
|
|
60
|
+
* Calculate exact time when a transit aspect becomes perfect
|
|
61
|
+
*
|
|
62
|
+
* @param transitingPlanet - Current transiting planet position
|
|
63
|
+
* @param natalPlanet - Natal planet position
|
|
64
|
+
* @param aspect - Aspect configuration
|
|
65
|
+
* @param currentJD - Current Julian Day
|
|
66
|
+
* @param heuristicApplying - Applying/separating estimate used as fallback selector
|
|
67
|
+
* @returns Exact-time resolution result including status and selected root
|
|
68
|
+
*
|
|
69
|
+
* @remarks
|
|
70
|
+
* Solver and product concerns are intentionally separated:
|
|
71
|
+
* - Solver: find candidate roots in [currentJD - searchWindow, currentJD + searchWindow]
|
|
72
|
+
* - Product: expose exactTime only when selected root is within PREVIEW_HORIZON_DAYS
|
|
73
|
+
*
|
|
74
|
+
* Status semantics:
|
|
75
|
+
* - within_preview: root found and exactTime exposed
|
|
76
|
+
* - outside_preview: root found but exactTime hidden by product policy
|
|
77
|
+
* - not_found: no root found in solver interval
|
|
78
|
+
* - unsupported_body: exact-time solver not supported for the transiting body
|
|
79
|
+
*/
|
|
80
|
+
private calculateExactTransitTime;
|
|
81
|
+
/**
|
|
82
|
+
* Determine if an aspect is applying or separating
|
|
83
|
+
*
|
|
84
|
+
* @param transitLon - Transiting planet longitude
|
|
85
|
+
* @param natalLon - Natal planet longitude
|
|
86
|
+
* @param transitSpeed - Transiting planet's daily motion
|
|
87
|
+
* @param aspectAngle - Target aspect angle
|
|
88
|
+
* @returns true if applying, false if separating
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* Applying: Aspect getting stronger (closer to exact)
|
|
92
|
+
* Separating: Aspect getting weaker (moving away from exact)
|
|
93
|
+
*/
|
|
94
|
+
private isApplying;
|
|
95
|
+
/**
|
|
96
|
+
* Get all transits for a specific date
|
|
97
|
+
*
|
|
98
|
+
* @param natalChart - Birth chart data
|
|
99
|
+
* @param date - Date for transit calculation (interpreted as-is, no timezone conversion)
|
|
100
|
+
* @returns TransitResponse with all active transits
|
|
101
|
+
* @throws Error if natal chart is invalid
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* Internal UTC primitive: calculates transits for the provided date/time as-is.
|
|
105
|
+
* Caller is responsible for any user-facing timezone semantics.
|
|
106
|
+
*/
|
|
107
|
+
getTransitsForDate(date: Date, natalChart: NatalChart): Promise<TransitResponse>;
|
|
108
|
+
}
|
package/dist/transits.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { ASPECTS, PLANETS, } from './types.js';
|
|
2
|
+
// Constants for transit calculations
|
|
3
|
+
const EXACT_TIME_ORB_THRESHOLD = 2; // degrees - only calculate exact times within this orb (inclusive)
|
|
4
|
+
const EXACT_TIME_SEARCH_WINDOW = 5; // days - minimum solver search window for root finding
|
|
5
|
+
const PREVIEW_HORIZON_DAYS = 90; // days - product preview horizon for exposing exactTime
|
|
6
|
+
/**
|
|
7
|
+
* Deduplicate transits by keeping the best hit per aspect
|
|
8
|
+
* Priority: exact time > smallest orb > earliest exact time > deterministic tiebreakers
|
|
9
|
+
*
|
|
10
|
+
* @param transits - Array of transits to deduplicate
|
|
11
|
+
* @returns Deduplicated array with one transit per unique aspect key
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* This is the production dedupe logic used by get_transits when collecting
|
|
15
|
+
* transits over multiple days. The key is: transitingPlanet + natalPlanet + aspect.
|
|
16
|
+
* Final tiebreakers use longitude and planet names for deterministic ordering.
|
|
17
|
+
*/
|
|
18
|
+
export function deduplicateTransits(transits) {
|
|
19
|
+
const bestTransits = new Map();
|
|
20
|
+
for (const t of transits) {
|
|
21
|
+
const key = `${t.transitingPlanet}-${t.natalPlanet}-${t.aspect}`;
|
|
22
|
+
const existing = bestTransits.get(key);
|
|
23
|
+
if (!existing) {
|
|
24
|
+
bestTransits.set(key, t);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
// Priority: exact > smallest orb > earliest exact time > deterministic tiebreakers
|
|
28
|
+
const shouldReplace = (t.exactTime && !existing.exactTime) ||
|
|
29
|
+
(!!t.exactTime === !!existing.exactTime && t.orb < existing.orb) ||
|
|
30
|
+
(t.orb === existing.orb &&
|
|
31
|
+
t.exactTime &&
|
|
32
|
+
existing.exactTime &&
|
|
33
|
+
t.exactTime < existing.exactTime) ||
|
|
34
|
+
(t.orb === existing.orb &&
|
|
35
|
+
!t.exactTime &&
|
|
36
|
+
!existing.exactTime &&
|
|
37
|
+
t.transitLongitude < existing.transitLongitude) ||
|
|
38
|
+
(t.orb === existing.orb &&
|
|
39
|
+
!t.exactTime &&
|
|
40
|
+
!existing.exactTime &&
|
|
41
|
+
t.transitLongitude === existing.transitLongitude &&
|
|
42
|
+
t.natalLongitude < existing.natalLongitude) ||
|
|
43
|
+
(t.orb === existing.orb &&
|
|
44
|
+
!t.exactTime &&
|
|
45
|
+
!existing.exactTime &&
|
|
46
|
+
t.transitLongitude === existing.transitLongitude &&
|
|
47
|
+
t.natalLongitude === existing.natalLongitude &&
|
|
48
|
+
t.transitingPlanet < existing.transitingPlanet) ||
|
|
49
|
+
(t.orb === existing.orb &&
|
|
50
|
+
!t.exactTime &&
|
|
51
|
+
!existing.exactTime &&
|
|
52
|
+
t.transitLongitude === existing.transitLongitude &&
|
|
53
|
+
t.natalLongitude === existing.natalLongitude &&
|
|
54
|
+
t.transitingPlanet === existing.transitingPlanet &&
|
|
55
|
+
t.natalPlanet < existing.natalPlanet);
|
|
56
|
+
if (shouldReplace) {
|
|
57
|
+
bestTransits.set(key, t);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return Array.from(bestTransits.values());
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Calculator for astrological transits and aspects
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Analyzes relationships between current planetary positions (transits)
|
|
68
|
+
* and natal chart positions. Calculates aspects, orbs, and exact timing
|
|
69
|
+
* when aspects become perfect.
|
|
70
|
+
*/
|
|
71
|
+
export class TransitCalculator {
|
|
72
|
+
/** Ephemeris calculator instance */
|
|
73
|
+
ephem;
|
|
74
|
+
exactTimeSupportedPlanetIds = new Set(Object.values(PLANETS));
|
|
75
|
+
/**
|
|
76
|
+
* Create a new transit calculator
|
|
77
|
+
*
|
|
78
|
+
* @param ephem - Initialized ephemeris calculator
|
|
79
|
+
* @throws Error if ephemeris is not initialized
|
|
80
|
+
*
|
|
81
|
+
* @remarks
|
|
82
|
+
* The ephemeris calculator must be initialized before passing
|
|
83
|
+
* to the TransitCalculator constructor.
|
|
84
|
+
*/
|
|
85
|
+
constructor(ephem) {
|
|
86
|
+
this.ephem = ephem;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Find all active transits between two sets of planets
|
|
90
|
+
*
|
|
91
|
+
* @param transitingPlanets - Current planetary positions
|
|
92
|
+
* @param natalPlanets - Birth chart planetary positions
|
|
93
|
+
* @param currentJD - Current Julian Day
|
|
94
|
+
* @returns Array of active transits with aspect details
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* Checks all combinations of transiting and natal planets against
|
|
98
|
+
* all defined aspects. Includes exact time resolution for close aspects.
|
|
99
|
+
*
|
|
100
|
+
* Exact-time policy:
|
|
101
|
+
* - Solver computes candidate roots in a bounded interval, capped to PREVIEW_HORIZON_DAYS.
|
|
102
|
+
* - Product layer exposes exactTime only when root is within PREVIEW_HORIZON_DAYS.
|
|
103
|
+
* - exactTimeStatus communicates why exactTime may be omitted.
|
|
104
|
+
* - exactTimeStatus is only set when exact-time resolution is attempted
|
|
105
|
+
* (i.e. orb <= EXACT_TIME_ORB_THRESHOLD). When orb is wider, exactTimeStatus is undefined.
|
|
106
|
+
*/
|
|
107
|
+
findTransits(transitingPlanets, natalPlanets, currentJD) {
|
|
108
|
+
const transits = [];
|
|
109
|
+
for (const transitPlanet of transitingPlanets) {
|
|
110
|
+
for (const natalPlanet of natalPlanets) {
|
|
111
|
+
const angle = this.ephem.calculateAspectAngle(transitPlanet.longitude, natalPlanet.longitude);
|
|
112
|
+
for (const aspect of ASPECTS) {
|
|
113
|
+
const orb = Math.abs(angle - aspect.angle);
|
|
114
|
+
if (orb <= aspect.orb) {
|
|
115
|
+
const heuristicApplying = this.isApplying(transitPlanet.longitude, natalPlanet.longitude, transitPlanet.speed, aspect.angle);
|
|
116
|
+
let exactTime;
|
|
117
|
+
let exactTimeStatus;
|
|
118
|
+
let isApplying = heuristicApplying;
|
|
119
|
+
if (orb <= EXACT_TIME_ORB_THRESHOLD) {
|
|
120
|
+
const exactResult = this.calculateExactTransitTime(transitPlanet, natalPlanet, aspect, currentJD, heuristicApplying);
|
|
121
|
+
exactTime = exactResult.exactTime;
|
|
122
|
+
exactTimeStatus = exactResult.status;
|
|
123
|
+
// Strong applying/separating policy:
|
|
124
|
+
// selected root in future => applying, past => separating
|
|
125
|
+
if (exactResult.selectedRoot != null) {
|
|
126
|
+
isApplying = exactResult.selectedRoot >= currentJD;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
transits.push({
|
|
130
|
+
transitingPlanet: transitPlanet.planet,
|
|
131
|
+
natalPlanet: natalPlanet.planet,
|
|
132
|
+
aspect: aspect.name,
|
|
133
|
+
orb,
|
|
134
|
+
exactTime,
|
|
135
|
+
exactTimeStatus,
|
|
136
|
+
isApplying,
|
|
137
|
+
transitLongitude: transitPlanet.longitude,
|
|
138
|
+
natalLongitude: natalPlanet.longitude,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return transits.sort((a, b) => a.orb - b.orb);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Calculate exact time when a transit aspect becomes perfect
|
|
148
|
+
*
|
|
149
|
+
* @param transitingPlanet - Current transiting planet position
|
|
150
|
+
* @param natalPlanet - Natal planet position
|
|
151
|
+
* @param aspect - Aspect configuration
|
|
152
|
+
* @param currentJD - Current Julian Day
|
|
153
|
+
* @param heuristicApplying - Applying/separating estimate used as fallback selector
|
|
154
|
+
* @returns Exact-time resolution result including status and selected root
|
|
155
|
+
*
|
|
156
|
+
* @remarks
|
|
157
|
+
* Solver and product concerns are intentionally separated:
|
|
158
|
+
* - Solver: find candidate roots in [currentJD - searchWindow, currentJD + searchWindow]
|
|
159
|
+
* - Product: expose exactTime only when selected root is within PREVIEW_HORIZON_DAYS
|
|
160
|
+
*
|
|
161
|
+
* Status semantics:
|
|
162
|
+
* - within_preview: root found and exactTime exposed
|
|
163
|
+
* - outside_preview: root found but exactTime hidden by product policy
|
|
164
|
+
* - not_found: no root found in solver interval
|
|
165
|
+
* - unsupported_body: exact-time solver not supported for the transiting body
|
|
166
|
+
*/
|
|
167
|
+
calculateExactTransitTime(transitingPlanet, natalPlanet, aspect, currentJD, heuristicApplying) {
|
|
168
|
+
// For non-conjunction/opposition aspects, there are 2 possible target longitudes
|
|
169
|
+
const target1 = (natalPlanet.longitude + aspect.angle) % 360;
|
|
170
|
+
const target2 = (natalPlanet.longitude - aspect.angle + 360) % 360;
|
|
171
|
+
const planetId = transitingPlanet.planetId;
|
|
172
|
+
// Skip exact time calculation for unsupported bodies
|
|
173
|
+
if (!this.exactTimeSupportedPlanetIds.has(planetId)) {
|
|
174
|
+
return { status: 'unsupported_body', selectedRoot: null };
|
|
175
|
+
}
|
|
176
|
+
// Calculate dynamic search window based on planet speed
|
|
177
|
+
// Slow movers (Saturn/Uranus/Neptune/Pluto) need wider windows,
|
|
178
|
+
// but solver horizon is capped to product preview horizon for bounded compute and aligned policy.
|
|
179
|
+
const speed = Math.abs(transitingPlanet.speed);
|
|
180
|
+
const daysToMove2Deg = speed > 0 ? 2 / speed : 30;
|
|
181
|
+
const searchWindow = Math.min(Math.max(daysToMove2Deg, EXACT_TIME_SEARCH_WINDOW), PREVIEW_HORIZON_DAYS);
|
|
182
|
+
// Search both targets (for conjunction/opposition, they're the same or opposite)
|
|
183
|
+
// Solver returns all roots sorted earliest-first
|
|
184
|
+
const roots1 = this.ephem.findExactTransitTimes(planetId, target1, currentJD - searchWindow, currentJD + searchWindow);
|
|
185
|
+
const roots2 = aspect.angle !== 0 && aspect.angle !== 180
|
|
186
|
+
? this.ephem.findExactTransitTimes(planetId, target2, currentJD - searchWindow, currentJD + searchWindow)
|
|
187
|
+
: [];
|
|
188
|
+
// Combine all roots from both targets and sort
|
|
189
|
+
const allRoots = [...roots1, ...roots2].sort((a, b) => a - b);
|
|
190
|
+
// Split into past and future roots
|
|
191
|
+
const futureRoots = allRoots.filter((jd) => jd >= currentJD);
|
|
192
|
+
const pastRoots = allRoots.filter((jd) => jd < currentJD);
|
|
193
|
+
// Select root based on applying/separating:
|
|
194
|
+
// - Applying: pick earliest future root (approaching exact)
|
|
195
|
+
// - Separating: pick latest past root (just passed exact), fallback to earliest future
|
|
196
|
+
const selectedRoot = heuristicApplying
|
|
197
|
+
? (futureRoots[0] ?? null)
|
|
198
|
+
: (pastRoots[pastRoots.length - 1] ?? futureRoots[0] ?? null);
|
|
199
|
+
if (selectedRoot === null) {
|
|
200
|
+
return { status: 'not_found', selectedRoot: null };
|
|
201
|
+
}
|
|
202
|
+
const daysUntilExact = selectedRoot - currentJD;
|
|
203
|
+
// Product policy: only show exact time if within preview horizon
|
|
204
|
+
// For outer planets, it's normal to be within 2° orb but months from exact
|
|
205
|
+
if (Math.abs(daysUntilExact) > PREVIEW_HORIZON_DAYS) {
|
|
206
|
+
return { status: 'outside_preview', selectedRoot };
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
exactTime: this.ephem.julianDayToDate(selectedRoot),
|
|
210
|
+
status: 'within_preview',
|
|
211
|
+
selectedRoot,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Determine if an aspect is applying or separating
|
|
216
|
+
*
|
|
217
|
+
* @param transitLon - Transiting planet longitude
|
|
218
|
+
* @param natalLon - Natal planet longitude
|
|
219
|
+
* @param transitSpeed - Transiting planet's daily motion
|
|
220
|
+
* @param aspectAngle - Target aspect angle
|
|
221
|
+
* @returns true if applying, false if separating
|
|
222
|
+
*
|
|
223
|
+
* @remarks
|
|
224
|
+
* Applying: Aspect getting stronger (closer to exact)
|
|
225
|
+
* Separating: Aspect getting weaker (moving away from exact)
|
|
226
|
+
*/
|
|
227
|
+
isApplying(transitLon, natalLon, transitSpeed, aspectAngle) {
|
|
228
|
+
if (transitSpeed === 0)
|
|
229
|
+
return false;
|
|
230
|
+
const currentAngle = this.ephem.calculateAspectAngle(transitLon, natalLon);
|
|
231
|
+
const futureAngle = this.ephem.calculateAspectAngle(transitLon + transitSpeed, natalLon);
|
|
232
|
+
return Math.abs(futureAngle - aspectAngle) < Math.abs(currentAngle - aspectAngle);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Get all transits for a specific date
|
|
236
|
+
*
|
|
237
|
+
* @param natalChart - Birth chart data
|
|
238
|
+
* @param date - Date for transit calculation (interpreted as-is, no timezone conversion)
|
|
239
|
+
* @returns TransitResponse with all active transits
|
|
240
|
+
* @throws Error if natal chart is invalid
|
|
241
|
+
*
|
|
242
|
+
* @remarks
|
|
243
|
+
* Internal UTC primitive: calculates transits for the provided date/time as-is.
|
|
244
|
+
* Caller is responsible for any user-facing timezone semantics.
|
|
245
|
+
*/
|
|
246
|
+
async getTransitsForDate(date, natalChart) {
|
|
247
|
+
const jd = this.ephem.dateToJulianDay(date);
|
|
248
|
+
// Get all major planets (Sun through Pluto)
|
|
249
|
+
const planetIds = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
250
|
+
const transitingPlanets = this.ephem.getAllPlanets(jd, planetIds);
|
|
251
|
+
const transits = this.findTransits(transitingPlanets, natalChart.planets || [], jd);
|
|
252
|
+
// Convert Transit[] to TransitData[] (serialize Date to ISO string)
|
|
253
|
+
const transitData = transits.map((t) => ({
|
|
254
|
+
...t,
|
|
255
|
+
exactTime: t.exactTime?.toISOString(),
|
|
256
|
+
}));
|
|
257
|
+
return {
|
|
258
|
+
date: date.toISOString().split('T')[0],
|
|
259
|
+
timezone: 'UTC',
|
|
260
|
+
transits: transitData,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|