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,159 @@
|
|
|
1
|
+
import { PLANETS } from '../../../../src/types.js';
|
|
2
|
+
import type { PositionFixture } from '../../utils/fixtureTypes.js';
|
|
3
|
+
|
|
4
|
+
export const positionFixtures: PositionFixture[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'spring-equinox-window',
|
|
7
|
+
isoUtc: '2024-03-26T12:00:00Z',
|
|
8
|
+
planetIds: [PLANETS.SUN, PLANETS.MOON, PLANETS.MERCURY, PLANETS.PLUTO],
|
|
9
|
+
expected: [
|
|
10
|
+
{
|
|
11
|
+
body: 'Sun',
|
|
12
|
+
longitude: 6.316731386907804,
|
|
13
|
+
latitude: -0.00007352800431972934,
|
|
14
|
+
speed: 0.9897125520461556,
|
|
15
|
+
retrograde: false,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
body: 'Moon',
|
|
19
|
+
longitude: 199.48694064839447,
|
|
20
|
+
latitude: -0.36176675515418294,
|
|
21
|
+
speed: 11.941713114615638,
|
|
22
|
+
retrograde: false,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
body: 'Mercury',
|
|
26
|
+
longitude: 24.69285200607294,
|
|
27
|
+
latitude: 2.672580045178339,
|
|
28
|
+
speed: 0.7867951188862425,
|
|
29
|
+
retrograde: false,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
body: 'Pluto',
|
|
33
|
+
longitude: 301.7781198496598,
|
|
34
|
+
latitude: -2.927662494192267,
|
|
35
|
+
speed: 0.017128737345548647,
|
|
36
|
+
retrograde: false,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'mercury-retrograde',
|
|
42
|
+
isoUtc: '2024-04-10T12:00:00Z',
|
|
43
|
+
planetIds: [PLANETS.MERCURY, PLANETS.VENUS, PLANETS.MARS],
|
|
44
|
+
expected: [
|
|
45
|
+
{
|
|
46
|
+
body: 'Mercury',
|
|
47
|
+
longitude: 23.614957304937263,
|
|
48
|
+
latitude: 2.529284646399885,
|
|
49
|
+
speed: -0.7176024601820039,
|
|
50
|
+
retrograde: true,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
body: 'Venus',
|
|
54
|
+
longitude: 6.588795330387654,
|
|
55
|
+
latitude: -1.4940621615222225,
|
|
56
|
+
speed: 1.2350609766572045,
|
|
57
|
+
retrograde: false,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
body: 'Mars',
|
|
61
|
+
longitude: 344.4004646784754,
|
|
62
|
+
latitude: -1.2486830823566866,
|
|
63
|
+
speed: 0.7771414567155127,
|
|
64
|
+
retrograde: false,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'slow-movers',
|
|
70
|
+
isoUtc: '2025-01-15T00:00:00Z',
|
|
71
|
+
planetIds: [PLANETS.SATURN, PLANETS.URANUS, PLANETS.NEPTUNE, PLANETS.PLUTO],
|
|
72
|
+
expected: [
|
|
73
|
+
{
|
|
74
|
+
body: 'Saturn',
|
|
75
|
+
longitude: 345.7060989429216,
|
|
76
|
+
latitude: -1.9494898994552385,
|
|
77
|
+
speed: 0.0926345534031346,
|
|
78
|
+
retrograde: false,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
body: 'Uranus',
|
|
82
|
+
longitude: 53.36875615623478,
|
|
83
|
+
latitude: -0.24802075433686954,
|
|
84
|
+
speed: -0.013507214995734195,
|
|
85
|
+
retrograde: true,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
body: 'Neptune',
|
|
89
|
+
longitude: 357.54312216762855,
|
|
90
|
+
latitude: -1.2774823347799653,
|
|
91
|
+
speed: 0.02110562924597566,
|
|
92
|
+
retrograde: false,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
body: 'Pluto',
|
|
96
|
+
longitude: 301.5060850985308,
|
|
97
|
+
latitude: -3.288200764433513,
|
|
98
|
+
speed: 0.032129088743535324,
|
|
99
|
+
retrograde: false,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'millennium',
|
|
105
|
+
isoUtc: '2000-01-01T12:00:00Z',
|
|
106
|
+
planetIds: [PLANETS.SUN, PLANETS.JUPITER, PLANETS.PLUTO],
|
|
107
|
+
expected: [
|
|
108
|
+
{
|
|
109
|
+
body: 'Sun',
|
|
110
|
+
longitude: 280.36891967534336,
|
|
111
|
+
latitude: 0.000232326514176311,
|
|
112
|
+
speed: 1.0194320944210782,
|
|
113
|
+
retrograde: false,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
body: 'Jupiter',
|
|
117
|
+
longitude: 25.253030309421774,
|
|
118
|
+
latitude: -1.2621728355212258,
|
|
119
|
+
speed: 0.040761317651403686,
|
|
120
|
+
retrograde: false,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
body: 'Pluto',
|
|
124
|
+
longitude: 251.4547088467409,
|
|
125
|
+
latitude: 10.855202461622458,
|
|
126
|
+
speed: 0.035152902046821095,
|
|
127
|
+
retrograde: false,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'far-future',
|
|
133
|
+
isoUtc: '2099-12-31T00:00:00Z',
|
|
134
|
+
planetIds: [PLANETS.SUN, PLANETS.MERCURY, PLANETS.NEPTUNE],
|
|
135
|
+
expected: [
|
|
136
|
+
{
|
|
137
|
+
body: 'Sun',
|
|
138
|
+
longitude: 279.58558664635507,
|
|
139
|
+
latitude: 0.00011630013927636058,
|
|
140
|
+
speed: 1.0187919739012508,
|
|
141
|
+
retrograde: false,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
body: 'Mercury',
|
|
145
|
+
longitude: 286.38509679515437,
|
|
146
|
+
latitude: -2.0863288163781823,
|
|
147
|
+
speed: 1.6192452202966239,
|
|
148
|
+
retrograde: false,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
body: 'Neptune',
|
|
152
|
+
longitude: 167.29740813269177,
|
|
153
|
+
latitude: 0.963176276464019,
|
|
154
|
+
speed: -0.006626443683328727,
|
|
155
|
+
retrograde: true,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PLANETS } from '../../../../src/types.js';
|
|
2
|
+
import type { RiseSetFixture } from '../../utils/fixtureTypes.js';
|
|
3
|
+
|
|
4
|
+
export const riseSetFixtures: RiseSetFixture[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'los-angeles-next-events',
|
|
7
|
+
isoUtc: '2024-03-26T20:30:00Z',
|
|
8
|
+
latitude: 34.0522,
|
|
9
|
+
longitude: -118.2437,
|
|
10
|
+
planetId: PLANETS.SUN,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'high-latitude-no-rise-no-set',
|
|
14
|
+
isoUtc: '2024-12-21T00:00:00Z',
|
|
15
|
+
latitude: 78.2232,
|
|
16
|
+
longitude: 15.6267,
|
|
17
|
+
planetId: PLANETS.SUN,
|
|
18
|
+
expectedNoRiseSet: true,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { PLANETS } from '../../../../src/types.js';
|
|
2
|
+
import type { RootFixture } from '../../utils/fixtureTypes.js';
|
|
3
|
+
|
|
4
|
+
export const rootFixtures: RootFixture[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'sign-change-root',
|
|
7
|
+
planetId: PLANETS.MOON,
|
|
8
|
+
targetLongitude: 180,
|
|
9
|
+
startIsoUtc: '2024-01-01T00:00:00Z',
|
|
10
|
+
endIsoUtc: '2024-01-20T00:00:00Z',
|
|
11
|
+
expectedMinRoots: 1,
|
|
12
|
+
expectedMaxRoots: 1,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'endpoint-near-root',
|
|
16
|
+
planetId: PLANETS.SUN,
|
|
17
|
+
targetFromStartLongitude: true,
|
|
18
|
+
startIsoUtc: '2024-03-20T00:00:00Z',
|
|
19
|
+
endIsoUtc: '2024-03-30T00:00:00Z',
|
|
20
|
+
expectedMinRoots: 1,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'no-root-interval',
|
|
24
|
+
planetId: PLANETS.SUN,
|
|
25
|
+
targetLongitude: 0,
|
|
26
|
+
startIsoUtc: '2024-03-01T00:00:00Z',
|
|
27
|
+
endIsoUtc: '2024-03-05T00:00:00Z',
|
|
28
|
+
expectedMinRoots: 0,
|
|
29
|
+
expectedMaxRoots: 0,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'multiple-root-interval',
|
|
33
|
+
planetId: PLANETS.MOON,
|
|
34
|
+
targetLongitude: 0,
|
|
35
|
+
startIsoUtc: '2024-01-01T00:00:00Z',
|
|
36
|
+
endIsoUtc: '2024-03-01T00:00:00Z',
|
|
37
|
+
expectedMinRoots: 2,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'tangential-mercury-station',
|
|
41
|
+
planetId: PLANETS.MERCURY,
|
|
42
|
+
targetFromSampledMinimum: { samples: 96 },
|
|
43
|
+
startIsoUtc: '2023-12-11T00:00:00Z',
|
|
44
|
+
endIsoUtc: '2023-12-15T00:00:00Z',
|
|
45
|
+
expectedMinRoots: 1,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PLANETS } from '../../../../src/types.js';
|
|
2
|
+
import type { TransitFixture } from '../../utils/fixtureTypes.js';
|
|
3
|
+
|
|
4
|
+
export const transitFixtures: TransitFixture[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'applying-square-policy',
|
|
7
|
+
currentIsoUtc: '2024-03-15T00:00:00Z',
|
|
8
|
+
transitingPlanetId: PLANETS.MARS,
|
|
9
|
+
natalPlanetId: PLANETS.VENUS,
|
|
10
|
+
natalOffsetDegrees: 92,
|
|
11
|
+
expectedAspect: 'square',
|
|
12
|
+
expectedIsApplying: true,
|
|
13
|
+
expectExactTimeStatus: 'within_preview',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'separating-square-policy',
|
|
17
|
+
currentIsoUtc: '2024-03-15T00:00:00Z',
|
|
18
|
+
transitingPlanetId: PLANETS.MARS,
|
|
19
|
+
natalPlanetId: PLANETS.VENUS,
|
|
20
|
+
natalOffsetDegrees: 88,
|
|
21
|
+
expectedAspect: 'square',
|
|
22
|
+
expectedIsApplying: false,
|
|
23
|
+
expectExactTimeStatus: 'within_preview',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'orb-too-wide-status-undefined',
|
|
27
|
+
currentIsoUtc: '2024-03-15T00:00:00Z',
|
|
28
|
+
transitingPlanetId: PLANETS.MARS,
|
|
29
|
+
natalPlanetId: PLANETS.VENUS,
|
|
30
|
+
natalOffsetDegrees: 3,
|
|
31
|
+
expectedAspect: 'conjunction',
|
|
32
|
+
expectExactTimeStatus: 'undefined',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'dual-target-trine',
|
|
36
|
+
currentIsoUtc: '2024-03-15T00:00:00Z',
|
|
37
|
+
transitingPlanetId: PLANETS.JUPITER,
|
|
38
|
+
natalPlanetId: PLANETS.SUN,
|
|
39
|
+
natalOffsetDegrees: 120,
|
|
40
|
+
expectedAspect: 'trine',
|
|
41
|
+
expectExactTimeStatus: 'within_preview',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'dual-target-sextile',
|
|
45
|
+
currentIsoUtc: '2024-03-15T00:00:00Z',
|
|
46
|
+
transitingPlanetId: PLANETS.MARS,
|
|
47
|
+
natalPlanetId: PLANETS.SUN,
|
|
48
|
+
natalOffsetDegrees: 60,
|
|
49
|
+
expectedAspect: 'sextile',
|
|
50
|
+
expectExactTimeStatus: 'within_preview',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'opposition-case',
|
|
54
|
+
currentIsoUtc: '2024-03-15T00:00:00Z',
|
|
55
|
+
transitingPlanetId: PLANETS.JUPITER,
|
|
56
|
+
natalPlanetId: PLANETS.MARS,
|
|
57
|
+
natalOffsetDegrees: 180,
|
|
58
|
+
expectedAspect: 'opposition',
|
|
59
|
+
expectExactTimeStatus: 'within_preview',
|
|
60
|
+
},
|
|
61
|
+
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface DstFixture {
|
|
2
|
+
name: string;
|
|
3
|
+
timezone: string;
|
|
4
|
+
local: { year: number; month: number; day: number; hour: number; minute: number };
|
|
5
|
+
kind: 'ambiguous' | 'nonexistent';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const dstFixtures: DstFixture[] = [
|
|
9
|
+
{
|
|
10
|
+
name: 'dst-ambiguous-local-time',
|
|
11
|
+
timezone: 'America/New_York',
|
|
12
|
+
local: { year: 2024, month: 11, day: 3, hour: 1, minute: 30 },
|
|
13
|
+
kind: 'ambiguous',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'dst-nonexistent-local-time',
|
|
17
|
+
timezone: 'America/New_York',
|
|
18
|
+
local: { year: 2024, month: 3, day: 10, hour: 2, minute: 30 },
|
|
19
|
+
kind: 'nonexistent',
|
|
20
|
+
},
|
|
21
|
+
];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { EphemerisCalculator } from '../../src/ephemeris.js';
|
|
3
|
+
import { PLANETS } from '../../src/types.js';
|
|
4
|
+
import { denseScanRootOracleWithDebug } from './utils/denseRootOracle.js';
|
|
5
|
+
|
|
6
|
+
function shortestDiff(longitude: number, targetLongitude: number): number {
|
|
7
|
+
let diff = (((longitude % 360) + 360) % 360) - (((targetLongitude % 360) + 360) % 360);
|
|
8
|
+
if (diff > 180) diff -= 360;
|
|
9
|
+
if (diff < -180) diff += 360;
|
|
10
|
+
return diff;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('Dense Root Oracle', () => {
|
|
14
|
+
let ephem: EphemerisCalculator;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
ephem = new EphemerisCalculator();
|
|
18
|
+
await ephem.init();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('finds multiple Moon crossings in a multi-week interval', () => {
|
|
22
|
+
const start = ephem.dateToJulianDay(new Date('2024-01-01T00:00:00Z'));
|
|
23
|
+
const end = ephem.dateToJulianDay(new Date('2024-03-01T00:00:00Z'));
|
|
24
|
+
const target = 0;
|
|
25
|
+
const result = denseScanRootOracleWithDebug(
|
|
26
|
+
(jd) => ephem.getPlanetPosition(PLANETS.MOON, jd).longitude,
|
|
27
|
+
target,
|
|
28
|
+
start,
|
|
29
|
+
end
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(result.roots.length).toBeGreaterThanOrEqual(2);
|
|
33
|
+
for (const jd of result.roots) {
|
|
34
|
+
const lon = ephem.getPlanetPosition(PLANETS.MOON, jd).longitude;
|
|
35
|
+
expect(Math.abs(shortestDiff(lon, target))).toBeLessThan(0.05);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is symmetric around wrap targets (0° vs 359.9°)', () => {
|
|
40
|
+
const start = ephem.dateToJulianDay(new Date('2024-03-19T00:00:00Z'));
|
|
41
|
+
const end = ephem.dateToJulianDay(new Date('2024-03-22T00:00:00Z'));
|
|
42
|
+
|
|
43
|
+
const at0 = denseScanRootOracleWithDebug(
|
|
44
|
+
(jd) => ephem.getPlanetPosition(PLANETS.SUN, jd).longitude,
|
|
45
|
+
0,
|
|
46
|
+
start,
|
|
47
|
+
end
|
|
48
|
+
).roots;
|
|
49
|
+
const at3599 = denseScanRootOracleWithDebug(
|
|
50
|
+
(jd) => ephem.getPlanetPosition(PLANETS.SUN, jd).longitude,
|
|
51
|
+
359.9,
|
|
52
|
+
start,
|
|
53
|
+
end
|
|
54
|
+
).roots;
|
|
55
|
+
|
|
56
|
+
expect(at0.length).toBeGreaterThan(0);
|
|
57
|
+
expect(at3599.length).toBeGreaterThan(0);
|
|
58
|
+
expect(Math.abs(at0[0] - at3599[0])).toBeLessThan(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('captures tangential near-station root behavior', () => {
|
|
62
|
+
const start = ephem.dateToJulianDay(new Date('2023-12-11T00:00:00Z'));
|
|
63
|
+
const end = ephem.dateToJulianDay(new Date('2023-12-15T00:00:00Z'));
|
|
64
|
+
|
|
65
|
+
let minLon = Number.POSITIVE_INFINITY;
|
|
66
|
+
for (let i = 0; i <= 96; i++) {
|
|
67
|
+
const jd = start + (i * (end - start)) / 96;
|
|
68
|
+
const lon = ephem.getPlanetPosition(PLANETS.MERCURY, jd).longitude;
|
|
69
|
+
if (lon < minLon) minLon = lon;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = denseScanRootOracleWithDebug(
|
|
73
|
+
(jd) => ephem.getPlanetPosition(PLANETS.MERCURY, jd).longitude,
|
|
74
|
+
minLon,
|
|
75
|
+
start,
|
|
76
|
+
end
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(result.roots.length).toBeGreaterThan(0);
|
|
80
|
+
const jd = result.roots[0];
|
|
81
|
+
const lon = ephem.getPlanetPosition(PLANETS.MERCURY, jd).longitude;
|
|
82
|
+
expect(Math.abs(shortestDiff(lon, minLon))).toBeLessThan(0.05);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps endpoint-near-root cases', () => {
|
|
86
|
+
const start = ephem.dateToJulianDay(new Date('2024-03-20T00:00:00Z'));
|
|
87
|
+
const end = ephem.dateToJulianDay(new Date('2024-03-30T00:00:00Z'));
|
|
88
|
+
const target = ephem.getPlanetPosition(PLANETS.SUN, start).longitude;
|
|
89
|
+
const result = denseScanRootOracleWithDebug(
|
|
90
|
+
(jd) => ephem.getPlanetPosition(PLANETS.SUN, jd).longitude,
|
|
91
|
+
target,
|
|
92
|
+
start,
|
|
93
|
+
end
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(result.roots.length).toBeGreaterThan(0);
|
|
97
|
+
expect(Math.abs(result.roots[0] - start)).toBeLessThan(1 / 24); // within ~1 hour
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('oracle roots are self-consistent (crossing or tangential minimum)', () => {
|
|
101
|
+
const start = ephem.dateToJulianDay(new Date('2024-01-01T00:00:00Z'));
|
|
102
|
+
const end = ephem.dateToJulianDay(new Date('2024-01-20T00:00:00Z'));
|
|
103
|
+
const target = 0;
|
|
104
|
+
const result = denseScanRootOracleWithDebug(
|
|
105
|
+
(jd) => ephem.getPlanetPosition(PLANETS.MOON, jd).longitude,
|
|
106
|
+
target,
|
|
107
|
+
start,
|
|
108
|
+
end
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const epsilon = 5 / 1440; // 5 minutes
|
|
112
|
+
for (const root of result.roots) {
|
|
113
|
+
const before = ephem.getPlanetPosition(PLANETS.MOON, root - epsilon).longitude;
|
|
114
|
+
const at = ephem.getPlanetPosition(PLANETS.MOON, root).longitude;
|
|
115
|
+
const after = ephem.getPlanetPosition(PLANETS.MOON, root + epsilon).longitude;
|
|
116
|
+
const dBefore = shortestDiff(before, target);
|
|
117
|
+
const dAt = shortestDiff(at, target);
|
|
118
|
+
const dAfter = shortestDiff(after, target);
|
|
119
|
+
const crossing = dBefore === 0 || dAfter === 0 || Math.sign(dBefore) !== Math.sign(dAfter);
|
|
120
|
+
const tangentialMin =
|
|
121
|
+
Math.abs(dAt) <= Math.abs(dBefore) &&
|
|
122
|
+
Math.abs(dAt) <= Math.abs(dAfter) &&
|
|
123
|
+
Math.abs(dAt) < 0.1;
|
|
124
|
+
|
|
125
|
+
expect(crossing || tangentialMin).toBe(true);
|
|
126
|
+
expect(Math.abs(dAt)).toBeLessThan(0.1);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { minutesToDays, TOLERANCES } from './tolerances.js';
|
|
2
|
+
|
|
3
|
+
interface Sample {
|
|
4
|
+
jd: number;
|
|
5
|
+
longitude: number;
|
|
6
|
+
shortestDiff: number;
|
|
7
|
+
phi: number; // Unwrapped raw phase (longitude - target), continuous over interval.
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OracleDebugInfo {
|
|
11
|
+
toleranceDeg: number;
|
|
12
|
+
sampleStepDays: number;
|
|
13
|
+
dedupeEpsilonDays: number;
|
|
14
|
+
samples: Array<{
|
|
15
|
+
jd: number;
|
|
16
|
+
isoUtc?: string;
|
|
17
|
+
longitude: number;
|
|
18
|
+
shortestDiff: number;
|
|
19
|
+
phi: number;
|
|
20
|
+
}>;
|
|
21
|
+
crossings: Array<{
|
|
22
|
+
k: number;
|
|
23
|
+
startJD: number;
|
|
24
|
+
endJD: number;
|
|
25
|
+
}>;
|
|
26
|
+
sanityWarnings: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface OracleOptions {
|
|
30
|
+
toleranceDeg?: number;
|
|
31
|
+
maxStepDays?: number;
|
|
32
|
+
dedupeEpsilonDays?: number;
|
|
33
|
+
maxIterations?: number;
|
|
34
|
+
toIsoUtc?: (jd: number) => string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeAngle(angle: number): number {
|
|
38
|
+
return ((angle % 360) + 360) % 360;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function signedShortestDiff(longitude: number, targetLongitude: number): number {
|
|
42
|
+
let diff = normalizeAngle(longitude) - normalizeAngle(targetLongitude);
|
|
43
|
+
if (diff > 180) diff -= 360;
|
|
44
|
+
if (diff < -180) diff += 360;
|
|
45
|
+
return diff;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function dedupeSortedRoots(roots: number[], epsilonDays: number): number[] {
|
|
49
|
+
const deduped: number[] = [];
|
|
50
|
+
for (const root of roots) {
|
|
51
|
+
const last = deduped[deduped.length - 1];
|
|
52
|
+
if (last == null || Math.abs(root - last) > epsilonDays) {
|
|
53
|
+
deduped.push(root);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return deduped;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unwrapNextPhi(prevPhi: number, rawPhase: number): number {
|
|
60
|
+
let candidate = rawPhase;
|
|
61
|
+
while (candidate - prevPhi > 180) candidate -= 360;
|
|
62
|
+
while (candidate - prevPhi < -180) candidate += 360;
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function enumerateCrossingKs(a: number, b: number): number[] {
|
|
67
|
+
const lo = Math.min(a, b);
|
|
68
|
+
const hi = Math.max(a, b);
|
|
69
|
+
const startK = Math.ceil(lo / 360);
|
|
70
|
+
const endK = Math.floor(hi / 360);
|
|
71
|
+
if (startK > endK) return [];
|
|
72
|
+
|
|
73
|
+
const ks: number[] = [];
|
|
74
|
+
for (let k = startK; k <= endK; k++) {
|
|
75
|
+
ks.push(k);
|
|
76
|
+
}
|
|
77
|
+
return ks;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function denseScanRootOracle(
|
|
81
|
+
getLongitudeAtJd: (jd: number) => number,
|
|
82
|
+
targetLongitude: number,
|
|
83
|
+
startJD: number,
|
|
84
|
+
endJD: number,
|
|
85
|
+
options: OracleOptions = {}
|
|
86
|
+
): number[] {
|
|
87
|
+
return denseScanRootOracleWithDebug(getLongitudeAtJd, targetLongitude, startJD, endJD, options)
|
|
88
|
+
.roots;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function denseScanRootOracleWithDebug(
|
|
92
|
+
getLongitudeAtJd: (jd: number) => number,
|
|
93
|
+
targetLongitude: number,
|
|
94
|
+
startJD: number,
|
|
95
|
+
endJD: number,
|
|
96
|
+
options: OracleOptions = {}
|
|
97
|
+
): { roots: number[]; debug: OracleDebugInfo } {
|
|
98
|
+
const toleranceDeg = options.toleranceDeg ?? 0.01;
|
|
99
|
+
const maxStepDays = options.maxStepDays ?? 0.125; // 3h
|
|
100
|
+
const dedupeEpsilonDays = options.dedupeEpsilonDays ?? minutesToDays(TOLERANCES.dedupeMinutes);
|
|
101
|
+
const maxIterations = options.maxIterations ?? 80;
|
|
102
|
+
|
|
103
|
+
if (!(startJD < endJD)) {
|
|
104
|
+
throw new Error(`denseScanRootOracle expected startJD < endJD, got ${startJD} >= ${endJD}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const spanDays = endJD - startJD;
|
|
108
|
+
const sampleCount = Math.max(16, Math.ceil(spanDays / maxStepDays));
|
|
109
|
+
const stepDays = spanDays / sampleCount;
|
|
110
|
+
|
|
111
|
+
const samples: Sample[] = [];
|
|
112
|
+
const sanityWarnings: string[] = [];
|
|
113
|
+
let prevPhi: number | null = null;
|
|
114
|
+
for (let i = 0; i <= sampleCount; i++) {
|
|
115
|
+
const jd = startJD + i * stepDays;
|
|
116
|
+
const longitude = getLongitudeAtJd(jd);
|
|
117
|
+
const rawPhase = longitude - targetLongitude;
|
|
118
|
+
const phi = prevPhi == null ? rawPhase : unwrapNextPhi(prevPhi, rawPhase);
|
|
119
|
+
const shortestDiff = signedShortestDiff(longitude, targetLongitude);
|
|
120
|
+
|
|
121
|
+
// Coarse sanity guard against sampling pathologies in test harness.
|
|
122
|
+
if (prevPhi != null && Math.abs(phi - prevPhi) > 40) {
|
|
123
|
+
sanityWarnings.push(
|
|
124
|
+
`Large phase jump at sample ${i} (Δphi=${(phi - prevPhi).toFixed(3)}° over ${stepDays.toFixed(6)}d)`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
samples.push({ jd, longitude, shortestDiff, phi });
|
|
129
|
+
prevPhi = phi;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const roots: number[] = [];
|
|
133
|
+
const crossings: OracleDebugInfo['crossings'] = [];
|
|
134
|
+
|
|
135
|
+
// Endpoint near-zero roots only (avoid over-counting sampled interior points).
|
|
136
|
+
const startSample = samples[0];
|
|
137
|
+
const endSample = samples[samples.length - 1];
|
|
138
|
+
if (Math.abs(startSample.shortestDiff) <= toleranceDeg * 2) {
|
|
139
|
+
roots.push(startSample.jd);
|
|
140
|
+
}
|
|
141
|
+
if (Math.abs(endSample.shortestDiff) <= toleranceDeg * 2) {
|
|
142
|
+
roots.push(endSample.jd);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Enumerate all k*360 crossings in each sampled interval.
|
|
146
|
+
for (let i = 0; i < samples.length - 1; i++) {
|
|
147
|
+
const left = samples[i];
|
|
148
|
+
const right = samples[i + 1];
|
|
149
|
+
const ks = enumerateCrossingKs(left.phi, right.phi);
|
|
150
|
+
if (ks.length === 0) continue;
|
|
151
|
+
|
|
152
|
+
for (const k of ks) {
|
|
153
|
+
const targetPhase = k * 360;
|
|
154
|
+
crossings.push({ k, startJD: left.jd, endJD: right.jd });
|
|
155
|
+
|
|
156
|
+
// If bracket endpoint is already exact, keep a single endpoint root.
|
|
157
|
+
if (Math.abs(left.shortestDiff) <= toleranceDeg) {
|
|
158
|
+
roots.push(left.jd);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (Math.abs(right.shortestDiff) <= toleranceDeg) {
|
|
162
|
+
roots.push(right.jd);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let bLeft = left;
|
|
167
|
+
let bRight = right;
|
|
168
|
+
let iterations = 0;
|
|
169
|
+
let found = false;
|
|
170
|
+
while (iterations < maxIterations && bRight.jd - bLeft.jd > dedupeEpsilonDays / 4) {
|
|
171
|
+
const midJD = (bLeft.jd + bRight.jd) / 2;
|
|
172
|
+
const midLongitude = getLongitudeAtJd(midJD);
|
|
173
|
+
const midRawPhase = midLongitude - targetLongitude;
|
|
174
|
+
const midPhi = unwrapNextPhi(bLeft.phi, midRawPhase);
|
|
175
|
+
const midShortestDiff = signedShortestDiff(midLongitude, targetLongitude);
|
|
176
|
+
|
|
177
|
+
if (Math.abs(midShortestDiff) <= toleranceDeg) {
|
|
178
|
+
roots.push(midJD);
|
|
179
|
+
found = true;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if ((bLeft.phi - targetPhase) * (midPhi - targetPhase) <= 0) {
|
|
184
|
+
bRight = {
|
|
185
|
+
jd: midJD,
|
|
186
|
+
longitude: midLongitude,
|
|
187
|
+
shortestDiff: midShortestDiff,
|
|
188
|
+
phi: midPhi,
|
|
189
|
+
};
|
|
190
|
+
} else {
|
|
191
|
+
bLeft = {
|
|
192
|
+
jd: midJD,
|
|
193
|
+
longitude: midLongitude,
|
|
194
|
+
shortestDiff: midShortestDiff,
|
|
195
|
+
phi: midPhi,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
iterations++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!found) {
|
|
202
|
+
roots.push((bLeft.jd + bRight.jd) / 2);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Tangential fallback (no crossing required).
|
|
208
|
+
for (let i = 1; i < samples.length - 1; i++) {
|
|
209
|
+
const prev = samples[i - 1];
|
|
210
|
+
const curr = samples[i];
|
|
211
|
+
const next = samples[i + 1];
|
|
212
|
+
const prevAbs = Math.abs(prev.shortestDiff);
|
|
213
|
+
const currAbs = Math.abs(curr.shortestDiff);
|
|
214
|
+
const nextAbs = Math.abs(next.shortestDiff);
|
|
215
|
+
|
|
216
|
+
const isLocalMin =
|
|
217
|
+
currAbs <= prevAbs && currAbs <= nextAbs && (currAbs < prevAbs || currAbs < nextAbs);
|
|
218
|
+
if (!isLocalMin) continue;
|
|
219
|
+
|
|
220
|
+
const hasPhaseCrossingHere = enumerateCrossingKs(prev.phi, next.phi).length > 0;
|
|
221
|
+
if (hasPhaseCrossingHere) continue;
|
|
222
|
+
|
|
223
|
+
if (currAbs > toleranceDeg * 20) continue;
|
|
224
|
+
|
|
225
|
+
let leftJD = prev.jd;
|
|
226
|
+
let rightJD = next.jd;
|
|
227
|
+
let iterations = 0;
|
|
228
|
+
while (iterations < maxIterations && rightJD - leftJD > dedupeEpsilonDays / 4) {
|
|
229
|
+
const m1 = leftJD + (rightJD - leftJD) / 3;
|
|
230
|
+
const m2 = rightJD - (rightJD - leftJD) / 3;
|
|
231
|
+
const d1 = Math.abs(signedShortestDiff(getLongitudeAtJd(m1), targetLongitude));
|
|
232
|
+
const d2 = Math.abs(signedShortestDiff(getLongitudeAtJd(m2), targetLongitude));
|
|
233
|
+
if (d1 <= d2) {
|
|
234
|
+
rightJD = m2;
|
|
235
|
+
} else {
|
|
236
|
+
leftJD = m1;
|
|
237
|
+
}
|
|
238
|
+
iterations++;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const candidateJD = (leftJD + rightJD) / 2;
|
|
242
|
+
const candidateAbs = Math.abs(
|
|
243
|
+
signedShortestDiff(getLongitudeAtJd(candidateJD), targetLongitude)
|
|
244
|
+
);
|
|
245
|
+
if (candidateAbs <= toleranceDeg * 2) {
|
|
246
|
+
roots.push(candidateJD);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
roots.sort((a, b) => a - b);
|
|
251
|
+
const dedupedRoots = dedupeSortedRoots(roots, dedupeEpsilonDays);
|
|
252
|
+
|
|
253
|
+
const debug: OracleDebugInfo = {
|
|
254
|
+
toleranceDeg,
|
|
255
|
+
sampleStepDays: stepDays,
|
|
256
|
+
dedupeEpsilonDays,
|
|
257
|
+
sanityWarnings,
|
|
258
|
+
crossings,
|
|
259
|
+
samples: samples.map((s) => ({
|
|
260
|
+
jd: s.jd,
|
|
261
|
+
isoUtc: options.toIsoUtc ? options.toIsoUtc(s.jd) : undefined,
|
|
262
|
+
longitude: s.longitude,
|
|
263
|
+
shortestDiff: s.shortestDiff,
|
|
264
|
+
phi: s.phi,
|
|
265
|
+
})),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
return { roots: dedupedRoots, debug };
|
|
269
|
+
}
|