@openwaters/almanac 0.5.0 → 0.6.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/README.md CHANGED
@@ -101,7 +101,7 @@ const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);
101
101
  ### Swift
102
102
 
103
103
  ```swift
104
- .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.5.0")
104
+ .package(url: "https://github.com/openwatersio/almanac.git", exact: "0.6.0")
105
105
  ```
106
106
 
107
107
  ```swift
@@ -155,7 +155,7 @@ anchor. Search results are global; apply `lunarEclipseVisibility` for an observe
155
155
 
156
156
  Solar eclipses are searched for an observer, because their contacts only exist for a place: `nextSolarEclipse(after, observer)`, `previousSolarEclipse(before, observer)`, and `solarEclipses(startUtc, endUtc, observer)`. An eclipse whose Sun is below the horizon at C1, the peak, and C4 is not returned.
157
157
 
158
- To ask about the whole Earth instead, `nextGlobalSolarEclipse(after)`, `previousGlobalSolarEclipse(before)`, and `globalSolarEclipses(startUtc, endUtc)` need no observer. Each eclipse reports its greatest eclipse, the shadow axis's distance from the Earth's center, and where the axis meets the ground, with the kind and obscuration seen there. When the axis misses the Earth the eclipse is partial and has no ground point.
158
+ To ask about the whole Earth instead, `nextGlobalSolarEclipse(after)`, `previousGlobalSolarEclipse(before)`, and `globalSolarEclipses(startUtc, endUtc)` need no observer. Each eclipse reports its greatest eclipse, the shadow axis's distance from the Earth's center, and where the axis meets the ground, with the kind and obscuration seen there. When the axis misses the Earth the eclipse is partial and has no ground point, and greatest eclipse falls on the Earth's limb nearest the axis, with the Sun on the horizon: `greatestLatitudeDeg`, `greatestLongitudeDeg`, and `greatestObscuration` give that place for every eclipse.
159
159
 
160
160
  `solarEclipseCentralLine(peak, stepSeconds)` samples where the axis meets the ground from its first contact to its last, and `solarEclipseAxisPoint(time)` gives that point at any instant. How far the next totality passes from a place is a distance to each point of a line:
161
161
 
@@ -13,6 +13,12 @@ export interface GlobalSolarEclipse {
13
13
  longitudeDeg: number | null;
14
14
  /** Fraction of the Sun's disc area covered at that point; exactly 1 for a total eclipse; `null` for a partial eclipse. */
15
15
  obscuration: number | null;
16
+ /** Geodetic latitude of greatest eclipse, degrees: the ground point, or for a partial eclipse the point on the Earth's limb nearest the axis, with the Sun on the horizon. */
17
+ greatestLatitudeDeg: number;
18
+ /** Longitude of greatest eclipse, degrees east in (−180, 180]. */
19
+ greatestLongitudeDeg: number;
20
+ /** Fraction of the Sun's disc area covered at greatest eclipse. */
21
+ greatestObscuration: number;
16
22
  }
17
23
  /** A point on a solar eclipse's central line: where the Moon's shadow axis meets the ground at one instant. */
18
24
  export interface SolarEclipseAxisPoint {
@@ -30,6 +30,10 @@
30
30
  // quadratic's discriminant, and an axis point rejects the crossing on the
31
31
  // Sun's side of the Moon that upstream, asking only at greatest eclipse,
32
32
  // never meets.
33
+ //
34
+ // Neither is greatest eclipse for an axis that misses the Earth: upstream
35
+ // reports no place at all, and this package reports the point on the limb
36
+ // nearest the axis, where the Sun is on the horizon.
33
37
  import { assertSupported, assertSupportedWindowEnd, AlmanacOutOfRangeError, SUPPORTED_MIN, SUPPORTED_MAX } from './types.js';
34
38
  import { dateFromUt, ttDaysFromUt, utDays } from './time.js';
35
39
  import { EARTH_EQUATORIAL_RADIUS_KM, EARTH_FLATTENING, EARTH_FLATTENING_SQUARED, KM_PER_AU, RAD2DEG, PrecessDirection, gyration } from './nutation.js';
@@ -133,6 +137,24 @@ function axisGround(shadow, axis, u) {
133
137
  const px = u * v.x - e.x;
134
138
  const py = u * v.y - e.y;
135
139
  const pz = (u * v.z - e.z) * EARTH_FLATTENING;
140
+ const { latitudeDeg, longitudeDeg, o } = geodeticPlace(shadow, tt, px, py, pz);
141
+ // Recalculate the shadow using a vector from the Moon's center toward the observer.
142
+ const surface = calcShadow(MOON_POLAR_RADIUS_KM, shadow.ut, o, shadow.dir);
143
+ // If we did everything right, the shadow distance should be very close to zero.
144
+ // That's because we already determined the observer 'o' is on the shadow axis!
145
+ // Upstream's bound is 1e-9 km; exceeding it is an implementation failure.
146
+ if (surface.r > 1.0e-9 || surface.r < 0.0)
147
+ throw new Error(`almanac internal: unexpected shadow distance from geoid intersection = ${surface.r}`);
148
+ const kind = eclipseKindFromUmbra(surface.k);
149
+ const obscuration = (kind === 'total') ? 1.0 : solarEclipseObscuration(shadow.dir, o);
150
+ return { latitudeDeg, longitudeDeg, kind, obscuration };
151
+ }
152
+ /**
153
+ * UPSTREAM: the middle of `GeoidIntersect`, astronomy.ts ~8842 — geodetic
154
+ * latitude and longitude of a geocentric equator-of-date point in km, and the
155
+ * lunacentric EQJ vector toward it in AU.
156
+ */
157
+ function geodeticPlace(shadow, tt, px, py, pz) {
136
158
  // Convert cartesian coordinates into geodetic latitude/longitude.
137
159
  const proj = Math.hypot(px, py) * EARTH_FLATTENING_SQUARED;
138
160
  let latitudeDeg;
@@ -153,16 +175,31 @@ function axisGround(shadow, axis, u) {
153
175
  const og = gyration({ x: px / KM_PER_AU, y: py / KM_PER_AU, z: pz / KM_PER_AU }, tt, PrecessDirection.Into2000);
154
176
  // Convert geocentric vector to lunacentric vector.
155
177
  const o = { x: og.x + shadow.target.x, y: og.y + shadow.target.y, z: og.z + shadow.target.z };
156
- // Recalculate the shadow using a vector from the Moon's center toward the observer.
178
+ return { latitudeDeg, longitudeDeg, o };
179
+ }
180
+ /**
181
+ * Greatest eclipse when the axis misses the geoid: the point on the Earth's
182
+ * limb nearest the axis. In the dilated coordinates the geoid is a sphere, and
183
+ * the axis's closest approach to its center, scaled onto it, stands for that point.
184
+ * The axis runs perpendicular to the radius there, so it lies in the sphere's
185
+ * tangent plane; dilation maps tangent planes to tangent planes, so on the
186
+ * ellipsoid the axis lies in the geodetic horizon and the Sun's center is at
187
+ * geometric altitude zero, as the catalog tabulates. The observer there sees
188
+ * what the local search would: a total or annular phase only inside the
189
+ * umbra, which a non-central eclipse can graze.
190
+ */
191
+ function limbGround(shadow, axis) {
192
+ const { tt, v, e, A, B } = axis;
193
+ const u = -B / (2 * A);
194
+ const cx = u * v.x - e.x;
195
+ const cy = u * v.y - e.y;
196
+ const cz = u * v.z - e.z;
197
+ const scale = EARTH_EQUATORIAL_RADIUS_KM / Math.sqrt(cx * cx + cy * cy + cz * cz);
198
+ const { latitudeDeg, longitudeDeg, o } = geodeticPlace(shadow, tt, cx * scale, cy * scale, cz * scale * EARTH_FLATTENING);
157
199
  const surface = calcShadow(MOON_POLAR_RADIUS_KM, shadow.ut, o, shadow.dir);
158
- // If we did everything right, the shadow distance should be very close to zero.
159
- // That's because we already determined the observer 'o' is on the shadow axis!
160
- // Upstream's bound is 1e-9 km; exceeding it is an implementation failure.
161
- if (surface.r > 1.0e-9 || surface.r < 0.0)
162
- throw new Error(`almanac internal: unexpected shadow distance from geoid intersection = ${surface.r}`);
163
- const kind = eclipseKindFromUmbra(surface.k);
164
- const obscuration = (kind === 'total') ? 1.0 : solarEclipseObscuration(shadow.dir, o);
165
- return { latitudeDeg, longitudeDeg, kind, obscuration };
200
+ const obscuration = (surface.r < Math.abs(surface.k) && eclipseKindFromUmbra(surface.k) === 'total')
201
+ ? 1.0 : solarEclipseObscuration(shadow.dir, o);
202
+ return { latitudeDeg, longitudeDeg, obscuration };
166
203
  }
167
204
  /**
168
205
  * UPSTREAM: `GeoidIntersect`'s choice of root — where the axis first meets
@@ -201,13 +238,18 @@ function grazingGround(shadow) {
201
238
  function geoidIntersect(shadow, peak) {
202
239
  const ground = nearAxisGround(shadow);
203
240
  if (ground === null) {
204
- // This is a partial solar eclipse, and an obscuration needs a place:
205
- // `solarEclipses` and `solarObscuration` take an observer.
206
- return { kind: 'partial', peak, axisDistanceKm: shadow.r, latitudeDeg: null, longitudeDeg: null, obscuration: null };
241
+ // This is a partial solar eclipse: no one stands on the axis, and
242
+ // greatest eclipse is on the limb nearest it.
243
+ const limb = limbGround(shadow, dilatedAxis(shadow));
244
+ return {
245
+ kind: 'partial', peak, axisDistanceKm: shadow.r, latitudeDeg: null, longitudeDeg: null, obscuration: null,
246
+ greatestLatitudeDeg: limb.latitudeDeg, greatestLongitudeDeg: limb.longitudeDeg, greatestObscuration: limb.obscuration
247
+ };
207
248
  }
208
249
  return {
209
250
  kind: ground.kind, peak, axisDistanceKm: shadow.r,
210
- latitudeDeg: ground.latitudeDeg, longitudeDeg: ground.longitudeDeg, obscuration: ground.obscuration
251
+ latitudeDeg: ground.latitudeDeg, longitudeDeg: ground.longitudeDeg, obscuration: ground.obscuration,
252
+ greatestLatitudeDeg: ground.latitudeDeg, greatestLongitudeDeg: ground.longitudeDeg, greatestObscuration: ground.obscuration
211
253
  };
212
254
  }
213
255
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwaters/almanac",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Offline sun & moon engine: positions, rise/set, moon phase, lunar and solar eclipses.",
5
5
  "license": "MIT",
6
6
  "repository": {