ether-to-astro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.env.example +13 -0
  2. package/.github/pull_request_template.md +16 -0
  3. package/.github/workflows/release.yml +35 -0
  4. package/.github/workflows/test.yml +32 -0
  5. package/AGENTS.md +99 -0
  6. package/LICENSE +18 -0
  7. package/NOTICE.md +45 -0
  8. package/README.md +301 -0
  9. package/SETUP.md +70 -0
  10. package/TESTING_SUMMARY.md +238 -0
  11. package/TEST_SUITE_STATUS.md +218 -0
  12. package/biome.json +48 -0
  13. package/dist/astro-service.d.ts +98 -0
  14. package/dist/astro-service.js +496 -0
  15. package/dist/chart-types.d.ts +52 -0
  16. package/dist/chart-types.js +51 -0
  17. package/dist/charts.d.ts +125 -0
  18. package/dist/charts.js +324 -0
  19. package/dist/cli.d.ts +7 -0
  20. package/dist/cli.js +472 -0
  21. package/dist/constants.d.ts +81 -0
  22. package/dist/constants.js +76 -0
  23. package/dist/eclipses.d.ts +85 -0
  24. package/dist/eclipses.js +184 -0
  25. package/dist/ephemeris.d.ts +120 -0
  26. package/dist/ephemeris.js +379 -0
  27. package/dist/formatter.d.ts +2 -0
  28. package/dist/formatter.js +22 -0
  29. package/dist/houses.d.ts +82 -0
  30. package/dist/houses.js +169 -0
  31. package/dist/index.d.ts +14 -0
  32. package/dist/index.js +150 -0
  33. package/dist/loader.d.ts +2 -0
  34. package/dist/loader.js +31 -0
  35. package/dist/logger.d.ts +25 -0
  36. package/dist/logger.js +73 -0
  37. package/dist/profile-store.d.ts +48 -0
  38. package/dist/profile-store.js +156 -0
  39. package/dist/riseset.d.ts +82 -0
  40. package/dist/riseset.js +185 -0
  41. package/dist/storage.d.ts +10 -0
  42. package/dist/storage.js +40 -0
  43. package/dist/time-utils.d.ts +68 -0
  44. package/dist/time-utils.js +136 -0
  45. package/dist/tool-registry.d.ts +35 -0
  46. package/dist/tool-registry.js +307 -0
  47. package/dist/tool-result.d.ts +175 -0
  48. package/dist/tool-result.js +188 -0
  49. package/dist/transits.d.ts +108 -0
  50. package/dist/transits.js +263 -0
  51. package/dist/types.d.ts +450 -0
  52. package/dist/types.js +161 -0
  53. package/example-usage.md +131 -0
  54. package/natal-chart.json +187 -0
  55. package/package.json +61 -0
  56. package/scripts/download-ephemeris.js +115 -0
  57. package/setup.sh +21 -0
  58. package/src/astro-service.ts +710 -0
  59. package/src/chart-types.ts +125 -0
  60. package/src/charts.ts +399 -0
  61. package/src/cli.ts +694 -0
  62. package/src/constants.ts +89 -0
  63. package/src/eclipses.ts +226 -0
  64. package/src/ephemeris.ts +437 -0
  65. package/src/formatter.ts +25 -0
  66. package/src/houses.ts +202 -0
  67. package/src/index.ts +170 -0
  68. package/src/loader.ts +36 -0
  69. package/src/logger.ts +104 -0
  70. package/src/profile-store.ts +285 -0
  71. package/src/riseset.ts +229 -0
  72. package/src/time-utils.ts +167 -0
  73. package/src/tool-registry.ts +357 -0
  74. package/src/tool-result.ts +283 -0
  75. package/src/transits.ts +352 -0
  76. package/src/types.ts +547 -0
  77. package/tests/README.md +173 -0
  78. package/tests/TESTING_STRATEGY.md +178 -0
  79. package/tests/fixtures/bowen-yang-chart.ts +69 -0
  80. package/tests/fixtures/calculate-expected.ts +81 -0
  81. package/tests/fixtures/expected-results.ts +117 -0
  82. package/tests/fixtures/generate-expected-simple.ts +94 -0
  83. package/tests/helpers/date-fixtures.ts +15 -0
  84. package/tests/helpers/ephem.ts +11 -0
  85. package/tests/helpers/temp.ts +9 -0
  86. package/tests/setup.ts +11 -0
  87. package/tests/unit/astro-service.test.ts +323 -0
  88. package/tests/unit/chart-types.test.ts +18 -0
  89. package/tests/unit/charts-errors.test.ts +42 -0
  90. package/tests/unit/charts.test.ts +157 -0
  91. package/tests/unit/cli-commands.test.ts +82 -0
  92. package/tests/unit/cli-profiles.test.ts +128 -0
  93. package/tests/unit/cli.test.ts +191 -0
  94. package/tests/unit/constants.test.ts +26 -0
  95. package/tests/unit/correctness-critical.test.ts +408 -0
  96. package/tests/unit/eclipses.test.ts +108 -0
  97. package/tests/unit/ephemeris.test.ts +213 -0
  98. package/tests/unit/error-handling.test.ts +116 -0
  99. package/tests/unit/formatter.test.ts +29 -0
  100. package/tests/unit/houses-errors.test.ts +27 -0
  101. package/tests/unit/houses-validation.test.ts +164 -0
  102. package/tests/unit/houses.test.ts +205 -0
  103. package/tests/unit/profile-store.test.ts +163 -0
  104. package/tests/unit/real-user-charts.test.ts +148 -0
  105. package/tests/unit/riseset.test.ts +106 -0
  106. package/tests/unit/solver-edges.test.ts +197 -0
  107. package/tests/unit/time-utils-temporal.test.ts +303 -0
  108. package/tests/unit/time-utils.test.ts +173 -0
  109. package/tests/unit/tool-registry.test.ts +222 -0
  110. package/tests/unit/tool-result.test.ts +45 -0
  111. package/tests/unit/transit-correctness.test.ts +78 -0
  112. package/tests/unit/transits.test.ts +238 -0
  113. package/tests/validation/README.md +32 -0
  114. package/tests/validation/adapters/astrolog.ts +306 -0
  115. package/tests/validation/adapters/internal.ts +184 -0
  116. package/tests/validation/compare/eclipses.ts +47 -0
  117. package/tests/validation/compare/houses.ts +76 -0
  118. package/tests/validation/compare/positions.ts +104 -0
  119. package/tests/validation/compare/riseSet.ts +48 -0
  120. package/tests/validation/compare/roots.ts +90 -0
  121. package/tests/validation/compare/transits.ts +69 -0
  122. package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
  123. package/tests/validation/fixtures/eclipses/core.ts +14 -0
  124. package/tests/validation/fixtures/houses/core.ts +47 -0
  125. package/tests/validation/fixtures/positions/core.ts +159 -0
  126. package/tests/validation/fixtures/rise-set/core.ts +20 -0
  127. package/tests/validation/fixtures/roots/core.ts +47 -0
  128. package/tests/validation/fixtures/transits/core.ts +61 -0
  129. package/tests/validation/fixtures/transits/dst.ts +21 -0
  130. package/tests/validation/oracle.spec.ts +129 -0
  131. package/tests/validation/utils/denseRootOracle.ts +269 -0
  132. package/tests/validation/utils/fixtureTypes.ts +146 -0
  133. package/tests/validation/utils/report.ts +60 -0
  134. package/tests/validation/utils/tolerances.ts +23 -0
  135. package/tests/validation/validation.spec.ts +836 -0
  136. package/tools/color-picker.html +388 -0
  137. package/tsconfig.json +17 -0
  138. package/vitest.config.ts +31 -0
@@ -0,0 +1,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
+ }