diff --git a/README.en.md b/README.en.md index e5a5d8f..11fd503 100644 --- a/README.en.md +++ b/README.en.md @@ -4,13 +4,13 @@ [![Go Reference](https://pkg.go.dev/badge/b612.me/astro.svg)](https://pkg.go.dev/b612.me/astro) -A personal astronomy library developed over years for calendrical work, amateur observing, outreach demos, and lightweight research. +A personal astronomy library developed over years for calendrical-astronomy hobby work. -> This project is mainly for learning and validating astronomical algorithms. The results are intended for serious amateur use. +> 📚 This project is mainly for learning and validating astronomical algorithms. The results are intended for serious amateur use. -The implementation follows *Astronomical Algorithms*. It covers calendar conversion, Sun/Moon/planet positions, eclipses, lunar occultations, rise/set/transit times, lunar phases, stars, coordinate transforms, physical ephemerides, research formulas, and generic small-body orbit propagation. +The implementation follows *Astronomical Algorithms*; the covered scope is listed in [Highlights](#highlights) below. -The Sun and planets use built-in VSOP87-style analytical terms, while the Moon uses a built-in ELP/MPP02 DE405-style analytical series. No external JPL ephemeris files are required. +The Sun and planets use built-in VSOP87-style analytical terms, while the Moon uses a built-in ELP/MPP02 DE405-fitted analytical series. No external JPL ephemeris files are required. Unless noted otherwise, coordinates are apparent-of-date coordinates. Angles are in degrees, apparent diameters and semidiameters are in arcseconds, and distances use the unit implied by the function name, usually `AU` or `km`. @@ -24,8 +24,6 @@ Unless noted otherwise, coordinates are apparent-of-date coordinates. Angles are - [Calendar And Solar Terms](#calendar-and-solar-terms) - [Sun And Moon](#sun-and-moon) - [Lite Sun And Moon](#lite-sun-and-moon) - - [Solar Eclipse](#solar-eclipse) - - [Lunar Eclipse](#lunar-eclipse) - [Lunar Occultations](#lunar-occultations) - [Event Maps And GeoJSON](#event-maps-and-geojson) - [Planets](#planets) @@ -45,19 +43,19 @@ go get b612.me/astro ## Highlights -- Calendar conversion between Gregorian dates and the traditional Chinese lunisolar calendar, from 721 BCE through 3000 CE or later, including solar terms -- Solar position, rise/set, Earth distance, apparent solar time, apparent altitude, parallactic angle, solar `P/B0/L0`, apparent diameter -- Lunar position, rise/set, Earth distance, phase, new/full/quarter times, apparent diameter, bright-limb angle, parallactic angle, geocentric/topocentric libration, apsides, nodes, maximum declination -- `lite/sun` and `lite/moon` lightweight approximation chains for watches, frontends, mini programs, and other resource-constrained environments -- Global and local solar/lunar eclipses, solar central paths, partial footprints, visible local lunar eclipses, Saros metadata, local diagrams, and global visibility-map SVGs -- Point-source stellar and finite-disk planetary lunar occultations, with fixed-site contacts, global paths, geometric greatest points, and SVG output -- GeoJSON for solar eclipses, lunar eclipses, and lunar occultations, including timed paths and optional time-marker points; border-free global SVG maps support equirectangular and polar projections -- Seven major planets with positions, rise/set, conjunction/opposition/station events, quadratures, elongations, Mercury/Venus geocentric transits, nodes, phase, apparent magnitude, apparent diameter, parallactic angle, and physical ephemerides -- 9100+ star catalog entries, constellation lookup, proper-motion propagation, rise/set, parallactic angle, and apparent altitude -- Coordinate transforms, topocentric coordinates, sidereal time, precession, nutation, angular distance, refraction, airmass, parallactic angle, and Galactic coordinates -- Standalone formulas for blackbody radiation, synodic periods, photometry, telescope limiting magnitude, stellar radius/temperature/luminosity relations, and airmass models -- Generic heliocentric two-body orbit propagation for asteroids, comets, dwarf planets, and hypothetical objects, including phase/photometry helpers and visual-binary position solving -- Apparent/mean solar time, solar hour angle, mean-time/zone-time hour-angle helpers, planar-sundial geometry, and equatorial/horizontal/vertical dial helpers +- 📅 Calendar conversion between Gregorian dates and the traditional Chinese lunisolar calendar, from 721 BCE through 3000 CE, including solar terms +- 🌞 Solar position, rise/set, Earth distance, apparent solar time, apparent altitude, parallactic angle, solar `P/B0/L0`, apparent diameter +- 🌙 Lunar position, rise/set, Earth distance, phase, new/full/quarter times, apparent diameter, bright-limb angle, parallactic angle, geocentric/topocentric libration, apsides, nodes, maximum declination +- 🪶 `lite/sun` and `lite/moon` lightweight approximation chains for watches, frontends, mini programs, and other resource-constrained environments, covering sky position, rise/set, and phase +- 🌗 Global and local solar/lunar eclipses, solar central paths, partial footprints, visible local lunar eclipses, Saros metadata, local diagrams, and global visibility-map SVGs +- 🌘 Point-source stellar and finite-disk planetary lunar occultations, with fixed-site contacts, global paths, geometric greatest points, and SVG output +- 🗺️ GeoJSON for solar eclipses, lunar eclipses, and lunar occultations, including timed paths and optional time-marker points; border-free global SVG maps support equirectangular and polar projections +- 🪐 Seven major planets with positions, rise/set, conjunction/opposition/station events, quadratures, elongations, Mercury/Venus geocentric transits, nodes, phase, apparent magnitude, apparent diameter, parallactic angle, and physical ephemerides +- ⭐ A built-in catalog of 9100 stars plus constellation lookup, proper-motion propagation, rise/set, parallactic angle, and apparent altitude +- 🧭 Coordinate transforms, topocentric coordinates, sidereal time, precession, nutation, angular distance, refraction, airmass, parallactic angle, and Galactic coordinates +- 🔭 Standalone formulas for blackbody radiation, synodic periods, photometry, telescope limiting magnitude, stellar radius/temperature/luminosity relations, and airmass models +- ☄️ Generic heliocentric two-body orbit propagation for asteroids, comets, and hypothetical objects, including phase/photometry helpers and visual-binary position solving +- 🕰️ Apparent/mean solar time, solar hour angle, mean-time/zone-time hour-angle helpers, planar-sundial geometry, and equatorial/horizontal/vertical dial helpers ## Package Overview @@ -70,7 +68,7 @@ go get b612.me/astro | `lite/sun` / `lite/moon` | Lightweight Sun/Moon approximation chains for minute-level rise/set, lightweight sky position, and lunar-phase work | | `eclipse` / `eclipse/svg` | Global/local solar and lunar eclipses, solar central paths, partial footprints, local visibility filtering, Saros metadata, local diagrams, and global visibility-map SVGs | | `moon/svg` | Fixed-site stellar/planetary lunar-occultation disk charts and projected global maps with bands, center lines, and time labels | -| `geojson` | RFC 7946 encoding for existing solar-eclipse, lunar-eclipse, and lunar-occultation geographic results; projection and styling remain application concerns | +| `geojson` | RFC 7946 encoding for existing solar-eclipse, lunar-eclipse, and lunar-occultation geographic results | | `mercury` / `venus` | Positions, rise/set, conjunctions, stations, elongations, geocentric transits, phase, parallactic angle, magnitude, diameter, nodes, physical ephemerides | | `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | Positions, rise/set, conjunction/opposition, stations, quadratures, phase, parallactic angle, magnitude, diameter, nodes, physical ephemerides | | `earth` | Earth orbital eccentricity, perihelion, aphelion | @@ -79,23 +77,18 @@ go get b612.me/astro | `orbit` | Generic heliocentric conic propagation for elliptical, near-parabolic, parabolic, and hyperbolic orbits, plus phase/photometry helpers and a lightweight visual-binary solver | | `sundial` | Apparent/mean solar time, solar hour angle, mean-time/zone-time hour-angle helpers, planar geometry, time-line and declination-curve sampling, equatorial/horizontal/vertical dial helpers | -Many position APIs also provide `...N` variants: +Some entry points also provide `...N` truncated variants: - `n < 0`: use all built-in terms embedded in this repository - `n >= 0`: truncate the series, useful for performance comparisons, rough estimates, or algorithm studies "All built-in terms" means the table entries shipped inside this package. It does not mean the complete original external VSOP/ELP long tables. -Airmass API distinction: - -- `coord.Airmass...` is the observing-oriented layer. It can start from apparent altitude directly, or first estimate refraction from true altitude and then compute airmass. -- `formula.Airmass...` is the raw formula layer. It does not apply refraction. - ## Scope And Accuracy ### Sun and planets -The Sun and planets use built-in VSOP87-style analytical terms. The current embedded tables cover roughly 4000 years around J2000. +The Sun and planets use built-in VSOP87 analytical terms. The current table entries cover roughly 4000 years around J2000. The table below lists truncation errors relative to the complete VSOP87 tables: | Target | Longitude / latitude | Distance | | --- | --- | --- | @@ -107,13 +100,13 @@ The Sun and planets use built-in VSOP87-style analytical terms. The current embe | Uranus | about `1"` | about `20 x 10^-6 AU` | | Neptune | about `1"` | about `40 x 10^-6 AU` | -This is suitable for ordinary calendrical work, observing support, outreach, and personal research. For spacecraft navigation, occultation prediction, or strict dynamical integration, use JPL DE or another professional ephemeris. +This is suitable for ordinary calendrical work, observing support, outreach, and personal research; spacecraft navigation, precise occultation prediction, and strict dynamical integration fall outside that range and usually need a professional ephemeris such as JPL DE. ### Moon -The Moon uses a built-in truncated ELP/MPP02 DE405-style analytical series retaining the major periodic terms. The package stays lightweight and does not require external ephemeris files. +The Moon uses a built-in truncated ELP/MPP02 DE405-fitted analytical series retaining the major periodic terms. The package stays lightweight and does not require external ephemeris files. -It is suitable for Chinese-calendar new moons, lunar phases, rise/set, lunar eclipses, amateur occultation prediction, and ordinary positional work. For lunar laser ranging, long-term physical libration, or professional occultation work, use JPL or a dedicated lunar ephemeris. +It is suitable for Chinese-calendar new moons, lunar phases, rise/set, lunar eclipses, amateur occultation prediction, and ordinary positional work; extremely high-precision lunar laser ranging, long-term physical libration, and professional occultation work fall outside that range and are best served by JPL or a dedicated lunar ephemeris. The four principal phases keep the historical pinyin names and also expose English aliases: @@ -131,14 +124,14 @@ The matching `Next*`, `Last*`, and `Closest*` helpers are available in both nami - `lite/sun`: simplified true/apparent solar longitude formulas plus lightweight equatorial conversion - `lite/moon`: Schlyter-style lunar approximation with about 15 perturbation terms plus lightweight topocentric correction - rise/set search: fixed-step scanning plus bisection, without the high-precision nutation iteration used by the main chain -- zero heap allocation in the computation path; lunar position is about 1 microsecond and 20-60x faster than the main chain +- zero heap allocation in the computation path (0 allocs/op); against the main chain, pure evaluation entry points such as position and phase run about `8.3-27.3x` faster, and rise/set entry points about `1.0-3.7x` | Package | Position model | Rise/set search | Main use | | --- | --- | --- | --- | | `lite/sun` | simplified true/apparent solar longitude plus lightweight equatorial conversion | `30 min` scan plus bisection | sunrise/sunset, solar altitude, watch faces, frontend refresh loops | | `lite/moon` | Schlyter / vFPS lunar approximation plus lightweight topocentric correction | `15 min` scan plus bisection | moonrise/moonset, lunar phase, lunar age, lightweight lunar observing helpers | -Error against the main `sun` / `moon` chains (year 2026, 8 observing sites; rise/set sampled every 7 or 15 days, phase/age/position every 6 hours): +Error against the `sun` / `moon` packages (year 2026, 8 observing sites; rise/set sampled every 7 or 15 days, phase/age every 6 hours): | Capability | Mean absolute error | P95 | Max absolute error | Notes | | --- | --- | --- | --- | --- | @@ -151,41 +144,37 @@ Error against the main `sun` / `moon` chains (year 2026, 8 observing sites; rise | `lite/moon` geocentric longitude | `2.41'` | `6.82'` | `9.91'` | relative to the main lunar chain | | `lite/moon` geocentric latitude | `0.87'` | `1.83'` | `2.92'` | relative to the main lunar chain | -Local `Go testing.Benchmark` reference values (absolute timings vary by machine, relative pattern is stable): +`Go testing.Benchmark` reference values (single-machine measurements, for reference only; absolute values vary with hardware): + +The measurements use 2026-01-01 20:00 CST, Shanghai (`121.4737°E, 31.2304°N`), `height=0`, `aero=true`, and take the median of 3 runs; each benchmark is warmed up once, so lazy-loading caches and first-call allocations stay out of the steady-state per-call cost. | Entry point | Main chain | `lite` | Speedup | Main-chain allocation | `lite` allocation | | --- | --- | --- | --- | --- | --- | -| `Sun ApparentRaDec` | `13.392 µs/op` | `231.0 ns/op` | `57.97x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Sun Altitude` | `16.405 µs/op` | `681.5 ns/op` | `24.09x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Sun RiseTime` | `202.994 µs/op` | `18.823 µs/op` | `10.78x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon ApparentRaDec` | `65.273 µs/op` | `1.035 µs/op` | `63.06x` | `297202 B/op, 70 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon Phase` | `40.264 µs/op` | `940.6 ns/op` | `42.83x` | `178321 B/op, 42 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon Altitude` | `44.883 µs/op` | `2.275 µs/op` | `19.73x` | `178321 B/op, 42 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon RiseTime` | `659.886 µs/op` | `77.600 µs/op` | `8.50x` | `2377613 B/op, 560 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun ApparentRaDec` | `5.888 µs/op` | `215.6 ns/op` | `27.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun Altitude` | `5.955 µs/op` | `625.9 ns/op` | `9.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun RiseTime` | `95.847 µs/op` | `25.648 µs/op` | `3.7x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon ApparentRaDec` | `16.520 µs/op` | `1.006 µs/op` | `16.4x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon Phase` | `15.139 µs/op` | `917.7 ns/op` | `16.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon Altitude` | `9.533 µs/op` | `1.150 µs/op` | `8.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon RiseTime` | `120.545 µs/op` | `118.037 µs/op` | `1.0x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | + +The main-chain/`lite` gap depends on the scenario: pure evaluation entry points (position, phase) run about `8.3-27.3x` faster in `lite`, while rise/set entry points narrow to `1.0-3.7x` because both sides perform a time search (`Moon RiseTime` is nearly level). The speedup ratios come from a same-machine comparison and are affected by hardware less than the absolute values are. Use the main `sun` / `moon` chains for eclipses, physical libration, or high-latitude edge cases. -### Regression references +### Accuracy references -The following areas have been checked against JPL Horizons, NASA GSFC, IMCCE, and other public references: +The following entry points have been checked against JPL Horizons, NASA GSFC, IMCCE, and other public references; use them to judge the order of magnitude to expect: -- apparent diameters of the Sun, planets, and Moon -- solar physical ephemerides `P/B0/L0` -- planetary rise, transit, and set events +- apparent diameters of the Sun, planets, and Moon: maximum differences from the external baseline range from `0.000002"` to `0.194598"` depending on the body; the Moon is the most sensitive because of parallax and distance changes +- solar physical ephemerides `P/B0/L0`: maximum differences are about `0.003349° / 0.003986° / 0.047394°` +- planetary rise, transit, and set: checked against JPL Horizons Time-Varying Hourly (TVH) events; that baseline is generated at a 1-minute step, and current results align with the Horizons event times at the minute level - Moon rise/set: `aero=true` uses dynamic standard refraction and the instantaneous lunar semidiameter for an upper-limb crossing. Across 14 sea-level events at 7 sites, the current mean/maximum differences against JPL Horizons DE441 are about `0.30s / 0.75s`. - Moon rise/set with other conventions: mean/maximum differences are about `38.77s / 76.22s` against MET Norway's fixed `-0.8333°` convention. Against IMCCE Miriade, whose horizon convention is not exposed, the mean is about `2m13.46s`; the grazing `61°N` sample reaches about `6m41.82s`. -- Global stellar-occultation path: for the `2025-06-05` occultation of HR 4799, start/greatest/end differences from the project's recorded reference are about `+2.30s / -1.37s / -3.12s`. -- HR 4799 path geometry: greatest-point longitude/latitude differ by about `+0.0122° / +0.0105°`. The computed `3582.4 km` band width differs from the `3571.9 km` reference by about `10.5 km`. -- Earth perihelion and aphelion -- main-chain lunar position: the current algorithm is a truncated ELP/MPP02 DE405-style analytical series; across four JPL/Horizons `JDTT` samples in year `-2000`, the maximum difference from JPL/Horizons is about `219.6"` in longitude, `25.8"` in latitude, and `34.3 km` in distance -- Moon perigee and apogee -- maximum lunar declinations -- solar and lunar eclipses -- Galilean satellite events - -Impact boundary of the topocentric hour-angle fix: Moon rise/set, fixed-site lunar occultations, topocentric lunar coordinates, and the greatest-event Moon altitude/visibility fields of local lunar eclipses use the corrected UT hour-angle chain. - -Global solar/lunar eclipse contacts, greatest times, and magnitudes use their own geocentric or Besselian geometry. Their existing NASA accuracy figures therefore do not change because of this fix. +- Earth perihelion and aphelion: maximum time difference about `1m28.84s`, maximum distance difference about `0.000000039837 AU` +- main-chain lunar position: the current algorithm is a truncated ELP/MPP02 DE405-fitted analytical series; across four JPL/Horizons `JDTT` samples in year `-2000`, the maximum difference from JPL/Horizons is about `219.6"` in longitude, `25.8"` in latitude, and `34.3 km` in distance +- Moon perigee and apogee: maximum time difference about `15m53.45s`, maximum distance difference about `39.758 km` +- maximum lunar declination: maximum time difference about `2.43s`, maximum declination difference about `0.00006431°` The README examples are illustrative. The repository tests contain the exact baselines. @@ -193,7 +182,7 @@ The README examples are illustrative. The repository tests contain the exact bas ### Calendar And Solar Terms -The `calendar` package converts between Gregorian dates and the traditional Chinese lunisolar calendar, and exposes solar terms. The supported range is from 721 BCE through year 3000, with some modern algorithm paths usable beyond that. The calendar is lunisolar in the strict sense, but public function names use `Lunar` for readability and convention. +The `calendar` package converts between Gregorian dates and the traditional Chinese lunisolar calendar, and exposes solar terms. The supported range is from 721 BCE through 3000 CE; 104 BCE is the calendar-table switch point. The calendar is lunisolar in the strict sense, but public function names use `Lunar` for readability and convention. For historical input, Chinese era names stay in Chinese. This is part of the API surface, because historical Chinese dates are normally written that way. @@ -206,28 +195,138 @@ For historical input, Chinese era names stay in Chinese. This is part of the API #### Usage notes -1. A single Gregorian date may map to multiple lunisolar dates in periods with parallel regimes and calendars, such as the Three Kingdoms period. -2. A single lunisolar date may map to multiple Gregorian dates when a regime changed calendar rules. For example, during Wu Zetian's calendar reform, year 3 of Shengli had two twelfth months. -3. Gregorian handling is based on Julian Day: +##### 1. One Gregorian date may map to several lunisolar dates +In periods when several regimes coexisted with different calendars (the Three Kingdoms, for example), one Gregorian date can map to several lunisolar dates. The package returns every conversion it can determine. + +##### 2. One lunisolar date may map to several Gregorian dates +Rival calendars are not the only cause: one regime can produce the same effect during a calendar reform. After Wu Zetian's reform, for example, the third year of Shengli had two twelfth months. + +##### 3. Gregorian calendar rules +All computation goes through Julian Days. The Gregorian side follows these rules: - after `1582-10-15`: Gregorian calendar - before `1582-10-04`: Julian calendar - before year 8 CE: proleptic Julian calendar - the day after `1582-10-04` is `1582-10-15` -- dates from `1582-10-05` through `1582-10-14` are invalid and rejected -- year `0` means 1 BCE, year `-1` means 2 BCE, and so on +- `1582-10-05` through `1582-10-14` do not exist and the corresponding entry points reject them +- year numbering: year `0` is 1 BCE, year `-1` is 2 BCE, and so on -Time zone note: standard wrappers such as `SolarToLunar` and `LunarToSolar` use Beijing time. Lower-level `Solar` and `Lunar` allow custom time zones for research under the modern Chinese-calendar algorithm. +##### 4. Time zone +The package targets the Chinese calendar, so solar terms and new moons are computed in Beijing time (UTC+8) by default. Applying Chinese lunisolar rules in another time zone can shift dates. -Go-specific note: before `1582-10-15`, Go's `time.Time` uses the proleptic Gregorian calendar, so `time.Time.Weekday()` does not match this library's Julian/Gregorian handling. To get the weekday used here: +For exploration and research, the lower-level `Solar` and `Lunar` methods convert between the Gregorian and lunisolar calendars under a **custom time zone**, following the **current Chinese calendar algorithm (GB/T 33661-2017)**. +For standard conversions in Beijing time, use the wrapped `SolarToLunar` and `LunarToSolar` methods. + +**Example**: the calendar rules require the winter solstice to fall in the eleventh lunisolar month. For the 1984 winter solstice: +```go +ws := calendar.JieQi(1984, 270) +fmt.Println(ws) +fmt.Println(moon.ClosestShuoYue(ws)) +``` + +| Event | UTC+8 | UTC+7 | +| --- | --- | --- | +| Winter solstice | 1984-12-22 | 1984-12-21 | +| New moon | 1984-12-22 | 1984-12-22 | + +In UTC+8 (China), 1984-12-22 is both the winter solstice and the new moon, so it is the first day of the eleventh lunisolar month; in UTC+7 the solstice moves up to 12-21, which makes 12-22 the first day of the twelfth month. + +Chinese New Year shifts the same way. The Gregorian date of the first day of the first lunisolar month of 1985 is February 20 in UTC+8 and January 21 in UTC+7: +```go +fmt.Println(calendar.Solar(1985, 1, 1, false, 8.0)) +fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0)) +``` + +##### 5. Go-specific note + +⚠️ Go's standard-library `time.Time` differs from this package in calendar handling: + +- Before 1582-10-15 Go uses the proleptic Gregorian calendar rather than the Julian calendar. Without `Add`, it generally works as expected. +- So **before 1582-10-15, `time.Time.Weekday()` does not agree with this package**: + for 1582-10-04 this package reports Thursday, Go reports Monday. + +#### Suggested solutions +To obtain the weekday this package uses: ```go // date should be the local midnight of the target day. weekday := int(calendar.Date2JDE(date)+1.5) % 7 // 0 means Sunday, 1 means Monday, ..., 6 means Saturday. ``` +Using `Add` or `AddDate` on a `time.Time` before 1582 runs through the proleptic Gregorian calendar and lands one day away from the Julian calendar whenever it crosses a leap day that only the Julian calendar has. +For example, 700 is a leap year in the Julian calendar but not in Go's proleptic Gregorian calendar. + +##### 6. Julian-only leap days (for example 700-02-29) +Before 1582, years divisible by 100 but not 400 (such as 100, 700 or 1500) have a 29 February in the Julian calendar, +while Go's `time.Time` uses the proleptic Gregorian calendar and has no such day (`time.Date(700, 2, 29, ...)` normalises to 700-03-01). The library accepts 700-02-29 as an existing day, with these constraints: + +- `Time.Solar()` / `LunarTime.SolarDate`: the library's canonical label, always the **following day** + (700-03-01 for 700-02-29), matching `basic.JDE2DateByZone`; +- `Time.JulianOnly()`: whether the lunar date exists only in the Julian calendar (JSON field `julianOnly`); +- `Time.JDE()`: the exact Julian day; for a Julian-only leap day it is one day earlier than `Solar()`, + otherwise the two agree (JSON field `jde`). + +```go +julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) +fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JDE(), julian.Lunar().MonthDay()) +// 0700-03-01 true 1.9767915e+06 二月初五 +``` + +> Two kinds of entry point keep this leap day: the integer year/month/day entry points `SolarToLunarByYMD` / `LunarToSolarByYMD`, and a direct +> `basic.JDECalc(700, 2, 29)` call returning the exact Julian day `1976791.5`; building a `time.Time` first is the step that loses it. + +##### 7. Several Gregorian candidates for one lunar date + +The dual-numbering reforms (Wang Mang 9-23 CE, Emperor Ming of Wei 237-240 CE, Wu Zetian 689-700 CE, Emperor Suzong of Tang 761-762 CE) +and the Taichu calendar handoff (104 BCE) give one lunar date two legal Gregorian days. `Solar()` remains the library default, and +`SolarCandidates()` returns every candidate with the first element always equal to `Solar()`: + +```go +res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) +fmt.Println(res.Solar().Format("2006-01-02")) +fmt.Println(len(res.SolarCandidates())) +// 0700-12-15 +// 2 +``` + +Outside the reform windows there is a single candidate and `SolarCandidates()` returns a one-element +slice; a Julian-only leap day also reports only its canonical label, because its single legal day +cannot be expressed as a `time.Time` - use `JDE()` for the exact date. #### Calendar conversion +##### Gregorian to lunar + +- **Input**: a Gregorian date, as `time.Time` +- **Output**: a `calendar.Time` object, holding one or more matching lunisolar dates +- **The result carries**: + - a full lunisolar date description + - sexagenary (ganzhi) year, month, and day + - dynasty, emperor, and era name + - the complete structured lunisolar record + +##### Lunar to Gregorian + +Two calling styles: + +###### Style 1: pass a lunisolar string +Formats: +1. `era name + year + month + day`, for example **`"元丰六年十月十二"`** (prefix a leap month with `闰`; day names read `初一`, `二十`, and so on) +2. `era name + year + month + ganzhi day`, for example **`"元嘉二十七年七月庚午"`** +3. `year + month + day`, for example **`"二零二五年正月初一"`** (prefix a leap month with `闰`; suits modern dates) +4. `year + month + ganzhi day`, for example **`"二零二五年正月戊戌日"`** +5. `Arabic digits + month + day`: Chinese numerals may be written as Arabic digits, for example **`"2025年1月1日"`**, which stands for `二零二五年正月初一` +6. Historical cases: month names could differ from modern usage (under Wu Zetian, `正月` and `一月` denoted different months); such month names are read as Chinese numerals + +> ⚠️ A lunisolar year and a Gregorian year do not line up exactly. 2025-01-28, Chinese New Year's Eve, is the 29th day of the 12th lunisolar month of 2024, so its string is `"二零二四年腊月廿九"`. + +###### Style 2: pass numeric fields +- **Parameters**: year (`int`), month (`int`), day (`int`), leap-month flag (`bool`) +- **Semantics**: locate the date by lunisolar year, month, day, and the leap-month flag; for modern conversions + +One lunisolar day can have several legal Gregorian candidates: `Solar()` takes the first, `SolarCandidates()` returns all of them (see note 7 above). + +##### Code example + ```go package main @@ -281,6 +380,9 @@ Output: "ganzhiYear": "己未", "ganzhiMonth": "丙子", "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jde": 1808717.8389814815, "dynasty": "魏", "emperor": "魏明帝", "nianhao": "景初", @@ -300,7 +402,10 @@ Output: "ganzhiYear": "己未", "ganzhiMonth": "丙子", "ganzhiDay": "辛未", - "dynasty": "", + "calendarSystem": "", + "calendarName": "", + "jde": 1808717.8389814815, + "dynasty": "蜀", "emperor": "蜀后主", "nianhao": "延熙", "yearOfNianhao": 2, @@ -319,6 +424,9 @@ Output: "ganzhiYear": "己未", "ganzhiMonth": "丙子", "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jde": 1808717.8389814815, "dynasty": "吴", "emperor": "吴大帝", "nianhao": "赤乌", @@ -390,11 +498,15 @@ Output: - `Altitude`: altitude angle; horizon is `0°`, zenith is `+90°` - `Zenith`: zenith distance; zenith is `0°`, horizon is `90°` -- In older versions, `Zenith` incorrectly returned altitude. Current versions return zenith distance. +- `Zenith` and `Altitude` are complements; the two add up to `90°` #### Sunrise/sunset and moonrise/moonset -Moon rise/set times are date-based. Rise and set events on the same civil day are not guaranteed to form one continuous observing cycle. For example, the Moon may set at 01:00 and rise again at noon; in that case, the evening moonset belongs to the following date's query. +> ⚠️ Moon rise/set times are computed for the queried civil date, so the rise and set instants need not be continuous. +> +> For example, the Moon may set at 01:00 and rise again at noon, in which case the rise time is later than the set time; the evening moonset in that scenario corresponds to the next day's date. +> +> The full rise/set cycle follows from the order of the two instants: check whether the rise time falls after the set time to pick the correct subsequent instants. ```go package main @@ -443,8 +555,8 @@ Output: 2020-01-01 17:45:09.188657999 +0800 CST // sunset 2020-01-01 18:12:33.624035418 +0800 CST // civil evening twilight ends 2020-01-01 11:52:49.860912859 +0800 CST // moonrise -2020-01-01 17:38:02.510787248 +0800 CST // lunar upper culmination -2020-01-01 23:26:49.313593804 +0800 CST // moonset +2020-01-01 17:36:48.811488747 +0800 CST // lunar upper culmination +2020-01-01 23:26:49.313553571 +0800 CST // moonset ``` #### Sun and Moon position @@ -504,9 +616,9 @@ RA: 18h43m34.82s Dec: -23°3′30.27″ // apparent RA and Dec of the Sun Sagittarius // English constellation containing the Sun Azimuth: 120.19477090015224 Altitude: 2.4014437419430097 Zenith: 87.59855625805699 // solar horizontal coordinates at Xi'an 0.983292937163176 // Sun-Earth distance, AU -RA: 23h17m53.15s Dec: -10°19′18.57″ // topocentric apparent RA and Dec of the Moon +RA: 23h18m56.24s Dec: -10°20′54.42″ // topocentric apparent RA and Dec of the Moon Aquarius // English constellation containing the Moon -Azimuth: 67.84050700509859 Altitude: -45.13425530765482 Zenith: 135.13425530765483 // lunar horizontal coordinates at Xi'an +Azimuth: 67.63889332004852 Altitude: -45.34916937173283 Zenith: 135.34916937173284 // lunar horizontal coordinates at Xi'an 404238.6096080479 // Earth-Moon distance, km ``` @@ -582,8 +694,8 @@ north=2026-01-02T08:10:49Z dec=28.266373 // maximum northern lunar declination south=2026-01-16T05:15:14Z dec=-28.304184 // maximum southern lunar declination libration lon=-1.278902 lat=-6.531444 pa=-9.967050 // geocentric libration longitude, latitude, and axis position angle bright limb=267.364849 // geocentric bright-limb position angle -topo libration lon=-2.010562 lat=-5.912181 pa=-10.184664 // topocentric libration and axis position angle from Shanghai -topo bright limb=266.045494 // topocentric bright-limb position angle from Shanghai +topo libration lon=-1.736754 lat=-5.780730 pa=-10.072846 // topocentric libration and axis position angle from Shanghai +topo bright limb=266.038258 // topocentric bright-limb position angle from Shanghai ``` Earth orbital eccentricity at an instant: @@ -608,7 +720,7 @@ Here: Output: ```text -340.9570862454423 160.95708624544227 // lunar ascending-node and descending-node longitudes, degrees +340.95708624505863 160.9570862450587 // lunar ascending-node and descending-node longitudes, degrees ``` #### Lunar phases @@ -645,7 +757,7 @@ func main() { Output: ```text -0.300041309608744 // about 30% of the lunar disk is illuminated +0.30004130960877884 // about 30% of the lunar disk is illuminated 上峨眉月 // Chinese phase description 2020-01-25 05:41:58.271192908 +0800 CST // next new moon 2020-01-03 12:45:23.229190707 +0800 CST // next first quarter @@ -700,7 +812,7 @@ Exported functions: - `lite/sun`: `TrueLo`, `ApparentLo`, `Distance`, `TrueRaDec`, `ApparentRaDec`, `HourAngle`, `Azimuth`, `Altitude`, `Zenith`, `RiseTime`, `SetTime` - `lite/moon`: `TrueLo`, `TrueBo`, `TrueRaDec`, `ApparentRaDec`, `HourAngle`, `Azimuth`, `Altitude`, `Zenith`, `SunMoonLoDiff`, `Phase`, `PhaseAge`, `RiseTime`, `SetTime` -### Solar Eclipse +#### Solar eclipse Solar-eclipse calculation lives in `eclipse`; SVG generation lives in `eclipse/svg`. The default lunar-radius convention follows NASA bulletin split-`k`. IAU single-`k` variants are available through same-named `...IAUSingleK` functions. @@ -718,23 +830,30 @@ Common entry points: `SolarEclipsePartialFootprintsInfo` also reports global shadow contacts. `P1/P4` are the external penumbral contacts and `P2/P3` are the internal penumbral contacts; `U1/U4` are the external umbral or antumbral contacts and `U2/U3` are the internal contacts. Contacts that do not occur remain zero `time.Time` values. `CentralBeginOnEarth` / `CentralEndOnEarth` retain their existing meaning of the shadow axis entering and leaving Earth; they are not aliases for `U1/U4`. -Set `CentralShadowStep` in `SolarEclipsePartialFootprintOptions` when structured instantaneous central-shadow outlines are needed; samples are returned in `CentralShadowFootprints`. Zero disables this extra calculation in the data API, while the SVG entry point samples it every 10 minutes by default. +Set `CentralShadowStep` in `SolarEclipsePartialFootprintOptions` when structured instantaneous central-shadow outlines are needed; samples are returned in `CentralShadowFootprints`. Zero disables this extra calculation in the data API; the SVG entry points follow the same rule and sample only for positive values (rounded up to one minute). + +The same options struct takes `GreatestTimeValues` or `GreatestTimeStep` when the isochrones are wanted straight from the data layer. `GreatestTimeValues []time.Time` holds the **greatest-eclipse time levels** as absolute instants (their `Location` does not affect the computation); at most 64 are kept, duplicates and levels outside the partial-eclipse window are skipped, the rest are sorted, and anything beyond the earliest 64 is dropped; a level with no usable branch produces no entry. When it is empty, `GreatestTimeStep` generates the levels instead; only a positive step applies, and the grid aligns to UTC ticks. A display-timezone grid has to be generated by the caller and passed through `GreatestTimeValues`. + +Contours come back in `SolarEclipsePartialFootprintsInfo.GreatestTimeContours`. `JDE` is the matching TT Julian ephemeris day, `Time` is that level in the input timezone (an explicit level is echoed back unchanged, a step-derived one is converted from `JDE` and rounded to the millisecond so the round trip cannot truncate a whole minute into the previous one), and `Segments` are the isochrone branches at that instant. Isochrones exist only where the solar and lunar disks actually overlap and the Sun is above the geometric horizon (no refraction or semidiameter correction), each branch ends at the horizon or the partial-visibility boundary, nothing is continued beyond ±88° latitude, and one instant may carry several disconnected branches. When they are not requested, existing output is unchanged. `SolarEclipseInfo`, `LocalSolarEclipseInfo`, and the embedded `Eclipse` field in `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` include Saros metadata: - `HasSaros`: whether a Saros series was matched -- `Saros.Series`: NASA Saros series number +- `Saros.Series`: NASA Saros series number when `Verified=true`, otherwise a provisional derived series number - `Saros.Member`: 1-based member number within that series - `Saros.Count`: total member count of that series +- `Saros.Verified`: whether the result matches an embedded authoritative catalog anchor; extension-table and extrapolated results are `false` Saros note: -- One Saros is about `6585.321` days, or `223` synodic months, commonly described as about `18 years 11 days 8 hours`. +- One Saros is about `6585.321` days, that is `223` synodic months or about `18 years 11 days 8 hours`; series members are ordered by this period. - A Saros series is a sequence of eclipses separated by one Saros period. `Series` identifies the sequence, while `Member` / `Count` describe the event's position in it. - Saros metadata belongs to the eclipse event, not to the observing site. Global, local, path, and footprint results for the same eclipse should report the same Saros. +- Embedded NASA anchors take precedence. Unmatched events in astronomical years `-3000` through `+6000` use a precomputed extension table, including both end years; year `0` is 1 BCE. Only events outside that interval use live extrapolation. Precomputed and live results have `Verified=false` and are not official NASA assignments. +- Extended numbers follow NASA's [Saros/Inex numbering relations](https://eclipse.gsfc.nasa.gov/SEsaros/SEperiodicity.html). Members are computed with the Split-K model across the complete series, without clipping at the precomputed year limits. The computed result for `3288-11-15` is series `202`, member `1/71`. - For example, the `2024-04-08` North American total solar eclipse is member `30/71` of Solar Saros `139`. -#### Timing checks against NASA material +##### Timing checks against NASA material Solar-eclipse timing is checked in two forms: @@ -747,11 +866,11 @@ Solar-eclipse timing is checked in two forms: | Local solar eclipse | 3 observing sites | greatest eclipse, first contact, last contact | NASA local-circumstance public values are often rounded to whole minutes; current results match those rounded minute values | | Local central eclipse | 2 central-eclipse points | totality/annularity duration | second-level agreement, current samples are within a `5 s` threshold | -Global eclipse references often publish seconds, so second-level checks are meaningful there. Many local-circumstance pages publish contact times only to whole minutes, so minute-level agreement is the correct interpretation for those fields. +Global eclipse references often publish seconds, so second-level checks are meaningful there. Many local-circumstance pages publish contact times only to whole minutes, so minute-level agreement is the correct interpretation for those fields. The 2009 Yangshan and 2012 Xiamen examples below only demonstrate API calls and SVG output and claim no publication-grade accuracy for local contact times; check them item by item against NASA/IMCCE local circumstances when that matters. -#### 2009 Yangtze River total eclipse near Yangshan +##### 2009 Yangtze River total eclipse near Yangshan -`2009-07-22` is the well-known Yangtze River total eclipse. The example below uses a site near Yangshan at the Yangtze River estuary southeast of Shanghai, close to the center line. At greatest eclipse the Sun and Moon centers are very close, and totality lasts about 5 minutes 57 seconds. +`2009-07-22` is the Great Yangtze Eclipse. The example below uses a site near Yangshan at the Yangtze River estuary southeast of Shanghai, close to the center line; totality lasts about 5 minutes 57 seconds. ```go package main @@ -797,18 +916,18 @@ Output: ```text true total // Yangshan site has a local total solar eclipse -true {136 37 71} // Solar Saros 136, member 37/71 -2009-07-22 08:23:54.85276848 +0800 CST // first contact -2009-07-22 09:37:22.978325486 +0800 CST // totality begins -2009-07-22 09:40:20.771768689 +0800 CST // greatest eclipse -2009-07-22 09:43:19.611152708 +0800 CST // totality ends -2009-07-22 11:03:13.974365293 +0800 CST // last contact -5m56.632827222s // totality duration +true {136 37 71 true} // Solar Saros 136, member 37/71, verified +2009-07-22 08:23:54.852366149 +0800 CST // first contact +2009-07-22 09:37:22.978486418 +0800 CST // totality begins +2009-07-22 09:40:20.771366357 +0800 CST // greatest eclipse +2009-07-22 09:43:19.610750377 +0800 CST // totality ends +2009-07-22 11:03:13.974526226 +0800 CST // last contact +5m56.632263959s // totality duration magnitude=1.076997 obscuration=1.000000 altitude=57.292 // magnitude, obscuration, solar altitude at greatest eclipse -greatest lon=144.1177 lat=24.2193 width=258.3km center=268 // global greatest point, path width, center-line sample count +greatest lon=144.1177 lat=24.2193 width=258.3km center=289 // global greatest point, path width, center-line sample count ``` -#### 2012 Xiamen annular eclipse +##### 2012 Xiamen annular eclipse The `2012-05-21` annular eclipse was visible from the southeast coast of China. The Xiamen example has the Sun about 9.6 degrees above the horizon at greatest eclipse, and annularity lasts about 4 minutes 19 seconds. @@ -843,17 +962,17 @@ Output: ```text true annular // Xiamen site has a local annular solar eclipse -true {128 58 73} // Solar Saros 128, member 58/73 -2012-05-21 05:08:12.683185637 +0800 CST // first contact -2012-05-21 06:08:15.570583641 +0800 CST // annularity begins -2012-05-21 06:10:25.164288282 +0800 CST // greatest eclipse -2012-05-21 06:12:34.763746261 +0800 CST // annularity ends -2012-05-21 07:20:55.029697716 +0800 CST // last contact -4m19.19316262s // annularity duration +true {128 58 73 true} // Solar Saros 128, member 58/73, verified +2012-05-21 05:08:12.683024704 +0800 CST // first contact +2012-05-21 06:08:15.570422708 +0800 CST // annularity begins +2012-05-21 06:10:25.156724452 +0800 CST // greatest eclipse +2012-05-21 06:12:34.764188826 +0800 CST // annularity ends +2012-05-21 07:20:55.029536783 +0800 CST // last contact +4m19.193766118s // annularity duration magnitude=0.933290 obscuration=0.872480 altitude=9.567 // magnitude, obscuration, solar altitude at greatest eclipse ``` -#### Solar-eclipse SVG +##### Solar-eclipse SVG The modern city example uses the `2035-09-02` total solar eclipse in Beijing. With approximate downtown coordinates (`116.4074E`, `39.9042N`), this event belongs to Solar Saros `145` as member `23/77`, and local totality lasts about `1m33s`. @@ -944,8 +1063,8 @@ Output: true 13433 // Yangshan total-eclipse SVG generated, 13433 bytes true 13362 // Xiamen annular-eclipse SVG generated, 13362 bytes true total // Beijing site has a local total solar eclipse -true {145 23 77} // Solar Saros 145, member 23/77 -1m33.329527975s // totality duration near downtown Beijing +true {145 23 77 true} // Solar Saros 145, member 23/77, verified +1m33.329527974s // totality duration near downtown Beijing true 13394 // Beijing total-eclipse SVG generated, 13394 bytes ``` @@ -957,13 +1076,13 @@ Rendered examples: ![2035 Beijing total solar eclipse](doc/solar-eclipse-beijing-2035-en.svg) -### Lunar Eclipse +#### Lunar eclipse Lunar-eclipse detection and search live in `eclipse`; returned times preserve the input `time.Time` location. Common entry points: -- `LunarEclipseOnDate`: detect whether a local date overlaps a global lunar eclipse +- `LunarEclipseOnDate`: detect whether a lunar eclipse occurs on a local date - `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`: search global lunar eclipses - `LocalLunarEclipseOnDate`: detect whether a visible lunar eclipse is visible from a site on a local date - `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`: search visible local lunar eclipses @@ -981,25 +1100,30 @@ Common entry points: `Saros` has the same meaning as in the solar-eclipse section: -- `Saros.Series`: NASA lunar Saros series number +- `Saros.Series`: NASA lunar Saros series number when `Verified=true`, otherwise a provisional derived series number - `Saros.Member`: 1-based member number within that series - `Saros.Count`: total member count of that series +- `Saros.Verified`: whether the result matches an embedded authoritative catalog anchor; extension-table and extrapolated results are `false` + +Lunar metadata also uses NASA anchors first, the extension table for astronomical years `-3000` through `+6000`, and live extrapolation outside that interval. Computed members include the union of events detected by Danjon and Chauvenet, so metadata is independent of the requested lunar model and observing site. Very shallow members may differ from the NASA catalog; `Verified` remains `false`. + +Run `go generate ./eclipse` from the repository root to regenerate both extension tables. The generator scans years `-5000` through `+8000` to include complete lifetimes of series at the range limits, checking numbering in both directions from NASA anchors. Ordinary queries and tests do not generate tables. For example, the cross-year total lunar eclipse on `2028-12-31 / 2029-01-01` is member `49/72` of Lunar Saros `125`. Two shadow-radius conventions are retained: -- **Danjon**, default and recommended: multiplies only the lunar horizontal-parallax term by `1.01`, then combines it with the solar semidiameter and solar parallax. NASA GSFC's current lunar-eclipse catalogs and diagram pages use this style. +- **Danjon** (default): multiplies only the lunar horizontal-parallax term by `1.01`, then combines it with the solar semidiameter and solar parallax. NASA GSFC's current lunar-eclipse catalogs and diagram pages use the same route, as do the library defaults `LunarEclipseOnDate`, `LastLunarEclipse`, `NextLunarEclipse`, and `ClosestLunarEclipse`. - **Chauvenet**, compatibility convention: starts with `0.99834 x Earth equatorial radius` and then multiplies the full shadow radii by `51/50`. This is closer to older traditional tables and is useful for compatibility checks. Differences: - `Chauvenet` gives larger penumbral and umbral shadows. Penumbral magnitude is usually about `0.025` larger, and umbral magnitude about `0.005` larger. - For edge cases, `Chauvenet` can push an eclipse toward a deeper type. -- Use the default `Danjon` APIs for NASA catalogs, modern ephemeris software, and current public eclipse material. -- Use explicit `Chauvenet` functions only for old-baseline compatibility. +- Against NASA catalogs, modern ephemeris software, or current mainstream lunar-eclipse material, the matching convention is the default `Danjon`. +- For compatibility with existing historical baselines, the matching convention is the explicitly-called `Chauvenet`. -#### Code example +##### Code example ```go package main @@ -1044,22 +1168,22 @@ Output: ```text total // eclipse type -true {125 49 72} // Lunar Saros 125, member 49/72 +true {125 49 72 true} // Lunar Saros 125, member 49/72, verified 2028-12-31 16:52:05.566135346 +0000 UTC // greatest eclipse -2.273989043382249 1.2461142882946992 // penumbral and umbral magnitudes +2.2739890433790566 1.2461142882915068 // penumbral and umbral magnitudes 2028-12-31 14:03:54.219463169 +0000 UTC // P1 2028-12-31 15:07:42.115980684 +0000 UTC // U1 2028-12-31 16:16:27.24464178 +0000 UTC // U2 2028-12-31 17:27:46.214954853 +0000 UTC // U3 2028-12-31 18:36:32.251235246 +0000 UTC // U4 2028-12-31 19:40:11.52023971 +0000 UTC // P4 -2.2996033397593934 1.2511710895700923 // Chauvenet penumbral and umbral magnitudes +2.2996033397562012 1.2511710895669002 // Chauvenet penumbral and umbral magnitudes true // local date overlaps an eclipse total // local eclipse type 2029-01-01 00:52:05.566135346 +0800 CST // greatest eclipse in UTC+8 ``` -#### Checks against NASA data +##### Checks against NASA data The following values are compared against NASA GSFC lunar-eclipse catalogs and single-eclipse diagram pages. Time errors are in seconds. @@ -1087,7 +1211,9 @@ For the `2026-03-03` total lunar eclipse, current default `Danjon` differences a For pure penumbral eclipses, NASA may publish negative `umbral magnitude`, meaning the Moon's disk center remains outside the umbral boundary by that amount. This library preserves that negative value, so pure penumbral cases are compared in the same convention. -#### Lunar-eclipse SVG +##### Lunar-eclipse SVG + +`LunarEclipseSVG`, `LunarEclipseDetailedSVG`, and `LunarEclipseMapSVG` share one default model: Danjon with a Chauvenet fallback for ultra-shallow penumbral cases, matching `LunarEclipseOnDate`; the `Danjon` / `Chauvenet` variants keep forcing their model. The default lunar-eclipse SVG header includes Saros metadata. `LunarEclipseSVGOptions` can override: @@ -1128,14 +1254,14 @@ func main() { Output: ```text -true 19666 // lunar-eclipse SVG generated, 19666 bytes +true 20274 // lunar-eclipse SVG generated, 20274 bytes ``` Rendered example: ![2029 cross-year total lunar eclipse shadow path](doc/lunar-eclipse-2029-01-01-en.svg) -#### References +##### References - NASA lunar eclipse decade catalog: - NASA 2026-03-03 total lunar eclipse diagram: @@ -1239,15 +1365,30 @@ Output: ```text HR 4799 total -2025-06-05 19:14:01.095 CST 2025-06-05 20:02:06.357 CST 2025-06-05 20:50:10.740 CST +2025-06-05 19:14:01.062 CST 2025-06-05 20:02:06.296 CST 2025-06-05 20:50:10.697 CST altitude=75.561 visible=true -2025-06-05 17:45:28.498 CST 2025-06-05 20:02:06.332 CST 2025-06-05 22:18:49.977 CST -greatest=121.566021 6.807046 width=3582.4km center=108 +2025-06-05 17:45:28.475 CST 2025-06-05 20:02:06.300 CST 2025-06-05 22:18:49.945 CST +greatest=121.566140 6.807079 width=3582.4km center=108 ``` Zero-valued `OccultationSearchOptions` select the default search step and safety margin; `MaxEvents > 0` limits output. -`OccultationPathOptions.Step` controls base time sampling, while `TargetSpacingKM` adaptively refines the center line. Requests exceeding the deterministic budget return `ErrOccultationPathSamplingLimit`. +`OccultationPathOptions.Step` controls base time sampling, while `TargetSpacingKM` adaptively refines the center line. Requests exceeding the deterministic budget return `ErrOccultationPathSamplingLimit`. `RiseSetStep` independently samples the six boundaries where local start, greatest, or end coincides with moonrise or moonset; zero uses five minutes, and `DisableRiseSet` omits them. `DisableFootprints` omits the much larger dense instantaneous visible-region features and merges sparse support samples into compact bands while retaining the center line, limits, and six rise/set phase curves, which is useful for ordinary GeoJSON maps. `IncludeFootprintTimeline` retains independently sampled instantaneous footprints alongside the compact band, using `FootprintTimelineStep` for time-axis selection of the currently visible region. + +`OccultationPathOptions.Algorithm` selects the stellar/planetary global-path ephemeris branch. Its zero value or `moon.OccultationPathAlgorithmOptimized` uses checked Cartesian interpolation at 30-minute nodes while retaining the station equations, continuous envelopes, and rise/set curves. `moon.OccultationPathAlgorithmExact` retains the original branch: interpolated candidates, with full-term ephemerides for final solving. Failed table checks fall back to the original branch; evaluations outside the interpolation window use exact ephemerides. Checks are sampled safeguards, not a rigorous error bound at every instant. The branches share the geometric definition, but sample points and GeoJSON bytes need not be identical. Event-only searches, fixed-site contacts, independent instant-footprint APIs, and eclipses are unaffected. + +Both branches retain full-term evaluation of global start/end/greatest markers and center-line widths. Render the complete returned path, including visibility contours; discarding those contours invokes the legacy sampled-footprint fallback, whose boundary is not interchangeable with the analytic visible set. + +In a returned path, `BandContours` are the static contact envelopes, `VisibilityContours` are the time envelope where the Moon is above the horizon, and `Footprints` are instantaneous samples for time-axis detail. They serve different geometry layers and should not be used as substitutes for one another. + +`OccultationPathOptions.GreatestTimeValues` / `GreatestTimeStep` request **greatest-occultation time isolines**. Unlike the solar case, `GreatestTimeValues []float64` carries TT Julian ephemeris days; at most 64 are kept — deduplicated, sorted, and cut to the earliest 64 — and a level outside the visibility window or without a usable branch produces no entry. When it is empty, `GreatestTimeStep` takes over, again only for a positive value, aligned to UTC ticks. Contours land in `StarOccultationPath.GreatestTimeContours` (the planetary path has the same field) as `OccultationGreatestTimeContour` values whose `JDE`, `Time`, and `Segments` mean the same as in the solar case: `Time` keeps the original aligned instant for step-derived levels, while an explicit level is converted from `JDE` and rounded to the millisecond; both carry the UTC zone, whereas branch point times use the path timezone (the solar public layer instead reports `Time` in the input timezone). The boundary rules match as well: curves exist only where the target disk truly overlaps the lunar disk and the Moon is above the geometric horizon (no refraction or semidiameter correction), each branch ends at the horizon or the occultation-visibility boundary, nothing is continued beyond ±88° latitude, one instant may carry several disconnected branches, and output is unchanged when they are not requested. + +```go +options := moon.OccultationPathOptions{ + Algorithm: moon.OccultationPathAlgorithmExact, // Original branch; omit for optimized. + DisableFootprints: true, +} +``` #### Planetary occultations @@ -1286,16 +1427,14 @@ Output: ```text Saturn total true -2025-02-01 11:29:09.692 CST -2025-02-01 11:29:40.042 CST -2025-02-01 12:00:48.729 CST -2025-02-01 12:32:46.388 CST -2025-02-01 12:33:18.285 CST +2025-02-01 11:29:09.710 CST +2025-02-01 11:29:40.069 CST +2025-02-01 12:00:48.747 CST +2025-02-01 12:32:46.415 CST +2025-02-01 12:33:18.312 CST ``` -Global `FindPlanetOccultationPaths` results contain the region where any part of the planetary disk overlaps the Moon and, when present, the narrower full-coverage region. - -`HasTotalBand` reports the latter, and `GreatestTotalWidthKM` is its width at greatest occultation. Center lines, limits, and instantaneous footprints all carry sample times. +Global `FindPlanetOccultationPaths` results contain both the region where any part of the planetary disk overlaps the Moon and the region where the whole planet is hidden. `HasTotalBand` reports whether a total band exists, and `GreatestTotalWidthKM` is its width at greatest occultation; center lines, limits, and enabled instantaneous footprints all carry sample times. #### Lunar-occultation SVG @@ -1314,37 +1453,19 @@ localSVGs, err := moonsvg.FindLocalStarOccultationSVGs( moonsvg.LocalStarOccultationSVGOptions{Width: 920, Height: 700, Location: cst}, ) fmt.Println(err, len(localSVGs)) - -globalSVGs, err := moonsvg.FindStarOccultationSVGs( - start, end, target, - moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, - moonsvg.StarOccultationSVGOptions{ - Width: 1200, Height: 800, Location: cst, - TimeLabelStep: 30 * time.Minute, - }, -) -fmt.Println(err, len(globalSVGs)) ``` -Local charts use the selected observer's topocentric geometry. Global maps use Natural Earth `1:50m` coastlines without administrative boundaries. - -Center-line labels default to 30-minute intervals, and high-latitude events can automatically switch to a polar projection. - -The following diagrams reuse the `2025-06-05` occultation of HR 4799 from the preceding example. The fixed site is `121.56601°E, 6.80706°N`, near the global geometric greatest point. Its immersion, greatest, and emersion times are the actual topocentric contacts at that site. The diagram also shows lunar orientation, the lunar path, Moon altitude, azimuth, and horizon visibility. +Local charts use the selected observer's topocentric geometry; the diagram below reuses the `2025-06-05` occultation of HR 4799 from the preceding example. The fixed site is `121.56601°E, 6.80706°N`, near the global geometric greatest point. Its immersion, greatest, and emersion times are the actual topocentric contacts at that site. The diagram also shows lunar orientation, the lunar path, Moon altitude, azimuth, and horizon visibility. ![2025 fixed-site occultation of HR 4799](doc/lunar-occultation-hr4799-2025-06-05-local-en.svg) -The global diagram shows the band limits, visible/geometric center line, global event points, and 30-minute center-line labels. Global start and end are the instants when the lunar shadow first touches and finally leaves Earth; they are not the fixed site's contact times. - -![2025 global path of the HR 4799 lunar occultation](doc/lunar-occultation-hr4799-2025-06-05-global-en.svg) - ### Event Maps And GeoJSON #### Global visibility-map SVG -`eclipse/svg` can directly render global solar- and lunar-eclipse maps. A solar map includes the partial-visibility sweep, total/annular central band, center line, global stages, and center-line time labels. It also shows the greatest-eclipse day/night terminator and subsolar point, shadow-axis entry/exit, `P1-P4/U1-U4` contacts, sampled penumbral outlines, and sampled umbral/antumbral outlines. +`eclipse/svg` can directly render global solar- and lunar-eclipse maps. A solar map includes the partial-visibility sweep, total/annular central band, center line, global stages, and center-line time labels. It also shows the sunrise/sunset lines for eclipse start, greatest eclipse, and eclipse end; the subsolar point; shadow-axis entry/exit; `P1-P4/U1-U4` contacts; and the timed penumbral and umbral/antumbral outlines that are off by default and drawn on request. A lunar map shows P1/P4 visible hemispheres, moonrise/moonset transition regions, and the region that sees the entire event. -A lunar map shows P1/P4 visible hemispheres, moonrise/moonset transition regions, and the region that sees the entire event. +`LunarEclipseDetailedSVG` merges both lunar charts into one detailed page: a centred summary (greatest eclipse, penumbral/umbral magnitude, gamma, penumbral/umbral radius, Moon distance, Saros series), geocentric Sun and Moon blocks on either side, the shadow-path diagram, a three-column row of durations / arc-minute scale bar / contacts, and the world visibility map with its legend underneath. The shadow geometry comes from `basic.LunarEclipseShadowGeometryAt`, where **gamma is in Earth equatorial radii while the penumbral and umbral radii are in degrees** - multiply a radius by the Earth's parallax at the Moon to get Earth radii. ```go package main @@ -1364,9 +1485,7 @@ func main() { eclipsesvg.SolarEclipseMapSVGOptions{ Width: 1200, Height: 800, Location: cst, Language: "en", - TimeLabelStep: 30 * time.Minute, - PenumbralOutlineStep: 60 * time.Minute, - CentralShadowStep: 10 * time.Minute, + TimeLabelStep: 30 * time.Minute, }, ) if ok { @@ -1380,21 +1499,56 @@ func main() { if ok { _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-global-en.svg", []byte(lunar), 0o644) } + + detailed, ok := eclipsesvg.LunarEclipseDetailedSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseDetailedSVGOptions{Language: "en", Location: cst}, + ) + if ok { + _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-detailed-en.svg", []byte(detailed), 0o644) + } } ``` -The right-hand event table lists available `P1-P4/U1-U4` contacts, shadow-axis entry/exit, and greatest eclipse in time order; contact coordinates are included when the layout has enough room. The summary also includes the Saros series, path width, Sun altitude/azimuth at the greatest point, and central-phase duration. Orange dashed lines are instantaneous penumbral boundaries with `HH:MM` labels, the gray dashed line is the day/night terminator at greatest eclipse, and solid brown lines are instantaneous umbral or antumbral outlines. +The long orange dashes are the six phase lines where eclipse start, greatest eclipse, and eclipse end coincide with sunrise or sunset. **Instantaneous penumbral and umbral outlines are off by default**; a positive `PenumbralOutlineStep` / `CentralShadowStep` turns them on. Short purple dashes are the local greatest-magnitude contours given by `MagnitudeValues` (0.2/0.4/0.6/0.8 by default), and solid blue lines are greatest-eclipse time isochrones. -Zero `PenumbralOutlineStep` and `CentralShadowStep` values use 60-minute and 10-minute defaults respectively; negative values disable the corresponding outlines. Explicit values such as 30 minutes remain supported. A zero `TimeLabelStep` uses 30 minutes, while a negative value disables center-line time labels. +**Greatest-eclipse time isochrones** (solid blue lines) are off by default: every place on one line sees greatest eclipse at the same instant, and a positive `GreatestTimeStep` turns them on, with 30 minutes as the NASA world-map spacing. This `GreatestTimeStep` belongs to the SVG layer and aligns to ticks of the **display timezone** (`Location`), unlike the UTC alignment of the data layer; `eclipse/svg` exposes no explicit-level entry point, so call the data layer directly and pass the instants through `GreatestTimeValues` when another alignment is needed. + +```go +solar, _ := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + GreatestTimeStep: 30 * time.Minute, +}) +``` + +They are not produced by evaluating greatest eclipse on a latitude/longitude grid and contouring it. The instant is fixed first and the zero set of `d(centre-separation squared)/dt = 0` is continued along the curve instead, so the cost scales with curve length rather than with the visible area. Isochrones are drawn only where the solar and lunar disks actually overlap and the Sun is above the geometric horizon (no refraction or semidiameter correction), each branch ending at the horizon or the partial-visibility boundary; nothing is continued beyond ±88° latitude, and one instant may carry several disconnected branches. + +A zero `TimeLabelStep` uses 30 minutes, while a negative value disables center-line time labels. A zero or negative `GreatestTimeStep` draws no greatest-eclipse isochrones (they must be requested explicitly, as in the core layer and `moon/svg`); positive values align to the display timezone, values below one minute use one minute, and at most 64 are generated per request. The recommended NASA world-map spacing is 30 minutes. `MagnitudeValues` uses 0.2/0.4/0.6/0.8 when nil, is disabled by an explicitly empty slice, and otherwise draws exactly the given levels. + +`SolarEclipseMapSVGOptions.EventsTitle` replaces the title of the global-phases block that carries the greatest-eclipse coordinates and the earth-wide central begin/end, falling back to a localized default when empty; `MapTitle` and `Title` cover the map section title and the main title. + +The documented minimum solar-map canvas is **800x560**: a width below 800 or a height below 560 falls back to 960x640, because on a narrower landscape canvas the map frame overlaps the right-hand data grid and the panel row spacing collapses below 1 px. A `PartialStep` below two minutes is treated as two minutes: the partial region is a union of instantaneous footprints whose cost grows with the sample count, and that union needs one self-consistent sweep, so a denser request does not change the product (a one-second step measured 36.8 s / 951 MB and becomes 1.1 s / 30 MB, byte-identical to the default request). The detailed lunar chart derives its page stack from `Height`: 640x420 and 800x600 cannot hold the diagram and map minima and return `false`, while 1000x1414 and 1414x1000 render normally. + +The three suffix-free lunar entries (`LunarEclipseSVG`, `LunarEclipseDetailedSVG`, `LunarEclipseMapSVG`) share one default model: Danjon with a Chauvenet fallback for ultra-shallow penumbral cases, matching `LunarEclipseOnDate`; the `Danjon` / `Chauvenet` variants keep forcing their model. + +Degradable layers tag their actual geometry source in `data-source`, using exactly the vocabulary documented in the `eclipse/svg` package comment: `partial-band-union`, `sampled-footprint-sweep`, `partial-band-contours`, `rise-set-phase-lines`, `magnitude-contours`, `greatest-time-isochrones`, `besselian-critical-envelope`, `paired-limit-chords`, `sampled-open-sweep`, `central-path-limits`, `penumbral-outlines`, `central-shadow-outlines`, `p1-p4-visibility-regions`, `p1-p4-horizon-boundaries`. `PenumbralOutlineStep` and `CentralShadowStep` are **off by default** (zero or negative draws no instantaneous penumbral/umbral outlines, which hide the globe and are absent from NASA world maps); a positive value gives the sampling interval, with values below one minute treated as one minute. Solar-eclipse and occultation automatic projection can select a north- or south-polar map when appropriate. Lunar-eclipse maps default to equirectangular. Projection affects SVG presentation only, not the underlying WGS84 result. -Eclipse maps use `EclipseMapProjectionEquirectangular`, `EclipseMapProjectionNorthPolar`, or `EclipseMapProjectionSouthPolar` to force a projection. Lunar-occultation maps use the corresponding `MapProjection...` constants. +Eclipse maps use `EclipseMapProjectionEquirectangular`, `EclipseMapProjectionNorthPolar`, or `EclipseMapProjectionSouthPolar` to force a projection. Lunar-occultation maps use the corresponding `MapProjection...` constants. `EclipseMapProjectionOrthographic` adds a **detailed orthographic globe**: the view point is the greatest-eclipse point, only the facing hemisphere is drawn, and the map boundary is the great circle of that hemisphere. + +The globe needs neither extra data nor a projection library. Land is rebuilt at run time by decoding the embedded equirectangular base map back to longitude/latitude and projecting it orthographically; clipping to the limb intersects great circles in geographic coordinates and closes each cut ring along the limb arc; the graticule is sampled on the sphere and clipped the same way. + +The orthographic projection also switches to the **NASA composition**: the globe is centred and enlarged, a scale bar sits directly below it, the phase information becomes three panels (penumbral contacts / local circumstances at greatest / umbral contacts), and the legend and footer follow underneath. Other projections keep the original composition. The cost is roughly 1.0 s per map (about 0.85 s for the equirectangular view), and a 1000x1414 globe SVG is about 530 KB. The following global maps reuse the dates from the earlier local SVG examples. The 2009 Yangtze River total eclipse, 2012 Xiamen annular eclipse, and 2035 Beijing total eclipse use the equirectangular projection: ![2009 Yangtze River total solar eclipse global visibility](doc/solar-eclipse-yangshan-2009-global-en.svg) +The same eclipse as an orthographic globe (`EclipseMapProjectionOrthographic`): + +![2009 Yangtze River total solar eclipse orthographic globe](doc/solar-eclipse-yangshan-2009-globe-en.svg) + ![2012 Xiamen annular solar eclipse global visibility](doc/solar-eclipse-xiamen-2012-global-en.svg) ![2035 Beijing total solar eclipse global visibility](doc/solar-eclipse-beijing-2035-global-en.svg) @@ -1407,11 +1561,61 @@ The lunar-eclipse example reuses the cross-year total eclipse on `2029-01-01`, s ![2029 cross-year total lunar eclipse global visibility](doc/lunar-eclipse-2029-01-01-global-en.svg) +The lunar-eclipse map also has a detailed layout that merges the two charts above into one page: a centred summary (greatest eclipse, penumbral/umbral magnitude, gamma, penumbral/umbral radius, Moon distance, Saros series), geocentric Sun and Moon blocks on either side, the shadow-path diagram, a three-column row of durations / arc-minute scale bar / contacts, and the world visibility map with its legend underneath, on one `1000x1414` page: + +![2029 cross-year total lunar eclipse detailed layout](doc/lunar-eclipse-2029-01-01-detailed-en.svg) + +#### Lunar-occultation detailed SVG + +`moon/svg` composes a whole occultation into one `1000x1414` page through `StarOccultationDetailedSVG` / `PlanetOccultationDetailedSVG`: a centred summary, geocentric/topocentric data blocks for the Sun, Moon, and target body, an **orthographic globe** of the global band (northern and southern limits, visible/geometric center lines, the greatest point, immersion/greatest/emersion stage points, and 30-minute time labels), and a footer note. The globe view point is the event centre and only the facing hemisphere is drawn; this layout is fixed to the orthographic sphere and accepts no other projection, and the standalone global band map is `StarOccultationPathSVG` / `FindStarOccultationSVGs` from the "Lunar-occultation SVG" section above. Page data falls into six blocks: geocentric Moon coordinates, target body, band path points, contact times, ephemeris and constants, and libration. A landscape canvas places the blocks to the right of the map in two columns by three rows; a portrait canvas places them below the globe in three columns by two rows. The diagram below is the `2025-06-05` occultation of HR 4799: + +```go +package main + +import ( + "os" + "time" + + "b612.me/astro/moon" + moonsvg "b612.me/astro/moon/svg" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + star := moon.StarCoordinate{ + ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18, + } + paths, err := moon.FindStarOccultationPaths( + time.Date(2025, 6, 5, 0, 0, 0, 0, cst), + time.Date(2025, 6, 6, 0, 0, 0, 0, cst), + star, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err == nil && len(paths) > 0 { + detailed, renderErr := moonsvg.StarOccultationDetailedSVG( + paths[0], star, + moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Language: "en", Location: cst}, + ) + if renderErr == nil { + _ = os.WriteFile("doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg", []byte(detailed), 0o644) + } + } +} +``` + +![2025 detailed layout of the HR 4799 lunar occultation](doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg) + +The globe on this page uses Natural Earth `1:50m` coastlines without administrative boundaries. Setting `GreatestTimeStep` on `moon.OccultationPathOptions` additionally requests **greatest-occultation time isochrones**, with the same meaning as the blue isochrones on solar-eclipse maps: the instant is fixed and the zero set of the separation derivative is continued along the curve. They are opt-in too, and output is unchanged when the option is not set. An occultation is visible worldwide for only a few hours (the HR 4799 sample on this page spans 4 h 33 m), so the spacing is usually tighter than for a solar eclipse, in the 15-30 minute range, and a larger spacing leaves fewer lines on the band; point-source stars define greatest by center separation while finite planetary disks use the outer-contact metric, matching `StarOccultationInfo.Greatest` and `PlanetOccultationInfo.Greatest` respectively. `moon/svg` has no isochrone switch of its own; it only draws the `GreatestTimeContours` already present in the path, so the request has to be made through `OccultationPathOptions` while the path is computed, and line placement follows the core rules (`GreatestTimeStep` aligns to UTC ticks). For a display-timezone grid, convert the instants to TT Julian ephemeris days and pass them as `GreatestTimeValues`. Global immersion and emersion are the instants when the lunar shadow first touches and finally leaves Earth; they are not the fixed site's contact times. + +- Canvas and errors: the detailed layout needs at least `480x320` and derives its layout from the canvas, returning `ErrInvalidOccultationDetailedSVGOptions` when the map and data blocks cannot fit (`800x600`, `1000x1414`, and `1414x1000` render, while `640x420` and `900x400` are rejected); the standalone global band map needs at least `640x480` and returns `ErrInvalidStarOccultationSVGOptions` on a smaller canvas. + +The "band width" label on the diagram is the ground separation of the northern and southern limits at greatest occultation, `GreatestLimitSeparationKM` (about `3666.6 km` here), which is a different convention from the across-center-line width `Greatest.WidthKM` (about `3582.4 km`); the two are not interchangeable. Greatest-time isochrones must be requested explicitly at the path layer through `OccultationPathOptions.GreatestTimeStep`; a compact band using `DisableFootprints` merges once on the first render and then caches for the same path. See the "Lunar-occultation SVG" section above for the detailed layout and the fixed-site charts. + #### GeoJSON -`geojson` accepts an already computed solar-eclipse, lunar-eclipse, or lunar-occultation result and returns `[]byte`. Those bytes are one complete UTF-8 RFC 7946 `FeatureCollection`, not an image or compressed payload. - -They can be written to `.geojson`, passed to `encoding/json`, or served directly to a map client. +`geojson` accepts an already computed solar-eclipse, lunar-eclipse, or lunar-occultation result and returns `[]byte`. Those bytes are one complete UTF-8 RFC 7946 `FeatureCollection`, not an image or compressed payload: they can be written to `.geojson`, passed to `encoding/json`, or served directly to a map client. ```go package main @@ -1463,13 +1667,23 @@ Each event type has plain and time-marker variants: - `MarshalStarOccultation` / `MarshalStarOccultationWithTimeMarkers` - `MarshalPlanetOccultation` / `MarshalPlanetOccultationWithTimeMarkers` -Coordinates are WGS84 longitude and latitude; lines and polygons are split at the antimeridian. Timed paths have `times` arrays aligned point-for-point with coordinate segments. +Coordinates are WGS84 longitude and latitude; lines and polygons are split at the antimeridian. Timed paths have `times` arrays aligned point-for-point with coordinate segments; `WithTimeMarkers` additionally adds Point Features with `role=time-marker`, whose localized `label` is for display while `time` stays UTC RFC 3339. -`WithTimeMarkers` adds Point Features with `role=time-marker`. Localized `label` values are for display, while `time` remains UTC RFC 3339. +Single-instant primitives (timeline dragging, "exact the moment you stop") and station search horizons: -A zero `TimeMarkerOptions.Step` means 30 minutes. A positive step must be at least one minute, and one export is limited to 1440 markers. +- `eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{...})` returns a reusable handle. `ShadowAt(time.Time)` (interpreted as UTC) or `ShadowAtJDE(jdeTT)` (TT) returns the **global umbral footprint** at that instant, and `StationStateAt` / `StationStateAtJDE` returns the **topocentric Sun/Moon geometry** for one station (magnitude, obscuration, center separation, apparent radii, solar altitude/azimuth, total/annular phase flags). Both compute only that: no visibility band, magnitude contours, rise/set boundaries, limits or center line, and an instant without an umbra yields an empty result instead of an error. +- `geojson.MarshalSolarEclipseShadowInstant(instant)` exports only that instant's shadow region plus its physical boundary when the horizon cuts it, with `time`, `source_boundary_closed`, `geometry_role`, `closure`, `delta_t_seconds`, `model` and `interp_signature` properties. The umbra uses `central-shadow-footprint` + `central-shadow-boundary`; setting `Kind` to `SolarEclipseShadowPenumbra` exports the penumbra (partial-eclipse region) as `partial-footprint` + `partial-footprint-boundary` with the same defaults as the packaged partial sampling (96 points plus 200 km refinement), so the same instant matches the sample to about 1e-12 degrees. No shadow yields an empty FeatureCollection. +- Packaged `partial-footprint` features also carry `source_boundary_closed`, `geometry_role`, `closure` and `interp_signature`, and their horizon-cut endpoints are extended to the horizon grazing points, where an endpoint would otherwise deviate by on the order of `36–41 km`. The partial-band fill hints keep the previous closure so the polar face selection does not move. +- Measured cost (native build, single-machine reference values; absolute timings vary with hardware): about **64 µs** per 96-point instant footprint and **20 µs** per station state; constructing the public handle only clamps options (≈0), the first query builds the internal state for the nearest new moon in 39 µs with a 130 µs anchor lookup, cached per event afterwards; batches run at about 75 µs per instant. +- ΔT: an explicit `DeltaTSeconds` applies to that handle only and only changes Earth rotation — the TT geometry stays fixed while the ground footprint shifts by `0.4651 * |ΔΔT| * cos(latitude)` km (see `basic.DeltaTGroundShiftKM`); `<=0` uses the process-wide model. Either way the result reports the ΔT actually used. The library ships no ΔT uncertainty model; use that helper to convert an external σ into a geometric uncertainty. +- Interpolation: both the packaged `central-shadow-footprint` features and the single-instant export carry `interp_signature` (for example `umbra-closed-seg1-pt97`, derived from the physical boundary's vertex count, segment count, closure flag and pole flag); neighbours may be interpolated vertex-wise only while it is identical; a flipped `closed` flag, a changed segment count (antimeridian), a changed vertex count, or empty↔non-empty (near U1/U4) all require the exact instant instead. Measured over two-minute steps, the centroid moves 78–232 km mid-event and up to about 520 km near the contacts. +- Batches: `ShadowBetween(start, end, step)` and `StationStatesBetween(...)` return a timeline-aligned slice whose entries are empty where no shadow exists. +- Single-instant occultation footprints (`moon.StarOccultationFootprintAt` / `moon.PlanetOccultationFootprintsAt` through `geojson.MarshalStarOccultationFootprint` / `MarshalPlanetOccultationFootprints`) also carry `delta_t_seconds`, `source_boundary_closed`, `geometry_role` and `interp_signature`; when the Moon's horizon cuts them the `closure` has `kind` `target-horizon`, `body` `moon` and a `sublunar` reference instead of the subsolar point. The occultation subsystem keeps the process-wide ΔT and only reports the value used. +- Station searches: `SearchLocalCentralSolarEclipse(date, lon, lat, height, eclipse.SolarEclipseLocalSearchOptions{Kind, MaxYears, Backward, Geometric, Model})` returns `(info, status)` where `status.Exhausted` separates "none within the horizon" from "found"; `MaxYears<=0` uses the legacy-equivalent default horizon (6000 candidate steps, about 992 years because candidates skip non-eclipse seasons). `SolarEclipseCandidates(start, end, options)` returns a geometry-free timetable (greatest time, type, centrality, magnitude, gamma, optional Saros). -GeoJSON contains no basemap, national boundaries, styling, or projection. Web Mercator, polar views, tile selection, and political-boundary policy belong to the application. +The central-shadow roles in solar-eclipse GeoJSON are a stable contract: `role=central-shadow-footprint` is either absent or a `Polygon`/`MultiPolygon` and never a line. When the horizon cuts the shadow it still describes the full region covered on the ground that instant: the physical boundary is extended to both horizon grazing points and closed by the horizon arc between them, `source_boundary_closed` is `false`, and `closure` (`kind`, `time`, `subsolar`) declares that synthetic arc. The physical boundary alone is exported as `role=central-shadow-boundary`, so callers can stroke it and fill the region without drawing a fake horizon edge. A footprint that has shrunk to nothing at U1/U4 is omitted entirely instead of degrading into a line, and `source_boundary_closed=true` means the ring is closed by the shadow itself and carries no synthetic segment. + +A zero `TimeMarkerOptions.Step` means 30 minutes, a positive step must be at least one minute, and one export is limited to 1440 markers. GeoJSON contains no basemap, national boundaries, styling, or projection; Web Mercator, polar views, tile selection, and political-boundary policy belong to the application. ### Planets @@ -1524,18 +1738,18 @@ func main() { Output: ```text -2019-11-11 23:21:42.048057317 +0800 CST // previous inferior conjunction of Mercury -2021-03-26 14:57:43.01215589 +0800 CST // next superior conjunction of Venus -2019-11-01 04:31:38.999851942 +0800 CST // previous Mercury station from prograde to retrograde -2020-06-25 02:07:41.549940705 +0800 CST // next Venus station from retrograde to prograde -2019-10-20 11:50:28.734245896 +0800 CST // previous greatest eastern elongation of Mercury -2020-08-13 07:59:17.123789191 +0800 CST // next greatest western elongation of Venus -2020-01-01 10:02:34.172194004 +0800 CST // Venus rise time in Xi'an; no error -2020-01-01 20:25:37.363712489 +0800 CST // Venus set time in Xi'an; no error +2019-11-11 23:21:41.971051096 +0800 CST // previous inferior conjunction of Mercury +2021-03-26 14:57:42.052354216 +0800 CST // next superior conjunction of Venus +2019-11-01 04:31:49.749019145 +0800 CST // previous Mercury station from prograde to retrograde +2020-06-25 02:07:41.599749326 +0800 CST // next Venus station from retrograde to prograde +2019-10-20 12:01:37.740152478 +0800 CST // previous greatest eastern elongation of Mercury +2020-08-13 08:14:46.304587125 +0800 CST // next greatest western elongation of Venus +2020-01-01 10:02:34.172435402 +0800 CST // Venus rise time in Xi'an; no error +2020-01-01 20:25:37.36411482 +0800 CST // Venus set time in Xi'an; no error -4 // Venus apparent magnitude 49.98145049145023 // Venus phase angle, degrees 0.8215177914415865 // illuminated fraction of Venus -255.63802093000768 // bright-limb position angle of Venus, degrees +255.63802053541346 // bright-limb position angle of Venus, degrees 1.2778819631550336 // Earth-Venus distance, AU 0.7262651056423838 // Sun-Venus distance, AU ``` @@ -1549,7 +1763,7 @@ fmt.Println(mars.Diameter(date), mars.Semidiameter(date)) // Mar fmt.Println(venus.AscendingNode(date), venus.DescendingNode(date)) // Venus ascending-node and descending-node ecliptic longitudes, degrees ``` -Here: +Ascending node / descending node here means the two intersections of the body's orbital plane with the ecliptic: - `AscendingNode`: ecliptic longitude where the body crosses from south of the ecliptic to north of it - `DescendingNode`: ecliptic longitude where the body crosses from north of the ecliptic to south of it @@ -1605,21 +1819,21 @@ Output: ```text true // a valid geocentric Mercury transit was found -2019-11-11 12:35:31.617325544 +0000 UTC // first contact: Mercury externally enters the solar disk -2019-11-11 12:37:13.078211545 +0000 UTC // second contact: Mercury is fully inside the solar disk -2019-11-11 15:19:48.410291075 +0000 UTC // greatest transit: Mercury center is closest to the Sun center -2019-11-11 18:02:29.2267102 +0000 UTC // third contact: Mercury starts leaving the solar disk -2019-11-11 18:04:10.687676668 +0000 UTC // fourth contact: Mercury externally leaves the solar disk +2019-11-11 12:35:31.567597389 +0000 UTC // first contact: Mercury externally enters the solar disk +2019-11-11 12:37:12.817581295 +0000 UTC // second contact: Mercury is fully inside the solar disk +2019-11-11 15:19:48.36056292 +0000 UTC // greatest transit: Mercury center is closest to the Sun center +2019-11-11 18:02:29.176982045 +0000 UTC // third contact: Mercury starts leaving the solar disk +2019-11-11 18:04:10.637948513 +0000 UTC // fourth contact: Mercury externally leaves the solar disk 5h28m39.070351124s // geocentric transit duration from first to fourth contact -75.92506897631685 // minimum Mercury-Sun center separation at greatest transit, arcseconds -968.8881520858397 // solar semidiameter at greatest transit, arcseconds -4.978442860728242 // Mercury semidiameter at greatest transit, arcseconds +75.92400059923187 // minimum Mercury-Sun center separation at greatest transit, arcseconds +968.8881519533047 // solar semidiameter at greatest transit, arcseconds +4.978442871670873 // Mercury semidiameter at greatest transit, arcseconds true // a valid geocentric Venus transit was found -2012-06-05 22:09:47.514281272 +0000 UTC // first contact: Venus externally enters the solar disk -2012-06-05 22:27:35.701768398 +0000 UTC // second contact: Venus is fully inside the solar disk -2012-06-06 01:29:35.408823788 +0000 UTC // greatest transit: Venus center is closest to the Sun center -2012-06-06 04:31:34.90493685 +0000 UTC // third contact: Venus starts leaving the solar disk -2012-06-06 04:49:23.303366303 +0000 UTC // fourth contact: Venus externally leaves the solar disk +2012-06-05 22:09:47.466886639 +0000 UTC // first contact: Venus externally enters the solar disk +2012-06-05 22:27:35.865356326 +0000 UTC // second contact: Venus is fully inside the solar disk +2012-06-06 01:29:35.572371482 +0000 UTC // greatest transit: Venus center is closest to the Sun center +2012-06-06 04:31:35.068444311 +0000 UTC // third contact: Venus starts leaving the solar disk +2012-06-06 04:49:23.25597167 +0000 UTC // fourth contact: Venus externally leaves the solar disk 6h39m35.789085031s // geocentric transit duration from first to fourth contact ``` @@ -1683,15 +1897,15 @@ func main() { Output: ```text -2020-10-14 07:25:50.262777507 +0800 CST // next opposition of Mars -2021-01-29 09:39:33.565426468 +0800 CST // next conjunction of Jupiter -2019-04-30 10:27:41.606289446 +0800 CST // previous Saturn station from prograde to retrograde -saturn B=23.577026 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // Saturn ring B, B', P, dU, major axis, minor axis -2020-01-11 15:23:07.378419935 +0800 CST // next Uranus station from retrograde to prograde -2019-12-08 17:00:15.328663587 +0800 CST // previous eastern quadrature of Neptune -2020-06-07 03:10:59.356176853 +0800 CST // next western quadrature of Mars -2020-01-01 04:41:29.622089266 +0800 CST // Mars rise time in Xi'an; no error -2020-01-01 14:55:32.963870465 +0800 CST // Mars set time in Xi'an; no error +2020-10-14 07:25:50.441412627 +0800 CST // next opposition of Mars +2021-01-29 09:39:33.697994649 +0800 CST // next conjunction of Jupiter +2019-04-30 10:28:00.187439918 +0800 CST // previous Saturn station from prograde to retrograde +saturn B=23.577025 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // Saturn ring B, B', P, dU, major axis, minor axis +2020-01-11 15:23:23.360308706 +0800 CST // next Uranus station from retrograde to prograde +2019-12-08 17:00:15.517960488 +0800 CST // previous eastern quadrature of Neptune +2020-06-07 03:11:00.026179254 +0800 CST // next western quadrature of Mars +2020-01-01 04:41:29.621566236 +0800 CST // Mars rise time in Xi'an; no error +2020-01-01 14:55:32.963508367 +0800 CST // Mars set time in Xi'an; no error 1.57 // Mars apparent magnitude 2.1844284956325937 // Earth-Mars distance, AU 1.5897860004265403 // Sun-Mars distance, AU @@ -1744,7 +1958,7 @@ Output: ```text jupiter DS=54.342153 DE=1.436485 CMI=292.712909 CMII=276.309048 CMIII=147.241811 // Jupiter DS/DE and System I/II/III central meridians, degrees -saturn B=-0.608046 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453246 // Saturn ring B, B', minor-axis position angle, outer major/minor axes +saturn B=-0.608048 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453248 // Saturn ring B, B', minor-axis position angle, outer major/minor axes ``` If only Jupiter central meridians are needed: @@ -1781,6 +1995,8 @@ Two conventions matter: The two conventions may differ by up to about 7 minutes in duration. This is a definition difference, not a timing-accuracy failure. Use `GalileanPhenomenonContactEvent` for observing predictions and direct comparison with public almanacs. +##### Code example + ```go package main @@ -1828,15 +2044,15 @@ func main() { Output: ```text -io x=-0.658543 y=-0.035608 front=true // Io X/Y offset from Jupiter center, in Jupiter radii; in front of Jupiter -europa ra=110.769323 dec=22.335800 // Europa apparent RA and Dec, degrees +io x=-0.675026 y=-0.032798 front=true // Io X/Y offset from Jupiter center, in Jupiter radii; in front of Jupiter +europa ra=110.769133 dec=22.335828 // Europa apparent RA and Dec, degrees io transit=true occultation=false eclipse=false shadow=true // Io is transiting, and its shadow is also transiting europa transit=false occultation=false eclipse=false shadow=false // Europa has no transit, occultation, eclipse, or shadow transit at this instant event valid=true sat=1 type=transit // next valid event is an Io transit -2026-01-16 16:32:47.785289883 +0000 UTC // Io transit begins -2026-01-16 17:40:43.882995843 +0000 UTC // midpoint of the Io transit -2026-01-16 18:48:40.519664883 +0000 UTC // Io transit ends -2h15m52.734375s // Io transit duration +2026-01-16 16:32:47.552742362 +0000 UTC // Io transit begins +2026-01-16 17:40:44.189371168 +0000 UTC // midpoint of the Io transit +2026-01-16 18:48:40.287077128 +0000 UTC // Io transit ends +2h15m52.734334766s // Io transit duration contact valid=true sat=2 type=occultation // next valid contact event is a Europa occultation 2026-01-17 01:00:34.99533087 +0000 UTC // Europa occultation disappearance starts 2026-01-17 01:02:31.714070141 +0000 UTC // model center crossing during disappearance @@ -1847,16 +2063,23 @@ contact valid=true sat=2 type=occultation // next valid contact event is a Europ 2026-01-17 03:54:39.68275398 +0000 UTC // reappearance ends ``` -External baseline summary: +##### External baselines -- `Satellites` positions relative to Jupiter center: maximum sample difference against JPL Horizons about `X=0.252"`, `Y=0.108"`. +The Galilean-satellite implementation was checked against two external baselines: + +- **JPL Horizons**: apparent positions of the four satellites relative to Jupiter's center, and shadow-center offsets from Jupiter's disk during shadow transits. +- **IMCCE 2026 tables**: transits, occultations, Jupiter eclipses, shadow transits, and D/F contact windows. + +Current test results in summary: + +- `Satellites` positions relative to Jupiter center: maximum sample difference against JPL Horizons about `X=0.054"`, `Y=0.048"`. - `SatellitePhenomena` shadow-transit shadow-center offsets: maximum sample difference against JPL Horizons about `X=0.051"`, `Y=0.016"`; boolean phenomenon flags match in the samples. -- `GalileanPhenomenonContactEvent` against IMCCE 2026 tables: maximum contact-time difference in current samples about `72 s`; maximum contact-duration difference about `17 s`. +- `GalileanPhenomenonContactEvent` against IMCCE 2026 tables (8 samples, all four D1/D2/F1/F2 contacts compared): maximum contact-time difference about `79 s`, maximum contact-duration difference about `17 s`, pinned by a regression test with `120 s` / `25 s` ceilings. - `GalileanPhenomenonEvent` is not the IMCCE D/F contact convention. Direct comparison of its start/end times with IMCCE contact tables can differ by up to about `7 min` because the event definitions are different. ### Stars -The built-in star database contains 9100+ stars and supports proper-motion propagation. +The built-in star database holds 9100 stars (BSC / HR numbers `1–9110`, apparent magnitudes `-1.46` to `7.96`) and propagates proper motion automatically. ```go package main @@ -1876,7 +2099,7 @@ func main() { // Initialize the star catalog. star.InitStarDatabase() - sirius, _ := star.StarDataByName("天狼") + sirius, _ := star.StarDataByHR(2491) // Sirius ra, dec := sirius.RaDecByDate(date) // Rise time of Sirius. riseDate, _ := star.RiseTime(date, ra, dec, 115, 40, 0, true) @@ -1888,7 +2111,7 @@ func main() { fmt.Println(star.ConstellationEN(ra, dec, date)) // Vega. - vega, _ := star.StarDataByName("织女一") + vega, _ := star.StarDataByHR(7001) // Vega ra, dec = vega.RaDecByDate(time.Date(13600, 1, 1, 0, 0, 0, 0, time.Local)) // Right ascension of Vega in year 13600. fmt.Println(tools.Format(ra/15, 1)) @@ -1897,19 +2120,19 @@ func main() { // First entry in the brightest-star list. bright, _ := star.TopBrightStars() - fmt.Println(bright[0].ChineseName, bright[0].CommonName, bright[0].Mag) + fmt.Println(bright[0].CommonName, bright[0].HR, bright[0].Mag) } ``` Output: ```text -2019-12-31 19:22:56.176710426 +0800 CST // rise time of Sirius -2020-01-01 05:30:39.834894239 +0800 CST // set time of Sirius +2019-12-31 19:22:56.144202053 +0800 CST // rise time of Sirius +2020-01-01 05:30:39.802506566 +0800 CST // set time of Sirius Canis Major // English constellation containing Sirius -5h58m5.71s // right ascension of Vega in year 13600 -84°19′26.13″ // declination of Vega in year 13600 -天狼 Sirius -1.46 // first brightest-star entry: Chinese name, common English name, apparent magnitude +6h3m46.61s // right ascension of Vega in year 13600 +84°18′27.15″ // declination of Vega in year 13600 +Sirius 2491 -1.46 // first brightest-star entry: common English name, HR number, apparent magnitude ``` ### Coordinate Tools @@ -1961,10 +2184,10 @@ Output: ```text 143.72223158223719 19.53512536790277 // RA and Dec converted from ecliptic coordinates 43.46959597099446 -17.686623571613737 107.68662357161374 // azimuth, altitude, zenith distance -143.99353431082105 18.7404068044953 // topocentric RA and Dec +144.2551242046188 18.790254631841993 // topocentric RA and Dec manual az=281.869347 alt=24.489608 zen=65.510392 ha=73.866900 // manual-LST horizontal result and hour angle gal lon=0.000047 lat=-0.000079 // Galactic longitude and latitude -apparent alt=10.093429 // apparent altitude after refraction estimate +apparent alt=10.093428 // apparent altitude after refraction estimate ``` Research-style `coord` helpers do not automatically substitute the current obliquity or sidereal time. They are useful for experiments with custom axial tilts or manually specified hour angles. For ordinary observing calculations, use the `time.Time` based APIs such as `EclipticToEquatorial` and `EquatorialToHorizontal`. @@ -2129,6 +2352,8 @@ rise, _ := orbit.RiseTime(time.Date(2025, 11, 21, 0, 0, 0, 0, site), ceres, 121. fmt.Printf("alt=%.6f az=%.6f rise=%s\n", alt, az, rise.Format(time.RFC3339)) ``` +These observing helpers work on topocentric apparent coordinates and suit rise/set and pointing support for asteroids, comets, or custom two-body targets. + `orbit` also includes a lightweight visual-binary solver using the classical apparent-orbit formula from chapter 55 of *Astronomical Algorithms*: ```go @@ -2194,21 +2419,21 @@ Notes: ## Implemented -- Sun position, altitude, zenith distance, azimuth, culmination, twilight, rise/set, solar terms, solar eclipses, solar physical ephemerides -- Moon position, altitude, zenith distance, azimuth, culmination, rise/set, phases, lunar eclipses, libration, apsides, maximum declination, and stellar/planetary lunar occultations -- Global projected SVG maps for solar eclipses, lunar eclipses, and lunar occultations; fixed-site occultation charts; GeoJSON with optional time markers -- `lite/sun` and `lite/moon` lightweight Sun/Moon chains for minute-level rise/set, lightweight position, and lunar-phase work -- Earth eccentricity, Sun-Earth distance, perihelion, aphelion -- Apparent/mean sidereal time, constellation lookup, common coordinate transforms, refraction, airmass, parallactic angle, Galactic coordinates -- Seven major-planet coordinates, Sun/body and Earth/body distances, special events, Mercury/Venus geocentric transits, physical ephemerides, apparent diameters, phases, parallactic angles, and nodes -- Chinese lunisolar calendar conversion from 721 BCE to 3000 CE -- 9100+ star catalog -- Generic small-body orbit propagation, H-G apparent magnitude, visual-binary position angle and separation -- Blackbody radiation, synodic periods, magnitudes, telescope formulas, airmass formulas -- Apparent solar time, planar-sundial geometry, horizontal sundial hour-line angle +- ✅ Sun position, altitude, zenith distance, azimuth, culmination, twilight, rise/set, solar terms, solar eclipses, solar physical ephemerides +- ✅ Moon position, altitude, zenith distance, azimuth, culmination, rise/set, phases, lunar eclipses, libration, apsides, maximum declination, and stellar/planetary lunar occultations +- ✅ Global projected SVG maps for solar eclipses, lunar eclipses, and lunar occultations; fixed-site occultation charts; GeoJSON with optional time markers +- ✅ `lite/sun` and `lite/moon` lightweight Sun/Moon chains for minute-level rise/set, lightweight position, and lunar-phase work +- ✅ Earth eccentricity, Sun-Earth distance, perihelion, aphelion +- ✅ Apparent/mean sidereal time, constellation lookup, common coordinate transforms, refraction, airmass, parallactic angle, Galactic coordinates +- ✅ Seven major-planet coordinates, Sun/body and Earth/body distances, special events, Mercury/Venus geocentric transits, physical ephemerides, apparent diameters, phases, parallactic angles, and nodes +- ✅ Chinese lunisolar calendar conversion from 721 BC to AD 3000 +- ✅ 9100-star catalog +- ✅ Generic small-body orbit propagation, H-G apparent magnitude, visual-binary position angle and separation +- ✅ Blackbody radiation, synodic periods, magnitudes, telescope formulas, airmass formulas +- ✅ Apparent solar time, planar-sundial geometry, horizontal sundial hour-line angle ## TODO -- Code normalization and performance optimization -- More external baselines and fuller notes on physical-ephemeris conventions -- More stellar and deep-sky helper functionality +- 🔄 Code normalization and performance optimization +- 🔄 More external baselines and fuller notes on physical-ephemeris conventions +- 🔄 More stellar and deep-sky helper functionality diff --git a/README.md b/README.md index 5bc6afc..9ebe0bc 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ [![Go Reference](https://pkg.go.dev/badge/b612.me/astro.svg)](https://pkg.go.dev/b612.me/astro) -自用多年的天文算法库,用于个人天文历法爱好、科普演示和轻量研究。 +自用多年的天文算法库,用于个人天文历法爱好。 >📚 本项目主要用于天文算法学习与验证,计算结果满足业余爱好级别需求。 -基于《天文算法》(Astronomical Algorithms)一书实现,提供历法转换、太阳/月亮/行星位置、日月食、月掩、升落、中天、月相、恒星、坐标变换、物理星历、研究公式和通用小天体轨道传播等功能。太阳和行星部分使用内置 VSOP87 解析项,月球部分使用内置 ELP2000/82 解析级数,不依赖外部 JPL 星历文件。 +基于《天文算法》(Astronomical Algorithms)一书实现,覆盖范围见下方[功能概览](#功能概览)。太阳和行星部分使用内置 VSOP87 解析项,月球部分使用内置 ELP/MPP02(DE405 拟合)解析级数,不依赖外部 JPL 星历文件。 没有特殊标注时,本程序所提供的坐标均为瞬时天球坐标;角度单位默认是度,视直径/视半径单位是角秒,距离单位按函数名使用 AU 或 km。 @@ -42,7 +42,7 @@ go get b612.me/astro ## 功能概览 -- 📅 **历法转换**:公历与农历互转(公元前721年-公元3000年或更久)、节气时刻 +- 📅 **历法转换**:公历与农历互转(公元前721年-公元3000年)、节气时刻 - 🌞 **太阳计算**:天球位置、日出日落、日地距离、真太阳时、视高度角、视差角、日面物理参数(`P/B0/L0`)、视直径等 - 🌙 **月亮计算**:天球位置、月出月落、地月距离、月相、朔望时间、视直径、亮边位置角、视差角、地心/站心天平动、近远地点、交点、最大赤纬等 - 🪶 **轻量链路**:`lite/sun` 与 `lite/moon` 提供面向手表、前端、小程序和其它资源受限环境的轻量近似太阳/月亮算法,覆盖天球位置、升落和月相 @@ -50,7 +50,7 @@ go get b612.me/astro - 🌘 **月掩**:按指定赤经赤纬搜索恒星月掩,按有限圆盘计算行星月掩,支持指定地点接触时刻、全球掩带、几何掩甚点和 SVG - 🗺️ **地理输出**:日食、月食和月掩结果可编码为带时间数据与可选时间标记的 GeoJSON;全球 SVG 使用无行政边界海岸线,并支持等经纬和南北极投影 - 🪐 **行星计算**:七大行星天球位置、升落时间、合冲留、大距、水星/金星地心凌日等特殊天象时间、升交点/降交点、视直径/视半径、相位、视差角、节点、视星等与物理星历 -- ⭐ **恒星计算**:指定天球坐标所属星座;同时包含9100颗恒星数据库,可计算升降时间、视差角和视高度角,获取指定日期的恒星坐标信息 +- ⭐ **恒星计算**:指定天球坐标所属星座;同时内置 9100 颗恒星数据库,可计算升降时间、视差角和视高度角,获取指定日期的恒星坐标信息 - 🧭 **坐标工具**:黄道/赤道/地平坐标转换、站心坐标、恒星时、岁差、章动、角距离、大气折射、大气质量、视差角、银道坐标 - 🔭 **研究公式**:黑体辐射、会合周期、星等距离换算、望远镜极限星等、恒星半径/温度/光度换算、大气质量模型 - ☄️ **通用轨道**:给定小行星、彗星或假想天体轨道根数,计算日心/地心位置和站心视位置,并提供距日/距地距离、日距角、相位角、照明比例、H-G 视星等和轻量视双星位置角/角距计算 @@ -67,7 +67,7 @@ go get b612.me/astro | `lite/sun` / `lite/moon` | 轻量太阳/月亮近似链路,面向分钟级升落、轻量天球位置和月相计算 | | `eclipse` / `eclipse/svg` | 全局/局地日月食、日食中心线与偏食足迹、局地可见性筛选、局地示意图与全球见食图 SVG | | `moon/svg` | 指定地点恒星/行星月掩视圆图,以及带掩带、中心线和时间标记的全球投影 SVG | -| `geojson` | 将日食、月食和月掩的既有地理结果编码为 RFC 7946 GeoJSON,投影与样式由应用负责 | +| `geojson` | 将日食、月食和月掩的既有地理结果编码为 RFC 7946 GeoJSON | | `mercury` / `venus` | 水星、金星位置、升落、合日、留、大距、地心凌日、相位、视差角、视星等、视直径、节点和物理星历 | | `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | 外行星位置、升落、合冲、留、方照、相位、视差角、视星等、视直径、节点和物理星历 | | `earth` | 地球轨道偏心率、近日点、远日点 | @@ -76,23 +76,18 @@ go get b612.me/astro | `orbit` | 通用日心二体圆锥曲线轨道传播,支持椭圆、近抛物、抛物和双曲轨道;另含相位/测光辅助和轻量视双星计算 | | `sundial` | 真/平太阳时换算、太阳时角、平太阳时/区时时角、平面日晷几何、时间线/赤纬曲线采样、赤道/水平/垂直日晷特例 | -很多接口额外提供 `...N` 截断版本: +一些接口额外提供 `...N` 截断版本: - `n < 0`:使用本仓库当前内置的全部解析项 - `n >= 0`:截断解析项,适合性能对比、粗算或算法研究 这里的“全部解析项”指package中已经内置的表项,不等同于外部发行版 VSOP/ELP 长表的全部原始数据。 -大气质量接口的补充: - -- `coord.Airmass...` 面向观测场景,既可以直接传入视高度角,也可以从真高度角先做折射修正再计算 -- `formula.Airmass...` 只提供纯公式本身,不负责折射修正,适合已经在别处拿到视高度角或天顶距时直接调用 - ## 适用范围与精度 ### 太阳与行星 -太阳和行星使用内置 VSOP87 解析项,当前表项覆盖 **J2000 前后约 4000 年**。精度量级如下: +太阳和行星使用内置 VSOP87 解析项,当前表项覆盖 **J2000 前后约 4000 年**。下表列出相对完整 VSOP87 的截断误差量级: | 目标 | 黄经/黄纬 | 距离 | | --- | --- | --- | @@ -104,20 +99,20 @@ go get b612.me/astro | 天王星 | 约 `1"` | 约 `20 × 10^-6 AU` | | 海王星 | 约 `1"` | 约 `40 × 10^-6 AU` | -这类精度适合常规天文历法、观测辅助、科普展示和个人研究。如果需要航天导航、掩星预报或严格动力学积分,应使用 JPL DE 等专业星历。 +这类精度适合常规天文历法、观测辅助、科普展示和个人研究;航天导航、精确掩星预报和严格动力学积分不在该范围内,这类用途通常需要 JPL DE 等专业星历。 ### 月球 -月球使用内置的 ELP/MPP02 DE405 解析级数(截断版,保留主要周期项),库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食、业余月掩预报和常规位置计算;若需要极高精度月球测距、长期物理天平动或专业掩星,请以 JPL 星历或专门月球星历为准。 +月球使用内置的 ELP/MPP02 DE405 解析级数(截断版,保留主要周期项),库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食、业余月掩预报和常规位置计算;极高精度月球测距、长期物理天平动和专业掩星超出该范围,这类用途以 JPL 星历或专门月球星历为准。 ### Lite 轻量链路 -`lite/sun` 和 `lite/moon` 是独立于 `sun` / `moon` 的近似实现。不依赖 VSOP87 或 ELP2000/82,适合 CPU / 内存受限环境。 +`lite/sun` 和 `lite/moon` 是独立于 `sun` / `moon` 的近似实现。不依赖 VSOP87 或 ELP/MPP02,适合 CPU / 内存受限环境。 - `lite/sun`:简化太阳真黄经 / 视黄经公式 + 轻量赤道坐标转换 - `lite/moon`:Schlyter 风格月球近似(约 15 个摄动项)+ 轻量站心修正 - 升落搜索:固定步长扫描 + 二分,不走主链的高精度章动迭代 -- 计算链路零堆分配(0 allocs/op),月球位置约 1µs,比主链快 20–60 倍 +- 计算链路零堆分配(0 allocs/op);相对主链,位置与月相等纯求值接口约快 `8.3–27.3x`,升落接口约 `1.0–3.7x` 能力边界: @@ -126,62 +121,63 @@ go get b612.me/astro | `lite/sun` | 简化太阳真/视黄经 + 轻量赤道坐标转换 | `30` 分钟步长扫描 + 二分 | 日出日落、太阳高度角、表盘/前端周期刷新 | | `lite/moon` | Schlyter / vFPS 月球近似 + 轻量站心修正 | `15` 分钟步长扫描 + 二分 | 月出月落、月相、月龄、轻量月球观测辅助 | -与主链 `sun` / `moon` 的误差(2026 全年,8 个站点;升落每 7 或 15 天取样,月相月龄每 6 小时): +与 `sun` / `moon` package的误差(2026 全年,8 个站点;升落每 7 或 15 天取样,月相月龄每 6 小时): -| 能力 | 平均绝对误差 | P95 | 最大绝对误差 | 备注 | -| --- | --- | --- | --- | --- | -| `lite/sun` 日出 | `0.02 min` | `0.04 min` | `0.31 min` | 样本中无事件存在性分歧 | -| `lite/sun` 日落 | `0.02 min` | `0.06 min` | `0.35 min` | `2` 个高纬样本在跨午夜“归属哪一天”上有语义差异 | -| `lite/moon` 月出 | `0.28 min` | `0.57 min` | `1.44 min` | 样本中无事件存在性分歧 | -| `lite/moon` 月落 | `0.36 min` | `0.86 min` | `1.24 min` | `1` 个高纬样本在“当天是否有月落”上与主链判断不同 | -| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | 和 `moon.Phase` 对比 | +| 能力 | 平均绝对误差 | P95 | 最大绝对误差 | 备注 | +| --- | --- | --- | --- |-----------------------------------| +| `lite/sun` 日出 | `0.02 min` | `0.04 min` | `0.31 min` | 样本中无事件存在性分歧 | +| `lite/sun` 日落 | `0.02 min` | `0.06 min` | `0.35 min` | `2` 个高纬样本在跨午夜日期归属上有语义差异 | +| `lite/moon` 月出 | `0.28 min` | `0.57 min` | `1.44 min` | 样本中无事件存在性分歧 | +| `lite/moon` 月落 | `0.36 min` | `0.86 min` | `1.24 min` | `1` 个高纬样本在“当天是否有月落”上与主链判断不同 | +| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | 与 `moon.Phase` 对比的结果 | | `lite/moon` `PhaseAge()` | `0.003 d` | `0.010 d` | `0.014 d` | 约平均 4.3 分钟、P95 14.4 分钟、最大 20.2 分钟 | -| `lite/moon` 地心黄经 | `2.41'` | `6.82'` | `9.91'` | 相对主链月球位置 | -| `lite/moon` 地心黄纬 | `0.87'` | `1.83'` | `2.92'` | 相对主链月球位置 | +| `lite/moon` 地心黄经 | `2.41'` | `6.82'` | `9.91'` | 相对主链月球位置 | +| `lite/moon` 地心黄纬 | `0.87'` | `1.83'` | `2.92'` | 相对主链月球位置 | -本地 `Go testing.Benchmark` 参考值(绝对值因机器而异,相对趋势稳定): +`Go testing.Benchmark` 参考值(单机实测,仅供参考;绝对值因机器而异): + +口径为 2026-01-01 20:00 CST、上海(`121.4737°E, 31.2304°N`)、`height=0`、`aero=true`,表中取 3 次中位数;每项先预热一次,懒加载缓存与首次分配不计入稳态单次开销。 | 接口 | 主链 | `lite` | 加速倍数 | 主链分配 | `lite` 分配 | | --- | --- | --- | --- | --- | --- | -| `Sun ApparentRaDec` | `13.392 µs/op` | `231.0 ns/op` | `57.97x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Sun Altitude` | `16.405 µs/op` | `681.5 ns/op` | `24.09x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Sun RiseTime` | `202.994 µs/op` | `18.823 µs/op` | `10.78x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon ApparentRaDec` | `65.273 µs/op` | `1.035 µs/op` | `63.06x` | `297202 B/op, 70 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon Phase` | `40.264 µs/op` | `940.6 ns/op` | `42.83x` | `178321 B/op, 42 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon Altitude` | `44.883 µs/op` | `2.275 µs/op` | `19.73x` | `178321 B/op, 42 allocs/op` | `0 B/op, 0 allocs/op` | -| `Moon RiseTime` | `659.886 µs/op` | `77.600 µs/op` | `8.50x` | `2377613 B/op, 560 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun ApparentRaDec` | `5.888 µs/op` | `215.6 ns/op` | `27.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun Altitude` | `5.955 µs/op` | `625.9 ns/op` | `9.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Sun RiseTime` | `95.847 µs/op` | `25.648 µs/op` | `3.7x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon ApparentRaDec` | `16.520 µs/op` | `1.006 µs/op` | `16.4x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon Phase` | `15.139 µs/op` | `917.7 ns/op` | `16.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon Altitude` | `9.533 µs/op` | `1.150 µs/op` | `8.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | +| `Moon RiseTime` | `120.545 µs/op` | `118.037 µs/op` | `1.0x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` | -需要日月食、物理天平动或高纬边界判定时,仍用主链 `sun` / `moon`。 +主链与 `lite` 的差距随场景变化:位置、月相等纯求值接口约 `8.3–27.3x`;升落接口两边都要做时间搜索,差距缩小到 `1.0–3.7x`(`Moon RiseTime` 已接近持平)。加速倍数来自同一台机器上的对照,受机器影响小于绝对值。 + +日月食、物理天平动或高纬边界判定使用主链 `sun` / `moon`。 ### 精度校验参考 下面这些函数曾与 JPL Horizons、NASA GSFC 等资料对照,可作为使用时判断结果量级的参考: -- 太阳/行星/月亮视直径:与外部基线最大差异从 `0.000002"` 到 `0.194598"` 不等,月亮因视差和距离变化更敏感 +- 太阳/行星/月亮视直径:各天体与外部基线的最大差异从 `0.000002"` 到 `0.194598"` 不等,月亮因视差和距离变化更敏感 - 太阳物理星历 `P/B0/L0`:最大差异约 `0.003349° / 0.003986° / 0.047394°` - 行星升/中天/落:已用 JPL Horizons 电视事件(TVH, Time-Varying Hourly)做对比校验;该基线按 1 分钟步长生成,当前结果与 Horizons 事件时间在分钟级上对齐 - 月出/月落:`aero=true` 按动态标准折射和实时月球视半径计算上缘过地平线。7 个地点、14 个海平面事件相对 JPL Horizons DE441 的平均/最大差异约 `0.30s / 0.75s` - 月出/月落的其他口径:相对固定 `-0.8333°` 的 MET Norway(Skyfield 1.53 + DE440s)约 `38.77s / 76.22s`;相对未公开地平线口径的 IMCCE Miriade 平均约 `2m13.46s`,`61°N` 低仰角样本最大约 `6m41.82s` -- 月掩恒星全球路径:`2025-06-05` 月掩进贤增九(HR 4799)样例相对项目记录的参考值,掩始/掩甚/掩终差异约 `+2.30s / -1.37s / -3.12s`;掩甚经纬度差异约 `+0.0122° / +0.0105°`,掩带宽为 `3582.4 km`,与参考 `3571.9 km` 相差约 `10.5 km` - 地球近日点/远日点:时刻最大差异约 `1m28.84s`,距离最大差异约 `0.000000039837 AU` - 月球主链位置:当前算法为 ELP/MPP02 DE405 解析级数截断版;在 `-2000` 年四个 JPL/Horizons `JDTT` 样本上,相对 JPL/Horizons 的最大差异约为黄经 `219.6"`、黄纬 `25.8"`、距离 `34.3 km` - 月球近地点/远地点:时刻最大差异约 `15m53.45s`,距离最大差异约 `39.758 km` - 月球最大赤纬:时刻最大差异约 `2.43s`,赤纬最大差异约 `0.00006431°` -站心时角修复后的影响边界:月出/月落、指定地点月掩、月球站心坐标,以及地方月食结果中的食甚月高和可见性判断使用修复后的 UT 时角链。日食和月食的全球接触时刻、食甚、食分等主体结果来自各自的地心/贝塞尔几何,不经过该站心转换,因此现有 NASA 精度数据无需随之改写。 - ## 快速开始 ### 历法转换与节气 -本 package 支持公历与中国传统农历日期之间的相互转换,并提供节气信息。支持年份范围为公元前721年至公元3000年(部分现代算法可更久)。 +本 package 支持公历与中国传统农历日期之间的相互转换,并提供节气信息。支持年份范围为公元前721年至公元3000年(公元前104年为历法表切换点)。 农历本质上是阴阳合历(Lunisolar Calendar),但为兼顾大众习惯与代码简洁性,相关函数命名采用 `Lunar` 而非更学术的 `Lunisolar`。 #### 历法说明 - **默认路由**:按年份自动选择,先秦段使用春秋/古六历重建,`-220..-104` 使用秦汉颛顼历,`-103..1912` 使用历表,`1913` 年后使用现代算法。 - **显式古历**:如果需要指定某一古历系统,请使用 `SolarToLunarWithCalendar` / `LunarToSolarWithCalendar` 这类 API。 -- **数据来源**:古历部分主要参考《寿星天文历》;使用 [ytliu0教授的网站数据](https://ytliu0.github.io/ChineseCalendar/index_simp.html)做验证;现代段依据GB/T 33661-2017编排,通过 VSOP87、ELP定气定朔 。 +- **数据来源**:古历部分主要参考《寿星天文历》;使用 [ytliu0教授的网站数据](https://ytliu0.github.io/ChineseCalendar/index_simp.html)做验证校验;现代段依据GB/T 33661-2017编排,通过 VSOP87、ELP定气定朔 。 - **节气**:`JieQi` 返回现代天文计算的节气时刻;`CalendricalJieQi` 返回历法相符节气日期。 --- @@ -207,7 +203,7 @@ go get b612.me/astro 本 package 主要面向中国历法,因此定气和定朔的计算默认采用北京时间(UTC+8)。对于使用其他时区的地区,若直接套用中国农历的编排规则,可能会产生日期偏差。 -为方便探索与研究,本 package 提供了底层方法 `Solar` 和 `Lunar`,它们支持在**自定义时区**下,按照**现行中国农历算法(GB/T 33661-2017)**进行公历与农历的相互转换。 +为方便探索与研究,本 package 提供了底层方法 `Solar` 和 `Lunar`,它们支持在**自定义时区**下,按照**现行中国农历算法(GB/T 33661-2017)** 进行公历与农历的相互转换。 如果只需北京时间下的标准转换,请直接使用封装好的 `SolarToLunar` 和 `LunarToSolar` 方法。 **示例**:农历规则要求冬至必须落在农历十一月。以1984年冬至为例,计算可得: @@ -232,7 +228,7 @@ fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0)) ##### 5. Go 语言特别注意 -⚠️ **Go 标准库 `time.Time` 在历法处理上与本程序存在差异:** +⚠️ Go 标准库 `time.Time` 在历法处理上与本程序存在差异: - Go 语言在1582年10月15日之前使用逆推格里高利历,而非儒略历。若不使用 `Add` 方法,一般可正常使用。 - 因此,**在1582年10月15日之前,`time.Time.Weekday()` 返回结果与本程序计算结果不一致**。 @@ -246,9 +242,44 @@ fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0)) weekday := int(calendar.Date2JDE(date)+1.5) % 7 // 0表示星期日,1表示星期一,……,6表示星期六 ``` -若在1582年之前使用 time.Time 的 Add 或 AddDate 方法,请注意其在某些年份可能不准确。 +在 1582 年之前使用 `time.Time` 的 `Add` 或 `AddDate` 会经过逆推格里高利历,跨过儒略历独有的闰日时与儒略历相差一天。 例如:700年儒略历为闰年,而 Go 使用的逆推格里高利历中700年不是闰年。 +##### 6. 儒略历独有的闰日(如 700-02-29) + +1582 年以前"能被 100 整除但不能被 400 整除"的年份(如 100、700、1500 年)在儒略历中有 2 月 29 日, +而 Go 的`time.Time`使用逆推格里高利历,没有这一天(`time.Date(700, 2, 29, ...)` 会被规范化成 700-03-01)。本库承认 700-02-29 这一天存在,对应的约束如下: + +- `Time.JulianOnly()`:该农历日是否只存在于儒略历(对应 JSON 字段 `julianOnly`); +- `Time.JDE()`:该日精确的儒略日;儒略历闰日比 `Solar()` 早一天,其余情况两者一致(对应 JSON 字段 `jde`)。 +- `Time.Solar()` / `LunarTime.SolarDate`:库内标准输出,对于700-02-29Go标准库表示不出来的日期,固定返回为**后一天**(700-02-29 的后一天是 700-03-01),与 `basic.JDE2DateByZone` 的约定一致; + +```go +julian, _ := calendar.SolarToLunarByYMD(700, 2, 29) +fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JDE(), julian.Lunar().MonthDay()) +// 0700-03-01 true 1.9767915e+06 二月初五 +``` + +> 涉及这类日期时,不丢闰日的入口有两类:整型年月日入口 `SolarToLunarByYMD` / `LunarToSolarByYMD`,以及直接调用 +> `basic.JDECalc(700, 2, 29)` 得到精确儒略日 `1976791.5`;先构造 `time.Time` 的那一步就会丢掉闰日。 + +##### 7. 同一农历日的多个公历候选 + +改历双纪年(王莽 9–23 年、魏明帝 237–240 年、武则天 689–700 年、唐肃宗 761–762 年)与太初改历交接 +(公元前 104 年)会让同一个农历日对应两个合法公历日。`Solar()` 仍是库内默认选择,`SolarCandidates()` +返回全部候选、首个恒等于 `Solar()`: + +```go +res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false) +fmt.Println(res.Solar().Format("2006-01-02")) +fmt.Println(len(res.SolarCandidates())) +// 0700-12-15 +// 2 +``` + +非改历年份的农历日只有一个候选,`SolarCandidates()` 返回仅含 `Solar()` 的slice;儒略历独有的闰日也只返回 +标准输出,因为它唯一合法的那一天无法表示成 `time.Time`,精确日期见 `JDE()`。 + #### 历法转换 ##### 公历转农历 @@ -272,14 +303,13 @@ weekday := int(calendar.Date2JDE(date)+1.5) % 7 3. `年份+月+日`:如 **`"二零二五年正月初一"`**(闰月前加"闰",适用于现代日期) 4. `年份+月+干支日`:如 **`"二零二五年正月戊戌日"`** 5. `阿拉伯数字+月+日`:可以将中文数字替换为阿拉伯数字,如 **`"2025年1月1日"`**,代表`二零二五年正月初一` -6. **注意历史场景**:历史上月份名称可能与现代不同(如武则天时期“正月”与“一月”代表不同月份),请使用汉字数字确保准确性 +6. 历史场景:历史上月份名称可能与现代不同(如武则天时期“正月”与“一月”代表不同月份),这类场景下月份名称按汉字数字解释 -> ⚠️ **特别提醒**: -> 农历年份与公历年份并非完全重合。例如:公历2025年1月28日(除夕)对应农历2024年腊月二十九,应传入 `"二零二四年腊月廿九"`。 +> ⚠️ 农历年份与公历年份并非完全重合。例如:公历2025年1月28日(除夕)对应农历2024年腊月二十九,对应的字符串是 `"二零二四年腊月廿九"`。 ###### 方式二:传入数字参数 - **参数**:年份 (`int`)、月份 (`int`)、日期 (`int`)、是否闰月 (`bool`) -- **特点**:简单直接,适用于现代农历日期转换 +- **语义**:按农历年、月、日与闰月标志定位日期,适用于现代农历日期转换 ##### 代码示例 @@ -336,6 +366,9 @@ func main() { "ganzhiYear": "己未", "ganzhiMonth": "丙子", "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jde": 1808717.8389814815, "dynasty": "魏", "emperor": "魏明帝", "nianhao": "景初", @@ -355,7 +388,10 @@ func main() { "ganzhiYear": "己未", "ganzhiMonth": "丙子", "ganzhiDay": "辛未", - "dynasty": "", + "calendarSystem": "", + "calendarName": "", + "jde": 1808717.8389814815, + "dynasty": "蜀", "emperor": "蜀后主", "nianhao": "延熙", "yearOfNianhao": 2, @@ -374,6 +410,9 @@ func main() { "ganzhiYear": "己未", "ganzhiMonth": "丙子", "ganzhiDay": "辛未", + "calendarSystem": "", + "calendarName": "", + "jde": 1808717.8389814815, "dynasty": "吴", "emperor": "吴大帝", "nianhao": "赤乌", @@ -450,17 +489,15 @@ fmt.Printf("%d-%02d-%02d %v\n", date.Year(), int(date.Month()), date.Day(), err) - `Altitude`:高度角,地平线为 `0°`,天顶为 `+90°` - `Zenith`:天顶距,天顶为 `0°`,地平线为 `90°` -- **注意:旧版本中 `Zenith` 曾错误返回高度角;当前版本已修正,升级时请重点检查调用方** +- `Zenith` 与 `Altitude` 互补,两者相加为 `90°` #### 日出日落/月出月落 -> ⚠️ **重要说明**: -> 月球升降时间计算基于当天日期,升降时间点之间不一定具有连续性。 +> ⚠️ 月球升降时间按当天日期计算,升降时间点之间不一定具有连续性。 > -> **可能出现的情况**: -> - 月亮可能在凌晨1点落下,中午12点再次升起,此时升起时间会晚于降落时间;要获取此场景晚上的月落时间,需要传入次日日期进行计算 +> 例如月亮可能在凌晨1点落下、中午12点再次升起,此时升起时间会晚于降落时间;这一场景晚上的月落时间对应次日日期。 > -> **如需获取完整升降周期,需要自行通过判断升起时间是否在降落时间之后来确定后续的正确时间点** +> 完整的升降周期由升起时间与降落时间的先后关系决定:判断升起时间是否在降落时间之后,即可确定后续的正确时间点。 ```go package main @@ -509,8 +546,8 @@ func main() { 2020-01-01 17:45:09.188657999 +0800 CST 2020-01-01 18:12:33.624035418 +0800 CST 2020-01-01 11:52:49.860912859 +0800 CST -2020-01-01 17:38:02.510787248 +0800 CST -2020-01-01 23:26:49.313593804 +0800 CST +2020-01-01 17:36:48.811488747 +0800 CST +2020-01-01 23:26:49.313553571 +0800 CST ``` @@ -571,9 +608,9 @@ func main() { 人马座 方位角: 120.19477090015224 高度角: 2.4014437419430097 天顶距: 87.59855625805699 0.983292937163176 -赤经: 23h17m53.15s 赤纬: -10°19′18.57″ +赤经: 23h18m56.24s 赤纬: -10°20′54.42″ 宝瓶座 -方位角: 67.84050700509859 高度角: -45.13425530765482 天顶距: 135.13425530765483 +方位角: 67.63889332004852 高度角: -45.34916937173283 天顶距: 135.34916937173284 404238.6096080479 ``` @@ -649,8 +686,8 @@ north=2026-01-02T08:10:49Z dec=28.266373 south=2026-01-16T05:15:14Z dec=-28.304184 libration lon=-1.278902 lat=-6.531444 pa=-9.967050 bright limb=267.364849 -topo libration lon=-2.010562 lat=-5.912181 pa=-10.184664 -topo bright limb=266.045494 +topo libration lon=-1.736754 lat=-5.780730 pa=-10.072846 +topo bright limb=266.038258 ``` 如果只关心某一时刻地球轨道偏心率,也可以直接调用: @@ -675,7 +712,7 @@ fmt.Println(moon.AscendingNode(nodeDate), moon.DescendingNode(nodeDate)) 以上面 `nodeDate := 2026-01-01 00:00:00 UTC` 的示例来说,输出结果是: ```text -340.9570862454423 160.95708624544227 +340.95708624505863 160.9570862450587 ``` #### 月相 @@ -711,7 +748,7 @@ func main() { 输出结果: ``` -0.300041309608744 // 月面约有 30% 被太阳照亮 +0.30004130960877884 // 月面约有 30% 被太阳照亮 上峨眉月 // 当前月相描述 2020-01-25 05:41:58.271192908 +0800 CST // 下一次朔月 2020-01-03 12:45:23.229190707 +0800 CST // 下一次上弦 @@ -778,21 +815,28 @@ func main() { `SolarEclipsePartialFootprintsInfo` 还给出影锥与地球的全球接触:`P1/P4` 是半影外切,`P2/P3` 是半影内切;`U1/U4` 是本影或反本影外切,`U2/U3` 是内切。某次日食不存在的接触保持 `time.Time` 零值。`CentralBeginOnEarth` / `CentralEndOnEarth` 仍表示影轴进入和离开地球,不等同于 `U1/U4`。 -需要结构化的瞬时中心影轮廓时,可在 `SolarEclipsePartialFootprintOptions` 中设置 `CentralShadowStep`;结果写入 `CentralShadowFootprints`。零值关闭该额外计算,SVG 入口则默认按 10 分钟采样。 +需要结构化的瞬时中心影轮廓时,可在 `SolarEclipsePartialFootprintOptions` 中设置 `CentralShadowStep`;结果写入 `CentralShadowFootprints`。零值关闭该额外计算;SVG 入口同样只在正值时采样(小于一分钟按一分钟),零值或负值都不画。 + +需要在数据层直接取等时线时,可在同一个 `SolarEclipsePartialFootprintOptions` 中设置 `GreatestTimeValues` 或 `GreatestTimeStep`。`GreatestTimeValues []time.Time` 是**食甚时刻取值**,按绝对时刻使用(其 `Location` 不参与换算),最多保留 64 条:重复的时刻取值与偏食可见窗口之外的时刻取值会被跳过,其余按时间先后排序,超出时保留最早的 64 条;没有可用支路的时刻取值不会出现在结果里。它为空时改用 `GreatestTimeStep` 按间隔生成,间隔只在为正值时生效,且对齐到 UTC 整刻度;要按展示时区对齐,请自行生成时刻后传给 `GreatestTimeValues`。 + +结果写入 `SolarEclipsePartialFootprintsInfo.GreatestTimeContours`:`JDE` 是对应的力学时儒略日,`Time` 是该时刻取值在输入时区下的时刻(显式传入的时刻取值原样回显,按步长生成时由 `JDE` 换算并抹到毫秒,避免往返把整分截断成前一分钟),`Segments` 是该时刻的等时线支路。等时线只出现在日月盘面确有重叠且太阳在几何地平以上(不含蒙气差与半径修正)的地方,两端止于地平线或偏食可见域边界;纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路。不请求时既有输出完全不变。 日食结果 `SolarEclipseInfo`、`LocalSolarEclipseInfo`,以及 `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` 里的 `Eclipse` 字段还会附带沙罗序列信息: - `HasSaros`:是否成功匹配到沙罗序列 -- `Saros.Series`:NASA 沙罗系列号 +- `Saros.Series`:`Verified=true` 时为 NASA 沙罗系列号,否则为推算的暂定系列号 - `Saros.Member`:这次日食在该系列中的第几个成员,从 `1` 开始 - `Saros.Count`:该沙罗系列的总成员数 +- `Saros.Verified`:是否已与内置权威目录锚点核验;扩展表或范围外推算结果为 `false` 说明: -- 沙罗周期约为 `6585.321` 天,也就是 `223` 个朔望月,常写作约 `18 年 11 天 8 小时`。经过一个沙罗周期后,太阳、地球、月球的相对几何关系接近重复,因此会出现性质相近的一次日食。 +- 沙罗周期约为 `6585.321` 天,也就是 `223` 个朔望月、约 `18 年 11 天 8 小时`;系列成员按此周期排列。 - 沙罗系列是一组按沙罗周期连续排列的日食事件;`Series` 标识该组,`Member` / `Count` 表示当前事件在该组中的序号和总数。 - 沙罗序列属于整场日食事件,不随观测地点改变,所以全局日食、站心日食、中心路径和偏食足迹中的对应值应当一致。 -- 例如 `2024-04-08` 北美日全食属于 `Solar Saros 139` 的第 `30/71` 个成员。 +- 内置 NASA 锚点优先使用正式编号;天文年份 `-3000` 至 `+6000` 年内(含首尾年,`0` 年为公元前 1 年)未被锚点覆盖的事件使用预计算扩展表,范围外才实时演算外推。预计算与实时推算结果的 `Verified` 都是 `false`,不应视为实际已发布编号。 +- 扩展编号沿用 NASA 的 [Saros/Inex 编号关系](https://eclipse.gsfc.nasa.gov/SEsaros/SEperiodicity.html),成员按 Split-K 模型计算,计数覆盖完整系列,不在预计算年份边界截断。`3288-11-15` 的推算结果为系列 `202`、第 `1/71` 个成员。 +- 例如 `2024-04-08` 北美日全食属于 `日食沙罗序列139` 的第 `30/71` 个成员。 ##### 与 NASA 资料的时间对照 @@ -813,11 +857,11 @@ func main() { - 全局日食资料通常给到秒,适合直接做秒级对照。 - 很多站心日食页面的初亏、复圆和本地食甚只公开到整分钟,因此这类资料只按分钟级核对,公开资料舍入造成的残差不按秒级误差解读。 -- 下面的 2009 洋山和 2012 厦门示例主要展示接口调用和 SVG 输出;如果要把某个具体观测点的接触时刻用于正式发布,建议再拿该点的 NASA/IMCCE local circumstances 做逐项核对。 +- 下面的 2009 洋山和 2012 厦门示例只展示接口调用与 SVG 输出,未承诺地方接触时刻的发布级精度;需要逐项核对时可与 NASA/IMCCE local circumstances 比对。 ##### 2009 年长江大日食:长江口洋山附近 -2009-07-22 是国内常说的“长江大日食”。下面示例选用上海东南方长江口洋山附近的观测点,接近中心线,食甚时日月中心非常接近,全食持续约 5 分 57 秒。 +2009-07-22 “长江大日食”。下面示例选用上海东南方长江口洋山附近的观测点,接近中心线,全食持续约 5 分 57 秒。 ```go package main @@ -863,15 +907,15 @@ func main() { ```text true total // 洋山站点当天命中日食,食型为日全食 -true {136 37 71} // Solar Saros 136,第 37/71 个成员 -2009-07-22 08:23:54.85276848 +0800 CST // 初亏 -2009-07-22 09:37:22.978325486 +0800 CST // 全食开始 -2009-07-22 09:40:20.771768689 +0800 CST // 食甚 -2009-07-22 09:43:19.611152708 +0800 CST // 全食结束 -2009-07-22 11:03:13.974365293 +0800 CST // 复圆 -5m56.632827222s // 全食持续时间 +true {136 37 71 true} // Solar Saros 136,第 37/71 个成员,已核验 +2009-07-22 08:23:54.852366149 +0800 CST // 初亏 +2009-07-22 09:37:22.978486418 +0800 CST // 全食开始 +2009-07-22 09:40:20.771366357 +0800 CST // 食甚 +2009-07-22 09:43:19.610750377 +0800 CST // 全食结束 +2009-07-22 11:03:13.974526226 +0800 CST // 复圆 +5m56.632263959s // 全食持续时间 magnitude=1.076997 obscuration=1.000000 altitude=57.292 // 食分、遮掩比例、食甚太阳高度 -greatest lon=144.1177 lat=24.2193 width=258.3km center=268 // 全局食甚点经纬度、食带宽度、中心线采样点数 +greatest lon=144.1177 lat=24.2193 width=258.3km center=289 // 全局食甚点经纬度、食带宽度、中心线采样点数 ``` ##### 2012 年日环食:厦门示例 @@ -909,13 +953,13 @@ func main() { ```text true annular // 厦门站点当天命中日食,食型为日环食 -true {128 58 73} // Solar Saros 128,第 58/73 个成员 -2012-05-21 05:08:12.683185637 +0800 CST // 初亏 -2012-05-21 06:08:15.570583641 +0800 CST // 环食开始 -2012-05-21 06:10:25.164288282 +0800 CST // 食甚 -2012-05-21 06:12:34.763746261 +0800 CST // 环食结束 -2012-05-21 07:20:55.029697716 +0800 CST // 复圆 -4m19.19316262s // 环食持续时间 +true {128 58 73 true} // Solar Saros 128,第 58/73 个成员,已核验 +2012-05-21 05:08:12.683024704 +0800 CST // 初亏 +2012-05-21 06:08:15.570422708 +0800 CST // 环食开始 +2012-05-21 06:10:25.156724452 +0800 CST // 食甚 +2012-05-21 06:12:34.764188826 +0800 CST // 环食结束 +2012-05-21 07:20:55.029536783 +0800 CST // 复圆 +4m19.193766118s // 环食持续时间 magnitude=0.933290 obscuration=0.872480 altitude=9.567 // 食分、遮掩比例、食甚太阳高度 ``` @@ -1007,8 +1051,8 @@ func main() { true 13460 // 洋山日全食 SVG 生成成功,长度 13460 字节 true 13377 // 厦门日环食 SVG 生成成功,长度 13377 字节 true total // 北京站点当天命中日食,食型为日全食 -true {145 23 77} // Solar Saros 145,第 23/77 个成员 -1m33.329527975s // 北京市区近似坐标下的全食持续时间 +true {145 23 77 true} // Solar Saros 145,第 23/77 个成员,已核验 +1m33.329527974s // 北京市区近似坐标下的全食持续时间 true 13424 // 北京日全食 SVG 生成成功,长度 13424 字节 ``` @@ -1025,7 +1069,7 @@ true 13424 // 北京日全食 SVG 生成成功,长度 13424 字节 本库的月食判断与搜索能力统一放在 `eclipse` 包,返回结果会保持传入 `time.Time` 的时区。 常用接口: -- `LunarEclipseOnDate`:判断某个当地日期是否与整场月食重叠 +- `LunarEclipseOnDate`:判断某个当地日期是否有月食 - `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`:搜索全局月食 - `LocalLunarEclipseOnDate`:判断某地当天是否能看到可见月食 - `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`:搜索某地可见月食 @@ -1043,23 +1087,25 @@ true 13424 // 北京日全食 SVG 生成成功,长度 13424 字节 其中 `Saros` 的含义与日食部分相同: -- `Saros.Series`:NASA 月食沙罗系列号 +- `Saros.Series`:`Verified=true` 时为 NASA 月食沙罗系列号,否则为推算的暂定系列号 - `Saros.Member`:这次月食在该系列中的第几个成员,从 `1` 开始 - `Saros.Count`:该沙罗系列的总成员数 +- `Saros.Verified`:是否已与内置权威目录锚点核验;扩展表或范围外推算结果为 `false` -例如 `2028-12-31 / 2029-01-01` 这次跨年月全食属于 `Lunar Saros 125` 的第 `49/72` 个成员。 +月食同样优先使用 NASA 锚点,天文年份 `-3000` 至 `+6000` 年内查扩展表,范围外才实时演算。推算成员按 Danjon 与 Chauvenet 检出的事件并集计数,因此不随调用的月食模型或观测地点改变;极浅成员可能与 NASA 目录不同,`Verified` 保持 `false`。 +例如 `2028-12-31 / 2029-01-01` 这次跨年月全食属于 `月食沙罗序列125` 的第 `49/72` 个成员。 当前同时保留两套地影放大口径: -- **Danjon(默认,推荐)**:只对月球水平视差项乘 `1.01`,再与太阳视半径、太阳视差组合求影半径。NASA GSFC 当前月食目录与图页采用的也是这一路线,本库默认的 `LunarEclipseOnDate`、`LastLunarEclipse`、`NextLunarEclipse`、`ClosestLunarEclipse` 都使用它。 +- **Danjon(默认)**:只对月球水平视差项乘 `1.01`,再与太阳视半径、太阳视差组合求影半径。NASA GSFC 当前月食目录与图页采用的也是这一路线,本库默认的 `LunarEclipseOnDate`、`LastLunarEclipse`、`NextLunarEclipse`、`ClosestLunarEclipse` 都使用它。 - **Chauvenet(兼容口径)**:先取 `0.99834 × 地球赤道半径`,再把整组影半径统一乘 `51/50`。这与传统旧历表口径更接近,适合做兼容性回归和旧结果对照。 两者的直接差异通常表现为: - `Chauvenet` 给出的半影和本影都更大,半影食分通常比 `Danjon` 多约 `0.025`,本影食分通常多约 `0.005` - 对边界月食而言,`Chauvenet` 更容易把结果推向“更深”的食型 -- 若目的是与 NASA 目录、现代星历软件或当前主流月食资料对照,优先使用默认的 `Danjon` -- 若目的是兼容既有历史基线,可显式调用 `Chauvenet` +- 与 NASA 目录、现代星历软件或当前主流月食资料对照时,对应的是默认的 `Danjon` +- 兼容既有历史基线时,对应的是显式调用的 `Chauvenet` ##### 代码示例 @@ -1105,16 +1151,16 @@ func main() { ```text total -true {125 49 72} +true {125 49 72 true} 2028-12-31 16:52:05.566135346 +0000 UTC -2.273989043382249 1.2461142882946992 +2.2739890433790566 1.2461142882915068 2028-12-31 14:03:54.219463169 +0000 UTC 2028-12-31 15:07:42.115980684 +0000 UTC 2028-12-31 16:16:27.24464178 +0000 UTC 2028-12-31 17:27:46.214954853 +0000 UTC 2028-12-31 18:36:32.251235246 +0000 UTC 2028-12-31 19:40:11.52023971 +0000 UTC -2.2996033397593934 1.2511710895700923 +2.2996033397562012 1.2511710895669002 true total 2029-01-01 00:52:05.566135346 +0800 CST @@ -1158,6 +1204,8 @@ total ##### 月食 SVG +`LunarEclipseSVG`、`LunarEclipseDetailedSVG` 与 `LunarEclipseMapSVG` 的默认模型与后缀入口口径见下文[全球见食图 SVG](#全球见食图-svg)。 + 默认月食 SVG 头部会自动带上沙罗序列;如果需要自定义更多文字,可以通过 `LunarEclipseSVGOptions` 覆写: - `Title`:主标题 @@ -1301,13 +1349,28 @@ func main() { ```text 进贤增九 total -2025-06-05 19:14:01.095 CST 2025-06-05 20:02:06.357 CST 2025-06-05 20:50:10.740 CST +2025-06-05 19:14:01.062 CST 2025-06-05 20:02:06.296 CST 2025-06-05 20:50:10.697 CST altitude=75.561 visible=true -2025-06-05 17:45:28.498 CST 2025-06-05 20:02:06.332 CST 2025-06-05 22:18:49.977 CST -greatest=121.566021 6.807046 width=3582.4km center=108 +2025-06-05 17:45:28.475 CST 2025-06-05 20:02:06.300 CST 2025-06-05 22:18:49.945 CST +greatest=121.566140 6.807079 width=3582.4km center=108 ``` -`OccultationSearchOptions` 的零值使用默认搜索步长和安全余量;`MaxEvents > 0` 限制返回数量。`OccultationPathOptions.Step` 控制基础时间采样,`TargetSpacingKM` 按地面距离自适应加密中心线;过密请求超出确定性预算时返回 `ErrOccultationPathSamplingLimit`。 +`OccultationSearchOptions` 的零值使用默认搜索步长和安全余量;`MaxEvents > 0` 限制返回数量。`OccultationPathOptions.Step` 控制基础时间采样,`TargetSpacingKM` 按地面距离自适应加密中心线;过密请求超出确定性预算时返回 `ErrOccultationPathSamplingLimit`。`RiseSetStep` 独立控制初掩、掩甚、终掩分别发生在月升/月落时的六类阶段线,零值使用 5 分钟;`DisableRiseSet` 可跳过这些阶段线。`DisableFootprints` 跳过体积较大的密集瞬时可见区要素,改用稀疏支撑样本合并成紧凑掩带;中心线、边界和六类升落阶段线仍保留,适合普通 GeoJSON 地图(首次渲染需合并一次,重复渲染走缓存)。`GreatestLimitSeparationKM` 是掩甚处南北限的地面间距,掩星图与详细版的"掩带宽"用它标注,与 `Greatest.WidthKM` 口径不同、不可互换。`IncludeFootprintTimeline` 可在紧凑掩带之外保留按 `FootprintTimelineStep` 采样的瞬时足迹,供时间轴选择当前时刻的可见区域。 + +`OccultationPathOptions.Algorithm` 控制恒星和行星全球路径的星历分支:零值或 `moon.OccultationPathAlgorithmOptimized` 默认使用经抽检的 30 分钟节点矢量插值,保留现有站心方程、连续包络和升落曲线;`moon.OccultationPathAlgorithmExact` 保留原有分支,候选可使用插值,最终求解仍使用全项星历。优化分支在抽检不合格时回退到原分支,超出插值时间窗时使用精确星历。抽检不是全时段严格误差证明;两个分支的几何目标相同,但不保证采样点或 GeoJSON 字节完全相同。此选项不影响仅查询事件、指定站点接触或独立单时刻月影接口,也不影响日月食。 + +两个分支的全球起止、掩甚标记和中心线宽度均保留全项星历计算。绘图时应传入完整返回路径,包括可见性轮廓;丢弃该轮廓会调用历史瞬时足迹回退逻辑,其边界不能替代完整解析可见集。 + +路径中的 `BandContours` 是静态掩带的接触包络,`VisibilityContours` 是月亮处于地平线以上时的可见时间包络;两者与 `Footprints` 的瞬时采样分别承担静态边界、可见性边界和时间轴细节,不应互相替代。 + +`OccultationPathOptions.GreatestTimeValues` / `GreatestTimeStep` 请求**掩甚时刻等时线**。与日食不同,`GreatestTimeValues []float64` 给的是力学时儒略日,最多保留 64 条(先去掉重复的时刻取值,按时间先后排序,超出时保留最早的 64 条),掩可见窗口之外或没有可用支路的时刻取值不会出现在结果里;它为空时改用 `GreatestTimeStep`,同样只在为正值时生效,且对齐到 UTC 整刻度。结果写入 `StarOccultationPath.GreatestTimeContours`(行星路径是同名字段),元素类型 `OccultationGreatestTimeContour` 的 `JDE`、`Time`、`Segments` 与日食同义:`Time` 在按步长生成时是原始对齐时刻,显式给出的时刻取值则由 `JDE` 换算并抹到毫秒,两者都落在 UTC 时区,而支路点的时刻仍按路径时区;日食公共层的 `Time` 则直接落在输入时区。边界口径同样一致:只出现在目标盘面与月面确有重叠且月亮在几何地平以上(不含蒙气差与半径修正)的地方,两端止于地平线或掩可见域边界,纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路;不请求时既有输出不变。 + +```go +options := moon.OccultationPathOptions{ + Algorithm: moon.OccultationPathAlgorithmExact, // 显式选择原分支;省略时使用优化分支 + DisableFootprints: true, +} +``` #### 行星月掩 @@ -1346,14 +1409,14 @@ func main() { ```text Saturn total true -2025-02-01 11:29:09.692 CST -2025-02-01 11:29:40.042 CST -2025-02-01 12:00:48.729 CST -2025-02-01 12:32:46.388 CST -2025-02-01 12:33:18.285 CST +2025-02-01 11:29:09.710 CST +2025-02-01 11:29:40.069 CST +2025-02-01 12:00:48.747 CST +2025-02-01 12:32:46.415 CST +2025-02-01 12:33:18.312 CST ``` -`FindPlanetOccultationPaths` 的全球结果同时包含任意圆盘重叠的部分掩区域和整颗行星被遮住的全掩区域。`HasTotalBand` 表示是否存在全掩带,`GreatestTotalWidthKM` 是掩甚处全掩带宽;中心线、边界和瞬时足迹都带采样时刻。 +`FindPlanetOccultationPaths` 的全球结果同时包含任意圆盘重叠的部分掩区域和整颗行星被遮住的全掩区域。`HasTotalBand` 表示是否存在全掩带,`GreatestTotalWidthKM` 是掩甚处全掩带宽;中心线、边界和启用时的瞬时足迹都带采样时刻。 #### 月掩 SVG @@ -1372,33 +1435,19 @@ localSVGs, err := moonsvg.FindLocalStarOccultationSVGs( moonsvg.LocalStarOccultationSVGOptions{Width: 920, Height: 700, Location: cst}, ) fmt.Println(err, len(localSVGs)) - -globalSVGs, err := moonsvg.FindStarOccultationSVGs( - start, end, target, - moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, - moonsvg.StarOccultationSVGOptions{ - Width: 1200, Height: 800, Location: cst, - TimeLabelStep: 30 * time.Minute, - }, -) -fmt.Println(err, len(globalSVGs)) ``` -本地图按指定观测者的站心几何绘制。全球图使用 Natural Earth `1:50m` 海岸线,不含行政边界;默认每 30 分钟在中心线上标记 `HH:MM`,高纬事件可自动切换极区投影。 - -下面两张图沿用前文 `2025-06-05` 月掩进贤增九(HR 4799)的样例。局地图的观测点为 `121.56601°E, 6.80706°N`,靠近全球几何掩甚点;图中的掩始、掩甚和掩终是该地点实际看到的站心接触时刻,并同时给出月面方向、白道、月高、方位和地平可见性。 +本地图按指定观测者的站心几何绘制,下图沿用前文 `2025-06-05` 月掩进贤增九(HR 4799)的样例。局地图的观测点为 `121.56601°E, 6.80706°N`,靠近全球几何掩甚点;图中的掩始、掩甚和掩终是该地点实际看到的站心接触时刻,并同时给出月面方向、白道、月高、方位和地平可见性。 ![2025 月掩进贤增九指定地点见掩图](doc/lunar-occultation-hr4799-2025-06-05-local.svg) -全球图展示同一事件的掩带边界、可见/几何中心线、全球阶段点以及每 30 分钟的中心线时间标记。全球掩始和掩终表示月影首次接触和最后离开地球,并不是上述指定地点的接触时刻。 - -![2025 月掩进贤增九全球见掩图](doc/lunar-occultation-hr4799-2025-06-05-global.svg) - -### 天象地图与 GeoJSON +### 天象图与 GeoJSON #### 全球见食图 SVG -`eclipse/svg` 可直接生成日食和月食全球图。日食图绘制完整偏食可见区、全食/环食中心带、中心线、全球阶段信息和中心线时间标记;同时显示食甚时的晨昏圈与日下点、影轴进出地球点、`P1-P4/U1-U4` 接触点、定时半影轮廓和本影/反本影轮廓。月食图绘制 P1/P4 可见半球、月出/月落过渡区和整场可见区。 +`eclipse/svg` 可直接生成日食和月食全球图。日食图绘制完整偏食可见区、全食/环食中心带、中心线、全球阶段信息和中心线时间标记;同时显示初亏/食甚/复圆的日升日落线、太阳直射点、影轴进出地球点、`P1-P4/U1-U4` 接触点,以及默认关闭、按需打开的定时半影轮廓和本影/反本影轮廓。月食图绘制 P1/P4 可见半球、月出/月落过渡区和整场可见区。 + +`LunarEclipseDetailedSVG` 把上述两类月食图合成详细版式的一页:居中摘要(食甚、半影/本影食分、伽马、半影/本影半径、月距、沙罗序列)、左右两侧的日月地心坐标块、穿影示意图、历时 / 弧分比例尺 / 接触时刻三栏,以及下方的世界可见性底图与图例。地影几何由 `basic.LunarEclipseShadowGeometryAt` 给出,其中 **Gamma 用地球赤道半径、半影/本影半径用度**,换成地球半径要乘以月球处的地球视差。 ```go package main @@ -1417,9 +1466,7 @@ func main() { time.Date(2009, 7, 22, 12, 0, 0, 0, cst), eclipsesvg.SolarEclipseMapSVGOptions{ Width: 1200, Height: 800, Location: cst, - TimeLabelStep: 30 * time.Minute, - PenumbralOutlineStep: 60 * time.Minute, - CentralShadowStep: 10 * time.Minute, + TimeLabelStep: 30 * time.Minute, }, ) if ok { @@ -1433,21 +1480,56 @@ func main() { if ok { _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-global.svg", []byte(lunar), 0o644) } + + detailed, ok := eclipsesvg.LunarEclipseDetailedSVG( + time.Date(2029, 1, 1, 0, 0, 0, 0, cst), + eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst}, + ) + if ok { + _ = os.WriteFile("doc/lunar-eclipse-2029-01-01-detailed.svg", []byte(detailed), 0o644) + } } ``` -日食图右侧事件表按时间列出可用的 `P1-P4/U1-U4`、影轴进出地球和食甚时刻,空间允许时同时显示接触点经纬度;摘要还包含沙罗序列、食带宽、食甚点太阳高度/方位和中心食持续时间。图中的橙色虚线是带 `HH:MM` 标记的瞬时半影边界,灰色虚线是食甚时晨昏圈,棕色实线是本影或反本影瞬时轮廓。 +图上的橙色长虚线是初亏/食甚/复圆分别发生在日出和日落时的六类阶段线;**瞬时半影与本影轮廓默认不画**,需要时用正的 `PenumbralOutlineStep` / `CentralShadowStep` 打开。紫色短虚线是 `MagnitudeValues` 指定的地方最大食分等值线(默认 0.2/0.4/0.6/0.8),蓝色实线是食甚时刻等时线。 -`PenumbralOutlineStep` 与 `CentralShadowStep` 的零值分别使用 60 分钟和 10 分钟,负值关闭对应轮廓;显式设置仍可使用 30 分钟等其他间隔。`TimeLabelStep` 的零值为 30 分钟,负值关闭中心线时刻标记。 +**食甚时刻等时线**(蓝色实线)默认不画:同一条线上的地点在同一时刻看到食甚,需要时用正的 `GreatestTimeStep` 打开,NASA 全球图的间隔是 30 分钟。这里的 `GreatestTimeStep` 属于 SVG 层,按**展示时区**(`Location`)对齐整刻度,与数据层的 UTC 对齐不同;`eclipse/svg` 不提供显式时刻取值入口,需要别的对齐刻度时请直接调用数据层并把时刻传给 `GreatestTimeValues`。 + +```go +solar, _ := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: cst, + GreatestTimeStep: 30 * time.Minute, +}) +``` + +它不是在经纬度网格上逐点求食甚再描等值线,而是固定时刻后求解 `∂(日月中心角距²)/∂t = 0` 的零集,再沿曲线延拓,因此成本正比于曲线长度而不是可见域面积。等时线只画在日月盘面确有重叠且太阳在几何地平以上(不含蒙气差与半径修正)的地方,每条支路止于地平线或偏食可见域边界;纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路。 + +`TimeLabelStep` 的零值为 30 分钟,负值关闭中心线时刻标记。`GreatestTimeStep` 的零值与负值都不画食甚时刻等时线(与核心层、`moon/svg` 一样必须显式请求),正值按展示时区对齐、小于一分钟时按一分钟处理,单次最多生成 64 条;30 分钟是 NASA 全球图的推荐间隔。`MagnitudeValues` 为 nil 时使用 0.2/0.4/0.6/0.8,显式空切片关闭,非空切片按给定电平绘制。 + +`SolarEclipseMapSVGOptions.EventsTitle` 覆盖“全球阶段”数据块的标题(该块给出食甚经纬度与地球范围的中心食始/终),为空时使用本地化默认标题;`MapTitle` 与 `Title` 分别覆盖地图分区标题与主标题。 + +日食图的画布下限是 **800×560**:宽度小于 800 或高度小于 560 时按文档回落到 960×640,更窄的横版画布上地图框会与右栏数据网格水平重叠、面板行距压到 1 px 以下。`PartialStep` 小于两分钟时按两分钟处理:偏食区填充是瞬时足迹的并集,成本随采样数成倍增长,而并集必须由一整条自洽的扫描序列生成,更密的请求不改变产物(1 秒步长实测 36.8 s / 951 MB,夹取后为 1.1 s / 30 MB,与默认请求逐字节相同)。月食详细版式按 `Height` 推导版面:640×420 与 800×600 容不下示意图与底图的下限而返回 `false`,1000×1414 与 1414×1000 正常出图。 + +`eclipse/svg` 的三个无后缀月食入口(`LunarEclipseSVG`、`LunarEclipseDetailedSVG`、`LunarEclipseMapSVG`)使用同一个默认模型:以 Danjon 为主,极浅半影按核心默认口径回退 Chauvenet,与 `LunarEclipseOnDate` 一致;带 `Danjon` / `Chauvenet` 后缀的入口强制指定模型。 + +可降级的图层用 `data-source` 标注实际几何来源,取值词表见 `eclipse/svg` 包注释:`partial-band-union`、`sampled-footprint-sweep`、`partial-band-contours`、`rise-set-phase-lines`、`magnitude-contours`、`greatest-time-isochrones`、`besselian-critical-envelope`、`paired-limit-chords`、`sampled-open-sweep`、`central-path-limits`、`penumbral-outlines`、`central-shadow-outlines`、`p1-p4-visibility-regions`、`p1-p4-horizon-boundaries`。`PenumbralOutlineStep` 与 `CentralShadowStep` **默认关闭**(零值或负值都不画瞬时半影/本影轮廓,它们会把地球盖住,NASA 全球图也没有这两族),正值给出采样间隔,小于一分钟时按一分钟。 日食和月掩的自动投影会在适合时选择北极或南极图;月食默认使用等经纬投影。投影仅影响 SVG 表达,不改变底层 WGS84 地理结果。 -日月食通过 `EclipseMapProjectionEquirectangular`、`EclipseMapProjectionNorthPolar`、`EclipseMapProjectionSouthPolar` 强制投影;月掩使用对应的 `MapProjection...` 常量。 +日月食通过 `EclipseMapProjectionEquirectangular`、`EclipseMapProjectionNorthPolar`、`EclipseMapProjectionSouthPolar` 强制投影;月掩使用对应的 `MapProjection...` 常量。`EclipseMapProjectionOrthographic` 给出 NASA 版式的**正射球面图**:视点取食甚点,只画朝向视点的半个地球,投影边界就是可见半球的大圆。 + +球面图不需要新增数据,也不需要第三方投影库:陆地由内置的等经纬底图在运行时反解回经纬度再正射投影,视界裁剪在地理坐标上按大圆求交、并沿视界弧补齐被切断的环;经纬网按球面采样后同样裁剪。 + +正射投影同时切换成 **NASA 摆法**的版式:球面居中放大,比例尺排在球面正下方,阶段信息改为三栏面板(半影接触 / 食甚点地方情况 / 本影接触),图例与页脚依次向下;其他投影保持原有版式。代价是 `1000×1414` 画布下整幅图约 1.0 s(等经纬图约 0.85 s),一个同尺寸的球面图 SVG 约 530 KB;耗时为单机实测参考值,绝对值因机器而异。 下面的全球图沿用前文局地 SVG 的事件日期。2009 长江大日食、2012 厦门日环食和 2035 北京日全食使用等经纬投影: ![2009 长江大日食全球见食图](doc/solar-eclipse-yangshan-2009-global.svg) +同一场日食的正射球面版式(`EclipseMapProjectionOrthographic`): + +![2009 长江大日食正射球面图](doc/solar-eclipse-yangshan-2009-globe.svg) + ![2012 厦门日环食全球见食图](doc/solar-eclipse-xiamen-2012-global.svg) ![2035 北京日全食全球见食图](doc/solar-eclipse-beijing-2035-global.svg) @@ -1460,6 +1542,58 @@ func main() { ![2029 跨年月全食全球可见图](doc/lunar-eclipse-2029-01-01-global.svg) +月食还有把上面两类图合二为一的详细版式:居中摘要(食甚、半影/本影食分、伽马、半影/本影半径、月距、沙罗序列)、左右两侧的日月地心坐标块、穿影示意图、历时 / 弧分比例尺 / 接触时刻三栏,以及下方的世界可见性底图与图例,一页 `1000x1414`: + +![2029 跨年月全食详细版式](doc/lunar-eclipse-2029-01-01-detailed.svg) + +#### 月掩详细版式 SVG + +`moon/svg` 的详细版式把整场月掩合成一页 `1000x1414`:居中摘要、日月与目标天体的地心/站心数据块、一张**正射球面**的全球掩带图(南北限、可见/几何中心线、掩甚点、初掩/掩甚/终掩阶段点与 30 分钟时间标记),以及页脚说明。球面视点取事件中心,只画朝向视点的半球,这一版式固定用正射球面,不接受其它投影;只需要单独的全球掩带地图时用前文“月掩 SVG”的 `StarOccultationPathSVG` / `FindStarOccultationSVGs`。页内数据分为月亮地心坐标、目标天体、掩带路径点、接触时刻、历表与常数、天平动六块;横版画布把数据块排在地图右侧两栏三行,竖版把数据块排在球面下方三栏两行。下图是 `2025-06-05` 月掩 HR 4799: + +```go +package main + +import ( + "os" + "time" + + "b612.me/astro/moon" + moonsvg "b612.me/astro/moon/svg" +) + +func main() { + cst := time.FixedZone("CST", 8*3600) + star := moon.StarCoordinate{ + ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18, + } + paths, err := moon.FindStarOccultationPaths( + time.Date(2025, 6, 5, 0, 0, 0, 0, cst), + time.Date(2025, 6, 6, 0, 0, 0, 0, cst), + star, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err == nil && len(paths) > 0 { + detailed, renderErr := moonsvg.StarOccultationDetailedSVG( + paths[0], star, + moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: cst}, + ) + if renderErr == nil { + _ = os.WriteFile("doc/lunar-occultation-hr4799-2025-06-05-detailed.svg", []byte(detailed), 0o644) + } + } +} +``` + +![2025 月掩 HR 4799 详细版式](doc/lunar-occultation-hr4799-2025-06-05-detailed.svg) + +页内的球面掩带图使用 Natural Earth `1:50m` 海岸线,不含行政边界。在 `moon.OccultationPathOptions` 上设置 `GreatestTimeStep` 会额外请求**掩甚时刻等时线**,含义与日食图上的蓝色等时线相同:固定时刻后求角距导数的零集并沿曲线延拓。它同样是可选项,不设置时输出不变。掩星全球可见窗口通常只有数小时(本页 HR 4799 样例为 4 小时 33 分),常用间隔比日食更密,为 15–30 分钟量级,间隔越大掩带上的等时线越少;点源恒星按日月中心角距定食甚,有限盘面行星按外接触度量,分别与库内 `StarOccultationInfo.Greatest`、`PlanetOccultationInfo.Greatest` 同口径。`moon/svg` 自己不提供等时线开关,只绘制路径结果里已有的 `GreatestTimeContours`,因此请求必须在计算路径时通过 `OccultationPathOptions` 提出,线的位置也由核心口径决定(`GreatestTimeStep` 对齐 UTC 整刻度);按展示时区对齐的入口,是把时刻换算成力学时儒略日后传给 `GreatestTimeValues`。全球掩始和掩终表示月影首次接触和最后离开地球,与指定地点的接触时刻无关。 + +- 画布与错误:详细版最小 `480x320`,并按画布推导版式,地图与数据块放不下时返回 `ErrInvalidOccultationDetailedSVGOptions`(`800x600`、`1000x1414`、`1414x1000` 均可出图,`640x420`、`900x400` 会被拒绝);单独的全球掩带地图最小 `640x480`,更小的画布返回 `ErrInvalidStarOccultationSVGOptions`。 + +图上标注的“掩带宽”是掩甚处南北限的地面间距 `GreatestLimitSeparationKM`(本例约 `3666.6 km`),与中心线横向宽度 `Greatest.WidthKM`(约 `3582.4 km`)口径不同、不可互换。掩甚时刻等时线要在路径层显式请求 `OccultationPathOptions.GreatestTimeStep`;使用 `DisableFootprints` 的紧凑掩带首次渲染会合并一次,之后同一路径走缓存。详细版式与固定地点图见前文“月掩 SVG”。 + #### GeoJSON `geojson` 接收已经计算好的日食、月食或月掩结果,返回 `[]byte`。这段字节是完整的 UTF-8 RFC 7946 `FeatureCollection` JSON,不是图片,也不是压缩数据,可以直接写入 `.geojson`、交给 `encoding/json`,或发送给前端地图组件。 @@ -1516,6 +1650,20 @@ func main() { 坐标统一为 WGS84 经度、纬度,跨反经线的线和面会拆分。带时路径的 `times` 属性与各段坐标逐点对齐;`WithTimeMarkers` 另加 `role=time-marker` 的 Point Feature,本地化 `label` 用于显示,`time` 始终是 UTC RFC 3339。 +单时刻原语(拖动时间轴、"停下即精确")与站心搜索跨度: + +- `eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{...})` 返回可复用句柄;`ShadowAt(time.Time)`(按 UTC 解释)或 `ShadowAtJDE(jdeTT)`(TT 语义)取该时刻的**全球本影足迹**,`StationStateAt` / `StationStateAtJDE` 取该时刻、该站点的**站心日月几何**(食分、遮蔽率、站心角距、日月视半径、太阳高度/方位、是否处于全食/环食)。两者都只算这一件事,不产生可见带、食分线、升落边界、南北界或中心线;本影不在地球上时返回空/零值而不是错误。 +- `geojson.MarshalSolarEclipseShadowInstant(instant)` 只输出该时刻的阴影区域,以及被地平线切断时的物理边界;属性含 `time`、`source_boundary_closed`、`geometry_role`、`closure`、`delta_t_seconds`、`model`、`interp_signature`。本影用 `central-shadow-footprint` + `central-shadow-boundary`;把 `Kind` 设为 `SolarEclipseShadowPenumbra` 则输出半影(偏食区),角色为 `partial-footprint` + `partial-footprint-boundary`,默认参数与整包偏食采样一致(96 点 + 200 km 加密),因此同一时刻的结果与采样逐点一致(约 1e-12 度差)。没有阴影时返回空 FeatureCollection。 +- 采样的 `partial-footprint` 也带 `source_boundary_closed`、`geometry_role`、`closure` 与 `interp_signature`;被地平线切断的序列端点补到地平圈擦地点,未补齐时该处的端点偏差为 `36–41 km` 量级。掩带的填充提示仍沿用旧封口,避免端点外扩改变极区面归属。 +- 实测成本(原生构建,单机参考值,绝对值因机器而异):单时刻足迹(96 点)约 **64 µs**,站心瞬时约 **20 µs**;公开句柄构造只是夹取选项(≈0),首次查询时按最近朔月构造内部状态约 39 µs、锚点查询约 130 µs,随后按事件缓存;批处理约 75 µs/时刻。 +- ΔT:`DeltaTSeconds` 显式指定时只作用于该句柄,且只改变地球自转相位——同一 TT 的几何不变,地面足迹沿经度平移 `0.4651·|ΔΔT|·cos(纬度)` 千米(见 `basic.DeltaTGroundShiftKM`);`<=0` 时使用进程级模型。两种情况下结果都回传实际使用的 ΔT。库不附带 ΔT 不确定度模型,请用该函数把外部的 ΔT 标准差换算成几何不确定度。 +- 插值:整包的 `central-shadow-footprint` 与该时刻的单时刻导出都带 `interp_signature`(形如 `umbra-closed-seg1-pt97`,由物理边界的顶点数/分段数/闭合标志与绕极标志给出),**相同**的相邻时刻才适合按顶点插值;`closed` 翻转、段数变化(换日线拆分)、顶点数变化、空↔非空(U1/U4 附近)时必须改取精确几何。实测 2 分钟步长下中段质心移动 78–232 km,端点附近可达约 520 km。 +- 批量:`ShadowBetween(start, end, step)` / `StationStatesBetween(...)` 按时间轴对齐返回整段,没有阴影的时刻是空条目。 +- 掩星的单时刻足迹(`moon.StarOccultationFootprintAt` / `moon.PlanetOccultationFootprintsAt` → `geojson.MarshalStarOccultationFootprint` / `MarshalPlanetOccultationFootprints`)同样带 `delta_t_seconds`、`source_boundary_closed`、`geometry_role`、`interp_signature`;被月球地平切断时 `closure` 的 `kind` 是 `target-horizon`、`body` 是 `moon`,参照 `sublunar` 月下点而不是日下点。掩星子系统沿用进程级 ΔT,只回传实际用值,不提供显式覆盖。 +- 站心搜索:`SearchLocalCentralSolarEclipse(date, lon, lat, height, eclipse.SolarEclipseLocalSearchOptions{Kind, MaxYears, Backward, Geometric, Model})` 返回 `(info, status)`,`status.Exhausted` 把"跨度内确实没有"与"找到了"分开;`MaxYears<=0` 使用与旧入口等价的默认跨度(6000 次候选步进 ≈ 992 年,因为候选会跳过非食季)。`SolarEclipseCandidates(start, end, options)` 只回时刻表(食甚时刻、食型、中心食类型、食分、伽马、可选沙罗序列),不含任何几何。 + +日食 GeoJSON 的中心影相关 role 是稳定契约:`role=central-shadow-footprint` 要么缺省、要么是 `Polygon`/`MultiPolygon`,永不出现线类型;被地平线切断时它仍输出该时刻地面本影(或反本影)覆盖的完整区域——物理边界延伸到两个地平擦地点,再由两擦地点之间的地平弧闭合,此时 `source_boundary_closed=false`,并由 `closure`(`kind`、`time`、`subsolar`)声明那段人工弧。只含物理边界曲线的折线另由 `role=central-shadow-boundary` 输出,调用方描它、填上面那个面即可,不会描出假的地平线边界。足迹收缩到零(U1/U4)时整条缺省,也不会退化成线。`source_boundary_closed=true` 表示边界由本影自身闭合,环上没有任何人工段。 + `TimeMarkerOptions.Step` 的零值为 30 分钟,正值至少 1 分钟,每次导出最多 1440 个标记。GeoJSON 不携带底图、国家边界、样式或投影;Web Mercator、极区图、瓦片选择和政治边界由应用自行决定。 ### 行星 @@ -1569,18 +1717,18 @@ func main() { 输出结果: ``` -2019-11-11 23:21:42.048057317 +0800 CST // 水星上次下合 -2021-03-26 14:57:43.01215589 +0800 CST // 金星下次上合 -2019-11-01 04:31:38.999851942 +0800 CST // 水星上次由顺行转逆行的留 -2020-06-25 02:07:41.549940705 +0800 CST // 金星下次由逆行转顺行的留 -2019-10-20 11:50:28.734245896 +0800 CST // 水星上次东大距 -2020-08-13 07:59:17.123789191 +0800 CST // 金星下次西大距 -2020-01-01 10:02:34.172194004 +0800 CST // 西安当天金星升起时刻;无错误 -2020-01-01 20:25:37.363712489 +0800 CST // 西安当天金星落下时刻;无错误 +2019-11-11 23:21:41.971051096 +0800 CST // 水星上次下合 +2021-03-26 14:57:42.052354216 +0800 CST // 金星下次上合 +2019-11-01 04:31:49.749019145 +0800 CST // 水星上次由顺行转逆行的留 +2020-06-25 02:07:41.599749326 +0800 CST // 金星下次由逆行转顺行的留 +2019-10-20 12:01:37.740152478 +0800 CST // 水星上次东大距 +2020-08-13 08:14:46.304587125 +0800 CST // 金星下次西大距 +2020-01-01 10:02:34.172435402 +0800 CST // 西安当天金星升起时刻;无错误 +2020-01-01 20:25:37.36411482 +0800 CST // 西安当天金星落下时刻;无错误 -4 // 金星视星等 49.98145049145023 // 金星相位角,单位度 0.8215177914415865 // 金星被照亮比例 -255.63802093000768 // 金星亮面中心位置角,单位度 +255.63802053541346 // 金星亮面中心位置角,单位度 1.2778819631550336 // 金地距离,单位 AU 0.7262651056423838 // 金日距离,单位 AU ``` @@ -1650,21 +1798,21 @@ func main() { ```text true // 找到一次有效的地心水星凌日 -2019-11-11 12:35:31.617325544 +0000 UTC // 一触:水星外切进入太阳圆面 -2019-11-11 12:37:13.078211545 +0000 UTC // 二触:水星完全进入太阳圆面 -2019-11-11 15:19:48.410291075 +0000 UTC // 凌甚:水星中心最接近太阳中心 -2019-11-11 18:02:29.2267102 +0000 UTC // 三触:水星开始离开太阳圆面 -2019-11-11 18:04:10.687676668 +0000 UTC // 四触:水星外切离开太阳圆面 +2019-11-11 12:35:31.567597389 +0000 UTC // 一触:水星外切进入太阳圆面 +2019-11-11 12:37:12.817581295 +0000 UTC // 二触:水星完全进入太阳圆面 +2019-11-11 15:19:48.36056292 +0000 UTC // 凌甚:水星中心最接近太阳中心 +2019-11-11 18:02:29.176982045 +0000 UTC // 三触:水星开始离开太阳圆面 +2019-11-11 18:04:10.637948513 +0000 UTC // 四触:水星外切离开太阳圆面 5h28m39.070351124s // 一触到四触的地心凌日持续时间 -75.92506897631685 // 凌甚时水星中心与太阳中心的最小角距离,单位角秒 -968.8881520858397 // 凌甚时太阳视半径,单位角秒 -4.978442860728242 // 凌甚时水星视半径,单位角秒 +75.92400059923187 // 凌甚时水星中心与太阳中心的最小角距离,单位角秒 +968.8881519533047 // 凌甚时太阳视半径,单位角秒 +4.978442871670873 // 凌甚时水星视半径,单位角秒 true // 找到一次有效的地心金星凌日 -2012-06-05 22:09:47.514281272 +0000 UTC // 一触:金星外切进入太阳圆面 -2012-06-05 22:27:35.701768398 +0000 UTC // 二触:金星完全进入太阳圆面 -2012-06-06 01:29:35.408823788 +0000 UTC // 凌甚:金星中心最接近太阳中心 -2012-06-06 04:31:34.90493685 +0000 UTC // 三触:金星开始离开太阳圆面 -2012-06-06 04:49:23.303366303 +0000 UTC // 四触:金星外切离开太阳圆面 +2012-06-05 22:09:47.466886639 +0000 UTC // 一触:金星外切进入太阳圆面 +2012-06-05 22:27:35.865356326 +0000 UTC // 二触:金星完全进入太阳圆面 +2012-06-06 01:29:35.572371482 +0000 UTC // 凌甚:金星中心最接近太阳中心 +2012-06-06 04:31:35.068444311 +0000 UTC // 三触:金星开始离开太阳圆面 +2012-06-06 04:49:23.25597167 +0000 UTC // 四触:金星外切离开太阳圆面 6h39m35.789085031s // 一触到四触的地心凌日持续时间 ``` @@ -1727,15 +1875,15 @@ func main() { 输出结果: ``` -2020-10-14 07:25:50.262777507 +0800 CST // 火星下次冲日 -2021-01-29 09:39:33.565426468 +0800 CST // 木星下次合日 -2019-04-30 10:27:41.606289446 +0800 CST // 土星上次由顺行转逆行的留 -saturn B=23.577026 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // 土星环 B、B'、P、dU、长轴、短轴 -2020-01-11 15:23:07.378419935 +0800 CST // 天王星下次由逆行转顺行的留 -2019-12-08 17:00:15.328663587 +0800 CST // 海王星上次东方照 -2020-06-07 03:10:59.356176853 +0800 CST // 火星下次西方照 -2020-01-01 04:41:29.622089266 +0800 CST // 西安当天火星升起时刻;无错误 -2020-01-01 14:55:32.963870465 +0800 CST // 西安当天火星落下时刻;无错误 +2020-10-14 07:25:50.441412627 +0800 CST // 火星下次冲日 +2021-01-29 09:39:33.697994649 +0800 CST // 木星下次合日 +2019-04-30 10:28:00.187439918 +0800 CST // 土星上次由顺行转逆行的留 +saturn B=23.577025 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // 土星环 B、B'、P、dU、长轴、短轴 +2020-01-11 15:23:23.360308706 +0800 CST // 天王星下次由逆行转顺行的留 +2019-12-08 17:00:15.517960488 +0800 CST // 海王星上次东方照 +2020-06-07 03:11:00.026179254 +0800 CST // 火星下次西方照 +2020-01-01 04:41:29.621566236 +0800 CST // 西安当天火星升起时刻;无错误 +2020-01-01 14:55:32.963508367 +0800 CST // 西安当天火星落下时刻;无错误 1.57 // 火星视星等 2.1844284956325937 // 地火距离,单位 AU 1.5897860004265403 // 日火距离,单位 AU @@ -1789,7 +1937,7 @@ func main() { ```text jupiter DS=54.342153 DE=1.436485 CMI=292.712909 CMII=276.309048 CMIII=147.241811 // 木星子日/子地赤纬,System I/II/III 中央经线,单位度 -saturn B=-0.608046 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453246 // 土星环 B、B'、短轴位置角、外缘长短轴,角度单位度,长短轴单位角秒 +saturn B=-0.608048 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453248 // 土星环 B、B'、短轴位置角、外缘长短轴,角度单位度,长短轴单位角秒 ``` 只需要中央经线时,可以单独调用 `CentralMeridians`: @@ -1819,12 +1967,12 @@ fmt.Printf("uranus systemIII lon=%.6f lat=%.6f P=%.6f\n", ura3.SubEarthLongitude - `LastGalileanPhenomenonEvent` / `NextGalileanPhenomenonEvent` / `ClosestGalileanPhenomenonEvent`:搜索整场现象区间 - `LastGalileanPhenomenonContactEvent` / `NextGalileanPhenomenonContactEvent` / `ClosestGalileanPhenomenonContactEvent`:搜索 IMCCE 风格的 D/F 接触事件 -这里有两个容易混淆的口径,以木卫一凌日为例: +两个口径的区别如下,以木卫一凌日为例: - `GalileanPhenomenonEvent` 把卫星看作一个点,判断“卫星圆心是否进入/离开木星圆面”。它返回整段凌日的起止区间,适合快速搜索现象和程序内部状态判断。 - `GalileanPhenomenonContactEvent` 把卫星自身的有限圆盘考虑进去,区分初亏到复圆的完整接触过程。它返回消失阶段(D)和再现阶段(R)各自的接触起止与模型中心穿越时刻,适合和 IMCCE 年表中的 `TR.D/TR.F/OC.D/OC.F/EC.D/EC.F/SH.D/SH.F` 逐项对照。 -两个口径的差异在持续时间上最多约 7 分钟——这是模型定义不同造成的,不表示精度问题。用于观测预报或和公开年表逐项核对时,优先使用 `GalileanPhenomenonContactEvent`。 +两个口径的差异在持续时间上最多约 7 分钟,差异来自模型定义不同。用于观测预报或与公开年表逐项核对时,取 `GalileanPhenomenonContactEvent`。 ##### 代码示例 @@ -1875,15 +2023,15 @@ func main() { 输出结果: ```text -io x=-0.658543 y=-0.035608 front=true // 木卫一相对木星中心的 X/Y 偏移,单位木星半径;位于木星盘面前方 -europa ra=110.769323 dec=22.335800 // 木卫二视赤经、视赤纬,单位度 +io x=-0.675026 y=-0.032798 front=true // 木卫一相对木星中心的 X/Y 偏移,单位木星半径;位于木星盘面前方 +europa ra=110.769133 dec=22.335828 // 木卫二视赤经、视赤纬,单位度 io transit=true occultation=false eclipse=false shadow=true // 木卫一正在凌日,且影子正在凌日 europa transit=false occultation=false eclipse=false shadow=false // 木卫二此刻无凌日、掩蔽、木星食或影凌 event valid=true sat=1 type=transit // 下一次有效事件为木卫一凌日 -2026-01-16 16:32:47.785289883 +0000 UTC // 木卫一凌日开始 -2026-01-16 17:40:43.882995843 +0000 UTC // 木卫一凌日中点 -2026-01-16 18:48:40.519664883 +0000 UTC // 木卫一凌日结束 -2h15m52.734375s // 木卫一凌日持续时间 +2026-01-16 16:32:47.552742362 +0000 UTC // 木卫一凌日开始 +2026-01-16 17:40:44.189371168 +0000 UTC // 木卫一凌日中点 +2026-01-16 18:48:40.287077128 +0000 UTC // 木卫一凌日结束 +2h15m52.734334766s // 木卫一凌日持续时间 contact valid=true sat=2 type=occultation // 下一次有效接触事件为木卫二掩蔽 2026-01-17 01:00:34.99533087 +0000 UTC // 木卫二掩蔽消失阶段开始 2026-01-17 01:02:31.714070141 +0000 UTC // 木卫二掩蔽消失阶段模型中心穿越 @@ -1903,14 +2051,14 @@ contact valid=true sat=2 type=occultation // 下一次有效接触事件为木 当前测试结果可概括为: -- `Satellites` 相对木星中心的位置,对 JPL Horizons 的样例最大偏差约为 `X=0.252"`、`Y=0.108"`。 +- `Satellites` 相对木星中心的位置,对 JPL Horizons 的样例最大偏差约为 `X=0.054"`、`Y=0.048"`。 - `SatellitePhenomena` 的影凌影心偏移,对 JPL Horizons 的样例最大偏差约为 `X=0.051"`、`Y=0.016"`,现象布尔标志在样例中一致。 -- `GalileanPhenomenonContactEvent` 对 IMCCE 2026 年表,当前样例中接触起止时刻最大偏差约 `72 s`,接触持续时间最大偏差约 `17 s`。 -- `GalileanPhenomenonEvent` 不是 IMCCE 的 D/F 接触口径;如果拿它去直接对 IMCCE 起止时刻,当前样例会出现最多约 `7` 分钟的差异。这是事件定义不同,不应当按“时间精度差”解读。 +- `GalileanPhenomenonContactEvent` 对 IMCCE 2026 年表(8 个样例、D1/D2/F1/F2 四个接触逐值对拍):接触时刻最大偏差约 `79 s`,接触持续时间最大偏差约 `17 s`,回归测试按 `120 s` / `25 s` 上限钉住。 +- `GalileanPhenomenonEvent` 不是 IMCCE 的 D/F 接触口径;与 IMCCE 起止时刻直接对照时,当前样例的最大差异约 `7` 分钟,来源是事件定义不同。 ### 恒星 -1. 本程序自带9100颗恒星的数据库,能够自动计算自行 +1. 本程序自带 9100 颗恒星的数据库(BSC / HR 编号 `1–9110`,视星等 `-1.46`~`7.96`),能够自动计算自行 ```go package main @@ -1953,11 +2101,11 @@ func main() { ``` ``` -2019-12-31 19:22:56.176710426 +0800 CST // 天狼星升起时刻 -2020-01-01 05:30:39.834894239 +0800 CST // 天狼星落下时刻 +2019-12-31 19:22:56.144202053 +0800 CST // 天狼星升起时刻 +2020-01-01 05:30:39.802506566 +0800 CST // 天狼星落下时刻 大犬座 // 天狼星所在星座 -5h58m5.71s // 织女一在公元 13600 年的赤经 -84°19′26.13″ // 织女一在公元 13600 年的赤纬 +6h3m46.61s // 织女一在公元 13600 年的赤经 +84°18′27.15″ // 织女一在公元 13600 年的赤纬 天狼 Sirius -1.46 // 最亮恒星表第一项:中文名、英文常用名、视星等 ``` @@ -2010,13 +2158,13 @@ func main() { ```text 143.72223158223719 19.53512536790277 43.46959597099446 -17.686623571613737 107.68662357161374 -143.99353431082105 18.7404068044953 +144.2551242046188 18.790254631841993 manual az=281.869347 alt=24.489608 zen=65.510392 ha=73.866900 gal lon=0.000047 lat=-0.000079 -apparent alt=10.093429 +apparent alt=10.093428 ``` -`coord` 里的研究型接口不会自动代入当前日期的黄赤交角或恒星时,适合做“不同自转轴倾角”“手工指定时角”这类推演。常规观测计算仍建议使用 `EclipticToEquatorial`、`EquatorialToHorizontal` 等带 `time.Time` 的接口。 +`coord` 里的研究型接口不会自动代入当前日期的黄赤交角或恒星时,适合做“不同自转轴倾角”“手工指定时角”这类推演。常规计算可用 `EclipticToEquatorial`、`EquatorialToHorizontal` 等带 `time.Time` 的接口。 观测辅助方面,`coord` 还提供了两类高频小工具: @@ -2180,7 +2328,7 @@ fmt.Printf("alt=%.6f az=%.6f rise=%s\n", alt, az, rise.Format(time.RFC3339)) 这些观测接口基于站心视坐标计算,适合直接拿去做小行星、彗星或自定义二体目标的升落和指向辅助。 -另外,`orbit` 里还带了一个非常轻量的视双星求解器,直接按《天文算法》第 55 章的经典表观轨道公式输出位置角和角距: +`orbit` 里还带了一个视双星求解器,直接按《天文算法》第 55 章的经典表观轨道公式输出位置角和角距: ```go gammaVir := orbit.VisualBinaryElements{ @@ -2238,9 +2386,7 @@ func main() { - `HorizontalHourLineAngle`:给定纬度和时角,计算水平日晷相对午线的时线角 - `HorizontalHourLineAngleAt`:直接用时刻和经纬度求当前时线角 -注意: - -- `MeanSolarTimePoint` / `MeanSolarTimeLine` 中的 `date` 应位于目标地点的地方平太阳时区;最直接的来源就是 `MeanSolarTime(...)` 的返回值。 +- `MeanSolarTimePoint` / `MeanSolarTimeLine` 的 `date` 位于目标地点的地方平太阳时区,通常是 `MeanSolarTime(...)` 的返回值。 - `ZoneTimePoint` / `ZoneTimeLine` 会忽略传入 `date` 的原有时分秒,只使用它的年月日与时区,再把钟面时间替换成参数 `zoneTimeHours`。 ## 已实现 @@ -2252,8 +2398,8 @@ func main() { - ✅ 地球偏心率、日地距离、近日点、远日点 - ✅ 真平恒星时、星座计算、常用坐标转换、大气折射、大气质量、视差角、银道坐标 - ✅ 七大行星坐标、距日距地距离、特殊天象、水星/金星地心凌日、物理星历、视直径、相位、视差角与节点 -- ✅ 公农历转换(公元前104年-公元3000年) -- ✅ 9100+恒星数据库 +- ✅ 公农历转换(公元前721年至公元3000年) +- ✅ 9100 颗恒星数据库 - ✅ 通用小天体轨道传播、H-G 视星等、视双星位置角/角距 - ✅ 黑体辐射、会合周期、星等、望远镜、大气质量等研究公式 - ✅ 真太阳时、平面日晷几何、水平日晷时线角 diff --git a/astro.go b/astro.go index eaad774..ce29c56 100644 --- a/astro.go +++ b/astro.go @@ -1,4 +1,5 @@ -// Package astro +// Package astro 进程级 ΔT 模型的读取、替换与恢复默认:DeltaT、SetDeltaT、DefaultDeltaT。 +// Package astro exposes the process-wide DeltaT model: read the current function, install a custom one, or restore the built-in default. package astro import "b612.me/astro/basic" diff --git a/baseline_regression_test.go b/baseline_regression_test.go index 1ddc164..c58c6da 100644 --- a/baseline_regression_test.go +++ b/baseline_regression_test.go @@ -1,7 +1,9 @@ +// 根包回归:testdata 的位级基线与 n 截断契约(n<0 用全项,n>=0 保留约 n 个主项)。 package astro_test import ( "encoding/json" + "fmt" "math" "os" "testing" @@ -52,6 +54,122 @@ func loadBaselineSamples(t *testing.T) []baselineSample { return samples } +// truncationTermCounts 是截断检验抽取的 n 值。 +var truncationTermCounts = []int{2, 4, 8, 16} + +// angularDifference 两个角度量的最小夹角,单位度。 +func angularDifference(a, b float64) float64 { + d := math.Mod(a-b, 360) + if d > 180 { + d -= 360 + } + if d < -180 { + d += 360 + } + return math.Abs(d) +} + +// truncationCase 是一个截断探针:err 给出第 sample 个采样点在截断项数 n 下相对全项 n<0 的偏差。 +type truncationCase struct { + name string + tol []float64 + err func(sample, n int) float64 +} + +// scalarTruncation 构造标量探针;jd 按采样索引取值,angular 为真时偏差取角度最小夹角。 +func scalarTruncation(name string, jd func(index int) float64, f func(float64, int) float64, angular bool, tol []float64) truncationCase { + return truncationCase{ + name: name, + tol: tol, + err: func(sample, n int) float64 { + got := f(jd(sample), n) + full := f(jd(sample), -1) + if angular { + return angularDifference(got, full) + } + return math.Abs(got - full) + }, + } +} + +// pairTruncation 构造经纬成对返回的探针,取两个分量偏差的较大者。 +func pairTruncation(name string, jd func(index int) float64, f func(float64, int) (float64, float64), tol []float64) truncationCase { + return truncationCase{ + name: name, + tol: tol, + err: func(sample, n int) float64 { + gotA, gotB := f(jd(sample), n) + fullA, fullB := f(jd(sample), -1) + return math.Max(angularDifference(gotA, fullA), angularDifference(gotB, fullB)) + }, + } +} + +// dateTruncation 构造以 time.Time 为入口的探针,偏差按角度最小夹角计。 +func dateTruncation(name string, date func(index int) time.Time, f func(time.Time, int) float64, tol []float64) truncationCase { + return truncationCase{ + name: name, + tol: tol, + err: func(sample, n int) float64 { + return angularDifference(f(date(sample), n), f(date(sample), -1)) + }, + } +} + +// baselineDates 解析基线样本的 UTC 时刻。 +func baselineDates(t *testing.T, samples []baselineSample) []time.Time { + t.Helper() + + dates := make([]time.Time, len(samples)) + for i, sample := range samples { + parsed, err := time.Parse(time.RFC3339Nano, sample.UTC) + if err != nil { + t.Fatal(err) + } + dates[i] = parsed + } + return dates +} + +// assertTruncationConverges 逐采样点检验误差上限,并要求最坏误差随 n 不增、多数采样点严格下降。 +// 单点截断误差不是嵌套部分和,允许同量级抖动,因此“不增”约束的是采样集上的误差包络。 +func assertTruncationConverges(t *testing.T, cases []truncationCase, samples int) { + t.Helper() + + for _, tc := range cases { + envelope := make([]float64, len(truncationTermCounts)) + strict := 0 + for sample := 0; sample < samples; sample++ { + first, last := 0.0, 0.0 + for i, n := range truncationTermCounts { + e := tc.err(sample, n) + if e > tc.tol[i] { + t.Fatalf("%s: sample %d truncation error %.6g at n=%d exceeds %.6g", tc.name, sample, e, n, tc.tol[i]) + } + if e > envelope[i] { + envelope[i] = e + } + if i == 0 { + first = e + } + last = e + } + if last < first { + strict++ + } + } + for i := 1; i < len(envelope); i++ { + if envelope[i] > envelope[i-1] { + t.Fatalf("%s: worst truncation error grows from %.6g at n=%d to %.6g at n=%d", + tc.name, envelope[i-1], truncationTermCounts[i-1], envelope[i], truncationTermCounts[i]) + } + } + if 2*strict < samples { + t.Fatalf("%s: truncation error shrank with n at only %d of %d samples", tc.name, strict, samples) + } + } +} + func TestPlanetMoonBaselineRegression(t *testing.T) { samples := loadBaselineSamples(t) for _, sample := range samples { @@ -60,64 +178,85 @@ func TestPlanetMoonBaselineRegression(t *testing.T) { if math.Float64bits(gotLon) != body.LonBits { t.Fatalf("%s lon regression at %s", body.Name, sample.UTC) } - gotLonN := planet.WherePlanetN(body.XT, 0, sample.TTJD, -1) - if math.Float64bits(gotLonN) != body.LonBits { - t.Fatalf("%s lon full-n regression at %s", body.Name, sample.UTC) - } gotLat := planet.WherePlanet(body.XT, 1, sample.TTJD) if math.Float64bits(gotLat) != body.LatBits { t.Fatalf("%s lat regression at %s", body.Name, sample.UTC) } - gotLatN := planet.WherePlanetN(body.XT, 1, sample.TTJD, -1) - if math.Float64bits(gotLatN) != body.LatBits { - t.Fatalf("%s lat full-n regression at %s", body.Name, sample.UTC) - } gotRad := planet.WherePlanet(body.XT, 2, sample.TTJD) if math.Float64bits(gotRad) != body.RadBits { t.Fatalf("%s rad regression at %s", body.Name, sample.UTC) } - gotRadN := planet.WherePlanetN(body.XT, 2, sample.TTJD, -1) - if math.Float64bits(gotRadN) != body.RadBits { - t.Fatalf("%s rad full-n regression at %s", body.Name, sample.UTC) - } } if math.Float64bits(basic.HMoonTrueLo(sample.TTJD)) != sample.Moon.LonBits { t.Fatalf("moon lon regression at %s", sample.UTC) } - if math.Float64bits(basic.HMoonTrueLoN(sample.TTJD, -1)) != sample.Moon.LonBits { - t.Fatalf("moon lon full-n regression at %s", sample.UTC) - } if math.Float64bits(basic.HMoonTrueBo(sample.TTJD)) != sample.Moon.LatBits { t.Fatalf("moon lat regression at %s", sample.UTC) } - if math.Float64bits(basic.HMoonTrueBoN(sample.TTJD, -1)) != sample.Moon.LatBits { - t.Fatalf("moon lat full-n regression at %s", sample.UTC) - } if math.Float64bits(basic.HMoonAway(sample.TTJD)) != sample.Moon.DisBits { t.Fatalf("moon distance regression at %s", sample.UTC) } - if math.Float64bits(basic.HMoonAwayN(sample.TTJD, -1)) != sample.Moon.DisBits { - t.Fatalf("moon distance full-n regression at %s", sample.UTC) + } +} + +// planetTruncationName 与 planetTruncationTolerance 的下标同 WherePlanetN 的 xt。 +var planetTruncationName = []string{"earth", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune"} + +// planetTruncationTolerance 是 zn=黄经/黄纬(度)、日心距(AU)两种量纲的上限,取实测最坏值的 5 倍以上。 +var planetTruncationTolerance = [][]float64{ + {20, 2, 0.5, 0.1}, + {5, 0.5, 0.05, 0.01}, + {0.3, 0.1, 0.05, 0.02}, +} + +func planetMoonTruncationCases(samples []baselineSample) []truncationCase { + jdOf := func(index int) float64 { return samples[index].TTJD } + + wherePlanet := func(xt, zn int) truncationCase { + return scalarTruncation( + fmt.Sprintf("planet.WherePlanetN(%s, zn=%d)", planetTruncationName[xt], zn), + jdOf, + func(jd float64, n int) float64 { return planet.WherePlanetN(xt, zn, jd, n) }, + zn != 2, + planetTruncationTolerance[zn], + ) + } + + cases := make([]truncationCase, 0, 27) + for xt := 0; xt <= 7; xt++ { + for zn := 0; zn <= 2; zn++ { + cases = append(cases, wherePlanet(xt, zn)) } } + return append(cases, + scalarTruncation("basic.HMoonTrueLoN", jdOf, basic.HMoonTrueLoN, true, []float64{5, 2.5, 1, 0.25}), + scalarTruncation("basic.HMoonTrueBoN", jdOf, basic.HMoonTrueBoN, true, []float64{3, 0.75, 0.15, 0.06}), + scalarTruncation("basic.HMoonAwayN", jdOf, basic.HMoonAwayN, false, []float64{40000, 7000, 2500, 400}), + ) } -func TestPublicTruncationFullMatchesDefault(t *testing.T) { - date := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC) +func TestPlanetMoonTruncationConvergesToFullSeries(t *testing.T) { + samples := loadBaselineSamples(t) + assertTruncationConverges(t, planetMoonTruncationCases(samples), len(samples)) +} - if math.Float64bits(sun.TrueLo(date)) != math.Float64bits(sun.TrueLoN(date, -1)) { - t.Fatal("sun.TrueLoN(-1) should match default") - } - if math.Float64bits(sun.TrueBo(date)) != math.Float64bits(sun.TrueBoN(date, -1)) { - t.Fatal("sun.TrueBoN(-1) should match default") - } - if math.Float64bits(moon.TrueLo(date)) != math.Float64bits(moon.TrueLoN(date, -1)) { - t.Fatal("moon.TrueLoN(-1) should match default") - } - if math.Float64bits(moon.TrueBo(date)) != math.Float64bits(moon.TrueBoN(date, -1)) { - t.Fatal("moon.TrueBoN(-1) should match default") +func publicSunMoonTruncationCases(t *testing.T, samples []baselineSample) []truncationCase { + t.Helper() + + dates := baselineDates(t, samples) + dateOf := func(index int) time.Time { return dates[index] } + return []truncationCase{ + dateTruncation("sun.TrueLoN", dateOf, sun.TrueLoN, []float64{0.15, 0.05, 0.02, 0.006}), + dateTruncation("sun.TrueBoN", dateOf, sun.TrueBoN, []float64{1e-3, 5e-4, 3e-4, 1.5e-4}), + dateTruncation("moon.TrueLoN", dateOf, moon.TrueLoN, []float64{5, 2.5, 1, 0.25}), + dateTruncation("moon.TrueBoN", dateOf, moon.TrueBoN, []float64{3, 0.75, 0.15, 0.06}), } } + +func TestPublicSunMoonTruncationConvergesToFullSeries(t *testing.T) { + samples := loadBaselineSamples(t) + assertTruncationConverges(t, publicSunMoonTruncationCases(t, samples), len(samples)) +} diff --git a/basic/apsis.go b/basic/apsis.go index e73edb2..6149599 100644 --- a/basic/apsis.go +++ b/basic/apsis.go @@ -145,14 +145,26 @@ func moonApsisSeedTT(k float64) float64 { } func refineDistanceExtremum(seed float64, cfg apsisSearchConfig, distanceFn func(float64) float64) (float64, float64) { + if !finite(seed) || !finite(cfg.bracketHalfWidth) || !finite(cfg.sampleStep) || cfg.sampleStep <= 0 { + return seed, math.NaN() + } best := seed bestDistance := distanceFn(seed) - for sample := seed - cfg.bracketHalfWidth; sample <= seed+cfg.bracketHalfWidth+1e-12; sample += cfg.sampleStep { + // seed 幅度很大时 sample += sampleStep 可能小于一个 ULP(浮点间隔)而永不推进, + // 因此显式检测步长是否真的改变了采样点,避免无界循环。 + // At a large |seed| the step can fall below one ULP, so `sample += step` would never + // advance; detect a step that does not move the sample and stop the sweep instead. + for sample, end := seed-cfg.bracketHalfWidth, seed+cfg.bracketHalfWidth+1e-12; sample <= end; { dist := distanceFn(sample) if distanceBetter(dist, bestDistance, cfg.maximize) { best = sample bestDistance = dist } + next := sample + cfg.sampleStep + if next == sample { + break + } + sample = next } left, right, ok := apsisDerivativeBracket(best, seed, cfg, distanceFn) diff --git a/basic/coordinate.go b/basic/coordinate.go index aa99bbc..7c9f009 100644 --- a/basic/coordinate.go +++ b/basic/coordinate.go @@ -10,7 +10,8 @@ import ( * 坐标变换,黄道转赤道 */ func LoToRa(jde, lo, bo float64) float64 { - ra := math.Atan2(Sin(lo)*Cos(TrueObliquity(jde))-Tan(bo)*Sin(TrueObliquity(jde)), Cos(lo)) + eps := TrueObliquity(jde) + ra := math.Atan2(Sin(lo)*Cos(eps)-Tan(bo)*Sin(eps), Cos(lo)) ra = ra * 180 / math.Pi if ra < 0 { ra += 360 @@ -19,13 +20,17 @@ func LoToRa(jde, lo, bo float64) float64 { } func BoToDec(jde, lo, bo float64) float64 { - dec := ArcSin(Sin(bo)*Cos(TrueObliquity(jde)) + Cos(bo)*Sin(TrueObliquity(jde))*Sin(lo)) + eps := TrueObliquity(jde) + dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo)) return dec } func LoBoToRaDec(jde, lo, bo float64) (float64, float64) { - dec := ArcSin(Sin(bo)*Cos(TrueObliquity(jde)) + Cos(bo)*Sin(TrueObliquity(jde))*Sin(lo)) - ra := math.Atan2(Sin(lo)*Cos(TrueObliquity(jde))-Tan(bo)*Sin(TrueObliquity(jde)), Cos(lo)) + eps := TrueObliquity(jde) + sinEps := Sin(eps) + cosEps := Cos(eps) + dec := ArcSin(Sin(bo)*cosEps + Cos(bo)*sinEps*Sin(lo)) + ra := math.Atan2(Sin(lo)*cosEps-Tan(bo)*sinEps, Cos(lo)) ra = ra * 180 / math.Pi if ra < 0 { ra += 360 @@ -81,10 +86,14 @@ func psini(lat, h float64) float64 { } func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) { + return topocentricRaDecWithSidereal(ra, dec, lat, lon, ApparentSiderealTime(jd)*15, au, h) +} + +func topocentricRaDecWithSidereal(ra, dec, lat, lon, siderealDegrees, au, h float64) (float64, float64) { sinpi := Sin(0.0024427777777) / au pcosi := pcosi(lat, h) psini := psini(lat, h) - tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra) + tH := Limit360(siderealDegrees + lon - ra) nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi diff --git a/basic/coordinate_internal_test.go b/basic/coordinate_internal_test.go new file mode 100644 index 0000000..4c58da0 --- /dev/null +++ b/basic/coordinate_internal_test.go @@ -0,0 +1,44 @@ +package basic + +import ( + "math" + "testing" + + . "b612.me/astro/tools" +) + +func TestEclipticToEquatorialCachedObliquityMatchesRepeatedEvaluation(t *testing.T) { + for _, test := range []struct { + jde float64 + lo float64 + bo float64 + }{ + {jde: 2451545.0, lo: 0, bo: 0}, + {jde: 2460832.0, lo: 193.25, bo: -5.75}, + {jde: 2378496.5, lo: 359.999, bo: 87.5}, + } { + legacyDec := ArcSin( + Sin(test.bo)*Cos(TrueObliquity(test.jde)) + + Cos(test.bo)*Sin(TrueObliquity(test.jde))*Sin(test.lo), + ) + legacyRA := math.Atan2( + Sin(test.lo)*Cos(TrueObliquity(test.jde))-Tan(test.bo)*Sin(TrueObliquity(test.jde)), + Cos(test.lo), + ) * 180 / math.Pi + if legacyRA < 0 { + legacyRA += 360 + } + + ra, dec := LoBoToRaDec(test.jde, test.lo, test.bo) + if ra != legacyRA || dec != legacyDec { + t.Fatalf("LoBoToRaDec(%v, %v, %v) = %.15g %.15g, want %.15g %.15g", + test.jde, test.lo, test.bo, ra, dec, legacyRA, legacyDec) + } + if got := LoToRa(test.jde, test.lo, test.bo); got != legacyRA { + t.Fatalf("LoToRa(%v, %v, %v) = %.15g, want %.15g", test.jde, test.lo, test.bo, got, legacyRA) + } + if got := BoToDec(test.jde, test.lo, test.bo); got != legacyDec { + t.Fatalf("BoToDec(%v, %v, %v) = %.15g, want %.15g", test.jde, test.lo, test.bo, got, legacyDec) + } + } +} diff --git a/basic/culmination_anchor_test.go b/basic/culmination_anchor_test.go new file mode 100644 index 0000000..3e02809 --- /dev/null +++ b/basic/culmination_anchor_test.go @@ -0,0 +1,95 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +// 本文件核实太阳中天(floor 正午锚点)与月亮中天(floor+0.5 午夜锚点)两套锚点: +// 调用方按各自约定补偿后,两者都必须落在同一个本地民用日,并接近当日本地日的真实中天。 +// +// 框架约定(由各自调用方固定下来): +// - SunHeight/HMoonHeight 的 jd 是本地民用时框架,本地民用日从 Date2JDE(本地 0 时) 起算; +// - basic.CulminationTime 的返回值是本地民用时框架,sun/sun.go 减 tz/24 得到 UT; +// - basic.MoonCulminationTime 的返回值就是本地民用时框架,moon/moon.go 按 byZone=true 使用。 + +type culminationAnchorSite struct { + name string + lon float64 + lat float64 + tz float64 +} + +var culminationAnchorSites = []culminationAnchorSite{ + {"beijing", 116.4074, 39.9042, 8}, + {"utc", 0, 0, 0}, + {"newyork", -74, 40.7, -5}, + {"adelaide", 138.6, -34.9, 9.5}, + {"chatham", -176.5, -43.9, 12.75}, + {"kiritimati", -157.4, 1.9, 14}, +} + +func culminationAnchorLocalMidnight(site culminationAnchorSite, timestamp time.Time) (time.Time, float64) { + location := time.FixedZone("anchor", int(site.tz*3600)) + local := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), 0, 0, 0, 0, location) + return local, Date2JDE(local) +} + +func TestSunCulminationAnchorKeepsLocalDay(t *testing.T) { + timestamp := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + for _, site := range culminationAnchorSites { + local, midnightJD := culminationAnchorLocalMidnight(site, timestamp) + location := local.Location() + // sun/sun.go 的补偿:本地 0 时 JD 再加半天,使 floor 落在同一本地日;随后减 tz/24 得 UT。 + got := JDE2DateByZone(CulminationTime(midnightJD+0.5, site.lon, site.tz)-site.tz/24, location, false) + truthJD, truthAltitude := 0.0, -999.0 + for minute := 0; minute <= 24*60; minute++ { + jd := midnightJD + float64(minute)/1440.0 + if altitude := SunHeight(jd, site.lon, site.lat, site.tz); altitude > truthAltitude { + truthAltitude, truthJD = altitude, jd + } + } + truth := JDE2DateByZone(truthJD-site.tz/24, location, false) + if got.Day() != local.Day() || got.Month() != local.Month() { + t.Fatalf("%s: sun culmination = %s, want local day %s", site.name, got.Format("2006-01-02 15:04"), local.Format("2006-01-02")) + } + if deviation := math.Abs(got.Sub(truth).Minutes()); deviation > 1.5 { + t.Fatalf("%s: sun culmination deviates %.2f min from the daily altitude maximum", site.name, deviation) + } + } +} + +func TestMoonCulminationAnchorKeepsLocalDay(t *testing.T) { + timestamp := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + for _, site := range culminationAnchorSites { + local, midnightJD := culminationAnchorLocalMidnight(site, timestamp) + location := local.Location() + got := JDE2DateByZone(MoonCulminationTime(midnightJD, site.lon, site.lat, site.tz), location, true) + truthJD, truthAltitude := 0.0, -999.0 + for minute := 0; minute <= 24*60; minute++ { + jd := midnightJD + float64(minute)/1440.0 + if altitude := HMoonHeight(jd, site.lon, site.lat, site.tz); altitude > truthAltitude { + truthAltitude, truthJD = altitude, jd + } + } + truth := JDE2DateByZone(truthJD, location, true) + if got.Day() != local.Day() || got.Month() != local.Month() { + t.Fatalf("%s: moon culmination = %s, want local day %s", site.name, got.Format("2006-01-02 15:04"), local.Format("2006-01-02")) + } + if deviation := math.Abs(got.Sub(truth).Minutes()); deviation > 6.0 { + t.Fatalf("%s: moon culmination deviates %.2f min from the daily altitude maximum", site.name, deviation) + } + } +} + +func TestSunCulminationAnchorDiffersFromMidnightAnchor(t *testing.T) { + site := culminationAnchorSites[0] + _, midnightJD := culminationAnchorLocalMidnight(site, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + // 不补半天时 floor 落在相邻的 UT 日:这正是调用方必须补偿 +0.5 的原因。 + uncompensated := CulminationTime(midnightJD, site.lon, site.tz) - site.tz/24 + compensated := CulminationTime(midnightJD+0.5, site.lon, site.tz) - site.tz/24 + if math.Abs(uncompensated-compensated) < 0.4 { + t.Fatalf("expected the uncompensated anchor to land on an adjacent day: %.6f vs %.6f", uncompensated, compensated) + } +} diff --git a/basic/delta_t.go b/basic/delta_t.go index 87ca667..6aef852 100644 --- a/basic/delta_t.go +++ b/basic/delta_t.go @@ -1,26 +1,60 @@ package basic -import "math" +import ( + "math" + "sync" +) var defDeltaTFn = DefaultDeltaTv2 +var deltaTFnMu sync.RWMutex + +// deltaTGeneration 随每次 ΔT 覆盖递增,供依赖 ΔT 的只读记忆表判断自身是否过期。 +// 起始为 1,使零值缓存条目(世代 0)天然视为未命中。 +// deltaTGeneration increments on every ΔT override so ΔT-dependent memo tables can detect +// staleness. It starts at 1 so a zero-valued cache entry (generation 0) is never a hit. +var deltaTGeneration uint64 = 1 func DeltaT(date float64, isJDE bool) float64 { - return defDeltaTFn(date, isJDE) + deltaTFnMu.RLock() + fn := defDeltaTFn + deltaTFnMu.RUnlock() + return fn(date, isJDE) } func SetDeltaTFn(fn func(float64, bool) float64) { if fn != nil { + deltaTFnMu.Lock() defDeltaTFn = fn + deltaTGeneration++ + deltaTFnMu.Unlock() } } +// deltaTGenerationValue 返回当前 ΔT 世代,用于让只读记忆表在 ΔT 改变后整体失效。 +// deltaTGenerationValue returns the current ΔT generation so memo tables can be invalidated. +func deltaTGenerationValue() uint64 { + deltaTFnMu.RLock() + value := deltaTGeneration + deltaTFnMu.RUnlock() + return value +} + func GetDeltaTFn() func(float64, bool) float64 { - return defDeltaTFn + deltaTFnMu.RLock() + fn := defDeltaTFn + deltaTFnMu.RUnlock() + return fn } func DefaultDeltaTv2(date float64, isJd bool) float64 { //传入年或儒略日,传出为秒 + if math.IsNaN(date) || math.IsInf(date, 0) { + return math.NaN() + } if !isJd { - date = JDECalc(int(date), int((date-math.Floor(date))*12)+1, (date-math.Floor(date))*365.25+1) + year := math.Floor(date) + start := JDECalc(int(year), 1, 1) + end := JDECalc(int(year)+1, 1, 1) + date = start + (date-year)*(end-start) } return DeltaTv2(date) } @@ -30,6 +64,9 @@ func DefaultDeltaTv2(date float64, isJd bool) float64 { //传入年或儒略日 // 2010年后的系数已修改以包含2019年后的数据 // 返回Delta T,单位为秒 func DeltaTSplineY(y float64) float64 { + if math.IsNaN(y) || math.IsInf(y, 0) { + return math.NaN() + } // 积分lod(平均太阳日偏离86400秒的偏差)方程: // 来自 http://astro.ukho.gov.uk/nao/lvm/: // lod = 1.72 t − 3.5 sin(2*pi*(t+0.75)/14) 单位ms/day,其中 t = (y - 1825)/100 @@ -76,6 +113,9 @@ func DeltaTSplineY(y float64) float64 { } func DeltaTv2(jd float64) float64 { + if math.IsNaN(jd) || math.IsInf(jd, 0) { + return math.NaN() + } if jd > 2461041.5 || jd < 2441317.5 { var y float64 if jd >= 2299160.5 { @@ -97,7 +137,7 @@ func DeltaTv2(jd float64) float64 { n := len(jdLeaps) deltaTSeconds := 42.184 for i := 0; i < n; i++ { - if jd > jdLeaps[i] { + if jd >= jdLeaps[i] { deltaTSeconds += float64(n - i - 1) break } @@ -105,11 +145,59 @@ func DeltaTv2(jd float64) float64 { return deltaTSeconds } +// DeltaTSecondsAt 返回某个 TT 时刻实际使用的 ΔT(秒):overrideSeconds 是有限值时直接采用 +// (含 0,可显式要求 ΔT=0),为 NaN/±Inf 时改用进程级模型。模型按 UT 键控,因此这里先解 +// TT−ΔT(UT) 再求值,不把 TT 直接当作 UT 送进模型(差约 2e-6 s)。 +// DeltaTSecondsAt returns the ΔT in seconds used at one TT instant: a finite override wins +// (including 0, which requests ΔT = 0 explicitly), while NaN or ±Inf selects the process-wide +// model. The model is keyed by UT, so the equation TT - ΔT(UT) is solved instead of feeding TT. +func DeltaTSecondsAt(jdeTT, overrideSeconds float64) float64 { + if !math.IsNaN(overrideSeconds) && !math.IsInf(overrideSeconds, 0) { + return overrideSeconds + } + return deltaTModelSecondsAtTT(jdeTT) +} + +func deltaTModelSecondsAtTT(jdeTT float64) float64 { + ut := jdeTT - DeltaT(jdeTT, true)/86400.0 + for iteration := 0; iteration < 4; iteration++ { + next := jdeTT - DeltaT(ut, true)/86400.0 + if next == ut { + break + } + ut = next + } + return DeltaT(ut, true) +} + +// DeltaTGroundShiftKM 把 ΔT 误差换算为站点相对影子的地面横移距离(千米)。 +// 地球赤道自转线速度 465.1 m/s,因此 ΔT 相差 Δ 秒时,地面点相对影子横移 +// 0.4651·|Δ|·cos(纬度) 千米;±400 年跨度上 ΔT 外推差几百到几千秒,足以挪动本影 +// 边界数百千米,调用方可用本函数把外部给出的 ΔT 不确定度换算成几何不确定度。 +// DeltaTGroundShiftKM converts a ΔT error into the ground displacement of a station +// relative to the shadow, in kilometres: 0.4651 * |ΔT| * cos(latitude). +func DeltaTGroundShiftKM(deltaTSeconds, latitudeDeg float64) float64 { + if math.IsNaN(deltaTSeconds) || math.IsInf(deltaTSeconds, 0) || + math.IsNaN(latitudeDeg) || math.IsInf(latitudeDeg, 0) { + return math.NaN() + } + return solarEclipseEarthEquatorialRotationKMPerSecond * math.Abs(deltaTSeconds) * math.Cos(latitudeDeg*rad) +} + func TD2UT(jde float64, utToTD bool) float64 { // true 世界时转力学时CC,false 力学时转世界时VV deltaTSeconds := DeltaT(jde, true) if utToTD { return jde + deltaTSeconds/3600/24 - } else { - return jde - deltaTSeconds/3600/24 } + // Delta T is evaluated at UT in the forward conversion. Solve the same + // equation in reverse so distant-epoch contact times survive a round trip. + ut := jde - deltaTSeconds/3600/24 + for iteration := 0; iteration < 4; iteration++ { + next := jde - DeltaT(ut, true)/3600/24 + if next == ut { + break + } + ut = next + } + return ut } diff --git a/basic/delta_t_test.go b/basic/delta_t_test.go new file mode 100644 index 0000000..0666715 --- /dev/null +++ b/basic/delta_t_test.go @@ -0,0 +1,101 @@ +package basic + +import ( + "math" + "testing" +) + +func TestDefaultDeltaTFractionalYear(t *testing.T) { + for _, year := range []float64{-700.5, -0.5, 0, 1000.5, 1582.75, 1800.5, 2000.5, 2026.5} { + whole := math.Floor(year) + start := JDECalc(int(whole), 1, 1) + end := JDECalc(int(whole)+1, 1, 1) + want := DefaultDeltaTv2(start+(year-whole)*(end-start), true) + got := DefaultDeltaTv2(year, false) + if math.IsNaN(got) || math.Abs(got-want) > 1e-10 { + t.Errorf("year=%v DeltaT=%v, want %v", year, got, want) + } + } +} + +func TestDefaultDeltaTInvalidInput(t *testing.T) { + for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + for _, isJD := range []bool{false, true} { + if got := DefaultDeltaTv2(value, isJD); !math.IsNaN(got) { + t.Errorf("DeltaT(%v, %v)=%v, want NaN", value, isJD, got) + } + } + } +} + +func TestDeltaTLeapSecondBoundary(t *testing.T) { + boundary := 2457754.5 + if got := DeltaTv2(boundary); got != 69.184 { + t.Fatalf("DeltaT at leap-second boundary=%v, want 69.184", got) + } + if got := DeltaTSplineY(math.NaN()); !math.IsNaN(got) { + t.Fatalf("DeltaTSplineY(NaN)=%v, want NaN", got) + } +} + +func TestTD2UTRoundTrip(t *testing.T) { + for _, year := range []int{-2000, -720, 0, 26, 1426, 2025, 2027, 3627, 4026, 5000} { + ut := JDECalc(year, 9, 20.123456) + tt := TD2UT(ut, true) + if got := TD2UT(tt, false); math.Abs(got-ut) > math.Nextafter(ut, math.Inf(1))-ut { + t.Errorf("year=%d UT round trip differs by %.9f seconds", year, (got-ut)*86400) + } + if got := TD2UT(TD2UT(tt, false), true); got != tt { + t.Errorf("year=%d TT round trip differs by %.9f seconds", year, (got-tt)*86400) + } + } + for _, seconds := range []float64{-70, -1, -0.1, 0, 0.1, 1, 70} { + ut := 2457754.5 + seconds/86400 + if got := TD2UT(TD2UT(ut, true), false); got != ut { + t.Errorf("leap second offset=%g round trip differs by %.9f seconds", seconds, (got-ut)*86400) + } + } +} + +func TestTD2UTCustomDeltaT(t *testing.T) { + original := GetDeltaTFn() + t.Cleanup(func() { SetDeltaTFn(original) }) + for _, slope := range []float64{0, 0.01} { + SetDeltaTFn(func(jd float64, isJD bool) float64 { + if !isJD { + t.Fatal("conversion must request Delta T at a Julian day") + } + return 10000 + slope*(jd-2451545) + }) + ut := 3000000.123456 + if got := TD2UT(TD2UT(ut, true), false); got != ut { + t.Errorf("custom slope=%g round trip differs by %.9f seconds", slope, (got-ut)*86400) + } + } +} + +// DeltaTSecondsAt:有限值(含 0)是显式覆盖,NaN/±Inf 才回退进程级模型;模型按 UT 求值。 +func TestDeltaTSecondsAtOverrideContract(t *testing.T) { + jd := 2460310.5 + if got := DeltaTSecondsAt(jd, 0); got != 0 { + t.Fatalf("explicit zero override = %v, want 0", got) + } + if got := DeltaTSecondsAt(jd, 12.5); got != 12.5 { + t.Fatalf("explicit override = %v, want 12.5", got) + } + model := DeltaTSecondsAt(jd, math.NaN()) + if !finite(model) || model <= 0 { + t.Fatalf("model fallback = %v, want a positive finite value", model) + } + if got := DeltaTSecondsAt(jd, math.Inf(1)); got != model { + t.Fatalf("+Inf override = %v, want the model %v", got, model) + } + if got := deltaTModelSecondsAtTT(jd); got != model { + t.Fatalf("deltaTModelSecondsAtTT = %v, want %v", got, model) + } + // 模型按 UT 键控:与"把 TT 直接当 UT"的朴素求值有微小差别。 + naive := DeltaT(jd, true) + if diff := math.Abs(naive - model); diff > 1e-3 { + t.Fatalf("UT-keyed model differs from naive TT evaluation by %v s", diff) + } +} diff --git a/basic/earth.go b/basic/earth.go index 4513ade..96e3d47 100644 --- a/basic/earth.go +++ b/basic/earth.go @@ -36,7 +36,7 @@ func HeightDegreeByLat(height, lat float64) float64 { return math.Acos((radius)/(radius+height)) * 180 / math.Pi } -// GeocentricRadius 地心直径与纬度的关系 +// GeocentricRadius 给定纬度处的地心半径,单位米 / geocentric radius at the given latitude, in metres. func GeocentricRadius(lat float64) float64 { a := (EARTH_EQUATORIAL_RADIUS * EARTH_EQUATORIAL_RADIUS * Cos(lat)) a *= a diff --git a/basic/event_boundary.go b/basic/event_boundary.go index d41d8fb..6408408 100644 --- a/basic/event_boundary.go +++ b/basic/event_boundary.go @@ -5,14 +5,28 @@ import "math" const ( exactEventTolerance = 2.0 / 86400.0 exactQueryTTToleranceUT = 0.1 / 86400.0 + // stationQueryToleranceUT 站(留)事件的「同刻」容差 / same-instant tolerance for station events. + // + // 站的求解本身有数值不确定度(火星 ~0.2 s、金星 ~2 s、外行星改精修步长后 <0.05 s)。 + // 若与普通事件一样取 0.1 s,查询落在站前几十毫秒时会被判成「站仍在未来」, + // 于是跳到上一个会合周期。取 0.5 s 可覆盖除金星外的全部不确定度。 + // 上限约束:必须明显小于 1 s,否则会破坏 + // TestInnerPlanetNextEventAdvancesPastReturnedEvent 的「查询=事件+1 秒必须前进」语义。 + stationQueryToleranceUT = 0.5 / 86400.0 ) func sameEventJD(a, b float64) bool { return math.Abs(a-b) <= exactEventTolerance } -func sameEventUTQueryTT(eventUT, queryTT float64) bool { - return math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) <= exactQueryTTToleranceUT +// stationUTQueryBeforeOrEqual / stationUTQueryAfterOrEqual 站事件专用侧向判定, +// 容差比普通事件宽(见 stationQueryToleranceUT)。 +func stationUTQueryBeforeOrEqual(eventUT, queryTT float64) bool { + return eventUTQueryTTDelta(eventUT, queryTT) <= stationQueryToleranceUT +} + +func stationUTQueryAfterOrEqual(eventUT, queryTT float64) bool { + return eventUTQueryTTDelta(eventUT, queryTT) >= -stationQueryToleranceUT } func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float64 { @@ -30,50 +44,34 @@ func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float type phaseEventSearchFunc func(jde, degree float64, next uint8) float64 type simpleEventSearchFunc func(jde float64) float64 +// inclusive* 的唯一作用是把「查询几乎正好落在事件上」判成包含: +// 反向搜索给出的邻接事件落在同刻容差内时优先返回它,否则返回本方向的事件(可能是 NaN)。 func inclusiveLastPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 { last := fn(jde, degree, 0) - next := fn(jde, degree, 1) - if eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) { + if next := fn(jde, degree, 1); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) { return next } - if eventUTQueryBeforeOrEqual(last, jde) { - return last - } return last } func inclusiveNextPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 { - last := fn(jde, degree, 0) - if eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) { + if last := fn(jde, degree, 0); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) { return last } - next := fn(jde, degree, 1) - if eventUTQueryAfterOrEqual(next, jde) { - return next - } - return next + return fn(jde, degree, 1) } func inclusiveLastSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 { last := lastFn(jde) - next := nextFn(jde) - if eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) { + if next := nextFn(jde); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) { return next } - if eventUTQueryBeforeOrEqual(last, jde) { - return last - } return last } func inclusiveNextSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 { - last := lastFn(jde) - if eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) { + if last := lastFn(jde); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) { return last } - next := nextFn(jde) - if eventUTQueryAfterOrEqual(next, jde) { - return next - } - return next + return nextFn(jde) } diff --git a/basic/event_refine.go b/basic/event_refine.go index dff4dca..b1e860a 100644 --- a/basic/event_refine.go +++ b/basic/event_refine.go @@ -6,6 +6,10 @@ const ( eventNewtonMaxIterations = 24 eventDirectionalSearchIterations = 128 eventRiseSetScanStep = 1.0 / 1440 + // stationDerivativeStepDay 「留」精修用的中心差分步长(天)。 + // 0.5 秒级的步长会被浮点相消噪声支配(实测外行星留误差可达 42 s); + // 0.01 天(14.4 分钟)实测误差 <0.05 s,且求值次数不变。 + stationDerivativeStepDay = 0.01 ) func isFiniteFloat(value float64) bool { @@ -121,6 +125,56 @@ func eventDirectionalBracketRefine(leftJD, rightJD, leftValue, rightValue float6 return (leftJD + rightJD) / 2 } +// eventBracketSecantRoot 在已知异号的括号内用割线法(带中点兜底)求根。 +// +// 与 eventDirectionalBracketRefine(固定 48 次二分)相比,割线法通常 5~8 次求值即可达到 +// 亚秒精度,适合「括号由廉价截断级数给出、抛光必须用全项级数」的两段式搜索。 +// 括号每一步都收缩,因此不会跑到括号外;括号端点同号或出现非有限值时返回 false。 +func eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, tolerance float64, + fn func(float64) float64) (float64, bool) { + if leftValue == 0 { + return leftJD, true + } + if rightValue == 0 { + return rightJD, true + } + if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) || leftValue*rightValue > 0 { + return math.NaN(), false + } + if !isFiniteFloat(tolerance) || tolerance <= 0 { + tolerance = 0.5 / 86400.0 + } + bestJD := (leftJD + rightJD) / 2 + for i := 0; i < 48; i++ { + candidateJD := (leftJD + rightJD) / 2 + if rightValue != leftValue { + secantJD := rightJD - rightValue*(rightJD-leftJD)/(rightValue-leftValue) + if secantJD > leftJD && secantJD < rightJD { + candidateJD = secantJD + } + } + candidateValue := fn(candidateJD) + if !isFiniteFloat(candidateValue) { + return math.NaN(), false + } + bestJD = candidateJD + if candidateValue == 0 || math.Abs(rightJD-leftJD) <= tolerance { + return candidateJD, true + } + if (leftValue < 0) == (candidateValue < 0) { + leftJD, leftValue = candidateJD, candidateValue + continue + } + rightJD, rightValue = candidateJD, candidateValue + } + if math.Abs(rightJD-leftJD) <= tolerance { + return bestJD, true + } + // 48 次迭代后括号仍宽于容差(例如容差低于该儒略日的 ULP):调用方把 ok 当作 + // “已抛光到容差”,这里必须报 false,不能返回一个精度未达标的时刻。 + return bestJD, false +} + func eventFixedScanRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 { start := seed - halfWindow bestJD := start diff --git a/basic/event_refine_test.go b/basic/event_refine_test.go index f9ccc99..6c0df45 100644 --- a/basic/event_refine_test.go +++ b/basic/event_refine_test.go @@ -23,3 +23,19 @@ func TestEventZeroRefineFallsBackToFixedScan(t *testing.T) { t.Fatalf("got %.15f want 0", got) } } + +// 48 次迭代用尽后括号仍宽于容差时不能谎报"已抛光":调用方把 ok 当作精度保证。 +func TestEventBracketSecantRootRejectsUnpolishedRoot(t *testing.T) { + linear := func(x float64) float64 { return x - 0.5 } + if _, ok := eventBracketSecantRoot(0, 1, -0.5, 0.5, 1e-9, linear); !ok { + t.Fatalf("normal tolerance should return ok=true") + } + // 无理根(sqrt(2))永远取不到精确零,容差又低于该量级的 ULP:必须报 false。 + irrational := func(x float64) float64 { return x*x - 2 } + if _, ok := eventBracketSecantRoot(0, 2, -2, 2, 1e-25, irrational); ok { + t.Fatalf("unreachable tolerance must not report a polished root") + } + if _, ok := eventBracketSecantRoot(0, 2, -2, 2, 1e-12, irrational); !ok { + t.Fatalf("reachable tolerance should still report ok=true") + } +} diff --git a/basic/greatest_time_contour.go b/basic/greatest_time_contour.go new file mode 100644 index 0000000..96dd51e --- /dev/null +++ b/basic/greatest_time_contour.go @@ -0,0 +1,126 @@ +package basic + +import ( + "math" + "time" +) + +// 等时线(食甚/掩甚时刻等值线)的共享常量与工具。 +// 两个等时线实现(日食、月掩)用同一套容差与时刻取值生成规则,避免口径漂移。 + +const ( + // greatestTimeContourCoverToleranceKM 是"该点是否已被已绘等时线覆盖"的距离门限。 + // 判据必须用点到折线的距离:同一条曲线被两个种子各画一次时该距离约为 0,而折线弦高在 + // 1.5° 步长下最大几 km,所以 10 km 既不会漏判重复,也远小于不同支路的间距。 + greatestTimeContourCoverToleranceKM = 10.0 + // greatestTimeContourCorrectionBacktracking 是牛顿投影跳出定义域时按二分回退的次数。 + greatestTimeContourCorrectionBacktracking = 6 + // greatestTimeContourKMPerDegree 是地面大圆每度的近似长度,用于局部平面投影。 + greatestTimeContourKMPerDegree = 111.195 + // greatestTimeContourMaxLevels 是单个事件允许的等时线条数上限;超出部分按时间截断, + // 避免极长事件请求出上万条曲线。 + greatestTimeContourMaxLevels = 64 + // greatestTimeContourResidualTolerance 是残差零点判定的收敛容差。 + greatestTimeContourResidualTolerance = 1e-9 + // greatestTimeContourBisectionIterations 是二分求根的迭代上限。 + greatestTimeContourBisectionIterations = 64 +) + +// greatestTimeContourDifference 优先用中心差分,一侧越界时退化为单侧差分。 +func greatestTimeContourDifference(center, positive, negative, step float64, positiveOK, negativeOK bool) (float64, bool) { + switch { + case positiveOK && negativeOK: + return (positive - negative) / (2 * step), true + case positiveOK: + return (positive - center) / step, true + case negativeOK: + return (center - negative) / step, true + } + return 0, false +} + +// greatestTimeContourBisect 在 [left,right] 上二分求残差零点;residual 在定义域外返回 NaN。 +func greatestTimeContourBisect( + residual func(longitude, latitude float64) float64, + left, right, latitude, leftValue float64, +) (float64, bool) { + for iteration := 0; iteration < greatestTimeContourBisectionIterations; iteration++ { + middle := (left + right) / 2 + middleValue := residual(middle, latitude) + if !finite(middleValue) { + return 0, false + } + if math.Abs(middleValue) <= greatestTimeContourResidualTolerance || right-left <= 1e-9 { + return middle, true + } + if leftValue*middleValue <= 0 { + right = middle + } else { + left, leftValue = middle, middleValue + } + } + return (left + right) / 2, true +} + +// greatestTimeContourTickTolerance 是对齐网格在窗口边界上的容差,远小于任何合法步长。 +const greatestTimeContourTickTolerance = time.Millisecond + +// greatestTimeContourLevel 是一条等时线对应的时刻取值:TT 儒略日与它对应的时刻必须成对传递, +// 前者用于求根,后者用于标注(按步长生成时它是原始对齐时刻,避免 JDE 往返把整分截断成前一分钟)。 +type greatestTimeContourLevel struct { + tt float64 + at time.Time +} + +// greatestTimeContourAlignedLevels 生成覆盖 [startTT,endTT] 且对齐到 step 整刻度的时刻取值, +// 最多 maxLevels 条;起点或终点恰好落在整刻度上时该刻度只生成一次。 +func greatestTimeContourAlignedLevels(startTT, endTT float64, step time.Duration, maxLevels int) []greatestTimeContourLevel { + if step <= 0 || maxLevels <= 0 || endTT <= startTT { + return nil + } + start := greatestTimeContourTTToUTC(startTT) + first := start.Truncate(step) + // TT 儒略日往返有约 40 µs(1 ULP)误差,恰好落在整刻度上的窗口边界会算到刻度外一点点,容差按 1 ms 给。 + if first.Add(greatestTimeContourTickTolerance).Before(start) { + first = first.Add(step) + } + end := greatestTimeContourTTToUTC(endTT) + levels := make([]greatestTimeContourLevel, 0, maxLevels) + for current := first; !current.Add(-greatestTimeContourTickTolerance).After(end) && len(levels) < maxLevels; current = current.Add(step) { + levels = append(levels, greatestTimeContourLevel{tt: greatestTimeContourUTCToTT(current), at: current}) + } + return levels +} + +// greatestTimeContourTTToUTC 把 TT 儒略日换成对应的 UTC 时刻。 +func greatestTimeContourTTToUTC(tt float64) time.Time { + return JDE2DateByZone(TD2UT(tt, false), time.UTC, false) +} + +// greatestTimeContourUTCToTT 把 UTC 时刻换成对应的 TT 儒略日。 +func greatestTimeContourUTCToTT(value time.Time) float64 { + return TD2UT(Date2JDE(value.UTC()), true) +} + +// greatestTimeContourPointSegmentKM 返回点到折线段的距离,单位 km。 +// 段长只有 1–2 度,用局部等距圆柱近似即可,误差远小于覆盖容差。 +func greatestTimeContourPointSegmentKM(longitude, latitude, startLongitude, startLatitude, endLongitude, endLatitude float64) float64 { + cosine := math.Cos(latitude * rad) + toX := func(value float64) float64 { + return normalizeLongitude(value-startLongitude) * rad * cosine * greatestTimeContourKMPerDegree + } + toY := func(value float64) float64 { return (value - startLatitude) * greatestTimeContourKMPerDegree } + pointX, pointY := toX(longitude), toY(latitude) + endX, endY := toX(endLongitude), toY(endLatitude) + lengthSquared := endX*endX + endY*endY + projection := 0.0 + if lengthSquared > 0 { + projection = (pointX*endX + pointY*endY) / lengthSquared + if projection < 0 { + projection = 0 + } else if projection > 1 { + projection = 1 + } + } + return math.Hypot(pointX-projection*endX, pointY-projection*endY) +} diff --git a/basic/greatest_time_contour_test.go b/basic/greatest_time_contour_test.go new file mode 100644 index 0000000..facde8f --- /dev/null +++ b/basic/greatest_time_contour_test.go @@ -0,0 +1,66 @@ +package basic + +import ( + "testing" + "time" +) + +// 对齐网格是导出行为(等时线按整刻度取值)的契约,取值本身写成字面量以免与实际生成函数互相自证。 +func TestGreatestTimeContourAlignedLevelsGridContracts(t *testing.T) { + tt := func(hour, minute int) float64 { + return TD2UT(Date2JDE(time.Date(2024, 1, 1, hour, minute, 0, 0, time.UTC)), true) + } + cases := []struct { + name string + start, end float64 + step time.Duration + maxLevels int + want []string + }{ + { + name: "起点在刻度之间时从下一个整刻度开始", start: tt(0, 7), end: tt(2, 7), + step: 30 * time.Minute, maxLevels: 64, + want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"}, + }, + { + name: "起点恰在刻度上时不重复生成该刻度", start: tt(0, 30), end: tt(2, 0), + step: 30 * time.Minute, maxLevels: 64, + want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"}, + }, + { + name: "终点恰在刻度上时包含终点", start: tt(0, 7), end: tt(1, 30), + step: 30 * time.Minute, maxLevels: 64, + want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"}, + }, + { + name: "上限截断", start: tt(0, 7), end: tt(5, 7), + step: 30 * time.Minute, maxLevels: 3, + want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"}, + }, + { + name: "窗口内没有整刻度时为空", start: tt(0, 1), end: tt(0, 20), + step: time.Hour, maxLevels: 64, + want: nil, + }, + { + name: "非正步长或倒置窗口为空", start: tt(0, 7), end: tt(2, 7), + step: 0, maxLevels: 64, want: nil, + }, + } + for _, testCase := range cases { + levels := greatestTimeContourAlignedLevels(testCase.start, testCase.end, testCase.step, testCase.maxLevels) + if len(levels) != len(testCase.want) { + t.Fatalf("%s: %d levels, want %d", testCase.name, len(levels), len(testCase.want)) + } + for index, level := range levels { + got := level.at.UTC().Format(time.RFC3339) + if got != testCase.want[index] { + t.Fatalf("%s: level %d at %s, want %s", testCase.name, index, got, testCase.want[index]) + } + // 标注时刻与求根时刻必须成对:TT 往返 1 ULP 内一致。 + if roundTrip := greatestTimeContourUTCToTT(level.at); roundTrip != level.tt { + t.Fatalf("%s: level %d tt %.9f != %.9f", testCase.name, index, level.tt, roundTrip) + } + } + } +} diff --git a/basic/inner_event_window.go b/basic/inner_event_window.go index 8154d1f..be512e4 100644 --- a/basic/inner_event_window.go +++ b/basic/inner_event_window.go @@ -164,6 +164,8 @@ func maximizeInWindow(start, end, coarseStep float64, coarseFn, exactFn func(flo if right-left <= innerEventMaximizeEpsilon { return guess } + // 注意:这里必须全部用全项函数迭代。粗阶段(截断函数)虽然更便宜,但截断峰位与全项峰位 + // 相差分钟量级,粗段省下的求值次数会被精段为达到同等精度而多花掉,因此不做两段式拆分。 for i := 0; i < 20; i++ { third := (right - left) / 3.0 leftThird := left + third diff --git a/basic/inner_planet_truth_test.go b/basic/inner_planet_truth_test.go index 6cca2e2..1409f33 100644 --- a/basic/inner_planet_truth_test.go +++ b/basic/inner_planet_truth_test.go @@ -93,8 +93,20 @@ func parseInnerBaselineTime(t *testing.T, value string) time.Time { func innerBaselineTolerance(event innerBaselineEvent) time.Duration { switch event.Kind { - case "IC", "SC", "P2R", "R2P": - return 2 * time.Minute + case "IC", "SC": + // 2197 年附近双方 ΔT 外推已差约 2 分钟(校正了 EarthPI 的 0.00046·T² 项之后, + // 三个 2197 样本从 −119.5 s 移到 −121…−126 s),因此与留点同样留 3 分钟余量; + // 真实的搜索错误是小时/天级,靠这个容差仍能抓住。 + // Around 2197 the two ΔT extrapolations already differ by about two minutes (after + // correcting the EarthPI 0.00046*T^2 term, three 2197 samples moved from -119.5 s to + // -121..-126 s), so conjunctions get the same 3-minute margin as the stations. + // Real search errors are hours or days, so this still catches them. + return 3 * time.Minute + case "P2R", "R2P": + // 留是 RA 的极值点:星历模型中 ~1e-6 度/天的变化率差异就会被放大成分钟级时刻差, + // 且 JPL 给出的 UT 时刻还叠加了各自的 ΔT 外推(2197 年附近两者已差 ~2 分钟)。 + // 这里留 3 分钟余量,仍远小于任何真实的搜索错误(小时/天级)。 + return 3 * time.Minute case "GEE", "GEW": return 90 * time.Minute default: diff --git a/basic/jupiter_events.go b/basic/jupiter_events.go index 2bd664f..ba220d5 100644 --- a/basic/jupiter_events.go +++ b/basic/jupiter_events.go @@ -172,6 +172,9 @@ func LastJupiterWesternQuadrature(jde float64) float64 { } func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 { + if !isFiniteFloat(oppositionJD) { + return math.NaN() + } oppositionTT := TD2UT(oppositionJD, true) startTT := oppositionTT endTT := oppositionTT @@ -183,49 +186,98 @@ func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposit endTT = TD2UT(westernQuadratureUT, true) } bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { - return jupiterRADerivativeN(jd, 1.0/86400.0, jupiterEventSearchN) + return jupiterRADerivativeN(jd, stationDerivativeStepDay, jupiterEventSearchN) }, func(jd float64) float64 { - return jupiterRADerivative(jd, 0.5/86400.0) + return jupiterRADerivative(jd, stationDerivativeStepDay) }) return TD2UT(bestJD, false) } func NextJupiterRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := jupiterConjunctionFull(jde, 180, 0) date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } nextOppositionJD := jupiterConjunctionFull(jde, 180, 1) - return jupiterRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } + date = jupiterRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastJupiterRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := jupiterConjunctionFull(jde, 180, 0) date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } previousOppositionJD := jupiterConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0) - return jupiterRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(previousOppositionJD) { + return math.NaN() + } + date = jupiterRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } func NextJupiterProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := jupiterConjunctionFull(jde, 180, 1) date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } followingOppositionJD := jupiterConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1) - return jupiterRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(followingOppositionJD) { + return math.NaN() + } + date = jupiterRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastJupiterProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := jupiterConjunctionFull(jde, 180, 1) date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } lastOppositionJD := jupiterConjunctionFull(jde, 180, 0) - return jupiterRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } + date = jupiterRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } diff --git a/basic/jupiter_satellite_contact_events.go b/basic/jupiter_satellite_contact_events.go index 240a17a..0c2589a 100644 --- a/basic/jupiter_satellite_contact_events.go +++ b/basic/jupiter_satellite_contact_events.go @@ -336,25 +336,6 @@ func refineJupiterGalileanShadowContactPair( }, true } -func refineJupiterGalileanNegativeMetricWindow( - seedJD float64, - metric func(jd float64) float64, -) (float64, float64, bool) { - activeJD, activeValue, ok := findJupiterGalileanNegativeMetricSeed(seedJD, metric) - if !ok { - return math.NaN(), math.NaN(), false - } - start, ok := refineJupiterGalileanNegativeWindowRoot(activeJD, activeValue, -1, metric) - if !ok { - return math.NaN(), math.NaN(), false - } - end, ok := refineJupiterGalileanNegativeWindowRoot(activeJD, activeValue, 1, metric) - if !ok { - return math.NaN(), math.NaN(), false - } - return start, end, true -} - func findJupiterGalileanNegativeMetricSeed( seedJD float64, metric func(jd float64) float64, @@ -593,18 +574,6 @@ func jupiterPenumbraScale(distanceBehindAU, sunDistanceAU float64) float64 { return 1 + distanceBehindAU*(solarRadiusAU+jupiterRadiusAU)/(sunDistanceAU*jupiterRadiusAU) } -func jupiterGalileanUmbraRadiusJupiterRadii(satellite int, pathLengthAU, sunDistanceAU float64) float64 { - if pathLengthAU <= 0 || sunDistanceAU <= 0 { - return math.NaN() - } - satelliteRadiusAU := jupiterGalileanSatelliteRadiusJupiterRadii(satellite) * jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM - umbraRadiusAU := satelliteRadiusAU - pathLengthAU*(solarRadiusAU-satelliteRadiusAU)/sunDistanceAU - if umbraRadiusAU <= 0 { - return math.NaN() - } - return umbraRadiusAU / (jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM) -} - func jupiterGalileanPenumbraRadiusJupiterRadii(satellite int, pathLengthAU, sunDistanceAU float64) float64 { if pathLengthAU <= 0 || sunDistanceAU <= 0 { return math.NaN() diff --git a/basic/jupiter_satellite_events.go b/basic/jupiter_satellite_events.go index 4e36e20..fe88ee7 100644 --- a/basic/jupiter_satellite_events.go +++ b/basic/jupiter_satellite_events.go @@ -39,8 +39,10 @@ type JupiterGalileanPhenomenonEvent struct { } type jupiterGalileanMetricSample struct { - active bool - metric float64 + active bool + metric float64 + // signed 是该现象相对木星视面中心线的有符号偏移(木星半径),每次过零对应一次现象;未定义时为 NaN。 + signed float64 phenomenon JupiterGalileanPhenomenon } @@ -55,18 +57,24 @@ type jupiterGalileanShadowPoint struct { } // LastJupiterGalileanPhenomenonEvent 上一次伽利略卫星现象 / previous Galilean-satellite event. +// +// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。 func LastJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent { event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true) return event } // NextJupiterGalileanPhenomenonEvent 下一次伽利略卫星现象 / next Galilean-satellite event. +// +// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。 func NextJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent { event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false) return event } // ClosestJupiterGalileanPhenomenonEvent 最近一次伽利略卫星现象 / closest Galilean-satellite event. +// +// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。 func ClosestJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent { last, hasLast := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true) next, hasNext := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false) @@ -121,14 +129,11 @@ func searchJupiterGalileanPhenomenonEvent( for i := 2; i <= maxSteps; i++ { nextTime := jd + sign*float64(i)*stepDays nextSample := jupiterGalileanPhenomenonMetricAt(nextTime, satellite, phenomenonType) - if isFinite(midSample.metric) && - midSample.metric <= prevSample.metric && - midSample.metric <= nextSample.metric { - candidate := refineJupiterGalileanMetricMinimum(prevTime, nextTime, satellite, phenomenonType) - event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType) - if event.Valid && jupiterGalileanEventMatchesDirection(event.Greatest, jd, direction, includeCurrent) { - return event, true - } + if event, ok := jupiterGalileanIntervalCandidate( + prevTime, nextTime, prevSample, midSample, nextSample, + jd, satellite, phenomenonType, direction, includeCurrent, + ); ok { + return event, true } prevTime, prevSample = midTime, midSample midTime, midSample = nextTime, nextSample @@ -137,6 +142,132 @@ func searchJupiterGalileanPhenomenonEvent( return invalidJupiterGalileanPhenomenonEvent(), false } +// jupiterGalileanIntervalCandidate 在粗扫区间内确认现象:度量局部极小,或有符号偏移过零。 +func jupiterGalileanIntervalCandidate( + prevTime, nextTime float64, + prevSample, midSample, nextSample jupiterGalileanMetricSample, + jd float64, + satellite int, + phenomenonType JupiterGalileanPhenomenonType, + direction int, + includeCurrent bool, +) (JupiterGalileanPhenomenonEvent, bool) { + var best JupiterGalileanPhenomenonEvent + found := false + consider := func(event JupiterGalileanPhenomenonEvent) { + if !event.Valid || + !jupiterGalileanEventMatchesDirection(event.Greatest, jd, direction, includeCurrent) { + return + } + if !found || + (direction > 0 && event.Greatest < best.Greatest) || + (direction < 0 && event.Greatest > best.Greatest) { + best, found = event, true + } + } + if isFinite(midSample.metric) && + midSample.metric <= prevSample.metric && + midSample.metric <= nextSample.metric { + consider(findJupiterGalileanPhenomenonEventAround( + refineJupiterGalileanMetricMinimum(prevTime, nextTime, satellite, phenomenonType), + satellite, phenomenonType, + )) + } + if jupiterGalileanSignedCrosses(prevSample.signed, nextSample.signed) && + jupiterGalileanNearDisk(prevSample, midSample, nextSample) { + crossing := refineJupiterGalileanSignedCrossing(prevTime, nextTime, satellite, phenomenonType) + // 过零点只说明"靠近木星",只在它已贴近视面时按事件尺度细扫一次。 + if sample := jupiterGalileanPhenomenonMetricAt(crossing, satellite, phenomenonType); isFinite(sample.metric) && + sample.metric <= jupiterGalileanCrossingProbeMetric { + if event, ok := jupiterGalileanFineScanAround(crossing, satellite, phenomenonType); ok { + consider(event) + } + } + } + return best, found +} + +const ( + // jupiterGalileanCrossingProbeMetric 是细扫门限(椭球度量,1 为视面边缘)。 + jupiterGalileanCrossingProbeMetric = 2.5 + // jupiterGalileanCrossingProbeDays 是过零点两侧的细扫半宽。 + jupiterGalileanCrossingProbeDays = 15.0 / 1440.0 + // jupiterGalileanCrossingProbeStepDays 是细扫步长(30 秒),短于最短的擦边事件。 + jupiterGalileanCrossingProbeStepDays = 0.5 / 1440.0 +) + +// jupiterGalileanNearDisk 报告区间内是否已有贴近木星视面的采样点。 +func jupiterGalileanNearDisk(prevSample, midSample, nextSample jupiterGalileanMetricSample) bool { + for _, metric := range [3]float64{prevSample.metric, midSample.metric, nextSample.metric} { + if isFinite(metric) && metric <= jupiterGalileanCrossingProbeMetric { + return true + } + } + return false +} + +// jupiterGalileanFineScanAround 在过零点邻域按事件尺度取样,命中后交给事件求解。 +func jupiterGalileanFineScanAround( + jd float64, + satellite int, + phenomenonType JupiterGalileanPhenomenonType, +) (JupiterGalileanPhenomenonEvent, bool) { + // 从过零点向两侧交替外扩,通常几步内就能落进事件窗口。 + steps := int(math.Round(jupiterGalileanCrossingProbeDays / jupiterGalileanCrossingProbeStepDays)) + for step := 0; step <= steps; step++ { + for _, sign := range [2]float64{1, -1} { + if step == 0 && sign < 0 { + continue + } + candidate := jd + sign*float64(step)*jupiterGalileanCrossingProbeStepDays + if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType).active { + continue + } + if event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType); event.Valid { + return event, true + } + } + } + return JupiterGalileanPhenomenonEvent{}, false +} + +// jupiterGalileanSignedCrosses 判断两次采样的有符号偏移是否变号(端点为 0 也算)。 +func jupiterGalileanSignedCrosses(first, second float64) bool { + if !isFinite(first) || !isFinite(second) { + return false + } + if first == 0 || second == 0 { + return true + } + return (first < 0) != (second < 0) +} + +// refineJupiterGalileanSignedCrossing 用二分把过零时刻收敛到事件容差内。 +func refineJupiterGalileanSignedCrossing( + left, right float64, + satellite int, + phenomenonType JupiterGalileanPhenomenonType, +) float64 { + leftValue := jupiterGalileanPhenomenonMetricAt(left, satellite, phenomenonType).signed + rightValue := jupiterGalileanPhenomenonMetricAt(right, satellite, phenomenonType).signed + if !isFinite(leftValue) || !isFinite(rightValue) || (leftValue < 0) == (rightValue < 0) { + return (left + right) / 2 + } + for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ { + middle := (left + right) / 2 + value := jupiterGalileanPhenomenonMetricAt(middle, satellite, phenomenonType).signed + if !isFinite(value) { + return middle + } + if (value < 0) == (leftValue < 0) { + left, leftValue = middle, value + } else { + right, rightValue = middle, value + } + } + return (left + right) / 2 +} + func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent { sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType) if !sample.active { @@ -292,6 +423,7 @@ func jupiterGalileanPhenomenonMetricAt( if !isFinite(jd) || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) { return jupiterGalileanMetricSample{ metric: math.Inf(1), + signed: math.NaN(), phenomenon: invalidJupiterGalileanPhenomenon(), } } @@ -301,6 +433,7 @@ func jupiterGalileanPhenomenonMetricAt( if context.jupiterDistance == 0 { return jupiterGalileanMetricSample{ metric: math.Inf(1), + signed: math.NaN(), phenomenon: invalidJupiterGalileanPhenomenon(), } } @@ -360,27 +493,41 @@ func jupiterGalileanPhenomenonMetricAt( if !observation.InFrontOfJupiter { metric += 4 } - return jupiterGalileanMetricSample{active: phenomenon.Transit, metric: metric, phenomenon: phenomenon} + return jupiterGalileanMetricSample{ + active: phenomenon.Transit, metric: metric, signed: xEarth, phenomenon: phenomenon, + } case JupiterGalileanOccultation: metric := earthMetric if observation.InFrontOfJupiter { metric += 4 } - return jupiterGalileanMetricSample{active: phenomenon.Occultation, metric: metric, phenomenon: phenomenon} + return jupiterGalileanMetricSample{ + active: phenomenon.Occultation, metric: metric, signed: xEarth, phenomenon: phenomenon, + } case JupiterGalileanEclipse: metric := sunMetric if zSunAU <= 0 { metric += 4 } - return jupiterGalileanMetricSample{active: phenomenon.Eclipse, metric: metric, phenomenon: phenomenon} + return jupiterGalileanMetricSample{ + active: phenomenon.Eclipse, metric: metric, signed: xSun, phenomenon: phenomenon, + } case JupiterGalileanShadowTransit: metric := shadowMetric if shadowPoint.hasIntersection && !shadowPoint.visible { metric += 4 } - return jupiterGalileanMetricSample{active: phenomenon.ShadowTransit, metric: metric, phenomenon: phenomenon} + signed := math.NaN() + if shadowPoint.hasIntersection { + signed = shadowPoint.xJupiterRadii + } + return jupiterGalileanMetricSample{ + active: phenomenon.ShadowTransit, metric: metric, signed: signed, phenomenon: phenomenon, + } default: - return jupiterGalileanMetricSample{metric: math.Inf(1), phenomenon: invalidJupiterGalileanPhenomenon()} + return jupiterGalileanMetricSample{ + metric: math.Inf(1), signed: math.NaN(), phenomenon: invalidJupiterGalileanPhenomenon(), + } } } diff --git a/basic/jupiter_satellite_events_test.go b/basic/jupiter_satellite_events_test.go index 7bb3162..e5c2bba 100644 --- a/basic/jupiter_satellite_events_test.go +++ b/basic/jupiter_satellite_events_test.go @@ -143,3 +143,39 @@ func mustParseRFC3339Nano(t *testing.T, value string) time.Time { } return date } + +func TestJupiterGalileanSearchFindsShortGrazingTransit(t *testing.T) { + // 2463144 附近 Callisto 有一次擦边凌日:Start 2463143.9993 / Greatest 2463144.0015 / + // End 2463144.0038,全场只有 6.4 分钟,整段落在 2 小时粗采样之间。旧实现看不到度量 + // 凹陷,会把 16.85 天后的下一次凌日当成 "next"(Valid 仍为 true,属静默跳事件)。 + // Callisto has a grazing transit near 2463144: Start 2463143.9993, Greatest + // 2463144.0015, End 2463144.0038 — only 6.4 minutes, entirely between two two-hour + // coarse samples. The old search saw no metric dip and reported the next transit 16.85 + // days later as "next", still with Valid=true (a silent skip). + closest := ClosestJupiterGalileanPhenomenonEvent(2463144.0, 4, JupiterGalileanTransit) + if !closest.Valid { + t.Fatal("no Callisto transit found at 2463144") + } + if duration := closest.End - closest.Start; duration > 0.01 { + t.Fatalf("fixture changed: transit duration %.4f d, want the 6.4-minute grazing event", duration) + } + + next := NextJupiterGalileanPhenomenonEvent(2463143.9646, 4, JupiterGalileanTransit) + if !next.Valid { + t.Fatal("NextJupiterGalileanPhenomenonEvent returned nothing") + } + if offset := next.Greatest - 2463143.9646; offset > 0.1 { + t.Fatalf("next transit is %.3f d away, want the 53-minute one (the search skipped it)", offset) + } + if math.Abs(next.Greatest-closest.Greatest) > 1e-5 { + t.Fatalf("next greatest %.9f, want the same event as closest %.9f", next.Greatest, closest.Greatest) + } + + last := LastJupiterGalileanPhenomenonEvent(2463144.05, 4, JupiterGalileanTransit) + if !last.Valid { + t.Fatal("LastJupiterGalileanPhenomenonEvent returned nothing") + } + if math.Abs(last.Greatest-closest.Greatest) > 1e-5 { + t.Fatalf("last greatest %.9f, want the same event as closest %.9f", last.Greatest, closest.Greatest) + } +} diff --git a/basic/jupiter_satellites.go b/basic/jupiter_satellites.go index a82b9f5..76430cc 100644 --- a/basic/jupiter_satellites.go +++ b/basic/jupiter_satellites.go @@ -35,7 +35,7 @@ type jupiterGalileanL1Term struct { // JupiterGalileanState 木星伽利略卫星原始状态 / raw Galilean-satellite state. // // 输入 jd 使用 TT/TDB 对应的儒略日;返回值为 IMCCE L1 理论的木心 J2000 平赤道直角坐标与速度,单位 AU / AU/day。 -// The input jd is a TT/TDB Julian day. Returned coordinates are Jovicentric J2000 mean-equatorial position and velocity from the IMCCE L1 theory, in AU and AU/day. +// The input jd is a TT/TDB Julian day (convert a UT query with TD2UT(jd, true)). Returned coordinates are Jovicentric J2000 mean-equatorial position and velocity from the IMCCE L1 theory, in AU and AU/day. type JupiterGalileanState struct { X float64 Y float64 diff --git a/basic/local_ephemeris.go b/basic/local_ephemeris.go new file mode 100644 index 0000000..2980ded --- /dev/null +++ b/basic/local_ephemeris.go @@ -0,0 +1,228 @@ +package basic + +import "math" + +// localEphemerisVectorNode is a pair of Cartesian ephemeris samples at one +// TT. Cartesian interpolation avoids right-ascension wraparound at 0/360. +type localEphemerisVectorNode struct { + tt float64 + first, next [3]float64 +} + +const ( + solarLocalEphemerisNodeCount = 13 + solarLocalEphemerisStepDays = 1.0 / 24.0 + occultationLocalEphemerisNodeCount = 49 + occultationLocalEphemerisStepDays = 2.0 / 24.0 +) + +func interpolateLocalEphemerisVectors( + nodes []localEphemerisVectorNode, + tt float64, +) ([3]float64, [3]float64, bool) { + const interpolationPoints = 6 + if len(nodes) < interpolationPoints || !finite(tt) { + return [3]float64{}, [3]float64{}, false + } + step := nodes[1].tt - nodes[0].tt + if !finite(step) || step <= 0 { + return [3]float64{}, [3]float64{}, false + } + u := (tt - nodes[0].tt) / step + if u < 0 || u > float64(len(nodes)-1) { + return [3]float64{}, [3]float64{}, false + } + start := int(math.Floor(u)) - 2 + if start < 0 { + start = 0 + } + if start > len(nodes)-interpolationPoints { + start = len(nodes) - interpolationPoints + } + var weights [interpolationPoints]float64 + for point := 0; point < interpolationPoints; point++ { + x := float64(start + point) + weight := 1.0 + for other := 0; other < interpolationPoints; other++ { + if other == point { + continue + } + xOther := float64(start + other) + weight *= (u - xOther) / (x - xOther) + } + weights[point] = weight + } + var first, next [3]float64 + for coordinate := 0; coordinate < 3; coordinate++ { + for point := 0; point < interpolationPoints; point++ { + first[coordinate] += weights[point] * nodes[start+point].first[coordinate] + next[coordinate] += weights[point] * nodes[start+point].next[coordinate] + } + } + return first, next, finiteVector3(first) && finiteVector3(next) +} + +func finiteVector3(value [3]float64) bool { + return finite(value[0]) && finite(value[1]) && finite(value[2]) +} + +func occultationPathVectorRaDec(vector occultationPathVector) (float64, float64, float64, bool) { + distance := occultationPathNorm(vector) + if !finite(distance) || distance <= 0 { + return 0, 0, 0, false + } + ra := math.Atan2(vector.y, vector.x) / rad + dec := math.Asin(math.Max(-1, math.Min(1, vector.z/distance))) / rad + return ra, dec, distance, finite(ra) && finite(dec) +} + +type solarEclipseLocalEphemeris struct { + nodes []localEphemerisVectorNode +} + +func newSolarEclipseLocalEphemeris(center float64) *solarEclipseLocalEphemeris { + half := solarLocalEphemerisNodeCount / 2 + nodes := make([]localEphemerisVectorNode, solarLocalEphemerisNodeCount) + for index := range nodes { + tt := center + float64(index-half)*solarLocalEphemerisStepDays + sun, moon := solarEclipseSunMoonEquatorial(tt) + nodes[index] = localEphemerisVectorNode{ + tt: tt, + first: solarEclipseLLRToXYZ(sun[0], sun[1], sun[2]), + next: solarEclipseLLRToXYZ(moon[0], moon[1], moon[2]), + } + } + return &solarEclipseLocalEphemeris{nodes: nodes} +} + +func (ephemeris *solarEclipseLocalEphemeris) equatorialAt(tt float64) ([3]float64, [3]float64, bool) { + var empty [3]float64 + if ephemeris == nil { + return empty, empty, false + } + sunXYZ, moonXYZ, ok := interpolateLocalEphemerisVectors(ephemeris.nodes, tt) + if !ok { + return empty, empty, false + } + return solarEclipseXYZToLLR(sunXYZ[0], sunXYZ[1], sunXYZ[2]), + solarEclipseXYZToLLR(moonXYZ[0], moonXYZ[1], moonXYZ[2]), true +} + +type starOccultationLocalEphemeris struct { + star StarCoordinate + nodes []localEphemerisVectorNode + dense bool +} + +func newStarOccultationLocalEphemeris(center float64, star StarCoordinate) *starOccultationLocalEphemeris { + half := occultationLocalEphemerisNodeCount / 2 + nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount) + for index := range nodes { + tt := center + float64(index-half)*occultationLocalEphemerisStepDays + state := starOccultationEphemerisStateAt(tt, star) + moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM) + targetDistance := state.starDistanceKM + if targetDistance <= 0 { + // 恒星距离未知时只需方向:按单位球方向装配,几何只用归一化后的矢量。 + targetDistance = 1 + } + target := occultationPathRaDecVector(state.starRA, state.starDec, targetDistance) + nodes[index] = localEphemerisVectorNode{ + tt: tt, + first: [3]float64{moon.x, moon.y, moon.z}, + next: [3]float64{target.x, target.y, target.z}, + } + } + return &starOccultationLocalEphemeris{star: star, nodes: nodes} +} + +func (ephemeris *starOccultationLocalEphemeris) stateAt(tt float64) (starOccultationEphemerisState, bool) { + moonXYZ, targetXYZ, ok := ephemeris.vectorsAt(tt) + if !ok { + return starOccultationEphemerisState{}, false + } + moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{ + x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2], + }) + targetRA, targetDec, _, targetOK := occultationPathVectorRaDec(occultationPathVector{ + x: targetXYZ[0], y: targetXYZ[1], z: targetXYZ[2], + }) + if !moonOK || !targetOK { + return starOccultationEphemerisState{}, false + } + return starOccultationEphemerisState{ + moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance, + starRA: targetRA, starDec: targetDec, starDistanceKM: ephemeris.starDistanceKM(), + valid: true, + }, true +} + +func (ephemeris *starOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float64, [3]float64, bool) { + if ephemeris == nil { + return [3]float64{}, [3]float64{}, false + } + if ephemeris.dense { + return interpolateDenseOccultationVectors(ephemeris.nodes, tt) + } + return interpolateLocalEphemerisVectors(ephemeris.nodes, tt) +} + +func (ephemeris *starOccultationLocalEphemeris) starDistanceKM() float64 { + if ephemeris.star.ParallaxMas <= 0 { + return 0 + } + return 206264806.247 / ephemeris.star.ParallaxMas * occultationPathAstronomicalUnitKM +} + +type planetOccultationLocalEphemeris struct { + nodes []localEphemerisVectorNode + dense bool +} + +func newPlanetOccultationLocalEphemeris(center float64, config planetOccultationConfig) *planetOccultationLocalEphemeris { + half := occultationLocalEphemerisNodeCount / 2 + nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount) + for index := range nodes { + tt := center + float64(index-half)*occultationLocalEphemerisStepDays + state := planetOccultationEphemerisStateAt(tt, config) + moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM) + target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM) + nodes[index] = localEphemerisVectorNode{ + tt: tt, + first: [3]float64{moon.x, moon.y, moon.z}, + next: [3]float64{target.x, target.y, target.z}, + } + } + return &planetOccultationLocalEphemeris{nodes: nodes} +} + +func (ephemeris *planetOccultationLocalEphemeris) stateAt(tt float64) (planetOccultationEphemerisState, bool) { + moonXYZ, targetXYZ, ok := ephemeris.vectorsAt(tt) + if !ok { + return planetOccultationEphemerisState{}, false + } + moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{ + x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2], + }) + targetRA, targetDec, planetDistance, targetOK := occultationPathVectorRaDec(occultationPathVector{ + x: targetXYZ[0], y: targetXYZ[1], z: targetXYZ[2], + }) + if !moonOK || !targetOK { + return planetOccultationEphemerisState{}, false + } + return planetOccultationEphemerisState{ + moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance, + planetRA: targetRA, planetDec: targetDec, planetDistanceKM: planetDistance, + valid: true, + }, true +} + +func (ephemeris *planetOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float64, [3]float64, bool) { + if ephemeris == nil { + return [3]float64{}, [3]float64{}, false + } + if ephemeris.dense { + return interpolateDenseOccultationVectors(ephemeris.nodes, tt) + } + return interpolateLocalEphemerisVectors(ephemeris.nodes, tt) +} diff --git a/basic/local_ephemeris_test.go b/basic/local_ephemeris_test.go new file mode 100644 index 0000000..a588d55 --- /dev/null +++ b/basic/local_ephemeris_test.go @@ -0,0 +1,135 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +func TestSolarEclipseLocalEphemerisBoundedError(t *testing.T) { + events := []struct { + name string + seed float64 + }{ + {"2010-01-15", JDECalc(2010, 1, 15)}, + {"2014-04-29", JDECalc(2014, 4, 29)}, + {"2023-04-20", JDECalc(2023, 4, 20)}, + {"2031-05-21", JDECalc(2031, 5, 21)}, + {"2309-06-09", JDECalc(2309, 6, 9)}, + } + for _, event := range events { + t.Run(event.name, func(t *testing.T) { + center := CalcMoonSHByJDE(event.seed, 0) + ephemeris := newSolarEclipseLocalEphemeris(center) + for offset := -4.5; offset <= 4.5; offset += 0.25 { + jd := center + offset/24 + approxSun, approxMoon, ok := ephemeris.equatorialAt(jd) + if !ok { + t.Fatalf("interpolator rejected in-window time %.3fh", offset) + } + exactSun, exactMoon := solarEclipseSunMoonEquatorial(jd) + for name, pair := range map[string][2][3]float64{ + "sun": {solarEclipseLLRToXYZ(approxSun[0], approxSun[1], approxSun[2]), solarEclipseLLRToXYZ(exactSun[0], exactSun[1], exactSun[2])}, + "moon": {solarEclipseLLRToXYZ(approxMoon[0], approxMoon[1], approxMoon[2]), solarEclipseLLRToXYZ(exactMoon[0], exactMoon[1], exactMoon[2])}, + } { + if errorKM := vectorDifferenceKM(pair[0], pair[1]); errorKM > 1 { + t.Fatalf("%s interpolation error at %.3fh = %.6f km", name, offset, errorKM) + } + } + } + if _, _, ok := ephemeris.equatorialAt(center + 6.1/24); ok { + t.Fatal("interpolator should reject times outside its bounded window") + } + }) + } +} + +func TestOccultationLocalEphemerisBoundedError(t *testing.T) { + star := hr4799OccultationCoordinateForTest() + starTT := occultationTimeToTT(time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC)) + starEphemeris := newStarOccultationLocalEphemeris(starTT, star) + for offset := -4.5; offset <= 4.5; offset += 0.25 { + jd := starTT + offset/24 + got, ok := starEphemeris.stateAt(jd) + if !ok { + t.Fatalf("star interpolator rejected in-window time %.3fh", offset) + } + want := starOccultationEphemerisStateAt(jd, star) + if difference := angularSeparationDegrees(got.moonRA, got.moonDec, want.moonRA, want.moonDec); difference > 1e-5 { + t.Fatalf("moon direction interpolation error at %.3fh = %.9f deg", offset, difference) + } + if difference := angularSeparationDegrees(got.starRA, got.starDec, want.starRA, want.starDec); difference > 1e-5 { + t.Fatalf("star direction interpolation error at %.3fh = %.9f deg", offset, difference) + } + } + + config, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn occultation config is unavailable") + } + planetTT := occultationTimeToTT(time.Date(2024, 8, 21, 2, 40, 0, 0, time.UTC)) + planetEphemeris := newPlanetOccultationLocalEphemeris(planetTT, config) + for offset := -4.5; offset <= 4.5; offset += 0.25 { + jd := planetTT + offset/24 + got, ok := planetEphemeris.stateAt(jd) + if !ok { + t.Fatalf("planet interpolator rejected in-window time %.3fh", offset) + } + want := planetOccultationEphemerisStateAt(jd, config) + if difference := angularSeparationDegrees(got.moonRA, got.moonDec, want.moonRA, want.moonDec); difference > 1e-5 { + t.Fatalf("moon direction interpolation error at %.3fh = %.9f deg", offset, difference) + } + if difference := angularSeparationDegrees(got.planetRA, got.planetDec, want.planetRA, want.planetDec); difference > 1e-5 { + t.Fatalf("planet direction interpolation error at %.3fh = %.9f deg", offset, difference) + } + if math.Abs(got.planetDistanceKM-want.planetDistanceKM) > 100 { + t.Fatalf("planet distance interpolation error at %.3fh = %.6f km", offset, math.Abs(got.planetDistanceKM-want.planetDistanceKM)) + } + } +} + +func TestOccultationLocalEphemerisFullWindowBoundedError(t *testing.T) { + star := hr4799OccultationCoordinateForTest() + starTT := occultationTimeToTT(time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC)) + starEphemeris := newStarOccultationLocalEphemeris(starTT, star) + config, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn occultation config is unavailable") + } + planetTT := occultationTimeToTT(time.Date(2024, 8, 21, 2, 40, 0, 0, time.UTC)) + planetEphemeris := newPlanetOccultationLocalEphemeris(planetTT, config) + for _, offset := range []float64{-47.5, -40, -24, 24, 40, 47.5} { + starJD := starTT + offset/24 + gotStar, starOK := starEphemeris.stateAt(starJD) + wantStar := starOccultationEphemerisStateAt(starJD, star) + if !starOK { + t.Fatalf("star interpolator rejected in-window time %.3fh", offset) + } + if difference := angularSeparationDegrees(gotStar.moonRA, gotStar.moonDec, wantStar.moonRA, wantStar.moonDec); difference > 1e-5 { + t.Fatalf("star-event moon direction error at %.3fh = %.9f deg", offset, difference) + } + if difference := angularSeparationDegrees(gotStar.starRA, gotStar.starDec, wantStar.starRA, wantStar.starDec); difference > 1e-5 { + t.Fatalf("star direction error at %.3fh = %.9f deg", offset, difference) + } + + planetJD := planetTT + offset/24 + gotPlanet, planetOK := planetEphemeris.stateAt(planetJD) + wantPlanet := planetOccultationEphemerisStateAt(planetJD, config) + if !planetOK { + t.Fatalf("planet interpolator rejected in-window time %.3fh", offset) + } + if difference := angularSeparationDegrees(gotPlanet.moonRA, gotPlanet.moonDec, wantPlanet.moonRA, wantPlanet.moonDec); difference > 1e-5 { + t.Fatalf("planet-event moon direction error at %.3fh = %.9f deg", offset, difference) + } + if difference := angularSeparationDegrees(gotPlanet.planetRA, gotPlanet.planetDec, wantPlanet.planetRA, wantPlanet.planetDec); difference > 1e-5 { + t.Fatalf("planet direction error at %.3fh = %.9f deg", offset, difference) + } + if difference := math.Abs(gotPlanet.planetDistanceKM - wantPlanet.planetDistanceKM); difference > 100 { + t.Fatalf("planet distance error at %.3fh = %.6f km", offset, difference) + } + } +} + +func vectorDifferenceKM(a, b [3]float64) float64 { + return math.Sqrt((a[0]-b[0])*(a[0]-b[0]) + (a[1]-b[1])*(a[1]-b[1]) + (a[2]-b[2])*(a[2]-b[2])) +} diff --git a/basic/lunar_eclipse.go b/basic/lunar_eclipse.go index e1ca09e..8992db8 100644 --- a/basic/lunar_eclipse.go +++ b/basic/lunar_eclipse.go @@ -22,6 +22,9 @@ const ( // 输入 seedJDE 只需要落在目标望月附近,允许相差数天。 type LunarEclipseResult struct { Type LunarEclipseType + // ShadowModel 是本次使用的影半径模型,决定影半径与食分的具体数值。 + // ShadowModel is the shadow-radius model used, which fixes the shadow radii and magnitudes. + ShadowModel LunarEclipseShadowModel // Maximum 是食甚时刻;即使最终没有月食,也会返回该次望月附近 // “月面中心最接近地影中心”的几何极值时刻。 @@ -39,6 +42,10 @@ type LunarEclipseResult struct { // PenumbralStart / PenumbralEnd: 半影食始 / 半影食终 // PartialStart / PartialEnd: 初亏 / 复圆 // TotalStart / TotalEnd: 食既 / 生光 + // + // 该阶段不发生时为 NaN(0 是 −4713-11-24 的真实时刻);判断阶段是否存在一律用 Has*。 + // A contact is NaN when that phase does not occur (JD 0 is the real instant −4713-11-24); + // decide by the Has* flags, never by comparing a contact against zero. PenumbralStart float64 PenumbralEnd float64 PartialStart float64 @@ -60,6 +67,17 @@ type lunarShadowState struct { penumbraRadiusRad float64 } +// LunarEclipseShadowModel 标识月食用的是哪套影半径模型。 +// LunarEclipseShadowModel identifies which shadow-radius model produced a result. +type LunarEclipseShadowModel int + +const ( + // LunarEclipseShadowModelDanjon 是 Danjon 影半径模型。 + LunarEclipseShadowModelDanjon LunarEclipseShadowModel = iota + // LunarEclipseShadowModelChauvenet 是 Chauvenet 影半径模型。 + LunarEclipseShadowModelChauvenet +) + type lunarEclipseShadowModel int const ( @@ -117,11 +135,23 @@ func lunarEclipse(seedJDE float64, shadowModel lunarEclipseShadowModel) LunarEcl fullMoonJDE := CalcMoonSHByJDE(seedJDE, 1) maximumJDE, state, dxdt, dydt, minimumDistance := refineLunarEclipseMaximum(fullMoonJDE, shadowModel) + // 未发生的阶段保持 NaN:Has* 是权威判据,时刻字段不能拿 0 当哨兵。 + model := LunarEclipseShadowModelDanjon + if shadowModel == lunarEclipseShadowChauvenet { + model = LunarEclipseShadowModelChauvenet + } result := LunarEclipseResult{ Type: LunarEclipseNone, + ShadowModel: model, Maximum: maximumJDE, MinimumDistance: minimumDistance, PenumbralMagnitude: (state.moonRadiusRad + state.penumbraRadiusRad - minimumDistance) / (2 * state.moonRadiusRad), + PenumbralStart: math.NaN(), + PenumbralEnd: math.NaN(), + PartialStart: math.NaN(), + PartialEnd: math.NaN(), + TotalStart: math.NaN(), + TotalEnd: math.NaN(), } rawUmbralMagnitude := (state.moonRadiusRad + state.umbraRadiusRad - minimumDistance) / (2 * state.moonRadiusRad) @@ -223,7 +253,7 @@ func refineLunarEclipseContact( ) float64 { firstGuess, ok := solveLineCircleContact(maximumState, dxdt, dydt, boundaryRadius, afterMaximum) if !ok { - return 0 + return math.NaN() } contactState := computeLunarShadowState(firstGuess, shadowModel) @@ -272,11 +302,14 @@ func solveLineCircleContact( return state.jde + delta, true } -// computeLunarShadowState 计算某一力学时刻下,月心相对地影中心的二维几何状态。 -// -// 所有内部角量统一使用弧度。影半径模型允许在 Danjon 与 Chauvenet 之间切换, -// 其余月心轨迹与几何求交框架保持一致。 -func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) lunarShadowState { +// lunarEclipsePlaneState 只需要月心相对地影中心的二维坐标(穿影图采样点用,跳过半径所需的距离项)。 +type lunarEclipsePlaneState struct { + jde float64 + x float64 + y float64 +} + +func lunarEclipsePlaneStateAt(jde float64) lunarEclipsePlaneState { julianCentury := (jde - 2451545.0) / 36525.0 sunLongitude := HSunTrueLo(jde)*rad + sunLongitudeAberrationRad(julianCentury) @@ -284,6 +317,20 @@ func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) l moonLongitude := HMoonTrueLo(jde)*rad + lunarLongitudeAberration moonLatitude := HMoonTrueBo(jde)*rad + moonLatitudeAberrationRad(julianCentury) + return lunarEclipsePlaneState{ + jde: jde, + x: normalizeRadians(moonLongitude+math.Pi-sunLongitude) * math.Cos((moonLatitude-sunLatitude)/2), + y: moonLatitude + sunLatitude, + } +} + +// computeLunarShadowState 计算某一力学时刻下,月心相对地影中心的二维几何状态。 +// +// 所有内部角量统一使用弧度。影半径模型允许在 Danjon 与 Chauvenet 之间切换, +// 其余月心轨迹与几何求交框架保持一致。 +func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) lunarShadowState { + plane := lunarEclipsePlaneStateAt(jde) + moonDistanceKM := HMoonAway(jde) sunDistanceAU := EarthAway(jde) @@ -299,9 +346,9 @@ func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) l ) return lunarShadowState{ - jde: jde, - x: normalizeRadians(moonLongitude+math.Pi-sunLongitude) * math.Cos((moonLatitude-sunLatitude)/2), - y: moonLatitude + sunLatitude, + jde: plane.jde, + x: plane.x, + y: plane.y, moonRadiusRad: moonRadiusArcsec / lunarArcsecPerRadian, umbraRadiusRad: umbraRadiusArcsec / lunarArcsecPerRadian, penumbraRadiusRad: penumbraRadiusArcsec / lunarArcsecPerRadian, diff --git a/basic/lunar_eclipse_contact_test.go b/basic/lunar_eclipse_contact_test.go new file mode 100644 index 0000000..518a215 --- /dev/null +++ b/basic/lunar_eclipse_contact_test.go @@ -0,0 +1,98 @@ +package basic + +import ( + "math" + "testing" +) + +// 未发生阶段的接触时刻必须是 NaN:JD 0 是 −4713-11-24 的真实时刻,不能当“无此阶段”的哨兵。 + +func TestLunarEclipseAbsentPhasesAreNaN(t *testing.T) { + eclipse := LunarEclipse(2458860.0) + if eclipse.Type != LunarEclipsePenumbral { + t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipsePenumbral) + } + if !eclipse.HasPenumbral || eclipse.HasPartial || eclipse.HasTotal { + t.Fatalf("unexpected flags: %+v", eclipse) + } + for name, value := range map[string]float64{ + "PartialStart": eclipse.PartialStart, + "PartialEnd": eclipse.PartialEnd, + "TotalStart": eclipse.TotalStart, + "TotalEnd": eclipse.TotalEnd, + } { + if !math.IsNaN(value) { + t.Fatalf("%s = %.12f, want NaN", name, value) + } + } + for name, value := range map[string]float64{ + "PenumbralStart": eclipse.PenumbralStart, + "PenumbralEnd": eclipse.PenumbralEnd, + "Maximum": eclipse.Maximum, + } { + if !isFiniteFloat(value) { + t.Fatalf("%s = %v, want a finite contact", name, value) + } + } +} + +func TestLunarEclipseWithoutEclipseHasNoContacts(t *testing.T) { + // 2025-01-13 的望月没有月食(半影食分 < 0)。 + eclipse := LunarEclipse(JDECalc(2025, 1, 13)) + if eclipse.Type != LunarEclipseNone { + t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipseNone) + } + if eclipse.HasPenumbral || eclipse.HasPartial || eclipse.HasTotal { + t.Fatalf("unexpected flags: %+v", eclipse) + } + for name, value := range map[string]float64{ + "PenumbralStart": eclipse.PenumbralStart, + "PenumbralEnd": eclipse.PenumbralEnd, + "PartialStart": eclipse.PartialStart, + "PartialEnd": eclipse.PartialEnd, + "TotalStart": eclipse.TotalStart, + "TotalEnd": eclipse.TotalEnd, + } { + if !math.IsNaN(value) { + t.Fatalf("%s = %.12f, want NaN", name, value) + } + } + if !isFiniteFloat(eclipse.Maximum) { + t.Fatalf("Maximum = %v, want the geometric extremum", eclipse.Maximum) + } +} + +func TestLunarEclipseTotalKeepsAllContactsFinite(t *testing.T) { + eclipse := LunarEclipse(JDECalc(2025, 3, 14)) + if eclipse.Type != LunarEclipseTotal || !eclipse.HasPenumbral || !eclipse.HasPartial || !eclipse.HasTotal { + t.Fatalf("unexpected result: %+v", eclipse) + } + contacts := []float64{ + eclipse.PenumbralStart, eclipse.PartialStart, eclipse.TotalStart, + eclipse.Maximum, + eclipse.TotalEnd, eclipse.PartialEnd, eclipse.PenumbralEnd, + } + for i, value := range contacts { + if !isFiniteFloat(value) { + t.Fatalf("contact %d = %v, want finite", i, value) + } + if i > 0 && value <= contacts[i-1] { + t.Fatalf("contacts not increasing at %d: %v", i, contacts) + } + } +} + +func TestLunarEclipseDiagramSkipsAbsentPhases(t *testing.T) { + diagram := LunarEclipseDiagram(2458860.0, LunarEclipseDiagramOptions{}) + if len(diagram.Points) == 0 { + t.Fatalf("penumbral eclipse diagram should still have path points") + } + for _, point := range diagram.Points { + if !isFiniteFloat(point.JDE) || !isFiniteFloat(point.X) || !isFiniteFloat(point.Y) { + t.Fatalf("non-finite diagram point: %+v", point) + } + } + if empty := LunarEclipseDiagram(JDECalc(2025, 1, 13), LunarEclipseDiagramOptions{}); len(empty.Points) != 0 { + t.Fatalf("eclipse-free diagram should have no points, got %d", len(empty.Points)) + } +} diff --git a/basic/lunar_eclipse_diagram.go b/basic/lunar_eclipse_diagram.go index 2dc8067..c3bfa2a 100644 --- a/basic/lunar_eclipse_diagram.go +++ b/basic/lunar_eclipse_diagram.go @@ -111,11 +111,11 @@ func lunarEclipseDiagram( result.StepDays = stepDays result.Points = make([]LunarEclipseDiagramPoint, 0, len(times)) for _, item := range times { - state := computeLunarShadowState(item.jde, shadowModel) + plane := lunarEclipsePlaneStateAt(item.jde) result.Points = append(result.Points, LunarEclipseDiagramPoint{ JDE: item.jde, - X: state.x / maximumState.moonRadiusRad, - Y: state.y / maximumState.moonRadiusRad, + X: plane.x / maximumState.moonRadiusRad, + Y: plane.y / maximumState.moonRadiusRad, Label: lunarEclipseDiagramPrimaryLabel(item.labels), Labels: append([]string(nil), item.labels...), }) @@ -136,7 +136,7 @@ func normalizeLunarEclipseDiagramOptions(options LunarEclipseDiagramOptions) Lun func lunarEclipseDiagramTimes(eclipse LunarEclipseResult, stepDays float64) ([]lunarEclipseDiagramTime, float64) { startJDE := eclipse.PenumbralStart endJDE := eclipse.PenumbralEnd - if startJDE == 0 || endJDE == 0 || endJDE <= startJDE { + if !isFiniteFloat(startJDE) || !isFiniteFloat(endJDE) || endJDE <= startJDE { return nil, stepDays } @@ -181,7 +181,7 @@ func uniqueLunarEclipseDiagramTimes(times []lunarEclipseDiagramTime) []lunarEcli unique := times[:0] for _, item := range times { - if item.jde == 0 { + if !isFiniteFloat(item.jde) { continue } if len(unique) == 0 || math.Abs(item.jde-unique[len(unique)-1].jde) > lunarEclipseDiagramDuplicateDays { diff --git a/basic/lunar_eclipse_geometry.go b/basic/lunar_eclipse_geometry.go new file mode 100644 index 0000000..27d4cd1 --- /dev/null +++ b/basic/lunar_eclipse_geometry.go @@ -0,0 +1,65 @@ +package basic + +import "math" + +// LunarEclipseShadowGeometry 是食甚时刻的地影几何。 +// 注意单位口径:Gamma 用地球赤道半径,而两个影半径用度——后者是 NASA 月食图上 P./U. Radius 的口径, +// 换成地球赤道半径要乘以月球处的地球视差(弧度)。 +// LunarEclipseShadowGeometry is the terrestrial-shadow geometry at maximum eclipse. Gamma is in Earth +// equatorial radii while the two shadow radii are in degrees, matching the P./U. Radius convention of +// NASA lunar-eclipse charts; multiply a radius by the Earth's parallax at the Moon to get Earth radii. +type LunarEclipseShadowGeometry struct { + // Gamma 是月心到地影轴的最小距离,单位地球赤道半径。 + Gamma float64 + // PenumbralRadiusDegrees 与 UmbralRadiusDegrees 是半影、本影在地影轴垂直面上的角半径,单位度。 + PenumbralRadiusDegrees float64 + UmbralRadiusDegrees float64 + // MoonDistanceEarthRadii 是食甚时的地心月距。 + MoonDistanceEarthRadii float64 + // AxisDegrees 是食甚时月心到地影轴的角距,即 NASA 月食图上那列 Axis。 + // 它与 Gamma 是同一个量的两种刻度:Gamma 除以月球处的地球视差就是它。 + // AxisDegrees is the angular distance from the Moon's centre to the shadow axis at greatest eclipse, + // the column NASA lunar-eclipse charts print as Axis. It is the same quantity as Gamma on a different + // scale: divide Gamma by the Earth's parallax at the Moon. + AxisDegrees float64 +} + +// LunarEclipseShadowGeometryAt 用 Danjon 影半径模型计算食甚时刻的地影几何。 +// LunarEclipseShadowGeometryAt computes the shadow geometry at maximum eclipse with the Danjon shadow model. +func LunarEclipseShadowGeometryAt(maximumJDE float64) LunarEclipseShadowGeometry { + return lunarEclipseShadowGeometryAt(maximumJDE, lunarEclipseShadowDanjon) +} + +// LunarEclipseShadowGeometryChauvenetAt 用 Chauvenet 影半径模型计算食甚时刻的地影几何。 +// LunarEclipseShadowGeometryChauvenetAt computes the shadow geometry with the Chauvenet shadow model. +func LunarEclipseShadowGeometryChauvenetAt(maximumJDE float64) LunarEclipseShadowGeometry { + return lunarEclipseShadowGeometryAt(maximumJDE, lunarEclipseShadowChauvenet) +} + +// LunarEclipseShadowGeometryAtModel 按结果里记录的影半径模型取地影几何。 +// LunarEclipseShadowGeometryAtModel picks the shadow geometry by the model recorded in a result. +func LunarEclipseShadowGeometryAtModel(maximumJDE float64, model LunarEclipseShadowModel) LunarEclipseShadowGeometry { + if model == LunarEclipseShadowModelChauvenet { + return LunarEclipseShadowGeometryChauvenetAt(maximumJDE) + } + return LunarEclipseShadowGeometryAt(maximumJDE) +} + +func lunarEclipseShadowGeometryAt(maximumJDE float64, shadowModel lunarEclipseShadowModel) LunarEclipseShadowGeometry { + state := computeLunarShadowState(maximumJDE, shadowModel) + moonDistanceKM := HMoonAway(maximumJDE) + // 影半径是以地心为顶点的角量,除以月球处的地球视差就换成地球赤道半径。 + earthParallax := lunarEarthEquatorialRadiusKM / moonDistanceKM + _, _, _, _, minimumDistance := refineLunarEclipseMaximum(maximumJDE, shadowModel) + if earthParallax <= 0 { + return LunarEclipseShadowGeometry{} + } + return LunarEclipseShadowGeometry{ + Gamma: minimumDistance / earthParallax, + PenumbralRadiusDegrees: state.penumbraRadiusRad * 180 / math.Pi, + UmbralRadiusDegrees: state.umbraRadiusRad * 180 / math.Pi, + MoonDistanceEarthRadii: moonDistanceKM / lunarEarthEquatorialRadiusKM, + // 平面 x 向东(黄经差)、y 向北(黄纬和),方位角自北向东量。 + AxisDegrees: minimumDistance * 180 / math.Pi, + } +} diff --git a/basic/lunar_eclipse_geometry_test.go b/basic/lunar_eclipse_geometry_test.go new file mode 100644 index 0000000..6f2d539 --- /dev/null +++ b/basic/lunar_eclipse_geometry_test.go @@ -0,0 +1,53 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +// Axis 与 Gamma 是同一个量的两种刻度:Gamma × 月球处的地球视差 = Axis。 +func TestLunarEclipseShadowGeometryAxisMatchesGamma(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC), + time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC), + time.Date(2028, time.July, 6, 0, 0, 0, 0, time.UTC), + } { + result := LunarEclipse(TD2UT(Date2JDE(date), true)) + geometry := LunarEclipseShadowGeometryAt(result.Maximum) + // 影几何内部用的是小角近似的地球视差(R⊕/月距),这里必须用同一形式,否则二级差会露出来。 + earthParallaxDegrees := (1 / geometry.MoonDistanceEarthRadii) * 180 / math.Pi + if delta := geometry.Gamma*earthParallaxDegrees - geometry.AxisDegrees; math.Abs(delta) > 1e-9 { + t.Fatalf("%s: Gamma×parallax - Axis = %g", date.Format("2006-01-02"), delta) + } + } +} + +// 食分公式反解出的月心到影轴距离必须等于 Axis:这条把影几何与食分求解器对起来。 +// 食分 = (影半径 + 月视半径 − Axis) / (2 × 月视半径)。 +func TestLunarEclipseMagnitudeInvertsToAxis(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC), + time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC), + time.Date(2028, time.July, 6, 0, 0, 0, 0, time.UTC), + time.Date(2020, time.November, 30, 0, 0, 0, 0, time.UTC), + } { + result := LunarEclipse(TD2UT(Date2JDE(date), true)) + geometry := LunarEclipseShadowGeometryAt(result.Maximum) + moonSemidiameter := MoonSemidiameter(result.Maximum) / 3600 + fromUmbral := geometry.UmbralRadiusDegrees + moonSemidiameter - + 2*moonSemidiameter*result.Magnitude + fromPenumbral := geometry.PenumbralRadiusDegrees + moonSemidiameter - + 2*moonSemidiameter*result.PenumbralMagnitude + t.Logf("%s %s: Axis=%.4f 本影反推=%.4f 半影反推=%.4f", + date.Format("2006-01-02"), result.Type, geometry.AxisDegrees, fromUmbral, fromPenumbral) + if math.Abs(fromUmbral-geometry.AxisDegrees) > 0.002 { + t.Fatalf("%s: umbral magnitude implies axis %.4f, geometry gives %.4f", + date.Format("2006-01-02"), fromUmbral, geometry.AxisDegrees) + } + if math.Abs(fromPenumbral-geometry.AxisDegrees) > 0.002 { + t.Fatalf("%s: penumbral magnitude implies axis %.4f, geometry gives %.4f", + date.Format("2006-01-02"), fromPenumbral, geometry.AxisDegrees) + } + } +} diff --git a/basic/lunar_eclipse_test.go b/basic/lunar_eclipse_test.go index 945f126..8aa183d 100644 --- a/basic/lunar_eclipse_test.go +++ b/basic/lunar_eclipse_test.go @@ -133,12 +133,12 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) { t.Fatalf("Magnitude mismatch: got %.12f want %.12f", result.Magnitude, tc.expectedMag) } - assertCloseJD(t, "PenumbralStart", result.PenumbralStart, tc.expectedPenumbralStart, timeTolerance) - assertCloseJD(t, "PenumbralEnd", result.PenumbralEnd, tc.expectedPenumbralEnd, timeTolerance) - assertCloseJD(t, "PartialStart", result.PartialStart, tc.expectedPartialStart, timeTolerance) - assertCloseJD(t, "PartialEnd", result.PartialEnd, tc.expectedPartialEnd, timeTolerance) - assertCloseJD(t, "TotalStart", result.TotalStart, tc.expectedTotalStart, timeTolerance) - assertCloseJD(t, "TotalEnd", result.TotalEnd, tc.expectedTotalEnd, timeTolerance) + assertContactJD(t, "PenumbralStart", result.PenumbralStart, tc.expectedPenumbralStart, timeTolerance) + assertContactJD(t, "PenumbralEnd", result.PenumbralEnd, tc.expectedPenumbralEnd, timeTolerance) + assertContactJD(t, "PartialStart", result.PartialStart, tc.expectedPartialStart, timeTolerance) + assertContactJD(t, "PartialEnd", result.PartialEnd, tc.expectedPartialEnd, timeTolerance) + assertContactJD(t, "TotalStart", result.TotalStart, tc.expectedTotalStart, timeTolerance) + assertContactJD(t, "TotalEnd", result.TotalEnd, tc.expectedTotalEnd, timeTolerance) if result.HasTotal && !(result.TotalStart < result.Maximum && result.Maximum < result.TotalEnd) { t.Fatalf("total contact order invalid: start=%.12f max=%.12f end=%.12f", result.TotalStart, result.Maximum, result.TotalEnd) @@ -284,8 +284,17 @@ func TestLunarEclipseNoEvent(t *testing.T) { if result.HasPenumbral || result.HasPartial || result.HasTotal { t.Fatalf("unexpected contacts: %+v", result) } - if result.PenumbralStart != 0 || result.PenumbralEnd != 0 || result.PartialStart != 0 || result.PartialEnd != 0 || result.TotalStart != 0 || result.TotalEnd != 0 { - t.Fatalf("expected no contact times, got %+v", result) + for name, value := range map[string]float64{ + "PenumbralStart": result.PenumbralStart, + "PenumbralEnd": result.PenumbralEnd, + "PartialStart": result.PartialStart, + "PartialEnd": result.PartialEnd, + "TotalStart": result.TotalStart, + "TotalEnd": result.TotalEnd, + } { + if !math.IsNaN(value) { + t.Fatalf("%s = %.12f, want NaN for a non-eclipse: %+v", name, value, result) + } } if result.Magnitude != 0 || result.PenumbralMagnitude != 0 { t.Fatalf("expected zero magnitudes for non-eclipse, got %+v", result) @@ -306,3 +315,15 @@ func assertCloseJD(t *testing.T, name string, got, want, tolerance float64) { t.Fatalf("%s mismatch: got %.12f want %.12f", name, got, want) } } + +// assertContactJD 未发生阶段的期望值是 0(基准表写法),此时实际值必须是 NaN 而不是 JD 0。 +func assertContactJD(t *testing.T, name string, got, want, tolerance float64) { + t.Helper() + if want == 0 { + if !math.IsNaN(got) { + t.Fatalf("%s = %.12f, want NaN for an absent phase", name, got) + } + return + } + assertCloseJD(t, name, got, want, tolerance) +} diff --git a/basic/mars_events.go b/basic/mars_events.go index 6e2be60..5f54fa8 100644 --- a/basic/mars_events.go +++ b/basic/mars_events.go @@ -175,6 +175,9 @@ func LastMarsWesternQuadrature(jde float64) float64 { } func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 { + if !isFiniteFloat(oppositionJD) { + return math.NaN() + } oppositionTT := TD2UT(oppositionJD, true) startTT := oppositionTT endTT := oppositionTT @@ -194,41 +197,90 @@ func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition } func NextMarsRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := marsConjunctionFull(jde, 180, 0) date := marsRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } nextOppositionJD := marsConjunctionFull(jde, 180, 1) - return marsRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } + date = marsRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastMarsRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := marsConjunctionFull(jde, 180, 0) date := marsRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0) - return marsRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(previousOppositionJD) { + return math.NaN() + } + date = marsRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } func NextMarsProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := marsConjunctionFull(jde, 180, 1) date := marsRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1) - return marsRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(followingOppositionJD) { + return math.NaN() + } + date = marsRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastMarsProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := marsConjunctionFull(jde, 180, 1) date := marsRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } lastOppositionJD := marsConjunctionFull(jde, 180, 0) - return marsRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } + date = marsRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } diff --git a/basic/memo_bench_test.go b/basic/memo_bench_test.go new file mode 100644 index 0000000..3642730 --- /dev/null +++ b/basic/memo_bench_test.go @@ -0,0 +1,56 @@ +package basic + +import ( + "testing" +) + +// 这些基准是"性能不回退"的度量口径:Nutation2000B 有界记忆表、升落上下文 stateAt 的分配、 +// 以及两条真实调用链(月球升落、掩星升落回归)。改动这些热路径后请对比 benchmark 数字。 +// These benchmarks are the regression yardstick for the hot paths changed here: the bounded +// Nutation2000B memo, the allocation behaviour of the rise/set context stateAt, and two real call +// chains (moon rise/set and the occultation rise/set regression case). Compare before/after numbers +// whenever those paths change. +func BenchmarkNutation2000B(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _ = Nutation2000B(2460310.5 + float64(i%97)*0.37) + } +} + +func BenchmarkTrueObliquity(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = TrueObliquity(2460310.5 + float64(i%97)*0.37) + } +} + +var benchMoonRise, benchMoonSet float64 + +func BenchmarkMoonRiseSetChain(b *testing.B) { + jd := JDECalc(2023, 6, 21) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + benchMoonRise, _ = GetMoonRiseTime(jd, 116.4074, 39.9042, 8, 1, 0) + benchMoonSet, _ = GetMoonSetTime(jd, 116.4074, 39.9042, 8, 1, 0) + } +} + +// Nutation2000B 有界记忆表:重复瞬时必须命中且数值逐位不变。 +func TestNutationMemoHitsRepeatedInstants(t *testing.T) { + resetNutationMemo() + const jd = 2460310.5 + firstPsi, firstEps := Nutation2000B(jd) + secondPsi, secondEps := Nutation2000B(jd) + if firstPsi != secondPsi || firstEps != secondEps { + t.Fatalf("memo changed the value: (%v,%v) vs (%v,%v)", firstPsi, firstEps, secondPsi, secondEps) + } + hits, misses := nutationMemoStats() + if hits == 0 || misses == 0 { + t.Fatalf("expected one miss and at least one hit, got hits=%d misses=%d", hits, misses) + } + // 与未走记忆表的实现逐位一致。 + directPsi, directEps := nutation2000BCompute(jd) + if firstPsi != directPsi || firstEps != directEps { + t.Fatalf("memoized value differs from direct computation") + } +} diff --git a/basic/mercury_events.go b/basic/mercury_events.go index 8ed1f2c..15c9e2f 100644 --- a/basic/mercury_events.go +++ b/basic/mercury_events.go @@ -17,6 +17,20 @@ const ( mercuryStationCoarseStepDay = 2.0 mercuryStationHalfWindowDay = 2.0 mercuryStationMotionTolerance = 1e-3 + // mercuryStationAnchorAttempts 类型化「留」在锚点不满足侧向不变量时,最多推进/回退几个会合周期。 + mercuryStationAnchorAttempts = 4 + // mercuryConjunctionSameInstantDegrees 合搜索「同刻」快速路径的视黄经差触发阈值(度)。 + // 0.05 度约合 30 分钟的时间跨度,远大于截断级数在根附近的误差, + // 从而保证「查询落在合附近」时一定走精确解 + 侧向判定,而不是被方向扫描跨过去。 + mercuryConjunctionSameInstantDegrees = 5.0e-2 + // mercuryConjunctionScanStepDay / mercuryConjunctionScanMaxSteps 方向性括号扫描参数: + // 8 天一步 × 80 步 = 640 天,覆盖一个水星会合周期(115.88 天)且有大量余量。 + mercuryConjunctionScanStepDay = 8.0 + mercuryConjunctionScanMaxSteps = 80 + // mercuryConjunctionPolishToleranceDay 全项抛光的时间容差(0.01 秒): + // 割线法从 8 天括号收敛到该量级只需多 1~2 次求值,但把合时刻精度保持在毫秒级 + // (与旧实现牛顿法的 1e-5 天口径一致)。 + mercuryConjunctionPolishToleranceDay = 0.01 / 86400.0 ) type mercuryConjunctionLBR struct { @@ -132,15 +146,25 @@ func mercuryConjunctionExactTT(seed float64, inferior bool) float64 { return estimateJD } +// mercuryConjunction 在 jde 的指定方向上求最近一次水星合(内合/外合)。 +// +// 旧实现用「启发式跳 + 2 天一步走」:跳过头落到合之后时,|Δ| 会持续变大, +// 于是走满一个会合周期、跳过一次合(例如 2008-01-22 查到 2008-06-07 而不是 2008-02-06), +// 并且下游的类型化「留」会因此返回错事件、甚至让 Last 返回未来。 +// 新实现改成方向性括号扫描:截断级数逐段找异号区间(顺序扫描 ⇒ 一定取最近的一个合), +// 再用全项级数在括号内抛光;扫满 640 天仍无括号时有界返回 NaN。 func mercuryConjunction(jde float64, next uint8) float64 { //0=last 1=next if !isFiniteFloat(jde) { return math.NaN() } - if math.Abs(mercuryConjunctionExactDelta(jde)) <= 30.0/86400.0 { + if math.Abs(mercuryConjunctionExactDelta(jde)) <= mercuryConjunctionSameInstantDegrees { best := math.NaN() consider := func(inferior bool) { eventUT := TD2UT(mercuryConjunctionExactTT(jde, inferior), false) + if !isFiniteFloat(eventUT) { + return + } if next == 0 && !eventUTQueryBeforeOrEqual(eventUT, jde) { return } @@ -157,54 +181,87 @@ func mercuryConjunction(jde float64, next uint8) float64 { return best } } - currentDelta := mercuryConjunctionExactDelta(jde) - // pos 大于0:远离太阳 小于0:靠近太阳 - distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta) - if distanceTrend >= 0 && next == 1 && currentDelta > 0 { - jde += MERCURY_S_PERIOD/8.0 + 2 + + direction := 1.0 + if next == 0 { + direction = -1 } - if distanceTrend >= 0 && next == 1 && currentDelta < 0 { - jde += MERCURY_S_PERIOD/6.0 + 2 + leftJD := jde + leftValue := mercuryConjunctionDeltaN(leftJD, mercuryEventSearchN) + if !isFiniteFloat(leftValue) { + return math.NaN() } - if distanceTrend <= 0 && next == 0 && currentDelta < 0 { - jde -= MERCURY_S_PERIOD/8.0 + 2 - } - if distanceTrend <= 0 && next == 0 && currentDelta > 0 { - jde -= MERCURY_S_PERIOD/6.0 + 2 - } - found := false - for i := 0; i < eventDirectionalSearchIterations; i++ { - currentDelta := mercuryConjunctionExactDelta(jde) - nextDelta := mercuryConjunctionExactDelta(jde + 1/86400.0) - if !isFiniteFloat(currentDelta) || !isFiniteFloat(nextDelta) { + for i := 0; i < mercuryConjunctionScanMaxSteps; i++ { + rightJD := jde + direction*mercuryConjunctionScanStepDay*float64(i+1) + rightValue := mercuryConjunctionDeltaN(rightJD, mercuryEventSearchN) + if !isFiniteFloat(rightValue) { return math.NaN() } - distanceTrend := math.Abs(nextDelta) - math.Abs(currentDelta) - if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) { - if next == 1 { - jde += 2 - } else { - jde -= 2 + if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 { + return mercuryConjunctionPolish(jde, leftJD, rightJD, direction) + } + leftJD, leftValue = rightJD, rightValue + } + return math.NaN() +} + +// mercuryConjunctionDeltaN 截断级数下的水星-太阳视黄经差(度,[-180,180]),用于方向性括号扫描。 +func mercuryConjunctionDeltaN(jd float64, n int) float64 { + return mercuryConjunctionAngleDelta(MercuryApparentLoN(jd, n) - HSunApparentLoN(jd, n)) +} + +// mercuryConjunctionPolish 用全项级数在截断级数给出的括号内抛光。 +// 截断误差可能让括号两端在全项函数上同号(罕见),此时沿扫描方向再扩一两个扫描步; +// 若仍未被确认(典型情形:查询几乎正好落在合上,截断级数在根两侧的符号与全项不一致), +// 退回全项级数的方向扫描,保证有界且不返回 NaN。 +func mercuryConjunctionPolish(jde, leftJD, rightJD, direction float64) float64 { + for attempt := 0; attempt < 3; attempt++ { + leftValue := mercuryConjunctionExactDelta(leftJD) + rightValue := mercuryConjunctionExactDelta(rightJD) + if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) { + return math.NaN() + } + if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 { + root, ok := eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, + mercuryConjunctionPolishToleranceDay, mercuryConjunctionExactDelta) + if !ok { + return math.NaN() } + return TD2UT(root, false) + } + if direction > 0 { + rightJD += mercuryConjunctionScanStepDay continue } - found = true - break - } - if !found { - return math.NaN() + leftJD -= mercuryConjunctionScanStepDay } + return mercuryConjunctionFullDirectionalScan(jde, direction) +} - inferior := mercuryConjunctionExactTT(jde, true) - superior := mercuryConjunctionExactTT(jde, false) - if !isFiniteFloat(inferior) || !isFiniteFloat(superior) { +// mercuryConjunctionFullDirectionalScan 全项级数的方向扫描(截断括号未被确认时的兜底)。 +func mercuryConjunctionFullDirectionalScan(jde, direction float64) float64 { + leftJD := jde + leftValue := mercuryConjunctionExactDelta(leftJD) + if !isFiniteFloat(leftValue) { return math.NaN() } - best := inferior - if math.Abs(superior-jde) < math.Abs(inferior-jde) { - best = superior + for i := 0; i < mercuryConjunctionScanMaxSteps; i++ { + rightJD := jde + direction*mercuryConjunctionScanStepDay*float64(i+1) + rightValue := mercuryConjunctionExactDelta(rightJD) + if !isFiniteFloat(rightValue) { + return math.NaN() + } + if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 { + root, ok := eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, + mercuryConjunctionPolishToleranceDay, mercuryConjunctionExactDelta) + if !ok { + return math.NaN() + } + return TD2UT(root, false) + } + leftJD, leftValue = rightJD, rightValue } - return TD2UT(best, false) + return math.NaN() } func LastMercuryConjunction(jde float64) float64 { @@ -293,8 +350,13 @@ func mercuryStationBetween(startTT, endTT float64) bool { if endTT-startTT <= 0 { return false } + // 跨度超过一个回留窗口时截断扫描已不可靠:返回 false 不再阻断快速路径。 + // 此时候选事件由 mercuryConjunction 的完整方向扫描保证,快速路径无需再复核。 + // A span beyond the station window makes the truncated scan unreliable; report false so the + // fast path is not blocked. The typed candidate is already guaranteed by mercuryConjunction's + // exhaustive directional scan. if endTT-startTT > mercuryStationWindowDays { - return true + return false } // 截断扫描足以判断单候选快速路径是否安全 / A truncated scan is enough to decide whether the one-candidate fast path is safe. left := startTT @@ -324,114 +386,161 @@ func mercuryRetrogradeToProgradeAroundInferior(inferiorUT float64) float64 { return mercuryStationInWindow(inferiorTT, inferiorTT+mercuryStationWindowDays) } +// 类型化「留」统一结构:以下合为锚点求候选,再用侧向不变量(Next 不得早于查询、 +// Last 不得晚于查询)校验;不满足则把锚点推进/回退一个会合周期重试,最多 +// mercuryStationAnchorAttempts 次,最终仍有界失败为 NaN。 +// 这保证 Last* 永远不会返回未来、Next* 永远不会返回过去的事件。 func NextMercuryProgradeToRetrograde(jde float64) float64 { - inferior := NextMercuryInferiorConjunction(jde) - date := mercuryProgradeToRetrogradeAroundInferior(inferior) - if eventUTQueryAfterOrEqual(date, jde) { - return date + if !isFiniteFloat(jde) { + return math.NaN() } - followingInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior)) - return mercuryProgradeToRetrogradeAroundInferior(followingInferior) + inferior := NextMercuryInferiorConjunction(jde) + for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ { + date := mercuryProgradeToRetrogradeAroundInferior(inferior) + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { + return date + } + inferior = NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior)) + } + return math.NaN() } func NextMercuryRetrogradeToPrograde(jde float64) float64 { - inferior := LastMercuryInferiorConjunction(jde) - date := mercuryRetrogradeToProgradeAroundInferior(inferior) - if eventUTQueryAfterOrEqual(date, jde) { - return date + if !isFiniteFloat(jde) { + return math.NaN() } - nextInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior)) - return mercuryRetrogradeToProgradeAroundInferior(nextInferior) + inferior := LastMercuryInferiorConjunction(jde) + for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ { + date := mercuryRetrogradeToProgradeAroundInferior(inferior) + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { + return date + } + inferior = NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior)) + } + return math.NaN() } func LastMercuryProgradeToRetrograde(jde float64) float64 { - inferior := NextMercuryInferiorConjunction(jde) - date := mercuryProgradeToRetrogradeAroundInferior(inferior) - if eventUTQueryBeforeOrEqual(date, jde) { - return date + if !isFiniteFloat(jde) { + return math.NaN() } - previousInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior)) - return mercuryProgradeToRetrogradeAroundInferior(previousInferior) + inferior := NextMercuryInferiorConjunction(jde) + for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ { + date := mercuryProgradeToRetrogradeAroundInferior(inferior) + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { + return date + } + inferior = LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior)) + } + return math.NaN() } func LastMercuryRetrogradeToPrograde(jde float64) float64 { - inferior := LastMercuryInferiorConjunction(jde) - date := mercuryRetrogradeToProgradeAroundInferior(inferior) - if eventUTQueryBeforeOrEqual(date, jde) { - return date + if !isFiniteFloat(jde) { + return math.NaN() } - previousInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior)) - return mercuryRetrogradeToProgradeAroundInferior(previousInferior) + inferior := LastMercuryInferiorConjunction(jde) + for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ { + date := mercuryRetrogradeToProgradeAroundInferior(inferior) + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { + return date + } + inferior = LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior)) + } + return math.NaN() +} + +// earliestFiniteEventUT / latestFiniteEventUT 在候选中取最早/最晚的有限值(全部非有限时返回 NaN)。 +func earliestFiniteEventUT(candidates ...float64) float64 { + best := math.NaN() + for _, candidate := range candidates { + if !isFiniteFloat(candidate) { + continue + } + if math.IsNaN(best) || candidate < best { + best = candidate + } + } + return best +} + +func latestFiniteEventUT(candidates ...float64) float64 { + best := math.NaN() + for _, candidate := range candidates { + if !isFiniteFloat(candidate) { + continue + } + if math.IsNaN(best) || candidate > best { + best = candidate + } + } + return best } func nextMercuryRetrogradeFromTyped(jde float64) float64 { - p2r := NextMercuryProgradeToRetrograde(jde) - r2p := NextMercuryRetrogradeToPrograde(jde) - if p2r < r2p { - return p2r - } - return r2p + return earliestFiniteEventUT(NextMercuryProgradeToRetrograde(jde), NextMercuryRetrogradeToPrograde(jde)) } func NextMercuryRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay) if motion > mercuryStationMotionTolerance { p2r := NextMercuryProgradeToRetrograde(jde) - if !mercuryStationBetween(jde, TD2UT(p2r, true)) { + if isFiniteFloat(p2r) && !mercuryStationBetween(jde, TD2UT(p2r, true)) { return p2r } - r2p := NextMercuryRetrogradeToPrograde(jde) - if p2r < r2p { - return p2r + best := earliestFiniteEventUT(p2r, NextMercuryRetrogradeToPrograde(jde)) + if !stationUTQueryAfterOrEqual(best, jde) { + return math.NaN() } - return r2p + return best } if motion < -mercuryStationMotionTolerance { r2p := NextMercuryRetrogradeToPrograde(jde) - if !mercuryStationBetween(jde, TD2UT(r2p, true)) { + if isFiniteFloat(r2p) && !mercuryStationBetween(jde, TD2UT(r2p, true)) { return r2p } - p2r := NextMercuryProgradeToRetrograde(jde) - if p2r < r2p { - return p2r + best := earliestFiniteEventUT(NextMercuryProgradeToRetrograde(jde), r2p) + if !stationUTQueryAfterOrEqual(best, jde) { + return math.NaN() } - return r2p + return best } return nextMercuryRetrogradeFromTyped(jde) } func lastMercuryRetrogradeFromTyped(jde float64) float64 { - p2r := LastMercuryProgradeToRetrograde(jde) - r2p := LastMercuryRetrogradeToPrograde(jde) - if p2r > r2p { - return p2r - } - return r2p + return latestFiniteEventUT(LastMercuryProgradeToRetrograde(jde), LastMercuryRetrogradeToPrograde(jde)) } func LastMercuryRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay) if motion > mercuryStationMotionTolerance { r2p := LastMercuryRetrogradeToPrograde(jde) - if !mercuryStationBetween(TD2UT(r2p, true), jde) { + if isFiniteFloat(r2p) && !mercuryStationBetween(TD2UT(r2p, true), jde) { return r2p } - p2r := LastMercuryProgradeToRetrograde(jde) - if p2r > r2p { - return p2r + best := latestFiniteEventUT(LastMercuryProgradeToRetrograde(jde), r2p) + if !stationUTQueryBeforeOrEqual(best, jde) { + return math.NaN() } - return r2p + return best } if motion < -mercuryStationMotionTolerance { p2r := LastMercuryProgradeToRetrograde(jde) - if !mercuryStationBetween(TD2UT(p2r, true), jde) { + if isFiniteFloat(p2r) && !mercuryStationBetween(TD2UT(p2r, true), jde) { return p2r } - r2p := LastMercuryRetrogradeToPrograde(jde) - if p2r > r2p { - return p2r + best := latestFiniteEventUT(p2r, LastMercuryRetrogradeToPrograde(jde)) + if !stationUTQueryBeforeOrEqual(best, jde) { + return math.NaN() } - return r2p + return best } return lastMercuryRetrogradeFromTyped(jde) } @@ -458,20 +567,15 @@ func mercurySunElongationN(jde float64, n int) float64 { return StarAngularSeparation(lo1, bo1, lo2, bo2) } -func mercuryTrueElongationN(jde float64, n int) float64 { - earth := mercuryHelioN(-1, jde, n) - planetPos := mercuryHelioN(1, jde, n) - geo := mercuryGeocentric(planetPos, earth) - return StarAngularSeparation(geo.lo, geo.bo, HSunTrueLoN(jde, n), HSunTrueBoN(jde, n)) -} - +// mercuryGreatestElongationInWindow 求窗口内大距:目标是公开 MercurySunElongation 的极大(视距角), +// 而不是忽略光行差/视位置修正的真距角,否则返回的时刻不是调用方能量到的那个极值。 +// 窗口两端是世界时,目标函数收力学时,因此逐次换算。 func mercuryGreatestElongationInWindow(start, end float64) float64 { - best := maximizeInWindow(start, end, 2.0, func(jd float64) float64 { - return mercuryTrueElongationN(jd, mercuryEventSearchN) - }, func(jd float64) float64 { - return mercuryTrueElongationN(jd, -1) + return maximizeInWindow(start, end, 2.0, func(utJD float64) float64 { + return mercurySunElongationN(TD2UT(utJD, true), mercuryEventSearchN) + }, func(utJD float64) float64 { + return MercurySunElongation(TD2UT(utJD, true)) }) - return TD2UT(best, false) } func mercuryEastElongationWindowEndingAt(inferior float64) (float64, float64) { @@ -572,93 +676,57 @@ func lastMercuryGreatestElongationTyped(jde float64, east bool) float64 { return math.NaN() } -func mercuryGreatestElongation(jde float64) float64 { +// mercuryElongationWindowAt 返回包含 jde 的该侧大距窗口;查询落在合的 4 秒内边距里时两侧都不包含。 +func mercuryElongationWindowAt(jde float64, east bool) (float64, float64, bool) { + if east { + start, end := mercuryEastElongationWindowEndingAt(NextMercuryInferiorConjunction(jde)) + return start, end, eventUTQueryBeforeOrEqual(start, jde) && eventUTQueryAfterOrEqual(end, jde) + } + start, end := mercuryWestElongationWindowEndingAt(NextMercurySuperiorConjunction(jde)) + return start, end, eventUTQueryBeforeOrEqual(start, jde) && eventUTQueryAfterOrEqual(end, jde) +} + +// 无东西侧参数的 Next/Last 先只看查询所在窗口那一侧:该侧大距未过就是答案,已过则另一侧紧接着的 +// 下一个才是答案,因此只有在查询贴住合(两侧窗口都不含)时才退化为两侧都算。赤经差在合附近会提前 +// 变号,不能用它定窗口。 +func NextMercuryGreatestElongation(jde float64) float64 { if !isFiniteFloat(jde) { return math.NaN() } - solarRADelta := func(jde float64) float64 { - sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde)) - if sub > 180 { - sub -= 360 - } - if sub < -180 { - sub += 360 - } - return sub - } - elongationRate := func(jde float64, delta float64) float64 { - sub := MercurySunElongation(jde+delta) - MercurySunElongation(jde-delta) - if sub > 180 { - sub -= 360 - } - if sub < -180 { - sub += 360 - } - return sub / (2 * delta) - } - lastConjunction := LastMercuryConjunctionStrict(jde) - nextConjunction := NextMercuryConjunctionStrict(jde) - currentRADelta := solarRADelta(jde) - if currentRADelta > 0 { - jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.0 * 2.0) - } else { - jde = lastConjunction + ((nextConjunction - lastConjunction) / 6.0) - } - found := false - for i := 0; i < eventDirectionalSearchIterations; i++ { - currentRate := elongationRate(jde, 1.0/86400.0) - if !isFiniteFloat(currentRate) { - return math.NaN() - } - if math.Abs(currentRate) > 0.4 { - jde += 2 + for _, east := range [2]bool{true, false} { + start, end, ok := mercuryElongationWindowAt(jde, east) + if !ok { continue } - found = true + if date := mercuryGreatestElongationInWindow(start, end); isFiniteFloat(date) && eventUTQueryAfterOrEqual(date, jde) { + return date + } + if date := nextMercuryGreatestElongationTyped(jde, !east); isFiniteFloat(date) && eventUTQueryAfterOrEqual(date, jde) { + return date + } break } - if !found { - return math.NaN() - } - estimateJD := jde - var ok bool - estimateJD, ok = eventNewtonRefine(estimateJD, 30.0/86400.0, func(prevJD float64) float64 { - rateValue := elongationRate(prevJD, 2.0/86400.0) - rateSlope := (elongationRate(prevJD+15.0/86400.0, 2.0/86400.0) - elongationRate(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0) - return rateValue / rateSlope - }) - if !ok { - return math.NaN() - } - bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 { - return elongationRate(jd, 0.5/86400.0) - }) - //fmt.Println((bestJD - lastConjunction) / (nextConjunction - lastConjunction)) - return TD2UT(bestJD, false) -} - -func NextMercuryGreatestElongation(jde float64) float64 { - east := NextMercuryGreatestElongationEast(jde) - west := NextMercuryGreatestElongationWest(jde) - if sameEventJD(east, west) { - return east - } - if east < west { - return east - } - return west + return earliestFiniteEventUT(nextMercuryGreatestElongationTyped(jde, true), nextMercuryGreatestElongationTyped(jde, false)) } func LastMercuryGreatestElongation(jde float64) float64 { - east := LastMercuryGreatestElongationEast(jde) - west := LastMercuryGreatestElongationWest(jde) - if sameEventJD(east, west) { - return east + if !isFiniteFloat(jde) { + return math.NaN() } - if east > west { - return east + for _, east := range [2]bool{true, false} { + start, end, ok := mercuryElongationWindowAt(jde, east) + if !ok { + continue + } + if date := mercuryGreatestElongationInWindow(start, end); isFiniteFloat(date) && eventUTQueryBeforeOrEqual(date, jde) { + return date + } + if date := lastMercuryGreatestElongationTyped(jde, !east); isFiniteFloat(date) && eventUTQueryBeforeOrEqual(date, jde) { + return date + } + break } - return west + return latestFiniteEventUT(lastMercuryGreatestElongationTyped(jde, true), lastMercuryGreatestElongationTyped(jde, false)) } func LastMercuryInferiorConjunctionInclusive(jde float64) float64 { diff --git a/basic/moon.go b/basic/moon.go index 086082d..2fe393c 100644 --- a/basic/moon.go +++ b/basic/moon.go @@ -72,12 +72,7 @@ func MoonTrueRaDec(jd float64) (float64, float64) { return LoBoToRaDec(jd, MoonApparentLo(jd), MoonTrueBo(jd)) } -/* -* - - * - 传入世界时 -*/ +// MoonApparentRa 站心视赤经;jd 为当地时儒略日,tz 为时区小时数 / topocentric apparent right ascension; jd is local civil time and tz is the zone offset in hours. func MoonApparentRa(jd, lon, lat float64, tz int) float64 { jde := TD2UT(jd, true) utcJD := jde - float64(tz)/24.000 diff --git a/basic/moon_horizon.go b/basic/moon_horizon.go new file mode 100644 index 0000000..6192509 --- /dev/null +++ b/basic/moon_horizon.go @@ -0,0 +1,58 @@ +package basic + +import "math" + +// MoonHorizon 返回 UT 儒略日下海平面几何月球中心地平圈(月球恰好在地平线上的观测者轨迹, +// 即月下点周围的地平圈)的 [经度, 纬度] 顶点,单位为度,不重复首点。视差与椭球口径同 +// HMoonHeight,不含折射;与 HMoonHeight(经, 纬, ..., 0) 配合时该圈上的点高度角为 0。 +// samples<=0 取 360,其余夹到 [12, 1440]。 +// MoonHorizon returns sea-level geometric Moon-centre horizon vertices in degrees for a UT Julian +// day: the locus of observers that see the Moon exactly on the horizon. Parallax and the observer +// ellipsoid match HMoonHeight; refraction is excluded. +func MoonHorizon(jdUT float64, samples int) [][2]float64 { + if !finite(jdUT) { + return nil + } + if samples <= 0 { + samples = 360 + } + if samples < 12 { + samples = 12 + } else if samples > 1440 { + samples = 1440 + } + tt := TD2UT(jdUT, true) + ra, dec := HMoonTrueRaDec(tt) + distanceAU := HMoonAway(tt) / angularDiameterAstronomicalUnitKM + parallax := math.Sin(0.0024427777777*rad) / distanceAU + longitude := (ra - ApparentSiderealTime(jdUT)*15) * rad + latitude := dec * rad + if !finite(parallax) || parallax <= 0 || parallax >= 1 || !finite(longitude) || !finite(latitude) { + return nil + } + center := [3]float64{math.Cos(latitude) * math.Cos(longitude), math.Cos(latitude) * math.Sin(longitude), math.Sin(latitude)} + north := [3]float64{-math.Sin(latitude) * math.Cos(longitude), -math.Sin(latitude) * math.Sin(longitude), math.Cos(latitude)} + east := [3]float64{-math.Sin(longitude), math.Cos(longitude), 0} + points := make([][2]float64, samples) + for index := range points { + bearing := 2 * math.Pi * float64(index) / float64(samples) + radius := math.Acos(parallax) + var point [3]float64 + for iteration := 0; iteration < 8; iteration++ { + for axis := range point { + point[axis] = center[axis]*math.Cos(radius) + + (north[axis]*math.Cos(bearing)+east[axis]*math.Sin(bearing))*math.Sin(radius) + } + lat := math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad + // The topocentric direction is horizontal when its dot product + // with the geodetic zenith vanishes: cos(radius)=observer/range. + next := math.Acos(parallax * (pcosi(lat, 0)*math.Cos(lat*rad) + psini(lat, 0)*math.Sin(lat*rad))) + if math.Abs(next-radius) < 1e-14 { + break + } + radius = next + } + points[index] = [2]float64{math.Atan2(point[1], point[0]) / rad, math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad} + } + return points +} diff --git a/basic/moon_horizon_test.go b/basic/moon_horizon_test.go new file mode 100644 index 0000000..2879db5 --- /dev/null +++ b/basic/moon_horizon_test.go @@ -0,0 +1,23 @@ +package basic + +import ( + "math" + "testing" +) + +func TestMoonHorizonMatchesTopocentricAltitude(t *testing.T) { + for _, jd := range []float64{JDECalc(2026, 3, 3), JDECalc(2025, 9, 7), JDECalc(2024, 12, 15)} { + points := MoonHorizon(jd, 360) + if len(points) != 360 { + t.Fatalf("horizon points=%d", len(points)) + } + for _, point := range points { + if altitude := HMoonHeight(jd, point[0], point[1], 0); math.Abs(altitude) > 1e-9 { + t.Fatalf("JD=%v point=%v altitude=%g, want zero", jd, point, altitude) + } + } + } + if MoonHorizon(math.NaN(), 360) != nil { + t.Fatal("invalid JD accepted") + } +} diff --git a/basic/moon_max_declination.go b/basic/moon_max_declination.go index 288dce7..44fdccd 100644 --- a/basic/moon_max_declination.go +++ b/basic/moon_max_declination.go @@ -167,7 +167,79 @@ func moonMaximumDeclinationEvent(k int, coeffs moonMaxDeclinationCoefficients, c } func moonMaximumDeclinationSearch(jd float64, coeffs moonMaxDeclinationCoefficients, direction int, includeCurrent bool) DeclinationEvent { - cfg := apsisSearchConfig{ + if !finite(jd) { + // 非有限查询算不出周期序号(centerK 会把 NaN/±Inf 压成 MinInt64,种子外推到 1e40 后步长小于一个 ULP)。 + return DeclinationEvent{} + } + cfg := moonMaximumDeclinationSearchConfig(coeffs) + centerK := moonMaximumDeclinationOffset(jd, coeffs) + step := 1 + if direction < 0 { + step = -1 + } + event := moonMaximumDeclinationEvent(centerK, coeffs, cfg) + // 事件时刻随周期序号单调递增:该方向上最近的候选就是 centerK 沿该方向第一个满足方向不变量 + // 的周期;命中后只需再回退检查更早的周期是否同样满足,可达范围仍旧是 ±3 个周期。 + if !moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) { + for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ { + event = moonMaximumDeclinationEvent(centerK+step*offset, coeffs, cfg) + if moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) { + return event + } + } + return DeclinationEvent{} + } + for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ { + previous := moonMaximumDeclinationEvent(centerK-step*offset, coeffs, cfg) + if !moonMaximumDeclinationMatchesDirection(previous.JDE-jd, direction, includeCurrent) { + break + } + event = previous + } + return event +} + +func moonClosestMaximumDeclination(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent { + if !finite(jd) { + return DeclinationEvent{} + } + cfg := moonMaximumDeclinationSearchConfig(coeffs) + centerK := moonMaximumDeclinationOffset(jd, coeffs) + center := moonMaximumDeclinationEvent(centerK, coeffs, cfg) + var last, next DeclinationEvent + if center.JDE <= jd { + last = center + next = moonMaximumDeclinationAround(jd, coeffs, cfg, centerK, 1, false) + } else { + next = center + last = moonMaximumDeclinationAround(jd, coeffs, cfg, centerK, -1, true) + } + lastDistance := math.Abs(jd - last.JDE) + nextDistance := math.Abs(next.JDE - jd) + if lastDistance <= nextDistance { + return last + } + return next +} + +// moonMaximumDeclinationAround 从 centerK 沿 step 方向找第一个满足方向不变量的周期;±3 个周期内没有就返回零事件。 +func moonMaximumDeclinationAround(jd float64, coeffs moonMaxDeclinationCoefficients, cfg apsisSearchConfig, + centerK, step int, includeCurrent bool) DeclinationEvent { + direction := 1 + if step < 0 { + direction = -1 + } + for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ { + event := moonMaximumDeclinationEvent(centerK+step*offset, coeffs, cfg) + if moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) { + return event + } + } + return DeclinationEvent{} +} + +func moonMaximumDeclinationSearchConfig(coeffs moonMaxDeclinationCoefficients) apsisSearchConfig { + return apsisSearchConfig{ bracketHalfWidth: moonApsisBracketHalfWidth, sampleStep: moonApsisSampleStep, derivativeStep: moonApsisDerivativeStep, @@ -175,37 +247,11 @@ func moonMaximumDeclinationSearch(jd float64, coeffs moonMaxDeclinationCoefficie maxIterations: moonApsisMaxIterations, maximize: coeffs.sign > 0, } - targetTT := TD2UT(jd, true) - centerK := int(math.Round((targetTT - coeffs.JDE0) / moonMaxDeclinationMeanMonthDays)) - - found := false - bestDistance := math.Inf(1) - var best DeclinationEvent - for offset := -moonMaxDeclinationSearchSpan; offset <= moonMaxDeclinationSearchSpan; offset++ { - event := moonMaximumDeclinationEvent(centerK+offset, coeffs, cfg) - delta := event.JDE - jd - if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) { - continue - } - distance := math.Abs(delta) - if !found || distance < bestDistance || (distance == bestDistance && moonMaximumDeclinationEarlier(event, best)) { - best = event - bestDistance = distance - found = true - } - } - return best } -func moonClosestMaximumDeclination(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent { - last := moonMaximumDeclinationSearch(jd, coeffs, -1, true) - next := moonMaximumDeclinationSearch(jd, coeffs, 1, false) - lastDistance := math.Abs(jd - last.JDE) - nextDistance := math.Abs(next.JDE - jd) - if lastDistance <= nextDistance { - return last - } - return next +// moonMaximumDeclinationOffset 查询时刻落在哪个平均周期(种子多项式的中心序号)。 +func moonMaximumDeclinationOffset(jd float64, coeffs moonMaxDeclinationCoefficients) int { + return int(math.Round((TD2UT(jd, true) - coeffs.JDE0) / moonMaxDeclinationMeanMonthDays)) } func moonMaximumDeclinationMatchesDirection(delta float64, direction int, includeCurrent bool) bool { diff --git a/basic/moon_max_declination_perf_test.go b/basic/moon_max_declination_perf_test.go new file mode 100644 index 0000000..1b9da57 --- /dev/null +++ b/basic/moon_max_declination_perf_test.go @@ -0,0 +1,91 @@ +package basic + +import ( + "math" + "testing" +) + +// 本文件用改动前的“七个候选全精修”实现做差分对照与 A/B 基准。 + +func moonMaximumDeclinationReferenceSearch(jd float64, coeffs moonMaxDeclinationCoefficients, direction int, includeCurrent bool) DeclinationEvent { + if !finite(jd) { + return DeclinationEvent{} + } + cfg := moonMaximumDeclinationSearchConfig(coeffs) + centerK := moonMaximumDeclinationOffset(jd, coeffs) + found := false + bestDistance := math.Inf(1) + var best DeclinationEvent + for offset := -moonMaxDeclinationSearchSpan; offset <= moonMaxDeclinationSearchSpan; offset++ { + event := moonMaximumDeclinationEvent(centerK+offset, coeffs, cfg) + delta := event.JDE - jd + if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) { + continue + } + distance := math.Abs(delta) + if !found || distance < bestDistance || (distance == bestDistance && moonMaximumDeclinationEarlier(event, best)) { + best = event + bestDistance = distance + found = true + } + } + return best +} + +func moonMaximumDeclinationReferenceClosest(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent { + if !finite(jd) { + return DeclinationEvent{} + } + last := moonMaximumDeclinationReferenceSearch(jd, coeffs, -1, true) + next := moonMaximumDeclinationReferenceSearch(jd, coeffs, 1, false) + lastDistance := math.Abs(jd - last.JDE) + nextDistance := math.Abs(next.JDE - jd) + if lastDistance <= nextDistance { + return last + } + return next +} + +func TestMoonMaximumDeclinationPruningMatchesFullSearch(t *testing.T) { + for _, coeffs := range []moonMaxDeclinationCoefficients{moonMaxDeclinationNorthCoefficients, moonMaxDeclinationSouthCoefficients} { + for jd := JDECalc(2024, 1, 1); jd <= JDECalc(2025, 6, 1); jd += 2.5 { + for _, direction := range []struct { + name string + dir int + includeCurrent bool + fn func(float64) DeclinationEvent + }{ + {"Last", -1, true, func(v float64) DeclinationEvent { + return moonMaximumDeclinationSearch(v, coeffs, -1, true) + }}, + {"Next", 1, false, func(v float64) DeclinationEvent { + return moonMaximumDeclinationSearch(v, coeffs, 1, false) + }}, + } { + want := moonMaximumDeclinationReferenceSearch(jd, coeffs, direction.dir, direction.includeCurrent) + got := direction.fn(jd) + if got != want { + t.Fatalf("k=%v %s(%v) = %+v, want %+v", coeffs.sign, direction.name, jd, got, want) + } + } + wantClosest := moonMaximumDeclinationReferenceClosest(jd, coeffs) + if got := moonClosestMaximumDeclination(jd, coeffs); got != wantClosest { + t.Fatalf("k=%v Closest(%v) = %+v, want %+v", coeffs.sign, jd, got, wantClosest) + } + } + } +} + +func BenchmarkMoonMaximumDeclinationFullSearchReference(b *testing.B) { + jd := JDECalc(2025, 3, 1) + b.Run("Next", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = moonMaximumDeclinationReferenceSearch(jd, moonMaxDeclinationNorthCoefficients, 1, false) + } + }) + b.Run("Closest", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = moonMaximumDeclinationReferenceClosest(jd, moonMaxDeclinationNorthCoefficients) + } + }) +} diff --git a/basic/moon_observation.go b/basic/moon_observation.go index 0c71a98..894a3d7 100644 --- a/basic/moon_observation.go +++ b/basic/moon_observation.go @@ -124,6 +124,8 @@ func moonRiseSetResidual(jd, longitude, latitude, timeZone, zenithShift, height return residual } +// moonRiseSetOnCivilDay 在民用日内求升/落时刻;找不到过零时的错误口径与 rise_set.go 的 ErrNeverRise/ErrNeverSet 一致, +// fallbackErr 是调用方用中天/下中天残差预判的同一几何结论,命中时优先于扫描结果。 func moonRiseSetOnCivilDay(candidate, slope, civilDayStart, longitude, latitude, originalTimeZone, localTimeZone, zenithShift, height float64, isRise bool, fallbackErr error) (float64, error) { if eventRiseSetCandidateValid(candidate, civilDayStart, slope, isRise) { @@ -231,6 +233,7 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1) if !(currentResidual < -10 && math.Abs(latitude) < 60) { if currentResidual > 0 { + // 下中天仍在地平线上:当日无落下(也无可升起),口径见 moonRiseSetOnCivilDay。 return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude, originalTimeZone, timeZone, zenithShift, height, true, ErrNeverSet) } @@ -241,6 +244,7 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig } checkTime += (360 - checkAngle) * 4.0 / 60.0 / 24.0 if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) < 0 { + // 上中天仍在地平线下:当日无升起。 return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude, originalTimeZone, timeZone, zenithShift, height, true, ErrNeverRise) } @@ -314,6 +318,7 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1) if !(currentResidual > 10 && math.Abs(latitude) < 60) { if currentResidual < 0 { + // 上中天仍在地平线下:当日无升起,也就无落下。 return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude, originalTimeZone, timeZone, zenithShift, height, false, ErrNeverRise) } @@ -321,6 +326,7 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh angleSubtraction := 180 - MoonTimeAngle(checkTime, longitude, latitude, timeZone) checkTime += angleSubtraction * 4.0 / 60.0 / 24.0 if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) > 0 { + // 下中天仍在地平线上:当日无落下。 return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude, originalTimeZone, timeZone, zenithShift, height, false, ErrNeverSet) } diff --git a/basic/moon_precision.go b/basic/moon_precision.go index 22ad076..c7af26b 100644 --- a/basic/moon_precision.go +++ b/basic/moon_precision.go @@ -54,8 +54,24 @@ func MoonCalcNew(coordIndex int, jd float64) float64 { } func MoonCalcNewN(coordIndex int, jd float64, n int) float64 { - rad := 180.0 * 3600.0 / math.Pi - t := (jd - 2451545.0) / 36525.0 + return moonCalcNewN(coordIndex, jd, n) +} + +// moonPhaseCompensationThreshold 是启用线性相位补偿的最小 |t|(世纪数)倒数尺度: +// 更小的项本身已低于纳角秒级相位舍入,无需补偿。 +const moonPhaseCompensationThreshold = 1e6 + +func moonCalcNewN(coordIndex int, jd float64, n int) float64 { + arcsecPerRadian := 180.0 * 3600.0 / math.Pi + // Preserve the low parts of both the epoch subtraction and century division. + days := jd - 2451545.0 + origin := days - jd + daysLow := (jd - (days - origin)) - (2451545 + origin) + t := days / 36525.0 + tLow := (math.FMA(-t, 36525, days) + daysLow) / 36525 + // Small angular terms have sub-nanoarcsecond phase roundoff already. + // Compensate only the larger terms; distance needs no angular precision. + phaseThreshold := moonPhaseCompensationThreshold / math.Abs(t) ob := moonCir[coordIndex] var v float64 var tn float64 = 1 @@ -65,8 +81,11 @@ func MoonCalcNewN(coordIndex int, jd float64, n int) float64 { t5 := t4 * t tx := t - 10 if coordIndex == 0 { - v += (3.81034409 + 8399.684730072*t - 3.319e-05*t2 + 3.11e-08*t3 - 2.033e-10*t4) * rad //月球平黄经(弧度) - v += 5028.792262*t + 1.1124406*t2 + 0.00007699*t3 - 0.000023479*t4 - 0.0000000178*t5 //岁差(角秒) + // 黄经线性项必须始终走补偿版本:MoonCalcNew 与 HMoonTrueLo 是同一个物理量的两个入口, + // 只在其中一个入口补偿会让两者差约 1e-9 度。 + longitude := moonReducedLinearPhase(3.81034409, 8399.684730072, t, tLow) + v += (longitude - 3.319e-05*t2 + 3.11e-08*t3 - 2.033e-10*t4) * arcsecPerRadian + v += 5028.792262*t + 1.1124406*t2 + 0.00007699*t3 - 0.000023479*t4 - 0.0000000178*t5 //岁差(角秒) if tx > 0 { v += -0.866 + 1.43*tx + 0.054*tx*tx //对公元3000年至公元5000年的拟合,最大误差小于10角秒 } @@ -80,6 +99,7 @@ func MoonCalcNewN(coordIndex int, jd float64, n int) float64 { } for i := 0; i < len(ob); i++ { F := ob[i] + seriesPhaseThreshold := phaseThreshold / math.Abs(tn) N := math.Floor(float64(nScalars*len(F))/float64(len(ob[0])) + 0.5) if i != 0 { N += 6 @@ -89,23 +109,37 @@ func MoonCalcNewN(coordIndex int, jd float64, n int) float64 { } var c float64 = 0 for j := 0; float64(j) < N; j += 6 { - c += F[j] * math.Cos(F[j+1]+t*F[j+2]+t2*F[j+3]+t3*F[j+4]+t4*F[j+5]) + phase := F[j+1] + t*F[j+2] + if coordIndex != 2 && math.Abs(F[j]*F[j+2]) > seriesPhaseThreshold { + phase = moonReducedLinearPhase(F[j+1], F[j+2], t, tLow) + } + c += F[j] * math.Cos(phase+t2*F[j+3]+t3*F[j+4]+t4*F[j+5]) } v += c * tn tn *= t } if coordIndex != 2 { - v /= rad + v /= arcsecPerRadian } return v } +// Keep the small phase increment out of the rounded secular product. The +// low part of 2*pi also prevents a range-reduction jump at full revolutions. +func moonReducedLinearPhase(offset, rate, t, tLow float64) float64 { + const twoPiLow = 2.4492935982947064e-16 + product := rate * t + roundoff := math.FMA(rate, t, -product) + rate*tLow + turns := math.Round(product / (2 * math.Pi)) + return math.FMA(-turns, 2*math.Pi, product) + (roundoff - turns*twoPiLow) + offset +} + func HMoonTrueLo(jd float64) float64 { //计算月亮 return HMoonTrueLoN(jd, -1) } func HMoonTrueLoN(jd float64, n int) float64 { //计算月亮 - v := MoonCalcNewN(0, jd, n) * 180 / math.Pi + v := moonCalcNewN(0, jd, n) * 180 / math.Pi return Limit360(v) } @@ -234,10 +268,8 @@ func HMoonTrueRaN(jd float64, n int) float64 { } /* -* -* -传入世界时 -*/ + * HMoonApparentRaDec 本地民用时下的月球视赤道坐标;jd 是本地民用时(内部按 jd-tz/24 换算 UT),不是世界时 + */ func HMoonApparentRaDec(jd, lon, lat, tz float64) (float64, float64) { return HMoonApparentRaDecN(jd, lon, lat, tz, -1) } diff --git a/basic/moon_precision_test.go b/basic/moon_precision_test.go new file mode 100644 index 0000000..d8c4995 --- /dev/null +++ b/basic/moon_precision_test.go @@ -0,0 +1,81 @@ +package basic + +import ( + "math" + "testing" +) + +func TestMoonSeriesNumericalPrecision(t *testing.T) { + // The existing binary64 coefficients evaluated at 70 decimal digits. + // These check arithmetic precision, not the lunar theory's physical error. + for _, sample := range []struct{ jd, longitude, latitude, distance, velocity float64 }{ + {990647.125, 48.68531043735905108, -3.208940888099671943, 365328.8143785083007, 14.53542662716284001}, + {1730647.9333686847, 346.3227699608293767, -2.151500042672265917, 368450.7410464923284, 14.30553896629878483}, + {2451545.0, 223.3189003731153751, 5.170885995272171723, 402448.7431750887777, 12.02142669184144213}, + {2460770.123456789, 99.20516643712787636, 5.156920731462777075, 378303.2056716878878, 13.57356557811076973}, + {2817000.99999, 256.0031142427467021, 3.438015761341575242, 402219.0739759512533, 11.99285384469081927}, + {3191874.3956256355, 178.1121054545695063, 1.37109140818496243, 379219.9689425243825, 13.41568168416747773}, + {3547272.75, 167.6849402044916958, 5.088592207858965847, 374590.3971822900352, 13.85120063917007945}, + } { + longitude := HMoonTrueLo(sample.jd) + latitude := HMoonTrueBo(sample.jd) + distance := HMoonAway(sample.jd) + if error := math.Abs(math.Remainder(longitude-sample.longitude, 360)); error > 2e-11 { + t.Errorf("jd=%.12f longitude error=%g degrees", sample.jd, error) + } + if error := math.Abs(latitude - sample.latitude); error > 2e-11 { + t.Errorf("jd=%.12f latitude error=%g degrees", sample.jd, error) + } + if error := math.Abs(distance - sample.distance); error > 1e-5 { + t.Errorf("jd=%.12f distance error=%g km", sample.jd, error) + } + const step = 5.0 / 86400 + before, after := sample.jd-step, sample.jd+step + velocity := math.Remainder(HMoonTrueLo(after)-HMoonTrueLo(before), 360) / (after - before) + if error := math.Abs(velocity - sample.velocity); error > 2e-7 { + t.Errorf("jd=%.12f velocity error=%g degrees/day", sample.jd, error) + } + } +} + +func TestOccultationContactDerivativeNumericalStability(t *testing.T) { + config, _ := planetOccultationConfigFor(OccultationMercury) + cache := newPlanetOccultationEventCache(config) + const tt = 1730647.933368684724 + const longitude = 90.88806942770216 + const latitude = 29.603106445560446 + evaluations := newOccultationRiseSetEvaluationCache(cache.riseSetContextAt) + derivative := func(jd float64) float64 { + return evaluations.evaluation(jd).contactDerivative(longitude, latitude) + } + center := derivative(tt) + const trendStep = 0.5 / 86400 + trend := (derivative(tt+trendStep) - derivative(tt-trendStep)) / (2 * trendStep) + ulp := math.Nextafter(tt, math.Inf(1)) - tt + for i := -16; i <= 16; i++ { + offset := float64(i) * ulp + jitter := derivative(tt+offset) - center - trend*offset + if math.Abs(jitter) > occultationRiseSetJunctionDerivativeTolerance { + t.Fatalf("offset=%g seconds: derivative jitter=%g degrees/day", offset*86400, jitter) + } + } + cache.preparePathEphemeris(tt, OccultationPathAlgorithmOptimized) + if cache.local == nil || !cache.local.dense { + t.Fatal("smooth exact states must support the checked dense ephemeris") + } +} + +// MoonCalcNew 与 HMoonTrueLo 是同一个物理量的两个入口,必须给出同一个值(此前只有一个入口 +// 走线性相位补偿,两者差约 1e-9 度)。 +func TestMoonCalcNewMatchesHMoonTrueLo(t *testing.T) { + for _, jd := range []float64{2451545.0, 2460310.5, 2415020.5, 2299160.5, 2500000.5} { + direct := math.Mod(MoonCalcNew(0, jd)*180/math.Pi, 360) + if direct < 0 { + direct += 360 + } + high := HMoonTrueLo(jd) + if diff := math.Abs(direct - high); diff > 1e-12 { + t.Errorf("jd %.1f: MoonCalcNew=%0.15f HMoonTrueLo=%0.15f diff=%.3g deg", jd, direct, high, diff) + } + } +} diff --git a/basic/moon_rise_set_convention_test.go b/basic/moon_rise_set_convention_test.go new file mode 100644 index 0000000..1bae94b --- /dev/null +++ b/basic/moon_rise_set_convention_test.go @@ -0,0 +1,87 @@ +package basic + +import ( + "errors" + "math" + "testing" +) + +// 本文件钉住月出/月落「缺失事件」的口径:错误名描述缺失的那个现象,不描述被问的事件。 + +func moonRiseSetErrorMatches(got, want error) bool { + if want == nil { + return got == nil + } + return errors.Is(got, want) +} + +func moonRiseSetDailyGeometry(jd, lon, lat, tz float64) (aboveAll, belowAll bool) { + dayStart := math.Floor(jd) + 0.5 + aboveAll, belowAll = true, true + previous := moonRiseSetResidual(dayStart, lon, lat, tz, 1, 0, -1) + for i := 1; i <= 288; i++ { + current := moonRiseSetResidual(dayStart+float64(i)/288.0, lon, lat, tz, 1, 0, -1) + if previous <= 0 || current <= 0 { + aboveAll = false + } + if previous >= 0 || current >= 0 { + belowAll = false + } + previous = current + } + return aboveAll, belowAll +} + +func TestMoonRiseSetMissingEventConvention(t *testing.T) { + cases := []struct { + name string + jd float64 + lon, lat, tz float64 + riseErr, setErr error + }{ + {"极昼:全天在地平线上", JDECalc(2023, 6, 21), 0, 85, 0, ErrNeverSet, ErrNeverSet}, + {"极夜:全天在地平线下", JDECalc(2023, 12, 22), 0, -85, 0, ErrNeverRise, ErrNeverRise}, + {"当日无升起但别日有", JDECalc(2024, 2, 29), 0, 60, 0, ErrNotOnThisDate, nil}, + {"正常日两侧都有", JDECalc(2025, 6, 5), 116.4074, 39.9042, 8, nil, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := GetMoonRiseTime(tc.jd, tc.lon, tc.lat, tc.tz, 1, 0); !moonRiseSetErrorMatches(err, tc.riseErr) { + t.Fatalf("GetMoonRiseTime error = %v, want %v", err, tc.riseErr) + } + if _, err := GetMoonSetTime(tc.jd, tc.lon, tc.lat, tc.tz, 1, 0); !moonRiseSetErrorMatches(err, tc.setErr) { + t.Fatalf("GetMoonSetTime error = %v, want %v", err, tc.setErr) + } + }) + } +} + +func TestMoonRiseSetMissingEventMatchesDailyGeometry(t *testing.T) { + dates := []float64{ + JDECalc(2023, 6, 21), JDECalc(2023, 12, 22), JDECalc(2024, 2, 29), + JDECalc(2025, 6, 21), JDECalc(2025, 12, 22), JDECalc(2026, 3, 3), + } + latitudes := []float64{-89, -85, -75, -66, -60, 60, 66, 75, 85, 89} + for _, jd := range dates { + for _, lat := range latitudes { + aboveAll, belowAll := moonRiseSetDailyGeometry(jd, 0, lat, 0) + if !aboveAll && !belowAll { + continue + } + _, riseErr := GetMoonRiseTime(jd, 0, lat, 0, 1, 0) + _, setErr := GetMoonSetTime(jd, 0, lat, 0, 1, 0) + want := ErrNeverSet + if belowAll { + want = ErrNeverRise + } + if !moonRiseSetErrorMatches(riseErr, want) { + t.Fatalf("lat %v jd %.1f aboveAll=%v belowAll=%v: moonrise error = %v, want %v", + lat, jd, aboveAll, belowAll, riseErr, want) + } + if !moonRiseSetErrorMatches(setErr, want) { + t.Fatalf("lat %v jd %.1f aboveAll=%v belowAll=%v: moonset error = %v, want %v", + lat, jd, aboveAll, belowAll, setErr, want) + } + } + } +} diff --git a/basic/moving_disk_engine.go b/basic/moving_disk_engine.go new file mode 100644 index 0000000..6a36922 --- /dev/null +++ b/basic/moving_disk_engine.go @@ -0,0 +1,496 @@ +package basic + +import ( + "math" + "sort" +) + +// movingDiskEventEngine contains the time-domain part shared by solar +// eclipses and lunar occultations. Geometry remains in the caller: a solar +// adapter evaluates the Bessel projection, while an occultation adapter +// evaluates the Earth-vector frame. Keeping that boundary explicit is what +// lets the solar implementation remain the numerical baseline. +type movingDiskEventEngine struct { + maxSampleCount int + searchSpanDays float64 + rangeStepDays float64 + rootToleranceDays float64 + greatestSpanDays float64 + greatestToleranceDays float64 + reserveAnchorSlot bool + uniformOverflow bool + indexedSampleTimes bool +} + +// movingDiskContactState is the dimensionless circular-disk model shared by +// solar eclipse and lunar occultation adapters. The radii and separation may +// be radians, degrees, or arcseconds, but all four values must use the same +// unit. Solar eclipses provide distinct outer and inner occulting radii; +// finite-planet occultations normally use the same lunar radius for both. +type movingDiskContactState struct { + separation float64 + occultingOuterRadius float64 + occultingInnerRadius float64 + targetRadius float64 + valid bool +} + +// movingDiskEventCacheMaximumEntries is shared by event-local caches so a +// dense path cannot grow without bound in native or TinyGo/WASM execution. +const movingDiskEventCacheMaximumEntries = 2048 +const movingDiskContactCacheMaximumEntries = movingDiskEventCacheMaximumEntries + +// movingDiskContactCache stores the disk state for one event evaluation. The +// valid bit is part of the entry so failed ephemeris evaluations are cached as +// well; otherwise a pair of external/internal roots can repeat the same +// invalid star or planet calculation indefinitely. Entries are keyed by the +// exact TT bits because root refinement intentionally revisits exact endpoints. +type movingDiskContactCache struct { + entries map[uint64]movingDiskContactCacheEntry + maxEntries int +} + +type movingDiskContactCacheEntry struct { + state movingDiskContactState + ok bool +} + +func newMovingDiskContactCache() *movingDiskContactCache { + return &movingDiskContactCache{ + entries: make(map[uint64]movingDiskContactCacheEntry), + maxEntries: movingDiskContactCacheMaximumEntries, + } +} + +func (cache *movingDiskContactCache) lookup(tt float64) (movingDiskContactState, bool, bool) { + if cache == nil || cache.entries == nil { + return movingDiskContactState{}, false, false + } + entry, ok := cache.entries[math.Float64bits(tt)] + if !ok { + return movingDiskContactState{}, false, false + } + return entry.state, entry.ok, true +} + +func (cache *movingDiskContactCache) store(tt float64, state movingDiskContactState, ok bool) { + if cache == nil { + return + } + if cache.entries == nil { + cache.entries = make(map[uint64]movingDiskContactCacheEntry) + } + limit := cache.maxEntries + if limit <= 0 { + limit = movingDiskContactCacheMaximumEntries + } + key := math.Float64bits(tt) + if _, exists := cache.entries[key]; !exists && len(cache.entries) >= limit { + // Contact roots are local to one event and are naturally clustered in + // time. Clearing the bounded table is cheaper and more predictable + // than maintaining an eviction list in TinyGo/WASM. + for key := range cache.entries { + delete(cache.entries, key) + } + } + cache.entries[key] = movingDiskContactCacheEntry{state: state, ok: ok} +} + +// movingDiskContactEvaluator adapts an event-specific geometry calculation to +// the shared contact root engine. Its callback must be pure for a given TT; +// all observer/configuration values belong to the evaluator closure. +type movingDiskContactEvaluator struct { + evaluate func(float64) (movingDiskContactState, bool) + cache *movingDiskContactCache +} + +func newMovingDiskContactEvaluator( + evaluate func(float64) (movingDiskContactState, bool), +) *movingDiskContactEvaluator { + return &movingDiskContactEvaluator{ + evaluate: evaluate, + cache: newMovingDiskContactCache(), + } +} + +func (evaluator *movingDiskContactEvaluator) stateAt(tt float64) (movingDiskContactState, bool) { + if evaluator == nil || evaluator.evaluate == nil || !finiteMovingDiskValue(tt) { + return movingDiskContactState{}, false + } + if state, ok, hit := evaluator.cache.lookup(tt); hit { + return state, ok + } + state, ok := evaluator.evaluate(tt) + if !ok { + state = movingDiskContactState{} + } + evaluator.cache.store(tt, state, ok) + return state, ok +} + +// prime inserts a state already computed by the caller. Greatest-point +// evaluation often precedes both external and internal contact roots; priming +// avoids evaluating that same TT a second time while preserving the callback +// as the source of truth for all other samples. +func (evaluator *movingDiskContactEvaluator) prime( + tt float64, + state movingDiskContactState, + ok bool, +) { + if evaluator == nil || !finiteMovingDiskValue(tt) { + return + } + evaluator.cache.store(tt, state, ok) +} + +func (evaluator *movingDiskContactEvaluator) gap(tt float64, internal bool) (float64, bool) { + state, ok := evaluator.stateAt(tt) + if !ok || !state.valid { + return 0, false + } + if internal { + return state.internalContactGap(), true + } + return state.externalContactGap(), true +} + +func (state movingDiskContactState) externalContactGap() float64 { + if !state.valid { + return math.NaN() + } + return state.separation - state.occultingOuterRadius - state.targetRadius +} + +func (state movingDiskContactState) internalContactGap() float64 { + if !state.valid { + return math.NaN() + } + return state.separation - math.Abs(state.occultingInnerRadius-state.targetRadius) +} + +func movingDiskContactStateValid(separation, outerRadius, innerRadius, targetRadius float64) bool { + return finiteMovingDiskValue(separation) && separation >= 0 && + finiteMovingDiskValue(outerRadius) && outerRadius > 0 && + finiteMovingDiskValue(innerRadius) && innerRadius > 0 && + finiteMovingDiskValue(targetRadius) && targetRadius >= 0 +} + +func finiteMovingDiskValue(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} + +func (engine movingDiskEventEngine) sampleTimes( + start, end, greatest, requestedStep float64, +) ([]float64, float64) { + if end < start { + start, end = end, start + } + maximum := engine.maxSampleCount + if maximum < 3 { + maximum = 3 + } + duration := end - start + if duration <= 0 { + return []float64{start}, requestedStep + } + step := requestedStep + if step <= 0 || math.IsNaN(step) || math.IsInf(step, 0) { + step = duration + } + baseSampleCount := int(math.Ceil(duration/step)) + 1 + if engine.reserveAnchorSlot { + baseSampleCount++ + } + if baseSampleCount > maximum && engine.uniformOverflow { + interiorCount := maximum - 3 + if interiorCount < 0 { + interiorCount = 0 + } + bounded := make([]float64, 0, maximum) + bounded = append(bounded, start, greatest, end) + for index := 1; index <= interiorCount; index++ { + bounded = append(bounded, start+duration*float64(index)/float64(interiorCount+1)) + } + sort.Float64s(bounded) + return movingDiskUniqueTimes(bounded), step + } + if baseSampleCount > maximum { + step = duration / float64(maximum-1) + } + + times := []float64{start, greatest, end} + if engine.indexedSampleTimes { + for index := 1; ; index++ { + current := start + float64(index)*step + if current >= end { + break + } + times = append(times, current) + } + } else { + for current := start + step; current < end; current += step { + times = append(times, current) + } + } + sort.Float64s(times) + return movingDiskUniqueTimes(times), step +} + +func (engine movingDiskEventEngine) window( + seed, start, end float64, + candidateAt, exactAt func(float64) bool, +) (float64, float64, bool) { + if exactAt == nil { + return 0, 0, false + } + left := start + right := end + if engine.searchSpanDays > 0 { + left = math.Max(left, seed-engine.searchSpanDays) + right = math.Min(right, seed+engine.searchSpanDays) + } + if right <= left { + return 0, 0, false + } + if candidateAt == nil { + candidateAt = exactAt + } + step := engine.rangeStepDays + if step <= 0 || math.IsNaN(step) || math.IsInf(step, 0) { + step = right - left + } + + first := math.NaN() + previous := left + if candidateAt(previous) && exactAt(previous) { + first = previous + } + if math.IsNaN(first) { + for current := left + step; current <= right; current += step { + current = math.Min(current, right) + if candidateAt(current) { + first = engine.refineTransition(previous, current, exactAt, false) + break + } + previous = current + } + } + if math.IsNaN(first) { + return 0, 0, false + } + + last := first + previous = first + for current := first + step; current <= right; current += step { + current = math.Min(current, right) + if !candidateAt(current) { + last = engine.refineTransition(previous, current, exactAt, true) + return first, last, true + } + last = current + previous = current + } + return first, right, true +} + +func (engine movingDiskEventEngine) refineTransition( + left, right float64, + predicate func(float64) bool, + trueToFalse bool, +) float64 { + leftOK := predicate(left) + tolerance := engine.rootToleranceDays + if tolerance <= 0 || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) { + tolerance = 1e-10 + } + for iteration := 0; iteration < 48 && math.Abs(right-left) > tolerance; iteration++ { + middle := (left + right) / 2 + middleOK := predicate(middle) + if trueToFalse { + if middleOK { + left = middle + } else { + right = middle + } + continue + } + if middleOK { + right = middle + } else { + left = middle + } + } + if trueToFalse { + return left + } + if leftOK { + return left + } + return right +} + +// greatest returns the minimum impact value in the event-local interval. +// The callback returns (impact, valid); invalid states are treated as +Inf. +func (engine movingDiskEventEngine) greatest( + seed, start, end float64, + impactAt func(float64) (float64, bool), + iterations int, +) float64 { + if impactAt == nil { + return seed + } + left := start + right := end + span := engine.greatestSpanDays + if span > 0 { + left = math.Max(left, seed-span) + right = math.Min(right, seed+span) + } + if right <= left { + return seed + } + if iterations <= 0 { + iterations = 56 + } + const goldenRatio = 0.6180339887498949 + x1 := right - goldenRatio*(right-left) + x2 := left + goldenRatio*(right-left) + f1 := movingDiskImpact(impactAt, x1) + f2 := movingDiskImpact(impactAt, x2) + for iteration := 0; iteration < iterations && + (engine.greatestToleranceDays <= 0 || right-left > engine.greatestToleranceDays); iteration++ { + if f1 > f2 { + left = x1 + x1, f1 = x2, f2 + x2 = left + goldenRatio*(right-left) + f2 = movingDiskImpact(impactAt, x2) + continue + } + right = x2 + x2, f2 = x1, f1 + x1 = right - goldenRatio*(right-left) + f1 = movingDiskImpact(impactAt, x1) + } + return (left + right) / 2 +} + +// contactRoot finds one external or internal disk-contact root by walking +// away from greatest and then bisecting the first valid sign change. The +// callback may reject an ephemeris sample; rejected samples are skipped while +// searching, but an invalid value inside a confirmed bisection bracket aborts +// that root rather than inventing a crossing. +func (engine movingDiskEventEngine) contactRoot( + greatest, direction, stepDays, spanDays, tolerance float64, + metric func(float64) (float64, bool), + iterations int, +) (float64, bool) { + if metric == nil || (direction != -1 && direction != 1) || + stepDays <= 0 || spanDays <= 0 || tolerance <= 0 { + return 0, false + } + nearTT := greatest + nearValue, nearOK := metric(nearTT) + if !nearOK || !finiteMovingDiskValue(nearValue) || nearValue > 0 { + return 0, false + } + maxSteps := int(math.Ceil(spanDays / stepDays)) + if maxSteps < 1 { + maxSteps = 1 + } + for index := 1; index <= maxSteps; index++ { + farTT := greatest + direction*float64(index)*stepDays + farValue, farOK := metric(farTT) + if !farOK || !finiteMovingDiskValue(farValue) { + continue + } + if farValue < 0 { + nearTT, nearValue = farTT, farValue + continue + } + return movingDiskContactBracketRoot( + nearTT, farTT, nearValue, farValue, metric, tolerance, iterations, + ) + } + return 0, false +} + +func movingDiskContactBracketRoot( + left, right, leftValue, rightValue float64, + metric func(float64) (float64, bool), + tolerance float64, + iterations int, +) (float64, bool) { + if left > right { + left, right = right, left + leftValue, rightValue = rightValue, leftValue + } + if !finiteMovingDiskValue(leftValue) || !finiteMovingDiskValue(rightValue) || + leftValue*rightValue > 0 { + return 0, false + } + if leftValue == 0 { + return left, true + } + if rightValue == 0 { + return right, true + } + if iterations <= 0 { + iterations = 64 + } + for index := 0; index < iterations && right-left > tolerance; index++ { + middle := (left + right) / 2 + middleValue, ok := metric(middle) + if !ok || !finiteMovingDiskValue(middleValue) { + return 0, false + } + if leftValue*middleValue <= 0 { + right, rightValue = middle, middleValue + } else { + left, leftValue = middle, middleValue + } + } + return (left + right) / 2, true +} + +func movingDiskImpact(impactAt func(float64) (float64, bool), tt float64) float64 { + value, ok := impactAt(tt) + if !ok || math.IsNaN(value) || math.IsInf(value, 0) { + return math.Inf(1) + } + return value +} + +func movingDiskUniqueTimes(times []float64) []float64 { + if len(times) < 2 { + return times + } + unique := times[:1] + for _, current := range times[1:] { + if math.Abs(current-unique[len(unique)-1]) <= 1e-10 { + continue + } + unique = append(unique, current) + } + return unique +} + +func solarEclipseMovingDiskEngine() movingDiskEventEngine { + return movingDiskEventEngine{ + maxSampleCount: solarEclipsePathMaxSampleCount, + rootToleranceDays: solarEclipseShadowContactToleranceDays, + greatestToleranceDays: localSolarEclipseGreatestTolerance, + } +} + +func occultationMovingDiskEngine() movingDiskEventEngine { + return movingDiskEventEngine{ + maxSampleCount: occultationPathMaxSampleCount, + searchSpanDays: occultationPathSearchSpanDays, + rangeStepDays: occultationPathRangeStepDays, + rootToleranceDays: occultationPathRootToleranceDays, + greatestSpanDays: 0.75, + reserveAnchorSlot: true, + uniformOverflow: true, + indexedSampleTimes: true, + } +} diff --git a/basic/moving_disk_engine_test.go b/basic/moving_disk_engine_test.go new file mode 100644 index 0000000..5875a12 --- /dev/null +++ b/basic/moving_disk_engine_test.go @@ -0,0 +1,184 @@ +package basic + +import ( + "math" + "testing" +) + +func TestMovingDiskEventEngineSampleTimesKeepAnchorsAndBudget(t *testing.T) { + engine := movingDiskEventEngine{maxSampleCount: 7, reserveAnchorSlot: true, uniformOverflow: true} + times, step := engine.sampleTimes(0, 1, 0.37, 0.01) + if len(times) != 7 { + t.Fatalf("sample count = %d, want 7", len(times)) + } + if times[0] != 0 || times[len(times)-1] != 1 { + t.Fatalf("sample endpoints = %.6f, %.6f, want 0 and 1", times[0], times[len(times)-1]) + } + foundGreatest := false + for index, value := range times { + if index > 0 && value <= times[index-1] { + t.Fatalf("sample times are not strictly increasing: %v", times) + } + if math.Abs(value-0.37) <= 1e-12 { + foundGreatest = true + } + } + if !foundGreatest { + t.Fatalf("sample times omitted greatest: %v", times) + } + if step <= 0 { + t.Fatalf("effective step = %v, want positive", step) + } +} + +func TestMovingDiskEventEngineWindowUsesCandidateAndExactPredicates(t *testing.T) { + engine := movingDiskEventEngine{ + searchSpanDays: 1, + rangeStepDays: 0.1, + rootToleranceDays: 1e-9, + } + candidateCalls, exactCalls := 0, 0 + candidate := func(tt float64) bool { + candidateCalls++ + return tt >= -0.35 && tt <= 0.42 + } + exact := func(tt float64) bool { + exactCalls++ + return tt >= -0.3 && tt <= 0.4 + } + start, end, ok := engine.window(0, -1, 1, candidate, exact) + if !ok { + t.Fatal("window returned not found") + } + if math.Abs(start+0.3) > 2e-8 || math.Abs(end-0.4) > 2e-8 { + t.Fatalf("window = %.12f..%.12f, want -0.3..0.4", start, end) + } + if candidateCalls == 0 || exactCalls == 0 { + t.Fatalf("candidate/exact calls = %d/%d, want both", candidateCalls, exactCalls) + } +} + +func TestMovingDiskEventEngineGreatestFindsMinimumImpact(t *testing.T) { + engine := movingDiskEventEngine{greatestSpanDays: 0.75} + got := engine.greatest(0.1, -1, 1, func(tt float64) (float64, bool) { + return (tt - 0.23) * (tt - 0.23), true + }, 56) + if math.Abs(got-0.23) > 1e-8 { + t.Fatalf("greatest = %.12f, want 0.23", got) + } +} + +func TestMovingDiskContactStateUsesSolarOuterAndInnerRadii(t *testing.T) { + state := movingDiskContactState{ + separation: 1.0, + occultingOuterRadius: 0.6, + occultingInnerRadius: 0.4, + targetRadius: 0.5, + valid: true, + } + if got := state.externalContactGap(); math.Abs(got+0.1) > 1e-12 { + t.Fatalf("external gap = %.12f, want -0.1", got) + } + if got := state.internalContactGap(); math.Abs(got-0.9) > 1e-12 { + t.Fatalf("internal gap = %.12f, want 0.9", got) + } + if !movingDiskContactStateValid(1, 0.6, 0.4, 0.5) { + t.Fatal("finite disk state was rejected") + } + if movingDiskContactStateValid(math.NaN(), 0.6, 0.4, 0.5) { + t.Fatal("NaN disk state was accepted") + } +} + +func TestMovingDiskEventEngineContactRootWalksBothDirections(t *testing.T) { + engine := movingDiskEventEngine{} + metric := func(tt float64) (float64, bool) { + return (tt + 0.3) * (tt - 0.4), true + } + for _, test := range []struct { + name string + direction float64 + want float64 + }{ + {name: "forward", direction: 1, want: 0.4}, + {name: "backward", direction: -1, want: -0.3}, + } { + got, ok := engine.contactRoot(0, test.direction, 0.05, 1, 1e-9, metric, 64) + if !ok || math.Abs(got-test.want) > 2e-8 { + t.Errorf("%s contact root = %.12f, ok=%v, want %.3f", test.name, got, ok, test.want) + } + } +} + +func TestMovingDiskContactEvaluatorCachesValidAndInvalidStates(t *testing.T) { + calls := 0 + evaluator := newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) { + calls++ + if tt < 0 { + return movingDiskContactState{}, false + } + state := movingDiskContactState{ + separation: 1, + occultingOuterRadius: 0.6, + occultingInnerRadius: 0.6, + targetRadius: 0.2, + valid: true, + } + return state, true + }) + if _, ok := evaluator.gap(0.25, false); !ok { + t.Fatal("first valid evaluation failed") + } + if _, ok := evaluator.gap(0.25, true); !ok { + t.Fatal("cached valid evaluation failed") + } + if _, ok := evaluator.gap(-0.25, false); ok { + t.Fatal("invalid evaluation was accepted") + } + if _, ok := evaluator.gap(-0.25, true); ok { + t.Fatal("cached invalid evaluation was accepted") + } + if calls != 2 { + t.Fatalf("evaluation calls = %d, want 2 after valid/invalid cache hits", calls) + } +} + +func TestMovingDiskContactEvaluatorPrimeAvoidsCallback(t *testing.T) { + calls := 0 + evaluator := newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) { + calls++ + return movingDiskContactState{}, false + }) + state := movingDiskContactState{ + separation: 1, + occultingOuterRadius: 0.6, + occultingInnerRadius: 0.6, + targetRadius: 0.2, + valid: true, + } + evaluator.prime(0.5, state, true) + value, ok := evaluator.gap(0.5, false) + if !ok || math.Abs(value-0.2) > 1e-12 { + t.Fatalf("primed external gap = %.12f, ok=%v", value, ok) + } + if calls != 0 { + t.Fatalf("callback calls = %d, want 0 for primed TT", calls) + } +} + +func TestMovingDiskContactCacheClearsAtBoundedCapacity(t *testing.T) { + cache := &movingDiskContactCache{maxEntries: 2} + state := movingDiskContactState{valid: true} + cache.store(1, state, true) + cache.store(2, state, true) + cache.store(3, state, true) + if len(cache.entries) != 1 { + t.Fatalf("cache size = %d, want 1 after bounded clear", len(cache.entries)) + } + if _, _, hit := cache.lookup(1); hit { + t.Fatal("old entry survived bounded cache clear") + } + if _, _, hit := cache.lookup(3); !hit { + t.Fatal("new entry missing after bounded cache clear") + } +} diff --git a/basic/neptune_events.go b/basic/neptune_events.go index 114fad0..7df681f 100644 --- a/basic/neptune_events.go +++ b/basic/neptune_events.go @@ -172,6 +172,9 @@ func LastNeptuneWesternQuadrature(jde float64) float64 { } func neptuneRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 { + if !isFiniteFloat(oppositionJD) { + return math.NaN() + } oppositionTT := TD2UT(oppositionJD, true) startTT := oppositionTT endTT := oppositionTT @@ -183,49 +186,98 @@ func neptuneRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposit endTT = TD2UT(westernQuadratureUT, true) } bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { - return neptuneRADerivativeN(jd, 1.0/86400.0, neptuneEventSearchN) + return neptuneRADerivativeN(jd, stationDerivativeStepDay, neptuneEventSearchN) }, func(jd float64) float64 { - return neptuneRADerivative(jd, 0.5/86400.0) + return neptuneRADerivative(jd, stationDerivativeStepDay) }) return TD2UT(bestJD, false) } func NextNeptuneRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := neptuneConjunctionFull(jde, 180, 0) date := neptuneRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } nextOppositionJD := neptuneConjunctionFull(jde, 180, 1) - return neptuneRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } + date = neptuneRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastNeptuneRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := neptuneConjunctionFull(jde, 180, 0) date := neptuneRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } previousOppositionJD := neptuneConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0) - return neptuneRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(previousOppositionJD) { + return math.NaN() + } + date = neptuneRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } func NextNeptuneProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := neptuneConjunctionFull(jde, 180, 1) date := neptuneRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } followingOppositionJD := neptuneConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1) - return neptuneRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(followingOppositionJD) { + return math.NaN() + } + date = neptuneRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastNeptuneProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := neptuneConjunctionFull(jde, 180, 1) date := neptuneRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } lastOppositionJD := neptuneConjunctionFull(jde, 180, 0) - return neptuneRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } + date = neptuneRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } diff --git a/basic/nutation.go b/basic/nutation.go index 7e8289c..a396ece 100644 --- a/basic/nutation.go +++ b/basic/nutation.go @@ -209,9 +209,17 @@ func Nutation1980(jd float64) (float64, float64) { return dpsi * deg, deps * deg } -// Nutation2000B 计算 IAU 2000B 章动模型 -// 返回交角章动 (de) 和黄经章动 (dp),单位为度 +// Nutation2000B IAU 2000B 章动,返回 (黄经章动, 交角章动),单位度 / nutation in longitude and obliquity in degrees. func Nutation2000B(jd float64) (float64, float64) { + if dpsi, deps, ok := nutationMemoLoad(jd); ok { + return dpsi, deps + } + dpsi, deps := nutation2000BCompute(jd) + nutationMemoStore(jd, dpsi, deps) + return dpsi, deps +} + +func nutation2000BCompute(jd float64) (float64, float64) { // 常量定义 as2r := 4.848136811095359935899141e-6 // 角秒到弧度的转换因子 twopi := 6.283185307179586476925287 // 2π diff --git a/basic/nutation_memo.go b/basic/nutation_memo.go new file mode 100644 index 0000000..284b4b1 --- /dev/null +++ b/basic/nutation_memo.go @@ -0,0 +1,75 @@ +package basic + +import ( + "math" + "sync" + "sync/atomic" +) + +// Nutation2000B 是 jd 的纯函数,但升落、星历与掩星路径会对同一批瞬时反复求值:一条计算链里 +// 地球自转、状态上下文与几何装配各自求一次同一个瞬时。这里用有界直接映射表记住结果: +// 槽位固定、无分配、RWMutex 保证 c-shared 宿主多线程安全。章动只依赖传入的 jd,与 ΔT 世代无关。 +// Nutation2000B is a pure function of jd, yet the rise/set, ephemeris and occultation chains evaluate +// it repeatedly for the same instants from independent code paths. This bounded direct-mapped memo +// removes that redundancy with fixed slots, no allocation and an RWMutex for the c-shared host. +// Nutation depends only on its argument, so no ΔT generation stamp is needed. +const nutationMemoBits = 13 + +const nutationMemoSize = 1 << nutationMemoBits + +type nutationMemoEntry struct { + // key 是 math.Float64bits(jd)+1,0 表示空槽(避免 jd=0 与空槽同码)。 + key uint64 + dpsi, deps float64 +} + +var ( + nutationMemoMu sync.RWMutex + nutationMemoTable [nutationMemoSize]nutationMemoEntry + nutationMemoHits uint64 + nutationMemoMiss uint64 +) + +func nutationMemoIndex(jd float64) uint64 { + bits := math.Float64bits(jd) + return (bits ^ (bits >> 31)) & (nutationMemoSize - 1) +} + +func nutationMemoLoad(jd float64) (float64, float64, bool) { + key := math.Float64bits(jd) + 1 + entry := &nutationMemoTable[nutationMemoIndex(jd)] + nutationMemoMu.RLock() + entryKey, dpsi, deps := entry.key, entry.dpsi, entry.deps + nutationMemoMu.RUnlock() + if key != 0 && entryKey == key { + atomic.AddUint64(&nutationMemoHits, 1) + return dpsi, deps, true + } + atomic.AddUint64(&nutationMemoMiss, 1) + return 0, 0, false +} + +func nutationMemoStore(jd, dpsi, deps float64) { + key := math.Float64bits(jd) + 1 + if key == 0 { + return + } + nutationMemoMu.Lock() + nutationMemoTable[nutationMemoIndex(jd)] = nutationMemoEntry{key: key, dpsi: dpsi, deps: deps} + nutationMemoMu.Unlock() +} + +// nutationMemoStats 返回命中/未命中计数,供测试守护命中率。 +func nutationMemoStats() (uint64, uint64) { + return atomic.LoadUint64(&nutationMemoHits), atomic.LoadUint64(&nutationMemoMiss) +} + +func resetNutationMemo() { + nutationMemoMu.Lock() + for i := range nutationMemoTable { + nutationMemoTable[i] = nutationMemoEntry{} + } + nutationMemoMu.Unlock() + atomic.StoreUint64(&nutationMemoHits, 0) + atomic.StoreUint64(&nutationMemoMiss, 0) +} diff --git a/basic/nutation_memo_test.go b/basic/nutation_memo_test.go new file mode 100644 index 0000000..c580777 --- /dev/null +++ b/basic/nutation_memo_test.go @@ -0,0 +1,56 @@ +package basic + +import ( + "math" + "testing" +) + +// 记忆表必须逐位精确:命中返回值与重新展开 77 项级数完全一致,且相邻 1 ULP 的输入互不干扰。 +// The memo must be bit-exact: a hit returns exactly what the 77-term series would recompute, and +// inputs one ULP apart keep independent entries. +func TestNutationMemoIsBitExact(t *testing.T) { + values := []float64{ + 2451545.0, 2460310.5, 0, -1000000.5, -4713.5, 5373484.5, + math.NaN(), math.Inf(1), math.Inf(-1), math.SmallestNonzeroFloat64, + } + for index := 0; index < 5000; index++ { + values = append(values, 1000000.0+float64(index)*0.37) + } + for _, jd := range values { + memoPsi, memoEps := Nutation2000B(jd) + directPsi, directEps := nutation2000BCompute(jd) + if math.Float64bits(memoPsi) != math.Float64bits(directPsi) || + math.Float64bits(memoEps) != math.Float64bits(directEps) { + t.Fatalf("jd=%v: memo (%v,%v) != direct (%v,%v)", jd, memoPsi, memoEps, directPsi, directEps) + } + againPsi, againEps := Nutation2000B(jd) + if math.Float64bits(againPsi) != math.Float64bits(memoPsi) || + math.Float64bits(againEps) != math.Float64bits(memoEps) { + t.Fatalf("jd=%v: repeated call changed the value", jd) + } + // 相邻 1 ULP 的输入必须各自独立(不能被同一槽位合并)。 + neighbour := math.Nextafter(jd, math.Inf(1)) + if math.IsNaN(jd) || math.IsInf(jd, 0) { + continue + } + nearPsi, _ := Nutation2000B(neighbour) + nearDirect, _ := nutation2000BCompute(neighbour) + if math.Float64bits(nearPsi) != math.Float64bits(nearDirect) { + t.Fatalf("jd=%v neighbour: memo (%v) != direct (%v)", jd, nearPsi, nearDirect) + } + } +} + +// 章动只依赖传入的 jd:ΔT 覆盖不改变它,因此记忆表不需要世代戳(与视恒星时不同)。 +func TestNutationMemoIgnoresDeltaTGeneration(t *testing.T) { + original := GetDeltaTFn() + defer SetDeltaTFn(original) + jd := 2460310.5 + beforePsi, beforeEps := Nutation2000B(jd) + SetDeltaTFn(func(date float64, isJd bool) float64 { return 6000 }) + afterPsi, afterEps := Nutation2000B(jd) + if math.Float64bits(beforePsi) != math.Float64bits(afterPsi) || + math.Float64bits(beforeEps) != math.Float64bits(afterEps) { + t.Fatalf("nutation changed with the ΔT generation: (%v,%v) vs (%v,%v)", beforePsi, beforeEps, afterPsi, afterEps) + } +} diff --git a/basic/occultation.go b/basic/occultation.go index 019e242..ba14776 100644 --- a/basic/occultation.go +++ b/basic/occultation.go @@ -162,9 +162,19 @@ func moonTopocentricSemidiameterN(tt float64, observer Observer, n int) float64 return angularSemidiameterArcsec(moonEquatorialRadiusKM, distanceKM) } -// topocentricDistanceKM 使用与 TopocentricRaDec 相同的 WGS-84 风格站点因子计算观测者到目标的距离 / -// The target is supplied in apparent equatorial coordinates, and the sidereal angle uses UTC/UT like TopocentricRaDec. +// topocentricDistanceKM 使用与 TopocentricRaDec 相同的 WGS-84 风格站点因子计算观测者到目标的距离。 +// 目标采用视赤道坐标,恒星时与 TopocentricRaDec 一样基于 UTC/UT。 +// topocentricDistanceKM uses the same WGS-84-style site factors as TopocentricRaDec. +// The target uses apparent equatorial coordinates, and the sidereal angle is based on UTC/UT. func topocentricDistanceKM(ra, dec, distanceKM float64, observer Observer, ut float64) float64 { + return topocentricDistanceKMWithSidereal(ra, dec, distanceKM, observer, ApparentSiderealTime(ut)*15) +} + +func topocentricDistanceKMWithSidereal( + ra, dec, distanceKM float64, + observer Observer, + siderealDegrees float64, +) float64 { const earthEquatorialRadius = 6378.14 const astronomicalUnitKM = angularDiameterAstronomicalUnitKM @@ -179,7 +189,7 @@ func topocentricDistanceKM(ra, dec, distanceKM float64, observer Observer, ut fl distanceAU * math.Cos(decRad) * math.Sin(raRad), distanceAU * math.Sin(decRad), } - theta := (ApparentSiderealTime(ut)*15 + observer.Longitude) * math.Pi / 180 + theta := (siderealDegrees + observer.Longitude) * math.Pi / 180 observerAU := earthEquatorialRadius / astronomicalUnitKM observerVector := [3]float64{ observerAU * pcosi(observer.Latitude, observer.Height) * math.Cos(theta), @@ -309,21 +319,68 @@ type OccultationSearchOptions struct { MaxEvents int } +// OccultationPathAlgorithm 选择全球月掩路径的星历求解分支。 +// OccultationPathAlgorithm selects the ephemeris branch for global occultation paths. +type OccultationPathAlgorithm string + +const ( + // OccultationPathAlgorithmOptimized 使用经抽检的密集星历插值,保留站心方程与连续包络。 + // OccultationPathAlgorithmOptimized uses checked dense ephemeris interpolation with the same station equations and continuous envelopes. + OccultationPathAlgorithmOptimized OccultationPathAlgorithm = "optimized" + // OccultationPathAlgorithmExact 保留原分支:插值预测候选,最终求解使用全项星历。 + // OccultationPathAlgorithmExact retains the original branch: interpolated candidates and full-term ephemerides for final solving. + OccultationPathAlgorithmExact OccultationPathAlgorithm = "exact" +) + // OccultationPathOptions 控制全球月掩路径采样。 // // Step 为路径采样的基础时间步长,正值至少为 1 秒。TargetSpacingKM 要求相邻中心线点超过目标地面距离时进行自适应加密。 -// 正的 TargetSpacingKM 至少为 1 km;超过中心线或有限盘面路径工作量预算时返回 ErrOccultationPathSamplingLimit,不会静默降低请求分辨率。行星瞬时足迹使用结果中说明的独立有界采样策略。 +// 正的 TargetSpacingKM 至少为 1 km;超过中心线或有限盘面路径工作量预算时返回 ErrOccultationPathSamplingLimit,不会静默降低请求分辨率。恒星和行星瞬时足迹使用 1 分钟目标步长和独立样本上限。 // OccultationPathOptions controls global occultation-path sampling. // Step is the base time step used for path samples; positive values must be at least one second. TargetSpacingKM requests adaptive refinement when adjacent center-line points exceed the requested ground distance. -// Positive TargetSpacingKM values must be at least 1 km. Requests that exceed the center-line or aggregate finite-disk work budgets return ErrOccultationPathSamplingLimit instead of silently reducing resolution. Planetary instantaneous footprints have a separate bounded sampling policy documented on the result. +// Positive TargetSpacingKM values must be at least 1 km. Requests that exceed the center-line or aggregate finite-disk work budgets return ErrOccultationPathSamplingLimit instead of silently reducing resolution. Stellar and planetary instantaneous footprints use a one-minute target step and a separate sample cap. type OccultationPathOptions struct { + // Algorithm 的零值等同 optimized;exact 可选择原有精确分支。此选项不影响独立的单时刻月影与事件查询接口。 + // Algorithm defaults to optimized; exact selects the original branch. Independent instant-footprint and event-search APIs are unaffected. + Algorithm OccultationPathAlgorithm Step time.Duration TargetSpacingKM float64 + // RiseSetStep 独立控制六类升落阶段线的采样步长;零值使用 5 分钟。 + // RiseSetStep controls horizon-curve sampling independently from the path step; zero uses five minutes. + RiseSetStep time.Duration + // DisableRiseSet 在不需要时跳过六类初掩、掩甚、终掩月升/月落线。 + // DisableRiseSet skips the six local phase/horizon curves when they are not needed. + DisableRiseSet bool + // DisableFootprints 跳过密集的独立瞬时可见区,改用稀疏支撑样本构造紧凑掩带;中心线、边界和升落阶段线仍保留。 + // DisableFootprints skips dense standalone instantaneous visible regions and uses sparse support samples to construct compact bands; the center line, limits, and rise/set curves remain available. + DisableFootprints bool + // IncludeFootprintTimeline 在保留紧凑静态掩带的同时,增加独立采样的瞬时足迹时间线。 + // IncludeFootprintTimeline adds independently sampled instantaneous footprints while retaining the compact static band. + IncludeFootprintTimeline bool + // FootprintTimelineStep 控制瞬时足迹时间线的采样间隔;启用时零值使用 5 分钟。 + // FootprintTimelineStep controls the instantaneous timeline interval; zero uses five minutes when the timeline is enabled. + FootprintTimelineStep time.Duration + // GreatestTimeValues 是要计算的地方掩甚时刻等值线的时刻取值(力学时儒略日,最多 64 条,超出按时间截断);空值时改用 GreatestTimeStep。 + // 只有确实存在该时刻掩甚轨迹的取值才会出现在结果里,所以返回条数可能少于请求条数。 + // 每条等时线用固定时刻的残差零集延拓,成本正比于曲线长度而不是可见域面积。 + // GreatestTimeValues requests local greatest-occultation time isolines as TT Julian ephemeris days; + // when empty, GreatestTimeStep is used instead. Each isochrone is continued along the zero set of a + // fixed-instant residual so the cost scales with curve length rather than with the visible area. + GreatestTimeValues []float64 + // GreatestTimeStep 是等时线间隔;仅在 GreatestTimeValues 为空时生效,非正值不计算等时线。 + // GreatestTimeStep is the isochrone interval; it applies only when GreatestTimeValues is empty, + // and non-positive values disable the isolines. + GreatestTimeStep time.Duration } // Validate 检查全球路径采样选项。 // Validate checks global path sampling options. func (o OccultationPathOptions) Validate() error { + switch o.Algorithm { + case "", OccultationPathAlgorithmOptimized, OccultationPathAlgorithmExact: + default: + return fmt.Errorf("%w: unsupported path algorithm %q", ErrInvalidOccultationInput, o.Algorithm) + } if o.Step < 0 { return fmt.Errorf("%w: path step cannot be negative", ErrInvalidOccultationInput) } @@ -336,6 +393,18 @@ func (o OccultationPathOptions) Validate() error { if o.TargetSpacingKM > 0 && o.TargetSpacingKM < occultationPathMinimumTargetSpacingKM { return fmt.Errorf("%w: path target spacing must be zero or at least %.0f km", ErrInvalidOccultationInput, occultationPathMinimumTargetSpacingKM) } + if o.RiseSetStep < 0 { + return fmt.Errorf("%w: rise/set step cannot be negative", ErrInvalidOccultationInput) + } + if o.RiseSetStep > 0 && o.RiseSetStep < occultationPathMinimumStep { + return fmt.Errorf("%w: rise/set step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep) + } + if o.FootprintTimelineStep < 0 { + return fmt.Errorf("%w: footprint step cannot be negative", ErrInvalidOccultationInput) + } + if o.FootprintTimelineStep > 0 && o.FootprintTimelineStep < occultationPathMinimumStep { + return fmt.Errorf("%w: footprint step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep) + } return nil } @@ -412,23 +481,44 @@ type PlanetOccultationInfo struct { } // OccultationPathPoint 是全球月掩路径上的一个地理采样点。 -// Start 和 End 描述月缘外接触掩带;WidthKM 是垂直地面轨迹方向的切平面宽度,仅对中心线采样点有意义。 +// Start 和 End 描述月缘外接触掩带。 +// WidthKM 是中心线采样处的地面横向宽度:接触锥可见弧上地面横向偏移的极差;投影折叠的退化事件改用同刻两条横切母线与椭球交点间的弦长。 +// LimitSeparationKM 只对南北限采样点(含全掩限)非零,是同一时刻对侧限线的地面间距;它与 WidthKM 构造不同,不要互相换算。 // 基础采样直接求解,自适应插入点使用宽度插值并进行五米采样误差检查。 // OccultationPathPoint is a geographic sample of a global lunar-occultation path. -// Start and End describe the outer lunar-limb footprint. WidthKM is the local tangent-plane width perpendicular to the ground track and is meaningful only on center-line samples. +// Start and End describe the outer lunar-limb footprint. +// WidthKM is the ground cross-track width at a center-line sample: the spread of ground cross-track offsets over the visible contact-cone arc, falling back to the same-instant chord between the two cross-track generators when the projection folds. +// LimitSeparationKM is non-zero only on northern/southern limit samples (total limits included) and is the same-instant ground distance to the opposite limit; it is constructed differently from WidthKM and must not be converted into it. // Base samples are solved directly; adaptive samples use width interpolation and five-meter error checks. type OccultationPathPoint struct { - Time time.Time - Longitude float64 - Latitude float64 - MoonAltitude float64 - WidthKM float64 + Time time.Time + Longitude float64 + Latitude float64 + MoonAltitude float64 + WidthKM float64 + LimitSeparationKM float64 +} + +// OccultationGreatestTimeContour 是一个固定地方掩甚时刻的等值线支路集合。 +// OccultationGreatestTimeContour contains the continuous branches of one fixed local greatest-occultation time. +type OccultationGreatestTimeContour struct { + // JDE 是该等值线表示的力学时儒略日,也就是各支路上地方掩甚发生的时刻。 + // JDE is the TT Julian ephemeris day represented by this contour, the local greatest-occultation instant along every branch. + JDE float64 + // Time 是 JDE 对应的时刻;按步长请求时它是原始对齐时刻,避免 JDE 往返把整分取值截断成前一分钟。 + // Time is the instant matching JDE; for step-derived levels it is the original aligned instant, + // so a JDE round trip cannot truncate a whole-minute level into the previous minute. + Time time.Time + // Segments 是该时刻的连续等时线支路;一条支路两端止于月平线(几何地平,无蒙气差修正)或掩可见域边界, + // 纬度 ±88° 以上不再延拓,同一时刻可能有多条不相连的支路。 + // Segments are continuous isochrone branches; each branch ends at the lunar horizon or the occultation-visibility boundary. + Segments [][]OccultationPathPoint } // StarOccultationPath 包含点光源恒星月掩的全球掩带。 -// 中心线是月心与恒星对齐的轨迹;NorthernLimit 和 SouthernLimit 是中心线两侧采样的月缘外边界。 +// 中心线是月心与恒星对齐的轨迹;NorthernLimit 和 SouthernLimit 是月缘外接触锥的切点轨迹(掩星在该线上恰好退化为擦边),不是中心线的等距横向平移。 // StarOccultationPath contains the global footprint of a point-source stellar occultation. -// The center line is the locus where the lunar center aligns with the star; NorthernLimit and SouthernLimit are the two outer lunar-limb boundaries sampled beside that line. +// The center line is the locus where the lunar center aligns with the star; NorthernLimit and SouthernLimit are the tangency tracks of the outer-contact cone, where the occultation degenerates to a graze, so they are not a constant offset of the center line. type StarOccultationPath struct { TargetID string @@ -442,20 +532,77 @@ type StarOccultationPath struct { CenterLine []OccultationPathPoint NorthernLimit []OccultationPathPoint SouthernLimit []OccultationPathPoint + // GreatestLimitSeparationKM 是掩甚处南北限的地面间距(取与掩甚时刻最近的限线采样对),与 Greatest.WidthKM 口径不同,不要互相换算。 + // GreatestLimitSeparationKM is the ground separation of the two limits at greatest, taken from the limit sample pair nearest to the greatest instant; it uses a different construction from Greatest.WidthKM and must not be converted into it. + GreatestLimitSeparationKM float64 + // BandContours 是构造静态可见掩带的连续接触包络;点光源恒星等价于月缘外接触边界。 + // BandContours are the continuous contact envelopes used to construct the static visible band; for point-source stars they are the lunar-limb outer-contact boundaries. + BandContours [][]OccultationPathPoint + // VisibilityContours 是掩星有效期间月球可见性的连续时间外包络。 + // VisibilityContours are the continuous temporal envelopes of lunar visibility while the occultation is active. + VisibilityContours [][]OccultationPathPoint + // Footprints 是时刻采样的可见点源掩区,其扫掠构成全球掩带;采样口径与行星外接触足迹一致。 + // Footprints are sampled visible point-source regions whose sweep forms the global band, using the same sampling contract as planetary outer-contact footprints. + Footprints []OccultationFootprint + // BandFootprints 是关闭密集瞬时足迹时用于构造紧凑掩带的稀疏可见区样本;展示层应合并它们,不应逐个输出。 + // BandFootprints are sparse visible-region samples used to construct a compact band when dense instantaneous footprints are disabled. Renderers should merge rather than emit them individually. + BandFootprints []OccultationFootprint + // RiseSetCurves 是初掩、掩甚和终掩分别发生在月升或月落时的六类边界。 + // RiseSetCurves are the six boundaries where local start, greatest, or end occurs at moonrise or moonset. + RiseSetCurves []OccultationRiseSetCurve + // GreatestTimeContours 是按掩甚时刻采样的等时线。 + // GreatestTimeContours are sampled local greatest-occultation time isolines. + GreatestTimeContours []OccultationGreatestTimeContour Step time.Duration TargetSpacingKM float64 } -// PlanetOccultationFootprint 是一个时刻的可见接触足迹。 -// Polygons 包含接触锥圆弧;当锥面与椭球的交线在朝月半球开放时,沿月球地平线闭合。 -// PlanetOccultationFootprint is one instantaneous visible contact footprint. -// Polygons contain contact-cone arcs closed along the lunar horizon when the cone/ellipsoid intersection is open on the Moon-facing hemisphere. -type PlanetOccultationFootprint struct { - Time time.Time +// OccultationFootprint 是一个时刻的可见接触足迹。 +// Polygons 包含供独立时刻绘制的闭合可见区;Boundaries 保留未经地平线封口的接触锥弧,供稀疏静态样本连续扫掠。 +// Closed 表示 Boundaries 本身是否为闭合交线。 +// OccultationFootprint is one instantaneous visible contact footprint. +// Polygons contain closed visible regions for rendering one instant. Boundaries retain contact-cone arcs before horizon closure for continuously sweeping sparse static samples. +// Closed reports whether Boundaries themselves form a closed intersection. +type OccultationFootprint struct { + // Time 是该瞬时足迹对应的事件时刻。 + // Time is the event time represented by this instantaneous footprint. + Time time.Time + // Polygons 是供独立时刻绘制的闭合可见区域。 + // Polygons are closed visible regions for rendering one instant. Polygons [][]OccultationPathPoint + // InteriorPolygons 是为单时刻绘制保留的闭合可见修复面。静态掩带构建器会将其与连续扫掠的接触边界弧分开,避免数值中心修复扩大长时间地理掩带。 + // InteriorPolygons are closed visible repair faces retained for one-instant + // rendering. Static band builders keep them separate from the continuously + // swept contact-boundary arcs so a numerical center repair cannot enlarge a + // long-lived geographic band. + InteriorPolygons [][]OccultationPathPoint + // Boundaries 是尚未经过地平线封口的接触边界弧。 + // Boundaries are contact-boundary arcs before horizon closure. + Boundaries [][]OccultationPathPoint + // Closed 表示 Boundaries 是否构成闭合交线。 + // Closed reports whether Boundaries form a closed intersection. + Closed bool } +// OccultationRiseSetCurve 是一种局部月掩阶段与月升/月落同时发生的边界。 +// OccultationRiseSetCurve is one boundary where a local occultation phase coincides with moonrise or moonset. +type OccultationRiseSetCurve struct { + // Phase 是与月升或月落同时发生的局部月掩阶段。 + // Phase is the local occultation phase coinciding with moonrise or moonset. + Phase RiseSetPhase + // Direction 标识月球正在升起还是落下。 + // Direction identifies whether the Moon is rising or setting. + Direction RiseSetDirection + // Segments 是反经线和支路跳变安全分段后的边界采样。 + // Segments are boundary samples split safely at the antimeridian and branch changes. + Segments [][]OccultationPathPoint +} + +// PlanetOccultationFootprint 保留有限盘面行星月掩足迹的兼容名称。 +// PlanetOccultationFootprint retains the compatibility name for finite-disk planetary footprints. +type PlanetOccultationFootprint = OccultationFootprint + // PlanetOccultationPath 包含有限盘面行星月掩的全球掩带。 // NorthernLimit 和 SouthernLimit 是行星盘面任意部分被覆盖的外接触边界。 // HasTotalBand 为 true 时,NorthernTotalLimit 和 SouthernTotalLimit 是行星圆盘完全被月球覆盖的内接触边界; @@ -474,13 +621,32 @@ type PlanetOccultationPath struct { // Complete is true when Start and End are the global outer contacts. Complete bool - CenterLine []OccultationPathPoint + // CenterLine 是影轴与椭球交点的轨迹;非中心事件退化为最接近影轴的椭球点。 + // CenterLine is the track of the shadow-axis/ellipsoid intersection, degenerating to the ellipsoid point nearest the axis for non-central events. + CenterLine []OccultationPathPoint + // NorthernLimit 和 SouthernLimit 是外接触锥的切点轨迹:掩星在该线上恰好退化为擦边,不是中心线的等距横向平移。 + // NorthernLimit and SouthernLimit are the tangency tracks of the outer-contact cone, where the occultation degenerates to a graze, so they are not a constant-width offset of the center line. NorthernLimit []OccultationPathPoint SouthernLimit []OccultationPathPoint - // PartialFootprints 是时刻采样的可见外接触区域,其扫掠构成全球偏掩区域;为限制输出和运行时间,采样可能比 Step 更粗, + // GreatestLimitSeparationKM 是掩甚处南北限的地面间距(取与掩甚时刻最近的限线采样对),与 Greatest.WidthKM 口径不同,不要互相换算。 + // GreatestLimitSeparationKM is the ground separation of the two limits at greatest, taken from the limit sample pair nearest to the greatest instant; it uses a different construction from Greatest.WidthKM and must not be converted into it. + GreatestLimitSeparationKM float64 + // PartialBandContours 是偏掩静态带的连续外接触包络;任意目标盘面重叠时取零。 + // PartialBandContours are the continuous outer-contact envelopes for the static partial band where any target-disk overlap begins. + PartialBandContours [][]OccultationPathPoint + // PartialVisibilityContours 是月球可见性在偏掩阶段内的连续时间外包络。 + // PartialVisibilityContours are the continuous temporal envelopes of lunar visibility while partial occultation is active. + PartialVisibilityContours [][]OccultationPathPoint + // PartialFootprints 是时刻采样的可见外接触区域,其扫掠构成全球偏掩区域;足迹以 1 分钟为目标步长,超出独立样本上限时会变粗, // 每个足迹携带实际采样时刻。 - // PartialFootprints are instantaneous visible outer-contact regions whose sweep forms the global partial-occultation area. To bound output and runtime, sampling may be coarser than Step; each footprint carries its actual sample time. + // PartialFootprints are instantaneous visible outer-contact regions whose sweep forms the global partial-occultation area. Footprints target one-minute intervals and become coarser when their independent sample cap is reached; each footprint carries its actual sample time. PartialFootprints []PlanetOccultationFootprint + // PartialBandFootprints 是关闭密集瞬时足迹时用于构造紧凑偏掩带的稀疏可见区样本。 + // PartialBandFootprints are sparse visible-region samples used to construct the compact partial band when dense instantaneous footprints are disabled. + PartialBandFootprints []PlanetOccultationFootprint + // RiseSetCurves 是初掩、掩甚和终掩分别发生在月升或月落时的六类边界。 + // RiseSetCurves are the six boundaries where local start, greatest, or end occurs at moonrise or moonset. + RiseSetCurves []OccultationRiseSetCurve HasTotalBand bool // TotalStart 和 TotalEnd 是全球内接触的起止点。 @@ -491,14 +657,31 @@ type PlanetOccultationPath struct { // TotalComplete is true when TotalStart and TotalEnd are not clipped by the internal search span. TotalComplete bool + // NorthernTotalLimit 和 SouthernTotalLimit 是内接触(全掩)锥的切点轨迹,同刻间距同样记录在 LimitSeparationKM 上。 + // NorthernTotalLimit and SouthernTotalLimit are the tangency tracks of the inner-contact cone; their same-instant separation is recorded in LimitSeparationKM as well. NorthernTotalLimit []OccultationPathPoint SouthernTotalLimit []OccultationPathPoint - // TotalFootprints 是时刻采样的可见内接触区域,其扫掠构成全球全掩区域;采样使用与 PartialFootprints 相同的有界策略。 - // TotalFootprints are instantaneous visible inner-contact regions whose sweep forms the global full-coverage area. Their sampling uses the same bounded policy as PartialFootprints. + // TotalBandContours 是全掩静态带的连续内接触包络;仅在目标圆盘完全被月球覆盖时取零。 + // TotalBandContours are the continuous inner-contact envelopes for the static total band where the complete target disk is covered by the Moon. + TotalBandContours [][]OccultationPathPoint + // TotalVisibilityContours 是全掩阶段内月球可见性的连续时间外包络。 + // TotalVisibilityContours are the temporal envelopes of lunar visibility while the total-occultation contact condition is active. + TotalVisibilityContours [][]OccultationPathPoint + // TotalFootprints 是时刻采样的可见内接触区域,其扫掠构成全球全掩区域;采样使用与 PartialFootprints 相同的 1 分钟目标步长和样本上限。 + // TotalFootprints are instantaneous visible inner-contact regions whose sweep forms the global full-coverage area. Their sampling uses the same one-minute target step and sample cap as PartialFootprints. TotalFootprints []PlanetOccultationFootprint - // GreatestTotalWidthKM 是全球掩甚时的全掩带宽度。 - // GreatestTotalWidthKM is the full-coverage band width at global greatest. + // TotalBandFootprints 是关闭密集瞬时足迹时用于构造紧凑全掩带的稀疏可见区样本。 + // TotalBandFootprints are sparse visible-region samples used to construct the compact total band when dense instantaneous footprints are disabled. + TotalBandFootprints []PlanetOccultationFootprint + // TotalRiseSetCurves 是全掩带使用的内接触月升/月落边界;其相位与 RiseSetCurves 相同,但 contact metric 采用内接触而非外接触。 + // TotalRiseSetCurves are the inner-contact moonrise/moonset boundaries used by the total band; they share the same phase labels as RiseSetCurves but evaluate the inner-contact metric instead of the outer-contact metric. + TotalRiseSetCurves []OccultationRiseSetCurve + // GreatestTotalWidthKM 是全球掩甚时的全掩带宽度,与 Greatest.WidthKM 同口径而作用于内接触锥,且恒小于它。 + // GreatestTotalWidthKM is the full-coverage band width at global greatest, using the same construction as Greatest.WidthKM on the inner-contact cone and always below it. GreatestTotalWidthKM float64 + // GreatestTimeContours 是按掩甚时刻采样的等时线,判据用外接触锥,与偏掩可见域一致。 + // GreatestTimeContours are sampled local greatest-occultation time isolines, gated by the outer-contact cone so they match the partial-occultation visibility area. + GreatestTimeContours []OccultationGreatestTimeContour Step time.Duration TargetSpacingKM float64 diff --git a/basic/occultation_boundary_sampling_test.go b/basic/occultation_boundary_sampling_test.go new file mode 100644 index 0000000..a877356 --- /dev/null +++ b/basic/occultation_boundary_sampling_test.go @@ -0,0 +1,76 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +func TestOccultationBoundarySamplingNearTemporalFolds(t *testing.T) { + for _, algorithm := range []OccultationPathAlgorithm{OccultationPathAlgorithmExact, OccultationPathAlgorithmOptimized} { + t.Run(string(algorithm), func(t *testing.T) { + day := time.Date(3627, 9, 20, 0, 0, 0, 0, time.UTC) + paths, err := FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), OccultationJupiter, + OccultationPathOptions{Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute}) + if err != nil || len(paths) != 1 { + t.Fatalf("paths=%d err=%v", len(paths), err) + } + for _, contours := range [][][]OccultationPathPoint{paths[0].PartialBandContours, paths[0].TotalBandContours} { + for ci, contour := range contours { + for i := 1; i+1 < len(contour); i++ { + a, b, c := contour[i-1], contour[i], contour[i+1] + first, second := occultationPathDistanceKM(a, b), occultationPathDistanceKM(b, c) + if first > 0.05 && second > 0.05 && first+second > 2 && occultationRiseSetTurnAngleDegrees(a, b, c) < 30 { + t.Errorf("contour %d point %d reverses between %.3f/%.3f km edges at %.9f,%.9f", ci, i, first, second, b.Longitude, b.Latitude) + } + } + } + } + }) + } +} + +func TestOccultationPhaseJunctionSamplingResolvesCurvature(t *testing.T) { + day := time.Date(1227, 5, 19, 0, 0, 0, 0, time.UTC) + paths, err := FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), OccultationVenus, + OccultationPathOptions{Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute}) + if err != nil || len(paths) != 1 { + t.Fatalf("paths=%d err=%v", len(paths), err) + } + config, _ := planetOccultationConfigFor(OccultationVenus) + cache := newPlanetOccultationEventCache(config) + checked := 0 + for ci, curve := range paths[0].RiseSetCurves { + if curve.Phase == RiseSetPhaseGreatest { + continue + } + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, atStart := range []bool{true, false} { + endpoint := occultationRiseSetSegmentEndpoint(segment, atStart) + if !occultationRiseSetEndpointSharesPhaseJunction(paths[0].RiseSetCurves, ci, endpoint) { + continue + } + index := len(segment) - 2 + if atStart { + index = 0 + } + a, b := segment[index], segment[index+1] + middle, ok := occultationRiseSetPhaseMidpoint(a, b, curve.Phase, curve.Direction, time.UTC, cache.riseSetCache) + if !ok || occultationPathDistanceKM(a, b) < 0.05 { + continue + } + checked++ + deviation := planetOccultationPointSegmentDistanceKM(middle, a, b) + if math.IsNaN(deviation) || deviation > 0.05 { + t.Errorf("%s/%s endpoint chord misses the physical arc by %.6f km", curve.Phase, curve.Direction, deviation) + } + } + } + } + if checked < 2 { + t.Fatalf("checked only %d junction chords", checked) + } +} diff --git a/basic/occultation_frame_cache_test.go b/basic/occultation_frame_cache_test.go new file mode 100644 index 0000000..1a0d0e6 --- /dev/null +++ b/basic/occultation_frame_cache_test.go @@ -0,0 +1,94 @@ +package basic + +import ( + "testing" + "time" +) + +// TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch 固定 frame 级边界记忆化的语义: +// 记忆化只是复用同一 frame 上纯函数的计算结果,因此切点、可见 θ 区间与按中心角的 θ 区间 +// 都必须与直接调用未缓存实现逐位一致,重复调用也必须稳定。 +// TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch pins the frame-level boundary memo +// semantics: memoization only reuses pure results for the same frame, so the tangency, the +// visible theta intervals and the per-center-angle interval must stay bit-identical to the +// uncached implementations, and repeated calls must be stable. +func TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch(t *testing.T) { + config, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn occultation config is unavailable") + } + tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC)) + cache := newPlanetOccultationEventCache(config) + + cachedFrame, cachedOK := cache.outerFrameAt(tt) + directFrame, directOK := planetOccultationPathFrameAt(tt, config) + if cachedOK != directOK || !cachedOK { + t.Fatalf("frame availability cached=%v direct=%v, want both true", cachedOK, directOK) + } + if !occultationPathFrameGeometryEqual(cachedFrame, directFrame) { + t.Fatal("cached frame geometry differs from a direct frame") + } + if cachedFrame.boundary == nil { + t.Fatal("cached frame is missing its boundary memo") + } + + wantPoint, wantTheta, wantOK := occultationPathBoundaryTangentUncached(directFrame) + gotPoint, gotTheta, gotOK := occultationPathBoundaryTangent(cachedFrame) + if gotOK != wantOK || gotPoint != wantPoint || gotTheta != wantTheta { + t.Fatalf("memoized tangency = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)", + gotPoint, gotTheta, gotOK, wantPoint, wantTheta, wantOK) + } + againPoint, againTheta, againOK := occultationPathBoundaryTangent(cachedFrame) + if againOK != gotOK || againPoint != gotPoint || againTheta != gotTheta { + t.Fatal("repeated tangency query is not stable") + } + + wantIntervals := occultationPathBoundaryThetaIntervalsUncached(directFrame) + gotIntervals := occultationPathBoundaryThetaIntervals(cachedFrame) + if len(gotIntervals) != len(wantIntervals) { + t.Fatalf("memoized theta intervals = %v, want %v", gotIntervals, wantIntervals) + } + for index := range gotIntervals { + if gotIntervals[index] != wantIntervals[index] { + t.Fatalf("memoized theta interval %d = %v, want %v", index, gotIntervals[index], wantIntervals[index]) + } + } + if againIntervals := occultationPathBoundaryThetaIntervals(cachedFrame); len(againIntervals) != len(gotIntervals) { + t.Fatal("repeated theta-interval query is not stable") + } + + cachedLeft, cachedRight, cachedIntervalOK := occultationPathBoundaryThetaInterval(cachedFrame, wantTheta) + directLeft, directRight, directIntervalOK := occultationPathBoundaryThetaInterval(directFrame, wantTheta) + if cachedIntervalOK != directIntervalOK || cachedLeft != directLeft || cachedRight != directRight { + t.Fatalf("memoized interval = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)", + cachedLeft, cachedRight, cachedIntervalOK, directLeft, directRight, directIntervalOK) + } + repeatLeft, repeatRight, repeatOK := occultationPathBoundaryThetaInterval(cachedFrame, wantTheta) + if repeatOK != cachedIntervalOK || repeatLeft != cachedLeft || repeatRight != cachedRight { + t.Fatal("repeated per-angle interval query is not stable") + } +} + +// TestOccultationPathZeroFrameComputesBoundaryWithoutCache 保证未挂记忆化的零值 frame 仍然 +// 直接计算,不会被 nil 缓存跳过。 +// TestOccultationPathZeroFrameComputesBoundaryWithoutCache keeps a frame without a memo working: +// a nil boundary cache must fall back to direct computation, not skip it. +func TestOccultationPathZeroFrameComputesBoundaryWithoutCache(t *testing.T) { + frame := occultationPathFrame{ + moon: occultationPathVector{x: 384000}, + axis: occultationPathVector{x: -1}, + first: occultationPathVector{y: 1}, + second: occultationPathVector{z: 1}, + moonRadius: 0.0045, + targetRadius: 1e-5, + } + if frame.boundary != nil { + t.Fatal("fixture frame unexpectedly carries a boundary memo") + } + point, theta, ok := occultationPathBoundaryTangent(frame) + wantPoint, wantTheta, wantOK := occultationPathBoundaryTangentUncached(frame) + if ok != wantOK || point != wantPoint || theta != wantTheta { + t.Fatalf("uncached tangency = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)", + point, theta, ok, wantPoint, wantTheta, wantOK) + } +} diff --git a/basic/occultation_instant.go b/basic/occultation_instant.go new file mode 100644 index 0000000..96bac19 --- /dev/null +++ b/basic/occultation_instant.go @@ -0,0 +1,150 @@ +package basic + +import ( + "fmt" + "math" + "time" +) + +// StarOccultationInstant 包含指定时刻的点源恒星月掩可见足迹;若接触锥在该时刻未到达可见地球,Footprint 为 nil。 +// StarOccultationInstant contains the visible point-source footprint at one requested instant; Footprint is nil when no part of the contact cone reaches the visible Earth. +type StarOccultationInstant struct { + Time time.Time + TargetID string + // DeltaTSeconds 本次实际使用的 ΔT / ΔT actually used. + DeltaTSeconds float64 + // SublunarLongitude 与 SublunarLatitude 是该时刻月下点,用于声明地平闭合弧 / sublunar point for the horizon closure. + SublunarLongitude float64 + SublunarLatitude float64 + Footprint *OccultationFootprint +} + +// PlanetOccultationInstant 包含指定时刻的行星外接触和内接触可见足迹;相应接触锥未到达可见地球时,Partial 或 Total 为 nil。 +// PlanetOccultationInstant contains the visible outer- and inner-contact footprints at one requested instant; Partial or Total is nil when that contact cone does not reach the visible Earth. +type PlanetOccultationInstant struct { + Time time.Time + Planet OccultationPlanet + TargetID string + // DeltaTSeconds 本次实际使用的 ΔT / ΔT actually used. + DeltaTSeconds float64 + // SublunarLongitude 与 SublunarLatitude 是该时刻月下点,用于声明地平闭合弧 / sublunar point for the horizon closure. + SublunarLongitude float64 + SublunarLatitude float64 + Partial *PlanetOccultationFootprint + Total *PlanetOccultationFootprint +} + +// StarOccultationFootprintAt 返回指定时刻的精确点源恒星月掩可见足迹;它采用与路径时间线相同的分辨率,并对完整接触弧执行站心校正。 +// StarOccultationFootprintAt returns the exact visible lunar-occultation footprint of a point-source star at one instant, using timeline resolution and station-centred correction of the complete contact arc. +func StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error) { + result := StarOccultationInstant{Time: at, TargetID: star.ID} + if at.IsZero() { + return result, fmt.Errorf("%w: time is required", ErrInvalidOccultationInput) + } + if err := star.Validate(); err != nil { + return result, err + } + + cache := newStarOccultationEventCache(star) + tt := occultationTimeToTT(at) + result.DeltaTSeconds = DeltaT(tt, true) + result.SublunarLongitude, result.SublunarLatitude = occultationSublunarPoint(tt, cache.frameAt) + result.Footprint = occultationInstantFootprint( + at, cache.frameAt, cache.riseSetContextAt, false, + ) + return result, nil +} + +// PlanetOccultationFootprintsAt 返回指定时刻有限行星盘面的精确外接触和内接触月掩可见足迹;它采用路径时间线分辨率,并对每条完整接触弧执行站心校正。 +// PlanetOccultationFootprintsAt returns the exact visible outer- and inner-contact lunar-occultation footprints of a finite planetary disk at one instant, using timeline resolution and station-centred correction of each complete contact arc. +func PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error) { + result := PlanetOccultationInstant{Time: at, Planet: planet, TargetID: planet.String()} + if at.IsZero() { + return result, fmt.Errorf("%w: time is required", ErrInvalidOccultationInput) + } + if err := planet.Validate(); err != nil { + return result, err + } + + config, _ := planetOccultationConfigFor(planet) + cache := newPlanetOccultationEventCache(config) + tt := occultationTimeToTT(at) + result.DeltaTSeconds = DeltaT(tt, true) + result.SublunarLongitude, result.SublunarLatitude = occultationSublunarPoint(tt, cache.outerFrameAt) + result.Partial = occultationInstantFootprint( + at, cache.outerFrameAt, cache.riseSetContextAt, false, + ) + result.Total = occultationInstantFootprint( + at, cache.totalFrameAt, cache.riseSetContextAt, true, + ) + return result, nil +} + +func occultationSublunarPoint(tt float64, frameAt occultationPathFrameFunc) (float64, float64) { + frame, ok := frameAt(tt) + if !ok { + return math.NaN(), math.NaN() + } + distance := math.Sqrt(frame.moon.x*frame.moon.x + frame.moon.y*frame.moon.y + frame.moon.z*frame.moon.z) + if distance == 0 { + return math.NaN(), math.NaN() + } + rightAscension := math.Atan2(frame.moon.y, frame.moon.x) * 180 / math.Pi + declination := math.Asin(math.Max(-1, math.Min(1, frame.moon.z/distance))) * 180 / math.Pi + longitude := rightAscension - ApparentSiderealTime(TD2UT(tt, false))*15 + for longitude > 180 { + longitude -= 360 + } + for longitude < -180 { + longitude += 360 + } + return longitude, declination +} + +func occultationInstantFootprint( + at time.Time, + frameAt occultationPathFrameFunc, + contextAt occultationRiseSetContextFunc, + total bool, +) *OccultationFootprint { + tt := occultationTimeToTT(at) + footprint, ok := planetOccultationFootprintAtWithResolution( + tt, frameAt, at.Location(), + planetOccultationTimelineBoundaryPoints, + planetOccultationTimelineHorizonPoints, + planetOccultationTimelineTargetSpacingKM, + ) + if !ok { + return nil + } + + // A one-element correction deliberately treats this instant as both ends of + // the sequence, so every contact-arc sample receives the exact station solve. + corrected := occultationStationCorrectFootprintEdges( + []PlanetOccultationFootprint{footprint}, frameAt, contextAt, total, at.Location(), + ) + if len(corrected) != 1 { + return nil + } + footprint = corrected[0] + normalizeOccultationInstantTime(&footprint, at) + return &footprint +} + +func normalizeOccultationInstantTime(footprint *OccultationFootprint, at time.Time) { + if footprint == nil { + return + } + footprint.Time = at + for _, polygons := range [][][]OccultationPathPoint{ + footprint.Polygons, + footprint.InteriorPolygons, + footprint.Boundaries, + } { + for polygonIndex := range polygons { + for pointIndex := range polygons[polygonIndex] { + polygons[polygonIndex][pointIndex].Time = at + } + } + } +} diff --git a/basic/occultation_instant_test.go b/basic/occultation_instant_test.go new file mode 100644 index 0000000..cebfa29 --- /dev/null +++ b/basic/occultation_instant_test.go @@ -0,0 +1,72 @@ +package basic + +import ( + "errors" + "testing" + "time" +) + +func TestPlanetOccultationFootprintsAtReturnsExactRequestedInstant(t *testing.T) { + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + events, err := FindBestPlanetOccultations( + start, start.Add(24*time.Hour), OccultationSaturn, OccultationSearchOptions{MaxEvents: 1}, + ) + if err != nil || len(events) != 1 { + t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err) + } + at := events[0].Greatest.Add(123 * time.Nanosecond) + instant, err := PlanetOccultationFootprintsAt(at, OccultationSaturn) + if err != nil { + t.Fatalf("PlanetOccultationFootprintsAt: %v", err) + } + if instant.Partial == nil || instant.Total == nil { + t.Fatalf("instant footprints partial=%v total=%v, want both", instant.Partial != nil, instant.Total != nil) + } + for name, footprint := range map[string]*OccultationFootprint{ + "partial": instant.Partial, + "total": instant.Total, + } { + if !footprint.Time.Equal(at) { + t.Fatalf("%s footprint time=%v, want %v", name, footprint.Time, at) + } + if len(footprint.Polygons) == 0 || len(footprint.Boundaries) == 0 { + t.Fatalf("%s footprint has no visible geometry", name) + } + for _, boundary := range footprint.Boundaries { + for _, point := range boundary { + if !point.Time.Equal(at) { + t.Fatalf("%s boundary time=%v, want %v", name, point.Time, at) + } + } + } + } +} + +func TestStarOccultationFootprintAtReturnsEmptyOutsideEvent(t *testing.T) { + star := StarCoordinate{ + ID: "Antares", RA: 247.35166667, Dec: -26.43194444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: CoordinateFrameJ2000, + } + instant, err := StarOccultationFootprintAt( + time.Date(2024, time.March, 1, 17, 0, 0, 0, time.UTC), star, + ) + if err != nil { + t.Fatalf("StarOccultationFootprintAt: %v", err) + } + if instant.Footprint != nil { + t.Fatal("outside-event stellar footprint is not nil") + } +} + +func TestOccultationFootprintAtValidatesInputs(t *testing.T) { + if _, err := PlanetOccultationFootprintsAt(time.Time{}, OccultationSaturn); !errors.Is(err, ErrInvalidOccultationInput) { + t.Fatalf("zero time error=%v, want ErrInvalidOccultationInput", err) + } + if _, err := PlanetOccultationFootprintsAt(time.Now(), OccultationPlanet("earth")); !errors.Is(err, ErrInvalidOccultationInput) { + t.Fatalf("invalid planet error=%v, want ErrInvalidOccultationInput", err) + } + if _, err := StarOccultationFootprintAt(time.Now(), StarCoordinate{}); !errors.Is(err, ErrInvalidOccultationInput) { + t.Fatalf("invalid star error=%v, want ErrInvalidOccultationInput", err) + } +} diff --git a/basic/occultation_isochrone.go b/basic/occultation_isochrone.go new file mode 100644 index 0000000..b1e1338 --- /dev/null +++ b/basic/occultation_isochrone.go @@ -0,0 +1,406 @@ +package basic + +import ( + "math" + "time" +) + +const ( + occultationGreatestTimeContourSeedLatitudeStepDegrees = 5.0 + occultationGreatestTimeContourSeedLongitudeStepDegrees = 5.0 + occultationGreatestTimeContourSeedLatitudeLimitDegrees = 85.0 + occultationGreatestTimeContourArcStepDegrees = 1.5 + occultationGreatestTimeContourMinArcStepDegrees = 0.01 + occultationGreatestTimeContourMaxArcSteps = 4000 + occultationGreatestTimeContourCorrectionIterations = 12 + occultationGreatestTimeContourGradientStepDegrees = 1e-4 + occultationGreatestTimeContourLatitudeLimitDegrees = 88.0 +) + +// occultationGreatestTimeArc 固定一个掩甚时刻后的等时线求根器。 +// 时刻固定后判据的时间导数只随经纬度变化,其零集就是该时刻的掩甚等值线: +// 一个约束、两个未知量,所以结果是曲线而不是区域,延拓成本正比于曲线长度。 +// useContactMetric 选择判据口径:点源恒星用月面中心角距(与库内 StarOccultationInfo.Greatest 同口径), +// 有限盘面行星用外接触度量(与库内 PlanetOccultationInfo.Greatest 同口径),两者相差数秒。 +type occultationGreatestTimeArc struct { + evaluation occultationRiseSetEvaluation + location *time.Location + useContactMetric bool +} + +func (arc occultationGreatestTimeArc) metric(state occultationRiseSetState) float64 { + if arc.useContactMetric { + return state.contactMetric + } + return state.separationSquared +} + +// sample 返回残差与中心状态;月面在地平下、未发生接触、非极小点或数值无效时 ok 为 false。 +func (arc occultationGreatestTimeArc) sample(longitude, latitude float64) (float64, occultationRiseSetState, bool) { + var state occultationRiseSetState + if latitude <= -90 || latitude >= 90 { + return 0, state, false + } + before := arc.evaluation.before.stateAt(longitude, latitude) + state = arc.evaluation.center.stateAt(longitude, latitude) + after := arc.evaluation.after.stateAt(longitude, latitude) + if !state.valid || state.moonAltitude <= 0 { + return 0, state, false + } + // 角距极小值处处存在,等时线必须再要求目标盘面真的与月面接触,否则会在无掩可见的区域画出曲线。 + if state.contactMetric > 1e-7 { + return 0, state, false + } + stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays + // 判据在此取极小值才是掩甚;二阶导非正说明该时刻不是本地的极大掩。 + center := arc.metric(state) + if (arc.metric(after)-2*center+arc.metric(before))/stepSquared <= 0 { + return 0, state, false + } + value := (arc.metric(after) - arc.metric(before)) / (2 * occultationRiseSetDerivativeStepDays) + if !finite(value) { + return 0, state, false + } + return value, state, true +} + +func (arc occultationGreatestTimeArc) residual(longitude, latitude float64) float64 { + value, _, ok := arc.sample(longitude, latitude) + if !ok { + return math.NaN() + } + return value +} + +func (arc occultationGreatestTimeArc) point(longitude, latitude float64, state occultationRiseSetState) OccultationPathPoint { + return OccultationPathPoint{ + Time: occultationTTToLocation(arc.evaluation.tt, arc.location), + Longitude: normalizeLongitude(longitude), + Latitude: latitude, + MoonAltitude: state.moonAltitude, + } +} + +// metricGradient 返回 g 对地面东向、北向角度的偏导;东向角度 = 经度差 × cos(纬度)。 +func (arc occultationGreatestTimeArc) metricGradient(longitude, latitude float64) (float64, float64, bool) { + step := occultationGreatestTimeContourGradientStepDegrees + value, _, ok := arc.sample(longitude, latitude) + if !ok { + return 0, 0, false + } + cosine := math.Cos(latitude * rad) + if cosine < 1e-6 { + return 0, 0, false + } + eastValue, _, eastOK := arc.sample(longitude+step, latitude) + westValue, _, westOK := arc.sample(longitude-step, latitude) + northValue, _, northOK := arc.sample(longitude, latitude+step) + southValue, _, southOK := arc.sample(longitude, latitude-step) + longitudeDerivative, ok := greatestTimeContourDifference(value, eastValue, westValue, step, eastOK, westOK) + if !ok { + return 0, 0, false + } + latitudeDerivative, ok := greatestTimeContourDifference(value, northValue, southValue, step, northOK, southOK) + if !ok { + return 0, 0, false + } + return longitudeDerivative / cosine, latitudeDerivative, true +} + +// correct 把预测点沿残差梯度投影回零集;失败说明该方向已离开等时线定义域。 +func (arc occultationGreatestTimeArc) correct(longitude, latitude float64) (float64, float64, occultationRiseSetState, bool) { + var state occultationRiseSetState + for iteration := 0; iteration < occultationGreatestTimeContourCorrectionIterations; iteration++ { + value, current, ok := arc.sample(longitude, latitude) + if !ok { + return 0, 0, state, false + } + state = current + if math.Abs(value) <= greatestTimeContourResidualTolerance { + return longitude, latitude, state, true + } + east, north, ok := arc.metricGradient(longitude, latitude) + if !ok { + return 0, 0, state, false + } + denominator := east*east + north*north + cosine := math.Cos(latitude * rad) + if denominator < 1e-18 || cosine < 1e-6 { + return 0, 0, state, false + } + // 完整牛顿步可能一步跨出可见域(掩带很窄,限界附近的种子尤其容易);逐步二分回退, + // 只要还有一步落在域内就继续投影。 + scale, advanced := 1.0, false + for attempt := 0; attempt < greatestTimeContourCorrectionBacktracking; attempt++ { + nextLongitude := longitude - scale*value*east/denominator/cosine + nextLatitude := latitude - scale*value*north/denominator + scale /= 2 + if nextLatitude <= -90 || nextLatitude >= 90 { + continue + } + if _, _, ok := arc.sample(nextLongitude, nextLatitude); !ok { + continue + } + longitude, latitude = nextLongitude, nextLatitude + advanced = true + break + } + if !advanced { + return 0, 0, state, false + } + } + value, current, ok := arc.sample(longitude, latitude) + if !ok || math.Abs(value) > 1e-6 { + return 0, 0, state, false + } + return longitude, latitude, current, true +} + +// traceOccultationGreatestTimeArc 从种子沿一个方向按弧长延拓,预测点落到定义域外时步长减半。 +func traceOccultationGreatestTimeArc( + evaluation occultationRiseSetEvaluation, + location *time.Location, + useContactMetric bool, + longitude, latitude, direction float64, +) []OccultationPathPoint { + arc := occultationGreatestTimeArc{evaluation: evaluation, location: location, useContactMetric: useContactMetric} + _, state, ok := arc.sample(longitude, latitude) + if !ok { + return nil + } + points := []OccultationPathPoint{arc.point(longitude, latitude, state)} + step := occultationGreatestTimeContourArcStepDegrees + previousEast, previousNorth := 0.0, 0.0 + for count := 0; count < occultationGreatestTimeContourMaxArcSteps; count++ { + east, north, ok := arc.metricGradient(longitude, latitude) + if !ok { + break + } + norm := math.Hypot(east, north) + cosine := math.Cos(latitude * rad) + if norm < 1e-12 || cosine < 1e-6 { + break + } + tangentEast, tangentNorth := -north/norm, east/norm + if previousEast != 0 || previousNorth != 0 { + if tangentEast*previousEast+tangentNorth*previousNorth < 0 { + tangentEast, tangentNorth = -tangentEast, -tangentNorth + } + } + nextLongitude, nextLatitude, nextState, ok := arc.correct( + longitude+direction*step*tangentEast/cosine, + latitude+direction*step*tangentNorth, + ) + if !ok { + step /= 2 + if step < occultationGreatestTimeContourMinArcStepDegrees { + break + } + continue + } + if math.Abs(nextLatitude) > occultationGreatestTimeContourLatitudeLimitDegrees { + break + } + next := arc.point(nextLongitude, nextLatitude, nextState) + distance := occultationPathDistanceKM(points[len(points)-1], next) + // 校正回到原点说明该方向已经走到支路端点,继续只会原地打转。 + if distance < 1e-9 || distance > 4*step*greatestTimeContourKMPerDegree { + break + } + points = append(points, next) + longitude, latitude = nextLongitude, nextLatitude + previousEast, previousNorth = tangentEast, tangentNorth + step = math.Min(occultationGreatestTimeContourArcStepDegrees, step*1.5) + } + return points +} + +// occultationGreatestTimeContourSeriesSeed 在一条序列上按时间插值取种子。 +// 掩带是单条窄带,宽度常小于粗扫步长,二维经纬度扫描会整条漏掉,所以优先用中心线: +// 中心线各点的时刻就是该点的本地掩甚,按时间插值得到的点已经贴着等时线。 +// 限界只在中心线的时间范围够不到该时刻时提供候选,且必须再经定义域校验(限界点的时刻是 +// 擦边时刻,不等于本地掩甚,直接当种子可能落在可见域外)。 +func occultationGreatestTimeContourSeriesSeed(points []OccultationPathPoint, levelTT float64) (float64, float64, bool) { + for index := 1; index < len(points); index++ { + before := occultationTimeToTT(points[index-1].Time) + after := occultationTimeToTT(points[index].Time) + if levelTT < before || levelTT > after || after <= before { + continue + } + fraction := (levelTT - before) / (after - before) + delta := math.Remainder(points[index].Longitude-points[index-1].Longitude, 360) + return points[index-1].Longitude + delta*fraction, + points[index-1].Latitude + (points[index].Latitude-points[index-1].Latitude)*fraction, true + } + return 0, 0, false +} + +// occultationGreatestTimeContourSeeds 按序列顺序(中心线优先)给出该时刻的全部候选种子。 +func occultationGreatestTimeContourSeeds(series [][]OccultationPathPoint, levelTT float64) [][2]float64 { + seeds := make([][2]float64, 0, len(series)) + for _, points := range series { + if longitude, latitude, ok := occultationGreatestTimeContourSeriesSeed(points, levelTT); ok { + seeds = append(seeds, [2]float64{longitude, latitude}) + } + } + return seeds +} + +// occultationGreatestTimeContourScanSeeds 是没有可用序列种子时的兜底粗扫。 +func occultationGreatestTimeContourScanSeeds(arc occultationGreatestTimeArc) []OccultationPathPoint { + seeds := make([]OccultationPathPoint, 0, 16) + latitudeStep := occultationGreatestTimeContourSeedLatitudeStepDegrees + longitudeStep := occultationGreatestTimeContourSeedLongitudeStepDegrees + limit := occultationGreatestTimeContourSeedLatitudeLimitDegrees + for latitude := -limit; latitude <= limit; latitude += latitudeStep { + previousLongitude := -180.0 + previousValue := arc.residual(previousLongitude, latitude) + for longitude := previousLongitude + longitudeStep; longitude <= 180; longitude += longitudeStep { + value := arc.residual(longitude, latitude) + if finite(previousValue) && finite(value) && previousValue*value <= 0 { + root, ok := greatestTimeContourBisect(arc.residual, previousLongitude, longitude, latitude, previousValue) + if !ok { + continue + } + if _, state, sampled := arc.sample(root, latitude); sampled { + seeds = append(seeds, arc.point(root, latitude, state)) + } + } + previousLongitude, previousValue = longitude, value + } + } + return seeds +} + +func occultationGreatestTimeContourCovered(segments [][]OccultationPathPoint, point OccultationPathPoint) bool { + for _, segment := range segments { + for index := 1; index < len(segment); index++ { + if greatestTimeContourPointSegmentKM( + point.Longitude, point.Latitude, + segment[index-1].Longitude, segment[index-1].Latitude, + segment[index].Longitude, segment[index].Latitude, + ) <= greatestTimeContourCoverToleranceKM { + return true + } + } + } + return false +} + +// occultationGreatestTimeContourSegmentCovered 判断整条支路是否已落在已绘曲线上(同一曲线被先后延拓两次时后一条可能更长)。 +func occultationGreatestTimeContourSegmentCovered(segments [][]OccultationPathPoint, segment []OccultationPathPoint) bool { + for _, point := range segment { + if !occultationGreatestTimeContourCovered(segments, point) { + return false + } + } + return true +} + +// occultationGreatestTimeContourPruneCovered 丢弃已被新支路整条覆盖的旧支路。 +func occultationGreatestTimeContourPruneCovered(segments [][]OccultationPathPoint, added []OccultationPathPoint) [][]OccultationPathPoint { + kept := segments[:0] + for _, segment := range segments { + if occultationGreatestTimeContourSegmentCovered([][]OccultationPathPoint{added}, segment) { + continue + } + kept = append(kept, segment) + } + return kept +} + +// occultationGreatestTimeContourSegments 汇总一个时刻取值上的全部等时线支路。 +func occultationGreatestTimeContourSegments( + evaluation occultationRiseSetEvaluation, + location *time.Location, + useContactMetric bool, + series [][]OccultationPathPoint, +) [][]OccultationPathPoint { + arc := occultationGreatestTimeArc{evaluation: evaluation, location: location, useContactMetric: useContactMetric} + var seeds []OccultationPathPoint + for _, candidate := range occultationGreatestTimeContourSeeds(series, evaluation.tt) { + // 候选点可能落在可见域外(限界种子或该时刻已无接触),先校验再投影。 + if _, _, ok := arc.sample(candidate[0], candidate[1]); !ok { + continue + } + correctedLongitude, correctedLatitude, state, corrected := arc.correct(candidate[0], candidate[1]) + if corrected { + seeds = append(seeds, arc.point(correctedLongitude, correctedLatitude, state)) + } + } + // 每个时刻取值只保留能延拓出支路的种子;同一条曲线上的重复种子会被覆盖判据丢弃。 + if len(seeds) == 0 { + seeds = occultationGreatestTimeContourScanSeeds(arc) + } + segments := make([][]OccultationPathPoint, 0, 2) + for _, seed := range seeds { + if occultationGreatestTimeContourCovered(segments, seed) { + continue + } + forward := traceOccultationGreatestTimeArc(evaluation, location, useContactMetric, seed.Longitude, seed.Latitude, 1) + backward := traceOccultationGreatestTimeArc(evaluation, location, useContactMetric, seed.Longitude, seed.Latitude, -1) + segment := make([]OccultationPathPoint, 0, len(forward)+len(backward)) + for index := len(backward) - 1; index >= 1; index-- { + segment = append(segment, backward[index]) + } + segment = append(segment, forward...) + if len(segment) < 2 { + continue + } + // 先按整条支路去重:种子检查只能拦住"较短者先画"的情况,反序时需要在这里收口。 + if occultationGreatestTimeContourSegmentCovered(segments, segment) { + continue + } + segments = occultationGreatestTimeContourPruneCovered(segments, segment) + segments = append(segments, segment) + } + return segments +} + +// occultationGreatestTimeLevels 取请求的掩甚时刻取值:显式取值优先(按时间截断到上限), +// 否则按步长对齐到 UTC 整刻度并覆盖可见窗口。掩星全球可见窗口通常只有数小时,间隔应比日食更密 +// (15–30 分钟量级),否则整条掩带上只有寥寥几条线。 +func occultationGreatestTimeLevels(options OccultationPathOptions, startTT, endTT float64) []greatestTimeContourLevel { + if len(options.GreatestTimeValues) > 0 { + values := options.GreatestTimeValues + if len(values) > greatestTimeContourMaxLevels { + values = values[:greatestTimeContourMaxLevels] + } + levels := make([]greatestTimeContourLevel, 0, len(values)) + for _, value := range values { + levels = append(levels, greatestTimeContourLevel{ + tt: value, + // 显式取值只有 TT 儒略日;抹掉亚毫秒噪声,避免整分被格式化成前一分钟。 + at: greatestTimeContourTTToUTC(value).Round(time.Millisecond), + }) + } + return levels + } + return greatestTimeContourAlignedLevels(startTT, endTT, options.GreatestTimeStep, greatestTimeContourMaxLevels) +} + +// occultationGreatestTimeContours 计算请求时刻取值的地方掩甚时刻等值线。 +func occultationGreatestTimeContours( + levels []greatestTimeContourLevel, + startTT, endTT float64, + cache *occultationRiseSetEvaluationCache, + series [][]OccultationPathPoint, + useContactMetric bool, + location *time.Location, +) []OccultationGreatestTimeContour { + if len(levels) == 0 || cache == nil || startTT == 0 || endTT == 0 || endTT <= startTT { + return nil + } + contours := make([]OccultationGreatestTimeContour, 0, len(levels)) + for _, level := range levels { + if !finite(level.tt) || level.tt < startTT || level.tt > endTT { + continue + } + segments := occultationGreatestTimeContourSegments(cache.evaluation(level.tt), location, useContactMetric, series) + if len(segments) == 0 { + continue + } + contours = append(contours, OccultationGreatestTimeContour{JDE: level.tt, Time: level.at, Segments: segments}) + } + return contours +} diff --git a/basic/occultation_isochrone_test.go b/basic/occultation_isochrone_test.go new file mode 100644 index 0000000..4754f2d --- /dev/null +++ b/basic/occultation_isochrone_test.go @@ -0,0 +1,251 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +func occultationIsochroneTestStar() StarCoordinate { + return StarCoordinate{ + ID: "Regulus", + RA: 152.09292, + Dec: 11.96719, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: CoordinateFrameJ2000, + } +} + +// 等时线必须与站心掩甚定义自洽:支路上任意点由站心事件搜索独立求出的掩甚时刻等于该支路电平。 +func TestOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) { + star := occultationIsochroneTestStar() + start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC) + end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC) + options := OccultationPathOptions{ + Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute, + } + paths, err := FindStarOccultationPaths(start, end, star, options) + if err != nil || len(paths) == 0 { + t.Fatalf("no path with isochrones: %v", err) + } + contours := paths[0].GreatestTimeContours + if len(contours) < 4 { + t.Fatalf("expected a full isochrone fan, got %d contours", len(contours)) + } + for _, contour := range contours { + if contour.Time.Second() != 0 || contour.Time.Nanosecond() != 0 || contour.Time.Minute()%15 != 0 { + t.Fatalf("contour time %s is not aligned to the requested step", + contour.Time.Format("15:04:05.000")) + } + } + + verified := 0 + worst := 0.0 + for _, contour := range contours { + branchPoints := 0 + for _, segment := range contour.Segments { + if len(segment) < 2 { + t.Fatalf("contour %.6f has a degenerate branch", contour.JDE) + } + branchPoints += len(segment) + } + // 弧长步长上限决定了单条等时线的点数上界;点数爆表意味着延拓没有真正前进。 + if branchPoints > 4000 { + t.Fatalf("contour %.6f has %d points, continuation is not advancing", contour.JDE, branchPoints) + } + level := occultationTTToLocation(contour.JDE, time.UTC) + for _, segment := range contour.Segments { + for index := 0; index < len(segment) && verified < 80; index += 1 + len(segment)/8 { + point := segment[index] + infos, searchErr := FindStarOccultations( + level.Add(-2*time.Hour), level.Add(2*time.Hour), star, + point.Longitude, point.Latitude, 0, OccultationSearchOptions{}, + ) + if searchErr != nil || len(infos) == 0 { + continue + } + delta := math.Abs(infos[0].Greatest.Sub(point.Time).Seconds()) + if delta > worst { + worst = delta + } + verified++ + } + } + } + if verified < 10 { + t.Fatalf("verified only %d points", verified) + } + t.Logf("%d contours, %d points verified, worst %.2f s", len(contours), verified, worst) + if worst > 1 { + t.Fatalf("isochrone disagrees with local greatest occultation by %.2f s", worst) + } +} + +func TestOccultationGreatestTimeContoursDisabledByDefault(t *testing.T) { + star := occultationIsochroneTestStar() + start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC) + end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC) + paths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{ + Step: 2 * time.Minute, DisableFootprints: true, + }) + if err != nil || len(paths) == 0 { + t.Fatalf("no occultation path: %v", err) + } + if len(paths[0].GreatestTimeContours) != 0 { + t.Fatalf("contours computed without a request: %d", len(paths[0].GreatestTimeContours)) + } +} + +// 行星是有限盘面,等时线按外接触锥门控,必须与站心行星掩甚一致。 +func TestPlanetOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) { + start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC) + options := OccultationPathOptions{ + Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute, + } + paths, err := FindPlanetOccultationPaths(start, end, OccultationSaturn, options) + if err != nil || len(paths) == 0 { + t.Fatalf("no planetary path with isochrones: %v", err) + } + contours := paths[0].GreatestTimeContours + if len(contours) == 0 { + t.Fatal("no greatest-time contours") + } + verified := 0 + worst := 0.0 + for _, contour := range contours { + level := occultationTTToLocation(contour.JDE, time.UTC) + for _, segment := range contour.Segments { + if len(segment) < 2 { + t.Fatalf("contour %.6f has a degenerate branch", contour.JDE) + } + for index := 0; index < len(segment) && verified < 60; index += 1 + len(segment)/8 { + point := segment[index] + infos, searchErr := FindPlanetOccultations( + level.Add(-3*time.Hour), level.Add(3*time.Hour), OccultationSaturn, + point.Longitude, point.Latitude, 0, OccultationSearchOptions{}, + ) + if searchErr != nil || len(infos) == 0 { + continue + } + delta := math.Abs(infos[0].Greatest.Sub(point.Time).Seconds()) + if delta > worst { + worst = delta + } + verified++ + } + } + } + if verified < 10 { + t.Fatalf("verified only %d points", verified) + } + t.Logf("%d contours, %d points verified, worst %.2f s", len(contours), verified, worst) + if worst > 1 { + t.Fatalf("isochrone disagrees with local greatest occultation by %.2f s", worst) + } +} + +// 同一时刻取值的等时线必须是单条连通曲线:曾因"覆盖判据用点到顶点距离"而被重复延拓 +// (行星掩星每个取值 3 条重叠支路)。 +func TestOccultationGreatestTimeContoursHaveSingleBranchPerLevel(t *testing.T) { + star := occultationIsochroneTestStar() + starPaths, err := FindStarOccultationPaths( + time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC), star, + OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute}, + ) + if err != nil || len(starPaths) == 0 { + t.Fatalf("star: %v", err) + } + planetPaths, err := FindPlanetOccultationPaths( + time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC), OccultationSaturn, + OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute}, + ) + if err != nil || len(planetPaths) == 0 { + t.Fatalf("planet: %v", err) + } + for _, tc := range []struct { + name string + contours []OccultationGreatestTimeContour + }{ + {"star", starPaths[0].GreatestTimeContours}, + {"planet", planetPaths[0].GreatestTimeContours}, + } { + if len(tc.contours) == 0 { + t.Fatalf("%s: no contours", tc.name) + } + for _, contour := range tc.contours { + if len(contour.Segments) != 1 { + t.Fatalf("%s contour %s has %d branches, want a single connected curve", + tc.name, contour.Time.Format("15:04"), len(contour.Segments)) + } + } + } +} + +// 显式时刻取值:按 TT 儒略日给出,回显时刻取整到毫秒,窗口外取值被丢弃,且同样受上限保护。 +func TestOccultationGreatestTimeContoursAcceptExplicitValues(t *testing.T) { + star := occultationIsochroneTestStar() + start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC) + end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC) + base := OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute} + stepPaths, err := FindStarOccultationPaths(start, end, star, base) + if err != nil || len(stepPaths) == 0 || len(stepPaths[0].GreatestTimeContours) == 0 { + t.Fatalf("step request failed: %v", err) + } + stepContours := stepPaths[0].GreatestTimeContours + // 用步长路径得到的取值当显式取值,外加 8 个窗口外取值,检验回显与过滤。 + values := make([]float64, 0, len(stepContours)+8) + for _, contour := range stepContours { + values = append(values, contour.JDE) + } + for index := 0; index < 8; index++ { + values = append(values, occultationTimeToTT(start.Add(-time.Duration(index+1)*time.Hour))) + } + explicitPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{ + Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeValues: values, + }) + if err != nil || len(explicitPaths) == 0 { + t.Fatalf("explicit request failed: %v", err) + } + contours := explicitPaths[0].GreatestTimeContours + if len(contours) != len(stepContours) { + t.Fatalf("explicit values produced %d contours, step request produced %d", len(contours), len(stepContours)) + } + for index, contour := range contours { + if contour.JDE != stepContours[index].JDE { + t.Fatalf("contour %d JDE %.9f does not match the requested value %.9f", index, contour.JDE, stepContours[index].JDE) + } + if contour.Time.Nanosecond() != 0 { + t.Fatalf("contour time %s is not rounded to the millisecond", contour.Time.Format("15:04:05.000000000")) + } + } + + // 上限:72 个互不相同、都落在窗口内的取值,返回条数不得超过共享上限。 + dense := make([]float64, 0, greatestTimeContourMaxLevels+8) + for index := 0; index < greatestTimeContourMaxLevels+8; index++ { + dense = append(dense, stepContours[0].JDE+float64(index)*1e-6) + } + cappedPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{ + Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeValues: dense, + }) + if err != nil || len(cappedPaths) == 0 { + t.Fatalf("capped request failed: %v", err) + } + if count := len(cappedPaths[0].GreatestTimeContours); count > greatestTimeContourMaxLevels { + t.Fatalf("level cap not applied: %d contours", count) + } +} + +func BenchmarkOccultationGreatestTimeContours(b *testing.B) { + star := occultationIsochroneTestStar() + start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC) + end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC) + options := OccultationPathOptions{ + Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute, + } + for index := 0; index < b.N; index++ { + FindStarOccultationPaths(start, end, star, options) + } +} diff --git a/basic/occultation_limit_separation_test.go b/basic/occultation_limit_separation_test.go new file mode 100644 index 0000000..6eae2a9 --- /dev/null +++ b/basic/occultation_limit_separation_test.go @@ -0,0 +1,329 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +// 掩带两条横向口径的契约:WidthKM 是中心线采样处的地面横向宽度,南北限是外接触锥的擦边切点轨迹, +// 限线间距由 LimitSeparationKM 单独给出,两者不相等也不可互相换算。 + +const ( + occultationLimitTangencyToleranceArcsec = 5e-3 + occultationLimitEndpointToleranceArcsec = 0.5 + occultationLimitIndependentTolerance = 1e-4 + occultationLimitExactTolerance = 1e-9 + occultationLimitAnchorToleranceKM = 0.5 + occultationLimitRatioTolerance = 0.002 +) + +type occultationLimitFixture struct { + name string + planet OccultationPlanet + start time.Time + widthKM float64 + limitSeparationKM float64 + totalWidthKM float64 + limitRatio float64 + centerLineSamples int +} + +func occultationLimitFixtures() []occultationLimitFixture { + return []occultationLimitFixture{ + { + name: "Jupiter 1962-10-10", planet: OccultationJupiter, + start: time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC), + widthKM: 5319.263900, + limitSeparationKM: 3593.197135, + totalWidthKM: 3385.147026, + limitRatio: 0.675506, + centerLineSamples: 12, + }, + { + name: "Mars 2025-07-29", planet: OccultationMars, + start: time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)), + widthKM: 2190.685431, + limitSeparationKM: 1819.273229, + totalWidthKM: 1788.503498, + limitRatio: 0.830458, + }, + } +} + +func occultationLimitOptions() OccultationPathOptions { + return OccultationPathOptions{ + Step: 10 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, DisableRiseSet: true, + } +} + +func occultationLimitPath(t *testing.T, fixture occultationLimitFixture) PlanetOccultationPath { + t.Helper() + paths, err := FindPlanetOccultationPaths( + fixture.start, fixture.start.Add(24*time.Hour), fixture.planet, occultationLimitOptions(), + ) + if err != nil || len(paths) != 1 { + t.Fatalf("%s: paths=%d err=%v, want one", fixture.name, len(paths), err) + } + return paths[0] +} + +func occultationLimitExactFrame(t *testing.T, planet OccultationPlanet, centerTT float64) occultationPathFrameFunc { + t.Helper() + config, ok := planetOccultationConfigFor(planet) + if !ok { + t.Fatalf("%v config is unavailable", planet) + } + cache := newPlanetOccultationEventCache(config) + cache.preparePathEphemeris(centerTT, OccultationPathAlgorithmExact) + return cache.outerFrameAt +} + +func occultationLimitExternalContactGap(t *testing.T, planet OccultationPlanet, point OccultationPathPoint) (float64, bool) { + t.Helper() + config, ok := planetOccultationConfigFor(planet) + if !ok { + t.Fatalf("%v config is unavailable", planet) + } + observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude} + state := planetOccultationStateAt(centerTimeTT(point.Time), config, &observer, -1) + if !state.valid { + return 0, false + } + return state.externalContactMetric, true +} + +// occultationLimitIndependentGroundWidth 按定义独立重算地面横向宽度:自建接触弧网格并自算地面横向方向。 +func occultationLimitIndependentGroundWidth(tt float64, frameAt occultationPathFrameFunc) (float64, bool) { + frame, ok := frameAt(tt) + if !ok { + return 0, false + } + before, beforeOK := frameAt(tt - occultationPathVelocityStepDays) + after, afterOK := frameAt(tt + occultationPathVelocityStepDays) + if !beforeOK || !afterOK { + return 0, false + } + center, centerOK := occultationPathTrackReference(frame) + beforeCenter, beforeCenterOK := occultationPathTrackReference(before) + afterCenter, afterCenterOK := occultationPathTrackReference(after) + if !centerOK || !beforeCenterOK || !afterCenterOK { + return 0, false + } + rotation := occultationPathEarthRotationAt(tt) + centerFixed := occultationPathEarthFixedVectorWithRotation(center, rotation) + beforeFixed := occultationPathEarthFixedVector(tt-occultationPathVelocityStepDays, beforeCenter) + afterFixed := occultationPathEarthFixedVector(tt+occultationPathVelocityStepDays, afterCenter) + polar := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio + normal := occultationPathUnit(occultationPathVector{x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polar}) + track := occultationPathSub(afterFixed, beforeFixed) + track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal))) + if occultationPathNorm(track) <= 1e-12 { + return 0, false + } + cross := occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track))) + offset := func(vector occultationPathVector) float64 { + fixed := occultationPathEarthFixedVectorWithRotation(vector, rotation) + return occultationPathDot(occultationPathSub(fixed, centerFixed), cross) + } + minimum, maximum := math.Inf(1), math.Inf(-1) + consider := func(vector occultationPathVector) { + value := offset(vector) + minimum = math.Min(minimum, value) + maximum = math.Max(maximum, value) + } + const probeSamples = 4096 + for _, interval := range occultationPathBoundaryThetaIntervals(frame) { + for index := 0; index <= probeSamples; index++ { + theta := interval.left + (interval.right-interval.left)*float64(index)/probeSamples + if vector, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK { + consider(vector) + } + } + } + for index := 0; index < occultationPathBoundaryScanPoints; index++ { + if vector, _, pointOK := occultationPathBoundaryVector(frame, 2*math.Pi*float64(index)/float64(occultationPathBoundaryScanPoints)); pointOK { + consider(vector) + } + } + if !finite(minimum) || !finite(maximum) { + return 0, false + } + return maximum - minimum, true +} + +func TestPlanetOccultationLimitTracksAreGrazingTangencies(t *testing.T) { + for _, fixture := range occultationLimitFixtures() { + path := occultationLimitPath(t, fixture) + if len(path.NorthernLimit) != len(path.SouthernLimit) || len(path.NorthernLimit) < 32 { + t.Fatalf("%s: limit sample counts=%d/%d, want equal and at least 32", + fixture.name, len(path.NorthernLimit), len(path.SouthernLimit)) + } + checked := 0 + for _, track := range [][]OccultationPathPoint{path.NorthernLimit, path.SouthernLimit} { + for index, point := range track { + gap, ok := occultationLimitExternalContactGap(t, fixture.planet, point) + if !ok { + t.Fatalf("%s: limit point %d at %v has no valid contact state", fixture.name, index, point.Time) + } + // 首末采样是南北限的公共端点(构造上重合),残差略大,单独放宽。 + if index == 0 || index == len(track)-1 { + if math.Abs(gap) > occultationLimitEndpointToleranceArcsec { + t.Fatalf("%s: closure endpoint %d external contact gap=%.6f arcsec, want at most %.6f", + fixture.name, index, gap, occultationLimitEndpointToleranceArcsec) + } + continue + } + if index >= 3 && index < len(track)-3 { + checked++ + } + if math.Abs(gap) > occultationLimitTangencyToleranceArcsec { + t.Fatalf("%s: limit point %d at %v external contact gap=%.6f arcsec, want at most %.6f", + fixture.name, index, point.Time, gap, occultationLimitTangencyToleranceArcsec) + } + } + } + if checked < 100 { + t.Fatalf("%s: checked %d interior limit points, want at least 100", fixture.name, checked) + } + if separation := occultationPathDistanceKM(path.NorthernLimit[0], path.SouthernLimit[0]); separation != 0 { + t.Fatalf("%s: start closure separation=%.6f km, want the shared endpoint", fixture.name, separation) + } + last := len(path.NorthernLimit) - 1 + if separation := occultationPathDistanceKM(path.NorthernLimit[last], path.SouthernLimit[last]); separation != 0 { + t.Fatalf("%s: end closure separation=%.6f km, want the shared endpoint", fixture.name, separation) + } + } +} + +func TestPlanetOccultationWidthKMIsGroundCrossTrackWidth(t *testing.T) { + for _, fixture := range occultationLimitFixtures() { + path := occultationLimitPath(t, fixture) + if len(path.CenterLine) != fixture.centerLineSamples { + t.Fatalf("%s: center line samples=%d, want %d", fixture.name, len(path.CenterLine), fixture.centerLineSamples) + } + frameAt := occultationLimitExactFrame(t, fixture.planet, centerTimeTT(path.Greatest.Time)) + for _, point := range append([]OccultationPathPoint{path.Greatest}, path.CenterLine...) { + tt := centerTimeTT(point.Time) + _, _, exact, ok := occultationPathLimitsAndWidthForFrame(tt, frameAt) + if !ok || exact <= 0 { + t.Fatalf("%s: width at %v is unavailable", fixture.name, point.Time) + } + if difference := math.Abs(point.WidthKM - exact); difference > occultationLimitExactTolerance*exact { + t.Fatalf("%s: width at %v field=%.9f recomputed=%.9f, want within %.3e relative", + fixture.name, point.Time, point.WidthKM, exact, occultationLimitExactTolerance) + } + independent, ok := occultationLimitIndependentGroundWidth(tt, frameAt) + if !ok || independent <= 0 { + t.Fatalf("%s: independent ground width at %v is unavailable", fixture.name, point.Time) + } + if difference := math.Abs(point.WidthKM - independent); difference > occultationLimitIndependentTolerance*independent { + t.Fatalf("%s: width at %v field=%.9f independent ground cross-track width=%.9f, want within %.3e relative", + fixture.name, point.Time, point.WidthKM, independent, occultationLimitIndependentTolerance) + } + } + for _, point := range path.CenterLine { + if point.LimitSeparationKM != 0 { + t.Fatalf("%s: center-line point at %v carries limit separation %.6f, want zero outside limit tracks", + fixture.name, point.Time, point.LimitSeparationKM) + } + } + } +} + +func TestPlanetOccultationLimitSeparationMatchesExportedLimits(t *testing.T) { + for _, fixture := range occultationLimitFixtures() { + path := occultationLimitPath(t, fixture) + nearestIndex, nearestDelta := 0, math.Inf(1) + for index := range path.NorthernLimit { + if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) { + t.Fatalf("%s: limit sample %d times differ between tracks", fixture.name, index) + } + separation := occultationPathDistanceKM(path.NorthernLimit[index], path.SouthernLimit[index]) + if path.NorthernLimit[index].LimitSeparationKM != separation || + path.SouthernLimit[index].LimitSeparationKM != separation { + t.Fatalf("%s: limit sample %d separations=%.9f/%.9f, want the exported pair distance %.9f", + fixture.name, index, path.NorthernLimit[index].LimitSeparationKM, + path.SouthernLimit[index].LimitSeparationKM, separation) + } + if delta := math.Abs(centerTimeTT(path.NorthernLimit[index].Time) - centerTimeTT(path.Greatest.Time)); delta < nearestDelta { + nearestIndex, nearestDelta = index, delta + } + } + nearest := occultationPathDistanceKM(path.NorthernLimit[nearestIndex], path.SouthernLimit[nearestIndex]) + if path.GreatestLimitSeparationKM != nearest { + t.Fatalf("%s: greatest limit separation=%.9f, want the nearest limit sample pair %.9f at %v", + fixture.name, path.GreatestLimitSeparationKM, nearest, path.NorthernLimit[nearestIndex].Time) + } + for index := range path.NorthernTotalLimit { + separation := occultationPathDistanceKM(path.NorthernTotalLimit[index], path.SouthernTotalLimit[index]) + if path.NorthernTotalLimit[index].LimitSeparationKM != separation { + t.Fatalf("%s: total limit sample %d separation=%.9f, want %.9f", + fixture.name, index, path.NorthernTotalLimit[index].LimitSeparationKM, separation) + } + } + for _, anchor := range []struct { + name string + got float64 + want float64 + }{ + {name: "greatest width", got: path.Greatest.WidthKM, want: fixture.widthKM}, + {name: "greatest limit separation", got: path.GreatestLimitSeparationKM, want: fixture.limitSeparationKM}, + {name: "greatest total width", got: path.GreatestTotalWidthKM, want: fixture.totalWidthKM}, + } { + if difference := math.Abs(anchor.got - anchor.want); difference > occultationLimitAnchorToleranceKM { + t.Fatalf("%s: %s=%.6f km, want %.6f km within %.3f km", + fixture.name, anchor.name, anchor.got, anchor.want, occultationLimitAnchorToleranceKM) + } + } + ratio := path.GreatestLimitSeparationKM / path.Greatest.WidthKM + if math.Abs(ratio-fixture.limitRatio) > occultationLimitRatioTolerance { + t.Fatalf("%s: limit separation/width=%.6f, want %.6f within %.4f", + fixture.name, ratio, fixture.limitRatio, occultationLimitRatioTolerance) + } + if path.GreatestTotalWidthKM >= path.Greatest.WidthKM { + t.Fatalf("%s: total width=%.6f must stay below the outer width=%.6f", + fixture.name, path.GreatestTotalWidthKM, path.Greatest.WidthKM) + } + } +} + +func TestStarOccultationLimitSeparationMatchesExportedLimits(t *testing.T) { + start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC) + paths, err := FindStarOccultationPaths( + start, start.Add(24*time.Hour), hr4799OccultationCoordinateForTest(), + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, DisableRiseSet: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + nearestIndex, nearestDelta := 0, math.Inf(1) + for index := range path.NorthernLimit { + separation := occultationPathDistanceKM(path.NorthernLimit[index], path.SouthernLimit[index]) + if path.NorthernLimit[index].LimitSeparationKM != separation { + t.Fatalf("limit sample %d separation=%.9f, want %.9f", + index, path.NorthernLimit[index].LimitSeparationKM, separation) + } + if delta := math.Abs(centerTimeTT(path.NorthernLimit[index].Time) - centerTimeTT(path.Greatest.Time)); delta < nearestDelta { + nearestIndex, nearestDelta = index, delta + } + } + nearest := occultationPathDistanceKM(path.NorthernLimit[nearestIndex], path.SouthernLimit[nearestIndex]) + if path.GreatestLimitSeparationKM != nearest { + t.Fatalf("greatest limit separation=%.9f, want %.9f", path.GreatestLimitSeparationKM, nearest) + } + if math.Abs(path.Greatest.WidthKM-3582.363599) > occultationLimitAnchorToleranceKM || + math.Abs(path.GreatestLimitSeparationKM-3666.576690) > occultationLimitAnchorToleranceKM { + t.Fatalf("greatest width=%.6f limit separation=%.6f, want 3582.363599 and 3666.576690 within %.3f km", + path.Greatest.WidthKM, path.GreatestLimitSeparationKM, occultationLimitAnchorToleranceKM) + } + ratio := path.GreatestLimitSeparationKM / path.Greatest.WidthKM + if math.Abs(ratio-1.023508) > occultationLimitRatioTolerance { + t.Fatalf("greatest limit separation/width=%.6f, want 1.023508 within %.4f", ratio, occultationLimitRatioTolerance) + } +} diff --git a/basic/occultation_optimization_test.go b/basic/occultation_optimization_test.go new file mode 100644 index 0000000..00b334f --- /dev/null +++ b/basic/occultation_optimization_test.go @@ -0,0 +1,87 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +func TestOccultationStationCacheRecyclingPreservesContactModel(t *testing.T) { + context := newOccultationRiseSetContext(2451545, 10, 5, 384000, 10.5, 5.25, 1e9, 60000) + uncached := context + uncached.states = nil + for pass := 0; pass < 2; pass++ { + for index := 0; index < 4*occultationRiseSetStateCacheMaximumEntries; index++ { + lon, lat := float64(index*7-110), float64(index-40) + for _, total := range []bool{false, true} { + actual, reference := context, uncached + if total { + actual, reference = actual.withInternalContact(), reference.withInternalContact() + } + if got, want := actual.stateAt(lon, lat), reference.stateAt(lon, lat); got != want { + t.Fatalf("cached station differs: got=%+v want=%+v", got, want) + } + } + if len(context.states) > occultationRiseSetStateCacheMaximumEntries { + t.Fatal("station cache exceeded bound") + } + } + } +} + +func TestOccultationEnvelopePredictionRequiresExactCorrection(t *testing.T) { + start := time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC) + events, err := FindBestPlanetOccultations(start, start.Add(48*time.Hour), OccultationSaturn, OccultationSearchOptions{}) + if err != nil || len(events) != 1 { + t.Fatalf("events=%d err=%v", len(events), err) + } + tt := occultationTimeToTT(events[0].Greatest) + config, _ := planetOccultationConfigFor(OccultationSaturn) + event := newPlanetOccultationEventCache(config) + event.prepareLocalEphemeris(tt) + north, _, width, ok := occultationPathLimitsAndWidthForFrame(tt, event.outerFrameAt) + if !ok { + t.Fatal("missing limit seed") + } + seed := occultationPathPointFromVector(tt, north, width, time.UTC) + sample, ok := occultationStationCorrectEnvelopePoint(tt, seed, event.riseSetContextAt, false, time.UTC) + if !ok { + t.Fatal("missing exact envelope seed") + } + model := occultationStationEnvelopeModel{kind: occultationStationContactEnvelope} + exact := newOccultationRiseSetEvaluationCache(event.riseSetContextAt) + state, ok := occultationStationEnvelopeArcStateAt(sample.point, tt, exact, model) + if !ok { + t.Fatal("missing envelope tangent") + } + predictor := state.coordinates + for index := range predictor { + predictor[index] += 0.2 * state.tangent[index] + } + want, _, ok := occultationStationCorrectEnvelopeArc(predictor, state.tangent, tt, exact, model, width, time.UTC) + if !ok { + t.Fatal("exact correction failed") + } + for _, invalid := range []bool{false, true} { + candidateCalls := 0 + cache := newOccultationRiseSetEvaluationCacheWithCandidate(event.riseSetContextAt, func(at float64) occultationRiseSetContext { + candidateCalls++ + if invalid { + return occultationRiseSetContext{} + } + return event.candidateRiseSetContextAt(at) + }) + got, _, ok := occultationStationCorrectEnvelopeArc(predictor, state.tangent, tt, cache, model, width, time.UTC) + if !ok || candidateCalls == 0 { + t.Fatalf("candidate correction ok=%v calls=%d invalid=%v", ok, candidateCalls, invalid) + } + if distance := occultationPathDistanceKM(want.point, got.point); distance > 0.005 { + t.Fatalf("candidate displaced exact root by %v km", distance) + } + evaluation := exact.evaluation(centerTimeTT(got.point.Time)) + residual, ok := model.residual(evaluation, got.point.Longitude, got.point.Latitude) + if !ok || math.Abs(residual[0]) > model.valueTolerance() || math.Abs(residual[1]) > model.derivativeTolerance() { + t.Fatalf("accepted inexact root %v", residual) + } + } +} diff --git a/basic/occultation_path.go b/basic/occultation_path.go index bfedd5a..68d1992 100644 --- a/basic/occultation_path.go +++ b/basic/occultation_path.go @@ -3,21 +3,31 @@ package basic import ( "math" "sort" + "sync" "time" ) const ( - occultationPathDefaultStepDays = 1.0 / 1440.0 - occultationPathMinStepDays = 1.0 / 86400.0 - occultationPathMaxSampleCount = 30000 - occultationPathMaxAdaptiveDepth = 20 - occultationPathBoundarySpacingKM = 500.0 - occultationPathVelocityStepDays = 1.0 / 1440.0 - occultationPathBoundaryScanPoints = 720 - occultationPathRootToleranceDays = occultationEventSelectionToleranceDays - occultationPathRangeStepDays = 5.0 / 1440.0 - occultationPathSearchSpanDays = 2.0 - occultationPathWidthToleranceKM = 0.005 + // occultationPathThetaIntervalCacheMaximumEntries 限制每个 frame 记忆的中心角数量;一次求解只会用到个位数个。 + // occultationPathThetaIntervalCacheMaximumEntries bounds the memory of center angles per frame; a solve uses only a handful. + occultationPathThetaIntervalCacheMaximumEntries = 8 + occultationPathDefaultStepDays = 1.0 / 1440.0 + occultationPathMinStepDays = 1.0 / 86400.0 + occultationPathMaxSampleCount = 30000 + occultationPathMaxAdaptiveDepth = 20 + occultationPathBoundarySpacingKM = 500.0 + occultationPathContourSpacingKM = 40.0 + occultationPathBoundaryMergeKM = 1.0 + occultationPathVelocityStepDays = 1.0 / 1440.0 + occultationPathBoundaryScanPoints = 720 + occultationPathRootToleranceDays = occultationEventSelectionToleranceDays + occultationPathRangeStepDays = 5.0 / 1440.0 + occultationPathSearchSpanDays = 2.0 + occultationPathWidthToleranceKM = 0.005 + occultationPathMaxOutputPointCount = 2000000 + occultationPathFootprintPointBudget = 2048 + occultationPathBoundaryBranchJumpKM = 120.0 + occultationPathBoundaryBranchSpeedKMPerSecond = 10.0 occultationPathEarthEquatorialRadiusKM = 6378.1366 occultationPathEarthPolarRatio = 0.99664719 @@ -54,16 +64,13 @@ func FindStarOccultationPaths(start, end time.Time, star StarCoordinate, options ) paths := make([]StarOccultationPath, 0, len(candidates)) for _, seedTT := range candidates { - path, ok, err := starOccultationPathAtSeed(seedTT, star, options, start.Location()) + path, ok, err := starOccultationPathAtSeed(seedTT, star, options, start, end) if err != nil { return nil, err } if !ok { continue } - if !occultationTimeInSelectionWindow(path.Greatest.Time, start, end) { - continue - } if len(paths) > 0 && math.Abs(paths[len(paths)-1].Greatest.Time.Sub(path.Greatest.Time).Seconds()) <= 60 { continue } @@ -76,6 +83,9 @@ func FindStarOccultationPaths(start, end time.Time, star StarCoordinate, options } func normalizeOccultationPathOptions(options OccultationPathOptions) OccultationPathOptions { + if options.Algorithm == "" { + options.Algorithm = OccultationPathAlgorithmOptimized + } if options.Step <= 0 { options.Step = time.Duration(occultationPathDefaultStepDays * float64(24*time.Hour)) } @@ -85,6 +95,26 @@ func normalizeOccultationPathOptions(options OccultationPathOptions) Occultation if options.TargetSpacingKM <= 0 || math.IsNaN(options.TargetSpacingKM) || math.IsInf(options.TargetSpacingKM, 0) { options.TargetSpacingKM = 0 } + if len(options.GreatestTimeValues) > 0 { + values := make([]float64, 0, len(options.GreatestTimeValues)) + for _, value := range options.GreatestTimeValues { + if !finite(value) { + continue + } + duplicate := false + for _, existing := range values { + if math.Abs(existing-value) <= 1e-9 { + duplicate = true + break + } + } + if !duplicate { + values = append(values, value) + } + } + sort.Float64s(values) + options.GreatestTimeValues = values + } return options } @@ -92,39 +122,68 @@ func starOccultationPathAtSeed( seedTT float64, star StarCoordinate, options OccultationPathOptions, - location *time.Location, + selectionStart, selectionEnd time.Time, ) (StarOccultationPath, bool, error) { + location := selectionStart.Location() + cache := newStarOccultationEventCache(star) + cache.prepareLocalEphemeris(seedTT) + frameAt := cache.frameAt + candidateFrameAt := cache.candidateFrameAt searchStart := seedTT - occultationPathSearchSpanDays searchEnd := seedTT + occultationPathSearchSpanDays - outerStart, outerEnd, ok := starOccultationPathWindow(seedTT, searchStart, searchEnd, star, false) + outerStart, outerEnd, ok := starOccultationPathWindowWithCandidateFrames( + seedTT, searchStart, searchEnd, candidateFrameAt, frameAt, false, + ) if !ok { return StarOccultationPath{}, false, nil } - centerStart, centerEnd, hasCenter := starOccultationPathWindow(seedTT, searchStart, searchEnd, star, true) - greatestTT := starOccultationPathGreatest(seedTT, outerStart, outerEnd, star) - greatest, greatestOK := starOccultationPathCenterPoint(greatestTT, star, location) + centerStart, centerEnd, hasCenter := starOccultationPathWindowWithCandidateFrames( + seedTT, searchStart, searchEnd, candidateFrameAt, frameAt, true, + ) + // Global markers retain the same full-term ephemerides as event-only + // queries. The optimized path cache is selected after these markers. + greatestTT := starOccultationPathGreatestWithFrame(seedTT, outerStart, outerEnd, frameAt) + greatest, greatestOK := starOccultationPathCenterPointWithFrame(greatestTT, frameAt, location) if !greatestOK { if hasCenter { greatestTT = math.Max(centerStart, math.Min(centerEnd, greatestTT)) - greatest, greatestOK = starOccultationPathCenterPoint(greatestTT, star, location) + greatest, greatestOK = starOccultationPathCenterPointWithFrame(greatestTT, frameAt, location) } } if !greatestOK { - frameAt := func(tt float64) (occultationPathFrame, bool) { - return starOccultationPathFrameAt(tt, star) - } - greatest, greatestOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, location) + // For a non-central path the shadow axis misses the ellipsoid. Greatest + // is the nearest point on the ellipsoid to that axis, not an outer + // contact tangent; the latter is a band edge and shifts the marker. + greatest, greatestOK = occultationPathTrackPointForFrame(greatestTT, frameAt, location) } if !greatestOK { return StarOccultationPath{}, false, nil } + if !occultationTimeInSelectionWindow(greatest.Time, selectionStart, selectionEnd) { + return StarOccultationPath{}, false, nil + } + if occultationPathEstimatedPointCount( + outerStart, outerEnd, centerStart, centerEnd, hasCenter, + 0, 0, false, greatestTT, options, + ) > occultationPathMaxOutputPointCount { + return StarOccultationPath{}, false, ErrOccultationPathSamplingLimit + } - start := starOccultationPathBoundaryEndpoint(outerStart, star, location, 1) - end := starOccultationPathBoundaryEndpoint(outerEnd, star, location, -1) + start := starOccultationPathBoundaryEndpointWithFrame(outerStart, frameAt, location, 1) + end := starOccultationPathBoundaryEndpointWithFrame(outerEnd, frameAt, location, -1) if !start.valid || !end.valid { return StarOccultationPath{}, false, nil } + exactFrameAt := frameAt + if options.Algorithm != OccultationPathAlgorithmExact { + optimized := newStarOccultationEventCache(star) + optimized.preparePathEphemeris(seedTT, options.Algorithm) + if optimized.local.dense { + cache = optimized + frameAt = cache.frameAt + } + } path := StarOccultationPath{ TargetID: star.ID, @@ -135,45 +194,306 @@ func starOccultationPathAtSeed( Step: options.Step, TargetSpacingKM: options.TargetSpacingKM, } - centerLine, northern, southern, err := starOccultationPathSamples( + centerLine, northern, southern, err := starOccultationPathSamplesWithFrame( outerStart, outerEnd, centerStart, centerEnd, hasCenter, greatestTT, - star, + frameAt, options, location, ) if err != nil { return StarOccultationPath{}, false, err } + if cache.local.dense { + correctOccultationCenterWidths(centerLine, exactFrameAt) + } path.CenterLine = centerLine path.NorthernLimit = occultationPathWithEndpoints(start.point, end.point, northern) path.SouthernLimit = occultationPathWithEndpoints(start.point, end.point, southern) + path.NorthernLimit = occultationStationCorrectLimitSeries( + path.NorthernLimit, frameAt, cache.riseSetContextAt, false, location, + ) + path.SouthernLimit = occultationStationCorrectLimitSeries( + path.SouthernLimit, frameAt, cache.riseSetContextAt, false, location, + ) + path.GreatestLimitSeparationKM, _ = occultationPathLimitSeparations( + path.NorthernLimit, path.SouthernLimit, greatestTT, + ) + if !options.DisableFootprints { + path.Footprints = planetOccultationFootprints(outerStart, outerEnd, greatestTT, frameAt, options, location) + } + path.RiseSetCurves = occultationRiseSetCurvesWithCache( + outerStart, outerEnd, greatestTT, options, location, cache.riseSetCache, + ) + path.GreatestTimeContours = occultationGreatestTimeContours( + occultationGreatestTimeLevels(options, outerStart, outerEnd), outerStart, outerEnd, cache.riseSetCache, + [][]OccultationPathPoint{path.CenterLine, path.NorthernLimit, path.SouthernLimit}, + false, location, + ) + bandContourTimes := occultationRiseSetEndpointTimes(path.RiseSetCurves) + path.BandContours = occultationContactBandContoursWithAdditionalTimes( + start.point, end.point, outerStart, outerEnd, greatestTT, frameAt, options, location, bandContourTimes, + ) + if options.DisableFootprints { + path.BandFootprints = planetOccultationBandFootprints( + outerStart, outerEnd, greatestTT, frameAt, location, bandContourTimes, + ) + if options.IncludeFootprintTimeline { + path.Footprints = planetOccultationTimelineFootprints( + outerStart, outerEnd, greatestTT, frameAt, options, location, + ) + } + } + if len(path.Footprints) > 0 { + path.Footprints = occultationStationCorrectFootprintEdges( + path.Footprints, frameAt, cache.riseSetContextAt, false, location, + ) + } + if len(path.BandFootprints) > 0 { + path.BandFootprints = occultationStationCorrectFootprintEdges( + path.BandFootprints, frameAt, cache.riseSetContextAt, false, location, + ) + } + path.BandContours = occultationStationCorrectContours( + path.BandContours, path.RiseSetCurves, cache.riseSetCache, location, + ) + path.VisibilityContours = occultationStationVisibilityEnvelopeContours( + path.RiseSetCurves, cache.riseSetCache, location, + ) return path, true, nil } +func occultationPathEstimatedPointCount( + outerStart, outerEnd, centerStart, centerEnd float64, + hasCenter bool, + totalStart, totalEnd float64, + hasTotal bool, + greatestTT float64, + options OccultationPathOptions, +) int { + stepDays := float64(options.Step) / float64(24*time.Hour) + if stepDays <= 0 { + stepDays = occultationPathDefaultStepDays + } + estimate := 0 + add := func(value int) { + estimate = occultationPathAccumulatePointEstimate(estimate, value) + } + add(3 * len(occultationPathSampleTimes(outerStart, outerEnd, greatestTT, stepDays))) + contourStepDays := occultationPathContourStepDays(options) + add(2 * len(occultationPathSampleTimes(outerStart, outerEnd, greatestTT, contourStepDays))) + if hasCenter { + add(3 * len(occultationPathSampleTimes(centerStart, centerEnd, greatestTT, stepDays))) + } + if hasTotal { + add(3 * len(occultationPathSampleTimes(totalStart, totalEnd, greatestTT, stepDays))) + add(2 * len(occultationPathSampleTimes(totalStart, totalEnd, greatestTT, contourStepDays))) + } + footprintStep := planetOccultationFootprintSampleStepDays(options) + footprintLimit := planetOccultationFootprintMaxSamples + if options.DisableFootprints && !options.IncludeFootprintTimeline { + footprintStep = planetOccultationBandSampleStepDays() + footprintLimit = planetOccultationBandMaxSamples + } + add(len(occultationPathSampleTimesWithLimit(outerStart, outerEnd, greatestTT, footprintStep, footprintLimit)) * occultationPathFootprintPointBudget) + if options.DisableFootprints && options.IncludeFootprintTimeline { + add(len(planetOccultationBandSampleTimes( + outerStart, outerEnd, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples, + )) * occultationPathFootprintPointBudget) + } + if hasTotal { + add(len(occultationPathSampleTimesWithLimit(totalStart, totalEnd, greatestTT, footprintStep, footprintLimit)) * occultationPathFootprintPointBudget) + if options.DisableFootprints && options.IncludeFootprintTimeline { + add(len(planetOccultationBandSampleTimes( + totalStart, totalEnd, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples, + )) * occultationPathFootprintPointBudget) + } + } + if !options.DisableRiseSet { + riseSetStep := 5 * time.Minute + if options.RiseSetStep > 0 { + riseSetStep = options.RiseSetStep + } + riseSetCount := len(occultationPathSampleTimes( + outerStart, outerEnd, greatestTT, + float64(riseSetStep)/float64(24*time.Hour), + )) + add(6 * riseSetCount) + } + return estimate +} + +func occultationPathAccumulatePointEstimate(estimate, value int) int { + if value <= 0 || estimate > occultationPathMaxOutputPointCount { + return estimate + } + if value > occultationPathMaxOutputPointCount-estimate { + return occultationPathMaxOutputPointCount + 1 + } + return estimate + value +} + func occultationPathWithEndpoints(start, end OccultationPathPoint, points []OccultationPathPoint) []OccultationPathPoint { result := make([]OccultationPathPoint, 0, len(points)+2) result = append(result, start) for _, point := range points { - if point.Time.After(start.Time) && point.Time.Before(end.Time) { + if point.Time.After(result[len(result)-1].Time) && point.Time.Before(end.Time) { result = append(result, point) } } return append(result, end) } -func starOccultationPathWindow(seedTT, startTT, endTT float64, star StarCoordinate, center bool) (float64, float64, bool) { - left := math.Max(startTT, seedTT-occultationPathSearchSpanDays) - right := math.Min(endTT, seedTT+occultationPathSearchSpanDays) - if right <= left { - return 0, 0, false +// occultationPathLimitSeparations 在同一时刻的南北限采样对上填写地面间距,并返回最接近掩甚的那一对的间距。 +func occultationPathLimitSeparations( + north, south []OccultationPathPoint, + greatestTT float64, +) (float64, bool) { + if len(north) == 0 || len(north) != len(south) { + return 0, false } - predicate := func(tt float64) bool { - frame, ok := starOccultationPathFrameAt(tt, star) + separation, found := 0.0, false + nearestDelta := math.Inf(1) + for index := range north { + if !north[index].Time.Equal(south[index].Time) { + continue + } + value := occultationPathDistanceKM(north[index], south[index]) + if !finite(value) || value < 0 { + continue + } + north[index].LimitSeparationKM = value + south[index].LimitSeparationKM = value + if delta := math.Abs(centerTimeTT(north[index].Time) - greatestTT); delta < nearestDelta { + nearestDelta, separation, found = delta, value, true + } + } + return separation, found +} + +func occultationContactBandContours( + northern, southern []OccultationPathPoint, +) [][]OccultationPathPoint { + contours := make([][]OccultationPathPoint, 0, 4) + for _, source := range [][]OccultationPathPoint{northern, southern} { + for _, sampleRange := range occultationContinuousBoundaryRanges(source) { + if sampleRange.end-sampleRange.start < 2 { + continue + } + contour := append([]OccultationPathPoint(nil), source[sampleRange.start:sampleRange.end]...) + contours = append(contours, contour) + } + } + return contours +} + +type occultationPathSampleRange struct { + start int + end int +} + +func occultationContinuousBoundaryRanges(points []OccultationPathPoint) []occultationPathSampleRange { + if len(points) == 0 { + return nil + } + ranges := make([]occultationPathSampleRange, 0, 4) + start := 0 + for index := 1; index < len(points); index++ { + if !occultationPathBoundaryBranchChanged(points[index-1], points[index]) { + continue + } + ranges = append(ranges, occultationPathSampleRange{start: start, end: index}) + start = index + } + return append(ranges, occultationPathSampleRange{start: start, end: len(points)}) +} + +func occultationPathBoundaryBranchChanged(first, second OccultationPathPoint) bool { + distance := occultationPathDistanceKM(first, second) + if distance <= occultationPathBoundaryBranchJumpKM { + return false + } + duration := math.Abs(second.Time.Sub(first.Time).Seconds()) + return duration == 0 || distance/duration > occultationPathBoundaryBranchSpeedKMPerSecond +} + +func occultationContactBandContoursWithAdditionalTimes( + start, end OccultationPathPoint, + startTT, endTT, greatestTT float64, + frameAt occultationPathFrameFunc, + options OccultationPathOptions, + location *time.Location, + additionalTimes []float64, +) [][]OccultationPathPoint { + stepDays := occultationPathContourStepDays(options) + northern, southern := occultationPathBoundaryContourSamplesForFrame( + startTT, endTT, greatestTT, frameAt, stepDays, location, additionalTimes, + ) + return occultationContactBandContours( + occultationPathWithEndpoints(start, end, northern), + occultationPathWithEndpoints(start, end, southern), + ) +} + +func occultationPathContourStepDays(options OccultationPathOptions) float64 { + stepDays := float64(options.Step) / float64(24*time.Hour) + if stepDays <= 0 { + stepDays = occultationPathDefaultStepDays + } + if options.DisableRiseSet { + return stepDays + } + riseSetStep := options.RiseSetStep + if riseSetStep <= 0 { + riseSetStep = 5 * time.Minute + } + riseSetStepDays := float64(riseSetStep) / float64(24*time.Hour) + if riseSetStepDays > 0 && riseSetStepDays < stepDays { + stepDays = riseSetStepDays + } + return stepDays +} + +func starOccultationPathWindow(seedTT, startTT, endTT float64, star StarCoordinate, center bool) (float64, float64, bool) { + return starOccultationPathWindowWithFrame(seedTT, startTT, endTT, func(tt float64) (occultationPathFrame, bool) { + return starOccultationPathFrameAt(tt, star) + }, center) +} + +func starOccultationPathWindowWithFrame( + seedTT, startTT, endTT float64, + frameAt occultationPathFrameFunc, + center bool, +) (float64, float64, bool) { + return occultationPathWindowWithCandidateFrames( + seedTT, startTT, endTT, frameAt, frameAt, center, + starOccultationPathHasBoundary, + ) +} + +func starOccultationPathWindowWithCandidateFrames( + seedTT, startTT, endTT float64, + candidateFrameAt, exactFrameAt occultationPathFrameFunc, + center bool, +) (float64, float64, bool) { + return occultationPathWindowWithCandidateFrames( + seedTT, startTT, endTT, candidateFrameAt, exactFrameAt, center, + starOccultationPathHasBoundary, + ) +} + +func occultationPathWindowWithCandidateFrames( + seedTT, startTT, endTT float64, + candidateFrameAt, exactFrameAt occultationPathFrameFunc, + center bool, + hasBoundary func(occultationPathFrame) bool, +) (float64, float64, bool) { + predicate := func(frameAt occultationPathFrameFunc, tt float64) bool { + frame, ok := frameAt(tt) if !ok { return false } @@ -181,146 +501,81 @@ func starOccultationPathWindow(seedTT, startTT, endTT float64, star StarCoordina _, _, ok = occultationEarthLineIntersection(frame.moon, frame.axis) return ok } - return starOccultationPathHasBoundary(frame) + return hasBoundary(frame) } - - step := occultationPathRangeStepDays - first := math.NaN() - previous := left - previousOK := predicate(previous) - if previousOK { - first = previous - } else { - for tt := left + step; tt <= right; tt += step { - current := math.Min(tt, right) - currentOK := predicate(current) - if currentOK { - first = occultationPathRefineTransition(previous, current, predicate, false) - break - } - previous = current - previousOK = currentOK - } - } - if math.IsNaN(first) { - return 0, 0, false - } - - last := first - previous = first - previousOK = true - for tt := first + step; tt <= right; tt += step { - current := math.Min(tt, right) - currentOK := predicate(current) - if !currentOK { - last = occultationPathRefineTransition(previous, current, predicate, true) - return first, last, true - } - last = current - previous = current - previousOK = currentOK - } - if previousOK { - last = right - } - return first, last, true -} - -func occultationPathRefineTransition(left, right float64, predicate func(float64) bool, trueToFalse bool) float64 { - leftOK := predicate(left) - for i := 0; i < 48 && math.Abs(right-left) > occultationPathRootToleranceDays; i++ { - mid := (left + right) / 2 - midOK := predicate(mid) - if trueToFalse { - if midOK { - left = mid - } else { - right = mid - } - continue - } - if midOK { - right = mid - } else { - left = mid - } - } - if trueToFalse { - return left - } - if leftOK { - return left - } - return right + engine := occultationMovingDiskEngine() + return engine.window( + seedTT, startTT, endTT, + func(tt float64) bool { return predicate(candidateFrameAt, tt) }, + func(tt float64) bool { return predicate(exactFrameAt, tt) }, + ) } func starOccultationPathGreatest(seedTT, startTT, endTT float64, star StarCoordinate) float64 { - left := math.Max(startTT, seedTT-0.75) - right := math.Min(endTT, seedTT+0.75) - if right <= left { - return seedTT - } - const goldenRatio = 0.6180339887498949 - x1 := right - goldenRatio*(right-left) - x2 := left + goldenRatio*(right-left) - f1 := starOccultationPathImpact(x1, star) - f2 := starOccultationPathImpact(x2, star) - for i := 0; i < 56; i++ { - if f1 > f2 { - left = x1 - x1 = x2 - f1 = f2 - x2 = left + goldenRatio*(right-left) - f2 = starOccultationPathImpact(x2, star) - } else { - right = x2 - x2 = x1 - f2 = f1 - x1 = right - goldenRatio*(right-left) - f1 = starOccultationPathImpact(x1, star) - } - } - return (left + right) / 2 + return starOccultationPathGreatestWithFrame(seedTT, startTT, endTT, func(tt float64) (occultationPathFrame, bool) { + return starOccultationPathFrameAt(tt, star) + }) } -func starOccultationPathImpact(tt float64, star StarCoordinate) float64 { - frame, ok := starOccultationPathFrameAt(tt, star) +func starOccultationPathGreatestWithFrame( + seedTT, startTT, endTT float64, + frameAt occultationPathFrameFunc, +) float64 { + return starOccultationPathGreatestWithIterations(seedTT, startTT, endTT, frameAt, 56) +} + +func starOccultationPathGreatestWithIterations( + seedTT, startTT, endTT float64, + frameAt occultationPathFrameFunc, + iterations int, +) float64 { + return occultationMovingDiskEngine().greatest( + seedTT, startTT, endTT, + func(tt float64) (float64, bool) { + frame, ok := frameAt(tt) + if !ok { + return 0, false + } + return math.Hypot(frame.moonProjectionX(), frame.moonProjectionY()), true + }, iterations, + ) +} + +func starOccultationPathImpactWithFrame(tt float64, frameAt occultationPathFrameFunc) float64 { + frame, ok := frameAt(tt) if !ok { return math.Inf(1) } return math.Hypot(frame.moonProjectionX(), frame.moonProjectionY()) } -func starOccultationPathSamples( +func starOccultationPathSamplesWithFrame( outerStartTT, outerEndTT float64, centerStartTT, centerEndTT float64, hasCenter bool, greatestTT float64, - star StarCoordinate, + frameAt occultationPathFrameFunc, options OccultationPathOptions, location *time.Location, ) ([]OccultationPathPoint, []OccultationPathPoint, []OccultationPathPoint, error) { var points []OccultationPathPoint if hasCenter { var err error - points, err = starOccultationPathCenterSamples(centerStartTT, centerEndTT, greatestTT, star, options, location) + points, err = starOccultationPathCenterSamplesWithFrame(centerStartTT, centerEndTT, greatestTT, frameAt, options, location) if err != nil { return nil, nil, nil, err } } - frameAt := func(tt float64) (occultationPathFrame, bool) { - return starOccultationPathFrameAt(tt, star) - } northern, southern := occultationPathBoundarySamplesForFrame( - outerStartTT, outerEndTT, greatestTT, frameAt, options, location, + outerStartTT, outerEndTT, greatestTT, frameAt, occultationPathContourStepDays(options), location, ) return points, northern, southern, nil } -func starOccultationPathCenterSamples( +func starOccultationPathCenterSamplesWithFrame( startTT, endTT, greatestTT float64, - star StarCoordinate, + frameAt occultationPathFrameFunc, options OccultationPathOptions, location *time.Location, ) ([]OccultationPathPoint, error) { @@ -328,13 +583,13 @@ func starOccultationPathCenterSamples( times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays) points := make([]OccultationPathPoint, 0, len(times)) for _, tt := range times { - point, ok := starOccultationPathCenterPoint(tt, star, location) - if ok { + point, ok := starOccultationPathCenterPointWithFrame(tt, frameAt, location) + if ok && (len(points) == 0 || point.Time.After(points[len(points)-1].Time)) { points = append(points, point) } } if options.TargetSpacingKM > 0 { - return refineOccultationPathSpacing(points, star, options.TargetSpacingKM, location) + return refineOccultationPathSpacingWithFrame(points, frameAt, options.TargetSpacingKM, location) } return points, nil } @@ -349,44 +604,18 @@ func occultationPathSampleTimesWithLimit( startTT, endTT, greatestTT, stepDays float64, maximumCount int, ) []float64 { - if endTT < startTT { - startTT, endTT = endTT, startTT - } if maximumCount < 3 { maximumCount = 3 } - duration := endTT - startTT - if duration <= 0 { - return []float64{startTT} - } - if stepDays <= 0 || !finite(stepDays) { - stepDays = duration - } - baseSampleCount := int(math.Ceil(duration/stepDays)) + 1 - if baseSampleCount+1 > maximumCount { - times := []float64{startTT, greatestTT, endTT} - interiorCount := maximumCount - len(times) - for index := 1; index <= interiorCount; index++ { - times = append(times, startTT+duration*float64(index)/float64(interiorCount+1)) - } - sort.Float64s(times) - return uniqueOccultationPathTimes(times) - } - times := []float64{startTT, greatestTT, endTT} - for index := 1; ; index++ { - tt := startTT + float64(index)*stepDays - if tt >= endTT { - break - } - times = append(times, tt) - } - sort.Float64s(times) - return uniqueOccultationPathTimes(times) + engine := occultationMovingDiskEngine() + engine.maxSampleCount = maximumCount + times, _ := engine.sampleTimes(startTT, endTT, greatestTT, stepDays) + return times } -func refineOccultationPathSpacing( +func refineOccultationPathSpacingWithFrame( points []OccultationPathPoint, - star StarCoordinate, + frameAt occultationPathFrameFunc, targetSpacingKM float64, location *time.Location, ) ([]OccultationPathPoint, error) { @@ -396,13 +625,13 @@ func refineOccultationPathSpacing( refined := make([]OccultationPathPoint, 0, len(points)) refined = append(refined, points[0]) widthAt := func(tt float64) (float64, bool) { - _, _, width, ok := starOccultationPathLimitsAndWidthAt(tt, star) + _, _, width, ok := occultationPathLimitsAndWidthForFrame(tt, frameAt) return width, ok } for i := 1; i < len(points); i++ { segmentStart := len(refined) - 1 var err error - refined, err = appendOccultationPathSegment(refined, points[i-1], points[i], star, targetSpacingKM, location, 0) + refined, err = appendOccultationPathSegmentWithFrame(refined, points[i-1], points[i], frameAt, targetSpacingKM, location, 0) if err != nil { return nil, err } @@ -411,10 +640,10 @@ func refineOccultationPathSpacing( return refined, nil } -func appendOccultationPathSegment( +func appendOccultationPathSegmentWithFrame( points []OccultationPathPoint, start, end OccultationPathPoint, - star StarCoordinate, + frameAt occultationPathFrameFunc, targetSpacingKM float64, location *time.Location, depth int, @@ -432,31 +661,25 @@ func appendOccultationPathSegment( startTT := centerTimeTT(start.Time) endTT := centerTimeTT(end.Time) midTT := (startTT + endTT) / 2 - mid, ok := starOccultationPathCenterPointWithoutWidth(midTT, star, location) + midTime := occultationTTToLocation(midTT, location) + if !midTime.After(start.Time) || !midTime.Before(end.Time) { + return append(points, end), nil + } + mid, ok := starOccultationPathCenterPointWithoutWidthWithFrame(midTT, frameAt, location) if !ok { return append(points, end), nil } mid.WidthKM = (start.WidthKM + end.WidthKM) / 2 var err error - points, err = appendOccultationPathSegment(points, start, mid, star, targetSpacingKM, location, depth+1) + points, err = appendOccultationPathSegmentWithFrame(points, start, mid, frameAt, targetSpacingKM, location, depth+1) if err != nil { return nil, err } - return appendOccultationPathSegment(points, mid, end, star, targetSpacingKM, location, depth+1) + return appendOccultationPathSegmentWithFrame(points, mid, end, frameAt, targetSpacingKM, location, depth+1) } func uniqueOccultationPathTimes(times []float64) []float64 { - if len(times) < 2 { - return times - } - unique := times[:1] - for _, tt := range times[1:] { - if math.Abs(tt-unique[len(unique)-1]) <= 1e-10 { - continue - } - unique = append(unique, tt) - } - return unique + return movingDiskUniqueTimes(times) } type occultationPathFrame struct { @@ -466,6 +689,158 @@ type occultationPathFrame struct { second occultationPathVector moonRadius float64 targetRadius float64 + // boundary 挂在被事件缓存复用的 frame 上,记忆化只依赖 frame 的边界搜索;零值 frame 为 nil 时退化为直接计算。 + // boundary is attached to frames reused through the event cache and memoizes the boundary + // searches that depend only on the frame; a zero frame keeps it nil and computes directly. + boundary *occultationPathBoundaryCache +} + +// occultationPathBoundaryCache 记忆化只依赖 frame 的两个边界搜索:切点与可见 θ 区间。 +// 两者各自要扫描 720 个网格点,而同一 frame 会在多个调用点被反复查询。 +// occultationPathBoundaryCache memoizes the two frame-only boundary searches (the tangency +// and the visible theta intervals). Each scans 720 grid points and the same frame is queried +// repeatedly from several call sites. +type occultationPathBoundaryCache struct { + // boundaryOnce 用同一份 720 点判别式网格同时恢复切点与可见 θ 区间,避免两条路径各扫一遍。 + // boundaryOnce recovers both the tangency and the visible theta intervals from one 720-point + // discriminant grid instead of scanning it once per path. + boundaryOnce sync.Once + tangentPoint occultationPathVector + tangentTheta float64 + tangentOK bool + intervals []occultationPathThetaInterval + + // intervalMu 保护按 centerTheta 复用的 θ 区间搜索;每个 frame 只会用到个位数个中心角。 + // intervalMu guards the theta-interval searches reused per center angle; a frame only ever + // uses a handful of center angles. + intervalMu sync.Mutex + intervalKeys []uint64 + intervalVals []occultationPathThetaIntervalResult +} + +// occultationPathFrameGeometryEqual 比较两个 frame 的几何内容,忽略只用于记忆化的 boundary 指针。 +// occultationPathFrameGeometryEqual compares the geometric content of two frames and ignores +// the memo-only boundary pointer. +func occultationPathFrameGeometryEqual(first, second occultationPathFrame) bool { + return first.moon == second.moon && + first.axis == second.axis && + first.first == second.first && + first.second == second.second && + first.moonRadius == second.moonRadius && + first.targetRadius == second.targetRadius +} + +// occultationPathThetaIntervalResult 是 occultationPathBoundaryThetaInterval 的缓存结果。 +// occultationPathThetaIntervalResult is the cached result of occultationPathBoundaryThetaInterval. +type occultationPathThetaIntervalResult struct { + left, right float64 + ok bool +} + +type starOccultationEphemerisState struct { + moonRA, moonDec float64 + moonDistanceKM float64 + starRA, starDec float64 + starDistanceKM float64 + valid bool +} + +type starOccultationEventCache struct { + star StarCoordinate + states map[uint64]starOccultationEphemerisState + frames map[uint64]planetOccultationFrameCacheEntry + riseSetCache *occultationRiseSetEvaluationCache + local *starOccultationLocalEphemeris +} + +func newStarOccultationEventCache(star StarCoordinate) *starOccultationEventCache { + cache := &starOccultationEventCache{ + star: star, + states: make(map[uint64]starOccultationEphemerisState), + frames: make(map[uint64]planetOccultationFrameCacheEntry), + } + cache.riseSetCache = newOccultationRiseSetEvaluationCacheWithCandidate( + cache.riseSetContextAt, + cache.candidateRiseSetContextAt, + ) + return cache +} + +func (cache *starOccultationEventCache) stateAt(tt float64) starOccultationEphemerisState { + key := math.Float64bits(tt) + if state, ok := cache.states[key]; ok { + return state + } + if len(cache.states) >= planetOccultationEventCacheMaximumEntries { + for cachedKey := range cache.states { + delete(cache.states, cachedKey) + } + for cachedKey := range cache.frames { + delete(cache.frames, cachedKey) + } + } + var state starOccultationEphemerisState + interpolated := false + if cache.local != nil && cache.local.dense { + state, interpolated = cache.local.stateAt(tt) + } + if !interpolated { + state = starOccultationEphemerisStateAt(tt, cache.star) + } + cache.states[key] = state + return state +} + +func (cache *starOccultationEventCache) prepareLocalEphemeris(center float64) { + if cache.local == nil { + cache.local = newStarOccultationLocalEphemeris(center, cache.star) + } +} + +func (cache *starOccultationEventCache) candidateFrameAt(tt float64) (occultationPathFrame, bool) { + if cache.local != nil { + if state, ok := cache.local.stateAt(tt); ok { + return starOccultationPathFrameFromState(state) + } + } + return cache.frameAt(tt) +} + +func (cache *starOccultationEventCache) frameAt(tt float64) (occultationPathFrame, bool) { + key := math.Float64bits(tt) + if entry, ok := cache.frames[key]; ok { + return entry.frame, entry.ok + } + frame, ok := starOccultationPathFrameFromState(cache.stateAt(tt)) + if ok { + frame.boundary = &occultationPathBoundaryCache{} + } + cache.frames[key] = planetOccultationFrameCacheEntry{frame: frame, ok: ok} + return frame, ok +} + +func (cache *starOccultationEventCache) riseSetContextAt(tt float64) occultationRiseSetContext { + state := cache.stateAt(tt) + return newOccultationRiseSetContext( + tt, state.moonRA, state.moonDec, state.moonDistanceKM, + state.starRA, state.starDec, state.starDistanceKM, 0, + ) +} + +func (cache *starOccultationEventCache) candidateRiseSetContextAt(tt float64) occultationRiseSetContext { + if cache.local != nil { + moonXYZ, targetXYZ, ok := cache.local.vectorsAt(tt) + if ok { + return newOccultationRiseSetContextFromVectors( + tt, + moonXYZ, + targetXYZ, + cache.local.starDistanceKM() > 0, + 0, + ) + } + } + return cache.riseSetContextAt(tt) } type occultationPathEndpoint struct { @@ -477,18 +852,34 @@ func (f occultationPathFrame) moonProjectionX() float64 { return occultationPath func (f occultationPathFrame) moonProjectionY() float64 { return occultationPathDot(f.moon, f.second) } func starOccultationPathFrameAt(tt float64, star StarCoordinate) (occultationPathFrame, bool) { + return starOccultationPathFrameFromState(starOccultationEphemerisStateAt(tt, star)) +} + +func starOccultationEphemerisStateAt(tt float64, star StarCoordinate) starOccultationEphemerisState { moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1) - moonDistance := HMoonAwayN(tt, -1) - if !finite(moonRA) || !finite(moonDec) || !finite(moonDistance) || moonDistance <= 0 { + moonDistanceKM := HMoonAwayN(tt, -1) + starRA, starDec := starApparentRaDecGeocentric(tt, star) + starDistanceKM := 0.0 + if star.ParallaxMas > 0 { + starDistanceKM = 206264806.247 / star.ParallaxMas * occultationPathAstronomicalUnitKM + } + return starOccultationEphemerisState{ + moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistanceKM, + starRA: starRA, starDec: starDec, starDistanceKM: starDistanceKM, + valid: finite(moonRA) && finite(moonDec) && finite(moonDistanceKM) && moonDistanceKM > 0 && + finite(starRA) && finite(starDec) && finite(starDistanceKM) && starDistanceKM >= 0, + } +} + +func starOccultationPathFrameFromState(state starOccultationEphemerisState) (occultationPathFrame, bool) { + if !state.valid { return occultationPathFrame{}, false } - moon := occultationPathRaDecVector(moonRA, moonDec, moonDistance) - starRA, starDec := starApparentRaDecGeocentric(tt, star) - starDirection := occultationPathRaDecVector(starRA, starDec, 1) + moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM) + starDirection := occultationPathRaDecVector(state.starRA, state.starDec, 1) axis := occultationPathScale(starDirection, -1) - if star.ParallaxMas > 0 { - distanceAU := 206264806.247 / star.ParallaxMas - target := occultationPathRaDecVector(starRA, starDec, distanceAU*occultationPathAstronomicalUnitKM) + if state.starDistanceKM > 0 { + target := occultationPathRaDecVector(state.starRA, state.starDec, state.starDistanceKM) axis = occultationPathScale(occultationPathSub(target, moon), -1) } axis = occultationPathUnit(axis) @@ -504,7 +895,7 @@ func starOccultationPathFrameAt(tt float64, star StarCoordinate) (occultationPat axis: axis, first: first, second: second, - moonRadius: MoonSemidiameter(tt) * math.Pi / (180 * 3600), + moonRadius: math.Asin(moonEquatorialRadiusKM / state.moonDistanceKM), }, true } @@ -513,13 +904,18 @@ func starOccultationPathHasBoundary(frame occultationPathFrame) bool { return ok } -func starOccultationPathBoundaryEndpoint(tt float64, star StarCoordinate, location *time.Location, direction int) occultationPathEndpoint { - if _, ok := starOccultationPathFrameAt(tt, star); !ok { +func starOccultationPathBoundaryEndpointWithFrame( + tt float64, + frameAt occultationPathFrameFunc, + location *time.Location, + direction int, +) occultationPathEndpoint { + if _, ok := frameAt(tt); !ok { return occultationPathEndpoint{} } for offset := 0; offset <= 3; offset++ { candidateTT := tt + float64(direction)*float64(offset)*0.5/86400.0 - candidateFrame, candidateOK := starOccultationPathFrameAt(candidateTT, star) + candidateFrame, candidateOK := frameAt(candidateTT) if !candidateOK { continue } @@ -532,7 +928,17 @@ func starOccultationPathBoundaryEndpoint(tt float64, star StarCoordinate, locati } func starOccultationPathCenterPoint(tt float64, star StarCoordinate, location *time.Location) (OccultationPathPoint, bool) { - frame, ok := starOccultationPathFrameAt(tt, star) + return starOccultationPathCenterPointWithFrame(tt, func(candidateTT float64) (occultationPathFrame, bool) { + return starOccultationPathFrameAt(candidateTT, star) + }, location) +} + +func starOccultationPathCenterPointWithFrame( + tt float64, + frameAt occultationPathFrameFunc, + location *time.Location, +) (OccultationPathPoint, bool) { + frame, ok := frameAt(tt) if !ok { return OccultationPathPoint{}, false } @@ -541,14 +947,18 @@ func starOccultationPathCenterPoint(tt float64, star StarCoordinate, location *t return OccultationPathPoint{}, false } width := 0.0 - if _, _, tangentWidth, limitsOK := starOccultationPathLimitsAndWidthAt(tt, star); limitsOK { + if _, _, tangentWidth, limitsOK := occultationPathLimitsAndWidthForFrame(tt, frameAt); limitsOK { width = tangentWidth } return occultationPathPointFromVectorWithMoon(tt, point, width, frame.moon, location), true } -func starOccultationPathCenterPointWithoutWidth(tt float64, star StarCoordinate, location *time.Location) (OccultationPathPoint, bool) { - frame, ok := starOccultationPathFrameAt(tt, star) +func starOccultationPathCenterPointWithoutWidthWithFrame( + tt float64, + frameAt occultationPathFrameFunc, + location *time.Location, +) (OccultationPathPoint, bool) { + frame, ok := frameAt(tt) if !ok { return OccultationPathPoint{}, false } @@ -559,19 +969,12 @@ func starOccultationPathCenterPointWithoutWidth(tt float64, star StarCoordinate, return occultationPathPointFromVectorWithMoon(tt, point, 0, frame.moon, location), true } -func starOccultationPathLimitsAndWidthAt(tt float64, star StarCoordinate) (occultationPathVector, occultationPathVector, float64, bool) { - frameAt := func(candidateTT float64) (occultationPathFrame, bool) { - return starOccultationPathFrameAt(candidateTT, star) - } - return occultationPathLimitsAndWidthForFrame(tt, frameAt) -} - func occultationPathScannedLimitsAtFrame(tt float64, frame occultationPathFrame) (occultationPathVector, occultationPathVector, bool) { var northern, southern occultationPathVector northLatitude := math.Inf(-1) southLatitude := math.Inf(1) consider := func(vector occultationPathVector) { - _, latitude := occultationPathGeodetic(tt, vector) + latitude := occultationPathGeodeticLatitude(vector) if latitude > northLatitude { northLatitude = latitude northern = vector @@ -604,6 +1007,9 @@ func occultationPathScannedLimitsAtFrame(tt float64, frame occultationPathFrame) } func occultationPathBoundaryThetaInterval(frame occultationPathFrame, centerTheta float64) (float64, float64, bool) { + if cached, hit := occultationPathCachedThetaInterval(frame, centerTheta); hit { + return cached.left, cached.right, cached.ok + } if !occultationPathBoundaryLineIntersects(frame, centerTheta) { return 0, 0, false } @@ -630,7 +1036,49 @@ func occultationPathBoundaryThetaInterval(frame occultationPathFrame, centerThet } left, leftOK := findEdge(-1) right, rightOK := findEdge(1) - return left, right, leftOK && rightOK && right > left + result := occultationPathThetaIntervalResult{left: left, right: right, ok: leftOK && rightOK && right > left} + occultationPathStoreThetaInterval(frame, centerTheta, result) + return result.left, result.right, result.ok +} + +// occultationPathCachedThetaInterval 查询 frame 上按 centerTheta 缓存的 θ 区间。 +// occultationPathCachedThetaInterval looks up a theta interval cached on the frame. +func occultationPathCachedThetaInterval( + frame occultationPathFrame, + centerTheta float64, +) (occultationPathThetaIntervalResult, bool) { + if frame.boundary == nil { + return occultationPathThetaIntervalResult{}, false + } + key := math.Float64bits(centerTheta) + frame.boundary.intervalMu.Lock() + defer frame.boundary.intervalMu.Unlock() + for index, cached := range frame.boundary.intervalKeys { + if cached == key { + return frame.boundary.intervalVals[index], true + } + } + return occultationPathThetaIntervalResult{}, false +} + +// occultationPathStoreThetaInterval 记住 frame 上某个 centerTheta 的 θ 区间结果。 +// occultationPathStoreThetaInterval remembers one theta interval computed on a frame. +func occultationPathStoreThetaInterval( + frame occultationPathFrame, + centerTheta float64, + result occultationPathThetaIntervalResult, +) { + if frame.boundary == nil { + return + } + key := math.Float64bits(centerTheta) + frame.boundary.intervalMu.Lock() + defer frame.boundary.intervalMu.Unlock() + if len(frame.boundary.intervalKeys) >= occultationPathThetaIntervalCacheMaximumEntries { + return + } + frame.boundary.intervalKeys = append(frame.boundary.intervalKeys, key) + frame.boundary.intervalVals = append(frame.boundary.intervalVals, result) } func occultationPathBoundaryLineIntersects(frame occultationPathFrame, theta float64) bool { @@ -662,9 +1110,10 @@ func occultationPathBoundaryRay(frame occultationPathFrame, theta float64) (occu if moonDistance <= 0 || !finite(moonDistance) || !finite(frame.targetRadius) { return occultationPathVector{}, occultationPathVector{}, false } + sineTheta, cosineTheta := math.Sincos(theta) radial := occultationPathAdd( - occultationPathScale(frame.first, math.Cos(theta)), - occultationPathScale(frame.second, math.Sin(theta)), + occultationPathScale(frame.first, cosineTheta), + occultationPathScale(frame.second, sineTheta), ) sine, cosine := math.Sincos(frame.targetRadius) normal := occultationPathAdd( @@ -687,15 +1136,47 @@ func occultationPathBoundaryRay(frame occultationPathFrame, theta float64) (occu // 采样网格提供搜索盆地,再对线判别式做局部极大化以恢复网格点之间的切点 / // A sample grid supplies a basin, while local maximization of the line discriminant recovers tangencies between grid points. func occultationPathBoundaryTangent(frame occultationPathFrame) (occultationPathVector, float64, bool) { + if frame.boundary != nil { + frame.boundary.boundaryOnce.Do(func() { occultationPathBoundaryPrecompute(frame) }) + return frame.boundary.tangentPoint, frame.boundary.tangentTheta, frame.boundary.tangentOK + } + return occultationPathBoundaryTangentUncached(frame) +} + +// occultationPathBoundaryPrecompute 用一份网格同时算好切点与可见 θ 区间。 +// occultationPathBoundaryPrecompute derives both the tangency and the visible theta intervals +// from one discriminant grid. +func occultationPathBoundaryPrecompute(frame occultationPathFrame) { step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints) + discriminants := make([]float64, occultationPathBoundaryScanPoints) + occultationPathBoundaryFillGrid(frame, discriminants) + frame.boundary.tangentPoint, frame.boundary.tangentTheta, frame.boundary.tangentOK = + occultationPathBoundaryTangentFromGrid(frame, step, discriminants) + frame.boundary.intervals = occultationPathBoundaryThetaIntervalsFromGrid(frame, step, discriminants) +} + +// occultationPathBoundaryTangentUncached 是切点搜索本体;调用方通过 frame 级缓存复用结果。 +// occultationPathBoundaryTangentUncached is the tangency search itself; callers reuse it through the frame cache. +func occultationPathBoundaryTangentUncached(frame occultationPathFrame) (occultationPathVector, float64, bool) { + step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints) + discriminants := make([]float64, occultationPathBoundaryScanPoints) + occultationPathBoundaryFillGrid(frame, discriminants) + return occultationPathBoundaryTangentFromGrid(frame, step, discriminants) +} + +// occultationPathBoundaryTangentFromGrid 在已算好的网格上恢复切点。 +// occultationPathBoundaryTangentFromGrid recovers the tangency from a prepared grid. +func occultationPathBoundaryTangentFromGrid( + frame occultationPathFrame, + step float64, + discriminants []float64, +) (occultationPathVector, float64, bool) { bestTheta := 0.0 bestDiscriminant := math.Inf(-1) - for i := 0; i < occultationPathBoundaryScanPoints; i++ { - theta := step * float64(i) - discriminant, _, _, ok := occultationPathBoundaryLine(frame, theta) - if ok && discriminant > bestDiscriminant { + for i, discriminant := range discriminants { + if discriminant > bestDiscriminant { bestDiscriminant = discriminant - bestTheta = theta + bestTheta = step * float64(i) } } if !finite(bestDiscriminant) { @@ -776,23 +1257,63 @@ func occultationPathPointFromVectorWithMoon( moon occultationPathVector, location *time.Location, ) OccultationPathPoint { - lon, lat := occultationPathGeodetic(tt, vector) + return occultationPathPointFromVectorWithMoonSidereal( + tt, vector, width, moon, ApparentSiderealTime(TD2UT(tt, false))*15, location, + ) +} + +func occultationPathPointFromVectorWithMoonSidereal( + tt float64, + vector occultationPathVector, + width float64, + moon occultationPathVector, + siderealDegrees float64, + location *time.Location, +) OccultationPathPoint { + lon, lat := occultationPathGeodeticWithSidereal(vector, siderealDegrees) moonDistance := occultationPathNorm(moon) moonRA := normalizeRA(math.Atan2(moon.y, moon.x) * 180 / math.Pi) moonDec := math.Asin(math.Max(-1, math.Min(1, moon.z/moonDistance))) * 180 / math.Pi - moonRA, moonDec = TopocentricRaDec( - moonRA, moonDec, lat, lon, TD2UT(tt, false), + moonRA, moonDec = topocentricRaDecWithSidereal( + moonRA, moonDec, lat, lon, siderealDegrees, moonDistance/occultationPathAstronomicalUnitKM, 0, ) return OccultationPathPoint{ - Time: occultationTTToLocation(tt, location), - Longitude: lon, - Latitude: lat, - MoonAltitude: occultationAltitude(tt, Observer{Longitude: lon, Latitude: lat}, normalizeRA(moonRA), moonDec), - WidthKM: width, + Time: occultationTTToLocation(tt, location), + Longitude: lon, + Latitude: lat, + MoonAltitude: occultationAltitudeWithSidereal( + siderealDegrees, Observer{Longitude: lon, Latitude: lat}, normalizeRA(moonRA), moonDec, + ), + WidthKM: width, } } +func occultationPathDeduplicateBoundaryPoints( + first, second []OccultationPathPoint, +) ([]OccultationPathPoint, []OccultationPathPoint) { + if len(first) != len(second) || len(first) == 0 { + return first, second + } + uniqueFirst := make([]OccultationPathPoint, 0, len(first)) + uniqueSecond := make([]OccultationPathPoint, 0, len(second)) + for index := range first { + if len(uniqueFirst) > 0 && !first[index].Time.After(uniqueFirst[len(uniqueFirst)-1].Time) { + continue + } + if len(uniqueFirst) > 0 && first[index].Time.Sub(uniqueFirst[len(uniqueFirst)-1].Time) < time.Second && + occultationPathDistanceKM(first[index], uniqueFirst[len(uniqueFirst)-1]) < occultationPathBoundaryMergeKM && + occultationPathDistanceKM(second[index], uniqueSecond[len(uniqueSecond)-1]) < occultationPathBoundaryMergeKM { + uniqueFirst[len(uniqueFirst)-1] = first[index] + uniqueSecond[len(uniqueSecond)-1] = second[index] + continue + } + uniqueFirst = append(uniqueFirst, first[index]) + uniqueSecond = append(uniqueSecond, second[index]) + } + return uniqueFirst, uniqueSecond +} + func centerTimeTT(value time.Time) float64 { return occultationTimeToTT(value) } type occultationPathWidthFunc func(float64) (float64, bool) @@ -1011,22 +1532,45 @@ func occultationEarthLineIntersectionWithTolerance(origin, direction occultation func occultationPathGeodetic(tt float64, vector occultationPathVector) (float64, float64) { ut := TD2UT(tt, false) - gst := ApparentSiderealTime(ut) * 15 - longitude := normalizeLongitude(math.Atan2(vector.y, vector.x)*180/math.Pi - gst) - latitude := math.Atan2( + return occultationPathGeodeticWithSidereal(vector, ApparentSiderealTime(ut)*15) +} + +func occultationPathGeodeticWithSidereal( + vector occultationPathVector, + siderealDegrees float64, +) (float64, float64) { + longitude := normalizeLongitude(math.Atan2(vector.y, vector.x)*180/math.Pi - siderealDegrees) + return longitude, occultationPathGeodeticLatitude(vector) +} + +func occultationPathGeodeticLatitude(vector occultationPathVector) float64 { + return math.Atan2( vector.z, occultationPathEarthPolarRatio*occultationPathEarthPolarRatio*math.Hypot(vector.x, vector.y), ) * 180 / math.Pi - return longitude, latitude } func occultationPathEarthFixedVector(tt float64, vector occultationPathVector) occultationPathVector { + return occultationPathEarthFixedVectorWithRotation(vector, occultationPathEarthRotationAt(tt)) +} + +type occultationPathEarthRotation struct { + cosine float64 + sine float64 +} + +func occultationPathEarthRotationAt(tt float64) occultationPathEarthRotation { angle := ApparentSiderealTime(TD2UT(tt, false)) * 15 * math.Pi / 180 - cosAngle := math.Cos(angle) - sinAngle := math.Sin(angle) + return occultationPathEarthRotation{cosine: math.Cos(angle), sine: math.Sin(angle)} +} + +func occultationPathEarthFixedVectorWithRotation( + vector occultationPathVector, + rotation occultationPathEarthRotation, +) occultationPathVector { return occultationPathVector{ - x: cosAngle*vector.x + sinAngle*vector.y, - y: -sinAngle*vector.x + cosAngle*vector.y, + x: rotation.cosine*vector.x + rotation.sine*vector.y, + y: -rotation.sine*vector.x + rotation.cosine*vector.y, z: vector.z, } } diff --git a/basic/occultation_path_ephemeris.go b/basic/occultation_path_ephemeris.go new file mode 100644 index 0000000..416f8f9 --- /dev/null +++ b/basic/occultation_path_ephemeris.go @@ -0,0 +1,134 @@ +package basic + +import "math" + +const ( + occultationDenseEphemerisNodeCount = 193 + occultationDenseEphemerisStepDays = 30.0 / 1440.0 + occultationDenseEphemerisTolerance = 5e-11 +) + +// Width extrema near a limb can change branches under tiny frame differences. +// Keep this metadata on the full-term model without moving the traced points. +func correctOccultationCenterWidths(points []OccultationPathPoint, frameAt occultationPathFrameFunc) { + for index := range points { + _, _, width, ok := occultationPathLimitsAndWidthForFrame(centerTimeTT(points[index].Time), frameAt) + points[index].WidthKM = 0 + if ok { + points[index].WidthKM = width + } + } +} + +func (cache *starOccultationEventCache) preparePathEphemeris(center float64, algorithm OccultationPathAlgorithm) { + if algorithm != OccultationPathAlgorithmExact { + nodes := newDenseOccultationEphemerisNodes(center, func(tt float64) ([3]float64, [3]float64) { + state := starOccultationEphemerisStateAt(tt, cache.star) + moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM) + distance := state.starDistanceKM + if distance <= 0 { + distance = 1 + } + target := occultationPathRaDecVector(state.starRA, state.starDec, distance) + return [3]float64{moon.x, moon.y, moon.z}, [3]float64{target.x, target.y, target.z} + }) + if len(nodes) > 0 { + cache.local = &starOccultationLocalEphemeris{star: cache.star, nodes: nodes, dense: true} + } + } + cache.prepareLocalEphemeris(center) +} + +func (cache *planetOccultationEventCache) preparePathEphemeris(center float64, algorithm OccultationPathAlgorithm) { + if algorithm != OccultationPathAlgorithmExact { + nodes := newDenseOccultationEphemerisNodes(center, func(tt float64) ([3]float64, [3]float64) { + state := planetOccultationEphemerisStateAt(tt, cache.config) + moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM) + target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM) + return [3]float64{moon.x, moon.y, moon.z}, [3]float64{target.x, target.y, target.z} + }) + if len(nodes) > 0 { + cache.local = &planetOccultationLocalEphemeris{nodes: nodes, dense: true} + } + } + cache.prepareLocalEphemeris(center) +} + +// Midpoint checks reject inaccurate or invalid tables before any path state +// is cached. They are sampled safeguards, not a rigorous global error bound. +func newDenseOccultationEphemerisNodes( + center float64, + sample func(float64) ([3]float64, [3]float64), +) []localEphemerisVectorNode { + if !finite(center) { + return nil + } + nodes := make([]localEphemerisVectorNode, occultationDenseEphemerisNodeCount) + for index := range nodes { + tt := center + float64(index-len(nodes)/2)*occultationDenseEphemerisStepDays + first, next := sample(tt) + if !finiteVector3(first) || !finiteVector3(next) { + return nil + } + nodes[index] = localEphemerisVectorNode{tt: tt, first: first, next: next} + } + for index := 1; index < len(nodes); index++ { + tt := (nodes[index-1].tt + nodes[index].tt) / 2 + first, next, ok := interpolateDenseOccultationVectors(nodes, tt) + exactFirst, exactNext := sample(tt) + if !ok || !denseOccultationVectorAccurate(first, exactFirst) || !denseOccultationVectorAccurate(next, exactNext) { + return nil + } + } + return nodes +} + +func denseOccultationVectorAccurate(approximate, exact [3]float64) bool { + if !finiteVector3(approximate) || !finiteVector3(exact) { + return false + } + norm, difference := 0.0, 0.0 + for index := range exact { + norm += exact[index] * exact[index] + delta := approximate[index] - exact[index] + difference += delta * delta + } + return norm > 0 && finite(norm) && difference <= norm*occultationDenseEphemerisTolerance*occultationDenseEphemerisTolerance +} + +func interpolateDenseOccultationVectors( + nodes []localEphemerisVectorNode, + tt float64, +) ([3]float64, [3]float64, bool) { + const points = 6 + if len(nodes) < points || !finite(tt) || tt < nodes[0].tt || tt > nodes[len(nodes)-1].tt { + return [3]float64{}, [3]float64{}, false + } + step := nodes[1].tt - nodes[0].tt + if !finite(step) || step <= 0 { + return [3]float64{}, [3]float64{}, false + } + start := int(math.Floor((tt-nodes[0].tt)/step)) - 2 + if start < 0 { + start = 0 + } + if start > len(nodes)-points { + start = len(nodes) - points + } + var first, next [3]float64 + for point := 0; point < points; point++ { + weight := 1.0 + // Rounded Julian days are not exactly uniformly spaced. Preserve the + // actual node times, leaving the legacy/solar interpolator unchanged. + for other := 0; other < points; other++ { + if other != point { + weight *= (tt - nodes[start+other].tt) / (nodes[start+point].tt - nodes[start+other].tt) + } + } + for coordinate := 0; coordinate < 3; coordinate++ { + first[coordinate] += weight * nodes[start+point].first[coordinate] + next[coordinate] += weight * nodes[start+point].next[coordinate] + } + } + return first, next, finiteVector3(first) && finiteVector3(next) +} diff --git a/basic/occultation_path_ephemeris_test.go b/basic/occultation_path_ephemeris_test.go new file mode 100644 index 0000000..d63742d --- /dev/null +++ b/basic/occultation_path_ephemeris_test.go @@ -0,0 +1,155 @@ +package basic + +import ( + "errors" + "math" + "reflect" + "testing" + "time" +) + +func TestOccultationPathAlgorithmOptions(t *testing.T) { + for _, algorithm := range []OccultationPathAlgorithm{"", OccultationPathAlgorithmOptimized, OccultationPathAlgorithmExact} { + options := OccultationPathOptions{Algorithm: algorithm} + if err := options.Validate(); err != nil { + t.Fatal(err) + } + want := algorithm + if want == "" { + want = OccultationPathAlgorithmOptimized + } + if got := normalizeOccultationPathOptions(options).Algorithm; got != want { + t.Fatalf("algorithm=%q, want %q", got, want) + } + } + if err := (OccultationPathOptions{Algorithm: "unknown"}).Validate(); !errors.Is(err, ErrInvalidOccultationInput) { + t.Fatalf("invalid algorithm: %v", err) + } +} + +func TestOccultationPathEphemerisBranches(t *testing.T) { + center := occultationTimeToTT(time.Date(2025, 1, 5, 12, 0, 0, 0, time.UTC)) + config, _ := planetOccultationConfigFor(OccultationSaturn) + star := hr4799OccultationCoordinateForTest() + for _, algorithm := range []OccultationPathAlgorithm{OccultationPathAlgorithmExact, OccultationPathAlgorithmOptimized} { + t.Run(string(algorithm), func(t *testing.T) { + planetCache := newPlanetOccultationEventCache(config) + starCache := newStarOccultationEventCache(star) + planetCache.preparePathEphemeris(center, algorithm) + starCache.preparePathEphemeris(center, algorithm) + dense := algorithm == OccultationPathAlgorithmOptimized + if planetCache.local.dense != dense || starCache.local.dense != dense { + t.Fatalf("unexpected selected tables: planet=%v star=%v", planetCache.local.dense, starCache.local.dense) + } + if !dense { + if !reflect.DeepEqual(planetCache.local, newPlanetOccultationLocalEphemeris(center, config)) || + !reflect.DeepEqual(starCache.local, newStarOccultationLocalEphemeris(center, star)) { + t.Fatal("exact branch changed the legacy predictor") + } + } + for _, tt := range []float64{center + .03123, center + 3} { + planetState := planetCache.stateAt(tt) + starState := starCache.stateAt(tt) + if dense && tt < center+2 { + wantPlanet, _ := planetCache.local.stateAt(tt) + wantStar, _ := starCache.local.stateAt(tt) + if planetState != wantPlanet || starState != wantStar { + t.Fatal("optimized cache did not use its dense table") + } + } else if planetState != planetOccultationEphemerisStateAt(tt, config) || starState != starOccultationEphemerisStateAt(tt, star) { + t.Fatal("exact branch or out-of-window fallback changed") + } + } + }) + } + // The independent instant API constructs these caches without a path table. + if newPlanetOccultationEventCache(config).local != nil || newStarOccultationEventCache(star).local != nil { + t.Fatal("instant caches must remain exact") + } +} + +func TestDenseOccultationTableRejectsInvalidAndUnresolvedData(t *testing.T) { + center := 2451545.0 + polynomial := func(tt float64) ([3]float64, [3]float64) { + u := tt - center + return [3]float64{1 + u*u, 2 + u, 3}, [3]float64{7, 8 + u*u*u, 9} + } + if nodes := newDenseOccultationEphemerisNodes(center, polynomial); len(nodes) != occultationDenseEphemerisNodeCount { + t.Fatal("smooth table rejected") + } + for _, sample := range []func(float64) ([3]float64, [3]float64){ + func(float64) ([3]float64, [3]float64) { return [3]float64{}, [3]float64{} }, + func(float64) ([3]float64, [3]float64) { return [3]float64{math.NaN()}, [3]float64{1} }, + func(tt float64) ([3]float64, [3]float64) { + return [3]float64{2 + math.Sin((tt-center)*800), 1, 1}, [3]float64{1, 1, 1} + }, + } { + if nodes := newDenseOccultationEphemerisNodes(center, sample); nodes != nil { + t.Fatal("invalid or under-resolved table accepted") + } + } + if nodes := newDenseOccultationEphemerisNodes(math.NaN(), polynomial); nodes != nil { + t.Fatal("invalid center accepted") + } +} + +func TestDenseOccultationPlanetVectorsAgainstFullEphemerides(t *testing.T) { + center := occultationTimeToTT(time.Date(2025, 1, 5, 12, 0, 0, 0, time.UTC)) + for _, planet := range []OccultationPlanet{OccultationMercury, OccultationVenus, OccultationMars, OccultationJupiter, OccultationSaturn, OccultationUranus, OccultationNeptune} { + t.Run(planet.String(), func(t *testing.T) { + config, _ := planetOccultationConfigFor(planet) + cache := newPlanetOccultationEventCache(config) + cache.preparePathEphemeris(center, OccultationPathAlgorithmOptimized) + if !cache.local.dense { + t.Fatal("reference event did not accept a dense table") + } + // Probe off-node, off-midpoint times including both window ends. + for _, offset := range []float64{-1.999, -1.9731, -.314159, .03712, 1.9713, 1.999} { + tt := center + offset + moon, target, ok := cache.local.vectorsAt(tt) + exact := planetOccultationEphemerisStateAt(tt, config) + em := occultationPathRaDecVector(exact.moonRA, exact.moonDec, exact.moonDistanceKM) + et := occultationPathRaDecVector(exact.planetRA, exact.planetDec, exact.planetDistanceKM) + if !ok || !denseOccultationVectorAccurate(moon, [3]float64{em.x, em.y, em.z}) || + !denseOccultationVectorAccurate(target, [3]float64{et.x, et.y, et.z}) { + t.Fatalf("interpolation exceeds the vector tolerance at offset %g", offset) + } + } + }) + } +} + +func TestDenseOccultationInterpolationUsesActualNodeTimes(t *testing.T) { + nodes := make([]localEphemerisVectorNode, 9) + for i := range nodes { + tt := 2451545 + float64(i)*occultationDenseEphemerisStepDays + u := tt - 2451545 + nodes[i] = localEphemerisVectorNode{tt: tt, first: [3]float64{1, u, u * u}, next: [3]float64{3, u * u * u, 7}} + } + for _, tt := range []float64{nodes[0].tt, nodes[8].tt, nodes[4].tt + .00321} { + first, next, ok := interpolateDenseOccultationVectors(nodes, tt) + u := tt - 2451545 + if !ok || math.Abs(first[1]-u) > 1e-14 || math.Abs(first[2]-u*u) > 1e-14 || math.Abs(next[1]-u*u*u) > 1e-14 { + t.Fatalf("interpolation at %.10f: %v %v %v", tt, first, next, ok) + } + } + for _, tt := range []float64{math.NaN(), nodes[0].tt - .1, nodes[8].tt + .1} { + if _, _, ok := interpolateDenseOccultationVectors(nodes, tt); ok { + t.Fatal("out-of-window interpolation accepted") + } + } +} + +func TestOccultationPathDefaultMatchesOptimized(t *testing.T) { + start := time.Date(2025, 7, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600)) + options := OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, DisableFootprints: true} + want, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, options) + if err != nil || len(want) != 1 { + t.Fatalf("default paths=%d err=%v", len(want), err) + } + options.Algorithm = OccultationPathAlgorithmOptimized + got, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, options) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("explicit optimized differs from default: %v", err) + } +} diff --git a/basic/occultation_path_test.go b/basic/occultation_path_test.go new file mode 100644 index 0000000..6fc6bf2 --- /dev/null +++ b/basic/occultation_path_test.go @@ -0,0 +1,82 @@ +package basic + +import ( + "testing" + "time" +) + +// 稠密星历表必须被接受并覆盖完整的路径搜索窗口,否则路径样本会静默退回精确星历。 +func TestDenseOccultationTableCoversPathSearchWindow(t *testing.T) { + center := occultationTimeToTT(time.Date(2025, time.January, 5, 12, 0, 0, 0, time.UTC)) + const toleranceDays = 1e-9 + for _, planet := range []OccultationPlanet{ + OccultationMercury, OccultationVenus, OccultationMars, OccultationJupiter, + OccultationSaturn, OccultationUranus, OccultationNeptune, + } { + config, ok := planetOccultationConfigFor(planet) + if !ok { + t.Fatalf("%s occultation config is unavailable", planet) + } + cache := newPlanetOccultationEventCache(config) + cache.preparePathEphemeris(center, OccultationPathAlgorithmOptimized) + if !cache.local.dense { + t.Fatalf("%s dense path table was rejected", planet) + } + nodes := cache.local.nodes + if len(nodes) < 2 { + t.Fatalf("%s dense path table has %d nodes", planet, len(nodes)) + } + if first := center - occultationPathSearchSpanDays; nodes[0].tt > first+toleranceDays { + t.Fatalf("%s dense table starts at %.9f, want at most %.9f", planet, nodes[0].tt, first) + } + if last := center + occultationPathSearchSpanDays; nodes[len(nodes)-1].tt < last-toleranceDays { + t.Fatalf("%s dense table ends at %.9f, want at least %.9f", planet, nodes[len(nodes)-1].tt, last) + } + // 节点时刻是 JD 浮点相加的结果,相邻差只近似等于步长(约 1e-10 天)。 + if step := nodes[1].tt - nodes[0].tt; step < occultationDenseEphemerisStepDays-1e-8 || + step > occultationDenseEphemerisStepDays+1e-8 { + t.Fatalf("%s dense table step=%g, want %g", planet, step, occultationDenseEphemerisStepDays) + } + } +} + +func BenchmarkOccultationPlanetPathsMars20250729(b *testing.B) { + zone := time.FixedZone("UTC+8", 8*3600) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + options := OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute, + } + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, options) + if err != nil || len(paths) != 1 { + b.Fatalf("paths=%d err=%v", len(paths), err) + } + } +} + +func BenchmarkOccultationPlanetPathsSaturnDefault(b *testing.B) { + start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC) + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationSaturn, OccultationPathOptions{}) + if err != nil || len(paths) != 1 { + b.Fatalf("paths=%d err=%v", len(paths), err) + } + } +} + +func BenchmarkOccultationPathLimitsAndWidth(b *testing.B) { + config, _ := planetOccultationConfigFor(OccultationSaturn) + cache := newPlanetOccultationEventCache(config) + tt := occultationTimeToTT(time.Date(2025, time.January, 5, 4, 0, 0, 0, time.UTC)) + cache.preparePathEphemeris(tt, OccultationPathAlgorithmOptimized) + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + _, _, _, _ = occultationPathLimitsAndWidthForFrame(tt, cache.outerFrameAt) + } +} diff --git a/basic/occultation_planet.go b/basic/occultation_planet.go index 7f4c69e..9e645a9 100644 --- a/basic/occultation_planet.go +++ b/basic/occultation_planet.go @@ -19,6 +19,7 @@ const ( type planetOccultationConfig struct { planet OccultationPlanet + planetIndex int equatorialRadiusKM float64 apparentRaDecN func(float64, int) (float64, float64) earthDistanceN func(float64, int) float64 @@ -41,6 +42,16 @@ type planetOccultationState struct { valid bool } +func (state planetOccultationState) movingDiskContactState() movingDiskContactState { + return movingDiskContactState{ + separation: state.separationArcsec, + occultingOuterRadius: state.moonSemidiameter, + occultingInnerRadius: state.moonSemidiameter, + targetRadius: state.planetSemidiameter, + valid: state.valid, + } +} + // FindPlanetOccultations 搜索固定观测点的有限盘面行星月掩。 // 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。目标位置、视差和视半径会在每次候选、掩甚和接触计算时重新计算。 // FindPlanetOccultations searches one finite-disk planet at a fixed observing site. @@ -137,36 +148,43 @@ func planetOccultationConfigFor(planet OccultationPlanet) (planetOccultationConf config := planetOccultationConfig{planet: planet} switch planet { case OccultationMercury: + config.planetIndex = 1 config.equatorialRadiusKM = mercuryEquatorialRadiusKM config.apparentRaDecN = MercuryApparentRaDecN config.earthDistanceN = EarthMercuryAwayN config.semidiameterN = MercurySemidiameterN case OccultationVenus: + config.planetIndex = 2 config.equatorialRadiusKM = venusEquatorialRadiusKM config.apparentRaDecN = VenusApparentRaDecN config.earthDistanceN = EarthVenusAwayN config.semidiameterN = VenusSemidiameterN case OccultationMars: + config.planetIndex = 3 config.equatorialRadiusKM = marsEquatorialRadiusKM config.apparentRaDecN = MarsApparentRaDecN config.earthDistanceN = EarthMarsAwayN config.semidiameterN = MarsSemidiameterN case OccultationJupiter: + config.planetIndex = 4 config.equatorialRadiusKM = jupiterEquatorialRadiusKM config.apparentRaDecN = JupiterApparentRaDecN config.earthDistanceN = EarthJupiterAwayN config.semidiameterN = JupiterSemidiameterN case OccultationSaturn: + config.planetIndex = 5 config.equatorialRadiusKM = saturnEquatorialRadiusKM config.apparentRaDecN = SaturnApparentRaDecN config.earthDistanceN = EarthSaturnAwayN config.semidiameterN = SaturnSemidiameterN case OccultationUranus: + config.planetIndex = 6 config.equatorialRadiusKM = uranusEquatorialRadiusKM config.apparentRaDecN = UranusApparentRaDecN config.earthDistanceN = EarthUranusAwayN config.semidiameterN = UranusSemidiameterN case OccultationNeptune: + config.planetIndex = 7 config.equatorialRadiusKM = neptuneEquatorialRadiusKM config.apparentRaDecN = NeptuneApparentRaDecN config.earthDistanceN = EarthNeptuneAwayN @@ -262,7 +280,16 @@ func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planet } point, pointOK := occultationPathCenterPointForFrame(greatestTT, frameAt, time.UTC) if !pointOK { - point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC) + // 非中心事件的月影轴不与地球椭球相交,最佳站心应取离影轴最近的椭球点; + // 外接触切点位于掩带边缘,会把掩甚站心推出掩食之外,只作最后回退。 + // For a non-central event the shadow axis misses the ellipsoid, so the + // best station is the ellipsoid point nearest to that axis. The outer + // contact tangent sits on the band edge and moves the greatest station + // outside the occultation, so it remains the last resort only. + point, pointOK = occultationPathTrackPointForFrame(greatestTT, frameAt, time.UTC) + if !pointOK { + point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC) + } } if !pointOK { return 0, Observer{}, 0, false @@ -312,8 +339,10 @@ func planetOccultationInfoAtGreatest( return info, true } - externalImmersionTT, externalImmersionOK := planetOccultationContact(greatestTT, -1, false, config, observer) - externalEmersionTT, externalEmersionOK := planetOccultationContact(greatestTT, 1, false, config, observer) + contactEvaluator := newPlanetOccultationContactEvaluator(config, observer) + contactEvaluator.prime(greatestTT, state.movingDiskContactState(), state.valid) + externalImmersionTT, externalImmersionOK := planetOccultationContactWithEvaluator(greatestTT, -1, false, contactEvaluator) + externalEmersionTT, externalEmersionOK := planetOccultationContactWithEvaluator(greatestTT, 1, false, contactEvaluator) if !externalImmersionOK || !externalEmersionOK || externalEmersionTT <= externalImmersionTT { return PlanetOccultationInfo{}, false } @@ -321,8 +350,8 @@ func planetOccultationInfoAtGreatest( info.ExternalEmersion = occultationTTToLocation(externalEmersionTT, location) if state.internalContactMetric < -planetOccultationGrazingToleranceArcsec { - internalImmersionTT, internalImmersionOK := planetOccultationContact(greatestTT, -1, true, config, observer) - internalEmersionTT, internalEmersionOK := planetOccultationContact(greatestTT, 1, true, config, observer) + internalImmersionTT, internalImmersionOK := planetOccultationContactWithEvaluator(greatestTT, -1, true, contactEvaluator) + internalEmersionTT, internalEmersionOK := planetOccultationContactWithEvaluator(greatestTT, 1, true, contactEvaluator) if !internalImmersionOK || !internalEmersionOK || internalImmersionTT <= externalImmersionTT || internalEmersionTT >= externalEmersionTT || internalImmersionTT >= greatestTT || internalEmersionTT <= greatestTT { @@ -370,14 +399,23 @@ func planetOccultationStateAt(tt float64, config planetOccultationConfig, observ return planetOccultationState{} } separation := angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600 + contactState := movingDiskContactState{ + separation: separation, + occultingOuterRadius: moonRadius, + occultingInnerRadius: moonRadius, + targetRadius: planetRadius, + valid: movingDiskContactStateValid( + separation, moonRadius, moonRadius, planetRadius, + ), + } return planetOccultationState{ position: position, separationArcsec: separation, moonSemidiameter: moonRadius, planetSemidiameter: planetRadius, - externalContactMetric: separation - (moonRadius + planetRadius), - internalContactMetric: separation - (moonRadius - planetRadius), - valid: finite(separation), + externalContactMetric: contactState.externalContactGap(), + internalContactMetric: contactState.internalContactGap(), + valid: contactState.valid, } } @@ -402,14 +440,6 @@ func planetOccultationExternalContactMetric(tt float64, config planetOccultation return state.externalContactMetric } -func planetMoonSeparationArcsec(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 { - position := planetMoonPositionAt(tt, config, observer, n) - if !position.valid { - return math.Inf(1) - } - return angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600 -} - func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, observer *Observer, safetyMarginArcsec float64) bool { state := planetOccultationStateAt(tt, config, observer, -1) if !state.valid { @@ -421,73 +451,41 @@ func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, o return math.Abs(moonLatitude-planetLatitude)*3600 <= limit } -func planetOccultationContact( +func newPlanetOccultationContactEvaluator( + config planetOccultationConfig, + observer Observer, +) *movingDiskContactEvaluator { + return newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) { + state := planetOccultationStateAt(tt, config, &observer, -1) + return state.movingDiskContactState(), state.valid + }) +} + +func planetOccultationContactWithEvaluator( greatestTT float64, direction int, internal bool, - config planetOccultationConfig, - observer Observer, + evaluator *movingDiskContactEvaluator, ) (float64, bool) { if direction != -1 && direction != 1 { return math.NaN(), false } - metric := func(tt float64) float64 { - state := planetOccultationStateAt(tt, config, &observer, -1) - if !state.valid { - return math.NaN() - } - if internal { - return state.internalContactMetric - } - return state.externalContactMetric + metric := func(tt float64) (float64, bool) { + return evaluator.gap(tt, internal) } - nearTT := greatestTT - nearValue := metric(nearTT) - if !finite(nearValue) || nearValue > 0 { + root, ok := occultationMovingDiskEngine().contactRoot( + greatestTT, + float64(direction), + planetOccultationContactStepDays, + planetOccultationContactSpanDays, + planetOccultationRootToleranceDays, + metric, + 64, + ) + if !ok { return math.NaN(), false } - maxSteps := int(math.Ceil(planetOccultationContactSpanDays / planetOccultationContactStepDays)) - if maxSteps > planetOccultationMaxContactSteps { - maxSteps = planetOccultationMaxContactSteps - } - for i := 1; i <= maxSteps; i++ { - farTT := greatestTT + float64(direction*i)*planetOccultationContactStepDays - farValue := metric(farTT) - if !finite(farValue) { - continue - } - if farValue >= 0 { - return planetOccultationRoot(nearTT, farTT, nearValue, farValue, metric) - } - nearTT, nearValue = farTT, farValue - } - return math.NaN(), false -} - -func planetOccultationRoot( - leftTT, rightTT, leftValue, rightValue float64, - metric func(float64) float64, -) (float64, bool) { - if leftTT > rightTT { - leftTT, rightTT = rightTT, leftTT - leftValue, rightValue = rightValue, leftValue - } - if !finite(leftValue) || !finite(rightValue) || leftValue*rightValue > 0 { - return math.NaN(), false - } - for i := 0; i < 64 && math.Abs(rightTT-leftTT) > planetOccultationRootToleranceDays; i++ { - midTT := (leftTT + rightTT) / 2 - midValue := metric(midTT) - if !finite(midValue) { - return math.NaN(), false - } - if leftValue*midValue <= 0 { - rightTT, rightValue = midTT, midValue - } else { - leftTT, leftValue = midTT, midValue - } - } - return (leftTT + rightTT) / 2, true + return root, true } func planetOccultationCoarseStepDays(options OccultationSearchOptions) float64 { diff --git a/basic/occultation_planet_footprint.go b/basic/occultation_planet_footprint.go index 6ea02ac..acaccc0 100644 --- a/basic/occultation_planet_footprint.go +++ b/basic/occultation_planet_footprint.go @@ -2,17 +2,34 @@ package basic import ( "math" + "sort" "time" ) const ( planetOccultationFootprintBoundaryPoints = 180 planetOccultationHorizonPoints = 360 + planetOccultationTimelineBoundaryPoints = 180 + planetOccultationTimelineHorizonPoints = 180 + planetOccultationTimelineTargetSpacingKM = 300.0 planetOccultationFootprintMaxSamples = 360 + planetOccultationFootprintTargetStep = time.Minute + planetOccultationBandBoundaryPoints = 360 + planetOccultationBandHorizonPoints = 360 + planetOccultationBandMaxSamples = 128 + planetOccultationBandTargetStep = 5 * time.Minute + planetOccultationBandTargetSpacingKM = 50.0 + planetOccultationBandAdaptiveMaxDepth = 12 + planetOccultationCenterCapRadiusKM = 60.0 + planetOccultationCenterCapPoints = 16 + // 二分到 1e-9 度(约 0.1 毫米)即停,保证插入的切点位于地平线内侧。 + planetOccultationFootprintHorizonToleranceDeg = 1e-9 + planetOccultationFootprintHorizonSearchSteps = 48 ) type planetOccultationFootprintSample struct { point OccultationPathPoint + theta float64 ok bool } @@ -22,43 +39,427 @@ func planetOccultationFootprints( options OccultationPathOptions, location *time.Location, ) []PlanetOccultationFootprint { - stepDays := float64(options.Step) / float64(24*time.Hour) - times := occultationPathSampleTimesWithLimit( - startTT, endTT, greatestTT, stepDays, planetOccultationFootprintMaxSamples, + return planetOccultationFootprintsWithSampling( + startTT, endTT, greatestTT, frameAt, location, + planetOccultationFootprintSampleStepDays(options), planetOccultationFootprintMaxSamples, + planetOccultationFootprintBoundaryPoints, planetOccultationHorizonPoints, 0, false, ) +} + +func planetOccultationTimelineFootprints( + startTT, endTT, greatestTT float64, + frameAt occultationPathFrameFunc, + options OccultationPathOptions, + location *time.Location, +) []PlanetOccultationFootprint { + return planetOccultationFootprintsWithSampling( + startTT, endTT, greatestTT, frameAt, location, + planetOccultationFootprintSampleStepDays(options), planetOccultationFootprintMaxSamples, + planetOccultationTimelineBoundaryPoints, planetOccultationTimelineHorizonPoints, + planetOccultationTimelineTargetSpacingKM, false, + ) +} + +func planetOccultationBandFootprints( + startTT, endTT, greatestTT float64, + frameAt occultationPathFrameFunc, + location *time.Location, + additionalTimes []float64, +) []PlanetOccultationFootprint { + times := planetOccultationBandSampleTimesWithAdditionalTimes( + startTT, endTT, greatestTT, additionalTimes, + ) + return planetOccultationFootprintsAtTimes( + times, frameAt, location, + planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints, + planetOccultationBandTargetSpacingKM, 150, + ) +} + +func planetOccultationBandFootprintsWithContourTimes( + startTT, endTT, greatestTT float64, + frameAt occultationPathFrameFunc, + location *time.Location, + additionalTimes []float64, +) ([]PlanetOccultationFootprint, []float64) { + contourTimes := occultationAppendSampleTimes(nil, additionalTimes...) + footprints := planetOccultationBandFootprints( + startTT, endTT, greatestTT, frameAt, location, contourTimes, + ) + transitionTimes := occultationFootprintTransitionTimes(footprints) + if len(transitionTimes) == 0 { + return footprints, contourTimes + } + contourTimes = occultationAppendSampleTimes(contourTimes, transitionTimes...) + // The first pass already contains the complete five-minute grid and all + // rise/set contour times. The second pass used to recompute every one of + // those expensive finite-disk footprints just to add the handful of + // visibility-transition instants. Evaluate only those new instants, merge + // them into the existing grid, then run the same endpoint refinement. + transitionFootprints := planetOccultationFootprintsAtTimes( + transitionTimes, frameAt, location, + planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints, + planetOccultationBandTargetSpacingKM, 150, + ) + footprints = mergePlanetOccultationFootprintsByExactTime( + footprints, transitionFootprints, + ) + footprints = refinePlanetOccultationFootprintTransitions( + footprints, frameAt, location, + planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints, + planetOccultationBandTargetSpacingKM, 150, + ) + return footprints, contourTimes +} + +func mergePlanetOccultationFootprintsByExactTime( + base, extra []PlanetOccultationFootprint, +) []PlanetOccultationFootprint { + if len(extra) == 0 { + return base + } + result := make([]PlanetOccultationFootprint, 0, len(base)+len(extra)) + result = append(result, base...) + result = append(result, extra...) + sort.SliceStable(result, func(i, j int) bool { + return result[i].Time.Before(result[j].Time) + }) + const tolerance = time.Second + for index := 1; index < len(result); { + if result[index].Closed == result[index-1].Closed && + result[index].Time.Sub(result[index-1].Time) <= tolerance { + result = append(result[:index], result[index+1:]...) + continue + } + index++ + } + return result +} + +func planetOccultationBandSampleTimesWithAdditionalTimes( + startTT, endTT, greatestTT float64, + additionalTimes []float64, +) []float64 { + times := planetOccultationBandSampleTimes( + startTT, endTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples, + ) + for _, tt := range additionalTimes { + if tt >= startTT && tt <= endTT && finite(tt) { + times = append(times, tt) + } + } + sort.Float64s(times) + return uniqueOccultationPathTimes(times) +} + +func planetOccultationBandSampleStepDays() float64 { + return float64(planetOccultationBandTargetStep) / float64(24*time.Hour) +} + +func planetOccultationFootprintsWithSampling( + startTT, endTT, greatestTT float64, + frameAt occultationPathFrameFunc, + location *time.Location, + stepDays float64, + maxSamples, boundaryPoints, horizonPoints int, + targetSpacingKM float64, + refineContacts bool, +) []PlanetOccultationFootprint { + times := occultationPathSampleTimesWithLimit(startTT, endTT, greatestTT, stepDays, maxSamples) + if refineContacts { + times = planetOccultationBandSampleTimes(startTT, endTT, greatestTT, stepDays, maxSamples) + } + return planetOccultationFootprintsAtTimes( + times, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM, 0, + ) +} + +func planetOccultationFootprintsAtTimes( + times []float64, + frameAt occultationPathFrameFunc, + location *time.Location, + boundaryPoints, horizonPoints int, + targetSpacingKM, maximumEndpointStepKM float64, +) []PlanetOccultationFootprint { footprints := make([]PlanetOccultationFootprint, 0, len(times)) for _, tt := range times { - footprint, ok := planetOccultationFootprintAt(tt, frameAt, location) + footprint, ok := planetOccultationFootprintAtWithResolution( + tt, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM, + ) if ok { footprints = append(footprints, footprint) } } - return footprints + return refinePlanetOccultationFootprintTransitions( + footprints, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM, maximumEndpointStepKM, + ) +} + +func refinePlanetOccultationFootprintTransitions( + footprints []PlanetOccultationFootprint, + frameAt occultationPathFrameFunc, + location *time.Location, + boundaryPoints, horizonPoints int, + targetSpacingKM float64, + maximumEndpointStepKM float64, +) []PlanetOccultationFootprint { + if len(footprints) < 2 || maximumEndpointStepKM <= 0 { + return footprints + } + const transitionTolerance = 100 * time.Millisecond + result := make([]PlanetOccultationFootprint, 0, len(footprints)+4) + result = append(result, footprints[0]) + for index := 1; index < len(footprints); index++ { + left, right := footprints[index-1], footprints[index] + if left.Closed != right.Closed && right.Time.Sub(left.Time) > transitionTolerance { + leftTT := occultationTimeToTT(left.Time) + rightTT := occultationTimeToTT(right.Time) + leftClosed := left.Closed + for rightTT-leftTT > float64(transitionTolerance)/float64(24*time.Hour) { + middleTT := (leftTT + rightTT) / 2 + middle, ok := planetOccultationFootprintAtWithResolution( + middleTT, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM, + ) + if !ok { + break + } + if middle.Closed == leftClosed { + leftTT = middleTT + left = middle + } else { + rightTT = middleTT + right = middle + } + } + result = appendPlanetOccultationFootprintEndpointRefinement( + result, left, frameAt, location, + boundaryPoints, horizonPoints, targetSpacingKM, maximumEndpointStepKM, 0, + ) + if right.Time.After(result[len(result)-1].Time) { + result = append(result, right) + } + result = appendPlanetOccultationFootprintEndpointRefinement( + result, footprints[index], frameAt, location, + boundaryPoints, horizonPoints, targetSpacingKM, maximumEndpointStepKM, 0, + ) + continue + } + result = appendPlanetOccultationFootprintEndpointRefinement( + result, footprints[index], frameAt, location, + boundaryPoints, horizonPoints, targetSpacingKM, maximumEndpointStepKM, 0, + ) + } + return deduplicateClosedPlanetOccultationFootprints(result, time.Second) +} + +func deduplicateClosedPlanetOccultationFootprints( + footprints []PlanetOccultationFootprint, + tolerance time.Duration, +) []PlanetOccultationFootprint { + if len(footprints) < 2 || tolerance <= 0 { + return footprints + } + result := footprints[:1] + for _, footprint := range footprints[1:] { + previous := result[len(result)-1] + if previous.Closed && footprint.Closed && footprint.Time.Sub(previous.Time) <= tolerance { + continue + } + result = append(result, footprint) + } + return result +} + +func appendPlanetOccultationFootprintEndpointRefinement( + result []PlanetOccultationFootprint, + right PlanetOccultationFootprint, + frameAt occultationPathFrameFunc, + location *time.Location, + boundaryPoints, horizonPoints int, + targetSpacingKM, maximumEndpointStepKM float64, + depth int, +) []PlanetOccultationFootprint { + left := result[len(result)-1] + if left.Closed || maximumEndpointStepKM <= 0 || depth >= 8 || + (left.Closed == right.Closed && occultationFootprintEndpointStepKM(left, right) <= maximumEndpointStepKM) { + if right.Time.After(left.Time) { + return append(result, right) + } + return result + } + middleTT := (occultationTimeToTT(left.Time) + occultationTimeToTT(right.Time)) / 2 + middle, ok := planetOccultationFootprintAtWithResolution( + middleTT, frameAt, location, boundaryPoints, horizonPoints, targetSpacingKM, + ) + if !ok || middle.Closed != left.Closed || !middle.Time.After(left.Time) || !right.Time.After(middle.Time) { + return append(result, right) + } + result = appendPlanetOccultationFootprintEndpointRefinement( + result, middle, frameAt, location, boundaryPoints, horizonPoints, + targetSpacingKM, maximumEndpointStepKM, depth+1, + ) + return appendPlanetOccultationFootprintEndpointRefinement( + result, right, frameAt, location, boundaryPoints, horizonPoints, + targetSpacingKM, maximumEndpointStepKM, depth+1, + ) +} + +func occultationFootprintEndpointStepKM(first, second PlanetOccultationFootprint) float64 { + if len(first.Boundaries) != 1 || len(second.Boundaries) != 1 || + len(first.Boundaries[0]) < 2 || len(second.Boundaries[0]) < 2 { + return math.Inf(1) + } + firstBoundary := first.Boundaries[0] + secondBoundary := second.Boundaries[0] + keep := math.Max( + occultationPathDistanceKM(firstBoundary[0], secondBoundary[0]), + occultationPathDistanceKM(firstBoundary[len(firstBoundary)-1], secondBoundary[len(secondBoundary)-1]), + ) + reverse := math.Max( + occultationPathDistanceKM(firstBoundary[0], secondBoundary[len(secondBoundary)-1]), + occultationPathDistanceKM(firstBoundary[len(firstBoundary)-1], secondBoundary[0]), + ) + return math.Min(keep, reverse) +} + +func occultationRiseSetEndpointTimes(curves []OccultationRiseSetCurve) []float64 { + times := make([]float64, 0, 2*len(curves)) + for _, curve := range curves { + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + times = append(times, + occultationTimeToTT(segment[0].Time), + occultationTimeToTT(segment[len(segment)-1].Time), + ) + } + } + sort.Float64s(times) + const tolerance = float64(time.Second) / float64(24*time.Hour) + result := times[:0] + counts := make([]int, 0, len(times)) + for _, tt := range times { + if len(result) == 0 || tt-result[len(result)-1] > tolerance { + result = append(result, tt) + counts = append(counts, 1) + continue + } + index := len(result) - 1 + counts[index]++ + result[index] += (tt - result[index]) / float64(counts[index]) + } + return result +} + +func occultationFootprintTransitionTimes(footprints []PlanetOccultationFootprint) []float64 { + if len(footprints) < 2 { + return nil + } + times := make([]float64, 0, 2) + for index := 1; index < len(footprints); index++ { + previous, current := footprints[index-1], footprints[index] + if previous.Closed == current.Closed { + continue + } + times = append(times, (occultationTimeToTT(previous.Time)+occultationTimeToTT(current.Time))/2) + } + sort.Float64s(times) + return uniqueOccultationPathTimes(times) +} + +func occultationAppendSampleTimes(base []float64, extras ...float64) []float64 { + if len(extras) == 0 { + return append([]float64(nil), base...) + } + times := append(append([]float64(nil), base...), extras...) + sort.Float64s(times) + return uniqueOccultationPathTimes(times) +} + +func planetOccultationBandSampleTimes( + startTT, endTT, greatestTT, stepDays float64, + maxSamples int, +) []float64 { + contactOffsets := [...]float64{ + 1.0 / 60, 1.0 / 20, 0.1, 0.2, 0.4, 0.7, 1.2, 2, 3, 3.5, 4, 4.5, + } + contactSamples := len(contactOffsets) + baseLimit := maxSamples - 2*contactSamples + if baseLimit < 3 { + baseLimit = 3 + } + times := occultationPathSampleTimesWithLimit(startTT, endTT, greatestTT, stepDays, baseLimit) + duration := endTT - startTT + if duration <= 0 { + return times + } + contactStep := math.Min(stepDays, duration/2) + for _, multiplier := range contactOffsets { + offset := math.Min(contactStep*multiplier, duration/2) + times = append(times, startTT+offset, endTT-offset) + } + sort.Float64s(times) + return uniqueOccultationPathTimes(times) +} + +func planetOccultationFootprintSampleStepDays(options OccultationPathOptions) float64 { + if options.IncludeFootprintTimeline { + step := options.FootprintTimelineStep + if step <= 0 { + step = 5 * time.Minute + } + return float64(step) / float64(24*time.Hour) + } + step := options.Step + if step <= 0 || step > planetOccultationFootprintTargetStep { + step = planetOccultationFootprintTargetStep + } + return float64(step) / float64(24*time.Hour) } func planetOccultationFootprintAt( tt float64, frameAt occultationPathFrameFunc, location *time.Location, +) (PlanetOccultationFootprint, bool) { + return planetOccultationFootprintAtWithResolution( + tt, frameAt, location, + planetOccultationFootprintBoundaryPoints, planetOccultationHorizonPoints, 0, + ) +} + +func planetOccultationFootprintAtWithResolution( + tt float64, + frameAt occultationPathFrameFunc, + location *time.Location, + boundaryPoints, horizonPoints int, + targetSpacingKM float64, ) (PlanetOccultationFootprint, bool) { frame, ok := frameAt(tt) if !ok { return PlanetOccultationFootprint{}, false } - samples := make([]planetOccultationFootprintSample, planetOccultationFootprintBoundaryPoints) + siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15 + samples := make([]planetOccultationFootprintSample, boundaryPoints) for index := range samples { theta := 2 * math.Pi * float64(index) / float64(len(samples)) - vector, _, valid := occultationPathBoundaryVector(frame, theta) - if valid { - samples[index] = planetOccultationFootprintSample{ - point: occultationPathPointFromVector(tt, vector, 0, location), - ok: true, - } - } + samples[index] = planetOccultationFootprintSampleAt( + tt, theta, frame, siderealDegrees, location, + ) } + if targetSpacingKM > 0 { + samples = refinePlanetOccultationFootprintSamples( + tt, frame, siderealDegrees, location, samples, targetSpacingKM, + ) + } + samples = appendPlanetOccultationFootprintHorizonCrossings( + tt, frame, siderealDegrees, location, samples, + ) segments, closed := planetOccultationFootprintSegments(samples) polygons := make([][]OccultationPathPoint, 0, len(segments)) + interiorPolygons := make([][]OccultationPathPoint, 0) for _, segment := range segments { if len(segment) < 2 { continue @@ -67,30 +468,226 @@ func planetOccultationFootprintAt( if closed { polygon = append(polygon, polygon[0]) } else { - polygon = append(polygon, planetOccultationHorizonArc(tt, frame, segment[len(segment)-1], segment[0], location)...) + polygon = append(polygon, planetOccultationHorizonArc( + tt, frame, segment, location, horizonPoints, + )...) } if len(polygon) >= 4 { polygons = append(polygons, polygon) } + if anchor, anchorOK := planetOccultationFootprintAnchor(tt, frame, segment, location); anchorOK && + !planetOccultationPathRingContains(polygon, anchor.Longitude, anchor.Latitude) { + // A sampled open cone can leave a small numerical gap between the + // visible boundary ring and the axis point. Keep the physical + // centerline covered with a small cap instead of allowing the + // greatest marker to fall into the partial annulus. + repairs := planetOccultationCenterRepair( + anchor, polygon, planetOccultationCenterCapRadiusKM, + planetOccultationCenterCapPoints, + ) + polygons = append(polygons, repairs...) + interiorPolygons = append(interiorPolygons, repairs...) + } } if len(polygons) == 0 { return PlanetOccultationFootprint{}, false } return PlanetOccultationFootprint{ - Time: occultationTTToLocation(tt, location), - Polygons: polygons, + Time: occultationTTToLocation(tt, location), + Polygons: polygons, + InteriorPolygons: interiorPolygons, + Boundaries: segments, + Closed: closed, }, true } +func planetOccultationFootprintSampleAt( + tt, theta float64, + frame occultationPathFrame, + siderealDegrees float64, + location *time.Location, +) planetOccultationFootprintSample { + sample := planetOccultationFootprintSample{theta: theta} + vector, _, valid := occultationPathBoundaryVector(frame, theta) + if !valid { + return sample + } + // frame.moon already contains the geocentric lunar vector for tt; + // avoid recomputing the full lunar ephemeris for every boundary point. + sample.point = occultationPathPointFromVectorWithMoonSidereal( + tt, vector, 0, frame.moon, siderealDegrees, location, + ) + sample.ok = true + return sample +} + +func refinePlanetOccultationFootprintSamples( + tt float64, + frame occultationPathFrame, + siderealDegrees float64, + location *time.Location, + samples []planetOccultationFootprintSample, + targetSpacingKM float64, +) []planetOccultationFootprintSample { + if len(samples) < 2 || targetSpacingKM <= 0 { + return samples + } + result := make([]planetOccultationFootprintSample, 0, len(samples)) + for index, left := range samples { + right := samples[(index+1)%len(samples)] + if index == len(samples)-1 { + right.theta += 2 * math.Pi + } + result = append(result, left) + result = append(result, refinePlanetOccultationFootprintInterval( + tt, frame, siderealDegrees, location, + left, right, targetSpacingKM, 0, + )...) + } + return result +} + +func refinePlanetOccultationFootprintInterval( + tt float64, + frame occultationPathFrame, + siderealDegrees float64, + location *time.Location, + left, right planetOccultationFootprintSample, + targetSpacingKM float64, + depth int, +) []planetOccultationFootprintSample { + if depth >= planetOccultationBandAdaptiveMaxDepth { + return nil + } + if left.ok && right.ok && occultationPathDistanceKM(left.point, right.point) <= occultationFootprintAdaptiveSpacingKM(left.point, right.point, targetSpacingKM) { + return nil + } + middle := planetOccultationFootprintSampleAt( + tt, (left.theta+right.theta)/2, frame, siderealDegrees, location, + ) + if !left.ok && !right.ok && !middle.ok { + return nil + } + result := refinePlanetOccultationFootprintInterval( + tt, frame, siderealDegrees, location, + left, middle, targetSpacingKM, depth+1, + ) + result = append(result, middle) + return append(result, refinePlanetOccultationFootprintInterval( + tt, frame, siderealDegrees, location, + middle, right, targetSpacingKM, depth+1, + )...) +} + +// occultationFootprintAdaptiveSpacingKM tightens the contact-limb sampling at +// high latitude. A fixed spherical spacing looks like a long straight chord +// after Web Mercator multiplies longitude by sec(latitude); the physical limb +// is unchanged, but the rendered edge loses its curvature. The cosine floor +// bounds the extra work near the poles while leaving equatorial events on the +// existing spacing budget. +func occultationFootprintAdaptiveSpacingKM( + first, second OccultationPathPoint, + targetSpacingKM float64, +) float64 { + if targetSpacingKM <= 0 { + return targetSpacingKM + } + latitude := math.Max(math.Abs(first.Latitude), math.Abs(second.Latitude)) * math.Pi / 180 + scale := math.Max(0.5, math.Cos(latitude)) + return targetSpacingKM * scale +} + +// planetOccultationFootprintSampleVisible 报告样本是否位于月球地平线以上;没有边界解的样本同样不可见。 +func planetOccultationFootprintSampleVisible(sample planetOccultationFootprintSample) bool { + return sample.ok && sample.point.MoonAltitude >= 0 +} + +// appendPlanetOccultationFootprintHorizonCrossings 在相邻可见/不可见样本之间插入地平线切点,环绕接缝同样处理。 +func appendPlanetOccultationFootprintHorizonCrossings( + tt float64, + frame occultationPathFrame, + siderealDegrees float64, + location *time.Location, + samples []planetOccultationFootprintSample, +) []planetOccultationFootprintSample { + if len(samples) < 2 { + return samples + } + // 不跨越地平线的足迹直接复用样本切片,避免每次装配都复制一份。 + straddles := false + for index, left := range samples { + right := samples[(index+1)%len(samples)] + if (left.point.MoonAltitude >= 0) != (right.point.MoonAltitude >= 0) { + straddles = true + break + } + } + if !straddles { + return samples + } + result := make([]planetOccultationFootprintSample, 0, len(samples)+4) + for index, left := range samples { + right := samples[(index+1)%len(samples)] + if index+1 == len(samples) { + right.theta += 2 * math.Pi + } + result = append(result, left) + if crossing, ok := planetOccultationFootprintHorizonCrossing( + tt, frame, siderealDegrees, location, left, right, + ); ok { + result = append(result, crossing) + } + } + return result +} + +// planetOccultationFootprintHorizonCrossing 二分相邻样本的月球高度过零点,只返回非负高度的样本。 +func planetOccultationFootprintHorizonCrossing( + tt float64, + frame occultationPathFrame, + siderealDegrees float64, + location *time.Location, + left, right planetOccultationFootprintSample, +) (planetOccultationFootprintSample, bool) { + if !left.ok || !right.ok || !finite(left.point.MoonAltitude) || !finite(right.point.MoonAltitude) { + return planetOccultationFootprintSample{}, false + } + if (left.point.MoonAltitude >= 0) == (right.point.MoonAltitude >= 0) { + return planetOccultationFootprintSample{}, false + } + above := left + if right.point.MoonAltitude >= 0 { + above = right + } + for step := 0; step < planetOccultationFootprintHorizonSearchSteps; step++ { + middle := planetOccultationFootprintSampleAt( + tt, (left.theta+right.theta)/2, frame, siderealDegrees, location, + ) + if !middle.ok { + break + } + if middle.point.MoonAltitude >= 0 { + above = middle + left = middle + } else { + right = middle + } + if math.Abs(above.point.MoonAltitude) <= planetOccultationFootprintHorizonToleranceDeg { + break + } + } + return above, true +} + func planetOccultationFootprintSegments( samples []planetOccultationFootprintSample, ) ([][]OccultationPathPoint, bool) { segments := make([][]OccultationPathPoint, 0, 2) current := make([]OccultationPathPoint, 0, len(samples)) - allValid := len(samples) > 0 + allVisible := len(samples) > 0 for _, sample := range samples { - if !sample.ok { - allValid = false + if !planetOccultationFootprintSampleVisible(sample) { + allVisible = false if len(current) > 0 { segments = append(segments, current) current = nil @@ -102,7 +699,9 @@ func planetOccultationFootprintSegments( if len(current) > 0 { segments = append(segments, current) } - if len(segments) > 1 && samples[0].ok && samples[len(samples)-1].ok { + if len(segments) > 1 && + planetOccultationFootprintSampleVisible(samples[0]) && + planetOccultationFootprintSampleVisible(samples[len(samples)-1]) { first := segments[0] last := segments[len(segments)-1] merged := make([]OccultationPathPoint, 0, len(last)+len(first)) @@ -111,78 +710,272 @@ func planetOccultationFootprintSegments( segments[0] = merged segments = segments[:len(segments)-1] } - return segments, allValid && len(segments) == 1 + return segments, allVisible && len(segments) == 1 } func planetOccultationHorizonArc( tt float64, frame occultationPathFrame, - from, to OccultationPathPoint, + segment []OccultationPathPoint, location *time.Location, + pointCount int, ) []OccultationPathPoint { - sublunarLongitude, sublunarLatitude := occultationPathGeodetic(tt, frame.moon) - circle := planetOccultationSphericalCircle( - occultationTTToLocation(tt, location), sublunarLongitude, sublunarLatitude, - 90, planetOccultationHorizonPoints, - ) - fromIndex := planetOccultationNearestPointIndex(circle, from) - toIndex := planetOccultationNearestPointIndex(circle, to) - forwardSteps := (toIndex - fromIndex + len(circle)) % len(circle) - backwardSteps := (fromIndex - toIndex + len(circle)) % len(circle) - direction := 1 - steps := forwardSteps - if backwardSteps < forwardSteps { - direction = -1 - steps = backwardSteps + if len(segment) < 2 { + return nil } - arc := make([]OccultationPathPoint, 0, steps+1) - for step := 1; step < steps; step++ { - index := (fromIndex + direction*step) % len(circle) - if index < 0 { - index += len(circle) - } - arc = append(arc, circle[index]) + circle := planetOccultationHorizonCircle(tt, frame, location, pointCount) + if len(circle) == 0 { + return []OccultationPathPoint{segment[0]} + } + from, to := segment[len(segment)-1], segment[0] + unitAt := func(point OccultationPathPoint) occultationPathVector { + fixed := occultationStationSurfaceVector(point.Longitude, point.Latitude) + return occultationPathVector{x: fixed.x / occultationPathEarthEquatorialRadiusKM, + y: fixed.y / occultationPathEarthEquatorialRadiusKM, + z: fixed.z / (occultationPathEarthEquatorialRadiusKM * occultationPathEarthPolarRatio)} + } + moonFixed := occultationPathEarthFixedVector(tt, frame.moon) + normal := occultationPathUnit(occultationPathVector{x: moonFixed.x, y: moonFixed.y, z: moonFixed.z / occultationPathEarthPolarRatio}) + first := unitAt(circle[0]) + first = occultationPathUnit(occultationPathSub(first, occultationPathScale(normal, occultationPathDot(first, normal)))) + second := occultationPathCross(normal, first) + angleAt := func(point OccultationPathPoint) float64 { + value := unitAt(point) + return math.Atan2(occultationPathDot(value, second), occultationPathDot(value, first)) + } + // Only interior samples belong to the closure. Rounding either endpoint + // to its nearest circle sample can extend the arc beyond the contact limb. + startAngle := angleAt(from) + delta := math.Remainder(angleAt(to)-startAngle, 2*math.Pi) + spacing := 2 * math.Pi / float64(len(circle)) + direction := 1 + index := int(math.Floor(startAngle/spacing)) + 1 + if delta < 0 { + direction = -1 + index = int(math.Ceil(startAngle/spacing)) - 1 + } + arc := make([]OccultationPathPoint, 0, int(math.Abs(delta)/spacing)+1) + for angle := float64(index) * spacing; math.Abs(angle-startAngle) < math.Abs(delta); angle += float64(direction) * spacing { + wrapped := (index%len(circle) + len(circle)) % len(circle) + point := circle[wrapped] + // 地平线弧顶点按契约取非负高度,解析圆的模型残差不是地平线以下的几何。 + point.MoonAltitude = math.Max(0, point.MoonAltitude) + arc = append(arc, point) + index += direction + } + if len(arc) == 0 && math.Abs(delta) > 1e-14 { + // Even a sub-sample arc needs an interior vertex to close a thin + // visible crescent. Evaluate its midpoint on the same small circle. + centerDistance := occultationPathDot(unitAt(circle[0]), normal) + radius := math.Sqrt(math.Max(0, 1-centerDistance*centerDistance)) + angle := startAngle + delta/2 + unit := occultationPathAdd(occultationPathScale(normal, centerDistance), + occultationPathScale(occultationPathAdd(occultationPathScale(first, math.Cos(angle)), + occultationPathScale(second, math.Sin(angle))), radius)) + fixed := occultationPathScale(unit, occultationPathEarthEquatorialRadiusKM) + fixed.z *= occultationPathEarthPolarRatio + middle := from + middle.Longitude, middle.Latitude = occultationStationGeodetic(fixed) + middle.MoonAltitude = 0 + arc = append(arc, middle) } return append(arc, to) } -func planetOccultationSphericalCircle( - value time.Time, - centerLongitude, centerLatitude, radius float64, +func planetOccultationFootprintAnchor( + tt float64, + frame occultationPathFrame, + segment []OccultationPathPoint, + location *time.Location, +) (OccultationPathPoint, bool) { + if center, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis); ok { + anchor := occultationPathPointFromVectorWithMoon( + tt, center, 0, frame.moon, location, + ) + if anchor.MoonAltitude >= 0 { + return anchor, true + } + } + if len(segment) == 0 { + return OccultationPathPoint{}, false + } + anchor := segment[0] + for _, point := range segment[1:] { + if point.MoonAltitude > anchor.MoonAltitude { + anchor = point + } + } + return anchor, true +} + +func planetOccultationCenterCap( + center OccultationPathPoint, + radiusKM float64, + pointCount int, +) []OccultationPathPoint { + if pointCount < 3 || radiusKM <= 0 { + return nil + } + const degreesPerRadian = 180 / math.Pi + latitudeRadians := center.Latitude / degreesPerRadian + cosine := math.Cos(latitudeRadians) + if math.Abs(cosine) < 1e-6 { + cosine = 1e-6 + } + latitudeOffset := radiusKM / occultationPathEarthEquatorialRadiusKM * degreesPerRadian + longitudeOffset := latitudeOffset / cosine + cap := make([]OccultationPathPoint, pointCount+1) + for index := 0; index < pointCount; index++ { + angle := 2 * math.Pi * float64(index) / float64(pointCount) + cap[index] = center + cap[index].Longitude = normalizeLongitude(center.Longitude + longitudeOffset*math.Cos(angle)) + cap[index].Latitude = math.Max(-90, math.Min(90, center.Latitude+latitudeOffset*math.Sin(angle))) + cap[index].MoonAltitude = center.MoonAltitude + cap[index].WidthKM = 0 + } + cap[pointCount] = cap[0] + return cap +} + +func planetOccultationCenterRepair( + center OccultationPathPoint, + ring []OccultationPathPoint, + radiusKM float64, + pointCount int, +) [][]OccultationPathPoint { + cap := planetOccultationCenterCap(center, radiusKM, pointCount) + if len(ring) < 2 { + return [][]OccultationPathPoint{cap} + } + nearestIndex := 0 + nearestDistance := math.Inf(1) + for index := 1; index < len(ring); index++ { + distance := planetOccultationPointSegmentDistanceKM( + center, ring[index-1], ring[index], + ) + if distance < nearestDistance { + nearestDistance = distance + nearestIndex = index - 1 + } + } + nextIndex := (nearestIndex + 1) % len(ring) + bridge := []OccultationPathPoint{ + center, + ring[nearestIndex], + ring[nextIndex], + center, + } + return [][]OccultationPathPoint{cap, bridge} +} + +func planetOccultationPointSegmentDistanceKM( + point, start, end OccultationPathPoint, +) float64 { + latitude := point.Latitude * math.Pi / 180 + scaleX := math.Cos(latitude) * occultationPathEarthEquatorialRadiusKM * math.Pi / 180 + scaleY := occultationPathEarthEquatorialRadiusKM * math.Pi / 180 + x := func(value OccultationPathPoint) float64 { + return math.Remainder(value.Longitude-point.Longitude, 360) * scaleX + } + y := func(value OccultationPathPoint) float64 { + return (value.Latitude - point.Latitude) * scaleY + } + startX, startY := x(start), y(start) + endX, endY := x(end), y(end) + deltaX, deltaY := endX-startX, endY-startY + fraction := 0.0 + if lengthSquared := deltaX*deltaX + deltaY*deltaY; lengthSquared > 0 { + fraction = math.Max(0, math.Min(1, + -(startX*deltaX+startY*deltaY)/lengthSquared, + )) + } + return math.Hypot(startX+fraction*deltaX, startY+fraction*deltaY) +} + +func planetOccultationPathRingContains( + ring []OccultationPathPoint, + longitude, latitude float64, +) bool { + inside := false + for current, previous := 0, len(ring)-1; current < len(ring); previous, current = current, current+1 { + currentLongitude := math.Remainder(ring[current].Longitude-longitude, 360) + previousLongitude := math.Remainder(ring[previous].Longitude-longitude, 360) + if math.Abs(currentLongitude-previousLongitude) > 180 { + if currentLongitude < previousLongitude { + currentLongitude += 360 + } else { + previousLongitude += 360 + } + } + currentLatitude := ring[current].Latitude + previousLatitude := ring[previous].Latitude + if (currentLatitude > latitude) == (previousLatitude > latitude) { + continue + } + intersection := previousLongitude + + (latitude-previousLatitude)*(currentLongitude-previousLongitude)/ + (currentLatitude-previousLatitude) + if intersection > 0 { + inside = !inside + } + } + return inside +} + +// planetOccultationHorizonCircle returns the exact lunar horizon on the +// reference ellipsoid. In unit-ellipsoid coordinates, tangent points satisfy +// both |u|=1 and scaledMoon dot u=1, so the horizon is a small circle. +func planetOccultationHorizonCircle( + tt float64, + frame occultationPathFrame, + location *time.Location, count int, ) []OccultationPathPoint { - centerLongitude *= math.Pi / 180 - centerLatitude *= math.Pi / 180 - radius *= math.Pi / 180 + if count < 3 { + return nil + } + equatorialRadius := occultationPathEarthEquatorialRadiusKM + polarRadius := equatorialRadius * occultationPathEarthPolarRatio + scaledMoon := occultationPathVector{ + x: frame.moon.x / equatorialRadius, + y: frame.moon.y / equatorialRadius, + z: frame.moon.z / polarRadius, + } + distance := occultationPathNorm(scaledMoon) + if distance <= 1 || !finite(distance) { + return nil + } + centerDirection := occultationPathScale(scaledMoon, 1/distance) + reference := occultationPathVector{z: 1} + if math.Abs(centerDirection.z) > 0.9 { + reference = occultationPathVector{x: 1} + } + firstAxis := occultationPathUnit(occultationPathCross(reference, centerDirection)) + secondAxis := occultationPathCross(centerDirection, firstAxis) + centerDistance := 1 / distance + circleRadius := math.Sqrt(math.Max(0, 1-centerDistance*centerDistance)) + siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15 points := make([]OccultationPathPoint, count) for index := range points { - bearing := 2 * math.Pi * float64(index) / float64(count) - latitude := math.Asin( - math.Sin(centerLatitude)*math.Cos(radius) + - math.Cos(centerLatitude)*math.Sin(radius)*math.Cos(bearing), + angle := 2 * math.Pi * float64(index) / float64(count) + unitPoint := occultationPathAdd( + occultationPathScale(centerDirection, centerDistance), + occultationPathScale( + occultationPathAdd( + occultationPathScale(firstAxis, math.Cos(angle)), + occultationPathScale(secondAxis, math.Sin(angle)), + ), + circleRadius, + ), ) - longitude := centerLongitude + math.Atan2( - math.Sin(bearing)*math.Sin(radius)*math.Cos(centerLatitude), - math.Cos(radius)-math.Sin(centerLatitude)*math.Sin(latitude), - ) - points[index] = OccultationPathPoint{ - Time: value, - Longitude: normalizeLongitude(longitude * 180 / math.Pi), - Latitude: latitude * 180 / math.Pi, + vector := occultationPathVector{ + x: equatorialRadius * unitPoint.x, + y: equatorialRadius * unitPoint.y, + z: polarRadius * unitPoint.z, } + points[index] = occultationPathPointFromVectorWithMoonSidereal( + tt, vector, 0, frame.moon, siderealDegrees, location, + ) } return points } - -func planetOccultationNearestPointIndex(points []OccultationPathPoint, target OccultationPathPoint) int { - nearest := 0 - distance := math.Inf(1) - for index, point := range points { - candidate := occultationPathDistanceKM(point, target) - if candidate < distance { - nearest = index - distance = candidate - } - } - return nearest -} diff --git a/basic/occultation_planet_footprint_test.go b/basic/occultation_planet_footprint_test.go index 51e508f..cc4632d 100644 --- a/basic/occultation_planet_footprint_test.go +++ b/basic/occultation_planet_footprint_test.go @@ -7,6 +7,40 @@ import ( "time" ) +func TestPlanetOccultationHorizonArcStaysBetweenContacts(t *testing.T) { + config, _ := planetOccultationConfigFor(OccultationSaturn) + tt := occultationTimeToTT(time.Date(2025, 1, 5, 17, 0, 0, 0, time.UTC)) + frame, ok := planetOccultationPathFrameAt(tt, config) + if !ok { + t.Fatal("missing frame") + } + fine := planetOccultationHorizonCircle(tt, frame, time.UTC, 3600) + for _, tc := range []struct { + from, to int + want []int + }{ + {20, 180, []int{100, 180}}, + {180, 20, []int{100, 20}}, + {3590, 130, []int{0, 100, 130}}, + {130, 3590, []int{100, 0, 3590}}, + {1790, 1930, []int{1800, 1900, 1930}}, + {1930, 1790, []int{1900, 1800, 1790}}, + {20, 30, []int{25, 30}}, + {30, 20, []int{25, 20}}, + } { + arc := planetOccultationHorizonArc(tt, frame, []OccultationPathPoint{fine[tc.to], fine[tc.from]}, time.UTC, 36) + if len(arc) != len(tc.want) { + t.Errorf("arc %d -> %d has %d points, want %d", tc.from, tc.to, len(arc), len(tc.want)) + continue + } + for i, index := range tc.want { + if distance := occultationPathDistanceKM(arc[i], fine[index]); distance > 1e-4 { + t.Errorf("arc %d -> %d point %d misses circle sample %d by %.6f km", tc.from, tc.to, i, index, distance) + } + } + } +} + func TestPlanetOccultationSaturnFootprintsContainCenterLine(t *testing.T) { config, ok := planetOccultationConfigFor(OccultationSaturn) if !ok { @@ -52,6 +86,42 @@ func TestPlanetOccultationSaturnFootprintsContainCenterLine(t *testing.T) { } } +func TestPlanetOccultationMars20250729GreatestIsInsideTotalFootprint(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationMars, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + RiseSetStep: time.Minute, DisableFootprints: true, + IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if !path.HasTotalBand || len(path.TotalBandFootprints) == 0 { + t.Fatalf("Mars path has no total band support: hasTotal=%v footprints=%d", + path.HasTotalBand, len(path.TotalBandFootprints)) + } + greatest := path.Greatest + foundGreatestFootprint := false + for _, footprint := range path.TotalBandFootprints { + if !footprint.Time.Equal(greatest.Time) { + continue + } + foundGreatestFootprint = true + if !planetOccultationFootprintContains(footprint, greatest.Longitude, greatest.Latitude) { + t.Fatalf("greatest point %.6f, %.6f is outside total footprint at %v", + greatest.Longitude, greatest.Latitude, greatest.Time) + } + } + if !foundGreatestFootprint { + t.Fatalf("total compact support omitted greatest time %v", greatest.Time) + } +} + func TestPlanetOccultationPathRejectsExcessiveAggregateSampling(t *testing.T) { start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) _, err := FindPlanetOccultationPaths( @@ -82,6 +152,27 @@ func TestPlanetOccultationFootprintsHaveIndependentSampleBudget(t *testing.T) { } } +func TestPlanetOccultationHorizonCircleUsesTopocentricLunarHorizon(t *testing.T) { + config, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn occultation config is unavailable") + } + tt := occultationTimeToTT(time.Date(2024, time.July, 24, 16, 30, 0, 0, time.UTC)) + frame, ok := planetOccultationTotalPathFrameAt(tt, config) + if !ok { + t.Fatal("Saturn total-occultation frame is unavailable") + } + points := planetOccultationHorizonCircle(tt, frame, time.UTC, 360) + if len(points) != 360 { + t.Fatalf("horizon point count = %d, want 360", len(points)) + } + for index, point := range points { + if altitude := math.Abs(point.MoonAltitude); altitude > 5e-5 { + t.Fatalf("horizon point %d lunar altitude = %.12f degrees, want zero", index, point.MoonAltitude) + } + } +} + func planetOccultationFootprintContains( footprint PlanetOccultationFootprint, longitude, latitude float64, @@ -114,3 +205,128 @@ func planetOccultationFootprintContains( } return false } + +// 导出的瞬时足迹必须停在月球地平线切口上:边界、闭合多边形与修复面都不得含地平线以下顶点。 +func TestPlanetOccultationFootprintsClipAtLunarHorizon(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + saturnStart := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + for _, testCase := range []struct { + name string + options OccultationPathOptions + }{ + { + name: "DenseFootprints", + options: OccultationPathOptions{ + Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute, + TargetSpacingKM: 900, RiseSetStep: time.Minute, + }, + }, + { + name: "TimelineFootprints", + options: OccultationPathOptions{ + Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute, + TargetSpacingKM: 900, DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute, + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + paths, err := FindPlanetOccultationPaths( + saturnStart, saturnStart.Add(24*time.Hour), OccultationSaturn, testCase.options, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + below, checked := 0, 0 + for _, footprints := range [][]PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} { + belowPart, checkedPart := planetOccultationFootprintHorizonViolations(footprints) + below += belowPart + checked += checkedPart + } + if checked < 1000 { + t.Fatalf("checked only %d footprint vertices", checked) + } + if below != 0 { + t.Fatalf("%d of %d footprint vertices lie below the lunar horizon", below, checked) + } + }) + } +} + +func planetOccultationFootprintHorizonViolations( + footprints []PlanetOccultationFootprint, +) (below, checked int) { + for _, footprint := range footprints { + for _, rings := range [][][]OccultationPathPoint{ + footprint.Boundaries, footprint.Polygons, footprint.InteriorPolygons, + } { + for _, ring := range rings { + for _, point := range ring { + checked++ + if point.MoonAltitude < 0 { + below++ + } + } + } + } + } + return below, checked +} + +func BenchmarkOccultationPlanetFootprints(b *testing.B) { + b.Run("Saturn20240821Default", func(b *testing.B) { + benchmarkPlanetOccultationFootprints( + b, OccultationSaturn, time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC), + OccultationPathOptions{}, + ) + }) + b.Run("Mars20250729Compact", func(b *testing.B) { + benchmarkPlanetOccultationFootprints( + b, OccultationMars, time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)), + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute, + }, + ) + }) +} + +// benchmarkPlanetOccultationFootprints 只度量足迹装配:窗口与帧函数取自一次真实路径求解,避免混入路径与升落成本。 +func benchmarkPlanetOccultationFootprints( + b *testing.B, + planet OccultationPlanet, + start time.Time, + options OccultationPathOptions, +) { + paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), planet, options) + if err != nil || len(paths) != 1 { + b.Fatalf("paths=%d err=%v", len(paths), err) + } + config, ok := planetOccultationConfigFor(planet) + if !ok { + b.Fatal("missing occultation config") + } + path := paths[0] + cache := newPlanetOccultationEventCache(config) + greatestTT := occultationTimeToTT(path.Greatest.Time) + cache.preparePathEphemeris(greatestTT, options.Algorithm) + startTT, endTT := occultationTimeToTT(path.Start.Time), occultationTimeToTT(path.End.Time) + location := start.Location() + frameAt := cache.outerFrameAt + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + if options.DisableFootprints { + band := planetOccultationBandFootprints(startTT, endTT, greatestTT, frameAt, location, nil) + timeline := planetOccultationTimelineFootprints(startTT, endTT, greatestTT, frameAt, options, location) + if len(band) == 0 || len(timeline) == 0 { + b.Fatalf("band=%d timeline=%d", len(band), len(timeline)) + } + continue + } + if footprints := planetOccultationFootprints(startTT, endTT, greatestTT, frameAt, options, location); len(footprints) == 0 { + b.Fatal("no footprints") + } + } +} diff --git a/basic/occultation_planet_path.go b/basic/occultation_planet_path.go index 58553e3..f69b120 100644 --- a/basic/occultation_planet_path.go +++ b/basic/occultation_planet_path.go @@ -6,10 +6,57 @@ import ( "time" ) -const planetOccultationPathMaxTemporalSamples = 5000 +const ( + planetOccultationPathMaxTemporalSamples = 5000 + // A single dense path evaluates several independent temporal grids and + // contour refinements. Keep all exact frames for that event so the shared + // ephemeris is not recomputed every time the bounded cache rolls over. + planetOccultationEventCacheMaximumEntries = 16384 +) type occultationPathFrameFunc func(float64) (occultationPathFrame, bool) +type planetOccultationEphemerisState struct { + moonRA, moonDec float64 + moonDistanceKM float64 + planetRA, planetDec float64 + planetDistanceKM float64 + valid bool +} + +type planetOccultationFrameCacheEntry struct { + frame occultationPathFrame + ok bool +} + +type planetOccultationEventCache struct { + config planetOccultationConfig + states map[uint64]planetOccultationEphemerisState + outerFrames map[uint64]planetOccultationFrameCacheEntry + totalFrames map[uint64]planetOccultationFrameCacheEntry + riseSetCache *occultationRiseSetEvaluationCache + totalRiseSetCache *occultationRiseSetEvaluationCache + local *planetOccultationLocalEphemeris +} + +func newPlanetOccultationEventCache(config planetOccultationConfig) *planetOccultationEventCache { + cache := &planetOccultationEventCache{ + config: config, + states: make(map[uint64]planetOccultationEphemerisState), + outerFrames: make(map[uint64]planetOccultationFrameCacheEntry), + totalFrames: make(map[uint64]planetOccultationFrameCacheEntry), + } + cache.riseSetCache = newOccultationRiseSetEvaluationCacheWithCandidate( + cache.riseSetContextAt, + cache.candidateRiseSetContextAt, + ) + cache.totalRiseSetCache = newOccultationRiseSetEvaluationCacheWithCandidate( + cache.totalRiseSetContextAt, + cache.candidateTotalRiseSetContextAt, + ) + return cache +} + // FindPlanetOccultationPaths 搜索有限盘面行星月掩的全球外接触和内接触掩带。 // 查询窗口按全球几何掩甚点选择事件;端点容差 10 ms 与数值根精度一致。求解成功时,每条路径扩展到完整全球起止点。 // FindPlanetOccultationPaths searches the global outer- and inner-contact footprints of one finite-disk planet. @@ -37,16 +84,13 @@ func FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet, ) paths := make([]PlanetOccultationPath, 0, len(candidates)) for _, seedTT := range candidates { - path, ok, err := planetOccultationPathAtSeed(seedTT, config, options, start.Location()) + path, ok, err := planetOccultationPathAtSeed(seedTT, config, options, start, end) if err != nil { return nil, err } if !ok { continue } - if !occultationTimeInSelectionWindow(path.Greatest.Time, start, end) { - continue - } if len(paths) > 0 && math.Abs(paths[len(paths)-1].Greatest.Time.Sub(path.Greatest.Time).Seconds()) <= 60 { continue } @@ -62,33 +106,57 @@ func planetOccultationPathAtSeed( seedTT float64, config planetOccultationConfig, options OccultationPathOptions, - location *time.Location, + selectionStart, selectionEnd time.Time, ) (PlanetOccultationPath, bool, error) { - frameAt := func(tt float64) (occultationPathFrame, bool) { - return planetOccultationPathFrameAt(tt, config) - } - totalFrameAt := func(tt float64) (occultationPathFrame, bool) { - return planetOccultationTotalPathFrameAt(tt, config) - } + location := selectionStart.Location() + cache := newPlanetOccultationEventCache(config) + cache.prepareLocalEphemeris(seedTT) + frameAt := cache.outerFrameAt + totalFrameAt := cache.totalFrameAt + candidateFrameAt := cache.candidateFrameAt + candidateTotalFrameAt := cache.candidateTotalFrameAt searchStart := seedTT - occultationPathSearchSpanDays searchEnd := seedTT + occultationPathSearchSpanDays - outerStart, outerEnd, ok := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, false) + outerStart, outerEnd, ok := occultationPathWindowWithCandidateFrames( + seedTT, searchStart, searchEnd, candidateFrameAt, frameAt, false, + occultationPathFrameHasBoundary, + ) if !ok { return PlanetOccultationPath{}, false, nil } - centerStart, centerEnd, hasCenter := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, true) - greatestTT := occultationPathGreatestForFrame(seedTT, outerStart, outerEnd, frameAt) + centerStart, centerEnd, hasCenter := occultationPathWindowWithCandidateFrames( + seedTT, searchStart, searchEnd, candidateFrameAt, frameAt, true, + occultationPathFrameHasBoundary, + ) + // Use the local interpolated ephemeris to predict the maximum first, then + // run the exact golden-section search in a bounded neighbourhood. Global + // markers stay identical to event-only queries in both path branches. + candidateGreatestTT := occultationPathGreatestForFrame(seedTT, outerStart, outerEnd, candidateFrameAt) + exactSearchMarginDays := 0.10 + exactStart := math.Max(outerStart, candidateGreatestTT-exactSearchMarginDays) + exactEnd := math.Min(outerEnd, candidateGreatestTT+exactSearchMarginDays) + if exactEnd <= exactStart { + exactStart, exactEnd = outerStart, outerEnd + } + greatestTT := occultationPathGreatestForFrame(candidateGreatestTT, exactStart, exactEnd, frameAt) greatest, greatestOK := occultationPathCenterPointForFrame(greatestTT, frameAt, location) if !greatestOK && hasCenter { greatestTT = math.Max(centerStart, math.Min(centerEnd, greatestTT)) greatest, greatestOK = occultationPathCenterPointForFrame(greatestTT, frameAt, location) } if !greatestOK { - greatest, greatestOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, location) + // Non-central events have no Earth intersection with the shadow axis. + // Greatest is the ellipsoid point nearest to that axis, not an outer + // contact tangent. The tangent fallback can place Greatest outside the + // total band for grazing finite-disk occultations. + greatest, greatestOK = occultationPathTrackPointForFrame(greatestTT, frameAt, location) } if !greatestOK { return PlanetOccultationPath{}, false, nil } + if !occultationTimeInSelectionWindow(greatest.Time, selectionStart, selectionEnd) { + return PlanetOccultationPath{}, false, nil + } _, _, greatestWidth, greatestWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, frameAt) if !greatestWidthOK || greatestWidth <= 0 { return PlanetOccultationPath{}, false, nil @@ -96,8 +164,9 @@ func planetOccultationPathAtSeed( // 仅有边界的事件没有影轴与椭球交点;原回退点使用纬度极值弦宽,全掩带使用下方的地面横向宽度。统一两种接触带宽度定义,使有限盘面内外接触宽度可比较。 // Boundary-only events do not have an axis/ellipsoid intersection. Their fallback point used to carry a latitude-extrema chord width, while total bands used the ground cross-track width below. Keep both contact bands on the same width definition so finite-disk inner/outer widths are comparable. greatest.WidthKM = greatestWidth - totalStartTT, totalEndTT, hasTotal := occultationPathWindowForFrame( - seedTT, searchStart, searchEnd, totalFrameAt, false, + totalStartTT, totalEndTT, hasTotal := occultationPathWindowWithCandidateFrames( + seedTT, searchStart, searchEnd, candidateTotalFrameAt, totalFrameAt, false, + occultationPathFrameHasBoundary, ) hasTotal = hasTotal && greatestTT >= totalStartTT && greatestTT <= totalEndTT if planetOccultationPathTemporalSampleCount( @@ -106,18 +175,36 @@ func planetOccultationPathAtSeed( ) > planetOccultationPathMaxTemporalSamples { return PlanetOccultationPath{}, false, ErrOccultationPathSamplingLimit } + if occultationPathEstimatedPointCount( + outerStart, outerEnd, centerStart, centerEnd, hasCenter, + totalStartTT, totalEndTT, hasTotal, greatestTT, options, + ) > occultationPathMaxOutputPointCount { + return PlanetOccultationPath{}, false, ErrOccultationPathSamplingLimit + } start := occultationPathBoundaryEndpointForFrame(outerStart, frameAt, location, 1) end := occultationPathBoundaryEndpointForFrame(outerEnd, frameAt, location, -1) if !start.valid || !end.valid { return PlanetOccultationPath{}, false, nil } + exactFrameAt, exactTotalFrameAt := frameAt, totalFrameAt + if options.Algorithm != OccultationPathAlgorithmExact { + optimized := newPlanetOccultationEventCache(config) + optimized.preparePathEphemeris(seedTT, options.Algorithm) + if optimized.local.dense { + cache = optimized + frameAt, totalFrameAt = cache.outerFrameAt, cache.totalFrameAt + } + } centerLine, northern, southern, err := planetOccultationPathSamples( outerStart, outerEnd, centerStart, centerEnd, hasCenter, greatestTT, frameAt, options, location, ) if err != nil { return PlanetOccultationPath{}, false, err } + if cache.local.dense { + correctOccultationCenterWidths(centerLine, exactFrameAt) + } path := PlanetOccultationPath{ Planet: config.planet, TargetID: config.planet.String(), @@ -131,21 +218,95 @@ func planetOccultationPathAtSeed( Step: options.Step, TargetSpacingKM: options.TargetSpacingKM, } - path.PartialFootprints = planetOccultationFootprints( - outerStart, outerEnd, greatestTT, frameAt, options, location, + // The static visible fill is evaluated from these limit tracks. Keep it on + // the same station-centred contact equation as the contact contours and + // rise/set curves; otherwise one event mixes geocentric limits with + // topocentric contours and the selected envelope can miss a branch. + path.NorthernLimit = occultationStationCorrectLimitSeries( + path.NorthernLimit, frameAt, cache.riseSetContextAt, false, location, ) + path.SouthernLimit = occultationStationCorrectLimitSeries( + path.SouthernLimit, frameAt, cache.riseSetContextAt, false, location, + ) + path.GreatestLimitSeparationKM, _ = occultationPathLimitSeparations( + path.NorthernLimit, path.SouthernLimit, greatestTT, + ) + if !options.DisableFootprints { + path.PartialFootprints = planetOccultationFootprints( + outerStart, outerEnd, greatestTT, frameAt, options, location, + ) + } + path.RiseSetCurves = occultationRiseSetCurvesWithCache( + outerStart, outerEnd, greatestTT, options, location, cache.riseSetCache, + ) + path.GreatestTimeContours = occultationGreatestTimeContours( + occultationGreatestTimeLevels(options, outerStart, outerEnd), outerStart, outerEnd, cache.riseSetCache, + [][]OccultationPathPoint{path.CenterLine, path.NorthernLimit, path.SouthernLimit}, + true, location, + ) + partialContourTimes := occultationRiseSetEndpointTimes(path.RiseSetCurves) + if options.DisableFootprints { + path.PartialBandFootprints, partialContourTimes = planetOccultationBandFootprintsWithContourTimes( + outerStart, outerEnd, greatestTT, frameAt, location, partialContourTimes, + ) + } + if len(path.PartialFootprints) > 0 { + path.PartialFootprints = occultationStationCorrectFootprintEdges( + path.PartialFootprints, frameAt, cache.riseSetContextAt, false, location, + ) + } + if len(path.PartialBandFootprints) > 0 { + path.PartialBandFootprints = occultationStationCorrectFootprintEdges( + path.PartialBandFootprints, frameAt, cache.riseSetContextAt, false, location, + ) + } + path.PartialBandContours = occultationContactBandContoursWithAdditionalTimes( + start.point, end.point, outerStart, outerEnd, greatestTT, frameAt, options, location, partialContourTimes, + ) + // The geocentric cone supplies the contour topology and time samples; the + // station oracle corrects each fixed-time contact onto the same topocentric + // equation used by rise/set curves. Keep the geocentric seed when a fixed + // time has no station root yet (normally the short endpoint sliver). + path.PartialBandContours = occultationStationCorrectContours( + path.PartialBandContours, path.RiseSetCurves, cache.riseSetCache, location, + ) + path.PartialVisibilityContours = occultationStationVisibilityEnvelopeContours( + path.RiseSetCurves, cache.riseSetCache, location, + ) + if options.DisableFootprints && options.IncludeFootprintTimeline { + path.PartialFootprints = planetOccultationTimelineFootprints( + outerStart, outerEnd, greatestTT, frameAt, options, location, + ) + } if !hasTotal { return path, true, nil } - totalStart := occultationPathBoundaryEndpointForFrame(totalStartTT, totalFrameAt, location, 1) - totalEnd := occultationPathBoundaryEndpointForFrame(totalEndTT, totalFrameAt, location, -1) - _, _, totalWidth, totalWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, totalFrameAt) + totalStart := occultationPathBoundaryEndpointForFrame(totalStartTT, exactTotalFrameAt, location, 1) + totalEnd := occultationPathBoundaryEndpointForFrame(totalEndTT, exactTotalFrameAt, location, -1) + _, _, totalWidth, totalWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, exactTotalFrameAt) if !totalStart.valid || !totalEnd.valid || !totalWidthOK || totalWidth <= 0 { return path, true, nil } + // The global extrema used for a boundary-only footprint can select + // different tangent branches for outer and inner cones. At greatest, the + // physical width is the paired cross-track separation on the same moving + // track; use it for the finite target before applying the public invariant. + if crossTrackWidth, ok := occultationPathCrossTrackWidthForFrame(greatestTT, exactTotalFrameAt); ok { + totalWidth = crossTrackWidth + } + if totalWidth >= greatestWidth { + if crossTrackWidth, ok := occultationPathCrossTrackWidthForFrame(greatestTT, exactFrameAt); ok && crossTrackWidth > 0 { + greatestWidth = crossTrackWidth + path.Greatest.WidthKM = crossTrackWidth + } + } + if totalWidth >= greatestWidth { + totalWidth = math.Nextafter(greatestWidth, 0) + } + contourStepDays := occultationPathContourStepDays(options) totalNorthern, totalSouthern := occultationPathBoundarySamplesForFrame( - totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location, + totalStartTT, totalEndTT, greatestTT, totalFrameAt, contourStepDays, location, ) if len(totalNorthern) == 0 || len(totalSouthern) == 0 { return path, true, nil @@ -156,9 +317,52 @@ func planetOccultationPathAtSeed( path.TotalComplete = totalStartTT > searchStart && totalEndTT < searchEnd path.NorthernTotalLimit = occultationPathWithEndpoints(totalStart.point, totalEnd.point, totalNorthern) path.SouthernTotalLimit = occultationPathWithEndpoints(totalStart.point, totalEnd.point, totalSouthern) - path.TotalFootprints = planetOccultationFootprints( - totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location, + path.NorthernTotalLimit = occultationStationCorrectLimitSeries( + path.NorthernTotalLimit, totalFrameAt, cache.riseSetContextAt, true, location, ) + path.SouthernTotalLimit = occultationStationCorrectLimitSeries( + path.SouthernTotalLimit, totalFrameAt, cache.riseSetContextAt, true, location, + ) + _, _ = occultationPathLimitSeparations(path.NorthernTotalLimit, path.SouthernTotalLimit, greatestTT) + path.TotalRiseSetCurves = occultationRiseSetCurvesWithCache( + totalStartTT, totalEndTT, greatestTT, options, location, cache.totalRiseSetCache, + ) + totalContourTimes := occultationRiseSetEndpointTimes(path.TotalRiseSetCurves) + if !options.DisableFootprints { + path.TotalFootprints = planetOccultationFootprints( + totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location, + ) + } + if options.DisableFootprints { + path.TotalBandFootprints, totalContourTimes = planetOccultationBandFootprintsWithContourTimes( + totalStartTT, totalEndTT, greatestTT, totalFrameAt, location, totalContourTimes, + ) + } + if len(path.TotalFootprints) > 0 { + path.TotalFootprints = occultationStationCorrectFootprintEdges( + path.TotalFootprints, totalFrameAt, cache.riseSetContextAt, true, location, + ) + } + if len(path.TotalBandFootprints) > 0 { + path.TotalBandFootprints = occultationStationCorrectFootprintEdges( + path.TotalBandFootprints, totalFrameAt, cache.riseSetContextAt, true, location, + ) + } + path.TotalBandContours = occultationContactBandContoursWithAdditionalTimes( + totalStart.point, totalEnd.point, totalStartTT, totalEndTT, greatestTT, + totalFrameAt, options, location, totalContourTimes, + ) + path.TotalBandContours = occultationStationCorrectContours( + path.TotalBandContours, path.TotalRiseSetCurves, cache.totalRiseSetCache, location, + ) + path.TotalVisibilityContours = occultationStationVisibilityEnvelopeContours( + path.TotalRiseSetCurves, cache.totalRiseSetCache, location, + ) + if options.DisableFootprints && options.IncludeFootprintTimeline { + path.TotalFootprints = planetOccultationTimelineFootprints( + totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location, + ) + } path.GreatestTotalWidthKM = totalWidth return path, true, nil } @@ -174,17 +378,42 @@ func planetOccultationPathTemporalSampleCount( ) int { stepDays := float64(options.Step) / float64(24*time.Hour) count := len(occultationPathSampleTimes(outerStartTT, outerEndTT, greatestTT, stepDays)) - count += len(occultationPathSampleTimesWithLimit( - outerStartTT, outerEndTT, greatestTT, stepDays, planetOccultationFootprintMaxSamples, - )) + if !options.DisableFootprints || options.IncludeFootprintTimeline { + count += len(occultationPathSampleTimesWithLimit( + outerStartTT, outerEndTT, greatestTT, planetOccultationFootprintSampleStepDays(options), planetOccultationFootprintMaxSamples, + )) + } else { + count += len(planetOccultationBandSampleTimes( + outerStartTT, outerEndTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples, + )) + } + if options.DisableFootprints && options.IncludeFootprintTimeline { + count += len(planetOccultationBandSampleTimes( + outerStartTT, outerEndTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples, + )) + } if hasCenter { count += len(occultationPathSampleTimes(centerStartTT, centerEndTT, greatestTT, stepDays)) } + contourStepDays := occultationPathContourStepDays(options) + count += 2 * len(occultationPathSampleTimes(outerStartTT, outerEndTT, greatestTT, contourStepDays)) if hasTotal { count += len(occultationPathSampleTimes(totalStartTT, totalEndTT, greatestTT, stepDays)) - count += len(occultationPathSampleTimesWithLimit( - totalStartTT, totalEndTT, greatestTT, stepDays, planetOccultationFootprintMaxSamples, - )) + count += 2 * len(occultationPathSampleTimes(totalStartTT, totalEndTT, greatestTT, contourStepDays)) + if !options.DisableFootprints || options.IncludeFootprintTimeline { + count += len(occultationPathSampleTimesWithLimit( + totalStartTT, totalEndTT, greatestTT, planetOccultationFootprintSampleStepDays(options), planetOccultationFootprintMaxSamples, + )) + } else { + count += len(planetOccultationBandSampleTimes( + totalStartTT, totalEndTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples, + )) + } + if options.DisableFootprints && options.IncludeFootprintTimeline { + count += len(planetOccultationBandSampleTimes( + totalStartTT, totalEndTT, greatestTT, planetOccultationBandSampleStepDays(), planetOccultationBandMaxSamples, + )) + } } return count } @@ -194,11 +423,6 @@ func occultationPathWindowForFrame( frameAt occultationPathFrameFunc, center bool, ) (float64, float64, bool) { - left := math.Max(startTT, seedTT-occultationPathSearchSpanDays) - right := math.Min(endTT, seedTT+occultationPathSearchSpanDays) - if right <= left { - return 0, 0, false - } predicate := func(tt float64) bool { frame, ok := frameAt(tt) if !ok { @@ -210,81 +434,29 @@ func occultationPathWindowForFrame( } return occultationPathFrameHasBoundary(frame) } - - step := occultationPathRangeStepDays - first := math.NaN() - previous := left - previousOK := predicate(previous) - if previousOK { - first = previous - } else { - for tt := left + step; tt <= right; tt += step { - current := math.Min(tt, right) - currentOK := predicate(current) - if currentOK { - first = occultationPathRefineTransition(previous, current, predicate, false) - break - } - previous = current - previousOK = currentOK - } - } - if math.IsNaN(first) { - return 0, 0, false - } - - last := first - previous = first - previousOK = true - for tt := first + step; tt <= right; tt += step { - current := math.Min(tt, right) - currentOK := predicate(current) - if !currentOK { - last = occultationPathRefineTransition(previous, current, predicate, true) - return first, last, true - } - last = current - previous = current - previousOK = currentOK - } - if previousOK { - last = right - } - return first, last, true + engine := occultationMovingDiskEngine() + return engine.window(seedTT, startTT, endTT, predicate, predicate) } func occultationPathGreatestForFrame(seedTT, startTT, endTT float64, frameAt occultationPathFrameFunc) float64 { - left := math.Max(startTT, seedTT-0.75) - right := math.Min(endTT, seedTT+0.75) - if right <= left { - return seedTT - } - impact := func(tt float64) float64 { - frame, ok := frameAt(tt) - if !ok { - return math.Inf(1) - } - return math.Hypot(frame.moonProjectionX(), frame.moonProjectionY()) - } - const goldenRatio = 0.6180339887498949 - x1 := right - goldenRatio*(right-left) - x2 := left + goldenRatio*(right-left) - f1 := impact(x1) - f2 := impact(x2) - for i := 0; i < 56; i++ { - if f1 > f2 { - left = x1 - x1, f1 = x2, f2 - x2 = left + goldenRatio*(right-left) - f2 = impact(x2) - } else { - right = x2 - x2, f2 = x1, f1 - x1 = right - goldenRatio*(right-left) - f1 = impact(x1) - } - } - return (left + right) / 2 + return occultationPathGreatestForFrameIterations(seedTT, startTT, endTT, frameAt, 56) +} + +func occultationPathGreatestForFrameIterations( + seedTT, startTT, endTT float64, + frameAt occultationPathFrameFunc, + iterations int, +) float64 { + return occultationMovingDiskEngine().greatest( + seedTT, startTT, endTT, + func(tt float64) (float64, bool) { + frame, ok := frameAt(tt) + if !ok { + return 0, false + } + return math.Hypot(frame.moonProjectionX(), frame.moonProjectionY()), true + }, iterations, + ) } func planetOccultationPathSamples( @@ -304,8 +476,9 @@ func planetOccultationPathSamples( return nil, nil, nil, err } } + contourStepDays := occultationPathContourStepDays(options) northern, southern := occultationPathBoundarySamplesForFrame( - outerStartTT, outerEndTT, greatestTT, frameAt, options, location, + outerStartTT, outerEndTT, greatestTT, frameAt, contourStepDays, location, ) return centerLine, northern, southern, nil } @@ -313,63 +486,346 @@ func planetOccultationPathSamples( func occultationPathBoundarySamplesForFrame( startTT, endTT, greatestTT float64, frameAt occultationPathFrameFunc, - options OccultationPathOptions, + stepDays float64, location *time.Location, ) ([]OccultationPathPoint, []OccultationPathPoint) { - stepDays := float64(options.Step) / float64(24*time.Hour) times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays) + return occultationPathBoundarySamplesAtTimesForFrame( + times, greatestTT, frameAt, location, occultationPathContourSpacingKM, false, + ) +} + +func occultationPathBoundaryContourSamplesForFrame( + startTT, endTT, greatestTT float64, + frameAt occultationPathFrameFunc, + stepDays float64, + location *time.Location, + additionalTimes []float64, +) ([]OccultationPathPoint, []OccultationPathPoint) { + times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays) + for _, tt := range additionalTimes { + if !finite(tt) || tt < startTT || tt > endTT { + continue + } + times = append(times, tt) + } + sort.Float64s(times) + times = uniqueOccultationPathTimes(times) + return occultationPathBoundarySamplesAtTimesForFrame( + times, greatestTT, frameAt, location, occultationPathContourSpacingKM, true, + ) +} + +func occultationPathBoundarySamplesAtTimesForFrame( + times []float64, + greatestTT float64, + frameAt occultationPathFrameFunc, + location *time.Location, + targetSpacingKM float64, + useFiniteArcExtrema bool, +) ([]OccultationPathPoint, []OccultationPathPoint) { + limitsAt := occultationPathCrossTrackLimitsForFrame + if useFiniteArcExtrema { + limitsAt = occultationPathContourCrossTrackLimitsForFrame + } samples := make([]occultationPathBoundaryPairSample, 0, len(times)) for _, tt := range times { - firstVector, secondVector, ok := occultationPathCrossTrackLimitsForFrame(tt, frameAt) + firstVector, secondVector, moon, ok := occultationPathContourBoundaryPairAt( + tt, frameAt, limitsAt, useFiniteArcExtrema, nil, + ) if !ok { continue } - sample := occultationPathBoundaryPairSample{tt: tt, first: firstVector, second: secondVector} + sample := occultationPathBoundaryPairSample{tt: tt, first: firstVector, second: secondVector, moon: moon} if len(samples) == 0 { samples = append(samples, sample) continue } - samples = appendOccultationPathBoundaryPairSegment(samples, samples[len(samples)-1], sample, frameAt, 0) + samples = appendOccultationPathBoundaryPairSegment( + samples, samples[len(samples)-1], sample, frameAt, targetSpacingKM, limitsAt, useFiniteArcExtrema, 0, + ) } first := make([]OccultationPathPoint, len(samples)) second := make([]OccultationPathPoint, len(samples)) for index, sample := range samples { - first[index] = occultationPathPointFromVector(sample.tt, sample.first, 0, location) - second[index] = occultationPathPointFromVector(sample.tt, sample.second, 0, location) + siderealDegrees := ApparentSiderealTime(TD2UT(sample.tt, false)) * 15 + first[index] = occultationPathPointFromVectorWithMoonSidereal( + sample.tt, sample.first, 0, sample.moon, siderealDegrees, location, + ) + second[index] = occultationPathPointFromVectorWithMoonSidereal( + sample.tt, sample.second, 0, sample.moon, siderealDegrees, location, + ) } + first, second = occultationPathDeduplicateBoundaryPoints(first, second) return occultationPathOrientBoundarySamples(first, second, greatestTT) } type occultationPathBoundaryPairSample struct { tt float64 first, second occultationPathVector + moon occultationPathVector } func appendOccultationPathBoundaryPairSegment( samples []occultationPathBoundaryPairSample, start, end occultationPathBoundaryPairSample, frameAt occultationPathFrameFunc, + targetSpacingKM float64, + limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool), + useFiniteArcExtrema bool, depth int, ) []occultationPathBoundaryPairSample { - if depth >= occultationPathMaxAdaptiveDepth || - occultationPathBoundaryPairSpacing(start, end) <= occultationPathBoundarySpacingKM { + if useFiniteArcExtrema { + end = occultationPathRepairContourBoundaryPair(start, end, frameAt, limitsAt) + } + end = occultationPathOrientBoundaryPair(start, end) + spacing := occultationPathBoundaryPairDirectSpacing(start, end) + if useFiniteArcExtrema { + // The public maps use Web Mercator. Near a pole a physically short + // ground-track step can span a much larger projected x/y distance, so + // a ground-distance-only sampler renders visible corners and notches. + // Refine only finite-disk contact contours using the same projection + // metric that the map consumes; ordinary event/path sampling remains + // on the cheaper physical-distance criterion. + projectedSpacing := occultationPathBoundaryPairWebMercatorSpacing(start, end) + projectedTarget := occultationPathBoundaryPairWebMercatorTarget(start, end, targetSpacingKM) + if projectedSpacing > projectedTarget { + spacing = math.Max(spacing, projectedSpacing*targetSpacingKM/projectedTarget) + } + } + if depth >= occultationPathMaxAdaptiveDepth || spacing <= targetSpacingKM { return append(samples, end) } midTT := (start.tt + end.tt) / 2 - first, second, ok := occultationPathCrossTrackLimitsForFrame(midTT, frameAt) + if midTT <= start.tt || midTT >= end.tt { + return append(samples, end) + } + first, second, moon, ok := occultationPathContourBoundaryPairAt( + midTT, frameAt, limitsAt, useFiniteArcExtrema, &start, + ) if !ok { return append(samples, end) } - mid := occultationPathBoundaryPairSample{tt: midTT, first: first, second: second} - samples = appendOccultationPathBoundaryPairSegment(samples, start, mid, frameAt, depth+1) - return appendOccultationPathBoundaryPairSegment(samples, mid, end, frameAt, depth+1) + mid := occultationPathBoundaryPairSample{tt: midTT, first: first, second: second, moon: moon} + samples = appendOccultationPathBoundaryPairSegment(samples, start, mid, frameAt, targetSpacingKM, limitsAt, useFiniteArcExtrema, depth+1) + return appendOccultationPathBoundaryPairSegment( + samples, samples[len(samples)-1], end, frameAt, targetSpacingKM, limitsAt, useFiniteArcExtrema, depth+1, + ) } -func occultationPathBoundaryPairSpacing(first, second occultationPathBoundaryPairSample) float64 { - firstA := occultationPathEarthFixedVector(first.tt, first.first) - firstB := occultationPathEarthFixedVector(first.tt, first.second) - secondA := occultationPathEarthFixedVector(second.tt, second.first) - secondB := occultationPathEarthFixedVector(second.tt, second.second) +const ( + occultationPathWebMercatorRadiusKM = 6378.1366 + occultationPathWebMercatorMaxLat = 85.05112878 +) + +func occultationPathBoundaryPairWebMercatorSpacing( + first, second occultationPathBoundaryPairSample, +) float64 { + return math.Max( + occultationPathWebMercatorPointSpacing(first.tt, first.first, second.tt, second.first), + occultationPathWebMercatorPointSpacing(first.tt, first.second, second.tt, second.second), + ) +} + +func occultationPathBoundaryPairWebMercatorTarget( + first, second occultationPathBoundaryPairSample, + targetSpacingKM float64, +) float64 { + if targetSpacingKM <= 0 { + return targetSpacingKM + } + _, firstStartLatitude := occultationPathGeodetic(first.tt, first.first) + _, firstEndLatitude := occultationPathGeodetic(second.tt, second.first) + _, secondStartLatitude := occultationPathGeodetic(first.tt, first.second) + _, secondEndLatitude := occultationPathGeodetic(second.tt, second.second) + latitude := math.Max( + math.Max(math.Abs(firstStartLatitude), math.Abs(firstEndLatitude)), + math.Max(math.Abs(secondStartLatitude), math.Abs(secondEndLatitude)), + ) * math.Pi / 180 + // Longitude is stretched by sec(latitude) in Web Mercator. Keep the + // projected contour step near the physical 40 km target at low latitudes, + // but cap it at roughly 10 km around the polar part of this event. + return targetSpacingKM * math.Max(0.75, math.Cos(latitude)) +} + +func occultationPathWebMercatorPointSpacing( + firstTT float64, + firstVector occultationPathVector, + secondTT float64, + secondVector occultationPathVector, +) float64 { + firstLongitude, firstLatitude := occultationPathGeodetic(firstTT, firstVector) + secondLongitude, secondLatitude := occultationPathGeodetic(secondTT, secondVector) + firstLatitude = math.Max(-occultationPathWebMercatorMaxLat, math.Min(occultationPathWebMercatorMaxLat, firstLatitude)) + secondLatitude = math.Max(-occultationPathWebMercatorMaxLat, math.Min(occultationPathWebMercatorMaxLat, secondLatitude)) + longitudeDelta := math.Remainder(secondLongitude-firstLongitude, 360) * math.Pi / 180 + firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude*math.Pi/360)) + secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude*math.Pi/360)) + latitudeDelta := secondY - firstY + return occultationPathWebMercatorRadiusKM * math.Hypot(longitudeDelta, latitudeDelta) +} + +func occultationPathContourBoundaryPairAt( + tt float64, + frameAt occultationPathFrameFunc, + limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool), + useFiniteArcExtrema bool, + previous *occultationPathBoundaryPairSample, +) (occultationPathVector, occultationPathVector, occultationPathVector, bool) { + first, second, moon, ok := limitsAt(tt, frameAt) + if !ok { + if useFiniteArcExtrema && previous != nil { + return previous.first, previous.second, previous.moon, true + } + return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false + } + candidate := occultationPathBoundaryPairSample{tt: tt, first: first, second: second, moon: moon} + if !useFiniteArcExtrema || previous == nil { + return candidate.first, candidate.second, candidate.moon, true + } + candidate = occultationPathOrientBoundaryPair(*previous, candidate) + if occultationPathBoundaryPairDirectSpacing(*previous, candidate) <= occultationPathBoundarySpacingKM { + return candidate.first, candidate.second, candidate.moon, true + } + if frame, frameOK := frameAt(tt); frameOK && frame.targetRadius != 0 { + if first, second, ok := occultationPathContinueFiniteBoundaryPair( + frame, tt, *previous, + ); ok { + return first, second, frame.moon, true + } + } + return candidate.first, candidate.second, candidate.moon, true +} + +func occultationPathContinueFiniteBoundaryPair( + frame occultationPathFrame, + tt float64, + previous occultationPathBoundaryPairSample, +) (occultationPathVector, occultationPathVector, bool) { + intervals := occultationPathBoundaryThetaIntervals(frame) + if len(intervals) == 0 { + return occultationPathVector{}, occultationPathVector{}, false + } + currentRotation := occultationPathEarthRotationAt(tt) + previousRotation := occultationPathEarthRotationAt(previous.tt) + nearest := func(target occultationPathVector) (occultationPathVector, bool) { + targetFixed := occultationPathEarthFixedVectorWithRotation(target, previousRotation) + bestDistance := math.Inf(1) + var best occultationPathVector + for _, interval := range intervals { + const samples = 48 + bestIndex := -1 + for index := 0; index <= samples; index++ { + theta := interval.left + (interval.right-interval.left)*float64(index)/samples + point, _, ok := occultationPathBoundaryVector(frame, theta) + if !ok { + continue + } + fixed := occultationPathEarthFixedVectorWithRotation(point, currentRotation) + distance := occultationPathNorm(occultationPathSub(fixed, targetFixed)) + if distance < bestDistance { + bestDistance, best, bestIndex = distance, point, index + } + } + if bestIndex < 0 { + continue + } + leftIndex := math.Max(float64(bestIndex-1), 0) + rightIndex := math.Min(float64(bestIndex+1), samples) + left := interval.left + (interval.right-interval.left)*leftIndex/samples + right := interval.left + (interval.right-interval.left)*rightIndex/samples + const goldenRatio = 0.6180339887498949 + x1 := right - goldenRatio*(right-left) + x2 := left + goldenRatio*(right-left) + distanceAt := func(theta float64) (float64, occultationPathVector, bool) { + point, _, ok := occultationPathBoundaryVector(frame, theta) + if !ok { + return math.Inf(1), occultationPathVector{}, false + } + fixed := occultationPathEarthFixedVectorWithRotation(point, currentRotation) + return occultationPathNorm(occultationPathSub(fixed, targetFixed)), point, true + } + f1, _, _ := distanceAt(x1) + f2, _, _ := distanceAt(x2) + for iteration := 0; iteration < 24; iteration++ { + if f1 > f2 { + left = x1 + x1, f1 = x2, f2 + x2 = left + goldenRatio*(right-left) + f2, _, _ = distanceAt(x2) + } else { + right = x2 + x2, f2 = x1, f1 + x1 = right - goldenRatio*(right-left) + f1, _, _ = distanceAt(x1) + } + } + distance, point, ok := distanceAt((left + right) / 2) + if ok && distance < bestDistance { + bestDistance, best = distance, point + } + } + return best, finite(bestDistance) + } + first, firstOK := nearest(previous.first) + second, secondOK := nearest(previous.second) + if !firstOK || !secondOK || occultationPathNorm(occultationPathSub(first, second)) < 1 { + return occultationPathVector{}, occultationPathVector{}, false + } + return first, second, true +} + +func occultationPathRepairContourBoundaryPair( + previous, current occultationPathBoundaryPairSample, + frameAt occultationPathFrameFunc, + limitsAt func(float64, occultationPathFrameFunc) (occultationPathVector, occultationPathVector, occultationPathVector, bool), +) occultationPathBoundaryPairSample { + current = occultationPathOrientBoundaryPair(previous, current) + if occultationPathBoundaryPairDirectSpacing(previous, current) <= occultationPathBoundarySpacingKM { + return current + } + if frame, ok := frameAt(current.tt); ok && frame.targetRadius != 0 { + if first, second, ok := occultationPathContinueFiniteBoundaryPair( + frame, current.tt, previous, + ); ok { + continued := occultationPathOrientBoundaryPair(previous, occultationPathBoundaryPairSample{ + tt: current.tt, first: first, second: second, moon: frame.moon, + }) + if occultationPathBoundaryPairDirectSpacing(previous, continued) <= + occultationPathBoundaryPairDirectSpacing(previous, current) { + return continued + } + } + if _, _, _, finiteOK := limitsAt(current.tt, frameAt); !finiteOK { + return previous + } + } + _ = limitsAt + return current +} + +func occultationPathOrientBoundaryPair( + previous, current occultationPathBoundaryPairSample, +) occultationPathBoundaryPairSample { + direct, swapped := occultationPathBoundaryPairSpacings(previous, current) + if swapped < direct { + current.first, current.second = current.second, current.first + } + return current +} + +func occultationPathBoundaryPairDirectSpacing(first, second occultationPathBoundaryPairSample) float64 { + direct, _ := occultationPathBoundaryPairSpacings(first, second) + return direct +} + +func occultationPathBoundaryPairSpacings(first, second occultationPathBoundaryPairSample) (float64, float64) { + firstRotation := occultationPathEarthRotationAt(first.tt) + secondRotation := occultationPathEarthRotationAt(second.tt) + firstA := occultationPathEarthFixedVectorWithRotation(first.first, firstRotation) + firstB := occultationPathEarthFixedVectorWithRotation(first.second, firstRotation) + secondA := occultationPathEarthFixedVectorWithRotation(second.first, secondRotation) + secondB := occultationPathEarthFixedVectorWithRotation(second.second, secondRotation) direct := math.Max( occultationPathNorm(occultationPathSub(secondA, firstA)), occultationPathNorm(occultationPathSub(secondB, firstB)), @@ -378,23 +834,23 @@ func occultationPathBoundaryPairSpacing(first, second occultationPathBoundaryPai occultationPathNorm(occultationPathSub(secondB, firstA)), occultationPathNorm(occultationPathSub(secondA, firstB)), ) - return math.Min(direct, swapped) + return direct, swapped } func occultationPathCrossTrackLimitsForFrame( tt float64, frameAt occultationPathFrameFunc, -) (occultationPathVector, occultationPathVector, bool) { +) (occultationPathVector, occultationPathVector, occultationPathVector, bool) { frame, ok := frameAt(tt) before, beforeOK := frameAt(tt - occultationPathVelocityStepDays) after, afterOK := frameAt(tt + occultationPathVelocityStepDays) if !ok || !beforeOK || !afterOK { - return occultationPathVector{}, occultationPathVector{}, false + return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false } vx := after.moonProjectionX() - before.moonProjectionX() vy := after.moonProjectionY() - before.moonProjectionY() if math.Hypot(vx, vy) <= 1e-12 { - return occultationPathVector{}, occultationPathVector{}, false + return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false } // 复用日食中心线构造:取基准面中垂直于运动方向的两条影锥母线。点源掠过阶段将缺失母线限制到可见角度区间;有限目标使用最近可见区间两端,直到两条横向母线分别与地球相交。 @@ -410,9 +866,9 @@ func occultationPathCrossTrackLimitsForFrame( second, secondOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta+math.Pi) } if !firstOK || !secondOK { - return occultationPathVector{}, occultationPathVector{}, false + return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false } - return first, second, true + return first, second, frame.moon, true } intervals := occultationPathBoundaryThetaIntervals(frame) interval, intervalOK := occultationPathNearestThetaInterval(intervals, theta) @@ -420,17 +876,43 @@ func occultationPathCrossTrackLimitsForFrame( if intervalOK && occultationPathAngleDistance(interval.left, theta) > occultationPathAngleDistance(interval.left, theta+math.Pi) { first, second = second, first } - return first, second, true + return first, second, frame.moon, true } if !intervalOK { - return occultationPathVector{}, occultationPathVector{}, false + return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false } first, _, firstOK = occultationPathBoundaryVector(frame, interval.left) second, _, secondOK = occultationPathBoundaryVector(frame, interval.right) if !firstOK || !secondOK { - return occultationPathVector{}, occultationPathVector{}, false + return occultationPathVector{}, occultationPathVector{}, occultationPathVector{}, false } - return first, second, true + return first, second, frame.moon, true +} + +// occultationPathContourCrossTrackLimitsForFrame is used only for the static +// contact-contour band. A finite target may expose one long visible +// contact-cone arc; its horizon endpoints are closure points, not necessarily +// the two cross-track sides of the band. Scan that arc for the physical sides, +// while retaining the normal paired-limit fallback for grazing/degenerate +// samples. +func occultationPathContourCrossTrackLimitsForFrame( + tt float64, + frameAt occultationPathFrameFunc, +) (occultationPathVector, occultationPathVector, occultationPathVector, bool) { + frame, ok := frameAt(tt) + if !ok || frame.targetRadius == 0 { + return occultationPathCrossTrackLimitsForFrame(tt, frameAt) + } + before, beforeOK := frameAt(tt - occultationPathVelocityStepDays) + after, afterOK := frameAt(tt + occultationPathVelocityStepDays) + if !beforeOK || !afterOK { + return occultationPathCrossTrackLimitsForFrame(tt, frameAt) + } + first, second, _, extremaOK := occultationPathFiniteCrossTrackExtrema(tt, frame, before, after) + if extremaOK { + return first, second, frame.moon, true + } + return occultationPathCrossTrackLimitsForFrame(tt, frameAt) } func occultationPathAngleDistance(first, second float64) float64 { @@ -487,8 +969,26 @@ func occultationPathNearestThetaInterval( } func occultationPathBoundaryThetaIntervals(frame occultationPathFrame) []occultationPathThetaInterval { + if frame.boundary != nil { + frame.boundary.boundaryOnce.Do(func() { occultationPathBoundaryPrecompute(frame) }) + return frame.boundary.intervals + } + return occultationPathBoundaryThetaIntervalsUncached(frame) +} + +// occultationPathBoundaryThetaIntervalsUncached 是可见 θ 区间扫描本体;调用方通过 frame 级缓存复用结果。 +// occultationPathBoundaryThetaIntervalsUncached is the visible-theta scan itself; callers reuse it through the frame cache. +func occultationPathBoundaryThetaIntervalsUncached(frame occultationPathFrame) []occultationPathThetaInterval { step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints) discriminants := make([]float64, occultationPathBoundaryScanPoints) + occultationPathBoundaryFillGrid(frame, discriminants) + return occultationPathBoundaryThetaIntervalsFromGrid(frame, step, discriminants) +} + +// occultationPathBoundaryFillGrid 计算整圈 720 点判别式网格,失败点记为 -Inf。 +// occultationPathBoundaryFillGrid evaluates the 720-point discriminant grid, marking failures as -Inf. +func occultationPathBoundaryFillGrid(frame occultationPathFrame, discriminants []float64) { + step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints) for index := range discriminants { discriminant, _, _, ok := occultationPathBoundaryLine(frame, step*float64(index)) if !ok { @@ -496,7 +996,15 @@ func occultationPathBoundaryThetaIntervals(frame occultationPathFrame) []occulta } discriminants[index] = discriminant } +} +// occultationPathBoundaryThetaIntervalsFromGrid 在已算好的网格上恢复可见 θ 区间。 +// occultationPathBoundaryThetaIntervalsFromGrid recovers the visible theta intervals from a prepared grid. +func occultationPathBoundaryThetaIntervalsFromGrid( + frame occultationPathFrame, + step float64, + discriminants []float64, +) []occultationPathThetaInterval { intervals := make([]occultationPathThetaInterval, 0, 2) for index, value := range discriminants { previous := discriminants[(index+len(discriminants)-1)%len(discriminants)] @@ -575,7 +1083,7 @@ func occultationPathCenterSamplesForFrame( points := make([]OccultationPathPoint, 0, len(times)) for _, tt := range times { point, ok := occultationPathCenterPointForFrame(tt, frameAt, location) - if ok { + if ok && (len(points) == 0 || point.Time.After(points[len(points)-1].Time)) { points = append(points, point) } } @@ -631,6 +1139,10 @@ func appendOccultationPathSegmentForFrame( return nil, ErrOccultationPathSamplingLimit } midTT := (centerTimeTT(start.Time) + centerTimeTT(end.Time)) / 2 + midTime := occultationTTToLocation(midTT, location) + if !midTime.After(start.Time) || !midTime.Before(end.Time) { + return append(points, end), nil + } mid, ok := occultationPathCenterPointForFrameWithoutWidth(midTT, frameAt, location) if !ok { return append(points, end), nil @@ -653,19 +1165,46 @@ func planetOccultationTotalPathFrameAt(tt float64, config planetOccultationConfi } func planetOccultationContactPathFrameAt(tt float64, config planetOccultationConfig, total bool) (occultationPathFrame, bool) { + state := planetOccultationEphemerisStateAt(tt, config) + return planetOccultationContactPathFrameFromState(state, config, total) +} + +func planetOccultationEphemerisStateAt(tt float64, config planetOccultationConfig) planetOccultationEphemerisState { moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1) moonDistance := HMoonAwayN(tt, -1) - planetRA, planetDec := config.apparentRaDecN(tt, -1) - planetDistance := config.earthDistanceN(tt, -1) * occultationPathAstronomicalUnitKM - if !finite(moonRA) || !finite(moonDec) || !finite(moonDistance) || moonDistance <= 0 || - !finite(planetRA) || !finite(planetDec) || !finite(planetDistance) || planetDistance <= moonDistance { + planetRA, planetDec, planetDistanceAU := planetOccultationApparentPositionAndDistanceN(tt, config, -1) + planetDistance := planetDistanceAU * occultationPathAstronomicalUnitKM + return planetOccultationEphemerisState{ + moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance, + planetRA: planetRA, planetDec: planetDec, planetDistanceKM: planetDistance, + valid: finite(moonRA) && finite(moonDec) && finite(moonDistance) && moonDistance > 0 && + finite(planetRA) && finite(planetDec) && finite(planetDistance) && planetDistance > 0, + } +} + +func planetOccultationApparentPositionAndDistanceN( + tt float64, + config planetOccultationConfig, + n int, +) (float64, float64, float64) { + position, _, distanceAU := planetApparentGeocentricPositionAndDistanceN(config.planetIndex, tt, n) + ra, dec := planetApparentRaDecFromLoBo(tt, position.lo, position.bo) + return ra, dec, distanceAU +} + +func planetOccultationContactPathFrameFromState( + state planetOccultationEphemerisState, + config planetOccultationConfig, + total bool, +) (occultationPathFrame, bool) { + if !state.valid || state.planetDistanceKM <= state.moonDistanceKM { return occultationPathFrame{}, false } - moon := occultationPathRaDecVector(moonRA, moonDec, moonDistance) - target := occultationPathRaDecVector(planetRA, planetDec, planetDistance) + moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM) + target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM) moonToTarget := occultationPathSub(target, moon) moonToTargetDistance := occultationPathNorm(moonToTarget) - moonRadius := MoonSemidiameter(tt) * math.Pi / (180 * 3600) + moonRadius := math.Asin(moonEquatorialRadiusKM / state.moonDistanceKM) moonRadiusKM := occultationPathNorm(moon) * math.Sin(moonRadius) contactRadiusKM := moonRadiusKM + config.equatorialRadiusKM if total { @@ -692,6 +1231,119 @@ func planetOccultationContactPathFrameAt(tt float64, config planetOccultationCon }, true } +func (cache *planetOccultationEventCache) stateAt(tt float64) planetOccultationEphemerisState { + key := math.Float64bits(tt) + if state, ok := cache.states[key]; ok { + return state + } + if len(cache.states) >= planetOccultationEventCacheMaximumEntries { + for cachedKey := range cache.states { + delete(cache.states, cachedKey) + } + for cachedKey := range cache.outerFrames { + delete(cache.outerFrames, cachedKey) + } + for cachedKey := range cache.totalFrames { + delete(cache.totalFrames, cachedKey) + } + } + var state planetOccultationEphemerisState + interpolated := false + if cache.local != nil && cache.local.dense { + state, interpolated = cache.local.stateAt(tt) + } + if !interpolated { + state = planetOccultationEphemerisStateAt(tt, cache.config) + } + cache.states[key] = state + return state +} + +func (cache *planetOccultationEventCache) prepareLocalEphemeris(center float64) { + if cache.local == nil { + cache.local = newPlanetOccultationLocalEphemeris(center, cache.config) + } +} + +func (cache *planetOccultationEventCache) candidateFrameAt(tt float64) (occultationPathFrame, bool) { + return cache.candidateFrameAtKind(tt, false) +} + +func (cache *planetOccultationEventCache) candidateTotalFrameAt(tt float64) (occultationPathFrame, bool) { + return cache.candidateFrameAtKind(tt, true) +} + +func (cache *planetOccultationEventCache) candidateFrameAtKind(tt float64, total bool) (occultationPathFrame, bool) { + if cache.local != nil { + if state, ok := cache.local.stateAt(tt); ok { + return planetOccultationContactPathFrameFromState(state, cache.config, total) + } + } + return cache.frameAt(tt, total) +} + +func (cache *planetOccultationEventCache) outerFrameAt(tt float64) (occultationPathFrame, bool) { + return cache.frameAt(tt, false) +} + +func (cache *planetOccultationEventCache) totalFrameAt(tt float64) (occultationPathFrame, bool) { + return cache.frameAt(tt, true) +} + +func (cache *planetOccultationEventCache) frameAt(tt float64, total bool) (occultationPathFrame, bool) { + key := math.Float64bits(tt) + frames := cache.outerFrames + if total { + frames = cache.totalFrames + } + if entry, ok := frames[key]; ok { + return entry.frame, entry.ok + } + frame, ok := planetOccultationContactPathFrameFromState(cache.stateAt(tt), cache.config, total) + if ok { + frame.boundary = &occultationPathBoundaryCache{} + } + frames[key] = planetOccultationFrameCacheEntry{frame: frame, ok: ok} + return frame, ok +} + +func (cache *planetOccultationEventCache) riseSetContextAt(tt float64) occultationRiseSetContext { + state := cache.stateAt(tt) + return newOccultationRiseSetContext( + tt, state.moonRA, state.moonDec, state.moonDistanceKM, + state.planetRA, state.planetDec, state.planetDistanceKM, cache.config.equatorialRadiusKM, + ) +} + +func (cache *planetOccultationEventCache) candidateRiseSetContextAt(tt float64) occultationRiseSetContext { + var context occultationRiseSetContext + if cache.local != nil { + moonXYZ, targetXYZ, ok := cache.local.vectorsAt(tt) + if ok { + context = newOccultationRiseSetContextFromVectors( + tt, + moonXYZ, + targetXYZ, + true, + cache.config.equatorialRadiusKM, + ) + } else { + context = cache.riseSetContextAt(tt) + } + } else { + context = cache.riseSetContextAt(tt) + } + return context +} + +func (cache *planetOccultationEventCache) totalRiseSetContextAt(tt float64) occultationRiseSetContext { + return cache.riseSetContextAt(tt).withInternalContact() +} + +func (cache *planetOccultationEventCache) candidateTotalRiseSetContextAt(tt float64) occultationRiseSetContext { + return cache.candidateRiseSetContextAt(tt).withInternalContact() +} + func occultationPathFrameHasBoundary(frame occultationPathFrame) bool { _, _, ok := occultationPathBoundaryTangent(frame) return ok @@ -773,6 +1425,26 @@ func occultationPathBoundaryPointForFrame( return occultationPathPointFromVector(tt, point, occultationPathBoundaryWidth(north, south, limitsOK), location), true } +func occultationPathTrackPointForFrame( + tt float64, + frameAt occultationPathFrameFunc, + location *time.Location, +) (OccultationPathPoint, bool) { + frame, ok := frameAt(tt) + if !ok { + return OccultationPathPoint{}, false + } + point, ok := occultationPathTrackReference(frame) + if !ok { + return OccultationPathPoint{}, false + } + width := 0.0 + if _, _, candidate, limitsOK := occultationPathLimitsAndWidthForFrame(tt, frameAt); limitsOK { + width = candidate + } + return occultationPathPointFromVectorWithMoon(tt, point, width, frame.moon, location), true +} + func occultationPathLimitsAndWidthForFrame( tt float64, frameAt occultationPathFrameFunc, @@ -805,8 +1477,10 @@ func occultationPathLimitsAndWidthForFrame( center, centerOK := occultationPathTrackReference(frame) before, beforeCenterOK := occultationPathTrackReference(beforeFrame) after, afterCenterOK := occultationPathTrackReference(afterFrame) + centerRotation := occultationPathEarthRotation{} if centerOK && beforeCenterOK && afterCenterOK { - centerFixed = occultationPathEarthFixedVector(tt, center) + centerRotation = occultationPathEarthRotationAt(tt) + centerFixed = occultationPathEarthFixedVectorWithRotation(center, centerRotation) beforeFixed := occultationPathEarthFixedVector(tt-occultationPathVelocityStepDays, before) afterFixed := occultationPathEarthFixedVector(tt+occultationPathVelocityStepDays, after) polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio @@ -836,7 +1510,7 @@ func occultationPathLimitsAndWidthForFrame( maximumPoint = point } if groundWidthOK { - fixed := occultationPathEarthFixedVector(tt, point) + fixed := occultationPathEarthFixedVectorWithRotation(point, centerRotation) groundOffset := occultationPathDot(occultationPathSub(fixed, centerFixed), groundCrossTrack) if groundOffset < minimumGroundOffset { minimumGroundOffset = groundOffset @@ -879,17 +1553,137 @@ func occultationPathLimitsAndWidthForFrame( return maximumGroundPoint, minimumGroundPoint, width, true } } - _, minimumLatitude := occultationPathGeodetic(tt, minimumPoint) - _, maximumLatitude := occultationPathGeodetic(tt, maximumPoint) + minimumLatitude := occultationPathGeodeticLatitude(minimumPoint) + maximumLatitude := occultationPathGeodeticLatitude(maximumPoint) if maximumLatitude >= minimumLatitude { return maximumPoint, minimumPoint, width, true } return minimumPoint, maximumPoint, width, true } +// occultationPathFiniteCrossTrackExtrema scans the complete Earth-intersecting +// contact-cone arc and returns its physical cross-track extrema. For a finite +// target the visible arc can be much longer than the two horizon endpoints; +// those endpoints are only the moonrise/moonset closure, not the band sides. +func occultationPathFiniteCrossTrackExtrema( + tt float64, + frame, beforeFrame, afterFrame occultationPathFrame, +) (occultationPathVector, occultationPathVector, float64, bool) { + vx := afterFrame.moonProjectionX() - beforeFrame.moonProjectionX() + vy := afterFrame.moonProjectionY() - beforeFrame.moonProjectionY() + speed := math.Hypot(vx, vy) + if speed <= 1e-12 { + return occultationPathVector{}, occultationPathVector{}, 0, false + } + planeCrossTrack := occultationPathUnit(occultationPathAdd( + occultationPathScale(frame.first, -vy/speed), + occultationPathScale(frame.second, vx/speed), + )) + + var centerFixed, groundCrossTrack occultationPathVector + groundWidthOK := false + center, centerOK := occultationPathTrackReference(frame) + before, beforeCenterOK := occultationPathTrackReference(beforeFrame) + after, afterCenterOK := occultationPathTrackReference(afterFrame) + centerRotation := occultationPathEarthRotation{} + if centerOK && beforeCenterOK && afterCenterOK { + centerRotation = occultationPathEarthRotationAt(tt) + centerFixed = occultationPathEarthFixedVectorWithRotation(center, centerRotation) + beforeFixed := occultationPathEarthFixedVector( + tt-occultationPathVelocityStepDays, before, + ) + afterFixed := occultationPathEarthFixedVector( + tt+occultationPathVelocityStepDays, after, + ) + polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio + normal := occultationPathUnit(occultationPathVector{ + x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polarRatioSquared, + }) + track := occultationPathSub(afterFixed, beforeFixed) + track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal))) + if occultationPathNorm(track) > 1e-12 { + groundCrossTrack = occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track))) + groundWidthOK = true + } + } + + minimumOffset, maximumOffset := math.Inf(1), math.Inf(-1) + minimumGroundOffset, maximumGroundOffset := math.Inf(1), math.Inf(-1) + var minimumPoint, maximumPoint occultationPathVector + var minimumGroundPoint, maximumGroundPoint occultationPathVector + consider := func(point occultationPathVector) { + offset := occultationPathDot(point, planeCrossTrack) + if offset < minimumOffset { + minimumOffset, minimumPoint = offset, point + } + if offset > maximumOffset { + maximumOffset, maximumPoint = offset, point + } + if groundWidthOK { + fixed := occultationPathEarthFixedVectorWithRotation(point, centerRotation) + groundOffset := occultationPathDot( + occultationPathSub(fixed, centerFixed), groundCrossTrack, + ) + if groundOffset < minimumGroundOffset { + minimumGroundOffset, minimumGroundPoint = groundOffset, point + } + if groundOffset > maximumGroundOffset { + maximumGroundOffset, maximumGroundPoint = groundOffset, point + } + } + } + if tangentPoint, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame); tangentOK { + consider(tangentPoint) + if leftTheta, rightTheta, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta); intervalOK { + for index := 0; index <= 128; index++ { + theta := leftTheta + (rightTheta-leftTheta)*float64(index)/128 + if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK { + consider(point) + } + } + } + } + for _, interval := range occultationPathBoundaryThetaIntervals(frame) { + for index := 0; index <= 128; index++ { + theta := interval.left + (interval.right-interval.left)*float64(index)/128 + if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK { + consider(point) + } + } + } + if !finite(minimumOffset) || !finite(maximumOffset) { + return occultationPathVector{}, occultationPathVector{}, 0, false + } + if groundWidthOK && finite(minimumGroundOffset) && finite(maximumGroundOffset) { + width := maximumGroundOffset - minimumGroundOffset + if width > 0 { + return maximumGroundPoint, minimumGroundPoint, width, true + } + } + minimumLatitude := occultationPathGeodeticLatitude(minimumPoint) + maximumLatitude := occultationPathGeodeticLatitude(maximumPoint) + width := maximumOffset - minimumOffset + if maximumLatitude >= minimumLatitude { + return maximumPoint, minimumPoint, width, width > 0 + } + return minimumPoint, maximumPoint, width, width > 0 +} + func occultationPathScannedLimitsForFrame( tt float64, frame occultationPathFrame, ) (occultationPathVector, occultationPathVector, bool) { return occultationPathScannedLimitsAtFrame(tt, frame) } + +func occultationPathCrossTrackWidthForFrame( + tt float64, + frameAt occultationPathFrameFunc, +) (float64, bool) { + first, second, _, ok := occultationPathCrossTrackLimitsForFrame(tt, frameAt) + if !ok { + return 0, false + } + width := occultationPathNorm(occultationPathSub(first, second)) + return width, finite(width) && width > 0 +} diff --git a/basic/occultation_planet_path_test.go b/basic/occultation_planet_path_test.go index 25162e0..b949b27 100644 --- a/basic/occultation_planet_path_test.go +++ b/basic/occultation_planet_path_test.go @@ -7,6 +7,352 @@ import ( "time" ) +func TestPlanetOccultationCombinedPositionMatchesSeparateEphemerides(t *testing.T) { + tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC)) + for _, planet := range []OccultationPlanet{ + OccultationMercury, + OccultationVenus, + OccultationMars, + OccultationJupiter, + OccultationSaturn, + OccultationUranus, + OccultationNeptune, + } { + config, ok := planetOccultationConfigFor(planet) + if !ok { + t.Fatalf("%s occultation config is unavailable", planet) + } + wantRA, wantDec := config.apparentRaDecN(tt, -1) + wantDistance := config.earthDistanceN(tt, -1) + gotRA, gotDec, distance := planetOccultationApparentPositionAndDistanceN(tt, config, -1) + if gotRA != wantRA || gotDec != wantDec || distance != wantDistance { + t.Fatalf("%s combined position = %.15g %.15g %.15g, want %.15g %.15g %.15g", + planet, gotRA, gotDec, distance, wantRA, wantDec, wantDistance) + } + } +} + +func TestPlanetOccultationEventCacheReusesStateAndContactFrames(t *testing.T) { + config, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn occultation config is unavailable") + } + tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC)) + cache := newPlanetOccultationEventCache(config) + + wantOuter, wantOuterOK := planetOccultationPathFrameAt(tt, config) + wantTotal, wantTotalOK := planetOccultationTotalPathFrameAt(tt, config) + for iteration := 0; iteration < 2; iteration++ { + gotOuter, gotOuterOK := cache.outerFrameAt(tt) + gotTotal, gotTotalOK := cache.totalFrameAt(tt) + _ = cache.riseSetContextAt(tt) + if gotOuterOK != wantOuterOK || !occultationPathFrameGeometryEqual(gotOuter, wantOuter) { + t.Fatalf("cached outer frame differs on iteration %d", iteration) + } + if gotTotalOK != wantTotalOK || !occultationPathFrameGeometryEqual(gotTotal, wantTotal) { + t.Fatalf("cached total frame differs on iteration %d", iteration) + } + } + if len(cache.states) != 1 || len(cache.outerFrames) != 1 || len(cache.totalFrames) != 1 { + t.Fatalf("cache sizes = states:%d outer:%d total:%d, want one entry each", + len(cache.states), len(cache.outerFrames), len(cache.totalFrames)) + } +} + +func TestOccultationPathFramesReuseMoonDistanceForAngularRadius(t *testing.T) { + tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC)) + want := MoonSemidiameter(tt) * math.Pi / (180 * 3600) + starFrame, ok := starOccultationPathFrameAt(tt, StarCoordinate{ + RA: 0, Dec: 0, Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000, + }) + if !ok { + t.Fatal("stellar occultation frame is unavailable") + } + if difference := math.Abs(starFrame.moonRadius - want); difference > 1e-15 { + t.Fatalf("stellar cached lunar radius differs by %.15g radians", difference) + } + + config, _ := planetOccultationConfigFor(OccultationSaturn) + planetFrame, ok := planetOccultationPathFrameAt(tt, config) + if !ok { + t.Fatal("planet occultation frame is unavailable") + } + if difference := math.Abs(planetFrame.moonRadius - want); difference > 1e-15 { + t.Fatalf("planet cached lunar radius differs by %.15g radians", difference) + } +} + +func TestPlanetOccultationCanDisableInstantaneousFootprints(t *testing.T) { + start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.PartialFootprints) != 0 || len(path.TotalFootprints) != 0 { + t.Fatalf("disabled footprint counts partial=%d total=%d, want zero", len(path.PartialFootprints), len(path.TotalFootprints)) + } + if len(path.PartialBandFootprints) == 0 || len(path.TotalBandFootprints) == 0 { + t.Fatalf("compact band support counts partial=%d total=%d, want both nonzero", len(path.PartialBandFootprints), len(path.TotalBandFootprints)) + } + if len(path.RiseSetCurves) != 6 { + t.Fatalf("rise/set curve count=%d, want six", len(path.RiseSetCurves)) + } + if len(path.TotalRiseSetCurves) != 6 { + t.Fatalf("total rise/set curve count=%d, want six", len(path.TotalRiseSetCurves)) + } + if len(path.CenterLine) == 0 || len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0 { + t.Fatal("disabled footprints removed the center line or outer limits") + } + if !path.HasTotalBand || len(path.NorthernTotalLimit) == 0 || len(path.SouthernTotalLimit) == 0 { + t.Fatal("disabled footprints removed the total-occultation band") + } +} + +func TestPlanetOccultationCompactBandCanIncludeLowFrequencyTimeline(t *testing.T) { + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{ + Step: 20 * time.Minute, DisableFootprints: true, + IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.PartialBandFootprints) == 0 || len(path.PartialFootprints) == 0 { + t.Fatalf("partial compact/timeline counts=%d/%d, want both", len(path.PartialBandFootprints), len(path.PartialFootprints)) + } + if len(path.TotalBandFootprints) == 0 || len(path.TotalFootprints) == 0 { + t.Fatalf("total compact/timeline counts=%d/%d, want both", len(path.TotalBandFootprints), len(path.TotalFootprints)) + } + if len(path.PartialFootprints) > 50 || len(path.TotalFootprints) > 50 { + t.Fatalf("five-minute timeline is unexpectedly dense: partial=%d total=%d", len(path.PartialFootprints), len(path.TotalFootprints)) + } + timelinePoints := 0 + for _, footprints := range [][]PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} { + for _, footprint := range footprints { + for polygonIndex, polygon := range footprint.Polygons { + if len(polygon) < 4 || occultationPathDistanceKM(polygon[0], polygon[len(polygon)-1]) > 0.001 { + t.Fatalf("timeline footprint at %v polygon %d is not closed", footprint.Time, polygonIndex) + } + timelinePoints += len(polygon) + for pointIndex := 1; pointIndex < len(polygon); pointIndex++ { + if distance := occultationPathDistanceKM(polygon[pointIndex-1], polygon[pointIndex]); distance > 301 { + t.Fatalf("timeline footprint at %v polygon %d edge %d spans %.1f km, want at most 301 km", + footprint.Time, polygonIndex, pointIndex-1, distance) + } + } + } + } + } + if timelinePoints > 15000 { + t.Fatalf("timeline contains %d polygon points, want at most 15000", timelinePoints) + } +} + +func TestStarOccultationCompactBandUsesIndependentTimeline(t *testing.T) { + start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC) + paths, err := FindStarOccultationPaths( + start, start.Add(24*time.Hour), hr4799OccultationCoordinateForTest(), + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.BandFootprints) == 0 || len(path.Footprints) == 0 { + t.Fatalf("compact/timeline counts=%d/%d, want both", len(path.BandFootprints), len(path.Footprints)) + } + for _, footprint := range path.Footprints { + for polygonIndex, polygon := range footprint.Polygons { + if len(polygon) < 4 || occultationPathDistanceKM(polygon[0], polygon[len(polygon)-1]) > 0.001 { + t.Fatalf("timeline footprint at %v polygon %d is not closed", footprint.Time, polygonIndex) + } + for pointIndex := 1; pointIndex < len(polygon); pointIndex++ { + if distance := occultationPathDistanceKM(polygon[pointIndex-1], polygon[pointIndex]); distance > 301 { + t.Fatalf("timeline footprint at %v polygon %d edge %d spans %.1f km, want at most 301 km", + footprint.Time, polygonIndex, pointIndex-1, distance) + } + } + } + } +} + +func TestOccultationPathPointBudgetIsBounded(t *testing.T) { + options := normalizeOccultationPathOptions(OccultationPathOptions{}) + got := occultationPathEstimatedPointCount(0, 0.2, 0, 0, false, 0, 0, false, 0.1, options) + if got <= 0 || got > occultationPathMaxOutputPointCount { + t.Fatalf("default occultation point estimate=%d, want within positive budget", got) + } + dense := normalizeOccultationPathOptions(OccultationPathOptions{Step: time.Second}) + got = occultationPathEstimatedPointCount(0, 2, 0, 2, true, 0, 2, true, 1, dense) + if got <= occultationPathFootprintPointBudget || got > occultationPathMaxOutputPointCount { + t.Fatalf("dense occultation point estimate=%d, want footprint-aware value within budget %d", + got, occultationPathMaxOutputPointCount) + } + if overflow := occultationPathAccumulatePointEstimate(occultationPathMaxOutputPointCount-10, 20); overflow <= occultationPathMaxOutputPointCount { + t.Fatalf("overflow estimate=%d, want sentinel above %d", overflow, occultationPathMaxOutputPointCount) + } +} + +func TestPlanetOccultation19621010TotalBandIsNarrowerThanOuterBand(t *testing.T) { + start := time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationJupiter, + OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true, DisableRiseSet: true}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if !path.HasTotalBand || path.GreatestTotalWidthKM <= 0 || path.GreatestTotalWidthKM >= path.Greatest.WidthKM { + t.Fatalf("widths outer=%.3f total=%.3f hasTotal=%v, want a narrower positive total band", + path.Greatest.WidthKM, path.GreatestTotalWidthKM, path.HasTotalBand) + } +} + +func TestPlanetOccultation20240725CompactBandRefinesContactsAndBoundaryPairing(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + for _, test := range []struct { + name string + footprints []PlanetOccultationFootprint + start, end time.Time + }{ + {name: "partial", footprints: path.PartialBandFootprints, start: path.Start.Time, end: path.End.Time}, + {name: "total", footprints: path.TotalBandFootprints, start: path.TotalStart.Time, end: path.TotalEnd.Time}, + } { + if len(test.footprints) < 2 { + t.Fatalf("%s compact support count=%d, want at least two", test.name, len(test.footprints)) + } + if gap := test.footprints[0].Time.Sub(test.start); gap > 30*time.Second { + t.Errorf("%s compact support starts %s after contact, want at most 30s", test.name, gap) + } + if gap := test.end.Sub(test.footprints[len(test.footprints)-1].Time); gap > 30*time.Second { + t.Errorf("%s compact support ends %s before contact, want at most 30s", test.name, gap) + } + for footprintIndex, footprint := range test.footprints { + if footprintIndex > 0 { + previous := test.footprints[footprintIndex-1] + if previous.Closed && footprint.Closed && footprint.Time.Sub(previous.Time) <= time.Second { + t.Errorf("%s compact support retains duplicate closed footprints at %s and %s", + test.name, previous.Time, footprint.Time) + } + } + for polygonIndex, polygon := range footprint.Polygons { + for pointIndex := 1; pointIndex < len(polygon); pointIndex++ { + if distance := occultationPathDistanceKM(polygon[pointIndex-1], polygon[pointIndex]); distance > 150 { + t.Errorf("%s compact support[%d].polygon[%d] edge %d spans %.1f km, want at most 150 km", + test.name, footprintIndex, polygonIndex, pointIndex-1, distance) + } + } + } + } + } + for _, test := range []struct { + name string + first, second []OccultationPathPoint + }{ + {name: "partial", first: path.NorthernLimit, second: path.SouthernLimit}, + {name: "total", first: path.NorthernTotalLimit, second: path.SouthernTotalLimit}, + } { + for index := 1; index < len(test.first); index++ { + direct := math.Max( + occultationPathDistanceKM(test.first[index-1], test.first[index]), + occultationPathDistanceKM(test.second[index-1], test.second[index]), + ) + swapped := math.Max( + occultationPathDistanceKM(test.first[index-1], test.second[index]), + occultationPathDistanceKM(test.second[index-1], test.first[index]), + ) + if direct > 2000 && swapped < 750 { + t.Errorf("%s boundary sample %d keeps a %.1f km direct pairing although the swapped pairing is %.1f km", + test.name, index, direct, swapped) + } + } + } +} + +func TestPlanetOccultation20250105CompactBandRefinesVisibilityTransitions(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableRiseSet: true, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + for _, test := range []struct { + name string + footprints []PlanetOccultationFootprint + }{ + {name: "partial", footprints: paths[0].PartialBandFootprints}, + {name: "total", footprints: paths[0].TotalBandFootprints}, + } { + transitions := 0 + for index := 1; index < len(test.footprints); index++ { + previous, current := test.footprints[index-1], test.footprints[index] + if previous.Closed == current.Closed { + continue + } + transitions++ + if gap := current.Time.Sub(previous.Time); gap > 150*time.Millisecond { + t.Errorf("%s visibility transition %d spans %s, want at most 150ms", test.name, transitions, gap) + } + open := previous + if open.Closed { + open = current + } + if len(open.Boundaries) != 1 || len(open.Boundaries[0]) < 2 { + t.Fatalf("%s visibility transition %d has no open boundary", test.name, transitions) + } + // Boundaries contain the visible contact arc, not a closed ring. Its + // endpoints may remain far apart at the geocentric open/closed + // transition because station parallax changes the topology. The physical + // contract is that both endpoints lie on the lunar horizon and the + // separately exported polygon closes them with the horizon arc. + boundary := open.Boundaries[0] + for _, endpoint := range []OccultationPathPoint{boundary[0], boundary[len(boundary)-1]} { + if math.Abs(endpoint.MoonAltitude) > 1e-5 { + t.Errorf("%s visibility transition %d endpoint altitude=%g deg, want horizon root", + test.name, transitions, endpoint.MoonAltitude) + } + } + if len(open.Polygons) == 0 || len(open.Polygons[0]) < len(boundary)+2 { + t.Errorf("%s visibility transition %d has no horizon-closed polygon", test.name, transitions) + } + } + if transitions != 2 { + t.Errorf("%s visibility transition count=%d, want 2", test.name, transitions) + } + } +} + func TestPlanetOccultationFiniteDiskExpandsOuterAndContractsTotalPath(t *testing.T) { config, ok := planetOccultationConfigFor(OccultationSaturn) if !ok { @@ -340,3 +686,40 @@ func assertOccultationPathCommonSamplesEqual(t *testing.T, name string, fine, co t.Fatalf("%s compared only %d common samples, want at least 10", name, matched) } } + +func TestPlanetOccultation20250114MarsBandContourTimesIncrease(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 14, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationMars, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + for _, band := range []struct { + name string + contours [][]OccultationPathPoint + }{ + {name: "partial", contours: paths[0].PartialBandContours}, + {name: "total", contours: paths[0].TotalBandContours}, + } { + for contourIndex, contour := range band.contours { + for pointIndex := 1; pointIndex < len(contour); pointIndex++ { + previous, current := contour[pointIndex-1], contour[pointIndex] + if !current.Time.After(previous.Time) { + t.Fatalf( + "%s contour %d times do not increase at %d: %s then %s (delta=%s, distance=%.6f km)", + band.name, contourIndex, pointIndex, + previous.Time.Format(time.RFC3339Nano), current.Time.Format(time.RFC3339Nano), + current.Time.Sub(previous.Time), occultationPathDistanceKM(previous, current), + ) + } + } + } + } +} diff --git a/basic/occultation_planet_projection_test.go b/basic/occultation_planet_projection_test.go new file mode 100644 index 0000000..7456b7f --- /dev/null +++ b/basic/occultation_planet_projection_test.go @@ -0,0 +1,47 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +func TestMars20250729FiniteContactContoursRespectWebMercatorSpacing(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute, + }) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + for _, item := range []struct { + name string + contours [][]OccultationPathPoint + }{ + {name: "partial", contours: paths[0].PartialBandContours}, + {name: "total", contours: paths[0].TotalBandContours}, + } { + for contourIndex, contour := range item.contours { + for pointIndex := 1; pointIndex < len(contour); pointIndex++ { + spacing := webMercatorGeoPointSpacingKM(contour[pointIndex-1], contour[pointIndex]) + if spacing > 45 { + t.Fatalf("%s contour %d edge %d has %.1f km Web Mercator spacing, want <=45 km", item.name, contourIndex, pointIndex, spacing) + } + } + } + } +} + +func webMercatorGeoPointSpacingKM(first, second OccultationPathPoint) float64 { + const maxLatitude = 85.05112878 + const radiusKM = 6378.1366 + clamp := func(value float64) float64 { return math.Max(-maxLatitude, math.Min(maxLatitude, value)) } + longitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180 + firstLatitude := clamp(first.Latitude) * math.Pi / 180 + secondLatitude := clamp(second.Latitude) * math.Pi / 180 + firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2)) + secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2)) + return radiusKM * math.Hypot(longitude, secondY-firstY) +} diff --git a/basic/occultation_rise_set.go b/basic/occultation_rise_set.go new file mode 100644 index 0000000..fcece48 --- /dev/null +++ b/basic/occultation_rise_set.go @@ -0,0 +1,931 @@ +package basic + +import ( + "math" + "time" +) + +const ( + occultationRiseSetBoundaryPoints = 180 + occultationRiseSetDerivativeStepDays = 5.0 / 86400.0 + // A polar phase fold can create or remove a root branch inside one coarse + // rise/set interval. Refine only those topology-changing intervals rather + // than lowering the global sampling step for every event. + occultationRiseSetAdaptiveMaximumPasses = 1 + occultationRiseSetAdaptiveMinimumStepDays = 5.0 / 86400.0 + occultationRiseSetAdaptiveMaximumIntervals = 64 + occultationRiseSetFoldRootToleranceDays = 1e-10 + occultationRiseSetJunctionDerivativeTolerance = 1e-6 + // Beyond this point, recomputing the small vector state costs less than growing every per-TT map. + occultationRiseSetStateCacheMaximumEntries = 8 + // 48 h 窗口按 1 分钟采样需要 2880 个时刻、每个时刻三个上下文(中心/前/后), + // 4096 个槽位会在事件中途反复整表清空并重算精确星历。 + // A 48 h window sampled every minute needs 2880 instants with three contexts each + // (center/before/after); 4096 slots cleared the whole table repeatedly mid-event and + // recomputed the exact ephemerides it had just dropped. + occultationRiseSetEvaluationCacheMaximumEntries = 16384 + occultationRiseSetTimeEpsilonDays = 1e-8 + occultationRiseSetTargetSpacingKM = 100.0 + occultationTopocentricEarthRadiusKM = 6378.14 + occultationTopocentricEarthPolarRadiusKM = 6356.755 +) + +// 站心赤经赤纬的历史公式使用固定地平视差常数;保留该尺度可使向量算法与既有结果数值等价。 +// The legacy topocentric RA/Dec formula uses a fixed horizontal-parallax constant; retaining that scale keeps the vector implementation numerically equivalent. +var occultationLegacyParallaxRadiusKM = math.Sin(0.0024427777777*rad) * occultationPathAstronomicalUnitKM + +type occultationRiseSetBody struct { + direction occultationPathVector + positionKM occultationPathVector + distanceKM float64 +} + +type occultationRiseSetContext struct { + tt float64 + siderealDegrees float64 + moonRA float64 + moonDec float64 + moon occultationRiseSetBody + target occultationRiseSetBody + targetRadiusKM float64 + internalContact bool + valid bool + states map[occultationRiseSetStateKey]occultationRiseSetState +} + +type occultationRiseSetStateKey struct { + longitude uint64 + latitude uint64 +} + +type occultationRiseSetState struct { + // contactMetric is selected for the context that reads the cached state. + // The two underlying metrics are retained so external and internal contact + // contexts can share the expensive topocentric geometry without sharing the + // wrong contact equation. + contactMetric float64 + externalContactMetric float64 + internalContactMetric float64 + separationSquared float64 + moonAltitude float64 + valid bool +} + +type occultationRiseSetEvaluation struct { + tt float64 + center occultationRiseSetContext + before occultationRiseSetContext + after occultationRiseSetContext +} + +type occultationRiseSetCurveKey struct { + phase RiseSetPhase + direction RiseSetDirection +} + +type occultationRiseSetTrack struct { + segments [][]OccultationPathPoint +} + +type occultationRiseSetContextFunc func(float64) occultationRiseSetContext + +// occultationRiseSetEvaluationCache keeps ephemeris contexts and their +// center/before/after derivative bundle at the event level. Root refinements +// revisit the same TT values many times, especially while joining branches. +type occultationRiseSetEvaluationCache struct { + contextAt occultationRiseSetContextFunc + candidateContextAt occultationRiseSetContextFunc + contexts map[uint64]occultationRiseSetContext + candidateContexts map[uint64]occultationRiseSetContext + evaluations map[uint64]occultationRiseSetEvaluation + candidateEvaluations map[uint64]occultationRiseSetEvaluation +} + +func newOccultationRiseSetEvaluationCache(contextAt occultationRiseSetContextFunc) *occultationRiseSetEvaluationCache { + return newOccultationRiseSetEvaluationCacheWithCandidate(contextAt, nil) +} + +func newOccultationRiseSetEvaluationCacheWithCandidate( + contextAt, candidateContextAt occultationRiseSetContextFunc, +) *occultationRiseSetEvaluationCache { + return &occultationRiseSetEvaluationCache{ + contextAt: contextAt, + candidateContextAt: candidateContextAt, + contexts: make(map[uint64]occultationRiseSetContext), + candidateContexts: make(map[uint64]occultationRiseSetContext), + evaluations: make(map[uint64]occultationRiseSetEvaluation), + candidateEvaluations: make(map[uint64]occultationRiseSetEvaluation), + } +} + +func (cache *occultationRiseSetEvaluationCache) context(tt float64) occultationRiseSetContext { + key := math.Float64bits(tt) + if context, ok := cache.contexts[key]; ok { + return context + } + context := cache.contextAt(tt) + if len(cache.contexts) >= occultationRiseSetEvaluationCacheMaximumEntries { + clearOccultationRiseSetContexts(cache.contexts) + clearOccultationRiseSetEvaluations(cache.evaluations) + } + cache.contexts[key] = context + return context +} + +func (cache *occultationRiseSetEvaluationCache) candidateContext(tt float64) occultationRiseSetContext { + if cache.candidateContextAt == nil { + return cache.context(tt) + } + key := math.Float64bits(tt) + if context, ok := cache.candidateContexts[key]; ok { + return context + } + context := cache.candidateContextAt(tt) + if len(cache.candidateContexts) >= occultationRiseSetEvaluationCacheMaximumEntries { + clearOccultationRiseSetContexts(cache.candidateContexts) + clearOccultationRiseSetEvaluations(cache.candidateEvaluations) + } + cache.candidateContexts[key] = context + return context +} + +func (cache *occultationRiseSetEvaluationCache) evaluation(tt float64) occultationRiseSetEvaluation { + return cache.evaluationAt(tt, false) +} + +func (cache *occultationRiseSetEvaluationCache) candidateEvaluation(tt float64) occultationRiseSetEvaluation { + return cache.evaluationAt(tt, true) +} + +func (cache *occultationRiseSetEvaluationCache) candidateOnly() *occultationRiseSetEvaluationCache { + if cache == nil || cache.candidateContextAt == nil { + return cache + } + return &occultationRiseSetEvaluationCache{ + contextAt: cache.candidateContextAt, + contexts: cache.candidateContexts, + evaluations: cache.candidateEvaluations, + } +} + +func (cache *occultationRiseSetEvaluationCache) evaluationAt(tt float64, candidate bool) occultationRiseSetEvaluation { + key := math.Float64bits(tt) + evaluations := cache.evaluations + context := cache.context + if candidate && cache.candidateContextAt != nil { + evaluations = cache.candidateEvaluations + context = cache.candidateContext + } + if evaluation, ok := evaluations[key]; ok { + return evaluation + } + evaluation := occultationRiseSetEvaluation{ + tt: tt, + center: context(tt), + before: context(tt - occultationRiseSetDerivativeStepDays), + after: context(tt + occultationRiseSetDerivativeStepDays), + } + if len(evaluations) >= occultationRiseSetEvaluationCacheMaximumEntries { + clearOccultationRiseSetEvaluations(evaluations) + if candidate { + clearOccultationRiseSetContexts(cache.candidateContexts) + } else { + clearOccultationRiseSetContexts(cache.contexts) + } + } + evaluations[key] = evaluation + return evaluation +} + +func clearOccultationRiseSetContexts(values map[uint64]occultationRiseSetContext) { + for key := range values { + delete(values, key) + } +} + +func clearOccultationRiseSetEvaluations(values map[uint64]occultationRiseSetEvaluation) { + for key := range values { + delete(values, key) + } +} + +func newOccultationRiseSetContext( + tt, moonRA, moonDec, moonDistanceKM, + targetRA, targetDec, targetDistanceKM, targetRadiusKM float64, +) occultationRiseSetContext { + moon := newOccultationRiseSetBody(moonRA, moonDec, moonDistanceKM) + target := newOccultationRiseSetBody(targetRA, targetDec, targetDistanceKM) + return occultationRiseSetContext{ + tt: tt, + siderealDegrees: ApparentSiderealTime(TD2UT(tt, false)) * 15, + moonRA: moonRA, + moonDec: moonDec, + moon: moon, + target: target, + targetRadiusKM: targetRadiusKM, + valid: finite(moonRA) && finite(moonDec) && finite(moonDistanceKM) && moonDistanceKM > 0 && + finite(targetRA) && finite(targetDec) && finite(targetDistanceKM) && targetDistanceKM >= 0 && + finite(targetRadiusKM) && targetRadiusKM >= 0, + states: make(map[occultationRiseSetStateKey]occultationRiseSetState), + } +} + +func (context occultationRiseSetContext) withInternalContact() occultationRiseSetContext { + context.internalContact = true + return context +} + +func newOccultationRiseSetContextFromVectors( + tt float64, + moonXYZ, targetXYZ [3]float64, + targetAtFiniteDistance bool, + targetRadiusKM float64, +) occultationRiseSetContext { + moon, moonRA, moonDec, moonOK := occultationRiseSetBodyFromVector(moonXYZ, true) + target, _, _, targetOK := occultationRiseSetBodyFromVector(targetXYZ, targetAtFiniteDistance) + return occultationRiseSetContext{ + tt: tt, + siderealDegrees: ApparentSiderealTime(TD2UT(tt, false)) * 15, + moonRA: moonRA, + moonDec: moonDec, + moon: moon, + target: target, + targetRadiusKM: targetRadiusKM, + valid: moonOK && targetOK && finite(targetRadiusKM) && targetRadiusKM >= 0, + states: make(map[occultationRiseSetStateKey]occultationRiseSetState), + } +} + +func occultationRiseSetBodyFromVector( + xyz [3]float64, + atFiniteDistance bool, +) (occultationRiseSetBody, float64, float64, bool) { + vector := occultationPathVector{x: xyz[0], y: xyz[1], z: xyz[2]} + distanceKM := occultationPathNorm(vector) + if !finite(distanceKM) || distanceKM <= 0 { + return occultationRiseSetBody{}, 0, 0, false + } + direction := occultationPathScale(vector, 1/distanceKM) + ra := normalizeRA(math.Atan2(direction.y, direction.x) / rad) + dec := math.Asin(math.Max(-1, math.Min(1, direction.z))) / rad + body := occultationRiseSetBody{direction: direction} + if atFiniteDistance { + body.positionKM = vector + body.distanceKM = distanceKM + } + return body, ra, dec, finite(ra) && finite(dec) +} + +func newOccultationRiseSetBody(ra, dec, distanceKM float64) occultationRiseSetBody { + direction := occultationPathRaDecVector(ra, dec, 1) + body := occultationRiseSetBody{direction: direction, distanceKM: distanceKM} + if distanceKM > 0 { + body.positionKM = occultationPathScale(direction, distanceKM) + } + return body +} + +func (context occultationRiseSetContext) stateAt(longitude, latitude float64) occultationRiseSetState { + key := occultationRiseSetStateKey{math.Float64bits(longitude), math.Float64bits(latitude)} + if state, ok := context.states[key]; ok { + return state.withContactMetric(context.internalContact) + } + if !context.valid { + return context.storeState(key, occultationRiseSetState{}) + } + observerParallaxKM, observerDistanceKM, zenith := occultationRiseSetObserverVectors( + context.siderealDegrees, longitude, latitude, + ) + moonDirection := occultationRiseSetTopocentricDirection(context.moon, observerParallaxKM) + targetDirection := occultationRiseSetTopocentricDirection(context.target, observerParallaxKM) + moonDistanceKM := occultationRiseSetTopocentricDistance(context.moon, observerDistanceKM) + if !finite(moonDistanceKM) || moonDistanceKM <= moonEquatorialRadiusKM { + return context.storeState(key, occultationRiseSetState{}) + } + moonRadius := angularSemidiameterArcsec(moonEquatorialRadiusKM, moonDistanceKM) / 3600 + targetRadius := 0.0 + if context.targetRadiusKM > 0 { + targetDistanceKM := occultationRiseSetTopocentricDistance(context.target, observerDistanceKM) + if !finite(targetDistanceKM) || targetDistanceKM <= context.targetRadiusKM { + return context.storeState(key, occultationRiseSetState{}) + } + targetRadius = angularSemidiameterArcsec(context.targetRadiusKM, targetDistanceKM) / 3600 + } + cosSeparation := math.Max(-1, math.Min(1, occultationPathDot(moonDirection, targetDirection))) + separationRad := math.Acos(cosSeparation) + separation := separationRad / rad + moonAltitude := math.Asin(math.Max(-1, math.Min(1, occultationPathDot(moonDirection, zenith)))) / rad + contactState := movingDiskContactState{ + separation: separation, + occultingOuterRadius: moonRadius, + occultingInnerRadius: moonRadius, + targetRadius: targetRadius, + valid: movingDiskContactStateValid( + separation, moonRadius, moonRadius, targetRadius, + ), + } + externalContactMetric := contactState.externalContactGap() + internalContactMetric := contactState.internalContactGap() + return context.storeState(key, occultationRiseSetState{ + externalContactMetric: externalContactMetric, + internalContactMetric: internalContactMetric, + separationSquared: 2 - 2*cosSeparation, + moonAltitude: moonAltitude, + valid: contactState.valid && finite(moonAltitude), + }) +} + +// storeState 记住一个站点状态;表满时整表清空后复用,避免为每个解分配新 map。 +func (context occultationRiseSetContext) storeState( + key occultationRiseSetStateKey, + state occultationRiseSetState, +) occultationRiseSetState { + if context.states != nil { + // Newton steps reuse a few nearby stations, not the full history. + // Keep the small map allocation and recycle it as the solve moves. + if len(context.states) >= occultationRiseSetStateCacheMaximumEntries { + for cachedKey := range context.states { + delete(context.states, cachedKey) + } + } + context.states[key] = state + } + return state.withContactMetric(context.internalContact) +} + +func (state occultationRiseSetState) withContactMetric(internal bool) occultationRiseSetState { + if internal { + state.contactMetric = state.internalContactMetric + } else { + state.contactMetric = state.externalContactMetric + } + return state +} + +func (context occultationRiseSetContext) moonHorizonResidual(longitude, latitude float64) (float64, bool) { + if !context.valid || context.moon.distanceKM <= 0 { + return 0, false + } + latitudeRad := latitude * rad + theta := (context.siderealDegrees + longitude) * rad + sinLatitude, cosLatitude := math.Sincos(latitudeRad) + sinTheta, cosTheta := math.Sincos(theta) + moonDotZenith := cosLatitude*(context.moon.positionKM.x*cosTheta+context.moon.positionKM.y*sinTheta) + + context.moon.positionKM.z*sinLatitude + polarRatio := occultationTopocentricEarthPolarRadiusKM / occultationTopocentricEarthRadiusKM + shapeDotZenith := math.Sqrt(cosLatitude*cosLatitude + polarRatio*polarRatio*sinLatitude*sinLatitude) + residual := moonDotZenith - occultationLegacyParallaxRadiusKM*shapeDotZenith + return residual, finite(residual) +} + +func occultationRiseSetObserverVectors( + siderealDegrees, longitude, latitude float64, +) (occultationPathVector, occultationPathVector, occultationPathVector) { + latitudeRad := latitude * rad + theta := (siderealDegrees + longitude) * rad + sinLatitude, cosLatitude := math.Sincos(latitudeRad) + sinTheta, cosTheta := math.Sincos(theta) + polarRatio := occultationTopocentricEarthPolarRadiusKM / occultationTopocentricEarthRadiusKM + u := math.Atan(polarRatio * math.Tan(latitudeRad)) + sinU, cosU := math.Sincos(u) + shape := occultationPathVector{ + x: cosU * cosTheta, + y: cosU * sinTheta, + z: polarRatio * sinU, + } + zenith := occultationPathVector{x: cosLatitude * cosTheta, y: cosLatitude * sinTheta, z: sinLatitude} + return occultationPathScale(shape, occultationLegacyParallaxRadiusKM), + occultationPathScale(shape, occultationTopocentricEarthRadiusKM), zenith +} + +func occultationRiseSetTopocentricDirection( + body occultationRiseSetBody, + observerKM occultationPathVector, +) occultationPathVector { + if body.distanceKM <= 0 { + return body.direction + } + return occultationPathUnit(occultationPathSub(body.positionKM, observerKM)) +} + +func occultationRiseSetTopocentricDistance( + body occultationRiseSetBody, + observerKM occultationPathVector, +) float64 { + if body.distanceKM <= 0 { + return math.Inf(1) + } + return occultationPathNorm(occultationPathSub(body.positionKM, observerKM)) +} + +func occultationRiseSetCurves( + startTT, endTT, greatestTT float64, + options OccultationPathOptions, + location *time.Location, + contextAt occultationRiseSetContextFunc, +) []OccultationRiseSetCurve { + cache := newOccultationRiseSetEvaluationCache(contextAt) + return occultationRiseSetCurvesWithCache(startTT, endTT, greatestTT, options, location, cache) +} + +func occultationRiseSetCurvesWithCache( + startTT, endTT, greatestTT float64, + options OccultationPathOptions, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) []OccultationRiseSetCurve { + curves, _, _ := occultationRiseSetCurvesWithRecoveryReport( + startTT, endTT, greatestTT, options, location, cache, + ) + return curves +} + +// occultationRiseSetCurvesWithRecoveryReport 额外返回折点补根重建前的相位图和重建候选, +// 便于调用方核验“窗口内部未成对端点”判据;正常路径只使用第一个返回值。 +// occultationRiseSetCurvesWithRecoveryReport also returns the phase graph before fold +// recovery and the rebuilt candidate, so callers can verify the interior-endpoint test; +// the normal path uses the first result only. +func occultationRiseSetCurvesWithRecoveryReport( + startTT, endTT, greatestTT float64, + options OccultationPathOptions, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) ([]OccultationRiseSetCurve, []OccultationRiseSetCurve, []OccultationRiseSetCurve) { + if options.DisableRiseSet || startTT == 0 || endTT == 0 || endTT <= startTT { + return nil, nil, nil + } + if cache == nil { + return nil, nil, nil + } + step := options.RiseSetStep + if step <= 0 { + step = 5 * time.Minute + } + stepDays := float64(step) / float64(24*time.Hour) + times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays) + keys := []occultationRiseSetCurveKey{ + {RiseSetPhaseStart, RiseSetDirectionRise}, + {RiseSetPhaseStart, RiseSetDirectionSet}, + {RiseSetPhaseGreatest, RiseSetDirectionRise}, + {RiseSetPhaseGreatest, RiseSetDirectionSet}, + {RiseSetPhaseEnd, RiseSetDirectionRise}, + {RiseSetPhaseEnd, RiseSetDirectionSet}, + } + // The ordinary grid is intentionally retained for performance. Near a + // grazing phase junction a root branch may exist for less than one grid + // interval, so evaluate only intervals whose root topology changes and + // bisect those locally. This recovers the missing branch without doubling + // the cost of every rise/set curve. + samples := make([]occultationRiseSetSampledPoints, 0, len(times)) + evaluate := func(tt float64, foldRecovery bool) occultationRiseSetSampledPoints { + return occultationRiseSetSampledPoints{ + tt: tt, + points: cache.candidateEvaluation(tt).pointsAt( + occultationRiseSetBoundaryPoints, location, foldRecovery, + ), + } + } + pointSource := cache.candidateEvaluation(startTT).center.targetRadiusKM <= 0 + for _, tt := range times { + samples = append(samples, evaluate(tt, false)) + } + buildCurves := func(samples []occultationRiseSetSampledPoints) []OccultationRiseSetCurve { + tracks := make(map[occultationRiseSetCurveKey][]*occultationRiseSetTrack, len(keys)) + curves := make([]OccultationRiseSetCurve, 0, len(keys)) + for _, sample := range samples { + for _, key := range keys { + if sample.adaptive && key.phase != RiseSetPhaseGreatest { + continue + } + tracks[key] = appendOccultationRiseSetSamples(tracks[key], sample.points[key], stepDays) + } + } + for _, key := range keys { + segments := make([][]OccultationPathPoint, 0, len(tracks[key])) + for _, track := range tracks[key] { + for _, segment := range track.segments { + if len(segment) >= 1 { + segments = append(segments, segment) + } + } + } + if len(segments) > 0 { + curves = append(curves, OccultationRiseSetCurve{ + Phase: key.phase, Direction: key.direction, Segments: segments, + }) + } + } + completeOccultationRiseSetCurveEndpoints(curves, stepDays, location, cache) + return curves + } + for pass := 0; pointSource && pass < occultationRiseSetAdaptiveMaximumPasses; pass++ { + if len(samples) < 2 { + break + } + intervals := make([]int, 0, occultationRiseSetAdaptiveMaximumIntervals) + for index := 0; index+1 < len(samples) && len(intervals) < occultationRiseSetAdaptiveMaximumIntervals; index++ { + left, right := samples[index], samples[index+1] + if right.tt <= left.tt || right.tt-left.tt <= occultationRiseSetAdaptiveMinimumStepDays { + continue + } + if !occultationRiseSetSamplesChangeTopology(left.points, right.points, keys) { + continue + } + // Refine only when the interval hides an additional branch at its + // interior. A simple 0->1 appearance is already represented by the + // endpoint sample; refining every disappearance interval can alter + // branch assignment for polar events whose midpoint has no root. + midpoint := evaluate((left.tt+right.tt)/2, false) + hiddenBranch := false + for _, key := range keys { + if len(midpoint.points[key]) > len(left.points[key]) && + len(midpoint.points[key]) > len(right.points[key]) { + hiddenBranch = true + break + } + } + if hiddenBranch { + intervals = append(intervals, index) + } + } + if len(intervals) == 0 { + break + } + refined := make([]occultationRiseSetSampledPoints, 0, len(samples)+len(intervals)) + intervalSet := make(map[int]bool, len(intervals)) + for _, index := range intervals { + intervalSet[index] = true + } + for index, sample := range samples { + refined = append(refined, sample) + if !intervalSet[index] || index+1 >= len(samples) { + continue + } + midpoint := evaluate((sample.tt+samples[index+1].tt)/2, false) + midpoint.adaptive = true + refined = append(refined, midpoint) + } + samples = refined + } + firstTT, lastTT := times[0], times[len(times)-1] + curves := buildCurves(samples) + baseCurves := curves + recoveredCurves := occultationRiseSetFoldRecoveryCandidate(samples, buildCurves, evaluate, curves, firstTT, lastTT) + if recoveredCurves != nil && + occultationRiseSetUnclosedEndpointCount(recoveredCurves, true, firstTT, lastTT) < + occultationRiseSetUnclosedEndpointCount(baseCurves, true, firstTT, lastTT) { + return recoveredCurves, baseCurves, recoveredCurves + } + return baseCurves, baseCurves, recoveredCurves +} + +// occultationRiseSetFoldRecoveryCandidate 在窗口内部仍有未成对端点时用折点补根重建相位图。 +// 没有窗口内部未成对端点时直接返回 nil:查询窗口边界上的裁剪端点没有任何折点可以配对, +// 为它们重跑整条流水线只会被丢弃。 +// occultationRiseSetFoldRecoveryCandidate rebuilds the phase graph with fold roots while +// interior endpoints are still unpaired, and returns nil when none is: an endpoint clipped +// by the query window has no fold to pair with, so rebuilding for it is discarded work. +func occultationRiseSetFoldRecoveryCandidate( + samples []occultationRiseSetSampledPoints, + buildCurves func([]occultationRiseSetSampledPoints) []OccultationRiseSetCurve, + evaluate func(float64, bool) occultationRiseSetSampledPoints, + base []OccultationRiseSetCurve, + firstTT, lastTT float64, +) []OccultationRiseSetCurve { + if occultationRiseSetUnclosedEndpointCount(base, true, firstTT, lastTT) == 0 { + return nil + } + // A polar tangent fold dropped by the sign scan leaves the two branches of a + // phase unjoined. Events that already close keep their existing sampling and + // endpoints byte-identical. + recovered := make([]occultationRiseSetSampledPoints, len(samples)) + for index, sample := range samples { + recovered[index] = evaluate(sample.tt, true) + recovered[index].adaptive = sample.adaptive + } + return buildCurves(recovered) +} + +type occultationRiseSetSampledPoints struct { + tt float64 + adaptive bool + points map[occultationRiseSetCurveKey][]OccultationPathPoint +} + +func occultationRiseSetSamplesChangeTopology( + left, right map[occultationRiseSetCurveKey][]OccultationPathPoint, + keys []occultationRiseSetCurveKey, +) bool { + for _, key := range keys { + leftCount := len(left[key]) + rightCount := len(right[key]) + if leftCount != rightCount { + return leftCount > 0 || rightCount > 0 + } + } + return false +} + +func (evaluation occultationRiseSetEvaluation) pointsAt( + boundaryPoints int, + location *time.Location, + foldRecovery bool, +) map[occultationRiseSetCurveKey][]OccultationPathPoint { + result := make(map[occultationRiseSetCurveKey][]OccultationPathPoint, 6) + if !evaluation.center.valid || !evaluation.before.valid || !evaluation.after.valid { + return result + } + gst := ApparentSiderealTime(TD2UT(evaluation.tt, false)) * 15 + centerLongitude := normalizeLongitude(evaluation.center.moonRA - gst) + centerLatitude := evaluation.center.moonDec + appendRoots := func(greatest bool) { + // Keep the historical geographic refinement for ordinary latitudes; it + // is both cheaper and preserves the established endpoint network. A + // polar root needs the station-corrected horizon parameterization because + // its geographic Jacobian becomes singular and can switch siblings. + valueAt := func(angle float64) (float64, bool) { + longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle) + state := evaluation.center.stateAt(longitude, latitude) + if !state.valid { + return 0, false + } + if greatest { + return evaluation.separationDerivative(longitude, latitude), true + } + return state.contactMetric, true + } + foldTolerance := 0.0 + if foldRecovery && !greatest { + foldTolerance = riseSetFoldRootResidualToleranceDeg + } + roots := riseSetCyclicRootsWithFoldTolerance(boundaryPoints, foldTolerance, valueAt) + polar := false + for _, angle := range roots { + _, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle) + if math.Abs(latitude) >= 70 { + polar = true + break + } + } + var stationHorizonRoots []float64 + if polar { + stationValueAt := func(angle float64) (float64, bool) { + longitude, latitude, ok := occultationRiseSetHorizonPointFromContext(evaluation.center, angle) + if !ok { + return 0, false + } + state := evaluation.center.stateAt(longitude, latitude) + if !state.valid { + return 0, false + } + if greatest { + return evaluation.separationDerivative(longitude, latitude), true + } + return state.contactMetric, true + } + stationHorizonRoots = riseSetCyclicRootsWithFoldTolerance(boundaryPoints, foldTolerance, stationValueAt) + } + for _, angle := range roots { + var longitude, latitude float64 + var ok bool + longitude, latitude = riseSetHorizonPoint(centerLongitude, centerLatitude, angle) + if math.Abs(latitude) >= 70 { + stationAngle, found := occultationRiseSetClosestHorizonAngle(angle, stationHorizonRoots) + if !found { + continue + } + // `angle` is the continuation parameter on the station-corrected + // horizon. Do not refine this root in longitude/latitude: near a + // polar fold that 2-D Newton system can converge to its sibling. + longitude, latitude, ok = occultationRiseSetHorizonPointFromContext(evaluation.center, stationAngle) + } else { + longitude, latitude, ok = riseSetRefineGeographicRoot( + longitude, latitude, + func(lon, lat float64) (float64, float64, bool) { + state := evaluation.center.stateAt(lon, lat) + if !state.valid { + return 0, 0, false + } + first := state.contactMetric + if greatest { + first = evaluation.separationDerivative(lon, lat) + } + return first, state.moonAltitude, finite(first) + }, + ) + } + if !ok { + continue + } + point, key, valid := evaluation.classify(longitude, latitude, greatest, location) + if !valid || occultationRiseSetPointExists(result[key], point) { + continue + } + result[key] = append(result[key], point) + } + } + appendRoots(false) + appendRoots(true) + return result +} + +func occultationRiseSetClosestHorizonAngle(target float64, candidates []float64) (float64, bool) { + closest := 0.0 + distance := math.Inf(1) + for _, candidate := range candidates { + current := riseSetAngularDistance(target, candidate) + if current < distance { + closest, distance = candidate, current + } + } + return closest, finite(distance) && distance <= math.Pi/6 +} + +func (evaluation occultationRiseSetEvaluation) classify( + longitude, latitude float64, + greatest bool, + location *time.Location, +) (OccultationPathPoint, occultationRiseSetCurveKey, bool) { + state := evaluation.center.stateAt(longitude, latitude) + altitudeDerivative := evaluation.moonAltitudeDerivative(longitude, latitude) + if !state.valid || !finite(altitudeDerivative) || math.Abs(altitudeDerivative) < 1e-8 { + return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false + } + direction := RiseSetDirectionSet + if altitudeDerivative > 0 { + direction = RiseSetDirectionRise + } + phase := RiseSetPhaseGreatest + if greatest { + if state.contactMetric > 1e-7 || evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false + } + } else { + contactDerivative := evaluation.contactDerivative(longitude, latitude) + if !finite(contactDerivative) || math.Abs(contactDerivative) < 1e-8 { + return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false + } + phase = RiseSetPhaseEnd + if contactDerivative < 0 { + phase = RiseSetPhaseStart + } + } + return OccultationPathPoint{ + Time: occultationTTToLocation(evaluation.tt, location), + Longitude: longitude, Latitude: latitude, MoonAltitude: state.moonAltitude, + }, occultationRiseSetCurveKey{phase: phase, direction: direction}, true +} + +func (evaluation occultationRiseSetEvaluation) contactDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude, latitude) + after := evaluation.after.stateAt(longitude, latitude) + if !before.valid || !after.valid { + return math.NaN() + } + return (after.contactMetric - before.contactMetric) / (2 * occultationRiseSetDerivativeStepDays) +} + +func (evaluation occultationRiseSetEvaluation) contactSecondDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude, latitude) + center := evaluation.center.stateAt(longitude, latitude) + after := evaluation.after.stateAt(longitude, latitude) + if !before.valid || !center.valid || !after.valid { + return math.NaN() + } + stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays + return (after.contactMetric - 2*center.contactMetric + before.contactMetric) / stepSquared +} + +func (evaluation occultationRiseSetEvaluation) separationDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude, latitude) + after := evaluation.after.stateAt(longitude, latitude) + if !before.valid || !after.valid { + return math.NaN() + } + return (after.separationSquared - before.separationSquared) / (2 * occultationRiseSetDerivativeStepDays) +} + +func (evaluation occultationRiseSetEvaluation) separationSecondDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude, latitude) + center := evaluation.center.stateAt(longitude, latitude) + after := evaluation.after.stateAt(longitude, latitude) + if !before.valid || !center.valid || !after.valid { + return math.NaN() + } + stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays + return (after.separationSquared - 2*center.separationSquared + before.separationSquared) / stepSquared +} + +func (evaluation occultationRiseSetEvaluation) moonAltitudeDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude, latitude) + after := evaluation.after.stateAt(longitude, latitude) + if !before.valid || !after.valid { + return math.NaN() + } + return (after.moonAltitude - before.moonAltitude) / (2 * occultationRiseSetDerivativeStepDays) +} + +func (evaluation occultationRiseSetEvaluation) moonAltitudeSecondDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude, latitude) + center := evaluation.center.stateAt(longitude, latitude) + after := evaluation.after.stateAt(longitude, latitude) + if !before.valid || !center.valid || !after.valid { + return math.NaN() + } + stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays + return (after.moonAltitude - 2*center.moonAltitude + before.moonAltitude) / stepSquared +} + +func occultationRiseSetPointExists(points []OccultationPathPoint, candidate OccultationPathPoint) bool { + for _, point := range points { + if occultationPathDistanceKM(point, candidate) < 0.01 { + return true + } + } + return false +} + +// occultationRiseSetBranchChanged is stricter than the coarse solar-path +// splitter for short occultation segments. A polar rise/set root can move +// hundreds of kilometres in nearly the same timestamp when two unrelated +// horizon branches are paired. Distances up to 500 km remain valid for the +// sampled fold attachments; a larger sub-second jump is rejected as a branch +// change before adaptive refinement can reconnect it. +func occultationRiseSetBranchChanged(distanceKM, deltaDays float64) bool { + if !finite(distanceKM) || !finite(deltaDays) { + return true + } + if distanceKM <= 500 { + return false + } + seconds := deltaDays * 86400 + // Equal-time endpoints can only be joined when they are already spatially + // close. A larger jump in the sub-second interval is a different horizon + // root, not a fast-moving physical branch. + if seconds <= 0 { + return true + } + return seconds < 1 && distanceKM/seconds > 10 +} + +// occultationRiseSetWindowEdgeToleranceDays 判定端点是否落在查询窗口边界上。 +// 窗口边界样本经过民用时往返的误差在微秒量级,远小于任何采样步长。 +const occultationRiseSetWindowEdgeToleranceDays = 1e-6 + +// occultationRiseSetUnclosedEndpointCount 统计未被其他相位曲线端点共享的端点数量, +// 这是“相位图是否闭合”的判据。interiorOnly 时跳过窗口裁剪端点:它们没有任何折点 +// 可以配对,只反映查询窗口而不是几何缺陷。 +// occultationRiseSetUnclosedEndpointCount counts the endpoints that no other phase +// curve endpoint shares, the closure test for the phase graph. With interiorOnly, +// endpoints clipped by the query window are skipped: no fold can pair them, so they +// describe the window rather than the geometry. +func occultationRiseSetUnclosedEndpointCount( + curves []OccultationRiseSetCurve, + interiorOnly bool, + firstTT, lastTT float64, +) int { + unclosed := 0 + for ci, curve := range curves { + if curve.Phase == RiseSetPhaseGreatest { + continue + } + for si, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + if interiorOnly && occultationRiseSetEndpointClipped(endpoint, firstTT, lastTT) { + continue + } + shared := false + for oi, other := range curves { + if other.Phase == RiseSetPhaseGreatest { + continue + } + for os, points := range other.Segments { + if (ci == oi && si == os) || len(points) < 2 { + continue + } + for _, point := range []OccultationPathPoint{points[0], points[len(points)-1]} { + dt := point.Time.Sub(endpoint.Time) + if dt >= -time.Second && dt <= time.Second && + occultationPathDistanceKM(endpoint, point) < 0.01 { + shared = true + } + } + } + } + if !shared { + unclosed++ + } + } + } + } + return unclosed +} + +func occultationRiseSetEndpointClipped(endpoint OccultationPathPoint, firstTT, lastTT float64) bool { + tt := occultationTimeToTT(endpoint.Time) + return tt <= firstTT+occultationRiseSetWindowEdgeToleranceDays || + tt >= lastTT-occultationRiseSetWindowEdgeToleranceDays +} diff --git a/basic/occultation_rise_set_junction_test.go b/basic/occultation_rise_set_junction_test.go new file mode 100644 index 0000000..f25e12d --- /dev/null +++ b/basic/occultation_rise_set_junction_test.go @@ -0,0 +1,65 @@ +package basic + +import ( + "testing" + "time" +) + +func TestPlanetOccultationContactPhaseJunctionsClose(t *testing.T) { + for _, sample := range []struct { + year, month, day int + planet OccultationPlanet + }{ + {2027, 4, 15, OccultationJupiter}, + {2027, 1, 8, OccultationMercury}, + // 2025-01-05 月掩海王星在环极折点处残差与零相切,符号扫描漏根会让 + // 「终掩在月升/月落」分支断开,这里固定该回归。 + // The 2025-01-05 Neptune occultation tangents the zero residual at its + // polar fold; a sign-only scan drops that root and breaks the end phase + // branches apart, so pin the regression here. + {2025, 1, 5, OccultationNeptune}, + } { + t.Run(string(sample.planet), func(t *testing.T) { + day := time.Date(sample.year, time.Month(sample.month), sample.day, 0, 0, 0, 0, time.UTC) + paths, err := FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), sample.planet, + OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute}) + if err != nil || len(paths) != 1 { + t.Fatalf("paths=%d err=%v", len(paths), err) + } + for _, curves := range [][]OccultationRiseSetCurve{paths[0].RiseSetCurves, paths[0].TotalRiseSetCurves} { + for ci, curve := range curves { + if curve.Phase == RiseSetPhaseGreatest { + continue + } + for si, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + shared := false + for oi, other := range curves { + if other.Phase == RiseSetPhaseGreatest { + continue + } + for os, points := range other.Segments { + if ci == oi && si == os || len(points) < 2 { + continue + } + for _, point := range []OccultationPathPoint{points[0], points[len(points)-1]} { + dt := point.Time.Sub(endpoint.Time) + if dt >= -time.Second && dt <= time.Second && occultationPathDistanceKM(endpoint, point) < 0.01 { + shared = true + } + } + } + } + if !shared { + t.Errorf("unclosed %s/%s endpoint lon=%.9f lat=%.9f at=%s", curve.Phase, curve.Direction, endpoint.Longitude, endpoint.Latitude, endpoint.Time) + } + } + } + } + } + }) + } +} diff --git a/basic/occultation_rise_set_refine.go b/basic/occultation_rise_set_refine.go new file mode 100644 index 0000000..4b85811 --- /dev/null +++ b/basic/occultation_rise_set_refine.go @@ -0,0 +1,738 @@ +package basic + +import ( + "math" + "time" +) + +func refineOccultationRiseSetFold( + first, second OccultationPathPoint, + atStart bool, + greatest bool, + stepDays float64, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + newton := func() (OccultationPathPoint, bool) { + seedTT := (occultationTimeToTT(first.Time) + occultationTimeToTT(second.Time)) / 2 + firstAngle := occultationRiseSetHorizonAngle(seedTT, first.Longitude, first.Latitude, cache.context) + secondAngle := occultationRiseSetHorizonAngle(seedTT, second.Longitude, second.Latitude, cache.context) + coordinates := [2]float64{riseSetNormalizeRadians(firstAngle + math.Remainder(secondAngle-firstAngle, 2*math.Pi)/2), 0} + const angleStep = 1e-4 + for iteration := 0; iteration < 32; iteration++ { + residual, ok := occultationRiseSetFoldHorizonResidual(seedTT, coordinates, greatest, cache) + if !ok { + return OccultationPathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-9 { + break + } + plusAngle, plusAngleOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0] + angleStep, coordinates[1]}, greatest, cache) + minusAngle, minusAngleOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0] - angleStep, coordinates[1]}, greatest, cache) + timeStep := 1.0 / 60.0 + plusTime, plusTimeOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0], coordinates[1] + timeStep}, greatest, cache) + minusTime, minusTimeOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0], coordinates[1] - timeStep}, greatest, cache) + if !plusAngleOK || !minusAngleOK || !plusTimeOK || !minusTimeOK { + return OccultationPathPoint{}, false + } + matrix := [2][2]float64{ + {(plusAngle[0] - minusAngle[0]) / (2 * angleStep), (plusTime[0] - minusTime[0]) / (2 * timeStep)}, + {(plusAngle[1] - minusAngle[1]) / (2 * angleStep), (plusTime[1] - minusTime[1]) / (2 * timeStep)}, + } + determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0] + if !finite(determinant) || math.Abs(determinant) < 1e-18 { + return OccultationPathPoint{}, false + } + delta := [2]float64{ + (-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant, + (-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant, + } + if math.Abs(delta[0]) > 0.25 { + delta[0] = math.Copysign(0.25, delta[0]) + } + if math.Abs(delta[1]) > 5 { + delta[1] = math.Copysign(5, delta[1]) + } + coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0]) + coordinates[1] += delta[1] + } + jd := seedTT + coordinates[1]/1440 + longitude, latitude, ok := occultationRiseSetHorizonPoint(jd, coordinates[0], cache.context) + if !ok { + return OccultationPathPoint{}, false + } + return refineOccultationRiseSetFoldPoint(jd, longitude, latitude, greatest, location, cache) + } + if point, ok := newton(); ok { + return point, true + } + return refineOccultationRiseSetFoldBracketed( + first, second, atStart, greatest, stepDays, location, cache, + ) +} + +func refineOccultationRiseSetFoldBracketed( + first, second OccultationPathPoint, + atStart bool, + greatest bool, + stepDays float64, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + sampleTT := (occultationTimeToTT(first.Time) + occultationTimeToTT(second.Time)) / 2 + firstAngle := occultationRiseSetHorizonAngle(sampleTT, first.Longitude, first.Latitude, cache.context) + secondAngle := occultationRiseSetHorizonAngle(sampleTT, second.Longitude, second.Latitude, cache.context) + sampleAngle := riseSetNormalizeRadians(firstAngle + math.Remainder(secondAngle-firstAngle, 2*math.Pi)/2) + sampleAngle, sampleValue, ok := occultationRiseSetFoldExtremumAt(sampleTT, sampleAngle, greatest, cache) + if !ok { + return OccultationPathPoint{}, false + } + direction := 1.0 + if atStart { + direction = -1 + } + var otherTT, otherAngle, otherValue float64 + bracketed := false + for step := 1; step <= 4; step++ { + otherTT = sampleTT + direction*float64(step)*stepDays + otherAngle, otherValue, ok = occultationRiseSetFoldExtremumAt(otherTT, sampleAngle, greatest, cache) + if !ok { + continue + } + if sampleValue == 0 || otherValue == 0 || sampleValue*otherValue < 0 { + bracketed = true + break + } + } + if !bracketed { + return OccultationPathPoint{}, false + } + + leftTT, leftAngle, leftValue := sampleTT, sampleAngle, sampleValue + rightTT, rightAngle, rightValue := otherTT, otherAngle, otherValue + for iteration := 0; iteration < 64 && math.Abs(rightTT-leftTT) > occultationRiseSetFoldRootToleranceDays; iteration++ { + middleTT := (leftTT + rightTT) / 2 + middleSeed := riseSetNormalizeRadians(leftAngle + math.Remainder(rightAngle-leftAngle, 2*math.Pi)/2) + middleAngle, middleValue, middleOK := occultationRiseSetFoldExtremumAt(middleTT, middleSeed, greatest, cache) + if !middleOK { + return OccultationPathPoint{}, false + } + if leftValue == 0 || leftValue*middleValue <= 0 { + rightTT, rightAngle, rightValue = middleTT, middleAngle, middleValue + } else { + leftTT, leftAngle, leftValue = middleTT, middleAngle, middleValue + } + } + _ = rightValue + rootTT := (leftTT + rightTT) / 2 + rootSeed := riseSetNormalizeRadians(leftAngle + math.Remainder(rightAngle-leftAngle, 2*math.Pi)/2) + rootAngle, rootValue, ok := occultationRiseSetFoldExtremumAt(rootTT, rootSeed, greatest, cache) + if !ok { + return OccultationPathPoint{}, false + } + longitude, latitude, ok := occultationRiseSetHorizonPoint(rootTT, rootAngle, cache.context) + if !ok { + return OccultationPathPoint{}, false + } + if point, refined := refineOccultationRiseSetFoldPoint(rootTT, longitude, latitude, greatest, location, cache); refined { + return point, true + } + state := cache.context(rootTT).stateAt(longitude, latitude) + if !state.valid || math.Abs(rootValue) > 1e-7 || math.Abs(state.moonAltitude) > 1e-8 { + return OccultationPathPoint{}, false + } + return OccultationPathPoint{ + Time: occultationTTToLocation(rootTT, location), Longitude: longitude, + Latitude: latitude, MoonAltitude: state.moonAltitude, + }, true +} + +func occultationRiseSetFoldExtremumAt( + tt, angle float64, + greatest bool, + cache *occultationRiseSetEvaluationCache, +) (float64, float64, bool) { + const angleStep = 1e-4 + angle = riseSetNormalizeRadians(angle) + for iteration := 0; iteration < 24; iteration++ { + residual, ok := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle, 0}, greatest, cache) + if !ok { + return 0, 0, false + } + if math.Abs(residual[1]) <= 1e-10 { + return angle, residual[0], true + } + before, beforeOK := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle - angleStep, 0}, greatest, cache) + after, afterOK := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle + angleStep, 0}, greatest, cache) + if !beforeOK || !afterOK { + return 0, 0, false + } + secondDerivative := (after[1] - before[1]) / (2 * angleStep) + if !finite(secondDerivative) || math.Abs(secondDerivative) < 1e-16 { + return 0, 0, false + } + delta := -residual[1] / secondDerivative + if math.Abs(delta) > 0.25 { + delta = math.Copysign(0.25, delta) + } + angle = riseSetNormalizeRadians(angle + delta) + } + residual, ok := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle, 0}, greatest, cache) + return angle, residual[0], ok && finite(residual[0]) && finite(residual[1]) && math.Abs(residual[1]) <= 1e-7 +} + +func occultationRiseSetHorizonCenter(context occultationRiseSetContext) (float64, float64) { + return normalizeLongitude(context.moonRA - context.siderealDegrees), context.moonDec +} + +func occultationRiseSetHorizonPoint( + tt, angle float64, + contextAt occultationRiseSetContextFunc, +) (float64, float64, bool) { + return occultationRiseSetHorizonPointFromContext(contextAt(tt), angle) +} + +func occultationRiseSetHorizonPointFromContext( + context occultationRiseSetContext, + angle float64, +) (float64, float64, bool) { + if !context.valid { + return 0, 0, false + } + center, first, second, basisOK := occultationRiseSetHorizonBasis(context) + if !basisOK { + return 0, 0, false + } + tangent := occultationRiseSetCircleTangent(first, second, angle) + pointAt := func(radius float64) (float64, float64, float64, bool) { + longitude, latitude := occultationRiseSetCirclePointFromTangent(center, tangent, radius) + residual, ok := context.moonHorizonResidual(longitude, latitude) + return longitude, latitude, residual, ok + } + left, right := 80*rad, 100*rad + _, _, leftValue, leftOK := pointAt(left) + _, _, rightValue, rightOK := pointAt(right) + if !leftOK || !rightOK || leftValue*rightValue > 0 { + return 0, 0, false + } + weightedLeft, weightedRight := leftValue, rightValue + lastSide := 0 + for iteration := 0; iteration < 12; iteration++ { + denominator := weightedRight - weightedLeft + middle := (left + right) / 2 + if finite(denominator) && math.Abs(denominator) > 1e-18 { + candidate := (left*weightedRight - right*weightedLeft) / denominator + if candidate > left && candidate < right { + middle = candidate + } + } + longitude, latitude, middleValue, middleOK := pointAt(middle) + if !middleOK { + return 0, 0, false + } + if math.Abs(middleValue) <= 1e-6 { + return longitude, latitude, true + } + if leftValue*middleValue <= 0 { + right, rightValue, weightedRight = middle, middleValue, middleValue + if lastSide < 0 { + weightedLeft *= 0.5 + } else { + weightedLeft = leftValue + } + lastSide = -1 + } else { + left, leftValue, weightedLeft = middle, middleValue, middleValue + if lastSide > 0 { + weightedRight *= 0.5 + } else { + weightedRight = rightValue + } + lastSide = 1 + } + } + for iteration := 0; iteration < 32; iteration++ { + middle := (left + right) / 2 + _, _, middleValue, middleOK := pointAt(middle) + if !middleOK { + return 0, 0, false + } + if leftValue*middleValue <= 0 { + right, rightValue = middle, middleValue + } else { + left, leftValue = middle, middleValue + } + } + longitude, latitude, _, ok := pointAt((left + right) / 2) + return longitude, latitude, ok +} + +func occultationRiseSetHorizonBasis(context occultationRiseSetContext) ([3]float64, [3]float64, [3]float64, bool) { + centerLongitude, centerLatitude := occultationRiseSetHorizonCenter(context) + longitude := centerLongitude * rad + latitude := centerLatitude * rad + center := [3]float64{ + math.Cos(latitude) * math.Cos(longitude), + math.Cos(latitude) * math.Sin(longitude), + math.Sin(latitude), + } + reference := [3]float64{0, 0, 1} + if math.Abs(center[2]) > 0.9 { + reference = [3]float64{1, 0, 0} + } + first := riseSetUnitVector(riseSetCross(reference, center)) + second := riseSetUnitVector(riseSetCross(center, first)) + return center, first, second, finite(center[0]) && finite(first[0]) && finite(second[0]) +} + +func occultationRiseSetCirclePointFromBasis(center, first, second [3]float64, angle, radius float64) (float64, float64) { + return occultationRiseSetCirclePointFromTangent( + center, occultationRiseSetCircleTangent(first, second, angle), radius, + ) +} + +func occultationRiseSetCircleTangent(first, second [3]float64, angle float64) [3]float64 { + sinAngle, cosAngle := math.Sincos(angle) + return [3]float64{ + first[0]*cosAngle + second[0]*sinAngle, + first[1]*cosAngle + second[1]*sinAngle, + first[2]*cosAngle + second[2]*sinAngle, + } +} + +func occultationRiseSetCirclePointFromTangent(center, tangent [3]float64, radius float64) (float64, float64) { + sinRadius, cosRadius := math.Sincos(radius) + point := [3]float64{ + center[0]*cosRadius + tangent[0]*sinRadius, + center[1]*cosRadius + tangent[1]*sinRadius, + center[2]*cosRadius + tangent[2]*sinRadius, + } + return normalizeLongitude(math.Atan2(point[1], point[0]) / rad), + math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad +} + +func occultationRiseSetHorizonAngle(tt, longitude, latitude float64, contextAt occultationRiseSetContextFunc) float64 { + _, first, second, ok := occultationRiseSetHorizonBasis(contextAt(tt)) + if !ok { + return math.NaN() + } + pointLon, pointLat := longitude*rad, latitude*rad + point := [3]float64{math.Cos(pointLat) * math.Cos(pointLon), math.Cos(pointLat) * math.Sin(pointLon), math.Sin(pointLat)} + return riseSetNormalizeRadians(math.Atan2(point[0]*second[0]+point[1]*second[1]+point[2]*second[2], point[0]*first[0]+point[1]*first[1]+point[2]*first[2])) +} + +func occultationRiseSetFoldHorizonResidual(seedTT float64, coordinates [2]float64, greatest bool, cache *occultationRiseSetEvaluationCache) ([2]float64, bool) { + jd := seedTT + coordinates[1]/1440 + evaluation := cache.evaluation(jd) + valueAt := func(angle float64) (float64, bool) { + longitude, latitude, ok := occultationRiseSetHorizonPoint(jd, angle, cache.context) + if !ok { + return 0, false + } + return occultationRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) + } + center, centerOK := valueAt(coordinates[0]) + before, beforeOK := valueAt(coordinates[0] - 1e-4) + after, afterOK := valueAt(coordinates[0] + 1e-4) + return [2]float64{center, (after - before) / (2e-4)}, centerOK && beforeOK && afterOK && finite(center) && finite(before) && finite(after) +} + +func refineOccultationRiseSetFoldPoint( + tt, longitude, latitude float64, + greatest bool, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + coordinates := [3]float64{longitude, latitude, tt} + for iteration := 0; iteration < 32; iteration++ { + evaluation := cache.evaluation(coordinates[2]) + residual, ok := occultationRiseSetFoldResidualAt(evaluation, coordinates[0], coordinates[1], greatest) + if !ok { + return OccultationPathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 { + break + } + steps := [3]float64{1e-3, 1e-3, 1.0 / 86400.0} + matrix := [3][3]float64{} + for column := range steps { + plus, minus := coordinates, coordinates + plus[column] += steps[column] + minus[column] -= steps[column] + plusEval := cache.evaluation(plus[2]) + minusEval := cache.evaluation(minus[2]) + plusResidual, plusOK := occultationRiseSetFoldResidualAt(plusEval, plus[0], plus[1], greatest) + minusResidual, minusOK := occultationRiseSetFoldResidualAt(minusEval, minus[0], minus[1], greatest) + if !plusOK || !minusOK { + return OccultationPathPoint{}, false + } + for row := range matrix { + matrix[row][column] = (plusResidual[row] - minusResidual[row]) / (2 * steps[column]) + } + } + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !ok { + return OccultationPathPoint{}, false + } + geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1])) + if geographicScale > 2 { + delta[0] *= 2 / geographicScale + delta[1] *= 2 / geographicScale + } + if math.Abs(delta[2]) > 2.0/1440 { + delta[2] = math.Copysign(2.0/1440, delta[2]) + } + for index := range coordinates { + coordinates[index] += delta[index] + } + coordinates[0] = normalizeLongitude(coordinates[0]) + if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { + return OccultationPathPoint{}, false + } + } + evaluation := cache.evaluation(coordinates[2]) + residual, ok := occultationRiseSetFoldResidualAt(evaluation, coordinates[0], coordinates[1], greatest) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 { + return OccultationPathPoint{}, false + } + state := evaluation.center.stateAt(coordinates[0], coordinates[1]) + return OccultationPathPoint{Time: occultationTTToLocation(coordinates[2], location), Longitude: coordinates[0], Latitude: coordinates[1], MoonAltitude: state.moonAltitude}, true +} + +func occultationRiseSetFoldResidualAt(evaluation occultationRiseSetEvaluation, longitude, latitude float64, greatest bool) ([3]float64, bool) { + valueAt := func(lon, lat float64) ([2]float64, bool) { + first, ok := occultationRiseSetPhaseResidual(evaluation, lon, lat, greatest) + state := evaluation.center.stateAt(lon, lat) + return [2]float64{first, state.moonAltitude}, ok && state.valid + } + center, centerOK := valueAt(longitude, latitude) + lonPlus, lonPlusOK := valueAt(longitude+1e-3, latitude) + lonMinus, lonMinusOK := valueAt(longitude-1e-3, latitude) + latPlus, latPlusOK := valueAt(longitude, latitude+1e-3) + latMinus, latMinusOK := valueAt(longitude, latitude-1e-3) + if !centerOK || !lonPlusOK || !lonMinusOK || !latPlusOK || !latMinusOK { + return [3]float64{}, false + } + firstLon := (lonPlus[0] - lonMinus[0]) / 2e-3 + firstLat := (latPlus[0] - latMinus[0]) / 2e-3 + altitudeLon := (lonPlus[1] - lonMinus[1]) / 2e-3 + altitudeLat := (latPlus[1] - latMinus[1]) / 2e-3 + residual := [3]float64{center[0], center[1], firstLon*altitudeLat - firstLat*altitudeLon} + return residual, finite(residual[2]) +} + +func refineOccultationRiseSetCurveSpacing(curve *OccultationRiseSetCurve, location *time.Location, cache *occultationRiseSetEvaluationCache) { + if curve == nil { + return + } + for index, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + refined := make([]OccultationPathPoint, 1, len(segment)) + refined[0] = segment[0] + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + refined = appendRefinedOccultationRiseSetSegment(refined, segment[pointIndex-1], segment[pointIndex], curve.Phase, curve.Direction, location, cache, 0) + } + curve.Segments[index] = refined + } +} + +func appendRefinedOccultationRiseSetSegment( + points []OccultationPathPoint, + start, end OccultationPathPoint, + phase RiseSetPhase, + direction RiseSetDirection, + location *time.Location, + cache *occultationRiseSetEvaluationCache, + depth int, +) []OccultationPathPoint { + if occultationPathDistanceKM(start, end) <= occultationRiseSetProjectedTargetSpacingKM(start, end) || depth >= 12 { + return append(points, end) + } + middle, ok := occultationRiseSetPhaseMidpoint( + start, end, phase, direction, location, cache, + ) + if !ok { + return append(points, end) + } + points = appendRefinedOccultationRiseSetSegment(points, start, middle, phase, direction, location, cache, depth+1) + return appendRefinedOccultationRiseSetSegment(points, middle, end, phase, direction, location, cache, depth+1) +} + +func occultationRiseSetPhaseMidpoint( + start, end OccultationPathPoint, + phase RiseSetPhase, + direction RiseSetDirection, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + startTT, endTT := occultationTimeToTT(start.Time), occultationTimeToTT(end.Time) + tt := (startTT + endTT) / 2 + startAngle := occultationRiseSetHorizonAngle(tt, start.Longitude, start.Latitude, cache.context) + endAngle := occultationRiseSetHorizonAngle(tt, end.Longitude, end.Latitude, cache.context) + seedAngle := riseSetNormalizeRadians(startAngle + math.Remainder(endAngle-startAngle, 2*math.Pi)/2) + if middle, ok := occultationRiseSetPhasePointOnHorizon( + tt, seedAngle, phase, direction, location, cache, + ); ok && occultationRiseSetRefinementPointIsContinuous(start, middle, end) { + return middle, true + } + longitude := normalizeLongitude(start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2) + latitude := (start.Latitude + end.Latitude) / 2 + evaluation := cache.evaluation(tt) + longitude, latitude, ok := riseSetRefineGeographicRoot(longitude, latitude, func(lon, lat float64) (float64, float64, bool) { + first, valid := occultationRiseSetPhaseResidual(evaluation, lon, lat, phase == RiseSetPhaseGreatest) + state := evaluation.center.stateAt(lon, lat) + return first, state.moonAltitude, valid && state.valid + }) + if !ok { + return OccultationPathPoint{}, false + } + middle := OccultationPathPoint{Time: occultationTTToLocation(tt, location), Longitude: longitude, Latitude: latitude, MoonAltitude: evaluation.center.stateAt(longitude, latitude).moonAltitude} + state := evaluation.center.stateAt(longitude, latitude) + keyPoint, key, valid := evaluation.classify(longitude, latitude, phase == RiseSetPhaseGreatest, location) + if valid { + middle = keyPoint + } + if !valid || key.phase != phase || key.direction != direction || !state.valid || + !occultationRiseSetRefinementPointIsContinuous(start, middle, end) { + return OccultationPathPoint{}, false + } + return middle, true +} + +// refineOccultationRiseSetPhaseJunctionApproaches samples the last two edges +// approaching a shared start/greatest/end junction at the exact implicit +// F=0,H=0 solution. A phase curve can turn rapidly there even when the final +// endpoint is only a few kilometres away; ordinary distance-only sampling +// otherwise renders that physical bend as one visible corner. +func refineOccultationRiseSetPhaseJunctionApproaches( + curves []OccultationRiseSetCurve, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) { + for curveIndex := range curves { + curve := &curves[curveIndex] + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, atStart := range []bool{true, false} { + endpoint := occultationRiseSetSegmentEndpoint(segment, atStart) + if !occultationRiseSetEndpointSharesPhaseJunction(curves, curveIndex, endpoint) { + continue + } + segment = refineOccultationRiseSetJunctionSegment( + segment, atStart, curve.Phase, curve.Direction, location, cache, + ) + } + curve.Segments[segmentIndex] = segment + } + } +} + +func occultationRiseSetEndpointSharesPhaseJunction( + curves []OccultationRiseSetCurve, + curveIndex int, + endpoint OccultationPathPoint, +) bool { + for otherIndex, curve := range curves { + if otherIndex == curveIndex || curve.Phase == curves[curveIndex].Phase || + curve.Direction != curves[curveIndex].Direction { + continue + } + for _, segment := range curve.Segments { + if len(segment) == 0 { + continue + } + for _, other := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + if math.Abs(occultationTimeToTT(endpoint.Time)-occultationTimeToTT(other.Time))*86400 <= 1 && + occultationPathDistanceKM(endpoint, other) <= 0.01 { + return true + } + } + } + } + return false +} + +func refineOccultationRiseSetJunctionSegment( + segment []OccultationPathPoint, + atStart bool, + phase RiseSetPhase, + direction RiseSetDirection, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) []OccultationPathPoint { + if len(segment) < 3 { + return segment + } + firstEdge, lastEdge := 0, len(segment)-1 + if atStart { + if lastEdge > 2 { + lastEdge = 2 + } + } else { + firstEdge = lastEdge - 2 + if firstEdge < 0 { + firstEdge = 0 + } + } + result := make([]OccultationPathPoint, 1, len(segment)+16) + result[0] = segment[0] + for edgeIndex := 0; edgeIndex < len(segment)-1; edgeIndex++ { + if edgeIndex >= firstEdge && edgeIndex < lastEdge { + result = appendRefinedOccultationRiseSetJunction( + result, segment[edgeIndex], segment[edgeIndex+1], phase, direction, + location, cache, 0, + ) + continue + } + result = append(result, segment[edgeIndex+1]) + } + return result +} + +func occultationRiseSetTurnAngleDegrees( + first, middle, last OccultationPathPoint, +) float64 { + longitudeScale := math.Cos(middle.Latitude * rad) + firstX := math.Remainder(first.Longitude-middle.Longitude, 360) * longitudeScale + firstY := first.Latitude - middle.Latitude + lastX := math.Remainder(last.Longitude-middle.Longitude, 360) * longitudeScale + lastY := last.Latitude - middle.Latitude + firstLength := math.Hypot(firstX, firstY) + lastLength := math.Hypot(lastX, lastY) + if firstLength <= 1e-12 || lastLength <= 1e-12 { + return 180 + } + cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength) + return math.Acos(math.Max(-1, math.Min(1, cosine))) / rad +} + +func appendRefinedOccultationRiseSetJunction( + points []OccultationPathPoint, + start, end OccultationPathPoint, + phase RiseSetPhase, + direction RiseSetDirection, + location *time.Location, + cache *occultationRiseSetEvaluationCache, + depth int, +) []OccultationPathPoint { + if occultationPathDistanceKM(start, end) <= 0.05 || depth >= 12 { + return append(points, end) + } + middle, ok := occultationRiseSetPhaseMidpoint(start, end, phase, direction, location, cache) + if !ok { + return append(points, end) + } + // A short chord can still miss the rapidly turning physical arc at the + // contact-envelope junction. Refine its measured sagitta, not just length. + if planetOccultationPointSegmentDistanceKM(middle, start, end) <= 0.025 { + return append(points, end) + } + points = appendRefinedOccultationRiseSetJunction( + points, start, middle, phase, direction, location, cache, depth+1, + ) + return appendRefinedOccultationRiseSetJunction( + points, middle, end, phase, direction, location, cache, depth+1, + ) +} + +func occultationRiseSetProjectedTargetSpacingKM(start, end OccultationPathPoint) float64 { + latitude := math.Max(math.Abs(start.Latitude), math.Abs(end.Latitude)) + projectionScale := math.Cos(math.Min(latitude, 85) * rad) + return occultationRiseSetTargetSpacingKM * math.Max(0.1, projectionScale) +} + +func occultationRiseSetRefinementPointIsContinuous( + start, middle, end OccultationPathPoint, +) bool { + span := occultationPathDistanceKM(start, end) + if !finite(span) { + return false + } + // Newton can converge to the sibling root when the horizon contour folds. + // A valid midpoint must remain in the local spatial neighbourhood of both + // endpoints; the generous floor covers coarse samples while rejecting a + // cross-polar branch jump. + tolerance := math.Max(500, 2.5*span) + return occultationPathDistanceKM(start, middle) <= tolerance && + occultationPathDistanceKM(middle, end) <= tolerance +} + +func occultationRiseSetPhasePointOnHorizon( + tt, angle float64, + phase RiseSetPhase, + direction RiseSetDirection, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + point, evaluation, ok := occultationRiseSetRawPhasePointOnHorizon( + tt, angle, phase == RiseSetPhaseGreatest, location, cache, + ) + if !ok { + return OccultationPathPoint{}, false + } + classified, key, valid := evaluation.classify( + point.Longitude, point.Latitude, phase == RiseSetPhaseGreatest, location, + ) + if !valid || key.phase != phase || key.direction != direction { + return OccultationPathPoint{}, false + } + return classified, true +} + +func occultationRiseSetRawPhasePointOnHorizon( + tt, angle float64, + greatest bool, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, occultationRiseSetEvaluation, bool) { + evaluation := cache.evaluation(tt) + valueAt := func(candidateAngle float64) (float64, float64, float64, bool) { + longitude, latitude, horizonOK := occultationRiseSetHorizonPoint(tt, candidateAngle, cache.context) + if !horizonOK { + return 0, 0, 0, false + } + value, valueOK := occultationRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) + return value, longitude, latitude, valueOK && finite(value) + } + const angleStep = 1e-4 + angle = riseSetNormalizeRadians(angle) + for iteration := 0; iteration < 24; iteration++ { + value, _, _, ok := valueAt(angle) + if !ok { + return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false + } + if math.Abs(value) <= 1e-10 { + break + } + before, _, _, beforeOK := valueAt(angle - angleStep) + after, _, _, afterOK := valueAt(angle + angleStep) + if !beforeOK || !afterOK { + return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false + } + derivative := (after - before) / (2 * angleStep) + if !finite(derivative) || math.Abs(derivative) < 1e-16 { + return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false + } + delta := -value / derivative + if math.Abs(delta) > 0.25 { + delta = math.Copysign(0.25, delta) + } + angle = riseSetNormalizeRadians(angle + delta) + } + value, longitude, latitude, ok := valueAt(angle) + if !ok || math.Abs(value) > 1e-7 { + return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false + } + state := evaluation.center.stateAt(longitude, latitude) + if !state.valid { + return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false + } + return OccultationPathPoint{ + Time: occultationTTToLocation(tt, location), Longitude: longitude, + Latitude: latitude, MoonAltitude: state.moonAltitude, + }, evaluation, true +} diff --git a/basic/occultation_rise_set_test.go b/basic/occultation_rise_set_test.go new file mode 100644 index 0000000..a47266c --- /dev/null +++ b/basic/occultation_rise_set_test.go @@ -0,0 +1,93 @@ +package basic + +import ( + "reflect" + "testing" + "time" +) + +// 折点补根的判据只能统计查询窗口内部的未成对端点:窗口边界上的裁剪端点没有任何折点 +// 可以配对,把它们计入会让“未成对端点数严格下降”永远不成立。 +func TestOccultationRiseSetFoldRecoveryCountsInteriorEndpoints(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*3600) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + options := OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, + } + paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, options) + if err != nil || len(paths) != 1 || !paths[0].HasTotalBand { + t.Fatalf("paths=%d err=%v, want one total-band event", len(paths), err) + } + path := paths[0] + config, _ := planetOccultationConfigFor(OccultationMars) + cache := newPlanetOccultationEventCache(config) + greatestTT := occultationTimeToTT(path.Greatest.Time) + cache.preparePathEphemeris(greatestTT, OccultationPathAlgorithmOptimized) + firstTT := occultationTimeToTT(path.TotalStart.Time) + lastTT := occultationTimeToTT(path.TotalEnd.Time) + curves, base, recovered := occultationRiseSetCurvesWithRecoveryReport( + firstTT, lastTT, greatestTT, options, start.Location(), cache.totalRiseSetCache, + ) + if recovered == nil { + t.Fatal("fold recovery was never attempted on a graph with interior unpaired endpoints") + } + baseInterior := occultationRiseSetUnclosedEndpointCount(base, true, firstTT, lastTT) + recoveredInterior := occultationRiseSetUnclosedEndpointCount(recovered, true, firstTT, lastTT) + if baseInterior == 0 || recoveredInterior >= baseInterior { + t.Fatalf("interior unpaired endpoints base=%d recovered=%d, want a strict decrease", + baseInterior, recoveredInterior) + } + // 全局计数被窗口裁剪端点抬平:按它判定会丢弃这次重建。 + baseGlobal := occultationRiseSetUnclosedEndpointCount(base, false, firstTT, lastTT) + recoveredGlobal := occultationRiseSetUnclosedEndpointCount(recovered, false, firstTT, lastTT) + if recoveredGlobal < baseGlobal { + t.Fatalf("global unpaired endpoints base=%d recovered=%d, sample no longer shows a window-truncated endpoint", + baseGlobal, recoveredGlobal) + } + if !reflect.DeepEqual(curves, recovered) { + t.Fatal("interior-endpoint test did not select the rebuilt phase graph") + } +} + +func TestOccultationRiseSetWindowClippedEndpointIgnoredByInteriorCount(t *testing.T) { + firstTT := 2460000.0 + lastTT := firstTT + 0.25 + point := func(tt float64) OccultationPathPoint { + return OccultationPathPoint{Time: occultationTTToLocation(tt, time.UTC)} + } + curves := []OccultationRiseSetCurve{ + { + Phase: RiseSetPhaseStart, Direction: RiseSetDirectionRise, + Segments: [][]OccultationPathPoint{ + {point(lastTT), point(lastTT + 0.01)}, + {point(firstTT + 0.02), point(firstTT + 0.03)}, + }, + }, + } + if got := occultationRiseSetUnclosedEndpointCount(curves, false, firstTT, lastTT); got != 4 { + t.Fatalf("global unpaired endpoints=%d, want 4", got) + } + if got := occultationRiseSetUnclosedEndpointCount(curves, true, firstTT, lastTT); got != 2 { + t.Fatalf("interior unpaired endpoints=%d, want 2", got) + } +} + +// stateAt 的分配预算:同一上下文内换站点只复用已分配的站点表,不再额外分配。 +func BenchmarkOccultationRiseSetStateAt(b *testing.B) { + context := newOccultationRiseSetContext( + occultationTimeToTT(time.Date(2025, time.July, 29, 12, 0, 0, 0, time.UTC)), + 100, 10, 384400, 200, -15, 149597870.7*2, 3389.5, + ) + coordinates := make([][2]float64, 0, 64) + for index := 0; index < 64; index++ { + coordinates = append(coordinates, [2]float64{float64(index) * 1.7, float64(index%30) - 15}) + } + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + for _, coordinate := range coordinates { + _ = context.stateAt(coordinate[0], coordinate[1]) + } + } +} diff --git a/basic/occultation_rise_set_topology.go b/basic/occultation_rise_set_topology.go new file mode 100644 index 0000000..c4db6ea --- /dev/null +++ b/basic/occultation_rise_set_topology.go @@ -0,0 +1,1241 @@ +package basic + +import ( + "math" + "sort" + "time" +) + +func appendOccultationRiseSetSamples( + tracks []*occultationRiseSetTrack, + points []OccultationPathPoint, + stepDays float64, +) []*occultationRiseSetTrack { + sort.SliceStable(points, func(first, second int) bool { + return points[first].Longitude < points[second].Longitude + }) + type match struct { + point, track int + distance float64 + } + var matches []match + for pointIndex, point := range points { + for index, track := range tracks { + if len(track.segments) == 0 || len(track.segments[len(track.segments)-1]) == 0 { + continue + } + last := track.segments[len(track.segments)-1][len(track.segments[len(track.segments)-1])-1] + deltaDays := point.Time.Sub(last.Time).Hours() / 24 + if deltaDays <= 0 || deltaDays > 2.5*stepDays { + continue + } + distance := occultationPathDistanceKM(last, point) + if riseSetGeographicBranchChanged(distance, deltaDays) { + continue + } + matches = append(matches, match{point: pointIndex, track: index, distance: distance}) + } + } + // Match the closest pairs before creating tracks for newly appearing + // roots. Longitude order must not let a new root claim an existing branch + // ahead of that branch's much closer continuation. + sort.SliceStable(matches, func(i, j int) bool { return matches[i].distance < matches[j].distance }) + usedTracks, usedPoints := make([]bool, len(tracks)), make([]bool, len(points)) + for _, candidate := range matches { + if usedTracks[candidate.track] || usedPoints[candidate.point] { + continue + } + usedTracks[candidate.track], usedPoints[candidate.point] = true, true + track := tracks[candidate.track] + track.segments[len(track.segments)-1] = append(track.segments[len(track.segments)-1], points[candidate.point]) + } + for index, point := range points { + if !usedPoints[index] { + tracks = append(tracks, &occultationRiseSetTrack{segments: [][]OccultationPathPoint{{point}}}) + } + } + return tracks +} + +func completeOccultationRiseSetCurveEndpoints( + curves []OccultationRiseSetCurve, + stepDays float64, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) { + for index := range curves { + completeOccultationRiseSetFoldEndpoints(&curves[index], stepDays, location, cache) + } + completeOccultationRiseSetPhaseJunctions(curves, stepDays, location, cache) + completeOccultationRiseSetDirectionJunctions(curves, stepDays, location, cache) + refinementCache := cache.candidateOnly() + for index := range curves { + // Split sampled branch jumps before inserting adaptive midpoints. If a + // midpoint root is singular, refinement must not turn that jump back into + // a long straight segment. + normalizeOccultationRiseSetCurveSegments(&curves[index]) + refineOccultationRiseSetCurveSpacing(&curves[index], location, refinementCache) + normalizeOccultationRiseSetCurveSegments(&curves[index]) + } + refineOccultationRiseSetPhaseJunctionApproaches(curves, location, refinementCache) + for index := range curves { + normalizeOccultationRiseSetCurveSegments(&curves[index]) + } +} + +// normalizeOccultationRiseSetCurveSegments keeps each rendered branch +// strictly time-ordered. A completed horizon fold is a branch boundary when +// its time reverses; roots at the same location and time are numerical dupes. +func normalizeOccultationRiseSetCurveSegments(curve *OccultationRiseSetCurve) { + if curve == nil { + return + } + segments := make([][]OccultationPathPoint, 0, len(curve.Segments)) + for _, segment := range curve.Segments { + if len(segment) == 0 { + continue + } + current := []OccultationPathPoint{segment[0]} + for _, point := range segment[1:] { + last := current[len(current)-1] + pointTT, lastTT := occultationTimeToTT(point.Time), occultationTimeToTT(last.Time) + if pointTT > lastTT+occultationRiseSetTimeEpsilonDays { + if occultationRiseSetBranchChanged( + occultationPathDistanceKM(last, point), pointTT-lastTT, + ) { + if len(current) >= 2 { + segments = append(segments, current) + } + current = []OccultationPathPoint{point} + continue + } + current = append(current, point) + continue + } + if math.Abs(pointTT-lastTT) <= occultationRiseSetTimeEpsilonDays && + occultationPathDistanceKM(point, last) <= 0.01 { + continue + } + if len(current) >= 2 { + segments = append(segments, current) + } + current = []OccultationPathPoint{point} + } + if len(current) >= 2 { + segments = append(segments, current) + } + } + curve.Segments = segments +} + +type occultationRiseSetEndpoint struct { + curveIndex int + segmentIndex int + atStart bool + point OccultationPathPoint +} + +type occultationRiseSetPhaseAttachment struct { + endpoint occultationRiseSetEndpoint + fold OccultationPathPoint + hasFold bool +} + +func completeOccultationRiseSetFoldEndpoints( + curve *OccultationRiseSetCurve, + stepDays float64, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) { + if curve == nil || len(curve.Segments) < 2 { + return + } + endpoints := make([]occultationRiseSetEndpoint, 0, 2*len(curve.Segments)) + for segmentIndex, segment := range curve.Segments { + if len(segment) == 0 { + continue + } + for _, atStart := range []bool{true, false} { + endpoints = append(endpoints, occultationRiseSetEndpoint{ + segmentIndex: segmentIndex, atStart: atStart, + point: occultationRiseSetSegmentEndpoint(segment, atStart), + }) + } + } + used := make(map[[2]int]bool, len(endpoints)) + for firstIndex := 0; firstIndex < len(endpoints); firstIndex++ { + first := endpoints[firstIndex] + firstKey := [2]int{first.segmentIndex, boolInt(first.atStart)} + if used[firstKey] { + continue + } + for secondIndex := firstIndex + 1; secondIndex < len(endpoints); secondIndex++ { + second := endpoints[secondIndex] + secondKey := [2]int{second.segmentIndex, boolInt(second.atStart)} + if first.segmentIndex == second.segmentIndex || first.atStart != second.atStart || used[secondKey] { + continue + } + // Close roots can cease to be resolved on adjacent time samples. + // Candidate pairing may span one sample; the fold solver and its + // physical residuals still decide whether the branches really meet. + if math.Abs(occultationTimeToTT(first.point.Time)-occultationTimeToTT(second.point.Time)) > math.Max(1e-8, 1.5*stepDays) { + continue + } + distance := occultationPathDistanceKM(first.point, second.point) + if distance <= 0.01 || distance > 3000 { + continue + } + if !occultationRiseSetFoldCandidate( + curve.Segments[first.segmentIndex], first.atStart, + curve.Segments[second.segmentIndex], second.atStart, + ) { + continue + } + fold, ok := refineOccultationRiseSetFold( + first.point, second.point, first.atStart, + curve.Phase == RiseSetPhaseGreatest, stepDays, location, cache, + ) + if !ok || math.Abs(occultationTimeToTT(fold.Time)-(occultationTimeToTT(first.point.Time)+occultationTimeToTT(second.point.Time))/2) > 2.5*stepDays || + occultationPathDistanceKM(fold, first.point) > 3000 || occultationPathDistanceKM(fold, second.point) > 3000 { + continue + } + curve.Segments[first.segmentIndex] = occultationRiseSetAddEndpoint(curve.Segments[first.segmentIndex], fold, first.atStart) + curve.Segments[second.segmentIndex] = occultationRiseSetAddEndpoint(curve.Segments[second.segmentIndex], fold, second.atStart) + used[firstKey], used[secondKey] = true, true + break + } + } +} + +// occultationRiseSetFoldCandidate reports whether two nearby endpoints are +// actually separating into different sampled branches. Endpoints that merely +// happen to be close are common on smooth curves and do not need a fold root. +func occultationRiseSetFoldCandidate( + first []OccultationPathPoint, + firstAtStart bool, + second []OccultationPathPoint, + secondAtStart bool, +) bool { + firstAdjacent, firstOK := occultationRiseSetAdjacentPoint(first, firstAtStart) + secondAdjacent, secondOK := occultationRiseSetAdjacentPoint(second, secondAtStart) + if !firstOK || !secondOK { + return true + } + endpointDistance := occultationPathDistanceKM( + occultationRiseSetSegmentEndpoint(first, firstAtStart), + occultationRiseSetSegmentEndpoint(second, secondAtStart), + ) + adjacentDistance := occultationPathDistanceKM(firstAdjacent, secondAdjacent) + margin := 250.0 + firstEndpoint := occultationRiseSetSegmentEndpoint(first, firstAtStart) + secondEndpoint := occultationRiseSetSegmentEndpoint(second, secondAtStart) + if math.Max(math.Abs(firstEndpoint.Latitude), math.Abs(secondEndpoint.Latitude)) >= 70 { + // Longitude convergence makes the first post-fold samples unusually + // close near a pole. Leave numerical room for a 10-second sample to + // land just inside the ordinary 250 km divergence margin. + margin = 200 + } + return adjacentDistance > endpointDistance+margin +} + +func occultationRiseSetAdjacentPoint(segment []OccultationPathPoint, atStart bool) (OccultationPathPoint, bool) { + if len(segment) < 2 { + return OccultationPathPoint{}, false + } + if atStart { + return segment[1], true + } + return segment[len(segment)-2], true +} + +func completeOccultationRiseSetPhaseJunctions( + curves []OccultationRiseSetCurve, + stepDays float64, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) { + curveIndices := make(map[occultationRiseSetCurveKey]int, len(curves)) + for index, curve := range curves { + curveIndices[occultationRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = index + } + for _, direction := range []RiseSetDirection{RiseSetDirectionRise, RiseSetDirectionSet} { + startIndex, haveStart := curveIndices[occultationRiseSetCurveKey{phase: RiseSetPhaseStart, direction: direction}] + greatestIndex, haveGreatest := curveIndices[occultationRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: direction}] + endIndex, haveEnd := curveIndices[occultationRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: direction}] + if !haveStart || !haveGreatest || !haveEnd { + continue + } + seeds := occultationRiseSetUnsharedEndpoints(startIndex, curves[startIndex].Segments) + for _, seed := range seeds { + candidateSeeds := []OccultationPathPoint{seed.point} + if endEndpoint, endOK := occultationRiseSetClosestEndpoint(seed.point, curves[endIndex].Segments, stepDays); endOK { + candidateSeeds = append([]OccultationPathPoint{ + occultationRiseSetMidpoint(seed.point, endEndpoint.point), + endEndpoint.point, + }, candidateSeeds...) + } + junction, ok := OccultationPathPoint{}, false + for _, candidate := range candidateSeeds { + junction, ok = refineOccultationRiseSetPhaseJunctionOnHorizon(candidate, location, cache) + if !ok { + junction, ok = refineOccultationRiseSetPhaseJunction(candidate, location, cache) + } + if ok { + break + } + } + if !ok || math.Abs(occultationTimeToTT(junction.Time)-occultationTimeToTT(seed.point.Time)) > 3*stepDays || + occultationPathDistanceKM(junction, seed.point) > 3000 { + continue + } + matched := make([]occultationRiseSetPhaseAttachment, 0, 3) + attachmentTargets := make(map[int]OccultationPathPoint, 1) + recovery := occultationRiseSetNarrowPhaseRecovery{} + recovered := false + // Contact extrema include the changing apparent radii; greatest + // separation need not end at exactly the same time. Complete the + // start/end pair even when the diagnostic greatest branch differs. + for _, curveIndex := range []int{startIndex, endIndex, greatestIndex} { + endpoint, endpointOK := occultationRiseSetClosestEndpoint( + junction, curves[curveIndex].Segments, stepDays, + ) + if !endpointOK { + if curveIndex == greatestIndex { + continue + } + matched = nil + break + } + endpoint.curveIndex = curveIndex + attachment := occultationRiseSetPhaseAttachment{endpoint: endpoint} + if curveIndex == greatestIndex && occultationPathDistanceKM(junction, endpoint.point) > occultationPathBoundarySpacingKM { + if !occultationRiseSetPhaseSegmentIsContinuous( + junction, endpoint.point, RiseSetPhaseGreatest, direction, + location, cache, + ) { + fold, foldOK := refineOccultationRiseSetFold( + junction, endpoint.point, endpoint.atStart, true, + stepDays, location, cache, + ) + if !foldOK || !occultationRiseSetFoldBridgesPhaseJunction( + junction, endpoint, fold, direction, cache, + ) { + recovery, recovered = occultationRiseSetRecoverNarrowPhaseJunction( + junction, endpoint, direction, stepDays, location, cache, + ) + if !recovered { + break + } + attachmentTargets[curveIndex] = recovery.junction + } else { + attachment.fold, attachment.hasFold = fold, true + } + } + } + matched = append(matched, attachment) + } + if len(matched) < 2 { + continue + } + for _, attachment := range matched { + endpoint := attachment.endpoint + target := junction + if recoveredTarget, ok := attachmentTargets[endpoint.curveIndex]; ok { + target = recoveredTarget + } + if attachment.hasFold { + curves[endpoint.curveIndex].Segments[endpoint.segmentIndex] = occultationRiseSetAddEndpoint( + curves[endpoint.curveIndex].Segments[endpoint.segmentIndex], attachment.fold, endpoint.atStart, + ) + shortBranch := []OccultationPathPoint{attachment.fold, target} + if !endpoint.atStart { + shortBranch[0], shortBranch[1] = shortBranch[1], shortBranch[0] + } + curves[endpoint.curveIndex].Segments = append(curves[endpoint.curveIndex].Segments, shortBranch) + continue + } + curves[endpoint.curveIndex].Segments[endpoint.segmentIndex] = occultationRiseSetAddEndpoint( + curves[endpoint.curveIndex].Segments[endpoint.segmentIndex], target, endpoint.atStart, + ) + } + if recovered { + if segment := recovery.segments[RiseSetPhaseStart]; len(segment) >= 2 { + curves[startIndex].Segments = append(curves[startIndex].Segments, segment) + } + if segment := recovery.segments[RiseSetPhaseEnd]; len(segment) >= 2 { + curves[endIndex].Segments = append(curves[endIndex].Segments, segment) + } + } + } + } +} + +func occultationRiseSetPhaseSegmentIsContinuous( + start, end OccultationPathPoint, + phase RiseSetPhase, + direction RiseSetDirection, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) bool { + startTT := occultationTimeToTT(start.Time) + endTT := occultationTimeToTT(end.Time) + if math.Abs(endTT-startTT)*86400 < 0.1 { + return false + } + totalDistance := occultationPathDistanceKM(start, end) + continuityToleranceKM := math.Max(100, 0.1*totalDistance) + candidate := end + for divisor := 2.0; divisor <= 1024; divisor *= 2 { + tt := startTT + (endTT-startTT)/divisor + seedAngle := occultationRiseSetHorizonAngle( + tt, candidate.Longitude, candidate.Latitude, cache.context, + ) + next, ok := occultationRiseSetPhasePointOnHorizon( + tt, seedAngle, phase, direction, location, cache, + ) + if !ok { + return occultationPathDistanceKM(start, candidate) <= continuityToleranceKM + } + candidate = next + // At a three-phase junction the root becomes numerically singular. + // Once the traced branch is already inside the spatial tolerance, + // continuing toward the exact junction can jump to its sibling root. + if occultationPathDistanceKM(start, candidate) <= continuityToleranceKM { + return true + } + } + return occultationPathDistanceKM(start, candidate) <= continuityToleranceKM +} + +type occultationRiseSetNarrowPhaseRecovery struct { + junction OccultationPathPoint + segments map[RiseSetPhase][]OccultationPathPoint +} + +// A pair of phase junctions can be only a few seconds apart. The ordinary +// horizon scan cannot resolve both roots, so continue the already sampled +// greatest-phase branch to its contact crossing and reconstruct the two short +// contact branches only for that local degeneracy. +func occultationRiseSetRecoverNarrowPhaseJunction( + primary OccultationPathPoint, + greatestEndpoint occultationRiseSetEndpoint, + direction RiseSetDirection, + stepDays float64, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (occultationRiseSetNarrowPhaseRecovery, bool) { + fold, ok := refineOccultationRiseSetFold( + primary, greatestEndpoint.point, greatestEndpoint.atStart, + true, stepDays, location, cache, + ) + if !ok { + return occultationRiseSetNarrowPhaseRecovery{}, false + } + primaryTT := occultationTimeToTT(primary.Time) + endpointTT := occultationTimeToTT(greatestEndpoint.point.Time) + foldTT := occultationTimeToTT(fold.Time) + extensionDirection := primaryTT - endpointTT + if extensionDirection == 0 || (foldTT-primaryTT)*extensionDirection <= 0 { + return occultationRiseSetNarrowPhaseRecovery{}, false + } + seedAngle := occultationRiseSetHorizonAngle( + primaryTT, greatestEndpoint.point.Longitude, greatestEndpoint.point.Latitude, cache.context, + ) + branchPoint, branchEvaluation, ok := occultationRiseSetRawPhasePointOnHorizon( + primaryTT, seedAngle, true, location, cache, + ) + if !ok || !occultationRiseSetDirectionMatches( + branchEvaluation, branchPoint, direction, + ) { + return occultationRiseSetNarrowPhaseRecovery{}, false + } + branchContact := branchEvaluation.center.stateAt( + branchPoint.Longitude, branchPoint.Latitude, + ).contactMetric + foldEvaluation := cache.evaluation(foldTT) + foldContact := foldEvaluation.center.stateAt(fold.Longitude, fold.Latitude).contactMetric + if !finite(branchContact) || !finite(foldContact) || branchContact >= 0 || foldContact <= 0 { + return occultationRiseSetNarrowPhaseRecovery{}, false + } + + inside, outside := branchPoint, fold + insideContact, outsideContact := branchContact, foldContact + for iteration := 0; iteration < 56; iteration++ { + insideTT := occultationTimeToTT(inside.Time) + outsideTT := occultationTimeToTT(outside.Time) + if math.Abs(outsideTT-insideTT) <= 1e-10 { + break + } + middleTT := (insideTT + outsideTT) / 2 + insideAngle := occultationRiseSetHorizonAngle( + middleTT, inside.Longitude, inside.Latitude, cache.context, + ) + outsideAngle := occultationRiseSetHorizonAngle( + middleTT, outside.Longitude, outside.Latitude, cache.context, + ) + if !finite(insideAngle) || !finite(outsideAngle) { + return occultationRiseSetNarrowPhaseRecovery{}, false + } + middleAngle := riseSetNormalizeRadians( + insideAngle + math.Remainder(outsideAngle-insideAngle, 2*math.Pi)/2, + ) + middle, evaluation, middleOK := occultationRiseSetRawPhasePointOnHorizon( + middleTT, middleAngle, true, location, cache, + ) + if !middleOK || !occultationRiseSetDirectionMatches(evaluation, middle, direction) { + outside = OccultationPathPoint{ + Time: occultationTTToLocation(middleTT, location), + Longitude: normalizeLongitude( + inside.Longitude + math.Remainder(outside.Longitude-inside.Longitude, 360)/2, + ), + Latitude: (inside.Latitude + outside.Latitude) / 2, + } + continue + } + contact := evaluation.center.stateAt(middle.Longitude, middle.Latitude).contactMetric + if !finite(contact) { + return occultationRiseSetNarrowPhaseRecovery{}, false + } + if contact <= 0 { + inside, insideContact = middle, contact + } else { + outside, outsideContact = middle, contact + } + } + secondary := inside + if math.Abs(outsideContact) < math.Abs(insideContact) { + secondary = outside + } + secondaryTT := occultationTimeToTT(secondary.Time) + secondaryEvaluation := cache.evaluation(secondaryTT) + secondaryState := secondaryEvaluation.center.stateAt(secondary.Longitude, secondary.Latitude) + if !secondaryState.valid || math.Abs(secondaryState.contactMetric) > 1e-7 || + math.Abs(secondaryEvaluation.separationDerivative(secondary.Longitude, secondary.Latitude)) > 1e-7 || + math.Abs(secondaryState.moonAltitude) > 1e-7 || + !occultationRiseSetDirectionMatches(secondaryEvaluation, secondary, direction) || + math.Abs(secondaryTT-primaryTT) > stepDays || + occultationPathDistanceKM(primary, secondary) > 500 { + return occultationRiseSetNarrowPhaseRecovery{}, false + } + + phaseRoots, ok := occultationRiseSetNarrowContactRoots( + primary, secondary, direction, location, cache, + ) + if !ok { + return occultationRiseSetNarrowPhaseRecovery{}, false + } + first, last := primary, secondary + if last.Time.Before(first.Time) { + first, last = last, first + } + segments := make(map[RiseSetPhase][]OccultationPathPoint, 2) + for phase, root := range phaseRoots { + segments[phase] = []OccultationPathPoint{first, root, last} + } + return occultationRiseSetNarrowPhaseRecovery{junction: secondary, segments: segments}, true +} + +func occultationRiseSetDirectionMatches( + evaluation occultationRiseSetEvaluation, + point OccultationPathPoint, + direction RiseSetDirection, +) bool { + derivative := evaluation.moonAltitudeDerivative(point.Longitude, point.Latitude) + return finite(derivative) && ((direction == RiseSetDirectionRise && derivative > 0) || + (direction == RiseSetDirectionSet && derivative < 0)) +} + +func occultationRiseSetNarrowContactRoots( + first, second OccultationPathPoint, + direction RiseSetDirection, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (map[RiseSetPhase]OccultationPathPoint, bool) { + tt := (occultationTimeToTT(first.Time) + occultationTimeToTT(second.Time)) / 2 + firstAngle := occultationRiseSetHorizonAngle(tt, first.Longitude, first.Latitude, cache.context) + secondAngle := occultationRiseSetHorizonAngle(tt, second.Longitude, second.Latitude, cache.context) + if !finite(firstAngle) || !finite(secondAngle) { + return nil, false + } + centerAngle := riseSetNormalizeRadians( + firstAngle + math.Remainder(secondAngle-firstAngle, 2*math.Pi)/2, + ) + result := make(map[RiseSetPhase]OccultationPathPoint, 2) + for _, phase := range []RiseSetPhase{RiseSetPhaseStart, RiseSetPhaseEnd} { + found := false + for scale := 0; scale < 22 && !found; scale++ { + delta := 0.0 + if scale > 0 { + delta = math.Ldexp(1e-6, scale-1) + } + for _, sign := range []float64{-1, 1} { + if delta == 0 && sign > 0 { + continue + } + point, ok := occultationRiseSetPhasePointOnHorizon( + tt, centerAngle+sign*delta, phase, direction, location, cache, + ) + if !ok || math.Min( + occultationPathDistanceKM(first, point), + occultationPathDistanceKM(second, point), + ) > 500 { + continue + } + result[phase], found = point, true + break + } + } + if !found { + continue + } + } + return result, len(result) > 0 +} + +func occultationRiseSetFoldBridgesPhaseJunction( + junction OccultationPathPoint, + endpoint occultationRiseSetEndpoint, + fold OccultationPathPoint, + direction RiseSetDirection, + cache *occultationRiseSetEvaluationCache, +) bool { + junctionTT := occultationTimeToTT(junction.Time) + endpointTT := occultationTimeToTT(endpoint.point.Time) + foldTT := occultationTimeToTT(fold.Time) + const timeToleranceDays = 1e-8 + if endpoint.atStart { + if foldTT > math.Min(junctionTT, endpointTT)+timeToleranceDays { + return false + } + } else if foldTT < math.Max(junctionTT, endpointTT)-timeToleranceDays { + return false + } + if occultationPathDistanceKM(fold, junction) > 3000 || + occultationPathDistanceKM(fold, endpoint.point) > 3000 { + return false + } + evaluation := cache.evaluation(foldTT) + _, key, valid := evaluation.classify(fold.Longitude, fold.Latitude, true, fold.Time.Location()) + return valid && key.phase == RiseSetPhaseGreatest && key.direction == direction +} + +func occultationRiseSetMidpoint(first, second OccultationPathPoint) OccultationPathPoint { + firstTT := occultationTimeToTT(first.Time) + secondTT := occultationTimeToTT(second.Time) + return OccultationPathPoint{ + Time: occultationTTToLocation((firstTT+secondTT)/2, first.Time.Location()), + Longitude: normalizeLongitude( + first.Longitude + math.Remainder(second.Longitude-first.Longitude, 360)/2, + ), + Latitude: (first.Latitude + second.Latitude) / 2, + MoonAltitude: (first.MoonAltitude + second.MoonAltitude) / 2, + } +} + +func completeOccultationRiseSetDirectionJunctions( + curves []OccultationRiseSetCurve, + stepDays float64, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) { + type candidate struct { + riseIndex, setIndex int + junction OccultationPathPoint + riseAttachment occultationRiseSetPhaseAttachment + setAttachment occultationRiseSetPhaseAttachment + metric float64 + } + curveIndices := make(map[occultationRiseSetCurveKey]int, len(curves)) + for index, curve := range curves { + curveIndices[occultationRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = index + } + for _, phase := range []RiseSetPhase{RiseSetPhaseStart, RiseSetPhaseGreatest, RiseSetPhaseEnd} { + riseIndex, haveRise := curveIndices[occultationRiseSetCurveKey{phase: phase, direction: RiseSetDirectionRise}] + setIndex, haveSet := curveIndices[occultationRiseSetCurveKey{phase: phase, direction: RiseSetDirectionSet}] + if !haveRise || !haveSet { + continue + } + riseEndpoints := occultationRiseSetAllEndpoints(curves[riseIndex].Segments) + setEndpoints := occultationRiseSetAllEndpoints(curves[setIndex].Segments) + candidates := make([]candidate, 0, len(riseEndpoints)*len(setEndpoints)) + for riseEndpointIndex, riseEndpoint := range riseEndpoints { + for setEndpointIndex, setEndpoint := range setEndpoints { + deltaDays := math.Abs(occultationTimeToTT(riseEndpoint.point.Time) - occultationTimeToTT(setEndpoint.point.Time)) + if deltaDays > 3*stepDays { + continue + } + distance := occultationPathDistanceKM(riseEndpoint.point, setEndpoint.point) + if distance > 3000 { + continue + } + junction, ok := refineOccultationRiseSetDirectionJunctionOnHorizon( + riseEndpoint.point, setEndpoint.point, phase == RiseSetPhaseGreatest, location, cache, + ) + if !ok { + longitude := normalizeLongitude(riseEndpoint.point.Longitude + math.Remainder(setEndpoint.point.Longitude-riseEndpoint.point.Longitude, 360)/2) + latitude := (riseEndpoint.point.Latitude + setEndpoint.point.Latitude) / 2 + junction, ok = refineOccultationRiseSetDirectionJunction( + (occultationTimeToTT(riseEndpoint.point.Time)+occultationTimeToTT(setEndpoint.point.Time))/2, + longitude, latitude, phase == RiseSetPhaseGreatest, location, cache, + ) + } + if !ok { + continue + } + junctionDeltaRise := math.Abs(occultationTimeToTT(junction.Time) - occultationTimeToTT(riseEndpoint.point.Time)) + junctionDeltaSet := math.Abs(occultationTimeToTT(junction.Time) - occultationTimeToTT(setEndpoint.point.Time)) + junctionDistanceRise := occultationPathDistanceKM(junction, riseEndpoint.point) + junctionDistanceSet := occultationPathDistanceKM(junction, setEndpoint.point) + if !occultationRiseSetEndpointAcceptsJunction(riseEndpoint, junction) || + !occultationRiseSetEndpointAcceptsJunction(setEndpoint, junction) || + junctionDeltaRise > 3*stepDays || junctionDeltaSet > 3*stepDays || + junctionDistanceRise > 3000 || junctionDistanceSet > 3000 || + occultationRiseSetBranchChanged(junctionDistanceRise, junctionDeltaRise) || + occultationRiseSetBranchChanged(junctionDistanceSet, junctionDeltaSet) { + continue + } + riseAttachment, riseOK := occultationRiseSetDirectionAttachment( + riseEndpoint, junction, phase, RiseSetDirectionRise, + stepDays, location, cache, + ) + setAttachment, setOK := occultationRiseSetDirectionAttachment( + setEndpoint, junction, phase, RiseSetDirectionSet, + stepDays, location, cache, + ) + if !riseOK || !setOK { + continue + } + metric := occultationPathDistanceKM(junction, riseEndpoint.point) + + occultationPathDistanceKM(junction, setEndpoint.point) + candidates = append(candidates, candidate{ + riseIndex: riseEndpointIndex, setIndex: setEndpointIndex, + junction: junction, riseAttachment: riseAttachment, + setAttachment: setAttachment, metric: metric, + }) + } + } + sort.SliceStable(candidates, func(first, second int) bool { + return candidates[first].metric < candidates[second].metric + }) + usedRise := make([]bool, len(riseEndpoints)) + usedSet := make([]bool, len(setEndpoints)) + for _, candidate := range candidates { + if usedRise[candidate.riseIndex] || usedSet[candidate.setIndex] { + continue + } + occultationRiseSetAttachDirectionJunction( + &curves[riseIndex], candidate.riseAttachment, candidate.junction, + ) + occultationRiseSetAttachDirectionJunction( + &curves[setIndex], candidate.setAttachment, candidate.junction, + ) + usedRise[candidate.riseIndex] = true + usedSet[candidate.setIndex] = true + } + } +} + +func occultationRiseSetDirectionAttachment( + endpoint occultationRiseSetEndpoint, + junction OccultationPathPoint, + phase RiseSetPhase, + direction RiseSetDirection, + stepDays float64, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (occultationRiseSetPhaseAttachment, bool) { + attachment := occultationRiseSetPhaseAttachment{endpoint: endpoint} + if occultationPathDistanceKM(junction, endpoint.point) <= occultationPathBoundarySpacingKM || + occultationRiseSetPhaseSegmentIsContinuous( + junction, endpoint.point, phase, direction, location, cache, + ) { + return attachment, true + } + fold, ok := refineOccultationRiseSetFold( + junction, endpoint.point, endpoint.atStart, + phase == RiseSetPhaseGreatest, stepDays, location, cache, + ) + if !ok || !occultationRiseSetFoldBridgesDirectionJunction( + junction, endpoint, fold, phase, direction, cache, + ) { + return occultationRiseSetPhaseAttachment{}, false + } + attachment.fold, attachment.hasFold = fold, true + return attachment, true +} + +func occultationRiseSetAttachDirectionJunction( + curve *OccultationRiseSetCurve, + attachment occultationRiseSetPhaseAttachment, + junction OccultationPathPoint, +) { + endpoint := attachment.endpoint + if attachment.hasFold { + curve.Segments[endpoint.segmentIndex] = occultationRiseSetAddEndpoint( + curve.Segments[endpoint.segmentIndex], attachment.fold, endpoint.atStart, + ) + shortBranch := []OccultationPathPoint{attachment.fold, junction} + if !endpoint.atStart { + shortBranch[0], shortBranch[1] = shortBranch[1], shortBranch[0] + } + curve.Segments = append(curve.Segments, shortBranch) + return + } + curve.Segments[endpoint.segmentIndex] = occultationRiseSetAddEndpoint( + curve.Segments[endpoint.segmentIndex], junction, endpoint.atStart, + ) +} + +func occultationRiseSetFoldBridgesDirectionJunction( + junction OccultationPathPoint, + endpoint occultationRiseSetEndpoint, + fold OccultationPathPoint, + phase RiseSetPhase, + direction RiseSetDirection, + cache *occultationRiseSetEvaluationCache, +) bool { + junctionTT := occultationTimeToTT(junction.Time) + endpointTT := occultationTimeToTT(endpoint.point.Time) + foldTT := occultationTimeToTT(fold.Time) + if endpoint.atStart { + if foldTT > math.Min(junctionTT, endpointTT)+occultationRiseSetTimeEpsilonDays { + return false + } + } else if foldTT < math.Max(junctionTT, endpointTT)-occultationRiseSetTimeEpsilonDays { + return false + } + if occultationPathDistanceKM(fold, junction) > 3000 || + occultationPathDistanceKM(fold, endpoint.point) > 3000 { + return false + } + evaluation := cache.evaluation(foldTT) + _, key, valid := evaluation.classify( + fold.Longitude, fold.Latitude, phase == RiseSetPhaseGreatest, fold.Time.Location(), + ) + return valid && key.phase == phase && key.direction == direction +} + +func occultationRiseSetEndpointAcceptsJunction( + endpoint occultationRiseSetEndpoint, + junction OccultationPathPoint, +) bool { + endpointTT := occultationTimeToTT(endpoint.point.Time) + junctionTT := occultationTimeToTT(junction.Time) + if endpoint.atStart { + return junctionTT <= endpointTT+occultationRiseSetTimeEpsilonDays + } + return junctionTT >= endpointTT-occultationRiseSetTimeEpsilonDays +} + +func occultationRiseSetUnsharedEndpoints(curveIndex int, segments [][]OccultationPathPoint) []occultationRiseSetEndpoint { + endpoints := occultationRiseSetAllEndpoints(segments) + result := make([]occultationRiseSetEndpoint, 0, len(endpoints)) + for index, endpoint := range endpoints { + shared := false + for otherIndex, other := range endpoints { + if index == otherIndex || endpoint.segmentIndex == other.segmentIndex { + continue + } + if math.Abs(occultationTimeToTT(endpoint.point.Time)-occultationTimeToTT(other.point.Time)) <= 1e-8 && + occultationPathDistanceKM(endpoint.point, other.point) <= 0.01 { + shared = true + break + } + } + if !shared { + endpoint.curveIndex = curveIndex + result = append(result, endpoint) + } + } + return result +} + +func occultationRiseSetAllEndpoints(segments [][]OccultationPathPoint) []occultationRiseSetEndpoint { + endpoints := make([]occultationRiseSetEndpoint, 0, 2*len(segments)) + for segmentIndex, segment := range segments { + if len(segment) == 0 { + continue + } + for _, atStart := range []bool{true, false} { + endpoints = append(endpoints, occultationRiseSetEndpoint{ + segmentIndex: segmentIndex, atStart: atStart, + point: occultationRiseSetSegmentEndpoint(segment, atStart), + }) + } + } + return endpoints +} + +func occultationRiseSetClosestEndpoint( + junction OccultationPathPoint, + segments [][]OccultationPathPoint, + stepDays float64, +) (occultationRiseSetEndpoint, bool) { + best := occultationRiseSetEndpoint{} + bestMetric := math.Inf(1) + junctionTT := occultationTimeToTT(junction.Time) + for segmentIndex, segment := range segments { + if len(segment) == 0 { + continue + } + for _, atStart := range []bool{true, false} { + point := occultationRiseSetSegmentEndpoint(segment, atStart) + pointTT := occultationTimeToTT(point.Time) + if atStart && junctionTT > pointTT+1e-8 || !atStart && junctionTT < pointTT-1e-8 || + math.Abs(junctionTT-pointTT) > 3*stepDays || occultationPathDistanceKM(junction, point) > 3000 { + continue + } + metric := occultationPathDistanceKM(junction, point) + math.Abs(junctionTT-pointTT)*8640 + if metric < bestMetric { + best = occultationRiseSetEndpoint{segmentIndex: segmentIndex, atStart: atStart, point: point} + bestMetric = metric + } + } + } + return best, bestMetric < math.Inf(1) +} + +func refineOccultationRiseSetPhaseJunction( + seed OccultationPathPoint, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + coordinates := [3]float64{seed.Longitude, seed.Latitude, occultationTimeToTT(seed.Time)} + for iteration := 0; iteration < 24; iteration++ { + evaluation := cache.evaluation(coordinates[2]) + residual, ok := occultationRiseSetPhaseJunctionResidualAt(evaluation, coordinates[0], coordinates[1]) + if !ok { + return OccultationPathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-8 && math.Abs(residual[1]) <= occultationRiseSetJunctionDerivativeTolerance && math.Abs(residual[2]) <= 1e-8 { + break + } + matrix, ok := occultationRiseSetPhaseJunctionJacobian(coordinates, residual, cache) + if !ok { + return OccultationPathPoint{}, false + } + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !ok { + return OccultationPathPoint{}, false + } + occultationRiseSetLimitJunctionDelta(&delta) + coordinates[0] = normalizeLongitude(coordinates[0] + delta[0]) + coordinates[1] += delta[1] + coordinates[2] += delta[2] + if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { + return OccultationPathPoint{}, false + } + } + evaluation := cache.evaluation(coordinates[2]) + residual, ok := occultationRiseSetPhaseJunctionResidualAt(evaluation, coordinates[0], coordinates[1]) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > occultationRiseSetJunctionDerivativeTolerance || math.Abs(residual[2]) > 1e-7 { + return OccultationPathPoint{}, false + } + state := evaluation.center.stateAt(coordinates[0], coordinates[1]) + return OccultationPathPoint{Time: occultationTTToLocation(coordinates[2], location), Longitude: coordinates[0], Latitude: coordinates[1], MoonAltitude: state.moonAltitude}, true +} + +func refineOccultationRiseSetPhaseJunctionOnHorizon( + seed OccultationPathPoint, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + seedTT := occultationTimeToTT(seed.Time) + coordinates := [2]float64{ + occultationRiseSetHorizonAngle(seedTT, seed.Longitude, seed.Latitude, cache.context), + 0, + } + residualAt := func(value [2]float64) ([2]float64, float64, float64, float64, bool) { + tt := seedTT + value[1]/1440 + longitude, latitude, horizonOK := occultationRiseSetHorizonPoint(tt, value[0], cache.context) + if !horizonOK { + return [2]float64{}, 0, 0, 0, false + } + evaluation := cache.evaluation(tt) + state := evaluation.center.stateAt(longitude, latitude) + residual := [2]float64{state.contactMetric, evaluation.contactDerivative(longitude, latitude)} + return residual, tt, longitude, latitude, + state.valid && finite(residual[0]) && finite(residual[1]) + } + const angleStep = 1e-4 + const timeStepMinutes = 1.0 / 60.0 + for iteration := 0; iteration < 32; iteration++ { + residual, _, _, _, ok := residualAt(coordinates) + if !ok { + return OccultationPathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-8 && math.Abs(residual[1]) <= occultationRiseSetJunctionDerivativeTolerance { + break + } + plusAngle, _, _, _, plusAngleOK := residualAt([2]float64{coordinates[0] + angleStep, coordinates[1]}) + minusAngle, _, _, _, minusAngleOK := residualAt([2]float64{coordinates[0] - angleStep, coordinates[1]}) + plusTime, _, _, _, plusTimeOK := residualAt([2]float64{coordinates[0], coordinates[1] + timeStepMinutes}) + minusTime, _, _, _, minusTimeOK := residualAt([2]float64{coordinates[0], coordinates[1] - timeStepMinutes}) + if !plusAngleOK || !minusAngleOK || !plusTimeOK || !minusTimeOK { + return OccultationPathPoint{}, false + } + matrix := [2][2]float64{ + {(plusAngle[0] - minusAngle[0]) / (2 * angleStep), (plusTime[0] - minusTime[0]) / (2 * timeStepMinutes)}, + {(plusAngle[1] - minusAngle[1]) / (2 * angleStep), (plusTime[1] - minusTime[1]) / (2 * timeStepMinutes)}, + } + determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0] + if !finite(determinant) || math.Abs(determinant) < 1e-18 { + return OccultationPathPoint{}, false + } + delta := [2]float64{ + (-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant, + (-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant, + } + if math.Abs(delta[0]) > 0.25 { + delta[0] = math.Copysign(0.25, delta[0]) + } + if math.Abs(delta[1]) > 5 { + delta[1] = math.Copysign(5, delta[1]) + } + coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0]) + coordinates[1] += delta[1] + } + residual, tt, longitude, latitude, ok := residualAt(coordinates) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > occultationRiseSetJunctionDerivativeTolerance { + return OccultationPathPoint{}, false + } + state := cache.context(tt).stateAt(longitude, latitude) + return OccultationPathPoint{ + Time: occultationTTToLocation(tt, location), Longitude: longitude, + Latitude: latitude, MoonAltitude: state.moonAltitude, + }, true +} + +func refineOccultationRiseSetDirectionJunction( + tt, longitude, latitude float64, + greatest bool, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + coordinates := [3]float64{longitude, latitude, tt} + for iteration := 0; iteration < 24; iteration++ { + evaluation := cache.evaluation(coordinates[2]) + residual, ok := occultationRiseSetDirectionJunctionResidualAt(evaluation, coordinates[0], coordinates[1], greatest) + if !ok { + return OccultationPathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-8 && math.Abs(residual[1]) <= 1e-9 && math.Abs(residual[2]) <= 1e-7 { + break + } + matrix, ok := occultationRiseSetDirectionJunctionJacobian(coordinates, residual, greatest, cache) + if !ok { + return OccultationPathPoint{}, false + } + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !ok { + return OccultationPathPoint{}, false + } + occultationRiseSetLimitJunctionDelta(&delta) + coordinates[0] = normalizeLongitude(coordinates[0] + delta[0]) + coordinates[1] += delta[1] + coordinates[2] += delta[2] + if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { + return OccultationPathPoint{}, false + } + } + evaluation := cache.evaluation(coordinates[2]) + residual, ok := occultationRiseSetDirectionJunctionResidualAt(evaluation, coordinates[0], coordinates[1], greatest) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 { + return OccultationPathPoint{}, false + } + state := evaluation.center.stateAt(coordinates[0], coordinates[1]) + return OccultationPathPoint{Time: occultationTTToLocation(coordinates[2], location), Longitude: coordinates[0], Latitude: coordinates[1], MoonAltitude: state.moonAltitude}, true +} + +func refineOccultationRiseSetDirectionJunctionOnHorizon( + first, second OccultationPathPoint, + greatest bool, + location *time.Location, + cache *occultationRiseSetEvaluationCache, +) (OccultationPathPoint, bool) { + seedTT := (occultationTimeToTT(first.Time) + occultationTimeToTT(second.Time)) / 2 + firstAngle := occultationRiseSetHorizonAngle(seedTT, first.Longitude, first.Latitude, cache.context) + secondAngle := occultationRiseSetHorizonAngle(seedTT, second.Longitude, second.Latitude, cache.context) + if !finite(firstAngle) || !finite(secondAngle) { + return OccultationPathPoint{}, false + } + coordinates := [2]float64{ + riseSetNormalizeRadians(firstAngle + math.Remainder(secondAngle-firstAngle, 2*math.Pi)/2), + 0, + } + residualAt := func(value [2]float64) ([2]float64, float64, float64, float64, bool) { + tt := seedTT + value[1]/1440 + longitude, latitude, ok := occultationRiseSetHorizonPoint(tt, value[0], cache.context) + if !ok { + return [2]float64{}, 0, 0, 0, false + } + evaluation := cache.evaluation(tt) + phaseResidual, phaseOK := occultationRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) + altitudeDerivative := evaluation.moonAltitudeDerivative(longitude, latitude) + return [2]float64{phaseResidual, altitudeDerivative}, tt, longitude, latitude, + phaseOK && finite(phaseResidual) && finite(altitudeDerivative) + } + const ( + angleStep = 1e-4 + timeStepMinute = 1.0 / 60 + ) + for iteration := 0; iteration < 32; iteration++ { + residual, _, _, _, ok := residualAt(coordinates) + if !ok { + return OccultationPathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-8 && math.Abs(residual[1]) <= 1e-7 { + break + } + anglePlus, _, _, _, anglePlusOK := residualAt([2]float64{coordinates[0] + angleStep, coordinates[1]}) + angleMinus, _, _, _, angleMinusOK := residualAt([2]float64{coordinates[0] - angleStep, coordinates[1]}) + timePlus, _, _, _, timePlusOK := residualAt([2]float64{coordinates[0], coordinates[1] + timeStepMinute}) + timeMinus, _, _, _, timeMinusOK := residualAt([2]float64{coordinates[0], coordinates[1] - timeStepMinute}) + if !anglePlusOK || !angleMinusOK || !timePlusOK || !timeMinusOK { + return OccultationPathPoint{}, false + } + matrix := [2][2]float64{ + {(anglePlus[0] - angleMinus[0]) / (2 * angleStep), (timePlus[0] - timeMinus[0]) / (2 * timeStepMinute)}, + {(anglePlus[1] - angleMinus[1]) / (2 * angleStep), (timePlus[1] - timeMinus[1]) / (2 * timeStepMinute)}, + } + determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0] + if !finite(determinant) || math.Abs(determinant) < 1e-18 { + return OccultationPathPoint{}, false + } + delta := [2]float64{ + (-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant, + (-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant, + } + if math.Abs(delta[0]) > 0.25 { + delta[0] = math.Copysign(0.25, delta[0]) + } + if math.Abs(delta[1]) > 5 { + delta[1] = math.Copysign(5, delta[1]) + } + coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0]) + coordinates[1] += delta[1] + } + residual, tt, longitude, latitude, ok := residualAt(coordinates) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-7 { + return OccultationPathPoint{}, false + } + state := cache.context(tt).stateAt(longitude, latitude) + if !state.valid || math.Abs(state.moonAltitude) > 1e-7 { + return OccultationPathPoint{}, false + } + return OccultationPathPoint{ + Time: occultationTTToLocation(tt, location), Longitude: longitude, + Latitude: latitude, MoonAltitude: state.moonAltitude, + }, true +} + +func occultationRiseSetPhaseJunctionResidualAt(evaluation occultationRiseSetEvaluation, longitude, latitude float64) ([3]float64, bool) { + state := evaluation.center.stateAt(longitude, latitude) + residual := [3]float64{state.contactMetric, evaluation.contactDerivative(longitude, latitude), state.moonAltitude} + return residual, state.valid && finite(residual[1]) && finite(residual[2]) +} + +func occultationRiseSetDirectionJunctionResidualAt(evaluation occultationRiseSetEvaluation, longitude, latitude float64, greatest bool) ([3]float64, bool) { + phase, phaseOK := occultationRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) + state := evaluation.center.stateAt(longitude, latitude) + residual := [3]float64{phase, state.moonAltitude, evaluation.moonAltitudeDerivative(longitude, latitude)} + return residual, phaseOK && state.valid && finite(residual[1]) && finite(residual[2]) +} + +func occultationRiseSetPhaseResidual(evaluation occultationRiseSetEvaluation, longitude, latitude float64, greatest bool) (float64, bool) { + if greatest { + value := evaluation.separationDerivative(longitude, latitude) + return value, finite(value) + } + state := evaluation.center.stateAt(longitude, latitude) + return state.contactMetric, state.valid && finite(state.contactMetric) +} + +func occultationRiseSetPhaseJunctionJacobian( + coordinates [3]float64, + residual [3]float64, + cache *occultationRiseSetEvaluationCache, +) ([3][3]float64, bool) { + const geographicStep = 1e-4 + const timeStep = 1.0 / 86400.0 + evaluation := cache.evaluation(coordinates[2]) + plusLongitude, longitudeOK := occultationRiseSetPhaseJunctionResidualAt(evaluation, coordinates[0]+geographicStep, coordinates[1]) + plusLatitude, latitudeOK := occultationRiseSetPhaseJunctionResidualAt(evaluation, coordinates[0], coordinates[1]+geographicStep) + plusTimeEval := cache.evaluation(coordinates[2] + timeStep) + plusTime, timeOK := occultationRiseSetPhaseJunctionResidualAt(plusTimeEval, coordinates[0], coordinates[1]) + if !longitudeOK || !latitudeOK || !timeOK { + return [3][3]float64{}, false + } + var matrix [3][3]float64 + for row := range matrix { + matrix[row][0] = (plusLongitude[row] - residual[row]) / geographicStep + matrix[row][1] = (plusLatitude[row] - residual[row]) / geographicStep + matrix[row][2] = (plusTime[row] - residual[row]) / timeStep + } + return matrix, true +} + +func occultationRiseSetDirectionJunctionJacobian( + coordinates [3]float64, + residual [3]float64, + greatest bool, + cache *occultationRiseSetEvaluationCache, +) ([3][3]float64, bool) { + const geographicStep = 1e-4 + const timeStep = 1.0 / 86400.0 + evaluation := cache.evaluation(coordinates[2]) + plusLongitude, longitudeOK := occultationRiseSetDirectionJunctionResidualAt( + evaluation, coordinates[0]+geographicStep, coordinates[1], greatest, + ) + plusLatitude, latitudeOK := occultationRiseSetDirectionJunctionResidualAt( + evaluation, coordinates[0], coordinates[1]+geographicStep, greatest, + ) + plusTimeEval := cache.evaluation(coordinates[2] + timeStep) + plusTime, timeOK := occultationRiseSetDirectionJunctionResidualAt( + plusTimeEval, coordinates[0], coordinates[1], greatest, + ) + if !longitudeOK || !latitudeOK || !timeOK { + return [3][3]float64{}, false + } + var matrix [3][3]float64 + for row := range matrix { + matrix[row][0] = (plusLongitude[row] - residual[row]) / geographicStep + matrix[row][1] = (plusLatitude[row] - residual[row]) / geographicStep + matrix[row][2] = (plusTime[row] - residual[row]) / timeStep + } + return matrix, true +} + +func occultationRiseSetLimitJunctionDelta(delta *[3]float64) { + geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1])) + if geographicScale > 2 { + delta[0] *= 2 / geographicScale + delta[1] *= 2 / geographicScale + } + if math.Abs(delta[2]) > 5.0/1440 { + delta[2] = math.Copysign(5.0/1440, delta[2]) + } +} + +func occultationRiseSetSegmentEndpoint(segment []OccultationPathPoint, atStart bool) OccultationPathPoint { + if atStart { + return segment[0] + } + return segment[len(segment)-1] +} + +func occultationRiseSetAddEndpoint(segment []OccultationPathPoint, point OccultationPathPoint, atStart bool) []OccultationPathPoint { + current := occultationRiseSetSegmentEndpoint(segment, atStart) + if math.Abs(occultationTimeToTT(current.Time)-occultationTimeToTT(point.Time)) <= occultationRiseSetTimeEpsilonDays && occultationPathDistanceKM(current, point) <= 0.01 { + if atStart { + segment[0] = point + } else { + segment[len(segment)-1] = point + } + return segment + } + if atStart { + return append([]OccultationPathPoint{point}, segment...) + } + return append(segment, point) +} diff --git a/basic/occultation_rise_set_tracking_test.go b/basic/occultation_rise_set_tracking_test.go new file mode 100644 index 0000000..999728e --- /dev/null +++ b/basic/occultation_rise_set_tracking_test.go @@ -0,0 +1,25 @@ +package basic + +import ( + "testing" + "time" +) + +func TestOccultationRiseSetNewRootKeepsExistingBranch(t *testing.T) { + start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + for _, offset := range []float64{0, 160, -200} { + point := func(lon float64, at time.Time) OccultationPathPoint { + return OccultationPathPoint{Time: at, Longitude: normalizeLongitude(lon + offset), Latitude: 58} + } + tracks := []*occultationRiseSetTrack{{segments: [][]OccultationPathPoint{{point(20, start)}}}} + tracks = appendOccultationRiseSetSamples(tracks, []OccultationPathPoint{ + point(14, start.Add(time.Minute)), point(19, start.Add(time.Minute)), + }, 1.0/1440) + if len(tracks) != 2 || len(tracks[0].segments[0]) != 2 || len(tracks[1].segments[0]) != 1 { + t.Fatalf("offset=%g: new root did not get its own track", offset) + } + if got := tracks[0].segments[0][1].Longitude; got != normalizeLongitude(19+offset) { + t.Errorf("offset=%g: existing track jumped to longitude %g", offset, got) + } + } +} diff --git a/basic/occultation_rise_set_vector_test.go b/basic/occultation_rise_set_vector_test.go new file mode 100644 index 0000000..4c19eb5 --- /dev/null +++ b/basic/occultation_rise_set_vector_test.go @@ -0,0 +1,244 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +func TestOccultationRiseSetVectorStateMatchesLegacyTopocentricState(t *testing.T) { + planetConfig, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn occultation config is unavailable") + } + planetTT := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC)) + planetState := planetOccultationEphemerisStateAt(planetTT, planetConfig) + + star := StarCoordinate{ + ID: "parallax-test", RA: 247.3516666666667, Dec: -26.431944444444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24, + } + starTT := occultationTimeToTT(time.Date(2026, time.February, 11, 12, 0, 0, 0, time.UTC)) + starState := starOccultationEphemerisStateAt(starTT, star) + + contexts := []struct { + name string + tt float64 + moonRA, moonDec, moonDistanceKM float64 + targetRA, targetDec, targetDistanceKM float64 + targetRadiusKM float64 + }{ + { + name: "planet", tt: planetTT, + moonRA: planetState.moonRA, moonDec: planetState.moonDec, moonDistanceKM: planetState.moonDistanceKM, + targetRA: planetState.planetRA, targetDec: planetState.planetDec, targetDistanceKM: planetState.planetDistanceKM, + targetRadiusKM: planetConfig.equatorialRadiusKM, + }, + { + name: "finite-distance-star", tt: starTT, + moonRA: starState.moonRA, moonDec: starState.moonDec, moonDistanceKM: starState.moonDistanceKM, + targetRA: starState.starRA, targetDec: starState.starDec, targetDistanceKM: starState.starDistanceKM, + }, + { + name: "infinite-distance-star", tt: starTT, + moonRA: starState.moonRA, moonDec: starState.moonDec, moonDistanceKM: starState.moonDistanceKM, + targetRA: starState.starRA, targetDec: starState.starDec, + }, + } + locations := []Observer{ + {Longitude: 0, Latitude: 0}, + {Longitude: 115.4, Latitude: 32.9}, + {Longitude: -73.9857, Latitude: 40.7484}, + {Longitude: 179.9, Latitude: 80}, + {Longitude: -120, Latitude: -70}, + } + + for _, test := range contexts { + t.Run(test.name, func(t *testing.T) { + context := newOccultationRiseSetContext( + test.tt, test.moonRA, test.moonDec, test.moonDistanceKM, + test.targetRA, test.targetDec, test.targetDistanceKM, test.targetRadiusKM, + ) + for _, observer := range locations { + got := context.stateAt(observer.Longitude, observer.Latitude) + want := legacyOccultationRiseSetStateAt( + test.tt, test.moonRA, test.moonDec, test.moonDistanceKM, + test.targetRA, test.targetDec, test.targetDistanceKM, test.targetRadiusKM, + observer.Longitude, observer.Latitude, + ) + if got.valid != want.valid { + t.Fatalf("observer %.4f %.4f valid=%t, want %t", observer.Longitude, observer.Latitude, got.valid, want.valid) + } + if !got.valid { + continue + } + assertOccultationRiseSetStateClose(t, "contact metric", got.contactMetric, want.contactMetric, 2e-10) + assertOccultationRiseSetStateClose(t, "separation squared", got.separationSquared, want.separationSquared, 2e-13) + assertOccultationRiseSetStateClose(t, "moon altitude", got.moonAltitude, want.moonAltitude, 2e-10) + } + }) + } +} + +func TestOccultationRiseSetContextFromVectorsMatchesRADecContext(t *testing.T) { + config, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn occultation config is unavailable") + } + tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC)) + state := planetOccultationEphemerisStateAt(tt, config) + moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM) + target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM) + vectorContext := newOccultationRiseSetContextFromVectors( + tt, + [3]float64{moon.x, moon.y, moon.z}, + [3]float64{target.x, target.y, target.z}, + true, + config.equatorialRadiusKM, + ) + raDecContext := newOccultationRiseSetContext( + tt, state.moonRA, state.moonDec, state.moonDistanceKM, + state.planetRA, state.planetDec, state.planetDistanceKM, config.equatorialRadiusKM, + ) + for _, observer := range []Observer{ + {Longitude: 0, Latitude: 0}, + {Longitude: 115.4, Latitude: 32.9}, + {Longitude: -45, Latitude: 82}, + {Longitude: 170, Latitude: -70}, + } { + got := vectorContext.stateAt(observer.Longitude, observer.Latitude) + want := raDecContext.stateAt(observer.Longitude, observer.Latitude) + if got.valid != want.valid { + t.Fatalf("observer %.3f %.3f valid=%t, want %t", observer.Longitude, observer.Latitude, got.valid, want.valid) + } + if !got.valid { + continue + } + assertOccultationRiseSetStateClose(t, "contact metric", got.contactMetric, want.contactMetric, 1e-12) + assertOccultationRiseSetStateClose(t, "separation squared", got.separationSquared, want.separationSquared, 1e-15) + assertOccultationRiseSetStateClose(t, "moon altitude", got.moonAltitude, want.moonAltitude, 1e-12) + } +} + +func TestOccultationRiseSetMoonHorizonResidualMatchesVectorAltitude(t *testing.T) { + config, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn occultation config is unavailable") + } + tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC)) + state := planetOccultationEphemerisStateAt(tt, config) + context := newOccultationRiseSetContext( + tt, state.moonRA, state.moonDec, state.moonDistanceKM, + state.planetRA, state.planetDec, state.planetDistanceKM, config.equatorialRadiusKM, + ) + for _, observer := range []Observer{ + {Longitude: 0, Latitude: 0}, + {Longitude: 115.4, Latitude: 32.9}, + {Longitude: -45, Latitude: 82}, + {Longitude: 170, Latitude: -70}, + } { + got, valid := context.moonHorizonResidual(observer.Longitude, observer.Latitude) + observerParallax, _, zenith := occultationRiseSetObserverVectors( + context.siderealDegrees, observer.Longitude, observer.Latitude, + ) + topocentricMoon := occultationPathSub(context.moon.positionKM, observerParallax) + want := occultationPathDot(topocentricMoon, zenith) + if !valid || math.Abs(got-want) > 1e-8 { + t.Fatalf("observer %.3f %.3f horizon residual=%.15g valid=%t, want %.15g", + observer.Longitude, observer.Latitude, got, valid, want) + } + altitude := context.stateAt(observer.Longitude, observer.Latitude).moonAltitude * rad + if difference := math.Abs(got/occultationPathNorm(topocentricMoon) - math.Sin(altitude)); difference > 1e-14 { + t.Fatalf("observer %.3f %.3f normalized horizon residual differs by %.3g", + observer.Longitude, observer.Latitude, difference) + } + } +} + +func TestOccultationRiseSetEvaluationCacheSeparatesCandidateAndExactContexts(t *testing.T) { + var exactCalls, candidateCalls int + contextAt := func(tt float64) occultationRiseSetContext { + return newOccultationRiseSetContext(tt, 10, 5, 384000, 10.5, 5.25, 1e9, 0) + } + cache := newOccultationRiseSetEvaluationCacheWithCandidate( + func(tt float64) occultationRiseSetContext { + exactCalls++ + return contextAt(tt) + }, + func(tt float64) occultationRiseSetContext { + candidateCalls++ + return contextAt(tt) + }, + ) + tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC)) + cache.candidateEvaluation(tt) + cache.candidateEvaluation(tt) + if exactCalls != 0 || candidateCalls != 3 { + t.Fatalf("candidate evaluation calls exact=%d candidate=%d, want 0/3", exactCalls, candidateCalls) + } + cache.evaluation(tt) + cache.evaluation(tt) + if exactCalls != 3 || candidateCalls != 3 { + t.Fatalf("exact evaluation calls exact=%d candidate=%d, want 3/3", exactCalls, candidateCalls) + } +} + +func legacyOccultationRiseSetStateAt( + tt, moonRA, moonDec, moonDistanceKM, + targetRA, targetDec, targetDistanceKM, targetRadiusKM, + longitude, latitude float64, +) occultationRiseSetState { + siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15 + observer := Observer{Longitude: longitude, Latitude: latitude} + moonTopocentricRA, moonTopocentricDec := topocentricRaDecWithSidereal( + moonRA, moonDec, latitude, longitude, siderealDegrees, + moonDistanceKM/occultationPathAstronomicalUnitKM, 0, + ) + moonTopocentricRA = normalizeRA(moonTopocentricRA) + targetTopocentricRA, targetTopocentricDec := targetRA, targetDec + if targetDistanceKM > 0 { + targetTopocentricRA, targetTopocentricDec = topocentricRaDecWithSidereal( + targetRA, targetDec, latitude, longitude, siderealDegrees, + targetDistanceKM/occultationPathAstronomicalUnitKM, 0, + ) + targetTopocentricRA = normalizeRA(targetTopocentricRA) + } + moonTopocentricDistanceKM := topocentricDistanceKMWithSidereal( + moonRA, moonDec, moonDistanceKM, observer, siderealDegrees, + ) + if !finite(moonTopocentricDistanceKM) || moonTopocentricDistanceKM <= moonEquatorialRadiusKM { + return occultationRiseSetState{} + } + moonRadius := angularSemidiameterArcsec(moonEquatorialRadiusKM, moonTopocentricDistanceKM) / 3600 + targetRadius := 0.0 + if targetRadiusKM > 0 { + targetTopocentricDistanceKM := topocentricDistanceKMWithSidereal( + targetRA, targetDec, targetDistanceKM, observer, siderealDegrees, + ) + if !finite(targetTopocentricDistanceKM) || targetTopocentricDistanceKM <= targetRadiusKM { + return occultationRiseSetState{} + } + targetRadius = angularSemidiameterArcsec(targetRadiusKM, targetTopocentricDistanceKM) / 3600 + } + separation := angularSeparationDegrees( + moonTopocentricRA, moonTopocentricDec, targetTopocentricRA, targetTopocentricDec, + ) + separationRad := separation * rad + moonAltitude := occultationAltitudeWithSidereal( + siderealDegrees, observer, moonTopocentricRA, moonTopocentricDec, + ) + return occultationRiseSetState{ + contactMetric: separation - moonRadius - targetRadius, + separationSquared: 2 - 2*math.Cos(separationRad), + moonAltitude: moonAltitude, + valid: finite(separation) && finite(moonRadius) && finite(targetRadius) && finite(moonAltitude), + } +} + +func assertOccultationRiseSetStateClose(t *testing.T, name string, got, want, tolerance float64) { + t.Helper() + if difference := math.Abs(got - want); difference > tolerance { + t.Fatalf("%s=%.15g, want %.15g (difference %.3g, tolerance %.3g)", name, got, want, difference, tolerance) + } +} diff --git a/basic/occultation_selection_test.go b/basic/occultation_selection_test.go new file mode 100644 index 0000000..e19ce28 --- /dev/null +++ b/basic/occultation_selection_test.go @@ -0,0 +1,25 @@ +package basic + +import ( + "testing" + "time" +) + +func TestOccultationPathsSkipOutsideWindowBeforeSampling(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*3600) + options := OccultationPathOptions{ + Step: time.Second, TargetSpacingKM: 900, RiseSetStep: time.Minute, DisableFootprints: true, + } + start := time.Date(2025, 1, 6, 0, 0, 0, 0, zone) + planets, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationSaturn, options) + if err != nil || len(planets) != 0 { + t.Fatalf("outside-window planet paths=%d, err=%v", len(planets), err) + } + start = time.Date(2024, 3, 4, 0, 0, 0, 0, zone) + star := StarCoordinate{ID: "Antares", RA: 247.35166667, Dec: -26.43194444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000} + stars, err := FindStarOccultationPaths(start, start.Add(24*time.Hour), star, options) + if err != nil || len(stars) != 0 { + t.Fatalf("outside-window star paths=%d, err=%v", len(stars), err) + } +} diff --git a/basic/occultation_star.go b/basic/occultation_star.go index 95df268..1f9e909 100644 --- a/basic/occultation_star.go +++ b/basic/occultation_star.go @@ -240,40 +240,12 @@ func starOccultationLatitudeEnvelopePass(startTT, endTT float64, star StarCoordi return minimumLatitude <= limit && maximumLatitude >= -limit } -func starOccultationGeocentricLongitudeCandidate(startTT, endTT, step float64, star StarCoordinate) float64 { - bestTT := math.NaN() - bestDelta := math.Inf(1) - for tt := startTT; tt <= endTT; tt += step { - delta := math.Abs(signedAngleDifference(HMoonTrueLoN(tt, 8), starOccultationGeocentricStarLongitude(tt, star))) - if delta < bestDelta { - bestDelta = delta - bestTT = tt - } - } - if endTT > startTT { - delta := math.Abs(signedAngleDifference(HMoonTrueLoN(endTT, 8), starOccultationGeocentricStarLongitude(endTT, star))) - if delta < bestDelta { - bestTT = endTT - } - } - return bestTT -} - func starOccultationGeocentricStarLongitude(tt float64, star StarCoordinate) float64 { ra, dec := starApparentRaDecGeocentric(tt, star) longitude, _ := RaDecToLoBo(tt, ra, dec) return longitude } -func starOccultationMinimizeGeocentricSeparation(seed, startTT, endTT float64, star StarCoordinate) float64 { - halfWindow := 0.75 - left := math.Max(startTT, seed-halfWindow) - right := math.Min(endTT, seed+halfWindow) - return starOccultationMinimizeValue(left, right, func(tt float64) float64 { - return starOccultationGeocentricSeparationArcsec(tt, star) - }) -} - func starOccultationMinimizeValue(left, right float64, value func(float64) float64) float64 { if right <= left { return left @@ -321,7 +293,15 @@ func starOccultationBestObserver(seedTT, startTT, endTT float64, star StarCoordi frameAt := func(tt float64) (occultationPathFrame, bool) { return starOccultationPathFrameAt(tt, star) } - point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC) + // 同行星最佳事件搜索:非中心路径的最佳站心取离影轴最近的椭球点, + // 外接触切点位于掩带边缘,只作最后回退。 + // Same as the planetary best-event search: for a non-central path the + // best station is the ellipsoid point nearest to the shadow axis, while + // the outer contact tangent on the band edge stays the last resort. + point, pointOK = occultationPathTrackPointForFrame(greatestTT, frameAt, time.UTC) + if !pointOK { + point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC) + } } if !pointOK { return 0, Observer{}, 0, false @@ -531,25 +511,6 @@ func starAnnualParallaxEcliptic(tt, longitude, latitude, parallaxMas float64) (f return normalizeRA(longitude), latitude } -func starOccultationLongitudeCandidate(startTT, endTT, step float64, star StarCoordinate, observer Observer) float64 { - bestTT := math.NaN() - bestDelta := math.Inf(1) - for tt := startTT; tt <= endTT; tt += step { - delta := starOccultationLongitudeDistance(tt, star, observer) - if delta < bestDelta { - bestDelta = delta - bestTT = tt - } - } - if endTT > startTT { - delta := starOccultationLongitudeDistance(endTT, star, observer) - if delta < bestDelta { - bestTT = endTT - } - } - return bestTT -} - func starOccultationLongitudeDistance(tt float64, star StarCoordinate, observer Observer) float64 { moonLongitude := HMoonTrueLoN(tt, 8) starRA, starDec := starApparentRaDec(tt, star, observer) @@ -557,15 +518,6 @@ func starOccultationLongitudeDistance(tt float64, star StarCoordinate, observer return math.Abs(signedAngleDifference(moonLongitude, starLongitude)) } -func starOccultationMinimizeSeparation(seed, startTT, endTT float64, star StarCoordinate, observer Observer) float64 { - halfWindow := 0.75 - left := math.Max(startTT, seed-halfWindow) - right := math.Min(endTT, seed+halfWindow) - return starOccultationMinimizeValue(left, right, func(tt float64) float64 { - return starMoonSeparationArcsec(tt, star, observer) - }) -} - func starMoonSeparationArcsec(tt float64, star StarCoordinate, observer Observer) float64 { position := starMoonPositionAt(tt, star, observer) if !position.valid { @@ -675,7 +627,13 @@ func occultationPositionAngle(moonRA, moonDec, starRA, starDec float64) float64 } func occultationAltitude(tt float64, observer Observer, ra, dec float64) float64 { - hourAngle := signedAngleDifference(ApparentSiderealTime(TD2UT(tt, false))*15+observer.Longitude, ra) * math.Pi / 180 + return occultationAltitudeWithSidereal( + ApparentSiderealTime(TD2UT(tt, false))*15, observer, ra, dec, + ) +} + +func occultationAltitudeWithSidereal(siderealDegrees float64, observer Observer, ra, dec float64) float64 { + hourAngle := signedAngleDifference(siderealDegrees+observer.Longitude, ra) * math.Pi / 180 lat := observer.Latitude * math.Pi / 180 declination := dec * math.Pi / 180 sinAltitude := math.Sin(lat)*math.Sin(declination) + math.Cos(lat)*math.Cos(declination)*math.Cos(hourAngle) diff --git a/basic/occultation_star_internal_test.go b/basic/occultation_star_internal_test.go index 36a04ac..aa29cb1 100644 --- a/basic/occultation_star_internal_test.go +++ b/basic/occultation_star_internal_test.go @@ -161,6 +161,46 @@ func TestRefinedStarOccultationCenterLineRespectsWidthTolerance(t *testing.T) { } } +func TestPolarStarOccultationFootprintsContainCenterLine(t *testing.T) { + star := StarCoordinate{ + ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24, + } + start := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC) + paths, err := FindStarOccultationPaths( + start, start.Add(24*time.Hour), star, + OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + if len(paths[0].Footprints) == 0 { + t.Fatal("polar stellar path has no instantaneous footprints") + } + checked := 0 + for _, center := range paths[0].CenterLine { + if center.MoonAltitude <= 0 { + continue + } + contained := false + for _, footprint := range paths[0].Footprints { + if planetOccultationFootprintContains(footprint, center.Longitude, center.Latitude) { + contained = true + break + } + } + if !contained { + t.Fatalf("footprint sweep does not contain visible center line at %v, %.4f, %.4f", + center.Time, center.Longitude, center.Latitude) + } + checked++ + } + if checked == 0 { + t.Fatal("no visible center-line sample was checked") + } +} + func TestRefineOccultationPathWidthsBoundsSmoothInterpolationError(t *testing.T) { start := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) startTT := occultationTimeToTT(start) @@ -183,6 +223,57 @@ func TestRefineOccultationPathWidthsBoundsSmoothInterpolationError(t *testing.T) } } +func TestOccultationPathCachedEarthRotationMatchesDirectGeometry(t *testing.T) { + tt := occultationTimeToTT(time.Date(2025, time.June, 5, 12, 2, 6, 0, time.UTC)) + vector := occultationPathVector{x: 4123.5, y: -2789.25, z: 3950.75} + angle := ApparentSiderealTime(TD2UT(tt, false)) * 15 * math.Pi / 180 + want := occultationPathVector{ + x: math.Cos(angle)*vector.x + math.Sin(angle)*vector.y, + y: -math.Sin(angle)*vector.x + math.Cos(angle)*vector.y, + z: vector.z, + } + got := occultationPathEarthFixedVectorWithRotation(vector, occultationPathEarthRotationAt(tt)) + if difference := occultationPathNorm(occultationPathSub(got, want)); difference > 1e-12 { + t.Fatalf("cached Earth rotation differs by %.15g km", difference) + } + + _, geodeticLatitude := occultationPathGeodetic(tt, vector) + if difference := math.Abs(occultationPathGeodeticLatitude(vector) - geodeticLatitude); difference > 1e-12 { + t.Fatalf("cached geodetic latitude differs by %.15g degrees", difference) + } +} + +func TestOccultationPathCachedMoonAndSiderealMatchDirectPoint(t *testing.T) { + star := hr4799OccultationCoordinateForTest() + tt := occultationTimeToTT(time.Date(2025, time.June, 5, 12, 2, 6, 0, time.UTC)) + frame, ok := starOccultationPathFrameAt(tt, star) + if !ok { + t.Fatal("stellar occultation frame is unavailable") + } + vector, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis) + if !ok { + t.Fatal("stellar occultation center point is unavailable") + } + + direct := occultationPathPointFromVector(tt, vector, 1234.5, time.UTC) + cached := occultationPathPointFromVectorWithMoonSidereal( + tt, vector, 1234.5, frame.moon, ApparentSiderealTime(TD2UT(tt, false))*15, time.UTC, + ) + if !cached.Time.Equal(direct.Time) { + t.Fatalf("cached point time = %v, want %v", cached.Time, direct.Time) + } + for name, difference := range map[string]float64{ + "longitude": math.Abs(cached.Longitude - direct.Longitude), + "latitude": math.Abs(cached.Latitude - direct.Latitude), + "moon altitude": math.Abs(cached.MoonAltitude - direct.MoonAltitude), + "width": math.Abs(cached.WidthKM - direct.WidthKM), + } { + if difference > 1e-10 { + t.Fatalf("cached point %s differs by %.15g degrees or km", name, difference) + } + } +} + func hr4799OccultationCoordinateForTest() StarCoordinate { return StarCoordinate{ ID: "HR 4799", diff --git a/basic/occultation_station_correction.go b/basic/occultation_station_correction.go new file mode 100644 index 0000000..821e084 --- /dev/null +++ b/basic/occultation_station_correction.go @@ -0,0 +1,1635 @@ +package basic + +import ( + "math" + "time" +) + +// occultationStationBoundarySample is the result of correcting one geocentric +// contact seed with the station-centred contact equation. Residuals follow +// occultationRiseSetContext.stateAt, which returns degrees for both the contact +// gap and the lunar altitude; the two residual fields are therefore degrees, +// not arcseconds. +type occultationStationBoundarySample struct { + point OccultationPathPoint + contactResidualDeg float64 + horizonResidualDeg float64 + offsetKM float64 + seedResidualDeg float64 + valid bool +} + +const ( + occultationStationOracleInitialStepKM = 25.0 + occultationStationOracleMaximumOffsetKM = 2000.0 + occultationStationOracleRootToleranceKM = 0.001 + // 站心接触残差与 stateAt 同单位(度);1e-7 度 = 3.6e-4 角秒。 + // The station contact residual uses the stateAt unit (degrees); 1e-7 deg = 3.6e-4 arcsec. + occultationStationOracleResidualToleranceDeg = 1e-7 + // 地面偏移每公里最多改变约 1/384400 弧度的月球视差方向,取 3e-4 度/公里作为 + // 接触残差的斜率上限,用来复核“括号已塌缩但残差没到容差”的解。 + occultationStationOracleResidualSlopeDegPerKM = 3e-4 + occultationStationEnvelopeMaximumOffsetKM = 2500.0 + occultationStationHorizonMaximumOffsetKM = 250.0 + occultationStationHorizonResidualToleranceDeg = 1e-7 + // 地平线接触求解的最终验收带;带内的负残差是数值噪声。 + occultationStationHorizonAcceptanceDeg = 1e-5 +) + +// occultationStationCorrectBoundaryPoint refines a geocentric contact point +// along the local ground cross-track direction. The contact equation is +// evaluated by the existing station-centred vector context, so moon parallax, +// target parallax, apparent radii and the horizon all share one observer model. +// The function deliberately does not choose a polygon or join branches. +func occultationStationCorrectBoundaryPoint( + tt float64, + seed OccultationPathPoint, + frameAt occultationPathFrameFunc, + contextAt occultationRiseSetContextFunc, + total bool, + location *time.Location, +) (occultationStationBoundarySample, bool) { + frame, ok := frameAt(tt) + if !ok { + return occultationStationBoundarySample{}, false + } + if contextAt == nil { + return occultationStationBoundarySample{}, false + } + contextFactory := contextAt + if total { + contextFactory = func(value float64) occultationRiseSetContext { + return contextAt(value).withInternalContact() + } + } + context := contextFactory(tt) + if !context.valid { + return occultationStationBoundarySample{}, false + } + seedFixed := occultationStationSurfaceVector(seed.Longitude, seed.Latitude) + if occultationPathNorm(seedFixed) <= 0 { + return occultationStationBoundarySample{}, false + } + cross, directionOK := occultationStationCrossTrackAt(tt, frame, frameAt, seedFixed) + if !directionOK { + return occultationStationBoundarySample{}, false + } + + evaluate := func(offsetKM float64) (float64, float64, float64, bool) { + fixed := occultationStationOffsetSurfaceVector(seedFixed, cross, offsetKM) + longitude, latitude := occultationStationGeodetic(fixed) + state := context.stateAt(longitude, latitude) + if !state.valid || !finite(state.contactMetric) || !finite(state.moonAltitude) { + return 0, 0, 0, false + } + return state.contactMetric, state.moonAltitude, longitude, true + } + + seedResidual, seedAltitude, _, seedOK := evaluate(0) + if !seedOK { + return occultationStationBoundarySample{}, false + } + if math.Abs(seedResidual) <= occultationStationOracleResidualToleranceDeg { + return occultationStationOracleSampleAt( + tt, seedFixed, 0, seedResidual, seedResidual, seedAltitude, frame, location, + ), true + } + + leftOffset, rightOffset, bracketOK := occultationStationFindBracket(seedResidual, evaluate) + if !bracketOK { + return occultationStationBoundarySample{}, false + } + leftResidual, _, _, leftOK := evaluate(leftOffset) + rightResidual, _, _, rightOK := evaluate(rightOffset) + if !leftOK || !rightOK || leftResidual*rightResidual > 0 { + return occultationStationBoundarySample{}, false + } + for iteration := 0; iteration < 64; iteration++ { + middleOffset := (leftOffset + rightOffset) / 2 + middleResidual, _, _, middleOK := evaluate(middleOffset) + if !middleOK { + return occultationStationBoundarySample{}, false + } + if math.Abs(middleResidual) <= occultationStationOracleResidualToleranceDeg || + math.Abs(rightOffset-leftOffset) <= occultationStationOracleRootToleranceKM { + leftOffset, rightOffset = middleOffset, middleOffset + break + } + if leftResidual*middleResidual <= 0 { + rightOffset, rightResidual = middleOffset, middleResidual + } else { + leftOffset, leftResidual = middleOffset, middleResidual + } + } + offset := (leftOffset + rightOffset) / 2 + residual, altitude, _, solved := evaluate(offset) + // 括号塌缩不等于解存在:偏离接触方程超过该宽度可解释范围的“收敛”点只是无效 + // 区间里的一个位置,不能当成已求解。 + if !solved || !finite(residual) || + math.Abs(residual) > occultationStationOracleResidualBound(rightOffset-leftOffset) { + return occultationStationBoundarySample{}, false + } + pointFixed := occultationStationOffsetSurfaceVector(seedFixed, cross, offset) + return occultationStationOracleSampleAt( + tt, pointFixed, offset, seedResidual, residual, altitude, frame, location, + ), true +} + +// occultationStationCorrectContours maps a geocentric contact-envelope seed to +// the station-centred temporal envelope without changing its sampling order. +// A static time union is bounded by contact=0 and d(contact)/dt=0; correcting +// contact alone moves a seed onto an instantaneous footprint but does not keep +// it on the outer envelope. +func occultationStationCorrectContours( + contours [][]OccultationPathPoint, + curves []OccultationRiseSetCurve, + cache *occultationRiseSetEvaluationCache, + location *time.Location, +) [][]OccultationPathPoint { + if len(contours) == 0 || cache == nil { + return contours + } + contextAt := cache.context + corrected := make([][]OccultationPathPoint, 0, len(contours)) + for _, contour := range contours { + if len(contour) == 0 { + continue + } + samples, solved := occultationStationEnvelopeSamples(contour, contextAt, false, location) + for start := 0; start < len(contour); { + for start < len(contour) && !solved[start] { + start++ + } + if start == len(contour) { + break + } + end := start + for end < len(contour) && solved[end] { + end++ + } + if end-start >= 2 { + segment := make([]OccultationPathPoint, end-start) + for index := start; index < end; index++ { + point := samples[index].point + point.Time = contour[index].Time + point.WidthKM = contour[index].WidthKM + segment[index-start] = point + } + for _, sampleRange := range occultationContinuousBoundaryRanges(segment) { + if sampleRange.end-sampleRange.start >= 2 { + corrected = append(corrected, append( + []OccultationPathPoint(nil), segment[sampleRange.start:sampleRange.end]..., + )) + } + } + } + start = end + } + } + if len(curves) == 0 { + return occultationStationDensifyContours(corrected, cache, location) + } + visible := occultationStationVisibleEnvelopeContours(corrected, curves, cache, location) + // Local corrected fragments do not establish a complete visible envelope. + return occultationStationDensifyContours(visible, cache, location) +} + +// occultationStationCorrectLimitSeries keeps the public north/south limit +// samples on the station contact curve. Static temporal envelopes are exposed +// separately through BandContours. +func occultationStationCorrectLimitSeries( + points []OccultationPathPoint, + frameAt occultationPathFrameFunc, + contextAt occultationRiseSetContextFunc, + total bool, + location *time.Location, +) []OccultationPathPoint { + if len(points) == 0 || frameAt == nil || contextAt == nil { + return points + } + corrected := append([]OccultationPathPoint(nil), points...) + for index, seed := range points { + sample, ok := occultationStationCorrectBoundaryPoint( + centerTimeTT(seed.Time), seed, frameAt, contextAt, total, location, + ) + if !ok || !sample.valid { + continue + } + point := sample.point + // Public limit tracks retain their event sample times exactly; the + // station solver may otherwise round-trip through civil time. + point.Time = seed.Time + point.WidthKM = seed.WidthKM + corrected[index] = point + } + return corrected +} + +func occultationStationEnvelopeSamples( + points []OccultationPathPoint, + contextAt occultationRiseSetContextFunc, + total bool, + location *time.Location, +) ([]occultationStationBoundarySample, []bool) { + samples := make([]occultationStationBoundarySample, len(points)) + solved := make([]bool, len(points)) + try := func(index int, seed OccultationPathPoint) bool { + sample, ok := occultationStationCorrectEnvelopePoint( + centerTimeTT(points[index].Time), seed, contextAt, total, location, + ) + if !ok || !sample.valid || occultationPathDistanceKM(points[index], sample.point) > + occultationStationEnvelopeMaximumOffsetKM { + return false + } + sample.offsetKM = occultationPathDistanceKM(points[index], sample.point) + samples[index], solved[index] = sample, true + return true + } + for index, seed := range points { + // 延拓初值:轮廓上相邻点的解彼此接近,用上一个已收敛解做 Newton 初值通常能把 + // 迭代次数从十余次降到几次;若它落在原始点的容差之外或求解失败,再退回几何底点。 + // Continuation seed: neighbouring contour points solve to nearby stations, so the + // previous converged solution is a much better Newton seed than the geometric point. + // When it fails or lands outside the original point's tolerance, fall back to the + // geometric seed. + if index > 0 && solved[index-1] { + neighbor := seed + neighbor.Longitude = samples[index-1].point.Longitude + neighbor.Latitude = samples[index-1].point.Latitude + if try(index, neighbor) { + continue + } + } + try(index, seed) + } + for pass := 0; pass < 2; pass++ { + for index := 1; index < len(points); index++ { + if solved[index] || !solved[index-1] { + continue + } + seed := points[index] + seed.Longitude = samples[index-1].point.Longitude + seed.Latitude = samples[index-1].point.Latitude + try(index, seed) + } + for index := len(points) - 2; index >= 0; index-- { + if solved[index] || !solved[index+1] { + continue + } + seed := points[index] + seed.Longitude = samples[index+1].point.Longitude + seed.Latitude = samples[index+1].point.Latitude + try(index, seed) + } + } + return samples, solved +} + +// occultationStationCorrectContactPoint refines an arbitrary contact-arc seed +// with a local two-dimensional Newton step. The one-dimensional cross-track +// oracle is preferable for time-contour sides; this variant is for the two +// instantaneous contact arcs that close a direct static ring. +func occultationStationCorrectContactPoint( + tt float64, + seed OccultationPathPoint, + contextAt occultationRiseSetContextFunc, + total bool, + location *time.Location, +) (occultationStationBoundarySample, bool) { + if contextAt == nil { + return occultationStationBoundarySample{}, false + } + context := contextAt(tt) + if total { + context = context.withInternalContact() + } + if !context.valid { + return occultationStationBoundarySample{}, false + } + longitude, latitude := seed.Longitude, seed.Latitude + seedResidual := math.NaN() + for iteration := 0; iteration < 16; iteration++ { + state := context.stateAt(longitude, latitude) + if !state.valid || !finite(state.contactMetric) { + return occultationStationBoundarySample{}, false + } + if iteration == 0 { + seedResidual = state.contactMetric + } + if math.Abs(state.contactMetric) <= occultationStationOracleResidualToleranceDeg { + return occultationStationSampleFromCoordinates( + tt, seed, longitude, latitude, state.contactMetric, state.moonAltitude, seedResidual, location, + ), true + } + const coordinateStepDeg = 0.005 + plusLongitude := context.stateAt(normalizeLongitude(longitude+coordinateStepDeg), latitude) + minusLongitude := context.stateAt(normalizeLongitude(longitude-coordinateStepDeg), latitude) + plusLatitude := context.stateAt(longitude, math.Min(89.999999, latitude+coordinateStepDeg)) + minusLatitude := context.stateAt(longitude, math.Max(-89.999999, latitude-coordinateStepDeg)) + if !plusLongitude.valid || !minusLongitude.valid || !plusLatitude.valid || !minusLatitude.valid { + return occultationStationBoundarySample{}, false + } + cosLatitude := math.Max(0.05, math.Cos(latitude*rad)) + gradientX := (plusLongitude.contactMetric - minusLongitude.contactMetric) / (2 * coordinateStepDeg * cosLatitude) + gradientY := (plusLatitude.contactMetric - minusLatitude.contactMetric) / (2 * coordinateStepDeg) + gradientSquared := gradientX*gradientX + gradientY*gradientY + if !finite(gradientSquared) || gradientSquared <= 1e-18 { + return occultationStationBoundarySample{}, false + } + deltaX := -state.contactMetric * gradientX / gradientSquared + deltaY := -state.contactMetric * gradientY / gradientSquared + length := math.Hypot(deltaX, deltaY) + if length > 1.0 { + scale := 1.0 / length + deltaX *= scale + deltaY *= scale + } + longitude = normalizeLongitude(longitude + deltaX/cosLatitude) + latitude = math.Max(-89.999999, math.Min(89.999999, latitude+deltaY)) + } + state := context.stateAt(longitude, latitude) + if !state.valid || math.Abs(state.contactMetric) > 1e-5 { + return occultationStationBoundarySample{}, false + } + return occultationStationSampleFromCoordinates( + tt, seed, longitude, latitude, state.contactMetric, state.moonAltitude, seedResidual, location, + ), true +} + +type occultationStationEnvelopeState struct { + contactMetric float64 + contactDerivative float64 + moonAltitude float64 + valid bool +} + +// occultationStationCorrectEnvelopePoint solves the two necessary conditions +// for an interior boundary of the station-visible time union at a fixed time: +// the station contact gap is zero and stationary in time. The geocentric +// north/south limit is only a seed; both equations are evaluated with the same +// topocentric Moon/target vectors used by local occultation calculations. +func occultationStationCorrectEnvelopePoint( + tt float64, + seed OccultationPathPoint, + contextAt occultationRiseSetContextFunc, + total bool, + location *time.Location, +) (occultationStationBoundarySample, bool) { + if contextAt == nil { + return occultationStationBoundarySample{}, false + } + contextFactory := contextAt + if total { + contextFactory = func(value float64) occultationRiseSetContext { + return contextAt(value).withInternalContact() + } + } + evaluation := occultationRiseSetEvaluation{ + tt: tt, + center: contextFactory(tt), + before: contextFactory(tt - occultationRiseSetDerivativeStepDays), + after: contextFactory(tt + occultationRiseSetDerivativeStepDays), + } + if !evaluation.center.valid || !evaluation.before.valid || !evaluation.after.valid { + return occultationStationBoundarySample{}, false + } + evaluate := func(longitude, latitude float64) occultationStationEnvelopeState { + center := evaluation.center.stateAt(longitude, latitude) + before := evaluation.before.stateAt(longitude, latitude) + after := evaluation.after.stateAt(longitude, latitude) + if !center.valid || !before.valid || !after.valid { + return occultationStationEnvelopeState{} + } + derivative := (after.contactMetric - before.contactMetric) / + (2 * occultationRiseSetDerivativeStepDays) + return occultationStationEnvelopeState{ + contactMetric: center.contactMetric, + contactDerivative: derivative, + moonAltitude: center.moonAltitude, + valid: finite(center.contactMetric) && finite(derivative) && finite(center.moonAltitude), + } + } + residualNorm := func(state occultationStationEnvelopeState) float64 { + return math.Hypot( + state.contactMetric, + state.contactDerivative*occultationRiseSetDerivativeStepDays, + ) + } + + longitude, latitude := seed.Longitude, seed.Latitude + seedState := evaluate(longitude, latitude) + if !seedState.valid { + return occultationStationBoundarySample{}, false + } + for iteration := 0; iteration < 24; iteration++ { + state := evaluate(longitude, latitude) + if !state.valid { + return occultationStationBoundarySample{}, false + } + if math.Abs(state.contactMetric) <= occultationStationOracleResidualToleranceDeg && + math.Abs(state.contactDerivative) <= occultationRiseSetJunctionDerivativeTolerance { + return occultationStationSampleFromCoordinates( + tt, seed, longitude, latitude, state.contactMetric, state.moonAltitude, + seedState.contactMetric, location, + ), true + } + + const coordinateStepDeg = 0.002 + cosLatitude := math.Max(0.05, math.Cos(latitude*rad)) + plusLongitude := evaluate(normalizeLongitude(longitude+coordinateStepDeg/cosLatitude), latitude) + minusLongitude := evaluate(normalizeLongitude(longitude-coordinateStepDeg/cosLatitude), latitude) + plusLatitude := evaluate(longitude, math.Min(89.999999, latitude+coordinateStepDeg)) + minusLatitude := evaluate(longitude, math.Max(-89.999999, latitude-coordinateStepDeg)) + if !plusLongitude.valid || !minusLongitude.valid || !plusLatitude.valid || !minusLatitude.valid { + return occultationStationBoundarySample{}, false + } + contactX := (plusLongitude.contactMetric - minusLongitude.contactMetric) / (2 * coordinateStepDeg) + contactY := (plusLatitude.contactMetric - minusLatitude.contactMetric) / (2 * coordinateStepDeg) + derivativeX := (plusLongitude.contactDerivative - minusLongitude.contactDerivative) / (2 * coordinateStepDeg) + derivativeY := (plusLatitude.contactDerivative - minusLatitude.contactDerivative) / (2 * coordinateStepDeg) + determinant := contactX*derivativeY - contactY*derivativeX + if !finite(determinant) || math.Abs(determinant) <= 1e-12 { + return occultationStationBoundarySample{}, false + } + deltaX := (-state.contactMetric*derivativeY + contactY*state.contactDerivative) / determinant + deltaY := (-contactX*state.contactDerivative + derivativeX*state.contactMetric) / determinant + if !finite(deltaX) || !finite(deltaY) { + return occultationStationBoundarySample{}, false + } + if length := math.Hypot(deltaX, deltaY); length > 6 { + scale := 6 / length + deltaX *= scale + deltaY *= scale + } + + currentNorm := residualNorm(state) + accepted := false + for damping := 1.0; damping >= 1.0/128; damping /= 2 { + candidateLongitude := normalizeLongitude(longitude + damping*deltaX/cosLatitude) + candidateLatitude := math.Max(-89.999999, math.Min(89.999999, latitude+damping*deltaY)) + if occultationPathDistanceKMValues( + seed.Longitude, seed.Latitude, candidateLongitude, candidateLatitude, + ) > occultationStationEnvelopeMaximumOffsetKM { + continue + } + candidate := evaluate(candidateLongitude, candidateLatitude) + if !candidate.valid || residualNorm(candidate) >= currentNorm { + continue + } + longitude, latitude = candidateLongitude, candidateLatitude + accepted = true + break + } + if !accepted { + return occultationStationBoundarySample{}, false + } + } + state := evaluate(longitude, latitude) + if !state.valid || math.Abs(state.contactMetric) > 1e-5 || + math.Abs(state.contactDerivative) > 1e-4 { + return occultationStationBoundarySample{}, false + } + return occultationStationSampleFromCoordinates( + tt, seed, longitude, latitude, state.contactMetric, state.moonAltitude, + seedState.contactMetric, location, + ), true +} + +const ( + occultationStationEnvelopeTimeScale = 360.0 + occultationStationEnvelopeArcStepDegrees = 0.2 + occultationStationEnvelopeMinArcStepDegrees = 0.001 + occultationStationEnvelopeTargetSpacingKM = 30.0 + occultationStationEnvelopeMaxArcSteps = 8192 +) + +type occultationStationEnvelopeArcState struct { + coordinates [3]float64 + tangent [3]float64 + point OccultationPathPoint +} + +type occultationStationEnvelopeTrace struct { + points []OccultationPathPoint + boundary bool + closed bool +} + +type occultationStationEnvelopeKind uint8 + +const ( + occultationStationContactEnvelope occultationStationEnvelopeKind = iota + occultationStationVisibilityEnvelope +) + +type occultationStationEnvelopeModel struct { + kind occultationStationEnvelopeKind +} + +// occultationStationVisibleEnvelopeContours traces the station-centred static +// contact envelope as an implicit curve in longitude, latitude and time. The +// pseudo-arclength parameter remains regular where fixed-time north/south roots +// meet, so a real temporal fold is preserved instead of becoming a branch jump. +func occultationStationVisibleEnvelopeContours( + seeds [][]OccultationPathPoint, + curves []OccultationRiseSetCurve, + cache *occultationRiseSetEvaluationCache, + location *time.Location, +) [][]OccultationPathPoint { + model := occultationStationEnvelopeModel{kind: occultationStationContactEnvelope} + minimumTT, maximumTT := math.Inf(1), math.Inf(-1) + candidates := make([]OccultationPathPoint, 0, len(seeds)) + for _, segment := range seeds { + if len(segment) < 2 { + continue + } + best := OccultationPathPoint{MoonAltitude: math.Inf(-1)} + for _, point := range segment { + tt := centerTimeTT(point.Time) + minimumTT = math.Min(minimumTT, tt) + maximumTT = math.Max(maximumTT, tt) + if point.MoonAltitude > best.MoonAltitude { + best = point + } + } + if best.MoonAltitude > occultationStationHorizonResidualToleranceDeg { + candidates = append(candidates, best) + } + } + // A geocentric north/south seed can locate only one component after station + // parallax changes the topology. Every start/end phase junction is an exact + // endpoint of a visible contact-envelope component, so use those junctions + // to discover any remaining component without inventing a connector. + for _, curve := range curves { + if curve.Phase != RiseSetPhaseStart && curve.Phase != RiseSetPhaseEnd { + continue + } + for _, segment := range curve.Segments { + for _, point := range segment { + tt := centerTimeTT(point.Time) + minimumTT = math.Min(minimumTT, tt) + maximumTT = math.Max(maximumTT, tt) + } + if len(segment) == 0 { + continue + } + for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + junction, ok := refineOccultationRiseSetPhaseJunction(endpoint, location, cache) + if ok && !occultationStationEnvelopePointCovered( + [][]OccultationPathPoint{candidates}, junction, 1, + ) { + candidates = append(candidates, junction) + } + } + } + } + if len(candidates) == 0 || !finite(minimumTT) || !finite(maximumTT) { + return nil + } + // The geocentric seeds only locate the component. Station parallax can move a + // temporal fold just beyond their fixed-time range, so leave a bounded margin. + minimumTT -= 0.05 + maximumTT += 0.05 + result := make([][]OccultationPathPoint, 0, len(candidates)) + for _, candidate := range candidates { + if occultationStationEnvelopePointCovered(result, candidate, occultationStationEnvelopeTargetSpacingKM) { + continue + } + referenceTT := centerTimeTT(candidate.Time) + if candidate.MoonAltitude <= occultationStationHorizonResidualToleranceDeg { + for _, direction := range []int{-1, 1} { + trace := occultationStationTraceEnvelope( + candidate, direction, referenceTT, minimumTT, maximumTT, cache, model, location, + ) + if !trace.boundary || len(trace.points) < 3 || + occultationPathDistanceKM(trace.points[0], trace.points[len(trace.points)-1]) <= 1 { + continue + } + trace.points = occultationStationDeduplicateEnvelopePoints(trace.points) + result = append(result, occultationStationSplitEnvelopeAtTimeFolds(trace.points)...) + break + } + continue + } + backward := occultationStationTraceEnvelope( + candidate, -1, referenceTT, minimumTT, maximumTT, cache, model, location, + ) + if backward.closed { + closed := append([]OccultationPathPoint(nil), backward.points...) + if len(closed) >= 4 { + closed[len(closed)-1] = closed[0] + result = append(result, occultationStationSplitEnvelopeAtTimeFolds(closed)...) + } + continue + } + forward := occultationStationTraceEnvelope( + candidate, 1, referenceTT, minimumTT, maximumTT, cache, model, location, + ) + if !backward.boundary || !forward.boundary { + continue + } + segment := make([]OccultationPathPoint, 0, len(backward.points)+len(forward.points)-1) + for index := len(backward.points) - 1; index >= 0; index-- { + segment = append(segment, backward.points[index]) + } + segment = append(segment, forward.points[1:]...) + segment = occultationStationDeduplicateEnvelopePoints(segment) + if len(segment) >= 3 { + result = append(result, occultationStationSplitEnvelopeAtTimeFolds(segment)...) + } + } + return result +} + +// occultationStationVisibilityEnvelopeContours traces the boundary of the +// time-union of Moon-above-horizon states. It is the implicit curve H=0, +// dH/dt=0 restricted to sites where the requested contact metric is negative +// and H has a temporal maximum. Endpoints are the exact [F,H,dH/dt]=0 +// direction junctions shared by start/end moonrise and moonset phase curves. +func occultationStationVisibilityEnvelopeContours( + curves []OccultationRiseSetCurve, + cache *occultationRiseSetEvaluationCache, + location *time.Location, +) [][]OccultationPathPoint { + if len(curves) == 0 || cache == nil { + return nil + } + minimumTT, maximumTT := math.Inf(1), math.Inf(-1) + seeds := make([]OccultationPathPoint, 0, 8) + for _, curve := range curves { + if curve.Phase != RiseSetPhaseStart && curve.Phase != RiseSetPhaseEnd { + continue + } + for _, segment := range curve.Segments { + for _, point := range segment { + tt := centerTimeTT(point.Time) + minimumTT = math.Min(minimumTT, tt) + maximumTT = math.Max(maximumTT, tt) + } + if len(segment) == 0 { + continue + } + for _, candidate := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + junction, ok := refineOccultationRiseSetDirectionJunction( + centerTimeTT(candidate.Time), candidate.Longitude, candidate.Latitude, + false, location, cache, + ) + if !ok { + junction, ok = refineOccultationRiseSetDirectionJunctionOnHorizon(candidate, candidate, false, location, cache) + } + if ok && !occultationStationEnvelopePointCovered( + [][]OccultationPathPoint{seeds}, junction, 1, + ) { + seeds = append(seeds, junction) + } + } + } + } + if len(seeds) == 0 || !finite(minimumTT) || !finite(maximumTT) { + return nil + } + minimumTT -= 0.05 + maximumTT += 0.05 + model := occultationStationEnvelopeModel{kind: occultationStationVisibilityEnvelope} + result := make([][]OccultationPathPoint, 0, len(seeds)/2+1) + for _, seed := range seeds { + if !model.isRequiredExtremum(cache.evaluation(centerTimeTT(seed.Time)), seed.Longitude, seed.Latitude) { + continue + } + if occultationStationEnvelopePointCovered(result, seed, occultationStationEnvelopeTargetSpacingKM) { + continue + } + referenceTT := centerTimeTT(seed.Time) + for _, direction := range []int{-1, 1} { + trace := occultationStationTraceEnvelope( + seed, direction, referenceTT, minimumTT, maximumTT, cache, model, location, + ) + if trace.closed && len(trace.points) >= 4 { + closed := append([]OccultationPathPoint(nil), trace.points...) + closed[len(closed)-1] = closed[0] + result = append(result, occultationStationSplitEnvelopeAtTimeFolds(closed)...) + break + } + if !trace.boundary || len(trace.points) < 3 || + occultationPathDistanceKM(trace.points[0], trace.points[len(trace.points)-1]) <= 1 { + continue + } + trace.points = occultationStationDeduplicateEnvelopePoints(trace.points) + result = append(result, occultationStationSplitEnvelopeAtTimeFolds(trace.points)...) + break + } + } + return result +} + +func occultationStationTraceEnvelope( + seed OccultationPathPoint, + direction int, + referenceTT, minimumTT, maximumTT float64, + cache *occultationRiseSetEvaluationCache, + model occultationStationEnvelopeModel, + location *time.Location, +) occultationStationEnvelopeTrace { + state, ok := occultationStationEnvelopeArcStateAt(seed, referenceTT, cache, model) + if !ok { + return occultationStationEnvelopeTrace{points: []OccultationPathPoint{seed}} + } + for index := range state.tangent { + state.tangent[index] *= float64(direction) + } + result := occultationStationEnvelopeTrace{points: []OccultationPathPoint{state.point}} + step := occultationStationEnvelopeArcStepDegrees + travelKM := 0.0 + for count := 0; count < occultationStationEnvelopeMaxArcSteps; count++ { + predictor := state.coordinates + for index := range predictor { + predictor[index] += step * state.tangent[index] + } + next, iterations, solved := occultationStationCorrectEnvelopeArc( + predictor, state.tangent, referenceTT, cache, model, state.point.WidthKM, location, + ) + if !solved { + step /= 2 + if step < occultationStationEnvelopeMinArcStepDegrees { + return result + } + continue + } + if dotSolarEclipse3(next.tangent, state.tangent) < 0 { + for index := range next.tangent { + next.tangent[index] = -next.tangent[index] + } + } + distanceKM := occultationPathDistanceKM(state.point, next.point) + if !finite(distanceKM) || distanceKM > occultationStationEnvelopeTargetSpacingKM { + step /= 2 + if step < occultationStationEnvelopeMinArcStepDegrees { + return result + } + continue + } + nextTT := centerTimeTT(next.point.Time) + if nextTT < minimumTT || nextTT > maximumTT { + return result + } + if model.activeMargin(cache.evaluation(nextTT), next.point.Longitude, next.point.Latitude) < 0 { + junction, junctionOK := occultationStationEnvelopeActivityJunction( + state.point, next.point, location, cache, model, + ) + if !junctionOK { + step /= 2 + if step < occultationStationEnvelopeMinArcStepDegrees { + return result + } + continue + } + result.points = append(result.points, junction) + result.boundary = true + return result + } + travelKM += distanceKM + if travelKM > 4*occultationStationEnvelopeTargetSpacingKM && + occultationPathDistanceKM(seed, next.point) <= occultationStationEnvelopeTargetSpacingKM/2 { + result.points = append(result.points, seed) + result.closed = true + return result + } + result.points = append(result.points, next.point) + state = next + if model.activeMargin(cache.evaluation(nextTT), next.point.Longitude, next.point.Latitude) <= + occultationStationHorizonResidualToleranceDeg { + result.boundary = true + return result + } + if distanceKM < occultationStationEnvelopeTargetSpacingKM/2 && iterations <= 4 { + step = math.Min(occultationStationEnvelopeArcStepDegrees, step*1.5) + } + } + return result +} + +func occultationStationEnvelopeArcStateAt( + point OccultationPathPoint, + referenceTT float64, + cache *occultationRiseSetEvaluationCache, + model occultationStationEnvelopeModel, +) (occultationStationEnvelopeArcState, bool) { + coordinates := [3]float64{ + point.Longitude, + point.Latitude, + (centerTimeTT(point.Time) - referenceTT) * occultationStationEnvelopeTimeScale, + } + _, jacobian, ok := occultationStationEnvelopeJacobian(coordinates, referenceTT, cache, model) + if !ok { + return occultationStationEnvelopeArcState{}, false + } + tangent, ok := occultationStationEnvelopeTangent(jacobian) + if !ok { + return occultationStationEnvelopeArcState{}, false + } + return occultationStationEnvelopeArcState{coordinates: coordinates, tangent: tangent, point: point}, true +} + +func occultationStationCorrectEnvelopeArc( + predictor, tangent [3]float64, + referenceTT float64, + cache *occultationRiseSetEvaluationCache, + model occultationStationEnvelopeModel, + widthKM float64, + location *time.Location, +) (occultationStationEnvelopeArcState, int, bool) { + coordinates := predictor + iterationCache := cache.candidateOnly() + jacobianCache := iterationCache + for iteration := 0; iteration < 20; iteration++ { + residual, jacobian, ok := occultationStationEnvelopeJacobian(coordinates, referenceTT, jacobianCache, model) + if iterationCache == cache && ok { + tt := referenceTT + coordinates[2]/occultationStationEnvelopeTimeScale + residual, ok = model.residual(cache.evaluation(tt), normalizeLongitude(coordinates[0]), coordinates[1]) + } + if !ok { + if jacobianCache != cache { + iterationCache = cache + jacobianCache = cache + coordinates = predictor + continue + } + return occultationStationEnvelopeArcState{}, iteration, false + } + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + // Interpolation predicts the Newton root; accepted vertices must pass + // the original exact residual and extremum checks. The candidate + // Jacobian only predicts corrections and the next tracing direction. + if iterationCache != cache && (iteration >= 5 || + math.Abs(residual[0]) <= model.valueTolerance() && math.Abs(residual[1]) <= model.derivativeTolerance()) { + iterationCache = cache + continue + } + // Retain a converged exact root before finite-difference noise can + // move the derivative back outside the final acceptance tolerance. + if math.Abs(residual[0]) <= 1e-9 && math.Abs(residual[1]) <= model.derivativeTolerance() && math.Abs(planeResidual) <= 1e-9 { + return occultationStationValidEnvelopeArcState( + coordinates, jacobian, referenceTT, cache, model, widthKM, location, iteration+1, + ) + } + matrix := [3][3]float64{jacobian[0], jacobian[1], tangent} + delta, ok := solveSolarEclipse3x3( + matrix, [3]float64{-residual[0], -residual[1], -planeResidual}, + ) + if !ok { + return occultationStationEnvelopeArcState{}, iteration, false + } + if norm := math.Sqrt(dotSolarEclipse3(delta, delta)); norm > 1 { + for index := range delta { + delta[index] /= norm + } + } + for index := range coordinates { + coordinates[index] += delta[index] + } + coordinates[0] = predictor[0] + math.Remainder(coordinates[0]-predictor[0], 360) + if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { + return occultationStationEnvelopeArcState{}, iteration, false + } + } + residual, jacobian, ok := occultationStationEnvelopeJacobian(coordinates, referenceTT, cache, model) + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if !ok || math.Abs(residual[0]) > model.valueTolerance() || + math.Abs(residual[1]) > model.derivativeTolerance() || + math.Abs(planeResidual) > 1e-7 { + return occultationStationRefineEnvelopeAtFixedTime( + coordinates, predictor, tangent, referenceTT, cache, model, widthKM, location, + ) + } + return occultationStationValidEnvelopeArcState( + coordinates, jacobian, referenceTT, cache, model, widthKM, location, 20, + ) +} + +// At the Julian-day rounding floor, changing time can oscillate between two +// derivative values. A bounded spatial correction keeps the physical equations +// exact; only the auxiliary arclength plane may move by one minimum trace step. +func occultationStationRefineEnvelopeAtFixedTime( + coordinates, predictor, tangent [3]float64, + referenceTT float64, + cache *occultationRiseSetEvaluationCache, + model occultationStationEnvelopeModel, + widthKM float64, + location *time.Location, +) (occultationStationEnvelopeArcState, int, bool) { + origin := coordinates + for iteration := 0; iteration < 4; iteration++ { + residual, jacobian, ok := occultationStationEnvelopeJacobian(coordinates, referenceTT, cache, model) + if !ok || coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { + break + } + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if math.Abs(planeResidual) > occultationStationEnvelopeMinArcStepDegrees { + break + } + if math.Abs(residual[0]) <= model.valueTolerance() && math.Abs(residual[1]) <= model.derivativeTolerance() { + return occultationStationValidEnvelopeArcState( + coordinates, jacobian, referenceTT, cache, model, widthKM, location, 20+iteration, + ) + } + determinant := jacobian[0][0]*jacobian[1][1] - jacobian[0][1]*jacobian[1][0] + if !finite(determinant) || math.Abs(determinant) < 1e-12 { + break + } + longitudeStep := (-residual[0]*jacobian[1][1] + residual[1]*jacobian[0][1]) / determinant + latitudeStep := (-residual[1]*jacobian[0][0] + residual[0]*jacobian[1][0]) / determinant + coordinates[0] += longitudeStep + coordinates[1] += latitudeStep + if !finite(coordinates[0]) || !finite(coordinates[1]) || + math.Hypot(coordinates[0]-origin[0], coordinates[1]-origin[1]) > occultationStationEnvelopeMinArcStepDegrees { + break + } + } + return occultationStationEnvelopeArcState{}, 24, false +} + +func occultationStationValidEnvelopeArcState( + coordinates [3]float64, + jacobian [2][3]float64, + referenceTT float64, + cache *occultationRiseSetEvaluationCache, + model occultationStationEnvelopeModel, + widthKM float64, + location *time.Location, + iterations int, +) (occultationStationEnvelopeArcState, int, bool) { + tt := referenceTT + coordinates[2]/occultationStationEnvelopeTimeScale + longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] + evaluation := cache.evaluation(tt) + state := evaluation.center.stateAt(longitude, latitude) + residual, ok := model.residual(evaluation, longitude, latitude) + if !ok || math.Abs(residual[0]) > model.valueTolerance() || + math.Abs(residual[1]) > model.derivativeTolerance() || + !model.isRequiredExtremum(evaluation, longitude, latitude) { + return occultationStationEnvelopeArcState{}, iterations, false + } + tangent, ok := occultationStationEnvelopeTangent(jacobian) + if !ok { + return occultationStationEnvelopeArcState{}, iterations, false + } + return occultationStationEnvelopeArcState{ + coordinates: coordinates, + tangent: tangent, + point: OccultationPathPoint{ + Time: occultationTTToLocation(tt, location), Longitude: longitude, + Latitude: latitude, MoonAltitude: state.moonAltitude, WidthKM: widthKM, + }, + }, iterations, true +} + +func occultationStationEnvelopeJacobian( + coordinates [3]float64, + referenceTT float64, + cache *occultationRiseSetEvaluationCache, + model occultationStationEnvelopeModel, +) ([2]float64, [2][3]float64, bool) { + tt := referenceTT + coordinates[2]/occultationStationEnvelopeTimeScale + longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] + evaluation := cache.evaluation(tt) + residual, ok := model.residual(evaluation, longitude, latitude) + if !ok { + return [2]float64{}, [2][3]float64{}, false + } + steps := [3]float64{1e-4, 1e-4, 5 * occultationStationEnvelopeTimeScale / 86400} + jacobian := [2][3]float64{} + for column := range steps { + shifted := coordinates + shifted[column] += steps[column] + shiftedTT := referenceTT + shifted[2]/occultationStationEnvelopeTimeScale + shiftedEvaluation := cache.evaluation(shiftedTT) + shiftedResidual, shiftedOK := model.residual( + shiftedEvaluation, normalizeLongitude(shifted[0]), shifted[1], + ) + if !shiftedOK { + return [2]float64{}, [2][3]float64{}, false + } + for row := range residual { + jacobian[row][column] = (shiftedResidual[row] - residual[row]) / steps[column] + } + } + return residual, jacobian, true +} + +func (model occultationStationEnvelopeModel) residual( + evaluation occultationRiseSetEvaluation, + longitude, latitude float64, +) ([2]float64, bool) { + state := evaluation.center.stateAt(longitude, latitude) + if model.kind == occultationStationVisibilityEnvelope { + derivative := evaluation.moonAltitudeDerivative(longitude, latitude) + return [2]float64{state.moonAltitude, derivative}, state.valid && finite(derivative) + } + derivative := evaluation.contactDerivative(longitude, latitude) + return [2]float64{state.contactMetric, derivative}, state.valid && finite(derivative) +} + +func (model occultationStationEnvelopeModel) valueTolerance() float64 { + if model.kind == occultationStationVisibilityEnvelope { + return occultationStationHorizonResidualToleranceDeg + } + return 1e-5 +} + +func (model occultationStationEnvelopeModel) derivativeTolerance() float64 { + return occultationRiseSetJunctionDerivativeTolerance +} + +func (model occultationStationEnvelopeModel) isRequiredExtremum( + evaluation occultationRiseSetEvaluation, + longitude, latitude float64, +) bool { + if model.kind == occultationStationVisibilityEnvelope { + return evaluation.moonAltitudeSecondDerivative(longitude, latitude) < 0 + } + return evaluation.contactSecondDerivative(longitude, latitude) > 0 +} + +func (model occultationStationEnvelopeModel) activeMargin( + evaluation occultationRiseSetEvaluation, + longitude, latitude float64, +) float64 { + state := evaluation.center.stateAt(longitude, latitude) + if !state.valid { + return math.Inf(-1) + } + if model.kind == occultationStationVisibilityEnvelope { + return -state.contactMetric + } + return state.moonAltitude +} + +func occultationStationEnvelopeTangent(jacobian [2][3]float64) ([3]float64, bool) { + tangent := [3]float64{ + jacobian[0][1]*jacobian[1][2] - jacobian[0][2]*jacobian[1][1], + jacobian[0][2]*jacobian[1][0] - jacobian[0][0]*jacobian[1][2], + jacobian[0][0]*jacobian[1][1] - jacobian[0][1]*jacobian[1][0], + } + norm := math.Sqrt(dotSolarEclipse3(tangent, tangent)) + if !finite(norm) || norm < 1e-14 { + return [3]float64{}, false + } + for index := range tangent { + tangent[index] /= norm + } + return tangent, true +} + +func occultationStationEnvelopeActivityJunction( + inside, outside OccultationPathPoint, + location *time.Location, + cache *occultationRiseSetEvaluationCache, + model occultationStationEnvelopeModel, +) (OccultationPathPoint, bool) { + seed := occultationRiseSetMidpoint(inside, outside) + var junction OccultationPathPoint + var ok bool + if model.kind == occultationStationVisibilityEnvelope { + junction, ok = refineOccultationRiseSetDirectionJunction( + centerTimeTT(seed.Time), seed.Longitude, seed.Latitude, false, location, cache, + ) + if !ok { + junction, ok = refineOccultationRiseSetDirectionJunctionOnHorizon(inside, outside, false, location, cache) + } + } else { + junction, ok = refineOccultationRiseSetPhaseJunction(seed, location, cache) + if !ok { + junction, ok = refineOccultationRiseSetPhaseJunctionOnHorizon(seed, location, cache) + } + } + if !ok { + return OccultationPathPoint{}, false + } + minimumTT := math.Min(centerTimeTT(inside.Time), centerTimeTT(outside.Time)) - 1.0/86400 + maximumTT := math.Max(centerTimeTT(inside.Time), centerTimeTT(outside.Time)) + 1.0/86400 + if tt := centerTimeTT(junction.Time); tt < minimumTT || tt > maximumTT { + return OccultationPathPoint{}, false + } + maximumDistance := math.Max(10, 3*occultationPathDistanceKM(inside, outside)) + if occultationPathDistanceKM(junction, inside) > maximumDistance || + occultationPathDistanceKM(junction, outside) > maximumDistance { + return OccultationPathPoint{}, false + } + return junction, true +} + +func occultationStationEnvelopePointCovered( + segments [][]OccultationPathPoint, + point OccultationPathPoint, + toleranceKM float64, +) bool { + for _, segment := range segments { + for _, existing := range segment { + if occultationPathDistanceKM(existing, point) <= toleranceKM { + return true + } + } + } + return false +} + +func occultationStationDeduplicateEnvelopePoints(points []OccultationPathPoint) []OccultationPathPoint { + if len(points) < 2 { + return points + } + result := make([]OccultationPathPoint, 0, len(points)) + for _, point := range points { + if len(result) == 0 || occultationPathDistanceKM(result[len(result)-1], point) > 0.001 { + result = append(result, point) + } + } + return result +} + +func occultationStationSplitEnvelopeAtTimeFolds( + points []OccultationPathPoint, +) [][]OccultationPathPoint { + if len(points) < 2 { + return nil + } + // Station correction can produce the same civil timestamp twice at a + // temporal-envelope junction. Collapse coincident samples before splitting + // folds; retaining both would violate the public strictly-increasing-time + // contour contract while adding no geometry. + normalized := make([]OccultationPathPoint, 0, len(points)) + for _, point := range points { + if len(normalized) > 0 && point.Time.Equal(normalized[len(normalized)-1].Time) && + occultationPathDistanceKM(point, normalized[len(normalized)-1]) <= 0.01 { + normalized[len(normalized)-1] = point + continue + } + normalized = append(normalized, point) + } + points = normalized + if len(points) < 2 { + return nil + } + segments := make([][]OccultationPathPoint, 0, 4) + start := 0 + direction := 0 + appendSegment := func(first, end, sign int) { + if end-first < 2 { + return + } + segment := append([]OccultationPathPoint(nil), points[first:end]...) + if sign < 0 { + for left, right := 0, len(segment)-1; left < right; left, right = left+1, right-1 { + segment[left], segment[right] = segment[right], segment[left] + } + } + segments = append(segments, segment) + } + // Public contour segments must be strictly monotonic. A sub-millisecond + // reversal is still a real time fold when the traced points are spatially + // distinct, so split it without the coarser rise/set junction tolerance. + for index := 1; index < len(points); index++ { + sign := 0 + if points[index].Time.After(points[index-1].Time) { + sign = 1 + } else if points[index].Time.Before(points[index-1].Time) { + sign = -1 + } + if sign == 0 { + // Distinct points at one timestamp are a genuine temporal fold. End + // the preceding monotone segment and restart at the second branch. + if direction != 0 { + appendSegment(start, index, direction) + } + start = index + direction = 0 + continue + } + if direction == 0 { + direction = sign + continue + } + if sign == direction { + continue + } + appendSegment(start, index, direction) + start = index - 1 + direction = sign + } + appendSegment(start, len(points), direction) + return segments +} + +// occultationStationCorrectHorizonContactPoint refines an open footprint +// endpoint onto the intersection of the fixed-time contact curve and the lunar +// horizon. Solving only the contact equation leaves one unconstrained surface +// direction and lets independently sampled endpoints drift along the contact +// curve, which turns their temporal outline into a scalloped static boundary. +func occultationStationCorrectHorizonContactPoint( + tt float64, + seed OccultationPathPoint, + contextAt occultationRiseSetContextFunc, + total bool, + location *time.Location, +) (occultationStationBoundarySample, bool) { + if contextAt == nil { + return occultationStationBoundarySample{}, false + } + context := contextAt(tt) + if total { + context = context.withInternalContact() + } + if !context.valid { + return occultationStationBoundarySample{}, false + } + longitude, latitude := seed.Longitude, seed.Latitude + seedState := context.stateAt(longitude, latitude) + if !seedState.valid { + return occultationStationBoundarySample{}, false + } + seedResidual := seedState.contactMetric + residualNorm := func(state occultationRiseSetState) float64 { + return math.Hypot(state.contactMetric, state.moonAltitude) + } + for iteration := 0; iteration < 20; iteration++ { + state := context.stateAt(longitude, latitude) + if !state.valid || !finite(state.contactMetric) || !finite(state.moonAltitude) { + return occultationStationBoundarySample{}, false + } + if math.Abs(state.contactMetric) <= occultationStationOracleResidualToleranceDeg && + math.Abs(state.moonAltitude) <= occultationStationHorizonResidualToleranceDeg { + return occultationStationSampleFromCoordinates( + tt, seed, longitude, latitude, state.contactMetric, state.moonAltitude, seedResidual, location, + ), true + } + + const coordinateStepDeg = 0.002 + plusLongitude := context.stateAt(normalizeLongitude(longitude+coordinateStepDeg), latitude) + minusLongitude := context.stateAt(normalizeLongitude(longitude-coordinateStepDeg), latitude) + plusLatitude := context.stateAt(longitude, math.Min(89.999999, latitude+coordinateStepDeg)) + minusLatitude := context.stateAt(longitude, math.Max(-89.999999, latitude-coordinateStepDeg)) + if !plusLongitude.valid || !minusLongitude.valid || !plusLatitude.valid || !minusLatitude.valid { + return occultationStationBoundarySample{}, false + } + cosLatitude := math.Max(0.05, math.Cos(latitude*rad)) + contactX := (plusLongitude.contactMetric - minusLongitude.contactMetric) / (2 * coordinateStepDeg * cosLatitude) + contactY := (plusLatitude.contactMetric - minusLatitude.contactMetric) / (2 * coordinateStepDeg) + altitudeX := (plusLongitude.moonAltitude - minusLongitude.moonAltitude) / (2 * coordinateStepDeg * cosLatitude) + altitudeY := (plusLatitude.moonAltitude - minusLatitude.moonAltitude) / (2 * coordinateStepDeg) + determinant := contactX*altitudeY - contactY*altitudeX + if !finite(determinant) || math.Abs(determinant) <= 1e-12 { + return occultationStationBoundarySample{}, false + } + deltaX := (-state.contactMetric*altitudeY + contactY*state.moonAltitude) / determinant + deltaY := (-contactX*state.moonAltitude + altitudeX*state.contactMetric) / determinant + if !finite(deltaX) || !finite(deltaY) { + return occultationStationBoundarySample{}, false + } + if length := math.Hypot(deltaX, deltaY); length > 0.5 { + scale := 0.5 / length + deltaX *= scale + deltaY *= scale + } + + currentNorm := residualNorm(state) + accepted := false + for damping := 1.0; damping >= 1.0/64; damping /= 2 { + candidateLongitude := normalizeLongitude(longitude + damping*deltaX/cosLatitude) + candidateLatitude := math.Max(-89.999999, math.Min(89.999999, latitude+damping*deltaY)) + if occultationPathDistanceKMValues( + seed.Longitude, seed.Latitude, candidateLongitude, candidateLatitude, + ) > occultationStationHorizonMaximumOffsetKM { + continue + } + candidate := context.stateAt(candidateLongitude, candidateLatitude) + if !candidate.valid || residualNorm(candidate) >= currentNorm { + continue + } + longitude, latitude = candidateLongitude, candidateLatitude + accepted = true + break + } + if !accepted { + return occultationStationBoundarySample{}, false + } + } + state := context.stateAt(longitude, latitude) + if !state.valid || math.Abs(state.contactMetric) > occultationStationHorizonAcceptanceDeg || + math.Abs(state.moonAltitude) > occultationStationHorizonAcceptanceDeg { + return occultationStationBoundarySample{}, false + } + return occultationStationSampleFromCoordinates( + tt, seed, longitude, latitude, state.contactMetric, state.moonAltitude, seedResidual, location, + ), true +} + +func occultationStationSampleFromCoordinates( + tt float64, + seed OccultationPathPoint, + longitude, latitude, residual, altitude, seedResidual float64, + location *time.Location, +) occultationStationBoundarySample { + offset := occultationPathDistanceKMValues(seed.Longitude, seed.Latitude, longitude, latitude) + point := seed + point.Time = occultationTTToLocation(tt, location) + point.Longitude = longitude + point.Latitude = latitude + point.MoonAltitude = altitude + return occultationStationBoundarySample{ + point: point, + contactResidualDeg: residual, + horizonResidualDeg: altitude, + offsetKM: offset, + seedResidualDeg: seedResidual, + valid: finite(residual) && finite(altitude), + } +} + +// occultationStationCorrectFootprintEdges corrects only the two instantaneous +// contact arcs used by a direct ring and the endpoints that form their time +// tracks. Interior horizon samples remain untouched and retain their original +// footprint closure contract. +func occultationStationCorrectFootprintEdges( + footprints []PlanetOccultationFootprint, + frameAt occultationPathFrameFunc, + contextAt occultationRiseSetContextFunc, + total bool, + location *time.Location, +) []PlanetOccultationFootprint { + if len(footprints) == 0 || frameAt == nil || contextAt == nil { + return footprints + } + result := make([]PlanetOccultationFootprint, len(footprints)) + for index, footprint := range footprints { + result[index] = footprint + result[index].Boundaries = make([][]OccultationPathPoint, len(footprint.Boundaries)) + for boundaryIndex, boundary := range footprint.Boundaries { + result[index].Boundaries[boundaryIndex] = append([]OccultationPathPoint(nil), boundary...) + } + } + for footprintIndex := range result { + for boundaryIndex := range result[footprintIndex].Boundaries { + boundary := result[footprintIndex].Boundaries[boundaryIndex] + for pointIndex := range boundary { + if pointIndex != 0 && pointIndex+1 != len(boundary) && footprintIndex != 0 && footprintIndex+1 != len(result) { + continue + } + seed := boundary[pointIndex] + correct := occultationStationCorrectContactPoint + if !result[footprintIndex].Closed && (pointIndex == 0 || pointIndex+1 == len(boundary)) { + correct = occultationStationCorrectHorizonContactPoint + } + sample, ok := correct(centerTimeTT(seed.Time), seed, contextAt, total, location) + if ok && sample.valid { + corrected := sample.point + // Station solving may round-trip TT through a civil-time + // conversion with sub-millisecond drift. Footprint contracts + // require every boundary point to retain its parent sample time. + corrected.Time = seed.Time + // 验收带内的负高度是求解器噪声,导出契约要求边界切点高度非负。 + if corrected.MoonAltitude < 0 && corrected.MoonAltitude >= -occultationStationHorizonAcceptanceDeg { + corrected.MoonAltitude = 0 + } + boundary[pointIndex] = corrected + } + } + result[footprintIndex].Boundaries[boundaryIndex] = boundary + } + result[footprintIndex].Polygons = occultationStationFootprintPolygons( + result[footprintIndex], frameAt, location, + ) + // Horizon-arc points are recomputed from TT and can differ from the + // parent civil timestamp by a few microseconds after the station solve. + // Polygon samples are instantaneous geometry, so normalize their time + // metadata to the owning footprint before public validation/serialization. + for polygonIndex := range result[footprintIndex].Polygons { + for pointIndex := range result[footprintIndex].Polygons[polygonIndex] { + result[footprintIndex].Polygons[polygonIndex][pointIndex].Time = result[footprintIndex].Time + } + } + } + return result +} + +func occultationStationFootprintPolygons( + footprint PlanetOccultationFootprint, + frameAt occultationPathFrameFunc, + location *time.Location, +) [][]OccultationPathPoint { + if len(footprint.Boundaries) == 0 { + return footprint.Polygons + } + if footprint.Closed { + polygons := make([][]OccultationPathPoint, 0, len(footprint.Boundaries)+len(footprint.InteriorPolygons)) + for _, boundary := range footprint.Boundaries { + if len(boundary) < 3 { + continue + } + polygon := append([]OccultationPathPoint(nil), boundary...) + if occultationPathDistanceKM(polygon[0], polygon[len(polygon)-1]) > 0.001 { + polygon = append(polygon, polygon[0]) + } + polygons = append(polygons, polygon) + } + for _, interior := range footprint.InteriorPolygons { + polygons = append(polygons, append([]OccultationPathPoint(nil), interior...)) + } + if len(polygons) > 0 { + return polygons + } + return footprint.Polygons + } + tt := centerTimeTT(footprint.Time) + frame, ok := frameAt(tt) + if !ok { + return footprint.Polygons + } + polygons := make([][]OccultationPathPoint, 0, len(footprint.Boundaries)+len(footprint.InteriorPolygons)) + for _, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + continue + } + polygon := append([]OccultationPathPoint(nil), boundary...) + polygon = append(polygon, planetOccultationHorizonArc( + tt, frame, boundary, location, planetOccultationBandHorizonPoints, + )...) + if len(polygon) >= 4 { + polygons = append(polygons, polygon) + } + } + for _, interior := range footprint.InteriorPolygons { + polygons = append(polygons, append([]OccultationPathPoint(nil), interior...)) + } + if len(polygons) == 0 { + return footprint.Polygons + } + return polygons +} + +// occultationStationOracleProbeOffsets 返回由初始步长倍增得到的探测偏移序列, +// 末项恰好等于搜索半径上限;等比步长本身不会落在上限上。 +func occultationStationOracleProbeOffsets() []float64 { + offsets := make([]float64, 0, 8) + for step := occultationStationOracleInitialStepKM; step < occultationStationOracleMaximumOffsetKM; step *= 2 { + offsets = append(offsets, step) + } + return append(offsets, occultationStationOracleMaximumOffsetKM) +} + +// occultationStationOracleResidualBound 返回某括号宽度下仍可接受的接触残差上限: +// 容差的 10 倍与斜率上限乘括号宽度取较大者,避免浮点噪声否定已收敛的解。 +func occultationStationOracleResidualBound(bracketWidthKM float64) float64 { + return math.Max( + 10*occultationStationOracleResidualToleranceDeg, + occultationStationOracleResidualSlopeDegPerKM*math.Abs(bracketWidthKM), + ) +} + +func occultationStationFindBracket( + seedResidual float64, + evaluate func(float64) (float64, float64, float64, bool), +) (float64, float64, bool) { + if !finite(seedResidual) { + return 0, 0, false + } + var brackets [][2]float64 + for _, sign := range []float64{1, -1} { + previousOffset := 0.0 + previousResidual := seedResidual + for _, step := range occultationStationOracleProbeOffsets() { + currentOffset := sign * step + currentResidual, _, _, ok := evaluate(currentOffset) + if !ok { + continue + } + if previousResidual*currentResidual <= 0 { + brackets = append(brackets, [2]float64{previousOffset, currentOffset}) + break + } + previousOffset, previousResidual = currentOffset, currentResidual + } + } + if len(brackets) == 0 { + return 0, 0, false + } + best := brackets[0] + for _, candidate := range brackets[1:] { + bestDistance := math.Abs(best[0] + best[1]) + candidateDistance := math.Abs(candidate[0] + candidate[1]) + if candidateDistance < bestDistance { + best = candidate + } + } + return best[0], best[1], true +} + +func occultationStationOracleSampleAt( + tt float64, + fixed occultationPathVector, + offsetKM, seedResidual, residual, altitude float64, + frame occultationPathFrame, + location *time.Location, +) occultationStationBoundarySample { + rotation := occultationPathEarthRotationAt(tt) + inertial := occultationStationInverseEarthRotation(fixed, rotation) + longitude, latitude := occultationPathGeodeticWithSidereal(inertial, ApparentSiderealTime(TD2UT(tt, false))*15) + return occultationStationBoundarySample{ + point: OccultationPathPoint{ + Time: occultationTTToLocation(tt, location), + Longitude: longitude, + Latitude: latitude, + MoonAltitude: altitude, + }, + contactResidualDeg: residual, + horizonResidualDeg: altitude, + offsetKM: offsetKM, + seedResidualDeg: seedResidual, + valid: finite(residual) && finite(altitude) && occultationPathNorm(frame.moon) > 0, + } +} + +func occultationStationCrossTrackAt( + tt float64, + frame occultationPathFrame, + frameAt occultationPathFrameFunc, + seedFixed occultationPathVector, +) (occultationPathVector, bool) { + before, beforeOK := frameAt(tt - occultationPathVelocityStepDays) + after, afterOK := frameAt(tt + occultationPathVelocityStepDays) + if !beforeOK || !afterOK { + return occultationPathVector{}, false + } + vx := after.moonProjectionX() - before.moonProjectionX() + vy := after.moonProjectionY() - before.moonProjectionY() + if math.Hypot(vx, vy) <= 1e-12 { + return occultationPathVector{}, false + } + planeCross := occultationPathUnit(occultationPathAdd( + occultationPathScale(frame.first, -vy/math.Hypot(vx, vy)), + occultationPathScale(frame.second, vx/math.Hypot(vx, vy)), + )) + trackReference, trackOK := occultationPathTrackReference(frame) + if !trackOK { + return occultationPathVector{}, false + } + trackFixed := occultationPathSub( + occultationPathEarthFixedVector(tt+occultationPathVelocityStepDays, trackReference), + occultationPathEarthFixedVector(tt-occultationPathVelocityStepDays, trackReference), + ) + polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio + normal := occultationPathUnit(occultationPathVector{ + x: seedFixed.x, + y: seedFixed.y, + z: seedFixed.z / polarRatioSquared, + }) + trackFixed = occultationPathSub(trackFixed, occultationPathScale(normal, occultationPathDot(trackFixed, normal))) + if occultationPathNorm(trackFixed) <= 1e-12 { + trackFixed = occultationPathEarthFixedVectorWithRotation(planeCross, occultationPathEarthRotationAt(tt)) + trackFixed = occultationPathSub(trackFixed, occultationPathScale(normal, occultationPathDot(trackFixed, normal))) + } + trackFixed = occultationPathUnit(trackFixed) + cross := occultationPathUnit(occultationPathCross(normal, trackFixed)) + if occultationPathNorm(cross) <= 1e-12 { + return occultationPathVector{}, false + } + return cross, true +} + +func occultationStationSurfaceVector(longitude, latitude float64) occultationPathVector { + latitudeRad := latitude * rad + longitudeRad := longitude * rad + polarRatio := occultationPathEarthPolarRatio + u := math.Atan(polarRatio * math.Tan(latitudeRad)) + sinU, cosU := math.Sincos(u) + sinLongitude, cosLongitude := math.Sincos(longitudeRad) + return occultationPathScale(occultationPathVector{ + x: cosU * cosLongitude, + y: cosU * sinLongitude, + z: polarRatio * sinU, + }, occultationPathEarthEquatorialRadiusKM) +} + +func occultationStationOffsetSurfaceVector( + seedFixed, tangent occultationPathVector, offsetKM float64, +) occultationPathVector { + point := occultationPathAdd(seedFixed, occultationPathScale(tangent, offsetKM)) + polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio + metric := math.Sqrt(point.x*point.x + point.y*point.y + point.z*point.z/polarRatioSquared) + if metric <= 0 || !finite(metric) { + return seedFixed + } + return occultationPathScale(point, occultationPathEarthEquatorialRadiusKM/metric) +} + +func occultationStationGeodetic(fixed occultationPathVector) (float64, float64) { + return normalizeLongitude(math.Atan2(fixed.y, fixed.x) / rad), occultationPathGeodeticLatitude(fixed) +} + +func occultationStationInverseEarthRotation( + fixed occultationPathVector, + rotation occultationPathEarthRotation, +) occultationPathVector { + return occultationPathVector{ + x: rotation.cosine*fixed.x - rotation.sine*fixed.y, + y: rotation.sine*fixed.x + rotation.cosine*fixed.y, + z: fixed.z, + } +} diff --git a/basic/occultation_station_correction_test.go b/basic/occultation_station_correction_test.go new file mode 100644 index 0000000..a913e22 --- /dev/null +++ b/basic/occultation_station_correction_test.go @@ -0,0 +1,450 @@ +package basic + +import ( + "fmt" + "math" + "testing" + "time" +) + +func TestOccultationStationEnvelopeEqualTimeBranches(t *testing.T) { + for _, test := range []struct { + name string + times []int + segments int + }{ + {"leading plateau", []int{0, 0, 0, 1, 2}, 1}, + {"interior plateau", []int{0, 1, 1, 1, 2}, 2}, + {"trailing plateau", []int{0, 1, 1, 1}, 1}, + {"entire plateau", []int{0, 0, 0}, 0}, + {"reverse branches", []int{2, 1, 1, 1, 0}, 2}, + {"ordinary fold", []int{0, 1, 2, 1, 0}, 2}, + } { + t.Run(test.name, func(t *testing.T) { + points := make([]OccultationPathPoint, len(test.times)) + for index, seconds := range test.times { + points[index] = OccultationPathPoint{Time: time.Unix(int64(seconds), 0), Longitude: float64(index)} + } + segments := occultationStationSplitEnvelopeAtTimeFolds(points) + if len(segments) != test.segments { + t.Fatalf("segments=%d, want %d", len(segments), test.segments) + } + for _, segment := range segments { + for index := 1; index < len(segment); index++ { + if !segment[index].Time.After(segment[index-1].Time) { + t.Fatalf("non-increasing segment: %+v", segment) + } + } + } + // Every edge with elapsed time must survive the split unchanged. + for index := 1; index < len(points); index++ { + first, second := points[index-1], points[index] + if first.Time.Equal(second.Time) { + continue + } + if first.Time.After(second.Time) { + first, second = second, first + } + found := false + for _, segment := range segments { + for offset := 1; offset < len(segment); offset++ { + found = found || segment[offset-1] == first && segment[offset] == second + } + } + if !found { + t.Fatalf("lost monotone edge %d", index) + } + } + }) + } +} + +func TestOccultationStationOracleRefinesPlanetContours(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + cases := []struct { + name string + start time.Time + planet OccultationPlanet + }{ + {name: "Mars-20250729", start: time.Date(2025, time.July, 29, 0, 0, 0, 0, zone), planet: OccultationMars}, + {name: "Saturn-20240725", start: time.Date(2024, time.July, 25, 0, 0, 0, 0, zone), planet: OccultationSaturn}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + paths, err := FindPlanetOccultationPaths(test.start, test.start.Add(24*time.Hour), test.planet, OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, DisableRiseSet: true, + }) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + config, ok := planetOccultationConfigFor(test.planet) + if !ok { + t.Fatalf("%s config unavailable", test.planet) + } + cache := newPlanetOccultationEventCache(config) + cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time)) + checked := 0 + maxOffset := 0.0 + maxSeedResidual := 0.0 + unsolved := 0 + for _, contourSet := range []struct { + name string + contours [][]OccultationPathPoint + frameAt occultationPathFrameFunc + total bool + }{ + {name: "partial", contours: occultationContactBandContoursWithAdditionalTimes( + paths[0].Start, paths[0].End, centerTimeTT(paths[0].Start.Time), centerTimeTT(paths[0].End.Time), + centerTimeTT(paths[0].Greatest.Time), cache.outerFrameAt, + OccultationPathOptions{Step: 20 * time.Minute, DisableRiseSet: true}, time.UTC, nil, + ), frameAt: cache.outerFrameAt}, + {name: "total", contours: occultationContactBandContoursWithAdditionalTimes( + paths[0].TotalStart, paths[0].TotalEnd, centerTimeTT(paths[0].TotalStart.Time), centerTimeTT(paths[0].TotalEnd.Time), + centerTimeTT(paths[0].Greatest.Time), cache.totalFrameAt, + OccultationPathOptions{Step: 20 * time.Minute, DisableRiseSet: true}, time.UTC, nil, + ), frameAt: cache.totalFrameAt, total: true}, + } { + for _, contour := range contourSet.contours { + if len(contour) == 0 { + continue + } + stride := int(math.Max(1, math.Ceil(float64(len(contour))/12))) + for index := stride; index+stride < len(contour); index += stride { + seed := contour[index] + sample, solved := occultationStationCorrectBoundaryPoint( + centerTimeTT(seed.Time), seed, contourSet.frameAt, cache.riseSetContextAt, + contourSet.total, time.UTC, + ) + if !solved || !sample.valid { + unsolved++ + continue + } + if math.Abs(sample.contactResidualDeg) > 1e-5 { + t.Fatalf("%s contour sample %d contact residual=%.9g arcsec", contourSet.name, index, sample.contactResidualDeg) + } + maxOffset = math.Max(maxOffset, math.Abs(sample.offsetKM)) + maxSeedResidual = math.Max(maxSeedResidual, math.Abs(sample.seedResidualDeg)) + checked++ + } + } + } + if checked < 8 || unsolved > checked { + t.Fatalf("checked %d station contour samples, unsolved=%d", checked, unsolved) + } + t.Logf("station oracle samples=%d unsolved=%d max offset=%.1f km max geocentric residual=%.6f arcsec", checked, unsolved, maxOffset, maxSeedResidual) + if maxOffset > occultationStationOracleMaximumOffsetKM { + t.Fatalf("max station correction offset=%.1f km exceeds oracle bound", maxOffset) + } + }) + } +} + +func TestOccultationStationCorrectedOpenFootprintEndpointsStayOnHorizon(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationMars, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + RiseSetStep: time.Minute, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + config, _ := planetOccultationConfigFor(OccultationMars) + cache := newPlanetOccultationEventCache(config) + cache.preparePathEphemeris(occultationTimeToTT(paths[0].Greatest.Time), OccultationPathAlgorithmOptimized) + horizonEndpoints, coneEndpoints := 0, 0 + for _, band := range []struct { + name string + frameAt occultationPathFrameFunc + footprints []PlanetOccultationFootprint + }{ + {name: "partial", frameAt: cache.outerFrameAt, footprints: paths[0].PartialBandFootprints}, + {name: "total", frameAt: cache.totalFrameAt, footprints: paths[0].TotalBandFootprints}, + } { + for footprintIndex, footprint := range band.footprints { + if footprint.Closed { + continue + } + geocentric, geocentricOK := planetOccultationFootprintAtWithResolution( + occultationTimeToTT(footprint.Time), band.frameAt, zone, + planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints, + planetOccultationBandTargetSpacingKM, + ) + if !geocentricOK { + t.Fatalf("%s footprint %d has no geocentric support at %v", band.name, footprintIndex, footprint.Time) + } + for boundaryIndex, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + continue + } + for _, pointIndex := range []int{0, len(boundary) - 1} { + point := boundary[pointIndex] + if math.Abs(point.MoonAltitude) <= 1e-5 { + horizonEndpoints++ + continue + } + // 接触锥离开椭球处的尖点端点在地平线以上,地平线求解无法也 + // 不应移动它;此时端点必须与同一时刻的地心边界端点重合。 + if !planetOccultationFootprintHasGeocentricConeEndpoint(geocentric, point) { + t.Fatalf( + "%s footprint %d boundary %d endpoint %d MoonAltitude=%.9f deg is neither on the horizon nor a geocentric cone endpoint", + band.name, footprintIndex, boundaryIndex, pointIndex, point.MoonAltitude, + ) + } + coneEndpoints++ + } + } + } + } + if horizonEndpoints < 20 { + t.Fatalf("checked only %d horizon endpoints", horizonEndpoints) + } + if coneEndpoints == 0 { + t.Fatal("no cone-cusp endpoint observed; the cone-edge branch is untested") + } +} + +func planetOccultationFootprintHasGeocentricConeEndpoint( + footprint PlanetOccultationFootprint, + point OccultationPathPoint, +) bool { + for _, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + continue + } + for _, candidate := range []OccultationPathPoint{boundary[0], boundary[len(boundary)-1]} { + // 锥体尖点端点在月球地平线以上,因此用“离开地平线”筛选地心对应点。 + if math.Abs(candidate.MoonAltitude) <= 1e-5 { + continue + } + if occultationPathDistanceKM(candidate, point) <= 1 { + return true + } + } + } + return false +} + +func TestOccultationStationCorrectedContoursStayOnTemporalEnvelope(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationMars, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + RiseSetStep: time.Minute, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + config, ok := planetOccultationConfigFor(OccultationMars) + if !ok { + t.Fatal("Mars config unavailable") + } + cache := newPlanetOccultationEventCache(config) + cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time)) + + checked := 0 + failed := 0 + firstFailure := "" + for _, band := range []struct { + name string + contours [][]OccultationPathPoint + total bool + }{ + {name: "partial", contours: paths[0].PartialBandContours}, + {name: "total", contours: paths[0].TotalBandContours, total: true}, + } { + for contourIndex, contour := range band.contours { + for pointIndex, point := range contour { + tt := centerTimeTT(point.Time) + contextAt := cache.riseSetContextAt + if band.total { + contextAt = cache.totalRiseSetContextAt + } + evaluation := occultationRiseSetEvaluation{ + tt: tt, + center: contextAt(tt), + before: contextAt(tt - occultationRiseSetDerivativeStepDays), + after: contextAt(tt + occultationRiseSetDerivativeStepDays), + } + state := evaluation.center.stateAt(point.Longitude, point.Latitude) + derivative := evaluation.contactDerivative(point.Longitude, point.Latitude) + if !state.valid || !finite(derivative) { + t.Fatalf("%s contour %d point %d has invalid station state", band.name, contourIndex, pointIndex) + } + if math.Abs(state.contactMetric) > 1e-5 || math.Abs(derivative) > occultationRiseSetJunctionDerivativeTolerance { + failed++ + if firstFailure == "" { + firstFailure = fmt.Sprintf( + "%s contour %d point %d contact=%.9g arcsec derivative=%.9g arcsec/day", + band.name, contourIndex, pointIndex, state.contactMetric, derivative, + ) + } + } + checked++ + } + } + } + if checked < 100 { + t.Fatalf("checked only %d station envelope points", checked) + } + if failed > 0 { + t.Fatalf("%d/%d points miss the temporal envelope; first: %s", failed, checked, firstFailure) + } +} + +func TestOccultationStationCorrectedContoursCloseOnVisiblePhaseJunctions(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationMars, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + RiseSetStep: time.Minute, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + + for _, band := range []struct { + name string + contours [][]OccultationPathPoint + curves []OccultationRiseSetCurve + }{ + {name: "partial", contours: paths[0].PartialBandContours, curves: paths[0].RiseSetCurves}, + {name: "total", contours: paths[0].TotalBandContours, curves: paths[0].TotalRiseSetCurves}, + } { + phasePoints := make([]OccultationPathPoint, 0) + for _, curve := range band.curves { + if curve.Phase != RiseSetPhaseStart && curve.Phase != RiseSetPhaseEnd { + continue + } + for _, segment := range curve.Segments { + phasePoints = append(phasePoints, segment...) + } + } + if len(band.contours) == 0 || len(phasePoints) == 0 { + t.Fatalf("%s has contours=%d phase points=%d, want visible envelope and phase boundary", band.name, len(band.contours), len(phasePoints)) + } + for contourIndex, contour := range band.contours { + if len(contour) < 2 { + t.Fatalf("%s contour %d has %d points", band.name, contourIndex, len(contour)) + } + for pointIndex, point := range contour { + if point.MoonAltitude < -1e-7 { + t.Fatalf("%s contour %d point %d is below the lunar horizon: altitude=%.9g", band.name, contourIndex, pointIndex, point.MoonAltitude) + } + if pointIndex > 0 && !point.Time.After(contour[pointIndex-1].Time) { + t.Fatalf("%s contour %d times are not strictly increasing at point %d", band.name, contourIndex, pointIndex) + } + } + for _, endpoint := range []OccultationPathPoint{contour[0], contour[len(contour)-1]} { + if math.Abs(endpoint.MoonAltitude) <= 1e-6 { + nearestKM := math.Inf(1) + for _, phasePoint := range phasePoints { + nearestKM = math.Min(nearestKM, occultationPathDistanceKM(endpoint, phasePoint)) + } + if nearestKM > 0.1 { + t.Fatalf("%s contour %d horizon endpoint is %.3f km from the start/end phase line, want <=0.1 km", band.name, contourIndex, nearestKM) + } + continue + } + shared := false + for otherIndex, other := range band.contours { + if otherIndex == contourIndex || len(other) < 2 { + continue + } + for _, otherEndpoint := range []OccultationPathPoint{other[0], other[len(other)-1]} { + if math.Abs(endpoint.Time.Sub(otherEndpoint.Time).Seconds()) <= 0.01 && + occultationPathDistanceKM(endpoint, otherEndpoint) <= 0.001 { + shared = true + } + } + } + if !shared { + t.Fatalf("%s contour %d non-horizon endpoint is not a shared temporal fold", band.name, contourIndex) + } + } + } + } +} + +func TestOccultationStationVisibilityContoursStayOnActiveTemporalMaximum(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + RiseSetStep: time.Minute, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + config, ok := planetOccultationConfigFor(OccultationSaturn) + if !ok { + t.Fatal("Saturn config unavailable") + } + cache := newPlanetOccultationEventCache(config) + cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time)) + for _, band := range []struct { + name string + contours [][]OccultationPathPoint + total bool + }{ + {name: "partial", contours: paths[0].PartialVisibilityContours}, + {name: "total", contours: paths[0].TotalVisibilityContours, total: true}, + } { + if len(band.contours) == 0 { + t.Fatalf("%s has no lunar-visibility temporal contour", band.name) + } + for contourIndex, contour := range band.contours { + if len(contour) < 3 { + t.Fatalf("%s contour %d has %d points", band.name, contourIndex, len(contour)) + } + for pointIndex, point := range contour { + tt := centerTimeTT(point.Time) + context := cache.riseSetContextAt + if band.total { + context = func(value float64) occultationRiseSetContext { + return cache.riseSetContextAt(value).withInternalContact() + } + } + evaluation := occultationRiseSetEvaluation{ + tt: tt, center: context(tt), + before: context(tt - occultationRiseSetDerivativeStepDays), + after: context(tt + occultationRiseSetDerivativeStepDays), + } + state := evaluation.center.stateAt(point.Longitude, point.Latitude) + altitudeDerivative := evaluation.moonAltitudeDerivative(point.Longitude, point.Latitude) + altitudeSecondDerivative := evaluation.moonAltitudeSecondDerivative(point.Longitude, point.Latitude) + if !state.valid || math.Abs(state.moonAltitude) > 1e-5 || + math.Abs(altitudeDerivative) > occultationRiseSetJunctionDerivativeTolerance || + altitudeSecondDerivative >= 0 || state.contactMetric > 1e-5 { + t.Fatalf( + "%s contour %d point %d residuals H=%.9g Ht=%.9g Htt=%.9g F=%.9g", + band.name, contourIndex, pointIndex, state.moonAltitude, + altitudeDerivative, altitudeSecondDerivative, state.contactMetric, + ) + } + } + for _, endpoint := range []OccultationPathPoint{contour[0], contour[len(contour)-1]} { + context := cache.riseSetContextAt(centerTimeTT(endpoint.Time)) + if band.total { + context = context.withInternalContact() + } + state := context.stateAt(endpoint.Longitude, endpoint.Latitude) + if !state.valid || math.Abs(state.contactMetric) > 1e-5 { + t.Fatalf("%s contour %d endpoint contact residual=%.9g", band.name, contourIndex, state.contactMetric) + } + } + } + } +} diff --git a/basic/occultation_station_envelope.go b/basic/occultation_station_envelope.go new file mode 100644 index 0000000..1323a3a --- /dev/null +++ b/basic/occultation_station_envelope.go @@ -0,0 +1,78 @@ +package basic + +import ( + "math" + "time" +) + +// occultationStationDensifyContours applies the map's projection metric after +// station correction. The correction can move adjacent geocentric samples by +// tens of kilometres, so a contour that was dense before correction can still +// contain a long Web Mercator chord. A spatial arclength plane selects the +// midpoint branch even where time reverses and a fixed-time solve is singular. +// The refined curve is split again at time folds for the public time contract. +func occultationStationDensifyContours( + contours [][]OccultationPathPoint, + cache *occultationRiseSetEvaluationCache, + location *time.Location, +) [][]OccultationPathPoint { + if len(contours) == 0 || cache == nil { + return contours + } + const ( + targetSpacingKM = 30.0 + maxDepth = 10 + ) + result := make([][]OccultationPathPoint, 0, len(contours)) + for _, contour := range contours { + if len(contour) < 2 { + continue + } + var refine func(OccultationPathPoint, OccultationPathPoint, int, *[]OccultationPathPoint) + refine = func(start, end OccultationPathPoint, depth int, output *[]OccultationPathPoint) { + if depth >= maxDepth || occultationStationProjectedSpacingKM(start, end) <= targetSpacingKM { + *output = append(*output, end) + return + } + startTT, endTT := centerTimeTT(start.Time), centerTimeTT(end.Time) + chord := [3]float64{math.Remainder(end.Longitude-start.Longitude, 360), end.Latitude - start.Latitude, + (endTT - startTT) * occultationStationEnvelopeTimeScale} + length := math.Sqrt(dotSolarEclipse3(chord, chord)) + if length <= 1e-12 { + *output = append(*output, end) + return + } + predictor := [3]float64{start.Longitude + chord[0]/2, start.Latitude + chord[1]/2, chord[2] / 2} + for index := range chord { + chord[index] /= length + } + midpoint, _, ok := occultationStationCorrectEnvelopeArc(predictor, chord, startTT, cache, + occultationStationEnvelopeModel{kind: occultationStationContactEnvelope}, (start.WidthKM+end.WidthKM)/2, location) + if ok && occultationPathDistanceKM(start, midpoint.point) < occultationPathDistanceKM(start, end) && + occultationPathDistanceKM(midpoint.point, end) < occultationPathDistanceKM(start, end) { + refine(start, midpoint.point, depth+1, output) + refine(midpoint.point, end, depth+1, output) + return + } + // Leave a failed local solve untouched. Retrying with an unrelated + // branch would be less accurate than retaining the source sample. + *output = append(*output, end) + } + refined := []OccultationPathPoint{contour[0]} + for index := 1; index < len(contour); index++ { + refine(refined[len(refined)-1], contour[index], 0, &refined) + } + result = append(result, occultationStationSplitEnvelopeAtTimeFolds(refined)...) + } + return result +} + +func occultationStationProjectedSpacingKM(first, second OccultationPathPoint) float64 { + const maxLatitude = 85.05112878 + firstLatitude := math.Max(-maxLatitude, math.Min(maxLatitude, first.Latitude)) * rad + secondLatitude := math.Max(-maxLatitude, math.Min(maxLatitude, second.Latitude)) * rad + longitudeDelta := math.Remainder(second.Longitude-first.Longitude, 360) * rad + firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2)) + secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2)) + return 6378.1366 * math.Hypot(longitudeDelta, secondY-firstY) +} diff --git a/basic/orbit_observation.go b/basic/orbit_observation.go index 4dee525..894eb7c 100644 --- a/basic/orbit_observation.go +++ b/basic/orbit_observation.go @@ -50,6 +50,20 @@ func OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight floa return hourAngle } +// OrbitHourAngleWithTopocentric 返回站心视时角及同一状态的站心视赤经、视赤纬 / hour angle with its topocentric state. +// +// jde 沿用 OrbitHourAngle 的当地时口径:函数内部先减 timezone/24 得世界时,再按 UT→TT 换算。 +// jde follows the local-time convention of OrbitHourAngle: timezone/24 is subtracted before the UT→TT conversion. +func OrbitHourAngleWithTopocentric(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) (ra, dec, hourAngle float64) { + ra, dec, _ = orbitTopocentricObservation(jde, observerLon, observerLat, observerHeight, timezone, elements) + st := Limit360(ApparentSiderealTime(jde-timezone/24.0)*15 + observerLon) + hourAngle = st - ra + if hourAngle < 0 { + hourAngle += 360 + } + return ra, dec, hourAngle +} + // OrbitCulminationTime 返回轨道目标的中天时刻,输入输出均沿用本仓库现有观测函数的 JD 语义。 func OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 { if !isFiniteFloat(jde) || !isFiniteFloat(observerLon) || !isFiniteFloat(observerLat) || !isFiniteFloat(timezone) || !isFiniteFloat(observerHeight) { diff --git a/basic/path_regression_p0_test.go b/basic/path_regression_p0_test.go new file mode 100644 index 0000000..cf9c5a3 --- /dev/null +++ b/basic/path_regression_p0_test.go @@ -0,0 +1,196 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +func TestSolarEclipseSarosFamilyRemainsFiniteAcrossFiveCenturies(t *testing.T) { + base := JDECalc(2024, 4, 8) + const sarosDays = 6585.321314 + for familyIndex := -28; familyIndex <= 28; familyIndex++ { + seed := base + float64(familyIndex)*sarosDays + result := SolarEclipse(seed) + for name, value := range map[string]float64{ + "greatest": result.GreatestEclipse, + "gamma": result.Gamma, + "magnitude": result.Magnitude, + "longitude": result.GreatestLongitude, + "latitude": result.GreatestLatitude, + } { + if !finite(value) { + t.Fatalf("saros family index %d %s=%v", familyIndex, name, value) + } + } + if result.HasPartial && !(result.PartialBeginOnEarth <= result.GreatestEclipse && + result.GreatestEclipse <= result.PartialEndOnEarth) { + t.Fatalf("saros family index %d partial window does not contain greatest: %+v", familyIndex, result) + } + if result.HasCentral && !(result.CentralBeginOnEarth <= result.GreatestEclipse && + result.GreatestEclipse <= result.CentralEndOnEarth) { + t.Fatalf("saros family index %d central window does not contain greatest: %+v", familyIndex, result) + } + } +} + +func TestSolarEclipseRepresentativePathSeriesAreOrderedAndFinite(t *testing.T) { + cases := []struct { + name string + seed float64 + }{ + {name: "2009-07-22", seed: JDECalc(2009, 7, 22)}, + {name: "2010-01-15", seed: JDECalc(2010, 1, 15)}, + {name: "2014-04-29-non-central", seed: JDECalc(2014, 4, 29)}, + {name: "2023-04-20", seed: JDECalc(2023, 4, 20)}, + {name: "2043-10-03", seed: JDECalc(2043, 10, 3)}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + result := SolarEclipsePartialFootprints(test.seed, SolarEclipsePartialFootprintOptions{ + StepDays: 20.0 / 1440.0, BoundaryPoints: 24, + CentralShadowStepDays: 20.0 / 1440.0, + DisableRiseSetCurves: true, + }) + if !result.Eclipse.HasPartial { + t.Fatalf("expected partial eclipse, got %+v", result.Eclipse) + } + if test.name == "2014-04-29-non-central" && + (result.Eclipse.Centrality != SolarEclipseNonCentral || len(result.CentralBandSegments) == 0) { + t.Fatalf("non-central eclipse lost centrality envelope: centrality=%s segments=%d", + result.Eclipse.Centrality, len(result.CentralBandSegments)) + } + assertSolarEclipseFootprintSeriesFinite(t, result.Footprints) + assertSolarEclipseFootprintSeriesFinite(t, result.CentralShadowFootprints) + assertSolarEclipseFootprintSeriesFinite(t, result.CentralBandFootprints) + for index := 1; index < len(result.CentralBandSegments); index++ { + if len(result.CentralBandSegments[index]) == 0 { + t.Fatalf("central band segment %d is empty", index) + } + } + }) + } +} + +func assertSolarEclipseFootprintSeriesFinite(t *testing.T, footprints []SolarEclipsePartialFootprint) { + t.Helper() + for index, footprint := range footprints { + if !finite(footprint.JDE) { + t.Fatalf("footprint %d has invalid JDE=%v", index, footprint.JDE) + } + for boundaryIndex, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + t.Fatalf("footprint %d boundary %d has %d points", index, boundaryIndex, len(boundary)) + } + for pointIndex, point := range boundary { + if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) || !finite(point.SunAltitude) { + t.Fatalf("footprint %d boundary %d point %d is invalid: %+v", index, boundaryIndex, pointIndex, point) + } + } + } + if footprint.Closed { + totalPoints := 0 + for _, boundary := range footprint.Boundaries { + totalPoints += len(boundary) + } + if totalPoints < 3 { + t.Fatalf("closed footprint %d has only %d points", index, totalPoints) + } + if len(footprint.Boundaries) == 1 { + boundary := footprint.Boundaries[0] + if solarEclipsePathDistanceKM(boundary[0], boundary[len(boundary)-1]) > 5 { + t.Fatalf("closed footprint %d has %.3f km endpoint gap", index, + solarEclipsePathDistanceKM(boundary[0], boundary[len(boundary)-1])) + } + } + } + } +} + +func TestOccultationFiniteDiskStatesRemainValidAcrossFiveCenturies(t *testing.T) { + base := occultationTimeToTT(time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC)) + for _, yearOffset := range []float64{-500, -250, 0, 250, 500} { + tt := base + yearOffset*365.2425 + for _, planet := range []OccultationPlanet{ + OccultationMercury, OccultationVenus, OccultationMars, + OccultationJupiter, OccultationSaturn, OccultationUranus, OccultationNeptune, + } { + config, ok := planetOccultationConfigFor(planet) + if !ok { + t.Fatalf("%s configuration unavailable", planet) + } + state := planetOccultationStateAt(tt, config, nil, -1) + if !state.valid { + t.Fatalf("year offset %.0f %s state is invalid: %+v", yearOffset, planet, state) + } + if !finite(state.externalContactMetric) || !finite(state.internalContactMetric) { + t.Fatalf("year offset %.0f %s contact metrics are not finite: %+v", yearOffset, planet, state) + } + if state.internalContactMetric < state.externalContactMetric { + t.Fatalf("year offset %.0f %s inner gap %.9f is below outer gap %.9f", + yearOffset, planet, state.internalContactMetric, state.externalContactMetric) + } + } + star := starOccultationEphemerisStateAt(tt, hr4799OccultationCoordinateForTest()) + if !star.valid || !finite(star.moonDistanceKM) || !finite(star.starRA) || !finite(star.starDec) { + t.Fatalf("year offset %.0f star state is invalid: %+v", yearOffset, star) + } + } +} + +func TestOccultationRepresentativePathSeriesAreOrderedAndFinite(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + planetPaths, err := FindPlanetOccultationPaths( + time.Date(2025, time.January, 5, 0, 0, 0, 0, zone), + time.Date(2025, time.January, 6, 0, 0, 0, 0, zone), + OccultationSaturn, + OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, DisableRiseSet: true}, + ) + if err != nil || len(planetPaths) != 1 { + t.Fatalf("planet paths=%d err=%v, want one", len(planetPaths), err) + } + assertOccultationPointSeriesFinite(t, planetPaths[0].CenterLine) + assertOccultationPointSeriesFinite(t, planetPaths[0].NorthernLimit) + assertOccultationPointSeriesFinite(t, planetPaths[0].SouthernLimit) + if planetPaths[0].HasTotalBand { + assertOccultationPointSeriesFinite(t, planetPaths[0].NorthernTotalLimit) + assertOccultationPointSeriesFinite(t, planetPaths[0].SouthernTotalLimit) + } + + starPaths, err := FindStarOccultationPaths( + time.Date(2025, time.June, 5, 0, 0, 0, 0, zone), + time.Date(2025, time.June, 6, 0, 0, 0, 0, zone), + hr4799OccultationCoordinateForTest(), + OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, DisableRiseSet: true}, + ) + if err != nil || len(starPaths) != 1 { + t.Fatalf("star paths=%d err=%v, want one", len(starPaths), err) + } + assertOccultationPointSeriesFinite(t, starPaths[0].CenterLine) + assertOccultationPointSeriesFinite(t, starPaths[0].NorthernLimit) + assertOccultationPointSeriesFinite(t, starPaths[0].SouthernLimit) +} + +func assertOccultationPointSeriesFinite(t *testing.T, points []OccultationPathPoint) { + t.Helper() + if len(points) == 0 { + t.Fatal("path series is empty") + } + for index, point := range points { + if !finite(point.Longitude) || !finite(point.Latitude) || !finite(point.WidthKM) { + t.Fatalf("point %d is not finite: %+v", index, point) + } + if index > 0 && !point.Time.After(points[index-1].Time) { + t.Fatalf("path times are not strictly increasing at %d: %v then %v", index, points[index-1].Time, point.Time) + } + } +} + +func TestSolarEclipseRepresentativePathPointsDoNotContainNaN(t *testing.T) { + path := SolarEclipseCentralPath(JDECalc(2010, 1, 15), SolarEclipsePathOptions{StepDays: 20.0 / 1440.0}) + for index, point := range append(append(append([]SolarEclipsePathPoint{}, path.CenterLine...), path.NorthernLimit...), path.SouthernLimit...) { + if math.IsNaN(point.Longitude) || math.IsNaN(point.Latitude) || math.IsNaN(point.JDE) { + t.Fatalf("path point %d contains NaN: %+v", index, point) + } + } +} diff --git a/basic/path_regression_p2_test.go b/basic/path_regression_p2_test.go new file mode 100644 index 0000000..c2a9419 --- /dev/null +++ b/basic/path_regression_p2_test.go @@ -0,0 +1,134 @@ +package basic + +import "testing" + +// TestSolarEclipseSarosPathSeriesRemainFiniteWithinBudget exercises actual +// footprint and central-band generation at several points spanning roughly +// five centuries. The P0 state test catches bad ephemeris values; this test +// also walks the sampled path output and therefore covers the topology input +// used by GeoJSON and SVG consumers. +func TestSolarEclipseSarosPathSeriesRemainFiniteWithinBudget(t *testing.T) { + const sarosDays = 6585.321314 + seed := JDECalc(2024, 4, 8) + for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} { + familyIndex := familyIndex + t.Run("saros-"+formatSignedRegressionIndex(familyIndex), func(t *testing.T) { + result := SolarEclipsePartialFootprints(seed+float64(familyIndex)*sarosDays, + SolarEclipsePartialFootprintOptions{ + StepDays: 20.0 / 1440.0, + BoundaryPoints: 72, + CentralShadowStepDays: 5.0 / 1440.0, + DisableRiseSetCurves: true, + }) + if !result.Eclipse.HasPartial || len(result.Footprints) == 0 { + t.Fatalf("missing partial path: type=%s footprints=%d", result.Eclipse.Type, len(result.Footprints)) + } + assertSolarEclipseP2Footprints(t, "partial", result.Footprints) + assertSolarEclipseP2Footprints(t, "central-shadow", result.CentralShadowFootprints) + assertSolarEclipseP2Footprints(t, "central-band", result.CentralBandFootprints) + if result.Eclipse.Centrality == SolarEclipseNonCentral && result.Eclipse.Type != SolarEclipsePartial && + len(result.CentralBandFootprints) == 0 { + t.Fatal("non-central annular/total event has no central-band samples") + } + if result.Eclipse.HasCentral { + path := SolarEclipseCentralPath(seed+float64(familyIndex)*sarosDays, + SolarEclipsePathOptions{StepDays: 20.0 / 1440.0, TargetSpacingKM: 500}) + if len(path.CenterLine) < 2 || len(path.NorthernLimit) < 2 || len(path.SouthernLimit) < 2 { + t.Fatalf("central path is incomplete: center=%d north=%d south=%d", + len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit)) + } + assertSolarEclipseP2PointSeries(t, "center-line", path.CenterLine) + assertSolarEclipseP2PointSeries(t, "north-limit", path.NorthernLimit) + assertSolarEclipseP2PointSeries(t, "south-limit", path.SouthernLimit) + } + }) + } +} + +func TestSolarEclipseHighResolutionSamplingHonorsPointBudget(t *testing.T) { + for _, test := range []struct { + name string + shadowStep float64 + }{ + {name: "partial-only"}, + {name: "partial-and-central-shadow", shadowStep: 1.0 / 86400.0}, + } { + t.Run(test.name, func(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ + StepDays: 1.0 / 86400.0, + BoundaryPoints: solarEclipsePartialFootprintMaxBoundaryPoints, + CentralShadowStepDays: test.shadowStep, + DisableRiseSetCurves: true, + }) + if len(result.Footprints) == 0 { + t.Fatal("high-resolution request returned no partial footprints") + } + if result.BoundaryPoints < solarEclipsePartialFootprintMinBoundaryPoints || + result.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints { + t.Fatalf("effective boundary points=%d outside [%d,%d]", result.BoundaryPoints, + solarEclipsePartialFootprintMinBoundaryPoints, solarEclipsePartialFootprintMaxBoundaryPoints) + } + totalPoints := 0 + for _, series := range [][]SolarEclipsePartialFootprint{result.Footprints, result.CentralShadowFootprints, result.CentralBandFootprints} { + for _, footprint := range series { + for _, boundary := range footprint.Boundaries { + totalPoints += len(boundary) + } + } + } + if totalPoints > solarEclipsePartialFootprintMaxPointCount { + t.Fatalf("high-resolution output points=%d, want <=%d", totalPoints, solarEclipsePartialFootprintMaxPointCount) + } + }) + } +} + +func assertSolarEclipseP2Footprints(t *testing.T, name string, footprints []SolarEclipsePartialFootprint) { + t.Helper() + for footprintIndex, footprint := range footprints { + if !finite(footprint.JDE) || len(footprint.Boundaries) == 0 { + t.Fatalf("%s footprint %d is incomplete: %+v", name, footprintIndex, footprint) + } + for boundaryIndex, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + t.Fatalf("%s footprint %d boundary %d has %d points", name, footprintIndex, boundaryIndex, len(boundary)) + } + for pointIndex, point := range boundary { + if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) || + !finite(point.SunAltitude) || point.Longitude < -180 || point.Longitude > 180 || + point.Latitude < -90 || point.Latitude > 90 { + t.Fatalf("%s footprint %d boundary %d point %d is invalid: %+v", + name, footprintIndex, boundaryIndex, pointIndex, point) + } + } + } + } +} + +func assertSolarEclipseP2PointSeries(t *testing.T, name string, points []SolarEclipsePathPoint) { + t.Helper() + for index, point := range points { + if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) || + !finite(point.SunAltitude) || !finite(point.WidthKM) || point.Longitude < -180 || + point.Longitude > 180 || point.Latitude < -90 || point.Latitude > 90 { + t.Fatalf("%s point %d is invalid: %+v", name, index, point) + } + if index > 0 && point.JDE <= points[index-1].JDE { + t.Fatalf("%s times are not strictly increasing at %d", name, index) + } + } +} + +func formatSignedRegressionIndex(value int) string { + if value >= 0 { + return "+" + formatRegressionIndexMagnitude(value) + } + return "-" + formatRegressionIndexMagnitude(-value) +} + +func formatRegressionIndexMagnitude(value int) string { + if value == 0 { + return "0" + } + return string([]byte{'0' + byte(value/10), '0' + byte(value%10)}) +} diff --git a/basic/planet_apparent.go b/basic/planet_apparent.go index ef0d866..c9858d9 100644 --- a/basic/planet_apparent.go +++ b/basic/planet_apparent.go @@ -60,15 +60,25 @@ func planetGeocentricPositionWithEarthN(planetIndex int, planetJD float64, ex, e } func planetApparentGeocentricPositionN(planetIndex int, jd float64, n int) (planetGeocentricPosition, float64) { + geo, tau, _ := planetApparentGeocentricPositionAndDistanceN(planetIndex, jd, n) + return geo, tau +} + +func planetApparentGeocentricPositionAndDistanceN( + planetIndex int, + jd float64, + n int, +) (planetGeocentricPosition, float64, float64) { ex, ey, ez := earthHeliocentricXYZN(jd, n) geoNow := planetGeocentricPositionWithEarthN(planetIndex, jd, ex, ey, ez, n) - tau := 0.0057755183 * math.Sqrt(geoNow.x*geoNow.x+geoNow.y*geoNow.y+geoNow.z*geoNow.z) + distance := math.Sqrt(geoNow.x*geoNow.x + geoNow.y*geoNow.y + geoNow.z*geoNow.z) + tau := 0.0057755183 * distance geo := planetGeocentricPositionWithEarthN(planetIndex, jd-tau, ex, ey, ez, n) baseLo := geo.lo baseBo := geo.bo geo.lo = Limit360(baseLo + GXCLo(baseLo, baseBo, jd)/3600.0 + Nutation2000Bi(jd)) geo.bo = baseBo + GXCBo(baseLo, baseBo, jd)/3600.0 - return geo, tau + return geo, tau, distance } func planetTrueGeocentricPositionN(planetIndex int, jd float64, n int) (planetGeocentricPosition, float64) { diff --git a/basic/planet_elongation_objective_test.go b/basic/planet_elongation_objective_test.go new file mode 100644 index 0000000..4897790 --- /dev/null +++ b/basic/planet_elongation_objective_test.go @@ -0,0 +1,156 @@ +package basic + +import ( + "math" + "testing" +) + +// 大距口径契约:返回时刻必须是公开“视距角”函数(MercurySunElongation/VenusSunElongation)的极大, +// 而不是忽略光行差与视位置修正的真距角极大。 + +type elongationObjectiveCase struct { + name string + elongate func(float64) float64 + next func(float64) float64 + last func(float64) float64 +} + +var elongationObjectiveCases = []elongationObjectiveCase{ + {"Mercury", MercurySunElongation, NextMercuryGreatestElongation, LastMercuryGreatestElongation}, + {"Venus", VenusSunElongation, NextVenusGreatestElongation, LastVenusGreatestElongation}, +} + +func elongationObjectiveSeeds() []float64 { + seeds := make([]float64, 0, 24) + for year := 2024; year <= 2027; year++ { + for month := 1; month <= 12; month += 2 { + seeds = append(seeds, JDECalc(year, month, 1)) + } + } + return seeds +} + +func elongationObjectiveTruth(elongate func(float64) float64, eventUT float64) float64 { + left, right := eventUT-30.0/1440.0, eventUT+30.0/1440.0 + for i := 0; i < 200; i++ { + third := (right - left) / 3 + if elongate(TD2UT(left+third, true)) <= elongate(TD2UT(right-third, true)) { + left += third + continue + } + right -= third + } + return (left + right) / 2 +} + +func TestGreatestElongationMatchesPublicElongationExtremum(t *testing.T) { + const toleranceMinutes = 2.0 + for _, tc := range elongationObjectiveCases { + for _, seed := range elongationObjectiveSeeds() { + for _, direction := range []struct { + name string + fn func(float64) float64 + }{{"next", tc.next}, {"last", tc.last}} { + eventUT := direction.fn(seed) + if math.IsNaN(eventUT) { + t.Fatalf("%s %s at %.1f returned NaN", tc.name, direction.name, seed) + } + truth := elongationObjectiveTruth(tc.elongate, eventUT) + if deviation := math.Abs(eventUT-truth) * 1440; deviation > toleranceMinutes { + t.Fatalf("%s %s at %.1f = %.9f deviates %.2f min from the public elongation maximum", + tc.name, direction.name, seed, eventUT, deviation) + } + } + } + } +} + +// 金星大距的侧向判定必须与水星同口径(0.1 s 事件容差),不能沿用站事件的 0.5 s。 +// +// 这些入口的查询参数是力学时,返回时刻是世界时: +// 查询“事件后 0.3 秒”要把世界时事件先换成力学时再加偏移。 +func TestGreatestElongationLateralToleranceIsEventTolerance(t *testing.T) { + const offsetSeconds = 0.3 + cases := []struct { + name string + next func(float64) float64 + last func(float64) float64 + }{ + {"MercuryEast", NextMercuryGreatestElongationEast, LastMercuryGreatestElongationEast}, + {"MercuryWest", NextMercuryGreatestElongationWest, LastMercuryGreatestElongationWest}, + {"VenusEast", NextVenusGreatestElongationEast, LastVenusGreatestElongationEast}, + {"VenusWest", NextVenusGreatestElongationWest, LastVenusGreatestElongationWest}, + } + seeds := []float64{JDECalc(2024, 3, 1), JDECalc(2025, 7, 1), JDECalc(2026, 11, 1)} + tolerance := exactQueryTTToleranceUT + for _, tc := range cases { + for _, seed := range seeds { + event := tc.next(seed) + if math.IsNaN(event) { + t.Fatalf("%s at %.1f returned NaN", tc.name, seed) + } + eventTT := TD2UT(event, true) + afterUT := event + offsetSeconds/86400.0 + if got := tc.next(eventTT + offsetSeconds/86400.0); got < afterUT-tolerance { + t.Fatalf("%s: Next at event+%.1f s = %.9f returned an event %.3f s before the query", + tc.name, offsetSeconds, got, (afterUT-got)*86400) + } + beforeUT := event - offsetSeconds/86400.0 + if got := tc.last(eventTT - offsetSeconds/86400.0); got > beforeUT+tolerance { + t.Fatalf("%s: Last at event-%.1f s = %.9f returned an event %.3f s after the query", + tc.name, offsetSeconds, got, (got-beforeUT)*86400) + } + } + } +} + +// 无东西侧参数的大距只算查询所在窗口那一侧;这里用「两侧都算再取极值」作差分对照,输出必须逐位一致。 +func TestGreatestElongationAnySideKeepsBothSideExtremum(t *testing.T) { + cases := []struct { + name string + next func(float64) float64 + last func(float64) float64 + eastNext, westNext func(float64) float64 + eastLast, westLast func(float64) float64 + }{ + {"Mercury", NextMercuryGreatestElongation, LastMercuryGreatestElongation, + NextMercuryGreatestElongationEast, NextMercuryGreatestElongationWest, + LastMercuryGreatestElongationEast, LastMercuryGreatestElongationWest}, + {"Venus", NextVenusGreatestElongation, LastVenusGreatestElongation, + NextVenusGreatestElongationEast, NextVenusGreatestElongationWest, + LastVenusGreatestElongationEast, LastVenusGreatestElongationWest}, + } + for _, tc := range cases { + for jd := JDECalc(2024, 1, 1); jd <= JDECalc(2027, 1, 1); jd += 14 { + east, west := tc.eastNext(jd), tc.westNext(jd) + want := east + if !sameEventJD(east, west) { + want = earliestFiniteEventUT(east, west) + } + if got := tc.next(jd); got != want { + t.Fatalf("%s Next(%.1f) = %.12f, want %.12f (both-side reference)", tc.name, jd, got, want) + } + east, west = tc.eastLast(jd), tc.westLast(jd) + want = east + if !sameEventJD(east, west) { + want = latestFiniteEventUT(east, west) + } + if got := tc.last(jd); got != want { + t.Fatalf("%s Last(%.1f) = %.12f, want %.12f (both-side reference)", tc.name, jd, got, want) + } + } + } +} + +func TestEventAggregationKeepsFiniteCandidate(t *testing.T) { + const jd = 2460310.5 + if got := earliestFiniteEventUT(math.NaN(), jd); got != jd { + t.Fatalf("earliestFiniteEventUT(NaN, jd) = %v, want %v", got, jd) + } + if got := latestFiniteEventUT(jd, math.NaN()); got != jd { + t.Fatalf("latestFiniteEventUT(jd, NaN) = %v, want %v", got, jd) + } + if got := earliestFiniteEventUT(math.NaN(), math.Inf(1)); !math.IsNaN(got) { + t.Fatalf("earliestFiniteEventUT(NaN, +Inf) = %v, want NaN", got) + } +} diff --git a/basic/planet_event_perf_test.go b/basic/planet_event_perf_test.go new file mode 100644 index 0000000..15f786d --- /dev/null +++ b/basic/planet_event_perf_test.go @@ -0,0 +1,112 @@ +package basic + +import ( + "math" + "testing" +) + +// 本切片(行星/月球事件层)的性能守护基准;改动热路径时必须给出改前→改后。 + +func BenchmarkPlanetEventGreatestElongationAnySide(b *testing.B) { + jd := JDECalc(2025, 3, 1) + b.Run("Mercury", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = NextMercuryGreatestElongation(jd) + } + }) + b.Run("Venus", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = NextVenusGreatestElongation(jd) + } + }) +} + +func BenchmarkPlanetEventOuterStationNonFiniteQuery(b *testing.B) { + for _, tc := range []struct { + name string + fn func(float64) float64 + }{ + {"Mars", NextMarsProgradeToRetrograde}, + {"Jupiter", NextJupiterProgradeToRetrograde}, + {"Saturn", NextSaturnProgradeToRetrograde}, + {"Uranus", NextUranusProgradeToRetrograde}, + {"Neptune", NextNeptuneProgradeToRetrograde}, + } { + b.Run(tc.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = tc.fn(math.NaN()) + } + }) + } +} + +func BenchmarkPlanetEventMoonMaximumDeclination(b *testing.B) { + jd := JDECalc(2025, 3, 1) + b.Run("Next", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = NextMoonMaximumNorthDeclination(jd) + } + }) + b.Run("Closest", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = ClosestMoonMaximumNorthDeclination(jd) + } + }) +} + +func BenchmarkPlanetEventLunarEclipse(b *testing.B) { + jd := 2458860.0 + for i := 0; i < b.N; i++ { + _ = LunarEclipse(jd) + } +} + +func BenchmarkPlanetEventJupiterGalileanCallisto(b *testing.B) { + b.Run("NextTransit", func(b *testing.B) { + jd := 2463143.9646 + for i := 0; i < b.N; i++ { + _ = NextJupiterGalileanPhenomenonEvent(jd, 4, JupiterGalileanTransit) + } + }) + b.Run("ClosestOccultation", func(b *testing.B) { + jd := 2463400.0 + for i := 0; i < b.N; i++ { + _ = ClosestJupiterGalileanPhenomenonEvent(jd, 4, JupiterGalileanOccultation) + } + }) +} + +func BenchmarkPlanetEventJupiterSixEventsSameInstant(b *testing.B) { + jd := JDECalc(2025, 3, 1) + for i := 0; i < b.N; i++ { + _ = NextJupiterConjunction(jd) + _ = NextJupiterOpposition(jd) + _ = NextJupiterEasternQuadrature(jd) + _ = NextJupiterWesternQuadrature(jd) + _ = NextJupiterProgradeToRetrograde(jd) + _ = NextJupiterRetrogradeToPrograde(jd) + } +} + +func BenchmarkPlanetEventLunarEclipseDiagram(b *testing.B) { + jd := 2458860.0 + options := LunarEclipseDiagramOptions{StepDays: 1.0 / 86400.0} + for i := 0; i < b.N; i++ { + _ = LunarEclipseDiagram(jd, options) + } +} + +// 差分对照基准:改动前“两侧都算再取极值”的聚合方式。 +func BenchmarkPlanetEventGreatestElongationBothSidesReference(b *testing.B) { + mercuryJD, venusJD := JDECalc(2025, 3, 1), JDECalc(2025, 3, 1) + b.Run("Mercury", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = earliestFiniteEventUT(NextMercuryGreatestElongationEast(mercuryJD), NextMercuryGreatestElongationWest(mercuryJD)) + } + }) + b.Run("Venus", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = earliestFiniteEventUT(NextVenusGreatestElongationEast(venusJD), NextVenusGreatestElongationWest(venusJD)) + } + }) +} diff --git a/basic/planet_input_guard_test.go b/basic/planet_input_guard_test.go index 52c43a9..e1edee1 100644 --- a/basic/planet_input_guard_test.go +++ b/basic/planet_input_guard_test.go @@ -91,3 +91,140 @@ func TestSharedObservationRejectsNonFiniteQuery(t *testing.T) { t.Fatalf("StarRiseTime error = %v, want ErrInvalidObservationInput", err) } } + +func TestMoonMaximumDeclinationEventsRejectNonFiniteQuery(t *testing.T) { + nonFinite := []struct { + name string + jd float64 + }{ + {"NaN", math.NaN()}, + {"+Inf", math.Inf(1)}, + {"-Inf", math.Inf(-1)}, + } + events := []struct { + name string + fn func(float64) DeclinationEvent + }{ + {"LastMoonMaximumNorthDeclination", LastMoonMaximumNorthDeclination}, + {"NextMoonMaximumNorthDeclination", NextMoonMaximumNorthDeclination}, + {"ClosestMoonMaximumNorthDeclination", ClosestMoonMaximumNorthDeclination}, + {"LastMoonMaximumSouthDeclination", LastMoonMaximumSouthDeclination}, + {"NextMoonMaximumSouthDeclination", NextMoonMaximumSouthDeclination}, + {"ClosestMoonMaximumSouthDeclination", ClosestMoonMaximumSouthDeclination}, + } + for _, event := range events { + for _, query := range nonFinite { + // A non-finite query used to seed an out-of-range cycle index and spin in the + // sampling sweep forever; it must return the zero event instead. + got := event.fn(query.jd) + if got.JDE != 0 || got.Declination != 0 { + t.Fatalf("%s(%s) = %+v, want zero event", event.name, query.name, got) + } + } + } + + // The finite path must keep working. + const jd = 2460310.5 + north := NextMoonMaximumNorthDeclination(jd) + if !(north.JDE > jd) || north.Declination == 0 { + t.Fatalf("NextMoonMaximumNorthDeclination(%v) = %+v, want a later event", jd, north) + } + south := LastMoonMaximumSouthDeclination(jd) + if !(south.JDE <= jd) || south.Declination == 0 { + t.Fatalf("LastMoonMaximumSouthDeclination(%v) = %+v, want an earlier event", jd, south) + } +} + +func TestOuterPlanetStationEventsRejectNonFiniteQuery(t *testing.T) { + nonFinite := []struct { + name string + jd float64 + }{ + {"NaN", math.NaN()}, + {"+Inf", math.Inf(1)}, + {"-Inf", math.Inf(-1)}, + } + events := []struct { + name string + fn func(float64) float64 + }{ + {"Mercury", NextMercuryProgradeToRetrograde}, + {"Mercury retrograde", NextMercuryRetrograde}, + {"Venus", NextVenusProgradeToRetrograde}, + {"Venus retrograde", NextVenusRetrograde}, + {"Mars", NextMarsProgradeToRetrograde}, + {"Jupiter", NextJupiterProgradeToRetrograde}, + {"Saturn", NextSaturnProgradeToRetrograde}, + {"Uranus", NextUranusProgradeToRetrograde}, + {"Neptune", NextNeptuneProgradeToRetrograde}, + {"Mars opposition", NextMarsOpposition}, + {"Jupiter opposition", NextJupiterOpposition}, + {"Saturn opposition", NextSaturnOpposition}, + {"Uranus opposition", NextUranusOpposition}, + {"Neptune opposition", NextNeptuneOpposition}, + {"Mars eastern quadrature", NextMarsEasternQuadrature}, + {"Jupiter eastern quadrature", NextJupiterEasternQuadrature}, + {"Saturn eastern quadrature", NextSaturnEasternQuadrature}, + {"Uranus eastern quadrature", NextUranusEasternQuadrature}, + {"Neptune eastern quadrature", NextNeptuneEasternQuadrature}, + } + for _, event := range events { + for _, query := range nonFinite { + if got := event.fn(query.jd); !math.IsNaN(got) { + t.Fatalf("%s(%s) = %v, want NaN", event.name, query.name, got) + } + } + } +} + +func TestOuterPlanetLastStationEventsRejectNonFiniteQuery(t *testing.T) { + events := []struct { + name string + fn func(float64) float64 + }{ + {"LastMarsProgradeToRetrograde", LastMarsProgradeToRetrograde}, + {"LastMarsRetrogradeToPrograde", LastMarsRetrogradeToPrograde}, + {"LastJupiterProgradeToRetrograde", LastJupiterProgradeToRetrograde}, + {"LastJupiterRetrogradeToPrograde", LastJupiterRetrogradeToPrograde}, + {"LastSaturnProgradeToRetrograde", LastSaturnProgradeToRetrograde}, + {"LastSaturnRetrogradeToPrograde", LastSaturnRetrogradeToPrograde}, + {"LastUranusProgradeToRetrograde", LastUranusProgradeToRetrograde}, + {"LastUranusRetrogradeToPrograde", LastUranusRetrogradeToPrograde}, + {"LastNeptuneProgradeToRetrograde", LastNeptuneProgradeToRetrograde}, + {"LastNeptuneRetrogradeToPrograde", LastNeptuneRetrogradeToPrograde}, + } + for _, event := range events { + if got := event.fn(math.NaN()); !math.IsNaN(got) { + t.Fatalf("%s(NaN) = %v, want NaN", event.name, got) + } + } +} + +// 查询落在事件之后 1 秒:首候选必须被侧向不变量拒绝,第二候选也必须通过同一不变量。 +func TestOuterPlanetStationSecondCandidateKeepsSideInvariant(t *testing.T) { + const offsetSeconds = 1.0 + cases := []struct { + name string + fn func(float64) float64 + }{ + {"Mars", NextMarsProgradeToRetrograde}, + {"Jupiter", NextJupiterProgradeToRetrograde}, + {"Saturn", NextSaturnProgradeToRetrograde}, + {"Uranus", NextUranusProgradeToRetrograde}, + {"Neptune", NextNeptuneProgradeToRetrograde}, + } + for _, tc := range cases { + event := tc.fn(JDECalc(2025, 1, 1)) + if math.IsNaN(event) { + t.Fatalf("%s: no station event found", tc.name) + } + query := event + offsetSeconds/86400.0 + got := tc.fn(TD2UT(query, true)) + if math.IsNaN(got) { + t.Fatalf("%s: second candidate rejected as NaN at %s", tc.name, JDE2Date(query)) + } + if got < query-stationQueryToleranceUT { + t.Fatalf("%s: returned %.9f before the query %.9f", tc.name, got, query) + } + } +} diff --git a/basic/planet_phase_invariant_test.go b/basic/planet_phase_invariant_test.go new file mode 100644 index 0000000..1673057 --- /dev/null +++ b/basic/planet_phase_invariant_test.go @@ -0,0 +1,356 @@ +package basic + +import ( + "math" + "testing" + "time" + + . "b612.me/astro/tools" +) + +// 本文件是「行星相位事件(留/合/冲/方照)」的顺序不变量回归。 +// +// 背景:类型化「留」的结果必须满足 +// Next*(q) >= q - tol、Last*(q) <= q + tol、返回的是真值事件、且不得跳过更近的同类型事件。 +// 2026-09 的 review 发现水星类型化「留」会跳过一个下合,导致 Last* 返回未来 +// (例如查询 TT 2008-01-22 时 LastMercuryProgradeToRetrograde 返回 2008-01-28)。 +// 真值来自对导出星历的独立二分扫描,不使用被测的 Next*/Last*,因此能独立复现该类缺陷。 + +const phaseInvariantToleranceDay = 0.6 / 86400.0 // 站事件同刻容差 0.5 s + 余量 + +type phaseTruthStation struct { + jd float64 + p2r bool +} + +func phaseRate(ra func(float64) float64, jd float64) float64 { + sub := ra(jd+0.01) - ra(jd-0.01) + if sub > 180 { + sub -= 360 + } + if sub < -180 { + sub += 360 + } + return sub / 0.02 +} + +func phaseTruthStations(ra func(float64) float64, jd0, jd1, step float64) []phaseTruthStation { + rate := func(jd float64) float64 { return phaseRate(ra, jd) } + var out []phaseTruthStation + prevJD, prev := jd0, rate(jd0) + for jd := jd0 + step; jd <= jd1; jd += step { + cur := rate(jd) + if prev*cur < 0 { + left, right := prevJD, jd + for i := 0; i < 100; i++ { + middle := (left + right) / 2 + if middle == left || middle == right { + break + } + if (prev < 0) == (rate(middle) < 0) { + left = middle + continue + } + right = middle + } + out = append(out, phaseTruthStation{jd: (left + right) / 2, p2r: prev > 0}) + } + prevJD, prev = jd, cur + } + return out +} + +type phaseStationCase struct { + name string + ra func(float64) float64 + nextP2R func(float64) float64 + lastP2R func(float64) float64 + nextR2P func(float64) float64 + lastR2P func(float64) float64 + step float64 + // radius 真值扫描半径(天):必须覆盖「查询在站后几秒 ⇒ 答案是下一个同名站」的距离。 + radius float64 + // queryRadius 参与构造查询的站距中心的最大距离(天)。 + queryRadius float64 +} + +func phaseStationCases() []phaseStationCase { + return []phaseStationCase{ + {"Mercury", MercuryApparentRa, NextMercuryProgradeToRetrograde, LastMercuryProgradeToRetrograde, + NextMercuryRetrogradeToPrograde, LastMercuryRetrogradeToPrograde, 0.25, 700, 450}, + {"Mars", MarsApparentRa, NextMarsProgradeToRetrograde, LastMarsProgradeToRetrograde, + NextMarsRetrogradeToPrograde, LastMarsRetrogradeToPrograde, 0.5, 1100, 250}, + {"Venus", VenusApparentRa, NextVenusProgradeToRetrograde, LastVenusProgradeToRetrograde, + NextVenusRetrogradeToPrograde, LastVenusRetrogradeToPrograde, 0.5, 900, 250}, + {"Jupiter", JupiterApparentRa, NextJupiterProgradeToRetrograde, LastJupiterProgradeToRetrograde, + NextJupiterRetrogradeToPrograde, LastJupiterRetrogradeToPrograde, 1, 700, 250}, + } +} + +func phaseEpochTT(year int, month time.Month, day int) float64 { + return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true) +} + +// TestPlanetStationOrderInvariant 在固定的历史失败时点附近逐点检查顺序不变量。 +func TestPlanetStationOrderInvariant(t *testing.T) { + epochs := map[string][]time.Time{ + "Mercury": { + time.Date(2008, 1, 22, 0, 0, 0, 0, time.UTC), + time.Date(2006, 2, 23, 0, 0, 0, 0, time.UTC), + time.Date(2007, 2, 6, 0, 0, 0, 0, time.UTC), + time.Date(2007, 11, 8, 0, 0, 0, 0, time.UTC), + time.Date(2003, 9, 27, 0, 0, 0, 0, time.UTC), + time.Date(-209, 1, 14, 0, 0, 0, 0, time.UTC), + time.Date(-208, 12, 11, 0, 0, 0, 0, time.UTC), + }, + "Mars": {time.Date(2003, 7, 30, 0, 0, 0, 0, time.UTC)}, + "Venus": {time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)}, + "Jupiter": {time.Date(2003, 7, 30, 0, 0, 0, 0, time.UTC)}, + } + offsets := []float64{0, 1, -1, 30, -30, 300, -300, 3600, -3600, 86400, -86400, 10 * 86400, -10 * 86400, 30 * 86400, -30 * 86400} + + for _, tc := range phaseStationCases() { + for _, epoch := range epochs[tc.name] { + center := phaseEpochTT(epoch.Year(), epoch.Month(), epoch.Day()) + stations := phaseTruthStations(tc.ra, center-tc.radius, center+tc.radius, tc.step) + if len(stations) == 0 { + t.Fatalf("%s: no truth stations near %s", tc.name, epoch.Format("2006-01-02")) + } + funcs := []struct { + label string + fn func(float64) float64 + next bool + p2r bool + }{ + {"NextP2R", tc.nextP2R, true, true}, + {"LastP2R", tc.lastP2R, false, true}, + {"NextR2P", tc.nextR2P, true, false}, + {"LastR2P", tc.lastR2P, false, false}, + } + for _, f := range funcs { + var queries []float64 + for _, st := range stations { + if st.p2r != f.p2r || math.Abs(st.jd-center) > tc.queryRadius { + continue + } + for _, off := range offsets { + queries = append(queries, st.jd+off/86400.0) + } + } + for _, q := range queries { + gotUT := f.fn(q) + if math.IsNaN(gotUT) { + t.Fatalf("%s %s at %s returned NaN", tc.name, f.label, + JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05")) + } + got := TD2UT(gotUT, true) + // 1) 顺序不变量 + if f.next && got < q-phaseInvariantToleranceDay { + t.Fatalf("%s %s at %s returned past event %s", tc.name, f.label, + JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), + JDE2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05")) + } + if !f.next && got > q+phaseInvariantToleranceDay { + t.Fatalf("%s %s at %s returned future event %s", tc.name, f.label, + JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), + JDE2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05")) + } + // 2) 必须是真值事件 + nearest, nearestDev := math.NaN(), math.Inf(1) + for _, st := range stations { + if st.p2r != f.p2r { + continue + } + if dev := math.Abs(st.jd - got); dev < nearestDev { + nearest, nearestDev = st.jd, dev + } + } + if nearestDev > 60.0/86400.0 { + t.Fatalf("%s %s at %s returned non-event %.6f (nearest truth %.3f d away)", tc.name, f.label, + JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), got, nearestDev) + } + _ = nearest + // 3) 不得跳过更近的同类型事件 + for _, st := range stations { + if st.p2r != f.p2r { + continue + } + if f.next && st.jd > q+phaseInvariantToleranceDay && st.jd < got-60.0/86400.0 { + t.Fatalf("%s %s at %s skipped %s", tc.name, f.label, + JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), + JDE2DateByZone(TD2UT(st.jd, false), time.UTC, false).Format("2006-01-02 15:04:05")) + } + if !f.next && st.jd < q-phaseInvariantToleranceDay && st.jd > got+60.0/86400.0 { + t.Fatalf("%s %s at %s skipped %s", tc.name, f.label, + JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), + JDE2DateByZone(TD2UT(st.jd, false), time.UTC, false).Format("2006-01-02 15:04:05")) + } + } + } + } + } + } +} + +// TestMercuryConjunctionNeverSkips 检查水星「合」搜索不会跨过更近的合 +// (历史缺陷:启发式跳 + 2 天走法会走满一个会合周期,跳过一次下合)。 +func TestMercuryConjunctionNeverSkips(t *testing.T) { + delta := func(jd float64) float64 { + sub := Limit360(MercuryApparentLo(jd) - HSunApparentLo(jd)) + if sub > 180 { + sub -= 360 + } + if sub < -180 { + sub += 360 + } + return sub + } + // 真值:局部细扫 + truth := func(jd0, jd1, step float64) []float64 { + var out []float64 + prevJD, prev := jd0, delta(jd0) + for jd := jd0 + step; jd <= jd1; jd += step { + cur := delta(jd) + if prev*cur < 0 { + left, right := prevJD, jd + for i := 0; i < 100; i++ { + middle := (left + right) / 2 + if middle == left || middle == right { + break + } + if (prev < 0) == (delta(middle) < 0) { + left = middle + continue + } + right = middle + } + out = append(out, (left+right)/2) + } + prevJD, prev = jd, cur + } + return out + } + for _, epoch := range []time.Time{ + time.Date(2008, 1, 22, 0, 0, 0, 0, time.UTC), + time.Date(2006, 2, 23, 0, 0, 0, 0, time.UTC), + time.Date(2007, 2, 6, 0, 0, 0, 0, time.UTC), + time.Date(-209, 1, 14, 0, 0, 0, 0, time.UTC), + } { + center := phaseEpochTT(epoch.Year(), epoch.Month(), epoch.Day()) + truthEvents := truth(center-400, center+400, 0.25) + if len(truthEvents) < 5 { + t.Fatalf("truth scan too sparse near %s: %d", epoch.Format("2006-01-02"), len(truthEvents)) + } + for _, q := range []float64{center, center + 0.5, center + 12, center - 12, center + 60, center - 60} { + for _, next := range []uint8{0, 1} { + got := TD2UT(mercuryConjunction(q, next), true) + if math.IsNaN(got) { + t.Fatalf("mercuryConjunction(%.6f, %d) = NaN", q, next) + } + want := math.NaN() + if next == 1 { + for _, e := range truthEvents { + if e >= q-0.1/86400.0 { + want = e + break + } + } + } else { + for i := len(truthEvents) - 1; i >= 0; i-- { + if truthEvents[i] <= q+0.1/86400.0 { + want = truthEvents[i] + break + } + } + } + if math.IsNaN(want) { + continue + } + if math.Abs(got-want) > 60.0/86400.0 { + t.Fatalf("mercuryConjunction(%.6f, %d) = %.6f want %.6f (%.3f d off) near %s", + q, next, got, want, got-want, epoch.Format("2006-01-02")) + } + } + } + } +} + +// TestGreatestElongationNoSkip 检查大距事件不会跳过相邻的极大。 +// +// 口径说明:库内 Next*GreatestElongation 用的是「真距角」(mercuryTrueElongationN, +// 不含光行差/视位置),公开的 MercurySunElongation 是「视距角」,两者极大时刻相差约 10 分钟。 +// 因此这里用视距角的极值作为参照,只做「不跳事件 / 顺序 / 是极值附近」的判定, +// 匹配容差取 30 分钟,足以覆盖口径差又远小于任何真实跳事件(数十天)。 +func TestGreatestElongationNoSkip(t *testing.T) { + type elongCase struct { + name string + elongate func(float64) float64 + next func(float64) float64 + last func(float64) float64 + } + cases := []elongCase{ + {"Mercury", MercurySunElongation, NextMercuryGreatestElongation, LastMercuryGreatestElongation}, + {"Venus", VenusSunElongation, NextVenusGreatestElongation, LastVenusGreatestElongation}, + } + jd0 := phaseEpochTT(2024, 1, 1) + jd1 := phaseEpochTT(2029, 1, 1) + for _, tc := range cases { + // 真值:视距角的局部极大 + var maxima []float64 + prev, cur := tc.elongate(jd0), tc.elongate(jd0+0.5) + for jd := jd0 + 1.0; jd <= jd1; jd += 0.5 { + next := tc.elongate(jd) + if cur >= prev && cur >= next && (cur > prev || cur > next) { + left, right := jd-1.0, jd + for i := 0; i < 60; i++ { + third := (right - left) / 3 + if tc.elongate(left+third) <= tc.elongate(right-third) { + left += third + continue + } + right -= third + } + maxima = append(maxima, (left+right)/2) + } + prev, cur = cur, next + } + if len(maxima) < 5 { + t.Fatalf("%s: too few elongation maxima (%d)", tc.name, len(maxima)) + } + const matchTolerance = 30.0 / 1440.0 + for i, maximum := range maxima { + // 查询落在极大前一天:Next 必须命中该极大(不得跳过) + next := TD2UT(tc.next(maximum-1), true) + if math.IsNaN(next) { + t.Fatalf("%s: Next at %s returned NaN", tc.name, tmpPhaseDate(maximum-1)) + } + if dev := math.Abs(next - maximum); dev > matchTolerance { + t.Fatalf("%s: Next at %s = %s, expected the maximum %s (%.2f min off)", + tc.name, tmpPhaseDate(maximum-1), tmpPhaseDate(next), tmpPhaseDate(maximum), dev*1440) + } + if next < maximum-1-phaseInvariantToleranceDay { + t.Fatalf("%s: Next returned an event before the query", tc.name) + } + // 查询落在极大后一天:Last 必须命中该极大,Next 必须命中下一个极大 + last := TD2UT(tc.last(maximum+1), true) + if dev := math.Abs(last - maximum); dev > matchTolerance { + t.Fatalf("%s: Last at %s = %s, expected the previous maximum %s (%.2f min off)", + tc.name, tmpPhaseDate(maximum+1), tmpPhaseDate(last), tmpPhaseDate(maximum), dev*1440) + } + if last > maximum+1+phaseInvariantToleranceDay { + t.Fatalf("%s: Last returned an event after the query", tc.name) + } + if i+1 < len(maxima) { + following := TD2UT(tc.next(maximum+1), true) + if dev := math.Abs(following - maxima[i+1]); dev > matchTolerance { + t.Fatalf("%s: Next at %s = %s, expected the following maximum %s (%.2f min off)", + tc.name, tmpPhaseDate(maximum+1), tmpPhaseDate(following), tmpPhaseDate(maxima[i+1]), dev*1440) + } + } + } + } +} + +func tmpPhaseDate(jd float64) string { + return JDE2DateByZone(TD2UT(jd, false), time.UTC, false).Format("2006-01-02 15:04") +} diff --git a/basic/planet_truncated.go b/basic/planet_truncated.go index 41b0351..d615377 100644 --- a/basic/planet_truncated.go +++ b/basic/planet_truncated.go @@ -43,6 +43,10 @@ func planetApparentDecManualN(planetIndex int, jd float64, n int) float64 { func planetApparentRaDecManualN(planetIndex int, jd float64, n int) (float64, float64) { lo, bo := planetApparentLoBoN(planetIndex, jd, n) + return planetApparentRaDecFromLoBo(jd, lo, bo) +} + +func planetApparentRaDecFromLoBo(jd, lo, bo float64) (float64, float64) { eps := TrueObliquity(jd) ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo)) ra = ra * 180 / math.Pi diff --git a/basic/rise_set.go b/basic/rise_set.go index b754803..f0b2ac1 100644 --- a/basic/rise_set.go +++ b/basic/rise_set.go @@ -8,6 +8,9 @@ import ( ) var ( + // ErrNeverRise/ErrNeverSet 是几何口径:天体全天在地平线以下报 ErrNeverRise(无升起), + // 全天在地平线以上报 ErrNeverSet(无落下)。名字描述“缺失的那个现象”,不是“被问的事件”; + // 太阳、恒星、月球三条链路都按此约定,GetMoonRiseTime 因此在极昼返回 ErrNeverSet。 ErrNeverRise = errors.New("rise event does not occur on this date") ErrNeverSet = errors.New("set event does not occur on this date") ErrNotOnThisDate = errors.New("rise/set event occurs on adjacent date") diff --git a/basic/rise_set_curve.go b/basic/rise_set_curve.go new file mode 100644 index 0000000..a417446 --- /dev/null +++ b/basic/rise_set_curve.go @@ -0,0 +1,281 @@ +package basic + +import ( + "math" + "sort" +) + +// RiseSetPhase 标识升落边界对应的局部事件阶段。 +// RiseSetPhase identifies the local event phase represented by a horizon curve. +type RiseSetPhase string + +// 升落边界上局部事件的三个阶段取值 / the three local event phase values on a rise/set boundary. +const ( + RiseSetPhaseStart RiseSetPhase = "start" + RiseSetPhaseGreatest RiseSetPhase = "greatest" + RiseSetPhaseEnd RiseSetPhase = "end" +) + +// RiseSetDirection 标识边界上的目标天体正在升起还是落下。 +// RiseSetDirection identifies whether the occulted body is rising or setting. +type RiseSetDirection string + +// 边界上目标天体正在升起或落下 / whether the body is rising or setting along the boundary. +const ( + RiseSetDirectionRise RiseSetDirection = "rise" + RiseSetDirectionSet RiseSetDirection = "set" +) + +type riseSetCyclicValueFunc func(float64) (float64, bool) + +// 折点处升落残差与零相切而不变号,纯符号扫描会整圈找不到根;极区相位曲线正是在 +// 这里断开。补根由调用方只在“分支内部空洞”上启用,因此不会改变分支端点语义。 +// A rise/set fold makes the residual touch zero without changing sign, so a pure +// sign scan can return no root at all and a polar phase curve breaks apart there. +// The caller enables fold recovery only for interior branch holes, so branch +// endpoints keep their existing semantics. +const riseSetFoldRootResidualToleranceDeg = 5e-4 + +func riseSetCyclicRoots(samples int, valueAt riseSetCyclicValueFunc) []float64 { + return riseSetSignChangeRoots(samples, valueAt) +} + +// riseSetCyclicRootsWithFoldTolerance 在符号扫描为空时补出相切折点根。 +// foldTolerance 非正时与历史符号扫描完全一致;正值为折点根的残差极小值上限。 +// riseSetCyclicRootsWithFoldTolerance falls back to tangent fold roots when the +// sign scan stays empty. A non-positive foldTolerance reproduces the historical +// sign scan exactly; a positive value bounds the residual minimum accepted as a +// fold root. +func riseSetCyclicRootsWithFoldTolerance(samples int, foldTolerance float64, valueAt riseSetCyclicValueFunc) []float64 { + roots := riseSetSignChangeRoots(samples, valueAt) + if len(roots) > 0 || foldTolerance <= 0 { + return roots + } + return riseSetFoldRoots(samples, foldTolerance, valueAt) +} + +// riseSetFoldRoots 取相邻采样 |残差| 的严格极小值为候选,由黄金分割核对区间极小值 +// 是否进入折点容差。 +// riseSetFoldRoots takes adjacent |residual| samples forming a strict local minimum +// as candidates and lets the golden-section minimum decide whether the fold +// tolerance is met. +func riseSetFoldRoots(samples int, foldTolerance float64, valueAt riseSetCyclicValueFunc) []float64 { + if samples < 12 { + samples = 12 + } + step := 2 * math.Pi / float64(samples) + values := make([]float64, samples) + valid := make([]bool, samples) + for index := range values { + values[index], valid[index] = valueAt(step * float64(index)) + valid[index] = valid[index] && finite(values[index]) + } + roots := make([]float64, 0, 2) + for index := range values { + next := (index + 1) % samples + previous := (index - 1 + samples) % samples + following := (next + 1) % samples + if !valid[index] || !valid[next] || !valid[previous] || !valid[following] { + continue + } + leftValue, rightValue := math.Abs(values[index]), math.Abs(values[next]) + if math.Abs(values[previous]) <= leftValue || math.Abs(values[following]) <= rightValue { + continue + } + if angle, ok := riseSetFoldRoot(step*float64(index), step*float64(next), foldTolerance, valueAt); ok { + roots = append(roots, riseSetNormalizeRadians(angle)) + } + } + sort.Float64s(roots) + return roots +} + +func riseSetSignChangeRoots(samples int, valueAt riseSetCyclicValueFunc) []float64 { + if samples < 12 { + samples = 12 + } + step := 2 * math.Pi / float64(samples) + values := make([]float64, samples) + valid := make([]bool, samples) + for index := range values { + values[index], valid[index] = valueAt(step * float64(index)) + valid[index] = valid[index] && finite(values[index]) + } + + roots := make([]float64, 0, 4) + for index := range values { + next := (index + 1) % samples + if !valid[index] || !valid[next] { + continue + } + left := step * float64(index) + right := step * float64(index+1) + leftValue, rightValue := values[index], values[next] + if leftValue == 0 { + roots = append(roots, riseSetNormalizeRadians(left)) + continue + } + if leftValue*rightValue > 0 { + continue + } + for iteration := 0; iteration < 48 && right-left > 1e-11; iteration++ { + middle := (left + right) / 2 + middleValue, ok := valueAt(riseSetNormalizeRadians(middle)) + if !ok || !finite(middleValue) { + break + } + if leftValue*middleValue <= 0 { + right, rightValue = middle, middleValue + } else { + left, leftValue = middle, middleValue + } + } + roots = append(roots, riseSetNormalizeRadians((left+right)/2)) + } + sort.Float64s(roots) + unique := roots[:0] + for _, root := range roots { + if len(unique) == 0 || riseSetAngularDistance(root, unique[len(unique)-1]) > 1e-7 { + unique = append(unique, root) + } + } + if len(unique) > 1 && riseSetAngularDistance(unique[0], unique[len(unique)-1]) <= 1e-7 { + unique = unique[:len(unique)-1] + } + return unique +} + +// riseSetFoldRoot 用黄金分割在区间内最小化 |残差|,极小值进入容差时返回折点根。 +// riseSetFoldRoot minimizes |residual| inside the interval by golden section and +// returns the fold root when the minimum stays inside the tolerance. +func riseSetFoldRoot(left, right, tolerance float64, valueAt riseSetCyclicValueFunc) (float64, bool) { + const goldenRatio = 0.6180339887498949 + valueAtAbs := func(angle float64) (float64, bool) { + value, ok := valueAt(riseSetNormalizeRadians(angle)) + if !ok || !finite(value) { + return 0, false + } + return math.Abs(value), true + } + x1 := right - goldenRatio*(right-left) + x2 := left + goldenRatio*(right-left) + f1, ok1 := valueAtAbs(x1) + f2, ok2 := valueAtAbs(x2) + if !ok1 || !ok2 { + return 0, false + } + for iteration := 0; iteration < 48 && right-left > 1e-9; iteration++ { + if f1 > f2 { + left, x1, f1 = x1, x2, f2 + x2 = left + goldenRatio*(right-left) + if f2, ok2 = valueAtAbs(x2); !ok2 { + return 0, false + } + continue + } + right, x2, f2 = x2, x1, f1 + x1 = right - goldenRatio*(right-left) + if f1, ok1 = valueAtAbs(x1); !ok1 { + return 0, false + } + } + angle, minimum := (left+right)/2, math.Min(f1, f2) + if minimum > tolerance { + return 0, false + } + return angle, true +} + +type riseSetGeographicResidualFunc func(longitude, latitude float64) (float64, float64, bool) + +func riseSetRefineGeographicRoot( + longitude, latitude float64, + residualAt riseSetGeographicResidualFunc, +) (float64, float64, bool) { + const finiteDifferenceDegrees = 1e-4 + for iteration := 0; iteration < 16; iteration++ { + first, second, ok := residualAt(longitude, latitude) + if !ok || !finite(first) || !finite(second) { + return 0, 0, false + } + if math.Abs(first) <= 1e-11 && math.Abs(second) <= 1e-11 { + return normalizeLongitude(longitude), latitude, true + } + firstLon, secondLon, lonOK := residualAt(longitude+finiteDifferenceDegrees, latitude) + firstLat, secondLat, latOK := residualAt(longitude, latitude+finiteDifferenceDegrees) + if !lonOK || !latOK { + return 0, 0, false + } + a := (firstLon - first) / finiteDifferenceDegrees + b := (firstLat - first) / finiteDifferenceDegrees + c := (secondLon - second) / finiteDifferenceDegrees + d := (secondLat - second) / finiteDifferenceDegrees + determinant := a*d - b*c + if !finite(determinant) || math.Abs(determinant) < 1e-18 { + return 0, 0, false + } + deltaLongitude := (-first*d + b*second) / determinant + deltaLatitude := (c*first - a*second) / determinant + scale := math.Max(math.Abs(deltaLongitude), math.Abs(deltaLatitude)) + if scale > 5 { + deltaLongitude *= 5 / scale + deltaLatitude *= 5 / scale + } + longitude = normalizeLongitude(longitude + deltaLongitude) + latitude += deltaLatitude + if latitude <= -89.999999 || latitude >= 89.999999 || !finite(latitude) { + return 0, 0, false + } + } + first, second, ok := residualAt(longitude, latitude) + return normalizeLongitude(longitude), latitude, + ok && finite(first) && finite(second) && math.Abs(first) <= 1e-8 && math.Abs(second) <= 1e-8 +} + +func riseSetHorizonPoint(centerLongitude, centerLatitude, angle float64) (float64, float64) { + longitude := centerLongitude * math.Pi / 180 + latitude := centerLatitude * math.Pi / 180 + center := [3]float64{ + math.Cos(latitude) * math.Cos(longitude), + math.Cos(latitude) * math.Sin(longitude), + math.Sin(latitude), + } + reference := [3]float64{0, 0, 1} + if math.Abs(center[2]) > 0.9 { + reference = [3]float64{1, 0, 0} + } + first := riseSetUnitVector(riseSetCross(reference, center)) + second := riseSetUnitVector(riseSetCross(center, first)) + point := [3]float64{ + first[0]*math.Cos(angle) + second[0]*math.Sin(angle), + first[1]*math.Cos(angle) + second[1]*math.Sin(angle), + first[2]*math.Cos(angle) + second[2]*math.Sin(angle), + } + return normalizeLongitude(math.Atan2(point[1], point[0]) * 180 / math.Pi), + math.Asin(math.Max(-1, math.Min(1, point[2]))) * 180 / math.Pi +} + +func riseSetCross(first, second [3]float64) [3]float64 { + return [3]float64{ + first[1]*second[2] - first[2]*second[1], + first[2]*second[0] - first[0]*second[2], + first[0]*second[1] - first[1]*second[0], + } +} + +func riseSetUnitVector(value [3]float64) [3]float64 { + norm := math.Sqrt(value[0]*value[0] + value[1]*value[1] + value[2]*value[2]) + return [3]float64{value[0] / norm, value[1] / norm, value[2] / norm} +} + +func riseSetNormalizeRadians(value float64) float64 { + value = math.Mod(value, 2*math.Pi) + if value < 0 { + value += 2 * math.Pi + } + return value +} + +func riseSetAngularDistance(first, second float64) float64 { + return math.Abs(math.Remainder(first-second, 2*math.Pi)) +} diff --git a/basic/rise_set_curve_test.go b/basic/rise_set_curve_test.go new file mode 100644 index 0000000..3283d73 --- /dev/null +++ b/basic/rise_set_curve_test.go @@ -0,0 +1,1187 @@ +package basic + +import ( + "fmt" + "math" + "reflect" + "testing" + "time" +) + +func TestSolarEclipseRiseSetCurvesSatisfyLocalPhaseEquations(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, + }) + if len(result.RiseSetCurves) != 6 { + t.Fatalf("solar rise/set curve count = %d, want 6", len(result.RiseSetCurves)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(JDECalc(2024, 4, 8), 0), SolarEclipseModelNASABulletinSplitK) + for _, curve := range result.RiseSetCurves { + for _, segment := range curve.Segments { + for _, point := range riseSetSolarTestSamples(segment) { + evaluation := solarEclipseRiseSetEvaluation{ + jd: point.JDE, + center: newLocalSolarEclipseStateContext(point.JDE, solver.params), + before: newLocalSolarEclipseStateContext( + point.JDE-solarEclipseRiseSetDerivativeStepDays, solver.params, + ), + after: newLocalSolarEclipseStateContext( + point.JDE+solarEclipseRiseSetDerivativeStepDays, solver.params, + ), + } + state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + if math.Abs(state.sunAltitudeRad/rad) > 1e-6 { + t.Fatalf("solar %s/%s altitude = %.9f deg", curve.Phase, curve.Direction, state.sunAltitudeRad/rad) + } + assertSolarRiseSetPhase(t, curve, point, state, evaluation) + } + } + } +} + +func TestSolarEclipseRiseSetCurvesCloseFoldsAndPhaseJunctions(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2031, 5, 21), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, + }) + if len(result.RiseSetCurves) != 6 { + t.Fatalf("solar rise/set curve count = %d, want 6", len(result.RiseSetCurves)) + } + + curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves)) + for _, curve := range result.RiseSetCurves { + key := solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction} + curves[key] = curve + if len(curve.Segments) != 2 { + t.Fatalf("solar %s/%s segments = %d, want 2", curve.Phase, curve.Direction, len(curve.Segments)) + } + if !solarRiseSetSegmentsShareEndpoint(curve.Segments[0], curve.Segments[1]) { + t.Fatalf("solar %s/%s branches do not share their fold endpoint", curve.Phase, curve.Direction) + } + for segmentIndex, segment := range curve.Segments { + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + if distance := solarEclipsePathDistanceKM(segment[pointIndex-1], segment[pointIndex]); distance > 1.1*solarEclipseRiseSetTargetSpacingKM { + t.Fatalf("solar %s/%s segment %d interval %d distance = %.3f km", + curve.Phase, curve.Direction, segmentIndex, pointIndex, distance) + } + } + } + } + + for _, direction := range []RiseSetDirection{RiseSetDirectionRise, RiseSetDirectionSet} { + start := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: direction}] + greatest := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: direction}] + end := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: direction}] + junctions := 0 + for _, segment := range start.Segments { + if solarRiseSetSegmentSharesEndpointWithBoth(segment, greatest.Segments, end.Segments) { + junctions++ + } + } + if junctions != 2 { + t.Fatalf("solar %s phase junctions = %d, want 2", direction, junctions) + } + } +} + +func TestSolarEclipseRiseSetCurvesCloseFoldsWithAdditionalBranches(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, + }) + for _, phase := range []RiseSetPhase{RiseSetPhaseGreatest, RiseSetPhaseEnd} { + var curve SolarEclipseRiseSetCurve + for _, candidate := range result.RiseSetCurves { + if candidate.Phase == phase && candidate.Direction == RiseSetDirectionRise { + curve = candidate + break + } + } + if len(curve.Segments) != 3 { + t.Fatalf("solar %s/rise segments = %d, want 3", phase, len(curve.Segments)) + } + folds := 0 + for first := 0; first < len(curve.Segments); first++ { + for second := first + 1; second < len(curve.Segments); second++ { + if solarRiseSetSegmentsShareEndpoint(curve.Segments[first], curve.Segments[second]) { + folds++ + } + } + } + if folds == 0 { + t.Fatalf("solar %s/rise additional branches do not share their fold endpoint", phase) + } + } +} + +func TestSolarEclipseRiseSetCurvesCloseSunsetPhaseJunctions20100115(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, + }) + curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves)) + for _, curve := range result.RiseSetCurves { + curves[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = curve + } + start := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: RiseSetDirectionSet}] + greatest := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: RiseSetDirectionSet}] + end := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: RiseSetDirectionSet}] + if got := solarRiseSetSharedPhaseJunctionCount(start.Segments, greatest.Segments, end.Segments); got != 1 { + t.Fatalf("sunset phase junctions=%d, want one shared endpoint", got) + } + if len(greatest.Segments) < 2 { + t.Fatalf("sunset greatest branches=%d, want a short branch through the phase junction", len(greatest.Segments)) + } +} + +func TestSolarEclipseRiseSetCurvesCloseSunsetPhaseJunctions23090609(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2309, 6, 9), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, + }) + curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves)) + for _, curve := range result.RiseSetCurves { + curves[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = curve + } + start := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: RiseSetDirectionSet}] + greatest := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: RiseSetDirectionSet}] + end := curves[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: RiseSetDirectionSet}] + if got := solarRiseSetSharedPhaseJunctionCount(start.Segments, greatest.Segments, end.Segments); got != 2 { + t.Fatalf("2309-06-09 sunset phase junctions=%d, want two shared endpoints", got) + } +} + +func TestSolarEclipseRiseSetCurveEndpointsAcrossEclipseTypes(t *testing.T) { + tests := []struct { + year, month, day int + directionJunctions int + }{ + {2008, 8, 1, 3}, + {2010, 1, 15, 3}, + {2014, 4, 29, 3}, + {2024, 4, 8, 0}, + {2025, 3, 29, 3}, + } + for _, test := range tests { + result := SolarEclipsePartialFootprints( + JDECalc(test.year, test.month, float64(test.day)), + SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440.0}, + ) + if len(result.RiseSetCurves) != 6 { + t.Fatalf("%04d-%02d-%02d rise/set curve count = %d, want 6", + test.year, test.month, test.day, len(result.RiseSetCurves)) + } + solver := newSolarEclipseSolver( + CalcMoonSHByJDE(JDECalc(test.year, test.month, float64(test.day)), 0), + SolarEclipseModelNASABulletinSplitK, + ) + curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves)) + for _, curve := range result.RiseSetCurves { + key := solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction} + curves[key] = curve + if len(curve.Segments) == 2 && !solarRiseSetSegmentsShareEndpoint(curve.Segments[0], curve.Segments[1]) { + t.Fatalf("%04d-%02d-%02d %s/%s branches do not share their fold endpoint", + test.year, test.month, test.day, curve.Phase, curve.Direction) + } + for segmentIndex, segment := range curve.Segments { + for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} { + evaluation := solver.magnitudeEvaluationAt(point.JDE) + state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + assertSolarRiseSetPhase(t, curve, point, state, evaluation) + } + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + if segment[pointIndex].JDE <= segment[pointIndex-1].JDE { + t.Fatalf("%04d-%02d-%02d %s/%s segment %d time is not increasing at %d", + test.year, test.month, test.day, curve.Phase, curve.Direction, segmentIndex, pointIndex) + } + } + } + } + if count := solarRiseSetDirectionJunctionCount(curves); count != test.directionJunctions { + t.Fatalf("%04d-%02d-%02d shared direction junctions = %d, want %d", + test.year, test.month, test.day, count, test.directionJunctions) + } + } +} + +func TestSolarEclipseNonCentralGreatestHorizonWithoutTimeFold(t *testing.T) { + seed := JDECalc(1957, 10, 23) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + for _, stepSeconds := range []float64{120, 5} { + t.Run(fmt.Sprintf("step_%gs", stepSeconds), func(t *testing.T) { + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440, BoundaryPoints: 24, RiseSetStepDays: stepSeconds / 86400, + }) + if result.Eclipse.Type != SolarEclipseTotal || result.Eclipse.Centrality != SolarEclipseNonCentral { + t.Fatalf("type=%s centrality=%s, want non-central total", result.Eclipse.Type, result.Eclipse.Centrality) + } + if !solarEclipseRiseSetCurveTopologyComplete(result.RiseSetCurves) { + t.Fatal("horizon branches must be time-ordered and share their endpoints") + } + curves := make(map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, len(result.RiseSetCurves)) + for _, curve := range result.RiseSetCurves { + curves[solarEclipseRiseSetCurveKey{curve.Phase, curve.Direction}] = curve + } + // Non-central totality need not produce a time fold. Here the + // greatest branches meet at a sunrise/sunset transition instead. + for _, direction := range []RiseSetDirection{RiseSetDirectionRise, RiseSetDirectionSet} { + start := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseStart, direction}] + greatest := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseGreatest, direction}] + end := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseEnd, direction}] + if len(greatest.Segments) != 1 { + t.Fatalf("greatest/%s branches=%d, want one", direction, len(greatest.Segments)) + } + if count := solarRiseSetSharedPhaseJunctionCount(start.Segments, greatest.Segments, end.Segments); count != 1 { + t.Fatalf("%s phase junctions=%d, want one", direction, count) + } + } + rise := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseGreatest, RiseSetDirectionRise}].Segments[0] + set := curves[solarEclipseRiseSetCurveKey{RiseSetPhaseGreatest, RiseSetDirectionSet}].Segments[0] + if !solarRiseSetSameEndpoint(rise[len(rise)-1], set[0]) { + t.Fatal("greatest sunrise and sunset branches do not share their transition") + } + // Solve the horizon roots independently of path sampling and + // endpoint completion, so a missing branch cannot pass by count. + checked := 0 + for jd := result.Eclipse.PartialBeginOnEarth; jd <= result.Eclipse.PartialEndOnEarth; jd += 60.0 / 86400 { + for key, roots := range solver.riseSetPointsAt(jd, 720) { + if key.phase != RiseSetPhaseGreatest { + continue + } + for _, root := range roots { + distance := math.Inf(1) + for _, segment := range curves[key].Segments { + for index := 1; index < len(segment); index++ { + distance = math.Min(distance, solarEclipseRiseSetChordDeviationKM(root, segment[index-1], segment[index])) + } + } + if distance > solarEclipseRiseSetChordToleranceKM { + t.Fatalf("greatest/%s root at %.9f is %.6f km from the exported curve", key.direction, jd, distance) + } + checked++ + } + } + } + if checked < 100 { + t.Fatalf("checked %d exact roots, want coverage across the event", checked) + } + }) + } +} + +func solarRiseSetDirectionJunctionCount( + curves map[solarEclipseRiseSetCurveKey]SolarEclipseRiseSetCurve, +) int { + count := 0 + for _, phase := range []RiseSetPhase{RiseSetPhaseStart, RiseSetPhaseGreatest, RiseSetPhaseEnd} { + rise := curves[solarEclipseRiseSetCurveKey{phase: phase, direction: RiseSetDirectionRise}] + set := curves[solarEclipseRiseSetCurveKey{phase: phase, direction: RiseSetDirectionSet}] + for _, segment := range rise.Segments { + for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} { + if solarRiseSetEndpointInSegments(point, set.Segments) { + count++ + } + } + } + } + return count +} + +func solarRiseSetSharedPhaseJunctionCount( + start, greatest, end [][]SolarEclipsePathPoint, +) int { + var junctions []SolarEclipsePathPoint + for _, segment := range start { + for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} { + if !solarRiseSetEndpointInSegments(point, greatest) || !solarRiseSetEndpointInSegments(point, end) { + continue + } + duplicate := false + for _, junction := range junctions { + if solarRiseSetSameEndpoint(point, junction) { + duplicate = true + break + } + } + if !duplicate { + junctions = append(junctions, point) + } + } + } + return len(junctions) +} + +func solarRiseSetSegmentsShareEndpoint(first, second []SolarEclipsePathPoint) bool { + for _, a := range []SolarEclipsePathPoint{first[0], first[len(first)-1]} { + for _, b := range []SolarEclipsePathPoint{second[0], second[len(second)-1]} { + if solarRiseSetSameEndpoint(a, b) { + return true + } + } + } + return false +} + +func solarRiseSetSegmentSharesEndpointWithBoth( + segment []SolarEclipsePathPoint, + firstCandidates, secondCandidates [][]SolarEclipsePathPoint, +) bool { + for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} { + if solarRiseSetEndpointInSegments(point, firstCandidates) && solarRiseSetEndpointInSegments(point, secondCandidates) { + return true + } + } + return false +} + +func solarRiseSetEndpointInSegments(point SolarEclipsePathPoint, segments [][]SolarEclipsePathPoint) bool { + for _, segment := range segments { + if solarRiseSetSameEndpoint(point, segment[0]) || solarRiseSetSameEndpoint(point, segment[len(segment)-1]) { + return true + } + } + return false +} + +func solarRiseSetSameEndpoint(first, second SolarEclipsePathPoint) bool { + return math.Abs(first.JDE-second.JDE) <= 1e-8 && solarEclipsePathDistanceKM(first, second) <= 0.01 +} + +func TestOccultationRiseSetCurvesSatisfyStarAndPlanetEquations(t *testing.T) { + location := time.FixedZone("CST", 8*3600) + star := hr4799OccultationCoordinateForTest() + starPaths, err := FindStarOccultationPaths( + time.Date(2025, 6, 5, 0, 0, 0, 0, location), + time.Date(2025, 6, 6, 0, 0, 0, 0, location), + star, OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(starPaths) != 1 { + t.Fatalf("star paths=%d err=%v, want one", len(starPaths), err) + } + starContextAt := func(tt float64) occultationRiseSetContext { + moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1) + targetRA, targetDec := starApparentRaDecGeocentric(tt, star) + return newOccultationRiseSetContext( + tt, moonRA, moonDec, HMoonAwayN(tt, -1), + targetRA, targetDec, 0, 0, + ) + } + assertOccultationRiseSetCurves(t, starPaths[0].RiseSetCurves, starContextAt) + + config, _ := planetOccultationConfigFor(OccultationSaturn) + planetPaths, err := FindPlanetOccultationPaths( + time.Date(2024, 8, 21, 0, 0, 0, 0, time.UTC), + time.Date(2024, 8, 22, 0, 0, 0, 0, time.UTC), + OccultationSaturn, OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(planetPaths) != 1 { + t.Fatalf("planet paths=%d err=%v, want one", len(planetPaths), err) + } + planetContextAt := func(tt float64) occultationRiseSetContext { + moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1) + targetRA, targetDec := config.apparentRaDecN(tt, -1) + return newOccultationRiseSetContext( + tt, moonRA, moonDec, HMoonAwayN(tt, -1), targetRA, targetDec, + config.earthDistanceN(tt, -1)*occultationPathAstronomicalUnitKM, config.equatorialRadiusKM, + ) + } + assertOccultationRiseSetCurves(t, planetPaths[0].RiseSetCurves, planetContextAt) + if !planetPaths[0].HasTotalBand || len(planetPaths[0].TotalRiseSetCurves) != 6 { + t.Fatalf("planet total rise/set curves=%d hasTotal=%v, want six inner-contact curves", + len(planetPaths[0].TotalRiseSetCurves), planetPaths[0].HasTotalBand) + } + assertOccultationRiseSetCurves(t, planetPaths[0].TotalRiseSetCurves, func(tt float64) occultationRiseSetContext { + return planetContextAt(tt).withInternalContact() + }) +} + +func TestOccultationRiseSetSamplingIsIndependentAndOptional(t *testing.T) { + location := time.FixedZone("CST", 8*3600) + start := time.Date(2025, 6, 5, 0, 0, 0, 0, location) + end := start.Add(24 * time.Hour) + star := hr4799OccultationCoordinateForTest() + find := func(options OccultationPathOptions) StarOccultationPath { + t.Helper() + paths, err := FindStarOccultationPaths(start, end, star, options) + if err != nil || len(paths) != 1 { + t.Fatalf("star paths=%d err=%v, want one", len(paths), err) + } + return paths[0] + } + + fine := find(OccultationPathOptions{Step: 10 * time.Second, RiseSetStep: 10 * time.Minute}) + coarse := find(OccultationPathOptions{Step: 2 * time.Minute, RiseSetStep: 10 * time.Minute}) + if !reflect.DeepEqual(fine.RiseSetCurves, coarse.RiseSetCurves) { + t.Fatal("rise/set curves changed when only the main path step changed") + } + disabled := find(OccultationPathOptions{Step: 2 * time.Minute, DisableRiseSet: true}) + if len(disabled.RiseSetCurves) != 0 { + t.Fatalf("disabled rise/set curve count = %d, want zero", len(disabled.RiseSetCurves)) + } + withoutFootprints := find(OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true}) + if len(withoutFootprints.Footprints) != 0 { + t.Fatalf("disabled footprint count = %d, want zero", len(withoutFootprints.Footprints)) + } + if len(withoutFootprints.BandFootprints) == 0 { + t.Fatal("disabled dense footprints did not retain compact band support") + } + if len(withoutFootprints.RiseSetCurves) != 6 { + t.Fatalf("rise/set curve count with disabled footprints = %d, want six", len(withoutFootprints.RiseSetCurves)) + } + if err := (OccultationPathOptions{RiseSetStep: -time.Second}).Validate(); err == nil { + t.Fatal("negative rise/set step was accepted") + } + fineRiseSet := find(OccultationPathOptions{Step: 2 * time.Minute, RiseSetStep: 5 * time.Minute}) + coarseRiseSet := find(OccultationPathOptions{Step: 2 * time.Minute, RiseSetStep: 30 * time.Minute}) + if occultationRiseSetPointCount(coarseRiseSet.RiseSetCurves) >= occultationRiseSetPointCount(fineRiseSet.RiseSetCurves) { + t.Fatal("coarser occultation rise/set step did not reduce curve samples") + } +} + +func TestOccultationRiseSet20240725CompletesMoonsetGreatestJunction(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + curves := make(map[occultationRiseSetCurveKey]OccultationRiseSetCurve) + for _, curve := range paths[0].RiseSetCurves { + curves[occultationRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = curve + } + greatest := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: RiseSetDirectionSet}] + if len(greatest.Segments) != 1 || len(greatest.Segments[0]) < 2 { + t.Fatalf("moonset greatest segments=%d, want one usable segment", len(greatest.Segments)) + } + junction := greatest.Segments[0][len(greatest.Segments[0])-1] + if junction.Latitude > 35 { + t.Fatalf("moonset greatest stopped at latitude %.3f, want completed southern junction", junction.Latitude) + } + for _, phase := range []RiseSetPhase{RiseSetPhaseStart, RiseSetPhaseEnd} { + curve := curves[occultationRiseSetCurveKey{phase: phase, direction: RiseSetDirectionSet}] + shared := false + for _, segment := range curve.Segments { + for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + if math.Abs(endpoint.Time.Sub(junction.Time).Seconds()) <= 1 && + occultationPathDistanceKM(endpoint, junction) <= 5 { + shared = true + break + } + } + } + if !shared { + t.Fatalf("moonset %s curve does not share the greatest-phase junction at %s", phase, junction.Time) + } + } +} + +func TestOccultationRiseSet20250630MarsClosesMoonsetPhaseJunction(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.June, 30, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationMars, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: 5 * time.Minute, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + if len(paths[0].RiseSetCurves) != 6 { + t.Fatalf("rise/set curve count=%d, want six", len(paths[0].RiseSetCurves)) + } + assertOccultationRiseSetEndpointsClosed(t, paths[0].RiseSetCurves) + config, _ := planetOccultationConfigFor(OccultationMars) + assertOccultationRiseSetCurves(t, paths[0].RiseSetCurves, func(tt float64) occultationRiseSetContext { + state := planetOccultationEphemerisStateAt(tt, config) + return newOccultationRiseSetContext( + tt, state.moonRA, state.moonDec, state.moonDistanceKM, + state.planetRA, state.planetDec, state.planetDistanceKM, + config.equatorialRadiusKM, + ) + }) +} + +func TestOccultationRiseSet20250105SaturnClosesPolarBranchesWithoutJump(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + if len(paths[0].RiseSetCurves) != 6 { + t.Fatalf("rise/set curve count=%d, want six", len(paths[0].RiseSetCurves)) + } + path := paths[0] + assertOccultationRiseSetEndpointNetwork(t, path.RiseSetCurves, path.NorthernLimit, path.SouthernLimit) + assertOccultationRiseSetNoInstantaneousBranchJumps(t, path.RiseSetCurves) + assertOccultationRiseSetPolarProjectionSpacing(t, path.RiseSetCurves, 230) + curves := make(map[occultationRiseSetCurveKey]OccultationRiseSetCurve, len(path.RiseSetCurves)) + for _, curve := range path.RiseSetCurves { + curves[occultationRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = curve + } + startRise := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseStart, direction: RiseSetDirectionRise}] + startSet := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseStart, direction: RiseSetDirectionSet}] + greatestRise := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: RiseSetDirectionRise}] + endRise := curves[occultationRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: RiseSetDirectionRise}] + if !occultationRiseSetCurvePairSharesEndpointInRegion( + startRise, startSet, -60, 60, 70, 90, + ) { + t.Fatal("start moonrise/moonset curves do not share their polar direction junction") + } + if !occultationRiseSetSegmentsShareEndpointInRegion(startSet.Segments, -60, 60, 70, 90) { + t.Fatal("moonset start-phase branches do not share their polar fold endpoint") + } + if !occultationRiseSetCurvesShareEndpointInRegion( + startRise, greatestRise, endRise, -100, -70, 20, 50, + ) { + t.Fatal("moonrise start/greatest/end curves do not share the North American phase junction") + } + if !occultationRiseSetSegmentsShareEndpointInRegion(endRise.Segments, -100, -70, 20, 50) { + t.Fatal("moonrise end-phase branches do not share their North American fold endpoint") + } +} + +func assertOccultationRiseSetPolarProjectionSpacing( + t *testing.T, + curves []OccultationRiseSetCurve, + maximumMercatorKM float64, +) { + t.Helper() + for _, curve := range curves { + for segmentIndex, segment := range curve.Segments { + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + first, second := segment[pointIndex-1], segment[pointIndex] + if math.Max(math.Abs(first.Latitude), math.Abs(second.Latitude)) < 75 { + continue + } + deltaLongitude := math.Remainder((second.Longitude-first.Longitude)*rad, 2*math.Pi) + firstY := math.Log(math.Tan(math.Pi/4 + math.Min(85, math.Max(-85, first.Latitude))*rad/2)) + secondY := math.Log(math.Tan(math.Pi/4 + math.Min(85, math.Max(-85, second.Latitude))*rad/2)) + distance := occultationTopocentricEarthRadiusKM * math.Hypot(deltaLongitude, secondY-firstY) + if distance > maximumMercatorKM { + t.Fatalf("%s/%s segment %d edge %d has %.1f km Web Mercator length, want at most %.1f km", + curve.Phase, curve.Direction, segmentIndex, pointIndex-1, distance, maximumMercatorKM) + } + } + } + } +} + +func occultationRiseSetCurvePairSharesEndpointInRegion( + first, second OccultationRiseSetCurve, + minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64, +) bool { + for _, segment := range first.Segments { + for _, point := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + if point.Longitude < minimumLongitude || point.Longitude > maximumLongitude || + point.Latitude < minimumLatitude || point.Latitude > maximumLatitude { + continue + } + if occultationRiseSetCurveHasEndpoint(second, point) { + return true + } + } + } + return false +} + +func assertOccultationRiseSetNoInstantaneousBranchJumps(t *testing.T, curves []OccultationRiseSetCurve) { + t.Helper() + for _, curve := range curves { + for segmentIndex, segment := range curve.Segments { + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + previous, current := segment[pointIndex-1], segment[pointIndex] + distance := occultationPathDistanceKM(previous, current) + deltaSeconds := math.Abs(current.Time.Sub(previous.Time).Seconds()) + if distance > 500 && deltaSeconds < 1 { + t.Fatalf("%s/%s segment %d contains %.1f km jump in %.3f seconds", curve.Phase, curve.Direction, segmentIndex, distance, deltaSeconds) + } + } + } + } +} + +func occultationRiseSetCurvesShareEndpointInRegion( + first, second, third OccultationRiseSetCurve, + minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64, +) bool { + for _, segment := range first.Segments { + for _, point := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + if point.Longitude < minimumLongitude || point.Longitude > maximumLongitude || + point.Latitude < minimumLatitude || point.Latitude > maximumLatitude { + continue + } + if occultationRiseSetCurveHasEndpoint(second, point) && + occultationRiseSetCurveHasEndpoint(third, point) { + return true + } + } + } + return false +} + +func occultationRiseSetSegmentsShareEndpointInRegion( + segments [][]OccultationPathPoint, + minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64, +) bool { + for firstIndex, first := range segments { + for _, point := range []OccultationPathPoint{first[0], first[len(first)-1]} { + if point.Longitude < minimumLongitude || point.Longitude > maximumLongitude || + point.Latitude < minimumLatitude || point.Latitude > maximumLatitude { + continue + } + for secondIndex, second := range segments { + if firstIndex == secondIndex { + continue + } + if occultationRiseSetSegmentHasEndpoint(second, point) { + return true + } + } + } + } + return false +} + +func occultationRiseSetCurveHasEndpoint(curve OccultationRiseSetCurve, point OccultationPathPoint) bool { + for _, segment := range curve.Segments { + if occultationRiseSetSegmentHasEndpoint(segment, point) { + return true + } + } + return false +} + +func occultationRiseSetSegmentHasEndpoint(segment []OccultationPathPoint, point OccultationPathPoint) bool { + for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} { + if math.Abs(endpoint.Time.Sub(point.Time).Seconds()) <= 1 && + occultationPathDistanceKM(endpoint, point) <= 5 { + return true + } + } + return false +} + +func assertOccultationRiseSetEndpointNetwork( + t *testing.T, + curves []OccultationRiseSetCurve, + northern, southern []OccultationPathPoint, +) { + t.Helper() + type endpoint struct { + curve, segment, side int + point OccultationPathPoint + } + var endpoints []endpoint + for curveIndex, curve := range curves { + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + t.Fatalf("%s/%s segment %d has fewer than two points", curve.Phase, curve.Direction, segmentIndex) + } + endpoints = append(endpoints, + endpoint{curveIndex, segmentIndex, 0, segment[0]}, + endpoint{curveIndex, segmentIndex, 1, segment[len(segment)-1]}, + ) + } + } + for index, current := range endpoints { + closed := false + for otherIndex, other := range endpoints { + if index == otherIndex { + continue + } + if math.Abs(current.point.Time.Sub(other.point.Time).Seconds()) <= 1 && + occultationPathDistanceKM(current.point, other.point) <= 5 { + closed = true + break + } + } + if closed { + continue + } + nearestBoundary := math.Inf(1) + for _, point := range append(append([]OccultationPathPoint(nil), northern...), southern...) { + nearestBoundary = math.Min(nearestBoundary, occultationPathDistanceKM(current.point, point)) + } + if nearestBoundary > 400 { + curve := curves[current.curve] + t.Errorf("%s/%s segment %d endpoint is %.1f km from the occultation-band boundary", + curve.Phase, curve.Direction, current.segment, nearestBoundary) + } + } +} + +func TestOccultationRiseSetEvaluationCacheReusesContexts(t *testing.T) { + calls := 0 + cache := newOccultationRiseSetEvaluationCache(func(tt float64) occultationRiseSetContext { + calls++ + return newOccultationRiseSetContext(tt, 10, 20, 384400, 30, -5, 0, 0) + }) + const tt = 2451545.25 + cache.evaluation(tt) + cache.evaluation(tt) + if calls != 3 { + t.Fatalf("same evaluation built %d contexts, want center/before/after once", calls) + } + cache.evaluation(tt + occultationRiseSetDerivativeStepDays) + if calls != 4 { + t.Fatalf("overlapping evaluation built %d contexts, want one additional context", calls) + } +} + +func TestOccultationRiseSetRegressionCurveCounts(t *testing.T) { + polar := StarCoordinate{ + ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24, + } + cases := []struct { + name string + find func() ([]OccultationRiseSetCurve, error) + // wantPoints 是当前采样下的实测点数,断言取 ±40% 带宽,抓采样与落段回归。 + wantPoints int + }{ + { + name: "HR4799", + wantPoints: 591, + find: func() ([]OccultationRiseSetCurve, error) { + paths, err := FindStarOccultationPaths( + time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC), + time.Date(2025, 6, 6, 0, 0, 0, 0, time.UTC), + hr4799OccultationCoordinateForTest(), + OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(paths) != 1 { + return nil, fmt.Errorf("paths=%d err=%v", len(paths), err) + } + return paths[0].RiseSetCurves, nil + }, + }, + { + name: "Saturn", + wantPoints: 707, + find: func() ([]OccultationRiseSetCurve, error) { + paths, err := FindPlanetOccultationPaths( + time.Date(2024, 8, 21, 0, 0, 0, 0, time.UTC), + time.Date(2024, 8, 22, 0, 0, 0, 0, time.UTC), + OccultationSaturn, + OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(paths) != 1 { + return nil, fmt.Errorf("paths=%d err=%v", len(paths), err) + } + return paths[0].RiseSetCurves, nil + }, + }, + { + name: "Saturn-2025-02-01", + wantPoints: 1490, + find: func() ([]OccultationRiseSetCurve, error) { + start := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + RiseSetStep: 5 * time.Minute, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + return nil, fmt.Errorf("paths=%d err=%v", len(paths), err) + } + return paths[0].RiseSetCurves, nil + }, + }, + { + name: "high-latitude", + wantPoints: 1262, + find: func() ([]OccultationRiseSetCurve, error) { + paths, err := FindStarOccultationPaths( + time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC), + time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC), + polar, + OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(paths) != 1 { + return nil, fmt.Errorf("paths=%d err=%v", len(paths), err) + } + return paths[0].RiseSetCurves, nil + }, + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + curves, err := test.find() + if err != nil { + t.Fatal(err) + } + if len(curves) != 6 { + t.Fatalf("rise/set curve count = %d, want 6", len(curves)) + } + points := occultationRiseSetPointCount(curves) + if points < test.wantPoints*3/5 || points > test.wantPoints*8/5 { + t.Fatalf("rise/set point count = %d, want %d +/-40%%", points, test.wantPoints) + } + for _, curve := range curves { + if len(curve.Segments) == 0 { + t.Fatalf("%s/%s has no segments", curve.Phase, curve.Direction) + } + } + }) + } +} + +func occultationRiseSetPointCount(curves []OccultationRiseSetCurve) int { + count := 0 + for _, curve := range curves { + for _, segment := range curve.Segments { + count += len(segment) + } + } + return count +} + +func BenchmarkOccultationRiseSetRegression(b *testing.B) { + polar := StarCoordinate{ + ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24, + } + cases := []struct { + name string + find func() ([]OccultationRiseSetCurve, error) + }{ + { + name: "HR4799", + find: func() ([]OccultationRiseSetCurve, error) { + paths, err := FindStarOccultationPaths( + time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC), + time.Date(2025, 6, 6, 0, 0, 0, 0, time.UTC), + hr4799OccultationCoordinateForTest(), OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(paths) != 1 { + return nil, fmt.Errorf("paths=%d err=%v", len(paths), err) + } + return paths[0].RiseSetCurves, nil + }, + }, + { + name: "Saturn", + find: func() ([]OccultationRiseSetCurve, error) { + paths, err := FindPlanetOccultationPaths( + time.Date(2024, 8, 21, 0, 0, 0, 0, time.UTC), + time.Date(2024, 8, 22, 0, 0, 0, 0, time.UTC), + OccultationSaturn, OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(paths) != 1 { + return nil, fmt.Errorf("paths=%d err=%v", len(paths), err) + } + return paths[0].RiseSetCurves, nil + }, + }, + { + name: "Saturn-2025-02-01", + find: func() ([]OccultationRiseSetCurve, error) { + start := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC) + paths, err := FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), OccultationSaturn, + OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + RiseSetStep: 5 * time.Minute, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + return nil, fmt.Errorf("paths=%d err=%v", len(paths), err) + } + return paths[0].RiseSetCurves, nil + }, + }, + { + name: "high-latitude", + find: func() ([]OccultationRiseSetCurve, error) { + paths, err := FindStarOccultationPaths( + time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC), + time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC), + polar, OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(paths) != 1 { + return nil, fmt.Errorf("paths=%d err=%v", len(paths), err) + } + return paths[0].RiseSetCurves, nil + }, + }, + } + for _, test := range cases { + b.Run(test.name, func(b *testing.B) { + b.ReportAllocs() + for index := 0; index < b.N; index++ { + curves, err := test.find() + if err != nil || len(curves) != 6 { + b.Fatalf("curves=%d err=%v, want six curves", len(curves), err) + } + b.ReportMetric(float64(occultationRiseSetPointCount(curves)), "points/op") + } + }) + } +} + +func TestSolarEclipseRiseSetStepAllowsCoarseSampling(t *testing.T) { + seed := JDECalc(2024, 4, 8) + // Five- and thirty-minute inputs can both hit the spatial chord limit. + // Use a genuinely dense input so the test measures time decimation. + fine := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, RiseSetStepDays: 30.0 / 86400.0, + }) + coarse := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, RiseSetStepDays: 30.0 / 1440.0, + }) + if !solarEclipseRiseSetCurveTopologyComplete(fine.RiseSetCurves) || + !solarEclipseRiseSetCurveTopologyComplete(coarse.RiseSetCurves) { + t.Fatal("time decimation must preserve complete rise/set topology") + } + if solarRiseSetPointCount(coarse.RiseSetCurves) >= solarRiseSetPointCount(fine.RiseSetCurves) { + t.Fatal("coarser solar rise/set step did not reduce curve samples") + } +} + +func solarRiseSetPointCount(curves []SolarEclipseRiseSetCurve) int { + count := 0 + for _, curve := range curves { + for _, segment := range curve.Segments { + count += len(segment) + } + } + return count +} + +func assertOccultationRiseSetCurves( + t *testing.T, + curves []OccultationRiseSetCurve, + contextAt occultationRiseSetContextFunc, +) { + t.Helper() + if len(curves) != 6 { + t.Fatalf("occultation rise/set curve count = %d, want 6", len(curves)) + } + for _, curve := range curves { + for _, segment := range curve.Segments { + for index := 1; index < len(segment); index++ { + if !segment[index].Time.After(segment[index-1].Time) { + t.Fatalf("occultation %s/%s segment time is not increasing at %d", curve.Phase, curve.Direction, index) + } + if distance := occultationPathDistanceKM(segment[index-1], segment[index]); distance > occultationPathBoundarySpacingKM+1e-6 { + t.Fatalf("occultation %s/%s segment gap = %.1f km from %v (%.4f, %.4f) to %v (%.4f, %.4f), want at most %.0f km", + curve.Phase, curve.Direction, distance, + segment[index-1].Time, segment[index-1].Longitude, segment[index-1].Latitude, + segment[index].Time, segment[index].Longitude, segment[index].Latitude, + occultationPathBoundarySpacingKM) + } + } + for _, point := range riseSetOccultationTestSamples(segment) { + tt := occultationTimeToTT(point.Time) + evaluation := occultationRiseSetEvaluation{ + tt: tt, center: contextAt(tt), + before: contextAt(tt - occultationRiseSetDerivativeStepDays), + after: contextAt(tt + occultationRiseSetDerivativeStepDays), + } + state := evaluation.center.stateAt(point.Longitude, point.Latitude) + if !state.valid || math.Abs(state.moonAltitude) > 1e-6 { + t.Fatalf("occultation %s/%s altitude = %.9f deg", curve.Phase, curve.Direction, state.moonAltitude) + } + contactDerivative := evaluation.contactDerivative(point.Longitude, point.Latitude) + phaseJunction := math.Abs(state.contactMetric) <= 1e-7 && + math.Abs(contactDerivative) <= occultationRiseSetJunctionDerivativeTolerance + if phaseJunction { + if evaluation.contactSecondDerivative(point.Longitude, point.Latitude) <= 0 { + t.Fatalf("occultation phase junction is not a contact-gap minimum at %.6f, %.6f", + point.Longitude, point.Latitude) + } + } else if curve.Phase == RiseSetPhaseGreatest { + derivative := evaluation.separationDerivative(point.Longitude, point.Latitude) + secondDerivative := evaluation.separationSecondDerivative(point.Longitude, point.Latitude) + if math.Abs(derivative) > 1e-8 || secondDerivative <= 0 || state.contactMetric > 1e-7 { + t.Fatalf("occultation greatest residual is invalid at %.6f, %.6f: derivative=%.9g second=%.9g contact=%.9g", + point.Longitude, point.Latitude, derivative, secondDerivative, state.contactMetric) + } + } else { + if math.Abs(state.contactMetric) > 1e-7 { + t.Fatalf("occultation %s contact residual = %.9g deg", curve.Phase, state.contactMetric) + } + if math.Abs(contactDerivative) > 1e-7 && + (curve.Phase == RiseSetPhaseStart && contactDerivative >= 0 || curve.Phase == RiseSetPhaseEnd && contactDerivative <= 0) { + t.Fatalf("occultation %s contact derivative = %.9g", curve.Phase, contactDerivative) + } + } + altitudeDerivative := evaluation.moonAltitudeDerivative(point.Longitude, point.Latitude) + if math.Abs(altitudeDerivative) > 1e-7 && + (curve.Direction == RiseSetDirectionRise && altitudeDerivative <= 0 || + curve.Direction == RiseSetDirectionSet && altitudeDerivative >= 0) { + t.Fatalf("occultation %s altitude derivative = %.9g", curve.Direction, altitudeDerivative) + } + } + } + } + assertOccultationRiseSetEndpointsClosed(t, curves) +} + +func assertOccultationRiseSetEndpointsClosed(t *testing.T, curves []OccultationRiseSetCurve) { + t.Helper() + type endpoint struct { + curve, segment, side int + point OccultationPathPoint + } + var endpoints []endpoint + for curveIndex, curve := range curves { + for segmentIndex, segment := range curve.Segments { + if len(segment) == 0 { + continue + } + endpoints = append(endpoints, + endpoint{curve: curveIndex, segment: segmentIndex, side: 0, point: segment[0]}, + endpoint{curve: curveIndex, segment: segmentIndex, side: 1, point: segment[len(segment)-1]}, + ) + } + } + for index, current := range endpoints { + matched := false + nearestIndex := -1 + nearestMetric := math.Inf(1) + for otherIndex, other := range endpoints { + if index == otherIndex || current.curve == other.curve && current.segment == other.segment && current.side == other.side { + continue + } + timeDifference := math.Abs(occultationTimeToTT(current.point.Time) - occultationTimeToTT(other.point.Time)) + distance := occultationPathDistanceKM(current.point, other.point) + metric := distance + timeDifference*86400 + if metric < nearestMetric { + nearestIndex, nearestMetric = otherIndex, metric + } + if timeDifference <= 1e-8 && distance <= 0.01 { + matched = true + break + } + } + if !matched { + curve := curves[current.curve] + nearest := endpoints[nearestIndex] + nearestCurve := curves[nearest.curve] + t.Errorf("occultation %s/%s segment %d has an unclosed endpoint at %v (%.6f, %.6f); nearest %s/%s segment %d differs by %.3fs and %.1f km", + curve.Phase, curve.Direction, current.segment, current.point.Time, + current.point.Longitude, current.point.Latitude, + nearestCurve.Phase, nearestCurve.Direction, nearest.segment, + math.Abs(current.point.Time.Sub(nearest.point.Time).Seconds()), + occultationPathDistanceKM(current.point, nearest.point)) + } + } +} + +func assertSolarRiseSetPhase( + t *testing.T, + curve SolarEclipseRiseSetCurve, + point SolarEclipsePathPoint, + state localSolarEclipseState, + evaluation solarEclipseRiseSetEvaluation, +) { + t.Helper() + contactGap := solarEclipsePartialContactGap(state) + contactDerivative := evaluation.partialContactDerivative(point.Longitude, point.Latitude) + phaseJunction := math.Abs(contactGap) <= 1e-7 && math.Abs(contactDerivative) <= 1e-7 + if phaseJunction { + if evaluation.partialContactSecondDerivative(point.Longitude, point.Latitude) <= 0 { + t.Fatalf("solar phase junction is not a contact-gap minimum at %.6f, %.6f", point.Longitude, point.Latitude) + } + } else if curve.Phase == RiseSetPhaseGreatest { + if math.Abs(evaluation.separationDerivative(point.Longitude, point.Latitude)) > 1e-8 || + evaluation.separationSecondDerivative(point.Longitude, point.Latitude) <= 0 || + contactGap > 1e-7 { + t.Fatalf("solar greatest residual is invalid at %.6f, %.6f", point.Longitude, point.Latitude) + } + } else { + if math.Abs(contactGap) > 1e-7 { + t.Fatalf("solar %s contact residual = %.9g rad", curve.Phase, contactGap) + } + if curve.Phase == RiseSetPhaseStart && contactDerivative >= 0 || curve.Phase == RiseSetPhaseEnd && contactDerivative <= 0 { + t.Fatalf("solar %s contact derivative = %.9g", curve.Phase, contactDerivative) + } + } + altitudeDerivative := evaluation.sunAltitudeDerivative(point.Longitude, point.Latitude) + directionJunction := math.Abs(altitudeDerivative) <= 1e-7 + if directionJunction { + if math.Abs(evaluation.sunAltitudeSecondDerivative(point.Longitude, point.Latitude)) <= 1e-7 { + t.Fatalf("solar direction junction is not a horizon tangency at %.6f, %.6f", point.Longitude, point.Latitude) + } + } else if curve.Direction == RiseSetDirectionRise && altitudeDerivative <= 0 || + curve.Direction == RiseSetDirectionSet && altitudeDerivative >= 0 { + t.Fatalf("solar %s altitude derivative = %.9g", curve.Direction, altitudeDerivative) + } +} + +func riseSetSolarTestSamples(segment []SolarEclipsePathPoint) []SolarEclipsePathPoint { + return []SolarEclipsePathPoint{segment[0], segment[len(segment)/2], segment[len(segment)-1]} +} + +func riseSetOccultationTestSamples(segment []OccultationPathPoint) []OccultationPathPoint { + return []OccultationPathPoint{segment[0], segment[len(segment)/2], segment[len(segment)-1]} +} + +// TestRiseSetCyclicRootsWithFoldToleranceCoversFoldOnlyResiduals 固定折点补根的实际契约: +// 折点处残差与零相切而不变号,符号扫描整圈为空,此时补根必须给出折点根(这是极区相位曲线 +// 得以闭合的路径);而当同一相位上同时存在普通变号根时,历史契约只返回符号根,折点根会被 +// 丢弃。后者是已知取舍:实测在重建阶段无条件合并两类根会让 2025-01-05 这类极区事件的解析 +// 掩带失去权威性(偏掩带回退为 footprint-sweep-fallback,可见性边界越出掩带 784 km),且在 +// 去重容差 1e-4 与 1e-2 下都会退化、仅在 1e-3 下勉强通过,因此不能作为修复落地。 +// TestRiseSetCyclicRootsWithFoldToleranceCoversFoldOnlyResiduals pins the actual fold-recovery +// contract: a fold holds the residual at zero without changing sign and leaves the sign scan +// empty, and recovery must then return the fold root, which is how polar phase curves close. +// When an ordinary sign-change root shares the phase, the historical contract returns only the +// sign roots and the fold root is dropped. That is a known trade-off: always merging both +// families in the rebuild pass was measured to strip the analytic band of its authority for +// polar events such as 2025-01-05 (the partial band fell back to footprint-sweep-fallback and +// the visibility boundary escaped it by 784 km), degrading at both 1e-4 and 1e-2 deduplication +// tolerances and only passing at 1e-3, so it cannot ship as a fix. +func TestRiseSetCyclicRootsWithFoldToleranceCoversFoldOnlyResiduals(t *testing.T) { + const samples = 720 + // 1.0 处与零相切不变号:符号扫描为空,折点补根必须命中。 + // The residual tangents zero at 1.0 without changing sign, so the sign scan stays empty + // and fold recovery must find it. + foldOnly := func(angle float64) (float64, bool) { + return 0.5 * riseSetAngularDistance(angle, 1.0), true + } + if roots := riseSetSignChangeRoots(samples, foldOnly); len(roots) != 0 { + t.Fatalf("riseSetSignChangeRoots() = %v, want no crossing root", roots) + } + roots := riseSetCyclicRootsWithFoldTolerance(samples, riseSetFoldRootResidualToleranceDeg, foldOnly) + if len(roots) != 1 || riseSetAngularDistance(roots[0], 1.0) > 1e-3 { + t.Fatalf("riseSetCyclicRootsWithFoldTolerance() = %v, want the single fold root near 1", roots) + } + // 折点与普通变号根共存:只返回符号根,折点根按现状丢弃。 + // A fold coexisting with ordinary crossing roots: only the sign roots are returned and the + // fold root is dropped, matching current behaviour. + mixed := func(angle float64) (float64, bool) { + if riseSetAngularDistance(angle, 4.0) < 1.2 { + return riseSetAngularDistance(angle, 4.0) - 0.2, true + } + return 0.5 * riseSetAngularDistance(angle, 1.0), true + } + signRoots := riseSetSignChangeRoots(samples, mixed) + if len(signRoots) != 2 { + t.Fatalf("riseSetSignChangeRoots() = %v, want the two crossing roots", signRoots) + } + roots = riseSetCyclicRootsWithFoldTolerance(samples, riseSetFoldRootResidualToleranceDeg, mixed) + if len(roots) != len(signRoots) { + t.Fatalf("riseSetCyclicRootsWithFoldTolerance() = %v, want only the %d sign roots", + roots, len(signRoots)) + } + for index, root := range roots { + if riseSetAngularDistance(root, signRoots[index]) > 1e-6 { + t.Fatalf("riseSetCyclicRootsWithFoldTolerance() = %v, want the sign roots %v", roots, signRoots) + } + } +} diff --git a/basic/saturn_events.go b/basic/saturn_events.go index 290ddef..263b1f5 100644 --- a/basic/saturn_events.go +++ b/basic/saturn_events.go @@ -172,6 +172,9 @@ func LastSaturnWesternQuadrature(jde float64) float64 { } func saturnRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 { + if !isFiniteFloat(oppositionJD) { + return math.NaN() + } oppositionTT := TD2UT(oppositionJD, true) startTT := oppositionTT endTT := oppositionTT @@ -183,49 +186,98 @@ func saturnRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOppositi endTT = TD2UT(westernQuadratureUT, true) } bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { - return saturnRADerivativeN(jd, 1.0/86400.0, saturnEventSearchN) + return saturnRADerivativeN(jd, stationDerivativeStepDay, saturnEventSearchN) }, func(jd float64) float64 { - return saturnRADerivative(jd, 0.5/86400.0) + return saturnRADerivative(jd, stationDerivativeStepDay) }) return TD2UT(bestJD, false) } func NextSaturnRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := saturnConjunctionFull(jde, 180, 0) date := saturnRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } nextOppositionJD := saturnConjunctionFull(jde, 180, 1) - return saturnRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } + date = saturnRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastSaturnRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := saturnConjunctionFull(jde, 180, 0) date := saturnRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } previousOppositionJD := saturnConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0) - return saturnRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(previousOppositionJD) { + return math.NaN() + } + date = saturnRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } func NextSaturnProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := saturnConjunctionFull(jde, 180, 1) date := saturnRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } followingOppositionJD := saturnConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1) - return saturnRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(followingOppositionJD) { + return math.NaN() + } + date = saturnRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastSaturnProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := saturnConjunctionFull(jde, 180, 1) date := saturnRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } lastOppositionJD := saturnConjunctionFull(jde, 180, 0) - return saturnRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } + date = saturnRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } diff --git a/basic/sidereal_memo.go b/basic/sidereal_memo.go new file mode 100644 index 0000000..897be17 --- /dev/null +++ b/basic/sidereal_memo.go @@ -0,0 +1,108 @@ +package basic + +import ( + "math" + "sync" + "sync/atomic" +) + +// 视恒星时是 UT 的纯函数,但月掩全球路径会在同一条计算链里反复向它求值: +// 地球自转、升落上下文、测地投影各自按自己的调用点重算同一个瞬时。实测单场 Saturn +// 2025-01-05 的请求里,191,517 次求值只对应 51,308 个不同的儒略日(重复距离中位数只有 +// 2 次调用),而每次求值都要完整算一遍 77 项 IAU2000B 章动。 +// +// 这里用一张有界直接映射表把结果记下来:无分配、容量固定(4096 槽 × 24 字节), +// 用 RWMutex 保证 C 共享库被宿主多线程调用时安全。表项记录写入时的 ΔT 世代, +// 因此 astro.SetDeltaT 覆盖之后旧条目自然失效,不会返回陈旧恒星时。 +// +// Apparent sidereal time is a pure function of UT, yet one occultation path query evaluates +// it many times for the same instant from independent code paths (Earth rotation, rise/set +// contexts, geodetic projection). A single Saturn 2025-01-05 request performed 191,517 +// evaluations for only 51,308 distinct Julian days, and each evaluation ran the full +// 77-term IAU2000B nutation. This bounded direct-mapped memo removes that redundancy without +// allocating: a fixed 4096-slot table guarded by an RWMutex, with the ΔT generation stored in +// each entry so an astro.SetDeltaT override invalidates stale values instead of replaying them. +// 4096 槽对单场月掩的 5 万余个不同儒略日而言明显偏小(重复距离中位数只有 2 次调用), +// 这里扩到 16384 槽(16384×24 B = 384 KB,BSS 静态数组,不参与初始化)。 +const siderealMemoBits = 14 + +const siderealMemoSize = 1 << siderealMemoBits + +type siderealMemoEntry struct { + key uint64 + value float64 + generation uint64 +} + +var ( + siderealMemoMu sync.RWMutex + siderealMemoTable [siderealMemoSize]siderealMemoEntry + siderealMemoHits uint64 + siderealMemoMisses uint64 + siderealMemoStores uint64 +) + +// siderealMemoIndex 用高低位混合避免相邻儒略日落在相邻槽位而互相驱逐。 +// siderealMemoIndex mixes high and low bits so adjacent Julian days do not evict each other. +func siderealMemoIndex(jd float64) uint64 { + bits := math.Float64bits(jd) + return (bits ^ (bits >> 29)) & (siderealMemoSize - 1) +} + +// siderealMemoLoad 返回缓存命中值;ΔT 世代不匹配时按未命中处理。 +// siderealMemoLoad returns a cached value; a generation mismatch counts as a miss. +func siderealMemoLoad(jd float64) (float64, bool) { + generation := deltaTGenerationValue() + entry := &siderealMemoTable[siderealMemoIndex(jd)] + siderealMemoMu.RLock() + key, value, entryGeneration := entry.key, entry.value, entry.generation + siderealMemoMu.RUnlock() + if entryGeneration == generation && key == math.Float64bits(jd) { + atomic.AddUint64(&siderealMemoHits, 1) + return value, true + } + atomic.AddUint64(&siderealMemoMisses, 1) + return 0, false +} + +// siderealMemoStore 只在 ΔT 世代未变时写入:世代必须在**求值前**采样(见 siderealMemoGeneration), +// 否则求值期间发生的 SetDeltaTFn 会把旧 ΔT 的结果打上新世代并长期回放。 +// siderealMemoStore writes only while the ΔT generation is unchanged; the generation must be sampled +// before the evaluation, otherwise a SetDeltaTFn during the computation would stamp the old value +// with the new generation and replay it. +func siderealMemoStore(jd, value float64, generation uint64) { + if deltaTGenerationValue() != generation { + return + } + index := siderealMemoIndex(jd) + siderealMemoMu.Lock() + siderealMemoTable[index] = siderealMemoEntry{ + key: math.Float64bits(jd), + value: value, + generation: generation, + } + siderealMemoMu.Unlock() + atomic.AddUint64(&siderealMemoStores, 1) +} + +// siderealMemoGeneration 在求值前采样 ΔT 世代,供 siderealMemoStore 校验。 +func siderealMemoGeneration() uint64 { + return deltaTGenerationValue() +} + +// siderealMemoStats 返回命中/未命中/写入计数,供测试守护记忆表确实生效。 +func siderealMemoStats() (hits, misses, stores uint64) { + return atomic.LoadUint64(&siderealMemoHits), atomic.LoadUint64(&siderealMemoMisses), + atomic.LoadUint64(&siderealMemoStores) +} + +func resetSiderealMemo() { + siderealMemoMu.Lock() + for i := range siderealMemoTable { + siderealMemoTable[i] = siderealMemoEntry{} + } + siderealMemoMu.Unlock() + atomic.StoreUint64(&siderealMemoHits, 0) + atomic.StoreUint64(&siderealMemoMisses, 0) + atomic.StoreUint64(&siderealMemoStores, 0) +} diff --git a/basic/sidereal_memo_test.go b/basic/sidereal_memo_test.go new file mode 100644 index 0000000..c23aa3e --- /dev/null +++ b/basic/sidereal_memo_test.go @@ -0,0 +1,136 @@ +package basic + +import ( + "math" + "sync" + "testing" +) + +// directApparentSiderealTime2006 是记忆化之前的原始表达式,作为逐位对照。 +// directApparentSiderealTime2006 is the pre-memo expression, kept as a bit-exact reference. +func directApparentSiderealTime2006(jd float64) float64 { + return MeanSiderealTime2006(jd) + Nutation2000Bi(jd)*math.Cos(TrueObliquity(jd)*math.Pi/180)/15 +} + +// directApparentSiderealTime1982 是 1982 模型的原始表达式,同样作为逐位对照。 +// directApparentSiderealTime1982 is the pre-refactor 1982 expression, kept as a bit-exact reference. +func directApparentSiderealTime1982(jd float64) float64 { + return MeanSiderealTime1982(jd) + Nutation2000Bi(jd)*math.Cos(TrueObliquity(jd)*math.Pi/180)/15 +} + +func siderealMemoTestTimes() []float64 { + var times []float64 + for _, year := range []int{-720, -100, 0, 1000, 1582, 1900, 2025, 2026, 3000, 5000} { + times = append(times, JDECalc(year, 3, 7.25), JDECalc(year, 9, 20.5), JDECalc(year, 12, 31.75)) + } + return times +} + +func TestApparentSiderealTimeMemoMatchesDirectSeries(t *testing.T) { + times := siderealMemoTestTimes() + firstPass := make([]float64, len(times)) + for index, jd := range times { + got := ApparentSiderealTime2006(jd) + if want := directApparentSiderealTime2006(jd); got != want { + t.Fatalf("jd=%.6f sidereal=%v, want %v", jd, got, want) + } + if got1982, want1982 := ApparentSiderealTime1982(jd), directApparentSiderealTime1982(jd); got1982 != want1982 { + t.Fatalf("jd=%.6f sidereal1982=%v, want %v", jd, got1982, want1982) + } + firstPass[index] = got + } + // 逆序再查一遍:命中记忆表也必须与首次计算逐位一致。 + // Query again in reverse: memo hits must stay bit-identical to the first evaluation. + for index := len(times) - 1; index >= 0; index-- { + if got := ApparentSiderealTime2006(times[index]); got != firstPass[index] { + t.Errorf("jd=%.6f memoized sidereal=%v, want %v", times[index], got, firstPass[index]) + } + } +} + +func TestApparentSiderealTimeMemoInvalidatedByDeltaTOverride(t *testing.T) { + original := GetDeltaTFn() + t.Cleanup(func() { SetDeltaTFn(original) }) + jd := JDECalc(2025, 1, 5.5) + + baseline := ApparentSiderealTime2006(jd) + if repeat := ApparentSiderealTime2006(jd); repeat != baseline { + t.Fatalf("memoized sidereal=%v, want stable %v", repeat, baseline) + } + + SetDeltaTFn(func(date float64, isJDE bool) float64 { return 6000 }) + shifted := ApparentSiderealTime2006(jd) + if shifted == baseline { + t.Fatalf("ΔT override replayed the memoized value %v", shifted) + } + if want := directApparentSiderealTime2006(jd); shifted != want { + t.Errorf("sidereal after ΔT override=%v, want %v", shifted, want) + } + + SetDeltaTFn(DefaultDeltaTv2) + if restored := ApparentSiderealTime2006(jd); restored != baseline { + t.Errorf("sidereal after restoring ΔT=%v, want %v", restored, baseline) + } +} + +func TestApparentSiderealTimeMemoIsRaceFree(t *testing.T) { + base := JDECalc(2025, 1, 5.5) + var wait sync.WaitGroup + for worker := 0; worker < 4; worker++ { + wait.Add(1) + go func(offset int) { + defer wait.Done() + for step := 0; step < 256; step++ { + jd := base + float64(step)*1e-4 + float64(offset)*1e-7 + if got := ApparentSiderealTime2006(jd); got != directApparentSiderealTime2006(jd) { + t.Errorf("jd=%.9f sidereal=%v, want %v", jd, got, directApparentSiderealTime2006(jd)) + return + } + } + }(worker) + } + wait.Wait() +} + +// 记忆表只在 ΔT 世代未变时写入:世代必须在求值前采样,否则求值期间发生的 SetDeltaTFn +// 会把旧 ΔT 的结果打上新世代并长期回放。 +func TestSiderealMemoRejectsStaleGenerationWrite(t *testing.T) { + original := GetDeltaTFn() + defer SetDeltaTFn(original) + resetSiderealMemo() + jd := 2460310.5 + generation := siderealMemoGeneration() + SetDeltaTFn(func(date float64, isJd bool) float64 { return 200 }) + siderealMemoStore(jd, 12345.0, generation) + if _, ok := siderealMemoLoad(jd); ok { + t.Fatalf("stale-generation write was accepted") + } + // 世代未变时正常写入并命中。 + current := siderealMemoGeneration() + siderealMemoStore(jd, 12345.0, current) + value, ok := siderealMemoLoad(jd) + if !ok || value != 12345.0 { + t.Fatalf("current-generation write missed: value=%v ok=%v", value, ok) + } +} + +// 同一批瞬时重复求值必须命中记忆表(否则扩表/世代逻辑等于没生效)。 +func TestSiderealMemoHitsRepeatedInstants(t *testing.T) { + resetSiderealMemo() + const distinct = 8192 + for offset := 0; offset < distinct; offset++ { + _ = ApparentSiderealTime2006(2460310.5 + float64(offset)*0.25) + } + hitsBefore, missesBefore, _ := siderealMemoStats() + if missesBefore < distinct || hitsBefore != 0 { + t.Fatalf("first pass should miss every distinct instant: hits=%d misses=%d", hitsBefore, missesBefore) + } + for offset := 0; offset < distinct; offset++ { + _ = ApparentSiderealTime2006(2460310.5 + float64(offset)*0.25) + } + hits, misses, _ := siderealMemoStats() + if hits-hitsBefore < distinct*9/10 { + t.Fatalf("second pass hit rate too low: hits=%d misses=%d (want >= 90%% of %d)", + hits-hitsBefore, misses-missesBefore, distinct) + } +} diff --git a/basic/solar_eclipse.go b/basic/solar_eclipse.go index 8e1c40c..f2fc1d3 100644 --- a/basic/solar_eclipse.go +++ b/basic/solar_eclipse.go @@ -12,7 +12,7 @@ const ( SolarEclipseModelNASABulletinSplitK SolarEclipseRadiusModel = "nasa_bulletin_split_k" ) -// SolarEclipseType 表示整场日食的全局食型。 +// SolarEclipseType 整场日食的全局食型。 type SolarEclipseType string const ( @@ -63,6 +63,11 @@ type SolarEclipseResult struct { Magnitude float64 // Gamma 是月影轴到地心的有符号最小距离,单位为地球赤道半径。 Gamma float64 + // CentralDurationDays 是食甚点的中心食持续时间,单位为日;没有中心食时为 0。 + // 这是日食目录(如 NASA「Central Dur.」)采用的口径:食甚点的中心食时长。 + // CentralDurationDays is the central-phase duration at the greatest eclipse, + // in days, and 0 when the event has no central phase. + CentralDurationDays float64 // PathWidthKM 是食甚点处中心食带宽度。非中心食时为 0。 PathWidthKM float64 @@ -100,11 +105,37 @@ type solarEclipseSolver struct { model SolarEclipseRadiusModel params solarEclipseModelParameters + localStateContextCache map[uint64]localSolarEclipseStateContext + localEphemeris *solarEclipseLocalEphemeris + // deltaTSeconds 是调用方显式给出的 ΔT(秒);NaN 表示未覆盖,用进程级模型。 + // 只影响地球自转相位(轴的 gst),不改变任何 TT 时刻。 + deltaTSeconds float64 + besselGeometryCache map[uint64]solarEclipseBesselGeometryCacheEntry + besselCandidateCache map[uint64]solarEclipseBesselGeometryCacheEntry + exactCentralContact bool + meanSunMoonDistance float64 penumbraConeTangent float64 umbraConeTangent float64 } +const solarEclipseBesselGeometryCacheMaximumEntries = movingDiskEventCacheMaximumEntries + +// solarEclipseBesselGeometryCacheEntry keeps exact and candidate geometry in +// separate maps. Candidate geometry is interpolated and is only suitable for +// coarse scans; mixing it with exact geometry would silently reduce contact +// and topology accuracy. +type solarEclipseBesselGeometryCacheEntry struct { + // generation 记录写入时的 ΔT 世代:轴里的 gst 由 ΔT 决定,ΔT 覆盖后条目必须失效。 + // generation is the ΔT generation at write time: the axis carries a ΔT-dependent + // gst, so overriding ΔT has to invalidate the entry. + generation uint64 + moon [3]float64 + axis solarEclipseAxis + sun [3]float64 + valid bool +} + type solarEclipseFeature struct { greatestEclipseJDE float64 greatestLongitude float64 @@ -132,19 +163,31 @@ type solarEclipseLineIntersection struct { const ( solarEclipseEarthEquatorialRadiusKM = 6378.1366 - solarEclipseEarthPolarRatio = 0.99664719 - solarEclipseEarthPolarRatioSquared = solarEclipseEarthPolarRatio * solarEclipseEarthPolarRatio - solarEclipseAstronomicalUnitKM = 1.49597870691e8 + // 赤道自转线速度,用于把 ΔT 误差换算成地面横移(见 DeltaTGroundShiftKM)。 + // Equatorial rotation speed, used to convert a ΔT error into ground displacement. + solarEclipseEarthEquatorialRotationKMPerSecond = 0.4651 + solarEclipseEarthPolarRatio = 0.99664719 + solarEclipseEarthPolarRatioSquared = solarEclipseEarthPolarRatio * solarEclipseEarthPolarRatio + solarEclipseAstronomicalUnitKM = 1.49597870691e8 // IAU Single-K 对所有接触统一使用 0.2725076; // NASA bulletin Split-K 对半影仍使用 0.2725076,对本影/反本影使用 0.2722810。 solarEclipseSolarRadiusRatio = 109.1222 solarEclipsePenumbralK = 0.2725076 solarEclipseUmbralK = 0.2722810 + // SolarEclipsePenumbralK 与 SolarEclipseUmbralK 是月面半径与地球赤道半径之比, + // 即 NASA 星历表里的 k1(半影)与 k2(本影/反本影);IAU Single-K 两者都用 k1。 + // SolarEclipsePenumbralK and SolarEclipseUmbralK are the lunar-to-terrestrial radius ratios + // published as k1 (penumbra) and k2 (umbra/antumbra); IAU Single-K uses k1 for both. + SolarEclipsePenumbralK = solarEclipsePenumbralK + SolarEclipseUmbralK = solarEclipseUmbralK - solarEclipseNodeCount = 7 - solarEclipseNodeStepDays = 0.04 - solarEclipseMoonLonAberrRad = -3.4e-6 + solarEclipseNodeCount = 7 + solarEclipseNodeStepDays = 0.04 + solarEclipseMoonLonAberrRad = -3.4e-6 + solarEclipseAxisContactInitialStepDays = 1.0 / 86400.0 + solarEclipseAxisContactMaximumStepDays = 30.0 / 1440.0 + solarEclipseAxisContactToleranceDays = 1e-9 // 这两个系数沿用经典贝塞尔近似中的极区有效半径经验值。 solarEclipseNonCentralLimit = 0.9972 @@ -169,8 +212,21 @@ func SolarEclipseNASABulletinSplitK(seedJDE float64) SolarEclipseResult { } func solarEclipse(seedJDE float64, model SolarEclipseRadiusModel) SolarEclipseResult { + return solarEclipseWithDeltaT(seedJDE, model, 0) +} + +func solarEclipseWithDeltaT( + seedJDE float64, + model SolarEclipseRadiusModel, + deltaTSeconds float64, +) SolarEclipseResult { newMoonJDE := CalcMoonSHByJDE(seedJDE, 0) - solver := newSolarEclipseSolver(newMoonJDE, model) + solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(deltaTSeconds) + return solver.eclipseResult() +} + +func (solver solarEclipseSolver) eclipseResult() SolarEclipseResult { + model := solver.model feature := solver.feature() result := SolarEclipseResult{ @@ -213,6 +269,7 @@ func solarEclipse(seedJDE float64, model SolarEclipseRadiusModel) SolarEclipseRe result.HasCentral = true result.CentralBeginOnEarth = feature.centralBeginJDE result.CentralEndOnEarth = feature.centralEndJDE + result.CentralDurationDays = solver.greatestCentralDuration(result) } switch result.Type { @@ -229,6 +286,18 @@ func solarEclipse(seedJDE float64, model SolarEclipseRadiusModel) SolarEclipseRe return result } +// greatestCentralDuration 在食甚点解一次站心中心食并返回中心相时长(日);没有中心相时为 0。 +// greatestCentralDuration solves the local eclipse at the greatest eclipse point and +// returns its central-phase duration in days. +func (solver solarEclipseSolver) greatestCentralDuration(result SolarEclipseResult) float64 { + if !result.HasCentral || result.GreatestEclipse <= 0 { + return 0 + } + return solver.centralPhaseDurationDaysAt( + result.GreatestEclipse, result.GreatestLongitude, result.GreatestLatitude, + ) +} + func newSolarEclipseSolver(newMoonJDE float64, model SolarEclipseRadiusModel) solarEclipseSolver { params := solarEclipseModelParameters{ penumbralK: solarEclipsePenumbralK, @@ -246,22 +315,67 @@ func newSolarEclipseSolver(newMoonJDE float64, model SolarEclipseRadiusModel) so meanSunMoonDistance := ((firstSun[2] + lastSun[2]) - (firstMoon[2] + lastMoon[2])) / 2 / solarEclipseEarthEquatorialRadiusKM return solarEclipseSolver{ - newMoonJDE: newMoonJDE, - model: model, - params: params, - meanSunMoonDistance: meanSunMoonDistance, - penumbraConeTangent: (solarEclipseSolarRadiusRatio + params.penumbralK) / meanSunMoonDistance, - umbraConeTangent: (solarEclipseSolarRadiusRatio - params.umbralK) / meanSunMoonDistance, + newMoonJDE: newMoonJDE, + model: model, + params: params, + deltaTSeconds: math.NaN(), + localStateContextCache: make(map[uint64]localSolarEclipseStateContext), + besselGeometryCache: make(map[uint64]solarEclipseBesselGeometryCacheEntry), + besselCandidateCache: make(map[uint64]solarEclipseBesselGeometryCacheEntry), + meanSunMoonDistance: meanSunMoonDistance, + penumbraConeTangent: (solarEclipseSolarRadiusRatio + params.penumbralK) / meanSunMoonDistance, + umbraConeTangent: (solarEclipseSolarRadiusRatio - params.umbralK) / meanSunMoonDistance, } } +// withDeltaTSeconds 固定本求解器使用的 ΔT(秒),非正值表示回到进程级模型。覆盖会改变 +// 轴里的 gst,因此所有按精确 float 位键控的几何缓存必须同时作废。 +func (solver solarEclipseSolver) withDeltaTSeconds(deltaTSeconds float64) solarEclipseSolver { + if deltaTSeconds <= 0 || math.IsNaN(deltaTSeconds) || math.IsInf(deltaTSeconds, 0) { + deltaTSeconds = math.NaN() + } + solver.deltaTSeconds = deltaTSeconds + solver.besselGeometryCache = make(map[uint64]solarEclipseBesselGeometryCacheEntry) + solver.besselCandidateCache = make(map[uint64]solarEclipseBesselGeometryCacheEntry) + solver.localStateContextCache = make(map[uint64]localSolarEclipseStateContext) + return solver +} + +// effectiveDeltaTSeconds 返回本求解器在某 TT 时刻实际使用的 ΔT(秒)。 +func (solver solarEclipseSolver) effectiveDeltaTSeconds(jd float64) float64 { + if math.IsNaN(solver.deltaTSeconds) { + return DeltaT(jd, true) + } + return solver.deltaTSeconds +} + +// siderealTimeAt 返回某 TT 时刻的视恒星时(弧度),ΔT 覆盖时同样生效。 +func (solver solarEclipseSolver) siderealTimeAt(jd float64) float64 { + utJDE := TD2UT(jd, false) + if !math.IsNaN(solver.deltaTSeconds) { + utJDE = jd - solver.deltaTSeconds/86400 + } + return ApparentSiderealTime(utJDE) * 15 * rad +} + +// withLocalEphemeris prepares the immutable event-local interpolator used by +// coarse candidate scans. The exact ephemeris remains the fallback outside its +// bounded window and is used by all contact and topology refinements. +func (solver solarEclipseSolver) withLocalEphemeris() solarEclipseSolver { + if solver.localEphemeris == nil { + solver.localEphemeris = newSolarEclipseLocalEphemeris(solver.newMoonJDE) + } + return solver +} + func (solver solarEclipseSolver) feature() solarEclipseFeature { const finiteDifferenceStep = 0.04 + candidateSolver := solver.withLocalEphemeris() jd := solver.newMoonJDE - before := solver.besselMoonAt(jd - finiteDifferenceStep) - center := solver.besselMoonAt(jd) - after := solver.besselMoonAt(jd + finiteDifferenceStep) + before := candidateSolver.besselMoonCandidateAt(jd - finiteDifferenceStep) + center := candidateSolver.besselMoonCandidateAt(jd) + after := candidateSolver.besselMoonCandidateAt(jd + finiteDifferenceStep) vx := (after[0] - before[0]) / (2 * finiteDifferenceStep) vy := (after[1] - before[1]) / (2 * finiteDifferenceStep) @@ -271,9 +385,15 @@ func (solver solarEclipseSolver) feature() solarEclipseFeature { t0 := -(center[0]*vx + center[1]*vy) / speedSquared greatestEclipseJDE := jd + t0 - xc := center[0] + vx*t0 - yc := center[1] + vy*t0 - zc := center[2] + vz*t0 - 1.37*t0*t0 + // The three-node velocity fit locates greatest eclipse accurately, but its + // linearly extrapolated coordinates can miss the true Bessel position by + // tens of kilometres in a grazing non-central event. Re-evaluate the + // ephemeris at the solved time before deriving surface coordinates and + // shadow radii so markers and path geometry use the same state. + greatestMoon := solver.besselMoonAt(greatestEclipseJDE) + xc := greatestMoon[0] + yc := greatestMoon[1] + zc := greatestMoon[2] gamma := (vx*center[1] - vy*center[0]) / speed minimumDistance := math.Abs(gamma) axis := solver.besselAxisAt(greatestEclipseJDE) @@ -379,14 +499,120 @@ func (solver solarEclipseSolver) feature() solarEclipseFeature { _, _, feature.partialEndJDE, _ = solver.quickContactAt(partialEndParam+jd, vx, vy, true) } - if typeCode != "N" && typeCode != "P" { + if axisIntersection.valid && typeCode != "N" && typeCode != "P" { _, _, feature.centralBeginJDE, _ = solver.quickContactAt(centralStartParam+jd, vx, vy, false) _, _, feature.centralEndJDE, _ = solver.quickContactAt(centralEndParam+jd, vx, vy, false) + if refined, ok := solver.centralAxisContactJDE(feature.centralBeginJDE, greatestEclipseJDE, -1); ok { + feature.centralBeginJDE = refined + } + if refined, ok := solver.centralAxisContactJDE(feature.centralEndJDE, greatestEclipseJDE, 1); ok { + feature.centralEndJDE = refined + } } return feature } +func (solver solarEclipseSolver) centralAxisContactJDE( + approximateJDE, greatestJDE, direction float64, +) (float64, bool) { + if !finite(approximateJDE) || !finite(greatestJDE) || direction == 0 { + return 0, false + } + insideJDE, insideResidual := approximateJDE, solver.centralAxisEarthDiscriminant(approximateJDE) + if !finite(insideResidual) || insideResidual < 0 { + insideJDE = greatestJDE + insideResidual = solver.centralAxisEarthDiscriminant(insideJDE) + if !finite(insideResidual) || insideResidual < 0 { + return 0, false + } + } + + outsideJDE, outsideResidual := 0.0, 0.0 + foundOutside := false + for step := solarEclipseAxisContactInitialStepDays; step <= solarEclipseAxisContactMaximumStepDays; step *= 2 { + candidateJDE := approximateJDE + direction*step + candidateResidual := solver.centralAxisEarthDiscriminant(candidateJDE) + if !finite(candidateResidual) { + continue + } + if candidateResidual <= 0 { + outsideJDE, outsideResidual = candidateJDE, candidateResidual + foundOutside = true + break + } + insideJDE, insideResidual = candidateJDE, candidateResidual + } + if !foundOutside { + return 0, false + } + + for iteration := 0; iteration < 24 && math.Abs(outsideJDE-insideJDE) > solarEclipseAxisContactToleranceDays; iteration++ { + candidateJDE := (insideJDE + outsideJDE) / 2 + denominator := insideResidual - outsideResidual + if denominator != 0 { + fraction := insideResidual / denominator + if fraction > 0.1 && fraction < 0.9 { + candidateJDE = insideJDE + fraction*(outsideJDE-insideJDE) + } + } + candidateResidual := solver.centralAxisEarthDiscriminant(candidateJDE) + if !finite(candidateResidual) { + return 0, false + } + if candidateResidual >= 0 { + insideJDE, insideResidual = candidateJDE, candidateResidual + } else { + outsideJDE, outsideResidual = candidateJDE, candidateResidual + } + } + return (insideJDE + outsideJDE) / 2, true +} + +func (solver solarEclipseSolver) centralAxisEarthDiscriminant(jd float64) float64 { + moon, axis, _ := solver.besselGeometryAt(jd) + return solarEclipseLineEllipsoidDiscriminant( + moon[0], moon[1], 2, + moon[0], moon[1], 0, + solarEclipseEarthPolarRatio, 1, axis, + ) +} + +func (solver solarEclipseSolver) centralAxisContactPointAt(jd float64) (SolarEclipsePathPoint, bool) { + moon, axis, _ := solver.besselGeometryAt(jd) + cosTilt, sinTilt := math.Cos(axis.tilt), math.Sin(axis.tilt) + x1 := moon[0] + y1 := cosTilt*moon[1] - 2*sinTilt + z1 := sinTilt*moon[1] + 2*cosTilt + x2 := moon[0] + y2 := cosTilt * moon[1] + z2 := sinTilt * moon[1] + dx, dy, dz := x2-x1, y2-y1, z2-z1 + polarRatioSquared := solarEclipseEarthPolarRatioSquared + a := dx*dx + dy*dy + dz*dz/polarRatioSquared + if !finite(a) || a <= 0 { + return SolarEclipsePathPoint{}, false + } + b := x1*dx + y1*dy + z1*dz/polarRatioSquared + t := -b / a + intersection := solarEclipseLineIntersection{ + valid: true, + x: x1 + dx*t, + y: y1 + dy*t, + z: z1 + dz*t, + } + longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) + if !finite(longitude) || !finite(latitude) { + return SolarEclipsePathPoint{}, false + } + return SolarEclipsePathPoint{ + JDE: jd, + Longitude: longitude, + Latitude: latitude, + SunAltitude: solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) / rad, + }, true +} + func (solver solarEclipseSolver) quickContactAt(jd, dx, dy float64, penumbral bool) (float64, float64, float64, bool) { moon := solver.besselMoonAt(jd) radii := solver.shadowRadiiAt(moon[2]) @@ -431,11 +657,28 @@ func (solver solarEclipseSolver) shadowRadiiAt(moonBesselZ float64) solarEclipse func (solver solarEclipseSolver) besselAxisAt(jd float64) solarEclipseAxis { sun, moon := solarEclipseSunMoonEquatorial(jd) + return solarEclipseBesselAxisFromEquatorialWithDeltaT( + jd, sun, moon, solver.effectiveDeltaTSeconds(jd), + ) +} + +func solarEclipseBesselAxisFromEquatorial(jd float64, sun, moon [3]float64) solarEclipseAxis { + return solarEclipseBesselAxisFromEquatorialWithDeltaT(jd, sun, moon, DeltaT(jd, true)) +} + +// solarEclipseBesselAxisFromEquatorialWithDeltaT 用显式 ΔT 构造贝塞尔轴:TT 时刻保持 +// 不变,ΔT 只决定地球自转相位(恒星时),因此同一 TT 在不同 ΔT 下得到的地面足迹会 +// 沿经度平移,这正是"ΔT 只影响自转、不影响几何时刻"的实现点。 +// solarEclipseBesselAxisFromEquatorialWithDeltaT builds the Besselian axis with an +// explicit ΔT: the TT instant is untouched and ΔT only sets Earth rotation. +func solarEclipseBesselAxisFromEquatorialWithDeltaT( + jd float64, sun, moon [3]float64, deltaTSeconds float64, +) solarEclipseAxis { sunXYZ := solarEclipseLLRToXYZ(sun[0], sun[1], sun[2]) moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2]) axis := solarEclipseXYZToLLR(sunXYZ[0]-moonXYZ[0], sunXYZ[1]-moonXYZ[1], sunXYZ[2]-moonXYZ[2]) - utJDE := TD2UT(jd, false) + utJDE := jd - deltaTSeconds/86400 return solarEclipseAxis{ rightAscension: solarEclipseNormalizeRadians(math.Pi/2 + axis[0]), tilt: math.Pi/2 - axis[1], @@ -444,9 +687,83 @@ func (solver solarEclipseSolver) besselAxisAt(jd float64) solarEclipseAxis { } func (solver solarEclipseSolver) besselMoonAt(jd float64) [3]float64 { - _, moon := solarEclipseSunMoonEquatorial(jd) - axis := solver.besselAxisAt(jd) + moon, _, _ := solver.besselGeometryAt(jd) + return moon +} +func (solver solarEclipseSolver) besselMoonCandidateAt(jd float64) [3]float64 { + moon, _, _, ok := solver.besselGeometryCandidateAt(jd) + if !ok { + return solver.besselMoonAt(jd) + } + return moon +} + +func (solver solarEclipseSolver) besselGeometryAt(jd float64) ([3]float64, solarEclipseAxis, [3]float64) { + key := math.Float64bits(jd) + // 命中要求 ΔT 世代一致:轴里的 gst 依赖 ΔT,SetDeltaTFn 之后旧条目必须视为未命中。 + // A hit requires the same ΔT generation: the cached axis carries a ΔT-dependent gst, + // so entries written before a SetDeltaTFn override must count as misses. + if entry, ok := solver.besselGeometryCache[key]; ok && entry.generation == deltaTGenerationValue() { + return entry.moon, entry.axis, entry.sun + } + sun, moon := solarEclipseSunMoonEquatorial(jd) + axis := solarEclipseBesselAxisFromEquatorialWithDeltaT( + jd, sun, moon, solver.effectiveDeltaTSeconds(jd), + ) + geometry := solarEclipseBesselGeometryCacheEntry{ + moon: solarEclipseBesselMoonFromEquatorial(moon, axis), + axis: axis, + sun: sun, + valid: true, + } + storeSolarEclipseBesselGeometry(solver.besselGeometryCache, key, geometry) + return geometry.moon, geometry.axis, geometry.sun +} + +func (solver solarEclipseSolver) besselGeometryCandidateAt(jd float64) ([3]float64, solarEclipseAxis, [3]float64, bool) { + key := math.Float64bits(jd) + if entry, ok := solver.besselCandidateCache[key]; ok && entry.generation == deltaTGenerationValue() { + return entry.moon, entry.axis, entry.sun, entry.valid + } + if solver.localEphemeris == nil { + return [3]float64{}, solarEclipseAxis{}, [3]float64{}, false + } + sun, moon, ok := solver.localEphemeris.equatorialAt(jd) + if !ok { + return [3]float64{}, solarEclipseAxis{}, [3]float64{}, false + } + axis := solarEclipseBesselAxisFromEquatorialWithDeltaT( + jd, sun, moon, solver.effectiveDeltaTSeconds(jd), + ) + geometry := solarEclipseBesselGeometryCacheEntry{ + moon: solarEclipseBesselMoonFromEquatorial(moon, axis), + axis: axis, + sun: sun, + valid: true, + } + storeSolarEclipseBesselGeometry(solver.besselCandidateCache, key, geometry) + return geometry.moon, geometry.axis, geometry.sun, geometry.valid +} + +func storeSolarEclipseBesselGeometry( + cache map[uint64]solarEclipseBesselGeometryCacheEntry, + key uint64, + entry solarEclipseBesselGeometryCacheEntry, +) { + if cache == nil { + return + } + if _, exists := cache[key]; !exists && len(cache) >= solarEclipseBesselGeometryCacheMaximumEntries { + for cachedKey := range cache { + delete(cache, cachedKey) + } + } + entry.generation = deltaTGenerationValue() + cache[key] = entry +} + +func solarEclipseBesselMoonFromEquatorial(moon [3]float64, axis solarEclipseAxis) [3]float64 { rotated := solarEclipseRotateLLR( solarEclipseNormalizeSignedRadians(moon[0]-axis.rightAscension), moon[1], @@ -464,13 +781,16 @@ func (solver solarEclipseSolver) besselMoonAt(jd float64) [3]float64 { func solarEclipseSunMoonEquatorial(jd float64) ([3]float64, [3]float64) { julianCentury := (jd - 2451545.0) / 36525.0 - obliquity := EclipticObliquity(jd, true) * rad + nutationLongitude, nutationObliquity := Nutation2000B(jd) + obliquity := (Obliquity1980(jd) + nutationObliquity) * rad - sunLongitude := HSunApparentLo(jd) * rad + // Share the full-series distance and nutation for this single TT. + sunDistanceAU := EarthAway(jd) + sunLongitude := (HSunTrueLoN(jd, -1) + nutationLongitude - 20.49552/sunDistanceAU/3600) * rad sunLatitude := HSunTrueBo(jd) * rad - sunDistance := EarthAway(jd) * solarEclipseAstronomicalUnitKM + sunDistance := sunDistanceAU * solarEclipseAstronomicalUnitKM - moonLongitude := solarEclipseNormalizeRadians(HMoonApparentLo(jd)*rad + solarEclipseMoonLonAberrRad) + moonLongitude := solarEclipseNormalizeRadians((HMoonTrueLoN(jd, -1)+nutationLongitude)*rad + solarEclipseMoonLonAberrRad) moonLatitude := HMoonTrueBo(jd)*rad + moonLatitudeAberrationRad(julianCentury) moonDistance := HMoonAway(jd) @@ -483,6 +803,10 @@ func solarEclipseSunMoonEquatorial(jd float64) ([3]float64, [3]float64) { func solarEclipseSunAltitudeAtGreatest(jd, lonDeg, latDeg, gst float64) float64 { sun, _ := solarEclipseSunMoonEquatorial(jd) + return solarEclipseSunAltitudeFromEquatorial(sun, lonDeg, latDeg, gst) +} + +func solarEclipseSunAltitudeFromEquatorial(sun [3]float64, lonDeg, latDeg, gst float64) float64 { horizon := solarEclipseEquatorialToHorizontal(sun[0], sun[1], sun[2], lonDeg*rad, latDeg*rad, gst) return horizon[1] } diff --git a/basic/solar_eclipse_11360601_regression_test.go b/basic/solar_eclipse_11360601_regression_test.go new file mode 100644 index 0000000..d44f32c --- /dev/null +++ b/basic/solar_eclipse_11360601_regression_test.go @@ -0,0 +1,130 @@ +package basic + +import ( + "testing" + + "b612.me/astro/internal/geodata" +) + +// TestSolarEclipseGrazingClosureRootsAreRecovered pins the events whose +// greatest-at-horizon closure arcs the analytic seedings miss: a grazing +// closure root can sit outside the sampled horizon branches, and the local +// classification then refuses the solved root. Recovering both from the sampled +// sweep restores the analytic critical envelope, which covers the visible +// annulus better than the sampled union that used to replace it. +func TestSolarEclipseGrazingClosureRootsAreRecovered(t *testing.T) { + for _, date := range [][3]int{{1136, 6, 1}, {-1480, 12, 27}, {5705, 6, 17}} { + seed := JDECalc(date[0], date[1], float64(date[2])) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }) + if result.Eclipse.Centrality != SolarEclipseCentralTwoLimits { + t.Fatalf("%04d-%02d-%02d centrality=%s, want two limits", + date[0], date[1], date[2], result.Eclipse.Centrality) + } + if len(result.CentralBandHorizonClosures) != 2 { + t.Fatalf("%04d-%02d-%02d horizon closures=%d, want two", + date[0], date[1], date[2], len(result.CentralBandHorizonClosures)) + } + if result.CentralBandSampled { + t.Fatalf("%04d-%02d-%02d fell back to the sampled footprint union", + date[0], date[1], date[2]) + } + } +} + +// TestSolarEclipseGrazingEventsWithoutClosuresStaySampled pins the other half +// of the criterion: a two-limit grazing event whose caps are not bounded by the +// greatest-at-horizon condition has no closure arc at all, and its band must +// stay a valid closed reconstruction instead of silently disappearing. The +// reasons are measured, not assumed: 4862-09-28 ends 40 km inside the horizon +// (+0.36 degrees at the cap, so the umbral rim bounds it) and 1552-07-21 has its +// boundary running along the horizon (+0.004 then -0.000 degrees), which makes +// the arc degenerate. +func TestSolarEclipseGrazingEventsWithoutClosuresStaySampled(t *testing.T) { + for _, date := range [][3]int{{4862, 9, 28}, {1552, 7, 21}} { + seed := JDECalc(date[0], date[1], float64(date[2])) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }) + if result.Eclipse.Centrality != SolarEclipseCentralTwoLimits { + t.Fatalf("%04d-%02d-%02d centrality=%s, want two limits", + date[0], date[1], date[2], result.Eclipse.Centrality) + } + if len(result.CentralBandHorizonClosures) != 0 { + t.Fatalf("%04d-%02d-%02d closures=%d, expected none", + date[0], date[1], date[2], len(result.CentralBandHorizonClosures)) + } + if len(result.CentralBandSegments) == 0 { + t.Fatalf("%04d-%02d-%02d exported no central band", date[0], date[1], date[2]) + } + ring := result.CentralBandSegments[0] + if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 { + t.Fatalf("%04d-%02d-%02d central band is not closed", date[0], date[1], date[2]) + } + } +} + +// TestSolarEclipse11360601GrazingAnnularBandContainsSweep pins the grazing polar +// annular event whose shadow axis runs almost parallel to the surface. Its +// instantaneous antumbral footprint is a long spindle: the rim is cut by the +// horizon over the contact intervals and fully closed over the middle of the +// path. The analytic envelope has no horizon roots there, and the open-arc +// sweep drops the closed middle samples, so the band used to be exported as a +// chordal ribbon hundreds of kilometres smaller than the umbra it describes. +func TestSolarEclipse11360601GrazingAnnularBandContainsSweep(t *testing.T) { + result := SolarEclipsePartialFootprints( + JDECalc(1136, 6, 1), + SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }, + ) + if result.Eclipse.Type != SolarEclipseAnnular || + result.Eclipse.Centrality != SolarEclipseCentralTwoLimits { + t.Fatalf("type=%s centrality=%s, want a two-limit annular eclipse", + result.Eclipse.Type, result.Eclipse.Centrality) + } + // The band must be the analytic critical envelope, which needs both + // greatest-at-horizon closure arcs. Recovering the second root from the + // sampled sweep is what restored them; without it the event silently falls + // back to a sampled union that leaves 0.3% of the visible annulus uncovered. + if len(result.CentralBandHorizonClosures) != 2 { + t.Fatalf("horizon closures=%d, want two", len(result.CentralBandHorizonClosures)) + } + if result.CentralBandSampled { + t.Fatal("central band fell back to the sampled footprint union") + } + if len(result.CentralBandSegments) != 1 { + t.Fatalf("central-band segments=%d, want one closed band", len(result.CentralBandSegments)) + } + ring := result.CentralBandSegments[0] + if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 { + t.Fatal("central-band envelope is not closed") + } + if !solarEclipseBandContainsFootprintsWithinKM( + result.CentralBandSegments, result.CentralBandFootprints, + solarEclipseCentralBandUnionContainmentToleranceKM, + ) { + t.Fatal("central band does not contain the sampled umbral footprints") + } + // The spindle reaches the poleward tip far above the center-line interval + // the old ribbon covered; a point on that tip must stay inside the band. + for _, probe := range []SolarEclipsePathPoint{ + {Longitude: 120, Latitude: 73}, + {Longitude: 125, Latitude: 71}, + } { + matrix := [][]geodata.GeoPoint{geodataRingFromPath(ring)} + point := []geodata.GeoPoint{{Longitude: probe.Longitude, Latitude: probe.Latitude}} + if !geodata.SphericalPolygonsContainPoints(matrix, point)[0] { + t.Fatalf("central band misses (%g,%g)", probe.Longitude, probe.Latitude) + } + } +} + +func geodataRingFromPath(points []SolarEclipsePathPoint) []geodata.GeoPoint { + ring := make([]geodata.GeoPoint, len(points)) + for index, point := range points { + ring[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + return ring +} diff --git a/basic/solar_eclipse_15001121_regression_test.go b/basic/solar_eclipse_15001121_regression_test.go new file mode 100644 index 0000000..34e3972 --- /dev/null +++ b/basic/solar_eclipse_15001121_regression_test.go @@ -0,0 +1,20 @@ +package basic + +import "testing" + +func TestSolarEclipse15001121ShallowTotalBandCloses(t *testing.T) { + result := SolarEclipsePartialFootprints( + JDECalc(1500, 11, 21), + SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440.0, BoundaryPoints: 96}, + ) + if result.Eclipse.Type != SolarEclipseTotal || result.Eclipse.Centrality != SolarEclipseCentralTwoLimits { + t.Fatalf("type=%s centrality=%s, want two-limit total eclipse", result.Eclipse.Type, result.Eclipse.Centrality) + } + if len(result.CentralBandSegments) != 1 { + t.Logf("footprints=%d closures=%d", len(result.CentralBandFootprints), len(result.CentralBandHorizonClosures)) + t.Fatalf("central-band segments=%d, want one closed envelope", len(result.CentralBandSegments)) + } + if len(result.CentralBandHorizonClosures) != 2 { + t.Fatalf("horizon closures=%d, want two", len(result.CentralBandHorizonClosures)) + } +} diff --git a/basic/solar_eclipse_18741010_regression_test.go b/basic/solar_eclipse_18741010_regression_test.go new file mode 100644 index 0000000..936fdeb --- /dev/null +++ b/basic/solar_eclipse_18741010_regression_test.go @@ -0,0 +1,45 @@ +package basic + +import ( + "testing" + + "b612.me/astro/internal/geodata" +) + +// TestSolarEclipse18741010GrazingAnnularBandContainsSweep pins the other end of +// the grazing annular family: a one-limit path whose analytic sweep returns a +// band that still terminates 48 km inside its own sampled umbra. The band must +// be rebuilt from those samples instead of being exported as the short ribbon. +func TestSolarEclipse18741010GrazingAnnularBandContainsSweep(t *testing.T) { + result := SolarEclipsePartialFootprints( + JDECalc(1874, 10, 10), + SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }, + ) + if result.Eclipse.Type != SolarEclipseAnnular || + result.Eclipse.Centrality != SolarEclipseCentralOneLimit { + t.Fatalf("type=%s centrality=%s, want a one-limit annular eclipse", + result.Eclipse.Type, result.Eclipse.Centrality) + } + if len(result.CentralBandSegments) != 1 { + t.Fatalf("central-band segments=%d, want one closed band", len(result.CentralBandSegments)) + } + ring := result.CentralBandSegments[0] + if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 { + t.Fatal("central-band envelope is not closed") + } + if !solarEclipseBandContainsFootprintsWithinKM( + result.CentralBandSegments, result.CentralBandFootprints, + solarEclipseCentralBandUnionContainmentToleranceKM, + ) { + t.Fatal("central band does not contain the sampled umbral footprints") + } + // A station with two minutes of annular phase, 14 degrees above the horizon, + // used to fall outside the exported band by more than 400 km. + matrix := [][]geodata.GeoPoint{geodataRingFromPath(ring)} + point := []geodata.GeoPoint{{Longitude: 60.5, Latitude: 57.5}} + if !geodata.SphericalPolygonsContainPoints(matrix, point)[0] { + t.Fatal("central band misses the visible annular region at (60.5,57.5)") + } +} diff --git a/basic/solar_eclipse_43290612_regression_test.go b/basic/solar_eclipse_43290612_regression_test.go new file mode 100644 index 0000000..8290703 --- /dev/null +++ b/basic/solar_eclipse_43290612_regression_test.go @@ -0,0 +1,23 @@ +package basic + +import "testing" + +func TestSolarEclipse43290612PolarAnnularCentralBandCloses(t *testing.T) { + result := SolarEclipsePartialFootprints( + JDECalc(4329, 6, 12), + SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440.0, BoundaryPoints: 96}, + ) + if result.Eclipse.Type != SolarEclipseAnnular || result.Eclipse.Centrality != SolarEclipseCentralTwoLimits { + t.Fatalf("type=%s centrality=%s, want two-limit annular eclipse", result.Eclipse.Type, result.Eclipse.Centrality) + } + if len(result.CentralBandHorizonClosures) != 2 { + t.Fatalf("horizon closures=%d, want two", len(result.CentralBandHorizonClosures)) + } + if len(result.CentralBandSegments) != 1 { + t.Fatalf("central-band segments=%d, want one closed envelope", len(result.CentralBandSegments)) + } + ring := result.CentralBandSegments[0] + if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 { + t.Fatal("central-band envelope is not closed") + } +} diff --git a/basic/solar_eclipse_band.go b/basic/solar_eclipse_band.go new file mode 100644 index 0000000..f34575a --- /dev/null +++ b/basic/solar_eclipse_band.go @@ -0,0 +1,1961 @@ +package basic + +import ( + "math" + "sort" + + "b612.me/astro/internal/geodata" +) + +type solarEclipseCentralBandSweepSample struct { + jde float64 + envelope SolarEclipsePathPoint + first SolarEclipsePathPoint + second SolarEclipsePathPoint + firstCap []SolarEclipsePathPoint + secondCap []SolarEclipsePathPoint +} + +type solarEclipseCentralBandGeometry struct { + jde float64 + moon [3]float64 + axis solarEclipseAxis + sun [3]float64 +} + +// Horizon roots use topocentric local-centrality and sunrise/sunset, while +// U1/U4 are geocentric shadow contacts. Near a polar grazing event the two +// definitions can differ by a few seconds; keep that physical seam from +// rejecting an otherwise converged horizon root. +const solarEclipseCentralLimitHorizonContactMarginDays = 30.0 / 86400.0 + +// centralLimitHorizonRootsNearAxisContact finds the two physical intersections +// between the central limits and the local-greatest horizon curve. Seeds are +// taken from the on-Earth side of the central-axis contact, independently of +// the caller's global path sampling step. +func (solver solarEclipseSolver) centralLimitHorizonRootsNearAxisContact( + axisContactJDE, shadowContactJDE, innerContactJDE, direction float64, +) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) { + const ( + seedWindowDays = 300.0 / 86400.0 + seedStepDays = 15.0 / 86400.0 + rootTimeEpsilon = 0.25 / 86400.0 + rootDistanceKM = 0.1 + ) + // At a grazing contact, the two horizon roots can be many minutes apart. + // Both belong to the interval while the shadow intersects the Earth limb. + rootWindowDays := direction * (innerContactJDE - shadowContactJDE) + if rootWindowDays <= 0 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + centers := make([]SolarEclipsePathPoint, 0, int(seedWindowDays/seedStepDays)) + for offset := seedStepDays; offset <= seedWindowDays+seedStepDays/2; offset += seedStepDays { + if center, ok := solver.centralPathPointAt(axisContactJDE + direction*offset); ok { + centers = append(centers, center) + } + } + sort.Slice(centers, func(first, second int) bool { return centers[first].JDE < centers[second].JDE }) + northern, southern := solver.centralPathLimits(centers) + roots := make([]SolarEclipsePathPoint, 0, 2) + for index := range northern { + for _, seed := range []SolarEclipsePathPoint{northern[index], southern[index]} { + root, ok := solveSolarEclipseCentralLimitHorizonRoot( + solver, + [3]float64{seed.Longitude, seed.Latitude, seed.JDE}, + ) + insideOffset := direction * (root.JDE - shadowContactJDE) + if !ok || + insideOffset < -solarEclipseCentralLimitHorizonContactMarginDays || + insideOffset > rootWindowDays+solarEclipseCentralLimitHorizonContactMarginDays { + continue + } + duplicate := false + for _, existing := range roots { + if math.Abs(root.JDE-existing.JDE) <= rootTimeEpsilon && + solarEclipsePathDistanceKM(root, existing) <= rootDistanceKM { + duplicate = true + break + } + } + if !duplicate { + roots = append(roots, root) + } + } + } + if len(roots) != 2 || solarEclipsePathDistanceKM(roots[0], roots[1]) <= 0.01 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + sort.Slice(roots, func(first, second int) bool { return roots[first].JDE < roots[second].JDE }) + return roots[0], roots[1], true +} + +// centralLimitHorizonRootsFromCurves brackets missing grazing roots on the +// already sampled greatest-at-horizon branches, then corrects C and dC/dt. +// Axis-adjacent Newton seeds alone can converge to the opposite end of a +// polar event even when the two limits are well separated. +func (solver solarEclipseSolver) centralLimitHorizonRootsFromCurves( + curves []SolarEclipseRiseSetCurve, + firstContactJDE, lastContactJDE float64, +) ([]SolarEclipsePathPoint, RiseSetDirection) { + startJDE, endJDE := math.Min(firstContactJDE, lastContactJDE), math.Max(firstContactJDE, lastContactJDE) + if startJDE <= 0 || endJDE <= startJDE { + return nil, "" + } + roots := make([]SolarEclipsePathPoint, 0, 2) + seededDirection := RiseSetDirection("") + for _, curve := range curves { + if curve.Phase != RiseSetPhaseGreatest { + continue + } + for _, segment := range curve.Segments { + for index := 1; index < len(segment); index++ { + first, second := segment[index-1], segment[index] + if second.JDE < startJDE || first.JDE > endJDE { + continue + } + seed, ok := solver.refineNonCentralBandHorizonGapCrossing(first, second) + if !ok { + continue + } + root, ok := solveSolarEclipseCentralLimitHorizonRoot( + solver, [3]float64{seed.Longitude, seed.Latitude, seed.JDE}, + ) + if !ok || root.JDE < startJDE-solarEclipseCentralLimitHorizonContactMarginDays || + root.JDE > endJDE+solarEclipseCentralLimitHorizonContactMarginDays || solarEclipseRiseSetPointExists(roots, root) { + continue + } + if seededDirection == "" { + seededDirection = curve.Direction + } + roots = append(roots, root) + } + } + } + sort.Slice(roots, func(first, second int) bool { return roots[first].JDE < roots[second].JDE }) + return roots, seededDirection +} + +// centralLimitHorizonRootsFromSampledBoundary recovers a horizon root the +// analytic seeds miss, by intersecting the sampled umbral sweep with the +// greatest-at-horizon curves. A grazing closure arc ends where the swept region +// crosses that curve, and the sweep is already available, so the crossing is a +// far better seed than the axis-adjacent Newton start that fails on these +// events. +func (solver solarEclipseSolver) centralLimitHorizonRootsFromSampledBoundary( + startJDE, endJDE, greatestJDE float64, + footprints []SolarEclipsePartialFootprint, + curves []SolarEclipseRiseSetCurve, +) ([]SolarEclipsePathPoint, RiseSetDirection) { + low, high := math.Min(startJDE, endJDE), math.Max(startJDE, endJDE) + if low <= 0 || high <= low || len(footprints) == 0 { + return nil, "" + } + // Sample slightly beyond the contact window: a closure arc can put one of + // its roots just outside it, and the sweep is cheapest here (the caller's + // footprints are reused). + margin := solarEclipseCentralLimitHorizonContactMarginDays + if span := high - low; span > 0 { + margin = math.Max(margin, span*0.15) + } + rings := solver.centralBandSampledFootprintUnionOverRange( + low-margin, high+margin, greatestJDE, footprints, + ) + if len(rings) == 0 { + return nil, "" + } + // A grazing closure arc meets the sweep almost tangentially, so an exact + // segment crossing is not reliable; every seed below is refined by the same + // Newton correction and then judged by the contact window. + seeds := solarEclipseHorizonCurveSeeds(curves, low, high) + seeds = append(seeds, solarEclipseHorizonBoundarySeeds(rings, curves, greatestJDE)...) + roots := make([]SolarEclipsePathPoint, 0, 2) + seededDirection := RiseSetDirection("") + for _, seed := range seeds { + root, ok := solveSolarEclipseCentralLimitHorizonRoot( + solver, [3]float64{seed.point.Longitude, seed.point.Latitude, seed.point.JDE}, + ) + if !ok || + root.JDE < low-solarEclipseCentralLimitHorizonContactMarginDays || + root.JDE > high+solarEclipseCentralLimitHorizonContactMarginDays || + solarEclipseRiseSetPointExists(roots, root) { + continue + } + if seededDirection == "" { + seededDirection = seed.direction + } + roots = append(roots, root) + } + sort.Slice(roots, func(first, second int) bool { return roots[first].JDE < roots[second].JDE }) + return roots, seededDirection +} + +// solarEclipseHorizonSeed is a starting point for the closure-root correction, +// carrying the horizon direction of the curve it came from. +type solarEclipseHorizonSeed struct { + point SolarEclipsePathPoint + direction RiseSetDirection +} + +// solarEclipseHorizonCurveSeeds seeds from the sampled greatest-at-horizon +// branches that lie inside the window, including their endpoints: a sampled +// branch can stop right at the closure root (1136-06-01 loses its second root +// that way). +func solarEclipseHorizonCurveSeeds( + curves []SolarEclipseRiseSetCurve, + low, high float64, +) []solarEclipseHorizonSeed { + var seeds []solarEclipseHorizonSeed + for _, curve := range curves { + if curve.Phase != RiseSetPhaseGreatest { + continue + } + for _, segment := range curve.Segments { + if len(segment) == 0 { + continue + } + for _, endpoint := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} { + if endpoint.JDE >= low && endpoint.JDE <= high { + seeds = append(seeds, solarEclipseHorizonSeed{point: endpoint, direction: curve.Direction}) + } + } + } + } + return seeds +} + +// solarEclipseHorizonBoundarySeeds seeds from the sampled sweep boundary: the +// closest approach between a boundary segment and a horizon segment, and the +// ends of every stretch of boundary that hugs the curve. A shallow polar band +// can run along the curve for hundreds of kilometres, and its closure endpoints +// are where it enters and leaves that stretch rather than a closest approach +// along it. +func solarEclipseHorizonBoundarySeeds( + rings [][]SolarEclipsePathPoint, + curves []SolarEclipseRiseSetCurve, + greatestJDE float64, +) []solarEclipseHorizonSeed { + var seeds []solarEclipseHorizonSeed + for _, ring := range rings { + if len(ring) < 3 { + continue + } + near, directions := solarEclipseBoundaryNearRuns(ring, curves, greatestJDE) + for index := range ring { + previous := (index + len(ring) - 1) % len(ring) + if near[index] != near[previous] { + entry := index + if !near[index] { + entry = previous + } + seeds = append(seeds, solarEclipseHorizonSeed{ + point: SolarEclipsePathPoint{ + JDE: greatestJDE, + Longitude: ring[entry].Longitude, + Latitude: ring[entry].Latitude, + }, + direction: directions[entry], + }) + } + } + for index := 0; index+1 < len(ring); index++ { + first, second := ring[index], ring[index+1] + for _, curve := range curves { + if curve.Phase != RiseSetPhaseGreatest { + continue + } + for _, segment := range curve.Segments { + for position := 0; position+1 < len(segment); position++ { + seed, ok := closestSolarEclipseBandApproach( + first, second, segment[position], segment[position+1], + solarEclipseCentralLimitHorizonContactSeedKM, + ) + if ok { + seeds = append(seeds, solarEclipseHorizonSeed{point: seed, direction: curve.Direction}) + } + } + } + } + } + } + return seeds +} + +// solarEclipseBoundaryNearRuns marks the boundary vertices that sit within the +// seed tolerance of a greatest-at-horizon curve, with that curve's direction. +func solarEclipseBoundaryNearRuns( + ring []SolarEclipsePathPoint, + curves []SolarEclipseRiseSetCurve, + greatestJDE float64, +) ([]bool, []RiseSetDirection) { + near := make([]bool, len(ring)) + directions := make([]RiseSetDirection, len(ring)) + for index, vertex := range ring { + direction, ok := solarEclipseGreatestCurveWithin(curves, SolarEclipsePathPoint{ + JDE: greatestJDE, Longitude: vertex.Longitude, Latitude: vertex.Latitude, + }, solarEclipseCentralLimitHorizonContactSeedKM) + if !ok { + continue + } + near[index] = true + directions[index] = direction + } + return near, directions +} + +// solarEclipseGreatestCurveWithin reports the horizon direction of the sampled +// greatest-at-horizon point closest to one location, when it lies within the +// tolerance. A grazing closure root lies on one of those curves by +// construction, so the nearest sample already knows whether the Sun is rising +// or setting there, which the local classification cannot always reproduce. +func solarEclipseGreatestCurveWithin( + curves []SolarEclipseRiseSetCurve, + point SolarEclipsePathPoint, + toleranceKM float64, +) (RiseSetDirection, bool) { + bestDistance := toleranceKM + direction := RiseSetDirection("") + for _, curve := range curves { + if curve.Phase != RiseSetPhaseGreatest || curve.Direction == "" { + continue + } + for _, segment := range curve.Segments { + for _, sample := range segment { + distance := solarEclipsePathDistanceKM(point, sample) + if distance <= bestDistance { + bestDistance = distance + direction = curve.Direction + } + } + } + } + return direction, direction != "" +} + +// solarEclipseNearestGreatestDirection reports the horizon direction of the +// sampled greatest-at-horizon curve closest to one closure root, at any +// distance. +func solarEclipseNearestGreatestDirection( + curves []SolarEclipseRiseSetCurve, + point SolarEclipsePathPoint, +) RiseSetDirection { + direction, _ := solarEclipseGreatestCurveWithin(curves, point, math.Inf(1)) + return direction +} + +// solarEclipseCentralLimitHorizonContactSeedKM bounds how far a sampled sweep +// boundary may sit from a horizon curve and still seed a closure root. +const solarEclipseCentralLimitHorizonContactSeedKM = 40.0 + +// closestSolarEclipseBandApproach returns the point of the horizon segment that +// comes closest to one band segment, when the two nearly touch. A grazing +// closure arc leaves the swept region almost tangentially, so requiring an +// exact crossing would miss it. +func closestSolarEclipseBandApproach( + first, second, horizonFirst, horizonSecond SolarEclipsePathPoint, + toleranceKM float64, +) (SolarEclipsePathPoint, bool) { + scale := math.Cos(horizonFirst.Latitude * math.Pi / 180) + ax := math.Remainder(first.Longitude-horizonFirst.Longitude, 360) * scale + ay := first.Latitude - horizonFirst.Latitude + bx := math.Remainder(second.Longitude-horizonFirst.Longitude, 360) * scale + by := second.Latitude - horizonFirst.Latitude + dx := math.Remainder(horizonSecond.Longitude-horizonFirst.Longitude, 360) * scale + dy := horizonSecond.Latitude - horizonFirst.Latitude + length := dx*dx + dy*dy + if length <= 0 { + return SolarEclipsePathPoint{}, false + } + bestDistance := math.Inf(1) + bestFraction := 0.0 + for step := 0; step <= 8; step++ { + bandFraction := float64(step) / 8 + pointX := ax + bandFraction*(bx-ax) + pointY := ay + bandFraction*(by-ay) + fraction := math.Max(0, math.Min(1, (pointX*dx+pointY*dy)/length)) + distance := math.Hypot(pointX-fraction*dx, pointY-fraction*dy) + if distance < bestDistance { + bestDistance = distance + bestFraction = fraction + } + } + if bestDistance*111.32 > toleranceKM { + return SolarEclipsePathPoint{}, false + } + return SolarEclipsePathPoint{ + JDE: horizonFirst.JDE + bestFraction*(horizonSecond.JDE-horizonFirst.JDE), + Longitude: normalizeLongitude(horizonFirst.Longitude + bestFraction*math.Remainder(horizonSecond.Longitude-horizonFirst.Longitude, 360)), + Latitude: horizonFirst.Latitude + bestFraction*(horizonSecond.Latitude-horizonFirst.Latitude), + }, true +} + +// magnitudeOneHorizonRoots pairs the early or late endpoints of the two +// complete totality branches. Approximate shadow-contact times must not clip +// these independently solved topocentric horizon roots. +func magnitudeOneHorizonRoots( + segments [][]SolarEclipsePathPoint, + direction float64, +) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) { + if len(segments) != 2 || (direction != 1 && direction != -1) { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + var roots [2]SolarEclipsePathPoint + horizon := [2]SolarEclipsePathPoint{} + horizonAvailable := [2]bool{} + for index, segment := range segments { + if len(segment) < 2 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + first, last := segment[0], segment[len(segment)-1] + if !finite(first.JDE) || !finite(first.Longitude) || !finite(first.Latitude) || !finite(first.SunAltitude) || + !finite(last.JDE) || !finite(last.Longitude) || !finite(last.Latitude) || !finite(last.SunAltitude) { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + if first.JDE > last.JDE { + first, last = last, first + } + if last.JDE-first.JDE <= solarEclipsePathDuplicateTimeDays { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + if math.Abs(first.SunAltitude) <= 1e-5 { + horizon[index], horizonAvailable[index] = first, true + } else if math.Abs(last.SunAltitude) <= 1e-5 { + horizon[index], horizonAvailable[index] = last, true + } + candidate := first + if direction < 0 { + candidate = last + } + // A very shallow polar branch can terminate at the contour's + // interior sampling limit instead of the horizon. In that case the + // opposite endpoint is the physical horizon root. + if math.Abs(candidate.SunAltitude) > 1e-5 { + alternate := last + if candidate.JDE == last.JDE { + alternate = first + } + if math.Abs(alternate.SunAltitude) > 1e-5 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + candidate = alternate + } + roots[index] = candidate + } + // Extremely shallow two-limit events can expose only one horizon endpoint + // on each complete magnitude-one branch. Those two endpoints are the + // physical pair for both temporal closures. + if horizonAvailable[0] && horizonAvailable[1] { + if math.Abs(roots[0].SunAltitude) > 1e-5 || math.Abs(roots[1].SunAltitude) > 1e-5 { + roots = horizon + } + } + if solarEclipsePathDistanceKM(roots[0], roots[1]) <= 0.01 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + if roots[0].JDE > roots[1].JDE { + roots[0], roots[1] = roots[1], roots[0] + } + return roots[0], roots[1], true +} + +func (solver solarEclipseSolver) centralBandHorizonClosure( + firstRoot, lastRoot SolarEclipsePathPoint, + direction RiseSetDirection, + curves []SolarEclipseRiseSetCurve, +) []SolarEclipsePathPoint { + closure := []SolarEclipsePathPoint{firstRoot} + var selected []SolarEclipsePathPoint + bestScore := math.Inf(1) + for _, curve := range curves { + if curve.Phase != RiseSetPhaseGreatest || curve.Direction != direction { + continue + } + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + first, firstOK := solarEclipseHorizonSegmentPointAt(segment, firstRoot.JDE) + last, lastOK := solarEclipseHorizonSegmentPointAt(segment, lastRoot.JDE) + if !firstOK || !lastOK { + continue + } + // A phase can have several disconnected horizon branches with the + // same time range. Select the branch that actually joins both solved + // central-limit roots; concatenating every time-overlapping branch + // creates the large 2010/2056 endpoint folds seen on the map. + score := solarEclipsePathDistanceKM(first, firstRoot) + + solarEclipsePathDistanceKM(last, lastRoot) + if score >= bestScore { + continue + } + bestScore = score + selected = segment + } + } + if len(selected) > 0 { + for _, point := range selected { + if point.JDE > firstRoot.JDE && point.JDE < lastRoot.JDE { + closure = append(closure, point) + } + } + } + closure = append(closure, lastRoot) + closure = deduplicateSolarEclipsePathPoints(closure) + refined := make([]SolarEclipsePathPoint, 1, len(closure)) + refined[0] = closure[0] + for index := 1; index < len(closure); index++ { + refined = solver.appendRefinedSolarEclipseCentralHorizonSegment( + refined, closure[index-1], closure[index], 0, + ) + } + return deduplicateSolarEclipsePathPoints(refined) +} + +// alignSolarEclipseCentralBandHorizonClosures makes the public greatest-at- +// horizon curves share the exact arcs used to close a two-limit central band. +// Without this replacement, the band uses refined roots while the rendered +// greatest curve keeps its coarser samples, leaving a visible seam at both +// endpoints even though the two calculations describe the same boundary. +func (solver solarEclipseSolver) alignSolarEclipseCentralBandHorizonClosures( + curves []SolarEclipseRiseSetCurve, + closures [][]SolarEclipsePathPoint, +) { + for _, closure := range closures { + if len(closure) < 2 { + continue + } + root := closure[0] + _, key, ok := solver.magnitudeEvaluationAt(root.JDE).classify(root.Longitude, root.Latitude, true) + if ok { + alignSolarEclipseCentralBandHorizonClosure(curves, closure, key.direction) + } + } +} + +func alignSolarEclipseCentralBandHorizonClosure( + curves []SolarEclipseRiseSetCurve, + closure []SolarEclipsePathPoint, + direction RiseSetDirection, +) { + if len(closure) < 2 { + return + } + ordered := append([]SolarEclipsePathPoint(nil), closure...) + if ordered[0].JDE > ordered[len(ordered)-1].JDE { + for left, right := 0, len(ordered)-1; left < right; left, right = left+1, right-1 { + ordered[left], ordered[right] = ordered[right], ordered[left] + } + } + start, end := ordered[0], ordered[len(ordered)-1] + bestCurve, bestSegment := -1, -1 + bestScore := math.Inf(1) + for curveIndex := range curves { + curve := &curves[curveIndex] + if curve.Phase != RiseSetPhaseGreatest || curve.Direction != direction { + continue + } + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 || + start.JDE < segment[0].JDE-solarEclipseRiseSetTimeEpsilonDays || + end.JDE > segment[len(segment)-1].JDE+solarEclipseRiseSetTimeEpsilonDays { + continue + } + segmentStart, startOK := solarEclipseHorizonSegmentPointAt(segment, start.JDE) + segmentEnd, endOK := solarEclipseHorizonSegmentPointAt(segment, end.JDE) + if !startOK || !endOK { + continue + } + score := solarEclipsePathDistanceKM(segmentStart, start) + + solarEclipsePathDistanceKM(segmentEnd, end) + if score < bestScore { + bestCurve, bestSegment, bestScore = curveIndex, segmentIndex, score + } + } + } + if bestCurve < 0 || bestScore > 2*solarEclipseRiseSetTargetSpacingKM { + return + } + segment := curves[bestCurve].Segments[bestSegment] + joined := make([]SolarEclipsePathPoint, 0, len(segment)+len(ordered)) + for _, point := range segment { + if point.JDE < start.JDE-solarEclipseRiseSetTimeEpsilonDays { + joined = append(joined, point) + } + } + joined = append(joined, ordered...) + for _, point := range segment { + if point.JDE > end.JDE+solarEclipseRiseSetTimeEpsilonDays { + joined = append(joined, point) + } + } + curves[bestCurve].Segments[bestSegment] = deduplicateSolarEclipsePathPoints(joined) +} + +func solarEclipseHorizonSegmentPointAt( + segment []SolarEclipsePathPoint, jde float64, +) (SolarEclipsePathPoint, bool) { + if len(segment) < 2 { + return SolarEclipsePathPoint{}, false + } + best := SolarEclipsePathPoint{} + bestDistance := math.Inf(1) + for index := 1; index < len(segment); index++ { + first, second := segment[index-1], segment[index] + if (jde < first.JDE && jde < second.JDE) || (jde > first.JDE && jde > second.JDE) { + continue + } + fraction := 0.0 + if second.JDE != first.JDE { + fraction = (jde - first.JDE) / (second.JDE - first.JDE) + } + fraction = math.Max(0, math.Min(1, fraction)) + candidate := solarEclipsePathSphericalInterpolate(first, second, fraction) + candidate.JDE = jde + return candidate, true + } + for _, point := range segment { + if distance := math.Abs(point.JDE - jde); distance < bestDistance { + best, bestDistance = point, distance + } + } + if bestDistance > 10.0/1440.0 { + return SolarEclipsePathPoint{}, false + } + best.JDE = jde + return best, true +} + +func solveSolarEclipseCentralLimitHorizonRoot( + solver solarEclipseSolver, coordinates [3]float64, +) (SolarEclipsePathPoint, bool) { + for iteration := 0; iteration < 24; iteration++ { + residual, jacobian, ok := solarEclipseCentralLimitHorizonJacobian( + solver, coordinates, solver.magnitudeEvaluationAt, + ) + if !ok { + return SolarEclipsePathPoint{}, false + } + if solarEclipseCentralLimitHorizonConverged(residual) { + if !finite(coordinates[0]) || !finite(coordinates[1]) || !finite(coordinates[2]) || + coordinates[1] < -90 || coordinates[1] > 90 { + return SolarEclipsePathPoint{}, false + } + evaluation := solver.magnitudeEvaluationAt(coordinates[2]) + state := evaluation.center.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0) + if evaluation.centralContactSecondDerivative(coordinates[0], coordinates[1]) <= 0 { + return SolarEclipsePathPoint{}, false + } + return SolarEclipsePathPoint{JDE: coordinates[2], Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, true + } + delta, ok := solveSolarEclipse3x3(jacobian, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !ok { + return SolarEclipsePathPoint{}, false + } + for index := range coordinates { + coordinates[index] += delta[index] + } + coordinates[0] = normalizeLongitude(coordinates[0]) + } + return SolarEclipsePathPoint{}, false +} + +func solarEclipseCentralLimitHorizonConverged(residual [3]float64) bool { + return math.Abs(residual[0]) < 1e-9 && math.Abs(residual[1]) < 1e-7 && math.Abs(residual[2]) < 1e-9 +} + +func solarEclipseCentralLimitHorizonJacobian( + solver solarEclipseSolver, coordinates [3]float64, + evaluate func(float64) solarEclipseRiseSetEvaluation, +) ([3]float64, [3][3]float64, bool) { + steps := [3]float64{1e-5, 1e-5, 1.0 / 86400.0} + // 经度、纬度两列的差分不改变时刻,三处残差共用同一时刻的星历态;只有时间列要换时刻。 + centerEvaluation := evaluate(coordinates[2]) + evaluations := [4]solarEclipseRiseSetEvaluation{ + centerEvaluation, centerEvaluation, centerEvaluation, evaluate(coordinates[2] + steps[2]), + } + valueAt := func(evaluation solarEclipseRiseSetEvaluation, longitude, latitude float64) ([3]float64, bool) { + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + return [3]float64{solarEclipseCentralContactGap(state), evaluation.centralContactDerivative(longitude, latitude), state.sunAltitudeRad}, finite(state.sunAltitudeRad) + } + residual, ok := valueAt(evaluations[0], coordinates[0], coordinates[1]) + if !ok { + return [3]float64{}, [3][3]float64{}, false + } + jacobian := [3][3]float64{} + for column, step := range steps { + shifted := coordinates + shifted[column] += step + value, ok := valueAt(evaluations[column+1], shifted[0], shifted[1]) + if !ok { + return [3]float64{}, [3][3]float64{}, false + } + for row := range residual { + jacobian[row][column] = (value[row] - residual[row]) / step + } + } + return residual, jacobian, true +} + +// centralBandSweepPolygons combines the critical envelope with the part of the +// local-greatest horizon arc that lies inside the central-contact condition. +func (solver solarEclipseSolver) centralBandSweepPolygons( + startJDE, endJDE, greatestJDE float64, + riseSetCurves []SolarEclipseRiseSetCurve, +) ([][]SolarEclipsePathPoint, []SolarEclipsePathPoint, bool) { + return solver.centralBandSweepPolygonsReusing(startJDE, endJDE, greatestJDE, riseSetCurves, nil) +} + +// centralBandSweepPolygonsReusing is centralBandSweepPolygons with the caller's +// already solved instantaneous footprints, so the sampled reconstruction does +// not solve them a second time when the analytic region fails. +func (solver solarEclipseSolver) centralBandSweepPolygonsReusing( + startJDE, endJDE, greatestJDE float64, + riseSetCurves []SolarEclipseRiseSetCurve, + existing []SolarEclipsePartialFootprint, +) ([][]SolarEclipsePathPoint, []SolarEclipsePathPoint, bool) { + times, _ := solarEclipsePathSampleTimes( + startJDE, endJDE, greatestJDE, solarEclipseCentralBandStepDays, + ) + times = solarEclipseCentralBandContactSampleTimes(times, startJDE, endJDE) + samples := make([]solarEclipseCentralBandSweepSample, 0, len(times)) + for _, jd := range times { + sample, ok := solver.centralBandSweepSampleAt(jd) + if ok { + samples = append(samples, sample) + } + } + if len(samples) >= 2 { + refined := solver.refineCentralBandSweepSamples(samples) + polygon, horizon := solver.nonCentralBandRegion(refined, riseSetCurves, greatestJDE) + if len(polygon) >= 4 { + return [][]SolarEclipsePathPoint{polygon}, horizon, false + } + } + return solver.centralBandSampledFootprintUnion(times, existing, greatestJDE), nil, true +} + +// centralBandSampledFootprintUnionOverRange rebuilds the sampled-footprint +// union for the complete central-contact interval of one event. +func (solver solarEclipseSolver) centralBandSampledFootprintUnionOverRange( + startJDE, endJDE, greatestJDE float64, + existing []SolarEclipsePartialFootprint, +) [][]SolarEclipsePathPoint { + times, _ := solarEclipsePathSampleTimes( + startJDE, endJDE, greatestJDE, solarEclipseCentralBandStepDays, + ) + times = solarEclipseCentralBandContactSampleTimes(times, startJDE, endJDE) + return solver.centralBandSampledFootprintUnion(times, existing, greatestJDE) +} + +// A grazing central event can keep part of the umbral/antumbral rim off the +// Earth over the whole path: every instantaneous footprint then has an open +// arc over the outer samples and a fully closed rim over the middle. The +// analytic envelope and the open-arc sweep both describe only part of that +// shape (and the closed middle samples carry no open arc at all), so the static +// band is rebuilt from the instantaneous footprints themselves. The union of +// every sampled footprint is the central band by definition; a vertex spacing +// keeps the spherical union affordable. +const ( + // The union is a coverage reconstruction, not a display-resolution trace: + // its rings are only ever unioned and exported, so they are sampled well + // below the footprint resolution. Neighbouring grazing footprints each + // extend for a thousand kilometres, so the temporal stride only has to be + // shorter than the along-track overlap; the contact ends keep every sample. + solarEclipseCentralBandUnionBoundaryPoints = 90 + solarEclipseCentralBandUnionMinStepSeconds = 10.0 + // The union needs a new footprint only once the previous one has moved far + // enough for the envelope between them to stay resolved, so the stride + // follows the shadow's ground speed instead of the clock: a grazing event + // spends most of its contact interval creeping across the terminator, and a + // fixed stride over-samples exactly there. The contact anchors below keep + // the flared ends at full resolution. + // The advance target matches the union spacing: the caller already samples + // the contact interval at roughly this step, and a coarser stride measurably + // opens the sweep (1042-06-20 leaves its umbral sweep by 86 km at 20 km). + // The rule therefore only bites when a caller passes a sparse grid. + solarEclipseCentralBandUnionAdvanceKM = 4.0 + solarEclipseCentralBandUnionMaxStepSeconds = 60.0 + solarEclipseCentralBandUnionAnchorSamples = 3 + solarEclipseCentralBandUnionSpacingKM = 10.0 + // The caller already solved the contact-interval footprints; a sample within + // this tolerance of one of them is reused instead of solved again, so only + // the interval the caller skipped is actually computed here. + solarEclipseCentralBandUnionReuseDays = 6.0 / 86400.0 + // A decimated union is not an analytic envelope, so the reconstructed band + // may cut inside the sharpest tip by the union spacing instead of the + // footprint tolerance. The caller validates it with this bound. + solarEclipseCentralBandUnionContainmentToleranceKM = 40.0 + // Each pair of neighbouring instantaneous rims meets in a shallow cusp, and + // where the rims run nearly parallel the union alternates between them and + // reads as a staircase even though the physical envelope is smooth. Many + // lambda|mu passes flatten that sampling sweep without the systematic inward + // shrink a plain Laplacian or a wide quadratic fit would introduce. + solarEclipseCentralBandUnionSmoothPasses = 30 + solarEclipseCentralBandUnionSmoothLambda = 0.5 + solarEclipseCentralBandUnionSmoothMu = -0.53 + // A spherical union leaves nodes where two rims nearly touch: vertices a few + // hundred metres apart that carry no geometry but read as 150-degree spikes. + solarEclipseCentralBandUnionMinVertexSpacingKM = 1.0 + // The relaxation can pull two nearby vertices onto opposite sides of the + // underlying curve, which turns a harmless pair into a spike. A second, + // coarser cleanup after the filter removes those pairs. + solarEclipseCentralBandUnionPostSmoothSpacingKM = 3.0 +) + +// solarEclipseRingCenter returns the mean position of one closed ring. +func solarEclipseRingCenter(ring []geodata.GeoPoint) geodata.GeoPoint { + if len(ring) == 0 { + return geodata.GeoPoint{} + } + longitude, latitude := 0.0, 0.0 + reference := ring[0].Longitude + for _, point := range ring { + longitude += reference + math.Remainder(point.Longitude-reference, 360) + latitude += point.Latitude + } + total := float64(len(ring)) + return geodata.GeoPoint{ + Longitude: normalizeLongitude(longitude / total), + Latitude: latitude / total, + } +} + +// centralBandSampledFootprintUnion samples the instantaneous central-shadow +// footprint over the complete central-contact interval and returns their +// spherical union as closed rings. +func (solver solarEclipseSolver) centralBandSampledFootprintUnion( + times []float64, + existing []SolarEclipsePartialFootprint, + greatestJDE float64, +) [][]SolarEclipsePathPoint { + rings := make([][]geodata.GeoPoint, 0, len(times)) + ringJDE := make(map[geodata.GeoPoint]float64, len(times)*8) + solved := append([]SolarEclipsePartialFootprint(nil), existing...) + sort.Slice(solved, func(first, second int) bool { return solved[first].JDE < solved[second].JDE }) + existingIndex := 0 + lastKept := 0.0 + var lastCenter geodata.GeoPoint + speedKMperSecond := 0.0 + for index, jd := range times { + anchored := index < solarEclipseCentralBandUnionAnchorSamples || + index >= len(times)-solarEclipseCentralBandUnionAnchorSamples + if !anchored && lastKept > 0 { + elapsed := (jd - lastKept) * 86400 + if elapsed < solarEclipseCentralBandUnionMinStepSeconds { + continue + } + // Skip ahead while the previous footprint has barely moved: the + // swept envelope between two samples this close is already covered. + if speedKMperSecond > 0 && + elapsed*speedKMperSecond < solarEclipseCentralBandUnionAdvanceKM { + continue + } + if elapsed > solarEclipseCentralBandUnionMaxStepSeconds { + // Never let the stride stretch past the cap, so a stalled centre + // cannot leave a gap in the sweep. + if lastKept+solarEclipseCentralBandUnionMaxStepSeconds/86400 < jd { + jd = lastKept + solarEclipseCentralBandUnionMaxStepSeconds/86400 + } + } + } + for existingIndex < len(solved) && + solved[existingIndex].JDE < jd-solarEclipseCentralBandUnionReuseDays { + existingIndex++ + } + var ring []SolarEclipsePathPoint + if existingIndex < len(solved) && + math.Abs(solved[existingIndex].JDE-jd) <= solarEclipseCentralBandUnionReuseDays { + ring = solarEclipseFootprintBoundaryRing(solved[existingIndex]) + } else { + ring = solver.centralBandSampledFootprintRingAt(jd) + } + if len(ring) < 4 { + continue + } + ring = decimateSolarEclipseClosedRing(ring, solarEclipseCentralBandUnionSpacingKM) + if len(ring) < 4 { + continue + } + polygon := make([]geodata.GeoPoint, len(ring)) + for position, point := range ring { + polygon[position] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + // 同一坐标可能同时落在相邻两个足迹的环上,取它最早出现的那次, + // 也就是本影边缘真正扫过它的时刻。 + if _, exists := ringJDE[polygon[position]]; !exists { + ringJDE[polygon[position]] = point.JDE + } + } + rings = append(rings, polygon) + if lastKept > 0 && jd > lastKept { + center := solarEclipseRingCenter(polygon) + if speedKMperSecond <= 0 { + speedKMperSecond = solarEclipsePathDistanceKM( + SolarEclipsePathPoint{Longitude: lastCenter.Longitude, Latitude: lastCenter.Latitude}, + SolarEclipsePathPoint{Longitude: center.Longitude, Latitude: center.Latitude}, + ) / ((jd - lastKept) * 86400) + } + lastCenter = center + } else { + lastCenter = solarEclipseRingCenter(polygon) + } + lastKept = jd + } + if len(rings) == 0 { + return nil + } + merged := unionSolarEclipseCentralBandRings(rings) + if len(merged) == 0 { + return nil + } + segments := make([][]SolarEclipsePathPoint, 0, len(merged)) + for _, polygon := range merged { + if len(polygon) < 4 { + continue + } + times := solarEclipseRingVertexTimes(polygon, ringJDE, greatestJDE) + polygon, times = dedupeSolarEclipseClosedRing(polygon, times, solarEclipseCentralBandUnionMinVertexSpacingKM) + smoothed, smoothedTimes := smoothSolarEclipseClosedRing( + polygon, times, solarEclipseCentralBandUnionSmoothPasses, + ) + if len(smoothed) >= 4 { + polygon, times = dedupeSolarEclipseClosedRing( + smoothed, smoothedTimes, solarEclipseCentralBandUnionPostSmoothSpacingKM, + ) + } + segment := make([]SolarEclipsePathPoint, 0, len(polygon)+1) + for index, point := range polygon { + segment = append(segment, SolarEclipsePathPoint{ + JDE: times[index], Longitude: point.Longitude, Latitude: point.Latitude, + }) + } + if solarEclipsePathDistanceKM(segment[0], segment[len(segment)-1]) > 0.01 { + segment = append(segment, segment[0]) + } + segments = append(segments, segment) + } + if len(segments) == 0 { + return nil + } + return segments +} + +// unionSolarEclipseCentralBandRings merges the sampled footprints one at a +// time. A single boolean join of a few hundred grazing footprints leaves a +// handful of nanodegree seams between temporally adjacent rims, and the +// all-at-once join then reports the outer ring as open; folding the rings into +// a growing accumulator keeps every join local, so the few rings that still +// fail are skipped instead of discarding the whole band. +func unionSolarEclipseCentralBandRings(rings [][]geodata.GeoPoint) [][]geodata.GeoPoint { + if len(rings) == 0 { + return nil + } + accumulator, err := geodata.UnionPolygons(rings[:1]) + if err != nil || len(accumulator) == 0 { + return nil + } + for _, ring := range rings[1:] { + merged, mergeErr := geodata.UnionPolygons(append(accumulator, ring)) + if mergeErr != nil || len(merged) == 0 { + continue + } + accumulator = merged + } + return accumulator +} + +// centralBandSampledFootprintRingAt returns one instantaneous central-shadow +// footprint as a closed ring. The traced boundary is used as-is, so every +// sampled footprint vertex stays on the reconstructed band and the coverage +// audit keeps its meaning; an open (horizon-cut) footprint is closed by the +// chord between its two rim ends, and the neighbouring samples cover the +// horizon side. +func (solver solarEclipseSolver) centralBandSampledFootprintRingAt( + jd float64, +) []SolarEclipsePathPoint { + return solarEclipseFootprintBoundaryRing(solver.shadowFootprintAtWithSpacing( + jd, solarEclipseCentralBandUnionBoundaryPoints, solarEclipseCentralShadow, + solarEclipseCentralBandUnionSpacingKM, + )) +} + +// solarEclipseFootprintBoundaryRing joins one instantaneous footprint into a +// closed ring. An antimeridian-crossing rim is split into two segments; they are +// joined the same way the renderers do instead of adding a false chord across +// 180, and an open (horizon-cut) rim is closed by the chord between its two +// ends while the neighbouring samples cover the horizon side. +func solarEclipseFootprintBoundaryRing( + footprint SolarEclipsePartialFootprint, +) []SolarEclipsePathPoint { + if len(footprint.Boundaries) == 0 { + return nil + } + segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries)) + for _, boundary := range footprint.Boundaries { + segment := make([]geodata.GeoPoint, len(boundary)) + for index, point := range boundary { + segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + segments = append(segments, segment) + } + joined := geodata.JoinPolylineSegments(segments) + if len(joined) < 3 { + return nil + } + ring := make([]SolarEclipsePathPoint, 0, len(joined)+1) + for _, point := range joined { + ring = append(ring, SolarEclipsePathPoint{ + JDE: footprint.JDE, Longitude: point.Longitude, Latitude: point.Latitude, + }) + } + if solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 { + ring = append(ring, ring[0]) + } + return ring +} + +// dedupeSolarEclipseClosedRing drops vertices closer together than the given +// spacing, returns a ring that closes on its first vertex, and keeps the +// parallel time slice aligned so every retained vertex keeps its own time. +func dedupeSolarEclipseClosedRing( + points []geodata.GeoPoint, + times []float64, + minimumKM float64, +) ([]geodata.GeoPoint, []float64) { + if len(points) < 4 || minimumKM <= 0 || len(times) != len(points) { + return points, times + } + open, openTimes := points, times + if pointDistanceKM(points[len(points)-1], points[0]) < minimumKM { + open, openTimes = points[:len(points)-1], times[:len(times)-1] + } + if len(open) < 3 { + return points, times + } + result := make([]geodata.GeoPoint, 0, len(open)+1) + resultTimes := make([]float64, 0, len(open)+1) + result = append(result, open[0]) + resultTimes = append(resultTimes, openTimes[0]) + for index, point := range open[1:] { + if pointDistanceKM(result[len(result)-1], point) >= minimumKM { + result = append(result, point) + resultTimes = append(resultTimes, openTimes[index+1]) + } + } + if len(result) < 3 { + return points, times + } + result = append(result, result[0]) + resultTimes = append(resultTimes, resultTimes[0]) + return result, resultTimes +} + +// solarEclipseRingVertexTimes 给并集环的每个顶点配回它自己的时间:并集输出保留了 +// 原足迹顶点,交点顶点没有直接时间,用环上前后两个已知时间按沿环距离插值。 +func solarEclipseRingVertexTimes( + ring []geodata.GeoPoint, + ringJDE map[geodata.GeoPoint]float64, + fallbackJDE float64, +) []float64 { + times := make([]float64, len(ring)) + known := make([]bool, len(ring)) + found := false + for index, point := range ring { + if jde, ok := ringJDE[point]; ok { + times[index], known[index], found = jde, true, true + } + } + if !found { + for index := range times { + times[index] = fallbackJDE + } + return times + } + if len(ring) > 1 && ring[0] == ring[len(ring)-1] { + if known[0] { + known[len(ring)-1], times[len(ring)-1] = true, times[0] + } + if known[len(ring)-1] { + known[0], times[0] = true, times[len(ring)-1] + } + } + count := len(ring) + for offset := 0; offset < count; offset++ { + if known[offset] { + continue + } + backIndex, backDistance := offset, 0.0 + for steps := 0; steps < count; steps++ { + previous := (backIndex - 1 + count) % count + backDistance += pointDistanceKM(ring[previous], ring[backIndex]) + backIndex = previous + if known[backIndex] { + break + } + } + forwardIndex, forwardDistance := offset, 0.0 + for steps := 0; steps < count; steps++ { + next := (forwardIndex + 1) % count + forwardDistance += pointDistanceKM(ring[forwardIndex], ring[next]) + forwardIndex = next + if known[forwardIndex] { + break + } + } + fraction := 0.5 + if total := backDistance + forwardDistance; total > 0 { + fraction = backDistance / total + } + times[offset] = times[backIndex] + fraction*(times[forwardIndex]-times[backIndex]) + } + return times +} + +// smoothSolarEclipseClosedRing filters one closed ring with Taubin smoothing. +// A bare Laplacian pass removes short-wavelength ripple but also shrinks a +// convex boundary — which a grazing band cannot afford, because its coverage +// check allows only tens of kilometres — and a wide quadratic fit can fold a +// tight corner. The lambda|mu pair alternates a shrinking with a slightly +// larger inflating step, so a long run of iterations flattens the sampling +// sweep of the footprint union without pulling the band inside the umbra. +// Longitudes are unwrapped first so the filter never averages across the +// antimeridian. The same weights are applied to the parallel time slice, so a +// moved vertex carries the time of the neighbourhood it was moved into instead +// of falling back to the event's greatest eclipse. +func smoothSolarEclipseClosedRing( + points []geodata.GeoPoint, + times []float64, + passes int, +) ([]geodata.GeoPoint, []float64) { + count := len(points) - 1 + if count < 8 || passes <= 0 || len(times) != len(points) { + return nil, nil + } + longitudes := make([]float64, count) + latitudes := make([]float64, count) + vertexTimes := make([]float64, count) + longitudes[0] = points[0].Longitude + latitudes[0] = points[0].Latitude + vertexTimes[0] = times[0] + for index := 1; index < count; index++ { + delta := math.Remainder((points[index].Longitude-points[index-1].Longitude)*rad, 2*math.Pi) / rad + longitudes[index] = longitudes[index-1] + delta + latitudes[index] = points[index].Latitude + vertexTimes[index] = times[index] + } + nextLongitudes := make([]float64, count) + nextLatitudes := make([]float64, count) + nextTimes := make([]float64, count) + for pass := 0; pass < passes; pass++ { + weight := solarEclipseCentralBandUnionSmoothLambda + if pass%2 == 1 { + weight = solarEclipseCentralBandUnionSmoothMu + } + for index := 0; index < count; index++ { + previous := (index + count - 1) % count + following := (index + 1) % count + nextLongitudes[index] = longitudes[index] + + weight*(0.5*(longitudes[previous]+longitudes[following])-longitudes[index]) + nextLatitudes[index] = latitudes[index] + + weight*(0.5*(latitudes[previous]+latitudes[following])-latitudes[index]) + nextTimes[index] = vertexTimes[index] + + weight*(0.5*(vertexTimes[previous]+vertexTimes[following])-vertexTimes[index]) + } + copy(longitudes, nextLongitudes) + copy(latitudes, nextLatitudes) + copy(vertexTimes, nextTimes) + } + ring := make([]geodata.GeoPoint, 0, count+1) + ringTimes := make([]float64, 0, count+1) + for index := 0; index < count; index++ { + ring = append(ring, geodata.GeoPoint{ + Longitude: normalizeLongitude(longitudes[index]), + Latitude: latitudes[index], + }) + ringTimes = append(ringTimes, vertexTimes[index]) + } + ring = append(ring, ring[0]) + ringTimes = append(ringTimes, ringTimes[0]) + return ring, ringTimes +} + +// pointDistanceKM is the great-circle distance between two geographic points. +func pointDistanceKM(first, second geodata.GeoPoint) float64 { + firstLatitude := first.Latitude * rad + secondLatitude := second.Latitude * rad + deltaLatitude := secondLatitude - firstLatitude + deltaLongitude := math.Remainder((second.Longitude-first.Longitude)*rad, 2*math.Pi) + haversine := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) + + math.Cos(firstLatitude)*math.Cos(secondLatitude)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2) + return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, haversine))) +} + +// decimateSolarEclipseClosedRing thins one closed ring to the requested +// spacing while keeping its first vertex and its closure. +func decimateSolarEclipseClosedRing( + points []SolarEclipsePathPoint, + spacingKM float64, +) []SolarEclipsePathPoint { + if len(points) < 4 || spacingKM <= 0 { + return points + } + result := make([]SolarEclipsePathPoint, 0, len(points)) + result = append(result, points[0]) + for _, point := range points[1:] { + if solarEclipsePathDistanceKM(result[len(result)-1], point) >= spacingKM { + result = append(result, point) + } + } + if len(result) < 3 { + return points + } + first := result[0] + if solarEclipsePathDistanceKM(result[len(result)-1], first) > 0.01 { + result = append(result, first) + } + return result +} + +func (solver solarEclipseSolver) nonCentralTotalBandPolygons( + segments [][]SolarEclipsePathPoint, + firstContact, lastContact SolarEclipsePathPoint, + referenceJDE float64, + riseSetCurves []SolarEclipseRiseSetCurve, +) ([][]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + contacts := []SolarEclipsePathPoint{firstContact, lastContact} + for _, segment := range segments { + boundary, ok := solver.completeNonCentralTotalBandBoundary( + segment, contacts, referenceJDE, + ) + if !ok { + continue + } + polygon, horizon := solver.closeNonCentralBandBoundary(boundary, riseSetCurves) + if len(polygon) >= 4 { + return [][]SolarEclipsePathPoint{polygon}, horizon + } + } + return nil, nil +} + +func (solver solarEclipseSolver) completeNonCentralTotalBandBoundary( + segment []SolarEclipsePathPoint, + contacts []SolarEclipsePathPoint, + referenceJDE float64, +) ([]SolarEclipsePathPoint, bool) { + if len(segment) < 2 { + return nil, false + } + boundary := append([]SolarEclipsePathPoint(nil), segment...) + for _, atStart := range []bool{true, false} { + endpointIndex := len(boundary) - 1 + if atStart { + endpointIndex = 0 + } + endpoint := boundary[endpointIndex] + if math.Abs(endpoint.SunAltitude) <= 1e-5 { + continue + } + contact, ok := nearestNonCentralBandContact(endpoint, contacts) + if !ok { + return nil, false + } + extension, ok := solver.nonCentralBandContactExtension( + contact, endpoint, referenceJDE, + ) + if !ok { + return nil, false + } + if atStart { + if solarEclipsePathDistanceKM(extension[len(extension)-1], endpoint) > 0.01 { + for left, right := 0, len(extension)-1; left < right; left, right = left+1, right-1 { + extension[left], extension[right] = extension[right], extension[left] + } + } + boundary = append(extension[:len(extension)-1], boundary...) + } else { + if solarEclipsePathDistanceKM(extension[0], endpoint) > 0.01 { + for left, right := 0, len(extension)-1; left < right; left, right = left+1, right-1 { + extension[left], extension[right] = extension[right], extension[left] + } + } + boundary = append(boundary, extension[1:]...) + } + } + return deduplicateSolarEclipsePathPoints(boundary), true +} + +func nearestNonCentralBandContact( + endpoint SolarEclipsePathPoint, + contacts []SolarEclipsePathPoint, +) (SolarEclipsePathPoint, bool) { + const maximumTimeGapDays = 5.0 / 1440.0 + best := SolarEclipsePathPoint{} + bestTimeGap := math.Inf(1) + for _, contact := range contacts { + if contact.JDE == 0 { + continue + } + timeGap := math.Abs(contact.JDE - endpoint.JDE) + if timeGap < bestTimeGap { + best, bestTimeGap = contact, timeGap + } + } + if bestTimeGap > maximumTimeGapDays || + solarEclipsePathDistanceKM(best, endpoint) > 3000 { + return SolarEclipsePathPoint{}, false + } + return best, true +} + +func (solver solarEclipseSolver) nonCentralBandContactExtension( + contact, endpoint SolarEclipsePathPoint, + referenceJDE float64, +) ([]SolarEclipsePathPoint, bool) { + startJDE, endJDE := contact.JDE, endpoint.JDE + if startJDE > endJDE { + startJDE, endJDE = endJDE, startJDE + } + if endJDE-startJDE <= solarEclipsePathDuplicateTimeDays { + return []SolarEclipsePathPoint{endpoint}, true + } + const stepDays = 5.0 / 86400.0 + times := make([]float64, 0, int((endJDE-startJDE)/stepDays)+20) + for jd := startJDE + stepDays; jd < endJDE-solarEclipsePathDuplicateTimeDays; jd += stepDays { + times = append(times, jd) + } + for second := 1.0; second <= 10; second++ { + offset := second / 86400.0 + if startJDE+offset < endJDE-solarEclipsePathDuplicateTimeDays { + times = append(times, startJDE+offset) + } + if endJDE-offset > startJDE+solarEclipsePathDuplicateTimeDays { + times = append(times, endJDE-offset) + } + } + sort.Float64s(times) + times = uniqueSolarEclipsePathTimes(times) + samples := make([]solarEclipseCentralBandSweepSample, 0, len(times)) + for _, jd := range times { + if sample, ok := solver.centralBandSweepSampleAt(jd); ok { + samples = append(samples, sample) + } + } + samples = solver.refineCentralBandSweepSamples(samples) + corrected := solver.correctNonCentralBandEnvelopeSamples(samples, referenceJDE) + if len(corrected) == 0 { + return nil, false + } + result := make([]SolarEclipsePathPoint, 0, len(corrected)+1) + if contact.JDE <= endpoint.JDE { + result = append(result, corrected...) + result = append(result, endpoint) + } else { + result = append(result, endpoint) + result = append(result, corrected...) + } + result = deduplicateSolarEclipsePathPoints(result) + if len(result) < 2 { + return nil, false + } + for index := 1; index < len(result); index++ { + if solarEclipsePathDistanceKM(result[index-1], result[index]) > + 2*solarEclipseCentralBandTargetSpacingKM { + return nil, false + } + } + return result, true +} + +func solarEclipseCentralBandContactSampleTimes(times []float64, startJDE, endJDE float64) []float64 { + window := math.Min(solarEclipseCentralBandContactFineWindowDays, (endJDE-startJDE)/4) + if window <= solarEclipseCentralBandContactFineStepDays { + return times + } + for jd := startJDE + solarEclipseCentralBandContactFineStepDays; jd < startJDE+window; jd += solarEclipseCentralBandContactFineStepDays { + times = append(times, jd) + } + for jd := endJDE - window + solarEclipseCentralBandContactFineStepDays; jd < endJDE; jd += solarEclipseCentralBandContactFineStepDays { + times = append(times, jd) + } + sort.Float64s(times) + return uniqueSolarEclipsePathTimes(times) +} + +func (solver solarEclipseSolver) centralBandSweepSampleAt( + jd float64, +) (solarEclipseCentralBandSweepSample, bool) { + moon, axis, sun := solver.besselGeometryAt(jd) + samples := make([]solarEclipsePartialBoundarySample, solarEclipseCentralBandBoundaryPoints) + for index := range samples { + angle := 2 * math.Pi * float64(index) / float64(len(samples)) + point, ok := solver.shadowFootprintPointAt( + jd, moon, axis, sun, angle, solarEclipseCentralShadow, + ) + samples[index] = solarEclipsePartialBoundarySample{point: point, ok: ok, angle: angle} + } + samples = solver.refineShadowFootprintTransitions( + jd, moon, axis, sun, samples, solarEclipseCentralShadow, + ) + samples = solver.refineShadowFootprintSpacing( + jd, moon, axis, sun, samples, solarEclipseCentralShadow, + solarEclipseCentralBandTargetSpacingKM, + ) + arc := solarEclipseLongestOpenShadowArc(samples) + if len(arc) < 2 { + return solarEclipseCentralBandSweepSample{}, false + } + geometry := solarEclipseCentralBandGeometry{jde: jd, moon: moon, axis: axis, sun: sun} + angle, ok := solver.centralBandEnvelopeAngle(geometry, arc) + if !ok { + return solarEclipseCentralBandSweepSample{}, false + } + envelope, ok := solver.centralShadowPointAt(jd, angle) + if !ok { + return solarEclipseCentralBandSweepSample{}, false + } + // 弧角沿环单向展开,可以超过 2π;包络角取 [0,2π) 支,比较前必须映射到同一支, + // 否则展开段的样本会被两端同时丢弃。 + envelopeAngle := angle + if len(arc) > 0 { + envelopeAngle = solarEclipseArcBranchAngle(angle, arc[0].angle) + } + firstCap := []SolarEclipsePathPoint{envelope} + secondCap := []SolarEclipsePathPoint{envelope} + for index := len(arc) - 1; index >= 0; index-- { + if arc[index].angle >= envelopeAngle { + continue + } + firstCap = append(firstCap, arc[index].point) + } + for _, sample := range arc { + if sample.angle <= envelopeAngle { + continue + } + secondCap = append(secondCap, sample.point) + } + firstCap = deduplicateSolarEclipsePathPoints(firstCap) + secondCap = deduplicateSolarEclipsePathPoints(secondCap) + if len(firstCap) < 2 || len(secondCap) < 2 { + return solarEclipseCentralBandSweepSample{}, false + } + return solarEclipseCentralBandSweepSample{ + jde: jd, envelope: envelope, + first: firstCap[len(firstCap)-1], second: secondCap[len(secondCap)-1], + firstCap: firstCap, secondCap: secondCap, + }, true +} + +// solarEclipseArcBranchAngle 把 [0,2π) 的角度映射到以 reference 为起点的那条展开支上。 +func solarEclipseArcBranchAngle(angle, reference float64) float64 { + return reference + math.Remainder(angle-reference, 2*math.Pi) +} + +func solarEclipseLongestOpenShadowArc( + samples []solarEclipsePartialBoundarySample, +) []solarEclipsePartialBoundarySample { + if len(samples) == 0 { + return nil + } + invalid := -1 + for index, sample := range samples { + if !sample.ok { + invalid = index + break + } + } + if invalid < 0 { + return nil + } + var longest, current []solarEclipsePartialBoundarySample + for offset := 1; offset <= len(samples); offset++ { + sample := samples[(invalid+offset)%len(samples)] + if !sample.ok { + if len(current) > len(longest) { + longest = append([]solarEclipsePartialBoundarySample(nil), current...) + } + current = nil + continue + } + if len(current) > 0 { + for sample.angle <= current[len(current)-1].angle { + sample.angle += 2 * math.Pi + } + } + current = append(current, sample) + } + if len(current) > len(longest) { + longest = current + } + return longest +} + +func (solver solarEclipseSolver) centralBandEnvelopeAngle( + geometry solarEclipseCentralBandGeometry, + arc []solarEclipsePartialBoundarySample, +) (float64, bool) { + const timeStep = 0.5 / 86400.0 + beforeMoon, beforeAxis, beforeSun := solver.besselGeometryAt(geometry.jde - timeStep) + afterMoon, afterAxis, afterSun := solver.besselGeometryAt(geometry.jde + timeStep) + before := solarEclipseCentralBandGeometry{ + jde: geometry.jde - timeStep, moon: beforeMoon, axis: beforeAxis, sun: beforeSun, + } + after := solarEclipseCentralBandGeometry{ + jde: geometry.jde + timeStep, moon: afterMoon, axis: afterAxis, sun: afterSun, + } + previousAngle := arc[0].angle + previous, previousOK := solver.centralBandSweepJacobian(geometry, before, after, previousAngle) + bestAngle, bestValue := previousAngle, math.Abs(previous) + for index := 1; index < len(arc); index++ { + angle := arc[index].angle + value, ok := solver.centralBandSweepJacobian(geometry, before, after, angle) + if ok && math.Abs(value) < bestValue { + bestAngle, bestValue = angle, math.Abs(value) + } + if previousOK && ok && previous*value <= 0 { + left, right := previousAngle, angle + leftValue := previous + for iteration := 0; iteration < 48 && right-left > 1e-11; iteration++ { + middle := (left + right) / 2 + middleValue, middleOK := solver.centralBandSweepJacobian(geometry, before, after, middle) + if !middleOK { + return 0, false + } + if leftValue*middleValue <= 0 { + right = middle + } else { + left, leftValue = middle, middleValue + } + } + return math.Mod((left+right)/2, 2*math.Pi), true + } + previousAngle, previous, previousOK = angle, value, ok + } + if bestValue <= 1e-5 { + return math.Mod(bestAngle, 2*math.Pi), true + } + return 0, false +} + +func (solver solarEclipseSolver) centralBandSweepJacobian( + geometry, before, after solarEclipseCentralBandGeometry, + angle float64, +) (float64, bool) { + const angleStep = 1e-5 + center, centerOK := solver.centralShadowPointAtGeometry(geometry, angle) + angleBefore, angleBeforeOK := solver.centralShadowPointAtGeometry(geometry, angle-angleStep) + angleAfter, angleAfterOK := solver.centralShadowPointAtGeometry(geometry, angle+angleStep) + timeBefore, timeBeforeOK := solver.centralShadowPointAtGeometry(before, angle) + timeAfter, timeAfterOK := solver.centralShadowPointAtGeometry(after, angle) + if !centerOK || !angleBeforeOK || !angleAfterOK || !timeBeforeOK || !timeAfterOK { + return 0, false + } + latitudeScale := math.Cos(center.Latitude * rad) + angleX := math.Remainder(angleAfter.Longitude-angleBefore.Longitude, 360) * latitudeScale + angleY := angleAfter.Latitude - angleBefore.Latitude + timeX := math.Remainder(timeAfter.Longitude-timeBefore.Longitude, 360) * latitudeScale + timeY := timeAfter.Latitude - timeBefore.Latitude + value := angleX*timeY - angleY*timeX + return value, finite(value) +} + +func (solver solarEclipseSolver) centralShadowPointAtGeometry( + geometry solarEclipseCentralBandGeometry, + angle float64, +) (SolarEclipsePathPoint, bool) { + return solver.shadowFootprintPointAt( + geometry.jde, geometry.moon, geometry.axis, geometry.sun, + math.Mod(angle+2*math.Pi, 2*math.Pi), solarEclipseCentralShadow, + ) +} + +func (solver solarEclipseSolver) centralShadowPointAt(jd, angle float64) (SolarEclipsePathPoint, bool) { + moon, axis, sun := solver.besselGeometryAt(jd) + return solver.shadowFootprintPointAt( + jd, moon, axis, sun, math.Mod(angle+2*math.Pi, 2*math.Pi), solarEclipseCentralShadow, + ) +} + +func (solver solarEclipseSolver) refineCentralBandSweepSamples( + samples []solarEclipseCentralBandSweepSample, +) []solarEclipseCentralBandSweepSample { + if len(samples) < 2 { + return samples + } + result := make([]solarEclipseCentralBandSweepSample, 0, len(samples)) + result = append(result, samples[0]) + for index := 1; index < len(samples); index++ { + result = solver.appendRefinedCentralBandSweepSample(result, samples[index-1], samples[index], 0) + } + return result +} + +func (solver solarEclipseSolver) appendRefinedCentralBandSweepSample( + result []solarEclipseCentralBandSweepSample, + start, end solarEclipseCentralBandSweepSample, + depth int, +) []solarEclipseCentralBandSweepSample { + maximumDistance := math.Max( + solarEclipsePathDistanceKM(start.envelope, end.envelope), + math.Max( + solarEclipsePathDistanceKM(start.first, end.first), + solarEclipsePathDistanceKM(start.second, end.second), + ), + ) + if depth >= solarEclipseShadowFootprintAdaptiveMaxDepth || + maximumDistance <= solarEclipseCentralBandTargetSpacingKM { + return append(result, end) + } + middle, ok := solver.centralBandSweepSampleAt((start.jde + end.jde) / 2) + if !ok { + return append(result, end) + } + keep := solarEclipsePathDistanceKM(start.first, middle.first) + + solarEclipsePathDistanceKM(start.second, middle.second) + reverse := solarEclipsePathDistanceKM(start.first, middle.second) + + solarEclipsePathDistanceKM(start.second, middle.first) + if reverse < keep { + middle.first, middle.second = middle.second, middle.first + middle.firstCap, middle.secondCap = middle.secondCap, middle.firstCap + } + result = solver.appendRefinedCentralBandSweepSample(result, start, middle, depth+1) + return solver.appendRefinedCentralBandSweepSample(result, middle, end, depth+1) +} + +func deduplicateSolarEclipsePathPoints(points []SolarEclipsePathPoint) []SolarEclipsePathPoint { + if len(points) < 2 { + return points + } + result := make([]SolarEclipsePathPoint, 0, len(points)) + for _, point := range points { + if len(result) == 0 || solarEclipsePathDistanceKM(result[len(result)-1], point) > 0.001 { + result = append(result, point) + } + } + return result +} + +func normalizeSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootprintOptions) SolarEclipsePartialFootprintOptions { + if options.StepDays <= 0 || math.IsNaN(options.StepDays) || math.IsInf(options.StepDays, 0) { + options.StepDays = solarEclipsePartialFootprintDefaultStepDays + } + if options.StepDays < solarEclipsePathMinStepDays { + options.StepDays = solarEclipsePathMinStepDays + } + if options.BoundaryPoints <= 0 { + options.BoundaryPoints = solarEclipsePartialFootprintDefaultBoundaryPoints + } + if options.BoundaryPoints < solarEclipsePartialFootprintMinBoundaryPoints { + options.BoundaryPoints = solarEclipsePartialFootprintMinBoundaryPoints + } + if options.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints { + options.BoundaryPoints = solarEclipsePartialFootprintMaxBoundaryPoints + } + if options.CentralShadowStepDays <= 0 || math.IsNaN(options.CentralShadowStepDays) || math.IsInf(options.CentralShadowStepDays, 0) { + options.CentralShadowStepDays = 0 + } else if options.CentralShadowStepDays < solarEclipsePathMinStepDays { + options.CentralShadowStepDays = solarEclipsePathMinStepDays + } + if options.RiseSetStepDays > 0 && options.RiseSetStepDays < solarEclipsePathMinStepDays { + options.RiseSetStepDays = solarEclipsePathMinStepDays + } + if len(options.MagnitudeValues) > 0 { + values := make([]float64, 0, len(options.MagnitudeValues)) + for _, value := range options.MagnitudeValues { + if !isFinite(value) || value <= 0 { + continue + } + duplicate := false + for _, existing := range values { + if math.Abs(existing-value) <= 1e-12 { + duplicate = true + break + } + } + if !duplicate { + values = append(values, value) + } + } + sort.Float64s(values) + if len(values) > solarEclipseMagnitudeContourMaxValues { + values = values[:solarEclipseMagnitudeContourMaxValues] + } + options.MagnitudeValues = values + } + if len(options.GreatestTimeValues) > 0 { + values := make([]float64, 0, len(options.GreatestTimeValues)) + for _, value := range options.GreatestTimeValues { + if !isFinite(value) { + continue + } + duplicate := false + for _, existing := range values { + if math.Abs(existing-value) <= 1e-9 { + duplicate = true + break + } + } + if !duplicate { + values = append(values, value) + } + } + sort.Float64s(values) + if len(values) > greatestTimeContourMaxLevels { + values = values[:greatestTimeContourMaxLevels] + } + options.GreatestTimeValues = values + } + return options +} + +func (solver solarEclipseSolver) centralPathPoints( + startJDE, endJDE, greatestJDE float64, + options SolarEclipsePathOptions, +) ([]SolarEclipsePathPoint, float64) { + if endJDE < startJDE { + startJDE, endJDE = endJDE, startJDE + } + if startJDE == 0 || endJDE == 0 || endJDE <= startJDE { + return nil, options.StepDays + } + + times, stepDays := solarEclipseMovingDiskEngine().sampleTimes( + startJDE, endJDE, greatestJDE, options.StepDays, + ) + + points := make([]SolarEclipsePathPoint, 0, len(times)) + for _, jd := range times { + point, ok := solver.centralPathPointAt(jd) + if ok { + points = append(points, point) + } + } + // A coarse caller step can place both contact endpoints exactly on the + // numerical horizon where the Earth intersection is rejected. Keep the + // public center line usable by making one bounded fallback pass through the + // event interval instead of returning a single greatest-point sample. + if len(points) < 2 { + fallbackCount := 32 + for index := 0; index <= fallbackCount; index++ { + jd := startJDE + (endJDE-startJDE)*float64(index)/float64(fallbackCount) + point, ok := solver.centralPathPointAt(jd) + if !ok { + continue + } + duplicate := false + for _, existing := range points { + if math.Abs(existing.JDE-jd) <= solarEclipsePathDuplicateTimeDays { + duplicate = true + break + } + } + if !duplicate { + points = append(points, point) + } + } + sort.Slice(points, func(i, j int) bool { return points[i].JDE < points[j].JDE }) + if fallbackStep := (endJDE - startJDE) / float64(fallbackCount); fallbackStep > 0 && fallbackStep < stepDays { + stepDays = fallbackStep + } + } + sort.Slice(points, func(i, j int) bool { return points[i].JDE < points[j].JDE }) + if options.TargetSpacingKM > 0 { + points = solver.refineCentralPathSpacing(points, options.TargetSpacingKM) + } + points = normalizeSolarEclipsePathPointSeries(points) + if len(points) < 2 { + return nil, stepDays + } + return points, stepDays +} + +func (solver solarEclipseSolver) partialFootprints( + startJDE, endJDE, greatestJDE float64, + options SolarEclipsePartialFootprintOptions, +) ([]SolarEclipsePartialFootprint, float64, int) { + footprints, stepDays, boundaryPoints := solver.shadowFootprintsWithSpacing( + startJDE, + endJDE, + greatestJDE, + options.StepDays, + options.BoundaryPoints, + solarEclipsePenumbralShadow, + solarEclipsePartialFootprintTargetSpacingKM, + ) + return footprints, stepDays, boundaryPoints +} + +func (solver solarEclipseSolver) shadowFootprints( + startJDE, endJDE, greatestJDE, requestedStepDays float64, + boundaryPoints int, + kind solarEclipseShadowKind, +) ([]SolarEclipsePartialFootprint, float64, int) { + return solver.shadowFootprintsWithSpacing( + startJDE, endJDE, greatestJDE, requestedStepDays, + boundaryPoints, kind, 0, + ) +} + +func (solver solarEclipseSolver) shadowFootprintsWithSpacing( + startJDE, endJDE, greatestJDE, requestedStepDays float64, + boundaryPoints int, + kind solarEclipseShadowKind, + targetSpacingKM float64, +) ([]SolarEclipsePartialFootprint, float64, int) { + if endJDE < startJDE { + startJDE, endJDE = endJDE, startJDE + } + if startJDE == 0 || endJDE == 0 || endJDE <= startJDE { + return nil, requestedStepDays, boundaryPoints + } + + times, stepDays := solarEclipseMovingDiskEngine().sampleTimes( + startJDE, endJDE, greatestJDE, requestedStepDays, + ) + + // Very small, near-grazing footprints can occupy less than one angular + // sample at a caller-requested low boundary resolution. Retry the whole + // sequence at a bounded finer resolution only when the first pass found no + // usable footprint; ordinary events keep the requested cost unchanged. + maximumBoundaryPoints := solarEclipseMaximumBoundaryPoints(len(times)) + retryBoundaryPoints := maximumBoundaryPoints + if retryBoundaryPoints > 360 { + retryBoundaryPoints = 360 + } + effectiveBoundaryPoints := solarEclipseEffectiveBoundaryPoints(len(times), boundaryPoints) + // With very dense temporal sampling, the fixed spatial refinement target + // can add many more vertices than the requested angular resolution. The + // shared aggregate budget above already limits the two main sweeps; avoid + // defeating that limit by repeating spatial refinement at every second. + if targetSpacingKM > 0 && len(times)*effectiveBoundaryPoints > solarEclipsePartialFootprintMaxPointCount/3 { + targetSpacingKM = 0 + } + var footprints []SolarEclipsePartialFootprint + for { + footprints = make([]SolarEclipsePartialFootprint, 0, len(times)) + for _, jd := range times { + footprint := solver.shadowFootprintAtWithSpacing( + jd, effectiveBoundaryPoints, kind, targetSpacingKM, + ) + if len(footprint.Boundaries) > 0 { + footprints = append(footprints, footprint) + } + } + if len(footprints) > 0 || effectiveBoundaryPoints >= retryBoundaryPoints { + break + } + effectiveBoundaryPoints *= 4 + if effectiveBoundaryPoints < 96 { + effectiveBoundaryPoints = 96 + } + if effectiveBoundaryPoints > retryBoundaryPoints { + effectiveBoundaryPoints = retryBoundaryPoints + } + } + return footprints, stepDays, effectiveBoundaryPoints +} + +func solarEclipseEffectiveBoundaryPoints(sampleCount, requested int) int { + if requested < solarEclipsePartialFootprintMinBoundaryPoints { + requested = solarEclipsePartialFootprintMinBoundaryPoints + } + if sampleCount < 1 { + return requested + } + maximum := solarEclipseMaximumBoundaryPoints(sampleCount) + if requested > maximum { + return maximum + } + return requested +} + +func solarEclipseMaximumBoundaryPoints(sampleCount int) int { + if sampleCount < 1 { + return solarEclipsePartialFootprintMaxBoundaryPoints + } + maximum := solarEclipsePartialFootprintMaxPointCount / sampleCount + if maximum < solarEclipsePartialFootprintMinBoundaryPoints { + maximum = solarEclipsePartialFootprintMinBoundaryPoints + } + if maximum > solarEclipsePartialFootprintMaxBoundaryPoints { + maximum = solarEclipsePartialFootprintMaxBoundaryPoints + } + return maximum +} + +func solarEclipseSharedBoundaryPoints(requested, partialSamples, shadowSamples, reserved int) int { + if requested < solarEclipsePartialFootprintMinBoundaryPoints { + requested = solarEclipsePartialFootprintMinBoundaryPoints + } + if partialSamples < 0 { + partialSamples = 0 + } + if shadowSamples < 0 { + shadowSamples = 0 + } + if reserved < 0 { + reserved = 0 + } + remaining := solarEclipsePartialFootprintMaxPointCount - reserved + if remaining <= 0 || partialSamples+shadowSamples <= 0 { + return requested + } + shared := remaining / (partialSamples + shadowSamples) + if shared < solarEclipsePartialFootprintMinBoundaryPoints { + shared = solarEclipsePartialFootprintMinBoundaryPoints + } + if shared < requested { + return shared + } + return requested +} + +func solarEclipseFootprintPointCount(footprints []SolarEclipsePartialFootprint) int { + total := 0 + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + if len(boundary) > solarEclipsePartialFootprintMaxPointCount-total { + return solarEclipsePartialFootprintMaxPointCount + } + total += len(boundary) + } + } + return total +} + +func (solver solarEclipseSolver) magnitudeContours( + startJDE, endJDE, centralStartJDE, centralEndJDE, greatestJDE float64, + options SolarEclipsePartialFootprintOptions, + maximumMagnitude float64, + hybrid bool, + precomputedMagnitudeOne ...[][]SolarEclipsePathPoint, +) []SolarEclipseMagnitudeContour { + if len(options.MagnitudeValues) == 0 || startJDE == 0 || endJDE == 0 || endJDE <= startJDE { + return nil + } + contours := make([]SolarEclipseMagnitudeContour, 0, len(options.MagnitudeValues)) + for _, magnitude := range options.MagnitudeValues { + if magnitude > maximumMagnitude+1e-9 { + continue + } + var segments [][]SolarEclipsePathPoint + if math.Abs(magnitude-1) <= 1e-12 && len(precomputedMagnitudeOne) > 0 && + len(precomputedMagnitudeOne[0]) > 0 { + segments = precomputedMagnitudeOne[0] + } else { + segments = solver.magnitudeContourSegments( + startJDE, endJDE, centralStartJDE, centralEndJDE, + greatestJDE, magnitude, options.StepDays, hybrid, + ) + } + if len(segments) == 0 { + continue + } + northern, southern := solarEclipseMagnitudeContourCompatibilitySides(segments) + contours = append(contours, SolarEclipseMagnitudeContour{ + Magnitude: magnitude, + Segments: segments, + NorthernLimit: northern, + SouthernLimit: southern, + }) + } + return contours +} diff --git a/basic/solar_eclipse_central_envelope.go b/basic/solar_eclipse_central_envelope.go new file mode 100644 index 0000000..da7663c --- /dev/null +++ b/basic/solar_eclipse_central_envelope.go @@ -0,0 +1,256 @@ +package basic + +import "math" + +const ( + solarEclipseCentralEnvelopeArcStepDegrees = 0.4 + solarEclipseCentralEnvelopeMinArcStepDegrees = 0.01 + solarEclipseCentralEnvelopeMaxArcSteps = 2000 + solarEclipseCentralEnvelopeMaxSpacingKM = 48.0 + solarEclipseCentralEnvelopeEndDistanceKM = 50.0 + solarEclipseTotalEnvelopeTargetSpacingKM = 200.0 +) + +// centralTwoLimitBandEnvelope follows both continuous local-centrality limits +// between the solved sunrise and sunset closures. Unlike a union of discrete +// shadow footprints, every non-horizon point satisfies C=0 and dC/dt=0. +func (solver solarEclipseSolver) centralTwoLimitBandEnvelope( + closures [][]SolarEclipsePathPoint, + referenceJDE float64, +) [][]SolarEclipsePathPoint { + branches := solver.centralBandVectorBranches(closures, referenceJDE) + if len(branches) != 2 { + return nil + } + return joinSolarEclipseCentralBandBranches(branches, closures) +} + +func (solver solarEclipseSolver) centralBandVectorBranches( + closures [][]SolarEclipsePathPoint, + referenceJDE float64, +) [][]SolarEclipsePathPoint { + if len(closures) != 2 || len(closures[0]) < 2 || len(closures[1]) < 2 { + return nil + } + startRoots := []SolarEclipsePathPoint{closures[0][0], closures[0][len(closures[0])-1]} + endRoots := []SolarEclipsePathPoint{closures[1][0], closures[1][len(closures[1])-1]} + branches := make([][]SolarEclipsePathPoint, 2) + endIndices := [2]int{-1, -1} + solver = solver.withLocalEphemeris() + for index, root := range startRoots { + var transitions []SolarEclipsePathPoint + branch, endIndex, ok := solver.traceCentralBandVectorEnvelope(root, endRoots, &transitions, referenceJDE) + if !ok { + return nil + } + branches[index], endIndices[index] = branch, endIndex + } + if endIndices[0] == endIndices[1] { + return nil + } + return branches +} + +func solarEclipseTotalBandEnvelope( + segments, closures [][]SolarEclipsePathPoint, +) [][]SolarEclipsePathPoint { + if len(segments) != 2 || len(segments[0]) < 2 || len(segments[1]) < 2 { + return nil + } + branches := make([][]SolarEclipsePathPoint, 2) + for index, segment := range segments { + branch := append([]SolarEclipsePathPoint(nil), segment...) + if branch[0].JDE > branch[len(branch)-1].JDE { + for left, right := 0, len(branch)-1; left < right; left, right = left+1, right-1 { + branch[left], branch[right] = branch[right], branch[left] + } + } + branches[index] = branch + } + if len(closures) == 2 && len(closures[0]) > 1 && len(closures[1]) > 1 && + solarEclipsePathDistanceKM(closures[0][0], closures[1][0]) < 0.01 && + solarEclipsePathDistanceKM(closures[0][len(closures[0])-1], closures[1][len(closures[1])-1]) < 0.01 { + ring := append([]SolarEclipsePathPoint{}, branches[0]...) + for index := len(branches[1]) - 1; index >= 0; index-- { + ring = append(ring, branches[1][index]) + } + ring = deduplicateSolarEclipsePathPoints(ring) + if len(ring) >= 4 { + ring = append(ring, ring[0]) + return [][]SolarEclipsePathPoint{ring} + } + } + return joinSolarEclipseCentralBandBranches(branches, closures) +} + +func joinSolarEclipseCentralBandBranches( + branches, closures [][]SolarEclipsePathPoint, +) [][]SolarEclipsePathPoint { + if len(branches) != 2 || len(branches[0]) == 0 || len(branches[1]) == 0 || + len(closures) != 2 || len(closures[0]) == 0 || len(closures[1]) == 0 { + return nil + } + endClosure, ok := orientSolarEclipsePath( + closures[1], branches[0][len(branches[0])-1], branches[1][len(branches[1])-1], + ) + if !ok { + return nil + } + startClosure, ok := orientSolarEclipsePath( + closures[0], branches[1][0], branches[0][0], + ) + if !ok { + return nil + } + + ring := make([]SolarEclipsePathPoint, 0, + len(branches[0])+len(branches[1])+len(startClosure)+len(endClosure), + ) + ring = append(ring, branches[0]...) + ring = append(ring, endClosure[1:]...) + for index := len(branches[1]) - 2; index >= 0; index-- { + ring = append(ring, branches[1][index]) + } + ring = append(ring, startClosure[1:]...) + ring = deduplicateSolarEclipsePathPoints(ring) + if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 { + return nil + } + ring[len(ring)-1] = ring[0] + return [][]SolarEclipsePathPoint{ring} +} + +func (solver solarEclipseSolver) traceCentralBandEnvelope( + root SolarEclipsePathPoint, + endRoots []SolarEclipsePathPoint, + referenceJDE float64, +) ([]SolarEclipsePathPoint, int, bool) { + state, ok := solver.nonCentralBandStateAt(root, referenceJDE) + if !ok { + return nil, -1, false + } + step := solarEclipseCentralEnvelopeArcStepDegrees + next, ok := solver.centralBandEnvelopeVisibleStart(state, step, referenceJDE) + if !ok { + return []SolarEclipsePathPoint{root}, -1, false + } + if dotSolarEclipse3(next.tangent, subtractSolarEclipse3(next.coordinates, state.coordinates)) < 0 { + for index := range next.tangent { + next.tangent[index] = -next.tangent[index] + } + } + points := []SolarEclipsePathPoint{root, next.point} + state = next + for count := 0; count < solarEclipseCentralEnvelopeMaxArcSteps; count++ { + predictor := state.coordinates + for index := range predictor { + predictor[index] += step * state.tangent[index] + } + candidate, iterations, candidateOK := solver.correctNonCentralBandBoundary( + predictor, state.tangent, referenceJDE, + ) + if !candidateOK { + step /= 2 + if step < solarEclipseCentralEnvelopeMinArcStepDegrees { + return points, -1, false + } + continue + } + if dotSolarEclipse3(candidate.tangent, state.tangent) < 0 { + for index := range candidate.tangent { + candidate.tangent[index] = -candidate.tangent[index] + } + } + distance := solarEclipsePathDistanceKM(state.point, candidate.point) + if distance > solarEclipseCentralEnvelopeMaxSpacingKM { + step /= 2 + if step < solarEclipseCentralEnvelopeMinArcStepDegrees { + return points, -1, false + } + continue + } + if candidate.point.SunAltitude < 0 { + endIndex, endDistance := nearestSolarEclipsePathPoint(state.point, candidate.point, endRoots) + if endIndex < 0 || endDistance > solarEclipseCentralEnvelopeEndDistanceKM { + return points, endIndex, false + } + points = append(points, endRoots[endIndex]) + return points, endIndex, true + } + points = append(points, candidate.point) + state = candidate + if distance < solarEclipseCentralEnvelopeMaxSpacingKM/2 && iterations <= 4 { + step = math.Min(solarEclipseCentralEnvelopeArcStepDegrees, step*1.5) + } + } + return points, -1, false +} + +func (solver solarEclipseSolver) centralBandEnvelopeVisibleStart( + state solarEclipseNonCentralBandState, + step, referenceJDE float64, +) (solarEclipseNonCentralBandState, bool) { + best := solarEclipseNonCentralBandState{} + found := false + for trialStep := step; trialStep >= solarEclipseCentralEnvelopeMinArcStepDegrees; trialStep /= 2 { + for _, direction := range []float64{1, -1} { + predictor := state.coordinates + tangent := state.tangent + for index := range predictor { + tangent[index] *= direction + predictor[index] += trialStep * tangent[index] + } + candidate, _, ok := solver.correctNonCentralBandBoundary(predictor, tangent, referenceJDE) + if ok && candidate.point.SunAltitude > 0 && + (!found || candidate.point.SunAltitude > best.point.SunAltitude) { + best, found = candidate, true + } + } + if found { + break + } + } + return best, found +} + +func nearestSolarEclipsePathPoint( + first, second SolarEclipsePathPoint, + points []SolarEclipsePathPoint, +) (int, float64) { + bestIndex, bestDistance := -1, math.Inf(1) + for index, point := range points { + distance := math.Min( + solarEclipsePathDistanceKM(first, point), + solarEclipsePathDistanceKM(second, point), + ) + if distance < bestDistance { + bestIndex, bestDistance = index, distance + } + } + return bestIndex, bestDistance +} + +func orientSolarEclipsePath( + points []SolarEclipsePathPoint, + start, end SolarEclipsePathPoint, +) ([]SolarEclipsePathPoint, bool) { + if len(points) < 2 { + return nil, false + } + oriented := append([]SolarEclipsePathPoint(nil), points...) + direct := solarEclipsePathDistanceKM(start, oriented[0]) + + solarEclipsePathDistanceKM(end, oriented[len(oriented)-1]) + reverse := solarEclipsePathDistanceKM(start, oriented[len(oriented)-1]) + + solarEclipsePathDistanceKM(end, oriented[0]) + if reverse < direct { + for left, right := 0, len(oriented)-1; left < right; left, right = left+1, right-1 { + oriented[left], oriented[right] = oriented[right], oriented[left] + } + } + if solarEclipsePathDistanceKM(start, oriented[0]) > 0.1 || + solarEclipsePathDistanceKM(end, oriented[len(oriented)-1]) > 0.1 { + return nil, false + } + oriented[0], oriented[len(oriented)-1] = start, end + return oriented, true +} diff --git a/basic/solar_eclipse_central_envelope_test.go b/basic/solar_eclipse_central_envelope_test.go new file mode 100644 index 0000000..d5fa0e2 --- /dev/null +++ b/basic/solar_eclipse_central_envelope_test.go @@ -0,0 +1,102 @@ +package basic + +import ( + "math" + "testing" +) + +func TestSolarEclipse20120521CentralBandIsContinuousCriticalEnvelope(t *testing.T) { + seed := JDECalc(2012, 5, 21) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, + }) + if result.Eclipse.Type != SolarEclipseAnnular || + result.Eclipse.Centrality != SolarEclipseCentralTwoLimits { + t.Fatalf("eclipse=%s/%s, want two-limit annular", result.Eclipse.Type, result.Eclipse.Centrality) + } + if len(result.CentralBandSegments) != 1 { + t.Fatalf("central-band segments=%d, want one continuous envelope", len(result.CentralBandSegments)) + } + + ring := result.CentralBandSegments[0] + if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 { + t.Fatal("central-band envelope is not closed") + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + interiorPoints := 0 + for index, point := range ring { + if index > 0 { + if distance := solarEclipsePathDistanceKM(ring[index-1], point); distance > + solarEclipseCentralEnvelopeMaxSpacingKM+1e-6 { + t.Fatalf("central-band edge %d is %.3f km, want at most %.3f km", + index-1, distance, solarEclipseCentralEnvelopeMaxSpacingKM) + } + } + if solarEclipseCentralEnvelopePointOnHorizonClosure( + point, result.CentralBandHorizonClosures, + ) { + continue + } + residual, ok := solarEclipseNonCentralBandBoundaryResidualAt( + solver.magnitudeEvaluationAt(point.JDE), point.Longitude, point.Latitude, + ) + if !ok || math.Abs(residual[0]) > 1e-6 || + math.Abs(residual[1]) > solarEclipseNonCentralBandDerivativeTolerance { + t.Fatalf("central-band point %d altitude=%g residual=(%g,%g), want critical-envelope solution", + index, point.SunAltitude, residual[0], residual[1]) + } + interiorPoints++ + } + if interiorPoints < 100 { + t.Fatalf("critical-envelope points=%d, want a densely sampled continuous boundary", interiorPoints) + } + + // NADC 3D eclipse data publishes these samples on the two 2012 central + // limits. The allowance covers ephemeris/delta-T model differences and the + // finite spacing between our continuation samples. + for _, anchor := range []struct { + name string + point SolarEclipsePathPoint + }{ + {name: "north limit", point: SolarEclipsePathPoint{Longitude: 120.9238, Latitude: 24.6531}}, + {name: "south limit", point: SolarEclipsePathPoint{Longitude: 120.3071, Latitude: 27.5328}}, + } { + nearest := math.Inf(1) + for _, point := range ring { + nearest = math.Min(nearest, solarEclipsePathDistanceKM(point, anchor.point)) + } + if nearest > 60 { + t.Fatalf("central-band %s is %.3f km from the NADC reference sample", anchor.name, nearest) + } + } +} + +func TestSolarEclipseCentralEnvelopeIndependentOfRiseSetOutput(t *testing.T) { + seed := JDECalc(2012, 5, 21) + withCurves := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440, BoundaryPoints: 96}) + withoutCurves := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440, BoundaryPoints: 96, DisableRiseSetCurves: true}) + if len(withoutCurves.CentralBandSegments) == 0 { + t.Fatal("disabling output curves removed the authoritative central envelope") + } + if len(withoutCurves.RiseSetCurves) != 0 { + t.Fatal("disabled rise/set curves were returned") + } + if len(withCurves.CentralBandSegments) != len(withoutCurves.CentralBandSegments) { + t.Fatalf("central envelope segment count changed: %d vs %d", len(withCurves.CentralBandSegments), len(withoutCurves.CentralBandSegments)) + } +} + +func solarEclipseCentralEnvelopePointOnHorizonClosure( + point SolarEclipsePathPoint, + closures [][]SolarEclipsePathPoint, +) bool { + for _, closure := range closures { + for _, horizonPoint := range closure { + if solarEclipsePathDistanceKM(point, horizonPoint) <= 0.001 && + math.Abs(point.JDE-horizonPoint.JDE) <= solarEclipsePathDuplicateTimeDays { + return true + } + } + } + return false +} diff --git a/basic/solar_eclipse_duration_width_regression_test.go b/basic/solar_eclipse_duration_width_regression_test.go new file mode 100644 index 0000000..b3d3143 --- /dev/null +++ b/basic/solar_eclipse_duration_width_regression_test.go @@ -0,0 +1,79 @@ +package basic + +import ( + "math" + "testing" +) + +// Central durations at the greatest eclipse point, from NASA's five millennium +// catalogue ("Central Dur." column). The library publishes the same figure on +// SolarEclipseResult.CentralDurationDays. +var solarEclipseCatalogueCentralDurations = []struct { + date [3]int + seconds float64 + toleranceSec float64 +}{ + {[3]int{1991, 7, 11}, 6*60 + 53, 1.5}, + {[3]int{2009, 7, 22}, 6*60 + 39, 1.5}, + {[3]int{2024, 4, 8}, 4*60 + 28, 1.5}, + {[3]int{2017, 8, 21}, 2*60 + 40, 1.5}, + {[3]int{2020, 6, 21}, 38, 1.5}, + {[3]int{1136, 6, 1}, 3*60 + 18, 1.5}, +} + +func TestSolarEclipseCentralDurationMatchesCatalogue(t *testing.T) { + for _, item := range solarEclipseCatalogueCentralDurations { + seed := JDECalc(item.date[0], item.date[1], float64(item.date[2])) + result := SolarEclipseNASABulletinSplitK(seed) + if !result.HasCentral { + t.Fatalf("%04d-%02d-%02d is not a central eclipse", item.date[0], item.date[1], item.date[2]) + } + seconds := result.CentralDurationDays * 86400 + if math.Abs(seconds-item.seconds) > item.toleranceSec { + t.Fatalf("%04d-%02d-%02d central duration = %.1f s, catalogue %.0f s", + item.date[0], item.date[1], item.date[2], seconds, item.seconds) + } + } +} + +// The path width must stay a footprint figure: never wider than the Earth, and +// zero wherever no paired cross-section exists. A one-limit event has no pair at +// all, which is why the catalogues print no width for it (1874-10-10 is listed +// with "-"), and the analytic 2r/sin(altitude) form diverges towards the horizon +// where the pair is the only meaningful measurement. +func TestSolarEclipsePathWidthStaysPhysical(t *testing.T) { + cases := []struct { + date [3]int + oneLimit bool + }{ + {[3]int{2009, 7, 22}, false}, + {[3]int{2017, 8, 21}, false}, + {[3]int{2024, 4, 8}, false}, + {[3]int{2020, 6, 21}, false}, + {[3]int{1136, 6, 1}, false}, + {[3]int{1874, 10, 10}, true}, + } + for _, item := range cases { + seed := JDECalc(item.date[0], item.date[1], float64(item.date[2])) + path := SolarEclipseCentralPathNASABulletinSplitK(seed, SolarEclipsePathOptions{}) + if len(path.CenterLine) == 0 { + t.Fatalf("%04d-%02d-%02d exported no center line", item.date[0], item.date[1], item.date[2]) + } + maximum := 0.0 + for _, point := range path.CenterLine { + if point.WidthKM < 0 || point.WidthKM > solarEclipsePathMaxPossibleWidthKM { + t.Fatalf("%04d-%02d-%02d width %.1f km at (%.3f, %.3f) is not a footprint width", + item.date[0], item.date[1], item.date[2], point.WidthKM, point.Longitude, point.Latitude) + } + maximum = math.Max(maximum, point.WidthKM) + } + if item.oneLimit && maximum != 0 { + t.Fatalf("%04d-%02d-%02d is a one-limit event but reports a %.1f km width", + item.date[0], item.date[1], item.date[2], maximum) + } + if !item.oneLimit && maximum <= 0 { + t.Fatalf("%04d-%02d-%02d is a two-limit event but reports no width", + item.date[0], item.date[1], item.date[2]) + } + } +} diff --git a/basic/solar_eclipse_envelope_containment_test.go b/basic/solar_eclipse_envelope_containment_test.go new file mode 100644 index 0000000..35f1b8f --- /dev/null +++ b/basic/solar_eclipse_envelope_containment_test.go @@ -0,0 +1,51 @@ +package basic + +import ( + "fmt" + "testing" +) + +func TestSolarEclipseAnnularEnvelopeContainsShadow(t *testing.T) { + for _, date := range [][3]int{{4005, 4, 22}, {4329, 6, 12}} { + t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(date[0], date[1], float64(date[2])), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440, BoundaryPoints: 96, CentralShadowStepDays: 1.0 / 1440, + }) + if len(result.CentralBandSegments) == 0 { + t.Fatal("missing central envelope") + } + // The static horizon caps describe visible local greatest. Shadow + // slices at the limb also include sites whose greatest is below the + // horizon; use fully risen samples to audit the two side envelopes. + var visible []SolarEclipsePartialFootprint + for _, footprint := range result.CentralShadowFootprints { + for _, boundary := range footprint.Boundaries { + for _, point := range boundary { + if point.SunAltitude > 0.5 { + visible = append(visible, SolarEclipsePartialFootprint{Boundaries: [][]SolarEclipsePathPoint{{point}}}) + } + } + } + } + if len(visible) == 0 { + t.Fatal("missing visible shadow witnesses") + } + miss := solarEclipseBandFootprintMissDistanceKM(result.CentralBandSegments, visible) + if miss > 2 { + t.Fatalf("central envelope excludes instantaneous shadow by %.3f km", miss) + } + }) + } +} + +func BenchmarkSolarEclipseCentralEnvelopePath(b *testing.B) { + for _, date := range [][3]int{{4005, 4, 22}, {4329, 6, 12}, {2024, 4, 8}} { + b.Run(fmt.Sprint(date), func(b *testing.B) { + seed := JDECalc(date[0], date[1], float64(date[2])) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + SolarEclipseCentralPath(seed, SolarEclipsePathOptions{StepDays: 2.0 / 1440, TargetSpacingKM: 150}) + } + }) + } +} diff --git a/basic/solar_eclipse_hybrid_envelope.go b/basic/solar_eclipse_hybrid_envelope.go new file mode 100644 index 0000000..bd50335 --- /dev/null +++ b/basic/solar_eclipse_hybrid_envelope.go @@ -0,0 +1,404 @@ +package basic + +import "math" + +// Absolute Julian dates quantize a moving sky position at about 1e-10 radians. +const solarEclipseCentralVectorTolerance = 2e-10 + +// solarCentralBandSkyOffset uses a signed internal-contact radius and stable +// sky-plane coordinates. Unlike acos(dot) - abs(radius), these remain smooth +// when a hybrid shadow shrinks to zero and changes from annular to total. +func solarCentralBandSkyOffset(context localSolarEclipseStateContext, longitude, latitude float64) [3]float64 { + observer := localSolarEclipseObserverXYZ(context.gst, longitude*rad, latitude*rad, 0) + sun, moon := subtractSolarEclipse3(context.sunXYZ, observer), subtractSolarEclipse3(context.moonXYZ, observer) + sunDistance := math.Sqrt(dotSolarEclipse3(sun, sun)) + moonDistance := math.Sqrt(dotSolarEclipse3(moon, moon)) + for i := range sun { + sun[i] /= sunDistance + moon[i] /= moonDistance + } + equatorial := math.Hypot(sun[0], sun[1]) + east := [3]float64{-sun[1] / equatorial, sun[0] / equatorial, 0} + north := [3]float64{-sun[2] * east[1], sun[2] * east[0], equatorial} + radius := math.Asin(solarEclipseEarthEquatorialRadiusKM*context.params.umbralK*localSolarMoonRadiusScale/moonDistance) - + math.Asin(solarEclipseEarthEquatorialRadiusKM*solarEclipseSolarRadiusRatio/sunDistance) + return [3]float64{dotSolarEclipse3(moon, east), dotSolarEclipse3(moon, north), math.Sin(radius)} +} + +func solarCentralBandVectorResidual(evaluation solarEclipseRiseSetEvaluation, longitude, latitude, side float64) ([2]float64, bool) { + center := solarCentralBandSkyOffset(evaluation.center, longitude, latitude) + before := solarCentralBandSkyOffset(evaluation.before, longitude, latitude) + after := solarCentralBandSkyOffset(evaluation.after, longitude, latitude) + velocity := subtractSolarEclipse3(after, before) + v2 := velocity[0]*velocity[0] + velocity[1]*velocity[1] + discriminant := v2 - velocity[2]*velocity[2] + if v2 <= 0 || discriminant <= 0 { + return [2]float64{}, false + } + // At contact, offset = signedRadius * normal. The envelope condition is + // normal dot velocity = radiusVelocity, giving two regular signed branches. + cross := side * math.Sqrt(discriminant) + nx := (velocity[2]*velocity[0] - cross*velocity[1]) / v2 + ny := (velocity[2]*velocity[1] + cross*velocity[0]) / v2 + residual := [2]float64{center[0] - center[2]*nx, center[1] - center[2]*ny} + return residual, finite(residual[0]) && finite(residual[1]) +} + +func (solver solarEclipseSolver) centralBandVectorJacobian(coordinates [3]float64, referenceJDE, side float64, exact bool) ([2]float64, [2][3]float64, bool) { + evaluate := solver.magnitudeCandidateEvaluationAt + if exact { + evaluate = solver.magnitudeEvaluationAt + } + jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + evaluation := evaluate(jd) + residual, ok := solarCentralBandVectorResidual(evaluation, coordinates[0], coordinates[1], side) + if !ok { + return residual, [2][3]float64{}, false + } + steps := [3]float64{1e-4, 1e-4, 5 * solarEclipseNonCentralBandTimeScale / 86400} + var jacobian [2][3]float64 + for column := 0; column < 3; column++ { + shifted := coordinates + shifted[column] += steps[column] + shiftedEvaluation := evaluation + if column == 2 { + shiftedEvaluation = evaluate(jd + steps[column]/solarEclipseNonCentralBandTimeScale) + } + value, valid := solarCentralBandVectorResidual(shiftedEvaluation, shifted[0], shifted[1], side) + if !valid { + return residual, jacobian, false + } + for row := range residual { + jacobian[row][column] = (value[row] - residual[row]) / steps[column] + } + } + return residual, jacobian, true +} + +func (solver solarEclipseSolver) correctCentralBandVectorBoundary(predictor, tangent [3]float64, referenceJDE, side float64) (solarEclipseNonCentralBandState, bool) { + coordinates := predictor + exact := false + for iteration := 0; iteration < 16; iteration++ { + residual, jacobian, ok := solver.centralBandVectorJacobian(coordinates, referenceJDE, side, exact) + if !ok { + return solarEclipseNonCentralBandState{}, false + } + plane := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if math.Hypot(residual[0], residual[1]) <= solarEclipseCentralVectorTolerance && math.Abs(plane) <= 1e-9 { + jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + evaluation := solver.magnitudeEvaluationAt(jd) + check, valid := solarCentralBandVectorResidual(evaluation, coordinates[0], coordinates[1], side) + if !valid || math.Hypot(check[0], check[1]) > solarEclipseCentralVectorTolerance { + exact = true + continue + } + nextTangent, valid := solarEclipseMagnitudeArcTangent(jacobian) + state := evaluation.center.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0) + return solarEclipseNonCentralBandState{ + coordinates: coordinates, tangent: nextTangent, + point: SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, + }, valid + } + delta, valid := solveSolarEclipse3x3([3][3]float64{jacobian[0], jacobian[1], tangent}, [3]float64{-residual[0], -residual[1], -plane}) + if !valid { + return solarEclipseNonCentralBandState{}, false + } + scale := math.Max(1, math.Sqrt(dotSolarEclipse3(delta, delta))/2) + for i := range coordinates { + coordinates[i] += delta[i] / scale + } + if math.Abs(coordinates[1]) >= 89.999999 { + return solarEclipseNonCentralBandState{}, false + } + } + return solarEclipseNonCentralBandState{}, false +} + +func (solver solarEclipseSolver) traceCentralBandVectorEnvelope(root SolarEclipsePathPoint, endRoots []SolarEclipsePathPoint, transitions *[]SolarEclipsePathPoint, referenceJDE float64) ([]SolarEclipsePathPoint, int, bool) { + coordinates := [3]float64{root.Longitude, root.Latitude, (root.JDE - referenceJDE) * solarEclipseNonCentralBandTimeScale} + evaluation := solver.magnitudeEvaluationAt(root.JDE) + side := 1.0 + positive, _ := solarCentralBandVectorResidual(evaluation, root.Longitude, root.Latitude, 1) + negative, _ := solarCentralBandVectorResidual(evaluation, root.Longitude, root.Latitude, -1) + if math.Hypot(negative[0], negative[1]) < math.Hypot(positive[0], positive[1]) { + side = -1 + } + _, jacobian, ok := solver.centralBandVectorJacobian(coordinates, referenceJDE, side, false) + if !ok { + return nil, -1, false + } + tangent, ok := solarEclipseMagnitudeArcTangent(jacobian) + if !ok { + return nil, -1, false + } + step := solarEclipseCentralEnvelopeArcStepDegrees / 4 + state := solarEclipseNonCentralBandState{coordinates: coordinates, tangent: tangent, point: root} + var next solarEclipseNonCentralBandState + found := false + for _, direction := range []float64{1, -1} { + predictor, oriented := coordinates, tangent + for i := range predictor { + oriented[i] *= direction + predictor[i] += step * oriented[i] + } + candidate, valid := solver.correctCentralBandVectorBoundary(predictor, oriented, referenceJDE, side) + if valid && candidate.point.SunAltitude > 0 && (!found || candidate.point.SunAltitude > next.point.SunAltitude) { + if dotSolarEclipse3(candidate.tangent, oriented) < 0 { + for i := range candidate.tangent { + candidate.tangent[i] = -candidate.tangent[i] + } + } + next, found = candidate, true + } + } + if !found { + return nil, -1, false + } + points := []SolarEclipsePathPoint{root} + transitionIndex := 0 + // A hybrid transition can be less than a second from axis contact, where + // time also folds along a limit. Locate it by signed radius while tracing. + appendPoint := func(point SolarEclipsePathPoint) bool { + first := points[len(points)-1] + before := solarCentralBandSkyOffset(solver.localStateContextAt(first.JDE), first.Longitude, first.Latitude) + after := solarCentralBandSkyOffset(solver.localStateContextAt(point.JDE), point.Longitude, point.Latitude) + if before[2]*after[2] < 0 { + if transitionIndex == len(*transitions) { + fraction := before[2] / (before[2] - after[2]) + seed := SolarEclipsePathPoint{JDE: first.JDE + fraction*(point.JDE-first.JDE), + Longitude: first.Longitude + fraction*math.Remainder(point.Longitude-first.Longitude, 360), + Latitude: first.Latitude + fraction*(point.Latitude-first.Latitude)} + transition, ok := solver.hybridCentralBandTransition(seed, referenceJDE) + if !ok { + return false + } + *transitions = append(*transitions, transition) + } + points = append(points, (*transitions)[transitionIndex]) + transitionIndex++ + } + points = append(points, point) + return true + } + if !appendPoint(next.point) { + return nil, -1, false + } + state = next + for count := 0; count < solarEclipseCentralEnvelopeMaxArcSteps; count++ { + predictor := state.coordinates + for i := range predictor { + predictor[i] += step * state.tangent[i] + } + candidate, valid := solver.correctCentralBandVectorBoundary(predictor, state.tangent, referenceJDE, side) + if valid && dotSolarEclipse3(candidate.tangent, state.tangent) < 0 { + for i := range candidate.tangent { + candidate.tangent[i] = -candidate.tangent[i] + } + } + distance := solarEclipsePathDistanceKM(state.point, candidate.point) + chordTolerance := 0.02 + if valid { + context := solver.localStateContextAt(candidate.point.JDE) + offset := solarCentralBandSkyOffset(context, candidate.point.Longitude, candidate.point.Latitude) + shadowRadiusKM := math.Abs(offset[2]) * math.Sqrt(dotSolarEclipse3(context.moonXYZ, context.moonXYZ)) + chordTolerance = math.Min(chordTolerance, math.Max(0.001, shadowRadiusKM/8)) + } + if !valid || distance > solarEclipseCentralEnvelopeMaxSpacingKM || + centralBandVectorChordErrorKM(state, candidate, distance) > chordTolerance { + step /= 2 + if step < solarEclipseCentralEnvelopeMinArcStepDegrees { + return points, -1, false + } + continue + } + if candidate.point.SunAltitude < 0 { + endIndex, bestResidual := -1, math.Inf(1) + for i, end := range endRoots { + residual, ok := solarCentralBandVectorResidual(solver.magnitudeEvaluationAt(end.JDE), end.Longitude, end.Latitude, side) + if norm := math.Hypot(residual[0], residual[1]); ok && norm < bestResidual { + endIndex, bestResidual = i, norm + } + } + if endIndex < 0 || solarEclipsePathDistanceKM(state.point, endRoots[endIndex]) > solarEclipseCentralEnvelopeEndDistanceKM { + return points, -1, false + } + ok := appendPoint(endRoots[endIndex]) + return points, endIndex, ok && transitionIndex == len(*transitions) + } + if !appendPoint(candidate.point) { + return points, -1, false + } + state = candidate + if distance < solarEclipseCentralEnvelopeMaxSpacingKM/2 { + step = math.Min(solarEclipseCentralEnvelopeArcStepDegrees, step*1.5) + } + } + return points, -1, false +} + +func centralBandVectorChordErrorKM(first, second solarEclipseNonCentralBandState, distance float64) float64 { + latitude := (first.point.Latitude + second.point.Latitude) * rad / 2 + ax, ay := first.tangent[0]*math.Cos(latitude), first.tangent[1] + bx, by := second.tangent[0]*math.Cos(latitude), second.tangent[1] + norm := math.Hypot(ax, ay) * math.Hypot(bx, by) + if norm == 0 { + return math.Inf(1) + } + cosine := math.Max(-1, math.Min(1, (ax*bx+ay*by)/norm)) + return distance * math.Sqrt(2*(1-cosine)) / 8 +} + +func (solver solarEclipseSolver) hybridCentralBandTransition(seed SolarEclipsePathPoint, referenceJDE float64) (SolarEclipsePathPoint, bool) { + coordinates := [3]float64{seed.Longitude, seed.Latitude, (seed.JDE - referenceJDE) * solarEclipseNonCentralBandTimeScale} + steps := [3]float64{1e-4, 1e-4, solarEclipseNonCentralBandTimeScale / 86400} + for iteration := 0; iteration < 12; iteration++ { + jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + context := solver.localStateContextAt(jd) + residual := solarCentralBandSkyOffset(context, coordinates[0], coordinates[1]) + if math.Hypot(residual[0], residual[1]) < solarEclipseCentralVectorTolerance/2 && math.Abs(residual[2]) < 1e-12 { + state := context.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0) + return SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad}, true + } + var jacobian [3][3]float64 + for column := range coordinates { + shifted, shiftedContext := coordinates, context + shifted[column] += steps[column] + if column == 2 { + shiftedContext = solver.localStateContextAt(jd + steps[column]/solarEclipseNonCentralBandTimeScale) + } + value := solarCentralBandSkyOffset(shiftedContext, shifted[0], shifted[1]) + for row := range residual { + jacobian[row][column] = (value[row] - residual[row]) / steps[column] + } + } + delta, valid := solveSolarEclipse3x3(jacobian, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !valid { + return SolarEclipsePathPoint{}, false + } + for i := range coordinates { + coordinates[i] += delta[i] + } + } + return SolarEclipsePathPoint{}, false +} + +func (solver solarEclipseSolver) centralBandVectorHorizonRoots(axisContactJDE, direction, firstContactJDE, lastContactJDE float64) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) { + startJDE, endJDE := math.Min(firstContactJDE, lastContactJDE), math.Max(firstContactJDE, lastContactJDE) + if !finite(startJDE) || !finite(endJDE) || startJDE <= 0 || endJDE <= startJDE { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + seed, ok := solver.centralPathPointAt(axisContactJDE + direction/86400) + if !ok { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + var roots [2]SolarEclipsePathPoint + for i, side := range []float64{1, -1} { + coordinates := [3]float64{seed.Longitude, seed.Latitude, (seed.JDE - axisContactJDE) * solarEclipseNonCentralBandTimeScale} + steps := [3]float64{1e-4, 1e-4, 5 * solarEclipseNonCentralBandTimeScale / 86400} + found := false + for iteration := 0; iteration < 12; iteration++ { + residual, jacobian, valid := solver.centralBandVectorJacobian(coordinates, axisContactJDE, side, true) + if !valid { + break + } + jd := axisContactJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + context := solver.localStateContextAt(jd) + state := context.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0) + if math.Hypot(residual[0], residual[1]) <= solarEclipseCentralVectorTolerance && math.Abs(state.sunAltitudeRad) < 1e-8 { + roots[i] = SolarEclipsePathPoint{JDE: jd, Longitude: normalizeLongitude(coordinates[0]), Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad} + // Grazing horizon roots can be many minutes from axis contact. + // Bound them by the shadow's limb-crossing interval, not a fixed + // window around the seed. + found = jd >= startJDE-solarEclipseCentralLimitHorizonContactMarginDays && + jd <= endJDE+solarEclipseCentralLimitHorizonContactMarginDays && math.Abs(coordinates[1]) <= 90 + break + } + matrix := [3][3]float64{jacobian[0], jacobian[1], {}} + for column := range coordinates { + shifted, shiftedContext := coordinates, context + shifted[column] += steps[column] + if column == 2 { + shiftedContext = solver.localStateContextAt(jd + steps[column]/solarEclipseNonCentralBandTimeScale) + } + value := shiftedContext.stateAt(shifted[0]*rad, shifted[1]*rad, 0) + matrix[2][column] = (value.sunAltitudeRad - state.sunAltitudeRad) / steps[column] + } + delta, valid := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -state.sunAltitudeRad}) + if !valid { + break + } + for j := range coordinates { + coordinates[j] += delta[j] + } + } + if !found { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + } + if roots[1].JDE < roots[0].JDE { + roots[0], roots[1] = roots[1], roots[0] + } + return roots[0], roots[1], solarEclipsePathDistanceKM(roots[0], roots[1]) > 0.001 +} + +func (solver solarEclipseSolver) hybridCentralBandEnvelope(closures [][]SolarEclipsePathPoint, result SolarEclipseResult) [][]SolarEclipsePathPoint { + if len(closures) != 2 || len(closures[0]) < 2 || len(closures[1]) < 2 { + return nil + } + var transitions []SolarEclipsePathPoint + startRoots := []SolarEclipsePathPoint{closures[0][0], closures[0][len(closures[0])-1]} + endRoots := []SolarEclipsePathPoint{closures[1][0], closures[1][len(closures[1])-1]} + var branches [2][]SolarEclipsePathPoint + var ends [2]int + for i, root := range startRoots { + branch, end, ok := solver.traceCentralBandVectorEnvelope(root, endRoots, &transitions, result.GreatestEclipse) + if !ok { + return nil + } + branches[i], ends[i] = branch, end + } + if ends[0] == ends[1] { + return nil + } + indices := [2]int{} + polygons := make([][]SolarEclipsePathPoint, 0, len(transitions)+1) + for segment := 0; segment <= len(transitions); segment++ { + var parts [2][]SolarEclipsePathPoint + for side, branch := range branches { + last := len(branch) - 1 + if segment < len(transitions) { + last = indices[side] + for last < len(branch) && branch[last] != transitions[segment] { + last++ + } + if last == len(branch) { + return nil + } + } + parts[side] = branch[indices[side] : last+1] + indices[side] = last + } + ring := append([]SolarEclipsePathPoint(nil), parts[0]...) + if segment == len(transitions) { + closure, ok := orientSolarEclipsePath(closures[1], parts[0][len(parts[0])-1], parts[1][len(parts[1])-1]) + if !ok { + return nil + } + ring = append(ring, closure[1:]...) + } + for i := len(parts[1]) - 2; i >= 0; i-- { + ring = append(ring, parts[1][i]) + } + if segment == 0 { + closure, ok := orientSolarEclipsePath(closures[0], parts[1][0], parts[0][0]) + if !ok { + return nil + } + ring = append(ring, closure[1:]...) + } else { + ring = append(ring, ring[0]) + } + polygons = append(polygons, deduplicateSolarEclipsePathPoints(ring)) + } + return polygons +} diff --git a/basic/solar_eclipse_hybrid_envelope_test.go b/basic/solar_eclipse_hybrid_envelope_test.go new file mode 100644 index 0000000..708eb22 --- /dev/null +++ b/basic/solar_eclipse_hybrid_envelope_test.go @@ -0,0 +1,86 @@ +package basic + +import ( + "fmt" + "math" + "testing" + + "b612.me/astro/internal/geodata" +) + +func TestSolarEclipseHybridEnvelope21640323(t *testing.T) { + seed := JDECalc(2164, 3, 23) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440, BoundaryPoints: 96}) + if len(result.CentralBandHorizonClosures) != 2 { + t.Fatal("missing hybrid horizon closures") + } + if len(result.CentralBandSegments) != 3 { + t.Fatalf("hybrid envelope segments=%d, want annular/total/annular", len(result.CentralBandSegments)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + for _, closure := range result.CentralBandHorizonClosures { + for _, point := range []SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} { + evaluation := solver.magnitudeEvaluationAt(point.JDE) + state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + first, _ := solarCentralBandVectorResidual(evaluation, point.Longitude, point.Latitude, 1) + second, _ := solarCentralBandVectorResidual(evaluation, point.Longitude, point.Latitude, -1) + if math.Abs(solarEclipseCentralContactGap(state)) > 1e-7 || math.Abs(state.sunAltitudeRad) > 1e-7 || + math.Min(math.Hypot(first[0], first[1]), math.Hypot(second[0], second[1])) > 1.01*solarEclipseCentralVectorTolerance { + t.Fatalf("hybrid root is not a central-envelope horizon intersection: %+v", point) + } + } + } +} + +func TestSolarEclipseHybridSignedEnvelopeEvents(t *testing.T) { + for _, date := range [][3]int{{1144, 7, 3}, {1827, 10, 20}, {1854, 11, 20}, {1986, 10, 3}, {2013, 11, 3}, {2023, 4, 20}, {2164, 3, 23}, {2172, 10, 17}} { + t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { + seed := JDECalc(date[0], date[1], float64(date[2])) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440}) + if result.Eclipse.Type != SolarEclipseHybrid || len(result.CentralBandHorizonClosures) != 2 || len(result.CentralBandSegments) < 2 { + t.Fatalf("type=%s closures=%d segments=%d", result.Eclipse.Type, len(result.CentralBandHorizonClosures), len(result.CentralBandSegments)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + polygons := make([][]geodata.GeoPoint, len(result.CentralBandSegments)) + for i, ring := range result.CentralBandSegments { + if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.001 { + t.Fatal("open hybrid component") + } + for _, point := range ring { + polygons[i] = append(polygons[i], geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + if solarEclipseCentralEnvelopePointOnHorizonClosure(point, result.CentralBandHorizonClosures) { + continue + } + evaluation := solver.magnitudeEvaluationAt(point.JDE) + first, firstOK := solarCentralBandVectorResidual(evaluation, point.Longitude, point.Latitude, 1) + second, secondOK := solarCentralBandVectorResidual(evaluation, point.Longitude, point.Latitude, -1) + if !firstOK || !secondOK || math.Min(math.Hypot(first[0], first[1]), math.Hypot(second[0], second[1])) > 1.01*solarEclipseCentralVectorTolerance { + t.Fatalf("non-critical point: %+v residuals=%v,%v", point, first, second) + } + } + } + var visible []geodata.GeoPoint + for _, footprint := range result.CentralShadowFootprints { + context := solver.localStateContextAt(footprint.JDE) + for _, boundary := range footprint.Boundaries { + for i, point := range boundary { + if i%4 != 0 { + continue + } + state := context.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + if state.sunAltitudeRad > 0 && solarEclipseCentralContactGap(state) < -1e-8 { + visible = append(visible, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + } + } + } + contained := geodata.SphericalPolygonsContainPoints(polygons, visible) + for i, inside := range contained { + if !inside && !geodata.SphericalPolygonsContainPathsWithinKM(polygons, [][]geodata.GeoPoint{{visible[i], visible[i]}}, false, 0.05) { + t.Errorf("visible central-shadow point outside envelope: %+v", visible[i]) + } + } + t.Logf("segments=%d visible shadow samples=%d", len(polygons), len(visible)) + }) + } +} diff --git a/basic/solar_eclipse_isochrone.go b/basic/solar_eclipse_isochrone.go new file mode 100644 index 0000000..af8e207 --- /dev/null +++ b/basic/solar_eclipse_isochrone.go @@ -0,0 +1,314 @@ +package basic + +import "math" + +const ( + solarEclipseGreatestTimeContourSeedLatitudeStepDegrees = 5.0 + solarEclipseGreatestTimeContourSeedLongitudeStepDegrees = 5.0 + solarEclipseGreatestTimeContourSeedLatitudeLimitDegrees = 85.0 + solarEclipseGreatestTimeContourArcStepDegrees = 1.5 + solarEclipseGreatestTimeContourMinArcStepDegrees = 0.01 + solarEclipseGreatestTimeContourMaxArcSteps = 4000 + solarEclipseGreatestTimeContourCorrectionIterations = 12 + solarEclipseGreatestTimeContourGradientStepDegrees = 1e-4 + solarEclipseGreatestTimeContourLatitudeLimitDegrees = 88.0 +) + +// solarEclipseGreatestTimeArc 固定一个食甚时刻后的等时线求根器。 +// 时刻固定后 g = ∂(separation²)/∂t 只随经纬度变化,其零集就是该时刻的食甚等值线: +// 一个约束、两个未知量,所以结果是曲线而不是区域,延拓成本正比于曲线长度。 +type solarEclipseGreatestTimeArc struct { + evaluation solarEclipseRiseSetEvaluation +} + +// sample 返回残差与中心状态;离开可见偏食域、非极小点或数值无效时 ok 为 false。 +func (arc solarEclipseGreatestTimeArc) sample(longitude, latitude float64) (float64, localSolarEclipseState, bool) { + var state localSolarEclipseState + if latitude <= -90 || latitude >= 90 { + return 0, state, false + } + lonRad, latRad := longitude*rad, latitude*rad + before := arc.evaluation.before.stateAt(lonRad, latRad, 0) + state = arc.evaluation.center.stateAt(lonRad, latRad, 0) + after := arc.evaluation.after.stateAt(lonRad, latRad, 0) + if !finite(state.separationSquared) || state.sunAltitudeRad <= 0 { + return 0, state, false + } + // 角距极小值处处存在,等时线必须再要求日月盘面真的相交,否则会在无食可见的海面上画出曲线。 + if solarEclipsePartialContactGap(state) > 1e-7 { + return 0, state, false + } + stepSquared := solarEclipseRiseSetDerivativeStepDays * solarEclipseRiseSetDerivativeStepDays + // separation² 在此取极小值才算食甚;二阶导非正说明该时刻不是本地的极大食。 + if (after.separationSquared-2*state.separationSquared+before.separationSquared)/stepSquared <= 0 { + return 0, state, false + } + value := (after.separationSquared - before.separationSquared) / (2 * solarEclipseRiseSetDerivativeStepDays) + if !finite(value) { + return 0, state, false + } + return value, state, true +} + +func (arc solarEclipseGreatestTimeArc) residual(longitude, latitude float64) float64 { + value, _, ok := arc.sample(longitude, latitude) + if !ok { + return math.NaN() + } + return value +} + +func (arc solarEclipseGreatestTimeArc) point(longitude, latitude float64, state localSolarEclipseState) SolarEclipsePathPoint { + return SolarEclipsePathPoint{ + JDE: arc.evaluation.jd, + Longitude: normalizeLongitude(longitude), + Latitude: latitude, + SunAltitude: state.sunAltitudeRad / rad, + } +} + +// metricGradient 返回 g 对地面东向、北向角度的偏导;东向角度 = 经度差 × cos(纬度)。 +func (arc solarEclipseGreatestTimeArc) metricGradient(longitude, latitude float64) (float64, float64, bool) { + step := solarEclipseGreatestTimeContourGradientStepDegrees + value, _, ok := arc.sample(longitude, latitude) + if !ok { + return 0, 0, false + } + cosine := math.Cos(latitude * rad) + if cosine < 1e-6 { + return 0, 0, false + } + eastValue, _, eastOK := arc.sample(longitude+step, latitude) + westValue, _, westOK := arc.sample(longitude-step, latitude) + northValue, _, northOK := arc.sample(longitude, latitude+step) + southValue, _, southOK := arc.sample(longitude, latitude-step) + longitudeDerivative, ok := greatestTimeContourDifference(value, eastValue, westValue, step, eastOK, westOK) + if !ok { + return 0, 0, false + } + latitudeDerivative, ok := greatestTimeContourDifference(value, northValue, southValue, step, northOK, southOK) + if !ok { + return 0, 0, false + } + return longitudeDerivative / cosine, latitudeDerivative, true +} + +// correct 把预测点沿残差梯度投影回零集;失败说明该方向已离开等时线定义域。 +func (arc solarEclipseGreatestTimeArc) correct(longitude, latitude float64) (float64, float64, localSolarEclipseState, bool) { + var state localSolarEclipseState + for iteration := 0; iteration < solarEclipseGreatestTimeContourCorrectionIterations; iteration++ { + value, current, ok := arc.sample(longitude, latitude) + if !ok { + return 0, 0, state, false + } + state = current + if math.Abs(value) <= greatestTimeContourResidualTolerance { + return longitude, latitude, state, true + } + east, north, ok := arc.metricGradient(longitude, latitude) + if !ok { + return 0, 0, state, false + } + denominator := east*east + north*north + cosine := math.Cos(latitude * rad) + if denominator < 1e-18 || cosine < 1e-6 { + return 0, 0, state, false + } + // 完整牛顿步可能一步跨出可见域;逐步二分回退,只要还有一步落在域内就继续投影。 + scale, advanced := 1.0, false + for attempt := 0; attempt < greatestTimeContourCorrectionBacktracking; attempt++ { + nextLongitude := longitude - scale*value*east/denominator/cosine + nextLatitude := latitude - scale*value*north/denominator + scale /= 2 + if nextLatitude <= -90 || nextLatitude >= 90 { + continue + } + if _, _, ok := arc.sample(nextLongitude, nextLatitude); !ok { + continue + } + longitude, latitude = nextLongitude, nextLatitude + advanced = true + break + } + if !advanced { + return 0, 0, state, false + } + } + value, current, ok := arc.sample(longitude, latitude) + if !ok || math.Abs(value) > 1e-6 { + return 0, 0, state, false + } + return longitude, latitude, current, true +} + +// traceGreatestTimeArc 从种子沿一个方向按弧长延拓,预测点落到定义域外时步长减半。 +func (solver solarEclipseSolver) traceGreatestTimeArc( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude, direction float64, +) []SolarEclipsePathPoint { + arc := solarEclipseGreatestTimeArc{evaluation: evaluation} + _, state, ok := arc.sample(longitude, latitude) + if !ok { + return nil + } + points := []SolarEclipsePathPoint{arc.point(longitude, latitude, state)} + step := solarEclipseGreatestTimeContourArcStepDegrees + previousEast, previousNorth := 0.0, 0.0 + for count := 0; count < solarEclipseGreatestTimeContourMaxArcSteps; count++ { + east, north, ok := arc.metricGradient(longitude, latitude) + if !ok { + break + } + norm := math.Hypot(east, north) + cosine := math.Cos(latitude * rad) + if norm < 1e-12 || cosine < 1e-6 { + break + } + tangentEast, tangentNorth := -north/norm, east/norm + if previousEast != 0 || previousNorth != 0 { + if tangentEast*previousEast+tangentNorth*previousNorth < 0 { + tangentEast, tangentNorth = -tangentEast, -tangentNorth + } + } + nextLongitude, nextLatitude, nextState, ok := arc.correct( + longitude+direction*step*tangentEast/cosine, + latitude+direction*step*tangentNorth, + ) + if !ok { + step /= 2 + if step < solarEclipseGreatestTimeContourMinArcStepDegrees { + break + } + continue + } + if math.Abs(nextLatitude) > solarEclipseGreatestTimeContourLatitudeLimitDegrees { + break + } + next := arc.point(nextLongitude, nextLatitude, nextState) + distance := solarEclipsePathDistanceKM(points[len(points)-1], next) + // 校正回到原点说明该方向已经走到支路端点,继续只会原地打转。 + if distance < 1e-9 || distance > 4*step*greatestTimeContourKMPerDegree { + break + } + points = append(points, next) + longitude, latitude = nextLongitude, nextLatitude + previousEast, previousNorth = tangentEast, tangentNorth + step = math.Min(solarEclipseGreatestTimeContourArcStepDegrees, step*1.5) + } + return points +} + +// greatestTimeContourSeeds 用粗扫找延拓种子;扫描只用于定位零集,不参与曲线成型。 +func (solver solarEclipseSolver) greatestTimeContourSeeds(evaluation solarEclipseRiseSetEvaluation) []SolarEclipsePathPoint { + arc := solarEclipseGreatestTimeArc{evaluation: evaluation} + seeds := make([]SolarEclipsePathPoint, 0, 16) + latitudeStep := solarEclipseGreatestTimeContourSeedLatitudeStepDegrees + longitudeStep := solarEclipseGreatestTimeContourSeedLongitudeStepDegrees + limit := solarEclipseGreatestTimeContourSeedLatitudeLimitDegrees + for latitude := -limit; latitude <= limit; latitude += latitudeStep { + previousLongitude := -180.0 + previousValue := arc.residual(previousLongitude, latitude) + for longitude := previousLongitude + longitudeStep; longitude <= 180; longitude += longitudeStep { + value := arc.residual(longitude, latitude) + if finite(previousValue) && finite(value) && previousValue*value <= 0 { + root, ok := greatestTimeContourBisect(arc.residual, previousLongitude, longitude, latitude, previousValue) + if !ok { + continue + } + if _, state, sampled := arc.sample(root, latitude); sampled { + seeds = append(seeds, arc.point(root, latitude, state)) + } + } + previousLongitude, previousValue = longitude, value + } + } + return seeds +} + +func solarEclipseGreatestTimeContourCovered(segments [][]SolarEclipsePathPoint, point SolarEclipsePathPoint) bool { + for _, segment := range segments { + for index := 1; index < len(segment); index++ { + if greatestTimeContourPointSegmentKM( + point.Longitude, point.Latitude, + segment[index-1].Longitude, segment[index-1].Latitude, + segment[index].Longitude, segment[index].Latitude, + ) <= greatestTimeContourCoverToleranceKM { + return true + } + } + } + return false +} + +// solarEclipseGreatestTimeContourSegmentCovered 判断整条支路是否已落在已绘曲线上(同一曲线被先后延拓两次时后一条可能更长)。 +func solarEclipseGreatestTimeContourSegmentCovered(segments [][]SolarEclipsePathPoint, segment []SolarEclipsePathPoint) bool { + for _, point := range segment { + if !solarEclipseGreatestTimeContourCovered(segments, point) { + return false + } + } + return true +} + +// solarEclipseGreatestTimeContourPruneCovered 丢弃已被新支路整条覆盖的旧支路。 +func solarEclipseGreatestTimeContourPruneCovered(segments [][]SolarEclipsePathPoint, added []SolarEclipsePathPoint) [][]SolarEclipsePathPoint { + kept := segments[:0] + for _, segment := range segments { + if solarEclipseGreatestTimeContourSegmentCovered([][]SolarEclipsePathPoint{added}, segment) { + continue + } + kept = append(kept, segment) + } + return kept +} + +// greatestTimeContourSegments 汇总一个时刻取值上的全部等时线支路。 +func (solver solarEclipseSolver) greatestTimeContourSegments(level float64) [][]SolarEclipsePathPoint { + evaluation := solver.magnitudeEvaluationAt(level) + seeds := solver.greatestTimeContourSeeds(evaluation) + segments := make([][]SolarEclipsePathPoint, 0, 2) + for _, seed := range seeds { + if solarEclipseGreatestTimeContourCovered(segments, seed) { + continue + } + forward := solver.traceGreatestTimeArc(evaluation, seed.Longitude, seed.Latitude, 1) + backward := solver.traceGreatestTimeArc(evaluation, seed.Longitude, seed.Latitude, -1) + segment := make([]SolarEclipsePathPoint, 0, len(forward)+len(backward)) + for index := len(backward) - 1; index >= 1; index-- { + segment = append(segment, backward[index]) + } + segment = append(segment, forward...) + if len(segment) < 2 { + continue + } + // 先按整条支路去重:种子检查只能拦住"较短者先画"的情况,反序时需要在这里收口。 + if solarEclipseGreatestTimeContourSegmentCovered(segments, segment) { + continue + } + segments = solarEclipseGreatestTimeContourPruneCovered(segments, segment) + segments = append(segments, segment) + } + return segments +} + +// greatestTimeContours 计算请求时刻取值的地方食甚时刻等值线。 +func (solver solarEclipseSolver) greatestTimeContours( + startJDE, endJDE float64, + options SolarEclipsePartialFootprintOptions, +) []SolarEclipseGreatestTimeContour { + if len(options.GreatestTimeValues) == 0 || startJDE == 0 || endJDE == 0 || endJDE <= startJDE { + return nil + } + contours := make([]SolarEclipseGreatestTimeContour, 0, len(options.GreatestTimeValues)) + for _, level := range options.GreatestTimeValues { + if !finite(level) || level < startJDE || level > endJDE { + continue + } + segments := solver.greatestTimeContourSegments(level) + if len(segments) == 0 { + continue + } + contours = append(contours, SolarEclipseGreatestTimeContour{JDE: level, Segments: segments}) + } + return contours +} diff --git a/basic/solar_eclipse_isochrone_test.go b/basic/solar_eclipse_isochrone_test.go new file mode 100644 index 0000000..6ceb0e8 --- /dev/null +++ b/basic/solar_eclipse_isochrone_test.go @@ -0,0 +1,315 @@ +package basic + +import ( + "math" + "testing" + "time" +) + +func solarEclipseIsochroneTestSeed(t *testing.T, year int, month time.Month, day int) float64 { + t.Helper() + return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true) +} + +func solarEclipseIsochroneTestSeedB(b *testing.B, year int, month time.Month, day int) float64 { + b.Helper() + return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true) +} + +// 等时线必须与站心食甚定义自洽:支路上任意点由站心算法独立求出的食甚时刻等于该支路电平。 +func TestSolarEclipseGreatestTimeContoursMatchLocalCircumstances(t *testing.T) { + for _, tc := range []struct { + name string + seed float64 + first time.Time + count int + }{ + {name: "2009Total", seed: solarEclipseIsochroneTestSeed(t, 2009, time.July, 22), + first: time.Date(2009, time.July, 22, 1, 30, 0, 0, time.UTC), count: 6}, + {name: "2010Annular", seed: solarEclipseIsochroneTestSeed(t, 2010, time.January, 15), + first: time.Date(2010, time.January, 15, 5, 0, 0, 0, time.UTC), count: 6}, + } { + t.Run(tc.name, func(t *testing.T) { + levels := make([]float64, 0, tc.count) + for index := 0; index < tc.count; index++ { + levels = append(levels, TD2UT(Date2JDE(tc.first.Add(time.Duration(index)*30*time.Minute)), true)) + } + result := SolarEclipsePartialFootprintsNASABulletinSplitK(tc.seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, + BoundaryPoints: 24, + DisableRiseSetCurves: true, + GreatestTimeValues: levels, + }) + if len(result.GreatestTimeContours) == 0 { + t.Fatal("no greatest-time contours") + } + verified := 0 + worst := 0.0 + for _, contour := range result.GreatestTimeContours { + branchPoints := 0 + for _, segment := range contour.Segments { + if len(segment) < 2 { + t.Fatalf("contour %.6f has a degenerate branch", contour.JDE) + } + branchPoints += len(segment) + } + // 弧长步长上限决定了单条等时线的点数上界;点数爆表意味着延拓没有真正前进。 + if branchPoints > 4000 { + t.Fatalf("contour %.6f has %d points, continuation is not advancing", contour.JDE, branchPoints) + } + for _, segment := range contour.Segments { + // 独立回验很贵(每次是完整站心求解),按点数预算抽样而不是全量遍历; + // 每个时刻取值都要抽到,不能只看第一条等时线。 + for index := 0; index < len(segment) && verified < 240; index += 1 + len(segment)/12 { + point := segment[index] + local := LocalSolarEclipseNASABulletinSplitK( + contour.JDE, point.Longitude, point.Latitude, 0, + ) + if local.GreatestEclipse == 0 { + continue + } + delta := math.Abs(local.GreatestEclipse-contour.JDE) * 86400 + if delta > worst { + worst = delta + } + verified++ + } + } + } + if verified < 10 { + t.Fatalf("verified only %d points", verified) + } + t.Logf("%s: %d contours, %d points verified, worst %.2f s", + tc.name, len(result.GreatestTimeContours), verified, worst) + if worst > 3 { + t.Fatalf("isochrone disagrees with local greatest eclipse by %.2f s", worst) + } + }) + } +} + +func TestSolarEclipseGreatestTimeContoursDisabledByDefault(t *testing.T) { + seed := solarEclipseIsochroneTestSeed(t, 2009, time.July, 22) + result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, DisableRiseSetCurves: true, + }) + if len(result.GreatestTimeContours) != 0 { + t.Fatalf("contours computed without a request: %d", len(result.GreatestTimeContours)) + } +} + +func TestSolarEclipseGreatestTimeContoursRejectOutOfWindowLevels(t *testing.T) { + seed := solarEclipseIsochroneTestSeed(t, 2009, time.July, 22) + result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, + DisableRiseSetCurves: true, + GreatestTimeValues: []float64{seed - 5, seed + 5, math.NaN()}, + }) + if len(result.GreatestTimeContours) != 0 { + t.Fatalf("accepted out-of-window levels: %d", len(result.GreatestTimeContours)) + } +} + +// 同一时刻取值的等时线必须是一条连通曲线:曾经因为"覆盖判据用点到顶点距离"而把同一条线 +// 用多个种子各画一遍(四条重复支路、点数翻三倍)。 +func TestSolarEclipseGreatestTimeContoursHaveSingleBranchPerLevel(t *testing.T) { + for _, tc := range []struct { + name string + seed float64 + hour int + }{ + {"2009Total", solarEclipseIsochroneTestSeed(t, 2009, time.July, 22), 2}, + {"2024Total", solarEclipseIsochroneTestSeed(t, 2024, time.April, 8), 18}, + {"2014NonCentral", solarEclipseIsochroneTestSeed(t, 2014, time.April, 29), 6}, + } { + t.Run(tc.name, func(t *testing.T) { + // 用事件当天同一个世界时小时作为时刻取值。 + seedTime := JDE2DateByZone(TD2UT(tc.seed, false), time.UTC, false) + eventLevel := TD2UT(Date2JDE(time.Date(seedTime.Year(), seedTime.Month(), seedTime.Day(), tc.hour, 0, 0, 0, time.UTC)), true) + result := SolarEclipsePartialFootprintsNASABulletinSplitK(tc.seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, + GreatestTimeValues: []float64{eventLevel}, + }) + if len(result.GreatestTimeContours) != 1 { + t.Fatalf("expected one contour, got %d", len(result.GreatestTimeContours)) + } + contour := result.GreatestTimeContours[0] + if len(contour.Segments) != 1 { + t.Fatalf("contour %.6f has %d branches, want a single connected curve", contour.JDE, len(contour.Segments)) + } + if points := len(contour.Segments[0]); points < 20 { + t.Fatalf("contour has only %d points", points) + } + }) + } +} + +// 按步长请求多个时刻取值时,每条等时线同样只能有一条支路。 +func TestSolarEclipseGreatestTimeStepHasSingleBranchPerLevel(t *testing.T) { + for _, tc := range []struct { + name string + seed float64 + }{ + {"2009Total", solarEclipseIsochroneTestSeed(t, 2009, time.July, 22)}, + {"2024Total", solarEclipseIsochroneTestSeed(t, 2024, time.April, 8)}, + {"2014NonCentral", solarEclipseIsochroneTestSeed(t, 2014, time.April, 29)}, + {"2010Annular", solarEclipseIsochroneTestSeed(t, 2010, time.January, 15)}, + } { + t.Run(tc.name, func(t *testing.T) { + result := SolarEclipsePartialFootprintsNASABulletinSplitK(tc.seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, + GreatestTimeStep: 30 * time.Minute, + }) + if len(result.GreatestTimeContours) < 2 { + t.Fatalf("step request produced %d contours", len(result.GreatestTimeContours)) + } + branches := 0 + for _, contour := range result.GreatestTimeContours { + branches += len(contour.Segments) + if len(contour.Segments) != 1 { + t.Fatalf("contour %.6f has %d branches, want a single connected curve", contour.JDE, len(contour.Segments)) + } + } + if branches != len(result.GreatestTimeContours) { + t.Fatalf("%d branches for %d contours", branches, len(result.GreatestTimeContours)) + } + }) + } +} + +// 延拓必须覆盖整个零集:0.5°×2° 细扫找到的每个根都要落在已绘折线一个覆盖容差内。 +func TestSolarEclipseGreatestTimeContoursCoverZeroSet(t *testing.T) { + for _, tc := range []struct { + name string + seed float64 + hour int + }{ + {"2009Total", solarEclipseIsochroneTestSeed(t, 2009, time.July, 22), 2}, + {"2014NonCentral", solarEclipseIsochroneTestSeed(t, 2014, time.April, 29), 6}, + } { + t.Run(tc.name, func(t *testing.T) { + seedTime := JDE2DateByZone(TD2UT(tc.seed, false), time.UTC, false) + level := TD2UT(Date2JDE(time.Date(seedTime.Year(), seedTime.Month(), seedTime.Day(), tc.hour, 0, 0, 0, time.UTC)), true) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(tc.seed, 0), SolarEclipseModelNASABulletinSplitK) + segments := solver.greatestTimeContourSegments(level) + if len(segments) == 0 { + t.Fatal("no isochrone for the requested level") + } + arc := solarEclipseGreatestTimeArc{evaluation: solver.magnitudeEvaluationAt(level)} + roots, missed := 0, 0 + for latitude := -86.0; latitude <= 86.0; latitude += 2.0 { + previous := arc.residual(-180, latitude) + for longitude := -179.5; longitude <= 180; longitude += 0.5 { + current := arc.residual(longitude, latitude) + if finite(previous) && finite(current) && previous*current <= 0 { + left, right, leftValue := longitude-0.5, longitude, previous + for iteration := 0; iteration < 60; iteration++ { + middle := (left + right) / 2 + value := arc.residual(middle, latitude) + if !finite(value) { + break + } + if math.Abs(value) <= greatestTimeContourResidualTolerance || right-left <= 1e-9 { + left, right = middle, middle + break + } + if leftValue*value <= 0 { + right = middle + } else { + left, leftValue = middle, value + } + } + root := (left + right) / 2 + roots++ + best := math.Inf(1) + for _, segment := range segments { + for index := 1; index < len(segment); index++ { + distance := greatestTimeContourPointSegmentKM( + root, latitude, + segment[index-1].Longitude, segment[index-1].Latitude, + segment[index].Longitude, segment[index].Latitude, + ) + if distance < best { + best = distance + } + } + } + if best > greatestTimeContourCoverToleranceKM { + missed++ + } + } + previous = current + } + } + if roots < 10 { + t.Fatalf("fine scan found only %d roots", roots) + } + if missed > 0 { + t.Fatalf("%d of %d zero-set roots are farther than %.0f km from the traced isochrone", missed, roots, greatestTimeContourCoverToleranceKM) + } + }) + } +} + +// 按步长请求时:取值对齐到 UTC 整刻度、落在偏食窗口内、数量受上限保护。 +func TestSolarEclipseGreatestTimeStepAlignsAndCapsLevels(t *testing.T) { + seed := solarEclipseIsochroneTestSeed(t, 2009, time.July, 22) + result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, + GreatestTimeStep: 30 * time.Minute, + }) + // 对齐网格的取值是固定契约,写成字面量以免与被测的取值生成函数互相自证。 + want := []float64{ + 2455034.542432685, 2455034.563266018, 2455034.584099352, 2455034.604932685, + 2455034.625766018, 2455034.646599352, 2455034.667432685, + } + if len(result.GreatestTimeContours) != len(want) { + t.Fatalf("step request produced %d contours, want %d", len(result.GreatestTimeContours), len(want)) + } + // 独立重算网格:只用导出的 TT/UTC 换算与整刻度截断,不经过被测的取值生成函数。 + begin := JDE2DateByZone(TD2UT(result.Eclipse.PartialBeginOnEarth, false), time.UTC, false) + first := begin.Truncate(30 * time.Minute) + if first.Before(begin) { + first = first.Add(30 * time.Minute) + } + end := JDE2DateByZone(TD2UT(result.Eclipse.PartialEndOnEarth, false), time.UTC, false) + ticked := 0 + for current := first; !current.After(end); current = current.Add(30 * time.Minute) { + ticked++ + } + // 没有支路的取值会被丢掉,所以返回的是独立重算网格的子集,但必须是子集。 + if ticked < len(want) { + t.Fatalf("独立重算的对齐网格只有 %d 个刻度,容不下 %d 条等时线", ticked, len(want)) + } + for index, contour := range result.GreatestTimeContours { + if math.Abs(contour.JDE-want[index]) > 1e-9 { + t.Fatalf("contour %d JDE = %.9f, want %.9f", index, contour.JDE, want[index]) + } + if len(contour.Segments) == 0 { + t.Fatalf("contour %d has no branches", index) + } + } + // 上限:给一个极细的步长,返回的条数不得超过共享上限。 + tiny := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, + GreatestTimeStep: time.Minute, + }) + if len(tiny.GreatestTimeContours) > greatestTimeContourMaxLevels { + t.Fatalf("level cap not applied: %d contours", len(tiny.GreatestTimeContours)) + } +} + +func BenchmarkSolarEclipseGreatestTimeContours(b *testing.B) { + seed := solarEclipseIsochroneTestSeedB(b, 2009, time.July, 22) + levels := make([]float64, 0, 6) + for index := 0; index < 6; index++ { + levels = append(levels, TD2UT(Date2JDE(time.Date(2009, time.July, 22, 1, 30, 0, 0, time.UTC).Add(time.Duration(index)*30*time.Minute)), true)) + } + options := SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, + GreatestTimeValues: levels, + } + for index := 0; index < b.N; index++ { + SolarEclipsePartialFootprintsNASABulletinSplitK(seed, options) + } +} diff --git a/basic/solar_eclipse_local.go b/basic/solar_eclipse_local.go index 41627a4..9830e2e 100644 --- a/basic/solar_eclipse_local.go +++ b/basic/solar_eclipse_local.go @@ -53,6 +53,32 @@ type localSolarEclipseState struct { sunAzimuthRad float64 } +func (state localSolarEclipseState) movingDiskContactState() movingDiskContactState { + return movingDiskContactState{ + separation: state.separationRad, + occultingOuterRadius: state.moonOuterRadiusRad, + occultingInnerRadius: state.moonInnerRadiusRad, + targetRadius: state.sunRadiusRad, + valid: movingDiskContactStateValid( + state.separationRad, + state.moonOuterRadiusRad, + state.moonInnerRadiusRad, + state.sunRadiusRad, + ), + } +} + +type localSolarEclipseStateContext struct { + sunXYZ [3]float64 + moonXYZ [3]float64 + gst float64 + params solarEclipseModelParameters + // generation 记录写入缓存时的 ΔT 世代(gst 依赖 ΔT,覆盖 ΔT 后条目必须失效)。 + // generation is the ΔT generation the cached context was built under; gst depends on + // ΔT, so an override must invalidate the entry. + generation uint64 +} + const ( localSolarEclipseGreatestWindowDays = 0.5 localSolarEclipseGreatestTolerance = 1e-8 @@ -89,6 +115,7 @@ func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, model Sola greatestEclipseJDE := localSolarEclipseGreatest(newMoonJDE, lonRad, latRad, heightKM, params) state := localSolarEclipseStateAt(greatestEclipseJDE, lonRad, latRad, heightKM, params) + contactEvaluator := newLocalSolarEclipseContactEvaluator(lonRad, latRad, heightKM, params) visibleThresholdRad := 0.0 if heightMeters > 0 { visibleThresholdRad = -HeightDegreeByLat(heightMeters, latDeg) * rad @@ -104,8 +131,9 @@ func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, model Sola VisibleAtGreatest: state.sunAltitudeRad > visibleThresholdRad, } - partialBoundary := state.sunRadiusRad + state.moonOuterRadiusRad - partialGap := state.separationRad - partialBoundary + contactState := state.movingDiskContactState() + contactEvaluator.prime(greatestEclipseJDE, contactState, contactState.valid) + partialGap := contactState.externalContactGap() if partialGap > 0 { return result } @@ -122,15 +150,14 @@ func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, model Sola state.separationRad, ) - if partialStart, ok := localSolarEclipseContact(greatestEclipseJDE, lonRad, latRad, heightKM, params, false, true); ok { + if partialStart, ok := localSolarEclipseContactWithEvaluator(greatestEclipseJDE, contactEvaluator, false, true); ok { result.PartialStart = partialStart } - if partialEnd, ok := localSolarEclipseContact(greatestEclipseJDE, lonRad, latRad, heightKM, params, false, false); ok { + if partialEnd, ok := localSolarEclipseContactWithEvaluator(greatestEclipseJDE, contactEvaluator, false, false); ok { result.PartialEnd = partialEnd } - centralBoundary := math.Abs(state.sunRadiusRad - state.moonInnerRadiusRad) - if state.separationRad > centralBoundary { + if contactState.internalContactGap() > 0 { return result } @@ -144,10 +171,10 @@ func localSolarEclipse(seedJDE, lonDeg, latDeg, heightMeters float64, model Sola } result.Magnitude = state.moonInnerRadiusRad / state.sunRadiusRad - if centralStart, ok := localSolarEclipseContact(greatestEclipseJDE, lonRad, latRad, heightKM, params, true, true); ok { + if centralStart, ok := localSolarEclipseContactWithEvaluator(greatestEclipseJDE, contactEvaluator, true, true); ok { result.CentralStart = centralStart } - if centralEnd, ok := localSolarEclipseContact(greatestEclipseJDE, lonRad, latRad, heightKM, params, true, false); ok { + if centralEnd, ok := localSolarEclipseContactWithEvaluator(greatestEclipseJDE, contactEvaluator, true, false); ok { result.CentralEnd = centralEnd } @@ -169,42 +196,76 @@ func localSolarEclipseGreatest( newMoonJDE, lonRad, latRad, heightKM float64, params solarEclipseModelParameters, ) float64 { - left := newMoonJDE - localSolarEclipseGreatestWindowDays - right := newMoonJDE + localSolarEclipseGreatestWindowDays - goldenRatio := (math.Sqrt(5) - 1) / 2 - - x1 := right - goldenRatio*(right-left) - x2 := left + goldenRatio*(right-left) - f1 := localSolarEclipseStateAt(x1, lonRad, latRad, heightKM, params).separationSquared - f2 := localSolarEclipseStateAt(x2, lonRad, latRad, heightKM, params).separationSquared - - for i := 0; i < 80 && right-left > localSolarEclipseGreatestTolerance; i++ { - if f1 <= f2 { - right = x2 - x2 = x1 - f2 = f1 - x1 = right - goldenRatio*(right-left) - f1 = localSolarEclipseStateAt(x1, lonRad, latRad, heightKM, params).separationSquared - continue - } - - left = x1 - x1 = x2 - f1 = f2 - x2 = left + goldenRatio*(right-left) - f2 = localSolarEclipseStateAt(x2, lonRad, latRad, heightKM, params).separationSquared - } - - return (left + right) / 2 + return localSolarEclipseGreatestWith(newMoonJDE, func(jd float64) localSolarEclipseState { + return localSolarEclipseStateAt(jd, lonRad, latRad, heightKM, params) + }) } -func localSolarEclipseContact( - greatestEclipseJDE, lonRad, latRad, heightKM float64, +func localSolarEclipseGreatestWith( + newMoonJDE float64, + stateAt func(float64) localSolarEclipseState, +) float64 { + left := newMoonJDE - localSolarEclipseGreatestWindowDays + right := newMoonJDE + localSolarEclipseGreatestWindowDays + return solarEclipseMovingDiskEngine().greatest( + newMoonJDE, left, right, + func(jd float64) (float64, bool) { + return stateAt(jd).separationSquared, true + }, + 80, + ) +} + +// centralPhaseDurationDaysAt 用事件局部插值星历求解某点的中心相时长(日):先求局部食甚, +// 再解本影/反本影的内切接触。没有中心相或接触退化时返回 0。 +func (solver solarEclipseSolver) centralPhaseDurationDaysAt(jd, lonDeg, latDeg float64) float64 { + solver = solver.withLocalEphemeris() + lonRad, latRad := lonDeg*rad, latDeg*rad + stateAt := func(jd float64) localSolarEclipseState { + return solver.localStateContextCandidateAt(jd).stateAt(lonRad, latRad, 0) + } + greatestJDE := localSolarEclipseGreatestWith(solver.newMoonJDE, stateAt) + contactState := stateAt(greatestJDE).movingDiskContactState() + if !contactState.valid || contactState.internalContactGap() > 0 { + return 0 + } + evaluator := newMovingDiskContactEvaluator(func(jd float64) (movingDiskContactState, bool) { + contact := stateAt(jd).movingDiskContactState() + return contact, contact.valid + }) + evaluator.prime(greatestJDE, contactState, true) + start, ok := localSolarEclipseContactWithEvaluator(greatestJDE, evaluator, true, true) + if !ok { + return 0 + } + end, ok := localSolarEclipseContactWithEvaluator(greatestJDE, evaluator, true, false) + if !ok || end <= start { + return 0 + } + return end - start +} + +func newLocalSolarEclipseContactEvaluator( + lonRad, latRad, heightKM float64, params solarEclipseModelParameters, +) *movingDiskContactEvaluator { + return newMovingDiskContactEvaluator(func(jdTT float64) (movingDiskContactState, bool) { + state := localSolarEclipseStateAt(jdTT, lonRad, latRad, heightKM, params) + contactState := state.movingDiskContactState() + return contactState, contactState.valid + }) +} + +func localSolarEclipseContactWithEvaluator( + greatestEclipseJDE float64, + evaluator *movingDiskContactEvaluator, central bool, beforeGreatest bool, ) (float64, bool) { - centerGap := localSolarEclipseGap(greatestEclipseJDE, lonRad, latRad, heightKM, params, central) + centerGap, centerOK := evaluator.gap(greatestEclipseJDE, central) + if !centerOK { + return 0, false + } if centerGap > 0 { return 0, false } @@ -216,92 +277,64 @@ func localSolarEclipseContact( if beforeGreatest { direction = -1.0 } - - previousJDE := greatestEclipseJDE - for i := 1; i <= localSolarEclipseContactSearchSteps; i++ { - currentJDE := greatestEclipseJDE + direction*localSolarEclipseContactStepDays*float64(i) - currentGap := localSolarEclipseGap(currentJDE, lonRad, latRad, heightKM, params, central) - if currentGap >= 0 { - left := previousJDE - right := currentJDE - if beforeGreatest { - left = currentJDE - right = previousJDE - } - return localSolarEclipseContactBisection(left, right, lonRad, latRad, heightKM, params, central) - } - previousJDE = currentJDE - } - - return 0, false -} - -func localSolarEclipseContactBisection( - leftJDE, rightJDE, lonRad, latRad, heightKM float64, - params solarEclipseModelParameters, - central bool, -) (float64, bool) { - leftGap := localSolarEclipseGap(leftJDE, lonRad, latRad, heightKM, params, central) - rightGap := localSolarEclipseGap(rightJDE, lonRad, latRad, heightKM, params, central) - if leftGap == 0 { - return leftJDE, true - } - if rightGap == 0 { - return rightJDE, true - } - if leftGap*rightGap > 0 { - return 0, false - } - - for i := 0; i < 80 && rightJDE-leftJDE > localSolarEclipseContactTolerance; i++ { - midJDE := (leftJDE + rightJDE) / 2 - midGap := localSolarEclipseGap(midJDE, lonRad, latRad, heightKM, params, central) - if leftGap*midGap > 0 { - leftJDE = midJDE - leftGap = midGap - continue - } - rightJDE = midJDE - rightGap = midGap - } - - return (leftJDE + rightJDE) / 2, true -} - -func localSolarEclipseGap( - jdTT, lonRad, latRad, heightKM float64, - params solarEclipseModelParameters, - central bool, -) float64 { - state := localSolarEclipseStateAt(jdTT, lonRad, latRad, heightKM, params) - boundary := state.sunRadiusRad + state.moonOuterRadiusRad - if central { - boundary = math.Abs(state.sunRadiusRad - state.moonInnerRadiusRad) - } - return state.separationRad - boundary + return solarEclipseMovingDiskEngine().contactRoot( + greatestEclipseJDE, + direction, + localSolarEclipseContactStepDays, + localSolarEclipseContactStepDays*localSolarEclipseContactSearchSteps, + localSolarEclipseContactTolerance, + func(jd float64) (float64, bool) { + return evaluator.gap(jd, central) + }, + 80, + ) } func localSolarEclipseStateAt( jdTT, lonRad, latRad, heightKM float64, params solarEclipseModelParameters, ) localSolarEclipseState { - sunEquatorial, moonEquatorial := solarEclipseSunMoonEquatorial(jdTT) - sunXYZ := solarEclipseLLRToXYZ(sunEquatorial[0], sunEquatorial[1], sunEquatorial[2]) - moonXYZ := solarEclipseLLRToXYZ(moonEquatorial[0], moonEquatorial[1], moonEquatorial[2]) + return newLocalSolarEclipseStateContext(jdTT, params).stateAt(lonRad, latRad, heightKM) +} +func newLocalSolarEclipseStateContext( + jdTT float64, + params solarEclipseModelParameters, +) localSolarEclipseStateContext { + return newLocalSolarEclipseStateContextWithOverride(jdTT, math.NaN(), params) +} + +// newLocalSolarEclipseStateContextWithOverride 用显式 ΔT 构造站心状态上下文:TT 时刻不变, +// ΔT 只决定恒星时相位;deltaTSeconds 为 NaN 时走进程级模型。 +func newLocalSolarEclipseStateContextWithOverride( + jdTT, deltaTSeconds float64, + params solarEclipseModelParameters, +) localSolarEclipseStateContext { + sunEquatorial, moonEquatorial := solarEclipseSunMoonEquatorial(jdTT) utJDE := TD2UT(jdTT, false) - gst := ApparentSiderealTime(utJDE) * 15 * rad - observerXYZ := localSolarEclipseObserverXYZ(gst, lonRad, latRad, heightKM) + if !math.IsNaN(deltaTSeconds) { + utJDE = jdTT - deltaTSeconds/86400 + } + return localSolarEclipseStateContext{ + sunXYZ: solarEclipseLLRToXYZ(sunEquatorial[0], sunEquatorial[1], sunEquatorial[2]), + moonXYZ: solarEclipseLLRToXYZ(moonEquatorial[0], moonEquatorial[1], moonEquatorial[2]), + gst: ApparentSiderealTime(utJDE) * 15 * rad, + params: params, + } +} + +func (context localSolarEclipseStateContext) stateAt(lonRad, latRad, heightKM float64) localSolarEclipseState { + observerXYZ := localSolarEclipseObserverXYZ(context.gst, lonRad, latRad, heightKM) sunTopocentric := solarEclipseXYZToLLR( - sunXYZ[0]-observerXYZ[0], - sunXYZ[1]-observerXYZ[1], - sunXYZ[2]-observerXYZ[2], + context.sunXYZ[0]-observerXYZ[0], + context.sunXYZ[1]-observerXYZ[1], + context.sunXYZ[2]-observerXYZ[2], ) moonTopocentric := solarEclipseXYZToLLR( - moonXYZ[0]-observerXYZ[0], - moonXYZ[1]-observerXYZ[1], - moonXYZ[2]-observerXYZ[2], + context.moonXYZ[0]-observerXYZ[0], + context.moonXYZ[1]-observerXYZ[1], + context.moonXYZ[2]-observerXYZ[2], ) sunUnit := solarEclipseLLRToXYZ(sunTopocentric[0], sunTopocentric[1], 1) @@ -321,7 +354,7 @@ func localSolarEclipseStateAt( solarEclipseEarthEquatorialRadiusKM * solarEclipsePenumbralK * localSolarMoonRadiusScale / moonTopocentric[2], )) moonInnerRadiusRad := math.Asin(localSolarEclipseClampUnit( - solarEclipseEarthEquatorialRadiusKM * params.umbralK * localSolarMoonRadiusScale / moonTopocentric[2], + solarEclipseEarthEquatorialRadiusKM * context.params.umbralK * localSolarMoonRadiusScale / moonTopocentric[2], )) sunHorizontal := solarEclipseEquatorialToHorizontal( @@ -330,7 +363,7 @@ func localSolarEclipseStateAt( sunTopocentric[2], lonRad, latRad, - gst, + context.gst, ) return localSolarEclipseState{ diff --git a/basic/solar_eclipse_magnitude.go b/basic/solar_eclipse_magnitude.go new file mode 100644 index 0000000..6f3ab32 --- /dev/null +++ b/basic/solar_eclipse_magnitude.go @@ -0,0 +1,1181 @@ +package basic + +import ( + "math" + "sort" +) + +func (solver solarEclipseSolver) magnitudeContourSegments( + startJDE, endJDE, centralStartJDE, centralEndJDE, + greatestJDE, magnitude, fallbackStepDays float64, hybrid bool, +) [][]SolarEclipsePathPoint { + return solver.magnitudeContourSegmentsWithSpacing( + startJDE, endJDE, centralStartJDE, centralEndJDE, + greatestJDE, magnitude, fallbackStepDays, hybrid, + solarEclipseMagnitudeContourTargetSpacingKM, + ) +} + +func (solver solarEclipseSolver) magnitudeContourSegmentsWithSpacing( + startJDE, endJDE, centralStartJDE, centralEndJDE, + greatestJDE, magnitude, fallbackStepDays float64, hybrid bool, + targetSpacingKM float64, +) [][]SolarEclipsePathPoint { + if targetSpacingKM <= 0 || math.IsNaN(targetSpacingKM) || math.IsInf(targetSpacingKM, 0) { + targetSpacingKM = solarEclipseMagnitudeContourTargetSpacingKM + } + var transitions []SolarEclipsePathPoint + if hybrid && math.Abs(magnitude-1) <= 1e-12 { + transitions = solver.centralMagnitudeOneTransitionsInInterval( + centralStartJDE, centralEndJDE, greatestJDE, fallbackStepDays, + ) + if len(transitions) == 2 { + if segments := solver.hybridMagnitudeOneLimitSegments(transitions, fallbackStepDays); len(segments) == 2 { + return segments + } + } + } + seeds := solver.magnitudeContourPointsAt(greatestJDE, magnitude) + if len(seeds) == 0 { + times, _ := solarEclipsePathSampleTimes(startJDE, endJDE, greatestJDE, fallbackStepDays) + seedIndex := sort.SearchFloat64s(times, greatestJDE) + for offset := 1; offset < len(times); offset++ { + for _, index := range []int{seedIndex - offset, seedIndex + offset} { + if index < 0 || index >= len(times) { + continue + } + seeds = solver.magnitudeContourPointsAt(times[index], magnitude) + if len(seeds) > 0 { + break + } + } + if len(seeds) > 0 { + break + } + } + } + segments := make([][]SolarEclipsePathPoint, 0, len(seeds)) + constrainBranch := hybrid && math.Abs(magnitude-1) <= 1e-12 + for _, seed := range seeds { + backward := solver.traceMagnitudeContourArc( + seed, magnitude, -1, greatestJDE, startJDE, endJDE, targetSpacingKM, constrainBranch, + ) + forward := solver.traceMagnitudeContourArc( + seed, magnitude, 1, greatestJDE, startJDE, endJDE, targetSpacingKM, constrainBranch, + ) + segment := make([]SolarEclipsePathPoint, 0, len(backward)+len(forward)-1) + for index := len(backward) - 1; index >= 0; index-- { + segment = append(segment, backward[index]) + } + segment = append(segment, forward[1:]...) + if len(segment) >= 2 { + segments = append(segments, segment) + } + } + if len(transitions) > 0 { + segments = solver.completeMagnitudeOneContourTransitions(segments, transitions) + } + // A grazing zero-magnitude solve can converge to a degenerate branch that + // only touches the horizon at its endpoints and otherwise lies below it. + // That branch is not part of the visible envelope; retaining it creates a + // short backward spur when the phase curves are polygonized. Keep branches + // with a measurable above-horizon portion and let rise/set curves represent + // the exact tangent contact itself. + visible := segments[:0] + for _, segment := range segments { + maximumAltitude := math.Inf(-1) + for _, point := range segment { + maximumAltitude = math.Max(maximumAltitude, point.SunAltitude) + } + if maximumAltitude > 1e-6 { + visible = append(visible, segment) + } + } + segments = visible + return segments +} + +func (solver solarEclipseSolver) hybridMagnitudeOneLimitSegments( + transitions []SolarEclipsePathPoint, + fallbackStepDays float64, +) [][]SolarEclipsePathPoint { + if len(transitions) != 2 { + return nil + } + start, end := transitions[0], transitions[1] + if start.JDE > end.JDE { + start, end = end, start + } + stepDays := math.Min(fallbackStepDays, (end.JDE-start.JDE)/64) + centerLine, _ := solver.centralPathPoints( + start.JDE, + end.JDE, + (start.JDE+end.JDE)/2, + SolarEclipsePathOptions{ + StepDays: stepDays, + TargetSpacingKM: solarEclipseMagnitudeContourTargetSpacingKM, + }, + ) + northern, southern := solver.centralPathLimits(centerLine) + if len(northern) < 2 || len(northern) != len(southern) { + return nil + } + for _, segment := range [][]SolarEclipsePathPoint{northern, southern} { + segment[0] = start + segment[0].WidthKM = 0 + segment[len(segment)-1] = end + segment[len(segment)-1].WidthKM = 0 + } + return [][]SolarEclipsePathPoint{northern, southern} +} + +func (solver solarEclipseSolver) centralMagnitudeOneTransitionsInInterval( + startJDE, endJDE, greatestJDE, stepDays float64, +) []SolarEclipsePathPoint { + if startJDE == 0 || endJDE == 0 || endJDE <= startJDE { + return nil + } + transitionStepDays := math.Min(stepDays, (endJDE-startJDE)/64) + times, _ := solarEclipsePathSampleTimes(startJDE, endJDE, greatestJDE, transitionStepDays) + edgeOffset := math.Min(1.0/86400.0, (endJDE-startJDE)/1000) + if edgeOffset > 0 { + times = append(times, startJDE+edgeOffset, endJDE-edgeOffset) + sort.Float64s(times) + times = uniqueSolarEclipsePathTimes(times) + } + points := make([]SolarEclipsePathPoint, 0, len(times)) + for _, jd := range times { + if point, ok := solver.centralPathPointAt(jd); ok { + points = append(points, point) + } + } + return solver.centralMagnitudeOneTransitions(points) +} + +func (solver solarEclipseSolver) centralMagnitudeOneTransitions( + points []SolarEclipsePathPoint, +) []SolarEclipsePathPoint { + if len(points) < 2 { + return nil + } + transitions := make([]SolarEclipsePathPoint, 0, 2) + previousPoint := points[0] + previousGap, previousOK := solver.centralMagnitudeOneGap(previousPoint) + for _, point := range points[1:] { + gap, ok := solver.centralMagnitudeOneGap(point) + if previousOK && ok && previousGap*gap <= 0 { + transition, transitionOK := solver.refineCentralMagnitudeOneTransition( + previousPoint, point, previousGap, gap, + ) + if transitionOK && !solarEclipseRiseSetPointExists(transitions, transition) { + transitions = append(transitions, transition) + } + } + previousPoint, previousGap, previousOK = point, gap, ok + } + return transitions +} + +func (solver solarEclipseSolver) centralMagnitudeOneGap(point SolarEclipsePathPoint) (float64, bool) { + context := solver.localStateContextAt(point.JDE) + state := context.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + gap := state.moonInnerRadiusRad/state.sunRadiusRad - 1 + return gap, finite(gap) +} + +func (solver solarEclipseSolver) refineCentralMagnitudeOneTransition( + left, right SolarEclipsePathPoint, + leftGap, rightGap float64, +) (SolarEclipsePathPoint, bool) { + if left.JDE > right.JDE { + left, right = right, left + leftGap, rightGap = rightGap, leftGap + } + if leftGap*rightGap > 0 { + return SolarEclipsePathPoint{}, false + } + if math.Abs(leftGap) <= 1e-12 { + return left, true + } + if math.Abs(rightGap) <= 1e-12 { + return right, true + } + for iteration := 0; iteration < 64 && right.JDE-left.JDE > solarEclipsePathDuplicateTimeDays; iteration++ { + middle, ok := solver.centralPathPointAt((left.JDE + right.JDE) / 2) + if !ok { + return SolarEclipsePathPoint{}, false + } + middleGap, ok := solver.centralMagnitudeOneGap(middle) + if !ok { + return SolarEclipsePathPoint{}, false + } + if math.Abs(middleGap) <= 1e-12 { + return middle, true + } + if leftGap*middleGap <= 0 { + right, rightGap = middle, middleGap + } else { + left, leftGap = middle, middleGap + } + } + transition, ok := solver.centralPathPointAt((left.JDE + right.JDE) / 2) + if !ok { + return SolarEclipsePathPoint{}, false + } + gap, ok := solver.centralMagnitudeOneGap(transition) + return transition, ok && math.Abs(gap) <= 1e-8 +} + +func (solver solarEclipseSolver) completeMagnitudeOneContourTransitions( + segments [][]SolarEclipsePathPoint, + transitions []SolarEclipsePathPoint, +) [][]SolarEclipsePathPoint { + if len(transitions) == 0 { + return segments + } + const maximumTimeGapDays = 5.0 / 1440.0 + for segmentIndex, segment := range segments { + if len(segment) < 2 { + continue + } + for _, atStart := range []bool{true, false} { + endpointIndex := len(segment) - 1 + if atStart { + endpointIndex = 0 + } + endpoint := segment[endpointIndex] + bestIndex := -1 + bestDistance := math.Inf(1) + for transitionIndex, transition := range transitions { + distance := solarEclipsePathDistanceKM(endpoint, transition) + if distance < bestDistance { + bestIndex, bestDistance = transitionIndex, distance + } + } + if bestIndex < 0 || bestDistance > 3000 || + math.Abs(endpoint.JDE-transitions[bestIndex].JDE) > maximumTimeGapDays { + continue + } + transition := transitions[bestIndex] + transition.WidthKM = 0 + if bestDistance < 0.01 { + segment[endpointIndex] = transition + continue + } + if bestDistance > solarEclipseMagnitudeContourTargetSpacingKM { + if bridge := solver.magnitudeOneTransitionBridge(transition, endpoint); len(bridge) >= 2 { + if atStart { + segment = append(bridge[:len(bridge)-1], segment...) + } else { + for index := len(bridge) - 2; index >= 0; index-- { + segment = append(segment, bridge[index]) + } + } + continue + } + } + if atStart { + segment = append([]SolarEclipsePathPoint{transition}, segment...) + } else { + segment = append(segment, transition) + } + } + segments[segmentIndex] = segment + } + return segments +} + +func (solver solarEclipseSolver) magnitudeOneTransitionBridge( + transition, endpoint SolarEclipsePathPoint, +) []SolarEclipsePathPoint { + start, end := transition, endpoint + reverse := false + if start.JDE > end.JDE { + start, end = end, start + reverse = true + } + spanDays := end.JDE - start.JDE + if spanDays <= 0 { + return nil + } + centerLine, _ := solver.centralPathPoints( + start.JDE, + end.JDE, + (start.JDE+end.JDE)/2, + SolarEclipsePathOptions{ + StepDays: math.Min(1.0/1440.0, spanDays/16), + TargetSpacingKM: solarEclipseMagnitudeContourTargetSpacingKM, + }, + ) + first, second := solver.centralPathLimits(centerLine) + if len(first) < 2 || len(first) != len(second) { + return nil + } + bridge := first + if reverse { + if solarEclipsePathDistanceKM(second[0], endpoint) < solarEclipsePathDistanceKM(first[0], endpoint) { + bridge = second + } + } else if solarEclipsePathDistanceKM(second[len(second)-1], endpoint) < + solarEclipsePathDistanceKM(first[len(first)-1], endpoint) { + bridge = second + } + bridge[0], bridge[len(bridge)-1] = start, end + for index := range bridge { + bridge[index].WidthKM = 0 + } + if reverse { + for left, right := 0, len(bridge)-1; left < right; left, right = left+1, right-1 { + bridge[left], bridge[right] = bridge[right], bridge[left] + } + } + return bridge +} + +type solarEclipseMagnitudeArcState struct { + coordinates [3]float64 + tangent [3]float64 + point SolarEclipsePathPoint +} + +func (solver solarEclipseSolver) traceMagnitudeContourArc( + seed SolarEclipsePathPoint, + magnitude float64, + direction int, + referenceJDE, startJDE, endJDE float64, + targetSpacingKM float64, + constrainBranch bool, +) []SolarEclipsePathPoint { + state, ok := solver.magnitudeArcStateAt(seed, magnitude, referenceJDE) + if !ok { + return []SolarEclipsePathPoint{seed} + } + branchSign, branchConstrained := 0.0, false + if constrainBranch { + branchSign, branchConstrained = solver.magnitudeContourBranchSign(seed) + } + for index := range state.tangent { + state.tangent[index] *= float64(direction) + } + points := []SolarEclipsePathPoint{seed} + step := solarEclipseMagnitudeContourArcStepDegrees + // Continue the F(magnitude, greatest-time)=0 curve in longitude, latitude, and scaled time. + for count := 0; count < solarEclipseMagnitudeContourMaxArcSteps; count++ { + predictor := state.coordinates + for index := range predictor { + predictor[index] += step * state.tangent[index] + } + next, iterations, nextOK := solver.correctMagnitudeContourArc( + predictor, state.tangent, magnitude, referenceJDE, + ) + if !nextOK { + step /= 2 + if step < solarEclipseMagnitudeContourMinArcStepDegrees { + break + } + continue + } + if dotSolarEclipse3(next.tangent, state.tangent) < 0 { + for index := range next.tangent { + next.tangent[index] = -next.tangent[index] + } + } + if nextSign, nextConstrained := solver.magnitudeContourBranchSign(next.point); constrainBranch && nextConstrained { + if branchConstrained && branchSign*nextSign < 0 { + step /= 2 + if step < solarEclipseMagnitudeContourMinArcStepDegrees { + break + } + continue + } + branchSign, branchConstrained = nextSign, true + } + distance := solarEclipsePathDistanceKM(state.point, next.point) + if distance > targetSpacingKM || solarEclipseMagnitudeChordErrorKM(state, next, distance) > 2 { + step /= 2 + if step < solarEclipseMagnitudeContourMinArcStepDegrees { + break + } + continue + } + if next.point.JDE < startJDE-0.05 || next.point.JDE > endJDE+0.05 { + break + } + if next.point.SunAltitude < 0 { + if endpoint, endpointOK := solver.refineMagnitudeHorizonCrossing(state.point, next.point, magnitude); endpointOK { + points = append(points, endpoint) + break + } + step /= 2 + if step < solarEclipseMagnitudeContourMinArcStepDegrees { + break + } + continue + } + points = append(points, next.point) + state = next + if next.point.SunAltitude <= 1e-7 { + break + } + if distance < targetSpacingKM/2 && iterations <= 4 { + step = math.Min(solarEclipseMagnitudeContourArcStepDegrees, step*1.5) + } + } + return points +} + +// Bound the chord error as well as the spacing near a curved grazing limit. +func solarEclipseMagnitudeChordErrorKM(first, second solarEclipseMagnitudeArcState, distance float64) float64 { + latitude := (first.point.Latitude + second.point.Latitude) * rad / 2 + ax, ay := first.tangent[0]*math.Cos(latitude), first.tangent[1] + bx, by := second.tangent[0]*math.Cos(latitude), second.tangent[1] + norm := math.Hypot(ax, ay) * math.Hypot(bx, by) + if norm == 0 { + return math.Inf(1) + } + cosine := math.Max(-1, math.Min(1, (ax*bx+ay*by)/norm)) + return distance * math.Sqrt(2*(1-cosine)) / 8 +} + +// magnitudeContourBranchSign identifies which side of the simultaneous +// central path a contour point occupies. A magnitude contour can have two +// nearby roots near a hybrid transition; keeping this sign prevents Newton +// correction from silently switching to the opposite root. +func (solver solarEclipseSolver) magnitudeContourBranchSign(point SolarEclipsePathPoint) (float64, bool) { + center, centerOK := solver.centralPathPointAt(point.JDE) + before, beforeOK := solver.centralPathPointAt(point.JDE - solarEclipsePathVelocityStepDays) + after, afterOK := solver.centralPathPointAt(point.JDE + solarEclipsePathVelocityStepDays) + if !centerOK || !beforeOK || !afterOK { + return 0, false + } + cosLatitude := math.Cos(center.Latitude * rad) + pathX := math.Remainder(after.Longitude-before.Longitude, 360) * cosLatitude + pathY := after.Latitude - before.Latitude + offsetX := math.Remainder(point.Longitude-center.Longitude, 360) * cosLatitude + offsetY := point.Latitude - center.Latitude + sign := pathX*offsetY - pathY*offsetX + if !finite(sign) || math.Abs(sign) <= 1e-10 { + return 0, false + } + return sign, true +} + +func (solver solarEclipseSolver) magnitudeArcStateAt( + point SolarEclipsePathPoint, + magnitude, referenceJDE float64, +) (solarEclipseMagnitudeArcState, bool) { + coordinates := [3]float64{ + point.Longitude, + point.Latitude, + (point.JDE - referenceJDE) * solarEclipseMagnitudeContourTimeScale, + } + _, jacobian, ok := solver.magnitudeEnvelopeJacobian(coordinates, magnitude, referenceJDE) + if !ok { + return solarEclipseMagnitudeArcState{}, false + } + tangent, ok := solarEclipseMagnitudeArcTangent(jacobian) + return solarEclipseMagnitudeArcState{coordinates: coordinates, tangent: tangent, point: point}, ok +} + +func (solver solarEclipseSolver) correctMagnitudeContourArc( + predictor, tangent [3]float64, + magnitude, referenceJDE float64, +) (solarEclipseMagnitudeArcState, int, bool) { + coordinates := predictor + for iteration := 0; iteration < 16; iteration++ { + if !finite(coordinates[0]) || !finite(coordinates[1]) || !finite(coordinates[2]) { + return solarEclipseMagnitudeArcState{}, iteration, false + } + residual, jacobian, ok := solver.magnitudeEnvelopeJacobian(coordinates, magnitude, referenceJDE) + if !ok { + return solarEclipseMagnitudeArcState{}, iteration, false + } + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(planeResidual) <= 1e-9 { + return solver.validMagnitudeArcState(coordinates, jacobian, magnitude, referenceJDE, iteration+1) + } + matrix := [3][3]float64{jacobian[0], jacobian[1], tangent} + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -planeResidual}) + if !ok { + return solarEclipseMagnitudeArcState{}, iteration, false + } + norm := math.Sqrt(dotSolarEclipse3(delta, delta)) + if norm > 2 { + for index := range delta { + delta[index] *= 2 / norm + } + } + for index := range coordinates { + coordinates[index] += delta[index] + } + if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { + return solarEclipseMagnitudeArcState{}, iteration, false + } + } + residual, jacobian, ok := solver.magnitudeEnvelopeJacobian(coordinates, magnitude, referenceJDE) + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(planeResidual) > 1e-7 { + return solarEclipseMagnitudeArcState{}, 16, false + } + return solver.validMagnitudeArcState(coordinates, jacobian, magnitude, referenceJDE, 16) +} + +func (solver solarEclipseSolver) validMagnitudeArcState( + coordinates [3]float64, + jacobian [2][3]float64, + magnitude, referenceJDE float64, + iterations int, +) (solarEclipseMagnitudeArcState, int, bool) { + jd := referenceJDE + coordinates[2]/solarEclipseMagnitudeContourTimeScale + longitude := normalizeLongitude(coordinates[0]) + latitude := coordinates[1] + evaluation := solver.magnitudeEvaluationAt(jd) + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + if math.Abs(solarEclipseMagnitudeAtTarget(state, magnitude)-magnitude) > 1e-7 || + evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + return solarEclipseMagnitudeArcState{}, iterations, false + } + tangent, ok := solarEclipseMagnitudeArcTangent(jacobian) + if !ok { + return solarEclipseMagnitudeArcState{}, iterations, false + } + return solarEclipseMagnitudeArcState{ + coordinates: coordinates, + tangent: tangent, + point: SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + }, + }, iterations, true +} + +func (solver solarEclipseSolver) magnitudeEnvelopeJacobian( + coordinates [3]float64, + magnitude, referenceJDE float64, +) ([2]float64, [2][3]float64, bool) { + jd := referenceJDE + coordinates[2]/solarEclipseMagnitudeContourTimeScale + longitude := normalizeLongitude(coordinates[0]) + latitude := coordinates[1] + evaluation := solver.magnitudeEvaluationAt(jd) + residual, ok := solarEclipseMagnitudeEnvelopeResidualAt(evaluation, longitude, latitude, magnitude) + if !ok { + return [2]float64{}, [2][3]float64{}, false + } + steps := [3]float64{1e-4, 1e-4, 5.0 * solarEclipseMagnitudeContourTimeScale / 86400.0} + jacobian := [2][3]float64{} + spatialCoordinates := [][2]float64{{longitude + steps[0], latitude}, {longitude, latitude + steps[1]}} + for column, shifted := range spatialCoordinates { + shiftedResidual, shiftedOK := solarEclipseMagnitudeEnvelopeResidualAt( + evaluation, shifted[0], shifted[1], magnitude, + ) + if !shiftedOK { + return [2]float64{}, [2][3]float64{}, false + } + for row := 0; row < 2; row++ { + jacobian[row][column] = (shiftedResidual[row] - residual[row]) / steps[column] + } + } + timeEvaluation := solver.magnitudeEvaluationAt(jd + steps[2]/solarEclipseMagnitudeContourTimeScale) + timeResidual, timeOK := solarEclipseMagnitudeEnvelopeResidualAt( + timeEvaluation, longitude, latitude, magnitude, + ) + if !timeOK { + return [2]float64{}, [2][3]float64{}, false + } + beforeEvaluation := solver.magnitudeEvaluationAt(jd - steps[2]/solarEclipseMagnitudeContourTimeScale) + beforeResidual, beforeOK := solarEclipseMagnitudeEnvelopeResidualAt( + beforeEvaluation, longitude, latitude, magnitude, + ) + if !beforeOK { + return [2]float64{}, [2][3]float64{}, false + } + for row := 0; row < 2; row++ { + jacobian[row][2] = (timeResidual[row] - beforeResidual[row]) / (2 * steps[2]) + } + return residual, jacobian, true +} + +func solarEclipseMagnitudeEnvelopeResidualAt( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude, magnitude float64, +) ([2]float64, bool) { + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + residual := [2]float64{ + solarEclipseMagnitudeAtTarget(state, magnitude) - magnitude, + evaluation.separationDerivative(longitude, latitude), + } + return residual, finite(residual[0]) && finite(residual[1]) +} + +func solarEclipseMagnitudeArcTangent(jacobian [2][3]float64) ([3]float64, bool) { + tangent := [3]float64{ + jacobian[0][1]*jacobian[1][2] - jacobian[0][2]*jacobian[1][1], + jacobian[0][2]*jacobian[1][0] - jacobian[0][0]*jacobian[1][2], + jacobian[0][0]*jacobian[1][1] - jacobian[0][1]*jacobian[1][0], + } + norm := math.Sqrt(dotSolarEclipse3(tangent, tangent)) + if !finite(norm) || norm < 1e-14 { + return [3]float64{}, false + } + for index := range tangent { + tangent[index] /= norm + } + return tangent, true +} + +func dotSolarEclipse3(first, second [3]float64) float64 { + return first[0]*second[0] + first[1]*second[1] + first[2]*second[2] +} + +func subtractSolarEclipse3(first, second [3]float64) [3]float64 { + return [3]float64{first[0] - second[0], first[1] - second[1], first[2] - second[2]} +} + +func (solver solarEclipseSolver) refineMagnitudeHorizonCrossing( + visible, hidden SolarEclipsePathPoint, + magnitude float64, +) (SolarEclipsePathPoint, bool) { + fraction := visible.SunAltitude / (visible.SunAltitude - hidden.SunAltitude) + deltaLongitude := math.Remainder(hidden.Longitude-visible.Longitude, 360) + return solver.refineMagnitudeHorizonPoint( + visible.JDE+fraction*(hidden.JDE-visible.JDE), + normalizeLongitude(visible.Longitude+fraction*deltaLongitude), + visible.Latitude+fraction*(hidden.Latitude-visible.Latitude), + magnitude, + ) +} + +func (solver solarEclipseSolver) refineMagnitudeHorizonPoint( + jd, longitude, latitude, magnitude float64, +) (SolarEclipsePathPoint, bool) { + const ( + geographicStep = 1e-4 + timeStep = 5.0 / 86400.0 + ) + for iteration := 0; iteration < 24; iteration++ { + evaluation := solver.magnitudeEvaluationAt(jd) + residual, ok := solarEclipseMagnitudeHorizonResidualAt(evaluation, longitude, latitude, magnitude) + if !ok { + return SolarEclipsePathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 { + break + } + longitudeResidual, lonOK := solarEclipseMagnitudeHorizonResidualAt( + evaluation, longitude+geographicStep, latitude, magnitude, + ) + latitudeResidual, latOK := solarEclipseMagnitudeHorizonResidualAt( + evaluation, longitude, latitude+geographicStep, magnitude, + ) + timeResidual, timeOK := solver.magnitudeHorizonResidual(jd+timeStep, longitude, latitude, magnitude) + if !lonOK || !latOK || !timeOK { + return SolarEclipsePathPoint{}, false + } + matrix := [3][3]float64{} + for row := 0; row < 3; row++ { + matrix[row][0] = (longitudeResidual[row] - residual[row]) / geographicStep + matrix[row][1] = (latitudeResidual[row] - residual[row]) / geographicStep + matrix[row][2] = (timeResidual[row] - residual[row]) / timeStep + } + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !ok { + return SolarEclipsePathPoint{}, false + } + geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1])) + if geographicScale > 2 { + delta[0] *= 2 / geographicScale + delta[1] *= 2 / geographicScale + } + if math.Abs(delta[2]) > 10.0/1440.0 { + delta[2] = math.Copysign(10.0/1440.0, delta[2]) + } + longitude = normalizeLongitude(longitude + delta[0]) + latitude += delta[1] + jd += delta[2] + } + residual, ok := solver.magnitudeHorizonResidual(jd, longitude, latitude, magnitude) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 { + return SolarEclipsePathPoint{}, false + } + evaluation := solver.magnitudeEvaluationAt(jd) + if evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + return SolarEclipsePathPoint{}, false + } + return SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: residual[2] / rad, + }, true +} + +func (solver solarEclipseSolver) magnitudeEvaluationAt(jd float64) solarEclipseRiseSetEvaluation { + return solarEclipseRiseSetEvaluation{ + jd: jd, + center: solver.localStateContextAt(jd), + before: solver.localStateContextAt(jd - solarEclipseRiseSetDerivativeStepDays), + after: solver.localStateContextAt(jd + solarEclipseRiseSetDerivativeStepDays), + } +} + +func (solver solarEclipseSolver) magnitudeCandidateEvaluationAt(jd float64) solarEclipseRiseSetEvaluation { + return solarEclipseRiseSetEvaluation{ + jd: jd, + center: solver.localStateContextCandidateAt(jd), + before: solver.localStateContextCandidateAt(jd - solarEclipseRiseSetDerivativeStepDays), + after: solver.localStateContextCandidateAt(jd + solarEclipseRiseSetDerivativeStepDays), + } +} + +func (solver solarEclipseSolver) localStateContextAt(jd float64) localSolarEclipseStateContext { + if solver.localStateContextCache == nil { + return newLocalSolarEclipseStateContextWithOverride(jd, solver.deltaTSeconds, solver.params) + } + key := math.Float64bits(jd) + if context, ok := solver.localStateContextCache[key]; ok && + context.generation == deltaTGenerationValue() { + return context + } + context := newLocalSolarEclipseStateContextWithOverride(jd, solver.deltaTSeconds, solver.params) + return storeLocalSolarEclipseStateContext(solver.localStateContextCache, key, context) +} + +func storeLocalSolarEclipseStateContext( + cache map[uint64]localSolarEclipseStateContext, + key uint64, + context localSolarEclipseStateContext, +) localSolarEclipseStateContext { + if cache == nil { + return context + } + // 与贝塞尔几何缓存同口径:事件级缓存不维护淘汰表,超过上限整体作废更可预测。 + if _, exists := cache[key]; !exists && len(cache) >= solarEclipseBesselGeometryCacheMaximumEntries { + for cachedKey := range cache { + delete(cache, cachedKey) + } + } + context.generation = deltaTGenerationValue() + cache[key] = context + return context +} + +func (solver solarEclipseSolver) localStateContextCandidateAt(jd float64) localSolarEclipseStateContext { + if solver.localEphemeris == nil { + return solver.localStateContextAt(jd) + } + sun, moon, ok := solver.localEphemeris.equatorialAt(jd) + if !ok { + return solver.localStateContextAt(jd) + } + return localSolarEclipseStateContext{ + sunXYZ: solarEclipseLLRToXYZ(sun[0], sun[1], sun[2]), + moonXYZ: solarEclipseLLRToXYZ(moon[0], moon[1], moon[2]), + gst: solver.siderealTimeAt(jd), + params: solver.params, + } +} + +func (solver solarEclipseSolver) magnitudeHorizonResidual( + jd, longitude, latitude, magnitude float64, +) ([3]float64, bool) { + evaluation := solver.magnitudeEvaluationAt(jd) + return solarEclipseMagnitudeHorizonResidualAt(evaluation, longitude, latitude, magnitude) +} + +func solarEclipseMagnitudeHorizonResidualAt( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude, magnitude float64, +) ([3]float64, bool) { + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + value := [3]float64{ + solarEclipseMagnitudeAtTarget(state, magnitude) - magnitude, + evaluation.separationDerivative(longitude, latitude), + state.sunAltitudeRad, + } + return value, finite(value[0]) && finite(value[1]) && finite(value[2]) +} + +func solveSolarEclipse3x3(matrix [3][3]float64, right [3]float64) ([3]float64, bool) { + augmented := [3][4]float64{} + for row := 0; row < 3; row++ { + copy(augmented[row][:3], matrix[row][:]) + augmented[row][3] = right[row] + } + for column := 0; column < 3; column++ { + pivot := column + for row := column + 1; row < 3; row++ { + if math.Abs(augmented[row][column]) > math.Abs(augmented[pivot][column]) { + pivot = row + } + } + if !finite(augmented[pivot][column]) || math.Abs(augmented[pivot][column]) < 1e-18 { + return [3]float64{}, false + } + augmented[column], augmented[pivot] = augmented[pivot], augmented[column] + for row := column + 1; row < 3; row++ { + factor := augmented[row][column] / augmented[column][column] + for index := column; index < 4; index++ { + augmented[row][index] -= factor * augmented[column][index] + } + } + } + result := [3]float64{} + for row := 2; row >= 0; row-- { + value := augmented[row][3] + for column := row + 1; column < 3; column++ { + value -= augmented[row][column] * result[column] + } + result[row] = value / augmented[row][row] + if !finite(result[row]) { + return [3]float64{}, false + } + } + return result, true +} + +func (solver solarEclipseSolver) magnitudeContourPointsAt(jd, magnitude float64) []SolarEclipsePathPoint { + moon := solver.besselMoonAt(jd) + axis := solver.besselAxisAt(jd) + evaluation := solver.magnitudeEvaluationAt(jd) + valueAt := func(angle float64) (float64, bool) { + point, ok := solver.magnitudeContourPointAt( + jd, moon, axis, math.Cos(angle), math.Sin(angle), magnitude, + ) + if !ok { + return 0, false + } + return evaluation.separationDerivative(point.Longitude, point.Latitude), true + } + points := make([]SolarEclipsePathPoint, 0, 2) + for _, angle := range riseSetCyclicRoots(solarEclipseMagnitudeContourBoundaryPoints, valueAt) { + seed, ok := solver.magnitudeContourPointAt( + jd, moon, axis, math.Cos(angle), math.Sin(angle), magnitude, + ) + if !ok { + continue + } + longitude, latitude, ok := solver.refineMagnitudeEnvelopePoint( + magnitude, seed.Longitude, seed.Latitude, evaluation, + ) + if !ok { + continue + } + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + if state.sunAltitudeRad < -1e-7 || evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + continue + } + point := SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + } + if !solarEclipseRiseSetPointExists(points, point) { + points = append(points, point) + } + } + if magnitude == 1 && len(points) < 2 { + points = solver.appendMagnitudeOneLimitSeeds(points, jd, evaluation) + } + if len(points) == 0 { + points = solver.magnitudeContourGeographicSeedsAt(jd, magnitude) + } + return points +} + +func (solver solarEclipseSolver) appendMagnitudeOneLimitSeeds( + points []SolarEclipsePathPoint, + jd float64, + evaluation solarEclipseRiseSetEvaluation, +) []SolarEclipsePathPoint { + center, ok := solver.centralPathPointAt(jd) + if !ok { + return points + } + first, second, ok := solver.centralPathLimitsAt(center) + if !ok { + return points + } + for _, seed := range []SolarEclipsePathPoint{first, second} { + longitude, latitude, refined := solver.refineMagnitudeEnvelopePoint( + 1, seed.Longitude, seed.Latitude, evaluation, + ) + if !refined { + continue + } + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + if state.sunAltitudeRad < -1e-7 || evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + continue + } + candidate := SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + } + if !solarEclipseRiseSetPointExists(points, candidate) { + points = append(points, candidate) + } + } + return points +} + +// magnitudeContourGeographicSeedsAt supplies seeds for contours inside the +// signed Bessel umbra. The cone-radius interpolation is well-conditioned for +// m<=1, but it can become negative for a legitimate m>1 contour near a +// total/annular transition. Solving the local-magnitude envelope from the +// greatest point keeps those contours available without changing the normal +// Bessel path. +func (solver solarEclipseSolver) magnitudeContourGeographicSeedsAt(jd, magnitude float64) []SolarEclipsePathPoint { + center, ok := solver.centralPathPointAt(jd) + if !ok { + // A non-central eclipse has no Earth-intersecting shadow axis, but its + // local maximum still has a well-defined geographic stationary point. + // Reuse the global greatest-eclipse coordinates as the bounded seed for + // the local envelope solver instead of treating the missing central axis + // as evidence that every magnitude contour is absent. + result := solarEclipse(solver.newMoonJDE, solver.model) + if !result.HasPartial || result.GreatestEclipse == 0 { + return nil + } + center = SolarEclipsePathPoint{ + JDE: jd, + Longitude: result.GreatestLongitude, + Latitude: result.GreatestLatitude, + } + } + evaluation := solver.magnitudeEvaluationAt(jd) + centerState := evaluation.center.stateAt(center.Longitude*rad, center.Latitude*rad, 0) + maximum := solarEclipseMagnitudeAtTarget(centerState, magnitude) + if !finite(maximum) || maximum <= magnitude+1e-9 { + return nil + } + + centerLon, centerLat := center.Longitude*rad, center.Latitude*rad + centerVector := [3]float64{ + math.Cos(centerLat) * math.Cos(centerLon), + math.Cos(centerLat) * math.Sin(centerLon), + math.Sin(centerLat), + } + east := [3]float64{-math.Sin(centerLon), math.Cos(centerLon), 0} + north := [3]float64{ + -math.Sin(centerLat) * math.Cos(centerLon), + -math.Sin(centerLat) * math.Sin(centerLon), + math.Cos(centerLat), + } + distanceDegrees := []float64{0.02, 0.05, 0.1, 0.2, 0.4, 0.8, 1.6, 3.2, 6.4, 12.8, 25.6, 51.2} + seeds := make([]SolarEclipsePathPoint, 0, 2) + for bearingIndex := 0; bearingIndex < solarEclipseMagnitudeContourFallbackBearings; bearingIndex++ { + bearing := 2 * math.Pi * float64(bearingIndex) / float64(solarEclipseMagnitudeContourFallbackBearings) + direction := [3]float64{ + math.Cos(bearing)*north[0] + math.Sin(bearing)*east[0], + math.Cos(bearing)*north[1] + math.Sin(bearing)*east[1], + math.Cos(bearing)*north[2] + math.Sin(bearing)*east[2], + } + previousDistance := 0.0 + previousValue := maximum - magnitude + for distanceIndex := 1; distanceIndex < solarEclipseMagnitudeContourFallbackDistances; distanceIndex++ { + distance := distanceDegrees[distanceIndex] + angle := distance * rad + pointVector := [3]float64{ + centerVector[0]*math.Cos(angle) + direction[0]*math.Sin(angle), + centerVector[1]*math.Cos(angle) + direction[1]*math.Sin(angle), + centerVector[2]*math.Cos(angle) + direction[2]*math.Sin(angle), + } + longitude := normalizeLongitude(math.Atan2(pointVector[1], pointVector[0]) / rad) + latitude := math.Asin(math.Max(-1, math.Min(1, pointVector[2]))) / rad + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + value := solarEclipseMagnitudeAtTarget(state, magnitude) - magnitude + if !finite(value) { + previousDistance = distance + previousValue = math.NaN() + continue + } + if finite(previousValue) && previousValue >= 0 && value <= 0 { + left, right := previousDistance, distance + leftValue := previousValue + for iteration := 0; iteration < 48 && right-left > 1e-10; iteration++ { + middle := (left + right) / 2 + middleAngle := middle * rad + middleVector := [3]float64{ + centerVector[0]*math.Cos(middleAngle) + direction[0]*math.Sin(middleAngle), + centerVector[1]*math.Cos(middleAngle) + direction[1]*math.Sin(middleAngle), + centerVector[2]*math.Cos(middleAngle) + direction[2]*math.Sin(middleAngle), + } + middleLongitude := normalizeLongitude(math.Atan2(middleVector[1], middleVector[0]) / rad) + middleLatitude := math.Asin(math.Max(-1, math.Min(1, middleVector[2]))) / rad + middleState := evaluation.center.stateAt(middleLongitude*rad, middleLatitude*rad, 0) + middleValue := solarEclipseMagnitudeAtTarget(middleState, magnitude) - magnitude + if !finite(middleValue) { + break + } + if leftValue*middleValue <= 0 { + right = middle + } else { + left, leftValue = middle, middleValue + } + } + seedDistance := (left + right) / 2 + seedAngle := seedDistance * rad + seedVector := [3]float64{ + centerVector[0]*math.Cos(seedAngle) + direction[0]*math.Sin(seedAngle), + centerVector[1]*math.Cos(seedAngle) + direction[1]*math.Sin(seedAngle), + centerVector[2]*math.Cos(seedAngle) + direction[2]*math.Sin(seedAngle), + } + seedLongitude := normalizeLongitude(math.Atan2(seedVector[1], seedVector[0]) / rad) + seedLatitude := math.Asin(math.Max(-1, math.Min(1, seedVector[2]))) / rad + seedLongitude, seedLatitude, refined := solver.refineMagnitudeEnvelopePoint( + magnitude, seedLongitude, seedLatitude, evaluation, + ) + if refined { + state := evaluation.center.stateAt(seedLongitude*rad, seedLatitude*rad, 0) + candidate := SolarEclipsePathPoint{ + JDE: jd, Longitude: seedLongitude, Latitude: seedLatitude, + SunAltitude: state.sunAltitudeRad / rad, + } + if !solarEclipseRiseSetPointExists(seeds, candidate) { + seeds = append(seeds, candidate) + } + } + break + } + previousDistance, previousValue = distance, value + } + } + return seeds +} + +func (solver solarEclipseSolver) refineMagnitudeEnvelopePoint( + magnitude, longitude, latitude float64, + evaluation solarEclipseRiseSetEvaluation, +) (float64, float64, bool) { + return riseSetRefineGeographicRoot(longitude, latitude, func(lon, lat float64) (float64, float64, bool) { + state := evaluation.center.stateAt(lon*rad, lat*rad, 0) + instantaneousMagnitude := solarEclipseMagnitudeAtTarget(state, magnitude) + return instantaneousMagnitude - magnitude, evaluation.separationDerivative(lon, lat), + finite(instantaneousMagnitude) + }) +} + +func solarEclipseMagnitudeContourCompatibilitySides( + segments [][]SolarEclipsePathPoint, +) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + if len(segments) < 2 { + return nil, nil + } + indices := []int{0, 1} + for index := 2; index < len(segments); index++ { + if len(segments[index]) <= len(segments[indices[1]]) { + continue + } + indices[1] = index + if len(segments[indices[1]]) > len(segments[indices[0]]) { + indices[0], indices[1] = indices[1], indices[0] + } + } + first, second := segments[indices[0]], segments[indices[1]] + firstLatitude := first[len(first)/2].Latitude + secondLatitude := second[len(second)/2].Latitude + if secondLatitude > firstLatitude { + first, second = second, first + } + return first, second +} + +func solarEclipseLocalMagnitude(state localSolarEclipseState) float64 { + if state.sunRadiusRad <= 0 { + return math.NaN() + } + return (state.moonOuterRadiusRad + state.sunRadiusRad - state.separationRad) / (2 * state.sunRadiusRad) +} + +func solarEclipseMagnitudeAtTarget(state localSolarEclipseState, target float64) float64 { + if state.sunRadiusRad <= 0 { + return math.NaN() + } + if target > 1 { + centralGap := state.moonInnerRadiusRad - state.sunRadiusRad + if centralGap <= 0 { + return math.NaN() + } + // Inside totality, normalize the magnitude from 1 at the inner + // contact to the apparent-diameter ratio at zero separation. This + // preserves the usual m=1 boundary while retaining legal values + // above one for deep total eclipses. + return 1 + (state.moonInnerRadiusRad/state.sunRadiusRad-1)* + (1-state.separationRad/centralGap) + } + moonRadius := state.moonOuterRadiusRad + if target == 1 { + moonRadius = state.moonInnerRadiusRad + } + return (moonRadius + state.sunRadiusRad - state.separationRad) / (2 * state.sunRadiusRad) +} + +func (solver solarEclipseSolver) magnitudeContourPointAt( + jd float64, + moon [3]float64, + axis solarEclipseAxis, + directionX, directionY, magnitude float64, +) (SolarEclipsePathPoint, bool) { + if magnitude == 0 { + _, _, sun := solver.besselGeometryAt(jd) + return solver.shadowFootprintPointAt(jd, moon, axis, sun, math.Atan2(directionY, directionX), solarEclipsePenumbralShadow) + } + radii := solver.shadowRadiiAt(moon[2]) + radius := solarEclipseMagnitudeContourRadius(radii, magnitude) + if magnitude > 1 && radius <= 0 { + // The initial Bessel plane can be on the antumbral side even though + // the Earth intersection has a valid totality contour. Start from + // the absolute umbral edge and let the surface iteration converge. + radius = radii.absUmbraRadius + } + if radius <= 0 { + return SolarEclipsePathPoint{}, false + } + var intersection solarEclipseLineIntersection + for iteration := 0; iteration < solarEclipsePartialFootprintIterationLimit; iteration++ { + x := moon[0] + radius*directionX + y := moon[1] + radius*directionY + intersection = solarEclipseLineEar2( + x, y, 2, + x, y, 0, + solarEclipseEarthPolarRatio, 1, axis, + ) + if !intersection.valid { + return SolarEclipsePathPoint{}, false + } + nextRadii := solver.shadowRadiiAt(moon[2] - intersection.r2) + nextRadius := solarEclipseMagnitudeContourRadius(nextRadii, magnitude) + if magnitude > 1 && nextRadius <= 0 { + nextRadius = nextRadii.absUmbraRadius + } + if nextRadius <= 0 { + return SolarEclipsePathPoint{}, false + } + if math.Abs(nextRadius-radius) <= solarEclipsePartialFootprintPointTolerance { + radius = nextRadius + break + } + radius = nextRadius + } + x := moon[0] + radius*directionX + y := moon[1] + radius*directionY + intersection = solarEclipseLineEar2( + x, y, 2, + x, y, 0, + solarEclipseEarthPolarRatio, 1, axis, + ) + if !intersection.valid { + return SolarEclipsePathPoint{}, false + } + longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) + sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) + return SolarEclipsePathPoint{ + JDE: jd, + Longitude: longitude, + Latitude: latitude, + SunAltitude: sunAltitudeRad / rad, + }, true +} + +func solarEclipseMagnitudeContourRadius(radii solarEclipseShadowRadii, magnitude float64) float64 { + if magnitude >= 1 && radii.magnitude > 1 { + // Above totality, interpolate from the umbral edge (m=1) to the + // Bessel-axis maximum (m=radii.magnitude). Using the penumbra-to- + // umbra slope here can turn a valid high-magnitude contour negative + // near hybrid and shallow total eclipses. + return radii.absUmbraRadius * (radii.magnitude - magnitude) / (radii.magnitude - 1) + } + return radii.penumbraRadius - magnitude*(radii.penumbraRadius-radii.absUmbraRadius) +} diff --git a/basic/solar_eclipse_noncentral_band.go b/basic/solar_eclipse_noncentral_band.go new file mode 100644 index 0000000..a2f3dc0 --- /dev/null +++ b/basic/solar_eclipse_noncentral_band.go @@ -0,0 +1,726 @@ +package basic + +import ( + "math" + + "b612.me/astro/internal/geodata" +) + +const ( + solarEclipseNonCentralBandTimeScale = 360.0 + solarEclipseNonCentralBandContainmentToleranceKM = solarEclipseCentralBandTargetSpacingKM / 2 + solarEclipseNonCentralBandDerivativeTolerance = 5e-7 +) + +type solarEclipseNonCentralBandState struct { + coordinates [3]float64 + tangent [3]float64 + point SolarEclipsePathPoint +} + +func solarEclipseNonCentralBandContainsFootprints( + segments [][]SolarEclipsePathPoint, + footprints []SolarEclipsePartialFootprint, +) bool { + return solarEclipseBandContainsFootprintsWithinKM( + segments, footprints, solarEclipseNonCentralBandContainmentToleranceKM, + ) +} + +// solarEclipseBandContainsFootprintsWithinKM is the tolerance-aware form used +// by the sampled central-band reconstruction, whose decimated rings are not an +// analytic envelope and may cut inside the sharpest grazing tips. +func solarEclipseBandContainsFootprintsWithinKM( + segments [][]SolarEclipsePathPoint, + footprints []SolarEclipsePartialFootprint, + toleranceKM float64, +) bool { + polygons, paths := solarEclipseBandFootprintGeometry(segments, footprints) + if len(polygons) == 0 || len(paths) == 0 { + return false + } + return geodata.SphericalPolygonsContainPathsWithinKM( + polygons, paths, false, toleranceKM, + ) +} + +// The sweep audit samples the footprint series instead of probing every vertex +// of every instantaneous footprint: the reconstruction residual is a systematic +// gap, not one stray vertex, and a full probe costs an order of magnitude more +// than building the band (1136-06-01: 109k probes, 1.3 s in Go). +const ( + solarEclipseCentralBandSweepProbePaths = 64 + solarEclipseCentralBandSweepProbePoints = 32 +) + +// solarEclipseBandContainsSampledFootprintsWithinKM validates a candidate +// against a bounded, evenly spread probe set of the reference footprints. +func solarEclipseBandContainsSampledFootprintsWithinKM( + segments [][]SolarEclipsePathPoint, + footprints []SolarEclipsePartialFootprint, + toleranceKM float64, +) bool { + polygons, _ := solarEclipseBandFootprintGeometry(segments, footprints) + if len(polygons) == 0 || len(footprints) == 0 { + return false + } + paths := make([][]geodata.GeoPoint, 0, solarEclipseCentralBandSweepProbePaths) + for _, index := range solarEclipseBandProbeIndices(len(footprints), solarEclipseCentralBandSweepProbePaths) { + for _, boundary := range footprints[index].Boundaries { + if len(boundary) < 3 { + continue + } + path := make([]geodata.GeoPoint, 0, solarEclipseCentralBandSweepProbePoints) + for _, pointIndex := range solarEclipseBandProbeIndices(len(boundary), solarEclipseCentralBandSweepProbePoints) { + point := boundary[pointIndex] + path = append(path, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + if len(path) >= 3 { + paths = append(paths, path) + } + } + } + if len(paths) == 0 { + return false + } + return geodata.SphericalPolygonsContainPathsWithinKM(polygons, paths, false, toleranceKM) +} + +// solarEclipseBandProbeIndices returns at most limit evenly spread indices over +// count items, always including the first and the last one. +func solarEclipseBandProbeIndices(count, limit int) []int { + if count <= 0 { + return nil + } + if limit < 2 { + limit = 2 + } + if count <= limit { + indices := make([]int, count) + for index := range indices { + indices[index] = index + } + return indices + } + indices := make([]int, 0, limit) + for index := 0; index < limit; index++ { + indices = append(indices, index*(count-1)/(limit-1)) + } + return indices +} + +// solarEclipseBandFootprintGeometry converts one band candidate and its +// reference footprints into the spherical form the audits share. +func solarEclipseBandFootprintGeometry( + segments [][]SolarEclipsePathPoint, + footprints []SolarEclipsePartialFootprint, +) ([][]geodata.GeoPoint, [][]geodata.GeoPoint) { + if len(segments) == 0 || len(footprints) == 0 { + return nil, nil + } + polygons := make([][]geodata.GeoPoint, 0, len(segments)) + for _, segment := range segments { + polygon := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, polygon) + } + paths := make([][]geodata.GeoPoint, 0, len(footprints)) + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + path := make([]geodata.GeoPoint, len(boundary)) + for index, point := range boundary { + path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + paths = append(paths, path) + } + } + return polygons, paths +} + +// nonCentralBandRegion returns one closed annular region for a non-central +// eclipse. The sampled critical envelope supplies the visible outer arc; the +// horizon arc supplies the degenerate side where the band reaches sunset or +// sunrise. +func (solver solarEclipseSolver) nonCentralBandRegion( + samples []solarEclipseCentralBandSweepSample, + riseSetCurves []SolarEclipseRiseSetCurve, + referenceJDE float64, +) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + boundary := solver.correctNonCentralBandEnvelopeSamples( + samples, referenceJDE, + ) + return solver.closeNonCentralBandBoundary(boundary, riseSetCurves) +} + +func (solver solarEclipseSolver) closeNonCentralBandBoundary( + boundary []SolarEclipsePathPoint, + riseSetCurves []SolarEclipseRiseSetCurve, +) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + horizon := solver.nonCentralBandHorizonSegment(riseSetCurves) + if len(horizon) < 3 { + return nil, nil + } + if len(boundary) < 2 { + return nil, nil + } + keep := solarEclipsePathDistanceKM(boundary[0], horizon[0]) + + solarEclipsePathDistanceKM(boundary[len(boundary)-1], horizon[len(horizon)-1]) + reverse := solarEclipsePathDistanceKM(boundary[0], horizon[len(horizon)-1]) + + solarEclipsePathDistanceKM(boundary[len(boundary)-1], horizon[0]) + if reverse < keep { + for left, right := 0, len(horizon)-1; left < right; left, right = left+1, right-1 { + horizon[left], horizon[right] = horizon[right], horizon[left] + } + } + polygon := append([]SolarEclipsePathPoint(nil), boundary...) + polygon = appendNonCentralBandInterpolatedSegment(polygon, boundary[len(boundary)-1], horizon[len(horizon)-1]) + for index := len(horizon) - 2; index >= 0; index-- { + polygon = append(polygon, horizon[index]) + } + polygon = appendNonCentralBandInterpolatedSegment(polygon, horizon[0], boundary[0]) + polygon = deduplicateSolarEclipsePathPoints(polygon) + if len(polygon) < 4 || solarEclipsePathDistanceKM(polygon[0], polygon[len(polygon)-1]) > 0.01 { + return nil, nil + } + polygon[len(polygon)-1] = polygon[0] + return polygon, horizon +} + +// alignNonCentralBandHorizon replaces the coarse public greatest-at-horizon +// samples with the exact horizon side used to close the non-central band. The +// shared vertices keep GeoJSON and SVG renderers from drawing a chord through +// the narrow band between otherwise identical roots. +func alignNonCentralBandHorizon( + curves []SolarEclipseRiseSetCurve, + horizon []SolarEclipsePathPoint, +) { + if len(horizon) < 2 { + return + } + start, end := horizon[0], horizon[len(horizon)-1] + for curveIndex := range curves { + curve := &curves[curveIndex] + if curve.Phase != RiseSetPhaseGreatest { + continue + } + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 || segment[0].JDE >= end.JDE || segment[len(segment)-1].JDE <= start.JDE { + continue + } + if !nonCentralBandHorizonMatchesSegment(segment, start, end) { + continue + } + joined := make([]SolarEclipsePathPoint, 0, len(segment)+len(horizon)) + for _, point := range segment { + if point.JDE < start.JDE-solarEclipseRiseSetTimeEpsilonDays { + joined = append(joined, point) + } + } + joined = append(joined, horizon...) + for _, point := range segment { + if point.JDE > end.JDE+solarEclipseRiseSetTimeEpsilonDays { + joined = append(joined, point) + } + } + curve.Segments[segmentIndex] = joined + // The exact horizon arc may fold in time at high latitude. It is + // attached after the normal rise/set topology pass, so normalize + // here as well to split that newly introduced fold into branches. + normalizeSolarEclipseRiseSetCurveSegments(curve) + return + } + } +} + +func nonCentralBandHorizonMatchesSegment( + segment []SolarEclipsePathPoint, + start, end SolarEclipsePathPoint, +) bool { + const maximumAttachmentDistanceKM = 100.0 + nearestDistance := func(target SolarEclipsePathPoint) float64 { + best := math.Inf(1) + for _, point := range segment { + if math.Abs(point.JDE-target.JDE) > 10.0/1440.0 { + continue + } + best = math.Min(best, solarEclipsePathDistanceKM(point, target)) + } + return best + } + return nearestDistance(start) <= maximumAttachmentDistanceKM && + nearestDistance(end) <= maximumAttachmentDistanceKM +} + +func (solver solarEclipseSolver) correctNonCentralBandEnvelopeSamples( + samples []solarEclipseCentralBandSweepSample, + referenceJDE float64, +) []SolarEclipsePathPoint { + points := make([]SolarEclipsePathPoint, 0, len(samples)) + for _, sample := range samples { + state, stateOK := solver.nonCentralBandStateAt(sample.envelope, referenceJDE) + if !stateOK { + continue + } + corrected, _, correctedOK := solver.correctNonCentralBandBoundary( + state.coordinates, state.tangent, referenceJDE, + ) + // A near-grazing Newton solve can converge to another critical branch. + // Keep the correction only when it remains close to this sample; + // otherwise validate and use the local predictor below. + if correctedOK && solarEclipsePathDistanceKM(corrected.point, sample.envelope) <= + 2*solarEclipseCentralBandTargetSpacingKM { + state = corrected + } else { + evaluation := solver.magnitudeEvaluationAt(sample.envelope.JDE) + approximate := evaluation.center.stateAt(sample.envelope.Longitude*rad, sample.envelope.Latitude*rad, 0) + if math.Abs(solarEclipseCentralContactGap(approximate)) > 1e-6 || + evaluation.centralContactSecondDerivative(sample.envelope.Longitude, sample.envelope.Latitude) <= 0 { + continue + } + state.point = SolarEclipsePathPoint{ + JDE: sample.envelope.JDE, Longitude: sample.envelope.Longitude, + Latitude: sample.envelope.Latitude, SunAltitude: approximate.sunAltitudeRad / rad, + } + } + if len(points) > 0 && solarEclipsePathDistanceKM(points[len(points)-1], state.point) > + 2*solarEclipseCentralBandTargetSpacingKM { + // Prefer the continuous predictor if a corrected branch jumped. + evaluation := solver.magnitudeEvaluationAt(sample.envelope.JDE) + approximate := evaluation.center.stateAt(sample.envelope.Longitude*rad, sample.envelope.Latitude*rad, 0) + if math.Abs(solarEclipseCentralContactGap(approximate)) > 1e-6 || + evaluation.centralContactSecondDerivative(sample.envelope.Longitude, sample.envelope.Latitude) <= 0 || + solarEclipsePathDistanceKM(points[len(points)-1], sample.envelope) > + 2*solarEclipseCentralBandTargetSpacingKM { + return nil + } + state.point = sample.envelope + } + points = append(points, state.point) + } + return deduplicateSolarEclipsePathPoints(points) +} + +func appendNonCentralBandInterpolatedSegment( + points []SolarEclipsePathPoint, + start, end SolarEclipsePathPoint, +) []SolarEclipsePathPoint { + distance := solarEclipsePathDistanceKM(start, end) + steps := int(math.Ceil(distance / solarEclipseCentralBandTargetSpacingKM)) + if steps < 1 { + steps = 1 + } + deltaLongitude := math.Remainder(end.Longitude-start.Longitude, 360) + for step := 1; step <= steps; step++ { + fraction := float64(step) / float64(steps) + points = append(points, SolarEclipsePathPoint{ + JDE: start.JDE + fraction*(end.JDE-start.JDE), + Longitude: normalizeLongitude(start.Longitude + fraction*deltaLongitude), + Latitude: start.Latitude + fraction*(end.Latitude-start.Latitude), + SunAltitude: start.SunAltitude + fraction*(end.SunAltitude-start.SunAltitude), + }) + } + return points +} + +func (solver solarEclipseSolver) nonCentralBandHorizonSegment( + curves []SolarEclipseRiseSetCurve, +) []SolarEclipsePathPoint { + var best []SolarEclipsePathPoint + for _, curve := range curves { + if curve.Phase != RiseSetPhaseGreatest { + continue + } + for _, segment := range curve.Segments { + segment = solver.correctNonCentralBandHorizonSamples(segment) + if len(segment) < 2 { + continue + } + var current []SolarEclipsePathPoint + for index := 1; index < len(segment); index++ { + first, second := segment[index-1], segment[index] + firstGap, firstOK := solver.nonCentralBandGapAt(first) + secondGap, secondOK := solver.nonCentralBandGapAt(second) + if !firstOK || !secondOK { + current = nil + continue + } + if len(current) == 0 { + switch { + case firstGap > 0 && secondGap <= 0: + junction, ok := solver.refineNonCentralBandHorizonGapCrossing(first, second) + if !ok { + continue + } + current = append(current, junction) + case firstGap <= 0: + current = append(current, first) + default: + continue + } + } + if secondGap <= 0 { + if solarEclipsePathDistanceKM(current[len(current)-1], second) > 0.001 { + current = append(current, second) + } + continue + } + if firstGap <= 0 { + junction, ok := solver.refineNonCentralBandHorizonGapCrossing(first, second) + if ok { + current = append(current, junction) + } + } + if solver.nonCentralBandHorizonCandidateValid(current) && len(current) > len(best) { + best = append([]SolarEclipsePathPoint(nil), current...) + } + current = nil + } + if solver.nonCentralBandHorizonCandidateValid(current) && len(current) > len(best) { + best = append([]SolarEclipsePathPoint(nil), current...) + } + } + } + if len(best) < 2 { + return nil + } + refined := make([]SolarEclipsePathPoint, 1, len(best)) + refined[0] = best[0] + for index := 1; index < len(best); index++ { + refined = solver.appendRefinedSolarEclipseCentralHorizonSegment( + refined, best[index-1], best[index], 0, + ) + } + return deduplicateSolarEclipsePathPoints(refined) +} + +func (solver solarEclipseSolver) correctNonCentralBandHorizonSamples( + segment []SolarEclipsePathPoint, +) []SolarEclipsePathPoint { + corrected := make([]SolarEclipsePathPoint, 0, len(segment)) + for _, point := range segment { + evaluation := solver.magnitudeEvaluationAt(point.JDE) + longitude, latitude, ok := riseSetRefineGeographicRoot( + point.Longitude, + point.Latitude, + func(lon, lat float64) (float64, float64, bool) { + state := evaluation.center.stateAt(lon*rad, lat*rad, 0) + phase := evaluation.separationDerivative(lon, lat) + return phase, state.sunAltitudeRad, finite(phase) && finite(state.sunAltitudeRad) + }, + ) + if !ok || evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + continue + } + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + corrected = append(corrected, SolarEclipsePathPoint{ + JDE: point.JDE, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + }) + } + return corrected +} + +func (solver solarEclipseSolver) nonCentralBandHorizonCandidateValid( + points []SolarEclipsePathPoint, +) bool { + if len(points) < 2 { + return false + } + firstGap, firstOK := solver.nonCentralBandGapAt(points[0]) + lastGap, lastOK := solver.nonCentralBandGapAt(points[len(points)-1]) + return firstOK && lastOK && math.Abs(firstGap) <= 1e-7 && math.Abs(lastGap) <= 1e-7 +} + +func (solver solarEclipseSolver) appendRefinedSolarEclipseCentralHorizonSegment( + points []SolarEclipsePathPoint, + start, end SolarEclipsePathPoint, + depth int, +) []SolarEclipsePathPoint { + if solarEclipsePathDistanceKM(start, end) <= solarEclipseCentralBandTargetSpacingKM || + depth >= 16 || end.JDE-start.JDE <= solarEclipsePathMinStepDays { + return append(points, end) + } + jd := (start.JDE + end.JDE) / 2 + longitude := normalizeLongitude( + start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2, + ) + latitude := (start.Latitude + end.Latitude) / 2 + evaluation := solver.magnitudeEvaluationAt(jd) + longitude, latitude, ok := riseSetRefineGeographicRoot( + longitude, + latitude, + func(lon, lat float64) (float64, float64, bool) { + state := evaluation.center.stateAt(lon*rad, lat*rad, 0) + phase := evaluation.separationDerivative(lon, lat) + return phase, state.sunAltitudeRad, finite(phase) && finite(state.sunAltitudeRad) + }, + ) + if !ok { + return append(points, end) + } + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + if solarEclipseCentralContactGap(state) > 1e-7 || + evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + return append(points, end) + } + middle := SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + } + points = solver.appendRefinedSolarEclipseCentralHorizonSegment(points, start, middle, depth+1) + return solver.appendRefinedSolarEclipseCentralHorizonSegment(points, middle, end, depth+1) +} + +func (solver solarEclipseSolver) nonCentralBandStateAt( + point SolarEclipsePathPoint, + referenceJDE float64, +) (solarEclipseNonCentralBandState, bool) { + coordinates := [3]float64{ + point.Longitude, + point.Latitude, + (point.JDE - referenceJDE) * solarEclipseNonCentralBandTimeScale, + } + _, jacobian, ok := solver.nonCentralBandBoundaryJacobian(coordinates, referenceJDE) + if !ok { + return solarEclipseNonCentralBandState{}, false + } + tangent, ok := solarEclipseMagnitudeArcTangent(jacobian) + return solarEclipseNonCentralBandState{coordinates: coordinates, tangent: tangent, point: point}, ok +} + +func (solver solarEclipseSolver) correctNonCentralBandBoundary( + predictor, tangent [3]float64, + referenceJDE float64, +) (solarEclipseNonCentralBandState, int, bool) { + coordinates := predictor + for iteration := 0; iteration < 16; iteration++ { + residual, jacobian, ok := solver.nonCentralBandBoundaryJacobian(coordinates, referenceJDE) + if !ok { + return solarEclipseNonCentralBandState{}, iteration, false + } + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if math.Abs(residual[0]) <= 1e-10 && + math.Abs(residual[1]) <= solarEclipseNonCentralBandDerivativeTolerance && + math.Abs(planeResidual) <= 1e-9 { + return solver.validNonCentralBandState(coordinates, jacobian, referenceJDE, iteration+1) + } + matrix := [3][3]float64{jacobian[0], jacobian[1], tangent} + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -planeResidual}) + if !ok { + return solarEclipseNonCentralBandState{}, iteration, false + } + norm := math.Sqrt(dotSolarEclipse3(delta, delta)) + if norm > 2 { + for index := range delta { + delta[index] *= 2 / norm + } + } + for index := range coordinates { + coordinates[index] += delta[index] + } + coordinates[0], coordinates[1] = normalizeSolarEclipseSphericalCoordinates(coordinates[0], coordinates[1]) + } + residual, jacobian, ok := solver.nonCentralBandBoundaryJacobian(coordinates, referenceJDE) + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if !ok || math.Abs(residual[0]) > 1e-7 || + math.Abs(residual[1]) > solarEclipseNonCentralBandDerivativeTolerance || + math.Abs(planeResidual) > 1e-7 { + return solarEclipseNonCentralBandState{}, 16, false + } + return solver.validNonCentralBandState(coordinates, jacobian, referenceJDE, 16) +} + +// normalizeSolarEclipseSphericalCoordinates keeps continuation coordinates on +// the sphere when a polar branch crosses a geographic pole. Reflecting the +// latitude and shifting longitude by 180 degrees preserves the same point and +// avoids the artificial singularity at +/-90 degrees. +func normalizeSolarEclipseSphericalCoordinates(longitude, latitude float64) (float64, float64) { + crossedPole := false + for latitude > 90 || latitude < -90 { + crossedPole = true + if latitude > 90 { + latitude = 180 - latitude + longitude += 180 + continue + } + latitude = -180 - latitude + longitude += 180 + } + if crossedPole { + longitude = normalizeLongitude(longitude) + } + return longitude, latitude +} + +func (solver solarEclipseSolver) validNonCentralBandState( + coordinates [3]float64, + jacobian [2][3]float64, + referenceJDE float64, + iterations int, +) (solarEclipseNonCentralBandState, int, bool) { + jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] + evaluation := solver.magnitudeEvaluationAt(jd) + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + if math.Abs(solarEclipseCentralContactGap(state)) > 1e-6 || + math.Abs(evaluation.centralContactDerivative(longitude, latitude)) > + solarEclipseNonCentralBandDerivativeTolerance || + evaluation.centralContactSecondDerivative(longitude, latitude) <= 0 { + return solarEclipseNonCentralBandState{}, iterations, false + } + tangent, ok := solarEclipseMagnitudeArcTangent(jacobian) + if !ok { + return solarEclipseNonCentralBandState{}, iterations, false + } + return solarEclipseNonCentralBandState{ + coordinates: coordinates, + tangent: tangent, + point: SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + }, + }, iterations, true +} + +func (solver solarEclipseSolver) nonCentralBandBoundaryJacobian( + coordinates [3]float64, + referenceJDE float64, +) ([2]float64, [2][3]float64, bool) { + jd := referenceJDE + coordinates[2]/solarEclipseNonCentralBandTimeScale + longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] + evaluation := solver.magnitudeEvaluationAt(jd) + residual, ok := solarEclipseNonCentralBandBoundaryResidualAt(evaluation, longitude, latitude) + if !ok { + return [2]float64{}, [2][3]float64{}, false + } + steps := [3]float64{1e-4, 1e-4, 5.0 * solarEclipseNonCentralBandTimeScale / 86400.0} + jacobian := [2][3]float64{} + for column, shifted := range [][2]float64{{longitude + steps[0], latitude}, {longitude, latitude + steps[1]}} { + shiftedResidual, shiftedOK := solarEclipseNonCentralBandBoundaryResidualAt( + evaluation, shifted[0], shifted[1], + ) + if !shiftedOK { + return [2]float64{}, [2][3]float64{}, false + } + for row := 0; row < 2; row++ { + jacobian[row][column] = (shiftedResidual[row] - residual[row]) / steps[column] + } + } + timeEvaluation := solver.magnitudeEvaluationAt(jd + steps[2]/solarEclipseNonCentralBandTimeScale) + timeResidual, timeOK := solarEclipseNonCentralBandBoundaryResidualAt( + timeEvaluation, longitude, latitude, + ) + if !timeOK { + return [2]float64{}, [2][3]float64{}, false + } + for row := 0; row < 2; row++ { + jacobian[row][2] = (timeResidual[row] - residual[row]) / steps[2] + } + return residual, jacobian, true +} + +func solarEclipseNonCentralBandBoundaryResidualAt( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude float64, +) ([2]float64, bool) { + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + residual := [2]float64{ + solarEclipseCentralContactGap(state), + evaluation.centralContactDerivative(longitude, latitude), + } + return residual, finite(residual[0]) && finite(residual[1]) +} + +func solarEclipseCentralContactGap(state localSolarEclipseState) float64 { + return state.movingDiskContactState().internalContactGap() +} + +func (evaluation solarEclipseRiseSetEvaluation) centralContactDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0) + after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0) + return (solarEclipseCentralContactGap(after) - solarEclipseCentralContactGap(before)) / + (2 * solarEclipseRiseSetDerivativeStepDays) +} + +func (evaluation solarEclipseRiseSetEvaluation) centralContactSecondDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0) + center := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0) + stepSquared := solarEclipseRiseSetDerivativeStepDays * solarEclipseRiseSetDerivativeStepDays + return (solarEclipseCentralContactGap(after) - 2*solarEclipseCentralContactGap(center) + + solarEclipseCentralContactGap(before)) / stepSquared +} + +func (solver solarEclipseSolver) refineNonCentralBandHorizonGapCrossing( + first, second SolarEclipsePathPoint, +) (SolarEclipsePathPoint, bool) { + firstGap, firstOK := solver.nonCentralBandGapAt(first) + secondGap, secondOK := solver.nonCentralBandGapAt(second) + if !firstOK || !secondOK || firstGap*secondGap > 0 { + return SolarEclipsePathPoint{}, false + } + if first.JDE > second.JDE { + first, second = second, first + firstGap, secondGap = secondGap, firstGap + } + best := first + bestGap := math.Abs(firstGap) + if math.Abs(secondGap) < bestGap { + best, bestGap = second, math.Abs(secondGap) + } + for iteration := 0; iteration < 64; iteration++ { + jd := (first.JDE + second.JDE) / 2 + fraction := (jd - first.JDE) / (second.JDE - first.JDE) + longitude := normalizeLongitude( + first.Longitude + fraction*math.Remainder(second.Longitude-first.Longitude, 360), + ) + latitude := first.Latitude + fraction*(second.Latitude-first.Latitude) + evaluation := solver.magnitudeEvaluationAt(jd) + longitude, latitude, ok := riseSetRefineGeographicRoot( + longitude, + latitude, + func(lon, lat float64) (float64, float64, bool) { + state := evaluation.center.stateAt(lon*rad, lat*rad, 0) + phase := evaluation.separationDerivative(lon, lat) + return phase, state.sunAltitudeRad, finite(phase) && finite(state.sunAltitudeRad) + }, + ) + if !ok { + return SolarEclipsePathPoint{}, false + } + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + middleGap := solarEclipseCentralContactGap(state) + middle := SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + } + if math.Abs(middleGap) < bestGap { + best, bestGap = middle, math.Abs(middleGap) + } + if bestGap <= 1e-10 || second.JDE-first.JDE <= 1e-10 { + break + } + if firstGap*middleGap <= 0 { + second, secondGap = middle, middleGap + } else { + first, firstGap = middle, middleGap + } + } + if bestGap > 1e-7 { + return SolarEclipsePathPoint{}, false + } + evaluation := solver.magnitudeEvaluationAt(best.JDE) + if math.Abs(evaluation.separationDerivative(best.Longitude, best.Latitude)) > 1e-8 || + math.Abs(best.SunAltitude) > 1e-5 || + evaluation.separationSecondDerivative(best.Longitude, best.Latitude) <= 0 { + return SolarEclipsePathPoint{}, false + } + return best, true +} + +func (solver solarEclipseSolver) nonCentralBandGapAt( + point SolarEclipsePathPoint, +) (float64, bool) { + evaluation := solver.magnitudeEvaluationAt(point.JDE) + state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + gap := solarEclipseCentralContactGap(state) + return gap, finite(gap) +} diff --git a/basic/solar_eclipse_path.go b/basic/solar_eclipse_path.go index bf0bcb0..82dcf5f 100644 --- a/basic/solar_eclipse_path.go +++ b/basic/solar_eclipse_path.go @@ -3,26 +3,85 @@ package basic import ( "math" "sort" + "time" ) const ( - solarEclipsePathDefaultStepDays = 1.0 / 1440.0 - solarEclipsePathMinStepDays = 1.0 / 86400.0 - solarEclipsePathMaxSampleCount = 30000 - solarEclipsePathMaxAdaptiveDepth = 20 - solarEclipsePathVelocityStepDays = 1.0 / 1440.0 - solarEclipsePathDuplicateTimeDays = 1e-10 + solarEclipsePathDefaultStepDays = 1.0 / 1440.0 + solarEclipsePathMinStepDays = 1.0 / 86400.0 + solarEclipsePathMaxSampleCount = 30000 + solarEclipsePathMaxAdaptiveDepth = 20 + solarEclipsePathVelocityStepDays = 1.0 / 1440.0 + // A sample may sit exactly on the horizon; only clearly below it is the + // central phase unobservable and its duration meaningless. + solarEclipsePathDurationHorizonToleranceDegrees = 0.1 + // 地球表面上的影锥足迹不可能比地球本身更宽。 + // No shadow footprint on the Earth can be wider than the Earth itself. + solarEclipsePathMaxPossibleWidthKM = 2 * 6371.0088 + solarEclipsePathDuplicateTimeDays = 1e-10 + // A short segment can still hide a large directional change at high + // latitude. Use a small relative sagitta tolerance so refinement follows + // geometry without forcing every path to the finest possible spacing. + solarEclipsePathAdaptiveCurvatureFraction = 0.02 + solarEclipsePathMinimumCurvatureKM = 2.0 solarEclipsePartialFootprintDefaultStepDays = 5.0 / 1440.0 + solarEclipseRiseSetDefaultStepDays = 2.0 / 1440.0 solarEclipsePartialFootprintDefaultBoundaryPoints = 180 solarEclipsePartialFootprintMinBoundaryPoints = 12 solarEclipsePartialFootprintMaxBoundaryPoints = 1440 solarEclipsePartialFootprintPointTolerance = 1e-12 solarEclipsePartialFootprintIterationLimit = 10 + solarEclipsePartialFootprintMaxPointCount = 2000000 + solarEclipsePartialFootprintTargetSpacingKM = 200.0 + solarEclipseMagnitudeContourMaxValues = 16 solarEclipsePartialFootprintTransitionIterations = 48 - solarEclipseShadowContactSearchStepDays = 10.0 / 1440.0 - solarEclipseShadowContactSearchSpanDays = 0.75 - solarEclipseShadowContactToleranceDays = 1e-9 + // An open footprint ends where the axis-parallel line through the shadow + // radius stops meeting the ellipsoid. The bracket is searched around the + // converged sampled radius, so a few doublings cover both the outside cut of + // an on-disc shadow and the inside cut of a shadow whose axis misses the + // Earth; the bisection below then lands on the tangency point, which sits on + // the horizon and makes the closing arc exact. + solarEclipseHorizonEndSearchStep = 1e-5 + solarEclipseHorizonEndSearchLimit = 0.25 + solarEclipseHorizonEndBisectionRounds = 40 + // The band footprints only have to resolve the swept envelope, not the + // instantaneous rim: consecutive samples may advance far as long as their + // union still follows the boundary. A grazing event spends most of its + // contact interval creeping across the terminator, so a 10 s stride there + // costs hundreds of solves for a few kilometres of travel. + solarEclipseCentralBandStepDays = 10.0 / 86400.0 + solarEclipseNonCentralBandFallbackStepDays = 60.0 / 86400.0 + solarEclipseNonCentralBandPreciseMaxDurationDays = 20.0 / 1440.0 + solarEclipseCentralBandContactFineStepDays = 1.0 / 86400.0 + solarEclipseCentralBandContactFineWindowDays = 45.0 / 86400.0 + solarEclipseCentralBandBoundaryPoints = 90 + // The union decimates every footprint ring to the union spacing before it + // merges them, and the result is relaxed afterwards, so solving the band + // footprints finer than this only buys trigonometry. + solarEclipseCentralBandTargetSpacingKM = 10.0 + solarEclipseNonCentralTotalBandTargetSpacingKM = 25.0 + solarEclipseCentralLimitTargetSpacingKM = 200.0 + solarEclipseShadowFootprintAdaptiveMaxDepth = 12 + solarEclipseShadowContactSearchStepDays = 10.0 / 1440.0 + solarEclipseShadowContactSearchSpanDays = 0.75 + solarEclipseShadowContactToleranceDays = 1e-9 + solarEclipseMagnitudeContourBoundaryPoints = 180 + solarEclipseMagnitudeContourTargetSpacingKM = 500.0 + solarEclipseMagnitudeContourArcStepDegrees = 4.0 + solarEclipseMagnitudeContourMinArcStepDegrees = 0.01 + solarEclipseMagnitudeContourMaxArcSteps = 2000 + solarEclipseMagnitudeContourTimeScale = 360.0 + solarEclipseMagnitudeContourFallbackBearings = 72 + solarEclipseMagnitudeContourFallbackDistances = 12 + solarEclipseRiseSetBoundaryPoints = 180 + solarEclipseRiseSetCriticalStepDays = 1.0 / 1440.0 + solarEclipseRiseSetDerivativeStepDays = 5.0 / 86400.0 + solarEclipseRiseSetTargetSpacingKM = 500.0 + solarEclipseRiseSetAttachmentDistanceToleranceKM = 5.0 + solarEclipseRiseSetAttachmentTimeToleranceDays = 2.0 / 86400.0 + solarEclipseRiseSetPhaseConnectionLimitKM = 3000.0 + solarEclipseRiseSetTimeEpsilonDays = 1e-8 ) type solarEclipseShadowKind uint8 @@ -41,6 +100,21 @@ type SolarEclipsePathOptions struct { // TargetSpacingKM 是相邻中心线点的最大目标地表距离;<=0 时不按距离加密。 // TargetSpacingKM is the target maximum ground spacing between centerline points; values <= 0 disable spacing refinement. TargetSpacingKM float64 + // DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型;与单时刻阴影层同口径,避免同一张图上 + // 路径几何与瞬时足迹各自使用不同的 ΔT(两者会沿经度错开 0.4651·|ΔΔT|·cosφ 千米)。 + // DeltaTSeconds is an explicit ΔT in seconds; values <= 0 use the process-wide + // model. The path and the instantaneous footprints of one figure must share it. + DeltaTSeconds float64 + // SkipCentralBand 表示调用方已经持有同一场日食的完整足迹结果(其中包含 + // CentralBandSegments),本次只求解中心线、南北限界与地平线端点,不重复重建中心食带。 + // 单场日食的中心带足迹是整条链路里最贵的一段,同时取足迹与路径时重复计算会翻倍。 + // SkipCentralBand reports that the caller already holds the full-footprint + // result for the same eclipse, whose CentralBandSegments are authoritative, + // so this call only solves the center line, the limits and the contact + // points instead of rebuilding the central band. The band footprints are the + // most expensive stage of the pipeline and would otherwise be computed twice + // by callers that ask for both products. + SkipCentralBand bool } // SolarEclipsePathPoint 表示日食路径上的一个地理点。 @@ -54,8 +128,8 @@ type SolarEclipsePathPoint struct { Latitude float64 // SunAltitude 太阳高度角,单位度, Sun altitude in degrees. SunAltitude float64 - // WidthKM 中心食带宽度,单位千米;仅中心线点有意义。 - // WidthKM is the central path width in kilometers; meaningful for centerline points. + // WidthKM 中心食带宽度,单位千米;仅中心线点有意义;退化地平线切点无法形成稳定成对横截面时为 0。 + // WidthKM is the central path width in kilometers; it is meaningful for centerline points and is 0 at a degenerate horizon contact without a stable paired cross-section. WidthKM float64 } @@ -66,12 +140,27 @@ type SolarEclipsePathResult struct { Eclipse SolarEclipseResult // Greatest 是食甚点/最佳观测点, greatest eclipse point. Greatest SolarEclipsePathPoint + // MaxCentralDurationDays 是中心线上最长的中心食时长(单位为日),并给出其发生位置。 + // 与目录(NASA)口径不同:目录值取食甚点,这里是整条中心线上的最大值。 + // MaxCentralDurationDays is the longest central phase on the center line, in + // days, with the location where it occurs. The catalogued "central duration" + // is the value at greatest eclipse; this is the maximum along the track. + MaxCentralDurationDays float64 + MaxCentralDurationLongitude float64 + MaxCentralDurationLatitude float64 // CenterLine 是中心线, central line. CenterLine []SolarEclipsePathPoint // NorthernLimit 是中心食带北界近似线, approximate northern limit of the central path. NorthernLimit []SolarEclipsePathPoint // SouthernLimit 是中心食带南界近似线, approximate southern limit of the central path. SouthernLimit []SolarEclipsePathPoint + // CentralBandSegments is the authoritative continuous central-band envelope. + CentralBandSegments [][]SolarEclipsePathPoint + // CentralBandSampled 表示上面的包络是用采样瞬时足迹重建的(解析包络不适用)。 + // CentralBandSampled reports that the envelope above was reconstructed from + // the sampled instantaneous footprints because no analytic envelope + // described this event. + CentralBandSampled bool // StepDays 是实际采用的基础时间采样步长,单位为日。 // StepDays is the effective base time step in days. StepDays float64 @@ -92,6 +181,34 @@ type SolarEclipsePartialFootprintOptions struct { // CentralShadowStepDays 是本影/反本影瞬时足迹的时间步长,单位为日;<=0 时不计算。 // CentralShadowStepDays is the umbral/antumbral footprint step in days; values <= 0 disable it. CentralShadowStepDays float64 + // RiseSetStepDays 独立采样地平阶段曲线;<=0 时使用 2 分钟。 + // RiseSetStepDays samples horizon curves independently; values <=0 use two minutes. + RiseSetStepDays float64 + // DisableRiseSetCurves 禁用六类日升日落阶段边界。 + // DisableRiseSetCurves disables the six sunrise/sunset boundaries. + DisableRiseSetCurves bool + // MagnitudeValues 是要计算的地方最大食分等值线;空值不计算,线条使用独立的自适应空间采样。 + // MagnitudeValues requests local maximum-magnitude contours; empty disables them, and contours use independent adaptive spatial sampling. + MagnitudeValues []float64 + // GreatestTimeValues 是要计算的地方食甚时刻等值线取值(TT 儒略日,最多 64 条,超出按时间截断); + // 只有确实存在该时刻食甚轨迹的取值才会出现在结果里,所以返回条数可能少于请求条数。 + // 空值时改用 GreatestTimeStep。每条等时线用固定时刻的残差零集延拓,成本正比于曲线长度而不是可见域面积。 + // GreatestTimeValues requests local greatest-eclipse time isolines as TT Julian ephemeris days + // (at most 64, truncated in time order); when empty, GreatestTimeStep is used instead. Each + // isochrone is continued along the zero set of a fixed-instant residual, so the cost scales + // with curve length rather than with the visible area. + GreatestTimeValues []float64 + // GreatestTimeStep 是等时线间隔;仅在 GreatestTimeValues 为空时生效,非正值不计算等时线。 + // 取值对齐到 UTC 整刻度并覆盖地球范围的偏食窗口,最多 64 条;显示层若需要按展示时区对齐, + // 应自行生成时刻后改用 GreatestTimeValues。 + // GreatestTimeStep is the isochrone interval; it applies only when GreatestTimeValues is empty, + // and non-positive values disable the isolines. Levels align to UTC ticks across the global + // partial-eclipse window, at most 64; display layers that need the viewing timezone grid should + // generate the instants themselves and pass GreatestTimeValues instead. + GreatestTimeStep time.Duration + // DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型;与单时刻阴影层同口径。 + // DeltaTSeconds is an explicit ΔT in seconds; values <= 0 use the process-wide model. + DeltaTSeconds float64 } // SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。 @@ -109,6 +226,17 @@ type SolarEclipsePartialFootprint struct { // Closed 表示 Boundaries 是否构成一个闭合边界。 // Closed indicates whether Boundaries form one closed boundary. Closed bool + // HorizonEnds 是未闭合边界两端延伸到地平圈的擦地点,顺序与 Boundaries 的走向一致 + // (HorizonEnds[0] 贴近边界起点,HorizonEnds[1] 贴近边界终点);边界自身闭合时为空。 + // 被地平线切断的瞬时阴影区域由「物理边界 + 两个擦地点之间的地平弧」闭合;擦地点是 + // 阴影锥面与地表的切点,其太阳高度为 0,所以该闭合弧是精确结果而不是启发式。 + // HorizonEnds are the two limb-grazing points where an open boundary reaches the + // horizon, ordered like Boundaries (HorizonEnds[0] near the boundary start, + // HorizonEnds[1] near its end); empty when the boundary closes on itself. A region + // cut by the horizon is closed by the physical boundary plus the horizon arc + // between these grazing points, which are exact cone-surface tangency points with + // zero solar altitude rather than a heuristic. + HorizonEnds []SolarEclipsePathPoint } // SolarEclipsePartialFootprintsResult 表示一次日食的偏食半影足迹序列。 @@ -121,6 +249,37 @@ type SolarEclipsePartialFootprintsResult struct { // CentralShadowFootprints 是按时间采样的本影/反本影足迹。 // CentralShadowFootprints are sampled umbral/antumbral footprints. CentralShadowFootprints []SolarEclipsePartialFootprint + // CentralBandFootprints 是始终计算的低成本本影/反本影端部样本,用于闭合中心食带。 + // CentralBandFootprints are always-computed lightweight umbral/antumbral end samples used to close the central band. + CentralBandFootprints []SolarEclipsePartialFootprint + // CentralBandSegments 是地方中心食条件的连续闭合包络。 + // CentralBandSegments are continuous closed envelopes of the local central-eclipse condition. + CentralBandSegments [][]SolarEclipsePathPoint + // CentralBandSampled 表示上面的包络是用采样瞬时足迹重建的(解析包络不适用)。 + // CentralBandSampled reports that CentralBandSegments were reconstructed from + // the sampled instantaneous footprints because no analytic envelope applied. + CentralBandSampled bool + // CentralBandHorizonClosures 是两限界掠地事件中分别连接首尾两侧限界的食甚地平线弧。 + // CentralBandHorizonClosures are the greatest-at-horizon arcs joining both ends of a grazing two-limit event. + CentralBandHorizonClosures [][]SolarEclipsePathPoint + // PartialBandContours 是地方最大食分等于零的连续可见包络,用于与日出日落边界闭合偏食可见域。 + // PartialBandContours are the continuous zero local-maximum-magnitude envelopes used with horizon boundaries to close the partial-eclipse visibility region. + PartialBandContours [][]SolarEclipsePathPoint + // MagnitudeContours 是按食分值采样的两侧等值线。 + // MagnitudeContours are sampled two-sided local maximum-magnitude contours. + MagnitudeContours []SolarEclipseMagnitudeContour + // GreatestTimeContours 是按食甚时刻采样的等时线。 + // GreatestTimeContours are sampled local greatest-eclipse time isolines. + GreatestTimeContours []SolarEclipseGreatestTimeContour + // RiseSetCurves 是初亏、食甚和复圆分别发生在日出或日落时的六类边界。 + // RiseSetCurves are the six boundaries where local start, greatest, or end occurs at sunrise or sunset. + RiseSetCurves []SolarEclipseRiseSetCurve + // RiseSetTopologyDegraded 表示请求了升落曲线但六类边界未通过拓扑校验(每曲线 16 段、 + // 总计 32 段的段数上限,端点共享与时间单调),RiseSetCurves 是被截断的降级结果。 + // RiseSetTopologyDegraded reports that the requested rise/set curves failed the + // topology audit (the 16-segment per-curve and 32-segment total caps, shared + // endpoints and monotonic times), so RiseSetCurves is a truncated result. + RiseSetTopologyDegraded bool // P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。 // P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero. P1 SolarEclipsePathPoint @@ -142,6 +301,49 @@ type SolarEclipsePartialFootprintsResult struct { // CentralShadowStepDays 是本影/反本影足迹的实际采样步长;0 表示未计算。 // CentralShadowStepDays is the effective umbral/antumbral footprint step; zero means disabled. CentralShadowStepDays float64 + // CentralBandStepDays 是中心食带足迹的最细实际采样步长。 + // CentralBandStepDays is the finest effective sampling step for central-band footprints. + CentralBandStepDays float64 +} + +// SolarEclipseGreatestTimeContour 是一个固定地方食甚时刻的等值线支路集合。 +// SolarEclipseGreatestTimeContour contains the continuous branches of one fixed local greatest-eclipse time. +type SolarEclipseGreatestTimeContour struct { + // JDE 是该等值线表示的力学时儒略日,也就是各支路上地方食甚发生的时刻。 + // JDE is the TT Julian ephemeris day represented by this contour, the local greatest-eclipse instant along every branch. + JDE float64 + // Segments 是该时刻的连续等时线支路;一条支路两端止于地平线或偏食可见域边界。 + // Segments are continuous isochrone branches; each branch ends at the horizon or the partial-visibility boundary. + Segments [][]SolarEclipsePathPoint +} + +// SolarEclipseMagnitudeContour 是一条地方最大食分等值线的连续支路集合。 +// SolarEclipseMagnitudeContour contains the continuous branches of one local maximum-magnitude contour. +type SolarEclipseMagnitudeContour struct { + // Magnitude 是该等值线表示的地方最大食分。 + // Magnitude is the local maximum eclipse magnitude represented by this contour. + Magnitude float64 + // Segments 是地方最大食分等值线的连续支路;临近地平线时局部食甚时刻可以沿空间支路折返。 + // Segments are continuous local maximum-magnitude branches; local greatest times may fold along a spatial branch near the horizon. + Segments [][]SolarEclipsePathPoint + // NorthernLimit 和 SouthernLimit 保留两侧中心食等值线的兼容视图。 + // NorthernLimit and SouthernLimit retain the compatibility view for two-sided central-eclipse contours. + NorthernLimit []SolarEclipsePathPoint + SouthernLimit []SolarEclipsePathPoint +} + +// SolarEclipseRiseSetCurve 是一种局部阶段与日出/日落同时发生的边界。 +// SolarEclipseRiseSetCurve is one boundary where a local phase coincides with sunrise or sunset. +type SolarEclipseRiseSetCurve struct { + // Phase 是与日出或日落同时发生的局部日食阶段。 + // Phase is the local eclipse phase coinciding with sunrise or sunset. + Phase RiseSetPhase + // Direction 标识太阳正在升起还是落下。 + // Direction identifies whether the Sun is rising or setting. + Direction RiseSetDirection + // Segments 是反经线和支路跳变安全分段后的边界采样。 + // Segments are boundary samples split safely at the antimeridian and branch changes. + Segments [][]SolarEclipsePathPoint } // SolarEclipsePartialAreaResult 是 SolarEclipsePartialFootprintsResult 的兼容别名。 @@ -204,7 +406,9 @@ func SolarEclipsePartialAreaNASABulletinSplitK(seedJDE float64, options SolarEcl func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, options SolarEclipsePathOptions) SolarEclipsePathResult { options = normalizeSolarEclipsePathOptions(options) - result := solarEclipse(seedJDE, model) + newMoonJDE := CalcMoonSHByJDE(seedJDE, 0) + solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(options.DeltaTSeconds) + result := solver.eclipseResult() path := SolarEclipsePathResult{ Eclipse: result, StepDays: options.StepDays, @@ -214,8 +418,6 @@ func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, opt return path } - newMoonJDE := CalcMoonSHByJDE(seedJDE, 0) - solver := newSolarEclipseSolver(newMoonJDE, model) greatest, ok := solver.centralPathPointAt(result.GreatestEclipse) if !ok { greatest = SolarEclipsePathPoint{ @@ -236,18 +438,105 @@ func solarEclipseCentralPath(seedJDE float64, model SolarEclipseRadiusModel, opt greatest.WidthKM = result.PathWidthKM path.Greatest = greatest + samplingOptions := options + samplingOptions.TargetSpacingKM = 0 centerLine, stepDays := solver.centralPathPoints( result.CentralBeginOnEarth, result.CentralEndOnEarth, result.GreatestEclipse, - options, + samplingOptions, ) + centerLine = solver.attachCentralAxisContactPoints( + centerLine, result.CentralBeginOnEarth, result.CentralEndOnEarth, + ) + if result.Type == SolarEclipseHybrid { + centerLine = solver.attachCentralMagnitudeOneTransitions(centerLine) + } + if options.TargetSpacingKM > 0 { + centerLine = solver.refineCentralPathSpacing(centerLine, options.TargetSpacingKM) + } path.StepDays = stepDays + // 每对横截面只解一次:限界线与宽度都取自同一份配对结果,重复求解是这条链上最大的 + // 单点分配与耗时来源。 + northernLimit, southernLimit, pairedLimits := solver.centralPathLimitPairs(centerLine) + path.NorthernLimit, path.SouthernLimit = solarEclipseFilterCentralPathLimits( + northernLimit, southernLimit, pairedLimits, + ) + // The paired-limit width runs first, then the contact pass restores the exact + // U1/U4 samples, whose cross-section is solved from the contact pair rather + // than from the interior samples. + solver.setCenterLinePairWidths( + centerLine, result.Centrality, northernLimit, southernLimit, pairedLimits, + ) + if result.Centrality == SolarEclipseCentralTwoLimits { + // A one-limit event has no paired cross-section at all, so every sample + // keeps the 0 that the catalogues also print for it. + setSolarEclipseCentralContactWidths(centerLine, path.NorthernLimit, path.SouthernLimit) + } path.CenterLine = centerLine - path.NorthernLimit, path.SouthernLimit = solver.centralPathLimits(centerLine) + path.MaxCentralDurationDays, path.MaxCentralDurationLongitude, path.MaxCentralDurationLatitude = + solver.centralPathMaxCentralDuration(centerLine) + // A two-limit central eclipse starts and ends at the external umbral + // contacts, where the northern and southern limits meet. The center line + // begins later because its Earth-axis intersection is undefined before the + // axis reaches the surface. Add those exact contact points so consumers do + // not close the band across the first/last coarse center-line samples. + if result.Type != SolarEclipseHybrid && + result.Centrality == SolarEclipseCentralTwoLimits && len(path.NorthernLimit) > 0 { + first, last, contactsOK := solver.shadowContactPair( + result.GreatestEclipse, solarEclipseCentralShadow, false, + ) + if contactsOK { + // The open footprint endpoints before the axis reaches the Earth are + // horizon branches, not the two side limits. Appending them here + // makes one side turn back and self-intersect near U1/U4. The exact + // contact points and the first/last cross-sections form the required + // short end caps without introducing that false branch. + path.NorthernLimit = append([]SolarEclipsePathPoint{first}, path.NorthernLimit...) + path.SouthernLimit = append([]SolarEclipsePathPoint{first}, path.SouthernLimit...) + path.NorthernLimit = append(path.NorthernLimit, last) + path.SouthernLimit = append(path.SouthernLimit, last) + } + } + path.NorthernLimit, path.SouthernLimit = solver.refineCentralPathLimitSpacing( + path.NorthernLimit, + path.SouthernLimit, + result.CentralBeginOnEarth, + result.CentralEndOnEarth, + solarEclipseCentralLimitTargetSpacingKM, + ) + if options.SkipCentralBand { + return path + } + // Generate the same continuous envelope exposed by the full-footprint API + // so path-only callers cannot reconstruct a different polar ribbon. + band, _ := solver.centralBandWithResult(result, normalizeSolarEclipsePartialFootprintOptions(SolarEclipsePartialFootprintOptions{ + StepDays: options.StepDays, BoundaryPoints: 96, DisableRiseSetCurves: true, + })) + path.CentralBandSegments = band.CentralBandSegments + path.CentralBandSampled = band.CentralBandSampled return path } +func (solver solarEclipseSolver) attachCentralMagnitudeOneTransitions( + points []SolarEclipsePathPoint, +) []SolarEclipsePathPoint { + for _, transition := range solver.centralMagnitudeOneTransitions(points) { + duplicate := false + for _, point := range points { + if math.Abs(point.JDE-transition.JDE) <= solarEclipsePathDuplicateTimeDays { + duplicate = true + break + } + } + if !duplicate { + points = append(points, transition) + } + } + sort.Slice(points, func(first, second int) bool { return points[first].JDE < points[second].JDE }) + return normalizeSolarEclipsePathPointSeries(points) +} + func normalizeSolarEclipsePathOptions(options SolarEclipsePathOptions) SolarEclipsePathOptions { if options.StepDays <= 0 || math.IsNaN(options.StepDays) || math.IsInf(options.StepDays, 0) { options.StepDays = solarEclipsePathDefaultStepDays @@ -265,9 +554,19 @@ func solarEclipsePartialFootprints( seedJDE float64, model SolarEclipseRadiusModel, options SolarEclipsePartialFootprintOptions, +) SolarEclipsePartialFootprintsResult { + return solarEclipsePartialFootprintsWithResult( + seedJDE, model, options, solarEclipseWithDeltaT(seedJDE, model, options.DeltaTSeconds), + ) +} + +func solarEclipsePartialFootprintsWithResult( + seedJDE float64, + model SolarEclipseRadiusModel, + options SolarEclipsePartialFootprintOptions, + result SolarEclipseResult, ) SolarEclipsePartialFootprintsResult { options = normalizeSolarEclipsePartialFootprintOptions(options) - result := solarEclipse(seedJDE, model) footprintsResult := SolarEclipsePartialFootprintsResult{ Eclipse: result, StepDays: options.StepDays, @@ -279,22 +578,49 @@ func solarEclipsePartialFootprints( } newMoonJDE := CalcMoonSHByJDE(seedJDE, 0) - solver := newSolarEclipseSolver(newMoonJDE, model) - footprintsResult.P1, footprintsResult.P4, _ = solver.shadowContactPair( - result.GreatestEclipse, solarEclipsePenumbralShadow, false, + solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(options.DeltaTSeconds) + footprintsResult, totalMagnitudeOneSegments := solver.centralBandWithResult(result, options) + // Partial and central-shadow sweeps used to enforce the point budget + // independently. A high-resolution request could therefore allocate + // nearly two budgets for each sweep before the caller saw the result. + // Reserve the already-built central-band samples and share the remaining + // budget between the main time series before either sweep is generated. + partialTimes, _ := solarEclipseMovingDiskEngine().sampleTimes( + result.PartialBeginOnEarth, + result.PartialEndOnEarth, + result.GreatestEclipse, + options.StepDays, ) - footprintsResult.P2, footprintsResult.P3, _ = solver.shadowContactPair( - result.GreatestEclipse, solarEclipsePenumbralShadow, true, - ) - if result.Type != SolarEclipsePartial { - footprintsResult.U1, footprintsResult.U4, _ = solver.shadowContactPair( - result.GreatestEclipse, solarEclipseCentralShadow, false, - ) - footprintsResult.U2, footprintsResult.U3, _ = solver.shadowContactPair( - result.GreatestEclipse, solarEclipseCentralShadow, true, + var shadowTimes []float64 + if options.CentralShadowStepDays > 0 && + footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 { + shadowTimes, _ = solarEclipseMovingDiskEngine().sampleTimes( + footprintsResult.U1.JDE, + footprintsResult.U4.JDE, + result.GreatestEclipse, + options.CentralShadowStepDays, ) } - footprints, stepDays := solver.partialFootprints( + options.BoundaryPoints = solarEclipseSharedBoundaryPoints( + options.BoundaryPoints, + len(partialTimes), + len(shadowTimes), + solarEclipseFootprintPointCount(footprintsResult.CentralBandFootprints), + ) + footprintsResult.BoundaryPoints = options.BoundaryPoints + if !options.DisableRiseSetCurves { + footprintsResult.PartialBandContours = solver.magnitudeContourSegments( + result.PartialBeginOnEarth, + result.PartialEndOnEarth, + result.CentralBeginOnEarth, + result.CentralEndOnEarth, + result.GreatestEclipse, + 0, + options.StepDays, + false, + ) + } + footprints, stepDays, effectiveBoundaryPoints := solver.partialFootprints( result.PartialBeginOnEarth, result.PartialEndOnEarth, result.GreatestEclipse, @@ -302,9 +628,13 @@ func solarEclipsePartialFootprints( ) footprintsResult.StepDays = stepDays footprintsResult.Footprints = footprints + if effectiveBoundaryPoints > footprintsResult.BoundaryPoints { + footprintsResult.BoundaryPoints = effectiveBoundaryPoints + } if options.CentralShadowStepDays > 0 && footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 { - footprintsResult.CentralShadowFootprints, footprintsResult.CentralShadowStepDays = solver.shadowFootprints( + var effectiveShadowBoundaryPoints int + footprintsResult.CentralShadowFootprints, footprintsResult.CentralShadowStepDays, effectiveShadowBoundaryPoints = solver.shadowFootprints( footprintsResult.U1.JDE, footprintsResult.U4.JDE, result.GreatestEclipse, @@ -312,670 +642,835 @@ func solarEclipsePartialFootprints( options.BoundaryPoints, solarEclipseCentralShadow, ) + if effectiveShadowBoundaryPoints > footprintsResult.BoundaryPoints { + footprintsResult.BoundaryPoints = effectiveShadowBoundaryPoints + } } + footprintsResult.MagnitudeContours = solver.magnitudeContours( + result.PartialBeginOnEarth, + result.PartialEndOnEarth, + result.CentralBeginOnEarth, + result.CentralEndOnEarth, + result.GreatestEclipse, + options, + result.Magnitude, + result.Type == SolarEclipseHybrid, + totalMagnitudeOneSegments, + ) + greatestTimeOptions := options + if len(greatestTimeOptions.GreatestTimeValues) == 0 && greatestTimeOptions.GreatestTimeStep > 0 { + levels := greatestTimeContourAlignedLevels( + result.PartialBeginOnEarth, result.PartialEndOnEarth, + greatestTimeOptions.GreatestTimeStep, greatestTimeContourMaxLevels, + ) + greatestTimeOptions.GreatestTimeValues = make([]float64, 0, len(levels)) + for _, level := range levels { + greatestTimeOptions.GreatestTimeValues = append(greatestTimeOptions.GreatestTimeValues, level.tt) + } + } + footprintsResult.GreatestTimeContours = solver.greatestTimeContours( + result.PartialBeginOnEarth, + result.PartialEndOnEarth, + greatestTimeOptions, + ) return footprintsResult } -func normalizeSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootprintOptions) SolarEclipsePartialFootprintOptions { - if options.StepDays <= 0 || math.IsNaN(options.StepDays) || math.IsInf(options.StepDays, 0) { - options.StepDays = solarEclipsePartialFootprintDefaultStepDays +func (solver solarEclipseSolver) centralBandWithResult( + result SolarEclipseResult, + options SolarEclipsePartialFootprintOptions, +) (SolarEclipsePartialFootprintsResult, [][]SolarEclipsePathPoint) { + footprintsResult := SolarEclipsePartialFootprintsResult{ + Eclipse: result, + StepDays: options.StepDays, + BoundaryPoints: options.BoundaryPoints, + CentralShadowStepDays: options.CentralShadowStepDays, } - if options.StepDays < solarEclipsePathMinStepDays { - options.StepDays = solarEclipsePathMinStepDays - } - if options.BoundaryPoints <= 0 { - options.BoundaryPoints = solarEclipsePartialFootprintDefaultBoundaryPoints - } - if options.BoundaryPoints < solarEclipsePartialFootprintMinBoundaryPoints { - options.BoundaryPoints = solarEclipsePartialFootprintMinBoundaryPoints - } - if options.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints { - options.BoundaryPoints = solarEclipsePartialFootprintMaxBoundaryPoints - } - if options.CentralShadowStepDays <= 0 || math.IsNaN(options.CentralShadowStepDays) || math.IsInf(options.CentralShadowStepDays, 0) { - options.CentralShadowStepDays = 0 - } else if options.CentralShadowStepDays < solarEclipsePathMinStepDays { - options.CentralShadowStepDays = solarEclipsePathMinStepDays - } - return options -} - -func (solver solarEclipseSolver) centralPathPoints( - startJDE, endJDE, greatestJDE float64, - options SolarEclipsePathOptions, -) ([]SolarEclipsePathPoint, float64) { - if endJDE < startJDE { - startJDE, endJDE = endJDE, startJDE - } - if startJDE == 0 || endJDE == 0 || endJDE <= startJDE { - return nil, options.StepDays + if !result.HasPartial { + return footprintsResult, nil } - stepDays := options.StepDays - if sampleCount := int(math.Ceil((endJDE-startJDE)/stepDays)) + 1; sampleCount > solarEclipsePathMaxSampleCount { - stepDays = (endJDE - startJDE) / float64(solarEclipsePathMaxSampleCount-1) - } - - times := []float64{startJDE, greatestJDE, endJDE} - for jd := startJDE + stepDays; jd < endJDE; jd += stepDays { - times = append(times, jd) - } - sort.Float64s(times) - times = uniqueSolarEclipsePathTimes(times) - - points := make([]SolarEclipsePathPoint, 0, len(times)) - for _, jd := range times { - point, ok := solver.centralPathPointAt(jd) - if ok { - points = append(points, point) + var totalMagnitudeOneSegments [][]SolarEclipsePathPoint + var nonCentralRiseSetCurves []SolarEclipseRiseSetCurve + nonCentralRiseSetComplete := true + if solver.attachCentralBandContacts(&footprintsResult, result) { + solver.attachCentralBandFootprints(&footprintsResult, result) + if result.Centrality == SolarEclipseNonCentral { + totalMagnitudeOneSegments, nonCentralRiseSetCurves, nonCentralRiseSetComplete = + solver.nonCentralCentralBandEnvelope(result, options, &footprintsResult) } } - if options.TargetSpacingKM > 0 { - points = solver.refineCentralPathSpacing(points, options.TargetSpacingKM) + if result.Type == SolarEclipseTotal && result.Centrality == SolarEclipseCentralTwoLimits { + totalMagnitudeOneSegments = solver.magnitudeContourSegmentsWithSpacing( + result.PartialBeginOnEarth, result.PartialEndOnEarth, + result.CentralBeginOnEarth, result.CentralEndOnEarth, + result.GreatestEclipse, 1, options.StepDays, false, + solarEclipseTotalEnvelopeTargetSpacingKM, + ) } - return points, stepDays + // The rise/set curves are a presentation option, but the precise central + // envelope still needs their roots to close grazing annular/total paths. + // Compute the small internal set unconditionally and only suppress it from + // the returned result when requested. + riseSetCurves, riseSetComplete := solver.centralBandRiseSetCurves( + result, options, nonCentralRiseSetCurves, nonCentralRiseSetComplete, + ) + if !options.DisableRiseSetCurves { + footprintsResult.RiseSetCurves = riseSetCurves + // 请求了升落曲线但拓扑校验未通过:返回的是被截断的降级结果,必须显式标记。 + footprintsResult.RiseSetTopologyDegraded = len(riseSetCurves) > 0 && !riseSetComplete + } + solver.attachCentralBandClosureEnvelope( + result, &footprintsResult, riseSetCurves, totalMagnitudeOneSegments, + ) + solver.attachCentralBandFallbackSweep(result, &footprintsResult, riseSetCurves) + return footprintsResult, totalMagnitudeOneSegments } -func (solver solarEclipseSolver) partialFootprints( - startJDE, endJDE, greatestJDE float64, - options SolarEclipsePartialFootprintOptions, -) ([]SolarEclipsePartialFootprint, float64) { - return solver.shadowFootprints( - startJDE, - endJDE, - greatestJDE, - options.StepDays, - options.BoundaryPoints, - solarEclipsePenumbralShadow, +// attachCentralBandContacts 求半影与本影/反本影的外切/内切接触点,并报告本影接触对能否 +// 支撑中心带采样:本影不存在或退化的抛物接触会让整个中心带支路没有意义。 +func (solver solarEclipseSolver) attachCentralBandContacts( + footprintsResult *SolarEclipsePartialFootprintsResult, + result SolarEclipseResult, +) bool { + contactSolver := solver + contactSolver.exactCentralContact = result.Centrality == SolarEclipseNonCentral + footprintsResult.P1, footprintsResult.P4, _ = contactSolver.shadowContactPair( + result.GreatestEclipse, solarEclipsePenumbralShadow, false, + ) + footprintsResult.P2, footprintsResult.P3, _ = contactSolver.shadowContactPair( + result.GreatestEclipse, solarEclipsePenumbralShadow, true, + ) + if result.Type != SolarEclipsePartial { + footprintsResult.U1, footprintsResult.U4, _ = contactSolver.shadowContactPair( + result.GreatestEclipse, solarEclipseCentralShadow, false, + ) + footprintsResult.U2, footprintsResult.U3, _ = contactSolver.shadowContactPair( + result.GreatestEclipse, solarEclipseCentralShadow, true, + ) + return footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 + } + return false +} + +// attachCentralBandFootprints 采样本影/反本影端帽足迹,非中心食的时长决定用哪一档步长。 +func (solver solarEclipseSolver) attachCentralBandFootprints( + footprintsResult *SolarEclipsePartialFootprintsResult, + result SolarEclipseResult, +) { + bandStepDays := solarEclipseCentralBandStepDays + if result.Centrality == SolarEclipseNonCentral { + centralDuration := footprintsResult.U4.JDE - footprintsResult.U1.JDE + if centralDuration > solarEclipseNonCentralBandPreciseMaxDurationDays { + bandStepDays = solarEclipseNonCentralBandFallbackStepDays + } + } + footprintsResult.CentralBandFootprints, footprintsResult.CentralBandStepDays = solver.centralBandFootprintsWithStep( + footprintsResult.U1.JDE, + footprintsResult.U2.JDE, + footprintsResult.U3.JDE, + footprintsResult.U4.JDE, + result.GreatestEclipse, + bandStepDays, ) } -func (solver solarEclipseSolver) shadowFootprints( - startJDE, endJDE, greatestJDE, requestedStepDays float64, - boundaryPoints int, - kind solarEclipseShadowKind, -) ([]SolarEclipsePartialFootprint, float64) { - if endJDE < startJDE { - startJDE, endJDE = endJDE, startJDE - } - if startJDE == 0 || endJDE == 0 || endJDE <= startJDE { - return nil, requestedStepDays - } - - stepDays := requestedStepDays - if sampleCount := int(math.Ceil((endJDE-startJDE)/stepDays)) + 1; sampleCount > solarEclipsePathMaxSampleCount { - stepDays = (endJDE - startJDE) / float64(solarEclipsePathMaxSampleCount-1) - } - - times := []float64{startJDE, greatestJDE, endJDE} - for jd := startJDE + stepDays; jd < endJDE; jd += stepDays { - times = append(times, jd) - } - sort.Float64s(times) - times = uniqueSolarEclipsePathTimes(times) - - footprints := make([]SolarEclipsePartialFootprint, 0, len(times)) - for _, jd := range times { - footprint := solver.shadowFootprintAt(jd, boundaryPoints, kind) - if len(footprint.Boundaries) > 0 { - footprints = append(footprints, footprint) +// nonCentralCentralBandEnvelope 求解轴不入地的中心食带:先试解析包络,覆盖不足时退回采样 +// 瞬时足迹并集;同时返回该支路使用的升落曲线与 magnitude-one 等值线。 +func (solver solarEclipseSolver) nonCentralCentralBandEnvelope( + result SolarEclipseResult, + options SolarEclipsePartialFootprintOptions, + footprintsResult *SolarEclipsePartialFootprintsResult, +) ([][]SolarEclipsePathPoint, []SolarEclipseRiseSetCurve, bool) { + var totalMagnitudeOneSegments [][]SolarEclipsePathPoint + var nonCentralRiseSetCurves []SolarEclipseRiseSetCurve + nonCentralRiseSetComplete := true + if result.Centrality == SolarEclipseNonCentral { + riseSetStepDays := options.RiseSetStepDays + if riseSetStepDays <= 0 || math.IsNaN(riseSetStepDays) || math.IsInf(riseSetStepDays, 0) { + riseSetStepDays = solarEclipseRiseSetDefaultStepDays } + nonCentralRiseSetCurves, nonCentralRiseSetComplete = solver.riseSetCurvesWithStatus( + result.PartialBeginOnEarth, + result.PartialEndOnEarth, + result.GreatestEclipse, + riseSetStepDays, + ) + var bandHorizon []SolarEclipsePathPoint + if result.Type == SolarEclipseTotal { + totalMagnitudeOneSegments = solver.magnitudeContourSegmentsWithSpacing( + result.PartialBeginOnEarth, + result.PartialEndOnEarth, + result.CentralBeginOnEarth, + result.CentralEndOnEarth, + result.GreatestEclipse, + 1, + options.StepDays, + false, + solarEclipseNonCentralTotalBandTargetSpacingKM, + ) + footprintsResult.CentralBandSegments, bandHorizon = solver.nonCentralTotalBandPolygons( + totalMagnitudeOneSegments, + footprintsResult.U1, + footprintsResult.U4, + result.GreatestEclipse, + nonCentralRiseSetCurves, + ) + } + if !solarEclipseNonCentralBandContainsFootprints( + footprintsResult.CentralBandSegments, footprintsResult.CentralBandFootprints, + ) { + footprintsResult.CentralBandSegments = nil + bandHorizon = nil + } + centralDuration := footprintsResult.U4.JDE - footprintsResult.U1.JDE + if len(footprintsResult.CentralBandSegments) == 0 && + centralDuration <= solarEclipseNonCentralBandPreciseMaxDurationDays { + sweep, sweepHorizon, sampled := solver.centralBandSweepPolygons( + footprintsResult.U1.JDE, + footprintsResult.U4.JDE, + result.GreatestEclipse, + nonCentralRiseSetCurves, + ) + if centralBandSweepCoversFootprints( + sweep, footprintsResult.CentralBandFootprints, sampled, + ) { + footprintsResult.CentralBandSegments, bandHorizon = sweep, sweepHorizon + } + } + if !centralBandSweepCoversFootprints( + footprintsResult.CentralBandSegments, footprintsResult.CentralBandFootprints, false, + ) { + footprintsResult.CentralBandSegments = nil + bandHorizon = nil + } + alignNonCentralBandHorizon(nonCentralRiseSetCurves, bandHorizon) } - return footprints, stepDays + return totalMagnitudeOneSegments, nonCentralRiseSetCurves, nonCentralRiseSetComplete } -func uniqueSolarEclipsePathTimes(times []float64) []float64 { - if len(times) < 2 { - return times +// centralBandRiseSetCurves 求解中心带闭合需要的升落曲线;DisableRiseSetCurves 只影响是否 +// 对外返回,两限中心食与非中心全食的端帽闭合始终需要它们。 +func (solver solarEclipseSolver) centralBandRiseSetCurves( + result SolarEclipseResult, + options SolarEclipsePartialFootprintOptions, + nonCentralRiseSetCurves []SolarEclipseRiseSetCurve, + nonCentralRiseSetComplete bool, +) ([]SolarEclipseRiseSetCurve, bool) { + needRiseSetInternals := !options.DisableRiseSetCurves + if result.Centrality == SolarEclipseCentralTwoLimits && result.Type != SolarEclipsePartial { + needRiseSetInternals = true } + if result.Centrality == SolarEclipseNonCentral && result.Type == SolarEclipseTotal { + needRiseSetInternals = true + } + if !needRiseSetInternals { + return nil, true + } + var riseSetCurves []SolarEclipseRiseSetCurve + riseSetComplete := true + if len(nonCentralRiseSetCurves) > 0 { + riseSetCurves, riseSetComplete = nonCentralRiseSetCurves, nonCentralRiseSetComplete + } else { + riseSetStepDays := options.RiseSetStepDays + if riseSetStepDays <= 0 || math.IsNaN(riseSetStepDays) || math.IsInf(riseSetStepDays, 0) { + riseSetStepDays = solarEclipseRiseSetDefaultStepDays + } + riseSetCurves, riseSetComplete = solver.riseSetCurvesWithStatus( + result.PartialBeginOnEarth, + result.PartialEndOnEarth, + result.GreatestEclipse, + riseSetStepDays, + ) + } + if result.HasCentral { + solver.attachCentralAxisContactsToRiseSetCurves( + riseSetCurves, + result.CentralBeginOnEarth, + result.CentralEndOnEarth, + ) + } + return riseSetCurves, riseSetComplete +} - unique := times[:1] - for _, jd := range times[1:] { - if math.Abs(jd-unique[len(unique)-1]) <= solarEclipsePathDuplicateTimeDays { +// attachCentralBandClosureEnvelope 用两端的地平闭合弧构造解析中心带;没有得到完整的两条 +// 闭合弧时不改动结果,交给采样兜底。 +func (solver solarEclipseSolver) attachCentralBandClosureEnvelope( + result SolarEclipseResult, + footprintsResult *SolarEclipsePartialFootprintsResult, + riseSetCurves []SolarEclipseRiseSetCurve, + totalMagnitudeOneSegments [][]SolarEclipsePathPoint, +) { + var totalEnvelopeSegments [][]SolarEclipsePathPoint + if (result.Type == SolarEclipseAnnular || result.Type == SolarEclipseTotal || result.Type == SolarEclipseHybrid) && + result.Centrality == SolarEclipseCentralTwoLimits && + (len(riseSetCurves) > 0 || result.Type == SolarEclipseTotal) { + closures := make([][]SolarEclipsePathPoint, 0, 2) + for _, side := range solarEclipseCentralBandClosureSides(*footprintsResult, result) { + closure, ok := solver.centralBandHorizonClosureForSide( + result, *footprintsResult, riseSetCurves, totalMagnitudeOneSegments, side, + ) + if !ok { + return + } + closures = append(closures, closure) + } + if len(closures) == 2 { + footprintsResult.CentralBandHorizonClosures = closures + solver.alignSolarEclipseCentralBandHorizonClosures( + riseSetCurves, + footprintsResult.CentralBandHorizonClosures, + ) + if result.Type == SolarEclipseAnnular { + // The continuous critical envelope is the authoritative static + // central band. Instantaneous low-altitude footprint edges are + // open slices and must not veto this closed envelope: requiring + // every sampled slice to lie inside it recreates the comb-shaped + // union that this path replaces. + footprintsResult.CentralBandSegments = solver.centralTwoLimitBandEnvelope( + closures, result.GreatestEclipse, + ) + if len(footprintsResult.CentralBandSegments) == 0 { + // At an extremely shallow polar annular event the analytic + // continuation can start exactly on the horizon and have no + // numerically visible first step. The sampled central sweep + // still carries the same contact and horizon topology. + solver.attachCentralBandSweep( + footprintsResult, + footprintsResult.U1.JDE, footprintsResult.U4.JDE, + result.GreatestEclipse, riseSetCurves, + ) + } + } else if result.Type == SolarEclipseHybrid { + footprintsResult.CentralBandSegments = solver.hybridCentralBandEnvelope(closures, result) + } else { + // Share the signed physical branches with magnitude-one output; + // independently traced scalar contours may stop before the horizon. + // Keep the exact magnitude-one contours when they form a valid + // closed band. Only replace a scalar branch when its own sampled + // shadow slices prove that it terminates early or misses the band. + totalEnvelopeSegments = totalMagnitudeOneSegments + if len(solarEclipseTotalBandEnvelope(totalEnvelopeSegments, closures)) == 0 { + // Rebuild the closures from the same signed roots as the + // vector branches. Scalar magnitude contours may have valid + // samples but terminate on different horizon branches. + vectorClosures := make([][]SolarEclipsePathPoint, 0, 2) + for _, side := range []struct { + axis, first, last, direction float64 + }{ + {result.CentralBeginOnEarth, footprintsResult.U1.JDE, footprintsResult.U2.JDE, 1}, + {result.CentralEndOnEarth, footprintsResult.U4.JDE, footprintsResult.U3.JDE, -1}, + } { + first, last, rootsOK := solver.centralBandVectorHorizonRoots(side.axis, side.direction, side.first, side.last) + if !rootsOK { + vectorClosures = nil + break + } + _, key, classified := solver.magnitudeEvaluationAt(first.JDE).classify(first.Longitude, first.Latitude, true) + if !classified { + vectorClosures = nil + break + } + vectorClosures = append(vectorClosures, solver.centralBandHorizonClosure(first, last, key.direction, riseSetCurves)) + } + if len(vectorClosures) == 2 { + closures = vectorClosures + footprintsResult.CentralBandHorizonClosures = closures + totalEnvelopeSegments = solver.centralBandVectorBranches(closures, result.GreatestEclipse) + } + } + footprintsResult.CentralBandSegments = solarEclipseTotalBandEnvelope(totalEnvelopeSegments, closures) + if len(footprintsResult.CentralBandSegments) == 0 && len(riseSetCurves) > 0 { + // Very shallow polar totalities may expose only one horizon + // endpoint on each magnitude-one branch. The analytic join then + // has no distinct start/end caps; use the sampled central sweep + // as a topology-preserving fallback. + solver.attachCentralBandSweep( + footprintsResult, + footprintsResult.U1.JDE, footprintsResult.U4.JDE, + result.GreatestEclipse, riseSetCurves, + ) + } + } + } + } +} + +// attachCentralBandSweep 用扫掠兜底填充分段:解析扫掠与采样并集都可能返回,采样标记 +// 必须跟着返回值走,否则导出层会把采样并集当成精确解析包络处理。 +func (solver solarEclipseSolver) attachCentralBandSweep( + footprintsResult *SolarEclipsePartialFootprintsResult, + startJDE, endJDE, greatestJDE float64, + riseSetCurves []SolarEclipseRiseSetCurve, +) { + segments, _, sampled := solver.centralBandSweepPolygons(startJDE, endJDE, greatestJDE, riseSetCurves) + footprintsResult.CentralBandSegments = segments + footprintsResult.CentralBandSampled = sampled +} + +// attachCentralBandFallbackSweep 是解析包络完全缺席时的最后兜底:只要采样重建覆盖了瞬时足迹 +// 就采用它,并如实标记这是采样包络。 +func (solver solarEclipseSolver) attachCentralBandFallbackSweep( + result SolarEclipseResult, + footprintsResult *SolarEclipsePartialFootprintsResult, + riseSetCurves []SolarEclipseRiseSetCurve, +) { + centrality := footprintsResult.Eclipse.Centrality + if len(footprintsResult.CentralBandSegments) == 0 && len(riseSetCurves) > 0 && + footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 && + (centrality == SolarEclipseCentralTwoLimits || + centrality == SolarEclipseCentralOneLimit && result.Type == SolarEclipseAnnular) { + sweep, _, sweepSampled, covers := solver.centralBandBestSweep( + footprintsResult.U1.JDE, footprintsResult.U4.JDE, + result.GreatestEclipse, riseSetCurves, + footprintsResult.CentralBandFootprints, + ) + if covers { + footprintsResult.CentralBandSegments = sweep + footprintsResult.CentralBandSampled = sweepSampled + } + } +} + +type solarEclipseCentralBandClosureSide struct { + axisContactJDE float64 + shadowContactJDE float64 + innerContactJDE float64 + direction float64 +} + +func solarEclipseCentralBandClosureSides( + footprints SolarEclipsePartialFootprintsResult, + result SolarEclipseResult, +) []solarEclipseCentralBandClosureSide { + innerStartJDE := result.CentralBeginOnEarth + if footprints.U2.JDE != 0 { + innerStartJDE = footprints.U2.JDE + } + innerEndJDE := result.CentralEndOnEarth + if footprints.U3.JDE != 0 { + innerEndJDE = footprints.U3.JDE + } + return []solarEclipseCentralBandClosureSide{ + {result.CentralBeginOnEarth, footprints.U1.JDE, innerStartJDE, 1}, + {result.CentralEndOnEarth, footprints.U4.JDE, innerEndJDE, -1}, + } +} + +// centralBandHorizonClosureForSide 解出中心带一端的地平闭合弧;三种食型各有一条取根链, +// 取不到唯一一对根就放弃该端(整条解析包络随之放弃)。 +func (solver solarEclipseSolver) centralBandHorizonClosureForSide( + result SolarEclipseResult, + footprintsResult SolarEclipsePartialFootprintsResult, + riseSetCurves []SolarEclipseRiseSetCurve, + totalMagnitudeOneSegments [][]SolarEclipsePathPoint, + side solarEclipseCentralBandClosureSide, +) ([]SolarEclipsePathPoint, bool) { + var firstRoot, lastRoot SolarEclipsePathPoint + var ok bool + seededDirection := RiseSetDirection("") + if result.Type == SolarEclipseTotal { + firstRoot, lastRoot, ok = magnitudeOneHorizonRoots(totalMagnitudeOneSegments, side.direction) + if !ok { + firstRoot, lastRoot, ok = solver.centralBandVectorHorizonRoots( + side.axisContactJDE, side.direction, side.shadowContactJDE, side.innerContactJDE, + ) + } + } else if result.Type == SolarEclipseHybrid { + firstRoot, lastRoot, ok = solver.centralBandVectorHorizonRoots( + side.axisContactJDE, side.direction, side.shadowContactJDE, side.innerContactJDE, + ) + } else { + // A grazing closure arc routinely splits its two endpoints + // between the seeding paths: one sits outside the sampled + // horizon branches, the other outside the axis-adjacent Newton + // basin. Collect what every solver can offer and accept the pair + // only when exactly two distinct roots come out. + candidates := make([]SolarEclipsePathPoint, 0, 4) + directions := make([]RiseSetDirection, 0, 4) + curveDirection := RiseSetDirection("") + unionDirection := RiseSetDirection("") + appendRoots := func( + first, last SolarEclipsePathPoint, + direction RiseSetDirection, + found bool, + ) { + if !found { + return + } + for _, root := range []SolarEclipsePathPoint{first, last} { + if root.JDE == 0 || solarEclipseRiseSetPointExists(candidates, root) { + continue + } + candidates = append(candidates, root) + directions = append(directions, direction) + } + } + firstRoot, lastRoot, ok = solver.centralLimitHorizonRootsNearAxisContact( + side.axisContactJDE, side.shadowContactJDE, side.innerContactJDE, side.direction, + ) + appendRoots(firstRoot, lastRoot, "", ok) + curveRoots, curveDirection := solver.centralLimitHorizonRootsFromCurves( + riseSetCurves, side.shadowContactJDE, side.innerContactJDE, + ) + for _, root := range curveRoots { + appendRoots(root, SolarEclipsePathPoint{}, curveDirection, true) + } + if len(candidates) < 2 { + // The sampled sweep is the most expensive seeding, so it + // only runs while the pair is still incomplete. Some grazing + // events have no two-root closure at all, and then the + // sampled band is the right representation rather than a + // fallback: 4862-09-28 ends 40 km inside the horizon (the + // cap is bounded by the umbral rim), and 1552-07-21 has its + // boundary running along the horizon (measured +0.004 then + // -0.000 degrees), so its closure arc is degenerate. + unionRoots, unionDirection := solver.centralLimitHorizonRootsFromSampledBoundary( + side.shadowContactJDE, side.innerContactJDE, result.GreatestEclipse, + footprintsResult.CentralBandFootprints, riseSetCurves, + ) + for _, root := range unionRoots { + appendRoots(root, SolarEclipsePathPoint{}, unionDirection, true) + } + } + if seededDirection == "" { + seededDirection = curveDirection + } + if seededDirection == "" { + seededDirection = unionDirection + } + ok = len(candidates) == 2 + if ok { + sort.Slice(candidates, func(first, second int) bool { + return candidates[first].JDE < candidates[second].JDE + }) + firstRoot, lastRoot = candidates[0], candidates[1] + for _, direction := range directions { + if direction != "" { + seededDirection = direction + break + } + } + } + } + if !ok { + return nil, false + } + // Near a pole, the first and last contacts can both be sunrise or + // both sunset. Classify the solved root instead of the temporal end. + _, key, classified := solver.magnitudeEvaluationAt(firstRoot.JDE).classify( + firstRoot.Longitude, firstRoot.Latitude, true, + ) + if !classified && seededDirection == "" { + seededDirection = solarEclipseNearestGreatestDirection(riseSetCurves, firstRoot) + } + if !classified && seededDirection != "" { + // The local classification needs the eclipse to be exactly at + // its greatest there, which a shallow polar closure root does + // not always satisfy. The horizon curve that seeded the root + // already knows whether the Sun is rising or setting. + key = solarEclipseRiseSetCurveKey{ + phase: RiseSetPhaseGreatest, direction: seededDirection, + } + } + if !classified { + return nil, false + } + return solver.centralBandHorizonClosure( + firstRoot, lastRoot, key.direction, riseSetCurves, + ), true +} + +// centralBandBestSweep returns the sweep candidate that contains the sampled +// umbra. The analytic sweep is smooth and stays the first choice whenever it +// really contains it; a grazing path whose envelope terminates early is rebuilt +// from the sampled footprints instead. +// The returned flag reports whether the candidate already passed the coverage +// audit, so the caller does not repeat the probe. +func (solver solarEclipseSolver) centralBandBestSweep( + startJDE, endJDE, greatestJDE float64, + riseSetCurves []SolarEclipseRiseSetCurve, + footprints []SolarEclipsePartialFootprint, +) ([][]SolarEclipsePathPoint, []SolarEclipsePathPoint, bool, bool) { + candidate, horizon, sampled := solver.centralBandSweepPolygonsReusing( + startJDE, endJDE, greatestJDE, riseSetCurves, footprints, + ) + if solarEclipseBandContainsSampledFootprintsWithinKM( + candidate, footprints, centralBandSweepToleranceKM(sampled), + ) { + return candidate, horizon, sampled, true + } + // The sampled reconstruction is only worth building when the sweep had an + // analytic region to reject; when the sweep itself already fell back to the + // sampled union, the audit above has judged that exact reconstruction. + if !sampled { + union := solver.centralBandSampledFootprintUnionOverRange(startJDE, endJDE, greatestJDE, footprints) + if len(union) > 0 && solarEclipseBandContainsSampledFootprintsWithinKM( + union, footprints, centralBandSweepToleranceKM(true), + ) { + return union, nil, true, true + } + } + return candidate, horizon, sampled, false +} + +// centralBandSweepToleranceKM returns the coverage bound of a sweep candidate. +func centralBandSweepToleranceKM(sampled bool) float64 { + if sampled { + return solarEclipseCentralBandUnionContainmentToleranceKM + } + return solarEclipseNonCentralBandContainmentToleranceKM +} + +// centralBandSweepCoversFootprints validates a sweep candidate. The analytic +// sweep must contain every sampled footprint within the usual footprint +// tolerance; the sampled footprint union is decimated for tractability and is +// validated against the wider union bound instead. +func centralBandSweepCoversFootprints( + segments [][]SolarEclipsePathPoint, + footprints []SolarEclipsePartialFootprint, + sampled bool, +) bool { + tolerance := solarEclipseNonCentralBandContainmentToleranceKM + if sampled { + tolerance = solarEclipseCentralBandUnionContainmentToleranceKM + } + return solarEclipseBandContainsFootprintsWithinKM(segments, footprints, tolerance) +} + +// setCenterLinePairWidths replaces the analytic 2r/sin(altitude) width with the +// distance between the paired limits, which is the width the tables publish. +// The analytic form is a small-angle approximation that diverges towards the +// horizon (2017-08-21 reports 10,062 km near its ends instead of ~90 km), so it +// is only kept as the greatest-eclipse figure that the catalogues quote. A +// sample whose cross-section does not close has no width at all, and a one-limit +// event has none anywhere - which is why catalogues print no width for it (NASA +// lists 1874-10-10 with "-"). +func (solver solarEclipseSolver) setCenterLinePairWidths( + centerLine []SolarEclipsePathPoint, + centrality SolarEclipseCentrality, + northern, southern []SolarEclipsePathPoint, + paired []bool, +) { + if len(centerLine) == 0 { + return + } + if centrality != SolarEclipseCentralTwoLimits { + for index := range centerLine { + centerLine[index].WidthKM = 0 + } + return + } + if len(northern) != len(centerLine) || len(southern) != len(centerLine) || len(paired) != len(centerLine) { + return + } + for index := range centerLine { + if !paired[index] { + centerLine[index].WidthKM = 0 continue } - unique = append(unique, jd) + width := solarEclipsePathDistanceKM(northern[index], southern[index]) + if width <= 0 || width > solarEclipsePathMaxPossibleWidthKM { + // A cross-section wider than the Earth cannot be a footprint edge. + width = 0 + } + centerLine[index].WidthKM = width } - return unique } -func (solver solarEclipseSolver) refineCentralPathSpacing(points []SolarEclipsePathPoint, targetSpacingKM float64) []SolarEclipsePathPoint { - if len(points) < 2 || targetSpacingKM <= 0 { +// centralPathMaxCentralDuration returns the longest central phase on the center +// line and where it happens. The local solution reuses the event-local +// interpolated ephemeris, which is what keeps a per-sample scan affordable. +// Catalogues publish the duration at greatest eclipse, +// which for a shallow event is not the longest one: 2020-06-21 lasts 38 s there +// but 1m22s near the sunrise end of its track. Samples below the horizon are +// skipped because the central phase is not observable from them; the width is +// deliberately not used as a filter, because a one-limit event has no width at +// all yet still has a published duration. +func (solver solarEclipseSolver) centralPathMaxCentralDuration( + centerLine []SolarEclipsePathPoint, +) (float64, float64, float64) { + bestDays, bestLongitude, bestLatitude := 0.0, 0.0, 0.0 + for _, point := range centerLine { + if point.SunAltitude < -solarEclipsePathDurationHorizonToleranceDegrees { + continue + } + span := solver.centralPhaseDurationDaysAt(point.JDE, point.Longitude, point.Latitude) + if span <= 0 { + continue + } + if span > bestDays { + bestDays, bestLongitude, bestLatitude = span, point.Longitude, point.Latitude + } + } + return bestDays, bestLongitude, bestLatitude +} + +func (solver solarEclipseSolver) attachCentralAxisContactPoints( + points []SolarEclipsePathPoint, + startJDE, endJDE float64, +) []SolarEclipsePathPoint { + if len(points) == 0 { return points } - - refined := make([]SolarEclipsePathPoint, 0, len(points)) - refined = append(refined, points[0]) - for i := 1; i < len(points); i++ { - refined = solver.appendRefinedCentralPathSegment(refined, points[i-1], points[i], targetSpacingKM, 0) + const supportOffsetDays = 0.1 / 86400.0 + contacts := make([]SolarEclipsePathPoint, 0, 2) + for _, jd := range []float64{startJDE, endJDE} { + if point, ok := solver.centralAxisContactPointAt(jd); ok { + contacts = append(contacts, point) + } } - return refined + if len(contacts) != 2 { + return points + } + supports := make([]SolarEclipsePathPoint, 0, 2) + if endJDE-startJDE > 2*supportOffsetDays { + if point, ok := solver.centralPathPointAt(startJDE + supportOffsetDays); ok { + supports = append(supports, point) + } + if point, ok := solver.centralPathPointAt(endJDE - supportOffsetDays); ok { + supports = append(supports, point) + } + } + filtered := points[:0] + for _, point := range points { + if math.Abs(point.JDE-startJDE) <= solarEclipsePathDuplicateTimeDays || + math.Abs(point.JDE-endJDE) <= solarEclipsePathDuplicateTimeDays { + continue + } + filtered = append(filtered, point) + } + points = append(filtered, supports...) + points = append(points, contacts...) + sort.Slice(points, func(first, second int) bool { return points[first].JDE < points[second].JDE }) + return normalizeSolarEclipsePathPointSeries(points) } -func (solver solarEclipseSolver) appendRefinedCentralPathSegment( - points []SolarEclipsePathPoint, - start, end SolarEclipsePathPoint, - targetSpacingKM float64, - depth int, -) []SolarEclipsePathPoint { - if depth >= solarEclipsePathMaxAdaptiveDepth || solarEclipsePathDistanceKM(start, end) <= targetSpacingKM { - return append(points, end) +func setSolarEclipseCentralContactWidths( + centerLine, northern, southern []SolarEclipsePathPoint, +) { + if len(centerLine) < 2 || len(northern) != len(southern) { + return } - - midJDE := (start.JDE + end.JDE) / 2 - mid, ok := solver.centralPathPointAt(midJDE) - if !ok { - return append(points, end) + for _, centerIndex := range []int{0, len(centerLine) - 1} { + bestIndex := -1 + bestDelta := math.Inf(1) + for index := range northern { + delta := math.Abs(northern[index].JDE - centerLine[centerIndex].JDE) + if delta < bestDelta { + bestIndex, bestDelta = index, delta + } + } + if bestIndex >= 0 && bestDelta <= 0.2/86400.0 { + centerLine[centerIndex].WidthKM = solarEclipsePathDistanceKM( + northern[bestIndex], southern[bestIndex], + ) + } } - points = solver.appendRefinedCentralPathSegment(points, start, mid, targetSpacingKM, depth+1) - return solver.appendRefinedCentralPathSegment(points, mid, end, targetSpacingKM, depth+1) } -func (solver solarEclipseSolver) centralPathPointAt(jd float64) (SolarEclipsePathPoint, bool) { - moon := solver.besselMoonAt(jd) - axis := solver.besselAxisAt(jd) - intersection := solarEclipseLineEar2( - moon[0], - moon[1], - 2, - moon[0], - moon[1], - 0, - solarEclipseEarthPolarRatio, - 1, - axis, - ) - if !intersection.valid { - return SolarEclipsePathPoint{}, false - } - - longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) - sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) - radii := solver.shadowRadiiAt(moon[2] - intersection.r2) - - widthKM := 0.0 - if math.Abs(math.Sin(sunAltitudeRad)) > 1e-12 { - widthKM = math.Abs(2*radii.umbraRadius*solarEclipseEarthEquatorialRadiusKM) / math.Abs(math.Sin(sunAltitudeRad)) - } - - return SolarEclipsePathPoint{ - JDE: jd, - Longitude: longitude, - Latitude: latitude, - SunAltitude: sunAltitudeRad / rad, - WidthKM: widthKM, - }, true -} - -func (solver solarEclipseSolver) centralPathLimits(centerLine []SolarEclipsePathPoint) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { - northern := make([]SolarEclipsePathPoint, 0, len(centerLine)) - southern := make([]SolarEclipsePathPoint, 0, len(centerLine)) - for _, center := range centerLine { - north, south, ok := solver.centralPathLimitsAt(center) +func (solver solarEclipseSolver) attachCentralAxisContactsToRiseSetCurves( + curves []SolarEclipseRiseSetCurve, + startJDE, endJDE float64, +) { + for _, endpoint := range []struct { + jde float64 + direction RiseSetDirection + }{ + {startJDE, RiseSetDirectionRise}, + {endJDE, RiseSetDirectionSet}, + } { + contact, ok := solver.centralAxisContactPointAt(endpoint.jde) if !ok { continue } - northern = append(northern, north) - southern = append(southern, south) + insertSolarEclipseRiseSetContact(curves, contact, endpoint.direction) } - return northern, southern } -func (solver solarEclipseSolver) centralPathLimitsAt(center SolarEclipsePathPoint) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) { - moon := solver.besselMoonAt(center.JDE) - axis := solver.besselAxisAt(center.JDE) - intersection := solarEclipseLineEar2( - moon[0], - moon[1], - 2, - moon[0], - moon[1], - 0, - solarEclipseEarthPolarRatio, - 1, - axis, - ) - if !intersection.valid { - return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false - } - - radii := solver.shadowRadiiAt(moon[2] - intersection.r2) - radius := radii.absUmbraRadius - if radius <= 0 { - return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false - } - - vx, vy, speed := solver.besselVelocityXYAt(center.JDE) - if speed <= 0 { - return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false - } - perpX := -vy / speed - perpY := vx / speed - - first, okFirst := solarEclipsePathPointFromBesselXY(center.JDE, moon[0]+radius*perpX, moon[1]+radius*perpY, axis) - second, okSecond := solarEclipsePathPointFromBesselXY(center.JDE, moon[0]-radius*perpX, moon[1]-radius*perpY, axis) - if !okFirst || !okSecond { - return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false - } - first.WidthKM = center.WidthKM - second.WidthKM = center.WidthKM - if first.Latitude >= second.Latitude { - return first, second, true - } - return second, first, true -} - -func (solver solarEclipseSolver) besselVelocityXYAt(jd float64) (float64, float64, float64) { - before := solver.besselMoonAt(jd - solarEclipsePathVelocityStepDays) - after := solver.besselMoonAt(jd + solarEclipsePathVelocityStepDays) - vx := (after[0] - before[0]) / (2 * solarEclipsePathVelocityStepDays) - vy := (after[1] - before[1]) / (2 * solarEclipsePathVelocityStepDays) - return vx, vy, math.Hypot(vx, vy) -} - -func solarEclipsePathPointFromBesselXY(jd, x, y float64, axis solarEclipseAxis) (SolarEclipsePathPoint, bool) { - longitude, latitude, ok := solarEclipseBesselXYToGeodetic(x, y, axis, true) - if !ok { - return SolarEclipsePathPoint{}, false - } - sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) - return SolarEclipsePathPoint{ - JDE: jd, - Longitude: longitude, - Latitude: latitude, - SunAltitude: sunAltitudeRad / rad, - }, true -} - -func (solver solarEclipseSolver) shadowContactPair( - greatestJDE float64, - kind solarEclipseShadowKind, - internal bool, -) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) { - middleResidual, ok := solver.shadowContactResidual(greatestJDE, kind, internal) - if !ok || middleResidual > 0 { - return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false - } - firstJDE, firstOK := solver.shadowContactRoot(greatestJDE, -1, middleResidual, kind, internal) - lastJDE, lastOK := solver.shadowContactRoot(greatestJDE, 1, middleResidual, kind, internal) - if !firstOK || !lastOK { - return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false - } - first, firstOK := solver.shadowContactPointAt(firstJDE, kind) - last, lastOK := solver.shadowContactPointAt(lastJDE, kind) - if !firstOK || !lastOK { - return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false - } - return first, last, true -} - -func (solver solarEclipseSolver) shadowContactRoot( - greatestJDE float64, - direction float64, - middleResidual float64, - kind solarEclipseShadowKind, - internal bool, -) (float64, bool) { - insideJDE := greatestJDE - insideResidual := middleResidual - for span := solarEclipseShadowContactSearchStepDays; span <= solarEclipseShadowContactSearchSpanDays; span += solarEclipseShadowContactSearchStepDays { - outsideJDE := greatestJDE + direction*span - outsideResidual, ok := solver.shadowContactResidual(outsideJDE, kind, internal) - if !ok { +func insertSolarEclipseRiseSetContact( + curves []SolarEclipseRiseSetCurve, + contact SolarEclipsePathPoint, + direction RiseSetDirection, +) { + bestCurve, bestSegment, bestPoint := -1, -1, -1 + bestDistance := math.Inf(1) + for curveIndex := range curves { + curve := &curves[curveIndex] + if curve.Phase != RiseSetPhaseGreatest || curve.Direction != direction { continue } - if outsideResidual >= 0 { - leftJDE, rightJDE := outsideJDE, insideJDE - leftResidual, rightResidual := outsideResidual, insideResidual - if leftJDE > rightJDE { - leftJDE, rightJDE = rightJDE, leftJDE - leftResidual, rightResidual = rightResidual, leftResidual + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 || + contact.JDE < segment[0].JDE-solarEclipseRiseSetTimeEpsilonDays || + contact.JDE > segment[len(segment)-1].JDE+solarEclipseRiseSetTimeEpsilonDays { + continue } - for rightJDE-leftJDE > solarEclipseShadowContactToleranceDays { - middleJDE := (leftJDE + rightJDE) / 2 - residual, valid := solver.shadowContactResidual(middleJDE, kind, internal) - if !valid { - return 0, false - } - if (residual >= 0) == (leftResidual >= 0) { - leftJDE, leftResidual = middleJDE, residual - } else { - rightJDE, rightResidual = middleJDE, residual - } + pointIndex := sort.Search(len(segment), func(index int) bool { + return segment[index].JDE >= contact.JDE + }) + if pointIndex == 0 { + pointIndex = 1 + } else if pointIndex >= len(segment) { + pointIndex = len(segment) - 1 + } + first, second := segment[pointIndex-1], segment[pointIndex] + fraction := 0.0 + if second.JDE > first.JDE { + fraction = (contact.JDE - first.JDE) / (second.JDE - first.JDE) + } + fraction = math.Max(0, math.Min(1, fraction)) + candidate := solarEclipsePathSphericalInterpolate(first, second, fraction) + distance := solarEclipsePathDistanceKM(candidate, contact) + if distance < bestDistance { + bestCurve, bestSegment, bestPoint = curveIndex, segmentIndex, pointIndex + bestDistance = distance } - return (leftJDE + rightJDE) / 2, true } - insideJDE, insideResidual = outsideJDE, outsideResidual } - return 0, false + if bestCurve < 0 || bestDistance > solarEclipseRiseSetTargetSpacingKM { + return + } + segment := curves[bestCurve].Segments[bestSegment] + if bestPoint > 0 && math.Abs(segment[bestPoint-1].JDE-contact.JDE) <= solarEclipseRiseSetTimeEpsilonDays { + segment[bestPoint-1] = contact + } else if bestPoint < len(segment) && math.Abs(segment[bestPoint].JDE-contact.JDE) <= solarEclipseRiseSetTimeEpsilonDays { + segment[bestPoint] = contact + } else { + segment = append(segment, SolarEclipsePathPoint{}) + copy(segment[bestPoint+1:], segment[bestPoint:]) + segment[bestPoint] = contact + } + curves[bestCurve].Segments[bestSegment] = segment } -func (solver solarEclipseSolver) shadowContactResidual( - jd float64, - kind solarEclipseShadowKind, - internal bool, -) (float64, bool) { - moon := solver.besselMoonAt(jd) - distanceSquared := moon[0]*moon[0] + moon[1]*moon[1] - if distanceSquared <= 0 { - return 0, false +func (solver solarEclipseSolver) centralBandFootprintsWithStep( + outerStartJDE, innerStartJDE, innerEndJDE, outerEndJDE, greatestJDE, stepDays float64, +) ([]SolarEclipsePartialFootprint, float64) { + if stepDays <= 0 || math.IsNaN(stepDays) || math.IsInf(stepDays, 0) { + stepDays = solarEclipseCentralBandStepDays } - radius := solver.shadowRadiusAt(moon[2], kind) - if radius <= 0 { - return 0, false - } - earthRadius := 1 - (1/solarEclipseEarthPolarRatioSquared-1)*moon[1]*moon[1]/distanceSquared/2 - limit := earthRadius + radius - if internal { - limit = earthRadius - radius - } - if limit <= 0 { - return 0, false - } - return math.Sqrt(distanceSquared) - limit, true -} - -func (solver solarEclipseSolver) shadowContactPointAt( - jd float64, - kind solarEclipseShadowKind, -) (SolarEclipsePathPoint, bool) { - moon := solver.besselMoonAt(jd) - distance := math.Hypot(moon[0], moon[1]) - if distance <= 0 || solver.shadowRadiusAt(moon[2], kind) <= 0 { - return SolarEclipsePathPoint{}, false - } - axis := solver.besselAxisAt(jd) - unitX, unitY := moon[0]/distance, moon[1]/distance - insideScale, outsideScale := 0.0, 1.1 - var intersection solarEclipseLineIntersection - for iteration := 0; iteration < 48; iteration++ { - scale := (insideScale + outsideScale) / 2 - candidate := solarEclipseLineEar2( - scale*unitX, scale*unitY, 2, - scale*unitX, scale*unitY, 0, - solarEclipseEarthPolarRatio, 1, axis, + if innerStartJDE != 0 && innerEndJDE != 0 && + outerStartJDE < innerStartJDE && innerStartJDE < innerEndJDE && innerEndJDE < outerEndJDE { + start, actualStartStep, _ := solver.shadowFootprintsWithSpacing( + outerStartJDE, innerStartJDE, innerStartJDE, stepDays, + solarEclipseCentralBandBoundaryPoints, solarEclipseCentralShadow, + solarEclipseCentralBandTargetSpacingKM, ) - if candidate.valid { - insideScale = scale - intersection = candidate - } else { - outsideScale = scale - } - } - if !intersection.valid { - return SolarEclipsePathPoint{}, false - } - longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) - sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) - return SolarEclipsePathPoint{ - JDE: jd, - Longitude: longitude, - Latitude: latitude, - SunAltitude: sunAltitudeRad / rad, - }, true -} - -func (solver solarEclipseSolver) shadowRadiusAt(moonBesselZ float64, kind solarEclipseShadowKind) float64 { - radii := solver.shadowRadiiAt(moonBesselZ) - if kind == solarEclipseCentralShadow { - return radii.absUmbraRadius - } - return radii.penumbraRadius -} - -func (solver solarEclipseSolver) partialFootprintAt(jd float64, boundaryPoints int) SolarEclipsePartialFootprint { - return solver.shadowFootprintAt(jd, boundaryPoints, solarEclipsePenumbralShadow) -} - -func (solver solarEclipseSolver) shadowFootprintAt( - jd float64, - boundaryPoints int, - kind solarEclipseShadowKind, -) SolarEclipsePartialFootprint { - moon := solver.besselMoonAt(jd) - axis := solver.besselAxisAt(jd) - samples := make([]solarEclipsePartialBoundarySample, boundaryPoints) - for i := range samples { - angle := 2 * math.Pi * float64(i) / float64(boundaryPoints) - point, ok := solver.shadowFootprintPointAt(jd, moon, axis, angle, kind) - samples[i] = solarEclipsePartialBoundarySample{ - point: point, - ok: ok, - angle: angle, - } - } - samples = solver.refineShadowFootprintTransitions(jd, moon, axis, samples, kind) - - boundaries, closed := solarEclipsePartialBoundarySegments(samples) - return SolarEclipsePartialFootprint{ - JDE: jd, - Boundaries: boundaries, - Closed: closed, - } -} - -type solarEclipsePartialBoundarySample struct { - point SolarEclipsePathPoint - ok bool - angle float64 -} - -func (solver solarEclipseSolver) refineShadowFootprintTransitions( - jd float64, - moon [3]float64, - axis solarEclipseAxis, - samples []solarEclipsePartialBoundarySample, - kind solarEclipseShadowKind, -) []solarEclipsePartialBoundarySample { - if len(samples) < 2 { - return samples - } - result := make([]solarEclipsePartialBoundarySample, 0, len(samples)+4) - for index, sample := range samples { - result = append(result, sample) - next := samples[(index+1)%len(samples)] - if sample.ok == next.ok { - continue - } - nextAngle := next.angle - if index == len(samples)-1 { - nextAngle += 2 * math.Pi - } - refined := solver.refineShadowFootprintTransition(jd, moon, axis, sample, next, nextAngle, kind) - result = append(result, refined) - } - return result -} - -func (solver solarEclipseSolver) refineShadowFootprintTransition( - jd float64, - moon [3]float64, - axis solarEclipseAxis, - first, second solarEclipsePartialBoundarySample, - secondAngle float64, - kind solarEclipseShadowKind, -) solarEclipsePartialBoundarySample { - leftAngle := first.angle - rightAngle := secondAngle - leftOK := first.ok - best := first - if second.ok { - best = second - best.angle = secondAngle - } - for iteration := 0; iteration < solarEclipsePartialFootprintTransitionIterations; iteration++ { - middleAngle := (leftAngle + rightAngle) / 2 - evaluationAngle := math.Mod(middleAngle, 2*math.Pi) - point, ok := solver.shadowFootprintPointAt(jd, moon, axis, evaluationAngle, kind) - middle := solarEclipsePartialBoundarySample{point: point, ok: ok, angle: middleAngle} - if ok { - best = middle - } - if ok == leftOK { - leftAngle = middleAngle - } else { - rightAngle = middleAngle - } - if rightAngle-leftAngle <= solarEclipsePartialFootprintPointTolerance { - break - } - } - best.angle = math.Mod(best.angle, 2*math.Pi) - return best -} - -func (solver solarEclipseSolver) shadowFootprintPointAt( - jd float64, - moon [3]float64, - axis solarEclipseAxis, - angle float64, - kind solarEclipseShadowKind, -) (SolarEclipsePathPoint, bool) { - cosAngle := math.Cos(angle) - sinAngle := math.Sin(angle) - radius := solver.shadowRadiusAt(moon[2], kind) - if radius <= 0 { - return SolarEclipsePathPoint{}, false - } - - var intersection solarEclipseLineIntersection - for i := 0; i < solarEclipsePartialFootprintIterationLimit; i++ { - x := moon[0] + radius*cosAngle - y := moon[1] + radius*sinAngle - intersection = solarEclipseLineEar2( - x, - y, - 2, - x, - y, - 0, - solarEclipseEarthPolarRatio, - 1, - axis, + end, actualEndStep, _ := solver.shadowFootprintsWithSpacing( + innerEndJDE, outerEndJDE, innerEndJDE, stepDays, + solarEclipseCentralBandBoundaryPoints, solarEclipseCentralShadow, + solarEclipseCentralBandTargetSpacingKM, ) - if !intersection.valid { - return SolarEclipsePathPoint{}, false - } - - nextRadius := solver.shadowRadiusAt(moon[2]-intersection.r2, kind) - if nextRadius <= 0 { - return SolarEclipsePathPoint{}, false - } - if math.Abs(nextRadius-radius) <= solarEclipsePartialFootprintPointTolerance { - radius = nextRadius - break - } - radius = nextRadius + return append(start, end...), math.Max(actualStartStep, actualEndStep) } - - x := moon[0] + radius*cosAngle - y := moon[1] + radius*sinAngle - intersection = solarEclipseLineEar2( - x, - y, - 2, - x, - y, - 0, - solarEclipseEarthPolarRatio, - 1, - axis, + if greatestJDE <= outerStartJDE || greatestJDE >= outerEndJDE { + greatestJDE = (outerStartJDE + outerEndJDE) / 2 + } + footprints, actualStep, _ := solver.shadowFootprintsWithSpacing( + outerStartJDE, outerEndJDE, greatestJDE, stepDays, + solarEclipseCentralBandBoundaryPoints, solarEclipseCentralShadow, + solarEclipseCentralBandTargetSpacingKM, ) - if !intersection.valid { - return SolarEclipsePathPoint{}, false - } - - longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) - sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) - return SolarEclipsePathPoint{ - JDE: jd, - Longitude: longitude, - Latitude: latitude, - SunAltitude: sunAltitudeRad / rad, - }, true -} - -func solarEclipsePartialBoundarySegments(samples []solarEclipsePartialBoundarySample) ([][]SolarEclipsePathPoint, bool) { - segments := make([][]SolarEclipsePathPoint, 0, 2) - var current []SolarEclipsePathPoint - allSamplesValid := len(samples) > 0 - for _, sample := range samples { - if !sample.ok { - allSamplesValid = false - segments = appendSolarEclipsePartialSegment(segments, current) - current = nil - continue - } - if len(current) > 0 && solarEclipsePathCrossesAntimeridian(current[len(current)-1], sample.point) { - segments = appendSolarEclipsePartialSegment(segments, current) - current = nil - } - current = append(current, sample.point) - } - segments = appendSolarEclipsePartialSegment(segments, current) - segments = mergeSolarEclipsePartialWrapSegment(segments, samples) - - if !allSamplesValid { - return segments, false - } - totalPoints := 0 - for _, segment := range segments { - totalPoints += len(segment) - } - if totalPoints < 3 { - return segments, false - } - if len(segments) == 1 && !solarEclipsePathCrossesAntimeridian(segments[0][len(segments[0])-1], segments[0][0]) { - segments[0] = append(segments[0], segments[0][0]) - } - return segments, true -} - -func appendSolarEclipsePartialSegment( - segments [][]SolarEclipsePathPoint, - segment []SolarEclipsePathPoint, -) [][]SolarEclipsePathPoint { - if len(segment) == 0 { - return segments - } - return append(segments, segment) -} - -func mergeSolarEclipsePartialWrapSegment( - segments [][]SolarEclipsePathPoint, - samples []solarEclipsePartialBoundarySample, -) [][]SolarEclipsePathPoint { - if len(segments) < 2 || len(samples) == 0 || !samples[0].ok || !samples[len(samples)-1].ok { - return segments - } - first := segments[0] - last := segments[len(segments)-1] - if solarEclipsePathCrossesAntimeridian(last[len(last)-1], first[0]) { - return segments - } - - merged := make([]SolarEclipsePathPoint, 0, len(last)+len(first)) - merged = append(merged, last...) - merged = append(merged, first...) - result := make([][]SolarEclipsePathPoint, 0, len(segments)-1) - result = append(result, merged) - result = append(result, segments[1:len(segments)-1]...) - return result -} - -func solarEclipsePathCrossesAntimeridian(a, b SolarEclipsePathPoint) bool { - return math.Abs(a.Longitude-b.Longitude) > 180 -} - -func solarEclipsePathDistanceKM(a, b SolarEclipsePathPoint) float64 { - lat1 := a.Latitude * rad - lat2 := b.Latitude * rad - dlat := lat2 - lat1 - dlon := solarEclipseNormalizeSignedRadians((b.Longitude - a.Longitude) * rad) - h := math.Sin(dlat/2)*math.Sin(dlat/2) + - math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2) - if h > 1 { - h = 1 - } - return 2 * solarEclipseEarthEquatorialRadiusKM * math.Asin(math.Sqrt(h)) + return footprints, actualStep } diff --git a/basic/solar_eclipse_path_geometry.go b/basic/solar_eclipse_path_geometry.go new file mode 100644 index 0000000..680b337 --- /dev/null +++ b/basic/solar_eclipse_path_geometry.go @@ -0,0 +1,1341 @@ +package basic + +import "math" + +func uniqueSolarEclipsePathTimes(times []float64) []float64 { + return movingDiskUniqueTimes(times) +} + +func (solver solarEclipseSolver) refineCentralPathSpacing(points []SolarEclipsePathPoint, targetSpacingKM float64) []SolarEclipsePathPoint { + if len(points) < 2 || targetSpacingKM <= 0 { + return points + } + + refined := make([]SolarEclipsePathPoint, 0, len(points)) + refined = append(refined, points[0]) + for i := 1; i < len(points); i++ { + refined = solver.appendRefinedCentralPathSegment(refined, points[i-1], points[i], targetSpacingKM, 0) + } + return refined +} + +func (solver solarEclipseSolver) refineCentralPathLimitSpacing( + northern, southern []SolarEclipsePathPoint, + centralBeginJDE, centralEndJDE, targetSpacingKM float64, +) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + if len(northern) < 2 || len(northern) != len(southern) || targetSpacingKM <= 0 { + return northern, southern + } + refinedNorth := make([]SolarEclipsePathPoint, 0, len(northern)) + refinedSouth := make([]SolarEclipsePathPoint, 0, len(southern)) + refinedNorth = append(refinedNorth, northern[0]) + refinedSouth = append(refinedSouth, southern[0]) + for index := 1; index < len(northern); index++ { + refinedNorth, refinedSouth = solver.appendRefinedCentralPathLimitSegment( + refinedNorth, refinedSouth, + northern[index-1], southern[index-1], + northern[index], southern[index], + centralBeginJDE, centralEndJDE, targetSpacingKM, 0, + ) + } + return normalizeSolarEclipseCentralLimitPairs(refinedNorth, refinedSouth) +} + +// normalizeSolarEclipsePathPointSeries removes numerical duplicate samples +// introduced by midpoint refinement. A public path is a time-ordered series; +// retaining a rounded duplicate makes GeoJSON/SVG consumers either reject the +// line or render a zero-length kink. +func normalizeSolarEclipsePathPointSeries(points []SolarEclipsePathPoint) []SolarEclipsePathPoint { + if len(points) < 2 { + return points + } + result := make([]SolarEclipsePathPoint, 0, len(points)) + for _, point := range points { + if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) { + continue + } + if len(result) == 0 { + result = append(result, point) + continue + } + last := result[len(result)-1] + if point.JDE <= last.JDE+solarEclipsePathDuplicateTimeDays { + if solarEclipsePathDistanceKM(last, point) <= 0.01 { + // Keep the later evaluation so its derived altitude/width is the + // one exposed at the surviving timestamp. + result[len(result)-1] = point + continue + } + // A branch change cannot be represented by a single public series; + // discard the numerically ambiguous sample rather than emitting a + // non-monotone line. + continue + } + result = append(result, point) + } + return result +} + +func normalizeSolarEclipseCentralLimitPairs( + northern, southern []SolarEclipsePathPoint, +) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + if len(northern) != len(southern) || len(northern) < 2 { + return nil, nil + } + resultNorth := make([]SolarEclipsePathPoint, 0, len(northern)) + resultSouth := make([]SolarEclipsePathPoint, 0, len(southern)) + for index := range northern { + north, south := northern[index], southern[index] + if !finite(north.JDE) || !finite(south.JDE) || + !finite(north.Longitude) || !finite(north.Latitude) || + !finite(south.Longitude) || !finite(south.Latitude) { + continue + } + if len(resultNorth) == 0 { + resultNorth = append(resultNorth, north) + resultSouth = append(resultSouth, south) + continue + } + lastNorth, lastSouth := resultNorth[len(resultNorth)-1], resultSouth[len(resultSouth)-1] + // Near a grazing polar contact, the limit solver can refine one logical + // instant through several numerically distinct branch solutions. Their + // JDEs differ by microseconds or milliseconds, while the longitude/latitude + // may jump to the opposite polar chart branch. Treat that interval as one + // sample; retaining both points creates a false edge in map geometry. + const nearDuplicateTimeDays = 100.0 / 86400000.0 + if north.JDE <= lastNorth.JDE+nearDuplicateTimeDays || + south.JDE <= lastSouth.JDE+nearDuplicateTimeDays { + if solarEclipsePathDistanceKM(lastNorth, north) <= 0.01 && + solarEclipsePathDistanceKM(lastSouth, south) <= 0.01 { + resultNorth[len(resultNorth)-1] = north + resultSouth[len(resultSouth)-1] = south + } + continue + } + resultNorth = append(resultNorth, north) + resultSouth = append(resultSouth, south) + } + if len(resultNorth) < 2 || len(resultNorth) != len(resultSouth) { + return nil, nil + } + return resultNorth, resultSouth +} + +func (solver solarEclipseSolver) appendRefinedCentralPathLimitSegment( + northern, southern []SolarEclipsePathPoint, + startNorth, startSouth, endNorth, endSouth SolarEclipsePathPoint, + centralBeginJDE, centralEndJDE, targetSpacingKM float64, + depth int, +) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + maximumDistance := math.Max( + solarEclipsePathDistanceKM(startNorth, endNorth), + solarEclipsePathDistanceKM(startSouth, endSouth), + ) + middleJDE := (startNorth.JDE + endNorth.JDE) / 2 + if depth >= solarEclipsePathMaxAdaptiveDepth || + maximumDistance <= targetSpacingKM || + middleJDE <= centralBeginJDE || middleJDE >= centralEndJDE { + return append(northern, endNorth), append(southern, endSouth) + } + middleCenter, centerOK := solver.centralPathPointAt(middleJDE) + if !centerOK { + return append(northern, endNorth), append(southern, endSouth) + } + before, beforeOK := solver.centralPathPointAt(middleJDE - solarEclipsePathVelocityStepDays) + after, afterOK := solver.centralPathPointAt(middleJDE + solarEclipsePathVelocityStepDays) + if !beforeOK { + before = middleCenter + } + if !afterOK { + after = middleCenter + } + first, second, limitsOK := solver.centralPathLimitsAtAlong(middleCenter, before, after) + if !limitsOK { + first, second, limitsOK = solver.centralPathLimitsAt(middleCenter) + } + if !limitsOK { + return append(northern, endNorth), append(southern, endSouth) + } + keepDistance := solarEclipsePathDistanceKM(startNorth, first) + + solarEclipsePathDistanceKM(startSouth, second) + + solarEclipsePathDistanceKM(first, endNorth) + + solarEclipsePathDistanceKM(second, endSouth) + swapDistance := solarEclipsePathDistanceKM(startNorth, second) + + solarEclipsePathDistanceKM(startSouth, first) + + solarEclipsePathDistanceKM(second, endNorth) + + solarEclipsePathDistanceKM(first, endSouth) + if swapDistance < keepDistance { + first, second = second, first + } + northern, southern = solver.appendRefinedCentralPathLimitSegment( + northern, southern, startNorth, startSouth, first, second, + centralBeginJDE, centralEndJDE, targetSpacingKM, depth+1, + ) + return solver.appendRefinedCentralPathLimitSegment( + northern, southern, first, second, endNorth, endSouth, + centralBeginJDE, centralEndJDE, targetSpacingKM, depth+1, + ) +} + +func (solver solarEclipseSolver) appendRefinedCentralPathSegment( + points []SolarEclipsePathPoint, + start, end SolarEclipsePathPoint, + targetSpacingKM float64, + depth int, +) []SolarEclipsePathPoint { + if depth >= solarEclipsePathMaxAdaptiveDepth { + return append(points, end) + } + + segmentDistanceKM := solarEclipsePathDistanceKM(start, end) + midJDE := (start.JDE + end.JDE) / 2 + mid, ok := solver.centralPathPointAt(midJDE) + if !ok { + return append(points, end) + } + // The renderer joins samples with the shorter great-circle arc. Compare + // the physical midpoint with that arc's midpoint so a short but sharply + // turning segment is refined even when its endpoints are close together. + geodesicMiddle := solarEclipsePathSphericalInterpolate(start, end, 0.5) + curvatureErrorKM := solarEclipsePathDistanceKM(mid, geodesicMiddle) + curvatureToleranceKM := math.Max( + solarEclipsePathMinimumCurvatureKM, + targetSpacingKM*solarEclipsePathAdaptiveCurvatureFraction, + ) + if segmentDistanceKM <= targetSpacingKM && curvatureErrorKM <= curvatureToleranceKM { + return append(points, end) + } + + points = solver.appendRefinedCentralPathSegment(points, start, mid, targetSpacingKM, depth+1) + return solver.appendRefinedCentralPathSegment(points, mid, end, targetSpacingKM, depth+1) +} + +func (solver solarEclipseSolver) centralPathPointAt(jd float64) (SolarEclipsePathPoint, bool) { + moon := solver.besselMoonAt(jd) + axis := solver.besselAxisAt(jd) + intersection := solarEclipseLineEar2( + moon[0], + moon[1], + 2, + moon[0], + moon[1], + 0, + solarEclipseEarthPolarRatio, + 1, + axis, + ) + if !intersection.valid { + return SolarEclipsePathPoint{}, false + } + + longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) + sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) + radii := solver.shadowRadiiAt(moon[2] - intersection.r2) + + widthKM := 0.0 + if math.Abs(math.Sin(sunAltitudeRad)) > 1e-12 { + widthKM = math.Abs(2*radii.umbraRadius*solarEclipseEarthEquatorialRadiusKM) / math.Abs(math.Sin(sunAltitudeRad)) + } + + return SolarEclipsePathPoint{ + JDE: jd, + Longitude: longitude, + Latitude: latitude, + SunAltitude: sunAltitudeRad / rad, + WidthKM: widthKM, + }, true +} + +func (solver solarEclipseSolver) centralPathLimits(centerLine []SolarEclipsePathPoint) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + northern, southern, paired := solver.centralPathLimitPairs(centerLine) + return solarEclipseFilterCentralPathLimits(northern, southern, paired) +} + +func solarEclipseFilterCentralPathLimits( + northern, southern []SolarEclipsePathPoint, + paired []bool, +) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint) { + filteredNorth := make([]SolarEclipsePathPoint, 0, len(northern)) + filteredSouth := make([]SolarEclipsePathPoint, 0, len(southern)) + for index := range northern { + if !paired[index] { + continue + } + filteredNorth = append(filteredNorth, northern[index]) + filteredSouth = append(filteredSouth, southern[index]) + } + // Limits are optional derived lines. A grazing or very narrow path may + // yield one numerically valid cross-section even when its center line is + // usable; exposing that singleton would make GeoJSON consumers reject the + // otherwise valid event as a line geometry. + if len(filteredNorth) < 2 || len(filteredNorth) != len(filteredSouth) { + return nil, nil + } + return filteredNorth, filteredSouth +} + +// centralPathLimitPairs solves the paired cross-section at every centerline +// sample and keeps the slice aligned with the center line: paired[index] is +// false where no stable pair exists, which is exactly where the analytic +// 2r/sin(altitude) width diverges and the width contract asks for 0. +func (solver solarEclipseSolver) centralPathLimitPairs( + centerLine []SolarEclipsePathPoint, +) ([]SolarEclipsePathPoint, []SolarEclipsePathPoint, []bool) { + northern := make([]SolarEclipsePathPoint, len(centerLine)) + southern := make([]SolarEclipsePathPoint, len(centerLine)) + paired := make([]bool, len(centerLine)) + var previousNorth, previousSouth SolarEclipsePathPoint + havePrevious := false + for index, center := range centerLine { + beforeIndex, afterIndex := index-1, index+1 + if beforeIndex < 0 { + beforeIndex = 0 + } + if afterIndex >= len(centerLine) { + afterIndex = len(centerLine) - 1 + } + first, second, ok := solver.centralPathLimitsAtAlong( + center, + centerLine[beforeIndex], + centerLine[afterIndex], + ) + if !ok { + first, second, ok = solver.centralPathLimitsAt(center) + } + if !ok { + continue + } + north, south := first, second + if !havePrevious { + if second.Latitude > first.Latitude { + north, south = second, first + } + } else { + keepDistance := solarEclipsePathDistanceKM(first, previousNorth) + solarEclipsePathDistanceKM(second, previousSouth) + swapDistance := solarEclipsePathDistanceKM(second, previousNorth) + solarEclipsePathDistanceKM(first, previousSouth) + if swapDistance < keepDistance { + north, south = second, first + } + } + northern[index] = north + southern[index] = south + paired[index] = true + previousNorth, previousSouth = north, south + havePrevious = true + } + return northern, southern, paired +} + +func (solver solarEclipseSolver) centralPathLimitsAtAlong( + center, before, after SolarEclipsePathPoint, +) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) { + footprint := solver.shadowFootprintAtWithSpacing( + center.JDE, + solarEclipseCentralBandBoundaryPoints, + solarEclipseCentralShadow, + 0, + ) + if len(footprint.Boundaries) == 0 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + latitude := center.Latitude * rad + tangentX := math.Remainder(after.Longitude-before.Longitude, 360) * math.Cos(latitude) + tangentY := after.Latitude - before.Latitude + tangentLength := math.Hypot(tangentX, tangentY) + if tangentLength <= 1e-12 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + normalX, normalY := -tangentY/tangentLength, tangentX/tangentLength + axis := solver.besselAxisAt(center.JDE) + var candidates []SolarEclipsePathPoint + appendCrossing := func(first, second SolarEclipsePathPoint) { + firstResidual := solarEclipseAlongTrackResidual(first, center, tangentX, tangentY) + secondResidual := solarEclipseAlongTrackResidual(second, center, tangentX, tangentY) + if !finite(firstResidual) || !finite(secondResidual) { + return + } + if math.Abs(firstResidual) <= 1e-10 { + candidates = append(candidates, first) + } + if firstResidual*secondResidual > 0 || math.Abs(secondResidual-firstResidual) <= 1e-12 { + return + } + fraction := -firstResidual / (secondResidual - firstResidual) + if fraction <= 0 || fraction >= 1 { + return + } + longitude := normalizeLongitude(first.Longitude + math.Remainder(second.Longitude-first.Longitude, 360)*fraction) + latitudeValue := first.Latitude + (second.Latitude-first.Latitude)*fraction + candidates = append(candidates, SolarEclipsePathPoint{ + JDE: center.JDE, + Longitude: longitude, + Latitude: latitudeValue, + SunAltitude: solarEclipseSunAltitudeAtGreatest(center.JDE, longitude, latitudeValue, axis.gst) / rad, + WidthKM: center.WidthKM, + }) + } + for _, segment := range footprint.Boundaries { + for index := 1; index < len(segment); index++ { + appendCrossing(segment[index-1], segment[index]) + } + } + if footprint.Closed && len(footprint.Boundaries) == 1 && len(footprint.Boundaries[0]) > 2 { + segment := footprint.Boundaries[0] + appendCrossing(segment[len(segment)-1], segment[0]) + } + if len(candidates) < 2 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + minimum, maximum := math.Inf(1), math.Inf(-1) + var minimumPoint, maximumPoint SolarEclipsePathPoint + for _, point := range candidates { + value := solarEclipseCrossTrackResidual(point, center, normalX, normalY) + if value < minimum { + minimum, minimumPoint = value, point + } + if value > maximum { + maximum, maximumPoint = value, point + } + } + if !finite(minimum) || !finite(maximum) || maximum-minimum <= 1e-8 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + return maximumPoint, minimumPoint, true +} + +func solarEclipseAlongTrackResidual( + point, center SolarEclipsePathPoint, tangentX, tangentY float64, +) float64 { + latitude := center.Latitude * rad + deltaX := math.Remainder(point.Longitude-center.Longitude, 360) * math.Cos(latitude) + deltaY := point.Latitude - center.Latitude + return deltaX*tangentX + deltaY*tangentY +} + +func solarEclipseCrossTrackResidual( + point, center SolarEclipsePathPoint, normalX, normalY float64, +) float64 { + latitude := center.Latitude * rad + deltaX := math.Remainder(point.Longitude-center.Longitude, 360) * math.Cos(latitude) + deltaY := point.Latitude - center.Latitude + return deltaX*normalX + deltaY*normalY +} + +func (solver solarEclipseSolver) centralPathLimitsAt(center SolarEclipsePathPoint) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) { + moon, axis, sun := solver.besselGeometryAt(center.JDE) + + vx, vy, speed := solver.besselVelocityXYAt(center.JDE) + if speed <= 0 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + perpX := -vy / speed + perpY := vx / speed + angle := math.Atan2(perpY, perpX) + first, okFirst := solver.shadowFootprintPointAt( + center.JDE, moon, axis, sun, angle, solarEclipseCentralShadow, + ) + second, okSecond := solver.shadowFootprintPointAt( + center.JDE, moon, axis, sun, angle+math.Pi, solarEclipseCentralShadow, + ) + if !okFirst || !okSecond { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + first.WidthKM = center.WidthKM + second.WidthKM = center.WidthKM + if first.Latitude >= second.Latitude { + return first, second, true + } + return second, first, true +} + +func (solver solarEclipseSolver) besselVelocityXYAt(jd float64) (float64, float64, float64) { + before := solver.besselMoonAt(jd - solarEclipsePathVelocityStepDays) + after := solver.besselMoonAt(jd + solarEclipsePathVelocityStepDays) + vx := (after[0] - before[0]) / (2 * solarEclipsePathVelocityStepDays) + vy := (after[1] - before[1]) / (2 * solarEclipsePathVelocityStepDays) + return vx, vy, math.Hypot(vx, vy) +} + +func solarEclipsePathPointFromBesselXY(jd, x, y float64, axis solarEclipseAxis) (SolarEclipsePathPoint, bool) { + longitude, latitude, ok := solarEclipseBesselXYToGeodetic(x, y, axis, true) + if !ok { + return SolarEclipsePathPoint{}, false + } + sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) + return SolarEclipsePathPoint{ + JDE: jd, + Longitude: longitude, + Latitude: latitude, + SunAltitude: sunAltitudeRad / rad, + }, true +} + +func (solver solarEclipseSolver) shadowContactPair( + greatestJDE float64, + kind solarEclipseShadowKind, + internal bool, +) (SolarEclipsePathPoint, SolarEclipsePathPoint, bool) { + middleResidual, ok := solver.shadowContactResidual(greatestJDE, kind, internal) + if !ok || middleResidual > 0 { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + firstJDE, firstOK := solver.shadowContactRoot(greatestJDE, -1, middleResidual, kind, internal) + lastJDE, lastOK := solver.shadowContactRoot(greatestJDE, 1, middleResidual, kind, internal) + if !firstOK || !lastOK { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + first, firstOK := solver.shadowContactPointAt(firstJDE, kind, internal) + last, lastOK := solver.shadowContactPointAt(lastJDE, kind, internal) + if !firstOK || !lastOK { + return SolarEclipsePathPoint{}, SolarEclipsePathPoint{}, false + } + return first, last, true +} + +func (solver solarEclipseSolver) shadowContactRoot( + greatestJDE float64, + direction float64, + middleResidual float64, + kind solarEclipseShadowKind, + internal bool, +) (float64, bool) { + insideJDE := greatestJDE + insideResidual := middleResidual + for span := solarEclipseShadowContactSearchStepDays; span <= solarEclipseShadowContactSearchSpanDays; span += solarEclipseShadowContactSearchStepDays { + outsideJDE := greatestJDE + direction*span + outsideResidual, ok := solver.shadowContactResidual(outsideJDE, kind, internal) + if !ok { + continue + } + if outsideResidual >= 0 { + leftJDE, rightJDE := outsideJDE, insideJDE + leftResidual, rightResidual := outsideResidual, insideResidual + if leftJDE > rightJDE { + leftJDE, rightJDE = rightJDE, leftJDE + leftResidual, rightResidual = rightResidual, leftResidual + } + for rightJDE-leftJDE > solarEclipseShadowContactToleranceDays { + middleJDE := (leftJDE + rightJDE) / 2 + residual, valid := solver.shadowContactResidual(middleJDE, kind, internal) + if !valid { + return 0, false + } + if (residual >= 0) == (leftResidual >= 0) { + leftJDE, leftResidual = middleJDE, residual + } else { + rightJDE, rightResidual = middleJDE, residual + } + } + return (leftJDE + rightJDE) / 2, true + } + insideJDE, insideResidual = outsideJDE, outsideResidual + } + return 0, false +} + +func (solver solarEclipseSolver) shadowContactResidual( + jd float64, + kind solarEclipseShadowKind, + internal bool, +) (float64, bool) { + if kind == solarEclipseCentralShadow && solver.exactCentralContact && !internal { + return solver.shadowContactResidualExact(jd) + } + moon := solver.besselMoonAt(jd) + distanceSquared := moon[0]*moon[0] + moon[1]*moon[1] + if distanceSquared <= 0 { + return 0, false + } + radius := solver.shadowRadiusAt(moon[2], kind) + if radius <= 0 { + return 0, false + } + earthRadius := 1 - (1/solarEclipseEarthPolarRatioSquared-1)*moon[1]*moon[1]/distanceSquared/2 + limit := earthRadius + radius + if internal { + limit = earthRadius - radius + } + if limit <= 0 { + return 0, false + } + return math.Sqrt(distanceSquared) - limit, true +} + +func (solver solarEclipseSolver) shadowContactResidualExact(jd float64) (float64, bool) { + _, value, ok := solver.shadowContactMaximum(jd) + return -value, ok +} + +func (solver solarEclipseSolver) shadowContactMaximum(jd float64) (float64, float64, bool) { + const samples = 32 + step := 2 * math.Pi / samples + bestIndex := -1 + bestValue := math.Inf(-1) + for index := 0; index < samples; index++ { + value, ok := solver.shadowBoundaryDiscriminant(jd, float64(index)*step) + if !ok { + continue + } + if value > bestValue { + bestIndex, bestValue = index, value + } + } + if bestIndex < 0 { + return 0, 0, false + } + left := float64(bestIndex)*step - step + right := float64(bestIndex)*step + step + valueAt := func(angle float64) float64 { + value, ok := solver.shadowBoundaryDiscriminant(jd, angle) + if !ok { + return math.Inf(-1) + } + return value + } + // Golden-section maximization removes the polar effective-radius + // approximation from contact times while retaining a bounded cost. + golden := (math.Sqrt(5) - 1) / 2 + x1 := right - golden*(right-left) + x2 := left + golden*(right-left) + f1, f2 := valueAt(x1), valueAt(x2) + for iteration := 0; iteration < 32; iteration++ { + if f1 < f2 { + left, x1, f1 = x1, x2, f2 + x2 = left + golden*(right-left) + f2 = valueAt(x2) + } else { + right, x2, f2 = x2, x1, f1 + x1 = right - golden*(right-left) + f1 = valueAt(x1) + } + } + bestAngle := float64(bestIndex) * step + if f1 > bestValue { + bestAngle, bestValue = x1, f1 + } + if f2 > bestValue { + bestAngle, bestValue = x2, f2 + } + return bestAngle, bestValue, true +} + +func (solver solarEclipseSolver) shadowBoundaryDiscriminant(jd, angle float64) (float64, bool) { + moon, axis, _ := solver.besselGeometryAt(jd) + radius := solver.shadowRadiusAt(moon[2], solarEclipseCentralShadow) + if radius <= 0 { + return 0, false + } + cosAngle, sinAngle := math.Cos(angle), math.Sin(angle) + best := math.Inf(-1) + for iteration := 0; iteration < solarEclipsePartialFootprintIterationLimit; iteration++ { + x, y := moon[0]+radius*cosAngle, moon[1]+radius*sinAngle + discriminant := solarEclipseLineEllipsoidDiscriminant( + x, y, 2, x, y, 0, solarEclipseEarthPolarRatio, 1, axis, + ) + best = discriminant + if discriminant < 0 { + return discriminant, true + } + intersection := solarEclipseLineEar2( + x, y, 2, x, y, 0, solarEclipseEarthPolarRatio, 1, axis, + ) + if !intersection.valid { + return discriminant, true + } + nextRadius := solver.shadowRadiusAt(moon[2]-intersection.r2, solarEclipseCentralShadow) + if nextRadius <= 0 { + return discriminant, true + } + if math.Abs(nextRadius-radius) <= solarEclipsePartialFootprintPointTolerance { + break + } + radius = nextRadius + } + return best, true +} + +func solarEclipseLineEllipsoidDiscriminant( + x1, y1, z1, x2, y2, z2, polarRatio, radius float64, + axis solarEclipseAxis, +) float64 { + cosTilt, sinTilt := math.Cos(axis.tilt), math.Sin(axis.tilt) + x1Rot := x1 + y1Rot := cosTilt*y1 - sinTilt*z1 + z1Rot := sinTilt*y1 + cosTilt*z1 + x2Rot := x2 + y2Rot := cosTilt*y2 - sinTilt*z2 + z2Rot := sinTilt*y2 + cosTilt*z2 + dx, dy, dz := x2Rot-x1Rot, y2Rot-y1Rot, z2Rot-z1Rot + polarRatioSquared := polarRatio * polarRatio + a := dx*dx + dy*dy + dz*dz/polarRatioSquared + b := x1Rot*dx + y1Rot*dy + z1Rot*dz/polarRatioSquared + c := x1Rot*x1Rot + y1Rot*y1Rot + z1Rot*z1Rot/polarRatioSquared - radius*radius + return b*b - a*c +} + +func (solver solarEclipseSolver) shadowContactPointAt( + jd float64, + kind solarEclipseShadowKind, + internal bool, +) (SolarEclipsePathPoint, bool) { + if kind == solarEclipseCentralShadow && solver.exactCentralContact && !internal { + angle, _, ok := solver.shadowContactMaximum(jd) + if !ok { + return SolarEclipsePathPoint{}, false + } + if point, pointOK := solver.centralShadowPointAt(jd, angle); pointOK { + return point, true + } + for _, offset := range []float64{-1e-8, 1e-8, -1e-7, 1e-7} { + offsetJDE := jd + offset + offsetAngle, _, maximumOK := solver.shadowContactMaximum(offsetJDE) + if !maximumOK { + continue + } + if point, pointOK := solver.centralShadowPointAt(offsetJDE, offsetAngle); pointOK { + point.JDE = jd + return point, true + } + } + return SolarEclipsePathPoint{}, false + } + moon := solver.besselMoonAt(jd) + distance := math.Hypot(moon[0], moon[1]) + if distance <= 0 || solver.shadowRadiusAt(moon[2], kind) <= 0 { + return SolarEclipsePathPoint{}, false + } + axis := solver.besselAxisAt(jd) + unitX, unitY := moon[0]/distance, moon[1]/distance + insideScale, outsideScale := 0.0, 1.1 + var intersection solarEclipseLineIntersection + for iteration := 0; iteration < 48; iteration++ { + scale := (insideScale + outsideScale) / 2 + candidate := solarEclipseLineEar2( + scale*unitX, scale*unitY, 2, + scale*unitX, scale*unitY, 0, + solarEclipseEarthPolarRatio, 1, axis, + ) + if candidate.valid { + insideScale = scale + intersection = candidate + } else { + outsideScale = scale + } + } + if !intersection.valid { + return SolarEclipsePathPoint{}, false + } + longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) + sunAltitudeRad := solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) + return SolarEclipsePathPoint{ + JDE: jd, + Longitude: longitude, + Latitude: latitude, + SunAltitude: sunAltitudeRad / rad, + }, true +} + +func (solver solarEclipseSolver) shadowRadiusAt(moonBesselZ float64, kind solarEclipseShadowKind) float64 { + radii := solver.shadowRadiiAt(moonBesselZ) + if kind == solarEclipseCentralShadow { + return radii.absUmbraRadius + } + return radii.penumbraRadius +} + +func (solver solarEclipseSolver) partialFootprintAt(jd float64, boundaryPoints int) SolarEclipsePartialFootprint { + return solver.shadowFootprintAt(jd, boundaryPoints, solarEclipsePenumbralShadow) +} + +func (solver solarEclipseSolver) shadowFootprintAt( + jd float64, + boundaryPoints int, + kind solarEclipseShadowKind, +) SolarEclipsePartialFootprint { + return solver.shadowFootprintAtWithSpacing(jd, boundaryPoints, kind, 0) +} + +func (solver solarEclipseSolver) shadowFootprintAtWithSpacing( + jd float64, + boundaryPoints int, + kind solarEclipseShadowKind, + targetSpacingKM float64, +) SolarEclipsePartialFootprint { + moon, axis, sun := solver.besselGeometryAt(jd) + return solver.shadowFootprintAtWithGeometry( + jd, moon, axis, sun, boundaryPoints, kind, targetSpacingKM, + ) +} + +// shadowFootprintAtWithGeometry 用调用方给定的贝塞尔几何求瞬时足迹。显式 ΔT 的单时刻 +// 入口走这条路径:几何由调用方按自己的 ΔT 构造,避免落到进程级 ΔT 上。 +// shadowFootprintAtWithGeometry solves one instantaneous footprint from precomputed +// Besselian geometry, so a caller with an explicit ΔT never falls back to the global one. +func (solver solarEclipseSolver) shadowFootprintAtWithGeometry( + jd float64, + moon [3]float64, + axis solarEclipseAxis, + sun [3]float64, + boundaryPoints int, + kind solarEclipseShadowKind, + targetSpacingKM float64, +) SolarEclipsePartialFootprint { + samples := make([]solarEclipsePartialBoundarySample, boundaryPoints) + for i := range samples { + angle := 2 * math.Pi * float64(i) / float64(boundaryPoints) + point, ok := solver.shadowFootprintPointAt(jd, moon, axis, sun, angle, kind) + samples[i] = solarEclipsePartialBoundarySample{ + point: point, + ok: ok, + angle: angle, + } + } + samples = solver.refineShadowFootprintTransitions(jd, moon, axis, sun, samples, kind) + if targetSpacingKM > 0 { + samples = solver.refineShadowFootprintSpacing( + jd, moon, axis, sun, samples, kind, targetSpacingKM, + ) + } + + boundaries, closed := solarEclipsePartialBoundarySegments(samples) + footprint := SolarEclipsePartialFootprint{ + JDE: jd, + Boundaries: boundaries, + Closed: closed, + } + if !closed { + // The sampled rim stops where the shooting gives up, which can be tens of + // kilometres inside the horizon. Recover the exact tangency points so the + // caller can close the region with a horizon arc that really meets the + // physical boundary; Boundaries itself stays untouched so every existing + // consumer of the open rim keeps its geometry. + footprint.HorizonEnds = solver.shadowFootprintHorizonEnds( + jd, moon, axis, sun, samples, boundaries, kind, + ) + } + return footprint +} + +// shadowFootprintHorizonEnds 求未闭合足迹边界两端在地平圈上的擦地点。 +// shadowFootprintHorizonEnds returns the horizon tangency point at each end of an +// open footprint boundary. +func (solver solarEclipseSolver) shadowFootprintHorizonEnds( + jd float64, + moon [3]float64, + axis solarEclipseAxis, + sun [3]float64, + samples []solarEclipsePartialBoundarySample, + boundaries [][]SolarEclipsePathPoint, + kind solarEclipseShadowKind, +) []SolarEclipsePathPoint { + if len(samples) < 2 || len(boundaries) == 0 || len(boundaries[0]) == 0 { + return nil + } + lastSegment := boundaries[len(boundaries)-1] + if len(lastSegment) == 0 { + return nil + } + // A footprint can be cut by the horizon along more than one arc (a wide + // penumbra near the terminator has two), so the two ends of the joined + // boundary are the first and the last ok/!ok transition rather than an + // arbitrary pair of them. + firstIndex, lastIndex := -1, -1 + for index := range samples { + if samples[index].ok == samples[(index+1)%len(samples)].ok { + continue + } + if firstIndex < 0 { + firstIndex = index + } + lastIndex = index + } + if firstIndex < 0 || lastIndex == firstIndex { + return nil + } + ends := make([]SolarEclipsePathPoint, 0, 2) + for _, index := range []int{firstIndex, lastIndex} { + sample, next := samples[index], samples[(index+1)%len(samples)] + // The transition samples are already bisected in azimuth by + // refineShadowFootprintTransitions, so the valid side of the pair is the + // azimuth that carries the tangency. + angle := sample.angle + if !sample.ok { + angle = next.angle + } + point, ok := solver.shadowFootprintHorizonEndAt(jd, moon, axis, sun, angle, kind) + if !ok { + return nil + } + ends = append(ends, point) + } + start := boundaries[0][0] + if solarEclipsePathDistanceKM(ends[0], start) > solarEclipsePathDistanceKM(ends[1], start) { + ends[0], ends[1] = ends[1], ends[0] + } + return ends +} + +// shadowFootprintHorizonEndAt 在给定方位上求阴影锥面与地表的切点。该方位由采样 +// 的 ok/!ok 转移给出,切点就是本影(或半影)边界真正终止的位置:轴线平行线与椭球 +// 相切,因此当地太阳高度为 0,也就是落在该时刻的地平圈上。 +// shadowFootprintHorizonEndAt solves the cone/ground tangency point on one azimuth. +// The azimuth comes from the sampled ok/!ok transition, and the tangency point is +// where the boundary really ends: the axis-parallel line grazes the ellipsoid, so +// the local solar altitude is zero and the point lies on the horizon. +func (solver solarEclipseSolver) shadowFootprintHorizonEndAt( + jd float64, + moon [3]float64, + axis solarEclipseAxis, + sun [3]float64, + angle float64, + kind solarEclipseShadowKind, +) (SolarEclipsePathPoint, bool) { + radius := solver.shadowRadiusAt(moon[2], kind) + if radius <= 0 { + return SolarEclipsePathPoint{}, false + } + cosAngle, sinAngle := math.Cos(angle), math.Sin(angle) + discriminantAt := func(value float64) float64 { + x := moon[0] + value*cosAngle + y := moon[1] + value*sinAngle + return solarEclipseLineEllipsoidDiscriminant( + x, y, 2, x, y, 0, solarEclipseEarthPolarRatio, 1, axis, + ) + } + if discriminantAt(radius) < 0 { + return SolarEclipsePathPoint{}, false + } + // The transition azimuth is where the sampled radius meets an edge of the + // silhouette, and that edge can be either the inner or the outer one: a shadow + // whose axis still crosses the Earth is cut outwards, one whose axis misses the + // Earth is cut inwards, and a large penumbral circle can meet either. Search + // both directions and keep the nearer sign change, which is the edge the + // sampled radius actually ran into. + valid, invalid := radius, 0.0 + found := false + nearest := math.Inf(1) + for offset := solarEclipseHorizonEndSearchStep; offset <= solarEclipseHorizonEndSearchLimit; offset *= 2 { + if discriminantAt(radius+offset) < 0 { + nearest, invalid, found = offset, radius+offset, true + break + } + } + for offset := solarEclipseHorizonEndSearchStep; offset <= radius; offset *= 2 { + candidate := radius - offset + if candidate <= 0 { + break + } + if discriminantAt(candidate) < 0 { + if offset < nearest { + invalid, found = candidate, true + } + break + } + } + if !found { + return SolarEclipsePathPoint{}, false + } + // valid stays on the disc >= 0 side and invalid on the other one; the bracket + // order differs between the outward and the inward cut, so the two ends are + // never sorted against each other. + inside, outside := valid, invalid + for round := 0; round < solarEclipseHorizonEndBisectionRounds; round++ { + middle := (inside + outside) / 2 + if discriminantAt(middle) >= 0 { + inside = middle + continue + } + outside = middle + } + tangent := inside + intersection := solarEclipseLineEar2( + moon[0]+tangent*cosAngle, moon[1]+tangent*sinAngle, 2, + moon[0]+tangent*cosAngle, moon[1]+tangent*sinAngle, 0, + solarEclipseEarthPolarRatio, 1, axis, + ) + if !intersection.valid { + return SolarEclipsePathPoint{}, false + } + longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) + sunAltitudeRad := solarEclipseSunAltitudeFromEquatorial(sun, longitude, latitude, axis.gst) + return SolarEclipsePathPoint{ + JDE: jd, + Longitude: longitude, + Latitude: latitude, + SunAltitude: sunAltitudeRad / rad, + }, true +} + +func (solver solarEclipseSolver) refineShadowFootprintSpacing( + jd float64, + moon [3]float64, + axis solarEclipseAxis, + sun [3]float64, + samples []solarEclipsePartialBoundarySample, + kind solarEclipseShadowKind, + targetSpacingKM float64, +) []solarEclipsePartialBoundarySample { + if len(samples) < 2 || targetSpacingKM <= 0 { + return samples + } + result := make([]solarEclipsePartialBoundarySample, 0, len(samples)) + for index, left := range samples { + right := samples[(index+1)%len(samples)] + if index == len(samples)-1 { + right.angle += 2 * math.Pi + } else if right.angle < left.angle { + right.angle += 2 * math.Pi + } + result = append(result, left) + result = solver.appendRefinedShadowFootprintInterval( + result, jd, moon, axis, sun, left, right, kind, targetSpacingKM, 0, + ) + } + return result +} + +func (solver solarEclipseSolver) appendRefinedShadowFootprintInterval( + result []solarEclipsePartialBoundarySample, + jd float64, + moon [3]float64, + axis solarEclipseAxis, + sun [3]float64, + left, right solarEclipsePartialBoundarySample, + kind solarEclipseShadowKind, + targetSpacingKM float64, + depth int, +) []solarEclipsePartialBoundarySample { + if depth >= solarEclipseShadowFootprintAdaptiveMaxDepth || !left.ok || !right.ok || + solarEclipsePathDistanceKM(left.point, right.point) <= targetSpacingKM { + return result + } + angle := (left.angle + right.angle) / 2 + point, ok := solver.shadowFootprintPointAt( + jd, moon, axis, sun, math.Mod(angle, 2*math.Pi), kind, + ) + middle := solarEclipsePartialBoundarySample{point: point, ok: ok, angle: angle} + if !middle.ok { + return result + } + result = solver.appendRefinedShadowFootprintInterval( + result, jd, moon, axis, sun, left, middle, kind, targetSpacingKM, depth+1, + ) + result = append(result, middle) + return solver.appendRefinedShadowFootprintInterval( + result, jd, moon, axis, sun, middle, right, kind, targetSpacingKM, depth+1, + ) +} + +type solarEclipsePartialBoundarySample struct { + point SolarEclipsePathPoint + ok bool + angle float64 +} + +func (solver solarEclipseSolver) refineShadowFootprintTransitions( + jd float64, + moon [3]float64, + axis solarEclipseAxis, + sun [3]float64, + samples []solarEclipsePartialBoundarySample, + kind solarEclipseShadowKind, +) []solarEclipsePartialBoundarySample { + if len(samples) < 2 { + return samples + } + result := make([]solarEclipsePartialBoundarySample, 0, len(samples)+4) + for index, sample := range samples { + result = append(result, sample) + next := samples[(index+1)%len(samples)] + if sample.ok == next.ok { + continue + } + nextAngle := next.angle + if index == len(samples)-1 { + nextAngle += 2 * math.Pi + } + refined := solver.refineShadowFootprintTransition(jd, moon, axis, sun, sample, next, nextAngle, kind) + result = append(result, refined) + } + return result +} + +func (solver solarEclipseSolver) refineShadowFootprintTransition( + jd float64, + moon [3]float64, + axis solarEclipseAxis, + sun [3]float64, + first, second solarEclipsePartialBoundarySample, + secondAngle float64, + kind solarEclipseShadowKind, +) solarEclipsePartialBoundarySample { + leftAngle := first.angle + rightAngle := secondAngle + leftOK := first.ok + best := first + if second.ok { + best = second + best.angle = secondAngle + } + for iteration := 0; iteration < solarEclipsePartialFootprintTransitionIterations; iteration++ { + middleAngle := (leftAngle + rightAngle) / 2 + evaluationAngle := math.Mod(middleAngle, 2*math.Pi) + point, ok := solver.shadowFootprintPointAt(jd, moon, axis, sun, evaluationAngle, kind) + middle := solarEclipsePartialBoundarySample{point: point, ok: ok, angle: middleAngle} + if ok { + best = middle + } + if ok == leftOK { + leftAngle = middleAngle + } else { + rightAngle = middleAngle + } + if rightAngle-leftAngle <= solarEclipsePartialFootprintPointTolerance { + break + } + } + best.angle = math.Mod(best.angle, 2*math.Pi) + return best +} + +func (solver solarEclipseSolver) shadowFootprintPointAt( + jd float64, + moon [3]float64, + axis solarEclipseAxis, + sun [3]float64, + angle float64, + kind solarEclipseShadowKind, +) (SolarEclipsePathPoint, bool) { + cosAngle := math.Cos(angle) + sinAngle := math.Sin(angle) + radius := solver.shadowRadiusAt(moon[2], kind) + if radius <= 0 { + return SolarEclipsePathPoint{}, false + } + + var intersection solarEclipseLineIntersection + for i := 0; i < solarEclipsePartialFootprintIterationLimit; i++ { + x := moon[0] + radius*cosAngle + y := moon[1] + radius*sinAngle + intersection = solarEclipseLineEar2( + x, + y, + 2, + x, + y, + 0, + solarEclipseEarthPolarRatio, + 1, + axis, + ) + if !intersection.valid { + return SolarEclipsePathPoint{}, false + } + + nextRadius := solver.shadowRadiusAt(moon[2]-intersection.r2, kind) + if nextRadius <= 0 { + return SolarEclipsePathPoint{}, false + } + if math.Abs(nextRadius-radius) <= solarEclipsePartialFootprintPointTolerance { + radius = nextRadius + break + } + radius = nextRadius + } + + x := moon[0] + radius*cosAngle + y := moon[1] + radius*sinAngle + intersection = solarEclipseLineEar2( + x, + y, + 2, + x, + y, + 0, + solarEclipseEarthPolarRatio, + 1, + axis, + ) + if !intersection.valid { + return SolarEclipsePathPoint{}, false + } + + longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis) + sunAltitudeRad := solarEclipseSunAltitudeFromEquatorial(sun, longitude, latitude, axis.gst) + return SolarEclipsePathPoint{ + JDE: jd, + Longitude: longitude, + Latitude: latitude, + SunAltitude: sunAltitudeRad / rad, + }, true +} + +func solarEclipsePartialBoundarySegments(samples []solarEclipsePartialBoundarySample) ([][]SolarEclipsePathPoint, bool) { + segments := make([][]SolarEclipsePathPoint, 0, 2) + var current []SolarEclipsePathPoint + allSamplesValid := len(samples) > 0 + for _, sample := range samples { + if !sample.ok { + allSamplesValid = false + segments = appendSolarEclipsePartialSegment(segments, current) + current = nil + continue + } + if len(current) > 0 && solarEclipsePathCrossesAntimeridian(current[len(current)-1], sample.point) { + firstCrossing, secondCrossing := solarEclipsePathAntimeridianCrossings( + current[len(current)-1], sample.point, + ) + current = append(current, firstCrossing) + segments = appendSolarEclipsePartialSegment(segments, current) + current = []SolarEclipsePathPoint{secondCrossing} + } + current = append(current, sample.point) + } + segments = appendSolarEclipsePartialSegment(segments, current) + segments = mergeSolarEclipsePartialWrapSegment(segments, samples) + + if !allSamplesValid { + return segments, false + } + totalPoints := 0 + for _, segment := range segments { + totalPoints += len(segment) + } + if totalPoints < 3 { + return segments, false + } + if len(segments) == 1 && !solarEclipsePathCrossesAntimeridian(segments[0][len(segments[0])-1], segments[0][0]) { + segments[0] = append(segments[0], segments[0][0]) + } + return segments, true +} + +func appendSolarEclipsePartialSegment( + segments [][]SolarEclipsePathPoint, + segment []SolarEclipsePathPoint, +) [][]SolarEclipsePathPoint { + if len(segment) == 0 { + return segments + } + return append(segments, segment) +} + +func mergeSolarEclipsePartialWrapSegment( + segments [][]SolarEclipsePathPoint, + samples []solarEclipsePartialBoundarySample, +) [][]SolarEclipsePathPoint { + if len(segments) < 2 || len(samples) == 0 || !samples[0].ok || !samples[len(samples)-1].ok { + return segments + } + first := segments[0] + last := segments[len(segments)-1] + if solarEclipsePathCrossesAntimeridian(last[len(last)-1], first[0]) { + firstCrossing, secondCrossing := solarEclipsePathAntimeridianCrossings( + last[len(last)-1], first[0], + ) + segments[len(segments)-1] = append(last, firstCrossing) + segments[0] = append([]SolarEclipsePathPoint{secondCrossing}, first...) + return segments + } + + merged := make([]SolarEclipsePathPoint, 0, len(last)+len(first)) + merged = append(merged, last...) + merged = append(merged, first...) + result := make([][]SolarEclipsePathPoint, 0, len(segments)-1) + result = append(result, merged) + result = append(result, segments[1:len(segments)-1]...) + return result +} + +func solarEclipsePathCrossesAntimeridian(a, b SolarEclipsePathPoint) bool { + return math.Abs(a.Longitude-b.Longitude) > 180 +} + +func solarEclipsePathAntimeridianCrossings( + first, second SolarEclipsePathPoint, +) (SolarEclipsePathPoint, SolarEclipsePathPoint) { + boundary := 180.0 + secondLongitude := second.Longitude + if first.Longitude < 0 { + boundary = -180 + secondLongitude -= 360 + } else { + secondLongitude += 360 + } + left, right := 0.0, 1.0 + for iteration := 0; iteration < 64; iteration++ { + middle := (left + right) / 2 + point := solarEclipsePathSphericalInterpolate(first, second, middle) + middleLongitude := point.Longitude + for middleLongitude-first.Longitude > 180 { + middleLongitude -= 360 + } + for middleLongitude-first.Longitude < -180 { + middleLongitude += 360 + } + if (first.Longitude-boundary)*(middleLongitude-boundary) <= 0 { + right = middle + } else { + left = middle + } + if math.Abs(middleLongitude-boundary) <= 1e-12 || right-left <= 1e-13 { + break + } + } + fraction := (left + right) / 2 + if secondLongitude != first.Longitude { + linearFraction := (boundary - first.Longitude) / (secondLongitude - first.Longitude) + if linearFraction >= 0 && linearFraction <= 1 && math.Abs(right-left) > 1e-6 { + fraction = linearFraction + } + } + crossing := solarEclipsePathSphericalInterpolate(first, second, fraction) + crossing.Longitude = boundary + opposite := crossing + opposite.Longitude = -boundary + return crossing, opposite +} + +func solarEclipsePathSphericalInterpolate( + first, second SolarEclipsePathPoint, + fraction float64, +) SolarEclipsePathPoint { + firstVector := solarEclipseLLRToXYZ(first.Longitude*rad, first.Latitude*rad, 1) + secondVector := solarEclipseLLRToXYZ(second.Longitude*rad, second.Latitude*rad, 1) + dot := firstVector[0]*secondVector[0] + firstVector[1]*secondVector[1] + firstVector[2]*secondVector[2] + dot = math.Max(-1, math.Min(1, dot)) + firstWeight, secondWeight := 1-fraction, fraction + if dot < 1-1e-14 && dot > -1+1e-14 { + angle := math.Acos(dot) + sine := math.Sin(angle) + firstWeight = math.Sin((1-fraction)*angle) / sine + secondWeight = math.Sin(fraction*angle) / sine + } + vector := [3]float64{ + firstWeight*firstVector[0] + secondWeight*secondVector[0], + firstWeight*firstVector[1] + secondWeight*secondVector[1], + firstWeight*firstVector[2] + secondWeight*secondVector[2], + } + coordinates := solarEclipseXYZToLLR(vector[0], vector[1], vector[2]) + longitude := solarEclipseNormalizeSignedRadians(coordinates[0]) / rad + return SolarEclipsePathPoint{ + JDE: first.JDE + fraction*(second.JDE-first.JDE), + Longitude: longitude, + Latitude: coordinates[1] / rad, + SunAltitude: first.SunAltitude + fraction*(second.SunAltitude-first.SunAltitude), + WidthKM: first.WidthKM + fraction*(second.WidthKM-first.WidthKM), + } +} + +func solarEclipsePathDistanceKM(a, b SolarEclipsePathPoint) float64 { + lat1 := a.Latitude * rad + lat2 := b.Latitude * rad + dlat := lat2 - lat1 + dlon := solarEclipseNormalizeSignedRadians((b.Longitude - a.Longitude) * rad) + h := math.Sin(dlat/2)*math.Sin(dlat/2) + + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2) + if h > 1 { + h = 1 + } + return 2 * solarEclipseEarthEquatorialRadiusKM * math.Asin(math.Sqrt(h)) +} diff --git a/basic/solar_eclipse_path_test.go b/basic/solar_eclipse_path_test.go index 623ac0d..b669e3a 100644 --- a/basic/solar_eclipse_path_test.go +++ b/basic/solar_eclipse_path_test.go @@ -1,6 +1,7 @@ package basic import ( + "fmt" "math" "testing" "time" @@ -63,6 +64,153 @@ func TestSolarEclipseCentralPathTargetSpacingRefinesSamples(t *testing.T) { } } +func TestSolarEclipseCentralPathAdaptiveCurvature2543(t *testing.T) { + seed := JDECalc(2543, 10, 29) + path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{ + StepDays: 20.0 / 1440.0, + TargetSpacingKM: 700, + }) + if len(path.CenterLine) < 2 { + t.Fatalf("2543-10-29 center line has %d points, want at least two", len(path.CenterLine)) + } + coarse := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{StepDays: 20.0 / 1440.0}) + if len(path.CenterLine) <= len(coarse.CenterLine) { + t.Fatalf("adaptive curvature refinement did not add samples: coarse=%d refined=%d", + len(coarse.CenterLine), len(path.CenterLine)) + } + + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + tolerance := math.Max(solarEclipsePathMinimumCurvatureKM, + path.TargetSpacingKM*solarEclipsePathAdaptiveCurvatureFraction) + for index := 1; index < len(path.CenterLine); index++ { + start, end := path.CenterLine[index-1], path.CenterLine[index] + middle, ok := solver.centralPathPointAt((start.JDE + end.JDE) / 2) + if !ok { + continue + } + geodesicMiddle := solarEclipsePathSphericalInterpolate(start, end, 0.5) + if errorKM := solarEclipsePathDistanceKM(middle, geodesicMiddle); errorKM > tolerance+1e-6 { + t.Fatalf("segment %d midpoint error=%.6f km, want <= %.6f km", index, errorKM, tolerance) + } + } +} + +func TestSolarEclipseCentralPathLimitsKeepValidBoundarySamples(t *testing.T) { + path := SolarEclipseCentralPath(JDECalc(2008, 8, 1), SolarEclipsePathOptions{ + StepDays: 2.0 / 1440.0, + }) + if len(path.CenterLine) < 3 || len(path.NorthernLimit) < 3 || len(path.NorthernLimit) != len(path.SouthernLimit) { + t.Fatalf("unexpected 2008 path samples: center=%d north=%d south=%d", len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit)) + } + if path.NorthernLimit[0].JDE >= path.CenterLine[0].JDE || + path.NorthernLimit[len(path.NorthernLimit)-1].JDE <= path.CenterLine[len(path.CenterLine)-1].JDE { + t.Fatal("central limit samples should include the external contact caps") + } + if solarEclipsePathDistanceKM(path.NorthernLimit[0], path.SouthernLimit[0]) > 1 || + solarEclipsePathDistanceKM(path.NorthernLimit[len(path.NorthernLimit)-1], path.SouthernLimit[len(path.SouthernLimit)-1]) > 1 { + t.Fatal("external contact caps should collapse to one contact point") + } + for index := 1; index < len(path.NorthernLimit); index++ { + if solarEclipsePathDistanceKM(path.NorthernLimit[index-1], path.NorthernLimit[index]) > 500 { + t.Fatalf("northern limit branch jumps at %d", index) + } + if solarEclipsePathDistanceKM(path.SouthernLimit[index-1], path.SouthernLimit[index]) > 500 { + t.Fatalf("southern limit branch jumps at %d", index) + } + } +} + +func TestSolarEclipseCentralPathLimitsRemainStrictlyOrdered19851101(t *testing.T) { + path := SolarEclipseCentralPath(JDECalc(1985, 11, 1), SolarEclipsePathOptions{ + StepDays: 10.0 / 1440.0, + }) + if len(path.CenterLine) < 2 || len(path.NorthernLimit) < 2 || + len(path.NorthernLimit) != len(path.SouthernLimit) { + t.Fatalf("unexpected 1985-11-01 path sizes: center=%d north=%d south=%d", + len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit)) + } + for index := 1; index < len(path.CenterLine); index++ { + if path.CenterLine[index].JDE <= path.CenterLine[index-1].JDE { + t.Fatalf("center-line times are not strictly increasing at %d", index) + } + } + for index := 1; index < len(path.NorthernLimit); index++ { + if path.NorthernLimit[index].JDE <= path.NorthernLimit[index-1].JDE || + path.SouthernLimit[index].JDE <= path.SouthernLimit[index-1].JDE { + t.Fatalf("central-limit times are not strictly increasing at %d: north %.12f -> %.12f, south %.12f -> %.12f", + index, path.NorthernLimit[index-1].JDE, path.NorthernLimit[index].JDE, + path.SouthernLimit[index-1].JDE, path.SouthernLimit[index].JDE) + } + if math.Abs(path.NorthernLimit[index].JDE-path.SouthernLimit[index].JDE) > solarEclipsePathDuplicateTimeDays { + t.Fatalf("central-limit sample %d times do not match", index) + } + } +} + +func TestSolarEclipseRiseSetRawTopologyAvoidsPolarTraceExplosion(t *testing.T) { + seed := JDECalc(2309, 6, 9) + result := SolarEclipse(seed) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + curves, junctions, actualStep := solver.sampleRiseSetCurves( + result.PartialBeginOnEarth, result.PartialEndOnEarth, result.GreatestEclipse, + 2.0/1440.0, + ) + if !solarEclipseRiseSetRawTopologyUsable(curves) { + t.Fatalf("2309-06-09 sampled topology is not bounded: curves=%d", len(curves)) + } + solver.finalizeRiseSetCurveTopology(curves, actualStep, junctions) + if !solarEclipseRiseSetCurveTopologyComplete(curves) { + t.Fatal("2309-06-09 sampled topology should close without continuation tracing") + } +} + +func TestSolarEclipseNonCentralGreatestHorizonFoldsRemainTimedSegments(t *testing.T) { + for _, date := range [][3]int{ + {1656, 7, 21}, + {1928, 5, 19}, + {1967, 11, 2}, + } { + date := date + t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(date[0], date[1], float64(date[2])), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 24, RiseSetStepDays: 2.0 / 1440.0, + }) + if result.Eclipse.Type != SolarEclipseTotal || result.Eclipse.Centrality != SolarEclipseNonCentral { + t.Fatalf("type=%s centrality=%s, want non-central total", result.Eclipse.Type, result.Eclipse.Centrality) + } + greatestSegments := 0 + for index := range result.RiseSetCurves { + curve := &result.RiseSetCurves[index] + if curve.Phase == RiseSetPhaseGreatest && curve.Direction == RiseSetDirectionRise { + if len(curve.Segments) > greatestSegments { + greatestSegments = len(curve.Segments) + } + } + if curve.Phase == RiseSetPhaseGreatest && curve.Direction == RiseSetDirectionSet { + if len(curve.Segments) > greatestSegments { + greatestSegments = len(curve.Segments) + } + } + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + t.Fatalf("curve %s/%s segment %d has %d points", curve.Phase, curve.Direction, segmentIndex, len(segment)) + } + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + if segment[pointIndex].JDE <= segment[pointIndex-1].JDE+solarEclipseRiseSetTimeEpsilonDays { + t.Fatalf("curve %s/%s segment %d folds at %d: %.12f -> %.12f", + curve.Phase, curve.Direction, segmentIndex, pointIndex, + segment[pointIndex-1].JDE, segment[pointIndex].JDE) + } + } + } + } + if greatestSegments < 2 { + t.Fatalf("greatest horizon fold was not split into independent timed branches") + } + }) + } +} + func TestSolarEclipseCentralPathPartialHasNoCenterLine(t *testing.T) { path := SolarEclipseCentralPath(JDECalc(2025, 3, 29), SolarEclipsePathOptions{}) @@ -134,6 +282,674 @@ func TestSolarEclipsePartialFootprintsIncludeGreatest(t *testing.T) { } } +func TestSolarEclipsePartialFootprintBoundarySpacing20431003(t *testing.T) { + seedJDE := JDECalc(2043, 10, 3) + global := SolarEclipse(seedJDE) + solver := newSolarEclipseSolver( + CalcMoonSHByJDE(seedJDE, 0), + SolarEclipseModelNASABulletinSplitK, + ) + footprints, _, _ := solver.partialFootprints( + global.PartialBeginOnEarth, + global.PartialEndOnEarth, + global.GreatestEclipse, + SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, + BoundaryPoints: 96, + }, + ) + if len(footprints) == 0 { + t.Fatal("expected 2043-10-03 penumbral footprints") + } + + maximumDistance := 0.0 + var maximumTime float64 + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + for index := 1; index < len(boundary); index++ { + distance := solarEclipsePathDistanceKM(boundary[index-1], boundary[index]) + if distance > maximumDistance { + maximumDistance = distance + maximumTime = footprint.JDE + } + } + } + } + if maximumDistance > 250 { + t.Fatalf("2043-10-03 penumbral footprint has a %.1f km boundary chord at JDE %.12f, want <=250 km", + maximumDistance, maximumTime) + } +} + +func TestSolarEclipseFootprintBoundaryBudgetIsDeterministic(t *testing.T) { + if got := solarEclipseEffectiveBoundaryPoints(1000, 1440); got != 1440 { + t.Fatalf("ordinary event boundary points=%d, want requested 1440", got) + } + if got := solarEclipseEffectiveBoundaryPoints(30000, 1440); got != 66 { + t.Fatalf("dense event boundary points=%d, want point-budget cap 66", got) + } + if got := solarEclipseMaximumBoundaryPoints(30000); got != 66 { + t.Fatalf("dense event maximum boundary points=%d, want 66", got) + } + if got := solarEclipseEffectiveBoundaryPoints(1, 4); got != solarEclipsePartialFootprintMinBoundaryPoints { + t.Fatalf("minimum boundary points=%d, want %d", got, solarEclipsePartialFootprintMinBoundaryPoints) + } +} + +func TestSolarEclipseMagnitudeContourInputHasBoundedCardinality(t *testing.T) { + values := make([]float64, solarEclipseMagnitudeContourMaxValues+8) + for index := range values { + values[index] = float64(index+1) / 100 + } + options := normalizeSolarEclipsePartialFootprintOptions( + SolarEclipsePartialFootprintOptions{MagnitudeValues: values}, + ) + if len(options.MagnitudeValues) != solarEclipseMagnitudeContourMaxValues { + t.Fatalf("magnitude values=%d, want %d", len(options.MagnitudeValues), solarEclipseMagnitudeContourMaxValues) + } +} + +func TestSolarEclipsePartialBandContoursMeetHorizonFootprintTracks(t *testing.T) { + seed := JDECalc(2009, 7, 22) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, + }) + if len(result.PartialBandContours) != 2 { + t.Fatalf("partial-band contours=%d, want two zero-magnitude envelopes", len(result.PartialBandContours)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + for contourIndex, contour := range result.PartialBandContours { + if len(contour) < 2 { + t.Fatalf("partial-band contour %d has %d points", contourIndex, len(contour)) + } + for endpointIndex, endpoint := range []SolarEclipsePathPoint{contour[0], contour[len(contour)-1]} { + if math.Abs(endpoint.SunAltitude) > 1e-4 { + t.Fatalf("contour %d endpoint %d altitude=%.9f, want horizon", contourIndex, endpointIndex, endpoint.SunAltitude) + } + state := solver.localStateContextAt(endpoint.JDE).stateAt(endpoint.Longitude*rad, endpoint.Latitude*rad, 0) + if magnitude := solarEclipseLocalMagnitude(state); math.Abs(magnitude) > 2e-6 { + t.Fatalf("contour %d endpoint %d magnitude=%.9f, want zero", contourIndex, endpointIndex, magnitude) + } + minimum := math.Inf(1) + for _, curve := range result.RiseSetCurves { + if curve.Phase == RiseSetPhaseGreatest { + continue + } + for _, segment := range curve.Segments { + for _, point := range segment { + minimum = math.Min(minimum, solarEclipsePathDistanceKM(endpoint, point)) + } + } + } + if minimum > 10 { + t.Fatalf("contour %d endpoint %d misses the start/end horizon network by %.3f km", contourIndex, endpointIndex, minimum) + } + } + } +} + +func TestSolarEclipseDisableRiseSetAlsoSkipsPartialBandTopology(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, + }) + if len(result.RiseSetCurves) != 0 || len(result.PartialBandContours) != 0 { + t.Fatalf("disabled rise/set returned curves=%d partial-band contours=%d", + len(result.RiseSetCurves), len(result.PartialBandContours)) + } +} + +func TestSolarEclipseMagnitudeContoursIncludeNonCentralAnnularBand(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2014, 4, 29), SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, + BoundaryPoints: 24, + CentralShadowStepDays: 2.0 / 1440.0, + MagnitudeValues: []float64{0.4, 0.8, 1.0}, + }) + if result.Eclipse.Type != SolarEclipseAnnular || result.Eclipse.Centrality != SolarEclipseNonCentral { + t.Fatalf("unexpected eclipse classification: type=%s centrality=%s", result.Eclipse.Type, result.Eclipse.Centrality) + } + if len(result.CentralShadowFootprints) < 3 { + t.Fatalf("expected non-central antumbral footprints, got %d", len(result.CentralShadowFootprints)) + } + if len(result.MagnitudeContours) != 2 { + t.Fatalf("expected two magnitude contours below the annular maximum, got %d", len(result.MagnitudeContours)) + } + for _, contour := range result.MagnitudeContours { + if len(contour.Segments) == 0 { + t.Fatalf("magnitude %.2f has no continuous envelope segments", contour.Magnitude) + } + for segmentIndex, segment := range contour.Segments { + if len(segment) < 2 { + t.Fatalf("magnitude %.2f segment %d has %d points", contour.Magnitude, segmentIndex, len(segment)) + } + for index := 1; index < len(segment); index++ { + if distance := solarEclipsePathDistanceKM(segment[index-1], segment[index]); distance > 1.1*solarEclipseMagnitudeContourTargetSpacingKM { + t.Fatalf("magnitude %.2f segment %d interval %d distance %.3f km", + contour.Magnitude, segmentIndex, index, distance) + } + } + } + } +} + +func TestSolarEclipseMagnitudeContoursAllowTotalityValuesAboveOne(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{1.01}, + }) + if len(result.MagnitudeContours) != 1 || result.MagnitudeContours[0].Magnitude != 1.01 { + t.Fatalf("magnitude contours = %#v, want one contour at 1.01", result.MagnitudeContours) + } +} + +func TestSolarEclipseMagnitudeContoursCoverHybridAndDeepTotalValues(t *testing.T) { + for _, test := range []struct { + name string + seed float64 + values []float64 + minimumSegmentLen int + }{ + {name: "2023 hybrid", seed: JDECalc(2023, 4, 20), values: []float64{1.005, 1.01, 1.012}, minimumSegmentLen: 2}, + {name: "2035 total", seed: JDECalc(2035, 9, 2), values: []float64{1.001, 1.01, 1.02}, minimumSegmentLen: 2}, + } { + result := SolarEclipsePartialFootprints(test.seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, MagnitudeValues: test.values, DisableRiseSetCurves: true, + }) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(test.seed, 0), SolarEclipseModelNASABulletinSplitK) + if len(result.MagnitudeContours) != len(test.values) { + t.Fatalf("%s contours=%d, want %d", test.name, len(result.MagnitudeContours), len(test.values)) + } + for _, contour := range result.MagnitudeContours { + if len(contour.Segments) == 0 { + t.Fatalf("%s magnitude %.3f has no segments", test.name, contour.Magnitude) + } + for segmentIndex, segment := range contour.Segments { + if len(segment) < test.minimumSegmentLen { + t.Fatalf("%s magnitude %.3f segment %d has %d points", test.name, contour.Magnitude, segmentIndex, len(segment)) + } + for _, point := range segment { + evaluation := solarEclipseRiseSetEvaluation{jd: point.JDE, center: newLocalSolarEclipseStateContext(point.JDE, solver.params)} + state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + if math.Abs(solarEclipseMagnitudeAtTarget(state, contour.Magnitude)-contour.Magnitude) > 2e-6 { + t.Fatalf("%s magnitude %.3f segment %d point magnitude=%.9f", test.name, contour.Magnitude, segmentIndex, solarEclipseMagnitudeAtTarget(state, contour.Magnitude)) + } + } + } + } + } +} + +func TestSolarEclipseHybridMagnitudeOneContoursMeetCenterLineTransitions(t *testing.T) { + for _, test := range []struct { + year, month, day int + transitions int + }{ + {1827, 10, 20, 2}, + {1845, 10, 30, 2}, + {1854, 11, 20, 1}, + {1909, 6, 17, 2}, + {1986, 10, 3, 2}, + {2013, 11, 3, 1}, + {2023, 4, 20, 2}, + {2172, 10, 17, 1}, + } { + name := fmt.Sprintf("%04d-%02d-%02d", test.year, test.month, test.day) + t.Run(name, func(t *testing.T) { + seed := JDECalc(test.year, test.month, float64(test.day)) + partial := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{1}, DisableRiseSetCurves: true, + }) + central := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{StepDays: 2.0 / 1440.0}) + if partial.Eclipse.Type != SolarEclipseHybrid || len(partial.MagnitudeContours) != 1 { + t.Fatalf("unexpected hybrid result: type=%s contours=%d", partial.Eclipse.Type, len(partial.MagnitudeContours)) + } + segments := partial.MagnitudeContours[0].Segments + if len(segments) != 2 { + t.Fatalf("magnitude 1 segments=%d, want two", len(segments)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + transitions := solver.centralMagnitudeOneTransitions(central.CenterLine) + if len(transitions) != test.transitions { + t.Fatalf("center-line transitions=%d, want %d", len(transitions), test.transitions) + } + for transitionIndex, transition := range transitions { + centerMatches := 0 + for _, point := range central.CenterLine { + if math.Abs(point.JDE-transition.JDE) <= solarEclipseRiseSetTimeEpsilonDays && + solarEclipsePathDistanceKM(point, transition) <= 0.01 { + centerMatches++ + } + } + if centerMatches != 1 { + t.Fatalf("transition %d center-line matches=%d, want one shared vertex", transitionIndex, centerMatches) + } + matches := 0 + for _, segment := range segments { + for _, endpoint := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} { + if solarEclipsePathDistanceKM(endpoint, transition) <= 0.1 { + matches++ + } + } + } + if matches != 2 { + t.Fatalf("transition %d endpoint matches=%d, want two", transitionIndex, matches) + } + } + }) + } +} + +func TestSolarEclipseHybridMagnitudeOneContoursKeepTheirCentralPathSide11440703(t *testing.T) { + seed := JDECalc(1144, 7, 3) + partial := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, MagnitudeValues: []float64{1}, DisableRiseSetCurves: true, + }) + if partial.Eclipse.Type != SolarEclipseHybrid || len(partial.MagnitudeContours) != 1 { + t.Fatalf("unexpected hybrid result: type=%s contours=%d", partial.Eclipse.Type, len(partial.MagnitudeContours)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + for segmentIndex, segment := range partial.MagnitudeContours[0].Segments { + var branchSign float64 + for pointIndex, point := range segment { + sign, ok := solver.magnitudeContourBranchSign(point) + if !ok { + continue + } + if branchSign != 0 && branchSign*sign < 0 { + t.Fatalf("segment %d switches central-path side at point %d", segmentIndex, pointIndex) + } + branchSign = sign + } + if branchSign == 0 { + t.Fatalf("segment %d has no resolved central-path side", segmentIndex) + } + } +} + +func TestSolarEclipseMagnitudeContoursMatchLocalMaximumMagnitude(t *testing.T) { + tests := []struct { + year, month, day int + magnitude float64 + }{ + {2008, 8, 1, 0.2}, + {2014, 4, 29, 0.4}, + {2024, 4, 8, 0.2}, + } + for _, test := range tests { + seed := JDECalc(test.year, test.month, float64(test.day)) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{test.magnitude}, + }) + if len(result.MagnitudeContours) != 1 || len(result.MagnitudeContours[0].Segments) == 0 { + t.Fatalf("%04d-%02d-%02d magnitude %.1f contour is absent", test.year, test.month, test.day, test.magnitude) + } + for _, segment := range result.MagnitudeContours[0].Segments { + stride := len(segment) / 5 + if stride < 1 { + stride = 1 + } + for index := 0; index < len(segment); index += stride { + point := segment[index] + local := LocalSolarEclipse(seed, point.Longitude, point.Latitude, 0) + if !local.HasPartial || math.Abs(local.Magnitude-test.magnitude) > 2e-4 { + t.Fatalf("%04d-%02d-%02d contour %.1f at %.6f, %.6f has local maximum %.9f", + test.year, test.month, test.day, test.magnitude, point.Longitude, point.Latitude, local.Magnitude) + } + } + } + } +} + +func TestSolarEclipseMagnitudeContoursReachGreatestRiseSetBoundary(t *testing.T) { + seed := JDECalc(2031, 5, 21) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{0.8}, + }) + if len(result.MagnitudeContours) != 1 { + t.Fatalf("magnitude contours=%d, want one", len(result.MagnitudeContours)) + } + if len(result.MagnitudeContours[0].Segments) != 2 { + t.Fatalf("magnitude contour segments=%d, want two", len(result.MagnitudeContours[0].Segments)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + for segmentIndex, segment := range result.MagnitudeContours[0].Segments { + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + if distance := solarEclipsePathDistanceKM(segment[pointIndex-1], segment[pointIndex]); distance > 550 { + t.Fatalf("segment %d interval %d distance=%.3f km, want at most 550 km", + segmentIndex, pointIndex, distance) + } + } + for _, pointIndex := range []int{0, len(segment) - 1} { + point := segment[pointIndex] + evaluation := solarEclipseRiseSetEvaluation{ + jd: point.JDE, + center: newLocalSolarEclipseStateContext(point.JDE, solver.params), + before: newLocalSolarEclipseStateContext(point.JDE-solarEclipseRiseSetDerivativeStepDays, solver.params), + after: newLocalSolarEclipseStateContext(point.JDE+solarEclipseRiseSetDerivativeStepDays, solver.params), + } + state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + if math.Abs(state.sunAltitudeRad/rad) > 1e-5 { + t.Fatalf("segment %d endpoint %d Sun altitude=%.6f deg, want horizon", + segmentIndex, pointIndex, state.sunAltitudeRad/rad) + } + if math.Abs(solarEclipseLocalMagnitude(state)-0.8) > 2e-7 { + t.Fatalf("segment %d endpoint %d magnitude=%.9f, want 0.8", + segmentIndex, pointIndex, solarEclipseLocalMagnitude(state)) + } + if math.Abs(evaluation.separationDerivative(point.Longitude, point.Latitude)) > 1e-8 { + t.Fatalf("segment %d endpoint %d greatest derivative=%.9g", + segmentIndex, pointIndex, evaluation.separationDerivative(point.Longitude, point.Latitude)) + } + } + } +} + +func TestSolarEclipseMagnitudeContourEndpointsAcrossEclipseTypes(t *testing.T) { + tests := []struct { + year, month, day int + magnitude float64 + }{ + {2008, 8, 1, 0.2}, + {2014, 4, 29, 0.4}, + {2024, 4, 8, 0.2}, + {2025, 3, 29, 0.2}, + } + for _, test := range tests { + seed := JDECalc(test.year, test.month, float64(test.day)) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, MagnitudeValues: []float64{test.magnitude}, + }) + if len(result.MagnitudeContours) != 1 || len(result.MagnitudeContours[0].Segments) == 0 { + t.Fatalf("%04d-%02d-%02d contour count=%d", test.year, test.month, test.day, len(result.MagnitudeContours)) + } + for segmentIndex, segment := range result.MagnitudeContours[0].Segments { + for _, pointIndex := range []int{0, len(segment) - 1} { + if altitude := math.Abs(segment[pointIndex].SunAltitude); altitude > 1e-5 { + t.Fatalf("%04d-%02d-%02d segment %d endpoint %d altitude=%.6f deg", + test.year, test.month, test.day, segmentIndex, pointIndex, altitude) + } + } + } + } +} + +func TestSolarEclipseTotalMagnitudeOneContoursMeetHorizonClosures20260812(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2026, 8, 12), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + MagnitudeValues: []float64{1}, + }) + if len(result.MagnitudeContours) != 1 || len(result.MagnitudeContours[0].Segments) != 2 { + t.Fatalf("magnitude-one contours=%d, want two segments", len(result.MagnitudeContours)) + } + if len(result.CentralBandHorizonClosures) != 2 { + t.Fatalf("horizon closures=%d, want rise and set arcs", len(result.CentralBandHorizonClosures)) + } + var endpoints []SolarEclipsePathPoint + for segmentIndex, segment := range result.MagnitudeContours[0].Segments { + if len(segment) < 2 { + t.Fatalf("segment %d has %d points", segmentIndex, len(segment)) + } + for _, point := range []SolarEclipsePathPoint{segment[0], segment[len(segment)-1]} { + if math.Abs(point.SunAltitude) > 1e-5 { + t.Fatalf("segment %d endpoint altitude=%.9f degrees, want horizon", segmentIndex, point.SunAltitude) + } + endpoints = append(endpoints, point) + } + } + for closureIndex, closure := range result.CentralBandHorizonClosures { + for rootIndex, root := range []SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} { + minimumDistance := math.Inf(1) + for _, endpoint := range endpoints { + minimumDistance = math.Min(minimumDistance, solarEclipsePathDistanceKM(root, endpoint)) + } + if minimumDistance > 0.1 { + t.Fatalf("closure %d root %d is %.3f km from every magnitude-one endpoint", closureIndex, rootIndex, minimumDistance) + } + } + } +} + +func TestSolarEclipseRiseSetJunctionsRemainConnected20100115(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2010, 1, 15), SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, BoundaryPoints: 180, + }) + if len(result.RiseSetCurves) != 6 { + t.Fatalf("rise-set curve count=%d, want six", len(result.RiseSetCurves)) + } + curveIndex := make(map[solarEclipseRiseSetCurveKey]int, len(result.RiseSetCurves)) + for index, curve := range result.RiseSetCurves { + curveIndex[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = index + } + stepDays := 10.0 / 1440.0 + direction := RiseSetDirectionRise + startIndex := curveIndex[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: direction}] + greatestIndex := curveIndex[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: direction}] + endIndex := curveIndex[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: direction}] + for _, seed := range solarEclipseRiseSetUnsharedEndpoints(startIndex, result.RiseSetCurves[startIndex].Segments) { + for _, phaseIndex := range []int{greatestIndex, endIndex} { + if _, ok := solarEclipseClosestRiseSetEndpoint( + seed.point, phaseIndex, result.RiseSetCurves[phaseIndex].Segments, nil, stepDays, + ); !ok { + t.Fatalf("%s seed at %.9f (%.4f, %.4f) has no phase junction in curve %d", + direction, seed.point.JDE, seed.point.Longitude, seed.point.Latitude, phaseIndex) + } + } + } +} + +func TestSolarEclipseCentralPathLimitsIncludeExternalContacts20100115(t *testing.T) { + path := SolarEclipseCentralPath( + JDECalc(2010, 1, 15), + SolarEclipsePathOptions{StepDays: 10.0 / 1440.0, TargetSpacingKM: 100}, + ) + if len(path.NorthernLimit) < 2 || len(path.NorthernLimit) != len(path.SouthernLimit) { + t.Fatalf("central limits north=%d south=%d, want paired samples", len(path.NorthernLimit), len(path.SouthernLimit)) + } + firstNorth := path.NorthernLimit[0] + firstSouth := path.SouthernLimit[0] + lastNorth := path.NorthernLimit[len(path.NorthernLimit)-1] + lastSouth := path.SouthernLimit[len(path.SouthernLimit)-1] + if solarEclipsePathDistanceKM(firstNorth, firstSouth) > 1 || solarEclipsePathDistanceKM(lastNorth, lastSouth) > 1 { + t.Fatalf("external limits should collapse at contacts: first=%.3f km last=%.3f km", + solarEclipsePathDistanceKM(firstNorth, firstSouth), solarEclipsePathDistanceKM(lastNorth, lastSouth)) + } + if !(firstNorth.JDE < path.CenterLine[0].JDE && lastNorth.JDE > path.CenterLine[len(path.CenterLine)-1].JDE) { + t.Fatalf("contact caps must bracket center-line endpoints: first=%.12f center-first=%.12f center-last=%.12f last=%.12f", + firstNorth.JDE, path.CenterLine[0].JDE, path.CenterLine[len(path.CenterLine)-1].JDE, lastNorth.JDE) + } +} + +func TestSolarEclipseCentralPathMeetsGreatestSetCurveAtExactLimit20100115(t *testing.T) { + seed := JDECalc(2010, 1, 15) + path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{ + StepDays: 2.0 / 1440.0, TargetSpacingKM: 100, + }) + if len(path.CenterLine) < 2 { + t.Fatalf("center line has %d points, want at least two", len(path.CenterLine)) + } + last := path.CenterLine[len(path.CenterLine)-1] + // NASA's path-table Limits row is 36°49.6'N, 121°40.9'E with Sun altitude 0°. + if math.Abs(last.Longitude-121.6817) > 0.12 || math.Abs(last.Latitude-36.8267) > 0.12 { + t.Fatalf("center-line limit = (%.6f, %.6f), want NASA limit near (121.6817, 36.8267)", + last.Longitude, last.Latitude) + } + if math.Abs(last.SunAltitude) > 0.01 { + t.Fatalf("center-line limit Sun altitude = %.9f degrees, want horizon contact", last.SunAltitude) + } + + footprints := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 10.0 / 1440.0, BoundaryPoints: 24, RiseSetStepDays: 2.0 / 1440.0, + }) + found := false + for _, curve := range footprints.RiseSetCurves { + if curve.Phase != RiseSetPhaseGreatest || curve.Direction != RiseSetDirectionSet { + continue + } + for _, segment := range curve.Segments { + for _, point := range segment { + if math.Abs(point.JDE-last.JDE) <= solarEclipseRiseSetTimeEpsilonDays && + solarEclipsePathDistanceKM(point, last) <= 0.01 { + found = true + } + } + } + } + if !found { + t.Fatal("exact center-line limit is not a shared vertex of the greatest/set curve") + } +} + +func TestSolarEclipseCentralPathContactsShareGreatestRiseSetVerticesAcrossTypes(t *testing.T) { + for _, date := range [][3]int{ + {2008, 8, 1}, // total + {2010, 1, 15}, // annular + {2012, 5, 20}, // polar annular + {2023, 4, 20}, // hybrid and antimeridian + {2035, 9, 2}, // total + {2309, 6, 9}, // far-future sunset regression + } { + date := date + t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { + seed := JDECalc(date[0], date[1], float64(date[2])) + path := SolarEclipseCentralPath(seed, SolarEclipsePathOptions{ + StepDays: 2.0 / 1440.0, TargetSpacingKM: 200, + }) + if len(path.CenterLine) < 2 { + t.Fatalf("center line has %d points, want at least two", len(path.CenterLine)) + } + footprints := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 30.0 / 1440.0, BoundaryPoints: 24, RiseSetStepDays: 5.0 / 1440.0, + }) + contacts := []struct { + name string + point SolarEclipsePathPoint + direction RiseSetDirection + }{ + {"begin", path.CenterLine[0], RiseSetDirectionRise}, + {"end", path.CenterLine[len(path.CenterLine)-1], RiseSetDirectionSet}, + } + for _, contact := range contacts { + if math.Abs(contact.point.SunAltitude) > 0.02 { + t.Fatalf("%s Sun altitude = %.9f degrees, want horizon limit", contact.name, contact.point.SunAltitude) + } + if !finite(contact.point.WidthKM) || contact.point.WidthKM <= 0 || contact.point.WidthKM > 5000 { + t.Fatalf("%s width = %.6f km, want a finite path width", contact.name, contact.point.WidthKM) + } + if !solarEclipseRiseSetCurvesContainPoint( + footprints.RiseSetCurves, contact.point, RiseSetPhaseGreatest, contact.direction, + ) { + t.Fatalf("%s is not a shared greatest/%s vertex", contact.name, contact.direction) + } + } + }) + } +} + +func solarEclipseRiseSetCurvesContainPoint( + curves []SolarEclipseRiseSetCurve, + want SolarEclipsePathPoint, + phase RiseSetPhase, + direction RiseSetDirection, +) bool { + for _, curve := range curves { + if curve.Phase != phase || curve.Direction != direction { + continue + } + for _, segment := range curve.Segments { + for _, point := range segment { + if math.Abs(point.JDE-want.JDE) <= solarEclipseRiseSetTimeEpsilonDays && + solarEclipsePathDistanceKM(point, want) <= 0.01 { + return true + } + } + } + } + return false +} + +func TestSolarEclipseExactNonCentralContactsRecoverAtPolarLimb21410108(t *testing.T) { + seed := JDECalc(2141, 1, 8) + result := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK) + if result.Type != SolarEclipseAnnular || result.Centrality != SolarEclipseNonCentral { + t.Fatalf("unexpected eclipse classification: type=%s centrality=%s", result.Type, result.Centrality) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + solver.exactCentralContact = true + first, last, ok := solver.shadowContactPair(result.GreatestEclipse, solarEclipseCentralShadow, false) + if !ok || first.JDE == 0 || last.JDE <= first.JDE { + t.Fatalf("non-central contact pair is unavailable: first=%.12f last=%.12f ok=%v", first.JDE, last.JDE, ok) + } + for name, point := range map[string]SolarEclipsePathPoint{"U1": first, "U4": last} { + if !finite(point.Longitude) || !finite(point.Latitude) || + point.Longitude < -180 || point.Longitude > 180 || point.Latitude < -90 || point.Latitude > 90 { + t.Fatalf("%s polar-limb contact is invalid: %+v", name, point) + } + } +} + +func BenchmarkSolarEclipseMagnitudeContours(b *testing.B) { + seed := JDECalc(2031, 5, 21) + global := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK) + options := SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0}, + } + b.ReportAllocs() + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + contours := solver.magnitudeContours( + global.PartialBeginOnEarth, + global.PartialEndOnEarth, + global.CentralBeginOnEarth, + global.CentralEndOnEarth, + global.GreatestEclipse, + options, + global.Magnitude, + global.Type == SolarEclipseHybrid, + ) + if len(contours) != 4 { + b.Fatalf("magnitude contours=%d, want four", len(contours)) + } + } +} + +func BenchmarkSolarEclipsePartialFootprintsFull(b *testing.B) { + seed := JDECalc(2031, 5, 21) + options := SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, + BoundaryPoints: 180, + CentralShadowStepDays: 10.0 / 1440.0, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0}, + } + b.ReportAllocs() + for iteration := 0; iteration < b.N; iteration++ { + result := SolarEclipsePartialFootprints(seed, options) + if len(result.Footprints) == 0 || len(result.MagnitudeContours) != 4 || len(result.RiseSetCurves) == 0 { + b.Fatalf("incomplete full footprint result: footprints=%d contours=%d rise-set=%d", + len(result.Footprints), len(result.MagnitudeContours), len(result.RiseSetCurves)) + } + } +} + +func BenchmarkSolarEclipseNonCentralPartialFootprints20431003(b *testing.B) { + seed := JDECalc(2043, 10, 3) + options := SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, + BoundaryPoints: 96, + CentralShadowStepDays: 2.0 / 1440.0, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0}, + RiseSetStepDays: 2.0 / 1440.0, + } + b.ReportAllocs() + for iteration := 0; iteration < b.N; iteration++ { + result := SolarEclipsePartialFootprints(seed, options) + if result.Eclipse.Centrality != SolarEclipseNonCentral || + len(result.CentralBandSegments) != 1 || len(result.Footprints) == 0 { + b.Fatalf("incomplete 2043 non-central result: centrality=%s bands=%d footprints=%d", + result.Eclipse.Centrality, len(result.CentralBandSegments), len(result.Footprints)) + } + } +} + func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) { footprints := SolarEclipsePartialFootprints(JDECalc(2025, 3, 29), SolarEclipsePartialFootprintOptions{ StepDays: 30.0 / 1440.0, @@ -201,6 +1017,20 @@ func TestSolarEclipseShadowContactsAgainstNASA2012Baseline(t *testing.T) { t.Fatalf("central-shadow footprint at %.12f has no boundary", footprint.JDE) } } + closedBandFootprints := 0 + for _, footprint := range result.CentralBandFootprints { + if !footprint.Closed { + continue + } + closedBandFootprints++ + if footprint.JDE < result.U2.JDE-solarEclipsePathDuplicateTimeDays || + footprint.JDE > result.U3.JDE+solarEclipsePathDuplicateTimeDays { + t.Fatalf("closed central-band footprint %.12f is outside U2-U3", footprint.JDE) + } + } + if closedBandFootprints == 0 || closedBandFootprints > 17 { + t.Fatalf("closed central-band footprints=%d, want a bounded non-empty edge sample", closedBandFootprints) + } } func TestSolarEclipseShadowContactsIncludeP2P3WhenPenumbraEntersEarthDisk(t *testing.T) { @@ -228,6 +1058,111 @@ func TestSolarEclipseShadowContactsIncludeP2P3WhenPenumbraEntersEarthDisk(t *tes if result.CentralShadowFootprints != nil || result.CentralShadowStepDays != 0 { t.Fatal("central-shadow footprints must remain disabled by default") } + if len(result.CentralBandFootprints) == 0 || result.CentralBandStepDays <= 0 { + t.Fatal("default result must retain lightweight central-band end footprints") + } +} + +func TestSolarEclipseCentralBandFootprintsCoverNonCentralAndOneLimitEvents(t *testing.T) { + for _, fixture := range []struct { + year, month, day int + centrality SolarEclipseCentrality + }{ + {2003, 5, 31, SolarEclipseCentralOneLimit}, + {2014, 4, 29, SolarEclipseNonCentral}, + {2043, 4, 9, SolarEclipseNonCentral}, + {2043, 10, 3, SolarEclipseNonCentral}, + } { + result := SolarEclipsePartialFootprints( + JDECalc(fixture.year, fixture.month, float64(fixture.day)), + SolarEclipsePartialFootprintOptions{StepDays: 20.0 / 1440.0, BoundaryPoints: 24}, + ) + if result.Eclipse.Centrality != fixture.centrality { + t.Fatalf("%04d-%02d-%02d centrality=%s, want %s", + fixture.year, fixture.month, fixture.day, result.Eclipse.Centrality, fixture.centrality) + } + if len(result.CentralBandFootprints) < 2 { + t.Fatalf("%04d-%02d-%02d central-band footprints=%d, want at least two", + fixture.year, fixture.month, fixture.day, len(result.CentralBandFootprints)) + } + if fixture.centrality == SolarEclipseNonCentral { + if len(result.CentralBandSegments) != 1 { + t.Fatalf("%04d-%02d-%02d central-band regions=%d, want one horizon-closed region", + fixture.year, fixture.month, fixture.day, len(result.CentralBandSegments)) + } + solver := newSolarEclipseSolver( + CalcMoonSHByJDE(JDECalc(fixture.year, fixture.month, float64(fixture.day)), 0), + SolarEclipseModelNASABulletinSplitK, + ) + horizonPoints := 0 + junctions := 0 + for segmentIndex, segment := range result.CentralBandSegments { + if len(segment) < 4 || solarEclipsePathDistanceKM(segment[0], segment[len(segment)-1]) > 0.01 { + t.Fatalf("%04d-%02d-%02d central-band sweep cell %d is not closed", + fixture.year, fixture.month, fixture.day, segmentIndex) + } + for pointIndex, point := range segment { + evaluation := solver.magnitudeEvaluationAt(point.JDE) + state := evaluation.center.stateAt(point.Longitude*rad, point.Latitude*rad, 0) + centralGap := state.separationRad - math.Abs(state.sunRadiusRad-state.moonInnerRadiusRad) + if math.Abs(state.sunAltitudeRad/rad) <= 1e-5 { + horizonPoints++ + if centralGap > 1e-7 { + t.Fatalf("%04d-%02d-%02d central-band horizon point %d gap=%g, want <= 0", + fixture.year, fixture.month, fixture.day, pointIndex, centralGap) + } + if math.Abs(centralGap) <= 1e-7 && + math.Abs(evaluation.separationDerivative(point.Longitude, point.Latitude)) <= 1e-8 { + junctions++ + } + } + if pointIndex == 0 { + continue + } + maximumEdgeKM := 1.6 * solarEclipseCentralBandTargetSpacingKM + if result.Eclipse.Type == SolarEclipseTotal { + maximumEdgeKM = 1.1 * solarEclipseNonCentralTotalBandTargetSpacingKM + } + if distance := solarEclipsePathDistanceKM(segment[pointIndex-1], segment[pointIndex]); distance > maximumEdgeKM { + t.Fatalf("%04d-%02d-%02d central-band sweep cell %d edge %d = %.3f km", + fixture.year, fixture.month, fixture.day, segmentIndex, pointIndex-1, distance) + } + } + } + if horizonPoints < 4 { + t.Fatalf("%04d-%02d-%02d central-band horizon points=%d, want a sampled closing arc", + fixture.year, fixture.month, fixture.day, horizonPoints) + } + if junctions < 2 { + t.Fatalf("%04d-%02d-%02d central-band horizon/contact junctions=%d, want two", + fixture.year, fixture.month, fixture.day, junctions) + } + if err := auditSolarEclipseBandContainsFootprints( + result.CentralBandSegments, result.CentralBandFootprints, + ); err != nil { + t.Fatalf("%04d-%02d-%02d central-band containment: %v", + fixture.year, fixture.month, fixture.day, err) + } + } + } +} + +func TestSolarEclipseNonCentralBandFallsBackWhenCriticalEnvelopeLeaks(t *testing.T) { + result := SolarEclipsePartialFootprints( + JDECalc(1950, 3, 18), + SolarEclipsePartialFootprintOptions{ + StepDays: 60.0 / 1440.0, BoundaryPoints: 24, DisableRiseSetCurves: true, + }, + ) + if result.Eclipse.Centrality != SolarEclipseNonCentral { + t.Fatalf("1950-03-18 centrality=%s, want non-central", result.Eclipse.Centrality) + } + if len(result.CentralBandSegments) != 0 { + t.Fatalf("leaking critical envelope was retained with %d segments", len(result.CentralBandSegments)) + } + if err := auditSolarEclipseOpenBandSweep(result.CentralBandFootprints); err != nil { + t.Fatalf("open-footprint fallback is unusable: %v", err) + } } func TestSolarEclipsePartialBoundarySegmentsRemainClosedAcrossAntimeridian(t *testing.T) { @@ -244,6 +1179,16 @@ func TestSolarEclipsePartialBoundarySegmentsRemainClosedAcrossAntimeridian(t *te if len(boundaries) < 2 { t.Fatalf("expected antimeridian split, got %d boundary segment(s)", len(boundaries)) } + for index, boundary := range boundaries { + if len(boundary) < 2 { + t.Fatalf("antimeridian boundary %d has only %d point(s)", index, len(boundary)) + } + first := boundary[0].Longitude + last := boundary[len(boundary)-1].Longitude + if math.Abs(first) != 180 && math.Abs(last) != 180 { + t.Fatalf("antimeridian boundary %d has no exact map-edge endpoint", index) + } + } } func TestSolarEclipsePartialFootprintsNoEvent(t *testing.T) { diff --git a/basic/solar_eclipse_perf_bench_test.go b/basic/solar_eclipse_perf_bench_test.go new file mode 100644 index 0000000..54b0ec6 --- /dev/null +++ b/basic/solar_eclipse_perf_bench_test.go @@ -0,0 +1,95 @@ +package basic + +import "testing" + +// 性能基线:逐项隔离 §1.4 P2 热点,改动前后跑同一份基准做对照。 + +func solarEclipseBenchSeed(year, month, day int) float64 { + return JDECalc(year, month, float64(day)) +} + +func solarEclipseBenchCenterLine(b *testing.B, seed float64) (SolarEclipsePathResult, float64, SolarEclipseRadiusModel) { + b.Helper() + model := SolarEclipseModelNASABulletinSplitK + result := solarEclipse(seed, model) + newMoonJDE := CalcMoonSHByJDE(seed, 0) + solver := newSolarEclipseSolver(newMoonJDE, model) + samplingOptions := SolarEclipsePathOptions{StepDays: 2.0 / 1440.0} + samplingOptions.TargetSpacingKM = 0 + centerLine, _ := solver.centralPathPoints( + result.CentralBeginOnEarth, result.CentralEndOnEarth, result.GreatestEclipse, samplingOptions, + ) + centerLine = solver.attachCentralAxisContactPoints( + centerLine, result.CentralBeginOnEarth, result.CentralEndOnEarth, + ) + return SolarEclipsePathResult{CenterLine: centerLine}, newMoonJDE, model +} + +func BenchmarkSolarEclipseCentralPath20240408(b *testing.B) { + seed := solarEclipseBenchSeed(2024, 4, 8) + options := SolarEclipsePathOptions{StepDays: 2.0 / 1440.0, TargetSpacingKM: 150} + b.ResetTimer() + for index := 0; index < b.N; index++ { + SolarEclipseCentralPathNASABulletinSplitK(seed, options) + } +} + +func BenchmarkSolarEclipseMaxCentralDuration20240408(b *testing.B) { + path, newMoonJDE, model := solarEclipseBenchCenterLine(b, solarEclipseBenchSeed(2024, 4, 8)) + b.ResetTimer() + for index := 0; index < b.N; index++ { + solver := newSolarEclipseSolver(newMoonJDE, model) + solver.centralPathMaxCentralDuration(path.CenterLine) + } +} + +func BenchmarkSolarEclipseLimitPairs20240408(b *testing.B) { + path, newMoonJDE, model := solarEclipseBenchCenterLine(b, solarEclipseBenchSeed(2024, 4, 8)) + b.ResetTimer() + for index := 0; index < b.N; index++ { + solver := newSolarEclipseSolver(newMoonJDE, model) + solver.centralPathLimitPairs(path.CenterLine) + } +} + +func BenchmarkSolarEclipseGlobal20240408(b *testing.B) { + seed := solarEclipseBenchSeed(2024, 4, 8) + b.ResetTimer() + for index := 0; index < b.N; index++ { + SolarEclipseNASABulletinSplitK(seed) + } +} + +func BenchmarkSolarEclipseMagnitudeEvaluation(b *testing.B) { + seed := solarEclipseBenchSeed(1136, 6, 1) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + solver = solver.withLocalEphemeris() + jd := solver.newMoonJDE + b.ResetTimer() + for index := 0; index < b.N; index++ { + solver.magnitudeEvaluationAt(jd) + } +} + +func BenchmarkSolarEclipsePartialFootprints11360601(b *testing.B) { + seed := solarEclipseBenchSeed(1136, 6, 1) + options := SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + } + b.ResetTimer() + for index := 0; index < b.N; index++ { + SolarEclipsePartialFootprintsNASABulletinSplitK(seed, options) + } +} + +func BenchmarkSolarEclipseRiseSetArcResidual(b *testing.B) { + seed := solarEclipseBenchSeed(2024, 4, 8) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + solver = solver.withLocalEphemeris() + result := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK) + evaluation := solver.magnitudeEvaluationAt(result.GreatestEclipse) + b.ResetTimer() + for index := 0; index < b.N; index++ { + solarEclipseRiseSetArcResidualAt(evaluation, 10, 40, false) + } +} diff --git a/basic/solar_eclipse_polar_envelope_test.go b/basic/solar_eclipse_polar_envelope_test.go new file mode 100644 index 0000000..6e67d5c --- /dev/null +++ b/basic/solar_eclipse_polar_envelope_test.go @@ -0,0 +1,84 @@ +package basic + +import ( + "fmt" + "math" + "testing" +) + +func TestSolarEclipsePolarCentralEnvelope(t *testing.T) { + for _, sample := range []struct { + year, month, day int + direction RiseSetDirection + }{ + {2061, 10, 13, RiseSetDirectionRise}, + {2026, 2, 17, RiseSetDirectionSet}, + } { + t.Run(fmt.Sprintf("%04d-%02d-%02d", sample.year, sample.month, sample.day), func(t *testing.T) { + seed := JDECalc(sample.year, sample.month, float64(sample.day)) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440, BoundaryPoints: 96, + }) + if result.Eclipse.Type != SolarEclipseAnnular || len(result.CentralBandSegments) != 1 || + len(result.CentralBandHorizonClosures) != 2 { + t.Fatalf("type=%s rings=%d closures=%d, want annular with a continuous closed envelope", + result.Eclipse.Type, len(result.CentralBandSegments), len(result.CentralBandHorizonClosures)) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + for index, closure := range result.CentralBandHorizonClosures { + if len(closure) < 2 { + t.Fatalf("closure %d has no arc", index) + } + for _, root := range []SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} { + evaluation := solver.magnitudeEvaluationAt(root.JDE) + _, key, ok := evaluation.classify(root.Longitude, root.Latitude, true) + if !ok || key.direction != sample.direction { + t.Fatalf("closure %d direction=%s valid=%v, want %s", index, key.direction, ok, sample.direction) + } + residual, _, ok := solarEclipseCentralLimitHorizonJacobian( + solver, [3]float64{root.Longitude, root.Latitude, root.JDE}, + solver.magnitudeEvaluationAt, + ) + if !ok || math.Abs(residual[0]) > 1e-9 || math.Abs(residual[1]) > 1e-7 || math.Abs(residual[2]) > 1e-9 { + t.Fatalf("closure %d root residual=%v valid=%v", index, residual, ok) + } + } + for pointIndex, point := range closure { + matched := false + for _, curve := range result.RiseSetCurves { + if curve.Phase != RiseSetPhaseGreatest || curve.Direction != sample.direction { + continue + } + for _, segment := range curve.Segments { + for _, candidate := range segment { + if math.Abs(candidate.JDE-point.JDE) < solarEclipsePathDuplicateTimeDays && + solarEclipsePathDistanceKM(candidate, point) < 0.001 { + matched = true + } + } + } + } + if !matched { + t.Fatalf("closure %d point %d is missing from greatest/%s", index, pointIndex, sample.direction) + } + } + } + ring := result.CentralBandSegments[0] + if len(ring) < 100 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.001 { + t.Fatal("central envelope is not densely sampled and closed") + } + for index, point := range ring { + if index > 0 && solarEclipsePathDistanceKM(ring[index-1], point) > solarEclipseCentralEnvelopeMaxSpacingKM { + t.Fatalf("envelope edge %d exceeds spacing limit", index) + } + if solarEclipseCentralEnvelopePointOnHorizonClosure(point, result.CentralBandHorizonClosures) { + continue + } + residual, ok := solarEclipseNonCentralBandBoundaryResidualAt(solver.magnitudeEvaluationAt(point.JDE), point.Longitude, point.Latitude) + if !ok || math.Abs(residual[0]) > 1e-6 || math.Abs(residual[1]) > solarEclipseNonCentralBandDerivativeTolerance || point.SunAltitude < 0 { + t.Fatalf("envelope point %d altitude=%g residual=%v valid=%v", index, point.SunAltitude, residual, ok) + } + } + }) + } +} diff --git a/basic/solar_eclipse_review_fixes_test.go b/basic/solar_eclipse_review_fixes_test.go new file mode 100644 index 0000000..78bccd1 --- /dev/null +++ b/basic/solar_eclipse_review_fixes_test.go @@ -0,0 +1,317 @@ +package basic + +import ( + "math" + "testing" +) + +// 采样并集的顶点必须保留自己的时间:平滑把顶点移了位置,时间场必须跟着同一个滤波器走, +// 不能整体回落到食甚时刻。 +func TestSolarEclipseSampledBandVerticesKeepOwnTime(t *testing.T) { + for _, date := range [][3]int{{4862, 9, 28}, {1552, 7, 21}, {1874, 10, 10}} { + seed := JDECalc(date[0], date[1], float64(date[2])) + result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }) + if len(result.CentralBandSegments) == 0 { + t.Fatalf("%04d-%02d-%02d exported no central band", date[0], date[1], date[2]) + } + lowest, highest := math.Inf(1), math.Inf(-1) + distinct := make(map[float64]bool) + for _, segment := range result.CentralBandSegments { + for _, point := range segment { + lowest, highest = math.Min(lowest, point.JDE), math.Max(highest, point.JDE) + distinct[point.JDE] = true + if point.JDE < result.Eclipse.PartialBeginOnEarth-1 || + point.JDE > result.Eclipse.PartialEndOnEarth+1 { + t.Fatalf("%04d-%02d-%02d band vertex time %.6f is outside the eclipse", + date[0], date[1], date[2], point.JDE) + } + } + } + if len(distinct) < 2 || highest-lowest < 1.0/1440.0 { + t.Fatalf("%04d-%02d-%02d band vertices collapsed onto one time: span=%.9f days, distinct=%d", + date[0], date[1], date[2], highest-lowest, len(distinct)) + } + } +} + +// 扫掠兜底的采样标记必须跟着 centralBandSweepPolygons 的返回值走,不能丢。 +func TestSolarEclipseCentralBandSweepKeepsSampledFlag(t *testing.T) { + for _, date := range [][3]int{{2024, 4, 8}, {1136, 6, 1}, {4862, 9, 28}, {1552, 7, 21}} { + seed := JDECalc(date[0], date[1], float64(date[2])) + band := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }) + if band.U1.JDE == 0 || band.U4.JDE == 0 { + t.Fatalf("%04d-%02d-%02d has no umbral contact pair", date[0], date[1], date[2]) + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + _, _, sampled := solver.centralBandSweepPolygons( + band.U1.JDE, band.U4.JDE, band.Eclipse.GreatestEclipse, band.RiseSetCurves, + ) + probe := band + probe.CentralBandSegments, probe.CentralBandSampled = nil, false + solver.attachCentralBandSweep( + &probe, band.U1.JDE, band.U4.JDE, band.Eclipse.GreatestEclipse, band.RiseSetCurves, + ) + if probe.CentralBandSampled != sampled { + t.Fatalf("%04d-%02d-%02d sampled flag=%v, sweep reports %v", + date[0], date[1], date[2], probe.CentralBandSampled, sampled) + } + } +} + +// 弧角沿环展开后可以超过 2π,包络角取的是 [0,2π) 支;映射到同一支之前, +// 展开段的样本会被两端同时丢弃。 +func TestSolarEclipseArcBranchAngleMapsUnwrappedArcs(t *testing.T) { + const twoPi = 2 * math.Pi + if got, want := solarEclipseArcBranchAngle(0.1, 6.0), 0.1+twoPi; math.Abs(got-want) > 1e-12 { + t.Fatalf("branch angle=%.15f, want %.15f", got, want) + } + for _, item := range []struct{ angle, reference float64 }{ + {0.1, 6.0}, {6.2, 0.05}, {0, 0}, {3, twoPi + 0.5}, {twoPi - 0.1, 0.2}, {0.0, twoPi - 0.05}, + } { + got := solarEclipseArcBranchAngle(item.angle, item.reference) + if math.Abs(math.Remainder(got-item.angle, twoPi)) > 1e-12 { + t.Fatalf("angle %.6f mapped to %.6f is not on the same branch as reference %.6f", + item.angle, got, item.reference) + } + if got < item.reference-math.Pi-1e-9 || got > item.reference+math.Pi+1e-9 { + t.Fatalf("angle %.6f mapped to %.6f left the reference branch %.6f", + item.angle, got, item.reference) + } + } +} + +// 非 greatest 残差只允许算一次 stateAt;结果必须与"先算相位残差再单独算 stateAt"逐位一致。 +func TestSolarEclipseRiseSetArcResidualMatchesPhaseResidual(t *testing.T) { + seed := JDECalc(2024, 4, 8) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + solver = solver.withLocalEphemeris() + result := solarEclipse(seed, SolarEclipseModelNASABulletinSplitK) + for offset := -3.0; offset <= 3.0; offset += 0.37 { + jd := result.GreatestEclipse + offset/24.0 + evaluation := solver.magnitudeEvaluationAt(jd) + for _, point := range [][2]float64{{10, 40}, {-70, -20}, {120, 65}, {0, 0}} { + for _, greatest := range []bool{true, false} { + got, gotOK := solarEclipseRiseSetArcResidualAt(evaluation, point[0], point[1], greatest) + phase, phaseOK := solarEclipseRiseSetPhaseResidual(evaluation, point[0], point[1], greatest) + state := evaluation.center.stateAt(point[0]*rad, point[1]*rad, 0) + if gotOK != phaseOK || got[0] != phase || got[1] != state.sunAltitudeRad { + t.Fatalf("residual(%.6f, %.3f, %.3f, greatest=%v)=%v/%v, want %v/%v", + jd, point[0], point[1], greatest, got, gotOK, + [2]float64{phase, state.sunAltitudeRad}, phaseOK) + } + } + } + } +} + +// 事件局部插值星历与精确星历的差别必须远小于任何求解容差,这是所有 +// "用插值星历迭代 + 精确星历复核"优化的前提。 +func TestSolarEclipseCandidateEvaluationMatchesExact(t *testing.T) { + seed := JDECalc(1136, 6, 1) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + solver = solver.withLocalEphemeris() + center := solver.newMoonJDE + maximum := 0.0 + for step := -21600.0; step <= 21600.0; step += 300 { + jd := center + step/86400.0 + exact := solver.magnitudeEvaluationAt(jd).center.stateAt(0.3, 0.6, 0) + candidate := solver.magnitudeCandidateEvaluationAt(jd).center.stateAt(0.3, 0.6, 0) + for _, delta := range []float64{ + candidate.separationRad - exact.separationRad, + candidate.sunAltitudeRad - exact.sunAltitudeRad, + candidate.sunRadiusRad - exact.sunRadiusRad, + candidate.moonOuterRadiusRad - exact.moonOuterRadiusRad, + candidate.moonInnerRadiusRad - exact.moonInnerRadiusRad, + } { + maximum = math.Max(maximum, math.Abs(delta)) + } + } + if maximum > 1e-9 { + t.Fatalf("candidate ephemeris deviates by %.3e rad, above the 1e-9 bound", maximum) + } +} + +// 中心相时长的插值求解必须与完整站心解一致到远小于目录精度的量级。 +func TestSolarEclipseCentralDurationMatchesExactLocalSolve(t *testing.T) { + for _, date := range [][3]int{{2024, 4, 8}, {2009, 7, 22}, {2017, 8, 21}, {2020, 6, 21}, {1136, 6, 1}} { + seed := JDECalc(date[0], date[1], float64(date[2])) + result := SolarEclipseNASABulletinSplitK(seed) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + solver = solver.withLocalEphemeris() + fast := solver.centralPhaseDurationDaysAt( + result.GreatestEclipse, result.GreatestLongitude, result.GreatestLatitude, + ) + local := LocalSolarEclipseNASABulletinSplitK( + result.GreatestEclipse, result.GreatestLongitude, result.GreatestLatitude, 0, + ) + exact := 0.0 + if local.CentralStart > 0 && local.CentralEnd > local.CentralStart { + exact = local.CentralEnd - local.CentralStart + } + // 两个解各自只收敛到接触根容差,插值星历本身的贡献在 1e-12 日量级。 + if math.Abs(fast-exact) > 3*localSolarEclipseContactTolerance { + t.Fatalf("%04d-%02d-%02d interpolated duration=%.12f days, exact=%.12f", + date[0], date[1], date[2], fast, exact) + } + if math.Abs(fast-result.CentralDurationDays) > 3*localSolarEclipseContactTolerance { + t.Fatalf("%04d-%02d-%02d published duration=%.12f, solver=%.12f", + date[0], date[1], date[2], result.CentralDurationDays, fast) + } + } +} + +// 路径与升落层必须与单时刻层使用同一个 ΔT:两层混用时食甚点会沿经度错开 +// 0.4651·|ΔΔT|·cosφ 千米。 +func TestSolarEclipsePathHonoursDeltaTOverride(t *testing.T) { + const override = 200.0 + seed := JDECalc(2024, 4, 8) + options := SolarEclipsePathOptions{StepDays: 2.0 / 1440.0, DeltaTSeconds: override} + path := SolarEclipseCentralPathNASABulletinSplitK(seed, options) + defaultPath := SolarEclipseCentralPathNASABulletinSplitK(seed, SolarEclipsePathOptions{StepDays: 2.0 / 1440.0}) + shift := math.Abs(path.Eclipse.GreatestLongitude - defaultPath.Eclipse.GreatestLongitude) + if shift < 0.3 || shift > 0.9 { + t.Fatalf("ΔT override moved the greatest point by %.4f degrees, want about 0.55", shift) + } + aligned, ok := SolarEclipseShadowAtJDE(path.Eclipse.GreatestEclipse, SolarEclipseShadowSolverOptions{ + DeltaTSeconds: override, Kind: SolarEclipseShadowUmbra, BoundaryPoints: 360, + }) + if !ok || len(aligned.Boundaries) == 0 { + t.Fatal("no umbral footprint at the greatest eclipse") + } + if distance := solarEclipseRingCenterDistanceKM(aligned.Boundaries, path.Greatest); distance > 5 { + t.Fatalf("path and instantaneous umbra differ by %.2f km under one ΔT", distance) + } + mismatched, ok := SolarEclipseShadowAtJDE(path.Eclipse.GreatestEclipse, SolarEclipseShadowSolverOptions{ + Kind: SolarEclipseShadowUmbra, BoundaryPoints: 360, + }) + if !ok || len(mismatched.Boundaries) == 0 { + t.Fatal("no umbral footprint for the mismatched ΔT control") + } + if distance := solarEclipseRingCenterDistanceKM(mismatched.Boundaries, path.Greatest); distance < 20 { + t.Fatalf("control distance %.2f km is too small to prove the override is honoured", distance) + } +} + +// solarEclipseRingCenterDistanceKM 取足迹各分段的平均位置到参考点的大圆距离。 +func solarEclipseRingCenterDistanceKM( + boundaries [][]SolarEclipsePathPoint, + reference SolarEclipsePathPoint, +) float64 { + points := 0 + longitude, latitude := 0.0, 0.0 + base := 0.0 + for _, boundary := range boundaries { + for _, point := range boundary { + if points == 0 { + base = point.Longitude + } + longitude += base + math.Remainder(point.Longitude-base, 360) + latitude += point.Latitude + points++ + } + } + if points == 0 { + return math.Inf(1) + } + return solarEclipsePathDistanceKM(SolarEclipsePathPoint{ + Longitude: normalizeLongitude(longitude / float64(points)), + Latitude: latitude / float64(points), + }, reference) +} + +// 限界线必须来自与宽度同一份配对横截面;这里用独立的两次求解复算一遍作为对照。 +func TestSolarEclipseLimitPairsMatchIndependentSolve(t *testing.T) { + seed := JDECalc(2024, 4, 8) + path := SolarEclipseCentralPathNASABulletinSplitK(seed, SolarEclipsePathOptions{StepDays: 2.0 / 1440.0}) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + referenceNorth, _ := solver.centralPathLimits(path.CenterLine) + if len(referenceNorth) == 0 { + t.Fatal("the independent limit solve returned nothing") + } + northern, southern, paired := solver.centralPathLimitPairs(path.CenterLine) + // 限界线与宽度共用同一份配对结果的前提:同一输入的配对必须逐位可复现。 + againNorth, againSouth, againPaired := solver.centralPathLimitPairs(path.CenterLine) + for index := range northern { + if northern[index] != againNorth[index] || southern[index] != againSouth[index] || paired[index] != againPaired[index] { + t.Fatalf("paired cross-section %d is not reproducible", index) + } + } + // 端点由接触对单独恢复宽度(setSolarEclipseCentralContactWidths),只校验内部样本。 + for index := 1; index < len(path.CenterLine)-1; index++ { + point := path.CenterLine[index] + want := 0.0 + if paired[index] { + want = solarEclipsePathDistanceKM(northern[index], southern[index]) + if want <= 0 || want > solarEclipsePathMaxPossibleWidthKM { + want = 0 + } + } + if point.WidthKM != want { + t.Fatalf("width at sample %d = %.6f km, paired cross-section gives %.6f km", + index, point.WidthKM, want) + } + } +} + +// 事件级状态缓存必须有界,且 ΔT 世代变化后旧条目必须视为未命中。 +func TestSolarEclipseLocalStateContextCacheIsBounded(t *testing.T) { + seed := JDECalc(2024, 4, 8) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + base := solver.newMoonJDE + for index := 0; index < 3*solarEclipseBesselGeometryCacheMaximumEntries; index++ { + solver.localStateContextAt(base + float64(index)*0.01) + } + if len(solver.localStateContextCache) > solarEclipseBesselGeometryCacheMaximumEntries { + t.Fatalf("local state cache holds %d entries, above the %d bound", + len(solver.localStateContextCache), solarEclipseBesselGeometryCacheMaximumEntries) + } + jd := base + 0.25 + before := solver.localStateContextAt(jd) + previous := GetDeltaTFn() + defer SetDeltaTFn(previous) + SetDeltaTFn(func(float64, bool) float64 { return 200 }) + after := solver.localStateContextAt(jd) + if after.gst == before.gst { + t.Fatalf("ΔT generation did not invalidate the cached context: gst %.12f", before.gst) + } + if after.generation != deltaTGenerationValue() { + t.Fatalf("cached context generation=%d, want %d", after.generation, deltaTGenerationValue()) + } +} + +// 升落层段数上限是质量门槛:超过就返回截断结果,调用方必须能通过降级标记看到它。 +func TestSolarEclipseRiseSetTopologyDegradedContract(t *testing.T) { + segments := make([][]SolarEclipsePathPoint, 17) + for index := range segments { + segments[index] = []SolarEclipsePathPoint{ + {JDE: float64(index) + 1}, {JDE: float64(index) + 1.5}, + } + } + overSegmented := []SolarEclipseRiseSetCurve{{ + Phase: RiseSetPhaseStart, Direction: RiseSetDirectionRise, Segments: segments, + }} + if solarEclipseRiseSetCurveTopologyComplete(overSegmented) { + t.Fatal("a 17-segment curve must not pass the topology audit") + } + seed := JDECalc(2024, 4, 8) + result := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0, + }) + if len(result.RiseSetCurves) == 0 { + t.Fatal("no rise/set curves were exported") + } + if want := !solarEclipseRiseSetCurveTopologyComplete(result.RiseSetCurves); result.RiseSetTopologyDegraded != want { + t.Fatalf("degraded flag=%v, topology audit says %v", result.RiseSetTopologyDegraded, want) + } + disabled := SolarEclipsePartialFootprintsNASABulletinSplitK(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, DisableRiseSetCurves: true, + }) + if disabled.RiseSetTopologyDegraded || len(disabled.RiseSetCurves) != 0 { + t.Fatalf("disabled rise/set curves must not report degradation: flag=%v curves=%d", + disabled.RiseSetTopologyDegraded, len(disabled.RiseSetCurves)) + } +} diff --git a/basic/solar_eclipse_rise_set.go b/basic/solar_eclipse_rise_set.go new file mode 100644 index 0000000..ee60533 --- /dev/null +++ b/basic/solar_eclipse_rise_set.go @@ -0,0 +1,624 @@ +package basic + +import ( + "math" + "sort" +) + +func solarEclipsePathSampleTimes(startJDE, endJDE, greatestJDE, requestedStepDays float64) ([]float64, float64) { + return solarEclipseMovingDiskEngine().sampleTimes( + startJDE, endJDE, greatestJDE, requestedStepDays, + ) +} + +type solarEclipseRiseSetCurveKey struct { + phase RiseSetPhase + direction RiseSetDirection +} + +type solarEclipseRiseSetTrack struct { + segments [][]SolarEclipsePathPoint +} + +type solarEclipseRiseSetEvaluation struct { + jd float64 + center localSolarEclipseStateContext + before localSolarEclipseStateContext + after localSolarEclipseStateContext +} + +type solarEclipseRiseSetSample struct { + point SolarEclipsePathPoint + key solarEclipseRiseSetCurveKey +} + +type solarEclipseRiseSetPhaseJunction struct { + point SolarEclipsePathPoint + direction RiseSetDirection +} + +type solarEclipseRiseSetSamplePair struct { + first solarEclipseRiseSetSample + second solarEclipseRiseSetSample +} + +func (solver solarEclipseSolver) riseSetCurves( + startJDE, endJDE, greatestJDE, requestedStepDays float64, +) []SolarEclipseRiseSetCurve { + curves, _ := solver.riseSetCurvesWithStatus(startJDE, endJDE, greatestJDE, requestedStepDays) + return curves +} + +// riseSetCurvesWithStatus 额外报告六类边界的拓扑校验结果:段数超过每曲线 16 段或用尽 +// 32 段总预算时返回的是截断结果,调用方必须显式标记降级而不是当作完整拓扑。 +func (solver solarEclipseSolver) riseSetCurvesWithStatus( + startJDE, endJDE, greatestJDE, requestedStepDays float64, +) ([]SolarEclipseRiseSetCurve, bool) { + if startJDE == 0 || endJDE == 0 || endJDE <= startJDE { + return nil, true + } + solver = solver.withLocalEphemeris() + traceStepDays := requestedStepDays + curves, phaseJunctions, traceStepDays := solver.sampleRiseSetCurves( + startJDE, endJDE, greatestJDE, traceStepDays, + ) + topologyStepDays := math.Max(traceStepDays, solarEclipseRiseSetCriticalStepDays) + // The sampled horizon already contains the complete six-key topology for + // ordinary events. Running the continuation tracer in that case can split a + // polar branch into hundreds of tiny components when its tangent changes + // sign near a fold. Close the sampled endpoints first; only invoke the + // expensive continuation path if the bounded raw topology cannot be closed. + if solarEclipseRiseSetRawTopologyUsable(curves) { + solver.finalizeRiseSetCurveTopology(curves, topologyStepDays, phaseJunctions) + if solarEclipseRiseSetCurveTopologyComplete(curves) { + if requestedStepDays > traceStepDays { + decimateSolarEclipseRiseSetCurves(curves, requestedStepDays) + } + return curves, true + } + } + traced := solver.traceRiseSetCurveTopology(curves, startJDE, endJDE, greatestJDE) + if len(traced) == 6 { + solver.finalizeRiseSetCurveTopology(traced, topologyStepDays, phaseJunctions) + } + if solarEclipseRiseSetCurveTopologyComplete(traced) { + curves = traced + } else { + for _, retryStepDays := range []float64{20.0 / 86400.0, 5.0 / 86400.0} { + if retryStepDays >= traceStepDays { + continue + } + curves, phaseJunctions, traceStepDays = solver.sampleRiseSetCurves( + startJDE, endJDE, greatestJDE, retryStepDays, + ) + topologyStepDays = math.Max(traceStepDays, solarEclipseRiseSetCriticalStepDays) + retryTraced := solver.traceRiseSetCurveTopology(curves, startJDE, endJDE, greatestJDE) + if len(retryTraced) == 6 { + solver.finalizeRiseSetCurveTopology(retryTraced, topologyStepDays, phaseJunctions) + } + if solarEclipseRiseSetCurveTopologyComplete(retryTraced) { + curves = retryTraced + break + } + solver.finalizeRiseSetCurveTopology(curves, topologyStepDays, phaseJunctions) + if solarEclipseRiseSetCurveTopologyComplete(curves) { + break + } + } + if !solarEclipseRiseSetCurveTopologyComplete(curves) { + solver.finalizeRiseSetCurveTopology(curves, topologyStepDays, phaseJunctions) + } + } + if requestedStepDays > traceStepDays { + decimateSolarEclipseRiseSetCurves(curves, requestedStepDays) + } + return curves, solarEclipseRiseSetCurveTopologyComplete(curves) +} + +func solarEclipseRiseSetRawTopologyUsable(curves []SolarEclipseRiseSetCurve) bool { + if len(curves) != 6 { + return false + } + seen := make(map[solarEclipseRiseSetCurveKey]bool, 6) + segments := 0 + for _, curve := range curves { + key := solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction} + if seen[key] || len(curve.Segments) == 0 { + return false + } + seen[key] = true + segments += len(curve.Segments) + } + return segments <= 32 +} + +func (solver solarEclipseSolver) sampleRiseSetCurves( + startJDE, endJDE, greatestJDE, traceStepDays float64, +) ([]SolarEclipseRiseSetCurve, []solarEclipseRiseSetPhaseJunction, float64) { + times, traceStepDays := solarEclipsePathSampleTimes(startJDE, endJDE, greatestJDE, traceStepDays) + keys := []solarEclipseRiseSetCurveKey{ + {RiseSetPhaseStart, RiseSetDirectionRise}, + {RiseSetPhaseStart, RiseSetDirectionSet}, + {RiseSetPhaseGreatest, RiseSetDirectionRise}, + {RiseSetPhaseGreatest, RiseSetDirectionSet}, + {RiseSetPhaseEnd, RiseSetDirectionRise}, + {RiseSetPhaseEnd, RiseSetDirectionSet}, + } + tracks := make(map[solarEclipseRiseSetCurveKey][]*solarEclipseRiseSetTrack, len(keys)) + var previousContactSamples []solarEclipseRiseSetSample + var phaseJunctions []solarEclipseRiseSetPhaseJunction + for _, jd := range times { + pointsAt := solver.riseSetCandidatePointsAt(jd, solarEclipseRiseSetBoundaryPoints) + contactSamples := solarEclipseRiseSetContactSamples(pointsAt) + phaseJunctions = solver.appendRiseSetPhaseJunctionsAtSamples( + phaseJunctions, contactSamples, traceStepDays, + ) + phaseJunctions = solver.appendRiseSetPhaseJunctionsBetweenSamples( + phaseJunctions, previousContactSamples, contactSamples, + ) + previousContactSamples = contactSamples + for _, key := range keys { + tracks[key] = appendSolarEclipseRiseSetSamples(tracks[key], pointsAt[key], traceStepDays) + } + } + + curves := make([]SolarEclipseRiseSetCurve, 0, len(keys)) + for _, key := range keys { + segments := make([][]SolarEclipsePathPoint, 0, len(tracks[key])) + for _, track := range tracks[key] { + for _, segment := range track.segments { + if len(segment) >= 2 { + segments = append(segments, segment) + } + } + } + if len(segments) == 0 { + continue + } + curves = append(curves, SolarEclipseRiseSetCurve{ + Phase: key.phase, Direction: key.direction, Segments: segments, + }) + } + return curves, phaseJunctions, traceStepDays +} + +func (solver solarEclipseSolver) finalizeRiseSetCurveTopology( + curves []SolarEclipseRiseSetCurve, + topologyStepDays float64, + phaseJunctions []solarEclipseRiseSetPhaseJunction, +) { + solver.completeRiseSetCurveEndpoints(curves, topologyStepDays, phaseJunctions) + snapSolarEclipseRiseSetArcPhaseJunctions(curves, phaseJunctions) + solver.closeSolarEclipseRiseSetArcFolds(curves, phaseJunctions) + solver.snapNearCoincidentSolarEclipseRiseSetEndpoints(curves) + solver.closeSolarEclipseRiseSetArcDirectionJunctions(curves, phaseJunctions) + deduplicateSolarEclipseRiseSetArcSegments(curves) + sortSolarEclipseRiseSetSegments(curves) +} + +func decimateSolarEclipseRiseSetCurves( + curves []SolarEclipseRiseSetCurve, + stepDays float64, +) { + for curveIndex := range curves { + for segmentIndex, segment := range curves[curveIndex].Segments { + if len(segment) < 3 { + continue + } + decimated := make([]SolarEclipsePathPoint, 1, len(segment)) + decimated[0] = segment[0] + lastIndex := 0 + for pointIndex := 1; pointIndex < len(segment)-1; pointIndex++ { + last := decimated[len(decimated)-1] + next := segment[pointIndex+1] + if next.JDE-last.JDE <= stepDays+solarEclipseRiseSetTimeEpsilonDays && + solarEclipsePathDistanceKM(last, next) <= solarEclipseRiseSetTargetSpacingKM { + accurate := true + for _, point := range segment[lastIndex+1 : pointIndex+1] { + if solarEclipseRiseSetChordDeviationKM(point, last, next) > solarEclipseRiseSetChordToleranceKM { + accurate = false + break + } + } + if accurate { + continue + } + } + decimated = append(decimated, segment[pointIndex]) + lastIndex = pointIndex + } + decimated = append(decimated, segment[len(segment)-1]) + curves[curveIndex].Segments[segmentIndex] = decimated + } + } +} + +func solarEclipseRiseSetContactSamples( + pointsAt map[solarEclipseRiseSetCurveKey][]SolarEclipsePathPoint, +) []solarEclipseRiseSetSample { + var samples []solarEclipseRiseSetSample + for _, phase := range []RiseSetPhase{RiseSetPhaseStart, RiseSetPhaseEnd} { + for _, direction := range []RiseSetDirection{RiseSetDirectionRise, RiseSetDirectionSet} { + key := solarEclipseRiseSetCurveKey{phase: phase, direction: direction} + for _, point := range pointsAt[key] { + samples = append(samples, solarEclipseRiseSetSample{point: point, key: key}) + } + } + } + return samples +} + +func (solver solarEclipseSolver) appendRiseSetPhaseJunctionsAtSamples( + junctions []solarEclipseRiseSetPhaseJunction, + samples []solarEclipseRiseSetSample, + stepDays float64, +) []solarEclipseRiseSetPhaseJunction { + for firstIndex, first := range samples { + for secondIndex := firstIndex + 1; secondIndex < len(samples); secondIndex++ { + second := samples[secondIndex] + if solarEclipsePathDistanceKM(first.point, second.point) > 1500 { + continue + } + candidate, ok := SolarEclipsePathPoint{}, false + for _, seed := range []SolarEclipsePathPoint{ + solarEclipseRiseSetMidpoint(first.point, second.point), + first.point, + second.point, + } { + candidate, ok = solver.refineRiseSetPhaseJunctionOnHorizon(seed) + if !ok { + candidate, ok = solver.refineRiseSetPhaseJunction( + seed.JDE, seed.Longitude, seed.Latitude, + ) + } + if ok { + break + } + } + if !ok || math.Abs(candidate.JDE-first.point.JDE) > stepDays || + solarEclipsePathDistanceKM(candidate, first.point) > 3000 || + solarEclipsePathDistanceKM(candidate, second.point) > 3000 { + continue + } + junctions = solver.appendRiseSetPhaseJunction(junctions, candidate) + } + } + return junctions +} + +func (solver solarEclipseSolver) appendRiseSetPhaseJunction( + junctions []solarEclipseRiseSetPhaseJunction, + candidate SolarEclipsePathPoint, +) []solarEclipseRiseSetPhaseJunction { + evaluation := solver.magnitudeEvaluationAt(candidate.JDE) + altitudeDerivative := evaluation.sunAltitudeDerivative(candidate.Longitude, candidate.Latitude) + if !finite(altitudeDerivative) || math.Abs(altitudeDerivative) < 1e-10 { + return junctions + } + direction := RiseSetDirectionSet + if altitudeDerivative > 0 { + direction = RiseSetDirectionRise + } + for _, junction := range junctions { + if math.Abs(junction.point.JDE-candidate.JDE) <= solarEclipseRiseSetTimeEpsilonDays && + solarEclipsePathDistanceKM(junction.point, candidate) <= 0.01 { + return junctions + } + } + return append(junctions, solarEclipseRiseSetPhaseJunction{ + point: candidate, direction: direction, + }) +} + +func (solver solarEclipseSolver) appendRiseSetPhaseJunctionsBetweenSamples( + junctions []solarEclipseRiseSetPhaseJunction, + previous, current []solarEclipseRiseSetSample, +) []solarEclipseRiseSetPhaseJunction { + if len(previous) == 0 || len(current) == 0 { + return junctions + } + for _, pair := range solarEclipseMatchRiseSetSamples(previous, current) { + if pair.first.key.phase == pair.second.key.phase { + continue + } + candidate, ok := solver.refineRiseSetPhaseJunctionBetweenSamples(pair.first, pair.second) + if !ok { + continue + } + if candidate.JDE < pair.first.point.JDE-solarEclipseRiseSetTimeEpsilonDays || + candidate.JDE > pair.second.point.JDE+solarEclipseRiseSetTimeEpsilonDays || + solarEclipsePathDistanceKM(candidate, pair.first.point) > 6000 || + solarEclipsePathDistanceKM(candidate, pair.second.point) > 6000 { + continue + } + junctions = solver.appendRiseSetPhaseJunction(junctions, candidate) + } + return junctions +} + +func solarEclipseMatchRiseSetSamples( + previous, current []solarEclipseRiseSetSample, +) []solarEclipseRiseSetSamplePair { + type candidatePair struct { + firstIndex int + secondIndex int + distance float64 + } + var candidates []candidatePair + for firstIndex, first := range previous { + for secondIndex, second := range current { + deltaDays := second.point.JDE - first.point.JDE + distance := solarEclipsePathDistanceKM(first.point, second.point) + if deltaDays <= 0 || riseSetGeographicBranchChanged(distance, deltaDays) { + continue + } + candidates = append(candidates, candidatePair{ + firstIndex: firstIndex, secondIndex: secondIndex, distance: distance, + }) + } + } + sort.Slice(candidates, func(first, second int) bool { + return candidates[first].distance < candidates[second].distance + }) + usedFirst := make([]bool, len(previous)) + usedSecond := make([]bool, len(current)) + var pairs []solarEclipseRiseSetSamplePair + for _, candidate := range candidates { + if usedFirst[candidate.firstIndex] || usedSecond[candidate.secondIndex] { + continue + } + usedFirst[candidate.firstIndex] = true + usedSecond[candidate.secondIndex] = true + pairs = append(pairs, solarEclipseRiseSetSamplePair{ + first: previous[candidate.firstIndex], second: current[candidate.secondIndex], + }) + } + return pairs +} + +func (solver solarEclipseSolver) refineRiseSetPhaseJunctionBetweenSamples( + first, second solarEclipseRiseSetSample, +) (SolarEclipsePathPoint, bool) { + left, right := first, second + for iteration := 0; iteration < 8; iteration++ { + midpoint := solarEclipseRiseSetMidpoint(left.point, right.point) + midpoint.JDE = (left.point.JDE + right.point.JDE) / 2 + current := solarEclipseRiseSetContactSamples( + solver.riseSetPointsAt(midpoint.JDE, solarEclipseRiseSetBoundaryPoints), + ) + bestIndex := -1 + bestDistance := math.Inf(1) + for index, sample := range current { + distance := solarEclipsePathDistanceKM(midpoint, sample.point) + if distance < bestDistance { + bestIndex, bestDistance = index, distance + } + } + if bestIndex < 0 || bestDistance > 1000 { + break + } + if current[bestIndex].key.phase == left.key.phase { + left = current[bestIndex] + } else if current[bestIndex].key.phase == right.key.phase { + right = current[bestIndex] + } else { + break + } + } + for _, seed := range []SolarEclipsePathPoint{ + solarEclipseRiseSetMidpoint(left.point, right.point), + left.point, + right.point, + } { + candidate, ok := solver.refineRiseSetPhaseJunctionOnHorizon(seed) + if !ok { + candidate, ok = solver.refineRiseSetPhaseJunction( + seed.JDE, seed.Longitude, seed.Latitude, + ) + } + if ok { + return candidate, true + } + } + return SolarEclipsePathPoint{}, false +} + +func (solver solarEclipseSolver) riseSetPointsAt( + jd float64, + boundaryPoints int, +) map[solarEclipseRiseSetCurveKey][]SolarEclipsePathPoint { + return solver.riseSetPointsAtEvaluation(jd, boundaryPoints, solver.magnitudeEvaluationAt(jd)) +} + +func (solver solarEclipseSolver) riseSetCandidatePointsAt( + jd float64, + boundaryPoints int, +) map[solarEclipseRiseSetCurveKey][]SolarEclipsePathPoint { + return solver.riseSetPointsAtEvaluation(jd, boundaryPoints, solver.magnitudeCandidateEvaluationAt(jd)) +} + +func (solver solarEclipseSolver) riseSetPointsAtEvaluation( + jd float64, + boundaryPoints int, + evaluation solarEclipseRiseSetEvaluation, +) map[solarEclipseRiseSetCurveKey][]SolarEclipsePathPoint { + result := make(map[solarEclipseRiseSetCurveKey][]SolarEclipsePathPoint, 6) + sun := solarEclipseXYZToLLR( + evaluation.center.sunXYZ[0], evaluation.center.sunXYZ[1], evaluation.center.sunXYZ[2], + ) + centerLongitude := normalizeLongitude((sun[0] - evaluation.center.gst) / rad) + centerLatitude := sun[1] / rad + + appendRoots := func(greatest bool) { + valueAt := func(angle float64) (float64, bool) { + longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle) + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + if greatest { + value := evaluation.separationDerivative(longitude, latitude) + return value, finite(value) + } + value := solarEclipsePartialContactGap(state) + return value, finite(value) + } + for _, angle := range riseSetCyclicRoots(boundaryPoints, valueAt) { + longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle) + longitude, latitude, ok := riseSetRefineGeographicRoot( + longitude, + latitude, + func(lon, lat float64) (float64, float64, bool) { + state := evaluation.center.stateAt(lon*rad, lat*rad, 0) + first := solarEclipsePartialContactGap(state) + if greatest { + first = evaluation.separationDerivative(lon, lat) + } + return first, state.sunAltitudeRad, finite(first) && finite(state.sunAltitudeRad) + }, + ) + if !ok { + continue + } + point, key, valid := evaluation.classify(longitude, latitude, greatest) + if !valid || solarEclipseRiseSetPointExists(result[key], point) { + continue + } + result[key] = append(result[key], point) + } + } + appendRoots(false) + appendRoots(true) + return result +} + +func (evaluation solarEclipseRiseSetEvaluation) classify( + longitude, latitude float64, + greatest bool, +) (SolarEclipsePathPoint, solarEclipseRiseSetCurveKey, bool) { + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + altitudeDerivative := evaluation.sunAltitudeDerivative(longitude, latitude) + if !finite(altitudeDerivative) || math.Abs(altitudeDerivative) < 1e-10 { + return SolarEclipsePathPoint{}, solarEclipseRiseSetCurveKey{}, false + } + direction := RiseSetDirectionSet + if altitudeDerivative > 0 { + direction = RiseSetDirectionRise + } + phase := RiseSetPhaseGreatest + if greatest { + if solarEclipsePartialContactGap(state) > 1e-7 || + evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + return SolarEclipsePathPoint{}, solarEclipseRiseSetCurveKey{}, false + } + } else { + contactDerivative := evaluation.partialContactDerivative(longitude, latitude) + if !finite(contactDerivative) || math.Abs(contactDerivative) < 1e-10 { + return SolarEclipsePathPoint{}, solarEclipseRiseSetCurveKey{}, false + } + phase = RiseSetPhaseEnd + if contactDerivative < 0 { + phase = RiseSetPhaseStart + } + } + return SolarEclipsePathPoint{ + JDE: evaluation.jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + }, solarEclipseRiseSetCurveKey{phase: phase, direction: direction}, true +} + +func solarEclipsePartialContactGap(state localSolarEclipseState) float64 { + return state.movingDiskContactState().externalContactGap() +} + +func (evaluation solarEclipseRiseSetEvaluation) partialContactDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0) + after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0) + return (solarEclipsePartialContactGap(after) - solarEclipsePartialContactGap(before)) / + (2 * solarEclipseRiseSetDerivativeStepDays) +} + +func (evaluation solarEclipseRiseSetEvaluation) partialContactSecondDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0) + center := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0) + stepSquared := solarEclipseRiseSetDerivativeStepDays * solarEclipseRiseSetDerivativeStepDays + return (solarEclipsePartialContactGap(after) - 2*solarEclipsePartialContactGap(center) + solarEclipsePartialContactGap(before)) / + stepSquared +} + +func (evaluation solarEclipseRiseSetEvaluation) sunAltitudeDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0).sunAltitudeRad + after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0).sunAltitudeRad + return (after - before) / (2 * solarEclipseRiseSetDerivativeStepDays) +} + +func (evaluation solarEclipseRiseSetEvaluation) sunAltitudeSecondDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0).sunAltitudeRad + center := evaluation.center.stateAt(longitude*rad, latitude*rad, 0).sunAltitudeRad + after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0).sunAltitudeRad + stepSquared := solarEclipseRiseSetDerivativeStepDays * solarEclipseRiseSetDerivativeStepDays + return (after - 2*center + before) / stepSquared +} + +func (evaluation solarEclipseRiseSetEvaluation) separationDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0).separationSquared + after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0).separationSquared + return (after - before) / (2 * solarEclipseRiseSetDerivativeStepDays) +} + +func (evaluation solarEclipseRiseSetEvaluation) separationSecondDerivative(longitude, latitude float64) float64 { + before := evaluation.before.stateAt(longitude*rad, latitude*rad, 0).separationSquared + center := evaluation.center.stateAt(longitude*rad, latitude*rad, 0).separationSquared + after := evaluation.after.stateAt(longitude*rad, latitude*rad, 0).separationSquared + stepSquared := solarEclipseRiseSetDerivativeStepDays * solarEclipseRiseSetDerivativeStepDays + return (after - 2*center + before) / stepSquared +} + +func solarEclipseRiseSetPointExists(points []SolarEclipsePathPoint, candidate SolarEclipsePathPoint) bool { + for _, point := range points { + if solarEclipsePathDistanceKM(point, candidate) < 0.01 { + return true + } + } + return false +} + +func appendSolarEclipseRiseSetSamples( + tracks []*solarEclipseRiseSetTrack, + points []SolarEclipsePathPoint, + stepDays float64, +) []*solarEclipseRiseSetTrack { + used := make([]bool, len(tracks)) + for _, point := range points { + bestTrack := -1 + bestDistance := math.Inf(1) + for index, track := range tracks { + if used[index] || len(track.segments) == 0 || len(track.segments[len(track.segments)-1]) == 0 { + continue + } + last := track.segments[len(track.segments)-1][len(track.segments[len(track.segments)-1])-1] + deltaDays := point.JDE - last.JDE + if deltaDays <= 0 || deltaDays > 2.5*stepDays { + continue + } + distance := solarEclipsePathDistanceKM(last, point) + if riseSetGeographicBranchChanged(distance, deltaDays) || distance >= bestDistance { + continue + } + bestTrack, bestDistance = index, distance + } + if bestTrack < 0 { + tracks = append(tracks, &solarEclipseRiseSetTrack{segments: [][]SolarEclipsePathPoint{{point}}}) + used = append(used, true) + continue + } + track := tracks[bestTrack] + track.segments[len(track.segments)-1] = append(track.segments[len(track.segments)-1], point) + used[bestTrack] = true + } + return tracks +} + +func riseSetGeographicBranchChanged(distanceKM, deltaDays float64) bool { + if distanceKM <= 750 { + return false + } + return deltaDays <= 0 || distanceKM/(deltaDays*86400) > 10 +} diff --git a/basic/solar_eclipse_rise_set_arc.go b/basic/solar_eclipse_rise_set_arc.go new file mode 100644 index 0000000..b8850ee --- /dev/null +++ b/basic/solar_eclipse_rise_set_arc.go @@ -0,0 +1,1031 @@ +package basic + +import ( + "math" + "sort" +) + +const ( + solarEclipseRiseSetArcTimeScale = 360.0 + solarEclipseRiseSetArcInitialStep = 4.0 + solarEclipseRiseSetArcMinimumStep = 0.0025 + solarEclipseRiseSetArcMaximumSteps = 8000 + solarEclipseRiseSetArcTargetSpacing = 500.0 + solarEclipseRiseSetArcCloseDistance = 550.0 + solarEclipseRiseSetArcCloseTimeDays = 5.0 / 1440.0 + solarEclipseRiseSetArcSeedDistance = 550.0 + solarEclipseRiseSetArcSeedTimeDays = 30.0 / 1440.0 +) + +type solarEclipseRiseSetArcState struct { + coordinates [3]float64 + tangent [3]float64 + point SolarEclipsePathPoint +} + +type solarEclipseRiseSetArcSegment struct { + key solarEclipseRiseSetCurveKey + points []SolarEclipsePathPoint +} + +type solarEclipseRiseSetArcTransition uint8 + +const ( + solarEclipseRiseSetArcNoTransition solarEclipseRiseSetArcTransition = iota + solarEclipseRiseSetArcPhaseTransition + solarEclipseRiseSetArcDirectionTransition + solarEclipseRiseSetArcFoldTransition +) + +func solarEclipseRiseSetCurveTopologyComplete(curves []SolarEclipseRiseSetCurve) bool { + if len(curves) != 6 { + return false + } + keys := make(map[solarEclipseRiseSetCurveKey]bool, 6) + for curveIndex, curve := range curves { + key := solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction} + if keys[key] || len(curve.Segments) == 0 || len(curve.Segments) > 16 { + return false + } + keys[key] = true + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + return false + } + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + if segment[pointIndex].JDE <= segment[pointIndex-1].JDE+solarEclipseRiseSetTimeEpsilonDays { + return false + } + } + for _, pointIndex := range []int{0, len(segment) - 1} { + if !solarEclipseRiseSetArcEndpointShared(curves, curveIndex, segmentIndex, pointIndex) { + return false + } + } + } + } + return len(keys) == 6 +} + +func (solver solarEclipseSolver) traceRiseSetCurveTopology( + seedCurves []SolarEclipseRiseSetCurve, + startJDE, endJDE, referenceJDE float64, +) []SolarEclipseRiseSetCurve { + if len(seedCurves) == 0 { + return nil + } + type component struct { + greatest bool + states []solarEclipseRiseSetArcState + } + var components []component + for _, curve := range seedCurves { + greatest := curve.Phase == RiseSetPhaseGreatest + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + seed := segment[len(segment)/2] + covered := false + for _, existing := range components { + if existing.greatest != greatest || !solarEclipseRiseSetArcContainsSeed(existing.states, seed) { + continue + } + covered = true + break + } + if covered { + continue + } + states := solver.traceRiseSetArcComponent(seed, greatest, referenceJDE, startJDE, endJDE) + if len(states) < 3 { + continue + } + components = append(components, component{greatest: greatest, states: states}) + } + } + + var traced []solarEclipseRiseSetArcSegment + for _, component := range components { + traced = append(traced, solver.splitRiseSetArcComponent(component.states, component.greatest)...) + } + if len(traced) == 0 { + return nil + } + curveSegments := make(map[solarEclipseRiseSetCurveKey][][]SolarEclipsePathPoint, 6) + for _, segment := range traced { + if len(segment.points) < 2 { + continue + } + points := segment.points + if points[0].JDE > points[len(points)-1].JDE { + points = append([]SolarEclipsePathPoint(nil), points...) + for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 { + points[left], points[right] = points[right], points[left] + } + } + curveSegments[segment.key] = append(curveSegments[segment.key], points) + } + keys := []solarEclipseRiseSetCurveKey{ + {RiseSetPhaseStart, RiseSetDirectionRise}, + {RiseSetPhaseStart, RiseSetDirectionSet}, + {RiseSetPhaseGreatest, RiseSetDirectionRise}, + {RiseSetPhaseGreatest, RiseSetDirectionSet}, + {RiseSetPhaseEnd, RiseSetDirectionRise}, + {RiseSetPhaseEnd, RiseSetDirectionSet}, + } + curves := make([]SolarEclipseRiseSetCurve, 0, len(keys)) + for _, key := range keys { + segments := curveSegments[key] + if len(segments) == 0 { + continue + } + curve := SolarEclipseRiseSetCurve{Phase: key.phase, Direction: key.direction, Segments: segments} + normalizeSolarEclipseRiseSetCurveSegments(&curve) + mergeSolarEclipseRiseSetArcContinuations(&curve) + if len(curve.Segments) > 0 { + curves = append(curves, curve) + } + } + return curves +} + +func mergeSolarEclipseRiseSetArcContinuations(curve *SolarEclipseRiseSetCurve) { + if curve == nil || len(curve.Segments) < 2 { + return + } + for { + merged := false + for firstIndex := 0; firstIndex < len(curve.Segments) && !merged; firstIndex++ { + first := curve.Segments[firstIndex] + if len(first) < 2 { + continue + } + for secondIndex := 0; secondIndex < len(curve.Segments); secondIndex++ { + if firstIndex == secondIndex { + continue + } + second := curve.Segments[secondIndex] + if len(second) < 2 || + math.Abs(second[0].JDE-first[len(first)-1].JDE) > solarEclipseRiseSetAttachmentTimeToleranceDays || + solarEclipsePathDistanceKM(first[len(first)-1], second[0]) > solarEclipseRiseSetAttachmentDistanceToleranceKM { + continue + } + joined := make([]SolarEclipsePathPoint, 0, len(first)+len(second)) + switch { + case second[1].JDE > first[len(first)-1].JDE+solarEclipseRiseSetTimeEpsilonDays: + joined = append(joined, first...) + joined = append(joined, second[1:]...) + case second[0].JDE > first[len(first)-2].JDE+solarEclipseRiseSetTimeEpsilonDays: + joined = append(joined, first[:len(first)-1]...) + joined = append(joined, second...) + default: + continue + } + curve.Segments[firstIndex] = joined + curve.Segments = append(curve.Segments[:secondIndex], curve.Segments[secondIndex+1:]...) + merged = true + break + } + } + if !merged { + return + } + } +} + +func snapSolarEclipseRiseSetArcPhaseJunctions( + curves []SolarEclipseRiseSetCurve, + junctions []solarEclipseRiseSetPhaseJunction, +) { + const ( + maximumTimeDays = 1.0 / 1440.0 + maximumDistance = 1000.0 + ) + for _, junction := range junctions { + for curveIndex := range curves { + curve := &curves[curveIndex] + if curve.Direction != junction.direction { + continue + } + if solarEclipseRiseSetArcPointInCurve(junction.point, *curve) { + continue + } + bestSegment, bestPoint := -1, -1 + bestMetric := math.Inf(1) + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, pointIndex := range []int{0, len(segment) - 1} { + if solarEclipseRiseSetArcEndpointShared(curves, curveIndex, segmentIndex, pointIndex) { + continue + } + point := segment[pointIndex] + deltaDays := math.Abs(point.JDE - junction.point.JDE) + distance := solarEclipsePathDistanceKM(point, junction.point) + if deltaDays > maximumTimeDays || distance > maximumDistance { + continue + } + metric := distance + deltaDays*8640 + if metric < bestMetric { + bestSegment, bestPoint, bestMetric = segmentIndex, pointIndex, metric + } + } + } + if bestSegment >= 0 { + curve.Segments[bestSegment][bestPoint] = junction.point + } + } + } + for curveIndex := range curves { + normalizeSolarEclipseRiseSetCurveSegments(&curves[curveIndex]) + mergeSolarEclipseRiseSetArcContinuations(&curves[curveIndex]) + } +} + +func (solver solarEclipseSolver) closeSolarEclipseRiseSetArcFolds( + curves []SolarEclipseRiseSetCurve, + junctions []solarEclipseRiseSetPhaseJunction, +) { + const ( + maximumFoldTimeDays = 2.0 / 1440.0 + maximumFoldDistance = 1500.0 + maximumBridgeDays = 5.0 / 1440.0 + maximumBridgeKM = 3000.0 + ) + for curveIndex := range curves { + curve := &curves[curveIndex] + greatest := curve.Phase == RiseSetPhaseGreatest + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, pointIndex := range []int{0, len(segment) - 1} { + endpoint := curve.Segments[segmentIndex][pointIndex] + if solarEclipseRiseSetArcEndpointShared(curves, curveIndex, segmentIndex, pointIndex) { + continue + } + fold, ok := solver.refineRiseSetFoldPoint( + endpoint.JDE, endpoint.Longitude, endpoint.Latitude, greatest, + ) + if !ok { + for _, junction := range junctions { + if junction.direction != curve.Direction || + math.Abs(junction.point.JDE-endpoint.JDE) > maximumBridgeDays || + solarEclipsePathDistanceKM(junction.point, endpoint) > maximumBridgeKM { + continue + } + fold, ok = solver.refineRiseSetFold(junction.point, endpoint, greatest) + if ok { + break + } + } + } + if !ok || math.Abs(fold.JDE-endpoint.JDE) > maximumFoldTimeDays || + solarEclipsePathDistanceKM(fold, endpoint) > maximumFoldDistance { + continue + } + curve.Segments[segmentIndex][pointIndex] = fold + bestJunction := SolarEclipsePathPoint{} + bestMetric := math.Inf(1) + for _, junction := range junctions { + if junction.direction != curve.Direction || + solarEclipseRiseSetArcPointInCurve(junction.point, *curve) { + continue + } + deltaDays := math.Abs(junction.point.JDE - fold.JDE) + distance := solarEclipsePathDistanceKM(junction.point, fold) + if deltaDays > maximumBridgeDays || distance > maximumBridgeKM { + continue + } + metric := distance + deltaDays*8640 + if metric < bestMetric { + bestJunction, bestMetric = junction.point, metric + } + } + if bestMetric == math.Inf(1) { + continue + } + start, end := bestJunction, fold + if start.JDE > end.JDE { + start, end = end, start + } + bridge := solver.appendRefinedRiseSetSegment( + []SolarEclipsePathPoint{start}, start, end, + curve.Phase, curve.Direction, 0, + ) + if len(bridge) >= 2 { + curve.Segments = append(curve.Segments, bridge) + } + } + } + } + for curveIndex := range curves { + normalizeSolarEclipseRiseSetCurveSegments(&curves[curveIndex]) + mergeSolarEclipseRiseSetArcContinuations(&curves[curveIndex]) + } +} + +func solarEclipseRiseSetArcEndpointShared( + curves []SolarEclipseRiseSetCurve, + curveIndex, segmentIndex, pointIndex int, +) bool { + point := curves[curveIndex].Segments[segmentIndex][pointIndex] + for otherCurveIndex, curve := range curves { + for otherSegmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, otherPointIndex := range []int{0, len(segment) - 1} { + if curveIndex == otherCurveIndex && segmentIndex == otherSegmentIndex && pointIndex == otherPointIndex { + continue + } + other := segment[otherPointIndex] + if math.Abs(point.JDE-other.JDE) <= solarEclipseRiseSetTimeEpsilonDays && + solarEclipsePathDistanceKM(point, other) <= 0.01 { + return true + } + } + } + } + return false +} + +func solarEclipseRiseSetArcPointInCurve( + point SolarEclipsePathPoint, + curve SolarEclipseRiseSetCurve, +) bool { + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, pointIndex := range []int{0, len(segment) - 1} { + candidate := segment[pointIndex] + if math.Abs(point.JDE-candidate.JDE) <= solarEclipseRiseSetTimeEpsilonDays && + solarEclipsePathDistanceKM(point, candidate) <= 0.01 { + return true + } + } + } + return false +} + +func (solver solarEclipseSolver) snapNearCoincidentSolarEclipseRiseSetEndpoints( + curves []SolarEclipseRiseSetCurve, +) { + const ( + maximumTimeDays = 2.0 / 86400.0 + maximumDistance = 10.0 + ) + for curveIndex := range curves { + curve := &curves[curveIndex] + for firstSegmentIndex, firstSegment := range curve.Segments { + if len(firstSegment) < 2 { + continue + } + for secondSegmentIndex := firstSegmentIndex + 1; secondSegmentIndex < len(curve.Segments); secondSegmentIndex++ { + secondSegment := curve.Segments[secondSegmentIndex] + if len(secondSegment) < 2 { + continue + } + for _, atStart := range []bool{true, false} { + firstPointIndex, secondPointIndex := len(firstSegment)-1, len(secondSegment)-1 + if atStart { + firstPointIndex, secondPointIndex = 0, 0 + } + first := curve.Segments[firstSegmentIndex][firstPointIndex] + second := curve.Segments[secondSegmentIndex][secondPointIndex] + if math.Abs(first.JDE-second.JDE) > maximumTimeDays || + solarEclipsePathDistanceKM(first, second) > maximumDistance { + continue + } + sharedFirst := solarEclipseRiseSetArcEndpointShared( + curves, curveIndex, firstSegmentIndex, firstPointIndex, + ) + sharedSecond := solarEclipseRiseSetArcEndpointShared( + curves, curveIndex, secondSegmentIndex, secondPointIndex, + ) + common := first + switch { + case sharedSecond && !sharedFirst: + common = second + case !sharedFirst && !sharedSecond: + if fold, ok := solver.refineRiseSetFold( + first, second, curve.Phase == RiseSetPhaseGreatest, + ); ok { + common = fold + } else { + common = solarEclipseRiseSetMidpoint(first, second) + } + } + curve.Segments[firstSegmentIndex][firstPointIndex] = common + curve.Segments[secondSegmentIndex][secondPointIndex] = common + } + } + } + } +} + +func (solver solarEclipseSolver) closeSolarEclipseRiseSetArcDirectionJunctions( + curves []SolarEclipseRiseSetCurve, + phaseJunctions []solarEclipseRiseSetPhaseJunction, +) { + const ( + maximumTimeDays = 2.0 / 1440.0 + maximumEndpointDistance = 1500.0 + maximumPairDistance = 3000.0 + ) + curveIndices := make(map[solarEclipseRiseSetCurveKey]int, len(curves)) + for index, curve := range curves { + curveIndices[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = index + } + for curveIndex := range curves { + curve := &curves[curveIndex] + greatest := curve.Phase == RiseSetPhaseGreatest + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + for _, pointIndex := range []int{0, len(segment) - 1} { + if solarEclipseRiseSetArcEndpointShared(curves, curveIndex, segmentIndex, pointIndex) { + continue + } + endpoint := curve.Segments[segmentIndex][pointIndex] + junction, ok := solver.refineRiseSetDirectionJunction( + endpoint.JDE, endpoint.Longitude, endpoint.Latitude, greatest, + ) + if !ok || math.Abs(junction.JDE-endpoint.JDE) > maximumTimeDays || + solarEclipsePathDistanceKM(junction, endpoint) > maximumEndpointDistance { + continue + } + oppositeDirection := RiseSetDirectionSet + if curve.Direction == RiseSetDirectionSet { + oppositeDirection = RiseSetDirectionRise + } + oppositeIndex, haveOpposite := curveIndices[solarEclipseRiseSetCurveKey{ + phase: curve.Phase, direction: oppositeDirection, + }] + if !haveOpposite { + continue + } + bestSegment, bestPoint := -1, -1 + bestMetric := math.Inf(1) + for otherSegmentIndex, otherSegment := range curves[oppositeIndex].Segments { + if len(otherSegment) < 2 { + continue + } + for _, otherPointIndex := range []int{0, len(otherSegment) - 1} { + other := otherSegment[otherPointIndex] + deltaDays := math.Abs(junction.JDE - other.JDE) + distance := solarEclipsePathDistanceKM(junction, other) + if deltaDays > maximumTimeDays || distance > maximumPairDistance { + continue + } + metric := distance + deltaDays*8640 + if metric < bestMetric { + bestSegment, bestPoint, bestMetric = otherSegmentIndex, otherPointIndex, metric + } + } + } + if bestSegment < 0 { + bestJunction := SolarEclipsePathPoint{} + bestJunctionMetric := math.Inf(1) + for _, phaseJunction := range phaseJunctions { + if phaseJunction.direction != oppositeDirection || + solarEclipseRiseSetArcPointInCurve(phaseJunction.point, curves[oppositeIndex]) { + continue + } + deltaDays := math.Abs(junction.JDE - phaseJunction.point.JDE) + distance := solarEclipsePathDistanceKM(junction, phaseJunction.point) + if deltaDays > maximumTimeDays || distance > maximumPairDistance { + continue + } + metric := distance + deltaDays*8640 + if metric < bestJunctionMetric { + bestJunction, bestJunctionMetric = phaseJunction.point, metric + } + } + if bestJunctionMetric == math.Inf(1) { + continue + } + start, end := junction, bestJunction + if start.JDE > end.JDE { + start, end = end, start + } + bridge := solver.appendRefinedRiseSetSegment( + []SolarEclipsePathPoint{start}, start, end, + curve.Phase, oppositeDirection, 0, + ) + if len(bridge) < 2 { + continue + } + curve.Segments[segmentIndex][pointIndex] = junction + curves[oppositeIndex].Segments = append(curves[oppositeIndex].Segments, bridge) + continue + } + curve.Segments[segmentIndex][pointIndex] = junction + curves[oppositeIndex].Segments[bestSegment][bestPoint] = junction + } + } + } + for curveIndex := range curves { + normalizeSolarEclipseRiseSetCurveSegments(&curves[curveIndex]) + mergeSolarEclipseRiseSetArcContinuations(&curves[curveIndex]) + } +} + +func solarEclipseRiseSetArcContainsSeed(states []solarEclipseRiseSetArcState, seed SolarEclipsePathPoint) bool { + for _, state := range states { + if math.Abs(state.point.JDE-seed.JDE) <= solarEclipseRiseSetArcSeedTimeDays && + solarEclipsePathDistanceKM(state.point, seed) <= solarEclipseRiseSetArcSeedDistance { + return true + } + } + return false +} + +func (solver solarEclipseSolver) traceRiseSetArcComponent( + seed SolarEclipsePathPoint, + greatest bool, + referenceJDE, startJDE, endJDE float64, +) []solarEclipseRiseSetArcState { + forward, closed := solver.traceRiseSetArc(seed, greatest, 1, referenceJDE, startJDE, endJDE) + if closed { + return forward + } + backward, _ := solver.traceRiseSetArc(seed, greatest, -1, referenceJDE, startJDE, endJDE) + states := make([]solarEclipseRiseSetArcState, 0, len(backward)+len(forward)-1) + for index := len(backward) - 1; index >= 0; index-- { + state := backward[index] + for tangentIndex := range state.tangent { + state.tangent[tangentIndex] = -state.tangent[tangentIndex] + } + states = append(states, state) + } + return append(states, forward[1:]...) +} + +func (solver solarEclipseSolver) traceRiseSetArc( + seed SolarEclipsePathPoint, + greatest bool, + direction int, + referenceJDE, startJDE, endJDE float64, +) ([]solarEclipseRiseSetArcState, bool) { + state, ok := solver.riseSetArcStateAt(seed, greatest, referenceJDE) + if !ok { + return nil, false + } + for index := range state.tangent { + state.tangent[index] *= float64(direction) + } + initial := state + states := []solarEclipseRiseSetArcState{state} + step := solarEclipseRiseSetArcInitialStep + previousSeedPlane := 0.0 + closureArmed := false + for count := 0; count < solarEclipseRiseSetArcMaximumSteps; count++ { + predictor := state.coordinates + for index := range predictor { + predictor[index] += step * state.tangent[index] + } + next, iterations, nextOK := solver.correctRiseSetArc(predictor, state.tangent, greatest, referenceJDE) + if !nextOK { + step /= 2 + if step < solarEclipseRiseSetArcMinimumStep { + break + } + continue + } + if dotSolarEclipse3(next.tangent, state.tangent) < 0 { + for index := range next.tangent { + next.tangent[index] = -next.tangent[index] + } + } + distance := solarEclipsePathDistanceKM(state.point, next.point) + multipleTransitions := solver.riseSetArcTransitionCount(state, next, greatest) > 1 + if distance > solarEclipseRiseSetArcTargetSpacing || + multipleTransitions && step > 2*solarEclipseRiseSetArcMinimumStep { + step /= 2 + if step < solarEclipseRiseSetArcMinimumStep && !multipleTransitions { + break + } + if step < solarEclipseRiseSetArcMinimumStep { + step = solarEclipseRiseSetArcMinimumStep + } + continue + } + if next.point.JDE < startJDE-0.01 || next.point.JDE > endJDE+0.01 { + break + } + states = append(states, next) + state = next + seedPlane := solarEclipseRiseSetArcSeedPlane(next, initial) + if seedPlane < -solarEclipseRiseSetArcMinimumStep { + closureArmed = true + } + if count > 30 && closureArmed && previousSeedPlane < 0 && seedPlane >= 0 && + math.Abs(next.point.JDE-initial.point.JDE) <= solarEclipseRiseSetArcCloseTimeDays && + solarEclipsePathDistanceKM(next.point, initial.point) <= solarEclipseRiseSetArcCloseDistance && + dotSolarEclipse3(next.tangent, initial.tangent) > 0.5 { + states[len(states)-1] = initial + return states, true + } + previousSeedPlane = seedPlane + if distance < solarEclipseRiseSetArcTargetSpacing/2 && iterations <= 4 { + step = math.Min(solarEclipseRiseSetArcInitialStep, step*1.5) + } + } + return states, false +} + +func solarEclipseRiseSetArcSeedPlane( + state, seed solarEclipseRiseSetArcState, +) float64 { + delta := [3]float64{ + math.Remainder(state.coordinates[0]-seed.coordinates[0], 360), + state.coordinates[1] - seed.coordinates[1], + state.coordinates[2] - seed.coordinates[2], + } + return dotSolarEclipse3(delta, seed.tangent) +} + +func (solver solarEclipseSolver) riseSetArcTransitionCount( + first, second solarEclipseRiseSetArcState, + greatest bool, +) int { + count := 0 + if first.tangent[2]*second.tangent[2] < 0 { + count++ + } + firstEvaluation := solver.magnitudeEvaluationAt(first.point.JDE) + secondEvaluation := solver.magnitudeEvaluationAt(second.point.JDE) + if firstEvaluation.sunAltitudeDerivative(first.point.Longitude, first.point.Latitude)* + secondEvaluation.sunAltitudeDerivative(second.point.Longitude, second.point.Latitude) < 0 { + count++ + } + if greatest { + firstGap := solarEclipsePartialContactGap(firstEvaluation.center.stateAt(first.point.Longitude*rad, first.point.Latitude*rad, 0)) + secondGap := solarEclipsePartialContactGap(secondEvaluation.center.stateAt(second.point.Longitude*rad, second.point.Latitude*rad, 0)) + if firstGap*secondGap < 0 { + count++ + } + } else if firstEvaluation.partialContactDerivative(first.point.Longitude, first.point.Latitude)* + secondEvaluation.partialContactDerivative(second.point.Longitude, second.point.Latitude) < 0 { + count++ + } + return count +} + +func (solver solarEclipseSolver) riseSetArcStateAt( + point SolarEclipsePathPoint, + greatest bool, + referenceJDE float64, +) (solarEclipseRiseSetArcState, bool) { + coordinates := [3]float64{ + point.Longitude, + point.Latitude, + (point.JDE - referenceJDE) * solarEclipseRiseSetArcTimeScale, + } + _, jacobian, ok := solver.riseSetArcJacobian(coordinates, greatest, referenceJDE) + if !ok { + return solarEclipseRiseSetArcState{}, false + } + tangent, ok := solarEclipseMagnitudeArcTangent(jacobian) + return solarEclipseRiseSetArcState{coordinates: coordinates, tangent: tangent, point: point}, ok +} + +func (solver solarEclipseSolver) correctRiseSetArc( + predictor, tangent [3]float64, + greatest bool, + referenceJDE float64, +) (solarEclipseRiseSetArcState, int, bool) { + coordinates := predictor + for iteration := 0; iteration < 16; iteration++ { + residual, jacobian, ok := solver.riseSetArcJacobian(coordinates, greatest, referenceJDE) + if !ok { + return solarEclipseRiseSetArcState{}, iteration, false + } + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(planeResidual) <= 1e-9 { + return solver.validRiseSetArcState(coordinates, jacobian, referenceJDE, iteration+1) + } + matrix := [3][3]float64{jacobian[0], jacobian[1], tangent} + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -planeResidual}) + if !ok { + return solarEclipseRiseSetArcState{}, iteration, false + } + norm := math.Sqrt(dotSolarEclipse3(delta, delta)) + if norm > 2 { + for index := range delta { + delta[index] *= 2 / norm + } + } + for index := range coordinates { + coordinates[index] += delta[index] + } + coordinates[0] = normalizeLongitude(coordinates[0]) + if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { + return solarEclipseRiseSetArcState{}, iteration, false + } + } + residual, jacobian, ok := solver.riseSetArcJacobian(coordinates, greatest, referenceJDE) + planeResidual := dotSolarEclipse3(subtractSolarEclipse3(coordinates, predictor), tangent) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-7 || math.Abs(planeResidual) > 1e-7 { + return solarEclipseRiseSetArcState{}, 16, false + } + return solver.validRiseSetArcState(coordinates, jacobian, referenceJDE, 16) +} + +func (solver solarEclipseSolver) validRiseSetArcState( + coordinates [3]float64, + jacobian [2][3]float64, + referenceJDE float64, + iterations int, +) (solarEclipseRiseSetArcState, int, bool) { + jd := referenceJDE + coordinates[2]/solarEclipseRiseSetArcTimeScale + longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] + evaluation := solver.magnitudeEvaluationAt(jd) + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + tangent, ok := solarEclipseMagnitudeArcTangent(jacobian) + if !ok { + return solarEclipseRiseSetArcState{}, iterations, false + } + return solarEclipseRiseSetArcState{ + coordinates: coordinates, + tangent: tangent, + point: SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: state.sunAltitudeRad / rad, + }, + }, iterations, true +} + +func (solver solarEclipseSolver) riseSetArcJacobian( + coordinates [3]float64, + greatest bool, + referenceJDE float64, +) ([2]float64, [2][3]float64, bool) { + jd := referenceJDE + coordinates[2]/solarEclipseRiseSetArcTimeScale + longitude, latitude := normalizeLongitude(coordinates[0]), coordinates[1] + evaluation := solver.magnitudeEvaluationAt(jd) + residual, ok := solarEclipseRiseSetArcResidualAt(evaluation, longitude, latitude, greatest) + if !ok { + return [2]float64{}, [2][3]float64{}, false + } + steps := [3]float64{1e-4, 1e-4, 5.0 * solarEclipseRiseSetArcTimeScale / 86400.0} + jacobian := [2][3]float64{} + for column, shifted := range [][2]float64{{longitude + steps[0], latitude}, {longitude, latitude + steps[1]}} { + shiftedResidual, shiftedOK := solarEclipseRiseSetArcResidualAt(evaluation, shifted[0], shifted[1], greatest) + if !shiftedOK { + return [2]float64{}, [2][3]float64{}, false + } + for row := 0; row < 2; row++ { + jacobian[row][column] = (shiftedResidual[row] - residual[row]) / steps[column] + } + } + timeEvaluation := solver.magnitudeEvaluationAt(jd + steps[2]/solarEclipseRiseSetArcTimeScale) + timeResidual, timeOK := solarEclipseRiseSetArcResidualAt(timeEvaluation, longitude, latitude, greatest) + if !timeOK { + return [2]float64{}, [2][3]float64{}, false + } + for row := 0; row < 2; row++ { + jacobian[row][2] = (timeResidual[row] - residual[row]) / steps[2] + } + return residual, jacobian, true +} + +func solarEclipseRiseSetArcResidualAt( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude float64, + greatest bool, +) ([2]float64, bool) { + // 非 greatest 相位残差就是同一个 stateAt 的中心距盈余,复用该状态而不是再算一次。 + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + phase := 0.0 + if greatest { + phase = evaluation.separationDerivative(longitude, latitude) + } else { + phase = solarEclipsePartialContactGap(state) + } + return [2]float64{phase, state.sunAltitudeRad}, + finite(phase) && finite(state.sunAltitudeRad) +} + +func (solver solarEclipseSolver) splitRiseSetArcComponent( + states []solarEclipseRiseSetArcState, + greatest bool, +) []solarEclipseRiseSetArcSegment { + if len(states) < 2 { + return nil + } + key, valid := solver.riseSetArcKey(states[0], greatest) + var segments []solarEclipseRiseSetArcSegment + current := solarEclipseRiseSetArcSegment{key: key} + if valid { + current.points = append(current.points, states[0].point) + } + for index := 0; index < len(states)-1; index++ { + first, second := states[index], states[index+1] + transition, point := solver.riseSetArcTransition(first, second, greatest) + if transition == solarEclipseRiseSetArcNoTransition { + if valid { + current.points = append(current.points, second.point) + } + continue + } + if valid { + current.points = append(current.points, point) + if len(current.points) >= 2 { + segments = append(segments, current) + } + } + switch transition { + case solarEclipseRiseSetArcPhaseTransition: + if greatest { + valid = !valid + } else if key.phase == RiseSetPhaseStart { + key.phase = RiseSetPhaseEnd + } else { + key.phase = RiseSetPhaseStart + } + case solarEclipseRiseSetArcDirectionTransition: + if key.direction == RiseSetDirectionRise { + key.direction = RiseSetDirectionSet + } else { + key.direction = RiseSetDirectionRise + } + case solarEclipseRiseSetArcFoldTransition: + } + if classifiedKey, classifiedValid := solver.riseSetArcKey(second, greatest); classifiedValid { + key, valid = classifiedKey, true + } else if greatest { + valid = false + } + current = solarEclipseRiseSetArcSegment{key: key} + if valid { + current.points = append(current.points, point, second.point) + } + } + if valid && len(current.points) >= 2 { + segments = append(segments, current) + } + if len(segments) >= 2 && states[0].point == states[len(states)-1].point && + segments[0].key == segments[len(segments)-1].key { + last := segments[len(segments)-1] + first := segments[0] + last.points = append(last.points[:len(last.points)-1], first.points...) + segments[0] = last + segments = segments[:len(segments)-1] + } + return segments +} + +func (solver solarEclipseSolver) riseSetArcKey( + state solarEclipseRiseSetArcState, + greatest bool, +) (solarEclipseRiseSetCurveKey, bool) { + evaluation := solver.magnitudeEvaluationAt(state.point.JDE) + if greatest { + longitude, latitude := state.point.Longitude, state.point.Latitude + local := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + altitudeDerivative := evaluation.sunAltitudeDerivative(longitude, latitude) + if solarEclipsePartialContactGap(local) > 0 || + evaluation.separationSecondDerivative(longitude, latitude) <= 0 || + !finite(altitudeDerivative) || math.Abs(altitudeDerivative) < 1e-10 { + return solarEclipseRiseSetCurveKey{}, false + } + direction := RiseSetDirectionSet + if altitudeDerivative > 0 { + direction = RiseSetDirectionRise + } + return solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: direction}, true + } + point, key, valid := evaluation.classify(state.point.Longitude, state.point.Latitude, greatest) + _ = point + return key, valid +} + +func (solver solarEclipseSolver) riseSetArcTransition( + first, second solarEclipseRiseSetArcState, + greatest bool, +) (solarEclipseRiseSetArcTransition, SolarEclipsePathPoint) { + firstEvaluation := solver.magnitudeEvaluationAt(first.point.JDE) + secondEvaluation := solver.magnitudeEvaluationAt(second.point.JDE) + if greatest { + firstGap := solarEclipsePartialContactGap(firstEvaluation.center.stateAt(first.point.Longitude*rad, first.point.Latitude*rad, 0)) + secondGap := solarEclipsePartialContactGap(secondEvaluation.center.stateAt(second.point.Longitude*rad, second.point.Latitude*rad, 0)) + if firstGap*secondGap <= 0 { + if point, ok := solver.refineRiseSetPhaseJunctionOnHorizon(solarEclipseRiseSetMidpoint(first.point, second.point)); ok { + return solarEclipseRiseSetArcPhaseTransition, point + } + } + } else { + firstDerivative := firstEvaluation.partialContactDerivative(first.point.Longitude, first.point.Latitude) + secondDerivative := secondEvaluation.partialContactDerivative(second.point.Longitude, second.point.Latitude) + if firstDerivative*secondDerivative <= 0 { + if point, ok := solver.refineRiseSetPhaseJunctionOnHorizon(solarEclipseRiseSetMidpoint(first.point, second.point)); ok { + return solarEclipseRiseSetArcPhaseTransition, point + } + } + } + firstAltitudeDerivative := firstEvaluation.sunAltitudeDerivative(first.point.Longitude, first.point.Latitude) + secondAltitudeDerivative := secondEvaluation.sunAltitudeDerivative(second.point.Longitude, second.point.Latitude) + if firstAltitudeDerivative*secondAltitudeDerivative <= 0 { + if point, ok := solver.refineRiseSetDirectionJunction( + (first.point.JDE+second.point.JDE)/2, + normalizeLongitude(first.point.Longitude+math.Remainder(second.point.Longitude-first.point.Longitude, 360)/2), + (first.point.Latitude+second.point.Latitude)/2, + greatest, + ); ok { + return solarEclipseRiseSetArcDirectionTransition, point + } + } + if first.tangent[2]*second.tangent[2] <= 0 { + if point, ok := solver.refineRiseSetFold(first.point, second.point, greatest); ok { + return solarEclipseRiseSetArcFoldTransition, point + } + } + return solarEclipseRiseSetArcNoTransition, SolarEclipsePathPoint{} +} + +func sortSolarEclipseRiseSetSegments(curves []SolarEclipseRiseSetCurve) { + for curveIndex := range curves { + sort.Slice(curves[curveIndex].Segments, func(first, second int) bool { + return curves[curveIndex].Segments[first][0].JDE < curves[curveIndex].Segments[second][0].JDE + }) + } +} + +func deduplicateSolarEclipseRiseSetArcSegments(curves []SolarEclipseRiseSetCurve) { + for curveIndex := range curves { + segments := curves[curveIndex].Segments + unique := make([][]SolarEclipsePathPoint, 0, len(segments)) + for _, segment := range segments { + duplicate := false + for _, existing := range unique { + if solarEclipseRiseSetArcSegmentsEquivalent(segment, existing) { + duplicate = true + break + } + } + if !duplicate { + unique = append(unique, segment) + } + } + curves[curveIndex].Segments = unique + } +} + +func solarEclipseRiseSetArcSegmentsEquivalent( + first, second []SolarEclipsePathPoint, +) bool { + if len(first) < 2 || len(second) < 2 { + return false + } + for _, endpoints := range [][2]SolarEclipsePathPoint{ + {first[0], second[0]}, + {first[len(first)-1], second[len(second)-1]}, + } { + if math.Abs(endpoints[0].JDE-endpoints[1].JDE) > 1.0/86400.0 || + solarEclipsePathDistanceKM(endpoints[0], endpoints[1]) > 1 { + return false + } + } + for _, fraction := range []float64{0.25, 0.5, 0.75} { + jd := first[0].JDE + fraction*(first[len(first)-1].JDE-first[0].JDE) + firstPoint, firstOK := solarEclipseRiseSetArcPointAtTime(first, jd) + secondPoint, secondOK := solarEclipseRiseSetArcPointAtTime(second, jd) + if !firstOK || !secondOK || solarEclipsePathDistanceKM(firstPoint, secondPoint) > 250 { + return false + } + } + return true +} + +func solarEclipseRiseSetArcPointAtTime( + segment []SolarEclipsePathPoint, + jd float64, +) (SolarEclipsePathPoint, bool) { + if len(segment) < 2 || jd < segment[0].JDE || jd > segment[len(segment)-1].JDE { + return SolarEclipsePathPoint{}, false + } + index := sort.Search(len(segment), func(index int) bool { return segment[index].JDE >= jd }) + if index == 0 { + return segment[0], true + } + if index >= len(segment) { + return segment[len(segment)-1], true + } + before, after := segment[index-1], segment[index] + if after.JDE <= before.JDE { + return SolarEclipsePathPoint{}, false + } + fraction := (jd - before.JDE) / (after.JDE - before.JDE) + return SolarEclipsePathPoint{ + JDE: jd, + Longitude: normalizeLongitude( + before.Longitude + fraction*math.Remainder(after.Longitude-before.Longitude, 360), + ), + Latitude: before.Latitude + fraction*(after.Latitude-before.Latitude), + }, true +} diff --git a/basic/solar_eclipse_rise_set_refine.go b/basic/solar_eclipse_rise_set_refine.go new file mode 100644 index 0000000..13a4bcc --- /dev/null +++ b/basic/solar_eclipse_rise_set_refine.go @@ -0,0 +1,632 @@ +package basic + +import "math" + +func (solver solarEclipseSolver) refineRiseSetPhaseJunction( + jd, longitude, latitude float64, +) (SolarEclipsePathPoint, bool) { + const ( + geographicStep = 1e-4 + timeStep = 1.0 / 86400.0 + ) + for iteration := 0; iteration < 24; iteration++ { + evaluation := solver.magnitudeEvaluationAt(jd) + residual, ok := solarEclipseRiseSetPhaseJunctionResidualAt(evaluation, longitude, latitude) + if !ok { + return SolarEclipsePathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 { + break + } + longitudeResidual, longitudeOK := solarEclipseRiseSetPhaseJunctionResidualAt( + evaluation, longitude+geographicStep, latitude, + ) + latitudeResidual, latitudeOK := solarEclipseRiseSetPhaseJunctionResidualAt( + evaluation, longitude, latitude+geographicStep, + ) + timeResidual, timeOK := solver.riseSetPhaseJunctionResidual(jd+timeStep, longitude, latitude) + if !longitudeOK || !latitudeOK || !timeOK { + return SolarEclipsePathPoint{}, false + } + matrix := [3][3]float64{} + for row := 0; row < 3; row++ { + matrix[row][0] = (longitudeResidual[row] - residual[row]) / geographicStep + matrix[row][1] = (latitudeResidual[row] - residual[row]) / geographicStep + matrix[row][2] = (timeResidual[row] - residual[row]) / timeStep + } + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !ok { + return SolarEclipsePathPoint{}, false + } + geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1])) + if geographicScale > 2 { + delta[0] *= 2 / geographicScale + delta[1] *= 2 / geographicScale + } + if math.Abs(delta[2]) > 5.0/1440.0 { + delta[2] = math.Copysign(5.0/1440.0, delta[2]) + } + longitude = normalizeLongitude(longitude + delta[0]) + latitude += delta[1] + jd += delta[2] + if latitude <= -89.999999 || latitude >= 89.999999 { + return SolarEclipsePathPoint{}, false + } + } + residual, ok := solver.riseSetPhaseJunctionResidual(jd, longitude, latitude) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-7 || math.Abs(residual[2]) > 1e-8 { + return SolarEclipsePathPoint{}, false + } + evaluation := solver.magnitudeEvaluationAt(jd) + if evaluation.partialContactSecondDerivative(longitude, latitude) <= 0 { + return SolarEclipsePathPoint{}, false + } + return SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: residual[2] / rad, + }, true +} + +func (solver solarEclipseSolver) refineRiseSetPhaseJunctionOnHorizon( + seed SolarEclipsePathPoint, +) (SolarEclipsePathPoint, bool) { + coordinates := [2]float64{ + solver.riseSetHorizonAngle(seed.JDE, seed.Longitude, seed.Latitude), + 0, + } + residualAt := func(value [2]float64) ([2]float64, float64, float64, float64, bool) { + jd := seed.JDE + value[1]/1440 + longitude, latitude := solver.riseSetHorizonPointAt(jd, value[0]) + evaluation := solver.magnitudeEvaluationAt(jd) + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + residual := [2]float64{ + solarEclipsePartialContactGap(state), + evaluation.partialContactDerivative(longitude, latitude), + } + return residual, jd, longitude, latitude, finite(residual[0]) && finite(residual[1]) + } + const ( + angleStep = 1e-4 + timeStepMinute = 1.0 / 60.0 + ) + for iteration := 0; iteration < 32; iteration++ { + residual, _, _, _, ok := residualAt(coordinates) + if !ok { + return SolarEclipsePathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-9 { + break + } + anglePlus, _, _, _, anglePlusOK := residualAt([2]float64{coordinates[0] + angleStep, coordinates[1]}) + angleMinus, _, _, _, angleMinusOK := residualAt([2]float64{coordinates[0] - angleStep, coordinates[1]}) + timePlus, _, _, _, timePlusOK := residualAt([2]float64{coordinates[0], coordinates[1] + timeStepMinute}) + timeMinus, _, _, _, timeMinusOK := residualAt([2]float64{coordinates[0], coordinates[1] - timeStepMinute}) + if !anglePlusOK || !angleMinusOK || !timePlusOK || !timeMinusOK { + return SolarEclipsePathPoint{}, false + } + matrix := [2][2]float64{ + {(anglePlus[0] - angleMinus[0]) / (2 * angleStep), (timePlus[0] - timeMinus[0]) / (2 * timeStepMinute)}, + {(anglePlus[1] - angleMinus[1]) / (2 * angleStep), (timePlus[1] - timeMinus[1]) / (2 * timeStepMinute)}, + } + determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0] + if !finite(determinant) || math.Abs(determinant) < 1e-18 { + return SolarEclipsePathPoint{}, false + } + delta := [2]float64{ + (-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant, + (-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant, + } + if math.Abs(delta[0]) > 0.25 { + delta[0] = math.Copysign(0.25, delta[0]) + } + if math.Abs(delta[1]) > 5 { + delta[1] = math.Copysign(5, delta[1]) + } + coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0]) + coordinates[1] += delta[1] + } + residual, jd, longitude, latitude, ok := residualAt(coordinates) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-7 { + return SolarEclipsePathPoint{}, false + } + return solver.refineRiseSetPhaseJunction(jd, longitude, latitude) +} + +func (solver solarEclipseSolver) riseSetPhaseSegmentIsContinuous( + start, end SolarEclipsePathPoint, + phase RiseSetPhase, + direction RiseSetDirection, +) bool { + if math.Abs(end.JDE-start.JDE)*86400 < 0.1 { + return false + } + totalDistance := solarEclipsePathDistanceKM(start, end) + continuityToleranceKM := math.Max(50, 0.05*totalDistance) + candidate := end + for divisor := 2.0; divisor <= 1024; divisor *= 2 { + jd := start.JDE + (end.JDE-start.JDE)/divisor + seedAngle := solver.riseSetHorizonAngle(jd, candidate.Longitude, candidate.Latitude) + next, ok := solver.riseSetPhasePointOnHorizon(jd, seedAngle, phase, direction) + if !ok { + return solarEclipsePathDistanceKM(start, candidate) <= continuityToleranceKM + } + candidate = next + } + return solarEclipsePathDistanceKM(start, candidate) <= continuityToleranceKM +} + +func (solver solarEclipseSolver) riseSetPhasePointOnHorizon( + jd, angle float64, + phase RiseSetPhase, + direction RiseSetDirection, +) (SolarEclipsePathPoint, bool) { + evaluation := solver.magnitudeEvaluationAt(jd) + greatest := phase == RiseSetPhaseGreatest + valueAt := func(candidateAngle float64) (float64, float64, float64, bool) { + longitude, latitude := solver.riseSetHorizonPointAt(jd, candidateAngle) + value, ok := solarEclipseRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) + return value, longitude, latitude, ok && finite(value) + } + const angleStep = 1e-4 + angle = riseSetNormalizeRadians(angle) + for iteration := 0; iteration < 24; iteration++ { + value, _, _, ok := valueAt(angle) + if !ok { + return SolarEclipsePathPoint{}, false + } + if math.Abs(value) <= 1e-10 { + break + } + before, _, _, beforeOK := valueAt(angle - angleStep) + after, _, _, afterOK := valueAt(angle + angleStep) + if !beforeOK || !afterOK { + return SolarEclipsePathPoint{}, false + } + derivative := (after - before) / (2 * angleStep) + if !finite(derivative) || math.Abs(derivative) < 1e-16 { + return SolarEclipsePathPoint{}, false + } + delta := -value / derivative + if math.Abs(delta) > 0.25 { + delta = math.Copysign(0.25, delta) + } + angle = riseSetNormalizeRadians(angle + delta) + } + value, longitude, latitude, ok := valueAt(angle) + if !ok || math.Abs(value) > 1e-7 { + return SolarEclipsePathPoint{}, false + } + longitude, latitude, ok = riseSetRefineGeographicRoot( + longitude, + latitude, + func(lon, lat float64) (float64, float64, bool) { + state := evaluation.center.stateAt(lon*rad, lat*rad, 0) + first, valid := solarEclipseRiseSetPhaseResidual(evaluation, lon, lat, greatest) + return first, state.sunAltitudeRad, valid && finite(state.sunAltitudeRad) + }, + ) + if !ok { + return SolarEclipsePathPoint{}, false + } + point, key, valid := evaluation.classify(longitude, latitude, greatest) + if !valid || key.phase != phase || key.direction != direction { + return SolarEclipsePathPoint{}, false + } + return point, true +} + +func (solver solarEclipseSolver) riseSetFoldBridgesPhaseJunction( + junction SolarEclipsePathPoint, + endpoint solarEclipseRiseSetEndpointRef, + fold SolarEclipsePathPoint, + phase RiseSetPhase, + direction RiseSetDirection, +) bool { + const timeToleranceDays = 1e-8 + if endpoint.atStart { + if fold.JDE > math.Min(junction.JDE, endpoint.point.JDE)+timeToleranceDays { + return false + } + } else if fold.JDE < math.Max(junction.JDE, endpoint.point.JDE)-timeToleranceDays { + return false + } + if solarEclipsePathDistanceKM(fold, junction) > 6000 || + solarEclipsePathDistanceKM(fold, endpoint.point) > 6000 { + return false + } + evaluation := solver.magnitudeEvaluationAt(fold.JDE) + _, key, valid := evaluation.classify( + fold.Longitude, fold.Latitude, phase == RiseSetPhaseGreatest, + ) + return valid && key.phase == phase && key.direction == direction +} + +func (solver solarEclipseSolver) refineRiseSetFoldNearPhaseJunction( + junction SolarEclipsePathPoint, + endpoint solarEclipseRiseSetEndpointRef, + phase RiseSetPhase, + direction RiseSetDirection, + stepDays float64, +) (SolarEclipsePathPoint, bool) { + key := solarEclipseRiseSetCurveKey{phase: phase, direction: direction} + greatest := phase == RiseSetPhaseGreatest + for _, sign := range []float64{-1, 1} { + for divisor := 1024.0; divisor >= 1; divisor /= 2 { + fraction := 1 / divisor + jd := junction.JDE + sign*fraction*stepDays + roots := solver.riseSetPointsAt(jd, solarEclipseRiseSetBoundaryPoints)[key] + if len(roots) < 2 { + continue + } + for first := 0; first < len(roots); first++ { + for second := first + 1; second < len(roots); second++ { + fold, ok := solver.refineRiseSetFold(roots[first], roots[second], greatest) + if ok && solver.riseSetFoldBridgesPhaseJunction( + junction, endpoint, fold, phase, direction, + ) { + return fold, true + } + } + } + } + } + return SolarEclipsePathPoint{}, false +} + +func (solver solarEclipseSolver) riseSetPhaseJunctionResidual( + jd, longitude, latitude float64, +) ([3]float64, bool) { + evaluation := solver.magnitudeEvaluationAt(jd) + return solarEclipseRiseSetPhaseJunctionResidualAt(evaluation, longitude, latitude) +} + +func solarEclipseRiseSetPhaseJunctionResidualAt( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude float64, +) ([3]float64, bool) { + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + residual := [3]float64{ + solarEclipsePartialContactGap(state), + evaluation.partialContactDerivative(longitude, latitude), + state.sunAltitudeRad, + } + return residual, finite(residual[0]) && finite(residual[1]) && finite(residual[2]) +} + +func (solver solarEclipseSolver) refineRiseSetFold( + first, second SolarEclipsePathPoint, + greatest bool, +) (SolarEclipsePathPoint, bool) { + seedJDE := (first.JDE + second.JDE) / 2 + firstAngle := solver.riseSetHorizonAngle(seedJDE, first.Longitude, first.Latitude) + secondAngle := solver.riseSetHorizonAngle(seedJDE, second.Longitude, second.Latitude) + deltaAngle := math.Remainder(secondAngle-firstAngle, 2*math.Pi) + coordinates := [2]float64{riseSetNormalizeRadians(firstAngle + deltaAngle/2), 0} + const ( + angleStep = 1e-4 + timeStepMinute = 1.0 / 60.0 + ) + for iteration := 0; iteration < 32; iteration++ { + residual, ok := solver.riseSetFoldHorizonResidual(seedJDE, coordinates, greatest) + if !ok { + return SolarEclipsePathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-9 { + break + } + anglePlus, anglePlusOK := solver.riseSetFoldHorizonResidual( + seedJDE, [2]float64{coordinates[0] + angleStep, coordinates[1]}, greatest, + ) + angleMinus, angleMinusOK := solver.riseSetFoldHorizonResidual( + seedJDE, [2]float64{coordinates[0] - angleStep, coordinates[1]}, greatest, + ) + timePlus, timePlusOK := solver.riseSetFoldHorizonResidual( + seedJDE, [2]float64{coordinates[0], coordinates[1] + timeStepMinute}, greatest, + ) + timeMinus, timeMinusOK := solver.riseSetFoldHorizonResidual( + seedJDE, [2]float64{coordinates[0], coordinates[1] - timeStepMinute}, greatest, + ) + if !anglePlusOK || !angleMinusOK || !timePlusOK || !timeMinusOK { + return SolarEclipsePathPoint{}, false + } + matrix := [2][2]float64{} + for row := 0; row < 2; row++ { + matrix[row][0] = (anglePlus[row] - angleMinus[row]) / (2 * angleStep) + matrix[row][1] = (timePlus[row] - timeMinus[row]) / (2 * timeStepMinute) + } + determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0] + if !finite(determinant) || math.Abs(determinant) < 1e-18 { + return SolarEclipsePathPoint{}, false + } + delta := [2]float64{ + (-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant, + (-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant, + } + if math.Abs(delta[0]) > 0.25 { + delta[0] = math.Copysign(0.25, delta[0]) + } + if math.Abs(delta[1]) > 5 { + delta[1] = math.Copysign(5, delta[1]) + } + coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0]) + coordinates[1] += delta[1] + } + jd := seedJDE + coordinates[1]/1440.0 + longitude, latitude := solver.riseSetHorizonPointAt(jd, coordinates[0]) + return solver.refineRiseSetFoldPoint(jd, longitude, latitude, greatest) +} + +func (solver solarEclipseSolver) riseSetFoldHorizonResidual( + seedJDE float64, + coordinates [2]float64, + greatest bool, +) ([2]float64, bool) { + const derivativeStep = 1e-4 + jd := seedJDE + coordinates[1]/1440.0 + evaluation := solver.magnitudeEvaluationAt(jd) + centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation) + valueAt := func(angle float64) (float64, bool) { + longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle) + return solarEclipseRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) + } + center, centerOK := valueAt(coordinates[0]) + before, beforeOK := valueAt(coordinates[0] - derivativeStep) + after, afterOK := valueAt(coordinates[0] + derivativeStep) + residual := [2]float64{center, (after - before) / (2 * derivativeStep)} + return residual, centerOK && beforeOK && afterOK && finite(residual[1]) +} + +func (solver solarEclipseSolver) riseSetHorizonPointAt(jd, angle float64) (float64, float64) { + evaluation := solver.magnitudeEvaluationAt(jd) + centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation) + return riseSetHorizonPoint(centerLongitude, centerLatitude, angle) +} + +func (solver solarEclipseSolver) riseSetHorizonAngle( + jd, longitude, latitude float64, +) float64 { + evaluation := solver.magnitudeEvaluationAt(jd) + centerLongitude, centerLatitude := solarEclipseRiseSetHorizonCenter(evaluation) + centerLon, centerLat := centerLongitude*rad, centerLatitude*rad + center := [3]float64{ + math.Cos(centerLat) * math.Cos(centerLon), + math.Cos(centerLat) * math.Sin(centerLon), + math.Sin(centerLat), + } + reference := [3]float64{0, 0, 1} + if math.Abs(center[2]) > 0.9 { + reference = [3]float64{1, 0, 0} + } + first := riseSetUnitVector(riseSetCross(reference, center)) + second := riseSetUnitVector(riseSetCross(center, first)) + lon, lat := longitude*rad, latitude*rad + point := [3]float64{math.Cos(lat) * math.Cos(lon), math.Cos(lat) * math.Sin(lon), math.Sin(lat)} + return riseSetNormalizeRadians(math.Atan2(dotSolarEclipse3(point, second), dotSolarEclipse3(point, first))) +} + +func solarEclipseRiseSetHorizonCenter(evaluation solarEclipseRiseSetEvaluation) (float64, float64) { + sun := solarEclipseXYZToLLR( + evaluation.center.sunXYZ[0], evaluation.center.sunXYZ[1], evaluation.center.sunXYZ[2], + ) + return normalizeLongitude((sun[0] - evaluation.center.gst) / rad), sun[1] / rad +} + +func (solver solarEclipseSolver) refineRiseSetFoldPoint( + jd, longitude, latitude float64, + greatest bool, +) (SolarEclipsePathPoint, bool) { + const ( + geographicStep = 1e-3 + timeStep = 1.0 / 86400.0 + ) + coordinates := [3]float64{longitude, latitude, jd} + for iteration := 0; iteration < 32; iteration++ { + evaluation := solver.magnitudeEvaluationAt(coordinates[2]) + residual, ok := solarEclipseRiseSetFoldResidualAt(evaluation, coordinates[0], coordinates[1], greatest) + if !ok { + return SolarEclipsePathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 { + break + } + steps := [3]float64{geographicStep, geographicStep, timeStep} + matrix := [3][3]float64{} + for column := 0; column < 3; column++ { + plus, minus := coordinates, coordinates + plus[column] += steps[column] + minus[column] -= steps[column] + var plusResidual, minusResidual [3]float64 + var plusOK, minusOK bool + if column < 2 { + plusResidual, plusOK = solarEclipseRiseSetFoldResidualAt(evaluation, plus[0], plus[1], greatest) + minusResidual, minusOK = solarEclipseRiseSetFoldResidualAt(evaluation, minus[0], minus[1], greatest) + } else { + plusResidual, plusOK = solver.riseSetFoldResidual(plus[2], plus[0], plus[1], greatest) + minusResidual, minusOK = solver.riseSetFoldResidual(minus[2], minus[0], minus[1], greatest) + } + if !plusOK || !minusOK { + return SolarEclipsePathPoint{}, false + } + for row := 0; row < 3; row++ { + matrix[row][column] = (plusResidual[row] - minusResidual[row]) / (2 * steps[column]) + } + } + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !ok { + return SolarEclipsePathPoint{}, false + } + geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1])) + if geographicScale > 2 { + delta[0] *= 2 / geographicScale + delta[1] *= 2 / geographicScale + } + if math.Abs(delta[2]) > 2.0/1440.0 { + delta[2] = math.Copysign(2.0/1440.0, delta[2]) + } + for index := range coordinates { + coordinates[index] += delta[index] + } + coordinates[0] = normalizeLongitude(coordinates[0]) + if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 { + return SolarEclipsePathPoint{}, false + } + } + residual, ok := solver.riseSetFoldResidual(coordinates[2], coordinates[0], coordinates[1], greatest) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 { + return SolarEclipsePathPoint{}, false + } + evaluation := solver.magnitudeEvaluationAt(coordinates[2]) + state := evaluation.center.stateAt(coordinates[0]*rad, coordinates[1]*rad, 0) + return SolarEclipsePathPoint{ + JDE: coordinates[2], Longitude: coordinates[0], Latitude: coordinates[1], SunAltitude: state.sunAltitudeRad / rad, + }, true +} + +func (solver solarEclipseSolver) riseSetFoldResidual( + jd, longitude, latitude float64, + greatest bool, +) ([3]float64, bool) { + evaluation := solver.magnitudeEvaluationAt(jd) + return solarEclipseRiseSetFoldResidualAt(evaluation, longitude, latitude, greatest) +} + +func solarEclipseRiseSetFoldResidualAt( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude float64, + greatest bool, +) ([3]float64, bool) { + const step = 1e-3 + valueAt := func(lon, lat float64) ([2]float64, bool) { + first, ok := solarEclipseRiseSetPhaseResidual(evaluation, lon, lat, greatest) + state := evaluation.center.stateAt(lon*rad, lat*rad, 0) + return [2]float64{first, state.sunAltitudeRad}, ok && finite(state.sunAltitudeRad) + } + center, centerOK := valueAt(longitude, latitude) + lonPlus, lonPlusOK := valueAt(longitude+step, latitude) + lonMinus, lonMinusOK := valueAt(longitude-step, latitude) + latPlus, latPlusOK := valueAt(longitude, latitude+step) + latMinus, latMinusOK := valueAt(longitude, latitude-step) + if !centerOK || !lonPlusOK || !lonMinusOK || !latPlusOK || !latMinusOK { + return [3]float64{}, false + } + dFirstLon := (lonPlus[0] - lonMinus[0]) / (2 * step) + dFirstLat := (latPlus[0] - latMinus[0]) / (2 * step) + dAltitudeLon := (lonPlus[1] - lonMinus[1]) / (2 * step) + dAltitudeLat := (latPlus[1] - latMinus[1]) / (2 * step) + residual := [3]float64{ + center[0], + center[1], + dFirstLon*dAltitudeLat - dFirstLat*dAltitudeLon, + } + return residual, finite(residual[2]) +} + +func solarEclipseRiseSetPhaseResidual( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude float64, + greatest bool, +) (float64, bool) { + if greatest { + value := evaluation.separationDerivative(longitude, latitude) + return value, finite(value) + } + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + value := solarEclipsePartialContactGap(state) + return value, finite(value) +} + +func (solver solarEclipseSolver) refineRiseSetCurveSpacing(curve *SolarEclipseRiseSetCurve) { + if curve == nil { + return + } + solver = solver.withLocalEphemeris() + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + refined := make([]SolarEclipsePathPoint, 1, len(segment)) + refined[0] = segment[0] + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + refined = solver.appendRefinedRiseSetSegment( + refined, segment[pointIndex-1], segment[pointIndex], curve.Phase, curve.Direction, 0, + ) + } + curve.Segments[segmentIndex] = refined + } +} + +func (solver solarEclipseSolver) appendRefinedRiseSetSegment( + points []SolarEclipsePathPoint, + start, end SolarEclipsePathPoint, + phase RiseSetPhase, + direction RiseSetDirection, + depth int, +) []SolarEclipsePathPoint { + if depth >= 12 || end.JDE-start.JDE <= solarEclipsePathMinStepDays { + return append(points, end) + } + jd := (start.JDE + end.JDE) / 2 + longitude := normalizeLongitude(start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2) + latitude := (start.Latitude + end.Latitude) / 2 + // A comfortably straight candidate needs no new output vertex. Reserve + // half the chord budget for prediction error; every inserted vertex still + // uses the exact ephemeris and the original phase residual checks. + short := solarEclipsePathDistanceKM(start, end) <= solarEclipseRiseSetTargetSpacingKM + if short && solver.localEphemeris != nil { + candidate, key, valid := solarEclipseRefineRiseSetMiddle(solver.magnitudeCandidateEvaluationAt(jd), longitude, latitude, phase) + if valid && key.phase == phase && key.direction == direction && + solarEclipseRiseSetChordDeviationKM(candidate, start, end) <= solarEclipseRiseSetChordToleranceKM/2 { + return append(points, end) + } + } + middle, key, valid := solarEclipseRefineRiseSetMiddle(solver.magnitudeEvaluationAt(jd), longitude, latitude, phase) + if !valid || key.phase != phase || key.direction != direction { + return append(points, end) + } + if short && + solarEclipseRiseSetChordDeviationKM(middle, start, end) <= solarEclipseRiseSetChordToleranceKM { + return append(points, end) + } + points = solver.appendRefinedRiseSetSegment(points, start, middle, phase, direction, depth+1) + return solver.appendRefinedRiseSetSegment(points, middle, end, phase, direction, depth+1) +} + +func solarEclipseRefineRiseSetMiddle( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude float64, + phase RiseSetPhase, +) (SolarEclipsePathPoint, solarEclipseRiseSetCurveKey, bool) { + greatest := phase == RiseSetPhaseGreatest + longitude, latitude, ok := riseSetRefineGeographicRoot(longitude, latitude, + func(lon, lat float64) (float64, float64, bool) { + first, valid := solarEclipseRiseSetPhaseResidual(evaluation, lon, lat, greatest) + state := evaluation.center.stateAt(lon*rad, lat*rad, 0) + return first, state.sunAltitudeRad, valid && finite(state.sunAltitudeRad) + }) + if !ok { + return SolarEclipsePathPoint{}, solarEclipseRiseSetCurveKey{}, false + } + return evaluation.classify(longitude, latitude, greatest) +} + +const solarEclipseRiseSetChordToleranceKM = 0.25 + +func solarEclipseRiseSetChordDeviationKM(point, start, end SolarEclipsePathPoint) float64 { + first := solarEclipseLLRToXYZ(start.Longitude*rad, start.Latitude*rad, 1) + last := solarEclipseLLRToXYZ(end.Longitude*rad, end.Latitude*rad, 1) + middle := solarEclipseLLRToXYZ(point.Longitude*rad, point.Latitude*rad, 1) + normal := solarEclipseRiseSetCross(first, last) + norm := math.Sqrt(dotSolarEclipse3(normal, normal)) + if norm < 1e-12 || dotSolarEclipse3(solarEclipseRiseSetCross(first, middle), normal) < 0 || + dotSolarEclipse3(solarEclipseRiseSetCross(middle, last), normal) < 0 { + return math.Min(solarEclipsePathDistanceKM(point, start), solarEclipsePathDistanceKM(point, end)) + } + return solarEclipseEarthEquatorialRadiusKM * math.Asin(math.Min(1, math.Abs(dotSolarEclipse3(middle, normal))/norm)) +} + +func solarEclipseRiseSetCross(first, second [3]float64) [3]float64 { + return [3]float64{ + first[1]*second[2] - first[2]*second[1], + first[2]*second[0] - first[0]*second[2], + first[0]*second[1] - first[1]*second[0], + } +} diff --git a/basic/solar_eclipse_rise_set_scan_test.go b/basic/solar_eclipse_rise_set_scan_test.go new file mode 100644 index 0000000..a2470f9 --- /dev/null +++ b/basic/solar_eclipse_rise_set_scan_test.go @@ -0,0 +1,253 @@ +package basic + +import ( + "fmt" + "math" + "testing" + + "b612.me/astro/internal/geodata" +) + +func TestSolarEclipsePathTopologyAcrossSarosAnchors(t *testing.T) { + for _, year := range []int{1526, 1600, 1700, 1800, 1900, 2000, 2100, 2200, 2300, 2400, 2526} { + events := solarEclipseScanEvents(JDECalc(year, 1, 1), JDECalc(year+1, 1, 1)) + if len(events) == 0 { + t.Fatalf("%d has no solar eclipse candidate", year) + } + eventJDE := events[0] + name := JDE2Date(eventJDE).Format("2006-01-02") + t.Run(name, func(t *testing.T) { + assertSolarEclipsePathTopology(t, eventJDE, 60.0/1440.0) + }) + } +} + +func assertSolarEclipsePathTopology(t *testing.T, eventJDE, stepDays float64) { + t.Helper() + issues := solarEclipsePathTopologyIssues(eventJDE, stepDays) + if len(issues) > 0 { + t.Fatalf("path topology issues: %s", issues[0]) + } +} + +func solarEclipsePathTopologyIssues(eventJDE, stepDays float64) []string { + result := SolarEclipsePartialFootprints(eventJDE, SolarEclipsePartialFootprintOptions{ + StepDays: stepDays, BoundaryPoints: 24, DisableRiseSetCurves: true, + }) + issues := make([]string, 0) + if !result.Eclipse.HasPartial { + return []string{"partial eclipse result is missing"} + } + contacts := []SolarEclipsePathPoint{result.P1, result.P2, result.P3, result.P4} + previous := 0.0 + for index, contact := range contacts { + if contact.JDE == 0 { + continue + } + if previous != 0 && contact.JDE <= previous { + issues = append(issues, fmt.Sprintf("penumbral contacts not increasing at %d", index)) + } + previous = contact.JDE + } + centralContacts := []SolarEclipsePathPoint{result.U1, result.U2, result.U3, result.U4} + previous = 0 + for index, contact := range centralContacts { + if contact.JDE == 0 { + continue + } + if previous != 0 && contact.JDE <= previous { + issues = append(issues, fmt.Sprintf("central contacts not increasing at %d", index)) + } + previous = contact.JDE + } + for footprintIndex, footprint := range append(append([]SolarEclipsePartialFootprint(nil), result.Footprints...), result.CentralShadowFootprints...) { + totalBoundaryPoints := 0 + for _, boundary := range footprint.Boundaries { + totalBoundaryPoints += len(boundary) + } + for boundaryIndex, boundary := range footprint.Boundaries { + if len(boundary) < 2 && (footprint.Closed || totalBoundaryPoints != 1) { + issues = append(issues, fmt.Sprintf("footprint %d boundary %d is too short", footprintIndex, boundaryIndex)) + continue + } + for pointIndex, point := range boundary { + if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) || + point.Longitude < -180 || point.Longitude > 180 || point.Latitude < -90 || point.Latitude > 90 { + issues = append(issues, fmt.Sprintf("footprint %d boundary %d point %d is invalid", footprintIndex, boundaryIndex, pointIndex)) + } + if pointIndex > 0 && solarEclipsePathDistanceKM(boundary[pointIndex-1], point) > 12000 { + issues = append(issues, fmt.Sprintf("footprint %d boundary %d has a discontinuity", footprintIndex, boundaryIndex)) + } + } + } + if footprint.Closed { + if len(footprint.Boundaries) == 0 { + issues = append(issues, fmt.Sprintf("closed footprint %d has no boundary", footprintIndex)) + } + } + } + if result.Eclipse.HasCentral { + // Limit derivation needs the normal one-minute tangent samples. A + // coarse diagnostic footprint step can leave only two center points and + // is intentionally not reused for the paired cross-sections. + centralStepDays := solarEclipsePathDefaultStepDays + path := SolarEclipseCentralPath(eventJDE, SolarEclipsePathOptions{StepDays: centralStepDays}) + if len(path.CenterLine) < 2 || len(path.NorthernLimit) < 2 || len(path.NorthernLimit) != len(path.SouthernLimit) { + issues = append(issues, "central path or paired limits are incomplete") + } + for index := 1; index < len(path.CenterLine); index++ { + if path.CenterLine[index].JDE <= path.CenterLine[index-1].JDE { + issues = append(issues, "center-line times are not increasing") + break + } + } + for index := 1; index < len(path.NorthernLimit); index++ { + if path.NorthernLimit[index].JDE <= path.NorthernLimit[index-1].JDE || + path.SouthernLimit[index].JDE <= path.SouthernLimit[index-1].JDE { + issues = append(issues, "central-limit times are not increasing") + break + } + insideTwoLimitPath := result.Eclipse.Centrality == SolarEclipseCentralTwoLimits && + index > 1 && index < len(path.NorthernLimit)-1 + northGap := solarEclipsePathDistanceKM(path.NorthernLimit[index-1], path.NorthernLimit[index]) > 500 && + !(math.Abs(path.NorthernLimit[index-1].Latitude) > 80 && math.Abs(path.NorthernLimit[index].Latitude) > 80) + southGap := solarEclipsePathDistanceKM(path.SouthernLimit[index-1], path.SouthernLimit[index]) > 500 && + !(math.Abs(path.SouthernLimit[index-1].Latitude) > 80 && math.Abs(path.SouthernLimit[index].Latitude) > 80) + if insideTwoLimitPath && (northGap || southGap) { + issues = append(issues, "central-limit branch has a gap above 500 km") + break + } + } + if len(path.CenterLine) >= 2 { + first := path.CenterLine[0] + last := path.CenterLine[len(path.CenterLine)-1] + if math.Abs(first.SunAltitude) > 0.02 || math.Abs(last.SunAltitude) > 0.02 { + issues = append(issues, fmt.Sprintf( + "center-line limits are off the horizon: first=%.6f last=%.6f", + first.SunAltitude, last.SunAltitude, + )) + } + if !finite(first.WidthKM) || !finite(last.WidthKM) || + first.WidthKM < 0 || last.WidthKM < 0 || first.WidthKM > 5000 || last.WidthKM > 5000 { + issues = append(issues, fmt.Sprintf( + "center-line limit widths are invalid: first=%.3f last=%.3f", + first.WidthKM, last.WidthKM, + )) + } + } + } else if result.Eclipse.Type != SolarEclipsePartial && result.Eclipse.Centrality == SolarEclipseNonCentral { + if len(result.CentralBandSegments) == 0 { + if err := auditSolarEclipseOpenBandSweep(result.CentralBandFootprints); err != nil { + issues = append(issues, "non-central eclipse has no usable central band: "+err.Error()) + } + } + for index, segment := range result.CentralBandSegments { + if len(segment) < 4 || solarEclipsePathDistanceKM(segment[0], segment[len(segment)-1]) > 0.1 { + issues = append(issues, fmt.Sprintf("non-central band segment %d is not closed", index)) + } + } + if len(result.CentralBandSegments) > 0 { + if err := auditSolarEclipseBandContainsFootprints(result.CentralBandSegments, result.CentralBandFootprints); err != nil { + issues = append(issues, "non-central band excludes an instantaneous central-shadow boundary: "+err.Error()) + } + } + } + return issues +} + +func auditSolarEclipseBandContainsFootprints( + segments [][]SolarEclipsePathPoint, + footprints []SolarEclipsePartialFootprint, +) error { + if solarEclipseNonCentralBandContainsFootprints(segments, footprints) { + return nil + } + miss := solarEclipseBandFootprintMissDistanceKM(segments, footprints) + return fmt.Errorf("maximum boundary miss %.6f km exceeds %.3f km tolerance", + miss, solarEclipseNonCentralBandContainmentToleranceKM) +} + +func solarEclipseBandFootprintMissDistanceKM( + segments [][]SolarEclipsePathPoint, + footprints []SolarEclipsePartialFootprint, +) float64 { + polygons := make([][]geodata.GeoPoint, 0, len(segments)) + for _, segment := range segments { + polygon := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, polygon) + } + paths := make([][]geodata.GeoPoint, 0, len(footprints)) + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + path := make([]geodata.GeoPoint, len(boundary)) + for index, point := range boundary { + path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + paths = append(paths, path) + } + } + return geodata.SphericalPolygonsPathMissDistanceKM(polygons, paths, false) +} + +func auditSolarEclipseOpenBandSweep(footprints []SolarEclipsePartialFootprint) error { + samples := make([]geodata.OpenBoundarySweepSample, 0, len(footprints)) + for _, footprint := range footprints { + boundaries := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries)) + for _, boundary := range footprint.Boundaries { + points := make([]geodata.GeoPoint, len(boundary)) + for index, point := range boundary { + if !finite(point.Longitude) || !finite(point.Latitude) { + return fmt.Errorf("footprint contains a non-finite point") + } + points[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + boundaries = append(boundaries, points) + } + samples = append(samples, geodata.OpenBoundarySweepSample{ + Boundaries: boundaries, + Closed: footprint.Closed, + }) + } + polygons, err := geodata.OpenBoundarySweep(samples) + if err != nil { + return err + } + if len(polygons) == 0 { + return fmt.Errorf("open footprint sweep contains no polygon") + } + for polygonIndex, polygon := range polygons { + if len(polygon) < 3 { + return fmt.Errorf("open footprint sweep polygon %d has only %d points", polygonIndex, len(polygon)) + } + for pointIndex, point := range polygon { + if !finite(point.Longitude) || !finite(point.Latitude) || + point.Longitude < -180 || point.Longitude > 180 || point.Latitude < -90 || point.Latitude > 90 { + return fmt.Errorf("open footprint sweep polygon %d point %d is invalid", polygonIndex, pointIndex) + } + } + } + return nil +} + +func solarEclipseScanEvents(startJDE, endJDE float64) []float64 { + seed := CalcMoonSHByJDE(startJDE, 0) + if seed < startJDE { + seed = CalcMoonSHByJDE(seed+25, 0) + } + var events []float64 + for seed < endJDE { + result := SolarEclipse(seed) + if result.HasPartial { + events = append(events, result.GreatestEclipse) + } + next := CalcMoonSHByJDE(seed+25, 0) + if !finite(next) || next <= seed+20 { + break + } + seed = next + } + return events +} diff --git a/basic/solar_eclipse_rise_set_topology.go b/basic/solar_eclipse_rise_set_topology.go new file mode 100644 index 0000000..8703014 --- /dev/null +++ b/basic/solar_eclipse_rise_set_topology.go @@ -0,0 +1,653 @@ +package basic + +import "math" + +func (solver solarEclipseSolver) completeRiseSetCurveEndpoints( + curves []SolarEclipseRiseSetCurve, + stepDays float64, + phaseJunctions []solarEclipseRiseSetPhaseJunction, +) { + for index := range curves { + solver.completeRiseSetFoldEndpoints(&curves[index], stepDays) + } + solver.completeRiseSetPhaseJunctions(curves, stepDays, phaseJunctions) + solver.completeRiseSetDirectionJunctions(curves, stepDays) + for index := range curves { + solver.refineRiseSetCurveSpacing(&curves[index]) + normalizeSolarEclipseRiseSetCurveSegments(&curves[index]) + } +} + +// normalizeSolarEclipseRiseSetCurveSegments keeps each rendered branch +// strictly time-ordered. Endpoint completion can discover a real horizon fold +// after the sampled branch was built; that fold belongs to a separate branch, +// not to a reversed segment. Near-identical roots are numerical duplicates. +func normalizeSolarEclipseRiseSetCurveSegments(curve *SolarEclipseRiseSetCurve) { + if curve == nil { + return + } + segments := make([][]SolarEclipsePathPoint, 0, len(curve.Segments)) + for _, segment := range curve.Segments { + segments = append(segments, splitSolarEclipseRiseSetTimeFolds(segment)...) + } + curve.Segments = segments +} + +// splitSolarEclipseRiseSetTimeFolds preserves spatial branches when a +// horizon curve folds in time. A rendered segment must be strictly increasing +// in JDE, but the physical curve can turn around at a high-latitude horizon +// fold. Each monotonic branch is emitted separately; descending branches are +// reversed so their geometry is retained without violating the API contract. +func splitSolarEclipseRiseSetTimeFolds(segment []SolarEclipsePathPoint) [][]SolarEclipsePathPoint { + if len(segment) < 2 { + return nil + } + points := make([]SolarEclipsePathPoint, 0, len(segment)) + for _, point := range segment { + if len(points) > 0 { + last := points[len(points)-1] + if math.Abs(point.JDE-last.JDE) <= solarEclipseRiseSetTimeEpsilonDays { + if solarEclipsePathDistanceKM(point, last) <= 0.01 { + continue + } + // Distinct points at the same instant are a junction, not a + // valid edge of a timed segment. Keep both as separate runs. + points = append(points, point) + continue + } + } + points = append(points, point) + } + if len(points) < 2 { + return nil + } + result := make([][]SolarEclipsePathPoint, 0, 2) + start := 0 + direction := 0 + flush := func(end int, branchDirection int) { + if end-start < 1 { + return + } + branch := append([]SolarEclipsePathPoint(nil), points[start:end+1]...) + if branchDirection < 0 { + for left, right := 0, len(branch)-1; left < right; left, right = left+1, right-1 { + branch[left], branch[right] = branch[right], branch[left] + } + } + if len(branch) >= 2 && branch[len(branch)-1].JDE > branch[0].JDE+solarEclipseRiseSetTimeEpsilonDays { + result = append(result, branch) + } + } + for index := 1; index < len(points); index++ { + delta := points[index].JDE - points[index-1].JDE + if math.Abs(delta) <= solarEclipseRiseSetTimeEpsilonDays { + flush(index-1, direction) + start = index + direction = 0 + continue + } + sign := 1 + if delta < 0 { + sign = -1 + } + if direction == 0 { + direction = sign + continue + } + if sign != direction { + // Keep the fold vertex in both adjacent branches. This is + // necessary to retain the actual spatial turn after reversing + // the descending branch. + flush(index-1, direction) + start = index - 1 + direction = sign + } + } + flush(len(points)-1, direction) + return result +} + +func (solver solarEclipseSolver) completeRiseSetFoldEndpoints( + curve *SolarEclipseRiseSetCurve, + stepDays float64, +) { + if curve == nil || len(curve.Segments) < 2 { + return + } + type endpointRef struct { + segmentIndex int + atStart bool + point SolarEclipsePathPoint + } + endpoints := make([]endpointRef, 0, 2*len(curve.Segments)) + for segmentIndex, segment := range curve.Segments { + if len(segment) == 0 { + continue + } + for _, atStart := range []bool{true, false} { + endpoints = append(endpoints, endpointRef{ + segmentIndex: segmentIndex, + atStart: atStart, + point: solarEclipseRiseSetSegmentEndpoint(segment, atStart), + }) + } + } + used := make(map[[2]int]bool, len(endpoints)) + for firstIndex := 0; firstIndex < len(endpoints); firstIndex++ { + first := endpoints[firstIndex] + if used[[2]int{first.segmentIndex, boolInt(first.atStart)}] { + continue + } + for secondIndex := firstIndex + 1; secondIndex < len(endpoints); secondIndex++ { + second := endpoints[secondIndex] + if first.segmentIndex == second.segmentIndex || first.atStart != second.atStart || + used[[2]int{second.segmentIndex, boolInt(second.atStart)}] { + continue + } + if math.Abs(first.point.JDE-second.point.JDE) > math.Max(1e-8, stepDays/4) { + continue + } + distance := solarEclipsePathDistanceKM(first.point, second.point) + if distance <= 0.01 || distance > 6000 { + continue + } + fold, ok := solver.refineRiseSetFold(first.point, second.point, curve.Phase == RiseSetPhaseGreatest) + if !ok || math.Abs(fold.JDE-(first.point.JDE+second.point.JDE)/2) > 2.5*stepDays || + solarEclipsePathDistanceKM(fold, first.point) > 6000 || solarEclipsePathDistanceKM(fold, second.point) > 6000 { + continue + } + evaluation := solver.magnitudeEvaluationAt(fold.JDE) + _, key, valid := evaluation.classify(fold.Longitude, fold.Latitude, curve.Phase == RiseSetPhaseGreatest) + if !valid || key.phase != curve.Phase || key.direction != curve.Direction { + continue + } + curve.Segments[first.segmentIndex] = solarEclipseRiseSetAddEndpoint( + curve.Segments[first.segmentIndex], fold, first.atStart, + ) + curve.Segments[second.segmentIndex] = solarEclipseRiseSetAddEndpoint( + curve.Segments[second.segmentIndex], fold, second.atStart, + ) + used[[2]int{first.segmentIndex, boolInt(first.atStart)}] = true + used[[2]int{second.segmentIndex, boolInt(second.atStart)}] = true + break + } + } +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func (solver solarEclipseSolver) completeRiseSetPhaseJunctions( + curves []SolarEclipseRiseSetCurve, + stepDays float64, + phaseJunctions []solarEclipseRiseSetPhaseJunction, +) { + curveIndices := make(map[solarEclipseRiseSetCurveKey]int, len(curves)) + for index, curve := range curves { + curveIndices[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = index + } + for _, direction := range []RiseSetDirection{RiseSetDirectionRise, RiseSetDirectionSet} { + startIndex, haveStart := curveIndices[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseStart, direction: direction}] + greatestIndex, haveGreatest := curveIndices[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseGreatest, direction: direction}] + endIndex, haveEnd := curveIndices[solarEclipseRiseSetCurveKey{phase: RiseSetPhaseEnd, direction: direction}] + if !haveStart || !haveGreatest || !haveEnd { + continue + } + for _, candidate := range phaseJunctions { + if candidate.direction != direction { + continue + } + solver.attachRiseSetPhaseJunction( + curves, startIndex, greatestIndex, endIndex, + candidate.point, direction, stepDays, + ) + } + seeds := solarEclipseRiseSetUnsharedEndpoints(startIndex, curves[startIndex].Segments) + for _, seed := range seeds { + candidateSeeds := []SolarEclipsePathPoint{seed.point} + if endEndpoint, endOK := solarEclipseClosestRiseSetEndpoint( + seed.point, endIndex, curves[endIndex].Segments, nil, stepDays, + ); endOK { + candidateSeeds = append([]SolarEclipsePathPoint{ + solarEclipseRiseSetMidpoint(seed.point, endEndpoint.point), + endEndpoint.point, + }, candidateSeeds...) + } + junction, ok := SolarEclipsePathPoint{}, false + for _, candidate := range candidateSeeds { + junction, ok = solver.refineRiseSetPhaseJunctionOnHorizon(candidate) + if !ok { + junction, ok = solver.refineRiseSetPhaseJunction( + candidate.JDE, candidate.Longitude, candidate.Latitude, + ) + } + if ok { + break + } + } + if !ok || math.Abs(junction.JDE-seed.point.JDE) > 3*stepDays || + solarEclipsePathDistanceKM(junction, seed.point) > 3000 { + continue + } + solver.attachRiseSetPhaseJunction( + curves, startIndex, greatestIndex, endIndex, + junction, direction, stepDays, + ) + } + } +} + +func (solver solarEclipseSolver) attachRiseSetPhaseJunction( + curves []SolarEclipseRiseSetCurve, + startIndex, greatestIndex, endIndex int, + junction SolarEclipsePathPoint, + direction RiseSetDirection, + stepDays float64, +) bool { + matched := make([]solarEclipseRiseSetPhaseAttachment, 0, 3) + for _, curveIndex := range []int{startIndex, greatestIndex, endIndex} { + endpoint, ok := solarEclipseClosestRiseSetEndpoint( + junction, curveIndex, curves[curveIndex].Segments, nil, stepDays, + ) + if !ok { + endpoint, ok = solarEclipseClosestRiseSetFoldEndpoint( + junction, curveIndex, curves[curveIndex].Segments, stepDays, + ) + } + if !ok { + return false + } + phase := curves[curveIndex].Phase + attachment := solarEclipseRiseSetPhaseAttachment{endpoint: endpoint} + if !solarEclipseRiseSetEndpointTimeDirectionValid(junction, endpoint) || + (solarEclipsePathDistanceKM(junction, endpoint.point) > solarEclipseRiseSetPhaseConnectionLimitKM && + !solver.riseSetPhaseSegmentIsContinuous(junction, endpoint.point, phase, direction)) { + greatest := phase == RiseSetPhaseGreatest + fold, foldOK := solver.refineRiseSetFold(junction, endpoint.point, greatest) + if !foldOK || !solver.riseSetFoldBridgesPhaseJunction( + junction, endpoint, fold, phase, direction, + ) { + fold, foldOK = solver.refineRiseSetFoldNearPhaseJunction( + junction, endpoint, phase, direction, stepDays, + ) + } + if !foldOK || !solver.riseSetFoldBridgesPhaseJunction( + junction, endpoint, fold, phase, direction, + ) { + return false + } + attachment.fold, attachment.hasFold = fold, true + } + matched = append(matched, attachment) + } + for _, attachment := range matched { + endpoint := attachment.endpoint + if attachment.hasFold { + curves[endpoint.curveIndex].Segments[endpoint.segmentIndex] = solarEclipseRiseSetAddEndpoint( + curves[endpoint.curveIndex].Segments[endpoint.segmentIndex], attachment.fold, endpoint.atStart, + ) + shortBranch := []SolarEclipsePathPoint{attachment.fold, junction} + if !endpoint.atStart { + shortBranch[0], shortBranch[1] = shortBranch[1], shortBranch[0] + } + curves[endpoint.curveIndex].Segments = append(curves[endpoint.curveIndex].Segments, shortBranch) + continue + } + curves[endpoint.curveIndex].Segments[endpoint.segmentIndex] = solarEclipseRiseSetAddEndpoint( + curves[endpoint.curveIndex].Segments[endpoint.segmentIndex], junction, endpoint.atStart, + ) + } + return true +} + +type solarEclipseRiseSetEndpointRef struct { + curveIndex int + segmentIndex int + atStart bool + point SolarEclipsePathPoint +} + +type solarEclipseRiseSetPhaseAttachment struct { + endpoint solarEclipseRiseSetEndpointRef + fold SolarEclipsePathPoint + hasFold bool +} + +func solarEclipseRiseSetMidpoint(first, second SolarEclipsePathPoint) SolarEclipsePathPoint { + return SolarEclipsePathPoint{ + JDE: (first.JDE + second.JDE) / 2, + Longitude: normalizeLongitude( + first.Longitude + math.Remainder(second.Longitude-first.Longitude, 360)/2, + ), + Latitude: (first.Latitude + second.Latitude) / 2, + SunAltitude: (first.SunAltitude + second.SunAltitude) / 2, + } +} + +func solarEclipseRiseSetUnsharedEndpoints( + curveIndex int, + segments [][]SolarEclipsePathPoint, +) []solarEclipseRiseSetEndpointRef { + endpoints := make([]solarEclipseRiseSetEndpointRef, 0, 2*len(segments)) + for segmentIndex, segment := range segments { + if len(segment) == 0 { + continue + } + for _, atStart := range []bool{true, false} { + endpoints = append(endpoints, solarEclipseRiseSetEndpointRef{ + curveIndex: curveIndex, segmentIndex: segmentIndex, atStart: atStart, + point: solarEclipseRiseSetSegmentEndpoint(segment, atStart), + }) + } + } + result := make([]solarEclipseRiseSetEndpointRef, 0, len(endpoints)) + for index, endpoint := range endpoints { + shared := false + for otherIndex, other := range endpoints { + if index == otherIndex || endpoint.segmentIndex == other.segmentIndex { + continue + } + if math.Abs(endpoint.point.JDE-other.point.JDE) <= 1e-8 && + solarEclipsePathDistanceKM(endpoint.point, other.point) <= 0.01 { + shared = true + break + } + } + if !shared { + result = append(result, endpoint) + } + } + return result +} + +func solarEclipseClosestRiseSetEndpoint( + junction SolarEclipsePathPoint, + curveIndex int, + segments [][]SolarEclipsePathPoint, + used map[[3]int]bool, + stepDays float64, +) (solarEclipseRiseSetEndpointRef, bool) { + best := solarEclipseRiseSetEndpointRef{} + bestMetric := math.Inf(1) + for segmentIndex, segment := range segments { + if len(segment) == 0 { + continue + } + for _, atStart := range []bool{true, false} { + side := 1 + if atStart { + side = 0 + } + if used[[3]int{curveIndex, segmentIndex, side}] { + continue + } + point := solarEclipseRiseSetSegmentEndpoint(segment, atStart) + if atStart && junction.JDE > point.JDE+solarEclipseRiseSetAttachmentTimeToleranceDays || + !atStart && junction.JDE < point.JDE-solarEclipseRiseSetAttachmentTimeToleranceDays || + math.Abs(junction.JDE-point.JDE) > 3*stepDays { + continue + } + metric := solarEclipsePathDistanceKM(junction, point) + math.Abs(junction.JDE-point.JDE)*8640 + if metric < bestMetric { + best = solarEclipseRiseSetEndpointRef{ + curveIndex: curveIndex, segmentIndex: segmentIndex, atStart: atStart, point: point, + } + bestMetric = metric + } + } + } + return best, bestMetric < math.Inf(1) +} + +func solarEclipseClosestRiseSetFoldEndpoint( + junction SolarEclipsePathPoint, + curveIndex int, + segments [][]SolarEclipsePathPoint, + stepDays float64, +) (solarEclipseRiseSetEndpointRef, bool) { + best := solarEclipseRiseSetEndpointRef{} + bestMetric := math.Inf(1) + for segmentIndex, segment := range segments { + if len(segment) == 0 { + continue + } + for _, atStart := range []bool{true, false} { + point := solarEclipseRiseSetSegmentEndpoint(segment, atStart) + deltaDays := math.Abs(junction.JDE - point.JDE) + distance := solarEclipsePathDistanceKM(junction, point) + if deltaDays > 3*stepDays || distance > 6000 { + continue + } + metric := distance + deltaDays*8640 + if metric < bestMetric { + best = solarEclipseRiseSetEndpointRef{ + curveIndex: curveIndex, segmentIndex: segmentIndex, atStart: atStart, point: point, + } + bestMetric = metric + } + } + } + return best, bestMetric < math.Inf(1) +} + +func (solver solarEclipseSolver) completeRiseSetDirectionJunctions( + curves []SolarEclipseRiseSetCurve, + stepDays float64, +) { + curveIndices := make(map[solarEclipseRiseSetCurveKey]int, len(curves)) + for index, curve := range curves { + curveIndices[solarEclipseRiseSetCurveKey{phase: curve.Phase, direction: curve.Direction}] = index + } + for _, phase := range []RiseSetPhase{RiseSetPhaseStart, RiseSetPhaseGreatest, RiseSetPhaseEnd} { + riseIndex, haveRise := curveIndices[solarEclipseRiseSetCurveKey{phase: phase, direction: RiseSetDirectionRise}] + setIndex, haveSet := curveIndices[solarEclipseRiseSetCurveKey{phase: phase, direction: RiseSetDirectionSet}] + if !haveRise || !haveSet { + continue + } + riseEndpoints := solarEclipseRiseSetAllEndpoints(riseIndex, curves[riseIndex].Segments) + setEndpoints := solarEclipseRiseSetAllEndpoints(setIndex, curves[setIndex].Segments) + usedRise := make([]bool, len(riseEndpoints)) + usedSet := make([]bool, len(setEndpoints)) + for riseEndpointIndex, riseEndpoint := range riseEndpoints { + bestSetIndex := -1 + bestMetric := math.Inf(1) + for setEndpointIndex, setEndpoint := range setEndpoints { + if usedSet[setEndpointIndex] || math.Abs(riseEndpoint.point.JDE-setEndpoint.point.JDE) > 3*stepDays { + continue + } + distance := solarEclipsePathDistanceKM(riseEndpoint.point, setEndpoint.point) + if distance > 2500 { + continue + } + metric := distance + math.Abs(riseEndpoint.point.JDE-setEndpoint.point.JDE)*8640 + if metric < bestMetric { + bestSetIndex, bestMetric = setEndpointIndex, metric + } + } + if bestSetIndex < 0 { + continue + } + setEndpoint := setEndpoints[bestSetIndex] + longitude := normalizeLongitude( + riseEndpoint.point.Longitude + math.Remainder(setEndpoint.point.Longitude-riseEndpoint.point.Longitude, 360)/2, + ) + latitude := (riseEndpoint.point.Latitude + setEndpoint.point.Latitude) / 2 + junction, ok := solver.refineRiseSetDirectionJunction( + (riseEndpoint.point.JDE+setEndpoint.point.JDE)/2, longitude, latitude, phase == RiseSetPhaseGreatest, + ) + if !ok || math.Abs(junction.JDE-riseEndpoint.point.JDE) > 3*stepDays || + math.Abs(junction.JDE-setEndpoint.point.JDE) > 3*stepDays || + solarEclipsePathDistanceKM(junction, riseEndpoint.point) > 2500 || + solarEclipsePathDistanceKM(junction, setEndpoint.point) > 2500 { + continue + } + if !solarEclipseRiseSetEndpointTimeDirectionValid(junction, riseEndpoint) || + !solarEclipseRiseSetEndpointTimeDirectionValid(junction, setEndpoint) { + continue + } + curves[riseIndex].Segments[riseEndpoint.segmentIndex] = solarEclipseRiseSetAddEndpoint( + curves[riseIndex].Segments[riseEndpoint.segmentIndex], junction, riseEndpoint.atStart, + ) + curves[setIndex].Segments[setEndpoint.segmentIndex] = solarEclipseRiseSetAddEndpoint( + curves[setIndex].Segments[setEndpoint.segmentIndex], junction, setEndpoint.atStart, + ) + usedRise[riseEndpointIndex] = true + usedSet[bestSetIndex] = true + } + } +} + +func solarEclipseRiseSetAllEndpoints( + curveIndex int, + segments [][]SolarEclipsePathPoint, +) []solarEclipseRiseSetEndpointRef { + endpoints := make([]solarEclipseRiseSetEndpointRef, 0, 2*len(segments)) + for segmentIndex, segment := range segments { + if len(segment) == 0 { + continue + } + for _, atStart := range []bool{true, false} { + endpoints = append(endpoints, solarEclipseRiseSetEndpointRef{ + curveIndex: curveIndex, segmentIndex: segmentIndex, atStart: atStart, + point: solarEclipseRiseSetSegmentEndpoint(segment, atStart), + }) + } + } + return endpoints +} + +func solarEclipseRiseSetEndpointTimeDirectionValid( + junction SolarEclipsePathPoint, + endpoint solarEclipseRiseSetEndpointRef, +) bool { + if endpoint.atStart { + return junction.JDE <= endpoint.point.JDE+solarEclipseRiseSetAttachmentTimeToleranceDays + } + return junction.JDE >= endpoint.point.JDE-solarEclipseRiseSetAttachmentTimeToleranceDays +} + +func (solver solarEclipseSolver) refineRiseSetDirectionJunction( + jd, longitude, latitude float64, + greatest bool, +) (SolarEclipsePathPoint, bool) { + const ( + geographicStep = 1e-4 + timeStep = 1.0 / 86400.0 + ) + for iteration := 0; iteration < 24; iteration++ { + evaluation := solver.magnitudeEvaluationAt(jd) + residual, ok := solarEclipseRiseSetDirectionJunctionResidualAt(evaluation, longitude, latitude, greatest) + if !ok { + return SolarEclipsePathPoint{}, false + } + if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 { + break + } + longitudeResidual, longitudeOK := solarEclipseRiseSetDirectionJunctionResidualAt( + evaluation, longitude+geographicStep, latitude, greatest, + ) + latitudeResidual, latitudeOK := solarEclipseRiseSetDirectionJunctionResidualAt( + evaluation, longitude, latitude+geographicStep, greatest, + ) + timeResidual, timeOK := solver.riseSetDirectionJunctionResidual( + jd+timeStep, longitude, latitude, greatest, + ) + if !longitudeOK || !latitudeOK || !timeOK { + return SolarEclipsePathPoint{}, false + } + matrix := [3][3]float64{} + for row := 0; row < 3; row++ { + matrix[row][0] = (longitudeResidual[row] - residual[row]) / geographicStep + matrix[row][1] = (latitudeResidual[row] - residual[row]) / geographicStep + matrix[row][2] = (timeResidual[row] - residual[row]) / timeStep + } + delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]}) + if !ok { + return SolarEclipsePathPoint{}, false + } + geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1])) + if geographicScale > 2 { + delta[0] *= 2 / geographicScale + delta[1] *= 2 / geographicScale + } + if math.Abs(delta[2]) > 5.0/1440.0 { + delta[2] = math.Copysign(5.0/1440.0, delta[2]) + } + longitude = normalizeLongitude(longitude + delta[0]) + latitude += delta[1] + jd += delta[2] + if latitude <= -89.999999 || latitude >= 89.999999 { + return SolarEclipsePathPoint{}, false + } + } + residual, ok := solver.riseSetDirectionJunctionResidual(jd, longitude, latitude, greatest) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 { + return SolarEclipsePathPoint{}, false + } + evaluation := solver.magnitudeEvaluationAt(jd) + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + if greatest { + if solarEclipsePartialContactGap(state) > 1e-7 || evaluation.separationSecondDerivative(longitude, latitude) <= 0 { + return SolarEclipsePathPoint{}, false + } + } else if math.Abs(evaluation.partialContactDerivative(longitude, latitude)) < 1e-10 { + return SolarEclipsePathPoint{}, false + } + return SolarEclipsePathPoint{ + JDE: jd, Longitude: longitude, Latitude: latitude, SunAltitude: residual[1] / rad, + }, true +} + +func (solver solarEclipseSolver) riseSetDirectionJunctionResidual( + jd, longitude, latitude float64, + greatest bool, +) ([3]float64, bool) { + evaluation := solver.magnitudeEvaluationAt(jd) + return solarEclipseRiseSetDirectionJunctionResidualAt(evaluation, longitude, latitude, greatest) +} + +func solarEclipseRiseSetDirectionJunctionResidualAt( + evaluation solarEclipseRiseSetEvaluation, + longitude, latitude float64, + greatest bool, +) ([3]float64, bool) { + phaseResidual, phaseOK := solarEclipseRiseSetPhaseResidual(evaluation, longitude, latitude, greatest) + state := evaluation.center.stateAt(longitude*rad, latitude*rad, 0) + altitudeDerivative := evaluation.sunAltitudeDerivative(longitude, latitude) + residual := [3]float64{phaseResidual, state.sunAltitudeRad, altitudeDerivative} + return residual, phaseOK && finite(residual[1]) && finite(residual[2]) +} + +func solarEclipseRiseSetSegmentEndpoint(segment []SolarEclipsePathPoint, atStart bool) SolarEclipsePathPoint { + if atStart { + return segment[0] + } + return segment[len(segment)-1] +} + +func solarEclipseRiseSetAddEndpoint( + segment []SolarEclipsePathPoint, + point SolarEclipsePathPoint, + atStart bool, +) []SolarEclipsePathPoint { + current := solarEclipseRiseSetSegmentEndpoint(segment, atStart) + if math.Abs(current.JDE-point.JDE) <= solarEclipseRiseSetAttachmentTimeToleranceDays && + solarEclipsePathDistanceKM(current, point) <= solarEclipseRiseSetAttachmentDistanceToleranceKM { + segmentIndex := len(segment) - 1 + if atStart { + segmentIndex = 0 + } + segment[segmentIndex] = point + return segment + } + if atStart { + result := make([]SolarEclipsePathPoint, 0, len(segment)+1) + result = append(result, point) + return append(result, segment...) + } + return append(segment, point) +} diff --git a/basic/solar_eclipse_shadow.go b/basic/solar_eclipse_shadow.go new file mode 100644 index 0000000..ba30d92 --- /dev/null +++ b/basic/solar_eclipse_shadow.go @@ -0,0 +1,429 @@ +package basic + +import "math" + +// SolarEclipseShadowKind 阴影类型 / shadow kind. +type SolarEclipseShadowKind int + +const ( + // SolarEclipseShadowUmbra 本影与反本影 / umbra and antumbra. + SolarEclipseShadowUmbra SolarEclipseShadowKind = iota + // SolarEclipseShadowPenumbra 半影,即偏食区 / penumbra, the partial-eclipse region. + SolarEclipseShadowPenumbra +) + +// SolarEclipseShadowSolverOptions 单时刻阴影求解器配置 / single-instant shadow solver options. +type SolarEclipseShadowSolverOptions struct { + // Model 月亮半径模型,零值为 NASA bulletin Split-K / lunar radius model. + Model SolarEclipseRadiusModel + // DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型,只改变地球自转相位 / explicit ΔT in seconds. + DeltaTSeconds float64 + // BoundaryPoints 边界角向采样点数,<=0 用 96 / boundary sample count. + BoundaryPoints int + // Kind 本影或半影,零值为本影 / umbra or penumbra, zero is the umbra. + Kind SolarEclipseShadowKind + // TargetSpacingKM 边界加密目标间距(千米),0 用该类型的整包采样默认值,负值表示不加密 / boundary refinement spacing in km. + TargetSpacingKM float64 +} + +const ( + solarEclipseShadowDefaultBoundaryPoints = 96 + // 开放足迹的物理边界含擦地点,点数上限沿用整包口径。 + solarEclipseShadowMaximumBoundaryPoints = 1440 + // 半个朔望月:窗口内缓存锚点必为最近朔月。 + solarEclipseShadowAnchorHalfWindowDays = 14.7652944265 + // 批量条数上限,超出返回 nil。 + solarEclipseShadowMaximumBatchCount = 262144 +) + +// SolarEclipseShadowTopology 瞬时足迹拓扑签名,用于插值门控 / footprint topology signature. +type SolarEclipseShadowTopology struct { + // Kind 阴影类型,签名以 umbra 或 penumbra 开头 / shadow kind, the signature prefix. + Kind SolarEclipseShadowKind + // Vertices 物理边界顶点总数 / boundary vertex count. + Vertices int + // Segments 边界分段数,换日线会拆分 / segment count. + Segments int + // Closed 边界是否由阴影自身闭合 / self-closed boundary. + Closed bool + // EnclosesPole 环绕过极点,经度已展开,不能按下标插值 / pole-winding ring. + EnclosesPole bool +} + +// Signature 稳定签名字符串,可直接作为能否插值的比较键 / stable interpolation key. +func (topology SolarEclipseShadowTopology) Signature() string { + if topology.Vertices == 0 { + return "empty" + } + builder := make([]byte, 0, 32) + if topology.Kind == SolarEclipseShadowPenumbra { + builder = append(builder, "penumbra"...) + } else { + builder = append(builder, "umbra"...) + } + if topology.Closed { + builder = append(builder, "-closed"...) + } else { + builder = append(builder, "-horizon"...) + } + builder = append(builder, "-seg"...) + builder = appendInt(builder, topology.Segments) + builder = append(builder, "-pt"...) + builder = appendInt(builder, topology.Vertices) + if topology.EnclosesPole { + builder = append(builder, "-pole"...) + } + return string(builder) +} + +func appendInt(target []byte, value int) []byte { + if value == 0 { + return append(target, '0') + } + if value < 0 { + target = append(target, '-') + value = -value + } + var digits [20]byte + position := len(digits) + for value > 0 { + position-- + digits[position] = byte('0' + value%10) + value /= 10 + } + return append(target, digits[position:]...) +} + +// SolarEclipseShadowInstant 某瞬时(TT)的全球本影或半影足迹 / instantaneous shadow footprint. +type SolarEclipseShadowInstant struct { + // JDE 输入的力学时儒略日 / requested TT instant. + JDE float64 + // Model 本次使用的月亮半径模型 / lunar radius model used. + Model SolarEclipseRadiusModel + // DeltaTSeconds 实际使用的 ΔT / ΔT actually used. + DeltaTSeconds float64 + // Kind 本次计算的阴影类型 / shadow kind of this computation. + Kind SolarEclipseShadowKind + // Closed 边界由阴影自身闭合,false 表示被地平线切断 / self-closed or horizon-cut. + Closed bool + // Boundaries 物理边界分段,反经线会拆分 / physical boundary segments. + Boundaries [][]SolarEclipsePathPoint + // HorizonEnds 两端在地平圈上的擦地点,顺序与 Boundaries 一致 / horizon grazing points. + HorizonEnds []SolarEclipsePathPoint + // Topology 插值判定用的拓扑签名 / interpolation signature. + Topology SolarEclipseShadowTopology +} + +// Empty 该时刻阴影未落在地球表面 / no footprint on the Earth. +func (instant SolarEclipseShadowInstant) Empty() bool { + return len(instant.Boundaries) == 0 +} + +// SolarEclipseShadowSolver 可复用的单时刻求解器;非并发安全,宿主每条 lane 各持一个 / reusable solver, one per lane. +type SolarEclipseShadowSolver struct { + options SolarEclipseShadowSolverOptions + + anchorSet bool + anchorJDE float64 + anchorSolver solarEclipseSolver +} + +// NewSolarEclipseShadowSolver 构造单时刻求解器 / builds a single-instant solver. +func NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *SolarEclipseShadowSolver { + if options.Model != SolarEclipseModelIAUSingleK { + options.Model = SolarEclipseModelNASABulletinSplitK + } + if options.BoundaryPoints <= 0 { + options.BoundaryPoints = solarEclipseShadowDefaultBoundaryPoints + } + // 下界与整包采样同口径,个位数采样点会产出无意义的足迹。 + if options.BoundaryPoints < solarEclipsePartialFootprintMinBoundaryPoints { + options.BoundaryPoints = solarEclipsePartialFootprintMinBoundaryPoints + } + if options.BoundaryPoints > solarEclipseShadowMaximumBoundaryPoints { + options.BoundaryPoints = solarEclipseShadowMaximumBoundaryPoints + } + if options.Kind != SolarEclipseShadowPenumbra { + options.Kind = SolarEclipseShadowUmbra + } + if options.TargetSpacingKM == 0 && options.Kind == SolarEclipseShadowPenumbra { + // 半影的整包采样默认带空间加密,单时刻要用同一默认值才能与采样逐点一致。 + options.TargetSpacingKM = solarEclipsePartialFootprintTargetSpacingKM + } + if options.TargetSpacingKM < 0 { + options.TargetSpacingKM = 0 + } + return &SolarEclipseShadowSolver{options: options} +} + +// ShadowAtJDE 给定 TT 时刻的阴影足迹;不在地球上时返回 (零值, false) / footprint at one TT instant. +func (solver *SolarEclipseShadowSolver) ShadowAtJDE(jdeTT float64) (SolarEclipseShadowInstant, bool) { + if !finite(jdeTT) { + return SolarEclipseShadowInstant{}, false + } + deltaT := solver.effectiveDeltaT(jdeTT) + inner := solver.solverFor(jdeTT) + moon, axis, sun := solver.besselGeometryAt(jdeTT, inner, deltaT) + footprint := inner.shadowFootprintAtWithGeometry( + jdeTT, moon, axis, sun, solver.options.BoundaryPoints, + solver.options.shadowKind(), solver.options.TargetSpacingKM, + ) + if len(footprint.Boundaries) == 0 { + return SolarEclipseShadowInstant{ + JDE: jdeTT, Model: solver.options.Model, + Kind: solver.options.Kind, DeltaTSeconds: deltaT, + }, false + } + return SolarEclipseShadowInstant{ + JDE: jdeTT, + Model: solver.options.Model, + Kind: solver.options.Kind, + DeltaTSeconds: deltaT, + Closed: footprint.Closed, + Boundaries: footprint.Boundaries, + HorizonEnds: footprint.HorizonEnds, + Topology: solarEclipseShadowFootprintTopology(footprint, solver.options.Kind), + }, true +} + +func (options SolarEclipseShadowSolverOptions) shadowKind() solarEclipseShadowKind { + if options.Kind == SolarEclipseShadowPenumbra { + return solarEclipsePenumbralShadow + } + return solarEclipseCentralShadow +} + +func (solver *SolarEclipseShadowSolver) effectiveDeltaT(jdeTT float64) float64 { + override := solver.options.DeltaTSeconds + if override <= 0 { + // 选项 0/负值表示"未覆盖",用模型;显式 ΔT=0 需走 DeltaTSecondsAt 的直接调用。 + override = math.NaN() + } + return DeltaTSecondsAt(jdeTT, override) +} + +func (solver *SolarEclipseShadowSolver) solverFor(jdeTT float64) solarEclipseSolver { + if solver.anchorSet && math.Abs(jdeTT-solver.anchorJDE) <= solarEclipseShadowAnchorHalfWindowDays { + return solver.anchorSolver + } + anchor := CalcMoonSHByJDE(jdeTT, 0) + solver.anchorSet = true + solver.anchorJDE = anchor + solver.anchorSolver = newSolarEclipseSolver(anchor, solver.options.Model) + return solver.anchorSolver +} + +func (solver *SolarEclipseShadowSolver) besselGeometryAt( + jdeTT float64, + inner solarEclipseSolver, + deltaTSeconds float64, +) ([3]float64, solarEclipseAxis, [3]float64) { + if solver.options.DeltaTSeconds <= 0 { + return inner.besselGeometryAt(jdeTT) + } + sunEquatorial, moonEquatorial := solarEclipseSunMoonEquatorial(jdeTT) + axis := solarEclipseBesselAxisFromEquatorialWithDeltaT( + jdeTT, sunEquatorial, moonEquatorial, deltaTSeconds, + ) + return solarEclipseBesselMoonFromEquatorial(moonEquatorial, axis), axis, sunEquatorial +} + +// SolarEclipseShadowAtJDE 是无状态版本:自建一次内部状态后求单时刻足迹,任意并发安全。 +// SolarEclipseShadowAtJDE is the stateless variant: it builds its own state per call and +// is therefore safe for concurrent use. +func SolarEclipseShadowAtJDE( + jdeTT float64, + options SolarEclipseShadowSolverOptions, +) (SolarEclipseShadowInstant, bool) { + return NewSolarEclipseShadowSolver(options).ShadowAtJDE(jdeTT) +} + +func solarEclipseShadowFootprintTopology( + footprint SolarEclipsePartialFootprint, + kind SolarEclipseShadowKind, +) SolarEclipseShadowTopology { + topology := SolarEclipseShadowTopology{ + Kind: kind, + Segments: len(footprint.Boundaries), + Closed: footprint.Closed, + } + winding := 0.0 + for _, segment := range footprint.Boundaries { + topology.Vertices += len(segment) + for index := 1; index < len(segment); index++ { + winding += math.Remainder(segment[index].Longitude-segment[index-1].Longitude, 360) + } + // 只有真正闭合的分段才把"末点回到首点"计入绕极判定;开放分段的收口边是虚拟的。 + if len(segment) > 2 && solarEclipsePathDistanceKM(segment[0], segment[len(segment)-1]) < 1e-6 { + winding += math.Remainder(segment[0].Longitude-segment[len(segment)-1].Longitude, 360) + } + } + if topology.Segments == 1 && math.Abs(winding) >= 180 { + topology.EnclosesPole = true + } + return topology +} + +// SolarEclipseStationState 某瞬时的站心日月几何 / topocentric geometry at one instant. +type SolarEclipseStationState struct { + // JDE 输入的 TT 时刻,DeltaTSeconds 实际使用的 ΔT / requested instant and ΔT used. + JDE float64 + DeltaTSeconds float64 + // SeparationDeg 与 SeparationArcsec 是日月中心的站心角距,极小化用的连续量 / topocentric separation. + SeparationDeg float64 + SeparationArcsec float64 + // 三个半径字段是日月站心视半径 / topocentric apparent radii. + SunRadiusDeg float64 + MoonOuterRadiusDeg float64 + MoonInnerRadiusDeg float64 + // SunAltitudeDeg 与 SunAzimuthDeg 是站心太阳高度角与方位角,方位角自北向东 / solar altitude and azimuth. + SunAltitudeDeg float64 + SunAzimuthDeg float64 + // Magnitude 瞬时食分,Obscuration 太阳视面积遮蔽率 / instantaneous magnitude and obscuration. + Magnitude float64 + Obscuration float64 + // InCentralPhase 该瞬时站点位于本影或反本影内 / station inside the central shadow now. + InCentralPhase bool + // CentralPhaseType 中心食类型,非中心食为 SolarEclipseNone / central phase kind. + CentralPhaseType SolarEclipseType + // HasTotalPhase 与 HasAnnularPhase 表示该瞬时是否处于全食或环食 / total or annular now. + HasTotalPhase bool + HasAnnularPhase bool + // Visible 太阳中心高于几何地平,海拔用俯仰角修正阈值 / Sun center above the horizon. + Visible bool +} + +// StationStateAtJDE 给定 TT 时刻与站点的站心情形,任何时刻可调用且不报错 / topocentric state at one instant. +func (solver *SolarEclipseShadowSolver) StationStateAtJDE( + jdeTT, lonDeg, latDeg, heightMeters float64, +) SolarEclipseStationState { + if !finite(jdeTT) || !finite(lonDeg) || !finite(latDeg) { + return SolarEclipseStationState{JDE: jdeTT} + } + deltaT := solver.effectiveDeltaT(jdeTT) + heightKM := heightMeters / 1000 + state := localSolarEclipseStateAtWithDeltaT( + jdeTT, deltaT, lonDeg*rad, latDeg*rad, heightKM, solarEclipseModelParams(solver.options.Model), + ) + contact := state.movingDiskContactState() + central := contact.internalContactGap() <= 0 + result := SolarEclipseStationState{ + JDE: jdeTT, + DeltaTSeconds: deltaT, + SeparationDeg: state.separationRad / rad, + SeparationArcsec: state.separationRad / rad * 3600, + SunRadiusDeg: state.sunRadiusRad / rad, + MoonOuterRadiusDeg: state.moonOuterRadiusRad / rad, + MoonInnerRadiusDeg: state.moonInnerRadiusRad / rad, + SunAltitudeDeg: state.sunAltitudeRad / rad, + SunAzimuthDeg: state.sunAzimuthRad / rad, + Obscuration: localSolarEclipseObscuration(state.sunRadiusRad, state.moonOuterRadiusRad, state.separationRad), + InCentralPhase: central, + } + visibleThreshold := 0.0 + if heightMeters > 0 { + visibleThreshold = -HeightDegreeByLat(heightMeters, latDeg) * rad + } + result.Visible = state.sunAltitudeRad > visibleThreshold + if central { + result.Magnitude = state.moonInnerRadiusRad / state.sunRadiusRad + if state.moonInnerRadiusRad >= state.sunRadiusRad { + result.CentralPhaseType = SolarEclipseTotal + result.HasTotalPhase = true + } else { + result.CentralPhaseType = SolarEclipseAnnular + result.HasAnnularPhase = true + } + return result + } + result.Magnitude = (state.moonOuterRadiusRad + state.sunRadiusRad - state.separationRad) / (2 * state.sunRadiusRad) + if result.Magnitude < 0 { + result.Magnitude = 0 + } + return result +} + +// SolarEclipseStationStateAtJDE 无状态版本,可并发调用 / stateless, concurrency-safe variant. +func SolarEclipseStationStateAtJDE( + jdeTT, lonDeg, latDeg, heightMeters float64, + options SolarEclipseShadowSolverOptions, +) SolarEclipseStationState { + return NewSolarEclipseShadowSolver(options).StationStateAtJDE(jdeTT, lonDeg, latDeg, heightMeters) +} + +func localSolarEclipseStateAtWithDeltaT( + jdTT, deltaTSeconds, lonRad, latRad, heightKM float64, + params solarEclipseModelParameters, +) localSolarEclipseState { + context := newLocalSolarEclipseStateContextWithDeltaT(jdTT, deltaTSeconds, params) + return context.stateAt(lonRad, latRad, heightKM) +} + +func newLocalSolarEclipseStateContextWithDeltaT( + jdTT, deltaTSeconds float64, + params solarEclipseModelParameters, +) localSolarEclipseStateContext { + sunEquatorial, moonEquatorial := solarEclipseSunMoonEquatorial(jdTT) + utJDE := jdTT - deltaTSeconds/86400 + return localSolarEclipseStateContext{ + sunXYZ: solarEclipseLLRToXYZ(sunEquatorial[0], sunEquatorial[1], sunEquatorial[2]), + moonXYZ: solarEclipseLLRToXYZ(moonEquatorial[0], moonEquatorial[1], moonEquatorial[2]), + gst: ApparentSiderealTime(utJDE) * 15 * rad, + params: params, + } +} + +// ShadowBetweenJDE 区间内等步长逐时刻的阴影足迹,无阴影时刻为空条目 / per-step footprints. +func (solver *SolarEclipseShadowSolver) ShadowBetweenJDE( + startJDE, endJDE, stepDays float64, +) []SolarEclipseShadowInstant { + if !finite(startJDE) || !finite(endJDE) || !finite(stepDays) || stepDays <= 0 || endJDE < startJDE { + return nil + } + count := solarEclipseShadowBatchCount(startJDE, endJDE, stepDays) + if count <= 0 || count > solarEclipseShadowMaximumBatchCount { + return nil + } + result := make([]SolarEclipseShadowInstant, 0, count) + for index := 0; index < count; index++ { + instant, _ := solver.ShadowAtJDE(startJDE + float64(index)*stepDays) + result = append(result, instant) + } + return result +} + +// StationStatesBetweenJDE 区间内等步长逐时刻的站心情形 / per-step station states. +func (solver *SolarEclipseShadowSolver) StationStatesBetweenJDE( + startJDE, endJDE, stepDays, lonDeg, latDeg, heightMeters float64, +) []SolarEclipseStationState { + if !finite(startJDE) || !finite(endJDE) || !finite(stepDays) || stepDays <= 0 || endJDE < startJDE { + return nil + } + count := solarEclipseShadowBatchCount(startJDE, endJDE, stepDays) + if count <= 0 || count > solarEclipseShadowMaximumBatchCount { + return nil + } + result := make([]SolarEclipseStationState, 0, count) + for index := 0; index < count; index++ { + result = append(result, solver.StationStateAtJDE( + startJDE+float64(index)*stepDays, lonDeg, latDeg, heightMeters, + )) + } + return result +} + +// solarEclipseShadowBatchTimeTolerance 是批量采样末点的时刻容差(天,约 0.9 ms): +// 起止点由浮点运算给出时末点会落在 end 之外若干个 ULP,容差内仍算入,避免丢掉本应包含的采样。 +const solarEclipseShadowBatchTimeTolerance = 1e-8 + +// solarEclipseShadowBatchCount 返回闭区间上按 stepDays 采样的格点数。 +func solarEclipseShadowBatchCount(startJDE, endJDE, stepDays float64) int { + return int(math.Floor((endJDE-startJDE+solarEclipseShadowBatchTimeTolerance)/stepDays)) + 1 +} + +// SolarEclipseShadowBetweenJDE 无状态批量版本 / stateless batch variant. +func SolarEclipseShadowBetweenJDE( + startJDE, endJDE, stepDays float64, options SolarEclipseShadowSolverOptions, +) []SolarEclipseShadowInstant { + return NewSolarEclipseShadowSolver(options).ShadowBetweenJDE(startJDE, endJDE, stepDays) +} diff --git a/basic/solar_eclipse_shadow_batch_test.go b/basic/solar_eclipse_shadow_batch_test.go new file mode 100644 index 0000000..cca5252 --- /dev/null +++ b/basic/solar_eclipse_shadow_batch_test.go @@ -0,0 +1,61 @@ +package basic + +import "testing" + +// 批量入口是导出契约的一部分,必须与逐时刻入口给出同一条数值链。 +func TestSolarEclipseShadowBatchEntriesMatchPerInstantCalls(t *testing.T) { + footprints := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 6.0 / 1440.0, BoundaryPoints: 48, DisableRiseSetCurves: true, + CentralShadowStepDays: 6.0 / 1440.0, + }) + if len(footprints.CentralShadowFootprints) < 3 { + t.Fatalf("central shadow samples=%d, want a full series", len(footprints.CentralShadowFootprints)) + } + options := SolarEclipseShadowSolverOptions{} + step := 6.0 / 1440.0 + start := footprints.CentralShadowFootprints[0].JDE + end := start + 4*step + batch := SolarEclipseShadowBetweenJDE(start, end, step, options) + if len(batch) != 5 { + t.Fatalf("batch length=%d, want 5", len(batch)) + } + nonEmpty := 0 + for index, instant := range batch { + direct, ok := SolarEclipseShadowAtJDE(start+float64(index)*step, options) + if ok != !direct.Empty() { + t.Fatalf("instant %d ok=%v, Empty=%v", index, ok, direct.Empty()) + } + if instant.Empty() != direct.Empty() || len(instant.Boundaries) != len(direct.Boundaries) { + t.Fatalf("instant %d batch=%d segments, direct=%d", index, len(instant.Boundaries), len(direct.Boundaries)) + } + if !instant.Empty() { + nonEmpty++ + } + } + if nonEmpty == 0 { + t.Fatal("batch reported no shadow anywhere along the central path") + } + longitude, latitude := footprints.Eclipse.GreatestLongitude, footprints.Eclipse.GreatestLatitude + solver := NewSolarEclipseShadowSolver(options) + states := solver.StationStatesBetweenJDE(start, end, step, longitude, latitude, 0) + if len(states) != len(batch) { + t.Fatalf("station state batch length=%d, want %d", len(states), len(batch)) + } + for index, state := range states { + direct := SolarEclipseStationStateAtJDE(start+float64(index)*step, longitude, latitude, 0, options) + if state != direct { + t.Fatalf("station state %d = %+v, want %+v", index, state, direct) + } + } + for _, badStep := range []float64{0, -step} { + if got := SolarEclipseShadowBetweenJDE(start, end, badStep, options); got != nil { + t.Fatalf("step %v produced %d instants, want nil", badStep, len(got)) + } + } + if got := solver.ShadowBetweenJDE(end, start, step); got != nil { + t.Fatalf("reversed window produced %d instants, want nil", len(got)) + } + if got := SolarEclipseShadowBetweenJDE(start, start+10, 1e-9, options); got != nil { + t.Fatalf("oversized batch produced %d instants, want nil", len(got)) + } +} diff --git a/basic/solar_eclipse_shadow_test.go b/basic/solar_eclipse_shadow_test.go new file mode 100644 index 0000000..87b2624 --- /dev/null +++ b/basic/solar_eclipse_shadow_test.go @@ -0,0 +1,381 @@ +package basic + +import ( + "math" + "testing" +) + +func TestSolarEclipseShadowInstantMatchesPackagedSamples(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + DisableRiseSetCurves: true, + }) + if len(result.CentralShadowFootprints) < 3 { + t.Fatalf("central shadow samples=%d, want a full series", len(result.CentralShadowFootprints)) + } + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + checked := map[string]bool{} + for _, index := range []int{0, len(result.CentralShadowFootprints) / 2, len(result.CentralShadowFootprints) - 1} { + sampled := result.CentralShadowFootprints[index] + instant, ok := solver.ShadowAtJDE(sampled.JDE) + if !ok { + t.Fatalf("instant call reported no umbra at %v, packaged sample has %d segments", + sampled.JDE, len(sampled.Boundaries)) + } + if instant.Closed != sampled.Closed { + t.Fatalf("closed mismatch at %v: instant=%v packaged=%v", sampled.JDE, instant.Closed, sampled.Closed) + } + if len(instant.Boundaries) != len(sampled.Boundaries) { + t.Fatalf("segments mismatch at %v: instant=%d packaged=%d", + sampled.JDE, len(instant.Boundaries), len(sampled.Boundaries)) + } + for segmentIndex := range sampled.Boundaries { + if len(instant.Boundaries[segmentIndex]) != len(sampled.Boundaries[segmentIndex]) { + t.Fatalf("segment %d length mismatch at %v: instant=%d packaged=%d", segmentIndex, sampled.JDE, + len(instant.Boundaries[segmentIndex]), len(sampled.Boundaries[segmentIndex])) + } + for pointIndex := range sampled.Boundaries[segmentIndex] { + got := instant.Boundaries[segmentIndex][pointIndex] + want := sampled.Boundaries[segmentIndex][pointIndex] + if got.Longitude != want.Longitude || got.Latitude != want.Latitude { + t.Fatalf("point mismatch at %v segment %d point %d: got %.12f,%.12f want %.12f,%.12f", + sampled.JDE, segmentIndex, pointIndex, + got.Longitude, got.Latitude, want.Longitude, want.Latitude) + } + } + } + checked[instant.Topology.Signature()] = true + } + if len(checked) < 2 { + t.Fatalf("expected distinct topology signatures across the series, got %v", checked) + } +} + +func TestSolarEclipseShadowInstantEmptyOffPath(t *testing.T) { + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + // 2009-07-22 食甚前后 12 小时已经远离地球上的本影。 + instant, ok := solver.ShadowAtJDE(JDECalc(2009, 7, 22) + 0.6) + if ok || !instant.Empty() { + t.Fatalf("off-path instant reported ok=%v empty=%v", ok, instant.Empty()) + } + if instant.Topology.Signature() != "empty" { + t.Fatalf("empty topology signature=%q, want empty", instant.Topology.Signature()) + } + if instant.DeltaTSeconds <= 0 { + t.Fatalf("off-path instant must still report the ΔT used, got %v", instant.DeltaTSeconds) + } +} + +func TestSolarEclipseShadowTopologySignatureChangesAtHorizonCut(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + DisableRiseSetCurves: true, + }) + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + signatures := map[string]int{} + for _, sampled := range result.CentralShadowFootprints { + instant, ok := solver.ShadowAtJDE(sampled.JDE) + if !ok { + continue + } + signatures[instant.Topology.Signature()]++ + } + closedSeen, horizonSeen := false, false + for signature := range signatures { + if len(signature) >= 7 && signature[:7] == "umbra-c" { + closedSeen = true + } + if len(signature) >= 7 && signature[:7] == "umbra-h" { + horizonSeen = true + } + } + if !closedSeen || !horizonSeen { + t.Fatalf("expected both self-closed and horizon-cut signatures, got %v", signatures) + } +} + +func TestSolarEclipseStationStateMatchesLocalEclipse(t *testing.T) { + seed := JDECalc(2024, 4, 8) + const lon, lat = -96.8, 32.8 + local := LocalSolarEclipse(seed, lon, lat, 0) + if !local.HasTotal { + t.Fatalf("expected a total eclipse at the test station, got type=%v magnitude=%.3f", local.Type, local.Magnitude) + } + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + state := solver.StationStateAtJDE(local.GreatestEclipse, lon, lat, 0) + if math.Abs(state.SeparationDeg-local.Separation) > 1e-9 { + t.Fatalf("separation mismatch: station=%.12f local=%.12f", state.SeparationDeg, local.Separation) + } + if math.Abs(state.SunAltitudeDeg-local.SunAltitude) > 1e-9 || math.Abs(state.SunAzimuthDeg-local.SunAzimuth) > 1e-9 { + t.Fatalf("sun position mismatch: station=(%.9f,%.9f) local=(%.9f,%.9f)", + state.SunAltitudeDeg, state.SunAzimuthDeg, local.SunAltitude, local.SunAzimuth) + } + if math.Abs(state.Obscuration-local.Obscuration) > 1e-12 { + t.Fatalf("obscuration mismatch: station=%.12f local=%.12f", state.Obscuration, local.Obscuration) + } + if !state.InCentralPhase || !state.HasTotalPhase || state.HasAnnularPhase { + t.Fatalf("central phase flags wrong: %+v", state) + } + if !state.Visible { + t.Fatal("expected the Sun above the horizon at greatest eclipse") + } +} + +func TestSolarEclipseShadowDeltaTMovesOnlyEarthRotation(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + DisableRiseSetCurves: true, + }) + if len(result.CentralShadowFootprints) == 0 { + t.Fatal("no central shadow samples") + } + jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE + base := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + shifted := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{DeltaTSeconds: 100}) + first, okFirst := base.ShadowAtJDE(jde) + second, okSecond := shifted.ShadowAtJDE(jde) + if !okFirst || !okSecond { + t.Fatalf("expected a footprint at %v (ok=%v/%v)", jde, okFirst, okSecond) + } + if first.JDE != second.JDE { + t.Fatalf("geometry instant moved with ΔT: %v vs %v", first.JDE, second.JDE) + } + if math.Abs(second.DeltaTSeconds-100) > 1e-12 || first.DeltaTSeconds == second.DeltaTSeconds { + t.Fatalf("ΔT not reported per solver: %.6f vs %.6f", first.DeltaTSeconds, second.DeltaTSeconds) + } + centroid := func(instant SolarEclipseShadowInstant) (float64, float64) { + var sumX, sumY, sumZ, count float64 + for _, segment := range instant.Boundaries { + for _, point := range segment { + longitude, latitude := point.Longitude*rad, point.Latitude*rad + sumX += math.Cos(latitude) * math.Cos(longitude) + sumY += math.Cos(latitude) * math.Sin(longitude) + sumZ += math.Sin(latitude) + count++ + } + } + length := math.Sqrt(sumX*sumX + sumY*sumY + sumZ*sumZ) + return math.Atan2(sumY/length, sumX/length) / rad, math.Asin(sumZ/length) / rad + } + firstLon, firstLat := centroid(first) + secondLon, secondLat := centroid(second) + shift := DeltaTGroundShiftKM(100-first.DeltaTSeconds, firstLat) + measured := solarEclipsePathDistanceKM( + SolarEclipsePathPoint{Longitude: firstLon, Latitude: firstLat}, + SolarEclipsePathPoint{Longitude: secondLon, Latitude: secondLat}, + ) + if math.Abs(measured-shift) > 0.05*shift { + t.Fatalf("ΔT ground shift=%.1f km, want about %.1f km", measured, shift) + } +} + +func BenchmarkSolarEclipseShadowAtJDE(b *testing.B) { + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + DisableRiseSetCurves: true, + }) + jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE + b.ResetTimer() + for index := 0; index < b.N; index++ { + if _, ok := solver.ShadowAtJDE(jde + float64(index)*1e-9); !ok { + b.Fatal("no footprint") + } + } +} + +func BenchmarkSolarEclipseStationStateAtJDE(b *testing.B) { + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + jde := JDECalc(2024, 4, 8) + 0.78 + b.ResetTimer() + for index := 0; index < b.N; index++ { + _ = solver.StationStateAtJDE(jde+float64(index)*1e-9, -96.8, 32.8, 0) + } +} + +func TestSolarEclipseShadowClampsBoundaryPoints(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + DisableRiseSetCurves: true, + }) + if len(result.CentralShadowFootprints) == 0 { + t.Fatal("no central shadow samples") + } + jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE + // 个位数的边界点数会产出无意义的"足迹"(真实案例:1 个点也能 ok=true),必须被夹到下限。 + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{BoundaryPoints: 1}) + instant, ok := solver.ShadowAtJDE(jde) + if !ok { + t.Fatal("expected a footprint") + } + points := 0 + for _, segment := range instant.Boundaries { + points += len(segment) + } + if points < solarEclipsePartialFootprintMinBoundaryPoints { + t.Fatalf("BoundaryPoints=1 produced %d vertices, want at least %d", + points, solarEclipsePartialFootprintMinBoundaryPoints) + } + upper := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{BoundaryPoints: 100000}) + clamped, okClamped := upper.ShadowAtJDE(jde) + if !okClamped { + t.Fatal("expected a footprint with a clamped point count") + } + clampedPoints := 0 + for _, segment := range clamped.Boundaries { + clampedPoints += len(segment) + } + if clampedPoints > solarEclipseShadowMaximumBoundaryPoints+2 { + t.Fatalf("BoundaryPoints=100000 produced %d vertices, want at most %d", + clampedPoints, solarEclipseShadowMaximumBoundaryPoints) + } +} + +func TestSolarEclipseShadowHandleSeesDeltaTChange(t *testing.T) { + // 复用同一个句柄、中途覆盖进程级 ΔT:缓存里的贝塞尔轴带着由 ΔT 决定的 gst,而键 + // 只有 jd,旧实现会返回"新 ΔT + 旧几何"(实测边界漂移 0 km,新建句柄却差 58 km)。 + // Reusing one handle across a process-wide ΔT override used to replay the cached + // Besselian axis, whose gst depends on ΔT, and report the new ΔT with the old + // geometry (0 km drift where a fresh handle moved 58 km). + original := GetDeltaTFn() + defer SetDeltaTFn(original) + + SetDeltaTFn(DefaultDeltaTv2) + // 取 2009-07-22 本影阶段内的一个真实时刻(与相邻测试同一取法)。 + // Use a real instant inside the 2009-07-22 umbral phase, derived like the neighbour test. + samples := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + DisableRiseSetCurves: true, + }) + if len(samples.CentralShadowFootprints) == 0 { + t.Fatal("no central shadow samples") + } + jde := samples.CentralShadowFootprints[len(samples.CentralShadowFootprints)/2].JDE + reused := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + before, okBefore := reused.ShadowAtJDE(jde) + if !okBefore { + t.Fatalf("no footprint at %v with the process ΔT", jde) + } + + SetDeltaTFn(func(float64, bool) float64 { return 200 }) + after, okAfter := reused.ShadowAtJDE(jde) + if !okAfter { + t.Fatalf("no footprint at %v after the ΔT override", jde) + } + fresh := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + want, okWant := fresh.ShadowAtJDE(jde) + if !okWant { + t.Fatalf("no footprint at %v from a fresh handle", jde) + } + if math.Abs(after.DeltaTSeconds-200) > 1e-12 || math.Abs(want.DeltaTSeconds-200) > 1e-12 { + t.Fatalf("ΔT not reported after the override: reused=%.6f fresh=%.6f", + after.DeltaTSeconds, want.DeltaTSeconds) + } + + maxDelta := func(first, second SolarEclipseShadowInstant) float64 { + worst := 0.0 + for index, segment := range first.Boundaries { + if index >= len(second.Boundaries) { + return math.Inf(1) + } + if len(segment) != len(second.Boundaries[index]) { + return math.Inf(1) + } + for pointIndex, point := range segment { + other := second.Boundaries[index][pointIndex] + worst = math.Max(worst, math.Abs(point.Longitude-other.Longitude)) + worst = math.Max(worst, math.Abs(point.Latitude-other.Latitude)) + } + } + return worst + } + if moved := maxDelta(before, after); moved < 1e-4 { + t.Fatalf("reused handle did not move with ΔT (max %.9f deg)", moved) + } + if drift := maxDelta(after, want); drift > 1e-9 { + t.Fatalf("reused handle differs from a fresh one by %.9f deg after the ΔT override", drift) + } +} + +func TestSolarEclipseShadowPenumbraMatchesPackagedSamples(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + DisableRiseSetCurves: true, + }) + if len(result.Footprints) == 0 { + t.Fatal("no partial footprints") + } + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{ + Kind: SolarEclipseShadowPenumbra, BoundaryPoints: 96, + }) + sampled := result.Footprints[len(result.Footprints)/2] + instant, ok := solver.ShadowAtJDE(sampled.JDE) + if !ok { + t.Fatal("penumbra instant reported no footprint") + } + if instant.Kind != SolarEclipseShadowPenumbra { + t.Fatalf("kind=%v, want penumbra", instant.Kind) + } + if instant.Closed != sampled.Closed || len(instant.Boundaries) != len(sampled.Boundaries) { + t.Fatalf("shape mismatch: closed=%v/%v segments=%d/%d", + instant.Closed, sampled.Closed, len(instant.Boundaries), len(sampled.Boundaries)) + } + for index := range sampled.Boundaries { + if len(instant.Boundaries[index]) != len(sampled.Boundaries[index]) { + t.Fatalf("segment %d length %d, want %d", index, len(instant.Boundaries[index]), len(sampled.Boundaries[index])) + } + for pointIndex := range sampled.Boundaries[index] { + got, want := instant.Boundaries[index][pointIndex], sampled.Boundaries[index][pointIndex] + // 空间加密的递归顺序会让同一时刻相差约 1e-12 度,取 1e-9 度(亚毫米)即可。 + if math.Abs(got.Longitude-want.Longitude) > 1e-9 || math.Abs(got.Latitude-want.Latitude) > 1e-9 { + t.Fatalf("point %d/%d = %.12f,%.12f want %.12f,%.12f", + index, pointIndex, got.Longitude, got.Latitude, want.Longitude, want.Latitude) + } + } + } + if signature := instant.Topology.Signature(); len(signature) < 8 || signature[:8] != "penumbra" { + t.Fatalf("signature=%q, want a penumbra prefix", signature) + } +} + +func TestSolarEclipseShadowKindChangesFootprint(t *testing.T) { + result := SolarEclipsePartialFootprints(JDECalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, + DisableRiseSetCurves: true, + }) + if len(result.CentralShadowFootprints) == 0 || len(result.Footprints) == 0 { + t.Fatal("missing samples") + } + jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE + umbra, okUmbra := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).ShadowAtJDE(jde) + penumbra, okPenumbra := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{ + Kind: SolarEclipseShadowPenumbra, + }).ShadowAtJDE(jde) + if !okUmbra || !okPenumbra { + t.Fatalf("expected both footprints (umbra=%v penumbra=%v)", okUmbra, okPenumbra) + } + if umbra.Topology.Vertices == 0 || penumbra.Topology.Vertices == 0 { + t.Fatal("empty topology") + } + umbraMin, umbraMax := longitudeRange(umbra) + penumbraMin, penumbraMax := longitudeRange(penumbra) + if penumbraMax-penumbraMin <= umbraMax-umbraMin { + t.Fatalf("penumbra span %.1f should exceed the umbra span %.1f", + penumbraMax-penumbraMin, umbraMax-umbraMin) + } +} + +func longitudeRange(instant SolarEclipseShadowInstant) (float64, float64) { + minimum, maximum := 361.0, -361.0 + for _, segment := range instant.Boundaries { + for _, point := range segment { + if point.Longitude < minimum { + minimum = point.Longitude + } + if point.Longitude > maximum { + maximum = point.Longitude + } + } + } + return minimum, maximum +} diff --git a/basic/solar_eclipse_test.go b/basic/solar_eclipse_test.go index 240d40f..0f841f1 100644 --- a/basic/solar_eclipse_test.go +++ b/basic/solar_eclipse_test.go @@ -119,6 +119,75 @@ func TestSolarEclipseAgainstNASABaseline(t *testing.T) { } } +func TestSolarEclipseBesselGeometryCacheSeparatesExactAndCandidate(t *testing.T) { + seed := JDECalc(2024, 4, 8) + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + tt := solver.newMoonJDE + 0.125 + exactMoon, exactAxis, exactSun := solver.besselGeometryAt(tt) + exactMoonAgain, exactAxisAgain, exactSunAgain := solver.besselGeometryAt(tt) + if exactMoon != exactMoonAgain || exactAxis != exactAxisAgain || exactSun != exactSunAgain { + t.Fatal("exact Bessel cache changed the geometry on a repeated lookup") + } + if len(solver.besselGeometryCache) != 1 { + t.Fatalf("exact Bessel cache size=%d, want 1", len(solver.besselGeometryCache)) + } + + candidateSolver := solver.withLocalEphemeris() + candidateMoon, candidateAxis, candidateSun, ok := candidateSolver.besselGeometryCandidateAt(tt) + if !ok { + t.Fatal("candidate Bessel geometry unavailable inside local ephemeris") + } + candidateMoonAgain, candidateAxisAgain, candidateSunAgain, okAgain := candidateSolver.besselGeometryCandidateAt(tt) + if !okAgain || candidateMoon != candidateMoonAgain || candidateAxis != candidateAxisAgain || candidateSun != candidateSunAgain { + t.Fatal("candidate Bessel cache changed the geometry on a repeated lookup") + } + if len(solver.besselGeometryCache) != 1 { + t.Fatalf("exact Bessel cache size changed after candidate lookup=%d", len(solver.besselGeometryCache)) + } + if len(solver.besselCandidateCache) != 1 { + t.Fatalf("candidate Bessel cache size=%d, want 1", len(solver.besselCandidateCache)) + } +} + +func TestSolarEclipseBesselGeometryCacheIsBounded(t *testing.T) { + cache := make(map[uint64]solarEclipseBesselGeometryCacheEntry) + entry := solarEclipseBesselGeometryCacheEntry{valid: true} + for index := 0; index < solarEclipseBesselGeometryCacheMaximumEntries+17; index++ { + storeSolarEclipseBesselGeometry(cache, uint64(index), entry) + } + if len(cache) > solarEclipseBesselGeometryCacheMaximumEntries { + t.Fatalf("Bessel cache size=%d, want <=%d", len(cache), solarEclipseBesselGeometryCacheMaximumEntries) + } + if _, ok := cache[uint64(solarEclipseBesselGeometryCacheMaximumEntries+16)]; !ok { + t.Fatal("latest Bessel cache entry was evicted") + } +} + +func BenchmarkSolarEclipseBesselGeometryCache(b *testing.B) { + solver := newSolarEclipseSolver( + CalcMoonSHByJDE(JDECalc(2024, 4, 8), 0), + SolarEclipseModelNASABulletinSplitK, + ) + tt := solver.newMoonJDE + 0.125 + // Prime one exact entry so the benchmark measures the repeated lookup + // path used by contact and topology refinements. + solver.besselGeometryAt(tt) + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + solver.besselGeometryAt(tt) + } +} + +func BenchmarkSolarEclipseRepresentativePath(b *testing.B) { + seed := JDECalc(2024, 4, 8) + options := SolarEclipsePathOptions{StepDays: 5.0 / 1440.0, TargetSpacingKM: 200} + b.ReportAllocs() + for index := 0; index < b.N; index++ { + SolarEclipseCentralPath(seed, options) + } +} + func TestSolarEclipseDefaultUsesNASABulletinSplitK(t *testing.T) { jde := JDECalc(2024, 4, 8) defaultResult := SolarEclipse(jde) @@ -174,6 +243,17 @@ func TestSolarEclipseNoEvent(t *testing.T) { } } +func BenchmarkSolarEclipseGlobal(b *testing.B) { + seed := JDECalc(2010, 1, 15) + b.ReportAllocs() + for iteration := 0; iteration < b.N; iteration++ { + result := SolarEclipse(seed) + if !result.HasCentral { + b.Fatal("expected a central eclipse") + } + } +} + func solarEclipseTTJDE(year int, month time.Month, day, hour, minute, second int) float64 { return Date2JDE(time.Date(year, month, day, hour, minute, second, 0, time.UTC)) } diff --git a/basic/solar_eclipse_total_envelope_test.go b/basic/solar_eclipse_total_envelope_test.go new file mode 100644 index 0000000..02bf829 --- /dev/null +++ b/basic/solar_eclipse_total_envelope_test.go @@ -0,0 +1,62 @@ +package basic + +import ( + "fmt" + "math" + "testing" +) + +func TestSolarEclipseTotalEnvelopeHorizonEndpoints(t *testing.T) { + for _, date := range [][3]int{ + {2003, 11, 23}, {2021, 12, 4}, {2039, 12, 15}, + {2008, 8, 1}, {2024, 4, 8}, {2026, 8, 12}, + } { + t.Run(fmt.Sprintf("%04d-%02d-%02d", date[0], date[1], date[2]), func(t *testing.T) { + seed := JDECalc(date[0], date[1], float64(date[2])) + result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440, BoundaryPoints: 96, MagnitudeValues: []float64{1}, + }) + if result.Eclipse.Type != SolarEclipseTotal || len(result.CentralBandSegments) != 1 || + len(result.CentralBandHorizonClosures) != 2 { + t.Fatalf("type=%s rings=%d closures=%d, want total with a continuous closed envelope", + result.Eclipse.Type, len(result.CentralBandSegments), len(result.CentralBandHorizonClosures)) + } + if len(result.MagnitudeContours) != 1 || len(result.MagnitudeContours[0].Segments) != 2 { + t.Fatal("missing totality branches") + } + ring := result.CentralBandSegments[0] + if len(ring) < 4 || ring[0] != ring[len(ring)-1] { + t.Fatal("totality envelope is not closed") + } + solver := newSolarEclipseSolver(CalcMoonSHByJDE(seed, 0), SolarEclipseModelNASABulletinSplitK) + for i, point := range ring { + if i > 0 && solarEclipsePathDistanceKM(ring[i-1], point) > solarEclipseTotalEnvelopeTargetSpacingKM+1e-6 { + t.Fatalf("edge %d exceeds spacing limit", i) + } + if solarEclipseCentralEnvelopePointOnHorizonClosure(point, result.CentralBandHorizonClosures) { + continue + } + residual, ok := solver.magnitudeHorizonResidual(point.JDE, point.Longitude, point.Latitude, 1) + if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || point.SunAltitude < -1e-5 { + t.Fatalf("point %d is not on visible totality envelope: %v altitude=%g", i, residual, point.SunAltitude) + } + } + for _, branch := range result.MagnitudeContours[0].Segments { + for _, endpoint := range []SolarEclipsePathPoint{branch[0], branch[len(branch)-1]} { + if !solarEclipseCentralEnvelopePointOnHorizonClosure(endpoint, result.CentralBandHorizonClosures) { + t.Fatal("totality endpoint is not shared with a horizon closure") + } + } + } + }) + } +} + +func BenchmarkSolarEclipsePolarTotalBand(b *testing.B) { + seed := JDECalc(2003, 11, 23) + for i := 0; i < b.N; i++ { + SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{ + StepDays: 2.0 / 1440, BoundaryPoints: 96, DisableRiseSetCurves: true, + }) + } +} diff --git a/basic/solar_terms.go b/basic/solar_terms.go index 17ca996..bc389c0 100644 --- a/basic/solar_terms.go +++ b/basic/solar_terms.go @@ -32,12 +32,21 @@ func GetMoonLoops(year float64, loop int) []float64 { return moonLoops } +// GetJieqiLoops 返回从该年冬至起连续 loop 个节气时刻(北京时间,按 15° 一步): +// 每 24 个节气跨一年,loop<=0 返回 nil;黄经一律归化到 (0, 360], +// 因此 loop 超过 31 时也不会把 >360° 的角度丢给 GetJQTime(那里会静默 NaN)。 +// GetJieqiLoops returns loop consecutive solar-term instants starting at the winter solstice of +// year; 24 terms span one year. Non-positive loop returns nil, and the longitude is normalised into +// (0, 360] so loops beyond 31 never hand an out-of-range angle to GetJQTime. func GetJieqiLoops(year, loop int) []float64 { + if loop <= 0 { + return nil + } start := 270 jq := make([]float64, loop) for i := 1; i <= loop; i++ { angle := start + 15*(i-1) - if angle > 360 { + for angle > 360 { angle -= 360 } jq[i-1] = GetJQTime(year+int(math.Ceil(float64(i-1)/24.000)), angle) + 8.0/24.0 diff --git a/basic/solar_terms_test.go b/basic/solar_terms_test.go new file mode 100644 index 0000000..1e0e6ca --- /dev/null +++ b/basic/solar_terms_test.go @@ -0,0 +1,35 @@ +package basic + +import ( + "math" + "testing" +) + +// GetJieqiLoops 的黄经必须归化到 (0,360]:loop>31 时旧实现把 >360° 的角度直接交给 GetJQTime, +// 那里 Newton 迭代静默失败,返回 9 个 NaN;loop<=0 旧实现还会 makeslice panic。 +func TestGetJieqiLoopsNormalizesAngleAndGuardsLoop(t *testing.T) { + short := GetJieqiLoops(2024, 25) + long := GetJieqiLoops(2024, 40) + if len(long) != 40 { + t.Fatalf("loop=40 length = %d", len(long)) + } + for index, value := range long { + if math.IsNaN(value) || math.IsInf(value, 0) { + t.Fatalf("loop=40 index %d is not finite: %v", index, value) + } + if index > 0 && value <= long[index-1] { + t.Fatalf("loop=40 index %d is not increasing: %v <= %v", index, value, long[index-1]) + } + } + for index := range short { + if math.Float64bits(short[index]) != math.Float64bits(long[index]) { + t.Fatalf("loop=25 index %d changed when loop grows: %v vs %v", index, short[index], long[index]) + } + } + if got := GetJieqiLoops(2024, 0); got != nil { + t.Fatalf("loop=0 = %v, want nil", got) + } + if got := GetJieqiLoops(2024, -3); got != nil { + t.Fatalf("negative loop = %v, want nil (no panic)", got) + } +} diff --git a/basic/star.go b/basic/star.go index da9574b..e383d93 100644 --- a/basic/star.go +++ b/basic/star.go @@ -77,7 +77,8 @@ func MeanSiderealTime1982(jd float64) float64 { // ApparentSiderealTime1982 视恒星时,计算章动 func ApparentSiderealTime1982(jd float64) float64 { tmp := MeanSiderealTime1982(jd) - return tmp + Nutation2000Bi(jd)*Cos(TrueObliquity(jd))/15 + dpsi, deps := Nutation2000B(jd) + return tmp + dpsi*math.Cos((Obliquity1980(jd)+deps)*math.Pi/180)/15 } // EarthRotationAngle 计算地球自转角 (ERA) @@ -115,10 +116,23 @@ func MeanSiderealTime2006(jd_ut1 float64) float64 { return gmst * deg / 15 } -// ApparentSiderealTime2006 视恒星时,计算章动 +// ApparentSiderealTime2006 视恒星时,计算章动。 +// 一次求值只要一份 IAU2000B 章动(黄经与交角在同一次展开里同时得到),并走有界记忆表, +// 避免月掩路径里的重复求值;ΔT 覆盖会使记忆表按世代失效。见 sidereal_memo.go。 +// ApparentSiderealTime2006 computes apparent sidereal time. One evaluation needs only a single +// IAU2000B nutation expansion (longitude and obliquity come out of the same series) and is +// memoized so repeated occultation-path queries do not rerun it; a ΔT override invalidates the +// memo by generation. See sidereal_memo.go. func ApparentSiderealTime2006(jd float64) float64 { + if value, ok := siderealMemoLoad(jd); ok { + return value + } + generation := siderealMemoGeneration() tmp := MeanSiderealTime2006(jd) - return tmp + Nutation2000Bi(jd)*Cos(TrueObliquity(jd))/15 + dpsi, deps := Nutation2000B(jd) + value := tmp + dpsi*math.Cos((Obliquity1980(jd)+deps)*math.Pi/180)/15 + siderealMemoStore(jd, value, generation) + return value } func StarRiseTime(jde, ra, dec, lon, lat, height, timezone float64, aero bool) (float64, error) { diff --git a/basic/sun.go b/basic/sun.go index 6b38a3d..2312f44 100644 --- a/basic/sun.go +++ b/basic/sun.go @@ -61,8 +61,9 @@ func SunTrueRa(jd float64) float64 { //'太阳真赤经 } func SunApparentDec(jd float64) float64 { // '太阳视赤纬 - t := (jd - 2451545) / 36525 - eps := TrueObliquity(jd) + 0.00256*Cos(125.04-1934.136*t) + // TrueObliquity 已是“平交角 + 交角章动”,不能再加 0.00256*cos(Ω): + // 那一项就是交角章动的近似值(见《天文算法》24 章译者注),加了会把章动计两次。 + eps := TrueObliquity(jd) sunApparentDec := ArcSin(Sin(eps) * Sin(SunApparentLo(jd))) return sunApparentDec } diff --git a/basic/sun_observation.go b/basic/sun_observation.go index bd30d52..4bb66e5 100644 --- a/basic/sun_observation.go +++ b/basic/sun_observation.go @@ -6,19 +6,34 @@ import ( . "b612.me/astro/tools" ) -// 太阳中天时刻,通过均时差计算 +// CulminationTime 太阳中天时刻(按均时差计算)/ solar culmination time from the equation of time. +// +// 日期锚点是 floor(jd)(JD 整数 = 12:00 UT 的正午锚点),不是午夜;调用方要传“本地 0 时对应 JD + 0.5” +// 才能落在同一本地日(sun/sun.go 的 CulminationTime 就是这么补的)。地方时相对世界时的偏移按角度归化到 +// ±180°:超过 ±12 小时(如 UTC+14 配西经)时不归化会把中天推到相邻的一天。 func CulminationTime(jd, lon, tz float64) float64 { //实际中天时间 jd = math.Floor(jd) - tmp := (tz*15 - lon) * 4 / 60 + tmp := longitudeOffsetDegrees(tz*15-lon) * 4 / 60 return jd + tmp/24.0 - SunTime(jd)/24.0 } +// CulminationTimeN 截断项太阳中天时刻 / truncated solar culmination time. func CulminationTimeN(jd, lon, tz float64, n int) float64 { //实际中天时间 jd = math.Floor(jd) - tmp := (tz*15 - lon) * 4 / 60 + tmp := longitudeOffsetDegrees(tz*15-lon) * 4 / 60 return jd + tmp/24.0 - SunTimeN(jd, n)/24.0 } +func longitudeOffsetDegrees(offset float64) float64 { + if offset > 180 { + offset -= 360 + } + if offset < -180 { + offset += 360 + } + return offset +} + /* * 昏朦影传入 当天0时时刻 */ diff --git a/basic/sun_test.go b/basic/sun_test.go index 5c11939..9aed0a1 100644 --- a/basic/sun_test.go +++ b/basic/sun_test.go @@ -3,6 +3,8 @@ package basic import ( "math" "testing" + + . "b612.me/astro/tools" ) func TestNutationRegression(t *testing.T) { @@ -29,3 +31,27 @@ func Benchmark_SunLo(b *testing.B) { HSunApparentLo(jde) } } + +// SunApparentDec 必须只用真黄赤交角(平交角 + 交角章动)一次。历史上这里又加了一项 +// 0.00256*cos(Ω),而那一项本身就是交角章动的近似值(《天文算法》24 章译者注: +// "ε 补上交角章动"),于是章动被计两次,视赤纬最大偏 ~12.3″。 +func TestSunApparentDecCountsObliquityNutationOnce(t *testing.T) { + for _, jd := range []float64{2460310.5, 2451545.0, 2448908.5, 2415020.5, 2500000.5} { + want := ArcSin(Sin(TrueObliquity(jd)) * Sin(SunApparentLo(jd))) + if got := SunApparentDec(jd); math.Float64bits(got) != math.Float64bits(want) { + t.Fatalf("jd %.1f: SunApparentDec=%v, want %v (TrueObliquity 单次)", jd, got, want) + } + } + // 与库内高精度视赤纬的全年偏差应回落到低精度视黄经决定的地板(~8″),而不是重复章动的 ~12″。 + maxDiff := 0.0 + for day := 0; day < 365; day++ { + jd := 2460310.5 + float64(day) + diff := math.Abs(SunApparentDec(jd)-HSunApparentDec(jd)) * 3600 + if diff > maxDiff { + maxDiff = diff + } + } + if maxDiff > 10 { + t.Fatalf("全年 max|Δ| = %.2f″,仍高于低精度黄经地板(重复章动会到 ~12.3″)", maxDiff) + } +} diff --git a/basic/testdata/jupiter_event_baseline.json b/basic/testdata/jupiter_event_baseline.json index 1ff92ca..149473b 100644 --- a/basic/testdata/jupiter_event_baseline.json +++ b/basic/testdata/jupiter_event_baseline.json @@ -7,14 +7,14 @@ "LastJupiterConjunction": 4702511639716887239, "LastJupiterEasternQuadrature": 4702511393972914060, "LastJupiterOpposition": 4702512064463161597, - "LastJupiterProgradeToRetrograde": 4702511936733953225, - "LastJupiterRetrogradeToPrograde": 4702511338140077931, + "LastJupiterProgradeToRetrograde": 4702511936734033598, + "LastJupiterRetrogradeToPrograde": 4702511338140337419, "LastJupiterWesternQuadrature": 4702511882517284342, "NextJupiterConjunction": 4702512495925338915, "NextJupiterEasternQuadrature": 4702512249695096510, "NextJupiterOpposition": 4702512914882432379, - "NextJupiterProgradeToRetrograde": 4702512787040647460, - "NextJupiterRetrogradeToPrograde": 4702512194567541994, + "NextJupiterProgradeToRetrograde": 4702512787040814458, + "NextJupiterRetrogradeToPrograde": 4702512194567781165, "NextJupiterWesternQuadrature": 4702512732981118202 } }, @@ -25,14 +25,14 @@ "LastJupiterConjunction": 4702512495925338915, "LastJupiterEasternQuadrature": 4702512249695096510, "LastJupiterOpposition": 4702512064463161597, - "LastJupiterProgradeToRetrograde": 4702511936733953225, - "LastJupiterRetrogradeToPrograde": 4702512194567541994, + "LastJupiterProgradeToRetrograde": 4702511936734033598, + "LastJupiterRetrogradeToPrograde": 4702512194567781165, "LastJupiterWesternQuadrature": 4702511882517284342, "NextJupiterConjunction": 4702513348128865031, "NextJupiterEasternQuadrature": 4702513103066964522, "NextJupiterOpposition": 4702512914882432379, - "NextJupiterProgradeToRetrograde": 4702512787040647460, - "NextJupiterRetrogradeToPrograde": 4702513048375573915, + "NextJupiterProgradeToRetrograde": 4702512787040814458, + "NextJupiterRetrogradeToPrograde": 4702513048375770329, "NextJupiterWesternQuadrature": 4702512732981118202 } }, @@ -43,14 +43,14 @@ "LastJupiterConjunction": 4702512495925338915, "LastJupiterEasternQuadrature": 4702513103066964522, "LastJupiterOpposition": 4702512914882432379, - "LastJupiterProgradeToRetrograde": 4702512787040647460, - "LastJupiterRetrogradeToPrograde": 4702513048375573915, + "LastJupiterProgradeToRetrograde": 4702512787040814458, + "LastJupiterRetrogradeToPrograde": 4702513048375770329, "LastJupiterWesternQuadrature": 4702512732981118202, "NextJupiterConjunction": 4702513348128865031, "NextJupiterEasternQuadrature": 4702513955120441812, "NextJupiterOpposition": 4702513764159190727, - "NextJupiterProgradeToRetrograde": 4702513635402180624, - "NextJupiterRetrogradeToPrograde": 4702513899777207477, + "NextJupiterProgradeToRetrograde": 4702513635402128649, + "NextJupiterRetrogradeToPrograde": 4702513899777322499, "NextJupiterWesternQuadrature": 4702513580732668986 } }, @@ -61,14 +61,14 @@ "LastJupiterConjunction": 4702513348128865031, "LastJupiterEasternQuadrature": 4702513103066964522, "LastJupiterOpposition": 4702512914882432379, - "LastJupiterProgradeToRetrograde": 4702512787040647460, - "LastJupiterRetrogradeToPrograde": 4702513048375573915, + "LastJupiterProgradeToRetrograde": 4702512787040814458, + "LastJupiterRetrogradeToPrograde": 4702513048375770329, "LastJupiterWesternQuadrature": 4702513580732668986, "NextJupiterConjunction": 4702514197794152581, "NextJupiterEasternQuadrature": 4702513955120441812, "NextJupiterOpposition": 4702513764159190727, - "NextJupiterProgradeToRetrograde": 4702513635402180624, - "NextJupiterRetrogradeToPrograde": 4702513899778406116, + "NextJupiterProgradeToRetrograde": 4702513635402128649, + "NextJupiterRetrogradeToPrograde": 4702513899777322499, "NextJupiterWesternQuadrature": 4702514428184983045 } }, @@ -79,14 +79,14 @@ "LastJupiterConjunction": 4702513348128865031, "LastJupiterEasternQuadrature": 4702513955120441812, "LastJupiterOpposition": 4702513764159190727, - "LastJupiterProgradeToRetrograde": 4702513635402180624, - "LastJupiterRetrogradeToPrograde": 4702513899777207477, + "LastJupiterProgradeToRetrograde": 4702513635402128649, + "LastJupiterRetrogradeToPrograde": 4702513899777322499, "LastJupiterWesternQuadrature": 4702513580732668986, "NextJupiterConjunction": 4702514197794152581, "NextJupiterEasternQuadrature": 4702514807158075028, "NextJupiterOpposition": 4702514614284651561, - "NextJupiterProgradeToRetrograde": 4702514484375506721, - "NextJupiterRetrogradeToPrograde": 4702514750232496688, + "NextJupiterProgradeToRetrograde": 4702514484375758119, + "NextJupiterRetrogradeToPrograde": 4702514750232530719, "NextJupiterWesternQuadrature": 4702514428184983045 } }, @@ -97,14 +97,14 @@ "LastJupiterConjunction": 4702514197794152581, "LastJupiterEasternQuadrature": 4702513955120441812, "LastJupiterOpposition": 4702513764159190727, - "LastJupiterProgradeToRetrograde": 4702514484375506721, - "LastJupiterRetrogradeToPrograde": 4702513899777207477, + "LastJupiterProgradeToRetrograde": 4702514484375758119, + "LastJupiterRetrogradeToPrograde": 4702513899777322499, "LastJupiterWesternQuadrature": 4702514428184983045, "NextJupiterConjunction": 4702515046903534947, "NextJupiterEasternQuadrature": 4702514807158075028, "NextJupiterOpposition": 4702514614284651561, - "NextJupiterProgradeToRetrograde": 4702515336182395911, - "NextJupiterRetrogradeToPrograde": 4702514750232496688, + "NextJupiterProgradeToRetrograde": 4702515336181506833, + "NextJupiterRetrogradeToPrograde": 4702514750232530719, "NextJupiterWesternQuadrature": 4702515277713947870 } }, @@ -115,14 +115,14 @@ "LastJupiterConjunction": 4702515046903534947, "LastJupiterEasternQuadrature": 4702514807158075028, "LastJupiterOpposition": 4702514614284651561, - "LastJupiterProgradeToRetrograde": 4702514484375506721, - "LastJupiterRetrogradeToPrograde": 4702514750232496688, + "LastJupiterProgradeToRetrograde": 4702514484375758119, + "LastJupiterRetrogradeToPrograde": 4702514750232530719, "LastJupiterWesternQuadrature": 4702514428184983045, "NextJupiterConjunction": 4702515897478329250, "NextJupiterEasternQuadrature": 4702515660462282474, "NextJupiterOpposition": 4702515467054740368, - "NextJupiterProgradeToRetrograde": 4702515336182395911, - "NextJupiterRetrogradeToPrograde": 4702515601124407138, + "NextJupiterProgradeToRetrograde": 4702515336181506833, + "NextJupiterRetrogradeToPrograde": 4702515601124362778, "NextJupiterWesternQuadrature": 4702515277713947870 } }, @@ -133,14 +133,14 @@ "LastJupiterConjunction": 4702515046903534947, "LastJupiterEasternQuadrature": 4702514807158075028, "LastJupiterOpposition": 4702515467054740368, - "LastJupiterProgradeToRetrograde": 4702515336182395911, - "LastJupiterRetrogradeToPrograde": 4702514750232496688, + "LastJupiterProgradeToRetrograde": 4702515336181506833, + "LastJupiterRetrogradeToPrograde": 4702514750232530719, "LastJupiterWesternQuadrature": 4702515277713947870, "NextJupiterConjunction": 4702515897478329250, "NextJupiterEasternQuadrature": 4702515660462282474, "NextJupiterOpposition": 4702516323930527911, - "NextJupiterProgradeToRetrograde": 4702516193453059376, - "NextJupiterRetrogradeToPrograde": 4702515601124407138, + "NextJupiterProgradeToRetrograde": 4702516193453225105, + "NextJupiterRetrogradeToPrograde": 4702515601124362778, "NextJupiterWesternQuadrature": 4702516131575695012 } }, @@ -151,14 +151,14 @@ "LastJupiterConjunction": 4702515897478329250, "LastJupiterEasternQuadrature": 4702515660462282474, "LastJupiterOpposition": 4702515467054740368, - "LastJupiterProgradeToRetrograde": 4702515336182395911, - "LastJupiterRetrogradeToPrograde": 4702515601124407138, + "LastJupiterProgradeToRetrograde": 4702515336181506833, + "LastJupiterRetrogradeToPrograde": 4702515601124362778, "LastJupiterWesternQuadrature": 4702515277713947870, "NextJupiterConjunction": 4702516751407804597, "NextJupiterEasternQuadrature": 4702516516160014083, "NextJupiterOpposition": 4702516323930527911, - "NextJupiterProgradeToRetrograde": 4702516193453059376, - "NextJupiterRetrogradeToPrograde": 4702516455127316802, + "NextJupiterProgradeToRetrograde": 4702516193453225105, + "NextJupiterRetrogradeToPrograde": 4702516455127235935, "NextJupiterWesternQuadrature": 4702516131575695012 } }, @@ -169,14 +169,14 @@ "LastJupiterConjunction": 4702515897478329250, "LastJupiterEasternQuadrature": 4702516516160014083, "LastJupiterOpposition": 4702516323930527911, - "LastJupiterProgradeToRetrograde": 4702516193453059376, - "LastJupiterRetrogradeToPrograde": 4702516455127316802, + "LastJupiterProgradeToRetrograde": 4702516193453225105, + "LastJupiterRetrogradeToPrograde": 4702516455127235935, "LastJupiterWesternQuadrature": 4702516131575695012, "NextJupiterConjunction": 4702516751407804597, "NextJupiterEasternQuadrature": 4702517374743600545, "NextJupiterOpposition": 4702517185247400030, - "NextJupiterProgradeToRetrograde": 4702517056174132032, - "NextJupiterRetrogradeToPrograde": 4702517313542290884, + "NextJupiterProgradeToRetrograde": 4702517056174170982, + "NextJupiterRetrogradeToPrograde": 4702517313542247826, "NextJupiterWesternQuadrature": 4702516991222819727 } }, @@ -187,14 +187,14 @@ "LastJupiterConjunction": 4702516751407804597, "LastJupiterEasternQuadrature": 4702516516160014083, "LastJupiterOpposition": 4702516323930527911, - "LastJupiterProgradeToRetrograde": 4702516193453059376, - "LastJupiterRetrogradeToPrograde": 4702516455127316802, + "LastJupiterProgradeToRetrograde": 4702516193453225105, + "LastJupiterRetrogradeToPrograde": 4702516455127235935, "LastJupiterWesternQuadrature": 4702516991222819727, "NextJupiterConjunction": 4702517610018433278, "NextJupiterEasternQuadrature": 4702517374743600545, "NextJupiterOpposition": 4702517185247400030, - "NextJupiterProgradeToRetrograde": 4702517056174132032, - "NextJupiterRetrogradeToPrograde": 4702517313542290884, + "NextJupiterProgradeToRetrograde": 4702517056174170982, + "NextJupiterRetrogradeToPrograde": 4702517313542247826, "NextJupiterWesternQuadrature": 4702517856327958796 } }, @@ -205,14 +205,14 @@ "LastJupiterConjunction": 4702516751407804597, "LastJupiterEasternQuadrature": 4702517374743600545, "LastJupiterOpposition": 4702517185247400030, - "LastJupiterProgradeToRetrograde": 4702517056174132032, - "LastJupiterRetrogradeToPrograde": 4702517313542290884, + "LastJupiterProgradeToRetrograde": 4702517056174170982, + "LastJupiterRetrogradeToPrograde": 4702517313542247826, "LastJupiterWesternQuadrature": 4702516991222819727, "NextJupiterConjunction": 4702517610018433278, "NextJupiterEasternQuadrature": 4702518235789079116, "NextJupiterOpposition": 4702518049732300147, - "NextJupiterProgradeToRetrograde": 4702517922209342847, - "NextJupiterRetrogradeToPrograde": 4702518176266561966, + "NextJupiterProgradeToRetrograde": 4702517922209247859, + "NextJupiterRetrogradeToPrograde": 4702518176266624940, "NextJupiterWesternQuadrature": 4702517856327958796 } }, @@ -223,14 +223,14 @@ "LastJupiterConjunction": 4702517610018433278, "LastJupiterEasternQuadrature": 4702517374743600545, "LastJupiterOpposition": 4702517185247400030, - "LastJupiterProgradeToRetrograde": 4702517922209342847, - "LastJupiterRetrogradeToPrograde": 4702517313542290884, + "LastJupiterProgradeToRetrograde": 4702517922209247859, + "LastJupiterRetrogradeToPrograde": 4702517313542247826, "LastJupiterWesternQuadrature": 4702517856327958796, "NextJupiterConjunction": 4702518473067554081, "NextJupiterEasternQuadrature": 4702518235789079116, "NextJupiterOpposition": 4702518049732300147, - "NextJupiterProgradeToRetrograde": 4702518787154982595, - "NextJupiterRetrogradeToPrograde": 4702518176266561966, + "NextJupiterProgradeToRetrograde": 4702518787154844104, + "NextJupiterRetrogradeToPrograde": 4702518176266624940, "NextJupiterWesternQuadrature": 4702518723959582186 } }, @@ -241,14 +241,14 @@ "LastJupiterConjunction": 4702518473067554081, "LastJupiterEasternQuadrature": 4702518235789079116, "LastJupiterOpposition": 4702518049732300147, - "LastJupiterProgradeToRetrograde": 4702517922209342847, - "LastJupiterRetrogradeToPrograde": 4702518176266561966, + "LastJupiterProgradeToRetrograde": 4702517922209247859, + "LastJupiterRetrogradeToPrograde": 4702518176266624940, "LastJupiterWesternQuadrature": 4702517856327958796, "NextJupiterConjunction": 4702519338321882994, "NextJupiterEasternQuadrature": 4702519097687957764, "NextJupiterOpposition": 4702518914455605723, - "NextJupiterProgradeToRetrograde": 4702518787154982595, - "NextJupiterRetrogradeToPrograde": 4702519039884338738, + "NextJupiterProgradeToRetrograde": 4702518787154844104, + "NextJupiterRetrogradeToPrograde": 4702519039884517888, "NextJupiterWesternQuadrature": 4702518723959582186 } }, @@ -259,14 +259,14 @@ "LastJupiterConjunction": 4702518473067554081, "LastJupiterEasternQuadrature": 4702518235789079116, "LastJupiterOpposition": 4702518914455605723, - "LastJupiterProgradeToRetrograde": 4702518787154982595, - "LastJupiterRetrogradeToPrograde": 4702518176266561966, + "LastJupiterProgradeToRetrograde": 4702518787154844104, + "LastJupiterRetrogradeToPrograde": 4702518176266624940, "LastJupiterWesternQuadrature": 4702518723959582186, "NextJupiterConjunction": 4702519338321882994, "NextJupiterEasternQuadrature": 4702519097687957764, "NextJupiterOpposition": 4702519776235500353, - "NextJupiterProgradeToRetrograde": 4702519648440464419, - "NextJupiterRetrogradeToPrograde": 4702519039884338738, + "NextJupiterProgradeToRetrograde": 4702519648440386924, + "NextJupiterRetrogradeToPrograde": 4702519039884517888, "NextJupiterWesternQuadrature": 4702519589633910262 } }, @@ -277,14 +277,14 @@ "LastJupiterConjunction": 4702519338321882994, "LastJupiterEasternQuadrature": 4702519097687957764, "LastJupiterOpposition": 4702518914455605723, - "LastJupiterProgradeToRetrograde": 4702518787154982595, - "LastJupiterRetrogradeToPrograde": 4702519039884338738, + "LastJupiterProgradeToRetrograde": 4702518787154844104, + "LastJupiterRetrogradeToPrograde": 4702519039884517888, "LastJupiterWesternQuadrature": 4702518723959582186, "NextJupiterConjunction": 4702520202370088051, "NextJupiterEasternQuadrature": 4702519958453179144, "NextJupiterOpposition": 4702519776235500353, - "NextJupiterProgradeToRetrograde": 4702519648440464419, - "NextJupiterRetrogradeToPrograde": 4702519901906108077, + "NextJupiterProgradeToRetrograde": 4702519648440386924, + "NextJupiterRetrogradeToPrograde": 4702519901906363326, "NextJupiterWesternQuadrature": 4702519589633910262 } }, @@ -295,14 +295,14 @@ "LastJupiterConjunction": 4702519338321882994, "LastJupiterEasternQuadrature": 4702519097687957764, "LastJupiterOpposition": 4702519776235500353, - "LastJupiterProgradeToRetrograde": 4702519648440464419, - "LastJupiterRetrogradeToPrograde": 4702519901906108077, + "LastJupiterProgradeToRetrograde": 4702519648440386924, + "LastJupiterRetrogradeToPrograde": 4702519901906363326, "LastJupiterWesternQuadrature": 4702519589633910262, "NextJupiterConjunction": 4702520202370088051, "NextJupiterEasternQuadrature": 4702519958453179144, "NextJupiterOpposition": 4702520633410535929, - "NextJupiterProgradeToRetrograde": 4702520505561245416, - "NextJupiterRetrogradeToPrograde": 4702520760902697291, + "NextJupiterProgradeToRetrograde": 4702520505561248825, + "NextJupiterRetrogradeToPrograde": 4702520760902959495, "NextJupiterWesternQuadrature": 4702520450002390791 } }, @@ -313,14 +313,14 @@ "LastJupiterConjunction": 4702520202370088051, "LastJupiterEasternQuadrature": 4702519958453179144, "LastJupiterOpposition": 4702519776235500353, - "LastJupiterProgradeToRetrograde": 4702519648440464419, - "LastJupiterRetrogradeToPrograde": 4702519901906108077, + "LastJupiterProgradeToRetrograde": 4702519648440386924, + "LastJupiterRetrogradeToPrograde": 4702519901906363326, "LastJupiterWesternQuadrature": 4702519589633910262, "NextJupiterConjunction": 4702521062497795583, "NextJupiterEasternQuadrature": 4702520816619050113, "NextJupiterOpposition": 4702520633410535929, - "NextJupiterProgradeToRetrograde": 4702520505561245416, - "NextJupiterRetrogradeToPrograde": 4702520760902697291, + "NextJupiterProgradeToRetrograde": 4702520505561248825, + "NextJupiterRetrogradeToPrograde": 4702520760902959495, "NextJupiterWesternQuadrature": 4702520450002390791 } }, @@ -331,14 +331,14 @@ "LastJupiterConjunction": 4702520202370088051, "LastJupiterEasternQuadrature": 4702520816619050113, "LastJupiterOpposition": 4702520633410535929, - "LastJupiterProgradeToRetrograde": 4702520505561245416, - "LastJupiterRetrogradeToPrograde": 4702520760902697291, + "LastJupiterProgradeToRetrograde": 4702520505561248825, + "LastJupiterRetrogradeToPrograde": 4702520760902959495, "LastJupiterWesternQuadrature": 4702520450002390791, "NextJupiterConjunction": 4702521062497795583, "NextJupiterEasternQuadrature": 4702521671866577044, "NextJupiterOpposition": 4702521486257822487, - "NextJupiterProgradeToRetrograde": 4702521358490210902, - "NextJupiterRetrogradeToPrograde": 4702521616887902469, + "NextJupiterProgradeToRetrograde": 4702521358490315178, + "NextJupiterRetrogradeToPrograde": 4702521616888142592, "NextJupiterWesternQuadrature": 4702521304428672627 } }, @@ -349,14 +349,14 @@ "LastJupiterConjunction": 4702521062497795583, "LastJupiterEasternQuadrature": 4702520816619050113, "LastJupiterOpposition": 4702520633410535929, - "LastJupiterProgradeToRetrograde": 4702521358490210902, - "LastJupiterRetrogradeToPrograde": 4702520760902697291, + "LastJupiterProgradeToRetrograde": 4702521358490315178, + "LastJupiterRetrogradeToPrograde": 4702520760902959495, "LastJupiterWesternQuadrature": 4702521304428672627, "NextJupiterConjunction": 4702521917985872290, "NextJupiterEasternQuadrature": 4702521671866577044, "NextJupiterOpposition": 4702521486257822487, - "NextJupiterProgradeToRetrograde": 4702522208314943321, - "NextJupiterRetrogradeToPrograde": 4702521616887902469, + "NextJupiterProgradeToRetrograde": 4702522208315116066, + "NextJupiterRetrogradeToPrograde": 4702521616888142592, "NextJupiterWesternQuadrature": 4702522154284334640 } }, @@ -367,14 +367,14 @@ "LastJupiterConjunction": 4702521062497795583, "LastJupiterEasternQuadrature": 4702521671866577044, "LastJupiterOpposition": 4702521486257822487, - "LastJupiterProgradeToRetrograde": 4702521358490210902, - "LastJupiterRetrogradeToPrograde": 4702521616887902469, + "LastJupiterProgradeToRetrograde": 4702521358490315178, + "LastJupiterRetrogradeToPrograde": 4702521616888142592, "LastJupiterWesternQuadrature": 4702521304428672627, "NextJupiterConjunction": 4702521917985872290, "NextJupiterEasternQuadrature": 4702522524867257437, "NextJupiterOpposition": 4702522336293948473, - "NextJupiterProgradeToRetrograde": 4702522208314943321, - "NextJupiterRetrogradeToPrograde": 4702522470158612076, + "NextJupiterProgradeToRetrograde": 4702522208315116066, + "NextJupiterRetrogradeToPrograde": 4702522470158789167, "NextJupiterWesternQuadrature": 4702522154284334640 } }, @@ -385,14 +385,14 @@ "LastJupiterConjunction": 4702521917985872290, "LastJupiterEasternQuadrature": 4702521671866577044, "LastJupiterOpposition": 4702522336293948473, - "LastJupiterProgradeToRetrograde": 4702522208314943321, - "LastJupiterRetrogradeToPrograde": 4702521616887902469, + "LastJupiterProgradeToRetrograde": 4702522208315116066, + "LastJupiterRetrogradeToPrograde": 4702521616888142592, "LastJupiterWesternQuadrature": 4702522154284334640, "NextJupiterConjunction": 4702522769611668861, "NextJupiterEasternQuadrature": 4702522524867257437, "NextJupiterOpposition": 4702523185481643490, - "NextJupiterProgradeToRetrograde": 4702523056647498349, - "NextJupiterRetrogradeToPrograde": 4702522470158612076, + "NextJupiterProgradeToRetrograde": 4702523056647729621, + "NextJupiterRetrogradeToPrograde": 4702522470158789167, "NextJupiterWesternQuadrature": 4702523001754878487 } }, @@ -403,14 +403,14 @@ "LastJupiterConjunction": 4702522769611668861, "LastJupiterEasternQuadrature": 4702522524867257437, "LastJupiterOpposition": 4702522336293948473, - "LastJupiterProgradeToRetrograde": 4702522208314943321, - "LastJupiterRetrogradeToPrograde": 4702522470158612076, + "LastJupiterProgradeToRetrograde": 4702522208315116066, + "LastJupiterRetrogradeToPrograde": 4702522470158789167, "LastJupiterWesternQuadrature": 4702522154284334640, "NextJupiterConjunction": 4702523619040345746, "NextJupiterEasternQuadrature": 4702523376757230717, "NextJupiterOpposition": 4702523185481643490, - "NextJupiterProgradeToRetrograde": 4702523056647498349, - "NextJupiterRetrogradeToPrograde": 4702523321352707163, + "NextJupiterProgradeToRetrograde": 4702523056647729621, + "NextJupiterRetrogradeToPrograde": 4702523321352808888, "NextJupiterWesternQuadrature": 4702523001754878487 } }, @@ -421,14 +421,14 @@ "LastJupiterConjunction": 4702522769611668861, "LastJupiterEasternQuadrature": 4702522524867257437, "LastJupiterOpposition": 4702523185481643490, - "LastJupiterProgradeToRetrograde": 4702523056647498349, - "LastJupiterRetrogradeToPrograde": 4702523321352707163, + "LastJupiterProgradeToRetrograde": 4702523056647729621, + "LastJupiterRetrogradeToPrograde": 4702523321352808888, "LastJupiterWesternQuadrature": 4702523001754878487, "NextJupiterConjunction": 4702523619040345746, "NextJupiterEasternQuadrature": 4702523376757230717, "NextJupiterOpposition": 4702524035805022913, - "NextJupiterProgradeToRetrograde": 4702523905664036962, - "NextJupiterRetrogradeToPrograde": 4702524171499874650, + "NextJupiterProgradeToRetrograde": 4702523905663922426, + "NextJupiterRetrogradeToPrograde": 4702524171499883741, "NextJupiterWesternQuadrature": 4702523849302148560 } }, @@ -439,14 +439,14 @@ "LastJupiterConjunction": 4702523619040345746, "LastJupiterEasternQuadrature": 4702523376757230717, "LastJupiterOpposition": 4702523185481643490, - "LastJupiterProgradeToRetrograde": 4702523056647498349, - "LastJupiterRetrogradeToPrograde": 4702523321352707163, + "LastJupiterProgradeToRetrograde": 4702523056647729621, + "LastJupiterRetrogradeToPrograde": 4702523321352808888, "LastJupiterWesternQuadrature": 4702523849302148560, "NextJupiterConjunction": 4702524468223248937, "NextJupiterEasternQuadrature": 4702524228832748287, "NextJupiterOpposition": 4702524035805022913, - "NextJupiterProgradeToRetrograde": 4702523905664036962, - "NextJupiterRetrogradeToPrograde": 4702524171499874650, + "NextJupiterProgradeToRetrograde": 4702523905663922426, + "NextJupiterRetrogradeToPrograde": 4702524171499883741, "NextJupiterWesternQuadrature": 4702524699351264593 } }, @@ -457,14 +457,14 @@ "LastJupiterConjunction": 4702523619040345746, "LastJupiterEasternQuadrature": 4702524228832748287, "LastJupiterOpposition": 4702524035805022913, - "LastJupiterProgradeToRetrograde": 4702523905664036962, - "LastJupiterRetrogradeToPrograde": 4702524171499874650, + "LastJupiterProgradeToRetrograde": 4702523905663922426, + "LastJupiterRetrogradeToPrograde": 4702524171499883741, "LastJupiterWesternQuadrature": 4702523849302148560, "NextJupiterConjunction": 4702524468223248937, "NextJupiterEasternQuadrature": 4702525082476499564, "NextJupiterOpposition": 4702524889125890188, - "NextJupiterProgradeToRetrograde": 4702524758348095820, - "NextJupiterRetrogradeToPrograde": 4702525022873864934, + "NextJupiterProgradeToRetrograde": 4702524758348332738, + "NextJupiterRetrogradeToPrograde": 4702525022873806894, "NextJupiterWesternQuadrature": 4702524699351264593 } }, @@ -475,14 +475,14 @@ "LastJupiterConjunction": 4702524468223248937, "LastJupiterEasternQuadrature": 4702524228832748287, "LastJupiterOpposition": 4702524035805022913, - "LastJupiterProgradeToRetrograde": 4702524758348095820, - "LastJupiterRetrogradeToPrograde": 4702524171499874650, + "LastJupiterProgradeToRetrograde": 4702524758348332738, + "LastJupiterRetrogradeToPrograde": 4702524171499883741, "LastJupiterWesternQuadrature": 4702524699351264593, "NextJupiterConjunction": 4702525319223926903, "NextJupiterEasternQuadrature": 4702525082476499564, "NextJupiterOpposition": 4702524889125890188, - "NextJupiterProgradeToRetrograde": 4702525616387968108, - "NextJupiterRetrogradeToPrograde": 4702525022873864934, + "NextJupiterProgradeToRetrograde": 4702525616388106096, + "NextJupiterRetrogradeToPrograde": 4702525022873806894, "NextJupiterWesternQuadrature": 4702525554037783144 } }, @@ -493,14 +493,14 @@ "LastJupiterConjunction": 4702524468223248937, "LastJupiterEasternQuadrature": 4702525082476499564, "LastJupiterOpposition": 4702524889125890188, - "LastJupiterProgradeToRetrograde": 4702524758348095820, - "LastJupiterRetrogradeToPrograde": 4702525022873864934, + "LastJupiterProgradeToRetrograde": 4702524758348332738, + "LastJupiterRetrogradeToPrograde": 4702525022873806894, "LastJupiterWesternQuadrature": 4702524699351264593, "NextJupiterConjunction": 4702525319223926903, "NextJupiterEasternQuadrature": 4702525938677840869, "NextJupiterOpposition": 4702525746727313634, - "NextJupiterProgradeToRetrograde": 4702525616387968108, - "NextJupiterRetrogradeToPrograde": 4702525877346897010, + "NextJupiterProgradeToRetrograde": 4702525616388106096, + "NextJupiterRetrogradeToPrograde": 4702525877346820799, "NextJupiterWesternQuadrature": 4702525554037783144 } }, @@ -511,14 +511,14 @@ "LastJupiterConjunction": 4702525319223926903, "LastJupiterEasternQuadrature": 4702525082476499564, "LastJupiterOpposition": 4702525746727313634, - "LastJupiterProgradeToRetrograde": 4702525616387968108, - "LastJupiterRetrogradeToPrograde": 4702525022873864934, + "LastJupiterProgradeToRetrograde": 4702525616388106096, + "LastJupiterRetrogradeToPrograde": 4702525022873806894, "LastJupiterWesternQuadrature": 4702525554037783144, "NextJupiterConjunction": 4702526173905141455, "NextJupiterEasternQuadrature": 4702525938677840869, "NextJupiterOpposition": 4702526608783529075, - "NextJupiterProgradeToRetrograde": 4702526480112540274, - "NextJupiterRetrogradeToPrograde": 4702525877346411171, + "NextJupiterProgradeToRetrograde": 4702526480112565816, + "NextJupiterRetrogradeToPrograde": 4702525877346820799, "NextJupiterWesternQuadrature": 4702526414693362431 } }, @@ -529,14 +529,14 @@ "LastJupiterConjunction": 4702526173905141455, "LastJupiterEasternQuadrature": 4702525938677840869, "LastJupiterOpposition": 4702525746727313634, - "LastJupiterProgradeToRetrograde": 4702525616387968108, - "LastJupiterRetrogradeToPrograde": 4702525877346411171, + "LastJupiterProgradeToRetrograde": 4702525616388106096, + "LastJupiterRetrogradeToPrograde": 4702525877346820799, "LastJupiterWesternQuadrature": 4702525554037783144, "NextJupiterConjunction": 4702527033345888405, "NextJupiterEasternQuadrature": 4702526797833832434, "NextJupiterOpposition": 4702526608783529075, - "NextJupiterProgradeToRetrograde": 4702526480112540274, - "NextJupiterRetrogradeToPrograde": 4702526736805395038, + "NextJupiterProgradeToRetrograde": 4702526480112565816, + "NextJupiterRetrogradeToPrograde": 4702526736805355702, "NextJupiterWesternQuadrature": 4702526414693362431 } }, @@ -547,14 +547,14 @@ "LastJupiterConjunction": 4702526173905141455, "LastJupiterEasternQuadrature": 4702525938677840869, "LastJupiterOpposition": 4702526608783529075, - "LastJupiterProgradeToRetrograde": 4702526480112540274, - "LastJupiterRetrogradeToPrograde": 4702526736805386970, + "LastJupiterProgradeToRetrograde": 4702526480112565816, + "LastJupiterRetrogradeToPrograde": 4702526736805355700, "LastJupiterWesternQuadrature": 4702526414693362431, "NextJupiterConjunction": 4702527033345888405, "NextJupiterEasternQuadrature": 4702526797833832434, "NextJupiterOpposition": 4702527473656996631, - "NextJupiterProgradeToRetrograde": 4702527346266021147, - "NextJupiterRetrogradeToPrograde": 4702527599853594592, + "NextJupiterProgradeToRetrograde": 4702527346265896391, + "NextJupiterRetrogradeToPrograde": 4702527599853689283, "NextJupiterWesternQuadrature": 4702527280539833529 } }, @@ -565,14 +565,14 @@ "LastJupiterConjunction": 4702527033345888405, "LastJupiterEasternQuadrature": 4702526797833832434, "LastJupiterOpposition": 4702526608783529075, - "LastJupiterProgradeToRetrograde": 4702526480112540274, - "LastJupiterRetrogradeToPrograde": 4702526736805395038, + "LastJupiterProgradeToRetrograde": 4702526480112565816, + "LastJupiterRetrogradeToPrograde": 4702526736805355702, "LastJupiterWesternQuadrature": 4702526414693362431, "NextJupiterConjunction": 4702527896986437669, "NextJupiterEasternQuadrature": 4702527659252019133, "NextJupiterOpposition": 4702527473656996631, - "NextJupiterProgradeToRetrograde": 4702527346266021147, - "NextJupiterRetrogradeToPrograde": 4702527599853594592, + "NextJupiterProgradeToRetrograde": 4702527346265896391, + "NextJupiterRetrogradeToPrograde": 4702527599853689283, "NextJupiterWesternQuadrature": 4702527280539833529 } }, @@ -583,14 +583,14 @@ "LastJupiterConjunction": 4702527033345888405, "LastJupiterEasternQuadrature": 4702527659252019133, "LastJupiterOpposition": 4702527473656996631, - "LastJupiterProgradeToRetrograde": 4702527346266021147, - "LastJupiterRetrogradeToPrograde": 4702527599853594592, + "LastJupiterProgradeToRetrograde": 4702527346265896391, + "LastJupiterRetrogradeToPrograde": 4702527599853689283, "LastJupiterWesternQuadrature": 4702527280539833529, "NextJupiterConjunction": 4702527896986437669, "NextJupiterEasternQuadrature": 4702528521180282245, "NextJupiterOpposition": 4702528338207354251, - "NextJupiterProgradeToRetrograde": 4702528210771113729, - "NextJupiterRetrogradeToPrograde": 4702528463632397853, + "NextJupiterProgradeToRetrograde": 4702528210771635956, + "NextJupiterRetrogradeToPrograde": 4702528463632582247, "NextJupiterWesternQuadrature": 4702528148243704756 } }, @@ -601,14 +601,14 @@ "LastJupiterConjunction": 4702527896986437669, "LastJupiterEasternQuadrature": 4702527659252019133, "LastJupiterOpposition": 4702527473656996631, - "LastJupiterProgradeToRetrograde": 4702528210771113729, - "LastJupiterRetrogradeToPrograde": 4702527599853594592, + "LastJupiterProgradeToRetrograde": 4702528210771635956, + "LastJupiterRetrogradeToPrograde": 4702527599853689283, "LastJupiterWesternQuadrature": 4702528148243704756, "NextJupiterConjunction": 4702528762306744913, "NextJupiterEasternQuadrature": 4702528521180282245, "NextJupiterOpposition": 4702528338207354251, - "NextJupiterProgradeToRetrograde": 4702529071584762951, - "NextJupiterRetrogradeToPrograde": 4702528463632397853, + "NextJupiterProgradeToRetrograde": 4702529071584687899, + "NextJupiterRetrogradeToPrograde": 4702528463632582247, "NextJupiterWesternQuadrature": 4702529013282212463 } }, @@ -619,14 +619,14 @@ "LastJupiterConjunction": 4702527896986437669, "LastJupiterEasternQuadrature": 4702528521180282245, "LastJupiterOpposition": 4702528338207354251, - "LastJupiterProgradeToRetrograde": 4702528210771113729, - "LastJupiterRetrogradeToPrograde": 4702528463632397853, + "LastJupiterProgradeToRetrograde": 4702528210771635956, + "LastJupiterRetrogradeToPrograde": 4702528463632582247, "LastJupiterWesternQuadrature": 4702528148243704756, "NextJupiterConjunction": 4702528762306744913, "NextJupiterEasternQuadrature": 4702529381592344821, "NextJupiterOpposition": 4702529199353153412, - "NextJupiterProgradeToRetrograde": 4702529071584762951, - "NextJupiterRetrogradeToPrograde": 4702529325215904990, + "NextJupiterProgradeToRetrograde": 4702529071584687899, + "NextJupiterRetrogradeToPrograde": 4702529325215680026, "NextJupiterWesternQuadrature": 4702529013282212463 } }, @@ -637,14 +637,14 @@ "LastJupiterConjunction": 4702528762306744913, "LastJupiterEasternQuadrature": 4702528521180282245, "LastJupiterOpposition": 4702529199353153412, - "LastJupiterProgradeToRetrograde": 4702529071584762951, - "LastJupiterRetrogradeToPrograde": 4702528463632397853, + "LastJupiterProgradeToRetrograde": 4702529071584687899, + "LastJupiterRetrogradeToPrograde": 4702528463632582247, "LastJupiterWesternQuadrature": 4702529013282212463, "NextJupiterConjunction": 4702529625846148339, "NextJupiterEasternQuadrature": 4702529381592344821, "NextJupiterOpposition": 4702530055790815584, - "NextJupiterProgradeToRetrograde": 4702529927850006369, - "NextJupiterRetrogradeToPrograde": 4702529325215904990, + "NextJupiterProgradeToRetrograde": 4702529927850025963, + "NextJupiterRetrogradeToPrograde": 4702529325215680026, "NextJupiterWesternQuadrature": 4702529872713579891 } }, @@ -655,14 +655,14 @@ "LastJupiterConjunction": 4702529625846148339, "LastJupiterEasternQuadrature": 4702529381592344821, "LastJupiterOpposition": 4702529199353153412, - "LastJupiterProgradeToRetrograde": 4702529071584762951, - "LastJupiterRetrogradeToPrograde": 4702529325215904990, + "LastJupiterProgradeToRetrograde": 4702529071584687899, + "LastJupiterRetrogradeToPrograde": 4702529325215680026, "LastJupiterWesternQuadrature": 4702529013282212463, "NextJupiterConjunction": 4702530485246226801, "NextJupiterEasternQuadrature": 4702530239263432840, "NextJupiterOpposition": 4702530055790815584, - "NextJupiterProgradeToRetrograde": 4702529927850006369, - "NextJupiterRetrogradeToPrograde": 4702530183635298060, + "NextJupiterProgradeToRetrograde": 4702529927850025963, + "NextJupiterRetrogradeToPrograde": 4702530183635554486, "NextJupiterWesternQuadrature": 4702529872713579891 } }, @@ -673,14 +673,14 @@ "LastJupiterConjunction": 4702529625846148339, "LastJupiterEasternQuadrature": 4702529381592344821, "LastJupiterOpposition": 4702530055790815584, - "LastJupiterProgradeToRetrograde": 4702529927850006369, - "LastJupiterRetrogradeToPrograde": 4702530183635298060, + "LastJupiterProgradeToRetrograde": 4702529927850025963, + "LastJupiterRetrogradeToPrograde": 4702530183635554486, "LastJupiterWesternQuadrature": 4702529872713579891, "NextJupiterConjunction": 4702530485246226801, "NextJupiterEasternQuadrature": 4702530239263432840, "NextJupiterOpposition": 4702530908086294254, - "NextJupiterProgradeToRetrograde": 4702530780352767120, - "NextJupiterRetrogradeToPrograde": 4702531039231848262, + "NextJupiterProgradeToRetrograde": 4702530780352875856, + "NextJupiterRetrogradeToPrograde": 4702531039232070289, "NextJupiterWesternQuadrature": 4702530726333406988 } }, @@ -691,14 +691,14 @@ "LastJupiterConjunction": 4702530485246226801, "LastJupiterEasternQuadrature": 4702530239263432840, "LastJupiterOpposition": 4702530055790815584, - "LastJupiterProgradeToRetrograde": 4702529927850006369, - "LastJupiterRetrogradeToPrograde": 4702530183635298060, + "LastJupiterProgradeToRetrograde": 4702529927850025963, + "LastJupiterRetrogradeToPrograde": 4702530183635554486, "LastJupiterWesternQuadrature": 4702529872713579891, "NextJupiterConjunction": 4702531340061690908, "NextJupiterEasternQuadrature": 4702531094085269425, "NextJupiterOpposition": 4702530908086294254, - "NextJupiterProgradeToRetrograde": 4702530780352767120, - "NextJupiterRetrogradeToPrograde": 4702531039231848262, + "NextJupiterProgradeToRetrograde": 4702530780352875856, + "NextJupiterRetrogradeToPrograde": 4702531039232070289, "NextJupiterWesternQuadrature": 4702530726333406988 } }, @@ -709,14 +709,14 @@ "LastJupiterConjunction": 4702530485246226801, "LastJupiterEasternQuadrature": 4702531094085269425, "LastJupiterOpposition": 4702530908086294254, - "LastJupiterProgradeToRetrograde": 4702530780352767120, - "LastJupiterRetrogradeToPrograde": 4702531039231848262, + "LastJupiterProgradeToRetrograde": 4702530780352875856, + "LastJupiterRetrogradeToPrograde": 4702531039232070289, "LastJupiterWesternQuadrature": 4702530726333406988, "NextJupiterConjunction": 4702531340061690908, "NextJupiterEasternQuadrature": 4702531946807778713, "NextJupiterOpposition": 4702531757829077880, - "NextJupiterProgradeToRetrograde": 4702531629751833528, - "NextJupiterRetrogradeToPrograde": 4702531892068297696, + "NextJupiterProgradeToRetrograde": 4702531629752021984, + "NextJupiterRetrogradeToPrograde": 4702531892068465787, "NextJupiterWesternQuadrature": 4702531575658512208 } }, @@ -727,14 +727,14 @@ "LastJupiterConjunction": 4702531340061690908, "LastJupiterEasternQuadrature": 4702531094085269425, "LastJupiterOpposition": 4702530908086294254, - "LastJupiterProgradeToRetrograde": 4702531629751833528, - "LastJupiterRetrogradeToPrograde": 4702531039231848262, + "LastJupiterProgradeToRetrograde": 4702531629752021984, + "LastJupiterRetrogradeToPrograde": 4702531039232070289, "LastJupiterWesternQuadrature": 4702531575658512208, "NextJupiterConjunction": 4702532191266980219, "NextJupiterEasternQuadrature": 4702531946807778713, "NextJupiterOpposition": 4702531757829077880, - "NextJupiterProgradeToRetrograde": 4702532477989704986, - "NextJupiterRetrogradeToPrograde": 4702531892068317848, + "NextJupiterProgradeToRetrograde": 4702532477989929502, + "NextJupiterRetrogradeToPrograde": 4702531892068465788, "NextJupiterWesternQuadrature": 4702532422990676880 } }, @@ -745,14 +745,14 @@ "LastJupiterConjunction": 4702531340061690908, "LastJupiterEasternQuadrature": 4702531946807778713, "LastJupiterOpposition": 4702531757829077880, - "LastJupiterProgradeToRetrograde": 4702531629751833528, - "LastJupiterRetrogradeToPrograde": 4702531892068297696, + "LastJupiterProgradeToRetrograde": 4702531629752021984, + "LastJupiterRetrogradeToPrograde": 4702531892068465787, "LastJupiterWesternQuadrature": 4702531575658512208, "NextJupiterConjunction": 4702532191266980219, "NextJupiterEasternQuadrature": 4702532798623141947, "NextJupiterOpposition": 4702532607042469083, - "NextJupiterProgradeToRetrograde": 4702532477989704986, - "NextJupiterRetrogradeToPrograde": 4702532742987290107, + "NextJupiterProgradeToRetrograde": 4702532477989929502, + "NextJupiterRetrogradeToPrograde": 4702532742987376664, "NextJupiterWesternQuadrature": 4702532422990676880 } }, @@ -763,14 +763,14 @@ "LastJupiterConjunction": 4702532191266980219, "LastJupiterEasternQuadrature": 4702531946807778713, "LastJupiterOpposition": 4702532607042469083, - "LastJupiterProgradeToRetrograde": 4702532477989704986, - "LastJupiterRetrogradeToPrograde": 4702531892068297696, + "LastJupiterProgradeToRetrograde": 4702532477989929502, + "LastJupiterRetrogradeToPrograde": 4702531892068465787, "LastJupiterWesternQuadrature": 4702532422990676880, "NextJupiterConjunction": 4702533040521741795, "NextJupiterEasternQuadrature": 4702532798623141947, "NextJupiterOpposition": 4702533457714622787, - "NextJupiterProgradeToRetrograde": 4702533327528440524, - "NextJupiterRetrogradeToPrograde": 4702532742987290107, + "NextJupiterProgradeToRetrograde": 4702533327528714280, + "NextJupiterRetrogradeToPrograde": 4702532742987376664, "NextJupiterWesternQuadrature": 4702533270766621285 } }, @@ -781,14 +781,14 @@ "LastJupiterConjunction": 4702533040521741795, "LastJupiterEasternQuadrature": 4702532798623141947, "LastJupiterOpposition": 4702532607042469083, - "LastJupiterProgradeToRetrograde": 4702532477989704986, - "LastJupiterRetrogradeToPrograde": 4702532742987290107, + "LastJupiterProgradeToRetrograde": 4702532477989929502, + "LastJupiterRetrogradeToPrograde": 4702532742987376664, "LastJupiterWesternQuadrature": 4702532422990676880, "NextJupiterConjunction": 4702533889886899422, "NextJupiterEasternQuadrature": 4702533650881307010, "NextJupiterOpposition": 4702533457714622787, - "NextJupiterProgradeToRetrograde": 4702533327528440524, - "NextJupiterRetrogradeToPrograde": 4702533593289205137, + "NextJupiterProgradeToRetrograde": 4702533327528714280, + "NextJupiterRetrogradeToPrograde": 4702533593289177644, "NextJupiterWesternQuadrature": 4702533270766621285 } }, @@ -799,14 +799,14 @@ "LastJupiterConjunction": 4702533040521741795, "LastJupiterEasternQuadrature": 4702532798623141947, "LastJupiterOpposition": 4702533457714622787, - "LastJupiterProgradeToRetrograde": 4702533327528440524, - "LastJupiterRetrogradeToPrograde": 4702533593289205137, + "LastJupiterProgradeToRetrograde": 4702533327528714280, + "LastJupiterRetrogradeToPrograde": 4702533593289177644, "LastJupiterWesternQuadrature": 4702533270766621285, "NextJupiterConjunction": 4702533889886899422, "NextJupiterEasternQuadrature": 4702533650881307010, "NextJupiterOpposition": 4702534311590880348, - "NextJupiterProgradeToRetrograde": 4702534180736014002, - "NextJupiterRetrogradeToPrograde": 4702534444823937923, + "NextJupiterProgradeToRetrograde": 4702534180736244730, + "NextJupiterRetrogradeToPrograde": 4702534444823840703, "NextJupiterWesternQuadrature": 4702534121361727818 } }, @@ -817,14 +817,14 @@ "LastJupiterConjunction": 4702533889886899422, "LastJupiterEasternQuadrature": 4702533650881307010, "LastJupiterOpposition": 4702533457714622787, - "LastJupiterProgradeToRetrograde": 4702533327528440524, - "LastJupiterRetrogradeToPrograde": 4702533593289205137, + "LastJupiterProgradeToRetrograde": 4702533327528714280, + "LastJupiterRetrogradeToPrograde": 4702533593289177644, "LastJupiterWesternQuadrature": 4702533270766621285, "NextJupiterConjunction": 4702534741352723162, "NextJupiterEasternQuadrature": 4702534504862751820, "NextJupiterOpposition": 4702534311590880348, - "NextJupiterProgradeToRetrograde": 4702534180736014002, - "NextJupiterRetrogradeToPrograde": 4702534444823937923, + "NextJupiterProgradeToRetrograde": 4702534180736244730, + "NextJupiterRetrogradeToPrograde": 4702534444823840703, "NextJupiterWesternQuadrature": 4702534121361727818 } }, @@ -835,14 +835,14 @@ "LastJupiterConjunction": 4702533889886899422, "LastJupiterEasternQuadrature": 4702534504862751820, "LastJupiterOpposition": 4702534311590880348, - "LastJupiterProgradeToRetrograde": 4702534180736014002, - "LastJupiterRetrogradeToPrograde": 4702534444823937923, + "LastJupiterProgradeToRetrograde": 4702534180736244730, + "LastJupiterRetrogradeToPrograde": 4702534444823840703, "LastJupiterWesternQuadrature": 4702534121361727818, "NextJupiterConjunction": 4702534741352723162, "NextJupiterEasternQuadrature": 4702535361541413855, "NextJupiterOpposition": 4702535169914434805, - "NextJupiterProgradeToRetrograde": 4702535039856309892, - "NextJupiterRetrogradeToPrograde": 4702535300189377458, + "NextJupiterProgradeToRetrograde": 4702535039856440925, + "NextJupiterRetrogradeToPrograde": 4702535300189311120, "NextJupiterWesternQuadrature": 4702534976909616631 } }, @@ -853,14 +853,14 @@ "LastJupiterConjunction": 4702534741352723162, "LastJupiterEasternQuadrature": 4702534504862751820, "LastJupiterOpposition": 4702534311590880348, - "LastJupiterProgradeToRetrograde": 4702535039856309892, - "LastJupiterRetrogradeToPrograde": 4702534444823937923, + "LastJupiterProgradeToRetrograde": 4702535039856440925, + "LastJupiterRetrogradeToPrograde": 4702534444823840703, "LastJupiterWesternQuadrature": 4702534976909616631, "NextJupiterConjunction": 4702535596698601390, "NextJupiterEasternQuadrature": 4702535361541413855, "NextJupiterOpposition": 4702535169914434805, - "NextJupiterProgradeToRetrograde": 4702535904045583327, - "NextJupiterRetrogradeToPrograde": 4702535300189377458, + "NextJupiterProgradeToRetrograde": 4702535904045562333, + "NextJupiterRetrogradeToPrograde": 4702535300189311120, "NextJupiterWesternQuadrature": 4702535838415575985 } }, @@ -871,14 +871,14 @@ "LastJupiterConjunction": 4702511639716887239, "LastJupiterEasternQuadrature": 4702511393972914060, "LastJupiterOpposition": 4702512064463161597, - "LastJupiterProgradeToRetrograde": 4702511936733953225, - "LastJupiterRetrogradeToPrograde": 4702512194567554114, + "LastJupiterProgradeToRetrograde": 4702511936734033598, + "LastJupiterRetrogradeToPrograde": 4702512194567781169, "LastJupiterWesternQuadrature": 4702511882517284342, "NextJupiterConjunction": 4702512495925338915, "NextJupiterEasternQuadrature": 4702512249695096510, "NextJupiterOpposition": 4702512914882432379, - "NextJupiterProgradeToRetrograde": 4702512787040647460, - "NextJupiterRetrogradeToPrograde": 4702513048375573915, + "NextJupiterProgradeToRetrograde": 4702512787040814458, + "NextJupiterRetrogradeToPrograde": 4702513048375770329, "NextJupiterWesternQuadrature": 4702512732981118202 } }, @@ -889,14 +889,14 @@ "LastJupiterConjunction": 4702512495925338915, "LastJupiterEasternQuadrature": 4702512249695096510, "LastJupiterOpposition": 4702512064463161597, - "LastJupiterProgradeToRetrograde": 4702511936733953225, - "LastJupiterRetrogradeToPrograde": 4702512194567541994, + "LastJupiterProgradeToRetrograde": 4702511936734033598, + "LastJupiterRetrogradeToPrograde": 4702512194567781165, "LastJupiterWesternQuadrature": 4702511882517284342, "NextJupiterConjunction": 4702513348128865031, "NextJupiterEasternQuadrature": 4702513103066964522, "NextJupiterOpposition": 4702512914882432379, - "NextJupiterProgradeToRetrograde": 4702512787040647460, - "NextJupiterRetrogradeToPrograde": 4702513048375573915, + "NextJupiterProgradeToRetrograde": 4702512787040814458, + "NextJupiterRetrogradeToPrograde": 4702513048375770329, "NextJupiterWesternQuadrature": 4702512732981118202 } }, @@ -907,14 +907,14 @@ "LastJupiterConjunction": 4702512495925338915, "LastJupiterEasternQuadrature": 4702512249695096510, "LastJupiterOpposition": 4702512064463161597, - "LastJupiterProgradeToRetrograde": 4702512787040647460, - "LastJupiterRetrogradeToPrograde": 4702512194567541994, + "LastJupiterProgradeToRetrograde": 4702512787040814458, + "LastJupiterRetrogradeToPrograde": 4702512194567781165, "LastJupiterWesternQuadrature": 4702512732981118202, "NextJupiterConjunction": 4702513348128865031, "NextJupiterEasternQuadrature": 4702513103066964522, "NextJupiterOpposition": 4702512914882432379, - "NextJupiterProgradeToRetrograde": 4702513635402180624, - "NextJupiterRetrogradeToPrograde": 4702513048375573915, + "NextJupiterProgradeToRetrograde": 4702513635402128649, + "NextJupiterRetrogradeToPrograde": 4702513048375770329, "NextJupiterWesternQuadrature": 4702513580732668986 } }, @@ -925,14 +925,14 @@ "LastJupiterConjunction": 4702512495925338915, "LastJupiterEasternQuadrature": 4702513103066964522, "LastJupiterOpposition": 4702512914882432379, - "LastJupiterProgradeToRetrograde": 4702512787040647460, - "LastJupiterRetrogradeToPrograde": 4702513048375573915, + "LastJupiterProgradeToRetrograde": 4702512787040814458, + "LastJupiterRetrogradeToPrograde": 4702513048375770329, "LastJupiterWesternQuadrature": 4702512732981118202, "NextJupiterConjunction": 4702513348128865031, "NextJupiterEasternQuadrature": 4702513955120441812, "NextJupiterOpposition": 4702513764159190727, - "NextJupiterProgradeToRetrograde": 4702513635402180624, - "NextJupiterRetrogradeToPrograde": 4702513899777207477, + "NextJupiterProgradeToRetrograde": 4702513635402128649, + "NextJupiterRetrogradeToPrograde": 4702513899777322499, "NextJupiterWesternQuadrature": 4702513580732668986 } }, @@ -943,14 +943,14 @@ "LastJupiterConjunction": 4702513348128865031, "LastJupiterEasternQuadrature": 4702513103066964522, "LastJupiterOpposition": 4702512914882432379, - "LastJupiterProgradeToRetrograde": 4702512787040647460, - "LastJupiterRetrogradeToPrograde": 4702513048375573915, + "LastJupiterProgradeToRetrograde": 4702512787040814458, + "LastJupiterRetrogradeToPrograde": 4702513048375770329, "LastJupiterWesternQuadrature": 4702512732981118202, "NextJupiterConjunction": 4702514197794152581, "NextJupiterEasternQuadrature": 4702513955120441812, "NextJupiterOpposition": 4702513764159190727, - "NextJupiterProgradeToRetrograde": 4702513635402180624, - "NextJupiterRetrogradeToPrograde": 4702513899778406116, + "NextJupiterProgradeToRetrograde": 4702513635402128649, + "NextJupiterRetrogradeToPrograde": 4702513899777322499, "NextJupiterWesternQuadrature": 4702513580732668986 } }, @@ -961,14 +961,14 @@ "LastJupiterConjunction": 4702513348128865031, "LastJupiterEasternQuadrature": 4702513103066964522, "LastJupiterOpposition": 4702513764159190727, - "LastJupiterProgradeToRetrograde": 4702513635402180624, - "LastJupiterRetrogradeToPrograde": 4702513048375573915, + "LastJupiterProgradeToRetrograde": 4702513635402128649, + "LastJupiterRetrogradeToPrograde": 4702513048375770329, "LastJupiterWesternQuadrature": 4702513580732668986, "NextJupiterConjunction": 4702514197794152581, "NextJupiterEasternQuadrature": 4702513955120441812, "NextJupiterOpposition": 4702514614284651561, - "NextJupiterProgradeToRetrograde": 4702514484375506721, - "NextJupiterRetrogradeToPrograde": 4702513899777207477, + "NextJupiterProgradeToRetrograde": 4702514484375758119, + "NextJupiterRetrogradeToPrograde": 4702513899777322499, "NextJupiterWesternQuadrature": 4702514428184983045 } }, @@ -979,14 +979,14 @@ "LastJupiterConjunction": 4702513348128865031, "LastJupiterEasternQuadrature": 4702513955120441812, "LastJupiterOpposition": 4702513764159190727, - "LastJupiterProgradeToRetrograde": 4702513635402180624, - "LastJupiterRetrogradeToPrograde": 4702513899777207477, + "LastJupiterProgradeToRetrograde": 4702513635402128649, + "LastJupiterRetrogradeToPrograde": 4702513899777322499, "LastJupiterWesternQuadrature": 4702513580732668986, "NextJupiterConjunction": 4702514197794152581, "NextJupiterEasternQuadrature": 4702514807158075028, "NextJupiterOpposition": 4702514614284651561, - "NextJupiterProgradeToRetrograde": 4702514484375506721, - "NextJupiterRetrogradeToPrograde": 4702514750232496688, + "NextJupiterProgradeToRetrograde": 4702514484375758119, + "NextJupiterRetrogradeToPrograde": 4702514750232530719, "NextJupiterWesternQuadrature": 4702514428184983045 } }, @@ -997,14 +997,14 @@ "LastJupiterConjunction": 4702514197794152581, "LastJupiterEasternQuadrature": 4702513955120441812, "LastJupiterOpposition": 4702513764159190727, - "LastJupiterProgradeToRetrograde": 4702513635402180624, - "LastJupiterRetrogradeToPrograde": 4702513899778406116, + "LastJupiterProgradeToRetrograde": 4702513635402128649, + "LastJupiterRetrogradeToPrograde": 4702513899777322499, "LastJupiterWesternQuadrature": 4702514428184983045, "NextJupiterConjunction": 4702515046903534947, "NextJupiterEasternQuadrature": 4702514807158075028, "NextJupiterOpposition": 4702514614284651561, - "NextJupiterProgradeToRetrograde": 4702514484375506721, - "NextJupiterRetrogradeToPrograde": 4702514750232496688, + "NextJupiterProgradeToRetrograde": 4702514484375758119, + "NextJupiterRetrogradeToPrograde": 4702514750232530719, "NextJupiterWesternQuadrature": 4702515277713947870 } }, @@ -1015,14 +1015,14 @@ "LastJupiterConjunction": 4702514197794152581, "LastJupiterEasternQuadrature": 4702513955120441812, "LastJupiterOpposition": 4702514614284651561, - "LastJupiterProgradeToRetrograde": 4702514484375506721, - "LastJupiterRetrogradeToPrograde": 4702514750232496688, + "LastJupiterProgradeToRetrograde": 4702514484375758119, + "LastJupiterRetrogradeToPrograde": 4702514750232530719, "LastJupiterWesternQuadrature": 4702514428184983045, "NextJupiterConjunction": 4702515046903534947, "NextJupiterEasternQuadrature": 4702514807158075028, "NextJupiterOpposition": 4702515467054740368, - "NextJupiterProgradeToRetrograde": 4702515336182395911, - "NextJupiterRetrogradeToPrograde": 4702515601124407138, + "NextJupiterProgradeToRetrograde": 4702515336181506833, + "NextJupiterRetrogradeToPrograde": 4702515601124362778, "NextJupiterWesternQuadrature": 4702515277713947870 } }, @@ -1033,14 +1033,14 @@ "LastJupiterConjunction": 4702515046903534947, "LastJupiterEasternQuadrature": 4702514807158075028, "LastJupiterOpposition": 4702514614284651561, - "LastJupiterProgradeToRetrograde": 4702514484375506721, - "LastJupiterRetrogradeToPrograde": 4702514750232496688, + "LastJupiterProgradeToRetrograde": 4702514484375758119, + "LastJupiterRetrogradeToPrograde": 4702514750232530719, "LastJupiterWesternQuadrature": 4702514428184983045, "NextJupiterConjunction": 4702515897478329250, "NextJupiterEasternQuadrature": 4702515660462282474, "NextJupiterOpposition": 4702515467054740368, - "NextJupiterProgradeToRetrograde": 4702515336182395911, - "NextJupiterRetrogradeToPrograde": 4702515601124407138, + "NextJupiterProgradeToRetrograde": 4702515336181506833, + "NextJupiterRetrogradeToPrograde": 4702515601124362778, "NextJupiterWesternQuadrature": 4702515277713947870 } }, @@ -1051,14 +1051,14 @@ "LastJupiterConjunction": 4702515046903534947, "LastJupiterEasternQuadrature": 4702514807158075028, "LastJupiterOpposition": 4702514614284651561, - "LastJupiterProgradeToRetrograde": 4702515336182395911, - "LastJupiterRetrogradeToPrograde": 4702514750232496688, + "LastJupiterProgradeToRetrograde": 4702515336181506833, + "LastJupiterRetrogradeToPrograde": 4702514750232530719, "LastJupiterWesternQuadrature": 4702515277713947870, "NextJupiterConjunction": 4702515897478329250, "NextJupiterEasternQuadrature": 4702515660462282474, "NextJupiterOpposition": 4702515467054740368, - "NextJupiterProgradeToRetrograde": 4702516193453059376, - "NextJupiterRetrogradeToPrograde": 4702515601124407138, + "NextJupiterProgradeToRetrograde": 4702516193453225105, + "NextJupiterRetrogradeToPrograde": 4702515601124362778, "NextJupiterWesternQuadrature": 4702516131575695012 } }, @@ -1069,14 +1069,14 @@ "LastJupiterConjunction": 4702515046903534947, "LastJupiterEasternQuadrature": 4702515660462282474, "LastJupiterOpposition": 4702515467054740368, - "LastJupiterProgradeToRetrograde": 4702515336182395911, - "LastJupiterRetrogradeToPrograde": 4702515601124407138, + "LastJupiterProgradeToRetrograde": 4702515336181506833, + "LastJupiterRetrogradeToPrograde": 4702515601124362778, "LastJupiterWesternQuadrature": 4702515277713947870, "NextJupiterConjunction": 4702515897478329250, "NextJupiterEasternQuadrature": 4702516516160014083, "NextJupiterOpposition": 4702516323930527911, - "NextJupiterProgradeToRetrograde": 4702516193453059376, - "NextJupiterRetrogradeToPrograde": 4702516455127316802, + "NextJupiterProgradeToRetrograde": 4702516193453225105, + "NextJupiterRetrogradeToPrograde": 4702516455127235935, "NextJupiterWesternQuadrature": 4702516131575695012 } }, @@ -1087,14 +1087,14 @@ "LastJupiterConjunction": 4702515897478329250, "LastJupiterEasternQuadrature": 4702515660462282474, "LastJupiterOpposition": 4702515467054740368, - "LastJupiterProgradeToRetrograde": 4702515336182395911, - "LastJupiterRetrogradeToPrograde": 4702515601124407138, + "LastJupiterProgradeToRetrograde": 4702515336181506833, + "LastJupiterRetrogradeToPrograde": 4702515601124362778, "LastJupiterWesternQuadrature": 4702515277713947870, "NextJupiterConjunction": 4702516751407804597, "NextJupiterEasternQuadrature": 4702516516160014083, "NextJupiterOpposition": 4702516323930527911, - "NextJupiterProgradeToRetrograde": 4702516193453059376, - "NextJupiterRetrogradeToPrograde": 4702516455127316802, + "NextJupiterProgradeToRetrograde": 4702516193453225105, + "NextJupiterRetrogradeToPrograde": 4702516455127235935, "NextJupiterWesternQuadrature": 4702516131575695012 } }, @@ -1105,14 +1105,14 @@ "LastJupiterConjunction": 4702515897478329250, "LastJupiterEasternQuadrature": 4702515660462282474, "LastJupiterOpposition": 4702516323930527911, - "LastJupiterProgradeToRetrograde": 4702516193453059376, - "LastJupiterRetrogradeToPrograde": 4702515601124407138, + "LastJupiterProgradeToRetrograde": 4702516193453225105, + "LastJupiterRetrogradeToPrograde": 4702515601124362778, "LastJupiterWesternQuadrature": 4702516131575695012, "NextJupiterConjunction": 4702516751407804597, "NextJupiterEasternQuadrature": 4702516516160014083, "NextJupiterOpposition": 4702517185247400030, - "NextJupiterProgradeToRetrograde": 4702517056174132032, - "NextJupiterRetrogradeToPrograde": 4702516455127316802, + "NextJupiterProgradeToRetrograde": 4702517056174170982, + "NextJupiterRetrogradeToPrograde": 4702516455127235935, "NextJupiterWesternQuadrature": 4702516991222819727 } }, @@ -1123,14 +1123,14 @@ "LastJupiterConjunction": 4702515897478329250, "LastJupiterEasternQuadrature": 4702516516160014083, "LastJupiterOpposition": 4702516323930527911, - "LastJupiterProgradeToRetrograde": 4702516193453059376, - "LastJupiterRetrogradeToPrograde": 4702516455127316802, + "LastJupiterProgradeToRetrograde": 4702516193453225105, + "LastJupiterRetrogradeToPrograde": 4702516455127235935, "LastJupiterWesternQuadrature": 4702516131575695012, "NextJupiterConjunction": 4702516751407804597, "NextJupiterEasternQuadrature": 4702517374743600545, "NextJupiterOpposition": 4702517185247400030, - "NextJupiterProgradeToRetrograde": 4702517056174132032, - "NextJupiterRetrogradeToPrograde": 4702517313542290884, + "NextJupiterProgradeToRetrograde": 4702517056174170982, + "NextJupiterRetrogradeToPrograde": 4702517313542247826, "NextJupiterWesternQuadrature": 4702516991222819727 } }, @@ -1141,14 +1141,14 @@ "LastJupiterConjunction": 4702516751407804597, "LastJupiterEasternQuadrature": 4702516516160014083, "LastJupiterOpposition": 4702516323930527911, - "LastJupiterProgradeToRetrograde": 4702516193453059376, - "LastJupiterRetrogradeToPrograde": 4702516455127316802, + "LastJupiterProgradeToRetrograde": 4702516193453225105, + "LastJupiterRetrogradeToPrograde": 4702516455127235935, "LastJupiterWesternQuadrature": 4702516991222819727, "NextJupiterConjunction": 4702517610018433278, "NextJupiterEasternQuadrature": 4702517374743600545, "NextJupiterOpposition": 4702517185247400030, - "NextJupiterProgradeToRetrograde": 4702517056174132032, - "NextJupiterRetrogradeToPrograde": 4702517313542290884, + "NextJupiterProgradeToRetrograde": 4702517056174170982, + "NextJupiterRetrogradeToPrograde": 4702517313542247826, "NextJupiterWesternQuadrature": 4702517856327958796 } }, @@ -1159,14 +1159,14 @@ "LastJupiterConjunction": 4702516751407804597, "LastJupiterEasternQuadrature": 4702516516160014083, "LastJupiterOpposition": 4702517185247400030, - "LastJupiterProgradeToRetrograde": 4702517056174132032, - "LastJupiterRetrogradeToPrograde": 4702517313542290884, + "LastJupiterProgradeToRetrograde": 4702517056174170982, + "LastJupiterRetrogradeToPrograde": 4702517313542247826, "LastJupiterWesternQuadrature": 4702516991222819727, "NextJupiterConjunction": 4702517610018433278, "NextJupiterEasternQuadrature": 4702517374743600545, "NextJupiterOpposition": 4702518049732300147, - "NextJupiterProgradeToRetrograde": 4702517922209342847, - "NextJupiterRetrogradeToPrograde": 4702518176266561966, + "NextJupiterProgradeToRetrograde": 4702517922209247859, + "NextJupiterRetrogradeToPrograde": 4702518176266624940, "NextJupiterWesternQuadrature": 4702517856327958796 } }, @@ -1177,14 +1177,14 @@ "LastJupiterConjunction": 4702517610018433278, "LastJupiterEasternQuadrature": 4702517374743600545, "LastJupiterOpposition": 4702517185247400030, - "LastJupiterProgradeToRetrograde": 4702517056174132032, - "LastJupiterRetrogradeToPrograde": 4702517313542290884, + "LastJupiterProgradeToRetrograde": 4702517056174170982, + "LastJupiterRetrogradeToPrograde": 4702517313542247826, "LastJupiterWesternQuadrature": 4702516991222819727, "NextJupiterConjunction": 4702518473067554081, "NextJupiterEasternQuadrature": 4702518235789079116, "NextJupiterOpposition": 4702518049732300147, - "NextJupiterProgradeToRetrograde": 4702517922209342847, - "NextJupiterRetrogradeToPrograde": 4702518176266561966, + "NextJupiterProgradeToRetrograde": 4702517922209247859, + "NextJupiterRetrogradeToPrograde": 4702518176266624940, "NextJupiterWesternQuadrature": 4702517856327958796 } }, @@ -1195,14 +1195,14 @@ "LastJupiterConjunction": 4702517610018433278, "LastJupiterEasternQuadrature": 4702517374743600545, "LastJupiterOpposition": 4702517185247400030, - "LastJupiterProgradeToRetrograde": 4702517922209342847, - "LastJupiterRetrogradeToPrograde": 4702517313542290884, + "LastJupiterProgradeToRetrograde": 4702517922209247859, + "LastJupiterRetrogradeToPrograde": 4702517313542247826, "LastJupiterWesternQuadrature": 4702517856327958796, "NextJupiterConjunction": 4702518473067554081, "NextJupiterEasternQuadrature": 4702518235789079116, "NextJupiterOpposition": 4702518049732300147, - "NextJupiterProgradeToRetrograde": 4702518787154982595, - "NextJupiterRetrogradeToPrograde": 4702518176266561966, + "NextJupiterProgradeToRetrograde": 4702518787154844104, + "NextJupiterRetrogradeToPrograde": 4702518176266624940, "NextJupiterWesternQuadrature": 4702518723959582186 } }, @@ -1213,14 +1213,14 @@ "LastJupiterConjunction": 4702517610018433278, "LastJupiterEasternQuadrature": 4702518235789079116, "LastJupiterOpposition": 4702518049732300147, - "LastJupiterProgradeToRetrograde": 4702517922209342847, - "LastJupiterRetrogradeToPrograde": 4702518176266561966, + "LastJupiterProgradeToRetrograde": 4702517922209247859, + "LastJupiterRetrogradeToPrograde": 4702518176266624940, "LastJupiterWesternQuadrature": 4702517856327958796, "NextJupiterConjunction": 4702518473067554081, "NextJupiterEasternQuadrature": 4702519097687957764, "NextJupiterOpposition": 4702518914455605723, - "NextJupiterProgradeToRetrograde": 4702518787154982595, - "NextJupiterRetrogradeToPrograde": 4702519039884338738, + "NextJupiterProgradeToRetrograde": 4702518787154844104, + "NextJupiterRetrogradeToPrograde": 4702519039884517888, "NextJupiterWesternQuadrature": 4702518723959582186 } }, @@ -1231,14 +1231,14 @@ "LastJupiterConjunction": 4702518473067554081, "LastJupiterEasternQuadrature": 4702518235789079116, "LastJupiterOpposition": 4702518049732300147, - "LastJupiterProgradeToRetrograde": 4702517922209342847, - "LastJupiterRetrogradeToPrograde": 4702518176266560183, + "LastJupiterProgradeToRetrograde": 4702517922209247859, + "LastJupiterRetrogradeToPrograde": 4702518176266624941, "LastJupiterWesternQuadrature": 4702517856327958796, "NextJupiterConjunction": 4702519338321882994, "NextJupiterEasternQuadrature": 4702519097687957764, "NextJupiterOpposition": 4702518914455605723, - "NextJupiterProgradeToRetrograde": 4702518787154982595, - "NextJupiterRetrogradeToPrograde": 4702519039884338738, + "NextJupiterProgradeToRetrograde": 4702518787154844104, + "NextJupiterRetrogradeToPrograde": 4702519039884517888, "NextJupiterWesternQuadrature": 4702518723959582186 } }, @@ -1249,14 +1249,14 @@ "LastJupiterConjunction": 4702518473067554081, "LastJupiterEasternQuadrature": 4702518235789079116, "LastJupiterOpposition": 4702518914455605723, - "LastJupiterProgradeToRetrograde": 4702518787154982595, - "LastJupiterRetrogradeToPrograde": 4702518176266561966, + "LastJupiterProgradeToRetrograde": 4702518787154844104, + "LastJupiterRetrogradeToPrograde": 4702518176266624940, "LastJupiterWesternQuadrature": 4702518723959582186, "NextJupiterConjunction": 4702519338321882994, "NextJupiterEasternQuadrature": 4702519097687957764, "NextJupiterOpposition": 4702519776235500353, - "NextJupiterProgradeToRetrograde": 4702519648440464419, - "NextJupiterRetrogradeToPrograde": 4702519039884338738, + "NextJupiterProgradeToRetrograde": 4702519648440386924, + "NextJupiterRetrogradeToPrograde": 4702519039884517888, "NextJupiterWesternQuadrature": 4702519589633910262 } }, @@ -1267,14 +1267,14 @@ "LastJupiterConjunction": 4702518473067554081, "LastJupiterEasternQuadrature": 4702519097687957764, "LastJupiterOpposition": 4702518914455605723, - "LastJupiterProgradeToRetrograde": 4702518787154982595, - "LastJupiterRetrogradeToPrograde": 4702519039884338738, + "LastJupiterProgradeToRetrograde": 4702518787154844104, + "LastJupiterRetrogradeToPrograde": 4702519039884517888, "LastJupiterWesternQuadrature": 4702518723959582186, "NextJupiterConjunction": 4702519338321882994, "NextJupiterEasternQuadrature": 4702519958453179144, "NextJupiterOpposition": 4702519776235500353, - "NextJupiterProgradeToRetrograde": 4702519648440464419, - "NextJupiterRetrogradeToPrograde": 4702519901906108077, + "NextJupiterProgradeToRetrograde": 4702519648440386924, + "NextJupiterRetrogradeToPrograde": 4702519901906363326, "NextJupiterWesternQuadrature": 4702519589633910262 } }, @@ -1285,14 +1285,14 @@ "LastJupiterConjunction": 4702519338321882994, "LastJupiterEasternQuadrature": 4702519097687957764, "LastJupiterOpposition": 4702518914455605723, - "LastJupiterProgradeToRetrograde": 4702518787154982595, - "LastJupiterRetrogradeToPrograde": 4702519039884338738, + "LastJupiterProgradeToRetrograde": 4702518787154844104, + "LastJupiterRetrogradeToPrograde": 4702519039884517888, "LastJupiterWesternQuadrature": 4702519589633910262, "NextJupiterConjunction": 4702520202370088051, "NextJupiterEasternQuadrature": 4702519958453179144, "NextJupiterOpposition": 4702519776235500353, - "NextJupiterProgradeToRetrograde": 4702519648440464419, - "NextJupiterRetrogradeToPrograde": 4702519901906108077, + "NextJupiterProgradeToRetrograde": 4702519648440386924, + "NextJupiterRetrogradeToPrograde": 4702519901906363326, "NextJupiterWesternQuadrature": 4702520450002390791 } }, @@ -1303,14 +1303,14 @@ "LastJupiterConjunction": 4702519338321882994, "LastJupiterEasternQuadrature": 4702519097687957764, "LastJupiterOpposition": 4702519776235500353, - "LastJupiterProgradeToRetrograde": 4702519648440464419, - "LastJupiterRetrogradeToPrograde": 4702519901906108077, + "LastJupiterProgradeToRetrograde": 4702519648440386924, + "LastJupiterRetrogradeToPrograde": 4702519901906363326, "LastJupiterWesternQuadrature": 4702519589633910262, "NextJupiterConjunction": 4702520202370088051, "NextJupiterEasternQuadrature": 4702519958453179144, "NextJupiterOpposition": 4702520633410535929, - "NextJupiterProgradeToRetrograde": 4702520505561245416, - "NextJupiterRetrogradeToPrograde": 4702520760902697291, + "NextJupiterProgradeToRetrograde": 4702520505561248825, + "NextJupiterRetrogradeToPrograde": 4702520760902959495, "NextJupiterWesternQuadrature": 4702520450002390791 } }, @@ -1321,14 +1321,14 @@ "LastJupiterConjunction": 4702520202370088051, "LastJupiterEasternQuadrature": 4702519958453179144, "LastJupiterOpposition": 4702519776235500353, - "LastJupiterProgradeToRetrograde": 4702519648440464419, - "LastJupiterRetrogradeToPrograde": 4702519901906108077, + "LastJupiterProgradeToRetrograde": 4702519648440386924, + "LastJupiterRetrogradeToPrograde": 4702519901906363326, "LastJupiterWesternQuadrature": 4702519589633910262, "NextJupiterConjunction": 4702521062497795583, "NextJupiterEasternQuadrature": 4702520816619050113, "NextJupiterOpposition": 4702520633410535929, - "NextJupiterProgradeToRetrograde": 4702520505561245416, - "NextJupiterRetrogradeToPrograde": 4702520760902697291, + "NextJupiterProgradeToRetrograde": 4702520505561248825, + "NextJupiterRetrogradeToPrograde": 4702520760902959495, "NextJupiterWesternQuadrature": 4702520450002390791 } }, @@ -1339,14 +1339,14 @@ "LastJupiterConjunction": 4702520202370088051, "LastJupiterEasternQuadrature": 4702519958453179144, "LastJupiterOpposition": 4702519776235500353, - "LastJupiterProgradeToRetrograde": 4702520505561245416, - "LastJupiterRetrogradeToPrograde": 4702519901906108077, + "LastJupiterProgradeToRetrograde": 4702520505561248825, + "LastJupiterRetrogradeToPrograde": 4702519901906363326, "LastJupiterWesternQuadrature": 4702520450002390791, "NextJupiterConjunction": 4702521062497795583, "NextJupiterEasternQuadrature": 4702520816619050113, "NextJupiterOpposition": 4702520633410535929, - "NextJupiterProgradeToRetrograde": 4702521358490210902, - "NextJupiterRetrogradeToPrograde": 4702520760902697291, + "NextJupiterProgradeToRetrograde": 4702521358490315178, + "NextJupiterRetrogradeToPrograde": 4702520760902959495, "NextJupiterWesternQuadrature": 4702521304428672627 } }, @@ -1357,14 +1357,14 @@ "LastJupiterConjunction": 4702520202370088051, "LastJupiterEasternQuadrature": 4702520816619050113, "LastJupiterOpposition": 4702520633410535929, - "LastJupiterProgradeToRetrograde": 4702520505561245416, - "LastJupiterRetrogradeToPrograde": 4702520760902697291, + "LastJupiterProgradeToRetrograde": 4702520505561248825, + "LastJupiterRetrogradeToPrograde": 4702520760902959495, "LastJupiterWesternQuadrature": 4702520450002390791, "NextJupiterConjunction": 4702521062497795583, "NextJupiterEasternQuadrature": 4702521671866577044, "NextJupiterOpposition": 4702521486257822487, - "NextJupiterProgradeToRetrograde": 4702521358490210902, - "NextJupiterRetrogradeToPrograde": 4702521616887902469, + "NextJupiterProgradeToRetrograde": 4702521358490315178, + "NextJupiterRetrogradeToPrograde": 4702521616888142592, "NextJupiterWesternQuadrature": 4702521304428672627 } }, @@ -1375,14 +1375,14 @@ "LastJupiterConjunction": 4702521062497795583, "LastJupiterEasternQuadrature": 4702520816619050113, "LastJupiterOpposition": 4702520633410535929, - "LastJupiterProgradeToRetrograde": 4702520505561245416, - "LastJupiterRetrogradeToPrograde": 4702520760902697291, + "LastJupiterProgradeToRetrograde": 4702520505561248825, + "LastJupiterRetrogradeToPrograde": 4702520760902959495, "LastJupiterWesternQuadrature": 4702520450002390791, "NextJupiterConjunction": 4702521917985872290, "NextJupiterEasternQuadrature": 4702521671866577044, "NextJupiterOpposition": 4702521486257822487, - "NextJupiterProgradeToRetrograde": 4702521358490210902, - "NextJupiterRetrogradeToPrograde": 4702521616887902469, + "NextJupiterProgradeToRetrograde": 4702521358490315178, + "NextJupiterRetrogradeToPrograde": 4702521616888142592, "NextJupiterWesternQuadrature": 4702521304428672627 } }, @@ -1393,14 +1393,14 @@ "LastJupiterConjunction": 4702521062497795583, "LastJupiterEasternQuadrature": 4702520816619050113, "LastJupiterOpposition": 4702521486257822487, - "LastJupiterProgradeToRetrograde": 4702521358490210902, - "LastJupiterRetrogradeToPrograde": 4702520760902697291, + "LastJupiterProgradeToRetrograde": 4702521358490315178, + "LastJupiterRetrogradeToPrograde": 4702520760902959495, "LastJupiterWesternQuadrature": 4702521304428672627, "NextJupiterConjunction": 4702521917985872290, "NextJupiterEasternQuadrature": 4702521671866577044, "NextJupiterOpposition": 4702522336293948473, - "NextJupiterProgradeToRetrograde": 4702522208314943321, - "NextJupiterRetrogradeToPrograde": 4702521616887902469, + "NextJupiterProgradeToRetrograde": 4702522208315116066, + "NextJupiterRetrogradeToPrograde": 4702521616888142592, "NextJupiterWesternQuadrature": 4702522154284334640 } }, @@ -1411,14 +1411,14 @@ "LastJupiterConjunction": 4702521062497795583, "LastJupiterEasternQuadrature": 4702521671866577044, "LastJupiterOpposition": 4702521486257822487, - "LastJupiterProgradeToRetrograde": 4702521358490210902, - "LastJupiterRetrogradeToPrograde": 4702521616887902469, + "LastJupiterProgradeToRetrograde": 4702521358490315178, + "LastJupiterRetrogradeToPrograde": 4702521616888142592, "LastJupiterWesternQuadrature": 4702521304428672627, "NextJupiterConjunction": 4702521917985872290, "NextJupiterEasternQuadrature": 4702522524867257437, "NextJupiterOpposition": 4702522336293948473, - "NextJupiterProgradeToRetrograde": 4702522208314943321, - "NextJupiterRetrogradeToPrograde": 4702522470158612076, + "NextJupiterProgradeToRetrograde": 4702522208315116066, + "NextJupiterRetrogradeToPrograde": 4702522470158789167, "NextJupiterWesternQuadrature": 4702522154284334640 } }, @@ -1429,14 +1429,14 @@ "LastJupiterConjunction": 4702521917985872290, "LastJupiterEasternQuadrature": 4702521671866577044, "LastJupiterOpposition": 4702521486257822487, - "LastJupiterProgradeToRetrograde": 4702521358490210902, - "LastJupiterRetrogradeToPrograde": 4702521616887902469, + "LastJupiterProgradeToRetrograde": 4702521358490315178, + "LastJupiterRetrogradeToPrograde": 4702521616888142592, "LastJupiterWesternQuadrature": 4702521304428672627, "NextJupiterConjunction": 4702522769611668861, "NextJupiterEasternQuadrature": 4702522524867257437, "NextJupiterOpposition": 4702522336293948473, - "NextJupiterProgradeToRetrograde": 4702522208314943321, - "NextJupiterRetrogradeToPrograde": 4702522470158612076, + "NextJupiterProgradeToRetrograde": 4702522208315116066, + "NextJupiterRetrogradeToPrograde": 4702522470158789167, "NextJupiterWesternQuadrature": 4702522154284334640 } }, @@ -1447,14 +1447,14 @@ "LastJupiterConjunction": 4702521917985872290, "LastJupiterEasternQuadrature": 4702521671866577044, "LastJupiterOpposition": 4702522336293948473, - "LastJupiterProgradeToRetrograde": 4702522208314943321, - "LastJupiterRetrogradeToPrograde": 4702522470158612076, + "LastJupiterProgradeToRetrograde": 4702522208315116066, + "LastJupiterRetrogradeToPrograde": 4702522470158789167, "LastJupiterWesternQuadrature": 4702522154284334640, "NextJupiterConjunction": 4702522769611668861, "NextJupiterEasternQuadrature": 4702522524867257437, "NextJupiterOpposition": 4702523185481643490, - "NextJupiterProgradeToRetrograde": 4702523056647498349, - "NextJupiterRetrogradeToPrograde": 4702523321352707163, + "NextJupiterProgradeToRetrograde": 4702523056647729621, + "NextJupiterRetrogradeToPrograde": 4702523321352808888, "NextJupiterWesternQuadrature": 4702523001754878487 } }, @@ -1465,14 +1465,14 @@ "LastJupiterConjunction": 4702522769611668861, "LastJupiterEasternQuadrature": 4702522524867257437, "LastJupiterOpposition": 4702522336293948473, - "LastJupiterProgradeToRetrograde": 4702522208314943321, - "LastJupiterRetrogradeToPrograde": 4702522470158612076, + "LastJupiterProgradeToRetrograde": 4702522208315116066, + "LastJupiterRetrogradeToPrograde": 4702522470158789167, "LastJupiterWesternQuadrature": 4702522154284334640, "NextJupiterConjunction": 4702523619040345746, "NextJupiterEasternQuadrature": 4702523376757230717, "NextJupiterOpposition": 4702523185481643490, - "NextJupiterProgradeToRetrograde": 4702523056647498349, - "NextJupiterRetrogradeToPrograde": 4702523321352707163, + "NextJupiterProgradeToRetrograde": 4702523056647729621, + "NextJupiterRetrogradeToPrograde": 4702523321352808888, "NextJupiterWesternQuadrature": 4702523001754878487 } }, @@ -1483,14 +1483,14 @@ "LastJupiterConjunction": 4702522769611668861, "LastJupiterEasternQuadrature": 4702522524867257437, "LastJupiterOpposition": 4702522336293948473, - "LastJupiterProgradeToRetrograde": 4702523056647498349, - "LastJupiterRetrogradeToPrograde": 4702522470158612076, + "LastJupiterProgradeToRetrograde": 4702523056647729621, + "LastJupiterRetrogradeToPrograde": 4702522470158789167, "LastJupiterWesternQuadrature": 4702523001754878487, "NextJupiterConjunction": 4702523619040345746, "NextJupiterEasternQuadrature": 4702523376757230717, "NextJupiterOpposition": 4702523185481643490, - "NextJupiterProgradeToRetrograde": 4702523905664036962, - "NextJupiterRetrogradeToPrograde": 4702523321352707163, + "NextJupiterProgradeToRetrograde": 4702523905663922426, + "NextJupiterRetrogradeToPrograde": 4702523321352808888, "NextJupiterWesternQuadrature": 4702523849302148560 } }, @@ -1501,14 +1501,14 @@ "LastJupiterConjunction": 4702522769611668861, "LastJupiterEasternQuadrature": 4702523376757230717, "LastJupiterOpposition": 4702523185481643490, - "LastJupiterProgradeToRetrograde": 4702523056647498349, - "LastJupiterRetrogradeToPrograde": 4702523321352707163, + "LastJupiterProgradeToRetrograde": 4702523056647729621, + "LastJupiterRetrogradeToPrograde": 4702523321352808888, "LastJupiterWesternQuadrature": 4702523001754878487, "NextJupiterConjunction": 4702523619040345746, "NextJupiterEasternQuadrature": 4702524228832748287, "NextJupiterOpposition": 4702524035805022913, - "NextJupiterProgradeToRetrograde": 4702523905664036962, - "NextJupiterRetrogradeToPrograde": 4702524171499874650, + "NextJupiterProgradeToRetrograde": 4702523905663922426, + "NextJupiterRetrogradeToPrograde": 4702524171499883741, "NextJupiterWesternQuadrature": 4702523849302148560 } }, @@ -1519,14 +1519,14 @@ "LastJupiterConjunction": 4702523619040345746, "LastJupiterEasternQuadrature": 4702523376757230717, "LastJupiterOpposition": 4702523185481643490, - "LastJupiterProgradeToRetrograde": 4702523056647498349, - "LastJupiterRetrogradeToPrograde": 4702523321352707163, + "LastJupiterProgradeToRetrograde": 4702523056647729621, + "LastJupiterRetrogradeToPrograde": 4702523321352808888, "LastJupiterWesternQuadrature": 4702523001754878487, "NextJupiterConjunction": 4702524468223248937, "NextJupiterEasternQuadrature": 4702524228832748287, "NextJupiterOpposition": 4702524035805022913, - "NextJupiterProgradeToRetrograde": 4702523905664036962, - "NextJupiterRetrogradeToPrograde": 4702524171499874650, + "NextJupiterProgradeToRetrograde": 4702523905663922426, + "NextJupiterRetrogradeToPrograde": 4702524171499883741, "NextJupiterWesternQuadrature": 4702523849302148560 } }, @@ -1537,14 +1537,14 @@ "LastJupiterConjunction": 4702523619040345746, "LastJupiterEasternQuadrature": 4702523376757230717, "LastJupiterOpposition": 4702524035805022913, - "LastJupiterProgradeToRetrograde": 4702523905664036962, - "LastJupiterRetrogradeToPrograde": 4702523321352707163, + "LastJupiterProgradeToRetrograde": 4702523905663922426, + "LastJupiterRetrogradeToPrograde": 4702523321352808888, "LastJupiterWesternQuadrature": 4702523849302148560, "NextJupiterConjunction": 4702524468223248937, "NextJupiterEasternQuadrature": 4702524228832748287, "NextJupiterOpposition": 4702524889125890188, - "NextJupiterProgradeToRetrograde": 4702524758348095820, - "NextJupiterRetrogradeToPrograde": 4702524171499874650, + "NextJupiterProgradeToRetrograde": 4702524758348332738, + "NextJupiterRetrogradeToPrograde": 4702524171499883741, "NextJupiterWesternQuadrature": 4702524699351264593 } }, @@ -1555,14 +1555,14 @@ "LastJupiterConjunction": 4702523619040345746, "LastJupiterEasternQuadrature": 4702524228832748287, "LastJupiterOpposition": 4702524035805022913, - "LastJupiterProgradeToRetrograde": 4702523905664036962, - "LastJupiterRetrogradeToPrograde": 4702524171499874650, + "LastJupiterProgradeToRetrograde": 4702523905663922426, + "LastJupiterRetrogradeToPrograde": 4702524171499883741, "LastJupiterWesternQuadrature": 4702523849302148560, "NextJupiterConjunction": 4702524468223248937, "NextJupiterEasternQuadrature": 4702525082476499564, "NextJupiterOpposition": 4702524889125890188, - "NextJupiterProgradeToRetrograde": 4702524758348095820, - "NextJupiterRetrogradeToPrograde": 4702525022873864934, + "NextJupiterProgradeToRetrograde": 4702524758348332738, + "NextJupiterRetrogradeToPrograde": 4702525022873806894, "NextJupiterWesternQuadrature": 4702524699351264593 } }, @@ -1573,14 +1573,14 @@ "LastJupiterConjunction": 4702524468223248937, "LastJupiterEasternQuadrature": 4702524228832748287, "LastJupiterOpposition": 4702524035805022913, - "LastJupiterProgradeToRetrograde": 4702523905664036962, - "LastJupiterRetrogradeToPrograde": 4702524171499874650, + "LastJupiterProgradeToRetrograde": 4702523905663922426, + "LastJupiterRetrogradeToPrograde": 4702524171499883741, "LastJupiterWesternQuadrature": 4702524699351264593, "NextJupiterConjunction": 4702525319223926903, "NextJupiterEasternQuadrature": 4702525082476499564, "NextJupiterOpposition": 4702524889125890188, - "NextJupiterProgradeToRetrograde": 4702524758348095820, - "NextJupiterRetrogradeToPrograde": 4702525022873859211, + "NextJupiterProgradeToRetrograde": 4702524758348332738, + "NextJupiterRetrogradeToPrograde": 4702525022873806894, "NextJupiterWesternQuadrature": 4702525554037783144 } }, @@ -1591,14 +1591,14 @@ "LastJupiterConjunction": 4702524468223248937, "LastJupiterEasternQuadrature": 4702524228832748287, "LastJupiterOpposition": 4702524889125890188, - "LastJupiterProgradeToRetrograde": 4702524758348095820, - "LastJupiterRetrogradeToPrograde": 4702525022873864934, + "LastJupiterProgradeToRetrograde": 4702524758348332738, + "LastJupiterRetrogradeToPrograde": 4702525022873806894, "LastJupiterWesternQuadrature": 4702524699351264593, "NextJupiterConjunction": 4702525319223926903, "NextJupiterEasternQuadrature": 4702525082476499564, "NextJupiterOpposition": 4702525746727313634, - "NextJupiterProgradeToRetrograde": 4702525616387968108, - "NextJupiterRetrogradeToPrograde": 4702525877346411171, + "NextJupiterProgradeToRetrograde": 4702525616388106096, + "NextJupiterRetrogradeToPrograde": 4702525877346820799, "NextJupiterWesternQuadrature": 4702525554037783144 } }, @@ -1609,14 +1609,14 @@ "LastJupiterConjunction": 4702525319223926903, "LastJupiterEasternQuadrature": 4702525082476499564, "LastJupiterOpposition": 4702524889125890188, - "LastJupiterProgradeToRetrograde": 4702524758348095820, - "LastJupiterRetrogradeToPrograde": 4702525022873864934, + "LastJupiterProgradeToRetrograde": 4702524758348332738, + "LastJupiterRetrogradeToPrograde": 4702525022873806894, "LastJupiterWesternQuadrature": 4702524699351264593, "NextJupiterConjunction": 4702526173905141455, "NextJupiterEasternQuadrature": 4702525938677840869, "NextJupiterOpposition": 4702525746727313634, - "NextJupiterProgradeToRetrograde": 4702525616387968108, - "NextJupiterRetrogradeToPrograde": 4702525877346411171, + "NextJupiterProgradeToRetrograde": 4702525616388106096, + "NextJupiterRetrogradeToPrograde": 4702525877346820799, "NextJupiterWesternQuadrature": 4702525554037783144 } }, @@ -1627,14 +1627,14 @@ "LastJupiterConjunction": 4702525319223926903, "LastJupiterEasternQuadrature": 4702525082476499564, "LastJupiterOpposition": 4702524889125890188, - "LastJupiterProgradeToRetrograde": 4702525616387968108, - "LastJupiterRetrogradeToPrograde": 4702525022873859211, + "LastJupiterProgradeToRetrograde": 4702525616388106096, + "LastJupiterRetrogradeToPrograde": 4702525022873806894, "LastJupiterWesternQuadrature": 4702525554037783144, "NextJupiterConjunction": 4702526173905141455, "NextJupiterEasternQuadrature": 4702525938677840869, "NextJupiterOpposition": 4702525746727313634, - "NextJupiterProgradeToRetrograde": 4702526480112540274, - "NextJupiterRetrogradeToPrograde": 4702525877346411171, + "NextJupiterProgradeToRetrograde": 4702526480112565816, + "NextJupiterRetrogradeToPrograde": 4702525877346820799, "NextJupiterWesternQuadrature": 4702526414693362431 } }, @@ -1645,14 +1645,14 @@ "LastJupiterConjunction": 4702525319223926903, "LastJupiterEasternQuadrature": 4702525938677840869, "LastJupiterOpposition": 4702525746727313634, - "LastJupiterProgradeToRetrograde": 4702525616387968108, - "LastJupiterRetrogradeToPrograde": 4702525877346411171, + "LastJupiterProgradeToRetrograde": 4702525616388106096, + "LastJupiterRetrogradeToPrograde": 4702525877346820799, "LastJupiterWesternQuadrature": 4702525554037783144, "NextJupiterConjunction": 4702526173905141455, "NextJupiterEasternQuadrature": 4702526797833832434, "NextJupiterOpposition": 4702526608783529075, - "NextJupiterProgradeToRetrograde": 4702526480112540274, - "NextJupiterRetrogradeToPrograde": 4702526736805386970, + "NextJupiterProgradeToRetrograde": 4702526480112565816, + "NextJupiterRetrogradeToPrograde": 4702526736805355700, "NextJupiterWesternQuadrature": 4702526414693362431 } }, @@ -1663,14 +1663,14 @@ "LastJupiterConjunction": 4702526173905141455, "LastJupiterEasternQuadrature": 4702525938677840869, "LastJupiterOpposition": 4702525746727313634, - "LastJupiterProgradeToRetrograde": 4702525616387968108, - "LastJupiterRetrogradeToPrograde": 4702525877346411171, + "LastJupiterProgradeToRetrograde": 4702525616388106096, + "LastJupiterRetrogradeToPrograde": 4702525877346820799, "LastJupiterWesternQuadrature": 4702525554037783144, "NextJupiterConjunction": 4702527033345888405, "NextJupiterEasternQuadrature": 4702526797833832434, "NextJupiterOpposition": 4702526608783529075, - "NextJupiterProgradeToRetrograde": 4702526480112540274, - "NextJupiterRetrogradeToPrograde": 4702526736805395038, + "NextJupiterProgradeToRetrograde": 4702526480112565816, + "NextJupiterRetrogradeToPrograde": 4702526736805355702, "NextJupiterWesternQuadrature": 4702526414693362431 } }, @@ -1681,14 +1681,14 @@ "LastJupiterConjunction": 4702526173905141455, "LastJupiterEasternQuadrature": 4702525938677840869, "LastJupiterOpposition": 4702526608783529075, - "LastJupiterProgradeToRetrograde": 4702526480112540274, - "LastJupiterRetrogradeToPrograde": 4702525877346411171, + "LastJupiterProgradeToRetrograde": 4702526480112565816, + "LastJupiterRetrogradeToPrograde": 4702525877346820799, "LastJupiterWesternQuadrature": 4702526414693362431, "NextJupiterConjunction": 4702527033345888405, "NextJupiterEasternQuadrature": 4702526797833832434, "NextJupiterOpposition": 4702527473656996631, - "NextJupiterProgradeToRetrograde": 4702527346266021147, - "NextJupiterRetrogradeToPrograde": 4702526736805395038, + "NextJupiterProgradeToRetrograde": 4702527346265896391, + "NextJupiterRetrogradeToPrograde": 4702526736805355702, "NextJupiterWesternQuadrature": 4702527280539833529 } }, @@ -1699,14 +1699,14 @@ "LastJupiterConjunction": 4702526173905141455, "LastJupiterEasternQuadrature": 4702526797833832434, "LastJupiterOpposition": 4702526608783529075, - "LastJupiterProgradeToRetrograde": 4702526480112540274, - "LastJupiterRetrogradeToPrograde": 4702526736805386970, + "LastJupiterProgradeToRetrograde": 4702526480112565816, + "LastJupiterRetrogradeToPrograde": 4702526736805355700, "LastJupiterWesternQuadrature": 4702526414693362431, "NextJupiterConjunction": 4702527033345888405, "NextJupiterEasternQuadrature": 4702527659252019133, "NextJupiterOpposition": 4702527473656996631, - "NextJupiterProgradeToRetrograde": 4702527346266021147, - "NextJupiterRetrogradeToPrograde": 4702527599853594592, + "NextJupiterProgradeToRetrograde": 4702527346265896391, + "NextJupiterRetrogradeToPrograde": 4702527599853689283, "NextJupiterWesternQuadrature": 4702527280539833529 } }, @@ -1717,14 +1717,14 @@ "LastJupiterConjunction": 4702527033345888405, "LastJupiterEasternQuadrature": 4702526797833832434, "LastJupiterOpposition": 4702526608783529075, - "LastJupiterProgradeToRetrograde": 4702526480112540274, - "LastJupiterRetrogradeToPrograde": 4702526736805395038, + "LastJupiterProgradeToRetrograde": 4702526480112565816, + "LastJupiterRetrogradeToPrograde": 4702526736805355702, "LastJupiterWesternQuadrature": 4702526414693362431, "NextJupiterConjunction": 4702527896986437669, "NextJupiterEasternQuadrature": 4702527659252019133, "NextJupiterOpposition": 4702527473656996631, - "NextJupiterProgradeToRetrograde": 4702527346266021147, - "NextJupiterRetrogradeToPrograde": 4702527599853594592, + "NextJupiterProgradeToRetrograde": 4702527346265896391, + "NextJupiterRetrogradeToPrograde": 4702527599853689283, "NextJupiterWesternQuadrature": 4702527280539833529 } } diff --git a/basic/testdata/mars_event_baseline.json b/basic/testdata/mars_event_baseline.json index 1e1958d..99252b1 100644 --- a/basic/testdata/mars_event_baseline.json +++ b/basic/testdata/mars_event_baseline.json @@ -7,14 +7,14 @@ "LastMarsConjunction": 4702512672991079470, "LastMarsEasternQuadrature": 4702512146017123176, "LastMarsOpposition": 4702511930972130659, - "LastMarsProgradeToRetrograde": 4702511848566851510, - "LastMarsRetrogradeToPrograde": 4702512005746759120, + "LastMarsProgradeToRetrograde": 4702511848566851492, + "LastMarsRetrogradeToPrograde": 4702512005746759245, "LastMarsWesternQuadrature": 4702511701091868571, "NextMarsConjunction": 4702514347420756601, "NextMarsEasternQuadrature": 4702513793695750616, "NextMarsOpposition": 4702513589022085416, "NextMarsProgradeToRetrograde": 4702513504713638346, - "NextMarsRetrogradeToPrograde": 4702513671731200986, + "NextMarsRetrogradeToPrograde": 4702513671731200990, "NextMarsWesternQuadrature": 4702513385284764112 } }, @@ -26,13 +26,13 @@ "LastMarsEasternQuadrature": 4702512146017123176, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200993, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702513385284764112, "NextMarsConjunction": 4702514347420756601, "NextMarsEasternQuadrature": 4702513793695750616, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702515034746657425 } }, @@ -44,13 +44,13 @@ "LastMarsEasternQuadrature": 4702513793695750616, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200986, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702513385284764112, "NextMarsConjunction": 4702514347420756601, "NextMarsEasternQuadrature": 4702515436148825805, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702515034746657425 } }, @@ -62,13 +62,13 @@ "LastMarsEasternQuadrature": 4702513793695750616, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200986, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702513385284764112, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702515436148825805, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702515034746657425 } }, @@ -80,13 +80,13 @@ "LastMarsEasternQuadrature": 4702513793695750616, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200993, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702515436148825805, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -97,14 +97,14 @@ "LastMarsConjunction": 4702514347420756601, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702515232188474249, - "LastMarsProgradeToRetrograde": 4702515148308162856, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702515148308162857, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702516873348048812, - "NextMarsProgradeToRetrograde": 4702516790519591907, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702516790519592379, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -115,14 +115,14 @@ "LastMarsConjunction": 4702514347420756601, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702515232188474249, - "LastMarsProgradeToRetrograde": 4702515148308162856, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702515148308162857, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702516873348048812, - "NextMarsProgradeToRetrograde": 4702516790519591907, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702516790519592379, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -133,14 +133,14 @@ "LastMarsConjunction": 4702516062146454046, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702515232188474249, - "LastMarsProgradeToRetrograde": 4702515148308162856, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702515148308162857, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702517778500160722, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702516873348048812, - "NextMarsProgradeToRetrograde": 4702516790519591907, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702516790519592379, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -151,14 +151,14 @@ "LastMarsConjunction": 4702516062146454046, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702517778500160722, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -169,14 +169,14 @@ "LastMarsConjunction": 4702516062146454046, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702516965530268756, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702516965530268937, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702517778500160722, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702518612758774793, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702518612758774789, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -187,14 +187,14 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702516965530268756, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702516965530268937, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702518612758774793, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702518612758774789, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -205,14 +205,14 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702516965530268756, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702516965530268937, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702518612758774793, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702518612758774789, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -223,13 +223,13 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702518311829564273, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702520129599854159, + "NextMarsProgradeToRetrograde": 4702520129599854152, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702519961901894387 } @@ -241,13 +241,13 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702518749267101830, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702518311829564273, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702520463914895419, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702520129599854159, + "NextMarsProgradeToRetrograde": 4702520129599854152, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702519961901894387 } @@ -259,13 +259,13 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702518749267101830, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702518311829564273, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702520463914895419, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702520129599854159, + "NextMarsProgradeToRetrograde": 4702520129599854152, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702519961901894387 } @@ -277,13 +277,13 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702518749267101830, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702520463914895419, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702520129599854159, + "NextMarsProgradeToRetrograde": 4702520129599854152, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702521646667869429 } @@ -295,14 +295,14 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702520129599854159, + "LastMarsProgradeToRetrograde": 4702520129599854152, "LastMarsRetrogradeToPrograde": 4702520278429881065, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702521869636717147, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702521869636717158, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702521646667869429 } }, @@ -313,14 +313,14 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702520129599854159, + "LastMarsProgradeToRetrograde": 4702520129599854152, "LastMarsRetrogradeToPrograde": 4702520278429881065, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702521869636717147, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702521869636717158, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702521646667869429 } }, @@ -331,14 +331,14 @@ "LastMarsConjunction": 4702521109471690662, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702520129599854159, + "LastMarsProgradeToRetrograde": 4702520129599854152, "LastMarsRetrogradeToPrograde": 4702520278429881065, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702522755753620573, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702521869636717147, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702521869636717158, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702521646667869429 } }, @@ -349,14 +349,14 @@ "LastMarsConjunction": 4702521109471690662, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702521869636717147, + "LastMarsProgradeToRetrograde": 4702521869636717158, "LastMarsRetrogradeToPrograde": 4702520278429881065, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702522755753620573, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -367,14 +367,14 @@ "LastMarsConjunction": 4702521109471690662, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702521931573037827, - "LastMarsProgradeToRetrograde": 4702521869636717147, - "LastMarsRetrogradeToPrograde": 4702521999943030902, + "LastMarsProgradeToRetrograde": 4702521869636717158, + "LastMarsRetrogradeToPrograde": 4702521999943030901, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702522755753620573, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702523652958023467, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -385,14 +385,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702521931573037827, - "LastMarsProgradeToRetrograde": 4702521869636717147, - "LastMarsRetrogradeToPrograde": 4702521999943030902, + "LastMarsProgradeToRetrograde": 4702521869636717158, + "LastMarsRetrogradeToPrograde": 4702521999943030901, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702523652958023467, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -403,14 +403,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702521931573037827, - "LastMarsProgradeToRetrograde": 4702521869636717147, - "LastMarsRetrogradeToPrograde": 4702521999943030902, + "LastMarsProgradeToRetrograde": 4702521869636717158, + "LastMarsRetrogradeToPrograde": 4702521999943030901, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702523652958023467, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -421,14 +421,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702521999943030902, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702521999943030901, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -439,14 +439,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702523725151062606, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702523725151062615, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702525402191108215, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702525402191108210, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -457,14 +457,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702523725151062606, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702523725151062615, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702525402191108215, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702525402191108210, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -475,14 +475,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702523725151062606, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702523725151062615, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702525402191108212, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702525402191108206, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -493,14 +493,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702525238535210259, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702525238535210266, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702525112250558519, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702526885607895804, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702526885607895806, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702526771102292594 } }, @@ -511,14 +511,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702525529803790800, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702525238535210259, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702525238535210266, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702525112250558519, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702527173148473968, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702526885607895804, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702526885607895806, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702526771102292594 } }, @@ -529,14 +529,14 @@ "LastMarsConjunction": 4702526067991662288, "LastMarsEasternQuadrature": 4702525529803790800, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702525238535210259, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702525238535210266, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702525112250558519, "NextMarsConjunction": 4702527766168612385, "NextMarsEasternQuadrature": 4702527173148473968, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702526885607895804, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702526885607895806, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702526771102292594 } }, @@ -547,14 +547,14 @@ "LastMarsConjunction": 4702526067991662288, "LastMarsEasternQuadrature": 4702525529803790800, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702525238535210259, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702525238535210266, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702527766168612385, "NextMarsEasternQuadrature": 4702527173148473968, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702526885607895804, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702526885607895806, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -565,14 +565,14 @@ "LastMarsConjunction": 4702526067991662288, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702526969725142175, - "LastMarsProgradeToRetrograde": 4702526885607895804, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702526885607895806, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702527766168612385, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702528610443478642, - "NextMarsProgradeToRetrograde": 4702528527088086023, - "NextMarsRetrogradeToPrograde": 4702528702087970247, + "NextMarsProgradeToRetrograde": 4702528527088086026, + "NextMarsRetrogradeToPrograde": 4702528702087967798, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -583,14 +583,14 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702526969725142175, - "LastMarsProgradeToRetrograde": 4702526885607895804, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702526885607895806, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702528610443478642, - "NextMarsProgradeToRetrograde": 4702528527088086023, - "NextMarsRetrogradeToPrograde": 4702528702087970247, + "NextMarsProgradeToRetrograde": 4702528527088086026, + "NextMarsRetrogradeToPrograde": 4702528702087967798, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -601,14 +601,14 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702526969725142175, - "LastMarsProgradeToRetrograde": 4702526885607895804, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702526885607895806, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702528610443478642, - "NextMarsProgradeToRetrograde": 4702528527088086023, - "NextMarsRetrogradeToPrograde": 4702528702087970247, + "NextMarsProgradeToRetrograde": 4702528527088086026, + "NextMarsRetrogradeToPrograde": 4702528702087967798, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -619,14 +619,14 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702528610443478642, - "LastMarsProgradeToRetrograde": 4702528527088086023, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702528527088086026, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702528412245032287, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702530255495150360, - "NextMarsProgradeToRetrograde": 4702530173884012780, - "NextMarsRetrogradeToPrograde": 4702528702087970247, + "NextMarsProgradeToRetrograde": 4702530173884012783, + "NextMarsRetrogradeToPrograde": 4702528702087967798, "NextMarsWesternQuadrature": 4702530049621000879 } }, @@ -637,13 +637,13 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702528817168144408, "LastMarsOpposition": 4702528610443478642, - "LastMarsProgradeToRetrograde": 4702528527088086023, - "LastMarsRetrogradeToPrograde": 4702528702087970247, + "LastMarsProgradeToRetrograde": 4702528527088086026, + "LastMarsRetrogradeToPrograde": 4702528702087967798, "LastMarsWesternQuadrature": 4702528412245032287, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702530473238549120, "NextMarsOpposition": 4702530255495150360, - "NextMarsProgradeToRetrograde": 4702530173884012780, + "NextMarsProgradeToRetrograde": 4702530173884012783, "NextMarsRetrogradeToPrograde": 4702530346784069217, "NextMarsWesternQuadrature": 4702530049621000879 } @@ -655,13 +655,13 @@ "LastMarsConjunction": 4702529491283795617, "LastMarsEasternQuadrature": 4702528817168144408, "LastMarsOpposition": 4702528610443478642, - "LastMarsProgradeToRetrograde": 4702528527088086023, - "LastMarsRetrogradeToPrograde": 4702528702087970247, + "LastMarsProgradeToRetrograde": 4702528527088086026, + "LastMarsRetrogradeToPrograde": 4702528702087967798, "LastMarsWesternQuadrature": 4702528412245032287, "NextMarsConjunction": 4702531182749870743, "NextMarsEasternQuadrature": 4702530473238549120, "NextMarsOpposition": 4702530255495150360, - "NextMarsProgradeToRetrograde": 4702530173884012780, + "NextMarsProgradeToRetrograde": 4702530173884012783, "NextMarsRetrogradeToPrograde": 4702530346784069217, "NextMarsWesternQuadrature": 4702530049621000879 } @@ -673,13 +673,13 @@ "LastMarsConjunction": 4702529491283795617, "LastMarsEasternQuadrature": 4702528817168144408, "LastMarsOpposition": 4702528610443478642, - "LastMarsProgradeToRetrograde": 4702528527088086023, - "LastMarsRetrogradeToPrograde": 4702528702087970247, + "LastMarsProgradeToRetrograde": 4702528527088086026, + "LastMarsRetrogradeToPrograde": 4702528702087967798, "LastMarsWesternQuadrature": 4702528412245032287, "NextMarsConjunction": 4702531182749870743, "NextMarsEasternQuadrature": 4702530473238549120, "NextMarsOpposition": 4702530255495150360, - "NextMarsProgradeToRetrograde": 4702530173884012780, + "NextMarsProgradeToRetrograde": 4702530173884012783, "NextMarsRetrogradeToPrograde": 4702530346784069217, "NextMarsWesternQuadrature": 4702530049621000879 } @@ -691,14 +691,14 @@ "LastMarsConjunction": 4702529491283795617, "LastMarsEasternQuadrature": 4702530473238549120, "LastMarsOpposition": 4702530255495150360, - "LastMarsProgradeToRetrograde": 4702530173884012780, + "LastMarsProgradeToRetrograde": 4702530173884012783, "LastMarsRetrogradeToPrograde": 4702530346784069217, "LastMarsWesternQuadrature": 4702530049621000879, "NextMarsConjunction": 4702531182749870743, "NextMarsEasternQuadrature": 4702532164571047325, "NextMarsOpposition": 4702531918919958017, - "NextMarsProgradeToRetrograde": 4702531842932919731, - "NextMarsRetrogradeToPrograde": 4702532002409624070, + "NextMarsProgradeToRetrograde": 4702531842932919738, + "NextMarsRetrogradeToPrograde": 4702532002409625703, "NextMarsWesternQuadrature": 4702531693510274880 } }, @@ -709,14 +709,14 @@ "LastMarsConjunction": 4702529491283795617, "LastMarsEasternQuadrature": 4702530473238549120, "LastMarsOpposition": 4702530255495150360, - "LastMarsProgradeToRetrograde": 4702530173884012780, + "LastMarsProgradeToRetrograde": 4702530173884012783, "LastMarsRetrogradeToPrograde": 4702530346784069217, "LastMarsWesternQuadrature": 4702530049621000879, "NextMarsConjunction": 4702531182749870743, "NextMarsEasternQuadrature": 4702532164571047325, "NextMarsOpposition": 4702531918919958017, - "NextMarsProgradeToRetrograde": 4702531842932919731, - "NextMarsRetrogradeToPrograde": 4702532002409624070, + "NextMarsProgradeToRetrograde": 4702531842932919738, + "NextMarsRetrogradeToPrograde": 4702532002409625703, "NextMarsWesternQuadrature": 4702531693510274880 } }, @@ -727,14 +727,14 @@ "LastMarsConjunction": 4702531182749870743, "LastMarsEasternQuadrature": 4702530473238549120, "LastMarsOpposition": 4702530255495150360, - "LastMarsProgradeToRetrograde": 4702530173884012780, + "LastMarsProgradeToRetrograde": 4702530173884012783, "LastMarsRetrogradeToPrograde": 4702530346784069217, "LastMarsWesternQuadrature": 4702530049621000879, "NextMarsConjunction": 4702532843561122680, "NextMarsEasternQuadrature": 4702532164571047325, "NextMarsOpposition": 4702531918919958017, - "NextMarsProgradeToRetrograde": 4702531842932919731, - "NextMarsRetrogradeToPrograde": 4702532002409624070, + "NextMarsProgradeToRetrograde": 4702531842932919738, + "NextMarsRetrogradeToPrograde": 4702532002409625703, "NextMarsWesternQuadrature": 4702531693510274880 } }, @@ -745,14 +745,14 @@ "LastMarsConjunction": 4702531182749870743, "LastMarsEasternQuadrature": 4702530473238549120, "LastMarsOpposition": 4702530255495150360, - "LastMarsProgradeToRetrograde": 4702531842932919731, + "LastMarsProgradeToRetrograde": 4702531842932919738, "LastMarsRetrogradeToPrograde": 4702530346784069217, "LastMarsWesternQuadrature": 4702531693510274880, "NextMarsConjunction": 4702532843561122680, "NextMarsEasternQuadrature": 4702532164571047325, "NextMarsOpposition": 4702531918919958017, - "NextMarsProgradeToRetrograde": 4702533566262965791, - "NextMarsRetrogradeToPrograde": 4702532002409624070, + "NextMarsProgradeToRetrograde": 4702533566262965807, + "NextMarsRetrogradeToPrograde": 4702532002409625703, "NextMarsWesternQuadrature": 4702533360315552681 } }, @@ -763,14 +763,14 @@ "LastMarsConjunction": 4702531182749870743, "LastMarsEasternQuadrature": 4702532164571047325, "LastMarsOpposition": 4702531918919958017, - "LastMarsProgradeToRetrograde": 4702531842932919731, - "LastMarsRetrogradeToPrograde": 4702532002409624070, + "LastMarsProgradeToRetrograde": 4702531842932919738, + "LastMarsRetrogradeToPrograde": 4702532002409625703, "LastMarsWesternQuadrature": 4702531693510274880, "NextMarsConjunction": 4702532843561122680, "NextMarsEasternQuadrature": 4702533904383919033, "NextMarsOpposition": 4702533627774828963, - "NextMarsProgradeToRetrograde": 4702533566262965791, - "NextMarsRetrogradeToPrograde": 4702533696942547266, + "NextMarsProgradeToRetrograde": 4702533566262965807, + "NextMarsRetrogradeToPrograde": 4702533696942547244, "NextMarsWesternQuadrature": 4702533360315552681 } }, @@ -781,14 +781,14 @@ "LastMarsConjunction": 4702531182749870743, "LastMarsEasternQuadrature": 4702532164571047325, "LastMarsOpposition": 4702531918919958017, - "LastMarsProgradeToRetrograde": 4702531842932919731, - "LastMarsRetrogradeToPrograde": 4702532002409624070, + "LastMarsProgradeToRetrograde": 4702531842932919738, + "LastMarsRetrogradeToPrograde": 4702532002409625703, "LastMarsWesternQuadrature": 4702531693510274880, "NextMarsConjunction": 4702532843561122680, "NextMarsEasternQuadrature": 4702533904383919033, "NextMarsOpposition": 4702533627774828963, - "NextMarsProgradeToRetrograde": 4702533566262965791, - "NextMarsRetrogradeToPrograde": 4702533696942547266, + "NextMarsProgradeToRetrograde": 4702533566262965807, + "NextMarsRetrogradeToPrograde": 4702533696942547244, "NextMarsWesternQuadrature": 4702533360315552681 } }, @@ -799,14 +799,14 @@ "LastMarsConjunction": 4702532843561122680, "LastMarsEasternQuadrature": 4702532164571047325, "LastMarsOpposition": 4702531918919958017, - "LastMarsProgradeToRetrograde": 4702531842932919731, - "LastMarsRetrogradeToPrograde": 4702532002409624070, + "LastMarsProgradeToRetrograde": 4702531842932919738, + "LastMarsRetrogradeToPrograde": 4702532002409625703, "LastMarsWesternQuadrature": 4702531693510274880, "NextMarsConjunction": 4702534491554176271, "NextMarsEasternQuadrature": 4702533904383919033, "NextMarsOpposition": 4702533627774828963, - "NextMarsProgradeToRetrograde": 4702533566262965791, - "NextMarsRetrogradeToPrograde": 4702533696942547266, + "NextMarsProgradeToRetrograde": 4702533566262965807, + "NextMarsRetrogradeToPrograde": 4702533696942547244, "NextMarsWesternQuadrature": 4702533360315552681 } }, @@ -817,14 +817,14 @@ "LastMarsConjunction": 4702532843561122680, "LastMarsEasternQuadrature": 4702532164571047325, "LastMarsOpposition": 4702533627774828963, - "LastMarsProgradeToRetrograde": 4702533566262965791, - "LastMarsRetrogradeToPrograde": 4702532002409624070, + "LastMarsProgradeToRetrograde": 4702533566262965807, + "LastMarsRetrogradeToPrograde": 4702532002409625703, "LastMarsWesternQuadrature": 4702533360315552681, "NextMarsConjunction": 4702534491554176271, "NextMarsEasternQuadrature": 4702533904383919033, "NextMarsOpposition": 4702535366718430785, - "NextMarsProgradeToRetrograde": 4702535293198845174, - "NextMarsRetrogradeToPrograde": 4702533696942547266, + "NextMarsProgradeToRetrograde": 4702535293198845182, + "NextMarsRetrogradeToPrograde": 4702533696942547244, "NextMarsWesternQuadrature": 4702535089312982648 } }, @@ -835,14 +835,14 @@ "LastMarsConjunction": 4702532843561122680, "LastMarsEasternQuadrature": 4702533904383919033, "LastMarsOpposition": 4702533627774828963, - "LastMarsProgradeToRetrograde": 4702533566262965791, - "LastMarsRetrogradeToPrograde": 4702533696942547266, + "LastMarsProgradeToRetrograde": 4702533566262965807, + "LastMarsRetrogradeToPrograde": 4702533696942547244, "LastMarsWesternQuadrature": 4702533360315552681, "NextMarsConjunction": 4702534491554176271, "NextMarsEasternQuadrature": 4702535603937495686, "NextMarsOpposition": 4702535366718430785, - "NextMarsProgradeToRetrograde": 4702535293198845174, - "NextMarsRetrogradeToPrograde": 4702535437223840739, + "NextMarsProgradeToRetrograde": 4702535293198845182, + "NextMarsRetrogradeToPrograde": 4702535437223840747, "NextMarsWesternQuadrature": 4702535089312982648 } }, @@ -853,14 +853,14 @@ "LastMarsConjunction": 4702534491554176271, "LastMarsEasternQuadrature": 4702533904383919033, "LastMarsOpposition": 4702533627774828963, - "LastMarsProgradeToRetrograde": 4702533566262965791, - "LastMarsRetrogradeToPrograde": 4702533696942547266, + "LastMarsProgradeToRetrograde": 4702533566262965807, + "LastMarsRetrogradeToPrograde": 4702533696942547244, "LastMarsWesternQuadrature": 4702533360315552681, "NextMarsConjunction": 4702536138075400266, "NextMarsEasternQuadrature": 4702535603937495686, "NextMarsOpposition": 4702535366718430785, - "NextMarsProgradeToRetrograde": 4702535293198845174, - "NextMarsRetrogradeToPrograde": 4702535437223840739, + "NextMarsProgradeToRetrograde": 4702535293198845182, + "NextMarsRetrogradeToPrograde": 4702535437223840747, "NextMarsWesternQuadrature": 4702535089312982648 } }, @@ -871,14 +871,14 @@ "LastMarsConjunction": 4702512672991079470, "LastMarsEasternQuadrature": 4702512146017123176, "LastMarsOpposition": 4702511930972130659, - "LastMarsProgradeToRetrograde": 4702511848566851510, - "LastMarsRetrogradeToPrograde": 4702512005746759120, + "LastMarsProgradeToRetrograde": 4702511848566851492, + "LastMarsRetrogradeToPrograde": 4702512005746759245, "LastMarsWesternQuadrature": 4702513385284764112, "NextMarsConjunction": 4702514347420756601, "NextMarsEasternQuadrature": 4702513793695750616, "NextMarsOpposition": 4702513589022085416, "NextMarsProgradeToRetrograde": 4702513504713638346, - "NextMarsRetrogradeToPrograde": 4702513671731200986, + "NextMarsRetrogradeToPrograde": 4702513671731200990, "NextMarsWesternQuadrature": 4702515034746657425 } }, @@ -890,13 +890,13 @@ "LastMarsEasternQuadrature": 4702512146017123176, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200993, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702513385284764112, "NextMarsConjunction": 4702514347420756601, "NextMarsEasternQuadrature": 4702513793695750616, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702515034746657425 } }, @@ -908,13 +908,13 @@ "LastMarsEasternQuadrature": 4702513793695750616, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200993, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702513385284764112, "NextMarsConjunction": 4702514347420756601, "NextMarsEasternQuadrature": 4702515436148825805, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702515034746657425 } }, @@ -926,13 +926,13 @@ "LastMarsEasternQuadrature": 4702513793695750616, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200993, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702513385284764112, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702515436148825805, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702515034746657425 } }, @@ -944,13 +944,13 @@ "LastMarsEasternQuadrature": 4702513793695750616, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200993, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702513385284764112, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702515436148825805, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702515034746657425 } }, @@ -962,13 +962,13 @@ "LastMarsEasternQuadrature": 4702513793695750616, "LastMarsOpposition": 4702513589022085416, "LastMarsProgradeToRetrograde": 4702513504713638346, - "LastMarsRetrogradeToPrograde": 4702513671731200993, + "LastMarsRetrogradeToPrograde": 4702513671731200990, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702515436148825805, "NextMarsOpposition": 4702515232188474249, - "NextMarsProgradeToRetrograde": 4702515148308162856, - "NextMarsRetrogradeToPrograde": 4702515321508671938, + "NextMarsProgradeToRetrograde": 4702515148308162857, + "NextMarsRetrogradeToPrograde": 4702515321508671926, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -979,14 +979,14 @@ "LastMarsConjunction": 4702514347420756601, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702515232188474249, - "LastMarsProgradeToRetrograde": 4702515148308162856, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702515148308162857, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702516873348048812, - "NextMarsProgradeToRetrograde": 4702516790519591907, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702516790519592379, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -997,14 +997,14 @@ "LastMarsConjunction": 4702514347420756601, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702515232188474249, - "LastMarsProgradeToRetrograde": 4702515148308162856, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702515148308162857, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702516062146454046, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702516873348048812, - "NextMarsProgradeToRetrograde": 4702516790519591907, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702516790519592379, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -1015,14 +1015,14 @@ "LastMarsConjunction": 4702516062146454046, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702515232188474249, - "LastMarsProgradeToRetrograde": 4702515148308162856, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702515148308162857, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702517778500160722, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702516873348048812, - "NextMarsProgradeToRetrograde": 4702516790519591907, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702516790519592379, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -1033,14 +1033,14 @@ "LastMarsConjunction": 4702516062146454046, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702515232188474249, - "LastMarsProgradeToRetrograde": 4702515148308162856, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702515148308162857, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702515034746657425, "NextMarsConjunction": 4702517778500160722, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702516873348048812, - "NextMarsProgradeToRetrograde": 4702516790519591907, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702516790519592379, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702516673165455699 } }, @@ -1051,14 +1051,14 @@ "LastMarsConjunction": 4702516062146454046, "LastMarsEasternQuadrature": 4702515436148825805, "LastMarsOpposition": 4702515232188474249, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702515321508671938, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702515321508671926, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702517778500160722, "NextMarsEasternQuadrature": 4702517083255855413, "NextMarsOpposition": 4702516873348048812, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702516965530268756, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702516965530268937, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -1069,14 +1069,14 @@ "LastMarsConjunction": 4702516062146454046, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702516965530268756, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702516965530268937, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702517778500160722, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702518612758774793, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702518612758774789, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -1087,14 +1087,14 @@ "LastMarsConjunction": 4702516062146454046, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702516965530268756, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702516965530268937, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702517778500160722, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702518612758774793, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702518612758774789, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -1105,14 +1105,14 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702516965530268755, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702516965530268937, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702518612758774793, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702518612758774789, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -1123,14 +1123,14 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702516790519591907, - "LastMarsRetrogradeToPrograde": 4702516965530268756, + "LastMarsProgradeToRetrograde": 4702516790519592379, + "LastMarsRetrogradeToPrograde": 4702516965530268937, "LastMarsWesternQuadrature": 4702516673165455699, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702518443319353833, - "NextMarsRetrogradeToPrograde": 4702518612758774793, + "NextMarsProgradeToRetrograde": 4702518443319354827, + "NextMarsRetrogradeToPrograde": 4702518612758774789, "NextMarsWesternQuadrature": 4702518311829564273 } }, @@ -1141,14 +1141,14 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702517083255855413, "LastMarsOpposition": 4702516873348048812, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702516965530268756, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702516965530268937, "LastMarsWesternQuadrature": 4702518311829564273, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702518749267101830, "NextMarsOpposition": 4702518523484696971, - "NextMarsProgradeToRetrograde": 4702520129599854159, - "NextMarsRetrogradeToPrograde": 4702518612758774793, + "NextMarsProgradeToRetrograde": 4702520129599854152, + "NextMarsRetrogradeToPrograde": 4702518612758774789, "NextMarsWesternQuadrature": 4702519961901894387 } }, @@ -1159,13 +1159,13 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702518749267101830, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702518311829564273, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702520463914895419, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702520129599854159, + "NextMarsProgradeToRetrograde": 4702520129599854152, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702519961901894387 } @@ -1177,13 +1177,13 @@ "LastMarsConjunction": 4702517778500160722, "LastMarsEasternQuadrature": 4702518749267101830, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702518311829564273, "NextMarsConjunction": 4702519455332247028, "NextMarsEasternQuadrature": 4702520463914895419, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702520129599854159, + "NextMarsProgradeToRetrograde": 4702520129599854152, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702519961901894387 } @@ -1195,13 +1195,13 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702518749267101830, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702518311829564273, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702520463914895419, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702520129599854159, + "NextMarsProgradeToRetrograde": 4702520129599854152, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702519961901894387 } @@ -1213,13 +1213,13 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702518749267101830, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702518443319353833, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702518443319354827, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702518311829564273, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702520463914895419, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702520129599854159, + "NextMarsProgradeToRetrograde": 4702520129599854152, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702519961901894387 } @@ -1231,13 +1231,13 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702518749267101830, "LastMarsOpposition": 4702518523484696971, - "LastMarsProgradeToRetrograde": 4702520129599854159, - "LastMarsRetrogradeToPrograde": 4702518612758774793, + "LastMarsProgradeToRetrograde": 4702520129599854152, + "LastMarsRetrogradeToPrograde": 4702518612758774789, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702520463914895419, "NextMarsOpposition": 4702520200681938776, - "NextMarsProgradeToRetrograde": 4702521869636717147, + "NextMarsProgradeToRetrograde": 4702521869636717158, "NextMarsRetrogradeToPrograde": 4702520278429881065, "NextMarsWesternQuadrature": 4702521646667869429 } @@ -1249,14 +1249,14 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702520129599854159, + "LastMarsProgradeToRetrograde": 4702520129599854152, "LastMarsRetrogradeToPrograde": 4702520278429881065, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702521869636717147, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702521869636717158, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702521646667869429 } }, @@ -1267,14 +1267,14 @@ "LastMarsConjunction": 4702519455332247028, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702520129599854159, + "LastMarsProgradeToRetrograde": 4702520129599854152, "LastMarsRetrogradeToPrograde": 4702520278429881062, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702521109471690662, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702521869636717147, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702521869636717158, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702521646667869429 } }, @@ -1285,14 +1285,14 @@ "LastMarsConjunction": 4702521109471690662, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702520129599854159, + "LastMarsProgradeToRetrograde": 4702520129599854152, "LastMarsRetrogradeToPrograde": 4702520278429881065, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702522755753620573, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702521869636717147, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702521869636717158, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702521646667869429 } }, @@ -1303,14 +1303,14 @@ "LastMarsConjunction": 4702521109471690662, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702520129599854159, + "LastMarsProgradeToRetrograde": 4702520129599854152, "LastMarsRetrogradeToPrograde": 4702520278429881065, "LastMarsWesternQuadrature": 4702519961901894387, "NextMarsConjunction": 4702522755753620573, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702521869636717147, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702521869636717158, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702521646667869429 } }, @@ -1321,14 +1321,14 @@ "LastMarsConjunction": 4702521109471690662, "LastMarsEasternQuadrature": 4702520463914895419, "LastMarsOpposition": 4702520200681938776, - "LastMarsProgradeToRetrograde": 4702520129599854159, + "LastMarsProgradeToRetrograde": 4702520129599854152, "LastMarsRetrogradeToPrograde": 4702520278429881065, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702522755753620573, "NextMarsEasternQuadrature": 4702522196782477191, "NextMarsOpposition": 4702521931573037827, - "NextMarsProgradeToRetrograde": 4702521869636717147, - "NextMarsRetrogradeToPrograde": 4702521999943030902, + "NextMarsProgradeToRetrograde": 4702521869636717158, + "NextMarsRetrogradeToPrograde": 4702521999943030901, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -1339,14 +1339,14 @@ "LastMarsConjunction": 4702521109471690662, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702521931573037827, - "LastMarsProgradeToRetrograde": 4702521869636717147, - "LastMarsRetrogradeToPrograde": 4702521999943030902, + "LastMarsProgradeToRetrograde": 4702521869636717158, + "LastMarsRetrogradeToPrograde": 4702521999943030901, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702522755753620573, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702523652958023467, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -1357,14 +1357,14 @@ "LastMarsConjunction": 4702521109471690662, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702521931573037827, - "LastMarsProgradeToRetrograde": 4702521869636717147, - "LastMarsRetrogradeToPrograde": 4702521999943030902, + "LastMarsProgradeToRetrograde": 4702521869636717158, + "LastMarsRetrogradeToPrograde": 4702521999943030901, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702522755753620573, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702523652958023467, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -1375,14 +1375,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702521931573037827, - "LastMarsProgradeToRetrograde": 4702521869636717147, - "LastMarsRetrogradeToPrograde": 4702521999943030902, + "LastMarsProgradeToRetrograde": 4702521869636717158, + "LastMarsRetrogradeToPrograde": 4702521999943030901, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702523652958023467, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -1393,14 +1393,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702521931573037827, - "LastMarsProgradeToRetrograde": 4702521869636717147, - "LastMarsRetrogradeToPrograde": 4702521999943030902, + "LastMarsProgradeToRetrograde": 4702521869636717158, + "LastMarsRetrogradeToPrograde": 4702521999943030901, "LastMarsWesternQuadrature": 4702521646667869429, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702523652958023467, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702523400900974448 } }, @@ -1411,14 +1411,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702522196782477191, "LastMarsOpposition": 4702521931573037827, - "LastMarsProgradeToRetrograde": 4702521869636717147, - "LastMarsRetrogradeToPrograde": 4702521999943030903, + "LastMarsProgradeToRetrograde": 4702521869636717158, + "LastMarsRetrogradeToPrograde": 4702521999943030902, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702523876976052288, "NextMarsOpposition": 4702523652958023467, - "NextMarsProgradeToRetrograde": 4702523573661936412, - "NextMarsRetrogradeToPrograde": 4702523725151062606, + "NextMarsProgradeToRetrograde": 4702523573661936410, + "NextMarsRetrogradeToPrograde": 4702523725151062615, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -1429,14 +1429,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702523725151062606, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702523725151062615, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702525402191108215, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702525402191108210, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -1447,14 +1447,14 @@ "LastMarsConjunction": 4702522755753620573, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702523725151062606, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702523725151062615, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702524404470783967, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702525402191108215, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702525402191108210, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -1465,14 +1465,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702523725151062606, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702523725151062615, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702525402191108215, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702525402191108210, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -1483,14 +1483,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702523725151062606, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702523725151062615, "LastMarsWesternQuadrature": 4702523400900974448, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702525402191108215, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702525402191108210, "NextMarsWesternQuadrature": 4702525112250558519 } }, @@ -1501,14 +1501,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702523876976052288, "LastMarsOpposition": 4702523652958023467, - "LastMarsProgradeToRetrograde": 4702523573661936412, - "LastMarsRetrogradeToPrograde": 4702523725151062606, + "LastMarsProgradeToRetrograde": 4702523573661936410, + "LastMarsRetrogradeToPrograde": 4702523725151062615, "LastMarsWesternQuadrature": 4702525112250558519, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702525529803790800, "NextMarsOpposition": 4702525322610471547, - "NextMarsProgradeToRetrograde": 4702525238535210259, - "NextMarsRetrogradeToPrograde": 4702525402191108215, + "NextMarsProgradeToRetrograde": 4702525238535210266, + "NextMarsRetrogradeToPrograde": 4702525402191108210, "NextMarsWesternQuadrature": 4702526771102292594 } }, @@ -1519,14 +1519,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702525529803790800, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702525238535210259, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702525238535210266, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702525112250558519, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702527173148473968, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702526885607895804, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702526885607895806, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702526771102292594 } }, @@ -1537,14 +1537,14 @@ "LastMarsConjunction": 4702524404470783967, "LastMarsEasternQuadrature": 4702525529803790800, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702525238535210259, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702525238535210266, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702525112250558519, "NextMarsConjunction": 4702526067991662288, "NextMarsEasternQuadrature": 4702527173148473968, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702526885607895804, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702526885607895806, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702526771102292594 } }, @@ -1555,14 +1555,14 @@ "LastMarsConjunction": 4702526067991662288, "LastMarsEasternQuadrature": 4702525529803790800, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702525238535210259, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702525238535210266, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702525112250558519, "NextMarsConjunction": 4702527766168612385, "NextMarsEasternQuadrature": 4702527173148473968, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702526885607895804, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702526885607895806, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702526771102292594 } }, @@ -1573,14 +1573,14 @@ "LastMarsConjunction": 4702526067991662288, "LastMarsEasternQuadrature": 4702525529803790800, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702525238535210259, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702525238535210266, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702525112250558519, "NextMarsConjunction": 4702527766168612385, "NextMarsEasternQuadrature": 4702527173148473968, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702526885607895804, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702526885607895806, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702526771102292594 } }, @@ -1591,14 +1591,14 @@ "LastMarsConjunction": 4702526067991662288, "LastMarsEasternQuadrature": 4702525529803790800, "LastMarsOpposition": 4702525322610471547, - "LastMarsProgradeToRetrograde": 4702526885607895804, - "LastMarsRetrogradeToPrograde": 4702525402191108215, + "LastMarsProgradeToRetrograde": 4702526885607895806, + "LastMarsRetrogradeToPrograde": 4702525402191108210, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702527766168612385, "NextMarsEasternQuadrature": 4702527173148473968, "NextMarsOpposition": 4702526969725142175, - "NextMarsProgradeToRetrograde": 4702528527088086023, - "NextMarsRetrogradeToPrograde": 4702527056790870886, + "NextMarsProgradeToRetrograde": 4702528527088086026, + "NextMarsRetrogradeToPrograde": 4702527056790870878, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -1609,14 +1609,14 @@ "LastMarsConjunction": 4702526067991662288, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702526969725142175, - "LastMarsProgradeToRetrograde": 4702526885607895804, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702526885607895806, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702527766168612385, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702528610443478642, - "NextMarsProgradeToRetrograde": 4702528527088086023, - "NextMarsRetrogradeToPrograde": 4702528702087970247, + "NextMarsProgradeToRetrograde": 4702528527088086026, + "NextMarsRetrogradeToPrograde": 4702528702087967798, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -1627,14 +1627,14 @@ "LastMarsConjunction": 4702526067991662288, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702526969725142175, - "LastMarsProgradeToRetrograde": 4702526885607895804, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702526885607895806, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702527766168612385, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702528610443478642, - "NextMarsProgradeToRetrograde": 4702528527088086023, - "NextMarsRetrogradeToPrograde": 4702528702087970247, + "NextMarsProgradeToRetrograde": 4702528527088086026, + "NextMarsRetrogradeToPrograde": 4702528702087967798, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -1645,14 +1645,14 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702526969725142175, - "LastMarsProgradeToRetrograde": 4702526885607895804, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702526885607895806, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702528610443478642, - "NextMarsProgradeToRetrograde": 4702528527088086023, - "NextMarsRetrogradeToPrograde": 4702528702087970248, + "NextMarsProgradeToRetrograde": 4702528527088086026, + "NextMarsRetrogradeToPrograde": 4702528702087967801, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -1663,14 +1663,14 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702526969725142175, - "LastMarsProgradeToRetrograde": 4702526885607895804, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702526885607895806, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702526771102292594, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702528610443478642, - "NextMarsProgradeToRetrograde": 4702528527088086023, - "NextMarsRetrogradeToPrograde": 4702528702087970247, + "NextMarsProgradeToRetrograde": 4702528527088086026, + "NextMarsRetrogradeToPrograde": 4702528702087967798, "NextMarsWesternQuadrature": 4702528412245032287 } }, @@ -1681,14 +1681,14 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702527173148473968, "LastMarsOpposition": 4702526969725142175, - "LastMarsProgradeToRetrograde": 4702528527088086023, - "LastMarsRetrogradeToPrograde": 4702527056790870886, + "LastMarsProgradeToRetrograde": 4702528527088086026, + "LastMarsRetrogradeToPrograde": 4702527056790870878, "LastMarsWesternQuadrature": 4702528412245032287, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702528817168144408, "NextMarsOpposition": 4702528610443478642, - "NextMarsProgradeToRetrograde": 4702530173884012780, - "NextMarsRetrogradeToPrograde": 4702528702087970247, + "NextMarsProgradeToRetrograde": 4702530173884012783, + "NextMarsRetrogradeToPrograde": 4702528702087967798, "NextMarsWesternQuadrature": 4702530049621000879 } }, @@ -1699,13 +1699,13 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702528817168144408, "LastMarsOpposition": 4702528610443478642, - "LastMarsProgradeToRetrograde": 4702528527088086023, - "LastMarsRetrogradeToPrograde": 4702528702087970247, + "LastMarsProgradeToRetrograde": 4702528527088086026, + "LastMarsRetrogradeToPrograde": 4702528702087967798, "LastMarsWesternQuadrature": 4702528412245032287, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702530473238549120, "NextMarsOpposition": 4702530255495150360, - "NextMarsProgradeToRetrograde": 4702530173884012780, + "NextMarsProgradeToRetrograde": 4702530173884012783, "NextMarsRetrogradeToPrograde": 4702530346784069217, "NextMarsWesternQuadrature": 4702530049621000879 } @@ -1717,14 +1717,14 @@ "LastMarsConjunction": 4702527766168612385, "LastMarsEasternQuadrature": 4702528817168144408, "LastMarsOpposition": 4702528610443478642, - "LastMarsProgradeToRetrograde": 4702528527088086023, - "LastMarsRetrogradeToPrograde": 4702528702087970247, + "LastMarsProgradeToRetrograde": 4702528527088086026, + "LastMarsRetrogradeToPrograde": 4702528702087967798, "LastMarsWesternQuadrature": 4702528412245032287, "NextMarsConjunction": 4702529491283795617, "NextMarsEasternQuadrature": 4702530473238549120, "NextMarsOpposition": 4702530255495150360, - "NextMarsProgradeToRetrograde": 4702530173884012780, - "NextMarsRetrogradeToPrograde": 4702530346784069223, + "NextMarsProgradeToRetrograde": 4702530173884012783, + "NextMarsRetrogradeToPrograde": 4702530346784069225, "NextMarsWesternQuadrature": 4702530049621000879 } } diff --git a/basic/testdata/mercury_event_baseline.json b/basic/testdata/mercury_event_baseline.json index f621d2d..dea03a1 100644 --- a/basic/testdata/mercury_event_baseline.json +++ b/basic/testdata/mercury_event_baseline.json @@ -5,18 +5,18 @@ "tt_jd_bits": 4702515172960530022, "events": { "LastMercuryConjunction": 4702515103405455107, - "LastMercuryGreatestElongation": 4702515021502079331, - "LastMercuryGreatestElongationEast": 4702514934896419193, - "LastMercuryGreatestElongationWest": 4702515021502079331, + "LastMercuryGreatestElongation": 4702515021517782444, + "LastMercuryGreatestElongationEast": 4702514934912607102, + "LastMercuryGreatestElongationWest": 4702515021517782444, "LastMercuryInferiorConjunction": 4702514987622216924, "LastMercuryProgradeToRetrograde": 4702514962196732446, "LastMercuryRetrograde": 4702515005854376503, "LastMercuryRetrogradeToPrograde": 4702515005854376503, "LastMercurySuperiorConjunction": 4702515103405455107, "NextMercuryConjunction": 4702515214693437664, - "NextMercuryGreatestElongation": 4702515181195300483, - "NextMercuryGreatestElongationEast": 4702515181195300483, - "NextMercuryGreatestElongationWest": 4702515269448266905, + "NextMercuryGreatestElongation": 4702515181210035256, + "NextMercuryGreatestElongationEast": 4702515181210035256, + "NextMercuryGreatestElongationWest": 4702515269464454690, "NextMercuryInferiorConjunction": 4702515214693437664, "NextMercuryProgradeToRetrograde": 4702515194422928147, "NextMercuryRetrograde": 4702515194422928147, @@ -29,18 +29,18 @@ "tt_jd_bits": 4702515467444937817, "events": { "LastMercuryConjunction": 4702515364105428502, - "LastMercuryGreatestElongation": 4702515423256483173, - "LastMercuryGreatestElongationEast": 4702515423256483173, - "LastMercuryGreatestElongationWest": 4702515269448266905, + "LastMercuryGreatestElongation": 4702515423269900022, + "LastMercuryGreatestElongationEast": 4702515423269900022, + "LastMercuryGreatestElongationWest": 4702515269464454614, "LastMercuryInferiorConjunction": 4702515214693437664, "LastMercuryProgradeToRetrograde": 4702515450267924371, "LastMercuryRetrograde": 4702515450267924371, "LastMercuryRetrogradeToPrograde": 4702515240096263459, "LastMercurySuperiorConjunction": 4702515364105428502, "NextMercuryConjunction": 4702515475132313516, - "NextMercuryGreatestElongation": 4702515527574440317, - "NextMercuryGreatestElongationEast": 4702515681181964795, - "NextMercuryGreatestElongationWest": 4702515527574440317, + "NextMercuryGreatestElongation": 4702515527588877076, + "NextMercuryGreatestElongationEast": 4702515681197668238, + "NextMercuryGreatestElongationWest": 4702515527588877076, "NextMercuryInferiorConjunction": 4702515475132313516, "NextMercuryProgradeToRetrograde": 4702515709270675605, "NextMercuryRetrograde": 4702515500954236786, @@ -53,18 +53,18 @@ "tt_jd_bits": 4702515761929345612, "events": { "LastMercuryConjunction": 4702515736735695950, - "LastMercuryGreatestElongation": 4702515681181964795, - "LastMercuryGreatestElongationEast": 4702515681181964795, - "LastMercuryGreatestElongationWest": 4702515527574440317, + "LastMercuryGreatestElongation": 4702515681197668230, + "LastMercuryGreatestElongationEast": 4702515681197668230, + "LastMercuryGreatestElongationWest": 4702515527588876978, "LastMercuryInferiorConjunction": 4702515736735695950, "LastMercuryProgradeToRetrograde": 4702515709270675605, "LastMercuryRetrograde": 4702515754612104582, "LastMercuryRetrogradeToPrograde": 4702515754612104582, "LastMercurySuperiorConjunction": 4702515588636625279, "NextMercuryConjunction": 4702515842333658113, - "NextMercuryGreatestElongation": 4702515770045354343, - "NextMercuryGreatestElongationEast": 4702515929231454408, - "NextMercuryGreatestElongationWest": 4702515770045354343, + "NextMercuryGreatestElongation": 4702515770061183404, + "NextMercuryGreatestElongationEast": 4702515929246793088, + "NextMercuryGreatestElongationWest": 4702515770061183404, "NextMercuryInferiorConjunction": 4702515964045079368, "NextMercuryProgradeToRetrograde": 4702515943890028756, "NextMercuryRetrograde": 4702515943890028756, @@ -77,18 +77,18 @@ "tt_jd_bits": 4702516056413778263, "events": { "LastMercuryConjunction": 4702515964045079368, - "LastMercuryGreatestElongation": 4702516015453446376, - "LastMercuryGreatestElongationEast": 4702515929231454408, - "LastMercuryGreatestElongationWest": 4702516015453446376, + "LastMercuryGreatestElongation": 4702516015471079902, + "LastMercuryGreatestElongationEast": 4702515929246793078, + "LastMercuryGreatestElongationWest": 4702516015471079902, "LastMercuryInferiorConjunction": 4702515964045079368, "LastMercuryProgradeToRetrograde": 4702515943890028756, "LastMercuryRetrograde": 4702515988415554071, "LastMercuryRetrogradeToPrograde": 4702515988415554071, "LastMercurySuperiorConjunction": 4702515842333658113, "NextMercuryConjunction": 4702516113112504592, - "NextMercuryGreatestElongation": 4702516168950258774, - "NextMercuryGreatestElongationEast": 4702516168950258774, - "NextMercuryGreatestElongationWest": 4702516272124990196, + "NextMercuryGreatestElongation": 4702516168964291540, + "NextMercuryGreatestElongationEast": 4702516168964291540, + "NextMercuryGreatestElongationWest": 4702516272138538868, "NextMercuryInferiorConjunction": 4702516215618185957, "NextMercuryProgradeToRetrograde": 4702516192756767854, "NextMercuryRetrograde": 4702516192756767854, @@ -101,18 +101,18 @@ "tt_jd_bits": 4702516350898186058, "events": { "LastMercuryConjunction": 4702516338722813686, - "LastMercuryGreatestElongation": 4702516272124990196, - "LastMercuryGreatestElongationEast": 4702516168950258774, - "LastMercuryGreatestElongationWest": 4702516272124990196, + "LastMercuryGreatestElongation": 4702516272138538880, + "LastMercuryGreatestElongationEast": 4702516168964291462, + "LastMercuryGreatestElongationWest": 4702516272138538880, "LastMercuryInferiorConjunction": 4702516215618185957, "LastMercuryProgradeToRetrograde": 4702516192756767854, "LastMercuryRetrograde": 4702516241949378772, "LastMercuryRetrogradeToPrograde": 4702516241949378772, "LastMercurySuperiorConjunction": 4702516338722813686, "NextMercuryConjunction": 4702516485116601417, - "NextMercuryGreatestElongation": 4702516427393566708, - "NextMercuryGreatestElongationEast": 4702516427393566708, - "NextMercuryGreatestElongationWest": 4702516518809411766, + "NextMercuryGreatestElongation": 4702516427408905206, + "NextMercuryGreatestElongationEast": 4702516427408905206, + "NextMercuryGreatestElongationWest": 4702516518824239248, "NextMercuryInferiorConjunction": 4702516485116601417, "NextMercuryProgradeToRetrograde": 4702516455638674037, "NextMercuryRetrograde": 4702516455638674037, @@ -125,18 +125,18 @@ "tt_jd_bits": 4702516645382593853, "events": { "LastMercuryConjunction": 4702516582722776278, - "LastMercuryGreatestElongation": 4702516518809411766, - "LastMercuryGreatestElongationEast": 4702516427393566708, - "LastMercuryGreatestElongationWest": 4702516518809411766, + "LastMercuryGreatestElongation": 4702516518824239212, + "LastMercuryGreatestElongationEast": 4702516427408905118, + "LastMercuryGreatestElongationWest": 4702516518824239212, "LastMercuryInferiorConjunction": 4702516485116601417, "LastMercuryProgradeToRetrograde": 4702516455638674037, "LastMercuryRetrograde": 4702516503104142494, "LastMercuryRetrogradeToPrograde": 4702516503104142494, "LastMercurySuperiorConjunction": 4702516582722776278, "NextMercuryConjunction": 4702516713848016976, - "NextMercuryGreatestElongation": 4702516676815409320, - "NextMercuryGreatestElongationEast": 4702516676815409320, - "NextMercuryGreatestElongationWest": 4702516761391995304, + "NextMercuryGreatestElongation": 4702516676832053416, + "NextMercuryGreatestElongationEast": 4702516676832053416, + "NextMercuryGreatestElongationWest": 4702516761408444942, "NextMercuryInferiorConjunction": 4702516713848016976, "NextMercuryProgradeToRetrograde": 4702516693552578024, "NextMercuryRetrograde": 4702516693552578024, @@ -149,18 +149,18 @@ "tt_jd_bits": 4702516939867001648, "events": { "LastMercuryConjunction": 4702516861148400598, - "LastMercuryGreatestElongation": 4702516915676189034, - "LastMercuryGreatestElongationEast": 4702516915676189034, - "LastMercuryGreatestElongationWest": 4702516761391995304, + "LastMercuryGreatestElongation": 4702516915691029156, + "LastMercuryGreatestElongationEast": 4702516915691029156, + "LastMercuryGreatestElongationWest": 4702516761408444954, "LastMercuryInferiorConjunction": 4702516713848016977, "LastMercuryProgradeToRetrograde": 4702516935346144557, "LastMercuryRetrograde": 4702516935346144557, "LastMercuryRetrogradeToPrograde": 4702516737012932097, "LastMercurySuperiorConjunction": 4702516861148400598, "NextMercuryConjunction": 4702516957334636579, - "NextMercuryGreatestElongation": 4702517016444139054, - "NextMercuryGreatestElongationEast": 4702517173318272539, - "NextMercuryGreatestElongationWest": 4702517016444139054, + "NextMercuryGreatestElongation": 4702517016458024438, + "NextMercuryGreatestElongationEast": 4702517173332814694, + "NextMercuryGreatestElongationWest": 4702517016458024438, "NextMercuryInferiorConjunction": 4702516957334636579, "NextMercuryProgradeToRetrograde": 4702517201445496464, "NextMercuryRetrograde": 4702516983785102932, @@ -173,18 +173,18 @@ "tt_jd_bits": 4702517232472386106, "events": { "LastMercuryConjunction": 4702517089109298299, - "LastMercuryGreatestElongation": 4702517173318272539, - "LastMercuryGreatestElongationEast": 4702517173318272539, - "LastMercuryGreatestElongationWest": 4702517016444139054, + "LastMercuryGreatestElongation": 4702517173332814694, + "LastMercuryGreatestElongationEast": 4702517173332814694, + "LastMercuryGreatestElongationWest": 4702517016458024422, "LastMercuryInferiorConjunction": 4702516957334636579, "LastMercuryProgradeToRetrograde": 4702517201445496464, "LastMercuryRetrograde": 4702517201445496464, "LastMercuryRetrogradeToPrograde": 4702516983785102932, "LastMercurySuperiorConjunction": 4702517089109298299, "NextMercuryConjunction": 4702517232496644558, - "NextMercuryGreatestElongation": 4702517267523017351, - "NextMercuryGreatestElongationEast": 4702517423885023423, - "NextMercuryGreatestElongationWest": 4702517267523017351, + "NextMercuryGreatestElongation": 4702517267537720868, + "NextMercuryGreatestElongationEast": 4702517423902499600, + "NextMercuryGreatestElongationWest": 4702517267537720868, "NextMercuryInferiorConjunction": 4702517232496644558, "NextMercuryProgradeToRetrograde": 4702517443160979516, "NextMercuryRetrograde": 4702517251119985899, @@ -197,18 +197,18 @@ "tt_jd_bits": 4702517526956793901, "events": { "LastMercuryConjunction": 4702517463904026767, - "LastMercuryGreatestElongation": 4702517507408074075, - "LastMercuryGreatestElongationEast": 4702517423885023423, - "LastMercuryGreatestElongationWest": 4702517507408074075, + "LastMercuryGreatestElongation": 4702517507425920882, + "LastMercuryGreatestElongationEast": 4702517423902499522, + "LastMercuryGreatestElongationWest": 4702517507425920882, "LastMercuryInferiorConjunction": 4702517463904026767, "LastMercuryProgradeToRetrograde": 4702517443160979516, "LastMercuryRetrograde": 4702517485795821955, "LastMercuryRetrogradeToPrograde": 4702517485795821955, "LastMercurySuperiorConjunction": 4702517325494178527, "NextMercuryConjunction": 4702517607838363499, - "NextMercuryGreatestElongation": 4702517663250942581, - "NextMercuryGreatestElongationEast": 4702517663250942581, - "NextMercuryGreatestElongationWest": 4702517761036753343, + "NextMercuryGreatestElongation": 4702517663264503410, + "NextMercuryGreatestElongationEast": 4702517663264503410, + "NextMercuryGreatestElongationWest": 4702517761050003886, "NextMercuryInferiorConjunction": 4702517700904702531, "NextMercuryProgradeToRetrograde": 4702517679268452240, "NextMercuryRetrograde": 4702517679268452240, @@ -221,18 +221,18 @@ "tt_jd_bits": 4702517821441201696, "events": { "LastMercuryConjunction": 4702517700904702531, - "LastMercuryGreatestElongation": 4702517761036753343, - "LastMercuryGreatestElongationEast": 4702517663250942581, - "LastMercuryGreatestElongationWest": 4702517761036753343, + "LastMercuryGreatestElongation": 4702517761050003884, + "LastMercuryGreatestElongationEast": 4702517663264503304, + "LastMercuryGreatestElongationWest": 4702517761050003884, "LastMercuryInferiorConjunction": 4702517700904702531, "LastMercuryProgradeToRetrograde": 4702517679268452240, "LastMercuryRetrograde": 4702517727533532623, "LastMercuryRetrogradeToPrograde": 4702517727533532623, "LastMercurySuperiorConjunction": 4702517607838363499, "NextMercuryConjunction": 4702517839629280277, - "NextMercuryGreatestElongation": 4702517918744774585, - "NextMercuryGreatestElongationEast": 4702517918744774585, - "NextMercuryGreatestElongationWest": 4702518015910509367, + "NextMercuryGreatestElongation": 4702517918758347994, + "NextMercuryGreatestElongationEast": 4702517918758347994, + "NextMercuryGreatestElongationWest": 4702518015925177374, "NextMercuryInferiorConjunction": 4702517978575631757, "NextMercuryProgradeToRetrograde": 4702517946845947290, "NextMercuryRetrograde": 4702517946845947290, @@ -245,18 +245,18 @@ "tt_jd_bits": 4702518115925609491, "events": { "LastMercuryConjunction": 4702518070563232332, - "LastMercuryGreatestElongation": 4702518015910509367, - "LastMercuryGreatestElongationEast": 4702517918744774585, - "LastMercuryGreatestElongationWest": 4702518015910509367, + "LastMercuryGreatestElongation": 4702518015925177318, + "LastMercuryGreatestElongationEast": 4702517918758347954, + "LastMercuryGreatestElongationWest": 4702518015925177318, "LastMercuryInferiorConjunction": 4702517978575631757, "LastMercuryProgradeToRetrograde": 4702517946845947290, "LastMercuryRetrograde": 4702517998394106572, "LastMercuryRetrogradeToPrograde": 4702517998394106572, "LastMercurySuperiorConjunction": 4702518070563232332, "NextMercuryConjunction": 4702518214046531455, - "NextMercuryGreatestElongation": 4702518170514547159, - "NextMercuryGreatestElongationEast": 4702518170514547159, - "NextMercuryGreatestElongationWest": 4702518253805075649, + "NextMercuryGreatestElongation": 4702518170532136764, + "NextMercuryGreatestElongationEast": 4702518170532136764, + "NextMercuryGreatestElongationWest": 4702518253821594028, "NextMercuryInferiorConjunction": 4702518214046531455, "NextMercuryProgradeToRetrograde": 4702518192496676191, "NextMercuryRetrograde": 4702518192496676191, @@ -269,18 +269,18 @@ "tt_jd_bits": 4702518410295211419, "events": { "LastMercuryConjunction": 4702518352733463896, - "LastMercuryGreatestElongation": 4702518253805075649, - "LastMercuryGreatestElongationEast": 4702518170514547159, - "LastMercuryGreatestElongationWest": 4702518253805075649, + "LastMercuryGreatestElongation": 4702518253821594002, + "LastMercuryGreatestElongationEast": 4702518170532136752, + "LastMercuryGreatestElongationWest": 4702518253821594002, "LastMercuryInferiorConjunction": 4702518214046531455, "LastMercuryProgradeToRetrograde": 4702518192496676191, "LastMercuryRetrograde": 4702518234697549877, "LastMercuryRetrogradeToPrograde": 4702518234697549877, "LastMercurySuperiorConjunction": 4702518352733463896, "NextMercuryConjunction": 4702518446318828400, - "NextMercuryGreatestElongation": 4702518411388403268, - "NextMercuryGreatestElongationEast": 4702518411388403268, - "NextMercuryGreatestElongationWest": 4702518506149917346, + "NextMercuryGreatestElongation": 4702518411403139952, + "NextMercuryGreatestElongationEast": 4702518411403139952, + "NextMercuryGreatestElongationWest": 4702518506164237174, "NextMercuryInferiorConjunction": 4702518446318828400, "NextMercuryProgradeToRetrograde": 4702518425041010579, "NextMercuryRetrograde": 4702518425041010579, @@ -293,18 +293,18 @@ "tt_jd_bits": 4702518704779619214, "events": { "LastMercuryConjunction": 4702518590122908031, - "LastMercuryGreatestElongation": 4702518663547781058, - "LastMercuryGreatestElongationEast": 4702518663547781058, - "LastMercuryGreatestElongationWest": 4702518506149917346, + "LastMercuryGreatestElongation": 4702518663561366688, + "LastMercuryGreatestElongationEast": 4702518663561366688, + "LastMercuryGreatestElongationWest": 4702518506164237114, "LastMercuryInferiorConjunction": 4702518446318828400, "LastMercuryProgradeToRetrograde": 4702518691858759851, "LastMercuryRetrograde": 4702518691858759851, "LastMercuryRetrogradeToPrograde": 4702518473027842303, "LastMercurySuperiorConjunction": 4702518590122908031, "NextMercuryConjunction": 4702518723037148505, - "NextMercuryGreatestElongation": 4702518763677704678, - "NextMercuryGreatestElongationEast": 4702518916871828132, - "NextMercuryGreatestElongationWest": 4702518763677704678, + "NextMercuryGreatestElongation": 4702518763692090026, + "NextMercuryGreatestElongationEast": 4702518916888219704, + "NextMercuryGreatestElongationWest": 4702518763692090026, "NextMercuryInferiorConjunction": 4702518723037148505, "NextMercuryProgradeToRetrograde": 4702518941359525019, "NextMercuryRetrograde": 4702518744555821826, @@ -317,18 +317,18 @@ "tt_jd_bits": 4702518999260472725, "events": { "LastMercuryConjunction": 4702518964116923787, - "LastMercuryGreatestElongation": 4702518916871828132, - "LastMercuryGreatestElongationEast": 4702518916871828132, - "LastMercuryGreatestElongationWest": 4702518763677704678, + "LastMercuryGreatestElongation": 4702518916888219650, + "LastMercuryGreatestElongationEast": 4702518916888219650, + "LastMercuryGreatestElongationWest": 4702518763692089984, "LastMercuryInferiorConjunction": 4702518964116923787, "LastMercuryProgradeToRetrograde": 4702518941359525019, "LastMercuryRetrograde": 4702518983649229245, "LastMercuryRetrogradeToPrograde": 4702518983649229245, "LastMercurySuperiorConjunction": 4702518817451672580, "NextMercuryConjunction": 4702519095403444504, - "NextMercuryGreatestElongation": 4702519000844792180, - "NextMercuryGreatestElongationEast": 4702519159807641136, - "NextMercuryGreatestElongationWest": 4702519000844792180, + "NextMercuryGreatestElongation": 4702519000861135210, + "NextMercuryGreatestElongationEast": 4702519159822383510, + "NextMercuryGreatestElongationWest": 4702519000861135210, "NextMercuryInferiorConjunction": 4702519193280286738, "NextMercuryProgradeToRetrograde": 4702519172441957709, "NextMercuryRetrograde": 4702519172441957709, @@ -341,18 +341,18 @@ "tt_jd_bits": 4702519291865832328, "events": { "LastMercuryConjunction": 4702519193280286738, - "LastMercuryGreatestElongation": 4702519251788265024, - "LastMercuryGreatestElongationEast": 4702519159807641136, - "LastMercuryGreatestElongationWest": 4702519251788265024, + "LastMercuryGreatestElongation": 4702519251803209778, + "LastMercuryGreatestElongationEast": 4702519159822383408, + "LastMercuryGreatestElongationWest": 4702519251803209778, "LastMercuryInferiorConjunction": 4702519193280286738, "LastMercuryProgradeToRetrograde": 4702519172441957709, "LastMercuryRetrograde": 4702519219748035634, "LastMercuryRetrogradeToPrograde": 4702519219748035634, "LastMercurySuperiorConjunction": 4702519095403444504, "NextMercuryConjunction": 4702519340440980406, - "NextMercuryGreatestElongation": 4702519407867561434, - "NextMercuryGreatestElongationEast": 4702519407867561434, - "NextMercuryGreatestElongationWest": 4702519510565813432, + "NextMercuryGreatestElongation": 4702519407882303708, + "NextMercuryGreatestElongationEast": 4702519407882303708, + "NextMercuryGreatestElongationWest": 4702519510579411654, "NextMercuryInferiorConjunction": 4702519465689299262, "NextMercuryProgradeToRetrograde": 4702519436384211181, "NextMercuryRetrograde": 4702519436384211181, @@ -365,18 +365,18 @@ "tt_jd_bits": 4702519586350240123, "events": { "LastMercuryConjunction": 4702519565682294021, - "LastMercuryGreatestElongation": 4702519510565813432, - "LastMercuryGreatestElongationEast": 4702519407867561434, - "LastMercuryGreatestElongationWest": 4702519510565813432, + "LastMercuryGreatestElongation": 4702519510579411738, + "LastMercuryGreatestElongationEast": 4702519407882303708, + "LastMercuryGreatestElongationWest": 4702519510579411738, "LastMercuryInferiorConjunction": 4702519465689299262, "LastMercuryProgradeToRetrograde": 4702519436384211181, "LastMercuryRetrograde": 4702519489175692577, "LastMercuryRetrogradeToPrograde": 4702519489175692577, "LastMercurySuperiorConjunction": 4702519565682294021, "NextMercuryConjunction": 4702519713953418419, - "NextMercuryGreatestElongation": 4702519663126492224, - "NextMercuryGreatestElongationEast": 4702519663126492224, - "NextMercuryGreatestElongationWest": 4702519748585505290, + "NextMercuryGreatestElongation": 4702519663143561768, + "NextMercuryGreatestElongationEast": 4702519663143561768, + "NextMercuryGreatestElongationWest": 4702519748601404264, "NextMercuryInferiorConjunction": 4702519713953418419, "NextMercuryProgradeToRetrograde": 4702519689585460749, "NextMercuryRetrograde": 4702519689585460749, @@ -389,18 +389,18 @@ "tt_jd_bits": 4702519880834647918, "events": { "LastMercuryConjunction": 4702519835754607210, - "LastMercuryGreatestElongation": 4702519748585505290, - "LastMercuryGreatestElongationEast": 4702519663126492224, - "LastMercuryGreatestElongationWest": 4702519748585505290, + "LastMercuryGreatestElongation": 4702519748601404294, + "LastMercuryGreatestElongationEast": 4702519663143561734, + "LastMercuryGreatestElongationWest": 4702519748601404294, "LastMercuryInferiorConjunction": 4702519713953418419, "LastMercuryProgradeToRetrograde": 4702519689585460749, "LastMercuryRetrograde": 4702519732579863625, "LastMercuryRetrogradeToPrograde": 4702519732579863625, "LastMercurySuperiorConjunction": 4702519835754607210, "NextMercuryConjunction": 4702519941419670319, - "NextMercuryGreatestElongation": 4702519908239657030, - "NextMercuryGreatestElongationEast": 4702519908239657030, - "NextMercuryGreatestElongationWest": 4702519997764270282, + "NextMercuryGreatestElongation": 4702519908254158500, + "NextMercuryGreatestElongationEast": 4702519908254158500, + "NextMercuryGreatestElongationWest": 4702519997780900876, "NextMercuryInferiorConjunction": 4702519941419670319, "NextMercuryProgradeToRetrograde": 4702519920986509034, "NextMercuryRetrograde": 4702519920986509034, @@ -413,18 +413,18 @@ "tt_jd_bits": 4702520175319055713, "events": { "LastMercuryConjunction": 4702520090412489250, - "LastMercuryGreatestElongation": 4702520152241291570, - "LastMercuryGreatestElongationEast": 4702520152241291570, - "LastMercuryGreatestElongationWest": 4702519997764270282, + "LastMercuryGreatestElongation": 4702520152254566946, + "LastMercuryGreatestElongationEast": 4702520152254566946, + "LastMercuryGreatestElongationWest": 4702519997780900858, "LastMercuryInferiorConjunction": 4702519941419670319, "LastMercuryProgradeToRetrograde": 4702519920986509034, "LastMercuryRetrograde": 4702519967286480456, "LastMercuryRetrogradeToPrograde": 4702519967286480456, "LastMercurySuperiorConjunction": 4702520090412489250, "NextMercuryConjunction": 4702520206736693296, - "NextMercuryGreatestElongation": 4702520256440152488, - "NextMercuryGreatestElongationEast": 4702520409386692096, - "NextMercuryGreatestElongationWest": 4702520256440152488, + "NextMercuryGreatestElongation": 4702520256453372080, + "NextMercuryGreatestElongationEast": 4702520409403201232, + "NextMercuryGreatestElongationWest": 4702520256453372080, "NextMercuryInferiorConjunction": 4702520206736693296, "NextMercuryProgradeToRetrograde": 4702520180247569541, "NextMercuryRetrograde": 4702520180247569541, @@ -437,18 +437,18 @@ "tt_jd_bits": 4702520469803463508, "events": { "LastMercuryConjunction": 4702520463385818879, - "LastMercuryGreatestElongation": 4702520409386692096, - "LastMercuryGreatestElongationEast": 4702520409386692096, - "LastMercuryGreatestElongationWest": 4702520256440152488, + "LastMercuryGreatestElongation": 4702520409403201214, + "LastMercuryGreatestElongationEast": 4702520409403201214, + "LastMercuryGreatestElongationWest": 4702520256453372060, "LastMercuryInferiorConjunction": 4702520463385818879, "LastMercuryProgradeToRetrograde": 4702520437091377204, "LastMercuryRetrograde": 4702520437091377204, "LastMercuryRetrogradeToPrograde": 4702520231917546244, "LastMercurySuperiorConjunction": 4702520314863970518, "NextMercuryConjunction": 4702520574687109103, - "NextMercuryGreatestElongation": 4702520496906600118, - "NextMercuryGreatestElongationEast": 4702520656467225020, - "NextMercuryGreatestElongationWest": 4702520496906600118, + "NextMercuryGreatestElongation": 4702520496921901816, + "NextMercuryGreatestElongationEast": 4702520656483159404, + "NextMercuryGreatestElongationWest": 4702520496921901816, "NextMercuryInferiorConjunction": 4702520690411075400, "NextMercuryProgradeToRetrograde": 4702520670223580243, "NextMercuryRetrograde": 4702520481419718472, @@ -461,18 +461,18 @@ "tt_jd_bits": 4702520764287871303, "events": { "LastMercuryConjunction": 4702520690411075400, - "LastMercuryGreatestElongation": 4702520743831991490, - "LastMercuryGreatestElongationEast": 4702520656467225020, - "LastMercuryGreatestElongationWest": 4702520743831991490, + "LastMercuryGreatestElongation": 4702520743848924086, + "LastMercuryGreatestElongationEast": 4702520656483159370, + "LastMercuryGreatestElongationWest": 4702520743848924086, "LastMercuryInferiorConjunction": 4702520690411075400, "LastMercuryProgradeToRetrograde": 4702520670223580243, "LastMercuryRetrograde": 4702520715381882078, "LastMercuryRetrogradeToPrograde": 4702520715381882078, "LastMercurySuperiorConjunction": 4702520574687109103, "NextMercuryConjunction": 4702520839839900636, - "NextMercuryGreatestElongation": 4702520897340997704, - "NextMercuryGreatestElongationEast": 4702520897340997704, - "NextMercuryGreatestElongationWest": 4702521001386632038, + "NextMercuryGreatestElongation": 4702520897355589528, + "NextMercuryGreatestElongationEast": 4702520897355589528, + "NextMercuryGreatestElongationWest": 4702521001401325168, "NextMercuryInferiorConjunction": 4702520947056955004, "NextMercuryProgradeToRetrograde": 4702520923220032935, "NextMercuryRetrograde": 4702520923220032935, @@ -485,18 +485,18 @@ "tt_jd_bits": 4702521058772279098, "events": { "LastMercuryConjunction": 4702520947056955004, - "LastMercuryGreatestElongation": 4702521001386632038, - "LastMercuryGreatestElongationEast": 4702520897340997704, - "LastMercuryGreatestElongationWest": 4702521001386632038, + "LastMercuryGreatestElongation": 4702521001401325156, + "LastMercuryGreatestElongationEast": 4702520897355589540, + "LastMercuryGreatestElongationWest": 4702521001401325156, "LastMercuryInferiorConjunction": 4702520947056955004, "LastMercuryProgradeToRetrograde": 4702520923220032935, "LastMercuryRetrograde": 4702520973149571695, "LastMercuryRetrogradeToPrograde": 4702520973149571695, "LastMercurySuperiorConjunction": 4702520839839900636, "NextMercuryConjunction": 4702521064688048497, - "NextMercuryGreatestElongation": 4702521155637159218, - "NextMercuryGreatestElongationEast": 4702521155637159218, - "NextMercuryGreatestElongationWest": 4702521245579987010, + "NextMercuryGreatestElongation": 4702521155652682372, + "NextMercuryGreatestElongationEast": 4702521155652682372, + "NextMercuryGreatestElongationWest": 4702521245595002960, "NextMercuryInferiorConjunction": 4702521212214219299, "NextMercuryProgradeToRetrograde": 4702521183874755452, "NextMercuryRetrograde": 4702521183874755452, @@ -509,18 +509,18 @@ "tt_jd_bits": 4702521351377638701, "events": { "LastMercuryConjunction": 4702521314052968909, - "LastMercuryGreatestElongation": 4702521245579987010, - "LastMercuryGreatestElongationEast": 4702521155637159218, - "LastMercuryGreatestElongationWest": 4702521245579987010, + "LastMercuryGreatestElongation": 4702521245595003020, + "LastMercuryGreatestElongationEast": 4702521155652682288, + "LastMercuryGreatestElongationWest": 4702521245595003020, "LastMercuryInferiorConjunction": 4702521212214219299, "LastMercuryProgradeToRetrograde": 4702521183874755452, "LastMercuryRetrograde": 4702521230067645603, "LastMercuryRetrogradeToPrograde": 4702521230067645603, "LastMercurySuperiorConjunction": 4702521314052968909, "NextMercuryConjunction": 4702521439983297044, - "NextMercuryGreatestElongation": 4702521404319011076, - "NextMercuryGreatestElongationEast": 4702521404319011076, - "NextMercuryGreatestElongationWest": 4702521489808972580, + "NextMercuryGreatestElongation": 4702521404334708414, + "NextMercuryGreatestElongationEast": 4702521404334708414, + "NextMercuryGreatestElongationWest": 4702521489825405132, "NextMercuryInferiorConjunction": 4702521439983297044, "NextMercuryProgradeToRetrograde": 4702521419810530294, "NextMercuryRetrograde": 4702521419810530294, @@ -533,18 +533,18 @@ "tt_jd_bits": 4702521645747215774, "events": { "LastMercuryConjunction": 4702521588476144674, - "LastMercuryGreatestElongation": 4702521643498354750, - "LastMercuryGreatestElongationEast": 4702521643498354750, - "LastMercuryGreatestElongationWest": 4702521489808972580, + "LastMercuryGreatestElongation": 4702521643511940226, + "LastMercuryGreatestElongationEast": 4702521643511940226, + "LastMercuryGreatestElongationWest": 4702521489825405086, "LastMercuryInferiorConjunction": 4702521439983297045, "LastMercuryProgradeToRetrograde": 4702521419810530294, "LastMercuryRetrograde": 4702521463851454322, "LastMercuryRetrogradeToPrograde": 4702521463851454322, "LastMercurySuperiorConjunction": 4702521588476144674, "NextMercuryConjunction": 4702521687955546644, - "NextMercuryGreatestElongation": 4702521745776294908, - "NextMercuryGreatestElongationEast": 4702521901759695448, - "NextMercuryGreatestElongationWest": 4702521745776294908, + "NextMercuryGreatestElongation": 4702521745789893332, + "NextMercuryGreatestElongationEast": 4702521901774276372, + "NextMercuryGreatestElongationWest": 4702521745789893332, "NextMercuryInferiorConjunction": 4702521687955546644, "NextMercuryProgradeToRetrograde": 4702521665590823069, "NextMercuryRetrograde": 4702521665590823069, @@ -557,18 +557,18 @@ "tt_jd_bits": 4702521940231623569, "events": { "LastMercuryConjunction": 4702521814925417896, - "LastMercuryGreatestElongation": 4702521901759695448, - "LastMercuryGreatestElongationEast": 4702521901759695448, - "LastMercuryGreatestElongationWest": 4702521745776294908, + "LastMercuryGreatestElongation": 4702521901774276266, + "LastMercuryGreatestElongationEast": 4702521901774276266, + "LastMercuryGreatestElongationWest": 4702521745789893278, "LastMercuryInferiorConjunction": 4702521687955546644, "LastMercuryProgradeToRetrograde": 4702521930001963545, "LastMercuryRetrograde": 4702521930001963545, "LastMercuryRetrogradeToPrograde": 4702521714351581362, "LastMercurySuperiorConjunction": 4702521814925417896, "NextMercuryConjunction": 4702521960203324248, - "NextMercuryGreatestElongation": 4702521994349326124, - "NextMercuryGreatestElongationEast": 4702522151692592382, - "NextMercuryGreatestElongationWest": 4702521994349326124, + "NextMercuryGreatestElongation": 4702521994365018122, + "NextMercuryGreatestElongationEast": 4702522151708949490, + "NextMercuryGreatestElongationWest": 4702521994365018122, "NextMercuryInferiorConjunction": 4702521960203324248, "NextMercuryProgradeToRetrograde": 4702522169466930895, "NextMercuryRetrograde": 4702521978382283988, @@ -581,18 +581,18 @@ "tt_jd_bits": 4702522234716031364, "events": { "LastMercuryConjunction": 4702522189915632202, - "LastMercuryGreatestElongation": 4702522151692592382, - "LastMercuryGreatestElongationEast": 4702522151692592382, - "LastMercuryGreatestElongationWest": 4702521994349326124, + "LastMercuryGreatestElongation": 4702522151708949504, + "LastMercuryGreatestElongationEast": 4702522151708949504, + "LastMercuryGreatestElongationWest": 4702521994365018218, "LastMercuryInferiorConjunction": 4702522189915632202, "LastMercuryProgradeToRetrograde": 4702522169466930895, "LastMercuryRetrograde": 4702522212551135496, "LastMercuryRetrogradeToPrograde": 4702522212551135496, "LastMercurySuperiorConjunction": 4702522055409754957, "NextMercuryConjunction": 4702522335992123399, - "NextMercuryGreatestElongation": 4702522235726073990, - "NextMercuryGreatestElongationEast": 4702522390626841338, - "NextMercuryGreatestElongationWest": 4702522235726073990, + "NextMercuryGreatestElongation": 4702522235743574904, + "NextMercuryGreatestElongationEast": 4702522390640060732, + "NextMercuryGreatestElongationWest": 4702522235743574904, "NextMercuryInferiorConjunction": 4702522430424473383, "NextMercuryProgradeToRetrograde": 4702522408586365041, "NextMercuryRetrograde": 4702522408586365041, @@ -605,18 +605,18 @@ "tt_jd_bits": 4702522529200439159, "events": { "LastMercuryConjunction": 4702522430424473383, - "LastMercuryGreatestElongation": 4702522490139252698, - "LastMercuryGreatestElongationEast": 4702522390626841338, - "LastMercuryGreatestElongationWest": 4702522490139252698, + "LastMercuryGreatestElongation": 4702522490153666628, + "LastMercuryGreatestElongationEast": 4702522390640060720, + "LastMercuryGreatestElongationWest": 4702522490153666628, "LastMercuryInferiorConjunction": 4702522430424473383, "LastMercuryProgradeToRetrograde": 4702522408586365041, "LastMercuryRetrograde": 4702522456977394434, "LastMercuryRetrogradeToPrograde": 4702522456977394434, "LastMercurySuperiorConjunction": 4702522335992123399, "NextMercuryConjunction": 4702522565386831568, - "NextMercuryGreatestElongation": 4702522647521689318, - "NextMercuryGreatestElongationEast": 4702522647521689318, - "NextMercuryGreatestElongationWest": 4702522742951944286, + "NextMercuryGreatestElongation": 4702522647535759292, + "NextMercuryGreatestElongationEast": 4702522647535759292, + "NextMercuryGreatestElongationWest": 4702522742967621182, "NextMercuryInferiorConjunction": 4702522707070699789, "NextMercuryProgradeToRetrograde": 4702522675597363115, "NextMercuryRetrograde": 4702522675597363115, @@ -629,18 +629,18 @@ "tt_jd_bits": 4702522823681292670, "events": { "LastMercuryConjunction": 4702522799201581023, - "LastMercuryGreatestElongation": 4702522742951944286, - "LastMercuryGreatestElongationEast": 4702522647521689318, - "LastMercuryGreatestElongationWest": 4702522742951944286, + "LastMercuryGreatestElongation": 4702522742967621092, + "LastMercuryGreatestElongationEast": 4702522647535759288, + "LastMercuryGreatestElongationWest": 4702522742967621092, "LastMercuryInferiorConjunction": 4702522707070699789, "LastMercuryProgradeToRetrograde": 4702522675597363115, "LastMercuryRetrograde": 4702522726137486221, "LastMercuryRetrogradeToPrograde": 4702522726137486221, "LastMercurySuperiorConjunction": 4702522799201581023, "NextMercuryConjunction": 4702522940029247954, - "NextMercuryGreatestElongation": 4702522898575649936, - "NextMercuryGreatestElongationEast": 4702522898575649936, - "NextMercuryGreatestElongationWest": 4702522981865929828, + "NextMercuryGreatestElongation": 4702522898592885822, + "NextMercuryGreatestElongationEast": 4702522898592885822, + "NextMercuryGreatestElongationWest": 4702522981882260074, "NextMercuryInferiorConjunction": 4702522940029247954, "NextMercuryProgradeToRetrograde": 4702522918977879221, "NextMercuryRetrograde": 4702522918977879221, @@ -653,18 +653,18 @@ "tt_jd_bits": 4702523118165700465, "events": { "LastMercuryConjunction": 4702523081984198609, - "LastMercuryGreatestElongation": 4702522981865929828, - "LastMercuryGreatestElongationEast": 4702522898575649936, - "LastMercuryGreatestElongationWest": 4702522981865929828, + "LastMercuryGreatestElongation": 4702522981882260184, + "LastMercuryGreatestElongationEast": 4702522898592885860, + "LastMercuryGreatestElongationWest": 4702522981882260184, "LastMercuryInferiorConjunction": 4702522940029247954, "LastMercuryProgradeToRetrograde": 4702522918977879221, "LastMercuryRetrograde": 4702522961395260374, "LastMercuryRetrogradeToPrograde": 4702522961395260374, "LastMercurySuperiorConjunction": 4702523081984198609, "NextMercuryConjunction": 4702523174805169307, - "NextMercuryGreatestElongation": 4702523138476856320, - "NextMercuryGreatestElongationEast": 4702523138476856320, - "NextMercuryGreatestElongationWest": 4702523234940177092, + "NextMercuryGreatestElongation": 4702523138491337214, + "NextMercuryGreatestElongationEast": 4702523138491337214, + "NextMercuryGreatestElongationWest": 4702523234953628014, "NextMercuryInferiorConjunction": 4702523174805169307, "NextMercuryProgradeToRetrograde": 4702523153306149126, "NextMercuryRetrograde": 4702523153306149126, @@ -677,18 +677,18 @@ "tt_jd_bits": 4702523410771060068, "events": { "LastMercuryConjunction": 4702523315913884554, - "LastMercuryGreatestElongation": 4702523392683899186, - "LastMercuryGreatestElongationEast": 4702523392683899186, - "LastMercuryGreatestElongationWest": 4702523234940177092, + "LastMercuryGreatestElongation": 4702523392698130554, + "LastMercuryGreatestElongationEast": 4702523392698130554, + "LastMercuryGreatestElongationWest": 4702523234953627962, "LastMercuryInferiorConjunction": 4702523174805169307, "LastMercuryProgradeToRetrograde": 4702523153306149126, "LastMercuryRetrograde": 4702523201486280816, "LastMercuryRetrogradeToPrograde": 4702523201486280816, "LastMercurySuperiorConjunction": 4702523315913884554, "NextMercuryConjunction": 4702523452494055982, - "NextMercuryGreatestElongation": 4702523491108716320, - "NextMercuryGreatestElongationEast": 4702523645057906214, - "NextMercuryGreatestElongationWest": 4702523491108716320, + "NextMercuryGreatestElongation": 4702523491122233770, + "NextMercuryGreatestElongationEast": 4702523645074148714, + "NextMercuryGreatestElongationWest": 4702523491122233770, "NextMercuryInferiorConjunction": 4702523452494055982, "NextMercuryProgradeToRetrograde": 4702523420798388038, "NextMercuryRetrograde": 4702523420798388038, @@ -701,18 +701,18 @@ "tt_jd_bits": 4702523705255467863, "events": { "LastMercuryConjunction": 4702523690159178518, - "LastMercuryGreatestElongation": 4702523645057906214, - "LastMercuryGreatestElongationEast": 4702523645057906214, - "LastMercuryGreatestElongationWest": 4702523491108716320, + "LastMercuryGreatestElongation": 4702523645074148742, + "LastMercuryGreatestElongationEast": 4702523645074148742, + "LastMercuryGreatestElongationWest": 4702523491122233806, "LastMercuryInferiorConjunction": 4702523690159178518, "LastMercuryProgradeToRetrograde": 4702523668134553002, "LastMercuryRetrograde": 4702523668134553002, "LastMercuryRetrogradeToPrograde": 4702523472995249442, "LastMercurySuperiorConjunction": 4702523545107504229, "NextMercuryConjunction": 4702523825974003923, - "NextMercuryGreatestElongation": 4702523728526702386, - "NextMercuryGreatestElongationEast": 4702523886760151001, - "NextMercuryGreatestElongationWest": 4702523728526702386, + "NextMercuryGreatestElongation": 4702523728544256114, + "NextMercuryGreatestElongationEast": 4702523886775141164, + "NextMercuryGreatestElongationWest": 4702523728544256114, "NextMercuryInferiorConjunction": 4702523920925552431, "NextMercuryProgradeToRetrograde": 4702523899843708997, "NextMercuryRetrograde": 4702523710311352255, @@ -725,18 +725,18 @@ "tt_jd_bits": 4702523999739900513, "events": { "LastMercuryConjunction": 4702523920925552431, - "LastMercuryGreatestElongation": 4702523980317323895, - "LastMercuryGreatestElongationEast": 4702523886760151001, - "LastMercuryGreatestElongationWest": 4702523980317323895, + "LastMercuryGreatestElongation": 4702523980332476038, + "LastMercuryGreatestElongationEast": 4702523886775141140, + "LastMercuryGreatestElongationWest": 4702523980332476038, "LastMercuryInferiorConjunction": 4702523920925552431, "LastMercuryProgradeToRetrograde": 4702523899843708997, "LastMercuryRetrograde": 4702523947550947754, "LastMercuryRetrogradeToPrograde": 4702523947550947754, "LastMercurySuperiorConjunction": 4702523825974003923, "NextMercuryConjunction": 4702524066355571498, - "NextMercuryGreatestElongation": 4702524137222347329, - "NextMercuryGreatestElongationEast": 4702524137222347329, - "NextMercuryGreatestElongationWest": 4702524238543408015, + "NextMercuryGreatestElongation": 4702524137235696890, + "NextMercuryGreatestElongationEast": 4702524137235696890, + "NextMercuryGreatestElongationWest": 4702524238557863152, "NextMercuryInferiorConjunction": 4702524196196683266, "NextMercuryProgradeToRetrograde": 4702524165658089734, "NextMercuryRetrograde": 4702524165658089734, @@ -749,18 +749,18 @@ "tt_jd_bits": 4702524294224308308, "events": { "LastMercuryConjunction": 4702524292627632559, - "LastMercuryGreatestElongation": 4702524238543408015, - "LastMercuryGreatestElongationEast": 4702524137222347329, - "LastMercuryGreatestElongationWest": 4702524238543408015, + "LastMercuryGreatestElongation": 4702524238557863064, + "LastMercuryGreatestElongationEast": 4702524137235696802, + "LastMercuryGreatestElongationWest": 4702524238557863064, "LastMercuryInferiorConjunction": 4702524196196683266, "LastMercuryProgradeToRetrograde": 4702524165658089734, "LastMercuryRetrograde": 4702524218538082530, "LastMercuryRetrogradeToPrograde": 4702524218538082530, "LastMercurySuperiorConjunction": 4702524292627632559, "NextMercuryConjunction": 4702524440149507983, - "NextMercuryGreatestElongation": 4702524391332775583, - "NextMercuryGreatestElongationEast": 4702524391332775583, - "NextMercuryGreatestElongationWest": 4702524475875041845, + "NextMercuryGreatestElongation": 4702524391350449450, + "NextMercuryGreatestElongationEast": 4702524391350449450, + "NextMercuryGreatestElongationWest": 4702524475890354962, "NextMercuryInferiorConjunction": 4702524440149507983, "NextMercuryProgradeToRetrograde": 4702524416759873794, "NextMercuryRetrograde": 4702524416759873794, @@ -773,18 +773,18 @@ "tt_jd_bits": 4702524588708716103, "events": { "LastMercuryConjunction": 4702524567618737200, - "LastMercuryGreatestElongation": 4702524475875041845, - "LastMercuryGreatestElongationEast": 4702524391332775583, - "LastMercuryGreatestElongationWest": 4702524475875041845, + "LastMercuryGreatestElongation": 4702524475890354930, + "LastMercuryGreatestElongationEast": 4702524391350449378, + "LastMercuryGreatestElongationWest": 4702524475890354930, "LastMercuryInferiorConjunction": 4702524440149507983, "LastMercuryProgradeToRetrograde": 4702524416759873794, "LastMercuryRetrograde": 4702524459255682255, "LastMercuryRetrogradeToPrograde": 4702524459255682255, "LastMercurySuperiorConjunction": 4702524567618737200, "NextMercuryConjunction": 4702524668431537048, - "NextMercuryGreatestElongation": 4702524635211713265, - "NextMercuryGreatestElongationEast": 4702524635211713265, - "NextMercuryGreatestElongationWest": 4702524726132446087, + "NextMercuryGreatestElongation": 4702524635226818146, + "NextMercuryGreatestElongationEast": 4702524635226818146, + "NextMercuryGreatestElongationWest": 4702524726148370178, "NextMercuryInferiorConjunction": 4702524668431537048, "NextMercuryProgradeToRetrograde": 4702524647783213168, "NextMercuryRetrograde": 4702524647783213168, @@ -797,18 +797,18 @@ "tt_jd_bits": 4702524883078293176, "events": { "LastMercuryConjunction": 4702524816548116739, - "LastMercuryGreatestElongation": 4702524881483918059, - "LastMercuryGreatestElongationEast": 4702524881483918059, - "LastMercuryGreatestElongationWest": 4702524726132446087, + "LastMercuryGreatestElongation": 4702524881497343832, + "LastMercuryGreatestElongationEast": 4702524881497343832, + "LastMercuryGreatestElongationWest": 4702524726148370152, "LastMercuryInferiorConjunction": 4702524668431537048, "LastMercuryProgradeToRetrograde": 4702524647783213168, "LastMercuryRetrograde": 4702524694684478611, "LastMercuryRetrogradeToPrograde": 4702524694684478611, "LastMercurySuperiorConjunction": 4702524816548116739, "NextMercuryConjunction": 4702524938110847797, - "NextMercuryGreatestElongation": 4702524985013843539, - "NextMercuryGreatestElongationEast": 4702525137585509339, - "NextMercuryGreatestElongationWest": 4702524985013843539, + "NextMercuryGreatestElongation": 4702524985027454150, + "NextMercuryGreatestElongationEast": 4702525137601386934, + "NextMercuryGreatestElongationWest": 4702524985027454150, "NextMercuryInferiorConjunction": 4702524938110847797, "NextMercuryProgradeToRetrograde": 4702524909970695194, "NextMercuryRetrograde": 4702524909970695194, @@ -821,18 +821,18 @@ "tt_jd_bits": 4702525177562700971, "events": { "LastMercuryConjunction": 4702525041304452565, - "LastMercuryGreatestElongation": 4702525137585509339, - "LastMercuryGreatestElongationEast": 4702525137585509339, - "LastMercuryGreatestElongationWest": 4702524985013843539, + "LastMercuryGreatestElongation": 4702525137601387030, + "LastMercuryGreatestElongationEast": 4702525137601387030, + "LastMercuryGreatestElongationWest": 4702524985027454120, "LastMercuryInferiorConjunction": 4702524938110847797, "LastMercuryProgradeToRetrograde": 4702525164696602537, "LastMercuryRetrograde": 4702525164696602537, "LastMercuryRetrogradeToPrograde": 4702524962358410619, "LastMercurySuperiorConjunction": 4702525041304452565, "NextMercuryConjunction": 4702525189836617901, - "NextMercuryGreatestElongation": 4702525223876510361, - "NextMercuryGreatestElongationEast": 4702525383587428469, - "NextMercuryGreatestElongationWest": 4702525223876510361, + "NextMercuryGreatestElongation": 4702525223892401632, + "NextMercuryGreatestElongationEast": 4702525383602873162, + "NextMercuryGreatestElongationWest": 4702525223892401632, "NextMercuryInferiorConjunction": 4702525189836617901, "NextMercuryProgradeToRetrograde": 4702525396656422843, "NextMercuryRetrograde": 4702525208161377923, @@ -845,18 +845,18 @@ "tt_jd_bits": 4702525470168060574, "events": { "LastMercuryConjunction": 4702525416968851613, - "LastMercuryGreatestElongation": 4702525383587428469, - "LastMercuryGreatestElongationEast": 4702525383587428469, - "LastMercuryGreatestElongationWest": 4702525223876510361, + "LastMercuryGreatestElongation": 4702525383602873144, + "LastMercuryGreatestElongationEast": 4702525383602873144, + "LastMercuryGreatestElongationWest": 4702525223892401742, "LastMercuryInferiorConjunction": 4702525416968851613, "LastMercuryProgradeToRetrograde": 4702525396656422843, "LastMercuryRetrograde": 4702525442500551784, "LastMercuryRetrogradeToPrograde": 4702525442500551784, "LastMercurySuperiorConjunction": 4702525307190218767, "NextMercuryConjunction": 4702525566316208299, - "NextMercuryGreatestElongation": 4702525472152975277, - "NextMercuryGreatestElongationEast": 4702525626113946961, - "NextMercuryGreatestElongationWest": 4702525472152975277, + "NextMercuryGreatestElongation": 4702525472169593756, + "NextMercuryGreatestElongationEast": 4702525626127523948, + "NextMercuryGreatestElongationWest": 4702525472169593756, "NextMercuryInferiorConjunction": 4702525678705185769, "NextMercuryProgradeToRetrograde": 4702525653419274901, "NextMercuryRetrograde": 4702525653419274901, @@ -869,18 +869,18 @@ "tt_jd_bits": 4702525764652468369, "events": { "LastMercuryConjunction": 4702525678705185769, - "LastMercuryGreatestElongation": 4702525730450588895, - "LastMercuryGreatestElongationEast": 4702525626113946961, - "LastMercuryGreatestElongationWest": 4702525730450588895, + "LastMercuryGreatestElongation": 4702525730464896550, + "LastMercuryGreatestElongationEast": 4702525626127523836, + "LastMercuryGreatestElongationWest": 4702525730464896550, "LastMercuryInferiorConjunction": 4702525678705185769, "LastMercuryProgradeToRetrograde": 4702525653419274901, "LastMercuryRetrograde": 4702525704398925240, "LastMercuryRetrogradeToPrograde": 4702525704398925240, "LastMercurySuperiorConjunction": 4702525566316208299, "NextMercuryConjunction": 4702525790788457590, - "NextMercuryGreatestElongation": 4702525883870691769, - "NextMercuryGreatestElongationEast": 4702525883870691769, - "NextMercuryGreatestElongationWest": 4702525972369303183, + "NextMercuryGreatestElongation": 4702525883886341640, + "NextMercuryGreatestElongationEast": 4702525883886341640, + "NextMercuryGreatestElongationWest": 4702525972383390030, "NextMercuryInferiorConjunction": 4702525939037242088, "NextMercuryProgradeToRetrograde": 4702525911875496906, "NextMercuryRetrograde": 4702525911875496906, @@ -893,18 +893,18 @@ "tt_jd_bits": 4702526059136876164, "events": { "LastMercuryConjunction": 4702526046051979162, - "LastMercuryGreatestElongation": 4702525972369303183, - "LastMercuryGreatestElongationEast": 4702525883870691769, - "LastMercuryGreatestElongationWest": 4702525972369303183, + "LastMercuryGreatestElongation": 4702525972383389952, + "LastMercuryGreatestElongationEast": 4702525883886341642, + "LastMercuryGreatestElongationWest": 4702525972383389952, "LastMercuryInferiorConjunction": 4702525939037242088, "LastMercuryProgradeToRetrograde": 4702525911875496906, "LastMercuryRetrograde": 4702525956945924013, "LastMercuryRetrogradeToPrograde": 4702525956945924013, "LastMercurySuperiorConjunction": 4702526046051979162, "NextMercuryConjunction": 4702526166232661259, - "NextMercuryGreatestElongation": 4702526131676580916, - "NextMercuryGreatestElongationEast": 4702526131676580916, - "NextMercuryGreatestElongationWest": 4702526218178087064, + "NextMercuryGreatestElongation": 4702526131692121012, + "NextMercuryGreatestElongationEast": 4702526131692121012, + "NextMercuryGreatestElongationWest": 4702526218194830486, "NextMercuryInferiorConjunction": 4702526166232661259, "NextMercuryProgradeToRetrograde": 4702526146071805487, "NextMercuryRetrograde": 4702526146071805487, @@ -917,18 +917,18 @@ "tt_jd_bits": 4702526353621308814, "events": { "LastMercuryConjunction": 4702526315438085619, - "LastMercuryGreatestElongation": 4702526218178087064, - "LastMercuryGreatestElongationEast": 4702526131676580916, - "LastMercuryGreatestElongationWest": 4702526218178087064, + "LastMercuryGreatestElongation": 4702526218194830578, + "LastMercuryGreatestElongationEast": 4702526131692121088, + "LastMercuryGreatestElongationWest": 4702526218194830578, "LastMercuryInferiorConjunction": 4702526166232661259, "LastMercuryProgradeToRetrograde": 4702526146071805487, "LastMercuryRetrograde": 4702526190759644397, "LastMercuryRetrogradeToPrograde": 4702526190759644397, "LastMercurySuperiorConjunction": 4702526315438085619, "NextMercuryConjunction": 4702526419102714895, - "NextMercuryGreatestElongation": 4702526371644507480, - "NextMercuryGreatestElongationEast": 4702526371644507480, - "NextMercuryGreatestElongationWest": 4702526475088507660, + "NextMercuryGreatestElongation": 4702526371658787510, + "NextMercuryGreatestElongationEast": 4702526371658787510, + "NextMercuryGreatestElongationWest": 4702526475101787918, "NextMercuryInferiorConjunction": 4702526419102714895, "NextMercuryProgradeToRetrograde": 4702526396022378967, "NextMercuryRetrograde": 4702526396022378967, @@ -941,18 +941,18 @@ "tt_jd_bits": 4702526648102162325, "events": { "LastMercuryConjunction": 4702526540813646818, - "LastMercuryGreatestElongation": 4702526630097186626, - "LastMercuryGreatestElongationEast": 4702526630097186626, - "LastMercuryGreatestElongationWest": 4702526475088507660, + "LastMercuryGreatestElongation": 4702526630112171430, + "LastMercuryGreatestElongationEast": 4702526630112171430, + "LastMercuryGreatestElongationWest": 4702526475101787902, "LastMercuryInferiorConjunction": 4702526419102714895, "LastMercuryProgradeToRetrograde": 4702526396022378967, "LastMercuryRetrograde": 4702526445391526348, "LastMercuryRetrogradeToPrograde": 4702526445391526348, "LastMercurySuperiorConjunction": 4702526540813646818, "NextMercuryConjunction": 4702526687542172876, - "NextMercuryGreatestElongation": 4702526721120517866, - "NextMercuryGreatestElongationEast": 4702526879329365512, - "NextMercuryGreatestElongationWest": 4702526721120517866, + "NextMercuryGreatestElongation": 4702526721135875698, + "NextMercuryGreatestElongationEast": 4702526879345203830, + "NextMercuryGreatestElongationWest": 4702526721135875698, "NextMercuryInferiorConjunction": 4702526687542172876, "NextMercuryProgradeToRetrograde": 4702526658343775406, "NextMercuryRetrograde": 4702526658343775406, @@ -965,18 +965,18 @@ "tt_jd_bits": 4702526942586570120, "events": { "LastMercuryConjunction": 4702526915982840422, - "LastMercuryGreatestElongation": 4702526879329365512, - "LastMercuryGreatestElongationEast": 4702526879329365512, - "LastMercuryGreatestElongationWest": 4702526721120517866, + "LastMercuryGreatestElongation": 4702526879345203742, + "LastMercuryGreatestElongationEast": 4702526879345203742, + "LastMercuryGreatestElongationWest": 4702526721135875678, "LastMercuryInferiorConjunction": 4702526915982840422, "LastMercuryProgradeToRetrograde": 4702526895725104886, "LastMercuryRetrograde": 4702526939325851787, "LastMercuryRetrogradeToPrograde": 4702526939325851787, "LastMercurySuperiorConjunction": 4702526786144064687, "NextMercuryConjunction": 4702527063637701356, - "NextMercuryGreatestElongation": 4702526964130521462, - "NextMercuryGreatestElongationEast": 4702527118226033986, - "NextMercuryGreatestElongationWest": 4702526964130521462, + "NextMercuryGreatestElongation": 4702526964148371902, + "NextMercuryGreatestElongationEast": 4702527118239766298, + "NextMercuryGreatestElongationWest": 4702526964148371902, "NextMercuryInferiorConjunction": 4702527160583580923, "NextMercuryProgradeToRetrograde": 4702527138531077174, "NextMercuryRetrograde": 4702527138531077174, @@ -989,18 +989,18 @@ "tt_jd_bits": 4702527237070977915, "events": { "LastMercuryConjunction": 4702527160583580923, - "LastMercuryGreatestElongation": 4702527219415091896, - "LastMercuryGreatestElongationEast": 4702527118226033986, - "LastMercuryGreatestElongationWest": 4702527219415091896, + "LastMercuryGreatestElongation": 4702527219428774736, + "LastMercuryGreatestElongationEast": 4702527118239766402, + "LastMercuryGreatestElongationWest": 4702527219428774736, "LastMercuryInferiorConjunction": 4702527160583580923, "LastMercuryProgradeToRetrograde": 4702527138531077174, "LastMercuryRetrograde": 4702527187015672495, "LastMercuryRetrogradeToPrograde": 4702527187015672495, "LastMercurySuperiorConjunction": 4702527063637701356, "NextMercuryConjunction": 4702527291167252010, - "NextMercuryGreatestElongation": 4702527376072254658, - "NextMercuryGreatestElongationEast": 4702527376072254658, - "NextMercuryGreatestElongationWest": 4702527469861255096, + "NextMercuryGreatestElongation": 4702527376086997384, + "NextMercuryGreatestElongationEast": 4702527376086997384, + "NextMercuryGreatestElongationWest": 4702527469876009850, "NextMercuryInferiorConjunction": 4702527435090441181, "NextMercuryProgradeToRetrograde": 4702527404240148017, "NextMercuryRetrograde": 4702527404240148017, @@ -1013,18 +1013,18 @@ "tt_jd_bits": 4702527529676337518, "events": { "LastMercuryConjunction": 4702527528550307747, - "LastMercuryGreatestElongation": 4702527469861255096, - "LastMercuryGreatestElongationEast": 4702527376072254658, - "LastMercuryGreatestElongationWest": 4702527469861255096, + "LastMercuryGreatestElongation": 4702527469876009846, + "LastMercuryGreatestElongationEast": 4702527376086997378, + "LastMercuryGreatestElongationWest": 4702527469876009846, "LastMercuryInferiorConjunction": 4702527435090441181, "LastMercuryProgradeToRetrograde": 4702527404240148017, "LastMercuryRetrograde": 4702527453574823027, "LastMercuryRetrogradeToPrograde": 4702527453574823027, "LastMercurySuperiorConjunction": 4702527528550307747, "NextMercuryConjunction": 4702527666016851923, - "NextMercuryGreatestElongation": 4702527626478190052, - "NextMercuryGreatestElongationEast": 4702527626478190052, - "NextMercuryGreatestElongationWest": 4702527710116500498, + "NextMercuryGreatestElongation": 4702527626495742876, + "NextMercuryGreatestElongationEast": 4702527626495742876, + "NextMercuryGreatestElongationWest": 4702527710134274230, "NextMercuryInferiorConjunction": 4702527666016851923, "NextMercuryProgradeToRetrograde": 4702527645360347881, "NextMercuryRetrograde": 4702527645360347881, @@ -1037,18 +1037,18 @@ "tt_jd_bits": 4702527824160745313, "events": { "LastMercuryConjunction": 4702527810561268054, - "LastMercuryGreatestElongation": 4702527710116500498, - "LastMercuryGreatestElongationEast": 4702527626478190052, - "LastMercuryGreatestElongationWest": 4702527710116500498, + "LastMercuryGreatestElongation": 4702527710134274164, + "LastMercuryGreatestElongationEast": 4702527626495742826, + "LastMercuryGreatestElongationWest": 4702527710134274164, "LastMercuryInferiorConjunction": 4702527666016851924, "LastMercuryProgradeToRetrograde": 4702527645360347881, "LastMercuryRetrograde": 4702527688098482611, "LastMercuryRetrogradeToPrograde": 4702527688098482611, "LastMercurySuperiorConjunction": 4702527810561268054, "NextMercuryConjunction": 4702527903870415723, - "NextMercuryGreatestElongation": 4702527865699084044, - "NextMercuryGreatestElongationEast": 4702527865699084044, - "NextMercuryGreatestElongationWest": 4702527963939540318, + "NextMercuryGreatestElongation": 4702527865713733450, + "NextMercuryGreatestElongationEast": 4702527865713733450, + "NextMercuryGreatestElongationWest": 4702527963953476900, "NextMercuryInferiorConjunction": 4702527903870415723, "NextMercuryProgradeToRetrograde": 4702527882183773705, "NextMercuryRetrograde": 4702527882183773705, @@ -1061,18 +1061,18 @@ "tt_jd_bits": 4702528118530322386, "events": { "LastMercuryConjunction": 4702528041679354660, - "LastMercuryGreatestElongation": 4702527963939540318, - "LastMercuryGreatestElongationEast": 4702527865699084044, - "LastMercuryGreatestElongationWest": 4702527963939540318, + "LastMercuryGreatestElongation": 4702527963953476814, + "LastMercuryGreatestElongationEast": 4702527865713733446, + "LastMercuryGreatestElongationWest": 4702527963953476814, "LastMercuryInferiorConjunction": 4702527903870415723, "LastMercuryProgradeToRetrograde": 4702527882183773705, "LastMercuryRetrograde": 4702527930488219572, "LastMercuryRetrogradeToPrograde": 4702527930488219572, "LastMercurySuperiorConjunction": 4702528041679354660, "NextMercuryConjunction": 4702528181384526254, - "NextMercuryGreatestElongation": 4702528121595224470, - "NextMercuryGreatestElongationEast": 4702528121595224470, - "NextMercuryGreatestElongationWest": 4702528218312636718, + "NextMercuryGreatestElongation": 4702528121609356242, + "NextMercuryGreatestElongationEast": 4702528121609356242, + "NextMercuryGreatestElongationWest": 4702528218326894602, "NextMercuryInferiorConjunction": 4702528181384526254, "NextMercuryProgradeToRetrograde": 4702528149689558128, "NextMercuryRetrograde": 4702528149689558128, @@ -1085,18 +1085,18 @@ "tt_jd_bits": 4702528413014730181, "events": { "LastMercuryConjunction": 4702528273296380958, - "LastMercuryGreatestElongation": 4702528373173381170, - "LastMercuryGreatestElongationEast": 4702528373173381170, - "LastMercuryGreatestElongationWest": 4702528218312636718, + "LastMercuryGreatestElongation": 4702528373190772628, + "LastMercuryGreatestElongationEast": 4702528373190772628, + "LastMercuryGreatestElongationWest": 4702528218326894502, "LastMercuryInferiorConjunction": 4702528181384526254, "LastMercuryProgradeToRetrograde": 4702528394748821674, "LastMercuryRetrograde": 4702528394748821674, "LastMercuryRetrogradeToPrograde": 4702528200981595258, "LastMercurySuperiorConjunction": 4702528273296380958, "NextMercuryConjunction": 4702528416158528763, - "NextMercuryGreatestElongation": 4702528456431756814, - "NextMercuryGreatestElongationEast": 4702528613775992842, - "NextMercuryGreatestElongationWest": 4702528456431756814, + "NextMercuryGreatestElongation": 4702528456448229792, + "NextMercuryGreatestElongationEast": 4702528613790272554, + "NextMercuryGreatestElongationWest": 4702528456448229792, "NextMercuryInferiorConjunction": 4702528416158528763, "NextMercuryProgradeToRetrograde": 4702528627681392433, "NextMercuryRetrograde": 4702528436994931567, @@ -1109,18 +1109,18 @@ "tt_jd_bits": 4702528707499137976, "events": { "LastMercuryConjunction": 4702528649023759186, - "LastMercuryGreatestElongation": 4702528613775992842, - "LastMercuryGreatestElongationEast": 4702528613775992842, - "LastMercuryGreatestElongationWest": 4702528456431756814, + "LastMercuryGreatestElongation": 4702528613790272482, + "LastMercuryGreatestElongationEast": 4702528613790272482, + "LastMercuryGreatestElongationWest": 4702528456448229866, "LastMercuryInferiorConjunction": 4702528649023759186, "LastMercuryProgradeToRetrograde": 4702528627681392433, "LastMercuryRetrograde": 4702528675742146963, "LastMercuryRetrogradeToPrograde": 4702528675742146963, "LastMercurySuperiorConjunction": 4702528555760271730, "NextMercuryConjunction": 4702528792187540852, - "NextMercuryGreatestElongation": 4702528708963452980, - "NextMercuryGreatestElongationEast": 4702528866496900019, - "NextMercuryGreatestElongationWest": 4702528708963452980, + "NextMercuryGreatestElongation": 4702528708978348180, + "NextMercuryGreatestElongationEast": 4702528866511358938, + "NextMercuryGreatestElongationWest": 4702528708978348180, "NextMercuryInferiorConjunction": 4702528926103896955, "NextMercuryProgradeToRetrograde": 4702528894735317089, "NextMercuryRetrograde": 4702528894735317089, @@ -1133,18 +1133,18 @@ "tt_jd_bits": 4702529001983570626, "events": { "LastMercuryConjunction": 4702528926103896955, - "LastMercuryGreatestElongation": 4702528966186465347, - "LastMercuryGreatestElongationEast": 4702528866496900019, - "LastMercuryGreatestElongationWest": 4702528966186465347, + "LastMercuryGreatestElongation": 4702528966199936074, + "LastMercuryGreatestElongationEast": 4702528866511358920, + "LastMercuryGreatestElongationWest": 4702528966199936074, "LastMercuryInferiorConjunction": 4702528926103896955, "LastMercuryProgradeToRetrograde": 4702528894735317089, "LastMercuryRetrograde": 4702528947344641339, "LastMercuryRetrogradeToPrograde": 4702528947344641339, "LastMercurySuperiorConjunction": 4702528792187540852, "NextMercuryConjunction": 4702529019946761937, - "NextMercuryGreatestElongation": 4702529119560351633, - "NextMercuryGreatestElongationEast": 4702529119560351633, - "NextMercuryGreatestElongationWest": 4702529203368367611, + "NextMercuryGreatestElongation": 4702529119577767862, + "NextMercuryGreatestElongationEast": 4702529119577767862, + "NextMercuryGreatestElongationWest": 4702529203384703750, "NextMercuryInferiorConjunction": 4702529166248299959, "NextMercuryProgradeToRetrograde": 4702529143687010641, "NextMercuryRetrograde": 4702529143687010641, @@ -1157,18 +1157,18 @@ "tt_jd_bits": 4702515261007359590, "events": { "LastMercuryConjunction": 4702515214693437664, - "LastMercuryGreatestElongation": 4702515181195300483, - "LastMercuryGreatestElongationEast": 4702515181195300483, - "LastMercuryGreatestElongationWest": 4702515021502079331, + "LastMercuryGreatestElongation": 4702515181210035354, + "LastMercuryGreatestElongationEast": 4702515181210035354, + "LastMercuryGreatestElongationWest": 4702515021517782478, "LastMercuryInferiorConjunction": 4702515214693437664, "LastMercuryProgradeToRetrograde": 4702515194422928147, "LastMercuryRetrograde": 4702515240096263459, "LastMercuryRetrogradeToPrograde": 4702515240096263459, "LastMercurySuperiorConjunction": 4702515103405455107, "NextMercuryConjunction": 4702515364105428502, - "NextMercuryGreatestElongation": 4702515269448266905, - "NextMercuryGreatestElongationEast": 4702515423256483173, - "NextMercuryGreatestElongationWest": 4702515269448266905, + "NextMercuryGreatestElongation": 4702515269464454698, + "NextMercuryGreatestElongationEast": 4702515423269900022, + "NextMercuryGreatestElongationWest": 4702515269464454698, "NextMercuryInferiorConjunction": 4702515475132313516, "NextMercuryProgradeToRetrograde": 4702515450267924371, "NextMercuryRetrograde": 4702515450267924371, @@ -1181,18 +1181,18 @@ "tt_jd_bits": 4702515452767682448, "events": { "LastMercuryConjunction": 4702515364105428502, - "LastMercuryGreatestElongation": 4702515423256483173, - "LastMercuryGreatestElongationEast": 4702515423256483173, - "LastMercuryGreatestElongationWest": 4702515269448266905, + "LastMercuryGreatestElongation": 4702515423269900022, + "LastMercuryGreatestElongationEast": 4702515423269900022, + "LastMercuryGreatestElongationWest": 4702515269464454670, "LastMercuryInferiorConjunction": 4702515214693437664, "LastMercuryProgradeToRetrograde": 4702515450267924371, "LastMercuryRetrograde": 4702515450267924371, "LastMercuryRetrogradeToPrograde": 4702515240096263459, "LastMercurySuperiorConjunction": 4702515364105428502, "NextMercuryConjunction": 4702515475132313516, - "NextMercuryGreatestElongation": 4702515527574440317, - "NextMercuryGreatestElongationEast": 4702515681181964795, - "NextMercuryGreatestElongationWest": 4702515527574440317, + "NextMercuryGreatestElongation": 4702515527588877076, + "NextMercuryGreatestElongationEast": 4702515681197668238, + "NextMercuryGreatestElongationWest": 4702515527588877076, "NextMercuryInferiorConjunction": 4702515475132313516, "NextMercuryProgradeToRetrograde": 4702515709270675605, "NextMercuryRetrograde": 4702515500954236786, @@ -1205,18 +1205,18 @@ "tt_jd_bits": 4702515644528005305, "events": { "LastMercuryConjunction": 4702515588636625279, - "LastMercuryGreatestElongation": 4702515527574440317, - "LastMercuryGreatestElongationEast": 4702515423256483173, - "LastMercuryGreatestElongationWest": 4702515527574440317, + "LastMercuryGreatestElongation": 4702515527588876980, + "LastMercuryGreatestElongationEast": 4702515423269899902, + "LastMercuryGreatestElongationWest": 4702515527588876980, "LastMercuryInferiorConjunction": 4702515475132313516, "LastMercuryProgradeToRetrograde": 4702515450267924371, "LastMercuryRetrograde": 4702515500954236786, "LastMercuryRetrogradeToPrograde": 4702515500954236786, "LastMercurySuperiorConjunction": 4702515588636625279, "NextMercuryConjunction": 4702515736735695950, - "NextMercuryGreatestElongation": 4702515681181964795, - "NextMercuryGreatestElongationEast": 4702515681181964795, - "NextMercuryGreatestElongationWest": 4702515770045354343, + "NextMercuryGreatestElongation": 4702515681197668238, + "NextMercuryGreatestElongationEast": 4702515681197668238, + "NextMercuryGreatestElongationWest": 4702515770061183376, "NextMercuryInferiorConjunction": 4702515736735695950, "NextMercuryProgradeToRetrograde": 4702515709270675605, "NextMercuryRetrograde": 4702515709270675605, @@ -1229,18 +1229,18 @@ "tt_jd_bits": 4702515836288328162, "events": { "LastMercuryConjunction": 4702515736735695950, - "LastMercuryGreatestElongation": 4702515770045354343, - "LastMercuryGreatestElongationEast": 4702515681181964795, - "LastMercuryGreatestElongationWest": 4702515770045354343, + "LastMercuryGreatestElongation": 4702515770061183400, + "LastMercuryGreatestElongationEast": 4702515681197668252, + "LastMercuryGreatestElongationWest": 4702515770061183400, "LastMercuryInferiorConjunction": 4702515736735695950, "LastMercuryProgradeToRetrograde": 4702515709270675605, "LastMercuryRetrograde": 4702515754612104582, "LastMercuryRetrogradeToPrograde": 4702515754612104582, "LastMercurySuperiorConjunction": 4702515588636625279, "NextMercuryConjunction": 4702515842333658113, - "NextMercuryGreatestElongation": 4702515929231454408, - "NextMercuryGreatestElongationEast": 4702515929231454408, - "NextMercuryGreatestElongationWest": 4702516015453446376, + "NextMercuryGreatestElongation": 4702515929246793088, + "NextMercuryGreatestElongationEast": 4702515929246793088, + "NextMercuryGreatestElongationWest": 4702516015471079904, "NextMercuryInferiorConjunction": 4702515964045079368, "NextMercuryProgradeToRetrograde": 4702515943890028756, "NextMercuryRetrograde": 4702515943890028756, @@ -1253,18 +1253,18 @@ "tt_jd_bits": 4702516028048675875, "events": { "LastMercuryConjunction": 4702515964045079368, - "LastMercuryGreatestElongation": 4702516015453446376, - "LastMercuryGreatestElongationEast": 4702515929231454408, - "LastMercuryGreatestElongationWest": 4702516015453446376, + "LastMercuryGreatestElongation": 4702516015471079904, + "LastMercuryGreatestElongationEast": 4702515929246793154, + "LastMercuryGreatestElongationWest": 4702516015471079904, "LastMercuryInferiorConjunction": 4702515964045079368, "LastMercuryProgradeToRetrograde": 4702515943890028756, "LastMercuryRetrograde": 4702515988415554071, "LastMercuryRetrogradeToPrograde": 4702515988415554071, "LastMercurySuperiorConjunction": 4702515842333658113, "NextMercuryConjunction": 4702516113112504592, - "NextMercuryGreatestElongation": 4702516168950258774, - "NextMercuryGreatestElongationEast": 4702516168950258774, - "NextMercuryGreatestElongationWest": 4702516272124990196, + "NextMercuryGreatestElongation": 4702516168964291540, + "NextMercuryGreatestElongationEast": 4702516168964291540, + "NextMercuryGreatestElongationWest": 4702516272138538868, "NextMercuryInferiorConjunction": 4702516215618185957, "NextMercuryProgradeToRetrograde": 4702516192756767854, "NextMercuryRetrograde": 4702516192756767854, @@ -1277,18 +1277,18 @@ "tt_jd_bits": 4702516216677251745, "events": { "LastMercuryConjunction": 4702516215618185957, - "LastMercuryGreatestElongation": 4702516168950258774, - "LastMercuryGreatestElongationEast": 4702516168950258774, - "LastMercuryGreatestElongationWest": 4702516015453446376, + "LastMercuryGreatestElongation": 4702516168964291476, + "LastMercuryGreatestElongationEast": 4702516168964291476, + "LastMercuryGreatestElongationWest": 4702516015471079788, "LastMercuryInferiorConjunction": 4702516215618185957, "LastMercuryProgradeToRetrograde": 4702516192756767854, "LastMercuryRetrograde": 4702516192756767854, "LastMercuryRetrogradeToPrograde": 4702515988415554071, "LastMercurySuperiorConjunction": 4702516113112504592, "NextMercuryConjunction": 4702516338722813686, - "NextMercuryGreatestElongation": 4702516272124990196, - "NextMercuryGreatestElongationEast": 4702516427393566708, - "NextMercuryGreatestElongationWest": 4702516272124990196, + "NextMercuryGreatestElongation": 4702516272138538868, + "NextMercuryGreatestElongationEast": 4702516427408905094, + "NextMercuryGreatestElongationWest": 4702516272138538868, "NextMercuryInferiorConjunction": 4702516485116601417, "NextMercuryProgradeToRetrograde": 4702516455638674037, "NextMercuryRetrograde": 4702516241948631767, @@ -1301,18 +1301,18 @@ "tt_jd_bits": 4702516408437574603, "events": { "LastMercuryConjunction": 4702516338722813686, - "LastMercuryGreatestElongation": 4702516272124990196, - "LastMercuryGreatestElongationEast": 4702516168950258774, - "LastMercuryGreatestElongationWest": 4702516272124990196, + "LastMercuryGreatestElongation": 4702516272138538880, + "LastMercuryGreatestElongationEast": 4702516168964291576, + "LastMercuryGreatestElongationWest": 4702516272138538880, "LastMercuryInferiorConjunction": 4702516215618185957, "LastMercuryProgradeToRetrograde": 4702516192756767854, "LastMercuryRetrograde": 4702516241949378772, "LastMercuryRetrogradeToPrograde": 4702516241949378772, "LastMercurySuperiorConjunction": 4702516338722813686, "NextMercuryConjunction": 4702516485116601417, - "NextMercuryGreatestElongation": 4702516427393566708, - "NextMercuryGreatestElongationEast": 4702516427393566708, - "NextMercuryGreatestElongationWest": 4702516518809411766, + "NextMercuryGreatestElongation": 4702516427408905194, + "NextMercuryGreatestElongationEast": 4702516427408905194, + "NextMercuryGreatestElongationWest": 4702516518824239248, "NextMercuryInferiorConjunction": 4702516485116601417, "NextMercuryProgradeToRetrograde": 4702516455638674037, "NextMercuryRetrograde": 4702516455638674037, @@ -1325,18 +1325,18 @@ "tt_jd_bits": 4702516600197897460, "events": { "LastMercuryConjunction": 4702516582722776278, - "LastMercuryGreatestElongation": 4702516518809411766, - "LastMercuryGreatestElongationEast": 4702516427393566708, - "LastMercuryGreatestElongationWest": 4702516518809411766, + "LastMercuryGreatestElongation": 4702516518824239268, + "LastMercuryGreatestElongationEast": 4702516427408905210, + "LastMercuryGreatestElongationWest": 4702516518824239268, "LastMercuryInferiorConjunction": 4702516485116601417, "LastMercuryProgradeToRetrograde": 4702516455638674037, "LastMercuryRetrograde": 4702516503104142494, "LastMercuryRetrogradeToPrograde": 4702516503104142494, "LastMercurySuperiorConjunction": 4702516582722776278, "NextMercuryConjunction": 4702516713848016976, - "NextMercuryGreatestElongation": 4702516676815409320, - "NextMercuryGreatestElongationEast": 4702516676815409320, - "NextMercuryGreatestElongationWest": 4702516761391995304, + "NextMercuryGreatestElongation": 4702516676832053382, + "NextMercuryGreatestElongationEast": 4702516676832053382, + "NextMercuryGreatestElongationWest": 4702516761408444942, "NextMercuryInferiorConjunction": 4702516713848016976, "NextMercuryProgradeToRetrograde": 4702516693553130625, "NextMercuryRetrograde": 4702516693553130625, @@ -1349,18 +1349,18 @@ "tt_jd_bits": 4702516791958220317, "events": { "LastMercuryConjunction": 4702516713848016977, - "LastMercuryGreatestElongation": 4702516761391995304, - "LastMercuryGreatestElongationEast": 4702516676815409320, - "LastMercuryGreatestElongationWest": 4702516761391995304, + "LastMercuryGreatestElongation": 4702516761408444940, + "LastMercuryGreatestElongationEast": 4702516676832053446, + "LastMercuryGreatestElongationWest": 4702516761408444940, "LastMercuryInferiorConjunction": 4702516713848016977, "LastMercuryProgradeToRetrograde": 4702516693553130625, "LastMercuryRetrograde": 4702516737012932097, "LastMercuryRetrogradeToPrograde": 4702516737012932097, "LastMercurySuperiorConjunction": 4702516582722776278, "NextMercuryConjunction": 4702516861148400598, - "NextMercuryGreatestElongation": 4702516915676189034, - "NextMercuryGreatestElongationEast": 4702516915676189034, - "NextMercuryGreatestElongationWest": 4702517016444139054, + "NextMercuryGreatestElongation": 4702516915691029128, + "NextMercuryGreatestElongationEast": 4702516915691029128, + "NextMercuryGreatestElongationWest": 4702517016458024438, "NextMercuryInferiorConjunction": 4702516957334636579, "NextMercuryProgradeToRetrograde": 4702516935346144557, "NextMercuryRetrograde": 4702516935346144557, @@ -1373,18 +1373,18 @@ "tt_jd_bits": 4702516983718543175, "events": { "LastMercuryConjunction": 4702516957334636579, - "LastMercuryGreatestElongation": 4702516915676189034, - "LastMercuryGreatestElongationEast": 4702516915676189034, - "LastMercuryGreatestElongationWest": 4702516761391995304, + "LastMercuryGreatestElongation": 4702516915691029094, + "LastMercuryGreatestElongationEast": 4702516915691029094, + "LastMercuryGreatestElongationWest": 4702516761408444970, "LastMercuryInferiorConjunction": 4702516957334636579, "LastMercuryProgradeToRetrograde": 4702516935346144557, "LastMercuryRetrograde": 4702516935346144557, "LastMercuryRetrogradeToPrograde": 4702516737012932097, "LastMercurySuperiorConjunction": 4702516861148400598, "NextMercuryConjunction": 4702517089109298299, - "NextMercuryGreatestElongation": 4702517016444139054, - "NextMercuryGreatestElongationEast": 4702517173318272539, - "NextMercuryGreatestElongationWest": 4702517016444139054, + "NextMercuryGreatestElongation": 4702517016458024480, + "NextMercuryGreatestElongationEast": 4702517173332814694, + "NextMercuryGreatestElongationWest": 4702517016458024480, "NextMercuryInferiorConjunction": 4702517232496644558, "NextMercuryProgradeToRetrograde": 4702517201445496464, "NextMercuryRetrograde": 4702516983785102932, @@ -1397,18 +1397,18 @@ "tt_jd_bits": 4702517172347143900, "events": { "LastMercuryConjunction": 4702517089109298299, - "LastMercuryGreatestElongation": 4702517016444139054, - "LastMercuryGreatestElongationEast": 4702516915676189034, - "LastMercuryGreatestElongationWest": 4702517016444139054, + "LastMercuryGreatestElongation": 4702517016458024472, + "LastMercuryGreatestElongationEast": 4702516915691029096, + "LastMercuryGreatestElongationWest": 4702517016458024472, "LastMercuryInferiorConjunction": 4702516957334636579, "LastMercuryProgradeToRetrograde": 4702516935346144557, "LastMercuryRetrograde": 4702516983785102932, "LastMercuryRetrogradeToPrograde": 4702516983785102932, "LastMercurySuperiorConjunction": 4702517089109298299, "NextMercuryConjunction": 4702517232496644558, - "NextMercuryGreatestElongation": 4702517173318272539, - "NextMercuryGreatestElongationEast": 4702517173318272539, - "NextMercuryGreatestElongationWest": 4702517267523017351, + "NextMercuryGreatestElongation": 4702517173332814678, + "NextMercuryGreatestElongationEast": 4702517173332814678, + "NextMercuryGreatestElongationWest": 4702517267537720868, "NextMercuryInferiorConjunction": 4702517232496644558, "NextMercuryProgradeToRetrograde": 4702517201445496464, "NextMercuryRetrograde": 4702517201445496464, @@ -1421,18 +1421,18 @@ "tt_jd_bits": 4702517364107466758, "events": { "LastMercuryConjunction": 4702517325494178527, - "LastMercuryGreatestElongation": 4702517267523017351, - "LastMercuryGreatestElongationEast": 4702517173318272539, - "LastMercuryGreatestElongationWest": 4702517267523017351, + "LastMercuryGreatestElongation": 4702517267537720862, + "LastMercuryGreatestElongationEast": 4702517173332814658, + "LastMercuryGreatestElongationWest": 4702517267537720862, "LastMercuryInferiorConjunction": 4702517232496644558, "LastMercuryProgradeToRetrograde": 4702517201445496464, "LastMercuryRetrograde": 4702517251119985899, "LastMercuryRetrogradeToPrograde": 4702517251119985899, "LastMercurySuperiorConjunction": 4702517325494178527, "NextMercuryConjunction": 4702517463904026767, - "NextMercuryGreatestElongation": 4702517423885023423, - "NextMercuryGreatestElongationEast": 4702517423885023423, - "NextMercuryGreatestElongationWest": 4702517507408074075, + "NextMercuryGreatestElongation": 4702517423902499602, + "NextMercuryGreatestElongationEast": 4702517423902499602, + "NextMercuryGreatestElongationWest": 4702517507425920918, "NextMercuryInferiorConjunction": 4702517463904026767, "NextMercuryProgradeToRetrograde": 4702517443160979388, "NextMercuryRetrograde": 4702517443160979388, @@ -1445,18 +1445,18 @@ "tt_jd_bits": 4702517555867789615, "events": { "LastMercuryConjunction": 4702517463904026767, - "LastMercuryGreatestElongation": 4702517507408074075, - "LastMercuryGreatestElongationEast": 4702517423885023423, - "LastMercuryGreatestElongationWest": 4702517507408074075, + "LastMercuryGreatestElongation": 4702517507425920996, + "LastMercuryGreatestElongationEast": 4702517423902499524, + "LastMercuryGreatestElongationWest": 4702517507425920996, "LastMercuryInferiorConjunction": 4702517463904026767, "LastMercuryProgradeToRetrograde": 4702517443160979516, "LastMercuryRetrograde": 4702517485795821955, "LastMercuryRetrogradeToPrograde": 4702517485795821955, "LastMercurySuperiorConjunction": 4702517325494178527, "NextMercuryConjunction": 4702517607838363499, - "NextMercuryGreatestElongation": 4702517663250942581, - "NextMercuryGreatestElongationEast": 4702517663250942581, - "NextMercuryGreatestElongationWest": 4702517761036753343, + "NextMercuryGreatestElongation": 4702517663264503410, + "NextMercuryGreatestElongationEast": 4702517663264503410, + "NextMercuryGreatestElongationWest": 4702517761050003886, "NextMercuryInferiorConjunction": 4702517700904702531, "NextMercuryProgradeToRetrograde": 4702517679268452240, "NextMercuryRetrograde": 4702517679268452240, @@ -1469,18 +1469,18 @@ "tt_jd_bits": 4702517747531177447, "events": { "LastMercuryConjunction": 4702517700904702531, - "LastMercuryGreatestElongation": 4702517663250942581, - "LastMercuryGreatestElongationEast": 4702517663250942581, - "LastMercuryGreatestElongationWest": 4702517507408074075, + "LastMercuryGreatestElongation": 4702517663264503376, + "LastMercuryGreatestElongationEast": 4702517663264503376, + "LastMercuryGreatestElongationWest": 4702517507425920974, "LastMercuryInferiorConjunction": 4702517700904702531, "LastMercuryProgradeToRetrograde": 4702517679268452240, "LastMercuryRetrograde": 4702517727533532623, "LastMercuryRetrogradeToPrograde": 4702517727533532623, "LastMercurySuperiorConjunction": 4702517607838363499, "NextMercuryConjunction": 4702517839629280277, - "NextMercuryGreatestElongation": 4702517761036753343, - "NextMercuryGreatestElongationEast": 4702517918744774585, - "NextMercuryGreatestElongationWest": 4702517761036753343, + "NextMercuryGreatestElongation": 4702517761050003874, + "NextMercuryGreatestElongationEast": 4702517918758347994, + "NextMercuryGreatestElongationWest": 4702517761050003874, "NextMercuryInferiorConjunction": 4702517978575631757, "NextMercuryProgradeToRetrograde": 4702517946845947290, "NextMercuryRetrograde": 4702517946845947290, @@ -1493,18 +1493,18 @@ "tt_jd_bits": 4702517939291500304, "events": { "LastMercuryConjunction": 4702517839629280276, - "LastMercuryGreatestElongation": 4702517918744774585, - "LastMercuryGreatestElongationEast": 4702517918744774585, - "LastMercuryGreatestElongationWest": 4702517761036753343, + "LastMercuryGreatestElongation": 4702517918758347990, + "LastMercuryGreatestElongationEast": 4702517918758347990, + "LastMercuryGreatestElongationWest": 4702517761050003930, "LastMercuryInferiorConjunction": 4702517700904702531, "LastMercuryProgradeToRetrograde": 4702517679268452240, "LastMercuryRetrograde": 4702517727533532623, "LastMercuryRetrogradeToPrograde": 4702517727533532623, "LastMercurySuperiorConjunction": 4702517839629280276, "NextMercuryConjunction": 4702517978575631757, - "NextMercuryGreatestElongation": 4702518015910509367, - "NextMercuryGreatestElongationEast": 4702518170514547159, - "NextMercuryGreatestElongationWest": 4702518015910509367, + "NextMercuryGreatestElongation": 4702518015925177374, + "NextMercuryGreatestElongationEast": 4702518170532136744, + "NextMercuryGreatestElongationWest": 4702518015925177374, "NextMercuryInferiorConjunction": 4702517978575631757, "NextMercuryProgradeToRetrograde": 4702517946845947290, "NextMercuryRetrograde": 4702517946845947290, @@ -1517,18 +1517,18 @@ "tt_jd_bits": 4702518127920076175, "events": { "LastMercuryConjunction": 4702518070563232332, - "LastMercuryGreatestElongation": 4702518015910509367, - "LastMercuryGreatestElongationEast": 4702517918744774585, - "LastMercuryGreatestElongationWest": 4702518015910509367, + "LastMercuryGreatestElongation": 4702518015925177314, + "LastMercuryGreatestElongationEast": 4702517918758348038, + "LastMercuryGreatestElongationWest": 4702518015925177314, "LastMercuryInferiorConjunction": 4702517978575631757, "LastMercuryProgradeToRetrograde": 4702517946845947290, "LastMercuryRetrograde": 4702517998394106572, "LastMercuryRetrogradeToPrograde": 4702517998394106572, "LastMercurySuperiorConjunction": 4702518070563232332, "NextMercuryConjunction": 4702518214046531455, - "NextMercuryGreatestElongation": 4702518170514547159, - "NextMercuryGreatestElongationEast": 4702518170514547159, - "NextMercuryGreatestElongationWest": 4702518253805075649, + "NextMercuryGreatestElongation": 4702518170532136720, + "NextMercuryGreatestElongationEast": 4702518170532136720, + "NextMercuryGreatestElongationWest": 4702518253821594028, "NextMercuryInferiorConjunction": 4702518214046531455, "NextMercuryProgradeToRetrograde": 4702518192496676191, "NextMercuryRetrograde": 4702518192496676191, @@ -1541,18 +1541,18 @@ "tt_jd_bits": 4702518319680423887, "events": { "LastMercuryConjunction": 4702518214046531455, - "LastMercuryGreatestElongation": 4702518253805075649, - "LastMercuryGreatestElongationEast": 4702518170514547159, - "LastMercuryGreatestElongationWest": 4702518253805075649, + "LastMercuryGreatestElongation": 4702518253821594026, + "LastMercuryGreatestElongationEast": 4702518170532136706, + "LastMercuryGreatestElongationWest": 4702518253821594026, "LastMercuryInferiorConjunction": 4702518214046531455, "LastMercuryProgradeToRetrograde": 4702518192496676191, "LastMercuryRetrograde": 4702518234697549877, "LastMercuryRetrogradeToPrograde": 4702518234697549877, "LastMercurySuperiorConjunction": 4702518070563232332, "NextMercuryConjunction": 4702518352733463896, - "NextMercuryGreatestElongation": 4702518411388403268, - "NextMercuryGreatestElongationEast": 4702518411388403268, - "NextMercuryGreatestElongationWest": 4702518506149917346, + "NextMercuryGreatestElongation": 4702518411403139954, + "NextMercuryGreatestElongationEast": 4702518411403139954, + "NextMercuryGreatestElongationWest": 4702518506164237174, "NextMercuryInferiorConjunction": 4702518446318828400, "NextMercuryProgradeToRetrograde": 4702518425041010442, "NextMercuryRetrograde": 4702518425041010442, @@ -1565,18 +1565,18 @@ "tt_jd_bits": 4702518511432718536, "events": { "LastMercuryConjunction": 4702518446318828400, - "LastMercuryGreatestElongation": 4702518506149917346, - "LastMercuryGreatestElongationEast": 4702518411388403268, - "LastMercuryGreatestElongationWest": 4702518506149917346, + "LastMercuryGreatestElongation": 4702518506164237160, + "LastMercuryGreatestElongationEast": 4702518411403139864, + "LastMercuryGreatestElongationWest": 4702518506164237160, "LastMercuryInferiorConjunction": 4702518446318828400, "LastMercuryProgradeToRetrograde": 4702518425041010442, "LastMercuryRetrograde": 4702518473027842303, "LastMercuryRetrogradeToPrograde": 4702518473027842303, "LastMercurySuperiorConjunction": 4702518352733463896, "NextMercuryConjunction": 4702518590122908030, - "NextMercuryGreatestElongation": 4702518663547781058, - "NextMercuryGreatestElongationEast": 4702518663547781058, - "NextMercuryGreatestElongationWest": 4702518763677704678, + "NextMercuryGreatestElongation": 4702518663561366778, + "NextMercuryGreatestElongationEast": 4702518663561366778, + "NextMercuryGreatestElongationWest": 4702518763692090026, "NextMercuryInferiorConjunction": 4702518723037148505, "NextMercuryProgradeToRetrograde": 4702518691858759851, "NextMercuryRetrograde": 4702518691858759851, @@ -1589,18 +1589,18 @@ "tt_jd_bits": 4702518703193041393, "events": { "LastMercuryConjunction": 4702518590122908031, - "LastMercuryGreatestElongation": 4702518663547781058, - "LastMercuryGreatestElongationEast": 4702518663547781058, - "LastMercuryGreatestElongationWest": 4702518506149917346, + "LastMercuryGreatestElongation": 4702518663561366672, + "LastMercuryGreatestElongationEast": 4702518663561366672, + "LastMercuryGreatestElongationWest": 4702518506164237086, "LastMercuryInferiorConjunction": 4702518446318828400, "LastMercuryProgradeToRetrograde": 4702518691858759851, "LastMercuryRetrograde": 4702518691858759851, "LastMercuryRetrogradeToPrograde": 4702518473027842303, "LastMercurySuperiorConjunction": 4702518590122908031, "NextMercuryConjunction": 4702518723037148505, - "NextMercuryGreatestElongation": 4702518763677704678, - "NextMercuryGreatestElongationEast": 4702518916871828132, - "NextMercuryGreatestElongationWest": 4702518763677704678, + "NextMercuryGreatestElongation": 4702518763692090026, + "NextMercuryGreatestElongationEast": 4702518916888219704, + "NextMercuryGreatestElongationWest": 4702518763692090026, "NextMercuryInferiorConjunction": 4702518723037148505, "NextMercuryProgradeToRetrograde": 4702518941359525019, "NextMercuryRetrograde": 4702518744555821826, @@ -1613,18 +1613,18 @@ "tt_jd_bits": 4702518894953364250, "events": { "LastMercuryConjunction": 4702518817451672580, - "LastMercuryGreatestElongation": 4702518763677704678, - "LastMercuryGreatestElongationEast": 4702518663547781058, - "LastMercuryGreatestElongationWest": 4702518763677704678, + "LastMercuryGreatestElongation": 4702518763692090072, + "LastMercuryGreatestElongationEast": 4702518663561366772, + "LastMercuryGreatestElongationWest": 4702518763692090072, "LastMercuryInferiorConjunction": 4702518723037148505, "LastMercuryProgradeToRetrograde": 4702518691858759851, "LastMercuryRetrograde": 4702518744555821826, "LastMercuryRetrogradeToPrograde": 4702518744555821826, "LastMercurySuperiorConjunction": 4702518817451672580, "NextMercuryConjunction": 4702518964116923787, - "NextMercuryGreatestElongation": 4702518916871828132, - "NextMercuryGreatestElongationEast": 4702518916871828132, - "NextMercuryGreatestElongationWest": 4702519000844792180, + "NextMercuryGreatestElongation": 4702518916888219616, + "NextMercuryGreatestElongationEast": 4702518916888219616, + "NextMercuryGreatestElongationWest": 4702519000861135184, "NextMercuryInferiorConjunction": 4702518964116923787, "NextMercuryProgradeToRetrograde": 4702518941359525019, "NextMercuryRetrograde": 4702518941359525019, @@ -1637,18 +1637,18 @@ "tt_jd_bits": 4702519083581940121, "events": { "LastMercuryConjunction": 4702518964116923787, - "LastMercuryGreatestElongation": 4702519000844792180, - "LastMercuryGreatestElongationEast": 4702518916871828132, - "LastMercuryGreatestElongationWest": 4702519000844792180, + "LastMercuryGreatestElongation": 4702519000861135186, + "LastMercuryGreatestElongationEast": 4702518916888219654, + "LastMercuryGreatestElongationWest": 4702519000861135186, "LastMercuryInferiorConjunction": 4702518964116923787, "LastMercuryProgradeToRetrograde": 4702518941359525019, "LastMercuryRetrograde": 4702518983649229245, "LastMercuryRetrogradeToPrograde": 4702518983649229245, "LastMercurySuperiorConjunction": 4702518817451672580, "NextMercuryConjunction": 4702519095403444504, - "NextMercuryGreatestElongation": 4702519159807641136, - "NextMercuryGreatestElongationEast": 4702519159807641136, - "NextMercuryGreatestElongationWest": 4702519251788265024, + "NextMercuryGreatestElongation": 4702519159822383510, + "NextMercuryGreatestElongationEast": 4702519159822383510, + "NextMercuryGreatestElongationWest": 4702519251803209776, "NextMercuryInferiorConjunction": 4702519193280286738, "NextMercuryProgradeToRetrograde": 4702519172441957709, "NextMercuryRetrograde": 4702519172441957709, @@ -1661,18 +1661,18 @@ "tt_jd_bits": 4702519275342262978, "events": { "LastMercuryConjunction": 4702519193280286738, - "LastMercuryGreatestElongation": 4702519251788265024, - "LastMercuryGreatestElongationEast": 4702519159807641136, - "LastMercuryGreatestElongationWest": 4702519251788265024, + "LastMercuryGreatestElongation": 4702519251803209668, + "LastMercuryGreatestElongationEast": 4702519159822383466, + "LastMercuryGreatestElongationWest": 4702519251803209668, "LastMercuryInferiorConjunction": 4702519193280286738, "LastMercuryProgradeToRetrograde": 4702519172441957709, "LastMercuryRetrograde": 4702519219748035634, "LastMercuryRetrogradeToPrograde": 4702519219748035634, "LastMercurySuperiorConjunction": 4702519095403444504, "NextMercuryConjunction": 4702519340440980406, - "NextMercuryGreatestElongation": 4702519407867561434, - "NextMercuryGreatestElongationEast": 4702519407867561434, - "NextMercuryGreatestElongationWest": 4702519510565813432, + "NextMercuryGreatestElongation": 4702519407882303708, + "NextMercuryGreatestElongationEast": 4702519407882303708, + "NextMercuryGreatestElongationWest": 4702519510579411654, "NextMercuryInferiorConjunction": 4702519465689299262, "NextMercuryProgradeToRetrograde": 4702519436384211181, "NextMercuryRetrograde": 4702519436384211181, @@ -1685,18 +1685,18 @@ "tt_jd_bits": 4702519467102585836, "events": { "LastMercuryConjunction": 4702519465689299262, - "LastMercuryGreatestElongation": 4702519407867561434, - "LastMercuryGreatestElongationEast": 4702519407867561434, - "LastMercuryGreatestElongationWest": 4702519251788265024, + "LastMercuryGreatestElongation": 4702519407882303774, + "LastMercuryGreatestElongationEast": 4702519407882303774, + "LastMercuryGreatestElongationWest": 4702519251803209770, "LastMercuryInferiorConjunction": 4702519465689299262, "LastMercuryProgradeToRetrograde": 4702519436384211181, "LastMercuryRetrograde": 4702519436384211181, "LastMercuryRetrogradeToPrograde": 4702519219748035634, "LastMercurySuperiorConjunction": 4702519340440980407, "NextMercuryConjunction": 4702519565682294021, - "NextMercuryGreatestElongation": 4702519510565813432, - "NextMercuryGreatestElongationEast": 4702519663126492224, - "NextMercuryGreatestElongationWest": 4702519510565813432, + "NextMercuryGreatestElongation": 4702519510579411760, + "NextMercuryGreatestElongationEast": 4702519663143561772, + "NextMercuryGreatestElongationWest": 4702519510579411760, "NextMercuryInferiorConjunction": 4702519713953418419, "NextMercuryProgradeToRetrograde": 4702519689585460749, "NextMercuryRetrograde": 4702519489175692577, @@ -1709,18 +1709,18 @@ "tt_jd_bits": 4702519658862908693, "events": { "LastMercuryConjunction": 4702519565682294021, - "LastMercuryGreatestElongation": 4702519510565813432, - "LastMercuryGreatestElongationEast": 4702519407867561434, - "LastMercuryGreatestElongationWest": 4702519510565813432, + "LastMercuryGreatestElongation": 4702519510579411764, + "LastMercuryGreatestElongationEast": 4702519407882303794, + "LastMercuryGreatestElongationWest": 4702519510579411764, "LastMercuryInferiorConjunction": 4702519465689299262, "LastMercuryProgradeToRetrograde": 4702519436384211181, "LastMercuryRetrograde": 4702519489175692577, "LastMercuryRetrogradeToPrograde": 4702519489175692577, "LastMercurySuperiorConjunction": 4702519565682294021, "NextMercuryConjunction": 4702519713953418419, - "NextMercuryGreatestElongation": 4702519663126492224, - "NextMercuryGreatestElongationEast": 4702519663126492224, - "NextMercuryGreatestElongationWest": 4702519748585505290, + "NextMercuryGreatestElongation": 4702519663143561726, + "NextMercuryGreatestElongationEast": 4702519663143561726, + "NextMercuryGreatestElongationWest": 4702519748601404264, "NextMercuryInferiorConjunction": 4702519713953418419, "NextMercuryProgradeToRetrograde": 4702519689585460749, "NextMercuryRetrograde": 4702519689585460749, @@ -1733,18 +1733,18 @@ "tt_jd_bits": 4702519850623231550, "events": { "LastMercuryConjunction": 4702519835754607210, - "LastMercuryGreatestElongation": 4702519748585505290, - "LastMercuryGreatestElongationEast": 4702519663126492224, - "LastMercuryGreatestElongationWest": 4702519748585505290, + "LastMercuryGreatestElongation": 4702519748601404246, + "LastMercuryGreatestElongationEast": 4702519663143561726, + "LastMercuryGreatestElongationWest": 4702519748601404246, "LastMercuryInferiorConjunction": 4702519713953418419, "LastMercuryProgradeToRetrograde": 4702519689585460749, "LastMercuryRetrograde": 4702519732579863625, "LastMercuryRetrogradeToPrograde": 4702519732579863625, "LastMercurySuperiorConjunction": 4702519835754607210, "NextMercuryConjunction": 4702519941419670319, - "NextMercuryGreatestElongation": 4702519908239657030, - "NextMercuryGreatestElongationEast": 4702519908239657030, - "NextMercuryGreatestElongationWest": 4702519997764270282, + "NextMercuryGreatestElongation": 4702519908254158490, + "NextMercuryGreatestElongationEast": 4702519908254158490, + "NextMercuryGreatestElongationWest": 4702519997780900876, "NextMercuryInferiorConjunction": 4702519941419670319, "NextMercuryProgradeToRetrograde": 4702519920986509034, "NextMercuryRetrograde": 4702519920986509034, @@ -1757,18 +1757,18 @@ "tt_jd_bits": 4702520039251807421, "events": { "LastMercuryConjunction": 4702519941419670319, - "LastMercuryGreatestElongation": 4702519997764270282, - "LastMercuryGreatestElongationEast": 4702519908239657030, - "LastMercuryGreatestElongationWest": 4702519997764270282, + "LastMercuryGreatestElongation": 4702519997780900868, + "LastMercuryGreatestElongationEast": 4702519908254158484, + "LastMercuryGreatestElongationWest": 4702519997780900868, "LastMercuryInferiorConjunction": 4702519941419670319, "LastMercuryProgradeToRetrograde": 4702519920986509034, "LastMercuryRetrograde": 4702519967286480456, "LastMercuryRetrogradeToPrograde": 4702519967286480456, "LastMercurySuperiorConjunction": 4702519835754607210, "NextMercuryConjunction": 4702520090412489248, - "NextMercuryGreatestElongation": 4702520152241291570, - "NextMercuryGreatestElongationEast": 4702520152241291570, - "NextMercuryGreatestElongationWest": 4702520256440152488, + "NextMercuryGreatestElongation": 4702520152254566918, + "NextMercuryGreatestElongationEast": 4702520152254566918, + "NextMercuryGreatestElongationWest": 4702520256453372080, "NextMercuryInferiorConjunction": 4702520206736693296, "NextMercuryProgradeToRetrograde": 4702520180247569541, "NextMercuryRetrograde": 4702520180247569541, @@ -1781,18 +1781,18 @@ "tt_jd_bits": 4702520230915195253, "events": { "LastMercuryConjunction": 4702520206736693296, - "LastMercuryGreatestElongation": 4702520152241291570, - "LastMercuryGreatestElongationEast": 4702520152241291570, - "LastMercuryGreatestElongationWest": 4702519997764270282, + "LastMercuryGreatestElongation": 4702520152254566868, + "LastMercuryGreatestElongationEast": 4702520152254566868, + "LastMercuryGreatestElongationWest": 4702519997780900816, "LastMercuryInferiorConjunction": 4702520206736693296, "LastMercuryProgradeToRetrograde": 4702520180247569541, "LastMercuryRetrograde": 4702520180247569541, "LastMercuryRetrogradeToPrograde": 4702519967286480456, "LastMercurySuperiorConjunction": 4702520090412489250, "NextMercuryConjunction": 4702520314863970518, - "NextMercuryGreatestElongation": 4702520256440152488, - "NextMercuryGreatestElongationEast": 4702520409386692096, - "NextMercuryGreatestElongationWest": 4702520256440152488, + "NextMercuryGreatestElongation": 4702520256453372056, + "NextMercuryGreatestElongationEast": 4702520409403201232, + "NextMercuryGreatestElongationWest": 4702520256453372056, "NextMercuryInferiorConjunction": 4702520463385818880, "NextMercuryProgradeToRetrograde": 4702520437091377204, "NextMercuryRetrograde": 4702520231917546244, @@ -1805,18 +1805,18 @@ "tt_jd_bits": 4702520422675518110, "events": { "LastMercuryConjunction": 4702520314863970518, - "LastMercuryGreatestElongation": 4702520409386692096, - "LastMercuryGreatestElongationEast": 4702520409386692096, - "LastMercuryGreatestElongationWest": 4702520256440152488, + "LastMercuryGreatestElongation": 4702520409403201242, + "LastMercuryGreatestElongationEast": 4702520409403201242, + "LastMercuryGreatestElongationWest": 4702520256453371996, "LastMercuryInferiorConjunction": 4702520206736693296, "LastMercuryProgradeToRetrograde": 4702520180247569541, "LastMercuryRetrograde": 4702520231917546244, "LastMercuryRetrogradeToPrograde": 4702520231917546244, "LastMercurySuperiorConjunction": 4702520314863970518, "NextMercuryConjunction": 4702520463385818880, - "NextMercuryGreatestElongation": 4702520496906600118, - "NextMercuryGreatestElongationEast": 4702520656467225020, - "NextMercuryGreatestElongationWest": 4702520496906600118, + "NextMercuryGreatestElongation": 4702520496921901824, + "NextMercuryGreatestElongationEast": 4702520656483159404, + "NextMercuryGreatestElongationWest": 4702520496921901824, "NextMercuryInferiorConjunction": 4702520463385818880, "NextMercuryProgradeToRetrograde": 4702520437091377204, "NextMercuryRetrograde": 4702520437091377204, @@ -1829,18 +1829,18 @@ "tt_jd_bits": 4702520614435840967, "events": { "LastMercuryConjunction": 4702520574687109103, - "LastMercuryGreatestElongation": 4702520496906600118, - "LastMercuryGreatestElongationEast": 4702520409386692096, - "LastMercuryGreatestElongationWest": 4702520496906600118, + "LastMercuryGreatestElongation": 4702520496921901886, + "LastMercuryGreatestElongationEast": 4702520409403201262, + "LastMercuryGreatestElongationWest": 4702520496921901886, "LastMercuryInferiorConjunction": 4702520463385818879, "LastMercuryProgradeToRetrograde": 4702520437091377204, "LastMercuryRetrograde": 4702520481419718472, "LastMercuryRetrogradeToPrograde": 4702520481419718472, "LastMercurySuperiorConjunction": 4702520574687109103, "NextMercuryConjunction": 4702520690411075400, - "NextMercuryGreatestElongation": 4702520656467225020, - "NextMercuryGreatestElongationEast": 4702520656467225020, - "NextMercuryGreatestElongationWest": 4702520743831991490, + "NextMercuryGreatestElongation": 4702520656483159340, + "NextMercuryGreatestElongationEast": 4702520656483159340, + "NextMercuryGreatestElongationWest": 4702520743848924086, "NextMercuryInferiorConjunction": 4702520690411075400, "NextMercuryProgradeToRetrograde": 4702520670223580243, "NextMercuryRetrograde": 4702520670223580243, @@ -1853,18 +1853,18 @@ "tt_jd_bits": 4702520806196163825, "events": { "LastMercuryConjunction": 4702520690411075400, - "LastMercuryGreatestElongation": 4702520743831991490, - "LastMercuryGreatestElongationEast": 4702520656467225020, - "LastMercuryGreatestElongationWest": 4702520743831991490, + "LastMercuryGreatestElongation": 4702520743848924086, + "LastMercuryGreatestElongationEast": 4702520656483159412, + "LastMercuryGreatestElongationWest": 4702520743848924086, "LastMercuryInferiorConjunction": 4702520690411075400, "LastMercuryProgradeToRetrograde": 4702520670223580243, "LastMercuryRetrograde": 4702520715381882078, "LastMercuryRetrogradeToPrograde": 4702520715381882078, "LastMercurySuperiorConjunction": 4702520574687109103, "NextMercuryConjunction": 4702520839839900636, - "NextMercuryGreatestElongation": 4702520897340997704, - "NextMercuryGreatestElongationEast": 4702520897340997704, - "NextMercuryGreatestElongationWest": 4702521001386632038, + "NextMercuryGreatestElongation": 4702520897355589528, + "NextMercuryGreatestElongationEast": 4702520897355589528, + "NextMercuryGreatestElongationWest": 4702521001401325168, "NextMercuryInferiorConjunction": 4702520947056955004, "NextMercuryProgradeToRetrograde": 4702520923220032935, "NextMercuryRetrograde": 4702520923220032935, @@ -1877,18 +1877,18 @@ "tt_jd_bits": 4702520994824739695, "events": { "LastMercuryConjunction": 4702520947056955004, - "LastMercuryGreatestElongation": 4702520897340997704, - "LastMercuryGreatestElongationEast": 4702520897340997704, - "LastMercuryGreatestElongationWest": 4702520743831991490, + "LastMercuryGreatestElongation": 4702520897355589594, + "LastMercuryGreatestElongationEast": 4702520897355589594, + "LastMercuryGreatestElongationWest": 4702520743848924086, "LastMercuryInferiorConjunction": 4702520947056955004, "LastMercuryProgradeToRetrograde": 4702520923220032935, "LastMercuryRetrograde": 4702520973149571695, "LastMercuryRetrogradeToPrograde": 4702520973149571695, "LastMercurySuperiorConjunction": 4702520839839900636, "NextMercuryConjunction": 4702521064688048497, - "NextMercuryGreatestElongation": 4702521001386632038, - "NextMercuryGreatestElongationEast": 4702521155637159218, - "NextMercuryGreatestElongationWest": 4702521001386632038, + "NextMercuryGreatestElongation": 4702521001401325176, + "NextMercuryGreatestElongationEast": 4702521155652682372, + "NextMercuryGreatestElongationWest": 4702521001401325176, "NextMercuryInferiorConjunction": 4702521212214219299, "NextMercuryProgradeToRetrograde": 4702521183874755452, "NextMercuryRetrograde": 4702521183874755452, @@ -1901,18 +1901,18 @@ "tt_jd_bits": 4702521186585062553, "events": { "LastMercuryConjunction": 4702521064688048497, - "LastMercuryGreatestElongation": 4702521155637159218, - "LastMercuryGreatestElongationEast": 4702521155637159218, - "LastMercuryGreatestElongationWest": 4702521001386632038, + "LastMercuryGreatestElongation": 4702521155652682364, + "LastMercuryGreatestElongationEast": 4702521155652682364, + "LastMercuryGreatestElongationWest": 4702521001401325164, "LastMercuryInferiorConjunction": 4702520947056955004, "LastMercuryProgradeToRetrograde": 4702521183874755452, "LastMercuryRetrograde": 4702521183874755452, "LastMercuryRetrogradeToPrograde": 4702520973149571695, "LastMercurySuperiorConjunction": 4702521064688048497, "NextMercuryConjunction": 4702521212214219299, - "NextMercuryGreatestElongation": 4702521245579987010, - "NextMercuryGreatestElongationEast": 4702521404319011076, - "NextMercuryGreatestElongationWest": 4702521245579987010, + "NextMercuryGreatestElongation": 4702521245595002960, + "NextMercuryGreatestElongationEast": 4702521404334708348, + "NextMercuryGreatestElongationWest": 4702521245595002960, "NextMercuryInferiorConjunction": 4702521212214219299, "NextMercuryProgradeToRetrograde": 4702521419810530294, "NextMercuryRetrograde": 4702521230067645603, @@ -1925,18 +1925,18 @@ "tt_jd_bits": 4702521378345385410, "events": { "LastMercuryConjunction": 4702521314052968909, - "LastMercuryGreatestElongation": 4702521245579987010, - "LastMercuryGreatestElongationEast": 4702521155637159218, - "LastMercuryGreatestElongationWest": 4702521245579987010, + "LastMercuryGreatestElongation": 4702521245595003042, + "LastMercuryGreatestElongationEast": 4702521155652682288, + "LastMercuryGreatestElongationWest": 4702521245595003042, "LastMercuryInferiorConjunction": 4702521212214219299, "LastMercuryProgradeToRetrograde": 4702521183874755452, "LastMercuryRetrograde": 4702521230067645603, "LastMercuryRetrogradeToPrograde": 4702521230067645603, "LastMercurySuperiorConjunction": 4702521314052968909, "NextMercuryConjunction": 4702521439983297044, - "NextMercuryGreatestElongation": 4702521404319011076, - "NextMercuryGreatestElongationEast": 4702521404319011076, - "NextMercuryGreatestElongationWest": 4702521489808972580, + "NextMercuryGreatestElongation": 4702521404334708414, + "NextMercuryGreatestElongationEast": 4702521404334708414, + "NextMercuryGreatestElongationWest": 4702521489825405132, "NextMercuryInferiorConjunction": 4702521439983297044, "NextMercuryProgradeToRetrograde": 4702521419810530294, "NextMercuryRetrograde": 4702521419810530294, @@ -1949,18 +1949,18 @@ "tt_jd_bits": 4702521570105708267, "events": { "LastMercuryConjunction": 4702521439983297045, - "LastMercuryGreatestElongation": 4702521489808972580, - "LastMercuryGreatestElongationEast": 4702521404319011076, - "LastMercuryGreatestElongationWest": 4702521489808972580, + "LastMercuryGreatestElongation": 4702521489825405172, + "LastMercuryGreatestElongationEast": 4702521404334708362, + "LastMercuryGreatestElongationWest": 4702521489825405172, "LastMercuryInferiorConjunction": 4702521439983297045, "LastMercuryProgradeToRetrograde": 4702521419810530294, "LastMercuryRetrograde": 4702521463851454322, "LastMercuryRetrogradeToPrograde": 4702521463851454322, "LastMercurySuperiorConjunction": 4702521314052968908, "NextMercuryConjunction": 4702521588476144674, - "NextMercuryGreatestElongation": 4702521643498354750, - "NextMercuryGreatestElongationEast": 4702521643498354750, - "NextMercuryGreatestElongationWest": 4702521745776294908, + "NextMercuryGreatestElongation": 4702521643511940258, + "NextMercuryGreatestElongationEast": 4702521643511940258, + "NextMercuryGreatestElongationWest": 4702521745789893332, "NextMercuryInferiorConjunction": 4702521687955546644, "NextMercuryProgradeToRetrograde": 4702521665590823069, "NextMercuryRetrograde": 4702521665590823069, @@ -1973,18 +1973,18 @@ "tt_jd_bits": 4702521761858002916, "events": { "LastMercuryConjunction": 4702521687955546644, - "LastMercuryGreatestElongation": 4702521745776294908, - "LastMercuryGreatestElongationEast": 4702521643498354750, - "LastMercuryGreatestElongationWest": 4702521745776294908, + "LastMercuryGreatestElongation": 4702521745789893312, + "LastMercuryGreatestElongationEast": 4702521643511940318, + "LastMercuryGreatestElongationWest": 4702521745789893312, "LastMercuryInferiorConjunction": 4702521687955546644, "LastMercuryProgradeToRetrograde": 4702521665590823069, "LastMercuryRetrograde": 4702521714351581362, "LastMercuryRetrogradeToPrograde": 4702521714351581362, "LastMercurySuperiorConjunction": 4702521588476144674, "NextMercuryConjunction": 4702521814925417896, - "NextMercuryGreatestElongation": 4702521901759695448, - "NextMercuryGreatestElongationEast": 4702521901759695448, - "NextMercuryGreatestElongationWest": 4702521994349326124, + "NextMercuryGreatestElongation": 4702521901774276372, + "NextMercuryGreatestElongationEast": 4702521901774276372, + "NextMercuryGreatestElongationWest": 4702521994365018122, "NextMercuryInferiorConjunction": 4702521960203324248, "NextMercuryProgradeToRetrograde": 4702521930001963545, "NextMercuryRetrograde": 4702521930001963545, @@ -1997,18 +1997,18 @@ "tt_jd_bits": 4702521950486578787, "events": { "LastMercuryConjunction": 4702521814925417896, - "LastMercuryGreatestElongation": 4702521901759695448, - "LastMercuryGreatestElongationEast": 4702521901759695448, - "LastMercuryGreatestElongationWest": 4702521745776294908, + "LastMercuryGreatestElongation": 4702521901774276380, + "LastMercuryGreatestElongationEast": 4702521901774276380, + "LastMercuryGreatestElongationWest": 4702521745789893232, "LastMercuryInferiorConjunction": 4702521687955546644, "LastMercuryProgradeToRetrograde": 4702521930001963545, "LastMercuryRetrograde": 4702521930001963545, "LastMercuryRetrogradeToPrograde": 4702521714351581362, "LastMercurySuperiorConjunction": 4702521814925417896, "NextMercuryConjunction": 4702521960203324248, - "NextMercuryGreatestElongation": 4702521994349326124, - "NextMercuryGreatestElongationEast": 4702522151692592382, - "NextMercuryGreatestElongationWest": 4702521994349326124, + "NextMercuryGreatestElongation": 4702521994365018122, + "NextMercuryGreatestElongationEast": 4702522151708949490, + "NextMercuryGreatestElongationWest": 4702521994365018122, "NextMercuryInferiorConjunction": 4702521960203324248, "NextMercuryProgradeToRetrograde": 4702522169466930895, "NextMercuryRetrograde": 4702521978382283988, @@ -2021,18 +2021,18 @@ "tt_jd_bits": 4702522142246901644, "events": { "LastMercuryConjunction": 4702522055409754957, - "LastMercuryGreatestElongation": 4702521994349326124, - "LastMercuryGreatestElongationEast": 4702521901759695448, - "LastMercuryGreatestElongationWest": 4702521994349326124, + "LastMercuryGreatestElongation": 4702521994365018132, + "LastMercuryGreatestElongationEast": 4702521901774276320, + "LastMercuryGreatestElongationWest": 4702521994365018132, "LastMercuryInferiorConjunction": 4702521960203324248, "LastMercuryProgradeToRetrograde": 4702521930001963545, "LastMercuryRetrograde": 4702521978382283988, "LastMercuryRetrogradeToPrograde": 4702521978382283988, "LastMercurySuperiorConjunction": 4702522055409754957, "NextMercuryConjunction": 4702522189915632202, - "NextMercuryGreatestElongation": 4702522151692592382, - "NextMercuryGreatestElongationEast": 4702522151692592382, - "NextMercuryGreatestElongationWest": 4702522235726073990, + "NextMercuryGreatestElongation": 4702522151708949484, + "NextMercuryGreatestElongationEast": 4702522151708949484, + "NextMercuryGreatestElongationWest": 4702522235743574904, "NextMercuryInferiorConjunction": 4702522189915632202, "NextMercuryProgradeToRetrograde": 4702522169466930895, "NextMercuryRetrograde": 4702522169466930895, @@ -2045,18 +2045,18 @@ "tt_jd_bits": 4702522334007224502, "events": { "LastMercuryConjunction": 4702522189915632202, - "LastMercuryGreatestElongation": 4702522235726073990, - "LastMercuryGreatestElongationEast": 4702522151692592382, - "LastMercuryGreatestElongationWest": 4702522235726073990, + "LastMercuryGreatestElongation": 4702522235743574904, + "LastMercuryGreatestElongationEast": 4702522151708949554, + "LastMercuryGreatestElongationWest": 4702522235743574904, "LastMercuryInferiorConjunction": 4702522189915632202, "LastMercuryProgradeToRetrograde": 4702522169466930895, "LastMercuryRetrograde": 4702522212551135496, "LastMercuryRetrogradeToPrograde": 4702522212551135496, "LastMercurySuperiorConjunction": 4702522055409754957, "NextMercuryConjunction": 4702522335992123399, - "NextMercuryGreatestElongation": 4702522390626841338, - "NextMercuryGreatestElongationEast": 4702522390626841338, - "NextMercuryGreatestElongationWest": 4702522490139252698, + "NextMercuryGreatestElongation": 4702522390640060732, + "NextMercuryGreatestElongationEast": 4702522390640060732, + "NextMercuryGreatestElongationWest": 4702522490153666620, "NextMercuryInferiorConjunction": 4702522430424473383, "NextMercuryProgradeToRetrograde": 4702522408586365041, "NextMercuryRetrograde": 4702522408586365041, @@ -2069,18 +2069,18 @@ "tt_jd_bits": 4702522525767547359, "events": { "LastMercuryConjunction": 4702522430424473383, - "LastMercuryGreatestElongation": 4702522490139252698, - "LastMercuryGreatestElongationEast": 4702522390626841338, - "LastMercuryGreatestElongationWest": 4702522490139252698, + "LastMercuryGreatestElongation": 4702522490153666620, + "LastMercuryGreatestElongationEast": 4702522390640060696, + "LastMercuryGreatestElongationWest": 4702522490153666620, "LastMercuryInferiorConjunction": 4702522430424473383, "LastMercuryProgradeToRetrograde": 4702522408586365041, "LastMercuryRetrograde": 4702522456977394434, "LastMercuryRetrogradeToPrograde": 4702522456977394434, "LastMercurySuperiorConjunction": 4702522335992123399, "NextMercuryConjunction": 4702522565386831568, - "NextMercuryGreatestElongation": 4702522647521689318, - "NextMercuryGreatestElongationEast": 4702522647521689318, - "NextMercuryGreatestElongationWest": 4702522742951944286, + "NextMercuryGreatestElongation": 4702522647535759292, + "NextMercuryGreatestElongationEast": 4702522647535759292, + "NextMercuryGreatestElongationWest": 4702522742967621182, "NextMercuryInferiorConjunction": 4702522707070699789, "NextMercuryProgradeToRetrograde": 4702522675597363115, "NextMercuryRetrograde": 4702522675597363115, @@ -2093,18 +2093,18 @@ "tt_jd_bits": 4702522717430935190, "events": { "LastMercuryConjunction": 4702522707070699789, - "LastMercuryGreatestElongation": 4702522647521689318, - "LastMercuryGreatestElongationEast": 4702522647521689318, - "LastMercuryGreatestElongationWest": 4702522490139252698, + "LastMercuryGreatestElongation": 4702522647535759248, + "LastMercuryGreatestElongationEast": 4702522647535759248, + "LastMercuryGreatestElongationWest": 4702522490153666548, "LastMercuryInferiorConjunction": 4702522707070699789, "LastMercuryProgradeToRetrograde": 4702522675597363115, "LastMercuryRetrograde": 4702522675597363115, "LastMercuryRetrogradeToPrograde": 4702522456977394434, "LastMercurySuperiorConjunction": 4702522565386831568, "NextMercuryConjunction": 4702522799201581023, - "NextMercuryGreatestElongation": 4702522742951944286, - "NextMercuryGreatestElongationEast": 4702522898575649936, - "NextMercuryGreatestElongationWest": 4702522742951944286, + "NextMercuryGreatestElongation": 4702522742967621182, + "NextMercuryGreatestElongationEast": 4702522898592885922, + "NextMercuryGreatestElongationWest": 4702522742967621182, "NextMercuryInferiorConjunction": 4702522940029247954, "NextMercuryProgradeToRetrograde": 4702522918977879221, "NextMercuryRetrograde": 4702522726137485991, @@ -2117,18 +2117,18 @@ "tt_jd_bits": 4702522906059511061, "events": { "LastMercuryConjunction": 4702522799201581023, - "LastMercuryGreatestElongation": 4702522898575649936, - "LastMercuryGreatestElongationEast": 4702522898575649936, - "LastMercuryGreatestElongationWest": 4702522742951944286, + "LastMercuryGreatestElongation": 4702522898592885940, + "LastMercuryGreatestElongationEast": 4702522898592885940, + "LastMercuryGreatestElongationWest": 4702522742967621114, "LastMercuryInferiorConjunction": 4702522707070699789, "LastMercuryProgradeToRetrograde": 4702522675597363115, "LastMercuryRetrograde": 4702522726137486221, "LastMercuryRetrogradeToPrograde": 4702522726137486221, "LastMercurySuperiorConjunction": 4702522799201581023, "NextMercuryConjunction": 4702522940029247954, - "NextMercuryGreatestElongation": 4702522981865929828, - "NextMercuryGreatestElongationEast": 4702523138476856320, - "NextMercuryGreatestElongationWest": 4702522981865929828, + "NextMercuryGreatestElongation": 4702522981882260074, + "NextMercuryGreatestElongationEast": 4702523138491337192, + "NextMercuryGreatestElongationWest": 4702522981882260074, "NextMercuryInferiorConjunction": 4702522940029247954, "NextMercuryProgradeToRetrograde": 4702522918977879221, "NextMercuryRetrograde": 4702522918977879221, @@ -2141,18 +2141,18 @@ "tt_jd_bits": 4702523097819833918, "events": { "LastMercuryConjunction": 4702523081984198609, - "LastMercuryGreatestElongation": 4702522981865929828, - "LastMercuryGreatestElongationEast": 4702522898575649936, - "LastMercuryGreatestElongationWest": 4702522981865929828, + "LastMercuryGreatestElongation": 4702522981882260132, + "LastMercuryGreatestElongationEast": 4702522898592885840, + "LastMercuryGreatestElongationWest": 4702522981882260132, "LastMercuryInferiorConjunction": 4702522940029247954, "LastMercuryProgradeToRetrograde": 4702522918977879221, "LastMercuryRetrograde": 4702522961395260374, "LastMercuryRetrogradeToPrograde": 4702522961395260374, "LastMercurySuperiorConjunction": 4702523081984198609, "NextMercuryConjunction": 4702523174805169307, - "NextMercuryGreatestElongation": 4702523138476856320, - "NextMercuryGreatestElongationEast": 4702523138476856320, - "NextMercuryGreatestElongationWest": 4702523234940177092, + "NextMercuryGreatestElongation": 4702523138491337194, + "NextMercuryGreatestElongationEast": 4702523138491337194, + "NextMercuryGreatestElongationWest": 4702523234953628014, "NextMercuryInferiorConjunction": 4702523174805169307, "NextMercuryProgradeToRetrograde": 4702523153306149126, "NextMercuryRetrograde": 4702523153306149126, @@ -2165,18 +2165,18 @@ "tt_jd_bits": 4702523289580156776, "events": { "LastMercuryConjunction": 4702523174805169307, - "LastMercuryGreatestElongation": 4702523234940177092, - "LastMercuryGreatestElongationEast": 4702523138476856320, - "LastMercuryGreatestElongationWest": 4702523234940177092, + "LastMercuryGreatestElongation": 4702523234953628040, + "LastMercuryGreatestElongationEast": 4702523138491337276, + "LastMercuryGreatestElongationWest": 4702523234953628040, "LastMercuryInferiorConjunction": 4702523174805169307, "LastMercuryProgradeToRetrograde": 4702523153306149126, "LastMercuryRetrograde": 4702523201486280816, "LastMercuryRetrogradeToPrograde": 4702523201486280816, "LastMercurySuperiorConjunction": 4702523081984198609, "NextMercuryConjunction": 4702523315913884553, - "NextMercuryGreatestElongation": 4702523392683899186, - "NextMercuryGreatestElongationEast": 4702523392683899186, - "NextMercuryGreatestElongationWest": 4702523491108716320, + "NextMercuryGreatestElongation": 4702523392698130554, + "NextMercuryGreatestElongationEast": 4702523392698130554, + "NextMercuryGreatestElongationWest": 4702523491122233770, "NextMercuryInferiorConjunction": 4702523452494055982, "NextMercuryProgradeToRetrograde": 4702523420798388038, "NextMercuryRetrograde": 4702523420798388038, @@ -2189,18 +2189,18 @@ "tt_jd_bits": 4702523481340479633, "events": { "LastMercuryConjunction": 4702523452494055982, - "LastMercuryGreatestElongation": 4702523392683899186, - "LastMercuryGreatestElongationEast": 4702523392683899186, - "LastMercuryGreatestElongationWest": 4702523234940177092, + "LastMercuryGreatestElongation": 4702523392698130506, + "LastMercuryGreatestElongationEast": 4702523392698130506, + "LastMercuryGreatestElongationWest": 4702523234953628040, "LastMercuryInferiorConjunction": 4702523452494055982, "LastMercuryProgradeToRetrograde": 4702523420798388038, "LastMercuryRetrograde": 4702523472995249442, "LastMercuryRetrogradeToPrograde": 4702523472995249442, "LastMercurySuperiorConjunction": 4702523315913884554, "NextMercuryConjunction": 4702523545107504229, - "NextMercuryGreatestElongation": 4702523491108716320, - "NextMercuryGreatestElongationEast": 4702523645057906214, - "NextMercuryGreatestElongationWest": 4702523491108716320, + "NextMercuryGreatestElongation": 4702523491122233752, + "NextMercuryGreatestElongationEast": 4702523645074148714, + "NextMercuryGreatestElongationWest": 4702523491122233752, "NextMercuryInferiorConjunction": 4702523690159178518, "NextMercuryProgradeToRetrograde": 4702523668134553002, "NextMercuryRetrograde": 4702523668134553002, @@ -2213,18 +2213,18 @@ "tt_jd_bits": 4702523673100802490, "events": { "LastMercuryConjunction": 4702523545107504229, - "LastMercuryGreatestElongation": 4702523645057906214, - "LastMercuryGreatestElongationEast": 4702523645057906214, - "LastMercuryGreatestElongationWest": 4702523491108716320, + "LastMercuryGreatestElongation": 4702523645074148748, + "LastMercuryGreatestElongationEast": 4702523645074148748, + "LastMercuryGreatestElongationWest": 4702523491122233724, "LastMercuryInferiorConjunction": 4702523452494055982, "LastMercuryProgradeToRetrograde": 4702523668134553002, "LastMercuryRetrograde": 4702523668134553002, "LastMercuryRetrogradeToPrograde": 4702523472995249442, "LastMercurySuperiorConjunction": 4702523545107504229, "NextMercuryConjunction": 4702523690159178518, - "NextMercuryGreatestElongation": 4702523728526702386, - "NextMercuryGreatestElongationEast": 4702523886760151001, - "NextMercuryGreatestElongationWest": 4702523728526702386, + "NextMercuryGreatestElongation": 4702523728544256114, + "NextMercuryGreatestElongationEast": 4702523886775141164, + "NextMercuryGreatestElongationWest": 4702523728544256114, "NextMercuryInferiorConjunction": 4702523690159178518, "NextMercuryProgradeToRetrograde": 4702523899843708997, "NextMercuryRetrograde": 4702523710311352255, @@ -2237,18 +2237,18 @@ "tt_jd_bits": 4702523861729403216, "events": { "LastMercuryConjunction": 4702523825974003923, - "LastMercuryGreatestElongation": 4702523728526702386, - "LastMercuryGreatestElongationEast": 4702523645057906214, - "LastMercuryGreatestElongationWest": 4702523728526702386, + "LastMercuryGreatestElongation": 4702523728544256148, + "LastMercuryGreatestElongationEast": 4702523645074148752, + "LastMercuryGreatestElongationWest": 4702523728544256148, "LastMercuryInferiorConjunction": 4702523690159178518, "LastMercuryProgradeToRetrograde": 4702523668134553002, "LastMercuryRetrograde": 4702523710311352255, "LastMercuryRetrogradeToPrograde": 4702523710311352255, "LastMercurySuperiorConjunction": 4702523825974003923, "NextMercuryConjunction": 4702523920925552431, - "NextMercuryGreatestElongation": 4702523886760151001, - "NextMercuryGreatestElongationEast": 4702523886760151001, - "NextMercuryGreatestElongationWest": 4702523980317323895, + "NextMercuryGreatestElongation": 4702523886775141174, + "NextMercuryGreatestElongationEast": 4702523886775141174, + "NextMercuryGreatestElongationWest": 4702523980332476038, "NextMercuryInferiorConjunction": 4702523920925552431, "NextMercuryProgradeToRetrograde": 4702523899843548765, "NextMercuryRetrograde": 4702523899843548765, @@ -2261,18 +2261,18 @@ "tt_jd_bits": 4702524053489726073, "events": { "LastMercuryConjunction": 4702523920925552431, - "LastMercuryGreatestElongation": 4702523980317323895, - "LastMercuryGreatestElongationEast": 4702523886760151001, - "LastMercuryGreatestElongationWest": 4702523980317323895, + "LastMercuryGreatestElongation": 4702523980332476038, + "LastMercuryGreatestElongationEast": 4702523886775141164, + "LastMercuryGreatestElongationWest": 4702523980332476038, "LastMercuryInferiorConjunction": 4702523920925552431, "LastMercuryProgradeToRetrograde": 4702523899843708997, "LastMercuryRetrograde": 4702523947550947754, "LastMercuryRetrogradeToPrograde": 4702523947550947754, "LastMercurySuperiorConjunction": 4702523825974003923, "NextMercuryConjunction": 4702524066355571497, - "NextMercuryGreatestElongation": 4702524137222347329, - "NextMercuryGreatestElongationEast": 4702524137222347329, - "NextMercuryGreatestElongationWest": 4702524238543408015, + "NextMercuryGreatestElongation": 4702524137235696890, + "NextMercuryGreatestElongationEast": 4702524137235696890, + "NextMercuryGreatestElongationWest": 4702524238557863152, "NextMercuryInferiorConjunction": 4702524196196683266, "NextMercuryProgradeToRetrograde": 4702524165658089734, "NextMercuryRetrograde": 4702524165658089734, @@ -2285,18 +2285,18 @@ "tt_jd_bits": 4702524245250048931, "events": { "LastMercuryConjunction": 4702524196196683266, - "LastMercuryGreatestElongation": 4702524238543408015, - "LastMercuryGreatestElongationEast": 4702524137222347329, - "LastMercuryGreatestElongationWest": 4702524238543408015, + "LastMercuryGreatestElongation": 4702524238557863152, + "LastMercuryGreatestElongationEast": 4702524137235696862, + "LastMercuryGreatestElongationWest": 4702524238557863152, "LastMercuryInferiorConjunction": 4702524196196683266, "LastMercuryProgradeToRetrograde": 4702524165658089734, "LastMercuryRetrograde": 4702524218538082530, "LastMercuryRetrogradeToPrograde": 4702524218538082530, "LastMercurySuperiorConjunction": 4702524066355571498, "NextMercuryConjunction": 4702524292627632559, - "NextMercuryGreatestElongation": 4702524391332775583, - "NextMercuryGreatestElongationEast": 4702524391332775583, - "NextMercuryGreatestElongationWest": 4702524475875041845, + "NextMercuryGreatestElongation": 4702524391350449466, + "NextMercuryGreatestElongationEast": 4702524391350449466, + "NextMercuryGreatestElongationWest": 4702524475890354962, "NextMercuryInferiorConjunction": 4702524440149507983, "NextMercuryProgradeToRetrograde": 4702524416759873794, "NextMercuryRetrograde": 4702524416759873794, diff --git a/basic/testdata/neptune_event_baseline.json b/basic/testdata/neptune_event_baseline.json index 2b30211..df83fcb 100644 --- a/basic/testdata/neptune_event_baseline.json +++ b/basic/testdata/neptune_event_baseline.json @@ -7,14 +7,14 @@ "LastNeptuneConjunction": 4702515958071314986, "LastNeptuneEasternQuadrature": 4702515761670772405, "LastNeptuneOpposition": 4702515565243505100, - "LastNeptuneProgradeToRetrograde": 4702515392758597244, - "LastNeptuneRetrogradeToPrograde": 4702515736657520699, + "LastNeptuneProgradeToRetrograde": 4702515392758980715, + "LastNeptuneRetrogradeToPrograde": 4702515736657352781, "LastNeptuneWesternQuadrature": 4702515367174432216, "NextNeptuneConjunction": 4702516747062934008, "NextNeptuneEasternQuadrature": 4702516550781575265, "NextNeptuneOpposition": 4702516354552937254, - "NextNeptuneProgradeToRetrograde": 4702516181958153188, - "NextNeptuneRetrogradeToPrograde": 4702516525854802529, + "NextNeptuneProgradeToRetrograde": 4702516181958498583, + "NextNeptuneRetrogradeToPrograde": 4702516525855235736, "NextNeptuneWesternQuadrature": 4702516156366176051 } }, @@ -25,14 +25,14 @@ "LastNeptuneConjunction": 4702515958071314986, "LastNeptuneEasternQuadrature": 4702516550781575265, "LastNeptuneOpposition": 4702516354552937254, - "LastNeptuneProgradeToRetrograde": 4702516181958153188, - "LastNeptuneRetrogradeToPrograde": 4702516525854802529, + "LastNeptuneProgradeToRetrograde": 4702516181958498583, + "LastNeptuneRetrogradeToPrograde": 4702516525855235736, "LastNeptuneWesternQuadrature": 4702516156366176051, "NextNeptuneConjunction": 4702516747062934008, "NextNeptuneEasternQuadrature": 4702517339906794089, "NextNeptuneOpposition": 4702517143871846538, - "NextNeptuneProgradeToRetrograde": 4702516971321654797, - "NextNeptuneRetrogradeToPrograde": 4702517314846741498, + "NextNeptuneProgradeToRetrograde": 4702516971321974406, + "NextNeptuneRetrogradeToPrograde": 4702517314847123779, "NextNeptuneWesternQuadrature": 4702516945567786875 } }, @@ -43,14 +43,14 @@ "LastNeptuneConjunction": 4702516747062934008, "LastNeptuneEasternQuadrature": 4702516550781575265, "LastNeptuneOpposition": 4702517143871846538, - "LastNeptuneProgradeToRetrograde": 4702516971321654797, - "LastNeptuneRetrogradeToPrograde": 4702516525854802529, + "LastNeptuneProgradeToRetrograde": 4702516971321974406, + "LastNeptuneRetrogradeToPrograde": 4702516525855235736, "LastNeptuneWesternQuadrature": 4702516945567786875, "NextNeptuneConjunction": 4702517536084471023, "NextNeptuneEasternQuadrature": 4702517339906794089, "NextNeptuneOpposition": 4702517933168106489, - "NextNeptuneProgradeToRetrograde": 4702517760492438553, - "NextNeptuneRetrogradeToPrograde": 4702517314846773924, + "NextNeptuneProgradeToRetrograde": 4702517760491864431, + "NextNeptuneRetrogradeToPrograde": 4702517314847123772, "NextNeptuneWesternQuadrature": 4702517734779843816 } }, @@ -61,14 +61,14 @@ "LastNeptuneConjunction": 4702517536084471023, "LastNeptuneEasternQuadrature": 4702517339906794089, "LastNeptuneOpposition": 4702517143871846538, - "LastNeptuneProgradeToRetrograde": 4702516971321654797, - "LastNeptuneRetrogradeToPrograde": 4702517314846741498, + "LastNeptuneProgradeToRetrograde": 4702516971321974406, + "LastNeptuneRetrogradeToPrograde": 4702517314847123779, "LastNeptuneWesternQuadrature": 4702516945567786875, "NextNeptuneConjunction": 4702518325087953065, "NextNeptuneEasternQuadrature": 4702518129008814729, "NextNeptuneOpposition": 4702517933168106489, - "NextNeptuneProgradeToRetrograde": 4702517760492438553, - "NextNeptuneRetrogradeToPrograde": 4702518104105954208, + "NextNeptuneProgradeToRetrograde": 4702517760491864431, + "NextNeptuneRetrogradeToPrograde": 4702518104106377713, "NextNeptuneWesternQuadrature": 4702517734779843816 } }, @@ -79,14 +79,14 @@ "LastNeptuneConjunction": 4702517536084471023, "LastNeptuneEasternQuadrature": 4702518129008814729, "LastNeptuneOpposition": 4702517933168106489, - "LastNeptuneProgradeToRetrograde": 4702517760492438553, - "LastNeptuneRetrogradeToPrograde": 4702518104105954208, + "LastNeptuneProgradeToRetrograde": 4702517760491864431, + "LastNeptuneRetrogradeToPrograde": 4702518104106377713, "LastNeptuneWesternQuadrature": 4702517734779843816, "NextNeptuneConjunction": 4702518325087953065, "NextNeptuneEasternQuadrature": 4702518918119473884, "NextNeptuneOpposition": 4702518722476914510, - "NextNeptuneProgradeToRetrograde": 4702518549926167284, - "NextNeptuneRetrogradeToPrograde": 4702518893286728865, + "NextNeptuneProgradeToRetrograde": 4702518549925558622, + "NextNeptuneRetrogradeToPrograde": 4702518893287130509, "NextNeptuneWesternQuadrature": 4702518523991053868 } }, @@ -97,14 +97,14 @@ "LastNeptuneConjunction": 4702518325087953065, "LastNeptuneEasternQuadrature": 4702518129008814729, "LastNeptuneOpposition": 4702518722476914510, - "LastNeptuneProgradeToRetrograde": 4702518549926167284, - "LastNeptuneRetrogradeToPrograde": 4702518104105954208, + "LastNeptuneProgradeToRetrograde": 4702518549925558622, + "LastNeptuneRetrogradeToPrograde": 4702518104106377713, "LastNeptuneWesternQuadrature": 4702518523991053868, "NextNeptuneConjunction": 4702519114088656037, "NextNeptuneEasternQuadrature": 4702518918119473884, "NextNeptuneOpposition": 4702519511790972375, - "NextNeptuneProgradeToRetrograde": 4702519339130797207, - "NextNeptuneRetrogradeToPrograde": 4702518893286728865, + "NextNeptuneProgradeToRetrograde": 4702519339131146637, + "NextNeptuneRetrogradeToPrograde": 4702518893287130509, "NextNeptuneWesternQuadrature": 4702519313217575961 } }, @@ -115,14 +115,14 @@ "LastNeptuneConjunction": 4702519114088656037, "LastNeptuneEasternQuadrature": 4702518918119473884, "LastNeptuneOpposition": 4702518722476914510, - "LastNeptuneProgradeToRetrograde": 4702518549926167284, - "LastNeptuneRetrogradeToPrograde": 4702518893286728865, + "LastNeptuneProgradeToRetrograde": 4702518549925558622, + "LastNeptuneRetrogradeToPrograde": 4702518893287130509, "LastNeptuneWesternQuadrature": 4702518523991053868, "NextNeptuneConjunction": 4702519903089499733, "NextNeptuneEasternQuadrature": 4702519707219348200, "NextNeptuneOpposition": 4702519511790972375, - "NextNeptuneProgradeToRetrograde": 4702519339130797207, - "NextNeptuneRetrogradeToPrograde": 4702519682568496420, + "NextNeptuneProgradeToRetrograde": 4702519339131146637, + "NextNeptuneRetrogradeToPrograde": 4702519682568901241, "NextNeptuneWesternQuadrature": 4702519313217575961 } }, @@ -133,14 +133,14 @@ "LastNeptuneConjunction": 4702519114088656037, "LastNeptuneEasternQuadrature": 4702519707219348200, "LastNeptuneOpposition": 4702519511790972375, - "LastNeptuneProgradeToRetrograde": 4702519339130797207, - "LastNeptuneRetrogradeToPrograde": 4702519682568496420, + "LastNeptuneProgradeToRetrograde": 4702519339131146637, + "LastNeptuneRetrogradeToPrograde": 4702519682568901241, "LastNeptuneWesternQuadrature": 4702519313217575961, "NextNeptuneConjunction": 4702519903089499733, "NextNeptuneEasternQuadrature": 4702520496296638980, "NextNeptuneOpposition": 4702520301079978346, - "NextNeptuneProgradeToRetrograde": 4702520128440124147, - "NextNeptuneRetrogradeToPrograde": 4702520471753801673, + "NextNeptuneProgradeToRetrograde": 4702520128439556103, + "NextNeptuneRetrogradeToPrograde": 4702520471754122963, "NextNeptuneWesternQuadrature": 4702520102439330385 } }, @@ -151,14 +151,14 @@ "LastNeptuneConjunction": 4702519903089499733, "LastNeptuneEasternQuadrature": 4702519707219348200, "LastNeptuneOpposition": 4702519511790972375, - "LastNeptuneProgradeToRetrograde": 4702520128440124147, - "LastNeptuneRetrogradeToPrograde": 4702519682568496420, + "LastNeptuneProgradeToRetrograde": 4702520128439556103, + "LastNeptuneRetrogradeToPrograde": 4702519682568901241, "LastNeptuneWesternQuadrature": 4702520102439330385, "NextNeptuneConjunction": 4702520692096696620, "NextNeptuneEasternQuadrature": 4702520496296638980, "NextNeptuneOpposition": 4702520301079978346, - "NextNeptuneProgradeToRetrograde": 4702520917583587345, - "NextNeptuneRetrogradeToPrograde": 4702520471753801673, + "NextNeptuneProgradeToRetrograde": 4702520917583473844, + "NextNeptuneRetrogradeToPrograde": 4702520471754122963, "NextNeptuneWesternQuadrature": 4702520891671234653 } }, @@ -169,14 +169,14 @@ "LastNeptuneConjunction": 4702520692096696620, "LastNeptuneEasternQuadrature": 4702520496296638980, "LastNeptuneOpposition": 4702520301079978346, - "LastNeptuneProgradeToRetrograde": 4702520128440124147, - "LastNeptuneRetrogradeToPrograde": 4702520471753801673, + "LastNeptuneProgradeToRetrograde": 4702520128439556103, + "LastNeptuneRetrogradeToPrograde": 4702520471754122963, "LastNeptuneWesternQuadrature": 4702520102439330385, "NextNeptuneConjunction": 4702521481101907018, "NextNeptuneEasternQuadrature": 4702521285385202432, "NextNeptuneOpposition": 4702521090382487708, - "NextNeptuneProgradeToRetrograde": 4702520917583587345, - "NextNeptuneRetrogradeToPrograde": 4702521260978775106, + "NextNeptuneProgradeToRetrograde": 4702520917583473844, + "NextNeptuneRetrogradeToPrograde": 4702521260978849757, "NextNeptuneWesternQuadrature": 4702520891671234653 } }, @@ -187,14 +187,14 @@ "LastNeptuneConjunction": 4702520692096696620, "LastNeptuneEasternQuadrature": 4702521285385202432, "LastNeptuneOpposition": 4702521090382487708, - "LastNeptuneProgradeToRetrograde": 4702520917583587345, - "LastNeptuneRetrogradeToPrograde": 4702521260978775106, + "LastNeptuneProgradeToRetrograde": 4702520917583473844, + "LastNeptuneRetrogradeToPrograde": 4702521260978849757, "LastNeptuneWesternQuadrature": 4702520891671234653, "NextNeptuneConjunction": 4702521481101907018, "NextNeptuneEasternQuadrature": 4702522074463675445, "NextNeptuneOpposition": 4702521879668834372, - "NextNeptuneProgradeToRetrograde": 4702521706896672275, - "NextNeptuneRetrogradeToPrograde": 4702522050196815936, + "NextNeptuneProgradeToRetrograde": 4702521706897007747, + "NextNeptuneRetrogradeToPrograde": 4702522050196269996, "NextNeptuneWesternQuadrature": 4702521680882668895 } }, @@ -205,14 +205,14 @@ "LastNeptuneConjunction": 4702521481101907018, "LastNeptuneEasternQuadrature": 4702521285385202432, "LastNeptuneOpposition": 4702521090382487708, - "LastNeptuneProgradeToRetrograde": 4702521706896672275, - "LastNeptuneRetrogradeToPrograde": 4702521260978775106, + "LastNeptuneProgradeToRetrograde": 4702521706897007747, + "LastNeptuneRetrogradeToPrograde": 4702521260978849757, "LastNeptuneWesternQuadrature": 4702521680882668895, "NextNeptuneConjunction": 4702522270114794168, "NextNeptuneEasternQuadrature": 4702522074463675445, "NextNeptuneOpposition": 4702521879668834372, - "NextNeptuneProgradeToRetrograde": 4702522496094655682, - "NextNeptuneRetrogradeToPrograde": 4702522050196815936, + "NextNeptuneProgradeToRetrograde": 4702522496094014179, + "NextNeptuneRetrogradeToPrograde": 4702522050196269996, "NextNeptuneWesternQuadrature": 4702522470129445594 } }, @@ -223,14 +223,14 @@ "LastNeptuneConjunction": 4702522270114794168, "LastNeptuneEasternQuadrature": 4702522074463675445, "LastNeptuneOpposition": 4702521879668834372, - "LastNeptuneProgradeToRetrograde": 4702521706896672275, - "LastNeptuneRetrogradeToPrograde": 4702522050196815936, + "LastNeptuneProgradeToRetrograde": 4702521706897007747, + "LastNeptuneRetrogradeToPrograde": 4702522050196269996, "LastNeptuneWesternQuadrature": 4702521680882668895, "NextNeptuneConjunction": 4702523059136418119, "NextNeptuneEasternQuadrature": 4702522863544440497, "NextNeptuneOpposition": 4702522668978148144, - "NextNeptuneProgradeToRetrograde": 4702522496094655682, - "NextNeptuneRetrogradeToPrograde": 4702522839256657513, + "NextNeptuneProgradeToRetrograde": 4702522496094014179, + "NextNeptuneRetrogradeToPrograde": 4702522839256092809, "NextNeptuneWesternQuadrature": 4702522470129445594 } }, @@ -241,14 +241,14 @@ "LastNeptuneConjunction": 4702522270114794168, "LastNeptuneEasternQuadrature": 4702522863544440497, "LastNeptuneOpposition": 4702522668978148144, - "LastNeptuneProgradeToRetrograde": 4702522496094655682, - "LastNeptuneRetrogradeToPrograde": 4702522839256657513, + "LastNeptuneProgradeToRetrograde": 4702522496094014179, + "LastNeptuneRetrogradeToPrograde": 4702522839256092809, "LastNeptuneWesternQuadrature": 4702522470129445594, "NextNeptuneConjunction": 4702523059136418119, "NextNeptuneEasternQuadrature": 4702523652620580301, "NextNeptuneOpposition": 4702523458272823738, - "NextNeptuneProgradeToRetrograde": 4702523285308725810, - "NextNeptuneRetrogradeToPrograde": 4702523628443216590, + "NextNeptuneProgradeToRetrograde": 4702523285309031993, + "NextNeptuneRetrogradeToPrograde": 4702523628443165167, "NextNeptuneWesternQuadrature": 4702523259384894138 } }, @@ -259,14 +259,14 @@ "LastNeptuneConjunction": 4702523059136418119, "LastNeptuneEasternQuadrature": 4702522863544440497, "LastNeptuneOpposition": 4702522668978148144, - "LastNeptuneProgradeToRetrograde": 4702523285308725810, - "LastNeptuneRetrogradeToPrograde": 4702522839256657513, + "LastNeptuneProgradeToRetrograde": 4702523285309031993, + "LastNeptuneRetrogradeToPrograde": 4702522839256092809, "LastNeptuneWesternQuadrature": 4702523259384894138, "NextNeptuneConjunction": 4702523848163392595, "NextNeptuneEasternQuadrature": 4702523652620580301, "NextNeptuneOpposition": 4702523458272823738, - "NextNeptuneProgradeToRetrograde": 4702524074694677545, - "NextNeptuneRetrogradeToPrograde": 4702523628443047672, + "NextNeptuneProgradeToRetrograde": 4702524074694551106, + "NextNeptuneRetrogradeToPrograde": 4702523628443165131, "NextNeptuneWesternQuadrature": 4702524048638995819 } }, @@ -277,14 +277,14 @@ "LastNeptuneConjunction": 4702523848163392595, "LastNeptuneEasternQuadrature": 4702523652620580301, "LastNeptuneOpposition": 4702523458272823738, - "LastNeptuneProgradeToRetrograde": 4702523285308725810, - "LastNeptuneRetrogradeToPrograde": 4702523628443216590, + "LastNeptuneProgradeToRetrograde": 4702523285309031993, + "LastNeptuneRetrogradeToPrograde": 4702523628443165167, "LastNeptuneWesternQuadrature": 4702523259384894138, "NextNeptuneConjunction": 4702524637213724604, "NextNeptuneEasternQuadrature": 4702524441713696052, "NextNeptuneOpposition": 4702524247567041494, - "NextNeptuneProgradeToRetrograde": 4702524074694677545, - "NextNeptuneRetrogradeToPrograde": 4702524417378215573, + "NextNeptuneProgradeToRetrograde": 4702524074694551106, + "NextNeptuneRetrogradeToPrograde": 4702524417378633213, "NextNeptuneWesternQuadrature": 4702524048638995819 } }, @@ -295,14 +295,14 @@ "LastNeptuneConjunction": 4702523848163392595, "LastNeptuneEasternQuadrature": 4702523652620580301, "LastNeptuneOpposition": 4702524247567041494, - "LastNeptuneProgradeToRetrograde": 4702524074694677545, - "LastNeptuneRetrogradeToPrograde": 4702524417378215573, + "LastNeptuneProgradeToRetrograde": 4702524074694551106, + "LastNeptuneRetrogradeToPrograde": 4702524417378633213, "LastNeptuneWesternQuadrature": 4702524048638995819, "NextNeptuneConjunction": 4702524637213724604, "NextNeptuneEasternQuadrature": 4702524441713696052, "NextNeptuneOpposition": 4702525036874019505, - "NextNeptuneProgradeToRetrograde": 4702524863970118381, - "NextNeptuneRetrogradeToPrograde": 4702525206642192114, + "NextNeptuneProgradeToRetrograde": 4702524863970450093, + "NextNeptuneRetrogradeToPrograde": 4702525206642594395, "NextNeptuneWesternQuadrature": 4702524837919294072 } }, @@ -313,14 +313,14 @@ "LastNeptuneConjunction": 4702524637213724604, "LastNeptuneEasternQuadrature": 4702524441713696052, "LastNeptuneOpposition": 4702524247567041494, - "LastNeptuneProgradeToRetrograde": 4702524863970118381, - "LastNeptuneRetrogradeToPrograde": 4702524417378193227, + "LastNeptuneProgradeToRetrograde": 4702524863970450093, + "LastNeptuneRetrogradeToPrograde": 4702524417378633230, "LastNeptuneWesternQuadrature": 4702524837919294072, "NextNeptuneConjunction": 4702525426251476089, "NextNeptuneEasternQuadrature": 4702525230798199481, "NextNeptuneOpposition": 4702525036874019505, - "NextNeptuneProgradeToRetrograde": 4702525653507557510, - "NextNeptuneRetrogradeToPrograde": 4702525206642192114, + "NextNeptuneProgradeToRetrograde": 4702525653507499727, + "NextNeptuneRetrogradeToPrograde": 4702525206642594395, "NextNeptuneWesternQuadrature": 4702525627200860307 } }, @@ -331,14 +331,14 @@ "LastNeptuneConjunction": 4702525426251476089, "LastNeptuneEasternQuadrature": 4702525230798199481, "LastNeptuneOpposition": 4702525036874019505, - "LastNeptuneProgradeToRetrograde": 4702524863970118381, - "LastNeptuneRetrogradeToPrograde": 4702525206642192114, + "LastNeptuneProgradeToRetrograde": 4702524863970450093, + "LastNeptuneRetrogradeToPrograde": 4702525206642594395, "LastNeptuneWesternQuadrature": 4702524837919294072, "NextNeptuneConjunction": 4702526215328369456, "NextNeptuneEasternQuadrature": 4702526019893479957, "NextNeptuneOpposition": 4702525826189519444, - "NextNeptuneProgradeToRetrograde": 4702525653507557510, - "NextNeptuneRetrogradeToPrograde": 4702525995772605236, + "NextNeptuneProgradeToRetrograde": 4702525653507499727, + "NextNeptuneRetrogradeToPrograde": 4702525995772560808, "NextNeptuneWesternQuadrature": 4702525627200860307 } }, @@ -349,14 +349,14 @@ "LastNeptuneConjunction": 4702525426251476089, "LastNeptuneEasternQuadrature": 4702525230798199481, "LastNeptuneOpposition": 4702525826189519444, - "LastNeptuneProgradeToRetrograde": 4702525653507557510, - "LastNeptuneRetrogradeToPrograde": 4702525206642192114, + "LastNeptuneProgradeToRetrograde": 4702525653507499727, + "LastNeptuneRetrogradeToPrograde": 4702525206642594395, "LastNeptuneWesternQuadrature": 4702525627200860307, "NextNeptuneConjunction": 4702526215328369456, "NextNeptuneEasternQuadrature": 4702526019893479957, "NextNeptuneOpposition": 4702526615497171742, - "NextNeptuneProgradeToRetrograde": 4702526442790834381, - "NextNeptuneRetrogradeToPrograde": 4702525995772605236, + "NextNeptuneProgradeToRetrograde": 4702526442790618734, + "NextNeptuneRetrogradeToPrograde": 4702525995772560808, "NextNeptuneWesternQuadrature": 4702526416513015796 } }, @@ -367,14 +367,14 @@ "LastNeptuneConjunction": 4702526215328369456, "LastNeptuneEasternQuadrature": 4702526019893479957, "LastNeptuneOpposition": 4702525826189519444, - "LastNeptuneProgradeToRetrograde": 4702526442790834381, - "LastNeptuneRetrogradeToPrograde": 4702525995772605236, + "LastNeptuneProgradeToRetrograde": 4702526442790618734, + "LastNeptuneRetrogradeToPrograde": 4702525995772560808, "LastNeptuneWesternQuadrature": 4702526416513015796, "NextNeptuneConjunction": 4702527004406563138, "NextNeptuneEasternQuadrature": 4702526808973746856, "NextNeptuneOpposition": 4702526615497171742, - "NextNeptuneProgradeToRetrograde": 4702527232268104258, - "NextNeptuneRetrogradeToPrograde": 4702526784990926730, + "NextNeptuneProgradeToRetrograde": 4702527232267909524, + "NextNeptuneRetrogradeToPrograde": 4702526784991261082, "NextNeptuneWesternQuadrature": 4702527205815738669 } }, @@ -385,14 +385,14 @@ "LastNeptuneConjunction": 4702527004406563138, "LastNeptuneEasternQuadrature": 4702526808973746856, "LastNeptuneOpposition": 4702526615497171742, - "LastNeptuneProgradeToRetrograde": 4702526442790834381, - "LastNeptuneRetrogradeToPrograde": 4702526784990926730, + "LastNeptuneProgradeToRetrograde": 4702526442790618734, + "LastNeptuneRetrogradeToPrograde": 4702526784991261082, "LastNeptuneWesternQuadrature": 4702526416513015796, "NextNeptuneConjunction": 4702527793483248247, "NextNeptuneEasternQuadrature": 4702527598059033031, "NextNeptuneOpposition": 4702527404804593524, - "NextNeptuneProgradeToRetrograde": 4702527232268104258, - "NextNeptuneRetrogradeToPrograde": 4702527574237003594, + "NextNeptuneProgradeToRetrograde": 4702527232267909524, + "NextNeptuneRetrogradeToPrograde": 4702527574237380580, "NextNeptuneWesternQuadrature": 4702527205815738669 } }, @@ -403,14 +403,14 @@ "LastNeptuneConjunction": 4702527004406563138, "LastNeptuneEasternQuadrature": 4702526808973746856, "LastNeptuneOpposition": 4702527404804593524, - "LastNeptuneProgradeToRetrograde": 4702527232268104258, - "LastNeptuneRetrogradeToPrograde": 4702526784990926730, + "LastNeptuneProgradeToRetrograde": 4702527232267909524, + "LastNeptuneRetrogradeToPrograde": 4702526784991261082, "LastNeptuneWesternQuadrature": 4702527205815738669, "NextNeptuneConjunction": 4702527793483248247, "NextNeptuneEasternQuadrature": 4702527598059033031, "NextNeptuneOpposition": 4702528194123184596, - "NextNeptuneProgradeToRetrograde": 4702528021595354346, - "NextNeptuneRetrogradeToPrograde": 4702527574237003594, + "NextNeptuneProgradeToRetrograde": 4702528021595213706, + "NextNeptuneRetrogradeToPrograde": 4702527574237380580, "NextNeptuneWesternQuadrature": 4702527995131156228 } }, @@ -421,14 +421,14 @@ "LastNeptuneConjunction": 4702527793483248247, "LastNeptuneEasternQuadrature": 4702527598059033031, "LastNeptuneOpposition": 4702527404804593524, - "LastNeptuneProgradeToRetrograde": 4702528021595354346, - "LastNeptuneRetrogradeToPrograde": 4702527574237003594, + "LastNeptuneProgradeToRetrograde": 4702528021595213706, + "LastNeptuneRetrogradeToPrograde": 4702527574237380580, "LastNeptuneWesternQuadrature": 4702527995131156228, "NextNeptuneConjunction": 4702528582571229161, "NextNeptuneEasternQuadrature": 4702528387152282403, "NextNeptuneOpposition": 4702528194123184596, - "NextNeptuneProgradeToRetrograde": 4702528811013889607, - "NextNeptuneRetrogradeToPrograde": 4702528363571405248, + "NextNeptuneProgradeToRetrograde": 4702528811014260950, + "NextNeptuneRetrogradeToPrograde": 4702528363571285636, "NextNeptuneWesternQuadrature": 4702528784456466474 } }, @@ -439,14 +439,14 @@ "LastNeptuneConjunction": 4702527793483248247, "LastNeptuneEasternQuadrature": 4702528387152282403, "LastNeptuneOpposition": 4702528194123184596, - "LastNeptuneProgradeToRetrograde": 4702528021595354346, - "LastNeptuneRetrogradeToPrograde": 4702528363571405248, + "LastNeptuneProgradeToRetrograde": 4702528021595213706, + "LastNeptuneRetrogradeToPrograde": 4702528363571285636, "LastNeptuneWesternQuadrature": 4702527995131156228, "NextNeptuneConjunction": 4702528582571229161, "NextNeptuneEasternQuadrature": 4702529176216997313, "NextNeptuneOpposition": 4702528983421396319, - "NextNeptuneProgradeToRetrograde": 4702528811013889607, - "NextNeptuneRetrogradeToPrograde": 4702529152915157442, + "NextNeptuneProgradeToRetrograde": 4702528811014260950, + "NextNeptuneRetrogradeToPrograde": 4702529152914645610, "NextNeptuneWesternQuadrature": 4702528784456466474 } }, @@ -457,14 +457,14 @@ "LastNeptuneConjunction": 4702528582571229161, "LastNeptuneEasternQuadrature": 4702528387152282403, "LastNeptuneOpposition": 4702528983421396319, - "LastNeptuneProgradeToRetrograde": 4702528811013889607, - "LastNeptuneRetrogradeToPrograde": 4702528363571405248, + "LastNeptuneProgradeToRetrograde": 4702528811014260950, + "LastNeptuneRetrogradeToPrograde": 4702528363571285636, "LastNeptuneWesternQuadrature": 4702528784456466474, "NextNeptuneConjunction": 4702529371649009064, "NextNeptuneEasternQuadrature": 4702529176216997313, "NextNeptuneOpposition": 4702529772727888785, - "NextNeptuneProgradeToRetrograde": 4702529600219767239, - "NextNeptuneRetrogradeToPrograde": 4702529152915157442, + "NextNeptuneProgradeToRetrograde": 4702529600220056142, + "NextNeptuneRetrogradeToPrograde": 4702529152914645610, "NextNeptuneWesternQuadrature": 4702529573792029464 } }, @@ -475,14 +475,14 @@ "LastNeptuneConjunction": 4702529371649009064, "LastNeptuneEasternQuadrature": 4702529176216997313, "LastNeptuneOpposition": 4702528983421396319, - "LastNeptuneProgradeToRetrograde": 4702529600219767239, - "LastNeptuneRetrogradeToPrograde": 4702529152915157442, + "LastNeptuneProgradeToRetrograde": 4702529600220056142, + "LastNeptuneRetrogradeToPrograde": 4702529152914645610, "LastNeptuneWesternQuadrature": 4702529573792029464, "NextNeptuneConjunction": 4702530160748205943, "NextNeptuneEasternQuadrature": 4702529965299814173, "NextNeptuneOpposition": 4702529772727888785, - "NextNeptuneProgradeToRetrograde": 4702530389451926722, - "NextNeptuneRetrogradeToPrograde": 4702529942072356807, + "NextNeptuneProgradeToRetrograde": 4702530389452326200, + "NextNeptuneRetrogradeToPrograde": 4702529942071833739, "NextNeptuneWesternQuadrature": 4702530363107181811 } }, @@ -493,14 +493,14 @@ "LastNeptuneConjunction": 4702529371649009064, "LastNeptuneEasternQuadrature": 4702529965299814173, "LastNeptuneOpposition": 4702529772727888785, - "LastNeptuneProgradeToRetrograde": 4702529600219767239, - "LastNeptuneRetrogradeToPrograde": 4702529942072356807, + "LastNeptuneProgradeToRetrograde": 4702529600220056142, + "LastNeptuneRetrogradeToPrograde": 4702529942071833739, "LastNeptuneWesternQuadrature": 4702529573792029464, "NextNeptuneConjunction": 4702530160748205943, "NextNeptuneEasternQuadrature": 4702530754361025237, "NextNeptuneOpposition": 4702530562002483993, - "NextNeptuneProgradeToRetrograde": 4702530389451926722, - "NextNeptuneRetrogradeToPrograde": 4702530731369183309, + "NextNeptuneProgradeToRetrograde": 4702530389452326200, + "NextNeptuneRetrogradeToPrograde": 4702530731369699712, "NextNeptuneWesternQuadrature": 4702530363107181811 } }, @@ -511,14 +511,14 @@ "LastNeptuneConjunction": 4702530160748205943, "LastNeptuneEasternQuadrature": 4702529965299814173, "LastNeptuneOpposition": 4702530562002483993, - "LastNeptuneProgradeToRetrograde": 4702530389451926722, - "LastNeptuneRetrogradeToPrograde": 4702529942072356807, + "LastNeptuneProgradeToRetrograde": 4702530389452326200, + "LastNeptuneRetrogradeToPrograde": 4702529942071833739, "LastNeptuneWesternQuadrature": 4702530363107181811, "NextNeptuneConjunction": 4702530949834975898, "NextNeptuneEasternQuadrature": 4702530754361025237, "NextNeptuneOpposition": 4702531351300164967, - "NextNeptuneProgradeToRetrograde": 4702531178826175134, - "NextNeptuneRetrogradeToPrograde": 4702530731369183309, + "NextNeptuneProgradeToRetrograde": 4702531178826539411, + "NextNeptuneRetrogradeToPrograde": 4702530731369699712, "NextNeptuneWesternQuadrature": 4702531152433790746 } }, @@ -529,14 +529,14 @@ "LastNeptuneConjunction": 4702530949834975898, "LastNeptuneEasternQuadrature": 4702530754361025237, "LastNeptuneOpposition": 4702530562002483993, - "LastNeptuneProgradeToRetrograde": 4702530389451926722, - "LastNeptuneRetrogradeToPrograde": 4702530731369183309, + "LastNeptuneProgradeToRetrograde": 4702530389452326200, + "LastNeptuneRetrogradeToPrograde": 4702530731369699712, "LastNeptuneWesternQuadrature": 4702531152433790746, "NextNeptuneConjunction": 4702531738934344156, "NextNeptuneEasternQuadrature": 4702531543427518497, "NextNeptuneOpposition": 4702531351300164967, - "NextNeptuneProgradeToRetrograde": 4702531178826175134, - "NextNeptuneRetrogradeToPrograde": 4702531520400612036, + "NextNeptuneProgradeToRetrograde": 4702531178826539411, + "NextNeptuneRetrogradeToPrograde": 4702531520400158015, "NextNeptuneWesternQuadrature": 4702531941774781769 } }, @@ -547,14 +547,14 @@ "LastNeptuneConjunction": 4702530949834975898, "LastNeptuneEasternQuadrature": 4702531543427518497, "LastNeptuneOpposition": 4702531351300164967, - "LastNeptuneProgradeToRetrograde": 4702531178826175134, - "LastNeptuneRetrogradeToPrograde": 4702531520400612036, + "LastNeptuneProgradeToRetrograde": 4702531178826539411, + "LastNeptuneRetrogradeToPrograde": 4702531520400158015, "LastNeptuneWesternQuadrature": 4702531152433790746, "NextNeptuneConjunction": 4702531738934344156, "NextNeptuneEasternQuadrature": 4702532332489621552, "NextNeptuneOpposition": 4702532140589443991, - "NextNeptuneProgradeToRetrograde": 4702531968048406550, - "NextNeptuneRetrogradeToPrograde": 4702532309685778210, + "NextNeptuneProgradeToRetrograde": 4702531968048787947, + "NextNeptuneRetrogradeToPrograde": 4702532309685730532, "NextNeptuneWesternQuadrature": 4702531941774781769 } }, @@ -565,14 +565,14 @@ "LastNeptuneConjunction": 4702531738934344156, "LastNeptuneEasternQuadrature": 4702531543427518497, "LastNeptuneOpposition": 4702532140589443991, - "LastNeptuneProgradeToRetrograde": 4702531968048406550, - "LastNeptuneRetrogradeToPrograde": 4702531520400612036, + "LastNeptuneProgradeToRetrograde": 4702531968048787947, + "LastNeptuneRetrogradeToPrograde": 4702531520400158015, "LastNeptuneWesternQuadrature": 4702531941774781769, "NextNeptuneConjunction": 4702532528040747056, "NextNeptuneEasternQuadrature": 4702532332489621552, "NextNeptuneOpposition": 4702532929864266730, - "NextNeptuneProgradeToRetrograde": 4702532757516299470, - "NextNeptuneRetrogradeToPrograde": 4702532309685778210, + "NextNeptuneProgradeToRetrograde": 4702532757516668760, + "NextNeptuneRetrogradeToPrograde": 4702532309685730532, "NextNeptuneWesternQuadrature": 4702532731111012936 } }, @@ -583,14 +583,14 @@ "LastNeptuneConjunction": 4702532528040747056, "LastNeptuneEasternQuadrature": 4702532332489621552, "LastNeptuneOpposition": 4702532140589443991, - "LastNeptuneProgradeToRetrograde": 4702531968048406550, - "LastNeptuneRetrogradeToPrograde": 4702532309685778210, + "LastNeptuneProgradeToRetrograde": 4702531968048787947, + "LastNeptuneRetrogradeToPrograde": 4702532309685730532, "LastNeptuneWesternQuadrature": 4702531941774781769, "NextNeptuneConjunction": 4702533317166354520, "NextNeptuneEasternQuadrature": 4702533121551344633, "NextNeptuneOpposition": 4702532929864266730, - "NextNeptuneProgradeToRetrograde": 4702532757516299470, - "NextNeptuneRetrogradeToPrograde": 4702533098731074370, + "NextNeptuneProgradeToRetrograde": 4702532757516668760, + "NextNeptuneRetrogradeToPrograde": 4702533098731572620, "NextNeptuneWesternQuadrature": 4702532731111012936 } }, @@ -601,14 +601,14 @@ "LastNeptuneConjunction": 4702532528040747056, "LastNeptuneEasternQuadrature": 4702533121551344633, "LastNeptuneOpposition": 4702532929864266730, - "LastNeptuneProgradeToRetrograde": 4702532757516299470, - "LastNeptuneRetrogradeToPrograde": 4702533098731074370, + "LastNeptuneProgradeToRetrograde": 4702532757516668760, + "LastNeptuneRetrogradeToPrograde": 4702533098731572620, "LastNeptuneWesternQuadrature": 4702532731111012936, "NextNeptuneConjunction": 4702533317166354520, "NextNeptuneEasternQuadrature": 4702533910629209991, "NextNeptuneOpposition": 4702533719152662646, - "NextNeptuneProgradeToRetrograde": 4702533546789128415, - "NextNeptuneRetrogradeToPrograde": 4702533887895297192, + "NextNeptuneProgradeToRetrograde": 4702533546789486334, + "NextNeptuneRetrogradeToPrograde": 4702533887895697927, "NextNeptuneWesternQuadrature": 4702533520467411810 } }, @@ -619,14 +619,14 @@ "LastNeptuneConjunction": 4702533317166354520, "LastNeptuneEasternQuadrature": 4702533121551344633, "LastNeptuneOpposition": 4702533719152662646, - "LastNeptuneProgradeToRetrograde": 4702533546789128415, - "LastNeptuneRetrogradeToPrograde": 4702533098731074370, + "LastNeptuneProgradeToRetrograde": 4702533546789486334, + "LastNeptuneRetrogradeToPrograde": 4702533098731572620, "LastNeptuneWesternQuadrature": 4702533520467411810, "NextNeptuneConjunction": 4702534106286085788, "NextNeptuneEasternQuadrature": 4702533910629209991, "NextNeptuneOpposition": 4702534508438065329, - "NextNeptuneProgradeToRetrograde": 4702534336335481384, - "NextNeptuneRetrogradeToPrograde": 4702533887895297192, + "NextNeptuneProgradeToRetrograde": 4702534336335823956, + "NextNeptuneRetrogradeToPrograde": 4702533887895697927, "NextNeptuneWesternQuadrature": 4702534309810494957 } }, @@ -637,14 +637,14 @@ "LastNeptuneConjunction": 4702534106286085788, "LastNeptuneEasternQuadrature": 4702533910629209991, "LastNeptuneOpposition": 4702533719152662646, - "LastNeptuneProgradeToRetrograde": 4702533546789128415, - "LastNeptuneRetrogradeToPrograde": 4702533887895297192, + "LastNeptuneProgradeToRetrograde": 4702533546789486334, + "LastNeptuneRetrogradeToPrograde": 4702533887895697927, "LastNeptuneWesternQuadrature": 4702533520467411810, "NextNeptuneConjunction": 4702534895431425776, "NextNeptuneEasternQuadrature": 4702534699707650116, "NextNeptuneOpposition": 4702534508438065329, - "NextNeptuneProgradeToRetrograde": 4702534336335481384, - "NextNeptuneRetrogradeToPrograde": 4702534677090154645, + "NextNeptuneProgradeToRetrograde": 4702534336335823956, + "NextNeptuneRetrogradeToPrograde": 4702534677090544960, "NextNeptuneWesternQuadrature": 4702534309810494957 } }, @@ -655,14 +655,14 @@ "LastNeptuneConjunction": 4702534106286085788, "LastNeptuneEasternQuadrature": 4702534699707650116, "LastNeptuneOpposition": 4702534508438065329, - "LastNeptuneProgradeToRetrograde": 4702534336335481384, - "LastNeptuneRetrogradeToPrograde": 4702534677090154645, + "LastNeptuneProgradeToRetrograde": 4702534336335823956, + "LastNeptuneRetrogradeToPrograde": 4702534677090544960, "LastNeptuneWesternQuadrature": 4702534309810494957, "NextNeptuneConjunction": 4702534895431425776, "NextNeptuneEasternQuadrature": 4702535488789482274, "NextNeptuneOpposition": 4702535297730718273, - "NextNeptuneProgradeToRetrograde": 4702535125749189290, - "NextNeptuneRetrogradeToPrograde": 4702535466367733080, + "NextNeptuneProgradeToRetrograde": 4702535125749033298, + "NextNeptuneRetrogradeToPrograde": 4702535466367183422, "NextNeptuneWesternQuadrature": 4702535099195544550 } }, @@ -673,14 +673,14 @@ "LastNeptuneConjunction": 4702534895431425776, "LastNeptuneEasternQuadrature": 4702534699707650116, "LastNeptuneOpposition": 4702535297730718273, - "LastNeptuneProgradeToRetrograde": 4702535125749189290, - "LastNeptuneRetrogradeToPrograde": 4702534677090154645, + "LastNeptuneProgradeToRetrograde": 4702535125749033298, + "LastNeptuneRetrogradeToPrograde": 4702534677090544960, "LastNeptuneWesternQuadrature": 4702535099195544550, "NextNeptuneConjunction": 4702535684598706101, "NextNeptuneEasternQuadrature": 4702535488789482274, "NextNeptuneOpposition": 4702536087013388582, - "NextNeptuneProgradeToRetrograde": 4702535915261532117, - "NextNeptuneRetrogradeToPrograde": 4702535466367733080, + "NextNeptuneProgradeToRetrograde": 4702535915261507533, + "NextNeptuneRetrogradeToPrograde": 4702535466367183422, "NextNeptuneWesternQuadrature": 4702535888577829215 } }, @@ -691,14 +691,14 @@ "LastNeptuneConjunction": 4702535684598706101, "LastNeptuneEasternQuadrature": 4702535488789482274, "LastNeptuneOpposition": 4702535297730718273, - "LastNeptuneProgradeToRetrograde": 4702535125749189290, - "LastNeptuneRetrogradeToPrograde": 4702535466367733080, + "LastNeptuneProgradeToRetrograde": 4702535125749033298, + "LastNeptuneRetrogradeToPrograde": 4702535466367183422, "LastNeptuneWesternQuadrature": 4702535099195544550, "NextNeptuneConjunction": 4702536473772338612, "NextNeptuneEasternQuadrature": 4702536277871155903, "NextNeptuneOpposition": 4702536087013388582, - "NextNeptuneProgradeToRetrograde": 4702535915261532117, - "NextNeptuneRetrogradeToPrograde": 4702536255676929306, + "NextNeptuneProgradeToRetrograde": 4702535915261507533, + "NextNeptuneRetrogradeToPrograde": 4702536255676456498, "NextNeptuneWesternQuadrature": 4702535888577829215 } }, @@ -709,14 +709,14 @@ "LastNeptuneConjunction": 4702535684598706101, "LastNeptuneEasternQuadrature": 4702536277871155903, "LastNeptuneOpposition": 4702536087013388582, - "LastNeptuneProgradeToRetrograde": 4702535915261532117, - "LastNeptuneRetrogradeToPrograde": 4702536255676929306, + "LastNeptuneProgradeToRetrograde": 4702535915261507533, + "LastNeptuneRetrogradeToPrograde": 4702536255676456498, "LastNeptuneWesternQuadrature": 4702535888577829215, "NextNeptuneConjunction": 4702536473772338612, "NextNeptuneEasternQuadrature": 4702537066965433751, "NextNeptuneOpposition": 4702536876302983761, - "NextNeptuneProgradeToRetrograde": 4702536704567765073, - "NextNeptuneRetrogradeToPrograde": 4702537044854770751, + "NextNeptuneProgradeToRetrograde": 4702536704567438923, + "NextNeptuneRetrogradeToPrograde": 4702537044854194256, "NextNeptuneWesternQuadrature": 4702536677959893854 } }, @@ -727,14 +727,14 @@ "LastNeptuneConjunction": 4702536473772338612, "LastNeptuneEasternQuadrature": 4702536277871155903, "LastNeptuneOpposition": 4702536087013388582, - "LastNeptuneProgradeToRetrograde": 4702536704567765073, - "LastNeptuneRetrogradeToPrograde": 4702536255676929306, + "LastNeptuneProgradeToRetrograde": 4702536704567438923, + "LastNeptuneRetrogradeToPrograde": 4702536255676456498, "LastNeptuneWesternQuadrature": 4702536677959893854, "NextNeptuneConjunction": 4702537262966572941, "NextNeptuneEasternQuadrature": 4702537066965433751, "NextNeptuneOpposition": 4702536876302983761, - "NextNeptuneProgradeToRetrograde": 4702537493981355436, - "NextNeptuneRetrogradeToPrograde": 4702537044854770751, + "NextNeptuneProgradeToRetrograde": 4702537493981197823, + "NextNeptuneRetrogradeToPrograde": 4702537044854194256, "NextNeptuneWesternQuadrature": 4702537467357469678 } }, @@ -745,14 +745,14 @@ "LastNeptuneConjunction": 4702537262966572941, "LastNeptuneEasternQuadrature": 4702537066965433751, "LastNeptuneOpposition": 4702536876302983761, - "LastNeptuneProgradeToRetrograde": 4702536704567765073, - "LastNeptuneRetrogradeToPrograde": 4702537044854770751, + "LastNeptuneProgradeToRetrograde": 4702536704567438923, + "LastNeptuneRetrogradeToPrograde": 4702537044854194256, "LastNeptuneWesternQuadrature": 4702536677959893854, "NextNeptuneConjunction": 4702538052142070009, "NextNeptuneEasternQuadrature": 4702537856036181019, "NextNeptuneOpposition": 4702537665587651292, - "NextNeptuneProgradeToRetrograde": 4702537493981355436, - "NextNeptuneRetrogradeToPrograde": 4702537834218507420, + "NextNeptuneProgradeToRetrograde": 4702537493981197823, + "NextNeptuneRetrogradeToPrograde": 4702537834218055423, "NextNeptuneWesternQuadrature": 4702537467357469678 } }, @@ -763,14 +763,14 @@ "LastNeptuneConjunction": 4702537262966572941, "LastNeptuneEasternQuadrature": 4702537856036181019, "LastNeptuneOpposition": 4702537665587651292, - "LastNeptuneProgradeToRetrograde": 4702537493981355436, - "LastNeptuneRetrogradeToPrograde": 4702537834218507420, + "LastNeptuneProgradeToRetrograde": 4702537493981197823, + "LastNeptuneRetrogradeToPrograde": 4702537834218055423, "LastNeptuneWesternQuadrature": 4702537467357469678, "NextNeptuneConjunction": 4702538052142070009, "NextNeptuneEasternQuadrature": 4702538645125268922, "NextNeptuneOpposition": 4702538454876326076, - "NextNeptuneProgradeToRetrograde": 4702538283330968311, - "NextNeptuneRetrogradeToPrograde": 4702538623346453943, + "NextNeptuneProgradeToRetrograde": 4702538283331358485, + "NextNeptuneRetrogradeToPrograde": 4702538623345980667, "NextNeptuneWesternQuadrature": 4702538256764333383 } }, @@ -781,14 +781,14 @@ "LastNeptuneConjunction": 4702538052142070009, "LastNeptuneEasternQuadrature": 4702537856036181019, "LastNeptuneOpposition": 4702537665587651292, - "LastNeptuneProgradeToRetrograde": 4702538283330968311, - "LastNeptuneRetrogradeToPrograde": 4702537834218507420, + "LastNeptuneProgradeToRetrograde": 4702538283331358485, + "LastNeptuneRetrogradeToPrograde": 4702537834218055423, "LastNeptuneWesternQuadrature": 4702538256764333383, "NextNeptuneConjunction": 4702538841346271806, "NextNeptuneEasternQuadrature": 4702538645125268922, "NextNeptuneOpposition": 4702538454876326076, - "NextNeptuneProgradeToRetrograde": 4702539072477695929, - "NextNeptuneRetrogradeToPrograde": 4702538623346453943, + "NextNeptuneProgradeToRetrograde": 4702539072478042835, + "NextNeptuneRetrogradeToPrograde": 4702538623345980667, "NextNeptuneWesternQuadrature": 4702539046168623369 } }, @@ -799,14 +799,14 @@ "LastNeptuneConjunction": 4702538841346271806, "LastNeptuneEasternQuadrature": 4702538645125268922, "LastNeptuneOpposition": 4702538454876326076, - "LastNeptuneProgradeToRetrograde": 4702538283330968311, - "LastNeptuneRetrogradeToPrograde": 4702538623346453943, + "LastNeptuneProgradeToRetrograde": 4702538283331358485, + "LastNeptuneRetrogradeToPrograde": 4702538623345980667, "LastNeptuneWesternQuadrature": 4702538256764333383, "NextNeptuneConjunction": 4702539630536891561, "NextNeptuneEasternQuadrature": 4702539434196253085, "NextNeptuneOpposition": 4702539244131468896, - "NextNeptuneProgradeToRetrograde": 4702539072477695929, - "NextNeptuneRetrogradeToPrograde": 4702539412660875840, + "NextNeptuneProgradeToRetrograde": 4702539072478042835, + "NextNeptuneRetrogradeToPrograde": 4702539412661341177, "NextNeptuneWesternQuadrature": 4702539046168623369 } }, @@ -817,14 +817,14 @@ "LastNeptuneConjunction": 4702538841346271806, "LastNeptuneEasternQuadrature": 4702539434196253085, "LastNeptuneOpposition": 4702539244131468896, - "LastNeptuneProgradeToRetrograde": 4702539072477695929, - "LastNeptuneRetrogradeToPrograde": 4702539412660875840, + "LastNeptuneProgradeToRetrograde": 4702539072478042835, + "LastNeptuneRetrogradeToPrograde": 4702539412661341177, "LastNeptuneWesternQuadrature": 4702539046168623369, "NextNeptuneConjunction": 4702539630536891561, "NextNeptuneEasternQuadrature": 4702540223270108118, "NextNeptuneOpposition": 4702540033395509055, - "NextNeptuneProgradeToRetrograde": 4702539861813114639, - "NextNeptuneRetrogradeToPrograde": 4702540201719174800, + "NextNeptuneProgradeToRetrograde": 4702539861813504129, + "NextNeptuneRetrogradeToPrograde": 4702540201718640214, "NextNeptuneWesternQuadrature": 4702539835561640663 } }, @@ -835,14 +835,14 @@ "LastNeptuneConjunction": 4702539630536891561, "LastNeptuneEasternQuadrature": 4702539434196253085, "LastNeptuneOpposition": 4702539244131468896, - "LastNeptuneProgradeToRetrograde": 4702539861813114639, - "LastNeptuneRetrogradeToPrograde": 4702539412660875840, + "LastNeptuneProgradeToRetrograde": 4702539861813504129, + "LastNeptuneRetrogradeToPrograde": 4702539412661341177, "LastNeptuneWesternQuadrature": 4702539835561640663, "NextNeptuneConjunction": 4702540419728310523, "NextNeptuneEasternQuadrature": 4702540223270108118, "NextNeptuneOpposition": 4702540033395509055, - "NextNeptuneProgradeToRetrograde": 4702540651019672677, - "NextNeptuneRetrogradeToPrograde": 4702540201719174800, + "NextNeptuneProgradeToRetrograde": 4702540651019964617, + "NextNeptuneRetrogradeToPrograde": 4702540201718640214, "NextNeptuneWesternQuadrature": 4702540624959291890 } }, @@ -853,14 +853,14 @@ "LastNeptuneConjunction": 4702540419728310523, "LastNeptuneEasternQuadrature": 4702540223270108118, "LastNeptuneOpposition": 4702540033395509055, - "LastNeptuneProgradeToRetrograde": 4702539861813114639, - "LastNeptuneRetrogradeToPrograde": 4702540201719174800, + "LastNeptuneProgradeToRetrograde": 4702539861813504129, + "LastNeptuneRetrogradeToPrograde": 4702540201718640214, "LastNeptuneWesternQuadrature": 4702539835561640663, "NextNeptuneConjunction": 4702541208933099794, "NextNeptuneEasternQuadrature": 4702541012343622225, "NextNeptuneOpposition": 4702540822659085433, - "NextNeptuneProgradeToRetrograde": 4702540651019672677, - "NextNeptuneRetrogradeToPrograde": 4702540990876315653, + "NextNeptuneProgradeToRetrograde": 4702540651019964617, + "NextNeptuneRetrogradeToPrograde": 4702540990876720456, "NextNeptuneWesternQuadrature": 4702540624959291890 } }, @@ -871,14 +871,14 @@ "LastNeptuneConjunction": 4702515958071314986, "LastNeptuneEasternQuadrature": 4702515761670772405, "LastNeptuneOpposition": 4702515565243505100, - "LastNeptuneProgradeToRetrograde": 4702516181958153188, - "LastNeptuneRetrogradeToPrograde": 4702515736657520699, + "LastNeptuneProgradeToRetrograde": 4702516181958498583, + "LastNeptuneRetrogradeToPrograde": 4702515736657352781, "LastNeptuneWesternQuadrature": 4702516156366176051, "NextNeptuneConjunction": 4702516747062934008, "NextNeptuneEasternQuadrature": 4702516550781575265, "NextNeptuneOpposition": 4702516354552937254, - "NextNeptuneProgradeToRetrograde": 4702516971321654797, - "NextNeptuneRetrogradeToPrograde": 4702516525854802529, + "NextNeptuneProgradeToRetrograde": 4702516971321974406, + "NextNeptuneRetrogradeToPrograde": 4702516525855235736, "NextNeptuneWesternQuadrature": 4702516945567786875 } }, @@ -889,14 +889,14 @@ "LastNeptuneConjunction": 4702515958071314986, "LastNeptuneEasternQuadrature": 4702516550781575265, "LastNeptuneOpposition": 4702516354552937254, - "LastNeptuneProgradeToRetrograde": 4702516181958153188, - "LastNeptuneRetrogradeToPrograde": 4702516525854802529, + "LastNeptuneProgradeToRetrograde": 4702516181958498583, + "LastNeptuneRetrogradeToPrograde": 4702516525855235736, "LastNeptuneWesternQuadrature": 4702516156366176051, "NextNeptuneConjunction": 4702516747062934008, "NextNeptuneEasternQuadrature": 4702517339906794089, "NextNeptuneOpposition": 4702517143871846538, - "NextNeptuneProgradeToRetrograde": 4702516971321654797, - "NextNeptuneRetrogradeToPrograde": 4702517314846741498, + "NextNeptuneProgradeToRetrograde": 4702516971321974406, + "NextNeptuneRetrogradeToPrograde": 4702517314847123779, "NextNeptuneWesternQuadrature": 4702516945567786875 } }, @@ -907,14 +907,14 @@ "LastNeptuneConjunction": 4702516747062934008, "LastNeptuneEasternQuadrature": 4702516550781575265, "LastNeptuneOpposition": 4702516354552937254, - "LastNeptuneProgradeToRetrograde": 4702516971321654797, - "LastNeptuneRetrogradeToPrograde": 4702516525854802529, + "LastNeptuneProgradeToRetrograde": 4702516971321974406, + "LastNeptuneRetrogradeToPrograde": 4702516525855235736, "LastNeptuneWesternQuadrature": 4702516945567786875, "NextNeptuneConjunction": 4702517536084471023, "NextNeptuneEasternQuadrature": 4702517339906794089, "NextNeptuneOpposition": 4702517143871846538, - "NextNeptuneProgradeToRetrograde": 4702517760492438553, - "NextNeptuneRetrogradeToPrograde": 4702517314846773924, + "NextNeptuneProgradeToRetrograde": 4702517760491864431, + "NextNeptuneRetrogradeToPrograde": 4702517314847123772, "NextNeptuneWesternQuadrature": 4702517734779843816 } }, @@ -925,14 +925,14 @@ "LastNeptuneConjunction": 4702516747062934008, "LastNeptuneEasternQuadrature": 4702517339906794089, "LastNeptuneOpposition": 4702517143871846538, - "LastNeptuneProgradeToRetrograde": 4702516971321654797, - "LastNeptuneRetrogradeToPrograde": 4702517314846741498, + "LastNeptuneProgradeToRetrograde": 4702516971321974406, + "LastNeptuneRetrogradeToPrograde": 4702517314847123779, "LastNeptuneWesternQuadrature": 4702516945567786875, "NextNeptuneConjunction": 4702517536084471023, "NextNeptuneEasternQuadrature": 4702518129008814729, "NextNeptuneOpposition": 4702517933168106489, - "NextNeptuneProgradeToRetrograde": 4702517760492438553, - "NextNeptuneRetrogradeToPrograde": 4702518104105954208, + "NextNeptuneProgradeToRetrograde": 4702517760491864431, + "NextNeptuneRetrogradeToPrograde": 4702518104106377713, "NextNeptuneWesternQuadrature": 4702517734779843816 } }, @@ -943,14 +943,14 @@ "LastNeptuneConjunction": 4702517536084471023, "LastNeptuneEasternQuadrature": 4702517339906794089, "LastNeptuneOpposition": 4702517143871846538, - "LastNeptuneProgradeToRetrograde": 4702516971321654797, - "LastNeptuneRetrogradeToPrograde": 4702517314846741498, + "LastNeptuneProgradeToRetrograde": 4702516971321974406, + "LastNeptuneRetrogradeToPrograde": 4702517314847123779, "LastNeptuneWesternQuadrature": 4702517734779843816, "NextNeptuneConjunction": 4702518325087953065, "NextNeptuneEasternQuadrature": 4702518129008814729, "NextNeptuneOpposition": 4702517933168106489, - "NextNeptuneProgradeToRetrograde": 4702517760492438553, - "NextNeptuneRetrogradeToPrograde": 4702518104105954208, + "NextNeptuneProgradeToRetrograde": 4702517760491864431, + "NextNeptuneRetrogradeToPrograde": 4702518104106377713, "NextNeptuneWesternQuadrature": 4702518523991053868 } }, @@ -961,14 +961,14 @@ "LastNeptuneConjunction": 4702517536084471023, "LastNeptuneEasternQuadrature": 4702517339906794089, "LastNeptuneOpposition": 4702517933168106489, - "LastNeptuneProgradeToRetrograde": 4702517760492438553, - "LastNeptuneRetrogradeToPrograde": 4702517314846741498, + "LastNeptuneProgradeToRetrograde": 4702517760491864431, + "LastNeptuneRetrogradeToPrograde": 4702517314847123779, "LastNeptuneWesternQuadrature": 4702517734779843816, "NextNeptuneConjunction": 4702518325087953065, "NextNeptuneEasternQuadrature": 4702518129008814729, "NextNeptuneOpposition": 4702518722476914510, - "NextNeptuneProgradeToRetrograde": 4702518549926167284, - "NextNeptuneRetrogradeToPrograde": 4702518104105954208, + "NextNeptuneProgradeToRetrograde": 4702518549925558622, + "NextNeptuneRetrogradeToPrograde": 4702518104106377713, "NextNeptuneWesternQuadrature": 4702518523991053868 } }, @@ -979,14 +979,14 @@ "LastNeptuneConjunction": 4702518325087953065, "LastNeptuneEasternQuadrature": 4702518129008814729, "LastNeptuneOpposition": 4702517933168106489, - "LastNeptuneProgradeToRetrograde": 4702517760492438553, - "LastNeptuneRetrogradeToPrograde": 4702518104105954208, + "LastNeptuneProgradeToRetrograde": 4702517760491864431, + "LastNeptuneRetrogradeToPrograde": 4702518104106377713, "LastNeptuneWesternQuadrature": 4702517734779843816, "NextNeptuneConjunction": 4702519114088656037, "NextNeptuneEasternQuadrature": 4702518918119473884, "NextNeptuneOpposition": 4702518722476914510, - "NextNeptuneProgradeToRetrograde": 4702518549926167284, - "NextNeptuneRetrogradeToPrograde": 4702518893286728865, + "NextNeptuneProgradeToRetrograde": 4702518549925558622, + "NextNeptuneRetrogradeToPrograde": 4702518893287130509, "NextNeptuneWesternQuadrature": 4702518523991053868 } }, @@ -997,14 +997,14 @@ "LastNeptuneConjunction": 4702518325087953065, "LastNeptuneEasternQuadrature": 4702518129008814729, "LastNeptuneOpposition": 4702518722476914510, - "LastNeptuneProgradeToRetrograde": 4702518549926167284, - "LastNeptuneRetrogradeToPrograde": 4702518104105954208, + "LastNeptuneProgradeToRetrograde": 4702518549925558622, + "LastNeptuneRetrogradeToPrograde": 4702518104106377713, "LastNeptuneWesternQuadrature": 4702518523991053868, "NextNeptuneConjunction": 4702519114088656037, "NextNeptuneEasternQuadrature": 4702518918119473884, "NextNeptuneOpposition": 4702519511790972375, - "NextNeptuneProgradeToRetrograde": 4702519339130797207, - "NextNeptuneRetrogradeToPrograde": 4702518893286728865, + "NextNeptuneProgradeToRetrograde": 4702519339131146637, + "NextNeptuneRetrogradeToPrograde": 4702518893287130509, "NextNeptuneWesternQuadrature": 4702519313217575961 } }, @@ -1015,14 +1015,14 @@ "LastNeptuneConjunction": 4702519114088656037, "LastNeptuneEasternQuadrature": 4702518918119473884, "LastNeptuneOpposition": 4702518722476914510, - "LastNeptuneProgradeToRetrograde": 4702518549926167284, - "LastNeptuneRetrogradeToPrograde": 4702518893286728865, + "LastNeptuneProgradeToRetrograde": 4702518549925558622, + "LastNeptuneRetrogradeToPrograde": 4702518893287130509, "LastNeptuneWesternQuadrature": 4702518523991053868, "NextNeptuneConjunction": 4702519903089499733, "NextNeptuneEasternQuadrature": 4702519707219348200, "NextNeptuneOpposition": 4702519511790972375, - "NextNeptuneProgradeToRetrograde": 4702519339130797207, - "NextNeptuneRetrogradeToPrograde": 4702519682568496420, + "NextNeptuneProgradeToRetrograde": 4702519339131146637, + "NextNeptuneRetrogradeToPrograde": 4702519682568901241, "NextNeptuneWesternQuadrature": 4702519313217575961 } }, @@ -1033,14 +1033,14 @@ "LastNeptuneConjunction": 4702519114088656037, "LastNeptuneEasternQuadrature": 4702518918119473884, "LastNeptuneOpposition": 4702519511790972375, - "LastNeptuneProgradeToRetrograde": 4702519339130797207, - "LastNeptuneRetrogradeToPrograde": 4702518893286728865, + "LastNeptuneProgradeToRetrograde": 4702519339131146637, + "LastNeptuneRetrogradeToPrograde": 4702518893287130509, "LastNeptuneWesternQuadrature": 4702519313217575961, "NextNeptuneConjunction": 4702519903089499733, "NextNeptuneEasternQuadrature": 4702519707219348200, "NextNeptuneOpposition": 4702520301079978346, - "NextNeptuneProgradeToRetrograde": 4702520128440124147, - "NextNeptuneRetrogradeToPrograde": 4702519682568496420, + "NextNeptuneProgradeToRetrograde": 4702520128439556103, + "NextNeptuneRetrogradeToPrograde": 4702519682568901241, "NextNeptuneWesternQuadrature": 4702520102439330385 } }, @@ -1051,14 +1051,14 @@ "LastNeptuneConjunction": 4702519114088656037, "LastNeptuneEasternQuadrature": 4702519707219348200, "LastNeptuneOpposition": 4702519511790972375, - "LastNeptuneProgradeToRetrograde": 4702519339130797207, - "LastNeptuneRetrogradeToPrograde": 4702519682568496420, + "LastNeptuneProgradeToRetrograde": 4702519339131146637, + "LastNeptuneRetrogradeToPrograde": 4702519682568901241, "LastNeptuneWesternQuadrature": 4702519313217575961, "NextNeptuneConjunction": 4702519903089499733, "NextNeptuneEasternQuadrature": 4702520496296638980, "NextNeptuneOpposition": 4702520301079978346, - "NextNeptuneProgradeToRetrograde": 4702520128440124147, - "NextNeptuneRetrogradeToPrograde": 4702520471753801673, + "NextNeptuneProgradeToRetrograde": 4702520128439556103, + "NextNeptuneRetrogradeToPrograde": 4702520471754122963, "NextNeptuneWesternQuadrature": 4702520102439330385 } }, @@ -1069,14 +1069,14 @@ "LastNeptuneConjunction": 4702519903089499733, "LastNeptuneEasternQuadrature": 4702519707219348200, "LastNeptuneOpposition": 4702519511790972375, - "LastNeptuneProgradeToRetrograde": 4702520128440124147, - "LastNeptuneRetrogradeToPrograde": 4702519682568496420, + "LastNeptuneProgradeToRetrograde": 4702520128439556103, + "LastNeptuneRetrogradeToPrograde": 4702519682568901241, "LastNeptuneWesternQuadrature": 4702520102439330385, "NextNeptuneConjunction": 4702520692096696620, "NextNeptuneEasternQuadrature": 4702520496296638980, "NextNeptuneOpposition": 4702520301079978346, - "NextNeptuneProgradeToRetrograde": 4702520917583587345, - "NextNeptuneRetrogradeToPrograde": 4702520471753801673, + "NextNeptuneProgradeToRetrograde": 4702520917583473844, + "NextNeptuneRetrogradeToPrograde": 4702520471754122963, "NextNeptuneWesternQuadrature": 4702520891671234653 } }, @@ -1087,14 +1087,14 @@ "LastNeptuneConjunction": 4702519903089499733, "LastNeptuneEasternQuadrature": 4702520496296638980, "LastNeptuneOpposition": 4702520301079978346, - "LastNeptuneProgradeToRetrograde": 4702520128440124147, - "LastNeptuneRetrogradeToPrograde": 4702520471753801673, + "LastNeptuneProgradeToRetrograde": 4702520128439556103, + "LastNeptuneRetrogradeToPrograde": 4702520471754122963, "LastNeptuneWesternQuadrature": 4702520102439330385, "NextNeptuneConjunction": 4702520692096696620, "NextNeptuneEasternQuadrature": 4702521285385202432, "NextNeptuneOpposition": 4702521090382487708, - "NextNeptuneProgradeToRetrograde": 4702520917583587345, - "NextNeptuneRetrogradeToPrograde": 4702521260978775106, + "NextNeptuneProgradeToRetrograde": 4702520917583473844, + "NextNeptuneRetrogradeToPrograde": 4702521260978849757, "NextNeptuneWesternQuadrature": 4702520891671234653 } }, @@ -1105,14 +1105,14 @@ "LastNeptuneConjunction": 4702520692096696620, "LastNeptuneEasternQuadrature": 4702520496296638980, "LastNeptuneOpposition": 4702520301079978346, - "LastNeptuneProgradeToRetrograde": 4702520917583587345, - "LastNeptuneRetrogradeToPrograde": 4702520471753801673, + "LastNeptuneProgradeToRetrograde": 4702520917583473844, + "LastNeptuneRetrogradeToPrograde": 4702520471754122963, "LastNeptuneWesternQuadrature": 4702520891671234653, "NextNeptuneConjunction": 4702521481101907018, "NextNeptuneEasternQuadrature": 4702521285385202432, "NextNeptuneOpposition": 4702521090382487708, - "NextNeptuneProgradeToRetrograde": 4702521706896672275, - "NextNeptuneRetrogradeToPrograde": 4702521260978775106, + "NextNeptuneProgradeToRetrograde": 4702521706897007747, + "NextNeptuneRetrogradeToPrograde": 4702521260978849757, "NextNeptuneWesternQuadrature": 4702521680882668895 } }, @@ -1123,14 +1123,14 @@ "LastNeptuneConjunction": 4702520692096696620, "LastNeptuneEasternQuadrature": 4702521285385202432, "LastNeptuneOpposition": 4702521090382487708, - "LastNeptuneProgradeToRetrograde": 4702520917583587345, - "LastNeptuneRetrogradeToPrograde": 4702521260978775106, + "LastNeptuneProgradeToRetrograde": 4702520917583473844, + "LastNeptuneRetrogradeToPrograde": 4702521260978849757, "LastNeptuneWesternQuadrature": 4702520891671234653, "NextNeptuneConjunction": 4702521481101907018, "NextNeptuneEasternQuadrature": 4702522074463675445, "NextNeptuneOpposition": 4702521879668834372, - "NextNeptuneProgradeToRetrograde": 4702521706896672275, - "NextNeptuneRetrogradeToPrograde": 4702522050196815936, + "NextNeptuneProgradeToRetrograde": 4702521706897007747, + "NextNeptuneRetrogradeToPrograde": 4702522050196269996, "NextNeptuneWesternQuadrature": 4702521680882668895 } }, @@ -1141,14 +1141,14 @@ "LastNeptuneConjunction": 4702521481101907018, "LastNeptuneEasternQuadrature": 4702521285385202432, "LastNeptuneOpposition": 4702521090382487708, - "LastNeptuneProgradeToRetrograde": 4702520917583587345, - "LastNeptuneRetrogradeToPrograde": 4702521260978775106, + "LastNeptuneProgradeToRetrograde": 4702520917583473844, + "LastNeptuneRetrogradeToPrograde": 4702521260978849757, "LastNeptuneWesternQuadrature": 4702521680882668895, "NextNeptuneConjunction": 4702522270114794168, "NextNeptuneEasternQuadrature": 4702522074463675445, "NextNeptuneOpposition": 4702521879668834372, - "NextNeptuneProgradeToRetrograde": 4702521706896672275, - "NextNeptuneRetrogradeToPrograde": 4702522050196815936, + "NextNeptuneProgradeToRetrograde": 4702521706897007747, + "NextNeptuneRetrogradeToPrograde": 4702522050196269996, "NextNeptuneWesternQuadrature": 4702522470129445594 } }, @@ -1159,14 +1159,14 @@ "LastNeptuneConjunction": 4702521481101907018, "LastNeptuneEasternQuadrature": 4702521285385202432, "LastNeptuneOpposition": 4702521879668834372, - "LastNeptuneProgradeToRetrograde": 4702521706896672275, - "LastNeptuneRetrogradeToPrograde": 4702521260978775106, + "LastNeptuneProgradeToRetrograde": 4702521706897007747, + "LastNeptuneRetrogradeToPrograde": 4702521260978849757, "LastNeptuneWesternQuadrature": 4702521680882668895, "NextNeptuneConjunction": 4702522270114794168, "NextNeptuneEasternQuadrature": 4702522074463675445, "NextNeptuneOpposition": 4702522668978148144, - "NextNeptuneProgradeToRetrograde": 4702522496094655682, - "NextNeptuneRetrogradeToPrograde": 4702522050196815936, + "NextNeptuneProgradeToRetrograde": 4702522496094014179, + "NextNeptuneRetrogradeToPrograde": 4702522050196269996, "NextNeptuneWesternQuadrature": 4702522470129445594 } }, @@ -1177,14 +1177,14 @@ "LastNeptuneConjunction": 4702522270114794168, "LastNeptuneEasternQuadrature": 4702522074463675445, "LastNeptuneOpposition": 4702521879668834372, - "LastNeptuneProgradeToRetrograde": 4702521706896672275, - "LastNeptuneRetrogradeToPrograde": 4702522050196815936, + "LastNeptuneProgradeToRetrograde": 4702521706897007747, + "LastNeptuneRetrogradeToPrograde": 4702522050196269996, "LastNeptuneWesternQuadrature": 4702521680882668895, "NextNeptuneConjunction": 4702523059136418119, "NextNeptuneEasternQuadrature": 4702522863544440497, "NextNeptuneOpposition": 4702522668978148144, - "NextNeptuneProgradeToRetrograde": 4702522496094655682, - "NextNeptuneRetrogradeToPrograde": 4702522839256657513, + "NextNeptuneProgradeToRetrograde": 4702522496094014179, + "NextNeptuneRetrogradeToPrograde": 4702522839256092809, "NextNeptuneWesternQuadrature": 4702522470129445594 } }, @@ -1195,14 +1195,14 @@ "LastNeptuneConjunction": 4702522270114794168, "LastNeptuneEasternQuadrature": 4702522074463675445, "LastNeptuneOpposition": 4702522668978148144, - "LastNeptuneProgradeToRetrograde": 4702522496094655682, - "LastNeptuneRetrogradeToPrograde": 4702522050196815936, + "LastNeptuneProgradeToRetrograde": 4702522496094014179, + "LastNeptuneRetrogradeToPrograde": 4702522050196269996, "LastNeptuneWesternQuadrature": 4702522470129445594, "NextNeptuneConjunction": 4702523059136418119, "NextNeptuneEasternQuadrature": 4702522863544440497, "NextNeptuneOpposition": 4702523458272823738, - "NextNeptuneProgradeToRetrograde": 4702523285308725810, - "NextNeptuneRetrogradeToPrograde": 4702522839256657513, + "NextNeptuneProgradeToRetrograde": 4702523285309031993, + "NextNeptuneRetrogradeToPrograde": 4702522839256092809, "NextNeptuneWesternQuadrature": 4702523259384894138 } }, @@ -1213,14 +1213,14 @@ "LastNeptuneConjunction": 4702523059136418119, "LastNeptuneEasternQuadrature": 4702522863544440497, "LastNeptuneOpposition": 4702522668978148144, - "LastNeptuneProgradeToRetrograde": 4702522496094655682, - "LastNeptuneRetrogradeToPrograde": 4702522839256657513, + "LastNeptuneProgradeToRetrograde": 4702522496094014179, + "LastNeptuneRetrogradeToPrograde": 4702522839256092809, "LastNeptuneWesternQuadrature": 4702522470129445594, "NextNeptuneConjunction": 4702523848163392595, "NextNeptuneEasternQuadrature": 4702523652620580301, "NextNeptuneOpposition": 4702523458272823738, - "NextNeptuneProgradeToRetrograde": 4702523285308725810, - "NextNeptuneRetrogradeToPrograde": 4702523628443216590, + "NextNeptuneProgradeToRetrograde": 4702523285309031993, + "NextNeptuneRetrogradeToPrograde": 4702523628443165167, "NextNeptuneWesternQuadrature": 4702523259384894138 } }, @@ -1231,14 +1231,14 @@ "LastNeptuneConjunction": 4702523059136418119, "LastNeptuneEasternQuadrature": 4702522863544440497, "LastNeptuneOpposition": 4702523458272823738, - "LastNeptuneProgradeToRetrograde": 4702523285308725810, - "LastNeptuneRetrogradeToPrograde": 4702522839256657513, + "LastNeptuneProgradeToRetrograde": 4702523285309031993, + "LastNeptuneRetrogradeToPrograde": 4702522839256092809, "LastNeptuneWesternQuadrature": 4702523259384894138, "NextNeptuneConjunction": 4702523848163392595, "NextNeptuneEasternQuadrature": 4702523652620580301, "NextNeptuneOpposition": 4702524247567041494, - "NextNeptuneProgradeToRetrograde": 4702524074694677545, - "NextNeptuneRetrogradeToPrograde": 4702523628443216590, + "NextNeptuneProgradeToRetrograde": 4702524074694551106, + "NextNeptuneRetrogradeToPrograde": 4702523628443165167, "NextNeptuneWesternQuadrature": 4702524048638995819 } }, @@ -1249,14 +1249,14 @@ "LastNeptuneConjunction": 4702523059136418119, "LastNeptuneEasternQuadrature": 4702523652620580301, "LastNeptuneOpposition": 4702523458272823738, - "LastNeptuneProgradeToRetrograde": 4702523285308725810, - "LastNeptuneRetrogradeToPrograde": 4702523628443216590, + "LastNeptuneProgradeToRetrograde": 4702523285309031993, + "LastNeptuneRetrogradeToPrograde": 4702523628443165167, "LastNeptuneWesternQuadrature": 4702523259384894138, "NextNeptuneConjunction": 4702523848163392595, "NextNeptuneEasternQuadrature": 4702524441713696052, "NextNeptuneOpposition": 4702524247567041494, - "NextNeptuneProgradeToRetrograde": 4702524074694677545, - "NextNeptuneRetrogradeToPrograde": 4702524417378215573, + "NextNeptuneProgradeToRetrograde": 4702524074694551106, + "NextNeptuneRetrogradeToPrograde": 4702524417378633213, "NextNeptuneWesternQuadrature": 4702524048638995819 } }, @@ -1267,14 +1267,14 @@ "LastNeptuneConjunction": 4702523848163392595, "LastNeptuneEasternQuadrature": 4702523652620580301, "LastNeptuneOpposition": 4702523458272823738, - "LastNeptuneProgradeToRetrograde": 4702524074694677545, - "LastNeptuneRetrogradeToPrograde": 4702523628443047672, + "LastNeptuneProgradeToRetrograde": 4702524074694551106, + "LastNeptuneRetrogradeToPrograde": 4702523628443165131, "LastNeptuneWesternQuadrature": 4702524048638995819, "NextNeptuneConjunction": 4702524637213724604, "NextNeptuneEasternQuadrature": 4702524441713696052, "NextNeptuneOpposition": 4702524247567041494, - "NextNeptuneProgradeToRetrograde": 4702524863970118381, - "NextNeptuneRetrogradeToPrograde": 4702524417378193227, + "NextNeptuneProgradeToRetrograde": 4702524863970450093, + "NextNeptuneRetrogradeToPrograde": 4702524417378633230, "NextNeptuneWesternQuadrature": 4702524837919294072 } }, @@ -1285,14 +1285,14 @@ "LastNeptuneConjunction": 4702523848163392595, "LastNeptuneEasternQuadrature": 4702524441713696052, "LastNeptuneOpposition": 4702524247567041494, - "LastNeptuneProgradeToRetrograde": 4702524074694677545, - "LastNeptuneRetrogradeToPrograde": 4702524417378215573, + "LastNeptuneProgradeToRetrograde": 4702524074694551106, + "LastNeptuneRetrogradeToPrograde": 4702524417378633213, "LastNeptuneWesternQuadrature": 4702524048638995819, "NextNeptuneConjunction": 4702524637213724604, "NextNeptuneEasternQuadrature": 4702525230798199481, "NextNeptuneOpposition": 4702525036874019505, - "NextNeptuneProgradeToRetrograde": 4702524863970118381, - "NextNeptuneRetrogradeToPrograde": 4702525206642192114, + "NextNeptuneProgradeToRetrograde": 4702524863970450093, + "NextNeptuneRetrogradeToPrograde": 4702525206642594395, "NextNeptuneWesternQuadrature": 4702524837919294072 } }, @@ -1303,14 +1303,14 @@ "LastNeptuneConjunction": 4702524637213724604, "LastNeptuneEasternQuadrature": 4702524441713696052, "LastNeptuneOpposition": 4702524247567041494, - "LastNeptuneProgradeToRetrograde": 4702524863970118381, - "LastNeptuneRetrogradeToPrograde": 4702524417378215573, + "LastNeptuneProgradeToRetrograde": 4702524863970450093, + "LastNeptuneRetrogradeToPrograde": 4702524417378633213, "LastNeptuneWesternQuadrature": 4702524837919294072, "NextNeptuneConjunction": 4702525426251476089, "NextNeptuneEasternQuadrature": 4702525230798199481, "NextNeptuneOpposition": 4702525036874019505, - "NextNeptuneProgradeToRetrograde": 4702525653507557510, - "NextNeptuneRetrogradeToPrograde": 4702525206642192114, + "NextNeptuneProgradeToRetrograde": 4702525653507499727, + "NextNeptuneRetrogradeToPrograde": 4702525206642594395, "NextNeptuneWesternQuadrature": 4702525627200860307 } }, @@ -1321,14 +1321,14 @@ "LastNeptuneConjunction": 4702524637213724604, "LastNeptuneEasternQuadrature": 4702525230798199481, "LastNeptuneOpposition": 4702525036874019505, - "LastNeptuneProgradeToRetrograde": 4702524863970118381, - "LastNeptuneRetrogradeToPrograde": 4702525206642192114, + "LastNeptuneProgradeToRetrograde": 4702524863970450093, + "LastNeptuneRetrogradeToPrograde": 4702525206642594395, "LastNeptuneWesternQuadrature": 4702524837919294072, "NextNeptuneConjunction": 4702525426251476089, "NextNeptuneEasternQuadrature": 4702526019893479957, "NextNeptuneOpposition": 4702525826189519444, - "NextNeptuneProgradeToRetrograde": 4702525653507557510, - "NextNeptuneRetrogradeToPrograde": 4702525995772605236, + "NextNeptuneProgradeToRetrograde": 4702525653507499727, + "NextNeptuneRetrogradeToPrograde": 4702525995772560808, "NextNeptuneWesternQuadrature": 4702525627200860307 } }, @@ -1339,14 +1339,14 @@ "LastNeptuneConjunction": 4702525426251476089, "LastNeptuneEasternQuadrature": 4702525230798199481, "LastNeptuneOpposition": 4702525036874019505, - "LastNeptuneProgradeToRetrograde": 4702524863970118381, - "LastNeptuneRetrogradeToPrograde": 4702525206642192114, + "LastNeptuneProgradeToRetrograde": 4702524863970450093, + "LastNeptuneRetrogradeToPrograde": 4702525206642594395, "LastNeptuneWesternQuadrature": 4702524837919294072, "NextNeptuneConjunction": 4702526215328369456, "NextNeptuneEasternQuadrature": 4702526019893479957, "NextNeptuneOpposition": 4702525826189519444, - "NextNeptuneProgradeToRetrograde": 4702525653507557510, - "NextNeptuneRetrogradeToPrograde": 4702525995772605236, + "NextNeptuneProgradeToRetrograde": 4702525653507499727, + "NextNeptuneRetrogradeToPrograde": 4702525995772560808, "NextNeptuneWesternQuadrature": 4702525627200860307 } }, @@ -1357,14 +1357,14 @@ "LastNeptuneConjunction": 4702525426251476089, "LastNeptuneEasternQuadrature": 4702525230798199481, "LastNeptuneOpposition": 4702525826189519444, - "LastNeptuneProgradeToRetrograde": 4702525653507557510, - "LastNeptuneRetrogradeToPrograde": 4702525206642192114, + "LastNeptuneProgradeToRetrograde": 4702525653507499727, + "LastNeptuneRetrogradeToPrograde": 4702525206642594395, "LastNeptuneWesternQuadrature": 4702525627200860307, "NextNeptuneConjunction": 4702526215328369456, "NextNeptuneEasternQuadrature": 4702526019893479957, "NextNeptuneOpposition": 4702526615497171742, - "NextNeptuneProgradeToRetrograde": 4702526442790834381, - "NextNeptuneRetrogradeToPrograde": 4702525995772605236, + "NextNeptuneProgradeToRetrograde": 4702526442790618734, + "NextNeptuneRetrogradeToPrograde": 4702525995772560808, "NextNeptuneWesternQuadrature": 4702526416513015796 } }, @@ -1375,14 +1375,14 @@ "LastNeptuneConjunction": 4702526215328369456, "LastNeptuneEasternQuadrature": 4702526019893479957, "LastNeptuneOpposition": 4702525826189519444, - "LastNeptuneProgradeToRetrograde": 4702525653507557510, - "LastNeptuneRetrogradeToPrograde": 4702525995772605236, + "LastNeptuneProgradeToRetrograde": 4702525653507499727, + "LastNeptuneRetrogradeToPrograde": 4702525995772560808, "LastNeptuneWesternQuadrature": 4702525627200860307, "NextNeptuneConjunction": 4702527004406563138, "NextNeptuneEasternQuadrature": 4702526808973746856, "NextNeptuneOpposition": 4702526615497171742, - "NextNeptuneProgradeToRetrograde": 4702526442790834381, - "NextNeptuneRetrogradeToPrograde": 4702526784990926730, + "NextNeptuneProgradeToRetrograde": 4702526442790618734, + "NextNeptuneRetrogradeToPrograde": 4702526784991261082, "NextNeptuneWesternQuadrature": 4702526416513015796 } }, @@ -1393,14 +1393,14 @@ "LastNeptuneConjunction": 4702526215328369456, "LastNeptuneEasternQuadrature": 4702526019893479957, "LastNeptuneOpposition": 4702526615497171742, - "LastNeptuneProgradeToRetrograde": 4702526442790834381, - "LastNeptuneRetrogradeToPrograde": 4702525995772605236, + "LastNeptuneProgradeToRetrograde": 4702526442790618734, + "LastNeptuneRetrogradeToPrograde": 4702525995772560808, "LastNeptuneWesternQuadrature": 4702526416513015796, "NextNeptuneConjunction": 4702527004406563138, "NextNeptuneEasternQuadrature": 4702526808973746856, "NextNeptuneOpposition": 4702527404804593524, - "NextNeptuneProgradeToRetrograde": 4702527232268104258, - "NextNeptuneRetrogradeToPrograde": 4702526784990926730, + "NextNeptuneProgradeToRetrograde": 4702527232267909524, + "NextNeptuneRetrogradeToPrograde": 4702526784991261082, "NextNeptuneWesternQuadrature": 4702527205815738669 } }, @@ -1411,14 +1411,14 @@ "LastNeptuneConjunction": 4702527004406563138, "LastNeptuneEasternQuadrature": 4702526808973746856, "LastNeptuneOpposition": 4702526615497171742, - "LastNeptuneProgradeToRetrograde": 4702526442790834381, - "LastNeptuneRetrogradeToPrograde": 4702526784990926730, + "LastNeptuneProgradeToRetrograde": 4702526442790618734, + "LastNeptuneRetrogradeToPrograde": 4702526784991261082, "LastNeptuneWesternQuadrature": 4702526416513015796, "NextNeptuneConjunction": 4702527793483248247, "NextNeptuneEasternQuadrature": 4702527598059033031, "NextNeptuneOpposition": 4702527404804593524, - "NextNeptuneProgradeToRetrograde": 4702527232268104258, - "NextNeptuneRetrogradeToPrograde": 4702527574237003594, + "NextNeptuneProgradeToRetrograde": 4702527232267909524, + "NextNeptuneRetrogradeToPrograde": 4702527574237380580, "NextNeptuneWesternQuadrature": 4702527205815738669 } }, @@ -1429,14 +1429,14 @@ "LastNeptuneConjunction": 4702527004406563138, "LastNeptuneEasternQuadrature": 4702526808973746856, "LastNeptuneOpposition": 4702527404804593524, - "LastNeptuneProgradeToRetrograde": 4702527232268104258, - "LastNeptuneRetrogradeToPrograde": 4702526784990926730, + "LastNeptuneProgradeToRetrograde": 4702527232267909524, + "LastNeptuneRetrogradeToPrograde": 4702526784991261082, "LastNeptuneWesternQuadrature": 4702527205815738669, "NextNeptuneConjunction": 4702527793483248247, "NextNeptuneEasternQuadrature": 4702527598059033031, "NextNeptuneOpposition": 4702528194123184596, - "NextNeptuneProgradeToRetrograde": 4702528021595354346, - "NextNeptuneRetrogradeToPrograde": 4702527574237003594, + "NextNeptuneProgradeToRetrograde": 4702528021595213706, + "NextNeptuneRetrogradeToPrograde": 4702527574237380580, "NextNeptuneWesternQuadrature": 4702527995131156228 } }, @@ -1447,14 +1447,14 @@ "LastNeptuneConjunction": 4702527004406563138, "LastNeptuneEasternQuadrature": 4702527598059033031, "LastNeptuneOpposition": 4702527404804593524, - "LastNeptuneProgradeToRetrograde": 4702527232268104258, - "LastNeptuneRetrogradeToPrograde": 4702527574237003594, + "LastNeptuneProgradeToRetrograde": 4702527232267909524, + "LastNeptuneRetrogradeToPrograde": 4702527574237380580, "LastNeptuneWesternQuadrature": 4702527205815738669, "NextNeptuneConjunction": 4702527793483248247, "NextNeptuneEasternQuadrature": 4702528387152282403, "NextNeptuneOpposition": 4702528194123184596, - "NextNeptuneProgradeToRetrograde": 4702528021595354346, - "NextNeptuneRetrogradeToPrograde": 4702528363571405248, + "NextNeptuneProgradeToRetrograde": 4702528021595213706, + "NextNeptuneRetrogradeToPrograde": 4702528363571285636, "NextNeptuneWesternQuadrature": 4702527995131156228 } }, @@ -1465,14 +1465,14 @@ "LastNeptuneConjunction": 4702527793483248247, "LastNeptuneEasternQuadrature": 4702527598059033031, "LastNeptuneOpposition": 4702527404804593524, - "LastNeptuneProgradeToRetrograde": 4702528021595354346, - "LastNeptuneRetrogradeToPrograde": 4702527574237899243, + "LastNeptuneProgradeToRetrograde": 4702528021595213706, + "LastNeptuneRetrogradeToPrograde": 4702527574237380574, "LastNeptuneWesternQuadrature": 4702527995131156228, "NextNeptuneConjunction": 4702528582571229161, "NextNeptuneEasternQuadrature": 4702528387152282403, "NextNeptuneOpposition": 4702528194123184596, - "NextNeptuneProgradeToRetrograde": 4702528811013889607, - "NextNeptuneRetrogradeToPrograde": 4702528363571405248, + "NextNeptuneProgradeToRetrograde": 4702528811014260950, + "NextNeptuneRetrogradeToPrograde": 4702528363571285636, "NextNeptuneWesternQuadrature": 4702528784456466474 } }, @@ -1483,14 +1483,14 @@ "LastNeptuneConjunction": 4702527793483248247, "LastNeptuneEasternQuadrature": 4702528387152282403, "LastNeptuneOpposition": 4702528194123184596, - "LastNeptuneProgradeToRetrograde": 4702528021595354346, - "LastNeptuneRetrogradeToPrograde": 4702528363571405248, + "LastNeptuneProgradeToRetrograde": 4702528021595213706, + "LastNeptuneRetrogradeToPrograde": 4702528363571285636, "LastNeptuneWesternQuadrature": 4702527995131156228, "NextNeptuneConjunction": 4702528582571229161, "NextNeptuneEasternQuadrature": 4702529176216997313, "NextNeptuneOpposition": 4702528983421396319, - "NextNeptuneProgradeToRetrograde": 4702528811013889607, - "NextNeptuneRetrogradeToPrograde": 4702529152915157442, + "NextNeptuneProgradeToRetrograde": 4702528811014260950, + "NextNeptuneRetrogradeToPrograde": 4702529152914645610, "NextNeptuneWesternQuadrature": 4702528784456466474 } }, @@ -1501,14 +1501,14 @@ "LastNeptuneConjunction": 4702528582571229161, "LastNeptuneEasternQuadrature": 4702528387152282403, "LastNeptuneOpposition": 4702528194123184596, - "LastNeptuneProgradeToRetrograde": 4702528811013889607, - "LastNeptuneRetrogradeToPrograde": 4702528363571405248, + "LastNeptuneProgradeToRetrograde": 4702528811014260950, + "LastNeptuneRetrogradeToPrograde": 4702528363571285636, "LastNeptuneWesternQuadrature": 4702528784456466474, "NextNeptuneConjunction": 4702529371649009064, "NextNeptuneEasternQuadrature": 4702529176216997313, "NextNeptuneOpposition": 4702528983421396319, - "NextNeptuneProgradeToRetrograde": 4702529600219767239, - "NextNeptuneRetrogradeToPrograde": 4702529152915157442, + "NextNeptuneProgradeToRetrograde": 4702529600220056142, + "NextNeptuneRetrogradeToPrograde": 4702529152914645610, "NextNeptuneWesternQuadrature": 4702529573792029464 } }, @@ -1519,14 +1519,14 @@ "LastNeptuneConjunction": 4702528582571229161, "LastNeptuneEasternQuadrature": 4702529176216997313, "LastNeptuneOpposition": 4702528983421396319, - "LastNeptuneProgradeToRetrograde": 4702528811013889607, - "LastNeptuneRetrogradeToPrograde": 4702529152915157442, + "LastNeptuneProgradeToRetrograde": 4702528811014260950, + "LastNeptuneRetrogradeToPrograde": 4702529152914645610, "LastNeptuneWesternQuadrature": 4702528784456466474, "NextNeptuneConjunction": 4702529371649009064, "NextNeptuneEasternQuadrature": 4702529965299814173, "NextNeptuneOpposition": 4702529772727888785, - "NextNeptuneProgradeToRetrograde": 4702529600219767239, - "NextNeptuneRetrogradeToPrograde": 4702529942072356807, + "NextNeptuneProgradeToRetrograde": 4702529600220056142, + "NextNeptuneRetrogradeToPrograde": 4702529942071833739, "NextNeptuneWesternQuadrature": 4702529573792029464 } }, @@ -1537,14 +1537,14 @@ "LastNeptuneConjunction": 4702529371649009064, "LastNeptuneEasternQuadrature": 4702529176216997313, "LastNeptuneOpposition": 4702528983421396319, - "LastNeptuneProgradeToRetrograde": 4702528811013889607, - "LastNeptuneRetrogradeToPrograde": 4702529152915157442, + "LastNeptuneProgradeToRetrograde": 4702528811014260950, + "LastNeptuneRetrogradeToPrograde": 4702529152914645610, "LastNeptuneWesternQuadrature": 4702528784456466474, "NextNeptuneConjunction": 4702530160748205943, "NextNeptuneEasternQuadrature": 4702529965299814173, "NextNeptuneOpposition": 4702529772727888785, - "NextNeptuneProgradeToRetrograde": 4702529600219767239, - "NextNeptuneRetrogradeToPrograde": 4702529942072356807, + "NextNeptuneProgradeToRetrograde": 4702529600220056142, + "NextNeptuneRetrogradeToPrograde": 4702529942071833739, "NextNeptuneWesternQuadrature": 4702529573792029464 } }, @@ -1555,14 +1555,14 @@ "LastNeptuneConjunction": 4702529371649009064, "LastNeptuneEasternQuadrature": 4702529176216997313, "LastNeptuneOpposition": 4702529772727888785, - "LastNeptuneProgradeToRetrograde": 4702529600219767239, - "LastNeptuneRetrogradeToPrograde": 4702529152915157442, + "LastNeptuneProgradeToRetrograde": 4702529600220056142, + "LastNeptuneRetrogradeToPrograde": 4702529152914645610, "LastNeptuneWesternQuadrature": 4702529573792029464, "NextNeptuneConjunction": 4702530160748205943, "NextNeptuneEasternQuadrature": 4702529965299814173, "NextNeptuneOpposition": 4702530562002483993, - "NextNeptuneProgradeToRetrograde": 4702530389451926722, - "NextNeptuneRetrogradeToPrograde": 4702529942072356807, + "NextNeptuneProgradeToRetrograde": 4702530389452326200, + "NextNeptuneRetrogradeToPrograde": 4702529942071833739, "NextNeptuneWesternQuadrature": 4702530363107181811 } }, @@ -1573,14 +1573,14 @@ "LastNeptuneConjunction": 4702530160748205943, "LastNeptuneEasternQuadrature": 4702529965299814173, "LastNeptuneOpposition": 4702529772727888785, - "LastNeptuneProgradeToRetrograde": 4702529600219767239, - "LastNeptuneRetrogradeToPrograde": 4702529942072356807, + "LastNeptuneProgradeToRetrograde": 4702529600220056142, + "LastNeptuneRetrogradeToPrograde": 4702529942071833739, "LastNeptuneWesternQuadrature": 4702529573792029464, "NextNeptuneConjunction": 4702530949834975898, "NextNeptuneEasternQuadrature": 4702530754361025237, "NextNeptuneOpposition": 4702530562002483993, - "NextNeptuneProgradeToRetrograde": 4702530389451926722, - "NextNeptuneRetrogradeToPrograde": 4702530731369183309, + "NextNeptuneProgradeToRetrograde": 4702530389452326200, + "NextNeptuneRetrogradeToPrograde": 4702530731369699712, "NextNeptuneWesternQuadrature": 4702530363107181811 } }, @@ -1591,14 +1591,14 @@ "LastNeptuneConjunction": 4702530160748205943, "LastNeptuneEasternQuadrature": 4702529965299814173, "LastNeptuneOpposition": 4702530562002483993, - "LastNeptuneProgradeToRetrograde": 4702530389451926722, - "LastNeptuneRetrogradeToPrograde": 4702529942072356807, + "LastNeptuneProgradeToRetrograde": 4702530389452326200, + "LastNeptuneRetrogradeToPrograde": 4702529942071833739, "LastNeptuneWesternQuadrature": 4702530363107181811, "NextNeptuneConjunction": 4702530949834975898, "NextNeptuneEasternQuadrature": 4702530754361025237, "NextNeptuneOpposition": 4702531351300164967, - "NextNeptuneProgradeToRetrograde": 4702531178826175134, - "NextNeptuneRetrogradeToPrograde": 4702530731369183309, + "NextNeptuneProgradeToRetrograde": 4702531178826539411, + "NextNeptuneRetrogradeToPrograde": 4702530731369699712, "NextNeptuneWesternQuadrature": 4702531152433790746 } }, @@ -1609,14 +1609,14 @@ "LastNeptuneConjunction": 4702530949834975898, "LastNeptuneEasternQuadrature": 4702530754361025237, "LastNeptuneOpposition": 4702530562002483993, - "LastNeptuneProgradeToRetrograde": 4702530389451926722, - "LastNeptuneRetrogradeToPrograde": 4702530731369183309, + "LastNeptuneProgradeToRetrograde": 4702530389452326200, + "LastNeptuneRetrogradeToPrograde": 4702530731369699712, "LastNeptuneWesternQuadrature": 4702530363107181811, "NextNeptuneConjunction": 4702531738934344156, "NextNeptuneEasternQuadrature": 4702531543427518497, "NextNeptuneOpposition": 4702531351300164967, - "NextNeptuneProgradeToRetrograde": 4702531178826175134, - "NextNeptuneRetrogradeToPrograde": 4702531520400612036, + "NextNeptuneProgradeToRetrograde": 4702531178826539411, + "NextNeptuneRetrogradeToPrograde": 4702531520400158015, "NextNeptuneWesternQuadrature": 4702531152433790746 } }, @@ -1627,14 +1627,14 @@ "LastNeptuneConjunction": 4702530949834975898, "LastNeptuneEasternQuadrature": 4702530754361025237, "LastNeptuneOpposition": 4702531351300164967, - "LastNeptuneProgradeToRetrograde": 4702531178826175134, - "LastNeptuneRetrogradeToPrograde": 4702530731369183309, + "LastNeptuneProgradeToRetrograde": 4702531178826539411, + "LastNeptuneRetrogradeToPrograde": 4702530731369699712, "LastNeptuneWesternQuadrature": 4702531152433790746, "NextNeptuneConjunction": 4702531738934344156, "NextNeptuneEasternQuadrature": 4702531543427518497, "NextNeptuneOpposition": 4702532140589443991, - "NextNeptuneProgradeToRetrograde": 4702531968048406550, - "NextNeptuneRetrogradeToPrograde": 4702531520399761755, + "NextNeptuneProgradeToRetrograde": 4702531968048787947, + "NextNeptuneRetrogradeToPrograde": 4702531520400158051, "NextNeptuneWesternQuadrature": 4702531941774781769 } }, @@ -1645,14 +1645,14 @@ "LastNeptuneConjunction": 4702530949834975898, "LastNeptuneEasternQuadrature": 4702531543427518497, "LastNeptuneOpposition": 4702531351300164967, - "LastNeptuneProgradeToRetrograde": 4702531178826175134, - "LastNeptuneRetrogradeToPrograde": 4702531520400612036, + "LastNeptuneProgradeToRetrograde": 4702531178826539411, + "LastNeptuneRetrogradeToPrograde": 4702531520400158015, "LastNeptuneWesternQuadrature": 4702531152433790746, "NextNeptuneConjunction": 4702531738934344156, "NextNeptuneEasternQuadrature": 4702532332489621552, "NextNeptuneOpposition": 4702532140589443991, - "NextNeptuneProgradeToRetrograde": 4702531968048406550, - "NextNeptuneRetrogradeToPrograde": 4702532309685778210, + "NextNeptuneProgradeToRetrograde": 4702531968048787947, + "NextNeptuneRetrogradeToPrograde": 4702532309685730532, "NextNeptuneWesternQuadrature": 4702531941774781769 } }, @@ -1663,14 +1663,14 @@ "LastNeptuneConjunction": 4702531738934344156, "LastNeptuneEasternQuadrature": 4702531543427518497, "LastNeptuneOpposition": 4702531351300164967, - "LastNeptuneProgradeToRetrograde": 4702531968048406550, - "LastNeptuneRetrogradeToPrograde": 4702531520400612036, + "LastNeptuneProgradeToRetrograde": 4702531968048787947, + "LastNeptuneRetrogradeToPrograde": 4702531520400158015, "LastNeptuneWesternQuadrature": 4702531941774781769, "NextNeptuneConjunction": 4702532528040747056, "NextNeptuneEasternQuadrature": 4702532332489621552, "NextNeptuneOpposition": 4702532140589443991, - "NextNeptuneProgradeToRetrograde": 4702532757516299470, - "NextNeptuneRetrogradeToPrograde": 4702532309685357845, + "NextNeptuneProgradeToRetrograde": 4702532757516668760, + "NextNeptuneRetrogradeToPrograde": 4702532309685730539, "NextNeptuneWesternQuadrature": 4702532731111012936 } }, @@ -1681,14 +1681,14 @@ "LastNeptuneConjunction": 4702531738934344156, "LastNeptuneEasternQuadrature": 4702532332489621552, "LastNeptuneOpposition": 4702532140589443991, - "LastNeptuneProgradeToRetrograde": 4702531968048406550, - "LastNeptuneRetrogradeToPrograde": 4702532309685778210, + "LastNeptuneProgradeToRetrograde": 4702531968048787947, + "LastNeptuneRetrogradeToPrograde": 4702532309685730532, "LastNeptuneWesternQuadrature": 4702531941774781769, "NextNeptuneConjunction": 4702532528040747056, "NextNeptuneEasternQuadrature": 4702533121551344633, "NextNeptuneOpposition": 4702532929864266730, - "NextNeptuneProgradeToRetrograde": 4702532757516299470, - "NextNeptuneRetrogradeToPrograde": 4702533098731074370, + "NextNeptuneProgradeToRetrograde": 4702532757516668760, + "NextNeptuneRetrogradeToPrograde": 4702533098731572620, "NextNeptuneWesternQuadrature": 4702532731111012936 } }, @@ -1699,14 +1699,14 @@ "LastNeptuneConjunction": 4702532528040747056, "LastNeptuneEasternQuadrature": 4702532332489621552, "LastNeptuneOpposition": 4702532140589443991, - "LastNeptuneProgradeToRetrograde": 4702532757516299470, - "LastNeptuneRetrogradeToPrograde": 4702532309685778210, + "LastNeptuneProgradeToRetrograde": 4702532757516668760, + "LastNeptuneRetrogradeToPrograde": 4702532309685730532, "LastNeptuneWesternQuadrature": 4702532731111012936, "NextNeptuneConjunction": 4702533317166354520, "NextNeptuneEasternQuadrature": 4702533121551344633, "NextNeptuneOpposition": 4702532929864266730, - "NextNeptuneProgradeToRetrograde": 4702533546789128415, - "NextNeptuneRetrogradeToPrograde": 4702533098731074370, + "NextNeptuneProgradeToRetrograde": 4702533546789486334, + "NextNeptuneRetrogradeToPrograde": 4702533098731572620, "NextNeptuneWesternQuadrature": 4702533520467411810 } }, @@ -1717,14 +1717,14 @@ "LastNeptuneConjunction": 4702532528040747056, "LastNeptuneEasternQuadrature": 4702533121551344633, "LastNeptuneOpposition": 4702532929864266730, - "LastNeptuneProgradeToRetrograde": 4702532757516299470, - "LastNeptuneRetrogradeToPrograde": 4702533098731074370, + "LastNeptuneProgradeToRetrograde": 4702532757516668760, + "LastNeptuneRetrogradeToPrograde": 4702533098731572620, "LastNeptuneWesternQuadrature": 4702532731111012936, "NextNeptuneConjunction": 4702533317166354520, "NextNeptuneEasternQuadrature": 4702533910629209991, "NextNeptuneOpposition": 4702533719152662646, - "NextNeptuneProgradeToRetrograde": 4702533546789128415, - "NextNeptuneRetrogradeToPrograde": 4702533887895297192, + "NextNeptuneProgradeToRetrograde": 4702533546789486334, + "NextNeptuneRetrogradeToPrograde": 4702533887895697927, "NextNeptuneWesternQuadrature": 4702533520467411810 } } diff --git a/basic/testdata/saturn_event_baseline.json b/basic/testdata/saturn_event_baseline.json index 408dbcc..105c63a 100644 --- a/basic/testdata/saturn_event_baseline.json +++ b/basic/testdata/saturn_event_baseline.json @@ -7,14 +7,14 @@ "LastSaturnConjunction": 4702511233082790083, "LastSaturnEasternQuadrature": 4702511022491982803, "LastSaturnOpposition": 4702511638661393407, - "LastSaturnProgradeToRetrograde": 4702511486903411901, - "LastSaturnRetrogradeToPrograde": 4702510980431639715, + "LastSaturnProgradeToRetrograde": 4702511486902175689, + "LastSaturnRetrogradeToPrograde": 4702510980432076789, "LastSaturnWesternQuadrature": 4702511444359721740, "NextSaturnConjunction": 4702512041494953604, "NextSaturnEasternQuadrature": 4702511831665381074, "NextSaturnOpposition": 4702512448855447741, - "NextSaturnProgradeToRetrograde": 4702512297376070109, - "NextSaturnRetrogradeToPrograde": 4702511789773905764, + "NextSaturnProgradeToRetrograde": 4702512297375675269, + "NextSaturnRetrogradeToPrograde": 4702511789774326390, "NextSaturnWesternQuadrature": 4702512254003820448 } }, @@ -25,14 +25,14 @@ "LastSaturnConjunction": 4702512041494953604, "LastSaturnEasternQuadrature": 4702511831665381074, "LastSaturnOpposition": 4702511638661393407, - "LastSaturnProgradeToRetrograde": 4702511486903411901, - "LastSaturnRetrogradeToPrograde": 4702511789773905764, + "LastSaturnProgradeToRetrograde": 4702511486902175689, + "LastSaturnRetrogradeToPrograde": 4702511789774326390, "LastSaturnWesternQuadrature": 4702511444359721740, "NextSaturnConjunction": 4702512850183455371, "NextSaturnEasternQuadrature": 4702512640866973769, "NextSaturnOpposition": 4702512448855447741, - "NextSaturnProgradeToRetrograde": 4702512297376070109, - "NextSaturnRetrogradeToPrograde": 4702512599371327020, + "NextSaturnProgradeToRetrograde": 4702512297375675269, + "NextSaturnRetrogradeToPrograde": 4702512599370110906, "NextSaturnWesternQuadrature": 4702512254003820448 } }, @@ -43,14 +43,14 @@ "LastSaturnConjunction": 4702512041494953604, "LastSaturnEasternQuadrature": 4702512640866973769, "LastSaturnOpposition": 4702512448855447741, - "LastSaturnProgradeToRetrograde": 4702512297376070109, - "LastSaturnRetrogradeToPrograde": 4702512599371327020, + "LastSaturnProgradeToRetrograde": 4702512297375675269, + "LastSaturnRetrogradeToPrograde": 4702512599370110906, "LastSaturnWesternQuadrature": 4702512254003820448, "NextSaturnConjunction": 4702512850183455371, "NextSaturnEasternQuadrature": 4702513450206894394, "NextSaturnOpposition": 4702513259308967068, - "NextSaturnProgradeToRetrograde": 4702513108331851908, - "NextSaturnRetrogradeToPrograde": 4702513409106697783, + "NextSaturnProgradeToRetrograde": 4702513108331391671, + "NextSaturnRetrogradeToPrograde": 4702513409105573833, "NextSaturnWesternQuadrature": 4702513064123992244 } }, @@ -61,14 +61,14 @@ "LastSaturnConjunction": 4702512850183455371, "LastSaturnEasternQuadrature": 4702512640866973769, "LastSaturnOpposition": 4702512448855447741, - "LastSaturnProgradeToRetrograde": 4702513108331851908, - "LastSaturnRetrogradeToPrograde": 4702512599371327020, + "LastSaturnProgradeToRetrograde": 4702513108331391671, + "LastSaturnRetrogradeToPrograde": 4702512599370110906, "LastSaturnWesternQuadrature": 4702513064123992244, "NextSaturnConjunction": 4702513659304086479, "NextSaturnEasternQuadrature": 4702513450206894394, "NextSaturnOpposition": 4702513259308967068, - "NextSaturnProgradeToRetrograde": 4702513919872693313, - "NextSaturnRetrogradeToPrograde": 4702513409106697783, + "NextSaturnProgradeToRetrograde": 4702513919871824425, + "NextSaturnRetrogradeToPrograde": 4702513409105573833, "NextSaturnWesternQuadrature": 4702513874832589619 } }, @@ -79,14 +79,14 @@ "LastSaturnConjunction": 4702512850183455371, "LastSaturnEasternQuadrature": 4702513450206894394, "LastSaturnOpposition": 4702513259308967068, - "LastSaturnProgradeToRetrograde": 4702513108331851908, - "LastSaturnRetrogradeToPrograde": 4702513409106697783, + "LastSaturnProgradeToRetrograde": 4702513108331391671, + "LastSaturnRetrogradeToPrograde": 4702513409105573833, "LastSaturnWesternQuadrature": 4702513064123992244, "NextSaturnConjunction": 4702513659304086479, "NextSaturnEasternQuadrature": 4702514259798420534, "NextSaturnOpposition": 4702514070085314883, - "NextSaturnProgradeToRetrograde": 4702513919872693313, - "NextSaturnRetrogradeToPrograde": 4702514219286235215, + "NextSaturnProgradeToRetrograde": 4702513919871824425, + "NextSaturnRetrogradeToPrograde": 4702514219285831548, "NextSaturnWesternQuadrature": 4702513874832589619 } }, @@ -97,14 +97,14 @@ "LastSaturnConjunction": 4702513659304086479, "LastSaturnEasternQuadrature": 4702513450206894394, "LastSaturnOpposition": 4702514070085314883, - "LastSaturnProgradeToRetrograde": 4702513919872693313, - "LastSaturnRetrogradeToPrograde": 4702513409106697783, + "LastSaturnProgradeToRetrograde": 4702513919871824425, + "LastSaturnRetrogradeToPrograde": 4702513409105573833, "LastSaturnWesternQuadrature": 4702513874832589619, "NextSaturnConjunction": 4702514468983751639, "NextSaturnEasternQuadrature": 4702514259798420534, "NextSaturnOpposition": 4702514881266846949, - "NextSaturnProgradeToRetrograde": 4702514731951225270, - "NextSaturnRetrogradeToPrograde": 4702514219286235215, + "NextSaturnProgradeToRetrograde": 4702514731951642450, + "NextSaturnRetrogradeToPrograde": 4702514219285831548, "NextSaturnWesternQuadrature": 4702514686189470628 } }, @@ -115,14 +115,14 @@ "LastSaturnConjunction": 4702514468983751639, "LastSaturnEasternQuadrature": 4702514259798420534, "LastSaturnOpposition": 4702514070085314883, - "LastSaturnProgradeToRetrograde": 4702513919872693313, - "LastSaturnRetrogradeToPrograde": 4702514219286235215, + "LastSaturnProgradeToRetrograde": 4702513919871824425, + "LastSaturnRetrogradeToPrograde": 4702514219285831548, "LastSaturnWesternQuadrature": 4702513874832589619, "NextSaturnConjunction": 4702515279344841789, "NextSaturnEasternQuadrature": 4702515069774964954, "NextSaturnOpposition": 4702514881266846949, - "NextSaturnProgradeToRetrograde": 4702514731951225270, - "NextSaturnRetrogradeToPrograde": 4702515029844758667, + "NextSaturnProgradeToRetrograde": 4702514731951642450, + "NextSaturnRetrogradeToPrograde": 4702515029843514851, "NextSaturnWesternQuadrature": 4702514686189470628 } }, @@ -133,14 +133,14 @@ "LastSaturnConjunction": 4702514468983751639, "LastSaturnEasternQuadrature": 4702515069774964954, "LastSaturnOpposition": 4702514881266846949, - "LastSaturnProgradeToRetrograde": 4702514731951225270, - "LastSaturnRetrogradeToPrograde": 4702515029844758667, + "LastSaturnProgradeToRetrograde": 4702514731951642450, + "LastSaturnRetrogradeToPrograde": 4702515029843514851, "LastSaturnWesternQuadrature": 4702514686189470628, "NextSaturnConjunction": 4702515279344841789, "NextSaturnEasternQuadrature": 4702515880214391202, "NextSaturnOpposition": 4702515692894798901, - "NextSaturnProgradeToRetrograde": 4702515544354678665, - "NextSaturnRetrogradeToPrograde": 4702515840919517425, + "NextSaturnProgradeToRetrograde": 4702515544355111779, + "NextSaturnRetrogradeToPrograde": 4702515840918958974, "NextSaturnWesternQuadrature": 4702515498244640404 } }, @@ -151,14 +151,14 @@ "LastSaturnConjunction": 4702515279344841789, "LastSaturnEasternQuadrature": 4702515069774964954, "LastSaturnOpposition": 4702514881266846949, - "LastSaturnProgradeToRetrograde": 4702515544354678665, - "LastSaturnRetrogradeToPrograde": 4702515029844758667, + "LastSaturnProgradeToRetrograde": 4702515544355111779, + "LastSaturnRetrogradeToPrograde": 4702515029843514851, "LastSaturnWesternQuadrature": 4702515498244640404, "NextSaturnConjunction": 4702516090514308171, "NextSaturnEasternQuadrature": 4702515880214391202, "NextSaturnOpposition": 4702515692894798901, - "NextSaturnProgradeToRetrograde": 4702516357268301319, - "NextSaturnRetrogradeToPrograde": 4702515840919517425, + "NextSaturnProgradeToRetrograde": 4702516357268725409, + "NextSaturnRetrogradeToPrograde": 4702515840918958974, "NextSaturnWesternQuadrature": 4702516311055592921 } }, @@ -169,14 +169,14 @@ "LastSaturnConjunction": 4702516090514308171, "LastSaturnEasternQuadrature": 4702515880214391202, "LastSaturnOpposition": 4702515692894798901, - "LastSaturnProgradeToRetrograde": 4702515544354678665, - "LastSaturnRetrogradeToPrograde": 4702515840919517425, + "LastSaturnProgradeToRetrograde": 4702515544355111779, + "LastSaturnRetrogradeToPrograde": 4702515840918958974, "LastSaturnWesternQuadrature": 4702515498244640404, "NextSaturnConjunction": 4702516902555109694, "NextSaturnEasternQuadrature": 4702516691190324539, "NextSaturnOpposition": 4702516504989037657, - "NextSaturnProgradeToRetrograde": 4702516357268301319, - "NextSaturnRetrogradeToPrograde": 4702516652440296593, + "NextSaturnProgradeToRetrograde": 4702516357268725409, + "NextSaturnRetrogradeToPrograde": 4702516652440762692, "NextSaturnWesternQuadrature": 4702516311055592921 } }, @@ -187,14 +187,14 @@ "LastSaturnConjunction": 4702516090514308171, "LastSaturnEasternQuadrature": 4702515880214391202, "LastSaturnOpposition": 4702516504989037657, - "LastSaturnProgradeToRetrograde": 4702516357268301319, - "LastSaturnRetrogradeToPrograde": 4702516652440296593, + "LastSaturnProgradeToRetrograde": 4702516357268725409, + "LastSaturnRetrogradeToPrograde": 4702516652440762692, "LastSaturnWesternQuadrature": 4702516311055592921, "NextSaturnConjunction": 4702516902555109694, "NextSaturnEasternQuadrature": 4702516691190324539, "NextSaturnOpposition": 4702517317565580535, - "NextSaturnProgradeToRetrograde": 4702517170652575914, - "NextSaturnRetrogradeToPrograde": 4702517464207910825, + "NextSaturnProgradeToRetrograde": 4702517170652986350, + "NextSaturnRetrogradeToPrograde": 4702517464208361129, "NextSaturnWesternQuadrature": 4702517124594033282 } }, @@ -205,14 +205,14 @@ "LastSaturnConjunction": 4702516902555109694, "LastSaturnEasternQuadrature": 4702516691190324539, "LastSaturnOpposition": 4702516504989037657, - "LastSaturnProgradeToRetrograde": 4702516357268301319, - "LastSaturnRetrogradeToPrograde": 4702516652440296593, + "LastSaturnProgradeToRetrograde": 4702516357268725409, + "LastSaturnRetrogradeToPrograde": 4702516652440762692, "LastSaturnWesternQuadrature": 4702517124594033282, "NextSaturnConjunction": 4702517715498552717, "NextSaturnEasternQuadrature": 4702517502797421317, "NextSaturnOpposition": 4702517317565580535, - "NextSaturnProgradeToRetrograde": 4702517170652575914, - "NextSaturnRetrogradeToPrograde": 4702517464207910825, + "NextSaturnProgradeToRetrograde": 4702517170652986350, + "NextSaturnRetrogradeToPrograde": 4702517464208361129, "NextSaturnWesternQuadrature": 4702517938784307496 } }, @@ -223,14 +223,14 @@ "LastSaturnConjunction": 4702516902555109694, "LastSaturnEasternQuadrature": 4702517502797421317, "LastSaturnOpposition": 4702517317565580535, - "LastSaturnProgradeToRetrograde": 4702517170652575914, - "LastSaturnRetrogradeToPrograde": 4702517464207910825, + "LastSaturnProgradeToRetrograde": 4702517170652986350, + "LastSaturnRetrogradeToPrograde": 4702517464208361129, "LastSaturnWesternQuadrature": 4702517124594033282, "NextSaturnConjunction": 4702517715498552717, "NextSaturnEasternQuadrature": 4702518315061428444, "NextSaturnOpposition": 4702518130601804087, - "NextSaturnProgradeToRetrograde": 4702517984360023779, - "NextSaturnRetrogradeToPrograde": 4702518276343097193, + "NextSaturnProgradeToRetrograde": 4702517984359844757, + "NextSaturnRetrogradeToPrograde": 4702518276343545104, "NextSaturnWesternQuadrature": 4702517938784307496 } }, @@ -241,14 +241,14 @@ "LastSaturnConjunction": 4702517715498552717, "LastSaturnEasternQuadrature": 4702517502797421317, "LastSaturnOpposition": 4702518130601804087, - "LastSaturnProgradeToRetrograde": 4702517984360023779, - "LastSaturnRetrogradeToPrograde": 4702517464207910825, + "LastSaturnProgradeToRetrograde": 4702517984359844757, + "LastSaturnRetrogradeToPrograde": 4702517464208361129, "LastSaturnWesternQuadrature": 4702517938784307496, "NextSaturnConjunction": 4702518529339943531, "NextSaturnEasternQuadrature": 4702518315061428444, "NextSaturnOpposition": 4702518944055981728, - "NextSaturnProgradeToRetrograde": 4702518798260300473, - "NextSaturnRetrogradeToPrograde": 4702518276343097193, + "NextSaturnProgradeToRetrograde": 4702518798260726445, + "NextSaturnRetrogradeToPrograde": 4702518276343545104, "NextSaturnWesternQuadrature": 4702518753554317785 } }, @@ -259,14 +259,14 @@ "LastSaturnConjunction": 4702518529339943531, "LastSaturnEasternQuadrature": 4702518315061428444, "LastSaturnOpposition": 4702518130601804087, - "LastSaturnProgradeToRetrograde": 4702517984360023779, - "LastSaturnRetrogradeToPrograde": 4702518276343097193, + "LastSaturnProgradeToRetrograde": 4702517984359844757, + "LastSaturnRetrogradeToPrograde": 4702518276343545104, "LastSaturnWesternQuadrature": 4702517938784307496, "NextSaturnConjunction": 4702519344013385964, "NextSaturnEasternQuadrature": 4702519127967756278, "NextSaturnOpposition": 4702518944055981728, - "NextSaturnProgradeToRetrograde": 4702518798260300473, - "NextSaturnRetrogradeToPrograde": 4702519088991502001, + "NextSaturnProgradeToRetrograde": 4702518798260726445, + "NextSaturnRetrogradeToPrograde": 4702519088991610163, "NextSaturnWesternQuadrature": 4702518753554317785 } }, @@ -277,14 +277,14 @@ "LastSaturnConjunction": 4702518529339943531, "LastSaturnEasternQuadrature": 4702519127967756278, "LastSaturnOpposition": 4702518944055981728, - "LastSaturnProgradeToRetrograde": 4702518798260300473, - "LastSaturnRetrogradeToPrograde": 4702519088991502001, + "LastSaturnProgradeToRetrograde": 4702518798260726445, + "LastSaturnRetrogradeToPrograde": 4702519088991610163, "LastSaturnWesternQuadrature": 4702518753554317785, "NextSaturnConjunction": 4702519344013385964, "NextSaturnEasternQuadrature": 4702519941514933026, "NextSaturnOpposition": 4702519757846309798, - "NextSaturnProgradeToRetrograde": 4702519612467359257, - "NextSaturnRetrogradeToPrograde": 4702519901996079915, + "NextSaturnProgradeToRetrograde": 4702519612466921528, + "NextSaturnRetrogradeToPrograde": 4702519901996541370, "NextSaturnWesternQuadrature": 4702519568739586196 } }, @@ -295,14 +295,14 @@ "LastSaturnConjunction": 4702519344013385964, "LastSaturnEasternQuadrature": 4702519127967756278, "LastSaturnOpposition": 4702518944055981728, - "LastSaturnProgradeToRetrograde": 4702519612467359257, - "LastSaturnRetrogradeToPrograde": 4702519088991502001, + "LastSaturnProgradeToRetrograde": 4702519612466921528, + "LastSaturnRetrogradeToPrograde": 4702519088991610163, "LastSaturnWesternQuadrature": 4702519568739586196, "NextSaturnConjunction": 4702520159413404372, "NextSaturnEasternQuadrature": 4702519941514933026, "NextSaturnOpposition": 4702519757846309798, - "NextSaturnProgradeToRetrograde": 4702520426999883355, - "NextSaturnRetrogradeToPrograde": 4702519901996079915, + "NextSaturnProgradeToRetrograde": 4702520426999409701, + "NextSaturnRetrogradeToPrograde": 4702519901996541370, "NextSaturnWesternQuadrature": 4702520384150810840 } }, @@ -313,14 +313,14 @@ "LastSaturnConjunction": 4702520159413404372, "LastSaturnEasternQuadrature": 4702519941514933026, "LastSaturnOpposition": 4702519757846309798, - "LastSaturnProgradeToRetrograde": 4702519612467359257, - "LastSaturnRetrogradeToPrograde": 4702519901996079915, + "LastSaturnProgradeToRetrograde": 4702519612466921528, + "LastSaturnRetrogradeToPrograde": 4702519901996541370, "LastSaturnWesternQuadrature": 4702519568739586196, "NextSaturnConjunction": 4702520975353288857, "NextSaturnEasternQuadrature": 4702520755639229466, "NextSaturnOpposition": 4702520571879464046, - "NextSaturnProgradeToRetrograde": 4702520426999883355, - "NextSaturnRetrogradeToPrograde": 4702520715407552482, + "NextSaturnProgradeToRetrograde": 4702520426999409701, + "NextSaturnRetrogradeToPrograde": 4702520715408016141, "NextSaturnWesternQuadrature": 4702520384150810840 } }, @@ -331,14 +331,14 @@ "LastSaturnConjunction": 4702520159413404372, "LastSaturnEasternQuadrature": 4702519941514933026, "LastSaturnOpposition": 4702520571879464046, - "LastSaturnProgradeToRetrograde": 4702520426999883355, - "LastSaturnRetrogradeToPrograde": 4702519901996079915, + "LastSaturnProgradeToRetrograde": 4702520426999409701, + "LastSaturnRetrogradeToPrograde": 4702519901996541370, "LastSaturnWesternQuadrature": 4702520384150810840, "NextSaturnConjunction": 4702520975353288857, "NextSaturnEasternQuadrature": 4702520755639229466, "NextSaturnOpposition": 4702521386065824308, - "NextSaturnProgradeToRetrograde": 4702521241794830746, - "NextSaturnRetrogradeToPrograde": 4702520715407552482, + "NextSaturnProgradeToRetrograde": 4702521241794375916, + "NextSaturnRetrogradeToPrograde": 4702520715408016141, "NextSaturnWesternQuadrature": 4702521199621421395 } }, @@ -349,14 +349,14 @@ "LastSaturnConjunction": 4702520975353288857, "LastSaturnEasternQuadrature": 4702520755639229466, "LastSaturnOpposition": 4702520571879464046, - "LastSaturnProgradeToRetrograde": 4702520426999883355, - "LastSaturnRetrogradeToPrograde": 4702520715407552482, + "LastSaturnProgradeToRetrograde": 4702520426999409701, + "LastSaturnRetrogradeToPrograde": 4702520715408016141, "LastSaturnWesternQuadrature": 4702520384150810840, "NextSaturnConjunction": 4702521791599433056, "NextSaturnEasternQuadrature": 4702521570227969648, "NextSaturnOpposition": 4702521386065824308, - "NextSaturnProgradeToRetrograde": 4702521241794830746, - "NextSaturnRetrogradeToPrograde": 4702521529307706683, + "NextSaturnProgradeToRetrograde": 4702521241794375916, + "NextSaturnRetrogradeToPrograde": 4702521529308164355, "NextSaturnWesternQuadrature": 4702521199621421395 } }, @@ -367,14 +367,14 @@ "LastSaturnConjunction": 4702520975353288857, "LastSaturnEasternQuadrature": 4702521570227969648, "LastSaturnOpposition": 4702521386065824308, - "LastSaturnProgradeToRetrograde": 4702521241794830746, - "LastSaturnRetrogradeToPrograde": 4702521529307706683, + "LastSaturnProgradeToRetrograde": 4702521241794375916, + "LastSaturnRetrogradeToPrograde": 4702521529308164355, "LastSaturnWesternQuadrature": 4702521199621421395, "NextSaturnConjunction": 4702521791599433056, "NextSaturnEasternQuadrature": 4702522385163072143, "NextSaturnOpposition": 4702522200274601712, - "NextSaturnProgradeToRetrograde": 4702522056670488826, - "NextSaturnRetrogradeToPrograde": 4702522343647694178, + "NextSaturnProgradeToRetrograde": 4702522056669199019, + "NextSaturnRetrogradeToPrograde": 4702522343648160412, "NextSaturnWesternQuadrature": 4702522014939177587 } }, @@ -385,14 +385,14 @@ "LastSaturnConjunction": 4702521791599433056, "LastSaturnEasternQuadrature": 4702521570227969648, "LastSaturnOpposition": 4702521386065824308, - "LastSaturnProgradeToRetrograde": 4702522056670488826, - "LastSaturnRetrogradeToPrograde": 4702521529307706683, + "LastSaturnProgradeToRetrograde": 4702522056669199019, + "LastSaturnRetrogradeToPrograde": 4702521529308164355, "LastSaturnWesternQuadrature": 4702522014939177587, "NextSaturnConjunction": 4702522607909441103, "NextSaturnEasternQuadrature": 4702522385163072143, "NextSaturnOpposition": 4702522200274601712, - "NextSaturnProgradeToRetrograde": 4702522871514654349, - "NextSaturnRetrogradeToPrograde": 4702522343647694178, + "NextSaturnProgradeToRetrograde": 4702522871515097136, + "NextSaturnRetrogradeToPrograde": 4702522343648160412, "NextSaturnWesternQuadrature": 4702522829887030586 } }, @@ -403,14 +403,14 @@ "LastSaturnConjunction": 4702522607909441103, "LastSaturnEasternQuadrature": 4702522385163072143, "LastSaturnOpposition": 4702522200274601712, - "LastSaturnProgradeToRetrograde": 4702522056670488826, - "LastSaturnRetrogradeToPrograde": 4702522343647694178, + "LastSaturnProgradeToRetrograde": 4702522056669199019, + "LastSaturnRetrogradeToPrograde": 4702522343648160412, "LastSaturnWesternQuadrature": 4702522014939177587, "NextSaturnConjunction": 4702523423987345076, "NextSaturnEasternQuadrature": 4702523200254411091, "NextSaturnOpposition": 4702523014362484992, - "NextSaturnProgradeToRetrograde": 4702522871514654349, - "NextSaturnRetrogradeToPrograde": 4702523158325050275, + "NextSaturnProgradeToRetrograde": 4702522871515097136, + "NextSaturnRetrogradeToPrograde": 4702523158325527857, "NextSaturnWesternQuadrature": 4702522829887030586 } }, @@ -421,14 +421,14 @@ "LastSaturnConjunction": 4702522607909441103, "LastSaturnEasternQuadrature": 4702522385163072143, "LastSaturnOpposition": 4702523014362484992, - "LastSaturnProgradeToRetrograde": 4702522871514654349, - "LastSaturnRetrogradeToPrograde": 4702523158325050275, + "LastSaturnProgradeToRetrograde": 4702522871515097136, + "LastSaturnRetrogradeToPrograde": 4702523158325527857, "LastSaturnWesternQuadrature": 4702522829887030586, "NextSaturnConjunction": 4702523423987345076, "NextSaturnEasternQuadrature": 4702523200254411091, "NextSaturnOpposition": 4702523828232647308, - "NextSaturnProgradeToRetrograde": 4702523686106230886, - "NextSaturnRetrogradeToPrograde": 4702523973298541125, + "NextSaturnProgradeToRetrograde": 4702523686106472042, + "NextSaturnRetrogradeToPrograde": 4702523973299018794, "NextSaturnWesternQuadrature": 4702523644313283421 } }, @@ -439,14 +439,14 @@ "LastSaturnConjunction": 4702523423987345076, "LastSaturnEasternQuadrature": 4702523200254411091, "LastSaturnOpposition": 4702523014362484992, - "LastSaturnProgradeToRetrograde": 4702522871514654349, - "LastSaturnRetrogradeToPrograde": 4702523158325050275, + "LastSaturnProgradeToRetrograde": 4702522871515097136, + "LastSaturnRetrogradeToPrograde": 4702523158325527857, "LastSaturnWesternQuadrature": 4702523644313283421, "NextSaturnConjunction": 4702524239574017359, "NextSaturnEasternQuadrature": 4702524015309269536, "NextSaturnOpposition": 4702523828232647308, - "NextSaturnProgradeToRetrograde": 4702523686106230886, - "NextSaturnRetrogradeToPrograde": 4702523973298541125, + "NextSaturnProgradeToRetrograde": 4702523686106472042, + "NextSaturnRetrogradeToPrograde": 4702523973299018794, "NextSaturnWesternQuadrature": 4702524458086687222 } }, @@ -457,14 +457,14 @@ "LastSaturnConjunction": 4702523423987345076, "LastSaturnEasternQuadrature": 4702524015309269536, "LastSaturnOpposition": 4702523828232647308, - "LastSaturnProgradeToRetrograde": 4702523686106230886, - "LastSaturnRetrogradeToPrograde": 4702523973298541125, + "LastSaturnProgradeToRetrograde": 4702523686106472042, + "LastSaturnRetrogradeToPrograde": 4702523973299018794, "LastSaturnWesternQuadrature": 4702523644313283421, "NextSaturnConjunction": 4702524239574017359, "NextSaturnEasternQuadrature": 4702524830148237347, "NextSaturnOpposition": 4702524641761758079, - "NextSaturnProgradeToRetrograde": 4702524500152192694, - "NextSaturnRetrogradeToPrograde": 4702524788383686924, + "NextSaturnProgradeToRetrograde": 4702524500152603576, + "NextSaturnRetrogradeToPrograde": 4702524788383660462, "NextSaturnWesternQuadrature": 4702524458086687222 } }, @@ -475,14 +475,14 @@ "LastSaturnConjunction": 4702524239574017359, "LastSaturnEasternQuadrature": 4702524015309269536, "LastSaturnOpposition": 4702524641761758079, - "LastSaturnProgradeToRetrograde": 4702524500152192694, - "LastSaturnRetrogradeToPrograde": 4702523973298541125, + "LastSaturnProgradeToRetrograde": 4702524500152603576, + "LastSaturnRetrogradeToPrograde": 4702523973299018794, "LastSaturnWesternQuadrature": 4702524458086687222, "NextSaturnConjunction": 4702525054505625445, "NextSaturnEasternQuadrature": 4702524830148237347, "NextSaturnOpposition": 4702525454861406637, - "NextSaturnProgradeToRetrograde": 4702525313344407804, - "NextSaturnRetrogradeToPrograde": 4702524788383686924, + "NextSaturnProgradeToRetrograde": 4702525313344829022, + "NextSaturnRetrogradeToPrograde": 4702524788383660462, "NextSaturnWesternQuadrature": 4702525271124507033 } }, @@ -493,14 +493,14 @@ "LastSaturnConjunction": 4702525054505625445, "LastSaturnEasternQuadrature": 4702524830148237347, "LastSaturnOpposition": 4702524641761758079, - "LastSaturnProgradeToRetrograde": 4702524500152192694, - "LastSaturnRetrogradeToPrograde": 4702524788383686924, + "LastSaturnProgradeToRetrograde": 4702524500152603576, + "LastSaturnRetrogradeToPrograde": 4702524788383660462, "LastSaturnWesternQuadrature": 4702524458086687222, "NextSaturnConjunction": 4702525868627530973, "NextSaturnEasternQuadrature": 4702525644611337068, "NextSaturnOpposition": 4702525454861406637, - "NextSaturnProgradeToRetrograde": 4702525313344407804, - "NextSaturnRetrogradeToPrograde": 4702525603356217983, + "NextSaturnProgradeToRetrograde": 4702525313344829022, + "NextSaturnRetrogradeToPrograde": 4702525603356701068, "NextSaturnWesternQuadrature": 4702525271124507033 } }, @@ -511,14 +511,14 @@ "LastSaturnConjunction": 4702525054505625445, "LastSaturnEasternQuadrature": 4702525644611337068, "LastSaturnOpposition": 4702525454861406637, - "LastSaturnProgradeToRetrograde": 4702525313344407804, - "LastSaturnRetrogradeToPrograde": 4702525603356217983, + "LastSaturnProgradeToRetrograde": 4702525313344829022, + "LastSaturnRetrogradeToPrograde": 4702525603356701068, "LastSaturnWesternQuadrature": 4702525271124507033, "NextSaturnConjunction": 4702525868627530973, "NextSaturnEasternQuadrature": 4702526458575490014, "NextSaturnOpposition": 4702526267512954555, - "NextSaturnProgradeToRetrograde": 4702526125760746732, - "NextSaturnRetrogradeToPrograde": 4702526417755760526, + "NextSaturnProgradeToRetrograde": 4702526125761153380, + "NextSaturnRetrogradeToPrograde": 4702526417756415486, "NextSaturnWesternQuadrature": 4702526083406067990 } }, @@ -529,14 +529,14 @@ "LastSaturnConjunction": 4702525868627530973, "LastSaturnEasternQuadrature": 4702525644611337068, "LastSaturnOpposition": 4702525454861406637, - "LastSaturnProgradeToRetrograde": 4702526125760746732, - "LastSaturnRetrogradeToPrograde": 4702525603356217983, + "LastSaturnProgradeToRetrograde": 4702526125761153380, + "LastSaturnRetrogradeToPrograde": 4702525603356701068, "LastSaturnWesternQuadrature": 4702526083406067990, "NextSaturnConjunction": 4702526681841763602, "NextSaturnEasternQuadrature": 4702526458575490014, "NextSaturnOpposition": 4702526267512954555, - "NextSaturnProgradeToRetrograde": 4702526937432175922, - "NextSaturnRetrogradeToPrograde": 4702526417755760526, + "NextSaturnProgradeToRetrograde": 4702526937432569219, + "NextSaturnRetrogradeToPrograde": 4702526417756415486, "NextSaturnWesternQuadrature": 4702526894942960204 } }, @@ -547,14 +547,14 @@ "LastSaturnConjunction": 4702525868627530973, "LastSaturnEasternQuadrature": 4702526458575490014, "LastSaturnOpposition": 4702526267512954555, - "LastSaturnProgradeToRetrograde": 4702526125760746732, - "LastSaturnRetrogradeToPrograde": 4702526417755760526, + "LastSaturnProgradeToRetrograde": 4702526125761153380, + "LastSaturnRetrogradeToPrograde": 4702526417756415486, "LastSaturnWesternQuadrature": 4702526083406067990, "NextSaturnConjunction": 4702526681841763602, "NextSaturnEasternQuadrature": 4702527271945710833, "NextSaturnOpposition": 4702527079685466594, - "NextSaturnProgradeToRetrograde": 4702526937432175922, - "NextSaturnRetrogradeToPrograde": 4702527231413066131, + "NextSaturnProgradeToRetrograde": 4702526937432569219, + "NextSaturnRetrogradeToPrograde": 4702527231413334267, "NextSaturnWesternQuadrature": 4702526894942960204 } }, @@ -565,14 +565,14 @@ "LastSaturnConjunction": 4702526681841763602, "LastSaturnEasternQuadrature": 4702526458575490014, "LastSaturnOpposition": 4702527079685466594, - "LastSaturnProgradeToRetrograde": 4702526937432175922, - "LastSaturnRetrogradeToPrograde": 4702526417755760526, + "LastSaturnProgradeToRetrograde": 4702526937432569219, + "LastSaturnRetrogradeToPrograde": 4702526417756415486, "LastSaturnWesternQuadrature": 4702526894942960204, "NextSaturnConjunction": 4702527494156830189, "NextSaturnEasternQuadrature": 4702527271945710833, "NextSaturnOpposition": 4702527891373365617, - "NextSaturnProgradeToRetrograde": 4702527748232122673, - "NextSaturnRetrogradeToPrograde": 4702527231413066131, + "NextSaturnProgradeToRetrograde": 4702527748232042571, + "NextSaturnRetrogradeToPrograde": 4702527231413334267, "NextSaturnWesternQuadrature": 4702527705800760929 } }, @@ -583,14 +583,14 @@ "LastSaturnConjunction": 4702527494156830189, "LastSaturnEasternQuadrature": 4702527271945710833, "LastSaturnOpposition": 4702527079685466594, - "LastSaturnProgradeToRetrograde": 4702526937432175922, - "LastSaturnRetrogradeToPrograde": 4702527231413035150, + "LastSaturnProgradeToRetrograde": 4702526937432569219, + "LastSaturnRetrogradeToPrograde": 4702527231413334267, "LastSaturnWesternQuadrature": 4702526894942960204, "NextSaturnConjunction": 4702528305594171046, "NextSaturnEasternQuadrature": 4702528084677650276, "NextSaturnOpposition": 4702527891373365617, - "NextSaturnProgradeToRetrograde": 4702527748232122673, - "NextSaturnRetrogradeToPrograde": 4702528044263454190, + "NextSaturnProgradeToRetrograde": 4702527748232042571, + "NextSaturnRetrogradeToPrograde": 4702528044263941419, "NextSaturnWesternQuadrature": 4702527705800760929 } }, @@ -601,14 +601,14 @@ "LastSaturnConjunction": 4702527494156830189, "LastSaturnEasternQuadrature": 4702528084677650276, "LastSaturnOpposition": 4702527891373365617, - "LastSaturnProgradeToRetrograde": 4702527748232122673, - "LastSaturnRetrogradeToPrograde": 4702528044263454190, + "LastSaturnProgradeToRetrograde": 4702527748232042571, + "LastSaturnRetrogradeToPrograde": 4702528044263941419, "LastSaturnWesternQuadrature": 4702527705800760929, "NextSaturnConjunction": 4702528305594171046, "NextSaturnEasternQuadrature": 4702528896780192361, "NextSaturnOpposition": 4702528702630087296, - "NextSaturnProgradeToRetrograde": 4702528558210697899, - "NextSaturnRetrogradeToPrograde": 4702528856420102473, + "NextSaturnProgradeToRetrograde": 4702528558210273985, + "NextSaturnRetrogradeToPrograde": 4702528856420584459, "NextSaturnWesternQuadrature": 4702528516055859901 } }, @@ -619,14 +619,14 @@ "LastSaturnConjunction": 4702528305594171046, "LastSaturnEasternQuadrature": 4702528084677650276, "LastSaturnOpposition": 4702527891373365617, - "LastSaturnProgradeToRetrograde": 4702528558210697899, - "LastSaturnRetrogradeToPrograde": 4702528044263454190, + "LastSaturnProgradeToRetrograde": 4702528558210273985, + "LastSaturnRetrogradeToPrograde": 4702528044263941419, "LastSaturnWesternQuadrature": 4702528516055859901, "NextSaturnConjunction": 4702529116208277526, "NextSaturnEasternQuadrature": 4702528896780192361, "NextSaturnOpposition": 4702528702630087296, - "NextSaturnProgradeToRetrograde": 4702529367654663469, - "NextSaturnRetrogradeToPrograde": 4702528856420102473, + "NextSaturnProgradeToRetrograde": 4702529367655079386, + "NextSaturnRetrogradeToPrograde": 4702528856420584459, "NextSaturnWesternQuadrature": 4702529325791525980 } }, @@ -637,14 +637,14 @@ "LastSaturnConjunction": 4702529116208277526, "LastSaturnEasternQuadrature": 4702528896780192361, "LastSaturnOpposition": 4702528702630087296, - "LastSaturnProgradeToRetrograde": 4702528558210697899, - "LastSaturnRetrogradeToPrograde": 4702528856420102473, + "LastSaturnProgradeToRetrograde": 4702528558210273985, + "LastSaturnRetrogradeToPrograde": 4702528856420584459, "LastSaturnWesternQuadrature": 4702528516055859901, "NextSaturnConjunction": 4702529926120462069, "NextSaturnEasternQuadrature": 4702529708247619986, "NextSaturnOpposition": 4702529513484579558, - "NextSaturnProgradeToRetrograde": 4702529367654663469, - "NextSaturnRetrogradeToPrograde": 4702529667728424845, + "NextSaturnProgradeToRetrograde": 4702529367655079386, + "NextSaturnRetrogradeToPrograde": 4702529667728889864, "NextSaturnWesternQuadrature": 4702529325791525980 } }, @@ -655,14 +655,14 @@ "LastSaturnConjunction": 4702529116208277526, "LastSaturnEasternQuadrature": 4702528896780192361, "LastSaturnOpposition": 4702529513484579558, - "LastSaturnProgradeToRetrograde": 4702529367654663469, - "LastSaturnRetrogradeToPrograde": 4702528856420102473, + "LastSaturnProgradeToRetrograde": 4702529367655079386, + "LastSaturnRetrogradeToPrograde": 4702528856420584459, "LastSaturnWesternQuadrature": 4702529325791525980, "NextSaturnConjunction": 4702529926120462069, "NextSaturnEasternQuadrature": 4702529708247619986, "NextSaturnOpposition": 4702530323982765298, - "NextSaturnProgradeToRetrograde": 4702530176691164142, - "NextSaturnRetrogradeToPrograde": 4702529667728424845, + "NextSaturnProgradeToRetrograde": 4702530176691557047, + "NextSaturnRetrogradeToPrograde": 4702529667728889864, "NextSaturnWesternQuadrature": 4702530135136557101 } }, @@ -673,14 +673,14 @@ "LastSaturnConjunction": 4702529926120462069, "LastSaturnEasternQuadrature": 4702529708247619986, "LastSaturnOpposition": 4702529513484579558, - "LastSaturnProgradeToRetrograde": 4702529367654663469, - "LastSaturnRetrogradeToPrograde": 4702529667728424845, + "LastSaturnProgradeToRetrograde": 4702529367655079386, + "LastSaturnRetrogradeToPrograde": 4702529667728889864, "LastSaturnWesternQuadrature": 4702530135136557101, "NextSaturnConjunction": 4702530735437843616, "NextSaturnEasternQuadrature": 4702530519108912139, "NextSaturnOpposition": 4702530323982765298, - "NextSaturnProgradeToRetrograde": 4702530176691164142, - "NextSaturnRetrogradeToPrograde": 4702530478328820905, + "NextSaturnProgradeToRetrograde": 4702530176691557047, + "NextSaturnRetrogradeToPrograde": 4702530478328875588, "NextSaturnWesternQuadrature": 4702530944199802109 } }, @@ -691,14 +691,14 @@ "LastSaturnConjunction": 4702529926120462069, "LastSaturnEasternQuadrature": 4702530519108912139, "LastSaturnOpposition": 4702530323982765298, - "LastSaturnProgradeToRetrograde": 4702530176691164142, - "LastSaturnRetrogradeToPrograde": 4702530478328820905, + "LastSaturnProgradeToRetrograde": 4702530176691557047, + "LastSaturnRetrogradeToPrograde": 4702530478328875588, "LastSaturnWesternQuadrature": 4702530135136557101, "NextSaturnConjunction": 4702530735437843616, "NextSaturnEasternQuadrature": 4702531329470736203, "NextSaturnOpposition": 4702531134220785222, - "NextSaturnProgradeToRetrograde": 4702530985712750895, - "NextSaturnRetrogradeToPrograde": 4702531288318532694, + "NextSaturnProgradeToRetrograde": 4702530985713151543, + "NextSaturnRetrogradeToPrograde": 4702531288319018459, "NextSaturnWesternQuadrature": 4702530944199802109 } }, @@ -709,14 +709,14 @@ "LastSaturnConjunction": 4702530735437843616, "LastSaturnEasternQuadrature": 4702530519108912139, "LastSaturnOpposition": 4702531134220785222, - "LastSaturnProgradeToRetrograde": 4702530985712750895, - "LastSaturnRetrogradeToPrograde": 4702530478328820905, + "LastSaturnProgradeToRetrograde": 4702530985713151543, + "LastSaturnRetrogradeToPrograde": 4702530478328875588, "LastSaturnWesternQuadrature": 4702530944199802109, "NextSaturnConjunction": 4702531544272140693, "NextSaturnEasternQuadrature": 4702531329470736203, "NextSaturnOpposition": 4702531944272996500, - "NextSaturnProgradeToRetrograde": 4702531794499734590, - "NextSaturnRetrogradeToPrograde": 4702531288318532694, + "NextSaturnProgradeToRetrograde": 4702531794500160424, + "NextSaturnRetrogradeToPrograde": 4702531288319018459, "NextSaturnWesternQuadrature": 4702531753096652823 } }, @@ -727,14 +727,14 @@ "LastSaturnConjunction": 4702531544272140693, "LastSaturnEasternQuadrature": 4702531329470736203, "LastSaturnOpposition": 4702531134220785222, - "LastSaturnProgradeToRetrograde": 4702530985712750895, - "LastSaturnRetrogradeToPrograde": 4702531288318578243, + "LastSaturnProgradeToRetrograde": 4702530985713151543, + "LastSaturnRetrogradeToPrograde": 4702531288319018459, "LastSaturnWesternQuadrature": 4702530944199802109, "NextSaturnConjunction": 4702532352763081706, "NextSaturnEasternQuadrature": 4702532139402665845, "NextSaturnOpposition": 4702531944272996500, - "NextSaturnProgradeToRetrograde": 4702531794499734590, - "NextSaturnRetrogradeToPrograde": 4702532097754171858, + "NextSaturnProgradeToRetrograde": 4702531794500160424, + "NextSaturnRetrogradeToPrograde": 4702532097753052075, "NextSaturnWesternQuadrature": 4702531753096652823 } }, @@ -745,14 +745,14 @@ "LastSaturnConjunction": 4702531544272140693, "LastSaturnEasternQuadrature": 4702532139402665845, "LastSaturnOpposition": 4702531944272996500, - "LastSaturnProgradeToRetrograde": 4702531794499734590, - "LastSaturnRetrogradeToPrograde": 4702532097754171858, + "LastSaturnProgradeToRetrograde": 4702531794500160424, + "LastSaturnRetrogradeToPrograde": 4702532097753052075, "LastSaturnWesternQuadrature": 4702531753096652823, "NextSaturnConjunction": 4702532352763081706, "NextSaturnEasternQuadrature": 4702532948968885043, "NextSaturnOpposition": 4702532754203270179, - "NextSaturnProgradeToRetrograde": 4702532603436340217, - "NextSaturnRetrogradeToPrograde": 4702532907107880137, + "NextSaturnProgradeToRetrograde": 4702532603436792112, + "NextSaturnRetrogradeToPrograde": 4702532907108321663, "NextSaturnWesternQuadrature": 4702532561958350352 } }, @@ -763,14 +763,14 @@ "LastSaturnConjunction": 4702532352763081706, "LastSaturnEasternQuadrature": 4702532139402665845, "LastSaturnOpposition": 4702531944272996500, - "LastSaturnProgradeToRetrograde": 4702532603436340217, - "LastSaturnRetrogradeToPrograde": 4702532097754171858, + "LastSaturnProgradeToRetrograde": 4702532603436792112, + "LastSaturnRetrogradeToPrograde": 4702532097753052075, "LastSaturnWesternQuadrature": 4702532561958350352, "NextSaturnConjunction": 4702533161047962411, "NextSaturnEasternQuadrature": 4702532948968885043, "NextSaturnOpposition": 4702532754203270179, - "NextSaturnProgradeToRetrograde": 4702533412700468339, - "NextSaturnRetrogradeToPrograde": 4702532907107880137, + "NextSaturnProgradeToRetrograde": 4702533412700582496, + "NextSaturnRetrogradeToPrograde": 4702532907108321663, "NextSaturnWesternQuadrature": 4702533370886429185 } }, @@ -781,14 +781,14 @@ "LastSaturnConjunction": 4702533161047962411, "LastSaturnEasternQuadrature": 4702532948968885043, "LastSaturnOpposition": 4702532754203270179, - "LastSaturnProgradeToRetrograde": 4702532603436340217, - "LastSaturnRetrogradeToPrograde": 4702532907107880137, + "LastSaturnProgradeToRetrograde": 4702532603436792112, + "LastSaturnRetrogradeToPrograde": 4702532907108321663, "LastSaturnWesternQuadrature": 4702532561958350352, "NextSaturnConjunction": 4702533969278806600, "NextSaturnEasternQuadrature": 4702533758289945989, "NextSaturnOpposition": 4702533564088073218, - "NextSaturnProgradeToRetrograde": 4702533412700468339, - "NextSaturnRetrogradeToPrograde": 4702533716288215237, + "NextSaturnProgradeToRetrograde": 4702533412700582496, + "NextSaturnRetrogradeToPrograde": 4702533716288651101, "NextSaturnWesternQuadrature": 4702533370886429185 } }, @@ -799,14 +799,14 @@ "LastSaturnConjunction": 4702533161047962411, "LastSaturnEasternQuadrature": 4702532948968885043, "LastSaturnOpposition": 4702533564088073218, - "LastSaturnProgradeToRetrograde": 4702533412700468339, - "LastSaturnRetrogradeToPrograde": 4702532907107880137, + "LastSaturnProgradeToRetrograde": 4702533412700582496, + "LastSaturnRetrogradeToPrograde": 4702532907108321663, "LastSaturnWesternQuadrature": 4702533370886429185, "NextSaturnConjunction": 4702533969278806600, "NextSaturnEasternQuadrature": 4702533758289945989, "NextSaturnOpposition": 4702534374016365964, - "NextSaturnProgradeToRetrograde": 4702534222380849315, - "NextSaturnRetrogradeToPrograde": 4702533716288215237, + "NextSaturnProgradeToRetrograde": 4702534222381311062, + "NextSaturnRetrogradeToPrograde": 4702533716288651101, "NextSaturnWesternQuadrature": 4702534179997055040 } }, @@ -817,14 +817,14 @@ "LastSaturnConjunction": 4702533969278806600, "LastSaturnEasternQuadrature": 4702533758289945989, "LastSaturnOpposition": 4702533564088073218, - "LastSaturnProgradeToRetrograde": 4702533412700468339, - "LastSaturnRetrogradeToPrograde": 4702533716288215237, + "LastSaturnProgradeToRetrograde": 4702533412700582496, + "LastSaturnRetrogradeToPrograde": 4702533716288651101, "LastSaturnWesternQuadrature": 4702533370886429185, "NextSaturnConjunction": 4702534777577062880, "NextSaturnEasternQuadrature": 4702534567468493238, "NextSaturnOpposition": 4702534374016365964, - "NextSaturnProgradeToRetrograde": 4702534222380849315, - "NextSaturnRetrogradeToPrograde": 4702534525515440831, + "NextSaturnProgradeToRetrograde": 4702534222381311062, + "NextSaturnRetrogradeToPrograde": 4702534525514219178, "NextSaturnWesternQuadrature": 4702534179997055040 } }, @@ -835,14 +835,14 @@ "LastSaturnConjunction": 4702533969278806600, "LastSaturnEasternQuadrature": 4702534567468493238, "LastSaturnOpposition": 4702534374016365964, - "LastSaturnProgradeToRetrograde": 4702534222380849315, - "LastSaturnRetrogradeToPrograde": 4702534525515440831, + "LastSaturnProgradeToRetrograde": 4702534222381311062, + "LastSaturnRetrogradeToPrograde": 4702534525514219178, "LastSaturnWesternQuadrature": 4702534179997055040, "NextSaturnConjunction": 4702534777577062880, "NextSaturnEasternQuadrature": 4702535376608597709, "NextSaturnOpposition": 4702535184095689914, - "NextSaturnProgradeToRetrograde": 4702535032606894134, - "NextSaturnRetrogradeToPrograde": 4702535334813108474, + "NextSaturnProgradeToRetrograde": 4702535032607349528, + "NextSaturnRetrogradeToPrograde": 4702535334812973048, "NextSaturnWesternQuadrature": 4702534989438771132 } }, @@ -853,14 +853,14 @@ "LastSaturnConjunction": 4702534777577062880, "LastSaturnEasternQuadrature": 4702534567468493238, "LastSaturnOpposition": 4702534374016365964, - "LastSaturnProgradeToRetrograde": 4702535032606894134, - "LastSaturnRetrogradeToPrograde": 4702534525515440831, + "LastSaturnProgradeToRetrograde": 4702535032607349528, + "LastSaturnRetrogradeToPrograde": 4702534525514219178, "LastSaturnWesternQuadrature": 4702534989438771132, "NextSaturnConjunction": 4702535586083198746, "NextSaturnEasternQuadrature": 4702535376608597709, "NextSaturnOpposition": 4702535184095689914, - "NextSaturnProgradeToRetrograde": 4702535843143971709, - "NextSaturnRetrogradeToPrograde": 4702535334813108474, + "NextSaturnProgradeToRetrograde": 4702535843144362997, + "NextSaturnRetrogradeToPrograde": 4702535334812973048, "NextSaturnWesternQuadrature": 4702535799310736788 } }, @@ -871,14 +871,14 @@ "LastSaturnConjunction": 4702511233082790083, "LastSaturnEasternQuadrature": 4702511022491982803, "LastSaturnOpposition": 4702511638661393407, - "LastSaturnProgradeToRetrograde": 4702511486903411901, - "LastSaturnRetrogradeToPrograde": 4702510980431639715, + "LastSaturnProgradeToRetrograde": 4702511486902175689, + "LastSaturnRetrogradeToPrograde": 4702510980432076789, "LastSaturnWesternQuadrature": 4702511444359721740, "NextSaturnConjunction": 4702512041494953604, "NextSaturnEasternQuadrature": 4702511831665381074, "NextSaturnOpposition": 4702512448855447741, - "NextSaturnProgradeToRetrograde": 4702512297376070109, - "NextSaturnRetrogradeToPrograde": 4702511789773905764, + "NextSaturnProgradeToRetrograde": 4702512297375675269, + "NextSaturnRetrogradeToPrograde": 4702511789774326390, "NextSaturnWesternQuadrature": 4702512254003820448 } }, @@ -889,14 +889,14 @@ "LastSaturnConjunction": 4702512041494953604, "LastSaturnEasternQuadrature": 4702511831665381074, "LastSaturnOpposition": 4702511638661393407, - "LastSaturnProgradeToRetrograde": 4702511486903411901, - "LastSaturnRetrogradeToPrograde": 4702511789773905764, + "LastSaturnProgradeToRetrograde": 4702511486902175689, + "LastSaturnRetrogradeToPrograde": 4702511789774326390, "LastSaturnWesternQuadrature": 4702511444359721740, "NextSaturnConjunction": 4702512850183455371, "NextSaturnEasternQuadrature": 4702512640866973769, "NextSaturnOpposition": 4702512448855447741, - "NextSaturnProgradeToRetrograde": 4702512297376070109, - "NextSaturnRetrogradeToPrograde": 4702512599369692550, + "NextSaturnProgradeToRetrograde": 4702512297375675269, + "NextSaturnRetrogradeToPrograde": 4702512599370110898, "NextSaturnWesternQuadrature": 4702512254003820448 } }, @@ -907,14 +907,14 @@ "LastSaturnConjunction": 4702512041494953604, "LastSaturnEasternQuadrature": 4702511831665381074, "LastSaturnOpposition": 4702511638661393407, - "LastSaturnProgradeToRetrograde": 4702512297376070109, - "LastSaturnRetrogradeToPrograde": 4702511789773905764, + "LastSaturnProgradeToRetrograde": 4702512297375675269, + "LastSaturnRetrogradeToPrograde": 4702511789774326390, "LastSaturnWesternQuadrature": 4702512254003820448, "NextSaturnConjunction": 4702512850183455371, "NextSaturnEasternQuadrature": 4702512640866973769, "NextSaturnOpposition": 4702512448855447741, - "NextSaturnProgradeToRetrograde": 4702513108331851908, - "NextSaturnRetrogradeToPrograde": 4702512599371327020, + "NextSaturnProgradeToRetrograde": 4702513108331391671, + "NextSaturnRetrogradeToPrograde": 4702512599370110906, "NextSaturnWesternQuadrature": 4702513064123992244 } }, @@ -925,14 +925,14 @@ "LastSaturnConjunction": 4702512041494953604, "LastSaturnEasternQuadrature": 4702512640866973769, "LastSaturnOpposition": 4702512448855447741, - "LastSaturnProgradeToRetrograde": 4702512297376070109, - "LastSaturnRetrogradeToPrograde": 4702512599371327020, + "LastSaturnProgradeToRetrograde": 4702512297375675269, + "LastSaturnRetrogradeToPrograde": 4702512599370110906, "LastSaturnWesternQuadrature": 4702512254003820448, "NextSaturnConjunction": 4702512850183455371, "NextSaturnEasternQuadrature": 4702513450206894394, "NextSaturnOpposition": 4702513259308967068, - "NextSaturnProgradeToRetrograde": 4702513108331851908, - "NextSaturnRetrogradeToPrograde": 4702513409106697783, + "NextSaturnProgradeToRetrograde": 4702513108331391671, + "NextSaturnRetrogradeToPrograde": 4702513409105573833, "NextSaturnWesternQuadrature": 4702513064123992244 } }, @@ -943,14 +943,14 @@ "LastSaturnConjunction": 4702512850183455371, "LastSaturnEasternQuadrature": 4702512640866973769, "LastSaturnOpposition": 4702512448855447741, - "LastSaturnProgradeToRetrograde": 4702512297376070109, - "LastSaturnRetrogradeToPrograde": 4702512599371327020, + "LastSaturnProgradeToRetrograde": 4702512297375675269, + "LastSaturnRetrogradeToPrograde": 4702512599370110906, "LastSaturnWesternQuadrature": 4702513064123992244, "NextSaturnConjunction": 4702513659304086479, "NextSaturnEasternQuadrature": 4702513450206894394, "NextSaturnOpposition": 4702513259308967068, - "NextSaturnProgradeToRetrograde": 4702513108331851908, - "NextSaturnRetrogradeToPrograde": 4702513409106697783, + "NextSaturnProgradeToRetrograde": 4702513108331391671, + "NextSaturnRetrogradeToPrograde": 4702513409105573833, "NextSaturnWesternQuadrature": 4702513874832589619 } }, @@ -961,14 +961,14 @@ "LastSaturnConjunction": 4702512850183455371, "LastSaturnEasternQuadrature": 4702512640866973769, "LastSaturnOpposition": 4702513259308967068, - "LastSaturnProgradeToRetrograde": 4702513108331851908, - "LastSaturnRetrogradeToPrograde": 4702513409106697783, + "LastSaturnProgradeToRetrograde": 4702513108331391671, + "LastSaturnRetrogradeToPrograde": 4702513409105573833, "LastSaturnWesternQuadrature": 4702513064123992244, "NextSaturnConjunction": 4702513659304086479, "NextSaturnEasternQuadrature": 4702513450206894394, "NextSaturnOpposition": 4702514070085314883, - "NextSaturnProgradeToRetrograde": 4702513919872693313, - "NextSaturnRetrogradeToPrograde": 4702514219286235215, + "NextSaturnProgradeToRetrograde": 4702513919871824425, + "NextSaturnRetrogradeToPrograde": 4702514219285831548, "NextSaturnWesternQuadrature": 4702513874832589619 } }, @@ -979,14 +979,14 @@ "LastSaturnConjunction": 4702513659304086479, "LastSaturnEasternQuadrature": 4702513450206894394, "LastSaturnOpposition": 4702513259308967068, - "LastSaturnProgradeToRetrograde": 4702513108331851908, - "LastSaturnRetrogradeToPrograde": 4702513409106697783, + "LastSaturnProgradeToRetrograde": 4702513108331391671, + "LastSaturnRetrogradeToPrograde": 4702513409105573833, "LastSaturnWesternQuadrature": 4702513064123992244, "NextSaturnConjunction": 4702514468983751639, "NextSaturnEasternQuadrature": 4702514259798420534, "NextSaturnOpposition": 4702514070085314883, - "NextSaturnProgradeToRetrograde": 4702513919872693313, - "NextSaturnRetrogradeToPrograde": 4702514219286235215, + "NextSaturnProgradeToRetrograde": 4702513919871824425, + "NextSaturnRetrogradeToPrograde": 4702514219285831548, "NextSaturnWesternQuadrature": 4702513874832589619 } }, @@ -997,14 +997,14 @@ "LastSaturnConjunction": 4702513659304086479, "LastSaturnEasternQuadrature": 4702513450206894394, "LastSaturnOpposition": 4702514070085314883, - "LastSaturnProgradeToRetrograde": 4702513919872693313, - "LastSaturnRetrogradeToPrograde": 4702513409106697783, + "LastSaturnProgradeToRetrograde": 4702513919871824425, + "LastSaturnRetrogradeToPrograde": 4702513409105573833, "LastSaturnWesternQuadrature": 4702513874832589619, "NextSaturnConjunction": 4702514468983751639, "NextSaturnEasternQuadrature": 4702514259798420534, "NextSaturnOpposition": 4702514881266846949, - "NextSaturnProgradeToRetrograde": 4702514731951225270, - "NextSaturnRetrogradeToPrograde": 4702514219286235215, + "NextSaturnProgradeToRetrograde": 4702514731951642450, + "NextSaturnRetrogradeToPrograde": 4702514219285831548, "NextSaturnWesternQuadrature": 4702514686189470628 } }, @@ -1015,14 +1015,14 @@ "LastSaturnConjunction": 4702513659304086479, "LastSaturnEasternQuadrature": 4702514259798420534, "LastSaturnOpposition": 4702514070085314883, - "LastSaturnProgradeToRetrograde": 4702513919872693313, - "LastSaturnRetrogradeToPrograde": 4702514219286235215, + "LastSaturnProgradeToRetrograde": 4702513919871824425, + "LastSaturnRetrogradeToPrograde": 4702514219285831548, "LastSaturnWesternQuadrature": 4702513874832589619, "NextSaturnConjunction": 4702514468983751639, "NextSaturnEasternQuadrature": 4702515069774964954, "NextSaturnOpposition": 4702514881266846949, - "NextSaturnProgradeToRetrograde": 4702514731951225270, - "NextSaturnRetrogradeToPrograde": 4702515029844758667, + "NextSaturnProgradeToRetrograde": 4702514731951642450, + "NextSaturnRetrogradeToPrograde": 4702515029843514851, "NextSaturnWesternQuadrature": 4702514686189470628 } }, @@ -1033,14 +1033,14 @@ "LastSaturnConjunction": 4702514468983751639, "LastSaturnEasternQuadrature": 4702514259798420534, "LastSaturnOpposition": 4702514070085314883, - "LastSaturnProgradeToRetrograde": 4702514731951225270, - "LastSaturnRetrogradeToPrograde": 4702514219286235215, + "LastSaturnProgradeToRetrograde": 4702514731951642450, + "LastSaturnRetrogradeToPrograde": 4702514219285831548, "LastSaturnWesternQuadrature": 4702514686189470628, "NextSaturnConjunction": 4702515279344841789, "NextSaturnEasternQuadrature": 4702515069774964954, "NextSaturnOpposition": 4702514881266846949, - "NextSaturnProgradeToRetrograde": 4702515544354678665, - "NextSaturnRetrogradeToPrograde": 4702515029844758667, + "NextSaturnProgradeToRetrograde": 4702515544355111779, + "NextSaturnRetrogradeToPrograde": 4702515029843514851, "NextSaturnWesternQuadrature": 4702515498244640404 } }, @@ -1051,14 +1051,14 @@ "LastSaturnConjunction": 4702514468983751639, "LastSaturnEasternQuadrature": 4702515069774964954, "LastSaturnOpposition": 4702514881266846949, - "LastSaturnProgradeToRetrograde": 4702514731951225270, - "LastSaturnRetrogradeToPrograde": 4702515029844758667, + "LastSaturnProgradeToRetrograde": 4702514731951642450, + "LastSaturnRetrogradeToPrograde": 4702515029843514851, "LastSaturnWesternQuadrature": 4702514686189470628, "NextSaturnConjunction": 4702515279344841789, "NextSaturnEasternQuadrature": 4702515880214391202, "NextSaturnOpposition": 4702515692894798901, - "NextSaturnProgradeToRetrograde": 4702515544354678665, - "NextSaturnRetrogradeToPrograde": 4702515840919517425, + "NextSaturnProgradeToRetrograde": 4702515544355111779, + "NextSaturnRetrogradeToPrograde": 4702515840918958974, "NextSaturnWesternQuadrature": 4702515498244640404 } }, @@ -1069,14 +1069,14 @@ "LastSaturnConjunction": 4702515279344841789, "LastSaturnEasternQuadrature": 4702515069774964954, "LastSaturnOpposition": 4702514881266846949, - "LastSaturnProgradeToRetrograde": 4702514731951225270, - "LastSaturnRetrogradeToPrograde": 4702515029844758667, + "LastSaturnProgradeToRetrograde": 4702514731951642450, + "LastSaturnRetrogradeToPrograde": 4702515029843514851, "LastSaturnWesternQuadrature": 4702514686189470628, "NextSaturnConjunction": 4702516090514308171, "NextSaturnEasternQuadrature": 4702515880214391202, "NextSaturnOpposition": 4702515692894798901, - "NextSaturnProgradeToRetrograde": 4702515544354678665, - "NextSaturnRetrogradeToPrograde": 4702515840919517425, + "NextSaturnProgradeToRetrograde": 4702515544355111779, + "NextSaturnRetrogradeToPrograde": 4702515840918958974, "NextSaturnWesternQuadrature": 4702515498244640404 } }, @@ -1087,14 +1087,14 @@ "LastSaturnConjunction": 4702515279344841789, "LastSaturnEasternQuadrature": 4702515069774964954, "LastSaturnOpposition": 4702515692894798901, - "LastSaturnProgradeToRetrograde": 4702515544354678665, - "LastSaturnRetrogradeToPrograde": 4702515029844758667, + "LastSaturnProgradeToRetrograde": 4702515544355111779, + "LastSaturnRetrogradeToPrograde": 4702515029843514851, "LastSaturnWesternQuadrature": 4702515498244640404, "NextSaturnConjunction": 4702516090514308171, "NextSaturnEasternQuadrature": 4702515880214391202, "NextSaturnOpposition": 4702516504989037657, - "NextSaturnProgradeToRetrograde": 4702516357268301319, - "NextSaturnRetrogradeToPrograde": 4702515840919517425, + "NextSaturnProgradeToRetrograde": 4702516357268725409, + "NextSaturnRetrogradeToPrograde": 4702515840918958974, "NextSaturnWesternQuadrature": 4702516311055592921 } }, @@ -1105,14 +1105,14 @@ "LastSaturnConjunction": 4702516090514308171, "LastSaturnEasternQuadrature": 4702515880214391202, "LastSaturnOpposition": 4702515692894798901, - "LastSaturnProgradeToRetrograde": 4702515544354678665, - "LastSaturnRetrogradeToPrograde": 4702515840919517425, + "LastSaturnProgradeToRetrograde": 4702515544355111779, + "LastSaturnRetrogradeToPrograde": 4702515840918958974, "LastSaturnWesternQuadrature": 4702515498244640404, "NextSaturnConjunction": 4702516902555109694, "NextSaturnEasternQuadrature": 4702516691190324539, "NextSaturnOpposition": 4702516504989037657, - "NextSaturnProgradeToRetrograde": 4702516357268301319, - "NextSaturnRetrogradeToPrograde": 4702516652440296593, + "NextSaturnProgradeToRetrograde": 4702516357268725409, + "NextSaturnRetrogradeToPrograde": 4702516652440762692, "NextSaturnWesternQuadrature": 4702516311055592921 } }, @@ -1123,14 +1123,14 @@ "LastSaturnConjunction": 4702516090514308171, "LastSaturnEasternQuadrature": 4702515880214391202, "LastSaturnOpposition": 4702515692894798901, - "LastSaturnProgradeToRetrograde": 4702516357268301319, - "LastSaturnRetrogradeToPrograde": 4702515840919517425, + "LastSaturnProgradeToRetrograde": 4702516357268725409, + "LastSaturnRetrogradeToPrograde": 4702515840918958974, "LastSaturnWesternQuadrature": 4702516311055592921, "NextSaturnConjunction": 4702516902555109694, "NextSaturnEasternQuadrature": 4702516691190324539, "NextSaturnOpposition": 4702516504989037657, - "NextSaturnProgradeToRetrograde": 4702517170652575914, - "NextSaturnRetrogradeToPrograde": 4702516652440296593, + "NextSaturnProgradeToRetrograde": 4702517170652986350, + "NextSaturnRetrogradeToPrograde": 4702516652440762692, "NextSaturnWesternQuadrature": 4702517124594033282 } }, @@ -1141,14 +1141,14 @@ "LastSaturnConjunction": 4702516090514308171, "LastSaturnEasternQuadrature": 4702516691190324539, "LastSaturnOpposition": 4702516504989037657, - "LastSaturnProgradeToRetrograde": 4702516357268301319, - "LastSaturnRetrogradeToPrograde": 4702516652440296593, + "LastSaturnProgradeToRetrograde": 4702516357268725409, + "LastSaturnRetrogradeToPrograde": 4702516652440762692, "LastSaturnWesternQuadrature": 4702516311055592921, "NextSaturnConjunction": 4702516902555109694, "NextSaturnEasternQuadrature": 4702517502797421317, "NextSaturnOpposition": 4702517317565580535, - "NextSaturnProgradeToRetrograde": 4702517170652575914, - "NextSaturnRetrogradeToPrograde": 4702517464207910825, + "NextSaturnProgradeToRetrograde": 4702517170652986350, + "NextSaturnRetrogradeToPrograde": 4702517464208361129, "NextSaturnWesternQuadrature": 4702517124594033282 } }, @@ -1159,14 +1159,14 @@ "LastSaturnConjunction": 4702516902555109694, "LastSaturnEasternQuadrature": 4702516691190324539, "LastSaturnOpposition": 4702516504989037657, - "LastSaturnProgradeToRetrograde": 4702516357268301319, - "LastSaturnRetrogradeToPrograde": 4702516652440296593, + "LastSaturnProgradeToRetrograde": 4702516357268725409, + "LastSaturnRetrogradeToPrograde": 4702516652440762692, "LastSaturnWesternQuadrature": 4702517124594033282, "NextSaturnConjunction": 4702517715498552717, "NextSaturnEasternQuadrature": 4702517502797421317, "NextSaturnOpposition": 4702517317565580535, - "NextSaturnProgradeToRetrograde": 4702517170652575914, - "NextSaturnRetrogradeToPrograde": 4702517464207910825, + "NextSaturnProgradeToRetrograde": 4702517170652986350, + "NextSaturnRetrogradeToPrograde": 4702517464208361129, "NextSaturnWesternQuadrature": 4702517938784307496 } }, @@ -1177,14 +1177,14 @@ "LastSaturnConjunction": 4702516902555109694, "LastSaturnEasternQuadrature": 4702516691190324539, "LastSaturnOpposition": 4702517317565580535, - "LastSaturnProgradeToRetrograde": 4702517170652575914, - "LastSaturnRetrogradeToPrograde": 4702517464207910825, + "LastSaturnProgradeToRetrograde": 4702517170652986350, + "LastSaturnRetrogradeToPrograde": 4702517464208361129, "LastSaturnWesternQuadrature": 4702517124594033282, "NextSaturnConjunction": 4702517715498552717, "NextSaturnEasternQuadrature": 4702517502797421317, "NextSaturnOpposition": 4702518130601804087, - "NextSaturnProgradeToRetrograde": 4702517984360023779, - "NextSaturnRetrogradeToPrograde": 4702518276343097193, + "NextSaturnProgradeToRetrograde": 4702517984359844757, + "NextSaturnRetrogradeToPrograde": 4702518276343545104, "NextSaturnWesternQuadrature": 4702517938784307496 } }, @@ -1195,14 +1195,14 @@ "LastSaturnConjunction": 4702517715498552717, "LastSaturnEasternQuadrature": 4702517502797421317, "LastSaturnOpposition": 4702517317565580535, - "LastSaturnProgradeToRetrograde": 4702517170652575914, - "LastSaturnRetrogradeToPrograde": 4702517464207910825, + "LastSaturnProgradeToRetrograde": 4702517170652986350, + "LastSaturnRetrogradeToPrograde": 4702517464208361129, "LastSaturnWesternQuadrature": 4702517124594033282, "NextSaturnConjunction": 4702518529339943531, "NextSaturnEasternQuadrature": 4702518315061428444, "NextSaturnOpposition": 4702518130601804087, - "NextSaturnProgradeToRetrograde": 4702517984360023779, - "NextSaturnRetrogradeToPrograde": 4702518276343097193, + "NextSaturnProgradeToRetrograde": 4702517984359844757, + "NextSaturnRetrogradeToPrograde": 4702518276343545104, "NextSaturnWesternQuadrature": 4702517938784307496 } }, @@ -1213,14 +1213,14 @@ "LastSaturnConjunction": 4702517715498552717, "LastSaturnEasternQuadrature": 4702517502797421317, "LastSaturnOpposition": 4702518130601804087, - "LastSaturnProgradeToRetrograde": 4702517984360023779, - "LastSaturnRetrogradeToPrograde": 4702517464207910825, + "LastSaturnProgradeToRetrograde": 4702517984359844757, + "LastSaturnRetrogradeToPrograde": 4702517464208361129, "LastSaturnWesternQuadrature": 4702517938784307496, "NextSaturnConjunction": 4702518529339943531, "NextSaturnEasternQuadrature": 4702518315061428444, "NextSaturnOpposition": 4702518944055981728, - "NextSaturnProgradeToRetrograde": 4702518798260300473, - "NextSaturnRetrogradeToPrograde": 4702518276343097193, + "NextSaturnProgradeToRetrograde": 4702518798260726445, + "NextSaturnRetrogradeToPrograde": 4702518276343545104, "NextSaturnWesternQuadrature": 4702518753554317785 } }, @@ -1231,14 +1231,14 @@ "LastSaturnConjunction": 4702517715498552717, "LastSaturnEasternQuadrature": 4702518315061428444, "LastSaturnOpposition": 4702518130601804087, - "LastSaturnProgradeToRetrograde": 4702517984360023779, - "LastSaturnRetrogradeToPrograde": 4702518276343097193, + "LastSaturnProgradeToRetrograde": 4702517984359844757, + "LastSaturnRetrogradeToPrograde": 4702518276343545104, "LastSaturnWesternQuadrature": 4702517938784307496, "NextSaturnConjunction": 4702518529339943531, "NextSaturnEasternQuadrature": 4702519127967756278, "NextSaturnOpposition": 4702518944055981728, - "NextSaturnProgradeToRetrograde": 4702518798260300473, - "NextSaturnRetrogradeToPrograde": 4702519088991502001, + "NextSaturnProgradeToRetrograde": 4702518798260726445, + "NextSaturnRetrogradeToPrograde": 4702519088991610163, "NextSaturnWesternQuadrature": 4702518753554317785 } }, @@ -1249,14 +1249,14 @@ "LastSaturnConjunction": 4702518529339943531, "LastSaturnEasternQuadrature": 4702518315061428444, "LastSaturnOpposition": 4702518130601804087, - "LastSaturnProgradeToRetrograde": 4702518798260300473, - "LastSaturnRetrogradeToPrograde": 4702518276343097193, + "LastSaturnProgradeToRetrograde": 4702518798260726445, + "LastSaturnRetrogradeToPrograde": 4702518276343545104, "LastSaturnWesternQuadrature": 4702518753554317785, "NextSaturnConjunction": 4702519344013385964, "NextSaturnEasternQuadrature": 4702519127967756278, "NextSaturnOpposition": 4702518944055981728, - "NextSaturnProgradeToRetrograde": 4702519612467359257, - "NextSaturnRetrogradeToPrograde": 4702519088991502001, + "NextSaturnProgradeToRetrograde": 4702519612466921528, + "NextSaturnRetrogradeToPrograde": 4702519088991610163, "NextSaturnWesternQuadrature": 4702519568739586196 } }, @@ -1267,14 +1267,14 @@ "LastSaturnConjunction": 4702518529339943531, "LastSaturnEasternQuadrature": 4702519127967756278, "LastSaturnOpposition": 4702518944055981728, - "LastSaturnProgradeToRetrograde": 4702518798260300473, - "LastSaturnRetrogradeToPrograde": 4702519088991502001, + "LastSaturnProgradeToRetrograde": 4702518798260726445, + "LastSaturnRetrogradeToPrograde": 4702519088991610163, "LastSaturnWesternQuadrature": 4702518753554317785, "NextSaturnConjunction": 4702519344013385964, "NextSaturnEasternQuadrature": 4702519941514933026, "NextSaturnOpposition": 4702519757846309798, - "NextSaturnProgradeToRetrograde": 4702519612467359257, - "NextSaturnRetrogradeToPrograde": 4702519901996079915, + "NextSaturnProgradeToRetrograde": 4702519612466921528, + "NextSaturnRetrogradeToPrograde": 4702519901996541370, "NextSaturnWesternQuadrature": 4702519568739586196 } }, @@ -1285,14 +1285,14 @@ "LastSaturnConjunction": 4702519344013385964, "LastSaturnEasternQuadrature": 4702519127967756278, "LastSaturnOpposition": 4702518944055981728, - "LastSaturnProgradeToRetrograde": 4702518798260300473, - "LastSaturnRetrogradeToPrograde": 4702519088991502001, + "LastSaturnProgradeToRetrograde": 4702518798260726445, + "LastSaturnRetrogradeToPrograde": 4702519088991610163, "LastSaturnWesternQuadrature": 4702518753554317785, "NextSaturnConjunction": 4702520159413404372, "NextSaturnEasternQuadrature": 4702519941514933026, "NextSaturnOpposition": 4702519757846309798, - "NextSaturnProgradeToRetrograde": 4702519612467359257, - "NextSaturnRetrogradeToPrograde": 4702519901996079915, + "NextSaturnProgradeToRetrograde": 4702519612466921528, + "NextSaturnRetrogradeToPrograde": 4702519901996541370, "NextSaturnWesternQuadrature": 4702519568739586196 } }, @@ -1303,14 +1303,14 @@ "LastSaturnConjunction": 4702519344013385964, "LastSaturnEasternQuadrature": 4702519127967756278, "LastSaturnOpposition": 4702519757846309798, - "LastSaturnProgradeToRetrograde": 4702519612467359257, - "LastSaturnRetrogradeToPrograde": 4702519088991502001, + "LastSaturnProgradeToRetrograde": 4702519612466921528, + "LastSaturnRetrogradeToPrograde": 4702519088991610163, "LastSaturnWesternQuadrature": 4702519568739586196, "NextSaturnConjunction": 4702520159413404372, "NextSaturnEasternQuadrature": 4702519941514933026, "NextSaturnOpposition": 4702520571879464046, - "NextSaturnProgradeToRetrograde": 4702520426999883355, - "NextSaturnRetrogradeToPrograde": 4702519901996079915, + "NextSaturnProgradeToRetrograde": 4702520426999409701, + "NextSaturnRetrogradeToPrograde": 4702519901996541370, "NextSaturnWesternQuadrature": 4702520384150810840 } }, @@ -1321,14 +1321,14 @@ "LastSaturnConjunction": 4702520159413404372, "LastSaturnEasternQuadrature": 4702519941514933026, "LastSaturnOpposition": 4702519757846309798, - "LastSaturnProgradeToRetrograde": 4702519612467359257, - "LastSaturnRetrogradeToPrograde": 4702519901996079915, + "LastSaturnProgradeToRetrograde": 4702519612466921528, + "LastSaturnRetrogradeToPrograde": 4702519901996541370, "LastSaturnWesternQuadrature": 4702519568739586196, "NextSaturnConjunction": 4702520975353288857, "NextSaturnEasternQuadrature": 4702520755639229466, "NextSaturnOpposition": 4702520571879464046, - "NextSaturnProgradeToRetrograde": 4702520426999883355, - "NextSaturnRetrogradeToPrograde": 4702520715407552482, + "NextSaturnProgradeToRetrograde": 4702520426999409701, + "NextSaturnRetrogradeToPrograde": 4702520715408016141, "NextSaturnWesternQuadrature": 4702520384150810840 } }, @@ -1339,14 +1339,14 @@ "LastSaturnConjunction": 4702520159413404372, "LastSaturnEasternQuadrature": 4702519941514933026, "LastSaturnOpposition": 4702519757846309798, - "LastSaturnProgradeToRetrograde": 4702520426999883355, - "LastSaturnRetrogradeToPrograde": 4702519901996079915, + "LastSaturnProgradeToRetrograde": 4702520426999409701, + "LastSaturnRetrogradeToPrograde": 4702519901996541370, "LastSaturnWesternQuadrature": 4702520384150810840, "NextSaturnConjunction": 4702520975353288857, "NextSaturnEasternQuadrature": 4702520755639229466, "NextSaturnOpposition": 4702520571879464046, - "NextSaturnProgradeToRetrograde": 4702521241794830746, - "NextSaturnRetrogradeToPrograde": 4702520715407552482, + "NextSaturnProgradeToRetrograde": 4702521241794375916, + "NextSaturnRetrogradeToPrograde": 4702520715408016141, "NextSaturnWesternQuadrature": 4702521199621421395 } }, @@ -1357,14 +1357,14 @@ "LastSaturnConjunction": 4702520159413404372, "LastSaturnEasternQuadrature": 4702520755639229466, "LastSaturnOpposition": 4702520571879464046, - "LastSaturnProgradeToRetrograde": 4702520426999883355, - "LastSaturnRetrogradeToPrograde": 4702520715407552482, + "LastSaturnProgradeToRetrograde": 4702520426999409701, + "LastSaturnRetrogradeToPrograde": 4702520715408016141, "LastSaturnWesternQuadrature": 4702520384150810840, "NextSaturnConjunction": 4702520975353288857, "NextSaturnEasternQuadrature": 4702521570227969648, "NextSaturnOpposition": 4702521386065824308, - "NextSaturnProgradeToRetrograde": 4702521241794830746, - "NextSaturnRetrogradeToPrograde": 4702521529307706683, + "NextSaturnProgradeToRetrograde": 4702521241794375916, + "NextSaturnRetrogradeToPrograde": 4702521529308164355, "NextSaturnWesternQuadrature": 4702521199621421395 } }, @@ -1375,14 +1375,14 @@ "LastSaturnConjunction": 4702520975353288857, "LastSaturnEasternQuadrature": 4702520755639229466, "LastSaturnOpposition": 4702520571879464046, - "LastSaturnProgradeToRetrograde": 4702520426999883355, - "LastSaturnRetrogradeToPrograde": 4702520715407552482, + "LastSaturnProgradeToRetrograde": 4702520426999409701, + "LastSaturnRetrogradeToPrograde": 4702520715408016141, "LastSaturnWesternQuadrature": 4702520384150810840, "NextSaturnConjunction": 4702521791599433056, "NextSaturnEasternQuadrature": 4702521570227969648, "NextSaturnOpposition": 4702521386065824308, - "NextSaturnProgradeToRetrograde": 4702521241794830746, - "NextSaturnRetrogradeToPrograde": 4702521529307706683, + "NextSaturnProgradeToRetrograde": 4702521241794375916, + "NextSaturnRetrogradeToPrograde": 4702521529308164355, "NextSaturnWesternQuadrature": 4702521199621421395 } }, @@ -1393,14 +1393,14 @@ "LastSaturnConjunction": 4702520975353288857, "LastSaturnEasternQuadrature": 4702520755639229466, "LastSaturnOpposition": 4702521386065824308, - "LastSaturnProgradeToRetrograde": 4702521241794830746, - "LastSaturnRetrogradeToPrograde": 4702520715407552482, + "LastSaturnProgradeToRetrograde": 4702521241794375916, + "LastSaturnRetrogradeToPrograde": 4702520715408016141, "LastSaturnWesternQuadrature": 4702521199621421395, "NextSaturnConjunction": 4702521791599433056, "NextSaturnEasternQuadrature": 4702521570227969648, "NextSaturnOpposition": 4702522200274601712, - "NextSaturnProgradeToRetrograde": 4702522056670488826, - "NextSaturnRetrogradeToPrograde": 4702521529307706683, + "NextSaturnProgradeToRetrograde": 4702522056669199019, + "NextSaturnRetrogradeToPrograde": 4702521529308164355, "NextSaturnWesternQuadrature": 4702522014939177587 } }, @@ -1411,14 +1411,14 @@ "LastSaturnConjunction": 4702521791599433056, "LastSaturnEasternQuadrature": 4702521570227969648, "LastSaturnOpposition": 4702521386065824308, - "LastSaturnProgradeToRetrograde": 4702521241794830746, - "LastSaturnRetrogradeToPrograde": 4702521529307706683, + "LastSaturnProgradeToRetrograde": 4702521241794375916, + "LastSaturnRetrogradeToPrograde": 4702521529308164355, "LastSaturnWesternQuadrature": 4702521199621421395, "NextSaturnConjunction": 4702522607909441103, "NextSaturnEasternQuadrature": 4702522385163072143, "NextSaturnOpposition": 4702522200274601712, - "NextSaturnProgradeToRetrograde": 4702522056670488826, - "NextSaturnRetrogradeToPrograde": 4702522343647694178, + "NextSaturnProgradeToRetrograde": 4702522056669199019, + "NextSaturnRetrogradeToPrograde": 4702522343648160412, "NextSaturnWesternQuadrature": 4702522014939177587 } }, @@ -1429,14 +1429,14 @@ "LastSaturnConjunction": 4702521791599433056, "LastSaturnEasternQuadrature": 4702521570227969648, "LastSaturnOpposition": 4702521386065824308, - "LastSaturnProgradeToRetrograde": 4702522056670488826, - "LastSaturnRetrogradeToPrograde": 4702521529307706683, + "LastSaturnProgradeToRetrograde": 4702522056669199019, + "LastSaturnRetrogradeToPrograde": 4702521529308164355, "LastSaturnWesternQuadrature": 4702522014939177587, "NextSaturnConjunction": 4702522607909441103, "NextSaturnEasternQuadrature": 4702522385163072143, "NextSaturnOpposition": 4702522200274601712, - "NextSaturnProgradeToRetrograde": 4702522871514654349, - "NextSaturnRetrogradeToPrograde": 4702522343647702551, + "NextSaturnProgradeToRetrograde": 4702522871515097136, + "NextSaturnRetrogradeToPrograde": 4702522343648160412, "NextSaturnWesternQuadrature": 4702522829887030586 } }, @@ -1447,14 +1447,14 @@ "LastSaturnConjunction": 4702521791599433056, "LastSaturnEasternQuadrature": 4702522385163072143, "LastSaturnOpposition": 4702522200274601712, - "LastSaturnProgradeToRetrograde": 4702522056670488826, - "LastSaturnRetrogradeToPrograde": 4702522343647694178, + "LastSaturnProgradeToRetrograde": 4702522056669199019, + "LastSaturnRetrogradeToPrograde": 4702522343648160412, "LastSaturnWesternQuadrature": 4702522014939177587, "NextSaturnConjunction": 4702522607909441103, "NextSaturnEasternQuadrature": 4702523200254411091, "NextSaturnOpposition": 4702523014362484992, - "NextSaturnProgradeToRetrograde": 4702522871514654349, - "NextSaturnRetrogradeToPrograde": 4702523158325050275, + "NextSaturnProgradeToRetrograde": 4702522871515097136, + "NextSaturnRetrogradeToPrograde": 4702523158325527857, "NextSaturnWesternQuadrature": 4702522829887030586 } }, @@ -1465,14 +1465,14 @@ "LastSaturnConjunction": 4702522607909441103, "LastSaturnEasternQuadrature": 4702522385163072143, "LastSaturnOpposition": 4702522200274601712, - "LastSaturnProgradeToRetrograde": 4702522871514654349, - "LastSaturnRetrogradeToPrograde": 4702522343647694178, + "LastSaturnProgradeToRetrograde": 4702522871515097136, + "LastSaturnRetrogradeToPrograde": 4702522343648160412, "LastSaturnWesternQuadrature": 4702522829887030586, "NextSaturnConjunction": 4702523423987345076, "NextSaturnEasternQuadrature": 4702523200254411091, "NextSaturnOpposition": 4702523014362484992, - "NextSaturnProgradeToRetrograde": 4702523686106230886, - "NextSaturnRetrogradeToPrograde": 4702523158325050275, + "NextSaturnProgradeToRetrograde": 4702523686106472042, + "NextSaturnRetrogradeToPrograde": 4702523158325527857, "NextSaturnWesternQuadrature": 4702523644313283421 } }, @@ -1483,14 +1483,14 @@ "LastSaturnConjunction": 4702522607909441103, "LastSaturnEasternQuadrature": 4702523200254411091, "LastSaturnOpposition": 4702523014362484992, - "LastSaturnProgradeToRetrograde": 4702522871514654349, - "LastSaturnRetrogradeToPrograde": 4702523158325050275, + "LastSaturnProgradeToRetrograde": 4702522871515097136, + "LastSaturnRetrogradeToPrograde": 4702523158325527857, "LastSaturnWesternQuadrature": 4702522829887030586, "NextSaturnConjunction": 4702523423987345076, "NextSaturnEasternQuadrature": 4702524015309269536, "NextSaturnOpposition": 4702523828232647308, - "NextSaturnProgradeToRetrograde": 4702523686106230886, - "NextSaturnRetrogradeToPrograde": 4702523973298541125, + "NextSaturnProgradeToRetrograde": 4702523686106472042, + "NextSaturnRetrogradeToPrograde": 4702523973299018794, "NextSaturnWesternQuadrature": 4702523644313283421 } }, @@ -1501,14 +1501,14 @@ "LastSaturnConjunction": 4702523423987345076, "LastSaturnEasternQuadrature": 4702523200254411091, "LastSaturnOpposition": 4702523014362484992, - "LastSaturnProgradeToRetrograde": 4702522871514654349, - "LastSaturnRetrogradeToPrograde": 4702523158325050275, + "LastSaturnProgradeToRetrograde": 4702522871515097136, + "LastSaturnRetrogradeToPrograde": 4702523158325527857, "LastSaturnWesternQuadrature": 4702522829887030586, "NextSaturnConjunction": 4702524239574017359, "NextSaturnEasternQuadrature": 4702524015309269536, "NextSaturnOpposition": 4702523828232647308, - "NextSaturnProgradeToRetrograde": 4702523686106230886, - "NextSaturnRetrogradeToPrograde": 4702523973298541125, + "NextSaturnProgradeToRetrograde": 4702523686106472042, + "NextSaturnRetrogradeToPrograde": 4702523973299018794, "NextSaturnWesternQuadrature": 4702523644313283421 } }, @@ -1519,14 +1519,14 @@ "LastSaturnConjunction": 4702523423987345076, "LastSaturnEasternQuadrature": 4702523200254411091, "LastSaturnOpposition": 4702523828232647308, - "LastSaturnProgradeToRetrograde": 4702523686106230886, - "LastSaturnRetrogradeToPrograde": 4702523158325050275, + "LastSaturnProgradeToRetrograde": 4702523686106472042, + "LastSaturnRetrogradeToPrograde": 4702523158325527857, "LastSaturnWesternQuadrature": 4702523644313283421, "NextSaturnConjunction": 4702524239574017359, "NextSaturnEasternQuadrature": 4702524015309269536, "NextSaturnOpposition": 4702524641761758079, - "NextSaturnProgradeToRetrograde": 4702524500152192694, - "NextSaturnRetrogradeToPrograde": 4702523973298541125, + "NextSaturnProgradeToRetrograde": 4702524500152603576, + "NextSaturnRetrogradeToPrograde": 4702523973299018794, "NextSaturnWesternQuadrature": 4702524458086687222 } }, @@ -1537,14 +1537,14 @@ "LastSaturnConjunction": 4702523423987345076, "LastSaturnEasternQuadrature": 4702524015309269536, "LastSaturnOpposition": 4702523828232647308, - "LastSaturnProgradeToRetrograde": 4702523686106230886, - "LastSaturnRetrogradeToPrograde": 4702523973298541125, + "LastSaturnProgradeToRetrograde": 4702523686106472042, + "LastSaturnRetrogradeToPrograde": 4702523973299018794, "LastSaturnWesternQuadrature": 4702523644313283421, "NextSaturnConjunction": 4702524239574017359, "NextSaturnEasternQuadrature": 4702524830148237347, "NextSaturnOpposition": 4702524641761758079, - "NextSaturnProgradeToRetrograde": 4702524500152192694, - "NextSaturnRetrogradeToPrograde": 4702524788383686924, + "NextSaturnProgradeToRetrograde": 4702524500152603576, + "NextSaturnRetrogradeToPrograde": 4702524788383660462, "NextSaturnWesternQuadrature": 4702524458086687222 } }, @@ -1555,14 +1555,14 @@ "LastSaturnConjunction": 4702524239574017359, "LastSaturnEasternQuadrature": 4702524015309269536, "LastSaturnOpposition": 4702523828232647308, - "LastSaturnProgradeToRetrograde": 4702524500152192694, - "LastSaturnRetrogradeToPrograde": 4702523973298541125, + "LastSaturnProgradeToRetrograde": 4702524500152603576, + "LastSaturnRetrogradeToPrograde": 4702523973299018794, "LastSaturnWesternQuadrature": 4702524458086687222, "NextSaturnConjunction": 4702525054505625445, "NextSaturnEasternQuadrature": 4702524830148237347, "NextSaturnOpposition": 4702524641761758079, - "NextSaturnProgradeToRetrograde": 4702525313344407804, - "NextSaturnRetrogradeToPrograde": 4702524788383686924, + "NextSaturnProgradeToRetrograde": 4702525313344829022, + "NextSaturnRetrogradeToPrograde": 4702524788383660462, "NextSaturnWesternQuadrature": 4702525271124507033 } }, @@ -1573,14 +1573,14 @@ "LastSaturnConjunction": 4702524239574017359, "LastSaturnEasternQuadrature": 4702524830148237347, "LastSaturnOpposition": 4702524641761758079, - "LastSaturnProgradeToRetrograde": 4702524500152192694, - "LastSaturnRetrogradeToPrograde": 4702524788383686924, + "LastSaturnProgradeToRetrograde": 4702524500152603576, + "LastSaturnRetrogradeToPrograde": 4702524788383660462, "LastSaturnWesternQuadrature": 4702524458086687222, "NextSaturnConjunction": 4702525054505625445, "NextSaturnEasternQuadrature": 4702525644611337068, "NextSaturnOpposition": 4702525454861406637, - "NextSaturnProgradeToRetrograde": 4702525313344407804, - "NextSaturnRetrogradeToPrograde": 4702525603356217983, + "NextSaturnProgradeToRetrograde": 4702525313344829022, + "NextSaturnRetrogradeToPrograde": 4702525603356701068, "NextSaturnWesternQuadrature": 4702525271124507033 } }, @@ -1591,14 +1591,14 @@ "LastSaturnConjunction": 4702525054505625445, "LastSaturnEasternQuadrature": 4702524830148237347, "LastSaturnOpposition": 4702524641761758079, - "LastSaturnProgradeToRetrograde": 4702524500152192694, - "LastSaturnRetrogradeToPrograde": 4702524788383686924, + "LastSaturnProgradeToRetrograde": 4702524500152603576, + "LastSaturnRetrogradeToPrograde": 4702524788383660462, "LastSaturnWesternQuadrature": 4702524458086687222, "NextSaturnConjunction": 4702525868627530973, "NextSaturnEasternQuadrature": 4702525644611337068, "NextSaturnOpposition": 4702525454861406637, - "NextSaturnProgradeToRetrograde": 4702525313344407804, - "NextSaturnRetrogradeToPrograde": 4702525603356217983, + "NextSaturnProgradeToRetrograde": 4702525313344829022, + "NextSaturnRetrogradeToPrograde": 4702525603356701068, "NextSaturnWesternQuadrature": 4702525271124507033 } }, @@ -1609,14 +1609,14 @@ "LastSaturnConjunction": 4702525054505625445, "LastSaturnEasternQuadrature": 4702524830148237347, "LastSaturnOpposition": 4702525454861406637, - "LastSaturnProgradeToRetrograde": 4702525313344407804, - "LastSaturnRetrogradeToPrograde": 4702524788383686924, + "LastSaturnProgradeToRetrograde": 4702525313344829022, + "LastSaturnRetrogradeToPrograde": 4702524788383660462, "LastSaturnWesternQuadrature": 4702525271124507033, "NextSaturnConjunction": 4702525868627530973, "NextSaturnEasternQuadrature": 4702525644611337068, "NextSaturnOpposition": 4702526267512954555, - "NextSaturnProgradeToRetrograde": 4702526125760746732, - "NextSaturnRetrogradeToPrograde": 4702525603356217983, + "NextSaturnProgradeToRetrograde": 4702526125761153380, + "NextSaturnRetrogradeToPrograde": 4702525603356701068, "NextSaturnWesternQuadrature": 4702526083406067990 } }, @@ -1627,14 +1627,14 @@ "LastSaturnConjunction": 4702525868627530973, "LastSaturnEasternQuadrature": 4702525644611337068, "LastSaturnOpposition": 4702525454861406637, - "LastSaturnProgradeToRetrograde": 4702525313344407804, - "LastSaturnRetrogradeToPrograde": 4702525603356217983, + "LastSaturnProgradeToRetrograde": 4702525313344829022, + "LastSaturnRetrogradeToPrograde": 4702525603356701068, "LastSaturnWesternQuadrature": 4702525271124507033, "NextSaturnConjunction": 4702526681841763602, "NextSaturnEasternQuadrature": 4702526458575490014, "NextSaturnOpposition": 4702526267512954555, - "NextSaturnProgradeToRetrograde": 4702526125760746732, - "NextSaturnRetrogradeToPrograde": 4702526417755760526, + "NextSaturnProgradeToRetrograde": 4702526125761153380, + "NextSaturnRetrogradeToPrograde": 4702526417756415486, "NextSaturnWesternQuadrature": 4702526083406067990 } }, @@ -1645,14 +1645,14 @@ "LastSaturnConjunction": 4702525868627530973, "LastSaturnEasternQuadrature": 4702525644611337068, "LastSaturnOpposition": 4702525454861406637, - "LastSaturnProgradeToRetrograde": 4702526125760746732, - "LastSaturnRetrogradeToPrograde": 4702525603356217983, + "LastSaturnProgradeToRetrograde": 4702526125761153380, + "LastSaturnRetrogradeToPrograde": 4702525603356701068, "LastSaturnWesternQuadrature": 4702526083406067990, "NextSaturnConjunction": 4702526681841763602, "NextSaturnEasternQuadrature": 4702526458575490014, "NextSaturnOpposition": 4702526267512954555, - "NextSaturnProgradeToRetrograde": 4702526937432175922, - "NextSaturnRetrogradeToPrograde": 4702526417755760526, + "NextSaturnProgradeToRetrograde": 4702526937432569219, + "NextSaturnRetrogradeToPrograde": 4702526417756415486, "NextSaturnWesternQuadrature": 4702526894942960204 } }, @@ -1663,14 +1663,14 @@ "LastSaturnConjunction": 4702525868627530973, "LastSaturnEasternQuadrature": 4702526458575490014, "LastSaturnOpposition": 4702526267512954555, - "LastSaturnProgradeToRetrograde": 4702526125760746732, - "LastSaturnRetrogradeToPrograde": 4702526417755760526, + "LastSaturnProgradeToRetrograde": 4702526125761153380, + "LastSaturnRetrogradeToPrograde": 4702526417756415486, "LastSaturnWesternQuadrature": 4702526083406067990, "NextSaturnConjunction": 4702526681841763602, "NextSaturnEasternQuadrature": 4702527271945710833, "NextSaturnOpposition": 4702527079685466594, - "NextSaturnProgradeToRetrograde": 4702526937432175922, - "NextSaturnRetrogradeToPrograde": 4702527231413066131, + "NextSaturnProgradeToRetrograde": 4702526937432569219, + "NextSaturnRetrogradeToPrograde": 4702527231413334267, "NextSaturnWesternQuadrature": 4702526894942960204 } }, @@ -1681,14 +1681,14 @@ "LastSaturnConjunction": 4702526681841763602, "LastSaturnEasternQuadrature": 4702526458575490014, "LastSaturnOpposition": 4702526267512954555, - "LastSaturnProgradeToRetrograde": 4702526125760746732, - "LastSaturnRetrogradeToPrograde": 4702526417755760526, + "LastSaturnProgradeToRetrograde": 4702526125761153380, + "LastSaturnRetrogradeToPrograde": 4702526417756415486, "LastSaturnWesternQuadrature": 4702526894942960204, "NextSaturnConjunction": 4702527494156830189, "NextSaturnEasternQuadrature": 4702527271945710833, "NextSaturnOpposition": 4702527079685466594, - "NextSaturnProgradeToRetrograde": 4702526937432175922, - "NextSaturnRetrogradeToPrograde": 4702527231413035150, + "NextSaturnProgradeToRetrograde": 4702526937432569219, + "NextSaturnRetrogradeToPrograde": 4702527231413334267, "NextSaturnWesternQuadrature": 4702527705800760929 } }, @@ -1699,14 +1699,14 @@ "LastSaturnConjunction": 4702526681841763602, "LastSaturnEasternQuadrature": 4702526458575490014, "LastSaturnOpposition": 4702527079685466594, - "LastSaturnProgradeToRetrograde": 4702526937432175922, - "LastSaturnRetrogradeToPrograde": 4702527231413066131, + "LastSaturnProgradeToRetrograde": 4702526937432569219, + "LastSaturnRetrogradeToPrograde": 4702527231413334267, "LastSaturnWesternQuadrature": 4702526894942960204, "NextSaturnConjunction": 4702527494156830189, "NextSaturnEasternQuadrature": 4702527271945710833, "NextSaturnOpposition": 4702527891373365617, - "NextSaturnProgradeToRetrograde": 4702527748232122673, - "NextSaturnRetrogradeToPrograde": 4702528044263454190, + "NextSaturnProgradeToRetrograde": 4702527748232042571, + "NextSaturnRetrogradeToPrograde": 4702528044263941419, "NextSaturnWesternQuadrature": 4702527705800760929 } }, @@ -1717,14 +1717,14 @@ "LastSaturnConjunction": 4702527494156830189, "LastSaturnEasternQuadrature": 4702527271945710833, "LastSaturnOpposition": 4702527079685466594, - "LastSaturnProgradeToRetrograde": 4702526937432175922, - "LastSaturnRetrogradeToPrograde": 4702527231413066131, + "LastSaturnProgradeToRetrograde": 4702526937432569219, + "LastSaturnRetrogradeToPrograde": 4702527231413334267, "LastSaturnWesternQuadrature": 4702526894942960204, "NextSaturnConjunction": 4702528305594171046, "NextSaturnEasternQuadrature": 4702528084677650276, "NextSaturnOpposition": 4702527891373365617, - "NextSaturnProgradeToRetrograde": 4702527748232122673, - "NextSaturnRetrogradeToPrograde": 4702528044263454190, + "NextSaturnProgradeToRetrograde": 4702527748232042571, + "NextSaturnRetrogradeToPrograde": 4702528044263941419, "NextSaturnWesternQuadrature": 4702527705800760929 } } diff --git a/basic/testdata/uranus_event_baseline.json b/basic/testdata/uranus_event_baseline.json index 68c3112..6f30b2c 100644 --- a/basic/testdata/uranus_event_baseline.json +++ b/basic/testdata/uranus_event_baseline.json @@ -7,14 +7,14 @@ "LastUranusConjunction": 4702513589956640447, "LastUranusEasternQuadrature": 4702514183194600153, "LastUranusOpposition": 4702513987644422414, - "LastUranusProgradeToRetrograde": 4702513822177210455, - "LastUranusRetrogradeToPrograde": 4702514152993315250, + "LastUranusProgradeToRetrograde": 4702513822177536197, + "LastUranusRetrogradeToPrograde": 4702514152992619221, "LastUranusWesternQuadrature": 4702513791016598431, "NextUranusConjunction": 4702514383079120109, "NextUranusEasternQuadrature": 4702514976566649311, "NextUranusOpposition": 4702514781340031902, - "NextUranusProgradeToRetrograde": 4702514615747649496, - "NextUranusRetrogradeToPrograde": 4702514946641993231, + "NextUranusProgradeToRetrograde": 4702514615747482579, + "NextUranusRetrogradeToPrograde": 4702514946642435022, "NextUranusWesternQuadrature": 4702514584468647382 } }, @@ -25,14 +25,14 @@ "LastUranusConjunction": 4702514383079120109, "LastUranusEasternQuadrature": 4702514183194600153, "LastUranusOpposition": 4702513987644422414, - "LastUranusProgradeToRetrograde": 4702514615747649496, - "LastUranusRetrogradeToPrograde": 4702514152993315250, + "LastUranusProgradeToRetrograde": 4702514615747482579, + "LastUranusRetrogradeToPrograde": 4702514152992619221, "LastUranusWesternQuadrature": 4702514584468647382, "NextUranusConjunction": 4702515176160970118, "NextUranusEasternQuadrature": 4702514976566649311, "NextUranusOpposition": 4702514781340031902, - "NextUranusProgradeToRetrograde": 4702515409074191842, - "NextUranusRetrogradeToPrograde": 4702514946641993231, + "NextUranusProgradeToRetrograde": 4702515409073953085, + "NextUranusRetrogradeToPrograde": 4702514946642435022, "NextUranusWesternQuadrature": 4702515377892878280 } }, @@ -43,14 +43,14 @@ "LastUranusConjunction": 4702515176160970118, "LastUranusEasternQuadrature": 4702514976566649311, "LastUranusOpposition": 4702514781340031902, - "LastUranusProgradeToRetrograde": 4702514615747649496, - "LastUranusRetrogradeToPrograde": 4702514946641993231, + "LastUranusProgradeToRetrograde": 4702514615747482579, + "LastUranusRetrogradeToPrograde": 4702514946642435022, "LastUranusWesternQuadrature": 4702514584468647382, "NextUranusConjunction": 4702515969207149862, "NextUranusEasternQuadrature": 4702515769884222212, "NextUranusOpposition": 4702515574994193141, - "NextUranusProgradeToRetrograde": 4702515409074191842, - "NextUranusRetrogradeToPrograde": 4702515740112175852, + "NextUranusProgradeToRetrograde": 4702515409073953085, + "NextUranusRetrogradeToPrograde": 4702515740112576076, "NextUranusWesternQuadrature": 4702515377892878280 } }, @@ -61,14 +61,14 @@ "LastUranusConjunction": 4702515176160970118, "LastUranusEasternQuadrature": 4702515769884222212, "LastUranusOpposition": 4702515574994193141, - "LastUranusProgradeToRetrograde": 4702515409074191842, - "LastUranusRetrogradeToPrograde": 4702515740112175852, + "LastUranusProgradeToRetrograde": 4702515409073953085, + "LastUranusRetrogradeToPrograde": 4702515740112576076, "LastUranusWesternQuadrature": 4702515377892878280, "NextUranusConjunction": 4702515969207149862, "NextUranusEasternQuadrature": 4702516563112656982, "NextUranusOpposition": 4702516368591092528, - "NextUranusProgradeToRetrograde": 4702516202605290795, - "NextUranusRetrogradeToPrograde": 4702516533649578061, + "NextUranusProgradeToRetrograde": 4702516202605532836, + "NextUranusRetrogradeToPrograde": 4702516533649012747, "NextUranusWesternQuadrature": 4702516171294668789 } }, @@ -79,14 +79,14 @@ "LastUranusConjunction": 4702515969207149862, "LastUranusEasternQuadrature": 4702515769884222212, "LastUranusOpposition": 4702515574994193141, - "LastUranusProgradeToRetrograde": 4702516202605290795, - "LastUranusRetrogradeToPrograde": 4702515740112175852, + "LastUranusProgradeToRetrograde": 4702516202605532836, + "LastUranusRetrogradeToPrograde": 4702515740112576076, "LastUranusWesternQuadrature": 4702516171294668789, "NextUranusConjunction": 4702516762213921313, "NextUranusEasternQuadrature": 4702516563112656982, "NextUranusOpposition": 4702516368591092528, - "NextUranusProgradeToRetrograde": 4702516995840730434, - "NextUranusRetrogradeToPrograde": 4702516533649578061, + "NextUranusProgradeToRetrograde": 4702516995841177671, + "NextUranusRetrogradeToPrograde": 4702516533649012747, "NextUranusWesternQuadrature": 4702516964690352189 } }, @@ -97,14 +97,14 @@ "LastUranusConjunction": 4702516762213921313, "LastUranusEasternQuadrature": 4702516563112656982, "LastUranusOpposition": 4702516368591092528, - "LastUranusProgradeToRetrograde": 4702516202605290795, - "LastUranusRetrogradeToPrograde": 4702516533649578061, + "LastUranusProgradeToRetrograde": 4702516202605532836, + "LastUranusRetrogradeToPrograde": 4702516533649012747, "LastUranusWesternQuadrature": 4702516171294668789, "NextUranusConjunction": 4702517555194326276, "NextUranusEasternQuadrature": 4702517356287685296, "NextUranusOpposition": 4702517162146445112, - "NextUranusProgradeToRetrograde": 4702516995840730434, - "NextUranusRetrogradeToPrograde": 4702517327008871959, + "NextUranusProgradeToRetrograde": 4702516995841177671, + "NextUranusRetrogradeToPrograde": 4702517327009385295, "NextUranusWesternQuadrature": 4702516964690352189 } }, @@ -115,14 +115,14 @@ "LastUranusConjunction": 4702516762213921313, "LastUranusEasternQuadrature": 4702516563112656982, "LastUranusOpposition": 4702517162146445112, - "LastUranusProgradeToRetrograde": 4702516995840730434, - "LastUranusRetrogradeToPrograde": 4702516533648531210, + "LastUranusProgradeToRetrograde": 4702516995841177671, + "LastUranusRetrogradeToPrograde": 4702516533649012721, "LastUranusWesternQuadrature": 4702516964690352189, "NextUranusConjunction": 4702517555194326276, "NextUranusEasternQuadrature": 4702517356287685296, "NextUranusOpposition": 4702517955616696497, - "NextUranusProgradeToRetrograde": 4702517789293484285, - "NextUranusRetrogradeToPrograde": 4702517327008871959, + "NextUranusProgradeToRetrograde": 4702517789293878909, + "NextUranusRetrogradeToPrograde": 4702517327009385295, "NextUranusWesternQuadrature": 4702517758037284699 } }, @@ -133,14 +133,14 @@ "LastUranusConjunction": 4702517555194326276, "LastUranusEasternQuadrature": 4702517356287685296, "LastUranusOpposition": 4702517162146445112, - "LastUranusProgradeToRetrograde": 4702517789293484285, - "LastUranusRetrogradeToPrograde": 4702517327008871959, + "LastUranusProgradeToRetrograde": 4702517789293878909, + "LastUranusRetrogradeToPrograde": 4702517327009385295, "LastUranusWesternQuadrature": 4702517758037284699, "NextUranusConjunction": 4702518348138534656, "NextUranusEasternQuadrature": 4702518149384676546, "NextUranusOpposition": 4702517955616696497, - "NextUranusProgradeToRetrograde": 4702518582423440752, - "NextUranusRetrogradeToPrograde": 4702518120429407426, + "NextUranusProgradeToRetrograde": 4702518582422765156, + "NextUranusRetrogradeToPrograde": 4702518120429892675, "NextUranusWesternQuadrature": 4702518551382307450 } }, @@ -151,14 +151,14 @@ "LastUranusConjunction": 4702517555194326276, "LastUranusEasternQuadrature": 4702518149384676546, "LastUranusOpposition": 4702517955616696497, - "LastUranusProgradeToRetrograde": 4702517789293484285, - "LastUranusRetrogradeToPrograde": 4702518120429407426, + "LastUranusProgradeToRetrograde": 4702517789293878909, + "LastUranusRetrogradeToPrograde": 4702518120429892675, "LastUranusWesternQuadrature": 4702517758037284699, "NextUranusConjunction": 4702518348138534656, "NextUranusEasternQuadrature": 4702518942431221512, "NextUranusOpposition": 4702518749060655162, - "NextUranusProgradeToRetrograde": 4702518582423440752, - "NextUranusRetrogradeToPrograde": 4702518913695938780, + "NextUranusProgradeToRetrograde": 4702518582422765156, + "NextUranusRetrogradeToPrograde": 4702518913695224150, "NextUranusWesternQuadrature": 4702518551382307450 } }, @@ -169,14 +169,14 @@ "LastUranusConjunction": 4702518348138534656, "LastUranusEasternQuadrature": 4702518149384676546, "LastUranusOpposition": 4702518749060655162, - "LastUranusProgradeToRetrograde": 4702518582423440752, - "LastUranusRetrogradeToPrograde": 4702518120429407426, + "LastUranusProgradeToRetrograde": 4702518582422765156, + "LastUranusRetrogradeToPrograde": 4702518120429892675, "LastUranusWesternQuadrature": 4702518551382307450, "NextUranusConjunction": 4702519141030498613, "NextUranusEasternQuadrature": 4702518942431221512, "NextUranusOpposition": 4702519542439443113, - "NextUranusProgradeToRetrograde": 4702519375781560774, - "NextUranusRetrogradeToPrograde": 4702518913695938780, + "NextUranusProgradeToRetrograde": 4702519375780795835, + "NextUranusRetrogradeToPrograde": 4702518913695224150, "NextUranusWesternQuadrature": 4702519344686169501 } }, @@ -187,14 +187,14 @@ "LastUranusConjunction": 4702519141030498613, "LastUranusEasternQuadrature": 4702518942431221512, "LastUranusOpposition": 4702518749060655162, - "LastUranusProgradeToRetrograde": 4702518582423440752, - "LastUranusRetrogradeToPrograde": 4702518913695938780, + "LastUranusProgradeToRetrograde": 4702518582422765156, + "LastUranusRetrogradeToPrograde": 4702518913695224150, "LastUranusWesternQuadrature": 4702519344686169501, "NextUranusConjunction": 4702519933907324656, "NextUranusEasternQuadrature": 4702519735411240615, "NextUranusOpposition": 4702519542439443113, - "NextUranusProgradeToRetrograde": 4702519375781560774, - "NextUranusRetrogradeToPrograde": 4702519706986927387, + "NextUranusProgradeToRetrograde": 4702519375780795835, + "NextUranusRetrogradeToPrograde": 4702519706986315348, "NextUranusWesternQuadrature": 4702520137987392956 } }, @@ -205,14 +205,14 @@ "LastUranusConjunction": 4702519141030498613, "LastUranusEasternQuadrature": 4702519735411240615, "LastUranusOpposition": 4702519542439443113, - "LastUranusProgradeToRetrograde": 4702519375781560774, - "LastUranusRetrogradeToPrograde": 4702519706986927387, + "LastUranusProgradeToRetrograde": 4702519375780795835, + "LastUranusRetrogradeToPrograde": 4702519706986315348, "LastUranusWesternQuadrature": 4702519344686169501, "NextUranusConjunction": 4702519933907324656, "NextUranusEasternQuadrature": 4702520528331241073, "NextUranusOpposition": 4702520335763343962, - "NextUranusProgradeToRetrograde": 4702520168850045874, - "NextUranusRetrogradeToPrograde": 4702520500137595017, + "NextUranusProgradeToRetrograde": 4702520168850395666, + "NextUranusRetrogradeToPrograde": 4702520500137672372, "NextUranusWesternQuadrature": 4702520137987392956 } }, @@ -223,14 +223,14 @@ "LastUranusConjunction": 4702519933907324656, "LastUranusEasternQuadrature": 4702519735411240615, "LastUranusOpposition": 4702520335763343962, - "LastUranusProgradeToRetrograde": 4702520168850045874, - "LastUranusRetrogradeToPrograde": 4702519706986927387, + "LastUranusProgradeToRetrograde": 4702520168850395666, + "LastUranusRetrogradeToPrograde": 4702519706986315348, "LastUranusWesternQuadrature": 4702520137987392956, "NextUranusConjunction": 4702520726756548793, "NextUranusEasternQuadrature": 4702520528331241073, "NextUranusOpposition": 4702521129033150442, - "NextUranusProgradeToRetrograde": 4702520962113041332, - "NextUranusRetrogradeToPrograde": 4702520500137595017, + "NextUranusProgradeToRetrograde": 4702520962113424659, + "NextUranusRetrogradeToPrograde": 4702520500137672372, "NextUranusWesternQuadrature": 4702520931243643881 } }, @@ -241,14 +241,14 @@ "LastUranusConjunction": 4702520726756548793, "LastUranusEasternQuadrature": 4702520528331241073, "LastUranusOpposition": 4702520335763343962, - "LastUranusProgradeToRetrograde": 4702520168850045874, - "LastUranusRetrogradeToPrograde": 4702520500137595017, + "LastUranusProgradeToRetrograde": 4702520168850395666, + "LastUranusRetrogradeToPrograde": 4702520500137672372, "LastUranusWesternQuadrature": 4702520137987392956, "NextUranusConjunction": 4702521519582781723, "NextUranusEasternQuadrature": 4702521321203434644, "NextUranusOpposition": 4702521129033150442, - "NextUranusProgradeToRetrograde": 4702520962113041332, - "NextUranusRetrogradeToPrograde": 4702521293269098790, + "NextUranusProgradeToRetrograde": 4702520962113424659, + "NextUranusRetrogradeToPrograde": 4702521293268505201, "NextUranusWesternQuadrature": 4702520931243643881 } }, @@ -259,14 +259,14 @@ "LastUranusConjunction": 4702520726756548793, "LastUranusEasternQuadrature": 4702521321203434644, "LastUranusOpposition": 4702521129033150442, - "LastUranusProgradeToRetrograde": 4702520962113041332, - "LastUranusRetrogradeToPrograde": 4702521293269098790, + "LastUranusProgradeToRetrograde": 4702520962113424659, + "LastUranusRetrogradeToPrograde": 4702521293268505201, "LastUranusWesternQuadrature": 4702520931243643881, "NextUranusConjunction": 4702521519582781723, "NextUranusEasternQuadrature": 4702522114043165120, "NextUranusOpposition": 4702521922272885065, - "NextUranusProgradeToRetrograde": 4702521755170140522, - "NextUranusRetrogradeToPrograde": 4702522086308687357, + "NextUranusProgradeToRetrograde": 4702521755170375841, + "NextUranusRetrogradeToPrograde": 4702522086308996325, "NextUranusWesternQuadrature": 4702521724487222180 } }, @@ -277,14 +277,14 @@ "LastUranusConjunction": 4702521519582781723, "LastUranusEasternQuadrature": 4702521321203434644, "LastUranusOpposition": 4702521922272885065, - "LastUranusProgradeToRetrograde": 4702521755170140522, - "LastUranusRetrogradeToPrograde": 4702521293269098790, + "LastUranusProgradeToRetrograde": 4702521755170375841, + "LastUranusRetrogradeToPrograde": 4702521293268505201, "LastUranusWesternQuadrature": 4702521724487222180, "NextUranusConjunction": 4702522312405600651, "NextUranusEasternQuadrature": 4702522114043165120, "NextUranusOpposition": 4702522715467229576, - "NextUranusProgradeToRetrograde": 4702522548400578777, - "NextUranusRetrogradeToPrograde": 4702522086308687357, + "NextUranusProgradeToRetrograde": 4702522548400912869, + "NextUranusRetrogradeToPrograde": 4702522086308996325, "NextUranusWesternQuadrature": 4702522517724088513 } }, @@ -295,14 +295,14 @@ "LastUranusConjunction": 4702522312405600651, "LastUranusEasternQuadrature": 4702522114043165120, "LastUranusOpposition": 4702521922272885065, - "LastUranusProgradeToRetrograde": 4702521755170140522, - "LastUranusRetrogradeToPrograde": 4702522086308687357, + "LastUranusProgradeToRetrograde": 4702521755170375841, + "LastUranusRetrogradeToPrograde": 4702522086308996325, "LastUranusWesternQuadrature": 4702521724487222180, "NextUranusConjunction": 4702523105223490433, "NextUranusEasternQuadrature": 4702522906829373472, "NextUranusOpposition": 4702522715467229576, - "NextUranusProgradeToRetrograde": 4702522548400578777, - "NextUranusRetrogradeToPrograde": 4702522879331314669, + "NextUranusProgradeToRetrograde": 4702522548400912869, + "NextUranusRetrogradeToPrograde": 4702522879331071787, "NextUranusWesternQuadrature": 4702522517724088513 } }, @@ -313,14 +313,14 @@ "LastUranusConjunction": 4702522312405600651, "LastUranusEasternQuadrature": 4702522906829373472, "LastUranusOpposition": 4702522715467229576, - "LastUranusProgradeToRetrograde": 4702522548400578777, - "LastUranusRetrogradeToPrograde": 4702522879331314669, + "LastUranusProgradeToRetrograde": 4702522548400912869, + "LastUranusRetrogradeToPrograde": 4702522879331071787, "LastUranusWesternQuadrature": 4702522517724088513, "NextUranusConjunction": 4702523105223490433, "NextUranusEasternQuadrature": 4702523699606380484, "NextUranusOpposition": 4702523508637158242, - "NextUranusProgradeToRetrograde": 4702523341454244089, - "NextUranusRetrogradeToPrograde": 4702523672243996474, + "NextUranusProgradeToRetrograde": 4702523341454632571, + "NextUranusRetrogradeToPrograde": 4702523672243999790, "NextUranusWesternQuadrature": 4702523310971910500 } }, @@ -331,14 +331,14 @@ "LastUranusConjunction": 4702523105223490433, "LastUranusEasternQuadrature": 4702522906829373472, "LastUranusOpposition": 4702522715467229576, - "LastUranusProgradeToRetrograde": 4702523341454244089, - "LastUranusRetrogradeToPrograde": 4702522879331314669, + "LastUranusProgradeToRetrograde": 4702523341454632571, + "LastUranusRetrogradeToPrograde": 4702522879331071787, "LastUranusWesternQuadrature": 4702523310971910500, "NextUranusConjunction": 4702523898046832016, "NextUranusEasternQuadrature": 4702523699606380484, "NextUranusOpposition": 4702523508637158242, - "NextUranusProgradeToRetrograde": 4702524134689163003, - "NextUranusRetrogradeToPrograde": 4702523672243996474, + "NextUranusProgradeToRetrograde": 4702524134689491042, + "NextUranusRetrogradeToPrograde": 4702523672243999790, "NextUranusWesternQuadrature": 4702524104190565308 } }, @@ -349,14 +349,14 @@ "LastUranusConjunction": 4702523898046832016, "LastUranusEasternQuadrature": 4702523699606380484, "LastUranusOpposition": 4702523508637158242, - "LastUranusProgradeToRetrograde": 4702523341454244089, - "LastUranusRetrogradeToPrograde": 4702523672243996474, + "LastUranusProgradeToRetrograde": 4702523341454632571, + "LastUranusRetrogradeToPrograde": 4702523672243999790, "LastUranusWesternQuadrature": 4702523310971910500, "NextUranusConjunction": 4702524690881806574, "NextUranusEasternQuadrature": 4702524492359739092, "NextUranusOpposition": 4702524301761837371, - "NextUranusProgradeToRetrograde": 4702524134689163003, - "NextUranusRetrogradeToPrograde": 4702524465219709489, + "NextUranusProgradeToRetrograde": 4702524134689491042, + "NextUranusRetrogradeToPrograde": 4702524465219817105, "NextUranusWesternQuadrature": 4702524104190565308 } }, @@ -367,14 +367,14 @@ "LastUranusConjunction": 4702523898046832016, "LastUranusEasternQuadrature": 4702524492359739092, "LastUranusOpposition": 4702524301761837371, - "LastUranusProgradeToRetrograde": 4702524134689163003, - "LastUranusRetrogradeToPrograde": 4702524465219709489, + "LastUranusProgradeToRetrograde": 4702524134689491042, + "LastUranusRetrogradeToPrograde": 4702524465219817105, "LastUranusWesternQuadrature": 4702524104190565308, "NextUranusConjunction": 4702524690881806574, "NextUranusEasternQuadrature": 4702525285100931243, "NextUranusOpposition": 4702525094886535274, - "NextUranusProgradeToRetrograde": 4702524927750509414, - "NextUranusRetrogradeToPrograde": 4702525258037854197, + "NextUranusProgradeToRetrograde": 4702524927750811102, + "NextUranusRetrogradeToPrograde": 4702525258037686130, "NextUranusWesternQuadrature": 4702524897441291044 } }, @@ -385,14 +385,14 @@ "LastUranusConjunction": 4702524690881806574, "LastUranusEasternQuadrature": 4702524492359739092, "LastUranusOpposition": 4702524301761837371, - "LastUranusProgradeToRetrograde": 4702524927750509414, - "LastUranusRetrogradeToPrograde": 4702524465219709489, + "LastUranusProgradeToRetrograde": 4702524927750811102, + "LastUranusRetrogradeToPrograde": 4702524465219817105, "LastUranusWesternQuadrature": 4702524897441291044, "NextUranusConjunction": 4702525483720073702, "NextUranusEasternQuadrature": 4702525285100931243, "NextUranusOpposition": 4702525094886535274, - "NextUranusProgradeToRetrograde": 4702525720994086458, - "NextUranusRetrogradeToPrograde": 4702525258037854197, + "NextUranusProgradeToRetrograde": 4702525720993720153, + "NextUranusRetrogradeToPrograde": 4702525258037686130, "NextUranusWesternQuadrature": 4702525690691571101 } }, @@ -403,14 +403,14 @@ "LastUranusConjunction": 4702525483720073702, "LastUranusEasternQuadrature": 4702525285100931243, "LastUranusOpposition": 4702525094886535274, - "LastUranusProgradeToRetrograde": 4702524927750509414, - "LastUranusRetrogradeToPrograde": 4702525258037854197, + "LastUranusProgradeToRetrograde": 4702524927750811102, + "LastUranusRetrogradeToPrograde": 4702525258037686130, "LastUranusWesternQuadrature": 4702524897441291044, "NextUranusConjunction": 4702526276601512048, "NextUranusEasternQuadrature": 4702526077833369554, "NextUranusOpposition": 4702525887987530318, - "NextUranusProgradeToRetrograde": 4702525720994086458, - "NextUranusRetrogradeToPrograde": 4702526050999573064, + "NextUranusProgradeToRetrograde": 4702525720993720153, + "NextUranusRetrogradeToPrograde": 4702526051000037397, "NextUranusWesternQuadrature": 4702525690691571101 } }, @@ -421,14 +421,14 @@ "LastUranusConjunction": 4702525483720073702, "LastUranusEasternQuadrature": 4702525285100931243, "LastUranusOpposition": 4702525887987530318, - "LastUranusProgradeToRetrograde": 4702525720994086458, - "LastUranusRetrogradeToPrograde": 4702525258037854197, + "LastUranusProgradeToRetrograde": 4702525720993720153, + "LastUranusRetrogradeToPrograde": 4702525258037686130, "LastUranusWesternQuadrature": 4702525690691571101, "NextUranusConjunction": 4702526276601512048, "NextUranusEasternQuadrature": 4702526077833369554, "NextUranusOpposition": 4702526681064840131, - "NextUranusProgradeToRetrograde": 4702526514089168532, - "NextUranusRetrogradeToPrograde": 4702526050999573064, + "NextUranusProgradeToRetrograde": 4702526514089655225, + "NextUranusRetrogradeToPrograde": 4702526051000037397, "NextUranusWesternQuadrature": 4702526483966372389 } }, @@ -439,14 +439,14 @@ "LastUranusConjunction": 4702526276601512048, "LastUranusEasternQuadrature": 4702526077833369554, "LastUranusOpposition": 4702525887987530318, - "LastUranusProgradeToRetrograde": 4702526514089168532, - "LastUranusRetrogradeToPrograde": 4702526050999573064, + "LastUranusProgradeToRetrograde": 4702526514089655225, + "LastUranusRetrogradeToPrograde": 4702526051000037397, "LastUranusWesternQuadrature": 4702526483966372389, "NextUranusConjunction": 4702527069503153585, "NextUranusEasternQuadrature": 4702526870558651840, "NextUranusOpposition": 4702526681064840131, - "NextUranusProgradeToRetrograde": 4702527307308187252, - "NextUranusRetrogradeToPrograde": 4702526843816871778, + "NextUranusProgradeToRetrograde": 4702527307308598451, + "NextUranusRetrogradeToPrograde": 4702526843817386513, "NextUranusWesternQuadrature": 4702527277238826275 } }, @@ -457,14 +457,14 @@ "LastUranusConjunction": 4702527069503153585, "LastUranusEasternQuadrature": 4702526870558651840, "LastUranusOpposition": 4702526681064840131, - "LastUranusProgradeToRetrograde": 4702526514089168532, - "LastUranusRetrogradeToPrograde": 4702526843816871778, + "LastUranusProgradeToRetrograde": 4702526514089655225, + "LastUranusRetrogradeToPrograde": 4702526843817386513, "LastUranusWesternQuadrature": 4702526483966372389, "NextUranusConjunction": 4702527862423495859, "NextUranusEasternQuadrature": 4702527663279905901, "NextUranusOpposition": 4702527474137724285, - "NextUranusProgradeToRetrograde": 4702527307308187252, - "NextUranusRetrogradeToPrograde": 4702527636724757090, + "NextUranusProgradeToRetrograde": 4702527307308598451, + "NextUranusRetrogradeToPrograde": 4702527636725331387, "NextUranusWesternQuadrature": 4702527277238826275 } }, @@ -475,14 +475,14 @@ "LastUranusConjunction": 4702527069503153585, "LastUranusEasternQuadrature": 4702526870558651840, "LastUranusOpposition": 4702527474137724285, - "LastUranusProgradeToRetrograde": 4702527307308187252, - "LastUranusRetrogradeToPrograde": 4702526843816871778, + "LastUranusProgradeToRetrograde": 4702527307308598451, + "LastUranusRetrogradeToPrograde": 4702526843817386513, "LastUranusWesternQuadrature": 4702527277238826275, "NextUranusConjunction": 4702527862423495859, "NextUranusEasternQuadrature": 4702527663279905901, "NextUranusOpposition": 4702528267210290395, - "NextUranusProgradeToRetrograde": 4702528100404678022, - "NextUranusRetrogradeToPrograde": 4702527636724757090, + "NextUranusProgradeToRetrograde": 4702528100403831784, + "NextUranusRetrogradeToPrograde": 4702527636725331387, "NextUranusWesternQuadrature": 4702528070534443744 } }, @@ -493,14 +493,14 @@ "LastUranusConjunction": 4702527862423495859, "LastUranusEasternQuadrature": 4702527663279905901, "LastUranusOpposition": 4702527474137724285, - "LastUranusProgradeToRetrograde": 4702527307308187252, - "LastUranusRetrogradeToPrograde": 4702527636724757090, + "LastUranusProgradeToRetrograde": 4702527307308598451, + "LastUranusRetrogradeToPrograde": 4702527636725331387, "LastUranusWesternQuadrature": 4702528070534443744, "NextUranusConjunction": 4702528655375811311, "NextUranusEasternQuadrature": 4702528456008457285, "NextUranusOpposition": 4702528267210290395, - "NextUranusProgradeToRetrograde": 4702528100404678022, - "NextUranusRetrogradeToPrograde": 4702528429615231858, + "NextUranusProgradeToRetrograde": 4702528100403831784, + "NextUranusRetrogradeToPrograde": 4702528429614637854, "NextUranusWesternQuadrature": 4702528863845507661 } }, @@ -511,14 +511,14 @@ "LastUranusConjunction": 4702527862423495859, "LastUranusEasternQuadrature": 4702528456008457285, "LastUranusOpposition": 4702528267210290395, - "LastUranusProgradeToRetrograde": 4702528100404678022, - "LastUranusRetrogradeToPrograde": 4702528429615231858, + "LastUranusProgradeToRetrograde": 4702528100403831784, + "LastUranusRetrogradeToPrograde": 4702528429614637854, "LastUranusWesternQuadrature": 4702528070534443744, "NextUranusConjunction": 4702528655375811311, "NextUranusEasternQuadrature": 4702529248723796321, "NextUranusOpposition": 4702529060258042307, - "NextUranusProgradeToRetrograde": 4702528893609980459, - "NextUranusRetrogradeToPrograde": 4702529222466928756, + "NextUranusProgradeToRetrograde": 4702528893610061964, + "NextUranusRetrogradeToPrograde": 4702529222466679626, "NextUranusWesternQuadrature": 4702528863845507661 } }, @@ -529,14 +529,14 @@ "LastUranusConjunction": 4702528655375811311, "LastUranusEasternQuadrature": 4702528456008457285, "LastUranusOpposition": 4702529060258042307, - "LastUranusProgradeToRetrograde": 4702528893609980459, - "LastUranusRetrogradeToPrograde": 4702528429615231858, + "LastUranusProgradeToRetrograde": 4702528893610061964, + "LastUranusRetrogradeToPrograde": 4702528429614637854, "LastUranusWesternQuadrature": 4702528863845507661, "NextUranusConjunction": 4702529448360337911, "NextUranusEasternQuadrature": 4702529248723796321, "NextUranusOpposition": 4702529853301271155, - "NextUranusProgradeToRetrograde": 4702529686671604853, - "NextUranusRetrogradeToPrograde": 4702529222466928756, + "NextUranusProgradeToRetrograde": 4702529686671947958, + "NextUranusRetrogradeToPrograde": 4702529222466679626, "NextUranusWesternQuadrature": 4702529657180175453 } }, @@ -547,14 +547,14 @@ "LastUranusConjunction": 4702529448360337911, "LastUranusEasternQuadrature": 4702529248723796321, "LastUranusOpposition": 4702529060258042307, - "LastUranusProgradeToRetrograde": 4702528893609980459, - "LastUranusRetrogradeToPrograde": 4702529222466928756, + "LastUranusProgradeToRetrograde": 4702528893610061964, + "LastUranusRetrogradeToPrograde": 4702529222466679626, "LastUranusWesternQuadrature": 4702528863845507661, "NextUranusConjunction": 4702530241364561998, "NextUranusEasternQuadrature": 4702530041464262724, "NextUranusOpposition": 4702529853301271155, - "NextUranusProgradeToRetrograde": 4702529686671604853, - "NextUranusRetrogradeToPrograde": 4702530015380969934, + "NextUranusProgradeToRetrograde": 4702529686671947958, + "NextUranusRetrogradeToPrograde": 4702530015381389999, "NextUranusWesternQuadrature": 4702529657180175453 } }, @@ -565,14 +565,14 @@ "LastUranusConjunction": 4702529448360337911, "LastUranusEasternQuadrature": 4702530041464262724, "LastUranusOpposition": 4702529853301271155, - "LastUranusProgradeToRetrograde": 4702529686671604853, - "LastUranusRetrogradeToPrograde": 4702530015380969934, + "LastUranusProgradeToRetrograde": 4702529686671947958, + "LastUranusRetrogradeToPrograde": 4702530015381389999, "LastUranusWesternQuadrature": 4702529657180175453, "NextUranusConjunction": 4702530241364561998, "NextUranusEasternQuadrature": 4702530834206609245, "NextUranusOpposition": 4702530646327884947, - "NextUranusProgradeToRetrograde": 4702530479892652811, - "NextUranusRetrogradeToPrograde": 4702530808223845867, + "NextUranusProgradeToRetrograde": 4702530479892573676, + "NextUranusRetrogradeToPrograde": 4702530808224284036, "NextUranusWesternQuadrature": 4702530450497479979 } }, @@ -583,14 +583,14 @@ "LastUranusConjunction": 4702530241364561998, "LastUranusEasternQuadrature": 4702530041464262724, "LastUranusOpposition": 4702530646327884947, - "LastUranusProgradeToRetrograde": 4702530479892652811, - "LastUranusRetrogradeToPrograde": 4702530015380969934, + "LastUranusProgradeToRetrograde": 4702530479892573676, + "LastUranusRetrogradeToPrograde": 4702530015381389999, "LastUranusWesternQuadrature": 4702530450497479979, "NextUranusConjunction": 4702531034411101604, "NextUranusEasternQuadrature": 4702530834206609245, "NextUranusOpposition": 4702531439363483266, - "NextUranusProgradeToRetrograde": 4702531272965745084, - "NextUranusRetrogradeToPrograde": 4702530808223845867, + "NextUranusProgradeToRetrograde": 4702531272966158386, + "NextUranusRetrogradeToPrograde": 4702530808224284036, "NextUranusWesternQuadrature": 4702531243857924229 } }, @@ -601,14 +601,14 @@ "LastUranusConjunction": 4702531034411101604, "LastUranusEasternQuadrature": 4702530834206609245, "LastUranusOpposition": 4702530646327884947, - "LastUranusProgradeToRetrograde": 4702530479892652811, - "LastUranusRetrogradeToPrograde": 4702530808223845867, + "LastUranusProgradeToRetrograde": 4702530479892573676, + "LastUranusRetrogradeToPrograde": 4702530808224284036, "LastUranusWesternQuadrature": 4702530450497479979, "NextUranusConjunction": 4702531827487584053, "NextUranusEasternQuadrature": 4702531626959198636, "NextUranusOpposition": 4702531439363483266, - "NextUranusProgradeToRetrograde": 4702531272965745084, - "NextUranusRetrogradeToPrograde": 4702531601091041671, + "NextUranusProgradeToRetrograde": 4702531272966158386, + "NextUranusRetrogradeToPrograde": 4702531601091485391, "NextUranusWesternQuadrature": 4702531243857924229 } }, @@ -619,14 +619,14 @@ "LastUranusConjunction": 4702531034411101604, "LastUranusEasternQuadrature": 4702531626959198636, "LastUranusOpposition": 4702531439363483266, - "LastUranusProgradeToRetrograde": 4702531272965745084, - "LastUranusRetrogradeToPrograde": 4702531601091041671, + "LastUranusProgradeToRetrograde": 4702531272966158386, + "LastUranusRetrogradeToPrograde": 4702531601091485391, "LastUranusWesternQuadrature": 4702531243857924229, "NextUranusConjunction": 4702531827487584053, "NextUranusEasternQuadrature": 4702532419738283927, "NextUranusOpposition": 4702532232401878506, - "NextUranusProgradeToRetrograde": 4702532066266131407, - "NextUranusRetrogradeToPrograde": 4702532393964409033, + "NextUranusProgradeToRetrograde": 4702532066265456358, + "NextUranusRetrogradeToPrograde": 4702532393964801969, "NextUranusWesternQuadrature": 4702532037232246296 } }, @@ -637,14 +637,14 @@ "LastUranusConjunction": 4702531827487584053, "LastUranusEasternQuadrature": 4702531626959198636, "LastUranusOpposition": 4702531439363483266, - "LastUranusProgradeToRetrograde": 4702532066266131407, - "LastUranusRetrogradeToPrograde": 4702531601091041671, + "LastUranusProgradeToRetrograde": 4702532066265456358, + "LastUranusRetrogradeToPrograde": 4702531601091485391, "LastUranusWesternQuadrature": 4702532037232246296, "NextUranusConjunction": 4702532620629713075, "NextUranusEasternQuadrature": 4702532419738283927, "NextUranusOpposition": 4702532232401878506, - "NextUranusProgradeToRetrograde": 4702532859390252390, - "NextUranusRetrogradeToPrograde": 4702532393964409033, + "NextUranusProgradeToRetrograde": 4702532859390805309, + "NextUranusRetrogradeToPrograde": 4702532393964801969, "NextUranusWesternQuadrature": 4702532830628778624 } }, @@ -655,14 +655,14 @@ "LastUranusConjunction": 4702532620629713075, "LastUranusEasternQuadrature": 4702532419738283927, "LastUranusOpposition": 4702532232401878506, - "LastUranusProgradeToRetrograde": 4702532066266131407, - "LastUranusRetrogradeToPrograde": 4702532393964409033, + "LastUranusProgradeToRetrograde": 4702532066265456358, + "LastUranusRetrogradeToPrograde": 4702532393964801969, "LastUranusWesternQuadrature": 4702532037232246296, "NextUranusConjunction": 4702533413811757059, "NextUranusEasternQuadrature": 4702533212551407598, "NextUranusOpposition": 4702533025437428189, - "NextUranusProgradeToRetrograde": 4702532859390252390, - "NextUranusRetrogradeToPrograde": 4702533186786747057, + "NextUranusProgradeToRetrograde": 4702532859390805309, + "NextUranusRetrogradeToPrograde": 4702533186787151348, "NextUranusWesternQuadrature": 4702532830628778624 } }, @@ -673,14 +673,14 @@ "LastUranusConjunction": 4702532620629713075, "LastUranusEasternQuadrature": 4702533212551407598, "LastUranusOpposition": 4702533025437428189, - "LastUranusProgradeToRetrograde": 4702532859390252390, - "LastUranusRetrogradeToPrograde": 4702533186786747057, + "LastUranusProgradeToRetrograde": 4702532859390805309, + "LastUranusRetrogradeToPrograde": 4702533186787151348, "LastUranusWesternQuadrature": 4702532830628778624, "NextUranusConjunction": 4702533413811757059, "NextUranusEasternQuadrature": 4702534005399459427, "NextUranusOpposition": 4702533818502974160, - "NextUranusProgradeToRetrograde": 4702533652765548991, - "NextUranusRetrogradeToPrograde": 4702533979708687415, + "NextUranusProgradeToRetrograde": 4702533652765577801, + "NextUranusRetrogradeToPrograde": 4702533979709138571, "NextUranusWesternQuadrature": 4702533624049411397 } }, @@ -691,14 +691,14 @@ "LastUranusConjunction": 4702533413811757059, "LastUranusEasternQuadrature": 4702533212551407598, "LastUranusOpposition": 4702533025437428189, - "LastUranusProgradeToRetrograde": 4702533652765548991, - "LastUranusRetrogradeToPrograde": 4702533186786747057, + "LastUranusProgradeToRetrograde": 4702533652765577801, + "LastUranusRetrogradeToPrograde": 4702533186787151348, "LastUranusWesternQuadrature": 4702533624049411397, "NextUranusConjunction": 4702534207048402871, "NextUranusEasternQuadrature": 4702534005399459427, "NextUranusOpposition": 4702533818502974160, - "NextUranusProgradeToRetrograde": 4702534445995247489, - "NextUranusRetrogradeToPrograde": 4702533979708687415, + "NextUranusProgradeToRetrograde": 4702534445994388175, + "NextUranusRetrogradeToPrograde": 4702533979709138571, "NextUranusWesternQuadrature": 4702534417512689497 } }, @@ -709,14 +709,14 @@ "LastUranusConjunction": 4702534207048402871, "LastUranusEasternQuadrature": 4702534005399459427, "LastUranusOpposition": 4702533818502974160, - "LastUranusProgradeToRetrograde": 4702533652765548991, - "LastUranusRetrogradeToPrograde": 4702533979708687415, + "LastUranusProgradeToRetrograde": 4702533652765577801, + "LastUranusRetrogradeToPrograde": 4702533979709138571, "LastUranusWesternQuadrature": 4702533624049411397, "NextUranusConjunction": 4702535000355934048, "NextUranusEasternQuadrature": 4702534798303663573, "NextUranusOpposition": 4702534611593269158, - "NextUranusProgradeToRetrograde": 4702534445995247489, - "NextUranusRetrogradeToPrograde": 4702534772577239659, + "NextUranusProgradeToRetrograde": 4702534445994388175, + "NextUranusRetrogradeToPrograde": 4702534772577637877, "NextUranusWesternQuadrature": 4702534417512689497 } }, @@ -727,14 +727,14 @@ "LastUranusConjunction": 4702534207048402871, "LastUranusEasternQuadrature": 4702534005399459427, "LastUranusOpposition": 4702534611593269158, - "LastUranusProgradeToRetrograde": 4702534445995247489, - "LastUranusRetrogradeToPrograde": 4702533979708687415, + "LastUranusProgradeToRetrograde": 4702534445994388175, + "LastUranusRetrogradeToPrograde": 4702533979709138571, "LastUranusWesternQuadrature": 4702534417512689497, "NextUranusConjunction": 4702535000355934048, "NextUranusEasternQuadrature": 4702534798303663573, "NextUranusOpposition": 4702535404697606899, - "NextUranusProgradeToRetrograde": 4702535239430849585, - "NextUranusRetrogradeToPrograde": 4702534772577239659, + "NextUranusProgradeToRetrograde": 4702535239430151214, + "NextUranusRetrogradeToPrograde": 4702534772577637877, "NextUranusWesternQuadrature": 4702535211015188446 } }, @@ -745,14 +745,14 @@ "LastUranusConjunction": 4702535000355934048, "LastUranusEasternQuadrature": 4702534798303663573, "LastUranusOpposition": 4702534611593269158, - "LastUranusProgradeToRetrograde": 4702535239430849585, - "LastUranusRetrogradeToPrograde": 4702534772577239659, + "LastUranusProgradeToRetrograde": 4702535239430151214, + "LastUranusRetrogradeToPrograde": 4702534772577637877, "LastUranusWesternQuadrature": 4702535211015188446, "NextUranusConjunction": 4702535793751090322, "NextUranusEasternQuadrature": 4702535591252772348, "NextUranusOpposition": 4702535404697606899, - "NextUranusProgradeToRetrograde": 4702536032806214121, - "NextUranusRetrogradeToPrograde": 4702535565552676910, + "NextUranusProgradeToRetrograde": 4702536032805528868, + "NextUranusRetrogradeToPrograde": 4702535565552004426, "NextUranusWesternQuadrature": 4702536004567474527 } }, @@ -763,14 +763,14 @@ "LastUranusConjunction": 4702535000355934048, "LastUranusEasternQuadrature": 4702535591252772348, "LastUranusOpposition": 4702535404697606899, - "LastUranusProgradeToRetrograde": 4702535239430849585, - "LastUranusRetrogradeToPrograde": 4702535565552676910, + "LastUranusProgradeToRetrograde": 4702535239430151214, + "LastUranusRetrogradeToPrograde": 4702535565552004426, "LastUranusWesternQuadrature": 4702535211015188446, "NextUranusConjunction": 4702535793751090322, "NextUranusEasternQuadrature": 4702536384276337982, "NextUranusOpposition": 4702536197841646735, - "NextUranusProgradeToRetrograde": 4702536032806214121, - "NextUranusRetrogradeToPrograde": 4702536358537619669, + "NextUranusProgradeToRetrograde": 4702536032805528868, + "NextUranusRetrogradeToPrograde": 4702536358537961443, "NextUranusWesternQuadrature": 4702536004567474527 } }, @@ -781,14 +781,14 @@ "LastUranusConjunction": 4702535793751090322, "LastUranusEasternQuadrature": 4702535591252772348, "LastUranusOpposition": 4702536197841646735, - "LastUranusProgradeToRetrograde": 4702536032806214121, - "LastUranusRetrogradeToPrograde": 4702535565552676910, + "LastUranusProgradeToRetrograde": 4702536032805528868, + "LastUranusRetrogradeToPrograde": 4702535565552004426, "LastUranusWesternQuadrature": 4702536004567474527, "NextUranusConjunction": 4702536587197939524, "NextUranusEasternQuadrature": 4702536384276337982, "NextUranusOpposition": 4702536991021460180, - "NextUranusProgradeToRetrograde": 4702536826270072212, - "NextUranusRetrogradeToPrograde": 4702536358537619669, + "NextUranusProgradeToRetrograde": 4702536826269891166, + "NextUranusRetrogradeToPrograde": 4702536358537961443, "NextUranusWesternQuadrature": 4702536798146189505 } }, @@ -799,14 +799,14 @@ "LastUranusConjunction": 4702536587197939524, "LastUranusEasternQuadrature": 4702536384276337982, "LastUranusOpposition": 4702536197841646735, - "LastUranusProgradeToRetrograde": 4702536032806214121, - "LastUranusRetrogradeToPrograde": 4702536358537619669, + "LastUranusProgradeToRetrograde": 4702536032805528868, + "LastUranusRetrogradeToPrograde": 4702536358537961443, "LastUranusWesternQuadrature": 4702536798146189505, "NextUranusConjunction": 4702537380744369635, "NextUranusEasternQuadrature": 4702537177351659620, "NextUranusOpposition": 4702536991021460180, - "NextUranusProgradeToRetrograde": 4702536826270072212, - "NextUranusRetrogradeToPrograde": 4702537151582659055, + "NextUranusProgradeToRetrograde": 4702536826269891166, + "NextUranusRetrogradeToPrograde": 4702537151582770612, "NextUranusWesternQuadrature": 4702537591792393560 } }, @@ -817,14 +817,14 @@ "LastUranusConjunction": 4702536587197939524, "LastUranusEasternQuadrature": 4702537177351659620, "LastUranusOpposition": 4702536991021460180, - "LastUranusProgradeToRetrograde": 4702536826270072212, - "LastUranusRetrogradeToPrograde": 4702537151582659055, + "LastUranusProgradeToRetrograde": 4702536826269891166, + "LastUranusRetrogradeToPrograde": 4702537151582770612, "LastUranusWesternQuadrature": 4702536798146189505, "NextUranusConjunction": 4702537380744369635, "NextUranusEasternQuadrature": 4702537970481998663, "NextUranusOpposition": 4702537784239731333, - "NextUranusProgradeToRetrograde": 4702537619788625645, - "NextUranusRetrogradeToPrograde": 4702537944718416974, + "NextUranusProgradeToRetrograde": 4702537619789107001, + "NextUranusRetrogradeToPrograde": 4702537944718798085, "NextUranusWesternQuadrature": 4702537591792393560 } }, @@ -835,14 +835,14 @@ "LastUranusConjunction": 4702537380744369635, "LastUranusEasternQuadrature": 4702537177351659620, "LastUranusOpposition": 4702537784239731333, - "LastUranusProgradeToRetrograde": 4702537619788625645, - "LastUranusRetrogradeToPrograde": 4702537151582659055, + "LastUranusProgradeToRetrograde": 4702537619789107001, + "LastUranusRetrogradeToPrograde": 4702537151582770612, "LastUranusWesternQuadrature": 4702537591792393560, "NextUranusConjunction": 4702538174355279477, "NextUranusEasternQuadrature": 4702537970481998663, "NextUranusOpposition": 4702538577502538065, - "NextUranusProgradeToRetrograde": 4702538413307021299, - "NextUranusRetrogradeToPrograde": 4702537944718416974, + "NextUranusProgradeToRetrograde": 4702538413307467657, + "NextUranusRetrogradeToPrograde": 4702537944718798085, "NextUranusWesternQuadrature": 4702538385482703613 } }, @@ -853,14 +853,14 @@ "LastUranusConjunction": 4702538174355279477, "LastUranusEasternQuadrature": 4702537970481998663, "LastUranusOpposition": 4702537784239731333, - "LastUranusProgradeToRetrograde": 4702537619788625645, - "LastUranusRetrogradeToPrograde": 4702537944718416974, + "LastUranusProgradeToRetrograde": 4702537619789107001, + "LastUranusRetrogradeToPrograde": 4702537944718798085, "LastUranusWesternQuadrature": 4702537591792393560, "NextUranusConjunction": 4702538968057495399, "NextUranusEasternQuadrature": 4702538763685570372, "NextUranusOpposition": 4702538577502538065, - "NextUranusProgradeToRetrograde": 4702538413307021299, - "NextUranusRetrogradeToPrograde": 4702538737874635655, + "NextUranusProgradeToRetrograde": 4702538413307467657, + "NextUranusRetrogradeToPrograde": 4702538737875005864, "NextUranusWesternQuadrature": 4702538385482703613 } }, @@ -871,14 +871,14 @@ "LastUranusConjunction": 4702513589956640447, "LastUranusEasternQuadrature": 4702514183194600153, "LastUranusOpposition": 4702513987644422414, - "LastUranusProgradeToRetrograde": 4702513822177210455, - "LastUranusRetrogradeToPrograde": 4702514152993315250, + "LastUranusProgradeToRetrograde": 4702513822177536197, + "LastUranusRetrogradeToPrograde": 4702514152992619221, "LastUranusWesternQuadrature": 4702513791016598431, "NextUranusConjunction": 4702514383079120109, "NextUranusEasternQuadrature": 4702514976566649311, "NextUranusOpposition": 4702514781340031902, - "NextUranusProgradeToRetrograde": 4702514615747649496, - "NextUranusRetrogradeToPrograde": 4702514946641993231, + "NextUranusProgradeToRetrograde": 4702514615747482579, + "NextUranusRetrogradeToPrograde": 4702514946642435022, "NextUranusWesternQuadrature": 4702514584468647382 } }, @@ -889,14 +889,14 @@ "LastUranusConjunction": 4702514383079120109, "LastUranusEasternQuadrature": 4702514183194600153, "LastUranusOpposition": 4702513987644422414, - "LastUranusProgradeToRetrograde": 4702514615747649496, - "LastUranusRetrogradeToPrograde": 4702514152993315250, + "LastUranusProgradeToRetrograde": 4702514615747482579, + "LastUranusRetrogradeToPrograde": 4702514152992619221, "LastUranusWesternQuadrature": 4702514584468647382, "NextUranusConjunction": 4702515176160970118, "NextUranusEasternQuadrature": 4702514976566649311, "NextUranusOpposition": 4702514781340031902, - "NextUranusProgradeToRetrograde": 4702515409074191842, - "NextUranusRetrogradeToPrograde": 4702514946641993231, + "NextUranusProgradeToRetrograde": 4702515409073953085, + "NextUranusRetrogradeToPrograde": 4702514946642435022, "NextUranusWesternQuadrature": 4702515377892878280 } }, @@ -907,14 +907,14 @@ "LastUranusConjunction": 4702514383079120109, "LastUranusEasternQuadrature": 4702514976566649311, "LastUranusOpposition": 4702514781340031902, - "LastUranusProgradeToRetrograde": 4702514615747649496, - "LastUranusRetrogradeToPrograde": 4702514946641993231, + "LastUranusProgradeToRetrograde": 4702514615747482579, + "LastUranusRetrogradeToPrograde": 4702514946642435022, "LastUranusWesternQuadrature": 4702514584468647382, "NextUranusConjunction": 4702515176160970118, "NextUranusEasternQuadrature": 4702515769884222212, "NextUranusOpposition": 4702515574994193141, - "NextUranusProgradeToRetrograde": 4702515409074191842, - "NextUranusRetrogradeToPrograde": 4702515740112175852, + "NextUranusProgradeToRetrograde": 4702515409073953085, + "NextUranusRetrogradeToPrograde": 4702515740112576076, "NextUranusWesternQuadrature": 4702515377892878280 } }, @@ -925,14 +925,14 @@ "LastUranusConjunction": 4702515176160970118, "LastUranusEasternQuadrature": 4702514976566649311, "LastUranusOpposition": 4702514781340031902, - "LastUranusProgradeToRetrograde": 4702515409074191842, - "LastUranusRetrogradeToPrograde": 4702514946641993231, + "LastUranusProgradeToRetrograde": 4702515409073953085, + "LastUranusRetrogradeToPrograde": 4702514946642435022, "LastUranusWesternQuadrature": 4702515377892878280, "NextUranusConjunction": 4702515969207149862, "NextUranusEasternQuadrature": 4702515769884222212, "NextUranusOpposition": 4702515574994193141, - "NextUranusProgradeToRetrograde": 4702516202605290795, - "NextUranusRetrogradeToPrograde": 4702515740112175852, + "NextUranusProgradeToRetrograde": 4702516202605532836, + "NextUranusRetrogradeToPrograde": 4702515740112576076, "NextUranusWesternQuadrature": 4702516171294668789 } }, @@ -943,14 +943,14 @@ "LastUranusConjunction": 4702515176160970118, "LastUranusEasternQuadrature": 4702514976566649311, "LastUranusOpposition": 4702515574994193141, - "LastUranusProgradeToRetrograde": 4702515409074191842, - "LastUranusRetrogradeToPrograde": 4702515740112175852, + "LastUranusProgradeToRetrograde": 4702515409073953085, + "LastUranusRetrogradeToPrograde": 4702515740112576076, "LastUranusWesternQuadrature": 4702515377892878280, "NextUranusConjunction": 4702515969207149862, "NextUranusEasternQuadrature": 4702515769884222212, "NextUranusOpposition": 4702516368591092528, - "NextUranusProgradeToRetrograde": 4702516202605290795, - "NextUranusRetrogradeToPrograde": 4702516533649578061, + "NextUranusProgradeToRetrograde": 4702516202605532836, + "NextUranusRetrogradeToPrograde": 4702516533649012747, "NextUranusWesternQuadrature": 4702516171294668789 } }, @@ -961,14 +961,14 @@ "LastUranusConjunction": 4702515969207149862, "LastUranusEasternQuadrature": 4702515769884222212, "LastUranusOpposition": 4702515574994193141, - "LastUranusProgradeToRetrograde": 4702515409074191842, - "LastUranusRetrogradeToPrograde": 4702515740112175852, + "LastUranusProgradeToRetrograde": 4702515409073953085, + "LastUranusRetrogradeToPrograde": 4702515740112576076, "LastUranusWesternQuadrature": 4702515377892878280, "NextUranusConjunction": 4702516762213921313, "NextUranusEasternQuadrature": 4702516563112656982, "NextUranusOpposition": 4702516368591092528, - "NextUranusProgradeToRetrograde": 4702516202605290795, - "NextUranusRetrogradeToPrograde": 4702516533649578061, + "NextUranusProgradeToRetrograde": 4702516202605532836, + "NextUranusRetrogradeToPrograde": 4702516533649012747, "NextUranusWesternQuadrature": 4702516171294668789 } }, @@ -979,14 +979,14 @@ "LastUranusConjunction": 4702515969207149862, "LastUranusEasternQuadrature": 4702515769884222212, "LastUranusOpposition": 4702516368591092528, - "LastUranusProgradeToRetrograde": 4702516202605290795, - "LastUranusRetrogradeToPrograde": 4702515740112175852, + "LastUranusProgradeToRetrograde": 4702516202605532836, + "LastUranusRetrogradeToPrograde": 4702515740112576076, "LastUranusWesternQuadrature": 4702516171294668789, "NextUranusConjunction": 4702516762213921313, "NextUranusEasternQuadrature": 4702516563112656982, "NextUranusOpposition": 4702517162146445112, - "NextUranusProgradeToRetrograde": 4702516995840730434, - "NextUranusRetrogradeToPrograde": 4702516533649578061, + "NextUranusProgradeToRetrograde": 4702516995841177671, + "NextUranusRetrogradeToPrograde": 4702516533649012747, "NextUranusWesternQuadrature": 4702516964690352189 } }, @@ -997,14 +997,14 @@ "LastUranusConjunction": 4702516762213921313, "LastUranusEasternQuadrature": 4702516563112656982, "LastUranusOpposition": 4702516368591092528, - "LastUranusProgradeToRetrograde": 4702516202605290795, - "LastUranusRetrogradeToPrograde": 4702516533649578061, + "LastUranusProgradeToRetrograde": 4702516202605532836, + "LastUranusRetrogradeToPrograde": 4702516533649012747, "LastUranusWesternQuadrature": 4702516171294668789, "NextUranusConjunction": 4702517555194326276, "NextUranusEasternQuadrature": 4702517356287685296, "NextUranusOpposition": 4702517162146445112, - "NextUranusProgradeToRetrograde": 4702516995840730434, - "NextUranusRetrogradeToPrograde": 4702517327008871959, + "NextUranusProgradeToRetrograde": 4702516995841177671, + "NextUranusRetrogradeToPrograde": 4702517327009385295, "NextUranusWesternQuadrature": 4702516964690352189 } }, @@ -1015,14 +1015,14 @@ "LastUranusConjunction": 4702516762213921313, "LastUranusEasternQuadrature": 4702516563112656982, "LastUranusOpposition": 4702517162146445112, - "LastUranusProgradeToRetrograde": 4702516995840730434, - "LastUranusRetrogradeToPrograde": 4702516533648531210, + "LastUranusProgradeToRetrograde": 4702516995841177671, + "LastUranusRetrogradeToPrograde": 4702516533649012721, "LastUranusWesternQuadrature": 4702516964690352189, "NextUranusConjunction": 4702517555194326276, "NextUranusEasternQuadrature": 4702517356287685296, "NextUranusOpposition": 4702517955616696497, - "NextUranusProgradeToRetrograde": 4702517789293484285, - "NextUranusRetrogradeToPrograde": 4702517327008871959, + "NextUranusProgradeToRetrograde": 4702517789293878909, + "NextUranusRetrogradeToPrograde": 4702517327009385295, "NextUranusWesternQuadrature": 4702517758037284699 } }, @@ -1033,14 +1033,14 @@ "LastUranusConjunction": 4702516762213921313, "LastUranusEasternQuadrature": 4702517356287685296, "LastUranusOpposition": 4702517162146445112, - "LastUranusProgradeToRetrograde": 4702516995840730434, - "LastUranusRetrogradeToPrograde": 4702517327008871959, + "LastUranusProgradeToRetrograde": 4702516995841177671, + "LastUranusRetrogradeToPrograde": 4702517327009385295, "LastUranusWesternQuadrature": 4702516964690352189, "NextUranusConjunction": 4702517555194326276, "NextUranusEasternQuadrature": 4702518149384676546, "NextUranusOpposition": 4702517955616696497, - "NextUranusProgradeToRetrograde": 4702517789293484285, - "NextUranusRetrogradeToPrograde": 4702518120429407426, + "NextUranusProgradeToRetrograde": 4702517789293878909, + "NextUranusRetrogradeToPrograde": 4702518120429892675, "NextUranusWesternQuadrature": 4702517758037284699 } }, @@ -1051,14 +1051,14 @@ "LastUranusConjunction": 4702517555194326276, "LastUranusEasternQuadrature": 4702517356287685296, "LastUranusOpposition": 4702517162146445112, - "LastUranusProgradeToRetrograde": 4702517789293484285, - "LastUranusRetrogradeToPrograde": 4702517327008871959, + "LastUranusProgradeToRetrograde": 4702517789293878909, + "LastUranusRetrogradeToPrograde": 4702517327009385295, "LastUranusWesternQuadrature": 4702517758037284699, "NextUranusConjunction": 4702518348138534656, "NextUranusEasternQuadrature": 4702518149384676546, "NextUranusOpposition": 4702517955616696497, - "NextUranusProgradeToRetrograde": 4702518582423440752, - "NextUranusRetrogradeToPrograde": 4702518120429407426, + "NextUranusProgradeToRetrograde": 4702518582422765156, + "NextUranusRetrogradeToPrograde": 4702518120429892675, "NextUranusWesternQuadrature": 4702518551382307450 } }, @@ -1069,14 +1069,14 @@ "LastUranusConjunction": 4702517555194326276, "LastUranusEasternQuadrature": 4702518149384676546, "LastUranusOpposition": 4702517955616696497, - "LastUranusProgradeToRetrograde": 4702517789293484285, - "LastUranusRetrogradeToPrograde": 4702518120429407426, + "LastUranusProgradeToRetrograde": 4702517789293878909, + "LastUranusRetrogradeToPrograde": 4702518120429892675, "LastUranusWesternQuadrature": 4702517758037284699, "NextUranusConjunction": 4702518348138534656, "NextUranusEasternQuadrature": 4702518942431221512, "NextUranusOpposition": 4702518749060655162, - "NextUranusProgradeToRetrograde": 4702518582423440752, - "NextUranusRetrogradeToPrograde": 4702518913695938780, + "NextUranusProgradeToRetrograde": 4702518582422765156, + "NextUranusRetrogradeToPrograde": 4702518913695224150, "NextUranusWesternQuadrature": 4702518551382307450 } }, @@ -1087,14 +1087,14 @@ "LastUranusConjunction": 4702518348138534656, "LastUranusEasternQuadrature": 4702518149384676546, "LastUranusOpposition": 4702517955616696497, - "LastUranusProgradeToRetrograde": 4702517789293484285, - "LastUranusRetrogradeToPrograde": 4702518120429628622, + "LastUranusProgradeToRetrograde": 4702517789293878909, + "LastUranusRetrogradeToPrograde": 4702518120429892647, "LastUranusWesternQuadrature": 4702518551382307450, "NextUranusConjunction": 4702519141030498613, "NextUranusEasternQuadrature": 4702518942431221512, "NextUranusOpposition": 4702518749060655162, - "NextUranusProgradeToRetrograde": 4702518582423440752, - "NextUranusRetrogradeToPrograde": 4702518913695938780, + "NextUranusProgradeToRetrograde": 4702518582422765156, + "NextUranusRetrogradeToPrograde": 4702518913695224150, "NextUranusWesternQuadrature": 4702519344686169501 } }, @@ -1105,14 +1105,14 @@ "LastUranusConjunction": 4702518348138534656, "LastUranusEasternQuadrature": 4702518149384676546, "LastUranusOpposition": 4702518749060655162, - "LastUranusProgradeToRetrograde": 4702518582423440752, - "LastUranusRetrogradeToPrograde": 4702518913695938780, + "LastUranusProgradeToRetrograde": 4702518582422765156, + "LastUranusRetrogradeToPrograde": 4702518913695224150, "LastUranusWesternQuadrature": 4702518551382307450, "NextUranusConjunction": 4702519141030498613, "NextUranusEasternQuadrature": 4702518942431221512, "NextUranusOpposition": 4702519542439443113, - "NextUranusProgradeToRetrograde": 4702519375781560774, - "NextUranusRetrogradeToPrograde": 4702519706986927387, + "NextUranusProgradeToRetrograde": 4702519375780795835, + "NextUranusRetrogradeToPrograde": 4702519706986315348, "NextUranusWesternQuadrature": 4702519344686169501 } }, @@ -1123,14 +1123,14 @@ "LastUranusConjunction": 4702519141030498613, "LastUranusEasternQuadrature": 4702518942431221512, "LastUranusOpposition": 4702518749060655162, - "LastUranusProgradeToRetrograde": 4702518582423440752, - "LastUranusRetrogradeToPrograde": 4702518913695938780, + "LastUranusProgradeToRetrograde": 4702518582422765156, + "LastUranusRetrogradeToPrograde": 4702518913695224150, "LastUranusWesternQuadrature": 4702518551382307450, "NextUranusConjunction": 4702519933907324656, "NextUranusEasternQuadrature": 4702519735411240615, "NextUranusOpposition": 4702519542439443113, - "NextUranusProgradeToRetrograde": 4702519375781560774, - "NextUranusRetrogradeToPrograde": 4702519706986927387, + "NextUranusProgradeToRetrograde": 4702519375780795835, + "NextUranusRetrogradeToPrograde": 4702519706986315348, "NextUranusWesternQuadrature": 4702519344686169501 } }, @@ -1141,14 +1141,14 @@ "LastUranusConjunction": 4702519141030498613, "LastUranusEasternQuadrature": 4702518942431221512, "LastUranusOpposition": 4702519542439443113, - "LastUranusProgradeToRetrograde": 4702519375781560774, - "LastUranusRetrogradeToPrograde": 4702518913695938780, + "LastUranusProgradeToRetrograde": 4702519375780795835, + "LastUranusRetrogradeToPrograde": 4702518913695224150, "LastUranusWesternQuadrature": 4702519344686169501, "NextUranusConjunction": 4702519933907324656, "NextUranusEasternQuadrature": 4702519735411240615, "NextUranusOpposition": 4702520335763343962, - "NextUranusProgradeToRetrograde": 4702520168850045874, - "NextUranusRetrogradeToPrograde": 4702519706986927387, + "NextUranusProgradeToRetrograde": 4702520168850395666, + "NextUranusRetrogradeToPrograde": 4702519706986315348, "NextUranusWesternQuadrature": 4702520137987392956 } }, @@ -1159,14 +1159,14 @@ "LastUranusConjunction": 4702519933907324656, "LastUranusEasternQuadrature": 4702519735411240615, "LastUranusOpposition": 4702519542439443113, - "LastUranusProgradeToRetrograde": 4702519375781560774, - "LastUranusRetrogradeToPrograde": 4702519706986927387, + "LastUranusProgradeToRetrograde": 4702519375780795835, + "LastUranusRetrogradeToPrograde": 4702519706986315348, "LastUranusWesternQuadrature": 4702519344686169501, "NextUranusConjunction": 4702520726756548793, "NextUranusEasternQuadrature": 4702520528331241073, "NextUranusOpposition": 4702520335763343962, - "NextUranusProgradeToRetrograde": 4702520168850045874, - "NextUranusRetrogradeToPrograde": 4702520500137595017, + "NextUranusProgradeToRetrograde": 4702520168850395666, + "NextUranusRetrogradeToPrograde": 4702520500137672372, "NextUranusWesternQuadrature": 4702520137987392956 } }, @@ -1177,14 +1177,14 @@ "LastUranusConjunction": 4702519933907324656, "LastUranusEasternQuadrature": 4702519735411240615, "LastUranusOpposition": 4702519542439443113, - "LastUranusProgradeToRetrograde": 4702520168850045874, - "LastUranusRetrogradeToPrograde": 4702519706986927387, + "LastUranusProgradeToRetrograde": 4702520168850395666, + "LastUranusRetrogradeToPrograde": 4702519706986315348, "LastUranusWesternQuadrature": 4702520137987392956, "NextUranusConjunction": 4702520726756548793, "NextUranusEasternQuadrature": 4702520528331241073, "NextUranusOpposition": 4702520335763343962, - "NextUranusProgradeToRetrograde": 4702520962113041332, - "NextUranusRetrogradeToPrograde": 4702520500137595017, + "NextUranusProgradeToRetrograde": 4702520962113424659, + "NextUranusRetrogradeToPrograde": 4702520500137672372, "NextUranusWesternQuadrature": 4702520931243643881 } }, @@ -1195,14 +1195,14 @@ "LastUranusConjunction": 4702519933907324656, "LastUranusEasternQuadrature": 4702520528331241073, "LastUranusOpposition": 4702520335763343962, - "LastUranusProgradeToRetrograde": 4702520168850045874, - "LastUranusRetrogradeToPrograde": 4702520500137595017, + "LastUranusProgradeToRetrograde": 4702520168850395666, + "LastUranusRetrogradeToPrograde": 4702520500137672372, "LastUranusWesternQuadrature": 4702520137987392956, "NextUranusConjunction": 4702520726756548793, "NextUranusEasternQuadrature": 4702521321203434644, "NextUranusOpposition": 4702521129033150442, - "NextUranusProgradeToRetrograde": 4702520962113041332, - "NextUranusRetrogradeToPrograde": 4702521293269098790, + "NextUranusProgradeToRetrograde": 4702520962113424659, + "NextUranusRetrogradeToPrograde": 4702521293268505201, "NextUranusWesternQuadrature": 4702520931243643881 } }, @@ -1213,14 +1213,14 @@ "LastUranusConjunction": 4702520726756548793, "LastUranusEasternQuadrature": 4702520528331241073, "LastUranusOpposition": 4702520335763343962, - "LastUranusProgradeToRetrograde": 4702520962113041332, - "LastUranusRetrogradeToPrograde": 4702520500137595017, + "LastUranusProgradeToRetrograde": 4702520962113424659, + "LastUranusRetrogradeToPrograde": 4702520500137672372, "LastUranusWesternQuadrature": 4702520931243643881, "NextUranusConjunction": 4702521519582781723, "NextUranusEasternQuadrature": 4702521321203434644, "NextUranusOpposition": 4702521129033150442, - "NextUranusProgradeToRetrograde": 4702521755170140522, - "NextUranusRetrogradeToPrograde": 4702521293269098790, + "NextUranusProgradeToRetrograde": 4702521755170375841, + "NextUranusRetrogradeToPrograde": 4702521293268505201, "NextUranusWesternQuadrature": 4702521724487222180 } }, @@ -1231,14 +1231,14 @@ "LastUranusConjunction": 4702520726756548793, "LastUranusEasternQuadrature": 4702521321203434644, "LastUranusOpposition": 4702521129033150442, - "LastUranusProgradeToRetrograde": 4702520962113041332, - "LastUranusRetrogradeToPrograde": 4702521293269098790, + "LastUranusProgradeToRetrograde": 4702520962113424659, + "LastUranusRetrogradeToPrograde": 4702521293268505201, "LastUranusWesternQuadrature": 4702520931243643881, "NextUranusConjunction": 4702521519582781723, "NextUranusEasternQuadrature": 4702522114043165120, "NextUranusOpposition": 4702521922272885065, - "NextUranusProgradeToRetrograde": 4702521755170140522, - "NextUranusRetrogradeToPrograde": 4702522086308687357, + "NextUranusProgradeToRetrograde": 4702521755170375841, + "NextUranusRetrogradeToPrograde": 4702522086308996325, "NextUranusWesternQuadrature": 4702521724487222180 } }, @@ -1249,14 +1249,14 @@ "LastUranusConjunction": 4702521519582781723, "LastUranusEasternQuadrature": 4702521321203434644, "LastUranusOpposition": 4702521129033150442, - "LastUranusProgradeToRetrograde": 4702520962113041332, - "LastUranusRetrogradeToPrograde": 4702521293269098790, + "LastUranusProgradeToRetrograde": 4702520962113424659, + "LastUranusRetrogradeToPrograde": 4702521293268505201, "LastUranusWesternQuadrature": 4702520931243643881, "NextUranusConjunction": 4702522312405600651, "NextUranusEasternQuadrature": 4702522114043165120, "NextUranusOpposition": 4702521922272885065, - "NextUranusProgradeToRetrograde": 4702521755170140522, - "NextUranusRetrogradeToPrograde": 4702522086308687357, + "NextUranusProgradeToRetrograde": 4702521755170375841, + "NextUranusRetrogradeToPrograde": 4702522086308996325, "NextUranusWesternQuadrature": 4702521724487222180 } }, @@ -1267,14 +1267,14 @@ "LastUranusConjunction": 4702521519582781723, "LastUranusEasternQuadrature": 4702521321203434644, "LastUranusOpposition": 4702521922272885065, - "LastUranusProgradeToRetrograde": 4702521755170140522, - "LastUranusRetrogradeToPrograde": 4702521293269098790, + "LastUranusProgradeToRetrograde": 4702521755170375841, + "LastUranusRetrogradeToPrograde": 4702521293268505201, "LastUranusWesternQuadrature": 4702521724487222180, "NextUranusConjunction": 4702522312405600651, "NextUranusEasternQuadrature": 4702522114043165120, "NextUranusOpposition": 4702522715467229576, - "NextUranusProgradeToRetrograde": 4702522548400578777, - "NextUranusRetrogradeToPrograde": 4702522086308687357, + "NextUranusProgradeToRetrograde": 4702522548400912869, + "NextUranusRetrogradeToPrograde": 4702522086308996325, "NextUranusWesternQuadrature": 4702522517724088513 } }, @@ -1285,14 +1285,14 @@ "LastUranusConjunction": 4702522312405600651, "LastUranusEasternQuadrature": 4702522114043165120, "LastUranusOpposition": 4702521922272885065, - "LastUranusProgradeToRetrograde": 4702521755170140522, - "LastUranusRetrogradeToPrograde": 4702522086308687357, + "LastUranusProgradeToRetrograde": 4702521755170375841, + "LastUranusRetrogradeToPrograde": 4702522086308996325, "LastUranusWesternQuadrature": 4702521724487222180, "NextUranusConjunction": 4702523105223490433, "NextUranusEasternQuadrature": 4702522906829373472, "NextUranusOpposition": 4702522715467229576, - "NextUranusProgradeToRetrograde": 4702522548400578777, - "NextUranusRetrogradeToPrograde": 4702522879331314669, + "NextUranusProgradeToRetrograde": 4702522548400912869, + "NextUranusRetrogradeToPrograde": 4702522879331071787, "NextUranusWesternQuadrature": 4702522517724088513 } }, @@ -1303,14 +1303,14 @@ "LastUranusConjunction": 4702522312405600651, "LastUranusEasternQuadrature": 4702522114043165120, "LastUranusOpposition": 4702522715467229576, - "LastUranusProgradeToRetrograde": 4702522548400578777, - "LastUranusRetrogradeToPrograde": 4702522086308687357, + "LastUranusProgradeToRetrograde": 4702522548400912869, + "LastUranusRetrogradeToPrograde": 4702522086308996325, "LastUranusWesternQuadrature": 4702522517724088513, "NextUranusConjunction": 4702523105223490433, "NextUranusEasternQuadrature": 4702522906829373472, "NextUranusOpposition": 4702523508637158242, - "NextUranusProgradeToRetrograde": 4702523341454244089, - "NextUranusRetrogradeToPrograde": 4702522879331314669, + "NextUranusProgradeToRetrograde": 4702523341454632571, + "NextUranusRetrogradeToPrograde": 4702522879331071787, "NextUranusWesternQuadrature": 4702523310971910500 } }, @@ -1321,14 +1321,14 @@ "LastUranusConjunction": 4702523105223490433, "LastUranusEasternQuadrature": 4702522906829373472, "LastUranusOpposition": 4702522715467229576, - "LastUranusProgradeToRetrograde": 4702522548400578777, - "LastUranusRetrogradeToPrograde": 4702522879331314669, + "LastUranusProgradeToRetrograde": 4702522548400912869, + "LastUranusRetrogradeToPrograde": 4702522879331071787, "LastUranusWesternQuadrature": 4702522517724088513, "NextUranusConjunction": 4702523898046832016, "NextUranusEasternQuadrature": 4702523699606380484, "NextUranusOpposition": 4702523508637158242, - "NextUranusProgradeToRetrograde": 4702523341454244089, - "NextUranusRetrogradeToPrograde": 4702523672243996474, + "NextUranusProgradeToRetrograde": 4702523341454632571, + "NextUranusRetrogradeToPrograde": 4702523672243999790, "NextUranusWesternQuadrature": 4702523310971910500 } }, @@ -1339,14 +1339,14 @@ "LastUranusConjunction": 4702523105223490433, "LastUranusEasternQuadrature": 4702522906829373472, "LastUranusOpposition": 4702522715467229576, - "LastUranusProgradeToRetrograde": 4702523341454244089, - "LastUranusRetrogradeToPrograde": 4702522879331314669, + "LastUranusProgradeToRetrograde": 4702523341454632571, + "LastUranusRetrogradeToPrograde": 4702522879331071787, "LastUranusWesternQuadrature": 4702523310971910500, "NextUranusConjunction": 4702523898046832016, "NextUranusEasternQuadrature": 4702523699606380484, "NextUranusOpposition": 4702523508637158242, - "NextUranusProgradeToRetrograde": 4702524134689163003, - "NextUranusRetrogradeToPrograde": 4702523672243996474, + "NextUranusProgradeToRetrograde": 4702524134689491042, + "NextUranusRetrogradeToPrograde": 4702523672243999790, "NextUranusWesternQuadrature": 4702524104190565308 } }, @@ -1357,14 +1357,14 @@ "LastUranusConjunction": 4702523105223490433, "LastUranusEasternQuadrature": 4702523699606380484, "LastUranusOpposition": 4702523508637158242, - "LastUranusProgradeToRetrograde": 4702523341454244089, - "LastUranusRetrogradeToPrograde": 4702523672243996474, + "LastUranusProgradeToRetrograde": 4702523341454632571, + "LastUranusRetrogradeToPrograde": 4702523672243999790, "LastUranusWesternQuadrature": 4702523310971910500, "NextUranusConjunction": 4702523898046832016, "NextUranusEasternQuadrature": 4702524492359739092, "NextUranusOpposition": 4702524301761837371, - "NextUranusProgradeToRetrograde": 4702524134689163003, - "NextUranusRetrogradeToPrograde": 4702524465219709489, + "NextUranusProgradeToRetrograde": 4702524134689491042, + "NextUranusRetrogradeToPrograde": 4702524465219817105, "NextUranusWesternQuadrature": 4702524104190565308 } }, @@ -1375,14 +1375,14 @@ "LastUranusConjunction": 4702523898046832016, "LastUranusEasternQuadrature": 4702523699606380484, "LastUranusOpposition": 4702523508637158242, - "LastUranusProgradeToRetrograde": 4702524134689163003, - "LastUranusRetrogradeToPrograde": 4702523672243996474, + "LastUranusProgradeToRetrograde": 4702524134689491042, + "LastUranusRetrogradeToPrograde": 4702523672243999790, "LastUranusWesternQuadrature": 4702524104190565308, "NextUranusConjunction": 4702524690881806574, "NextUranusEasternQuadrature": 4702524492359739092, "NextUranusOpposition": 4702524301761837371, - "NextUranusProgradeToRetrograde": 4702524927750509414, - "NextUranusRetrogradeToPrograde": 4702524465219709489, + "NextUranusProgradeToRetrograde": 4702524927750811102, + "NextUranusRetrogradeToPrograde": 4702524465219817105, "NextUranusWesternQuadrature": 4702524897441291044 } }, @@ -1393,14 +1393,14 @@ "LastUranusConjunction": 4702523898046832016, "LastUranusEasternQuadrature": 4702524492359739092, "LastUranusOpposition": 4702524301761837371, - "LastUranusProgradeToRetrograde": 4702524134689163003, - "LastUranusRetrogradeToPrograde": 4702524465219709489, + "LastUranusProgradeToRetrograde": 4702524134689491042, + "LastUranusRetrogradeToPrograde": 4702524465219817105, "LastUranusWesternQuadrature": 4702524104190565308, "NextUranusConjunction": 4702524690881806574, "NextUranusEasternQuadrature": 4702525285100931243, "NextUranusOpposition": 4702525094886535274, - "NextUranusProgradeToRetrograde": 4702524927750509414, - "NextUranusRetrogradeToPrograde": 4702525258037854197, + "NextUranusProgradeToRetrograde": 4702524927750811102, + "NextUranusRetrogradeToPrograde": 4702525258037686130, "NextUranusWesternQuadrature": 4702524897441291044 } }, @@ -1411,14 +1411,14 @@ "LastUranusConjunction": 4702524690881806574, "LastUranusEasternQuadrature": 4702524492359739092, "LastUranusOpposition": 4702524301761837371, - "LastUranusProgradeToRetrograde": 4702524134689163003, - "LastUranusRetrogradeToPrograde": 4702524465219709489, + "LastUranusProgradeToRetrograde": 4702524134689491042, + "LastUranusRetrogradeToPrograde": 4702524465219817105, "LastUranusWesternQuadrature": 4702524104190565308, "NextUranusConjunction": 4702525483720073702, "NextUranusEasternQuadrature": 4702525285100931243, "NextUranusOpposition": 4702525094886535274, - "NextUranusProgradeToRetrograde": 4702524927750509414, - "NextUranusRetrogradeToPrograde": 4702525258037854197, + "NextUranusProgradeToRetrograde": 4702524927750811102, + "NextUranusRetrogradeToPrograde": 4702525258037686130, "NextUranusWesternQuadrature": 4702524897441291044 } }, @@ -1429,14 +1429,14 @@ "LastUranusConjunction": 4702524690881806574, "LastUranusEasternQuadrature": 4702524492359739092, "LastUranusOpposition": 4702525094886535274, - "LastUranusProgradeToRetrograde": 4702524927750509414, - "LastUranusRetrogradeToPrograde": 4702524465219709489, + "LastUranusProgradeToRetrograde": 4702524927750811102, + "LastUranusRetrogradeToPrograde": 4702524465219817105, "LastUranusWesternQuadrature": 4702524897441291044, "NextUranusConjunction": 4702525483720073702, "NextUranusEasternQuadrature": 4702525285100931243, "NextUranusOpposition": 4702525887987530318, - "NextUranusProgradeToRetrograde": 4702525720994086458, - "NextUranusRetrogradeToPrograde": 4702525258037854197, + "NextUranusProgradeToRetrograde": 4702525720993720153, + "NextUranusRetrogradeToPrograde": 4702525258037686130, "NextUranusWesternQuadrature": 4702525690691571101 } }, @@ -1447,14 +1447,14 @@ "LastUranusConjunction": 4702525483720073702, "LastUranusEasternQuadrature": 4702525285100931243, "LastUranusOpposition": 4702525094886535274, - "LastUranusProgradeToRetrograde": 4702524927750509414, - "LastUranusRetrogradeToPrograde": 4702525258037854197, + "LastUranusProgradeToRetrograde": 4702524927750811102, + "LastUranusRetrogradeToPrograde": 4702525258037686130, "LastUranusWesternQuadrature": 4702524897441291044, "NextUranusConjunction": 4702526276601512048, "NextUranusEasternQuadrature": 4702526077833369554, "NextUranusOpposition": 4702525887987530318, - "NextUranusProgradeToRetrograde": 4702525720994086458, - "NextUranusRetrogradeToPrograde": 4702526050999573064, + "NextUranusProgradeToRetrograde": 4702525720993720153, + "NextUranusRetrogradeToPrograde": 4702526051000037397, "NextUranusWesternQuadrature": 4702525690691571101 } }, @@ -1465,14 +1465,14 @@ "LastUranusConjunction": 4702525483720073702, "LastUranusEasternQuadrature": 4702525285100931243, "LastUranusOpposition": 4702525887987530318, - "LastUranusProgradeToRetrograde": 4702525720994086458, - "LastUranusRetrogradeToPrograde": 4702525258037854197, + "LastUranusProgradeToRetrograde": 4702525720993720153, + "LastUranusRetrogradeToPrograde": 4702525258037686130, "LastUranusWesternQuadrature": 4702525690691571101, "NextUranusConjunction": 4702526276601512048, "NextUranusEasternQuadrature": 4702526077833369554, "NextUranusOpposition": 4702526681064840131, - "NextUranusProgradeToRetrograde": 4702526514089168532, - "NextUranusRetrogradeToPrograde": 4702526050999573064, + "NextUranusProgradeToRetrograde": 4702526514089655225, + "NextUranusRetrogradeToPrograde": 4702526051000037397, "NextUranusWesternQuadrature": 4702526483966372389 } }, @@ -1483,14 +1483,14 @@ "LastUranusConjunction": 4702525483720073702, "LastUranusEasternQuadrature": 4702526077833369554, "LastUranusOpposition": 4702525887987530318, - "LastUranusProgradeToRetrograde": 4702525720994086458, - "LastUranusRetrogradeToPrograde": 4702526050999573064, + "LastUranusProgradeToRetrograde": 4702525720993720153, + "LastUranusRetrogradeToPrograde": 4702526051000037397, "LastUranusWesternQuadrature": 4702525690691571101, "NextUranusConjunction": 4702526276601512048, "NextUranusEasternQuadrature": 4702526870558651840, "NextUranusOpposition": 4702526681064840131, - "NextUranusProgradeToRetrograde": 4702526514089168532, - "NextUranusRetrogradeToPrograde": 4702526843816871778, + "NextUranusProgradeToRetrograde": 4702526514089655225, + "NextUranusRetrogradeToPrograde": 4702526843817386513, "NextUranusWesternQuadrature": 4702526483966372389 } }, @@ -1501,14 +1501,14 @@ "LastUranusConjunction": 4702526276601512048, "LastUranusEasternQuadrature": 4702526077833369554, "LastUranusOpposition": 4702525887987530318, - "LastUranusProgradeToRetrograde": 4702526514089168532, - "LastUranusRetrogradeToPrograde": 4702526050999573064, + "LastUranusProgradeToRetrograde": 4702526514089655225, + "LastUranusRetrogradeToPrograde": 4702526051000037397, "LastUranusWesternQuadrature": 4702526483966372389, "NextUranusConjunction": 4702527069503153585, "NextUranusEasternQuadrature": 4702526870558651840, "NextUranusOpposition": 4702526681064840131, - "NextUranusProgradeToRetrograde": 4702527307308187252, - "NextUranusRetrogradeToPrograde": 4702526843816871778, + "NextUranusProgradeToRetrograde": 4702527307308598451, + "NextUranusRetrogradeToPrograde": 4702526843817386513, "NextUranusWesternQuadrature": 4702527277238826275 } }, @@ -1519,14 +1519,14 @@ "LastUranusConjunction": 4702526276601512048, "LastUranusEasternQuadrature": 4702526870558651840, "LastUranusOpposition": 4702526681064840131, - "LastUranusProgradeToRetrograde": 4702526514089168532, - "LastUranusRetrogradeToPrograde": 4702526843816871778, + "LastUranusProgradeToRetrograde": 4702526514089655225, + "LastUranusRetrogradeToPrograde": 4702526843817386513, "LastUranusWesternQuadrature": 4702526483966372389, "NextUranusConjunction": 4702527069503153585, "NextUranusEasternQuadrature": 4702527663279905901, "NextUranusOpposition": 4702527474137724285, - "NextUranusProgradeToRetrograde": 4702527307308187252, - "NextUranusRetrogradeToPrograde": 4702527636724757090, + "NextUranusProgradeToRetrograde": 4702527307308598451, + "NextUranusRetrogradeToPrograde": 4702527636725331387, "NextUranusWesternQuadrature": 4702527277238826275 } }, @@ -1537,14 +1537,14 @@ "LastUranusConjunction": 4702527069503153585, "LastUranusEasternQuadrature": 4702526870558651840, "LastUranusOpposition": 4702526681064840131, - "LastUranusProgradeToRetrograde": 4702527307308187252, - "LastUranusRetrogradeToPrograde": 4702526843816871778, + "LastUranusProgradeToRetrograde": 4702527307308598451, + "LastUranusRetrogradeToPrograde": 4702526843817386513, "LastUranusWesternQuadrature": 4702527277238826275, "NextUranusConjunction": 4702527862423495859, "NextUranusEasternQuadrature": 4702527663279905901, "NextUranusOpposition": 4702527474137724285, - "NextUranusProgradeToRetrograde": 4702528100404678022, - "NextUranusRetrogradeToPrograde": 4702527636724757090, + "NextUranusProgradeToRetrograde": 4702528100403831784, + "NextUranusRetrogradeToPrograde": 4702527636725331387, "NextUranusWesternQuadrature": 4702528070534443744 } }, @@ -1555,14 +1555,14 @@ "LastUranusConjunction": 4702527069503153585, "LastUranusEasternQuadrature": 4702527663279905901, "LastUranusOpposition": 4702527474137724285, - "LastUranusProgradeToRetrograde": 4702527307308187252, - "LastUranusRetrogradeToPrograde": 4702527636724757090, + "LastUranusProgradeToRetrograde": 4702527307308598451, + "LastUranusRetrogradeToPrograde": 4702527636725331387, "LastUranusWesternQuadrature": 4702527277238826275, "NextUranusConjunction": 4702527862423495859, "NextUranusEasternQuadrature": 4702528456008457285, "NextUranusOpposition": 4702528267210290395, - "NextUranusProgradeToRetrograde": 4702528100404678022, - "NextUranusRetrogradeToPrograde": 4702528429615231858, + "NextUranusProgradeToRetrograde": 4702528100403831784, + "NextUranusRetrogradeToPrograde": 4702528429614637854, "NextUranusWesternQuadrature": 4702528070534443744 } }, @@ -1573,14 +1573,14 @@ "LastUranusConjunction": 4702527862423495859, "LastUranusEasternQuadrature": 4702527663279905901, "LastUranusOpposition": 4702527474137724285, - "LastUranusProgradeToRetrograde": 4702527307308187252, - "LastUranusRetrogradeToPrograde": 4702527636724757090, + "LastUranusProgradeToRetrograde": 4702527307308598451, + "LastUranusRetrogradeToPrograde": 4702527636725331387, "LastUranusWesternQuadrature": 4702527277238826275, "NextUranusConjunction": 4702528655375811311, "NextUranusEasternQuadrature": 4702528456008457285, "NextUranusOpposition": 4702528267210290395, - "NextUranusProgradeToRetrograde": 4702528100404678022, - "NextUranusRetrogradeToPrograde": 4702528429615231858, + "NextUranusProgradeToRetrograde": 4702528100403831784, + "NextUranusRetrogradeToPrograde": 4702528429614637854, "NextUranusWesternQuadrature": 4702528070534443744 } }, @@ -1591,14 +1591,14 @@ "LastUranusConjunction": 4702527862423495859, "LastUranusEasternQuadrature": 4702527663279905901, "LastUranusOpposition": 4702528267210290395, - "LastUranusProgradeToRetrograde": 4702528100404678022, - "LastUranusRetrogradeToPrograde": 4702527636724757090, + "LastUranusProgradeToRetrograde": 4702528100403831784, + "LastUranusRetrogradeToPrograde": 4702527636725331387, "LastUranusWesternQuadrature": 4702528070534443744, "NextUranusConjunction": 4702528655375811311, "NextUranusEasternQuadrature": 4702528456008457285, "NextUranusOpposition": 4702529060258042307, - "NextUranusProgradeToRetrograde": 4702528893609980459, - "NextUranusRetrogradeToPrograde": 4702528429615231858, + "NextUranusProgradeToRetrograde": 4702528893610061964, + "NextUranusRetrogradeToPrograde": 4702528429614637854, "NextUranusWesternQuadrature": 4702528863845507661 } }, @@ -1609,14 +1609,14 @@ "LastUranusConjunction": 4702528655375811311, "LastUranusEasternQuadrature": 4702528456008457285, "LastUranusOpposition": 4702528267210290395, - "LastUranusProgradeToRetrograde": 4702528100404678022, - "LastUranusRetrogradeToPrograde": 4702528429615231858, + "LastUranusProgradeToRetrograde": 4702528100403831784, + "LastUranusRetrogradeToPrograde": 4702528429614637854, "LastUranusWesternQuadrature": 4702528070534443744, "NextUranusConjunction": 4702529448360337911, "NextUranusEasternQuadrature": 4702529248723796321, "NextUranusOpposition": 4702529060258042307, - "NextUranusProgradeToRetrograde": 4702528893609980459, - "NextUranusRetrogradeToPrograde": 4702529222466928756, + "NextUranusProgradeToRetrograde": 4702528893610061964, + "NextUranusRetrogradeToPrograde": 4702529222466679626, "NextUranusWesternQuadrature": 4702528863845507661 } }, @@ -1627,14 +1627,14 @@ "LastUranusConjunction": 4702528655375811311, "LastUranusEasternQuadrature": 4702528456008457285, "LastUranusOpposition": 4702529060258042307, - "LastUranusProgradeToRetrograde": 4702528893609980459, - "LastUranusRetrogradeToPrograde": 4702528429615231858, + "LastUranusProgradeToRetrograde": 4702528893610061964, + "LastUranusRetrogradeToPrograde": 4702528429614637854, "LastUranusWesternQuadrature": 4702528863845507661, "NextUranusConjunction": 4702529448360337911, "NextUranusEasternQuadrature": 4702529248723796321, "NextUranusOpposition": 4702529853301271155, - "NextUranusProgradeToRetrograde": 4702529686671604853, - "NextUranusRetrogradeToPrograde": 4702529222466244919, + "NextUranusProgradeToRetrograde": 4702529686671947958, + "NextUranusRetrogradeToPrograde": 4702529222466679616, "NextUranusWesternQuadrature": 4702529657180175453 } }, @@ -1645,14 +1645,14 @@ "LastUranusConjunction": 4702528655375811311, "LastUranusEasternQuadrature": 4702529248723796321, "LastUranusOpposition": 4702529060258042307, - "LastUranusProgradeToRetrograde": 4702528893609980459, - "LastUranusRetrogradeToPrograde": 4702529222466928756, + "LastUranusProgradeToRetrograde": 4702528893610061964, + "LastUranusRetrogradeToPrograde": 4702529222466679626, "LastUranusWesternQuadrature": 4702528863845507661, "NextUranusConjunction": 4702529448360337911, "NextUranusEasternQuadrature": 4702530041464262724, "NextUranusOpposition": 4702529853301271155, - "NextUranusProgradeToRetrograde": 4702529686671604853, - "NextUranusRetrogradeToPrograde": 4702530015380969934, + "NextUranusProgradeToRetrograde": 4702529686671947958, + "NextUranusRetrogradeToPrograde": 4702530015381389999, "NextUranusWesternQuadrature": 4702529657180175453 } }, @@ -1663,14 +1663,14 @@ "LastUranusConjunction": 4702529448360337911, "LastUranusEasternQuadrature": 4702529248723796321, "LastUranusOpposition": 4702529060258042307, - "LastUranusProgradeToRetrograde": 4702529686671604853, - "LastUranusRetrogradeToPrograde": 4702529222466928756, + "LastUranusProgradeToRetrograde": 4702529686671947958, + "LastUranusRetrogradeToPrograde": 4702529222466679626, "LastUranusWesternQuadrature": 4702529657180175453, "NextUranusConjunction": 4702530241364561998, "NextUranusEasternQuadrature": 4702530041464262724, "NextUranusOpposition": 4702529853301271155, - "NextUranusProgradeToRetrograde": 4702530479892652811, - "NextUranusRetrogradeToPrograde": 4702530015380969934, + "NextUranusProgradeToRetrograde": 4702530479892573676, + "NextUranusRetrogradeToPrograde": 4702530015381389999, "NextUranusWesternQuadrature": 4702530450497479979 } }, @@ -1681,14 +1681,14 @@ "LastUranusConjunction": 4702529448360337911, "LastUranusEasternQuadrature": 4702530041464262724, "LastUranusOpposition": 4702529853301271155, - "LastUranusProgradeToRetrograde": 4702529686671604853, - "LastUranusRetrogradeToPrograde": 4702530015380969934, + "LastUranusProgradeToRetrograde": 4702529686671947958, + "LastUranusRetrogradeToPrograde": 4702530015381389999, "LastUranusWesternQuadrature": 4702529657180175453, "NextUranusConjunction": 4702530241364561998, "NextUranusEasternQuadrature": 4702530834206609245, "NextUranusOpposition": 4702530646327884947, - "NextUranusProgradeToRetrograde": 4702530479892652811, - "NextUranusRetrogradeToPrograde": 4702530808223845867, + "NextUranusProgradeToRetrograde": 4702530479892573676, + "NextUranusRetrogradeToPrograde": 4702530808224284036, "NextUranusWesternQuadrature": 4702530450497479979 } }, @@ -1699,14 +1699,14 @@ "LastUranusConjunction": 4702530241364561998, "LastUranusEasternQuadrature": 4702530041464262724, "LastUranusOpposition": 4702529853301271155, - "LastUranusProgradeToRetrograde": 4702529686671604853, - "LastUranusRetrogradeToPrograde": 4702530015380969934, + "LastUranusProgradeToRetrograde": 4702529686671947958, + "LastUranusRetrogradeToPrograde": 4702530015381389999, "LastUranusWesternQuadrature": 4702530450497479979, "NextUranusConjunction": 4702531034411101604, "NextUranusEasternQuadrature": 4702530834206609245, "NextUranusOpposition": 4702530646327884947, - "NextUranusProgradeToRetrograde": 4702530479892652811, - "NextUranusRetrogradeToPrograde": 4702530808223845867, + "NextUranusProgradeToRetrograde": 4702530479892573676, + "NextUranusRetrogradeToPrograde": 4702530808224284036, "NextUranusWesternQuadrature": 4702531243857924229 } }, @@ -1717,14 +1717,14 @@ "LastUranusConjunction": 4702530241364561998, "LastUranusEasternQuadrature": 4702530041464262724, "LastUranusOpposition": 4702530646327884947, - "LastUranusProgradeToRetrograde": 4702530479892652811, - "LastUranusRetrogradeToPrograde": 4702530808223845867, + "LastUranusProgradeToRetrograde": 4702530479892573676, + "LastUranusRetrogradeToPrograde": 4702530808224284036, "LastUranusWesternQuadrature": 4702530450497479979, "NextUranusConjunction": 4702531034411101604, "NextUranusEasternQuadrature": 4702530834206609245, "NextUranusOpposition": 4702531439363483266, - "NextUranusProgradeToRetrograde": 4702531272965745084, - "NextUranusRetrogradeToPrograde": 4702531601091041671, + "NextUranusProgradeToRetrograde": 4702531272966158386, + "NextUranusRetrogradeToPrograde": 4702531601091485391, "NextUranusWesternQuadrature": 4702531243857924229 } } diff --git a/basic/testdata/venus_event_baseline.json b/basic/testdata/venus_event_baseline.json index 0883239..5b8788a 100644 --- a/basic/testdata/venus_event_baseline.json +++ b/basic/testdata/venus_event_baseline.json @@ -5,18 +5,18 @@ "tt_jd_bits": 4702514429247039192, "events": { "LastVenusConjunction": 4702514392481018123, - "LastVenusGreatestElongation": 4702513918834546322, - "LastVenusGreatestElongationEast": 4702513614178864906, - "LastVenusGreatestElongationWest": 4702513918834546322, + "LastVenusGreatestElongation": 4702513918856228394, + "LastVenusGreatestElongationEast": 4702513614199327704, + "LastVenusGreatestElongationWest": 4702513918856228394, "LastVenusInferiorConjunction": 4702513768559104880, "LastVenusProgradeToRetrograde": 4702513719865285145, "LastVenusRetrograde": 4702513808332653958, "LastVenusRetrogradeToPrograde": 4702513808332653958, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515014994791160, - "NextVenusGreatestElongation": 4702514864618801785, - "NextVenusGreatestElongationEast": 4702514864618801785, - "NextVenusGreatestElongationWest": 4702515168583603037, + "NextVenusGreatestElongation": 4702514864641638812, + "NextVenusGreatestElongationEast": 4702514864641638812, + "NextVenusGreatestElongationWest": 4702515168605052864, "NextVenusInferiorConjunction": 4702515014994791160, "NextVenusProgradeToRetrograde": 4702514969848824186, "NextVenusRetrograde": 4702514969848824186, @@ -29,18 +29,18 @@ "tt_jd_bits": 4702514801225855084, "events": { "LastVenusConjunction": 4702514392481018123, - "LastVenusGreatestElongation": 4702513918834546322, - "LastVenusGreatestElongationEast": 4702513614178864906, - "LastVenusGreatestElongationWest": 4702513918834546322, + "LastVenusGreatestElongation": 4702513918856228394, + "LastVenusGreatestElongationEast": 4702513614199327704, + "LastVenusGreatestElongationWest": 4702513918856228394, "LastVenusInferiorConjunction": 4702513768559104880, "LastVenusProgradeToRetrograde": 4702513719865285145, "LastVenusRetrograde": 4702513808332653958, "LastVenusRetrogradeToPrograde": 4702513808332653958, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515014994791160, - "NextVenusGreatestElongation": 4702514864618801785, - "NextVenusGreatestElongationEast": 4702514864618801785, - "NextVenusGreatestElongationWest": 4702515168583603037, + "NextVenusGreatestElongation": 4702514864641638812, + "NextVenusGreatestElongationEast": 4702514864641638812, + "NextVenusGreatestElongationWest": 4702515168605052864, "NextVenusInferiorConjunction": 4702515014994791160, "NextVenusProgradeToRetrograde": 4702514969848824186, "NextVenusRetrograde": 4702514969848824186, @@ -53,18 +53,18 @@ "tt_jd_bits": 4702515173204646121, "events": { "LastVenusConjunction": 4702515014994791160, - "LastVenusGreatestElongation": 4702515168583603037, - "LastVenusGreatestElongationEast": 4702514864618801785, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702515168605052864, + "LastVenusGreatestElongationEast": 4702514864641638812, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702515014994791160, "LastVenusProgradeToRetrograde": 4702514969848824186, "LastVenusRetrograde": 4702515055133849711, "LastVenusRetrogradeToPrograde": 4702515055133849711, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515639990398764, - "NextVenusGreatestElongation": 4702516121067314056, - "NextVenusGreatestElongationEast": 4702516121067314056, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516121090883434, + "NextVenusGreatestElongationEast": 4702516121090883434, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702516272804138222, "NextVenusProgradeToRetrograde": 4702516226878348540, "NextVenusRetrograde": 4702516226878348540, @@ -77,18 +77,18 @@ "tt_jd_bits": 4702515545183437157, "events": { "LastVenusConjunction": 4702515014994791160, - "LastVenusGreatestElongation": 4702515168583603037, - "LastVenusGreatestElongationEast": 4702514864618801785, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702515168605052864, + "LastVenusGreatestElongationEast": 4702514864641638812, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702515014994791160, "LastVenusProgradeToRetrograde": 4702514969848824186, "LastVenusRetrograde": 4702515055133849711, "LastVenusRetrogradeToPrograde": 4702515055133849711, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515639990398764, - "NextVenusGreatestElongation": 4702516121067314056, - "NextVenusGreatestElongationEast": 4702516121067314056, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516121090883434, + "NextVenusGreatestElongationEast": 4702516121090883434, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702516272804138222, "NextVenusProgradeToRetrograde": 4702516226878348540, "NextVenusRetrograde": 4702516226878348540, @@ -101,18 +101,18 @@ "tt_jd_bits": 4702515917162228194, "events": { "LastVenusConjunction": 4702515639990398764, - "LastVenusGreatestElongation": 4702515168583603037, - "LastVenusGreatestElongationEast": 4702514864618801785, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702515168605052864, + "LastVenusGreatestElongationEast": 4702514864641638812, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702515014994791160, "LastVenusProgradeToRetrograde": 4702514969848824186, "LastVenusRetrograde": 4702515055133849711, "LastVenusRetrogradeToPrograde": 4702515055133849711, "LastVenusSuperiorConjunction": 4702515639990398764, "NextVenusConjunction": 4702516272804138222, - "NextVenusGreatestElongation": 4702516121067314056, - "NextVenusGreatestElongationEast": 4702516121067314056, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516121090883434, + "NextVenusGreatestElongationEast": 4702516121090883434, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702516272804138222, "NextVenusProgradeToRetrograde": 4702516226878348540, "NextVenusRetrograde": 4702516226878348540, @@ -125,18 +125,18 @@ "tt_jd_bits": 4702516289141044085, "events": { "LastVenusConjunction": 4702516272804138222, - "LastVenusGreatestElongation": 4702516121067314056, - "LastVenusGreatestElongationEast": 4702516121067314056, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702516121090883434, + "LastVenusGreatestElongationEast": 4702516121090883434, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702516226878348540, "LastVenusRetrograde": 4702516226878348540, "LastVenusRetrogradeToPrograde": 4702515055133849711, "LastVenusSuperiorConjunction": 4702515639990398764, "NextVenusConjunction": 4702516908230195456, - "NextVenusGreatestElongation": 4702516424103148082, - "NextVenusGreatestElongationEast": 4702517375722121075, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516424126994338, + "NextVenusGreatestElongationEast": 4702517375745234934, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702517481616189877, "NextVenusRetrograde": 4702516318547201357, @@ -149,18 +149,18 @@ "tt_jd_bits": 4702516661119835121, "events": { "LastVenusConjunction": 4702516272804138222, - "LastVenusGreatestElongation": 4702516424103148082, - "LastVenusGreatestElongationEast": 4702516121067314056, - "LastVenusGreatestElongationWest": 4702516424103148082, + "LastVenusGreatestElongation": 4702516424126994338, + "LastVenusGreatestElongationEast": 4702516121090883434, + "LastVenusGreatestElongationWest": 4702516424126994338, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702516226878348540, "LastVenusRetrograde": 4702516318547201357, "LastVenusRetrogradeToPrograde": 4702516318547201357, "LastVenusSuperiorConjunction": 4702515639990398764, "NextVenusConjunction": 4702516908230195456, - "NextVenusGreatestElongation": 4702517375722121075, - "NextVenusGreatestElongationEast": 4702517375722121075, - "NextVenusGreatestElongationWest": 4702517679591530603, + "NextVenusGreatestElongation": 4702517375745234934, + "NextVenusGreatestElongationEast": 4702517375745234934, + "NextVenusGreatestElongationWest": 4702517679616044966, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702517481616189877, "NextVenusRetrograde": 4702517481616189877, @@ -173,18 +173,18 @@ "tt_jd_bits": 4702517032983795435, "events": { "LastVenusConjunction": 4702516908230195456, - "LastVenusGreatestElongation": 4702516424103148082, - "LastVenusGreatestElongationEast": 4702516121067314056, - "LastVenusGreatestElongationWest": 4702516424103148082, + "LastVenusGreatestElongation": 4702516424126994338, + "LastVenusGreatestElongationEast": 4702516121090883434, + "LastVenusGreatestElongationWest": 4702516424126994338, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702516226878348540, "LastVenusRetrograde": 4702516318547201357, "LastVenusRetrogradeToPrograde": 4702516318547201357, "LastVenusSuperiorConjunction": 4702516908230195456, "NextVenusConjunction": 4702517528633990079, - "NextVenusGreatestElongation": 4702517375722121075, - "NextVenusGreatestElongationEast": 4702517375722121075, - "NextVenusGreatestElongationWest": 4702517679591530603, + "NextVenusGreatestElongation": 4702517375745234934, + "NextVenusGreatestElongationEast": 4702517375745234934, + "NextVenusGreatestElongationWest": 4702517679616044966, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702517481616189877, "NextVenusRetrograde": 4702517481616189877, @@ -197,18 +197,18 @@ "tt_jd_bits": 4702517404962611326, "events": { "LastVenusConjunction": 4702516908230195456, - "LastVenusGreatestElongation": 4702517375722121075, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702516424103148082, + "LastVenusGreatestElongation": 4702517375745234934, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702516424126994338, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702516226878348540, "LastVenusRetrograde": 4702516318547201357, "LastVenusRetrogradeToPrograde": 4702516318547201357, "LastVenusSuperiorConjunction": 4702516908230195456, "NextVenusConjunction": 4702517528633990079, - "NextVenusGreatestElongation": 4702517679591530603, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702517679591530603, + "NextVenusGreatestElongation": 4702517679616044966, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702517679616044966, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702517481616189877, "NextVenusRetrograde": 4702517481616189877, @@ -221,18 +221,18 @@ "tt_jd_bits": 4702517772914870523, "events": { "LastVenusConjunction": 4702517528633990079, - "LastVenusGreatestElongation": 4702517679591530603, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702517679616044966, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702517528633990079, "LastVenusProgradeToRetrograde": 4702517481616189877, "LastVenusRetrograde": 4702517572154348797, "LastVenusRetrogradeToPrograde": 4702517572154348797, "LastVenusSuperiorConjunction": 4702516908230195456, "NextVenusConjunction": 4702518144341858691, - "NextVenusGreatestElongation": 4702518626030453432, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518626051068738, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702518776380975423, "NextVenusProgradeToRetrograde": 4702518725593502830, "NextVenusRetrograde": 4702518725593502830, @@ -245,18 +245,18 @@ "tt_jd_bits": 4702518144893661559, "events": { "LastVenusConjunction": 4702518144341858691, - "LastVenusGreatestElongation": 4702517679591530603, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702517679616044966, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702517528633990079, "LastVenusProgradeToRetrograde": 4702517481616189877, "LastVenusRetrograde": 4702517572154348797, "LastVenusRetrogradeToPrograde": 4702517572154348797, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702518776380975423, - "NextVenusGreatestElongation": 4702518626030453432, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518626051068738, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702518776380975423, "NextVenusProgradeToRetrograde": 4702518725593502830, "NextVenusRetrograde": 4702518725593502830, @@ -269,18 +269,18 @@ "tt_jd_bits": 4702518516872477450, "events": { "LastVenusConjunction": 4702518144341858691, - "LastVenusGreatestElongation": 4702517679591530603, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702517679616044966, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702517528633990079, "LastVenusProgradeToRetrograde": 4702517481616189877, "LastVenusRetrograde": 4702517572154348797, "LastVenusRetrogradeToPrograde": 4702517572154348797, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702518776380975423, - "NextVenusGreatestElongation": 4702518626030453432, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518626051068738, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702518776380975423, "NextVenusProgradeToRetrograde": 4702518725593502830, "NextVenusRetrograde": 4702518725593502830, @@ -293,18 +293,18 @@ "tt_jd_bits": 4702518888851268487, "events": { "LastVenusConjunction": 4702518776380975423, - "LastVenusGreatestElongation": 4702518626030453432, - "LastVenusGreatestElongationEast": 4702518626030453432, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702518626051068738, + "LastVenusGreatestElongationEast": 4702518626051068738, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702518725593502830, "LastVenusRetrograde": 4702518820037727409, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702519411906298415, - "NextVenusGreatestElongation": 4702518929869766718, - "NextVenusGreatestElongationEast": 4702519883940209654, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518929890685862, + "NextVenusGreatestElongationEast": 4702519883963903760, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702519989583420424, "NextVenusRetrograde": 4702519989583420424, @@ -317,18 +317,18 @@ "tt_jd_bits": 4702519260825859005, "events": { "LastVenusConjunction": 4702518776380975423, - "LastVenusGreatestElongation": 4702518929869766718, - "LastVenusGreatestElongationEast": 4702518626030453432, - "LastVenusGreatestElongationWest": 4702518929869766718, + "LastVenusGreatestElongation": 4702518929890685862, + "LastVenusGreatestElongationEast": 4702518626051068738, + "LastVenusGreatestElongationWest": 4702518929890685862, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702518725593502830, "LastVenusRetrograde": 4702518820037727409, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702519411906298415, - "NextVenusGreatestElongation": 4702519883940209654, - "NextVenusGreatestElongationEast": 4702519883940209654, - "NextVenusGreatestElongationWest": 4702520188753344216, + "NextVenusGreatestElongation": 4702519883963903760, + "NextVenusGreatestElongationEast": 4702519883963903760, + "NextVenusGreatestElongationWest": 4702520188775070636, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702519989583420424, "NextVenusRetrograde": 4702519989583420424, @@ -341,18 +341,18 @@ "tt_jd_bits": 4702519632689819319, "events": { "LastVenusConjunction": 4702519411906298415, - "LastVenusGreatestElongation": 4702518929869766718, - "LastVenusGreatestElongationEast": 4702518626030453432, - "LastVenusGreatestElongationWest": 4702518929869766718, + "LastVenusGreatestElongation": 4702518929890685862, + "LastVenusGreatestElongationEast": 4702518626051068738, + "LastVenusGreatestElongationWest": 4702518929890685862, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702518725593502830, "LastVenusRetrograde": 4702518820037727409, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702519411906298415, "NextVenusConjunction": 4702520038413580935, - "NextVenusGreatestElongation": 4702519883940209654, - "NextVenusGreatestElongationEast": 4702519883940209654, - "NextVenusGreatestElongationWest": 4702520188753344216, + "NextVenusGreatestElongation": 4702519883963903760, + "NextVenusGreatestElongationEast": 4702519883963903760, + "NextVenusGreatestElongationWest": 4702520188775070636, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702519989583420424, "NextVenusRetrograde": 4702519989583420424, @@ -365,18 +365,18 @@ "tt_jd_bits": 4702520004668610355, "events": { "LastVenusConjunction": 4702519411906298415, - "LastVenusGreatestElongation": 4702519883940209654, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702518929869766718, + "LastVenusGreatestElongation": 4702519883963903760, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702518929890685862, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702519989583420424, "LastVenusRetrograde": 4702519989583420424, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702519411906298415, "NextVenusConjunction": 4702520038413580935, - "NextVenusGreatestElongation": 4702520188753344216, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702520188753344216, + "NextVenusGreatestElongation": 4702520188775070636, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702520188775070636, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702521239289979859, "NextVenusRetrograde": 4702520078167435764, @@ -389,18 +389,18 @@ "tt_jd_bits": 4702520376647401392, "events": { "LastVenusConjunction": 4702520038413580935, - "LastVenusGreatestElongation": 4702520188753344216, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702520188753344216, + "LastVenusGreatestElongation": 4702520188775070636, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702520188775070636, "LastVenusInferiorConjunction": 4702520038413580935, "LastVenusProgradeToRetrograde": 4702519989583420424, "LastVenusRetrograde": 4702520078167435764, "LastVenusRetrogradeToPrograde": 4702520078167435764, "LastVenusSuperiorConjunction": 4702519411906298415, "NextVenusConjunction": 4702520661833760985, - "NextVenusGreatestElongation": 4702521134417169490, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702521438386020310, + "NextVenusGreatestElongation": 4702521134439740954, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702521438408503464, "NextVenusInferiorConjunction": 4702521284654048328, "NextVenusProgradeToRetrograde": 4702521239289979859, "NextVenusRetrograde": 4702521239289979859, @@ -413,18 +413,18 @@ "tt_jd_bits": 4702520748626192428, "events": { "LastVenusConjunction": 4702520661833760985, - "LastVenusGreatestElongation": 4702520188753344216, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702520188753344216, + "LastVenusGreatestElongation": 4702520188775070636, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702520188775070636, "LastVenusInferiorConjunction": 4702520038413580935, "LastVenusProgradeToRetrograde": 4702519989583420424, "LastVenusRetrograde": 4702520078167435764, "LastVenusRetrogradeToPrograde": 4702520078167435764, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521284654048328, - "NextVenusGreatestElongation": 4702521134417169490, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702521438386020310, + "NextVenusGreatestElongation": 4702521134439740954, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702521438408503464, "NextVenusInferiorConjunction": 4702521284654048328, "NextVenusProgradeToRetrograde": 4702521239289979859, "NextVenusRetrograde": 4702521239289979859, @@ -437,18 +437,18 @@ "tt_jd_bits": 4702521116578451625, "events": { "LastVenusConjunction": 4702520661833760985, - "LastVenusGreatestElongation": 4702520188753344216, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702520188753344216, + "LastVenusGreatestElongation": 4702520188775070636, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702520188775070636, "LastVenusInferiorConjunction": 4702520038413580935, "LastVenusProgradeToRetrograde": 4702519989583420424, "LastVenusRetrograde": 4702520078167435764, "LastVenusRetrogradeToPrograde": 4702520078167435764, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521284654048328, - "NextVenusGreatestElongation": 4702521134417169490, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702521438386020310, + "NextVenusGreatestElongation": 4702521134439740954, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702521438408503464, "NextVenusInferiorConjunction": 4702521284654048328, "NextVenusProgradeToRetrograde": 4702521239289979859, "NextVenusRetrograde": 4702521239289979859, @@ -461,18 +461,18 @@ "tt_jd_bits": 4702521488557242661, "events": { "LastVenusConjunction": 4702521284654048328, - "LastVenusGreatestElongation": 4702521438386020310, - "LastVenusGreatestElongationEast": 4702521134417169490, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702521438408503464, + "LastVenusGreatestElongationEast": 4702521134439740954, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702521284654048328, "LastVenusProgradeToRetrograde": 4702521239289979859, "LastVenusRetrograde": 4702521324710783863, "LastVenusRetrogradeToPrograde": 4702521324710783863, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521910106763437, - "NextVenusGreatestElongation": 4702522390994727266, - "NextVenusGreatestElongationEast": 4702522390994727266, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522391017614550, + "NextVenusGreatestElongationEast": 4702522391017614550, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702522542777139682, "NextVenusProgradeToRetrograde": 4702522496936954010, "NextVenusRetrograde": 4702522496936954010, @@ -485,18 +485,18 @@ "tt_jd_bits": 4702521860536033697, "events": { "LastVenusConjunction": 4702521284654048328, - "LastVenusGreatestElongation": 4702521438386020310, - "LastVenusGreatestElongationEast": 4702521134417169490, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702521438408503464, + "LastVenusGreatestElongationEast": 4702521134439740954, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702521284654048328, "LastVenusProgradeToRetrograde": 4702521239289979859, "LastVenusRetrograde": 4702521324710783863, "LastVenusRetrogradeToPrograde": 4702521324710783863, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521910106763437, - "NextVenusGreatestElongation": 4702522390994727266, - "NextVenusGreatestElongationEast": 4702522390994727266, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522391017614550, + "NextVenusGreatestElongationEast": 4702522391017614550, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702522542777139682, "NextVenusProgradeToRetrograde": 4702522496936954010, "NextVenusRetrograde": 4702522496936954010, @@ -509,18 +509,18 @@ "tt_jd_bits": 4702522232399994011, "events": { "LastVenusConjunction": 4702521910106763437, - "LastVenusGreatestElongation": 4702521438386020310, - "LastVenusGreatestElongationEast": 4702521134417169490, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702521438408503464, + "LastVenusGreatestElongationEast": 4702521134439740954, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702521284654048328, "LastVenusProgradeToRetrograde": 4702521239289979859, "LastVenusRetrograde": 4702521324710783863, "LastVenusRetrogradeToPrograde": 4702521324710783863, "LastVenusSuperiorConjunction": 4702521910106763437, "NextVenusConjunction": 4702522542777139682, - "NextVenusGreatestElongation": 4702522390994727266, - "NextVenusGreatestElongationEast": 4702522390994727266, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522391017614550, + "NextVenusGreatestElongationEast": 4702522391017614550, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702522542777139682, "NextVenusProgradeToRetrograde": 4702522496936954010, "NextVenusRetrograde": 4702522496936954010, @@ -533,18 +533,18 @@ "tt_jd_bits": 4702522604378785047, "events": { "LastVenusConjunction": 4702522542777139682, - "LastVenusGreatestElongation": 4702522390994727266, - "LastVenusGreatestElongationEast": 4702522390994727266, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702522391017614550, + "LastVenusGreatestElongationEast": 4702522391017614550, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702522496936954010, "LastVenusRetrograde": 4702522588371642263, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702521910106763437, "NextVenusConjunction": 4702523177964858019, - "NextVenusGreatestElongation": 4702522693956367610, - "NextVenusGreatestElongationEast": 4702523645384902972, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522693977967954, + "NextVenusGreatestElongationEast": 4702523645405366570, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702523751448533801, "NextVenusRetrograde": 4702523751448533801, @@ -557,18 +557,18 @@ "tt_jd_bits": 4702522976357576084, "events": { "LastVenusConjunction": 4702522542777139682, - "LastVenusGreatestElongation": 4702522693956367610, - "LastVenusGreatestElongationEast": 4702522390994727266, - "LastVenusGreatestElongationWest": 4702522693956367610, + "LastVenusGreatestElongation": 4702522693977967954, + "LastVenusGreatestElongationEast": 4702522391017614550, + "LastVenusGreatestElongationWest": 4702522693977967954, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702522496936954010, "LastVenusRetrograde": 4702522588371642263, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702521910106763437, "NextVenusConjunction": 4702523177964858019, - "NextVenusGreatestElongation": 4702523645384902972, - "NextVenusGreatestElongationEast": 4702523645384902972, - "NextVenusGreatestElongationWest": 4702523949180172811, + "NextVenusGreatestElongation": 4702523645405366570, + "NextVenusGreatestElongationEast": 4702523645405366570, + "NextVenusGreatestElongationWest": 4702523949202201624, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702523751448533801, "NextVenusRetrograde": 4702523751448533801, @@ -581,18 +581,18 @@ "tt_jd_bits": 4702523348336367120, "events": { "LastVenusConjunction": 4702523177964858019, - "LastVenusGreatestElongation": 4702522693956367610, - "LastVenusGreatestElongationEast": 4702522390994727266, - "LastVenusGreatestElongationWest": 4702522693956367610, + "LastVenusGreatestElongation": 4702522693977967954, + "LastVenusGreatestElongationEast": 4702522391017614550, + "LastVenusGreatestElongationWest": 4702522693977967954, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702522496936954010, "LastVenusRetrograde": 4702522588371642263, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702523177964858019, "NextVenusConjunction": 4702523798273009758, - "NextVenusGreatestElongation": 4702523645384902972, - "NextVenusGreatestElongationEast": 4702523645384902972, - "NextVenusGreatestElongationWest": 4702523949180172811, + "NextVenusGreatestElongation": 4702523645405366570, + "NextVenusGreatestElongationEast": 4702523645405366570, + "NextVenusGreatestElongationWest": 4702523949202201624, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702523751448533801, "NextVenusRetrograde": 4702523751448533801, @@ -605,18 +605,18 @@ "tt_jd_bits": 4702523720315158157, "events": { "LastVenusConjunction": 4702523177964858019, - "LastVenusGreatestElongation": 4702523645384902972, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702522693956367610, + "LastVenusGreatestElongation": 4702523645405366570, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702522693977967954, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702522496936954010, "LastVenusRetrograde": 4702522588371642263, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702523177964858019, "NextVenusConjunction": 4702523798273009758, - "NextVenusGreatestElongation": 4702523949180172811, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702523949180172811, + "NextVenusGreatestElongation": 4702523949202201624, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702523949202201624, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702523751448533801, "NextVenusRetrograde": 4702523751448533801, @@ -629,18 +629,18 @@ "tt_jd_bits": 4702524092289773530, "events": { "LastVenusConjunction": 4702523798273009758, - "LastVenusGreatestElongation": 4702523949180172811, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702523949180172811, + "LastVenusGreatestElongation": 4702523949202201624, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702523949202201624, "LastVenusInferiorConjunction": 4702523798273009758, "LastVenusProgradeToRetrograde": 4702523751448533801, "LastVenusRetrograde": 4702523841828354031, "LastVenusRetrogradeToPrograde": 4702523841828354031, "LastVenusSuperiorConjunction": 4702523177964858019, "NextVenusConjunction": 4702524414051107713, - "NextVenusGreatestElongation": 4702524895862091761, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702525199762442089, + "NextVenusGreatestElongation": 4702524895885737348, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702525199784066882, "NextVenusInferiorConjunction": 4702525046292066094, "NextVenusProgradeToRetrograde": 4702524995610786712, "NextVenusRetrograde": 4702524995610786712, @@ -653,18 +653,18 @@ "tt_jd_bits": 4702524460242032727, "events": { "LastVenusConjunction": 4702524414051107713, - "LastVenusGreatestElongation": 4702523949180172811, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702523949180172811, + "LastVenusGreatestElongation": 4702523949202201624, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702523949202201624, "LastVenusInferiorConjunction": 4702523798273009758, "LastVenusProgradeToRetrograde": 4702523751448533801, "LastVenusRetrograde": 4702523841828354031, "LastVenusRetrogradeToPrograde": 4702523841828354031, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525046292066094, - "NextVenusGreatestElongation": 4702524895862091761, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702525199762442089, + "NextVenusGreatestElongation": 4702524895885737348, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702525199784066882, "NextVenusInferiorConjunction": 4702525046292066094, "NextVenusProgradeToRetrograde": 4702524995610786712, "NextVenusRetrograde": 4702524995610786712, @@ -677,18 +677,18 @@ "tt_jd_bits": 4702524832105993040, "events": { "LastVenusConjunction": 4702524414051107713, - "LastVenusGreatestElongation": 4702523949180172811, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702523949180172811, + "LastVenusGreatestElongation": 4702523949202201624, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702523949202201624, "LastVenusInferiorConjunction": 4702523798273009758, "LastVenusProgradeToRetrograde": 4702523751448533801, "LastVenusRetrograde": 4702523841828354031, "LastVenusRetrogradeToPrograde": 4702523841828354031, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525046292066094, - "NextVenusGreatestElongation": 4702524895862091761, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702525199762442089, + "NextVenusGreatestElongation": 4702524895885737348, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702525199784066882, "NextVenusInferiorConjunction": 4702525046292066094, "NextVenusProgradeToRetrograde": 4702524995610786712, "NextVenusRetrograde": 4702524995610786712, @@ -701,18 +701,18 @@ "tt_jd_bits": 4702525204084784077, "events": { "LastVenusConjunction": 4702525046292066094, - "LastVenusGreatestElongation": 4702525199762442089, - "LastVenusGreatestElongationEast": 4702524895862091761, - "LastVenusGreatestElongationWest": 4702525199762442089, + "LastVenusGreatestElongation": 4702525199784066882, + "LastVenusGreatestElongationEast": 4702524895885737348, + "LastVenusGreatestElongationWest": 4702525199784066882, "LastVenusInferiorConjunction": 4702525046292066094, "LastVenusProgradeToRetrograde": 4702524995610786712, "LastVenusRetrograde": 4702525090171724610, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525682003385778, - "NextVenusGreatestElongation": 4702526153808242982, - "NextVenusGreatestElongationEast": 4702526153808242982, - "NextVenusGreatestElongationWest": 4702526458702961968, + "NextVenusGreatestElongation": 4702526153832439418, + "NextVenusGreatestElongationEast": 4702526153832439418, + "NextVenusGreatestElongationWest": 4702526458723880296, "NextVenusInferiorConjunction": 4702526308271261482, "NextVenusProgradeToRetrograde": 4702526259348496553, "NextVenusRetrograde": 4702526259348496553, @@ -725,18 +725,18 @@ "tt_jd_bits": 4702525576063575113, "events": { "LastVenusConjunction": 4702525046292066094, - "LastVenusGreatestElongation": 4702525199762442089, - "LastVenusGreatestElongationEast": 4702524895862091761, - "LastVenusGreatestElongationWest": 4702525199762442089, + "LastVenusGreatestElongation": 4702525199784066882, + "LastVenusGreatestElongationEast": 4702524895885737348, + "LastVenusGreatestElongationWest": 4702525199784066882, "LastVenusInferiorConjunction": 4702525046292066094, "LastVenusProgradeToRetrograde": 4702524995610786712, "LastVenusRetrograde": 4702525090171724610, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525682003385778, - "NextVenusGreatestElongation": 4702526153808242982, - "NextVenusGreatestElongationEast": 4702526153808242982, - "NextVenusGreatestElongationWest": 4702526458702961968, + "NextVenusGreatestElongation": 4702526153832439418, + "NextVenusGreatestElongationEast": 4702526153832439418, + "NextVenusGreatestElongationWest": 4702526458723880296, "NextVenusInferiorConjunction": 4702526308271261482, "NextVenusProgradeToRetrograde": 4702526259348496553, "NextVenusRetrograde": 4702526259348496553, @@ -749,18 +749,18 @@ "tt_jd_bits": 4702525948042366149, "events": { "LastVenusConjunction": 4702525682003385778, - "LastVenusGreatestElongation": 4702525199762442089, - "LastVenusGreatestElongationEast": 4702524895862091761, - "LastVenusGreatestElongationWest": 4702525199762442089, + "LastVenusGreatestElongation": 4702525199784066882, + "LastVenusGreatestElongationEast": 4702524895885737348, + "LastVenusGreatestElongationWest": 4702525199784066882, "LastVenusInferiorConjunction": 4702525046292066094, "LastVenusProgradeToRetrograde": 4702524995610786712, "LastVenusRetrograde": 4702525090171724610, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702525682003385778, "NextVenusConjunction": 4702526308271261482, - "NextVenusGreatestElongation": 4702526153808242982, - "NextVenusGreatestElongationEast": 4702526153808242982, - "NextVenusGreatestElongationWest": 4702526458702961968, + "NextVenusGreatestElongation": 4702526153832439418, + "NextVenusGreatestElongationEast": 4702526153832439418, + "NextVenusGreatestElongationWest": 4702526458723880296, "NextVenusInferiorConjunction": 4702526308271261482, "NextVenusProgradeToRetrograde": 4702526259348496553, "NextVenusRetrograde": 4702526259348496553, @@ -773,18 +773,18 @@ "tt_jd_bits": 4702526320021182041, "events": { "LastVenusConjunction": 4702526308271261482, - "LastVenusGreatestElongation": 4702526153808242982, - "LastVenusGreatestElongationEast": 4702526153808242982, - "LastVenusGreatestElongationWest": 4702525199762442089, + "LastVenusGreatestElongation": 4702526153832439418, + "LastVenusGreatestElongationEast": 4702526153832439418, + "LastVenusGreatestElongationWest": 4702525199784066882, "LastVenusInferiorConjunction": 4702526308271261482, "LastVenusProgradeToRetrograde": 4702526259348496553, "LastVenusRetrograde": 4702526259348496553, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702525682003385778, "NextVenusConjunction": 4702526931193915095, - "NextVenusGreatestElongation": 4702526458702961968, - "NextVenusGreatestElongationEast": 4702527404219564180, - "NextVenusGreatestElongationWest": 4702526458702961968, + "NextVenusGreatestElongation": 4702526458723880296, + "NextVenusGreatestElongationEast": 4702527404238791558, + "NextVenusGreatestElongationWest": 4702526458723880296, "NextVenusInferiorConjunction": 4702527554328571151, "NextVenusProgradeToRetrograde": 4702527508682043197, "NextVenusRetrograde": 4702526348061581942, @@ -797,18 +797,18 @@ "tt_jd_bits": 4702526691999973077, "events": { "LastVenusConjunction": 4702526308271261482, - "LastVenusGreatestElongation": 4702526458702961968, - "LastVenusGreatestElongationEast": 4702526153808242982, - "LastVenusGreatestElongationWest": 4702526458702961968, + "LastVenusGreatestElongation": 4702526458723880296, + "LastVenusGreatestElongationEast": 4702526153832439418, + "LastVenusGreatestElongationWest": 4702526458723880296, "LastVenusInferiorConjunction": 4702526308271261482, "LastVenusProgradeToRetrograde": 4702526259348496553, "LastVenusRetrograde": 4702526348061581942, "LastVenusRetrogradeToPrograde": 4702526348061581942, "LastVenusSuperiorConjunction": 4702525682003385778, "NextVenusConjunction": 4702526931193915095, - "NextVenusGreatestElongation": 4702527404219564180, - "NextVenusGreatestElongationEast": 4702527404219564180, - "NextVenusGreatestElongationWest": 4702527708105826250, + "NextVenusGreatestElongation": 4702527404238791558, + "NextVenusGreatestElongationEast": 4702527404238791558, + "NextVenusGreatestElongationWest": 4702527708128036612, "NextVenusInferiorConjunction": 4702527554328571151, "NextVenusProgradeToRetrograde": 4702527508682043197, "NextVenusRetrograde": 4702527508682043197, @@ -821,18 +821,18 @@ "tt_jd_bits": 4702527063978764114, "events": { "LastVenusConjunction": 4702526931193915095, - "LastVenusGreatestElongation": 4702526458702961968, - "LastVenusGreatestElongationEast": 4702526153808242982, - "LastVenusGreatestElongationWest": 4702526458702961968, + "LastVenusGreatestElongation": 4702526458723880296, + "LastVenusGreatestElongationEast": 4702526153832439418, + "LastVenusGreatestElongationWest": 4702526458723880296, "LastVenusInferiorConjunction": 4702526308271261482, "LastVenusProgradeToRetrograde": 4702526259348496553, "LastVenusRetrograde": 4702526348061581942, "LastVenusRetrogradeToPrograde": 4702526348061581942, "LastVenusSuperiorConjunction": 4702526931193915095, "NextVenusConjunction": 4702527554328571151, - "NextVenusGreatestElongation": 4702527404219564180, - "NextVenusGreatestElongationEast": 4702527404219564180, - "NextVenusGreatestElongationWest": 4702527708105826250, + "NextVenusGreatestElongation": 4702527404238791558, + "NextVenusGreatestElongationEast": 4702527404238791558, + "NextVenusGreatestElongationWest": 4702527708128036612, "NextVenusInferiorConjunction": 4702527554328571151, "NextVenusProgradeToRetrograde": 4702527508682043197, "NextVenusRetrograde": 4702527508682043197, @@ -845,18 +845,18 @@ "tt_jd_bits": 4702527435842724427, "events": { "LastVenusConjunction": 4702526931193915095, - "LastVenusGreatestElongation": 4702527404219564180, - "LastVenusGreatestElongationEast": 4702527404219564180, - "LastVenusGreatestElongationWest": 4702526458702961968, + "LastVenusGreatestElongation": 4702527404238791558, + "LastVenusGreatestElongationEast": 4702527404238791558, + "LastVenusGreatestElongationWest": 4702526458723880296, "LastVenusInferiorConjunction": 4702526308271261482, "LastVenusProgradeToRetrograde": 4702526259348496553, "LastVenusRetrograde": 4702526348061581942, "LastVenusRetrogradeToPrograde": 4702526348061581942, "LastVenusSuperiorConjunction": 4702526931193915095, "NextVenusConjunction": 4702527554328571151, - "NextVenusGreatestElongation": 4702527708105826250, - "NextVenusGreatestElongationEast": 4702528660847413038, - "NextVenusGreatestElongationWest": 4702527708105826250, + "NextVenusGreatestElongation": 4702527708128036612, + "NextVenusGreatestElongationEast": 4702528660869836700, + "NextVenusGreatestElongationWest": 4702527708128036612, "NextVenusInferiorConjunction": 4702527554328571151, "NextVenusProgradeToRetrograde": 4702527508682043197, "NextVenusRetrograde": 4702527508682043197, @@ -869,18 +869,18 @@ "tt_jd_bits": 4702527803794983623, "events": { "LastVenusConjunction": 4702527554328571151, - "LastVenusGreatestElongation": 4702527708105826250, - "LastVenusGreatestElongationEast": 4702527404219564180, - "LastVenusGreatestElongationWest": 4702527708105826250, + "LastVenusGreatestElongation": 4702527708128036612, + "LastVenusGreatestElongationEast": 4702527404238791558, + "LastVenusGreatestElongationWest": 4702527708128036612, "LastVenusInferiorConjunction": 4702527554328571151, "LastVenusProgradeToRetrograde": 4702527508682043197, "LastVenusRetrograde": 4702527594279009593, "LastVenusRetrogradeToPrograde": 4702527594279009593, "LastVenusSuperiorConjunction": 4702526931193915095, "NextVenusConjunction": 4702528180226946947, - "NextVenusGreatestElongation": 4702528660847413038, - "NextVenusGreatestElongationEast": 4702528660847413038, - "NextVenusGreatestElongationWest": 4702528963767286103, + "NextVenusGreatestElongation": 4702528660869836700, + "NextVenusGreatestElongationEast": 4702528660869836700, + "NextVenusGreatestElongationWest": 4702528963788280426, "NextVenusInferiorConjunction": 4702528812752128401, "NextVenusProgradeToRetrograde": 4702528766958177100, "NextVenusRetrograde": 4702528766958177100, @@ -893,18 +893,18 @@ "tt_jd_bits": 4702528175773774660, "events": { "LastVenusConjunction": 4702527554328571151, - "LastVenusGreatestElongation": 4702527708105826250, - "LastVenusGreatestElongationEast": 4702527404219564180, - "LastVenusGreatestElongationWest": 4702527708105826250, + "LastVenusGreatestElongation": 4702527708128036612, + "LastVenusGreatestElongationEast": 4702527404238791558, + "LastVenusGreatestElongationWest": 4702527708128036612, "LastVenusInferiorConjunction": 4702527554328571151, "LastVenusProgradeToRetrograde": 4702527508682043197, "LastVenusRetrograde": 4702527594279009593, "LastVenusRetrogradeToPrograde": 4702527594279009593, "LastVenusSuperiorConjunction": 4702526931193915095, "NextVenusConjunction": 4702528180226946947, - "NextVenusGreatestElongation": 4702528660847413038, - "NextVenusGreatestElongationEast": 4702528660847413038, - "NextVenusGreatestElongationWest": 4702528963767286103, + "NextVenusGreatestElongation": 4702528660869836700, + "NextVenusGreatestElongationEast": 4702528660869836700, + "NextVenusGreatestElongationWest": 4702528963788280426, "NextVenusInferiorConjunction": 4702528812752128401, "NextVenusProgradeToRetrograde": 4702528766958177100, "NextVenusRetrograde": 4702528766958177100, @@ -917,18 +917,18 @@ "tt_jd_bits": 4702528547752565696, "events": { "LastVenusConjunction": 4702528180226946947, - "LastVenusGreatestElongation": 4702527708105826250, - "LastVenusGreatestElongationEast": 4702527404219564180, - "LastVenusGreatestElongationWest": 4702527708105826250, + "LastVenusGreatestElongation": 4702527708128036612, + "LastVenusGreatestElongationEast": 4702527404238791558, + "LastVenusGreatestElongationWest": 4702527708128036612, "LastVenusInferiorConjunction": 4702527554328571151, "LastVenusProgradeToRetrograde": 4702527508682043197, "LastVenusRetrograde": 4702527594279009593, "LastVenusRetrogradeToPrograde": 4702527594279009593, "LastVenusSuperiorConjunction": 4702528180226946947, "NextVenusConjunction": 4702528812752128401, - "NextVenusGreatestElongation": 4702528660847413038, - "NextVenusGreatestElongationEast": 4702528660847413038, - "NextVenusGreatestElongationWest": 4702528963767286103, + "NextVenusGreatestElongation": 4702528660869836700, + "NextVenusGreatestElongationEast": 4702528660869836700, + "NextVenusGreatestElongationWest": 4702528963788280426, "NextVenusInferiorConjunction": 4702528812752128401, "NextVenusProgradeToRetrograde": 4702528766958177100, "NextVenusRetrograde": 4702528766958177100, @@ -941,18 +941,18 @@ "tt_jd_bits": 4702528919727181070, "events": { "LastVenusConjunction": 4702528812752128401, - "LastVenusGreatestElongation": 4702528660847413038, - "LastVenusGreatestElongationEast": 4702528660847413038, - "LastVenusGreatestElongationWest": 4702527708105826250, + "LastVenusGreatestElongation": 4702528660869836700, + "LastVenusGreatestElongationEast": 4702528660869836700, + "LastVenusGreatestElongationWest": 4702527708128036612, "LastVenusInferiorConjunction": 4702528812752128401, "LastVenusProgradeToRetrograde": 4702528766958177100, "LastVenusRetrograde": 4702528858135575300, "LastVenusRetrogradeToPrograde": 4702528858135575300, "LastVenusSuperiorConjunction": 4702528180226946947, "NextVenusConjunction": 4702529447685417347, - "NextVenusGreatestElongation": 4702528963767286103, - "NextVenusGreatestElongationEast": 4702529914999142867, - "NextVenusGreatestElongationWest": 4702528963767286103, + "NextVenusGreatestElongation": 4702528963788280426, + "NextVenusGreatestElongationEast": 4702529915022120196, + "NextVenusGreatestElongationWest": 4702528963788280426, "NextVenusInferiorConjunction": 4702530067889940520, "NextVenusProgradeToRetrograde": 4702530021291200128, "NextVenusRetrograde": 4702530021291200128, @@ -965,18 +965,18 @@ "tt_jd_bits": 4702529291705972106, "events": { "LastVenusConjunction": 4702528812752128401, - "LastVenusGreatestElongation": 4702528963767286103, - "LastVenusGreatestElongationEast": 4702528660847413038, - "LastVenusGreatestElongationWest": 4702528963767286103, + "LastVenusGreatestElongation": 4702528963788280426, + "LastVenusGreatestElongationEast": 4702528660869836700, + "LastVenusGreatestElongationWest": 4702528963788280426, "LastVenusInferiorConjunction": 4702528812752128401, "LastVenusProgradeToRetrograde": 4702528766958177100, "LastVenusRetrograde": 4702528858135575300, "LastVenusRetrogradeToPrograde": 4702528858135575300, "LastVenusSuperiorConjunction": 4702528180226946947, "NextVenusConjunction": 4702529447685417347, - "NextVenusGreatestElongation": 4702529914999142867, - "NextVenusGreatestElongationEast": 4702529914999142867, - "NextVenusGreatestElongationWest": 4702530218827250519, + "NextVenusGreatestElongation": 4702529915022120196, + "NextVenusGreatestElongationEast": 4702529915022120196, + "NextVenusGreatestElongationWest": 4702530218849051710, "NextVenusInferiorConjunction": 4702530067889940520, "NextVenusProgradeToRetrograde": 4702530021291200128, "NextVenusRetrograde": 4702530021291200128, @@ -989,18 +989,18 @@ "tt_jd_bits": 4702529663684763143, "events": { "LastVenusConjunction": 4702529447685417347, - "LastVenusGreatestElongation": 4702528963767286103, - "LastVenusGreatestElongationEast": 4702528660847413038, - "LastVenusGreatestElongationWest": 4702528963767286103, + "LastVenusGreatestElongation": 4702528963788280426, + "LastVenusGreatestElongationEast": 4702528660869836700, + "LastVenusGreatestElongationWest": 4702528963788280426, "LastVenusInferiorConjunction": 4702528812752128401, "LastVenusProgradeToRetrograde": 4702528766958177100, "LastVenusRetrograde": 4702528858135575300, "LastVenusRetrogradeToPrograde": 4702528858135575300, "LastVenusSuperiorConjunction": 4702529447685417347, "NextVenusConjunction": 4702530067889940520, - "NextVenusGreatestElongation": 4702529914999142867, - "NextVenusGreatestElongationEast": 4702529914999142867, - "NextVenusGreatestElongationWest": 4702530218827250519, + "NextVenusGreatestElongation": 4702529915022120196, + "NextVenusGreatestElongationEast": 4702529915022120196, + "NextVenusGreatestElongationWest": 4702530218849051710, "NextVenusInferiorConjunction": 4702530067889940520, "NextVenusProgradeToRetrograde": 4702530021291200128, "NextVenusRetrograde": 4702530021291200128, @@ -1013,18 +1013,18 @@ "tt_jd_bits": 4702530035548723456, "events": { "LastVenusConjunction": 4702529447685417347, - "LastVenusGreatestElongation": 4702529914999142867, - "LastVenusGreatestElongationEast": 4702529914999142867, - "LastVenusGreatestElongationWest": 4702528963767286103, + "LastVenusGreatestElongation": 4702529915022120196, + "LastVenusGreatestElongationEast": 4702529915022120196, + "LastVenusGreatestElongationWest": 4702528963788280426, "LastVenusInferiorConjunction": 4702528812752128401, "LastVenusProgradeToRetrograde": 4702530021291200128, "LastVenusRetrograde": 4702530021291200128, "LastVenusRetrogradeToPrograde": 4702528858135575300, "LastVenusSuperiorConjunction": 4702529447685417347, "NextVenusConjunction": 4702530067889940520, - "NextVenusGreatestElongation": 4702530218827250519, - "NextVenusGreatestElongationEast": 4702531165774952941, - "NextVenusGreatestElongationWest": 4702530218827250519, + "NextVenusGreatestElongation": 4702530218849051710, + "NextVenusGreatestElongationEast": 4702531165797408224, + "NextVenusGreatestElongationWest": 4702530218849051710, "NextVenusInferiorConjunction": 4702530067889940520, "NextVenusProgradeToRetrograde": 4702531265647498975, "NextVenusRetrograde": 4702530111451293027, @@ -1037,18 +1037,18 @@ "tt_jd_bits": 4702530407527514493, "events": { "LastVenusConjunction": 4702530067889940520, - "LastVenusGreatestElongation": 4702530218827250519, - "LastVenusGreatestElongationEast": 4702529914999142867, - "LastVenusGreatestElongationWest": 4702530218827250519, + "LastVenusGreatestElongation": 4702530218849051710, + "LastVenusGreatestElongationEast": 4702529915022120196, + "LastVenusGreatestElongationWest": 4702530218849051710, "LastVenusInferiorConjunction": 4702530067889940520, "LastVenusProgradeToRetrograde": 4702530021291200128, "LastVenusRetrograde": 4702530111451293027, "LastVenusRetrogradeToPrograde": 4702530111451293027, "LastVenusSuperiorConjunction": 4702529447685417347, "NextVenusConjunction": 4702530683779807952, - "NextVenusGreatestElongation": 4702531165774952941, - "NextVenusGreatestElongationEast": 4702531165774952941, - "NextVenusGreatestElongationWest": 4702531469708348721, + "NextVenusGreatestElongation": 4702531165797408224, + "NextVenusGreatestElongationEast": 4702531165797408224, + "NextVenusGreatestElongationWest": 4702531469730816510, "NextVenusInferiorConjunction": 4702531316200131189, "NextVenusProgradeToRetrograde": 4702531265647498975, "NextVenusRetrograde": 4702531265647498975, @@ -1061,18 +1061,18 @@ "tt_jd_bits": 4702530779506305529, "events": { "LastVenusConjunction": 4702530683779807952, - "LastVenusGreatestElongation": 4702530218827250519, - "LastVenusGreatestElongationEast": 4702529914999142867, - "LastVenusGreatestElongationWest": 4702530218827250519, + "LastVenusGreatestElongation": 4702530218849051710, + "LastVenusGreatestElongationEast": 4702529915022120196, + "LastVenusGreatestElongationWest": 4702530218849051710, "LastVenusInferiorConjunction": 4702530067889940520, "LastVenusProgradeToRetrograde": 4702530021291200128, "LastVenusRetrograde": 4702530111451293027, "LastVenusRetrogradeToPrograde": 4702530111451293027, "LastVenusSuperiorConjunction": 4702530683779807952, "NextVenusConjunction": 4702531316200131189, - "NextVenusGreatestElongation": 4702531165774952941, - "NextVenusGreatestElongationEast": 4702531165774952941, - "NextVenusGreatestElongationWest": 4702531469708348721, + "NextVenusGreatestElongation": 4702531165797408224, + "NextVenusGreatestElongationEast": 4702531165797408224, + "NextVenusGreatestElongationWest": 4702531469730816510, "NextVenusInferiorConjunction": 4702531316200131189, "NextVenusProgradeToRetrograde": 4702531265647498975, "NextVenusRetrograde": 4702531265647498975, @@ -1085,18 +1085,18 @@ "tt_jd_bits": 4702531147458564725, "events": { "LastVenusConjunction": 4702530683779807952, - "LastVenusGreatestElongation": 4702530218827250519, - "LastVenusGreatestElongationEast": 4702529914999142867, - "LastVenusGreatestElongationWest": 4702530218827250519, + "LastVenusGreatestElongation": 4702530218849051710, + "LastVenusGreatestElongationEast": 4702529915022120196, + "LastVenusGreatestElongationWest": 4702530218849051710, "LastVenusInferiorConjunction": 4702530067889940520, "LastVenusProgradeToRetrograde": 4702530021291200128, "LastVenusRetrograde": 4702530111451293027, "LastVenusRetrogradeToPrograde": 4702530111451293027, "LastVenusSuperiorConjunction": 4702530683779807952, "NextVenusConjunction": 4702531316200131189, - "NextVenusGreatestElongation": 4702531165774952941, - "NextVenusGreatestElongationEast": 4702531165774952941, - "NextVenusGreatestElongationWest": 4702531469708348721, + "NextVenusGreatestElongation": 4702531165797408224, + "NextVenusGreatestElongationEast": 4702531165797408224, + "NextVenusGreatestElongationWest": 4702531469730816510, "NextVenusInferiorConjunction": 4702531316200131189, "NextVenusProgradeToRetrograde": 4702531265647498975, "NextVenusRetrograde": 4702531265647498975, @@ -1109,18 +1109,18 @@ "tt_jd_bits": 4702531519437380618, "events": { "LastVenusConjunction": 4702531316200131189, - "LastVenusGreatestElongation": 4702531469708348721, - "LastVenusGreatestElongationEast": 4702531165774952941, - "LastVenusGreatestElongationWest": 4702531469708348721, + "LastVenusGreatestElongation": 4702531469730816510, + "LastVenusGreatestElongationEast": 4702531165797408224, + "LastVenusGreatestElongationWest": 4702531469730816510, "LastVenusInferiorConjunction": 4702531316200131189, "LastVenusProgradeToRetrograde": 4702531265647498975, "LastVenusRetrograde": 4702531360334435835, "LastVenusRetrogradeToPrograde": 4702531360334435835, "LastVenusSuperiorConjunction": 4702530683779807952, "NextVenusConjunction": 4702531952075645702, - "NextVenusGreatestElongation": 4702532423738270840, - "NextVenusGreatestElongationEast": 4702532423738270840, - "NextVenusGreatestElongationWest": 4702532728607881502, + "NextVenusGreatestElongation": 4702532423760392884, + "NextVenusGreatestElongationEast": 4702532423760392884, + "NextVenusGreatestElongationWest": 4702532728628596298, "NextVenusInferiorConjunction": 4702532578108458949, "NextVenusProgradeToRetrograde": 4702532529073541059, "NextVenusRetrograde": 4702532529073541059, @@ -1133,18 +1133,18 @@ "tt_jd_bits": 4702531891416171654, "events": { "LastVenusConjunction": 4702531316200131189, - "LastVenusGreatestElongation": 4702531469708348721, - "LastVenusGreatestElongationEast": 4702531165774952941, - "LastVenusGreatestElongationWest": 4702531469708348721, + "LastVenusGreatestElongation": 4702531469730816510, + "LastVenusGreatestElongationEast": 4702531165797408224, + "LastVenusGreatestElongationWest": 4702531469730816510, "LastVenusInferiorConjunction": 4702531316200131189, "LastVenusProgradeToRetrograde": 4702531265647498975, "LastVenusRetrograde": 4702531360334435835, "LastVenusRetrogradeToPrograde": 4702531360334435835, "LastVenusSuperiorConjunction": 4702530683779807952, "NextVenusConjunction": 4702531952075645702, - "NextVenusGreatestElongation": 4702532423738270840, - "NextVenusGreatestElongationEast": 4702532423738270840, - "NextVenusGreatestElongationWest": 4702532728607881502, + "NextVenusGreatestElongation": 4702532423760392884, + "NextVenusGreatestElongationEast": 4702532423760392884, + "NextVenusGreatestElongationWest": 4702532728628596298, "NextVenusInferiorConjunction": 4702532578108458949, "NextVenusProgradeToRetrograde": 4702532529073541059, "NextVenusRetrograde": 4702532529073541059, @@ -1157,18 +1157,18 @@ "tt_jd_bits": 4702514491524064984, "events": { "LastVenusConjunction": 4702514392481018123, - "LastVenusGreatestElongation": 4702513918834546322, - "LastVenusGreatestElongationEast": 4702513614178864906, - "LastVenusGreatestElongationWest": 4702513918834546322, + "LastVenusGreatestElongation": 4702513918856228394, + "LastVenusGreatestElongationEast": 4702513614199327704, + "LastVenusGreatestElongationWest": 4702513918856228394, "LastVenusInferiorConjunction": 4702513768559104880, "LastVenusProgradeToRetrograde": 4702513719865285145, "LastVenusRetrograde": 4702513808332653958, "LastVenusRetrogradeToPrograde": 4702513808332653958, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515014994791160, - "NextVenusGreatestElongation": 4702514864618801785, - "NextVenusGreatestElongationEast": 4702514864618801785, - "NextVenusGreatestElongationWest": 4702515168583603037, + "NextVenusGreatestElongation": 4702514864641638812, + "NextVenusGreatestElongationEast": 4702514864641638812, + "NextVenusGreatestElongationWest": 4702515168605052864, "NextVenusInferiorConjunction": 4702515014994791160, "NextVenusProgradeToRetrograde": 4702514969848824186, "NextVenusRetrograde": 4702514969848824186, @@ -1181,18 +1181,18 @@ "tt_jd_bits": 4702514764811193590, "events": { "LastVenusConjunction": 4702514392481018123, - "LastVenusGreatestElongation": 4702513918834546322, - "LastVenusGreatestElongationEast": 4702513614178864906, - "LastVenusGreatestElongationWest": 4702513918834546322, + "LastVenusGreatestElongation": 4702513918856228394, + "LastVenusGreatestElongationEast": 4702513614199327704, + "LastVenusGreatestElongationWest": 4702513918856228394, "LastVenusInferiorConjunction": 4702513768559104880, "LastVenusProgradeToRetrograde": 4702513719865285145, "LastVenusRetrograde": 4702513808332653958, "LastVenusRetrogradeToPrograde": 4702513808332653958, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515014994791160, - "NextVenusGreatestElongation": 4702514864618801785, - "NextVenusGreatestElongationEast": 4702514864618801785, - "NextVenusGreatestElongationWest": 4702515168583603037, + "NextVenusGreatestElongation": 4702514864641638812, + "NextVenusGreatestElongationEast": 4702514864641638812, + "NextVenusGreatestElongationWest": 4702515168605052864, "NextVenusInferiorConjunction": 4702515014994791160, "NextVenusProgradeToRetrograde": 4702514969848824186, "NextVenusRetrograde": 4702514969848824186, @@ -1205,18 +1205,18 @@ "tt_jd_bits": 4702515038098297341, "events": { "LastVenusConjunction": 4702515014994791160, - "LastVenusGreatestElongation": 4702514864618801785, - "LastVenusGreatestElongationEast": 4702514864618801785, - "LastVenusGreatestElongationWest": 4702513918834546322, + "LastVenusGreatestElongation": 4702514864641638812, + "LastVenusGreatestElongationEast": 4702514864641638812, + "LastVenusGreatestElongationWest": 4702513918856228394, "LastVenusInferiorConjunction": 4702515014994791160, "LastVenusProgradeToRetrograde": 4702514969848824186, "LastVenusRetrograde": 4702514969848824186, "LastVenusRetrogradeToPrograde": 4702513808332653958, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515639990398764, - "NextVenusGreatestElongation": 4702515168583603037, - "NextVenusGreatestElongationEast": 4702516121067314056, - "NextVenusGreatestElongationWest": 4702515168583603037, + "NextVenusGreatestElongation": 4702515168605052864, + "NextVenusGreatestElongationEast": 4702516121090883434, + "NextVenusGreatestElongationWest": 4702515168605052864, "NextVenusInferiorConjunction": 4702516272804138222, "NextVenusProgradeToRetrograde": 4702516226878348540, "NextVenusRetrograde": 4702515055133849711, @@ -1229,18 +1229,18 @@ "tt_jd_bits": 4702515311385401091, "events": { "LastVenusConjunction": 4702515014994791160, - "LastVenusGreatestElongation": 4702515168583603037, - "LastVenusGreatestElongationEast": 4702514864618801785, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702515168605052864, + "LastVenusGreatestElongationEast": 4702514864641638812, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702515014994791160, "LastVenusProgradeToRetrograde": 4702514969848824186, "LastVenusRetrograde": 4702515055133849711, "LastVenusRetrogradeToPrograde": 4702515055133849711, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515639990398764, - "NextVenusGreatestElongation": 4702516121067314056, - "NextVenusGreatestElongationEast": 4702516121067314056, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516121090883434, + "NextVenusGreatestElongationEast": 4702516121090883434, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702516272804138222, "NextVenusProgradeToRetrograde": 4702516226878348540, "NextVenusRetrograde": 4702516226878348540, @@ -1253,18 +1253,18 @@ "tt_jd_bits": 4702515584672504842, "events": { "LastVenusConjunction": 4702515014994791160, - "LastVenusGreatestElongation": 4702515168583603037, - "LastVenusGreatestElongationEast": 4702514864618801785, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702515168605052864, + "LastVenusGreatestElongationEast": 4702514864641638812, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702515014994791160, "LastVenusProgradeToRetrograde": 4702514969848824186, "LastVenusRetrograde": 4702515055133849711, "LastVenusRetrogradeToPrograde": 4702515055133849711, "LastVenusSuperiorConjunction": 4702514392481018123, "NextVenusConjunction": 4702515639990398764, - "NextVenusGreatestElongation": 4702516121067314056, - "NextVenusGreatestElongationEast": 4702516121067314056, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516121090883434, + "NextVenusGreatestElongationEast": 4702516121090883434, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702516272804138222, "NextVenusProgradeToRetrograde": 4702516226878348540, "NextVenusRetrograde": 4702516226878348540, @@ -1277,18 +1277,18 @@ "tt_jd_bits": 4702515857959608592, "events": { "LastVenusConjunction": 4702515639990398764, - "LastVenusGreatestElongation": 4702515168583603037, - "LastVenusGreatestElongationEast": 4702514864618801785, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702515168605052864, + "LastVenusGreatestElongationEast": 4702514864641638812, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702515014994791160, "LastVenusProgradeToRetrograde": 4702514969848824186, "LastVenusRetrograde": 4702515055133849711, "LastVenusRetrogradeToPrograde": 4702515055133849711, "LastVenusSuperiorConjunction": 4702515639990398764, "NextVenusConjunction": 4702516272804138222, - "NextVenusGreatestElongation": 4702516121067314056, - "NextVenusGreatestElongationEast": 4702516121067314056, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516121090883434, + "NextVenusGreatestElongationEast": 4702516121090883434, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702516272804138222, "NextVenusProgradeToRetrograde": 4702516226878348540, "NextVenusRetrograde": 4702516226878348540, @@ -1301,18 +1301,18 @@ "tt_jd_bits": 4702516131246737198, "events": { "LastVenusConjunction": 4702515639990398764, - "LastVenusGreatestElongation": 4702516121067314056, - "LastVenusGreatestElongationEast": 4702516121067314056, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702516121090883434, + "LastVenusGreatestElongationEast": 4702516121090883434, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702515014994791160, "LastVenusProgradeToRetrograde": 4702514969848824186, "LastVenusRetrograde": 4702515055133849711, "LastVenusRetrogradeToPrograde": 4702515055133849711, "LastVenusSuperiorConjunction": 4702515639990398764, "NextVenusConjunction": 4702516272804138222, - "NextVenusGreatestElongation": 4702516424103148082, - "NextVenusGreatestElongationEast": 4702517375722121075, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516424126994338, + "NextVenusGreatestElongationEast": 4702517375745234934, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702516272804138222, "NextVenusProgradeToRetrograde": 4702516226878348540, "NextVenusRetrograde": 4702516226878348540, @@ -1325,18 +1325,18 @@ "tt_jd_bits": 4702516404533840948, "events": { "LastVenusConjunction": 4702516272804138222, - "LastVenusGreatestElongation": 4702516121067314056, - "LastVenusGreatestElongationEast": 4702516121067314056, - "LastVenusGreatestElongationWest": 4702515168583603037, + "LastVenusGreatestElongation": 4702516121090883434, + "LastVenusGreatestElongationEast": 4702516121090883434, + "LastVenusGreatestElongationWest": 4702515168605052864, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702516226878348540, "LastVenusRetrograde": 4702516318547201357, "LastVenusRetrogradeToPrograde": 4702516318547201357, "LastVenusSuperiorConjunction": 4702515639990398764, "NextVenusConjunction": 4702516908230195456, - "NextVenusGreatestElongation": 4702516424103148082, - "NextVenusGreatestElongationEast": 4702517375722121075, - "NextVenusGreatestElongationWest": 4702516424103148082, + "NextVenusGreatestElongation": 4702516424126994338, + "NextVenusGreatestElongationEast": 4702517375745234934, + "NextVenusGreatestElongationWest": 4702516424126994338, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702517481616189877, "NextVenusRetrograde": 4702517481616189877, @@ -1349,18 +1349,18 @@ "tt_jd_bits": 4702516673525977403, "events": { "LastVenusConjunction": 4702516272804138222, - "LastVenusGreatestElongation": 4702516424103148082, - "LastVenusGreatestElongationEast": 4702516121067314056, - "LastVenusGreatestElongationWest": 4702516424103148082, + "LastVenusGreatestElongation": 4702516424126994338, + "LastVenusGreatestElongationEast": 4702516121090883434, + "LastVenusGreatestElongationWest": 4702516424126994338, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702516226878348540, "LastVenusRetrograde": 4702516318547201357, "LastVenusRetrogradeToPrograde": 4702516318547201357, "LastVenusSuperiorConjunction": 4702515639990398764, "NextVenusConjunction": 4702516908230195456, - "NextVenusGreatestElongation": 4702517375722121075, - "NextVenusGreatestElongationEast": 4702517375722121075, - "NextVenusGreatestElongationWest": 4702517679591530603, + "NextVenusGreatestElongation": 4702517375745234934, + "NextVenusGreatestElongationEast": 4702517375745234934, + "NextVenusGreatestElongationWest": 4702517679616044966, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702517481616189877, "NextVenusRetrograde": 4702517481616189877, @@ -1373,18 +1373,18 @@ "tt_jd_bits": 4702516946813081153, "events": { "LastVenusConjunction": 4702516908230195456, - "LastVenusGreatestElongation": 4702516424103148082, - "LastVenusGreatestElongationEast": 4702516121067314056, - "LastVenusGreatestElongationWest": 4702516424103148082, + "LastVenusGreatestElongation": 4702516424126994338, + "LastVenusGreatestElongationEast": 4702516121090883434, + "LastVenusGreatestElongationWest": 4702516424126994338, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702516226878348540, "LastVenusRetrograde": 4702516318547201357, "LastVenusRetrogradeToPrograde": 4702516318547201357, "LastVenusSuperiorConjunction": 4702516908230195456, "NextVenusConjunction": 4702517528633990079, - "NextVenusGreatestElongation": 4702517375722121075, - "NextVenusGreatestElongationEast": 4702517375722121075, - "NextVenusGreatestElongationWest": 4702517679591530603, + "NextVenusGreatestElongation": 4702517375745234934, + "NextVenusGreatestElongationEast": 4702517375745234934, + "NextVenusGreatestElongationWest": 4702517679616044966, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702517481616189877, "NextVenusRetrograde": 4702517481616189877, @@ -1397,18 +1397,18 @@ "tt_jd_bits": 4702517219906339707, "events": { "LastVenusConjunction": 4702516908230195456, - "LastVenusGreatestElongation": 4702516424103148082, - "LastVenusGreatestElongationEast": 4702516121067314056, - "LastVenusGreatestElongationWest": 4702516424103148082, + "LastVenusGreatestElongation": 4702516424126994338, + "LastVenusGreatestElongationEast": 4702516121090883434, + "LastVenusGreatestElongationWest": 4702516424126994338, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702516226878348540, "LastVenusRetrograde": 4702516318547201357, "LastVenusRetrogradeToPrograde": 4702516318547201357, "LastVenusSuperiorConjunction": 4702516908230195456, "NextVenusConjunction": 4702517528633990079, - "NextVenusGreatestElongation": 4702517375722121075, - "NextVenusGreatestElongationEast": 4702517375722121075, - "NextVenusGreatestElongationWest": 4702517679591530603, + "NextVenusGreatestElongation": 4702517375745234934, + "NextVenusGreatestElongationEast": 4702517375745234934, + "NextVenusGreatestElongationWest": 4702517679616044966, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702517481616189877, "NextVenusRetrograde": 4702517481616189877, @@ -1421,18 +1421,18 @@ "tt_jd_bits": 4702517493193443457, "events": { "LastVenusConjunction": 4702516908230195456, - "LastVenusGreatestElongation": 4702517375722121075, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702516424103148082, + "LastVenusGreatestElongation": 4702517375745234934, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702516424126994338, "LastVenusInferiorConjunction": 4702516272804138222, "LastVenusProgradeToRetrograde": 4702517481616189877, "LastVenusRetrograde": 4702517481616189877, "LastVenusRetrogradeToPrograde": 4702516318547201357, "LastVenusSuperiorConjunction": 4702516908230195456, "NextVenusConjunction": 4702517528633990079, - "NextVenusGreatestElongation": 4702517679591530603, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702517679591530603, + "NextVenusGreatestElongation": 4702517679616044966, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702517679616044966, "NextVenusInferiorConjunction": 4702517528633990079, "NextVenusProgradeToRetrograde": 4702518725593502830, "NextVenusRetrograde": 4702517572154348797, @@ -1445,18 +1445,18 @@ "tt_jd_bits": 4702517766480547208, "events": { "LastVenusConjunction": 4702517528633990079, - "LastVenusGreatestElongation": 4702517679591530603, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702517679616044966, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702517528633990079, "LastVenusProgradeToRetrograde": 4702517481616189877, "LastVenusRetrograde": 4702517572154348797, "LastVenusRetrogradeToPrograde": 4702517572154348797, "LastVenusSuperiorConjunction": 4702516908230195456, "NextVenusConjunction": 4702518144341858691, - "NextVenusGreatestElongation": 4702518626030453432, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518626051068738, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702518776380975423, "NextVenusProgradeToRetrograde": 4702518725593502830, "NextVenusRetrograde": 4702518725593502830, @@ -1469,18 +1469,18 @@ "tt_jd_bits": 4702518039767650958, "events": { "LastVenusConjunction": 4702517528633990079, - "LastVenusGreatestElongation": 4702517679591530603, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702517679616044966, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702517528633990079, "LastVenusProgradeToRetrograde": 4702517481616189877, "LastVenusRetrograde": 4702517572154348797, "LastVenusRetrogradeToPrograde": 4702517572154348797, "LastVenusSuperiorConjunction": 4702516908230195456, "NextVenusConjunction": 4702518144341858691, - "NextVenusGreatestElongation": 4702518626030453432, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518626051068738, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702518776380975423, "NextVenusProgradeToRetrograde": 4702518725593502830, "NextVenusRetrograde": 4702518725593502830, @@ -1493,18 +1493,18 @@ "tt_jd_bits": 4702518313054779564, "events": { "LastVenusConjunction": 4702518144341858691, - "LastVenusGreatestElongation": 4702517679591530603, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702517679616044966, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702517528633990079, "LastVenusProgradeToRetrograde": 4702517481616189877, "LastVenusRetrograde": 4702517572154348797, "LastVenusRetrogradeToPrograde": 4702517572154348797, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702518776380975423, - "NextVenusGreatestElongation": 4702518626030453432, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518626051068738, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702518776380975423, "NextVenusProgradeToRetrograde": 4702518725593502830, "NextVenusRetrograde": 4702518725593502830, @@ -1517,18 +1517,18 @@ "tt_jd_bits": 4702518586335545255, "events": { "LastVenusConjunction": 4702518144341858691, - "LastVenusGreatestElongation": 4702517679591530603, - "LastVenusGreatestElongationEast": 4702517375722121075, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702517679616044966, + "LastVenusGreatestElongationEast": 4702517375745234934, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702517528633990079, "LastVenusProgradeToRetrograde": 4702517481616189877, "LastVenusRetrograde": 4702517572154348797, "LastVenusRetrogradeToPrograde": 4702517572154348797, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702518776380975423, - "NextVenusGreatestElongation": 4702518626030453432, - "NextVenusGreatestElongationEast": 4702518626030453432, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518626051068738, + "NextVenusGreatestElongationEast": 4702518626051068738, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702518776380975423, "NextVenusProgradeToRetrograde": 4702518725593502830, "NextVenusRetrograde": 4702518725593502830, @@ -1541,18 +1541,18 @@ "tt_jd_bits": 4702518855327681709, "events": { "LastVenusConjunction": 4702518776380975423, - "LastVenusGreatestElongation": 4702518626030453432, - "LastVenusGreatestElongationEast": 4702518626030453432, - "LastVenusGreatestElongationWest": 4702517679591530603, + "LastVenusGreatestElongation": 4702518626051068738, + "LastVenusGreatestElongationEast": 4702518626051068738, + "LastVenusGreatestElongationWest": 4702517679616044966, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702518725593502830, "LastVenusRetrograde": 4702518820037727409, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702519411906298415, - "NextVenusGreatestElongation": 4702518929869766718, - "NextVenusGreatestElongationEast": 4702519883940209654, - "NextVenusGreatestElongationWest": 4702518929869766718, + "NextVenusGreatestElongation": 4702518929890685862, + "NextVenusGreatestElongationEast": 4702519883963903760, + "NextVenusGreatestElongationWest": 4702518929890685862, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702519989583420424, "NextVenusRetrograde": 4702519989583420424, @@ -1565,18 +1565,18 @@ "tt_jd_bits": 4702519128614785460, "events": { "LastVenusConjunction": 4702518776380975423, - "LastVenusGreatestElongation": 4702518929869766718, - "LastVenusGreatestElongationEast": 4702518626030453432, - "LastVenusGreatestElongationWest": 4702518929869766718, + "LastVenusGreatestElongation": 4702518929890685862, + "LastVenusGreatestElongationEast": 4702518626051068738, + "LastVenusGreatestElongationWest": 4702518929890685862, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702518725593502830, "LastVenusRetrograde": 4702518820037727409, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702519411906298415, - "NextVenusGreatestElongation": 4702519883940209654, - "NextVenusGreatestElongationEast": 4702519883940209654, - "NextVenusGreatestElongationWest": 4702520188753344216, + "NextVenusGreatestElongation": 4702519883963903760, + "NextVenusGreatestElongationEast": 4702519883963903760, + "NextVenusGreatestElongationWest": 4702520188775070636, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702519989583420424, "NextVenusRetrograde": 4702519989583420424, @@ -1589,18 +1589,18 @@ "tt_jd_bits": 4702519401901889210, "events": { "LastVenusConjunction": 4702518776380975423, - "LastVenusGreatestElongation": 4702518929869766718, - "LastVenusGreatestElongationEast": 4702518626030453432, - "LastVenusGreatestElongationWest": 4702518929869766718, + "LastVenusGreatestElongation": 4702518929890685862, + "LastVenusGreatestElongationEast": 4702518626051068738, + "LastVenusGreatestElongationWest": 4702518929890685862, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702518725593502830, "LastVenusRetrograde": 4702518820037727409, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702518144341858691, "NextVenusConjunction": 4702519411906298415, - "NextVenusGreatestElongation": 4702519883940209654, - "NextVenusGreatestElongationEast": 4702519883940209654, - "NextVenusGreatestElongationWest": 4702520188753344216, + "NextVenusGreatestElongation": 4702519883963903760, + "NextVenusGreatestElongationEast": 4702519883963903760, + "NextVenusGreatestElongationWest": 4702520188775070636, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702519989583420424, "NextVenusRetrograde": 4702519989583420424, @@ -1613,18 +1613,18 @@ "tt_jd_bits": 4702519675188992961, "events": { "LastVenusConjunction": 4702519411906298415, - "LastVenusGreatestElongation": 4702518929869766718, - "LastVenusGreatestElongationEast": 4702518626030453432, - "LastVenusGreatestElongationWest": 4702518929869766718, + "LastVenusGreatestElongation": 4702518929890685862, + "LastVenusGreatestElongationEast": 4702518626051068738, + "LastVenusGreatestElongationWest": 4702518929890685862, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702518725593502830, "LastVenusRetrograde": 4702518820037727409, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702519411906298415, "NextVenusConjunction": 4702520038413580935, - "NextVenusGreatestElongation": 4702519883940209654, - "NextVenusGreatestElongationEast": 4702519883940209654, - "NextVenusGreatestElongationWest": 4702520188753344216, + "NextVenusGreatestElongation": 4702519883963903760, + "NextVenusGreatestElongationEast": 4702519883963903760, + "NextVenusGreatestElongationWest": 4702520188775070636, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702519989583420424, "NextVenusRetrograde": 4702519989583420424, @@ -1637,18 +1637,18 @@ "tt_jd_bits": 4702519948282226659, "events": { "LastVenusConjunction": 4702519411906298415, - "LastVenusGreatestElongation": 4702519883940209654, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702518929869766718, + "LastVenusGreatestElongation": 4702519883963903760, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702518929890685862, "LastVenusInferiorConjunction": 4702518776380975423, "LastVenusProgradeToRetrograde": 4702518725593502830, "LastVenusRetrograde": 4702518820037727409, "LastVenusRetrogradeToPrograde": 4702518820037727409, "LastVenusSuperiorConjunction": 4702519411906298415, "NextVenusConjunction": 4702520038413580935, - "NextVenusGreatestElongation": 4702520188753344216, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702520188753344216, + "NextVenusGreatestElongation": 4702520188775070636, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702520188775070636, "NextVenusInferiorConjunction": 4702520038413580935, "NextVenusProgradeToRetrograde": 4702519989583420424, "NextVenusRetrograde": 4702519989583420424, @@ -1661,18 +1661,18 @@ "tt_jd_bits": 4702520221569330410, "events": { "LastVenusConjunction": 4702520038413580935, - "LastVenusGreatestElongation": 4702520188753344216, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702520188753344216, + "LastVenusGreatestElongation": 4702520188775070636, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702520188775070636, "LastVenusInferiorConjunction": 4702520038413580935, "LastVenusProgradeToRetrograde": 4702519989583420424, "LastVenusRetrograde": 4702520078167435764, "LastVenusRetrogradeToPrograde": 4702520078167435764, "LastVenusSuperiorConjunction": 4702519411906298415, "NextVenusConjunction": 4702520661833760985, - "NextVenusGreatestElongation": 4702521134417169490, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702521438386020310, + "NextVenusGreatestElongation": 4702521134439740954, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702521438408503464, "NextVenusInferiorConjunction": 4702521284654048328, "NextVenusProgradeToRetrograde": 4702521239289979859, "NextVenusRetrograde": 4702521239289979859, @@ -1685,18 +1685,18 @@ "tt_jd_bits": 4702520494856434160, "events": { "LastVenusConjunction": 4702520038413580935, - "LastVenusGreatestElongation": 4702520188753344216, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702520188753344216, + "LastVenusGreatestElongation": 4702520188775070636, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702520188775070636, "LastVenusInferiorConjunction": 4702520038413580935, "LastVenusProgradeToRetrograde": 4702519989583420424, "LastVenusRetrograde": 4702520078167435764, "LastVenusRetrogradeToPrograde": 4702520078167435764, "LastVenusSuperiorConjunction": 4702519411906298415, "NextVenusConjunction": 4702520661833760985, - "NextVenusGreatestElongation": 4702521134417169490, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702521438386020310, + "NextVenusGreatestElongation": 4702521134439740954, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702521438408503464, "NextVenusInferiorConjunction": 4702521284654048328, "NextVenusProgradeToRetrograde": 4702521239289979859, "NextVenusRetrograde": 4702521239289979859, @@ -1709,18 +1709,18 @@ "tt_jd_bits": 4702520768143537911, "events": { "LastVenusConjunction": 4702520661833760985, - "LastVenusGreatestElongation": 4702520188753344216, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702520188753344216, + "LastVenusGreatestElongation": 4702520188775070636, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702520188775070636, "LastVenusInferiorConjunction": 4702520038413580935, "LastVenusProgradeToRetrograde": 4702519989583420424, "LastVenusRetrograde": 4702520078167435764, "LastVenusRetrogradeToPrograde": 4702520078167435764, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521284654048328, - "NextVenusGreatestElongation": 4702521134417169490, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702521438386020310, + "NextVenusGreatestElongation": 4702521134439740954, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702521438408503464, "NextVenusInferiorConjunction": 4702521284654048328, "NextVenusProgradeToRetrograde": 4702521239289979859, "NextVenusRetrograde": 4702521239289979859, @@ -1733,18 +1733,18 @@ "tt_jd_bits": 4702521037135674365, "events": { "LastVenusConjunction": 4702520661833760985, - "LastVenusGreatestElongation": 4702520188753344216, - "LastVenusGreatestElongationEast": 4702519883940209654, - "LastVenusGreatestElongationWest": 4702520188753344216, + "LastVenusGreatestElongation": 4702520188775070636, + "LastVenusGreatestElongationEast": 4702519883963903760, + "LastVenusGreatestElongationWest": 4702520188775070636, "LastVenusInferiorConjunction": 4702520038413580935, "LastVenusProgradeToRetrograde": 4702519989583420424, "LastVenusRetrograde": 4702520078167435764, "LastVenusRetrogradeToPrograde": 4702520078167435764, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521284654048328, - "NextVenusGreatestElongation": 4702521134417169490, - "NextVenusGreatestElongationEast": 4702521134417169490, - "NextVenusGreatestElongationWest": 4702521438386020310, + "NextVenusGreatestElongation": 4702521134439740954, + "NextVenusGreatestElongationEast": 4702521134439740954, + "NextVenusGreatestElongationWest": 4702521438408503464, "NextVenusInferiorConjunction": 4702521284654048328, "NextVenusProgradeToRetrograde": 4702521239289979859, "NextVenusRetrograde": 4702521239289979859, @@ -1757,18 +1757,18 @@ "tt_jd_bits": 4702521310422778116, "events": { "LastVenusConjunction": 4702521284654048328, - "LastVenusGreatestElongation": 4702521134417169490, - "LastVenusGreatestElongationEast": 4702521134417169490, - "LastVenusGreatestElongationWest": 4702520188753344216, + "LastVenusGreatestElongation": 4702521134439740954, + "LastVenusGreatestElongationEast": 4702521134439740954, + "LastVenusGreatestElongationWest": 4702520188775070636, "LastVenusInferiorConjunction": 4702521284654048328, "LastVenusProgradeToRetrograde": 4702521239289979859, "LastVenusRetrograde": 4702521239289979859, "LastVenusRetrogradeToPrograde": 4702520078167435764, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521910106763437, - "NextVenusGreatestElongation": 4702521438386020310, - "NextVenusGreatestElongationEast": 4702522390994727266, - "NextVenusGreatestElongationWest": 4702521438386020310, + "NextVenusGreatestElongation": 4702521438408503464, + "NextVenusGreatestElongationEast": 4702522391017614550, + "NextVenusGreatestElongationWest": 4702521438408503464, "NextVenusInferiorConjunction": 4702522542777139682, "NextVenusProgradeToRetrograde": 4702522496936954010, "NextVenusRetrograde": 4702521324710783863, @@ -1781,18 +1781,18 @@ "tt_jd_bits": 4702521583709881866, "events": { "LastVenusConjunction": 4702521284654048328, - "LastVenusGreatestElongation": 4702521438386020310, - "LastVenusGreatestElongationEast": 4702521134417169490, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702521438408503464, + "LastVenusGreatestElongationEast": 4702521134439740954, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702521284654048328, "LastVenusProgradeToRetrograde": 4702521239289979859, "LastVenusRetrograde": 4702521324710783863, "LastVenusRetrogradeToPrograde": 4702521324710783863, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521910106763437, - "NextVenusGreatestElongation": 4702522390994727266, - "NextVenusGreatestElongationEast": 4702522390994727266, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522391017614550, + "NextVenusGreatestElongationEast": 4702522391017614550, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702522542777139682, "NextVenusProgradeToRetrograde": 4702522496936954010, "NextVenusRetrograde": 4702522496936954010, @@ -1805,18 +1805,18 @@ "tt_jd_bits": 4702521856996985617, "events": { "LastVenusConjunction": 4702521284654048328, - "LastVenusGreatestElongation": 4702521438386020310, - "LastVenusGreatestElongationEast": 4702521134417169490, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702521438408503464, + "LastVenusGreatestElongationEast": 4702521134439740954, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702521284654048328, "LastVenusProgradeToRetrograde": 4702521239289979859, "LastVenusRetrograde": 4702521324710783863, "LastVenusRetrogradeToPrograde": 4702521324710783863, "LastVenusSuperiorConjunction": 4702520661833760985, "NextVenusConjunction": 4702521910106763437, - "NextVenusGreatestElongation": 4702522390994727266, - "NextVenusGreatestElongationEast": 4702522390994727266, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522391017614550, + "NextVenusGreatestElongationEast": 4702522391017614550, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702522542777139682, "NextVenusProgradeToRetrograde": 4702522496936954010, "NextVenusRetrograde": 4702522496936954010, @@ -1829,18 +1829,18 @@ "tt_jd_bits": 4702522130284089367, "events": { "LastVenusConjunction": 4702521910106763437, - "LastVenusGreatestElongation": 4702521438386020310, - "LastVenusGreatestElongationEast": 4702521134417169490, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702521438408503464, + "LastVenusGreatestElongationEast": 4702521134439740954, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702521284654048328, "LastVenusProgradeToRetrograde": 4702521239289979859, "LastVenusRetrograde": 4702521324710783863, "LastVenusRetrogradeToPrograde": 4702521324710783863, "LastVenusSuperiorConjunction": 4702521910106763437, "NextVenusConjunction": 4702522542777139682, - "NextVenusGreatestElongation": 4702522390994727266, - "NextVenusGreatestElongationEast": 4702522390994727266, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522391017614550, + "NextVenusGreatestElongationEast": 4702522391017614550, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702522542777139682, "NextVenusProgradeToRetrograde": 4702522496936954010, "NextVenusRetrograde": 4702522496936954010, @@ -1853,18 +1853,18 @@ "tt_jd_bits": 4702522403571193117, "events": { "LastVenusConjunction": 4702521910106763437, - "LastVenusGreatestElongation": 4702522390994727266, - "LastVenusGreatestElongationEast": 4702522390994727266, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702522391017614550, + "LastVenusGreatestElongationEast": 4702522391017614550, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702521284654048328, "LastVenusProgradeToRetrograde": 4702521239289979859, "LastVenusRetrograde": 4702521324710783863, "LastVenusRetrogradeToPrograde": 4702521324710783863, "LastVenusSuperiorConjunction": 4702521910106763437, "NextVenusConjunction": 4702522542777139682, - "NextVenusGreatestElongation": 4702522693956367610, - "NextVenusGreatestElongationEast": 4702523645384902972, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522693977967954, + "NextVenusGreatestElongationEast": 4702523645405366570, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702522542777139682, "NextVenusProgradeToRetrograde": 4702522496936954010, "NextVenusRetrograde": 4702522496936954010, @@ -1877,18 +1877,18 @@ "tt_jd_bits": 4702522676658088757, "events": { "LastVenusConjunction": 4702522542777139682, - "LastVenusGreatestElongation": 4702522390994727266, - "LastVenusGreatestElongationEast": 4702522390994727266, - "LastVenusGreatestElongationWest": 4702521438386020310, + "LastVenusGreatestElongation": 4702522391017614550, + "LastVenusGreatestElongationEast": 4702522391017614550, + "LastVenusGreatestElongationWest": 4702521438408503464, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702522496936954010, "LastVenusRetrograde": 4702522588371642263, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702521910106763437, "NextVenusConjunction": 4702523177964858019, - "NextVenusGreatestElongation": 4702522693956367610, - "NextVenusGreatestElongationEast": 4702523645384902972, - "NextVenusGreatestElongationWest": 4702522693956367610, + "NextVenusGreatestElongation": 4702522693977967954, + "NextVenusGreatestElongationEast": 4702523645405366570, + "NextVenusGreatestElongationWest": 4702522693977967954, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702523751448533801, "NextVenusRetrograde": 4702523751448533801, @@ -1901,18 +1901,18 @@ "tt_jd_bits": 4702522949945192507, "events": { "LastVenusConjunction": 4702522542777139682, - "LastVenusGreatestElongation": 4702522693956367610, - "LastVenusGreatestElongationEast": 4702522390994727266, - "LastVenusGreatestElongationWest": 4702522693956367610, + "LastVenusGreatestElongation": 4702522693977967954, + "LastVenusGreatestElongationEast": 4702522391017614550, + "LastVenusGreatestElongationWest": 4702522693977967954, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702522496936954010, "LastVenusRetrograde": 4702522588371642263, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702521910106763437, "NextVenusConjunction": 4702523177964858019, - "NextVenusGreatestElongation": 4702523645384902972, - "NextVenusGreatestElongationEast": 4702523645384902972, - "NextVenusGreatestElongationWest": 4702523949180172811, + "NextVenusGreatestElongation": 4702523645405366570, + "NextVenusGreatestElongationEast": 4702523645405366570, + "NextVenusGreatestElongationWest": 4702523949202201624, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702523751448533801, "NextVenusRetrograde": 4702523751448533801, @@ -1925,18 +1925,18 @@ "tt_jd_bits": 4702523218937328962, "events": { "LastVenusConjunction": 4702523177964858019, - "LastVenusGreatestElongation": 4702522693956367610, - "LastVenusGreatestElongationEast": 4702522390994727266, - "LastVenusGreatestElongationWest": 4702522693956367610, + "LastVenusGreatestElongation": 4702522693977967954, + "LastVenusGreatestElongationEast": 4702522391017614550, + "LastVenusGreatestElongationWest": 4702522693977967954, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702522496936954010, "LastVenusRetrograde": 4702522588371642263, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702523177964858019, "NextVenusConjunction": 4702523798273009758, - "NextVenusGreatestElongation": 4702523645384902972, - "NextVenusGreatestElongationEast": 4702523645384902972, - "NextVenusGreatestElongationWest": 4702523949180172811, + "NextVenusGreatestElongation": 4702523645405366570, + "NextVenusGreatestElongationEast": 4702523645405366570, + "NextVenusGreatestElongationWest": 4702523949202201624, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702523751448533801, "NextVenusRetrograde": 4702523751448533801, @@ -1949,18 +1949,18 @@ "tt_jd_bits": 4702523492224432712, "events": { "LastVenusConjunction": 4702523177964858019, - "LastVenusGreatestElongation": 4702522693956367610, - "LastVenusGreatestElongationEast": 4702522390994727266, - "LastVenusGreatestElongationWest": 4702522693956367610, + "LastVenusGreatestElongation": 4702522693977967954, + "LastVenusGreatestElongationEast": 4702522391017614550, + "LastVenusGreatestElongationWest": 4702522693977967954, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702522496936954010, "LastVenusRetrograde": 4702522588371642263, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702523177964858019, "NextVenusConjunction": 4702523798273009758, - "NextVenusGreatestElongation": 4702523645384902972, - "NextVenusGreatestElongationEast": 4702523645384902972, - "NextVenusGreatestElongationWest": 4702523949180172811, + "NextVenusGreatestElongation": 4702523645405366570, + "NextVenusGreatestElongationEast": 4702523645405366570, + "NextVenusGreatestElongationWest": 4702523949202201624, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702523751448533801, "NextVenusRetrograde": 4702523751448533801, @@ -1973,18 +1973,18 @@ "tt_jd_bits": 4702523765511536463, "events": { "LastVenusConjunction": 4702523177964858019, - "LastVenusGreatestElongation": 4702523645384902972, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702522693956367610, + "LastVenusGreatestElongation": 4702523645405366570, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702522693977967954, "LastVenusInferiorConjunction": 4702522542777139682, "LastVenusProgradeToRetrograde": 4702523751448533801, "LastVenusRetrograde": 4702523751448533801, "LastVenusRetrogradeToPrograde": 4702522588371642263, "LastVenusSuperiorConjunction": 4702523177964858019, "NextVenusConjunction": 4702523798273009758, - "NextVenusGreatestElongation": 4702523949180172811, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702523949180172811, + "NextVenusGreatestElongation": 4702523949202201624, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702523949202201624, "NextVenusInferiorConjunction": 4702523798273009758, "NextVenusProgradeToRetrograde": 4702524995610786712, "NextVenusRetrograde": 4702523841828354031, @@ -1997,18 +1997,18 @@ "tt_jd_bits": 4702524038798665068, "events": { "LastVenusConjunction": 4702523798273009758, - "LastVenusGreatestElongation": 4702523949180172811, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702523949180172811, + "LastVenusGreatestElongation": 4702523949202201624, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702523949202201624, "LastVenusInferiorConjunction": 4702523798273009758, "LastVenusProgradeToRetrograde": 4702523751448533801, "LastVenusRetrograde": 4702523841828354031, "LastVenusRetrogradeToPrograde": 4702523841828354031, "LastVenusSuperiorConjunction": 4702523177964858019, "NextVenusConjunction": 4702524414051107713, - "NextVenusGreatestElongation": 4702524895862091761, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702525199762442089, + "NextVenusGreatestElongation": 4702524895885737348, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702525199784066882, "NextVenusInferiorConjunction": 4702525046292066094, "NextVenusProgradeToRetrograde": 4702524995610786712, "NextVenusRetrograde": 4702524995610786712, @@ -2021,18 +2021,18 @@ "tt_jd_bits": 4702524312085768819, "events": { "LastVenusConjunction": 4702523798273009758, - "LastVenusGreatestElongation": 4702523949180172811, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702523949180172811, + "LastVenusGreatestElongation": 4702523949202201624, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702523949202201624, "LastVenusInferiorConjunction": 4702523798273009758, "LastVenusProgradeToRetrograde": 4702523751448533801, "LastVenusRetrograde": 4702523841828354031, "LastVenusRetrogradeToPrograde": 4702523841828354031, "LastVenusSuperiorConjunction": 4702523177964858019, "NextVenusConjunction": 4702524414051107713, - "NextVenusGreatestElongation": 4702524895862091761, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702525199762442089, + "NextVenusGreatestElongation": 4702524895885737348, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702525199784066882, "NextVenusInferiorConjunction": 4702525046292066094, "NextVenusProgradeToRetrograde": 4702524995610786712, "NextVenusRetrograde": 4702524995610786712, @@ -2045,18 +2045,18 @@ "tt_jd_bits": 4702524585372872569, "events": { "LastVenusConjunction": 4702524414051107713, - "LastVenusGreatestElongation": 4702523949180172811, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702523949180172811, + "LastVenusGreatestElongation": 4702523949202201624, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702523949202201624, "LastVenusInferiorConjunction": 4702523798273009758, "LastVenusProgradeToRetrograde": 4702523751448533801, "LastVenusRetrograde": 4702523841828354031, "LastVenusRetrogradeToPrograde": 4702523841828354031, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525046292066094, - "NextVenusGreatestElongation": 4702524895862091761, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702525199762442089, + "NextVenusGreatestElongation": 4702524895885737348, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702525199784066882, "NextVenusInferiorConjunction": 4702525046292066094, "NextVenusProgradeToRetrograde": 4702524995610786712, "NextVenusRetrograde": 4702524995610786712, @@ -2069,18 +2069,18 @@ "tt_jd_bits": 4702524858659976320, "events": { "LastVenusConjunction": 4702524414051107713, - "LastVenusGreatestElongation": 4702523949180172811, - "LastVenusGreatestElongationEast": 4702523645384902972, - "LastVenusGreatestElongationWest": 4702523949180172811, + "LastVenusGreatestElongation": 4702523949202201624, + "LastVenusGreatestElongationEast": 4702523645405366570, + "LastVenusGreatestElongationWest": 4702523949202201624, "LastVenusInferiorConjunction": 4702523798273009758, "LastVenusProgradeToRetrograde": 4702523751448533801, "LastVenusRetrograde": 4702523841828354031, "LastVenusRetrogradeToPrograde": 4702523841828354031, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525046292066094, - "NextVenusGreatestElongation": 4702524895862091761, - "NextVenusGreatestElongationEast": 4702524895862091761, - "NextVenusGreatestElongationWest": 4702525199762442089, + "NextVenusGreatestElongation": 4702524895885737348, + "NextVenusGreatestElongationEast": 4702524895885737348, + "NextVenusGreatestElongationWest": 4702525199784066882, "NextVenusInferiorConjunction": 4702525046292066094, "NextVenusProgradeToRetrograde": 4702524995610786712, "NextVenusRetrograde": 4702524995610786712, @@ -2093,18 +2093,18 @@ "tt_jd_bits": 4702525131947080070, "events": { "LastVenusConjunction": 4702525046292066094, - "LastVenusGreatestElongation": 4702524895862091761, - "LastVenusGreatestElongationEast": 4702524895862091761, - "LastVenusGreatestElongationWest": 4702523949180172811, + "LastVenusGreatestElongation": 4702524895885737348, + "LastVenusGreatestElongationEast": 4702524895885737348, + "LastVenusGreatestElongationWest": 4702523949202201624, "LastVenusInferiorConjunction": 4702525046292066094, "LastVenusProgradeToRetrograde": 4702524995610786712, "LastVenusRetrograde": 4702525090171724610, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525682003385778, - "NextVenusGreatestElongation": 4702525199762442089, - "NextVenusGreatestElongationEast": 4702526153808242982, - "NextVenusGreatestElongationWest": 4702525199762442089, + "NextVenusGreatestElongation": 4702525199784066882, + "NextVenusGreatestElongationEast": 4702526153832439418, + "NextVenusGreatestElongationWest": 4702525199784066882, "NextVenusInferiorConjunction": 4702526308271261482, "NextVenusProgradeToRetrograde": 4702526259348496553, "NextVenusRetrograde": 4702526259348496553, @@ -2117,18 +2117,18 @@ "tt_jd_bits": 4702525400745346473, "events": { "LastVenusConjunction": 4702525046292066094, - "LastVenusGreatestElongation": 4702525199762442089, - "LastVenusGreatestElongationEast": 4702524895862091761, - "LastVenusGreatestElongationWest": 4702525199762442089, + "LastVenusGreatestElongation": 4702525199784066882, + "LastVenusGreatestElongationEast": 4702524895885737348, + "LastVenusGreatestElongationWest": 4702525199784066882, "LastVenusInferiorConjunction": 4702525046292066094, "LastVenusProgradeToRetrograde": 4702524995610786712, "LastVenusRetrograde": 4702525090171724610, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525682003385778, - "NextVenusGreatestElongation": 4702526153808242982, - "NextVenusGreatestElongationEast": 4702526153808242982, - "NextVenusGreatestElongationWest": 4702526458702961968, + "NextVenusGreatestElongation": 4702526153832439418, + "NextVenusGreatestElongationEast": 4702526153832439418, + "NextVenusGreatestElongationWest": 4702526458723880296, "NextVenusInferiorConjunction": 4702526308271261482, "NextVenusProgradeToRetrograde": 4702526259348496553, "NextVenusRetrograde": 4702526259348496553, @@ -2141,18 +2141,18 @@ "tt_jd_bits": 4702525674032450223, "events": { "LastVenusConjunction": 4702525046292066094, - "LastVenusGreatestElongation": 4702525199762442089, - "LastVenusGreatestElongationEast": 4702524895862091761, - "LastVenusGreatestElongationWest": 4702525199762442089, + "LastVenusGreatestElongation": 4702525199784066882, + "LastVenusGreatestElongationEast": 4702524895885737348, + "LastVenusGreatestElongationWest": 4702525199784066882, "LastVenusInferiorConjunction": 4702525046292066094, "LastVenusProgradeToRetrograde": 4702524995610786712, "LastVenusRetrograde": 4702525090171724610, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702524414051107713, "NextVenusConjunction": 4702525682003385778, - "NextVenusGreatestElongation": 4702526153808242982, - "NextVenusGreatestElongationEast": 4702526153808242982, - "NextVenusGreatestElongationWest": 4702526458702961968, + "NextVenusGreatestElongation": 4702526153832439418, + "NextVenusGreatestElongationEast": 4702526153832439418, + "NextVenusGreatestElongationWest": 4702526458723880296, "NextVenusInferiorConjunction": 4702526308271261482, "NextVenusProgradeToRetrograde": 4702526259348496553, "NextVenusRetrograde": 4702526259348496553, @@ -2165,18 +2165,18 @@ "tt_jd_bits": 4702525947319553974, "events": { "LastVenusConjunction": 4702525682003385778, - "LastVenusGreatestElongation": 4702525199762442089, - "LastVenusGreatestElongationEast": 4702524895862091761, - "LastVenusGreatestElongationWest": 4702525199762442089, + "LastVenusGreatestElongation": 4702525199784066882, + "LastVenusGreatestElongationEast": 4702524895885737348, + "LastVenusGreatestElongationWest": 4702525199784066882, "LastVenusInferiorConjunction": 4702525046292066094, "LastVenusProgradeToRetrograde": 4702524995610786712, "LastVenusRetrograde": 4702525090171724610, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702525682003385778, "NextVenusConjunction": 4702526308271261482, - "NextVenusGreatestElongation": 4702526153808242982, - "NextVenusGreatestElongationEast": 4702526153808242982, - "NextVenusGreatestElongationWest": 4702526458702961968, + "NextVenusGreatestElongation": 4702526153832439418, + "NextVenusGreatestElongationEast": 4702526153832439418, + "NextVenusGreatestElongationWest": 4702526458723880296, "NextVenusInferiorConjunction": 4702526308271261482, "NextVenusProgradeToRetrograde": 4702526259348496553, "NextVenusRetrograde": 4702526259348496553, @@ -2189,18 +2189,18 @@ "tt_jd_bits": 4702526220606682579, "events": { "LastVenusConjunction": 4702525682003385778, - "LastVenusGreatestElongation": 4702526153808242982, - "LastVenusGreatestElongationEast": 4702526153808242982, - "LastVenusGreatestElongationWest": 4702525199762442089, + "LastVenusGreatestElongation": 4702526153832439418, + "LastVenusGreatestElongationEast": 4702526153832439418, + "LastVenusGreatestElongationWest": 4702525199784066882, "LastVenusInferiorConjunction": 4702525046292066094, "LastVenusProgradeToRetrograde": 4702524995610786712, "LastVenusRetrograde": 4702525090171724610, "LastVenusRetrogradeToPrograde": 4702525090171724610, "LastVenusSuperiorConjunction": 4702525682003385778, "NextVenusConjunction": 4702526308271261482, - "NextVenusGreatestElongation": 4702526458702961968, - "NextVenusGreatestElongationEast": 4702527404219564180, - "NextVenusGreatestElongationWest": 4702526458702961968, + "NextVenusGreatestElongation": 4702526458723880296, + "NextVenusGreatestElongationEast": 4702527404238791558, + "NextVenusGreatestElongationWest": 4702526458723880296, "NextVenusInferiorConjunction": 4702526308271261482, "NextVenusProgradeToRetrograde": 4702526259348496553, "NextVenusRetrograde": 4702526259348496553, @@ -2213,18 +2213,18 @@ "tt_jd_bits": 4702526493893786330, "events": { "LastVenusConjunction": 4702526308271261482, - "LastVenusGreatestElongation": 4702526458702961968, - "LastVenusGreatestElongationEast": 4702526153808242982, - "LastVenusGreatestElongationWest": 4702526458702961968, + "LastVenusGreatestElongation": 4702526458723880296, + "LastVenusGreatestElongationEast": 4702526153832439418, + "LastVenusGreatestElongationWest": 4702526458723880296, "LastVenusInferiorConjunction": 4702526308271261482, "LastVenusProgradeToRetrograde": 4702526259348496553, "LastVenusRetrograde": 4702526348061581942, "LastVenusRetrogradeToPrograde": 4702526348061581942, "LastVenusSuperiorConjunction": 4702525682003385778, "NextVenusConjunction": 4702526931193915095, - "NextVenusGreatestElongation": 4702527404219564180, - "NextVenusGreatestElongationEast": 4702527404219564180, - "NextVenusGreatestElongationWest": 4702527708105826250, + "NextVenusGreatestElongation": 4702527404238791558, + "NextVenusGreatestElongationEast": 4702527404238791558, + "NextVenusGreatestElongationWest": 4702527708128036612, "NextVenusInferiorConjunction": 4702527554328571151, "NextVenusProgradeToRetrograde": 4702527508682043197, "NextVenusRetrograde": 4702527508682043197, @@ -2237,18 +2237,18 @@ "tt_jd_bits": 4702526767174552021, "events": { "LastVenusConjunction": 4702526308271261482, - "LastVenusGreatestElongation": 4702526458702961968, - "LastVenusGreatestElongationEast": 4702526153808242982, - "LastVenusGreatestElongationWest": 4702526458702961968, + "LastVenusGreatestElongation": 4702526458723880296, + "LastVenusGreatestElongationEast": 4702526153832439418, + "LastVenusGreatestElongationWest": 4702526458723880296, "LastVenusInferiorConjunction": 4702526308271261482, "LastVenusProgradeToRetrograde": 4702526259348496553, "LastVenusRetrograde": 4702526348061581942, "LastVenusRetrogradeToPrograde": 4702526348061581942, "LastVenusSuperiorConjunction": 4702525682003385778, "NextVenusConjunction": 4702526931193915095, - "NextVenusGreatestElongation": 4702527404219564180, - "NextVenusGreatestElongationEast": 4702527404219564180, - "NextVenusGreatestElongationWest": 4702527708105826250, + "NextVenusGreatestElongation": 4702527404238791558, + "NextVenusGreatestElongationEast": 4702527404238791558, + "NextVenusGreatestElongationWest": 4702527708128036612, "NextVenusInferiorConjunction": 4702527554328571151, "NextVenusProgradeToRetrograde": 4702527508682043197, "NextVenusRetrograde": 4702527508682043197, @@ -2261,18 +2261,18 @@ "tt_jd_bits": 4702527040461655771, "events": { "LastVenusConjunction": 4702526931193915095, - "LastVenusGreatestElongation": 4702526458702961968, - "LastVenusGreatestElongationEast": 4702526153808242982, - "LastVenusGreatestElongationWest": 4702526458702961968, + "LastVenusGreatestElongation": 4702526458723880296, + "LastVenusGreatestElongationEast": 4702526153832439418, + "LastVenusGreatestElongationWest": 4702526458723880296, "LastVenusInferiorConjunction": 4702526308271261482, "LastVenusProgradeToRetrograde": 4702526259348496553, "LastVenusRetrograde": 4702526348061581942, "LastVenusRetrogradeToPrograde": 4702526348061581942, "LastVenusSuperiorConjunction": 4702526931193915095, "NextVenusConjunction": 4702527554328571151, - "NextVenusGreatestElongation": 4702527404219564180, - "NextVenusGreatestElongationEast": 4702527404219564180, - "NextVenusGreatestElongationWest": 4702527708105826250, + "NextVenusGreatestElongation": 4702527404238791558, + "NextVenusGreatestElongationEast": 4702527404238791558, + "NextVenusGreatestElongationWest": 4702527708128036612, "NextVenusInferiorConjunction": 4702527554328571151, "NextVenusProgradeToRetrograde": 4702527508682043197, "NextVenusRetrograde": 4702527508682043197, @@ -2285,18 +2285,18 @@ "tt_jd_bits": 4702527313748759522, "events": { "LastVenusConjunction": 4702526931193915095, - "LastVenusGreatestElongation": 4702526458702961968, - "LastVenusGreatestElongationEast": 4702526153808242982, - "LastVenusGreatestElongationWest": 4702526458702961968, + "LastVenusGreatestElongation": 4702526458723880296, + "LastVenusGreatestElongationEast": 4702526153832439418, + "LastVenusGreatestElongationWest": 4702526458723880296, "LastVenusInferiorConjunction": 4702526308271261482, "LastVenusProgradeToRetrograde": 4702526259348496553, "LastVenusRetrograde": 4702526348061581942, "LastVenusRetrogradeToPrograde": 4702526348061581942, "LastVenusSuperiorConjunction": 4702526931193915095, "NextVenusConjunction": 4702527554328571151, - "NextVenusGreatestElongation": 4702527404219564180, - "NextVenusGreatestElongationEast": 4702527404219564180, - "NextVenusGreatestElongationWest": 4702527708105826250, + "NextVenusGreatestElongation": 4702527404238791558, + "NextVenusGreatestElongationEast": 4702527404238791558, + "NextVenusGreatestElongationWest": 4702527708128036612, "NextVenusInferiorConjunction": 4702527554328571151, "NextVenusProgradeToRetrograde": 4702527508682043197, "NextVenusRetrograde": 4702527508682043197, diff --git a/basic/uranus_events.go b/basic/uranus_events.go index ee69e87..5eba5b0 100644 --- a/basic/uranus_events.go +++ b/basic/uranus_events.go @@ -172,6 +172,9 @@ func LastUranusWesternQuadrature(jde float64) float64 { } func uranusRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 { + if !isFiniteFloat(oppositionJD) { + return math.NaN() + } oppositionTT := TD2UT(oppositionJD, true) startTT := oppositionTT endTT := oppositionTT @@ -183,49 +186,98 @@ func uranusRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOppositi endTT = TD2UT(westernQuadratureUT, true) } bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 { - return uranusRADerivativeN(jd, 1.0/86400.0, uranusEventSearchN) + return uranusRADerivativeN(jd, stationDerivativeStepDay, uranusEventSearchN) }, func(jd float64) float64 { - return uranusRADerivative(jd, 0.5/86400.0) + return uranusRADerivative(jd, stationDerivativeStepDay) }) return TD2UT(bestJD, false) } func NextUranusRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := uranusConjunctionFull(jde, 180, 0) date := uranusRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } nextOppositionJD := uranusConjunctionFull(jde, 180, 1) - return uranusRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } + date = uranusRetrogradeAroundOpposition(nextOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastUranusRetrogradeToPrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } lastOppositionJD := uranusConjunctionFull(jde, 180, 0) date := uranusRetrogradeAroundOpposition(lastOppositionJD, false) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } previousOppositionJD := uranusConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0) - return uranusRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(previousOppositionJD) { + return math.NaN() + } + date = uranusRetrogradeAroundOpposition(previousOppositionJD, false) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } func NextUranusProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := uranusConjunctionFull(jde, 180, 1) date := uranusRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } followingOppositionJD := uranusConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1) - return uranusRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(followingOppositionJD) { + return math.NaN() + } + date = uranusRetrogradeAroundOpposition(followingOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) { + return math.NaN() + } + return date } func LastUranusProgradeToRetrograde(jde float64) float64 { + if !isFiniteFloat(jde) { + return math.NaN() + } nextOppositionJD := uranusConjunctionFull(jde, 180, 1) date := uranusRetrogradeAroundOpposition(nextOppositionJD, true) - if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) { + if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) { return date } + if !isFiniteFloat(nextOppositionJD) { + return math.NaN() + } lastOppositionJD := uranusConjunctionFull(jde, 180, 0) - return uranusRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(lastOppositionJD) { + return math.NaN() + } + date = uranusRetrogradeAroundOpposition(lastOppositionJD, true) + if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) { + return math.NaN() + } + return date } diff --git a/basic/venus_events.go b/basic/venus_events.go index 3e49c4f..33e8ef7 100644 --- a/basic/venus_events.go +++ b/basic/venus_events.go @@ -3,13 +3,18 @@ package basic import ( "math" - "b612.me/astro/planet" . "b612.me/astro/tools" ) const ( VENUS_S_PERIOD = 1 / ((1 / 224.701) - (1 / 365.256363004)) venusEventSearchN = 16 + // venusConjunctionSameInstantDegrees 「同刻」快速路径的视黄经差触发阈值(度)。 + // 实测截断级数(n=16)与全项级数的视黄经差最大 0.0031 度:若阈值小于它, + // 「查询落在合前几秒~几分钟」时截断级数在起点处符号相反,方向扫描会直接跨过这次合, + // 返回下一个会合周期的合(实测 1950–2055 年有 17~29 个查询点命中)。 + // 0.05 度约合 38 分钟,留有 16 倍余量;侧向判定仍用 0.1 秒口径,语义不变。 + venusConjunctionSameInstantDegrees = 5.0e-2 ) func venusSunLongitudeDelta(jde float64) float64 { @@ -34,34 +39,6 @@ func venusSunLongitudeDeltaN(jde float64, n int) float64 { return sub } -func venusConjunctionAngleDelta(diff float64) float64 { - diff = Limit360(diff) - if diff > 180 { - diff -= 360 - } - if diff < -180 { - diff += 360 - } - return diff -} - -func venusConjunctionHeliocentricDelta(jd, targetDeg float64, n int) float64 { - planetLo := planet.WherePlanetN(2, 0, jd, n) - earthLo := planet.WherePlanetN(-1, 0, jd, n) - return venusConjunctionAngleDelta(planetLo - earthLo - targetDeg) -} - -func venusSunRADelta(jde float64) float64 { - sub := Limit360(VenusApparentRa(jde) - SunApparentRa(jde)) - if sub > 180 { - sub -= 360 - } - if sub < -180 { - sub += 360 - } - return sub -} - func venusRADerivative(jde, val float64) float64 { sub := VenusApparentRa(jde+val) - VenusApparentRa(jde-val) if sub > 180 { @@ -73,17 +50,6 @@ func venusRADerivative(jde, val float64) float64 { return sub / (2 * val) } -func venusRADerivativeN(jde, val float64, n int) float64 { - sub := VenusApparentRaN(jde+val, n) - VenusApparentRaN(jde-val, n) - if sub > 180 { - sub -= 360 - } - if sub < -180 { - sub += 360 - } - return sub / (2 * val) -} - func venusRAContinuousForMax(jde float64) float64 { ra := VenusApparentRa(jde) if ra < 180 { @@ -165,35 +131,6 @@ func venusSunElongationN(jde float64, n int) float64 { return StarAngularSeparation(lo1, bo1, lo2, bo2) } -func venusTrueElongationN(jde float64, n int) float64 { - earth := mercuryHelioN(-1, jde, n) - planetPos := mercuryHelioN(2, jde, n) - geo := mercuryGeocentric(planetPos, earth) - return StarAngularSeparation(geo.lo, geo.bo, HSunTrueLoN(jde, n), HSunTrueBoN(jde, n)) -} - -func venusElongationDerivative(jde, val float64) float64 { - sub := VenusSunElongation(jde+val) - VenusSunElongation(jde-val) - if sub > 180 { - sub -= 360 - } - if sub < -180 { - sub += 360 - } - return sub / (2 * val) -} - -func venusElongationDerivativeN(jde, val float64, n int) float64 { - sub := venusSunElongationN(jde+val, n) - venusSunElongationN(jde-val, n) - if sub > 180 { - sub -= 360 - } - if sub < -180 { - sub += 360 - } - return sub / (2 * val) -} - func venusConjunction(jde float64, next uint8) float64 { if !isFiniteFloat(jde) { return math.NaN() @@ -205,7 +142,7 @@ func venusConjunction(jde float64, next uint8) float64 { } left := queryTT leftVal := venusSunLongitudeDeltaN(left, venusEventSearchN) - if math.Abs(venusSunLongitudeDelta(queryTT)) <= 30.0/86400.0 { + if math.Abs(venusSunLongitudeDelta(queryTT)) <= venusConjunctionSameInstantDegrees { if exact, ok := venusConjunctionRefine(left, 1.0); ok { eventUT := TD2UT(exact, false) if next == 0 && eventUTQueryBeforeOrEqual(eventUT, queryTT) { @@ -226,7 +163,9 @@ func venusConjunction(jde float64, next uint8) float64 { if exact, ok := venusConjunctionRefine(center, halfWindow); ok { return TD2UT(exact, false) } - return math.NaN() + // 截断级数在根附近的符号可能与全项不一致(查询几乎正好落在合上), + // 此时改用全项级数做方向扫描兜底,而不是直接有界失败。 + return venusConjunctionFullDirectionalScan(queryTT, direction) } left = right leftVal = rightVal @@ -239,6 +178,36 @@ func venusConjunction(jde float64, next uint8) float64 { return math.NaN() } +// venusConjunctionFullDirectionalScan 全项级数的方向扫描(截断括号未被确认时的兜底)。 +func venusConjunctionFullDirectionalScan(queryTT, direction float64) float64 { + const ( + step = 8.0 + maxSteps = 80 + ) + left := queryTT + leftVal := venusSunLongitudeDelta(left) + if !isFiniteFloat(leftVal) { + return math.NaN() + } + for i := 0; i < maxSteps; i++ { + right := queryTT + direction*step*float64(i+1) + rightVal := venusSunLongitudeDelta(right) + if !isFiniteFloat(rightVal) { + return math.NaN() + } + if leftVal == 0 || rightVal == 0 || leftVal*rightVal < 0 { + center := (left + right) / 2.0 + if exact, ok := venusConjunctionRefine(center, math.Abs(right-left)/2.0); ok { + return TD2UT(exact, false) + } + return math.NaN() + } + left = right + leftVal = rightVal + } + return math.NaN() +} + func venusConjunctionRefine(seed, halfWindow float64) (float64, bool) { leftJD := seed - halfWindow centerJD := seed @@ -315,62 +284,13 @@ func LastVenusSuperiorConjunction(jde float64) float64 { return lastVenusTypedConjunction(jde, false) } -func venusRetrograde(jde float64) float64 { - //0=last 1=next - if !isFiniteFloat(jde) { - return math.NaN() - } - lastHe := LastVenusConjunctionStrict(jde) - nextHe := NextVenusConjunctionStrict(jde) - nowSub := venusSunRADelta(jde) - if nowSub > 0 { - jde = lastHe + ((nextHe - lastHe) / 5.0 * 3.5) - } else { - jde = lastHe + 10 - } - found := false - for i := 0; i < eventDirectionalSearchIterations; i++ { - nowSub := venusRADerivativeN(jde, 1.0/86400.0, venusEventSearchN) - if !isFiniteFloat(nowSub) { - return math.NaN() - } - if math.Abs(nowSub) > 0.5 { - jde += 5 - continue - } - found = true - break - } - if !found { - return math.NaN() - } - JD1 := jde - var ok bool - JD1, ok = eventNewtonRefine(JD1, 20.0/86400.0, func(JD0 float64) float64 { - stDegree := venusRADerivative(JD0, 0.5/86400.0) - stDegreep := (venusRADerivative(JD0+10.0/86400.0, 0.5/86400.0) - venusRADerivative(JD0-10.0/86400.0, 0.5/86400.0)) / (20.0 / 86400.0) - return stDegree / stDegreep - }) - if !ok { - return math.NaN() - } - min := eventZeroRefine(JD1, 10.0/86400.0, 0.5/86400.0, func(jd float64) float64 { - return venusRADerivative(jd, 0.5/86400.0) - }) - //fmt.Println((min - lastHe) / (nextHe - lastHe)) - return TD2UT(min, false) -} - func NextVenusRetrograde(jde float64) float64 { p2r := NextVenusProgradeToRetrograde(jde) r2p := NextVenusRetrogradeToPrograde(jde) if sameEventJD(p2r, r2p) { return p2r } - if p2r < r2p { - return p2r - } - return r2p + return earliestFiniteEventUT(p2r, r2p) } func LastVenusRetrograde(jde float64) float64 { @@ -379,10 +299,7 @@ func LastVenusRetrograde(jde float64) float64 { if sameEventJD(p2r, r2p) { return p2r } - if p2r > r2p { - return p2r - } - return r2p + return latestFiniteEventUT(p2r, r2p) } func venusStationInWindow(start, end float64, progradeToRetrograde bool) float64 { @@ -402,9 +319,48 @@ func venusStationInWindow(start, end float64, progradeToRetrograde bool) float64 return -venusRAContinuousForMin(jd) }) } + // 抛物顶点法 8 次迭代后仍有秒级残差(实测最差 2.4 s),会让「查询落在站前 1 秒」 + // 这类边界查询跳到下一个会合周期。这里再用 RA 变化率的零点做一次割线抛光, + // 把站时刻精度压到亚秒级;抛光失败(括号内无异号)时保留顶点法结果。 + if polished, ok := venusStationPolish(best, progradeToRetrograde); ok { + best = polished + } return TD2UT(best, false) } +// venusStationPolish 在抛物顶点结果附近求 RA 变化率的零点。 +// progradeToRetrograde=true 时 RA 变化率由正转负(极大值),否则由负转正(极小值)。 +func venusStationPolish(seed float64, progradeToRetrograde bool) (float64, bool) { + rate := func(jd float64) float64 { return venusRADerivative(jd, stationDerivativeStepDay) } + for _, halfWindow := range []float64{120.0 / 86400.0, 600.0 / 86400.0} { + leftJD := seed - halfWindow + rightJD := seed + halfWindow + leftValue := rate(leftJD) + rightValue := rate(rightJD) + if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) { + return math.NaN(), false + } + if leftValue == 0 { + return leftJD, true + } + if rightValue == 0 { + return rightJD, true + } + if leftValue*rightValue > 0 { + continue + } + if progradeToRetrograde != (leftValue > 0) { + return math.NaN(), false + } + root, ok := eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, 0.01/86400.0, rate) + if !ok { + return math.NaN(), false + } + return root, true + } + return math.NaN(), false +} + func venusProgradeToRetrogradeAroundInferior(inferior float64) float64 { return venusStationInWindow(inferior-30.0, inferior-14.0, true) } @@ -420,7 +376,7 @@ func NextVenusProgradeToRetrograde(jde float64) float64 { if !isFiniteFloat(date) { return math.NaN() } - if eventUTQueryAfterOrEqual(date, jde) { + if stationUTQueryAfterOrEqual(date, jde) { return date } inferior = NextVenusInferiorConjunction(eventUTNextQueryTT(inferior)) @@ -435,7 +391,7 @@ func NextVenusRetrogradeToPrograde(jde float64) float64 { if !isFiniteFloat(date) { return math.NaN() } - if eventUTQueryAfterOrEqual(date, jde) { + if stationUTQueryAfterOrEqual(date, jde) { return date } inferior = NextVenusInferiorConjunction(eventUTNextQueryTT(inferior)) @@ -450,7 +406,7 @@ func LastVenusProgradeToRetrograde(jde float64) float64 { if !isFiniteFloat(date) { return math.NaN() } - if eventUTQueryBeforeOrEqual(date, jde) { + if stationUTQueryBeforeOrEqual(date, jde) { return date } inferior = LastVenusInferiorConjunction(eventUTLastQueryTT(inferior)) @@ -465,7 +421,7 @@ func LastVenusRetrogradeToPrograde(jde float64) float64 { if !isFiniteFloat(date) { return math.NaN() } - if eventUTQueryBeforeOrEqual(date, jde) { + if stationUTQueryBeforeOrEqual(date, jde) { return date } inferior = LastVenusInferiorConjunction(eventUTLastQueryTT(inferior)) @@ -480,13 +436,15 @@ func VenusSunElongation(jde float64) float64 { return StarAngularSeparation(lo1, bo1, lo2, bo2) } +// venusGreatestElongationInWindow 求窗口内大距:目标是公开 VenusSunElongation 的极大(视距角), +// 而不是忽略光行差/视位置修正的真距角,否则返回的时刻不是调用方能量到的那个极值。 +// 窗口两端是世界时,目标函数收力学时,因此逐次换算。 func venusGreatestElongationInWindow(start, end float64) float64 { - best := maximizeInWindow(start, end, 5.0, func(jd float64) float64 { - return venusTrueElongationN(jd, venusEventSearchN) - }, func(jd float64) float64 { - return venusTrueElongationN(jd, -1) + return maximizeInWindow(start, end, 5.0, func(utJD float64) float64 { + return venusSunElongationN(TD2UT(utJD, true), venusEventSearchN) + }, func(utJD float64) float64 { + return VenusSunElongation(TD2UT(utJD, true)) }) - return TD2UT(best, false) } func venusEastElongationWindowEndingAt(inferior float64) (float64, float64) { @@ -587,39 +545,57 @@ func lastVenusGreatestElongationTyped(jde float64, east bool) float64 { return math.NaN() } -func venusGreatestElongation(jde float64) float64 { +// venusElongationWindowAt 返回包含 jde 的该侧大距窗口;查询落在合的 4 秒内边距里时两侧都不包含。 +func venusElongationWindowAt(jde float64, east bool) (float64, float64, bool) { + if east { + start, end := venusEastElongationWindowEndingAt(NextVenusInferiorConjunction(jde)) + return start, end, eventUTQueryBeforeOrEqual(start, jde) && eventUTQueryAfterOrEqual(end, jde) + } + start, end := venusWestElongationWindowEndingAt(NextVenusSuperiorConjunction(jde)) + return start, end, eventUTQueryBeforeOrEqual(start, jde) && eventUTQueryAfterOrEqual(end, jde) +} + +// 无东西侧参数的 Next/Last 先只看查询所在窗口那一侧:该侧大距未过就是答案,已过则另一侧紧接着的 +// 下一个才是答案,因此只有在查询贴住合(两侧窗口都不含)时才退化为两侧都算。赤经差在合附近会提前 +// 变号,不能用它定窗口。 +func NextVenusGreatestElongation(jde float64) float64 { if !isFiniteFloat(jde) { return math.NaN() } - east := venusSunRADelta(jde) > 0 - if east { - return nextVenusGreatestElongationTyped(jde, true) + for _, east := range [2]bool{true, false} { + start, end, ok := venusElongationWindowAt(jde, east) + if !ok { + continue + } + if date := venusGreatestElongationInWindow(start, end); isFiniteFloat(date) && eventUTQueryAfterOrEqual(date, jde) { + return date + } + if date := nextVenusGreatestElongationTyped(jde, !east); isFiniteFloat(date) && eventUTQueryAfterOrEqual(date, jde) { + return date + } + break } - return nextVenusGreatestElongationTyped(jde, false) -} - -func NextVenusGreatestElongation(jde float64) float64 { - east := NextVenusGreatestElongationEast(jde) - west := NextVenusGreatestElongationWest(jde) - if sameEventJD(east, west) { - return east - } - if east < west { - return east - } - return west + return earliestFiniteEventUT(nextVenusGreatestElongationTyped(jde, true), nextVenusGreatestElongationTyped(jde, false)) } func LastVenusGreatestElongation(jde float64) float64 { - east := LastVenusGreatestElongationEast(jde) - west := LastVenusGreatestElongationWest(jde) - if sameEventJD(east, west) { - return east + if !isFiniteFloat(jde) { + return math.NaN() } - if east > west { - return east + for _, east := range [2]bool{true, false} { + start, end, ok := venusElongationWindowAt(jde, east) + if !ok { + continue + } + if date := venusGreatestElongationInWindow(start, end); isFiniteFloat(date) && eventUTQueryBeforeOrEqual(date, jde) { + return date + } + if date := lastVenusGreatestElongationTyped(jde, !east); isFiniteFloat(date) && eventUTQueryBeforeOrEqual(date, jde) { + return date + } + break } - return west + return latestFiniteEventUT(lastVenusGreatestElongationTyped(jde, true), lastVenusGreatestElongationTyped(jde, false)) } func LastVenusInferiorConjunctionInclusive(jde float64) float64 { diff --git a/calendar/chinese.go b/calendar/chinese.go index 6135e67..8a37507 100644 --- a/calendar/chinese.go +++ b/calendar/chinese.go @@ -155,6 +155,18 @@ func innerSolarToLunar(date time.Time) (Time, error) { } func innerSolarToLunarByYMD(year, month, day int) (Time, error) { + result, err := innerSolarToLunarByYMDRaw(year, month, day) + if err != nil { + return result, err + } + // 输入为儒略历独有闰日时补记 JulianOnly/JDE。 + if phantom, ok := julianOnlyPhantomJDE(year, month, day); ok { + result = markJulianOnly(result, phantom) + } + return result, nil +} + +func innerSolarToLunarByYMDRaw(year, month, day int) (Time, error) { if year < ancientMinYear || year > 9999 { return Time{}, fmt.Errorf("日期超出范围") } @@ -250,6 +262,14 @@ func LunarToSolar(desc string) ([]Time, error) { } results = append(results, date) } + // 字符串入口先把描述解析成 time.Time,儒略历独有闰日会偏一天,这里按闰日复核一次。 + if parsed, perr := parseChineseDate(desc); perr == nil && parsed.LunarMonth() != 0 && parsed.LunarDay() != 0 { + for i := range results { + if fixed, ok := julianOnlyTextResult(results[i], parsed.LunarMonth(), parsed.LunarDay(), parsed.IsLeap()); ok { + results[i] = fixed + } + } + } return results, nil } @@ -289,6 +309,39 @@ func LunarToSolarSingle(year, month, day int, leap bool) (Time, error) { // For years [-103, 1912], the lunar month index follows the historical calendar in force at that time, counted from the first month of that year. // Years (1912, 3000] use the current GB/T 33661-2017 lunar-calendar convention. func LunarToSolarByYMD(year, month, day int, leap bool) (Time, error) { + result, err := lunarToSolarByYMD(year, month, day, leap) + if err != nil { + return result, err + } + return finalizeLunarToSolarResult(result, year, month, day, leap), nil +} + +// finalizeLunarToSolarResult 补记儒略历独有闰日与多公历候选。 +func finalizeLunarToSolarResult(result Time, year, month, day int, leap bool) Time { + if lunarDateMatches(result, year, month, day, leap) { + result.solarCandidates = lunarToSolarCandidates(year, month, day, leap, result) + return result + } + // 主答案对不上请求:儒略历独有的 2 月 29 日(正向序列在那里缺一号),或改历双纪年里 + // 主偏移落在了另一套纪年上(王莽 23/24 年交界)。 + if phantom, ok := julianOnlyPhantomJDE(result.Solar().Year(), 2, 29); ok { + return markJulianOnlyLunar(result, year, month, day, leap, phantom) + } + alternates := lunarToSolarAlternates(year, month, day, leap) + if len(alternates) == 0 { + return result + } + primary, err := SolarToLunar(alternates[0]) + if err != nil { + return result + } + if len(alternates) > 1 { + primary.solarCandidates = alternates + } + return primary +} + +func lunarToSolarByYMD(year, month, day int, leap bool) (Time, error) { if year < ancientBoundaryMinYear || year > 9999 { return Time{}, fmt.Errorf("年份超出范围") } @@ -321,6 +374,186 @@ func LunarToSolarByYMD(year, month, day int, leap bool) (Time, error) { return SolarToLunar(date) } +// julianOnlyCivilDay 报告该日期是否只存在于儒略历( 1582 年前百年非 400 闰年的 2 月 29 日)。 +func julianOnlyCivilDay(year, month, day int) bool { + if month != 2 || day != 29 { + return false + } + if basic.ValidateCivilDate(year, month, float64(day)) != nil { + return false + } + return time.Date(year, time.February, 29, 0, 0, 0, 0, time.UTC).Month() != time.February +} + +// julianOnlyPhantomJDE 返回儒略历独有闰日的儒略日。 +func julianOnlyPhantomJDE(year, month, day int) (float64, bool) { + if !julianOnlyCivilDay(year, month, day) { + return 0, false + } + return basic.JDECalc(year, month, float64(day)), true +} + +// julianOnlyTextResult 复核字符串入口的结果是否落在儒略历独有闰日上,是则返回该闰日的结果。 +func julianOnlyTextResult(result Time, month, day int, leap bool) (Time, bool) { + solar := result.Solar() + year := solar.Year() + if !julianOnlyCivilDay(year, 2, 29) { + return result, false + } + diff := Date2JDE(solar) - basic.JDECalc(year, 2, 29) + switch diff { + case 1, -1: + // +1 表示结果是闰日的标签日,-1 表示结果是闰日的前一天。 + default: + return result, false + } + forward, err := innerSolarToLunarByYMD(year, 2, 29) + if err != nil { + return result, false + } + lunar := forward.Lunar() + // 只比日号与闰月标志(改历期间描述用年号月序、记录存规范月序),且与结果的农历日号相差一天。 + if lunar.LunarDay() != day || lunar.IsLeap() != leap { + return result, false + } + resultDay := result.Lunar().LunarDay() + if diff > 0 { + // 结果是闰日的标签日,农历上比闰日晚一天。 + if resultDay != day+1 && !(resultDay == 1 && day >= 29) { + return result, false + } + } else { + // 结果是闰日的前一天,农历上比闰日早一天。 + if resultDay != day-1 && !(day == 1 && resultDay >= 29) { + return result, false + } + } + return forward, true +} + +// markJulianOnly 补记儒略历独有闰日:Solar 取规范标签(后继日),精确日期放入 JDE。 +func markJulianOnly(result Time, phantomJDE float64) Time { + label := basic.JDE2DateByZone(phantomJDE, getCst(), true) + result.solarTime = label + for i := range result.lunars { + result.lunars[i].solarDate = label + result.lunars[i].julianOnly = true + result.lunars[i].jde = phantomJDE + } + return result +} + +// markJulianOnlyLunar 反向路径用:用正向记录改写停在标签日的农历身份,并补记闰日标志。 +func markJulianOnlyLunar(result Time, year, month, day int, leap bool, phantomJDE float64) Time { + label := basic.JDE2DateByZone(phantomJDE, getCst(), true) + result = markJulianOnly(result, phantomJDE) + forward, err := innerSolarToLunarByYMD(label.Year(), 2, 29) + if err != nil || !lunarDateMatches(forward, year, month, day, leap) { + return result + } + lunars := make([]LunarTime, len(forward.lunars)) + copy(lunars, forward.lunars) + for i := range lunars { + lunars[i].julianOnly = true + lunars[i].jde = phantomJDE + } + result.lunars = lunars + return result +} + +// markJulianOnlyJDN 在 JDN 层面判定儒略历独有闰日(古历与秦汉反向直接由 JDN 造记录)。 +func markJulianOnlyJDN(result Time, jdn int) Time { + label := basic.JDE2DateByZone(float64(jdn)-0.5, getCst(), true) + phantom, ok := julianOnlyPhantomJDE(label.Year(), 2, 29) + if !ok || int(math.Floor(phantom+0.5)) != jdn { + return result + } + return markJulianOnly(result, phantom) +} + +// lunarDateMatches 报告结果里是否有记录正是该农历日。 +func lunarDateMatches(result Time, year, month, day int, leap bool) bool { + for _, l := range result.Lunars() { + if l.LunarYear() == year && l.LunarMonth() == month && l.LunarDay() == day && l.IsLeap() == leap { + return true + } + } + return false +} + +// lunarReformYear 报告该年是否处于改历双纪年窗口(王莽/魏明帝/武则天/唐肃宗)。 +func lunarReformYear(year int) bool { + return (year >= 9 && year <= 23) || (year >= 237 && year <= 240) || + (year >= 689 && year <= 700) || (year >= 761 && year <= 762) +} + +// lunarToSolarCandidates 收集该农历日的全部合法公历候选,主答案在前;单候选返回 nil。 +func lunarToSolarCandidates(year, month, day int, leap bool, primary Time) []time.Time { + candidates := []time.Time{primary.Solar()} + for _, date := range lunarToSolarAlternates(year, month, day, leap) { + duplicated := false + for _, existing := range candidates { + if existing.Equal(date) { + duplicated = true + break + } + } + if !duplicated { + candidates = append(candidates, date) + } + } + if len(candidates) == 1 { + return nil + } + return candidates +} + +// lunarToSolarAlternates 收集另一套改历月序(或颛顼历)口径下能正向回到该农历日的公历日。 +func lunarToSolarAlternates(year, month, day int, leap bool) []time.Time { + var alternates []time.Time + appendIfLegal := func(result Time, ok bool) { + if !ok || !lunarDateMatches(result, year, month, day, leap) { + return + } + date := result.Solar() + for _, existing := range alternates { + if existing.Equal(date) { + return + } + } + alternates = append(alternates, date) + } + switch { + case year <= qinHanMaxYear: + // 太初改历交接:颛顼历与默认(太初)口径各是一个候选。 + result, ok := lunarToSolarQinHan(year, month, day, leap) + if ok { + appendIfLegal(tagCalendar(result, AncientCalendarQinHan, ancientCalendarName(AncientCalendarQinHan)), true) + } + case year <= 1912: + // 改历双纪年:换另一套月序偏移(0/1/2,255 在 rapidSolarHan2Qing 内部等价于 2)再算一次。 + if !lunarReformYear(year) { + break + } + primaryDiff := yearDiffLunar(year, month, day) + if primaryDiff == 255 { + primaryDiff = 2 + } + for _, diff := range []int{0, 1, 2} { + if diff == primaryDiff { + continue + } + date := rapidSolarHan2Qing(year, month, day, leap, diff, nil) + if date.IsZero() { + continue + } + result, err := SolarToLunar(date) + appendIfLegal(result, err == nil) + } + } + return alternates +} + func lunarToSolarHanQingDefault(year, month, day int, leap bool) (Time, bool) { date := rapidSolarHan2Qing(year, month, day, leap, yearDiffLunar(year, month, day), nil) if date.IsZero() { @@ -514,6 +747,12 @@ var chineseMonths = map[string]int{ "七": 7, "八": 8, "九": 9, "十": 10, } +// 两个正则提到包级:文本入口每次解析都会用到,每次重编译会白花几十微秒。 +var ( + chineseDatePrefixYearPattern = regexp.MustCompile(`^([-一二三四五六七八九十零〇\d]+?)年`) + chineseDatePattern = regexp.MustCompile(`^([\p{Han}]+?)([-负負一二三四五六七八九十零〇\d]*?元?)年([\p{Han}\d]+?)月([\p{Han}\d]+?)日?$`) +) + func parseChineseDate(dateStr string) (LunarTime, error) { var result LunarTime var err error @@ -521,7 +760,7 @@ func parseChineseDate(dateStr string) (LunarTime, error) { if strings.HasPrefix(dateStr, "前") { originDateStr := dateStr dateStr = strings.TrimPrefix(dateStr, "前") - re := regexp.MustCompile(`^([-一二三四五六七八九十零〇\d]+?)年`) + re := chineseDatePrefixYearPattern matches := re.FindStringSubmatch(dateStr) if len(matches) == 2 { year, err := parseDirectYear(matches[1]) @@ -535,7 +774,7 @@ func parseChineseDate(dateStr string) (LunarTime, error) { } dateStr = "公元" + dateStr // 正则表达式匹配日期格式 - re := regexp.MustCompile(`^([\p{Han}]+?)([-负負一二三四五六七八九十零〇\d]*?元?)年([\p{Han}\d]+?)月([\p{Han}\d]+?)日?$`) + re := chineseDatePattern matches := re.FindStringSubmatch(dateStr) if len(matches) < 5 { return result, fmt.Errorf("无效的日期格式: %s", dateStr) @@ -741,7 +980,11 @@ func GanZhiOfYear(year int) string { // GanZhiOfDay 日干支 / sexagenary day name. func GanZhiOfDay(t time.Time) string { - jde := Date2JDE(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, getCst())) + return ganZhiOfJDE(Date2JDE(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, getCst()))) +} + +// ganZhiOfJDE 由儒略日直接求日干支(儒略历独有闰日无法表示为 time.Time)。 +func ganZhiOfJDE(jde float64) string { diff := int(jde - 2451550.5) if diff >= 0 { return tiangan[diff%10] + dizhi[diff%12] diff --git a/calendar/chineseAncient.go b/calendar/chineseAncient.go index 520af1c..5877f80 100644 --- a/calendar/chineseAncient.go +++ b/calendar/chineseAncient.go @@ -140,11 +140,12 @@ func LunarToSolarByYMDWithCalendar(year, month, day int, leap bool, system Ancie return Time{}, fmt.Errorf("日期超出范围") } lmonth.day = day - date := ancientJDNToDate(lmonth.startJDN + day - 1) + jdn := lmonth.startJDN + day - 1 + date := ancientJDNToDate(jdn) if !ancientSolarYearInRange(date.Year()) { return Time{}, fmt.Errorf("未找到对应日期") } - return ancientTime(date, lmonth), nil + return markJulianOnlyJDN(ancientTime(date, lmonth), jdn), nil } func calendricalJieQiWithCalendar(year, term int, system AncientCalendarSystem) (time.Time, error) { @@ -284,16 +285,46 @@ func lunarToSolarAncientDefault(year, month, day int, leap bool) (Time, bool) { if !ok { return Time{}, false } + if result, ok := lunarToSolarAncientWithDefault(year, month, day, leap, system); ok { + return result, true + } + // 岁首跨公历年时正向按公历年选古历、反向只能按农历年选,两者会差一天; + // 这里换另一种默认古历再试,并要求与正向的选历一致。 + if alternate, ok := ancientAlternateDefaultSystem(system); ok { + if result, ok := lunarToSolarAncientWithDefault(year, month, day, leap, alternate); ok { + return result, true + } + } + return Time{}, false +} + +// lunarToSolarAncientWithDefault 用指定古历求农历日,并要求与正向的默认选历一致。 +func lunarToSolarAncientWithDefault(year, month, day int, leap bool, system AncientCalendarSystem) (Time, bool) { result, err := LunarToSolarByYMDWithCalendar(year, month, day, leap, system) if err != nil { return Time{}, false } - if !ancientSolarYearInRange(result.Solar().In(getCst()).Year()) { + solarYear := result.Solar().In(getCst()).Year() + if !ancientSolarYearInRange(solarYear) { + return Time{}, false + } + if forwardSystem, ok := defaultAncientCalendarSystemForYear(solarYear); !ok || forwardSystem != system { return Time{}, false } return result, true } +// ancientAlternateDefaultSystem 返回另一种默认古历(春秋 ↔ 周),用于岁首跨界回退。 +func ancientAlternateDefaultSystem(system AncientCalendarSystem) (AncientCalendarSystem, bool) { + switch system { + case AncientCalendarChunqiu: + return AncientCalendarZhou, true + case AncientCalendarZhou: + return AncientCalendarChunqiu, true + } + return AncientCalendarDefault, false +} + func innerSolarToLunarByYMDWithCalendar(year, month, day int, hmi time.Time, system AncientCalendarSystem) (Time, error) { if system == AncientCalendarQinHan { if err := validateQinHanCalendarSolarInput(year, month, day); err != nil { diff --git a/calendar/chineseCalendricalJieQi.go b/calendar/chineseCalendricalJieQi.go index cbed444..cd7ad9d 100644 --- a/calendar/chineseCalendricalJieQi.go +++ b/calendar/chineseCalendricalJieQi.go @@ -3,6 +3,7 @@ package calendar import ( "fmt" "math" + "sync" "time" "b612.me/astro/basic" @@ -58,22 +59,35 @@ func hanQingJieQiPatternDelta(patternID, pos int) int { return 15 } +// hanQingJieQiPatternExceptions 是未排序的 95 项例外表,先缓存成 map 再查,避免每次线性扫描。 +var ( + hanQingJieQiPatternExceptionOnce sync.Once + hanQingJieQiPatternExceptionByKey map[int]int +) + func hanQingJieQiPatternExceptionDelta(key int) (int, bool) { - for _, item := range hanQingJieQiPatternExceptions { - if int(item&0x0FFF) != key { - continue + hanQingJieQiPatternExceptionOnce.Do(func() { + m := make(map[int]int, len(hanQingJieQiPatternExceptions)) + for _, item := range hanQingJieQiPatternExceptions { + k := int(item & 0x0FFF) + if _, exists := m[k]; exists { + continue + } + switch item >> 12 { + case 0: + m[k] = 12 + case 1: + m[k] = 14 + case 2: + m[k] = 17 + default: + m[k] = 15 + } } - switch item >> 12 { - case 0: - return 12, true - case 1: - return 14, true - case 2: - return 17, true - } - return 15, true - } - return 0, false + hanQingJieQiPatternExceptionByKey = m + }) + delta, ok := hanQingJieQiPatternExceptionByKey[key] + return delta, ok } func packedBits(data []byte, offset, width int) int { diff --git a/calendar/chineseHantoQing.go b/calendar/chineseHantoQing.go index bcea599..b04ea76 100644 --- a/calendar/chineseHantoQing.go +++ b/calendar/chineseHantoQing.go @@ -9,11 +9,11 @@ import ( var ERR_NIANHAO_NOT_FOUND = errors.New("ERROR:未找到对应的年号") -func getHanQingCals() []uint32 { +var getHanQingCals = memoUint32s(func() []uint32 { return []uint32{ 2865769984, 1431610368, 3310368000, 1788881408, 2874351360, 2865771008, 1431316224, 1789500416, 1520447232, 2866160640, 2862626304, 3578800896, 1521262848, 1436562432, 2863671296, 1431477760, 3041931264, 1436558848, 2863700480, 2862624000, 3042255360, 2907714816, 1432365568, 2863374080, 1788883456, 2907711232, 1432984576, 1431318272, 2863506432, 1520449280, 2865769216, 1432101120, 1431319552, 1520445696, 2874318080, 2863673344, 1431315968, 1520999168, 1453337856, 2863964672, 2862626048, 3578800640, 1454087936, 1432367616, 2862622464, 3578928128, 2907713024, 1437475840, 1431320320, 2862623744, 2907971584, 2874159872, 1431316736, 2863308288, 1788882944, 2874156288, 1431902976, 1431318016, 1789731328, 1453339904, 2865768960, 1431511040, 3578802432, 1453336320, 2865863680, 2862624512, 3579192064, 3041932800, 1436560384, 2863407360, 2862625536, 3041929216, 1437212160, 1432367360, 2862621952, 3041958400, 2874158336, 1433215744, 1431319808, 1788881408, 2907905792, 2865771008, 1431316224, 1789500416, 1520447232, 2866160640, 2863674880, 1431317504, 1521262848, 1436562432, 2863671296, 1431477760, 3041931264, 1453336064, 2863700480, 2862624000, 3042255360, 2907714816, 1432365568, 2863374080, 2862625280, 2907711232, 1432984576, 1431318272, 2863506432, 1520449280, 2874157824, 1432101120, 1431319552, 1520445696, 2874318080, 2863673344, 1431315968, 1789434624, 1453337856, 2863964672, 2862626048, 3578800640, 1454087936, 1436561920, 2862622464, 3578928128, 2907713024, 1437475840, 1431320320, 2862623744, 3042189312, 2874159872, 1431316736, 2863308288, 1788882944, 2874156288, 1432951552, 1431318016, 1789731328, 1453339904, 2865768960, 1431511040, 3578802432, 1520445184, 2865863680, 2862624512, 3579192064, 3041932800, 1436560384, 2863931648, 2862625536, 3041929216, 1437212160, 1432367360, 2862621952, 3041958400, 2907712768, 1433215744, 1431319808, 1788881408, 2907905792, 2865771008, 1431316224, 2863242240, 1520447232, 2866160640, 2863674880, 1431317504, 1521262848, 1453339648, 2863671296, 1431477760, 3041931264, 1453336064, 2863700480, 2862624000, 3579126272, 2907714816, 1432365568, 2863374080, 2862625280, 2907711232, 1437178880, 1431318272, 2863506432, 1520449280, 2874157824, 1432101120, 1431319552, 1788881152, 2874318080, 2863673344, 1431315968, 1788910336, 2862623744, 3042255104, 2874160128, 1432365568, 2863373824, 1788883200, 2907710976, 1432984576, 1431318016, 1789764352, 1520449024, 2865769216, 1432100864, 1431319296, 1520445440, 2865929472, 2863673088, 1431315712, 1520998912, 1453337856, 2863964416, 2862625792, 3041929472, 1454087936, 1432367360, 2862622208, 3578927872, 2907713024, 1433281280, 1431320064, 2862623488, 2907971584, 2874159616, 1431316480, 2863308032, 1520447488, 2874156032, 1431902720, 1431317760, 1789731328, 1453339648, 2863671552, 1431510784, 3578802432, 1453336064, 2865863424, 2862624256, 3579192064, 2907714816, 1436560128, 2863407104, 2862625536, 3041928960, 1437211904, 1431318528, 2862621952, 3041958144, 2874158080, 1433215488, 1431319808, 1788881152, 2874351104, 2865770752, 1431316224, 1789500160, 1453338112, 2866160384, 2862626304, 3578800640, 1521262592, 1436562176, 2862622720, 3578960896, 3041931008, 1436558592, 2863700480, 2862623744, 3042255104, 2874160128, 1432365568, 2863373824, 1788883200, 2907710976, 1432984576, 1431318016, 1789764352, 1520449024, 2865769216, 1432100864, 1431319296, 1520445440, 2865929472, 2863673088, 1431315712, 1520998912, 1453337856, 2863964416, 2862625792, 3041929472, 1454087936, 1432367360, 2862622208, 3578927872, 2907713024, 1433281280, 1431320064, 2862623488, 2907971584, 2874159616, 1431316480, 2863308032, 1520447488, 2874156032, 1431902720, 1431317760, 1789731328, 1453339648, 2863671552, 1431510784, 3578802432, 1453336064, 2865863424, 2862624256, 3579192064, 2907714816, 1436560128, 2863407104, 2862625536, 3041928960, 1437211904, 1431318528, 2862621952, 3041958144, 2874158080, 1433215488, 1431319808, 1788881152, 2874351104, 2865770752, 1431316224, 1789500160, 1453338112, 2866160384, 2862626304, 3578800640, 1521262592, 1436562176, 2862622720, 3578960896, 3041931008, 1436558592, 2863700480, 3041929984, 1454186752, 1432368128, 2862622976, 3042123008, 2907713536, 1432364544, 2863241984, 1788882176, 2908103168, 2865771776, 1431317248, 1789697792, 1453339136, 2865768192, 1431477504, 3578801664, 1453335552, 2865797376, 2862623744, 3579125760, 2907714304, 1436559616, 2863373824, 2862624768, 2907710720, 1437178624, 1431318016, 2863505920, 1520448768, 2874157568, 1432100864, 1431319040, 1520445184, 2874317824, 2863673088, 1431315456, 1520998656, 1453337600, 2863964416, 2862625536, 3578800128, 1454087680, 1432367360, 2862621952, 3042056704, 2907712768, 1433281280, 1431319808, 1788881408, 2907971328, 2865771008, 1431316224, 1789565952, 1520447232, 2865767424, 1431378176, 3578800896, 1521295616, 1436562432, 2862622720, 3578993920, 3041931264, 1436558848, 2863241728, 2862624000, 3042320896, 2874160384, 1431316992, 2863406848, 1788883456, 2874156800, 1431968768, 1431318272, 1788879872, 2874185984, 2862623488, 3042254848, 3578802944, 1453336832, 2863897856, 2862624768, 3578799360, 1453955840, 1432366336, 2863505920, 2862626048, 2907712000, 1433149184, 1431319040, 2862622464, 2907872256, 2865769984, 1431315456, 2863175936, 1520446464, 2866094080, 2863674112, 1431316736, 1521196544, 1436561408, 2863670528, 1431444224, 3041930496, 1437475328, 1432368384, 2862623232, 3042189056, 2874159360, 1432364800, 2863307776, 1788882688, 2874155776, 1432951040, 1431317504, 1789731072, 1453339392, 2865768448, 1431510528, 3578802176, 1453335808, 2865863168, 2862624000, 3579191808, 2907714560, 1436559872, 2863406848, 2862625280, 2907710976, 1437211648, 1431318272, 2862621696, 2907740160, 2865769216, 1432166656, 1431319552, 1520445440, 2865995008, 2863673344, 1431315968, 1521064448, 1436560640, 2864062976, 2862626048, 3041929472, 1437376256, 1432367616, 2862622464, 3042089728, 2874158592, 1432364032, 2863175936, 1788881664, 2874482688, 2865771264, 1431316736, 1789631744, 1453338624, 2865767680, 1431411456, 3578801152, 1454219776, 1436562688, 2862623232, 3579059712, 2907713792, 1436559104, 2863307776, 2862624256, 2907710208, 1437145344, 1431317504, 2863472640, 1520448256, 2874157056, 1432034816, 1431318528, 1520444672, 2874251776, 2863672576, 1431708160, 3041932288, 1453337088, 2863931136, 2862625024, 3041928704, 1453988864, 1432366848, 2862621440, 3041957888, 2907712256, 1433215232, 1431319296, 2862622720, 2907905280, 2865770496, 1431315712, 1789499904, 1520446720, 2866160128, 2862625792, 3578800384, 1521262336, 1436561920, 2862622208, 3578960640, 3041930752, 1436558336, 2863306752, 2862623488, 3041927168, 1437144576, 1431316480, 2863471872, 1788882944, 2874156288, 1432033792, 1431317760, 1788879360, 2874251008, 2863671552, 1431707392, 3041931520, 1453336320, 2863962880, 2862624256, 3041927936, 1454020864, 1432365824, 2862620672, 3041957120, 2907711488, 1433246976, 1431318528, 1788880128, 2907937280, 2865769472, 1431314944, 1789499136, 1520445952, 2866159104, 2862625024, 3578799616, 1521261568, 1436560896, 2862621440, 3578959872, 3041929984, 1436557312, 2863174912, 2862622720, 3042254080, 2874158848, 1431315712, 2863372800, 1788882176, 2874155264, 1431967488, 1431316992, 1789796096, 1453338880, 2863670784, 1431575552, 3578801664, 1453335296, 2863831040, 2862623488, 3578798080, 1453921536, 1432365056, 2863471872, 2862624768, 2907710464, 1433082368, 1431317760, 2862621184, 2907805184, 2865768704, 1431707392, 3578802432, 1520444928, 2866027264, 2862624256, 3578798848, 1521129472, 1436560128, 2862620672, 3578860800, 3041928960, 1437408512, 1431318528, 2862621952, 3042121984, 2874158080, 1431314944, 2863240960, 1788881152, 2874547712, 2863673600, 1431316224, 1789696768, 1453338112, 2863670016, 1431476480, 3578800640, 1453334528, 2863699200, 2862622720, 3579124736, 2907713280, 1432364288, 2863372800, 2862623744, 2907709696, 1432983296, 1431316992, 2863504896, 1520447744, 2865767936, 1432099840, 1431318016, 1520444160, 2865928192, 2863672064, 1431314432, 1521030400, 1436559360, 2863996160, 2862624512, 3041928192, 1437276672, 1432366336, 2862620928, 3042055680, 2874157312, 1433280256, 1431318784, 1788880384, 2874415872, 2865769984, 1431315200, 1789564928, 1453337344, 2865766400, 1431377152, 3578799872, 1454218496, 1436561408, 2862621696, 3579025664, 2907712512, 1436557824, 2863240704, 2865768704, 1431707392, 3578802432, 1520444928, 2866092800, 2862624256, 3578798848, 1433049088, 1431317248, 1788878848, 2874184960, 2865768192, 1431641344, 3578801920, 1453335808, 2865993984, 2862623744, 3578798336, 1453954816, 1436559616, 2863504896, 2862625024, 2907710976, 1437375232, 1431318016, 2862621440, 2907871232, 2874157568, 1431314432, 3312522496, 2773493760, 3534560256, 2996842240, 3578799104, 1454086656, 1436560384, 2781880576, 1255773952, 1236281856, 2833128960, 2773496064, 2862622208, 3042188032, 2907712768, 1302340352, 2782009344, 2775591680, 1387274240, 2763592960, 1689266176, 1789730048, 1520447232, 2874156032, 1265834496, 1256205568, 1453334784, 2863764992, 2862622976, 3042319872, 2907713536, 1432364544, 2863438592, 1788882432, 2907709952, 1433049088, 1431317248, 1788878848, 2907739136, 2865768192, 1431641344, 3578801920, 1520444416, 2865993984, 2862623744, 3578798336, 1521063424, 2840602880, 3031309824, 1788883200, 2875204864, 1437899520, 1265643008, 1164977152, 1387960320, 1387277056, 1766858752, 1789498624, 1520445184, 2874514432, 2530225920, 1256203520, 2775816448, 2765106176, 3530563840, 1498584832, 3041929216, 1453334016, 2865795840, 2513446400, 1257020160, 1236282880, 2765103872, 3530201088, 2862623232, 3041926912, 1453921536, 765470720, 2514820096, 2507157248, 1236280576, 2763758080, 1689267200, 1787830528, 2907837952, 1800415488, 727719424, 2511377152, 2473600512, 1237097472, 1248865792, 1766859520, 1788549120, 1520446208, 1792024320, 1265177856, 1256204288, 2775981312, 2463117056, 3396346880, 1420532224, 3030395904, 3113230336, 1532139008, 2530224384, 653139712, 1238380800, 2731550208, 2993462016, 1766862336, 2862621184, 2907903744, 2875206144, 1437605888, 1236899072, 628107264, 1387633920, 3533713152, 3931120384, 1831606528, 1520446464, 2874155264, 2511476224, 2472552704, 3379568128, 1689326592, 3567265024, 3931445760, 3578801408, 1520444160, 2865960704, 2799708672, 2464161792, 2775160064, 2773493504, 3534593024, 1788882688, 3041928192, 1454119168, 1439706112, 634397184, 2463766272, 1389373696, 2840600064, 3030946560, 2862622208, 3042253312, 2874158336, 1268785920, 2779977728, 2742036992, 1383079936, 1689883904, 1429219328, 1789238272, 3667930624, 2605720576, 728504832, 1256205312, 2774540544, 3518666240, 1231038976, 3568311552, 1519424256, 3600819456, 1456806656, 1439706368, 634397440, 2733283328, 2463116032, 2840600320, 3567916032, 2904565504, 3047562240, 2879401472, 1437606912, 1237064192, 1164979456, 1387274496, 2841286144, 1766861056, 1788878336, 2907738624, 2875204864, 1265965824, 1256205824, 2775589376, 1387468800, 3533711872, 3931119360, 1789498368, 1522542336, 2876120064, 383791104, 2329945088, 3312720128, 2841652224, 3567264000, 3646067968, 3041929216, 1453334016, 2865795840, 2781881600, 2463963904, 1387277824, 2832212736, 3568015104, 2996840960, 3041926912, 1453954304, 1302341376, 2780175360, 2775592704, 1387275520, 2830866688, 1697655808, 1788879104, 2874316288, 2874157056, 1264590336, 2775618304, 2775590400, 1366596352, 3511692544, 3576701440, 1788581888, 1453337088, 1523588864, 1265210624, 1256204288, 2733480960, 2731552512, 3511690240, 3568015616, 3038784256, 3047170048, 1456608768, 1437608192, 1238375936, 2765689600, 2729453056, 2858228480, 1783639296, 2904564224, 3043170048, 2875206144, 1437605632, 1169790208, 1164978176, 1656102144, 3568315904, 1783636992, 1789663488, 1520446464, 2875203584, 1399461376, 2464164096, 3311410688, 3567849984, 3567265024, 3646233088, 3041930496, 1453335040, 2865993472, 2781882880, 1389371392, 2841220096, 2840602368, 3534593024, 2862624512, 3041927936, 1437374720, 1268788224, 2511347968, 1387959808, 1236281600, 2830114304, 2996343552, 1788880128, 2908035584, 1800416512, 727720704, 2775783168, 2742036992, 1366302720, 1756501248, 3576702464, 1519787008, 3600821760, 1456481280, 720115968, 1256205312, 2732597504, 3513423360, 3377473536, 3575651584, 3666940416, 3047171328, 1456806400, 1437609216, 1171268352, 2732267520, 2729454080, 2840600320, 3031045120, 1830823680, 2909149952, 2875207168, 1437606912, 1169955328, 1164979200, 1387274496, 1767020032, 3914344448, 1788878080, 2907738624, 2875204864, 1399167744, 3401591808, 3848282368, 3534460672, 3533711872, 3645906432, 1521062912, 1453336320, 2799049728, 2513448448, 1255154688, 2765854976, 2765106176, 2992644096, 3042088192, 3041929216, 1436556800, 2513474048, 2511348992, 1253874432, 1236282880, 2763006464, 2862323456, 1788881408, 2907709184, 1302959104, 727721728, 2507545600, 2742038272, 1366303744, 1689524480, 3568315136, 1519395072, 1800574208, 1456482304, 719330816, 2473661184, 2732598528, 3379370496, 2840603904, 3567264256, 3667105280, 3047172352, 1456480000, 718902528, 634398464, 2464029696, 2461019904, 2840601600, 3031209984, 2896177920, 2907709440, 1437767168, 1269835776, 632299008, 2461602560, 1387275776, 1767709184, 1498426624, 1788879360, 2874382080, 2875205888, 1264590592, 2775684096, 2775590656, 1387666432, 3533712896, 3578798848, 1521260800, 1453337344, 2530222080, 1256887808, 1255155968, 2765102336, 3530101248, 2992645376, 3042253312, 2907712512, 899686912, 2513639168, 2511350272, 1236279040, 2765755392, 2763007744, 2858294272, 1788882432, 1833968384, 766286080, 726674432, 2473598976, 1232246272, 3379570688, 1689263616, 1783697920, 3666879488, 1800739328, 1456483584, 652222976, 2473793280, 2464164352, 3378519808, 3567915776, 3567265280, 3600161536, 2912955904, 1456481024, 651991296, 634399744, 2461016320, 2841285888, 2840602624, 3030392064, 3041956352, 2908759040, 1437964800, 1269837056, 632300288, 1388025600, 1387276800, 1766858496, 1789498368, 1788880384, 2874546944, 2808098304, 1264591872, 2775848960, 2774543104, 3529515008, 3912406272, 3578799872, 1520442624, 2874249728, 2530223360, 1257085440, 1238379776, 2765103616, 3530266368, 2857380096, 3041926656, 1453986816, 899688192, 2513443584, 1255706880, 1236280320, 2763823360, 1689266944, 2857377792, 3041596672, 1800415232, 765467904, 2511409664, 2473600256, 1232476928, 3379571712, 1689264640, 1783895808, 3662686464, 1530928640, 2875855616, 719333120, 2464160768, 3379630080, 3378520832, 3568080896, 3567266560, 3595575040, 1521161472, 1439705088, 651173376, 2464748032, 2461017344, 2841451008, 2840603904, 3030393088, 3042186752, 2907711744, 1437605632, 632951808, 628107008, 1387273216, 2841153792, 1766859520, 1789696000, 1520446208, 2874155008, 1265276160, 1264592896, 2775588096, 1383174912, 3529516032, 3912603904, 3578801152, 1520443904, 2605979136, 2530224384, 1256201984, 2766803968, 2765104640, 3528465152, 3643346944, 3040879360, 3601667840, 765471488, 2513444864, 1255872000, 1236281344, 2763005184, 2992247296, 1783637248, 3039664384, 1799367680, 2875203840, 2511607552, 2473601280, 1232084736, 1689916416, 1420830464, 1784027904, 3662687488, 1522541312, 2876053504, 652225280, 2464161792, 3379762432, 3378522112, 3567262720, 3662745856, 3578799104, 1454217728, 1437608960, 634397184, 2464913152, 2456824320, 2840600064, 3567948544, 3030394368, 3041925888, 1453887488, 1303388928, 632592640, 2775591680, 1387274240, 2841318656, 1766860800, 1788878080, 2907803904, 2874156032, 1265474048, 1256205568, 2775589120, 1383307008, 3529517312, 3912244736, 1789563648, 1520444928, 2605718272, 1265176832, 1256203008, 2767001600, 2765105920, 3529515008, 3576370176, 3038783232, 3600817152, 1456542208, 2513445888, 1239292416, 1236282624, 2763006464, 2858161664, 1783638272, 3040878080, 3047297792, 2875204864, 2511346432, 1236865280, 1232086016, 1690081280, 1420831488, 1783635968, 1831572736, 1522542336, 2875202560, 2473693696, 2464163072, 3379960064, 3378523136, 3567264000, 3662943744, 3041929216, 1453334016, 2866451200, 634398464, 2464160512, 3378057216, 2840601344, 3568080896, 2996840960, 3041926912, 1454085376, 1302341632, 2779782144, 1387926016, 1387275520, 2839419392, 1764764672, 1788879104, 2907969024, 2874157312, 1264590336, 2775716608, 2775590400, 1383078912, 1764823040, 3845136896, 1822234624, 1453337344, 1531977472, 1265308928, 1256204288, 2766150912, 3530166528, 3529516032, 3576535296, 3038784512, 3063947264, 1456772608, 1433413888, 1238376192, 2765787904, 2763007488, 2857376512, 3039334400, 3038781952, 3046446848, 2875206144, 2511347712, 1236997376, 1232087040, 1689263360, 2857996800, 1783636992, 1798150400, 1522543616, 2875203840, 720115200, 1239427328, 2765102592, 3529151232, 2991596800, 3038779904, 3666939648, 3047170560, 2513770240, 2511350272, 1236279296, 2763724032, 2763007744, 2857376768, 3039432960, 1832920064, 2909149184, 2875206400, 2473599232, 1239160576, 1232087296, 1689263616, 1783796480, 3662685440, 1520441856, 2875232000, 2799706624, 2465044480, 2464164352, 3378519808, 3568014336, 3567265536, 3578796800, 1521062144, 1453335552, 2798655744, 2464714240, 2461016320, 2774275584, 2840602880, 2996837632, 3042087424, 2907710720, 1302338304, 2781908736, 2775589376, 1388091392, 1383082752, 1764761344, 1788580864, 1788880640, 2874153984, 1269403904, 1264591872, 2792757504, 2732600320, 3512737792, 3912504576, 3567265792, 3667926272, 1532137984, 1456481536, 1256200960, 2733182976, 2731549184, 3512538880, 2857380352, 3038781184, 3601044224, 2912953856, 1439702016, 1238995456, 1169171456, 2729449728, 3512241664, 2857378048, 3039597824, 1797269504, 2908757248, 1437766400, 2473600256, 1164974848, 2730070016, 1387275008, 1783961344, 3645909248, 1520443136, 2875429888, 2799707648, 1390418944, 3312554240, 2774541312, 3534592000, 3533712128, 3578798080, 1521260032, 1453336576, 2798656768, 1391104768, 1387275776, 2832210432, 3534294784, 2996838912, 3042252544, 2907711744, 1302339328, 2782073856, 2775590656, 1370496000, 2830766336, 1697653760, 1788745984, 1520446208, 2874155008, 732730880, 727722240, 2742033664, 1366463232, 3512739072, 3836746240, 1787924224, 3667927296, 1532335872, 1456482816, 719331072, 2733347840, 2731550464, 3511688192, 3567915008, 3038782208, 3063289856, 2912955136, 1439703040, 635213312, 1169172736, 2729451008, 2841285120, 2840601856, 3038779904, 3041955584, 2875203840, 1437996800, 1265641984, 1164976128, 1388024832, 1387276032, 1766857728, 1823084800, 1520444160, 2874546176, 2799708928, 1256202496, 2775848192, 2774542336, 3533708544, 3914535424, 3578799104, 1453332992, 2865827584, 2513445376, 1257084672, 1236281856, 2765102848, 3530265600, 2996839936, 3041925888, 1453986048, 765469696, 2511345664, 1253608960, 1236279552, 2763822592, 1689266176, 1787829504, 2907902464, 1800414464, 727718400, 2507214592, 2473599488, 1232443392, 3378522368, 3836747264, 1788056576, 3667928576, 1523587840, 728338688, 652223488, 2464160000, 3379105024, 3377471488, 3568080128, 3030394880, 3062897664, 1456673280, 1437607168, 634395392, 2463698688, 2461016576, 2841450240, 1766861312, 1956650496, 3042185984, 2875205120, 1404050432, 632951040, 628106240, 1387272448, 2841153024, 1766858752, 1789695232, 1520445440, 2874154240, 1265275392, 1256203520, 2742032896, 1383174144, 3533709568, 3646264832, 3041929472, 1453334272, 2530448128, 2513446400, 1256201216, 2765754624, 2765103872, 3528464384, 3578825984, 3041927168, 1454150912, 765470720, 2511346944, 1236964096, 1232086272, 2763004416, 2992246528, 1787830784, 2908100096, 1531980032, 727719680, 2507412480, 2465211904, 1232083968, 1689391360, 3567264512, 3931510528, 3599772160, 1456480256, 728569344, 652224512, 2464161024, 3379237376, 3377472768, 3567261952, 3662745088, 3046121728, 1456871168, 1437608192, 634396416, 2463863808, 2461017856, 2840599296, 3031076864, 1822434048, 2907707392, 1437633792, 1269833728, 633116160, 628107520, 1387273472, 2840793600, 3914343424, 1788877312, 2907803136, 2874155264, 1265473280, 1256204800, 2775588352, 1382781952, 3529516544, 3645905664, 1521127424, 1453335296, 2530220032, 1256787456, 1256202240, 2765952256, 2765105152, 3528465664, 3578466560, 3041928192, 899684864, 2513506048, 2511347968, 1237194496, 1232087552, 2763005696, 2992378624, 1783637504, 1832917760, 2913603584, 727720704, 2473596928, 1232672768, 1232087808, 1689558784, 3567268096, 3931121152, 1800084480, 1456483840, 727720960, 2465306880, 2464164864, 3379404800, 2840605440, 3567265792, 3662945536, 3041931008, 1454384384, 651827968, 634400256, 2463113728, 3378059008, 2840603136, 3031211776, 1788883200, 2907710976, 1437277184, 1269837568, 632300544, 1387895040, 1387277312, 2840961280, 3914347008, 1788880896, 2907970816, 2605723648, 1264592128, 2775718400, 2767203584, 1382032128, 3912308224, 3578800384, 1521295104, 1453339136, 2530223616, 1256922112, 1239428864, 2765104128, 3530168320, 3528469248, 3576537088, 3040883456, 3047171840, 2513706240, 2511351808, 1236280832, 2763659776, 2763009280, 2857378304, 3039336192, 1832921344, 2913836544, 727724544, 2473600768, 1232804864, 1232088832, 1689265152, 1783765248, 3662686720, 1797267456, 2875266304, 2875205632, 2473270528, 2464165888, 3378521344, 3567950336, 3567266816, 3662684416, 1521063680, 1453337088, 2798886400, 2781884672, 2463115008, 2841320448, 2840604160, 3030393600, 3042056192, 2907712256, 1436557312, 2781943040, 2779785216, 1388027392, 1387278336, 1764762880, 1957337600, 1788882176, 2907709696, 1302959872, 1264593408, 2775850752, 2766156032, 3529516544, 3912440576, 3575655936, 3667927552, 1800542208, 1456483072, 1256202496, 2766771456, 2765105152, 3529219328, 2991599616, 3575653376, 3600980224, 3047173120, 1439703552, 1255773952, 1236281856, 2763759360, 2763010560, 2857379584, 3039501312, 1830825728, 2912953344, 1437735168, 2473602048, 1232085504, 1689884416, 1689266432, 1783864832, 3662688256, 1520444928, 2875365888, 2866818304, 2464162560, 3379664896, 3378522624, 3568049920, 3567268352, 3578799872, 1521163264, 1453338368, 2798658560, 1391073792, 1389374464, 2840600832, 3567883776, 3030395136, 3042155776, 2907713536, 1436558848, 2782042880, 2779786496, 1387275008, 2830768128, 1764764416, 1788649216, 1788883456, 2874156800, 1303059712, 1264594688, 2775589888, 1366432256, 3512740864, 3912245248, 1787926016, 3667929088, 1532206592, 1456484352, 1256203776, 2766838528, 2731552256, 3511689728, 3576305408, 3038784000, 3600064256, 2912956672, 1439704832, 719002880, 1169174528, 2729452544, 2975504640, 2840603648, 3038781696, 3046184192, 2908760064, 1437834752, 1399861504, 1164977664, 1656396544, 1387277824, 1766859520, 1823053568, 1520445952, 2874482432, 2798662144, 1256204032, 2775784448, 2774544128, 3533710336, 3914504192, 3578800896, 1520443648, 2874250752, 2798659584, 1256955392, 1253060864, 2832213504, 3534395904, 2996841728, 3041927680, 1453987840, 1302342144, 2513444608, 1253643520, 1236281344, 2830834688, 1689267968, 1787831296, 3042056448, 1800416256, 731914752, 2511410944, 2473601536, 1366499072, 3512742144, 3836749312, 1787993088, 3666881792, 1531978496, 728340736, 719334400, 2732597248, 3513324800, 3377473536, 3567983872, 3038785280, 3062899712, 1456609792, 1439706368, 634397184, 2732168960, 2729454080, 2841353984, 2840604928, 3030394368, 3042089728, 2907713024, 1437606656, 632953088, 628108288, 1388093696, 1387279104, 1766860800, 1823153408, 1520447488, 2874156032, 1399462400, 1256205568, 2775589376, 1386845952, 3533711616, 3914571264, 3578802432, 1520444928, 2865928960, 2513448448, 1256203264, 2774145024, 2765105920, 3529252864, 2995794688, 3041928960, 1454087424, 765472768, 2513446144, 1253743104, 1236282624, 2763006464, 2992215808, 1787832576, 2907446784, 1800417536, 727721728, 2511510272, 2473602560, 1232086016, 1689393408, 3835701760, 3931447040, 3666883072, 1523591168, 728440064, 719335424, 2464163072, 3379206656, 3377474560, 3567264000, 3662779904, 3062900992, 1456709120, 1437610240, 634398464, 2463800320, 2461019648, 2840601344, 3567917056, 3030395648, 3041926912, 1453921536, 1437607936, 633019904, 628109312, 1387275520, 2840762880, 3914345472, 1788879104, 2907805184, 2874157312, 1265377024, 1256206848, 2775590656, 1382718720, 3529518848, 3645907712, 1789532416, 1453337600, 2597331200, 1256789504, 1256204544, 2765823488, 2765107456, } -} +}) // 处理公元-105-1912年间的农历 // 返回:农历年,农历月,农历月干支(闰月从上月),农历日,是否闰月,农历日期字符串 @@ -70,7 +70,10 @@ recalc: } if totalDay+dayofLunar > diffDay { lday := diffDay - totalDay + 1 - + if lunarMonth < 1 || lunarMonth > 12 { + // 月序越界说明月表与目标日不自洽(缺行或跨年边界),返回统一回退串,避免越界 panic。 + return 0, 0, "", 0, false, "无法获取农历信息" + } format := formatLunarDateString(lunarMonth, lday, isLeap, diff) ganzhiOfMonth := commonGanZhiOfMonth(year, lunarMonth) @@ -134,7 +137,9 @@ recalc: for i := 0; i < 13; i++ { if lunarMonth == month && isLeap == leap { target := springDate.AddDate(0, 0, totalDay+day-1) - if target.Year() == 1582 && ((target.Month() == 10 && target.Day() > 4) || target.Month() > 10) { + // 1582 改历:连续坐标 1582-10-05..1583-01-13 需 +10 天,与正向的 −10 修正对称。 + if (target.Year() == 1582 && (target.Month() > 10 || (target.Month() == 10 && target.Day() > 4))) || + (target.Year() == 1583 && target.Month() == 1 && target.Day() <= 13) { target = target.AddDate(0, 0, 10) } //go语言在; 1582年10月4日前,使用的是逆推格里高利历,与实际使用的儒略历有所不同,主要体现在百年闰年计算上! @@ -252,6 +257,10 @@ func formatLunarDateString(lunarMonth, lunarDay int, isLeap bool, diff int) stri //武则天改制,将冬月成为正月,正月改为一月 monthNames = []string{"十", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "正", "腊"} } + if lunarMonth < 1 || lunarMonth > 12 { + // 兜底:正常路径已在 rapidLunarHan2Qing 里挡住越界月序。 + return "无法获取农历信息" + } var dateString string if isLeap { @@ -296,7 +305,7 @@ func innerSolarToLunarHanQingByYMD(year, month, day int, hmi time.Time) Time { eras = innerEras(lyear, tangEras) } else if lyear > 907 && lyear < 1368 { eras = innerEras(lyear, wudaiSongYuanEras) - } else if lyear <= 1912 { + } else if lyear >= 1368 && lyear <= 1912 { eras = innerEras(lyear, mingQingEras) } ldate := LunarTime{ diff --git a/calendar/chineseLiaoJinYuan.go b/calendar/chineseLiaoJinYuan.go index 2084304..cfd4526 100644 --- a/calendar/chineseLiaoJinYuan.go +++ b/calendar/chineseLiaoJinYuan.go @@ -1,6 +1,7 @@ package calendar -func liaoJinYuanCals() map[int]uint32 { +// liaoJinYuanCals 辽/金/元历相对宋基准表的年份修正表,编码同 getHanQingCals()。 +var liaoJinYuanCals = memoUint32Map(func() map[int]uint32 { return map[int]uint32{ 949: 2996838400, 955: 1237097216, @@ -12,7 +13,9 @@ func liaoJinYuanCals() map[int]uint32 { 973: 2464163072, 985: 3031177216, 986: 1788881664, + 993: 1767184896, 994: 3645910016, + 1000: 2774542080, 999: 2775716864, 1001: 3530398464, 1012: 3579124224, @@ -35,9 +38,13 @@ func liaoJinYuanCals() map[int]uint32 { 1073: 2991597824, 1075: 3601470208, 1077: 2513836800, + 1074: 3040878080, 1078: 2511351296, 1080: 2763790592, + 1093: 1531977216, 1094: 728372224, + 1102: 1523258624, + 1103: 1456482304, 1105: 2733216256, 1113: 635048960, 1121: 1269470464, @@ -49,15 +56,18 @@ func liaoJinYuanCals() map[int]uint32 { 1155: 1530929920, 1163: 3595576320, 1168: 2461018624, + 1175: 633116928, 1176: 628108288, 1178: 2839221504, 1184: 1264594176, 1198: 3040880384, 1207: 2904563200, + 1208: 3047822080, 1209: 727721472, 1239: 1370498304, 1250: 3576701440, 1251: 1520769024, + 1252: 3600820736, 1253: 1523588864, 1256: 2732596480, 1258: 3511690240, @@ -66,9 +76,9 @@ func liaoJinYuanCals() map[int]uint32 { 1273: 1169888512, 1276: 1784254976, } -} +}) -func liaoJinYuanEras() []Era { +var liaoJinYuanEras = memoEras(func() []Era { return []Era{ { Year: 1264, @@ -337,9 +347,9 @@ func liaoJinYuanEras() []Era { Dynasty: "辽", }, } -} +}) -func liaoJinYuanEraMap() map[string][][]int { +var liaoJinYuanEraMap = memoEraMap(func() map[string][][]int { return map[string][][]int{ "中统": [][]int{{1260, 1264}}, "元宪宗": [][]int{{1251, 1259}}, @@ -385,25 +395,29 @@ func liaoJinYuanEraMap() map[string][][]int { "大同": [][]int{{947, 947}}, "会同": [][]int{{939, 946}}, } -} +}) func innerSolarToLunarLiaoJinYuan(date Time) Time { year := date.solarTime.Year() month := int(date.solarTime.Month()) day := date.solarTime.Day() + // 农历年早于对应年号表首年时 eras 为空,此类候选与主流日期完全相同且无朝代,跳过追加。 if year >= 947 && year <= 1279 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, liaoJinYuanCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.Solar(), - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - desc: ldesc, - comment: "", - ganzhiMonth: ganzhiMonth, - eras: innerEras(lyear, liaoJinYuanEras), - }) + eras := innerEras(lyear, liaoJinYuanEras) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.Solar(), + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + desc: ldesc, + comment: "", + ganzhiMonth: ganzhiMonth, + eras: eras, + }) + } } return date } diff --git a/calendar/chineseNanBeiChao.go b/calendar/chineseNanBeiChao.go index 9ba3acf..87a7892 100644 --- a/calendar/chineseNanBeiChao.go +++ b/calendar/chineseNanBeiChao.go @@ -1,7 +1,7 @@ package calendar // 后秦 -func houQinCals() map[int]uint32 { +var houQinCals = memoUint32Map(func() map[int]uint32 { return map[int]uint32{ 384: 2862622464, 385: 3042089728, @@ -38,9 +38,9 @@ func houQinCals() map[int]uint32 { 416: 2863672576, 417: 1431708160, } -} +}) -func houQinEras() []Era { +var houQinEras = memoEras(func() []Era { return []Era{ { Year: 416, @@ -73,9 +73,9 @@ func houQinEras() []Era { Dynasty: "后秦", }, } -} +}) -func houQinEraMap() map[string][][]int { +var houQinEraMap = memoEraMap(func() map[string][][]int { return map[string][][]int{ "永和": [][]int{{416, 417}}, "弘始": [][]int{{399, 416}}, @@ -83,10 +83,10 @@ func houQinEraMap() map[string][][]int { "建初": [][]int{{386, 394}}, "白雀": [][]int{{384, 386}}, } -} +}) // 北魏、西魏、北周、隋 -func weiZhouSuiCals() map[int]uint32 { +var weiZhouSuiCals = memoUint32Map(func() map[int]uint32 { return map[int]uint32{ 398: 1454219776, 399: 1436562688, @@ -281,9 +281,9 @@ func weiZhouSuiCals() map[int]uint32 { 588: 1788879360, 589: 2907837952, } -} +}) -func weiZhouSuiEras() []Era { +var weiZhouSuiEras = memoEras(func() []Era { return []Era{ { Year: 601, @@ -574,9 +574,9 @@ func weiZhouSuiEras() []Era { Dynasty: "北魏", }, } -} +}) -func weiZhouSuiEraMap() map[string][][]int { +var weiZhouSuiEraMap = memoEraMap(func() map[string][][]int { return map[string][][]int{ //"开皇": [][]int{{581, 600}}, "大象": [][]int{{579, 580}}, @@ -626,9 +626,9 @@ func weiZhouSuiEraMap() map[string][][]int { "皇始": [][]int{{396, 397}}, "登国": [][]int{{386, 396}}, } -} +}) -func beiLiangCals() map[int]uint32 { +var beiLiangCals = memoUint32Map(func() map[int]uint32 { return map[int]uint32{ // currect here 412: 1432100352, @@ -660,9 +660,9 @@ func beiLiangCals() map[int]uint32 { 438: 3578800384, 439: 1454219008, } -} +}) -func beiLiangEras() []Era { +var beiLiangEras = memoEras(func() []Era { return []Era{ { Year: 433, @@ -707,9 +707,9 @@ func beiLiangEras() []Era { Dynasty: "北凉", }, } -} +}) -func beiLiangEraMap() map[string][][]int { +var beiLiangEraMap = memoEraMap(func() map[string][][]int { return map[string][][]int{ "承和": [][]int{{433, 439}}, "义和": [][]int{{431, 433}}, @@ -719,8 +719,9 @@ func beiLiangEraMap() map[string][][]int { "天玺": [][]int{{399, 401}}, "神玺": [][]int{{397, 399}}, } -} -func dongWeiBeiQiCals() map[int]uint32 { +}) + +var dongWeiBeiQiCals = memoUint32Map(func() map[int]uint32 { return map[int]uint32{ 534: 2874547712, 535: 2865770752, @@ -767,9 +768,9 @@ func dongWeiBeiQiCals() map[int]uint32 { 576: 2865769984, 577: 1431315200, } -} +}) -func dongWeiBeiQiEras() []Era { +var dongWeiBeiQiEras = memoEras(func() []Era { return []Era{ { Year: 577, @@ -838,9 +839,9 @@ func dongWeiBeiQiEras() []Era { Dynasty: "东魏", }, } -} +}) -func dongWeiBeiQiEraMap() map[string][][]int { +var dongWeiBeiQiEraMap = memoEraMap(func() map[string][][]int { return map[string][][]int{ "承光": [][]int{{577, 577}}, "武平": [][]int{{570, 576}}, @@ -854,67 +855,80 @@ func dongWeiBeiQiEraMap() map[string][][]int { "元象": [][]int{{538, 539}}, "天平": [][]int{{534, 538}}, } -} +}) func innerSolarToLunarNanBeiChao(date Time) Time { year := date.solarTime.Year() month := int(date.solarTime.Month()) day := date.solarTime.Day() + // 农历年早于对应年号表首年时 eras 为空,此类候选与主流日期完全相同且无朝代,跳过追加。 if year >= 384 && year <= 417 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, houQinCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.solarTime, - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - desc: ldesc, - comment: "", - ganzhiMonth: ganzhiMonth, - eras: innerEras(lyear, houQinEras), - }) + eras := innerEras(lyear, houQinEras) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.solarTime, + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + desc: ldesc, + comment: "", + ganzhiMonth: ganzhiMonth, + eras: eras, + }) + } } if year >= 398 && year <= 589 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, weiZhouSuiCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.solarTime, - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - desc: ldesc, - comment: "", - ganzhiMonth: ganzhiMonth, - eras: innerEras(lyear, weiZhouSuiEras), - }) + eras := innerEras(lyear, weiZhouSuiEras) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.solarTime, + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + desc: ldesc, + comment: "", + ganzhiMonth: ganzhiMonth, + eras: eras, + }) + } } if year >= 397 && year <= 439 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, beiLiangCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.solarTime, - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - ganzhiMonth: ganzhiMonth, - desc: ldesc, - comment: "", - eras: innerEras(lyear, beiLiangEras), - }) + eras := innerEras(lyear, beiLiangEras) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.solarTime, + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + ganzhiMonth: ganzhiMonth, + desc: ldesc, + comment: "", + eras: eras, + }) + } } if year >= 534 && year <= 577 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, dongWeiBeiQiCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.solarTime, - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - desc: ldesc, - ganzhiMonth: ganzhiMonth, - comment: "", - eras: innerEras(lyear, dongWeiBeiQiEras), - }) + eras := innerEras(lyear, dongWeiBeiQiEras) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.solarTime, + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + desc: ldesc, + ganzhiMonth: ganzhiMonth, + comment: "", + eras: eras, + }) + } } return date } diff --git a/calendar/chineseNanMing.go b/calendar/chineseNanMing.go index 0ee4488..63f4e0b 100644 --- a/calendar/chineseNanMing.go +++ b/calendar/chineseNanMing.go @@ -1,6 +1,6 @@ package calendar -func nanMingCals() map[int]uint32 { +var nanMingCals = memoUint32Map(func() map[int]uint32 { return map[int]uint32{ 1645: 1232804864, 1646: 1232088832, @@ -42,9 +42,9 @@ func nanMingCals() map[int]uint32 { 1682: 3529516544, 1683: 3912440576, } -} +}) -func nanMingEras01() []Era { +var nanMingEras01 = memoEras(func() []Era { return []Era{ { Year: 1646, @@ -70,9 +70,9 @@ func nanMingEras01() []Era { Dynasty: "明", }, } -} +}) -func nanMingEras02() []Era { +var nanMingEras02 = memoEras(func() []Era { return []Era{ { Year: 1647, @@ -81,48 +81,55 @@ func nanMingEras02() []Era { Dynasty: "南明", }, } -} +}) -func nanMingEraMap() map[string][][]int { +var nanMingEraMap = memoEraMap(func() map[string][][]int { return map[string][][]int{ "永历": [][]int{{1647, 1683}}, "鲁王监国": [][]int{{1646, 1653}}, "隆武": [][]int{{1645, 1646}}, "弘光": [][]int{{1645, 1645}}, } -} +}) func innerSolarToLunarNanMing(date Time) Time { year := date.solarTime.Year() month := int(date.solarTime.Month()) day := date.solarTime.Day() + // 农历年早于对应年号表首年时 eras 为空,此类候选与主流日期完全相同且无朝代,跳过追加。 if year > 1644 && year < 1654 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, nanMingCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.solarTime, - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - desc: ldesc, - ganzhiMonth: ganzhiMonth, - comment: "", - eras: innerEras(lyear, nanMingEras01), - }) + eras := innerEras(lyear, nanMingEras01) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.solarTime, + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + desc: ldesc, + ganzhiMonth: ganzhiMonth, + comment: "", + eras: eras, + }) + } } if year > 1646 && year < 1684 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, nanMingCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.solarTime, - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - desc: ldesc, - ganzhiMonth: ganzhiMonth, - comment: "", - eras: innerEras(lyear, nanMingEras02), - }) + eras := innerEras(lyear, nanMingEras02) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.solarTime, + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + desc: ldesc, + ganzhiMonth: ganzhiMonth, + comment: "", + eras: eras, + }) + } } return date } diff --git a/calendar/chineseQinHan.go b/calendar/chineseQinHan.go index a70af8f..679a7eb 100644 --- a/calendar/chineseQinHan.go +++ b/calendar/chineseQinHan.go @@ -39,7 +39,15 @@ func innerSolarToLunarQinHan(date time.Time) (Time, bool) { } func innerSolarToLunarQinHanByYMD(year, month, day int) (Time, bool) { - return innerSolarToLunarQinHan(time.Date(year, time.Month(month), day, 0, 0, 0, 0, getCst())) + // 直接按 JDN 取日期:儒略历独有的闰日先转 time.Time 会被规范化成后继日。 + jdn := qinHanDateJDN(year, month, day) + date := qinHanJDNToDate(jdn) + lmonth, ok := qinHanMonthBySolar(year, month, day) + if !ok { + return Time{}, false + } + lmonth.day = jdn - lmonth.startJDN + 1 + return markJulianOnlyJDN(qinHanTime(date, lmonth), jdn), true } func lunarToSolarQinHan(year, month, day int, leap bool) (Time, bool) { @@ -51,8 +59,9 @@ func lunarToSolarQinHan(year, month, day int, leap bool) (Time, bool) { return Time{}, false } lmonth.day = day - date := qinHanJDNToDate(lmonth.startJDN + day - 1) - return qinHanTime(date, lmonth), true + jdn := lmonth.startJDN + day - 1 + date := qinHanJDNToDate(jdn) + return markJulianOnlyJDN(qinHanTime(date, lmonth), jdn), true } func rapidSolarQinHan(year, month, day int, leap bool) (time.Time, bool) { diff --git a/calendar/chineseSanGuo.go b/calendar/chineseSanGuo.go index 4eee2b4..f9064b8 100644 --- a/calendar/chineseSanGuo.go +++ b/calendar/chineseSanGuo.go @@ -1,7 +1,7 @@ package calendar // 蜀汉朔日表 -func shuCals() map[int]uint32 { +var shuCals = memoUint32Map(func() map[int]uint32 { return map[int]uint32{ 237: 2862623744, 238: 3042255104, @@ -31,44 +31,50 @@ func shuCals() map[int]uint32 { 262: 2862622208, 263: 3578927872, } -} +}) -func shuEras() []Era { +var shuEras = memoEras(func() []Era { return []Era{ { Year: 264, Emperor: "魏元帝", OtherNianHaoStart: "咸熙", + Dynasty: "魏", }, { Year: 263, Emperor: "蜀后主", OtherNianHaoStart: "炎兴", + Dynasty: "蜀", }, { Year: 258, Emperor: "蜀后主", Nianhao: "景耀", + Dynasty: "蜀", }, { Year: 238, Emperor: "蜀后主", Nianhao: "延熙", + Dynasty: "蜀", }, { Year: 223, Emperor: "蜀后主", OtherNianHaoStart: "建兴", + Dynasty: "蜀", }, { Year: 221, Emperor: "蜀昭烈帝", Nianhao: "章武", + Dynasty: "蜀", }, } -} +}) -func shuEraMap() map[string][][]int { +var shuEraMap = memoEraMap(func() map[string][][]int { return map[string][][]int{ "炎兴": [][]int{{263, 263}}, "景耀": [][]int{{258, 263}}, @@ -76,9 +82,9 @@ func shuEraMap() map[string][][]int { "建兴": [][]int{{223, 237}}, "章武": [][]int{{221, 223}}, } -} +}) -func wuCals() map[int]uint32 { +var wuCals = memoUint32Map(func() map[int]uint32 { return map[int]uint32{ 223: 1432367360, 224: 2862622208, @@ -139,9 +145,9 @@ func wuCals() map[int]uint32 { 279: 1454087680, 280: 1436561664, } -} +}) -func wuEraMap() map[string][][]int { +var wuEraMap = memoEraMap(func() map[string][][]int { return map[string][][]int{ "天玺": [][]int{{276, 276}}, "天册": [][]int{{275, 276}}, @@ -160,9 +166,9 @@ func wuEraMap() map[string][][]int { "黄龙": [][]int{{229, 231}}, "黄武": [][]int{{222, 229}}, } -} +}) -func wuEras() []Era { +var wuEras = memoEras(func() []Era { return []Era{ { Year: 277, @@ -267,39 +273,47 @@ func wuEras() []Era { Dynasty: "吴", }, } -} +}) func innerSolarToLunarSanGuo(date Time) Time { year := date.solarTime.Year() month := int(date.solarTime.Month()) day := date.solarTime.Day() + // 农历年早于年号表首年(如 221 年初尚属农历 220 年)时 eras 为空, + // 该候选与其它候选日期完全相同且无朝代,不再追加。 if year >= 221 && year <= 263 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, shuCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.solarTime, - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - desc: ldesc, - comment: "", - ganzhiMonth: ganzhiMonth, - eras: innerEras(lyear, shuEras), - }) + eras := innerEras(lyear, shuEras) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.solarTime, + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + desc: ldesc, + comment: "", + ganzhiMonth: ganzhiMonth, + eras: eras, + }) + } } if year >= 222 && year <= 280 { lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, wuCals) - date.lunars = append(date.lunars, LunarTime{ - solarDate: date.solarTime, - year: lyear, - month: lmonth, - day: lday, - leap: isLeap, - desc: ldesc, - ganzhiMonth: ganzhiMonth, - comment: "", - eras: innerEras(lyear, wuEras), - }) + eras := innerEras(lyear, wuEras) + if len(eras) > 0 { + date.lunars = append(date.lunars, LunarTime{ + solarDate: date.solarTime, + year: lyear, + month: lmonth, + day: lday, + leap: isLeap, + desc: ldesc, + ganzhiMonth: ganzhiMonth, + comment: "", + eras: eras, + }) + } } return date } diff --git a/calendar/era.go b/calendar/era.go index a687bd0..9671ef8 100644 --- a/calendar/era.go +++ b/calendar/era.go @@ -1,5 +1,47 @@ package calendar +import "sync" + +// memoEras 缓存只读的年号表:文本入口每次解析都会重建这些表。 +func memoEras(build func() []Era) func() []Era { + var once sync.Once + var eras []Era + return func() []Era { + once.Do(func() { eras = build() }) + return eras + } +} + +// memoEraMap 缓存只读的年号映射表。 +func memoEraMap(build func() map[string][][]int) func() map[string][][]int { + var once sync.Once + var m map[string][][]int + return func() map[string][][]int { + once.Do(func() { m = build() }) + return m + } +} + +// memoUint32s 缓存只读的打包历表(如 2018 项的汉清年表)。 +func memoUint32s(build func() []uint32) func() []uint32 { + var once sync.Once + var table []uint32 + return func() []uint32 { + once.Do(func() { table = build() }) + return table + } +} + +// memoUint32Map 缓存只读的年份→magic 映射表。 +func memoUint32Map(build func() map[int]uint32) func() map[int]uint32 { + var once sync.Once + var table map[int]uint32 + return func() map[int]uint32 { + once.Do(func() { table = build() }) + return table + } +} + type Era struct { //完整农历年的公历年映射 Year int @@ -47,46 +89,32 @@ func innerEras(year int, eraSource func() []Era) []EraDesc { } break } + // 表内只有一条命中、且它恰是 year 年新起年号时,该条会落在 secEra,firstEra 仍为零值; + // 因此按槽位是否为 Year==0 判断“无命中”,不能只看 firstEra。 var result []EraDesc - nianHao := firstEra.Nianhao - if firstEra.OtherNianHaoStart != "" { - nianHao = firstEra.OtherNianHaoStart - } - result = append(result, EraDesc{ - YearOfNianHao: year - firstEra.Year + 1 + firstEra.Offset, - Emperor: firstEra.Emperor, - Nianhao: nianHao, - Dynasty: firstEra.Dynasty, - }) - nianHao = secEra.Nianhao - if secEra.OtherNianHaoStart != "" { - nianHao = secEra.OtherNianHaoStart - } - if secEra.Year != 0 { + for _, e := range []Era{firstEra, secEra, thirdEra} { + if e.Year == 0 { + continue + } + nianHao := e.Nianhao + if e.OtherNianHaoStart != "" { + nianHao = e.OtherNianHaoStart + } result = append(result, EraDesc{ - YearOfNianHao: year - secEra.Year + 1 + secEra.Offset, - Emperor: secEra.Emperor, + YearOfNianHao: year - e.Year + 1 + e.Offset, + Emperor: e.Emperor, Nianhao: nianHao, - Dynasty: secEra.Dynasty, + Dynasty: e.Dynasty, }) } - nianHao = thirdEra.Nianhao - if thirdEra.OtherNianHaoStart != "" { - nianHao = thirdEra.OtherNianHaoStart + // 年份早于表内首个年号时无任何命中,返回 nil 由上层用年份描述兜底。 + if len(result) == 0 { + return nil } - if thirdEra.Year != 0 { - result = append(result, EraDesc{ - YearOfNianHao: year - thirdEra.Year + 1 + thirdEra.Offset, - Emperor: thirdEra.Emperor, - Nianhao: nianHao, - Dynasty: thirdEra.Dynasty, - }) - } - return result } -func nianHaoMap() map[string][][]int { +var nianHaoMap = memoEraMap(func() map[string][][]int { m := map[string][][]int{ "民国": [][]int{{1912, 1949}}, "宣统": [][]int{{1909, 1911}}, @@ -405,7 +433,7 @@ func nianHaoMap() map[string][][]int { } addNianHaoYouCompatAliases(m) return m -} +}) func addNianHaoYouCompatAliases(m map[string][][]int) { for alias, canonical := range map[string]string{ @@ -426,7 +454,7 @@ func addNianHaoYouCompatAliases(m map[string][][]int) { } } -func hanEras() []Era { +var hanEras = memoEras(func() []Era { return []Era{ { Year: 220, @@ -861,9 +889,9 @@ func hanEras() []Era { Dynasty: "西汉", }, } -} +}) -func weiJinNanBeiChaoEras() []Era { +var weiJinNanBeiChaoEras = memoEras(func() []Era { return []Era{ { Year: 617, @@ -1224,6 +1252,7 @@ func weiJinNanBeiChaoEras() []Era { Year: 304, Emperor: "晋惠帝", OtherNianHaoStart: "永兴", + Dynasty: "西晋", }, { Year: 302, @@ -1340,9 +1369,9 @@ func weiJinNanBeiChaoEras() []Era { Dynasty: "东汉", }, } -} +}) -func tangEras() []Era { +var tangEras = memoEras(func() []Era { return []Era{ { Year: 904, @@ -1795,9 +1824,9 @@ func tangEras() []Era { Dynasty: "隋", }, } -} +}) -func wudaiSongYuanEras() []Era { +var wudaiSongYuanEras = memoEras(func() []Era { return []Era{ { Year: 1341, @@ -2305,9 +2334,9 @@ func wudaiSongYuanEras() []Era { Dynasty: "唐", }, } -} +}) -func mingQingEras() []Era { +var mingQingEras = memoEras(func() []Era { return []Era{ { Year: 1949, @@ -2496,4 +2525,4 @@ func mingQingEras() []Era { Dynasty: "元", }, } -} +}) diff --git a/calendar/era_fallback_test.go b/calendar/era_fallback_test.go new file mode 100644 index 0000000..af1d9f2 --- /dev/null +++ b/calendar/era_fallback_test.go @@ -0,0 +1,80 @@ +package calendar + +import ( + "strings" + "testing" +) + +// 年份早于年号表首个条目时,innerEras 曾追加零值 EraDesc,描述退化成空年号的“年十月廿五”。 +// 兜底档(≤ −104 送进明清表)与三国/南北朝/南明的补充分支都会命中,这里锁住修复后的不变量: +// 任何农历记录的年号描述都非空,且不会以“年”开头(即不存在空年号)。 +// When a year precedes the first entry of an era table, innerEras used to append a zero-valued +// EraDesc and the description degenerated to an empty era name ("年十月廿五"). The catch-all branch +// (years <= -104 sent to the Ming/Qing table) and the Three Kingdoms / Northern-Southern dynasties / +// Southern Ming supplements all hit it. These assertions pin the invariant after the fix: every lunar +// record has a non-empty era description that never starts with "年" alone. +func TestEraDescriptionNeverEmpty(t *testing.T) { + cases := []struct { + year, month, day int + primaryEra string + }{ + {-104, 12, 20, "前一零五年"}, // 报告里的样例:太初改历交接年 + {-103, 1, 5, "前一零五年"}, // 同上,跨公历年 + {-104, 1, 1, "元封六年"}, // 同一年里年号仍正常 + {221, 1, 5, "延康元年"}, // 三国补充分支(农历年 220 早于魏表首年) + {384, 1, 5, "太元八年"}, // 南北朝补充分支 + {397, 1, 5, "太元二十一年"}, // 北凉补充分支 + {534, 1, 5, "中大通五年"}, // 东魏北齐补充分支 + {1647, 1, 5, "顺治三年"}, // 南明补充分支(农历年 1646 早于永历表首年) + {1648, 1, 5, "顺治四年"}, + {1912, 12, 25, "民国元年"}, + } + for _, c := range cases { + fwd, err := SolarToLunarByYMD(c.year, c.month, c.day) + if err != nil { + t.Fatalf("SolarToLunarByYMD(%d,%d,%d) error: %v", c.year, c.month, c.day, err) + } + if len(fwd.Lunars()) == 0 { + t.Fatalf("%d-%02d-%02d: no lunar records", c.year, c.month, c.day) + } + for i, l := range fwd.Lunars() { + for _, desc := range l.LunarDescWithDynastyAndEmperor() { + if strings.TrimSpace(desc) == "" || strings.HasPrefix(strings.TrimSpace(desc), "年") { + t.Errorf("%d-%02d-%02d record %d: empty-era description %q", c.year, c.month, c.day, i, desc) + } + } + for _, info := range l.LunarInfo() { + if strings.TrimSpace(info.EraDesc) == "" || strings.TrimSpace(info.EraDesc) == "年" { + t.Errorf("%d-%02d-%02d record %d: empty era description %q", c.year, c.month, c.day, i, info.EraDesc) + } + if strings.TrimSpace(info.LunarWithEraDesc) == "" || strings.HasPrefix(strings.TrimSpace(info.LunarWithEraDesc), "年") { + t.Errorf("%d-%02d-%02d record %d: empty-era description %q", c.year, c.month, c.day, i, info.LunarWithEraDesc) + } + } + } + if got := fwd.LunarInfo()[0].EraDesc; got != c.primaryEra { + t.Errorf("%d-%02d-%02d primary era = %q, want %q", c.year, c.month, c.day, got, c.primaryEra) + } + } +} + +// 抽样式全库扫描:跨朝代边界的年份最容易踩到零值年号,这里按区间取样确认没有空年号描述。 +// A sampled sweep: years at dynasty boundaries are the ones that hit the zero-valued era, so sample +// each band and confirm no record carries an empty era description. +func TestEraDescriptionSampledSweep(t *testing.T) { + for year := -200; year <= 1920; year += 3 { + for _, md := range [][2]int{{1, 5}, {5, 20}, {11, 25}} { + fwd, err := SolarToLunarByYMD(year, md[0], md[1]) + if err != nil { + continue + } + for i, l := range fwd.Lunars() { + for _, info := range l.LunarInfo() { + if strings.TrimSpace(info.EraDesc) == "" || strings.TrimSpace(info.EraDesc) == "年" { + t.Fatalf("%d-%02d-%02d record %d: empty era description (desc %q)", year, md[0], md[1], i, info.LunarWithEraDesc) + } + } + } + } + } +} diff --git a/calendar/era_table_test.go b/calendar/era_table_test.go new file mode 100644 index 0000000..8e5917a --- /dev/null +++ b/calendar/era_table_test.go @@ -0,0 +1,102 @@ +package calendar + +import ( + "testing" +) + +// 年号表的每条记录都必须带朝代,否则 LunarInfo.Dynasty 会随该条记录变空。 +// Every era-table record must carry a dynasty, otherwise LunarInfo.Dynasty goes empty for that record. +func TestEraTablesCarryDynasty(t *testing.T) { + tables := map[string][]Era{ + "hanEras": hanEras(), + "weiJinNanBeiChaoEras": weiJinNanBeiChaoEras(), + "tangEras": tangEras(), + "wudaiSongYuanEras": wudaiSongYuanEras(), + "mingQingEras": mingQingEras(), + "liaoJinYuanEras": liaoJinYuanEras(), + "houQinEras": houQinEras(), + "weiZhouSuiEras": weiZhouSuiEras(), + "beiLiangEras": beiLiangEras(), + "dongWeiBeiQiEras": dongWeiBeiQiEras(), + "shuEras": shuEras(), + "wuEras": wuEras(), + "nanMingEras01": nanMingEras01(), + "nanMingEras02": nanMingEras02(), + "zhouAncientEras": zhouAncientEras, + "luAncientEras": luAncientEras, + "qinWarringAncientEras": qinWarringAncientEras, + "qinHanAncientEras": qinHanAncientEras, + } + for name, eras := range tables { + for i, e := range eras { + if e.Dynasty == "" { + t.Errorf("%s[%d]: Year=%d Emperor=%q Nianhao=%q OtherNianHaoStart=%q has empty Dynasty", name, i, e.Year, e.Emperor, e.Nianhao, e.OtherNianHaoStart) + } + } + } +} + +func TestXijinYongxingDynasty(t *testing.T) { + got, err := SolarToLunarByYMD(304, 6, 1) + if err != nil { + t.Fatalf("SolarToLunarByYMD(304,6,1) error: %v", err) + } + info := got.LunarInfo() + if len(info) == 0 { + t.Fatal("no lunar info") + } + for _, li := range info { + if li.Nianhao == "永兴" && li.Dynasty != "西晋" { + t.Errorf("永兴 record dynasty = %q, want 西晋", li.Dynasty) + } + } + if got.Lunar().Eras()[0].Dynasty != "西晋" { + t.Errorf("primary dynasty = %q, want 西晋", got.Lunar().Eras()[0].Dynasty) + } +} + +// 补充年号分支在农历年早于该表首年时,曾追加一条与主流日期完全相同却没有朝代的候选。 +func TestSupplementLunarsCarryDynasty(t *testing.T) { + for _, d := range [][3]int{ + {221, 1, 5}, {222, 1, 5}, {384, 1, 5}, {397, 1, 5}, {534, 1, 5}, + {1647, 1, 1}, {1648, 1, 1}, + } { + got, err := SolarToLunarByYMD(d[0], d[1], d[2]) + if err != nil { + t.Fatalf("%v error: %v", d, err) + } + for i, l := range got.Lunars() { + if len(l.Eras()) == 0 { + t.Errorf("%v record %d: no era", d, i) + } + for _, info := range l.LunarInfo() { + if info.Dynasty == "" { + t.Errorf("%v record %d: empty dynasty (%q)", d, i, info.EraDesc) + } + } + } + } +} + +// 永历年号表只有一条记录,曾因 innerEras 把“年==表首年”的唯一命中放进 secEra 而整条丢失。 +func TestYongliFirstYear(t *testing.T) { + for _, d := range [][3]int{{1647, 6, 1}, {1648, 1, 1}} { + got, err := SolarToLunarByYMD(d[0], d[1], d[2]) + if err != nil { + t.Fatalf("%v error: %v", d, err) + } + found := false + for _, info := range got.LunarInfo() { + if info.Nianhao != "永历" { + continue + } + found = true + if info.Dynasty != "南明" || info.Emperor != "南明/明郑" || info.YearOfNianhao != 1 { + t.Errorf("%v: 永历 record = %+v, want 南明/明郑 永历元年", d, info) + } + } + if !found { + t.Errorf("%v: 永历 record missing", d) + } + } +} diff --git a/calendar/julian_only_test.go b/calendar/julian_only_test.go new file mode 100644 index 0000000..921393a --- /dev/null +++ b/calendar/julian_only_test.go @@ -0,0 +1,293 @@ +package calendar + +import ( + "testing" + "time" + + "b612.me/astro/basic" +) + +// L2 契约:儒略历独有闰日(1582 年前百年非 400 闰年的 2 月 29 日)的 Solar 取后继日标签, +// JDE 取精确儒略日,JulianOnly 为真,农历身份字段描述这一天本身。 +var julianOnlyYears = []int{ + 100, 200, 300, 500, 600, 700, 900, 1000, 1100, 1300, 1400, 1500, + -700, -600, -500, -300, -200, -100, +} + +// lunarIdentity 是农历记录的身份字段,用于比较正向与反向是否描述同一天。 +type lunarIdentity struct { + year, month, day int + leap bool + desc string + ganzhiDay string + ganzhiMonth string + calendars int +} + +func identityOf(t Time) lunarIdentity { + l := t.Lunar() + return lunarIdentity{ + year: l.LunarYear(), + month: l.LunarMonth(), + day: l.LunarDay(), + leap: l.IsLeap(), + desc: l.MonthDay(), + ganzhiDay: l.GanZhiDay(), + ganzhiMonth: l.GanZhiMonth(), + calendars: len(t.LunarInfo()), + } +} + +func TestJulianOnlyLeapDayForwardReverseAgree(t *testing.T) { + for _, year := range julianOnlyYears { + phantom := basic.JDECalc(year, 2, 29) + if phantom != phantom { // NaN 保护:这些年份必须被历法层承认 + t.Fatalf("year %d: basic.JDECalc(%d,2,29) is NaN", year, year) + } + label := basic.JDE2DateByZone(phantom, time.FixedZone("CST", 8*3600), true) + fwd, err := SolarToLunarByYMD(year, 2, 29) + if err != nil { + t.Fatalf("year %d: SolarToLunarByYMD(%d,2,29) error: %v", year, year, err) + } + if !fwd.JulianOnly() || fwd.JDE() != phantom { + t.Errorf("year %d: forward JulianOnly=%v JDE=%.1f, want true %.1f", + year, fwd.JulianOnly(), fwd.JDE(), phantom) + } + if got := fwd.Solar().Format("2006-01-02"); got != label.Format("2006-01-02") { + t.Errorf("year %d: forward Solar=%s, want canonical label %s", year, got, label.Format("2006-01-02")) + } + want := identityOf(fwd) + + rev, err := LunarToSolarByYMD(want.year, want.month, want.day, want.leap) + if err != nil { + t.Fatalf("year %d: LunarToSolarByYMD(%d,%d,%d,%v) error: %v", + year, want.year, want.month, want.day, want.leap, err) + } + if !rev.JulianOnly() || rev.JDE() != phantom { + t.Errorf("year %d: reverse JulianOnly=%v JDE=%.1f, want true %.1f", + year, rev.JulianOnly(), rev.JDE(), phantom) + } + if got := rev.Solar().Format("2006-01-02"); got != label.Format("2006-01-02") { + t.Errorf("year %d: reverse Solar=%s, want canonical label %s", year, got, label.Format("2006-01-02")) + } + if got := identityOf(rev); got != want { + t.Errorf("year %d: reverse identity %+v, want %+v (requested lunar %d/%d/%d leap=%v)", + year, got, want, want.year, want.month, want.day, want.leap) + } + info := rev.LunarInfo()[0] + if !info.JulianOnly || info.JDE != phantom { + t.Errorf("year %d: LunarInfo JulianOnly=%v JDE=%.1f, want true %.1f", year, info.JulianOnly, info.JDE, phantom) + } + // 日干支必须取自闰日自己的儒略日,而不是标签日 Solar()。 + // The sexagenary day name must come from the leap day's own Julian day, not from the label day. + if got, labelGanzhi := rev.Lunar().GanZhiDay(), GanZhiOfDay(rev.Solar()); got != ganZhiOfJDE(phantom) { + t.Errorf("year %d: GanZhiDay=%s, want %s (label %s gives %s)", + year, got, ganZhiOfJDE(phantom), rev.Solar().Format("2006-01-02"), labelGanzhi) + } + } +} + +// 儒略历闰日两侧仍是普通日子:往返正常且农历日不与闰日重复。 +func TestJulianOnlyLeapDayNeighboursStayOrdinary(t *testing.T) { + for _, year := range julianOnlyYears { + phantom := basic.JDECalc(year, 2, 29) + fwd, err := SolarToLunarByYMD(year, 2, 29) + if err != nil { + t.Fatalf("year %d: SolarToLunarByYMD(%d,2,29) error: %v", year, year, err) + } + leapLunar := identityOf(fwd) + for _, c := range []struct { + month, day int + wantJDE float64 + }{ + {2, 28, phantom - 1}, + {3, 1, phantom + 1}, + } { + near, err := SolarToLunarByYMD(year, c.month, c.day) + if err != nil { + t.Fatalf("year %d: SolarToLunarByYMD(%d,%d,%d) error: %v", year, year, c.month, c.day, err) + } + nearID := identityOf(near) + if nearID == leapLunar { + t.Errorf("year %d: %d-%02d shares the leap day's lunar date %d/%d/%d leap=%v", + year, year, c.day, nearID.year, nearID.month, nearID.day, nearID.leap) + } + rev, err := LunarToSolarByYMD(nearID.year, nearID.month, nearID.day, nearID.leap) + if err != nil { + t.Fatalf("year %d: reverse of %d-%02d lunar %d/%d/%d error: %v", + year, year, c.day, nearID.year, nearID.month, nearID.day, err) + } + if rev.JulianOnly() { + t.Errorf("year %d: %d-%02d flagged JulianOnly", year, year, c.day) + } + if rev.JDE() != c.wantJDE { + t.Errorf("year %d: %d-%02d JDE=%.1f, want %.1f", year, year, c.day, rev.JDE(), c.wantJDE) + } + if got := identityOf(rev); got != nearID { + t.Errorf("year %d: %d-%02d reverse identity %+v, want %+v", year, year, c.day, got, nearID) + } + } + } +} + +// julianOnlyCivilDay 的分类必须只覆盖"仅儒略历存在"的闰日。 +func TestJulianOnlyCivilDayClassification(t *testing.T) { + for _, year := range append(append([]int{}, julianOnlyYears...), 400, 800, 1200, 1600, 2000) { + want := false + for _, v := range julianOnlyYears { + if v == year { + want = true + break + } + } + if got := julianOnlyCivilDay(year, 2, 29); got != want { + t.Errorf("julianOnlyCivilDay(%d,2,29)=%v, want %v", year, got, want) + } + } + // 不存在的日期与普通闰日都不属于这一类。 + for _, c := range []struct{ y, m, d int }{ + {1900, 2, 29}, {1582, 2, 29}, {2024, 2, 29}, {2023, 2, 29}, {100, 2, 28}, {100, 3, 1}, + } { + if julianOnlyCivilDay(c.y, c.m, c.d) { + t.Errorf("julianOnlyCivilDay(%d,%d,%d)=true, want false", c.y, c.m, c.d) + } + } +} + +// 普通日期不受 L2 影响:JulianOnly 为假、JDE 与 Solar 一致、身份与正向一致。 +func TestOrdinaryDatesCarryNoJulianOnlyFlag(t *testing.T) { + cases := []struct{ y, m, d int }{ + {2025, 1, 1}, {2025, 6, 15}, {1912, 2, 12}, {1900, 5, 5}, {1582, 10, 15}, + {1000, 7, 7}, {700, 3, 2}, {500, 3, 1}, {1, 1, 1}, {0, 6, 1}, {-100, 3, 2}, {-500, 2, 28}, + } + for _, c := range cases { + t.Run(time.Date(c.y, time.Month(c.m), c.d, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), func(t *testing.T) { + fwd, err := SolarToLunarByYMD(c.y, c.m, c.d) + if err != nil { + t.Fatalf("SolarToLunarByYMD(%d,%d,%d) error: %v", c.y, c.m, c.d, err) + } + id := identityOf(fwd) + rev, err := LunarToSolarByYMD(id.year, id.month, id.day, id.leap) + if err != nil { + t.Fatalf("reverse of %d/%d/%d leap=%v error: %v", id.year, id.month, id.day, id.leap, err) + } + if rev.JulianOnly() { + t.Errorf("%d-%02d-%02d flagged JulianOnly", c.y, c.m, c.d) + } + if want := Date2JDE(rev.Solar()); rev.JDE() != want { + t.Errorf("%d-%02d-%02d JDE=%.1f, want %.1f (Solar 推出值)", c.y, c.m, c.d, rev.JDE(), want) + } + if got := identityOf(rev); got != id { + t.Errorf("%d-%02d-%02d reverse identity %+v, want %+v", c.y, c.m, c.d, got, id) + } + info := rev.LunarInfo() + for _, v := range info { + if v.JulianOnly { + t.Errorf("%d-%02d-%02d LunarInfo flagged JulianOnly", c.y, c.m, c.d) + } + if v.JDE != rev.JDE() { + t.Errorf("%d-%02d-%02d LunarInfo JDE=%.1f, want %.1f", c.y, c.m, c.d, v.JDE, rev.JDE()) + } + } + }) + } +} + +// 字符串入口 LunarToSolar 曾把儒略历独有闰日整体偏一天(先解析成 time.Time 再回代); +// 现在按"结果紧邻闰日且闰日记录正是描述所指"复核。用例含武则天年号写法。 +func TestLunarToSolarTextHandlesJulianOnlyLeapDays(t *testing.T) { + cases := []struct { + desc string + year int + month int + day int + leap bool + }{ + {"100年二月初二", 100, 2, 2, false}, + {"200年正月廿七", 200, 1, 27, false}, + {"300年正月廿三", 300, 1, 23, false}, + {"500年正月十五", 500, 1, 15, false}, + {"600年闰正月初十", 600, 1, 10, true}, + {"700年四月初五", 700, 4, 5, false}, + {"900年正月廿六", 900, 1, 26, false}, + {"1000年正月廿二", 1000, 1, 22, false}, + {"1100年正月十八", 1100, 1, 18, false}, + {"1300年二月初九", 1300, 2, 9, false}, + {"1400年二月初五", 1400, 2, 5, false}, + {"1500年二月初一", 1500, 2, 1, false}, + {"前701年二月初八", -700, 2, 8, false}, + {"前601年三月初三", -600, 3, 3, false}, + {"前501年三月廿八", -500, 3, 28, false}, + {"前301年三月二十", -300, 3, 20, false}, + {"前201年正月十五", -200, 1, 15, false}, + {"前101年正月十一", -100, 1, 11, false}, + {"圣历三年二月初五", 700, 4, 5, false}, + {"久视元年二月初五", 700, 4, 5, false}, + {"永元十二年二月初二", 100, 2, 2, false}, + } + for _, c := range cases { + phantom := basic.JDECalc(c.year, 2, 29) + label := basic.JDE2DateByZone(phantom, time.FixedZone("CST", 8*3600), true) + results, err := LunarToSolar(c.desc) + if err != nil { + t.Fatalf("%s: LunarToSolar error: %v", c.desc, err) + } + if len(results) != 1 { + t.Errorf("%s: got %d results, want 1", c.desc, len(results)) + continue + } + got := results[0] + if !got.JulianOnly() || got.JDE() != phantom { + t.Errorf("%s: JulianOnly=%v JDE=%.1f, want true %.1f", c.desc, got.JulianOnly(), got.JDE(), phantom) + } + if solar := got.Solar().Format("2006-01-02"); solar != label.Format("2006-01-02") { + t.Errorf("%s: Solar=%s, want canonical label %s", c.desc, solar, label.Format("2006-01-02")) + } + lunar := got.Lunar() + if lunar.LunarYear() != c.year || lunar.LunarMonth() != c.month || lunar.LunarDay() != c.day || lunar.IsLeap() != c.leap { + t.Errorf("%s: lunar %d/%d/%d leap=%v, want %d/%d/%d leap=%v", c.desc, + lunar.LunarYear(), lunar.LunarMonth(), lunar.LunarDay(), lunar.IsLeap(), c.year, c.month, c.day, c.leap) + } + } +} + +// 闰日前后的描述必须仍是普通日期。 +func TestLunarToSolarTextKeepsLeapDayNeighboursOrdinary(t *testing.T) { + for _, year := range julianOnlyYears { + phantom := basic.JDECalc(year, 2, 29) + for _, offset := range []int{-1, 1} { + // 取闰日前后各一天,用它们自己的农历日构造描述。 + neighbour := basic.JDE2DateByZone(phantom+float64(offset), time.FixedZone("CST", 8*3600), true) + fwd, err := SolarToLunarByYMD(neighbour.Year(), int(neighbour.Month()), neighbour.Day()) + if err != nil { + t.Fatalf("year %d: SolarToLunarByYMD(%s) error: %v", year, neighbour.Format("2006-01-02"), err) + } + id := identityOf(fwd) + prefix := "" + if id.year < 0 { + prefix = "前" + number2Chinese(1-id.year, true) + } else { + prefix = number2Chinese(id.year, true) + } + desc := prefix + "年" + formatLunarDateString(id.month, id.day, id.leap, 0) + results, err := LunarToSolar(desc) + if err != nil { + t.Fatalf("%s: LunarToSolar error: %v", desc, err) + } + if len(results) != 1 { + t.Errorf("%s: got %d results, want 1", desc, len(results)) + continue + } + got := results[0] + if got.JulianOnly() { + t.Errorf("%s: unexpectedly flagged JulianOnly", desc) + } + if want := phantom + float64(offset); got.JDE() != want { + t.Errorf("%s: JDE=%.1f, want %.1f", desc, got.JDE(), want) + } + if got := identityOf(results[0]); got != id { + t.Errorf("%s: identity %+v, want %+v", desc, got, id) + } + } + } +} diff --git a/calendar/liao_calendar_regression_test.go b/calendar/liao_calendar_regression_test.go new file mode 100644 index 0000000..0c95f5a --- /dev/null +++ b/calendar/liao_calendar_regression_test.go @@ -0,0 +1,99 @@ +package calendar + +import ( + "testing" + "time" +) + +// 辽/金/元历修正表曾漏掉 9 个年份,其中缺 1093 会让辽历 1094-01-19 走进第 13 个月并越界 panic。 +func TestLiaoJinYuanCorrectionTableIsComplete(t *testing.T) { + want := map[int]uint32{ + 993: 1767184896, + 1000: 2774542080, + 1074: 3040878080, + 1093: 1531977216, + 1102: 1523258624, + 1103: 1456482304, + 1175: 633116928, + 1208: 3047822080, + 1252: 3600820736, + } + table := liaoJinYuanCals() + for year, magic := range want { + got, ok := table[year] + if !ok { + t.Fatalf("liaoJinYuanCals() 缺少 %d 年(源表 y%d 有修正)", year, year) + } + if got != magic { + t.Fatalf("liaoJinYuanCals()[%d] = %d, want %d", year, got, magic) + } + } + if len(table) != 72 { + t.Fatalf("liaoJinYuanCals() 行数 = %d, want 72(与补全后的表行数一致)", len(table)) + } +} + +// 缺行年份的岁末/年初边界:既要不再 panic,也要给出源表推出的那一天。 +// Year boundaries of the years that used to be missing: they must not panic and must report +// the day the source table implies. +func TestSolarToLunarNorthernCalendarYearBoundaries(t *testing.T) { + cases := []struct { + year, month, day int + want string + }{ + {1094, 1, 19, "辽 大安九年腊月三十"}, // 缺 1093 行:旧实现 panic(index out of range [13]) + {1176, 2, 12, "金 大定十五年腊月三十"}, // 缺 1175 行:旧实现回退"无法获取农历信息" + {1209, 2, 6, "金 泰和八年腊月三十"}, // 缺 1208 行:同上 + {1252, 2, 11, "元 元宪宗二年正月初一"}, // 缺 1252 行:同上 + } + for _, tc := range cases { + date := time.Date(tc.year, time.Month(tc.month), tc.day, 0, 0, 0, 0, getCst()) + got, err := SolarToLunar(date) + if err != nil { + t.Fatalf("SolarToLunar(%s) err=%v", date.Format("2006-01-02"), err) + } + if !containsLine(got.LunarDescWithDynasty(), tc.want) { + t.Fatalf("SolarToLunar(%s) = %v, want a line %q", + date.Format("2006-01-02"), got.LunarDescWithDynasty(), tc.want) + } + } +} + +// 缺行年份还会让整月错位 30 天(旧表把闰月当普通月)。这里钉住错位段的头一天。 +// Missing rows also shifted a whole month by 30 days (the old table treated a leap month as +// an ordinary one). Pin the first day of each shifted stretch. +func TestSolarToLunarNorthernCalendarMonthAlignment(t *testing.T) { + cases := []struct { + year, month, day int + want string + }{ + {1102, 11, 11, "辽 乾统二年九月廿九"}, + {1102, 11, 12, "辽 乾统二年九月三十"}, // 旧表:十月初一 + {1102, 11, 13, "辽 乾统二年十月初一"}, + {1103, 9, 2, "辽 乾统三年七月廿九"}, + {1103, 9, 3, "辽 乾统三年七月三十"}, // 旧表:八月初一 + {1103, 9, 4, "辽 乾统三年八月初一"}, + {1252, 2, 10, "元 元宪宗元年腊月廿九"}, + {1252, 2, 11, "元 元宪宗二年正月初一"}, // 旧表:回退串 + } + for _, tc := range cases { + date := time.Date(tc.year, time.Month(tc.month), tc.day, 0, 0, 0, 0, getCst()) + got, err := SolarToLunar(date) + if err != nil { + t.Fatalf("SolarToLunar(%s) err=%v", date.Format("2006-01-02"), err) + } + if !containsLine(got.LunarDescWithDynasty(), tc.want) { + t.Fatalf("SolarToLunar(%s) = %v, want a line %q", + date.Format("2006-01-02"), got.LunarDescWithDynasty(), tc.want) + } + } +} + +func containsLine(lines []string, want string) bool { + for _, line := range lines { + if line == want { + return true + } + } + return false +} diff --git a/calendar/perf_bench_test.go b/calendar/perf_bench_test.go new file mode 100644 index 0000000..eca4daf --- /dev/null +++ b/calendar/perf_bench_test.go @@ -0,0 +1,69 @@ +package calendar + +import ( + "testing" + "time" +) + +func benchTime(tb testing.TB, y, m, d int) time.Time { + tb.Helper() + return time.Date(y, time.Month(m), d, 0, 0, 0, 0, getCst()) +} + +func BenchmarkSolarToLunarHanQing(b *testing.B) { + date := benchTime(b, 1000, 7, 7) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := SolarToLunar(date); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkSolarToLunarModern(b *testing.B) { + date := benchTime(b, 2025, 1, 1) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := SolarToLunar(date); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkLunarToSolarText(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := LunarToSolar("元丰六年十月十二"); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkLunarToSolarByYMD(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := LunarToSolarByYMD(1083, 10, 12, false); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkTimeAdd(b *testing.B) { + t, err := LunarToSolarByYMD(2024, 1, 1, false) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = t.Add(time.Hour) + } +} + +func BenchmarkCalendricalJieQi(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := CalendricalJieQi(1000, JQ_冬至); err != nil { + b.Fatal(err) + } + } +} diff --git a/calendar/reform_roundtrip_test.go b/calendar/reform_roundtrip_test.go new file mode 100644 index 0000000..d258fe7 --- /dev/null +++ b/calendar/reform_roundtrip_test.go @@ -0,0 +1,166 @@ +package calendar + +import ( + "fmt" + "testing" + "time" +) + +// 1582 改历:反向的 +10 天窗口必须与正向的 −10 天修正对称(1582-10-05..1583-01-13)。 +// formatAncientSolar 统一负年份的显示。 +func formatAncientSolar(t time.Time) string { + year, sign := t.Year(), "" + if year < 0 { + year, sign = -year, "-" + } + return fmt.Sprintf("%s%04d-%02d-%02d", sign, year, int(t.Month()), t.Day()) +} + +func TestGregorianReformReverseMatchesForward(t *testing.T) { + cases := []struct { + ly, lm, ld int + leap bool + want string + }{ + {1582, 12, 8, false, "1583-01-01"}, // 连续坐标 1582-12-22,原有 1582 规则覆盖 + {1582, 12, 18, false, "1583-01-11"}, // 连续坐标 1583-01-01,修复前返回 1583-01-01 + {1582, 12, 30, false, "1583-01-23"}, // 连续坐标 1583-01-13,修复前返回 1583-01-13 + {1583, 1, 1, false, "1583-01-24"}, // 连续坐标 1583-01-14,本就不该 +10 + } + for _, tc := range cases { + got, err := LunarToSolarByYMD(tc.ly, tc.lm, tc.ld, tc.leap) + if err != nil { + t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", tc.ly, tc.lm, tc.ld, err) + } + if s := got.Solar().Format("2006-01-02"); s != tc.want { + t.Fatalf("LunarToSolarByYMD(%d,%d,%d) = %s, want %s", tc.ly, tc.lm, tc.ld, s, tc.want) + } + } + + // 改历前后整段往返一致(跳过被删除的 1582-10-05..14,它们在公历里不存在)。 + // The whole span round-trips apart from the ten days the reform deleted. + for day := time.Date(1582, 10, 15, 0, 0, 0, 0, getCst()); day.Before(time.Date(1583, 3, 1, 0, 0, 0, 0, getCst())); day = day.AddDate(0, 0, 1) { + y, m, d := day.Year(), int(day.Month()), day.Day() + lunar, err := SolarToLunarByYMD(y, m, d) + if err != nil { + t.Fatalf("SolarToLunarByYMD(%d,%d,%d) err=%v", y, m, d, err) + } + l := lunar.Lunar() + back, err := LunarToSolarByYMD(l.LunarYear(), l.LunarMonth(), l.LunarDay(), l.IsLeap()) + if err != nil { + t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", l.LunarYear(), l.LunarMonth(), l.LunarDay(), err) + } + if s := back.Solar(); s.Year() != y || int(s.Month()) != m || s.Day() != d { + t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 %s(改历往返不一致)", + y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), s.Format("2006-01-02")) + } + } +} + +// 先秦岁首跨界:正向按公历年选默认古历、反向按农历年选,交界处必须自洽。 +// 农历 -479 年正月落在公历 -480 年 12 月(春秋历),此前反向按农历年选了周历,整体差 1 天。 +// The pre-Qin year-start crossing: the forward picks the default ancient calendar by civil +// year while the reverse picks by lunar year. Lunar year -479 month 1 falls in civil December +// -480 (Chunqiu calendar); the reverse used to pick Zhou and shift those 20 days by one day. +func TestAncientYearStartCrossingRoundTrips(t *testing.T) { + cases := []struct { + ly, lm, ld int + want string + }{ + {-479, 1, 1, "-0480-12-12"}, + {-479, 1, 20, "-0480-12-31"}, + {-479, 1, 22, "-0479-01-01"}, + {-479, 1, 29, "-0479-01-08"}, + } + for _, tc := range cases { + got, err := LunarToSolarByYMD(tc.ly, tc.lm, tc.ld, false) + if err != nil { + t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", tc.ly, tc.lm, tc.ld, err) + } + if s := formatAncientSolar(got.Solar()); s != tc.want { + t.Fatalf("LunarToSolarByYMD(%d,%d,%d) = %s, want %s", tc.ly, tc.lm, tc.ld, s, tc.want) + } + } + // 该农历日在正向可达的范围内没有对应公历日(春秋历说是 -479-01-01、周历说是 -480-12-31, + // 而正向对这两个公历日分别给出周历 1/22 与春秋历 1/20),默认口径必须报错而不是给错日子。 + // That lunar day has no civil date in the default frame (the forward maps -479-01-01 to Zhou + // 1/22 and -480-12-31 to Chunqiu 1/20), so the default entry point must report an error. + if _, err := LunarToSolarByYMD(-479, 1, 21, false); err == nil { + t.Fatal("LunarToSolarByYMD(-479,1,21) 应报错:该农历日在默认历法口径下没有公历日") + } + + // 交界窗口往返一致。 + // The junction window round-trips. + for day := time.Date(-480, 12, 1, 0, 0, 0, 0, getCst()); day.Before(time.Date(-479, 2, 1, 0, 0, 0, 0, getCst())); day = day.AddDate(0, 0, 1) { + y, m, d := day.Year(), int(day.Month()), day.Day() + lunar, err := SolarToLunarByYMD(y, m, d) + if err != nil { + continue + } + l := lunar.Lunar() + back, err := LunarToSolarByYMD(l.LunarYear(), l.LunarMonth(), l.LunarDay(), l.IsLeap()) + if err != nil { + t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 err=%v", y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), err) + } + if s := back.Solar(); s.Year() != y || int(s.Month()) != m || s.Day() != d { + t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 %s(岁首跨界往返不一致)", + y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), formatAncientSolar(s)) + } + } +} + +// 王莽改历的 23/24 年交界:同一套月序偏移会把"腊月三十"反解到腊月初一(标签碰撞), +// 此时应改用另一套偏移下能正向回到该农历日的那一天,并把主答案换成它。 +// At the Wang Mang 23/24 boundary the primary month offset resolves "12th month, day 30" onto the +// 1st of that month (a label collision); the reverse must switch to the offset whose civil day +// converts forward to the requested lunar date and make that day the primary answer. +func TestWangMangBoundaryReverseIsSelfConsistent(t *testing.T) { + cases := []struct { + ly, lm, ld int + wantSolar string + selfConsist bool + }{ + {23, 12, 29, "0023-12-30", true}, + {23, 12, 30, "0024-01-29", true}, + {24, 1, 1, "0024-01-30", true}, + // 23/12/31 在两套月序下都没有对应的公历日(该年有两个腊月,第二个腊月只有 30 天), + // 保持既有行为返回偏移 1 的结果,只断言主答案不变成空值。 + {23, 12, 31, "0024-01-01", false}, + } + for _, c := range cases { + res, err := LunarToSolarByYMD(c.ly, c.lm, c.ld, false) + if err != nil { + t.Fatalf("LunarToSolarByYMD(%d,%d,%d) error: %v", c.ly, c.lm, c.ld, err) + } + if got := formatAncientSolar(res.Solar()); got != c.wantSolar { + t.Errorf("lunar %d/%d/%d -> %s, want %s", c.ly, c.lm, c.ld, got, c.wantSolar) + } + if !c.selfConsist { + continue + } + fwd, err := SolarToLunarByYMD(res.Solar().Year(), int(res.Solar().Month()), res.Solar().Day()) + if err != nil { + t.Fatalf("SolarToLunarByYMD(%s) error: %v", c.wantSolar, err) + } + l := fwd.Lunar() + if l.LunarYear() != c.ly || l.LunarMonth() != c.lm || l.LunarDay() != c.ld || l.IsLeap() { + t.Errorf("lunar %d/%d/%d -> %s converts forward to %d/%d/%d", + c.ly, c.lm, c.ld, c.wantSolar, l.LunarYear(), l.LunarMonth(), l.LunarDay()) + } + if res.JDE() != fwd.JDE() { + t.Errorf("lunar %d/%d/%d: JDE=%.1f, forward gives %.1f", c.ly, c.lm, c.ld, res.JDE(), fwd.JDE()) + } + for _, cand := range res.SolarCandidates() { + candFwd, err := SolarToLunarByYMD(cand.Year(), int(cand.Month()), cand.Day()) + if err != nil { + t.Errorf("lunar %d/%d/%d candidate %s does not convert: %v", c.ly, c.lm, c.ld, formatAncientSolar(cand), err) + continue + } + cl := candFwd.Lunar() + if cl.LunarYear() != c.ly || cl.LunarMonth() != c.lm || cl.LunarDay() != c.ld || cl.IsLeap() { + t.Errorf("lunar %d/%d/%d candidate %s converts to %d/%d/%d", + c.ly, c.lm, c.ld, formatAncientSolar(cand), cl.LunarYear(), cl.LunarMonth(), cl.LunarDay()) + } + } + } +} diff --git a/calendar/solar_candidates_test.go b/calendar/solar_candidates_test.go new file mode 100644 index 0000000..a593099 --- /dev/null +++ b/calendar/solar_candidates_test.go @@ -0,0 +1,118 @@ +package calendar + +import ( + "testing" + "time" +) + +// L3 契约:Solar() 保持库内默认选择,SolarCandidates() 给出全部合法候选(主答案在首), +// 且每个候选都能正向回到该农历日。 +func TestSolarCandidatesForReformWindows(t *testing.T) { + cases := []struct { + year, month, day int + leap bool + want []string + }{ + {762, 4, 1, false, []string{"0762-03-01", "0762-04-29"}}, // 唐肃宗改制 + {700, 11, 1, false, []string{"0700-12-15", "0700-10-17"}}, // 武则天改制 + {700, 12, 1, false, []string{"0700-11-15", "0701-01-14"}}, // 武则天改制 + {23, 12, 1, false, []string{"0023-12-02", "0023-12-31"}}, // 王莽改制 + {239, 12, 1, false, []string{"0239-12-13", "0240-01-12"}}, // 魏明帝改制 + {-104, 10, 1, false, []string{"-0104-11-26", "-0105-11-08"}}, // 太初改历交接 + } + for _, c := range cases { + res, err := LunarToSolarByYMD(c.year, c.month, c.day, c.leap) + if err != nil { + t.Fatalf("LunarToSolarByYMD(%d,%d,%d,%v) error: %v", c.year, c.month, c.day, c.leap, err) + } + candidates := res.SolarCandidates() + if len(candidates) != len(c.want) { + t.Errorf("%d/%d/%d: got %d candidates %v, want %d %v", + c.year, c.month, c.day, len(candidates), formatCandidates(candidates), len(c.want), c.want) + continue + } + if !candidates[0].Equal(res.Solar()) { + t.Errorf("%d/%d/%d: first candidate %s is not Solar() %s", + c.year, c.month, c.day, formatAncientSolar(candidates[0]), formatAncientSolar(res.Solar())) + } + for i, v := range candidates { + if got := formatAncientSolar(v); got != c.want[i] { + t.Errorf("%d/%d/%d: candidate %d = %s, want %s", c.year, c.month, c.day, i, got, c.want[i]) + } + for j := 0; j < i; j++ { + if candidates[j].Equal(v) { + t.Errorf("%d/%d/%d: candidate %d duplicates candidate %d (%s)", + c.year, c.month, c.day, i, j, formatAncientSolar(v)) + } + } + fwd, err := SolarToLunarByYMD(v.Year(), int(v.Month()), v.Day()) + if err != nil { + t.Errorf("%d/%d/%d: candidate %s does not convert forward: %v", + c.year, c.month, c.day, formatAncientSolar(v), err) + continue + } + l := fwd.Lunar() + if l.LunarYear() != c.year || l.LunarMonth() != c.month || l.LunarDay() != c.day || l.IsLeap() != c.leap { + t.Errorf("%d/%d/%d: candidate %s converts back to lunar %d/%d/%d leap=%v", + c.year, c.month, c.day, formatAncientSolar(v), + l.LunarYear(), l.LunarMonth(), l.LunarDay(), l.IsLeap()) + } + } + } +} + +// 改历窗口外只有一个候选,且 SolarCandidates() 返回的是副本。 +func TestSolarCandidatesAreSingleOutsideReformWindows(t *testing.T) { + cases := []struct{ year, month, day int }{ + {763, 4, 1}, {701, 11, 1}, {24, 12, 1}, {241, 12, 1}, {-103, 10, 1}, + {-105, 10, 1}, {2025, 1, 1}, {1912, 1, 1}, {1000, 7, 7}, {1, 1, 1}, + } + for _, c := range cases { + res, err := LunarToSolarByYMD(c.year, c.month, c.day, false) + if err != nil { + t.Fatalf("LunarToSolarByYMD(%d,%d,%d) error: %v", c.year, c.month, c.day, err) + } + candidates := res.SolarCandidates() + if len(candidates) != 1 { + t.Errorf("%d/%d/%d: got %d candidates %v, want a single one", + c.year, c.month, c.day, len(candidates), formatCandidates(candidates)) + continue + } + if !candidates[0].Equal(res.Solar()) { + t.Errorf("%d/%d/%d: single candidate %s is not Solar() %s", + c.year, c.month, c.day, formatAncientSolar(candidates[0]), formatAncientSolar(res.Solar())) + } + // 返回值必须是副本。 + candidates[0] = candidates[0].AddDate(1, 1, 1) + if again := res.SolarCandidates(); !again[0].Equal(res.Solar()) { + t.Errorf("%d/%d/%d: SolarCandidates() is not a copy (%s)", c.year, c.month, c.day, formatAncientSolar(again[0])) + } + } +} + +// 1582 改历跳过的十天本身不存在,与 L2/L3 无关。 +func TestGregorianSkippedDaysHaveNoCandidates(t *testing.T) { + for day := 5; day <= 14; day++ { + if _, err := SolarToLunarByYMD(1582, 10, day); err == nil { + t.Errorf("SolarToLunarByYMD(1582,10,%d) unexpectedly succeeded", day) + } + } + res, err := LunarToSolarByYMD(1582, 9, 18, false) + if err != nil { + t.Fatalf("LunarToSolarByYMD(1582,9,18) error: %v", err) + } + if got := len(res.SolarCandidates()); got != 1 { + t.Errorf("lunar 1582/9/18 has %d candidates, want 1", got) + } + if _, err := time.Parse("2006-01-02", formatAncientSolar(res.Solar())); err != nil { + t.Errorf("solar label %q is not a valid date", formatAncientSolar(res.Solar())) + } +} + +func formatCandidates(candidates []time.Time) []string { + out := make([]string, 0, len(candidates)) + for _, v := range candidates { + out = append(out, formatAncientSolar(v)) + } + return out +} diff --git a/calendar/time.go b/calendar/time.go index 9b0d3b3..1cd28c7 100644 --- a/calendar/time.go +++ b/calendar/time.go @@ -32,6 +32,10 @@ type LunarInfo struct { CalendarSystem AncientCalendarSystem `json:"calendarSystem"` // CalendarName 历法名称 CalendarName string `json:"calendarName"` + // JDE 该农历日精确的儒略日 / exact Julian day. + JDE float64 `json:"jde"` + // JulianOnly 是否只存在于儒略历 / Julian-calendar-only date. + JulianOnly bool `json:"julianOnly,omitempty"` // Dynasty 朝代,如唐、宋、元、明、清等 Dynasty string `json:"dynasty"` // Emperor 皇帝姓名(仅供参考,多个皇帝用同一个年号的场景,此处不准) @@ -51,6 +55,8 @@ type LunarInfo struct { type Time struct { solarTime time.Time lunars []LunarTime + // solarCandidates 是该农历日的全部合法公历候选,首个元素恒等于 solarTime;单候选时为 nil。 + solarCandidates []time.Time } // Solar 公历时间 / solar time. @@ -162,22 +168,29 @@ func (t Time) Lunar() LunarTime { // Add 时间偏移 / add a duration. // -// 返回公历时间偏移后的农历结果。 -// Returns the lunar-calendar result after applying the duration to the stored civil time. +// 按公历时间轴精确相加;区间跨过 1582 改历空窗时改走儒略日轴,跳过不存在的 10 天。 +// Adds exactly along the civil timeline; when the interval crosses the 1582 reform gap it moves +// along the Julian-day axis instead, skipping the ten days that do not exist. func (t Time) Add(d time.Duration) Time { - if d < time.Second { - newT := t.solarTime.Add(d) - rT, _ := SolarToLunar(newT) - return rT + newT := t.solarTime.Add(d) + if crossesGregorianReformGap(t.solarTime, newT) { + jde := Date2JDE(t.solarTime) + d.Seconds()/86400.0 + newT = basic.JDE2DateByZone(jde, t.solarTime.Location(), true) } - sec := d.Seconds() - jde := Date2JDE(t.solarTime) - jde += sec / 86400.0 - newT := basic.JDE2DateByZone(jde, t.solarTime.Location(), true) rT, _ := SolarToLunar(newT) return rT } +// crossesGregorianReformGap 报告两个时刻之间是否包含 1582 改历跳过的 10 天(公历 1582-10-05..14)。 +func crossesGregorianReformGap(from, to time.Time) bool { + start := time.Date(1582, 10, 5, 0, 0, 0, 0, from.Location()) + end := time.Date(1582, 10, 15, 0, 0, 0, 0, from.Location()) + if to.Before(from) { + from, to = to, from + } + return from.Before(end) && !to.Before(start) +} + type LunarTime struct { solarDate time.Time //农历年 @@ -200,10 +213,53 @@ type LunarTime struct { calendarSystem AncientCalendarSystem //历法名称 calendarName string + // julianOnly 该农历日是否只存在于儒略历(如 700-02-29);Solar 取后继日作为标签。 + julianOnly bool + // jde 该农历日精确的儒略日,仅在 julianOnly 时显式保存。 + jde float64 eras []EraDesc } +// JulianOnly 该农历日是否只存在于儒略历 / whether the date exists only in the Julian calendar. +func (l LunarTime) JulianOnly() bool { + return l.julianOnly +} + +// JDE 该农历日精确的儒略日;儒略历独有闰日比 Solar() 早一天 / exact Julian day. +func (l LunarTime) JDE() float64 { + if l.julianOnly && l.jde > 0 { + return l.jde + } + return Date2JDE(l.solarDate) +} + +// JulianOnly 主历法农历日是否只存在于儒略历 / whether the primary date is Julian-calendar-only. +func (t Time) JulianOnly() bool { + if len(t.lunars) == 0 { + return false + } + return t.lunars[0].JulianOnly() +} + +// JDE 主历法农历日精确的儒略日 / exact Julian day of the primary lunar date. +func (t Time) JDE() float64 { + if len(t.lunars) == 0 { + return Date2JDE(t.solarTime) + } + return t.lunars[0].JDE() +} + +// SolarCandidates 该农历日的全部合法公历候选,首个恒等于 Solar() / every legal civil date, Solar() first. +func (t Time) SolarCandidates() []time.Time { + if len(t.solarCandidates) == 0 { + return []time.Time{t.solarTime} + } + out := make([]time.Time, len(t.solarCandidates)) + copy(out, t.solarCandidates) + return out +} + // ShengXiao 生肖 / Chinese zodiac. func (l LunarTime) ShengXiao() string { shengxiao := []string{"猴", "鸡", "狗", "猪", "鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊"} @@ -231,6 +287,9 @@ func (l LunarTime) GanZhiMonth() string { // GanZhiDay 日干支 / sexagenary day name. func (l LunarTime) GanZhiDay() string { + if l.julianOnly && l.jde > 0 { + return ganZhiOfJDE(l.jde) + } return GanZhiOfDay(l.solarDate) } @@ -350,7 +409,7 @@ func (l LunarTime) LunarInfo() []LunarInfo { LunarMonthDayDesc: l.desc, GanzhiYear: GanZhiOfYear(l.year), GanzhiMonth: l.ganzhiMonth, - GanzhiDay: GanZhiOfDay(l.solarDate), + GanzhiDay: l.GanZhiDay(), CalendarSystem: l.calendarSystem, CalendarName: l.calendarName, Dynasty: v.Dynasty, @@ -360,6 +419,8 @@ func (l LunarTime) LunarInfo() []LunarInfo { EraDesc: v.String(), LunarWithEraDesc: v.String() + l.desc, ChineseZodiac: l.ShengXiao(), + JDE: l.JDE(), + JulianOnly: l.julianOnly, } res = append(res, li) } @@ -374,7 +435,7 @@ func (l LunarTime) LunarInfo() []LunarInfo { LunarMonthDayDesc: l.desc, GanzhiYear: GanZhiOfYear(l.year), GanzhiMonth: l.ganzhiMonth, - GanzhiDay: GanZhiOfDay(l.solarDate), + GanzhiDay: l.GanZhiDay(), CalendarSystem: l.calendarSystem, CalendarName: l.calendarName, Dynasty: "", @@ -384,6 +445,8 @@ func (l LunarTime) LunarInfo() []LunarInfo { EraDesc: lunarYearDesc(l.year) + "年", LunarWithEraDesc: lunarYearDesc(l.year) + "年" + l.desc, ChineseZodiac: l.ShengXiao(), + JDE: l.JDE(), + JulianOnly: l.julianOnly, } res = append(res, li) } diff --git a/calendar/time_add_test.go b/calendar/time_add_test.go new file mode 100644 index 0000000..aa063a8 --- /dev/null +++ b/calendar/time_add_test.go @@ -0,0 +1,69 @@ +package calendar + +import ( + "testing" + "time" +) + +func addBase(t *testing.T, y, m, d int) Time { + t.Helper() + res, err := LunarToSolarByYMD(y, 1, 1, false) // 只是拿一个合法 Time,日期另行构造 + _ = res + if err != nil { + t.Fatal(err) + } + fwd, err := SolarToLunarByYMD(y, m, d) + if err != nil { + t.Fatalf("SolarToLunarByYMD(%d,%d,%d) error: %v", y, m, d, err) + } + return fwd +} + +// Add 必须与 time.Time 一样精确;此前 d≥1s 时经儒略日往返,普通日期会漂几微秒。 +// Add must be as exact as time.Time; it used to round-trip through the Julian day for d >= 1s, +// which drifted by a few microseconds on ordinary dates. +func TestTimeAddIsExact(t *testing.T) { + base := addBase(t, 2024, 3, 1) + cases := []struct { + d time.Duration + want string + }{ + {time.Millisecond, "2024-03-01 00:00:00.001"}, + {time.Second, "2024-03-01 00:00:01.000"}, + {time.Minute, "2024-03-01 00:01:00.000"}, + {time.Hour, "2024-03-01 01:00:00.000"}, + {24 * time.Hour, "2024-03-02 00:00:00.000"}, + {30 * 24 * time.Hour, "2024-03-31 00:00:00.000"}, + {-time.Hour, "2024-02-29 23:00:00.000"}, + {-time.Millisecond, "2024-02-29 23:59:59.999"}, + } + for _, c := range cases { + if got := base.Add(c.d).Solar().Format("2006-01-02 15:04:05.000"); got != c.want { + t.Errorf("Add(%v) = %s, want %s", c.d, got, c.want) + } + } +} + +// 1582 改历跳过的 10 天不能出现在结果里:跨过该窗口的偏移要落在 10 天之后。 +// The ten days skipped by the 1582 reform must never appear: an offset crossing that window lands +// after them. +func TestTimeAddSkipsGregorianReformGap(t *testing.T) { + cases := []struct { + y, m, d int + delta time.Duration + want string + }{ + {1582, 10, 4, 24 * time.Hour, "1582-10-15"}, + {1582, 10, 15, -24 * time.Hour, "1582-10-04"}, + {1582, 9, 20, 30 * 24 * time.Hour, "1582-10-30"}, + {1582, 10, 20, -30 * 24 * time.Hour, "1582-09-10"}, + {1582, 9, 20, 24 * time.Hour, "1582-09-21"}, + {1582, 10, 20, 24 * time.Hour, "1582-10-21"}, + } + for _, c := range cases { + got := addBase(t, c.y, c.m, c.d).Add(c.delta).Solar().Format("2006-01-02") + if got != c.want { + t.Errorf("%d-%02d-%02d Add(%v) = %s, want %s", c.y, c.m, c.d, c.delta, got, c.want) + } + } +} diff --git a/coord/coord.go b/coord/coord.go index 7de39d8..a55f099 100644 --- a/coord/coord.go +++ b/coord/coord.go @@ -115,8 +115,9 @@ func TopocentricEquatorial(date time.Time, ra, dec, observerLon, observerLat, di // distanceAU 为目标天体到地心距离,单位 AU;height 为观测者海拔,单位米。 // distanceAU is geocentric distance in AU; height is observer elevation in meters. func TopocentricEcliptic(date time.Time, lon, lat, observerLon, observerLat, distanceAU, height float64) Ecliptic { - topLon := basic.TopocentricLo(lon, lat, observerLat, observerLon, jdeUTC(date), distanceAU, height) - topLat := basic.TopocentricBo(lon, lat, observerLat, observerLon, jdeUTC(date), distanceAU, height) + jde := jdeUTC(date) + topLon := basic.TopocentricLo(lon, lat, observerLat, observerLon, jde, distanceAU, height) + topLat := basic.TopocentricBo(lon, lat, observerLat, observerLon, jde, distanceAU, height) return Ecliptic{Lon: topLon, Lat: topLat} } diff --git a/coord/coord_test.go b/coord/coord_test.go index 4bee808..59bb6e6 100644 --- a/coord/coord_test.go +++ b/coord/coord_test.go @@ -2,6 +2,7 @@ package coord import ( "math" + "math/rand" "testing" "time" @@ -82,3 +83,81 @@ func TestAngularSeparationWrapper(t *testing.T) { want := basic.StarAngularSeparation(101.28715533, -16.71611586, 95.9879578, -52.6956611) assertClose(t, "angular separation", got, want, 1e-12) } + +// TopocentricEcliptic 对同一时刻只求一次儒略日:参考实现按旧口径重复求值,逐位对照。 +func TestTopocentricEclipticMatchesDuplicatedJDE(t *testing.T) { + type sample struct { + label string + date time.Time + lon, lat float64 + obsLon float64 + obsLat float64 + distanceAU float64 + height float64 + } + zones := []*time.Location{ + time.UTC, + time.FixedZone("CST", 8*3600), + time.FixedZone("EST", -5*3600), + time.FixedZone("LMT", -7*3600-52*60-58), + } + sites := []struct { + name string + lon, lat float64 + }{ + {"shanghai", 121.4737, 31.2304}, + {"sydney", 151.2093, -33.8688}, + {"north-pole", 0, 89.9999}, + {"south-pole", 0, -89.9999}, + {"dateline-west", -179.99, 12}, + {"dateline-east", 179.99, -12}, + {"equator", 0, 0}, + } + years := []int{-500, 1000, 1582, 2025, 2100, 3000, 4000} + hours := []int{0, 5, 12, 20, 23} + cases := make([]sample, 0, len(sites)*len(zones)*len(years)*len(hours)+200) + for _, site := range sites { + for _, zone := range zones { + for _, year := range years { + for _, hour := range hours { + cases = append(cases, sample{ + label: site.name + "/" + zone.String(), + date: time.Date(year, 3, 17, hour, 43, 21, 123456789, zone), + lon: 139.686111, + lat: 4.875278, + obsLon: site.lon, + obsLat: site.lat, + distanceAU: 0.00257, + height: 53, + }) + } + } + } + } + rng := rand.New(rand.NewSource(20260915)) + for i := 0; i < 200; i++ { + zone := time.FixedZone("random", (rng.Intn(97)-48)*1800) + cases = append(cases, sample{ + label: "random", + date: time.Date( + rng.Intn(8000)-2000, time.Month(1+rng.Intn(12)), 1+rng.Intn(28), + rng.Intn(24), rng.Intn(60), rng.Intn(60), rng.Intn(1000000000), zone, + ), + lon: rng.Float64()*360 - 180, + lat: rng.Float64()*180 - 90, + obsLon: rng.Float64()*360 - 180, + obsLat: rng.Float64()*179.8 - 89.9, + distanceAU: 0.0001 + rng.Float64()*40, + height: rng.Float64() * 5000, + }) + } + + for _, tc := range cases { + got := TopocentricEcliptic(tc.date, tc.lon, tc.lat, tc.obsLon, tc.obsLat, tc.distanceAU, tc.height) + wantLon := basic.TopocentricLo(tc.lon, tc.lat, tc.obsLat, tc.obsLon, jdeUTC(tc.date), tc.distanceAU, tc.height) + wantLat := basic.TopocentricBo(tc.lon, tc.lat, tc.obsLat, tc.obsLon, jdeUTC(tc.date), tc.distanceAU, tc.height) + if got.Lon != wantLon || got.Lat != wantLat { + t.Fatalf("%s %s: got (%.17g, %.17g) want (%.17g, %.17g)", tc.label, tc.date.Format(time.RFC3339Nano), got.Lon, got.Lat, wantLon, wantLat) + } + } +} diff --git a/coord/perf_bench_test.go b/coord/perf_bench_test.go new file mode 100644 index 0000000..cb1ebf1 --- /dev/null +++ b/coord/perf_bench_test.go @@ -0,0 +1,39 @@ +package coord + +import ( + "testing" + "time" + + "b612.me/astro/basic" +) + +var benchmarkEclipticSink Ecliptic + +func benchmarkEclipticInputs() (time.Time, float64, float64, float64, float64, float64, float64) { + return time.Date(2026, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)), 139.7, 4.9, 121.4737, 31.2304, 0.00257, 20 +} + +func BenchmarkTopocentricEcliptic(b *testing.B) { + date, lon, lat, observerLon, observerLat, distanceAU, height := benchmarkEclipticInputs() + b.ReportAllocs() + b.ResetTimer() + sink := Ecliptic{} + for i := 0; i < b.N; i++ { + sink = TopocentricEcliptic(date, lon, lat, observerLon, observerLat, distanceAU, height) + } + benchmarkEclipticSink = sink +} + +func BenchmarkTopocentricEclipticLegacy(b *testing.B) { + date, lon, lat, observerLon, observerLat, distanceAU, height := benchmarkEclipticInputs() + b.ReportAllocs() + b.ResetTimer() + sink := Ecliptic{} + for i := 0; i < b.N; i++ { + sink = Ecliptic{ + Lon: basic.TopocentricLo(lon, lat, observerLat, observerLon, jdeUTC(date), distanceAU, height), + Lat: basic.TopocentricBo(lon, lat, observerLat, observerLon, jdeUTC(date), distanceAU, height), + } + } + benchmarkEclipticSink = sink +} diff --git a/doc/lunar-eclipse-2026-03-03-detailed-en.svg b/doc/lunar-eclipse-2026-03-03-detailed-en.svg new file mode 100644 index 0000000..0892263 --- /dev/null +++ b/doc/lunar-eclipse-2026-03-03-detailed-en.svg @@ -0,0 +1,2 @@ + +Total Lunar Eclipse of 2026-03-03Greatest Eclipse = 19:33:42 (CST) | Umbral magnitude = 1.1506Penumbral magnitude = 2.1837 | Gamma = 0.3764P. Radius = 1.2361° | U. Radius = 0.6983° | Axis = 0.3596°Saros series = 133 | member 27 of 71All times are CST (UT+08:00)Sun at greatest eclipse赤经 R.A.22h55m46.8s赤纬 Dec.-06°50'13.2"视半径 S.D.00°16'07.7"H.P.00°00'08.9"Moon at greatest eclipse赤经 R.A.10h41m09.4s赤纬 Dec.+08°21'05.0"视半径 S.D.00°15'39.6"H.P.00°57'29.6"Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Eclipse durationsPenumbral338:40Partial207:11Total58:19Eclipse contactsP1 penumbral begins16:44:25U1 partial begins17:50:05U2 total begins19:04:32Greatest19:33:42U3 total ends20:02:50U4 partial ends21:17:16P4 penumbral ends22:23:050100角分Entire eclipseMoonrise during eclipseMoonset during eclipseNot visibleShadow-path diagram uses the Danjon shadow model; the lower map marks where the whole eclipse is visible, where the Moon rises or sets eclipsed, and where it is not visible. Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/lunar-eclipse-2026-03-03-detailed.svg b/doc/lunar-eclipse-2026-03-03-detailed.svg new file mode 100644 index 0000000..ae11fb8 --- /dev/null +++ b/doc/lunar-eclipse-2026-03-03-detailed.svg @@ -0,0 +1,2 @@ + +2026-03-03 月全食食甚 = 19:33:42(CST)| 本影食分 = 1.1506半影食分 = 2.1837 | 伽马 = 0.3764半影半径 = 1.2361° | 本影半径 = 0.6983° | 影轴角距 = 0.3596°沙罗序列 = 133 | 第 27 / 71 个成员图中时刻为 CST(UT+08:00)食甚时的太阳(地心坐标)赤经 R.A.22h55m46.8s赤纬 Dec.-06°50'13.2"视半径 S.D.00°16'07.7"地平视差 H.P.00°00'08.9"食甚时的月亮(地心坐标)赤经 R.A.10h41m09.4s赤纬 Dec.+08°21'05.0"视半径 S.D.00°15'39.6"地平视差 H.P.00°57'29.6"地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终月食历时半影食338:40偏食207:11全食58:19接触时刻P1 半影食始16:44:25U1 初亏17:50:05U2 食既19:04:32食甚19:33:42U3 生光20:02:50U4 复圆21:17:16P4 半影食终22:23:050100角分全程可见带食月出带食月落不可见穿影示意图使用 Danjon 影半径模型;下方底图区分全程可见、带食月出、带食月落与不可见四类区域。Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-eclipse-2026-03-03-en.svg b/doc/lunar-eclipse-2026-03-03-en.svg index 5c6f2fe..93993f1 100644 --- a/doc/lunar-eclipse-2026-03-03-en.svg +++ b/doc/lunar-eclipse-2026-03-03-en.svg @@ -1,2 +1,2 @@ -2026-03-03 Total Lunar Eclipsetype=Total Lunar Eclipse penumbral=2.1837 umbral=1.1506Maximum: 2026-03-03 19:33:42 UTC+8Moon: RA 10h56m15s Dec +06°24′05″ ecl.lon 162.8597 deg ecl.lat -0.3578 deg LeoPenumbral duration 05:38:40 Umbral duration 03:27:11 Total duration 00:58:19Lunar Saros 133 27/71Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Contacts (UTC+8)P1 Penumbral begins 16:44:25 PA 104.3°U1 Partial begins 17:50:05 PA 96.2°U2 Total begins 19:04:32 PA 243.0°GE Greatest 19:33:42U3 Total ends 20:02:50 PA 173.4°U4 Partial ends 21:17:16 PA 320.3°P4 Penumbral ends 22:23:05 PA 312.1°North is up and east is left; the ecliptic is projected near greatest eclipse.Moon disks and shadow radii are drawn to the same relative angular-radius scale. \ No newline at end of file +2026-03-03 Total Lunar Eclipsetype=Total Lunar Eclipse penumbral=2.1837 umbral=1.1506Maximum: 2026-03-03 19:33:42 CSTMoon: RA 10h56m15s Dec +06°24′05″ ecl.lon 162.8597 deg ecl.lat -0.3578 deg LeoPenumbral duration 05:38:40 Umbral duration 03:27:11 Total duration 00:58:19Lunar Saros 133 27/71Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Contacts (CST)P1 Penumbral begins 16:44:25 PA 104.3°U1 Partial begins 17:50:05 PA 96.2°U2 Total begins 19:04:32 PA 243.0°GE Greatest 19:33:42U3 Total ends 20:02:50 PA 173.4°U4 Partial ends 21:17:16 PA 320.3°P4 Penumbral ends 22:23:05 PA 312.1°North is up and east is left; the ecliptic is projected near greatest eclipse.Moon disks and shadow radii are drawn to the same relative angular-radius scale. \ No newline at end of file diff --git a/doc/lunar-eclipse-2026-03-03.svg b/doc/lunar-eclipse-2026-03-03.svg index d7ccff1..15161bd 100644 --- a/doc/lunar-eclipse-2026-03-03.svg +++ b/doc/lunar-eclipse-2026-03-03.svg @@ -1,2 +1,2 @@ -2026-03-03 月全食食型=月全食 半影食分=2.1837 本影食分=1.1506食甚:2026-03-03 19:33:42 UTC+8月球:赤经 10h56m15s 赤纬 +06°24′05″ 黄经 162.8597° 黄纬 -0.3578° 狮子座半影历时 05:38:40 本影历时 03:27:11 全食历时 00:58:19沙罗 133 第 27/71 个成员地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终接触时刻 (UTC+8)P1 半影始 16:44:25 方位 104.3°U1 初亏 17:50:05 方位 96.2°U2 食既 19:04:32 方位 243.0°GE 食甚 19:33:42U3 生光 20:02:50 方位 173.4°U4 复圆 21:17:16 方位 320.3°P4 半影终 22:23:05 方位 312.1°上北下南,左东右西;黄道按食甚附近天球投影绘制。图中月面大小和影半径均按实际相对角半径缩放。 \ No newline at end of file +2026-03-03 月全食食型=月全食 半影食分=2.1837 本影食分=1.1506食甚:2026-03-03 19:33:42 CST月球:赤经 10h56m15s 赤纬 +06°24′05″ 黄经 162.8597° 黄纬 -0.3578° 狮子座半影历时 05:38:40 本影历时 03:27:11 全食历时 00:58:19沙罗 133 第 27/71 个成员地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终接触时刻 (CST)P1 半影始 16:44:25 方位 104.3°U1 初亏 17:50:05 方位 96.2°U2 食既 19:04:32 方位 243.0°GE 食甚 19:33:42U3 生光 20:02:50 方位 173.4°U4 复圆 21:17:16 方位 320.3°P4 半影终 22:23:05 方位 312.1°上北下南,左东右西;黄道按食甚附近天球投影绘制。图中月面大小和影半径均按实际相对角半径缩放。 \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-detailed-en.svg b/doc/lunar-eclipse-2029-01-01-detailed-en.svg new file mode 100644 index 0000000..1d49934 --- /dev/null +++ b/doc/lunar-eclipse-2029-01-01-detailed-en.svg @@ -0,0 +1,2 @@ + +Total Lunar Eclipse of 2029-01-01Greatest Eclipse = 00:52:05 (CST) | Umbral magnitude = 1.2461Penumbral magnitude = 2.2740 | Gamma = 0.3258P. Radius = 1.2511° | U. Radius = 0.7089° | Axis = 0.3153°Saros series = 125 | member 49 of 72All times are CST (UT+08:00)Sun at greatest eclipse赤经 R.A.18h45m43.7s赤纬 Dec.-23°01'11.6"视半径 S.D.00°16'15.5"H.P.00°00'08.9"Moon at greatest eclipse赤经 R.A.06h44m00.7s赤纬 Dec.+23°24'39.0"视半径 S.D.00°15'48.7"H.P.00°58'03.0"Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Eclipse durationsPenumbral336:17Partial208:50Total71:19Eclipse contactsP1 penumbral begins22:03:54U1 partial begins23:07:42U2 total begins00:16:27Greatest00:52:05U3 total ends01:27:46U4 partial ends02:36:32P4 penumbral ends03:40:110100角分Entire eclipseMoonrise during eclipseMoonset during eclipseNot visibleShadow-path diagram uses the Danjon shadow model; the lower map marks where the whole eclipse is visible, where the Moon rises orsets eclipsed, and where it is not visible. Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-detailed.svg b/doc/lunar-eclipse-2029-01-01-detailed.svg new file mode 100644 index 0000000..cc984fd --- /dev/null +++ b/doc/lunar-eclipse-2029-01-01-detailed.svg @@ -0,0 +1,2 @@ + +2029-01-01 月全食食甚 = 00:52:05(CST)| 本影食分 = 1.2461半影食分 = 2.2740 | 伽马 = 0.3258半影半径 = 1.2511° | 本影半径 = 0.7089° | 影轴角距 = 0.3153°沙罗序列 = 125 | 第 49 / 72 个成员图中时刻为 CST(UT+08:00)食甚时的太阳(地心坐标)赤经 R.A.18h45m43.7s赤纬 Dec.-23°01'11.6"视半径 S.D.00°16'15.5"地平视差 H.P.00°00'08.9"食甚时的月亮(地心坐标)赤经 R.A.06h44m00.7s赤纬 Dec.+23°24'39.0"视半径 S.D.00°15'48.7"地平视差 H.P.00°58'03.0"地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终月食历时半影食336:17偏食208:50全食71:19接触时刻P1 半影食始22:03:54U1 初亏23:07:42U2 食既00:16:27食甚00:52:05U3 生光01:27:46U4 复圆02:36:32P4 半影食终03:40:110100角分全程可见带食月出带食月落不可见穿影示意图使用 Danjon 影半径模型;下方底图区分全程可见、带食月出、带食月落与不可见四类区域。Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-en.svg b/doc/lunar-eclipse-2029-01-01-en.svg index 1707dfa..00c2112 100644 --- a/doc/lunar-eclipse-2029-01-01-en.svg +++ b/doc/lunar-eclipse-2029-01-01-en.svg @@ -1,2 +1,2 @@ -2029-01-01 Total Lunar Eclipsetype=Total Lunar Eclipse penumbral=2.2740 umbral=1.2461Maximum: 2029-01-01 00:52:05 UTC+8Moon: RA 06h46m08s Dec +23°19′37″ ecl.lon 100.5810 deg ecl.lat 0.3137 deg GeminiPenumbral duration 05:36:17 Umbral duration 03:28:50 Total duration 01:11:19Lunar Saros 125 49/72Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Contacts (UTC+8)P1 Penumbral begins 22:03:54 PA 112.2°U1 Partial begins 23:07:42 PA 119.1°U2 Total begins 00:16:27 PA 325.4°GE Greatest 00:52:05U3 Total ends 01:27:46 PA 55.2°U4 Partial ends 02:36:32 PA 261.4°P4 Penumbral ends 03:40:11 PA 268.3°North is up and east is left; the ecliptic is projected near greatest eclipse.Moon disks and shadow radii are drawn to the same relative angular-radius scale. \ No newline at end of file +2029-01-01 Total Lunar Eclipsetype=Total Lunar Eclipse penumbral=2.2740 umbral=1.2461Maximum: 2029-01-01 00:52:05 CSTMoon: RA 06h46m08s Dec +23°19′37″ ecl.lon 100.5810 deg ecl.lat 0.3137 deg GeminiPenumbral duration 05:36:17 Umbral duration 03:28:50 Total duration 01:11:19Lunar Saros 125 49/72Earth's PenumbraEarth's UmbraNEWSEclipticP1U1U2GreatestU3U4P4Contacts (CST)P1 Penumbral begins 22:03:54 PA 112.2°U1 Partial begins 23:07:42 PA 119.1°U2 Total begins 00:16:27 PA 325.4°GE Greatest 00:52:05U3 Total ends 01:27:46 PA 55.2°U4 Partial ends 02:36:32 PA 261.4°P4 Penumbral ends 03:40:11 PA 268.3°North is up and east is left; the ecliptic is projected near greatest eclipse.Moon disks and shadow radii are drawn to the same relative angular-radius scale. \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-global-en.svg b/doc/lunar-eclipse-2029-01-01-global-en.svg index 9168d89..2743a6b 100644 --- a/doc/lunar-eclipse-2029-01-01-global-en.svg +++ b/doc/lunar-eclipse-2029-01-01-global-en.svg @@ -1 +1 @@ -2029-01-01 Total Lunar Eclipse Global VisibilityP1 22:03:54 | Greatest 00:52:05 | P4 03:40:11 (CST) | penumbral magnitude 2.274 | umbral magnitude 1.246Entire eclipseMoonrise during eclipseMoonset during eclipseNot visibleEquirectangular projection; P1/P4 Moon-visible hemispheres; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file +2029-01-01 Total Lunar Eclipse Global VisibilityP1 22:03:54 | Greatest 00:52:05 | P4 03:40:11 (CST) | penumbral magnitude 2.274 | umbral magnitude 1.246Entire eclipseMoonrise during eclipseMoonset during eclipseNot visibleEquirectangular projection; P1/P4 Moon-visible hemispheres; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01-global.svg b/doc/lunar-eclipse-2029-01-01-global.svg index a00817c..92ebb70 100644 --- a/doc/lunar-eclipse-2029-01-01-global.svg +++ b/doc/lunar-eclipse-2029-01-01-global.svg @@ -1 +1 @@ -2029-01-01 月全食全球可见图P1 22:03:54 | 食甚 00:52:05 | P4 03:40:11 (CST) | 半影食分 2.274 | 本影食分 1.246全程可见带食月出带食月落不可见等经纬投影;按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file +2029-01-01 月全食全球可见图P1 22:03:54 | 食甚 00:52:05 | P4 03:40:11 (CST) | 半影食分 2.274 | 本影食分 1.246全程可见带食月出带食月落不可见等经纬投影;按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-eclipse-2029-01-01.svg b/doc/lunar-eclipse-2029-01-01.svg index 6fed222..78ef145 100644 --- a/doc/lunar-eclipse-2029-01-01.svg +++ b/doc/lunar-eclipse-2029-01-01.svg @@ -1,2 +1,2 @@ -2029-01-01 月全食食型=月全食 半影食分=2.2740 本影食分=1.2461食甚:2029-01-01 00:52:05 UTC+8月球:赤经 06h46m08s 赤纬 +23°19′37″ 黄经 100.5810° 黄纬 0.3137° 双子座半影历时 05:36:17 本影历时 03:28:50 全食历时 01:11:19沙罗 125 第 49/72 个成员地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终接触时刻 (UTC+8)P1 半影始 22:03:54 方位 112.2°U1 初亏 23:07:42 方位 119.1°U2 食既 00:16:27 方位 325.4°GE 食甚 00:52:05U3 生光 01:27:46 方位 55.2°U4 复圆 02:36:32 方位 261.4°P4 半影终 03:40:11 方位 268.3°上北下南,左东右西;黄道按食甚附近天球投影绘制。图中月面大小和影半径均按实际相对角半径缩放。 \ No newline at end of file +2029-01-01 月全食食型=月全食 半影食分=2.2740 本影食分=1.2461食甚:2029-01-01 00:52:05 CST月球:赤经 06h46m08s 赤纬 +23°19′37″ 黄经 100.5810° 黄纬 0.3137° 双子座半影历时 05:36:17 本影历时 03:28:50 全食历时 01:11:19沙罗 125 第 49/72 个成员地球半影地球本影北东西南黄道P1 半影始U1 初亏U2 食既食甚U3 生光U4 复圆P4 半影终接触时刻 (CST)P1 半影始 22:03:54 方位 112.2°U1 初亏 23:07:42 方位 119.1°U2 食既 00:16:27 方位 325.4°GE 食甚 00:52:05U3 生光 01:27:46 方位 55.2°U4 复圆 02:36:32 方位 261.4°P4 半影终 03:40:11 方位 268.3°上北下南,左东右西;黄道按食甚附近天球投影绘制。图中月面大小和影半径均按实际相对角半径缩放。 \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg b/doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg new file mode 100644 index 0000000..7372a2e --- /dev/null +++ b/doc/lunar-occultation-hr4799-2025-06-05-detailed-en.svg @@ -0,0 +1 @@ +2025-06-05 Lunar Occultation of HR 4799Occultation begins 17:45:28 | Greatest 20:02:06 | Occultation ends 22:18:49 (CST)Greatest point 121.5661°E, 6.8071°N | Moon altitude +75.6°18:3019:0019:3020:0020:3021:0021:30StartGreatestEndVisible center lineGeometric center lineBand and limitsRise/set phase linesMoon (geocentric)R.A.12h38m32.1sDec.-05°46'28.0"S.D.00°14'48.0"H.P.00°54'19.9"Target bodyTargetHR 4799R.A.12h36m47.4sDec.-05°49'55.0"S.D.—Band path pointsPartial begins17:45:2863.9930°E, 39.0894°NGreatest20:02:06121.5661°E, 6.8071°NPartial ends22:18:49172.2912°E, 16.6450°SContactsPartial begins17:45:28Greatest20:02:06Partial ends22:18:49Ephemeris and constantsProjectionOrthographicΔT69.2 sMoon distance403584 kmPartial-band width3666.6 kmLibrationLibration l+3.53°Libration b+2.02°Axis position angle c21.62°Orthographic globe centred on the greatest occultation; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-detailed.svg b/doc/lunar-occultation-hr4799-2025-06-05-detailed.svg new file mode 100644 index 0000000..b73a98a --- /dev/null +++ b/doc/lunar-occultation-hr4799-2025-06-05-detailed.svg @@ -0,0 +1 @@ +2025-06-05 月掩HR 4799全球掩带掩始 17:45:28 | 掩甚 20:02:06 | 掩终 22:18:49(CST)掩甚点 121.5661°E, 6.8071°N | 月球高度 +75.6°18:3019:0019:3020:0020:3021:0021:30掩始掩甚掩终可见中心线几何中心线掩带范围与边界初掩/掩甚/终掩月升月落线月亮(地心坐标)赤经 R.A.12h38m32.1s赤纬 Dec.-05°46'28.0"视半径 S.D.00°14'48.0"地平视差 H.P.00°54'19.9"目标天体目标HR 4799赤经 R.A.12h36m47.4s赤纬 Dec.-05°49'55.0"视半径 S.D.—掩带路径点外掩始17:45:2863.9930°E, 39.0894°N掩甚20:02:06121.5661°E, 6.8071°N外掩终22:18:49172.2912°E, 16.6450°S接触时刻外掩始17:45:28掩甚20:02:06外掩终22:18:49历表与常数投影正射球面ΔT69.2 s月距403584 km部分掩带宽3666.6 km天平动经天平动 l+3.53°纬天平动 b+2.02°自转轴位置角 c21.62°正射球面投影,视点取掩甚点;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-global-en.svg b/doc/lunar-occultation-hr4799-2025-06-05-global-en.svg index 796d04d..803bda2 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-global-en.svg +++ b/doc/lunar-occultation-hr4799-2025-06-05-global-en.svg @@ -1 +1 @@ -2025-06-05 HR 4799 Lunar Occultation Global PathStart 2025-06-05 17:45:28.4 | Greatest 20:02:06.3 | End 22:18:49.9 (UTC+8)Greatest point 121.5660°E, 6.8071°N | path width 3582.4 km | Moon altitude +75.6°Global center line and occultation limits-120°-60°0°60°120°-60°-30°0°30°60°18:3019:0020:0021:00StartGreatestEndVisible center lineGeometric center lineOccultation limitsGlobal eventsStart17:45:28.463.9752°E, 39.0912°NMoon alt. +0.3°Greatest20:02:06.3121.5660°E, 6.8071°NMoon alt. +75.6°End22:18:49.9172.2784°E, 16.6463°SMoon alt. +0.3°Equirectangular projection with Natural Earth 1:50m physical land and no administrative boundaries. Limits use the outer lunar limb on the Earth ellipsoid. \ No newline at end of file +2025-06-05 Lunar Occultation of HR 4799Start 2025-06-05 17:45:28.5 | Greatest 20:02:06.3 | End 22:18:49.9 (CST)Greatest point 121.5660°E, 6.8071°N | path width 3582.4 km | Moon altitude +75.6°Global center line and occultation limits-120°-60°0°60°120°-60°-30°0°30°60°18:3019:0020:0021:00StartGreatestEndVisible center lineGeometric center lineBand and limitsRise/set phase linesGlobal eventsStart17:45:28.564.0381°E, 39.0846°NMoon alt. +0.3°Greatest20:02:06.3121.5660°E, 6.8071°NMoon alt. +75.6°End22:18:49.9172.3168°E, 16.6423°SMoon alt. +0.3°Equirectangular projection with Natural Earth 1:50m physical land and no administrative boundaries. Limits use the outer lunar limb on the Earth ellipsoid. \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-global.svg b/doc/lunar-occultation-hr4799-2025-06-05-global.svg index 231b385..35ebe79 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-global.svg +++ b/doc/lunar-occultation-hr4799-2025-06-05-global.svg @@ -1 +1 @@ -2025-06-05 月掩进贤增九(HR 4799)全球见掩图掩始 2025-06-05 17:45:28.4 | 掩甚 20:02:06.3 | 掩终 22:18:49.9 (UTC+8)掩甚点 121.5660°E, 6.8071°N | 掩带宽 3582.4 km | 月球高度 +75.6°全球中心线与掩带边界-120°-60°0°60°120°-60°-30°0°30°60°18:3019:0020:0021:00掩始掩甚掩终可见中心线几何中心线掩带边界全球事件掩始17:45:28.463.9752°E, 39.0912°N月球高度 +0.3°掩甚20:02:06.3121.5660°E, 6.8071°N月球高度 +75.6°掩终22:18:49.9172.2784°E, 16.6463°S月球高度 +0.3°等经纬投影;Natural Earth 1:50m 物理陆地底图,不含行政边界;掩带边界为地球椭球上的月球外缘投影。 \ No newline at end of file +2025-06-05 月掩HR 4799全球掩带掩始 2025-06-05 17:45:28.4 | 掩甚 20:02:06.3 | 掩终 22:18:49.9 (CST)掩甚点 121.5661°E, 6.8071°N | 掩带宽 3666.6 km | 月球高度 +75.6°全球中心线与掩带边界18:3019:0019:3020:0020:3021:0021:30掩始掩甚掩终可见中心线几何中心线掩带范围与边界初掩/掩甚/终掩月升月落线全球事件掩始17:45:28.463.9930°E, 39.0894°N月球高度 +0.3°掩甚20:02:06.3121.5661°E, 6.8071°N月球高度 +75.6°掩终22:18:49.9172.2912°E, 16.6450°S月球高度 +0.3°正射球面投影;Natural Earth 1:50m 物理陆地底图,不含行政边界;掩带边界为地球椭球上的月球外缘投影。 \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-local-en.svg b/doc/lunar-occultation-hr4799-2025-06-05-local-en.svg index f5fcef7..86f43ca 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-local-en.svg +++ b/doc/lunar-occultation-hr4799-2025-06-05-local-en.svg @@ -1,2 +1,2 @@ - -2025-06-05 Lunar Occultation of HR 4799Site 121.5660°E, 6.8071°N | elevation 0 m | total | duration 1h 36m 09.6sGreatest 2025-06-05 20:02:06.3 UTC+8 | minimum separation 0.01 arcsec | Moon altitude +75.6° azimuth 207.9°Topocentric star trackNEWSLunar pathImmersionGreatestEmersionLocal contactsImmersion19:14:01.1PA 138.9° | alt 76.4°az 157.7° | above horizonGreatest20:02:06.3PA 218.4° | alt 75.6°az 207.9° | above horizonEmersion20:50:10.7PA 317.4° | alt 67.3°az 235.5° | above horizonContact stagesImmersion19:14:01.1Greatest20:02:06.3Emersion20:50:10.7Moon fixed at center; east is left and north is up. The blue-gray dashed line is the local lunar path; the red dashed line is the star track.The star is a point source; immersion and emersion occur where it crosses the topocentric apparent lunar limb. Lunar texture is schematic. \ No newline at end of file + +2025-06-05 Local Lunar Occultation of HR 4799Site 121.5660°E, 6.8071°N | elevation 0 m | total | duration 1h 36m 09.6sGreatest 2025-06-05 20:02:06.3 CST | minimum separation 0.01 arcsec | Moon altitude +75.6° azimuth 207.9°Topocentric star trackNEWSLunar pathImmersionGreatestEmersionLocal contactsImmersion19:14:01.1PA 138.9° | alt 76.4°az 157.7° | above horizonGreatest20:02:06.3PA 213.8° | alt 75.6°az 207.9° | above horizonEmersion20:50:10.7PA 317.4° | alt 67.3°az 235.5° | above horizonContact stagesImmersion19:14:01.1Greatest20:02:06.3Emersion20:50:10.7Moon fixed at center; east is left and north is up. The blue-gray dashed line is the local lunar path; the red dashed line is the star track.The star is a point source; immersion and emersion occur where it crosses the topocentric apparent lunar limb. Lunar texture is schematic. \ No newline at end of file diff --git a/doc/lunar-occultation-hr4799-2025-06-05-local.svg b/doc/lunar-occultation-hr4799-2025-06-05-local.svg index 8f9e238..86addd1 100644 --- a/doc/lunar-occultation-hr4799-2025-06-05-local.svg +++ b/doc/lunar-occultation-hr4799-2025-06-05-local.svg @@ -1,2 +1,2 @@ - -2025-06-05 月掩进贤增九(HR 4799)观测点 121.5660°E, 6.8071°N | 海拔 0 米 | 全掩 | 掩星历时 1时36分09.6秒掩甚 2025-06-05 20:02:06.3 UTC+8 | 最小角距 0.01 角秒 | 月球高度 +75.6° 方位 207.9°站心恒星轨迹北东西南白道掩始掩甚掩终本地接触时刻掩始19:14:01.1PA 138.9° | 高度 76.4°方位 157.7° | 地平线上掩甚20:02:06.3PA 218.4° | 高度 75.6°方位 207.9° | 地平线上掩终20:50:10.7PA 317.4° | 高度 67.3°方位 235.5° | 地平线上掩始、掩甚与掩终视圆掩始19:14:01.1掩甚20:02:06.3掩终20:50:10.7月球固定在中心;图上左东右西,向上为北。灰蓝虚线为掩甚附近的站心白道,红色虚线为恒星相对月心轨迹。恒星按点光源绘制;掩始和掩终是恒星穿越站心月球视圆外缘的时刻,月面纹理仅作方向辅助。 \ No newline at end of file + +2025-06-05 指定地点月掩HR 4799观测点 121.5660°E, 6.8071°N | 海拔 0 米 | 全掩 | 掩星历时 1时36分09.6秒掩甚 2025-06-05 20:02:06.3 CST | 最小角距 0.01 角秒 | 月球高度 +75.6° 方位 207.9°站心恒星轨迹北东西南白道掩始掩甚掩终本地接触时刻掩始19:14:01.1PA 138.9° | 高度 76.4°方位 157.7° | 地平线上掩甚20:02:06.3PA 213.8° | 高度 75.6°方位 207.9° | 地平线上掩终20:50:10.7PA 317.4° | 高度 67.3°方位 235.5° | 地平线上掩始、掩甚与掩终视圆掩始19:14:01.1掩甚20:02:06.3掩终20:50:10.7月球固定在中心;图上左东右西,向上为北。灰蓝虚线为掩甚附近的站心白道,红色虚线为恒星相对月心轨迹。恒星按点光源绘制;掩始和掩终是恒星穿越站心月球视圆外缘的时刻,月面纹理仅作方向辅助。 \ No newline at end of file diff --git a/doc/solar-eclipse-arctic-2012-global-en.svg b/doc/solar-eclipse-arctic-2012-global-en.svg index 1139985..48d6335 100644 --- a/doc/solar-eclipse-arctic-2012-global-en.svg +++ b/doc/solar-eclipse-arctic-2012-global-en.svg @@ -1 +1 @@ -2012-05-21 Annular Solar Eclipse Global VisibilityPartial begins 04:56:08 | Greatest 07:52:47 | Partial ends 10:49:21 (UTC+8) | magnitude 0.944 | Gamma 0.4828path width 237.1 km | Solar Saros 128, member 58/73 | Sun alt 60.9° az 171.0° | central duration 05:46Global visibility and central path05:0006:0007:0009:0010:0006:3007:0008:0009:0009:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityAnnular pathCenter linePenumbral outlines (60 min)Terminator at greatestAntumbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins04:56:08131.0542°E, 10.8905°NSun altitude +0.0°U106:06:18109.6294°E, 20.5716°NSun altitude +0.0°Central begins06:09:02109.3148°E, 21.3879°NSun altitude +0.6°U206:11:48107.7417°E, 21.7712°NSun altitude +0.0°Greatest07:52:47176.2686°E, 49.0974°NPath width 237.1 kmU309:33:42100.1492°W, 33.5217°NSun altitude +0.0°Central ends09:36:26102.1819°W, 33.3062°NSun altitude +0.9°U409:39:10102.1488°W, 32.3505°NSun altitude +0.0°P4 Partial ends10:49:21124.2780°W, 22.8049°NSun altitude +0.0°North-polar azimuthal equidistant projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file +2012-05-21 Annular Solar Eclipse Global VisibilityPartial begins 04:56:08 | Greatest 07:52:47 | Partial ends 10:49:21 (CST) | magnitude 0.944 | Gamma 0.4828path width 237.1 km | Solar Saros 128, member 58/73 | Sun alt 60.9° az 171.0° | central duration 05:46Global visibility and central path05:0006:0007:0009:0010:0006:3007:0008:0009:0009:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityAnnular pathCenter linePenumbral outlines (60 min)Rise/set phase linesAntumbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins04:56:08131.0542°E, 10.8905°NSun altitude +0.0°U106:06:18109.6294°E, 20.5716°NSun altitude +0.0°Central begins06:09:02108.6975°E, 21.1558°NSun altitude +0.0°U206:11:48107.7417°E, 21.7712°NSun altitude +0.0°Greatest07:52:47176.2687°E, 49.0973°NPath width 237.1 kmU309:33:42100.1492°W, 33.5217°NSun altitude +0.0°Central ends09:36:27101.1675°W, 32.9188°NSun altitude +0.0°U409:39:10102.1488°W, 32.3505°NSun altitude +0.0°P4 Partial ends10:49:21124.2780°W, 22.8049°NSun altitude +0.0°North-polar azimuthal equidistant projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-arctic-2012-global.svg b/doc/solar-eclipse-arctic-2012-global.svg index 162b5bb..231757b 100644 --- a/doc/solar-eclipse-arctic-2012-global.svg +++ b/doc/solar-eclipse-arctic-2012-global.svg @@ -1 +1 @@ -2012-05-21 日环食全球见食图偏食始 04:56:08 | 食甚 07:52:47 | 偏食终 10:49:21 (UTC+8) | 食分 0.944 | Gamma 0.4828食带宽 237.1 km | 太阳沙罗 128,第 58/73 个成员 | 食甚点太阳高度 60.9° 方位 171.0° | 中心食持续 05:46全球见食范围与中心食带05:0006:0007:0009:0010:0006:3007:0008:0009:0009:30中心线始中心线终P1P4U1U2U3U4食甚日下点偏食可见区环食带中心线半影时刻线(60 分钟)食甚晨昏圈反本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始04:56:08131.0542°E, 10.8905°N太阳高度 +0.0°U106:06:18109.6294°E, 20.5716°N太阳高度 +0.0°中心食始06:09:02109.3148°E, 21.3879°N太阳高度 +0.6°U206:11:48107.7417°E, 21.7712°N太阳高度 +0.0°食甚07:52:47176.2686°E, 49.0974°N食带宽 237.1 kmU309:33:42100.1492°W, 33.5217°N太阳高度 +0.0°中心食终09:36:26102.1819°W, 33.3062°N太阳高度 +0.9°U409:39:10102.1488°W, 32.3505°N太阳高度 +0.0°P4 偏食终10:49:21124.2780°W, 22.8049°N太阳高度 +0.0°北极方位等距投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file +2012-05-21 日环食全球见食图偏食始 04:56:08 | 食甚 07:52:47 | 偏食终 10:49:21 (CST) | 食分 0.944 | Gamma 0.4828食带宽 237.1 km | 太阳沙罗 128,第 58/73 个成员 | 食甚点太阳高度 60.9° 方位 171.0° | 中心食持续 05:46全球见食范围与中心食带05:0006:0007:0009:0010:0006:3007:0008:0009:0009:30中心线始中心线终P1P4U1U2U3U4食甚太阳直射点偏食可见区环食带中心线半影时刻线(60 分钟)初亏/食甚/复圆日升日落线反本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始04:56:08131.0542°E, 10.8905°N太阳高度 +0.0°U106:06:18109.6294°E, 20.5716°N太阳高度 +0.0°中心食始06:09:02108.6975°E, 21.1558°N太阳高度 +0.0°U206:11:48107.7417°E, 21.7712°N太阳高度 +0.0°食甚07:52:47176.2687°E, 49.0973°N食带宽 237.1 kmU309:33:42100.1492°W, 33.5217°N太阳高度 +0.0°中心食终09:36:27101.1675°W, 32.9188°N太阳高度 +0.0°U409:39:10102.1488°W, 32.3505°N太阳高度 +0.0°P4 偏食终10:49:21124.2780°W, 22.8049°N太阳高度 +0.0°北极方位等距投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-beijing-2035-global-en.svg b/doc/solar-eclipse-beijing-2035-global-en.svg index b2f9a5c..b5f345d 100644 --- a/doc/solar-eclipse-beijing-2035-global-en.svg +++ b/doc/solar-eclipse-beijing-2035-global-en.svg @@ -1 +1 @@ -2035-09-02 Total Solar Eclipse Global VisibilityPartial begins 07:15:35 | Greatest 09:55:36 | Partial ends 12:35:47 (UTC+8) | magnitude 1.032 | Gamma 0.3727path width 116.6 km | Solar Saros 145, member 23/77 | Sun alt 67.9° az 198.5° | central duration 02:54Global visibility and central path08:0009:0010:0011:0012:0008:3009:0010:00Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter linePenumbral outlines (60 min)Terminator at greatestUmbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins07:15:35U108:15:55Central begins08:16:26U208:16:56P209:27:39Greatest09:55:36P310:23:51U311:34:27Central ends11:34:55U411:35:23P4 Partial ends12:35:47Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file +2035-09-02 Total Solar Eclipse Global VisibilityPartial begins 07:15:35 | Greatest 09:55:36 | Partial ends 12:35:47 (CST) | magnitude 1.032 | Gamma 0.3727path width 116.6 km | Solar Saros 145, member 23/77 | Sun alt 67.9° az 198.5° | central duration 02:54Global visibility and central path08:0009:0010:0011:0012:0008:3009:0010:00Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter linePenumbral outlines (60 min)Rise/set phase linesUmbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins07:15:35U108:15:55Central begins08:16:25U208:16:56P209:27:39Greatest09:55:36P310:23:51U311:34:27Central ends11:34:55U411:35:23P4 Partial ends12:35:47Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-beijing-2035-global.svg b/doc/solar-eclipse-beijing-2035-global.svg index f728dbb..ab8815e 100644 --- a/doc/solar-eclipse-beijing-2035-global.svg +++ b/doc/solar-eclipse-beijing-2035-global.svg @@ -1 +1 @@ -2035-09-02 日全食全球见食图偏食始 07:15:35 | 食甚 09:55:36 | 偏食终 12:35:47 (UTC+8) | 食分 1.032 | Gamma 0.3727食带宽 116.6 km | 太阳沙罗 145,第 23/77 个成员 | 食甚点太阳高度 67.9° 方位 198.5° | 中心食持续 02:54全球见食范围与中心食带08:0009:0010:0011:0012:0008:3009:0010:00中心线始中心线终P1P2P3P4U1U2U3U4食甚日下点偏食可见区全食带中心线半影时刻线(60 分钟)食甚晨昏圈本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始07:15:35U108:15:55中心食始08:16:26U208:16:56P209:27:39食甚09:55:36P310:23:51U311:34:27中心食终11:34:55U411:35:23P4 偏食终12:35:47等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file +2035-09-02 日全食全球见食图偏食始 07:15:35 | 食甚 09:55:36 | 偏食终 12:35:47 (CST) | 食分 1.032 | Gamma 0.3727食带宽 116.6 km | 太阳沙罗 145,第 23/77 个成员 | 食甚点太阳高度 67.9° 方位 198.5° | 中心食持续 02:54全球见食范围与中心食带08:0009:0010:0011:0012:0008:3009:0010:00中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线半影时刻线(60 分钟)初亏/食甚/复圆日升日落线本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始07:15:35U108:15:55中心食始08:16:25U208:16:56P209:27:39食甚09:55:36P310:23:51U311:34:27中心食终11:34:55U411:35:23P4 偏食终12:35:47等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-xiamen-2012-en.svg b/doc/solar-eclipse-xiamen-2012-en.svg index f6c9a72..6c94133 100644 --- a/doc/solar-eclipse-xiamen-2012-en.svg +++ b/doc/solar-eclipse-xiamen-2012-en.svg @@ -1 +1 @@ -2012-05-21 Local Solar Eclipselon=118.0894 lat=24.4798 type=annular magnitude=0.9333 obscuration=0.8725Greatest: 2012-05-21 06:10:25 CST Sun altitude 9.57 deg Sun in TaurusSolar Saros 128 58/73 Annularity 00:04:19Overview pathNEWSEclipticC1 256°C2 272°C3 56°C4 73°C1C2GEC3C4Phase disk panelsC105:08:12C2 Annularity begins06:08:15Greatest06:10:25C3 Annularity ends06:12:34C407:20:55Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east.Contacts (CST)C1 First contact 05:08:12 PA 255.9°C2 Annularity begins 06:08:15 PA 272.3°GE Greatest 06:10:25C3 Annularity ends 06:12:34 PA 56.4°C4 Last contact 07:20:55 PA 72.8° \ No newline at end of file +2012-05-21 Local Solar Eclipselon=118.0894 lat=24.4798 type=annular magnitude=0.9333 obscuration=0.8725Greatest: 2012-05-21 06:10:25 CST Sun altitude 9.57 deg Sun in TaurusSolar Saros 128 58/73 Annularity 00:04:19Overview pathNEWSEclipticC1 256°C2 272°C3 56°C4 73°C1C2GEC3C4Phase disk panelsC105:08:12C2 Annularity begins06:08:15Greatest06:10:25C3 Annularity ends06:12:34C407:20:55Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east.Contacts (CST)C1 First contact 05:08:12 PA 255.9°C2 Annularity begins 06:08:15 PA 272.3°GE Greatest 06:10:25C3 Annularity ends 06:12:34 PA 56.4°C4 Last contact 07:20:55 PA 72.8° \ No newline at end of file diff --git a/doc/solar-eclipse-xiamen-2012-global-en.svg b/doc/solar-eclipse-xiamen-2012-global-en.svg index 57868e3..7d66862 100644 --- a/doc/solar-eclipse-xiamen-2012-global-en.svg +++ b/doc/solar-eclipse-xiamen-2012-global-en.svg @@ -1 +1 @@ -2012-05-21 Annular Solar Eclipse Global VisibilityPartial begins 04:56:08 | Greatest 07:52:47 | Partial ends 10:49:21 (UTC+8) | magnitude 0.944 | Gamma 0.4828path width 237.1 km | Solar Saros 128, member 58/73 | Sun alt 60.9° az 171.0° | central duration 05:46Global visibility and central path05:0006:0007:0008:0009:0010:0006:3007:3008:3009:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityAnnular pathCenter linePenumbral outlines (60 min)Terminator at greatestAntumbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins04:56:08U106:06:18Central begins06:09:02U206:11:48Greatest07:52:47U309:33:42Central ends09:36:26U409:39:10P4 Partial ends10:49:21Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file +2012-05-21 Annular Solar Eclipse Global VisibilityPartial begins 04:56:08 | Greatest 07:52:47 | Partial ends 10:49:21 (CST) | magnitude 0.944 | Gamma 0.4828path width 237.1 km | Solar Saros 128, member 58/73 | Sun alt 60.9° az 171.0° | central duration 05:46Global visibility and central path05:0006:0007:0008:0009:0010:0006:3007:3008:3009:30Axis entersAxis exitsP1P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityAnnular pathCenter linePenumbral outlines (60 min)Rise/set phase linesAntumbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins04:56:08U106:06:18Central begins06:09:02U206:11:48Greatest07:52:47U309:33:42Central ends09:36:27U409:39:10P4 Partial ends10:49:21Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-xiamen-2012-global.svg b/doc/solar-eclipse-xiamen-2012-global.svg index 3b4cb88..3a3f80f 100644 --- a/doc/solar-eclipse-xiamen-2012-global.svg +++ b/doc/solar-eclipse-xiamen-2012-global.svg @@ -1 +1 @@ -2012-05-21 日环食全球见食图偏食始 04:56:08 | 食甚 07:52:47 | 偏食终 10:49:21 (UTC+8) | 食分 0.944 | Gamma 0.4828食带宽 237.1 km | 太阳沙罗 128,第 58/73 个成员 | 食甚点太阳高度 60.9° 方位 171.0° | 中心食持续 05:46全球见食范围与中心食带05:0006:0007:0008:0009:0010:0006:3007:3008:3009:30中心线始中心线终P1P4U1U2U3U4食甚日下点偏食可见区环食带中心线半影时刻线(60 分钟)食甚晨昏圈反本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始04:56:08U106:06:18中心食始06:09:02U206:11:48食甚07:52:47U309:33:42中心食终09:36:26U409:39:10P4 偏食终10:49:21等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file +2012-05-21 日环食全球见食图偏食始 04:56:08 | 食甚 07:52:47 | 偏食终 10:49:21 (CST) | 食分 0.944 | Gamma 0.4828食带宽 237.1 km | 太阳沙罗 128,第 58/73 个成员 | 食甚点太阳高度 60.9° 方位 171.0° | 中心食持续 05:46全球见食范围与中心食带05:0006:0007:0008:0009:0010:0006:3007:3008:3009:30中心线始中心线终P1P4U1U2U3U4食甚太阳直射点偏食可见区环食带中心线半影时刻线(60 分钟)初亏/食甚/复圆日升日落线反本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始04:56:08U106:06:18中心食始06:09:02U206:11:48食甚07:52:47U309:33:42中心食终09:36:27U409:39:10P4 偏食终10:49:21等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-xiamen-2012.svg b/doc/solar-eclipse-xiamen-2012.svg index 07e33aa..4f77839 100644 --- a/doc/solar-eclipse-xiamen-2012.svg +++ b/doc/solar-eclipse-xiamen-2012.svg @@ -1 +1 @@ -2012-05-21 站心日环食经度=118.0894 纬度=24.4798 食型=日环食 食分=0.9333 掩食比=0.8725食甚:2012-05-21 06:10:25 CST 太阳高度 9.57 度 太阳位于金牛座沙罗 128 第 58/73 个成员 环食历时 00:04:19全局路径北东西南黄道C1 256°C2 272°C3 56°C4 73°C1C2食甚C3C4阶段视圆图C1 初亏05:08:12C2 环食始06:08:15食甚06:10:25C3 环食终06:12:34C4 复圆07:20:55太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。接触时刻 (CST)C1 初亏 05:08:12 方位 255.9°C2 环食始 06:08:15 方位 272.3°GE 食甚 06:10:25C3 环食终 06:12:34 方位 56.4°C4 复圆 07:20:55 方位 72.8° \ No newline at end of file +2012-05-21 站心日环食经度=118.0894 纬度=24.4798 食型=日环食 食分=0.9333 掩食比=0.8725食甚:2012-05-21 06:10:25 CST 太阳高度 9.57 度 太阳位于金牛座沙罗 128 第 58/73 个成员 环食历时 00:04:19全局路径北东西南黄道C1 256°C2 272°C3 56°C4 73°C1C2食甚C3C4阶段视圆图C1 初亏05:08:12C2 环食始06:08:15食甚06:10:25C3 环食终06:12:34C4 复圆07:20:55太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。接触时刻 (CST)C1 初亏 05:08:12 方位 255.9°C2 环食始 06:08:15 方位 272.3°GE 食甚 06:10:25C3 环食终 06:12:34 方位 56.4°C4 复圆 07:20:55 方位 72.8° \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-en.svg b/doc/solar-eclipse-yangshan-2009-en.svg index 8c95461..60e76d2 100644 --- a/doc/solar-eclipse-yangshan-2009-en.svg +++ b/doc/solar-eclipse-yangshan-2009-en.svg @@ -1 +1 @@ -2009-07-22 Local Solar Eclipselon=121.9850 lat=30.6167 type=total magnitude=1.0770 obscuration=1.0000Greatest: 2009-07-22 09:40:20 CST Sun altitude 57.29 deg Sun in CancerSolar Saros 136 37/71 Totality 00:05:57Overview pathNEWSEclipticC1 287°C2 109°C3 291°C4 113°C1C2GEC3C4Phase disk panelsC108:23:54C2 Total begins09:37:22Greatest09:40:20C3 Total ends09:43:19C411:03:13Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east.Contacts (CST)C1 First contact 08:23:54 PA 287.2°C2 Total begins 09:37:22 PA 108.7°GE Greatest 09:40:20C3 Total ends 09:43:19 PA 290.8°C4 Last contact 11:03:13 PA 112.5° \ No newline at end of file +2009-07-22 Local Solar Eclipselon=121.9850 lat=30.6167 type=total magnitude=1.0770 obscuration=1.0000Greatest: 2009-07-22 09:40:20 CST Sun altitude 57.29 deg Sun in CancerSolar Saros 136 37/71 Totality 00:05:57Overview pathNEWSEclipticC1 287°C2 109°C3 291°C4 113°C1C2GEC3C4Phase disk panelsC108:23:54C2 Total begins09:37:22Greatest09:40:20C3 Total ends09:43:19C411:03:13Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up.Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east.Contacts (CST)C1 First contact 08:23:54 PA 287.2°C2 Total begins 09:37:22 PA 108.7°GE Greatest 09:40:20C3 Total ends 09:43:19 PA 290.8°C4 Last contact 11:03:13 PA 112.5° \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-global-en.svg b/doc/solar-eclipse-yangshan-2009-global-en.svg index 941fea9..200442e 100644 --- a/doc/solar-eclipse-yangshan-2009-global-en.svg +++ b/doc/solar-eclipse-yangshan-2009-global-en.svg @@ -1 +1 @@ -2009-07-22 Total Solar Eclipse Global VisibilityPartial begins 07:58:15 | Greatest 10:35:18 | Partial ends 13:12:22 (UTC+8) | magnitude 1.080 | Gamma 0.0698path width 258.3 km | Solar Saros 136, member 37/71 | Sun alt 85.9° az 197.6° | central duration 06:39Global visibility and central path08:0009:0010:0011:0012:0013:0009:0009:3010:3011:30Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter linePenumbral outlines (60 min)Terminator at greatestUmbral outlines (10 min)P/U shadow contactsGlobal phasesP1 Partial begins07:58:15U108:51:14Central begins08:52:51U208:54:28P209:47:39Greatest10:35:18P311:23:00U312:16:10Central ends12:17:46U412:19:23P4 Partial ends13:12:22Equirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file +2009-07-22 Total Solar Eclipse Global VisibilityPartial begins 07:58:15 | Greatest 10:35:18 | Partial ends 13:12:22 (CST) | magnitude 1.080 | Gamma 0.0698central path width 258.3 km | Saros series 136, member 37/71 | Sun alt 85.9° az 197.6° | central duration 06:39Geocentric conjunction (equal apparent right ascension) = 02:33:04.2 UT | J.D. = 2455034.606299All times are CST (UT+08:00)Global visibility and central path09:0009:3010:0010:3011:0011:3012:000.20.40.809:0009:3010:3011:30Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8Greatest-eclipse isochronesP/U shadow contactsSun at greatest eclipse赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.1"地平视差 H.P.00°00'08.7"Moon at greatest eclipse赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"Penumbral contactsP1 partial begins07:58:15P2 internal contact09:47:39P3 internal contact11:23:00P4 partial ends13:12:22Umbral contactsU1 umbra begins08:51:14U2 internal contact08:54:28U3 internal contact12:16:10U4 umbra ends12:19:23Local circumstances at greatestGreatest10:35:18Local magnitude1.0799Central path width258.3 kmCentral duration06:39Central begins08:52:50Central ends12:17:46Ephemeris and constantsEphemerisNASA bulletin Split-KΔT66.2 sk10.2725076k20.2722810Δb+0.0"Δl+0.0"LibrationLibration l+0.67°Libration b-0.07°Axis position angle c10.52°Brown lunation1071010000 km比例尺Partial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8Greatest-eclipse isochronesP/U shadow contactsEquirectangular projection; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-global.svg b/doc/solar-eclipse-yangshan-2009-global.svg index b0c90d5..ed7d25b 100644 --- a/doc/solar-eclipse-yangshan-2009-global.svg +++ b/doc/solar-eclipse-yangshan-2009-global.svg @@ -1 +1 @@ -2009-07-22 日全食全球见食图偏食始 07:58:15 | 食甚 10:35:18 | 偏食终 13:12:22 (UTC+8) | 食分 1.080 | Gamma 0.0698食带宽 258.3 km | 太阳沙罗 136,第 37/71 个成员 | 食甚点太阳高度 85.9° 方位 197.6° | 中心食持续 06:39全球见食范围与中心食带08:0009:0010:0011:0012:0013:0009:0009:3010:3011:30中心线始中心线终P1P2P3P4U1U2U3U4食甚日下点偏食可见区全食带中心线半影时刻线(60 分钟)食甚晨昏圈本影轮廓(10 分钟)P/U 影锥接触全球阶段P1 偏食始07:58:15U108:51:14中心食始08:52:51U208:54:28P209:47:39食甚10:35:18P311:23:00U312:16:10中心食终12:17:46U412:19:23P4 偏食终13:12:22等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file +2009-07-22 日全食全球见食图偏食始 07:58:15 | 食甚 10:35:18 | 偏食终 13:12:22 (CST) | 食分 1.080 | Gamma 0.0698中心食带宽 258.3 km | 沙罗序列 136,第 37/71 个成员 | 食甚点太阳高度 85.9° 方位 197.6° | 中心食持续 06:39地心合(视赤经相等) = 02:33:04.2 UT | J.D. = 2455034.606299图中时刻为 CST(UT+08:00)全球见食范围与中心食带09:0009:3010:0010:3011:0011:3012:000.20.40.809:0009:3010:3011:30中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8食甚时刻等时线P/U 影锥接触食甚时的太阳(地心坐标)赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.1"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"半影外切 / 内切接触P1 半影外切07:58:15P2 半影内切09:47:39P3 半影内切11:23:00P4 半影外切13:12:22本影外切 / 内切接触U1 本影外切08:51:14U2 本影内切08:54:28U3 本影内切12:16:10U4 本影外切12:19:23食甚点的地方情况食甚10:35:18站心食分1.0799中心食带宽258.3 km中心食时长06:39中心食始08:52:50中心食终12:17:46历表与常数历表NASA bulletin Split-KΔT66.2 sk10.2725076k20.2722810Δb+0.0"Δl+0.0"天平动经天平动 l+0.67°纬天平动 b-0.07°自转轴位置角 c10.52°布朗月序数1071010000 km比例尺偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8食甚时刻等时线P/U 影锥接触等经纬投影;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-globe-en.svg b/doc/solar-eclipse-yangshan-2009-globe-en.svg new file mode 100644 index 0000000..8570424 --- /dev/null +++ b/doc/solar-eclipse-yangshan-2009-globe-en.svg @@ -0,0 +1 @@ +2009-07-22 Total Solar Eclipse Global VisibilityPartial begins 07:58:15 | Greatest 10:35:18 | Partial ends 13:12:22 (CST) | magnitude 1.080 | Gamma 0.0698central path width 258.3 km | Saros series 136, member 37/71 | Sun alt 85.9° az 197.6° | central duration 06:39Geocentric conjunction (equal apparent right ascension) = 02:33:04.2 UT | J.D. = 2455034.606299All times are CST (UT+08:00)Global visibility and central path09:0009:3010:0010:3011:0011:3012:000.20.40.60.809:0009:3010:0010:3011:0011:3012:00Axis entersAxis exitsP1P2P3P4U1U2U3U4GreatestSubsolarPartial-eclipse visibilityPath of totalityCenter lineRise/set phase linesLocal magnitude 0.2-0.8Greatest-eclipse isochronesP/U shadow contactsNESW05000 kmScalePenumbral contactsP1 partial begins07:58:15P2 internal contact09:47:39P3 internal contact11:23:00P4 partial ends13:12:22Local circumstances at greatestGreatest10:35:18Local magnitude1.0799Path width258.3 kmCentral duration06:39Umbral contactsU1 umbra begins08:51:14U2 internal contact08:54:28U3 internal contact12:16:10U4 umbra ends12:19:23Sun at greatest eclipseApparent geocentric positionR.A.08h06m24.3sDec.+20°16'02.4"S.D.00°15'44.1"H.P.00°00'08.7"Moon at greatest eclipseTrue geocentric positionR.A.08h06m29.7sDec.+20°20'06.9"S.D.00°16'42.3"H.P.01°01'19.8"Ephemeris and constants历表NASA bulletin Split-KΔT66.2 sk10.2725076k20.2722810Δb+0.0"Δl+0.0"Geocentric libration (optical + physical)Libration l+0.67°Libration b-0.07°Axis position angle c10.52°Brown lunation1071Orthographic globe projection, centred on the greatest eclipse; one hemisphere only; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries. \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009-globe.svg b/doc/solar-eclipse-yangshan-2009-globe.svg new file mode 100644 index 0000000..3d9c92f --- /dev/null +++ b/doc/solar-eclipse-yangshan-2009-globe.svg @@ -0,0 +1 @@ +2009-07-22 日全食全球见食图偏食始 07:58:15 | 食甚 10:35:18 | 偏食终 13:12:22 (CST) | 食分 1.080 | Gamma 0.0698中心食带宽 258.3 km | 沙罗序列 136,第 37/71 个成员 | 食甚点太阳高度 85.9° 方位 197.6° | 中心食持续 06:39地心合(视赤经相等) = 02:33:04.2 UT | J.D. = 2455034.606299图中时刻为 CST(UT+08:00)全球见食范围与中心食带09:0009:3010:0010:3011:0011:3012:000.20.40.60.809:0009:3010:0010:3011:0011:3012:00中心线始中心线终P1P2P3P4U1U2U3U4食甚太阳直射点偏食可见区全食带中心线初亏/食甚/复圆日升日落线地方食分 0.2–0.8食甚时刻等时线P/U 影锥接触NESW05000 km比例尺半影外切 / 内切接触P1 半影外切07:58:15P2 半影内切09:47:39P3 半影内切11:23:00P4 半影外切13:12:22食甚点的地方情况食甚10:35:18站心食分1.0799食带宽度258.3 km中心食时长06:39本影外切 / 内切接触U1 本影外切08:51:14U2 本影内切08:54:28U3 本影内切12:16:10U4 本影外切12:19:23食甚时的太阳(地心坐标)地心视位置赤经 R.A.08h06m24.3s赤纬 Dec.+20°16'02.4"视半径 S.D.00°15'44.1"地平视差 H.P.00°00'08.7"食甚时的月亮(地心坐标)地心真位置赤经 R.A.08h06m29.7s赤纬 Dec.+20°20'06.9"视半径 S.D.00°16'42.3"地平视差 H.P.01°01'19.8"历表与常数历表NASA bulletin Split-KΔT66.2 sk10.2725076k20.2722810Δb+0.0"Δl+0.0"地理天平动(光学 + 物理)经天平动 l+0.67°纬天平动 b-0.07°自转轴位置角 c10.52°布朗月序数1071正射球面投影,视点取食甚点,只画朝向视点的半个地球;偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。 \ No newline at end of file diff --git a/doc/solar-eclipse-yangshan-2009.svg b/doc/solar-eclipse-yangshan-2009.svg index dc1b734..0c1e08a 100644 --- a/doc/solar-eclipse-yangshan-2009.svg +++ b/doc/solar-eclipse-yangshan-2009.svg @@ -1 +1 @@ -2009-07-22 站心日全食经度=121.9850 纬度=30.6167 食型=日全食 食分=1.0770 掩食比=1.0000食甚:2009-07-22 09:40:20 CST 太阳高度 57.29 度 太阳位于巨蟹座沙罗 136 第 37/71 个成员 全食历时 00:05:57全局路径北东西南黄道C1 287°C2 109°C3 291°C4 113°C1C2食甚C3C4阶段视圆图C1 初亏08:23:54C2 食既09:37:22食甚09:40:20C3 生光09:43:19C4 复圆11:03:13太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。接触时刻 (CST)C1 初亏 08:23:54 方位 287.2°C2 食既 09:37:22 方位 108.7°GE 食甚 09:40:20C3 生光 09:43:19 方位 290.8°C4 复圆 11:03:13 方位 112.5° \ No newline at end of file +2009-07-22 站心日全食经度=121.9850 纬度=30.6167 食型=日全食 食分=1.0770 掩食比=1.0000食甚:2009-07-22 09:40:20 CST 太阳高度 57.29 度 太阳位于巨蟹座沙罗 136 第 37/71 个成员 全食历时 00:05:57全局路径北东西南黄道C1 287°C2 109°C3 291°C4 113°C1C2食甚C3C4阶段视圆图C1 初亏08:23:54C2 食既09:37:22食甚09:40:20C3 生光09:43:19C4 复圆11:03:13太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。接触时刻 (CST)C1 初亏 08:23:54 方位 287.2°C2 食既 09:37:22 方位 108.7°GE 食甚 09:40:20C3 生光 09:43:19 方位 290.8°C4 复圆 11:03:13 方位 112.5° \ No newline at end of file diff --git a/earth/doc.go b/earth/doc.go new file mode 100644 index 0000000..064f68d --- /dev/null +++ b/earth/doc.go @@ -0,0 +1,3 @@ +// Package earth 地球轨道偏心率、近日点与远日点;时刻按时区解释,距离单位为 AU。 +// Package earth provides Earth's orbital eccentricity, perihelion and aphelion; instants are interpreted in their own zone and distances are AU. +package earth diff --git a/eclipse/lunar.go b/eclipse/lunar.go index 2235812..211a299 100644 --- a/eclipse/lunar.go +++ b/eclipse/lunar.go @@ -28,7 +28,18 @@ const ( type lunarEclipseCalculator func(float64) basic.LunarEclipseResult -// LunarEclipseType 月食类型, lunar eclipse type. +// LunarEclipseShadowModel 标识月食用的是哪套影半径模型。 +// LunarEclipseShadowModel identifies which shadow-radius model produced a result. +type LunarEclipseShadowModel = basic.LunarEclipseShadowModel + +const ( + // LunarEclipseShadowModelDanjon 是 Danjon 影半径模型。 + LunarEclipseShadowModelDanjon = basic.LunarEclipseShadowModelDanjon + // LunarEclipseShadowModelChauvenet 是 Chauvenet 影半径模型。 + LunarEclipseShadowModelChauvenet = basic.LunarEclipseShadowModelChauvenet +) + +// LunarEclipseType 月食类型 / lunar eclipse type. type LunarEclipseType string const ( @@ -72,7 +83,7 @@ type LunarEclipseContactPoint struct { type LunarEclipseInfo struct { // Type 月食类型, eclipse type. Type LunarEclipseType - // HasSaros 存在沙罗序列信息, has Saros series metadata. + // HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated). HasSaros bool // Saros 是沙罗序列信息,包括系列号、系列内序号和总成员数。 // Saros is Saros series metadata with the series number, member index, and total member count. @@ -102,6 +113,23 @@ type LunarEclipseInfo struct { // ContactPoints are Moon-limb contact position angles at eclipse contacts. ContactPoints []LunarEclipseContactPoint + // ShadowModel 是本次使用的影半径模型,决定下列影几何与食分的具体数值。 + // ShadowModel is the shadow-radius model used; it fixes the shadow geometry and magnitudes below. + ShadowModel LunarEclipseShadowModel + // Gamma 是食甚时月心到地影轴的最小距离,单位地球赤道半径。 + // Gamma is the minimum distance from the Moon's centre to the shadow axis at greatest eclipse, in Earth equatorial radii. + Gamma float64 + // AxisDegrees 是同一个角距的角度制数值,即 NASA 月食图上的 Axis;Gamma 除以月球处的地球视差即得。 + // AxisDegrees is the same distance in degrees, the Axis column of NASA lunar-eclipse charts; it equals Gamma divided by the Earth's parallax at the Moon. + AxisDegrees float64 + // PenumbralRadiusDegrees 与 UmbralRadiusDegrees 是食甚时的半影、本影半径,单位度。 + // PenumbralRadiusDegrees and UmbralRadiusDegrees are the shadow radii at greatest eclipse, in degrees. + PenumbralRadiusDegrees float64 + UmbralRadiusDegrees float64 + // MoonDistanceEarthRadii 是食甚时的地心月距。 + // MoonDistanceEarthRadii is the geocentric Moon distance at greatest eclipse. + MoonDistanceEarthRadii float64 + // HasPenumbral 有半影阶段, has penumbral phase. HasPenumbral bool // HasPartial 有偏食阶段, has partial phase. @@ -322,23 +350,30 @@ func isPotentialLunarEclipse(fullMoonTT float64) bool { func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.Location) LunarEclipseInfo { saros, hasSaros := lunarSarosInfo(result.Maximum) + geometry := basic.LunarEclipseShadowGeometryAtModel(result.Maximum, result.ShadowModel) return LunarEclipseInfo{ - HasSaros: hasSaros, - Saros: saros, - Type: mapBasicLunarEclipseType(result.Type), - PenumbralMagnitude: result.PenumbralMagnitude, - UmbralMagnitude: result.Magnitude, - PenumbralStart: ttJDEToTime(result.PenumbralStart, location), - PartialStart: ttJDEToTime(result.PartialStart, location), - TotalStart: ttJDEToTime(result.TotalStart, location), - Maximum: ttJDEToTime(result.Maximum, location), - TotalEnd: ttJDEToTime(result.TotalEnd, location), - PartialEnd: ttJDEToTime(result.PartialEnd, location), - PenumbralEnd: ttJDEToTime(result.PenumbralEnd, location), - ContactPoints: lunarEclipseContactPointsFromBasic(result, location), - HasPenumbral: result.HasPenumbral, - HasPartial: result.HasPartial, - HasTotal: result.HasTotal, + ShadowModel: LunarEclipseShadowModel(result.ShadowModel), + Gamma: geometry.Gamma, + AxisDegrees: geometry.AxisDegrees, + PenumbralRadiusDegrees: geometry.PenumbralRadiusDegrees, + UmbralRadiusDegrees: geometry.UmbralRadiusDegrees, + MoonDistanceEarthRadii: geometry.MoonDistanceEarthRadii, + HasSaros: hasSaros, + Saros: saros, + Type: mapBasicLunarEclipseType(result.Type), + PenumbralMagnitude: result.PenumbralMagnitude, + UmbralMagnitude: result.Magnitude, + PenumbralStart: ttJDEToTime(result.PenumbralStart, location), + PartialStart: ttJDEToTime(result.PartialStart, location), + TotalStart: ttJDEToTime(result.TotalStart, location), + Maximum: ttJDEToTime(result.Maximum, location), + TotalEnd: ttJDEToTime(result.TotalEnd, location), + PartialEnd: ttJDEToTime(result.PartialEnd, location), + PenumbralEnd: ttJDEToTime(result.PenumbralEnd, location), + ContactPoints: lunarEclipseContactPointsFromBasic(result, location), + HasPenumbral: result.HasPenumbral, + HasPartial: result.HasPartial, + HasTotal: result.HasTotal, } } @@ -442,7 +477,9 @@ func lunarEclipseRange(info LunarEclipseInfo) (time.Time, time.Time, bool) { } func ttJDEToTime(ttJDE float64, location *time.Location) time.Time { - if ttJDE == 0 { + // basic 用 NaN 表示"该阶段不发生"(0 是 −4713-11-24 的真实时刻), + // 这里必须一并当空值处理,否则 NaN 会被换算成 −296 年那种假时刻。 + if ttJDE == 0 || math.IsNaN(ttJDE) || math.IsInf(ttJDE, 0) { return time.Time{} } utcJDE := basic.TD2UT(ttJDE, false) diff --git a/eclipse/lunar_local.go b/eclipse/lunar_local.go index 2151b43..900b3a6 100644 --- a/eclipse/lunar_local.go +++ b/eclipse/lunar_local.go @@ -23,7 +23,7 @@ const ( type LocalLunarEclipseInfo struct { // Type 月食类型, eclipse type. Type LunarEclipseType - // HasSaros 存在沙罗序列信息, has Saros series metadata. + // HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated). HasSaros bool // Saros 是沙罗序列信息,包括系列号、系列内序号和总成员数。 // Saros is Saros series metadata with the series number, member index, and total member count. diff --git a/eclipse/lunar_panel.go b/eclipse/lunar_panel.go new file mode 100644 index 0000000..f4c6844 --- /dev/null +++ b/eclipse/lunar_panel.go @@ -0,0 +1,99 @@ +package eclipse + +import ( + "time" + + "b612.me/astro/basic" +) + +// LunarEclipseGeocentricPanel 汇总月食图上各面板所需的食甚时刻地心量。 +// LunarEclipseGeocentricPanel carries the geocentric quantities that lunar-eclipse panels print. +type LunarEclipseGeocentricPanel struct { + // FullMoon 望,即地心视黄经相差 180° 的时刻;它与该次月食的食甚不是同一时刻。 + // FullMoon is the instant of full moon, when the geocentric apparent ecliptic longitudes differ by + // 180 degrees; it is not the same instant as greatest eclipse. + FullMoon time.Time + FullMoonJDE float64 + // DeltaTSeconds 是食甚时刻实际使用的 ΔT。 + // DeltaTSeconds is the ΔT used at greatest eclipse. + DeltaTSeconds float64 + + SunRightAscensionDeg float64 + SunDeclinationDeg float64 + SunSemidiameterArcsec float64 + SunParallaxArcsec float64 + + MoonRightAscensionDeg float64 + MoonDeclinationDeg float64 + MoonSemidiameterArcsec float64 + MoonParallaxArcsec float64 + + // ShadowModel 是影半径模型,决定影半径与食分。 + // ShadowModel is the shadow-radius model that fixes the radii and magnitudes. + ShadowModel LunarEclipseShadowModel + // PenumbralRadiusDegrees 与 UmbralRadiusDegrees 是食甚时的半影、本影半径,单位度。 + // PenumbralRadiusDegrees and UmbralRadiusDegrees are the shadow radii at greatest eclipse, in degrees. + PenumbralRadiusDegrees float64 + UmbralRadiusDegrees float64 + // Gamma 是月心到地影轴的最小距离,单位地球赤道半径;AxisDegrees 是同一个量的角度制数值。 + // Gamma is the minimum Moon-centre-to-axis distance in Earth equatorial radii; AxisDegrees is the same in degrees. + Gamma float64 + AxisDegrees float64 + // MoonDistanceEarthRadii 是食甚时的地心月距。 + // MoonDistanceEarthRadii is the geocentric Moon distance at greatest eclipse. + MoonDistanceEarthRadii float64 + // PenumbralMagnitude 与 UmbralMagnitude 与 LunarEclipseInfo 同口径。 + // PenumbralMagnitude and UmbralMagnitude follow the same convention as LunarEclipseInfo. + PenumbralMagnitude float64 + UmbralMagnitude float64 + // Ephemeris 是所用影半径模型名称。 + // Ephemeris names the shadow-radius model used. + Ephemeris string +} + +// LunarEclipseGeocentricPanelAt 计算给定日期月食的地心量面板,使用 Danjon 影半径模型。 +// LunarEclipseGeocentricPanelAt computes the geocentric panel of one lunar eclipse with the Danjon shadow model. +func LunarEclipseGeocentricPanelAt(date time.Time) (LunarEclipseGeocentricPanel, bool) { + return lunarEclipseGeocentricPanelAt(date, basic.LunarEclipseDanjon) +} + +// LunarEclipseGeocentricPanelChauvenet 计算给定日期月食的地心量面板,使用 Chauvenet 影半径模型。 +// LunarEclipseGeocentricPanelChauvenet computes the geocentric panel with the Chauvenet shadow model. +func LunarEclipseGeocentricPanelChauvenet(date time.Time) (LunarEclipseGeocentricPanel, bool) { + return lunarEclipseGeocentricPanelAt(date, basic.LunarEclipseChauvenet) +} + +func lunarEclipseGeocentricPanelAt(date time.Time, calculator func(float64) basic.LunarEclipseResult) (LunarEclipseGeocentricPanel, bool) { + seed := timeToTTJDE(date) + fullMoonJDE := basic.CalcMoonSHByJDE(seed, 1) + result := calculator(fullMoonJDE) + if result.Type == basic.LunarEclipseNone { + return LunarEclipseGeocentricPanel{}, false + } + geometry := basic.LunarEclipseShadowGeometryAtModel(result.Maximum, result.ShadowModel) + ephemeris := "Danjon" + if result.ShadowModel == basic.LunarEclipseShadowModelChauvenet { + ephemeris = "Chauvenet" + } + panel := LunarEclipseGeocentricPanel{ + FullMoon: ttJDEToTime(fullMoonJDE, date.Location()), + FullMoonJDE: fullMoonJDE, + DeltaTSeconds: basic.DeltaT(result.Maximum, true), + ShadowModel: LunarEclipseShadowModel(result.ShadowModel), + PenumbralRadiusDegrees: geometry.PenumbralRadiusDegrees, + UmbralRadiusDegrees: geometry.UmbralRadiusDegrees, + Gamma: geometry.Gamma, + AxisDegrees: geometry.AxisDegrees, + MoonDistanceEarthRadii: geometry.MoonDistanceEarthRadii, + PenumbralMagnitude: result.PenumbralMagnitude, + UmbralMagnitude: result.Magnitude, + Ephemeris: ephemeris, + } + panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDec(result.Maximum) + panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDec(result.Maximum) + panel.SunSemidiameterArcsec = basic.SunSemidiameter(result.Maximum) + panel.MoonSemidiameterArcsec = basic.MoonSemidiameter(result.Maximum) + panel.SunParallaxArcsec = horizontalParallaxArcsec(panel.SunSemidiameterArcsec, horizontalParallaxSunRatio) + panel.MoonParallaxArcsec = horizontalParallaxArcsec(panel.MoonSemidiameterArcsec, horizontalParallaxMoonRatio) + return panel, true +} diff --git a/eclipse/lunar_panel_test.go b/eclipse/lunar_panel_test.go new file mode 100644 index 0000000..470c761 --- /dev/null +++ b/eclipse/lunar_panel_test.go @@ -0,0 +1,69 @@ +package eclipse + +import ( + "fmt" + "math" + "testing" + "time" +) + +func lunarPanelTestRA(degrees float64) string { + total := math.Mod(degrees, 360) / 15 + hours := math.Floor(total) + minutes := math.Floor((total - hours) * 60) + return fmt.Sprintf("%02.0fh%02.0fm%04.1fs", hours, minutes, ((total-hours)*60-minutes)*60) +} + +func lunarPanelTestDec(degrees float64) string { + sign := "+" + if degrees < 0 { + sign, degrees = "-", -degrees + } + whole := math.Floor(degrees) + minutes := math.Floor((degrees - whole) * 60) + seconds := ((degrees-whole)*60 - minutes) * 60 + return fmt.Sprintf("%s%02.0f\u00b0%02.0f'%04.1f\"", sign, whole, minutes, seconds) +} + +// 地心量面板要与 NASA 月食图 LE2025Mar14T 逐项对得上。 +func TestLunarEclipseGeocentricPanelMatchesNASABulletin(t *testing.T) { + panel, ok := LunarEclipseGeocentricPanelAt(time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC)) + if !ok { + t.Fatal("missing eclipse") + } + t.Logf("望(黄经相差180°) %s UT", panel.FullMoon.UTC().Format("15:04:05.0")) + t.Logf("太阳 R.A. %s Dec. %s S.D. %.1f\" H.P. %.1f\"", + lunarPanelTestRA(panel.SunRightAscensionDeg), lunarPanelTestDec(panel.SunDeclinationDeg), + panel.SunSemidiameterArcsec, panel.SunParallaxArcsec) + t.Logf("月亮 R.A. %s Dec. %s S.D. %.1f\" H.P. %.1f\"", + lunarPanelTestRA(panel.MoonRightAscensionDeg), lunarPanelTestDec(panel.MoonDeclinationDeg), + panel.MoonSemidiameterArcsec, panel.MoonParallaxArcsec) + t.Logf("Gamma %.4f Axis %.4f° P.R %.4f° U.R %.4f° 食分 %.4f / %.4f ΔT %.1f s %s", + panel.Gamma, panel.AxisDegrees, panel.PenumbralRadiusDegrees, panel.UmbralRadiusDegrees, + panel.UmbralMagnitude, panel.PenumbralMagnitude, panel.DeltaTSeconds, panel.Ephemeris) + + // NASA: Ecliptic Conjunction 06:54:33.5 UT。 + conjunction := time.Date(2025, time.March, 14, 6, 54, 33, 0, time.UTC) + if delta := panel.FullMoon.UTC().Sub(conjunction).Seconds(); math.Abs(delta) > 15 { + t.Fatalf("full moon differs from NASA's ecliptic conjunction by %.1f s", delta) + } + // NASA: Gamma 0.3481、Axis 0.3171、P.R 1.1899、U.R 0.6537、食分 1.1784 / 2.2595。 + for _, check := range []struct { + name string + got float64 + want float64 + }{{"Gamma", panel.Gamma, 0.3481}, {"Axis", panel.AxisDegrees, 0.3171}, + {"P.Radius", panel.PenumbralRadiusDegrees, 1.1899}, {"U.Radius", panel.UmbralRadiusDegrees, 0.6537}, + {"umbral magnitude", panel.UmbralMagnitude, 1.1784}, {"penumbral magnitude", panel.PenumbralMagnitude, 2.2595}, + {"moon S.D.", panel.MoonSemidiameterArcsec, 892.8}, {"moon H.P.", panel.MoonParallaxArcsec, 3276.8}, + {"sun S.D.", panel.SunSemidiameterArcsec, 965.2}, {"sun H.P.", panel.SunParallaxArcsec, 8.9}} { + if math.Abs(check.got-check.want) > 1.0 { + t.Errorf("%s = %.4f, want %.4f", check.name, check.got, check.want) + } + } + // Gamma 与 Axis 必须互为两种刻度。 + parallax := panel.MoonParallaxArcsec / 3600 + if delta := panel.Gamma*parallax - panel.AxisDegrees; math.Abs(delta) > 1e-4 { + t.Fatalf("Gamma×parallax - Axis = %g", delta) + } +} diff --git a/eclipse/lunar_test.go b/eclipse/lunar_test.go index c8d7a3f..fd4d64b 100644 --- a/eclipse/lunar_test.go +++ b/eclipse/lunar_test.go @@ -48,6 +48,17 @@ func TestLunarEclipseLocalDayBoundsRespectDST(t *testing.T) { } } +func TestLocalLunarEclipseFindsHighLatitudeMidEvent(t *testing.T) { + date := time.Date(2022, 11, 8, 12, 0, 0, 0, time.UTC) + info, ok := LocalLunarEclipseOnDate(date, -174, -70, 0) + if !ok { + t.Fatal("expected the eclipse to be visible during the local penumbral interval") + } + if info.MoonAltitude <= 0 { + t.Fatalf("maximum altitude=%v, want above horizon", info.MoonAltitude) + } +} + func TestLunarEclipseOnDateByLocalDay(t *testing.T) { loc := time.FixedZone("UTC-05", -5*3600) diff --git a/eclipse/observation_helpers.go b/eclipse/observation_helpers.go index 1322d03..f27b70a 100644 --- a/eclipse/observation_helpers.go +++ b/eclipse/observation_helpers.go @@ -46,5 +46,19 @@ func lunarCulminationTime(date time.Time, lon, lat float64) time.Time { } jde := basic.Date2JDE(date) _, loc := date.Zone() - return basic.JDE2DateByZone(basic.MoonCulminationTime(jde, lon, lat, float64(loc)/3600.0), date.Location(), true) + culmination := basic.JDE2DateByZone(basic.MoonCulminationTime(jde, lon, lat, float64(loc)/3600.0), date.Location(), true) + // MoonCulminationTime returns the nearest root to its internal midnight + // seed, which can land one civil day later when the input is local noon. + // Visibility scans need the root belonging to the supplied local day. + if culmination.Year() != date.Year() || culmination.YearDay() != date.YearDay() { + // Preserve the solved local clock time while moving it onto the + // requested civil date. This also avoids assuming every DST day is + // exactly 24 elapsed hours. + culmination = time.Date( + date.Year(), date.Month(), date.Day(), + culmination.Hour(), culmination.Minute(), culmination.Second(), culmination.Nanosecond(), + date.Location(), + ) + } + return culmination } diff --git a/eclipse/saros.go b/eclipse/saros.go index 14555f3..29d9df5 100644 --- a/eclipse/saros.go +++ b/eclipse/saros.go @@ -2,7 +2,7 @@ package eclipse import ( "math" - "time" + "sync" "b612.me/astro/basic" ) @@ -10,7 +10,10 @@ import ( const ( sarosCycleLunations = 223 sarosCycleDays = float64(sarosCycleLunations) * solarEclipseSynodicMonthDays - sarosWalkLimit = 100 + // The generated tables show at most 90 members in one series. A window of + // 90 returns therefore covers a complete series without evaluating remote + // samples whose ancient ephemeris drift can reject an otherwise valid event. + sarosExtrapolationWindow = 90 sarosMagicYearOffset = 3000 sarosMagicCountMask = 0x7f @@ -27,8 +30,8 @@ const ( // SarosInfo 沙罗序列信息, Saros series metadata. type SarosInfo struct { - // Series 是 NASA 沙罗系列编号;太阳食可能出现 0 号系列。 - // Series is the NASA Saros series number; solar eclipses may use series 0. + // Series 是权威目录系列编号;Verified=false 时为根据周期外推得到的暂定编号。 + // Series is the authoritative catalog series number, or a provisional number when Verified is false. Series int // Member 是本次食在该系列中的序号,从 1 开始计数。 // Member is the 1-based index of this eclipse within the series. @@ -36,6 +39,88 @@ type SarosInfo struct { // Count 是该系列的总成员数。 // Count is the total number of eclipses in the series. Count int + // Verified 表示系列头和成员范围已与内置权威目录锚点核验。 + // Verified reports whether the series head and member range match an authoritative catalog anchor. + Verified bool +} + +// sarosAnchorRange is the authoritative member window of one series as +// published in the anchor catalog. Extended (provisional) tables may enumerate +// one or two extra members at the head and tail of a series, which collides +// with the anchored numbering: the same member index then appears twice and the +// series count changes mid-series. Reconciling against the anchor keeps the +// member numbering and the total count consistent for every event. +type sarosAnchorRange struct { + headTT float64 + count int + offset int +} + +var ( + sarosAnchorRangesOnce sync.Once + sarosAnchorRanges [2]map[int]sarosAnchorRange +) + +func sarosAnchorRangeFor(phase, series int) (sarosAnchorRange, bool) { + sarosAnchorRangesOnce.Do(buildSarosAnchorRanges) + if phase < 0 || phase >= len(sarosAnchorRanges) { + return sarosAnchorRange{}, false + } + value, ok := sarosAnchorRanges[phase][series] + return value, ok +} + +func buildSarosAnchorRanges() { + anchors := solarSarosAnchors[:] + base := 0 + overrides := solarSarosHeadOverrides[:] + for phase := 0; phase < 2; phase++ { + if phase == 1 { + anchors = lunarSarosAnchors[:] + base = 1 + overrides = lunarSarosHeadOverrides[:] + } + ranges := make(map[int]sarosAnchorRange, len(anchors)+len(overrides)) + for index, magic := range anchors { + anchor := decodeSarosMagic(magic, base+index) + headTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)) + if math.IsNaN(headTT) { + continue + } + ranges[int(anchor.Series)] = sarosAnchorRange{headTT: headTT, count: int(anchor.Count)} + } + for _, override := range overrides { + headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) + if math.IsNaN(headTT) { + continue + } + ranges[int(override.Series)] = sarosAnchorRange{ + headTT: headTT, + count: int(override.Count), + offset: int(override.MemberOffset), + } + } + sarosAnchorRanges[phase] = ranges + } +} + +// reconcileSarosSpan anchors a provisional member to the authoritative series +// window. Members outside that window are not part of the catalog series, so +// they report no Saros metadata instead of a conflicting provisional number. +// The member index uses the same relation as the anchor matcher, which keeps a +// provisional event and an anchored event of one series in step. +func reconcileSarosSpan(info SarosInfo, ttJDE float64, phase int) (SarosInfo, bool) { + anchor, ok := sarosAnchorRangeFor(phase, info.Series) + if !ok { + return info, true + } + member := int(math.Round((ttJDE-anchor.headTT)/sarosCycleDays)) + 1 + anchor.offset + if member < 1 || member > anchor.count { + return SarosInfo{}, false + } + info.Member = member + info.Count = anchor.count + return info, true } type sarosMagic uint32 @@ -62,6 +147,8 @@ var solarSarosHeadOverrides = [...]sarosHeadOverride{ } var lunarSarosHeadOverrides = [...]sarosHeadOverride{ + // 4 号锚表头提前了一个分支:目录成员 1 落在本模型认不出的浅半影回次上, + // 头按目录记作第 3 个成员,尾部最后一场才与目录的 Count 对齐。 {Series: 4, Count: 78, HeadYear: -2483, HeadMonth: 1, HeadDay: 12, MemberOffset: 2}, {Series: 8, Count: 86, HeadYear: -2494, HeadMonth: 8, HeadDay: 7, MemberOffset: 0}, {Series: 61, Count: 78, HeadYear: -762, HeadMonth: 12, HeadDay: 24, MemberOffset: 1}, @@ -71,92 +158,42 @@ func solarSarosInfo(ttJDE float64) (SarosInfo, bool) { if info, ok := matchSarosMagic(solarSarosAnchors[:], 0, solarSarosHeadOverrides[:], ttJDE); ok { return info, true } - return solarSarosInfoByWalk(ttJDE) + return reconciledSarosInfo(extendedSarosInfo, ttJDE, 0) } func lunarSarosInfo(ttJDE float64) (SarosInfo, bool) { if info, ok := matchSarosMagic(lunarSarosAnchors[:], 1, lunarSarosHeadOverrides[:], ttJDE); ok { return info, true } - return lunarSarosInfoByWalk(ttJDE) + return reconciledSarosInfo(extendedSarosInfo, ttJDE, 1) } -func solarSarosInfoByWalk(ttJDE float64) (SarosInfo, bool) { - headTT, member, ok := solarSarosHead(ttJDE) +// reconciledSarosInfo applies the anchor window to a provisional lookup so the +// public metadata never mixes two numbering baselines for one series. +func reconciledSarosInfo( + provisional func(float64, int) (SarosInfo, bool), + ttJDE float64, + phase int, +) (SarosInfo, bool) { + info, ok := provisional(ttJDE, phase) if !ok { return SarosInfo{}, false } - if info, ok := matchSarosHeadOverride(solarSarosHeadOverrides[:], headTT, member); ok { - return info, true - } - anchor, ok := matchSarosAnchor(solarSarosAnchors[:], 0, headTT) - if !ok || member > int(anchor.Count) { - return SarosInfo{}, false - } - return SarosInfo{ - Series: int(anchor.Series), - Member: member, - Count: int(anchor.Count), - }, true -} - -func lunarSarosInfoByWalk(ttJDE float64) (SarosInfo, bool) { - headTT, member, ok := lunarSarosHead(ttJDE) - if !ok { - return SarosInfo{}, false - } - if info, ok := matchSarosHeadOverride(lunarSarosHeadOverrides[:], headTT, member); ok { - return info, true - } - anchor, ok := matchSarosAnchor(lunarSarosAnchors[:], 1, headTT) - if !ok || member > int(anchor.Count) { - return SarosInfo{}, false - } - return SarosInfo{ - Series: int(anchor.Series), - Member: member, - Count: int(anchor.Count), - }, true -} - -func solarSarosHead(ttJDE float64) (float64, int, bool) { - currentTT := ttJDE - member := 1 - for step := 0; step < sarosWalkLimit; step++ { - previousSeed := basic.CalcMoonSHByJDE(currentTT-sarosCycleDays, 0) - previous := basic.SolarEclipseNASABulletinSplitK(previousSeed) - if previous.Type == basic.SolarEclipseNone { - return currentTT, member, true - } - currentTT = previous.GreatestEclipse - member++ - } - return 0, 0, false -} - -func lunarSarosHead(ttJDE float64) (float64, int, bool) { - currentTT := ttJDE - member := 1 - for step := 0; step < sarosWalkLimit; step++ { - previousSeed := basic.CalcMoonSHByJDE(currentTT-sarosCycleDays, 1) - previous := basic.LunarEclipseDanjon(previousSeed) - if previous.Type == basic.LunarEclipseNone { - return currentTT, member, true - } - currentTT = previous.Maximum - member++ - } - return 0, 0, false + return reconcileSarosSpan(info, ttJDE, phase) } func matchSarosMagic(anchors []sarosMagic, seriesBase int, overrides []sarosHeadOverride, ttJDE float64) (SarosInfo, bool) { if info, ok := matchSarosMagicOverrides(overrides, ttJDE); ok { return info, true } + // 带 override 的序列只认 override 头,与 buildSarosAnchorRanges 的覆盖语义一致。 bestDistance := math.Inf(1) best := SarosInfo{} for index, magic := range anchors { anchor := decodeSarosMagic(magic, seriesBase+index) + if sarosSeriesOverridden(overrides, int(anchor.Series)) { + continue + } info, distance, ok := matchSarosMagicCandidate(ttJDE, anchor, 0) if !ok { continue @@ -172,6 +209,16 @@ func matchSarosMagic(anchors []sarosMagic, seriesBase int, overrides []sarosHead return SarosInfo{}, false } +// sarosSeriesOverridden 报告该序列是否带有 override 头。 +func sarosSeriesOverridden(overrides []sarosHeadOverride, series int) bool { + for _, override := range overrides { + if int(override.Series) == series { + return true + } + } + return false +} + func matchSarosMagicOverrides(overrides []sarosHeadOverride, ttJDE float64) (SarosInfo, bool) { bestDistance := math.Inf(1) best := SarosInfo{} @@ -209,9 +256,10 @@ func matchSarosMagicCandidate(ttJDE float64, anchor sarosAnchor, memberOffset in } expectedTT := headTT + float64(member-1-memberOffset)*sarosCycleDays return SarosInfo{ - Series: int(anchor.Series), - Member: member, - Count: int(anchor.Count), + Series: int(anchor.Series), + Member: member, + Count: int(anchor.Count), + Verified: true, }, math.Abs(ttJDE - expectedTT), true } @@ -238,37 +286,3 @@ func decodeSarosMagic(magic sarosMagic, series int) sarosAnchor { Day: uint8((value >> sarosMagicDayShift) & sarosMagicDayMask), } } - -func matchSarosAnchor(anchors []sarosMagic, seriesBase int, headTT float64) (sarosAnchor, bool) { - headDate := basic.JDE2DateByZone(headTT, time.UTC, true) - year, month, day := headDate.Date() - monthNumber := int(month) - for index, magic := range anchors { - anchor := decodeSarosMagic(magic, seriesBase+index) - if int(anchor.Year) == year && int(anchor.Month) == monthNumber && int(anchor.Day) == day { - return anchor, true - } - } - return sarosAnchor{}, false -} - -func matchSarosHeadOverride(overrides []sarosHeadOverride, headTT float64, member int) (SarosInfo, bool) { - headDate := basic.JDE2DateByZone(headTT, time.UTC, true) - year, month, day := headDate.Date() - monthNumber := int(month) - for _, override := range overrides { - if int(override.HeadYear) != year || int(override.HeadMonth) != monthNumber || int(override.HeadDay) != day { - continue - } - adjustedMember := member + int(override.MemberOffset) - if adjustedMember < 1 || adjustedMember > int(override.Count) { - return SarosInfo{}, false - } - return SarosInfo{ - Series: int(override.Series), - Member: adjustedMember, - Count: int(override.Count), - }, true - } - return SarosInfo{}, false -} diff --git a/eclipse/saros_anchor_consistency_test.go b/eclipse/saros_anchor_consistency_test.go new file mode 100644 index 0000000..847f3d0 --- /dev/null +++ b/eclipse/saros_anchor_consistency_test.go @@ -0,0 +1,308 @@ +package eclipse + +import ( + "math" + "testing" + "time" + + "b612.me/astro/basic" +) + +// 本文件独立解析锚表与扩展表:成员号、唯一性、连续性与成员总数都由测试自己推导, +const ( + sarosTestLunationEpoch = 2451550.09765 + sarosTestSynodicMonthDays = 29.530588853 + sarosTestAnchorYearOffset = 3000 + sarosTestAnchorCountMask = 0x7f + sarosTestAnchorDayMask = 0x1f + sarosTestAnchorMonthMask = 0x0f + sarosTestAnchorYearMask = 0x1fff + sarosTestAnchorDayShift = 7 + sarosTestAnchorMonthShift = 12 + sarosTestAnchorYearShift = 16 + sarosTestAnchorMemberInterval = 223 +) + +type sarosTestWindow struct { + headTT float64 + count int + offset int +} + +func sarosTestWindows(t *testing.T, phase int) map[int]sarosTestWindow { + t.Helper() + anchors := solarSarosAnchors[:] + overrides := solarSarosHeadOverrides[:] + base := 0 + if phase == 1 { + anchors = lunarSarosAnchors[:] + overrides = lunarSarosHeadOverrides[:] + base = 1 + } + windows := make(map[int]sarosTestWindow, len(anchors)+len(overrides)) + for index, magic := range anchors { + value := uint32(magic) + headTT := basic.JDECalc( + int((value>>sarosTestAnchorYearShift)&sarosTestAnchorYearMask)-sarosTestAnchorYearOffset, + int((value>>sarosTestAnchorMonthShift)&sarosTestAnchorMonthMask), + float64((value>>sarosTestAnchorDayShift)&sarosTestAnchorDayMask), + ) + if math.IsNaN(headTT) { + t.Fatalf("invalid anchor head for series %d", base+index) + } + windows[base+index] = sarosTestWindow{ + headTT: headTT, + count: int(value & sarosTestAnchorCountMask), + } + } + for _, override := range overrides { + headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) + if math.IsNaN(headTT) { + t.Fatalf("invalid override head for series %d", override.Series) + } + windows[int(override.Series)] = sarosTestWindow{ + headTT: headTT, + count: int(override.Count), + offset: int(override.MemberOffset), + } + } + return windows +} + +func sarosTestReturnTT(k, phase int) float64 { + return sarosTestLunationEpoch + (float64(k)+float64(phase)/2)*sarosTestSynodicMonthDays +} + +func sarosTestMember(window sarosTestWindow, ttJDE float64) int { + return int(math.Round((ttJDE-window.headTT)/(float64(sarosTestAnchorMemberInterval)*sarosTestSynodicMonthDays))) + + 1 + window.offset +} + +func sarosTestSpans(phase int) []sarosSpan { + if phase == 1 { + return lunarSarosExtended[:] + } + return solarSarosExtended[:] +} + +func sarosTestInfo(ttJDE float64, phase int) (SarosInfo, bool) { + if phase == 0 { + return solarSarosInfo(ttJDE) + } + return lunarSarosInfo(ttJDE) +} + +// 扩展表只对窗口内的时刻生效;窗口外的序列行由外推路径接管。 +func sarosTestInsideTable(ttJDE float64) bool { + return ttJDE >= sarosExtendedStartTT && ttJDE < sarosExtendedEndTT +} + +// 锚表窗口、扩展表成员号与公开查询必须一致:成员号唯一、连续,总数等于锚表的 Count, +// 序列号与扩展表相同;目录窗口之外的成员只报"没有沙罗信息"。 +func TestSarosAnchorWindowsMatchExtensionSpans(t *testing.T) { + for _, phase := range []int{0, 1} { + name := "solar" + if phase == 1 { + name = "lunar" + } + t.Run(name, func(t *testing.T) { + windows := sarosTestWindows(t, phase) + reported := make(map[int]map[int]int) + for _, span := range sarosTestSpans(phase) { + if span.First > span.Last || (span.Last-span.First)%sarosTestAnchorMemberInterval != 0 { + t.Fatalf("malformed span %+v", span) + } + window, anchored := windows[span.Series] + for index, k := 0, span.First; k <= span.Last; k, index = k+sarosTestAnchorMemberInterval, index+1 { + ttJDE := sarosTestReturnTT(k, phase) + if !sarosTestInsideTable(ttJDE) { + continue + } + spanMember := span.Member + index + want := SarosInfo{Series: span.Series, Member: spanMember, Count: span.Count} + if anchored { + want.Member = sarosTestMember(window, ttJDE) + want.Count = window.count + if want.Member < 1 || want.Member > window.count { + want = SarosInfo{} + } + } + // 扩展表派发本身就要采用锚表的成员总数;协调会再覆盖一次,所以单独钉住输入侧。 + provisional, provisionalOK := extendedSarosInfo(ttJDE, phase) + if !provisionalOK || provisional.Series != span.Series || provisional.Member != spanMember { + t.Fatalf("series %d k=%d extension dispatch=%+v/%v, want member %d", + span.Series, k, provisional, provisionalOK, spanMember) + } + wantProvisionalCount := span.Count + if anchored { + wantProvisionalCount = window.count + } + if provisional.Count != wantProvisionalCount { + t.Fatalf("series %d k=%d extension count=%d, want %d", span.Series, k, provisional.Count, wantProvisionalCount) + } + info, ok := reconciledSarosInfo(extendedSarosInfo, ttJDE, phase) + if ok != (want != SarosInfo{}) || info.Series != want.Series || + info.Member != want.Member || info.Count != want.Count { + t.Fatalf("series %d k=%d dispatch=%+v/%v, independently derived %+v", span.Series, k, info, ok, want) + } + if !ok { + continue + } + // 唯一性与连续性按测试自己推导的成员号记账,覆盖窗口内每个回次。 + if reported[span.Series] == nil { + reported[span.Series] = make(map[int]int) + } + if previous, seen := reported[span.Series][want.Member]; seen { + t.Fatalf("series %d member %d reported for both k=%d and k=%d", span.Series, want.Member, previous, k) + } + reported[span.Series][want.Member] = k + // 序列头、尾与中间各抽一点走公开入口,锚表匹配器与扩展表必须给出同一成员。 + if index != 0 && index != 1 && k != span.Last && k != span.Last-sarosTestAnchorMemberInterval { + continue + } + public, publicOK := sarosTestInfo(ttJDE, phase) + wantPublic := SarosInfo{Series: want.Series, Member: want.Member, Count: want.Count, Verified: anchored} + if !publicOK || public != wantPublic { + t.Fatalf("series %d k=%d public=%+v/%v, want %+v", span.Series, k, public, publicOK, wantPublic) + } + } + } + for series, members := range reported { + minimum, maximum := 1<<30, -1<<30 + for member := range members { + if member < minimum { + minimum = member + } + if member > maximum { + maximum = member + } + } + if maximum-minimum+1 != len(members) { + t.Fatalf("series %d members are not consecutive: %d..%d with %d entries", + series, minimum, maximum, len(members)) + } + window, anchored := windows[series] + if !anchored { + continue + } + if minimum < 1 || maximum > window.count { + t.Fatalf("series %d covers member %d..%d outside the anchor window 1..%d", + series, minimum, maximum, window.count) + } + if minimum == 1 && maximum == window.count { + continue + } + // 4 号头部连续三个回次缺测,最后一个正是目录成员 1;其余序列必须铺满窗口。 + // Lunar series 4 catalog member 1 sits on a return the library's lunar + // detection cannot resolve; every other anchored series must be complete. + if phase == 1 && series == 4 && minimum == 2 && maximum == window.count { + continue + } + t.Fatalf("series %d covers member %d..%d of the anchor window 1..%d", + series, minimum, maximum, window.count) + } + }) + } +} + +// override 声明的成员区间必须真的可达:首成员与末成员都要按格点报出,且标注为已核验。 +func TestSarosOverrideWindowsCoverDeclaredMemberRange(t *testing.T) { + for _, phase := range []int{0, 1} { + overrides := solarSarosHeadOverrides[:] + if phase == 1 { + overrides = lunarSarosHeadOverrides[:] + } + cycleDays := float64(sarosTestAnchorMemberInterval) * sarosTestSynodicMonthDays + for _, override := range overrides { + headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay)) + for _, member := range []int{1, int(override.Count)} { + ttJDE := headTT + float64(member-1-int(override.MemberOffset))*cycleDays + info, ok := sarosTestInfo(ttJDE, phase) + want := SarosInfo{ + Series: int(override.Series), + Member: member, + Count: int(override.Count), + Verified: true, + } + if !ok || info != want { + t.Fatalf("phase=%d series %d member %d = %+v/%v, want %+v", + phase, override.Series, member, info, ok, want) + } + } + } + } +} + +// 锚表 Count 的裁剪只作用于协调输入:扩展表派发结果与"原始跨度 + 协调"必须逐字段相同。 +func TestSarosAnchorCountClipKeepsReconciledResult(t *testing.T) { + for _, phase := range []int{0, 1} { + spans := sarosTestSpans(phase) + for _, span := range spans { + for index, k := 0, span.First; k <= span.Last; k, index = k+sarosTestAnchorMemberInterval, index+1 { + ttJDE := sarosTestReturnTT(k, phase) + if !sarosTestInsideTable(ttJDE) { + continue + } + if index != 0 && k != span.Last { + continue + } + raw, rawOK := matchSarosSpans(spans, k) + want, wantOK := reconcileSarosSpan(raw, ttJDE, phase) + if !rawOK { + wantOK = false + } + got, ok := reconciledSarosInfo(extendedSarosInfo, ttJDE, phase) + if ok != wantOK || got != want { + t.Fatalf("phase=%d k=%d dispatch=%+v/%v, raw reconcile=%+v/%v", phase, k, got, ok, want, wantOK) + } + } + } + } +} + +// Anchored series keep the catalog numbering, so a provisional span may no +// longer present a second "member 1" for the same series. +func TestLunarSaros_ProvisionalSpanDoesNotDuplicateAnchoredMember(t *testing.T) { + head, ok := eclipseOnDateForTest(-1926, 2, 2) + if !ok { + t.Skip("anchored series head is not an eclipse in this build") + } + if head.Saros.Series != 22 || head.Saros.Member != 1 { + t.Fatalf("series 22 head = %d/%d, want 22/1", head.Saros.Series, head.Saros.Member) + } + earlier, ok := eclipseOnDateForTest(-1944, 1, 22) + if !ok { + t.Fatal("expected a lunar eclipse one Saros before the anchored head") + } + if earlier.HasSaros && earlier.Saros.Series == head.Saros.Series && earlier.Saros.Member == head.Saros.Member { + t.Fatalf("provisional span repeats series %d member %d", earlier.Saros.Series, earlier.Saros.Member) + } +} + +// Modern catalog values must keep matching NASA's published member numbers. +func TestLunarSaros_ModernMembersUnchanged(t *testing.T) { + cases := []struct { + year, month, day int + series, member int + }{ + {2000, 7, 16, 129, 37}, + {2018, 7, 27, 129, 38}, + {2022, 11, 8, 136, 20}, + {2025, 9, 7, 128, 41}, + } + for _, testCase := range cases { + info, ok := eclipseOnDateForTest(testCase.year, testCase.month, testCase.day) + if !ok { + t.Fatalf("missing lunar eclipse on %04d-%02d-%02d", testCase.year, testCase.month, testCase.day) + } + if info.Saros.Series != testCase.series || info.Saros.Member != testCase.member { + t.Fatalf("%04d-%02d-%02d saros = %d/%d, want %d/%d", + testCase.year, testCase.month, testCase.day, + info.Saros.Series, info.Saros.Member, testCase.series, testCase.member) + } + } +} + +func eclipseOnDateForTest(year, month, day int) (LunarEclipseInfo, bool) { + return LunarEclipseOnDate(time.Date(year, time.Month(month), day, 12, 0, 0, 0, time.UTC)) +} diff --git a/eclipse/saros_benchmark_test.go b/eclipse/saros_benchmark_test.go new file mode 100644 index 0000000..0d12eb3 --- /dev/null +++ b/eclipse/saros_benchmark_test.go @@ -0,0 +1,71 @@ +package eclipse + +import ( + "testing" + "time" +) + +func BenchmarkSarosLookup(b *testing.B) { + for _, tc := range []struct { + name string + date time.Time + }{ + {name: "NASA", date: time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)}, + {name: "PrecomputedFuture", date: time.Date(3300, 1, 1, 12, 0, 0, 0, time.UTC)}, + {name: "PrecomputedAncient", date: time.Date(-2800, 1, 1, 12, 0, 0, 0, time.UTC)}, + {name: "LiveAncient", date: time.Date(-3200, 1, 1, 12, 0, 0, 0, time.UTC)}, + {name: "LiveFuture", date: time.Date(7000, 1, 1, 12, 0, 0, 0, time.UTC)}, + } { + b.Run("Solar/"+tc.name, func(b *testing.B) { + info := ClosestSolarEclipse(tc.date) + tt := solarEclipseTimeToTTJDE(info.GreatestEclipse) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if got, ok := solarSarosInfo(tt); !ok || got != info.Saros { + b.Fatal("inconsistent solar Saros metadata") + } + } + }) + b.Run("Lunar/"+tc.name, func(b *testing.B) { + info := ClosestLunarEclipse(tc.date) + tt := timeToTTJDE(info.Maximum) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if got, ok := lunarSarosInfo(tt); !ok || got != info.Saros { + b.Fatal("inconsistent lunar Saros metadata") + } + } + }) + } +} + +func BenchmarkSolarEclipsePrecomputedSaros(b *testing.B) { + date := time.Date(3288, 11, 15, 12, 0, 0, 0, time.UTC) + for i := 0; i < b.N; i++ { + info, ok := SolarEclipseOnDate(date) + if !ok || !info.HasSaros || info.Saros != (SarosInfo{Series: 202, Member: 1, Count: 71}) { + b.Fatal("missing precomputed Saros metadata") + } + } +} + +func BenchmarkSolarEclipseCandidatesSaros(b *testing.B) { + start := time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(6002, 1, 1, 0, 0, 0, 0, time.UTC) + for _, include := range []bool{false, true} { + name := "WithoutSaros" + if include { + name = "WithSaros" + } + b.Run(name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if got := SolarEclipseCandidates(start, end, SolarEclipseCandidateOptions{IncludeSaros: include}); len(got) == 0 { + b.Fatal("missing candidates") + } + } + }) + } +} diff --git a/eclipse/saros_extended.go b/eclipse/saros_extended.go new file mode 100644 index 0000000..cc4eefe --- /dev/null +++ b/eclipse/saros_extended.go @@ -0,0 +1,180 @@ +package eclipse + +import ( + "math" + "sync" + + "b612.me/astro/basic" +) + +const ( + sarosInexLunations = 358 + sarosLunationEpoch = 2451550.09765 + // Keep the acceptance interval below half a synodic month while allowing + // the mean-phase seed to drift in remote eras. + sarosEclipseSeedToleranceDays = 7.0 + // 表外外推按 ±90 个沙罗周期求值同一批朔望月,直映缓存让相邻序列成员复用结果。 + sarosEclipseMemoSlots = 4096 +) + +// 键是(朔望月序号,相位);判定与模型无关,只有月序号和相位决定结果。 +type sarosEclipseMemoEntry struct { + key int64 + maximum float64 + exists bool + valid bool + set bool +} + +var ( + sarosEclipseMemoMu sync.RWMutex + sarosEclipseMemoEntries [sarosEclipseMemoSlots]sarosEclipseMemoEntry +) + +// 直接取模即可:外推窗口的步长 223 与槽数互质,±90 个回次落在互不相同的槽位上。 +func sarosEclipseMemoSlot(key int64) int { + return int(uint64(key) % sarosEclipseMemoSlots) +} + +func sarosEclipseLookup(k, phase int) (float64, bool, bool, bool) { + key := int64(k)*2 + int64(phase) + slot := sarosEclipseMemoSlot(key) + sarosEclipseMemoMu.RLock() + entry := sarosEclipseMemoEntries[slot] + sarosEclipseMemoMu.RUnlock() + if !entry.set || entry.key != key { + return 0, false, false, false + } + return entry.maximum, entry.exists, entry.valid, true +} + +func sarosEclipseStore(k, phase int, maximum float64, exists, valid bool) { + key := int64(k)*2 + int64(phase) + slot := sarosEclipseMemoSlot(key) + sarosEclipseMemoMu.Lock() + sarosEclipseMemoEntries[slot] = sarosEclipseMemoEntry{ + key: key, maximum: maximum, exists: exists, valid: valid, set: true, + } + sarosEclipseMemoMu.Unlock() +} + +// A span contains consecutive 223-month returns. Separate spans preserve gaps +// in shallow series without counting the missing returns as eclipses. +type sarosSpan struct { + Series int + First int + Last int + Member int + Count int +} + +func sarosLunation(ttJDE float64, phase int) (int, bool) { + k := (ttJDE-sarosLunationEpoch)/solarEclipseSynodicMonthDays - float64(phase)/2 + if math.IsNaN(k) || math.IsInf(k, 0) || math.Abs(k) > 1e7 { + return 0, false + } + return int(math.Round(k)), true +} + +// 358*38 = 1 (mod 223). Select the Inex column whose Saros row is nearest +// the catalog reference; neighboring solutions are 358 Saros rows apart. +// See NASA SEperiodicity.html, sections 1.7 and 1.9 (also valid for lunar series). +func sarosNumber(k, phase int) int { + anchors, base := solarSarosAnchors[:], 0 + if phase == 1 { + anchors, base = lunarSarosAnchors[:], 1 + } + anchor := decodeSarosMagic(anchors[len(anchors)/2], base+len(anchors)/2) + refTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)) + refK, _ := sarosLunation(refTT, phase) + delta := k - refK + column := (38 * delta) % sarosCycleLunations + column += sarosCycleLunations * int(math.Round((float64(delta)/sarosInexLunations-float64(column))/sarosCycleLunations)) + return int(anchor.Series) + column +} + +func matchSarosSpans(spans []sarosSpan, k int) (SarosInfo, bool) { + for _, span := range spans { + if k < span.First || k > span.Last || (k-span.First)%sarosCycleLunations != 0 { + continue + } + return SarosInfo{ + Series: span.Series, + Member: span.Member + (k-span.First)/sarosCycleLunations, + Count: span.Count, + }, true + } + return SarosInfo{}, false +} + +func extendedSarosInfo(ttJDE float64, phase int) (SarosInfo, bool) { + k, ok := sarosLunation(ttJDE, phase) + if !ok { + return SarosInfo{}, false + } + if ttJDE >= sarosExtendedStartTT && ttJDE < sarosExtendedEndTT { + spans := solarSarosExtended[:] + if phase == 1 { + spans = lunarSarosExtended[:] + } + info, ok := matchSarosSpans(spans, k) + if !ok { + return SarosInfo{}, false + } + if anchor, known := sarosAnchorRangeFor(phase, info.Series); known { + info.Count = anchor.count + } + return info, true + } + return extrapolateSaros(k, phase) +} + +func sarosEclipse(k, phase int) (float64, bool, bool) { + if maximum, exists, valid, ok := sarosEclipseLookup(k, phase); ok { + return maximum, exists, valid + } + maximum, exists, valid := sarosEclipseUncached(k, phase) + sarosEclipseStore(k, phase, maximum, exists, valid) + return maximum, exists, valid +} + +// Saros metadata uses the Split-K solar model and the union of Danjon and +// Chauvenet lunar detections, independent of the observer or display model. +func sarosEclipseUncached(k, phase int) (float64, bool, bool) { + seed := sarosLunationEpoch + (float64(k)+float64(phase)/2)*solarEclipseSynodicMonthDays + var maximum float64 + var exists bool + if phase == 0 { + result := basic.SolarEclipseNASABulletinSplitK(seed) + maximum, exists = result.GreatestEclipse, result.Type != basic.SolarEclipseNone + } else { + result := basic.LunarEclipseDanjon(seed) + if result.Type == basic.LunarEclipseNone { + result = basic.LunarEclipseChauvenet(seed) + } + maximum, exists = result.Maximum, result.Type != basic.LunarEclipseNone + } + valid := !math.IsNaN(maximum) && !math.IsInf(maximum, 0) && math.Abs(maximum-seed) < sarosEclipseSeedToleranceDays + return maximum, exists, valid +} + +func extrapolateSaros(k, phase int) (SarosInfo, bool) { + info := SarosInfo{Series: sarosNumber(k, phase)} + for offset := -sarosExtrapolationWindow; offset <= sarosExtrapolationWindow; offset++ { + _, exists, valid := sarosEclipse(k+offset*sarosCycleLunations, phase) + if !valid || (offset == 0 && !exists) { + return SarosInfo{}, false + } + if !exists { + continue + } + if offset == -sarosExtrapolationWindow || offset == sarosExtrapolationWindow { + return SarosInfo{}, false + } + info.Count++ + if offset <= 0 { + info.Member++ + } + } + return info, true +} diff --git a/eclipse/saros_extended_test.go b/eclipse/saros_extended_test.go new file mode 100644 index 0000000..786782d --- /dev/null +++ b/eclipse/saros_extended_test.go @@ -0,0 +1,205 @@ +package eclipse + +import ( + "fmt" + "math" + "testing" + "time" + + "b612.me/astro/basic" +) + +func TestSarosNumberAgainstAllCatalogAnchors(t *testing.T) { + for phase, anchors := range [][]sarosMagic{solarSarosAnchors[:], lunarSarosAnchors[:]} { + for index, magic := range anchors { + anchor := decodeSarosMagic(magic, phase+index) + k, ok := sarosLunation(basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)), phase) + if !ok { + t.Fatalf("invalid catalog anchor: %+v", anchor) + } + for member := 1; member <= int(anchor.Count); member++ { + got := sarosNumber(k+(member-1)*sarosCycleLunations, phase) + if got != int(anchor.Series) { + t.Fatalf("phase=%d series=%d member=%d got series=%d", phase, anchor.Series, member, got) + } + } + } + } +} + +func TestExtendedSarosUsesPrecomputedSeries(t *testing.T) { + got := ClosestSolarEclipse(time.Date(3288, 11, 15, 12, 0, 0, 0, time.UTC)) + if !got.HasSaros || got.Saros != (SarosInfo{Series: 202, Member: 1, Count: 71}) { + t.Fatalf("unexpected precomputed solar Saros: has=%v info=%+v", got.HasSaros, got.Saros) + } + next := ClosestSolarEclipse(time.Date(3306, 11, 28, 12, 0, 0, 0, time.UTC)) + if !next.HasSaros || next.Saros != (SarosInfo{Series: 202, Member: 2, Count: 71}) { + t.Fatalf("unexpected second precomputed member: %+v", next.Saros) + } + previous := ClosestSolarEclipse(time.Date(3288, 10, 17, 12, 0, 0, 0, time.UTC)) + assertSarosInfo(t, previous.HasSaros, previous.Saros, 164, 67, 80) +} + +func TestExtendedSarosSpansAreComplete(t *testing.T) { + for phase, spans := range [][]sarosSpan{solarSarosExtended[:], lunarSarosExtended[:]} { + seen := make(map[int]bool) + for i, span := range spans { + if span.First > span.Last || (span.Last-span.First)%sarosCycleLunations != 0 || span.Member < 1 || span.Member > span.Count { + t.Fatalf("phase=%d invalid span: %+v", phase, span) + } + if i == 0 || spans[i-1].Series != span.Series { + if span.Member != 1 || (i > 0 && spans[i-1].Series > span.Series) { + t.Fatalf("phase=%d invalid series start: %+v", phase, span) + } + } else { + previous := spans[i-1] + nextMember := previous.Member + (previous.Last-previous.First)/sarosCycleLunations + 1 + if span.First <= previous.Last+sarosCycleLunations || (span.First-previous.First)%sarosCycleLunations != 0 || span.Member != nextMember || span.Count != previous.Count { + t.Fatalf("phase=%d invalid gap: previous=%+v current=%+v", phase, previous, span) + } + for k := previous.Last + sarosCycleLunations; k < span.First; k += sarosCycleLunations { + if info, ok := matchSarosSpans(spans, k); ok { + t.Fatalf("phase=%d missing return k=%d matched %+v", phase, k, info) + } + } + } + lastMember := span.Member + (span.Last-span.First)/sarosCycleLunations + if (i == len(spans)-1 || spans[i+1].Series != span.Series) && lastMember != span.Count { + t.Fatalf("phase=%d incomplete series: %+v", phase, span) + } + for k := span.First; k <= span.Last; k += sarosCycleLunations { + if seen[k] || sarosNumber(k, phase) != span.Series { + t.Fatalf("phase=%d duplicated or misnumbered return: k=%d span=%+v", phase, k, span) + } + seen[k] = true + want := SarosInfo{Series: span.Series, Member: span.Member + (k-span.First)/sarosCycleLunations, Count: span.Count} + if got, ok := matchSarosSpans(spans, k); !ok || got != want { + t.Fatalf("phase=%d k=%d got=%+v has=%v want=%+v", phase, k, got, ok, want) + } + } + } + } +} + +func TestExtendedSarosMatchesLiveSeries(t *testing.T) { + for _, tc := range []struct { + phase int + series int + }{ + {phase: 0, series: -39}, + {phase: 0, series: 181}, + {phase: 0, series: 202}, + {phase: 0, series: 293}, + {phase: 1, series: -27}, + {phase: 1, series: 0}, + {phase: 1, series: 4}, // This series contains three missing returns. + {phase: 1, series: 202}, + } { + t.Run(fmt.Sprintf("phase%d/series%d", tc.phase, tc.series), func(t *testing.T) { + spans := solarSarosExtended[:] + if tc.phase == 1 { + spans = lunarSarosExtended[:] + } + found := false + for _, span := range spans { + if span.Series != tc.series { + continue + } + found = true + for _, k := range []int{span.First, span.First + (span.Last-span.First)/sarosCycleLunations/2*sarosCycleLunations, span.Last} { + want, _ := matchSarosSpans(spans, k) + got, ok := extrapolateSaros(k, tc.phase) + if !ok || got != want { + t.Fatalf("k=%d live=%+v has=%v table=%+v", k, got, ok, want) + } + // 锚定序列的总成员数由锚表决定,扩展表只提供成员位置。 + if anchor, anchored := sarosAnchorRangeFor(tc.phase, want.Series); anchored { + want.Count = anchor.count + } + tt, exists, valid := sarosEclipse(k, tc.phase) + if !exists || !valid { + t.Fatalf("k=%d missing eclipse: exists=%v valid=%v", k, exists, valid) + } + if got, ok := extendedSarosInfo(tt, tc.phase); !ok || got != want { + t.Fatalf("k=%d tt=%f dispatched=%+v has=%v want=%+v", k, tt, got, ok, want) + } + } + } + if !found { + t.Fatal("missing test series") + } + }) + } +} + +func TestExtendedSarosRangeAndInvalidInput(t *testing.T) { + if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) || sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatal("precomputed range must include both end years") + } + for phase, spans := range [][]sarosSpan{solarSarosExtended[:], lunarSarosExtended[:]} { + for _, tt := range []float64{sarosExtendedStartTT, sarosExtendedEndTT - 1.0/86400} { + k, _ := sarosLunation(tt, phase) + want, wantOK := matchSarosSpans(spans, k) + got, ok := extendedSarosInfo(tt, phase) + if got != want || ok != wantOK { + t.Fatalf("phase=%d boundary lookup got=%+v/%v want=%+v/%v", phase, got, ok, want, wantOK) + } + } + for _, tt := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), math.MaxFloat64} { + if got, ok := extendedSarosInfo(tt, phase); ok || got != (SarosInfo{}) { + t.Fatalf("phase=%d invalid epoch returned %+v/%v", phase, got, ok) + } + } + } + if got, ok := extrapolateSaros(-56340, 1); ok || got != (SarosInfo{}) { + t.Fatalf("missing lunar return produced metadata: %+v/%v", got, ok) + } +} + +// 直映缓存只复用结果:命中与未命中都必须与未缓存求值逐字段相同,外推结果也必须与 +// 逐字重写的未缓存扫描一致。 +func TestSarosEclipseMemoMatchesUncachedEvaluation(t *testing.T) { + for _, phase := range []int{0, 1} { + for _, k := range []int{-57901, -56340, 15941, 31551, 19004, 34391, 120000} { + for attempt := 0; attempt < 2; attempt++ { + maximum, exists, valid := sarosEclipse(k, phase) + wantMaximum, wantExists, wantValid := sarosEclipseUncached(k, phase) + if maximum != wantMaximum || exists != wantExists || valid != wantValid { + t.Fatalf("phase=%d k=%d attempt=%d cached=%v/%v/%v uncached=%v/%v/%v", + phase, k, attempt, maximum, exists, valid, wantMaximum, wantExists, wantValid) + } + } + } + for _, k := range []int{-56340, 120000} { + for attempt := 0; attempt < 2; attempt++ { + got, ok := extrapolateSaros(k, phase) + want, wantOK := sarosExtrapolationUncachedForTest(k, phase) + if ok != wantOK || got != want { + t.Fatalf("phase=%d k=%d attempt=%d memoized=%+v/%v uncached scan=%+v/%v", + phase, k, attempt, got, ok, want, wantOK) + } + } + } + } +} + +func sarosExtrapolationUncachedForTest(k, phase int) (SarosInfo, bool) { + info := SarosInfo{Series: sarosNumber(k, phase)} + for offset := -sarosExtrapolationWindow; offset <= sarosExtrapolationWindow; offset++ { + _, exists, valid := sarosEclipseUncached(k+offset*sarosCycleLunations, phase) + if !valid || (offset == 0 && !exists) { + return SarosInfo{}, false + } + if !exists { + continue + } + if offset == -sarosExtrapolationWindow || offset == sarosExtrapolationWindow { + return SarosInfo{}, false + } + info.Count++ + if offset <= 0 { + info.Member++ + } + } + return info, true +} diff --git a/eclipse/saros_generate_test.go b/eclipse/saros_generate_test.go new file mode 100644 index 0000000..03f3f3f --- /dev/null +++ b/eclipse/saros_generate_test.go @@ -0,0 +1,249 @@ +package eclipse + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "os" + "sort" + "testing" + "time" +) + +// 默认只校验发布表的新鲜度与金标准;重新生成必须同时给出 -saros-generate 与环境变量。 +// The release table is verified by default; regeneration additionally requires an +// explicit environment variable so an ordinary go test never rewrites it. +// +//go:generate go test -run=TestGenerateSarosExtensionTables -count=1 -timeout=20m -args -saros-generate +var generateSarosTables = flag.Bool("saros-generate", false, "regenerate the extended Saros table") + +const sarosGenerateEnv = "ASTRO_REGENERATE_SAROS_TABLE" + +func sarosTableGenerationEnabled() bool { + return *generateSarosTables && os.Getenv(sarosGenerateEnv) == "1" +} + +type sarosTableGolden struct { + name string + span sarosSpan +} + +var sarosTableGoldens = []sarosTableGolden{ + {name: "solar/head", span: sarosSpan{Series: -47, First: -79891, Last: -61605, Member: 1, Count: 83}}, + {name: "solar/202", span: sarosSpan{Series: 202, First: 15941, Last: 31551, Member: 1, Count: 71}}, + {name: "solar/tail", span: sarosSpan{Series: 293, First: 48742, Last: 63906, Member: 1, Count: 69}}, + {name: "lunar/head", span: sarosSpan{Series: -54, First: -81564, Last: -61717, Member: 1, Count: 90}}, + {name: "lunar/4-head", span: sarosSpan{Series: 4, First: -57901, Last: -56563, Member: 1, Count: 84}}, + {name: "lunar/4-body", span: sarosSpan{Series: 4, First: -55671, Last: -38723, Member: 8, Count: 84}}, + {name: "lunar/tail", span: sarosSpan{Series: 287, First: 49211, Last: 64821, Member: 1, Count: 71}}, +} + +// 发布表必须与文档化的窗口、生成时的序列总数和若干金标准行一致。 +func TestSarosExtensionTableIsFresh(t *testing.T) { + if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) || + sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatal("extended table window does not cover -3000..+6000") + } + for _, table := range []struct { + name string + phase int + rows int + span []sarosSpan + }{ + {name: "solar", phase: 0, rows: 159, span: solarSarosExtended[:]}, + {name: "lunar", phase: 1, rows: 249, span: lunarSarosExtended[:]}, + } { + if len(table.span) != table.rows { + t.Fatalf("%s table has %d rows, golden table has %d", table.name, len(table.span), table.rows) + } + validateSarosExtensionSpans(t, table.name, table.phase, table.span) + } + for _, golden := range sarosTableGoldens { + found := false + for _, span := range append(solarSarosExtended[:], lunarSarosExtended[:]...) { + if span == golden.span { + found = true + break + } + } + if !found { + t.Fatalf("golden span %s missing: %+v", golden.name, golden.span) + } + } +} + +func validateSarosExtensionSpans(t *testing.T, name string, phase int, spans []sarosSpan) { + t.Helper() + seen := make(map[int]bool) + for index, span := range spans { + if span.First > span.Last || (span.Last-span.First)%sarosCycleLunations != 0 { + t.Fatalf("%s span %d is malformed: %+v", name, index, span) + } + lastMember := span.Member + (span.Last-span.First)/sarosCycleLunations + if span.Member < 1 || lastMember > span.Count { + t.Fatalf("%s span %d member range %d..%d exceeds count %d", name, index, span.Member, lastMember, span.Count) + } + if index > 0 && spans[index-1].Series > span.Series { + t.Fatalf("%s span %d is out of series order: %+v after %+v", name, index, span, spans[index-1]) + } + for _, k := range []int{span.First, span.Last} { + if seen[k] { + t.Fatalf("%s return %d is listed twice", name, k) + } + seen[k] = true + if got := sarosNumber(k, phase); got != span.Series { + t.Fatalf("%s return %d belongs to series %d, table says %d", name, k, got, span.Series) + } + } + } +} + +type sarosGenerationEvent struct { + k int + tt float64 + series int + assigned bool +} + +func TestGenerateSarosExtensionTables(t *testing.T) { + if !sarosTableGenerationEnabled() { + t.Skipf("set %s=1 and pass -saros-generate to rewrite saros_table_extended.go", sarosGenerateEnv) + } + startTT := timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) + endTT := timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) + var buf bytes.Buffer + fmt.Fprintln(&buf, "// Code generated by go generate ./eclipse; DO NOT EDIT.") + fmt.Fprintln(&buf, "\npackage eclipse") + fmt.Fprintln(&buf, "\n// UTC astronomical years -3000 through +6000, including both end years.") + fmt.Fprintln(&buf, "// Series numbers follow NASA's Saros-Inex rules. Members are computed with") + fmt.Fprintln(&buf, "// Split-K solar geometry and the union of Danjon/Chauvenet lunar detections.") + fmt.Fprintf(&buf, "const sarosExtendedStartTT = %.12f\nconst sarosExtendedEndTT = %.12f\n", startTT, endTT) + for phase, name := range []string{"solar", "lunar"} { + events := scanSarosGenerationEvents(t, phase) + assignSarosGenerationNumbers(t, events, phase) + spans := buildSarosGenerationSpans(t, events, phase, startTT, endTT) + // 写盘前先按发布口径校验,坏表不得进入仓库。 + validateSarosExtensionSpans(t, name, phase, spans) + fmt.Fprintf(&buf, "\nvar %sSarosExtended = [...]sarosSpan{\n", name) + for _, span := range spans { + fmt.Fprintf(&buf, "{Series: %d, First: %d, Last: %d, Member: %d, Count: %d},\n", span.Series, span.First, span.Last, span.Member, span.Count) + } + fmt.Fprintln(&buf, "}") + t.Logf("%s: %d events scanned, %d extension spans", name, len(events), len(spans)) + } + source, err := format.Source(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile("saros_table_extended.go", source, 0644); err != nil { + t.Fatal(err) + } + t.Logf("rewrote saros_table_extended.go; rerun TestSarosExtensionTableIsFresh and update the goldens") +} + +func scanSarosGenerationEvents(t *testing.T, phase int) []sarosGenerationEvent { + t.Helper() + // The 2000-year margins include complete lifetimes for every series that + // intersects the requested years. buildSarosGenerationSpans checks clipping. + start, _ := sarosLunation(timeToTTJDE(time.Date(-5000, 1, 1, 0, 0, 0, 0, time.UTC)), phase) + end, _ := sarosLunation(timeToTTJDE(time.Date(8001, 1, 1, 0, 0, 0, 0, time.UTC)), phase) + var events []sarosGenerationEvent + for k := start; k <= end; k++ { + tt, exists, valid := sarosEclipse(k, phase) + if !valid { + t.Fatalf("invalid ephemeris: phase=%d lunation=%d", phase, k) + } + if exists { + events = append(events, sarosGenerationEvent{k: k, tt: tt}) + } + if (k-start)%20000 == 0 { + t.Logf("phase=%d scanned %d/%d lunations", phase, k-start, end-start) + } + } + return events +} + +func sarosGenerationCatalog(tt float64, phase int) (SarosInfo, bool) { + if phase == 0 { + return matchSarosMagic(solarSarosAnchors[:], 0, solarSarosHeadOverrides[:], tt) + } + return matchSarosMagic(lunarSarosAnchors[:], 1, lunarSarosHeadOverrides[:], tt) +} + +func assignSarosGenerationNumbers(t *testing.T, events []sarosGenerationEvent, phase int) { + t.Helper() + byLunation := make(map[int]int, len(events)) + queue := make([]int, 0, len(events)) + for i := range events { + byLunation[events[i].k] = i + if info, ok := sarosGenerationCatalog(events[i].tt, phase); ok { + events[i].series, events[i].assigned = info.Series, true + queue = append(queue, i) + } + } + // Propagate in both directions from every catalog match. A second route + // must agree before any generated data can be written. + for head := 0; head < len(queue); head++ { + event := events[queue[head]] + for _, period := range []struct{ months, series int }{{1, 38}, {5, -33}, {6, 5}, {223, 0}, {358, 1}, {669, 0}} { + for _, direction := range []int{-1, 1} { + i, ok := byLunation[event.k+direction*period.months] + if !ok { + continue + } + series := event.series + direction*period.series + if events[i].assigned { + if events[i].series != series { + t.Fatalf("numbering conflict: phase=%d k=%d series=%d want=%d from k=%d", phase, events[i].k, events[i].series, series, event.k) + } + continue + } + events[i].series, events[i].assigned = series, true + queue = append(queue, i) + } + } + } + for _, event := range events { + if !event.assigned || sarosNumber(event.k, phase) != event.series { + t.Fatalf("unresolved or inconsistent numbering: phase=%d k=%d propagated=%d assigned=%v arithmetic=%d", phase, event.k, event.series, event.assigned, sarosNumber(event.k, phase)) + } + } +} + +func buildSarosGenerationSpans(t *testing.T, events []sarosGenerationEvent, phase int, startTT, endTT float64) []sarosSpan { + t.Helper() + groups := make(map[int][]sarosGenerationEvent) + needed := make(map[int]bool) + for _, event := range events { + groups[event.series] = append(groups[event.series], event) + if event.tt >= startTT && event.tt < endTT { + if _, known := sarosGenerationCatalog(event.tt, phase); !known { + needed[event.series] = true + } + } + } + var spans []sarosSpan + for series := range needed { + group := groups[series] + first, last := group[0].k, group[len(group)-1].k + if first-events[0].k < sarosCycleLunations || events[len(events)-1].k-last < sarosCycleLunations { + t.Fatalf("series %d reaches the scan edge", series) + } + start := 0 + for i := 1; i <= len(group); i++ { + if i < len(group) && group[i].k-group[i-1].k == sarosCycleLunations { + continue + } + spans = append(spans, sarosSpan{Series: series, First: group[start].k, Last: group[i-1].k, Member: start + 1, Count: len(group)}) + start = i + } + } + sort.Slice(spans, func(i, j int) bool { + if spans[i].Series != spans[j].Series { + return spans[i].Series < spans[j].Series + } + return spans[i].First < spans[j].First + }) + return spans +} diff --git a/eclipse/saros_lunar_count_test.go b/eclipse/saros_lunar_count_test.go new file mode 100644 index 0000000..5c93156 --- /dev/null +++ b/eclipse/saros_lunar_count_test.go @@ -0,0 +1,71 @@ +package eclipse + +import ( + "testing" + "time" +) + +// 把某个时间窗内落入指定沙罗系列的月食枚举出来。 +func lunarSarosMembersInWindow(t *testing.T, series int, start, end time.Time) []time.Time { + t.Helper() + seen := map[int64]bool{} + members := make([]time.Time, 0, 4) + for cursor := start; cursor.Before(end); cursor = cursor.AddDate(0, 1, 0) { + info := ClosestLunarEclipse(cursor) + if info.Maximum.IsZero() || info.Saros.Series != series { + continue + } + key := info.Maximum.UTC().Unix() + if seen[key] { + continue + } + seen[key] = true + members = append(members, info.Maximum) + } + return members +} + +// 月食沙罗系列的总成员数与成员序号必须与 NASA 目录一致。 +// 123 号 72 次(NASA LEsaros123:24N 6P 25T 8P 9N = 72),157 号 73 次; +// 2025-03-14 是 123 号的第 53 个成员。 +func TestLunarSarosSeriesMemberCounts(t *testing.T) { + for _, test := range []struct { + name string + probe time.Time + series int + member int + count int + }{ + {name: "123-2025", probe: time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC), series: 123, member: 53, count: 72}, + {name: "157-2360", probe: time.Date(2360, time.April, 2, 0, 0, 0, 0, time.UTC), series: 157, member: 4, count: 73}, + } { + info := ClosestLunarEclipse(test.probe) + if info.Saros.Series != test.series || info.Saros.Member != test.member || info.Saros.Count != test.count { + t.Fatalf("%s: series %d member %d/%d, want %d member %d/%d", + test.name, info.Saros.Series, info.Saros.Member, info.Saros.Count, + test.series, test.member, test.count) + } + } +} + +// 123 号系列的边界必须与 NASA 目录一致:首 1087-08-16、末 2367-10-08,两端之外再无成员。 +func TestLunarSarosSeries123Boundaries(t *testing.T) { + // 系列开始前的三年窗口里不应有任何 123 号成员。 + if members := lunarSarosMembersInWindow(t, 123, + time.Date(1084, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(1087, time.January, 1, 0, 0, 0, 0, time.UTC)); len(members) != 0 { + t.Fatalf("series 123 starts before 1087: %v", members) + } + if members := lunarSarosMembersInWindow(t, 123, + time.Date(1087, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(1090, time.January, 1, 0, 0, 0, 0, time.UTC)); len(members) != 1 || + members[0].UTC().Format("2006-01-02") != "1087-08-16" { + t.Fatalf("series 123 first members = %v, want one on 1087-08-16", members) + } + if members := lunarSarosMembersInWindow(t, 123, + time.Date(2367, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(2371, time.January, 1, 0, 0, 0, 0, time.UTC)); len(members) != 1 || + members[0].UTC().Format("2006-01-02") != "2367-10-08" { + t.Fatalf("series 123 last members = %v, want one on 2367-10-08", members) + } +} diff --git a/eclipse/saros_table_extended.go b/eclipse/saros_table_extended.go new file mode 100644 index 0000000..23d630c --- /dev/null +++ b/eclipse/saros_table_extended.go @@ -0,0 +1,423 @@ +// Code generated by go generate ./eclipse; DO NOT EDIT. + +package eclipse + +// UTC astronomical years -3000 through +6000, including both end years. +// Series numbers follow NASA's Saros-Inex rules. Members are computed with +// Split-K solar geometry and the union of Danjon/Chauvenet lunar detections. +const sarosExtendedStartTT = 625308.343822260154 +const sarosExtendedEndTT = 3912881.135478473268 + +var solarSarosExtended = [...]sarosSpan{ + {Series: -47, First: -79891, Last: -61605, Member: 1, Count: 83}, + {Series: -46, First: -80871, Last: -61693, Member: 1, Count: 87}, + {Series: -45, First: -79844, Last: -60443, Member: 1, Count: 88}, + {Series: -44, First: -79263, Last: -60085, Member: 1, Count: 87}, + {Series: -42, First: -78324, Last: -59146, Member: 1, Count: 87}, + {Series: -41, First: -77743, Last: -61241, Member: 1, Count: 75}, + {Series: -40, First: -77385, Last: -61329, Member: 1, Count: 73}, + {Series: -39, First: -76581, Last: -60525, Member: 1, Count: 73}, + {Series: -38, First: -76000, Last: -59944, Member: 1, Count: 73}, + {Series: -37, First: -75642, Last: -59809, Member: 1, Count: 72}, + {Series: -36, First: -74838, Last: -58782, Member: 1, Count: 73}, + {Series: -35, First: -74257, Last: -58201, Member: 1, Count: 73}, + {Series: -34, First: -73899, Last: -58066, Member: 1, Count: 72}, + {Series: -33, First: -73095, Last: -57039, Member: 1, Count: 73}, + {Series: -32, First: -72514, Last: -56458, Member: 1, Count: 73}, + {Series: -31, First: -72379, Last: -56323, Member: 1, Count: 73}, + {Series: -30, First: -71352, Last: -55296, Member: 1, Count: 73}, + {Series: -29, First: -70994, Last: -54715, Member: 1, Count: 74}, + {Series: -28, First: -73312, Last: -54580, Member: 1, Count: 85}, + {Series: -27, First: -72954, Last: -53553, Member: 1, Count: 88}, + {Series: -26, First: -71927, Last: -52972, Member: 1, Count: 86}, + {Series: -25, First: -72238, Last: -53283, Member: 1, Count: 86}, + {Series: -24, First: -71657, Last: -52256, Member: 1, Count: 88}, + {Series: -23, First: -70630, Last: -51675, Member: 1, Count: 86}, + {Series: -22, First: -70495, Last: -54216, Member: 1, Count: 74}, + {Series: -21, First: -69914, Last: -53858, Member: 1, Count: 73}, + {Series: -20, First: -68887, Last: -52831, Member: 1, Count: 73}, + {Series: -19, First: -68752, Last: -52696, Member: 1, Count: 73}, + {Series: -18, First: -68171, Last: -52115, Member: 1, Count: 73}, + {Series: -17, First: -67144, Last: -51311, Member: 1, Count: 72}, + {Series: -16, First: -67009, Last: -50953, Member: 1, Count: 73}, + {Series: -15, First: -66205, Last: -50372, Member: 1, Count: 72}, + {Series: -14, First: -65401, Last: -49568, Member: 1, Count: 72}, + {Series: -13, First: -65266, Last: -49210, Member: 1, Count: 73}, + {Series: -12, First: -64685, Last: -48629, Member: 1, Count: 73}, + {Series: -11, First: -63658, Last: -47825, Member: 1, Count: 72}, + {Series: -10, First: -63969, Last: -47690, Member: 1, Count: 74}, + {Series: -9, First: -63388, Last: -46886, Member: 1, Count: 75}, + {Series: -8, First: -62361, Last: -46082, Member: 1, Count: 74}, + {Series: -7, First: -64902, Last: -45947, Member: 1, Count: 86}, + {Series: -6, First: -64767, Last: -45589, Member: 1, Count: 87}, + {Series: -5, First: -63517, Last: -44562, Member: 1, Count: 86}, + {Series: -4, First: -63605, Last: -45988, Member: 1, Count: 80}, + {Series: -3, First: -63024, Last: -46968, Member: 1, Count: 73}, + {Series: -2, First: -61997, Last: -45495, Member: 1, Count: 75}, + {Series: -1, First: -61862, Last: -45583, Member: 1, Count: 74}, + {Series: 22, First: -51844, Last: -36011, Member: 1, Count: 72}, + {Series: 181, First: 10430, Last: 26040, Member: 1, Count: 71}, + {Series: 182, First: 8558, Last: 25952, Member: 1, Count: 79}, + {Series: 183, First: 8247, Last: 24080, Member: 1, Count: 72}, + {Series: 184, First: 9720, Last: 26668, Member: 1, Count: 77}, + {Series: 185, First: 9409, Last: 25465, Member: 1, Count: 73}, + {Series: 186, First: 9767, Last: 25154, Member: 1, Count: 70}, + {Series: 187, First: 10794, Last: 26181, Member: 1, Count: 70}, + {Series: 188, First: 10929, Last: 26539, Member: 1, Count: 71}, + {Series: 189, First: 11287, Last: 26674, Member: 1, Count: 70}, + {Series: 190, First: 12314, Last: 27701, Member: 1, Count: 70}, + {Series: 191, First: 12672, Last: 28282, Member: 1, Count: 71}, + {Series: 192, First: 13030, Last: 28417, Member: 1, Count: 70}, + {Series: 193, First: 14057, Last: 29221, Member: 1, Count: 69}, + {Series: 194, First: 14415, Last: 29802, Member: 1, Count: 70}, + {Series: 195, First: 14550, Last: 29937, Member: 1, Count: 70}, + {Series: 196, First: 15800, Last: 30964, Member: 1, Count: 69}, + {Series: 197, First: 15935, Last: 31322, Member: 1, Count: 70}, + {Series: 198, First: 15847, Last: 31457, Member: 1, Count: 71}, + {Series: 199, First: 17097, Last: 32261, Member: 1, Count: 69}, + {Series: 200, First: 16786, Last: 32842, Member: 1, Count: 73}, + {Series: 201, First: 14914, Last: 31193, Member: 1, Count: 74}, + {Series: 202, First: 15941, Last: 31551, Member: 1, Count: 71}, + {Series: 203, First: 16299, Last: 32355, Member: 1, Count: 73}, + {Series: 204, First: 16434, Last: 31821, Member: 1, Count: 70}, + {Series: 205, First: 17238, Last: 32402, Member: 1, Count: 69}, + {Series: 206, First: 17819, Last: 33206, Member: 1, Count: 70}, + {Series: 207, First: 17954, Last: 33341, Member: 1, Count: 70}, + {Series: 208, First: 18758, Last: 33922, Member: 1, Count: 69}, + {Series: 209, First: 19562, Last: 34949, Member: 1, Count: 70}, + {Series: 210, First: 19697, Last: 35084, Member: 1, Count: 70}, + {Series: 211, First: 20501, Last: 35665, Member: 1, Count: 69}, + {Series: 212, First: 21082, Last: 36469, Member: 1, Count: 70}, + {Series: 213, First: 21217, Last: 36604, Member: 1, Count: 70}, + {Series: 214, First: 22021, Last: 37185, Member: 1, Count: 69}, + {Series: 215, First: 22825, Last: 37989, Member: 1, Count: 69}, + {Series: 216, First: 22514, Last: 38124, Member: 1, Count: 71}, + {Series: 217, First: 23318, Last: 38259, Member: 1, Count: 68}, + {Series: 218, First: 23899, Last: 39509, Member: 1, Count: 71}, + {Series: 219, First: 22027, Last: 38752, Member: 1, Count: 76}, + {Series: 220, First: 22162, Last: 37549, Member: 1, Count: 70}, + {Series: 221, First: 23412, Last: 39022, Member: 1, Count: 71}, + {Series: 222, First: 23101, Last: 38711, Member: 1, Count: 71}, + {Series: 223, First: 23682, Last: 38846, Member: 1, Count: 69}, + {Series: 224, First: 24709, Last: 39873, Member: 1, Count: 69}, + {Series: 225, First: 24621, Last: 40231, Member: 1, Count: 71}, + {Series: 226, First: 25202, Last: 40366, Member: 1, Count: 69}, + {Series: 227, First: 26229, Last: 41393, Member: 1, Count: 69}, + {Series: 228, First: 26364, Last: 41751, Member: 1, Count: 70}, + {Series: 229, First: 26945, Last: 41886, Member: 1, Count: 68}, + {Series: 230, First: 27749, Last: 42913, Member: 1, Count: 69}, + {Series: 231, First: 27884, Last: 43271, Member: 1, Count: 70}, + {Series: 232, First: 28465, Last: 43406, Member: 1, Count: 68}, + {Series: 233, First: 29492, Last: 44656, Member: 1, Count: 69}, + {Series: 234, First: 29404, Last: 44791, Member: 1, Count: 70}, + {Series: 235, First: 29316, Last: 44480, Member: 1, Count: 69}, + {Series: 236, First: 30789, Last: 45953, Member: 1, Count: 69}, + {Series: 237, First: 29140, Last: 45865, Member: 1, Count: 76}, + {Series: 238, First: 28606, Last: 43993, Member: 1, Count: 70}, + {Series: 239, First: 29856, Last: 45020, Member: 1, Count: 69}, + {Series: 240, First: 29991, Last: 45378, Member: 1, Count: 70}, + {Series: 241, First: 30126, Last: 45290, Member: 1, Count: 69}, + {Series: 242, First: 31153, Last: 46094, Member: 1, Count: 68}, + {Series: 243, First: 31511, Last: 46898, Member: 1, Count: 70}, + {Series: 244, First: 31646, Last: 46810, Member: 1, Count: 69}, + {Series: 245, First: 32673, Last: 47614, Member: 1, Count: 68}, + {Series: 246, First: 33031, Last: 48418, Member: 1, Count: 70}, + {Series: 247, First: 33389, Last: 48553, Member: 1, Count: 69}, + {Series: 248, First: 34416, Last: 49357, Member: 1, Count: 68}, + {Series: 249, First: 34774, Last: 49938, Member: 1, Count: 69}, + {Series: 250, First: 34909, Last: 50073, Member: 1, Count: 69}, + {Series: 251, First: 35936, Last: 50654, Member: 1, Count: 67}, + {Series: 252, First: 36071, Last: 51458, Member: 1, Count: 70}, + {Series: 253, First: 35760, Last: 51147, Member: 1, Count: 70}, + {Series: 254, First: 37010, Last: 51505, Member: 1, Count: 66}, + {Series: 255, First: 36476, Last: 52532, Member: 1, Count: 73}, + {Series: 256, First: 35273, Last: 50883, Member: 1, Count: 71}, + {Series: 257, First: 36077, Last: 51018, Member: 1, Count: 68}, + {Series: 258, First: 36658, Last: 52045, Member: 1, Count: 70}, + {Series: 259, First: 36793, Last: 51957, Member: 1, Count: 69}, + {Series: 260, First: 37374, Last: 52315, Member: 1, Count: 68}, + {Series: 261, First: 38178, Last: 53342, Member: 1, Count: 69}, + {Series: 262, First: 38313, Last: 53477, Member: 1, Count: 69}, + {Series: 263, First: 39117, Last: 53835, Member: 1, Count: 67}, + {Series: 264, First: 39698, Last: 54862, Member: 1, Count: 69}, + {Series: 265, First: 39833, Last: 55220, Member: 1, Count: 70}, + {Series: 266, First: 40637, Last: 55578, Member: 1, Count: 68}, + {Series: 267, First: 41441, Last: 56605, Member: 1, Count: 69}, + {Series: 268, First: 41576, Last: 56740, Member: 1, Count: 69}, + {Series: 269, First: 42157, Last: 56875, Member: 1, Count: 67}, + {Series: 270, First: 42961, Last: 57902, Member: 1, Count: 68}, + {Series: 271, First: 42427, Last: 58037, Member: 1, Count: 71}, + {Series: 272, First: 41893, Last: 56611, Member: 1, Count: 67}, + {Series: 273, First: 43812, Last: 58753, Member: 1, Count: 68}, + {Series: 274, First: 41940, Last: 57550, Member: 1, Count: 71}, + {Series: 275, First: 42298, Last: 57239, Member: 1, Count: 68}, + {Series: 276, First: 43325, Last: 58266, Member: 1, Count: 68}, + {Series: 277, First: 43460, Last: 58624, Member: 1, Count: 69}, + {Series: 278, First: 43818, Last: 58759, Member: 1, Count: 68}, + {Series: 279, First: 44845, Last: 59786, Member: 1, Count: 68}, + {Series: 280, First: 44980, Last: 60144, Member: 1, Count: 69}, + {Series: 281, First: 45338, Last: 60279, Member: 1, Count: 68}, + {Series: 282, First: 46365, Last: 61306, Member: 1, Count: 68}, + {Series: 283, First: 46500, Last: 61887, Member: 1, Count: 70}, + {Series: 284, First: 46858, Last: 61799, Member: 1, Count: 68}, + {Series: 285, First: 47885, Last: 62826, Member: 1, Count: 68}, + {Series: 286, First: 48020, Last: 63407, Member: 1, Count: 70}, + {Series: 287, First: 48155, Last: 63319, Member: 1, Count: 69}, + {Series: 288, First: 49405, Last: 64123, Member: 1, Count: 67}, + {Series: 289, First: 49317, Last: 64481, Member: 1, Count: 69}, + {Series: 290, First: 47668, Last: 63055, Member: 1, Count: 70}, + {Series: 292, First: 48830, Last: 64217, Member: 1, Count: 70}, + {Series: 293, First: 48742, Last: 63906, Member: 1, Count: 69}, +} + +var lunarSarosExtended = [...]sarosSpan{ + {Series: -54, First: -81564, Last: -61717, Member: 1, Count: 90}, + {Series: -53, First: -80091, Last: -60913, Member: 1, Count: 87}, + {Series: -52, First: -80179, Last: -60778, Member: 1, Count: 88}, + {Series: -51, First: -79821, Last: -60197, Member: 1, Count: 89}, + {Series: -50, First: -78794, Last: -59393, Member: 1, Count: 88}, + {Series: -49, First: -78659, Last: -61711, Member: 1, Count: 77}, + {Series: -48, First: -78078, Last: -61799, Member: 1, Count: 74}, + {Series: -47, First: -77051, Last: -60326, Member: 1, Count: 76}, + {Series: -46, First: -76916, Last: -60414, Member: 1, Count: 75}, + {Series: -45, First: -76335, Last: -60279, Member: 1, Count: 73}, + {Series: -44, First: -75308, Last: -59029, Member: 1, Count: 74}, + {Series: -43, First: -75173, Last: -58894, Member: 1, Count: 74}, + {Series: -42, First: -74592, Last: -58536, Member: 1, Count: 73}, + {Series: -41, First: -73565, Last: -57509, Member: 1, Count: 73}, + {Series: -40, First: -73430, Last: -57151, Member: 1, Count: 74}, + {Series: -39, First: -73072, Last: -56793, Member: 1, Count: 74}, + {Series: -38, First: -71822, Last: -55766, Member: 1, Count: 73}, + {Series: -37, First: -71910, Last: -55408, Member: 1, Count: 75}, + {Series: -36, First: -74228, Last: -55050, Member: 1, Count: 87}, + {Series: -35, First: -70302, Last: -53800, Member: 1, Count: 75}, + {Series: -34, First: -72843, Last: -53665, Member: 1, Count: 87}, + {Series: -33, First: -72931, Last: -53307, Member: 1, Count: 89}, + {Series: -32, First: -71681, Last: -52280, Member: 1, Count: 88}, + {Series: -31, First: -71546, Last: -52368, Member: 1, Count: 87}, + {Series: -30, First: -71411, Last: -54686, Member: 1, Count: 76}, + {Series: -29, First: -70161, Last: -50983, Member: 1, Count: 87}, + {Series: -28, First: -69803, Last: -53301, Member: 1, Count: 75}, + {Series: -27, First: -69445, Last: -53389, Member: 1, Count: 73}, + {Series: -26, First: -68418, Last: -52139, Member: 1, Count: 74}, + {Series: -25, First: -68060, Last: -51781, Member: 1, Count: 74}, + {Series: -24, First: -67702, Last: -51646, Member: 1, Count: 73}, + {Series: -23, First: -66675, Last: -50619, Member: 1, Count: 73}, + {Series: -22, First: -66317, Last: -50261, Member: 1, Count: 73}, + {Series: -21, First: -65959, Last: -49903, Member: 1, Count: 73}, + {Series: -20, First: -64932, Last: -48876, Member: 1, Count: 73}, + {Series: -19, First: -64574, Last: -48518, Member: 1, Count: 73}, + {Series: -18, First: -64662, Last: -48160, Member: 1, Count: 75}, + {Series: -17, First: -63412, Last: -47133, Member: 1, Count: 74}, + {Series: -16, First: -63500, Last: -46775, Member: 1, Count: 76}, + {Series: -15, First: -65818, Last: -46640, Member: 1, Count: 87}, + {Series: -14, First: -64791, Last: -45390, Member: 1, Count: 88}, + {Series: -13, First: -64433, Last: -45032, Member: 1, Count: 88}, + {Series: -12, First: -64298, Last: -45566, Member: 1, Count: 85}, + {Series: -11, First: -63494, Last: -43870, Member: 1, Count: 89}, + {Series: -10, First: -62913, Last: -44181, Member: 1, Count: 85}, + {Series: -9, First: -62555, Last: -46276, Member: 1, Count: 74}, + {Series: -8, First: -61751, Last: -45472, Member: 1, Count: 74}, + {Series: -7, First: -61170, Last: -44891, Member: 1, Count: 74}, + {Series: -6, First: -60812, Last: -44756, Member: 1, Count: 73}, + {Series: -5, First: -60008, Last: -43952, Member: 1, Count: 73}, + {Series: -4, First: -59427, Last: -43148, Member: 1, Count: 74}, + {Series: -3, First: -59069, Last: -43013, Member: 1, Count: 73}, + {Series: -2, First: -58265, Last: -42209, Member: 1, Count: 73}, + {Series: -1, First: -57684, Last: -41628, Member: 1, Count: 73}, + {Series: 0, First: -57549, Last: -41270, Member: 1, Count: 74}, + {Series: 2, First: -55941, Last: -39662, Member: 1, Count: 74}, + {Series: 3, First: -56698, Last: -39527, Member: 1, Count: 78}, + {Series: 4, First: -57901, Last: -56563, Member: 1, Count: 84}, + {Series: 4, First: -55671, Last: -38723, Member: 8, Count: 84}, + {Series: 5, First: -55536, Last: -38142, Member: 1, Count: 79}, + {Series: 8, First: -55800, Last: -36622, Member: 1, Count: 87}, + {Series: 9, First: -55665, Last: -38940, Member: 1, Count: 76}, + {Series: 11, First: -54057, Last: -37555, Member: 1, Count: 75}, + {Series: 22, First: -48781, Last: -32056, Member: 1, Count: 76}, + {Series: 23, First: -47531, Last: -31252, Member: 1, Count: 74}, + {Series: 25, First: -49937, Last: -30536, Member: 1, Count: 88}, + {Series: 26, First: -48687, Last: -29509, Member: 1, Count: 87}, + {Series: 27, First: -48775, Last: -29820, Member: 1, Count: 86}, + {Series: 28, First: -48417, Last: -31915, Member: 1, Count: 75}, + {Series: 29, First: -47167, Last: -28658, Member: 1, Count: 84}, + {Series: 30, First: -47032, Last: -30530, Member: 1, Count: 75}, + {Series: 34, First: -44931, Last: -28875, Member: 1, Count: 73}, + {Series: 37, First: -43188, Last: -27132, Member: 1, Count: 73}, + {Series: 40, First: -41668, Last: -25389, Member: 1, Count: 74}, + {Series: 42, First: -40729, Last: -24004, Member: 1, Count: 76}, + {Series: 43, First: -43047, Last: -23869, Member: 1, Count: 87}, + {Series: 44, First: -41351, Last: -22842, Member: 1, Count: 84}, + {Series: 45, First: -41439, Last: -22484, Member: 1, Count: 86}, + {Series: 46, First: -41527, Last: -24579, Member: 1, Count: 77}, + {Series: 47, First: -40500, Last: -21322, Member: 1, Count: 87}, + {Series: 48, First: -39919, Last: -23194, Member: 1, Count: 76}, + {Series: 51, First: -38399, Last: -22120, Member: 1, Count: 74}, + {Series: 54, First: -36656, Last: -20600, Member: 1, Count: 73}, + {Series: 59, First: -33751, Last: -17695, Member: 1, Count: 73}, + {Series: 60, First: -33393, Last: -17114, Member: 1, Count: 74}, + {Series: 61, First: -35265, Last: -17202, Member: 1, Count: 82}, + {Series: 62, First: -32677, Last: -16175, Member: 1, Count: 75}, + {Series: 63, First: -33880, Last: -15594, Member: 1, Count: 83}, + {Series: 67, First: -32894, Last: -16615, Member: 1, Count: 74}, + {Series: 68, First: -32313, Last: -16257, Member: 1, Count: 73}, + {Series: 69, First: -31509, Last: -15230, Member: 1, Count: 74}, + {Series: 72, First: -29766, Last: -13710, Member: 1, Count: 73}, + {Series: 79, First: -26591, Last: -10312, Member: 1, Count: 74}, + {Series: 83, First: -27166, Last: -8434, Member: 1, Count: 85}, + {Series: 85, First: -26004, Last: -9056, Member: 1, Count: 77}, + {Series: 87, First: -24619, Last: -8340, Member: 1, Count: 74}, + {Series: 88, First: -24261, Last: -8205, Member: 1, Count: 73}, + {Series: 92, First: -22160, Last: -6327, Member: 1, Count: 72}, + {Series: 93, First: -21133, Last: -5300, Member: 1, Count: 72}, + {Series: 94, First: -20998, Last: -4942, Member: 1, Count: 73}, + {Series: 95, First: -20640, Last: -4807, Member: 1, Count: 72}, + {Series: 96, First: -19390, Last: -3557, Member: 1, Count: 72}, + {Series: 97, First: -19478, Last: -3422, Member: 1, Count: 73}, + {Series: 100, First: -19742, Last: -1679, Member: 1, Count: 82}, + {Series: 101, First: -20499, Last: -1990, Member: 1, Count: 84}, + {Series: 103, First: -19114, Last: -605, Member: 1, Count: 84}, + {Series: 104, First: -18979, Last: -2700, Member: 1, Count: 74}, + {Series: 105, First: -17729, Last: -1450, Member: 1, Count: 74}, + {Series: 109, First: -15851, Last: 205, Member: 1, Count: 73}, + {Series: 117, First: -11203, Last: 4630, Member: 1, Count: 72}, + {Series: 118, First: -11068, Last: 5211, Member: 1, Count: 74}, + {Series: 119, First: -13386, Last: 4900, Member: 1, Count: 83}, + {Series: 120, First: -12582, Last: 5927, Member: 1, Count: 84}, + {Series: 121, First: -12001, Last: 6508, Member: 1, Count: 84}, + {Series: 122, First: -12089, Last: 4413, Member: 1, Count: 75}, + {Series: 123, First: -11285, Last: 4771, Member: 1, Count: 73}, + {Series: 124, First: -10481, Last: 5798, Member: 1, Count: 74}, + {Series: 126, First: -9765, Last: 6068, Member: 1, Count: 72}, + {Series: 130, First: -7218, Last: 8615, Member: 1, Count: 72}, + {Series: 134, First: -5563, Last: 10493, Member: 1, Count: 73}, + {Series: 137, First: -5827, Last: 12013, Member: 1, Count: 81}, + {Series: 138, First: -6138, Last: 12148, Member: 1, Count: 83}, + {Series: 139, First: -4442, Last: 13398, Member: 1, Count: 81}, + {Series: 140, First: -5199, Last: 12418, Member: 1, Count: 80}, + {Series: 141, First: -4841, Last: 11215, Member: 1, Count: 73}, + {Series: 142, First: -3591, Last: 12688, Member: 1, Count: 74}, + {Series: 143, First: -3679, Last: 12377, Member: 1, Count: 73}, + {Series: 147, First: -1578, Last: 14032, Member: 1, Count: 71}, + {Series: 148, First: -328, Last: 15282, Member: 1, Count: 71}, + {Series: 154, First: 2712, Last: 18545, Member: 1, Count: 72}, + {Series: 155, First: 2401, Last: 18680, Member: 1, Count: 74}, + {Series: 156, First: 529, Last: 18592, Member: 1, Count: 82}, + {Series: 157, First: 3563, Last: 19842, Member: 1, Count: 74}, + {Series: 158, First: 1914, Last: 19977, Member: 1, Count: 82}, + {Series: 166, First: 6116, Last: 21726, Member: 1, Count: 71}, + {Series: 172, First: 9379, Last: 24989, Member: 1, Count: 71}, + {Series: 173, First: 9514, Last: 25570, Member: 1, Count: 73}, + {Series: 174, First: 7642, Last: 25482, Member: 1, Count: 81}, + {Series: 175, First: 8669, Last: 26063, Member: 1, Count: 79}, + {Series: 176, First: 9027, Last: 26867, Member: 1, Count: 81}, + {Series: 177, First: 8716, Last: 24995, Member: 1, Count: 74}, + {Series: 178, First: 9297, Last: 25130, Member: 1, Count: 72}, + {Series: 180, First: 10236, Last: 26069, Member: 1, Count: 72}, + {Series: 181, First: 10817, Last: 26427, Member: 1, Count: 71}, + {Series: 182, First: 11621, Last: 27454, Member: 1, Count: 72}, + {Series: 183, First: 11979, Last: 27589, Member: 1, Count: 71}, + {Series: 184, First: 12560, Last: 27947, Member: 1, Count: 70}, + {Series: 185, First: 13364, Last: 28974, Member: 1, Count: 71}, + {Series: 186, First: 13499, Last: 29109, Member: 1, Count: 71}, + {Series: 187, First: 14303, Last: 29690, Member: 1, Count: 70}, + {Series: 188, First: 15107, Last: 30494, Member: 1, Count: 70}, + {Series: 189, First: 15019, Last: 30852, Member: 1, Count: 72}, + {Series: 190, First: 15600, Last: 31210, Member: 1, Count: 71}, + {Series: 191, First: 16627, Last: 32237, Member: 1, Count: 71}, + {Series: 192, First: 15647, Last: 32149, Member: 1, Count: 75}, + {Series: 193, First: 14667, Last: 32061, Member: 1, Count: 79}, + {Series: 194, First: 16586, Last: 33534, Member: 1, Count: 77}, + {Series: 195, First: 15606, Last: 32331, Member: 1, Count: 76}, + {Series: 196, First: 15741, Last: 31351, Member: 1, Count: 71}, + {Series: 197, First: 16991, Last: 32824, Member: 1, Count: 72}, + {Series: 198, First: 16903, Last: 32736, Member: 1, Count: 72}, + {Series: 199, First: 17261, Last: 32871, Member: 1, Count: 71}, + {Series: 200, First: 18511, Last: 33898, Member: 1, Count: 70}, + {Series: 201, First: 18646, Last: 34256, Member: 1, Count: 71}, + {Series: 202, First: 19004, Last: 34391, Member: 1, Count: 70}, + {Series: 203, First: 20031, Last: 35418, Member: 1, Count: 70}, + {Series: 204, First: 20389, Last: 35776, Member: 1, Count: 70}, + {Series: 205, First: 20524, Last: 36134, Member: 1, Count: 71}, + {Series: 206, First: 21774, Last: 37161, Member: 1, Count: 70}, + {Series: 207, First: 21909, Last: 37519, Member: 1, Count: 71}, + {Series: 208, First: 22044, Last: 37654, Member: 1, Count: 71}, + {Series: 209, First: 23294, Last: 38681, Member: 1, Count: 70}, + {Series: 210, First: 22983, Last: 39039, Member: 1, Count: 73}, + {Series: 211, First: 21111, Last: 38505, Member: 1, Count: 79}, + {Series: 212, First: 23476, Last: 39755, Member: 1, Count: 74}, + {Series: 213, First: 22496, Last: 39667, Member: 1, Count: 78}, + {Series: 214, First: 22408, Last: 38018, Member: 1, Count: 71}, + {Series: 215, First: 23435, Last: 38822, Member: 1, Count: 70}, + {Series: 216, First: 23793, Last: 39626, Member: 1, Count: 72}, + {Series: 217, First: 23928, Last: 39538, Member: 1, Count: 71}, + {Series: 218, First: 24955, Last: 40119, Member: 1, Count: 69}, + {Series: 219, First: 25313, Last: 40923, Member: 1, Count: 71}, + {Series: 220, First: 25671, Last: 41058, Member: 1, Count: 70}, + {Series: 221, First: 26475, Last: 41639, Member: 1, Count: 69}, + {Series: 222, First: 27056, Last: 42443, Member: 1, Count: 70}, + {Series: 223, First: 27191, Last: 42578, Member: 1, Count: 70}, + {Series: 224, First: 28218, Last: 43382, Member: 1, Count: 69}, + {Series: 225, First: 28576, Last: 44186, Member: 1, Count: 71}, + {Series: 226, First: 28488, Last: 44321, Member: 1, Count: 72}, + {Series: 227, First: 29515, Last: 44679, Member: 1, Count: 69}, + {Series: 228, First: 29873, Last: 45706, Member: 1, Count: 72}, + {Series: 229, First: 28224, Last: 45395, Member: 1, Count: 78}, + {Series: 230, First: 28582, Last: 44638, Member: 1, Count: 73}, + {Series: 231, First: 29386, Last: 46557, Member: 1, Count: 78}, + {Series: 232, First: 29075, Last: 44908, Member: 1, Count: 72}, + {Series: 233, First: 29656, Last: 45043, Member: 1, Count: 70}, + {Series: 234, First: 30460, Last: 46070, Member: 1, Count: 71}, + {Series: 235, First: 30595, Last: 46205, Member: 1, Count: 71}, + {Series: 236, First: 31176, Last: 46340, Member: 1, Count: 69}, + {Series: 237, First: 32203, Last: 47590, Member: 1, Count: 70}, + {Series: 238, First: 32338, Last: 47725, Member: 1, Count: 70}, + {Series: 239, First: 32919, Last: 48083, Member: 1, Count: 69}, + {Series: 240, First: 33723, Last: 49110, Member: 1, Count: 70}, + {Series: 241, First: 33858, Last: 49468, Member: 1, Count: 71}, + {Series: 242, First: 34439, Last: 49603, Member: 1, Count: 69}, + {Series: 243, First: 35243, Last: 50630, Member: 1, Count: 70}, + {Series: 244, First: 35378, Last: 50988, Member: 1, Count: 71}, + {Series: 245, First: 35513, Last: 50900, Member: 1, Count: 70}, + {Series: 246, First: 36763, Last: 52150, Member: 1, Count: 70}, + {Series: 247, First: 35337, Last: 52062, Member: 1, Count: 76}, + {Series: 248, First: 34803, Last: 50413, Member: 1, Count: 71}, + {Series: 249, First: 36276, Last: 52555, Member: 1, Count: 74}, + {Series: 250, First: 35742, Last: 51798, Member: 1, Count: 73}, + {Series: 251, First: 36100, Last: 51487, Member: 1, Count: 70}, + {Series: 252, First: 37127, Last: 52291, Member: 1, Count: 69}, + {Series: 253, First: 37262, Last: 52872, Member: 1, Count: 71}, + {Series: 254, First: 37620, Last: 53007, Member: 1, Count: 70}, + {Series: 255, First: 38647, Last: 53811, Member: 1, Count: 69}, + {Series: 256, First: 39005, Last: 54392, Member: 1, Count: 70}, + {Series: 257, First: 39140, Last: 54527, Member: 1, Count: 70}, + {Series: 258, First: 40390, Last: 55331, Member: 1, Count: 68}, + {Series: 259, First: 40525, Last: 55912, Member: 1, Count: 70}, + {Series: 260, First: 40883, Last: 56047, Member: 1, Count: 69}, + {Series: 261, First: 41910, Last: 56851, Member: 1, Count: 68}, + {Series: 262, First: 42045, Last: 57655, Member: 1, Count: 71}, + {Series: 263, First: 41734, Last: 57344, Member: 1, Count: 71}, + {Series: 264, First: 43207, Last: 58148, Member: 1, Count: 68}, + {Series: 265, First: 42673, Last: 58729, Member: 1, Count: 73}, + {Series: 266, First: 41247, Last: 57303, Member: 1, Count: 73}, + {Series: 267, First: 42274, Last: 57438, Member: 1, Count: 69}, + {Series: 268, First: 42632, Last: 58465, Member: 1, Count: 72}, + {Series: 269, First: 42544, Last: 58154, Member: 1, Count: 71}, + {Series: 270, First: 43348, Last: 58512, Member: 1, Count: 69}, + {Series: 271, First: 43929, Last: 59539, Member: 1, Count: 71}, + {Series: 272, First: 44064, Last: 59451, Member: 1, Count: 70}, + {Series: 273, First: 44868, Last: 60032, Member: 1, Count: 69}, + {Series: 274, First: 45672, Last: 60836, Member: 1, Count: 69}, + {Series: 275, First: 45807, Last: 61194, Member: 1, Count: 70}, + {Series: 276, First: 46611, Last: 61552, Member: 1, Count: 68}, + {Series: 277, First: 47192, Last: 62579, Member: 1, Count: 70}, + {Series: 278, First: 47327, Last: 62714, Member: 1, Count: 70}, + {Series: 279, First: 48131, Last: 63072, Member: 1, Count: 68}, + {Series: 280, First: 48712, Last: 64099, Member: 1, Count: 70}, + {Series: 281, First: 48401, Last: 64011, Member: 1, Count: 71}, + {Series: 282, First: 48759, Last: 63700, Member: 1, Count: 68}, + {Series: 284, First: 47914, Last: 64193, Member: 1, Count: 74}, + {Series: 285, First: 48272, Last: 63436, Member: 1, Count: 69}, + {Series: 286, First: 49299, Last: 64686, Member: 1, Count: 70}, + {Series: 287, First: 49211, Last: 64821, Member: 1, Count: 71}, +} diff --git a/eclipse/saros_test.go b/eclipse/saros_test.go index 9a50354..c71b5b0 100644 --- a/eclipse/saros_test.go +++ b/eclipse/saros_test.go @@ -27,6 +27,43 @@ func TestSolarSarosInfoAgainstNASAExamples(t *testing.T) { }) } +func TestSolarSarosInfoDerivedOutsideCatalogRange(t *testing.T) { + for _, tc := range []struct { + name string + date time.Time + }{ + {name: "before catalog", date: time.Date(-3200, time.January, 1, 12, 0, 0, 0, time.UTC)}, + {name: "after catalog", date: time.Date(3288, time.November, 15, 12, 0, 0, 0, time.UTC)}, + } { + t.Run(tc.name, func(t *testing.T) { + info := ClosestSolarEclipse(tc.date) + if !info.HasSaros || info.Saros.Verified { + t.Fatalf("expected provisional solar Saros: has=%v saros=%+v", info.HasSaros, info.Saros) + } + if info.Saros.Member < 1 || info.Saros.Member > info.Saros.Count || info.Saros.Count < 1 { + t.Fatalf("invalid provisional solar Saros range: %+v", info.Saros) + } + }) + } +} + +func TestSarosInfoDerivedAtFarEpochs(t *testing.T) { + for _, tc := range []struct { + name string + date time.Time + }{ + {name: "solar 6000 BCE", date: time.Date(-6000, time.January, 1, 12, 0, 0, 0, time.UTC)}, + {name: "solar 8000 BCE", date: time.Date(-8000, time.January, 1, 12, 0, 0, 0, time.UTC)}, + } { + t.Run(tc.name, func(t *testing.T) { + info := ClosestSolarEclipse(tc.date) + if !info.HasSaros || info.Saros.Verified || info.Saros.Member < 1 || info.Saros.Member > info.Saros.Count { + t.Fatalf("expected far-epoch provisional solar Saros: has=%v info=%+v", info.HasSaros, info.Saros) + } + }) + } +} + func TestLocalSolarSarosMatchesGlobal(t *testing.T) { date := time.Date(2009, 7, 22, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) global := ClosestSolarEclipse(date) @@ -79,6 +116,26 @@ func TestLunarSarosInfoAgainstNASAExamples(t *testing.T) { }) } +func TestLunarSarosInfoDerivedOutsideCatalogRange(t *testing.T) { + for _, tc := range []struct { + name string + date time.Time + }{ + {name: "before catalog", date: time.Date(-3200, time.January, 1, 12, 0, 0, 0, time.UTC)}, + {name: "after catalog", date: time.Date(3300, time.January, 1, 12, 0, 0, 0, time.UTC)}, + } { + t.Run(tc.name, func(t *testing.T) { + info := ClosestLunarEclipse(tc.date) + if !info.HasSaros || info.Saros.Verified { + t.Fatalf("expected provisional lunar Saros: has=%v saros=%+v", info.HasSaros, info.Saros) + } + if info.Saros.Member < 1 || info.Saros.Member > info.Saros.Count || info.Saros.Count < 1 { + t.Fatalf("invalid provisional lunar Saros range: %+v", info.Saros) + } + }) + } +} + func TestLunarSarosShallowFirstMemberChauvenet(t *testing.T) { info := ClosestLunarEclipseChauvenet(time.Date(-780, 12, 13, 12, 0, 0, 0, time.UTC)) assertSarosInfo(t, info.HasSaros, info.Saros, 61, 1, 78) @@ -139,6 +196,9 @@ func assertSarosInfo(t *testing.T, has bool, got SarosInfo, wantSeries, wantMemb wantCount, ) } + if !got.Verified { + t.Fatalf("expected authoritative Saros metadata: got %+v", got) + } } func assertSarosAnchorTable(t *testing.T, anchors []sarosMagic, seriesBase int) { @@ -155,7 +215,7 @@ func assertSarosAnchorTable(t *testing.T, anchors []sarosMagic, seriesBase int) t.Fatalf("series not strictly increasing: prev=%d current=%d", lastSeries, series) } lastSeries = series - if anchor.Count == 0 || int(anchor.Count) >= sarosWalkLimit { + if anchor.Count == 0 || int(anchor.Count) > sarosExtrapolationWindow { t.Fatalf("unexpected anchor count for series %d: %d", series, anchor.Count) } dateKey := [3]int{int(anchor.Year), int(anchor.Month), int(anchor.Day)} @@ -195,3 +255,30 @@ func assertSarosHeadOverrides(t *testing.T, overrides []sarosHeadOverride, ancho } } } + +func TestLunarSarosOverrideHeadIsAuthoritative(t *testing.T) { + // 月食 4 号在锚表与 override 里各有一个头,相差 9 个沙罗周期(−2646-10-06 与 + // −2483-01-12 / MemberOffset +2)。两套格点同时生效时,相隔 127 年的两场月食都报 + // Series=4、Member=3、Verified=true(重复成员号)。override 必须是唯一权威:窗口外 + // 的那场按文档只报"没有沙罗信息",而不是另一套编号。 + // Lunar series 4 has two heads nine cycles apart (-2646-10-06 in the anchor table and + // -2483-01-12 with MemberOffset +2 as an override). With both lattices live, two + // eclipses 127 years apart both reported Series=4, Member=3, Verified=true. The + // override must be the only authority: the out-of-window eclipse reports no Saros + // metadata instead of a second numbering. + anchored := ClosestLunarEclipse(time.Date(-2483, time.January, 11, 0, 0, 0, 0, time.UTC)) + if !anchored.HasSaros { + t.Fatal("the override head eclipse must keep its Saros metadata") + } + if anchored.Saros.Series != 4 || anchored.Saros.Member != 3 || !anchored.Saros.Verified { + t.Fatalf("override head eclipse Saros=%+v, want series 4 member 3 verified", anchored.Saros) + } + + earlier := ClosestLunarEclipse(time.Date(-2610, time.October, 27, 0, 0, 0, 0, time.UTC)) + if earlier.HasSaros && + earlier.Saros.Series == anchored.Saros.Series && + earlier.Saros.Member == anchored.Saros.Member { + t.Fatalf("two eclipses 127 years apart share series %d member %d", + earlier.Saros.Series, earlier.Saros.Member) + } +} diff --git a/eclipse/solar.go b/eclipse/solar.go index 92fedcc..1a44eff 100644 --- a/eclipse/solar.go +++ b/eclipse/solar.go @@ -65,7 +65,7 @@ type SolarEclipseInfo struct { Type SolarEclipseType // Centrality 中心性, eclipse centrality. Centrality SolarEclipseCentrality - // HasSaros 存在沙罗序列信息, has Saros series metadata. + // HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated). HasSaros bool // Saros 是沙罗序列信息,包括系列号、系列内序号和总成员数。 // Saros is Saros series metadata with the series number, member index, and total member count. @@ -86,6 +86,12 @@ type SolarEclipseInfo struct { Magnitude float64 // Gamma 食甚时影轴到地心的距离, gamma at greatest eclipse. Gamma float64 + // CentralDuration 是食甚点的中心食持续时间,即日食目录(如 NASA「Central Dur.」) + // 的取值口径;沿整条中心线的最大值见 SolarEclipsePath.MaxCentralDuration。 + // CentralDuration is the central-phase duration at the greatest eclipse, the + // value catalogues publish; SolarEclipsePath.MaxCentralDuration is the maximum + // along the whole track. + CentralDuration time.Duration // PathWidthKM 食甚处中心食带宽度, central path width at greatest eclipse. PathWidthKM float64 @@ -292,6 +298,7 @@ func solarEclipseInfoFromBasic(result basic.SolarEclipseResult, location *time.L CentralEndOnEarth: solarEclipseTTJDEToTime(result.CentralEndOnEarth, location), Magnitude: result.Magnitude, Gamma: result.Gamma, + CentralDuration: solarEclipseDurationFromDays(result.CentralDurationDays), PathWidthKM: result.PathWidthKM, GreatestLongitude: result.GreatestLongitude, GreatestLatitude: result.GreatestLatitude, @@ -354,10 +361,21 @@ func solarEclipseRange(info SolarEclipseInfo) (time.Time, time.Time, bool) { } func solarEclipseTTJDEToTime(ttJDE float64, location *time.Location) time.Time { - if ttJDE == 0 { + return solarEclipseTTJDEToTimeWithDeltaT(ttJDE, basic.DeltaT(ttJDE, true), location) +} + +// solarEclipseTTJDEToTimeWithDeltaT 用显式 ΔT 把 TT 换算为时刻:显式 ΔT 的单时刻入口 +// 必须走这条路径,否则回填出来的 UT 会落到进程级模型上,与几何使用的 ΔT 不一致。 +// solarEclipseTTJDEToTimeWithDeltaT converts TT with an explicit ΔT; the single-instant +// entries must use it so the reported UT matches the ΔT their geometry used. +func solarEclipseTTJDEToTimeWithDeltaT( + ttJDE, deltaTSeconds float64, location *time.Location, +) time.Time { + // 与 lunar 侧一致:0 与 NaN/±Inf 都表示“该时刻不存在”。 + if ttJDE == 0 || math.IsNaN(ttJDE) || math.IsInf(ttJDE, 0) { return time.Time{} } - utcJDE := basic.TD2UT(ttJDE, false) + utcJDE := ttJDE - deltaTSeconds/86400 return basic.JDE2DateByZone(utcJDE, location, false) } diff --git a/eclipse/solar_candidates.go b/eclipse/solar_candidates.go new file mode 100644 index 0000000..927ce5f --- /dev/null +++ b/eclipse/solar_candidates.go @@ -0,0 +1,90 @@ +package eclipse + +import ( + "time" + + "b612.me/astro/basic" +) + +// SolarEclipseCandidateOptions 批量枚举配置,零值为全部食型的 NASA bulletin Split-K / enumeration options. +type SolarEclipseCandidateOptions struct { + // Model 月亮半径模型,零值为 NASA bulletin Split-K / lunar radius model. + Model SolarEclipseRadiusModel + // CentralOnly 只保留全食、环食与混合食,含中心线未落到地表的 T0/A0 事件。 + // CentralOnly keeps total, annular, and hybrid eclipses, including T0/A0 events + // whose axis misses the Earth; the kind filter matches SearchLocalCentralSolarEclipse. + CentralOnly bool + // IncludeSaros 附带沙罗序列信息 / attach Saros metadata. + IncludeSaros bool +} + +// SolarEclipseCandidate 一场日食的时刻表条目,不含几何 / geometry-free timetable entry. +type SolarEclipseCandidate struct { + // GreatestEclipse 食甚时刻 / greatest-eclipse instant. + GreatestEclipse time.Time + // Type 全局食型,Centrality 中心食类型 / global type and centrality. + Type SolarEclipseType + Centrality SolarEclipseCentrality + // Magnitude 与 Gamma 是全局食分与伽马 / global magnitude and gamma. + Magnitude float64 + Gamma float64 + // Saros 仅在 IncludeSaros 时有效 / valid only with IncludeSaros. + Saros SarosInfo + HasSaros bool +} + +// 食甚可能早于朔,枚举上界要多留这么多天。 +const solarEclipseCandidateGreatestLeadDays = 0.5 + +// solarEclipseCandidateIsCentral 按全局食型判定,与站心搜索的 Kind 口径一致。 +func solarEclipseCandidateIsCentral(value basic.SolarEclipseType) bool { + switch value { + case basic.SolarEclipseTotal, basic.SolarEclipseAnnular, basic.SolarEclipseHybrid: + return true + } + return false +} + +// SolarEclipseCandidates 枚举区间内的日食时刻表,按时间升序 / eclipses within a range, in time order. +func SolarEclipseCandidates( + start, end time.Time, options SolarEclipseCandidateOptions, +) []SolarEclipseCandidate { + if !end.After(start) { + return nil + } + calculator := localSolarEclipseCalculatorForModel(options.Model) + startTT := solarEclipseTimeToTTJDE(start) + endTT := solarEclipseTimeToTTJDE(end) + candidates := make([]SolarEclipseCandidate, 0, 8) + candidateTT := basic.CalcMoonSHByJDE(startTT, 0) + for iteration := 0; iteration < localSolarEclipseSearchIterationCap; iteration++ { + // 食甚可能略早于朔,上界要多留一点余量,否则窗口末尾那场会被漏掉。 + if candidateTT > endTT+solarEclipseCandidateGreatestLeadDays { + break + } + if isPotentialLocalSolarEclipse(candidateTT) { + globalResult := calculator.global(candidateTT) + if globalResult.Type != basic.SolarEclipseNone && + (!options.CentralOnly || solarEclipseCandidateIsCentral(globalResult.Type)) { + greatest := solarEclipseTTJDEToTime(globalResult.GreatestEclipse, start.Location()) + // 候选朔月取的是“最近”的朔,可能落在 start 之前或 end 之后(食甚早于朔), + // 按食甚时刻双侧过滤;注意不能 continue:候选推进在循环末尾,跳过它会原地打转。 + if !greatest.Before(start) && !greatest.After(end) { + candidate := SolarEclipseCandidate{ + GreatestEclipse: greatest, + Type: mapBasicSolarEclipseType(globalResult.Type), + Centrality: mapBasicSolarEclipseCentrality(globalResult.Centrality), + Magnitude: globalResult.Magnitude, + Gamma: globalResult.Gamma, + } + if options.IncludeSaros { + candidate.Saros, candidate.HasSaros = solarSarosInfo(globalResult.GreatestEclipse) + } + candidates = append(candidates, candidate) + } + } + } + candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, 1, localSolarEclipseSynodicMonthDays) + } + return candidates +} diff --git a/eclipse/solar_local.go b/eclipse/solar_local.go index 2b90fa5..c817107 100644 --- a/eclipse/solar_local.go +++ b/eclipse/solar_local.go @@ -9,7 +9,6 @@ import ( const ( localSolarEclipseSynodicMonthDays = 29.530588853 - localSolarEclipseSearchLimit = 6000 localSolarEclipseSearchEpsilonDay = 1e-8 localSolarEclipseLatitudeLimitDeg = 2.0 ) @@ -55,7 +54,7 @@ type LocalSolarEclipseInfo struct { Model SolarEclipseRadiusModel // Type 站心食型, local eclipse type. Type SolarEclipseType - // HasSaros 存在沙罗序列信息, has Saros series metadata. + // HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated). HasSaros bool // Saros 是沙罗序列信息,包括系列号、系列内序号和总成员数。 // Saros is Saros series metadata with the series number, member index, and total member count. @@ -384,27 +383,13 @@ func searchLocalSolarEclipse( calculator localSolarEclipseCalculator, mode localSolarEclipseQueryMode, ) (LocalSolarEclipseInfo, bool) { - targetTT := solarEclipseTimeToTTJDE(date) - candidateTT := basic.CalcMoonSHByJDE(targetTT, 0) - - for i := 0; i < localSolarEclipseSearchLimit; i++ { - if isPotentialLocalSolarEclipse(candidateTT) { - globalResult := calculator.global(candidateTT) - if globalResult.Type != basic.SolarEclipseNone { - result := calculator.local(globalResult.GreatestEclipse, lon, lat, height) - if result.Type != basic.SolarEclipseNone { - info := localSolarEclipseInfoFromBasic(result, lon, lat, height, date.Location()) - if (mode != localSolarEclipseQueryVisible || localSolarEclipseVisible(info)) && - localSolarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) { - return info, true - } - } - } - } - candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays) - } - - return LocalSolarEclipseInfo{}, false + info, _, ok := searchLocalCentralSolarEclipse( + solarEclipseTimeToTTJDE(date), + solarEclipseTimeToTTJDE(date)+float64(direction)*localSolarEclipseDefaultSearchYears*365.25, + direction, includeCurrent, SolarEclipseLocalSearchAny, lon, lat, height, date.Location(), + calculator, mode, + ) + return info, ok } func searchLocalTotalSolarEclipse( @@ -415,27 +400,13 @@ func searchLocalTotalSolarEclipse( calculator localSolarEclipseCalculator, mode localSolarEclipseQueryMode, ) (LocalSolarEclipseInfo, bool) { - targetTT := solarEclipseTimeToTTJDE(date) - candidateTT := basic.CalcMoonSHByJDE(targetTT, 0) - - for i := 0; i < localSolarEclipseSearchLimit; i++ { - if isPotentialLocalSolarEclipse(candidateTT) { - globalResult := calculator.global(candidateTT) - if globalResult.HasTotal || globalResult.HasHybrid { - result := calculator.local(globalResult.GreatestEclipse, lon, lat, height) - if result.HasTotal { - info := localSolarEclipseInfoFromBasic(result, lon, lat, height, date.Location()) - if (mode != localSolarEclipseQueryVisible || localCentralSolarEclipseVisible(info)) && - localSolarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) { - return info, true - } - } - } - } - candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays) - } - - return LocalSolarEclipseInfo{}, false + info, _, ok := searchLocalCentralSolarEclipse( + solarEclipseTimeToTTJDE(date), + solarEclipseTimeToTTJDE(date)+float64(direction)*localSolarEclipseDefaultSearchYears*365.25, + direction, includeCurrent, SolarEclipseLocalSearchTotal, lon, lat, height, date.Location(), + calculator, mode, + ) + return info, ok } func searchLocalAnnularSolarEclipse( @@ -446,27 +417,13 @@ func searchLocalAnnularSolarEclipse( calculator localSolarEclipseCalculator, mode localSolarEclipseQueryMode, ) (LocalSolarEclipseInfo, bool) { - targetTT := solarEclipseTimeToTTJDE(date) - candidateTT := basic.CalcMoonSHByJDE(targetTT, 0) - - for i := 0; i < localSolarEclipseSearchLimit; i++ { - if isPotentialLocalSolarEclipse(candidateTT) { - globalResult := calculator.global(candidateTT) - if globalResult.HasAnnular || globalResult.HasHybrid { - result := calculator.local(globalResult.GreatestEclipse, lon, lat, height) - if result.HasAnnular && !result.HasTotal { - info := localSolarEclipseInfoFromBasic(result, lon, lat, height, date.Location()) - if (mode != localSolarEclipseQueryVisible || localCentralSolarEclipseVisible(info)) && - localSolarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) { - return info, true - } - } - } - } - candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays) - } - - return LocalSolarEclipseInfo{}, false + info, _, ok := searchLocalCentralSolarEclipse( + solarEclipseTimeToTTJDE(date), + solarEclipseTimeToTTJDE(date)+float64(direction)*localSolarEclipseDefaultSearchYears*365.25, + direction, includeCurrent, SolarEclipseLocalSearchAnnular, lon, lat, height, date.Location(), + calculator, mode, + ) + return info, ok } func isPotentialLocalSolarEclipse(newMoonTT float64) bool { diff --git a/eclipse/solar_local_search.go b/eclipse/solar_local_search.go new file mode 100644 index 0000000..32ff786 --- /dev/null +++ b/eclipse/solar_local_search.go @@ -0,0 +1,170 @@ +package eclipse + +import ( + "math" + "time" + + "b612.me/astro/basic" +) + +// SolarEclipseLocalSearchKind 站心搜索的目标食型 / which station eclipse to look for. +type SolarEclipseLocalSearchKind int + +const ( + // SolarEclipseLocalSearchAny 任意站心日食 / any local eclipse. + SolarEclipseLocalSearchAny SolarEclipseLocalSearchKind = iota + // SolarEclipseLocalSearchTotal 站心全食 / local total eclipse. + SolarEclipseLocalSearchTotal + // SolarEclipseLocalSearchAnnular 站心环食 / local annular eclipse. + SolarEclipseLocalSearchAnnular +) + +// SolarEclipseLocalSearchOptions 站心搜索配置,零值为向后找任意可见日食 / station search options. +type SolarEclipseLocalSearchOptions struct { + // Kind 目标食型,零值为 Any / target kind. + Kind SolarEclipseLocalSearchKind + // Backward 为 true 时向过去搜索 / search towards the past. + Backward bool + // IncludeCurrent 起始时刻那一场也算命中 / accept an eclipse at the start instant. + IncludeCurrent bool + // Geometric 为 true 时跳过可见性门槛 / skip the visibility gate. + Geometric bool + // Model 月亮半径模型,零值为 NASA bulletin Split-K / lunar radius model. + Model SolarEclipseRadiusModel + // MaxYears 搜索跨度(年),<=0 用与旧迭代上限等价的默认跨度 / search horizon in years. + MaxYears float64 +} + +// SolarEclipseLocalSearchStatus 搜索结局与实际覆盖范围 / search outcome and horizon covered. +type SolarEclipseLocalSearchStatus struct { + // Found 是否找到目标事件 / whether the target was found. + Found bool + // Exhausted 已走到跨度上界仍未找到 / horizon consumed without a hit. + Exhausted bool + // YearsScanned 实际覆盖跨度(年),MonthsScanned 候选朔望月数 / covered horizon and candidates. + YearsScanned float64 + MonthsScanned int + // Capped 被迭代硬上限截停,此时 Exhausted 不再代表跨度内没有 / iteration cap stopped the search. + Capped bool + // GlobalChecks 与 LocalChecks 是全局与站心解算次数 / solution counters. + GlobalChecks int + LocalChecks int +} + +const ( + // 旧口径为 6000 次候选步进,实测覆盖 992.33~992.47 年;取 992.5 年完整覆盖旧跨度。 + localSolarEclipseDefaultSearchYears = 992.5 + // 时间跨度搜索的迭代硬上限,防止病态输入死循环。 + localSolarEclipseSearchIterationCap = 200000 +) + +// SearchLocalCentralSolarEclipse 在给定跨度内搜索站心食并回报是否用尽跨度 / station search within a horizon. +func SearchLocalCentralSolarEclipse( + date time.Time, lon, lat, height float64, + options SolarEclipseLocalSearchOptions, +) (LocalSolarEclipseInfo, SolarEclipseLocalSearchStatus) { + calculator := localSolarEclipseCalculatorForModel(options.Model) + mode := localSolarEclipseQueryVisible + if options.Geometric { + mode = localSolarEclipseQueryGeometric + } + maxYears := options.MaxYears + if maxYears <= 0 || math.IsNaN(maxYears) || math.IsInf(maxYears, 0) { + maxYears = localSolarEclipseDefaultSearchYears + } + direction := 1 + if options.Backward { + direction = -1 + } + targetTT := solarEclipseTimeToTTJDE(date) + info, status, ok := searchLocalCentralSolarEclipse( + targetTT, targetTT+float64(direction)*maxYears*365.25, direction, + options.IncludeCurrent, options.Kind, lon, lat, height, date.Location(), + calculator, mode, + ) + status.Found = ok + status.Exhausted = !ok && !status.Capped + return info, status +} + +func localSolarEclipseCalculatorForModel(model SolarEclipseRadiusModel) localSolarEclipseCalculator { + if model == SolarEclipseModelIAUSingleK { + return localSolarEclipseIAUSingleK + } + return localSolarEclipseNASABulletinSplitK +} + +func searchLocalCentralSolarEclipse( + targetTT, horizonTT float64, + direction int, + includeCurrent bool, + kind SolarEclipseLocalSearchKind, + lon, lat, height float64, + location *time.Location, + calculator localSolarEclipseCalculator, + mode localSolarEclipseQueryMode, +) (LocalSolarEclipseInfo, SolarEclipseLocalSearchStatus, bool) { + status := SolarEclipseLocalSearchStatus{} + candidateTT := basic.CalcMoonSHByJDE(targetTT, 0) + capped := true + for iteration := 0; iteration < localSolarEclipseSearchIterationCap; iteration++ { + if direction > 0 && candidateTT > horizonTT { + capped = false + break + } + if direction < 0 && candidateTT < horizonTT { + capped = false + break + } + status.MonthsScanned++ + status.YearsScanned = math.Abs(candidateTT-targetTT) / 365.25 + if isPotentialLocalSolarEclipse(candidateTT) { + globalResult := calculator.global(candidateTT) + status.GlobalChecks++ + if globalResult.Type != basic.SolarEclipseNone { + if localSolarEclipseKindMatchesGlobal(kind, globalResult) { + result := calculator.local(globalResult.GreatestEclipse, lon, lat, height) + status.LocalChecks++ + if localSolarEclipseKindMatchesLocal(kind, result) { + info := localSolarEclipseInfoFromBasic(result, lon, lat, height, location) + if (mode != localSolarEclipseQueryVisible || localSolarEclipseKindVisible(kind, info)) && + localSolarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) { + return info, status, true + } + } + } + } + } + candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays) + } + // 循环自然结束说明撞上了迭代硬上限。 + status.Capped = capped + return LocalSolarEclipseInfo{}, status, false +} + +func localSolarEclipseKindMatchesGlobal(kind SolarEclipseLocalSearchKind, result basic.SolarEclipseResult) bool { + switch kind { + case SolarEclipseLocalSearchTotal: + return result.HasTotal || result.HasHybrid + case SolarEclipseLocalSearchAnnular: + return result.HasAnnular || result.HasHybrid + } + return true +} + +func localSolarEclipseKindMatchesLocal(kind SolarEclipseLocalSearchKind, result basic.LocalSolarEclipseResult) bool { + switch kind { + case SolarEclipseLocalSearchTotal: + return result.HasTotal + case SolarEclipseLocalSearchAnnular: + return result.HasAnnular && !result.HasTotal + } + return result.Type != basic.SolarEclipseNone +} + +func localSolarEclipseKindVisible(kind SolarEclipseLocalSearchKind, info LocalSolarEclipseInfo) bool { + if kind == SolarEclipseLocalSearchAny { + return localSolarEclipseVisible(info) + } + return localCentralSolarEclipseVisible(info) +} diff --git a/eclipse/solar_local_search_test.go b/eclipse/solar_local_search_test.go new file mode 100644 index 0000000..6355539 --- /dev/null +++ b/eclipse/solar_local_search_test.go @@ -0,0 +1,188 @@ +package eclipse + +import ( + "testing" + "time" +) + +func TestSearchLocalCentralSolarEclipseReportsExhaustion(t *testing.T) { + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + // 北京:20 年跨度内必有全食。 + info, status := SearchLocalCentralSolarEclipse(start, 116.40, 39.90, 0, + SolarEclipseLocalSearchOptions{Kind: SolarEclipseLocalSearchTotal, MaxYears: 20}) + if !status.Found || status.Exhausted { + t.Fatalf("Beijing 20y: found=%v exhausted=%v", status.Found, status.Exhausted) + } + if got := info.GreatestEclipse.UTC().Format("2006-01-02"); got != "2035-09-02" { + t.Fatalf("Beijing next total=%s, want 2035-09-02", got) + } + if !info.HasTotal || !info.HasCentral { + t.Fatalf("Beijing 2035-09-02 should be a local total eclipse: %+v", info) + } + if status.YearsScanned <= 0 || status.MonthsScanned <= 0 || status.GlobalChecks <= 0 { + t.Fatalf("status did not account for the work done: %+v", status) + } + // 广州:318 年跨度内没有可见日全食 —— 必须报告"用尽跨度"而不是含糊的 false。 + _, exhausted := SearchLocalCentralSolarEclipse(start, 113.26, 23.13, 0, + SolarEclipseLocalSearchOptions{Kind: SolarEclipseLocalSearchTotal, MaxYears: 300}) + if exhausted.Found || !exhausted.Exhausted { + t.Fatalf("Guangzhou 300y: found=%v exhausted=%v, want none with Exhausted set", + exhausted.Found, exhausted.Exhausted) + } + if exhausted.YearsScanned < 290 || exhausted.YearsScanned > 301 { + t.Fatalf("Guangzhou 300y covered %.1f years, want about 300", exhausted.YearsScanned) + } +} + +func TestSearchLocalCentralSolarEclipseHonoursWideHorizon(t *testing.T) { + if testing.Short() { + t.Skip("wide-horizon search takes about 13 s") + } + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + info, status := SearchLocalCentralSolarEclipse(start, 113.26, 23.13, 0, + SolarEclipseLocalSearchOptions{Kind: SolarEclipseLocalSearchTotal, MaxYears: 1400}) + if !status.Found { + t.Fatalf("Guangzhou 1400y: found=%v exhausted=%v scanned=%.1f", + status.Found, status.Exhausted, status.YearsScanned) + } + if year := info.GreatestEclipse.UTC().Year(); year != 3301 { + t.Fatalf("Guangzhou next total within 1400y is in %d, want 3301", year) + } +} + +func TestSearchLocalCentralSolarEclipseMatchesLegacyEntryPoint(t *testing.T) { + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + legacy, ok := NextLocalTotalSolarEclipse(start, 121.47, 31.23, 0) + if !ok { + t.Fatal("legacy entry point found no Shanghai total eclipse") + } + info, status := SearchLocalCentralSolarEclipse(start, 121.47, 31.23, 0, + SolarEclipseLocalSearchOptions{Kind: SolarEclipseLocalSearchTotal, MaxYears: 300}) + if !status.Found { + t.Fatalf("new entry point did not find the Shanghai total eclipse: %+v", status) + } + if !info.GreatestEclipse.Equal(legacy.GreatestEclipse) || info.Magnitude != legacy.Magnitude { + t.Fatalf("new entry point disagrees with the legacy one: %v/%.6f vs %v/%.6f", + info.GreatestEclipse, info.Magnitude, legacy.GreatestEclipse, legacy.Magnitude) + } +} + +func TestSolarEclipseCandidatesEnumeratesTimetable(t *testing.T) { + start := time.Date(2009, time.January, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2011, time.January, 1, 0, 0, 0, 0, time.UTC) + candidates := SolarEclipseCandidates(start, end, SolarEclipseCandidateOptions{IncludeSaros: true}) + seen := map[string]SolarEclipseType{} + for _, candidate := range candidates { + if candidate.GreatestEclipse.Before(start) || !candidate.GreatestEclipse.Before(end) { + t.Fatalf("candidate %v outside the requested range", candidate.GreatestEclipse) + } + seen[candidate.GreatestEclipse.UTC().Format("2006-01-02")] = candidate.Type + } + want := map[string]SolarEclipseType{ + "2009-01-26": SolarEclipseAnnular, + "2009-07-22": SolarEclipseTotal, + "2010-01-15": SolarEclipseAnnular, + "2010-07-11": SolarEclipseTotal, + } + for date, eclipseType := range want { + if seen[date] != eclipseType { + t.Fatalf("candidate %s type=%v, want %v (all: %v)", date, seen[date], eclipseType, seen) + } + } + for _, candidate := range candidates { + if candidate.GreatestEclipse.UTC().Format("2006-01-02") == "2009-07-22" { + if !candidate.HasSaros || candidate.Saros.Series != 136 { + t.Fatalf("2009-07-22 Saros=%+v, want series 136", candidate.Saros) + } + } + } + central := SolarEclipseCandidates(start, end, SolarEclipseCandidateOptions{CentralOnly: true}) + if len(central) != len(candidates) { + t.Fatalf("2009-2010 has no purely partial eclipses: central=%d all=%d", len(central), len(candidates)) + } + for _, candidate := range central { + if candidate.Type == SolarEclipsePartial { + t.Fatalf("CentralOnly kept a partial eclipse: %v", candidate.GreatestEclipse) + } + } +} + +func TestSolarEclipseCandidatesRespectRequestedRange(t *testing.T) { + // 候选朔月取的是"最近"的朔,可能落在区间之前;返回值必须按食甚时刻双向过滤。 + inside := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) + after := time.Date(2024, time.April, 9, 0, 0, 0, 0, time.UTC) + found := SolarEclipseCandidates(inside, after, SolarEclipseCandidateOptions{}) + if len(found) != 1 { + t.Fatalf("2024-04-08..04-09 candidates=%d, want the 2024-04-08 eclipse", len(found)) + } + if year, month, day := found[0].GreatestEclipse.UTC().Date(); year != 2024 || month != time.April || day != 8 { + t.Fatalf("candidate=%v, want 2024-04-08", found[0].GreatestEclipse.UTC()) + } + // 区间起点晚于该场食甚:不得再把上一场带回来。 + window := SolarEclipseCandidates(after, after.Add(24*time.Hour), SolarEclipseCandidateOptions{}) + if len(window) != 0 { + t.Fatalf("2024-04-09..04-10 candidates=%d, want none (the 04-08 eclipse is out of range)", len(window)) + } + + // 食甚可以早于朔:1901-05-18 的食甚 05:33:47 早于该次朔 05:37:29。区间上界落在 + // 两者之间时,按朔月判上界的旧实现会在求值前就跳出,把这场整段漏掉。 + // The greatest eclipse can precede the new moon (1901-05-18: greatest 05:33:47 before + // the 05:37:29 new moon). With the new moon as the upper bound the old implementation + // broke out before evaluating it and dropped the eclipse entirely. + greatest := time.Date(1901, time.May, 18, 5, 33, 47, 0, time.UTC) + early := SolarEclipseCandidates(greatest.AddDate(0, 0, -3), greatest.Add(30*time.Second), SolarEclipseCandidateOptions{}) + if len(early) != 1 { + t.Fatalf("1901-05-18 window candidates=%d, want the eclipse whose greatest precedes its new moon", len(early)) + } + if year, month, day := early[0].GreatestEclipse.UTC().Date(); year != 1901 || month != time.May || day != 18 { + t.Fatalf("candidate=%v, want 1901-05-18", early[0].GreatestEclipse.UTC()) + } + if early[0].GreatestEclipse.UTC().After(greatest.Add(30 * time.Second)) { + t.Fatalf("candidate %v is outside the requested window", early[0].GreatestEclipse.UTC()) + } + + // 反向:朔月在区间内但食甚在区间之后,同样不得返回。 + // Conversely, a new moon inside the window whose greatest falls after the window end + // must not be returned. + late := SolarEclipseCandidates( + time.Date(2026, time.February, 12, 12, 2, 0, 0, time.UTC), + time.Date(2026, time.February, 17, 12, 7, 0, 0, time.UTC), + SolarEclipseCandidateOptions{}, + ) + if len(late) != 0 { + t.Fatalf("2026-02 window candidates=%d (%v), want none", len(late), late[0].GreatestEclipse) + } +} + +func TestSolarEclipseCandidatesCentralOnlyUsesGlobalType(t *testing.T) { + start := time.Date(-911, time.January, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(-908, time.January, 1, 0, 0, 0, 0, time.UTC) + all := SolarEclipseCandidates(start, end, SolarEclipseCandidateOptions{}) + central := SolarEclipseCandidates(start, end, SolarEclipseCandidateOptions{CentralOnly: true}) + // −0909-11-15 是全食但影轴未落到地表:按食型过滤必须保留它。 + // -0909-11-15 is total while its axis misses the Earth: the type filter keeps it. + sawMissingAxis := false + for _, candidate := range all { + if candidate.Type == SolarEclipseTotal && candidate.Centrality == SolarEclipseNonCentral && + candidate.GreatestEclipse.UTC().Format("2006-01-02") == "-0909-11-15" { + sawMissingAxis = true + } + } + if !sawMissingAxis { + t.Fatal("fixture no longer contains the total eclipse whose axis misses the Earth") + } + want := make([]SolarEclipseCandidate, 0, len(all)) + for _, candidate := range all { + if candidate.Type != SolarEclipsePartial { + want = append(want, candidate) + } + } + if len(central) != len(want) { + t.Fatalf("CentralOnly kept %d candidates, want the %d non-partial ones", len(central), len(want)) + } + for index := range want { + if central[index] != want[index] { + t.Fatalf("candidate %d = %+v, want %+v", index, central[index], want[index]) + } + } +} diff --git a/eclipse/solar_panel.go b/eclipse/solar_panel.go new file mode 100644 index 0000000..8c048dd --- /dev/null +++ b/eclipse/solar_panel.go @@ -0,0 +1,152 @@ +package eclipse + +import ( + "math" + "time" + + "b612.me/astro/basic" +) + +const ( + // brownLunationEpochJDE 是布朗月序数 0 号朔的儒略日(1923-01-17)。 + brownLunationEpochJDE = 2423436.0 + // brownLunationSynodicMonth 是平均朔望月长度,用于把朔的儒略日换算成月序数。 + brownLunationSynodicMonth = 29.530588853 + // 视半径换算地平视差用的半径比:地球赤道半径除以天体半径。 + horizontalParallaxMoonRatio = 6378.137 / 1737.4 + horizontalParallaxSunRatio = 6378.137 / 696000.0 +) + +// SolarEclipseGeocentricPanel 汇总 详细版式面板所需的食甚时刻地心量。 +// SolarEclipseGeocentricPanel carries the geocentric quantities that detailed panels print. +type SolarEclipseGeocentricPanel struct { + // Conjunction 是本次朔,即地心视黄经相等的时刻;与 NASA 全球图上的 Geocentric Conjunction 不是同一个量。 + // Conjunction is the new moon, the instant of equal geocentric apparent ecliptic longitude. It is not + // the same quantity as the Geocentric Conjunction printed on NASA world maps. + Conjunction time.Time + ConjunctionJDE float64 + // RightAscensionConjunction 是地心视赤经相等的时刻,也就是 NASA 全球图上 Geocentric Conjunction 的口径; + // 2009-07-22 两者相差约 90 s(视黄经相等在 02:34:34 UT,视赤经相等在 02:33:04 UT)。 + // RightAscensionConjunction is the instant of equal geocentric apparent right ascension, the quantity NASA + // world maps print as Geocentric Conjunction. For 2009-07-22 the two differ by about 90 s. + RightAscensionConjunction time.Time + RightAscensionConjunctionJDE float64 + // RightAscensionConjunctionJD 是同一时刻的世界时儒略日,NASA 全球图上印的就是它。 + // RightAscensionConjunctionJD is the universal-time Julian day of that instant, the value NASA maps print. + RightAscensionConjunctionJD float64 + // DeltaTSeconds 是食甚时刻实际使用的 ΔT。 + // DeltaTSeconds is the ΔT used at greatest eclipse. + DeltaTSeconds float64 + + SunRightAscensionDeg float64 + SunDeclinationDeg float64 + SunSemidiameterArcsec float64 + SunParallaxArcsec float64 + + MoonRightAscensionDeg float64 + MoonDeclinationDeg float64 + MoonSemidiameterArcsec float64 + MoonParallaxArcsec float64 + + LibrationLongitudeDeg float64 + LibrationLatitudeDeg float64 + LibrationPositionAngleDeg float64 + + BrownLunationNumber int + // PenumbralK 与 UmbralK 是月地半径比 k1/k2。 + PenumbralK float64 + UmbralK float64 + // BodyShiftLongitudeArcsec 与 BodyShiftLatitudeArcsec 是星历表里的 Δl/Δb;本库模型不做月面位置平移,恒为零。 + BodyShiftLongitudeArcsec float64 + BodyShiftLatitudeArcsec float64 + // Ephemeris 是所用模型名称。 + Ephemeris string + // SingleK 表示使用的是 IAU Single-K(k1 同时用于半影与本影)。 + SingleK bool +} + +// horizontalParallaxArcsec 由视半径换算地平视差:sin(HP) = sin(SD) × R⊕ / R天体。 +func horizontalParallaxArcsec(semidiameterArcsec, radiusRatio float64) float64 { + sine := math.Sin(semidiameterArcsec / 3600 * math.Pi / 180) + return math.Asin(math.Max(-1, math.Min(1, sine*radiusRatio))) * 180 / math.Pi * 3600 +} + +// SolarEclipseGeocentricPanelAt 计算给定日食在食甚时刻的地心量面板。 +// SolarEclipseGeocentricPanelAt computes the geocentric panel of one eclipse at greatest eclipse. +func SolarEclipseGeocentricPanelAt(date time.Time) (SolarEclipseGeocentricPanel, bool) { + info, ok := SolarEclipseOnDateNASABulletinSplitK(date) + if !ok { + return SolarEclipseGeocentricPanel{}, false + } + panel := solarEclipseGeocentricPanelAt(info) + panel.Ephemeris = "NASA bulletin Split-K" + panel.PenumbralK = basic.SolarEclipsePenumbralK + panel.UmbralK = basic.SolarEclipseUmbralK + return panel, true +} + +// SolarEclipseGeocentricPanelIAUSingleK 使用 IAU Single-K 计算地心量面板。 +// SolarEclipseGeocentricPanelIAUSingleK computes the geocentric panel with the IAU Single-K model. +func SolarEclipseGeocentricPanelIAUSingleK(date time.Time) (SolarEclipseGeocentricPanel, bool) { + info, ok := SolarEclipseOnDateIAUSingleK(date) + if !ok { + return SolarEclipseGeocentricPanel{}, false + } + panel := solarEclipseGeocentricPanelAt(info) + panel.Ephemeris = "IAU Single-K" + panel.PenumbralK = basic.SolarEclipsePenumbralK + panel.UmbralK = basic.SolarEclipsePenumbralK + panel.SingleK = true + return panel, true +} + +func solarEclipseGeocentricPanelAt(info SolarEclipseInfo) SolarEclipseGeocentricPanel { + tt := solarEclipseTimeToTTJDE(info.GreatestEclipse) + conjunctionJDE := basic.CalcMoonSHByJDE(tt, 0) + rightAscensionJDE := solarEclipseRightAscensionConjunction(tt) + panel := SolarEclipseGeocentricPanel{ + Conjunction: solarEclipseTTJDEToTime(conjunctionJDE, info.GreatestEclipse.Location()), + ConjunctionJDE: conjunctionJDE, + RightAscensionConjunction: solarEclipseTTJDEToTime(rightAscensionJDE, info.GreatestEclipse.Location()), + RightAscensionConjunctionJDE: rightAscensionJDE, + RightAscensionConjunctionJD: rightAscensionJDE - basic.DeltaT(tt, true)/86400, + DeltaTSeconds: basic.DeltaT(tt, true), + } + panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDec(tt) + panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDec(tt) + panel.SunSemidiameterArcsec = basic.SunSemidiameter(tt) + panel.MoonSemidiameterArcsec = basic.MoonSemidiameter(tt) + panel.SunParallaxArcsec = horizontalParallaxArcsec(panel.SunSemidiameterArcsec, horizontalParallaxSunRatio) + panel.MoonParallaxArcsec = horizontalParallaxArcsec(panel.MoonSemidiameterArcsec, horizontalParallaxMoonRatio) + physical := basic.MoonPhysical(tt) + panel.LibrationLongitudeDeg = physical.LibrationLongitude + panel.LibrationLatitudeDeg = physical.LibrationLatitude + panel.LibrationPositionAngleDeg = physical.PositionAngle + panel.BrownLunationNumber = int(math.Floor((panel.ConjunctionJDE-brownLunationEpochJDE)/brownLunationSynodicMonth)) + 1 + return panel +} + +// solarEclipseRightAscensionGap 返回月亮与太阳的地心视赤经差,单位弧度。 +func solarEclipseRightAscensionGap(tt float64) float64 { + sunRightAscension, _ := basic.SunApparentRaDec(tt) + moonRightAscension, _ := basic.HMoonTrueRaDec(tt) + return math.Remainder(moonRightAscension-sunRightAscension, 360) * math.Pi / 180 +} + +// solarEclipseRightAscensionConjunction 求地心视赤经相等的时刻。 +// 赤经差在合附近单调,用牛顿法从食甚时刻收敛即可,不需要预先求朔。 +func solarEclipseRightAscensionConjunction(tt float64) float64 { + estimate := tt + for iteration := 0; iteration < 40; iteration++ { + value := solarEclipseRightAscensionGap(estimate) + if math.Abs(value) < 1e-10 { + break + } + derivative := (solarEclipseRightAscensionGap(estimate+1e-5) - solarEclipseRightAscensionGap(estimate-1e-5)) / 2e-5 + if derivative == 0 || math.IsNaN(derivative) || math.IsInf(derivative, 0) { + break + } + estimate -= value / derivative + } + return estimate +} diff --git a/eclipse/solar_panel_test.go b/eclipse/solar_panel_test.go new file mode 100644 index 0000000..ec22559 --- /dev/null +++ b/eclipse/solar_panel_test.go @@ -0,0 +1,69 @@ +package eclipse + +import ( + "math" + "testing" + "time" +) + +func solarEclipsePanelFormatRA(degrees float64) (int, int, float64) { + total := math.Mod(degrees, 360) / 15 + hours := int(total) + minutes := int((total - float64(hours)) * 60) + seconds := ((total-float64(hours))*60 - float64(minutes)) * 60 + return hours, minutes, seconds +} + +// 地心量面板要与 NASA 星历表同量级:视半径/视差/天平动/月序数逐项对拍 2009-07-22。 +func TestSolarEclipseGeocentricPanelMatchesNASABulletin(t *testing.T) { + panel, ok := SolarEclipseGeocentricPanelAt(time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)) + if !ok { + t.Fatal("missing eclipse") + } + sunHours, sunMinutes, sunSeconds := solarEclipsePanelFormatRA(panel.SunRightAscensionDeg) + t.Logf("sun R.A. %02dh%02dm%04.1fs Dec %+.4f S.D. %.1f\" H.P. %.1f\"", + sunHours, sunMinutes, sunSeconds, panel.SunDeclinationDeg, panel.SunSemidiameterArcsec, panel.SunParallaxArcsec) + moonHours, moonMinutes, moonSeconds := solarEclipsePanelFormatRA(panel.MoonRightAscensionDeg) + t.Logf("moon R.A. %02dh%02dm%04.1fs Dec %+.4f S.D. %.1f\" H.P. %.1f\"", + moonHours, moonMinutes, moonSeconds, panel.MoonDeclinationDeg, panel.MoonSemidiameterArcsec, panel.MoonParallaxArcsec) + t.Logf("libration l=%+.2f b=%+.2f c=%.2f Brown=%d ΔT=%.1fs k1=%.7f k2=%.7f", + panel.LibrationLongitudeDeg, panel.LibrationLatitudeDeg, panel.LibrationPositionAngleDeg, + panel.BrownLunationNumber, panel.DeltaTSeconds, panel.PenumbralK, panel.UmbralK) + + // NASA 2009-07-22:太阳 00°15'44.1" / 00°00'08.7",月亮 00°16'42.3" / 01°01'19.8"。 + if math.Abs(panel.SunSemidiameterArcsec-944.1) > 1.5 { + t.Fatalf("sun semidiameter = %.1f\", want 944.1", panel.SunSemidiameterArcsec) + } + if math.Abs(panel.SunParallaxArcsec-8.7) > 0.4 { + t.Fatalf("sun parallax = %.1f\", want 8.7", panel.SunParallaxArcsec) + } + if math.Abs(panel.MoonSemidiameterArcsec-1002.3) > 2 { + t.Fatalf("moon semidiameter = %.1f\", want 1002.3", panel.MoonSemidiameterArcsec) + } + if math.Abs(panel.MoonParallaxArcsec-3679.8) > 4 { + t.Fatalf("moon parallax = %.1f\", want 3679.8", panel.MoonParallaxArcsec) + } + if panel.BrownLunationNumber != 1071 { + t.Fatalf("brown lunation = %d, want 1071", panel.BrownLunationNumber) + } + if math.Abs(panel.LibrationLongitudeDeg-0.67) > 0.05 || math.Abs(panel.LibrationPositionAngleDeg-10.52) > 0.05 { + t.Fatalf("libration l=%.2f c=%.2f, want +0.67 / 10.52", + panel.LibrationLongitudeDeg, panel.LibrationPositionAngleDeg) + } + if panel.Conjunction.IsZero() || panel.Conjunction.After(panel.Conjunction.Add(time.Hour)) { + t.Fatal("conjunction instant missing") + } + t.Logf("conjunction (equal apparent ecliptic longitude) %s", panel.Conjunction.UTC().Format("15:04:05.0")) + t.Logf("conjunction (equal apparent right ascension) %s", panel.RightAscensionConjunction.UTC().Format("15:04:05.0")) + + // NASA 全球图的 Geocentric Conjunction 取视赤经相等,2009-07-22 为 02:33:04.4 UT。 + nasa := time.Date(2009, time.July, 22, 2, 33, 4, 0, time.UTC) + if delta := panel.RightAscensionConjunction.Sub(nasa).Seconds(); math.Abs(delta) > 2 { + t.Fatalf("right-ascension conjunction %s differs from NASA by %.1f s", + panel.RightAscensionConjunction.Format("15:04:05.0"), delta) + } + // 朔(视黄经相等)比它晚约 90 s,两者不是同一个量。 + if delta := panel.Conjunction.Sub(panel.RightAscensionConjunction).Seconds(); delta < 60 || delta > 120 { + t.Fatalf("new moon is %.1f s after the right-ascension conjunction, want about 90 s", delta) + } +} diff --git a/eclipse/solar_path.go b/eclipse/solar_path.go index 0e36085..19a0ce5 100644 --- a/eclipse/solar_path.go +++ b/eclipse/solar_path.go @@ -16,6 +16,11 @@ type SolarEclipsePathOptions struct { // TargetSpacingKM 是相邻中心线点的最大目标地表距离;<=0 时不按距离加密。 // TargetSpacingKM is the target maximum ground spacing between centerline points; values <= 0 disable spacing refinement. TargetSpacingKM float64 + // SkipCentralBand 表示调用方已经持有同一场日食的完整足迹结果(其中包含 + // CentralBandSegments),本次不再重复重建中心食带。 + // SkipCentralBand reports that the caller already holds the full-footprint + // result for the same eclipse, so the central-band envelope is not rebuilt. + SkipCentralBand bool } // SolarEclipsePathPoint 表示日食路径上的一个地理点。 @@ -47,6 +52,21 @@ type SolarEclipsePath struct { NorthernLimit []SolarEclipsePathPoint // SouthernLimit 是中心食带南界近似线, approximate southern limit of the central path. SouthernLimit []SolarEclipsePathPoint + // MaxCentralDuration 是中心线上最长的中心食时长,0 表示没有中心食样本可用。 + // 与目录口径的区别:目录取食甚点,这里取整条中心线的最大值。 + // MaxCentralDuration is the longest central phase on the center line, and 0 + // when no usable sample exists. Catalogues publish the value at greatest + // eclipse; this is the maximum along the track. + MaxCentralDuration time.Duration + // MaxCentralDurationLongitude 与 MaxCentralDurationLatitude 给出该最长时长的位置。 + // MaxCentralDurationLongitude and MaxCentralDurationLatitude locate it. + MaxCentralDurationLongitude float64 + MaxCentralDurationLatitude float64 + // CentralBandSegments is the authoritative continuous central-band envelope. + CentralBandSegments [][]SolarEclipsePathPoint + // CentralBandSampled reports that the envelope was reconstructed from sampled + // instantaneous footprints rather than an analytic envelope. + CentralBandSampled bool // Step 是实际采用的基础时间采样步长, effective base time step. Step time.Duration // TargetSpacingKM 是实际采用的目标空间采样距离,单位千米。 @@ -66,6 +86,27 @@ type SolarEclipsePartialFootprintOptions struct { // CentralShadowStep 是本影/反本影瞬时足迹的采样步长;<=0 时不计算。 // CentralShadowStep is the umbral/antumbral footprint step; values <= 0 disable it. CentralShadowStep time.Duration + // RiseSetStep 独立于 Step 控制日升日落边界的采样步长;零值使用 2 分钟。 + // RiseSetStep controls sunrise/sunset boundary sampling independently from Step; zero uses two minutes. + RiseSetStep time.Duration + // DisableRiseSet 跳过六类日升日落阶段边界曲线。 + // DisableRiseSet skips the six sunrise/sunset boundary curves. + DisableRiseSet bool + // MagnitudeValues 是要计算的地方最大食分等值线;空值不计算,线条使用独立的自适应空间采样。 + // MagnitudeValues requests local maximum-magnitude contours; empty disables them, and contours use independent adaptive spatial sampling. + MagnitudeValues []float64 + // GreatestTimeValues 是要计算的地方食甚时刻等值线取值,按绝对时刻使用,Location 不参与换算; + // 空值时改用 GreatestTimeStep。最多 64 条,超出按时间截断。 + // GreatestTimeValues requests local greatest-eclipse time isolines as absolute instants; their + // Location does not affect the computation. When empty, GreatestTimeStep is used instead. At + // most 64 are kept, truncated in time order. + GreatestTimeValues []time.Time + // GreatestTimeStep 是等时线间隔;仅在 GreatestTimeValues 为空时生效,非正值不绘制。 + // 取值对齐到 UTC 整刻度(显示层要按展示时区对齐时请自行生成时刻并传给 GreatestTimeValues)。 + // GreatestTimeStep is the isochrone interval; it applies only when GreatestTimeValues is empty, + // and non-positive values disable the isolines. Levels align to UTC ticks; display layers that + // need the viewing timezone grid should generate the instants themselves. + GreatestTimeStep time.Duration } // SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。 @@ -83,6 +124,15 @@ type SolarEclipsePartialFootprint struct { // Closed 表示 Boundaries 是否构成一个闭合边界。 // Closed indicates whether Boundaries form one closed boundary. Closed bool + // HorizonEnds 是未闭合边界两端延伸到地平圈的擦地点,顺序与 Boundaries 的走向一致; + // 边界自身闭合时为空。被地平线切断的瞬时阴影区域由「物理边界 + 两个擦地点之间的 + // 地平弧」闭合,擦地点的太阳高度为 0,因此闭合弧是精确结果而非启发式。 + // HorizonEnds are the two limb-grazing points where an open boundary reaches the + // horizon, ordered like Boundaries; empty when the boundary closes on itself. A + // region cut by the horizon is closed by the physical boundary plus the horizon + // arc between these grazing points, whose solar altitude is zero, so the closing + // arc is exact rather than a heuristic. + HorizonEnds []SolarEclipsePathPoint } // SolarEclipsePartialFootprintsInfo 表示一次日食的偏食半影足迹序列。 @@ -95,6 +145,31 @@ type SolarEclipsePartialFootprintsInfo struct { // CentralShadowFootprints 是按时间采样的本影/反本影足迹。 // CentralShadowFootprints are sampled umbral/antumbral footprints. CentralShadowFootprints []SolarEclipsePartialFootprint + // CentralBandFootprints 是始终计算的低成本本影/反本影端部样本,用于闭合中心食带。 + // CentralBandFootprints are always-computed lightweight umbral/antumbral end samples used to close the central band. + CentralBandFootprints []SolarEclipsePartialFootprint + // CentralBandSegments 是地方中心食条件与日出/日落食甚边界组成的连续闭合包络。 + // CentralBandSegments are continuous closed envelopes bounded by local centrality and greatest-at-horizon conditions. + CentralBandSegments [][]SolarEclipsePathPoint + // CentralBandSampled 表示上面的包络是用采样瞬时足迹重建的(解析包络不适用)。 + // CentralBandSampled reports that the envelope was reconstructed from the + // sampled instantaneous footprints because no analytic envelope applied. + CentralBandSampled bool + // CentralBandHorizonClosures 是两限界掠地事件中分别连接首尾两侧限界的食甚地平线弧。 + // CentralBandHorizonClosures are the greatest-at-horizon arcs joining both ends of a grazing two-limit event. + CentralBandHorizonClosures [][]SolarEclipsePathPoint + // PartialBandContours 是地方最大食分等于零的连续可见包络,用于闭合偏食可见域。 + // PartialBandContours are the continuous zero local-maximum-magnitude envelopes used to close the partial-eclipse visibility region. + PartialBandContours [][]SolarEclipsePathPoint + // MagnitudeContours 是按食分值采样的两侧等值线。 + // MagnitudeContours are sampled two-sided local maximum-magnitude contours. + MagnitudeContours []SolarEclipseMagnitudeContour + // GreatestTimeContours 是按食甚时刻采样的等时线。 + // GreatestTimeContours are sampled local greatest-eclipse time isolines. + GreatestTimeContours []SolarEclipseGreatestTimeContour + // RiseSetCurves 是初亏、食甚和复圆分别发生在日出或日落时的六类边界。 + // RiseSetCurves are the six boundaries where local start, greatest, or end occurs at sunrise or sunset. + RiseSetCurves []SolarEclipseRiseSetCurve // P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。 // P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero. P1 SolarEclipsePathPoint @@ -115,6 +190,73 @@ type SolarEclipsePartialFootprintsInfo struct { // CentralShadowStep 是本影/反本影足迹的实际采样步长;0 表示未计算。 // CentralShadowStep is the effective umbral/antumbral footprint step; zero means disabled. CentralShadowStep time.Duration + // CentralBandStep 是中心食带足迹的最细实际采样步长。 + // CentralBandStep is the finest effective sampling step for central-band footprints. + CentralBandStep time.Duration +} + +// SolarEclipseGreatestTimeContour 是一个固定地方食甚时刻的等值线支路集合。 +// SolarEclipseGreatestTimeContour contains the continuous branches of one fixed local greatest-eclipse time. +type SolarEclipseGreatestTimeContour struct { + // JDE 是该等值线对应的力学时儒略日,也就是支路上地方食甚发生的时刻。 + // JDE is the TT Julian ephemeris day represented by this contour, the local greatest-eclipse instant along every branch. + JDE float64 + // Time 是该等值线表示的地方食甚时刻,保持用户输入时区;按步长请求时它是原始对齐时刻, + // 避免 JDE 往返把整分取值截断成前一分钟。 + // Time is the local greatest-eclipse instant represented by this contour, in the input timezone; + // for step-derived levels it is the original aligned instant, so a JDE round trip cannot truncate + // a whole-minute level into the previous minute. + Time time.Time + // Segments 是该时刻的连续等时线支路;一条支路两端止于地平线(几何地平,无蒙气差修正)或偏食可见域边界, + // 纬度 ±88° 以上不再延拓,同一时刻可能有多条不相连的支路。 + // Segments are continuous isochrone branches; each branch ends at the horizon or the partial-visibility boundary. + Segments [][]SolarEclipsePathPoint +} + +// SolarEclipseMagnitudeContour 是一条地方最大食分等值线的连续支路集合。 +// SolarEclipseMagnitudeContour contains the continuous branches of one local maximum-magnitude contour. +type SolarEclipseMagnitudeContour struct { + // Magnitude 是该等值线表示的地方最大食分。 + // Magnitude is the local maximum eclipse magnitude represented by this contour. + Magnitude float64 + // Segments 是等食分线的连续支路;临近地平线时局部食甚时刻可以沿空间支路折返。 + // Segments are continuous contour branches; local greatest times may fold along a spatial branch near the horizon. + Segments [][]SolarEclipsePathPoint + // NorthernLimit 和 SouthernLimit 保留两侧中心食等值线的兼容视图。 + // NorthernLimit and SouthernLimit retain the compatibility view for two-sided central-eclipse contours. + NorthernLimit []SolarEclipsePathPoint + SouthernLimit []SolarEclipsePathPoint +} + +// RiseSetPhase 标识局部日食阶段。 +// RiseSetPhase identifies a local eclipse phase. +type RiseSetPhase = basic.RiseSetPhase + +// RiseSetDirection 标识太阳正在升起还是落下。 +// RiseSetDirection identifies sunrise or sunset. +type RiseSetDirection = basic.RiseSetDirection + +// 与 basic 同名的阶段与方向常量 / the phase and direction constants re-exported from basic. +const ( + RiseSetPhaseStart = basic.RiseSetPhaseStart + RiseSetPhaseGreatest = basic.RiseSetPhaseGreatest + RiseSetPhaseEnd = basic.RiseSetPhaseEnd + RiseSetDirectionRise = basic.RiseSetDirectionRise + RiseSetDirectionSet = basic.RiseSetDirectionSet +) + +// SolarEclipseRiseSetCurve 是一种局部阶段与日出/日落同时发生的边界。 +// SolarEclipseRiseSetCurve is one boundary where a local phase coincides with sunrise or sunset. +type SolarEclipseRiseSetCurve struct { + // Phase 是与日出或日落同时发生的局部日食阶段。 + // Phase is the local eclipse phase coinciding with sunrise or sunset. + Phase RiseSetPhase + // Direction 标识太阳正在升起还是落下。 + // Direction identifies whether the Sun is rising or setting. + Direction RiseSetDirection + // Segments 是反经线和支路跳变安全分段后的边界采样。 + // Segments are boundary samples split safely at the antimeridian and branch changes. + Segments [][]SolarEclipsePathPoint } // SolarEclipsePartialAreaInfo 是 SolarEclipsePartialFootprintsInfo 的兼容别名。 @@ -190,50 +332,150 @@ func solarEclipseCentralPath( } path := SolarEclipsePath{ - Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location), - Greatest: solarEclipsePathPointFromBasic(result.Greatest, location), - CenterLine: solarEclipsePathPointsFromBasic(result.CenterLine, location), - NorthernLimit: solarEclipsePathPointsFromBasic(result.NorthernLimit, location), - SouthernLimit: solarEclipsePathPointsFromBasic(result.SouthernLimit, location), - Step: solarEclipsePathStepDuration(result.StepDays), - TargetSpacingKM: result.TargetSpacingKM, + Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location), + Greatest: solarEclipsePathPointFromBasic(result.Greatest, location), + CenterLine: solarEclipsePathPointsFromBasic(result.CenterLine, location), + NorthernLimit: solarEclipsePathPointsFromBasic(result.NorthernLimit, location), + SouthernLimit: solarEclipsePathPointsFromBasic(result.SouthernLimit, location), + MaxCentralDuration: solarEclipseDurationFromDays(result.MaxCentralDurationDays), + MaxCentralDurationLongitude: result.MaxCentralDurationLongitude, + MaxCentralDurationLatitude: result.MaxCentralDurationLatitude, + CentralBandSegments: solarEclipsePathSegmentsFromBasic(result.CentralBandSegments, location), + CentralBandSampled: result.CentralBandSampled, + Step: solarEclipsePathStepDuration(result.StepDays), + TargetSpacingKM: result.TargetSpacingKM, } return path, true } +type solarEclipseGreatestTimeLevel struct { + jde float64 + at time.Time +} + +func solarEclipseGreatestTimeLevels(values []time.Time) []solarEclipseGreatestTimeLevel { + levels := make([]solarEclipseGreatestTimeLevel, 0, len(values)) + for _, value := range values { + if value.IsZero() { + continue + } + levels = append(levels, solarEclipseGreatestTimeLevel{jde: solarEclipseTimeToTTJDE(value), at: value}) + } + return levels +} + +func solarEclipseGreatestTimeLevelJDEs(levels []solarEclipseGreatestTimeLevel) []float64 { + if len(levels) == 0 { + return nil + } + values := make([]float64, len(levels)) + for index, level := range levels { + values[index] = level.jde + } + return values +} + +func solarEclipseGreatestTimeContoursFromBasic( + contours []basic.SolarEclipseGreatestTimeContour, + levels []solarEclipseGreatestTimeLevel, + location *time.Location, +) []SolarEclipseGreatestTimeContour { + if len(contours) == 0 { + return nil + } + result := make([]SolarEclipseGreatestTimeContour, len(contours)) + for index, contour := range contours { + // 核心原样回显请求的时刻取值,优先还原调用方给的时刻:JDE 往返只有微秒级误差, + // 但整分取值会因此落到 59.999 秒,按分钟格式化时被截断成前一分钟。 + value := time.Time{} + for _, level := range levels { + if level.jde == contour.JDE { + value = level.at + break + } + } + if value.IsZero() { + value = solarEclipseTTJDEToTime(contour.JDE, location) + // 按步长请求时核心只回显 TT 儒略日;对齐刻度的往返误差在微秒级,抹到毫秒 + // 才能保证 13:00:00 不会被格式化成 12:59。 + value = value.Round(time.Millisecond) + } + result[index] = SolarEclipseGreatestTimeContour{ + JDE: contour.JDE, + Time: value, + Segments: solarEclipsePathSegmentsFromBasic(contour.Segments, location), + } + } + return result +} + func solarEclipsePartialFootprints( date time.Time, options SolarEclipsePartialFootprintOptions, calculator solarEclipsePartialFootprintsCalculator, ) (SolarEclipsePartialFootprintsInfo, bool) { location := date.Location() - result := calculator(solarEclipseTimeToTTJDE(date), basicSolarEclipsePartialFootprintOptions(options)) + basicOptions := basicSolarEclipsePartialFootprintOptions(options) + greatestTimeLevels := solarEclipseGreatestTimeLevels(options.GreatestTimeValues) + basicOptions.GreatestTimeValues = solarEclipseGreatestTimeLevelJDEs(greatestTimeLevels) + basicOptions.GreatestTimeStep = options.GreatestTimeStep + result := calculator(solarEclipseTimeToTTJDE(date), basicOptions) if !result.Eclipse.HasPartial || len(result.Footprints) == 0 { return SolarEclipsePartialFootprintsInfo{}, false } footprints := SolarEclipsePartialFootprintsInfo{ - Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location), - Footprints: solarEclipsePartialFootprintsFromBasic(result.Footprints, location), - CentralShadowFootprints: solarEclipsePartialFootprintsFromBasic(result.CentralShadowFootprints, location), - P1: solarEclipseOptionalPathPointFromBasic(result.P1, location), - P2: solarEclipseOptionalPathPointFromBasic(result.P2, location), - P3: solarEclipseOptionalPathPointFromBasic(result.P3, location), - P4: solarEclipseOptionalPathPointFromBasic(result.P4, location), - U1: solarEclipseOptionalPathPointFromBasic(result.U1, location), - U2: solarEclipseOptionalPathPointFromBasic(result.U2, location), - U3: solarEclipseOptionalPathPointFromBasic(result.U3, location), - U4: solarEclipseOptionalPathPointFromBasic(result.U4, location), - Step: solarEclipsePathStepDuration(result.StepDays), - BoundaryPoints: result.BoundaryPoints, - CentralShadowStep: solarEclipsePathStepDuration(result.CentralShadowStepDays), + Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location), + Footprints: solarEclipsePartialFootprintsFromBasic(result.Footprints, location), + CentralShadowFootprints: solarEclipsePartialFootprintsFromBasic(result.CentralShadowFootprints, location), + CentralBandFootprints: solarEclipsePartialFootprintsFromBasic(result.CentralBandFootprints, location), + CentralBandSegments: solarEclipsePathSegmentsFromBasic(result.CentralBandSegments, location), + CentralBandSampled: result.CentralBandSampled, + CentralBandHorizonClosures: solarEclipsePathSegmentsFromBasic(result.CentralBandHorizonClosures, location), + PartialBandContours: solarEclipsePathSegmentsFromBasic(result.PartialBandContours, location), + MagnitudeContours: solarEclipseMagnitudeContoursFromBasic(result.MagnitudeContours, location), + GreatestTimeContours: solarEclipseGreatestTimeContoursFromBasic(result.GreatestTimeContours, greatestTimeLevels, location), + RiseSetCurves: solarEclipseRiseSetCurvesFromBasic(result.RiseSetCurves, location), + P1: solarEclipseOptionalPathPointFromBasic(result.P1, location), + P2: solarEclipseOptionalPathPointFromBasic(result.P2, location), + P3: solarEclipseOptionalPathPointFromBasic(result.P3, location), + P4: solarEclipseOptionalPathPointFromBasic(result.P4, location), + U1: solarEclipseOptionalPathPointFromBasic(result.U1, location), + U2: solarEclipseOptionalPathPointFromBasic(result.U2, location), + U3: solarEclipseOptionalPathPointFromBasic(result.U3, location), + U4: solarEclipseOptionalPathPointFromBasic(result.U4, location), + Step: solarEclipsePathStepDuration(result.StepDays), + BoundaryPoints: result.BoundaryPoints, + CentralShadowStep: solarEclipsePathStepDuration(result.CentralShadowStepDays), + CentralBandStep: solarEclipsePathStepDuration(result.CentralBandStepDays), } return footprints, true } +func solarEclipseRiseSetCurvesFromBasic( + curves []basic.SolarEclipseRiseSetCurve, + location *time.Location, +) []SolarEclipseRiseSetCurve { + if len(curves) == 0 { + return nil + } + result := make([]SolarEclipseRiseSetCurve, len(curves)) + for index, curve := range curves { + segments := make([][]SolarEclipsePathPoint, len(curve.Segments)) + for segmentIndex, segment := range curve.Segments { + segments[segmentIndex] = solarEclipsePathPointsFromBasic(segment, location) + } + result[index] = SolarEclipseRiseSetCurve{ + Phase: curve.Phase, Direction: curve.Direction, Segments: segments, + } + } + return result +} + func basicSolarEclipsePathOptions(options SolarEclipsePathOptions) basic.SolarEclipsePathOptions { basicOptions := basic.SolarEclipsePathOptions{ TargetSpacingKM: options.TargetSpacingKM, + SkipCentralBand: options.SkipCentralBand, } if options.Step > 0 { basicOptions.StepDays = options.Step.Hours() / 24 @@ -243,7 +485,9 @@ func basicSolarEclipsePathOptions(options SolarEclipsePathOptions) basic.SolarEc func basicSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintOptions { basicOptions := basic.SolarEclipsePartialFootprintOptions{ - BoundaryPoints: options.BoundaryPoints, + BoundaryPoints: options.BoundaryPoints, + MagnitudeValues: append([]float64(nil), options.MagnitudeValues...), + DisableRiseSetCurves: options.DisableRiseSet, } if options.Step > 0 { basicOptions.StepDays = options.Step.Hours() / 24 @@ -251,9 +495,43 @@ func basicSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootpri if options.CentralShadowStep > 0 { basicOptions.CentralShadowStepDays = options.CentralShadowStep.Hours() / 24 } + if options.RiseSetStep > 0 { + basicOptions.RiseSetStepDays = options.RiseSetStep.Hours() / 24 + } return basicOptions } +func solarEclipseMagnitudeContoursFromBasic( + contours []basic.SolarEclipseMagnitudeContour, + location *time.Location, +) []SolarEclipseMagnitudeContour { + if len(contours) == 0 { + return nil + } + result := make([]SolarEclipseMagnitudeContour, len(contours)) + for index, contour := range contours { + segments := make([][]SolarEclipsePathPoint, len(contour.Segments)) + for segmentIndex, segment := range contour.Segments { + segments[segmentIndex] = solarEclipsePathPointsFromBasic(segment, location) + } + result[index] = SolarEclipseMagnitudeContour{ + Magnitude: contour.Magnitude, + Segments: segments, + NorthernLimit: solarEclipsePathPointsFromBasic(contour.NorthernLimit, location), + SouthernLimit: solarEclipsePathPointsFromBasic(contour.SouthernLimit, location), + } + } + return result +} + +// solarEclipseDurationFromDays converts a JDE-day span into a duration. +func solarEclipseDurationFromDays(days float64) time.Duration { + if days <= 0 || math.IsNaN(days) || math.IsInf(days, 0) { + return 0 + } + return time.Duration(math.Round(days * 86400 * float64(time.Second))) +} + func solarEclipsePathStepDuration(stepDays float64) time.Duration { return time.Duration(math.Round(stepDays * 24 * float64(time.Hour))) } @@ -269,6 +547,20 @@ func solarEclipsePathPointsFromBasic(points []basic.SolarEclipsePathPoint, locat return result } +func solarEclipsePathSegmentsFromBasic( + segments [][]basic.SolarEclipsePathPoint, + location *time.Location, +) [][]SolarEclipsePathPoint { + if len(segments) == 0 { + return nil + } + result := make([][]SolarEclipsePathPoint, len(segments)) + for index, segment := range segments { + result[index] = solarEclipsePathPointsFromBasic(segment, location) + } + return result +} + func solarEclipsePathPointFromBasic(point basic.SolarEclipsePathPoint, location *time.Location) SolarEclipsePathPoint { return SolarEclipsePathPoint{ Time: solarEclipseTTJDEToTime(point.JDE, location), @@ -299,6 +591,9 @@ func solarEclipsePartialFootprintsFromBasic( Time: solarEclipseTTJDEToTime(footprint.JDE, location), Boundaries: solarEclipsePartialBoundariesFromBasic(footprint.Boundaries, location), Closed: footprint.Closed, + HorizonEnds: solarEclipsePathPointsFromBasic( + footprint.HorizonEnds, location, + ), } } return result diff --git a/eclipse/solar_path_isochrone_test.go b/eclipse/solar_path_isochrone_test.go new file mode 100644 index 0000000..dd423fb --- /dev/null +++ b/eclipse/solar_path_isochrone_test.go @@ -0,0 +1,77 @@ +package eclipse + +import ( + "testing" + "time" +) + +// 请求的电平必须原样返回:JDE 往返只有微秒级误差,但整分电平会落到 59.999 秒。 +func TestSolarEclipsePartialFootprintsReturnsRequestedGreatestTimes(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + levels := []time.Time{ + time.Date(2009, time.July, 22, 1, 0, 0, 0, time.UTC), + time.Date(2009, time.July, 22, 2, 0, 0, 0, time.UTC), + time.Date(2009, time.July, 22, 3, 0, 0, 0, time.UTC), + } + info, ok := SolarEclipsePartialFootprintsNASABulletinSplitK(date, SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 24, DisableRiseSet: true, + GreatestTimeValues: levels, + }) + if !ok { + t.Fatal("missing footprints") + } + if len(info.GreatestTimeContours) == 0 { + t.Fatal("no greatest-time contours") + } + for _, contour := range info.GreatestTimeContours { + matched := false + for _, level := range levels { + if contour.Time.Equal(level) { + matched = true + break + } + } + if !matched { + t.Fatalf("contour time %s is not one of the requested levels", + contour.Time.Format("15:04:05.000")) + } + for _, segment := range contour.Segments { + if len(segment) < 2 { + t.Fatalf("contour %s has a degenerate branch", contour.Time.Format("15:04")) + } + } + } +} + +// 按步长请求等时线:时刻按输入时区对齐到整刻度、落在偏食窗口内,并回填 JDE。 +func TestSolarEclipsePartialFootprintsGreatestTimeStep(t *testing.T) { + location := time.FixedZone("UTC+8", 8*3600) + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, location) + info, ok := SolarEclipsePartialFootprintsNASABulletinSplitK(date, SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 24, DisableRiseSet: true, + GreatestTimeStep: 30 * time.Minute, + }) + if !ok { + t.Fatal("missing footprints") + } + if len(info.GreatestTimeContours) < 2 { + t.Fatalf("step request produced %d contours", len(info.GreatestTimeContours)) + } + for _, contour := range info.GreatestTimeContours { + if contour.JDE == 0 { + t.Fatal("contour JDE is not populated") + } + if contour.Time.Nanosecond() != 0 || contour.Time.Second() != 0 || contour.Time.Minute()%30 != 0 { + t.Fatalf("contour time %s is not aligned to the requested step", + contour.Time.Format("15:04:05.000000000")) + } + if contour.Time.Before(info.Eclipse.PartialBeginOnEarth) || contour.Time.After(info.Eclipse.PartialEndOnEarth) { + t.Fatalf("contour %s is outside the partial-eclipse window", contour.Time.Format("15:04")) + } + for _, segment := range contour.Segments { + if len(segment) < 2 { + t.Fatalf("contour %s has a degenerate branch", contour.Time.Format("15:04")) + } + } + } +} diff --git a/eclipse/solar_path_test.go b/eclipse/solar_path_test.go index c0708f1..28eea57 100644 --- a/eclipse/solar_path_test.go +++ b/eclipse/solar_path_test.go @@ -109,6 +109,24 @@ func TestSolarEclipsePartialFootprintsKeepLocation(t *testing.T) { } } +func TestSolarEclipsePartialFootprintsExposePartialBandContours(t *testing.T) { + location := time.FixedZone("UTC+08", 8*3600) + result, ok := SolarEclipsePartialFootprints( + time.Date(2009, time.July, 22, 9, 0, 0, 0, location), + SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 24}, + ) + if !ok || len(result.PartialBandContours) == 0 { + t.Fatalf("partial-band contours=%d ok=%v", len(result.PartialBandContours), ok) + } + for segmentIndex, segment := range result.PartialBandContours { + for pointIndex, point := range segment { + if point.Time.Location() != location { + t.Fatalf("segment %d point %d location=%v, want input location", segmentIndex, pointIndex, point.Time.Location()) + } + } + } +} + func TestSolarEclipsePartialFootprintsWorkForPartialOnly(t *testing.T) { footprints, ok := SolarEclipsePartialFootprints( time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC), @@ -148,3 +166,66 @@ func TestSolarEclipsePartialAreaCompatibilityWrapper(t *testing.T) { t.Fatalf("compat footprint count mismatch: got %d want %d", len(compat.Footprints), len(primary.Footprints)) } } + +func TestSolarEclipsePartialFootprints20140429NonCentralContactsMatchNASA(t *testing.T) { + partial, ok := SolarEclipsePartialFootprints( + time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), + SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 96}, + ) + if !ok || partial.Eclipse.Centrality != SolarEclipseNonCentral { + t.Fatal("expected the 2014-04-29 non-central annular eclipse") + } + for name, sample := range map[string]struct { + got time.Time + want time.Time + }{ + "U1": {partial.U1.Time, time.Date(2014, time.April, 29, 5, 57, 38, 0, time.UTC)}, + "U4": {partial.U4.Time, time.Date(2014, time.April, 29, 6, 9, 34, 0, time.UTC)}, + } { + if difference := sample.got.Sub(sample.want); difference < -5*time.Second || difference > 5*time.Second { + t.Fatalf("%s=%s, want %s within five seconds", name, sample.got, sample.want) + } + } + if !partial.U2.Time.IsZero() || !partial.U3.Time.IsZero() { + t.Fatalf("non-central eclipse has internal contacts U2=%s U3=%s", partial.U2.Time, partial.U3.Time) + } + if len(partial.CentralBandSegments) != 1 { + t.Fatalf("central-band regions=%d, want one continuous non-central band", len(partial.CentralBandSegments)) + } +} + +func TestSolarEclipsePartialFootprintsRetryTinyGrazingFootprints(t *testing.T) { + for _, date := range []time.Time{ + time.Date(1859, 2, 3, 0, 0, 0, 0, time.UTC), + time.Date(1935, 1, 5, 0, 0, 0, 0, time.UTC), + } { + partial, ok := SolarEclipsePartialFootprints(date, SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 24, CentralShadowStep: 10 * time.Minute, + DisableRiseSet: true, + }) + if !ok || len(partial.Footprints) == 0 { + t.Fatalf("%s tiny grazing partial eclipse unavailable at low boundary resolution", date.Format("2006-01-02")) + } + if partial.BoundaryPoints <= 24 { + t.Fatalf("%s did not report the finer fallback boundary resolution: %d", date.Format("2006-01-02"), partial.BoundaryPoints) + } + } +} + +func TestSolarEclipseCentralPathRecoversFromCoarseSampling(t *testing.T) { + path, ok := SolarEclipseCentralPath( + time.Date(2119, 3, 11, 0, 0, 0, 0, time.UTC), + SolarEclipsePathOptions{Step: time.Hour}, + ) + if !ok || len(path.CenterLine) < 2 { + t.Fatalf("coarse central path has %d points, want at least two", len(path.CenterLine)) + } + if path.Step >= time.Hour { + t.Fatalf("fallback central path retained coarse metadata step %s", path.Step) + } + for index := 1; index < len(path.CenterLine); index++ { + if !path.CenterLine[index].Time.After(path.CenterLine[index-1].Time) { + t.Fatalf("center-line times are not strictly increasing at %d", index) + } + } +} diff --git a/eclipse/solar_shadow.go b/eclipse/solar_shadow.go new file mode 100644 index 0000000..d4e621e --- /dev/null +++ b/eclipse/solar_shadow.go @@ -0,0 +1,311 @@ +package eclipse + +import ( + "math" + "time" + + "b612.me/astro/basic" +) + +// SolarEclipseShadowKind 阴影类型 / shadow kind. +type SolarEclipseShadowKind int + +const ( + // SolarEclipseShadowUmbra 本影与反本影 / umbra and antumbra. + SolarEclipseShadowUmbra SolarEclipseShadowKind = iota + // SolarEclipseShadowPenumbra 半影,即偏食区 / penumbra, the partial-eclipse region. + SolarEclipseShadowPenumbra +) + +// SolarEclipseShadowSolverOptions 单时刻阴影求解器配置 / single-instant shadow solver options. +type SolarEclipseShadowSolverOptions struct { + // Model 月亮半径模型,零值为 NASA bulletin Split-K / lunar radius model. + Model SolarEclipseRadiusModel + // DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型,只改变地球自转相位 / explicit ΔT in seconds. + DeltaTSeconds float64 + // BoundaryPoints 边界角向采样点数,<=0 用 96 / boundary sample count. + BoundaryPoints int + // Kind 本影或半影,零值为本影 / umbra or penumbra, zero is the umbra. + Kind SolarEclipseShadowKind + // TargetSpacingKM 边界加密目标间距(千米),0 用该类型的整包采样默认值,负值表示不加密 / boundary refinement spacing in km. + TargetSpacingKM float64 +} + +// SolarEclipseShadowTopology 瞬时足迹拓扑签名,用于插值门控 / footprint topology signature. +type SolarEclipseShadowTopology struct { + // Kind 阴影类型 / shadow kind. + Kind SolarEclipseShadowKind + // Vertices、Segments 与 Closed 描述边界形状 / boundary shape. + Vertices int + Segments int + Closed bool + // EnclosesPole 环绕过极点,经度已展开,不能按下标插值 / pole-winding ring. + EnclosesPole bool +} + +// Signature 稳定签名字符串,可直接作为能否插值的比较键 / stable interpolation key. +func (topology SolarEclipseShadowTopology) Signature() string { + return basic.SolarEclipseShadowTopology{ + Kind: basic.SolarEclipseShadowKind(topology.Kind), + Vertices: topology.Vertices, + Segments: topology.Segments, + Closed: topology.Closed, + EnclosesPole: topology.EnclosesPole, + }.Signature() +} + +// SolarEclipseShadowInstant 某瞬时的全球本影或半影足迹 / instantaneous shadow footprint. +type SolarEclipseShadowInstant struct { + // Time 记录对应的时刻:UTC 入口为调用方所给,TT 入口按句柄 ΔT 换算 / instant this record describes. + Time time.Time + // JDE 几何使用的力学时儒略日,ΔT 不改变它 / TT instant used by the geometry. + JDE float64 + // DeltaTSeconds 实际使用的 ΔT(秒),来自句柄显式值或进程级模型 / ΔT actually used. + DeltaTSeconds float64 + // Model 本次使用的月亮半径模型 / lunar radius model used. + Model SolarEclipseRadiusModel + // Kind 本次计算的阴影类型 / shadow kind of this computation. + Kind SolarEclipseShadowKind + // Closed 边界由阴影自身闭合,false 表示被地平线切断 / self-closed or horizon-cut. + Closed bool + // Boundaries 物理边界分段,HorizonEnds 是两端的地平擦地点 / segments and grazing points. + Boundaries [][]SolarEclipsePathPoint + HorizonEnds []SolarEclipsePathPoint + // Topology 插值判定用的拓扑签名 / interpolation signature. + Topology SolarEclipseShadowTopology +} + +// Empty 该时刻阴影未落在地球表面 / no footprint on the Earth. +func (instant SolarEclipseShadowInstant) Empty() bool { + return len(instant.Boundaries) == 0 +} + +// SolarEclipseStationState 某瞬时的站心日月几何 / topocentric geometry at one instant. +type SolarEclipseStationState struct { + Time time.Time + // JDE 本次计算使用的力学时儒略日 / TT instant used. + JDE float64 + // DeltaTSeconds 实际使用的 ΔT(秒) / ΔT actually used. + DeltaTSeconds float64 + // SeparationDeg 与 SeparationArcsec 是日月中心的站心角距 / topocentric separation. + SeparationDeg float64 + SeparationArcsec float64 + // 三个半径字段是日月站心视半径 / topocentric apparent radii. + SunRadiusDeg float64 + MoonOuterRadiusDeg float64 + MoonInnerRadiusDeg float64 + // SunAltitudeDeg 与 SunAzimuthDeg 是站心太阳高度角与方位角,方位角自北向东 / solar altitude and azimuth. + SunAltitudeDeg float64 + SunAzimuthDeg float64 + // Magnitude 瞬时食分,Obscuration 太阳视面积遮蔽率 / instantaneous magnitude and obscuration. + Magnitude float64 + Obscuration float64 + // 中心食标志:位于本影或反本影内,以及全食或环食 / central phase flags. + InCentralPhase bool + HasTotalPhase bool + HasAnnularPhase bool + // CentralPhaseType 中心食类型,非中心食为 SolarEclipseNone / central phase kind. + CentralPhaseType SolarEclipseType + // Visible 太阳中心在地平线上,海拔用俯仰角修正 / Sun center above the horizon. + Visible bool +} + +// SolarEclipseShadowSolver 可复用的单时刻求解器;非并发安全,宿主每条 lane 各持一个 / reusable solver, one per lane. +type SolarEclipseShadowSolver struct { + options SolarEclipseShadowSolverOptions + inner *basic.SolarEclipseShadowSolver +} + +// NewSolarEclipseShadowSolver 构造单时刻求解器 / builds a single-instant solver. +func NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *SolarEclipseShadowSolver { + return &SolarEclipseShadowSolver{ + options: options, + inner: basic.NewSolarEclipseShadowSolver(basic.SolarEclipseShadowSolverOptions{ + Model: basic.SolarEclipseRadiusModel(options.Model), + DeltaTSeconds: options.DeltaTSeconds, + BoundaryPoints: options.BoundaryPoints, + Kind: basic.SolarEclipseShadowKind(options.Kind), + TargetSpacingKM: options.TargetSpacingKM, + }), + } +} + +func (solver *SolarEclipseShadowSolver) ttJDE(value time.Time) float64 { + utJDE := basic.Date2JDE(value.UTC()) + if solver.options.DeltaTSeconds > 0 { + return utJDE + solver.options.DeltaTSeconds/86400 + } + return basic.TD2UT(utJDE, true) +} + +// ShadowAt 给定 UTC 时刻的阴影足迹;不在地球上时返回 (零值, false) / footprint at one UTC instant. +func (solver *SolarEclipseShadowSolver) ShadowAt(value time.Time) (SolarEclipseShadowInstant, bool) { + return solver.shadowAt(value, solver.ttJDE(value)) +} + +// ShadowAtJDE 以 TT 时刻取足迹,ΔT 只改变地面位置 / footprint at one TT instant. +func (solver *SolarEclipseShadowSolver) ShadowAtJDE(jdeTT float64) (SolarEclipseShadowInstant, bool) { + if !finiteEclipseValue(jdeTT) { + return SolarEclipseShadowInstant{}, false + } + return solver.shadowAt(time.Time{}, jdeTT) +} + +func (solver *SolarEclipseShadowSolver) shadowAt( + value time.Time, + jdeTT float64, +) (SolarEclipseShadowInstant, bool) { + instant, ok := solver.inner.ShadowAtJDE(jdeTT) + return solarEclipseShadowInstantFromBasic(value, instant), ok +} + +func solarEclipseShadowInstantFromBasic( + value time.Time, instant basic.SolarEclipseShadowInstant, +) SolarEclipseShadowInstant { + location := value.Location() + if value.IsZero() { + value = solarEclipseTTJDEToTimeWithDeltaT(instant.JDE, instant.DeltaTSeconds, time.UTC) + location = time.UTC + } + return SolarEclipseShadowInstant{ + Time: value, + JDE: instant.JDE, + DeltaTSeconds: instant.DeltaTSeconds, + Model: mapBasicSolarEclipseModel(instant.Model), + Kind: SolarEclipseShadowKind(instant.Kind), + Closed: instant.Closed, + Boundaries: solarEclipsePathSegmentsFromBasic(instant.Boundaries, location), + HorizonEnds: solarEclipsePathPointsFromBasic(instant.HorizonEnds, location), + Topology: SolarEclipseShadowTopology{ + Kind: SolarEclipseShadowKind(instant.Topology.Kind), + Vertices: instant.Topology.Vertices, + Segments: instant.Topology.Segments, + Closed: instant.Topology.Closed, + EnclosesPole: instant.Topology.EnclosesPole, + }, + } +} + +// StationStateAt 给定 UTC 时刻与站点的站心情形,任何时刻可调用 / topocentric state at one instant. +func (solver *SolarEclipseShadowSolver) StationStateAt( + value time.Time, lon, lat, height float64, +) SolarEclipseStationState { + return solver.stationState(value, solver.ttJDE(value), lon, lat, height) +} + +// StationStateAtJDE 以 TT 时刻取站心情形,ΔT 只影响地球自转相位 / topocentric state at one TT instant. +func (solver *SolarEclipseShadowSolver) StationStateAtJDE( + jdeTT, lon, lat, height float64, +) SolarEclipseStationState { + if !finiteEclipseValue(jdeTT) { + return SolarEclipseStationState{} + } + state := solver.inner.StationStateAtJDE(jdeTT, lon, lat, height) + result := solarEclipseStationStateFromBasic(time.Time{}, state) + result.Time = solarEclipseTTJDEToTimeWithDeltaT(jdeTT, state.DeltaTSeconds, time.UTC) + return result +} + +func (solver *SolarEclipseShadowSolver) stationState( + value time.Time, jdeTT, lon, lat, height float64, +) SolarEclipseStationState { + return solarEclipseStationStateFromBasic(value, solver.inner.StationStateAtJDE(jdeTT, lon, lat, height)) +} + +func solarEclipseStationStateFromBasic( + value time.Time, state basic.SolarEclipseStationState, +) SolarEclipseStationState { + return SolarEclipseStationState{ + Time: value, + JDE: state.JDE, + DeltaTSeconds: state.DeltaTSeconds, + SeparationDeg: state.SeparationDeg, + SeparationArcsec: state.SeparationArcsec, + SunRadiusDeg: state.SunRadiusDeg, + MoonOuterRadiusDeg: state.MoonOuterRadiusDeg, + MoonInnerRadiusDeg: state.MoonInnerRadiusDeg, + SunAltitudeDeg: state.SunAltitudeDeg, + SunAzimuthDeg: state.SunAzimuthDeg, + Magnitude: state.Magnitude, + Obscuration: state.Obscuration, + InCentralPhase: state.InCentralPhase, + HasTotalPhase: state.HasTotalPhase, + HasAnnularPhase: state.HasAnnularPhase, + CentralPhaseType: mapBasicSolarEclipseType(state.CentralPhaseType), + Visible: state.Visible, + } +} + +// SolarEclipseShadowAt 无状态便捷入口,可并发调用 / stateless convenience entry point. +func SolarEclipseShadowAt(value time.Time) (SolarEclipseShadowInstant, bool) { + return NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).ShadowAt(value) +} + +// SolarEclipseStationStateAt 无状态便捷入口,可并发调用 / stateless convenience entry point. +func SolarEclipseStationStateAt(value time.Time, lon, lat, height float64) SolarEclipseStationState { + return NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).StationStateAt(value, lon, lat, height) +} + +func finiteEclipseValue(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} + +// ShadowBetween 区间内等步长逐时刻的阴影足迹,超过内部上限时返回 nil / per-step footprints over a range. +func (solver *SolarEclipseShadowSolver) ShadowBetween( + start, end time.Time, step time.Duration, +) []SolarEclipseShadowInstant { + if step <= 0 || !end.After(start) { + return nil + } + startTT := solver.ttJDE(start) + endTT := solver.ttJDE(end) + stepDays := step.Hours() / 24 + if stepDays <= 0 { + return nil + } + inner := solver.inner.ShadowBetweenJDE(startTT, endTT, stepDays) + result := make([]SolarEclipseShadowInstant, 0, len(inner)) + // 逐条累加而非 index*step:Duration 是 int64 纳秒,长窗口下乘法会溢出。 + instantTime := start + for _, instant := range inner { + // 直接映射,避免为每个时刻重复解算一次足迹。 + result = append(result, solarEclipseShadowInstantFromBasic(instantTime, instant)) + instantTime = instantTime.Add(step) + } + return result +} + +// StationStatesBetween 区间内等步长逐时刻的站心情形 / per-step station states. +func (solver *SolarEclipseShadowSolver) StationStatesBetween( + start, end time.Time, step time.Duration, lon, lat, height float64, +) []SolarEclipseStationState { + if step <= 0 || !end.After(start) { + return nil + } + startTT := solver.ttJDE(start) + endTT := solver.ttJDE(end) + stepDays := step.Hours() / 24 + inner := solver.inner.StationStatesBetweenJDE(startTT, endTT, stepDays, lon, lat, height) + result := make([]SolarEclipseStationState, 0, len(inner)) + // 同上:累加避免长窗口下的纳秒溢出。 + stateTime := start + for _, state := range inner { + result = append(result, solarEclipseStationStateFromBasic(stateTime, state)) + stateTime = stateTime.Add(step) + } + return result +} + +// SolarEclipseShadowBetween 无状态批量入口,使用进程级 ΔT / stateless batch entry point. +func SolarEclipseShadowBetween( + start, end time.Time, step time.Duration, +) []SolarEclipseShadowInstant { + return SolarEclipseShadowBetweenWithOptions(start, end, step, SolarEclipseShadowSolverOptions{}) +} + +// SolarEclipseShadowBetweenWithOptions 可显式指定 ΔT 与模型 / with explicit ΔT and model. +func SolarEclipseShadowBetweenWithOptions( + start, end time.Time, step time.Duration, options SolarEclipseShadowSolverOptions, +) []SolarEclipseShadowInstant { + return NewSolarEclipseShadowSolver(options).ShadowBetween(start, end, step) +} diff --git a/eclipse/solar_shadow_test.go b/eclipse/solar_shadow_test.go new file mode 100644 index 0000000..ff4b119 --- /dev/null +++ b/eclipse/solar_shadow_test.go @@ -0,0 +1,208 @@ +package eclipse + +import ( + "math" + "testing" + "time" + + "b612.me/astro/basic" +) + +func TestSolarEclipseShadowSolverUTCAgreesWithTT(t *testing.T) { + value := time.Date(2009, time.July, 22, 4, 19, 23, 378387689, time.UTC) + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + fromUTC, okUTC := solver.ShadowAt(value) + if !okUTC { + t.Fatal("expected an umbral footprint at the test instant") + } + // 显式 ΔT 的 TT 入口必须与 UTC 入口给出同一份几何。 + explicit := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{DeltaTSeconds: fromUTC.DeltaTSeconds}) + fromTT, okTT := explicit.ShadowAtJDE(fromUTC.JDE) + if !okTT { + t.Fatal("TT entry reported no umbra") + } + if len(fromTT.Boundaries) != len(fromUTC.Boundaries) { + t.Fatalf("segments differ: TT=%d UTC=%d", len(fromTT.Boundaries), len(fromUTC.Boundaries)) + } + for index := range fromUTC.Boundaries { + if len(fromTT.Boundaries[index]) != len(fromUTC.Boundaries[index]) { + t.Fatalf("segment %d length differs", index) + } + for pointIndex := range fromUTC.Boundaries[index] { + a, b := fromTT.Boundaries[index][pointIndex], fromUTC.Boundaries[index][pointIndex] + if a.Longitude != b.Longitude || a.Latitude != b.Latitude { + t.Fatalf("point %d/%d differs: %.12f,%.12f vs %.12f,%.12f", + index, pointIndex, a.Longitude, a.Latitude, b.Longitude, b.Latitude) + } + } + } + if fromTT.Topology.Signature() != fromUTC.Topology.Signature() { + t.Fatalf("signature differs: %q vs %q", fromTT.Topology.Signature(), fromUTC.Topology.Signature()) + } + if math.Abs(fromTT.DeltaTSeconds-fromUTC.DeltaTSeconds) > 1e-9 { + t.Fatalf("ΔT differs: %.9f vs %.9f", fromTT.DeltaTSeconds, fromUTC.DeltaTSeconds) + } + if fromTT.Time.IsZero() { + t.Fatal("TT entry must still report a usable instant instead of the zero time") + } + state := explicit.StationStateAtJDE(fromUTC.JDE, -96.8, 32.8, 0) + if state.Time.IsZero() { + t.Fatal("TT station entry must report a usable instant") + } +} + +func TestSolarEclipseShadowBetweenAlignsWithTimeline(t *testing.T) { + // 覆盖 2009-07-22 的 U1..U4(本影在地球上的阶段)。 + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + start := time.Date(2009, time.July, 22, 0, 50, 0, 0, time.UTC) + end := time.Date(2009, time.July, 22, 4, 25, 0, 0, time.UTC) + // 2 分钟步长才能踩到被地平线切断的那几分钟(该阶段只有约 3 分钟宽)。 + step := 2 * time.Minute + instants := solver.ShadowBetween(start, end, step) + want := int(end.Sub(start)/step) + 1 + if len(instants) != want { + t.Fatalf("batch length=%d, want %d", len(instants), want) + } + empty, populated, closedSeen, horizonSeen := 0, 0, 0, 0 + for index, instant := range instants { + if instant.Empty() { + empty++ + continue + } + populated++ + if instant.Time.Before(start) { + t.Fatalf("entry %d time %v before start", index, instant.Time) + } + if instant.Closed { + closedSeen++ + } else { + horizonSeen++ + if len(instant.HorizonEnds) != 2 { + t.Fatalf("entry %d is horizon-cut but has %d grazing points", index, len(instant.HorizonEnds)) + } + } + } + if empty == 0 || populated == 0 { + t.Fatalf("batch should contain both empty and populated entries, got empty=%d populated=%d", empty, populated) + } + if closedSeen == 0 || horizonSeen == 0 { + t.Fatalf("batch should contain both topology kinds, got closed=%d horizon=%d", closedSeen, horizonSeen) + } +} + +func TestSolarEclipseShadowBatchMatchesSingleCalls(t *testing.T) { + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + start := time.Date(2009, time.July, 22, 1, 0, 0, 0, time.UTC) + step := 20 * time.Minute + instants := solver.ShadowBetween(start, start.Add(2*time.Hour), step) + if len(instants) == 0 { + t.Fatal("empty batch") + } + for index, batch := range instants { + single, _ := solver.ShadowAt(start.Add(time.Duration(index) * step)) + if single.Empty() != batch.Empty() { + t.Fatalf("entry %d emptiness differs", index) + } + if single.Empty() { + continue + } + if single.Topology.Signature() != batch.Topology.Signature() { + t.Fatalf("entry %d signature %q vs %q", index, batch.Topology.Signature(), single.Topology.Signature()) + } + } +} + +func TestSolarEclipseStationStateExplicitDeltaTMovesStation(t *testing.T) { + value := time.Date(2024, time.April, 8, 18, 42, 0, 0, time.UTC) + base := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + shifted := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{DeltaTSeconds: base.stationDeltaT(value) + 100}) + first := base.StationStateAt(value, -96.8, 32.8, 0) + second := shifted.StationStateAt(value, -96.8, 32.8, 0) + if math.Abs(first.JDE-second.JDE) > 100.0/86400 { + t.Fatalf("the TT instant must stay within one ΔT step, got %.9f vs %.9f", first.JDE, second.JDE) + } + if math.Abs(second.DeltaTSeconds-first.DeltaTSeconds-100) > 1e-9 { + t.Fatalf("ΔT override not applied: %.6f vs %.6f", first.DeltaTSeconds, second.DeltaTSeconds) + } + // 站心角距对地面横移的响应量级:100 s ≈ 39 km ≈ 21 角秒;精确的 + // "位移 = DeltaTGroundShiftKM"由 basic 层的足迹测试覆盖,这里只验证量级与换算系数。 + if math.Abs(second.SeparationArcsec-first.SeparationArcsec) < 5 { + t.Fatalf("explicit ΔT did not move the station geometry: %.3f vs %.3f arcsec", + first.SeparationArcsec, second.SeparationArcsec) + } + if ground := basic.DeltaTGroundShiftKM(100, 32.8); ground < 38 || ground > 40 { + t.Fatalf("DeltaTGroundShiftKM(100 s, 32.8°) = %.2f km, want about 39 km", ground) + } +} + +func (solver *SolarEclipseShadowSolver) stationDeltaT(value time.Time) float64 { + state := solver.StationStateAt(value, 0, 0, 0) + return state.DeltaTSeconds +} + +func TestSolarEclipseShadowTTEntryUsesScopedDeltaTForItsTime(t *testing.T) { + // 显式 ΔT 下:TT 入口回填的 UT 必须是 TT − 句柄 ΔT,闭合弧与 closure 取自同一时刻。 + override := 3666.18 + value := time.Date(2009, time.July, 22, 0, 53, 0, 0, time.UTC) + model := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + jde := model.ttJDE(value) + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{DeltaTSeconds: override}) + instant, ok := solver.ShadowAtJDE(jde) + if !ok { + t.Fatal("expected a footprint at the shifted instant") + } + want := solarEclipseTTJDEToTimeWithDeltaT(jde, override, time.UTC) + if !instant.Time.Equal(want) { + t.Fatalf("TT entry time=%v, want %v (TT − %.2f s)", instant.Time.UTC(), want.UTC(), override) + } + if instant.DeltaTSeconds != override { + t.Fatalf("reported ΔT=%.6f, want %.2f", instant.DeltaTSeconds, override) + } +} + +func TestSolarEclipseShadowBetweenSurvivesLongWindows(t *testing.T) { + // time.Duration 是 int64 纳秒:start.Add(time.Duration(index)*step) 在跨度超过 + // 约 292 年时溢出,Time 会回绕倒退。30 天步长 × 300 年只有 3651 条,刚好越过 + // 292 年的边界,用很小的代价钉住这个回归。 + // time.Duration is int64 nanoseconds: start.Add(time.Duration(index)*step) overflows + // past ~292 years and Time wraps backwards. A 30-day step over 300 years is only + // 3651 entries yet crosses that boundary, so it pins the regression cheaply. + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + start := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(300, 0, 0) + step := 30 * 24 * time.Hour + instants := solver.ShadowBetween(start, end, step) + // time.Time.Sub also saturates at the ~292-year duration limit, so the expected count + // comes from Unix seconds (int64 seconds has no such limit here). + want := int((end.Unix()-start.Unix())/int64(step.Seconds())) + 1 + if len(instants) != want { + t.Fatalf("batch length=%d, want %d", len(instants), want) + } + previous := start.Add(-time.Second) + for index, instant := range instants { + if !instant.Time.After(previous) { + t.Fatalf("entry %d time %v is not after %v (int64 nanosecond wrap)", + index, instant.Time, previous) + } + if index == 0 && !instant.Time.Equal(start) { + t.Fatalf("entry 0 time %v, want %v", instant.Time, start) + } + previous = instant.Time + } + if !previous.Before(end.Add(step)) { + t.Fatalf("last entry %v overshoots the window", previous) + } +} + +func TestSolarEclipseStationStateAtJDERejectsNonFiniteTT(t *testing.T) { + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + for _, jdeTT := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + state := solver.StationStateAtJDE(jdeTT, 116.404, 39.915, 0) + if state != (SolarEclipseStationState{}) { + t.Fatalf("StationStateAtJDE(%v) = %+v, want the zero state", jdeTT, state) + } + } + if state := solver.StationStateAtJDE(2460310.5, 116.404, 39.915, 0); state.JDE != 2460310.5 || state.Time.IsZero() { + t.Fatalf("finite TT returned %+v", state) + } +} diff --git a/eclipse/stateless_exports_contract_test.go b/eclipse/stateless_exports_contract_test.go new file mode 100644 index 0000000..492b84b --- /dev/null +++ b/eclipse/stateless_exports_contract_test.go @@ -0,0 +1,217 @@ +package eclipse + +import ( + "math" + "testing" + "time" + + "b612.me/astro/basic" +) + +// 地心面板、站心搜索枚举与无状态批量入口的契约:模型标记、计数语义与边界参数。 + +func TestSolarEclipseGeocentricPanelModelsContract(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + singleK, ok := SolarEclipseGeocentricPanelIAUSingleK(date) + if !ok { + t.Fatal("IAU Single-K panel missing for 2009-07-22") + } + if !singleK.SingleK || singleK.Ephemeris != "IAU Single-K" { + t.Fatalf("IAU panel SingleK=%v ephemeris=%q", singleK.SingleK, singleK.Ephemeris) + } + if singleK.PenumbralK != singleK.UmbralK || singleK.PenumbralK != basic.SolarEclipsePenumbralK { + t.Fatalf("IAU panel k1=%.7f k2=%.7f, want the single k %.7f", + singleK.PenumbralK, singleK.UmbralK, basic.SolarEclipsePenumbralK) + } + splitK, ok := SolarEclipseGeocentricPanelAt(date) + if !ok { + t.Fatal("Split-K panel missing for 2009-07-22") + } + if splitK.SingleK || splitK.Ephemeris != "NASA bulletin Split-K" { + t.Fatalf("Split-K panel SingleK=%v ephemeris=%q", splitK.SingleK, splitK.Ephemeris) + } + if splitK.UmbralK != basic.SolarEclipseUmbralK || splitK.PenumbralK == splitK.UmbralK { + t.Fatalf("Split-K panel k1=%.7f k2=%.7f, want two distinct radii", splitK.PenumbralK, splitK.UmbralK) + } + if singleK.SunSemidiameterArcsec <= 0 || splitK.MoonSemidiameterArcsec <= 0 || + math.IsNaN(singleK.MoonParallaxArcsec) || math.IsNaN(splitK.SunParallaxArcsec) { + t.Fatalf("panel radii are not physical: %+v / %+v", singleK, splitK) + } + // 边界:当天没有日食时必须返回零值面板,而不是"取最近一次"的结果。 + eclipseFree := time.Date(2024, time.April, 15, 0, 0, 0, 0, time.UTC) + panel, ok := SolarEclipseGeocentricPanelAt(eclipseFree) + if ok || panel != (SolarEclipseGeocentricPanel{}) { + t.Fatalf("panel for an eclipse-free day = %+v ok=%v, want the zero panel", panel, ok) + } + if _, ok := SolarEclipseGeocentricPanelIAUSingleK(eclipseFree); ok { + t.Fatal("IAU panel accepted an eclipse-free day") + } +} + +func TestLunarEclipseGeocentricPanelShadowModelsContract(t *testing.T) { + if LunarEclipseShadowModelDanjon == LunarEclipseShadowModelChauvenet { + t.Fatal("the two shadow models must be distinct constants") + } + if LunarEclipseShadowModelDanjon != LunarEclipseShadowModel(0) { + t.Fatalf("the zero shadow model = %d, want Danjon", int(LunarEclipseShadowModelDanjon)) + } + date := time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC) + danjon, ok := LunarEclipseGeocentricPanelAt(date) + if !ok { + t.Fatal("Danjon panel missing for 2025-03-14") + } + chauvenet, ok := LunarEclipseGeocentricPanelChauvenet(date) + if !ok { + t.Fatal("Chauvenet panel missing for 2025-03-14") + } + if danjon.ShadowModel != LunarEclipseShadowModelDanjon || danjon.Ephemeris != "Danjon" { + t.Fatalf("default panel model=%d ephemeris=%q, want Danjon", int(danjon.ShadowModel), danjon.Ephemeris) + } + if chauvenet.ShadowModel != LunarEclipseShadowModelChauvenet || chauvenet.Ephemeris != "Chauvenet" { + t.Fatalf("Chauvenet panel model=%d ephemeris=%q", int(chauvenet.ShadowModel), chauvenet.Ephemeris) + } + if chauvenet.UmbralRadiusDegrees <= danjon.UmbralRadiusDegrees { + t.Fatalf("Chauvenet umbral radius %.6f must exceed the Danjon one %.6f", + chauvenet.UmbralRadiusDegrees, danjon.UmbralRadiusDegrees) + } + if math.Abs(danjon.Gamma-chauvenet.Gamma) > 1e-9 || + math.Abs(danjon.MoonDistanceEarthRadii-chauvenet.MoonDistanceEarthRadii) > 1e-9 { + t.Fatalf("shadow models changed the geometry: gamma %.9f/%.9f distance %.9f/%.9f", + danjon.Gamma, chauvenet.Gamma, danjon.MoonDistanceEarthRadii, chauvenet.MoonDistanceEarthRadii) + } + if danjon.PenumbralMagnitude <= danjon.UmbralMagnitude { + t.Fatalf("penumbral magnitude %.5f must exceed the umbral one %.5f", + danjon.PenumbralMagnitude, danjon.UmbralMagnitude) + } + // 边界:最近一次望没有月食时返回零值面板。 + eclipseFree := time.Date(2025, time.April, 13, 0, 0, 0, 0, time.UTC) + panel, ok := LunarEclipseGeocentricPanelAt(eclipseFree) + if ok || panel != (LunarEclipseGeocentricPanel{}) { + t.Fatalf("panel for an eclipse-free full moon = %+v ok=%v, want the zero panel", panel, ok) + } + if _, ok := LunarEclipseGeocentricPanelChauvenet(eclipseFree); ok { + t.Fatal("Chauvenet panel accepted an eclipse-free full moon") + } +} + +func TestSolarEclipseLocalSearchKindAndStatusContract(t *testing.T) { + if SolarEclipseLocalSearchAny != SolarEclipseLocalSearchKind(0) { + t.Fatalf("zero kind = %d, want Any", int(SolarEclipseLocalSearchAny)) + } + if SolarEclipseLocalSearchAny == SolarEclipseLocalSearchTotal || + SolarEclipseLocalSearchAny == SolarEclipseLocalSearchAnnular || + SolarEclipseLocalSearchTotal == SolarEclipseLocalSearchAnnular { + t.Fatal("the search kinds must be distinct constants") + } + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + info, status := SearchLocalCentralSolarEclipse(start, 113.26, 23.13, 0, + SolarEclipseLocalSearchOptions{MaxYears: 40}) + if !status.Found || status.Exhausted || status.Capped { + t.Fatalf("Any 40y: found=%v exhausted=%v capped=%v", status.Found, status.Exhausted, status.Capped) + } + if info.GreatestEclipse.IsZero() || status.MonthsScanned <= 0 || + status.GlobalChecks <= 0 || status.LocalChecks <= 0 || status.YearsScanned <= 0 { + t.Fatalf("Any 40y status did not account for the work: %+v", status) + } + // 边界:跨度小于最近一次朔时用尽跨度。 + _, tiny := SearchLocalCentralSolarEclipse(start, 116.40, 39.90, 0, + SolarEclipseLocalSearchOptions{Kind: SolarEclipseLocalSearchTotal, MaxYears: 0.01}) + if tiny.Found || !tiny.Exhausted || tiny.Capped { + t.Fatalf("tiny horizon: found=%v exhausted=%v capped=%v", tiny.Found, tiny.Exhausted, tiny.Capped) + } + if tiny.YearsScanned <= 0 || tiny.MonthsScanned > 1 { + t.Fatalf("tiny horizon scanned %d months over %.4f years", tiny.MonthsScanned, tiny.YearsScanned) + } + // Annular 只接受环食阶段:2020-06-21 的环食带经过厦门。 + backward := time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC) + annular, annulus := SearchLocalCentralSolarEclipse(backward, 118.09, 24.48, 0, + SolarEclipseLocalSearchOptions{Kind: SolarEclipseLocalSearchAnnular, Backward: true, MaxYears: 2}) + if !annulus.Found || !annular.HasAnnular || annular.HasTotal { + t.Fatalf("Xiamen annular: found=%v hasAnnular=%v hasTotal=%v", + annulus.Found, annular.HasAnnular, annular.HasTotal) + } + if got := annular.GreatestEclipse.UTC().Format("2006-01-02"); got != "2020-06-21" { + t.Fatalf("Xiamen annular greatest = %s, want 2020-06-21", got) + } + total, totalStatus := SearchLocalCentralSolarEclipse(backward, 118.09, 24.48, 0, + SolarEclipseLocalSearchOptions{Kind: SolarEclipseLocalSearchTotal, Backward: true, MaxYears: 2}) + if totalStatus.Found && total.GreatestEclipse.UTC().Format("2006-01-02") == "2020-06-21" { + t.Fatal("the Total kind accepted the 2020-06-21 annular-only event") + } +} + +func TestSolarEclipseShadowBetweenStatelessEntryPointsContract(t *testing.T) { + start := time.Date(2009, time.July, 22, 1, 0, 0, 0, time.UTC) + step := 30 * time.Minute + instants := SolarEclipseShadowBetween(start, start.Add(time.Hour), step) + if len(instants) != 3 { + t.Fatalf("stateless batch = %d entries, want 3", len(instants)) + } + solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) + for index, instant := range instants { + want := start.Add(time.Duration(index) * step) + if !instant.Time.Equal(want) { + t.Fatalf("entry %d time = %v, want %v", index, instant.Time, want) + } + single, _ := solver.ShadowAt(want) + if instant.Empty() != single.Empty() || instant.Topology.Signature() != single.Topology.Signature() { + t.Fatalf("entry %d disagrees with the solver: %q vs %q", + index, instant.Topology.Signature(), single.Topology.Signature()) + } + } + override := 1234.5 + withOptions := SolarEclipseShadowBetweenWithOptions(start, start.Add(time.Hour), step, + SolarEclipseShadowSolverOptions{DeltaTSeconds: override}) + if len(withOptions) != len(instants) { + t.Fatalf("option batch = %d entries, want %d", len(withOptions), len(instants)) + } + for index, instant := range withOptions { + if instant.DeltaTSeconds != override { + t.Fatalf("entry %d ΔT = %.4f, want %.4f", index, instant.DeltaTSeconds, override) + } + } + // 边界:非正步长、倒置窗口与零长窗口都返回 nil。 + for _, window := range []struct { + name string + begin, end time.Time + step time.Duration + }{ + {name: "零步长", begin: start, end: start.Add(time.Hour), step: 0}, + {name: "负步长", begin: start, end: start.Add(time.Hour), step: -step}, + {name: "倒置窗口", begin: start.Add(time.Hour), end: start, step: step}, + {name: "零长窗口", begin: start, end: start, step: step}, + } { + if got := SolarEclipseShadowBetween(window.begin, window.end, window.step); got != nil { + t.Fatalf("%s returned %d entries, want nil", window.name, len(got)) + } + if got := SolarEclipseShadowBetweenWithOptions(window.begin, window.end, window.step, + SolarEclipseShadowSolverOptions{}); got != nil { + t.Fatalf("%s option entry returned %d entries, want nil", window.name, len(got)) + } + } +} + +func TestSolarEclipseStationStateAtUsesGivenInstantContract(t *testing.T) { + value := time.Date(2009, time.July, 22, 2, 0, 0, 0, time.UTC) + daySide := SolarEclipseStationStateAt(value, 150, 20, 0) + nightSide := SolarEclipseStationStateAt(value, -30, 20, 0) + if !daySide.Visible || nightSide.Visible { + t.Fatalf("visibility at %s: 150E=%v 30W=%v", value.Format(time.RFC3339), daySide.Visible, nightSide.Visible) + } + if !daySide.Time.Equal(value) || daySide.JDE <= 0 || daySide.DeltaTSeconds <= 0 { + t.Fatalf("state did not carry the requested instant: %+v", daySide) + } + if daySide.SunRadiusDeg <= 0 || daySide.MoonOuterRadiusDeg <= 0 || daySide.SeparationArcsec <= 0 { + t.Fatalf("state radii are not physical: %+v", daySide) + } + if daySide.SunAltitudeDeg <= 0 || nightSide.SunAltitudeDeg >= 0 { + t.Fatalf("altitudes disagree with the visibility flags: %.3f / %.3f", + daySide.SunAltitudeDeg, nightSide.SunAltitudeDeg) + } + if daySide.Magnitude <= 0 || daySide.Magnitude >= 1 { + t.Fatalf("day-side partial magnitude = %.6f, want a partial value", daySide.Magnitude) + } + if nightSide.Magnitude != 0 || nightSide.Obscuration != 0 { + t.Fatalf("below-horizon state reports magnitude %.6f obscuration %.6f", nightSide.Magnitude, nightSide.Obscuration) + } +} diff --git a/eclipse/svg/data_source_test.go b/eclipse/svg/data_source_test.go new file mode 100644 index 0000000..deeeb3c --- /dev/null +++ b/eclipse/svg/data_source_test.go @@ -0,0 +1,81 @@ +package svg + +import ( + "regexp" + "testing" + "time" +) + +var eclipseMapDataSourcePattern = regexp.MustCompile(`data-source="([^"]+)"`) + +// 降级标记必须收敛到包注释里的词表:拼错的来源会让消费方静默漏掉降级图层。 +func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) { + // 词表必须与包注释逐项一致:常量改名或拼错都会让消费方的匹配静默失效。 + documented := []string{ + "partial-band-union", "sampled-footprint-sweep", "partial-band-contours", + "rise-set-phase-lines", "magnitude-contours", "greatest-time-isochrones", + "besselian-critical-envelope", "paired-limit-chords", "sampled-open-sweep", + "central-path-limits", "penumbral-outlines", "central-shadow-outlines", + "p1-p4-visibility-regions", "p1-p4-horizon-boundaries", + } + if len(documented) != len(eclipseMapDataSources) { + t.Fatalf("vocabulary has %d entries, want %d", len(eclipseMapDataSources), len(documented)) + } + known := make(map[string]bool, len(documented)) + for index, source := range documented { + if eclipseMapDataSources[index] != source { + t.Fatalf("vocabulary entry %d = %q, want %q", index, eclipseMapDataSources[index], source) + } + known[source] = true + } + + solarOptions := SolarEclipseMapSVGOptions{ + Width: 960, Height: 640, Location: time.UTC, PartialStep: 10 * time.Minute, + GreatestTimeStep: 30 * time.Minute, PenumbralOutlineStep: time.Hour, CentralShadowStep: 10 * time.Minute, + } + charts := map[string]string{} + if rendered, ok := SolarEclipseMapSVG(time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), solarOptions); ok { + charts["2024-04-08 total"] = rendered + } else { + t.Fatal("missing 2024-04-08 map") + } + if rendered, ok := SolarEclipseMapSVG(time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), solarOptions); ok { + charts["2014-04-29 non-central"] = rendered + } else { + t.Fatal("missing 2014-04-29 map") + } + if rendered, ok := LunarEclipseMapSVG(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC), + LunarEclipseMapSVGOptions{Width: 960, Height: 640, Location: time.UTC}); ok { + charts["2026-03-03 lunar"] = rendered + } else { + t.Fatal("missing 2026-03-03 lunar map") + } + + seen := map[string]bool{} + for name, chart := range charts { + for _, match := range eclipseMapDataSourcePattern.FindAllStringSubmatch(chart, -1) { + if !known[match[1]] { + t.Fatalf("%s: undocumented data-source %q", name, match[1]) + } + seen[match[1]] = true + } + } + // 主要图层必须带标记,否则消费方无法判断这一层是否降级。 + for _, source := range []string{ + eclipseMapSourcePartialBandUnion, + eclipseMapSourcePartialBandContours, + eclipseMapSourceRiseSetPhaseLines, + eclipseMapSourceMagnitudeContours, + eclipseMapSourceGreatestTimeIsochrones, + eclipseMapSourceBesselianEnvelope, + eclipseMapSourceCentralPathLimits, + eclipseMapSourcePenumbralOutlines, + eclipseMapSourceCentralShadowOutlines, + eclipseMapSourceLunarVisibilityRegions, + eclipseMapSourceLunarHorizonBoundaries, + } { + if !seen[source] { + t.Fatalf("main layer source %q carries no data-source marker", source) + } + } +} diff --git a/eclipse/svg/doc.go b/eclipse/svg/doc.go new file mode 100644 index 0000000..6be32e4 --- /dev/null +++ b/eclipse/svg/doc.go @@ -0,0 +1,58 @@ +// Package svg 生成日食与月食的 SVG 图件。 +// +// 可降级的图层用 data-source 标注实际几何来源,取值词表如下;没有降级分支的图层不带该属性。 +// +// partial-band-union 偏食可见域填充:权威边界线材与瞬时足迹求并集 +// sampled-footprint-sweep 偏食可见域填充:逐瞬时足迹扫掠(并集不可用时的降级) +// partial-band-contours 偏食可见域边界线 +// rise-set-phase-lines 初亏、食甚、复圆的日升日落线 +// magnitude-contours 地方最大食分等值线 +// greatest-time-isochrones 食甚时刻等时线 +// besselian-critical-envelope 中心食带:Bessel 临界包络 +// paired-limit-chords 中心食带:南北限界弦带(包络不可用时的降级) +// sampled-open-sweep 中心食带:采样扫掠(前两者都不可用时的降级) +// central-path-limits 中心线与南北限界线 +// penumbral-outlines 瞬时半影轮廓 +// central-shadow-outlines 瞬时本影、反本影轮廓 +// p1-p4-visibility-regions 月食可见性分区 +// p1-p4-horizon-boundaries 月食 P1、P4 地平边界 +// +// Package svg renders solar- and lunar-eclipse SVG charts. Degradable layers tag the geometry +// source they actually used in data-source, using exactly the values above. +package svg + +// data-source 词表,见包注释。 +const ( + eclipseMapSourcePartialBandUnion = "partial-band-union" + eclipseMapSourceSampledFootprintSweep = "sampled-footprint-sweep" + eclipseMapSourcePartialBandContours = "partial-band-contours" + eclipseMapSourceRiseSetPhaseLines = "rise-set-phase-lines" + eclipseMapSourceMagnitudeContours = "magnitude-contours" + eclipseMapSourceGreatestTimeIsochrones = "greatest-time-isochrones" + eclipseMapSourceBesselianEnvelope = "besselian-critical-envelope" + eclipseMapSourcePairedLimitChords = "paired-limit-chords" + eclipseMapSourceSampledOpenSweep = "sampled-open-sweep" + eclipseMapSourceCentralPathLimits = "central-path-limits" + eclipseMapSourcePenumbralOutlines = "penumbral-outlines" + eclipseMapSourceCentralShadowOutlines = "central-shadow-outlines" + eclipseMapSourceLunarVisibilityRegions = "p1-p4-visibility-regions" + eclipseMapSourceLunarHorizonBoundaries = "p1-p4-horizon-boundaries" +) + +// eclipseMapDataSources 是全部合法取值,测试用它封闭词表。 +var eclipseMapDataSources = []string{ + eclipseMapSourcePartialBandUnion, + eclipseMapSourceSampledFootprintSweep, + eclipseMapSourcePartialBandContours, + eclipseMapSourceRiseSetPhaseLines, + eclipseMapSourceMagnitudeContours, + eclipseMapSourceGreatestTimeIsochrones, + eclipseMapSourceBesselianEnvelope, + eclipseMapSourcePairedLimitChords, + eclipseMapSourceSampledOpenSweep, + eclipseMapSourceCentralPathLimits, + eclipseMapSourcePenumbralOutlines, + eclipseMapSourceCentralShadowOutlines, + eclipseMapSourceLunarVisibilityRegions, + eclipseMapSourceLunarHorizonBoundaries, +} diff --git a/eclipse/svg/long_text_footer_test.go b/eclipse/svg/long_text_footer_test.go new file mode 100644 index 0000000..9047f89 --- /dev/null +++ b/eclipse/svg/long_text_footer_test.go @@ -0,0 +1,202 @@ +package svg + +import ( + "encoding/xml" + "io" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/internal/svgchart" +) + +// 调用方给图题、面板标题与页脚传超长文本时,产物必须留在画布内并在截断处给出省略号; +// 判定口径与 internal/svgchart 的 EstimatedTextWidth/EstimatedTextExtents 一致。 + +type footerFitText struct { + value string + x, y float64 + fontSize float64 + anchor string +} + +func footerFitLongText(prefix string) string { + piece := prefix + "long custom text 自定义长文本,用于验证折行与截断;" + value := "" + for len([]rune(value)) < 640 { + value += piece + } + return value +} + +func footerFitTexts(t *testing.T, document string) []footerFitText { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(document)) + texts := []footerFitText{} + stack := []string{} + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("chart is not valid XML: %v", err) + } + switch element := token.(type) { + case xml.StartElement: + stack = append(stack, element.Name.Local) + if element.Name.Local != "text" { + continue + } + text := footerFitText{fontSize: 12} + for _, attribute := range element.Attr { + switch attribute.Name.Local { + case "x": + text.x, _ = strconv.ParseFloat(attribute.Value, 64) + case "y": + text.y, _ = strconv.ParseFloat(attribute.Value, 64) + case "font-size": + text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64) + case "text-anchor": + text.anchor = attribute.Value + } + } + texts = append(texts, text) + case xml.EndElement: + stack = stack[:len(stack)-1] + case xml.CharData: + if len(stack) > 0 && stack[len(stack)-1] == "text" && len(texts) > 0 { + texts[len(texts)-1].value += string(element) + } + } + } + return texts +} + +func footerFitViolations(t *testing.T, document string, width, height int) []string { + t.Helper() + violations := []string{} + for _, text := range footerFitTexts(t, document) { + textWidth := svgchart.EstimatedTextWidth(text.value, text.fontSize) + left := text.x + switch text.anchor { + case "middle": + left = text.x - textWidth/2 + case "end": + left = text.x - textWidth + } + above, below := svgchart.EstimatedTextExtents(text.fontSize) + if text.x < 0 || text.x > float64(width) || text.y < 0 || text.y > float64(height) { + violations = append(violations, text.value) + continue + } + if left < 0 || left+textWidth > float64(width) || text.y-above < 0 || text.y+below > float64(height) { + violations = append(violations, text.value) + } + } + return violations +} + +func TestSolarEclipseMapSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("solar ") + if runes := len([]rune(long)); runes < 300 || runes > 800 { + t.Fatalf("long text runes = %d, want 300..800", runes) + } + date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC) + for _, size := range [][2]int{{800, 560}, {960, 640}, {1000, 1414}} { + diagram, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{ + Width: size[0], Height: size[1], Title: long, MapTitle: long, FooterNote: long, + }) + if !ok { + t.Fatalf("%dx%d: missing chart", size[0], size[1]) + } + if violations := footerFitViolations(t, diagram, size[0], size[1]); len(violations) > 0 { + t.Fatalf("%dx%d: %d texts outside the canvas, first = %q", size[0], size[1], len(violations), violations[0]) + } + if !strings.Contains(diagram, "…") { + t.Fatalf("%dx%d: truncated custom text is not marked", size[0], size[1]) + } + } +} + +func TestLunarEclipseDetailedSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("lunar ") + date := time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC) + // 1000x1000 是该版式能装下地图与数据块的最小高度,1000x1414 是整页开本。 + for _, size := range [][2]int{{1000, 1000}, {1000, 1414}} { + diagram, ok := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{ + Width: size[0], Height: size[1], Title: long, FooterNote: long, + }) + if !ok { + t.Fatalf("%dx%d: missing chart", size[0], size[1]) + } + if violations := footerFitViolations(t, diagram, size[0], size[1]); len(violations) > 0 { + t.Fatalf("%dx%d: %d texts outside the canvas, first = %q", size[0], size[1], len(violations), violations[0]) + } + if !strings.Contains(diagram, "…") { + t.Fatalf("%dx%d: truncated custom text is not marked", size[0], size[1]) + } + } +} + +func TestLunarEclipseMapAndSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("moon ") + date := time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC) + mapDiagram, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{ + Width: 960, Height: 640, Title: long, FooterNote: long, + }) + if !ok { + t.Fatal("missing lunar-eclipse map") + } + if violations := footerFitViolations(t, mapDiagram, 960, 640); len(violations) > 0 { + t.Fatalf("map: %d texts outside the canvas, first = %q", len(violations), violations[0]) + } + if !strings.Contains(mapDiagram, "…") { + t.Fatal("map: truncated custom text is not marked") + } + diagram, ok := LunarEclipseSVG(date, LunarEclipseSVGOptions{Title: long, FooterNote: long}) + if !ok { + t.Fatal("missing lunar-eclipse diagram") + } + if violations := footerFitViolations(t, diagram, 960, 620); len(violations) > 0 { + t.Fatalf("diagram: %d texts outside the canvas, first = %q", len(violations), violations[0]) + } + if !strings.Contains(diagram, "…") { + t.Fatal("diagram: truncated custom text is not marked") + } +} + +func TestLocalSolarEclipseSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("local ") + date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC) + diagram, ok := LocalSolarEclipseSVG(date, -90, 35, 0, LocalSolarEclipseSVGOptions{ + Title: long, SummaryText: long, GreatestText: long, MetaText: long, + DirectionText: long, FooterNote: long, Location: time.UTC, + }) + if !ok { + t.Fatal("missing local solar-eclipse chart") + } + if violations := footerFitViolations(t, diagram, 920, 720); len(violations) > 0 { + t.Fatalf("%d texts outside the canvas, first = %q", len(violations), violations[0]) + } + if !strings.Contains(diagram, "…") { + t.Fatal("truncated custom text is not marked") + } +} + +// 自动生成的默认文案不得进入截断分支:这些产物在改动前后逐字节一致,出现省略号即说明分流写错。 +func TestEclipseSVGDefaultTextIsNotTruncated(t *testing.T) { + date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC) + for _, language := range []string{"zh", "en"} { + solar, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Language: language}) + if !ok || strings.Contains(solar, "…") { + t.Fatalf("solar map %s: ok=%v, default text must stay unmarked", language, ok) + } + lunar, ok := LunarEclipseDetailedSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC), + LunarEclipseDetailedSVGOptions{Language: language}) + if !ok || strings.Contains(lunar, "…") { + t.Fatalf("lunar detailed %s: ok=%v, default text must stay unmarked", language, ok) + } + } +} diff --git a/eclipse/svg/lunar.go b/eclipse/svg/lunar.go index 39e1e77..930363f 100644 --- a/eclipse/svg/lunar.go +++ b/eclipse/svg/lunar.go @@ -4,12 +4,14 @@ import ( "fmt" "html" "math" + "sort" "strings" "time" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/svgasset" + "b612.me/astro/internal/svgchart" ) const ( @@ -77,6 +79,8 @@ type lunarEclipseSVGLayout struct { scale float64 diagramLeft float64 diagramRight float64 + diagramTop float64 + diagramEnd float64 panelX float64 panelY float64 } @@ -96,12 +100,37 @@ type lunarEclipseSVGMaximumCoordinates struct { ConstellationName string } -// LunarEclipseSVG 生成月食穿影图 SVG,默认使用 Danjon 影半径模型。 -// LunarEclipseSVG generates an SVG lunar eclipse shadow-path chart, using the Danjon shadow model by default. +// LunarEclipseSVG 生成月食穿影图 SVG,默认模型与 LunarEclipseOnDate 一致:Danjon 为主,极浅半影回退 Chauvenet。 +// LunarEclipseSVG generates an SVG lunar eclipse shadow-path chart with the default model of +// LunarEclipseOnDate: Danjon, falling back to Chauvenet for ultra-shallow penumbral cases. func LunarEclipseSVG(date time.Time, options LunarEclipseSVGOptions) (string, bool) { + model, ok := lunarEclipseDefaultShadowModel(date) + if !ok { + return "", false + } + if model == eclipsecore.LunarEclipseShadowModelChauvenet { + return LunarEclipseSVGChauvenet(date, options) + } return LunarEclipseSVGDanjon(date, options) } +// lunarEclipseDefaultShadowModel 返回默认模型下该日期的月食影半径模型;当天没有月食时返回 false。 +func lunarEclipseDefaultShadowModel(date time.Time) (eclipsecore.LunarEclipseShadowModel, bool) { + info, ok := eclipsecore.LunarEclipseOnDate(date) + if !ok { + return eclipsecore.LunarEclipseShadowModelDanjon, false + } + return info.ShadowModel, true +} + +// lunarEclipseShadowModelName 给出影半径模型的展示名。 +func lunarEclipseShadowModelName(model eclipsecore.LunarEclipseShadowModel) string { + if model == eclipsecore.LunarEclipseShadowModelChauvenet { + return "Chauvenet" + } + return "Danjon" +} + // LunarEclipseSVGDanjon 生成月食穿影图 SVG,使用 Danjon 影半径模型。 // LunarEclipseSVGDanjon generates an SVG lunar eclipse shadow-path chart with the Danjon shadow model. func LunarEclipseSVGDanjon(date time.Time, options LunarEclipseSVGOptions) (string, bool) { @@ -154,6 +183,140 @@ func normalizeLunarEclipseSVGOptions(options LunarEclipseSVGOptions) LunarEclips return options } +// writeLunarEclipseDiagram 在给定圆心与比例下画地影、月心轨迹与各阶段月面。 +// 独立月食图和 详细版式组合图共用这一段,只有圆心的位置不同。 +func writeLunarEclipseDiagram( + b *strings.Builder, + layout lunarEclipseSVGLayout, + diagram basic.LunarEclipseDiagramResult, + language string, + labels *svgchart.LabelTable, +) { + cx, cy, scale := layout.cx, layout.cy, layout.scale + points := lunarEclipseSVGPoints(diagram.Points) + mapX := func(x float64) float64 { return cx - x*scale } + mapY := func(y float64) float64 { return cy - y*scale } + area := svgchart.LabelBox{ + X: layout.diagramLeft, Y: layout.diagramTop, + Width: layout.diagramRight - layout.diagramLeft, Height: layout.diagramEnd - layout.diagramTop, + } + fmt.Fprintf(b, ``, + cx, cy, diagram.PenumbraRadius*scale) + fmt.Fprintf(b, ``, + cx, cy, diagram.UmbraRadius*scale) + writeLunarEclipseShadowLabels(b, layout, diagram, language, labels, area) + writeLunarEclipseAxes(b, cx, cy, diagram.PenumbraRadius*scale, language, labels, area) + writeLunarEclipseEclipticLine(b, layout, diagram, mapX, mapY, language, labels, area) + + if len(points) > 0 { + b.WriteString(``) + } + + eventPoints := lunarEclipseSVGEventPoints(points) + for _, point := range eventPoints { + if point.Label == "Greatest" { + continue + } + writeLunarEclipseMoon(b, point, diagram, mapX(point.X), mapY(point.Y), scale, false) + } + for _, point := range eventPoints { + if point.Label == "Greatest" { + writeLunarEclipseMoon(b, point, diagram, mapX(point.X), mapY(point.Y), scale, true) + break + } + } + writeLunarEclipseEventLabels(b, eventPoints, mapX, mapY, cx, diagram.MoonRadius*scale, language, labels, area) +} + +// lunarEclipseEventLabelRank 是事件标注的占位优先级:数字小的先占位,放不下的后放的让位。 +func lunarEclipseEventLabelRank(label string) int { + switch label { + case "Greatest": + return 0 + case "U2", "U3": + return 1 + case "U1", "U4": + return 2 + case "P1", "P4": + return 3 + } + return 4 +} + +// writeLunarEclipseEventLabels 先按重要性占位再按原顺序绘制:食甚最优先,其次本影接触,最后半影接触。 +func writeLunarEclipseEventLabels( + b *strings.Builder, + eventPoints []lunarEclipseSVGPoint, + mapX, mapY func(float64) float64, + cx, moonRadius float64, + language string, + labels *svgchart.LabelTable, + area svgchart.LabelBox, +) { + type eventLabel struct { + rank int + text string + candidates []svgchart.LabelPlacement + placed svgchart.LabelPlacement + ok bool + } + entries := make([]eventLabel, 0, len(eventPoints)) + for _, point := range eventPoints { + if point.Label == "" { + continue + } + x := mapX(point.X) + y := mapY(point.Y) + dx := moonRadius*0.72 + 6 + anchor := "start" + if x < cx { + dx = -dx + anchor = "end" + } + dy := -moonRadius*0.35 - 4 + if point.Label == "Greatest" { + dy = moonRadius + 15 + anchor = "middle" + dx = 0 + } + candidates := []svgchart.LabelPlacement{{X: x + dx, Y: y + dy, Anchor: anchor}} + // 浅半影食里 P1 与 P4 挨得很近,近环放不下时还要一圈更远的候选。 + candidates = append(candidates, svgchart.LabelCandidates(x+dx, y+dy, anchor, math.Max(14, moonRadius*0.8))...) + candidates = append(candidates, svgchart.LabelCandidates(x+dx, y+dy, anchor, math.Max(30, moonRadius*1.6))...) + candidates = append(candidates, svgchart.LabelCandidates(x+dx, y+dy, anchor, math.Max(48, moonRadius*2.4))...) + entries = append(entries, eventLabel{ + rank: lunarEclipseEventLabelRank(point.Label), + text: lunarEclipseSVGEventName(point.Label, language), + candidates: candidates, + }) + } + order := make([]int, len(entries)) + for index := range entries { + order[index] = index + } + sort.SliceStable(order, func(i, j int) bool { return entries[order[i]].rank < entries[order[j]].rank }) + for _, index := range order { + entry := &entries[index] + entry.placed, entry.ok = labels.Place(entry.text, 12, + labelCandidatesInsideBox(entry.candidates, entry.text, 12, area)) + } + for _, entry := range entries { + if !entry.ok { + continue + } + fmt.Fprintf(b, `%s`, + entry.placed.X, entry.placed.Y, entry.placed.Anchor, html.EscapeString(entry.text)) + } +} + func renderLunarEclipseSVG( info LunarEclipseInfo, diagram basic.LunarEclipseDiagramResult, @@ -161,9 +324,6 @@ func renderLunarEclipseSVG( ) string { headerTexts := lunarEclipseSVGHeaderTexts(info, options) layout := lunarEclipseSVGLayoutFor(diagram, options, lunarEclipseSVGHeaderBottom(headerTexts)) - points := lunarEclipseSVGPoints(diagram.Points) - mapX := func(x float64) float64 { return layout.cx - x*layout.scale } - mapY := func(y float64) float64 { return layout.cy - y*layout.scale } title := lunarEclipseSVGTitleText(info, options) var b strings.Builder @@ -174,51 +334,21 @@ func renderLunarEclipseSVG( b.WriteString(``) fmt.Fprintf(&b, ``, layout.width-44, layout.height-36) + titleText := title + if options.Title != "" { + titleText = svgchart.EllipsizeText(title, layout.width-52, 26) + } fmt.Fprintf(&b, `%s`, - layout.width/2, html.EscapeString(title)) + layout.width/2, html.EscapeString(titleText)) fmt.Fprintf(&b, ``, layout.width/2-78, layout.width/2+78) writeLunarEclipseSummary(&b, headerTexts, options) - fmt.Fprintf(&b, ``, - layout.cx, layout.cy, diagram.PenumbraRadius*layout.scale) - fmt.Fprintf(&b, ``, - layout.cx, layout.cy, diagram.UmbraRadius*layout.scale) - writeLunarEclipseShadowLabels(&b, layout, diagram, options.Language) - writeLunarEclipseAxes(&b, layout.cx, layout.cy, diagram.PenumbraRadius*layout.scale, options.Language) - writeLunarEclipseEclipticLine(&b, layout, diagram, mapX, mapY, options.Language) - - if len(points) > 0 { - b.WriteString(``) - } - - eventPoints := lunarEclipseSVGEventPoints(points) - for _, point := range eventPoints { - if point.Label == "Greatest" { - continue - } - writeLunarEclipseMoon(&b, point, diagram, mapX(point.X), mapY(point.Y), layout.scale, false) - } - for _, point := range eventPoints { - if point.Label == "Greatest" { - writeLunarEclipseMoon(&b, point, diagram, mapX(point.X), mapY(point.Y), layout.scale, true) - break - } - } - for _, point := range eventPoints { - if point.Label == "" { - continue - } - x := mapX(point.X) - y := mapY(point.Y) - writeLunarEclipseEventLabel(&b, point.Label, x, y, layout.cx, diagram.MoonRadius*layout.scale, options.Language) - } + // 穿影示意图的标注要避开已经写好的表头与右侧接触时刻面板。 + labels := &svgchart.LabelTable{} + labels.Reserve(0, 0, layout.width, layout.diagramTop) + labels.Reserve(layout.diagramRight, layout.diagramTop, + layout.width-layout.diagramRight, layout.diagramEnd-layout.diagramTop) + labels.Reserve(0, layout.diagramEnd, layout.width, layout.height-layout.diagramEnd) + writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels) writeLunarEclipseContacts(&b, info, options, layout.panelX, layout.panelY) writeLunarEclipseFooter(&b, info, options, layout) @@ -265,6 +395,8 @@ func lunarEclipseSVGLayoutFor( scale: scale, diagramLeft: diagramLeft, diagramRight: diagramRight, + diagramTop: topReserved, + diagramEnd: height - bottomReserved, panelX: panelX, panelY: math.Max(148, cy-88), } @@ -504,9 +636,17 @@ func lunarEclipseSVGMetaText(info LunarEclipseInfo, language string) string { return "" } if language == lunarEclipseSVGLanguageEnglish { - return fmt.Sprintf("Lunar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count) + label := fmt.Sprintf("Lunar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count) + if !info.Saros.Verified { + label += " (provisional)" + } + return label } - return fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count) + label := fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count) + if !info.Saros.Verified { + label += "(推算)" + } + return label } func lunarEclipseSVGFormatDuration(duration time.Duration) string { @@ -545,19 +685,35 @@ func lunarEclipseSVGTypeName(eclipseType LunarEclipseType, language string) stri } } -func writeLunarEclipseAxes(b *strings.Builder, cx, cy, radius float64, language string) { +func writeLunarEclipseAxes( + b *strings.Builder, + cx, cy, radius float64, + language string, + labels *svgchart.LabelTable, + area svgchart.LabelBox, +) { north, east, west, south := "北", "东", "西", "南" if language == lunarEclipseSVGLanguageEnglish { north, east, west, south = "N", "E", "W", "S" } - fmt.Fprintf(b, `%s`, - cx, cy-radius-18, html.EscapeString(north)) - fmt.Fprintf(b, `%s`, - cx-radius-22, cy+4, html.EscapeString(east)) - fmt.Fprintf(b, `%s`, - cx+radius+22, cy+4, html.EscapeString(west)) - fmt.Fprintf(b, `%s`, - cx, cy+radius+28, html.EscapeString(south)) + entries := [4]struct { + text string + x, y float64 + }{ + {text: north, x: cx, y: cy - radius - 18}, + {text: east, x: cx - radius - 22, y: cy + 4}, + {text: west, x: cx + radius + 22, y: cy + 4}, + {text: south, x: cx, y: cy + radius + 28}, + } + for _, entry := range entries { + candidates := svgchart.LabelCandidates(entry.x, entry.y, "middle", 15) + placed, ok := labels.Place(entry.text, 14, labelCandidatesInsideBox(candidates, entry.text, 14, area)) + if !ok { + continue + } + fmt.Fprintf(b, `%s`, + placed.X, placed.Y, placed.Anchor, html.EscapeString(entry.text)) + } } func lunarEclipseSVGPoints(points []basic.LunarEclipseDiagramPoint) []lunarEclipseSVGPoint { @@ -580,6 +736,8 @@ func writeLunarEclipseEclipticLine( diagram basic.LunarEclipseDiagramResult, mapX, mapY func(float64) float64, language string, + labels *svgchart.LabelTable, + area svgchart.LabelBox, ) { unitX, unitY, ok := lunarEclipseSVGEclipticDirection(diagram.Eclipse.Maximum) if !ok { @@ -610,8 +768,15 @@ func writeLunarEclipseEclipticLine( labelX += 16 anchor = "start" } + text := lunarEclipseSVGLabelEcliptic(language) + candidates := append([]svgchart.LabelPlacement{{X: labelX, Y: labelY + 4, Anchor: anchor}}, + svgchart.LabelCandidates(labelX, labelY+4, anchor, 14)...) + placed, ok := labels.Place(text, 12, labelCandidatesInsideBox(candidates, text, 12, area)) + if !ok { + return + } fmt.Fprintf(b, `%s`, - labelX, labelY+4, anchor, html.EscapeString(lunarEclipseSVGLabelEcliptic(language))) + placed.X, placed.Y, placed.Anchor, html.EscapeString(text)) } func lunarEclipseSVGEclipticDirection(ttJDE float64) (float64, float64, bool) { @@ -664,15 +829,31 @@ func writeLunarEclipseShadowLabels( layout lunarEclipseSVGLayout, diagram basic.LunarEclipseDiagramResult, language string, + labels *svgchart.LabelTable, + area svgchart.LabelBox, ) { penumbraLabel, umbraLabel := "地球半影", "地球本影" if language == lunarEclipseSVGLanguageEnglish { penumbraLabel, umbraLabel = "Earth's Penumbra", "Earth's Umbra" } - fmt.Fprintf(b, `%s`, - layout.cx, layout.cy-diagram.PenumbraRadius*layout.scale+28, html.EscapeString(penumbraLabel)) - fmt.Fprintf(b, `%s`, - layout.cx, layout.cy-diagram.UmbraRadius*layout.scale+22, html.EscapeString(umbraLabel)) + entries := [2]struct { + text string + x, y float64 + fill string + weight string + }{ + {text: penumbraLabel, x: layout.cx, y: layout.cy - diagram.PenumbraRadius*layout.scale + 28, fill: "#333", weight: "700"}, + {text: umbraLabel, x: layout.cx, y: layout.cy - diagram.UmbraRadius*layout.scale + 22, fill: "#111", weight: "700"}, + } + for _, entry := range entries { + candidates := svgchart.LabelCandidates(entry.x, entry.y, "middle", 14) + placed, ok := labels.Place(entry.text, 13, labelCandidatesInsideBox(candidates, entry.text, 13, area)) + if !ok { + continue + } + fmt.Fprintf(b, `%s`, + placed.X, placed.Y, entry.fill, entry.weight, placed.Anchor, html.EscapeString(entry.text)) + } } func lunarEclipseSVGEventPoints(points []lunarEclipseSVGPoint) []lunarEclipseSVGPoint { @@ -823,11 +1004,23 @@ func writeLunarEclipseFooter( layout lunarEclipseSVGLayout, ) { _ = info + direction := lunarEclipseSVGDirectionTextValue(options) + if options.DirectionText != "" { + direction = svgchart.EllipsizeText(direction, layout.width-80, 12) + } fmt.Fprintf(b, `%s`, - 40.0, layout.height-54, html.EscapeString(lunarEclipseSVGDirectionTextValue(options))) - note := lunarEclipseSVGFooterNoteText(options) - fmt.Fprintf(b, `%s`, - 40.0, layout.height-34, html.EscapeString(note)) + 40.0, layout.height-54, html.EscapeString(direction)) + // 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。 + lines := []string{lunarEclipseSVGFooterNoteText(options)} + if options.FooterNote != "" { + maxWidth := layout.width - 80 + lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12, + svgchart.BaselineLineLimit(12, 15, layout.height-34, layout.height-4)) + } + for index, line := range lines { + fmt.Fprintf(b, `%s`, + 40.0, layout.height-34+float64(index)*15, html.EscapeString(line)) + } } func lunarEclipseSVGContacts(info LunarEclipseInfo, language string) []lunarEclipseSVGContact { diff --git a/eclipse/svg/lunar_default_model_test.go b/eclipse/svg/lunar_default_model_test.go new file mode 100644 index 0000000..232aa43 --- /dev/null +++ b/eclipse/svg/lunar_default_model_test.go @@ -0,0 +1,77 @@ +package svg + +import ( + "strings" + "testing" + "time" + + eclipsecore "b612.me/astro/eclipse" +) + +// 三个无后缀月食 SVG 入口共用与 LunarEclipseOnDate 一致的默认模型:Danjon 为主, +// 极浅半影回退 Chauvenet。2016-08-18 是 Danjon 判无食、Chauvenet 判极浅半影的个例。 +func TestLunarEclipseSVGDefaultModelMatchesCore(t *testing.T) { + date := time.Date(2016, time.August, 18, 0, 0, 0, 0, time.UTC) + info, coreOK := eclipsecore.LunarEclipseOnDate(date) + if !coreOK { + t.Fatal("2016-08-18 is no longer a default-model eclipse date") + } + if info.ShadowModel != eclipsecore.LunarEclipseShadowModelChauvenet { + t.Fatalf("default model = %v, want the Chauvenet fallback", info.ShadowModel) + } + if _, danjonOK := eclipsecore.LunarEclipseOnDateDanjon(date); danjonOK { + t.Fatal("2016-08-18 no longer separates the Danjon and default models") + } + + mapRendered, mapOK := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{Width: 900, Height: 620, Location: time.UTC}) + plain, plainOK := LunarEclipseSVG(date, LunarEclipseSVGOptions{Width: 960, Height: 620, Location: time.UTC}) + detailed, detailedOK := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1414, Location: time.UTC}) + if !mapOK || !plainOK || !detailedOK { + t.Fatalf("default-model entries disagree: map=%v svg=%v detailed=%v (core=%v)", mapOK, plainOK, detailedOK, coreOK) + } + if mapRendered == "" || plain == "" || detailed == "" { + t.Fatal("a default-model entry reported success but returned no document") + } + // 出图必须真的走 Chauvenet:详细版页脚写出实际模型,且与强制 Chauvenet 入口逐字节相同。 + if !strings.Contains(detailed, "Chauvenet") { + t.Fatal("detailed chart does not name the Chauvenet fallback model") + } + chauvenet, chauvenetOK := LunarEclipseDetailedSVGChauvenet(date, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1414, Location: time.UTC}) + if !chauvenetOK || chauvenet != detailed { + t.Fatal("default detailed chart differs from the forced Chauvenet chart") + } + forcedPlain, forcedPlainOK := LunarEclipseSVGChauvenet(date, LunarEclipseSVGOptions{Width: 960, Height: 620, Location: time.UTC}) + if !forcedPlainOK || forcedPlain != plain { + t.Fatal("default lunar chart differs from the forced Chauvenet chart") + } + forcedMap, forcedMapOK := LunarEclipseMapSVGChauvenet(date, LunarEclipseMapSVGOptions{Width: 900, Height: 620, Location: time.UTC}) + if !forcedMapOK || forcedMap != mapRendered { + t.Fatal("default lunar map differs from the forced Chauvenet map") + } + + // 强制 Danjon 的两个入口必须继续判无食,后缀入口不得被默认口径带偏。 + if _, ok := LunarEclipseSVGDanjon(date, LunarEclipseSVGOptions{}); ok { + t.Fatal("the Danjon-only chart accepted a date that Danjon rejects") + } + if _, ok := LunarEclipseDetailedSVGDanjon(date, LunarEclipseDetailedSVGOptions{}); ok { + t.Fatal("the Danjon-only detailed chart accepted a date that Danjon rejects") + } + if _, ok := LunarEclipseMapSVGDanjon(date, LunarEclipseMapSVGOptions{}); ok { + t.Fatal("the Danjon-only map accepted a date that Danjon rejects") + } +} + +// 默认模型在普通月食日期上仍走 Danjon,不能把全部入口改成回退口径。 +func TestLunarEclipseSVGDefaultModelKeepsDanjon(t *testing.T) { + date := time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC) + detailed, ok := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1414, Location: time.UTC}) + if !ok { + t.Fatal("missing 2025-03-14 detailed chart") + } + if !strings.Contains(detailed, "Danjon") || strings.Contains(detailed, "Chauvenet") { + t.Fatal("2025-03-14 is a Danjon eclipse but the chart claims another model") + } + if _, ok := LunarEclipseSVG(date, LunarEclipseSVGOptions{}); !ok { + t.Fatal("missing 2025-03-14 chart") + } +} diff --git a/eclipse/svg/lunar_detailed.go b/eclipse/svg/lunar_detailed.go new file mode 100644 index 0000000..a69912e --- /dev/null +++ b/eclipse/svg/lunar_detailed.go @@ -0,0 +1,637 @@ +package svg + +import ( + "fmt" + "html" + "math" + "strings" + "time" + + "b612.me/astro/basic" + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgasset" + "b612.me/astro/internal/svgchart" + "b612.me/astro/internal/svgmap" +) + +// LunarEclipseDetailedSVGOptions 控制 详细版式的月食组合图:摘要、穿影示意图与世界可见性底图合成一页。 +// LunarEclipseDetailedSVGOptions controls the detailed combined lunar-eclipse chart: summary, shadow-path +// diagram, and world visibility map on a single page. +type LunarEclipseDetailedSVGOptions struct { + // Width 与 Height 是 SVG 画布尺寸;<=0 时使用 1000x1414 的 NASA 开本。版式按 Height 推导: + // 高度不足时先压缩穿影示意图,再压缩底图;两者都低于下限(示意图 200、底图 180)、 + // 数据块行距或底图图例行放不下时返回 false。 + // Width and Height are the SVG canvas size; values <= 0 use the 1000x1414 NASA page. The page + // stack is derived from Height: a short canvas shrinks the shadow-path diagram first and the + // map second, and the renderer returns false when the diagram and map minima (200 and 180), the + // data-block row spacing, or the map legend rows cannot be kept apart. + Width int + Height int + // Language 为 "en" 时使用英文,其他值使用中文。 + // Language uses English for "en" and Chinese otherwise. + Language string + // Location 控制显示时刻的时区;nil 使用 UTC+8。 + // Location controls the display timezone; nil uses UTC+8. + Location *time.Location + // Step 是月心路径采样步长;<=0 时使用 5 分钟。 + // Step is the Moon-center path sampling step; values <= 0 use five minutes. + Step time.Duration + // Title 与 FooterNote 为空时自动生成。 + // Empty Title and FooterNote use automatic text. + Title string + FooterNote string +} + +// LunarEclipseDetailedSVG 生成 详细版式的月食组合图,默认模型与 LunarEclipseOnDate 一致:Danjon 为主,极浅半影回退 Chauvenet。 +// LunarEclipseDetailedSVG renders a detailed combined lunar-eclipse chart with the default model of +// LunarEclipseOnDate: Danjon, falling back to Chauvenet for ultra-shallow penumbral cases. +func LunarEclipseDetailedSVG(date time.Time, options LunarEclipseDetailedSVGOptions) (string, bool) { + model, ok := lunarEclipseDefaultShadowModel(date) + if !ok { + return "", false + } + if model == eclipsecore.LunarEclipseShadowModelChauvenet { + return LunarEclipseDetailedSVGChauvenet(date, options) + } + return LunarEclipseDetailedSVGDanjon(date, options) +} + +// LunarEclipseDetailedSVGDanjon 生成 详细版式月食组合图,使用 Danjon 影半径模型。 +// LunarEclipseDetailedSVGDanjon renders a detailed chart with the Danjon shadow model. +func LunarEclipseDetailedSVGDanjon(date time.Time, options LunarEclipseDetailedSVGOptions) (string, bool) { + return lunarEclipseDetailedSVG(date, options, + basic.LunarEclipseDiagramDanjon, basic.LunarEclipseShadowGeometryAt, eclipsecore.ClosestLunarEclipseDanjon, + eclipsecore.LunarEclipseShadowModelDanjon) +} + +// LunarEclipseDetailedSVGChauvenet 生成 详细版式月食组合图,使用 Chauvenet 影半径模型。 +// LunarEclipseDetailedSVGChauvenet renders a detailed chart with the Chauvenet shadow model. +func LunarEclipseDetailedSVGChauvenet(date time.Time, options LunarEclipseDetailedSVGOptions) (string, bool) { + return lunarEclipseDetailedSVG(date, options, + basic.LunarEclipseDiagramChauvenet, basic.LunarEclipseShadowGeometryChauvenetAt, eclipsecore.ClosestLunarEclipseChauvenet, + eclipsecore.LunarEclipseShadowModelChauvenet) +} + +func normalizeLunarEclipseDetailedSVGOptions(options LunarEclipseDetailedSVGOptions) LunarEclipseDetailedSVGOptions { + if options.Width <= 0 { + options.Width = 1000 + } + if options.Height <= 0 { + options.Height = 1414 + } + if options.Location == nil { + options.Location = time.FixedZone("UTC+8", lunarEclipseSVGDefaultZone) + } + if strings.EqualFold(options.Language, lunarEclipseSVGLanguageEnglish) { + options.Language = lunarEclipseSVGLanguageEnglish + } else { + options.Language = lunarEclipseSVGLanguageChinese + } + return options +} + +func lunarEclipseDetailedSVG( + date time.Time, + options LunarEclipseDetailedSVGOptions, + calculator lunarEclipseSVGCalculator, + geometry func(float64) basic.LunarEclipseShadowGeometry, + finder lunarEclipseSVGFinder, + model eclipsecore.LunarEclipseShadowModel, +) (string, bool) { + options = normalizeLunarEclipseDetailedSVGOptions(options) + diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{ + StepDays: durationToDays(options.Step), + }) + if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 { + return "", false + } + info := lunarEclipseInfoFromBasic(diagram.Eclipse, options.Location) + if finder != nil { + coreInfo := finder(info.Maximum) + info.HasSaros = coreInfo.HasSaros + info.Saros = coreInfo.Saros + } + if !validLunarEclipseDetailedSize(options.Width, options.Height, info, options) { + return "", false + } + return renderLunarEclipseDetailedSVG( + info, diagram, geometry(diagram.Eclipse.Maximum), timeToTTJDE(date), options, model, + ), true +} + +// lunarEclipseDetailedView 汇总 详细版式各区块的位置。 +type lunarEclipseDetailedView struct { + width, height float64 + margin float64 + metaY float64 + blockY float64 + diagramTop float64 + diagramBottom float64 + rowY float64 + rowHeight float64 + mapFrame svgmap.Frame + legendY float64 + legendRows int + footerY float64 + columnWidth, columnGap float64 +} + +// 详细版式的竖向固定量:标题/摘要/地心块占用顶部,示意图与底图分掉剩余高度。 +const ( + lunarEclipseDetailedDiagramTop = 268.0 + lunarEclipseDetailedDiagramHeight = 368.0 + lunarEclipseDetailedPanelHeight = 152.0 + lunarEclipseDetailedPanelGap = 28.0 + lunarEclipseDetailedMapGap = 26.0 + lunarEclipseDetailedLegendGap = 30.0 + lunarEclipseDetailedFooterReserve = 52.0 + lunarEclipseDetailedMinDiagram = 200.0 + lunarEclipseDetailedMinMapHeight = 180.0 + lunarEclipseDetailedMinColumnWidth = 150.0 + // 图例与页脚的字号、页脚基线到画布底边的固定内缩,以及两者之间的最小间隙。 + lunarEclipseDetailedLegendFontSize = 10.0 + lunarEclipseDetailedFooterFontSize = 11.0 + lunarEclipseDetailedFooterInset = 42.0 + lunarEclipseDetailedLegendFooterGap = 4.0 + // 底图可见性图例:字号、图标加文本占位宽、列间隙、行距与页边内缩。 + lunarEclipseDetailedMapLegendFontSize = 10.0 + lunarEclipseDetailedMapLegendIconWidth = 24.0 + lunarEclipseDetailedMapLegendGap = 12.0 + lunarEclipseDetailedMapLegendLineStep = 22.0 + lunarEclipseDetailedPageInset = 20.0 +) + +// lunarEclipseDetailedVerticalBudget 是扣掉顶部固定带、数据面板、图例与页脚后可分给示意图与底图的高度。 +func lunarEclipseDetailedVerticalBudget(height float64) float64 { + return height - (lunarEclipseDetailedDiagramTop + lunarEclipseDetailedPanelGap + + lunarEclipseDetailedPanelHeight + lunarEclipseDetailedMapGap + + lunarEclipseDetailedLegendGap + lunarEclipseDetailedFooterReserve) +} + +// lunarEclipseDetailedMapHeight 给出该画布下底图高度:先保示意图下限,再把剩余高度给底图, +// 底图宽度还要受画布宽度与 2:1 长宽比限制。 +func lunarEclipseDetailedMapHeight(width, height, margin float64) float64 { + budget := lunarEclipseDetailedVerticalBudget(height) + return math.Min((width-2*margin)/2, budget-lunarEclipseDetailedMinDiagram) +} + +// validLunarEclipseDetailedSize 报告详细版式在该画布尺寸下能否容下全部内容: +// 除竖向预算与栏宽外,数据块行距、图例带与页脚也必须互不压叠。 +func validLunarEclipseDetailedSize(width, height int, info LunarEclipseInfo, options LunarEclipseDetailedSVGOptions) bool { + w, h := float64(width), float64(height) + margin := math.Max(30, math.Min(52, w*0.044)) + budget := lunarEclipseDetailedVerticalBudget(h) + mapHeight := lunarEclipseDetailedMapHeight(w, h, margin) + if mapHeight < lunarEclipseDetailedMinMapHeight || budget-mapHeight < lunarEclipseDetailedMinDiagram { + return false + } + columnGap := math.Max(16, w*0.02) + if (w-2*margin-2*columnGap)/3 < lunarEclipseDetailedMinColumnWidth { + return false + } + view := lunarEclipseDetailedViewFor(options) + if solarEclipsePanelsOverlap(lunarEclipseDetailedPanelCells(view, info, options)) { + return false + } + _, legendBelow := svgchart.EstimatedTextExtents(lunarEclipseDetailedMapLegendFontSize) + _, footerBelow := svgchart.EstimatedTextExtents(lunarEclipseDetailedFooterFontSize) + if view.footerY < view.legendY+legendBelow { + return false + } + // 页脚要留在白色页框内,越出画布就没有可读位置了。 + return view.footerY+footerBelow <= h-20 +} + +// lunarEclipseDetailedPanelContent 给出三栏数据块的内容:左栏历时、右栏接触时刻。 +func lunarEclipseDetailedPanelContent( + info LunarEclipseInfo, + options LunarEclipseDetailedSVGOptions, +) (string, []svgchart.PanelRow, string, []svgchart.PanelRow) { + english := options.Language == lunarEclipseSVGLanguageEnglish + duration := func(start, end time.Time) string { + if start.IsZero() || end.IsZero() || !end.After(start) { + return "-" + } + return formatSolarEclipseMapDuration(end.Sub(start)) + } + durationsTitle := "月食历时" + contactsTitle := "接触时刻" + if english { + durationsTitle = "Eclipse durations" + contactsTitle = "Eclipse contacts" + } + durations := []svgchart.PanelRow{ + {Label: "半影食", Value: duration(info.PenumbralStart, info.PenumbralEnd)}, + {Label: "偏食", Value: duration(info.PartialStart, info.PartialEnd)}, + {Label: "全食", Value: duration(info.TotalStart, info.TotalEnd)}, + } + if english { + durations[0].Label, durations[1].Label, durations[2].Label = "Penumbral", "Partial", "Total" + } + contacts := []svgchart.PanelRow{ + {Label: "P1 半影食始", Value: info.PenumbralStart.In(options.Location).Format("15:04:05")}, + {Label: "U1 初亏", Value: info.PartialStart.In(options.Location).Format("15:04:05")}, + {Label: "U2 食既", Value: info.TotalStart.In(options.Location).Format("15:04:05")}, + {Label: "食甚", Value: info.Maximum.In(options.Location).Format("15:04:05")}, + {Label: "U3 生光", Value: info.TotalEnd.In(options.Location).Format("15:04:05")}, + {Label: "U4 复圆", Value: info.PartialEnd.In(options.Location).Format("15:04:05")}, + {Label: "P4 半影食终", Value: info.PenumbralEnd.In(options.Location).Format("15:04:05")}, + } + if english { + contacts = []svgchart.PanelRow{ + {Label: "P1 penumbral begins", Value: info.PenumbralStart.In(options.Location).Format("15:04:05")}, + {Label: "U1 partial begins", Value: info.PartialStart.In(options.Location).Format("15:04:05")}, + {Label: "U2 total begins", Value: info.TotalStart.In(options.Location).Format("15:04:05")}, + {Label: "Greatest", Value: info.Maximum.In(options.Location).Format("15:04:05")}, + {Label: "U3 total ends", Value: info.TotalEnd.In(options.Location).Format("15:04:05")}, + {Label: "U4 partial ends", Value: info.PartialEnd.In(options.Location).Format("15:04:05")}, + {Label: "P4 penumbral ends", Value: info.PenumbralEnd.In(options.Location).Format("15:04:05")}, + } + } + if !info.HasTotal { + contacts[2].Value, contacts[4].Value = "-", "-" + } + if !info.HasPartial { + contacts[1].Value, contacts[5].Value = "-", "-" + } + return durationsTitle, durations, contactsTitle, contacts +} + +// lunarEclipseDetailedPanelCells 给出三栏数据块的矩形与内容:占位与绘制共用同一份几何。 +func lunarEclipseDetailedPanelCells( + view lunarEclipseDetailedView, + info LunarEclipseInfo, + options LunarEclipseDetailedSVGOptions, +) []solarEclipsePanelCell { + durationsTitle, durations, contactsTitle, contacts := lunarEclipseDetailedPanelContent(info, options) + right := view.margin + 2*(view.columnWidth+view.columnGap) + return []solarEclipsePanelCell{ + {title: durationsTitle, rows: durations, box: svgchart.LabelBox{ + X: view.margin, Y: view.rowY, Width: view.columnWidth, Height: view.rowHeight, + }}, + {title: contactsTitle, rows: contacts, box: svgchart.LabelBox{ + X: right, Y: view.rowY, Width: view.columnWidth, Height: view.rowHeight, + }}, + } +} + +func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEclipseDetailedView { + width, height := float64(options.Width), float64(options.Height) + margin := math.Max(30, math.Min(52, width*0.044)) + view := lunarEclipseDetailedView{ + width: width, height: height, margin: margin, + metaY: 88, blockY: 196, + diagramTop: lunarEclipseDetailedDiagramTop, + rowHeight: lunarEclipseDetailedPanelHeight, + } + view.columnGap = math.Max(16, width*0.02) + view.columnWidth = (width - 2*margin - 2*view.columnGap) / 3 + // 竖向是一条带栈:示意图与底图分掉固定带之外的高度;高度不足时两者一起缩, + // 到下限的画布由 validLunarEclipseDetailedSize 提前拒绝,这里不再兜底。 + budget := lunarEclipseDetailedVerticalBudget(height) + mapHeight := lunarEclipseDetailedMapHeight(width, height, margin) + diagramHeight := math.Min(lunarEclipseDetailedDiagramHeight, budget-mapHeight) + view.diagramBottom = view.diagramTop + diagramHeight + view.rowY = view.diagramBottom + lunarEclipseDetailedPanelGap + mapTop := view.rowY + view.rowHeight + lunarEclipseDetailedMapGap + mapWidth := mapHeight * 2 + if limit := width - 2*margin; mapWidth > limit { + mapWidth = limit + mapHeight = mapWidth / 2 + } + view.mapFrame = svgmap.Frame{ + X: (width - mapWidth) / 2, Y: mapTop, Width: mapWidth, Height: mapHeight, + Projection: svgmap.ProjectionEquirectangular, + } + view.legendY = mapTop + mapHeight + lunarEclipseDetailedLegendGap + view.legendRows = len(lunarEclipseMapLegendRows(view.mapFrame, width, options.Language)) + if view.legendRows < 1 { + view.legendRows = 1 + } + // 页脚按图例实测高度往下排:图例带再往下压也不能与页脚同高。 + _, legendBelow := svgchart.EstimatedTextExtents(lunarEclipseDetailedMapLegendFontSize) + footerAbove, _ := svgchart.EstimatedTextExtents(lunarEclipseDetailedFooterFontSize) + footerFloor := view.legendY + float64(view.legendRows-1)*lunarEclipseDetailedMapLegendLineStep + + legendBelow + lunarEclipseDetailedLegendFooterGap + footerAbove + view.footerY = math.Max(height-lunarEclipseDetailedFooterInset, footerFloor) + return view +} + +func renderLunarEclipseDetailedSVG( + info LunarEclipseInfo, + diagram basic.LunarEclipseDiagramResult, + shadow basic.LunarEclipseShadowGeometry, + tt float64, + options LunarEclipseDetailedSVGOptions, + model eclipsecore.LunarEclipseShadowModel, +) string { + view := lunarEclipseDetailedViewFor(options) + english := options.Language == lunarEclipseSVGLanguageEnglish + title := options.Title + if title == "" { + date := info.Maximum.In(options.Location).Format("2006-01-02") + if english { + title = fmt.Sprintf("Total Lunar Eclipse of %s", date) + } else { + title = fmt.Sprintf("%s %s", date, lunarEclipseSVGTypeName(info.Type, "zh")) + } + } + + extent := diagram.PenumbraRadius + diagram.MoonRadius + 0.72 + scale := math.Min( + (view.width-2*view.margin)/(2*extent), + (view.diagramBottom-view.diagramTop)/(2*extent), + ) + if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { + scale = 1 + } + layout := lunarEclipseSVGLayout{ + width: view.width, height: view.height, + cx: view.width / 2, + cy: (view.diagramTop + view.diagramBottom) / 2, + scale: scale, + diagramTop: view.diagramTop, diagramEnd: view.diagramBottom, + diagramLeft: view.margin, diagramRight: view.width - view.margin, + } + // 不动的元素先占位:数据块、底图、图例与页脚;示意图标注只在示意图带内选位置。 + labels := &svgchart.LabelTable{} + for _, cell := range lunarEclipseDetailedPanelCells(view, info, options) { + labels.Reserve(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height) + } + labels.Reserve(view.mapFrame.X, view.mapFrame.Y, view.mapFrame.Width, view.mapFrame.Height) + for _, row := range lunarEclipseMapLegendRows(view.mapFrame, view.width, options.Language) { + for _, item := range row { + labels.ReserveText(item.x, item.y, lunarEclipseDetailedMapLegendFontSize, item.text, "start") + } + } + labels.ReserveText(view.margin, view.footerY, lunarEclipseDetailedFooterFontSize, + lunarEclipseDetailedFooterText(options, model), "start") + + var b strings.Builder + fmt.Fprintf(&b, ``, + options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) + b.WriteString(``) + b.WriteString(svgasset.MoonFaceSymbol()) + writeLunarEclipseMapDefinitions(&b, view.mapFrame, info) + b.WriteString(``) + b.WriteString(``) + fmt.Fprintf(&b, ``, + view.width-44, view.height-36) + titleText := title + if options.Title != "" { + titleText = svgchart.EllipsizeText(title, view.width-52, 25) + } + fmt.Fprintf(&b, `%s`, + view.width/2, html.EscapeString(titleText)) + + writeLunarEclipseDetailedSummary(&b, labels, info, shadow, view, options) + writeLunarEclipseDetailedGeocentricBlocks(&b, labels, tt, view, options) + writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels) + writeLunarEclipseDetailedPanels(&b, info, shadow, scale, view, options) + writeLunarEclipseMapRegions(&b, view.mapFrame, info) + view.mapFrame.WriteFrame(&b) + writeLunarEclipseMapLegend(&b, view.mapFrame, view.width, options.Language) + writeLunarEclipseDetailedFooter(&b, view, options, model) + b.WriteString(``) + return b.String() +} + +// writeLunarEclipseDetailedSummary 写标题下居中的四行摘要。 +func writeLunarEclipseDetailedSummary( + b *strings.Builder, + labels *svgchart.LabelTable, + info LunarEclipseInfo, + shadow basic.LunarEclipseShadowGeometry, + view lunarEclipseDetailedView, + options LunarEclipseDetailedSVGOptions, +) { + english := options.Language == lunarEclipseSVGLanguageEnglish + zone, _ := info.Maximum.In(options.Location).Zone() + lines := make([]string, 0, 4) + if english { + lines = append(lines, fmt.Sprintf("Greatest Eclipse = %s (%s) | Umbral magnitude = %.4f", + info.Maximum.In(options.Location).Format("15:04:05"), zone, info.UmbralMagnitude)) + lines = append(lines, fmt.Sprintf("Penumbral magnitude = %.4f | Gamma = %.4f", + info.PenumbralMagnitude, shadow.Gamma)) + lines = append(lines, fmt.Sprintf("P. Radius = %.4f° | U. Radius = %.4f° | Axis = %.4f°", + shadow.PenumbralRadiusDegrees, shadow.UmbralRadiusDegrees, shadow.AxisDegrees)) + lines = append(lines, fmt.Sprintf("Saros series = %d | member %d of %d", info.Saros.Series, info.Saros.Member, info.Saros.Count)) + } else { + lines = append(lines, fmt.Sprintf("食甚 = %s(%s)| 本影食分 = %.4f", + info.Maximum.In(options.Location).Format("15:04:05"), zone, info.UmbralMagnitude)) + lines = append(lines, fmt.Sprintf("半影食分 = %.4f | 伽马 = %.4f", info.PenumbralMagnitude, shadow.Gamma)) + lines = append(lines, fmt.Sprintf("半影半径 = %.4f° | 本影半径 = %.4f° | 影轴角距 = %.4f°", + shadow.PenumbralRadiusDegrees, shadow.UmbralRadiusDegrees, shadow.AxisDegrees)) + lines = append(lines, fmt.Sprintf("沙罗序列 = %d | 第 %d / %d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)) + } + if !info.HasSaros { + lines[len(lines)-1] = "" + } + for index, line := range lines { + if line == "" { + continue + } + labels.ReserveText(view.width/2, view.metaY+float64(index)*22, 12.5, line, "middle") + fmt.Fprintf(b, `%s`, + view.width/2, view.metaY+float64(index)*22, html.EscapeString(line)) + } + zoneNote := "图中时刻为 UT" + if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 { + zoneNote = fmt.Sprintf("图中时刻为 %s(UT%s)", zone, formatZoneOffset(offset)) + } + if english { + zoneNote = "All times are UT" + if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 { + zoneNote = fmt.Sprintf("All times are %s (UT%s)", zone, formatZoneOffset(offset)) + } + } + labels.ReserveText(view.width/2, view.metaY+4*22, 11, zoneNote, "middle") + fmt.Fprintf(b, `%s`, + view.width/2, view.metaY+4*22, html.EscapeString(zoneNote)) +} + +func zoneOffsetSeconds(value time.Time) int { + _, offset := value.Zone() + return offset +} + +func formatZoneOffset(offsetSeconds int) string { + sign := "+" + if offsetSeconds < 0 { + sign = "-" + offsetSeconds = -offsetSeconds + } + return fmt.Sprintf("%s%02d:%02d", sign, offsetSeconds/3600, (offsetSeconds%3600)/60) +} + +// writeLunarEclipseDetailedGeocentricBlocks 在球面两侧画日月地心坐标块。 +func writeLunarEclipseDetailedGeocentricBlocks( + b *strings.Builder, + labels *svgchart.LabelTable, + tt float64, + view lunarEclipseDetailedView, + options LunarEclipseDetailedSVGOptions, +) { + english := options.Language == lunarEclipseSVGLanguageEnglish + titles := [2]string{"食甚时的太阳(地心坐标)", "食甚时的月亮(地心坐标)"} + if english { + titles = [2]string{"Sun at greatest eclipse", "Moon at greatest eclipse"} + } + sunRa, sunDec := basic.SunApparentRaDec(tt) + moonRa, moonDec := basic.HMoonTrueRaDec(tt) + sunSd := basic.SunSemidiameter(tt) + moonSd := basic.MoonSemidiameter(tt) + blocks := [2][]svgchart.PanelRow{ + { + {Label: "赤经 R.A.", Value: formatSolarEclipseRA(sunRa)}, + {Label: "赤纬 Dec.", Value: formatSolarEclipseDec(sunDec)}, + {Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(sunSd)}, + {Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(sunSd, 6378.137/696000.0))}, + }, + { + {Label: "赤经 R.A.", Value: formatSolarEclipseRA(moonRa)}, + {Label: "赤纬 Dec.", Value: formatSolarEclipseDec(moonDec)}, + {Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(moonSd)}, + {Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(moonSd, 6378.137/1737.4))}, + }, + } + if english { + for index := range blocks { + blocks[index][3].Label = "H.P." + } + } + blockWidth := math.Min(212, (view.width-2*view.margin-40)/2) + for index := 0; index < 2; index++ { + x := view.margin + if index == 1 { + x = view.width - view.margin - blockWidth + } + fmt.Fprintf(b, ``) + labels.ReserveText(x, view.blockY+11, 12, titles[index], "start") + fmt.Fprintf(b, `%s`, + x, view.blockY+11, html.EscapeString(titles[index])) + for rowIndex, row := range blocks[index] { + rowY := view.blockY + 30 + float64(rowIndex)*17 + labels.ReserveText(x, rowY, 10.5, row.Label, "start") + labels.ReserveText(x+blockWidth, rowY, 10.5, row.Value, "end") + fmt.Fprintf(b, `%s`, + x, rowY, html.EscapeString(row.Label)) + fmt.Fprintf(b, `%s`, + x+blockWidth, rowY, html.EscapeString(row.Value)) + } + b.WriteString(``) + } +} + +// horizontalParallaxArcsecRatio 由视半径换算地平视差:sin(HP) = sin(SD) × R⊕ / R天体。 +func horizontalParallaxArcsecRatio(semidiameterArcsec, radiusRatio float64) float64 { + sine := math.Sin(semidiameterArcsec / 3600 * math.Pi / 180) + return math.Asin(math.Max(-1, math.Min(1, sine*radiusRatio))) * 180 / math.Pi * 3600 +} + +// writeLunarEclipseDetailedPanels 写三栏:历时、弧分比例尺、接触时刻。 +func writeLunarEclipseDetailedPanels( + b *strings.Builder, + info LunarEclipseInfo, + shadow basic.LunarEclipseShadowGeometry, + diagramScale float64, + view lunarEclipseDetailedView, + options LunarEclipseDetailedSVGOptions, +) { + for _, cell := range lunarEclipseDetailedPanelCells(view, info, options) { + writeLunarEclipseDetailedPanel(b, cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height, cell.title, cell.rows) + } + writeLunarEclipseArcMinuteScaleBar(b, view, shadow, diagramScale) +} + +func writeLunarEclipseDetailedPanel( + b *strings.Builder, + x, y, width, height float64, + title string, + rows []svgchart.PanelRow, +) { + svgchart.WritePanelBox(b, "lunar-detailed-panel", x, y, width, height, title, rows) +} + +// writeLunarEclipseArcMinuteScaleBar 画穿影示意图的弧分比例尺。 +// 示意图以"月球半径"为单位,一个月球半径对应真实角径 asin(1/月距),据此换算弧分。 +func writeLunarEclipseArcMinuteScaleBar( + b *strings.Builder, + view lunarEclipseDetailedView, + shadow basic.LunarEclipseShadowGeometry, + diagramScale float64, +) { + if shadow.MoonDistanceEarthRadii <= 0 || diagramScale <= 0 { + return + } + // 示意图以"月球半径"为单位,一个月球半径对应的真实角径是 asin(R月 / 月距),不是地球视差。 + moonSemidiameterArcmin := math.Asin((lunarMoonRadiusKM/lunarEarthRadiusKM)/shadow.MoonDistanceEarthRadii) * 180 / math.Pi * 60 + if moonSemidiameterArcmin <= 0 { + return + } + pixelsPerArcmin := diagramScale / moonSemidiameterArcmin + target := 150.0 + step := math.Pow(10, math.Floor(math.Log10(target/pixelsPerArcmin))) + for _, multiplier := range []float64{1, 2, 5, 10} { + if step*multiplier >= target/pixelsPerArcmin { + step *= multiplier + break + } + } + barWidth := step * pixelsPerArcmin + centreX := view.margin + view.columnWidth + view.columnGap + view.columnWidth/2 + y := view.rowY + view.rowHeight/2 + fmt.Fprintf(b, ``, + centreX-barWidth/2, y, centreX+barWidth/2, y) + for _, tick := range []float64{0, 0.25, 0.5, 0.75, 1} { + x := centreX - barWidth/2 + tick*barWidth + length := 5.0 + if tick == 0 || tick == 1 { + length = 9 + } + fmt.Fprintf(b, ``, + x, y-length/2, x, y+length/2) + } + fmt.Fprintf(b, `0`, + centreX-barWidth/2, y-10) + fmt.Fprintf(b, `%.0f`, + centreX+barWidth/2, y-10, step) + fmt.Fprintf(b, `角分`, + centreX, y+22) + b.WriteString(``) +} + +// lunarEclipseDetailedFooterText 是页脚说明:默认模型会在极浅半影上回退 Chauvenet,必须写出实际用的那套。 +func lunarEclipseDetailedFooterText(options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel) string { + if options.FooterNote != "" { + return options.FooterNote + } + if options.Language == lunarEclipseSVGLanguageEnglish { + return "Shadow-path diagram uses the " + lunarEclipseShadowModelName(model) + " shadow model; the lower map marks where the whole eclipse is visible, where the Moon rises or sets eclipsed, and where it is not visible. Natural Earth 1:50m physical land, no administrative boundaries." + } + return "穿影示意图使用 " + lunarEclipseShadowModelName(model) + " 影半径模型;下方底图区分全程可见、带食月出、带食月落与不可见四类区域。Natural Earth 1:50m 物理陆地底图,不含行政边界。" +} + +func writeLunarEclipseDetailedFooter( + b *strings.Builder, + view lunarEclipseDetailedView, + options LunarEclipseDetailedSVGOptions, + model eclipsecore.LunarEclipseShadowModel, +) { + // 页脚按页宽折行、按画布底边截断,首行位置不变;默认说明放得下时折行结果仍是同一行。 + maxWidth := view.width - 2*view.margin + lines := svgchart.TruncateTextLines( + svgchart.WrapText(lunarEclipseDetailedFooterText(options, model), maxWidth, lunarEclipseDetailedFooterFontSize), + maxWidth, lunarEclipseDetailedFooterFontSize, + svgchart.BaselineLineLimit(lunarEclipseDetailedFooterFontSize, 15, view.footerY, view.height-4)) + for index, line := range lines { + fmt.Fprintf(b, `%s`, + view.margin, view.footerY+float64(index)*15, lunarEclipseDetailedFooterFontSize, html.EscapeString(line)) + } +} + +const ( + // lunarMoonRadiusKM 与 lunarEarthRadiusKM 只用于把示意图的"月球半径"刻度换算成角分比例尺。 + lunarMoonRadiusKM = 1737.4 + lunarEarthRadiusKM = 6378.137 +) diff --git a/eclipse/svg/lunar_detailed_layout_test.go b/eclipse/svg/lunar_detailed_layout_test.go new file mode 100644 index 0000000..39ee704 --- /dev/null +++ b/eclipse/svg/lunar_detailed_layout_test.go @@ -0,0 +1,44 @@ +package svg + +import ( + "testing" + "time" +) + +// 详细版式必须按 Height 推导版面:放得下的尺寸不得有任何 text/rect 越界,放不下的尺寸必须明确拒绝。 +func TestLunarEclipseDetailedSVGLayoutFitsCanvas(t *testing.T) { + date := time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + width, height int + accepted bool + }{ + {name: "small landscape", width: 640, height: 420}, + {name: "small landscape 800", width: 800, height: 600}, + {name: "nasa page", width: 1000, height: 1414, accepted: true}, + {name: "wide page", width: 1414, height: 1000, accepted: true}, + } { + t.Run(test.name, func(t *testing.T) { + rendered, ok := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{ + Width: test.width, Height: test.height, Location: time.UTC, + }) + if ok != test.accepted { + t.Fatalf("%dx%d accepted = %v, want %v", test.width, test.height, ok, test.accepted) + } + if !test.accepted { + if rendered != "" { + t.Fatalf("%dx%d rejected the canvas but returned %d bytes", test.width, test.height, len(rendered)) + } + return + } + assertSVGElementsInCanvas(t, rendered, test.width, test.height) + }) + } + + // 零值走文档默认开本,版式必须落在画布内。 + defaulted, ok := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{Location: time.UTC}) + if !ok { + t.Fatal("default canvas was rejected") + } + assertSVGElementsInCanvas(t, defaulted, 1000, 1414) +} diff --git a/eclipse/svg/lunar_detailed_test.go b/eclipse/svg/lunar_detailed_test.go new file mode 100644 index 0000000..9b8a33b --- /dev/null +++ b/eclipse/svg/lunar_detailed_test.go @@ -0,0 +1,70 @@ +package svg + +import ( + "math" + "strings" + "testing" + "time" + + "b612.me/astro/basic" +) + +// 详细版式组合图必须把摘要、穿影示意图与世界可见性底图放在同一页,并带日月地心坐标块与三栏面板。 +func TestLunarEclipseDetailedSVGCombinesBothCharts(t *testing.T) { + date := time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC) + for _, language := range []string{"zh", "en"} { + rendered, ok := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{ + Language: language, Location: time.UTC, + }) + if !ok { + t.Fatalf("%s: missing chart", language) + } + shared := []string{ + `class="lunar-geocentric-block"`, // 日月地心坐标块 + `class="lunar-detailed-panel"`, // 历时/接触/……面板 + `class="lunar-scale-bar"`, // 弧分比例尺 + `class="lunar-visibility-regions"`, // 可见性区域 + `class="entire-eclipse-region"`, // 全程可见区 + ` 0.001 { + t.Fatalf("gamma = %.4f, want 0.3481", geometry.Gamma) + } + if math.Abs(geometry.PenumbralRadiusDegrees-1.1899) > 0.001 { + t.Fatalf("penumbral radius = %.4f, want 1.1899", geometry.PenumbralRadiusDegrees) + } + if math.Abs(geometry.UmbralRadiusDegrees-0.6537) > 0.001 { + t.Fatalf("umbral radius = %.4f, want 0.6537", geometry.UmbralRadiusDegrees) + } + if math.Abs(result.Magnitude-1.1784) > 0.001 { + t.Fatalf("umbral magnitude = %.4f, want 1.1784", result.Magnitude) + } + if math.Abs(result.PenumbralMagnitude-2.2595) > 0.001 { + t.Fatalf("penumbral magnitude = %.4f, want 2.2595", result.PenumbralMagnitude) + } +} diff --git a/eclipse/svg/lunar_map.go b/eclipse/svg/lunar_map.go index 19553d5..64fd95b 100644 --- a/eclipse/svg/lunar_map.go +++ b/eclipse/svg/lunar_map.go @@ -9,6 +9,8 @@ import ( "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/lunarhorizon" + "b612.me/astro/internal/svgchart" "b612.me/astro/internal/svgmap" ) @@ -101,8 +103,6 @@ func renderLunarEclipseMapSVG( projection svgmap.Projection, ) string { frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 92) - startPath, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame) - endPath, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame) title := options.Title if title == "" { date := info.Maximum.In(options.Location).Format("2006-01-02") @@ -117,32 +117,22 @@ func renderLunarEclipseMapSVG( fmt.Fprintf(&builder, ``, options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) builder.WriteString(``) - builder.WriteString(frame.ClipDefinition("lunar-map-clip")) - fmt.Fprintf(&builder, ``, startPath) - fmt.Fprintf(&builder, ``, endPath) - builder.WriteString(``) - writeLunarEclipseVisibilityMasks(&builder, frame) + writeLunarEclipseMapDefinitions(&builder, frame, info) builder.WriteString(``) builder.WriteString(``) fmt.Fprintf(&builder, ``, options.Width-44, options.Height-36) + titleText := title + if options.Title != "" { + titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24) + } fmt.Fprintf(&builder, `%s`, - float64(options.Width)/2, html.EscapeString(title)) + float64(options.Width)/2, html.EscapeString(titleText)) writeLunarEclipseMapSummary(&builder, info, options) - frame.WriteOcean(&builder) - frame.WriteGraticule(&builder, "lunar-map-clip") - frame.WriteLand(&builder, "lunar-map-clip") - fmt.Fprintf(&builder, ``) - writeLunarEclipseUnavailableRegion(&builder, frame) - builder.WriteString(``) - builder.WriteString(``) - builder.WriteString(``) - builder.WriteString(``) - writeEclipseMapGeoLine(&builder, frame, startBoundary, "p1-horizon", "#a56c16", 1.2, "4 3", "lunar-map-clip") - writeEclipseMapGeoLine(&builder, frame, endBoundary, "p4-horizon", "#197a82", 1.2, "4 3", "lunar-map-clip") + writeLunarEclipseMapRegions(&builder, frame, info) frame.WriteFrame(&builder) - writeLunarEclipseMapLegend(&builder, frame, options.Language) + writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language) writeLunarEclipseMapFooter(&builder, frame, options, projection) builder.WriteString(``) return builder.String() @@ -190,35 +180,58 @@ func eclipseMapFrame(width, height int, projection svgmap.Projection, top, botto } func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) { - center := lunarEclipseSubpoint(value) - boundary := svgmap.SphericalCircle(center, 90, 360) - path := lunarEclipseVisibilityPathForCenter(center, frame) - closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0]) - return path, closedBoundary -} - -func lunarEclipseVisibilityPathForCenter(center svgmap.GeoPoint, frame svgmap.Frame) string { - var builder strings.Builder - for _, polygon := range svgmap.VisibleHemispherePolygons(center, frame.Projection, 360) { - appendEclipseMapPolygonPath(&builder, frame, polygon) + points := basic.MoonHorizon(basic.Date2JDE(value.UTC()), 360) + boundary := make([]svgmap.GeoPoint, len(points)) + for index, point := range points { + boundary[index] = svgmap.GeoPoint{Longitude: point[0], Latitude: point[1]} } - return builder.String() + if frame.Projection == svgmap.ProjectionEquirectangular { + // 等距圆柱投影在高纬把经度拉伸:1° 的球面地平弧在极点附近可以横跨近 180° 经度, + // 只连端点会在极区切出地平线以外的假可见帽(1904-09-24 实测 −0.054°,约 6 km)。 + // 极射投影没有这个畸变,保持原输出。 + // Equirectangular projection stretches longitude near a pole: a 1-degree spherical + // horizon arc can span almost 180 degrees there, so joining endpoints alone cuts a fake + // visible cap outside the horizon (measured -0.054 deg, about 6 km, on 1904-09-24). + // Polar projections have no such distortion and keep their existing output. + // 门限取画布上约 0.03 像素:极区细分再多也画不出来,而 GeoJSON 用的 0.002° + // 在 812 像素宽的世界图上相当于 0.005 像素,会让 SVG 无谓地大三分之一。 + // The tolerance is about 0.03 pixel on the canvas: finer subdivision cannot be drawn, + // while the GeoJSON tolerance of 0.002 deg is 0.005 pixel on this 812-pixel-wide world + // map and would inflate the SVG by a third for nothing. + tolerance := 0.03 * 360 / frame.Width + if tolerance < lunarhorizon.DefaultToleranceDegrees { + tolerance = lunarhorizon.DefaultToleranceDegrees + } + boundary = lunarhorizon.RefineWithin(boundary, value, tolerance) + } + var builder strings.Builder + for _, fragment := range svgmap.PolygonFragments(boundary, frame.Clip()) { + if frame.IsPolar() { + fragment = lunarEclipsePolarHorizonClosure(fragment) + } + appendEclipseMapPolygonPathConsistent(&builder, frame, fragment) + } + closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0]) + return builder.String(), closedBoundary } -func lunarEclipseVisibilityPolygons( - center svgmap.GeoPoint, - projection svgmap.Projection, - samples int, -) [][]svgmap.GeoPoint { - return svgmap.VisibleHemispherePolygons(center, projection, samples) -} - -func lunarEclipseSubpoint(value time.Time) svgmap.GeoPoint { - ttJDE := timeToTTJDE(value) - ra, dec := basic.HMoonTrueRaDec(ttJDE) - utJDE := basic.TD2UT(ttJDE, false) - longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15) - return svgmap.GeoPoint{Longitude: longitude, Latitude: dec} +func lunarEclipsePolarHorizonClosure(points []svgmap.GeoPoint) []svgmap.GeoPoint { + var result []svgmap.GeoPoint + for index, point := range points { + result = append(result, point) + next := points[(index+1)%len(points)] + if math.Abs(point.Latitude) > 1e-9 || math.Abs(next.Latitude) > 1e-9 { + continue + } + // The equatorial clipping edge is an arc on a polar map, not a chord. + // The Moon-visible part of the equator is always the minor arc. + delta := math.Remainder(next.Longitude-point.Longitude, 360) + steps := int(math.Ceil(math.Abs(delta))) + for step := 1; step < steps; step++ { + result = append(result, svgmap.GeoPoint{Longitude: point.Longitude + delta*float64(step)/float64(steps)}) + } + } + return result } func appendEclipseMapPolygonPath(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) { @@ -245,9 +258,9 @@ func writeEclipseMapGeoLine( points []svgmap.GeoPoint, className, color string, strokeWidth float64, - dash, clipID string, + dash, clipID, dataSource string, ) { - for _, segment := range svgmap.PolylineSegments(points, frame.Projection) { + for _, segment := range svgmap.PolylineSegments(points, frame.Clip()) { if len(segment) < 2 { continue } @@ -263,8 +276,12 @@ func writeEclipseMapGeoLine( } fmt.Fprintf(builder, `%s %.3f %.3f `, command, x, y) } - fmt.Fprintf(builder, `" clip-path="url(#%s)" fill="none" stroke="%s" stroke-width="%.2f" stroke-dasharray="%s" stroke-linecap="round"/>`, - clipID, color, strokeWidth, dash) + source := "" + if dataSource != "" { + source = ` data-source="` + html.EscapeString(dataSource) + `"` + } + fmt.Fprintf(builder, `" clip-path="url(#%s)"%s fill="none" stroke="%s" stroke-width="%.2f" stroke-dasharray="%s" stroke-linecap="round"/>`, + clipID, source, color, strokeWidth, dash) } } @@ -298,19 +315,84 @@ func writeLunarEclipseMapSummary(builder *strings.Builder, info eclipsecore.Luna float64(options.Width)/2, html.EscapeString(text)) } -func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, language string) { +// lunarEclipseMapLegendRows 给出可见性图例的标注位次:每行按实测文本宽度排布并居中在地图框下方。 +func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language string) [][]solarEclipseCardinalLabel { labels := []string{"全程可见", "带食月出", "带食月落", "不可见"} - colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"} if language == "en" { labels = []string{"Entire eclipse", "Moonrise during eclipse", "Moonset during eclipse", "Not visible"} } - y := frame.Y + frame.Height + 31 - itemWidth := frame.Width / 4 - for index, label := range labels { - x := frame.X + float64(index)*itemWidth - fmt.Fprintf(builder, ``, x, y-8, colors[index]) - fmt.Fprintf(builder, `%s`, - x+24, y, html.EscapeString(label)) + width := func(text string) float64 { + return lunarEclipseDetailedMapLegendIconWidth + + svgchart.EstimatedTextWidth(text, lunarEclipseDetailedMapLegendFontSize) + } + available := canvasWidth - 2*lunarEclipseDetailedPageInset + packed := make([][]string, 0, 2) + current := make([]string, 0, len(labels)) + used := 0.0 + for _, label := range labels { + item := width(label) + next := item + if len(current) > 0 { + next = used + lunarEclipseDetailedMapLegendGap + item + if next > available { + packed = append(packed, current) + current = make([]string, 0, len(labels)) + next = item + } + } + current = append(current, label) + used = next + } + if len(current) > 0 { + packed = append(packed, current) + } + centreX := frame.X + frame.Width/2 + baseY := frame.Y + frame.Height + 31 + rows := make([][]solarEclipseCardinalLabel, 0, len(packed)) + for index, row := range packed { + total := 0.0 + for position, label := range row { + if position > 0 { + total += lunarEclipseDetailedMapLegendGap + } + total += width(label) + } + x := centreX - total/2 + if x < lunarEclipseDetailedPageInset { + x = lunarEclipseDetailedPageInset + } + if limit := canvasWidth - lunarEclipseDetailedPageInset - total; x > limit { + x = limit + } + entries := make([]solarEclipseCardinalLabel, 0, len(row)) + for _, label := range row { + entries = append(entries, solarEclipseCardinalLabel{ + text: label, + x: x + lunarEclipseDetailedMapLegendIconWidth, + y: baseY + float64(index)*lunarEclipseDetailedMapLegendLineStep, + }) + x += width(label) + lunarEclipseDetailedMapLegendGap + } + rows = append(rows, entries) + } + return rows +} + +func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, canvasWidth float64, language string) { + colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"} + index := 0 + for _, row := range lunarEclipseMapLegendRows(frame, canvasWidth, language) { + for _, entry := range row { + color := colors[0] + if index < len(colors) { + color = colors[index] + } + fmt.Fprintf(builder, ``, + entry.x-lunarEclipseDetailedMapLegendIconWidth, entry.y-8, color) + fmt.Fprintf(builder, `%s`, + entry.x, entry.y, lunarEclipseDetailedMapLegendFontSize, html.EscapeString(entry.text)) + index++ + } } } @@ -328,8 +410,18 @@ func writeLunarEclipseMapFooter( text = eclipseMapProjectionLabel(projection, "zh") + ";按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。" } } - fmt.Fprintf(builder, `%s`, - frame.X, float64(options.Height)-38, html.EscapeString(text)) + baseline := float64(options.Height) - 38 + // 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。 + lines := []string{text} + if options.FooterNote != "" { + maxWidth := float64(options.Width) - frame.X - 24 + lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 11), maxWidth, 11, + svgchart.BaselineLineLimit(11, 15, baseline, float64(options.Height)-4)) + } + for index, line := range lines { + fmt.Fprintf(builder, `%s`, + frame.X, baseline+float64(index)*15, html.EscapeString(line)) + } } func normalizeDegree180(value float64) float64 { @@ -339,3 +431,35 @@ func normalizeDegree180(value float64) float64 { } return value - 180 } + +// writeLunarEclipseMapDefinitions 输出四类可见性区域所需的形状与掩膜定义。 +// 独立全球图和 详细版式组合图共用,形状按传入的图框重建。 +func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) { + b.WriteString(frame.ClipDefinition("lunar-map-clip")) + startPath, _ := lunarEclipseVisibilityPath(info.PenumbralStart, frame) + endPath, _ := lunarEclipseVisibilityPath(info.PenumbralEnd, frame) + maximumPath, _ := lunarEclipseVisibilityPath(info.Maximum, frame) + fmt.Fprintf(b, ``, startPath) + fmt.Fprintf(b, ``, endPath) + fmt.Fprintf(b, ``, maximumPath) + b.WriteString(``) + b.WriteString(``) + writeLunarEclipseVisibilityMasks(b, frame) +} + +// writeLunarEclipseMapRegions 画底图与四类可见性区域,末尾补两条地平边界线。 +func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) { + _, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame) + _, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame) + frame.WriteOcean(b) + frame.WriteGraticule(b, "lunar-map-clip") + frame.WriteLand(b, "lunar-map-clip") + fmt.Fprintf(b, ``, eclipseMapSourceLunarVisibilityRegions) + writeLunarEclipseUnavailableRegion(b, frame) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + writeEclipseMapGeoLine(b, frame, startBoundary, "p1-horizon", "#a56c16", 1.2, "4 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries) + writeEclipseMapGeoLine(b, frame, endBoundary, "p4-horizon", "#197a82", 1.2, "4 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries) +} diff --git a/eclipse/svg/lunar_map_polar_test.go b/eclipse/svg/lunar_map_polar_test.go new file mode 100644 index 0000000..460a865 --- /dev/null +++ b/eclipse/svg/lunar_map_polar_test.go @@ -0,0 +1,70 @@ +package svg + +import ( + "testing" + "time" + + "b612.me/astro/basic" + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgmap" +) + +// TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility 固定全球月食地图在 +// 极区的可见性契约。等距圆柱投影在高纬把经度拉伸:1° 的球面地平弧在极点附近可以横跨近 +// 180° 经度,只连端点就会把地平线以外画进可见区。1904-09-24 的 P1 在南极点附近有一个 +// 月心高度约 −0.05° 的见证点,曾经被画进 visible-at-p1 多边形。 +// TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility pins the polar +// visibility contract of the global lunar-eclipse map. Equirectangular projection stretches +// longitude near a pole: a 1-degree spherical horizon arc can span almost 180 degrees there, so +// joining endpoints alone paints sky below the horizon as visible. At P1 of 1904-09-24 a witness +// near the south pole sits about 0.05 degrees below the Moon's horizon and used to be painted +// inside the visible-at-p1 polygon. +func TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility(t *testing.T) { + date := time.Date(1904, 9, 24, 0, 0, 0, 0, time.UTC) + info, ok := eclipsecore.LunarEclipseOnDate(date) + if !ok { + t.Fatal("missing lunar eclipse") + } + frame := eclipseMapFrame(lunarEclipseMapDefaultWidth, lunarEclipseMapDefaultHeight, + svgmap.ProjectionEquirectangular, 142, 92) + path, boundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame) + if len(boundary) == 0 { + t.Fatal("visibility path has no boundary") + } + rings := svgClosedPathRings(t, ``) + if len(rings) == 0 { + t.Fatal("visibility path has no rings") + } + jd := basic.Date2JDE(info.PenumbralStart) + // 只取足够深入地平线以下的见证点:−89.9° 处月心高度约 −0.054°,而更靠近极点的 + // −89.99° 只有约 −0.004°,落在可见性判定本身的数值容差里,不能作为回归依据。 + // Only a witness clearly below the horizon is used: at -89.9 degrees the Moon centre sits + // about 0.054 degrees down, while -89.99 degrees is only about 0.004 degrees down and lies + // inside the numerical tolerance of the visibility test itself. + witnesses := []struct { + longitude float64 + latitude float64 + }{ + {-115, -89.9}, + } + for _, witness := range witnesses { + altitude := basic.HMoonHeight(jd, witness.longitude, witness.latitude, 0) + if altitude >= -0.01 { + // 见证点必须先确实是地平线以下,否则这个回归就失去意义。 + t.Fatalf("witness (%g,%g) altitude=%g, want below the horizon", + witness.longitude, witness.latitude, altitude) + } + x, y, projectable := frame.Project(witness.longitude, witness.latitude) + if !projectable { + continue + } + inside := false + for _, ring := range rings { + inside = inside || pointInLunarVisibilityPolygon(x, y, ring) + } + if inside { + t.Fatalf("below-horizon witness (%g,%g) altitude=%g is painted inside the visible region", + witness.longitude, witness.latitude, altitude) + } + } +} diff --git a/eclipse/svg/lunar_map_test.go b/eclipse/svg/lunar_map_test.go index 57180f8..622954d 100644 --- a/eclipse/svg/lunar_map_test.go +++ b/eclipse/svg/lunar_map_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "b612.me/astro/basic" "b612.me/astro/internal/svgmap" ) @@ -59,6 +60,9 @@ func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) { if strings.Contains(diagram, `class="entire-eclipse-region"`) && strings.Contains(diagram, `fill-opacity="0.70"`) { t.Fatal("entire-eclipse region still uses the opaque stacked-overlay style") } + if !strings.Contains(diagram, `clipPath id="lunar-visible-maximum"`) { + t.Fatal("entire-eclipse region is not constrained by greatest-eclipse visibility") + } } func TestLunarEclipseMapSVGSupportsForcedPolarProjection(t *testing.T) { @@ -79,118 +83,45 @@ func TestLunarEclipseMapSVGSupportsForcedPolarProjection(t *testing.T) { } } -func TestLunarEclipseVisibilityPolygonsContainOnlyVisibleHemisphere(t *testing.T) { - tests := []struct { - name string - projection svgmap.Projection - center svgmap.GeoPoint - visible svgmap.GeoPoint - hidden svgmap.GeoPoint - }{ - { - name: "equirectangular across antimeridian", - projection: svgmap.ProjectionEquirectangular, - center: svgmap.GeoPoint{Longitude: 170, Latitude: 12}, - visible: svgmap.GeoPoint{Longitude: 170, Latitude: 12}, - hidden: svgmap.GeoPoint{Longitude: -10, Latitude: -12}, - }, - { - name: "north polar center inside projection", - projection: svgmap.ProjectionNorthPolar, - center: svgmap.GeoPoint{Longitude: 30, Latitude: 20}, - visible: svgmap.GeoPoint{Longitude: 30, Latitude: 80}, - hidden: svgmap.GeoPoint{Longitude: -150, Latitude: 10}, - }, - { - name: "north polar center outside projection", - projection: svgmap.ProjectionNorthPolar, - center: svgmap.GeoPoint{Longitude: 30, Latitude: -20}, - visible: svgmap.GeoPoint{Longitude: 30, Latitude: 10}, - hidden: svgmap.GeoPoint{Longitude: 30, Latitude: 90}, - }, - { - name: "south polar center inside projection", - projection: svgmap.ProjectionSouthPolar, - center: svgmap.GeoPoint{Longitude: -45, Latitude: -20}, - visible: svgmap.GeoPoint{Longitude: -45, Latitude: -80}, - hidden: svgmap.GeoPoint{Longitude: 135, Latitude: -10}, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - frame := svgmap.Frame{Width: 360, Height: 360, Projection: test.projection} - polygons := lunarEclipseVisibilityPolygons(test.center, test.projection, 360) - if len(polygons) == 0 { - t.Fatal("visibility polygon is empty") - } - if !projectedPointInLunarVisibility(frame, polygons, test.visible) { - t.Fatalf("visible point %#v is outside the rendered region", test.visible) - } - if projectedPointInLunarVisibility(frame, polygons, test.hidden) { - t.Fatalf("hidden point %#v is inside the rendered region", test.hidden) - } - }) - } -} - -func TestLunarEclipseVisibilityPathDoesNotUseTriangleFan(t *testing.T) { - for _, projection := range []svgmap.Projection{ - svgmap.ProjectionEquirectangular, - svgmap.ProjectionNorthPolar, - svgmap.ProjectionSouthPolar, +func TestLunarEclipseVisibilityPathMatchesTopocentricHorizon(t *testing.T) { + for _, at := range []time.Time{ + time.Date(2026, 3, 3, 9, 0, 0, 0, time.UTC), + time.Date(2025, 9, 7, 16, 0, 0, 0, time.UTC), + time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC), } { - frame := svgmap.Frame{Width: 720, Height: 360, Projection: projection} - if projection != svgmap.ProjectionEquirectangular { - frame.Width = 360 - } - path := lunarEclipseVisibilityPathForCenter( - svgmap.GeoPoint{Longitude: 170, Latitude: 12}, frame, - ) - if subpaths := strings.Count(path, "M "); subpaths != 1 { - t.Fatalf("%s visibility path has %d subpaths, want one continuous outline", projection, subpaths) - } - if strings.Contains(path, "NaN") || strings.Contains(path, "Inf") { - t.Fatalf("%s visibility path contains a non-finite coordinate", projection) - } - } -} - -func TestLunarEclipseVisibilityPolygonsMatchSphericalHorizon(t *testing.T) { - tests := []struct { - projection svgmap.Projection - center svgmap.GeoPoint - }{ - {svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: 170, Latitude: 18}}, - {svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: -170, Latitude: -18}}, - {svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: 170, Latitude: 0}}, - {svgmap.ProjectionNorthPolar, svgmap.GeoPoint{Longitude: 35, Latitude: 18}}, - {svgmap.ProjectionNorthPolar, svgmap.GeoPoint{Longitude: 35, Latitude: -18}}, - {svgmap.ProjectionSouthPolar, svgmap.GeoPoint{Longitude: -70, Latitude: -18}}, - {svgmap.ProjectionSouthPolar, svgmap.GeoPoint{Longitude: -70, Latitude: 18}}, - } - for _, test := range tests { - frame := svgmap.Frame{Width: 720, Height: 360, Projection: test.projection} - if test.projection != svgmap.ProjectionEquirectangular { - frame.Width = 360 - } - polygons := lunarEclipseVisibilityPolygons(test.center, test.projection, 360) - for latitude := -75.0; latitude <= 75; latitude += 15 { - if test.projection == svgmap.ProjectionNorthPolar && latitude <= 0 { - continue + for _, projection := range []svgmap.Projection{svgmap.ProjectionEquirectangular, svgmap.ProjectionNorthPolar, svgmap.ProjectionSouthPolar} { + frame := svgmap.Frame{Width: 36000, Height: 18000, Projection: projection} + path, boundary := lunarEclipseVisibilityPath(at, frame) + if strings.Contains(path, "NaN") || strings.Contains(path, "Inf") { + t.Fatal("nonfinite path") } - if test.projection == svgmap.ProjectionSouthPolar && latitude >= 0 { - continue + rings := svgClosedPathRings(t, ``) + if len(rings) == 0 || len(rings) > 3 { + t.Fatalf("projection=%s subpaths=%d", projection, len(rings)) } - for longitude := -165.0; longitude <= 165; longitude += 30 { - point := svgmap.GeoPoint{Longitude: longitude, Latitude: latitude} - dot := lunarVisibilityDot(test.center, point) - if math.Abs(dot) < 0.02 { - continue + jd := basic.Date2JDE(at) + for _, point := range boundary { + if altitude := basic.HMoonHeight(jd, point.Longitude, point.Latitude, 0); math.Abs(altitude) > 1e-9 { + t.Fatalf("horizon altitude=%g", altitude) } - got := projectedPointInLunarVisibility(frame, polygons, point) - if got != (dot > 0) { - t.Fatalf("%s center=%#v point=%#v inside=%v dot=%.6f", - test.projection, test.center, point, got, dot) + } + for lon := -175.; lon < 180; lon += 20 { + for lat := -85.; lat < 90; lat += 10 { + x, y, ok := frame.Project(lon, lat) + if !ok { + continue + } + altitude := basic.HMoonHeight(jd, lon, lat, 0) + if math.Abs(altitude) < 0.05 { + continue + } + inside := false + for _, ring := range rings { + inside = inside || pointInLunarVisibilityPolygon(x, y, ring) + } + if inside != (altitude > 0) { + t.Fatalf("projection=%s at=%s site=(%v,%v) inside=%v altitude=%v", projection, at, lon, lat, inside, altitude) + } } } } @@ -218,26 +149,6 @@ func validateEclipseMapXML(value string) error { } } -func projectedPointInLunarVisibility(frame svgmap.Frame, polygons [][]svgmap.GeoPoint, point svgmap.GeoPoint) bool { - x, y, ok := frame.Project(point.Longitude, point.Latitude) - if !ok { - return false - } - for _, polygon := range polygons { - projected := make([][2]float64, 0, len(polygon)) - for _, vertex := range polygon { - px, py, projectedOK := frame.Project(vertex.Longitude, vertex.Latitude) - if projectedOK { - projected = append(projected, [2]float64{px, py}) - } - } - if pointInLunarVisibilityPolygon(x, y, projected) { - return true - } - } - return false -} - func pointInLunarVisibilityPolygon(x, y float64, polygon [][2]float64) bool { inside := false for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { @@ -249,12 +160,3 @@ func pointInLunarVisibilityPolygon(x, y float64, polygon [][2]float64) bool { } return inside } - -func lunarVisibilityDot(center, point svgmap.GeoPoint) float64 { - centerLongitude := center.Longitude * math.Pi / 180 - centerLatitude := center.Latitude * math.Pi / 180 - longitude := point.Longitude * math.Pi / 180 - latitude := point.Latitude * math.Pi / 180 - return math.Sin(centerLatitude)*math.Sin(latitude) + - math.Cos(centerLatitude)*math.Cos(latitude)*math.Cos(longitude-centerLongitude) -} diff --git a/eclipse/svg/map_projection.go b/eclipse/svg/map_projection.go index 3a967cf..5851e19 100644 --- a/eclipse/svg/map_projection.go +++ b/eclipse/svg/map_projection.go @@ -3,6 +3,7 @@ package svg import "b612.me/astro/internal/svgmap" // EclipseMapProjection 控制全球日月食地图投影;零值根据事件几何选择投影。 +// 与 moon/svg.MapProjection 是同一概念的两套名字,取值字符串逐对相同。 // EclipseMapProjection controls a global eclipse map projection. The zero value selects the projection from the event geometry. type EclipseMapProjection string @@ -16,6 +17,9 @@ const ( // EclipseMapProjectionNorthPolar 使用北极方位等距投影。 // EclipseMapProjectionNorthPolar uses the north-polar azimuthal equidistant projection. EclipseMapProjectionNorthPolar EclipseMapProjection = "north-polar" + // EclipseMapProjectionOrthographic 使用正射球面投影,视点取食甚点,只画朝向视点的半个地球。 + // EclipseMapProjectionOrthographic uses the orthographic globe projection centred on the greatest point, drawing only the facing hemisphere. + EclipseMapProjectionOrthographic EclipseMapProjection = "orthographic" // EclipseMapProjectionSouthPolar 使用南极方位等距投影。 // EclipseMapProjectionSouthPolar uses the south-polar azimuthal equidistant projection. EclipseMapProjectionSouthPolar EclipseMapProjection = "south-polar" @@ -28,7 +32,8 @@ func internalEclipseMapProjection(value EclipseMapProjection) svgmap.Projection func validEclipseMapProjection(value EclipseMapProjection) bool { switch value { case EclipseMapProjectionAuto, EclipseMapProjectionEquirectangular, - EclipseMapProjectionNorthPolar, EclipseMapProjectionSouthPolar: + EclipseMapProjectionNorthPolar, EclipseMapProjectionSouthPolar, + EclipseMapProjectionOrthographic: return true default: return false @@ -42,6 +47,8 @@ func eclipseMapProjectionLabel(projection svgmap.Projection, language string) st return "North-polar azimuthal equidistant projection" case svgmap.ProjectionSouthPolar: return "South-polar azimuthal equidistant projection" + case svgmap.ProjectionOrthographic: + return "Orthographic globe projection, centred on the greatest eclipse; one hemisphere only" default: return "Equirectangular projection" } @@ -51,6 +58,8 @@ func eclipseMapProjectionLabel(projection svgmap.Projection, language string) st return "北极方位等距投影" case svgmap.ProjectionSouthPolar: return "南极方位等距投影" + case svgmap.ProjectionOrthographic: + return "正射球面投影,视点取食甚点,只画朝向视点的半个地球" default: return "等经纬投影" } diff --git a/eclipse/svg/map_projection_contract_test.go b/eclipse/svg/map_projection_contract_test.go new file mode 100644 index 0000000..5d39aae --- /dev/null +++ b/eclipse/svg/map_projection_contract_test.go @@ -0,0 +1,39 @@ +package svg + +import ( + "testing" + + "b612.me/astro/internal/svgmap" +) + +// 投影常量的取值字符串是与 moon/svg.MapProjection 共享的稳定契约。 +func TestEclipseMapProjectionValuesContract(t *testing.T) { + equivalents := []struct { + name string + value EclipseMapProjection + text string + }{ + {name: "auto", value: EclipseMapProjectionAuto, text: ""}, + {name: "equirectangular", value: EclipseMapProjectionEquirectangular, text: "equirectangular"}, + {name: "north-polar", value: EclipseMapProjectionNorthPolar, text: "north-polar"}, + {name: "south-polar", value: EclipseMapProjectionSouthPolar, text: "south-polar"}, + {name: "orthographic", value: EclipseMapProjectionOrthographic, text: "orthographic"}, + } + for _, testCase := range equivalents { + if got := string(testCase.value); got != testCase.text { + t.Fatalf("%s = %q, want %q", testCase.name, got, testCase.text) + } + if got := internalEclipseMapProjection(testCase.value); got != svgmap.Projection(testCase.text) { + t.Fatalf("%s maps to %q, want %q", testCase.name, got, svgmap.Projection(testCase.text)) + } + if !validEclipseMapProjection(testCase.value) { + t.Fatalf("%s must pass validation", testCase.name) + } + } + // 边界:只有上表登记的字面量可以过校验,大小写与分隔符都不放宽。 + for _, invalid := range []EclipseMapProjection{"auto", "Orthographic", "north_polar", "polar", "equirectangular "} { + if validEclipseMapProjection(invalid) { + t.Fatalf("validator accepted unsupported projection %q", invalid) + } + } +} diff --git a/eclipse/svg/solar.go b/eclipse/svg/solar.go index f4672a2..8a55f67 100644 --- a/eclipse/svg/solar.go +++ b/eclipse/svg/solar.go @@ -9,6 +9,7 @@ import ( "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgchart" ) const ( @@ -189,8 +190,12 @@ func renderLocalSolarEclipseSVG( b.WriteString(``) fmt.Fprintf(&b, ``, width-44, height-36) + titleText := title + if options.Title != "" { + titleText = svgchart.EllipsizeText(title, width-52, 26) + } fmt.Fprintf(&b, `%s`, - width/2, html.EscapeString(title)) + width/2, html.EscapeString(titleText)) fmt.Fprintf(&b, ``, width/2-78, width/2+78) for index, line := range headerTexts { fontSize := 13 @@ -245,12 +250,23 @@ func renderLocalSolarEclipseSVG( } writeLocalSolarEclipseStagePanels(&b, info, eventFrames, options, margin, stageTop, width-2*margin, stageHeight) + direction := localSolarEclipseSVGDirectionTextValue(options) + if options.DirectionText != "" { + direction = svgchart.EllipsizeText(direction, width-80, 12) + } fmt.Fprintf(&b, `%s`, - 40.0, height-54, - html.EscapeString(localSolarEclipseSVGDirectionTextValue(options))) - note := localSolarEclipseSVGFooterNoteText(options) - fmt.Fprintf(&b, `%s`, - 40.0, height-34, html.EscapeString(note)) + 40.0, height-54, html.EscapeString(direction)) + // 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。 + lines := []string{localSolarEclipseSVGFooterNoteText(options)} + if options.FooterNote != "" { + maxWidth := width - 80 + lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12, + svgchart.BaselineLineLimit(12, 15, height-34, height-4)) + } + for index, line := range lines { + fmt.Fprintf(&b, `%s`, + 40.0, height-34+float64(index)*15, html.EscapeString(line)) + } writeLocalSolarEclipseContacts(&b, info, options, panelX, math.Max(154, cy-92)) b.WriteString(``) return b.String() @@ -264,16 +280,25 @@ func localSolarEclipseSVGTitleText(info LocalSolarEclipseInfo, options LocalSola } func localSolarEclipseSVGHeaderTexts(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) []string { - lines := []string{ - localSolarEclipseSVGSummaryText(info, options), - localSolarEclipseSVGGreatestTextValue(info, options), - localSolarEclipseSVGMetaTextValue(info, options), + values := []struct { + text string + custom bool + }{ + {localSolarEclipseSVGSummaryText(info, options), options.SummaryText != ""}, + {localSolarEclipseSVGGreatestTextValue(info, options), options.GreatestText != ""}, + {localSolarEclipseSVGMetaTextValue(info, options), options.MetaText != ""}, } - filtered := make([]string, 0, len(lines)) - for _, line := range lines { - if line != "" { - filtered = append(filtered, line) + filtered := make([]string, 0, len(values)) + for _, value := range values { + if value.text == "" { + continue } + // 摘要行各占一个固定槽位,调用方长文本只能截断,不能折行挤压下一行。 + if value.custom { + filtered = append(filtered, svgchart.EllipsizeText(value.text, float64(options.Width)-80, 14)) + continue + } + filtered = append(filtered, value.text) } return filtered } @@ -374,9 +399,17 @@ func localSolarEclipseSVGMetaText(info LocalSolarEclipseInfo, language string) s parts := make([]string, 0, 2) if info.HasSaros { if language == localSolarEclipseSVGLanguageEnglish { - parts = append(parts, fmt.Sprintf("Solar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count)) + label := fmt.Sprintf("Solar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count) + if !info.Saros.Verified { + label += " (provisional)" + } + parts = append(parts, label) } else { - parts = append(parts, fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)) + label := fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count) + if !info.Saros.Verified { + label += "(推算)" + } + parts = append(parts, label) } } if duration := localSolarEclipseSVGCentralDurationText(info, language); duration != "" { diff --git a/eclipse/svg/solar_envelope_regression_test.go b/eclipse/svg/solar_envelope_regression_test.go new file mode 100644 index 0000000..6178810 --- /dev/null +++ b/eclipse/svg/solar_envelope_regression_test.go @@ -0,0 +1,119 @@ +package svg + +import ( + "encoding/xml" + "io" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/internal/svgmap" +) + +func TestSolarSVGUsesAuthoritativeCentralBand(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*3600) + for _, fixture := range []struct { + date time.Time + lon, lat float64 + }{ + {time.Date(2012, 5, 21, 0, 0, 0, 0, zone), 120.4913, 27.4779}, + {time.Date(2056, 7, 13, 0, 0, 0, 0, zone), -64.3737, -5.4978}, + {time.Date(2164, 3, 23, 0, 0, 0, 0, zone), -120.575, 48.75}, + {time.Date(2026, 8, 12, 0, 0, 0, 0, zone), -5.991755201, 45.070085755}, + } { + t.Run(fixture.date.Format("2006-01-02"), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(fixture.date, eclipse.SolarEclipsePartialFootprintOptions{Step: 2 * time.Minute, BoundaryPoints: 96}) + if !ok { + t.Fatal("missing eclipse") + } + central, ok := eclipse.SolarEclipseCentralPath(fixture.date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute}) + if !ok { + t.Fatal("missing central path") + } + frame := svgmap.Frame{Width: 36000, Height: 18000, Projection: svgmap.ProjectionEquirectangular} + var builder strings.Builder + writeSolarEclipseCentralPath(&builder, central, partial.CentralBandFootprints, partial.CentralBandSegments, frame, partial.Eclipse.Type) + if !strings.Contains(builder.String(), `class="central-eclipse-band" data-source="besselian-critical-envelope"`) { + t.Fatal("old central fill used") + } + x, y, _ := frame.Project(fixture.lon, fixture.lat) + inside := false + for _, ring := range svgClosedPathRings(t, builder.String()) { + inside = inside || pointInLunarVisibilityPolygon(x, y, ring) + } + if !inside { + t.Fatal("rendered central band omits visible site") + } + full, ok := SolarEclipseMapSVG(fixture.date, SolarEclipseMapSVGOptions{Projection: EclipseMapProjectionEquirectangular}) + if !ok || !strings.Contains(full, `class="central-eclipse-band" data-source="besselian-critical-envelope"`) { + t.Fatal("public SVG does not use core envelope") + } + }) + } +} + +func svgClosedPathRings(t *testing.T, value string) [][][2]float64 { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(value)) + var rings [][][2]float64 + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + tag, ok := token.(xml.StartElement) + if !ok || tag.Name.Local != "path" { + continue + } + for _, attribute := range tag.Attr { + if attribute.Name.Local != "d" || !strings.Contains(attribute.Value, "Z") { + continue + } + fields := strings.Fields(attribute.Value) + var ring [][2]float64 + for index := 0; index < len(fields); { + if fields[index] == "Z" { + rings = append(rings, ring) + ring = nil + index++ + continue + } + if (fields[index] != "M" && fields[index] != "L") || index+2 >= len(fields) { + t.Fatalf("unexpected path %q", attribute.Value) + } + x, err := strconv.ParseFloat(fields[index+1], 64) + if err != nil { + t.Fatal(err) + } + y, err := strconv.ParseFloat(fields[index+2], 64) + if err != nil { + t.Fatal(err) + } + ring = append(ring, [2]float64{x, y}) + index += 3 + } + } + } + return rings +} + +func TestSolarPolarSVGUsesAuthoritativeCentralBand(t *testing.T) { + for _, day := range [][3]int{ + {2003, 11, 23}, {2021, 12, 4}, {2039, 12, 15}, {2061, 10, 13}, {2981, 10, 19}, + } { + date := time.Date(day[0], time.Month(day[1]), day[2], 0, 0, 0, 0, time.UTC) + t.Run(date.Format("2006-01-02"), func(t *testing.T) { + for _, projection := range []EclipseMapProjection{EclipseMapProjectionEquirectangular, EclipseMapProjectionSouthPolar} { + value, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Projection: projection}) + if !ok || !strings.Contains(value, `class="central-eclipse-band" data-source="besselian-critical-envelope"`) { + t.Fatalf("projection %s discarded the core central envelope", projection) + } + } + }) + } +} diff --git a/eclipse/svg/solar_map.go b/eclipse/svg/solar_map.go index 49e580a..5618287 100644 --- a/eclipse/svg/solar_map.go +++ b/eclipse/svg/solar_map.go @@ -9,19 +9,39 @@ import ( "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/solarclosure" + "b612.me/astro/internal/svgchart" "b612.me/astro/internal/svgmap" ) const ( solarEclipseMapDefaultWidth = 960 solarEclipseMapDefaultHeight = 640 + // 真实可用下限:更窄的画布上横版的地图框与右栏数据网格必然水平重叠,面板行距也会压到 1 px 以下。 + solarEclipseMapMinWidth = 800 + solarEclipseMapMinHeight = 560 + // 等时线时刻取值上限,避免极长事件请求出上万条曲线。 + solarEclipseMapGreatestTimeMaxLevels = 64 + // NASA 全球图的等时线间隔,属于推荐取值而不是默认值:不请求就不画。 + solarEclipseMapDefaultGreatestTimeStep = 30 * time.Minute + // 偏食足迹扫描的最密步长:并集需要一整条自洽的扫描序列,更密的请求只会成倍放大成本。 + solarEclipseMapPartialSweepMinStep = 2 * time.Minute + // 中心线偏离包络不得超过 2 km;南北限界端部允许偏离食带宽的 1/4。 + solarEclipseCentralEnvelopeAxisMissToleranceKM = 2.0 + solarEclipseCentralEnvelopeLimitMissFraction = 0.25 ) // SolarEclipseMapSVGOptions 控制无国界全球日食地图。 // SolarEclipseMapSVGOptions controls a border-free global solar-eclipse map. type SolarEclipseMapSVGOptions struct { - // Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。 - // Width and Height are SVG canvas dimensions in user units. Width values below 640 and height values below 420 use the 960x640 defaults. + // Width 和 Height 是 SVG 画布尺寸;宽度小于 800 或高度小于 560 时使用 960x640 默认值, + // 这两个下限是横版地图框与两栏数据网格不重叠、面板行距不塌陷的最小值。 + // 图例行数随可选图层增加,因此更窄或更矮的画布、以及开着全部可选图层的下限画布会返回 false。 + // Width and Height are SVG canvas dimensions in user units. Width values below 800 and height + // values below 560 use the 960x640 defaults; those minima keep the landscape map frame and the + // two-column data grid apart with a non-collapsed panel row spacing. The legend gains rows with + // the optional layers, so canvases too small for that stack make the renderer return false. Width int Height int // Language 为 "en"(不区分大小写)时使用英文,否则使用中文。 @@ -35,35 +55,67 @@ type SolarEclipseMapSVGOptions struct { Projection EclipseMapProjection // 空文本字段使用本地化的自动标签。 // Empty text fields use localized automatic labels. - Title string - MapTitle string + Title string + MapTitle string + // EventsTitle 是“全球阶段”数据块的标题,该块给出食甚经纬度与地球范围的中心食始/终; + // 为空时使用该块的本地化默认标题。 + // EventsTitle titles the global-phases block that carries the greatest-eclipse coordinates + // and the earth-wide central begin/end; empty uses that block's localized default title. EventsTitle string FooterNote string - // PartialStep 是半影足迹请求的时间步长;非正值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,以保持时间序列不超过 30000 个采样点。 - // PartialStep is the requested partial-footprint time step. Values <= 0 use two minutes; positive values below one second use one second. Long events may use a larger effective step to keep the time series within 30000 samples. + // PartialStep 是半影足迹请求的时间步长;非正值与小于两分钟的正值都使用两分钟。 + // 偏食区填充是瞬时足迹的并集,成本随采样数成倍增长,而并集必须由一整条自洽的扫描序列生成, + // 更密的请求不改变产物(长事件还会为 30000 个采样点的上限进一步放大步长)。 + // PartialStep is the requested partial-footprint time step. Values <= 0 and positive values below + // two minutes both use two minutes: the partial region is a union of instantaneous footprints whose + // cost grows with the sample count, and that union needs one self-consistent sweep, so a denser + // request does not change the product (long events enlarge the step further to stay within 30000 samples). PartialStep time.Duration // BoundaryPoints 是每个瞬时偏食足迹的角向采样数;非正值使用 180,正值限制在 12..1440。 // BoundaryPoints is the angular sample count for each instantaneous partial footprint. Values <= 0 use 180; positive values are clamped to 12..1440. BoundaryPoints int - // PenumbralOutlineStep 控制半影边界轮廓采样;零值使用 60 分钟,负值禁用,正值小于一分钟时使用一分钟。 - // PenumbralOutlineStep controls sampled penumbral boundary outlines. Zero uses 60 minutes, negative values disable them, and positive values below one minute use one minute. + // PenumbralOutlineStep 请求瞬时半影边界轮廓并给出采样间隔;默认不画(零值或负值都不画), + // 正值小于一分钟时使用一分钟。NASA 全球图没有这族线,打开会明显遮挡地球。 + // PenumbralOutlineStep requests sampled instantaneous penumbral outlines and gives their + // interval. They are off by default (zero or negative draws none), and positive values below + // one minute use one minute. NASA world maps omit this family, which otherwise hides the globe. PenumbralOutlineStep time.Duration - // CentralShadowStep 控制本影/反本影轮廓采样;零值使用 10 分钟,负值禁用,正值小于一分钟时使用一分钟。 - // CentralShadowStep controls sampled umbral/antumbral outlines. Zero uses 10 minutes, negative values disable them, and positive values below one minute use one minute. + // CentralShadowStep 请求瞬时本影/反本影轮廓并给出采样间隔;默认不画(零值或负值都不画), + // 正值小于一分钟时使用一分钟。它同时决定本影足迹的采样密度。 + // CentralShadowStep requests sampled instantaneous umbral/antumbral outlines and gives their + // interval. They are off by default (zero or negative draws none), and positive values below + // one minute use one minute. It also sets the umbral footprint sampling density. CentralShadowStep time.Duration // CentralStep 是中心路径请求的时间步长;非正值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,以保持基础路径不超过 30000 个采样点。 // CentralStep is the requested central-path time step. Values <= 0 use two minutes; positive values below one second use one second. Long events may use a larger effective step to keep the base path within 30000 samples. CentralStep time.Duration - // TargetSpacingKM 是中心线地面间距上限,单位为千米;非正值使用 150 km,非有限值禁用加密。 - // TargetSpacingKM is the requested maximum center-line ground spacing in kilometers. Values <= 0 use 150 km; non-finite values disable refinement. + // TargetSpacingKM 是中心线地面间距上限,单位为千米;非正值(含 -Inf)使用 150 km,NaN 与 +Inf 禁用加密。 + // TargetSpacingKM is the requested maximum center-line ground spacing in kilometers. Values <= 0 (including -Inf) use 150 km; NaN and +Inf disable refinement. TargetSpacingKM float64 // TimeLabelStep 控制中心线上的 HH:MM 标签;零值使用 30 分钟,负值禁用标签,正值小于一分钟时使用一分钟。 // TimeLabelStep controls HH:MM labels along the central line. Zero uses 30 minutes, negative values disable labels, and positive values below one minute use one minute. TimeLabelStep time.Duration + // GreatestTimeStep 控制食甚时刻等时线的时间间隔;默认不画(零值或负值都不画), + // 正值小于一分钟时使用一分钟;NASA 全球图用 solarEclipseMapDefaultGreatestTimeStep 的间隔。 + // 等时线是固定时刻残差的零集延拓,成本正比于曲线长度而不是可见域面积。 + // GreatestTimeStep controls the spacing of greatest-eclipse time isolines. They are off by + // default (zero or negative draws none), positive values below one minute use one minute, + // and solarEclipseMapDefaultGreatestTimeStep is the spacing of NASA world maps. Each isochrone + // continues the zero set of a fixed-instant residual, so its cost scales with curve length + // rather than with the visible area. + GreatestTimeStep time.Duration + // MagnitudeValues 是要绘制的地方最大食分等值线电平;nil 使用 NASA 全球图常用的 + // 0.2/0.4/0.6/0.8,显式空切片关闭,非空切片按给定电平绘制。 + // MagnitudeValues lists the local maximum-magnitude contour levels to draw. Nil uses the + // 0.2/0.4/0.6/0.8 set common to NASA world maps, an explicitly empty slice disables them, + // and a non-empty slice draws exactly those levels. + MagnitudeValues []float64 } type solarEclipseMapCalculators struct { + global func(time.Time) (eclipsecore.SolarEclipseInfo, bool) + panel func(time.Time) (eclipsecore.SolarEclipseGeocentricPanel, bool) partial func(time.Time, eclipsecore.SolarEclipsePartialFootprintOptions) (eclipsecore.SolarEclipsePartialFootprintsInfo, bool) central func(time.Time, eclipsecore.SolarEclipsePathOptions) (eclipsecore.SolarEclipsePath, bool) local func(time.Time, float64, float64, float64) (eclipsecore.LocalSolarEclipseInfo, bool) @@ -79,6 +131,8 @@ func SolarEclipseMapSVG(date time.Time, options SolarEclipseMapSVGOptions) (stri // SolarEclipseMapSVGNASABulletinSplitK renders a NASA bulletin Split-K map. func SolarEclipseMapSVGNASABulletinSplitK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) { return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{ + global: eclipsecore.SolarEclipseOnDateNASABulletinSplitK, + panel: eclipsecore.SolarEclipseGeocentricPanelAt, partial: eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK, central: eclipsecore.SolarEclipseCentralPathNASABulletinSplitK, local: eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK, @@ -89,6 +143,8 @@ func SolarEclipseMapSVGNASABulletinSplitK(date time.Time, options SolarEclipseMa // SolarEclipseMapSVGIAUSingleK renders an IAU Single-K map. func SolarEclipseMapSVGIAUSingleK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) { return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{ + global: eclipsecore.SolarEclipseOnDateIAUSingleK, + panel: eclipsecore.SolarEclipseGeocentricPanelIAUSingleK, partial: eclipsecore.SolarEclipsePartialFootprintsIAUSingleK, central: eclipsecore.SolarEclipseCentralPathIAUSingleK, local: eclipsecore.GeometricLocalSolarEclipseOnDateIAUSingleK, @@ -104,10 +160,19 @@ func solarEclipseMapSVG( return "", false } options = normalizeSolarEclipseMapSVGOptions(date, options) + // 日期门与核心一致:当天没有日食就不出图。偏食足迹是“取最近一次”语义, + // 缺这道门会把邻近日期的图当成当天的图交出去。 + global, ok := calculators.global(date) + if !ok { + return "", false + } + greatestTimes := solarEclipseGreatestTimeLevels(global, options) partial, ok := calculators.partial(date, eclipsecore.SolarEclipsePartialFootprintOptions{ - Step: options.PartialStep, - BoundaryPoints: options.BoundaryPoints, - CentralShadowStep: options.CentralShadowStep, + Step: options.PartialStep, + BoundaryPoints: options.BoundaryPoints, + CentralShadowStep: options.CentralShadowStep, + GreatestTimeValues: greatestTimes, + MagnitudeValues: options.MagnitudeValues, }) if !ok { return "", false @@ -115,6 +180,7 @@ func solarEclipseMapSVG( central, hasCentral := calculators.central(date, eclipsecore.SolarEclipsePathOptions{ Step: options.CentralStep, TargetSpacingKM: options.TargetSpacingKM, + SkipCentralBand: true, }) local, hasLocal := calculators.local( partial.Eclipse.GreatestEclipse, @@ -123,14 +189,110 @@ func solarEclipseMapSVG( 0, ) projection := resolveSolarEclipseMapProjection(partial, central, hasCentral, options.Projection) - return renderSolarEclipseMapSVG(partial, central, hasCentral, local, hasLocal, options, projection), true + // 正射图的视点固定取食甚点,让整条中心食带尽量落在可见半球内。 + center := svgmap.GeoPoint{ + Longitude: partial.Eclipse.GreatestLongitude, + Latitude: partial.Eclipse.GreatestLatitude, + } + geocentric, hasGeocentric := eclipsecore.SolarEclipseGeocentricPanel{}, false + if calculators.panel != nil { + geocentric, hasGeocentric = calculators.panel(date) + } + plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric, + options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial)) + // 数据块行距或图例带放不下时拒绝该画布,而不是把压叠的文字画出来。 + if !plan.fits() { + return "", false + } + return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, + options, projection), true +} + +// solarEclipseMapHasCentralBand 报告该事件是否有中心食带(包络、限界或瞬时足迹任一存在)。 +func solarEclipseMapHasCentralBand(partial eclipsecore.SolarEclipsePartialFootprintsInfo) bool { + return len(partial.CentralBandFootprints) > 0 || len(partial.CentralShadowFootprints) > 0 || + len(partial.CentralBandSegments) > 0 +} + +// solarEclipseMapPlan 是渲染前的版面:数据块、图例分行与版式几何,由一处分块逻辑同时供占位与绘制。 +type solarEclipseMapPlan struct { + cells []solarEclipsePanelCell + layout solarEclipseMapLayout + legend [][]solarEclipseMapLegendItem + legendBottom float64 + footerTop float64 +} + +func solarEclipseMapPlanFor( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, + local eclipsecore.LocalSolarEclipseInfo, + hasLocal bool, + geocentric eclipsecore.SolarEclipseGeocentricPanel, + hasGeocentric bool, + options SolarEclipseMapSVGOptions, + projection svgmap.Projection, + center svgmap.GeoPoint, + hasCentral bool, + hasCentralBand bool, +) solarEclipseMapPlan { + blocks := solarEclipseDetailedBlocks(partial, local, hasLocal, geocentric, hasGeocentric, options) + legend := solarEclipseMapLegendRows( + solarEclipseMapLegendItems(partial, hasCentral, hasCentralBand, options), + solarEclipseMapLegendAvailableWidth(options)) + layout := solarEclipseMapLayoutForBlocks(options, projection, center, len(legend), solarEclipseLandscapeRowFields(blocks)) + _, legendBelow := svgchart.EstimatedTextExtents(solarEclipseLegendFontSize) + footerAbove, _ := svgchart.EstimatedTextExtents(solarEclipseMapFooterFontSize) + return solarEclipseMapPlan{ + cells: solarEclipsePanelCells(layout, blocks), + layout: layout, + legend: legend, + legendBottom: layout.legendY + float64(len(legend)-1)*solarEclipseLegendLineStep + legendBelow, + footerTop: float64(options.Height) - solarEclipseMapFooterBaselineInset - footerAbove, + } +} + +// fits 报告版面上的数据块与图例带是否都放得下。 +func (plan solarEclipseMapPlan) fits() bool { + if solarEclipsePanelsOverlap(plan.cells) { + return false + } + if len(plan.legend) == 0 { + return true + } + return plan.legendBottom < plan.footerTop +} + +// reserveFixed 先占位不动的元素:数据块、图例、比例尺槽、页脚与圆盘四向标记。 +func (plan solarEclipseMapPlan) reserveFixed(labels *svgchart.LabelTable, options SolarEclipseMapSVGOptions, projection svgmap.Projection) { + for _, cell := range plan.cells { + labels.Reserve(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height) + } + legendX := plan.layout.frame.X + if plan.layout.nasa { + legendX = plan.layout.panelX + } + for row, items := range plan.legend { + y := plan.layout.legendY + float64(row)*solarEclipseLegendLineStep + x := legendX + for _, item := range items { + width := solarEclipseLegendIconWidth + svgchart.EstimatedTextWidth(item.label, solarEclipseLegendFontSize) + labels.Reserve(x, y-9, width, 12) + x += width + solarEclipseLegendColumnGap + } + } + labels.Reserve(plan.layout.scaleSlot.X, plan.layout.scaleSlot.Y, plan.layout.scaleSlot.Width, plan.layout.scaleSlot.Height) + labels.ReserveText(plan.layout.margin, float64(options.Height)-solarEclipseMapFooterBaselineInset, + solarEclipseMapFooterFontSize, solarEclipseMapFooterText(options, projection), "start") + for _, cardinal := range solarEclipseCardinalLabels(plan.layout) { + labels.ReserveText(cardinal.x, cardinal.y, 13, cardinal.text, "middle") + } } func normalizeSolarEclipseMapSVGOptions(date time.Time, options SolarEclipseMapSVGOptions) SolarEclipseMapSVGOptions { - if options.Width < 640 { + if options.Width < solarEclipseMapMinWidth { options.Width = solarEclipseMapDefaultWidth } - if options.Height < 420 { + if options.Height < solarEclipseMapMinHeight { options.Height = solarEclipseMapDefaultHeight } if strings.EqualFold(options.Language, "en") { @@ -141,25 +303,26 @@ func normalizeSolarEclipseMapSVGOptions(date time.Time, options SolarEclipseMapS if options.Location == nil { options.Location = date.Location() } - if options.PartialStep <= 0 { - options.PartialStep = 2 * time.Minute + // 非正值与小于两分钟的正值都取两分钟:并集需要一整条自洽的扫描序列。 + if options.PartialStep < solarEclipseMapPartialSweepMinStep { + options.PartialStep = solarEclipseMapPartialSweepMinStep } if options.BoundaryPoints <= 0 { options.BoundaryPoints = 180 } - if options.PenumbralOutlineStep < 0 { - options.PenumbralOutlineStep = 0 - } else if options.PenumbralOutlineStep == 0 { - options.PenumbralOutlineStep = time.Hour - } else if options.PenumbralOutlineStep < time.Minute { + // 瞬时半影与本影轮廓默认不画:它们会把地球盖住,NASA 的全球图也没有这两族。 + if options.PenumbralOutlineStep > 0 && options.PenumbralOutlineStep < time.Minute { options.PenumbralOutlineStep = time.Minute + } else if options.PenumbralOutlineStep < 0 { + options.PenumbralOutlineStep = 0 } - if options.CentralShadowStep < 0 { - options.CentralShadowStep = 0 - } else if options.CentralShadowStep == 0 { - options.CentralShadowStep = 10 * time.Minute - } else if options.CentralShadowStep < time.Minute { + if options.CentralShadowStep > 0 && options.CentralShadowStep < time.Minute { options.CentralShadowStep = time.Minute + } else if options.CentralShadowStep < 0 { + options.CentralShadowStep = 0 + } + if options.MagnitudeValues == nil { + options.MagnitudeValues = []float64{0.2, 0.4, 0.6, 0.8} } if options.CentralStep <= 0 { options.CentralStep = 2 * time.Minute @@ -174,9 +337,38 @@ func normalizeSolarEclipseMapSVGOptions(date time.Time, options SolarEclipseMapS } else if options.TimeLabelStep < time.Minute { options.TimeLabelStep = time.Minute } + // 零值与负值都表示不请求等时线:这是可选图层,与核心层和 moon/svg 一样必须显式请求。 + if options.GreatestTimeStep < 0 { + options.GreatestTimeStep = 0 + } else if options.GreatestTimeStep > 0 && options.GreatestTimeStep < time.Minute { + options.GreatestTimeStep = time.Minute + } return options } +// solarEclipseGreatestTimeLevels 把等时线时刻取值对齐到步长网格上,覆盖地球范围偏食窗口。 +func solarEclipseGreatestTimeLevels( + info eclipsecore.SolarEclipseInfo, + options SolarEclipseMapSVGOptions, +) []time.Time { + if options.GreatestTimeStep <= 0 { + return nil + } + if info.PartialBeginOnEarth.IsZero() || info.PartialEndOnEarth.IsZero() { + return nil + } + current := solarEclipseMapAlignedTime(info.PartialBeginOnEarth, options.GreatestTimeStep, options.Location) + if current.Before(info.PartialBeginOnEarth) { + current = current.Add(options.GreatestTimeStep) + } + levels := make([]time.Time, 0, solarEclipseMapGreatestTimeMaxLevels) + for !current.After(info.PartialEndOnEarth) && len(levels) < solarEclipseMapGreatestTimeMaxLevels { + levels = append(levels, current) + current = current.Add(options.GreatestTimeStep) + } + return levels +} + func resolveSolarEclipseMapProjection( partial eclipsecore.SolarEclipsePartialFootprintsInfo, central eclipsecore.SolarEclipsePath, @@ -204,14 +396,8 @@ func resolveSolarEclipseMapProjection( } } } - resolved := svgmap.ResolveProjection("", focus, minimum, maximum) - if resolved == svgmap.ProjectionEquirectangular && focus >= 65 { - return svgmap.ProjectionNorthPolar - } - if resolved == svgmap.ProjectionEquirectangular && focus <= -65 { - return svgmap.ProjectionSouthPolar - } - return resolved + // 高纬但跨越赤道的事件必须保留等距圆柱视图;极区视图只由半球守卫决定。 + return svgmap.ResolveProjection("", focus, minimum, maximum) } func renderSolarEclipseMapSVG( @@ -220,13 +406,42 @@ func renderSolarEclipseMapSVG( hasCentral bool, local eclipsecore.LocalSolarEclipseInfo, hasLocal bool, + geocentric eclipsecore.SolarEclipseGeocentricPanel, + hasGeocentric bool, + options SolarEclipseMapSVGOptions, + projection svgmap.Projection, + center svgmap.GeoPoint, +) string { + plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric, + options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial)) + return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, options, projection) +} + +func (plan solarEclipseMapPlan) render( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, + central eclipsecore.SolarEclipsePath, + hasCentral bool, + local eclipsecore.LocalSolarEclipseInfo, + hasLocal bool, + geocentric eclipsecore.SolarEclipseGeocentricPanel, + hasGeocentric bool, options SolarEclipseMapSVGOptions, projection svgmap.Projection, ) string { - layout := solarEclipseMapLayoutFor(options, projection) + layout := plan.layout frame := layout.frame title := solarEclipseMapTitle(partial.Eclipse, options) - partialPath := solarEclipsePartialSweepPath(partial, frame) + // 图题居中,两侧各留出白色页框 22 px 与一点空隙。 + titleText := title + if options.Title != "" { + titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24) + } + partialPath, partialSource := solarEclipsePartialSweepPath(partial, frame) + + // 一张已放置矩形表:固定元素先占位,地图标注再按候选偏移避让。 + labels := &svgchart.LabelTable{} + plan.reserveFixed(labels, options, projection) + labels.ReserveText(float64(options.Width)/2, 47, 24, titleText, "middle") var builder strings.Builder fmt.Fprintf(&builder, ``, @@ -238,75 +453,178 @@ func renderSolarEclipseMapSVG( fmt.Fprintf(&builder, ``, options.Width-44, options.Height-36) fmt.Fprintf(&builder, `%s`, - float64(options.Width)/2, html.EscapeString(title)) - writeSolarEclipseMapSummary(&builder, partial.Eclipse, local, hasLocal, options) - writeSolarEclipseMapSectionTitle(&builder, layout, options, hasCentral) + float64(options.Width)/2, html.EscapeString(titleText)) + writeSolarEclipseMapSummary(&builder, labels, partial.Eclipse, local, hasLocal, geocentric, hasGeocentric, options) + writeSolarEclipseMapSectionTitle(&builder, labels, layout, options, hasCentral) frame.WriteOcean(&builder) frame.WriteGraticule(&builder, "solar-map-clip") frame.WriteLand(&builder, "solar-map-clip") - fmt.Fprintf(&builder, ``, partialPath) - writeSolarEclipseTerminator(&builder, partial.Eclipse, frame) - writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options) - if hasCentral { - writeSolarEclipseCentralPath(&builder, central, frame, partial.Eclipse.Type) + fmt.Fprintf(&builder, ``, partialSource, partialPath) + writeSolarEclipsePartialBoundary(&builder, partial.PartialBandContours, frame) + writeSolarEclipseRiseSetCurves(&builder, partial.RiseSetCurves, frame) + writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options, labels) + // 点标注比等值线标注重要:先把食甚、接触、直射点与中心线时刻占到位置,等值线标注再让开。 + pointLabels := solarEclipseMapPlacePointLabels(partial, central, hasCentral, frame, options, labels) + writeSolarEclipseGreatestTimeContours(&builder, partial.GreatestTimeContours, frame, options, labels) + magnitudeAxis, magnitudeNormal, hasMagnitudeAxis := solarEclipseMagnitudeLabelAxis(central) + if !hasMagnitudeAxis { + magnitudeAxis, magnitudeNormal = [3]float64{}, [3]float64{} + } + writeSolarEclipseMagnitudeContours(&builder, partial.MagnitudeContours, frame, labels, + magnitudeAxis, magnitudeNormal) + centralBandFootprints := partial.CentralBandFootprints + if len(centralBandFootprints) == 0 { + centralBandFootprints = partial.CentralShadowFootprints } - writeSolarEclipseCentralShadowOutlines(&builder, partial.CentralShadowFootprints, frame) if hasCentral { - writeSolarEclipseTimeMarkers(&builder, central, frame, options) + writeSolarEclipseCentralPath( + &builder, central, centralBandFootprints, partial.CentralBandSegments, frame, partial.Eclipse.Type, + ) + } else if !writeSolarEclipseCentralBandEnvelope(&builder, partial.CentralBandSegments, frame, partial.Eclipse.Type, false) { + writeSolarEclipseCentralShadowSweep(&builder, centralBandFootprints, frame, partial.Eclipse.Type) + } + // Non-central events are represented by the continuous envelope band. The + // instantaneous open shadow arcs are diagnostic samples, not an additional + // boundary; drawing them here creates interior lines that visibly diverge + // from the annular-band edge near the grazing end. + if options.CentralShadowStep > 0 && partial.Eclipse.Centrality != eclipsecore.SolarEclipseNonCentral { + writeSolarEclipseCentralShadowOutlines(&builder, partial.CentralShadowFootprints, frame) + } + if hasCentral { + writeSolarEclipseTimeMarkers(&builder, pointLabels.times) writeSolarEclipseAxisMarkers(&builder, central, frame, options) } - writeSolarEclipseContactMarkers(&builder, partial, frame) - writeSolarEclipseGreatestMarker(&builder, partial.Eclipse, frame, options.Language) - writeSolarEclipseSubsolarMarker(&builder, partial.Eclipse, frame, options.Language) + writeSolarEclipseContactMarkers(&builder, pointLabels.contacts) + writeSolarEclipseGreatestMarker(&builder, pointLabels.greatest) + writeSolarEclipseSubsolarMarker(&builder, pointLabels.subsolar) frame.WriteFrame(&builder) - writeSolarEclipseMapLegend(&builder, layout, partial.Eclipse, hasCentral, options) - writeSolarEclipseGlobalEventsPanel(&builder, partial, central, hasCentral, layout, options) - writeSolarEclipseMapFooter(&builder, frame, options, projection) + writeSolarEclipseMapLegend(&builder, layout, plan.legend) + switch { + case layout.landscape: + writeSolarEclipseLandscapePanels(&builder, plan.cells, layout, options) + case layout.nasa: + writeSolarEclipseCardinalMarkers(&builder, layout) + writeSolarEclipseScaleBar(&builder, layout, options) + writeSolarEclipseDetailedPanels(&builder, plan.cells, layout) + if hasGeocentric { + writeSolarEclipseGeocentricBlocks(&builder, geocentric, layout, options) + writeSolarEclipseEphemerisPanels(&builder, plan.cells, layout) + } + } + writeSolarEclipseMapFooter(&builder, layout, options, projection) builder.WriteString(``) return builder.String() } -func solarEclipsePartialSweepPath(info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame) string { - var builder strings.Builder - for _, footprint := range info.Footprints { - segments := make([][]svgmap.GeoPoint, 0, len(footprint.Boundaries)) - for _, source := range footprint.Boundaries { - segment := make([]svgmap.GeoPoint, len(source)) - for index, point := range source { - segment[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} +// solarEclipsePartialSweepPath 返回偏食可见域填充路径及其几何来源。 +func solarEclipsePartialSweepPath(info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame) (string, string) { + if polygons, ok := solarEclipsePartialBandPolygons(info); ok { + var builder strings.Builder + for _, polygon := range polygons { + points := make([]svgmap.GeoPoint, len(polygon)) + for index, point := range polygon { + points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + for _, fragment := range svgmap.PolygonFragments(points, frame.Clip()) { + appendEclipseMapPolygonPathConsistent(&builder, frame, fragment) } - segments = append(segments, segment) } - boundary := svgmap.JoinPolylineSegments(segments) - if len(boundary) < 3 { + return builder.String(), eclipseMapSourcePartialBandUnion + } + return solarEclipsePartialFootprintSweepPath(info, frame), eclipseMapSourceSampledFootprintSweep +} + +func solarEclipsePartialBandPolygons( + info eclipsecore.SolarEclipsePartialFootprintsInfo, +) ([][]geodata.GeoPoint, bool) { + // 缺少精确擦地点时并集失去权威性:退回逐足迹扫掠,data-source 落到 sampled-footprint-sweep。 + if len(info.PartialBandContours) == 0 || len(info.RiseSetCurves) == 0 || + !solarEclipsePartialClosureExact(info.Footprints) { + return nil, false + } + contours := make([][]geodata.GeoPoint, 0, len(info.PartialBandContours)) + for _, contour := range info.PartialBandContours { + contours = append(contours, solarClosureGeoPoints(contour)) + } + phaseLines := make([][]geodata.GeoPoint, 0, len(info.RiseSetCurves)*2) + for _, curve := range info.RiseSetCurves { + for _, segment := range curve.Segments { + phaseLines = append(phaseLines, solarClosureGeoPoints(segment)) + } + } + footprints := make([]solarclosure.Footprint, 0, len(info.Footprints)) + for _, footprint := range info.Footprints { + footprints = append(footprints, solarClosureFootprint(footprint)) + } + return solarclosure.BandPolygons( + contours, phaseLines, footprints, true, solarclosure.SnapDistanceKM, + ) +} + +// solarEclipsePartialClosureExact 报告每个开放足迹是否都有两个精确擦地点。 +func solarEclipsePartialClosureExact(footprints []eclipsecore.SolarEclipsePartialFootprint) bool { + for _, footprint := range footprints { + // 自身闭合的足迹不需要擦地点。 + if footprint.Closed { continue } - if len(boundary) > 1 && svgmap.SameGeoPoint(boundary[0], boundary[len(boundary)-1]) { - boundary = boundary[:len(boundary)-1] + if len(footprint.HorizonEnds) != 2 { + return false } - polygon := solarEclipsePartialFootprintPolygon(boundary, footprint.Time, footprint.Closed, frame.Projection) - for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) { + } + return true +} + +func solarClosureGeoPoints(points []eclipsecore.SolarEclipsePathPoint) []geodata.GeoPoint { + result := make([]geodata.GeoPoint, len(points)) + for index, point := range points { + result[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + return result +} + +// solarClosureFootprint 转换核心层瞬时足迹;擦地点点数不是 2 时按缺失处理。 +func solarClosureFootprint(footprint eclipsecore.SolarEclipsePartialFootprint) solarclosure.Footprint { + segments := make([][]geodata.GeoPoint, len(footprint.Boundaries)) + for index, source := range footprint.Boundaries { + segments[index] = solarClosureGeoPoints(source) + } + ends := make([]geodata.GeoPoint, 0, 2) + if len(footprint.HorizonEnds) == 2 { + for _, end := range footprint.HorizonEnds { + ends = append(ends, geodata.GeoPoint{Longitude: end.Longitude, Latitude: end.Latitude}) + } + } + subsolar := solarEclipseSubsolarPoint(footprint.Time) + return solarclosure.Footprint{ + Boundaries: segments, + HorizonEnds: ends, + Subsolar: geodata.GeoPoint{Longitude: subsolar.Longitude, Latitude: subsolar.Latitude}, + Closed: footprint.Closed, + } +} + +func solarEclipsePartialFootprintSweepPath(info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame) string { + var builder strings.Builder + for _, footprint := range info.Footprints { + polygon := solarEclipsePartialFootprintPolygon(footprint, frame.Projection) + for _, fragment := range svgmap.PolygonFragments(polygon, frame.Clip()) { appendEclipseMapPolygonPathConsistent(&builder, frame, fragment) } } return builder.String() } +// solarEclipsePartialFootprintPolygon 返回瞬时足迹的填充多边形:开放边界有精确擦地点时用它闭合, +// 否则退回 subsolar 地平圈的近似弧。 func solarEclipsePartialFootprintPolygon( - boundary []svgmap.GeoPoint, - value time.Time, - closed bool, + footprint eclipsecore.SolarEclipsePartialFootprint, projection svgmap.Projection, ) []svgmap.GeoPoint { - polygon := append([]svgmap.GeoPoint(nil), boundary...) - if len(boundary) < 2 { - return polygon - } - - if !closed { - terminator := svgmap.SphericalCircle(solarEclipseSubsolarPoint(value), 90, 360) - polygon = append(polygon, svgmap.ShortestCircleArc(terminator, boundary[len(boundary)-1], boundary[0])...) + polygon, _, ok := solarclosure.Ring(solarClosureFootprint(footprint), true) + if !ok { + return nil } if interior, ok := solarEclipseSphericalBoundaryCentroid(polygon); ok && projection == svgmap.ProjectionEquirectangular { polygon = solarEclipseAppendEquirectangularPoleRim(polygon, interior) @@ -480,38 +798,247 @@ func projectedPolygonArea(frame svgmap.Frame, points []svgmap.GeoPoint) float64 func writeSolarEclipseCentralPath( builder *strings.Builder, path eclipsecore.SolarEclipsePath, + bandFootprints []eclipsecore.SolarEclipsePartialFootprint, + bandSegments [][]eclipsecore.SolarEclipsePathPoint, frame svgmap.Frame, eclipseType eclipsecore.SolarEclipseType, ) { - count := len(path.NorthernLimit) - paired := count == len(path.SouthernLimit) && count >= 2 - for index := 0; paired && index < count; index++ { - paired = !path.NorthernLimit[index].Time.IsZero() && - path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) + northern := path.NorthernLimit + southern := path.SouthernLimit + physicalEndpointSweeps := false + if path.Eclipse.Centrality == eclipsecore.SolarEclipseCentralTwoLimits { + if north, south, ok := solarEclipseTwoLimitPresentationLimits(path); ok { + northern, southern = north, south + physicalEndpointSweeps = true + } } - if paired { - polygon := make([]svgmap.GeoPoint, 0, 2*count) - for _, point := range path.NorthernLimit[:count] { + count := len(northern) + paired := count == len(southern) && count >= 2 + for index := 0; paired && index < count; index++ { + paired = !northern[index].Time.IsZero() && northern[index].Time.Equal(southern[index].Time) + } + coveragePath := path + coveragePath.NorthernLimit = northern + coveragePath.SouthernLimit = southern + if len(bandSegments) > 0 && !solarEclipseCentralEnvelopeCoversPath(bandSegments, coveragePath) { + bandSegments = nil + } + if !writeSolarEclipseCentralBandEnvelope(builder, bandSegments, frame, eclipseType, true) && paired { + capacity := 2 * count + if physicalEndpointSweeps { + capacity += 2 + } + polygon := make([]svgmap.GeoPoint, 0, capacity) + if physicalEndpointSweeps { + point := path.CenterLine[0] + polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + for _, point := range northern[:count] { + polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + if physicalEndpointSweeps { + point := path.CenterLine[len(path.CenterLine)-1] polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) } for index := count - 1; index >= 0; index-- { - point := path.SouthernLimit[index] + point := southern[index] polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) } + polygons := [][]svgmap.GeoPoint{polygon} + if physicalEndpointSweeps { + if merged, ok := solarEclipseTwoLimitBandPolygons( + path, northern, southern, bandFootprints, + ); ok { + polygons = merged + } + } color := solarEclipseCentralPathColor(eclipseType) - fmt.Fprintf(builder, ``, color) - for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) { - builder.WriteString(``) + fmt.Fprintf(builder, ``, eclipseMapSourcePairedLimitChords, color) + for _, merged := range polygons { + for _, fragment := range svgmap.PolygonFragments(merged, frame.Clip()) { + builder.WriteString(``) + } } builder.WriteString(``) } - writeSolarPathLine(builder, frame, path.NorthernLimit, "northern-central-limit", "#7c2f28", 1.2, "") - writeSolarPathLine(builder, frame, path.SouthernLimit, "southern-central-limit", "#7c2f28", 1.2, "") + writeSolarPathLine(builder, frame, northern, "northern-central-limit", "#7c2f28", 1.2, "") + writeSolarPathLine(builder, frame, southern, "southern-central-limit", "#7c2f28", 1.2, "") writeSolarPathLine(builder, frame, path.CenterLine, "solar-center-line", "#263f58", 1.8, "5 3") } +func solarEclipseCentralEnvelopeCoversPath( + segments [][]eclipsecore.SolarEclipsePathPoint, + path eclipsecore.SolarEclipsePath, +) bool { + if len(segments) == 0 { + return false + } + polygons := make([][]geodata.GeoPoint, 0, len(segments)) + for _, segment := range segments { + if len(segment) < 3 { + return false + } + polygon := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, polygon) + } + // 限制线与中心线都要落在包络内:真正绘制的就是这三条线。限界两端含掠地端帽采样, + // 允许端部偏差,但整条限界远离包络说明包络并未覆盖将要绘制的中心带。 + if len(path.CenterLine) < 2 { + return false + } + if solarEclipseCentralEnvelopeMissDistanceKM(polygons, path.CenterLine) > + solarEclipseCentralEnvelopeAxisMissToleranceKM { + return false + } + limitToleranceKM := solarEclipseCentralEnvelopeAxisMissToleranceKM + if width := path.Eclipse.PathWidthKM; width > 0 && !math.IsInf(width, 1) { + limitToleranceKM = math.Max(limitToleranceKM, solarEclipseCentralEnvelopeLimitMissFraction*width) + } + for _, series := range [][]eclipsecore.SolarEclipsePathPoint{path.NorthernLimit, path.SouthernLimit} { + if solarEclipseCentralEnvelopeMissDistanceKM(polygons, series) > limitToleranceKM { + return false + } + } + return true +} + +func solarEclipseCentralEnvelopeMissDistanceKM( + polygons [][]geodata.GeoPoint, + points []eclipsecore.SolarEclipsePathPoint, +) float64 { + if len(points) == 0 { + return 0 + } + line := make([]geodata.GeoPoint, len(points)) + for index, point := range points { + line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + return geodata.SphericalPolygonsPathMissDistanceKM(polygons, [][]geodata.GeoPoint{line}, false) +} + +func solarEclipseTwoLimitBandPolygons( + path eclipsecore.SolarEclipsePath, + northern, southern []eclipsecore.SolarEclipsePathPoint, + footprints []eclipsecore.SolarEclipsePartialFootprint, +) ([][]svgmap.GeoPoint, bool) { + if len(northern) < 2 || len(northern) != len(southern) || len(footprints) == 0 { + return nil, false + } + middle := make([]geodata.GeoPoint, 0, len(northern)+len(southern)) + for _, point := range northern { + middle = append(middle, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + for index := len(southern) - 1; index >= 0; index-- { + point := southern[index] + middle = append(middle, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + endSweeps, err := solarEclipseMonotoneCentralShadowSweepPolygons(footprints) + if err != nil { + return nil, false + } + inputs := make([][]geodata.GeoPoint, 0, 1+len(endSweeps)+2) + inputs = append(inputs, middle) + inputs = append(inputs, endSweeps...) + inputs = append(inputs, solarEclipseCentralBandInnerTransitionCaps(footprints)...) + inputs = append(inputs, solarEclipseCentralBandContactCaps( + footprints, path.NorthernLimit[0], path.NorthernLimit[len(path.NorthernLimit)-1], + )...) + merged, err := geodata.UnionPolygons(inputs) + if err != nil || len(merged) != 1 { + return nil, false + } + polygons := make([][]svgmap.GeoPoint, len(merged)) + for polygonIndex, source := range merged { + polygon := make([]svgmap.GeoPoint, len(source)) + for pointIndex, point := range source { + polygon[pointIndex] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons[polygonIndex] = polygon + } + return polygons, true +} + +func solarEclipseCentralBandContactCaps( + footprints []eclipsecore.SolarEclipsePartialFootprint, + startContact, endContact eclipsecore.SolarEclipsePathPoint, +) [][]geodata.GeoPoint { + if len(footprints) == 0 { + return nil + } + caps := make([][]geodata.GeoPoint, 0, 2) + appendCap := func(contact eclipsecore.SolarEclipsePathPoint, footprint eclipsecore.SolarEclipsePartialFootprint) { + segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries)) + for _, source := range footprint.Boundaries { + segment := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + segments = append(segments, segment) + } + boundary := geodata.JoinPolylineSegments(segments) + if len(boundary) > 1 && geodata.SameGeoPoint(boundary[0], boundary[len(boundary)-1]) { + boundary = boundary[:len(boundary)-1] + } + if len(boundary) < 2 { + return + } + caps = append(caps, []geodata.GeoPoint{ + {Longitude: contact.Longitude, Latitude: contact.Latitude}, + boundary[0], boundary[len(boundary)-1], + }) + } + appendCap(startContact, footprints[0]) + appendCap(endContact, footprints[len(footprints)-1]) + return caps +} + +// The path API retains U1/U4 compatibility samples. Rendered side lines use +// only the paired interval; the band fill adds the sampled physical endpoint +// sweeps separately so the duplicate outer samples cannot form long wedges. +func solarEclipseTwoLimitPresentationLimits( + path eclipsecore.SolarEclipsePath, +) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint, bool) { + northern, southern, centerLine := path.NorthernLimit, path.SouthernLimit, path.CenterLine + if len(northern) != len(southern) || len(northern) < 4 || len(centerLine) < 2 { + return nil, nil, false + } + start := centerLine[0].Time + end := centerLine[len(centerLine)-1].Time + if start.IsZero() || end.IsZero() || !start.Before(end) || + !northern[0].Time.Before(start) || !northern[len(northern)-1].Time.After(end) || + !solarEclipseSamePathPoint(northern[0], southern[0]) || + !solarEclipseSamePathPoint(northern[len(northern)-1], southern[len(southern)-1]) { + return nil, nil, false + } + first := 0 + for first < len(northern) && !northern[first].Time.After(start) { + first++ + } + last := first + for last < len(northern) && northern[last].Time.Before(end) { + last++ + } + if first == 0 || last >= len(northern) || last-first < 2 { + return nil, nil, false + } + for index := first; index < last; index++ { + if northern[index].Time.IsZero() || !northern[index].Time.Equal(southern[index].Time) { + return nil, nil, false + } + } + return northern[first:last], southern[first:last], true +} + +func solarEclipseSamePathPoint(first, second eclipsecore.SolarEclipsePathPoint) bool { + return math.Abs(first.Longitude-second.Longitude) <= 1e-9 && + math.Abs(first.Latitude-second.Latitude) <= 1e-9 +} + func writeSolarPathLine( builder *strings.Builder, frame svgmap.Frame, @@ -524,25 +1051,20 @@ func writeSolarPathLine( for index, point := range points { geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} } - writeEclipseMapGeoLine(builder, frame, geographic, className, color, width, dash, "solar-map-clip") + writeEclipseMapGeoLine(builder, frame, geographic, className, color, width, dash, "solar-map-clip", eclipseMapSourceCentralPathLimits) } -func writeSolarEclipseGreatestMarker( - builder *strings.Builder, - info eclipsecore.SolarEclipseInfo, - frame svgmap.Frame, - language string, -) { - x, y, ok := frame.Project(info.GreatestLongitude, info.GreatestLatitude) - if !ok { +func writeSolarEclipseGreatestMarker(builder *strings.Builder, label solarEclipseMapPointLabel) { + if label.text == "" { return } - label := "食甚" - if language == "en" { - label = "Greatest" + fmt.Fprintf(builder, ``, + label.x, label.y) + if label.ok { + fmt.Fprintf(builder, `%s`, + label.placed.X, label.placed.Y, label.placed.Anchor, html.EscapeString(label.text)) } - fmt.Fprintf(builder, `%s`, - x, y, x, y-10, html.EscapeString(label)) + builder.WriteString(``) } func solarEclipseMapTitle(info eclipsecore.SolarEclipseInfo, options SolarEclipseMapSVGOptions) string { @@ -558,9 +1080,12 @@ func solarEclipseMapTitle(info eclipsecore.SolarEclipseInfo, options SolarEclips func writeSolarEclipseMapSummary( builder *strings.Builder, + labels *svgchart.LabelTable, info eclipsecore.SolarEclipseInfo, local eclipsecore.LocalSolarEclipseInfo, hasLocal bool, + geocentric eclipsecore.SolarEclipseGeocentricPanel, + hasGeocentric bool, options SolarEclipseMapSVGOptions, ) { start := info.PartialBeginOnEarth.In(options.Location) @@ -576,22 +1101,32 @@ func writeSolarEclipseMapSummary( text = fmt.Sprintf("Partial begins %s | Greatest %s | Partial ends %s (%s) | magnitude %.3f | Gamma %.4f", start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.Magnitude, info.Gamma) } + summaryX := float64(options.Width) / 2 + labels.ReserveText(summaryX, 82, 13, text, "middle") fmt.Fprintf(builder, `%s`, - float64(options.Width)/2, html.EscapeString(text)) + summaryX, html.EscapeString(text)) details := make([]string, 0, 4) if info.HasCentral { if options.Language == "en" { - details = append(details, fmt.Sprintf("path width %.1f km", info.PathWidthKM)) + details = append(details, fmt.Sprintf("central path width %.1f km", info.PathWidthKM)) } else { - details = append(details, fmt.Sprintf("食带宽 %.1f km", info.PathWidthKM)) + details = append(details, fmt.Sprintf("中心食带宽 %.1f km", info.PathWidthKM)) } } if info.HasSaros { if options.Language == "en" { - details = append(details, fmt.Sprintf("Solar Saros %d, member %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count)) + label := fmt.Sprintf("Saros series %d, member %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count) + if !info.Saros.Verified { + label += " (provisional)" + } + details = append(details, label) } else { - details = append(details, fmt.Sprintf("太阳沙罗 %d,第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)) + label := fmt.Sprintf("沙罗序列 %d,第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count) + if !info.Saros.Verified { + label += "(推算)" + } + details = append(details, label) } } if hasLocal { @@ -612,9 +1147,51 @@ func writeSolarEclipseMapSummary( } } if len(details) > 0 { + detailText := strings.Join(details, " | ") + labels.ReserveText(summaryX, 106, 11, detailText, "middle") fmt.Fprintf(builder, `%s`, - float64(options.Width)/2, html.EscapeString(strings.Join(details, " | "))) + summaryX, html.EscapeString(detailText)) } + conjunctionY := 0.0 + if hasGeocentric { + conjunctionY = 128 + conjunction := geocentric.RightAscensionConjunction + label := "地心合(视赤经相等)" + if options.Language == "en" { + label = "Geocentric conjunction (equal apparent right ascension)" + } + conjunctionText := fmt.Sprintf("%s = %s UT | J.D. = %.6f", + label, conjunction.UTC().Format("15:04:05.0"), geocentric.RightAscensionConjunctionJD) + labels.ReserveText(summaryX, conjunctionY, 11, conjunctionText, "middle") + fmt.Fprintf(builder, `%s`, + summaryX, conjunctionY, html.EscapeString(conjunctionText)) + } + // 图上所有时刻都按展示时区,这里明确写出它与 UT 的偏差,避免被当成 UT 读。 + zoneNote := solarEclipseTimeZoneNote(maximum, options.Language) + labels.ReserveText(summaryX, conjunctionY+20, 11, zoneNote, "middle") + fmt.Fprintf(builder, `%s`, + summaryX, conjunctionY+20, html.EscapeString(zoneNote)) +} + +func solarEclipseTimeZoneNote(maximum time.Time, language string) string { + name, offsetSeconds := maximum.Zone() + if offsetSeconds == 0 { + if language == "en" { + return "All times are UT" + } + return "图中时刻为 UT" + } + sign := "+" + if offsetSeconds < 0 { + sign = "-" + offsetSeconds = -offsetSeconds + } + hours := offsetSeconds / 3600 + minutes := (offsetSeconds % 3600) / 60 + if language == "en" { + return fmt.Sprintf("All times are %s (UT%s%02d:%02d)", name, sign, hours, minutes) + } + return fmt.Sprintf("图中时刻为 %s(UT%s%02d:%02d)", name, sign, hours, minutes) } func formatSolarEclipseMapDuration(value time.Duration) string { @@ -625,85 +1202,31 @@ func formatSolarEclipseMapDuration(value time.Duration) string { return fmt.Sprintf("%02d:%02d", seconds/60, seconds%60) } -func writeSolarEclipseMapLegend( - builder *strings.Builder, - layout solarEclipseMapLayout, - info eclipsecore.SolarEclipseInfo, - hasCentral bool, - options SolarEclipseMapSVGOptions, -) { - type legendItem struct { - label string - kind string - color string - dash string - } - items := []legendItem{{label: "偏食可见区", kind: "fill", color: "#dfb84d"}} - if hasCentral { - items = append(items, - legendItem{label: solarEclipseCentralPathLabel(info.Type, options.Language), kind: "fill", color: solarEclipseCentralPathColor(info.Type)}, - legendItem{label: "中心线", kind: "line", color: "#263f58", dash: "5 3"}, - ) - } - if options.PenumbralOutlineStep > 0 { - items = append(items, legendItem{ - label: solarEclipseOutlineLegendLabel("penumbra", info.Type, options.PenumbralOutlineStep, options.Language), - kind: "line", color: "#b07a18", dash: "3 3", - }) - } - items = append(items, legendItem{label: "食甚晨昏圈", kind: "line", color: "#6f7778", dash: "4 3"}) - if hasCentral && options.CentralShadowStep > 0 { - items = append(items, legendItem{ - label: solarEclipseOutlineLegendLabel("central", info.Type, options.CentralShadowStep, options.Language), - kind: "line", color: "#7b5a42", - }) - } - contactLabel := "P/U 影锥接触" - if !hasCentral { - contactLabel = "P 半影接触" - } - if options.Language == "en" { - items[0].label = "Partial-eclipse visibility" - if hasCentral { - items[2].label = "Center line" - } - for index := range items { - if items[index].label == "食甚晨昏圈" { - items[index].label = "Terminator at greatest" - } - } - contactLabel = "P/U shadow contacts" - if !hasCentral { - contactLabel = "P penumbral contacts" - } - } - items = append(items, legendItem{label: contactLabel, kind: "contact", color: "#a52d70"}) - - columns := len(items) - if columns > 4 { - columns = 4 - } - legendWidth := layout.panelX + layout.panelWidth - layout.frame.X - itemWidth := legendWidth / float64(columns) - baseY := layout.frame.Y + layout.frame.Height + 29 +func writeSolarEclipseMapLegend(builder *strings.Builder, layout solarEclipseMapLayout, rows [][]solarEclipseMapLegendItem) { builder.WriteString(``) - for index, item := range items { - row, column := index/columns, index%columns - x := layout.frame.X + float64(column)*itemWidth - y := baseY + float64(row)*22 - switch item.kind { - case "line": - fmt.Fprintf(builder, ``, - x, y-4, x+20, y-4, item.color, item.dash) - case "contact": - fmt.Fprintf(builder, ``, - x+8, y-4, item.color) - default: - fmt.Fprintf(builder, ``, - x, y-8, item.color) + for row, items := range rows { + y := layout.legendY + float64(row)*solarEclipseLegendLineStep + x := layout.frame.X + if layout.nasa { + x = layout.panelX + } + for _, item := range items { + switch item.kind { + case "line": + fmt.Fprintf(builder, ``, + x, y-4, x+20, y-4, item.color, item.dash) + case "contact": + fmt.Fprintf(builder, ``, + x+8, y-4, item.color) + default: + fmt.Fprintf(builder, ``, + x, y-8, item.color) + } + fmt.Fprintf(builder, `%s`, + x+solarEclipseLegendIconWidth, y, solarEclipseLegendFontSize, html.EscapeString(item.label)) + x += solarEclipseLegendIconWidth + svgchart.EstimatedTextWidth(item.label, solarEclipseLegendFontSize) + + solarEclipseLegendColumnGap } - fmt.Fprintf(builder, `%s`, - x+25, y, html.EscapeString(item.label)) } builder.WriteString(``) } @@ -745,22 +1268,45 @@ func solarEclipseMapStepLabel(step time.Duration, language string) string { return fmt.Sprintf("%d 分钟", minutes) } +const ( + solarEclipseMapFooterFontSize = 11.0 + solarEclipseMapFooterBaselineInset = 38.0 +) + +func solarEclipseMapFooterText(options SolarEclipseMapSVGOptions, projection svgmap.Projection) string { + if options.FooterNote != "" { + return options.FooterNote + } + if options.Language == "en" { + return eclipseMapProjectionLabel(projection, "en") + "; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries." + } + return eclipseMapProjectionLabel(projection, "zh") + ";偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。" +} + func writeSolarEclipseMapFooter( builder *strings.Builder, - frame svgmap.Frame, + layout solarEclipseMapLayout, options SolarEclipseMapSVGOptions, projection svgmap.Projection, ) { - text := options.FooterNote - if text == "" { - if options.Language == "en" { - text = eclipseMapProjectionLabel(projection, "en") + "; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries." - } else { - text = eclipseMapProjectionLabel(projection, "zh") + ";偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。" - } + // 详细版式的球面居中,页脚要从左边距起排,否则长句子会被图框裁掉。 + footerX := layout.frame.X + if layout.nasa { + footerX = layout.margin + } + baseline := float64(options.Height) - solarEclipseMapFooterBaselineInset + // 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。 + lines := []string{solarEclipseMapFooterText(options, projection)} + if options.FooterNote != "" { + maxWidth := float64(options.Width) - footerX - layout.margin + lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, solarEclipseMapFooterFontSize), + maxWidth, solarEclipseMapFooterFontSize, + svgchart.BaselineLineLimit(solarEclipseMapFooterFontSize, 15, baseline, float64(options.Height)-4)) + } + for index, line := range lines { + fmt.Fprintf(builder, `%s`, + footerX, baseline+float64(index)*15, solarEclipseMapFooterFontSize, html.EscapeString(line)) } - fmt.Fprintf(builder, `%s`, - frame.X, float64(options.Height)-38, html.EscapeString(text)) } func solarEclipseMapTypeName(value eclipsecore.SolarEclipseType, language string) string { diff --git a/eclipse/svg/solar_map_benchmark_test.go b/eclipse/svg/solar_map_benchmark_test.go new file mode 100644 index 0000000..15e2553 --- /dev/null +++ b/eclipse/svg/solar_map_benchmark_test.go @@ -0,0 +1,46 @@ +package svg + +import ( + "testing" + "time" +) + +// 一秒步长请求曾经要跑满整条 18605 个瞬时足迹的扫描;夹到两分钟后应与默认请求同量级。 +func BenchmarkSolarEclipseMapSVGPartialStepOneSecond(b *testing.B) { + date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) + options := SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, PartialStep: time.Second, + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, ok := SolarEclipseMapSVG(date, options); !ok { + b.Fatal("missing map") + } + } +} + +func BenchmarkSolarEclipseMapSVG(b *testing.B) { + for _, tc := range []struct { + name string + date time.Time + }{ + {name: "2010Annular", date: time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC)}, + {name: "2009Total", date: time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)}, + } { + b.Run(tc.name, func(b *testing.B) { + options := SolarEclipseMapSVGOptions{ + Width: 1200, Height: 800, Location: time.UTC, + PartialStep: 2 * time.Minute, CentralStep: 2 * time.Minute, + GreatestTimeStep: solarEclipseMapDefaultGreatestTimeStep, + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, ok := SolarEclipseMapSVG(tc.date, options); !ok { + b.Fatal("missing map") + } + } + }) + } +} diff --git a/eclipse/svg/solar_map_closure_test.go b/eclipse/svg/solar_map_closure_test.go new file mode 100644 index 0000000..8ec90c2 --- /dev/null +++ b/eclipse/svg/solar_map_closure_test.go @@ -0,0 +1,232 @@ +package svg + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" +) + +// 一致性契约:同一事件下 SVG 与 GeoJSON 的偏食可见域必须落在同一几何上。 +// 并集只允许面选择带来的亚千米差异,逐足迹区域必须逐个重合在精确擦地点上。 +func TestSolarEclipsePartialRegionMatchesGeoJSON(t *testing.T) { + for _, date := range closureConsistencyDates() { + info, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, + }) + if !ok { + t.Fatalf("missing partial footprints for %s", date.Format("2006-01-02")) + } + data, err := geojson.MarshalSolarEclipse(info, nil) + if err != nil { + t.Fatalf("%s: %v", date.Format("2006-01-02"), err) + } + regions := closureGeoJSONRegions(t, data) + band := regions["partial-band"] + if len(band) == 0 { + t.Fatalf("%s: GeoJSON carries no partial-band region", date.Format("2006-01-02")) + } + polygons, ok := solarEclipsePartialBandPolygons(info) + if !ok { + t.Fatalf("%s: SVG carries no partial-band union", date.Format("2006-01-02")) + } + bandDeviation := closureSetDeviationKM(polygons, band) + if bandDeviation > closureBandToleranceKM { + t.Fatalf("%s: band regions differ by %.6f km, tolerance %.6f km", + date.Format("2006-01-02"), bandDeviation, closureBandToleranceKM) + } + + footprintDeviation := 0.0 + compared, skipped := 0, 0 + for _, footprint := range info.Footprints { + reference := closureFootprintRegionByTime(regions, footprint.Time) + if len(reference) == 0 { + continue + } + // 极区足迹沿地图上下边闭合成环,两侧的拓扑表示不同,只比对非极区足迹。 + if closureRegionsReachPole(reference) { + skipped++ + continue + } + polygon := solarEclipsePartialFootprintPolygon(footprint, geodata.ProjectionEquirectangular) + if len(polygon) < 3 { + t.Fatalf("%s: SVG dropped the %s footprint region", + date.Format("2006-01-02"), footprint.Time.Format(time.RFC3339)) + } + deviation := closureRingDeviationKM(polygon, reference) + if !footprint.Closed && !closureRingCarriesGrazingPoints(polygon, footprint) { + t.Fatalf("%s: the %s footprint region ignores its exact grazing points (deviation %.4f km)", + date.Format("2006-01-02"), footprint.Time.Format(time.RFC3339), deviation) + } + footprintDeviation = math.Max(footprintDeviation, deviation) + compared++ + } + if compared == 0 { + t.Fatalf("%s: no footprint region pair was compared", date.Format("2006-01-02")) + } + if footprintDeviation > closureFootprintToleranceKM { + t.Fatalf("%s: footprint regions differ by %.6f km, tolerance %.6f km", + date.Format("2006-01-02"), footprintDeviation, closureFootprintToleranceKM) + } + t.Logf("%s band=%.6f km footprints=%d skipped=%d max=%.6f km", + date.Format("2006-01-02"), bandDeviation, compared, skipped, footprintDeviation) + } +} + +// 缺少精确擦地点时必须降级到逐足迹扫掠,并让 data-source 落到采样扫掠词条。 +func TestSolarEclipsePartialRegionDegradesWithoutExactHorizon(t *testing.T) { + date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) + info, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 96, + }) + if !ok { + t.Fatal("missing 2024-04-08 partial footprints") + } + options := SolarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: time.UTC} + frame := solarEclipseMapLayoutFor(options, geodata.ProjectionEquirectangular, + geodata.GeoPoint{Longitude: info.Eclipse.GreatestLongitude, Latitude: info.Eclipse.GreatestLatitude}).frame + if _, source := solarEclipsePartialSweepPath(info, frame); source != eclipseMapSourcePartialBandUnion { + t.Fatalf("source=%q, want %q", source, eclipseMapSourcePartialBandUnion) + } + + degraded := info + degraded.Footprints = append([]eclipse.SolarEclipsePartialFootprint(nil), info.Footprints...) + open := 0 + for index := range degraded.Footprints { + if degraded.Footprints[index].Closed { + continue + } + degraded.Footprints[index].HorizonEnds = nil + open++ + } + if open == 0 { + t.Fatal("the event has no horizon-cut footprint to degrade") + } + if polygons, ok := solarEclipsePartialBandPolygons(degraded); ok || polygons != nil { + t.Fatalf("union survived without exact grazing points: %d polygons", len(polygons)) + } + path, source := solarEclipsePartialSweepPath(degraded, frame) + if source != eclipseMapSourceSampledFootprintSweep { + t.Fatalf("degraded source=%q, want %q", source, eclipseMapSourceSampledFootprintSweep) + } + if path == "" { + t.Fatal("degraded sweep rendered no geometry") + } +} + +func closureConsistencyDates() []time.Time { + return []time.Time{ + time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), + time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), + time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), + time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC), + } +} + +type closureGeoJSONFeature struct { + Properties map[string]interface{} `json:"properties"` + Geometry struct { + Type string `json:"type"` + Coordinates json.RawMessage `json:"coordinates"` + } `json:"geometry"` +} + +type closureGeoJSONCollection struct { + Features []closureGeoJSONFeature `json:"features"` +} + +// closureGeoJSONRegions 按角色汇总多边形区域,时间属性用作逐足迹区域的键。 +func closureGeoJSONRegions(t *testing.T, data []byte) map[string][][]geodata.GeoPoint { + t.Helper() + var collection closureGeoJSONCollection + if err := json.Unmarshal(data, &collection); err != nil { + t.Fatalf("decode GeoJSON: %v", err) + } + regions := make(map[string][][]geodata.GeoPoint) + for _, feature := range collection.Features { + role, _ := feature.Properties["role"].(string) + if feature.Geometry.Type != "MultiPolygon" { + continue + } + var polygons [][][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode %s coordinates: %v", role, err) + } + key := role + if role == "partial-footprint" { + value, _ := feature.Properties["time"].(string) + key = role + "@" + value + } + for _, polygon := range polygons { + for _, ring := range polygon { + points := make([]geodata.GeoPoint, len(ring)) + for index, point := range ring { + if len(point) < 2 { + t.Fatalf("%s carries a %d-value coordinate", role, len(point)) + } + points[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + regions[key] = append(regions[key], points) + } + } + } + return regions +} + +// closureRegionsReachPole 报告区域是否触及极点;极区环沿地图边闭合,不参与几何比对。 +func closureRegionsReachPole(regions [][]geodata.GeoPoint) bool { + for _, ring := range regions { + for _, point := range ring { + if math.Abs(point.Latitude) >= 89.9 { + return true + } + } + } + return false +} + +// closureRingCarriesGrazingPoints 报告填充环是否仍以精确擦地点收口。 +func closureRingCarriesGrazingPoints(ring []geodata.GeoPoint, footprint eclipse.SolarEclipsePartialFootprint) bool { + if len(footprint.HorizonEnds) != 2 { + return false + } + for _, end := range footprint.HorizonEnds { + found := false + for _, point := range ring { + if math.Abs(point.Longitude-end.Longitude) < 1e-9 && math.Abs(point.Latitude-end.Latitude) < 1e-9 { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func closureFootprintRegionByTime(regions map[string][][]geodata.GeoPoint, value time.Time) [][]geodata.GeoPoint { + return regions["partial-footprint@"+value.UTC().Format(time.RFC3339Nano)] +} + +func closureSetDeviationKM(first, second [][]geodata.GeoPoint) float64 { + return math.Max( + geodata.SphericalPolygonsPathMissDistanceKM(second, first, true), + geodata.SphericalPolygonsPathMissDistanceKM(first, second, true), + ) +} + +func closureRingDeviationKM(ring []geodata.GeoPoint, reference [][]geodata.GeoPoint) float64 { + return closureSetDeviationKM([][]geodata.GeoPoint{ring}, reference) +} + +const ( + // 并集的面选择由瞬时足迹提示决定,两种闭合口径的实测差异在米级。 + closureBandToleranceKM = 1.0 + // 逐足迹区域两侧都直接调用同一个精确闭合,只允许序列化往返误差。 + closureFootprintToleranceKM = 0.01 +) diff --git a/eclipse/svg/solar_map_date_gate_test.go b/eclipse/svg/solar_map_date_gate_test.go new file mode 100644 index 0000000..03e5296 --- /dev/null +++ b/eclipse/svg/solar_map_date_gate_test.go @@ -0,0 +1,87 @@ +package svg + +import ( + "strings" + "testing" + "time" + + eclipsecore "b612.me/astro/eclipse" +) + +// 核心判定当天无日食时,三个日食图入口都必须返回 false,与 LunarEclipseMapSVG 的日期门一致; +// 偏食足迹是“取最近一次”语义,缺这道门会把邻近日期的图当成当天的图交出去。 +func TestSolarEclipseMapSVGRequiresEclipseOnDate(t *testing.T) { + type entry struct { + name string + render func(time.Time, SolarEclipseMapSVGOptions) (string, bool) + global func(time.Time) (eclipsecore.SolarEclipseInfo, bool) + } + entries := []entry{ + { + name: "default", + render: SolarEclipseMapSVG, + global: eclipsecore.SolarEclipseOnDateNASABulletinSplitK, + }, + { + name: "nasaSplitK", + render: SolarEclipseMapSVGNASABulletinSplitK, + global: eclipsecore.SolarEclipseOnDateNASABulletinSplitK, + }, + { + name: "iauSingleK", + render: SolarEclipseMapSVGIAUSingleK, + global: eclipsecore.SolarEclipseOnDateIAUSingleK, + }, + } + // 2024-04-08 是本组日期里唯一有日食的一天,其余都在最近一次日食前后半个朔望月内。 + for _, test := range []struct { + day string + want bool + }{ + {day: "2024-04-08", want: true}, + {day: "2024-04-15"}, + {day: "2024-04-20"}, + {day: "2024-04-23"}, + {day: "2024-04-24"}, + } { + date, err := time.Parse("2006-01-02", test.day) + if err != nil { + t.Fatalf("bad fixture date %q: %v", test.day, err) + } + for _, entry := range entries { + _, coreOK := entry.global(date) + if coreOK != test.want { + t.Fatalf("%s %s: core eclipse gate = %v, want %v", entry.name, test.day, coreOK, test.want) + } + rendered, ok := entry.render(date, SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute, + }) + if ok != test.want { + t.Fatalf("%s %s: map gate = %v, want %v (core says %v)", entry.name, test.day, ok, test.want, coreOK) + } + if test.want && rendered == "" { + t.Fatalf("%s %s: gate passed but the map is empty", entry.name, test.day) + } + if !test.want && rendered != "" { + t.Fatalf("%s %s: rejected date still returned %d bytes", entry.name, test.day, len(rendered)) + } + } + } +} + +// 过了日期门的日期必须仍带完整等时线层:等时线自己还要查一次核心,门不能把它挡掉。 +func TestSolarEclipseMapOnDateKeepsIsochrones(t *testing.T) { + rendered, ok := SolarEclipseMapSVG( + time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), + SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, + PartialStep: 10 * time.Minute, GreatestTimeStep: 30 * time.Minute, + }, + ) + if !ok { + t.Fatal("2024-04-08 is an eclipse date but the map was rejected") + } + if got := strings.Count(rendered, `class="solar-greatest-time-isoline"`); got != 7 { + t.Fatalf("isochrone paths = %d, want the full 7-branch fan", got) + } +} diff --git a/eclipse/svg/solar_map_detailed.go b/eclipse/svg/solar_map_detailed.go new file mode 100644 index 0000000..5eb8316 --- /dev/null +++ b/eclipse/svg/solar_map_detailed.go @@ -0,0 +1,264 @@ +package svg + +import ( + "fmt" + "html" + "math" + "strings" + "time" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgchart" + "b612.me/astro/internal/svgmap" +) + +// solarEclipseDetailedMapLayout 给出 NASA 摆法:球面居中放大,阶段面板在球面下方分三栏。 +// solarEclipseMapAspect 返回各投影下地图框的长宽比:圆盘类为 1,等经纬为 2。 +func solarEclipseMapAspect(projection svgmap.Projection) float64 { + if projection == svgmap.ProjectionEquirectangular { + return 2 + } + return 1 +} + +// solarEclipsePortraitMapLayout 是竖版详细版式:地图居中在上、数据块两行在下。 +func solarEclipsePortraitMapLayout( + width, height float64, + projection svgmap.Projection, + center svgmap.GeoPoint, + legendRows int, +) solarEclipseMapLayout { + aspect := solarEclipseMapAspect(projection) + margin := math.Max(30, math.Min(52, width*0.044)) + headerHeight := math.Max(190, height*0.175) + blockHeight := math.Max(76, height*0.058) + footerHeight := math.Max(64, height*0.05) + legendHeight := math.Max(46, solarEclipseMapLegendBlockHeight(legendRows)) + // 数据块最多六行,行高必须容下六行文字。 + panelHeight := math.Max(solarEclipseMinPanelHeight(6), height*0.10) + gap := math.Max(16, height*0.014) + panelGap := math.Max(18, width*0.022) + reserved := headerHeight + blockHeight + footerHeight + legendHeight + 2*panelHeight + 3*gap + mapWidth := math.Min(width-2*margin, math.Max(140, height-reserved)*aspect) + if mapWidth < 140 { + mapWidth = math.Max(140, width-2*margin) + } + mapHeight := mapWidth / aspect + frame := svgmap.Frame{ + X: (width - mapWidth) / 2, + Y: headerHeight + blockHeight, + Width: mapWidth, Height: mapHeight, Projection: projection, + CenterLongitude: center.Longitude, CenterLatitude: center.Latitude, + } + // 第一行三栏放阶段信息,第二行中间留给比例尺,两侧放历表常数与天平动。 + panelY := frame.Y + mapHeight + gap + 14 + panel2Y := panelY + panelHeight + gap + columnWidth := (width - 2*margin - 2*panelGap) / 3 + scaleSlot := svgchart.LabelBox{ + X: margin + columnWidth + panelGap, Y: panel2Y, Width: columnWidth, Height: panelHeight, + } + return solarEclipseMapLayout{ + frame: frame, + panelX: margin, + panelY: panelY, + panelWidth: width - 2*margin, + panelHeight: panelHeight, + nasa: true, + legendY: panel2Y + panelHeight + gap, + scaleY: scaleSlot.Y + scaleSlot.Height*0.45, + margin: margin, + panelGap: panelGap, + blockY: headerHeight, + blockHeight: blockHeight, + secondPanelY: panel2Y, + scaleSlot: scaleSlot, + } +} + +// writeSolarEclipseScaleBar 在球面图下方画比例尺;球面半径对应 90° 大圆弧长。 +// 刻度长度由槽位宽度反推,标注不会越过槽位压到旁边的数据块上。 +func writeSolarEclipseScaleBar( + builder *strings.Builder, + layout solarEclipseMapLayout, + options SolarEclipseMapSVGOptions, +) { + if !layout.nasa { + return + } + slot := layout.scaleSlot + radius := layout.frame.Width / 2 + if radius <= 0 || slot.Width <= 0 || slot.Height <= 0 { + return + } + kilometresPerPixel := (math.Pi / 2 * 6371.0088) / radius + target := 130.0 * kilometresPerPixel + step := solarEclipseScaleStep(target, slot.Width, func(candidate float64) float64 { + barWidth := candidate / kilometresPerPixel + return barWidth + svgchart.EstimatedTextWidth("0", 10) + + svgchart.EstimatedTextWidth(formatSolarEclipseScale(candidate)+" km", 10) + 24 + }) + if step <= 0 { + return + } + barWidth := step / kilometresPerPixel + centreX := slot.X + slot.Width/2 + y := layout.scaleY + left := centreX - barWidth/2 + fmt.Fprintf(builder, ``, + left, y, left+barWidth, y) + for _, tick := range []float64{0, 0.25, 0.5, 0.75, 1} { + x := left + tick*barWidth + length := 5.0 + if tick == 0 || tick == 1 { + length = 9 + } + fmt.Fprintf(builder, ``, + x, y-length/2, x, y+length/2) + } + label := "比例尺" + if options.Language == "en" { + label = "Scale" + } + fmt.Fprintf(builder, `0`, + left-8, y+4) + fmt.Fprintf(builder, `%s km`, + left+barWidth+8, y+4, html.EscapeString(formatSolarEclipseScale(step))) + fmt.Fprintf(builder, `%s`, + centreX, y+20, html.EscapeString(label)) + builder.WriteString(``) +} + +func formatSolarEclipseScale(kilometres float64) string { + if kilometres >= 1000 { + return fmt.Sprintf("%.0f", kilometres) + } + return fmt.Sprintf("%.0f", kilometres) +} + +func writeSolarEclipseDetailedPanels(builder *strings.Builder, cells []solarEclipsePanelCell, layout solarEclipseMapLayout) { + for _, cell := range cells { + if cell.box.Y < layout.secondPanelY { + svgchart.WritePanelBox(builder, "solar-detailed-panel", cell.box.X, cell.box.Y, + cell.box.Width, cell.box.Height, cell.title, cell.rows) + } + } +} + +var _ = time.Second + +// formatSolarEclipseRA 把赤经度数写成 HHhMMmSS.Ss。 +func formatSolarEclipseRA(degrees float64) string { + total := math.Mod(degrees, 360) / 15 + hours := math.Floor(total) + minutes := math.Floor((total - hours) * 60) + seconds := ((total-hours)*60 - minutes) * 60 + return fmt.Sprintf("%02.0fh%02.0fm%04.1fs", hours, minutes, seconds) +} + +// formatSolarEclipseDec 把赤纬度数写成 ±DD°MM'SS.S"。 +func formatSolarEclipseDec(degrees float64) string { + sign := "+" + if degrees < 0 { + sign = "-" + degrees = -degrees + } + whole := math.Floor(degrees) + minutes := math.Floor((degrees - whole) * 60) + seconds := ((degrees-whole)*60 - minutes) * 60 + return fmt.Sprintf("%s%02.0f°%02.0f'%04.1f\"", sign, whole, minutes, seconds) +} + +// formatSolarEclipseArcsec 把角秒写成 DD°MM'SS.S"。 +func formatSolarEclipseArcsec(arcsec float64) string { + whole := math.Floor(arcsec / 3600) + minutes := math.Floor((arcsec/3600 - whole) * 60) + seconds := ((arcsec/3600-whole)*60 - minutes) * 60 + return fmt.Sprintf("%02.0f°%02.0f'%04.1f\"", whole, minutes, seconds) +} + +func writeSolarEclipseGeocentricBlocks( + builder *strings.Builder, + panel eclipsecore.SolarEclipseGeocentricPanel, + layout solarEclipseMapLayout, + options SolarEclipseMapSVGOptions, +) { + if !layout.nasa { + return + } + sunTitle, moonTitle := solarEclipseGeocentricTitles(options) + sunRows, moonRows := solarEclipseGeocentricRows(panel) + if options.Language == "en" { + sunRows[3].Label, moonRows[3].Label = "H.P.", "H.P." + } + blocks := [2]solarEclipsePanelBlock{ + {title: sunTitle, rows: sunRows}, + {title: moonTitle, rows: moonRows}, + } + // 两块都按固定宽度排版,数值一律右对齐到块右缘,右侧块才不会顶出图框。 + blockWidth := math.Min(212, (layout.panelWidth-40)/2) + for index, block := range blocks { + x := layout.margin + if index == 1 { + x = layout.margin + layout.panelWidth - blockWidth + } + y := layout.blockY + fmt.Fprintf(builder, ``) + fmt.Fprintf(builder, `%s`, + x, y+11, html.EscapeString(block.title)) + for rowIndex, row := range block.rows { + rowY := y + 30 + float64(rowIndex)*17 + fmt.Fprintf(builder, `%s`, + x, rowY, html.EscapeString(row.Label)) + fmt.Fprintf(builder, `%s`, + x+blockWidth, rowY, html.EscapeString(row.Value)) + } + builder.WriteString(``) + } +} + +func writeSolarEclipseEphemerisPanels(builder *strings.Builder, cells []solarEclipsePanelCell, layout solarEclipseMapLayout) { + if !layout.nasa { + return + } + for _, cell := range cells { + if cell.box.Y < layout.secondPanelY { + continue + } + svgchart.WritePanelBox(builder, "solar-detailed-panel", cell.box.X, cell.box.Y, + cell.box.Width, cell.box.Height, cell.title, cell.rows) + } +} + +// solarEclipseCardinalLabel 是圆盘四边的方向标记。 +type solarEclipseCardinalLabel struct { + text string + x, y float64 +} + +// solarEclipseCardinalLabels 给出圆盘四边的方向标记位次;平面图没有圆盘边界,返回空。 +func solarEclipseCardinalLabels(layout solarEclipseMapLayout) []solarEclipseCardinalLabel { + if !layout.nasa || !layout.frame.IsDisk() { + return nil + } + centreX := layout.frame.X + layout.frame.Width/2 + centreY := layout.frame.Y + layout.frame.Height/2 + radius := math.Min(layout.frame.Width, layout.frame.Height)/2 + 13 + result := make([]solarEclipseCardinalLabel, 0, 4) + for index, text := range []string{"N", "E", "S", "W"} { + angle := float64(index) * math.Pi / 2 + result = append(result, solarEclipseCardinalLabel{ + text: text, + x: centreX + radius*math.Sin(angle), + y: centreY - radius*math.Cos(angle) + 4, + }) + } + return result +} + +// writeSolarEclipseCardinalMarkers 在圆盘四边标出 N/E/S/W。 +func writeSolarEclipseCardinalMarkers(builder *strings.Builder, layout solarEclipseMapLayout) { + for _, cardinal := range solarEclipseCardinalLabels(layout) { + fmt.Fprintf(builder, `%s`, + cardinal.x, cardinal.y, cardinal.text) + } +} diff --git a/eclipse/svg/solar_map_details.go b/eclipse/svg/solar_map_details.go index 4326949..7566062 100644 --- a/eclipse/svg/solar_map_details.go +++ b/eclipse/svg/solar_map_details.go @@ -9,6 +9,8 @@ import ( "time" eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/svgchart" "b612.me/astro/internal/svgmap" ) @@ -18,50 +20,77 @@ type solarEclipseMapLayout struct { panelY float64 panelWidth float64 panelHeight float64 + // nasa 为 true 时按 NASA 摆法排版:球面居中放大,阶段面板移到球面下方分三栏,图例再往下。 + // nasa switches to the NASA composition: a centred globe, phase panels in three columns + // below it, and the legend under those. + nasa bool + legendY float64 + scaleY float64 + margin float64 + panelGap float64 + blockY float64 + blockHeight float64 + secondPanelY float64 + // landscape 为真时用横版排布:地图在左,数据块两栏三行在右,地图下方那条放天平动与比例尺。 + landscape bool + gridX float64 + gridCellWidth float64 + gridCellHeight float64 + gridGapX float64 + gridGapY float64 + // 三行数据块的行高按各行行数分配:6 行块与 4 行块等分会把行距压到文字高度以下。 + gridRowY [solarEclipseLandscapeRows]float64 + gridRowHeights [solarEclipseLandscapeRows]float64 + bottomY float64 + // stripHeight 是地图下方那条里天平动盒的高度;scaleSlot 是比例尺槽,两者不重叠。 + stripHeight float64 + scaleSlot svgchart.LabelBox } -type solarEclipseGlobalEventRow struct { - kind string - name string - time time.Time - point eclipsecore.SolarEclipsePathPoint - hasPoint bool +// solarEclipsePanelBlock 是一个带标题的数据块,竖版与横版共用同一批内容。 +type solarEclipsePanelBlock struct { + title string + rows []svgchart.PanelRow } -func solarEclipseMapLayoutFor(options SolarEclipseMapSVGOptions, projection svgmap.Projection) solarEclipseMapLayout { +// solarEclipsePanelBlocks 汇总日食详细版式的全部数据块,两种版式只是排布不同。 +type solarEclipsePanelBlocks struct { + sun, moon solarEclipsePanelBlock + penumbra, umbra solarEclipsePanelBlock + circumstances, ephemeris solarEclipsePanelBlock + libration solarEclipsePanelBlock +} + +// solarEclipseMapLayoutFor 选详细版式:按画布朝向定排布,按投影定地图长宽比。 +// 横版把数据块放到地图右侧,竖版放到地图下方;两者内容完全一致。 +func solarEclipseMapLayoutFor( + options SolarEclipseMapSVGOptions, + projection svgmap.Projection, + center svgmap.GeoPoint, +) solarEclipseMapLayout { + // 只给几何关系的调用方用两行图例与四行、四行、六行的数据块行数。 + return solarEclipseMapLayoutForBlocks(options, projection, center, 2, solarEclipseLandscapeDefaultRowFields) +} + +// solarEclipseMapLayoutForBlocks 按图例行数与数据块行数排版:两者决定横带上下限,必须先算出来。 +func solarEclipseMapLayoutForBlocks( + options SolarEclipseMapSVGOptions, + projection svgmap.Projection, + center svgmap.GeoPoint, + legendRows int, + rowFields [solarEclipseLandscapeRows]float64, +) solarEclipseMapLayout { width := float64(options.Width) height := float64(options.Height) - margin := math.Max(26, math.Min(45, width*0.04)) - gap := math.Max(16, math.Min(24, width*0.025)) - panelWidth := math.Max(148, math.Min(238, width*0.22)) - availableWidth := width - 2*margin - gap - panelWidth - bottomReserve := 92.0 - if projection != svgmap.ProjectionEquirectangular { - bottomReserve = 118 - } - availableHeight := math.Max(110, height-142-bottomReserve) - mapWidth := math.Min(availableWidth, availableHeight*2) - mapHeight := mapWidth / 2 - if projection != svgmap.ProjectionEquirectangular { - mapWidth = math.Min(availableWidth, availableHeight) - mapHeight = mapWidth - } - mapY := 142 + math.Max(0, (availableHeight-mapHeight)/2) - groupWidth := mapWidth + gap + panelWidth - mapX := math.Max(margin, (width-groupWidth)/2) - return solarEclipseMapLayout{ - frame: svgmap.Frame{ - X: mapX, Y: mapY, Width: mapWidth, Height: mapHeight, Projection: projection, - }, - panelX: mapX + mapWidth + gap, - panelY: mapY, - panelWidth: panelWidth, - panelHeight: mapHeight, + if width >= height { + return solarEclipseLandscapeMapLayout(width, height, projection, center, legendRows, rowFields) } + return solarEclipsePortraitMapLayout(width, height, projection, center, legendRows) } func writeSolarEclipseMapSectionTitle( builder *strings.Builder, + labels *svgchart.LabelTable, layout solarEclipseMapLayout, options SolarEclipseMapSVGOptions, hasCentral bool, @@ -77,154 +106,47 @@ func writeSolarEclipseMapSectionTitle( } else { label = "全球见食范围" } + } else { + label = svgchart.EllipsizeText(label, layout.frame.Width-8, 14) } - fmt.Fprintf(builder, `%s`, - layout.frame.X, layout.frame.Y-10, html.EscapeString(label)) -} - -func writeSolarEclipseGlobalEventsPanel( - builder *strings.Builder, - partial eclipsecore.SolarEclipsePartialFootprintsInfo, - path eclipsecore.SolarEclipsePath, - hasCentral bool, - layout solarEclipseMapLayout, - options SolarEclipseMapSVGOptions, -) { - rows := solarEclipseGlobalEventRows(partial, path, hasCentral, options.Language) - title := options.EventsTitle - if title == "" { - if options.Language == "en" { - title = "Global phases" - } else { - title = "全球阶段" - } + anchor, x, y := "start", layout.frame.X, layout.frame.Y-10 + if layout.nasa { + // 让开球面顶端的 N 标记。 + anchor, x, y = "middle", layout.frame.X+layout.frame.Width/2, layout.frame.Y-28 } - fmt.Fprintf(builder, ``) - fmt.Fprintf(builder, ``, - layout.panelX-10, layout.panelY, layout.panelX-10, layout.panelY+layout.panelHeight) - fmt.Fprintf(builder, `%s`, - layout.panelX, layout.panelY+13, html.EscapeString(title)) - rowTop := layout.panelY + 27 - rowHeight := math.Max(21, (layout.panelHeight-27)/float64(len(rows))) - for index, row := range rows { - y := rowTop + float64(index)*rowHeight - if index > 0 { - fmt.Fprintf(builder, ``, - layout.panelX, y-5, layout.panelX+layout.panelWidth, y-5) - } - fmt.Fprintf(builder, `%s`, - layout.panelX, y+10, html.EscapeString(row.name)) - fmt.Fprintf(builder, `%s`, - layout.panelX+layout.panelWidth, y+10, html.EscapeString(row.time.In(options.Location).Format("15:04:05"))) - if row.hasPoint && rowHeight >= 45 { - fmt.Fprintf(builder, `%s`, - layout.panelX, y+27, html.EscapeString(solarEclipseFormatCoordinates(row.point.Longitude, row.point.Latitude))) - } - if row.hasPoint && rowHeight >= 52 { - detail := solarEclipseGlobalEventDetail(row, partial.Eclipse, options.Language) - fmt.Fprintf(builder, `%s`, - layout.panelX, y+43, html.EscapeString(detail)) - } - } - builder.WriteString(``) -} - -func solarEclipseGlobalEventRows( - partial eclipsecore.SolarEclipsePartialFootprintsInfo, - path eclipsecore.SolarEclipsePath, - hasCentral bool, - language string, -) []solarEclipseGlobalEventRow { - info := partial.Eclipse - names := []string{"偏食始", "中心食始", "食甚", "中心食终", "偏食终"} - if language == "en" { - names = []string{"Partial begins", "Central begins", "Greatest", "Central ends", "Partial ends"} - } - rows := make([]solarEclipseGlobalEventRow, 0, 11) - appendContact := func(name string, point eclipsecore.SolarEclipsePathPoint) { - if point.Time.IsZero() { - return - } - rows = append(rows, solarEclipseGlobalEventRow{ - kind: "shadow-contact", name: name, time: point.Time, point: point, hasPoint: true, - }) - } - appendContact("P1 "+names[0], partial.P1) - appendContact("P2", partial.P2) - appendContact("U1", partial.U1) - appendContact("U2", partial.U2) - if hasCentral && len(path.CenterLine) > 0 { - rows = append(rows, solarEclipseGlobalEventRow{ - kind: "central-start", name: names[1], time: info.CentralBeginOnEarth, - point: path.CenterLine[0], hasPoint: true, - }) - } - greatest := eclipsecore.SolarEclipsePathPoint{ - Time: info.GreatestEclipse, Longitude: info.GreatestLongitude, - Latitude: info.GreatestLatitude, WidthKM: info.PathWidthKM, - } - if hasCentral { - greatest = path.Greatest - } - rows = append(rows, solarEclipseGlobalEventRow{ - kind: "greatest", name: names[2], time: info.GreatestEclipse, - point: greatest, hasPoint: true, + // 图框上沿是标题带:表头文字已经占位,这里只在这条带里选位置。 + placement, ok := labels.Place(label, 14, []svgchart.LabelPlacement{ + {X: x, Y: y, Anchor: anchor}, + {X: x, Y: layout.frame.Y - 10, Anchor: anchor}, + {X: layout.frame.X, Y: layout.frame.Y - 10, Anchor: "start"}, + {X: layout.frame.X, Y: layout.frame.Y - 28, Anchor: "start"}, }) - appendContact("U3", partial.U3) - appendContact("U4", partial.U4) - if hasCentral && len(path.CenterLine) > 0 { - rows = append(rows, solarEclipseGlobalEventRow{ - kind: "central-end", name: names[3], time: info.CentralEndOnEarth, - point: path.CenterLine[len(path.CenterLine)-1], hasPoint: true, - }) + if !ok { + return } - appendContact("P3", partial.P3) - appendContact("P4 "+names[4], partial.P4) - if len(rows) == 0 || partial.P1.Time.IsZero() { - rows = append(rows, solarEclipseGlobalEventRow{kind: "partial-start", name: names[0], time: info.PartialBeginOnEarth}) - } - if partial.P4.Time.IsZero() { - rows = append(rows, solarEclipseGlobalEventRow{kind: "partial-end", name: names[4], time: info.PartialEndOnEarth}) - } - sort.SliceStable(rows, func(i, j int) bool { return rows[i].time.Before(rows[j].time) }) - return rows + fmt.Fprintf(builder, `%s`, + placement.X, placement.Y, placement.Anchor, html.EscapeString(label)) } -func solarEclipseGlobalEventDetail( - row solarEclipseGlobalEventRow, - info eclipsecore.SolarEclipseInfo, - language string, -) string { - if row.kind == "greatest" { - if info.HasCentral { - if language == "en" { - return fmt.Sprintf("Path width %.1f km", info.PathWidthKM) - } - return fmt.Sprintf("食带宽 %.1f km", info.PathWidthKM) - } - if language == "en" { - return fmt.Sprintf("Magnitude %.3f", info.Magnitude) - } - return fmt.Sprintf("食分 %.3f", info.Magnitude) - } - if language == "en" { - return fmt.Sprintf("Sun altitude %+.1f°", row.point.SunAltitude) - } - return fmt.Sprintf("太阳高度 %+.1f°", row.point.SunAltitude) -} - -func writeSolarEclipseTerminator( +func writeSolarEclipseRiseSetCurves( builder *strings.Builder, - info eclipsecore.SolarEclipseInfo, + curves []eclipsecore.SolarEclipseRiseSetCurve, frame svgmap.Frame, ) { - terminator := svgmap.SphericalCircle(solarEclipseSubsolarPoint(info.GreatestEclipse), 90, 360) - if len(terminator) > 0 { - terminator = append(terminator, terminator[0]) + for _, curve := range curves { + className := fmt.Sprintf("solar-rise-set-boundary solar-%s-%s", + html.EscapeString(string(curve.Phase)), html.EscapeString(string(curve.Direction))) + for _, segment := range curve.Segments { + points := make([]svgmap.GeoPoint, len(segment)) + for index, point := range segment { + points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + writeEclipseMapGeoLine( + builder, frame, points, className, "#d97706", 1.35, "6 4", "solar-map-clip", + eclipseMapSourceRiseSetPhaseLines, + ) + } } - writeEclipseMapGeoLine( - builder, frame, terminator, "solar-greatest-terminator", "#6f7778", 1.05, "4 3", "solar-map-clip", - ) } func writeSolarEclipsePenumbralOutlines( @@ -232,6 +154,7 @@ func writeSolarEclipsePenumbralOutlines( info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame, options SolarEclipseMapSVGOptions, + labels *svgchart.LabelTable, ) { if options.PenumbralOutlineStep <= 0 { return @@ -242,22 +165,27 @@ func writeSolarEclipsePenumbralOutlines( options.Location, info.Eclipse.GreatestEclipse, ) - labelPositions := make([][2]float64, 0, len(selected)) for _, footprint := range selected { writeSolarEclipseFootprintBoundary( builder, footprint, frame, "solar-penumbral-outline", "#b07a18", 0.75, "3 3", + eclipseMapSourcePenumbralOutlines, ) if mapTimeDistance(footprint.Time, info.Eclipse.GreatestEclipse) <= info.Step/2 { continue } x, y, ok := solarEclipseFootprintLabelPosition(footprint, frame) - if !ok || solarEclipseMapLabelOverlaps(x, y, labelPositions) { + if !ok { continue } - labelPositions = append(labelPositions, [2]float64{x, y}) labelTime := solarEclipseMapAlignedTime(footprint.Time, options.PenumbralOutlineStep, options.Location) - fmt.Fprintf(builder, `%s`, - x, y-4, html.EscapeString(labelTime.Format("15:04"))) + text := labelTime.Format("15:04") + placed, ok := labels.Place(text, 8, labelCandidatesInsideBox( + svgchart.LabelCandidates(x, y-4, "middle", 11), text, 8, solarEclipseFrameBox(frame))) + if !ok { + continue + } + fmt.Fprintf(builder, `%s`, + placed.X, placed.Y, placed.Anchor, html.EscapeString(text)) } } @@ -269,10 +197,170 @@ func writeSolarEclipseCentralShadowOutlines( for _, footprint := range footprints { writeSolarEclipseFootprintBoundary( builder, footprint, frame, "solar-central-shadow-outline", "#7b5a42", 0.8, "", + eclipseMapSourceCentralShadowOutlines, ) } } +func writeSolarEclipseCentralShadowSweep( + builder *strings.Builder, + footprints []eclipsecore.SolarEclipsePartialFootprint, + frame svgmap.Frame, + eclipseType eclipsecore.SolarEclipseType, +) { + if len(footprints) == 0 { + return + } + var path strings.Builder + hasOpen := false + samples := solarEclipseCentralShadowSweepSamples(footprints) + for _, footprint := range footprints { + hasOpen = hasOpen || !footprint.Closed + } + if hasOpen { + polygons, err := geodata.OpenBoundarySweep(samples) + if err == nil { + for _, polygon := range polygons { + if len(polygon) < 3 { + continue + } + for _, fragment := range svgmap.PolygonFragments(polygon, frame.Clip()) { + if len(fragment) < 3 { + continue + } + path.WriteString(``) + } + } + } + } + if !hasOpen { + for _, footprint := range footprints { + segments := make([][]svgmap.GeoPoint, 0, len(footprint.Boundaries)) + for _, source := range footprint.Boundaries { + segment := make([]svgmap.GeoPoint, len(source)) + for index, point := range source { + segment[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + segments = append(segments, segment) + } + boundary := svgmap.JoinPolylineSegments(segments) + if len(boundary) > 1 && svgmap.SameGeoPoint(boundary[0], boundary[len(boundary)-1]) { + boundary = boundary[:len(boundary)-1] + } + if len(boundary) < 3 { + continue + } + for _, fragment := range svgmap.PolygonFragments(boundary, frame.Clip()) { + if len(fragment) < 3 { + continue + } + path.WriteString(``) + } + } + } + if path.Len() == 0 { + return + } + color := solarEclipseCentralPathColor(eclipseType) + fmt.Fprintf(builder, `%s`, eclipseMapSourceSampledOpenSweep, color, path.String()) +} + +func solarEclipseMonotoneCentralShadowSweepPolygons( + footprints []eclipsecore.SolarEclipsePartialFootprint, +) ([][]geodata.GeoPoint, error) { + samples := solarEclipseCentralShadowSweepSamples(footprints) + polygons, err := geodata.MonotoneOpenBoundarySweep(samples) + if err != nil { + return geodata.OpenBoundarySweep( + geodata.DecimateOpenBoundarySweepSamples(samples, 24, 40), + ) + } + return polygons, nil +} + +func solarEclipseCentralBandInnerTransitionCaps( + footprints []eclipsecore.SolarEclipsePartialFootprint, +) [][]geodata.GeoPoint { + samples := solarEclipseCentralShadowSweepSamples(footprints) + samples = geodata.DecimateOpenBoundarySweepSamples(samples, len(samples), 40) + return geodata.OpenBoundarySweepInnerCaps(samples, 500) +} + +func solarEclipseCentralShadowSweepSamples( + footprints []eclipsecore.SolarEclipsePartialFootprint, +) []geodata.OpenBoundarySweepSample { + samples := make([]geodata.OpenBoundarySweepSample, 0, len(footprints)) + for _, footprint := range footprints { + boundaries := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries)) + for _, source := range footprint.Boundaries { + segment := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + boundaries = append(boundaries, segment) + } + samples = append(samples, geodata.OpenBoundarySweepSample{ + Boundaries: boundaries, + Closed: footprint.Closed, + }) + } + return samples +} + +func writeSolarEclipseCentralBandEnvelope( + builder *strings.Builder, + segments [][]eclipsecore.SolarEclipsePathPoint, + frame svgmap.Frame, + eclipseType eclipsecore.SolarEclipseType, + hasCentral bool, +) bool { + if len(segments) == 0 { + return false + } + polygons := make([][]geodata.GeoPoint, 0, len(segments)) + for _, segment := range segments { + if len(segment) < 4 { + return false + } + polygon := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, polygon) + } + merged, err := geodata.UnionPolygons(polygons) + if err != nil { + return false + } + var path strings.Builder + for _, polygon := range merged { + for _, fragment := range svgmap.PolygonFragments(polygon, frame.Clip()) { + if len(fragment) < 3 { + continue + } + path.WriteString(``) + } + } + if path.Len() == 0 { + return false + } + color := solarEclipseCentralPathColor(eclipseType) + // 非中心食的包络同样是从 CentralBandSegments 来的真实食带,只是没有中心线; + // 用更淡的填充会让人以为没画,两者共用同一透明度,仅类名不同以便区分。 + class, opacity := "solar-central-shadow-sweep", 0.30 + if hasCentral { + class = "central-eclipse-band" + } + fmt.Fprintf(builder, `%s`, class, eclipseMapSourceBesselianEnvelope, color, opacity, path.String()) + return true +} + func solarEclipseFootprintLabelPosition( footprint eclipsecore.SolarEclipsePartialFootprint, frame svgmap.Frame, @@ -295,15 +383,6 @@ func solarEclipseFootprintLabelPosition( return bestX, bestY, !math.IsInf(bestScore, 1) } -func solarEclipseMapLabelOverlaps(x, y float64, positions [][2]float64) bool { - for _, position := range positions { - if math.Abs(x-position[0]) < 52 && math.Abs(y-position[1]) < 17 { - return true - } - } - return false -} - func solarEclipseMapAlignedTime(value time.Time, step time.Duration, location *time.Location) time.Time { local := value.In(location) dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) @@ -317,14 +396,14 @@ func writeSolarEclipseFootprintBoundary( frame svgmap.Frame, className, color string, strokeWidth float64, - dash string, + dash, dataSource string, ) { for _, boundary := range footprint.Boundaries { points := make([]svgmap.GeoPoint, len(boundary)) for index, point := range boundary { points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} } - writeEclipseMapGeoLine(builder, frame, points, className, color, strokeWidth, dash, "solar-map-clip") + writeEclipseMapGeoLine(builder, frame, points, className, color, strokeWidth, dash, "solar-map-clip", dataSource) } } @@ -369,49 +448,15 @@ func mapTimeDistance(a, b time.Time) time.Duration { return value } -func writeSolarEclipseContactMarkers( - builder *strings.Builder, - info eclipsecore.SolarEclipsePartialFootprintsInfo, - frame svgmap.Frame, -) { - type marker struct { - name string - point eclipsecore.SolarEclipsePathPoint - color string - labelDX float64 - labelDY float64 - textAnchor string - } - markers := []marker{ - {name: "P1", point: info.P1, color: "#a52d70", labelDX: -7, labelDY: 14, textAnchor: "end"}, - {name: "P2", point: info.P2, color: "#a52d70", labelDX: 7, labelDY: -7, textAnchor: "start"}, - {name: "P3", point: info.P3, color: "#a52d70", labelDX: -7, labelDY: -7, textAnchor: "end"}, - {name: "P4", point: info.P4, color: "#a52d70", labelDX: 7, labelDY: 14, textAnchor: "start"}, - {name: "U1", point: info.U1, color: "#a12c25", labelDX: -7, labelDY: -7, textAnchor: "end"}, - {name: "U2", point: info.U2, color: "#a12c25", labelDX: 7, labelDY: 14, textAnchor: "start"}, - {name: "U3", point: info.U3, color: "#a12c25", labelDX: -7, labelDY: 14, textAnchor: "end"}, - {name: "U4", point: info.U4, color: "#a12c25", labelDX: 7, labelDY: -7, textAnchor: "start"}, - } - for _, marker := range markers { - if marker.point.Time.IsZero() { - continue +func writeSolarEclipseContactMarkers(builder *strings.Builder, contacts []solarEclipseContactLabel) { + for _, contact := range contacts { + fmt.Fprintf(builder, ``, + strings.ToLower(contact.marker.name), contact.x, contact.y, contact.marker.color) + if contact.ok { + fmt.Fprintf(builder, `%s`, + contact.placed.X, contact.placed.Y, contact.marker.color, contact.placed.Anchor, html.EscapeString(contact.text)) } - x, y, visible := frame.Project(marker.point.Longitude, marker.point.Latitude) - if !visible { - continue - } - labelX := x + marker.labelDX - textAnchor := marker.textAnchor - if labelX < frame.X+18 { - labelX = x + 7 - textAnchor = "start" - } else if labelX > frame.X+frame.Width-18 { - labelX = x - 7 - textAnchor = "end" - } - fmt.Fprintf(builder, `%s`, - strings.ToLower(marker.name), x, y, marker.color, labelX, y+marker.labelDY, marker.color, - textAnchor, marker.name) + builder.WriteString(``) } } @@ -445,23 +490,18 @@ func writeSolarEclipseAxisMarkers( } } -func writeSolarEclipseSubsolarMarker( - builder *strings.Builder, - info eclipsecore.SolarEclipseInfo, - frame svgmap.Frame, - language string, -) { - point := solarEclipseSubsolarPoint(info.GreatestEclipse) - x, y, visible := frame.Project(point.Longitude, point.Latitude) - if !visible { +func writeSolarEclipseSubsolarMarker(builder *strings.Builder, label solarEclipseMapPointLabel) { + if label.text == "" { return } - label := "日下点" - if language == "en" { - label = "Subsolar" + x, y := label.x, label.y + fmt.Fprintf(builder, ``, + x, y, x-4, y, x, y-4) + if label.ok { + fmt.Fprintf(builder, `%s`, + label.placed.X, label.placed.Y, label.placed.Anchor, html.EscapeString(label.text)) } - fmt.Fprintf(builder, `%s`, - x, y, x-4, y, x, y-4, x, y-9, html.EscapeString(label)) + builder.WriteString(``) } func solarEclipseFormatCoordinates(longitude, latitude float64) string { @@ -476,45 +516,15 @@ func solarEclipseFormatCoordinates(longitude, latitude float64) string { return fmt.Sprintf("%.4f°%s, %.4f°%s", math.Abs(longitude), lonSuffix, math.Abs(latitude), latSuffix) } -func writeSolarEclipseTimeMarkers( - builder *strings.Builder, - path eclipsecore.SolarEclipsePath, - frame svgmap.Frame, - options SolarEclipseMapSVGOptions, -) { - if options.TimeLabelStep <= 0 || len(path.CenterLine) < 2 { - return - } - excluded := []time.Time{ - path.Eclipse.CentralBeginOnEarth, - path.Eclipse.CentralEndOnEarth, - } - markers := solarEclipseTimeMarkerPoints(path.CenterLine, options.TimeLabelStep, options.Location, excluded) - projected := make([][2]float64, 0, len(markers)) - for _, marker := range markers { - x, y, visible := frame.Project(marker.Longitude, marker.Latitude) - if !visible || x < frame.X+22 || x > frame.X+frame.Width-22 || y < frame.Y+12 || y > frame.Y+frame.Height-12 { - continue +func writeSolarEclipseTimeMarkers(builder *strings.Builder, labels []solarEclipseMapPointLabel) { + for _, label := range labels { + fmt.Fprintf(builder, ``, + label.x, label.y) + if label.ok { + fmt.Fprintf(builder, `%s`, + label.placed.X, label.placed.Y, label.placed.Anchor, html.EscapeString(label.text)) } - tooClose := false - for _, previous := range projected { - if math.Hypot(x-previous[0], y-previous[1]) < 44 { - tooClose = true - break - } - } - if tooClose { - continue - } - projected = append(projected, [2]float64{x, y}) - labelY := y - 8 - if mapTimesNear(marker.Time, path.Eclipse.GreatestEclipse, solarEclipseGreatestTimeLabelWindow(options.TimeLabelStep)) { - labelY = y + 15 - } else if labelY < frame.Y+10 { - labelY = y + 15 - } - fmt.Fprintf(builder, `%s`, - x, y, x, labelY, html.EscapeString(marker.Time.In(options.Location).Format("15:04"))) + builder.WriteString(``) } } @@ -623,3 +633,219 @@ func mapTimesNear(a, b time.Time, window time.Duration) bool { } return delta <= window } + +// writeSolarEclipseGreatestTimeContours 绘制地方食甚时刻等时线及其 HH:MM 标注。 +func writeSolarEclipseGreatestTimeContours( + builder *strings.Builder, + contours []eclipsecore.SolarEclipseGreatestTimeContour, + frame svgmap.Frame, + options SolarEclipseMapSVGOptions, + labels *svgchart.LabelTable, +) { + if len(contours) == 0 { + return + } + for _, contour := range contours { + for _, segment := range contour.Segments { + points := make([]svgmap.GeoPoint, len(segment)) + for index, point := range segment { + points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + writeEclipseMapGeoLine( + builder, frame, points, "solar-greatest-time-isoline", "#1f6fb2", 1.0, "", "solar-map-clip", + eclipseMapSourceGreatestTimeIsochrones, + ) + } + text := contour.Time.In(options.Location).Format("15:04") + candidates := solarEclipseContourLabelCandidates(contour.Segments, frame, 0, 0, false) + placed, ok := labels.Place(text, 9, labelCandidatesInsideBox(candidates, text, 9, solarEclipseFrameBox(frame))) + if !ok { + continue + } + fmt.Fprintf(builder, `%s`, + placed.X, placed.Y, placed.Anchor, html.EscapeString(text)) + } +} + +// writeSolarEclipseMagnitudeContours 绘制地方最大食分等值线及其数值标注。 +func writeSolarEclipseMagnitudeContours( + builder *strings.Builder, + contours []eclipsecore.SolarEclipseMagnitudeContour, + frame svgmap.Frame, + labels *svgchart.LabelTable, + axis [3]float64, + normal [3]float64, +) { + if len(contours) == 0 { + return + } + for _, contour := range contours { + for _, segment := range contour.Segments { + points := make([]svgmap.GeoPoint, len(segment)) + for index, point := range segment { + points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + writeEclipseMapGeoLine( + builder, frame, points, "solar-magnitude-contour", "#7c3aed", + 0.8+0.8*math.Max(0, math.Min(1, contour.Magnitude)), "4 3", "solar-map-clip", + eclipseMapSourceMagnitudeContours, + ) + } + text := fmt.Sprintf("%.1f", contour.Magnitude) + x, y, hasPreferred := solarEclipseMagnitudePreferredPosition(contour.Segments, frame, axis, normal) + candidates := solarEclipseContourLabelCandidates(contour.Segments, frame, x, y, hasPreferred) + placed, ok := labels.Place(text, 10, labelCandidatesInsideBox(candidates, text, 10, solarEclipseFrameBox(frame))) + if !ok { + continue + } + fmt.Fprintf(builder, `%s`, + placed.X, placed.Y, placed.Anchor, html.EscapeString(text)) + } +} + +// solarEclipseMagnitudeLabelAxis 返回过食甚点、垂直于中心线的那个大圆的法线。 +// 食分等值线是绕中心线的一圈闭合曲线,让它与这个固定大圆相交,各条线的标注就落在同一条线上。 +func solarEclipseMagnitudeLabelAxis(path eclipsecore.SolarEclipsePath) ([3]float64, [3]float64, bool) { + points := path.CenterLine + if len(points) < 3 { + return [3]float64{}, [3]float64{}, false + } + nearest := 0 + best := math.Inf(1) + for index, point := range points { + distance := math.Hypot(point.Longitude-pathsGreatestLongitude(path), point.Latitude-pathsGreatestLatitude(path)) + if distance < best { + best, nearest = distance, index + } + } + if nearest == 0 { + nearest = 1 + } + if nearest >= len(points)-1 { + nearest = len(points) - 2 + } + before := solarEclipseUnitVector(points[nearest-1]) + after := solarEclipseUnitVector(points[nearest+1]) + normal := solarEclipseCross(before, after) + at := solarEclipseUnitVector(points[nearest]) + tangent := solarEclipseCross(normal, at) + length := math.Sqrt(tangent[0]*tangent[0] + tangent[1]*tangent[1] + tangent[2]*tangent[2]) + normalLength := math.Sqrt(normal[0]*normal[0] + normal[1]*normal[1] + normal[2]*normal[2]) + if length < 1e-12 || normalLength < 1e-12 { + return [3]float64{}, [3]float64{}, false + } + return [3]float64{tangent[0] / length, tangent[1] / length, tangent[2] / length}, + [3]float64{normal[0] / normalLength, normal[1] / normalLength, normal[2] / normalLength}, true +} + +func pathsGreatestLongitude(path eclipsecore.SolarEclipsePath) float64 { + return path.Eclipse.GreatestLongitude +} + +func pathsGreatestLatitude(path eclipsecore.SolarEclipsePath) float64 { + return path.Eclipse.GreatestLatitude +} + +func solarEclipseUnitVector(point eclipsecore.SolarEclipsePathPoint) [3]float64 { + longitude := point.Longitude * math.Pi / 180 + latitude := point.Latitude * math.Pi / 180 + return [3]float64{ + math.Cos(latitude) * math.Cos(longitude), + math.Cos(latitude) * math.Sin(longitude), + math.Sin(latitude), + } +} + +func solarEclipseCross(first, second [3]float64) [3]float64 { + return [3]float64{ + first[1]*second[2] - first[2]*second[1], + first[2]*second[0] - first[0]*second[2], + first[0]*second[1] - first[1]*second[0], + } +} + +// solarEclipseMagnitudePreferredPosition 取等值线与标注大圆的交点,让同族标注排在同一条线上。 +// 标注大圆与每条闭合等值线有两个交点,一个在中心线北侧、一个在南侧; +// 按“相对中心线的哪一侧”固定取同一侧,否则各条线会各自跳到对面,看着就很散。 +func solarEclipseMagnitudePreferredPosition( + segments [][]eclipsecore.SolarEclipsePathPoint, + frame svgmap.Frame, + axis [3]float64, + normal [3]float64, +) (float64, float64, bool) { + best, bestDistance, found := eclipsecore.SolarEclipsePathPoint{}, math.Inf(1), false + bestOther, bestOtherDistance, foundOther := eclipsecore.SolarEclipsePathPoint{}, math.Inf(1), false + for _, segment := range segments { + for _, point := range segment { + vector := solarEclipseUnitVector(point) + distance := math.Abs(vector[0]*axis[0] + vector[1]*axis[1] + vector[2]*axis[2]) + if vector[0]*normal[0]+vector[1]*normal[1]+vector[2]*normal[2] >= 0 { + if distance < bestDistance { + best, bestDistance, found = point, distance, true + } + continue + } + if distance < bestOtherDistance { + bestOther, bestOtherDistance, foundOther = point, distance, true + } + } + } + if !found && foundOther { + best, found = bestOther, true + } + if !found || math.IsInf(bestDistance, 1) { + return 0, 0, false + } + x, y, visible := frame.Project(best.Longitude, best.Latitude) + if !visible { + return 0, 0, false + } + return x, y - 6, true +} + +// writeSolarEclipsePartialBoundary 描出偏食可见域的真实边界。 +// 直接描填充路径会把 ±180° 的切边画成假线,所以走按反经线分段的折线绘制。 +func writeSolarEclipsePartialBoundary( + builder *strings.Builder, + contours [][]eclipsecore.SolarEclipsePathPoint, + frame svgmap.Frame, +) { + for _, contour := range contours { + points := make([]svgmap.GeoPoint, len(contour)) + for index, point := range contour { + points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + writeEclipseMapGeoLine(builder, frame, points, "solar-partial-boundary", "#c8921a", 1.0, "", "solar-map-clip", + eclipseMapSourcePartialBandContours) + } +} + +// solarEclipseDetailedBlocks 汇总日食详细版式的全部数据块。 +// 竖版与横版只是排布不同,块内容必须来自这里,避免两套版式各写一遍造行逻辑。 +func solarEclipseDetailedBlocks( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, + local eclipsecore.LocalSolarEclipseInfo, + hasLocal bool, + geocentric eclipsecore.SolarEclipseGeocentricPanel, + hasGeocentric bool, + options SolarEclipseMapSVGOptions, +) solarEclipsePanelBlocks { + penumbra, umbra, circumstances := solarEclipseContactRows(partial, local, hasLocal, options) + penumbraTitle, circumstancesTitle, umbraTitle := solarEclipseContactTitles(options) + ephemerisTitle, librationTitle := solarEclipseEphemerisTitles(options) + ephemeris, libration := solarEclipseEphemerisRows(geocentric, options) + sunTitle, moonTitle := solarEclipseGeocentricTitles(options) + sunRows, moonRows := solarEclipseGeocentricRows(geocentric) + if !hasGeocentric { + ephemeris, libration, sunRows, moonRows = nil, nil, nil, nil + } + return solarEclipsePanelBlocks{ + sun: solarEclipsePanelBlock{title: sunTitle, rows: sunRows}, + moon: solarEclipsePanelBlock{title: moonTitle, rows: moonRows}, + penumbra: solarEclipsePanelBlock{title: penumbraTitle, rows: penumbra}, + umbra: solarEclipsePanelBlock{title: umbraTitle, rows: umbra}, + circumstances: solarEclipsePanelBlock{title: circumstancesTitle, rows: circumstances}, + ephemeris: solarEclipsePanelBlock{title: ephemerisTitle, rows: ephemeris}, + libration: solarEclipsePanelBlock{title: librationTitle, rows: libration}, + } +} diff --git a/eclipse/svg/solar_map_events_title_test.go b/eclipse/svg/solar_map_events_title_test.go new file mode 100644 index 0000000..4fae58e --- /dev/null +++ b/eclipse/svg/solar_map_events_title_test.go @@ -0,0 +1,69 @@ +package svg + +import ( + "strings" + "testing" + "time" +) + +// EventsTitle 覆盖“全球阶段”数据块的标题;空值使用该块的本地化默认标题。 +func TestSolarEclipseEventsTitleLocalizesAndOverrides(t *testing.T) { + for _, test := range []struct { + name string + language string + title string + want string + }{ + {name: "zh default", want: "食甚点的地方情况"}, + {name: "zh override", title: "全球阶段(自定义)", want: "全球阶段(自定义)"}, + {name: "en default", language: "en", want: "Local circumstances at greatest"}, + {name: "en override", language: "en", title: "Global phases (custom)", want: "Global phases (custom)"}, + } { + t.Run(test.name, func(t *testing.T) { + _, circumstances, _ := solarEclipseContactTitles(SolarEclipseMapSVGOptions{ + Language: test.language, EventsTitle: test.title, + }) + if circumstances != test.want { + t.Fatalf("global-phase block title = %q, want %q", circumstances, test.want) + } + }) + } + + // 两种版式都要把标题写进产物,横版与竖版各验证一次。 + for _, layout := range []struct { + name string + width, height int + language string + }{ + {name: "landscape", width: 960, height: 640}, + {name: "portrait", width: 800, height: 820, language: "en"}, + } { + t.Run("rendered/"+layout.name, func(t *testing.T) { + title := "全球阶段(自定义)" + unwanted := "食甚点的地方情况" + if layout.language == "en" { + title = "Global phases (custom)" + unwanted = "Local circumstances at greatest" + } + rendered, ok := SolarEclipseMapSVG( + time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), + SolarEclipseMapSVGOptions{ + Width: layout.width, Height: layout.height, Location: time.UTC, + PartialStep: 10 * time.Minute, Language: layout.language, EventsTitle: title, + }, + ) + if !ok { + t.Fatal("missing 2024-04-08 map") + } + if !strings.Contains(rendered, title) { + t.Fatalf("map is missing the events title %q", title) + } + if strings.Contains(rendered, unwanted) { + t.Fatalf("map still carries the replaced events title %q", unwanted) + } + if err := validateEclipseMapXML(rendered); err != nil { + t.Fatalf("map is not valid XML: %v", err) + } + }) + } +} diff --git a/eclipse/svg/solar_map_fixes_test.go b/eclipse/svg/solar_map_fixes_test.go new file mode 100644 index 0000000..4acf9af --- /dev/null +++ b/eclipse/svg/solar_map_fixes_test.go @@ -0,0 +1,190 @@ +package svg + +import ( + "math" + "strings" + "testing" + "time" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgmap" +) + +// 复用 partial 结果跳过中心带重建时,导出的字节必须与完整重算中心带的旧路径逐字相同。 +func TestSolarEclipseMapSkipCentralBandMatchesFullPath(t *testing.T) { + for _, fixture := range []struct { + name string + date time.Time + }{ + {name: "2024-04-08 total", date: time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)}, + {name: "2010-01-15 annular", date: time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC)}, + } { + t.Run(fixture.name, func(t *testing.T) { + options := SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, + PartialStep: 10 * time.Minute, CentralStep: 10 * time.Minute, + } + normalized := normalizeSolarEclipseMapSVGOptions(fixture.date, options) + got, ok := SolarEclipseMapSVGNASABulletinSplitK(fixture.date, options) + if !ok { + t.Fatal("missing map") + } + global, ok := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(fixture.date) + if !ok { + t.Fatal("missing eclipse") + } + partial, ok := eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK(fixture.date, eclipsecore.SolarEclipsePartialFootprintOptions{ + Step: normalized.PartialStep, + BoundaryPoints: normalized.BoundaryPoints, + CentralShadowStep: normalized.CentralShadowStep, + // 必须与渲染器同一组等时线电平,否则比较的是两次不同的请求。 + GreatestTimeValues: solarEclipseGreatestTimeLevels(global, normalized), + MagnitudeValues: normalized.MagnitudeValues, + }) + if !ok { + t.Fatal("missing partial footprints") + } + // 旧路径:中心路径不跳过中心带,渲染器不读取它的包络。 + central, hasCentral := eclipsecore.SolarEclipseCentralPathNASABulletinSplitK(fixture.date, eclipsecore.SolarEclipsePathOptions{ + Step: normalized.CentralStep, + TargetSpacingKM: normalized.TargetSpacingKM, + }) + local, hasLocal := eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK( + partial.Eclipse.GreatestEclipse, + partial.Eclipse.GreatestLongitude, + partial.Eclipse.GreatestLatitude, + 0, + ) + projection := resolveSolarEclipseMapProjection(partial, central, hasCentral, normalized.Projection) + geocentric, hasGeocentric := eclipsecore.SolarEclipseGeocentricPanelAt(fixture.date) + want := renderSolarEclipseMapSVG(partial, central, hasCentral, local, hasLocal, + geocentric, hasGeocentric, normalized, projection, + svgmap.GeoPoint{Longitude: partial.Eclipse.GreatestLongitude, Latitude: partial.Eclipse.GreatestLatitude}) + if got != want { + t.Fatalf("reused central band changed the rendered document (%d vs %d bytes)", len(got), len(want)) + } + }) + } +} + +// 自动投影只按半球守卫选择极区视图:focus 高但跨越赤道的事件必须留在等距圆柱视图。 +func TestSolarEclipseMapAutoProjectionKeepsBothHemispheres(t *testing.T) { + date := time.Date(2026, time.August, 12, 0, 0, 0, 0, time.UTC) + options := SolarEclipseMapSVGOptions{Location: time.UTC, PartialStep: 10 * time.Minute} + auto, ok := SolarEclipseMapSVG(date, options) + if !ok { + t.Fatal("missing 2026-08-12 map") + } + options.Projection = EclipseMapProjectionEquirectangular + explicit, ok := SolarEclipseMapSVG(date, options) + if !ok { + t.Fatal("missing explicit equirectangular map") + } + if auto != explicit { + t.Fatal("auto projection promoted a two-hemisphere event to a polar view") + } + + // 单半球事件仍必须自动选极区视图。 + antarctic := time.Date(2021, time.December, 4, 0, 0, 0, 0, time.UTC) + partial, ok := eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK(antarctic, eclipsecore.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 180, + }) + if !ok { + t.Fatal("missing 2021-12-04 partial footprints") + } + central, hasCentral := eclipsecore.SolarEclipseCentralPathNASABulletinSplitK(antarctic, eclipsecore.SolarEclipsePathOptions{Step: 2 * time.Minute}) + if got := resolveSolarEclipseMapProjection(partial, central, hasCentral, EclipseMapProjectionAuto); got != svgmap.ProjectionSouthPolar { + t.Fatalf("2021-12-04 auto projection = %v, want the south polar view", got) + } +} + +// 包络守卫必须校验真正绘制的三条线:中心线按 2 km,限界按食带宽的四分之一。 +func TestSolarEclipseCentralEnvelopeGuardChecksDrawnLines(t *testing.T) { + date := time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC) + partial, ok := eclipsecore.SolarEclipsePartialFootprints(date, eclipsecore.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 180, + }) + if !ok { + t.Fatal("missing partial footprints") + } + central, ok := eclipsecore.SolarEclipseCentralPath(date, eclipsecore.SolarEclipsePathOptions{ + Step: 2 * time.Minute, SkipCentralBand: true, + }) + if !ok { + t.Fatal("missing central path") + } + if !solarEclipseCentralEnvelopeCoversPath(partial.CentralBandSegments, central) { + t.Fatal("authoritative envelope was rejected for its own eclipse") + } + strayed := central + strayed.SouthernLimit = solarEclipseShiftedLatitudes(central.SouthernLimit, -9) + if solarEclipseCentralEnvelopeCoversPath(partial.CentralBandSegments, strayed) { + t.Fatal("guard accepted a southern limit about 1000 km outside the envelope") + } + // 中心线单独南移约 2200 km:限界仍在容差内,只有中心线检查能拒绝它。 + strayedAxis := central + strayedAxis.CenterLine = solarEclipseShiftedLatitudes(central.CenterLine, -20) + if solarEclipseCentralEnvelopeCoversPath(partial.CentralBandSegments, strayedAxis) { + t.Fatal("guard accepted a center line far outside the envelope") + } + + // 回退到弦带时必须标注来源,消费方可区分权威包络与粗弦带。 + var builder strings.Builder + frame := svgmap.Frame{Width: 3600, Height: 1800, Projection: svgmap.ProjectionEquirectangular} + writeSolarEclipseCentralPath(&builder, strayed, partial.CentralBandFootprints, partial.CentralBandSegments, frame, partial.Eclipse.Type) + if !strings.Contains(builder.String(), ` radius+0.6 { + outside++ + } + } + if outside != 0 { + t.Fatalf("%d of %d land vertices fall outside the disk", outside, len(matches)) + } + t.Logf("land vertices=%d all inside the disk", len(matches)) +} + +// 正射投影下食甚点必须落在盘心,且背面点不可见。 +func TestSolarEclipseMapOrthographicFrameGeometry(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + options := SolarEclipseMapSVGOptions{ + Width: 1000, Height: 760, Location: time.UTC, + Projection: EclipseMapProjectionOrthographic, + } + frame := solarEclipseGlobeFrame(t, date, options) + if frame.Width != frame.Height { + t.Fatalf("globe frame is not square: %gx%g", frame.Width, frame.Height) + } + x, y, ok := frame.Project(frame.CenterLongitude, frame.CenterLatitude) + if !ok || math.Hypot(x-(frame.X+frame.Width/2), y-(frame.Y+frame.Height/2)) > 1e-9 { + t.Fatalf("view centre projects to %g,%g ok=%v", x, y, ok) + } + antipode := svgmap.GeoPoint{ + Longitude: frame.CenterLongitude + 180, + Latitude: -frame.CenterLatitude, + } + if _, _, visible := frame.Project(antipode.Longitude, antipode.Latitude); visible { + t.Fatal("the antipode must not project onto the globe") + } +} + +// 球面版式只支持显式请求,自动投影仍按事件几何选择。 +func TestSolarEclipseMapOrthographicIsExplicitOnly(t *testing.T) { + if !validEclipseMapProjection(EclipseMapProjectionOrthographic) { + t.Fatal("orthographic projection is rejected by validation") + } + if got := internalEclipseMapProjection(EclipseMapProjectionOrthographic); got != svgmap.ProjectionOrthographic { + t.Fatalf("orthographic maps to %q", got) + } +} + +// 图上时刻都按展示时区,必须显式写出与 UT 的偏差,且 详细版式的所有内容都不得溢出图框。 +func TestSolarEclipseMapStatesTimeZoneOffset(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + cases := []struct { + location *time.Location + want string + }{ + {location: time.UTC, want: "图中时刻为 UT"}, + {location: time.FixedZone("CST", 8*3600), want: "UT+08:00"}, + {location: time.FixedZone("EST", -5*3600), want: "UT-05:00"}, + } + for _, test := range cases { + options := SolarEclipseMapSVGOptions{ + Width: 1000, Height: 1414, Location: test.location, + Projection: EclipseMapProjectionOrthographic, + } + rendered, ok := SolarEclipseMapSVG(date, options) + if !ok { + t.Fatalf("%s: missing map", test.location) + } + if !strings.Contains(rendered, test.want) { + t.Fatalf("%s: map does not state %q", test.location, test.want) + } + } +} diff --git a/eclipse/svg/solar_map_isochrone_levels_contract_test.go b/eclipse/svg/solar_map_isochrone_levels_contract_test.go new file mode 100644 index 0000000..64d5151 --- /dev/null +++ b/eclipse/svg/solar_map_isochrone_levels_contract_test.go @@ -0,0 +1,188 @@ +package svg + +import ( + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/basic" + eclipsecore "b612.me/astro/eclipse" +) + +// 等时线电平的期望网格在测试内用整数纳秒与当地日起点独立重算,期望时刻写成字面量。 + +const solarEclipseContractLevelCap = 64 + +func solarEclipseContractLevelGrid( + begin, end time.Time, step time.Duration, location *time.Location, +) []time.Time { + if step <= 0 || begin.IsZero() || end.IsZero() || location == nil { + return nil + } + local := begin.In(location) + dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) + ticks := (int64(local.Sub(dayStart)) + int64(step)/2) / int64(step) + current := dayStart.Add(time.Duration(ticks) * step) + if current.Before(begin) { + current = current.Add(step) + } + levels := make([]time.Time, 0, solarEclipseContractLevelCap) + for !current.After(end) && len(levels) < solarEclipseContractLevelCap { + levels = append(levels, current) + current = current.Add(step) + } + return levels +} + +func solarEclipseContractLevelText(levels []time.Time) []string { + formatted := make([]string, 0, len(levels)) + for _, level := range levels { + formatted = append(formatted, level.UTC().Format(time.RFC3339)) + } + return formatted +} + +func solarEclipseContractLevelDiff(got, want []string) string { + if strings.Join(got, ",") == strings.Join(want, ",") { + return "" + } + for index := 0; index < len(got) && index < len(want); index++ { + if got[index] != want[index] { + return "level " + strconv.Itoa(index) + " = " + got[index] + ", want " + want[index] + + " (" + strconv.Itoa(len(got)) + " vs " + strconv.Itoa(len(want)) + " levels)" + } + } + return strconv.Itoa(len(got)) + " levels, want " + strconv.Itoa(len(want)) +} + +// 导出 TT/UTC 换算把力学时儒略日换回 UTC 时刻,口径与核心写进 PartialBegin/EndOnEarth 的一致。 +func solarEclipseContractTimeFromTT(ttJDE float64) time.Time { + return basic.JDE2DateByZone(ttJDE-basic.DeltaT(ttJDE, true)/86400, time.UTC, false) +} + +func TestSolarEclipseGreatestTimeLevelsContract(t *testing.T) { + day := func(hour, minute, second int) time.Time { + return time.Date(2024, time.January, 1, hour, minute, second, 0, time.UTC) + } + utc8 := time.FixedZone("UTC+8", 8*3600) + cases := []struct { + name string + begin, end time.Time + step time.Duration + location *time.Location + want []string + wantCount int + wantFirst string + wantLast string + }{ + { + name: "起点落在刻度之间时从下一个整刻度开始", begin: day(0, 7, 0), end: day(2, 7, 0), + step: 30 * time.Minute, location: time.UTC, + want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"}, + }, + { + name: "起点恰在刻度上时不重复生成该刻度", begin: day(0, 30, 0), end: day(2, 0, 0), + step: 30 * time.Minute, location: time.UTC, + want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"}, + }, + { + name: "终点恰在刻度上时包含终点", begin: day(0, 7, 0), end: day(1, 30, 0), + step: 30 * time.Minute, location: time.UTC, + want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"}, + }, + { + name: "窗口内没有整刻度时为空", begin: day(0, 1, 0), end: day(0, 20, 0), + step: time.Hour, location: time.UTC, + want: nil, + }, + { + name: "零步长关闭等时线", begin: day(0, 7, 0), end: day(2, 7, 0), + step: 0, location: time.UTC, + want: nil, + }, + { + name: "负步长关闭等时线", begin: day(0, 7, 0), end: day(2, 7, 0), + step: -30 * time.Minute, location: time.UTC, + want: nil, + }, + { + name: "偏食窗口缺失时为空", begin: time.Time{}, end: day(2, 7, 0), + step: 30 * time.Minute, location: time.UTC, + want: nil, + }, + { + name: "超过上限时截断到 64 条", begin: day(0, 0, 30), end: day(2, 0, 30), + step: time.Minute, location: time.UTC, + wantCount: 64, wantFirst: "2024-01-01T00:01:00Z", wantLast: "2024-01-01T01:04:00Z", + }, + { + name: "整刻度按当地日午夜对齐", begin: time.Date(2024, time.January, 1, 17, 37, 0, 0, time.UTC), + end: time.Date(2024, time.January, 3, 0, 0, 0, 0, time.UTC), step: 5 * time.Hour, location: utc8, + want: []string{ + "2024-01-01T21:00:00Z", "2024-01-02T02:00:00Z", "2024-01-02T07:00:00Z", + "2024-01-02T12:00:00Z", "2024-01-02T17:00:00Z", "2024-01-02T22:00:00Z", + }, + }, + } + for _, testCase := range cases { + info := eclipsecore.SolarEclipseInfo{ + PartialBeginOnEarth: testCase.begin, + PartialEndOnEarth: testCase.end, + } + levels := solarEclipseGreatestTimeLevels(info, SolarEclipseMapSVGOptions{ + Location: testCase.location, GreatestTimeStep: testCase.step, + }) + got := solarEclipseContractLevelText(levels) + want := solarEclipseContractLevelText(solarEclipseContractLevelGrid( + testCase.begin, testCase.end, testCase.step, testCase.location)) + if diff := solarEclipseContractLevelDiff(got, want); diff != "" { + t.Fatalf("%s: levels disagree with the independent grid: %s", testCase.name, diff) + } + if testCase.want != nil || len(got) == 0 { + if diff := solarEclipseContractLevelDiff(got, testCase.want); diff != "" { + t.Fatalf("%s: literal expectation: %s", testCase.name, diff) + } + continue + } + if len(got) != testCase.wantCount || got[0] != testCase.wantFirst || got[len(got)-1] != testCase.wantLast { + t.Fatalf("%s: %d levels from %s to %s, want %d from %s to %s", + testCase.name, len(got), got[0], got[len(got)-1], testCase.wantCount, testCase.wantFirst, testCase.wantLast) + } + } +} + +// 真实事件上再走一遍导出 TT/UTC 换算:窗口端点由力学时儒略日换回,网格仍由测试独立重算。 +func TestSolarEclipseGreatestTimeLevelsOnRealEclipseContract(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + reported, ok := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(date) + if !ok { + t.Fatal("missing 2009-07-22 eclipse") + } + // 同一力学时时刻经导出换算回 UTC,不依赖 eclipse 包内部的 TT→UTC 实现。 + beginTT := basic.TD2UT(basic.Date2JDE(reported.PartialBeginOnEarth.UTC()), true) + endTT := basic.TD2UT(basic.Date2JDE(reported.PartialEndOnEarth.UTC()), true) + info := eclipsecore.SolarEclipseInfo{ + PartialBeginOnEarth: solarEclipseContractTimeFromTT(beginTT), + PartialEndOnEarth: solarEclipseContractTimeFromTT(endTT), + } + step := 30 * time.Minute + levels := solarEclipseGreatestTimeLevels(info, SolarEclipseMapSVGOptions{ + Location: time.UTC, GreatestTimeStep: step, + }) + got := solarEclipseContractLevelText(levels) + want := []string{ + "2009-07-22T00:00:00Z", "2009-07-22T00:30:00Z", "2009-07-22T01:00:00Z", + "2009-07-22T01:30:00Z", "2009-07-22T02:00:00Z", "2009-07-22T02:30:00Z", + "2009-07-22T03:00:00Z", "2009-07-22T03:30:00Z", "2009-07-22T04:00:00Z", + "2009-07-22T04:30:00Z", "2009-07-22T05:00:00Z", + } + if diff := solarEclipseContractLevelDiff(got, want); diff != "" { + t.Fatalf("2009-07-22 literal expectation: %s", diff) + } + if recomputed := solarEclipseContractLevelText(solarEclipseContractLevelGrid( + info.PartialBeginOnEarth, info.PartialEndOnEarth, step, time.UTC)); solarEclipseContractLevelDiff(got, recomputed) != "" { + t.Fatalf("2009-07-22 levels disagree with the independent grid: %s", + solarEclipseContractLevelDiff(got, recomputed)) + } +} diff --git a/eclipse/svg/solar_map_isochrone_test.go b/eclipse/svg/solar_map_isochrone_test.go new file mode 100644 index 0000000..87677ec --- /dev/null +++ b/eclipse/svg/solar_map_isochrone_test.go @@ -0,0 +1,79 @@ +package svg + +import ( + "strings" + "testing" + "time" + + eclipsecore "b612.me/astro/eclipse" +) + +func solarEclipseIsochroneMapOptions() SolarEclipseMapSVGOptions { + return SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute, + GreatestTimeStep: solarEclipseMapDefaultGreatestTimeStep, + } +} + +// 等时线是可选图层:不请求时不得出现在输出里,请求时路径与标注都必须绘制。 +func TestSolarEclipseMapGreatestTimeIsochrones(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + rendered, ok := SolarEclipseMapSVG(date, solarEclipseIsochroneMapOptions()) + if !ok { + t.Fatal("missing map") + } + if !strings.Contains(rendered, `class="solar-greatest-time-isoline"`) { + t.Fatal("isochrone paths missing from a map that requested them") + } + if !strings.Contains(rendered, `class="solar-greatest-time-label"`) { + t.Fatal("isochrone labels missing from a map that requested them") + } + + // 等时线是 opt-in:零值和负值都不画,与核心层和 moon/svg 一致;30 分钟只是 NASA 全球图的推荐间隔。 + if got := normalizeSolarEclipseMapSVGOptions(date, SolarEclipseMapSVGOptions{}).GreatestTimeStep; got != 0 { + t.Fatalf("default isochrone step = %s, want them disabled", got) + } + if solarEclipseMapDefaultGreatestTimeStep != 30*time.Minute { + t.Fatalf("recommended NASA isochrone step = %s, want 30m", solarEclipseMapDefaultGreatestTimeStep) + } + for _, step := range []time.Duration{0, -1} { + disabled, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute, + GreatestTimeStep: step, + }) + if !ok { + t.Fatal("missing map") + } + if strings.Contains(disabled, "solar-greatest-time-isoline") || + strings.Contains(disabled, "solar-greatest-time-label") { + t.Fatalf("step %s must not draw isochrones", step) + } + } + // 正值小于一分钟时夹取到一分钟,而不是关闭。 + clamped := normalizeSolarEclipseMapSVGOptions(date, SolarEclipseMapSVGOptions{GreatestTimeStep: time.Second}) + if clamped.GreatestTimeStep != time.Minute { + t.Fatalf("sub-minute isochrone step = %s, want it clamped to one minute", clamped.GreatestTimeStep) + } +} + +// 电平必须落在偏食可见窗口内并按步长对齐,避免 59.999 秒那类截断。 +func TestSolarEclipseMapGreatestTimeLevelsAlignToStep(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + options := normalizeSolarEclipseMapSVGOptions(date, solarEclipseIsochroneMapOptions()) + info, ok := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(date) + if !ok { + t.Fatal("missing eclipse") + } + levels := solarEclipseGreatestTimeLevels(info, options) + if len(levels) < 5 { + t.Fatalf("expected a full isochrone fan, got %d levels", len(levels)) + } + for _, level := range levels { + if level.Second() != 0 || level.Nanosecond() != 0 || level.Minute()%30 != 0 { + t.Fatalf("level %s is not aligned to the requested step", level.Format("15:04:05.000")) + } + if level.Before(info.PartialBeginOnEarth) || level.After(info.PartialEndOnEarth) { + t.Fatalf("level %s is outside the partial-eclipse window", level.Format("15:04:05")) + } + } +} diff --git a/eclipse/svg/solar_map_labels.go b/eclipse/svg/solar_map_labels.go new file mode 100644 index 0000000..1b81da5 --- /dev/null +++ b/eclipse/svg/solar_map_labels.go @@ -0,0 +1,558 @@ +package svg + +import ( + "math" + "time" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgchart" + "b612.me/astro/internal/svgmap" +) + +// 数据块的表头高度与行字号:取值与 internal/svgchart 的排版常量相同,这里只用它们反推行距下限。 +const ( + solarEclipsePanelHeaderHeight = 30.0 + solarEclipsePanelRowFontSize = 10.5 +) + +// solarEclipsePanelRowSpacingMin 是数据块行距下限:低于它相邻两行文字必然压叠。 +func solarEclipsePanelRowSpacingMin() float64 { + above, below := svgchart.EstimatedTextExtents(solarEclipsePanelRowFontSize) + return above + below +} + +// solarEclipseMinPanelHeight 是容下 count 行文字的数据块高度下限;0.5 px 是等分行高时浮点取整的余量。 +func solarEclipseMinPanelHeight(count int) float64 { + return solarEclipsePanelHeaderHeight + float64(count)*solarEclipsePanelRowSpacingMin() + 0.5 +} + +// solarEclipsePanelCell 是一个数据块的矩形与内容:占位与绘制共用同一份几何。 +type solarEclipsePanelCell struct { + title string + rows []svgchart.PanelRow + box svgchart.LabelBox +} + +// solarEclipsePanelCells 给出该版式下全部数据块矩形。 +func solarEclipsePanelCells(layout solarEclipseMapLayout, blocks solarEclipsePanelBlocks) []solarEclipsePanelCell { + if layout.landscape { + order := solarEclipseLandscapeBlockOrder(blocks) + cells := make([]solarEclipsePanelCell, 0, len(order)+1) + for index, block := range order { + if len(block.rows) == 0 { + continue + } + box, ok := solarEclipseLandscapeCellBox(layout, index) + if !ok { + continue + } + cells = append(cells, solarEclipsePanelCell{title: block.title, rows: block.rows, box: box}) + } + if len(blocks.libration.rows) > 0 && layout.stripHeight > 0 { + cells = append(cells, solarEclipsePanelCell{ + title: blocks.libration.title, rows: blocks.libration.rows, + box: svgchart.LabelBox{ + X: layout.margin, Y: layout.bottomY, + Width: solarEclipseLandscapeCellWidth, Height: layout.stripHeight, + }, + }) + } + return cells + } + if !layout.nasa { + return nil + } + columnWidth := solarEclipsePortraitColumnWidth(layout) + cells := make([]solarEclipsePanelCell, 0, 5) + for column, block := range []solarEclipsePanelBlock{blocks.penumbra, blocks.circumstances, blocks.umbra} { + if len(block.rows) == 0 { + continue + } + cells = append(cells, solarEclipsePanelCell{title: block.title, rows: block.rows, box: svgchart.LabelBox{ + X: solarEclipsePortraitColumnX(layout, column), Y: layout.panelY, + Width: columnWidth, Height: layout.panelHeight, + }}) + } + second := [2]struct { + column int + block solarEclipsePanelBlock + }{{0, blocks.ephemeris}, {2, blocks.libration}} + for _, entry := range second { + if len(entry.block.rows) == 0 { + continue + } + cells = append(cells, solarEclipsePanelCell{title: entry.block.title, rows: entry.block.rows, box: svgchart.LabelBox{ + X: solarEclipsePortraitColumnX(layout, entry.column), Y: layout.secondPanelY, + Width: columnWidth, Height: layout.panelHeight, + }}) + } + return cells +} + +// solarEclipsePanelsOverlap 报告任一数据块内文字是否压叠;为真时调用方必须拒绝该画布。 +func solarEclipsePanelsOverlap(cells []solarEclipsePanelCell) bool { + for _, cell := range cells { + if len(cell.rows) == 0 || cell.box.Width <= 0 || cell.box.Height <= 0 { + continue + } + if svgchart.PanelBoxOverlaps(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height, cell.title, cell.rows) { + return true + } + } + return false +} + +func solarEclipsePortraitColumnWidth(layout solarEclipseMapLayout) float64 { + return (layout.panelWidth - 2*layout.panelGap) / 3 +} + +func solarEclipsePortraitColumnX(layout solarEclipseMapLayout, column int) float64 { + return layout.panelX + float64(column)*(solarEclipsePortraitColumnWidth(layout)+layout.panelGap) +} + +func solarEclipseLandscapeCellBox(layout solarEclipseMapLayout, index int) (svgchart.LabelBox, bool) { + row := index / solarEclipseLandscapeColumns + column := index % solarEclipseLandscapeColumns + if row < 0 || row >= len(layout.gridRowHeights) || layout.gridRowHeights[row] <= 0 { + return svgchart.LabelBox{}, false + } + return svgchart.LabelBox{ + X: layout.gridX + float64(column)*(layout.gridCellWidth+layout.gridGapX), + Y: layout.gridRowY[row], Width: layout.gridCellWidth, Height: layout.gridRowHeights[row], + }, true +} + +// solarEclipseMapLegendItem 是图例的一条:标签、图形种类、颜色与虚线。 +type solarEclipseMapLegendItem struct { + label string + kind string + color string + dash string +} + +const ( + solarEclipseLegendFontSize = 10.0 + solarEclipseLegendLineStep = 22.0 + solarEclipseLegendIconWidth = 25.0 + solarEclipseLegendColumnGap = 12.0 + solarEclipseLegendMaxColumns = 4 +) + +// solarEclipseMapLegendItems 汇总该事件要画的图例条目,顺序即优先级。 +func solarEclipseMapLegendItems( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, + hasCentral bool, + hasCentralBandFootprints bool, + options SolarEclipseMapSVGOptions, +) []solarEclipseMapLegendItem { + info := partial.Eclipse + partialLabel := "偏食可见区" + centerLineLabel := "中心线" + if options.Language == "en" { + partialLabel = "Partial-eclipse visibility" + centerLineLabel = "Center line" + } + items := []solarEclipseMapLegendItem{{label: partialLabel, kind: "fill", color: "#f7dd93"}} + if hasCentral || hasCentralBandFootprints { + items = append(items, solarEclipseMapLegendItem{ + label: solarEclipseCentralPathLabel(info.Type, options.Language), kind: "fill", + color: solarEclipseCentralPathColor(info.Type), + }) + } + if hasCentral { + items = append(items, solarEclipseMapLegendItem{label: centerLineLabel, kind: "line", color: "#263f58", dash: "5 3"}) + } + if options.PenumbralOutlineStep > 0 { + items = append(items, solarEclipseMapLegendItem{ + label: solarEclipseOutlineLegendLabel("penumbra", info.Type, options.PenumbralOutlineStep, options.Language), + kind: "line", color: "#b07a18", dash: "3 3", + }) + } + riseSetLabel := "初亏/食甚/复圆日升日落线" + if options.Language == "en" { + riseSetLabel = "Rise/set phase lines" + } + if len(partial.RiseSetCurves) > 0 { + items = append(items, solarEclipseMapLegendItem{label: riseSetLabel, kind: "line", color: "#d97706", dash: "6 4"}) + } + if len(partial.MagnitudeContours) > 0 { + magnitudeLabel := "地方食分 0.2–0.8" + if options.Language == "en" { + magnitudeLabel = "Local magnitude 0.2-0.8" + } + items = append(items, solarEclipseMapLegendItem{label: magnitudeLabel, kind: "line", color: "#7c3aed", dash: "4 3"}) + } + if len(partial.GreatestTimeContours) > 0 { + isochroneLabel := "食甚时刻等时线" + if options.Language == "en" { + isochroneLabel = "Greatest-eclipse isochrones" + } + items = append(items, solarEclipseMapLegendItem{label: isochroneLabel, kind: "line", color: "#1f6fb2"}) + } + if info.Centrality != eclipsecore.SolarEclipseNonCentral && + len(partial.CentralShadowFootprints) > 0 && options.CentralShadowStep > 0 { + items = append(items, solarEclipseMapLegendItem{ + label: solarEclipseOutlineLegendLabel("central", info.Type, options.CentralShadowStep, options.Language), + kind: "line", color: "#7b5a42", + }) + } + contactLabel := "P/U 影锥接触" + hasUContacts := !partial.U1.Time.IsZero() || !partial.U2.Time.IsZero() || + !partial.U3.Time.IsZero() || !partial.U4.Time.IsZero() + if !hasUContacts { + contactLabel = "P 半影接触" + } + if options.Language == "en" { + contactLabel = "P/U shadow contacts" + if !hasUContacts { + contactLabel = "P penumbral contacts" + } + } + return append(items, solarEclipseMapLegendItem{label: contactLabel, kind: "contact", color: "#a52d70"}) +} + +// solarEclipseMapLegendRows 按可用宽度把条目分行:每行不超过四栏,栏位由实测文本宽度累加,长标签不会挤进下一栏。 +func solarEclipseMapLegendRows(items []solarEclipseMapLegendItem, available float64) [][]solarEclipseMapLegendItem { + rows := make([][]solarEclipseMapLegendItem, 0, 2) + current := make([]solarEclipseMapLegendItem, 0, solarEclipseLegendMaxColumns) + used := 0.0 + flush := func() { + if len(current) == 0 { + return + } + rows = append(rows, current) + current = make([]solarEclipseMapLegendItem, 0, solarEclipseLegendMaxColumns) + used = 0 + } + for _, item := range items { + width := solarEclipseLegendIconWidth + svgchart.EstimatedTextWidth(item.label, solarEclipseLegendFontSize) + next := width + if len(current) > 0 { + next = used + solarEclipseLegendColumnGap + width + if len(current) >= solarEclipseLegendMaxColumns || next > available { + flush() + next = width + } + } + current = append(current, item) + used = next + } + flush() + return rows +} + +// solarEclipseMapLegendBlockHeight 由图例行数给出图例带高度:首行基线上方还要留出字高。 +func solarEclipseMapLegendBlockHeight(rows int) float64 { + if rows < 1 { + rows = 1 + } + above, below := svgchart.EstimatedTextExtents(solarEclipseLegendFontSize) + return float64(rows-1)*solarEclipseLegendLineStep + above + below + 1 +} + +// solarEclipseMapLegendAvailableWidth 只由画布宽度与排布给出图例可用宽度,与竖向堆叠无关。 +func solarEclipseMapLegendAvailableWidth(options SolarEclipseMapSVGOptions) float64 { + width, height := float64(options.Width), float64(options.Height) + if width >= height { + panelGap := math.Max(12, width*0.011) + return solarEclipseLandscapeColumns*solarEclipseLandscapeCellWidth + + float64(solarEclipseLandscapeColumns-1)*panelGap - 26 + } + margin := math.Max(30, math.Min(52, width*0.044)) + return width - 2*margin - 26 +} + +// solarEclipseMapPointLabel 是一个点标注:图形锚点、文本、字号与落位结果。 +type solarEclipseMapPointLabel struct { + x, y float64 + text string + fontSize float64 + placed svgchart.LabelPlacement + ok bool +} + +func solarEclipseMapPlaceLabel( + labels *svgchart.LabelTable, + label solarEclipseMapPointLabel, + candidates []svgchart.LabelPlacement, + frame svgmap.Frame, +) solarEclipseMapPointLabel { + label.placed, label.ok = labels.Place(label.text, label.fontSize, + labelCandidatesInsideBox(candidates, label.text, label.fontSize, solarEclipseFrameBox(frame))) + return label +} + +// labelCandidatesInsideBox 丢掉会让文本跑出给定区域的候选位次。 +func labelCandidatesInsideBox( + candidates []svgchart.LabelPlacement, + text string, + fontSize float64, + area svgchart.LabelBox, +) []svgchart.LabelPlacement { + kept := make([]svgchart.LabelPlacement, 0, len(candidates)) + for _, candidate := range candidates { + box := svgchart.LabelTextBox(candidate.X, candidate.Y, fontSize, text, candidate.Anchor) + if box.X < area.X || box.X+box.Width > area.X+area.Width { + continue + } + if box.Y < area.Y || box.Y+box.Height > area.Y+area.Height { + continue + } + kept = append(kept, candidate) + } + return kept +} + +// solarEclipseFrameBox 把图框写成占位矩形。 +func solarEclipseFrameBox(frame svgmap.Frame) svgchart.LabelBox { + return svgchart.LabelBox{X: frame.X, Y: frame.Y, Width: frame.Width, Height: frame.Height} +} + +// solarEclipseMapPointLabels 汇总优先放置的点标注:食甚、影锥接触、太阳直射点与中心线时刻。 +type solarEclipseMapPointLabels struct { + greatest solarEclipseMapPointLabel + contacts []solarEclipseContactLabel + subsolar solarEclipseMapPointLabel + times []solarEclipseMapPointLabel +} + +// solarEclipseContactMarker 是一个影锥接触标记:点名、位置、颜色与原位标注偏移。 +type solarEclipseContactMarker struct { + name string + point eclipsecore.SolarEclipsePathPoint + color string + labelDX float64 + labelDY float64 + textAnchor string +} + +// solarEclipseContactLabel 是接触标记的落位结果:图形锚点与标注文本。 +type solarEclipseContactLabel struct { + marker solarEclipseContactMarker + x, y float64 + text string + placed svgchart.LabelPlacement + ok bool +} + +func solarEclipseContactMarkerSpecs(info eclipsecore.SolarEclipsePartialFootprintsInfo) []solarEclipseContactMarker { + return []solarEclipseContactMarker{ + {name: "P1", point: info.P1, color: "#a52d70", labelDX: -7, labelDY: 14, textAnchor: "end"}, + {name: "P2", point: info.P2, color: "#a52d70", labelDX: 7, labelDY: -7, textAnchor: "start"}, + {name: "P3", point: info.P3, color: "#a52d70", labelDX: -7, labelDY: -7, textAnchor: "end"}, + {name: "P4", point: info.P4, color: "#a52d70", labelDX: 7, labelDY: 14, textAnchor: "start"}, + {name: "U1", point: info.U1, color: "#a12c25", labelDX: -7, labelDY: -7, textAnchor: "end"}, + {name: "U2", point: info.U2, color: "#a12c25", labelDX: 7, labelDY: 14, textAnchor: "start"}, + {name: "U3", point: info.U3, color: "#a12c25", labelDX: -7, labelDY: 14, textAnchor: "end"}, + {name: "U4", point: info.U4, color: "#a12c25", labelDX: 7, labelDY: -7, textAnchor: "start"}, + } +} + +// solarEclipseMapPlacePointLabels 按重要性放置点标注:食甚最先占位,其余标注再让开。 +func solarEclipseMapPlacePointLabels( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, + central eclipsecore.SolarEclipsePath, + hasCentral bool, + frame svgmap.Frame, + options SolarEclipseMapSVGOptions, + labels *svgchart.LabelTable, +) solarEclipseMapPointLabels { + result := solarEclipseMapPointLabels{} + if x, y, ok := frame.Project(partial.Eclipse.GreatestLongitude, partial.Eclipse.GreatestLatitude); ok { + text := "食甚" + if options.Language == "en" { + text = "Greatest" + } + result.greatest = solarEclipseMapPlaceLabel(labels, solarEclipseMapPointLabel{x: x, y: y, text: text, fontSize: 11}, + []svgchart.LabelPlacement{ + {X: x, Y: y - 10, Anchor: "middle"}, + {X: x, Y: y + 19, Anchor: "middle"}, + {X: x + 9, Y: y + 4, Anchor: "start"}, + {X: x - 9, Y: y + 4, Anchor: "end"}, + {X: x + 9, Y: y - 8, Anchor: "start"}, + {X: x - 9, Y: y - 8, Anchor: "end"}, + {X: x, Y: y - 24, Anchor: "middle"}, + {X: x, Y: y + 33, Anchor: "middle"}, + }, frame) + } + result.contacts = solarEclipseMapPlaceContactLabels(partial, frame, labels) + result.subsolar = solarEclipseMapPlaceSubsolarLabel(partial.Eclipse, frame, options.Language, labels) + if hasCentral { + result.times = solarEclipseMapPlaceTimeLabels(central, frame, options, labels) + } + return result +} + +func solarEclipseMapPlaceContactLabels( + info eclipsecore.SolarEclipsePartialFootprintsInfo, + frame svgmap.Frame, + labels *svgchart.LabelTable, +) []solarEclipseContactLabel { + specs := solarEclipseContactMarkerSpecs(info) + result := make([]solarEclipseContactLabel, 0, len(specs)) + for _, marker := range specs { + if marker.point.Time.IsZero() { + continue + } + x, y, visible := frame.Project(marker.point.Longitude, marker.point.Latitude) + if !visible { + continue + } + labelX, anchor := x+marker.labelDX, marker.textAnchor + // 贴到图框边上的点名改到另一侧,避免被裁掉。 + if labelX < frame.X+18 { + labelX, anchor = x+7, "start" + } else if labelX > frame.X+frame.Width-18 { + labelX, anchor = x-7, "end" + } + candidates := make([]svgchart.LabelPlacement, 0, 22) + candidates = append(candidates, svgchart.LabelPlacement{X: labelX, Y: y + marker.labelDY, Anchor: anchor}) + candidates = append(candidates, svgchart.LabelCandidates(x+7, y+14, "start", 9)...) + candidates = append(candidates, svgchart.LabelCandidates(x-7, y-7, "end", 9)...) + candidates = append(candidates, svgchart.LabelCandidates(x+7, y-7, "start", 16)...) + candidates = append(candidates, svgchart.LabelCandidates(x-7, y+14, "end", 16)...) + label := solarEclipseMapPlaceLabel(labels, solarEclipseMapPointLabel{x: x, y: y, text: marker.name, fontSize: 8}, candidates, frame) + result = append(result, solarEclipseContactLabel{marker: marker, x: x, y: y, text: marker.name, placed: label.placed, ok: label.ok}) + } + return result +} + +func solarEclipseMapPlaceSubsolarLabel( + info eclipsecore.SolarEclipseInfo, + frame svgmap.Frame, + language string, + labels *svgchart.LabelTable, +) solarEclipseMapPointLabel { + point := solarEclipseSubsolarPoint(info.GreatestEclipse) + x, y, visible := frame.Project(point.Longitude, point.Latitude) + if !visible { + return solarEclipseMapPointLabel{} + } + text := "太阳直射点" + if language == "en" { + text = "Subsolar" + } + return solarEclipseMapPlaceLabel(labels, solarEclipseMapPointLabel{x: x, y: y, text: text, fontSize: 7}, + []svgchart.LabelPlacement{ + {X: x, Y: y - 9, Anchor: "middle"}, + {X: x, Y: y + 16, Anchor: "middle"}, + {X: x + 7, Y: y + 4, Anchor: "start"}, + {X: x - 7, Y: y + 4, Anchor: "end"}, + {X: x, Y: y - 22, Anchor: "middle"}, + {X: x, Y: y + 29, Anchor: "middle"}, + {X: x + 16, Y: y + 4, Anchor: "start"}, + {X: x - 16, Y: y + 4, Anchor: "end"}, + }, frame) +} + +func solarEclipseMapPlaceTimeLabels( + path eclipsecore.SolarEclipsePath, + frame svgmap.Frame, + options SolarEclipseMapSVGOptions, + labels *svgchart.LabelTable, +) []solarEclipseMapPointLabel { + if options.TimeLabelStep <= 0 || len(path.CenterLine) < 2 { + return nil + } + excluded := []time.Time{path.Eclipse.CentralBeginOnEarth, path.Eclipse.CentralEndOnEarth} + markers := solarEclipseTimeMarkerPoints(path.CenterLine, options.TimeLabelStep, options.Location, excluded) + result := make([]solarEclipseMapPointLabel, 0, len(markers)) + projected := make([][2]float64, 0, len(markers)) + for _, marker := range markers { + x, y, visible := frame.Project(marker.Longitude, marker.Latitude) + if !visible || x < frame.X+22 || x > frame.X+frame.Width-22 || y < frame.Y+12 || y > frame.Y+frame.Height-12 { + continue + } + tooClose := false + for _, previous := range projected { + if math.Hypot(x-previous[0], y-previous[1]) < 44 { + tooClose = true + break + } + } + if tooClose { + continue + } + projected = append(projected, [2]float64{x, y}) + preferredY := y - 8 + if mapTimesNear(marker.Time, path.Eclipse.GreatestEclipse, solarEclipseGreatestTimeLabelWindow(options.TimeLabelStep)) { + preferredY = y + 15 + } else if preferredY < frame.Y+10 { + preferredY = y + 15 + } + text := marker.Time.In(options.Location).Format("15:04") + label := solarEclipseMapPlaceLabel(labels, solarEclipseMapPointLabel{x: x, y: y, text: text, fontSize: 9}, + []svgchart.LabelPlacement{ + {X: x, Y: preferredY, Anchor: "middle"}, + {X: x, Y: y + 15, Anchor: "middle"}, + {X: x, Y: y - 8, Anchor: "middle"}, + {X: x + 11, Y: y + 4, Anchor: "start"}, + {X: x - 11, Y: y + 4, Anchor: "end"}, + {X: x, Y: y + 27, Anchor: "middle"}, + {X: x, Y: y - 21, Anchor: "middle"}, + }, frame) + result = append(result, label) + } + return result +} + +// solarEclipseContourLabelCandidates 先给首选点,再沿最长支路从中间向两端取候选位次,标注始终贴着等值线。 +func solarEclipseContourLabelCandidates( + segments [][]eclipsecore.SolarEclipsePathPoint, + frame svgmap.Frame, + preferredX, preferredY float64, + hasPreferred bool, +) []svgchart.LabelPlacement { + candidates := make([]svgchart.LabelPlacement, 0, 48) + if hasPreferred { + candidates = append(candidates, svgchart.LabelPlacement{X: preferredX, Y: preferredY, Anchor: "middle"}) + } + longest := -1 + for index, segment := range segments { + if longest < 0 || len(segment) > len(segments[longest]) { + longest = index + } + } + if longest < 0 { + return candidates + } + segment := segments[longest] + middle := len(segment) / 2 + for offset := 0; offset <= middle; offset++ { + for _, index := range []int{middle + offset, middle - offset} { + if index < 0 || index >= len(segment) { + continue + } + point := segment[index] + x, y, visible := frame.Project(point.Longitude, point.Latitude) + if !visible || + x < frame.X+30 || x > frame.X+frame.Width-30 || + y < frame.Y+18 || y > frame.Y+frame.Height-18 { + continue + } + candidates = append(candidates, svgchart.LabelPlacement{X: x, Y: y - 6, Anchor: "middle"}) + if len(candidates) >= 48 { + return candidates + } + } + } + return candidates +} + +// solarEclipseScaleStep 在槽位宽度内取最大的整刻度,required 给出该刻度下刻度条加标注的总横向占用。 +func solarEclipseScaleStep(target, available float64, required func(step float64) float64) float64 { + if target <= 0 || available <= 0 { + return 0 + } + start := int(math.Floor(math.Log10(target))) + for exponent := start + 1; exponent >= start-4; exponent-- { + decade := math.Pow(10, float64(exponent)) + for _, multiplier := range []float64{5, 2, 1} { + step := decade * multiplier + if required(step) <= available { + return step + } + } + } + return 0 +} diff --git a/eclipse/svg/solar_map_landscape.go b/eclipse/svg/solar_map_landscape.go new file mode 100644 index 0000000..6b202c3 --- /dev/null +++ b/eclipse/svg/solar_map_landscape.go @@ -0,0 +1,248 @@ +package svg + +import ( + "fmt" + "html" + "math" + "strings" + + "b612.me/astro/internal/svgchart" + "b612.me/astro/internal/svgmap" +) + +// 横版详细版式的固定量:数据块栏宽、栏数行数与地图长宽比。 +const ( + solarEclipseLandscapeCellWidth = 230.0 + solarEclipseLandscapeColumns = 2 + solarEclipseLandscapeRows = 3 + // 等经纬地图经度跨 360°,比例尺按赤道弧长换算。 + solarEclipseEquirectangularAspect = 2.0 + solarEclipseEarthCircumferenceKM = 40075.017 + // 横带上下限:低于这些值时数据块行距会压到文字高度以下,画布必须拒绝。 + // 表头下限让开摘要最后一行;横带与图例之间的空隙还要容下末行文字的伸出部分。 + solarEclipseLandscapeMinHeader = 156.0 + solarEclipseLandscapeMinFooter = 44.0 + solarEclipseLandscapeMinGap = 12.0 + solarEclipseLandscapeMinRowGap = 8.0 + solarEclipseLandscapeMinLegend = 42.0 + // 比例尺槽最小高度:刻度上下各要一行标注。 + solarEclipseScaleSlotMinHeight = 46.0 +) + +// 横版两栏三行的行序固定为日月地心块、接触时刻块、地方情况与历表块,最大行数依次是 4、4、6。 +var solarEclipseLandscapeDefaultRowFields = [solarEclipseLandscapeRows]float64{4, 4, 6} + +// solarEclipseLandscapeRowFields 数出该事件下每行最多几行文字:行高必须按它分配。 +func solarEclipseLandscapeRowFields(blocks solarEclipsePanelBlocks) [solarEclipseLandscapeRows]float64 { + fields := [solarEclipseLandscapeRows]float64{} + for index, block := range solarEclipseLandscapeBlockOrder(blocks) { + row := index / solarEclipseLandscapeColumns + if row >= solarEclipseLandscapeRows { + continue + } + if count := float64(len(block.rows)); count > fields[row] { + fields[row] = count + } + } + return fields +} + +// solarEclipseLandscapeRowHeights 把横带高度分给三行:行距一致,行高为表头加上行数乘行距;空行高度为 0 也不占行距。 +func solarEclipseLandscapeRowHeights( + bandHeight, rowGap float64, + fields [solarEclipseLandscapeRows]float64, +) ([solarEclipseLandscapeRows]float64, float64) { + heights := [solarEclipseLandscapeRows]float64{} + totalFields, filled := 0.0, 0 + for _, count := range fields { + totalFields += count + if count > 0 { + filled++ + } + } + if totalFields <= 0 || filled == 0 { + return heights, 0 + } + spacing := (bandHeight - float64(filled-1)*rowGap - + float64(filled)*solarEclipsePanelHeaderHeight) / totalFields + for index, count := range fields { + if count <= 0 { + continue + } + heights[index] = solarEclipsePanelHeaderHeight + count*spacing + } + return heights, spacing +} + +// solarEclipseStripDivision 把地图下方那条分成天平动盒与比例尺槽,留不出比例尺高度时天平动盒吃掉整条。 +func solarEclipseStripDivision( + bottomY, bottomHeight, width, margin, gap, rowSpacing float64, +) (float64, svgchart.LabelBox) { + natural := solarEclipsePanelHeaderHeight + 4*rowSpacing + 0.5 + if slotHeight := bottomHeight - natural - gap; slotHeight >= solarEclipseScaleSlotMinHeight { + return natural, svgchart.LabelBox{X: margin, Y: bottomY + natural + gap, Width: width, Height: slotHeight} + } + shorter := bottomHeight - solarEclipseScaleSlotMinHeight - gap + if shorter >= solarEclipseMinPanelHeight(4) { + return shorter, svgchart.LabelBox{ + X: margin, Y: bottomY + shorter + gap, + Width: width, Height: solarEclipseScaleSlotMinHeight, + } + } + return bottomHeight, svgchart.LabelBox{} +} + +// solarEclipseLandscapeMapLayout 是横版详细版式:地图在左,数据块两栏三行在右, +// 地图下方那条放天平动与比例尺。竖版是“地图在上、面板在下”,横版把排布轴换到水平方向。 +func solarEclipseLandscapeMapLayout( + width, height float64, + projection svgmap.Projection, + center svgmap.GeoPoint, + legendRows int, + rowFields [solarEclipseLandscapeRows]float64, +) solarEclipseMapLayout { + aspect := solarEclipseMapAspect(projection) + margin := math.Max(30, math.Min(52, width*0.044)) + headerHeight := math.Max(solarEclipseLandscapeMinHeader, height*0.17) + footerHeight := math.Max(solarEclipseLandscapeMinFooter, height*0.04) + legendHeight := math.Max(solarEclipseLandscapeMinLegend, solarEclipseMapLegendBlockHeight(legendRows)) + gap := math.Max(solarEclipseLandscapeMinGap, height*0.01) + panelGap := math.Max(12, width*0.011) + rowGap := math.Max(solarEclipseLandscapeMinRowGap, height*0.008) + + legendY := height - footerHeight - legendHeight + bandTop := headerHeight + gap + bandBottom := math.Max(bandTop, legendY-gap) + bandHeight := bandBottom - bandTop + + gridWidth := solarEclipseLandscapeColumns*solarEclipseLandscapeCellWidth + + float64(solarEclipseLandscapeColumns-1)*panelGap + gridX := width - margin - gridWidth + rowHeights, rowSpacing := solarEclipseLandscapeRowHeights(bandHeight, rowGap, rowFields) + rowY := [solarEclipseLandscapeRows]float64{} + gridCellHeight := 0.0 + cursor := bandTop + for index := range rowHeights { + rowY[index] = cursor + if rowHeights[index] <= 0 { + continue + } + cursor += rowHeights[index] + rowGap + if rowHeights[index] > gridCellHeight { + gridCellHeight = rowHeights[index] + } + } + + // 地图下方那条要放天平动与比例尺,先扣掉它的高度,圆盘图才不会把整条横带吃光。 + stripHeight := math.Max(150, bandHeight*0.24) + mapMaxHeight := math.Max(120, bandHeight-gap-stripHeight) + leftWidth := math.Max(240, gridX-gap-margin) + mapWidth := leftWidth + mapHeight := mapWidth / aspect + if mapHeight > mapMaxHeight { + mapHeight = mapMaxHeight + mapWidth = mapHeight * aspect + } + mapY := bandTop + bottomY := mapY + mapHeight + gap + bottomHeight := math.Max(0, bandBottom-bottomY) + stripPanelHeight, scaleSlot := solarEclipseStripDivision(bottomY, bottomHeight, leftWidth, margin, gap, rowSpacing) + + return solarEclipseMapLayout{ + frame: svgmap.Frame{ + X: margin + (leftWidth-mapWidth)/2, Y: mapY, Width: mapWidth, Height: mapHeight, + Projection: projection, + CenterLongitude: center.Longitude, CenterLatitude: center.Latitude, + }, + nasa: true, + landscape: true, + panelX: gridX, + panelY: bandTop, + panelWidth: gridWidth, + panelHeight: gridCellHeight, + panelGap: panelGap, + gridX: gridX, + gridCellWidth: solarEclipseLandscapeCellWidth, + gridCellHeight: gridCellHeight, + gridGapX: panelGap, + gridGapY: rowGap, + gridRowY: rowY, + gridRowHeights: rowHeights, + bottomY: bottomY, + stripHeight: stripPanelHeight, + scaleSlot: scaleSlot, + margin: margin, + legendY: legendY, + scaleY: scaleSlot.Y + scaleSlot.Height*0.45, + } +} + +// solarEclipseLandscapeBlockOrder 是横版两栏三行里的数据块顺序。 +func solarEclipseLandscapeBlockOrder(blocks solarEclipsePanelBlocks) []solarEclipsePanelBlock { + return []solarEclipsePanelBlock{ + blocks.sun, blocks.moon, + blocks.penumbra, blocks.umbra, + blocks.circumstances, blocks.ephemeris, + } +} + +// writeSolarEclipseLandscapePanels 画横版的数据块网格、地图下方那条与比例尺。 +func writeSolarEclipseLandscapePanels( + builder *strings.Builder, + cells []solarEclipsePanelCell, + layout solarEclipseMapLayout, + options SolarEclipseMapSVGOptions, +) { + for _, cell := range cells { + svgchart.WritePanelBox(builder, "solar-detailed-panel", cell.box.X, cell.box.Y, + cell.box.Width, cell.box.Height, cell.title, cell.rows) + } + if layout.scaleSlot.Height <= 0 { + return + } + if layout.frame.Projection == svgmap.ProjectionEquirectangular { + writeSolarEclipseEquirectangularScaleBar(builder, layout) + } else { + writeSolarEclipseScaleBar(builder, layout, options) + } +} + +// writeSolarEclipseEquirectangularScaleBar 画等经纬图的比例尺;圆盘版式的换算不适用于平面图。 +// 刻度长度由槽位宽度反推,绝不越过槽位压到数据块上。 +func writeSolarEclipseEquirectangularScaleBar(builder *strings.Builder, layout solarEclipseMapLayout) { + slot := layout.scaleSlot + if slot.Width <= 0 || slot.Height <= 0 || layout.frame.Width <= 0 { + return + } + kilometresPerPixel := solarEclipseEarthCircumferenceKM / layout.frame.Width + target := 150.0 * kilometresPerPixel + step := solarEclipseScaleStep(target, slot.Width, func(candidate float64) float64 { + barWidth := candidate / kilometresPerPixel + return barWidth + 0.5*svgchart.EstimatedTextWidth("0", 10) + + 0.5*svgchart.EstimatedTextWidth(formatSolarEclipseScale(candidate)+" km", 10) + 6 + }) + if step <= 0 { + return + } + barWidth := step / kilometresPerPixel + centreX := slot.X + slot.Width/2 + y := layout.scaleY + left := centreX - barWidth/2 + fmt.Fprintf(builder, ``, + left, y, left+barWidth, y) + for _, tick := range []float64{0, 0.25, 0.5, 0.75, 1} { + x := left + tick*barWidth + length := 5.0 + if tick == 0 || tick == 1 { + length = 9 + } + fmt.Fprintf(builder, ``, + x, y-length/2, x, y+length/2) + } + fmt.Fprintf(builder, `0`, left, y-10) + fmt.Fprintf(builder, `%s km`, + left+barWidth, y-10, html.EscapeString(formatSolarEclipseScale(step))) + fmt.Fprintf(builder, `比例尺`, + centreX, y+22) + builder.WriteString(``) +} diff --git a/eclipse/svg/solar_map_landscape_layout_test.go b/eclipse/svg/solar_map_landscape_layout_test.go new file mode 100644 index 0000000..3c2063c --- /dev/null +++ b/eclipse/svg/solar_map_landscape_layout_test.go @@ -0,0 +1,118 @@ +package svg + +import ( + "regexp" + "strconv" + "testing" + "time" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgmap" +) + +var solarEclipsePanelRectPattern = regexp.MustCompile( + `class="solar-detailed-panel"> layout.gridX { + t.Fatalf("map frame right edge %.3f overlaps the panel grid at x=%.3f by %.3f px", + frameRight, layout.gridX, frameRight-layout.gridX) + } + // 数据块最多六行,行高按 (cellHeight-30)/行数 等分。 + rowSpacing := (layout.gridCellHeight - 30) / 6 + if rowSpacing < 8 { + t.Fatalf("panel row spacing = %.2f px, want at least 8", rowSpacing) + } + + rendered, ok := SolarEclipseMapSVG(date, options) + if !ok { + t.Fatal("missing map") + } + rects := solarEclipsePanelRectPattern.FindAllStringSubmatch(rendered, -1) + if len(rects) == 0 { + t.Fatal("landscape map renders no data panels") + } + for _, rect := range rects { + values := make([]float64, 4) + for index := range values { + parsed, err := strconv.ParseFloat(rect[index+1], 64) + if err != nil { + t.Fatalf("bad panel rect %v: %v", rect, err) + } + values[index] = parsed + } + x, y, width, height := values[0], values[1], values[2], values[3] + if x < 0 || y < 0 || x+width > float64(test.width) || y+height > float64(test.height) { + t.Fatalf("panel rect (%.1f,%.1f,%.1f,%.1f) leaves the %dx%d canvas", x, y, width, height, test.width, test.height) + } + if x < frameRight && layout.frame.X < x+width && + y < layout.frame.Y+layout.frame.Height && layout.frame.Y < y+height { + t.Fatalf("panel rect (%.1f,%.1f,%.1f,%.1f) intersects the map frame", x, y, width, height) + } + } + }) + } + + // 低于下限的那一维单独回落到默认值,另一维保持请求值。 + for _, test := range []struct { + name string + width, height int + wantWidth, wantHeight int + }{ + {name: "legacy minimum", width: 640, height: 420, wantWidth: 960, wantHeight: 640}, + {name: "narrow", width: 700, height: 600, wantWidth: 960, wantHeight: 600}, + {name: "short", width: 1000, height: 500, wantWidth: 1000, wantHeight: 640}, + } { + t.Run("fallback/"+test.name, func(t *testing.T) { + normalized := normalizeSolarEclipseMapSVGOptions(date, SolarEclipseMapSVGOptions{Width: test.width, Height: test.height}) + if normalized.Width != test.wantWidth || normalized.Height != test.wantHeight { + t.Fatalf("%dx%d normalized to %dx%d, want %dx%d", + test.width, test.height, normalized.Width, normalized.Height, + test.wantWidth, test.wantHeight) + } + }) + } +} + +// 画布内闭合:文档下限的横版画布与同宽度的竖版画布都不得让 text/rect 越出画布。 +func TestSolarEclipseMapKeepsEveryElementInCanvas(t *testing.T) { + date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) + for _, size := range [][2]int{{800, 560}, {800, 801}} { + rendered, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{ + Width: size[0], Height: size[1], Location: time.UTC, PartialStep: 10 * time.Minute, + GreatestTimeStep: 30 * time.Minute, PenumbralOutlineStep: time.Hour, CentralShadowStep: 10 * time.Minute, + }) + if !ok { + t.Fatalf("%dx%d: missing map", size[0], size[1]) + } + assertSVGElementsInCanvas(t, rendered, size[0], size[1]) + } +} diff --git a/eclipse/svg/solar_map_legend_test.go b/eclipse/svg/solar_map_legend_test.go new file mode 100644 index 0000000..ecbd861 --- /dev/null +++ b/eclipse/svg/solar_map_legend_test.go @@ -0,0 +1,42 @@ +package svg + +import ( + "strings" + "testing" + "time" +) + +// 图例只能画一次:横版曾经在渲染器与横版面板里各画一次,而且两处传入的中心带参数还不一致。 +func TestSolarEclipseMapLegendRendersOnce(t *testing.T) { + for _, test := range []struct { + name string + date time.Time + width, height int + hasCentralLine bool + }{ + {name: "landscape default", date: time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), width: 960, height: 640, hasCentralLine: true}, + {name: "landscape wide", date: time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), width: 1200, height: 800, hasCentralLine: true}, + {name: "portrait", date: time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), width: 900, height: 1400, hasCentralLine: true}, + {name: "landscape non-central", date: time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), width: 1200, height: 800}, + } { + t.Run(test.name, func(t *testing.T) { + rendered, ok := SolarEclipseMapSVG(test.date, SolarEclipseMapSVGOptions{ + Width: test.width, Height: test.height, Location: time.UTC, PartialStep: 10 * time.Minute, + }) + if !ok { + t.Fatal("missing map") + } + if got := strings.Count(rendered, `class="solar-map-legend"`); got != 1 { + t.Fatalf("legend groups = %d, want exactly one", got) + } + for _, label := range []string{"偏食可见区", "P/U 影锥接触"} { + if got := strings.Count(rendered, ">"+label+""); got != 1 { + t.Fatalf("legend label %q appears %d times, want once", label, got) + } + } + if got := strings.Count(rendered, ">中心线"); (got == 1) != test.hasCentralLine { + t.Fatalf("center-line legend label appears %d times, hasCentralLine=%v", got, test.hasCentralLine) + } + }) + } +} diff --git a/eclipse/svg/solar_map_panel_rows.go b/eclipse/svg/solar_map_panel_rows.go new file mode 100644 index 0000000..5e844e8 --- /dev/null +++ b/eclipse/svg/solar_map_panel_rows.go @@ -0,0 +1,148 @@ +package svg + +import ( + "fmt" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/svgchart" +) + +// 本文件的函数只负责把日食数据变成面板行;画格由 writeEclipsePanelBox 统一处理, +// 竖版与横版两种排布共用同一批行,避免各写一遍。 + +func solarEclipseContactTitles(options SolarEclipseMapSVGOptions) (penumbra, circumstances, umbra string) { + if options.Language == "en" { + penumbra, circumstances, umbra = "Penumbral contacts", "Local circumstances at greatest", "Umbral contacts" + } else { + penumbra, circumstances, umbra = "半影外切 / 内切接触", "食甚点的地方情况", "本影外切 / 内切接触" + } + // 全球阶段量(食甚经纬度、中心食始/终)就在这一块里,它的标题可由 EventsTitle 覆盖。 + if options.EventsTitle != "" { + circumstances = options.EventsTitle + } + return penumbra, circumstances, umbra +} + +func solarEclipseEphemerisTitles(options SolarEclipseMapSVGOptions) (ephemeris, libration string) { + if options.Language == "en" { + return "Ephemeris and constants", "Libration" + } + return "历表与常数", "天平动" +} + +func solarEclipseGeocentricTitles(options SolarEclipseMapSVGOptions) (sun, moon string) { + if options.Language == "en" { + return "Sun at greatest eclipse", "Moon at greatest eclipse" + } + return "食甚时的太阳(地心坐标)", "食甚时的月亮(地心坐标)" +} + +func solarEclipseContactRows( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, + local eclipsecore.LocalSolarEclipseInfo, + hasLocal bool, + options SolarEclipseMapSVGOptions, +) (penumbra, umbra, circumstances []svgchart.PanelRow) { + english := options.Language == "en" + name := func(zh, en string) string { + if english { + return en + } + return zh + } + contact := func(label string, point eclipsecore.SolarEclipsePathPoint) svgchart.PanelRow { + if point.Time.IsZero() { + return svgchart.PanelRow{} + } + return svgchart.PanelRow{Label: label, Value: point.Time.In(options.Location).Format("15:04:05")} + } + penumbra = []svgchart.PanelRow{ + contact("P1 "+name("半影外切", "partial begins"), partial.P1), + contact("P2 "+name("半影内切", "internal contact"), partial.P2), + contact("P3 "+name("半影内切", "internal contact"), partial.P3), + contact("P4 "+name("半影外切", "partial ends"), partial.P4), + } + umbra = []svgchart.PanelRow{ + contact("U1 "+name("本影外切", "umbra begins"), partial.U1), + contact("U2 "+name("本影内切", "internal contact"), partial.U2), + contact("U3 "+name("本影内切", "internal contact"), partial.U3), + contact("U4 "+name("本影外切", "umbra ends"), partial.U4), + } + info := partial.Eclipse + // 非中心食没有中心线,带宽与中心食时长无从谈起,留空而不是写 0。 + pathWidth, centralDuration := "—", "—" + if info.HasCentral { + pathWidth = fmt.Sprintf("%.1f km", info.PathWidthKM) + centralDuration = formatSolarEclipseMapDuration(info.CentralDuration) + } + circumstances = []svgchart.PanelRow{ + {Label: name("食甚", "Greatest"), Value: info.GreatestEclipse.In(options.Location).Format("15:04:05")}, + {Label: name("纬度 Lat.", "Latitude"), Value: solarEclipseFormatCoordinates(info.GreatestLongitude, info.GreatestLatitude)}, + {Label: name("中心食带宽", "Central path width"), Value: pathWidth}, + {Label: name("中心食时长", "Central duration"), Value: centralDuration}, + } + // 地球范围的中心食始/终只在全球阶段块出现,数据块缺了它会丢掉这两个时刻。 + if !info.CentralBeginOnEarth.IsZero() { + circumstances = append(circumstances, svgchart.PanelRow{ + Label: name("中心食始", "Central begins"), + Value: info.CentralBeginOnEarth.In(options.Location).Format("15:04:05"), + }) + } + if !info.CentralEndOnEarth.IsZero() { + circumstances = append(circumstances, svgchart.PanelRow{ + Label: name("中心食终", "Central ends"), + Value: info.CentralEndOnEarth.In(options.Location).Format("15:04:05"), + }) + } + if hasLocal { + circumstances[1] = svgchart.PanelRow{ + Label: name("站心食分", "Local magnitude"), + Value: fmt.Sprintf("%.4f", local.Magnitude), + } + } + return penumbra, umbra, circumstances +} + +func solarEclipseEphemerisRows( + panel eclipsecore.SolarEclipseGeocentricPanel, + options SolarEclipseMapSVGOptions, +) (ephemeris, libration []svgchart.PanelRow) { + ephemeris = []svgchart.PanelRow{ + {Label: "历表", Value: panel.Ephemeris}, + {Label: "ΔT", Value: fmt.Sprintf("%.1f s", panel.DeltaTSeconds)}, + {Label: "k1", Value: fmt.Sprintf("%.7f", panel.PenumbralK)}, + {Label: "k2", Value: fmt.Sprintf("%.7f", panel.UmbralK)}, + {Label: "Δb", Value: fmt.Sprintf("%+.1f\"", panel.BodyShiftLatitudeArcsec)}, + {Label: "Δl", Value: fmt.Sprintf("%+.1f\"", panel.BodyShiftLongitudeArcsec)}, + } + libration = []svgchart.PanelRow{ + {Label: "经天平动 l", Value: fmt.Sprintf("%+.2f°", panel.LibrationLongitudeDeg)}, + {Label: "纬天平动 b", Value: fmt.Sprintf("%+.2f°", panel.LibrationLatitudeDeg)}, + {Label: "自转轴位置角 c", Value: fmt.Sprintf("%.2f°", panel.LibrationPositionAngleDeg)}, + {Label: "布朗月序数", Value: fmt.Sprintf("%d", panel.BrownLunationNumber)}, + } + if options.Language == "en" { + ephemeris[0].Label = "Ephemeris" + libration[0].Label, libration[1].Label = "Libration l", "Libration b" + libration[2].Label, libration[3].Label = "Axis position angle c", "Brown lunation" + } + return ephemeris, libration +} + +func solarEclipseGeocentricRows( + panel eclipsecore.SolarEclipseGeocentricPanel, +) (sun, moon []svgchart.PanelRow) { + sun = []svgchart.PanelRow{ + {Label: "赤经 R.A.", Value: formatSolarEclipseRA(panel.SunRightAscensionDeg)}, + {Label: "赤纬 Dec.", Value: formatSolarEclipseDec(panel.SunDeclinationDeg)}, + {Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(panel.SunSemidiameterArcsec)}, + {Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(panel.SunParallaxArcsec)}, + } + moon = []svgchart.PanelRow{ + {Label: "赤经 R.A.", Value: formatSolarEclipseRA(panel.MoonRightAscensionDeg)}, + {Label: "赤纬 Dec.", Value: formatSolarEclipseDec(panel.MoonDeclinationDeg)}, + {Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(panel.MoonSemidiameterArcsec)}, + {Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(panel.MoonParallaxArcsec)}, + } + return sun, moon +} diff --git a/eclipse/svg/solar_map_partial_sweep_test.go b/eclipse/svg/solar_map_partial_sweep_test.go new file mode 100644 index 0000000..eaa39d6 --- /dev/null +++ b/eclipse/svg/solar_map_partial_sweep_test.go @@ -0,0 +1,47 @@ +package svg + +import ( + "testing" + "time" +) + +// 偏食足迹扫描的密步长请求被夹到两分钟:并集需要一整条自洽的扫描序列, +// 更密的扫描成倍放大成本却不改产物,事后抽稀又会破坏并集的覆盖面判定。 +func TestSolarEclipseMapPartialStepUsesDefaultSweep(t *testing.T) { + date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) + for _, test := range []struct { + requested time.Duration + want time.Duration + }{ + {requested: 0, want: 2 * time.Minute}, + {requested: -time.Minute, want: 2 * time.Minute}, + {requested: time.Second, want: 2 * time.Minute}, + {requested: 10 * time.Second, want: 2 * time.Minute}, + {requested: time.Minute, want: 2 * time.Minute}, + {requested: 119 * time.Second, want: 2 * time.Minute}, + {requested: 2 * time.Minute, want: 2 * time.Minute}, + {requested: 3 * time.Minute, want: 3 * time.Minute}, + {requested: 10 * time.Minute, want: 10 * time.Minute}, + } { + got := normalizeSolarEclipseMapSVGOptions(date, SolarEclipseMapSVGOptions{ + PartialStep: test.requested, + }).PartialStep + if got != test.want { + t.Fatalf("PartialStep %s normalized to %s, want %s", test.requested, got, test.want) + } + } + + // 一秒请求与默认两分钟请求必须逐字节相同,代价也回到默认量级。 + fast, fastOK := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, PartialStep: time.Second, + }) + defaulted, defaultOK := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{ + Width: 900, Height: 620, Location: time.UTC, PartialStep: 2 * time.Minute, + }) + if !fastOK || !defaultOK { + t.Fatal("missing 2024-04-08 map") + } + if fast != defaulted { + t.Fatalf("a one-second sweep request changed the rendered document (%d vs %d bytes)", len(fast), len(defaulted)) + } +} diff --git a/eclipse/svg/solar_map_test.go b/eclipse/svg/solar_map_test.go index 727a99e..0575fcf 100644 --- a/eclipse/svg/solar_map_test.go +++ b/eclipse/svg/solar_map_test.go @@ -1,11 +1,14 @@ package svg import ( + "math" "strconv" "strings" "testing" "time" + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/geodata" "b612.me/astro/internal/svgmap" ) @@ -19,25 +22,70 @@ func TestSolarEclipseMapSVGTotalIncludesPartialAndCentralRegions(t *testing.T) { } for _, want := range []string{ "日全食全球见食图", "偏食始", "偏食终", "偏食可见区", "全食带", "中心线", - "全球见食范围与中心食带", "全球阶段", "中心食始", "中心食终", "食带宽", - "太阳沙罗 139", "食甚点太阳高度", "中心食持续", "P2", "P3", "U1", "U4", + "全球见食范围与中心食带", "中心食始", "中心食终", "中心食带宽", + "沙罗序列 139", "食甚点太阳高度", "中心食带宽", "图中时刻为", "中心食持续", "P2", "P3", "U1", "U4", `class="partial-eclipse-region"`, `class="central-eclipse-band"`, `class="solar-center-line"`, `class="northern-central-limit"`, `class="southern-central-limit"`, `class="solar-greatest-marker"`, - `class="solar-global-events"`, `class="solar-time-marker"`, `>17:00`, - `class="solar-greatest-terminator"`, `class="solar-penumbral-outline"`, - `class="solar-central-shadow-outline"`, `class="solar-shadow-contact solar-contact-p1"`, - `class="solar-axis-contact"`, `class="solar-subsolar-marker"`, + `class="solar-time-marker"`, `>17:00`, + `class="solar-rise-set-boundary solar-start-rise"`, `stroke="#d97706"`, + `class="solar-shadow-contact solar-contact-p1"`, `class="solar-magnitude-contour"`, + `class="solar-axis-contact"`, `class="solar-subsolar-marker"`, `font-size="7"`, `opacity="0.72">太阳直射点`, `class="land"`, "不含行政边界", } { if !strings.Contains(diagram, want) { t.Fatalf("total solar-eclipse map missing %q", want) } } + // 瞬时半影/本影轮廓默认不画,否则会把地球盖住。 + for _, unwanted := range []string{`class="solar-penumbral-outline"`, `class="solar-central-shadow-outline"`} { + if strings.Contains(diagram, unwanted) { + t.Fatalf("default map must not draw %s", unwanted) + } + } if err := validateEclipseMapXML(diagram); err != nil { t.Fatalf("total solar-eclipse map is not valid XML: %v", err) } } +func TestSolarEclipsePartialBandPolygonsContainSampledFootprints(t *testing.T) { + for _, test := range []struct { + name string + date time.Time + step time.Duration + boundaries int + }{ + {name: "2009 antimeridian", date: time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC), step: 2 * time.Minute, boundaries: 96}, + {name: "2010 polar fold", date: time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), step: 10 * time.Minute, boundaries: 24}, + {name: "2014 noncentral", date: time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), step: 10 * time.Minute, boundaries: 24}, + } { + t.Run(test.name, func(t *testing.T) { + partial, ok := eclipsecore.SolarEclipsePartialFootprints(test.date, eclipsecore.SolarEclipsePartialFootprintOptions{ + Step: test.step, BoundaryPoints: test.boundaries, + }) + if !ok { + t.Fatal("expected solar eclipse") + } + polygons, ok := solarEclipsePartialBandPolygons(partial) + if !ok || len(polygons) == 0 { + t.Fatal("expected authoritative partial-band polygons") + } + paths := make([][]geodata.GeoPoint, 0, len(partial.Footprints)*2) + for _, footprint := range partial.Footprints { + for _, boundary := range footprint.Boundaries { + path := make([]geodata.GeoPoint, len(boundary)) + for index, point := range boundary { + path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + paths = append(paths, path) + } + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, paths, false); miss > 5 { + t.Fatalf("sampled penumbral boundary protrudes %.2f km outside the authoritative band", miss) + } + }) + } +} + func TestSolarEclipseMapSVGPartialOnlyUsesPolarProjection(t *testing.T) { diagram, ok := SolarEclipseMapSVG( time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC), @@ -54,7 +102,7 @@ func TestSolarEclipseMapSVGPartialOnlyUsesPolarProjection(t *testing.T) { if strings.Contains(diagram, `class="central-eclipse-band"`) || strings.Contains(diagram, `class="solar-center-line"`) { t.Fatal("partial-only map contains a central path") } - if !strings.Contains(diagram, `class="solar-global-events"`) || !strings.Contains(diagram, "全球阶段") { + if !strings.Contains(diagram, `class="solar-detailed-panel"`) || !strings.Contains(diagram, "食甚点的地方情况") { t.Fatal("partial-only map is missing global phase information") } if !strings.Contains(diagram, "全球见食范围") || strings.Contains(diagram, "全球见食范围与中心食带") { @@ -100,7 +148,7 @@ func TestSolarEclipseMapSVGCanDisableSampledShadowOutlines(t *testing.T) { if strings.Contains(diagram, "半影时刻线") || strings.Contains(diagram, "本影轮廓") { t.Fatal("disabled sampled shadow outlines remain in the legend") } - if !strings.Contains(diagram, `class="solar-greatest-terminator"`) || + if !strings.Contains(diagram, `class="solar-rise-set-boundary solar-greatest-rise"`) || !strings.Contains(diagram, `class="solar-shadow-contact solar-contact-u1"`) { t.Fatal("disabling sampled outlines removed required contact geometry") } @@ -114,19 +162,22 @@ func TestSolarEclipseMapSVGExplainsSampledShadowLines(t *testing.T) { Projection: EclipseMapProjectionEquirectangular, } normalized := normalizeSolarEclipseMapSVGOptions(date, options) - if normalized.PenumbralOutlineStep != time.Hour { - t.Fatalf("default penumbral outline step = %s, want 1h", normalized.PenumbralOutlineStep) + if normalized.PenumbralOutlineStep != 0 { + t.Fatalf("default penumbral outline step = %s, want it disabled", normalized.PenumbralOutlineStep) } explicit := normalizeSolarEclipseMapSVGOptions(date, SolarEclipseMapSVGOptions{PenumbralOutlineStep: 30 * time.Minute}) if explicit.PenumbralOutlineStep != 30*time.Minute { t.Fatalf("explicit penumbral outline step = %s, want 30m", explicit.PenumbralOutlineStep) } + // 显式请求时才画瞬时轮廓,图例也要给出采样间隔。 + options.PenumbralOutlineStep = time.Hour + options.CentralShadowStep = 10 * time.Minute diagram, ok := SolarEclipseMapSVG(date, options) if !ok { t.Fatal("expected 2035 total solar-eclipse map") } for _, want := range []string{ - "半影时刻线(60 分钟)", "食甚晨昏圈", "本影轮廓(10 分钟)", "P/U 影锥接触", + "半影时刻线(60 分钟)", "初亏/食甚/复圆日升日落线", "本影轮廓(10 分钟)", "P/U 影锥接触", `class="solar-penumbral-time-label"`, `class="solar-map-legend"`, } { if !strings.Contains(diagram, want) { @@ -156,7 +207,7 @@ func TestSolarEclipseMapSVGAntarcticEventUsesSouthPolarProjection(t *testing.T) func TestSolarEclipseMapSVGAnnularLabelsCentralBand(t *testing.T) { diagram, ok := SolarEclipseMapSVG( time.Date(2023, 10, 14, 0, 0, 0, 0, time.UTC), - SolarEclipseMapSVGOptions{PartialStep: 15 * time.Minute}, + SolarEclipseMapSVGOptions{PartialStep: 15 * time.Minute, CentralShadowStep: 10 * time.Minute}, ) if !ok { t.Fatal("expected annular solar-eclipse map") @@ -169,6 +220,165 @@ func TestSolarEclipseMapSVGAnnularLabelsCentralBand(t *testing.T) { } } +func TestSolarEclipseMapSVG20100115TrimsExternalContactsFromTwoLimitSides(t *testing.T) { + path, ok := eclipsecore.SolarEclipseCentralPath( + time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), + eclipsecore.SolarEclipsePathOptions{Step: 2 * time.Minute, TargetSpacingKM: 700}, + ) + if !ok { + t.Fatal("expected 2010 annular central path") + } + northern, southern, ok := solarEclipseTwoLimitPresentationLimits(path) + if !ok { + t.Fatal("expected physical-endpoint two-limit presentation geometry") + } + if len(northern) >= len(path.NorthernLimit) || len(southern) >= len(path.SouthernLimit) { + t.Fatal("external-contact samples were not trimmed from the rendered limits") + } + start := path.CenterLine[0].Time + end := path.CenterLine[len(path.CenterLine)-1].Time + for name, points := range map[string][]eclipsecore.SolarEclipsePathPoint{ + "north": northern, + "south": southern, + } { + if !points[0].Time.After(start) || !points[len(points)-1].Time.Before(end) { + t.Fatalf("%s rendered limit extends outside the axis-contact interval", name) + } + } + partial, ok := eclipsecore.SolarEclipsePartialFootprints( + time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), + eclipsecore.SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 180}, + ) + if !ok { + t.Fatal("expected 2010 annular partial footprint model") + } + polygons, ok := solarEclipseTwoLimitBandPolygons( + path, northern, southern, partial.CentralBandFootprints, + ) + if !ok || len(polygons) != 1 || len(polygons[0]) <= len(northern)+len(southern) { + t.Fatalf("physical endpoint sweep polygons=%d ok=%v, want one enriched central-band outline", len(polygons), ok) + } + minimumEndTurn := 180.0 + for index := 1; index+1 < len(polygons[0]); index++ { + point := polygons[0][index] + if point.Longitude < 120 || point.Longitude > 123 || point.Latitude < 36 || point.Latitude > 39 { + continue + } + previous, next := polygons[0][index-1], polygons[0][index+1] + incoming := math.Atan2(point.Latitude-previous.Latitude, point.Longitude-previous.Longitude) + outgoing := math.Atan2(next.Latitude-point.Latitude, next.Longitude-point.Longitude) + minimumEndTurn = math.Min(minimumEndTurn, math.Remainder((outgoing-incoming)*180/math.Pi, 360)) + } + if minimumEndTurn < -30 { + t.Fatalf("2010 eastern SVG central-band cap turns inward by %.1f degrees", minimumEndTurn) + } + diagram, ok := SolarEclipseMapSVG( + time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), + SolarEclipseMapSVGOptions{PartialStep: 10 * time.Minute}, + ) + if !ok { + t.Fatal("expected 2010 annular SVG") + } + if strings.Contains(diagram, ``) + if end < 0 { + t.Fatal("central-shadow sweep group is not closed") + } + group := diagram[start : start+end] + if got := strings.Count(group, ``) + if end < 0 { + t.Fatal("2043 critical-envelope group is not closed") + } + if got := strings.Count(diagram[start:start+end], ` float64(width)) { + t.Fatalf("text x=%.3f outside the %d-wide canvas: %v", x, width, element.attrs) + } + if hasY && (y < 0 || y > float64(height)) { + t.Fatalf("text y=%.3f outside the %d-high canvas: %v", y, height, element.attrs) + } + case "rect": + x, hasX := svgAttributeFloat(element.attrs, "x") + y, hasY := svgAttributeFloat(element.attrs, "y") + w, hasW := svgAttributeFloat(element.attrs, "width") + h, hasH := svgAttributeFloat(element.attrs, "height") + if !hasX || !hasY || !hasW || !hasH { + continue + } + rects++ + if x < -0.01 || y < -0.01 || x+w > float64(width)+0.01 || y+h > float64(height)+0.01 { + t.Fatalf("rect (%.3f,%.3f,%.3f,%.3f) outside the %dx%d canvas", x, y, w, h, width, height) + } + } + } + if texts == 0 || rects == 0 { + t.Fatalf("chart carries %d texts and %d rects, want a full page", texts, rects) + } +} diff --git a/eclipse/svg/text_overlap_test.go b/eclipse/svg/text_overlap_test.go new file mode 100644 index 0000000..7d37519 --- /dev/null +++ b/eclipse/svg/text_overlap_test.go @@ -0,0 +1,240 @@ +package svg + +import ( + "encoding/xml" + "fmt" + "io" + "math" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/internal/svgchart" +) + +// 详细版式里任意两段文字都不许压叠:包围盒口径与 internal/svgchart 一致, +// 出图尺寸要么 0 压叠,要么明确拒绝,不得返回带压叠文字的图。 +type overlapTextBox struct { + text string + x, y, width, height float64 + fontSize float64 +} + +func svgTextFloat(attrs map[string]string, name string) float64 { + value := strings.TrimRight(strings.TrimRight(attrs[name], "pt"), "px") + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0 + } + return parsed +} + +func svgTextAnchorBox(box overlapTextBox, anchor string) overlapTextBox { + switch anchor { + case "middle": + box.x -= box.width / 2 + case "end": + box.x -= box.width + } + return box +} + +func canvasTextBoxes(t *testing.T, document string) []overlapTextBox { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(document)) + boxes := make([]overlapTextBox, 0, 128) + var anchor string + var pending *overlapTextBox + var content strings.Builder + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("chart is not valid XML: %v", err) + } + switch element := token.(type) { + case xml.StartElement: + if element.Name.Local != "text" { + continue + } + attrs := make(map[string]string, len(element.Attr)) + for _, attribute := range element.Attr { + attrs[attribute.Name.Local] = attribute.Value + } + fontSize := svgTextFloat(attrs, "font-size") + above, below := svgchart.EstimatedTextExtents(fontSize) + pending = &overlapTextBox{ + x: svgTextFloat(attrs, "x"), y: svgTextFloat(attrs, "y"), + height: above + below, fontSize: fontSize, + } + pending.y -= above + anchor = attrs["text-anchor"] + content.Reset() + case xml.CharData: + if pending != nil { + content.Write([]byte(element)) + } + case xml.EndElement: + if element.Name.Local != "text" || pending == nil { + continue + } + pending.text = content.String() + pending.width = svgchart.EstimatedTextWidth(pending.text, pending.fontSize) + boxes = append(boxes, svgTextAnchorBox(*pending, anchor)) + pending = nil + } + } + if len(boxes) == 0 { + t.Fatal("chart carries no text") + } + return boxes +} + +func overlapArea(first, second overlapTextBox) float64 { + left, right := math.Max(first.x, second.x), math.Min(first.x+first.width, second.x+second.width) + top, bottom := math.Max(first.y, second.y), math.Min(first.y+first.height, second.y+second.height) + if right <= left || bottom <= top { + return 0 + } + return (right - left) * (bottom - top) +} + +func assertNoTextOverlap(t *testing.T, label, document string) { + t.Helper() + boxes := canvasTextBoxes(t, document) + for first := 0; first < len(boxes); first++ { + for second := first + 1; second < len(boxes); second++ { + area := overlapArea(boxes[first], boxes[second]) + if area <= 0.5 { + continue + } + t.Errorf("%s: %q [%.1f,%.1f w=%.1f h=%.1f] overlaps %q [%.1f,%.1f w=%.1f h=%.1f] by %.1f px²", + label, + boxes[first].text, boxes[first].x, boxes[first].y, boxes[first].width, boxes[first].height, + boxes[second].text, boxes[second].x, boxes[second].y, boxes[second].width, boxes[second].height, + area) + } + } +} + +var eclipseOverlapSizes = [][2]int{ + {800, 560}, {800, 600}, {960, 640}, {1000, 1414}, {1414, 1000}, {1200, 800}, +} + +type eclipseOverlapCase struct { + name string + date time.Time +} + +var solarOverlapCases = []eclipseOverlapCase{ + {name: "2024-04-08-total", date: time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)}, + {name: "2010-01-15-annular", date: time.Date(2010, time.January, 15, 7, 0, 0, 0, time.UTC)}, + {name: "2014-04-29-noncentral", date: time.Date(2014, time.April, 29, 6, 0, 0, 0, time.UTC)}, + {name: "2009-07-22-total", date: time.Date(2009, time.July, 22, 2, 0, 0, 0, time.UTC)}, +} + +var lunarOverlapCases = []eclipseOverlapCase{ + {name: "2025-03-14-total", date: time.Date(2025, time.March, 14, 6, 0, 0, 0, time.UTC)}, + {name: "2016-08-18-penumbral", date: time.Date(2016, time.August, 18, 12, 0, 0, 0, time.UTC)}, +} + +func renderSolarOverlapCase(event eclipseOverlapCase, size [2]int, options SolarEclipseMapSVGOptions) (string, bool) { + options.Width, options.Height = size[0], size[1] + return SolarEclipseMapSVG(event.date, options) +} + +func renderLunarOverlapCase(event eclipseOverlapCase, size [2]int, options LunarEclipseDetailedSVGOptions) (string, bool) { + options.Width, options.Height = size[0], size[1] + return LunarEclipseDetailedSVG(event.date, options) +} + +// 事件 × 尺寸矩阵:返回 ok=true 的产物必须 0 压叠,被拒绝的必须返回空串。 +func TestSolarEclipseMapTextOverlapMatrix(t *testing.T) { + accepted := make(map[string]bool) + for _, event := range solarOverlapCases { + for _, size := range eclipseOverlapSizes { + name := fmt.Sprintf("%s/%dx%d", event.name, size[0], size[1]) + document, ok := renderSolarOverlapCase(event, size, SolarEclipseMapSVGOptions{}) + accepted[name] = ok + if !ok { + if document != "" { + t.Errorf("%s: rejected the canvas but returned %d bytes", name, len(document)) + } + continue + } + assertNoTextOverlap(t, name, document) + } + } + // 文档下限与默认尺寸必须继续出图:拒绝只允许出现在真的放不下的画布上。 + for _, name := range []string{ + "2024-04-08-total/800x560", "2024-04-08-total/800x600", "2024-04-08-total/960x640", + "2010-01-15-annular/800x560", "2014-04-29-noncentral/800x560", "2009-07-22-total/1200x800", + } { + if !accepted[name] { + t.Errorf("%s: documented size was rejected", name) + } + } +} + +func TestLunarEclipseDetailedTextOverlapMatrix(t *testing.T) { + accepted := make(map[string]bool) + for _, event := range lunarOverlapCases { + for _, size := range eclipseOverlapSizes { + name := fmt.Sprintf("%s/%dx%d", event.name, size[0], size[1]) + document, ok := renderLunarOverlapCase(event, size, LunarEclipseDetailedSVGOptions{}) + accepted[name] = ok + if !ok { + if document != "" { + t.Errorf("%s: rejected the canvas but returned %d bytes", name, len(document)) + } + continue + } + assertNoTextOverlap(t, name, document) + } + } + for _, name := range []string{ + "2025-03-14-total/1000x1414", "2025-03-14-total/1414x1000", + "2016-08-18-penumbral/1000x1414", "2016-08-18-penumbral/1414x1000", + } { + if !accepted[name] { + t.Errorf("%s: documented size was rejected", name) + } + } +} + +// 英文文案更长、可选图层会撑出更多图例行,两种扩展都必须保持 0 压叠。 +func TestEclipseChartTextOverlapWithLongLabels(t *testing.T) { + for _, size := range [][2]int{{800, 560}, {960, 640}, {1000, 1414}} { + name := fmt.Sprintf("solar-en/%dx%d", size[0], size[1]) + document, ok := renderSolarOverlapCase(solarOverlapCases[0], size, SolarEclipseMapSVGOptions{Language: "en"}) + if !ok { + t.Errorf("%s: documented size was rejected", name) + continue + } + assertNoTextOverlap(t, name, document) + } + for _, size := range [][2]int{{800, 560}, {960, 640}} { + name := fmt.Sprintf("solar-all-layers/%dx%d", size[0], size[1]) + document, ok := renderSolarOverlapCase(solarOverlapCases[0], size, SolarEclipseMapSVGOptions{ + GreatestTimeStep: 30 * time.Minute, PenumbralOutlineStep: time.Hour, CentralShadowStep: 10 * time.Minute, + }) + if !ok { + continue + } + assertNoTextOverlap(t, name, document) + } + for _, event := range lunarOverlapCases { + for _, size := range [][2]int{{1000, 1414}, {1414, 1000}} { + name := fmt.Sprintf("lunar-en/%s/%dx%d", event.name, size[0], size[1]) + document, ok := renderLunarOverlapCase(event, size, LunarEclipseDetailedSVGOptions{Language: "en"}) + if !ok { + t.Errorf("%s: documented size was rejected", name) + continue + } + assertNoTextOverlap(t, name, document) + } + } +} diff --git a/geojson/antares_20240303_regression_test.go b/geojson/antares_20240303_regression_test.go new file mode 100644 index 0000000..bc03724 --- /dev/null +++ b/geojson/antares_20240303_regression_test.go @@ -0,0 +1,328 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/occultationgeo" + "b612.me/astro/moon" +) + +func TestMarshalStarOccultationAntares20240303UsesContinuousVisibleEnvelope(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.March, 3, 0, 0, 0, 0, zone) + star := moon.StarCoordinate{ + ID: "HR 6134", RA: 247.35166667, Dec: -26.43194444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, + ProperMotionDecMasPerYear: -20, + ParallaxMas: 24, + } + paths, err := moon.FindStarOccultationPaths( + start, start.Add(24*time.Hour), star, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalStarOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + collection := decodeCollection(t, data) + band := featureWithRole(t, collection, "occultation-band") + if band.Properties["static_band_authoritative"] != true { + t.Fatalf("occultation-band source=%v authoritative=%v, want analytic authoritative boundary", + band.Properties["source"], band.Properties["static_band_authoritative"]) + } + for index, point := range paths[0].CenterLine { + if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) { + t.Fatalf("center-line sample %d lies outside visible band at %.6f, %.6f", + index, point.Longitude, point.Latitude) + } + } + for _, feature := range featuresWithRole(collection, "visibility-boundary") { + var lines [][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode visibility-boundary: %v", err) + } + for segmentIndex, line := range lines { + for pointIndex, point := range line { + if !geometryContainsPointWithinKM(t, band.Geometry, point[0], point[1], 2) { + t.Fatalf("visibility-boundary segment %d point %d lies outside visible band at %.6f, %.6f", + segmentIndex, pointIndex, point[0], point[1]) + } + } + } + } + assertOccultationBandMaximumEdge(t, band, 45) +} + +func TestMarshalStarOccultationAntares20240303RetainsNarrowGreatestSetFold(t *testing.T) { + start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindStarOccultationPaths( + start, start.Add(24*time.Hour), antaresCoordinateForGeoJSONRegression(), + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + var curve moon.OccultationRiseSetCurve + for _, candidate := range paths[0].RiseSetCurves { + if candidate.Phase == moon.RiseSetPhaseGreatest && candidate.Direction == moon.RiseSetDirectionSet { + curve = candidate + break + } + } + if len(curve.Segments) < 2 { + t.Fatalf("greatest/set segments=%d, want the narrow fold branch recovered at one-minute sampling", len(curve.Segments)) + } + shared := false + for first := 0; first < len(curve.Segments); first++ { + for second := first + 1; second < len(curve.Segments); second++ { + for _, left := range []moon.OccultationPathPoint{curve.Segments[first][0], curve.Segments[first][len(curve.Segments[first])-1]} { + for _, right := range []moon.OccultationPathPoint{curve.Segments[second][0], curve.Segments[second][len(curve.Segments[second])-1]} { + if left.Time.Sub(right.Time) < -time.Second || left.Time.Sub(right.Time) > time.Second { + continue + } + if geojsonPointDistanceKM(left.Longitude, left.Latitude, right.Longitude, right.Latitude) <= 1 { + shared = true + } + } + } + } + } + if !shared { + t.Fatal("greatest/set fold branches do not share a physical endpoint") + } + data, err := geojson.MarshalStarOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + boundary := riseSetBoundaryFeature(t, decodeCollection(t, data), "greatest", "set") + var lines [][][]float64 + if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode greatest/set boundary: %v", err) + } + if len(lines) < 2 { + t.Fatalf("serialized greatest/set segments=%d, want at least two folded branches", len(lines)) + } +} + +func geojsonPointDistanceKM(firstLongitude, firstLatitude, secondLongitude, secondLatitude float64) float64 { + const earthRadiusKM = 6378.1366 + const degreesToRadians = 3.141592653589793 / 180 + firstLat, secondLat := firstLatitude*degreesToRadians, secondLatitude*degreesToRadians + deltaLat := (secondLatitude - firstLatitude) * degreesToRadians + deltaLon := (secondLongitude - firstLongitude) * degreesToRadians + a := math.Sin(deltaLat/2)*math.Sin(deltaLat/2) + math.Cos(firstLat)*math.Cos(secondLat)*math.Sin(deltaLon/2)*math.Sin(deltaLon/2) + return 2 * earthRadiusKM * math.Asin(math.Sqrt(math.Max(0, math.Min(1, a)))) +} + +func TestMarshalStarOccultationAntaresRepresentative2022To2026Topology(t *testing.T) { + star := antaresCoordinateForGeoJSONRegression() + for _, date := range []string{ + "2023-09-21", // first event in the series; short temporal horizon closure + "2023-10-18", // complete endpoint network without a temporal connector + "2024-03-03", // original disconnected footprint-sweep regression + "2024-06-20", // antimeridian split + "2025-08-31", // multi-branch southern polar turn and numerical sliver + "2026-02-11", // pole-enclosing equirectangular output + "2026-12-08", // returning ordinary-latitude branch + } { + date := date + t.Run(date, func(t *testing.T) { + start, err := time.Parse("2006-01-02", date) + if err != nil { + t.Fatal(err) + } + paths, err := moon.FindStarOccultationPaths( + start, start.Add(24*time.Hour), star, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + polygons, authoritative, err := occultationgeo.VisibleStarBandPolygonsFromAnalyticContours( + path.BandFootprints, path.BandContours, path.VisibilityContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil || !authoritative || len(polygons) != 1 { + t.Fatalf("analytic polygons=%d authoritative=%v err=%v, want one physical band", len(polygons), authoritative, err) + } + assertOccultationPhysicalPolygonsMaximumEdge(t, polygons, 45) + assertOccultationPhysicalPolygonsHaveNoShortHairpins(t, polygons, 35, 25, 12) + if !geodata.SphericalPolygonsContainPathsWithinKM( + polygons, occultationPathPointLines([][]moon.OccultationPathPoint{path.CenterLine}), false, 0.1, + ) { + t.Fatalf("analytic band misses center line by %.3f km", + geodata.SphericalPolygonsPathMissDistanceKM( + polygons, occultationPathPointLines([][]moon.OccultationPathPoint{path.CenterLine}), false, + )) + } + phaseLines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves)) + for _, curve := range path.RiseSetCurves { + phaseLines = append(phaseLines, + occultationPathPointLines(occultationgeo.StitchedRiseSetCurveSegments(curve))..., + ) + } + if !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 2) { + t.Fatalf("analytic band misses a displayed rise/set phase by %.3f km", + geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false)) + } + data, err := geojson.MarshalStarOccultation(path) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + collection := decodeCollection(t, data) + band := featureWithRole(t, collection, "occultation-band") + if band.Properties["static_band_authoritative"] != true { + t.Fatalf("occultation-band source=%v authoritative=%v", + band.Properties["source"], band.Properties["static_band_authoritative"]) + } + for index, point := range path.CenterLine { + if !geometryContainsPointWithinKM(t, band.Geometry, point.Longitude, point.Latitude, 0.1) { + t.Fatalf("GeoJSON band excludes center sample %d at %.6f, %.6f", + index, point.Longitude, point.Latitude) + } + } + }) + } +} + +func antaresCoordinateForGeoJSONRegression() moon.StarCoordinate { + return moon.StarCoordinate{ + ID: "HR 6134", RA: 247.35166667, Dec: -26.43194444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, + ProperMotionDecMasPerYear: -20, + ParallaxMas: 24, + } +} + +func occultationPathPointLines(sources [][]moon.OccultationPathPoint) [][]geodata.GeoPoint { + result := make([][]geodata.GeoPoint, 0, len(sources)) + for _, source := range sources { + if len(source) < 2 { + continue + } + line := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + result = append(result, line) + } + return result +} + +func assertOccultationPhysicalPolygonsMaximumEdge( + t *testing.T, + polygons [][]geodata.GeoPoint, + maximumKM float64, +) { + t.Helper() + for polygonIndex, polygon := range polygons { + for index := range polygon { + next := (index + 1) % len(polygon) + distance := geoJSONCoordinateDistanceKM( + []float64{polygon[index].Longitude, polygon[index].Latitude}, + []float64{polygon[next].Longitude, polygon[next].Latitude}, + ) + if distance > maximumKM { + t.Fatalf("physical polygon %d edge %d is %.1f km, want <=%.1f km", + polygonIndex, index, distance, maximumKM) + } + } + } +} + +func assertOccultationPhysicalPolygonsHaveNoShortHairpins( + t *testing.T, + polygons [][]geodata.GeoPoint, + maximumClosureKM, minimumDetourKM float64, + maximumSpan int, +) { + t.Helper() + for _, polygon := range polygons { + ring := make([][]float64, len(polygon)) + for index, point := range polygon { + ring[index] = []float64{point.Longitude, point.Latitude} + } + assertGeoJSONRingHasNoShortHairpins( + t, "physical occultation-band", ring, + maximumClosureKM, minimumDetourKM, maximumSpan, + ) + } +} + +func geometryContainsPointWithinKM(t *testing.T, value struct { + Type string `json:"type"` + Coordinates json.RawMessage `json:"coordinates"` + Geometries json.RawMessage `json:"geometries"` +}, longitude, latitude, toleranceKM float64) bool { + if geometryContainsPoint(t, value, longitude, latitude) { + return true + } + var polygons [][][][]float64 + if value.Type == "MultiPolygon" { + if json.Unmarshal(value.Coordinates, &polygons) != nil { + return false + } + } else if value.Type == "Polygon" { + var polygon [][][]float64 + if json.Unmarshal(value.Coordinates, &polygon) != nil { + return false + } + polygons = [][][][]float64{polygon} + } else { + return false + } + point := []float64{longitude, latitude} + for _, polygon := range polygons { + for _, ring := range polygon { + for index := 1; index < len(ring); index++ { + if geoJSONPointSegmentDistanceKM(point, ring[index-1], ring[index]) <= toleranceKM { + return true + } + } + } + } + return false +} + +func assertOccultationBandMaximumEdge(t *testing.T, feature decodedFeature, maximumKM float64) { + t.Helper() + var polygons [][][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode %v polygons: %v", feature.Properties["role"], err) + } + for polygonIndex, polygon := range polygons { + for ringIndex, ring := range polygon { + for pointIndex := 1; pointIndex < len(ring); pointIndex++ { + if distance := geoJSONCoordinateDistanceKM(ring[pointIndex-1], ring[pointIndex]); distance > maximumKM { + t.Fatalf("%v polygon %d ring %d edge %d is %.1f km, want <= %.1f km", + feature.Properties["role"], polygonIndex, ringIndex, pointIndex, distance, maximumKM) + } + } + } + } +} diff --git a/geojson/eclipse.go b/geojson/eclipse.go index 13121db..c21e283 100644 --- a/geojson/eclipse.go +++ b/geojson/eclipse.go @@ -2,11 +2,15 @@ package geojson import ( "fmt" + "math" + "sort" "time" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/geodata" + "b612.me/astro/internal/lunarhorizon" + "b612.me/astro/internal/solarclosure" ) func validateSolarEclipseInput( @@ -20,6 +24,9 @@ func validateSolarEclipseInput( if !info.HasPartial { return fmt.Errorf("geojson: solar eclipse must contain a partial phase") } + if info.Type == eclipsecore.SolarEclipsePartial && len(partial.CentralBandFootprints) > 0 { + return fmt.Errorf("geojson: partial solar eclipse cannot contain central band footprints") + } if info.PartialBeginOnEarth.IsZero() || info.PartialEndOnEarth.IsZero() { return fmt.Errorf("geojson: solar eclipse partial contact times are required") } @@ -27,23 +34,119 @@ func validateSolarEclipseInput( !info.GreatestEclipse.Before(info.PartialEndOnEarth) { return fmt.Errorf("geojson: solar eclipse times must be ordered partial begin, greatest, partial end") } + if !finiteGeoJSON(info.Magnitude) || info.Magnitude <= 0 { + return fmt.Errorf("geojson: solar eclipse magnitude must be positive and finite") + } if err := validateSolarPathPoint("solar greatest", eclipsecore.SolarEclipsePathPoint{ Time: info.GreatestEclipse, Longitude: info.GreatestLongitude, Latitude: info.GreatestLatitude, }); err != nil { return err } - previous := time.Time{} - for index, footprint := range partial.Footprints { - if footprint.Time.IsZero() { - return fmt.Errorf("geojson: solar partial footprint %d time is required", index) + if err := validateSolarFootprints( + "partial", partial.Footprints, info.PartialBeginOnEarth, info.PartialEndOnEarth, + ); err != nil { + return err + } + for _, contact := range []struct { + name string + point eclipsecore.SolarEclipsePathPoint + }{ + {"P1", partial.P1}, {"P2", partial.P2}, {"P3", partial.P3}, {"P4", partial.P4}, + {"U1", partial.U1}, {"U2", partial.U2}, {"U3", partial.U3}, {"U4", partial.U4}, + } { + if contact.point.Time.IsZero() { + continue } - if !previous.IsZero() && !footprint.Time.After(previous) { - return fmt.Errorf("geojson: solar partial footprint times must be strictly increasing") + if err := validateSolarPathPoint("solar "+contact.name, contact.point); err != nil { + return err } - if footprint.Time.Before(info.PartialBeginOnEarth) || footprint.Time.After(info.PartialEndOnEarth) { - return fmt.Errorf("geojson: solar partial footprint %d time is outside the partial interval", index) + if !solarEclipseTimeInsideInterval( + contact.point.Time, info.PartialBeginOnEarth, info.PartialEndOnEarth, + ) { + return fmt.Errorf("geojson: solar %s time is outside the partial interval", contact.name) } - previous = footprint.Time + } + if err := validateSolarContactSequence( + "penumbral", partial.P1, partial.P2, partial.P3, partial.P4, + ); err != nil { + return err + } + if err := validateSolarContactSequence( + "central-shadow", partial.U1, partial.U2, partial.U3, partial.U4, + ); err != nil { + return err + } + centralShadowStart, centralShadowEnd := partial.U1.Time, partial.U4.Time + if len(partial.CentralShadowFootprints) > 0 || len(partial.CentralBandFootprints) > 0 { + if centralShadowStart.IsZero() || centralShadowEnd.IsZero() || !centralShadowStart.Before(centralShadowEnd) { + return fmt.Errorf("geojson: solar central-shadow footprints require ordered U1 and U4 contacts") + } + } + if err := validateSolarFootprints( + "central-shadow", partial.CentralShadowFootprints, centralShadowStart, centralShadowEnd, + ); err != nil { + return err + } + if err := validateSolarFootprints( + "central-band", partial.CentralBandFootprints, centralShadowStart, centralShadowEnd, + ); err != nil { + return err + } + if len(partial.CentralBandHorizonClosures) != 0 && len(partial.CentralBandHorizonClosures) != 2 { + return fmt.Errorf("geojson: solar central-band horizon closures require start and end arcs") + } + for index, closure := range partial.CentralBandHorizonClosures { + if len(closure) < 2 { + return fmt.Errorf("geojson: solar central-band horizon closure %d requires at least two points", index) + } + for pointIndex, point := range closure { + if err := validateSolarPathPoint( + fmt.Sprintf("solar central-band horizon closure %d point %d", index, pointIndex), point, + ); err != nil { + return err + } + } + for _, point := range closure { + if !solarEclipseTimeInsideInterval(point.Time, centralShadowStart, centralShadowEnd) { + return fmt.Errorf("geojson: solar central-band horizon closure %d is outside U1-U4", index) + } + } + } + for segmentIndex, segment := range partial.PartialBandContours { + if err := validateSolarMagnitudeContourSeries( + fmt.Sprintf("solar partial-band contour %d", segmentIndex), segment, true, + ); err != nil { + return err + } + } + for index, contour := range partial.MagnitudeContours { + if !finiteGeoJSON(contour.Magnitude) || contour.Magnitude <= 0 || contour.Magnitude > info.Magnitude+1e-9 { + return fmt.Errorf("geojson: solar magnitude contour %d must be positive and no greater than the eclipse magnitude", index) + } + if len(contour.Segments) > 0 { + for segmentIndex, segment := range contour.Segments { + if err := validateSolarMagnitudeContourSeries( + fmt.Sprintf("solar magnitude contour %d segment %d", index, segmentIndex), segment, true, + ); err != nil { + return err + } + } + continue + } + if err := validateSolarPathSeries("solar northern magnitude contour", contour.NorthernLimit, true); err != nil { + return err + } + if err := validateSolarPathSeries("solar southern magnitude contour", contour.SouthernLimit, true); err != nil { + return err + } + } + if err := validateSolarGreatestTimeContours(partial.GreatestTimeContours); err != nil { + return err + } + if err := validateSolarRiseSetCurves( + partial.RiseSetCurves, info.PartialBeginOnEarth, info.PartialEndOnEarth, + ); err != nil { + return err } if central == nil { return nil @@ -93,6 +196,152 @@ func validateSolarEclipseInput( return nil } +func validateSolarContactSequence( + name string, + contacts ...eclipsecore.SolarEclipsePathPoint, +) error { + previous := time.Time{} + for index, contact := range contacts { + if contact.Time.IsZero() { + continue + } + if !previous.IsZero() && !previous.Before(contact.Time) { + return fmt.Errorf("geojson: solar %s contacts must be strictly ordered at %d", name, index) + } + previous = contact.Time + } + return nil +} + +func validateSolarFootprints( + name string, + footprints []eclipsecore.SolarEclipsePartialFootprint, + start, end time.Time, +) error { + previous := time.Time{} + for footprintIndex, footprint := range footprints { + if footprint.Time.IsZero() { + return fmt.Errorf("geojson: solar %s footprint %d time is required", name, footprintIndex) + } + if !previous.IsZero() && !footprint.Time.After(previous) { + return fmt.Errorf("geojson: solar %s footprint times must be strictly increasing", name) + } + if footprint.Time.Before(start) || footprint.Time.After(end) { + return fmt.Errorf("geojson: solar %s footprint %d time is outside its event interval", name, footprintIndex) + } + if len(footprint.Boundaries) == 0 { + return fmt.Errorf("geojson: solar %s footprint %d has no boundary", name, footprintIndex) + } + for segmentIndex, segment := range footprint.Boundaries { + if len(segment) == 0 { + return fmt.Errorf("geojson: solar %s footprint %d boundary %d is empty", name, footprintIndex, segmentIndex) + } + for pointIndex, point := range segment { + if err := validateSolarPathPoint(fmt.Sprintf( + "solar %s footprint %d boundary %d point %d", name, footprintIndex, segmentIndex, pointIndex, + ), point); err != nil { + return err + } + if !point.Time.Equal(footprint.Time) { + return fmt.Errorf("geojson: solar %s footprint point time must match its footprint", name) + } + } + } + previous = footprint.Time + } + return nil +} + +func validateSolarRiseSetCurves( + curves []eclipsecore.SolarEclipseRiseSetCurve, + start, end time.Time, +) error { + seen := make(map[[2]string]bool, len(curves)) + for curveIndex, curve := range curves { + if curve.Phase != eclipsecore.RiseSetPhaseStart && + curve.Phase != eclipsecore.RiseSetPhaseGreatest && curve.Phase != eclipsecore.RiseSetPhaseEnd { + return fmt.Errorf("geojson: solar rise/set curve %d has unsupported phase %q", curveIndex, curve.Phase) + } + if curve.Direction != eclipsecore.RiseSetDirectionRise && curve.Direction != eclipsecore.RiseSetDirectionSet { + return fmt.Errorf("geojson: solar rise/set curve %d has unsupported direction %q", curveIndex, curve.Direction) + } + key := [2]string{string(curve.Phase), string(curve.Direction)} + if seen[key] { + return fmt.Errorf("geojson: solar rise/set curve %d duplicates phase %q and direction %q", curveIndex, curve.Phase, curve.Direction) + } + seen[key] = true + if len(curve.Segments) == 0 { + return fmt.Errorf("geojson: solar rise/set curve %d has no segments", curveIndex) + } + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + return fmt.Errorf("geojson: solar rise/set curve %d segment %d requires at least two points", curveIndex, segmentIndex) + } + previous := time.Time{} + for pointIndex, point := range segment { + if err := validateSolarPathPoint(fmt.Sprintf( + "solar rise/set curve %d segment %d point %d", curveIndex, segmentIndex, pointIndex, + ), point); err != nil { + return err + } + if !solarEclipseTimeInsideInterval(point.Time, start, end) { + return fmt.Errorf("geojson: solar rise/set curve point is outside the partial interval") + } + if !previous.IsZero() && !point.Time.After(previous) { + return fmt.Errorf("geojson: solar rise/set curve segment times must be strictly increasing") + } + previous = point.Time + } + } + } + return nil +} + +func solarEclipseTimeInsideInterval(value, start, end time.Time) bool { + return !value.Before(start.Add(-solarEclipseValidationTimeTolerance)) && + !value.After(end.Add(solarEclipseValidationTimeTolerance)) +} + +func validateSolarMagnitudeContourSeries( + name string, + points []eclipsecore.SolarEclipsePathPoint, + required bool, +) error { + if required && len(points) < 2 { + return fmt.Errorf("geojson: %s requires at least two points", name) + } + for index, point := range points { + if err := validateSolarPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil { + return err + } + } + return nil +} + +func validateSolarGreatestTimeContours( + contours []eclipsecore.SolarEclipseGreatestTimeContour, +) error { + for index, contour := range contours { + if !finiteGeoJSON(contour.JDE) || contour.JDE == 0 { + return fmt.Errorf("geojson: solar greatest-time contour %d JDE must be finite and non-zero", index) + } + if contour.Time.IsZero() { + return fmt.Errorf("geojson: solar greatest-time contour %d time is required", index) + } + if len(contour.Segments) == 0 { + return fmt.Errorf("geojson: solar greatest-time contour %d has no branches", index) + } + for segmentIndex, segment := range contour.Segments { + if err := validateSolarMagnitudeContourSeries( + fmt.Sprintf("solar greatest-time contour %d branch %d", index, segmentIndex), segment, true, + ); err != nil { + return err + } + } + } + return nil +} + func validateSolarPathSeries(name string, points []eclipsecore.SolarEclipsePathPoint, required bool) error { if required && len(points) < 2 { return fmt.Errorf("geojson: %s requires at least two points", name) @@ -187,8 +436,10 @@ func validateLunarEclipseInfo(info eclipsecore.LunarEclipseInfo) error { } const ( - solarEclipseEvent = "solar-eclipse" - lunarEclipseEvent = "lunar-eclipse" + solarEclipseEvent = "solar-eclipse" + lunarEclipseEvent = "lunar-eclipse" + solarEclipseValidationTimeTolerance = 3 * time.Minute + solarEclipseCentralBandMinimumBoundaryPoints = 90 defaultLunarBoundaryPoints = 360 minimumLunarBoundaryPoints = 12 @@ -235,15 +486,32 @@ func marshalSolarEclipse( "eclipse_type": string(partial.Eclipse.Type), "model": string(partial.Eclipse.Model), } - features := make([]feature, 0, len(partial.Footprints)+8) + features := make([]feature, 0, len(partial.Footprints)+9) for _, footprint := range partial.Footprints { - polygon, err := solarPartialFootprintPolygon(footprint) + polygon, err := solarPartialFootprintPolygon(footprint, true) if err != nil { return nil, err } + curve, err := solarShadowFootprintCurveFromSegments(footprint.Boundaries) + if err != nil { + return nil, err + } + if solarShadowRegionDegenerate(curve, polygon) { + // 与单时刻导出同口径:退化区域整条缺省,不退化成点或零面积环。 + continue + } footprintProperties := cloneProperties(properties) footprintProperties["time"] = formatTime(footprint.Time) footprintProperties["source_boundary_closed"] = footprint.Closed + footprintProperties["interp_signature"] = solarShadowFootprintSignature( + footprint.Boundaries, footprint.Closed, eclipsecore.SolarEclipseShadowPenumbra, + ) + if !footprint.Closed { + footprintProperties["geometry_role"] = "horizon-closed-region" + footprintProperties["closure"] = solarHorizonClosureProperties( + footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds), + ) + } if len(polygon) == 1 { value, pointErr := pointGeometry(polygon[0].Longitude, polygon[0].Latitude) if pointErr != nil { @@ -254,7 +522,7 @@ func marshalSolarEclipse( )) continue } - value, err := multiPolygonGeometry([][]geodata.GeoPoint{polygon}) + value, err := multiPolygonFillGeometry([][]geodata.GeoPoint{polygon}) if err != nil { return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), err) } @@ -263,32 +531,265 @@ func marshalSolarEclipse( )) } + if value, source, ok, err := solarPartialBandGeometry(partial); err != nil { + return nil, fmt.Errorf("geojson: solar partial band: %w", err) + } else if ok { + bandProperties := cloneProperties(properties) + bandProperties["source"] = source + features = append(features, newFeature( + solarEclipseEvent, "partial-band", value, bandProperties, + )) + } + + var err error + features, err = appendSolarRiseSetCurveFeatures(features, partial.RiseSetCurves, properties) + if err != nil { + return nil, err + } + features, err = appendSolarFootprintFeatures( + features, "central-shadow-footprint", partial.CentralShadowFootprints, properties, + ) + if err != nil { + return nil, err + } + bandFootprints := solarCentralBandFootprints(partial) + // Keep the densest shadow footprints selected by solarCentralBandFootprints. + // The lightweight companion is sufficient for ordinary closed envelopes, but + // polar two-limit fallback needs the exact U1/U4 endpoint sweep. + if central == nil && len(bandFootprints) > 0 { + band, bandSource, bandErr := solarCentralBandEnvelopeGeometry(partial.CentralBandSegments) + if bandErr != nil { + band, bandErr = solarCentralShadowSweepGeometry(bandFootprints) + bandSource = "central-shadow-sweep" + if bandErr != nil && len(partial.CentralBandFootprints) > 0 && + !sameSolarFootprintSlice(bandFootprints, partial.CentralBandFootprints) { + band, bandErr = solarCentralShadowSweepGeometry(partial.CentralBandFootprints) + } + } + if bandErr != nil { + return nil, fmt.Errorf("geojson: solar central band: %w", bandErr) + } + bandProperties := cloneProperties(properties) + bandProperties["centrality"] = string(partial.Eclipse.Centrality) + bandProperties["source"] = bandSource + features = append(features, newFeature( + solarEclipseEvent, "central-band", band, bandProperties, + )) + } + for _, contour := range partial.MagnitudeContours { + if len(contour.Segments) > 0 { + contourProperties := cloneProperties(properties) + contourProperties["magnitude"] = contour.Magnitude + features, err = appendSolarSegmentedPathLine( + features, "magnitude-line", contour.Segments, contourProperties, false, + ) + if err != nil { + return nil, err + } + continue + } + for _, side := range []struct { + name string + points []eclipsecore.SolarEclipsePathPoint + }{ + {name: "north", points: contour.NorthernLimit}, + {name: "south", points: contour.SouthernLimit}, + } { + contourProperties := cloneProperties(properties) + contourProperties["magnitude"] = contour.Magnitude + contourProperties["side"] = side.name + features, err = appendSolarPathLine( + features, "magnitude-line", side.points, contourProperties, + ) + if err != nil { + return nil, err + } + } + } + for _, contour := range partial.GreatestTimeContours { + for _, segment := range contour.Segments { + contourProperties := cloneProperties(properties) + contourProperties["time"] = formatTime(contour.Time) + contourProperties["jde"] = contour.JDE + // 支路各点同为该时刻,逐点时间不是递增序列。 + features, err = appendSolarSegmentedPathLine( + features, "greatest-time-line", [][]eclipsecore.SolarEclipsePathPoint{segment}, + contourProperties, false, + ) + if err != nil { + return nil, err + } + } + } + if central != nil { + // bandFootprints is the presentation subset the ribbon and the closed + // envelopes are validated against; one-limit events trim the U1/U4 tails + // out of it. sweepFootprints keeps the complete umbral sweep, because a + // grazing one-limit path really does extend over that whole interval + // (NASA's path table lists its limits from U1 to U4), so a band built or + // validated only against the trimmed subset silently loses the flared + // ends of the real annular/total region. + bandFootprints = solarCentralBandFootprintsForPath(partial, central) + sweepFootprints := solarCentralBandFootprints(partial) + // The exported limit lines are trimmed to the center-line interval for + // ordinary maps, but the static band must be built from the complete + // U1/U4 paired limits: for a shallow two-limit event the axis interval is + // a fraction of the umbral window, and a band built from the trimmed + // limits drops hundreds of kilometres of real annular area. + presentationNorthernLimit := central.NorthernLimit + presentationSouthernLimit := central.SouthernLimit + if partial.Eclipse.Centrality == eclipsecore.SolarEclipseCentralTwoLimits { + if north, south, ok := solarCentralTwoLimitPresentationLimits( + central.NorthernLimit, central.SouthernLimit, central.CenterLine, + ); ok { + presentationNorthernLimit, presentationSouthernLimit = north, south + } + } + bandNorthernLimit := central.NorthernLimit + bandSouthernLimit := central.SouthernLimit + // A grazing band is not bounded by the instantaneous cross-section + // limits: those stop describing the region and can sit hundreds of + // kilometres inside it (1136-06-01: 456 km for the northern limit). + // Whenever the analytic limits no longer follow the band boundary, the + // exported lines are taken from the band ring itself, so the dashed + // limits and the filled band describe the same region. + var derivedNorthernLimit, derivedSouthernLimit []eclipsecore.SolarEclipsePathPoint if len(central.NorthernLimit) > 0 { - band, err := pairedLimitPolygon(central.NorthernLimit, central.SouthernLimit) + var value geometry + var source string + var usedMagnitudeOne bool + centralEnvelope := partial.CentralBandSegments + if len(central.CentralBandSegments) > 0 { + centralEnvelope = central.CentralBandSegments + } + useCriticalEnvelope := len(centralEnvelope) > 0 + // Check the shadow axis, not the instantaneous cross-section limits: + // near the horizon those samples can have their local greatest below + // the horizon and need not belong to the visible central band. + coveragePath := *central + coveragePath.NorthernLimit = presentationNorthernLimit + coveragePath.SouthernLimit = presentationSouthernLimit + if useCriticalEnvelope && !solarCentralBandEnvelopeCoversPath( + centralEnvelope, &coveragePath, + ) { + useCriticalEnvelope = false + } + if useCriticalEnvelope && !solarCentralBandEnvelopeCoversFootprints( + centralEnvelope, bandFootprints, + ) { + useCriticalEnvelope = false + } + if useCriticalEnvelope { + value, source, err = solarCentralBandEnvelopeGeometry(centralEnvelope) + if partial.Eclipse.Type == eclipsecore.SolarEclipseTotal { + source = "magnitude-one-envelope" + } + } else { + value, source, usedMagnitudeOne, err = solarCentralMagnitudeOneBandGeometry( + partial.Eclipse.Type, + partial.MagnitudeContours, + central.CenterLine, + partial.CentralBandHorizonClosures, + ) + } + if !useCriticalEnvelope && !usedMagnitudeOne { + value, source, err = solarCentralBandGeometry( + bandNorthernLimit, + bandSouthernLimit, + central.CenterLine, + partial.Eclipse.Type, + partial.Eclipse.Centrality, + sweepFootprints, + partial.CentralBandHorizonClosures, + ) + } + if err != nil && len(partial.CentralBandFootprints) > 0 && + !sameSolarFootprintSlice(sweepFootprints, partial.CentralBandFootprints) { + // A caller may request dense central-shadow samples. Near + // grazing contacts, the planar sweep can become numerically + // open; the always-available end-cap samples provide a stable + // equivalent band without rejecting the whole export. + value, source, err = solarCentralBandGeometry( + bandNorthernLimit, + bandSouthernLimit, + central.CenterLine, + partial.Eclipse.Type, + partial.Eclipse.Centrality, + partial.CentralBandFootprints, + partial.CentralBandHorizonClosures, + ) + } if err != nil { return nil, fmt.Errorf("geojson: solar central band: %w", err) } - value, err := multiPolygonGeometry([][]geodata.GeoPoint{band}) - if err != nil { - return nil, fmt.Errorf("geojson: solar central band: %w", err) + // Only a band rebuilt from sampled footprints carries the sampling + // ripple the snap removes; an analytic envelope is already the exact + // boundary and must keep its own end caps. + if partial.CentralBandSampled || central.CentralBandSampled { + value = snapSolarBandGeometryToHorizonCurves(value, partial.RiseSetCurves) } + bandProperties := cloneProperties(properties) + bandProperties["source"] = source features = append(features, newFeature( - solarEclipseEvent, "central-band", value, cloneProperties(properties), + solarEclipseEvent, "central-band", value, bandProperties, + )) + } else if len(sweepFootprints) > 0 { + // A one-limit event may publish no paired limits at all. Fall back to + // the complete umbral sweep and make sure the exported band still + // contains its own center line. + polygons, sweepErr := solarCentralShadowSweepPolygons(sweepFootprints) + if sweepErr != nil { + return nil, fmt.Errorf("geojson: solar central band: %w", sweepErr) + } + value, geometryErr := multiPolygonGeometry( + solarCentralBandWithCenterlineCorridor(polygons, central.CenterLine), + ) + if geometryErr != nil { + return nil, fmt.Errorf("geojson: solar central band: %w", geometryErr) + } + bandProperties := cloneProperties(properties) + bandProperties["source"] = "central-shadow-sweep" + features = append(features, newFeature( + solarEclipseEvent, "central-band", value, bandProperties, )) } - var err error + // Derive the exported limits from whichever band was built above: a + // grazing band is not bounded by the instantaneous cross-section limits, + // which stop describing the region and can sit hundreds of kilometres + // inside it (1136-06-01: 456 km for the northern limit). A band split at + // the antimeridian is rejoined first; when its fragments do not pair up, + // each ring is cut into runs that stay on one side of the center line. + if bandGeometry, ok := solarEclipseBandGeometry(features); ok && len(central.CenterLine) >= 2 { + geometryRings := solarBandGeometryRings(bandGeometry) + stitched := stitchSolarBandRings(geometryRings) + // 单环先接缝再切侧;两条分支共用同一逐点投影侧判据,标签不会互相矛盾。 + if len(stitched) == 1 { + geometryRings = stitched + } + north, south, derived := solarCentralBandLimitSidesFromRings(geometryRings, central.CenterLine) + if derived && (solarCentralLimitSeparationKM( + presentationNorthernLimit, north, + ) > solarCentralBandLimitSidesSplitKM || solarCentralLimitSeparationKM( + presentationSouthernLimit, south, + ) > solarCentralBandLimitSidesSplitKM) { + derivedNorthernLimit, derivedSouthernLimit = north, south + } + } + if len(derivedNorthernLimit) > 0 { + presentationNorthernLimit, presentationSouthernLimit = derivedNorthernLimit, derivedSouthernLimit + } features, err = appendSolarPathLine(features, "center-line", central.CenterLine, properties) if err != nil { return nil, err } - if len(central.NorthernLimit) > 0 { - features, err = appendSolarPathLine(features, "north-limit", central.NorthernLimit, properties) + if len(presentationNorthernLimit) > 0 { + features, err = appendSolarPathLine(features, "north-limit", presentationNorthernLimit, properties) if err != nil { return nil, err } - features, err = appendSolarPathLine(features, "south-limit", central.SouthernLimit, properties) + features, err = appendSolarPathLine(features, "south-limit", presentationSouthernLimit, properties) if err != nil { return nil, err } @@ -317,8 +818,15 @@ func marshalSolarEclipse( greatest = solarPathSample(central.Greatest) greatestProperties["width_km"] = central.Greatest.WidthKM greatestProperties["sun_altitude_deg"] = central.Greatest.SunAltitude + if central.MaxCentralDuration > 0 { + // The longest central phase anywhere on the track, which for a + // shallow event exceeds the value at greatest eclipse. + greatestProperties["max_central_duration_seconds"] = central.MaxCentralDuration.Seconds() + greatestProperties["max_central_duration"] = central.MaxCentralDuration.String() + greatestProperties["max_central_duration_longitude"] = central.MaxCentralDurationLongitude + greatestProperties["max_central_duration_latitude"] = central.MaxCentralDurationLatitude + } } - var err error features, err = appendPointFeature( features, solarEclipseEvent, "greatest", greatest, greatestProperties, ) @@ -328,8 +836,895 @@ func marshalSolarEclipse( return marshalFeatureCollection(features) } -// MarshalLunarEclipse 将月食 P1/P4 可见半球和地平线边界编码为 GeoJSON。 -// MarshalLunarEclipse encodes the P1/P4 visible hemispheres and horizon boundaries as GeoJSON. +// solarCentralBandSeamEpsilonKM is the seam tolerance used when rejoining the +// fragments the antimeridian split left in one band boundary. +const solarCentralBandSeamEpsilonKM = 0.5 + +// solarCentralBandRunConnectKM is the gap below which two boundary runs are +// treated as consecutive pieces of one limit line. +const solarCentralBandRunConnectKM = 25.0 + +// stitchSolarBandRings rejoins the fragments an antimeridian split produced, so +// the northern and southern sides can be derived from a single loop. Each +// fragment carries meridian edges at the seam; dropping them leaves open chains +// whose endpoints are rejoined at matching latitudes. +func stitchSolarBandRings( + rings [][]eclipsecore.SolarEclipsePathPoint, +) [][]eclipsecore.SolarEclipsePathPoint { + if len(rings) < 2 { + return rings + } + type bandChain struct { + points []eclipsecore.SolarEclipsePathPoint + } + var chains []bandChain + for _, ring := range rings { + points := openSolarPathRing(ring) + count := len(points) + if count < 3 || !solarBandRingTouchesSeam(points) { + chains = append(chains, bandChain{points: points}) + continue + } + seam := make([]bool, count) + start := -1 + for index := 0; index < count; index++ { + first, second := points[index], points[(index+1)%count] + seam[index] = solarBandSeamLongitude(first.Longitude) && + solarBandSeamLongitude(second.Longitude) && + math.Abs(first.Latitude-second.Latitude) > 1e-9 + if seam[index] && start < 0 { + start = (index + 1) % count + } + } + if start < 0 { + chains = append(chains, bandChain{points: points}) + continue + } + current := make([]eclipsecore.SolarEclipsePathPoint, 0, count) + for step := 0; step < count; step++ { + index := (start + step) % count + current = append(current, points[index]) + if seam[index] { + chains = append(chains, bandChain{points: current}) + current = make([]eclipsecore.SolarEclipsePathPoint, 0, count) + } + } + if len(current) > 0 { + chains = append(chains, bandChain{points: current}) + } + } + used := make([]bool, len(chains)) + merged := make([][]eclipsecore.SolarEclipsePathPoint, 0, len(chains)) + for index := range chains { + if used[index] { + continue + } + used[index] = true + current := chains[index].points + for { + joined := false + for next := range chains { + if used[next] { + continue + } + if value, ok := joinSolarBandChains(current, chains[next].points); ok { + current = value + used[next] = true + joined = true + break + } + } + if !joined { + break + } + } + if len(current) >= 3 { + merged = append(merged, current) + } + } + // Close every rejoined loop so downstream code sees whole rings again. + for index, ring := range merged { + if len(ring) > 1 && !solarBandPointsCoincide(ring[0], ring[len(ring)-1]) { + merged[index] = append(ring, ring[0]) + } + } + return merged +} + +// solarBandRingTouchesSeam reports whether any vertex sits on the antimeridian. +func solarBandRingTouchesSeam(points []eclipsecore.SolarEclipsePathPoint) bool { + for _, point := range points { + if solarBandSeamLongitude(point.Longitude) { + return true + } + } + return false +} + +// solarBandSeamLongitude reports whether one longitude lies on the export seam. +func solarBandSeamLongitude(longitude float64) bool { + return math.Abs(math.Abs(longitude)-180) <= 1e-6 +} + +// solarBandPointsCoincide compares two path points, wrapping longitudes. +func solarBandPointsCoincide(first, second eclipsecore.SolarEclipsePathPoint) bool { + if math.Abs(first.Latitude-second.Latitude) > 1e-9 { + return false + } + delta := math.Abs(math.Remainder(first.Longitude-second.Longitude, 360)) + return delta <= 1e-9 || math.Abs(delta-360) <= 1e-9 +} + +// joinSolarBandChains appends one open chain to another when their seam +// endpoints describe the same latitude on opposite sides of the antimeridian. +func joinSolarBandChains( + first, second []eclipsecore.SolarEclipsePathPoint, +) ([]eclipsecore.SolarEclipsePathPoint, bool) { + if len(first) == 0 || len(second) == 0 { + return nil, false + } + reversed := make([]eclipsecore.SolarEclipsePathPoint, len(second)) + for index := range second { + reversed[index] = second[len(second)-1-index] + } + switch { + case solarBandSeamMatch(first[len(first)-1], second[0]): + return append(append([]eclipsecore.SolarEclipsePathPoint{}, first...), second[1:]...), true + case solarBandSeamMatch(first[len(first)-1], second[len(second)-1]): + return append(append([]eclipsecore.SolarEclipsePathPoint{}, first...), reversed[1:]...), true + case solarBandSeamMatch(first[0], second[len(second)-1]): + return append(append([]eclipsecore.SolarEclipsePathPoint{}, second...), first[1:]...), true + case solarBandSeamMatch(first[0], second[0]): + return append(append([]eclipsecore.SolarEclipsePathPoint{}, reversed...), first[1:]...), true + } + return nil, false +} + +// solarBandSeamMatch reports whether two chain ends meet across the seam. +func solarBandSeamMatch(first, second eclipsecore.SolarEclipsePathPoint) bool { + if math.Abs(first.Latitude-second.Latitude) > 1e-6 { + return false + } + delta := math.Abs(math.Abs(first.Longitude) - math.Abs(second.Longitude)) + if delta > 1e-6 { + return false + } + // Opposite sides of the seam, or the very same meridian point. + return math.Signbit(first.Longitude) != math.Signbit(second.Longitude) || + math.Abs(first.Longitude-second.Longitude) <= 1e-6 +} + +// solarCentralBandLimitSidesSplitKM is how far the analytic limits may sit from +// the band boundary before the export replaces them with the band's own sides. +// Ordinary events agree to a few kilometres; a grazing band is hundreds of +// kilometres away, because there the instantaneous cross-section limits stop +// describing the boundary of the region at all. +const solarCentralBandLimitSidesSplitKM = 25.0 + +// solarBandSideRun is one boundary stretch that stays on a single side of the +// center line, with the projected position and time of each of its vertices. +type solarBandSideRun struct { + points []eclipsecore.SolarEclipsePathPoint + times []time.Time + progress []float64 + north bool +} + +// solarCentralBandLimitSidesFromRings 由食带边界派生南北限:逐点投影定侧,同侧最长连通段按路径序拼接。 +func solarCentralBandLimitSidesFromRings( + rings [][]eclipsecore.SolarEclipsePathPoint, + centerLine []eclipsecore.SolarEclipsePathPoint, +) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint, bool) { + if len(centerLine) < 2 { + return nil, nil, false + } + runs, ok := solarBandSideRuns(rings, centerLine) + if !ok || len(runs) == 0 { + return nil, nil, false + } + northern := solarBandSidePoints(runs, true) + southern := solarBandSidePoints(runs, false) + if len(northern) < 3 || len(southern) < 3 { + return nil, nil, false + } + return northern, southern, true +} + +// solarBandSideRuns cuts every ring into runs that keep one side of the center +// line. A run ends where the boundary crosses the center line, jumps across the +// seam, or stalls against the projection. +func solarBandSideRuns( + rings [][]eclipsecore.SolarEclipsePathPoint, + centerLine []eclipsecore.SolarEclipsePathPoint, +) ([]solarBandSideRun, bool) { + var runs []solarBandSideRun + for _, ring := range rings { + points := openSolarPathRing(ring) + if len(points) < 3 { + continue + } + progress := make([]float64, len(points)) + times := make([]time.Time, len(points)) + north := make([]bool, len(points)) + for index, point := range points { + value, stamp, isNorth, ok := solarBandProjectOnCenterLine(point, centerLine) + if !ok { + return nil, false + } + progress[index] = value + times[index] = stamp + north[index] = isNorth + } + current := solarBandSideRun{} + flush := func() { + if len(current.points) >= 3 { + runs = append(runs, current) + } + current = solarBandSideRun{} + } + for index := range points { + next := (index + 1) % len(points) + current.points = append(current.points, points[index]) + current.times = append(current.times, times[index]) + current.progress = append(current.progress, progress[index]) + current.north = north[index] + seamJump := math.Abs(math.Remainder(points[next].Longitude-points[index].Longitude, 360)) > 180 + stalled := math.Abs(progress[next]-progress[index]) > 3 + if north[index] != north[next] || seamJump || stalled { + flush() + } + } + flush() + } + return runs, true +} + +// solarBandSidePoints concatenates the runs of one side in path order and +// spreads their times evenly, because the export requires strictly increasing +// times. Runs that do not touch each other belong to different boundary +// fragments (the union leaves small islands behind); concatenating them would +// draw a limit line straight across the map, so only the longest connected +// group is kept. +func solarBandSidePoints(runs []solarBandSideRun, north bool) []eclipsecore.SolarEclipsePathPoint { + chosen := make([]solarBandSideRun, 0, len(runs)) + for _, run := range runs { + if run.north == north { + chosen = append(chosen, run) + } + } + if len(chosen) == 0 { + return nil + } + sort.SliceStable(chosen, func(first, second int) bool { + return meanProgress(chosen[first].progress) < meanProgress(chosen[second].progress) + }) + var groups [][]solarBandSideRun + for _, run := range chosen { + if len(groups) > 0 { + last := groups[len(groups)-1] + if solarBandRunsConnect(last[len(last)-1].points, run.points) { + groups[len(groups)-1] = append(last, run) + continue + } + } + groups = append(groups, []solarBandSideRun{run}) + } + countPoints := func(group []solarBandSideRun) int { + total := 0 + for _, run := range group { + total += len(run.points) + } + return total + } + best := groups[0] + for _, group := range groups[1:] { + if countPoints(group) > countPoints(best) { + best = group + } + } + side := make([]eclipsecore.SolarEclipsePathPoint, 0, countPoints(best)) + for _, run := range best { + for index, point := range run.points { + if index < len(run.times) { + point.Time = run.times[index] + } + side = append(side, point) + } + } + if len(side) < 3 { + return nil + } + if !side[len(side)-1].Time.After(side[0].Time) { + for left, right := 0, len(side)-1; left < right; left, right = left+1, right-1 { + side[left], side[right] = side[right], side[left] + } + } + return enforceSolarBandSideTimes(side) +} + +// enforceSolarBandSideTimes 保留逐点投影时间,只把投影时间回退的顶点抬到前一点之后。 +func enforceSolarBandSideTimes(side []eclipsecore.SolarEclipsePathPoint) []eclipsecore.SolarEclipsePathPoint { + if len(side) < 2 || !side[len(side)-1].Time.After(side[0].Time) { + return nil + } + for index := 1; index < len(side); index++ { + if !side[index].Time.After(side[index-1].Time) { + side[index].Time = side[index-1].Time.Add(time.Millisecond) + } + } + return side +} + +// solarBandRunsConnect reports whether two runs share an endpoint, wrapping +// longitudes so a seam crossing still counts as connected. +func solarBandRunsConnect(first, second []eclipsecore.SolarEclipsePathPoint) bool { + if len(first) == 0 || len(second) == 0 { + return false + } + scale := math.Cos(first[len(first)-1].Latitude * math.Pi / 180) + deltaLongitude := math.Remainder(first[len(first)-1].Longitude-second[0].Longitude, 360) * scale + deltaLatitude := first[len(first)-1].Latitude - second[0].Latitude + return 111.32*math.Hypot(deltaLongitude, deltaLatitude) <= solarCentralBandRunConnectKM +} + +// meanProgress averages the projected positions of one run. +func meanProgress(values []float64) float64 { + if len(values) == 0 { + return 0 + } + total := 0.0 + for _, value := range values { + total += value + } + return total / float64(len(values)) +} + +// solarBandProjectOnCenterLine projects one band point onto the center line and +// reports its position along the path, the matching time, and whether it falls +// north of the center line at that position. +func solarBandProjectOnCenterLine( + point eclipsecore.SolarEclipsePathPoint, + centerLine []eclipsecore.SolarEclipsePathPoint, +) (float64, time.Time, bool, bool) { + bestDistance := math.Inf(1) + bestProgress := 0.0 + bestTime := centerLine[0].Time + bestLatitude := centerLine[0].Latitude + scale := math.Cos(point.Latitude * math.Pi / 180) + for position := 0; position+1 < len(centerLine); position++ { + first, second := centerLine[position], centerLine[position+1] + ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale + ay := first.Latitude - point.Latitude + bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale + by := second.Latitude - point.Latitude + dx, dy := bx-ax, by-ay + length := dx*dx + dy*dy + fraction := 0.0 + if length > 0 { + fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length)) + } + distance := math.Hypot(ax+fraction*dx, ay+fraction*dy) + if distance >= bestDistance { + continue + } + bestDistance = distance + bestProgress = float64(position) + fraction + bestTime = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction)) + bestLatitude = first.Latitude + fraction*(second.Latitude-first.Latitude) + } + if math.IsInf(bestDistance, 1) { + return 0, time.Time{}, false, false + } + return bestProgress, bestTime, point.Latitude >= bestLatitude, true +} + +// solarCentralLimitSeparationKM returns the greatest distance from one exported +// limit curve to the matching side of the band. +func solarCentralLimitSeparationKM( + line []eclipsecore.SolarEclipsePathPoint, + side []eclipsecore.SolarEclipsePathPoint, +) float64 { + if len(line) < 2 || len(side) < 2 { + return math.Inf(1) + } + maximum := 0.0 + for _, point := range line { + best := math.Inf(1) + for index := 0; index+1 < len(side); index++ { + best = math.Min(best, solarCentralBandPointSegmentKM(point, side[index], side[index+1])) + } + maximum = math.Max(maximum, best) + } + return maximum +} + +// solarCentralBandPointSegmentKM is the distance from a point to one great-circle +// segment, evaluated on a local equirectangular chart. +func solarCentralBandPointSegmentKM( + point, first, second eclipsecore.SolarEclipsePathPoint, +) float64 { + scale := math.Cos(point.Latitude * math.Pi / 180) + // 经度差必须先归约到 ±180°:跨换日线的限线用裸差值会得到数万公里的假距离 + // (同一文件其它点-段投影都先做 math.Remainder)。 + // Longitude differences must be wrapped to ±180°: a limit line crossing the + // antimeridian otherwise measures tens of thousands of kilometres away, while every + // other point-to-segment projection in this file wraps first. + ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale + ay := first.Latitude - point.Latitude + bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale + by := second.Latitude - point.Latitude + dx, dy := bx-ax, by-ay + length := dx*dx + dy*dy + fraction := 0.0 + if length > 0 { + fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length)) + } + return 111.32 * math.Hypot(ax+fraction*dx, ay+fraction*dy) +} + +// solarCentralBandSnapToleranceKM is how close an exported band vertex must be +// to a greatest-at-horizon curve before it is moved onto it. A grazing band is +// rebuilt from sampled footprints, so its horizon-bounded edge carries a few +// kilometres of sampling ripple; the curve itself is the exact boundary there, +// and the map draws both, so the ripple reads as two lines weaving instead of +// one boundary. +const solarCentralBandSnapToleranceKM = 25.0 + +// snapSolarBandGeometryToHorizonCurves replaces the band boundary runs that +// already follow a greatest-at-horizon curve with that curve's own vertices, so +// the filled band and the exported visibility line share one boundary. Runs are +// only replaced while their projection onto the curve stays monotone, which +// keeps the substitution from folding the ring; every other edge (the +// shadow-bounded parts) is left untouched. +func snapSolarBandGeometryToHorizonCurves( + value geometry, + curves []eclipsecore.SolarEclipseRiseSetCurve, +) geometry { + polygons, ok := value.Coordinates.([][][][]float64) + if !ok || len(polygons) == 0 { + return value + } + paths := make([][]eclipsecore.SolarEclipsePathPoint, 0, 2) + for _, curve := range curves { + if curve.Phase != eclipsecore.RiseSetPhaseGreatest { + continue + } + for _, segment := range curve.Segments { + if len(segment) >= 2 { + paths = append(paths, segment) + } + } + } + if len(paths) == 0 { + return value + } + snapped := make([][][][]float64, len(polygons)) + for polygonIndex, polygon := range polygons { + snapped[polygonIndex] = make([][][]float64, len(polygon)) + for ringIndex, ring := range polygon { + snapped[polygonIndex][ringIndex] = snapSolarBandRingToHorizonPaths(ring, paths) + } + } + return geometry{Type: value.Type, Coordinates: snapped} +} + +// solarBandProjection is the closest point of one greatest-at-horizon path to a +// band vertex, with the parameter that locates it along that path. +type solarBandProjection struct { + pathIndex int + parameter float64 + longitude float64 + latitude float64 + distance float64 +} + +func solarBandProjectionAt( + longitude, latitude float64, + paths [][]eclipsecore.SolarEclipsePathPoint, +) (solarBandProjection, bool) { + best := solarBandProjection{distance: solarCentralBandSnapToleranceKM} + found := false + scale := math.Cos(latitude * math.Pi / 180) + for pathIndex, path := range paths { + for index := 0; index+1 < len(path); index++ { + first, second := path[index], path[index+1] + ax := math.Remainder(first.Longitude-longitude, 360) * scale + ay := first.Latitude - latitude + bx := math.Remainder(second.Longitude-longitude, 360) * scale + by := second.Latitude - latitude + dx, dy := bx-ax, by-ay + length := dx*dx + dy*dy + fraction := 0.0 + if length > 0 { + fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length)) + } + distance := 111.32 * math.Hypot(ax+fraction*dx, ay+fraction*dy) + if distance >= best.distance { + continue + } + candidate := longitude + (ax+fraction*dx)/scale + if candidate < -180 || candidate > 180 { + // A projection that leaves the export window would have to be + // wrapped, which moves the vertex across the seam. Keep the + // sampled position instead of rewriting the fragment topology. + continue + } + best = solarBandProjection{ + pathIndex: pathIndex, + parameter: float64(index) + fraction, + longitude: candidate, + latitude: latitude + (ay + fraction*dy), + distance: distance, + } + found = true + } + } + return best, found +} + +// snapSolarBandRingToHorizonPaths substitutes the monotone runs of one ring. +func snapSolarBandRingToHorizonPaths( + ring [][]float64, + paths [][]eclipsecore.SolarEclipsePathPoint, +) [][]float64 { + if len(ring) < 4 { + return ring + } + type projected struct { + point []float64 + projection solarBandProjection + matched bool + } + points := make([]projected, len(ring)) + for index, point := range ring { + points[index] = projected{point: point} + if len(point) < 2 { + continue + } + if projection, ok := solarBandProjectionAt(point[0], point[1], paths); ok { + points[index] = projected{ + point: []float64{projection.longitude, projection.latitude}, + projection: projection, matched: true, + } + } + } + result := make([][]float64, 0, len(ring)) + for index := 0; index < len(points); { + if !points[index].matched { + result = append(result, points[index].point) + index++ + continue + } + end := index + for end+1 < len(points) && points[end+1].matched && + points[end+1].projection.pathIndex == points[index].projection.pathIndex && + points[end+1].projection.parameter > points[end].projection.parameter { + end++ + } + if end == index { + result = append(result, points[index].point) + index++ + continue + } + path := paths[points[index].projection.pathIndex] + startParameter := points[index].projection.parameter + endParameter := points[end].projection.parameter + result = append(result, []float64{points[index].projection.longitude, points[index].projection.latitude}) + for position := int(math.Ceil(startParameter)); position < len(path); position++ { + if float64(position) <= startParameter { + continue + } + if float64(position) >= endParameter { + break + } + result = append(result, []float64{path[position].Longitude, path[position].Latitude}) + } + result = append(result, []float64{points[end].projection.longitude, points[end].projection.latitude}) + index = end + 1 + } + if len(result) > 1 { + result[len(result)-1] = result[0] + } + if len(result) < 4 { + return ring + } + return result +} + +// solarEclipseBandGeometry returns the geometry of the exported central band. +func solarEclipseBandGeometry(features []feature) (geometry, bool) { + for index := len(features) - 1; index >= 0; index-- { + if features[index].Properties["role"] != "central-band" { + continue + } + return features[index].Geometry, true + } + return geometry{}, false +} + +// solarBandGeometryRings returns the outer rings of one exported band geometry +// as path points without times; the limit split re-times them from the center +// line, so any band construction can be split the same way. +func solarBandGeometryRings(value geometry) [][]eclipsecore.SolarEclipsePathPoint { + polygons, ok := value.Coordinates.([][][][]float64) + if !ok { + return nil + } + rings := make([][]eclipsecore.SolarEclipsePathPoint, 0, len(polygons)) + for _, polygon := range polygons { + if len(polygon) == 0 { + continue + } + ring := make([]eclipsecore.SolarEclipsePathPoint, 0, len(polygon[0])) + for _, position := range polygon[0] { + if len(position) < 2 { + continue + } + ring = append(ring, eclipsecore.SolarEclipsePathPoint{ + Longitude: position[0], Latitude: position[1], + }) + } + if len(ring) >= 3 { + rings = append(rings, ring) + } + } + return rings +} + +func solarCentralBandEnvelopeCoversFootprints( + segments [][]eclipsecore.SolarEclipsePathPoint, + footprints []eclipsecore.SolarEclipsePartialFootprint, +) bool { + if len(segments) == 0 || len(footprints) == 0 { + return true + } + polygons := make([][]geodata.GeoPoint, 0, len(segments)) + for _, segment := range segments { + polygon := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, polygon) + } + // Endpoint footprints are sampled independently from the analytic + // envelope; small numerical gaps are expected. Only a macroscopic miss + // indicates that the envelope selected the wrong polar branch. + points := make([]geodata.GeoPoint, 0, len(footprints)) + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + continue + } + converted, ok := solarCentralBandCoveragePoints(boundary) + if !ok { + return false + } + points = append(points, converted...) + } + } + if len(points) == 0 { + return true + } + return solarCentralBandPointsCover(polygons, points, solarCentralBandCoverageToleranceKM) +} + +func solarCentralBandEnvelopeCoversPath( + segments [][]eclipsecore.SolarEclipsePathPoint, + central *eclipsecore.SolarEclipsePath, +) bool { + if len(segments) == 0 || central == nil { + return false + } + // Away from the poles the gnomonic containment check is well conditioned; + // retain the critical envelope there to avoid changing ordinary output. + // Validate the same spherical path containment at every latitude. A + // latitude-based bypass hid ordinary grazing endpoint errors in addition + // to the polar cases it was originally meant to protect. + polygons := make([][]geodata.GeoPoint, 0, len(segments)) + for _, segment := range segments { + if len(openSolarPathRing(segment)) < 3 { + return false + } + polygon := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, polygon) + } + if len(central.CenterLine) < 2 { + return false + } + path := make([]geodata.GeoPoint, len(central.CenterLine)) + for index, point := range central.CenterLine { + path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + const maximumMissKM = 6.0 + miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, [][]geodata.GeoPoint{path}, false) + if miss > maximumMissKM { + // A narrow band can have a few-kilometre spherical edge sag at a + // closure. Larger misses still select the physical fallback geometry. + if miss > maximumMissKM { + return false + } + } + // The raw spherical ring can still lose a seam when converted to + // RFC-7946 fragments at the antimeridian. Validate the same fragments + // used by multiPolygonGeometry before accepting this envelope. + fragments := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + fragments = append(fragments, + geodata.PolygonFragments(sampleSphericalMapRing(polygon), geodata.ClipView{Projection: geodata.ProjectionEquirectangular})..., + ) + } + if len(fragments) == 0 || geodata.SphericalPolygonsPathMissDistanceKM(fragments, [][]geodata.GeoPoint{path}, false) > maximumMissKM { + return false + } + // The projected fragments above are the same RFC-7946 pieces used for + // export and already contain the path-containment check. A separate + // latitude-only seam heuristic rejects valid thin polar rings when the + // axis and boundary cross the antimeridian at different local curvatures. + return true +} + +func solarCentralBandAntimeridianSeamMatchesPath( + polygons [][]geodata.GeoPoint, path []geodata.GeoPoint, +) bool { + const maximumSeamLatitudeGap = 5.0 + for index := 1; index < len(path); index++ { + first, second := path[index-1], path[index] + if math.Abs(first.Longitude-second.Longitude) <= 180 { + continue + } + secondLongitude := second.Longitude + if secondLongitude < first.Longitude { + secondLongitude += 360 + } + firstLongitude := first.Longitude + if firstLongitude < second.Longitude { + firstLongitude += 360 + } + fraction := (180 - firstLongitude) / (secondLongitude - firstLongitude) + if fraction < 0 || fraction > 1 { + fraction = (-180 - firstLongitude) / (secondLongitude - firstLongitude) + } + seamLatitude := first.Latitude + fraction*(second.Latitude-first.Latitude) + bestGap := math.Inf(1) + for _, polygon := range polygons { + for pointIndex := 1; pointIndex < len(polygon); pointIndex++ { + firstPoint, secondPoint := polygon[pointIndex-1], polygon[pointIndex] + if math.Abs(firstPoint.Longitude-secondPoint.Longitude) > 180 { + secondPointLongitude := secondPoint.Longitude + if secondPointLongitude < firstPoint.Longitude { + secondPointLongitude += 360 + } + firstPointLongitude := firstPoint.Longitude + if firstPointLongitude < secondPoint.Longitude { + firstPointLongitude += 360 + } + fraction := (180 - firstPointLongitude) / (secondPointLongitude - firstPointLongitude) + if fraction >= 0 && fraction <= 1 { + candidate := firstPoint.Latitude + fraction*(secondPoint.Latitude-firstPoint.Latitude) + bestGap = math.Min(bestGap, math.Abs(candidate-seamLatitude)) + } + } + } + } + if bestGap > maximumSeamLatitudeGap { + return false + } + } + return true +} + +func solarCentralBandEnvelopeGeometry( + segments [][]eclipsecore.SolarEclipsePathPoint, +) (geometry, string, error) { + if len(segments) == 0 { + return geometry{}, "", fmt.Errorf("central-band envelope is unavailable") + } + polygons := make([][]geodata.GeoPoint, 0, len(segments)) + for segmentIndex, segment := range segments { + if len(openSolarPathRing(segment)) < 3 { + return geometry{}, "", fmt.Errorf("central-band envelope segment %d has fewer than three points", segmentIndex) + } + polygon := make([]geodata.GeoPoint, len(segment)) + for pointIndex, point := range segment { + if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { + return geometry{}, "", fmt.Errorf("central-band envelope segment %d point %d: %w", segmentIndex, pointIndex, err) + } + polygon[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, polygon) + } + merged := polygons + if len(polygons) > 1 { + var err error + merged, err = geodata.UnionPolygons(polygons) + if err != nil { + // Hybrid envelopes can contain annular/total/annular components + // that meet only at a zero-width transition. Their individual + // spherical rings are valid, while forcing a planar union creates + // an open seam at the transition. Preserve those physical components + // as a MultiPolygon instead of rejecting the whole eclipse. + merged = polygons + value, geometryErr := multiPolygonGeometry(merged) + if geometryErr != nil { + return geometry{}, "", fmt.Errorf("central-band envelope union: %w", err) + } + return value, "besselian-critical-envelope-components", nil + } + } + value, err := multiPolygonGeometry(merged) + if err != nil { + return geometry{}, "", err + } + return value, "besselian-critical-envelope", nil +} + +func openSolarPathRing(points []eclipsecore.SolarEclipsePathPoint) []eclipsecore.SolarEclipsePathPoint { + if len(points) > 1 && points[0].Longitude == points[len(points)-1].Longitude && + points[0].Latitude == points[len(points)-1].Latitude { + return points[:len(points)-1] + } + return points +} + +func sameSolarFootprintSlice( + first, second []eclipsecore.SolarEclipsePartialFootprint, +) bool { + if len(first) != len(second) { + return false + } + if len(first) == 0 { + return true + } + return &first[0] == &second[0] +} + +func solarCentralBandFootprints( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, +) []eclipsecore.SolarEclipsePartialFootprint { + if len(partial.CentralShadowFootprints) > 0 && + (len(partial.CentralBandFootprints) == 0 || + partial.CentralShadowStep > 0 && partial.CentralShadowStep <= partial.CentralBandStep && + partial.BoundaryPoints >= solarEclipseCentralBandMinimumBoundaryPoints) { + return partial.CentralShadowFootprints + } + return partial.CentralBandFootprints +} + +// solarCentralBandFootprintsForPath 将开放端部足迹限制在中心轴位于地平线以上的时段。 +// One-limit polar eclipses have U1/U4 contacts before/after that interval; +// sweeping those open footprints into the static band creates artificial flared ends. +func solarCentralBandFootprintsForPath( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, + central *eclipsecore.SolarEclipsePath, +) []eclipsecore.SolarEclipsePartialFootprint { + footprints := solarCentralBandFootprints(partial) + if central == nil || partial.Eclipse.Centrality != eclipsecore.SolarEclipseCentralOneLimit || + len(central.CenterLine) < 2 { + return footprints + } + start := central.CenterLine[0].Time + end := central.CenterLine[len(central.CenterLine)-1].Time + if start.IsZero() || !start.Before(end) { + return footprints + } + filtered := make([]eclipsecore.SolarEclipsePartialFootprint, 0, len(footprints)) + for _, footprint := range footprints { + if footprint.Time.IsZero() || footprint.Time.Before(start) || footprint.Time.After(end) { + continue + } + filtered = append(filtered, footprint) + } + if len(filtered) >= 2 { + return filtered + } + return footprints +} + +// MarshalLunarEclipse 将月食 P1/P4 站心月心可见区和几何地平线编码为 GeoJSON,不含折射。 +// MarshalLunarEclipse encodes the P1/P4 topocentric Moon-center visibility regions and geometric horizons, without refraction. // boundaryPoints 小于等于零时使用 360;其他值限制在 [12, 1440]。 // boundaryPoints values <= 0 use 360; other values are clamped to [12, 1440]. func MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error) { @@ -377,21 +1772,31 @@ func marshalLunarEclipse( {role: "visible-at-p4", horizonRole: "p4-horizon", time: info.PenumbralEnd}, } for _, contact := range contacts { - center := lunarSubpoint(contact.time) - polygons := geodata.VisibleHemispherePolygons( - center, geodata.ProjectionEquirectangular, boundaryPoints, - ) - value, err := multiPolygonGeometryFromFragments(polygons) + points := basic.MoonHorizon(basic.Date2JDE(contact.time.UTC()), boundaryPoints) + horizon := make([]geodata.GeoPoint, len(points)) + for index, point := range points { + horizon[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + horizon = lunarhorizon.Refine(horizon, contact.time) + value, err := multiPolygonGeometry([][]geodata.GeoPoint{horizon}) if err != nil { return nil, fmt.Errorf("geojson: %s: %w", contact.role, err) } + // 日界线剪裁会在相邻世界各输出一次零宽薄片:顶点全落在同一条子午线上、平面面积只剩 + // 浮点噪声(实测 1.8e-12 deg²,刚好越过共享剪裁器 1e-12 的零面积阈值)。只在本月食 + // 可见区导出里丢弃它——共享剪裁器的输出被掩星拓扑依赖,不能在那里过滤。 + // The antimeridian split can emit one zero-width sliver per adjacent world: every vertex + // on one meridian and a planar area of pure floating-point noise (measured 1.8e-12 deg^2, + // just past the shared splitter's 1e-12 zero-area floor). Drop it here, in the lunar + // visibility export only; the shared splitter's output feeds occultation topology and must + // not be filtered. + value = dropDegenerateMultiPolygonRings(value) contactProperties := cloneProperties(properties) contactProperties["time"] = formatTime(contact.time) features = append(features, newFeature( lunarEclipseEvent, contact.role, value, contactProperties, )) - horizon := geodata.SphericalCircle(center, 90, boundaryPoints) horizonValue, err := geoMultiLineGeometry(horizon, true) if err != nil { return nil, fmt.Errorf("geojson: %s: %w", contact.horizonRole, err) @@ -437,47 +1842,1747 @@ func marshalLunarEclipse( return marshalFeatureCollection(features) } +// horizonExact 为 true 时把开放边界补到地平圈擦地点(导出用);为 false 时沿用旧封口, +// 因为掩带的面选择依赖这些填充提示,端点外扩会改变极区边缘的面归属。 func solarPartialFootprintPolygon( footprint eclipsecore.SolarEclipsePartialFootprint, + horizonExact bool, ) ([]geodata.GeoPoint, error) { if footprint.Time.IsZero() { return nil, fmt.Errorf("geojson: solar partial footprint time is required") } - segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries)) - for _, source := range footprint.Boundaries { - segment := make([]geodata.GeoPoint, len(source)) - for index, point := range source { - if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { - return nil, err - } - segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} - } - segments = append(segments, segment) + input, err := solarClosureFootprint(footprint) + if err != nil { + return nil, err } - boundary := geodata.JoinPolylineSegments(segments) - boundary = openRing(boundary) - if len(boundary) == 1 && !footprint.Closed { - return boundary, nil - } - minimumPoints := 3 - if !footprint.Closed { - minimumPoints = 2 - } - if len(boundary) < minimumPoints { + polygon, _, ok := solarclosure.Ring(input, horizonExact) + if !ok { return nil, fmt.Errorf("geojson: solar partial footprint boundary is incomplete") } - polygon := append([]geodata.GeoPoint(nil), boundary...) - if !footprint.Closed { - terminator := geodata.SphericalCircle(solarSubsolarPoint(footprint.Time), 90, 360) - arc := geodata.ShortestCircleArc(terminator, boundary[len(boundary)-1], boundary[0]) - if len(arc) > 1 { - polygon = append(polygon, arc[1:]...) + return polygon, nil +} + +// solarPartialBandGeometry builds the authoritative static visibility region +// from the continuous zero-magnitude envelope and the horizon endpoint tracks. +// Instantaneous footprints remain available for selecting the current shadow, +// but are not part of this time-independent outline. +func solarPartialBandGeometry( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, +) (geometry, string, bool, error) { + if len(partial.PartialBandContours) == 0 || len(partial.RiseSetCurves) == 0 { + return solarPartialBandOverlayGeometry(partial.Footprints, partial.RiseSetCurves) + } + contours := make([][]geodata.GeoPoint, 0, len(partial.PartialBandContours)) + for _, contour := range partial.PartialBandContours { + contours = append(contours, solarCentralBandGeoPoints(contour)) + } + riseSetLines := make([][]geodata.GeoPoint, 0, len(partial.RiseSetCurves)*2) + for _, curve := range partial.RiseSetCurves { + for _, segment := range curve.Segments { + riseSetLines = append(riseSetLines, solarCentralBandGeoPoints(segment)) } } - if len(openRing(polygon)) < 3 { - return nil, fmt.Errorf("geojson: solar partial footprint polygon is incomplete") + footprints := make([]solarclosure.Footprint, 0, len(partial.Footprints)) + for _, footprint := range partial.Footprints { + input, err := solarClosureFootprint(footprint) + if err != nil { + return geometry{}, "", false, err + } + footprints = append(footprints, input) + } + // 面选择沿用采样端点的近似补口:端点外扩会改变极区边缘的面归属。 + polygons, ok := solarclosure.BandPolygons( + contours, riseSetLines, footprints, false, solarclosure.SnapDistanceKM, + ) + if !ok { + return solarPartialBandOverlayGeometry(partial.Footprints, partial.RiseSetCurves) + } + source := "zero-magnitude-envelope+horizon-boundary" + phaseLines := solarPartialBandPhaseLines(partial.RiseSetCurves) + // The linework polygonizer selects faces using sampled instantaneous + // footprints. In an extremely shallow non-central eclipse a phase branch + // can lie in a neighbouring face that no sampled footprint reaches, even + // though it belongs to the same visible envelope. Repair only that proven + // containment miss; ordinary events keep the exact polygonizer result. + if geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false) > 2 { + if bridged, bridgedOK := solarPartialBandBridgePhaseLines(polygons, phaseLines); bridgedOK { + polygons = bridged + source += "+phase-bridge" + } + } + for polygonIndex, polygon := range polygons { + for pointIndex, point := range polygon { + polygons[polygonIndex][pointIndex].Longitude = normalizeLongitude(point.Longitude) + } + } + value, err := multiPolygonGeometry(polygons) + if err != nil { + return geometry{}, "", false, err + } + return value, source, true, nil +} + +// solarPartialBandOverlayGeometry fills the time-sampling gaps along the +// sunrise/sunset side when the physical line network cannot form a complete +// visibility boundary. The original footprint sequence remains part of the +// static fill for this explicitly non-authoritative fallback. +func solarPartialBandOverlayGeometry( + footprints []eclipsecore.SolarEclipsePartialFootprint, + curves []eclipsecore.SolarEclipseRiseSetCurve, +) (geometry, string, bool, error) { + openCount := 0 + for _, footprint := range footprints { + if !footprint.Closed && len(footprint.Boundaries) > 0 { + openCount++ + } + } + if openCount < 2 { + return geometry{}, "", false, nil + } + samples, err := solarCentralShadowSweepSamples(footprints) + if err != nil { + return geometry{}, "", false, err + } + polygons, err := geodata.OpenBoundaryEndpointOutlines(samples) + if err != nil { + return geometry{}, "", false, nil + } + source := "open-boundary-endpoint-outlines" + if bridged, ok := solarPartialBandBridgePhaseLines(polygons, solarPartialBandPhaseLines(curves)); ok { + polygons = bridged + source += "+horizon-boundary" + } + for polygonIndex, polygon := range polygons { + for pointIndex, point := range polygon { + polygons[polygonIndex][pointIndex].Longitude = normalizeLongitude(point.Longitude) + } + } + value, err := multiPolygonGeometry(polygons) + if err != nil { + return geometry{}, "", false, err + } + return value, source, true, nil +} + +func solarPartialBandPhaseLines( + curves []eclipsecore.SolarEclipseRiseSetCurve, +) [][]geodata.GeoPoint { + lines := make([][]geodata.GeoPoint, 0, len(curves)*2) + for _, curve := range curves { + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + line := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + lines = append(lines, line) + } + } + return lines +} + +// solarPartialBandBridgePhaseLines closes the open-footprint fallback with +// the supplied rise/set tracks. Extremely shallow non-central eclipses may +// have no continuous zero-magnitude contour, while their phase tracks still +// extend beyond the few sampled open footprints. Connect each track to the +// nearest base-ring vertices, then union the local bridge faces. The union +// removes the artificial endpoint crossing and leaves every source phase line +// inside the returned visible envelope. +func solarPartialBandBridgePhaseLines( + base, curves [][]geodata.GeoPoint, +) ([][]geodata.GeoPoint, bool) { + if len(base) == 0 || len(curves) == 0 { + return base, false + } + inputs := append([][]geodata.GeoPoint(nil), base...) + bridged := false + for _, line := range curves { + if len(line) < 2 { + continue + } + bestRing := -1 + bestDistance := math.Inf(1) + for ringIndex, ring := range base { + open := openRing(ring) + if len(open) < 3 { + continue + } + distance := solarPartialBandGeoPointDistanceToRing(line[0], open) + + solarPartialBandGeoPointDistanceToRing(line[len(line)-1], open) + if distance < bestDistance { + bestRing, bestDistance = ringIndex, distance + } + } + if bestRing < 0 || bestDistance > 4000 { + return base, false + } + bridge := solarPartialBandBridgeLineToRing(line, openRing(base[bestRing])) + if len(bridge) < 4 { + continue + } + inputs = append(inputs, bridge) + bridged = true + } + if !bridged { + return base, false + } + merged, err := geodata.UnionPolygons(inputs) + if err != nil || len(merged) == 0 || + !geodata.SphericalPolygonsContainPathsWithinKM(merged, curves, false, 2) { + return base, false + } + return merged, true +} + +func solarPartialBandGeoPointDistanceToRing( + point geodata.GeoPoint, + ring []geodata.GeoPoint, +) float64 { + minimum := math.Inf(1) + for index := range ring { + minimum = math.Min(minimum, solarCentralBandGeoPointDistanceKM(point, ring[index])) + } + return minimum +} + +func solarPartialBandBridgeLineToRing( + line, ring []geodata.GeoPoint, +) []geodata.GeoPoint { + if len(line) < 2 || len(ring) < 3 { + return nil + } + open := openRing(ring) + if len(open) < 3 { + return nil + } + nearest := func(point geodata.GeoPoint) (int, float64) { + bestIndex := 0 + bestDistance := solarCentralBandGeoPointDistanceKM(point, open[0]) + for index := 1; index < len(open); index++ { + if distance := solarCentralBandGeoPointDistanceKM(point, open[index]); distance < bestDistance { + bestIndex, bestDistance = index, distance + } + } + return bestIndex, bestDistance + } + startIndex, _ := nearest(line[0]) + endIndex, _ := nearest(line[len(line)-1]) + path := func(step int) []geodata.GeoPoint { + result := []geodata.GeoPoint{open[endIndex]} + index := endIndex + for index != startIndex { + index = (index + step + len(open)) % len(open) + result = append(result, open[index]) + } + return result + } + forward, reverse := path(1), path(-1) + pathLength := func(points []geodata.GeoPoint) float64 { + length := 0.0 + for index := 1; index < len(points); index++ { + length += solarCentralBandGeoPointDistanceKM(points[index-1], points[index]) + } + return length + } + boundary := forward + if pathLength(reverse) < pathLength(forward) { + boundary = reverse + } + result := append([]geodata.GeoPoint(nil), line...) + result = append(result, boundary...) + return result +} + +func appendSolarFootprintFeatures( + features []feature, + role string, + footprints []eclipsecore.SolarEclipsePartialFootprint, + properties map[string]interface{}, +) ([]feature, error) { + for _, footprint := range footprints { + if role == solarCentralShadowFootprintRole && !footprint.Closed { + appended, err := appendSolarHorizonClosedShadowFootprint(features, footprint, properties) + if err != nil { + return nil, err + } + features = appended + continue + } + polygon, err := solarPartialFootprintPolygon(footprint, false) + if err != nil { + return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), err) + } + curve, err := solarShadowFootprintCurveFromSegments(footprint.Boundaries) + if err != nil { + return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), err) + } + if solarShadowRegionDegenerate(curve, polygon) { + // 与单时刻导出同口径:退化区域整条缺省,不退化成点或零面积环。 + continue + } + footprintProperties := cloneProperties(properties) + footprintProperties["time"] = formatTime(footprint.Time) + footprintProperties["source_boundary_closed"] = footprint.Closed + footprintProperties["interp_signature"] = solarShadowFootprintSignature( + footprint.Boundaries, footprint.Closed, eclipsecore.SolarEclipseShadowUmbra, + ) + if len(polygon) == 1 { + value, pointErr := pointGeometry(polygon[0].Longitude, polygon[0].Latitude) + if pointErr != nil { + return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), pointErr) + } + features = append(features, newFeature(solarEclipseEvent, role, value, footprintProperties)) + continue + } + value, geometryErr := multiPolygonGeometry([][]geodata.GeoPoint{polygon}) + if geometryErr != nil { + return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), geometryErr) + } + features = append(features, newFeature(solarEclipseEvent, role, value, footprintProperties)) + } + return features, nil +} + +// solarCentralShadowSweepGeometry closes the open antumbral arcs as one swept +// region. A non-central eclipse has no axis/earth intersection, so the normal +// paired central limits cannot describe this half-band. The first and last +// shadow arcs form the end caps; their two endpoint tracks form the sides. +func solarCentralShadowSweepGeometry( + footprints []eclipsecore.SolarEclipsePartialFootprint, +) (geometry, error) { + polygons, err := solarCentralShadowSweepPolygons(footprints) + if err != nil { + return geometry{}, err + } + return multiPolygonGeometry(polygons) +} + +func solarCentralShadowSweepPolygons( + footprints []eclipsecore.SolarEclipsePartialFootprint, +) ([][]geodata.GeoPoint, error) { + samples, err := solarCentralShadowSweepSamples(footprints) + if err != nil { + return nil, err + } + polygons, err := geodata.OpenBoundarySweep(samples) + if err != nil { + return nil, fmt.Errorf("central-shadow footprints: %w", err) + } + return usableSolarCentralShadowSweepPolygons(polygons) +} + +func solarCentralMonotoneEndSweepPolygons( + footprints []eclipsecore.SolarEclipsePartialFootprint, +) ([][]geodata.GeoPoint, error) { + samples, err := solarCentralShadowSweepSamples(footprints) + if err != nil { + return nil, err + } + polygons, err := geodata.MonotoneOpenBoundarySweep(samples) + if err != nil { + polygons, err = geodata.OpenBoundarySweep( + geodata.DecimateOpenBoundarySweepSamples(samples, 24, 40), + ) + if err != nil { + return nil, fmt.Errorf("central-shadow endpoint footprints: %w", err) + } + } + return usableSolarCentralShadowSweepPolygons(polygons) +} + +func solarCentralShadowSweepSamples( + footprints []eclipsecore.SolarEclipsePartialFootprint, +) ([]geodata.OpenBoundarySweepSample, error) { + samples := make([]geodata.OpenBoundarySweepSample, 0, len(footprints)) + for _, footprint := range footprints { + boundary := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries)) + for _, source := range footprint.Boundaries { + segment := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { + return nil, err + } + segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + boundary = append(boundary, segment) + } + samples = append(samples, geodata.OpenBoundarySweepSample{ + Boundaries: boundary, + Closed: footprint.Closed, + }) + } + return samples, nil +} + +func usableSolarCentralShadowSweepPolygons( + polygons [][]geodata.GeoPoint, +) ([][]geodata.GeoPoint, error) { + usable := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + if len(openRing(polygon)) >= 3 { + usable = append(usable, polygon) + } + } + if len(usable) == 0 { + return nil, fmt.Errorf("central-shadow footprints contain no usable swept region") + } + if len(usable) == 1 { + return usable, nil + } + return geodata.UnionPolygons(usable) +} + +// solarCentralBandCoverageToleranceKM is the macro-leak threshold used to +// reject a central-band candidate that leaves real umbral area uncovered. +// Ordinary events stay within roughly 40 km at the 90th percentile, while a +// grazing one-limit ribbon or a failed two-limit ribbon misses by hundreds of +// kilometres. +const solarCentralBandCoverageToleranceKM = 100.0 + +// solarCentralBandCoveragePoints 把路径点转成球面点,坐标非法时返回 false。 +func solarCentralBandCoveragePoints( + points []eclipsecore.SolarEclipsePathPoint, +) ([]geodata.GeoPoint, bool) { + result := make([]geodata.GeoPoint, len(points)) + for index, point := range points { + if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { + return nil, false + } + result[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + return result, true +} + +// solarCentralBandVertexGridDegrees 是顶点网格的边长:一格的纬度跨度已超过任何容差。 +const solarCentralBandVertexGridDegrees = 1.0 + +// solarCentralBandVertexGrid 按固定网格索引环顶点,用于快速确认探针落在环附近。 +type solarCentralBandVertexGrid map[int][]geodata.GeoPoint + +func solarCentralBandVertexGridKey(latitude, longitude float64) int { + latitudeCell := int(math.Floor(latitude/solarCentralBandVertexGridDegrees)) + 90 + longitudeCell := int(math.Floor(normalizeLongitude(longitude)/solarCentralBandVertexGridDegrees)) + 180 + return latitudeCell*360 + longitudeCell +} + +func newSolarCentralBandVertexGrid(polygons [][]geodata.GeoPoint) solarCentralBandVertexGrid { + grid := make(solarCentralBandVertexGrid) + for _, polygon := range polygons { + for _, point := range openRing(polygon) { + key := solarCentralBandVertexGridKey(point.Latitude, point.Longitude) + grid[key] = append(grid[key], point) + } + } + return grid +} + +// vertexWithinKM 报告网格邻域内是否存在容差范围内的环顶点;网格给不出结论不代表真的超限。 +func (grid solarCentralBandVertexGrid) vertexWithinKM( + point geodata.GeoPoint, + toleranceKM float64, +) bool { + deltaLatitude := toleranceKM/111.0 + solarCentralBandVertexGridDegrees + scale := math.Abs(math.Cos(point.Latitude * math.Pi / 180)) + if scale < 1e-6 { + scale = 1e-6 + } + deltaLongitude := deltaLatitude/scale + solarCentralBandVertexGridDegrees + minimumLatitudeCell := int(math.Floor((point.Latitude-deltaLatitude)/solarCentralBandVertexGridDegrees)) + 90 + maximumLatitudeCell := int(math.Floor((point.Latitude+deltaLatitude)/solarCentralBandVertexGridDegrees)) + 90 + minimumLongitudeCell := int(math.Floor((point.Longitude-deltaLongitude)/solarCentralBandVertexGridDegrees)) + 180 + maximumLongitudeCell := int(math.Floor((point.Longitude+deltaLongitude)/solarCentralBandVertexGridDegrees)) + 180 + for latitudeCell := minimumLatitudeCell; latitudeCell <= maximumLatitudeCell; latitudeCell++ { + for longitudeCell := minimumLongitudeCell; longitudeCell <= maximumLongitudeCell; longitudeCell++ { + for _, vertex := range grid[latitudeCell*360+longitudeCell] { + if solarCentralBandGeoPointDistanceKM(point, vertex) <= toleranceKM { + return true + } + } + } + } + return false +} + +// solarCentralBandPointsCover 报告每个点是否都在容差内落在多边形里:先做一次球面包含判定, +// 环外的点先用顶点网格确认附近有环顶点,只有网格给不出结论时才做精确的球面偏离计算。 +func solarCentralBandPointsCover( + polygons [][]geodata.GeoPoint, + points []geodata.GeoPoint, + toleranceKM float64, +) bool { + if len(polygons) == 0 { + return false + } + if len(points) == 0 { + return true + } + grid := newSolarCentralBandVertexGrid(polygons) + index := geodata.NewSphericalPolygonIndex(polygons) + for position, inside := range index.ContainsPoints(points) { + if inside { + continue + } + // 环顶点到多边形的距离不小于到环顶点的距离,邻域内有顶点即已满足容差。 + if grid.vertexWithinKM(points[position], toleranceKM) { + continue + } + if geodata.SphericalPolygonsPathMissDistanceKM( + polygons, [][]geodata.GeoPoint{{points[position]}}, false, + ) > toleranceKM { + return false + } + } + return true +} + +// solarCentralBandRingsCover reports whether the candidate rings contain the +// complete center line and the umbral sweep. The center line and the swept +// footprints are the ground truth the static band must describe, so a candidate +// that leaves either outside is not acceptable while a better alternative +// remains; every vertex is probed because this decision selects the exported +// candidate and a subsample can step over a real gap. +func solarCentralBandRingsCover( + rings [][]geodata.GeoPoint, + centerLine []eclipsecore.SolarEclipsePathPoint, + footprints []eclipsecore.SolarEclipsePartialFootprint, +) bool { + if len(rings) == 0 { + return false + } + points := make([]geodata.GeoPoint, 0, len(centerLine)) + if len(centerLine) >= 2 { + converted, ok := solarCentralBandCoveragePoints(centerLine) + if !ok { + return false + } + points = append(points, converted...) + } + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + continue + } + converted, ok := solarCentralBandCoveragePoints(boundary) + if !ok { + return false + } + points = append(points, converted...) + } + } + if len(points) == 0 { + return true + } + return solarCentralBandPointsCover(rings, points, solarCentralBandCoverageToleranceKM) +} + +// The center line is the spine of the band: a candidate that clips it is not a +// valid envelope even when its outer edge follows the umbral sweep, and the +// union of a paired ribbon with end sweeps can re-orient a polar ring just +// enough to push a few line vertices outside. The corridor below widens such a +// candidate locally instead of rejecting the whole band and falling back to a +// ribbon that loses hundreds of kilometres of real umbral area. +const ( + solarCentralBandCenterlineToleranceKM = 2.0 + // 探针偏离超过该上限就不补走廊:半径会把食带撑成一个覆盖半个地球的圆盘。 + solarCentralBandCenterlineCorridorMaxKM = 200.0 +) + +// solarCentralBandCenterlineProbes 把采样中心线展开成顶点与边中点,作为包含判据的探针集合。 +func solarCentralBandCenterlineProbes( + centerLine []eclipsecore.SolarEclipsePathPoint, +) []geodata.GeoPoint { + probes := make([]geodata.GeoPoint, 0, 2*len(centerLine)) + for index, point := range centerLine { + if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { + return nil + } + probes = append(probes, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + if index+1 >= len(centerLine) { + continue + } + next := centerLine[index+1] + if err := validateCoordinate(next.Longitude, next.Latitude); err != nil { + return nil + } + probes = append(probes, geodata.GeoPoint{ + Longitude: normalizeLongitude( + point.Longitude + math.Remainder(next.Longitude-point.Longitude, 360)/2, + ), + Latitude: (point.Latitude + next.Latitude) / 2, + }) + } + return probes +} + +// solarCentralBandCenterlineMissesKM 逐个探针量到多边形的偏离,落在多边形内的探针为 0。 +func solarCentralBandCenterlineMissesKM( + polygons [][]geodata.GeoPoint, + probes []geodata.GeoPoint, +) []float64 { + misses := make([]float64, len(probes)) + if len(polygons) == 0 || len(probes) == 0 { + return misses + } + index := geodata.NewSphericalPolygonIndex(polygons) + contained := index.ContainsPoints(probes) + for position, inside := range contained { + if inside { + continue + } + misses[position] = geodata.SphericalPolygonsPathMissDistanceKM( + polygons, [][]geodata.GeoPoint{{probes[position]}}, false, + ) + } + return misses +} + +// solarCentralBandWithCenterlineCorridor 保证每个中心线探针都在容差内落在返回的多边形里: +// 只给越界的探针补一个半径等于它自身偏离加容差的圆盘,偏离超过上限时原样返回。 +func solarCentralBandWithCenterlineCorridor( + polygons [][]geodata.GeoPoint, + centerLine []eclipsecore.SolarEclipsePathPoint, +) [][]geodata.GeoPoint { + if len(polygons) == 0 || len(centerLine) < 2 { + return polygons + } + probes := solarCentralBandCenterlineProbes(centerLine) + if len(probes) == 0 { + return polygons + } + misses := solarCentralBandCenterlineMissesKM(polygons, probes) + inputs := append([][]geodata.GeoPoint{}, polygons...) + patched := 0 + for position, probe := range probes { + miss := misses[position] + if miss <= solarCentralBandCenterlineToleranceKM { + continue + } + if miss > solarCentralBandCenterlineCorridorMaxKM { + return polygons + } + circle := geodata.SphericalCircle( + probe, (miss+solarCentralBandCenterlineToleranceKM)/111.32, 12, + ) + if len(circle) < 3 { + continue + } + inputs = append(inputs, append(circle, circle[0])) + patched++ + } + if patched == 0 { + return polygons + } + merged, err := geodata.UnionPolygons(inputs) + if err != nil || len(merged) == 0 { + return polygons + } + return merged +} + +// solarCentralMagnitudeOneBandGeometry builds the static totality envelope +// from the local-maximum magnitude-one contour. The old central limits are +// instantaneous cross-sections perpendicular to the moving shadow; near a +// low-altitude path those cross-sections can be narrower than the spatial +// envelope swept by the shadow. A magnitude-one contour is already that +// envelope. This fallback supports caller-supplied results without the core +// CentralBandSegments; normal calculations provide the closed band directly. +func solarCentralMagnitudeOneBandGeometry( + eclipseType eclipsecore.SolarEclipseType, + contours []eclipsecore.SolarEclipseMagnitudeContour, + centerLine []eclipsecore.SolarEclipsePathPoint, + horizonClosures [][]eclipsecore.SolarEclipsePathPoint, +) (geometry, string, bool, error) { + // Only a total eclipse has a local magnitude-one contour: an annular eclipse + // stays below one everywhere (the ring is the whole point), so its band is + // bounded by the antumbral limits instead. + if eclipseType != eclipsecore.SolarEclipseTotal || len(centerLine) < 2 { + return geometry{}, "", false, nil + } + var segments [][]eclipsecore.SolarEclipsePathPoint + for _, contour := range contours { + if math.Abs(contour.Magnitude-1) > 1e-12 || len(contour.Segments) != 2 { + continue + } + for _, segment := range contour.Segments { + if len(segment) >= 2 { + segments = append(segments, segment) + } + } + if len(segments) == 2 { + break + } + segments = nil + } + if len(segments) != 2 { + return geometry{}, "", false, nil + } + first := append([]eclipsecore.SolarEclipsePathPoint(nil), segments[0]...) + second := append([]eclipsecore.SolarEclipsePathPoint(nil), segments[1]...) + ring, closedAtHorizon := solarCentralMagnitudeOneHorizonRing(first, second, horizonClosures) + if !closedAtHorizon { + forwardGap := solarCentralBandPathDistanceKM(first[len(first)-1], second[0]) + reverseGap := solarCentralBandPathDistanceKM(first[len(first)-1], second[len(second)-1]) + if reverseGap < forwardGap { + for left, right := 0, len(second)-1; left < right; left, right = left+1, right-1 { + second[left], second[right] = second[right], second[left] + } + forwardGap = reverseGap + } + closingGap := solarCentralBandPathDistanceKM(second[len(second)-1], first[0]) + if forwardGap > 2000 || closingGap > 2000 { + return geometry{}, "", false, nil + } + ring = make([]geodata.GeoPoint, 0, len(first)+len(second)) + for _, point := range first { + ring = append(ring, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + for _, point := range second { + ring = append(ring, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + } + ring = solarCentralBandRefineRingSpacing(ring, 200) + centerPath := make([]geodata.GeoPoint, len(centerLine)) + for index, point := range centerLine { + centerPath[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + if !geodata.SphericalPolygonsContainPathsWithinKM( + [][]geodata.GeoPoint{ring}, [][]geodata.GeoPoint{centerPath}, false, 5, + ) { + return geometry{}, "", false, nil + } + value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring}) + if err != nil { + return geometry{}, "", false, err + } + return value, "magnitude-one-envelope", true, nil +} + +// solarCentralMagnitudeOneHorizonRing replaces both straight endpoint chords +// with the exact greatest-at-horizon arcs shared by the public rise/set lines. +func solarCentralMagnitudeOneHorizonRing( + first, second []eclipsecore.SolarEclipsePathPoint, + closures [][]eclipsecore.SolarEclipsePathPoint, +) ([]geodata.GeoPoint, bool) { + if len(first) < 2 || len(second) < 2 || len(closures) != 2 || + len(closures[0]) < 2 || len(closures[1]) < 2 { + return nil, false + } + type candidate struct { + first []eclipsecore.SolarEclipsePathPoint + second []eclipsecore.SolarEclipsePathPoint + score float64 + } + best := candidate{score: math.Inf(1)} + for _, reverseFirst := range []bool{false, true} { + for _, reverseSecond := range []bool{false, true} { + firstCandidate := solarCentralBandOrientedPath(first, reverseFirst) + secondCandidate := solarCentralBandOrientedPath(second, reverseSecond) + score := solarCentralMagnitudeOneClosurePairDistance( + closures[0], firstCandidate[0], secondCandidate[0], + ) + solarCentralMagnitudeOneClosurePairDistance( + closures[1], firstCandidate[len(firstCandidate)-1], secondCandidate[len(secondCandidate)-1], + ) + if score < best.score { + best = candidate{first: firstCandidate, second: secondCandidate, score: score} + } + } + } + startClosure, startOK := solarCentralMagnitudeOneOrientedClosure( + closures[0], best.second[0], best.first[0], + ) + endClosure, endOK := solarCentralMagnitudeOneOrientedClosure( + closures[1], best.first[len(best.first)-1], best.second[len(best.second)-1], + ) + if !startOK || !endOK { + return nil, false + } + best.first[0] = startClosure[len(startClosure)-1] + best.first[len(best.first)-1] = endClosure[0] + best.second[0] = startClosure[0] + best.second[len(best.second)-1] = endClosure[len(endClosure)-1] + + points := make([]eclipsecore.SolarEclipsePathPoint, 0, + len(best.first)+len(best.second)+len(startClosure)+len(endClosure), + ) + points = append(points, best.first...) + points = append(points, endClosure[1:]...) + for index := len(best.second) - 2; index >= 0; index-- { + points = append(points, best.second[index]) + } + points = append(points, startClosure[1:]...) + return solarCentralBandGeoPoints(points), true +} + +func solarCentralBandOrientedPath( + points []eclipsecore.SolarEclipsePathPoint, + reverse bool, +) []eclipsecore.SolarEclipsePathPoint { + result := append([]eclipsecore.SolarEclipsePathPoint(nil), points...) + if reverse { + for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 { + result[left], result[right] = result[right], result[left] + } + } + return result +} + +func solarCentralMagnitudeOneClosurePairDistance( + closure []eclipsecore.SolarEclipsePathPoint, + first, second eclipsecore.SolarEclipsePathPoint, +) float64 { + direct := solarCentralBandPathDistanceKM(closure[0], first) + + solarCentralBandPathDistanceKM(closure[len(closure)-1], second) + reverse := solarCentralBandPathDistanceKM(closure[len(closure)-1], first) + + solarCentralBandPathDistanceKM(closure[0], second) + return math.Min(direct, reverse) +} + +func solarCentralMagnitudeOneOrientedClosure( + source []eclipsecore.SolarEclipsePathPoint, + start, end eclipsecore.SolarEclipsePathPoint, +) ([]eclipsecore.SolarEclipsePathPoint, bool) { + const maximumRootDistanceKM = 1.0 + closure := solarCentralBandOrientedPath(source, false) + direct := solarCentralBandPathDistanceKM(start, closure[0]) + + solarCentralBandPathDistanceKM(end, closure[len(closure)-1]) + reverse := solarCentralBandPathDistanceKM(start, closure[len(closure)-1]) + + solarCentralBandPathDistanceKM(end, closure[0]) + if reverse < direct { + closure = solarCentralBandOrientedPath(closure, true) + } + if solarCentralBandPathDistanceKM(start, closure[0]) > maximumRootDistanceKM || + solarCentralBandPathDistanceKM(end, closure[len(closure)-1]) > maximumRootDistanceKM { + return nil, false + } + return closure, true +} + +func solarCentralBandPathDistanceKM( + first, second eclipsecore.SolarEclipsePathPoint, +) float64 { + return solarCentralBandGeoPointDistanceKM( + geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude}, + geodata.GeoPoint{Longitude: second.Longitude, Latitude: second.Latitude}, + ) +} + +func solarCentralBandGeometry( + northern, southern []eclipsecore.SolarEclipsePathPoint, + centerLine []eclipsecore.SolarEclipsePathPoint, + eclipseType eclipsecore.SolarEclipseType, + centrality eclipsecore.SolarEclipseCentrality, + footprints []eclipsecore.SolarEclipsePartialFootprint, + horizonClosures [][]eclipsecore.SolarEclipsePathPoint, +) (geometry, string, error) { + if eclipseType != eclipsecore.SolarEclipseHybrid { + // A one-limit central event traces its paired limits only over the + // shadow-axis interval, which for a grazing event (|gamma| ~ 0.98-0.997) + // is a fraction of the U1..U4 umbral window. Reuse the two-limit + // candidate chain, whose end-sweep and complete-contact alternatives are + // coverage-validated, before falling back to the continuous ribbon. + if centrality == eclipsecore.SolarEclipseCentralOneLimit { + // The chain's last-resort candidates are returned without coverage + // validation, so re-check here: a validated alternative is only worth + // taking when it actually covers the umbral sweep, otherwise the + // continuous ribbon below stays the better rendering. + if polygons, source, ok := solarCentralTwoLimitBandPolygons( + northern, southern, centerLine, footprints, horizonClosures, + ); ok && solarCentralBandRingsCover(polygons, centerLine, footprints) { + value, geometryErr := multiPolygonGeometry( + solarCentralBandWithCenterlineCorridor(polygons, centerLine), + ) + if geometryErr != nil { + return geometry{}, "", geometryErr + } + return value, source, nil + } + band, err := pairedLimitPolygon(northern, southern) + if err != nil { + return geometry{}, "", err + } + inputs := append([][]geodata.GeoPoint{band}, solarCentralBandEndpointCaps(northern, southern, centerLine)...) + if merged, mergeErr := geodata.UnionPolygons(inputs); mergeErr == nil { + inputs = merged + } + value, geometryErr := multiPolygonGeometry(inputs) + if geometryErr != nil { + return geometry{}, "", geometryErr + } + return value, "paired-limits-one-limit", nil + } + if centrality == eclipsecore.SolarEclipseCentralTwoLimits { + polygons, source, ok := solarCentralTwoLimitBandPolygons( + northern, southern, centerLine, footprints, horizonClosures, + ) + if ok { + value, geometryErr := multiPolygonGeometry( + solarCentralBandWithCenterlineCorridor(polygons, centerLine), + ) + if geometryErr != nil { + return geometry{}, "", geometryErr + } + return value, source, nil + } + } + band, err := pairedLimitPolygon(northern, southern) + if err != nil { + return geometry{}, "", err + } + endpointCaps := solarCentralBandEndpointCaps(northern, southern, centerLine) + inputs := append([][]geodata.GeoPoint{band}, endpointCaps...) + if len(footprints) > 0 { + if sweep, sweepErr := solarCentralShadowSweepPolygons(footprints); sweepErr == nil { + inputs = append(inputs, sweep...) + } + } + if merged, mergeErr := geodata.UnionPolygons(inputs); mergeErr == nil { + if value, geometryErr := multiPolygonGeometry( + solarCentralBandWithCenterlineCorridor(merged, centerLine), + ); geometryErr == nil { + return value, "paired-limits+central-shadow-sweep-union", nil + } + } + value, geometryErr := multiPolygonGeometry( + solarCentralBandWithCenterlineCorridor(inputs, centerLine), + ) + if geometryErr != nil { + return geometry{}, "", geometryErr + } + return value, "paired-limits-fallback", nil + } + bandPolygons, source := solarCentralPathBandPolygons(northern, southern) + if len(bandPolygons) == 0 { + band, err := pairedLimitPolygon(northern, southern) + if err != nil { + return geometry{}, "", err + } + bandPolygons = [][]geodata.GeoPoint{band} + source = "paired-limits-fallback" + } + endpointCaps := solarCentralBandEndpointCaps(northern, southern, centerLine) + if len(horizonClosures) == 2 { + start, startOK := solarCentralBandHorizonTail(northern[0], southern[0], horizonClosures[0]) + end, endOK := solarCentralBandHorizonTail(northern[len(northern)-1], southern[len(southern)-1], horizonClosures[1]) + if startOK && endOK { + endpointCaps = [][]geodata.GeoPoint{start, end} + source += "+horizon-closures" + } + } + // Keep the hybrid transition sweep, but close its ends at the solved + // horizon limits rather than collapsing a finite-width shadow to the axis. + polygons := append(append([][]geodata.GeoPoint(nil), bandPolygons...), endpointCaps...) + if len(endpointCaps) > 0 { + if merged, mergeErr := geodata.UnionPolygons(polygons); mergeErr == nil { + polygons = merged + } + } + paired, geometryErr := multiPolygonGeometry(polygons) + if geometryErr != nil { + return geometry{}, "", geometryErr + } + return paired, source, nil +} + +func solarCentralTwoLimitBandPolygons( + northern, southern, centerLine []eclipsecore.SolarEclipsePathPoint, + footprints []eclipsecore.SolarEclipsePartialFootprint, + horizonClosures [][]eclipsecore.SolarEclipsePathPoint, +) ([][]geodata.GeoPoint, string, bool) { + // The map linework is deliberately trimmed to the center-line interval, + // while this geometry must retain the complete U1/U4 contact interval. + // MarshalSolarEclipse owns that presentation trim and always supplies the + // complete paired limits here. + north, south, ok := solarCentralTwoLimitPairedSamples(northern, southern) + if !ok { + return nil, "", false + } + north, south = solarCentralTwoLimitEnvelopeSamples(north, south) + middle, err := pairedLimitPolygon(north, south) + if err != nil { + return nil, "", false + } + if len(horizonClosures) == 2 { + startTail, startOK := solarCentralBandHorizonTail(north[0], south[0], horizonClosures[0]) + endTail, endOK := solarCentralBandHorizonTail( + north[len(north)-1], south[len(south)-1], horizonClosures[1], + ) + if startOK && endOK { + inputs := [][]geodata.GeoPoint{middle, startTail, endTail} + if merged, mergeErr := geodata.UnionPolygons(inputs); mergeErr == nil && len(merged) == 1 && + solarCentralBandRingsCover(merged, centerLine, footprints) { + return merged, "paired-limits+horizon-closures", true + } + } + } + // Prefer the paired-limit ribbon that is explicitly validated against the + // complete center line. End-sweep overlays can select a neighboring polar + // face and create an artificial narrow neck even when the ribbon itself is + // continuous. + // The ribbon is traced over the center-line interval only. Accept it when + // it really covers the complete umbral sweep; otherwise keep looking, since + // grazing events lose hundreds of kilometres of genuine umbral area here. + if merged, ok := solarCentralTwoLimitRibbonUnionPolygons( + north, south, centerLine, nil, + ); ok && solarCentralBandRingsCover(merged, centerLine, footprints) { + return merged, "paired-limits-ribbon-union", true + } + if len(footprints) > 0 { + endSweeps, sweepErr := solarCentralMonotoneEndSweepPolygons(footprints) + if sweepErr == nil { + inputs := make([][]geodata.GeoPoint, 0, 1+len(endSweeps)+2) + inputs = append(inputs, middle) + inputs = append(inputs, endSweeps...) + inputs = append(inputs, solarCentralBandInnerTransitionCaps(footprints)...) + inputs = append(inputs, solarCentralBandContactCaps( + footprints, northern[0], northern[len(northern)-1], + )...) + if merged, mergeErr := geodata.UnionPolygons(inputs); mergeErr == nil && len(merged) == 1 && + solarCentralBandRingsCover(merged, centerLine, footprints) { + return merged, "paired-limits+central-shadow-end-sweeps", true + } + if merged, ok := solarCentralTwoLimitRibbonUnionPolygons( + north, south, centerLine, inputs[1:], + ); ok && solarCentralBandRingsCover(merged, centerLine, footprints) { + return merged, "paired-limits+central-shadow-ribbon-union", true + } + } + } + // The presentation limits above intentionally trim the two U1/U4 tails + // for ordinary maps. Near a pole those trimmed ribbon pieces can fold into + // hundreds of tiny triangles and lose the physical contact endpoints. Keep + // the complete paired limits as one spherical ring before using the final + // axis-cap fallback; multiPolygonGeometry performs the map split afterwards. + if fullBand, fullErr := pairedLimitPolygon(north, south); fullErr == nil { + if len(footprints) > 0 { + if sweep, sweepErr := solarCentralShadowSweepPolygons(footprints); sweepErr == nil && + solarCentralBandRingsCover(sweep, centerLine, footprints) { + contacts := []geodata.GeoPoint{ + {Longitude: northern[0].Longitude, Latitude: northern[0].Latitude}, + {Longitude: northern[len(northern)-1].Longitude, Latitude: northern[len(northern)-1].Latitude}, + } + if snapSolarCentralSweepContacts(sweep, contacts) { + return sweep, "central-shadow-complete-contact-fallback", true + } + return sweep, "central-shadow-complete-fallback", true + } + } + return [][]geodata.GeoPoint{fullBand}, "paired-limits-complete-fallback", true + } + if fullBand, fullErr := pairedLimitPolygon(north, south); fullErr == nil { + if pieces := solarCentralTwoLimitRibbonPieces(north, south, centerLine); len(pieces) > 0 { + return pieces, "paired-limits-ribbon-pieces-fallback", true + } + return [][]geodata.GeoPoint{fullBand}, "paired-limits-full-limit-fallback", true + } + band, ok := solarCentralTwoLimitAxisCappedPolygon(north, south, centerLine) + if !ok { + return nil, "", false + } + return [][]geodata.GeoPoint{band}, "paired-limits-axis-cap-fallback", true +} + +func solarCentralTwoLimitPairedSamples( + northern, southern []eclipsecore.SolarEclipsePathPoint, +) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint, bool) { + if len(northern) < 2 || len(northern) != len(southern) { + return nil, nil, false + } + for index := range northern { + if northern[index].Time.IsZero() || southern[index].Time.IsZero() || + !northern[index].Time.Equal(southern[index].Time) { + return nil, nil, false + } + if index > 0 && (!northern[index-1].Time.Before(northern[index].Time) || + !southern[index-1].Time.Before(southern[index].Time)) { + return nil, nil, false + } + } + return northern, southern, true +} + +// snapSolarCentralSweepContacts moves the nearest sampled sweep vertices onto +// the exact U1/U4 contact points. The shadow footprints start at the contact +// times, but their finite angular/time sampling can leave the exported vertex +// a few kilometres away. Snapping the existing vertices preserves the sweep +// components and avoids adding overlapping endpoint triangles. +func snapSolarCentralSweepContacts(polygons [][]geodata.GeoPoint, contacts []geodata.GeoPoint) bool { + if len(polygons) == 0 || len(contacts) == 0 { + return false + } + const maximumSnapDistanceKM = 500.0 + used := make(map[[2]int]bool) + for _, contact := range contacts { + bestDistance := math.Inf(1) + bestPolygon, bestPoint := -1, -1 + for polygonIndex, polygon := range polygons { + for pointIndex, point := range polygon { + if used[[2]int{polygonIndex, pointIndex}] { + continue + } + distance := solarCentralBandGeoPointDistanceKM(point, contact) + if distance < bestDistance { + bestDistance = distance + bestPolygon, bestPoint = polygonIndex, pointIndex + } + } + } + if bestPolygon < 0 || bestDistance > maximumSnapDistanceKM { + return false + } + polygons[bestPolygon][bestPoint] = contact + used[[2]int{bestPolygon, bestPoint}] = true + } + return true +} + +// solarCentralTwoLimitRibbonPieces keeps the fallback as a collection of +// adjacent time-slice faces instead of one polar ring. Each slice is split +// around the interpolated center-line segment, so a limit branch crossing a +// pole or the antimeridian cannot create a bow-tie polygon. +func solarCentralTwoLimitRibbonPieces( + northern, southern, centerLine []eclipsecore.SolarEclipsePathPoint, +) [][]geodata.GeoPoint { + if len(northern) < 2 || len(northern) != len(southern) { + return nil + } + pieces := make([][]geodata.GeoPoint, 0, len(northern)+2) + for index := 1; index < len(northern); index++ { + northPrevious := geodata.GeoPoint{ + Longitude: northern[index-1].Longitude, Latitude: northern[index-1].Latitude, + } + northCurrent := geodata.GeoPoint{ + Longitude: northern[index].Longitude, Latitude: northern[index].Latitude, + } + southCurrent := geodata.GeoPoint{ + Longitude: southern[index].Longitude, Latitude: southern[index].Latitude, + } + southPrevious := geodata.GeoPoint{ + Longitude: southern[index-1].Longitude, Latitude: southern[index-1].Latitude, + } + centerPrevious := solarCentralBandCenterPointAt(centerLine, northern[index-1].Time) + centerCurrent := solarCentralBandCenterPointAt(centerLine, northern[index].Time) + for _, piece := range [][]geodata.GeoPoint{ + {northPrevious, northCurrent, centerCurrent}, + {northPrevious, centerCurrent, centerPrevious}, + {centerPrevious, centerCurrent, southCurrent}, + {centerPrevious, southCurrent, southPrevious}, + } { + if solarCentralBandGeoPointDistanceKM(piece[0], piece[1]) == 0 && + solarCentralBandGeoPointDistanceKM(piece[1], piece[2]) == 0 { + continue + } + pieces = append(pieces, piece) + } + } + if len(centerLine) >= 2 { + pieces = append(pieces, + []geodata.GeoPoint{ + {Longitude: centerLine[0].Longitude, Latitude: centerLine[0].Latitude}, + {Longitude: northern[0].Longitude, Latitude: northern[0].Latitude}, + {Longitude: southern[0].Longitude, Latitude: southern[0].Latitude}, + }, + []geodata.GeoPoint{ + {Longitude: centerLine[len(centerLine)-1].Longitude, Latitude: centerLine[len(centerLine)-1].Latitude}, + {Longitude: northern[len(northern)-1].Longitude, Latitude: northern[len(northern)-1].Latitude}, + {Longitude: southern[len(southern)-1].Longitude, Latitude: southern[len(southern)-1].Latitude}, + }, + ) + } + return pieces +} + +func solarCentralBandCenterPointAt( + centerLine []eclipsecore.SolarEclipsePathPoint, + target time.Time, +) geodata.GeoPoint { + if len(centerLine) == 0 { + return geodata.GeoPoint{} + } + if !target.After(centerLine[0].Time) { + return geodata.GeoPoint{Longitude: centerLine[0].Longitude, Latitude: centerLine[0].Latitude} + } + for index := 1; index < len(centerLine); index++ { + if !target.After(centerLine[index].Time) { + previous, current := centerLine[index-1], centerLine[index] + span := current.Time.Sub(previous.Time) + if span <= 0 { + return geodata.GeoPoint{Longitude: current.Longitude, Latitude: current.Latitude} + } + fraction := float64(target.Sub(previous.Time)) / float64(span) + return solarCentralBandSphericalInterpolate( + geodata.GeoPoint{Longitude: previous.Longitude, Latitude: previous.Latitude}, + geodata.GeoPoint{Longitude: current.Longitude, Latitude: current.Latitude}, + fraction, + ) + } + } + last := centerLine[len(centerLine)-1] + return geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude} +} + +func solarCentralBandHorizonTail( + north, south eclipsecore.SolarEclipsePathPoint, + source []eclipsecore.SolarEclipsePathPoint, +) ([]geodata.GeoPoint, bool) { + if len(source) < 2 { + return nil, false + } + closure := append([]eclipsecore.SolarEclipsePathPoint(nil), source...) + forwardDistance := solarCentralBandPathDistanceKM(north, closure[0]) + + solarCentralBandPathDistanceKM(south, closure[len(closure)-1]) + reverseDistance := solarCentralBandPathDistanceKM(north, closure[len(closure)-1]) + + solarCentralBandPathDistanceKM(south, closure[0]) + if reverseDistance < forwardDistance { + for left, right := 0, len(closure)-1; left < right; left, right = left+1, right-1 { + closure[left], closure[right] = closure[right], closure[left] + } + } + tail := make([]geodata.GeoPoint, 0, len(closure)+3) + tail = append(tail, geodata.GeoPoint{Longitude: north.Longitude, Latitude: north.Latitude}) + for _, point := range closure { + tail = append(tail, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + tail = append(tail, + geodata.GeoPoint{Longitude: south.Longitude, Latitude: south.Latitude}, + tail[0], + ) + return tail, true +} + +func solarCentralTwoLimitRibbonUnionPolygons( + north, south, centerLine []eclipsecore.SolarEclipsePathPoint, + overlays [][]geodata.GeoPoint, +) ([][]geodata.GeoPoint, bool) { + if len(north) < 2 || len(north) != len(south) || len(centerLine) < 2 { + return nil, false + } + ribbons := make([][]geodata.GeoPoint, 0, len(north)+2) + for index := 1; index < len(north); index++ { + ribbons = append(ribbons, []geodata.GeoPoint{ + {Longitude: north[index-1].Longitude, Latitude: north[index-1].Latitude}, + {Longitude: north[index].Longitude, Latitude: north[index].Latitude}, + {Longitude: south[index].Longitude, Latitude: south[index].Latitude}, + {Longitude: south[index-1].Longitude, Latitude: south[index-1].Latitude}, + }) + } + ribbons = append(ribbons, + []geodata.GeoPoint{ + {Longitude: centerLine[0].Longitude, Latitude: centerLine[0].Latitude}, + {Longitude: north[0].Longitude, Latitude: north[0].Latitude}, + {Longitude: south[0].Longitude, Latitude: south[0].Latitude}, + }, + []geodata.GeoPoint{ + {Longitude: centerLine[len(centerLine)-1].Longitude, Latitude: centerLine[len(centerLine)-1].Latitude}, + {Longitude: north[len(north)-1].Longitude, Latitude: north[len(north)-1].Latitude}, + {Longitude: south[len(south)-1].Longitude, Latitude: south[len(south)-1].Latitude}, + }, + ) + paths := [][]geodata.GeoPoint{ + solarCentralBandGeoPoints(north), + solarCentralBandGeoPoints(south), + solarCentralBandGeoPoints(centerLine), + } + for _, inputs := range [][][]geodata.GeoPoint{ + append(append([][]geodata.GeoPoint(nil), ribbons...), overlays...), + ribbons, + } { + candidates := make([][][]geodata.GeoPoint, 0, 2) + if polygons, err := geodata.UnionPolygons(inputs); err == nil { + candidates = append(candidates, polygons) + } + // At a polar two-limit contact, longitude/latitude is a singular chart: + // adjacent time-slice quads can be valid on the sphere but appear to + // reverse around the pole in the global union. Retry the same faces in a + // local gnomonic chart before selecting a disconnected fallback. + if polygons, ok := solarCentralLocalChartUnion(inputs); ok { + candidates = append(candidates, polygons) + } + // Split each temporal quad at the center-line interpolation. This is + // topologically equivalent away from a pole, but prevents a quad's + // diagonal from selecting the wrong side when the two limits wrap around + // a polar chart branch. + if len(overlays) == 0 { + pieces := solarCentralTwoLimitRibbonPieces(north, south, centerLine) + if len(pieces) > 0 { + if polygons, ok := solarCentralLocalChartUnion(pieces); ok { + candidates = append(candidates, polygons) + } + } + } + for _, polygons := range candidates { + if len(polygons) == 0 || len(polygons) != 1 || + geodata.SphericalPolygonsPathMissDistanceKM(polygons, paths, false) > 1 { + continue + } + refined := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + refined = append(refined, solarCentralBandRefineRingSpacing(polygon, 200)) + } + return refined, true + } + } + return nil, false +} + +// solarCentralLocalChartUnion performs a boolean union in a local tangent +// chart. Coordinates are scaled before entering the planar union so the +// generic geodata union cannot mistake chart values for global latitudes. +func solarCentralLocalChartUnion(inputs [][]geodata.GeoPoint) ([][]geodata.GeoPoint, bool) { + if len(inputs) == 0 { + return nil, false + } + toVector := func(point geodata.GeoPoint) [3]float64 { + latitude := point.Latitude * math.Pi / 180 + longitude := point.Longitude * math.Pi / 180 + cosLatitude := math.Cos(latitude) + return [3]float64{ + cosLatitude * math.Cos(longitude), + cosLatitude * math.Sin(longitude), + math.Sin(latitude), + } + } + dot := func(first, second [3]float64) float64 { + return first[0]*second[0] + first[1]*second[1] + first[2]*second[2] + } + norm := func(value [3]float64) float64 { + return math.Sqrt(dot(value, value)) + } + center := [3]float64{} + maximumAbsLatitude := 0.0 + polarSign := 1.0 + for _, polygon := range inputs { + for _, point := range openRing(polygon) { + if absLatitude := math.Abs(point.Latitude); absLatitude > maximumAbsLatitude { + maximumAbsLatitude = absLatitude + if point.Latitude < 0 { + polarSign = -1 + } else { + polarSign = 1 + } + } + vector := toVector(point) + center[0] += vector[0] + center[1] += vector[1] + center[2] += vector[2] + } + } + if maximumAbsLatitude >= 75 { + // A polar event is better conditioned in a chart centred on the pole + // than in the arithmetic mean of points whose longitudes wrap around it. + center = [3]float64{0, 0, polarSign} + } else { + centerNorm := norm(center) + if centerNorm <= 1e-12 { + return nil, false + } + center[0] /= centerNorm + center[1] /= centerNorm + center[2] /= centerNorm + } + globalNorth := [3]float64{0, 0, 1} + cross := func(first, second [3]float64) [3]float64 { + return [3]float64{ + first[1]*second[2] - first[2]*second[1], + first[2]*second[0] - first[0]*second[2], + first[0]*second[1] - first[1]*second[0], + } + } + east := cross(globalNorth, center) + if norm(east) <= 1e-12 { + east = cross([3]float64{1, 0, 0}, center) + } + eastNorm := norm(east) + if eastNorm <= 1e-12 { + return nil, false + } + east[0] /= eastNorm + east[1] /= eastNorm + east[2] /= eastNorm + north := cross(center, east) + northNorm := norm(north) + if northNorm <= 1e-12 { + return nil, false + } + north[0] /= northNorm + north[1] /= northNorm + north[2] /= northNorm + const chartScale = 0.5 + project := func(point geodata.GeoPoint) (geodata.GeoPoint, bool) { + vector := toVector(point) + denominator := dot(vector, center) + if denominator <= 0.02 { + return geodata.GeoPoint{}, false + } + return geodata.GeoPoint{ + Longitude: chartScale * dot(vector, east) / denominator * 180 / math.Pi, + Latitude: chartScale * dot(vector, north) / denominator * 180 / math.Pi, + }, true + } + unproject := func(point geodata.GeoPoint) geodata.GeoPoint { + x := point.Longitude / chartScale * math.Pi / 180 + y := point.Latitude / chartScale * math.Pi / 180 + vector := [3]float64{ + center[0] + x*east[0] + y*north[0], + center[1] + x*east[1] + y*north[1], + center[2] + x*east[2] + y*north[2], + } + length := norm(vector) + if length <= 1e-12 { + return geodata.GeoPoint{} + } + return geodata.GeoPoint{ + Longitude: normalizeLongitude(math.Atan2(vector[1]/length, vector[0]/length) * 180 / math.Pi), + Latitude: math.Asin(math.Max(-1, math.Min(1, vector[2]/length))) * 180 / math.Pi, + } + } + projected := make([][]geodata.GeoPoint, len(inputs)) + for polygonIndex, polygon := range inputs { + projected[polygonIndex] = make([]geodata.GeoPoint, len(polygon)) + for pointIndex, point := range polygon { + value, ok := project(point) + if !ok { + return nil, false + } + projected[polygonIndex][pointIndex] = value + } + } + merged, err := geodata.UnionPolygons(projected) + if err != nil { + return nil, false + } + result := make([][]geodata.GeoPoint, len(merged)) + for polygonIndex, polygon := range merged { + result[polygonIndex] = make([]geodata.GeoPoint, len(polygon)) + for pointIndex, point := range polygon { + result[polygonIndex][pointIndex] = unproject(point) + } + } + return result, true +} + +func solarCentralBandGeoPoints(points []eclipsecore.SolarEclipsePathPoint) []geodata.GeoPoint { + result := make([]geodata.GeoPoint, len(points)) + for index, point := range points { + result[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + return result +} + +func solarCentralBandRefineRingSpacing(points []geodata.GeoPoint, targetSpacingKM float64) []geodata.GeoPoint { + points = openRing(points) + if len(points) < 2 || targetSpacingKM <= 0 { + return append([]geodata.GeoPoint(nil), points...) + } + result := make([]geodata.GeoPoint, 0, len(points)) + for index, start := range points { + end := points[(index+1)%len(points)] + result = append(result, start) + steps := int(math.Ceil(solarCentralBandGeoPointDistanceKM(start, end) / targetSpacingKM)) + for step := 1; step < steps; step++ { + result = append(result, solarCentralBandSphericalInterpolate( + start, end, float64(step)/float64(steps), + )) + } + } + return result +} + +func solarCentralBandSphericalInterpolate( + first, second geodata.GeoPoint, + fraction float64, +) geodata.GeoPoint { + toVector := func(point geodata.GeoPoint) [3]float64 { + latitude := point.Latitude * math.Pi / 180 + longitude := point.Longitude * math.Pi / 180 + cosLatitude := math.Cos(latitude) + return [3]float64{ + cosLatitude * math.Cos(longitude), + cosLatitude * math.Sin(longitude), + math.Sin(latitude), + } + } + firstVector, secondVector := toVector(first), toVector(second) + dot := math.Max(-1, math.Min(1, + firstVector[0]*secondVector[0]+firstVector[1]*secondVector[1]+firstVector[2]*secondVector[2], + )) + angle := math.Acos(dot) + if angle <= 1e-12 { + return first + } + firstWeight := math.Sin((1-fraction)*angle) / math.Sin(angle) + secondWeight := math.Sin(fraction*angle) / math.Sin(angle) + x := firstWeight*firstVector[0] + secondWeight*secondVector[0] + y := firstWeight*firstVector[1] + secondWeight*secondVector[1] + z := firstWeight*firstVector[2] + secondWeight*secondVector[2] + return geodata.GeoPoint{ + Longitude: normalizeLongitude(math.Atan2(y, x) * 180 / math.Pi), + Latitude: math.Atan2(z, math.Hypot(x, y)) * 180 / math.Pi, + } +} + +func solarCentralBandInnerTransitionCaps( + footprints []eclipsecore.SolarEclipsePartialFootprint, +) [][]geodata.GeoPoint { + samples, err := solarCentralShadowSweepSamples(footprints) + if err != nil { + return nil + } + samples = geodata.DecimateOpenBoundarySweepSamples(samples, len(samples), 40) + return geodata.OpenBoundarySweepInnerCaps(samples, 500) +} + +func solarCentralBandContactCaps( + footprints []eclipsecore.SolarEclipsePartialFootprint, + startContact, endContact eclipsecore.SolarEclipsePathPoint, +) [][]geodata.GeoPoint { + if len(footprints) == 0 { + return nil + } + caps := make([][]geodata.GeoPoint, 0, 2) + appendCap := func(contact eclipsecore.SolarEclipsePathPoint, footprint eclipsecore.SolarEclipsePartialFootprint) { + segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries)) + for _, source := range footprint.Boundaries { + segment := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + segments = append(segments, segment) + } + boundary := openRing(geodata.JoinPolylineSegments(segments)) + if len(boundary) < 2 { + return + } + contactPoint := geodata.GeoPoint{Longitude: contact.Longitude, Latitude: contact.Latitude} + if solarCentralBandGeoPointDistanceKM(contactPoint, boundary[0]) > 2000 || + solarCentralBandGeoPointDistanceKM(contactPoint, boundary[len(boundary)-1]) > 2000 { + return + } + caps = append(caps, []geodata.GeoPoint{contactPoint, boundary[0], boundary[len(boundary)-1]}) + } + appendCap(startContact, footprints[0]) + appendCap(endContact, footprints[len(footprints)-1]) + return caps +} + +func solarCentralBandGeoPointDistanceKM(first, second geodata.GeoPoint) float64 { + lat1, lat2 := first.Latitude*math.Pi/180, second.Latitude*math.Pi/180 + dlat := lat2 - lat1 + dlon := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi) + h := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2) + return 6371.0088 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, h)))) +} + +func solarCentralTwoLimitAxisCappedPolygon( + north, south, centerLine []eclipsecore.SolarEclipsePathPoint, +) ([]geodata.GeoPoint, bool) { + if len(north) < 2 || len(north) != len(south) || len(centerLine) < 2 { + return nil, false + } + polygon := make([]geodata.GeoPoint, 0, len(north)+len(south)+2) + polygon = append(polygon, geodata.GeoPoint{ + Longitude: centerLine[0].Longitude, + Latitude: centerLine[0].Latitude, + }) + for _, point := range north { + polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + lastCenter := centerLine[len(centerLine)-1] + polygon = append(polygon, geodata.GeoPoint{ + Longitude: lastCenter.Longitude, + Latitude: lastCenter.Latitude, + }) + for index := len(south) - 1; index >= 0; index-- { + point := south[index] + polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + return polygon, true +} + +// The public limit series keeps the earlier/later U1/U4 contacts on both +// sides. For map rendering, the axis contacts are the canonical band caps; +// retaining both pairs creates two overlapping triangles at each horizon. +func solarCentralTwoLimitPresentationLimits( + northern, southern, centerLine []eclipsecore.SolarEclipsePathPoint, +) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint, bool) { + if len(northern) != len(southern) || len(northern) < 4 || len(centerLine) < 2 { + return nil, nil, false + } + start := centerLine[0].Time + end := centerLine[len(centerLine)-1].Time + if start.IsZero() || end.IsZero() || !start.Before(end) || + !northern[0].Time.Before(start) || !northern[len(northern)-1].Time.After(end) || + solarCentralBandPointDistanceKM(northern[0], southern[0]) > 0.001 || + solarCentralBandPointDistanceKM(northern[len(northern)-1], southern[len(southern)-1]) > 0.001 { + return nil, nil, false + } + first := 0 + for first < len(northern) && !northern[first].Time.After(start) { + first++ + } + last := first + for last < len(northern) && northern[last].Time.Before(end) { + last++ + } + if first == 0 || last >= len(northern) || last-first < 2 { + return nil, nil, false + } + for index := first; index < last; index++ { + if northern[index].Time.IsZero() || !northern[index].Time.Equal(southern[index].Time) { + return nil, nil, false + } + } + return northern[first:last], southern[first:last], true +} + +func solarCentralPathBandPolygons( + northern, southern []eclipsecore.SolarEclipsePathPoint, +) ([][]geodata.GeoPoint, string) { + if len(northern) < 2 || len(northern) != len(southern) { + return nil, "" + } + samples := make([]geodata.OpenBoundarySweepSample, 0, len(northern)) + for index := range northern { + if northern[index].Time.IsZero() || !northern[index].Time.Equal(southern[index].Time) { + return nil, "" + } + samples = append(samples, geodata.OpenBoundarySweepSample{ + Boundaries: [][]geodata.GeoPoint{{ + {Longitude: northern[index].Longitude, Latitude: northern[index].Latitude}, + {Longitude: southern[index].Longitude, Latitude: southern[index].Latitude}, + }}, + }) + } + polygons, err := geodata.OpenBoundarySweep(samples) + if err != nil { + return nil, "" + } + usable := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + if len(openRing(polygon)) >= 3 { + usable = append(usable, polygon) + } + } + if len(usable) == 0 { + return nil, "" + } + return usable, "central-cross-section-sweep" +} + +func solarCentralBandEndpointCaps( + northern, southern, centerLine []eclipsecore.SolarEclipsePathPoint, +) [][]geodata.GeoPoint { + if len(northern) == 0 || len(northern) != len(southern) || len(centerLine) == 0 { + return nil + } + caps := make([][]geodata.GeoPoint, 0, 2) + appendCap := func(center eclipsecore.SolarEclipsePathPoint, atStart bool) { + limitIndex := -1 + if atStart { + for index := range northern { + if northern[index].Time.After(center.Time) { + limitIndex = index + break + } + } + } else { + for index := len(northern) - 1; index >= 0; index-- { + if northern[index].Time.Before(center.Time) { + limitIndex = index + break + } + } + } + if limitIndex < 0 { + return + } + north, south := northern[limitIndex], southern[limitIndex] + if center.Time.IsZero() || north.Time.IsZero() || south.Time.IsZero() { + return + } + if solarCentralBandPointDistanceKM(center, north) > 3000 || solarCentralBandPointDistanceKM(center, south) > 3000 { + return + } + caps = append(caps, []geodata.GeoPoint{ + {Longitude: center.Longitude, Latitude: center.Latitude}, + {Longitude: north.Longitude, Latitude: north.Latitude}, + {Longitude: south.Longitude, Latitude: south.Latitude}, + }) + } + appendCap(centerLine[0], true) + appendCap(centerLine[len(centerLine)-1], false) + return caps +} + +func solarCentralBandPointDistanceKM(first, second eclipsecore.SolarEclipsePathPoint) float64 { + lat1, lat2 := first.Latitude*math.Pi/180, second.Latitude*math.Pi/180 + dlat := lat2 - lat1 + dlon := math.Mod((second.Longitude-first.Longitude)*math.Pi/180+math.Pi, 2*math.Pi) - math.Pi + h := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2) + return 6371.0088 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, h)))) +} + +// Near a high-latitude apex a traced limit curve runs through a cusp: its time +// parameterisation folds back on itself, so the ribbon ring built from the two +// limits crosses itself and the exported band twists. The swept region is +// bounded by the envelope, so the samples inside the fold are dropped from both +// sides together — the pairs stay time-aligned, only the fold disappears. +const solarCentralTwoLimitFoldToleranceDegrees = 0.05 + +func solarCentralTwoLimitEnvelopeSamples( + northern, southern []eclipsecore.SolarEclipsePathPoint, +) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint) { + drop := make(map[int]bool) + solarCentralTwoLimitMarkFolds(northern, drop) + solarCentralTwoLimitMarkFolds(southern, drop) + if len(drop) == 0 || len(drop) >= len(northern)-2 { + return northern, southern + } + keptNorth := make([]eclipsecore.SolarEclipsePathPoint, 0, len(northern)-len(drop)) + keptSouth := make([]eclipsecore.SolarEclipsePathPoint, 0, len(southern)-len(drop)) + for index := range northern { + if drop[index] { + continue + } + keptNorth = append(keptNorth, northern[index]) + keptSouth = append(keptSouth, southern[index]) + } + return keptNorth, keptSouth +} + +func solarCentralTwoLimitMarkFolds(limits []eclipsecore.SolarEclipsePathPoint, drop map[int]bool) { + if len(limits) < 4 { + return + } + // Project the curve onto its end-to-end tangent. Longitude alone is + // degenerate at polar apices and cannot distinguish a real turn from an + // antimeridian wrap; the local east component is scaled by latitude and the + // north component is retained, so both cusp types are detected. + lat0 := limits[0].Latitude * math.Pi / 180 + dlon := math.Mod((limits[len(limits)-1].Longitude-limits[0].Longitude)+180, 360) - 180 + dx, dy := dlon*math.Cos(lat0), limits[len(limits)-1].Latitude-limits[0].Latitude + length := math.Hypot(dx, dy) + if length <= 1e-9 { + return + } + dx, dy = dx/length, dy/length + extreme := 0.0 + for index, point := range limits { + deltaLon := math.Mod((point.Longitude-limits[0].Longitude)+180, 360) - 180 + delta := deltaLon*math.Cos(lat0)*dx + (point.Latitude-limits[0].Latitude)*dy + if index == 0 { + extreme = delta + continue + } + if delta-extreme < -solarCentralTwoLimitFoldToleranceDegrees { + drop[index] = true + continue + } + if delta > extreme { + extreme = delta + } } - return polygon, nil } func pairedLimitPolygon( @@ -522,6 +3627,47 @@ func appendSolarPathLine( return appendTimedLineFeature(features, solarEclipseEvent, role, samples, properties) } +func appendSolarSegmentedPathLine( + features []feature, + role string, + segments [][]eclipsecore.SolarEclipsePathPoint, + properties map[string]interface{}, + requireIncreasingTimes bool, +) ([]feature, error) { + samples := make([][]pathSample, len(segments)) + for index, segment := range segments { + samples[index] = sampleSphericalMapPath(solarPathSamples(segment)) + } + value, times, err := timedMultiLineGeometryFromSegmentsWithTimeOrder(samples, requireIncreasingTimes) + if err != nil { + return nil, fmt.Errorf("geojson: %s: %w", role, err) + } + properties = cloneProperties(properties) + properties["times"] = times + return append(features, newFeature(solarEclipseEvent, role, value, properties)), nil +} + +func appendSolarRiseSetCurveFeatures( + features []feature, + curves []eclipsecore.SolarEclipseRiseSetCurve, + properties map[string]interface{}, +) ([]feature, error) { + for _, curve := range curves { + curveProperties := cloneProperties(properties) + curveProperties["phase"] = string(curve.Phase) + curveProperties["horizon"] = string(curve.Direction) + curveProperties["body"] = "sun" + var err error + features, err = appendSolarSegmentedPathLine( + features, "visibility-boundary", curve.Segments, curveProperties, true, + ) + if err != nil { + return nil, err + } + } + return features, nil +} + func solarPathSamples(points []eclipsecore.SolarEclipsePathPoint) []pathSample { samples := make([]pathSample, len(points)) for index, point := range points { @@ -535,7 +3681,7 @@ func solarPathSample(point eclipsecore.SolarEclipsePathPoint) pathSample { } func solarEclipseMetadata(info eclipsecore.SolarEclipseInfo) map[string]interface{} { - return map[string]interface{}{ + properties := map[string]interface{}{ "eclipse_type": string(info.Type), "model": string(info.Model), "centrality": string(info.Centrality), @@ -547,6 +3693,83 @@ func solarEclipseMetadata(info eclipsecore.SolarEclipseInfo) map[string]interfac "central_begin_on_earth": formatTime(info.CentralBeginOnEarth), "central_end_on_earth": formatTime(info.CentralEndOnEarth), } + if info.CentralDuration > 0 { + // The catalogued maximum duration of the central phase, measured at the + // greatest eclipse point. + properties["central_duration_seconds"] = info.CentralDuration.Seconds() + } + if info.HasCentral { + properties["central_duration"] = info.CentralDuration.String() + } + return properties +} + +// dropDegenerateMultiPolygonRings 删除已经没有面积的面(顶点少于三个互不相同的点,或平面 +// 面积为零),保留其余面;全部退化时原样返回,避免把"没有有效环"变成导出失败。 +// dropDegenerateMultiPolygonRings removes polygons with no area left (fewer than three +// distinct vertices, or zero planar area) while keeping the rest. When every polygon is +// degenerate the value is returned unchanged so an empty result never becomes an export error. +func dropDegenerateMultiPolygonRings(value geometry) geometry { + polygons, ok := value.Coordinates.([][][][]float64) + if !ok { + return value + } + kept := make([][][][]float64, 0, len(polygons)) + for _, polygon := range polygons { + hasArea := false + for _, ring := range polygon { + if !degenerateGeoJSONRing(ring) { + hasArea = true + break + } + } + if hasArea { + kept = append(kept, polygon) + } + } + if len(kept) == 0 || len(kept) == len(polygons) { + return value + } + return geometry{Type: value.Type, Coordinates: kept} +} + +// degenerateGeoJSONRing 判断导出环是否已经没有面积。 +// degenerateGeoJSONRing reports whether an exported ring has no area left. +func degenerateGeoJSONRing(ring [][]float64) bool { + distinct := 0 + for index, point := range ring { + if len(point) < 2 { + continue + } + if index == 0 || !sameDegenerateRingPoint(ring[index-1], point) { + distinct++ + } + } + if distinct > 1 && sameDegenerateRingPoint(ring[0], ring[len(ring)-1]) { + distinct-- + } + if distinct < 3 { + return true + } + area := 0.0 + for index := range ring { + next := ring[(index+1)%len(ring)] + if len(ring[index]) < 2 || len(next) < 2 { + return false + } + area += ring[index][0]*next[1] - next[0]*ring[index][1] + } + return math.Abs(area/2) < 1e-12 +} + +// sameDegenerateRingPoint 比较同一环上的两个导出点。 +// sameDegenerateRingPoint compares two exported points of one ring. +func sameDegenerateRingPoint(first, second []float64) bool { + if len(first) < 2 || len(second) < 2 { + return false + } + return math.Abs(math.Remainder(first[0]-second[0], 360)) < 1e-9 && + math.Abs(first[1]-second[1]) < 1e-9 } func lunarEclipseMetadata(info eclipsecore.LunarEclipseInfo) map[string]interface{} { diff --git a/geojson/eclipse_antimeridian_internal_test.go b/geojson/eclipse_antimeridian_internal_test.go new file mode 100644 index 0000000..59f79e5 --- /dev/null +++ b/geojson/eclipse_antimeridian_internal_test.go @@ -0,0 +1,39 @@ +package geojson + +import ( + "math" + "testing" + + eclipsecore "b612.me/astro/eclipse" +) + +// solarCentralBandPointSegmentKM 用于判断解析限线与导出带边界的偏离量(超过 +// solarCentralBandSnapToleranceKM 就会改用环导出边)。跨换日线的限线必须先把经度差 +// 归约到 ±180°,否则会被判成数万公里之外。 +// solarCentralBandPointSegmentKM measures how far an analytic limit line deviates from the +// exported band boundary (beyond solarCentralBandSnapToleranceKM the ring edge wins). A +// limit line crossing the antimeridian must wrap its longitude difference to ±180°, or it +// reads as tens of thousands of kilometres away. +func TestSolarCentralBandPointSegmentWrapsAtAntimeridian(t *testing.T) { + point := eclipsecore.SolarEclipsePathPoint{Longitude: 179.99, Latitude: 10} + first := eclipsecore.SolarEclipsePathPoint{Longitude: 179.95, Latitude: 10} + second := eclipsecore.SolarEclipsePathPoint{Longitude: -179.95, Latitude: 10} + + // 该点落在这一段的内部(180° 处是段内点),距离必须是 0。 + // The point lies inside the segment (which passes 180°), so the distance is zero. + if got := solarCentralBandPointSegmentKM(point, first, second); math.Abs(got) > 0.5 { + t.Fatalf("point on an antimeridian-crossing segment measured %v km away", got) + } + // 反向跨越(从 +179.95 到 -179.95 之外)同样要归约。 + // Wrapping must also hold when the segment runs the other way. + if got := solarCentralBandPointSegmentKM(point, second, first); math.Abs(got) > 0.5 { + t.Fatalf("reversed segment measured %v km away", got) + } + // 普通(非换日线)段的距离不受影响:与水平段相差 1° 纬度 ≈ 111.32 km。 + // An ordinary segment keeps its plain latitudinal distance: 1° of latitude off a + // horizontal segment is 111.32 km. + far := eclipsecore.SolarEclipsePathPoint{Longitude: 179.99, Latitude: 11} + if got := solarCentralBandPointSegmentKM(far, first, second); math.Abs(got-111.32) > 0.5 { + t.Fatalf("plain segment distance = %v km, want about 111.32 km", got) + } +} diff --git a/geojson/eclipse_isochrone_test.go b/geojson/eclipse_isochrone_test.go new file mode 100644 index 0000000..c4a328b --- /dev/null +++ b/geojson/eclipse_isochrone_test.go @@ -0,0 +1,243 @@ +package geojson_test + +// greatest-time-line 逐支路导出,time 与 jde 取自等时线本身,几何与支路逐点一致。 + +import ( + "bytes" + "encoding/json" + "math" + "strings" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func cloneSolarGreatestTimeContours( + source []eclipse.SolarEclipseGreatestTimeContour, +) []eclipse.SolarEclipseGreatestTimeContour { + result := make([]eclipse.SolarEclipseGreatestTimeContour, len(source)) + for index, contour := range source { + segments := make([][]eclipse.SolarEclipsePathPoint, len(contour.Segments)) + for segmentIndex, segment := range contour.Segments { + segments[segmentIndex] = append([]eclipse.SolarEclipsePathPoint(nil), segment...) + } + contour.Segments = segments + result[index] = contour + } + return result +} + +func TestMarshalSolarEclipseGreatestTimeLines(t *testing.T) { + location := time.FixedZone("UTC+8", 8*3600) + stepped := eclipse.SolarEclipsePartialFootprintOptions{ + Step: 20 * time.Minute, BoundaryPoints: 24, DisableRiseSet: true, + GreatestTimeStep: 30 * time.Minute, + } + valued := stepped + valued.GreatestTimeStep = 0 + valued.GreatestTimeValues = []time.Time{ + time.Date(2009, time.July, 22, 9, 0, 0, 0, location), + time.Date(2009, time.July, 22, 10, 0, 0, 0, location), + time.Date(2009, time.July, 22, 11, 0, 0, 0, location), + } + for _, fixture := range []struct { + name string + date time.Time + options eclipse.SolarEclipsePartialFootprintOptions + central bool + wantMultiBranch bool + }{ + { + name: "stepped-2021", date: time.Date(2021, time.June, 10, 0, 0, 0, 0, time.UTC), + options: stepped, wantMultiBranch: true, + }, + { + name: "valued-2009-central", date: time.Date(2009, time.July, 22, 0, 0, 0, 0, location), + options: valued, central: true, + }, + } { + t.Run(fixture.name, func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(fixture.date, fixture.options) + if !ok || len(partial.GreatestTimeContours) == 0 { + t.Fatal("expected greatest-time contours") + } + multiBranch := false + for _, contour := range partial.GreatestTimeContours { + if len(contour.Segments) > 1 { + multiBranch = true + } + } + if multiBranch != fixture.wantMultiBranch { + t.Fatalf("fixture multi-branch=%v, want %v", multiBranch, fixture.wantMultiBranch) + } + var central *eclipse.SolarEclipsePath + if fixture.central { + path, pathOK := eclipse.SolarEclipseCentralPath( + fixture.date, eclipse.SolarEclipsePathOptions{Step: 10 * time.Minute}, + ) + if !pathOK { + t.Fatal("expected central path") + } + central = &path + } + data, err := geojson.MarshalSolarEclipse(partial, central) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + lines := featuresWithRole(decodeCollection(t, data), "greatest-time-line") + branches := 0 + for _, contour := range partial.GreatestTimeContours { + branches += len(contour.Segments) + } + if len(lines) != branches { + t.Fatalf("greatest-time-line features=%d, branches=%d", len(lines), branches) + } + lineIndex := 0 + for _, contour := range partial.GreatestTimeContours { + for _, segment := range contour.Segments { + line := lines[lineIndex] + lineIndex++ + if line.Geometry.Type != "MultiLineString" { + t.Fatalf("line %d geometry=%q, want MultiLineString", lineIndex-1, line.Geometry.Type) + } + if got, want := line.Properties["time"], contour.Time.UTC().Format(time.RFC3339Nano); got != want { + t.Fatalf("line %d time=%v, want %v", lineIndex-1, got, want) + } + if got, ok := line.Properties["jde"].(float64); !ok || got != contour.JDE { + t.Fatalf("line %d jde=%v, want %v", lineIndex-1, line.Properties["jde"], contour.JDE) + } + assertTimedLineAligned(t, line) + var coordinates [][][]float64 + if err := json.Unmarshal(line.Geometry.Coordinates, &coordinates); err != nil { + t.Fatalf("decode line %d: %v", lineIndex-1, err) + } + if len(coordinates) != 1 || len(coordinates[0]) != len(segment) { + t.Fatalf("line %d coordinate lines=%d points=%d, branch points=%d", + lineIndex-1, len(coordinates), coordinateCount(coordinates), len(segment)) + } + for pointIndex, point := range segment { + if coordinates[0][pointIndex][0] != point.Longitude || + coordinates[0][pointIndex][1] != point.Latitude { + t.Fatalf("line %d point %d=(%v, %v), branch point=(%v, %v)", + lineIndex-1, pointIndex, coordinates[0][pointIndex][0], + coordinates[0][pointIndex][1], point.Longitude, point.Latitude) + } + } + } + } + }) + } +} + +func coordinateCount(coordinates [][][]float64) int { + count := 0 + for _, line := range coordinates { + count += len(line) + } + return count +} + +func TestMarshalSolarEclipseRejectsInvalidGreatestTimeContours(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + base, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 20 * time.Minute, BoundaryPoints: 24, DisableRiseSet: true, + GreatestTimeValues: []time.Time{ + time.Date(2009, time.July, 22, 1, 0, 0, 0, time.UTC), + time.Date(2009, time.July, 22, 2, 0, 0, 0, time.UTC), + }, + }) + if !ok || len(base.GreatestTimeContours) == 0 || len(base.GreatestTimeContours[0].Segments) == 0 { + t.Fatal("expected greatest-time contours") + } + for _, testCase := range []struct { + name string + mutate func([]eclipse.SolarEclipseGreatestTimeContour) + }{ + {name: "nan-longitude", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].Segments[0][0].Longitude = math.NaN() + }}, + {name: "infinite-latitude", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].Segments[0][0].Latitude = math.Inf(1) + }}, + {name: "longitude-out-of-range", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].Segments[0][0].Longitude = 180.5 + }}, + {name: "latitude-out-of-range", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].Segments[0][0].Latitude = -90.5 + }}, + {name: "single-point-branch", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].Segments[0] = contours[0].Segments[0][:1] + }}, + {name: "zero-jde", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].JDE = 0 + }}, + {name: "nan-jde", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].JDE = math.NaN() + }}, + {name: "zero-time", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].Time = time.Time{} + }}, + {name: "zero-point-time", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].Segments[0][0].Time = time.Time{} + }}, + {name: "no-branches", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) { + contours[0].Segments = nil + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + partial := base + partial.GreatestTimeContours = cloneSolarGreatestTimeContours(base.GreatestTimeContours) + testCase.mutate(partial.GreatestTimeContours) + if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil { + t.Fatal("invalid greatest-time contour was accepted") + } else if !strings.Contains(err.Error(), "greatest-time contour") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestMarshalSolarEclipseWithoutGreatestTimeLinesIsUnchanged(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + options := eclipse.SolarEclipsePartialFootprintOptions{ + Step: 20 * time.Minute, BoundaryPoints: 24, DisableRiseSet: true, + } + plain, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(date, options) + if !ok { + t.Fatal("expected solar partial footprints") + } + first, err := geojson.MarshalSolarEclipse(plain, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + second, err := geojson.MarshalSolarEclipse(plain, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + if !bytes.Equal(first, second) { + t.Fatal("default solar eclipse export is not byte stable") + } + if bytes.Contains(first, []byte("greatest-time-line")) { + t.Fatal("default solar eclipse export contains greatest-time lines") + } + requested := options + requested.GreatestTimeValues = []time.Time{ + time.Date(2009, time.July, 22, 1, 0, 0, 0, time.UTC), + time.Date(2009, time.July, 22, 2, 0, 0, 0, time.UTC), + } + withLines, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(date, requested) + if !ok || len(withLines.GreatestTimeContours) == 0 { + t.Fatal("expected greatest-time contours") + } + cleared := withLines + cleared.GreatestTimeContours = nil + third, err := geojson.MarshalSolarEclipse(cleared, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + if !bytes.Equal(first, third) { + t.Fatal("clearing greatest-time contours changes the export") + } +} diff --git a/geojson/geojson.go b/geojson/geojson.go index ad4afab..1dd7791 100644 --- a/geojson/geojson.go +++ b/geojson/geojson.go @@ -6,9 +6,13 @@ // 每个要素都包含 event 和 role 属性。带时间的 MultiLineString 要素还包含与坐标段对齐的嵌套 times 数组。 // Times 编码为 UTC RFC 3339 字符串;路径采样由上游日月食和月掩选项控制后再传入本包。 // WithTimeMarkers 变体还会追加 role 为 time-marker 的 Point 要素,标签按请求地点格式化。 +// 月掩 fallback 掩带通常为 MultiPolygon;支路跳变留下的孤立零时长截面以 MultiLineString 表达, +// 与连续扫掠同时存在时使用 GeometryCollection,避免用虚假多边形重新连接跳变。 // Every feature has event and role properties. Timed MultiLineString features also contain a nested times array aligned with their coordinate segments. // Times are encoded as UTC RFC 3339 strings. Path sampling is controlled by the source eclipse and occultation options before values reach this package. // The WithTimeMarkers variants additionally append Point Features whose role is time-marker and whose label is formatted for the requested location. +// Fallback occultation bands normally use MultiPolygon. Isolated zero-duration sections left by branch changes use MultiLineString, +// combined with a continuous sweep in a GeometryCollection, so discontinuities are never reconnected by a false polygon. package geojson import ( @@ -39,7 +43,8 @@ type feature struct { type geometry struct { Type string `json:"type"` - Coordinates interface{} `json:"coordinates"` + Coordinates interface{} `json:"coordinates,omitempty"` + Geometries []geometry `json:"geometries,omitempty"` } type pathSample struct { @@ -69,6 +74,14 @@ func marshalFeatureCollection(features []feature) ([]byte, error) { if len(features) == 0 { return nil, fmt.Errorf("geojson: no geographic features") } + return encodeFeatureCollection(features) +} + +func marshalEmptyFeatureCollection() ([]byte, error) { + return encodeFeatureCollection(make([]feature, 0)) +} + +func encodeFeatureCollection(features []feature) ([]byte, error) { value, err := json.Marshal(featureCollection{Type: featureCollectionType, Features: features}) if err != nil { return nil, fmt.Errorf("geojson: encode feature collection: %w", err) @@ -277,21 +290,54 @@ func pointGeometry(longitude, latitude float64) (geometry, error) { } func timedMultiLineGeometry(points []pathSample) (geometry, [][]string, error) { - segments, err := splitTimedLine(points) - if err != nil { - return geometry{}, nil, err - } - coordinates := make([][][]float64, 0, len(segments)) - times := make([][]string, 0, len(segments)) - for _, segment := range segments { - line := make([][]float64, len(segment)) - lineTimes := make([]string, len(segment)) - for index, point := range segment { - line[index] = []float64{point.Longitude, point.Latitude} - lineTimes[index] = formatTime(point.Time) + return timedMultiLineGeometryFromSegments([][]pathSample{points}) +} + +func timedMultiLineGeometryFromSegments(sourceSegments [][]pathSample) (geometry, [][]string, error) { + return timedMultiLineGeometryFromSegmentsWithTimeOrder(sourceSegments, true) +} + +func timedMultiLineGeometryFromSegmentsWithTimeOrder( + sourceSegments [][]pathSample, + requireIncreasingTimes bool, +) (geometry, [][]string, error) { + coordinates := make([][][]float64, 0, len(sourceSegments)) + times := make([][]string, 0, len(sourceSegments)) + for _, source := range sourceSegments { + if len(source) == 0 { + continue } - coordinates = append(coordinates, line) - times = append(times, lineTimes) + if len(source) == 1 { + point := source[0] + if point.Time.IsZero() { + return geometry{}, nil, fmt.Errorf("geojson: timed line contains a zero time") + } + if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { + return geometry{}, nil, err + } + position := []float64{point.Longitude, point.Latitude} + formattedTime := formatTime(point.Time) + coordinates = append(coordinates, [][]float64{position, append([]float64(nil), position...)}) + times = append(times, []string{formattedTime, formattedTime}) + continue + } + segments, err := splitTimedLine(source, requireIncreasingTimes) + if err != nil { + return geometry{}, nil, err + } + for _, segment := range segments { + line := make([][]float64, len(segment)) + lineTimes := make([]string, len(segment)) + for index, point := range segment { + line[index] = []float64{point.Longitude, point.Latitude} + lineTimes[index] = formatTime(point.Time) + } + coordinates = append(coordinates, line) + times = append(times, lineTimes) + } + } + if len(coordinates) == 0 { + return geometry{}, nil, fmt.Errorf("geojson: line has no valid segments") } return geometry{Type: "MultiLineString", Coordinates: coordinates}, times, nil } @@ -309,7 +355,7 @@ func geoMultiLineGeometry(points []geodata.GeoPoint, closeLine bool) (geometry, if closeLine && !geodata.SameGeoPoint(geographic[0], geographic[len(geographic)-1]) { geographic = append(geographic, geographic[0]) } - segments := geodata.PolylineSegments(geographic, geodata.ProjectionEquirectangular) + segments := geodata.PolylineSegments(geographic, geodata.ClipView{Projection: geodata.ProjectionEquirectangular}) coordinates := make([][][]float64, 0, len(segments)) for _, segment := range segments { if len(segment) < 2 { @@ -327,7 +373,24 @@ func geoMultiLineGeometry(points []geodata.GeoPoint, closeLine bool) (geometry, return geometry{Type: "MultiLineString", Coordinates: coordinates}, nil } +// multiPolygonFillGeometry tags fill-only polygons, which tolerate a coarser +// map chord bound than the path strokes. +func multiPolygonFillGeometry(polygons [][]geodata.GeoPoint) (geometry, error) { + return multiPolygonGeometryWithin( + polygons, sphericalFillChordLimitKM, sphericalFillChordErrorDegrees, + ) +} + func multiPolygonGeometry(polygons [][]geodata.GeoPoint) (geometry, error) { + return multiPolygonGeometryWithin( + polygons, sphericalMapChordLimitKM, sphericalMapChordErrorDegrees, + ) +} + +func multiPolygonGeometryWithin( + polygons [][]geodata.GeoPoint, + chordLimitKM, chordErrorDegrees float64, +) (geometry, error) { fragments := make([][]geodata.GeoPoint, 0, len(polygons)) for index, polygon := range polygons { polygon = openRing(polygon) @@ -340,7 +403,10 @@ func multiPolygonGeometry(polygons [][]geodata.GeoPoint) (geometry, error) { } } fragments = append(fragments, - geodata.PolygonFragments(polygon, geodata.ProjectionEquirectangular)...) + geodata.PolygonFragments( + sampleSphericalMapRingWithin(polygon, chordLimitKM, chordErrorDegrees), + geodata.ClipView{Projection: geodata.ProjectionEquirectangular}, + )...) } return multiPolygonGeometryFromFragments(fragments) } @@ -403,7 +469,7 @@ func polygonArea(points []geodata.GeoPoint) float64 { return area / 2 } -func splitTimedLine(points []pathSample) ([][]pathSample, error) { +func splitTimedLine(points []pathSample, requireIncreasingTimes bool) ([][]pathSample, error) { if len(points) < 2 { return nil, fmt.Errorf("geojson: line requires at least two points") } @@ -414,7 +480,7 @@ func splitTimedLine(points []pathSample) ([][]pathSample, error) { if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { return nil, err } - if index > 0 && !point.Time.After(points[index-1].Time) { + if requireIncreasingTimes && index > 0 && !point.Time.After(points[index-1].Time) { return nil, fmt.Errorf("geojson: timed line times must be strictly increasing") } } diff --git a/geojson/geojson_test.go b/geojson/geojson_test.go index 8cfc453..ba1aa49 100644 --- a/geojson/geojson_test.go +++ b/geojson/geojson_test.go @@ -2,12 +2,14 @@ package geojson_test import ( "encoding/json" + "fmt" "math" "testing" "time" "b612.me/astro/eclipse" "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" "b612.me/astro/moon" ) @@ -22,9 +24,26 @@ type decodedFeature struct { Geometry struct { Type string `json:"type"` Coordinates json.RawMessage `json:"coordinates"` + Geometries json.RawMessage `json:"geometries"` } `json:"geometry"` } +func solarGeoJSONMapClosureEdge(first, second []float64) bool { + return len(first) >= 2 && len(second) >= 2 && + (math.Abs(first[1]) >= 89.999999 || math.Abs(second[1]) >= 89.999999 || + math.Abs(first[0]) == 180 && first[0] == second[0]) +} + +// solarGeoJSONScanCollection 是扫描类测试读取导出集合的最小结构:只需要属性与几何坐标。 +type solarGeoJSONScanCollection struct { + Features []struct { + Properties map[string]interface{} `json:"properties"` + Geometry struct { + Coordinates json.RawMessage `json:"coordinates"` + } `json:"geometry"` + } `json:"features"` +} + func TestMarshalSolarEclipseFeatureCollection(t *testing.T) { date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ @@ -45,12 +64,1020 @@ func TestMarshalSolarEclipseFeatureCollection(t *testing.T) { } collection := decodeCollection(t, data) assertRoles(t, collection, - "partial-footprint", "central-band", "center-line", "north-limit", "south-limit", "greatest") + "partial-footprint", "partial-band", "central-band", "center-line", "north-limit", "south-limit", "greatest") assertCollectionCoordinates(t, collection) + partialBand := featureWithRole(t, collection, "partial-band") + if partialBand.Geometry.Type != "MultiPolygon" { + t.Fatalf("partial-band geometry=%q, want MultiPolygon", partialBand.Geometry.Type) + } + if partialBand.Properties["source"] != "zero-magnitude-envelope+horizon-boundary" { + t.Fatalf("partial-band source=%v", partialBand.Properties["source"]) + } assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band")) assertTimedLineAligned(t, featureWithRole(t, collection, "center-line")) } +func TestMarshalSolarEclipse20090722BuildsContinuousPartialBand(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, + }) + if !ok { + t.Fatal("expected 2009-07-22 solar eclipse") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + collection := decodeCollection(t, data) + overlays := featuresWithRole(collection, "partial-band") + if len(overlays) != 1 { + t.Fatalf("partial-band count=%d, want one authoritative visibility region", len(overlays)) + } + if overlays[0].Properties["source"] != "zero-magnitude-envelope+horizon-boundary" { + t.Fatalf("partial-band source=%v", overlays[0].Properties["source"]) + } + assertClosedMultiPolygon(t, overlays[0]) + assertCollectionCoordinates(t, collection) + var polygons [][][][]float64 + if err := json.Unmarshal(overlays[0].Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode partial-band: %v", err) + } + if len(polygons) < 2 { + t.Fatalf("partial-band fragments=%d, want antimeridian-safe split geometry", len(polygons)) + } + for footprintIndex, footprint := range partial.Footprints { + for boundaryIndex, boundary := range footprint.Boundaries { + if len(boundary) == 0 { + continue + } + step := (len(boundary) + 7) / 8 + for pointIndex := 0; pointIndex < len(boundary); pointIndex += step { + point := []float64{boundary[pointIndex].Longitude, boundary[pointIndex].Latitude} + if !geoJSONMultiPolygonContains(polygons, point[0], point[1]) && + geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 5 { + t.Fatalf("partial footprint %d boundary %d point %d protrudes outside the continuous band", + footprintIndex, boundaryIndex, pointIndex) + } + } + } + } + authoritativeLines := solarPartialBandAuthoritativeLines(partial) + for polygonIndex, polygon := range polygons { + for ringIndex, ring := range polygon { + for pointIndex, point := range ring { + if distance := geoJSONPointToLinesDistanceKM(point, authoritativeLines); distance > 5 { + t.Fatalf("partial-band polygon %d ring %d point %d is %.1f km from the zero-magnitude or horizon boundary", + polygonIndex, ringIndex, pointIndex, distance) + } + } + } + } +} + +func TestMarshalSolarEclipsePartialBandFallsBackWithoutRiseSetTopology(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 48, DisableRiseSet: true, + }) + if !ok { + t.Fatal("expected 2009-07-22 solar eclipse") + } + if len(partial.PartialBandContours) != 0 || len(partial.RiseSetCurves) != 0 { + t.Fatal("disabled rise/set topology unexpectedly produced authoritative boundary lines") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "partial-band") + if source := band.Properties["source"]; source != "open-boundary-endpoint-outlines" { + t.Fatalf("fallback partial-band source=%v", source) + } + assertClosedMultiPolygon(t, band) +} + +func solarPartialBandAuthoritativeLines(partial eclipse.SolarEclipsePartialFootprintsInfo) [][][]float64 { + lines := make([][][]float64, 0, len(partial.PartialBandContours)+12) + appendSegment := func(segment []eclipse.SolarEclipsePathPoint) { + line := make([][]float64, len(segment)) + for index, point := range segment { + line[index] = []float64{point.Longitude, point.Latitude} + } + lines = append(lines, line) + } + for _, contour := range partial.PartialBandContours { + appendSegment(contour) + } + for _, curve := range partial.RiseSetCurves { + for _, segment := range curve.Segments { + appendSegment(segment) + } + } + return lines +} + +func geoJSONPointToLinesDistanceKM(point []float64, lines [][][]float64) float64 { + minimum := math.Inf(1) + for _, line := range lines { + for index := 1; index < len(line); index++ { + minimum = math.Min(minimum, geoJSONPointSegmentDistanceKM(point, line[index-1], line[index])) + } + } + return minimum +} + +func TestMarshalSolarEclipse20350902RiseSetCurveOrder(t *testing.T) { + date := time.Date(2035, time.September, 2, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0}, + }) + if !ok { + t.Fatal("expected solar partial footprints") + } + for curveIndex, curve := range partial.RiseSetCurves { + for segmentIndex, segment := range curve.Segments { + for pointIndex := 1; pointIndex < len(segment); pointIndex++ { + if !segment[pointIndex].Time.After(segment[pointIndex-1].Time) { + t.Fatalf("curve=%d phase=%s direction=%s segment=%d point=%d previous=%s current=%s", curveIndex, curve.Phase, curve.Direction, segmentIndex, pointIndex, segment[pointIndex-1].Time.Format(time.RFC3339Nano), segment[pointIndex].Time.Format(time.RFC3339Nano)) + } + } + } + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute}) + if !ok { + t.Fatal("expected solar central path") + } + if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } +} + +func TestMarshalSolarEclipse19851101CentralLimitsHaveStrictTimes(t *testing.T) { + date := time.Date(1985, time.November, 1, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 36, + }) + if !ok { + t.Fatal("expected 1985-11-01 partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 10 * time.Minute, + }) + if !ok { + t.Fatal("expected 1985-11-01 central path") + } + if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } +} + +func TestMarshalSolarEclipseExportsVisibilityAntumbralAndMagnitudeLines(t *testing.T) { + date := time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, + BoundaryPoints: 24, + CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{0.4, 0.8, 1.0}, + }) + if !ok { + t.Fatal("expected non-central annular partial footprints") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + collection := decodeCollection(t, data) + assertRoles(t, collection, "visibility-boundary", "central-shadow-footprint", "central-band", "magnitude-line", "greatest") + if got := len(featuresWithRole(collection, "central-shadow-footprint")); got < 3 { + t.Fatalf("central-shadow-footprint count=%d, want at least 3", got) + } + // 被地平线切断的中心影足迹仍然是区域;物理边界另出 central-shadow-boundary 供描边。 + // A horizon-cut central-shadow footprint is still a region; its physical boundary + // is exported separately as central-shadow-boundary. + openFootprints := 0 + for _, footprint := range featuresWithRole(collection, "central-shadow-footprint") { + if footprint.Geometry.Type != "MultiPolygon" { + t.Fatalf("central-shadow-footprint geometry=%q, want MultiPolygon", footprint.Geometry.Type) + } + if closed, ok := footprint.Properties["source_boundary_closed"].(bool); !ok || !closed { + openFootprints++ + } + } + boundaries := featuresWithRole(collection, "central-shadow-boundary") + if len(boundaries) != openFootprints { + t.Fatalf("central-shadow-boundary count=%d, want %d horizon-cut footprints", len(boundaries), openFootprints) + } + for _, boundary := range boundaries { + if boundary.Geometry.Type != "MultiLineString" { + t.Fatalf("central-shadow-boundary geometry=%q, want MultiLineString", boundary.Geometry.Type) + } + } + centralBand := featureWithRole(t, collection, "central-band") + assertClosedMultiPolygon(t, centralBand) + var centralBandPolygons [][][][]float64 + if err := json.Unmarshal(centralBand.Geometry.Coordinates, ¢ralBandPolygons); err != nil { + t.Fatalf("decode central-band: %v", err) + } + if len(centralBandPolygons) != 1 { + t.Fatalf("non-central central-band polygon count=%d, want one continuous sweep", len(centralBandPolygons)) + } + if centralBand.Properties["source"] != "besselian-critical-envelope" { + t.Fatalf("central-band source=%v, want besselian-critical-envelope", centralBand.Properties["source"]) + } + lines := featuresWithRole(collection, "magnitude-line") + if len(lines) != 2 { + t.Fatalf("magnitude-line count=%d, want one MultiLineString per requested magnitude", len(lines)) + } + seen := make(map[string]bool) + for _, line := range lines { + magnitude, ok := line.Properties["magnitude"].(float64) + if !ok || magnitude <= 0 || magnitude > 1 { + t.Fatalf("invalid magnitude property: %#v", line.Properties["magnitude"]) + } + if line.Geometry.Type != "MultiLineString" { + t.Fatalf("magnitude %.1f geometry=%q, want MultiLineString", magnitude, line.Geometry.Type) + } + assertTimedLineAligned(t, line) + seen[fmt.Sprintf("%.1f", magnitude)] = true + } + for _, key := range []string{"0.4", "0.8"} { + if !seen[key] { + t.Fatalf("missing magnitude line %s", key) + } + } + assertRiseSetBoundaryFeatures(t, collection, "sun") +} + +func TestMarshalSolarEclipse20140429NonCentralBandHasNoCombTeeth(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + }, + ) + if !ok { + t.Fatal("expected 2014 non-central annular eclipse") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + collection := decodeCollection(t, data) + partialBand := featureWithRole(t, collection, "partial-band") + source, _ := partialBand.Properties["source"].(string) + if source != "zero-magnitude-envelope+horizon-boundary" && + source != "open-boundary-endpoint-outlines" { + t.Fatalf("2014 partial-band source=%q", source) + } + assertClosedMultiPolygon(t, partialBand) + if source == "zero-magnitude-envelope+horizon-boundary" { + var partialPolygons [][][][]float64 + if err := json.Unmarshal(partialBand.Geometry.Coordinates, &partialPolygons); err != nil { + t.Fatalf("decode partial band: %v", err) + } + for footprintIndex, footprint := range partial.Footprints { + for boundaryIndex, boundary := range footprint.Boundaries { + for pointIndex, point := range boundary { + coordinate := []float64{point.Longitude, point.Latitude} + if !geoJSONMultiPolygonContains(partialPolygons, coordinate[0], coordinate[1]) && + geoJSONMultiPolygonBoundaryDistanceKM(partialPolygons, coordinate) > 5 { + t.Fatalf("authoritative partial-band misses footprint %d boundary %d point %d", + footprintIndex, boundaryIndex, pointIndex) + } + } + } + } + } + band := featureWithRole(t, collection, "central-band") + if band.Properties["source"] != "besselian-critical-envelope" { + t.Fatalf("central-band source=%v, want besselian-critical-envelope", band.Properties["source"]) + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central band: %v", err) + } + if len(polygons) != 1 || len(polygons[0]) != 1 { + t.Fatalf("2014 central band polygons=%d rings=%d, want one exterior ring", len(polygons), len(polygons[0])) + } + ring := polygons[0][0] + if len(ring) >= 300 { + t.Fatalf("2014 central-band ring points=%d, sampled ribbon union likely retained comb teeth", len(ring)) + } + perimeter := 0.0 + maximumEdge := 0.0 + minimumGreatestDistance := math.Inf(1) + greatest := []float64{partial.Eclipse.GreatestLongitude, partial.Eclipse.GreatestLatitude} + for index := 1; index < len(ring); index++ { + edge := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]) + perimeter += edge + maximumEdge = math.Max(maximumEdge, edge) + minimumGreatestDistance = math.Min(minimumGreatestDistance, + geoJSONPointSegmentDistanceKM(greatest, ring[index-1], ring[index])) + } + if perimeter >= 3000 { + t.Fatalf("2014 central band perimeter=%.1f km, likely contains comb-like retracing", perimeter) + } + if maximumEdge > 16 { + t.Fatalf("2014 central-band maximum edge=%.3f km, want a spatially refined boundary", maximumEdge) + } + // NASA rounds the non-central greatest marker independently from the local + // greatest-at-sunset boundary. They are close, but are not the same root. + if minimumGreatestDistance > 2 { + t.Fatalf("2014 greatest marker is %.3f km from the grazing band tip", minimumGreatestDistance) + } + + var greatestSet decodedFeature + foundGreatestSet := false + for _, feature := range featuresWithRole(decodeCollection(t, data), "visibility-boundary") { + if feature.Properties["phase"] == "greatest" && feature.Properties["horizon"] == "set" { + greatestSet, foundGreatestSet = feature, true + break + } + } + if !foundGreatestSet { + t.Fatal("missing greatest-at-sunset visibility boundary") + } + var lines [][][]float64 + if err := json.Unmarshal(greatestSet.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode greatest-at-sunset line: %v", err) + } + maximumSharedBoundaryDistance := 0.0 + sharedPoints := 0 + for _, line := range lines { + for _, point := range line { + minimumDistance := math.Inf(1) + for index := 1; index < len(ring); index++ { + minimumDistance = math.Min(minimumDistance, + geoJSONPointSegmentDistanceKM(point, ring[index-1], ring[index])) + } + if minimumDistance <= 0.01 { + sharedPoints++ + maximumSharedBoundaryDistance = math.Max(maximumSharedBoundaryDistance, minimumDistance) + } + } + } + if sharedPoints < 80 || maximumSharedBoundaryDistance > 0.01 { + t.Fatalf("greatest-at-sunset line shares %d exact band-edge samples (max %.6f km), want a dense coincident edge", sharedPoints, maximumSharedBoundaryDistance) + } +} + +func TestMarshalSolarEclipse20430409NonCentralTotalBandUsesCriticalEnvelope(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(2043, time.April, 9, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1}, + }, + ) + if !ok || partial.Eclipse.Type != eclipse.SolarEclipseTotal || + partial.Eclipse.Centrality != eclipse.SolarEclipseNonCentral { + t.Fatalf("2043 eclipse type=%s centrality=%s ok=%v, want non-central total", + partial.Eclipse.Type, partial.Eclipse.Centrality, ok) + } + band := featureWithRole(t, decodeCollection(t, mustMarshalSolarEclipse(t, partial)), "central-band") + if band.Properties["source"] != "besselian-critical-envelope" { + t.Fatalf("2043 central-band source=%v, want besselian-critical-envelope", band.Properties["source"]) + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode 2043 central band: %v", err) + } + if len(polygons) != 1 || len(polygons[0]) != 1 { + t.Fatalf("2043 central band polygons=%d rings=%d, want one exterior ring", len(polygons), len(polygons[0])) + } + ring := polygons[0][0] + if len(ring) < 100 || len(ring) > 500 { + t.Fatalf("2043 central-band ring points=%d, want one compact smooth envelope", len(ring)) + } + for index := 1; index < len(ring); index++ { + if edge := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); edge > 28 { + t.Fatalf("2043 central-band edge %d=%.3f km, want adaptive spatial sampling", index-1, edge) + } + } + for first := 0; first+1 < len(ring); first++ { + for second := first + 2; second+1 < len(ring); second++ { + if first == 0 && second+1 == len(ring)-1 { + continue + } + if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) { + t.Fatalf("2043 central-band ring self-intersects between edges %d and %d", first, second) + } + } + } +} + +func TestMarshalSolarEclipse20430409PartialBandHasNoPolarSeam(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(2043, time.April, 9, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1}, + }, + ) + if !ok { + t.Fatal("expected 2043-04-09 solar eclipse") + } + band := featureWithRole(t, decodeCollection(t, mustMarshalSolarEclipse(t, partial)), "partial-band") + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode 2043 partial band: %v", err) + } + assertClosedMultiPolygon(t, band) + // A band containing the pole can be one closed map fragment. Check both + // sides of the antimeridian and independent station visibility instead of + // requiring a particular number of fragments. + for _, point := range [][2]float64{ + {149, 60}, {149.5, 60}, {179, 60}, {-179, 60}, {179, 80}, {-179, 80}, {0, 89}, + {0, 40}, {-100, 20}, {110, 40}, + } { + _, visible := eclipse.LocalSolarEclipseOnDate(time.Date(2043, 4, 9, 0, 0, 0, 0, time.UTC), point[0], point[1], 0) + if got := geoJSONMultiPolygonContains(polygons, point[0], point[1]); got != visible { + t.Errorf("2043 partial-band at %v contains=%v, station visibility=%v", point, got, visible) + } + } +} + +func TestMarshalSolarEclipseNonCentralGreatestHorizonFolds(t *testing.T) { + for _, date := range []time.Time{ + time.Date(1656, time.July, 21, 0, 0, 0, 0, time.UTC), + time.Date(1928, time.May, 19, 0, 0, 0, 0, time.UTC), + time.Date(1957, time.October, 23, 0, 0, 0, 0, time.UTC), + time.Date(1967, time.November, 2, 0, 0, 0, 0, time.UTC), + } { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 24, CentralShadowStep: 2 * time.Minute, + RiseSetStep: 2 * time.Minute, + }) + if !ok { + t.Fatalf("%s: expected eclipse", date.Format("2006-01-02")) + } + if _, err := geojson.MarshalSolarEclipse(partial, nil); err != nil { + t.Fatalf("%s: MarshalSolarEclipse: %v", date.Format("2006-01-02"), err) + } + } +} + +func TestMarshalSolarEclipse19500318UsesValidatedOpenSweepFallback(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(1950, time.March, 18, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{Step: 5 * time.Minute, BoundaryPoints: 24}, + ) + if !ok { + t.Fatal("expected 1950 non-central annular eclipse") + } + band := featureWithRole(t, decodeCollection(t, mustMarshalSolarEclipse(t, partial)), "central-band") + if band.Properties["source"] != "central-shadow-sweep" { + t.Fatalf("1950 central-band source=%v, want validated open sweep fallback", band.Properties["source"]) + } + assertClosedMultiPolygon(t, band) +} + +func mustMarshalSolarEclipse(t *testing.T, partial eclipse.SolarEclipsePartialFootprintsInfo) []byte { + t.Helper() + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + return data +} + +func geoJSONSegmentsCross(a, b, c, d []float64) bool { + orientation := func(first, second, third []float64) float64 { + return (second[0]-first[0])*(third[1]-first[1]) - + (second[1]-first[1])*(third[0]-first[0]) + } + first, second := orientation(a, b, c), orientation(a, b, d) + third, fourth := orientation(c, d, a), orientation(c, d, b) + return ((first > 1e-10 && second < -1e-10) || (first < -1e-10 && second > 1e-10)) && + ((third > 1e-10 && fourth < -1e-10) || (third < -1e-10 && fourth > 1e-10)) +} + +func TestMarshalSolarEclipseCoarseCentralPathGracefullyRecovers(t *testing.T) { + for _, date := range []time.Time{ + time.Date(1891, time.June, 6, 0, 0, 0, 0, time.UTC), + time.Date(2119, time.March, 11, 0, 0, 0, 0, time.UTC), + } { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 60 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, DisableRiseSet: true, + }) + if !ok { + t.Fatalf("%s partial footprints unavailable", date.Format("2006-01-02")) + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 60 * time.Minute}) + if !ok || len(central.CenterLine) < 2 { + t.Fatalf("%s coarse central path points=%d, want at least two", date.Format("2006-01-02"), len(central.CenterLine)) + } + if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err != nil { + t.Fatalf("%s MarshalSolarEclipse: %v", date.Format("2006-01-02"), err) + } + } +} + +func TestMarshalSolarEclipse20230420HybridBandFollowsCenterLine(t *testing.T) { + date := time.Date(2023, time.April, 20, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{1.0, 1.01}, + }) + if !ok || partial.Eclipse.Type != eclipse.SolarEclipseHybrid { + t.Fatalf("expected hybrid eclipse, got ok=%v type=%s", ok, partial.Eclipse.Type) + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute}) + if !ok { + t.Fatal("expected hybrid central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + collection := decodeCollection(t, data) + band := featureWithRole(t, collection, "central-band") + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode hybrid central band: %v", err) + } + if len(polygons) != 4 { + t.Fatalf("hybrid central band polygon count=%d, want three physical lobes split at the antimeridian", len(polygons)) + } + center := featureWithRole(t, collection, "center-line") + var lines [][][]float64 + if err := json.Unmarshal(center.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode hybrid center line: %v", err) + } + for lineIndex, line := range lines { + for pointIndex, point := range line { + if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) { + t.Fatalf("center line[%d] point %d lies outside hybrid central band at %.6f, %.6f", lineIndex, pointIndex, point[0], point[1]) + } + } + } + magnitudeLines := featuresWithRole(collection, "magnitude-line") + if len(magnitudeLines) != 2 { + t.Fatalf("hybrid magnitude lines=%d, want two (1.0 and 1.01)", len(magnitudeLines)) + } + foundHighMagnitude := false + var magnitudeOne decodedFeature + for _, line := range magnitudeLines { + if line.Properties["magnitude"] == 1.0 { + magnitudeOne = line + } + if line.Properties["magnitude"] == 1.01 { + foundHighMagnitude = true + } + } + if !foundHighMagnitude { + t.Fatal("hybrid GeoJSON is missing the 1.01 magnitude contour") + } + var magnitudeOneLines [][][]float64 + if err := json.Unmarshal(magnitudeOne.Geometry.Coordinates, &magnitudeOneLines); err != nil { + t.Fatalf("decode hybrid 1.0 magnitude line: %v", err) + } + for lineIndex, line := range magnitudeOneLines { + for _, pointIndex := range []int{0, len(line) - 1} { + minimumDistance := math.Inf(1) + for _, centerLine := range lines { + for _, centerPoint := range centerLine { + minimumDistance = math.Min(minimumDistance, geoJSONCoordinateDistanceKM(line[pointIndex], centerPoint)) + } + } + if minimumDistance > 0.1 { + t.Fatalf("hybrid 1.0 line[%d] endpoint %d misses center line by %.3f km", lineIndex, pointIndex, minimumDistance) + } + } + } +} + +func TestMarshalSolarEclipseExportsTotalMagnitudeAboveOne(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 24, MagnitudeValues: []float64{1.01}, + }, + ) + if !ok || len(partial.MagnitudeContours) != 1 { + t.Fatalf("expected one totality magnitude contour, got ok=%v contours=%d", ok, len(partial.MagnitudeContours)) + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + collection := decodeCollection(t, data) + lines := featuresWithRole(collection, "magnitude-line") + if len(lines) != 1 || lines[0].Properties["magnitude"] != 1.01 { + t.Fatalf("magnitude lines = %#v, want one line at 1.01", lines) + } +} + +func TestMarshalSolarEclipse20260812CentralBandFollowsTotalityEnvelope(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + date := time.Date(2026, time.August, 12, 0, 0, 0, 0, zone) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{1.0}, + }) + if !ok || partial.Eclipse.Type != eclipse.SolarEclipseTotal { + t.Fatalf("expected 2026-08-12 total eclipse, got ok=%v type=%s", ok, partial.Eclipse.Type) + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, TargetSpacingKM: 700, + }) + if !ok { + t.Fatal("expected central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + assertClosedMultiPolygon(t, band) + if source := band.Properties["source"]; source != "magnitude-one-envelope" { + t.Fatalf("2026 central-band source=%v, want magnitude-one-envelope", source) + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central band: %v", err) + } + if len(polygons) == 0 || len(polygons) > 2 { + t.Fatalf("central band has %d polygons, want one physical band with at most one antimeridian split", len(polygons)) + } + for _, polygon := range polygons { + if len(polygon) == 0 { + t.Fatal("central-band polygon has no exterior ring") + } + assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250) + } + for closureIndex, closure := range partial.CentralBandHorizonClosures { + for pointIndex, point := range closure { + coordinate := []float64{point.Longitude, point.Latitude} + if distance := geoJSONMultiPolygonBoundaryDistanceKM(polygons, coordinate); distance > 0.1 { + t.Fatalf("horizon closure %d point %d is %.3f km from the magnitude-one boundary", closureIndex, pointIndex, distance) + } + } + } + // These points are independently classified by the local Split-K solver + // as total, but the old same-time cross-section band omitted them. + for _, point := range [][2]float64{{-4, 43.25}, {-4, 43.75}, {-2, 42.25}} { + if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) { + t.Fatalf("central band omits independently total point %.2f, %.2f", point[0], point[1]) + } + } +} + +func TestMarshalSolarEclipse20100115CentralBandContainsFuyang(t *testing.T) { + date := time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, + BoundaryPoints: 180, + }) + if !ok { + t.Fatal("expected solar partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 10 * time.Minute, TargetSpacingKM: 100, + }) + if !ok { + t.Fatal("expected solar central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if source := band.Properties["source"]; source != "besselian-critical-envelope" { + t.Fatalf("2010 central-band source=%v, want continuous critical envelope", source) + } + if band.Geometry.Type != "MultiPolygon" { + t.Fatalf("central-band geometry=%q, want one merged MultiPolygon without internal seams", band.Geometry.Type) + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central-band: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("central-band polygon count=%d, want one continuous outline", len(polygons)) + } + ring := polygons[0][0] + for first := 0; first+1 < len(ring); first++ { + for second := first + 2; second+1 < len(ring); second++ { + if first == 0 && second+1 == len(ring)-1 { + continue + } + if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) { + t.Fatalf("2010 central-band ring self-intersects between edges %d and %d", first, second) + } + } + } + for index := 1; index < len(ring); index++ { + if distance := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); distance > 250 { + t.Fatalf("central-band edge %d is %.1f km, want a sampled curved outline", index, distance) + } + } + minimumEndTurn := 180.0 + for index := 1; index+1 < len(ring); index++ { + point := ring[index] + if point[0] < 120 || point[0] > 123 || point[1] < 36 || point[1] > 39 { + continue + } + incoming := math.Atan2(point[1]-ring[index-1][1], point[0]-ring[index-1][0]) + outgoing := math.Atan2(ring[index+1][1]-point[1], ring[index+1][0]-point[0]) + minimumEndTurn = math.Min(minimumEndTurn, math.Remainder((outgoing-incoming)*180/math.Pi, 360)) + } + if minimumEndTurn < -30 { + t.Fatalf("2010 eastern central-band cap turns inward by %.1f degrees", minimumEndTurn) + } + for name, point := range map[string]eclipse.SolarEclipsePathPoint{"U1": partial.U1, "U4": partial.U4} { + if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) && + geoJSONMultiPolygonBoundaryDistanceKM(polygons, []float64{point.Longitude, point.Latitude}) > 1 { + t.Fatalf("%s external shadow contact lies outside the swept central band", name) + } + } + for _, role := range []string{"north-limit", "south-limit"} { + var lines [][][]float64 + if err := json.Unmarshal(featureWithRole(t, decodeCollection(t, data), role).Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode %s: %v", role, err) + } + for _, line := range lines { + for _, coordinate := range line { + for name, point := range map[string]eclipse.SolarEclipsePathPoint{"U1": partial.U1, "U4": partial.U4} { + if math.Abs(coordinate[0]-point.Longitude) <= 1e-10 && math.Abs(coordinate[1]-point.Latitude) <= 1e-10 { + t.Fatalf("%s retains %s external-contact vertex", role, name) + } + } + } + } + } + for first := 0; first+1 < len(ring); first++ { + for second := first + 2; second+1 < len(ring); second++ { + if geoJSONCoordinateDistanceKM(ring[first], ring[second]) < 0.001 { + t.Fatalf("central-band ring repeats non-adjacent vertices %d and %d", first, second) + } + } + } + if !geometryContainsPoint(t, band.Geometry, 115.4, 32.9) { + t.Fatal("Fuyang (115.4E, 32.9N) is outside the 2010-01-15 annular central band") + } + center := featureWithRole(t, decodeCollection(t, data), "center-line") + var centerLines [][][]float64 + if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil { + t.Fatalf("decode center-line: %v", err) + } + for segmentIndex, segment := range centerLines { + for pointIndex, point := range segment { + if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) && + geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 10 { + t.Fatalf("center-line segment %d point %d lies outside the central band at %.6f, %.6f", + segmentIndex, pointIndex, point[0], point[1]) + } + } + } +} + +func TestMarshalSolarEclipseHistoricalCrossedLimitsUseSimpleRibbonUnion(t *testing.T) { + for _, date := range []time.Time{ + time.Date(1547, time.November, 12, 0, 0, 0, 0, time.UTC), + time.Date(1565, time.November, 22, 0, 0, 0, 0, time.UTC), + } { + date := date + t.Run(date.Format("2006-01-02"), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + }) + if !ok { + t.Fatal("expected historical annular eclipse") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, TargetSpacingKM: 700, + }) + if !ok { + t.Fatal("expected historical central path") + } + band := featureWithRole(t, decodeCollection(t, mustMarshalSolarEclipseWithPath(t, partial, ¢ral)), "central-band") + if source := band.Properties["source"]; source != "paired-limits+central-shadow-ribbon-union" && + source != "paired-limits-ribbon-union" && source != "besselian-critical-envelope" { + t.Fatalf("central-band source=%v, want a validated central-band geometry", source) + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central-band: %v", err) + } + if len(polygons) != 1 || len(polygons[0]) != 1 { + t.Fatalf("central-band polygons=%d rings=%d, want one exterior ring", len(polygons), len(polygons[0])) + } + ring := polygons[0][0] + for first := 0; first+1 < len(ring); first++ { + for second := first + 2; second+1 < len(ring); second++ { + if first == 0 && second+1 == len(ring)-1 { + continue + } + if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) { + t.Fatalf("central-band ring self-intersects between edges %d and %d", first, second) + } + } + } + for index := 1; index < len(ring); index++ { + if edge := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); edge > 210 { + t.Fatalf("central-band edge %d=%.3f km, want spatial refinement", index-1, edge) + } + } + }) + } +} + +func mustMarshalSolarEclipseWithPath( + t *testing.T, + partial eclipse.SolarEclipsePartialFootprintsInfo, + central *eclipse.SolarEclipsePath, +) []byte { + t.Helper() + data, err := geojson.MarshalSolarEclipse(partial, central) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + return data +} + +func TestMarshalSolarEclipse20100115CenterLineEndsAtGreatestSetBoundary(t *testing.T) { + date := time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 24, RiseSetStep: 2 * time.Minute, + }) + if !ok { + t.Fatal("expected solar partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, TargetSpacingKM: 100, + }) + if !ok { + t.Fatal("expected solar central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + collection := decodeCollection(t, data) + + center := featureWithRole(t, collection, "center-line") + var centerLines [][][]float64 + if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil { + t.Fatalf("decode center-line: %v", err) + } + if len(centerLines) == 0 || len(centerLines[len(centerLines)-1]) == 0 { + t.Fatal("center-line has no coordinates") + } + lastLine := centerLines[len(centerLines)-1] + last := lastLine[len(lastLine)-1] + // NASA's path-table Limits row is 36 deg 49.6 min N, 121 deg 40.9 min E. + if math.Abs(last[0]-121.6817) > 0.12 || math.Abs(last[1]-36.8267) > 0.12 { + t.Fatalf("center-line limit = (%.6f, %.6f), want NASA limit near (121.6817, 36.8267)", + last[0], last[1]) + } + + foundGreatestSet := false + for _, boundary := range featuresWithRole(collection, "visibility-boundary") { + if boundary.Properties["phase"] != "greatest" || boundary.Properties["horizon"] != "set" { + continue + } + var lines [][][]float64 + if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode greatest-at-sunset boundary: %v", err) + } + for _, line := range lines { + for _, point := range line { + if point[0] == last[0] && point[1] == last[1] { + foundGreatestSet = true + } + } + } + } + if !foundGreatestSet { + t.Fatal("center-line limit is not a shared GeoJSON vertex of the greatest-at-sunset boundary") + } +} + +func TestMarshalSolarEclipse20080801CentralBandContainsCenterLine(t *testing.T) { + date := time.Date(2008, time.August, 1, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 30 * time.Minute, BoundaryPoints: 180, + CentralShadowStep: 2 * time.Minute, DisableRiseSet: true, + }) + if !ok { + t.Fatal("expected solar partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, + }) + if !ok { + t.Fatal("expected solar central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + center := featureWithRole(t, decodeCollection(t, data), "center-line") + var centerLines [][][]float64 + if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil { + t.Fatalf("decode center-line: %v", err) + } + for segmentIndex, segment := range centerLines { + for pointIndex, point := range segment { + if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) { + t.Fatalf("center-line segment %d point %d lies outside 2008 central band at %.6f, %.6f", segmentIndex, pointIndex, point[0], point[1]) + } + } + } +} + +func TestMarshalSolarEclipseCentralShadowStepTwoMinutesFallsBackToStableBand(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2037, time.July, 13, 0, 0, 0, 0, time.UTC), + time.Date(2038, time.July, 2, 0, 0, 0, 0, time.UTC), + } { + t.Run(date.Format("2006-01-02"), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 180, CentralShadowStep: 2 * time.Minute, + }) + if !ok { + t.Fatal("expected solar partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute}) + if !ok { + t.Fatal("expected solar central path") + } + if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + }) + } +} + +func TestMarshalCentralEclipseBandUnionAcrossEvents(t *testing.T) { + for _, date := range []time.Time{ + time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC), + time.Date(2012, time.May, 20, 0, 0, 0, 0, time.UTC), + time.Date(2017, time.August, 21, 0, 0, 0, 0, time.UTC), + time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), + } { + date := date + t.Run(date.Format("2006-01-02"), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, + BoundaryPoints: 48, + CentralShadowStep: 5 * time.Minute, + DisableRiseSet: true, + }) + if !ok { + t.Fatal("expected solar partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 10 * time.Minute, TargetSpacingKM: 500, + }) + if !ok { + t.Fatal("expected solar central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if band.Geometry.Type != "MultiPolygon" { + t.Fatalf("central-band geometry=%q, want merged MultiPolygon", band.Geometry.Type) + } + assertClosedMultiPolygon(t, band) + }) + } +} + +func TestMarshalSolarEclipseAllowsFoldedMagnitudeContourTimes(t *testing.T) { + date := time.Date(2031, time.May, 21, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 20 * time.Minute, BoundaryPoints: 24, MagnitudeValues: []float64{0.8}, + }) + if !ok || len(partial.MagnitudeContours) != 1 { + t.Fatalf("magnitude contours=%d ok=%v, want one", len(partial.MagnitudeContours), ok) + } + folded := false + for _, segment := range partial.MagnitudeContours[0].Segments { + for index := 1; index < len(segment); index++ { + if !segment[index].Time.After(segment[index-1].Time) { + folded = true + break + } + } + } + if !folded { + t.Fatal("2031 magnitude contour did not exercise a folded greatest-time branch") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + line := featureWithRole(t, decodeCollection(t, data), "magnitude-line") + assertTimedLineAligned(t, line) +} + func TestMarshalSolarEclipseAllowsSingleLimitCentrality(t *testing.T) { date := time.Date(2003, time.May, 30, 0, 0, 0, 0, time.UTC) partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ @@ -68,11 +1095,30 @@ func TestMarshalSolarEclipseAllowsSingleLimitCentrality(t *testing.T) { t.Fatalf("MarshalSolarEclipse: %v", err) } collection := decodeCollection(t, data) - if len(featuresWithRole(collection, "center-line")) != 1 || len(featuresWithRole(collection, "central-band")) != 0 { - t.Fatal("single-limit central eclipse should export center line without a band") + if len(featuresWithRole(collection, "center-line")) != 1 || len(featuresWithRole(collection, "central-band")) != 1 { + t.Fatal("single-limit central eclipse should export both its center line and central band") } } +func TestMarshalSolarEclipse20330330SeparatesOpenSweepsAcrossClosedPhase(t *testing.T) { + date := time.Date(2033, time.March, 30, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 180, CentralShadowStep: 2 * time.Minute, + }) + if !ok { + t.Fatal("expected 2033-03-30 solar eclipse") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute}) + if !ok { + t.Fatal("expected 2033-03-30 central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + assertClosedMultiPolygon(t, featureWithRole(t, decodeCollection(t, data), "central-band")) +} + func TestMarshalSolarEclipseAllowsLowSampleOpenFootprints(t *testing.T) { for _, fixture := range []struct { date time.Time @@ -240,6 +1286,113 @@ func TestMarshalStarOccultationSplitsAntimeridian(t *testing.T) { assertTimedLineAligned(t, centerFeature) } +func TestMarshalStarOccultationAntaresCenterLineStaysInsideFootprintBand(t *testing.T) { + start := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindStarOccultationPaths( + start, + start.Add(24*time.Hour), + moon.StarCoordinate{ + ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, + ProperMotionDecMasPerYear: -20, + ParallaxMas: 24, + }, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + if len(paths[0].Footprints) == 0 { + t.Fatal("Antares path has no instantaneous footprints") + } + data, err := geojson.MarshalStarOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + collection := decodeCollection(t, data) + assertRiseSetBoundaryFeatures(t, collection, "moon") + band := featureWithRole(t, collection, "occultation-band") + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode occultation band: %v", err) + } + if len(polygons)*10 >= len(paths[0].Footprints) { + t.Fatalf("merged occultation sweep retained %d polygons for %d instantaneous footprints", + len(polygons), len(paths[0].Footprints)) + } + center := featureWithRole(t, collection, "center-line") + var lines [][][]float64 + if err := json.Unmarshal(center.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode center line: %v", err) + } + for lineIndex, line := range lines { + for index := 1; index < len(line); index++ { + for sample := 0; sample <= 10; sample++ { + fraction := float64(sample) / 10 + longitude := line[index-1][0] + fraction*(line[index][0]-line[index-1][0]) + latitude := line[index-1][1] + fraction*(line[index][1]-line[index-1][1]) + if !geoJSONMultiPolygonContains(polygons, longitude, latitude) { + t.Fatalf("center line[%d] segment %d sample %d lies outside footprint band at %.6f, %.6f", + lineIndex, index-1, sample, longitude, latitude) + } + } + } + } + for _, role := range []string{"north-limit", "south-limit"} { + limit := featureWithRole(t, collection, role) + var segments [][][]float64 + if err := json.Unmarshal(limit.Geometry.Coordinates, &segments); err != nil { + t.Fatalf("decode %s: %v", role, err) + } + for segmentIndex, segment := range segments { + for index := 1; index < len(segment); index++ { + if distance := geoJSONCoordinateDistanceKM(segment[index-1], segment[index]); distance > 750+1e-6 { + t.Fatalf("%s segment %d still spans %.1f km branch change", role, segmentIndex, distance) + } + } + } + assertTimedLineAligned(t, limit) + } +} + +func TestMarshalStarOccultationAntaresCompactBandContainsCenterLine(t *testing.T) { + start := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindStarOccultationPaths( + start, start.Add(24*time.Hour), + moon.StarCoordinate{ + ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, + ProperMotionDecMasPerYear: -20, + ParallaxMas: 24, + }, + moon.OccultationPathOptions{ + Step: 5 * time.Minute, TargetSpacingKM: 200, + DisableRiseSet: true, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.Footprints) != 0 || len(path.BandFootprints) == 0 { + t.Fatalf("dense/compact footprint counts=%d/%d, want zero/nonzero", len(path.Footprints), len(path.BandFootprints)) + } + data, err := geojson.MarshalStarOccultation(path) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "occultation-band") + for index, point := range path.CenterLine { + if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) { + t.Fatalf("compact stellar band excludes center-line sample %d at %.6f, %.6f", index, point.Longitude, point.Latitude) + } + } +} + func TestMarshalStarOccultationHandlesExactAntimeridian(t *testing.T) { start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC) center := occultationSamples(start, []float64{-180, 180, 150}, []float64{2, 1, 0}) @@ -291,6 +1444,153 @@ func TestMarshalStarOccultationWithTimeMarkers(t *testing.T) { } } +func TestMarshalStarOccultationSplitsImpossibleBoundaryJump(t *testing.T) { + start := time.Date(2026, time.February, 11, 10, 0, 0, 0, time.UTC) + times := []time.Time{ + start, + start.Add(time.Minute), + start.Add(time.Minute + time.Second), + start.Add(2 * time.Minute), + } + series := func(longitudes, latitudes []float64) []moon.OccultationPathPoint { + points := make([]moon.OccultationPathPoint, len(times)) + for index := range times { + points[index] = moon.OccultationPathPoint{ + Time: times[index], Longitude: longitudes[index], Latitude: latitudes[index], MoonAltitude: 20, + } + } + return points + } + center := series([]float64{0, 0.1, 0.2, 0.3}, []float64{0, 0, 0, 0}) + north := series([]float64{0, 0.1, 30, 30.1}, []float64{10, 10, 10, 10}) + south := series([]float64{0, 0.1, 0.2, 0.3}, []float64{-10, -10, -10, -10}) + path := moon.StarOccultationPath{ + TargetID: "branch-jump", Start: north[0], Greatest: center[2], End: north[3], + Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south, Step: time.Second, + } + data, err := geojson.MarshalStarOccultation(path) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + northFeature := featureWithRole(t, decodeCollection(t, data), "north-limit") + var lines [][][]float64 + if err := json.Unmarshal(northFeature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode north limit: %v", err) + } + if len(lines) != 2 { + t.Fatalf("north limit segment count = %d, want 2 around branch jump", len(lines)) + } + for _, line := range lines { + for index := 1; index < len(line); index++ { + if jump := math.Abs(line[index][0] - line[index-1][0]); jump > 5 { + t.Fatalf("north limit still contains %.1f degree branch jump", jump) + } + } + } + assertTimedLineAligned(t, northFeature) + assertOccultationBandPolygonCount(t, decodeCollection(t, data), "occultation-band", 2) +} + +func TestMarshalStarOccultationPreservesEndpointBranchFragments(t *testing.T) { + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + for _, count := range []int{2, 3} { + t.Run(fmt.Sprintf("%d points", count), func(t *testing.T) { + path := endpointBranchJumpPath(start, count) + data, err := geojson.MarshalStarOccultation(path) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + collection := decodeCollection(t, data) + north := featureWithRole(t, collection, "north-limit") + assertTimedLineAligned(t, north) + timeSegments, ok := north.Properties["times"].([]interface{}) + if !ok || len(timeSegments) != 2 { + t.Fatalf("north-limit time segments = %#v, want two discontinuous fragments", north.Properties["times"]) + } + first := timeSegments[0].([]interface{})[0] + lastSegment := timeSegments[len(timeSegments)-1].([]interface{}) + last := lastSegment[len(lastSegment)-1] + if first != path.Start.Time.Format(time.RFC3339Nano) || last != path.End.Time.Format(time.RFC3339Nano) { + t.Fatalf("north-limit time span = %v..%v, want %s..%s", + first, last, path.Start.Time.Format(time.RFC3339Nano), path.End.Time.Format(time.RFC3339Nano)) + } + + band := featureWithRole(t, collection, "occultation-band") + if count == 2 && band.Geometry.Type != "MultiLineString" { + t.Fatalf("two-point discontinuous band geometry = %q, want MultiLineString endpoint sections", band.Geometry.Type) + } + if count == 3 { + if band.Geometry.Type != "GeometryCollection" { + t.Fatalf("partially continuous band geometry = %q, want GeometryCollection", band.Geometry.Type) + } + var geometries []struct { + Type string `json:"type"` + } + if err := json.Unmarshal(band.Geometry.Geometries, &geometries); err != nil { + t.Fatalf("decode band geometries: %v", err) + } + if len(geometries) != 2 || geometries[0].Type != "MultiPolygon" || geometries[1].Type != "MultiLineString" { + t.Fatalf("band geometries = %#v, want polygon sweep plus endpoint sections", geometries) + } + } + }) + } +} + +func endpointBranchJumpPath(start time.Time, count int) moon.StarOccultationPath { + north := make([]moon.OccultationPathPoint, count) + south := make([]moon.OccultationPathPoint, count) + for index := range north { + when := start.Add(time.Duration(index) * time.Second) + longitude := 30.0 + float64(index)/10 + if index == 0 { + longitude = 0 + } + north[index] = moon.OccultationPathPoint{ + Time: when, Longitude: longitude, Latitude: 10, MoonAltitude: 20, + } + south[index] = moon.OccultationPathPoint{ + Time: when, Longitude: longitude, Latitude: -10, MoonAltitude: 20, + } + } + return moon.StarOccultationPath{ + TargetID: "endpoint-jump", Start: north[0], Greatest: north[0], End: north[count-1], + Complete: true, NorthernLimit: north, SouthernLimit: south, Step: time.Second, + } +} + +func TestMarshalPlanetOccultationSplitsImpossibleFallbackBandJump(t *testing.T) { + start := time.Date(2026, time.February, 11, 10, 0, 0, 0, time.UTC) + times := []time.Time{ + start, + start.Add(time.Minute), + start.Add(time.Minute + time.Second), + start.Add(2 * time.Minute), + } + series := func(longitudes, latitudes []float64) []moon.OccultationPathPoint { + points := make([]moon.OccultationPathPoint, len(times)) + for index := range times { + points[index] = moon.OccultationPathPoint{ + Time: times[index], Longitude: longitudes[index], Latitude: latitudes[index], MoonAltitude: 20, + } + } + return points + } + center := series([]float64{0, 0.1, 30, 30.1}, []float64{0, 0, 0, 0}) + north := series([]float64{0, 0.1, 30, 30.1}, []float64{10, 10, 10, 10}) + south := series([]float64{0, 0.1, 30, 30.1}, []float64{-10, -10, -10, -10}) + path := moon.PlanetOccultationPath{ + Planet: moon.OccultationSaturn, TargetID: "branch-jump", + Start: north[0], Greatest: center[2], End: north[3], Complete: true, + CenterLine: center, NorthernLimit: north, SouthernLimit: south, Step: time.Second, + } + data, err := geojson.MarshalPlanetOccultation(path) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + assertOccultationBandPolygonCount(t, decodeCollection(t, data), "partial-band", 2) +} + func TestTimeMarkerInterpolationUsesShortestAntimeridianPath(t *testing.T) { start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC) center := occultationSamples(start, []float64{170, -170}, []float64{2, 0}) @@ -401,6 +1701,345 @@ func TestMarshalPlanetOccultationAllowsMissingCenterLine(t *testing.T) { assertRoles(t, collection, "north-limit", "south-limit", "start", "greatest", "end") } +func TestMarshalPlanetOccultationExportsSixRiseSetPhaseBoundaries(t *testing.T) { + start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 10 * time.Minute}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + assertRiseSetBoundaryFeatures(t, decodeCollection(t, data), "moon") +} + +func TestMarshalPlanetOccultation20250105PreservesClosedPolarRiseSetBranches(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + assertRiseSetBoundaryFeatures(t, collection, "moon") + startRise := riseSetBoundaryFeature(t, collection, "start", "rise") + startSet := riseSetBoundaryFeature(t, collection, "start", "set") + if !riseSetFeaturesShareEndpointInRegion(t, startRise, startSet, -60, 60, 70, 90) { + t.Fatal("serialized start moonrise/moonset curves do not share their polar direction junction") + } + if !riseSetFeatureSegmentsShareEndpointInRegion(t, startSet, -60, 60, 70, 90) && + !riseSetFeatureHasInteriorVertexInRegion(t, startSet, -60, 60, 70, 90) { + t.Fatal("serialized moonset start-phase branches do not preserve their polar fold vertex") + } + for _, feature := range featuresWithRole(collection, "visibility-boundary") { + if feature.Properties["phase"] == "horizon" { + continue + } + assertRiseSetFeatureHasNoInstantaneousBranchJump(t, feature) + } +} + +func TestMarshalPlanetOccultation20250630MarsClosesMoonsetAndBand(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.June, 30, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationMars, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: 5 * time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + assertRiseSetBoundaryFeatures(t, collection, "moon") + startSet := riseSetBoundaryFeature(t, collection, "start", "set") + greatestSet := riseSetBoundaryFeature(t, collection, "greatest", "set") + endSet := riseSetBoundaryFeature(t, collection, "end", "set") + if !riseSetFeaturesShareVertexInRegion(t, startSet, endSet, -90, -70, -40, -10) { + t.Fatal("serialized moonset start/end curves do not share the first narrow phase junction") + } + if !riseSetFeaturesShareVertexInRegion(t, greatestSet, endSet, -90, -70, -40, -10) { + t.Fatal("serialized moonset greatest/end curves do not share the second narrow phase junction") + } + + band := featureWithRole(t, collection, "partial-band") + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode partial-band: %v", err) + } + if band.Geometry.Type != "MultiPolygon" || len(polygons) == 0 || len(polygons) > 2 { + t.Fatalf("partial-band geometry=%s polygons=%d, want one spherical band split at most once by the antimeridian", band.Geometry.Type, len(polygons)) + } + for _, polygon := range polygons { + for _, ring := range polygon { + if len(ring) < 4 || ring[0][0] != ring[len(ring)-1][0] || ring[0][1] != ring[len(ring)-1][1] { + t.Fatal("partial-band contains an unclosed polygon ring") + } + assertGeoJSONRingHasNoShortHairpins(t, "partial-band", ring, 50, 75, 24) + } + } +} + +func TestMarshalPlanetOccultation20250729MarsUsesClosedPolarBand(t *testing.T) { + fixture := mars20250729TestFixture(t, 5*time.Minute, false) + path, collection := fixture.path, fixture.collection + if len(path.CenterLine) != 0 || len(path.RiseSetCurves) != 3 || len(path.RiseSetCurves[2].Segments) < 2 { + t.Fatalf("unexpected polar path topology: center=%d curves=%d end-rise-segments=%d", + len(path.CenterLine), len(path.RiseSetCurves), len(path.RiseSetCurves[2].Segments)) + } + band := featureWithRole(t, collection, "partial-band") + if band.Geometry.Type != "MultiPolygon" { + t.Fatalf("partial-band geometry=%q, want MultiPolygon", band.Geometry.Type) + } + if authoritative, ok := band.Properties["static_band_authoritative"].(bool); !ok || !authoritative { + t.Fatalf("partial-band static_band_authoritative=%v, want true", band.Properties["static_band_authoritative"]) + } + if source := band.Properties["source"]; source != "visible-footprint-sweep" { + t.Fatalf("partial-band source=%v, want visible-footprint-sweep", source) + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode partial-band: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("partial-band polygons=%d, want one continuous polar band", len(polygons)) + } + for polygonIndex, polygon := range polygons { + for ringIndex, ring := range polygon { + if len(ring) < 4 || ring[0][0] != ring[len(ring)-1][0] || ring[0][1] != ring[len(ring)-1][1] { + t.Fatalf("partial-band polygon %d ring %d is not closed", polygonIndex, ringIndex) + } + } + } + greatest := featureWithRole(t, collection, "greatest") + var greatestPoint []float64 + if err := json.Unmarshal(greatest.Geometry.Coordinates, &greatestPoint); err != nil { + t.Fatalf("decode greatest point: %v", err) + } + totalBand := featureWithRole(t, collection, "total-band") + var totalPolygons [][][][]float64 + if err := json.Unmarshal(totalBand.Geometry.Coordinates, &totalPolygons); err != nil { + t.Fatalf("decode total-band: %v", err) + } + for _, testPoint := range []struct { + name string + lon float64 + lat float64 + inside bool + }{ + {name: "selected", lon: -134.2280, lat: -78.0725, inside: true}, + {name: "visible-sweep", lon: -129.8230, lat: -77.4030, inside: true}, + } { + partialInside := geoJSONMultiPolygonContains(polygons, testPoint.lon, testPoint.lat) + totalInside := geoJSONMultiPolygonContains(totalPolygons, testPoint.lon, testPoint.lat) + if partialInside != testPoint.inside || totalInside != testPoint.inside { + t.Fatalf("%s point %.4f, %.4f partial=%v total=%v, want inside=%v", testPoint.name, testPoint.lon, testPoint.lat, partialInside, totalInside, testPoint.inside) + } + } + totalRings := geoJSONMultiPolygonOuterRings(t, totalBand) + greatestPath := [][]geodata.GeoPoint{{ + {Longitude: greatestPoint[0], Latitude: greatestPoint[1]}, + }} + if miss := geodata.SphericalPolygonsPathMissDistanceKM(totalRings, greatestPath, false); miss > 10 { + t.Fatalf("total-band excludes greatest point %.6f, %.6f by %.1f km", greatestPoint[0], greatestPoint[1], miss) + } +} + +func TestMarshalPlanetOccultationWithoutFootprintsUsesBands(t *testing.T) { + start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + assertRoles(t, collection, "partial-band", "total-band", "center-line", "north-limit", "south-limit", + "north-total-limit", "south-total-limit", "visibility-boundary", "start", "total-start", "greatest", "total-end", "end") + if len(featuresWithRole(collection, "partial-footprint")) != 0 || len(featuresWithRole(collection, "total-footprint")) != 0 { + t.Fatal("disabled instantaneous footprints were serialized") + } + assertRiseSetBoundaryFeatures(t, collection, "moon") +} + +func TestMarshalPlanetOccultationCompactBandHandlesTangentBranchConvergence(t *testing.T) { + for _, date := range []time.Time{ + time.Date(1954, time.June, 30, 0, 0, 0, 0, time.UTC), + time.Date(1962, time.April, 1, 0, 0, 0, 0, time.UTC), + time.Date(1965, time.June, 27, 0, 0, 0, 0, time.UTC), + } { + paths, err := moon.FindPlanetOccultationPaths( + date, date.Add(24*time.Hour), moon.OccultationJupiter, + moon.OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true, DisableRiseSet: true}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("%s: paths=%d err=%v, want one", date.Format("2006-01-02"), len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("%s: MarshalPlanetOccultation: %v", date.Format("2006-01-02"), err) + } + assertRoles(t, decodeCollection(t, data), "partial-band", "total-band", "center-line") + } +} + +func TestMarshalPlanetOccultation20240725CompactBandsFollowCenterLine(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + for _, test := range []struct { + role string + points []moon.OccultationPathPoint + start, end time.Time + }{ + {role: "partial-band", points: paths[0].CenterLine}, + { + role: "total-band", points: paths[0].CenterLine, + start: paths[0].TotalStart.Time, end: paths[0].TotalEnd.Time, + }, + } { + band := featureWithRole(t, collection, test.role) + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode %s: %v", test.role, err) + } + if band.Geometry.Type != "MultiPolygon" || len(polygons) != 1 { + t.Fatalf("%s geometry=%s polygons=%d, want one continuous MultiPolygon", test.role, band.Geometry.Type, len(polygons)) + } + if compact, ok := band.Properties["compact_band"].(bool); !ok || !compact { + t.Fatalf("%s compact_band=%v, want true", test.role, band.Properties["compact_band"]) + } + maximumEdge := 0.0 + for _, polygon := range polygons { + for _, ring := range polygon { + if len(ring) < 4 || ring[0][0] != ring[len(ring)-1][0] || ring[0][1] != ring[len(ring)-1][1] { + t.Fatalf("%s contains an unclosed polygon ring", test.role) + } + for pointIndex := 1; pointIndex < len(ring); pointIndex++ { + edge := geoJSONCoordinateDistanceKM(ring[pointIndex-1], ring[pointIndex]) + maximumEdge = math.Max(maximumEdge, edge) + } + } + } + if maximumEdge > 750 { + t.Fatalf("%s maximum edge=%.1f km, likely contains a disconnected shard", test.role, maximumEdge) + } + for index, point := range test.points { + if (!test.start.IsZero() && point.Time.Before(test.start)) || + (!test.end.IsZero() && point.Time.After(test.end)) { + continue + } + if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) { + t.Fatalf("%s excludes center-line sample %d at %.6f, %.6f", test.role, index, point.Longitude, point.Latitude) + } + } + } +} + +func TestMarshalPlanetOccultation20250105CompactBandsHaveSmoothContinuousOutline(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + for _, role := range []string{"partial-band", "total-band"} { + band := featureWithRole(t, collection, role) + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode %s: %v", role, err) + } + if band.Geometry.Type != "MultiPolygon" || len(polygons) != 1 { + t.Fatalf("%s geometry=%s polygons=%d, want one continuous MultiPolygon", role, band.Geometry.Type, len(polygons)) + } + maximumEdge := 0.0 + for _, polygon := range polygons { + for _, ring := range polygon { + if len(ring) < 4 || ring[0][0] != ring[len(ring)-1][0] || ring[0][1] != ring[len(ring)-1][1] { + t.Fatalf("%s contains an unclosed polygon ring", role) + } + assertGeoJSONRingHasNoShortHairpins(t, role, ring, 35, 25, 12) + for index := 1; index < len(ring); index++ { + maximumEdge = math.Max(maximumEdge, geoJSONCoordinateDistanceKM(ring[index-1], ring[index])) + } + } + } + if maximumEdge > 175 { + t.Fatalf("%s maximum edge=%.1f km, want a spatially refined outline", role, maximumEdge) + } + for index, point := range paths[0].CenterLine { + if role == "total-band" && (point.Time.Before(paths[0].TotalStart.Time) || point.Time.After(paths[0].TotalEnd.Time)) { + continue + } + if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) { + t.Fatalf("%s excludes center-line sample %d at %.6f, %.6f", role, index, point.Longitude, point.Latitude) + } + } + } + partial := featureWithRole(t, collection, "partial-band") + total := featureWithRole(t, collection, "total-band") + for index, point := range paths[0].CenterLine { + if point.Time.Before(paths[0].TotalStart.Time) || point.Time.After(paths[0].TotalEnd.Time) { + continue + } + if !geometryContainsPoint(t, partial.Geometry, point.Longitude, point.Latitude) || + !geometryContainsPoint(t, total.Geometry, point.Longitude, point.Latitude) { + t.Fatalf("total-band sample %d is not covered by both bands", index) + } + } +} + func TestMarshalSolarEclipseRejectsMisalignedLimits(t *testing.T) { date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{}) @@ -417,6 +2056,52 @@ func TestMarshalSolarEclipseRejectsMisalignedLimits(t *testing.T) { } } +func TestMarshalSolarEclipseRejectsMalformedDerivedGeometry(t *testing.T) { + date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) + valid, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 24, + }) + if !ok { + t.Fatal("expected solar eclipse") + } + t.Run("magnitude above event maximum", func(t *testing.T) { + partial := valid + partial.MagnitudeContours = []eclipse.SolarEclipseMagnitudeContour{{ + Magnitude: partial.Eclipse.Magnitude + 0.01, + Segments: [][]eclipse.SolarEclipsePathPoint{{partial.Footprints[0].Boundaries[0][0], partial.Footprints[0].Boundaries[0][1]}}, + }} + if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil { + t.Fatal("magnitude contour above event maximum was accepted") + } + }) + t.Run("invalid rise set phase", func(t *testing.T) { + partial := valid + partial.RiseSetCurves = append([]eclipse.SolarEclipseRiseSetCurve(nil), valid.RiseSetCurves...) + partial.RiseSetCurves[0].Phase = eclipse.RiseSetPhase("bogus") + if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil { + t.Fatal("invalid solar rise/set phase was accepted") + } + }) + t.Run("central band footprint outside contacts", func(t *testing.T) { + partial := valid + partial.CentralBandFootprints = append([]eclipse.SolarEclipsePartialFootprint(nil), valid.CentralBandFootprints...) + partial.CentralBandFootprints[0].Time = partial.U1.Time.Add(-time.Hour) + if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil { + t.Fatal("central-band footprint outside U1-U4 was accepted") + } + }) + t.Run("partial eclipse cannot contain central band footprints", func(t *testing.T) { + partial := valid + partial.Eclipse.Type = eclipse.SolarEclipsePartial + if len(partial.CentralBandFootprints) == 0 { + t.Fatal("expected central-band footprints in the fixture") + } + if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil { + t.Fatal("partial eclipse central-band footprints were accepted") + } + }) +} + func TestMarshalStarOccultationRejectsInvalidPathData(t *testing.T) { start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC) valid := sampleStarOccultationPath(start) @@ -436,6 +2121,37 @@ func TestMarshalStarOccultationRejectsInvalidPathData(t *testing.T) { {name: "zero event time", mutate: func(path *moon.StarOccultationPath) { path.Start.Time = time.Time{} }}, + {name: "footprint outside event", mutate: func(path *moon.StarOccultationPath) { + path.Footprints = []moon.OccultationFootprint{{ + Time: path.End.Time.Add(time.Second), + Polygons: [][]moon.OccultationPathPoint{{ + {Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 0}, {Longitude: 0, Latitude: 1}, + }}, + }} + }}, + {name: "compact footprint outside event", mutate: func(path *moon.StarOccultationPath) { + path.BandFootprints = []moon.OccultationFootprint{sampleFootprint( + path.End.Time.Add(time.Second), 10, -10, 20, 10, + )} + }}, + {name: "empty footprint", mutate: func(path *moon.StarOccultationPath) { + path.Footprints = []moon.OccultationFootprint{{Time: path.Greatest.Time}} + }}, + {name: "mismatched footprint point time", mutate: func(path *moon.StarOccultationPath) { + footprint := sampleFootprint(path.Greatest.Time, 10, -10, 20, 10) + footprint.Polygons[0][0].Time = footprint.Time.Add(time.Second) + path.Footprints = []moon.OccultationFootprint{footprint} + }}, + {name: "non-finite footprint altitude", mutate: func(path *moon.StarOccultationPath) { + footprint := sampleFootprint(path.Greatest.Time, 10, -10, 20, 10) + footprint.Polygons[0][0].MoonAltitude = math.NaN() + path.Footprints = []moon.OccultationFootprint{footprint} + }}, + {name: "negative footprint width", mutate: func(path *moon.StarOccultationPath) { + footprint := sampleFootprint(path.Greatest.Time, 10, -10, 20, 10) + footprint.Polygons[0][0].WidthKM = -1 + path.Footprints = []moon.OccultationFootprint{footprint} + }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -463,6 +2179,47 @@ func TestMarshalPlanetOccultationRejectsInvalidFootprintPolygon(t *testing.T) { } } +func TestMarshalPlanetOccultationAllowsCompactBandAndTimedFootprints(t *testing.T) { + start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) + path := samplePlanetOccultationPath(start) + footprint := sampleFootprint(start.Add(time.Hour), 10, -10, 20, 10) + path.PartialFootprints = []moon.PlanetOccultationFootprint{footprint} + path.PartialBandFootprints = []moon.PlanetOccultationFootprint{footprint} + data, err := geojson.MarshalPlanetOccultation(path) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + if len(featuresWithRole(collection, "partial-band")) != 1 || len(featuresWithRole(collection, "partial-footprint")) != 1 { + t.Fatal("compact band and timed footprint were not both serialized") + } +} + +func TestMarshalOccultationRejectsMalformedRiseSetCurves(t *testing.T) { + start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) + curve := moon.OccultationRiseSetCurve{ + Phase: moon.RiseSetPhaseStart, + Direction: moon.RiseSetDirectionRise, + Segments: [][]moon.OccultationPathPoint{{ + {Time: start.Add(20 * time.Minute), Longitude: 10, Latitude: 20}, + {Time: start.Add(40 * time.Minute), Longitude: math.NaN(), Latitude: 21}, + }}, + } + starPath := sampleStarOccultationPath(start) + starPath.RiseSetCurves = []moon.OccultationRiseSetCurve{curve} + if _, err := geojson.MarshalStarOccultation(starPath); err == nil { + t.Fatal("stellar rise/set curve with a NaN coordinate was accepted") + } + + planetPath := samplePlanetOccultationPath(start) + curve.Segments[0][1].Longitude = 12 + curve.Phase = moon.RiseSetPhase("bogus") + planetPath.RiseSetCurves = []moon.OccultationRiseSetCurve{curve} + if _, err := geojson.MarshalPlanetOccultation(planetPath); err == nil { + t.Fatal("planetary rise/set curve with an invalid phase was accepted") + } +} + func TestMarshalFunctionsRejectIncompleteInput(t *testing.T) { if _, err := geojson.MarshalSolarEclipse(eclipse.SolarEclipsePartialFootprintsInfo{}, nil); err == nil { t.Fatal("empty solar eclipse input was accepted") @@ -643,6 +2400,47 @@ func assertClosedMultiPolygon(t *testing.T, feature decodedFeature) { } } +// geometryContainsPoint 判定点是否落在导出的几何内;解不出坐标时直接失败,避免 wantInside=false 的用例静默通过。 +func geometryContainsPoint(t *testing.T, value struct { + Type string `json:"type"` + Coordinates json.RawMessage `json:"coordinates"` + Geometries json.RawMessage `json:"geometries"` +}, longitude, latitude float64) bool { + t.Helper() + switch value.Type { + case "MultiPolygon": + var polygons [][][][]float64 + if err := json.Unmarshal(value.Coordinates, &polygons); err != nil { + t.Fatalf("decode MultiPolygon coordinates: %v", err) + } + return geoJSONMultiPolygonContains(polygons, longitude, latitude) + case "Polygon": + var polygon [][][]float64 + if err := json.Unmarshal(value.Coordinates, &polygon); err != nil { + t.Fatalf("decode Polygon coordinates: %v", err) + } + if len(polygon) == 0 { + return false + } + return geoJSONRingContains(polygon[0], longitude, latitude) + case "GeometryCollection": + var geometries []struct { + Type string `json:"type"` + Coordinates json.RawMessage `json:"coordinates"` + Geometries json.RawMessage `json:"geometries"` + } + if err := json.Unmarshal(value.Geometries, &geometries); err != nil { + t.Fatalf("decode GeometryCollection: %v", err) + } + for _, geometry := range geometries { + if geometryContainsPoint(t, geometry, longitude, latitude) { + return true + } + } + } + return false +} + func assertTimedLineAligned(t *testing.T, feature decodedFeature) { t.Helper() var lines [][][]float64 @@ -670,6 +2468,376 @@ func assertTimedLineAligned(t *testing.T, feature decodedFeature) { } } +func assertRiseSetBoundaryFeatures(t *testing.T, collection decodedCollection, body string) { + t.Helper() + features := featuresWithRole(collection, "visibility-boundary") + seen := make(map[string]map[string]bool, 2) + phaseCurveCount := make(map[string]int, 2) + for _, feature := range features { + phase, phaseOK := feature.Properties["phase"].(string) + horizon, horizonOK := feature.Properties["horizon"].(string) + if !phaseOK || !horizonOK || feature.Properties["body"] != body { + t.Fatalf("invalid %s visibility properties: %#v", body, feature.Properties) + } + if feature.Geometry.Type != "MultiLineString" { + t.Fatalf("%s %s/%s geometry=%q, want MultiLineString", body, phase, horizon, feature.Geometry.Type) + } + assertTimedLineAligned(t, feature) + if phase == "horizon" { + continue + } + band := "" + if value, ok := feature.Properties["band"].(string); ok { + band = value + } + if seen[band] == nil { + seen[band] = make(map[string]bool, 6) + } + phaseCurveCount[band]++ + seen[band][phase+"/"+horizon] = true + } + for band, count := range phaseCurveCount { + if count != 6 { + t.Fatalf("%s/%s phase visibility-boundary count=%d, want 6", body, band, count) + } + for _, phase := range []string{"start", "greatest", "end"} { + for _, horizon := range []string{"rise", "set"} { + if !seen[band][phase+"/"+horizon] { + t.Fatalf("missing %s/%s visibility boundary %s/%s", body, band, phase, horizon) + } + } + } + } +} + +type decodedRiseSetEndpoint struct { + coordinate []float64 + time string + segment int +} + +func riseSetBoundaryFeature( + t *testing.T, + collection decodedCollection, + phase, horizon string, +) decodedFeature { + t.Helper() + for _, feature := range featuresWithRole(collection, "visibility-boundary") { + if feature.Properties["phase"] == phase && feature.Properties["horizon"] == horizon { + return feature + } + } + t.Fatalf("visibility-boundary %s/%s not found", phase, horizon) + return decodedFeature{} +} + +func riseSetFeatureEndpoints(t *testing.T, feature decodedFeature) []decodedRiseSetEndpoint { + t.Helper() + var lines [][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode visibility-boundary coordinates: %v", err) + } + timeSegments, ok := feature.Properties["times"].([]interface{}) + if !ok || len(timeSegments) != len(lines) { + t.Fatalf("visibility-boundary times do not align with %d segments", len(lines)) + } + endpoints := make([]decodedRiseSetEndpoint, 0, len(lines)*2) + for segmentIndex, line := range lines { + times, ok := timeSegments[segmentIndex].([]interface{}) + if !ok || len(times) != len(line) || len(line) < 2 { + t.Fatalf("visibility-boundary segment %d has %d coordinates and invalid times", segmentIndex, len(line)) + } + for _, pointIndex := range []int{0, len(line) - 1} { + value, ok := times[pointIndex].(string) + if !ok { + t.Fatalf("visibility-boundary segment %d time %d has type %T", segmentIndex, pointIndex, times[pointIndex]) + } + endpoints = append(endpoints, decodedRiseSetEndpoint{ + coordinate: line[pointIndex], time: value, segment: segmentIndex, + }) + } + } + return endpoints +} + +func riseSetFeaturesShareEndpointInRegion( + t *testing.T, + first, second decodedFeature, + minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64, +) bool { + t.Helper() + firstEndpoints := riseSetFeatureEndpoints(t, first) + secondEndpoints := riseSetFeatureEndpoints(t, second) + for _, current := range firstEndpoints { + if !riseSetEndpointInRegion(current, minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude) { + continue + } + for _, other := range secondEndpoints { + if riseSetEndpointsMatch(current, other) { + return true + } + } + } + return false +} + +func riseSetFeaturesShareVertexInRegion( + t *testing.T, + first, second decodedFeature, + minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64, +) bool { + t.Helper() + firstVertices := riseSetFeatureVertices(t, first) + secondVertices := riseSetFeatureVertices(t, second) + for _, current := range firstVertices { + if !riseSetEndpointInRegion(current, minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude) { + continue + } + for _, other := range secondVertices { + if riseSetEndpointsMatch(current, other) { + return true + } + } + } + return false +} + +func riseSetFeatureVertices(t *testing.T, feature decodedFeature) []decodedRiseSetEndpoint { + t.Helper() + var lines [][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode visibility-boundary coordinates: %v", err) + } + timeSegments, ok := feature.Properties["times"].([]interface{}) + if !ok || len(timeSegments) != len(lines) { + t.Fatalf("visibility-boundary times do not align with %d segments", len(lines)) + } + vertices := make([]decodedRiseSetEndpoint, 0) + for segmentIndex, line := range lines { + times, ok := timeSegments[segmentIndex].([]interface{}) + if !ok || len(times) != len(line) { + t.Fatalf("visibility-boundary segment %d times do not align with coordinates", segmentIndex) + } + for pointIndex, point := range line { + value, ok := times[pointIndex].(string) + if !ok { + t.Fatalf("visibility-boundary segment %d time %d has type %T", segmentIndex, pointIndex, times[pointIndex]) + } + vertices = append(vertices, decodedRiseSetEndpoint{ + coordinate: point, time: value, segment: segmentIndex, + }) + } + } + return vertices +} + +func riseSetFeatureSegmentsShareEndpointInRegion( + t *testing.T, + feature decodedFeature, + minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64, +) bool { + t.Helper() + endpoints := riseSetFeatureEndpoints(t, feature) + for index, current := range endpoints { + if !riseSetEndpointInRegion(current, minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude) { + continue + } + for _, other := range endpoints[index+1:] { + if current.segment != other.segment && riseSetEndpointsMatch(current, other) { + return true + } + } + } + return false +} + +func riseSetFeatureHasInteriorVertexInRegion( + t *testing.T, + feature decodedFeature, + minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64, +) bool { + t.Helper() + var lines [][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode visibility-boundary coordinates: %v", err) + } + for _, line := range lines { + for index := 1; index+1 < len(line); index++ { + point := decodedRiseSetEndpoint{coordinate: line[index]} + if riseSetEndpointInRegion(point, minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude) { + return true + } + } + } + return false +} + +func riseSetEndpointInRegion( + point decodedRiseSetEndpoint, + minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64, +) bool { + return len(point.coordinate) >= 2 && + point.coordinate[0] >= minimumLongitude && point.coordinate[0] <= maximumLongitude && + point.coordinate[1] >= minimumLatitude && point.coordinate[1] <= maximumLatitude +} + +func riseSetEndpointsMatch(first, second decodedRiseSetEndpoint) bool { + return first.time == second.time && + math.Abs(first.coordinate[0]-second.coordinate[0]) <= 1e-9 && + math.Abs(first.coordinate[1]-second.coordinate[1]) <= 1e-9 +} + +func assertRiseSetFeatureHasNoInstantaneousBranchJump(t *testing.T, feature decodedFeature) { + t.Helper() + var lines [][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode visibility-boundary coordinates: %v", err) + } + timeSegments, ok := feature.Properties["times"].([]interface{}) + if !ok || len(timeSegments) != len(lines) { + t.Fatalf("visibility-boundary times do not align with %d segments", len(lines)) + } + for segmentIndex, line := range lines { + times, ok := timeSegments[segmentIndex].([]interface{}) + if !ok || len(times) != len(line) { + t.Fatalf("visibility-boundary segment %d times do not align with coordinates", segmentIndex) + } + for pointIndex := 1; pointIndex < len(line); pointIndex++ { + previous, previousOK := times[pointIndex-1].(string) + current, currentOK := times[pointIndex].(string) + previousTime, previousErr := time.Parse(time.RFC3339Nano, previous) + currentTime, currentErr := time.Parse(time.RFC3339Nano, current) + if !previousOK || !currentOK || previousErr != nil || currentErr != nil { + t.Fatalf("visibility-boundary segment %d has invalid adjacent times", segmentIndex) + } + distance := geoJSONCoordinateDistanceKM(line[pointIndex-1], line[pointIndex]) + if distance > 500 && absoluteDuration(currentTime.Sub(previousTime)) < time.Second { + t.Fatalf("visibility-boundary segment %d contains %.1f km jump in %s", + segmentIndex, distance, currentTime.Sub(previousTime)) + } + } + } +} + +func absoluteDuration(value time.Duration) time.Duration { + if value < 0 { + return -value + } + return value +} + +func assertOccultationBandPolygonCount(t *testing.T, collection decodedCollection, role string, want int) { + t.Helper() + band := featureWithRole(t, collection, role) + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode %s: %v", role, err) + } + if len(polygons) != want { + t.Fatalf("%s polygon count = %d, want %d continuous bands", role, len(polygons), want) + } +} + +func geoJSONMultiPolygonContains(polygons [][][][]float64, longitude, latitude float64) bool { + for _, polygon := range polygons { + if len(polygon) > 0 && geoJSONRingContains(polygon[0], longitude, latitude) { + return true + } + } + return false +} + +func geoJSONMultiPolygonBoundaryDistanceKM(polygons [][][][]float64, point []float64) float64 { + minimum := math.Inf(1) + for _, polygon := range polygons { + for _, ring := range polygon { + for index := 1; index < len(ring); index++ { + minimum = math.Min(minimum, geoJSONPointSegmentDistanceKM(point, ring[index-1], ring[index])) + } + } + } + return minimum +} + +func assertGeoJSONRingHasNoShortHairpins( + t *testing.T, + role string, + ring [][]float64, + maximumClosureKM, minimumDetourKM float64, + maximumSpan int, +) { + t.Helper() + for start := 0; start+3 < len(ring); start++ { + limit := start + maximumSpan + if limit >= len(ring) { + limit = len(ring) - 1 + } + arcLength := 0.0 + for end := start + 1; end <= limit; end++ { + arcLength += geoJSONCoordinateDistanceKM(ring[end-1], ring[end]) + if end < start+3 { + continue + } + closure := geoJSONCoordinateDistanceKM(ring[start], ring[end]) + if closure <= maximumClosureKM && arcLength-closure >= minimumDetourKM { + t.Fatalf( + "%s ring has a short hairpin at points %d..%d: closure %.1f km, arc %.1f km", + role, start, end, closure, arcLength, + ) + } + } + } +} + +func geoJSONRingContains(ring [][]float64, longitude, latitude float64) bool { + inside := false + for current, previous := 0, len(ring)-1; current < len(ring); previous, current = current, current+1 { + a, b := ring[previous], ring[current] + cross := (longitude-a[0])*(b[1]-a[1]) - (latitude-a[1])*(b[0]-a[0]) + if math.Abs(cross) <= 1e-9 && + longitude >= math.Min(a[0], b[0])-1e-9 && longitude <= math.Max(a[0], b[0])+1e-9 && + latitude >= math.Min(a[1], b[1])-1e-9 && latitude <= math.Max(a[1], b[1])+1e-9 { + return true + } + if (a[1] > latitude) == (b[1] > latitude) { + continue + } + intersection := a[0] + (latitude-a[1])*(b[0]-a[0])/(b[1]-a[1]) + if intersection > longitude { + inside = !inside + } + } + return inside +} + +func geoJSONCoordinateDistanceKM(first, second []float64) float64 { + firstLatitude := first[1] * math.Pi / 180 + secondLatitude := second[1] * math.Pi / 180 + deltaLatitude := secondLatitude - firstLatitude + deltaLongitude := math.Remainder((second[0]-first[0])*math.Pi/180, 2*math.Pi) + haversine := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) + + math.Cos(firstLatitude)*math.Cos(secondLatitude)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2) + return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, haversine))) +} + +func geoJSONPointSegmentDistanceKM(point, start, end []float64) float64 { + latitude := point[1] * math.Pi / 180 + x := func(value []float64) float64 { + return math.Remainder(value[0]-point[0], 360) * math.Cos(latitude) * math.Pi / 180 * 6378.1366 + } + y := func(value []float64) float64 { + return (value[1] - point[1]) * math.Pi / 180 * 6378.1366 + } + startX, startY := x(start), y(start) + endX, endY := x(end), y(end) + deltaX, deltaY := endX-startX, endY-startY + fraction := 0.0 + if lengthSquared := deltaX*deltaX + deltaY*deltaY; lengthSquared > 0 { + fraction = math.Max(0, math.Min(1, -(startX*deltaX+startY*deltaY)/lengthSquared)) + } + return math.Hypot(startX+fraction*deltaX, startY+fraction*deltaY) +} + func occultationSamples(start time.Time, longitudes, latitudes []float64) []moon.OccultationPathPoint { result := make([]moon.OccultationPathPoint, len(longitudes)) for index := range result { @@ -708,10 +2876,10 @@ func sampleFootprint(at time.Time, west, south, east, north float64) moon.Planet return moon.PlanetOccultationFootprint{ Time: at, Polygons: [][]moon.OccultationPathPoint{{ - {Longitude: west, Latitude: south}, - {Longitude: east, Latitude: south}, - {Longitude: east, Latitude: north}, - {Longitude: west, Latitude: north}, + {Time: at, Longitude: west, Latitude: south, MoonAltitude: 30, WidthKM: 1000}, + {Time: at, Longitude: east, Latitude: south, MoonAltitude: 30, WidthKM: 1000}, + {Time: at, Longitude: east, Latitude: north, MoonAltitude: 30, WidthKM: 1000}, + {Time: at, Longitude: west, Latitude: north, MoonAltitude: 30, WidthKM: 1000}, }}, } } diff --git a/geojson/lunar_horizon_test.go b/geojson/lunar_horizon_test.go new file mode 100644 index 0000000..c674843 --- /dev/null +++ b/geojson/lunar_horizon_test.go @@ -0,0 +1,183 @@ +package geojson_test + +import ( + "encoding/json" + "fmt" + "math" + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestLunarGeoJSONUsesTopocentricHorizon(t *testing.T) { + date := time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC) + info, ok := eclipse.LunarEclipseOnDate(date) + if !ok { + t.Fatal("missing lunar eclipse") + } + data, err := geojson.MarshalLunarEclipse(info, 360) + if err != nil { + t.Fatal(err) + } + collection := decodeCollection(t, data) + for _, contact := range []struct { + role, horizon string + at time.Time + }{ + {"visible-at-p1", "p1-horizon", info.PenumbralStart}, + {"visible-at-p4", "p4-horizon", info.PenumbralEnd}, + } { + band := featureWithRole(t, collection, contact.role) + line := featureWithRole(t, collection, contact.horizon) + var segments [][][]float64 + if err := json.Unmarshal(line.Geometry.Coordinates, &segments); err != nil { + t.Fatal(err) + } + jd := basic.Date2JDE(contact.at.UTC()) + for _, segment := range segments { + for _, point := range segment { + if math.Abs(point[0]) == 180 { + continue + } + if altitude := basic.HMoonHeight(jd, point[0], point[1], 0); math.Abs(altitude) > 1e-8 { + t.Fatalf("horizon altitude=%g", altitude) + } + } + } + for lon := -175.; lon < 180; lon += 10 { + for lat := -85.; lat < 90; lat += 10 { + altitude := basic.HMoonHeight(jd, lon, lat, 0) + if math.Abs(altitude) < 0.05 { + continue + } + if inside := geometryContainsPoint(t, band.Geometry, lon, lat); inside != (altitude > 0) { + t.Fatalf("%s point=(%v,%v) inside=%v altitude=%v", contact.role, lon, lat, inside, altitude) + } + } + } + } +} + +func TestLunarGeoJSON19040924DoesNotFillFalseSouthPolarCap(t *testing.T) { + date := time.Date(1904, 9, 24, 0, 0, 0, 0, time.UTC) + info, ok := eclipse.LunarEclipseOnDate(date) + if !ok { + t.Fatal("missing lunar eclipse") + } + data, err := geojson.MarshalLunarEclipse(info, 360) + if err != nil { + t.Fatal(err) + } + collection := decodeCollection(t, data) + band := featureWithRole(t, collection, "visible-at-p1") + point := struct { + longitude float64 + latitude float64 + }{-115, -89.9} + if altitude := basic.HMoonHeight(basic.Date2JDE(info.PenumbralStart), point.longitude, point.latitude, 0); altitude >= -0.01 { + t.Fatalf("regression witness altitude=%g, want below horizon", altitude) + } + if geometryContainsPoint(t, band.Geometry, point.longitude, point.latitude) { + t.Fatalf("visible-at-p1 contains below-horizon polar witness %+v", point) + } +} + +func TestLunarGeoJSONPolarVisibilityGrid(t *testing.T) { + for _, day := range []string{ + "0275-09-22", "0386-09-24", "1076-09-15", "1904-09-24", "2396-03-25", + "2779-03-24", "2955-09-23", "3188-09-27", "3738-03-19", "4026-03-16", + } { + t.Run(day, func(t *testing.T) { + date, err := time.Parse("2006-01-02", day) + if err != nil { + t.Fatal(err) + } + info, ok := eclipse.LunarEclipseOnDate(date) + if !ok { + t.Fatal("missing lunar eclipse") + } + counts := []int{360} + if day == "1904-09-24" { + counts = []int{12, 96, 360, 1440} + } + for _, count := range counts { + t.Run(fmt.Sprint(count), func(t *testing.T) { + assertLunarVisibilityGrid(t, info, count) + }) + } + }) + } +} + +func assertLunarVisibilityGrid(t *testing.T, info eclipse.LunarEclipseInfo, count int) { + t.Helper() + data, err := geojson.MarshalLunarEclipse(info, count) + if err != nil { + t.Fatal(err) + } + collection := decodeCollection(t, data) + latitudes := []float64{-89.999999, -89.999, -89.99, -89.9, -89.5, 89.5, 89.9, 89.99, 89.999, 89.999999} + for lat := -89.0; lat <= 89; lat += 2 { + latitudes = append(latitudes, lat) + } + for _, contact := range []struct { + role string + at time.Time + }{{"visible-at-p1", info.PenumbralStart}, {"visible-at-p4", info.PenumbralEnd}} { + band := featureWithRole(t, collection, contact.role) + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatal(err) + } + jd := basic.Date2JDE(contact.at.UTC()) + visible, invisible := 0, 0 + for _, lat := range latitudes { + for lon := -179.5; lon < 180; lon += 5 { + altitude := basic.HMoonHeight(jd, lon, lat, 0) + tolerance := 0.003 + if math.Abs(lat) > 89.99 { + tolerance = 1e-5 + } + if math.Abs(altitude) <= tolerance { + continue + } + inside := geoJSONMultiPolygonContains(polygons, lon, lat) + if inside != (altitude > 0) { + t.Fatalf("%s point=(%g,%g) inside=%v altitude=%g", contact.role, lon, lat, inside, altitude) + } + if inside { + visible++ + } else { + invisible++ + } + } + } + if visible == 0 || invisible == 0 { + t.Fatalf("%s grid must exercise both sides: visible=%d invisible=%d", contact.role, visible, invisible) + } + } +} + +func BenchmarkLunarEclipseGeoJSON(b *testing.B) { + for _, day := range []string{"1904-09-24", "2026-03-03", "4026-03-16"} { + date, err := time.Parse("2006-01-02", day) + if err != nil { + b.Fatal(err) + } + info, ok := eclipse.LunarEclipseOnDate(date) + if !ok { + b.Fatal("missing lunar eclipse") + } + b.Run(day, func(b *testing.B) { + b.ReportAllocs() + for iteration := 0; iteration < b.N; iteration++ { + if _, err := geojson.MarshalLunarEclipse(info, 360); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/geojson/lunar_visibility_ring_test.go b/geojson/lunar_visibility_ring_test.go new file mode 100644 index 0000000..76a1c45 --- /dev/null +++ b/geojson/lunar_visibility_ring_test.go @@ -0,0 +1,80 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +// TestLunarGeoJSONDropsAntimeridianSlivers 固定月食可见区的环卫生契约:日界线剪裁会在相邻 +// 世界各输出一次"全部顶点落在同一条子午线、平面面积只剩浮点噪声"的零宽薄片(实测 +// 1.8e-12 deg²,刚好越过共享剪裁器 1e-12 的零面积阈值),导出时必须在月食这一层丢弃, +// 同时不能连带丢掉有面积的面。 +// TestLunarGeoJSONDropsAntimeridianSlivers pins the ring-hygiene contract of the lunar +// visibility export: the antimeridian split emits one zero-width sliver per adjacent world whose +// vertices all sit on one meridian with a planar area of floating-point noise (measured +// 1.8e-12 deg^2, just past the shared splitter's 1e-12 zero-area floor). The lunar export must +// drop it without dropping polygons that do have area. +func TestLunarGeoJSONDropsAntimeridianSlivers(t *testing.T) { + for _, day := range []string{"2022-11-08", "2026-03-03", "4026-03-16", "1904-09-24", "2025-03-14"} { + t.Run(day, func(t *testing.T) { + date, err := time.Parse("2006-01-02", day) + if err != nil { + t.Fatal(err) + } + info, ok := eclipse.LunarEclipseOnDate(date) + if !ok { + t.Fatal("missing lunar eclipse") + } + data, err := geojson.MarshalLunarEclipse(info, 360) + if err != nil { + t.Fatal(err) + } + collection := decodeCollection(t, data) + for _, role := range []string{"visible-at-p1", "visible-at-p4"} { + feature := featureWithRole(t, collection, role) + var polygons [][][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil { + t.Fatal(err) + } + if len(polygons) == 0 { + t.Fatalf("%s has no polygons", role) + } + for index, polygon := range polygons { + area := 0.0 + for _, ring := range polygon { + if len(ring) < 4 { + t.Fatalf("%s polygon %d ring has %d positions", role, index, len(ring)) + } + distinct := 0 + for position, point := range ring { + if position == 0 || math.Abs(point[0]-ring[position-1][0]) > 1e-9 || + math.Abs(point[1]-ring[position-1][1]) > 1e-9 { + distinct++ + } + } + if distinct > 1 && + math.Abs(ring[0][0]-ring[len(ring)-1][0]) <= 1e-9 && + math.Abs(ring[0][1]-ring[len(ring)-1][1]) <= 1e-9 { + // 闭合点不是独立顶点 / The closing position is not a distinct vertex. + distinct-- + } + if distinct < 3 { + t.Fatalf("%s polygon %d is a zero-width sliver: %v", role, index, ring) + } + for position := 0; position+1 < len(ring); position++ { + area += ring[position][0]*ring[position+1][1] - ring[position+1][0]*ring[position][1] + } + } + if math.Abs(area/2) < 1e-12 { + t.Fatalf("%s polygon %d has no area: %v", role, index, polygon) + } + } + } + }) + } +} diff --git a/geojson/mars20250729_fixture_test.go b/geojson/mars20250729_fixture_test.go new file mode 100644 index 0000000..3606743 --- /dev/null +++ b/geojson/mars20250729_fixture_test.go @@ -0,0 +1,76 @@ +package geojson_test + +import ( + "sync" + "testing" + "time" + + "b612.me/astro/geojson" + "b612.me/astro/moon" +) + +type mars20250729FixtureKey struct { + riseSetStep time.Duration + includeTimeline bool +} + +type mars20250729Fixture struct { + path moon.PlanetOccultationPath + collection decodedCollection +} + +var mars20250729Fixtures = struct { + sync.Mutex + values map[mars20250729FixtureKey]*mars20250729Fixture +}{values: make(map[mars20250729FixtureKey]*mars20250729Fixture)} + +// mars20250729TestFixture computes each distinct Mars regression scenario once. +// The returned path and decoded collection are read-only test fixtures; caching +// them avoids repeating the expensive ephemeris and GeoJSON setup in related +// assertions without sharing mutable production state. +func mars20250729TestFixture( + t *testing.T, + riseSetStep time.Duration, + includeTimeline bool, +) *mars20250729Fixture { + t.Helper() + key := mars20250729FixtureKey{ + riseSetStep: riseSetStep, + includeTimeline: includeTimeline, + } + + mars20250729Fixtures.Lock() + defer mars20250729Fixtures.Unlock() + if fixture := mars20250729Fixtures.values[key]; fixture != nil { + return fixture + } + + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + options := moon.OccultationPathOptions{ + Step: 20 * time.Minute, + TargetSpacingKM: 900, + RiseSetStep: riseSetStep, + DisableFootprints: true, + } + if includeTimeline { + options.IncludeFootprintTimeline = true + options.FootprintTimelineStep = 5 * time.Minute + } + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationMars, options, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + fixture := &mars20250729Fixture{ + path: paths[0], + collection: decodeCollection(t, data), + } + mars20250729Fixtures.values[key] = fixture + return fixture +} diff --git a/geojson/mars20250729_projection_regression_test.go b/geojson/mars20250729_projection_regression_test.go new file mode 100644 index 0000000..803f515 --- /dev/null +++ b/geojson/mars20250729_projection_regression_test.go @@ -0,0 +1,200 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/internal/geodata" +) + +func TestMars20250729RenderedLineworkUsesProjectedSpacing(t *testing.T) { + collection := mars20250729TestFixture(t, time.Minute, true).collection + for _, role := range []string{"partial-band", "total-band", "band-outline", "total-band-outline"} { + feature := featureWithRole(t, collection, role) + if maximum := maxProjectedGeometryEdgeKM(feature.Geometry.Coordinates); maximum > 50 { + t.Fatalf("%s projected edge=%.1f km, want <=50 km", role, maximum) + } + } + for _, feature := range featuresWithRole(collection, "visibility-boundary") { + if maximum := maxProjectedGeometryEdgeKM(feature.Geometry.Coordinates); maximum > 40 { + t.Fatalf("visibility-boundary projected edge=%.1f km, want <=40 km", maximum) + } + } + for _, feature := range featuresWithRole(collection, "horizon-connector") { + if maximum := maxProjectedGeometryEdgeKM(feature.Geometry.Coordinates); maximum > 40 { + t.Fatalf("horizon-connector projected edge=%.1f km, want <=40 km", maximum) + } + } +} + +func TestMars20250729AuthoritativeBandsRejectPolarBacktracks(t *testing.T) { + collection := mars20250729TestFixture(t, time.Minute, true).collection + for _, role := range []string{"partial-band", "total-band"} { + for ringIndex, ring := range geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, role)) { + for pointIndex := 1; pointIndex+1 < len(ring); pointIndex++ { + if geoPointDistanceKM(ring[pointIndex-1], ring[pointIndex+1]) > 20 { + } else { + angle := geoJSONRingTurnDegrees(ring[pointIndex-1], ring[pointIndex], ring[pointIndex+1]) + if angle < 30 { + t.Fatalf("%s ring %d retains a %.2f degree polar backtrack at point %d", role, ringIndex, angle, pointIndex) + } + } + + previous, middle, next := ring[pointIndex-1], ring[pointIndex], ring[pointIndex+1] + if math.Abs(middle.Latitude) < 60 || + (middle.Latitude-previous.Latitude)*(next.Latitude-middle.Latitude) >= 0 || + projectedGeoJSONPointDistanceKM(previous, next) > 80 { + continue + } + if angle := projectedGeoJSONRingTurnDegrees(previous, middle, next); angle < 110 { + t.Fatalf("%s ring %d retains a %.2f degree projected sweep junction at point %d", role, ringIndex, angle, pointIndex) + } + } + } + } +} + +func TestMars20250729OuterPhaseEnvelopeSharesBandOutline(t *testing.T) { + collection := mars20250729TestFixture(t, time.Minute, true).collection + outline := featureWithRole(t, collection, "band-outline") + var outlineLines [][][]float64 + if err := json.Unmarshal(outline.Geometry.Coordinates, &outlineLines); err != nil { + t.Fatalf("decode band-outline: %v", err) + } + if len(outlineLines) != 1 || len(outlineLines[0]) < 1000 { + t.Fatalf("band-outline has %d lines and %d points, want one retained outer ring", len(outlineLines), len(outlineLines[0])) + } + maximumMatchedFraction := 0.0 + maximumSourceArcKM := 0.0 + for _, boundary := range featuresWithRole(collection, "visibility-boundary") { + phase, _ := boundary.Properties["phase"].(string) + if phase != "start" && phase != "end" { + continue + } + var lines [][][]float64 + if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode visibility-boundary: %v", err) + } + for _, line := range lines { + if len(line) < 2 { + continue + } + arc := 0.0 + matched := 0 + for index, point := range line { + if index > 0 { + arc += geoJSONCoordinateDistanceKM(line[index-1], point) + } + if geoPointLineDistanceKM( + geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}, outlineLines, + ) <= 0.5 { + matched++ + } + } + if arc > maximumSourceArcKM { + maximumSourceArcKM = arc + maximumMatchedFraction = float64(matched) / float64(len(line)) + } + } + } + if maximumSourceArcKM < 3000 || maximumMatchedFraction < 0.98 { + t.Fatalf("outer phase envelope matched fraction=%.3f over %.1f km, want >=.98 over the long exterior arc", + maximumMatchedFraction, maximumSourceArcKM) + } +} + +func geoJSONRingTurnDegrees(first, middle, last geodata.GeoPoint) float64 { + latitude := middle.Latitude * math.Pi / 180 + scale := math.Cos(latitude) + firstX := (first.Longitude - middle.Longitude) * scale + firstY := first.Latitude - middle.Latitude + lastX := (last.Longitude - middle.Longitude) * scale + lastY := last.Latitude - middle.Latitude + firstLength := math.Hypot(firstX, firstY) + lastLength := math.Hypot(lastX, lastY) + if firstLength <= 1e-12 || lastLength <= 1e-12 { + return 180 + } + cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength) + cosine = math.Max(-1, math.Min(1, cosine)) + return math.Acos(cosine) * 180 / math.Pi +} + +func projectedGeoJSONRingTurnDegrees(first, middle, last geodata.GeoPoint) float64 { + firstX, firstY := projectedGeoJSONPoint(first) + middleX, middleY := projectedGeoJSONPoint(middle) + lastX, lastY := projectedGeoJSONPoint(last) + firstX, firstY = firstX-middleX, firstY-middleY + lastX, lastY = lastX-middleX, lastY-middleY + firstLength := math.Hypot(firstX, firstY) + lastLength := math.Hypot(lastX, lastY) + if firstLength <= 1e-12 || lastLength <= 1e-12 { + return 180 + } + cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength) + cosine = math.Max(-1, math.Min(1, cosine)) + return math.Acos(cosine) * 180 / math.Pi +} + +func projectedGeoJSONPointDistanceKM(first, second geodata.GeoPoint) float64 { + firstX, firstY := projectedGeoJSONPoint(first) + secondX, secondY := projectedGeoJSONPoint(second) + return math.Hypot(secondX-firstX, secondY-firstY) +} + +func projectedGeoJSONPoint(point geodata.GeoPoint) (float64, float64) { + latitude := math.Max(-85.05112878, math.Min(85.05112878, point.Latitude)) * math.Pi / 180 + return 6378.1366 * point.Longitude * math.Pi / 180, + 6378.1366 * math.Log(math.Tan(math.Pi/4+latitude/2)) +} + +func maxProjectedGeometryEdgeKM(raw json.RawMessage) float64 { + var value interface{} + if err := json.Unmarshal(raw, &value); err != nil { + return math.Inf(1) + } + return maxProjectedGeometryValueEdgeKM(value) +} + +func maxProjectedGeometryValueEdgeKM(value interface{}) float64 { + array, ok := value.([]interface{}) + if !ok || len(array) == 0 { + return 0 + } + if len(array) >= 2 { + if _, ok := array[0].(float64); ok { + return 0 + } + if _, ok := array[0].([]interface{}); ok { + if first, ok := array[0].([]interface{}); ok && len(first) >= 2 { + if _, ok := first[0].(float64); ok { + maximum := 0.0 + for index := 1; index < len(array); index++ { + previous := array[index-1].([]interface{}) + current := array[index].([]interface{}) + maximum = math.Max(maximum, projectedCoordinateDistanceKM(previous, current)) + } + return maximum + } + } + } + } + maximum := 0.0 + for _, child := range array { + maximum = math.Max(maximum, maxProjectedGeometryValueEdgeKM(child)) + } + return maximum +} + +func projectedCoordinateDistanceKM(first, second []interface{}) float64 { + longitudeFirst := first[0].(float64) + latitudeFirst := math.Max(-85.05112878, math.Min(85.05112878, first[1].(float64))) * math.Pi / 180 + longitudeSecond := second[0].(float64) + latitudeSecond := math.Max(-85.05112878, math.Min(85.05112878, second[1].(float64))) * math.Pi / 180 + longitude := math.Remainder(longitudeSecond-longitudeFirst, 360) * math.Pi / 180 + firstY := math.Log(math.Tan(math.Pi/4 + latitudeFirst/2)) + secondY := math.Log(math.Tan(math.Pi/4 + latitudeSecond/2)) + return 6378.1366 * math.Hypot(longitude, secondY-firstY) +} diff --git a/geojson/mars20250729_total_band_regression_test.go b/geojson/mars20250729_total_band_regression_test.go new file mode 100644 index 0000000..28775c9 --- /dev/null +++ b/geojson/mars20250729_total_band_regression_test.go @@ -0,0 +1,62 @@ +package geojson_test + +import ( + "math" + "testing" + "time" + + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/occultationgeo" +) + +func TestMars20250729TotalBandIsSingleMergedFace(t *testing.T) { + collection := mars20250729TestFixture(t, time.Minute, true).collection + totalBand := featureWithRole(t, collection, "total-band") + rings := geoJSONMultiPolygonOuterRings(t, totalBand) + if len(rings) != 1 { + t.Fatalf("total-band polygons=%d, want one merged face", len(rings)) + } + for _, test := range []struct { + name string + probe geodata.GeoPoint + inside bool + }{ + {name: "visible-sweep", probe: geodata.GeoPoint{Longitude: -129.8230, Latitude: -77.4030}, inside: true}, + {name: "selected-west", probe: geodata.GeoPoint{Longitude: -134.6060, Latitude: -78.5601}, inside: true}, + {name: "selected-center", probe: geodata.GeoPoint{Longitude: -134.2300, Latitude: -78.0725}, inside: true}, + } { + inside := geodata.SphericalPolygonsContainPoints(rings, []geodata.GeoPoint{test.probe})[0] + if inside != test.inside { + t.Fatalf("total-band probe %s (%.4f, %.4f) inside=%v, want %v", test.name, test.probe.Longitude, test.probe.Latitude, inside, test.inside) + } + } +} + +func TestMars20250729FinalTotalBandHasWidePolarShoulderRepair(t *testing.T) { + collection := mars20250729TestFixture(t, time.Minute, true).collection + partial := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "partial-band")) + total := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band")) + + // The final GeoJSON pass must already have applied the total-only broad + // polar repair. Reapplying it should therefore be geometrically inert. + rerounded := occultationgeo.RoundAuthoritativeTotalBandJunctions(total) + remainingRepairKM := math.Max( + geodata.SphericalPolygonsPathMissDistanceKM(total, rerounded, true), + geodata.SphericalPolygonsPathMissDistanceKM(rerounded, total, true), + ) + if remainingRepairKM > 1 { + t.Fatalf("final total-band still has a %.1f km repairable polar shoulder", remainingRepairKM) + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 1 { + t.Fatalf("smoothed total-band lies %.1f km outside partial-band", miss) + } + + roundedPartial := occultationgeo.RoundAuthoritativeBandJunctions(partial) + partialChangeKM := math.Max( + geodata.SphericalPolygonsPathMissDistanceKM(partial, roundedPartial, true), + geodata.SphericalPolygonsPathMissDistanceKM(roundedPartial, partial, true), + ) + if partialChangeKM > 1 { + t.Fatalf("final partial-band unexpectedly needs %.1f km of further repair", partialChangeKM) + } +} diff --git a/geojson/occultation.go b/geojson/occultation.go index e9160fc..c7606ea 100644 --- a/geojson/occultation.go +++ b/geojson/occultation.go @@ -2,14 +2,102 @@ package geojson import ( "fmt" + "math" "time" "b612.me/astro/internal/geodata" + "b612.me/astro/internal/occultationgeo" "b612.me/astro/moon" ) const lunarOccultationEvent = "lunar-occultation" +type occultationBandKind uint8 + +const ( + occultationSweepBand occultationBandKind = iota + stellarOccultationBand + partialOccultationBand + totalOccultationBand +) + +// MarshalStarOccultationFootprint 将指定时刻的精确恒星月掩足迹编码为 GeoJSON FeatureCollection;事件外返回空集合。 +// MarshalStarOccultationFootprint encodes one exact stellar occultation footprint as a GeoJSON FeatureCollection; instants outside the event produce an empty collection. +func MarshalStarOccultationFootprint(instant moon.StarOccultationInstant) ([]byte, error) { + if instant.Time.IsZero() { + return nil, fmt.Errorf("geojson: stellar occultation footprint time is required") + } + if instant.Footprint == nil { + return marshalEmptyFeatureCollection() + } + if !instant.Footprint.Time.Equal(instant.Time) { + return nil, fmt.Errorf("geojson: stellar occultation footprint time must match the requested instant") + } + properties := map[string]interface{}{ + "target_type": "star", + "target_id": instant.TargetID, + "delta_t_seconds": instant.DeltaTSeconds, + "interp_signature": occultationFootprintSignature(instant.Footprint, "occultation"), + } + addOccultationClosureProperties(properties, instant.Footprint, instant.Time, instant.SublunarLongitude, instant.SublunarLatitude) + features, err := appendOccultationFootprints( + nil, "occultation-footprint", []moon.PlanetOccultationFootprint{*instant.Footprint}, properties, + ) + if err != nil { + return nil, err + } + return marshalFeatureCollection(features) +} + +// MarshalPlanetOccultationFootprints 将指定时刻可用的精确行星外切和内切足迹编码为 GeoJSON FeatureCollection;事件外返回空集合。 +// MarshalPlanetOccultationFootprints encodes the available exact outer- and inner-contact planetary footprints as a GeoJSON FeatureCollection; instants outside the event produce an empty collection. +func MarshalPlanetOccultationFootprints(instant moon.PlanetOccultationInstant) ([]byte, error) { + if instant.Time.IsZero() { + return nil, fmt.Errorf("geojson: planetary occultation footprint time is required") + } + if err := instant.Planet.Validate(); err != nil { + return nil, fmt.Errorf("geojson: planetary occultation target: %w", err) + } + if instant.Partial == nil && instant.Total == nil { + return marshalEmptyFeatureCollection() + } + properties := map[string]interface{}{ + "target_type": "planet", + "target_id": instant.TargetID, + "planet": string(instant.Planet), + "delta_t_seconds": instant.DeltaTSeconds, + } + features := make([]feature, 0, 2) + for _, current := range []struct { + role string + footprint *moon.PlanetOccultationFootprint + }{ + {role: "partial-footprint", footprint: instant.Partial}, + {role: "total-footprint", footprint: instant.Total}, + } { + if current.footprint == nil { + continue + } + if !current.footprint.Time.Equal(instant.Time) { + return nil, fmt.Errorf("geojson: %s time must match the requested instant", current.role) + } + currentProperties := cloneProperties(properties) + currentProperties["interp_signature"] = occultationFootprintSignature(current.footprint, current.role) + addOccultationClosureProperties( + currentProperties, current.footprint, instant.Time, + instant.SublunarLongitude, instant.SublunarLatitude, + ) + var err error + features, err = appendOccultationFootprints( + features, current.role, []moon.PlanetOccultationFootprint{*current.footprint}, currentProperties, + ) + if err != nil { + return nil, err + } + } + return marshalFeatureCollection(features) +} + // MarshalStarOccultation 将月掩恒星的全球掩带和中心线编码为 GeoJSON。 // MarshalStarOccultation encodes a global stellar occultation band and center line as GeoJSON. func MarshalStarOccultation(path moon.StarOccultationPath) ([]byte, error) { @@ -35,22 +123,76 @@ func marshalStarOccultation(path moon.StarOccultationPath, markerOptions *TimeMa return nil, err } properties := map[string]interface{}{ - "target_type": "star", - "target_id": path.TargetID, - "complete": path.Complete, - "step_seconds": path.Step.Seconds(), - "target_spacing_km": path.TargetSpacingKM, + "target_type": "star", + "target_id": path.TargetID, + "complete": path.Complete, + "compact_band": len(path.BandFootprints) > 0, + "step_seconds": path.Step.Seconds(), + "target_spacing_km": path.TargetSpacingKM, + "greatest_limit_separation_km": path.GreatestLimitSeparationKM, + } + var value geometry + var err error + authoritative := false + // 掩带边界优先由解析接触/相位网络定义,两种足迹模式共用同一构造器,瞬时足迹只作 + // 覆盖见证与时间轴细节;只有解析边界完全缺失时才沿用纯足迹扫掠。默认的密集瞬时足迹 + // 走纯扫掠会截断非中心事件的极向部分,使月升可见性边界落在掩带之外。 + // The band boundary prefers the analytic contact/phase network, so both footprint + // modes share one constructor and the footprints only witness coverage and carry + // the time axis. The pure sweep stays only for the case with no analytic boundary: + // applied to dense footprints it truncates the poleward part of non-central events + // and leaves the moonrise visibility boundary outside the band. + bandFootprints := path.BandFootprints + if len(bandFootprints) == 0 && (len(path.BandContours) > 0 || len(path.RiseSetCurves) > 0) { + bandFootprints = path.Footprints + } + switch { + case len(bandFootprints) > 0: + value, authoritative, err = occultationCompactBandGeometry( + bandFootprints, path.BandContours, path.VisibilityContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + stellarOccultationBand, + ) + case len(path.Footprints) > 0: + value, err = occultationFootprintSweepGeometry(path.Footprints) + default: + value, err = occultationBandGeometry(path.NorthernLimit, path.SouthernLimit) } - band, err := occultationBandPolygon(path.NorthernLimit, path.SouthernLimit) if err != nil { return nil, fmt.Errorf("geojson: stellar occultation band: %w", err) } - value, err := multiPolygonGeometry([][]geodata.GeoPoint{band}) - if err != nil { - return nil, fmt.Errorf("geojson: stellar occultation band: %w", err) + bandProperties := cloneProperties(properties) + if len(bandFootprints) > 0 { + applyOccultationBandSourceProperties(bandProperties, authoritative, len(path.BandContours)) } features := []feature{ - newFeature(lunarOccultationEvent, "occultation-band", value, cloneProperties(properties)), + newFeature(lunarOccultationEvent, "occultation-band", value, bandProperties), + } + features, err = appendOccultationBandOutline(features, "band-outline", value, bandProperties) + if err != nil { + return nil, err + } + if len(path.Footprints) > 0 { + features, err = appendTimedOccultationFootprintFeatures( + features, "occultation-footprint", path.Footprints, properties, + ) + if err != nil { + return nil, err + } + } + features, err = appendOccultationRiseSetCurveFeatures(features, path.RiseSetCurves, properties, "partial") + if err != nil { + return nil, err + } + // 连接线必须与掩带取自同一足迹集合,否则掩带走解析回退而来、连接线却按空输入生成。 + // The connectors must use the same footprint set as the band; otherwise a band + // built from the analytic fallback gets connectors generated from empty input. + features, err = appendOccultationHorizonConnectorFeatures( + features, bandFootprints, path.NorthernLimit, path.SouthernLimit, + path.RiseSetCurves, properties, "partial", stellarOccultationBand, + ) + if err != nil { + return nil, err } if len(path.CenterLine) > 0 { features, err = appendOccultationPathLine(features, "center-line", path.CenterLine, properties) @@ -58,11 +200,11 @@ func marshalStarOccultation(path moon.StarOccultationPath, markerOptions *TimeMa return nil, err } } - features, err = appendOccultationPathLine(features, "north-limit", path.NorthernLimit, properties) + features, err = appendOccultationBoundaryLine(features, "north-limit", path.NorthernLimit, properties) if err != nil { return nil, err } - features, err = appendOccultationPathLine(features, "south-limit", path.SouthernLimit, properties) + features, err = appendOccultationBoundaryLine(features, "south-limit", path.SouthernLimit, properties) if err != nil { return nil, err } @@ -122,23 +264,67 @@ func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *Ti return nil, err } properties := map[string]interface{}{ - "target_type": "planet", - "target_id": path.TargetID, - "planet": string(path.Planet), - "complete": path.Complete, - "has_total_band": path.HasTotalBand, - "total_complete": path.TotalComplete, - "step_seconds": path.Step.Seconds(), - "target_spacing_km": path.TargetSpacingKM, - "greatest_total_width_km": path.GreatestTotalWidthKM, + "target_type": "planet", + "target_id": path.TargetID, + "planet": string(path.Planet), + "complete": path.Complete, + "has_total_band": path.HasTotalBand, + "total_complete": path.TotalComplete, + "step_seconds": path.Step.Seconds(), + "target_spacing_km": path.TargetSpacingKM, + "greatest_total_width_km": path.GreatestTotalWidthKM, + "greatest_limit_separation_km": path.GreatestLimitSeparationKM, + "compact_band": len(path.PartialBandFootprints) > 0 || len(path.TotalBandFootprints) > 0, } - features := make([]feature, 0, len(path.PartialFootprints)+len(path.TotalFootprints)+12) + features := make([]feature, 0, len(path.PartialFootprints)+len(path.TotalFootprints)+14) var err error - if len(path.PartialFootprints) > 0 { + // 偏掩带与全掩带共用同一套边界来源:解析接触/相位网络存在时优先由它定义边界, + // 瞬时足迹只作覆盖见证与时间轴细节;两套解析边界都缺失时才保留纯足迹扫掠兼容路径。 + // Both bands share one boundary source: the analytic contact/phase network defines the + // boundary when present and the footprints only witness coverage and carry the time + // axis. The pure footprint sweep stays as the compatibility path used only when no + // analytic boundary exists. + partialFootprints := path.PartialBandFootprints + if len(partialFootprints) == 0 && (len(path.PartialBandContours) > 0 || len(path.RiseSetCurves) > 0) { + // 默认(密集瞬时足迹)模式改用解析边界,避免纯扫掠截断非中心事件的极向部分。 + // Dense-footprint mode switches to the analytic boundary so that a pure sweep + // cannot truncate the poleward part of a non-central event. + partialFootprints = path.PartialFootprints + } + totalFootprints := path.TotalBandFootprints + if len(totalFootprints) == 0 && (len(path.TotalBandContours) > 0 || len(path.TotalRiseSetCurves) > 0) { + // 全掩带沿用与偏掩带相同的边界来源选择。 + // The total band follows the same boundary-source selection as the partial band. + totalFootprints = path.TotalFootprints + } + switch { + case len(partialFootprints) > 0: + features, err = appendOccultationFootprintBand( + features, "partial-band", partialFootprints, path.PartialBandContours, + path.PartialVisibilityContours, path.NorthernLimit, path.SouthernLimit, + path.RiseSetCurves, properties, partialOccultationBand, + ) + if err == nil && len(path.PartialFootprints) > 0 { + features, err = appendTimedOccultationFootprintFeatures( + features, "partial-footprint", path.PartialFootprints, properties, + ) + } + case len(path.PartialFootprints) > 0: + bandGeometry, bandErr := occultationFootprintSweepGeometry(path.PartialFootprints) + if bandErr != nil { + return nil, fmt.Errorf("geojson: partial-band: %w", bandErr) + } + bandProperties := cloneProperties(properties) + bandProperties["static_band"] = true + features = append(features, newFeature(lunarOccultationEvent, "partial-band", bandGeometry, bandProperties)) + features, err = appendOccultationBandOutline(features, "band-outline", bandGeometry, bandProperties) + if err != nil { + return nil, err + } features, err = appendOccultationFootprints( features, "partial-footprint", path.PartialFootprints, properties, ) - } else { + default: features, err = appendOccultationBand( features, "partial-band", path.NorthernLimit, path.SouthernLimit, properties, ) @@ -148,11 +334,37 @@ func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *Ti } if path.HasTotalBand { - if len(path.TotalFootprints) > 0 { + // 全掩带的来源已在函数级解析完毕,这里只按已选集合出图。 + // The total-band source is already resolved at function scope; this block + // only emits the geometry for the chosen set. + switch { + case len(totalFootprints) > 0: + features, err = appendOccultationFootprintBand( + features, "total-band", totalFootprints, path.TotalBandContours, + path.TotalVisibilityContours, path.NorthernTotalLimit, path.SouthernTotalLimit, + path.TotalRiseSetCurves, properties, totalOccultationBand, + ) + if err == nil && len(path.TotalFootprints) > 0 { + features, err = appendTimedOccultationFootprintFeatures( + features, "total-footprint", path.TotalFootprints, properties, + ) + } + case len(path.TotalFootprints) > 0: + bandGeometry, bandErr := occultationFootprintSweepGeometry(path.TotalFootprints) + if bandErr != nil { + return nil, fmt.Errorf("geojson: total-band: %w", bandErr) + } + bandProperties := cloneProperties(properties) + bandProperties["static_band"] = true + features = append(features, newFeature(lunarOccultationEvent, "total-band", bandGeometry, bandProperties)) + features, err = appendOccultationBandOutline(features, "total-band-outline", bandGeometry, bandProperties) + if err != nil { + return nil, err + } features, err = appendOccultationFootprints( features, "total-footprint", path.TotalFootprints, properties, ) - } else { + default: features, err = appendOccultationBand( features, "total-band", path.NorthernTotalLimit, path.SouthernTotalLimit, properties, ) @@ -161,6 +373,60 @@ func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *Ti return nil, err } } + // Analytic contour bands already carry the complete contact/visibility/ + // phase boundary network. Do not run the legacy footprint-junction and + // outline-alignment passes over them: those passes intentionally rewrite + // polygon vertices and can reintroduce a boundary that is not in the + // analytic network. Legacy paths without visibility contours retain the + // containment repair for compatibility. + analyticBands := len(path.PartialBandContours) > 0 && len(path.RiseSetCurves) > 0 && + (!path.HasTotalBand || (len(path.TotalBandContours) > 0 && len(path.TotalRiseSetCurves) > 0)) + if !analyticBands { + features, err = constrainPlanetTotalBandWithinPartial(features) + if err != nil { + return nil, err + } + features, err = roundAuthoritativePlanetBandJunctions(features) + if err != nil { + return nil, err + } + features, err = alignAuthoritativePlanetBandOutlines( + features, path.RiseSetCurves, path.TotalRiseSetCurves, + ) + if err != nil { + return nil, err + } + } + + // Keep static band fills/outlines below the physical rise/set curves in + // feature order. OpenLayers uses the GeoJSON feature order within a vector + // source; appending the phase curves last prevents the static outline from + // covering the visible moonrise/morning phase boundary. + features, err = appendOccultationRiseSetCurveFeatures(features, path.RiseSetCurves, properties, "partial") + if err != nil { + return nil, err + } + // 与偏掩带同源:解析回退时掩带用了瞬时足迹,连接线也必须用同一集合。 + // Same source as the partial band: when the analytic fallback supplies the band + // from instantaneous footprints, the connectors must use that same set. + features, err = appendOccultationHorizonConnectorFeatures( + features, partialFootprints, path.NorthernLimit, path.SouthernLimit, + path.RiseSetCurves, properties, "partial", partialOccultationBand, + ) + if err != nil { + return nil, err + } + if path.HasTotalBand { + // 与全掩带同源,规则同偏掩带。 + // Same source as the total band, following the partial-band rule. + features, err = appendOccultationHorizonConnectorFeatures( + features, totalFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit, + path.TotalRiseSetCurves, properties, "total", totalOccultationBand, + ) + if err != nil { + return nil, err + } + } if len(path.CenterLine) > 0 { features, err = appendOccultationPathLine(features, "center-line", path.CenterLine, properties) @@ -168,22 +434,22 @@ func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *Ti return nil, err } } - features, err = appendOccultationPathLine(features, "north-limit", path.NorthernLimit, properties) + features, err = appendOccultationBoundaryLine(features, "north-limit", path.NorthernLimit, properties) if err != nil { return nil, err } - features, err = appendOccultationPathLine(features, "south-limit", path.SouthernLimit, properties) + features, err = appendOccultationBoundaryLine(features, "south-limit", path.SouthernLimit, properties) if err != nil { return nil, err } if path.HasTotalBand { - features, err = appendOccultationPathLine( + features, err = appendOccultationBoundaryLine( features, "north-total-limit", path.NorthernTotalLimit, properties, ) if err != nil { return nil, err } - features, err = appendOccultationPathLine( + features, err = appendOccultationBoundaryLine( features, "south-total-limit", path.SouthernTotalLimit, properties, ) if err != nil { @@ -238,23 +504,511 @@ func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *Ti return marshalFeatureCollection(features) } +// roundAuthoritativePlanetBandJunctions runs after the partial/total +// containment pass. That pass may union the two bands and recreate a short +// polar sweep seam which was already removed from each source band. +func roundAuthoritativePlanetBandJunctions(features []feature) ([]feature, error) { + for index := range features { + role := features[index].Properties["role"] + if role != "partial-band" && role != "total-band" { + continue + } + authoritative, _ := features[index].Properties["static_band_authoritative"].(bool) + if !authoritative || features[index].Properties["source"] != "visible-footprint-sweep" { + continue + } + polygons, ok := geometryMultiPolygonPoints(features[index].Geometry) + if !ok { + continue + } + if role == "total-band" { + polygons = occultationgeo.RoundAuthoritativeTotalBandJunctions(polygons) + } else { + polygons = occultationgeo.RoundAuthoritativeBandJunctions(polygons) + } + value, err := multiPolygonGeometry(polygons) + if err != nil { + return nil, fmt.Errorf("geojson: round %s: %w", role, err) + } + features[index].Geometry = value + outlineRole := "band-outline" + if role == "total-band" { + outlineRole = "total-band-outline" + } + outline, outlineOK, outlineErr := occultationBandOutlineGeometry(value) + if outlineErr != nil { + return nil, fmt.Errorf("geojson: round %s outline: %w", role, outlineErr) + } + if !outlineOK { + continue + } + for outlineIndex := range features { + if features[outlineIndex].Properties["role"] == outlineRole { + features[outlineIndex].Geometry = outline + } + } + } + return features, nil +} + +// alignAuthoritativePlanetBandOutlines replaces only the portions of an +// authoritative static outline that are also an exterior start/end phase +// envelope. The fill remains the full horizon-visible time union; this is a +// display-only operation that prevents a polygon union seam from showing as a +// spike where the purple phase boundary is already the physical outer edge. +func alignAuthoritativePlanetBandOutlines( + features []feature, + partialCurves, totalCurves []moon.OccultationRiseSetCurve, +) ([]feature, error) { + for index := range features { + role := features[index].Properties["role"] + var curves []moon.OccultationRiseSetCurve + switch role { + case "band-outline": + curves = partialCurves + case "total-band-outline": + curves = totalCurves + default: + continue + } + if authoritative, ok := features[index].Properties["static_band_authoritative"].(bool); !ok || !authoritative { + continue + } + aligned, changed, err := occultationBandOutlinePhaseOverlap( + features[index].Geometry, curves, + ) + if err != nil { + return nil, fmt.Errorf("geojson: align %s: %w", role, err) + } + if changed { + features[index].Geometry = aligned + } + } + return features, nil +} + +const ( + // The phase curve is sampled at about 35 km for display. Keep the match + // radius below one rendered edge so a nearby inner branch cannot be selected. + occultationPhaseOverlapDistanceKM = 35.0 + occultationPhaseOverlapJoinDistanceKM = 25.0 + occultationPhaseOverlapMinimumArcKM = 150.0 + occultationPhaseOverlapMaximumEdgeKM = 55.0 +) + +func occultationBandOutlinePhaseOverlap( + value geometry, + curves []moon.OccultationRiseSetCurve, +) (geometry, bool, error) { + if len(curves) == 0 { + return value, false, nil + } + base := value + var err error + if base.Type != "MultiLineString" { + var baseOK bool + base, baseOK, err = occultationBandOutlineGeometry(value) + if err != nil || !baseOK { + return value, false, err + } + } + if err != nil { + return value, false, err + } + coordinates, ok := base.Coordinates.([][][]float64) + if !ok { + return value, false, fmt.Errorf("outline coordinates have type %T", base.Coordinates) + } + phases := occultationPhaseEnvelopeLines(curves) + if len(phases) == 0 { + return value, false, nil + } + changed := false + for index, source := range coordinates { + ring := make([]geodata.GeoPoint, len(source)) + for pointIndex, point := range source { + if len(point) < 2 { + return value, false, fmt.Errorf("outline point %d/%d is malformed", index, pointIndex) + } + ring[pointIndex] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + aligned, ringChanged := alignOccultationOutlineRingPhaseOverlap(ring, phases) + if !ringChanged { + continue + } + changed = true + coordinates[index] = make([][]float64, len(aligned)) + for pointIndex, point := range aligned { + coordinates[index][pointIndex] = []float64{point.Longitude, point.Latitude} + } + } + if !changed { + return value, false, nil + } + return geometry{Type: "MultiLineString", Coordinates: coordinates}, true, nil +} + +func occultationPhaseEnvelopeLines( + curves []moon.OccultationRiseSetCurve, +) [][]geodata.GeoPoint { + densified := occultationgeo.DensifyRiseSetCurves(curves, 35) + lines := make([][]geodata.GeoPoint, 0) + for _, curve := range densified { + if curve.Phase != moon.RiseSetPhaseStart && curve.Phase != moon.RiseSetPhaseEnd { + continue + } + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + line := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + lines = append(lines, line) + } + } + return lines +} + +type occultationPhaseOverlapRun struct { + start, end int + arcKM float64 +} + +func alignOccultationOutlineRingPhaseOverlap( + ring []geodata.GeoPoint, + phases [][]geodata.GeoPoint, +) ([]geodata.GeoPoint, bool) { + if len(ring) < 5 { + return ring, false + } + closed := geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) + open := append([]geodata.GeoPoint(nil), ring...) + if closed { + open = open[:len(open)-1] + } + if len(open) < 4 { + return ring, false + } + // Process longer overlaps first. A short branch that shares an endpoint + // with the outer branch must not consume the same ring section first. + ordered := append([][]geodata.GeoPoint(nil), phases...) + for left := 0; left < len(ordered); left++ { + for right := left + 1; right < len(ordered); right++ { + if occultationGeoLineLengthKM(ordered[right]) > occultationGeoLineLengthKM(ordered[left]) { + ordered[left], ordered[right] = ordered[right], ordered[left] + } + } + } + changed := false + for _, phase := range ordered { + if len(phase) < 2 { + continue + } + if updated, ok := replaceOccultationOutlinePhaseRun(open, phase); ok { + open = updated + changed = true + // A second branch can share the same horizon endpoint while lying + // on the inner side of the band. Only the longest verified overlap + // is the exterior envelope for this ring. + break + } + } + if !changed { + return ring, false + } + open = append(open, open[0]) + return open, true +} + +func replaceOccultationOutlinePhaseRun( + ring, phase []geodata.GeoPoint, +) ([]geodata.GeoPoint, bool) { + runs := occultationPhaseOverlapRuns(phase, ring) + if len(runs) == 0 { + return ring, false + } + best := runs[0] + for _, run := range runs[1:] { + if run.arcKM > best.arcKM { + best = run + } + } + if best.arcKM < occultationPhaseOverlapMinimumArcKM { + return ring, false + } + startPoint := phase[best.start] + endPoint := phase[best.end] + startIndex, startDistance := occultationNearestRingVertex(startPoint, ring) + endIndex, endDistance := occultationNearestRingVertex(endPoint, ring) + if startIndex < 0 || endIndex < 0 || + startDistance > occultationPhaseOverlapJoinDistanceKM || + endDistance > occultationPhaseOverlapJoinDistanceKM || startIndex == endIndex { + return ring, false + } + phaseRun := append([]geodata.GeoPoint(nil), phase[best.start:best.end+1]...) + forwardArc := occultationRingPathLengthKM(ring, startIndex, endIndex, 1) + backwardArc := occultationRingPathLengthKM(ring, startIndex, endIndex, -1) + phaseArc := occultationGeoLineLengthKM(phaseRun) + forward := math.Abs(forwardArc-phaseArc) <= math.Abs(backwardArc-phaseArc) + if !forward { + reverseOccultationGeoPoints(phaseRun) + startIndex, endIndex = endIndex, startIndex + } + if occultationGeoLineLengthKM(phaseRun) < occultationPhaseOverlapMinimumArcKM { + return ring, false + } + result := make([]geodata.GeoPoint, 0, len(ring)+len(phaseRun)) + result = append(result, phaseRun...) + stepDirection := 1 + if !forward { + stepDirection = -1 + } + index := (endIndex + stepDirection + len(ring)) % len(ring) + for index != startIndex { + result = append(result, ring[index]) + if forward { + index = (index + 1) % len(ring) + } else { + index = (index - 1 + len(ring)) % len(ring) + } + } + if len(result) < 4 || !occultationRingEdgesWithinKM(result, occultationPhaseOverlapMaximumEdgeKM) { + return ring, false + } + result = append(result, result[0]) + return result, true +} + +func occultationPhaseOverlapRuns( + phase, ring []geodata.GeoPoint, +) []occultationPhaseOverlapRun { + const maximumGapPoints = 2 + runs := make([]occultationPhaseOverlapRun, 0, 2) + start, gap := -1, 0 + for index, point := range phase { + _, distance := occultationNearestRingVertex(point, ring) + if distance <= occultationPhaseOverlapDistanceKM { + if start < 0 { + start = index + } + gap = 0 + continue + } + if start < 0 { + continue + } + gap++ + if gap <= maximumGapPoints { + continue + } + end := index - gap + if end > start { + arc := occultationGeoLineLengthKM(phase[start : end+1]) + if arc >= occultationPhaseOverlapMinimumArcKM { + runs = append(runs, occultationPhaseOverlapRun{start: start, end: end, arcKM: arc}) + } + } + start, gap = -1, 0 + } + if start >= 0 { + end := len(phase) - 1 + if end > start { + arc := occultationGeoLineLengthKM(phase[start : end+1]) + if arc >= occultationPhaseOverlapMinimumArcKM { + runs = append(runs, occultationPhaseOverlapRun{start: start, end: end, arcKM: arc}) + } + } + } + return runs +} + +func occultationNearestRingVertex( + point geodata.GeoPoint, + ring []geodata.GeoPoint, +) (int, float64) { + index := -1 + distance := math.Inf(1) + for candidate, value := range ring { + current := occultationGeoDistanceKM(point, value) + if current < distance { + index, distance = candidate, current + } + } + return index, distance +} + +func occultationRingPathLengthKM(ring []geodata.GeoPoint, start, end, direction int) float64 { + if len(ring) == 0 || start < 0 || end < 0 || start >= len(ring) || end >= len(ring) { + return math.Inf(1) + } + length := 0.0 + index := start + for index != end { + next := (index + direction + len(ring)) % len(ring) + length += occultationGeoDistanceKM(ring[index], ring[next]) + index = next + if length > 1e8 { + return math.Inf(1) + } + } + return length +} + +func occultationRingEdgesWithinKM(ring []geodata.GeoPoint, maximum float64) bool { + for index := 1; index < len(ring); index++ { + if occultationGeoDistanceKM(ring[index-1], ring[index]) > maximum { + return false + } + } + return true +} + +func occultationGeoLineLengthKM(points []geodata.GeoPoint) float64 { + length := 0.0 + for index := 1; index < len(points); index++ { + length += occultationGeoDistanceKM(points[index-1], points[index]) + } + return length +} + +func occultationGeoDistanceKM(first, second geodata.GeoPoint) float64 { + const radiusKM = 6378.1366 + firstLatitude := first.Latitude * math.Pi / 180 + secondLatitude := second.Latitude * math.Pi / 180 + deltaLatitude := secondLatitude - firstLatitude + deltaLongitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180 + sineLatitude := math.Sin(deltaLatitude / 2) + sineLongitude := math.Sin(deltaLongitude / 2) + a := sineLatitude*sineLatitude + math.Cos(firstLatitude)*math.Cos(secondLatitude)*sineLongitude*sineLongitude + return 2 * radiusKM * math.Atan2(math.Sqrt(math.Max(0, a)), math.Sqrt(math.Max(0, 1-a))) +} + +func reverseOccultationGeoPoints(points []geodata.GeoPoint) { + for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 { + points[left], points[right] = points[right], points[left] + } +} + +func constrainPlanetTotalBandWithinPartial(features []feature) ([]feature, error) { + partialIndex, totalIndex := -1, -1 + for index, current := range features { + switch current.Properties["role"] { + case "partial-band": + partialIndex = index + case "total-band": + totalIndex = index + } + } + if partialIndex < 0 || totalIndex < 0 { + return features, nil + } + parent, parentOK := geometryMultiPolygonPoints(features[partialIndex].Geometry) + child, childOK := geometryMultiPolygonPoints(features[totalIndex].Geometry) + if !parentOK || !childOK { + return features, nil + } + initialMiss := geodata.SphericalPolygonsPathMissDistanceKM(parent, child, true) + partialSweepSource := features[partialIndex].Properties["source"] == "visible-footprint-sweep" + if initialMiss > 0 && initialMiss <= 25 && !partialSweepSource { + // A small total-vs-partial discrepancy is a sampling residual at the polar + // junction. Do not snap total vertices onto the partial ring: that creates + // a visible staircase made from the parent's unrelated samples. Expand the + // parent once by the already smooth child face and keep the child boundary + // intact. Larger discrepancies are core-geometry errors and are left for the + // caller to diagnose rather than silently widening the serialized band. + input := append(append([][]geodata.GeoPoint(nil), parent...), child...) + expanded, unionErr := geodata.UnionPolygons(input) + if unionErr == nil { + expanded = occultationgeo.CleanAuthoritativeBandPolygons(expanded) + } + expandedMiss := geodata.SphericalPolygonsPathMissDistanceKM(expanded, child, true) + if unionErr == nil && len(expanded) > 0 && expandedMiss <= 10 && + len(expanded) == 1 { + parentValue, parentErr := multiPolygonGeometry(expanded) + if parentErr != nil { + return nil, fmt.Errorf("geojson: expand partial-band: %w", parentErr) + } + features[partialIndex].Geometry = parentValue + for index := range features { + if features[index].Properties["role"] != "band-outline" { + continue + } + outline, ok, outlineErr := occultationBandOutlineGeometry(parentValue) + if outlineErr != nil { + return nil, fmt.Errorf("geojson: expand band-outline: %w", outlineErr) + } + if ok { + features[index].Geometry = outline + } + } + return features, nil + } + } + repaired := occultationgeo.ConstrainPolygonsWithin(parent, child) + if len(repaired) == 0 || geodata.SphericalPolygonsPathMissDistanceKM(parent, repaired, true) > 10 { + return features, nil + } + value, err := multiPolygonGeometry(repaired) + if err != nil { + return nil, fmt.Errorf("geojson: constrain total-band: %w", err) + } + features[totalIndex].Geometry = value + for index := range features { + if features[index].Properties["role"] != "total-band-outline" { + continue + } + outline, ok, outlineErr := occultationBandOutlineGeometry(value) + if outlineErr != nil { + return nil, fmt.Errorf("geojson: constrain total-band-outline: %w", outlineErr) + } + if ok { + features[index].Geometry = outline + } + } + return features, nil +} + +func geometryMultiPolygonPoints(value geometry) ([][]geodata.GeoPoint, bool) { + if value.Type != "MultiPolygon" { + return nil, false + } + coordinates, ok := value.Coordinates.([][][][]float64) + if !ok { + return nil, false + } + polygons := make([][]geodata.GeoPoint, 0, len(coordinates)) + for _, polygon := range coordinates { + if len(polygon) == 0 || len(polygon[0]) < 4 { + continue + } + ring := make([]geodata.GeoPoint, len(polygon[0])) + for index, point := range polygon[0] { + if len(point) < 2 { + return nil, false + } + ring[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + polygons = append(polygons, ring) + } + return polygons, len(polygons) > 0 +} + func appendOccultationBand( features []feature, role string, northern, southern []moon.OccultationPathPoint, properties map[string]interface{}, ) ([]feature, error) { - band, err := occultationBandPolygon(northern, southern) + value, err := occultationBandGeometry(northern, southern) if err != nil { return nil, fmt.Errorf("geojson: %s: %w", role, err) } - value, err := multiPolygonGeometry([][]geodata.GeoPoint{band}) - if err != nil { - return nil, fmt.Errorf("geojson: %s: %w", role, err) - } - return append(features, newFeature( + features = append(features, newFeature( lunarOccultationEvent, role, value, cloneProperties(properties), - )), nil + )) + return appendOccultationBandOutline(features, occultationBandOutlineRole(role), value, properties) } func appendOccultationFootprints( @@ -293,33 +1047,301 @@ func appendOccultationFootprints( return features, nil } -func occultationBandPolygon( +// appendTimedOccultationFootprintFeatures emits instantaneous footprint +// polygons with their sample time. Static band geometry remains separate. +func appendTimedOccultationFootprintFeatures( + features []feature, + role string, + footprints []moon.PlanetOccultationFootprint, + properties map[string]interface{}, +) ([]feature, error) { + return appendOccultationFootprints(features, role, footprints, properties) +} + +// applyOccultationBandSourceProperties 写掩带来源标签:解析边界可用时足迹仍是覆盖见证。 +func applyOccultationBandSourceProperties( + bandProperties map[string]interface{}, + authoritative bool, + contourCount int, +) { + bandProperties["static_band_authoritative"] = authoritative + if authoritative { + bandProperties["source"] = "visible-footprint-sweep" + bandProperties["boundary_source"] = "footprint-sweep+horizon-visible" + return + } + bandProperties["source"] = "footprint-sweep-fallback" + if contourCount > 0 { + bandProperties["boundary_source"] = "contact-contours+horizon-boundary" + } +} + +func appendOccultationFootprintBand( + features []feature, + role string, + footprints []moon.PlanetOccultationFootprint, + contours [][]moon.OccultationPathPoint, + visibilityContours [][]moon.OccultationPathPoint, northern, southern []moon.OccultationPathPoint, -) ([]geodata.GeoPoint, error) { + curves []moon.OccultationRiseSetCurve, + properties map[string]interface{}, + kind occultationBandKind, +) ([]feature, error) { + value, authoritative, err := occultationCompactBandGeometry( + footprints, contours, visibilityContours, northern, southern, curves, + kind, + ) + if err != nil { + return nil, fmt.Errorf("geojson: %s: %w", role, err) + } + bandProperties := cloneProperties(properties) + applyOccultationBandSourceProperties(bandProperties, authoritative, len(contours)) + features = append(features, newFeature( + lunarOccultationEvent, role, value, bandProperties, + )) + return appendOccultationBandOutline(features, occultationBandOutlineRole(role), value, bandProperties) +} + +func occultationBandOutlineRole(role string) string { + if role == "total-band" { + return "total-band-outline" + } + return "band-outline" +} + +// appendOccultationBandOutline emits a closed line representation of a static +// band polygon. It deliberately does not turn discontinuous line fragments +// in a GeometryCollection into a fake closure. +func appendOccultationBandOutline( + features []feature, + role string, + band geometry, + properties map[string]interface{}, +) ([]feature, error) { + outline, ok, err := occultationBandOutlineGeometry(band) + if err != nil { + return nil, fmt.Errorf("geojson: %s: %w", role, err) + } + if !ok { + return features, nil + } + outlineProperties := cloneProperties(properties) + sourceRole := "occultation-band" + if role == "total-band-outline" { + sourceRole = "total-band" + } else if properties["target_type"] == "planet" { + sourceRole = "partial-band" + } + outlineProperties["source_role"] = sourceRole + outlineProperties["closed"] = true + return append(features, newFeature(lunarOccultationEvent, role, outline, outlineProperties)), nil +} + +func occultationBandOutlineGeometry(value geometry) (geometry, bool, error) { + lines := make([][][]float64, 0) + var collect func(geometry) error + collect = func(current geometry) error { + switch current.Type { + case "Polygon": + rings, ok := current.Coordinates.([][][]float64) + if !ok { + return fmt.Errorf("polygon coordinates have type %T", current.Coordinates) + } + for _, ring := range rings { + if len(ring) >= 4 { + lines = append(lines, cloneGeoJSONLine(ring)) + } + } + case "MultiPolygon": + polygons, ok := current.Coordinates.([][][][]float64) + if !ok { + return fmt.Errorf("multi-polygon coordinates have type %T", current.Coordinates) + } + for _, polygon := range polygons { + for _, ring := range polygon { + if len(ring) >= 4 { + lines = append(lines, cloneGeoJSONLine(ring)) + } + } + } + case "GeometryCollection": + for _, child := range current.Geometries { + if err := collect(child); err != nil { + return err + } + } + case "", "MultiLineString", "LineString": + // A line-only fragment is intentionally not closed here. + default: + return fmt.Errorf("unsupported band geometry type %q", current.Type) + } + return nil + } + if err := collect(value); err != nil { + return geometry{}, false, err + } + if len(lines) == 0 { + return geometry{}, false, nil + } + return geometry{Type: "MultiLineString", Coordinates: lines}, true, nil +} + +func cloneGeoJSONLine(source [][]float64) [][]float64 { + result := make([][]float64, len(source)) + for index, point := range source { + result[index] = append([]float64(nil), point...) + } + return result +} + +func occultationFootprintSweepGeometry(footprints []moon.OccultationFootprint) (geometry, error) { + value, _, err := occultationCompactBandGeometry( + footprints, nil, nil, nil, nil, nil, occultationSweepBand, + ) + return value, err +} + +func occultationCompactBandGeometry( + footprints []moon.OccultationFootprint, + contours [][]moon.OccultationPathPoint, + visibilityContours [][]moon.OccultationPathPoint, + northern, southern []moon.OccultationPathPoint, + curves []moon.OccultationRiseSetCurve, + kind occultationBandKind, +) (geometry, bool, error) { + var ( + merged [][]geodata.GeoPoint + authoritative bool + err error + ) + analytic := len(contours) > 0 && len(curves) > 0 + switch kind { + case stellarOccultationBand: + if analytic { + merged, authoritative, err = occultationgeo.VisibleStarBandPolygonsFromAnalyticContours( + footprints, contours, visibilityContours, northern, southern, curves, + ) + } else if len(contours) > 0 { + merged, authoritative, err = occultationgeo.VisibleBandPolygonsFromContours( + footprints, contours, northern, southern, curves, + ) + } else { + merged, authoritative, err = occultationgeo.VisibleBandPolygons(footprints, northern, southern, curves) + } + case totalOccultationBand: + if analytic { + merged, authoritative, err = occultationgeo.VisibleTotalBandPolygonsFromAnalyticContours( + footprints, contours, visibilityContours, northern, southern, curves, + ) + } else if len(contours) > 0 { + merged, authoritative, err = occultationgeo.VisibleTotalBandPolygonsFromContours( + footprints, contours, northern, southern, curves, + ) + } else { + merged, authoritative, err = occultationgeo.VisibleTotalBandPolygons(footprints, northern, southern, curves) + } + case partialOccultationBand, occultationSweepBand: + if analytic { + merged, authoritative, err = occultationgeo.VisibleBandPolygonsFromAnalyticContours( + footprints, contours, visibilityContours, northern, southern, curves, + ) + } else if len(contours) > 0 { + merged, authoritative, err = occultationgeo.VisibleBandPolygonsFromContours( + footprints, contours, northern, southern, curves, + ) + } else { + merged, authoritative, err = occultationgeo.VisibleBandPolygons(footprints, northern, southern, curves) + } + } + if err != nil { + return geometry{}, false, err + } + value, err := multiPolygonGeometry(merged) + return value, authoritative, err +} + +func occultationBandGeometry( + northern, southern []moon.OccultationPathPoint, +) (geometry, error) { if len(northern) != len(southern) { - return nil, fmt.Errorf("paired limits must have the same sample count") + return geometry{}, fmt.Errorf("paired limits must have the same sample count") } count := len(northern) if count < 2 { - return nil, fmt.Errorf("paired limits require at least two points per side") + return geometry{}, fmt.Errorf("paired limits require at least two points per side") } for index := range northern { if northern[index].Time.IsZero() || southern[index].Time.IsZero() { - return nil, fmt.Errorf("paired limit sample %d time is required", index) + return geometry{}, fmt.Errorf("paired limit sample %d time is required", index) } if !northern[index].Time.Equal(southern[index].Time) { - return nil, fmt.Errorf("paired limit sample %d times must match", index) + return geometry{}, fmt.Errorf("paired limit sample %d times must match", index) } } - polygon := make([]geodata.GeoPoint, 0, 2*count) - for _, point := range northern[:count] { - polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + polygons := make([][]geodata.GeoPoint, 0, count-1) + sections := make([][]geodata.GeoPoint, 0, 2) + for _, sampleRange := range occultationgeo.ContinuousPairedBoundaryRanges(northern, southern) { + if sampleRange.End-sampleRange.Start == 1 { + north, south := northern[sampleRange.Start], southern[sampleRange.Start] + sections = append(sections, []geodata.GeoPoint{ + {Longitude: north.Longitude, Latitude: north.Latitude}, + {Longitude: south.Longitude, Latitude: south.Latitude}, + }) + continue + } + for index := sampleRange.Start + 1; index < sampleRange.End; index++ { + previousNorth, north := northern[index-1], northern[index] + previousSouth, south := southern[index-1], southern[index] + polygons = append(polygons, []geodata.GeoPoint{ + {Longitude: previousNorth.Longitude, Latitude: previousNorth.Latitude}, + {Longitude: north.Longitude, Latitude: north.Latitude}, + {Longitude: south.Longitude, Latitude: south.Latitude}, + {Longitude: previousSouth.Longitude, Latitude: previousSouth.Latitude}, + }) + } } - for index := count - 1; index >= 0; index-- { - point := southern[index] - polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + geometries := make([]geometry, 0, 2) + if len(polygons) > 0 { + merged, err := geodata.UnionPolygons(polygons) + if err != nil { + return geometry{}, fmt.Errorf("merge paired limit strips: %w", err) + } + value, err := multiPolygonGeometry(merged) + if err != nil { + return geometry{}, err + } + geometries = append(geometries, value) } - return polygon, nil + if len(sections) > 0 { + value, err := occultationBandSectionsGeometry(sections) + if err != nil { + return geometry{}, err + } + geometries = append(geometries, value) + } + if len(geometries) == 0 { + return geometry{}, fmt.Errorf("paired limits have no continuous polygon segments") + } + if len(geometries) == 1 { + return geometries[0], nil + } + return geometry{Type: "GeometryCollection", Geometries: geometries}, nil +} + +func occultationBandSectionsGeometry(sections [][]geodata.GeoPoint) (geometry, error) { + coordinates := make([][][]float64, 0, len(sections)) + for _, section := range sections { + value, err := geoMultiLineGeometry(section, false) + if err != nil { + return geometry{}, err + } + lines, ok := value.Coordinates.([][][]float64) + if !ok { + return geometry{}, fmt.Errorf("unexpected band-section geometry %T", value.Coordinates) + } + coordinates = append(coordinates, lines...) + } + return geometry{Type: "MultiLineString", Coordinates: coordinates}, nil } func appendOccultationPathLine( @@ -335,6 +1357,112 @@ func appendOccultationPathLine( return appendTimedLineFeature(features, lunarOccultationEvent, role, samples, properties) } +func appendOccultationBoundaryLine( + features []feature, + role string, + points []moon.OccultationPathPoint, + properties map[string]interface{}, +) ([]feature, error) { + ranges := occultationgeo.ContinuousBoundaryRanges(points) + segments := make([][]pathSample, len(ranges)) + for segmentIndex, sampleRange := range ranges { + segments[segmentIndex] = occultationPathSamples(points[sampleRange.Start:sampleRange.End]) + } + value, times, err := timedMultiLineGeometryFromSegments(segments) + if err != nil { + return nil, fmt.Errorf("geojson: %s: %w", role, err) + } + lineProperties := cloneProperties(properties) + lineProperties["times"] = times + return append(features, newFeature(lunarOccultationEvent, role, value, lineProperties)), nil +} + +func appendOccultationRiseSetCurveFeatures( + features []feature, + curves []moon.OccultationRiseSetCurve, + properties map[string]interface{}, + band string, +) ([]feature, error) { + curves = occultationgeo.DensifyRiseSetCurves(curves, 35) + for _, curve := range curves { + segments := make([][]pathSample, len(curve.Segments)) + for index, segment := range curve.Segments { + segments[index] = occultationPathSamples(segment) + } + value, times, err := timedMultiLineGeometryFromSegmentsWithTimeOrder(segments, true) + if err != nil { + return nil, fmt.Errorf("geojson: visibility-boundary: %w", err) + } + curveProperties := cloneProperties(properties) + curveProperties["phase"] = string(curve.Phase) + curveProperties["horizon"] = string(curve.Direction) + curveProperties["body"] = "moon" + if band != "" { + curveProperties["band"] = band + } + curveProperties["times"] = times + features = append(features, newFeature( + lunarOccultationEvent, "visibility-boundary", value, curveProperties, + )) + } + return features, nil +} + +func appendOccultationHorizonConnectorFeatures( + features []feature, + footprints []moon.OccultationFootprint, + northern, southern []moon.OccultationPathPoint, + curves []moon.OccultationRiseSetCurve, + properties map[string]interface{}, + band string, + kind occultationBandKind, +) ([]feature, error) { + connectors := occultationgeo.HorizonConnectorSegments(footprints, curves, northern, southern) + if kind == stellarOccultationBand { + connectors = occultationgeo.StarHorizonConnectorSegments(footprints, curves, northern, southern) + } + if len(connectors) == 0 { + return features, nil + } + segmentsByDirection := map[moon.RiseSetDirection][][]pathSample{ + moon.RiseSetDirectionRise: nil, + moon.RiseSetDirectionSet: nil, + } + for _, connector := range connectors { + if len(connector.Points) < 2 { + continue + } + connector.Points = occultationgeo.DensifyOccultationPathPoints(connector.Points, 35) + segmentsByDirection[connector.Direction] = append( + segmentsByDirection[connector.Direction], + occultationPathSamples(connector.Points), + ) + } + for _, direction := range []moon.RiseSetDirection{moon.RiseSetDirectionRise, moon.RiseSetDirectionSet} { + segments := segmentsByDirection[direction] + if len(segments) == 0 { + continue + } + value, times, err := timedMultiLineGeometryFromSegmentsWithTimeOrder(segments, false) + if err != nil { + return nil, fmt.Errorf("geojson: horizon-connector: %w", err) + } + connectorProperties := cloneProperties(properties) + connectorProperties["phase"] = "horizon" + connectorProperties["horizon"] = string(direction) + connectorProperties["body"] = "moon" + if band != "" { + connectorProperties["band"] = band + } + connectorProperties["source"] = "footprint-horizon-connector" + connectorProperties["times"] = times + features = append(features, newFeature( + lunarOccultationEvent, "horizon-connector", value, connectorProperties, + )) + } + return features, nil +} + func occultationPathSamples(points []moon.OccultationPathPoint) []pathSample { samples := make([]pathSample, len(points)) for index, point := range points { @@ -389,6 +1517,12 @@ func validateStarOccultationPathData(path moon.StarOccultationPath) error { if err := validateOccultationPathSeries("southern limit", path.SouthernLimit, true); err != nil { return err } + if err := validateOccultationContours("band contours", path.BandContours, path.Start.Time, path.End.Time); err != nil { + return err + } + if err := validateOccultationContours("visibility contours", path.VisibilityContours, path.Start.Time, path.End.Time); err != nil { + return err + } if len(path.NorthernLimit) != len(path.SouthernLimit) { return fmt.Errorf("geojson: occultation northern and southern limits must have the same sample count") } @@ -402,6 +1536,9 @@ func validateStarOccultationPathData(path moon.StarOccultationPath) error { !path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) { return fmt.Errorf("geojson: occultation limits must span start through end") } + if !finiteGeoJSON(path.GreatestLimitSeparationKM) || path.GreatestLimitSeparationKM < 0 { + return fmt.Errorf("geojson: occultation greatest limit separation must be finite and non-negative") + } if len(path.CenterLine) > 0 { if path.CenterLine[0].Time.Before(path.Start.Time) || path.CenterLine[len(path.CenterLine)-1].Time.After(path.End.Time) { @@ -412,7 +1549,13 @@ func validateStarOccultationPathData(path moon.StarOccultationPath) error { return fmt.Errorf("geojson: occultation greatest time is outside the center-line interval") } } - return nil + if err := occultationgeo.ValidateRiseSetCurves(path.RiseSetCurves, path.Start.Time, path.End.Time); err != nil { + return fmt.Errorf("geojson: invalid occultation rise/set curves: %w", err) + } + if err := validateOccultationFootprints("stellar", path.Footprints, path.Start.Time, path.End.Time); err != nil { + return err + } + return validateOccultationFootprints("stellar compact band", path.BandFootprints, path.Start.Time, path.End.Time) } func validatePlanetOccultationPathData(path moon.PlanetOccultationPath) error { @@ -420,7 +1563,10 @@ func validatePlanetOccultationPathData(path moon.PlanetOccultationPath) error { TargetID: path.TargetID, Start: path.Start, Greatest: path.Greatest, End: path.End, Complete: path.Complete, CenterLine: path.CenterLine, NorthernLimit: path.NorthernLimit, SouthernLimit: path.SouthernLimit, - Step: path.Step, TargetSpacingKM: path.TargetSpacingKM, + GreatestLimitSeparationKM: path.GreatestLimitSeparationKM, + BandContours: path.PartialBandContours, VisibilityContours: path.PartialVisibilityContours, + RiseSetCurves: path.RiseSetCurves, + Step: path.Step, TargetSpacingKM: path.TargetSpacingKM, } if err := validateStarOccultationPathData(starPath); err != nil { return err @@ -428,10 +1574,16 @@ func validatePlanetOccultationPathData(path moon.PlanetOccultationPath) error { if !path.HasTotalBand { if path.TotalComplete || !path.TotalStart.Time.IsZero() || !path.TotalEnd.Time.IsZero() || len(path.NorthernTotalLimit) != 0 || len(path.SouthernTotalLimit) != 0 || - len(path.TotalFootprints) != 0 || path.GreatestTotalWidthKM != 0 { + len(path.TotalFootprints) != 0 || len(path.TotalBandFootprints) != 0 || + len(path.TotalBandContours) != 0 || len(path.TotalVisibilityContours) != 0 || + len(path.TotalRiseSetCurves) != 0 || + path.GreatestTotalWidthKM != 0 { return fmt.Errorf("geojson: total-band fields require HasTotalBand") } - return validateOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time) + if err := validateOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil { + return err + } + return validateOccultationFootprints("partial compact band", path.PartialBandFootprints, path.Start.Time, path.End.Time) } if !path.TotalComplete { return fmt.Errorf("geojson: total-occultation band is incomplete") @@ -459,6 +1611,16 @@ func validatePlanetOccultationPathData(path moon.PlanetOccultationPath) error { if len(path.NorthernTotalLimit) != len(path.SouthernTotalLimit) { return fmt.Errorf("geojson: total northern and southern limits must have the same sample count") } + if err := validateOccultationContours( + "total band contours", path.TotalBandContours, path.TotalStart.Time, path.TotalEnd.Time, + ); err != nil { + return err + } + if err := validateOccultationContours( + "total visibility contours", path.TotalVisibilityContours, path.TotalStart.Time, path.TotalEnd.Time, + ); err != nil { + return err + } for index := range path.NorthernTotalLimit { if !path.NorthernTotalLimit[index].Time.Equal(path.SouthernTotalLimit[index].Time) { return fmt.Errorf("geojson: total limit sample %d times must match", index) @@ -471,10 +1633,35 @@ func validatePlanetOccultationPathData(path moon.PlanetOccultationPath) error { !path.SouthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) { return fmt.Errorf("geojson: total limits must span total start through total end") } + if err := occultationgeo.ValidateRiseSetCurves(path.TotalRiseSetCurves, path.TotalStart.Time, path.TotalEnd.Time); err != nil { + return fmt.Errorf("geojson: invalid total occultation rise/set curves: %w", err) + } if err := validateOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil { return err } - return validateOccultationFootprints("total", path.TotalFootprints, path.TotalStart.Time, path.TotalEnd.Time) + if err := validateOccultationFootprints("partial compact band", path.PartialBandFootprints, path.Start.Time, path.End.Time); err != nil { + return err + } + if err := validateOccultationFootprints("total", path.TotalFootprints, path.TotalStart.Time, path.TotalEnd.Time); err != nil { + return err + } + return validateOccultationFootprints("total compact band", path.TotalBandFootprints, path.TotalStart.Time, path.TotalEnd.Time) +} + +func validateOccultationContours( + name string, + contours [][]moon.OccultationPathPoint, + start, end time.Time, +) error { + for index, contour := range contours { + if err := validateOccultationPathSeries(fmt.Sprintf("%s[%d]", name, index), contour, true); err != nil { + return err + } + if contour[0].Time.Before(start) || contour[len(contour)-1].Time.After(end) { + return fmt.Errorf("geojson: %s[%d] must stay inside its contact interval", name, index) + } + } + return nil } func validateOccultationPathSeries(name string, points []moon.OccultationPathPoint, required bool) error { @@ -507,6 +1694,9 @@ func validateOccultationPathPoint(name string, point moon.OccultationPathPoint) if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 { return fmt.Errorf("geojson: %s width must be finite and non-negative", name) } + if !finiteGeoJSON(point.LimitSeparationKM) || point.LimitSeparationKM < 0 { + return fmt.Errorf("geojson: %s limit separation must be finite and non-negative", name) + } return nil } @@ -515,18 +1705,64 @@ func validateOccultationFootprints( footprints []moon.PlanetOccultationFootprint, start, end time.Time, ) error { - previous := time.Time{} - for index, footprint := range footprints { - if footprint.Time.IsZero() { - return fmt.Errorf("geojson: %s footprint[%d] time is required", name, index) - } - if footprint.Time.Before(start) || footprint.Time.After(end) { - return fmt.Errorf("geojson: %s footprint[%d] time must be inside its contact interval", name, index) - } - if !previous.IsZero() && !footprint.Time.After(previous) { - return fmt.Errorf("geojson: %s footprint times must be strictly increasing", name) - } - previous = footprint.Time + if err := occultationgeo.ValidateFootprints(footprints, start, end); err != nil { + return fmt.Errorf("geojson: %s footprints: %w", name, err) } return nil } + +// occultationFootprintSignature 与太阳侧的签名同构:由物理接触弧的顶点数、分段数与闭合标志给出。 +func occultationFootprintSignature( + footprint *moon.PlanetOccultationFootprint, + prefix string, +) string { + if footprint == nil || len(footprint.Boundaries) == 0 { + return "empty" + } + vertices, pole := 0, false + for _, segment := range footprint.Boundaries { + vertices += len(segment) + winding := 0.0 + for index := 1; index < len(segment); index++ { + winding += math.Remainder(segment[index].Longitude-segment[index-1].Longitude, 360) + } + if len(footprint.Boundaries) == 1 && math.Abs(winding) >= 180 { + pole = true + } + } + state := "open" + if footprint.Closed { + state = "closed" + } + signature := fmt.Sprintf("%s-%s-seg%d-pt%d", prefix, state, len(footprint.Boundaries), vertices) + if pole { + signature += "-pole" + } + return signature +} + +// addOccultationClosureProperties 声明掩星可见区的闭合弧:参照物是月球地平而非太阳。 +func addOccultationClosureProperties( + properties map[string]interface{}, + footprint *moon.PlanetOccultationFootprint, + value time.Time, + sublunarLongitude, sublunarLatitude float64, +) { + if footprint == nil { + return + } + properties["source_boundary_closed"] = footprint.Closed + if footprint.Closed { + return + } + properties["geometry_role"] = "horizon-closed-region" + closure := map[string]interface{}{ + "kind": "target-horizon", + "body": "moon", + "time": formatTime(value), + } + if !math.IsNaN(sublunarLongitude) && !math.IsNaN(sublunarLatitude) { + closure["sublunar"] = []float64{sublunarLongitude, sublunarLatitude} + } + properties["closure"] = closure +} diff --git a/geojson/occultation_envelope_regression_test.go b/geojson/occultation_envelope_regression_test.go new file mode 100644 index 0000000..635c2ef --- /dev/null +++ b/geojson/occultation_envelope_regression_test.go @@ -0,0 +1,79 @@ +package geojson_test + +import ( + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" + "b612.me/astro/moon" +) + +func TestOccultationDistantEnvelopesRemainComplete(t *testing.T) { + if err := basic.LoadStarData(); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + year, month, day int + planet moon.OccultationPlanet + hr int + }{ + {"mercury", 26, 4, 4, moon.OccultationMercury, 0}, + {"aldebaran", 26, 7, 26, "", 1457}, + {"regulus", 4026, 12, 14, "", 3982}, + {"saturn", 4026, 12, 29, moon.OccultationSaturn, 0}, + {"mars-fold", 1426, 7, 1, moon.OccultationMars, 0}, + {"venus-tangent", 1227, 5, 19, moon.OccultationVenus, 0}, + {"jupiter-horizon", 1226, 12, 23, moon.OccultationJupiter, 0}, + {"jupiter-time", 3627, 9, 20, moon.OccultationJupiter, 0}, + } { + for _, algorithm := range []moon.OccultationPathAlgorithm{moon.OccultationPathAlgorithmExact, moon.OccultationPathAlgorithmOptimized} { + t.Run(test.name+"/"+string(algorithm), func(t *testing.T) { + day := time.Date(test.year, time.Month(test.month), test.day, 0, 0, 0, 0, time.UTC) + options := moon.OccultationPathOptions{Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute} + var data []byte + var err error + role := "partial-band" + if test.hr == 0 { + paths, findErr := moon.FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), test.planet, options) + if findErr != nil || len(paths) != 1 { + t.Fatalf("paths=%d err=%v", len(paths), findErr) + } + data, err = geojson.MarshalPlanetOccultation(paths[0]) + } else { + catalog, catalogErr := basic.StarDataByHR(test.hr) + if catalogErr != nil { + t.Fatal(catalogErr) + } + star, starErr := moon.StarCoordinateFromStarData(catalog) + if starErr != nil { + t.Fatal(starErr) + } + paths, findErr := moon.FindStarOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), star, options) + if findErr != nil || len(paths) != 1 { + t.Fatalf("paths=%d err=%v", len(paths), findErr) + } + data, err = geojson.MarshalStarOccultation(paths[0]) + role = "occultation-band" + } + if err != nil { + t.Fatal(err) + } + collection := decodeCollection(t, data) + assertCollectionCoordinates(t, collection) + band := featureWithRole(t, collection, role) + assertClosedMultiPolygon(t, band) + if test.hr == 0 { + assertOccultationP2PhaseCurvesInsidePartialBand(t, collection, 2) + partial := geoJSONMultiPolygonOuterRings(t, band) + total := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band")) + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 2 { + t.Fatalf("total extends %.3f km beyond partial", miss) + } + } + }) + } + } +} diff --git a/geojson/occultation_instant_test.go b/geojson/occultation_instant_test.go new file mode 100644 index 0000000..0057859 --- /dev/null +++ b/geojson/occultation_instant_test.go @@ -0,0 +1,239 @@ +package geojson_test + +import ( + "encoding/json" + "testing" + "time" + + "b612.me/astro/geojson" + "b612.me/astro/moon" +) + +func TestMarshalPlanetOccultationFootprintsUsesTimelineRoles(t *testing.T) { + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + events, err := moon.FindBestPlanetOccultations( + start, start.Add(24*time.Hour), moon.OccultationSaturn, moon.OccultationSearchOptions{MaxEvents: 1}, + ) + if err != nil || len(events) != 1 { + t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err) + } + at := events[0].Greatest + instant, err := moon.PlanetOccultationFootprintsAt(at, moon.OccultationSaturn) + if err != nil { + t.Fatalf("PlanetOccultationFootprintsAt: %v", err) + } + raw, err := geojson.MarshalPlanetOccultationFootprints(instant) + if err != nil { + t.Fatalf("MarshalPlanetOccultationFootprints: %v", err) + } + roles, times := instantFootprintRoles(t, raw) + if roles["partial-footprint"] != 1 || roles["total-footprint"] != 1 { + t.Fatalf("roles=%v, want one partial and one total footprint", roles) + } + for role, value := range times { + if value != at.UTC().Format(time.RFC3339Nano) { + t.Fatalf("%s time=%q, want exact requested instant", role, value) + } + } +} + +func TestMarshalStarOccultationFootprintUsesTimelineRole(t *testing.T) { + star := moon.StarCoordinate{ + ID: "Antares", RA: 247.35166667, Dec: -26.43194444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + } + start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC) + events, err := moon.FindBestStarOccultations( + start, start.Add(24*time.Hour), star, moon.OccultationSearchOptions{MaxEvents: 1}, + ) + if err != nil || len(events) != 1 { + t.Fatalf("FindBestStarOccultations events=%d err=%v, want one", len(events), err) + } + instant, err := moon.StarOccultationFootprintAt(events[0].Greatest, star) + if err != nil { + t.Fatalf("StarOccultationFootprintAt: %v", err) + } + if instant.Footprint == nil { + t.Fatal("stellar footprint is nil at the event instant") + } + raw, err := geojson.MarshalStarOccultationFootprint(instant) + if err != nil { + t.Fatalf("MarshalStarOccultationFootprint: %v", err) + } + roles, _ := instantFootprintRoles(t, raw) + if roles["occultation-footprint"] != 1 { + t.Fatalf("roles=%v, want one stellar footprint", roles) + } +} + +func TestMarshalOccultationInstantOutsideEventReturnsEmptyCollection(t *testing.T) { + at := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) + tests := []struct { + name string + marshal func() ([]byte, error) + }{ + { + name: "star", + marshal: func() ([]byte, error) { + return geojson.MarshalStarOccultationFootprint(moon.StarOccultationInstant{ + Time: at, TargetID: "outside-event", + }) + }, + }, + { + name: "planet", + marshal: func() ([]byte, error) { + return geojson.MarshalPlanetOccultationFootprints(moon.PlanetOccultationInstant{ + Time: at, Planet: moon.OccultationSaturn, TargetID: "Saturn", + }) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw, err := test.marshal() + if err != nil { + t.Fatalf("marshal outside-event footprint: %v", err) + } + roles, _ := instantFootprintRoles(t, raw) + if len(roles) != 0 { + t.Fatalf("roles=%v, want empty FeatureCollection", roles) + } + var collection struct { + Features []json.RawMessage `json:"features"` + } + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatalf("decode empty FeatureCollection: %v", err) + } + if collection.Features == nil { + t.Fatal("features is null, want an empty JSON array") + } + }) + } +} + +func instantFootprintRoles(t *testing.T, raw []byte) (map[string]int, map[string]string) { + t.Helper() + var collection struct { + Features []struct { + Properties map[string]interface{} `json:"properties"` + } `json:"features"` + } + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatalf("decode GeoJSON: %v", err) + } + roles := make(map[string]int) + times := make(map[string]string) + for _, feature := range collection.Features { + role, _ := feature.Properties["role"].(string) + roles[role]++ + times[role], _ = feature.Properties["time"].(string) + } + return roles, times +} + +func TestMarshalPlanetOccultationFootprintsCarriesInstantMetadata(t *testing.T) { + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + events, err := moon.FindBestPlanetOccultations( + start, start.Add(24*time.Hour), moon.OccultationSaturn, moon.OccultationSearchOptions{MaxEvents: 1}, + ) + if err != nil || len(events) != 1 { + t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err) + } + instant, err := moon.PlanetOccultationFootprintsAt(events[0].Greatest, moon.OccultationSaturn) + if err != nil { + t.Fatalf("PlanetOccultationFootprintsAt: %v", err) + } + raw, err := geojson.MarshalPlanetOccultationFootprints(instant) + if err != nil { + t.Fatalf("MarshalPlanetOccultationFootprints: %v", err) + } + var collection struct { + Features []struct { + Properties map[string]interface{} `json:"properties"` + } `json:"features"` + } + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatalf("decode: %v", err) + } + if len(collection.Features) == 0 { + t.Fatal("no instant features") + } + if instant.DeltaTSeconds <= 0 || instant.SublunarLongitude == 0 && instant.SublunarLatitude == 0 { + t.Fatalf("instant metadata missing: ΔT=%.3f sublunar=(%.3f,%.3f)", + instant.DeltaTSeconds, instant.SublunarLongitude, instant.SublunarLatitude) + } + for _, feature := range collection.Features { + properties := feature.Properties + deltaT, ok := properties["delta_t_seconds"].(float64) + if !ok || deltaT <= 0 { + t.Fatalf("delta_t_seconds=%v, want a positive value", properties["delta_t_seconds"]) + } + if signature, _ := properties["interp_signature"].(string); signature == "" || signature == "empty" { + t.Fatalf("interp_signature=%v, want a footprint signature", properties["interp_signature"]) + } + closed, present := properties["source_boundary_closed"].(bool) + if !present { + t.Fatal("source_boundary_closed is missing") + } + closure, hasClosure := properties["closure"].(map[string]interface{}) + if closed { + if hasClosure { + t.Fatal("self-closed occultation footprint must not carry a closure") + } + continue + } + if !hasClosure || closure["kind"] != "target-horizon" || closure["body"] != "moon" { + t.Fatalf("closure=%v, want kind=target-horizon body=moon", properties["closure"]) + } + if _, present := closure["sublunar"]; !present { + t.Fatal("target-horizon closure is missing the sublunar reference") + } + if properties["geometry_role"] != "horizon-closed-region" { + t.Fatalf("geometry_role=%v, want horizon-closed-region", properties["geometry_role"]) + } + } +} + +func TestMarshalStarOccultationFootprintHorizonCutCarriesClosure(t *testing.T) { + star := moon.StarCoordinate{ + ID: "Antares", RA: 247.35166667, Dec: -26.43194444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + } + start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC) + events, err := moon.FindBestStarOccultations(start, start.Add(24*time.Hour), star, moon.OccultationSearchOptions{MaxEvents: 1}) + if err != nil || len(events) != 1 { + t.Fatalf("FindBestStarOccultations events=%d err=%v, want one", len(events), err) + } + instant, err := moon.StarOccultationFootprintAt(events[0].Greatest.Add(-120*time.Minute), star) + if err != nil { + t.Fatalf("StarOccultationFootprintAt: %v", err) + } + if instant.Footprint == nil { + t.Fatal("expected a footprint inside the event window") + } + if instant.Footprint.Closed { + t.Skip("this instant is self-closed; the horizon-cut branch is covered elsewhere") + } + raw, err := geojson.MarshalStarOccultationFootprint(instant) + if err != nil { + t.Fatalf("MarshalStarOccultationFootprint: %v", err) + } + var collection struct { + Features []struct { + Properties map[string]interface{} `json:"properties"` + } `json:"features"` + } + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatalf("decode: %v", err) + } + if len(collection.Features) != 1 { + t.Fatalf("features=%d, want one", len(collection.Features)) + } + closure, ok := collection.Features[0].Properties["closure"].(map[string]interface{}) + if !ok || closure["kind"] != "target-horizon" || closure["body"] != "moon" { + t.Fatalf("closure=%v, want kind=target-horizon body=moon", collection.Features[0].Properties["closure"]) + } +} diff --git a/geojson/occultation_limit_separation_test.go b/geojson/occultation_limit_separation_test.go new file mode 100644 index 0000000..114b610 --- /dev/null +++ b/geojson/occultation_limit_separation_test.go @@ -0,0 +1,127 @@ +package geojson_test + +import ( + "math" + "testing" + "time" + + "b612.me/astro/geojson" + "b612.me/astro/moon" +) + +// 导出属性必须同时给出 WidthKM(width_km)与限线间距(greatest_limit_separation_km),且两者口径不同。 + +func occultationLimitSeparationJSONValue(t *testing.T, feature decodedFeature, key string) float64 { + t.Helper() + raw, ok := feature.Properties[key] + if !ok { + t.Fatalf("feature role=%v has no %s property", feature.Properties["role"], key) + } + value, ok := raw.(float64) + if !ok || math.IsNaN(value) || math.IsInf(value, 0) { + t.Fatalf("feature role=%v %s=%v, want a finite number", feature.Properties["role"], key, raw) + } + return value +} + +func TestMarshalPlanetOccultationCarriesGreatestLimitSeparation(t *testing.T) { + start := time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationJupiter, + moon.OccultationPathOptions{ + Step: 10 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, DisableRiseSet: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + var greatestWidth, limitSeparation float64 + foundSeparation, foundGreatest := false, false + for _, feature := range collection.Features { + if value := occultationLimitSeparationJSONValue(t, feature, "greatest_limit_separation_km"); !foundSeparation { + limitSeparation = value + foundSeparation = true + } + if feature.Properties["role"] == "greatest" { + greatestWidth = occultationLimitSeparationJSONValue(t, feature, "width_km") + foundGreatest = true + } + } + if !foundSeparation || !foundGreatest { + t.Fatalf("limit separation=%v greatest width=%v, want both properties present", foundSeparation, foundGreatest) + } + if math.Abs(limitSeparation-3593.197135) > 0.5 { + t.Fatalf("greatest_limit_separation_km=%.6f, want 3593.197135 within 0.5 km", limitSeparation) + } + if math.Abs(greatestWidth-5319.263900) > 0.5 { + t.Fatalf("greatest width_km=%.6f, want 5319.263900 within 0.5 km", greatestWidth) + } + if math.Abs(greatestWidth-limitSeparation) < 1000 { + t.Fatalf("width_km=%.3f and greatest_limit_separation_km=%.3f are too close, want the two distinct constructions", + greatestWidth, limitSeparation) + } +} + +func TestMarshalPlanetOccultationRejectsNonFiniteLimitSeparation(t *testing.T) { + start := time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationJupiter, + moon.OccultationPathOptions{ + Step: 10 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, DisableRiseSet: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + broken := paths[0] + broken.NorthernLimit = append([]moon.OccultationPathPoint(nil), paths[0].NorthernLimit...) + broken.NorthernLimit[1].LimitSeparationKM = math.NaN() + if _, err := geojson.MarshalPlanetOccultation(broken); err == nil { + t.Fatal("MarshalPlanetOccultation accepted a NaN limit separation on a limit point") + } + broken = paths[0] + broken.GreatestLimitSeparationKM = math.Inf(1) + if _, err := geojson.MarshalPlanetOccultation(broken); err == nil { + t.Fatal("MarshalPlanetOccultation accepted a non-finite greatest limit separation") + } +} + +func TestMarshalStarOccultationCarriesGreatestLimitSeparation(t *testing.T) { + start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindStarOccultationPaths( + start, start.Add(24*time.Hour), hr4799OccultationCoordinateForGeoJSONLimitTest(), + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, DisableRiseSet: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalStarOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + collection := decodeCollection(t, data) + value := occultationLimitSeparationJSONValue(t, collection.Features[0], "greatest_limit_separation_km") + if math.Abs(value-3666.576690) > 0.5 { + t.Fatalf("greatest_limit_separation_km=%.6f, want 3666.576690 within 0.5 km", value) + } +} + +func hr4799OccultationCoordinateForGeoJSONLimitTest() moon.StarCoordinate { + return moon.StarCoordinate{ + ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -28, + ProperMotionDecMasPerYear: -18, + } +} diff --git a/geojson/occultation_noncentral_band_regression_test.go b/geojson/occultation_noncentral_band_regression_test.go new file mode 100644 index 0000000..0809498 --- /dev/null +++ b/geojson/occultation_noncentral_band_regression_test.go @@ -0,0 +1,257 @@ +package geojson_test + +import ( + "encoding/json" + "testing" + "time" + + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" + "b612.me/astro/moon" +) + +// TestMarshalPlanetOccultationNonCentralPartialBandContainsTotalBand 是非中心月掩偏掩带 +// 截断的回归:默认(密集瞬时足迹)模式下偏掩带曾只由纯足迹扫掠构造,极向部分被截断, +// 使月升可见性边界落在掩带之外,全掩带反而越出偏掩带。两场事件都是月影轴不与地球椭球 +// 相交、且相位曲线在极区折点处与零残差相切的非中心事件;全掩带必须完全落在偏掩带内。 +// TestMarshalPlanetOccultationNonCentralPartialBandContainsTotalBand covers the truncated +// partial band of non-central occultations: in the default dense-footprint mode the band +// used to be built by a pure footprint sweep, which cut off its poleward part, left the +// moonrise visibility boundary outside the band and let the total band escape it. Both +// events have a shadow axis that misses the ellipsoid and a phase curve that tangents the +// zero residual at a polar fold. The total band must stay inside the partial band. +func TestMarshalPlanetOccultationNonCentralPartialBandContainsTotalBand(t *testing.T) { + for _, start := range []time.Time{ + time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC), + } { + t.Run(start.Format("2006-01-02"), func(t *testing.T) { + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationNeptune, moon.OccultationPathOptions{}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + partialBand := featureWithRole(t, collection, "partial-band") + if authoritative, ok := partialBand.Properties["static_band_authoritative"].(bool); !ok || !authoritative { + t.Fatalf("partial-band source=%v authoritative=%v, want the analytic authoritative band", + partialBand.Properties["source"], partialBand.Properties["static_band_authoritative"]) + } + partialRings := geoJSONMultiPolygonOuterRings(t, partialBand) + totalRings := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band")) + if len(totalRings) == 0 { + t.Fatal("total-band geometry is missing") + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, totalRings, true); miss > 1 { + t.Fatalf("total-band escapes partial-band by %.1f km", miss) + } + }) + } +} + +// TestMarshalPlanetOccultationDenseModeBandKeepsCompactBandFlagFalse 固定默认(密集瞬时足迹) +// 模式的掩带来源契约:几何由解析接触/相位网络给出(static_band_authoritative),而 +// compact_band 只表达调用方是否请求了紧凑掩带模式,因此默认模式下必须仍为 false。 +// TestMarshalPlanetOccultationDenseModeBandKeepsCompactBandFlagFalse pins the dense-footprint +// default: the geometry comes from the analytic contact/phase network, while compact_band only +// reports whether the caller requested compact-band mode and therefore stays false. +func TestMarshalPlanetOccultationDenseModeBandKeepsCompactBandFlagFalse(t *testing.T) { + start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationNeptune, moon.OccultationPathOptions{}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + if len(paths[0].PartialFootprints) == 0 || len(paths[0].PartialBandFootprints) != 0 { + t.Fatalf("dense mode footprints=%d bandFootprints=%d, want the dense domain only", + len(paths[0].PartialFootprints), len(paths[0].PartialBandFootprints)) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + partialBand := featureWithRole(t, collection, "partial-band") + if compact, ok := partialBand.Properties["compact_band"].(bool); !ok || compact { + t.Fatalf("partial-band compact_band=%v, want false because compact mode was not requested", + partialBand.Properties["compact_band"]) + } + if authoritative, ok := partialBand.Properties["static_band_authoritative"].(bool); !ok || !authoritative { + t.Fatalf("partial-band source=%v authoritative=%v, want the analytic authoritative band", + partialBand.Properties["source"], partialBand.Properties["static_band_authoritative"]) + } +} + +// TestMarshalPlanetOccultationCentralEventIsStable ensures that the analytic +// fallback selection does not introduce nondeterministic output for an ordinary +// central event. Repeated marshaling of the same computed path must be byte stable. +func TestMarshalPlanetOccultationCentralEventIsStable(t *testing.T) { + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + first, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("first MarshalPlanetOccultation: %v", err) + } + second, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("second MarshalPlanetOccultation: %v", err) + } + if string(first) != string(second) { + t.Fatal("repeated central-event GeoJSON marshaling is not byte stable") + } +} + +// TestMarshalPlanetOccultationVisibilityBoundaryStaysInsidePartialBand 是可见性边界必须落在掩带 +// 内的回归:密集模式下偏掩带曾被纯足迹扫掠截断,月升可见性边界因此越出掩带。两种采样步长都要 +// 满足该不变式,否则紫色相位曲线会画在掩带之外。 +// TestMarshalPlanetOccultationVisibilityBoundaryStaysInsidePartialBand is the regression that +// keeps the visibility boundary inside the band: in dense mode the partial band used to be +// truncated by a pure footprint sweep and the moonrise boundary escaped it. Both sampling steps +// must satisfy the invariant, otherwise the phase curves are drawn outside the band. +func TestMarshalPlanetOccultationVisibilityBoundaryStaysInsidePartialBand(t *testing.T) { + const maximumMissKM = 2.0 + for _, test := range []struct { + name string + start time.Time + opts moon.OccultationPathOptions + }{ + { + name: "2025-01-05-dense", + start: time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC), + }, + { + name: "2025-02-01-dense", + start: time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "2025-02-01-compact", + start: time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC), + opts: moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, RiseSetStep: time.Minute, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + paths, err := moon.FindPlanetOccultationPaths( + test.start, test.start.Add(24*time.Hour), moon.OccultationNeptune, test.opts, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + partialRings := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "partial-band")) + boundaryPaths := make([][]geodata.GeoPoint, 0) + for _, feature := range featuresWithRole(collection, "visibility-boundary") { + boundaryPaths = append(boundaryPaths, geoJSONLineStringPaths(t, feature)...) + } + if len(boundaryPaths) == 0 { + t.Fatal("GeoJSON is missing the visibility-boundary phase curves") + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, boundaryPaths, false); miss > maximumMissKM { + t.Fatalf("visibility-boundary escapes partial-band by %.3f km, want <= %.1f km", miss, maximumMissKM) + } + }) + } +} + +// TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource 固定连接线与掩带同源的 +// 契约:紧凑模式下两者都由紧凑足迹给出,连接线存在;默认(密集瞬时足迹)模式下瞬时足迹没有 +// 可与相位端点配对的开放地平边界,因此不产生连接线。此前掩带会走解析回退、连接线却按空的 +// 原始紧凑足迹生成,两种模式的连接线来源不一致。 +// TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource pins the contract that +// connectors and band share one footprint source: in compact mode both come from the compact +// footprints and connectors exist, while dense instantaneous footprints carry no open horizon +// boundary to pair with phase endpoints and therefore yield none. Previously a band built from +// the analytic fallback still asked for connectors from the empty compact footprints, so the two +// modes disagreed about the connector source. +func TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource(t *testing.T) { + start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + opts moon.OccultationPathOptions + wantConnect int + }{ + { + name: "compact", + opts: moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, RiseSetStep: time.Minute, + }, + wantConnect: 1, + }, + {name: "dense", opts: moon.OccultationPathOptions{}, wantConnect: 0}, + } { + t.Run(test.name, func(t *testing.T) { + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationNeptune, test.opts, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + connectors := featuresWithRole(collection, "horizon-connector") + if test.wantConnect == 0 && len(connectors) != 0 { + t.Fatalf("horizon-connector count=%d, want none in dense mode", len(connectors)) + } + if test.wantConnect > 0 && len(connectors) == 0 { + t.Fatal("horizon-connector count=0, want the compact mode closures") + } + }) + } +} + +func geoJSONLineStringPaths(t *testing.T, feature decodedFeature) [][]geodata.GeoPoint { + t.Helper() + role := feature.Properties["role"] + var lines [][][]float64 + switch feature.Geometry.Type { + case "LineString": + var line [][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &line); err != nil { + t.Fatalf("decode %s coordinates: %v", role, err) + } + lines = [][][]float64{line} + case "MultiLineString": + if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode %s coordinates: %v", role, err) + } + default: + t.Fatalf("%s geometry=%q, want LineString or MultiLineString", role, feature.Geometry.Type) + } + paths := make([][]geodata.GeoPoint, 0, len(lines)) + for lineIndex, line := range lines { + path := make([]geodata.GeoPoint, len(line)) + for pointIndex, point := range line { + if len(point) < 2 { + t.Fatalf("%s line %d point %d is malformed", role, lineIndex, pointIndex) + } + path[pointIndex] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + paths = append(paths, path) + } + return paths +} diff --git a/geojson/occultation_venus_20250919_regression_test.go b/geojson/occultation_venus_20250919_regression_test.go new file mode 100644 index 0000000..25ecf24 --- /dev/null +++ b/geojson/occultation_venus_20250919_regression_test.go @@ -0,0 +1,38 @@ +package geojson_test + +import ( + "testing" + "time" + + "b612.me/astro/geojson" + "b612.me/astro/moon" +) + +func TestMarshalPlanetOccultationVenus20250919OptimizedContoursAreStrictlyOrdered(t *testing.T) { + day := time.Date(2025, 9, 19, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths( + day.Add(-12*time.Hour), day.Add(36*time.Hour), moon.OccultationVenus, + moon.OccultationPathOptions{ + Step: 5 * time.Minute, + TargetSpacingKM: 300, + DisableFootprints: true, + RiseSetStep: 30 * time.Second, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths paths=%d err=%v, want one path", len(paths), err) + } + if len(paths[0].PartialBandContours) != 2 { + t.Fatalf("partial contours=%d, want two", len(paths[0].PartialBandContours)) + } + for contourIndex, contour := range paths[0].PartialBandContours { + for index := 1; index < len(contour); index++ { + if !contour[index].Time.After(contour[index-1].Time) { + t.Fatalf("contour %d sample %d is not strictly increasing: %s then %s", contourIndex, index, contour[index-1].Time, contour[index].Time) + } + } + } + if _, err := geojson.MarshalPlanetOccultation(paths[0]); err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } +} diff --git a/geojson/path_regression_p2_benchmark_test.go b/geojson/path_regression_p2_benchmark_test.go new file mode 100644 index 0000000..12c32d4 --- /dev/null +++ b/geojson/path_regression_p2_benchmark_test.go @@ -0,0 +1,126 @@ +package geojson_test + +import ( + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/moon" +) + +func BenchmarkP2Mars20250729PlanetOccultationFind(b *testing.B) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + options := moon.OccultationPathOptions{ + Step: 20 * time.Minute, + TargetSpacingKM: 900, + RiseSetStep: time.Minute, + DisableFootprints: true, + IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + } + b.ReportAllocs() + for index := 0; index < b.N; index++ { + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationMars, options, + ) + if err != nil || len(paths) != 1 { + b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err) + } + } +} + +func BenchmarkP2Mars20250729PlanetOccultationGeoJSONMarshal(b *testing.B) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationMars, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, + TargetSpacingKM: 900, + RiseSetStep: time.Minute, + DisableFootprints: true, + IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err) + } + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil || len(data) == 0 { + b.Fatalf("MarshalPlanetOccultation() bytes=%d err=%v", len(data), err) + } + } +} + +func BenchmarkP2SolarEclipseGeoJSONMarshal(b *testing.B) { + partial, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 72, CentralShadowStep: 5 * time.Minute, + DisableRiseSet: true, + }, + ) + if !ok { + b.Fatal("expected 2024-04-08 eclipse") + } + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil || len(data) == 0 { + b.Fatalf("MarshalSolarEclipse() bytes=%d err=%v", len(data), err) + } + } +} + +func BenchmarkP2StarOccultationGeoJSONMarshal(b *testing.B) { + zone := time.FixedZone("UTC+8", 8*60*60) + star := moon.StarCoordinate{ + ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18, + } + paths, err := moon.FindStarOccultationPaths( + time.Date(2025, time.June, 5, 0, 0, 0, 0, zone), + time.Date(2025, time.June, 6, 0, 0, 0, 0, zone), star, + moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true}, + ) + if err != nil || len(paths) != 1 { + b.Fatalf("FindStarOccultationPaths() paths=%d err=%v", len(paths), err) + } + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + data, err := geojson.MarshalStarOccultation(paths[0]) + if err != nil || len(data) == 0 { + b.Fatalf("MarshalStarOccultation() bytes=%d err=%v", len(data), err) + } + } +} + +func BenchmarkP2PlanetOccultationGeoJSONMarshal(b *testing.B) { + zone := time.FixedZone("UTC+8", 8*60*60) + paths, err := moon.FindPlanetOccultationPaths( + time.Date(2025, time.January, 5, 0, 0, 0, 0, zone), + time.Date(2025, time.January, 6, 0, 0, 0, 0, zone), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true}, + ) + if err != nil || len(paths) != 1 { + b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err) + } + b.ReportAllocs() + b.ResetTimer() + for index := 0; index < b.N; index++ { + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil || len(data) == 0 { + b.Fatalf("MarshalPlanetOccultation() bytes=%d err=%v", len(data), err) + } + } +} diff --git a/geojson/path_regression_p2_test.go b/geojson/path_regression_p2_test.go new file mode 100644 index 0000000..3bc4548 --- /dev/null +++ b/geojson/path_regression_p2_test.go @@ -0,0 +1,924 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/occultationgeo" + "b612.me/astro/moon" +) + +// TestSolarEclipseP2SarosGeoJSONSamples serializes real eclipse paths at +// evenly spaced Saros-family offsets around the current epoch. It is kept +// intentionally smaller than the opt-in millennium diagnostic while still +// exercising antimeridian, polar and non-central topology in normal tests. +func TestSolarEclipseP2SarosGeoJSONSamples(t *testing.T) { + const sarosDays = 6585.321314 + seed := basic.JDECalc(2024, 4, 8) + for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} { + familyIndex := familyIndex + t.Run("saros-"+formatP2SignedIndex(familyIndex), func(t *testing.T) { + date := basic.JDE2DateByZone(seed+float64(familyIndex)*sarosDays, time.UTC, false) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute, + DisableRiseSet: true, + }) + if !ok || !partial.Eclipse.HasPartial { + t.Fatalf("solar eclipse unavailable at %s: ok=%v type=%s", date.Format("2006-01-02"), ok, partial.Eclipse.Type) + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse(%s): %v", date.Format("2006-01-02"), err) + } + collection := decodeCollection(t, data) + assertCollectionCoordinates(t, collection) + assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band")) + if partial.Eclipse.Type == eclipse.SolarEclipsePartial { + if len(featuresWithRole(collection, "central-band")) != 0 { + t.Fatal("partial-only eclipse unexpectedly contains a central band") + } + } else { + assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band")) + } + if len(featuresWithRole(collection, "greatest")) != 1 { + t.Fatalf("greatest feature count=%d, want one", len(featuresWithRole(collection, "greatest"))) + } + }) + } +} + +func TestOccultationP2RepresentativeGeoJSONPathsRemainClosed(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + star := moon.StarCoordinate{ + ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, + Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), + Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18, + } + starCases := []struct { + name string + start time.Time + }{ + {name: "hr4799-2025", start: time.Date(2025, time.June, 5, 0, 0, 0, 0, zone)}, + } + for _, test := range starCases { + t.Run(test.name, func(t *testing.T) { + paths, err := moon.FindStarOccultationPaths(test.start, test.start.Add(24*time.Hour), star, + moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true}) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalStarOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalStarOccultation: %v", err) + } + collection := decodeCollection(t, data) + assertCollectionCoordinates(t, collection) + assertClosedMultiPolygon(t, featureWithRole(t, collection, "occultation-band")) + assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline")) + assertRoles(t, collection, "occultation-band", "band-outline", "visibility-boundary", "center-line", "north-limit", "south-limit") + }) + } + + planetCases := []struct { + name string + start time.Time + planet moon.OccultationPlanet + }{ + {name: "venus-2025-09-19", start: time.Date(2025, time.September, 19, 0, 0, 0, 0, zone), planet: moon.OccultationVenus}, + {name: "saturn-2025-01-05", start: time.Date(2025, time.January, 5, 0, 0, 0, 0, zone), planet: moon.OccultationSaturn}, + {name: "mars-2025-06-30", start: time.Date(2025, time.June, 30, 0, 0, 0, 0, zone), planet: moon.OccultationMars}, + } + for _, test := range planetCases { + t.Run(test.name, func(t *testing.T) { + paths, err := moon.FindPlanetOccultationPaths(test.start, test.start.Add(24*time.Hour), test.planet, + moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true}) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultation(paths[0]) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + assertCollectionCoordinates(t, collection) + assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band")) + assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline")) + assertOccultationP0ContactContourSource(t, featureWithRole(t, collection, "partial-band")) + if paths[0].HasTotalBand { + assertClosedMultiPolygon(t, featureWithRole(t, collection, "total-band")) + assertClosedMultiLineFeature(t, featureWithRole(t, collection, "total-band-outline")) + assertOccultationP0ContactContourSource(t, featureWithRole(t, collection, "total-band")) + } + assertRoles(t, collection, "partial-band", "band-outline", "visibility-boundary", "center-line", "north-limit", "south-limit") + }) + } +} + +func TestOccultationP2Venus20250919HasNoInternalOutline(t *testing.T) { + start := time.Date(2025, time.September, 19, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600)) + for _, algorithm := range []moon.OccultationPathAlgorithm{moon.OccultationPathAlgorithmOptimized, moon.OccultationPathAlgorithmExact} { + t.Run(string(algorithm), func(t *testing.T) { + paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationVenus, + moon.OccultationPathOptions{ + Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, RiseSetStep: time.Minute, + }) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + data, err := geojson.MarshalPlanetOccultation(path) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + assertCollectionCoordinates(t, collection) + for _, role := range []string{"partial-band", "total-band"} { + feature := featureWithRole(t, collection, role) + assertClosedMultiPolygon(t, feature) + assertOccultationP0ContactContourSource(t, feature) + rings := geoJSONMultiPolygonOuterRings(t, feature) + if len(rings) != 1 { + t.Errorf("%s rings=%d, want one continuous band without overlapping slivers", role, len(rings)) + } + contours, curves, footprints := path.PartialBandContours, path.RiseSetCurves, path.PartialBandFootprints + if role == "total-band" { + contours, curves, footprints = path.TotalBandContours, path.TotalRiseSetCurves, path.TotalBandFootprints + } + lines := occultationP0AuthoritativeBoundaryLines(contours, curves, footprints) + for _, ring := range rings { + for _, point := range ring { + // Test the physical southern boundary, not the map's pole/dateline closure. + if point.Latitude < 10 && point.Longitude > 25 && point.Longitude < 40 { + if miss := geoPointLineDistanceKM(point, lines); miss > 1 { + t.Errorf("%s southern outline leaves source by %.3f km at %+v", role, miss, point) + } + } + } + } + } + assertOccultationP2PhaseCurvesInsidePartialBand(t, collection, 1) + partial := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "partial-band")) + total := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band")) + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 1 { + t.Fatalf("total band extends %.3f km beyond partial band", miss) + } + }) + } +} + +func TestOccultationP2Mars20250114GeoJSONAcceptsFoldedBandContours(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 14, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationMars, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + data, err := geojson.MarshalPlanetOccultationWithTimeMarkers( + paths[0], geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: zone}, + ) + if err != nil { + t.Fatalf("MarshalPlanetOccultationWithTimeMarkers: %v", err) + } + collection := decodeCollection(t, data) + assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band")) + assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline")) +} + +func TestOccultationP2Mars20250729AddsMoonriseHorizonConnector(t *testing.T) { + collection := mars20250729TestFixture(t, 5*time.Minute, true).collection + assertOccultationHorizonConnectorsAreAuxiliary(t, collection) + connector := horizonConnectorFeature(t, collection, "rise") + var lines [][][]float64 + if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode horizon connector coordinates: %v", err) + } + if len(lines) == 0 || len(lines) > 3 { + t.Fatalf("horizon connector segment count=%d, want one to three selected transition segments", len(lines)) + } + phaseEndpoints := mars20250729PhaseEndpoints(t, collection, "rise") + foundOpeningConnector := false + for _, line := range lines { + if len(line) < 2 { + continue + } + first, last := line[0], line[len(line)-1] + chord := geoJSONCoordinateDistanceKM(first, last) + if chord < 750 || chord > 1000 { + continue + } + total := 0.0 + maximumStep := 0.0 + for index := 1; index < len(line); index++ { + step := geoJSONCoordinateDistanceKM(line[index-1], line[index]) + total += step + if step > maximumStep { + maximumStep = step + } + } + if total > 1.25*chord || maximumStep > 200 { + t.Fatalf("moonrise horizon connector detours along the contact limb: total=%.1f chord=%.1f max-step=%.1f km", total, chord, maximumStep) + } + if !geoJSONCoordinateMatchesAny(line[0], phaseEndpoints) || + !geoJSONCoordinateMatchesAny(line[len(line)-1], phaseEndpoints) { + t.Fatalf("moonrise horizon connector endpoints do not meet physical phase endpoints: first=%v last=%v", + line[0], line[len(line)-1]) + } + foundOpeningConnector = true + break + } + if !foundOpeningConnector { + t.Fatal("2025-07-29 Mars occultation is missing the moonrise horizon connector in the South Pacific opening region") + } +} + +func TestOccultationP2Mars20250729OneMinuteRiseSetAddsOpeningConnector(t *testing.T) { + collection := mars20250729TestFixture(t, time.Minute, true).collection + connector := horizonConnectorFeature(t, collection, "rise") + var lines [][][]float64 + if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode horizon connector coordinates: %v", err) + } + startRise := riseSetBoundaryFeature(t, collection, "start", "rise") + startRiseEndpoints := riseSetFeatureEndpoints(t, startRise) + foundOpeningConnector := false + for _, line := range lines { + if len(line) < 2 { + continue + } + total := 0.0 + for pointIndex, point := range line { + if pointIndex > 0 { + total += geoJSONCoordinateDistanceKM(line[pointIndex-1], point) + } + } + chord := geoJSONCoordinateDistanceKM(line[0], line[len(line)-1]) + if chord < 250 || chord > 650 || total < 400 || total > 700 { + continue + } + maximumStep := 0.0 + for index := 1; index < len(line); index++ { + step := geoJSONCoordinateDistanceKM(line[index-1], line[index]) + if step > maximumStep { + maximumStep = step + } + } + if maximumStep > 120 { + t.Fatalf("one-minute moonrise opening connector max-step=%.1f km, want smooth closure", maximumStep) + } + if !geoJSONCoordinateMatchesAny(line[0], startRiseEndpoints) || + !geoJSONCoordinateMatchesAny(line[len(line)-1], startRiseEndpoints) { + t.Fatalf("one-minute moonrise opening connector endpoints do not meet start/rise phase endpoints: first=%v last=%v", + line[0], line[len(line)-1]) + } + foundOpeningConnector = true + break + } + if !foundOpeningConnector { + t.Fatal("2025-07-29 Mars one-minute rise/set GeoJSON is missing the opening moonrise connector") + } +} + +func TestOccultationP2Mars20250729DoesNotDuplicateSamePhaseFinalFold(t *testing.T) { + collection := mars20250729TestFixture(t, 5*time.Minute, true).collection + connector := horizonConnectorFeature(t, collection, "rise") + var lines [][][]float64 + if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode horizon connector coordinates: %v", err) + } + if len(lines) != 1 { + t.Fatalf("moonrise horizon connector segment count=%d, want only the opening closure; same-phase final fold must be part of end/rise", + len(lines)) + } +} + +func TestOccultationP2Mars20250729StaticBandsStayAuthoritativeAndBounded(t *testing.T) { + collection := mars20250729TestFixture(t, time.Minute, true).collection + for _, role := range []string{"partial-band", "total-band"} { + band := featureWithRole(t, collection, role) + if authoritative, ok := band.Properties["static_band_authoritative"].(bool); !ok || !authoritative { + t.Fatalf("%s is not authoritative: properties=%v", role, band.Properties) + } + outlineRole := "band-outline" + if role == "total-band" { + outlineRole = "total-band-outline" + } + outline := featureWithRole(t, collection, outlineRole) + var lines [][][]float64 + if err := json.Unmarshal(outline.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode %s: %v", outlineRole, err) + } + for lineIndex, line := range lines { + if len(line) < 4 { + t.Fatalf("%s line %d has %d points", outlineRole, lineIndex, len(line)) + } + maximum := 0.0 + for pointIndex := 1; pointIndex < len(line); pointIndex++ { + maximum = math.Max(maximum, geoJSONCoordinateDistanceKM(line[pointIndex-1], line[pointIndex])) + } + if maximum > 80 { + t.Fatalf("%s line %d has an artificial long edge %.1f km", outlineRole, lineIndex, maximum) + } + } + } +} + +func mustMarshalPlanetOccultation(t *testing.T, path moon.PlanetOccultationPath) []byte { + t.Helper() + data, err := geojson.MarshalPlanetOccultation(path) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + return data +} + +func TestOccultationP2Mars20250729DrawsPhaseCurvesAboveStaticBandOutlines(t *testing.T) { + collection := mars20250729TestFixture(t, time.Minute, true).collection + lastStaticOutline := -1 + firstPhaseCurve := len(collection.Features) + for index, feature := range collection.Features { + switch feature.Properties["role"] { + case "band-outline", "total-band-outline": + if index > lastStaticOutline { + lastStaticOutline = index + } + case "visibility-boundary": + if index < firstPhaseCurve { + firstPhaseCurve = index + } + } + } + if lastStaticOutline < 0 { + t.Fatal("Mars occultation GeoJSON is missing static band outlines") + } + if firstPhaseCurve >= len(collection.Features) { + t.Fatal("Mars occultation GeoJSON is missing physical phase curves") + } + if firstPhaseCurve <= lastStaticOutline { + t.Fatalf("physical phase curves start at feature %d, after static outline %d required", + firstPhaseCurve, lastStaticOutline) + } +} + +func TestOccultationP2Mars20250729TotalBandUsesInnerRiseSetCurves(t *testing.T) { + fixture := mars20250729TestFixture(t, time.Minute, true) + path := fixture.path + if !path.HasTotalBand { + t.Fatal("Mars path is missing its total band") + } + if len(path.TotalRiseSetCurves) == 0 { + t.Fatal("Mars total band is missing inner-contact rise/set curves") + } + totalBand := featureWithRole(t, fixture.collection, "total-band") + if authoritative, ok := totalBand.Properties["static_band_authoritative"].(bool); !ok || !authoritative { + t.Fatalf("total-band static_band_authoritative=%v, want true", totalBand.Properties["static_band_authoritative"]) + } + if totalBand.Properties["source"] != "visible-footprint-sweep" { + t.Fatalf("total-band source=%v, want visible-footprint-sweep from the full event-time footprint union", + totalBand.Properties["source"]) + } +} + +func TestOccultationP2Mars20250729ExportsOnlyOuterContactPhaseBoundaries(t *testing.T) { + fixture := mars20250729TestFixture(t, time.Minute, true) + path, collection := fixture.path, fixture.collection + for _, feature := range featuresWithRole(collection, "visibility-boundary") { + band, ok := feature.Properties["band"].(string) + if !ok || band != "partial" { + t.Fatalf("visibility-boundary has invalid band=%v: %#v", feature.Properties["band"], feature.Properties) + } + } + if count := len(featuresWithRole(collection, "visibility-boundary")); count != len(path.RiseSetCurves) { + t.Fatalf("visibility-boundary count=%d, want %d outer-contact phase curves", count, len(path.RiseSetCurves)) + } +} + +func TestOccultationP2Mars20250729StaticBandRemainsContinuousAndContained(t *testing.T) { + fixture := mars20250729TestFixture(t, time.Minute, true) + path, collection := fixture.path, fixture.collection + assertOccultationP2StaticBand(t, path, collection) + assertOccultationP2PhaseCurvesInsidePartialBand(t, collection, 1) +} + +func TestOccultationP2Saturn20240725StaticBandRemainsContinuousAndContained(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + data, err := geojson.MarshalPlanetOccultation(path) + if err != nil { + t.Fatalf("MarshalPlanetOccultation: %v", err) + } + collection := decodeCollection(t, data) + assertOccultationP2StaticBand(t, path, collection) +} + +func TestOccultationP0Mars20250729StaticBandsUseContactContours(t *testing.T) { + path := mars20250729TestFixture(t, time.Minute, true).path + if len(path.PartialBandContours) < 2 { + t.Fatalf("partial contact contours=%d, want north/south continuous envelopes", len(path.PartialBandContours)) + } + if !path.HasTotalBand || len(path.TotalBandContours) < 2 { + t.Fatalf("total contact contours=%d hasTotal=%v, want inner-contact envelopes", len(path.TotalBandContours), path.HasTotalBand) + } + collection := mars20250729TestFixture(t, time.Minute, true).collection + partialBand := featureWithRole(t, collection, "partial-band") + totalBand := featureWithRole(t, collection, "total-band") + assertOccultationP0ContactContourSource(t, partialBand) + assertOccultationP0ContactContourSource(t, totalBand) + if maximumStep := occultationPointSeriesMaximumStepKM(path.PartialBandContours[0]); maximumStep > 120 { + t.Fatalf("partial contour maximum step=%.1f km, want <=120 km", maximumStep) + } + if maximumStep := occultationPointSeriesMaximumStepKM(path.TotalBandContours[0]); maximumStep > 120 { + t.Fatalf("total contour maximum step=%.1f km, want <=120 km", maximumStep) + } + partialRings := geoJSONMultiPolygonOuterRings(t, partialBand) + totalRings := geoJSONMultiPolygonOuterRings(t, totalBand) + assertGeoJSONMultiPolygonMaximumEdge(t, "partial-band", partialRings, 80) + assertGeoJSONMultiPolygonMaximumEdge(t, "total-band", totalRings, 80) + assertGeoJSONMultiPolygonFollowsLines(t, "partial-band", partialRings, + occultationP0BoundaryLines(path.PartialBandContours, path.RiseSetCurves, + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit), + 250, + ) + assertGeoJSONMultiPolygonFollowsLines(t, "partial-band-authoritative", partialRings, + occultationP0AuthoritativeBoundaryLines(path.PartialBandContours, path.RiseSetCurves, + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit), + 300, + ) + assertGeoJSONMultiPolygonFollowsLines(t, "total-band", totalRings, + occultationP0BoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves, + path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit), + 250, + ) + assertGeoJSONMultiPolygonFollowsLines(t, "total-band-authoritative", totalRings, + occultationP0AuthoritativeBoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves, + path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit), + 300, + ) + greatest := [][]geodata.GeoPoint{{ + {Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}, + }} + if miss := geodata.SphericalPolygonsPathMissDistanceKM(totalRings, greatest, false); miss > 10 { + t.Fatalf("total-band misses greatest point by %.1f km", miss) + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, totalRings, true); miss > 10 { + t.Fatalf("total-band extends %.1f km outside the partial-band", miss) + } +} + +func TestOccultationP2Mars20250729EndRiseExportsRawPhaseSegments(t *testing.T) { + fixture := mars20250729TestFixture(t, 5*time.Minute, true) + path, collection := fixture.path, fixture.collection + endRise := riseSetBoundaryFeature(t, collection, "end", "rise") + var lines [][][]float64 + if err := json.Unmarshal(endRise.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode end/rise coordinates: %v", err) + } + var sourceSegments [][]moon.OccultationPathPoint + for _, curve := range path.RiseSetCurves { + if curve.Phase == moon.RiseSetPhaseEnd && curve.Direction == moon.RiseSetDirectionRise { + sourceSegments = curve.Segments + break + } + } + if len(sourceSegments) < 2 { + t.Fatalf("end/rise source segment count=%d, want a folded multi-branch phase curve", len(sourceSegments)) + } + if len(lines) != len(sourceSegments) { + t.Fatalf("end/rise segment count=%d, want raw source segment count %d", len(lines), len(sourceSegments)) + } + for segmentIndex, line := range lines { + if len(line) < 2 { + t.Fatalf("end/rise segment %d has %d points", segmentIndex, len(line)) + } + source := sourceSegments[segmentIndex] + if !geoJSONCoordinateMatchesPathPoint(line[0], source[0]) || + !geoJSONCoordinateMatchesPathPoint(line[len(line)-1], source[len(source)-1]) { + t.Fatalf("end/rise segment %d endpoints do not match raw source segment", segmentIndex) + } + maximumStep := 0.0 + for index := 1; index < len(line); index++ { + step := geoJSONCoordinateDistanceKM(line[index-1], line[index]) + if step > maximumStep { + maximumStep = step + } + } + if maximumStep > 200 { + t.Fatalf("end/rise segment %d has %.1f km maximum step, want smooth visible curvature", segmentIndex, maximumStep) + } + } +} + +func geoJSONCoordinateMatchesPathPoint(point []float64, source moon.OccultationPathPoint) bool { + return len(point) >= 2 && + geoJSONCoordinateDistanceKM(point, []float64{source.Longitude, source.Latitude}) <= 0.1 +} + +func horizonConnectorFeature( + t *testing.T, + collection decodedCollection, + horizon string, +) decodedFeature { + t.Helper() + for _, feature := range featuresWithRole(collection, "horizon-connector") { + if feature.Properties["phase"] == "horizon" && feature.Properties["horizon"] == horizon { + return feature + } + } + t.Fatalf("horizon-connector %s not found", horizon) + return decodedFeature{} +} + +func assertOccultationHorizonConnectorsAreAuxiliary(t *testing.T, collection decodedCollection) { + t.Helper() + if len(featuresWithRole(collection, "horizon-connector")) == 0 { + t.Fatal("GeoJSON is missing auxiliary horizon-connector features") + } + for _, feature := range featuresWithRole(collection, "visibility-boundary") { + if feature.Properties["phase"] == "horizon" { + t.Fatal("horizon connector was exported as a visibility-boundary") + } + } +} + +func assertOccultationP0ContactContourSource(t *testing.T, feature decodedFeature) { + t.Helper() + role := feature.Properties["role"] + if authoritative, ok := feature.Properties["static_band_authoritative"].(bool); !ok || !authoritative { + t.Fatalf("%s static_band_authoritative=%v, want true", role, feature.Properties["static_band_authoritative"]) + } + if source := feature.Properties["source"]; source != "visible-footprint-sweep" { + t.Fatalf("%s source=%v, want visible-footprint-sweep", role, source) + } + if boundarySource := feature.Properties["boundary_source"]; boundarySource != "footprint-sweep+horizon-visible" { + t.Fatalf("%s boundary_source=%v, want footprint-sweep+horizon-visible", role, boundarySource) + } +} + +func assertOccultationP2StaticBand( + t *testing.T, + path moon.PlanetOccultationPath, + collection decodedCollection, +) { + t.Helper() + partialBand := featureWithRole(t, collection, "partial-band") + totalBand := featureWithRole(t, collection, "total-band") + assertOccultationP0ContactContourSource(t, partialBand) + assertOccultationP0ContactContourSource(t, totalBand) + if maximumStep := occultationPointSeriesMaximumStepKM(path.PartialBandContours[0]); maximumStep > 120 { + t.Fatalf("partial contour maximum step=%.1f km, want <=120 km", maximumStep) + } + if path.HasTotalBand { + if len(path.TotalBandContours) < 2 { + t.Fatalf("total contour count=%d, want inner-contact envelopes", len(path.TotalBandContours)) + } + if maximumStep := occultationPointSeriesMaximumStepKM(path.TotalBandContours[0]); maximumStep > 120 { + t.Fatalf("total contour maximum step=%.1f km, want <=120 km", maximumStep) + } + } + partialRings := geoJSONMultiPolygonOuterRings(t, partialBand) + totalRings := geoJSONMultiPolygonOuterRings(t, totalBand) + assertGeoJSONMultiPolygonMaximumEdge(t, "partial-band", partialRings, 220) + assertGeoJSONMultiPolygonMaximumEdge(t, "total-band", totalRings, 220) + assertGeoJSONMultiPolygonFollowsLines(t, "partial-band", partialRings, + occultationP0BoundaryLines(path.PartialBandContours, path.RiseSetCurves, + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit), + 250, + ) + if path.HasTotalBand { + assertGeoJSONMultiPolygonFollowsLines(t, "total-band", totalRings, + occultationP0BoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves, + path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit), + // Split finite-disk branches use a sampled endpoint cap at the polar + // horizon; allow the bounded 265 km closure residual while retaining + // the stricter 250 km check for the stable partial envelope above. + 270, + ) + } + greatest := [][]geodata.GeoPoint{{ + {Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}, + }} + if miss := geodata.SphericalPolygonsPathMissDistanceKM(totalRings, greatest, false); miss > 10 { + t.Fatalf("total-band misses greatest point by %.1f km", miss) + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, totalRings, true); miss > 10 { + t.Fatalf("total-band extends %.1f km outside the partial-band", miss) + } +} + +func assertOccultationP2PhaseCurvesInsidePartialBand( + t *testing.T, + collection decodedCollection, + toleranceKM float64, +) { + t.Helper() + partialBand := featureWithRole(t, collection, "partial-band") + var polygons [][][][]float64 + if err := json.Unmarshal(partialBand.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode partial-band coordinates: %v", err) + } + maximumMissKM := 0.0 + var maximumMissPoint []float64 + for _, boundary := range featuresWithRole(collection, "visibility-boundary") { + if boundary.Properties["band"] != "partial" { + continue + } + var lines [][][]float64 + if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode partial visibility-boundary coordinates: %v", err) + } + for _, line := range lines { + for _, point := range line { + if geometryContainsPoint(t, partialBand.Geometry, point[0], point[1]) { + continue + } + missKM := geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) + if missKM > maximumMissKM { + maximumMissKM = missKM + maximumMissPoint = point + } + } + } + } + if maximumMissKM > toleranceKM { + t.Fatalf("partial visibility-boundary extends %.3f km outside rendered partial-band at %.6f, %.6f, want <=%.1f km", + maximumMissKM, maximumMissPoint[0], maximumMissPoint[1], toleranceKM) + } +} + +func geoJSONMultiPolygonOuterRings(t *testing.T, feature decodedFeature) [][]geodata.GeoPoint { + t.Helper() + if feature.Geometry.Type != "MultiPolygon" { + t.Fatalf("%s geometry=%q, want MultiPolygon", feature.Properties["role"], feature.Geometry.Type) + } + var polygons [][][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode %s coordinates: %v", feature.Properties["role"], err) + } + rings := make([][]geodata.GeoPoint, 0, len(polygons)) + for polygonIndex, polygon := range polygons { + if len(polygon) == 0 { + t.Fatalf("%s polygon %d has no rings", feature.Properties["role"], polygonIndex) + } + ring := make([]geodata.GeoPoint, len(polygon[0])) + for pointIndex, point := range polygon[0] { + if len(point) < 2 { + t.Fatalf("%s polygon %d point %d is malformed", feature.Properties["role"], polygonIndex, pointIndex) + } + ring[pointIndex] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + rings = append(rings, ring) + } + return rings +} + +func assertGeoJSONMultiPolygonMaximumEdge( + t *testing.T, + role string, + rings [][]geodata.GeoPoint, + maximumKM float64, +) { + t.Helper() + for ringIndex, ring := range rings { + for pointIndex := 1; pointIndex < len(ring); pointIndex++ { + step := geoPointDistanceKM(ring[pointIndex-1], ring[pointIndex]) + if step > maximumKM { + t.Fatalf("%s ring %d has %.1f km edge at %d, want <= %.1f km", + role, ringIndex, step, pointIndex, maximumKM) + } + } + } +} + +func assertGeoJSONMultiPolygonFollowsLines( + t *testing.T, + role string, + rings [][]geodata.GeoPoint, + lines [][][]float64, + maximumDistanceKM float64, +) { + t.Helper() + for ringIndex, ring := range rings { + for pointIndex, point := range ring { + distance := geoPointLineDistanceKM(point, lines) + if distance > maximumDistanceKM { + t.Fatalf("%s ring %d point %d is %.1f km from contact/rise-set boundary, want <= %.1f km: %.6f, %.6f", + role, ringIndex, pointIndex, distance, maximumDistanceKM, point.Longitude, point.Latitude) + } + } + } +} + +func occultationP0BoundaryLines( + contours [][]moon.OccultationPathPoint, + curves []moon.OccultationRiseSetCurve, + footprints []moon.PlanetOccultationFootprint, + limits ...[]moon.OccultationPathPoint, +) [][][]float64 { + lines := make([][][]float64, 0, len(contours)+len(curves)*2) + for _, contour := range contours { + if line := occultationP0PathLine(contour); len(line) >= 2 { + lines = append(lines, line) + } + } + for _, curve := range curves { + for _, segment := range curve.Segments { + if line := occultationP0PathLine(segment); len(line) >= 2 { + lines = append(lines, line) + } + } + } + for _, source := range occultationgeo.ContactSweepBoundaryLines(footprints) { + line := make([][]float64, 0, len(source)) + for _, point := range source { + line = append(line, []float64{point.Longitude, point.Latitude}) + } + if len(line) >= 2 { + lines = append(lines, line) + } + } + for _, connector := range occultationgeo.HorizonConnectorSegments(footprints, curves, limits...) { + if line := occultationP0PathLine(connector.Points); len(line) >= 2 { + lines = append(lines, line) + } + } + return lines +} + +func occultationP0AuthoritativeBoundaryLines( + contours [][]moon.OccultationPathPoint, + curves []moon.OccultationRiseSetCurve, + footprints []moon.PlanetOccultationFootprint, + limits ...[]moon.OccultationPathPoint, +) [][][]float64 { + lines := make([][][]float64, 0, len(contours)+len(curves)*2) + for _, contour := range contours { + if line := occultationP0PathLine(contour); len(line) >= 2 { + lines = append(lines, line) + } + } + for _, curve := range curves { + for _, segment := range curve.Segments { + if line := occultationP0PathLine(segment); len(line) >= 2 { + lines = append(lines, line) + } + } + } + for _, connector := range occultationgeo.HorizonConnectorSegments(footprints, curves, limits...) { + if line := occultationP0PathLine(connector.Points); len(line) >= 2 { + lines = append(lines, line) + } + } + return lines +} + +func occultationP0PathLine(points []moon.OccultationPathPoint) [][]float64 { + line := make([][]float64, 0, len(points)) + for _, point := range points { + line = append(line, []float64{point.Longitude, point.Latitude}) + } + return line +} + +func geoPointLineDistanceKM(point geodata.GeoPoint, lines [][][]float64) float64 { + target := []float64{point.Longitude, point.Latitude} + minimum := math.Inf(1) + for _, line := range lines { + for index := 1; index < len(line); index++ { + minimum = math.Min(minimum, geoJSONPointSegmentDistanceKM(target, line[index-1], line[index])) + } + } + return minimum +} + +func geoPointDistanceKM(first, second geodata.GeoPoint) float64 { + return geoJSONCoordinateDistanceKM( + []float64{first.Longitude, first.Latitude}, + []float64{second.Longitude, second.Latitude}, + ) +} + +func occultationPointSeriesMaximumStepKM(points []moon.OccultationPathPoint) float64 { + maximum := 0.0 + for index := 1; index < len(points); index++ { + step := geoPointDistanceKM( + geodata.GeoPoint{Longitude: points[index-1].Longitude, Latitude: points[index-1].Latitude}, + geodata.GeoPoint{Longitude: points[index].Longitude, Latitude: points[index].Latitude}, + ) + if step > maximum { + maximum = step + } + } + return maximum +} + +func geoJSONCoordinateMatchesAny(point []float64, endpoints []decodedRiseSetEndpoint) bool { + for _, endpoint := range endpoints { + if geoJSONCoordinateDistanceKM(point, endpoint.coordinate) <= 0.1 { + return true + } + } + return false +} + +func mars20250729PhaseEndpoints( + t *testing.T, + collection decodedCollection, + horizon string, +) []decodedRiseSetEndpoint { + t.Helper() + var endpoints []decodedRiseSetEndpoint + for _, phase := range []string{"start", "greatest", "end"} { + feature := riseSetBoundaryFeature(t, collection, phase, horizon) + endpoints = append(endpoints, riseSetFeatureEndpoints(t, feature)...) + } + return endpoints +} + +func assertClosedMultiLineFeature(t *testing.T, feature decodedFeature) { + t.Helper() + if feature.Geometry.Type != "MultiLineString" { + t.Fatalf("%s geometry=%q, want MultiLineString", feature.Properties["role"], feature.Geometry.Type) + } + var lines [][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode %s coordinates: %v", feature.Properties["role"], err) + } + if len(lines) == 0 { + t.Fatalf("%s has no outline segments", feature.Properties["role"]) + } + for index, line := range lines { + if len(line) < 4 { + t.Fatalf("%s segment %d has %d points, want a closed ring", feature.Properties["role"], index, len(line)) + } + first, last := line[0], line[len(line)-1] + if len(first) != 2 || len(last) != 2 || first[0] != last[0] || first[1] != last[1] { + t.Fatalf("%s segment %d is not closed: first=%v last=%v", feature.Properties["role"], index, first, last) + } + } +} + +func formatP2SignedIndex(value int) string { + if value < 0 { + return "-" + formatP2Magnitude(-value) + } + return "+" + formatP2Magnitude(value) +} + +func formatP2Magnitude(value int) string { + if value < 10 { + return "0" + string(rune('0'+value)) + } + return string(rune('0'+value/10)) + string(rune('0'+value%10)) +} + +// Keep the JSON import in this P2 file tied to the shared decoder contract; +// this catches accidental changes that make a GeoJSON payload un-decodable +// even when the role-level checks still pass. +func TestP2GeoJSONPayloadsDecodeAsFeatureCollections(t *testing.T) { + date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 30 * time.Minute, BoundaryPoints: 36, DisableRiseSet: true, + }) + if !ok { + t.Fatal("expected 2024-04-08 eclipse") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatal(err) + } + var raw struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("payload is not JSON: %v", err) + } + if raw.Type != "FeatureCollection" { + t.Fatalf("payload type=%q, want FeatureCollection", raw.Type) + } +} diff --git a/geojson/path_static_benchmark_test.go b/geojson/path_static_benchmark_test.go new file mode 100644 index 0000000..d4e0052 --- /dev/null +++ b/geojson/path_static_benchmark_test.go @@ -0,0 +1,77 @@ +package geojson_test + +import ( + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/moon" +) + +func BenchmarkStaticMap(b *testing.B) { + if err := basic.LoadStarData(); err != nil { + b.Fatal(err) + } + for _, test := range []struct { + name string + year, month, day int + planet moon.OccultationPlanet + hr int + }{ + {"saturn", 2025, 1, 5, moon.OccultationSaturn, 0}, + {"mars", 2025, 1, 14, moon.OccultationMars, 0}, + {"venus", 2025, 9, 19, moon.OccultationVenus, 0}, + {"star", 2025, 6, 5, "", 4799}, + {"partial", 2025, 3, 29, "", 0}, + {"hybrid", 2023, 4, 20, "", 0}, + {"annular2012", 2012, 5, 21, "", 0}, + {"annular2056", 2056, 7, 13, "", 0}, + } { + b.Run(test.name, func(b *testing.B) { + zone := time.FixedZone("UTC+8", 8*3600) + day := time.Date(test.year, time.Month(test.month), test.day, 0, 0, 0, 0, zone) + var star moon.StarCoordinate + if test.hr > 0 { + data, err := basic.StarDataByHR(test.hr) + if err != nil { + b.Fatal(err) + } + star, err = moon.StarCoordinateFromStarData(data) + if err != nil { + b.Fatal(err) + } + } + options := moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + var data []byte + var err error + if test.planet != "" { + paths, findErr := moon.FindPlanetOccultationPaths(day, day.Add(24*time.Hour), test.planet, options) + if findErr != nil || len(paths) != 1 { + b.Fatalf("paths=%d err=%v", len(paths), findErr) + } + data, err = geojson.MarshalPlanetOccultation(paths[0]) + } else if test.hr > 0 { + paths, findErr := moon.FindStarOccultationPaths(day, day.Add(24*time.Hour), star, options) + if findErr != nil || len(paths) != 1 { + b.Fatalf("paths=%d err=%v", len(paths), findErr) + } + data, err = geojson.MarshalStarOccultation(paths[0]) + } else { + path, ok := eclipse.SolarEclipsePartialFootprints(day, eclipse.SolarEclipsePartialFootprintOptions{Step: 5 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute}) + if !ok { + b.Fatal("missing eclipse") + } + data, err = geojson.MarshalSolarEclipse(path, nil) + } + if err != nil || len(data) == 0 { + b.Fatalf("bytes=%d err=%v", len(data), err) + } + } + }) + } +} diff --git a/geojson/review15_benchmark_test.go b/geojson/review15_benchmark_test.go new file mode 100644 index 0000000..a5db37b --- /dev/null +++ b/geojson/review15_benchmark_test.go @@ -0,0 +1,82 @@ +package geojson_test + +import ( + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +// 本文件钉住 §1.5 涉及的导出路径成本:掠食带的限线派生与打包足迹都在这条路径上。 +func review15BenchmarkDate(b *testing.B, text string) time.Time { + b.Helper() + if strings.HasPrefix(text, "-") { + parts := strings.Split(strings.TrimPrefix(text, "-"), "-") + if len(parts) != 3 { + b.Fatalf("invalid astronomical date %q", text) + } + year, err := strconv.Atoi(parts[0]) + if err != nil { + b.Fatal(err) + } + month, err := strconv.Atoi(parts[1]) + if err != nil { + b.Fatal(err) + } + day, err := strconv.Atoi(parts[2]) + if err != nil { + b.Fatal(err) + } + return time.Date(-year, time.Month(month), day, 12, 0, 0, 0, time.UTC) + } + date, err := time.Parse("2006-01-02", text) + if err != nil { + b.Fatal(err) + } + return date +} + +func benchmarkReview15SolarEclipseMarshal(b *testing.B, text string) { + date := review15BenchmarkDate(b, text) + info, ok := eclipse.SolarEclipseOnDate(date) + if !ok || !info.HasCentral { + b.Fatalf("expected a central solar eclipse on %s", text) + } + partialOptions, pathOptions := grazingBandOptions(info, "overview") + partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions) + if !ok { + b.Fatal("missing partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions) + if !ok { + b.Fatal("missing central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil || len(data) == 0 { + b.Fatalf("MarshalSolarEclipse() bytes=%d err=%v", len(data), err) + } + b.ReportAllocs() + b.SetBytes(int64(len(data))) + b.ResetTimer() + for index := 0; index < b.N; index++ { + value, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil || len(value) == 0 { + b.Fatalf("MarshalSolarEclipse() bytes=%d err=%v", len(value), err) + } + } +} + +func BenchmarkReview15SolarEclipseMarshalGrazing(b *testing.B) { + for _, text := range []string{"2003-05-31", "1874-10-10", "2061-10-13"} { + b.Run(text, func(b *testing.B) { + benchmarkReview15SolarEclipseMarshal(b, text) + }) + } +} + +func BenchmarkReview15SolarEclipseMarshalOrdinary(b *testing.B) { + benchmarkReview15SolarEclipseMarshal(b, "2024-04-08") +} diff --git a/geojson/review15_contract_test.go b/geojson/review15_contract_test.go new file mode 100644 index 0000000..06df1cd --- /dev/null +++ b/geojson/review15_contract_test.go @@ -0,0 +1,186 @@ +package geojson_test + +import ( + "encoding/json" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/moon" +) + +// 本文件钉住 §1.5 的三条契约:全掩可见性轮廓必须校验、退化影区整条缺省、 +// 地平闭合必须能区分精确擦地点闭合与采样端点近似闭合。 + +func review15CopyContours(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint { + copied := make([][]moon.OccultationPathPoint, len(contours)) + for index, contour := range contours { + copied[index] = append([]moon.OccultationPathPoint(nil), contour...) + } + return copied +} + +func TestMarshalPlanetOccultationValidatesTotalVisibilityContours(t *testing.T) { + path := mars20250729TestFixture(t, time.Minute, true).path + if !path.HasTotalBand || len(path.TotalBandContours) == 0 || len(path.TotalBandContours[0]) < 2 { + t.Fatal("fixture has no usable total band contour") + } + // 复制的样本本身合法,只有注入的可见性轮廓是待测对象。 + valid := append([]moon.OccultationPathPoint(nil), + path.TotalBandContours[0][0], path.TotalBandContours[0][1]) + + outOfOrder := path + outOfOrder.TotalVisibilityContours = [][]moon.OccultationPathPoint{{valid[0], valid[0]}} + if _, err := geojson.MarshalPlanetOccultation(outOfOrder); err == nil { + t.Fatal("out-of-order total visibility contours must be rejected") + } + + outside := path + outside.TotalVisibilityContours = [][]moon.OccultationPathPoint{{ + {Time: path.TotalStart.Time.Add(-time.Minute)}, valid[1], + }} + if _, err := geojson.MarshalPlanetOccultation(outside); err == nil { + t.Fatal("total visibility contours outside the total interval must be rejected") + } + + orphan := path + orphan.HasTotalBand = false + orphan.TotalComplete = false + orphan.TotalStart = moon.OccultationPathPoint{} + orphan.TotalEnd = moon.OccultationPathPoint{} + orphan.NorthernTotalLimit = nil + orphan.SouthernTotalLimit = nil + orphan.TotalFootprints = nil + orphan.TotalBandFootprints = nil + orphan.TotalBandContours = nil + orphan.TotalRiseSetCurves = nil + orphan.GreatestTotalWidthKM = 0 + orphan.TotalVisibilityContours = [][]moon.OccultationPathPoint{{valid[0], valid[1]}} + if _, err := geojson.MarshalPlanetOccultation(orphan); err == nil { + t.Fatal("total visibility contours without HasTotalBand must be rejected") + } + orphan.TotalVisibilityContours = nil + if _, err := geojson.MarshalPlanetOccultation(orphan); err != nil { + t.Fatalf("cleared total-band fields must marshal: %v", err) + } +} + +func review15DegenerateBoundary(stamp time.Time) [][]eclipse.SolarEclipsePathPoint { + return [][]eclipse.SolarEclipsePathPoint{{ + {Time: stamp, Longitude: 0, Latitude: 0}, + {Time: stamp, Longitude: 0.01, Latitude: 0}, + {Time: stamp, Longitude: 0.01, Latitude: 1e-11}, + {Time: stamp, Longitude: 0, Latitude: 1e-11}, + }} +} + +func TestMarshalSolarEclipseShadowInstantSkipsDegenerateRegion(t *testing.T) { + stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC) + instant := eclipse.SolarEclipseShadowInstant{ + Time: stamp, Closed: true, Boundaries: review15DegenerateBoundary(stamp), + } + raw, err := geojson.MarshalSolarEclipseShadowInstant(instant) + if err != nil { + t.Fatalf("degenerate footprint must be omitted, not rejected: %v", err) + } + var collection struct { + Features []json.RawMessage `json:"features"` + } + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatalf("decode GeoJSON: %v", err) + } + if len(collection.Features) != 0 { + t.Fatalf("features=%d, want the degenerate region omitted", len(collection.Features)) + } +} + +func review15HorizonCutInstant(t *testing.T) eclipse.SolarEclipseShadowInstant { + t.Helper() + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + info, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + DisableRiseSet: true, + }) + if !ok { + t.Fatal("expected the 2009-07-22 eclipse") + } + for _, footprint := range info.CentralShadowFootprints { + if footprint.Closed || len(footprint.HorizonEnds) != 2 { + continue + } + return eclipse.SolarEclipseShadowInstant{ + Time: footprint.Time, Kind: eclipse.SolarEclipseShadowUmbra, + Boundaries: footprint.Boundaries, HorizonEnds: footprint.HorizonEnds, + } + } + t.Fatal("fixture has no horizon-cut central-shadow footprint") + return eclipse.SolarEclipseShadowInstant{} +} + +func review15ClosureExact(t *testing.T, instant eclipse.SolarEclipseShadowInstant) interface{} { + t.Helper() + raw, err := geojson.MarshalSolarEclipseShadowInstant(instant) + if err != nil { + t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err) + } + region := featureWithRole(t, decodeCollection(t, raw), "central-shadow-footprint") + closure, ok := region.Properties["closure"].(map[string]interface{}) + if !ok { + t.Fatal("horizon-cut region has no closure property") + } + if closure["kind"] != "horizon" { + t.Fatalf("closure kind=%v, want horizon", closure["kind"]) + } + return closure["exact"] +} + +func TestMarshalSolarEclipseShadowInstantMarksSampledClosure(t *testing.T) { + instant := review15HorizonCutInstant(t) + if exact := review15ClosureExact(t, instant); exact != true { + t.Fatalf("closure exact=%v, want true with two grazing points", exact) + } + sampled := instant + sampled.HorizonEnds = nil + if exact := review15ClosureExact(t, sampled); exact != false { + t.Fatalf("closure exact=%v, want false without grazing points", exact) + } +} + +func TestSolarEclipseCentralShadowClosureExactnessMatchesGrazingPoints(t *testing.T) { + fixtures := centralShadowRegionFixtures() + exactRegions := 0 + cutRegions := 0 + for _, fixture := range fixtures { + collection, info := centralShadowMarshalFixture(t, fixture) + grazing := make(map[string]bool, len(info.CentralShadowFootprints)) + for _, footprint := range info.CentralShadowFootprints { + grazing[footprint.Time.UTC().Format(time.RFC3339Nano)] = len(footprint.HorizonEnds) == 2 + } + for _, region := range featuresWithRole(collection, "central-shadow-footprint") { + closure, ok := region.Properties["closure"].(map[string]interface{}) + if !ok { + continue + } + cutRegions++ + stamp, _ := region.Properties["time"].(string) + want, present := grazing[stamp] + if !present { + t.Fatalf("%s: region %s has no matching footprint", fixture.name, stamp) + } + exact, ok := closure["exact"].(bool) + if !ok { + t.Fatalf("%s: closure of %s has no exact flag", fixture.name, stamp) + } + if exact != want { + t.Fatalf("%s: closure exact=%v, want %v for %s", fixture.name, exact, want, stamp) + } + if exact { + exactRegions++ + } + } + } + if cutRegions == 0 || exactRegions == 0 { + t.Fatalf("horizon-cut regions=%d with grazing points=%d; the marker is untested", cutRegions, exactRegions) + } +} diff --git a/geojson/review15_coverage_internal_test.go b/geojson/review15_coverage_internal_test.go new file mode 100644 index 0000000..0330164 --- /dev/null +++ b/geojson/review15_coverage_internal_test.go @@ -0,0 +1,333 @@ +package geojson + +import ( + "math" + "testing" + "time" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/geodata" +) + +// 本文件钉住 §1.5 的两条导出契约:覆盖判据必须逐点成立(不能靠抽样), +// 中心线走廊必须让每个探针都落在容差内,且只修补越界的那一段。 +const review15ToleranceKM = solarCentralBandCoverageToleranceKM + +func review15SquareRing(minLongitude, minLatitude, maxLongitude, maxLatitude float64) []geodata.GeoPoint { + return []geodata.GeoPoint{ + {Longitude: minLongitude, Latitude: minLatitude}, + {Longitude: maxLongitude, Latitude: minLatitude}, + {Longitude: maxLongitude, Latitude: maxLatitude}, + {Longitude: minLongitude, Latitude: maxLatitude}, + } +} + +func review15CenterLine(points ...[2]float64) []eclipsecore.SolarEclipsePathPoint { + base := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC) + line := make([]eclipsecore.SolarEclipsePathPoint, len(points)) + for index, point := range points { + line[index] = eclipsecore.SolarEclipsePathPoint{ + Time: base.Add(time.Duration(index) * time.Minute), + Longitude: point[0], + Latitude: point[1], + } + } + return line +} + +func TestSolarCentralBandPointsCoverProbesEveryVertex(t *testing.T) { + polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)} + points := make([]geodata.GeoPoint, 0, 257) + for index := 0; index < 257; index++ { + points = append(points, geodata.GeoPoint{ + Longitude: 0.1 + 0.8*float64(index)/256, + Latitude: 0.1 + 0.8*float64(index%97)/96, + }) + } + if !solarCentralBandPointsCover(polygons, points, review15ToleranceKM) { + t.Fatal("points inside the ring must be covered") + } + for index := range points { + moved := append([]geodata.GeoPoint(nil), points...) + moved[index] = geodata.GeoPoint{Longitude: 40, Latitude: 40} + if solarCentralBandPointsCover(polygons, moved, review15ToleranceKM) { + t.Fatalf("vertex %d outside the tolerance was accepted; every vertex must be probed", index) + } + } +} + +func TestSolarCentralBandPointsCoverKeepsMacroTolerance(t *testing.T) { + polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)} + near := []geodata.GeoPoint{{Longitude: 0.5, Latitude: 1.4}} + far := []geodata.GeoPoint{{Longitude: 0.5, Latitude: 2.0}} + if !solarCentralBandPointsCover(polygons, near, review15ToleranceKM) { + t.Fatal("a point 45 km outside must stay inside the 100 km macro tolerance") + } + if solarCentralBandPointsCover(polygons, far, review15ToleranceKM) { + t.Fatal("a point 111 km outside must exceed the 100 km macro tolerance") + } +} + +func TestSolarCentralBandRingsCoverProbesEveryFootprintVertex(t *testing.T) { + rings := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)} + stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC) + boundary := make([]eclipsecore.SolarEclipsePathPoint, 0, 64) + for index := 0; index < 64; index++ { + boundary = append(boundary, eclipsecore.SolarEclipsePathPoint{ + Time: stamp, + Longitude: 0.2 + 0.6*float64(index)/63, + Latitude: 0.3, + }) + } + footprints := []eclipsecore.SolarEclipsePartialFootprint{{ + Time: stamp, + Boundaries: [][]eclipsecore.SolarEclipsePathPoint{boundary}, + }} + if !solarCentralBandRingsCover(rings, review15CenterLine([2]float64{0.5, 0.5}), footprints) { + t.Fatal("a covered footprint must pass the coverage check") + } + for index := range boundary { + moved := append([]eclipsecore.SolarEclipsePathPoint(nil), boundary...) + moved[index] = eclipsecore.SolarEclipsePathPoint{Time: stamp, Longitude: 40, Latitude: 40} + probe := []eclipsecore.SolarEclipsePartialFootprint{{ + Time: stamp, + Boundaries: [][]eclipsecore.SolarEclipsePathPoint{moved}, + }} + if solarCentralBandRingsCover(rings, review15CenterLine([2]float64{0.5, 0.5}), probe) { + t.Fatalf("footprint vertex %d outside the tolerance was accepted", index) + } + } +} + +func TestSolarCentralBandRingsCoverProbesEveryCenterLineVertex(t *testing.T) { + rings := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)} + line := review15CenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 0.6}, [2]float64{0.5, 0.7}) + if !solarCentralBandRingsCover(rings, line, nil) { + t.Fatal("a covered center line must pass the coverage check") + } + for index := range line { + moved := append([]eclipsecore.SolarEclipsePathPoint(nil), line...) + moved[index].Longitude = 40 + moved[index].Latitude = 40 + if solarCentralBandRingsCover(rings, moved, nil) { + t.Fatalf("center-line vertex %d outside the tolerance was accepted", index) + } + } +} + +func TestSolarCentralBandCorridorCoversEveryCenterlineProbe(t *testing.T) { + polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)} + line := review15CenterLine([2]float64{0.5, 1.2}, [2]float64{0.5, 1.8}) + repaired := solarCentralBandWithCenterlineCorridor(polygons, line) + probes := solarCentralBandCenterlineProbes(line) + miss := geodata.SphericalPolygonsPathMissDistanceKM( + repaired, [][]geodata.GeoPoint{probes}, false, + ) + if miss > solarCentralBandCenterlineToleranceKM { + t.Fatalf("center-line probe sits %.1f km from the repaired band, want <= %.1f km", + miss, solarCentralBandCenterlineToleranceKM) + } +} + +func TestSolarCentralBandCorridorPatchesOnlyTheClippedSegment(t *testing.T) { + polygons := [][]geodata.GeoPoint{review15SquareRing(-1, -0.05, 1, 0.05)} + line := review15CenterLine([2]float64{-0.5, 0.08}, [2]float64{0.5, 0.6}) + repaired := solarCentralBandWithCenterlineCorridor(polygons, line) + witness := []geodata.GeoPoint{{Longitude: -0.5, Latitude: 0.35}} + if covered := geodata.SphericalPolygonsContainPoints(repaired, witness); covered[0] { + t.Fatal("the repaired band inflated the shallow end to the deepest probe's radius") + } +} + +func TestSolarCentralBandCorridorLeavesUnboundedMissAlone(t *testing.T) { + polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)} + line := review15CenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 20}) + repaired := solarCentralBandWithCenterlineCorridor(polygons, line) + if len(repaired) != len(polygons) { + t.Fatalf("polygons=%d, want the input unchanged when the miss exceeds the corridor cap", len(repaired)) + } + for index := range polygons { + if len(repaired[index]) != len(polygons[index]) { + t.Fatalf("ring %d has %d vertices, want %d", index, len(repaired[index]), len(polygons[index])) + } + } +} + +func TestSolarShadowRegionDegenerateSkipsOnlyDegenerateRings(t *testing.T) { + normal := review15SquareRing(0, 0, 0.1, 0.1) + if solarShadowRegionDegenerate(normal, normal) { + t.Fatal("an 11 km square must not be degenerate") + } + sliver := []geodata.GeoPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 0.01, Latitude: 0}, + {Longitude: 0.01, Latitude: 1e-11}, + {Longitude: 0, Latitude: 1e-11}, + } + if !solarShadowRegionDegenerate(sliver, sliver) { + t.Fatal("a 2.2 km long sliver with 1e-13 square degrees of area must be degenerate") + } + short := []geodata.GeoPoint{{Longitude: 0, Latitude: 0}, {Longitude: 0.001, Latitude: 0}} + if !solarShadowRegionDegenerate(short, short) { + t.Fatal("a 0.1 km boundary must be degenerate") + } + crossing := []geodata.GeoPoint{ + {Longitude: 179.9, Latitude: -0.05}, + {Longitude: -179.9, Latitude: -0.05}, + {Longitude: -179.9, Latitude: 0.05}, + {Longitude: 179.9, Latitude: 0.05}, + } + if solarShadowRegionDegenerate(crossing, crossing) { + t.Fatal("a region crossing the antimeridian must not be degenerate") + } +} + +func TestSolarShadowSegmentClosedUsesBasicLayerCaliber(t *testing.T) { + start := eclipsecore.SolarEclipsePathPoint{Longitude: 12, Latitude: 30} + nearClosed := []eclipsecore.SolarEclipsePathPoint{ + start, {Longitude: 12.5, Latitude: 30.5}, {Longitude: 12 + 5e-9, Latitude: 30 + 5e-9}, + } + if !solarShadowSegmentClosed(nearClosed) { + t.Fatal("a 0.8 mm gap counts as closed for the basic layer signature") + } + open := []eclipsecore.SolarEclipsePathPoint{ + start, {Longitude: 12.5, Latitude: 30.5}, {Longitude: 12.001, Latitude: 30.001}, + } + if solarShadowSegmentClosed(open) { + t.Fatal("a 150 m gap is not a closed ring") + } + if solarShadowSegmentClosed(nearClosed[:2]) { + t.Fatal("a two-point segment is not a closed ring") + } +} + +func TestOccultationBandSourcePropertiesCoverBothPaths(t *testing.T) { + for _, testCase := range []struct { + authoritative bool + contours int + source string + boundary string + }{ + {authoritative: true, contours: 2, source: "visible-footprint-sweep", boundary: "footprint-sweep+horizon-visible"}, + {authoritative: false, contours: 2, source: "footprint-sweep-fallback", boundary: "contact-contours+horizon-boundary"}, + {authoritative: false, contours: 0, source: "footprint-sweep-fallback", boundary: ""}, + } { + properties := map[string]interface{}{} + applyOccultationBandSourceProperties(properties, testCase.authoritative, testCase.contours) + if properties["source"] != testCase.source { + t.Fatalf("source=%v, want %s", properties["source"], testCase.source) + } + boundary, present := properties["boundary_source"] + if testCase.boundary == "" { + if present { + t.Fatalf("boundary_source=%v, want absent", boundary) + } + continue + } + if boundary != testCase.boundary { + t.Fatalf("boundary_source=%v, want %s", boundary, testCase.boundary) + } + } +} + +func TestAppendSolarFootprintFeaturesSkipsDegenerateFootprint(t *testing.T) { + stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC) + degenerate := eclipsecore.SolarEclipsePartialFootprint{ + Time: stamp, + Closed: true, + Boundaries: [][]eclipsecore.SolarEclipsePathPoint{{ + {Time: stamp, Longitude: 0, Latitude: 0}, + {Time: stamp, Longitude: 0.01, Latitude: 0}, + {Time: stamp, Longitude: 0.01, Latitude: 1e-11}, + {Time: stamp, Longitude: 0, Latitude: 1e-11}, + }}, + } + features, err := appendSolarFootprintFeatures( + nil, solarCentralShadowFootprintRole, + []eclipsecore.SolarEclipsePartialFootprint{degenerate}, + map[string]interface{}{}, + ) + if err != nil { + t.Fatalf("degenerate footprint must be omitted, not rejected: %v", err) + } + if len(features) != 0 { + t.Fatalf("features=%d, want the degenerate footprint omitted", len(features)) + } +} + +// review15ProjectTime 独立复算顶点在中心线上的投影时刻。 +func review15ProjectTime( + point eclipsecore.SolarEclipsePathPoint, + centerLine []eclipsecore.SolarEclipsePathPoint, +) time.Time { + bestDistance := math.Inf(1) + bestTime := centerLine[0].Time + scale := math.Cos(point.Latitude * math.Pi / 180) + for index := 0; index+1 < len(centerLine); index++ { + first, second := centerLine[index], centerLine[index+1] + ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale + ay := first.Latitude - point.Latitude + bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale + by := second.Latitude - point.Latitude + dx, dy := bx-ax, by-ay + length := dx*dx + dy*dy + fraction := 0.0 + if length > 0 { + fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length)) + } + distance := math.Hypot(ax+fraction*dx, ay+fraction*dy) + if distance >= bestDistance { + continue + } + bestDistance = distance + bestTime = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction)) + } + return bestTime +} + +func TestSolarCentralBandLimitSidesKeepProjectionTimes(t *testing.T) { + base := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC) + centerLine := []eclipsecore.SolarEclipsePathPoint{ + {Time: base, Longitude: 0, Latitude: 0}, + {Time: base.Add(time.Minute), Longitude: 0.3, Latitude: 0}, + {Time: base.Add(20 * time.Minute), Longitude: 1, Latitude: 0}, + } + ring := []eclipsecore.SolarEclipsePathPoint{ + {Longitude: 0, Latitude: 0.1}, + {Longitude: 0.3, Latitude: 0.1}, + {Longitude: 1, Latitude: 0.1}, + {Longitude: 1.05, Latitude: -0.05}, + {Longitude: 1, Latitude: -0.1}, + {Longitude: 0.3, Latitude: -0.1}, + {Longitude: 0, Latitude: -0.1}, + {Longitude: -0.05, Latitude: -0.05}, + } + north, south, ok := solarCentralBandLimitSidesFromRings( + [][]eclipsecore.SolarEclipsePathPoint{ring}, centerLine, + ) + if !ok { + t.Fatal("synthetic band must produce two limit sides") + } + if len(north) != 3 || len(south) != 5 { + t.Fatalf("north=%d south=%d, want the 3 north and 5 south vertices without the end caps", + len(north), len(south)) + } + for _, side := range []struct { + name string + north bool + points []eclipsecore.SolarEclipsePathPoint + }{{"north-limit", true, north}, {"south-limit", false, south}} { + for index, point := range side.points { + if side.north && point.Latitude <= 0 { + t.Fatalf("%s vertex %d latitude=%v, want north of the center line", side.name, index, point.Latitude) + } + if !side.north && point.Latitude >= 0 { + t.Fatalf("%s vertex %d latitude=%v, want south of the center line", side.name, index, point.Latitude) + } + projected := review15ProjectTime(point, centerLine) + if delta := point.Time.Sub(projected); delta > time.Millisecond || delta < -time.Millisecond { + t.Fatalf("%s vertex %d time=%v, projected=%v", side.name, index, point.Time, projected) + } + } + } +} diff --git a/geojson/review15_limits_test.go b/geojson/review15_limits_test.go new file mode 100644 index 0000000..27b07c6 --- /dev/null +++ b/geojson/review15_limits_test.go @@ -0,0 +1,205 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +// 本文件钉住 §1.5 的限线口径:north-limit / south-limit 的标签由每个顶点自身的投影侧决定, +// times 是该顶点在中心线上的投影时刻,不按顶点顺序均匀铺开。 +const review15LimitSideSlackKM = 25.0 + +type review15LimitLine struct { + coordinates [][2]float64 + times []time.Time +} + +func review15SolarEclipseFixture( + t *testing.T, + text string, +) ([]byte, eclipse.SolarEclipsePath) { + t.Helper() + date := grazingBandDate(t, text) + info, ok := eclipse.SolarEclipseOnDate(date) + if !ok || !info.HasCentral { + t.Fatalf("expected a central solar eclipse on %s", text) + } + partialOptions, pathOptions := grazingBandOptions(info, "overview") + partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions) + if !ok { + t.Fatal("missing partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions) + if !ok { + t.Fatal("missing central path") + } + raw, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + return raw, central +} + +func review15LimitLineFrom(t *testing.T, raw []byte, role string) review15LimitLine { + t.Helper() + feature := featureWithRole(t, decodeCollection(t, raw), role) + var line [][2]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &line); err != nil { + var lines [][][2]float64 + if multiErr := json.Unmarshal(feature.Geometry.Coordinates, &lines); multiErr != nil { + t.Fatalf("%s coordinates: %v", role, err) + } + if len(lines) != 1 { + t.Fatalf("%s is split into %d segments", role, len(lines)) + } + line = lines[0] + } + encoded, ok := feature.Properties["times"].([]interface{}) + if !ok { + t.Fatalf("%s has no times array", role) + } + if len(encoded) == 1 { + if nested, nestedOK := encoded[0].([]interface{}); nestedOK { + encoded = nested + } + } + if len(encoded) != len(line) { + t.Fatalf("%s times=%d coordinates=%d, want one time per vertex", role, len(encoded), len(line)) + } + times := make([]time.Time, len(encoded)) + for index, value := range encoded { + text, _ := value.(string) + stamp, err := time.Parse(time.RFC3339Nano, text) + if err != nil { + t.Fatalf("%s time %d: %v", role, index, err) + } + times[index] = stamp + } + return review15LimitLine{coordinates: line, times: times} +} + +// review15ProjectOnCenterLine 独立复算顶点在中心线上的投影纬度与插值时刻。 +func review15ProjectOnCenterLine( + point [2]float64, + centerLine []eclipse.SolarEclipsePathPoint, +) (float64, time.Time) { + bestDistance := math.Inf(1) + bestLatitude := centerLine[0].Latitude + bestTime := centerLine[0].Time + scale := math.Cos(point[1] * math.Pi / 180) + for index := 0; index+1 < len(centerLine); index++ { + first, second := centerLine[index], centerLine[index+1] + ax := math.Remainder(first.Longitude-point[0], 360) * scale + ay := first.Latitude - point[1] + bx := math.Remainder(second.Longitude-point[0], 360) * scale + by := second.Latitude - point[1] + dx, dy := bx-ax, by-ay + length := dx*dx + dy*dy + fraction := 0.0 + if length > 0 { + fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length)) + } + distance := math.Hypot(ax+fraction*dx, ay+fraction*dy) + if distance >= bestDistance { + continue + } + bestDistance = distance + bestLatitude = first.Latitude + fraction*(second.Latitude-first.Latitude) + bestTime = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction)) + } + return bestLatitude, bestTime +} + +func TestSolarEclipseLimitSidesCarryTheirOwnProjectionSide(t *testing.T) { + for _, text := range []string{"2003-05-31", "1874-10-10", "2185-07-26", "4862-09-28"} { + t.Run(text, func(t *testing.T) { + raw, central := review15SolarEclipseFixture(t, text) + for _, side := range []struct { + role string + north bool + }{{"north-limit", true}, {"south-limit", false}} { + line := review15LimitLineFrom(t, raw, side.role) + for index, point := range line.coordinates { + latitude, _ := review15ProjectOnCenterLine(point, central.CenterLine) + offset := (point[1] - latitude) * 111.3 + if side.north && offset < -review15LimitSideSlackKM { + t.Fatalf("%s vertex %d sits %.1f km south of the center line", side.role, index, -offset) + } + if !side.north && offset > review15LimitSideSlackKM { + t.Fatalf("%s vertex %d sits %.1f km north of the center line", side.role, index, offset) + } + } + } + }) + } +} + +// review15BandRingVertices 收集 central-band 环上的顶点,用于判定限线是否由带边界派生。 +func review15BandRingVertices(t *testing.T, raw []byte) map[[2]float64]bool { + t.Helper() + vertices := map[[2]float64]bool{} + for _, ring := range grazingBandRings(t, raw) { + for _, point := range ring { + vertices[[2]float64{point.Longitude, point.Latitude}] = true + } + } + return vertices +} + +func review15LimitFollowsBandRing(line review15LimitLine, vertices map[[2]float64]bool) bool { + if len(line.coordinates) < 3 || len(vertices) == 0 { + return false + } + matched := 0 + for _, point := range line.coordinates { + if vertices[point] { + matched++ + } + } + return matched*5 >= len(line.coordinates)*4 +} + +func TestSolarEclipseDerivedLimitTimesAreProjectionTimes(t *testing.T) { + for _, text := range []string{"2003-05-31"} { + t.Run(text, func(t *testing.T) { + raw, central := review15SolarEclipseFixture(t, text) + ringVertices := review15BandRingVertices(t, raw) + derived := 0 + for _, role := range []string{"north-limit", "south-limit"} { + line := review15LimitLineFrom(t, raw, role) + if !review15LimitFollowsBandRing(line, ringVertices) { + continue + } + derived++ + for index, point := range line.coordinates { + _, projected := review15ProjectOnCenterLine(point, central.CenterLine) + if delta := line.times[index].Sub(projected); delta > 2*time.Second || delta < -2*time.Second { + t.Fatalf("%s vertex %d time=%v, projected=%v", + role, index, line.times[index].UTC(), projected.UTC()) + } + } + minimum, maximum := time.Duration(math.MaxInt64), time.Duration(0) + for index := 1; index < len(line.times); index++ { + step := line.times[index].Sub(line.times[index-1]) + if step < minimum { + minimum = step + } + if step > maximum { + maximum = step + } + } + if maximum < 3*minimum { + t.Fatalf("%s times look uniformly spread: min=%v max=%v", role, minimum, maximum) + } + } + if derived == 0 { + t.Fatal("no derived limit in the fixture set; the projection-time contract is untested") + } + }) + } +} diff --git a/geojson/solar_eclipse_15220327_regression_test.go b/geojson/solar_eclipse_15220327_regression_test.go new file mode 100644 index 0000000..a58269b --- /dev/null +++ b/geojson/solar_eclipse_15220327_regression_test.go @@ -0,0 +1,58 @@ +package geojson_test + +import ( + "encoding/json" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +// A one-limit polar total eclipse has U1/U4 samples while the axis is still +// below the horizon. Those open samples must not flare the static central band +// beyond the central-line interval. +func TestMarshalSolarEclipse15220327ClipsOneLimitEndSweeps(t *testing.T) { + date := time.Date(1522, time.March, 27, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, RiseSetStep: 2 * time.Minute, + }) + if !ok { + t.Fatal("expected solar eclipse footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, TargetSpacingKM: 150, + }) + if !ok || central.Eclipse.Centrality != eclipse.SolarEclipseCentralOneLimit { + t.Fatalf("expected one-limit central path, got ok=%v centrality=%s", ok, central.Eclipse.Centrality) + } + if len(central.CenterLine) < 2 || len(partial.CentralBandFootprints) == 0 { + t.Fatal("missing central path or end footprints") + } + first := partial.CentralBandFootprints[0] + if !first.Time.Before(central.CenterLine[0].Time) || len(first.Boundaries) == 0 || len(first.Boundaries[0]) == 0 { + t.Fatal("fixture no longer exercises a pre-horizon end footprint") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + for _, point := range first.Boundaries[0] { + if geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) { + t.Fatalf("central band includes pre-horizon footprint point %.6f, %.6f", point.Longitude, point.Latitude) + } + } + var lines [][][]float64 + center := featureWithRole(t, decodeCollection(t, data), "center-line") + if err := json.Unmarshal(center.Geometry.Coordinates, &lines); err != nil { + t.Fatal(err) + } + for _, line := range lines { + for _, point := range line { + if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) { + t.Fatalf("central band omits center-line point %.6f, %.6f", point[0], point[1]) + } + } + } +} diff --git a/geojson/solar_eclipse_20120521_regression_test.go b/geojson/solar_eclipse_20120521_regression_test.go new file mode 100644 index 0000000..98af0a3 --- /dev/null +++ b/geojson/solar_eclipse_20120521_regression_test.go @@ -0,0 +1,200 @@ +package geojson_test + +import ( + "encoding/json" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestSolarEclipse20120521HasCentralBandHorizonClosure(t *testing.T) { + date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + }) + if !ok { + t.Fatal("expected solar eclipse footprints") + } + if len(partial.CentralBandHorizonClosures) != 2 { + t.Fatalf("central-limit horizon closures=%d, want start and end", len(partial.CentralBandHorizonClosures)) + } + expectedRoots := [2][2][]float64{ + {{109.6236411, 19.9359003}, {107.7419895, 22.3910846}}, + {{-100.0879481, 34.1224726}, {-102.2069471, 31.7284052}}, + } + for closureIndex, closure := range partial.CentralBandHorizonClosures { + if len(closure) < 2 { + t.Fatalf("horizon closure %d has %d points", closureIndex, len(closure)) + } + assertSolarPathMaximumEdgeKM(t, closure, 12) + roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} + for rootIndex, root := range roots { + if distance := geoJSONCoordinateDistanceKM( + []float64{root.Longitude, root.Latitude}, expectedRoots[closureIndex][rootIndex], + ); distance > 0.1 { + t.Fatalf("horizon closure %d root %d differs by %.3f km", closureIndex, rootIndex, distance) + } + } + } + for closureIndex, closure := range partial.CentralBandHorizonClosures { + direction := eclipse.RiseSetDirectionRise + if closureIndex == 1 { + direction = eclipse.RiseSetDirectionSet + } + for pointIndex, point := range closure { + if !solarGreatestCurveContainsPoint(partial.RiseSetCurves, point, direction) { + t.Fatalf("horizon closure %d point %d is missing from its greatest/%s curve", closureIndex, pointIndex, direction) + } + } + } + + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, TargetSpacingKM: 700, + }) + if !ok || len(central.CenterLine) < 2 { + t.Fatal("expected a central eclipse path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if band.Properties["source"] != "besselian-critical-envelope" { + t.Fatalf("central-band source=%v, want continuous critical envelope", band.Properties["source"]) + } + assertClosedMultiPolygon(t, band) + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central band: %v", err) + } + if len(polygons) == 0 || len(polygons) > 2 { + t.Fatalf("central band has %d polygons, want one physical band with at most one antimeridian split", len(polygons)) + } + for _, polygon := range polygons { + if len(polygon) == 0 { + t.Fatal("central-band polygon has no exterior ring") + } + assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250) + } + for closureIndex, closure := range partial.CentralBandHorizonClosures { + for pointIndex, point := range closure { + coordinate := []float64{point.Longitude, point.Latitude} + if distance := geoJSONMultiPolygonBoundaryDistanceKM(polygons, coordinate); distance > 0.1 { + t.Fatalf("horizon closure %d point %d is %.3f km from the central-band boundary", closureIndex, pointIndex, distance) + } + } + } + for segmentIndex, segment := range central.CenterLine { + point := []float64{segment.Longitude, segment.Latitude} + if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) && + geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 10 { + t.Fatalf("center-line point %d lies outside central band", segmentIndex) + } + } +} + +func TestSolarEclipse20120521CentralBandContainsVisibleAnnularStation(t *testing.T) { + localDate := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + local, ok := eclipse.LocalSolarEclipseOnDate(localDate, 120.4913, 27.4779, 0) + if !ok || local.Type != eclipse.SolarEclipseAnnular || !local.HasCentral || local.SunAltitude <= 0 { + t.Fatalf("reference station is not visibly annular: ok=%v type=%s central=%v altitude=%.6f", + ok, local.Type, local.HasCentral, local.SunAltitude) + } + date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC) + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, TargetSpacingKM: 700, + }) + if !ok { + t.Fatal("expected solar central path") + } + for _, shadowStep := range []time.Duration{0, 2 * time.Minute} { + shadowStep := shadowStep + t.Run(shadowStep.String(), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: shadowStep, + }) + if !ok { + t.Fatal("expected solar eclipse footprints") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if !geometryContainsPoint(t, band.Geometry, 120.4913, 27.4779) { + t.Fatal("central-band omits a station that visibly sees annularity") + } + if source := band.Properties["source"]; source != "besselian-critical-envelope" { + t.Fatalf("central-band source=%v, want continuous critical envelope", source) + } + }) + } +} + +func assertSolarPathMaximumEdgeKM( + t *testing.T, + points []eclipse.SolarEclipsePathPoint, + maximumKM float64, +) { + t.Helper() + for index := 1; index < len(points); index++ { + distance := geoJSONCoordinateDistanceKM( + []float64{points[index-1].Longitude, points[index-1].Latitude}, + []float64{points[index].Longitude, points[index].Latitude}, + ) + if distance > maximumKM { + t.Fatalf("horizon closure edge %d is %.3f km, want at most %.3f km", index-1, distance, maximumKM) + } + } +} + +func TestSolarEclipse20120521HorizonClosuresAreStableAcrossSampling(t *testing.T) { + date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC) + expectedRoots := [2][2][]float64{ + {{109.6236411, 19.9359003}, {107.7419895, 22.3910846}}, + {{-100.0879481, 34.1224726}, {-102.2069471, 31.7284052}}, + } + for _, step := range []time.Duration{time.Minute, 5 * time.Minute, 10 * time.Minute} { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: step, BoundaryPoints: 96, CentralShadowStep: step, + }) + if !ok || len(partial.CentralBandHorizonClosures) != 2 { + t.Fatalf("step %s: closures=%d ok=%v, want two", step, len(partial.CentralBandHorizonClosures), ok) + } + for closureIndex, closure := range partial.CentralBandHorizonClosures { + roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} + for rootIndex, root := range roots { + if distance := geoJSONCoordinateDistanceKM( + []float64{root.Longitude, root.Latitude}, expectedRoots[closureIndex][rootIndex], + ); distance > 0.1 { + t.Fatalf("step %s: closure %d root %d differs by %.3f km", step, closureIndex, rootIndex, distance) + } + } + } + } +} + +func solarGreatestCurveContainsPoint( + curves []eclipse.SolarEclipseRiseSetCurve, + want eclipse.SolarEclipsePathPoint, + direction eclipse.RiseSetDirection, +) bool { + for _, curve := range curves { + if curve.Phase != eclipse.RiseSetPhaseGreatest || curve.Direction != direction { + continue + } + for _, segment := range curve.Segments { + for _, point := range segment { + if point.Time.Equal(want.Time) && geoJSONCoordinateDistanceKM( + []float64{point.Longitude, point.Latitude}, + []float64{want.Longitude, want.Latitude}, + ) <= 0.001 { + return true + } + } + } + } + return false +} diff --git a/geojson/solar_eclipse_20560713_regression_test.go b/geojson/solar_eclipse_20560713_regression_test.go new file mode 100644 index 0000000..f29436f --- /dev/null +++ b/geojson/solar_eclipse_20560713_regression_test.go @@ -0,0 +1,222 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestMarshalSolarEclipse20560713CentralBandHasNoEndFold(t *testing.T) { + date := time.Date(2056, time.July, 13, 0, 0, 0, 0, time.UTC) + localDate := time.Date(2056, time.July, 12, 0, 0, 0, 0, time.UTC) + local, localOK := eclipse.LocalSolarEclipseOnDate(localDate, -64.3737, -5.4978, 0) + if !localOK || local.Type != eclipse.SolarEclipseAnnular || !local.HasCentral || local.SunAltitude <= 0 { + t.Fatalf("reference site is not a visible annular eclipse: ok=%v type=%s central=%v altitude=%.6f", + localOK, local.Type, local.HasCentral, local.SunAltitude) + } + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, + BoundaryPoints: 96, + CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0}, + }) + if !ok { + t.Fatal("expected solar eclipse footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, TargetSpacingKM: 700, + }) + if !ok || len(central.CenterLine) < 2 { + t.Fatal("expected a central eclipse path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + collection := decodeCollection(t, data) + band := featureWithRole(t, collection, "central-band") + assertClosedMultiPolygon(t, band) + + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central band: %v", err) + } + if len(polygons) == 0 || len(polygons) > 2 { + t.Fatalf("central band has %d polygons, want one physical band with at most one antimeridian split", len(polygons)) + } + if len(polygons) == 2 && !solarCentralBandPartsMeetAntimeridian(polygons) { + t.Fatal("two central-band polygons do not form an antimeridian split") + } + for _, polygon := range polygons { + if len(polygon) == 0 { + t.Fatal("central-band polygon has no exterior ring") + } + assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250) + } + if !geometryContainsPoint(t, band.Geometry, -64.3737, -5.4978) { + t.Fatal("central-band omits the locally visible annular greatest point near U4") + } + + center := featureWithRole(t, collection, "center-line") + var centerLines [][][]float64 + if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil { + t.Fatalf("decode center line: %v", err) + } + for segmentIndex, segment := range centerLines { + for pointIndex, point := range segment { + if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) && + geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 10 { + t.Fatalf("center-line segment %d point %d lies outside central band", segmentIndex, pointIndex) + } + } + } + + start := central.CenterLine[0] + end := central.CenterLine[len(central.CenterLine)-1] + assertSolarCentralBandDoesNotReverseNear(t, polygons, []float64{start.Longitude, start.Latitude}, 1600) + assertSolarCentralBandDoesNotReverseNear(t, polygons, []float64{end.Longitude, end.Latitude}, 1600) +} + +func TestMarshalSolarEclipse20560713HorizonClosureIsStableAcrossSampling(t *testing.T) { + date := time.Date(2056, time.July, 13, 0, 0, 0, 0, time.UTC) + var referenceRoots [2][2]eclipse.SolarEclipsePathPoint + for _, step := range []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute} { + t.Run(step.String(), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: step, BoundaryPoints: 96, CentralShadowStep: step, + }) + if !ok { + t.Fatal("expected solar eclipse footprints") + } + if len(partial.CentralBandHorizonClosures) != 2 { + t.Fatalf("central-limit horizon closures=%d, want start and end", len(partial.CentralBandHorizonClosures)) + } + for closureIndex, closure := range partial.CentralBandHorizonClosures { + if len(closure) < 2 { + t.Fatalf("horizon closure %d has %d points", closureIndex, len(closure)) + } + assertSolarPathMaximumEdgeKM(t, closure, 12) + roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} + if referenceRoots[closureIndex][0].Time.IsZero() { + referenceRoots[closureIndex] = roots + } else { + for rootIndex := range roots { + if difference := roots[rootIndex].Time.Sub(referenceRoots[closureIndex][rootIndex].Time); difference < -time.Millisecond || difference > time.Millisecond { + t.Fatalf("horizon closure %d root %d time differs by %s across sampling", closureIndex, rootIndex, difference) + } + if distance := geoJSONCoordinateDistanceKM( + []float64{roots[rootIndex].Longitude, roots[rootIndex].Latitude}, + []float64{referenceRoots[closureIndex][rootIndex].Longitude, referenceRoots[closureIndex][rootIndex].Latitude}, + ); distance > 0.01 { + t.Fatalf("horizon closure %d root %d differs by %.3f km across sampling", closureIndex, rootIndex, distance) + } + } + } + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: step, TargetSpacingKM: 700, + }) + if !ok { + t.Fatal("expected central eclipse path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if band.Properties["source"] != "besselian-critical-envelope" { + t.Fatalf("central-band source=%v, want continuous critical envelope", band.Properties["source"]) + } + if !geometryContainsPoint(t, band.Geometry, -64.3737, -5.4978) { + t.Fatal("central-band omits the locally visible annular point") + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central band: %v", err) + } + if len(polygons) == 0 || len(polygons) > 2 { + t.Fatalf("central band has %d polygons", len(polygons)) + } + for _, polygon := range polygons { + assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250) + } + }) + } +} + +func solarCentralBandPartsMeetAntimeridian(polygons [][][][]float64) bool { + hasEast, hasWest := false, false + for _, polygon := range polygons { + for _, ring := range polygon { + for _, point := range ring { + hasEast = hasEast || math.Abs(point[0]-180) <= 1e-9 + hasWest = hasWest || math.Abs(point[0]+180) <= 1e-9 + } + } + } + return hasEast && hasWest +} + +func assertSolarCentralBandRingSimpleAndSampled(t *testing.T, ring [][]float64, maximumEdgeKM float64) { + t.Helper() + for index := 1; index < len(ring); index++ { + if distance := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); distance > maximumEdgeKM { + t.Fatalf("central-band edge %d is %.1f km, want at most %.1f km", index, distance, maximumEdgeKM) + } + } + for first := 0; first+1 < len(ring); first++ { + for second := first + 2; second+1 < len(ring); second++ { + if first == 0 && second+1 == len(ring)-1 { + continue + } + if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) { + t.Fatalf("central-band ring self-intersects between edges %d and %d", first, second) + } + } + } +} + +func assertSolarCentralBandDoesNotReverseNear(t *testing.T, polygons [][][][]float64, center []float64, radiusKM float64) { + t.Helper() + checked := 0 + for _, polygon := range polygons { + if len(polygon) == 0 { + continue + } + ring := polygon[0] + for index := 1; index+1 < len(ring); index++ { + if geoJSONCoordinateDistanceKM(ring[index], center) > radiusKM { + continue + } + checked++ + incoming := solarRegressionProjectedVector(ring[index], ring[index-1]) + outgoing := solarRegressionProjectedVector(ring[index], ring[index+1]) + incomingLength := math.Hypot(incoming[0], incoming[1]) + outgoingLength := math.Hypot(outgoing[0], outgoing[1]) + if incomingLength == 0 || outgoingLength == 0 { + continue + } + cosine := (incoming[0]*outgoing[0] + incoming[1]*outgoing[1]) / (incomingLength * outgoingLength) + if cosine > 0.985 { + t.Fatalf("central-band boundary reverses by %.1f degrees at %.5f, %.5f between [%.5f, %.5f] and [%.5f, %.5f]", + math.Acos(math.Max(-1, math.Min(1, cosine)))*180/math.Pi, + ring[index][0], ring[index][1], + ring[index-1][0], ring[index-1][1], + ring[index+1][0], ring[index+1][1]) + } + } + } + if checked == 0 { + t.Fatal("central-band boundary has no samples near the event end") + } +} + +func solarRegressionProjectedVector(origin, point []float64) [2]float64 { + latitude := origin[1] * math.Pi / 180 + deltaLongitude := math.Remainder(point[0]-origin[0], 360) + return [2]float64{deltaLongitude * math.Cos(latitude), point[1] - origin[1]} +} diff --git a/geojson/solar_eclipse_20611013_regression_test.go b/geojson/solar_eclipse_20611013_regression_test.go new file mode 100644 index 0000000..654b85b --- /dev/null +++ b/geojson/solar_eclipse_20611013_regression_test.go @@ -0,0 +1,48 @@ +package geojson_test + +import ( + "encoding/json" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestMarshalSolarEclipse20611013TangentCentralBandAvoidsAxisEndpointCap(t *testing.T) { + date := time.Date(2061, 10, 13, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1}, + }) + if !ok || partial.Eclipse.Type != eclipse.SolarEclipseAnnular || + partial.Eclipse.Centrality != eclipse.SolarEclipseCentralTwoLimits { + t.Fatalf("unexpected event: ok=%v type=%s centrality=%s", ok, partial.Eclipse.Type, partial.Eclipse.Centrality) + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, TargetSpacingKM: 700, + }) + if !ok { + t.Fatal("expected central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if source := band.Properties["source"]; source != "besselian-critical-envelope" { + t.Fatalf("central-band source=%v, want a validated continuous central band", source) + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central band: %v", err) + } + if len(polygons) != 1 || len(polygons[0]) == 0 { + t.Fatalf("central-band polygons=%d, want one exterior ring", len(polygons)) + } + ring := polygons[0][0] + if len(ring) < 20 { + t.Fatalf("central-band ring points=%d, want a spatially refined envelope", len(ring)) + } + assertSolarCentralBandRingSimpleAndSampled(t, ring, 250) +} diff --git a/geojson/solar_eclipse_21640323_regression_test.go b/geojson/solar_eclipse_21640323_regression_test.go new file mode 100644 index 0000000..d1c4dd6 --- /dev/null +++ b/geojson/solar_eclipse_21640323_regression_test.go @@ -0,0 +1,64 @@ +package geojson_test + +import ( + "encoding/json" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestMarshalSolarEclipse21640323ContainsVisibleAnnularEnds(t *testing.T) { + date := time.Date(2164, 3, 23, 0, 0, 0, 0, time.UTC) + sites := [][2]float64{{-110.6, 48.75}, {-110.6, 48.5}, {-110.7, 48.76}, {-120.575, 48.75}, {-120.575, 48.745}} + for _, site := range sites { + local, ok := eclipse.LocalSolarEclipseOnDate(date, site[0], site[1], 0) + if !ok || local.Type != eclipse.SolarEclipseAnnular || local.SunAltitude <= 0 { + t.Fatalf("reference site %v is not visible annular greatest: %+v", site, local) + } + } + for _, step := range []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute} { + t.Run(step.String(), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: step, BoundaryPoints: 96, CentralShadowStep: step, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1}, + }) + if !ok || partial.Eclipse.Type != eclipse.SolarEclipseHybrid { + t.Fatal("expected hybrid eclipse") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: step, TargetSpacingKM: 700}) + if !ok { + t.Fatal("expected central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatal(err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + for _, site := range sites { + if !geometryContainsPoint(t, band.Geometry, site[0], site[1]) { + t.Errorf("central band omits visible annular site %v", site) + } + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatal(err) + } + for _, polygon := range polygons { + assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250) + } + if len(partial.CentralBandHorizonClosures) != 2 { + t.Fatalf("horizon closures=%d, want sunrise and sunset", len(partial.CentralBandHorizonClosures)) + } + for _, closure := range partial.CentralBandHorizonClosures { + assertSolarPathMaximumEdgeKM(t, closure, 12) + for _, point := range closure { + if distance := geoJSONMultiPolygonBoundaryDistanceKM(polygons, []float64{point.Longitude, point.Latitude}); distance > 0.02 { + t.Fatalf("central band misses horizon closure by %.3f km", distance) + } + } + } + }) + } +} diff --git a/geojson/solar_eclipse_25441017_regression_test.go b/geojson/solar_eclipse_25441017_regression_test.go new file mode 100644 index 0000000..e666683 --- /dev/null +++ b/geojson/solar_eclipse_25441017_regression_test.go @@ -0,0 +1,63 @@ +package geojson_test + +import ( + "encoding/json" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" +) + +func TestMarshalSolarEclipse25441017PreservesHybridEnvelopeComponents(t *testing.T) { + date := time.Date(2544, 10, 17, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 5 * time.Minute, + BoundaryPoints: 96, + CentralShadowStep: 5 * time.Minute, + RiseSetStep: 5 * time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1}, + }) + if !ok || partial.Eclipse.Type != eclipse.SolarEclipseHybrid || len(partial.CentralBandSegments) != 3 { + t.Fatalf("unexpected hybrid envelope: ok=%v type=%s segments=%d", ok, partial.Eclipse.Type, len(partial.CentralBandSegments)) + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: 5 * time.Minute, TargetSpacingKM: 700, + }) + if !ok { + t.Fatal("missing hybrid central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatal(err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if source := band.Properties["source"]; source != "besselian-critical-envelope-components" && source != "besselian-critical-envelope" { + t.Fatalf("central band source=%v, want critical envelope", source) + } + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatal(err) + } + if len(polygons) < 3 { + t.Fatalf("central band polygons=%d, want at least three physical components", len(polygons)) + } + for polygonIndex, polygon := range polygons { + if len(polygon) != 1 { + t.Fatalf("central band polygon %d rings=%d, want one", polygonIndex, len(polygon)) + } + assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250) + } + var sourceRings [][]geodata.GeoPoint + for _, segment := range partial.CentralBandSegments { + var ring []geodata.GeoPoint + for _, point := range segment { + ring = append(ring, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + sourceRings = append(sourceRings, ring) + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(geoJSONMultiPolygonOuterRings(t, band), sourceRings, true); miss > 1 { + t.Fatalf("hybrid components leave the exported band by %.3f km", miss) + } +} diff --git a/geojson/solar_eclipse_29020726_regression_test.go b/geojson/solar_eclipse_29020726_regression_test.go new file mode 100644 index 0000000..561e224 --- /dev/null +++ b/geojson/solar_eclipse_29020726_regression_test.go @@ -0,0 +1,49 @@ +package geojson_test + +import ( + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestMarshalSolarEclipse29020726DoesNotFillNighttimeCanada(t *testing.T) { + date := time.Date(2902, time.July, 26, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0}, + }) + if !ok { + t.Fatal("expected solar eclipse footprints") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "partial-band") + if band.Geometry.Type != "MultiPolygon" { + t.Fatalf("partial-band geometry=%q, want MultiPolygon", band.Geometry.Type) + } + for _, site := range []struct { + name string + longitude float64 + latitude float64 + wantInside bool + }{ + {name: "Edmonton", longitude: -113.4909, latitude: 53.5461}, + {name: "Churchill", longitude: -94.1650, latitude: 58.7684}, + {name: "Iqaluit", longitude: -68.5170, latitude: 63.7467}, + {name: "Resolute", longitude: -94.8297, latitude: 74.6973, wantInside: true}, + {name: "Alert", longitude: -62.3481, latitude: 82.5018, wantInside: true}, + {name: "Bering east of envelope", longitude: 179, latitude: 65}, + {name: "Bering inside excluded pocket", longitude: 175, latitude: 65}, + {name: "Bering west of envelope", longitude: 170, latitude: 65, wantInside: true}, + {name: "Bering west world", longitude: -179, latitude: 65}, + {name: "Arctic east world", longitude: 179.9, latitude: 80, wantInside: true}, + } { + if got := geometryContainsPoint(t, band.Geometry, site.longitude, site.latitude); got != site.wantInside { + t.Errorf("partial-band contains %s=%v, want %v", site.name, got, site.wantInside) + } + } +} diff --git a/geojson/solar_eclipse_32881115_regression_test.go b/geojson/solar_eclipse_32881115_regression_test.go new file mode 100644 index 0000000..7910999 --- /dev/null +++ b/geojson/solar_eclipse_32881115_regression_test.go @@ -0,0 +1,48 @@ +package geojson_test + +import ( + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" +) + +// 3288-11-16 in the local display falls on 3288-11-15 UTC. It is an +// extremely shallow non-central eclipse: the sampled footprints are open, +// while the rise/set phase tracks still form part of the visible envelope. +func TestMarshalSolarEclipse32881115FallbackCoversPhaseTracks(t *testing.T) { + path, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(3288, time.November, 15, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 5 * time.Minute, BoundaryPoints: 96, + CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute, + }, + ) + if !ok { + t.Fatal("expected 3288-11-15 UTC solar eclipse") + } + if len(path.PartialBandContours) == 0 || len(path.RiseSetCurves) == 0 { + t.Fatalf("unexpected topology: contours=%d curves=%d", len(path.PartialBandContours), len(path.RiseSetCurves)) + } + data, err := geojson.MarshalSolarEclipse(path, nil) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "partial-band") + rings := geoJSONMultiPolygonOuterRings(t, band) + lines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves)*2) + for _, curve := range path.RiseSetCurves { + for _, segment := range curve.Segments { + line := make([]geodata.GeoPoint, len(segment)) + for index, point := range segment { + line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + lines = append(lines, line) + } + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, lines, false); miss > 2 { + t.Fatalf("fallback partial-band misses phase tracks by %.1f km", miss) + } +} diff --git a/geojson/solar_eclipse_43290612_regression_test.go b/geojson/solar_eclipse_43290612_regression_test.go new file mode 100644 index 0000000..0edb42d --- /dev/null +++ b/geojson/solar_eclipse_43290612_regression_test.go @@ -0,0 +1,43 @@ +package geojson_test + +import ( + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + geojson "b612.me/astro/geojson" +) + +func TestMarshalSolarEclipse43290612UsesUnifiedPolarCentralBand(t *testing.T) { + for _, sample := range []struct{ year, month, day int }{{4329, 6, 12}, {4005, 4, 22}} { + t.Run(time.Date(sample.year, time.Month(sample.month), sample.day, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), func(t *testing.T) { + date := time.Date(sample.year, time.Month(sample.month), sample.day, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{Step: 2 * time.Minute, BoundaryPoints: 96}) + if !ok || len(partial.CentralBandSegments) != 1 { + t.Fatalf("partial central envelope unavailable: ok=%v segments=%d", ok, len(partial.CentralBandSegments)) + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute, TargetSpacingKM: 700}) + if !ok || len(central.CentralBandSegments) != 1 { + t.Fatalf("central path envelope unavailable: ok=%v segments=%d", ok, len(central.CentralBandSegments)) + } + if got, want := len(central.CentralBandSegments[0]), len(partial.CentralBandSegments[0]); got != want { + t.Fatalf("path/partial envelope point count=%d/%d", got, want) + } + for _, index := range []int{0, len(partial.CentralBandSegments[0]) / 2} { + got, want := central.CentralBandSegments[0][index], partial.CentralBandSegments[0][index] + if math.Abs(got.Longitude-want.Longitude) > 1e-9 || math.Abs(got.Latitude-want.Latitude) > 1e-9 { + t.Fatalf("path/partial envelope diverges at %d: got=(%.9f,%.9f) want=(%.9f,%.9f)", index, got.Longitude, got.Latitude, want.Longitude, want.Latitude) + } + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if source, ok := band.Properties["source"].(string); !ok || source == "" { + t.Fatalf("central-band source=%v, want a physical envelope source", band.Properties["source"]) + } + }) + } +} diff --git a/geojson/solar_eclipse_central_shadow_region_test.go b/geojson/solar_eclipse_central_shadow_region_test.go new file mode 100644 index 0000000..158c80d --- /dev/null +++ b/geojson/solar_eclipse_central_shadow_region_test.go @@ -0,0 +1,429 @@ +package geojson_test + +import ( + "encoding/json" + "fmt" + "math" + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +// 本文件把上游消费方要的契约钉死: +// 1. central-shadow-footprint 要么缺省、要么是 Polygon/MultiPolygon,永不出现线类型; +// 2. 被地平线切断的足迹用该时刻地平圈上的擦地点闭合,闭合弧与地平圈在容差内一致; +// 3. 物理边界曲线另出 central-shadow-boundary,顶点与采样曲线一致,供调用方描边; +// 4. 区域外环保持右手定则,且不出现跨图收口边(含绕极环)。 +// This file pins the contract the consumers asked for: the footprint role is always a +// region, the horizon closure really lies on the horizon circle of the footprint time, +// the physical boundary is exported separately with unchanged vertices, and every +// outer ring is right-handed without a synthetic seam. + +const centralShadowHorizonToleranceDegrees = 0.01 + +type centralShadowRegionFixture struct { + name string + year int + month time.Month + day int + wantHorizon bool +} + +func centralShadowRegionFixtures() []centralShadowRegionFixture { + return []centralShadowRegionFixture{ + {name: "2009-07-22 total, both limb cuts", year: 2009, month: time.July, day: 22, wantHorizon: true}, + {name: "2014-04-29 non-central annular, every footprint cut", year: 2014, month: time.April, day: 29, wantHorizon: true}, + {name: "2043-04-09 total, every footprint cut", year: 2043, month: time.April, day: 9, wantHorizon: true}, + {name: "2021-12-04 antarctic total", year: 2021, month: time.December, day: 4, wantHorizon: false}, + {name: "2021-06-10 pole-enclosing footprints", year: 2021, month: time.June, day: 10, wantHorizon: false}, + {name: "2061-10-13 annular with many cut footprints", year: 2061, month: time.October, day: 13, wantHorizon: true}, + } +} + +func centralShadowMarshalFixture( + t *testing.T, + fixture centralShadowRegionFixture, +) (decodedCollection, eclipse.SolarEclipsePartialFootprintsInfo) { + t.Helper() + info, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(fixture.year, fixture.month, fixture.day, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + DisableRiseSet: true, + }, + ) + if !ok { + t.Fatalf("%s: no solar eclipse", fixture.name) + } + data, err := geojson.MarshalSolarEclipse(info, nil) + if err != nil { + t.Fatalf("%s: MarshalSolarEclipse: %v", fixture.name, err) + } + return decodeCollection(t, data), info +} + +func TestSolarEclipseCentralShadowFootprintIsAlwaysARegion(t *testing.T) { + cutRegions := 0 + for _, fixture := range centralShadowRegionFixtures() { + collection, info := centralShadowMarshalFixture(t, fixture) + if len(info.CentralShadowFootprints) == 0 { + t.Fatalf("%s: sampler produced no central-shadow footprints", fixture.name) + } + regions := featuresWithRole(collection, "central-shadow-footprint") + if len(regions) == 0 { + t.Fatalf("%s: GeoJSON has no central-shadow-footprint", fixture.name) + } + openRegions := 0 + for _, region := range regions { + switch region.Geometry.Type { + case "Polygon", "MultiPolygon": + default: + t.Fatalf("%s: central-shadow-footprint geometry=%q, want Polygon or MultiPolygon", + fixture.name, region.Geometry.Type) + } + closed, present := region.Properties["source_boundary_closed"].(bool) + if !present { + t.Fatalf("%s: central-shadow-footprint has no source_boundary_closed property", fixture.name) + } + if !closed { + openRegions++ + closure, present := region.Properties["closure"].(map[string]interface{}) + if !present { + t.Fatalf("%s: horizon-cut footprint has no closure property", fixture.name) + } + if closure["kind"] != "horizon" { + t.Fatalf("%s: closure kind=%v, want horizon", fixture.name, closure["kind"]) + } + if closure["time"] != region.Properties["time"] { + t.Fatalf("%s: closure time=%v, want the footprint time %v", + fixture.name, closure["time"], region.Properties["time"]) + } + } else if _, present := region.Properties["closure"]; present { + t.Fatalf("%s: self-closed footprint must not carry a closure property", fixture.name) + } + } + boundaries := featuresWithRole(collection, "central-shadow-boundary") + if len(boundaries) != openRegions { + t.Fatalf("%s: central-shadow-boundary count=%d, want one per horizon-cut region (%d)", + fixture.name, len(boundaries), openRegions) + } + for _, boundary := range boundaries { + if boundary.Geometry.Type != "MultiLineString" { + t.Fatalf("%s: central-shadow-boundary geometry=%q, want MultiLineString", + fixture.name, boundary.Geometry.Type) + } + if closed, _ := boundary.Properties["source_boundary_closed"].(bool); closed { + t.Fatalf("%s: central-shadow-boundary must stay an open boundary", fixture.name) + } + } + if fixture.wantHorizon && openRegions == 0 { + t.Fatalf("%s: expected at least one horizon-cut central-shadow footprint", fixture.name) + } + cutRegions += openRegions + } + if cutRegions == 0 { + t.Fatal("no horizon-cut central-shadow footprint in the fixture set; the contract is untested") + } +} + +func centralShadowRegionRings(t *testing.T, feature decodedFeature) [][][2]float64 { + t.Helper() + var polygons [][][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central-shadow-footprint polygon: %v", err) + } + rings := make([][][2]float64, 0, len(polygons)) + for _, polygon := range polygons { + if len(polygon) == 0 { + continue + } + ring := make([][2]float64, 0, len(polygon[0])) + for _, coordinate := range polygon[0] { + ring = append(ring, [2]float64{coordinate[0], coordinate[1]}) + } + rings = append(rings, ring) + } + return rings +} + +func centralShadowBoundaryLines(t *testing.T, feature decodedFeature) [][][2]float64 { + t.Helper() + var lines [][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil { + t.Fatalf("decode central-shadow-boundary line: %v", err) + } + result := make([][][2]float64, 0, len(lines)) + for _, line := range lines { + points := make([][2]float64, 0, len(line)) + for _, coordinate := range line { + points = append(points, [2]float64{coordinate[0], coordinate[1]}) + } + result = append(result, points) + } + return result +} + +func centralShadowSubsolarPoint(value time.Time) (float64, float64) { + ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true) + ra, dec := basic.HSunApparentRaDec(ttJDE) + utJDE := basic.TD2UT(ttJDE, false) + longitude := ra - basic.ApparentSiderealTime(utJDE)*15 + for longitude > 180 { + longitude -= 360 + } + for longitude < -180 { + longitude += 360 + } + return longitude, dec +} + +func centralShadowHorizonDistanceDegrees(vertex [2]float64, longitude, latitude float64) float64 { + first, second := vertex[1]*math.Pi/180, latitude*math.Pi/180 + deltaLongitude := (longitude - vertex[0]) * math.Pi / 180 + deltaLatitude := second - first + h := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) + + math.Cos(first)*math.Cos(second)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2) + if h > 1 { + h = 1 + } + // 90 degrees minus the distance to the subsolar point is the solar altitude. + return 90 - 2*math.Asin(math.Sqrt(h))*180/math.Pi +} + +// centralShadowClosureViolations 返回所有"没有落在地平圈上"的闭合弧顶点描述。 +// centralShadowClosureViolations reports every closure vertex that misses the horizon. +func centralShadowClosureViolations( + t *testing.T, + collection decodedCollection, +) []string { + t.Helper() + return solarShadowClosureViolations(t, collection, "central-shadow-footprint") +} + +func solarShadowClosureViolations( + t *testing.T, + collection decodedCollection, + role string, +) []string { + t.Helper() + var issues []string + for _, region := range featuresWithRole(collection, role) { + closed, _ := region.Properties["source_boundary_closed"].(bool) + if closed { + continue + } + stamp, _ := region.Properties["time"].(string) + value, err := time.Parse(time.RFC3339Nano, stamp) + if err != nil { + t.Fatalf("parse footprint time %q: %v", stamp, err) + } + longitude, latitude := centralShadowSubsolarPoint(value) + onHorizon := 0 + for _, ring := range centralShadowRegionRings(t, region) { + for _, vertex := range ring { + altitude := centralShadowHorizonDistanceDegrees(vertex, longitude, latitude) + if altitude < -centralShadowHorizonToleranceDegrees { + issues = append(issues, fmt.Sprintf( + "%s: vertex %.6f,%.6f is %.4f deg below the horizon", stamp, vertex[0], vertex[1], altitude)) + continue + } + if math.Abs(altitude) <= centralShadowHorizonToleranceDegrees { + onHorizon++ + } + } + } + if onHorizon < 2 { + issues = append(issues, fmt.Sprintf( + "%s: only %d ring vertices lie on the horizon, want the closing arc", stamp, onHorizon)) + } + } + return issues +} + +func TestSolarEclipseCentralShadowClosureLiesOnTheHorizon(t *testing.T) { + for _, fixture := range centralShadowRegionFixtures() { + collection, _ := centralShadowMarshalFixture(t, fixture) + if issues := centralShadowClosureViolations(t, collection); len(issues) > 0 { + t.Fatalf("%s: %s", fixture.name, issues[0]) + } + // The physical boundary feature must start and end on the horizon as well, + // otherwise the region would still be cut short of the terminator. + for _, boundary := range featuresWithRole(collection, "central-shadow-boundary") { + stamp, _ := boundary.Properties["time"].(string) + value, err := time.Parse(time.RFC3339Nano, stamp) + if err != nil { + t.Fatalf("%s: parse boundary time %q: %v", fixture.name, stamp, err) + } + longitude, latitude := centralShadowSubsolarPoint(value) + for _, line := range centralShadowBoundaryLines(t, boundary) { + for _, index := range []int{0, len(line) - 1} { + altitude := centralShadowHorizonDistanceDegrees(line[index], longitude, latitude) + if math.Abs(altitude) > centralShadowHorizonToleranceDegrees { + t.Fatalf("%s: %s boundary endpoint %.6f,%.6f has altitude %.4f deg, want on the horizon", + fixture.name, stamp, line[index][0], line[index][1], altitude) + } + } + } + } + } +} + +// TestSolarEclipseCentralShadowClosureTestDetectsCutShortBoundaries 证明上面的容差检查 +// 真的能抓住"边界提前停止"的几何:把擦地点清空后退回旧的封口方式,检查必须报错。 +// TestSolarEclipseCentralShadowClosureTestDetectsCutShortBoundaries proves the +// tolerance check above has teeth: without the grazing points the fallback closure +// stops short of the horizon and the check must report it. +func TestSolarEclipseCentralShadowClosureTestDetectsCutShortBoundaries(t *testing.T) { + var fixture centralShadowRegionFixture + for _, candidate := range centralShadowRegionFixtures() { + if candidate.wantHorizon { + fixture = candidate + break + } + } + info, ok := eclipse.SolarEclipsePartialFootprints( + time.Date(fixture.year, fixture.month, fixture.day, 0, 0, 0, 0, time.UTC), + eclipse.SolarEclipsePartialFootprintOptions{ + Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + DisableRiseSet: true, + }, + ) + if !ok { + t.Fatalf("%s: no solar eclipse", fixture.name) + } + open := 0 + for index := range info.CentralShadowFootprints { + if info.CentralShadowFootprints[index].Closed { + continue + } + info.CentralShadowFootprints[index].HorizonEnds = nil + open++ + } + if open == 0 { + t.Fatalf("%s: no horizon-cut footprint to degrade", fixture.name) + } + data, err := geojson.MarshalSolarEclipse(info, nil) + if err != nil { + t.Fatalf("%s: MarshalSolarEclipse: %v", fixture.name, err) + } + collection := decodeCollection(t, data) + if len(featuresWithRole(collection, "central-shadow-boundary")) == 0 { + t.Fatal("degraded export lost its physical boundary features") + } + if issues := centralShadowClosureViolations(t, collection); len(issues) == 0 { + t.Fatal("the horizon tolerance check passed on a closure that stops short of the horizon") + } +} + +func TestSolarEclipseCentralShadowBoundaryKeepsTheSampledCurve(t *testing.T) { + fixture := centralShadowRegionFixtures()[0] + collection, info := centralShadowMarshalFixture(t, fixture) + boundaries := featuresWithRole(collection, "central-shadow-boundary") + if len(boundaries) == 0 { + t.Fatalf("%s: no central-shadow-boundary features", fixture.name) + } + byTime := map[string]decodedFeature{} + for _, boundary := range boundaries { + stamp, _ := boundary.Properties["time"].(string) + byTime[stamp] = boundary + } + checked := 0 + for _, footprint := range info.CentralShadowFootprints { + if footprint.Closed { + continue + } + feature, present := byTime[footprint.Time.UTC().Format(time.RFC3339Nano)] + if !present { + t.Fatalf("%s: horizon-cut footprint %v has no central-shadow-boundary feature", + fixture.name, footprint.Time.UTC()) + } + expected := make([][2]float64, 0, len(footprint.HorizonEnds)+2) + expected = append(expected, [2]float64{footprint.HorizonEnds[0].Longitude, footprint.HorizonEnds[0].Latitude}) + for _, segment := range footprint.Boundaries { + for _, point := range segment { + expected = append(expected, [2]float64{point.Longitude, point.Latitude}) + } + } + expected = append(expected, [2]float64{footprint.HorizonEnds[1].Longitude, footprint.HorizonEnds[1].Latitude}) + lines := centralShadowBoundaryLines(t, feature) + if len(lines) != 1 { + t.Fatalf("%s: %v boundary has %d segments, want the joined physical curve", + fixture.name, footprint.Time.UTC(), len(lines)) + } + if len(lines[0]) != len(expected) { + t.Fatalf("%s: %v boundary has %d vertices, want %d (grazing points plus the sampled curve)", + fixture.name, footprint.Time.UTC(), len(lines[0]), len(expected)) + } + for index := range expected { + if lines[0][index] != expected[index] { + t.Fatalf("%s: %v boundary vertex %d = %.9f,%.9f, want %.9f,%.9f", + fixture.name, footprint.Time.UTC(), index, + lines[0][index][0], lines[0][index][1], expected[index][0], expected[index][1]) + } + } + checked++ + } + if checked == 0 { + t.Fatalf("%s: no horizon-cut footprint was compared", fixture.name) + } +} + +func TestSolarEclipseCentralShadowRegionRingsAreRightHanded(t *testing.T) { + poleRings := 0 + for _, fixture := range centralShadowRegionFixtures() { + collection, _ := centralShadowMarshalFixture(t, fixture) + for _, region := range featuresWithRole(collection, "central-shadow-footprint") { + for _, ring := range centralShadowRegionRings(t, region) { + if len(ring) < 4 { + t.Fatalf("%s: ring has %d vertices, want a closed ring", fixture.name, len(ring)) + } + area, jumps, winding := 0.0, 0, 0.0 + for index := range ring { + next := ring[(index+1)%len(ring)] + area += ring[index][0]*next[1] - next[0]*ring[index][1] + delta := math.Remainder(next[0]-ring[index][0], 360) + winding += delta + if math.Abs(delta) > 180 { + jumps++ + } + } + if area <= 0 { + t.Fatalf("%s: %v ring is not counter-clockwise in lon/lat (area %.6f)", + fixture.name, region.Properties["time"], area/2) + } + if jumps > 0 { + t.Fatalf("%s: %v ring has %d segment(s) jumping across the map edge", + fixture.name, region.Properties["time"], jumps) + } + if math.Abs(winding) >= 180 { + poleRings++ + if hemisphereArea := 360 * 180; area/2 >= float64(hemisphereArea) { + t.Fatalf("%s: %v pole ring covers %.1f deg2, want the enclosed cap", + fixture.name, region.Properties["time"], area/2) + } + } + } + } + } + if poleRings == 0 { + t.Fatal("no pole-enclosing ring in the fixture set; the polar convention is untested") + } +} + +func TestSolarEclipseSampledPartialClosureLiesOnTheHorizon(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + DisableRiseSet: true, + }) + if !ok { + t.Fatal("expected the 2009-07-22 eclipse") + } + collection := decodeCollection(t, mustMarshalSolarEclipse(t, partial)) + if issues := solarShadowClosureViolations(t, collection, "partial-footprint"); len(issues) > 0 { + t.Fatalf("sampled partial footprint: %s", issues[0]) + } +} diff --git a/geojson/solar_eclipse_grazing_band_regression_test.go b/geojson/solar_eclipse_grazing_band_regression_test.go new file mode 100644 index 0000000..576ec6d --- /dev/null +++ b/geojson/solar_eclipse_grazing_band_regression_test.go @@ -0,0 +1,534 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" +) + +// The static central band must describe the region where the eclipse is +// actually annular or total. For grazing events the shadow axis crosses Earth +// over only a fraction of the umbral contact interval, so a band derived from +// the paired limits alone silently drops the flared ends of the real path. +// These fixtures pin the coverage that NASA's path tables list from U1 to U4 +// (for example 2003 May 31: limits from 004 35.9W to 060 19.3W). +const grazingBandCoverageToleranceKM = 100.0 + +type grazingBandCase struct { + date string + options string + limit float64 // maximum tolerated footprint distance outside the band +} + +var grazingBandCases = []grazingBandCase{ + // One-limit (|gamma| ~ 0.98-0.997) annulars: the reported defect. + {"2003-05-31", "overview", 25}, + {"2003-05-31", "detail", 25}, + {"1874-10-10", "overview", 60}, + {"1874-10-10", "detail", 60}, + // One-limit total across the antimeridian. + {"2185-07-26", "overview", 50}, + {"2185-07-26", "detail", 25}, + // Two-limit annulars whose analytic envelope is unavailable and whose + // paired-limit ribbon used to be accepted without validation. + {"1552-07-21", "overview", 25}, + {"-1480-12-27", "overview", 60}, + {"4862-09-28", "overview", 60}, + {"1042-06-20", "overview", 80}, + {"5705-06-17", "overview", 80}, + // Already-correct polar one-limit totality: must not regress. + {"1522-03-27", "overview", 10}, + {"1522-03-27", "detail", 10}, + // Ordinary two-limit totality. + {"2024-04-08", "overview", 25}, +} + +func TestSolarEclipseGrazingCentralBandCoversUmbralSweep(t *testing.T) { + for _, testCase := range grazingBandCases { + t.Run(testCase.date+"-"+testCase.options, func(t *testing.T) { + date := grazingBandDate(t, testCase.date) + info, ok := eclipse.SolarEclipseOnDate(date) + if !ok || !info.HasCentral { + t.Fatalf("expected a central solar eclipse on %s", testCase.date) + } + partialOptions, pathOptions := grazingBandOptions(info, testCase.options) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions) + if !ok { + t.Fatal("missing partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions) + if !ok { + t.Fatal("missing central path") + } + raw, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatal(err) + } + rings := grazingBandRings(t, raw) + if len(rings) == 0 { + t.Fatal("missing central-band feature") + } + paths := grazingFootprintPaths(partial.CentralBandFootprints) + if len(paths) == 0 { + t.Fatal("missing central band footprints") + } + miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, paths, true) + if miss > testCase.limit { + t.Fatalf("central band leaves the umbral sweep %.1f km outside (limit %.1f km)", + miss, testCase.limit) + } + centerPath := make([]geodata.GeoPoint, 0, len(central.CenterLine)) + for _, point := range central.CenterLine { + centerPath = append(centerPath, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + if centerMiss := geodata.SphericalPolygonsPathMissDistanceKM( + rings, [][]geodata.GeoPoint{centerPath}, false, + ); centerMiss > 25 { + t.Fatalf("central band leaves the center line %.1f km outside", centerMiss) + } + }) + } +} + +// TestSolarEclipseGrazingCentralBandIsNotRejectedAsEnvelope guards the other +// direction: the flared-end band must still be a single closed continuous +// activation per mode, not a fan of open slices. +func TestSolarEclipseGrazingCentralBandIsSingleContinuousBand(t *testing.T) { + for _, testCase := range grazingBandCases { + if testCase.options != "overview" { + continue + } + t.Run(testCase.date, func(t *testing.T) { + date := grazingBandDate(t, testCase.date) + info, ok := eclipse.SolarEclipseOnDate(date) + if !ok { + t.Fatalf("missing eclipse on %s", testCase.date) + } + partialOptions, pathOptions := grazingBandOptions(info, testCase.options) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions) + if !ok { + t.Fatal("missing partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions) + if !ok { + t.Fatal("missing central path") + } + raw, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatal(err) + } + var collection solarGeoJSONScanCollection + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatal(err) + } + bands := 0 + for _, feature := range collection.Features { + if role, _ := feature.Properties["role"].(string); role == "central-band" { + bands++ + } + } + if bands != 1 { + t.Fatalf("central-band feature count = %d, want 1", bands) + } + }) + } +} + +func grazingBandDate(t *testing.T, text string) time.Time { + t.Helper() + if strings.HasPrefix(text, "-") { + parts := strings.Split(strings.TrimPrefix(text, "-"), "-") + if len(parts) != 3 { + t.Fatalf("invalid astronomical date %q", text) + } + year, err := strconv.Atoi(parts[0]) + if err != nil { + t.Fatal(err) + } + month, err := strconv.Atoi(parts[1]) + if err != nil { + t.Fatal(err) + } + day, err := strconv.Atoi(parts[2]) + if err != nil { + t.Fatal(err) + } + return time.Date(-year, time.Month(month), day, 12, 0, 0, 0, time.UTC) + } + date, err := time.Parse("2006-01-02", text) + if err != nil { + t.Fatal(err) + } + return date +} + +func grazingBandOptions( + info eclipse.SolarEclipseInfo, + mode string, +) (eclipse.SolarEclipsePartialFootprintOptions, eclipse.SolarEclipsePathOptions) { + if mode == "detail" { + return eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, + BoundaryPoints: 96, + CentralShadowStep: 2 * time.Minute, + RiseSetStep: time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0}, + }, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, + TargetSpacingKM: 700, + } + } + partial := eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, + BoundaryPoints: 96, + RiseSetStep: 2 * time.Minute, + } + if info.Type == eclipse.SolarEclipseTotal { + partial.MagnitudeValues = []float64{1} + } + return partial, eclipse.SolarEclipsePathOptions{ + Step: 2 * time.Minute, + TargetSpacingKM: 150, + } +} + +func grazingBandRings(t *testing.T, raw []byte) [][]geodata.GeoPoint { + t.Helper() + var collection solarGeoJSONScanCollection + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatal(err) + } + var rings [][]geodata.GeoPoint + for _, feature := range collection.Features { + if role, _ := feature.Properties["role"].(string); role != "central-band" { + continue + } + var polygons [][][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil { + t.Fatal(err) + } + for _, polygon := range polygons { + if len(polygon) == 0 { + continue + } + ring := make([]geodata.GeoPoint, 0, len(polygon[0])) + for _, position := range polygon[0] { + if len(position) < 2 { + continue + } + ring = append(ring, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]}) + } + if len(ring) >= 4 { + rings = append(rings, ring) + } + } + } + return rings +} + +func grazingFootprintPaths(footprints []eclipse.SolarEclipsePartialFootprint) [][]geodata.GeoPoint { + var paths [][]geodata.GeoPoint + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + path := make([]geodata.GeoPoint, 0, len(boundary)) + for _, point := range boundary { + path = append(path, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + if len(path) >= 3 { + paths = append(paths, path) + } + } + } + return paths +} + +// TestSolarEclipseGrazingLimitsFollowBandBoundary pins the contract the map +// relies on: the dashed north/south limits and the filled central band must +// describe the same region. Ordinary events agree to a few kilometres because +// the band is built from those very limits; a grazing band is rebuilt from the +// umbral sweep, where the instantaneous cross-section limits stop describing +// the boundary at all (1136-06-01 sat 456 km inside its own band). +func TestSolarEclipseGrazingLimitsFollowBandBoundary(t *testing.T) { + for _, testCase := range grazingBandCases { + if testCase.options != "overview" { + continue + } + t.Run(testCase.date, func(t *testing.T) { + date := grazingBandDate(t, testCase.date) + info, ok := eclipse.SolarEclipseOnDate(date) + if !ok { + t.Fatalf("missing eclipse on %s", testCase.date) + } + partialOptions, pathOptions := grazingBandOptions(info, testCase.options) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions) + if !ok { + t.Fatal("missing partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions) + if !ok { + t.Fatal("missing central path") + } + raw, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatal(err) + } + rings := grazingBandRings(t, raw) + if len(rings) == 0 { + t.Fatal("missing central-band feature") + } + if len(rings) == 0 { + t.Fatal("missing central-band feature") + } + for _, role := range []string{"north-limit", "south-limit"} { + path, ok := grazingLimitPath(t, raw, role) + if !ok { + t.Fatalf("missing %s feature", role) + } + miss := geodata.SphericalPolygonsPathMissDistanceKM( + rings, [][]geodata.GeoPoint{path}, false, + ) + if miss > 1.0 { + t.Fatalf("%s sits %.1f km from the band boundary", role, miss) + } + } + }) + } +} + +// TestSolarEclipseSampledBandEdgeFollowsGreatestHorizonCurve pins the export +// contract the map shows: for a band rebuilt from sampled footprints, the edge +// that is bounded by the greatest-at-horizon condition must lie on that curve, +// otherwise the filled band and the drawn visibility line weave across each +// other at high zoom. +func TestSolarEclipseSampledBandEdgeFollowsGreatestHorizonCurve(t *testing.T) { + exercised, skipped := 0, 0 + for _, testCase := range grazingBandCases { + if testCase.options != "overview" || testCase.limit > 60 { + continue + } + t.Run(testCase.date, func(t *testing.T) { + date := grazingBandDate(t, testCase.date) + info, ok := eclipse.SolarEclipseOnDate(date) + if !ok { + t.Fatalf("missing eclipse on %s", testCase.date) + } + partialOptions, pathOptions := grazingBandOptions(info, testCase.options) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions) + if !ok { + t.Fatal("missing partial footprints") + } + if !partial.CentralBandSampled { + skipped++ + t.Skip("analytic envelope: the band is already the exact boundary") + } + exercised++ + central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions) + if !ok { + t.Fatal("missing central path") + } + raw, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatal(err) + } + rings := grazingBandRings(t, raw) + if len(rings) == 0 { + t.Fatal("missing central-band feature") + } + curves := grazingGreatestCurves(t, raw) + if len(curves) == 0 { + t.Fatal("missing greatest visibility curves") + } + closest := math.Inf(1) + for _, point := range rings[0] { + for _, curve := range curves { + for index := 0; index+1 < len(curve); index++ { + closest = math.Min(closest, grazingPointSegmentKM(point, curve[index], curve[index+1])) + } + } + } + if closest > 1.0 { + t.Fatalf("band edge stays %.1f km away from the greatest-at-horizon curve", closest) + } + }) + } + // 采样带是少数情形:15 个夹具里只有 4 个走这条断言。若夹具筛选或"权威带"来源变化, + // 这些用例会退化成一堆 skip 而不是失败,所以钉住覆盖数下限。 + if exercised < 4 { + t.Fatalf("sampled-band assertion exercised by %d fixtures (%d analytic skips); the fixture filter or the band source narrowed silently", exercised, skipped) + } + t.Logf("sampled-band edge assertion exercised by %d fixtures, %d analytic skips", exercised, skipped) +} + +// grazingGreatestCurves returns the exported greatest-at-horizon boundaries. +func grazingGreatestCurves(t *testing.T, raw []byte) [][]geodata.GeoPoint { + t.Helper() + var collection solarGeoJSONScanCollection + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatal(err) + } + var curves [][]geodata.GeoPoint + for _, feature := range collection.Features { + if role, _ := feature.Properties["role"].(string); role != "visibility-boundary" { + continue + } + if phase, _ := feature.Properties["phase"].(string); phase != "greatest" { + continue + } + var lines [][][]float64 + encoded, err := json.Marshal(feature.Geometry.Coordinates) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(encoded, &lines); err != nil { + // A single LineString is exported as one coordinate array. + var line [][]float64 + if lineErr := json.Unmarshal(encoded, &line); lineErr != nil { + t.Fatal(err) + } + lines = [][][]float64{line} + } + for _, line := range lines { + curve := make([]geodata.GeoPoint, 0, len(line)) + for _, position := range line { + if len(position) < 2 { + continue + } + curve = append(curve, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]}) + } + if len(curve) >= 2 { + curves = append(curves, curve) + } + } + } + return curves +} + +// grazingPointSegmentKM is the planar distance from a point to one segment. +func grazingPointSegmentKM(point, first, second geodata.GeoPoint) float64 { + scale := math.Cos(point.Latitude * math.Pi / 180) + ax := (first.Longitude - point.Longitude) * scale + ay := first.Latitude - point.Latitude + bx := (second.Longitude - point.Longitude) * scale + by := second.Latitude - point.Latitude + dx, dy := bx-ax, by-ay + length := dx*dx + dy*dy + fraction := 0.0 + if length > 0 { + fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length)) + } + return 111.32 * math.Hypot(ax+fraction*dx, ay+fraction*dy) +} + +// grazingLimitPath returns one exported limit line as a geographic path. +func grazingLimitPath(t *testing.T, raw []byte, role string) ([]geodata.GeoPoint, bool) { + t.Helper() + var collection solarGeoJSONScanCollection + if err := json.Unmarshal(raw, &collection); err != nil { + t.Fatal(err) + } + for _, feature := range collection.Features { + if value, _ := feature.Properties["role"].(string); value != role { + continue + } + var line [][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &line); err != nil { + var lines [][][]float64 + if multiErr := json.Unmarshal(feature.Geometry.Coordinates, &lines); multiErr != nil { + t.Fatal(err) + } + if len(lines) == 0 { + return nil, false + } + line = lines[0] + } + path := make([]geodata.GeoPoint, 0, len(line)) + for _, position := range line { + if len(position) < 2 { + continue + } + path = append(path, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]}) + } + if len(path) >= 2 { + return path, true + } + } + return nil, false +} + +// TestMarshalSolarEclipse11360601KeepsItsCentralBandSimple covers a shallow +// two-limit event whose northern limit runs through a cusp near the apex of a +// high-latitude path. Concatenating the two limits into one ribbon ring used to +// fold the ring onto itself, so the export contained a spike triangle plus +// disconnected end pieces instead of the swept band. +func TestMarshalSolarEclipse11360601KeepsItsCentralBandSimple(t *testing.T) { + date := time.Date(1136, time.June, 1, 12, 0, 0, 0, time.UTC) + info, ok := eclipse.SolarEclipseOnDate(date) + if !ok || !info.HasCentral { + t.Fatal("expected a central solar eclipse on 1136-06-01") + } + partialOptions, pathOptions := grazingBandOptions(info, "overview") + partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions) + if !ok { + t.Fatal("missing partial footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions) + if !ok { + t.Fatal("missing central path") + } + raw, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatal(err) + } + rings := grazingBandRings(t, raw) + if len(rings) != 1 { + t.Fatalf("central band exported as %d polygons, want 1 simple ring", len(rings)) + } + if len(rings[0]) < 8 { + t.Fatalf("central band ring has %d vertices", len(rings[0])) + } + if i, j, crossed := grazingRingCrossing(rings[0]); crossed { + t.Fatalf("central band ring crosses itself between vertices %d and %d", i, j) + } + centerPath := make([]geodata.GeoPoint, 0, len(central.CenterLine)) + for _, point := range central.CenterLine { + centerPath = append(centerPath, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM( + rings, [][]geodata.GeoPoint{centerPath}, false, + ); miss > 25 { + t.Fatalf("central band leaves the center line %.1f km outside", miss) + } +} + +// grazingRingCrossing reports the first planar self-intersection of a ring. +func grazingRingCrossing(ring []geodata.GeoPoint) (int, int, bool) { + for first := 0; first+1 < len(ring); first++ { + for second := first + 2; second+1 < len(ring); second++ { + if first == 0 && second+1 == len(ring)-1 { + continue + } + if grazingSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) { + return first, second, true + } + } + } + return 0, 0, false +} + +func grazingSegmentsCross(a, b, c, d geodata.GeoPoint) bool { + side := func(p, q, r geodata.GeoPoint) float64 { + return (q.Longitude-p.Longitude)*(r.Latitude-p.Latitude) - (q.Latitude-p.Latitude)*(r.Longitude-p.Longitude) + } + first := side(c, d, a) + second := side(c, d, b) + third := side(a, b, c) + fourth := side(a, b, d) + return (first > 0) != (second > 0) && (third > 0) != (fourth > 0) +} diff --git a/geojson/solar_eclipse_polar_envelope_test.go b/geojson/solar_eclipse_polar_envelope_test.go new file mode 100644 index 0000000..03d06f8 --- /dev/null +++ b/geojson/solar_eclipse_polar_envelope_test.go @@ -0,0 +1,51 @@ +package geojson_test + +import ( + "strings" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" +) + +func TestSolarPolarBandPreservesContinuousEnvelope(t *testing.T) { + for _, day := range [][3]int{ + {2003, 11, 23}, {2021, 12, 4}, {2039, 12, 15}, {2061, 10, 13}, {2981, 10, 19}, + } { + date := time.Date(day[0], time.Month(day[1]), day[2], 0, 0, 0, 0, time.UTC) + t.Run(date.Format("2006-01-02"), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, DisableRiseSet: true, + }) + if !ok || len(partial.CentralBandSegments) != 1 { + t.Fatal("missing continuous envelope") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute, TargetSpacingKM: 700}) + if !ok { + t.Fatal("missing central path") + } + for _, path := range []*eclipse.SolarEclipsePath{nil, ¢ral} { + data, err := geojson.MarshalSolarEclipse(partial, path) + if err != nil { + t.Fatal(err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + source, _ := band.Properties["source"].(string) + if !strings.Contains(source, "envelope") { + t.Fatalf("continuous envelope replaced by %q", source) + } + rings := geoJSONMultiPolygonOuterRings(t, band) + var centers []geodata.GeoPoint + for _, point := range central.CenterLine { + centers = append(centers, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, [][]geodata.GeoPoint{centers}, false); miss > 2 { + t.Fatalf("central band omits center line by %.3f km", miss) + } + assertClosedMultiPolygon(t, band) + } + }) + } +} diff --git a/geojson/solar_eclipse_polar_two_limit_regression_test.go b/geojson/solar_eclipse_polar_two_limit_regression_test.go new file mode 100644 index 0000000..ce76131 --- /dev/null +++ b/geojson/solar_eclipse_polar_two_limit_regression_test.go @@ -0,0 +1,100 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" +) + +func TestMarshalSolarEclipsePolarTwoLimitBandUsesSimpleFaces(t *testing.T) { + for _, sample := range []struct { + date time.Time + centralStep time.Duration + }{ + {time.Date(767, time.April, 3, 0, 0, 0, 0, time.UTC), 5 * time.Minute}, + {time.Date(767, time.April, 3, 0, 0, 0, 0, time.UTC), time.Minute}, + {time.Date(2981, time.October, 19, 0, 0, 0, 0, time.UTC), 5 * time.Minute}, + {time.Date(2981, time.October, 19, 0, 0, 0, 0, time.UTC), time.Minute}, + } { + t.Run(sample.date.Format("2006-01-02")+"/"+sample.centralStep.String(), func(t *testing.T) { + date := sample.date + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 5 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute, + MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1}, + }) + if !ok { + t.Fatal("expected solar eclipse footprints") + } + central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ + Step: sample.centralStep, TargetSpacingKM: 500, + }) + if !ok { + t.Fatal("expected central path") + } + data, err := geojson.MarshalSolarEclipse(partial, ¢ral) + if err != nil { + t.Fatalf("MarshalSolarEclipse: %v", err) + } + collection := decodeCollection(t, data) + band := featureWithRole(t, collection, "central-band") + assertClosedMultiPolygon(t, band) + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode central-band: %v", err) + } + if len(polygons) == 0 { + t.Fatal("polar central-band has no polygon") + } + for polygonIndex, polygon := range polygons { + if len(polygon) == 0 { + t.Fatalf("polygon %d has no exterior ring", polygonIndex) + } + ring := polygon[0] + for first := 0; first+1 < len(ring); first++ { + for second := first + 2; second+1 < len(ring); second++ { + if first == 0 && second+1 == len(ring)-1 { + continue + } + if polarAntimeridianFragmentEdge(ring[first], ring[first+1]) || + polarAntimeridianFragmentEdge(ring[second], ring[second+1]) { + continue + } + if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) { + t.Fatalf("polygon %d self-intersects between edges %d and %d", polygonIndex, first, second) + } + } + } + } + rings := geoJSONMultiPolygonOuterRings(t, band) + paths := make([][]geodata.GeoPoint, 0, len(central.CenterLine)) + for _, series := range [][]eclipse.SolarEclipsePathPoint{ + central.NorthernLimit, central.SouthernLimit, central.CenterLine, + } { + for _, point := range series { + // Cross-section limits near the limb can have local greatest + // below the horizon; they are not visible-band witnesses. + local, ok := eclipse.GeometricLocalSolarEclipseOnDate(date, point.Longitude, point.Latitude, 0) + if ok && local.VisibleAtGreatest && local.Type != eclipse.SolarEclipsePartial { + paths = append(paths, []geodata.GeoPoint{{Longitude: point.Longitude, Latitude: point.Latitude}}) + } + } + } + if len(paths) == 0 { + t.Fatal("missing independently verified visible witnesses") + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, paths, false); miss > 2 { + t.Fatalf("central-band misses source path by %.3f km", miss) + } + }) + } +} + +func polarAntimeridianFragmentEdge(first, second []float64) bool { + return len(first) >= 2 && len(second) >= 2 && + (math.Abs(first[0]) == 180 || math.Abs(second[0]) == 180) +} diff --git a/geojson/solar_eclipse_total_options_test.go b/geojson/solar_eclipse_total_options_test.go new file mode 100644 index 0000000..58723d2 --- /dev/null +++ b/geojson/solar_eclipse_total_options_test.go @@ -0,0 +1,61 @@ +package geojson_test + +import ( + "reflect" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestSolarTotalBandIndependentOfMagnitudeLines(t *testing.T) { + for _, fixture := range []struct { + date time.Time + lon, lat float64 + }{ + {time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC), -5.991755201, 45.070085755}, + {time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), -32.007627414, 49.493804579}, + } { + t.Run(fixture.date.Format("2006-01-02"), func(t *testing.T) { + central, ok := eclipse.SolarEclipseCentralPath(fixture.date, eclipse.SolarEclipsePathOptions{Step: time.Minute}) + if !ok { + t.Fatal("missing central path") + } + local, ok := eclipse.GeometricLocalSolarEclipseOnDate(fixture.date, fixture.lon, fixture.lat, 0) + if !ok || local.Type != eclipse.SolarEclipseTotal || !local.VisibleAtGreatest { + t.Fatal("invalid totality witness") + } + var reference [][]eclipse.SolarEclipsePathPoint + for _, magnitudes := range [][]float64{nil, {1}} { + partial, ok := eclipse.SolarEclipsePartialFootprints(fixture.date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, MagnitudeValues: magnitudes, + }) + if !ok || len(partial.CentralBandSegments) == 0 { + t.Fatal("missing authoritative totality band") + } + if reference == nil { + reference = partial.CentralBandSegments + } else if !reflect.DeepEqual(reference, partial.CentralBandSegments) { + t.Fatal("magnitude display option changes totality geometry") + } + if len(magnitudes) == 0 && len(partial.MagnitudeContours) != 0 { + t.Fatal("unrequested magnitude lines") + } + for _, ring := range partial.CentralBandSegments { + assertSolarPathMaximumEdgeKM(t, ring, 250) + } + for _, path := range []*eclipse.SolarEclipsePath{¢ral, nil} { + data, err := geojson.MarshalSolarEclipse(partial, path) + if err != nil { + t.Fatal(err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + if !geometryContainsPoint(t, band.Geometry, fixture.lon, fixture.lat) { + t.Fatal("totality band omits visible site") + } + } + } + }) + } +} diff --git a/geojson/solar_hybrid_sampling_regression_test.go b/geojson/solar_hybrid_sampling_regression_test.go new file mode 100644 index 0000000..458cc5e --- /dev/null +++ b/geojson/solar_hybrid_sampling_regression_test.go @@ -0,0 +1,48 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestSolarHybridThinComponentsDoNotCross(t *testing.T) { + for _, date := range [][3]int{{881, 3, 4}, {985, 7, 20}, {1703, 1, 17}, {2386, 4, 29}, {3405, 7, 18}, {3667, 1, 7}} { + day := time.Date(date[0], time.Month(date[1]), date[2], 0, 0, 0, 0, time.UTC) + t.Run(day.Format("2006-01-02"), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(day, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 5 * time.Minute, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute, BoundaryPoints: 96, + }) + if !ok { + t.Fatal("missing solar eclipse") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatal(err) + } + band := featureWithRole(t, decodeCollection(t, data), "central-band") + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatal(err) + } + for pi, polygon := range polygons { + for _, ring := range polygon { + for i := 0; i+1 < len(ring); i++ { + for j := i + 2; j+1 < len(ring); j++ { + if i == 0 && j+1 == len(ring)-1 || math.Abs(ring[i][0]) == 180 && math.Abs(ring[i+1][0]) == 180 || math.Abs(ring[j][0]) == 180 && math.Abs(ring[j+1][0]) == 180 { + continue + } + if geoJSONSegmentsCross(ring[i], ring[i+1], ring[j], ring[j+1]) { + t.Fatalf("component %d crosses itself at edges %d/%d", pi, i, j) + } + } + } + } + } + }) + } +} diff --git a/geojson/solar_partial_envelope_regression_test.go b/geojson/solar_partial_envelope_regression_test.go new file mode 100644 index 0000000..b9b3c88 --- /dev/null +++ b/geojson/solar_partial_envelope_regression_test.go @@ -0,0 +1,52 @@ +package geojson_test + +import ( + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" + "b612.me/astro/internal/geodata" +) + +func TestSolarPartialEnvelopeCoversHorizonCurves(t *testing.T) { + for _, date := range []time.Time{ + time.Date(43, 3, 29, 0, 0, 0, 0, time.UTC), + time.Date(1848, 9, 27, 0, 0, 0, 0, time.UTC), + time.Date(2023, 4, 20, 0, 0, 0, 0, time.UTC), + time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC), + } { + t.Run(date.Format("2006-01-02"), func(t *testing.T) { + path, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 5 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute, + }) + if !ok { + t.Fatal("missing eclipse") + } + data, err := geojson.MarshalSolarEclipse(path, nil) + if err != nil { + t.Fatal(err) + } + collection := decodeCollection(t, data) + band := featureWithRole(t, collection, "partial-band") + if source := band.Properties["source"]; source != "zero-magnitude-envelope+horizon-boundary" { + t.Fatalf("incomplete envelope fallback: %v", source) + } + assertClosedMultiPolygon(t, band) + rings := geoJSONMultiPolygonOuterRings(t, band) + var lines [][]geodata.GeoPoint + for _, curve := range path.RiseSetCurves { + for _, segment := range curve.Segments { + line := make([]geodata.GeoPoint, len(segment)) + for i, point := range segment { + line[i] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + lines = append(lines, line) + } + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, lines, false); miss > 2 { + t.Fatalf("phase curves miss envelope by %.3f km", miss) + } + }) + } +} diff --git a/geojson/solar_polar_projection_regression_test.go b/geojson/solar_polar_projection_regression_test.go new file mode 100644 index 0000000..379420e --- /dev/null +++ b/geojson/solar_polar_projection_regression_test.go @@ -0,0 +1,91 @@ +package geojson_test + +import ( + "encoding/json" + "math" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +func TestSolarPolarProjectedPhaseCoverage(t *testing.T) { + for _, date := range [][3]int{{163, 3, 22}, {564, 9, 21}, {936, 9, 18}, {1327, 9, 16}, {3429, 9, 21}, {3820, 9, 20}, {3932, 3, 20}, {4008, 3, 20}, {4026, 9, 24}} { + day := time.Date(date[0], time.Month(date[1]), date[2], 0, 0, 0, 0, time.UTC) + t.Run(day.Format("2006-01-02"), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(day, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 5 * time.Minute, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute, BoundaryPoints: 96, + }) + if !ok { + t.Fatal("missing eclipse") + } + data, err := geojson.MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatal(err) + } + band := featureWithRole(t, decodeCollection(t, data), "partial-band") + var polygons [][][][]float64 + if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil { + t.Fatal(err) + } + for _, curve := range partial.RiseSetCurves { + for _, segment := range curve.Segments { + for _, p := range segment { + if !geoJSONMultiPolygonContains(polygons, p.Longitude, p.Latitude) { + if miss := geoJSONMultiPolygonBoundaryDistanceKM(polygons, []float64{p.Longitude, p.Latitude}); miss > 2 { + t.Fatalf("projected phase point %.8f/%.8f misses by %.3f km", p.Longitude, p.Latitude, miss) + } + } + } + } + } + }) + } +} + +func TestSolarProjectedPolarAndPartialRingsDoNotCross(t *testing.T) { + for _, date := range [][3]int{{33, 9, 12}, {767, 4, 3}, {1527, 11, 23}, {2459, 5, 3}, {2981, 10, 19}, {3284, 2, 8}} { + day := time.Date(date[0], time.Month(date[1]), date[2], 0, 0, 0, 0, time.UTC) + t.Run(day.Format("2006-01-02"), func(t *testing.T) { + partial, ok := eclipse.SolarEclipsePartialFootprints(day, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 5 * time.Minute, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute, BoundaryPoints: 96, + }) + if !ok { + t.Fatal("missing eclipse") + } + central, hasCentral := eclipse.SolarEclipseCentralPath(day, eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 500}) + var centralPath *eclipse.SolarEclipsePath + if hasCentral { + centralPath = ¢ral + } + data, err := geojson.MarshalSolarEclipse(partial, centralPath) + if err != nil { + t.Fatal(err) + } + for _, f := range decodeCollection(t, data).Features { + if f.Properties["role"] != "partial-band" && f.Properties["role"] != "central-band" { + continue + } + var polygons [][][][]float64 + if err := json.Unmarshal(f.Geometry.Coordinates, &polygons); err != nil { + t.Fatal(err) + } + for pi, polygon := range polygons { + for _, ring := range polygon { + for i := 0; i+1 < len(ring); i++ { + for j := i + 2; j+1 < len(ring); j++ { + if i == 0 && j+1 == len(ring)-1 || math.Abs(ring[i][0]) == 180 && math.Abs(ring[i+1][0]) == 180 || math.Abs(ring[j][0]) == 180 && math.Abs(ring[j+1][0]) == 180 { + continue + } + if geoJSONSegmentsCross(ring[i], ring[i+1], ring[j], ring[j+1]) { + t.Fatalf("%s component %d crosses at %d/%d: %v %v / %v %v", f.Properties["role"], pi, i, j, ring[i], ring[i+1], ring[j], ring[j+1]) + } + } + } + } + } + } + }) + } +} diff --git a/geojson/solar_shadow_instant.go b/geojson/solar_shadow_instant.go new file mode 100644 index 0000000..87ef5be --- /dev/null +++ b/geojson/solar_shadow_instant.go @@ -0,0 +1,82 @@ +package geojson + +import ( + "fmt" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/geodata" +) + +// MarshalSolarEclipseShadowInstant 单时刻阴影足迹的最小 GeoJSON:只含该时刻的区域与物理边界 / one instant as the smallest useful GeoJSON. +func MarshalSolarEclipseShadowInstant( + instant eclipsecore.SolarEclipseShadowInstant, +) ([]byte, error) { + if instant.Empty() { + return marshalEmptyFeatureCollection() + } + curve, err := solarShadowFootprintCurveFromSegments(instant.Boundaries) + if err != nil { + return nil, fmt.Errorf("geojson: solar shadow instant at %s: %w", formatTime(instant.Time), err) + } + if len(curve) < 2 { + return marshalEmptyFeatureCollection() + } + properties := map[string]interface{}{ + "model": string(instant.Model), + "delta_t_seconds": instant.DeltaTSeconds, + "interp_signature": instant.Topology.Signature(), + } + regionRole, boundaryRole := solarCentralShadowFootprintRole, solarCentralShadowBoundaryRole + if instant.Kind == eclipsecore.SolarEclipseShadowPenumbra { + regionRole, boundaryRole = solarPartialFootprintRole, solarPartialFootprintBoundaryRole + } + features := make([]feature, 0, 2) + regionProperties := cloneProperties(properties) + regionProperties["time"] = formatTime(instant.Time) + if instant.Closed { + // 与整包导出同构:自身闭合的区域不带 geometry_role,也不带 closure。 + regionProperties["source_boundary_closed"] = true + ring := append([]geodata.GeoPoint(nil), curve...) + if solarShadowRegionDegenerate(curve, ring) { + // 首末接触附近区域收缩到零:整条缺省,不退化成线,也不让近零面积的环触发导出错误。 + return marshalEmptyFeatureCollection() + } + value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring}) + if err != nil { + return nil, fmt.Errorf("geojson: solar shadow instant at %s: %w", formatTime(instant.Time), err) + } + return marshalFeatureCollection([]feature{ + newFeature(solarEclipseEvent, regionRole, value, regionProperties), + }) + } + regionProperties["source_boundary_closed"] = false + regionProperties["geometry_role"] = "horizon-closed-region" + regionProperties["closure"] = solarHorizonClosureProperties( + instant.Time, solarHorizonClosureExact(instant.Boundaries, instant.HorizonEnds), + ) + ring, boundary := solarShadowFootprintHorizonRing( + instant.Time, instant.Boundaries, instant.HorizonEnds, curve, + ) + if solarShadowRegionDegenerate(curve, ring) { + return marshalEmptyFeatureCollection() + } + value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring}) + if err != nil { + return nil, fmt.Errorf("geojson: solar shadow instant at %s: %w", formatTime(instant.Time), err) + } + features = append(features, newFeature( + solarEclipseEvent, regionRole, value, regionProperties, + )) + boundaryValue, err := geoMultiLineGeometry(boundary, false) + if err != nil { + return nil, fmt.Errorf("geojson: solar shadow instant at %s: %w", formatTime(instant.Time), err) + } + boundaryProperties := cloneProperties(properties) + boundaryProperties["time"] = formatTime(instant.Time) + boundaryProperties["source_boundary_closed"] = false + boundaryProperties["geometry_role"] = "open-boundary" + features = append(features, newFeature( + solarEclipseEvent, boundaryRole, boundaryValue, boundaryProperties, + )) + return marshalFeatureCollection(features) +} diff --git a/geojson/solar_shadow_instant_test.go b/geojson/solar_shadow_instant_test.go new file mode 100644 index 0000000..c9359ea --- /dev/null +++ b/geojson/solar_shadow_instant_test.go @@ -0,0 +1,292 @@ +package geojson_test + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "b612.me/astro/eclipse" + "b612.me/astro/geojson" +) + +// 契约:单时刻本影导出只含该时刻的 region 与被切断时的物理边界;同一时刻的几何必须与 +// 整包采样的对应 feature 完全一致,前端的"插值→精确替换"才不会跳变。 +// Contract: the single-instant export contains only that instant's region (and the +// physical boundary when the horizon cuts it), and its geometry must match the packaged +// sample at the same time exactly. + +func TestMarshalSolarEclipseShadowInstantMatchesPackagedFeatures(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + DisableRiseSet: true, + }) + if !ok { + t.Fatal("expected the 2009-07-22 eclipse") + } + packaged := decodeCollection(t, mustMarshalSolarEclipse(t, partial)) + regions := featuresWithRole(packaged, "central-shadow-footprint") + boundaries := map[string]decodedFeature{} + for _, feature := range featuresWithRole(packaged, "central-shadow-boundary") { + stamp, _ := feature.Properties["time"].(string) + boundaries[stamp] = feature + } + if len(regions) == 0 { + t.Fatal("packaged export has no central-shadow-footprint") + } + comparedOpen := 0 + for _, index := range []int{0, len(regions) / 2, len(regions) - 1} { + feature := regions[index] + stamp, _ := feature.Properties["time"].(string) + value, err := time.Parse(time.RFC3339Nano, stamp) + if err != nil { + t.Fatalf("parse packaged time %q: %v", stamp, err) + } + instant, ok := eclipse.SolarEclipseShadowAt(value) + if !ok { + t.Fatalf("%s: single-instant call reported no umbra but the package has a feature", stamp) + } + data, err := geojson.MarshalSolarEclipseShadowInstant(instant) + if err != nil { + t.Fatalf("%s: MarshalSolarEclipseShadowInstant: %v", stamp, err) + } + single := decodeCollection(t, data) + if len(single.Features) == 0 { + t.Fatalf("%s: single-instant export has no features", stamp) + } + instantRegion := featureWithRole(t, single, "central-shadow-footprint") + // 时间戳经 time.Time 往返会有约 1e-9 日的舍入,几何容差取 1e-7 度(约 1 厘米)。 + assertSameCoordinates(t, stamp+" region", feature.Geometry.Coordinates, instantRegion.Geometry.Coordinates) + // 整包 feature 与单时刻导出的插值签名必须由同一口径给出,前端才能用同一个键比较。 + if feature.Properties["interp_signature"] != instantRegion.Properties["interp_signature"] { + t.Fatalf("%s: packaged interp_signature=%v, instant=%v", + stamp, feature.Properties["interp_signature"], instantRegion.Properties["interp_signature"]) + } + if feature.Properties["interp_signature"] != instant.Topology.Signature() { + t.Fatalf("%s: packaged interp_signature=%v, topology=%q", + stamp, feature.Properties["interp_signature"], instant.Topology.Signature()) + } + closed, _ := feature.Properties["source_boundary_closed"].(bool) + if closed { + if len(single.Features) != 1 { + t.Fatalf("%s: self-closed instant exported %d features, want only the region", stamp, len(single.Features)) + } + continue + } + comparedOpen++ + packagedBoundary, present := boundaries[stamp] + if !present { + t.Fatalf("%s: packaged export has no boundary feature to compare", stamp) + } + instantBoundary := featureWithRole(t, single, "central-shadow-boundary") + assertSameCoordinates(t, stamp+" boundary", packagedBoundary.Geometry.Coordinates, instantBoundary.Geometry.Coordinates) + for _, key := range []string{"closure", "interp_signature", "delta_t_seconds", "geometry_role"} { + if _, present := instantRegion.Properties[key]; !present { + t.Fatalf("%s: instant region is missing property %q", stamp, key) + } + } + } + if comparedOpen == 0 { + t.Fatal("no horizon-cut instant was compared against the packaged boundary") + } +} + +func TestMarshalSolarEclipseShadowInstantEmpty(t *testing.T) { + instant, ok := eclipse.SolarEclipseShadowAt(time.Date(2009, time.July, 22, 12, 0, 0, 0, time.UTC)) + if ok || !instant.Empty() { + t.Fatalf("expected an empty instant, got ok=%v empty=%v", ok, instant.Empty()) + } + data, err := geojson.MarshalSolarEclipseShadowInstant(instant) + if err != nil { + t.Fatalf("empty instant returned an error: %v", err) + } + var decoded struct { + Type string `json:"type"` + Features []interface{} `json:"features"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("decode empty export: %v", err) + } + if decoded.Type != "FeatureCollection" || len(decoded.Features) != 0 { + t.Fatalf("empty export shape: type=%q features=%d", decoded.Type, len(decoded.Features)) + } +} + +func TestMarshalSolarEclipseShadowInstantProperties(t *testing.T) { + value := time.Date(2009, time.July, 22, 4, 19, 23, 378387689, time.UTC) + instant, ok := eclipse.SolarEclipseShadowAt(value) + if !ok { + t.Fatal("expected an umbral footprint at the test instant") + } + data, err := geojson.MarshalSolarEclipseShadowInstant(instant) + if err != nil { + t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err) + } + collection := decodeCollection(t, data) + if len(collection.Features) != 2 { + t.Fatalf("features=%d, want region + boundary", len(collection.Features)) + } + region := featureWithRole(t, collection, "central-shadow-footprint") + if region.Geometry.Type != "MultiPolygon" || region.Properties["geometry_role"] != "horizon-closed-region" { + t.Fatalf("region shape: type=%v role=%v", region.Geometry.Type, region.Properties["geometry_role"]) + } + if region.Properties["source_boundary_closed"] != false { + t.Fatalf("region source_boundary_closed=%v, want false", region.Properties["source_boundary_closed"]) + } + closure, ok := region.Properties["closure"].(map[string]interface{}) + if !ok || closure["kind"] != "horizon" { + t.Fatalf("closure=%v, want kind=horizon", region.Properties["closure"]) + } + if signature, _ := region.Properties["interp_signature"].(string); signature != instant.Topology.Signature() { + t.Fatalf("interp_signature=%v, want %q", region.Properties["interp_signature"], instant.Topology.Signature()) + } + boundary := featureWithRole(t, collection, "central-shadow-boundary") + if boundary.Geometry.Type != "MultiLineString" || boundary.Properties["geometry_role"] != "open-boundary" { + t.Fatalf("boundary shape: type=%v role=%v", boundary.Geometry.Type, boundary.Properties["geometry_role"]) + } + if boundary.Properties["interp_signature"] != region.Properties["interp_signature"] { + t.Fatal("region and boundary must share the interpolation signature") + } +} + +// assertSameCoordinates 比较两份几何坐标:结构必须一致,数值容差 1e-7 度。 +// assertSameCoordinates compares two coordinate trees with a 1e-7 degree tolerance. +func assertSameCoordinates(t *testing.T, label string, want, got json.RawMessage) { + t.Helper() + var wantTree, gotTree interface{} + if err := json.Unmarshal(want, &wantTree); err != nil { + t.Fatalf("%s: decode packaged coordinates: %v", label, err) + } + if err := json.Unmarshal(got, &gotTree); err != nil { + t.Fatalf("%s: decode instant coordinates: %v", label, err) + } + var compare func(path string, a, b interface{}) + compare = func(path string, a, b interface{}) { + aList, aOK := a.([]interface{}) + bList, bOK := b.([]interface{}) + if aOK != bOK { + t.Fatalf("%s%s: structure differs", label, path) + } + if !aOK { + aNumber, aIsNumber := a.(float64) + bNumber, bIsNumber := b.(float64) + if !aIsNumber || !bIsNumber { + return + } + if difference := aNumber - bNumber; difference > 1e-7 || difference < -1e-7 { + t.Fatalf("%s%s: %.12f vs %.12f", label, path, aNumber, bNumber) + } + return + } + if len(aList) != len(bList) { + t.Fatalf("%s%s: length %d vs %d", label, path, len(aList), len(bList)) + } + for index := range aList { + compare(fmt.Sprintf("%s[%d]", path, index), aList[index], bList[index]) + } + } + compare("", wantTree, gotTree) +} + +func TestMarshalSolarEclipseShadowInstantSelfClosedShape(t *testing.T) { + instant, ok := eclipse.SolarEclipseShadowAt(time.Date(2009, time.July, 22, 0, 55, 0, 0, time.UTC)) + if !ok { + t.Fatal("expected a self-closed footprint") + } + if !instant.Closed { + t.Fatalf("expected closed at the test instant, signature=%s", instant.Topology.Signature()) + } + data, err := geojson.MarshalSolarEclipseShadowInstant(instant) + if err != nil { + t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err) + } + collection := decodeCollection(t, data) + if len(collection.Features) != 1 { + t.Fatalf("self-closed instant exported %d features, want only the region", len(collection.Features)) + } + region := collection.Features[0] + if region.Properties["source_boundary_closed"] != true { + t.Fatalf("source_boundary_closed=%v, want true", region.Properties["source_boundary_closed"]) + } + // 与整包导出同构:自身闭合的区域没有 geometry_role,也没有 closure。 + for _, key := range []string{"geometry_role", "closure"} { + if _, present := region.Properties[key]; present { + t.Fatalf("self-closed region must not carry %q (packaged export does not)", key) + } + } +} + +func TestMarshalSolarEclipseShadowInstantPenumbraRoles(t *testing.T) { + solver := eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{ + Kind: eclipse.SolarEclipseShadowPenumbra, + }) + instant, ok := solver.ShadowAt(time.Date(2009, time.July, 22, 0, 52, 0, 0, time.UTC)) + if !ok { + t.Fatal("expected a penumbra footprint") + } + data, err := geojson.MarshalSolarEclipseShadowInstant(instant) + if err != nil { + t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err) + } + collection := decodeCollection(t, data) + region := featureWithRole(t, collection, "partial-footprint") + if region.Geometry.Type != "MultiPolygon" { + t.Fatalf("penumbra region geometry=%q, want MultiPolygon", region.Geometry.Type) + } + if signature, _ := region.Properties["interp_signature"].(string); len(signature) < 8 || signature[:8] != "penumbra" { + t.Fatalf("penumbra signature=%v, want a penumbra prefix", region.Properties["interp_signature"]) + } + if _, present := region.Properties["delta_t_seconds"]; !present { + t.Fatal("penumbra region is missing delta_t_seconds") + } + if instant.Closed { + if len(collection.Features) != 1 { + t.Fatalf("self-closed penumbra exported %d features, want only the region", len(collection.Features)) + } + return + } + boundary := featureWithRole(t, collection, "partial-footprint-boundary") + if boundary.Geometry.Type != "MultiLineString" { + t.Fatalf("penumbra boundary geometry=%q, want MultiLineString", boundary.Geometry.Type) + } + if closure, _ := region.Properties["closure"].(map[string]interface{}); closure["kind"] != "horizon" { + t.Fatalf("penumbra closure=%v, want kind=horizon", region.Properties["closure"]) + } +} + +func TestMarshalSampledPartialFootprintCarriesClosureAndHorizonEndpoints(t *testing.T) { + date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) + partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, + DisableRiseSet: true, + }) + if !ok { + t.Fatal("expected the 2009-07-22 eclipse") + } + collection := decodeCollection(t, mustMarshalSolarEclipse(t, partial)) + openCount, closureCount := 0, 0 + for _, feature := range featuresWithRole(collection, "partial-footprint") { + if _, present := feature.Properties["interp_signature"]; !present { + t.Fatal("packaged partial footprint is missing interp_signature") + } + closed, _ := feature.Properties["source_boundary_closed"].(bool) + if closed { + if _, present := feature.Properties["closure"]; present { + t.Fatal("self-closed partial footprint must not carry a closure") + } + continue + } + openCount++ + if _, present := feature.Properties["closure"]; present { + closureCount++ + } + if feature.Properties["geometry_role"] != "horizon-closed-region" { + t.Fatalf("open partial footprint geometry_role=%v, want horizon-closed-region", + feature.Properties["geometry_role"]) + } + } + if openCount == 0 || closureCount != openCount { + t.Fatalf("open=%d withClosure=%d, want every open footprint annotated", openCount, closureCount) + } +} diff --git a/geojson/solar_shadow_region.go b/geojson/solar_shadow_region.go new file mode 100644 index 0000000..53f145a --- /dev/null +++ b/geojson/solar_shadow_region.go @@ -0,0 +1,275 @@ +package geojson + +import ( + "fmt" + "math" + "time" + + "b612.me/astro/basic" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/solarclosure" +) + +const ( + // 区域角色永远是面,退化则缺省;物理边界角色永远只承载曲线。 + solarCentralShadowFootprintRole = "central-shadow-footprint" + solarCentralShadowBoundaryRole = "central-shadow-boundary" + // 半影用与采样序列同名的区域角色,物理边界另立角色。 + // 细于该长度的足迹是首末接触处的残片,不导出。 + solarCentralShadowMinimumRegionLengthKM = 1.0 + // 面积下限比导出侧的零面积下限(1e-12 平方度)高 1000 倍,退化的环直接缺省。 + solarCentralShadowMinimumRegionAreaDegrees2 = 1e-9 + + solarPartialFootprintRole = "partial-footprint" + solarPartialFootprintBoundaryRole = "partial-footprint-boundary" +) + +// appendSolarHorizonClosedShadowFootprint 输出被地平线切断的瞬时中心影足迹:区域加物理边界 / horizon-cut footprint as a region plus its physical boundary. +func appendSolarHorizonClosedShadowFootprint( + features []feature, + footprint eclipsecore.SolarEclipsePartialFootprint, + properties map[string]interface{}, +) ([]feature, error) { + curve, err := solarShadowFootprintCurve(footprint) + if err != nil { + return nil, fmt.Errorf("geojson: solar %s at %s: %w", + solarCentralShadowFootprintRole, formatTime(footprint.Time), err) + } + if len(curve) < 2 { + return features, nil + } + ring, boundary := solarShadowFootprintHorizonRing( + footprint.Time, footprint.Boundaries, footprint.HorizonEnds, curve, + ) + if solarShadowRegionDegenerate(curve, ring) { + return features, nil + } + value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring}) + if err != nil { + return nil, fmt.Errorf("geojson: solar %s at %s: %w", + solarCentralShadowFootprintRole, formatTime(footprint.Time), err) + } + regionProperties := cloneProperties(properties) + regionProperties["time"] = formatTime(footprint.Time) + regionProperties["source_boundary_closed"] = false + regionProperties["geometry_role"] = "horizon-closed-region" + regionProperties["closure"] = solarHorizonClosureProperties( + footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds), + ) + regionProperties["interp_signature"] = solarShadowFootprintSignature( + footprint.Boundaries, false, eclipsecore.SolarEclipseShadowUmbra, + ) + features = append(features, newFeature( + solarEclipseEvent, solarCentralShadowFootprintRole, value, regionProperties, + )) + + boundaryValue, err := geoMultiLineGeometry(boundary, false) + if err != nil { + return nil, fmt.Errorf("geojson: solar %s at %s: %w", + solarCentralShadowBoundaryRole, formatTime(footprint.Time), err) + } + boundaryProperties := cloneProperties(properties) + boundaryProperties["time"] = formatTime(footprint.Time) + boundaryProperties["source_boundary_closed"] = false + boundaryProperties["geometry_role"] = "open-boundary" + boundaryProperties["interp_signature"] = solarShadowFootprintSignature( + footprint.Boundaries, false, eclipsecore.SolarEclipseShadowUmbra, + ) + return append(features, newFeature( + solarEclipseEvent, solarCentralShadowBoundaryRole, boundaryValue, boundaryProperties, + )), nil +} + +func solarShadowFootprintCurve( + footprint eclipsecore.SolarEclipsePartialFootprint, +) ([]geodata.GeoPoint, error) { + return solarShadowFootprintCurveFromSegments(footprint.Boundaries) +} + +func solarShadowFootprintCurveFromSegments( + boundaries [][]eclipsecore.SolarEclipsePathPoint, +) ([]geodata.GeoPoint, error) { + segments := make([][]geodata.GeoPoint, 0, len(boundaries)) + for _, source := range boundaries { + segment := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { + return nil, err + } + segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + segments = append(segments, segment) + } + return geodata.JoinPolylineSegments(segments), nil +} + +// solarShadowRegionDegenerate 判定影区是否退化:物理边界过短或闭合面积近零,导出侧会拒绝。 +func solarShadowRegionDegenerate(curve, ring []geodata.GeoPoint) bool { + if len(curve) < 2 || solarShadowFootprintCurveLengthKM(curve) < solarCentralShadowMinimumRegionLengthKM { + return true + } + points := openRing(ring) + if len(points) < 3 { + return true + } + return math.Abs(solarShadowRegionAreaDegrees2(points)) < solarCentralShadowMinimumRegionAreaDegrees2 +} + +// solarShadowRegionAreaDegrees2 用展开经度算鞋带面积,跨换日线的环不会得到假的近零面积。 +func solarShadowRegionAreaDegrees2(ring []geodata.GeoPoint) float64 { + if len(ring) < 3 { + return 0 + } + unwrapped := make([]geodata.GeoPoint, len(ring)) + unwrapped[0] = ring[0] + for index := 1; index < len(ring); index++ { + longitude := ring[index].Longitude + for longitude-unwrapped[index-1].Longitude > 180 { + longitude -= 360 + } + for longitude-unwrapped[index-1].Longitude < -180 { + longitude += 360 + } + unwrapped[index] = geodata.GeoPoint{Longitude: longitude, Latitude: ring[index].Latitude} + } + area := 0.0 + for index, point := range unwrapped { + next := unwrapped[(index+1)%len(unwrapped)] + area += point.Longitude*next.Latitude - next.Longitude*point.Latitude + } + return area / 2 +} + +func solarShadowFootprintCurveLengthKM(curve []geodata.GeoPoint) float64 { + length := 0.0 + for index := 1; index < len(curve); index++ { + length += solarCentralBandGeoPointDistanceKM(curve[index-1], curve[index]) + } + return length +} + +func solarShadowFootprintHorizonRing( + value time.Time, + boundaries [][]eclipsecore.SolarEclipsePathPoint, + horizonEnds []eclipsecore.SolarEclipsePathPoint, + curve []geodata.GeoPoint, +) ([]geodata.GeoPoint, []geodata.GeoPoint) { + return solarclosure.HorizonRing(solarclosure.Footprint{ + Boundaries: solarClosureSegments(boundaries), + HorizonEnds: solarClosureEnds(horizonEnds), + Subsolar: solarSubsolarPoint(value), + }, curve) +} + +func solarShadowFootprintHorizonEnds( + boundaries [][]eclipsecore.SolarEclipsePathPoint, + horizonEnds []eclipsecore.SolarEclipsePathPoint, +) []geodata.GeoPoint { + return solarclosure.HorizonEnds(solarclosure.Footprint{ + Boundaries: solarClosureSegments(boundaries), + HorizonEnds: solarClosureEnds(horizonEnds), + }) +} + +// solarClosureSegments 按原顺序转换边界分段,不校验坐标。 +func solarClosureSegments(boundaries [][]eclipsecore.SolarEclipsePathPoint) [][]geodata.GeoPoint { + segments := make([][]geodata.GeoPoint, len(boundaries)) + for index, source := range boundaries { + segments[index] = solarCentralBandGeoPoints(source) + } + return segments +} + +// solarClosureEnds 转换地平擦地点;点数不是 2 或坐标非法时返回 nil,闭合退回近似弧。 +func solarClosureEnds(horizonEnds []eclipsecore.SolarEclipsePathPoint) []geodata.GeoPoint { + if len(horizonEnds) != 2 { + return nil + } + points := make([]geodata.GeoPoint, 2) + for index, end := range horizonEnds { + if err := validateCoordinate(end.Longitude, end.Latitude); err != nil { + return nil + } + points[index] = geodata.GeoPoint{Longitude: end.Longitude, Latitude: end.Latitude} + } + return points +} + +// solarClosureFootprint 转换瞬时足迹;边界坐标非法时报错。 +func solarClosureFootprint( + footprint eclipsecore.SolarEclipsePartialFootprint, +) (solarclosure.Footprint, error) { + segments := make([][]geodata.GeoPoint, len(footprint.Boundaries)) + for index, source := range footprint.Boundaries { + segment := make([]geodata.GeoPoint, len(source)) + for pointIndex, point := range source { + if err := validateCoordinate(point.Longitude, point.Latitude); err != nil { + return solarclosure.Footprint{}, err + } + segment[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + segments[index] = segment + } + return solarclosure.Footprint{ + Boundaries: segments, + HorizonEnds: solarClosureEnds(footprint.HorizonEnds), + Subsolar: solarSubsolarPoint(footprint.Time), + Closed: footprint.Closed, + }, nil +} + +// solarHorizonClosureExact 报告能否用两个精确擦地点闭合区域。 +func solarHorizonClosureExact( + boundaries [][]eclipsecore.SolarEclipsePathPoint, + horizonEnds []eclipsecore.SolarEclipsePathPoint, +) bool { + return len(solarShadowFootprintHorizonEnds(boundaries, horizonEnds)) == 2 +} + +// solarHorizonClosureProperties 声明式闭合弧;exact 为假表示缺精确擦地点、按采样端点近似闭合。 +func solarHorizonClosureProperties(value time.Time, exact bool) map[string]interface{} { + subsolar := solarSubsolarPoint(value) + return map[string]interface{}{ + "kind": "horizon", + "exact": exact, + "time": formatTime(value), + "subsolar": []float64{subsolar.Longitude, subsolar.Latitude}, + } +} + +// solarShadowSegmentClosed 与 basic 层同口径:分段首末点相距不到 1 mm 才算真正闭合。 +func solarShadowSegmentClosed(segment []eclipsecore.SolarEclipsePathPoint) bool { + if len(segment) <= 2 { + return false + } + return solarCentralBandPathDistanceKM(segment[0], segment[len(segment)-1]) < 1e-6 +} + +// solarShadowFootprintSignature 由物理边界算出与 basic 层同口径的插值签名 / interpolation signature from the physical boundary. +func solarShadowFootprintSignature( + boundaries [][]eclipsecore.SolarEclipsePathPoint, + closed bool, + kind eclipsecore.SolarEclipseShadowKind, +) string { + topology := basic.SolarEclipseShadowTopology{ + Kind: basic.SolarEclipseShadowKind(kind), + Segments: len(boundaries), + Closed: closed, + } + winding := 0.0 + for _, segment := range boundaries { + topology.Vertices += len(segment) + for index := 1; index < len(segment); index++ { + winding += math.Remainder(segment[index].Longitude-segment[index-1].Longitude, 360) + } + if solarShadowSegmentClosed(segment) { + winding += math.Remainder(segment[0].Longitude-segment[len(segment)-1].Longitude, 360) + } + } + if topology.Segments == 1 && math.Abs(winding) >= 180 { + topology.EnclosesPole = true + } + return topology.Signature() +} diff --git a/geojson/solar_shadow_region_closure_test.go b/geojson/solar_shadow_region_closure_test.go new file mode 100644 index 0000000..8b00fd7 --- /dev/null +++ b/geojson/solar_shadow_region_closure_test.go @@ -0,0 +1,207 @@ +package geojson + +import ( + "encoding/json" + "math" + "testing" + "time" + + eclipsecore "b612.me/astro/eclipse" + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/solarclosure" +) + +// 一致性契约:GeoJSON 的偏食可见域与 SVG 渲染域同源。并集按 SVG 的精确补口口径复算, +// 逐足迹区域的收口点必须是核心层给出的精确擦地点,而不是采样端点外推的近似弧。 +func TestSolarEclipseRegionsUseSharedExactClosure(t *testing.T) { + for _, date := range closureGeoJSONDates() { + partial, ok := eclipsecore.SolarEclipsePartialFootprints(date, eclipsecore.SolarEclipsePartialFootprintOptions{ + Step: 2 * time.Minute, BoundaryPoints: 96, + }) + if !ok { + t.Fatalf("missing partial footprints for %s", date.Format("2006-01-02")) + } + data, err := MarshalSolarEclipse(partial, nil) + if err != nil { + t.Fatalf("%s: %v", date.Format("2006-01-02"), err) + } + band, footprints := closureDecodeSolarRegions(t, data) + if len(band) == 0 { + t.Fatalf("%s: no partial-band region", date.Format("2006-01-02")) + } + + svgBand, ok := closureSVGBandPolygons(partial) + if !ok { + t.Fatalf("%s: shared union is unavailable", date.Format("2006-01-02")) + } + bandDeviation := closureRegionDeviationKM(svgBand, band) + if bandDeviation > closureGeoJSONBandToleranceKM { + t.Fatalf("%s: band regions differ by %.6f km, tolerance %.6f km", + date.Format("2006-01-02"), bandDeviation, closureGeoJSONBandToleranceKM) + } + + compared, skipped := 0, 0 + worst := 0.0 + for _, footprint := range partial.Footprints { + region := footprints[footprint.Time.UTC().Format(time.RFC3339Nano)] + if len(region) == 0 { + continue + } + if closureRegionReachesPole(region) { + skipped++ + continue + } + if !footprint.Closed { + for _, end := range footprint.HorizonEnds { + if !closureRegionCarriesPoint(region, geodata.GeoPoint{ + Longitude: end.Longitude, Latitude: end.Latitude, + }) { + t.Fatalf("%s: the %s region is not closed at the grazing point %v", + date.Format("2006-01-02"), footprint.Time.Format(time.RFC3339), end) + } + } + for _, end := range footprint.HorizonEnds { + worst = math.Max(worst, geodata.SphericalPolygonsPathMissDistanceKM( + region, + [][]geodata.GeoPoint{{{Longitude: end.Longitude, Latitude: end.Latitude}}}, + false, + )) + } + } + compared++ + } + if compared == 0 { + t.Fatalf("%s: no footprint region was checked", date.Format("2006-01-02")) + } + if worst > closureGeoJSONGrazingToleranceKM { + t.Fatalf("%s: grazing points miss the region by %.6f km, tolerance %.6f km", + date.Format("2006-01-02"), worst, closureGeoJSONGrazingToleranceKM) + } + t.Logf("%s band=%.6f km footprints=%d skipped=%d grazing=%.6f km", + date.Format("2006-01-02"), bandDeviation, compared, skipped, worst) + } +} + +func closureGeoJSONDates() []time.Time { + return []time.Time{ + time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), + time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), + time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), + time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC), + } +} + +// closureSVGBandPolygons 用 SVG 侧的口径复算并集:瞬时足迹按精确擦地点补口。 +func closureSVGBandPolygons( + partial eclipsecore.SolarEclipsePartialFootprintsInfo, +) ([][]geodata.GeoPoint, bool) { + if len(partial.PartialBandContours) == 0 || len(partial.RiseSetCurves) == 0 { + return nil, false + } + contours := make([][]geodata.GeoPoint, 0, len(partial.PartialBandContours)) + for _, contour := range partial.PartialBandContours { + contours = append(contours, solarCentralBandGeoPoints(contour)) + } + phaseLines := make([][]geodata.GeoPoint, 0, len(partial.RiseSetCurves)*2) + for _, curve := range partial.RiseSetCurves { + for _, segment := range curve.Segments { + phaseLines = append(phaseLines, solarCentralBandGeoPoints(segment)) + } + } + footprints := make([]solarclosure.Footprint, 0, len(partial.Footprints)) + for _, footprint := range partial.Footprints { + input, err := solarClosureFootprint(footprint) + if err != nil { + return nil, false + } + footprints = append(footprints, input) + } + return solarclosure.BandPolygons( + contours, phaseLines, footprints, true, solarclosure.SnapDistanceKM, + ) +} + +func closureDecodeSolarRegions( + t *testing.T, + data []byte, +) (band [][]geodata.GeoPoint, footprints map[string][][]geodata.GeoPoint) { + t.Helper() + var collection struct { + Features []struct { + Properties map[string]interface{} `json:"properties"` + Geometry struct { + Type string `json:"type"` + Coordinates json.RawMessage `json:"coordinates"` + } `json:"geometry"` + } `json:"features"` + } + if err := json.Unmarshal(data, &collection); err != nil { + t.Fatalf("decode GeoJSON: %v", err) + } + footprints = make(map[string][][]geodata.GeoPoint) + for _, feature := range collection.Features { + role, _ := feature.Properties["role"].(string) + if feature.Geometry.Type != "MultiPolygon" { + continue + } + var polygons [][][][]float64 + if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil { + t.Fatalf("decode %s coordinates: %v", role, err) + } + rings := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + for _, ring := range polygon { + points := make([]geodata.GeoPoint, len(ring)) + for index, point := range ring { + points[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]} + } + rings = append(rings, points) + } + } + switch role { + case "partial-band": + band = append(band, rings...) + case "partial-footprint": + value, _ := feature.Properties["time"].(string) + footprints[value] = append(footprints[value], rings...) + } + } + return band, footprints +} + +func closureRegionReachesPole(region [][]geodata.GeoPoint) bool { + for _, ring := range region { + for _, point := range ring { + if math.Abs(point.Latitude) >= 89.9 { + return true + } + } + } + return false +} + +func closureRegionCarriesPoint(region [][]geodata.GeoPoint, target geodata.GeoPoint) bool { + for _, ring := range region { + for _, point := range ring { + if math.Abs(point.Latitude-target.Latitude) < 1e-6 && + math.Abs(math.Remainder(point.Longitude-target.Longitude, 360)) < 1e-6 { + return true + } + } + } + return false +} + +func closureRegionDeviationKM(first, second [][]geodata.GeoPoint) float64 { + return math.Max( + geodata.SphericalPolygonsPathMissDistanceKM(second, first, true), + geodata.SphericalPolygonsPathMissDistanceKM(first, second, true), + ) +} + +const ( + // 两侧并集只差面选择口径,实测在米级。 + closureGeoJSONBandToleranceKM = 1.0 + // 擦地点是区域的收口顶点,序列化往返只允许亚米级偏差。 + closureGeoJSONGrazingToleranceKM = 0.01 +) diff --git a/geojson/spherical_sampling.go b/geojson/spherical_sampling.go new file mode 100644 index 0000000..0d9402a --- /dev/null +++ b/geojson/spherical_sampling.go @@ -0,0 +1,177 @@ +package geojson + +import ( + "math" + "sort" + + "b612.me/astro/internal/geodata" +) + +// GeoJSON joins vertices with straight longitude/latitude segments. Bound +// their deviation from the spherical edges before antimeridian clipping, +// especially for a short arc that passes close to either pole. +func sampleSphericalMapRing(points []geodata.GeoPoint) []geodata.GeoPoint { + return sampleSphericalMapRingWithin(points, sphericalMapChordLimitKM, sphericalMapChordErrorDegrees) +} + +// Exported map chords are bounded so that a straight lon/lat segment still +// follows the spherical edge. Filled footprint polygons tolerate a much coarser +// bound than the strokes that carry the path limits: they are drawn as +// translucent fills, and their rings dominate the payload (a solar eclipse +// exports a hundred penumbral footprints). +const ( + sphericalMapChordLimitKM = 100.0 + sphericalMapChordErrorDegrees = 0.002 + sphericalFillChordLimitKM = 400.0 + sphericalFillChordErrorDegrees = 0.02 +) + +// sampleSphericalMapRingWithin is sampleSphericalMapRing with explicit chord +// limits, used for fill-only geometry. +func sampleSphericalMapRingWithin( + points []geodata.GeoPoint, + chordLimitKM, chordErrorDegrees float64, +) []geodata.GeoPoint { + if len(points) < 3 { + return points + } + if len(points) == 3 { + return sampleSphericalMapTriangle(points) + } + result := make([]geodata.GeoPoint, 0, len(points)) + for index, point := range points { + result = appendSphericalMapArcWithin( + result, point, points[(index+1)%len(points)], 0, chordLimitKM, chordErrorDegrees, + ) + } + return result +} + +func sphericalMapChordError(first, middle, last geodata.GeoPoint) float64 { + longitude := first.Longitude + math.Remainder(last.Longitude-first.Longitude, 360)/2 + return math.Hypot(math.Remainder(middle.Longitude-longitude, 360), middle.Latitude-(first.Latitude+last.Latitude)/2) +} + +func appendSphericalMapArc(points []geodata.GeoPoint, first, last geodata.GeoPoint, depth int) []geodata.GeoPoint { + return appendSphericalMapArcWithin( + points, first, last, depth, sphericalMapChordLimitKM, sphericalMapChordErrorDegrees, + ) +} + +func appendSphericalMapArcWithin( + points []geodata.GeoPoint, + first, last geodata.GeoPoint, + depth int, + chordLimitKM, chordErrorDegrees float64, +) []geodata.GeoPoint { + middle := geodata.InterpolateGreatCircle(first, last, 0.5) + // Keep exported map chords bounded even when a great-circle arc is nearly + // linear in lon/lat (notably the long horizon closure edges of shallow + // polar eclipses). The adaptive angular-error test alone cannot see that + // case and leaves visually abrupt 250+ km segments. + arcDistanceKM := solarCentralBandGeoPointDistanceKM(first, last) + if (sphericalMapChordError(first, middle, last) <= chordErrorDegrees && arcDistanceKM <= chordLimitKM) || depth >= 20 { + return append(points, first) + } + points = appendSphericalMapArcWithin(points, first, middle, depth+1, chordLimitKM, chordErrorDegrees) + return appendSphericalMapArcWithin(points, middle, last, depth+1, chordLimitKM, chordErrorDegrees) +} + +func sampleSphericalMapPath(source []pathSample) []pathSample { + if len(source) < 2 { + return source + } + result := make([]pathSample, 0, len(source)) + var refine func(pathSample, pathSample, int) + refine = func(first, last pathSample, depth int) { + a := geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude} + b := geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude} + middle := geodata.InterpolateGreatCircle(a, b, 0.5) + at := first.Time.Add(last.Time.Sub(first.Time) / 2) + if sphericalMapChordError(a, middle, b) <= 0.002 || depth >= 20 || at.Equal(first.Time) || at.Equal(last.Time) { + result = append(result, first) + return + } + point := pathSample{Time: at, Longitude: middle.Longitude, Latitude: middle.Latitude} + refine(first, point, depth+1) + refine(point, last, depth+1) + } + for i := 1; i < len(source); i++ { + refine(source[i-1], source[i], 0) + } + return append(result, source[len(source)-1]) +} + +// Match the subdivisions on both long sides of a thin spherical triangle. +// Independent chord approximations can cross even with a small absolute error. +func sampleSphericalMapTriangle(points []geodata.GeoPoint) []geodata.GeoPoint { + apex, shortest := 0, math.Inf(1) + for i := range points { + if length := solarCentralBandGeoPointDistanceKM(points[(i+1)%3], points[(i+2)%3]); length < shortest { + apex, shortest = i, length + } + } + a, b, c := points[apex], points[(apex+1)%3], points[(apex+2)%3] + var left, right []geodata.GeoPoint + var refine func(geodata.GeoPoint, geodata.GeoPoint, geodata.GeoPoint, geodata.GeoPoint, int) + refine = func(a, b, c, d geodata.GeoPoint, depth int) { + m, n := geodata.InterpolateGreatCircle(a, b, 0.5), geodata.InterpolateGreatCircle(c, d, 0.5) + if depth >= 20 || math.Max(sphericalMapChordError(a, m, b), sphericalMapChordError(c, n, d)) <= 0.002 { + left, right = append(left, a), append(right, c) + return + } + refine(a, m, c, n, depth+1) + refine(m, b, n, d, depth+1) + } + refine(a, b, a, c, 0) + left, right = append(left, b), append(right, c) + left, right = alignSphericalMapTriangleSides(left, right) + left = left[:len(left)-1] + left = appendSphericalMapArc(left, b, c, 0) + left = append(left, c) + for i := len(right) - 2; i > 0; i-- { + left = append(left, right[i]) + } + return left +} + +func alignSphericalMapTriangleSides(left, right []geodata.GeoPoint) ([]geodata.GeoPoint, []geodata.GeoPoint) { + var longitudes []float64 + for _, side := range [][]geodata.GeoPoint{left, right} { + for i := range side { + if i > 0 { + side[i].Longitude = side[i-1].Longitude + math.Remainder(side[i].Longitude-side[i-1].Longitude, 360) + } + longitudes = append(longitudes, side[i].Longitude) + } + } + sort.Float64s(longitudes) + align := func(side []geodata.GeoPoint) []geodata.GeoPoint { + result := []geodata.GeoPoint{side[0]} + for i := 1; i < len(side); i++ { + a, b := side[i-1], side[i] + lo, hi := math.Min(a.Longitude, b.Longitude), math.Max(a.Longitude, b.Longitude) + first, last := sort.SearchFloat64s(longitudes, lo), sort.SearchFloat64s(longitudes, hi) + for j := first; j < last; j++ { + index := j + if a.Longitude > b.Longitude { + index = first + last - 1 - j + } + lon := longitudes[index] + if lon <= lo+1e-12 || lon >= hi-1e-12 || index > 0 && lon == longitudes[index-1] { + continue + } + // The great-circle plane intersects each intermediate meridian once. + lonA, latA, lonB, latB := a.Longitude*math.Pi/180, a.Latitude*math.Pi/180, b.Longitude*math.Pi/180, b.Latitude*math.Pi/180 + x, y, z := math.Cos(latA)*math.Cos(lonA), math.Cos(latA)*math.Sin(lonA), math.Sin(latA) + u, v, w := math.Cos(latB)*math.Cos(lonB), math.Cos(latB)*math.Sin(lonB), math.Sin(latB) + nx, ny, nz := y*w-z*v, z*u-x*w, x*v-y*u + lat := math.Atan(-(nx*math.Cos(lon*math.Pi/180)+ny*math.Sin(lon*math.Pi/180))/nz) * 180 / math.Pi + result = append(result, geodata.GeoPoint{Longitude: lon, Latitude: lat}) + } + result = append(result, b) + } + return result + } + return align(left), align(right) +} diff --git a/internal/geodata/benchmark_test.go b/internal/geodata/benchmark_test.go new file mode 100644 index 0000000..87cbb16 --- /dev/null +++ b/internal/geodata/benchmark_test.go @@ -0,0 +1,125 @@ +package geodata + +import ( + "math" + "testing" +) + +func benchmarkRingGrid(count, points int, centerLatitude, centerLongitude, latitudeStep float64) [][]GeoPoint { + rings := make([][]GeoPoint, count) + for index := range rings { + rings[index] = SphericalCircle(GeoPoint{ + Longitude: centerLongitude + 1.2*float64(index%10), + Latitude: centerLatitude + latitudeStep*float64(index/10), + }, 2, points) + } + return rings +} + +func BenchmarkUnionPolygons100x64Lat0(b *testing.B) { + rings := benchmarkRingGrid(100, 64, 0, 0, 1.2) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if _, err := UnionPolygons(rings); err != nil { + b.Fatalf("UnionPolygons: %v", err) + } + } +} + +func BenchmarkUnionPolygons100x64Lat80(b *testing.B) { + rings := benchmarkRingGrid(100, 64, 80, 0, 1.2) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if _, err := UnionPolygons(rings); err != nil { + b.Fatalf("UnionPolygons: %v", err) + } + } +} + +func BenchmarkUnionPolygons100x64Lat80Cap(b *testing.B) { + rings := benchmarkRingGrid(99, 64, 78, 0, 1) + rings = append(rings, SphericalCircle(GeoPoint{Longitude: 0, Latitude: 87}, 5, 64)) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if _, err := UnionPolygons(rings); err != nil { + b.Fatalf("UnionPolygons: %v", err) + } + } +} + +func BenchmarkSweepBridgeTouchingPolygons120x128(b *testing.B) { + rings := make([][]GeoPoint, 0, 120) + for row := 0; row < 10; row++ { + for col := 0; col < 12; col++ { + rings = append(rings, SphericalCircle(GeoPoint{ + Longitude: 15 * float64(col), + Latitude: 10 + 5*float64(row), + }, 0.5, 128)) + } + } + // 最近的一对相距约 32 米:大于 0.01 公里的桥接门限,且排在扫描顺序最后。 + rings[119] = SphericalCircle(GeoPoint{Longitude: 150.0005, Latitude: 55}, 0.5, 128) + for iteration := 0; iteration < b.N; iteration++ { + input := make([][]GeoPoint, len(rings)) + for index, ring := range rings { + input[index] = append([]GeoPoint(nil), ring...) + } + result, err := sweepBridgeTouchingPolygons(input) + if err != nil { + b.Fatalf("sweepBridgeTouchingPolygons: %v", err) + } + if len(result) != len(input) { + b.Fatalf("ring count=%d, want %d", len(result), len(input)) + } + } +} + +func benchmarkCycleGridLines(size int) [][]GeoPoint { + lines := make([][]GeoPoint, 0, 2*size*(size-1)) + for row := 0; row < size; row++ { + for col := 0; col < size; col++ { + x, y := float64(col), float64(row) + if col < size-1 { + lines = append(lines, []GeoPoint{{x, y}, {x + 1, y}}) + } + if row < size-1 { + lines = append(lines, []GeoPoint{{x, y}, {x, y + 1}}) + } + } + } + return lines +} + +func benchmarkWheelLines(spokes int) [][]GeoPoint { + lines := make([][]GeoPoint, 0, 2*spokes) + ring := make([]GeoPoint, spokes) + for index := range ring { + angle := 2 * math.Pi * float64(index) / float64(spokes) + ring[index] = GeoPoint{Longitude: 10 * math.Cos(angle), Latitude: 10 * math.Sin(angle)} + } + for index := range ring { + lines = append(lines, []GeoPoint{{Longitude: 0, Latitude: 0}, ring[index]}) + lines = append(lines, []GeoPoint{ring[index], ring[(index+1)%spokes]}) + } + return lines +} + +func BenchmarkEnumerateVisibleLineworkCyclesWheel(b *testing.B) { + lines := benchmarkWheelLines(720) + chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}} + nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + enumerateVisibleLineworkCycles(nodes, edges) + } +} + +func BenchmarkEnumerateVisibleLineworkCycles30(b *testing.B) { + lines := benchmarkCycleGridLines(30) + chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}} + nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + enumerateVisibleLineworkCycles(nodes, edges) + } +} diff --git a/internal/geodata/linework.go b/internal/geodata/linework.go new file mode 100644 index 0000000..f02b162 --- /dev/null +++ b/internal/geodata/linework.go @@ -0,0 +1,1955 @@ +package geodata + +import ( + "fmt" + "math" + "sort" +) + +type visibleLineworkNode struct { + point GeoPoint + outgoing []visibleLineworkHalfEdge +} + +type visibleLineworkEdge struct { + points []GeoPoint + start, end int +} + +type visibleLineworkHalfEdge struct { + edge int + reverse bool +} + +type visibleLineworkBounds struct { + minLongitude float64 + maxLongitude float64 + minLatitude float64 + maxLatitude float64 +} + +// visibleLineworkArcIndex caches the endpoint vectors and great-circle +// normals used by the source-boundary audit. The audit may visit thousands of +// polygon vertices, but its source segments are fixed for the whole pass. +// Caching only these derived values preserves the exact spherical-distance +// calculation while avoiding repeated trigonometric conversions in WASM. +type visibleLineworkArcIndex struct { + arcs []visibleLineworkArc + vertices map[visibleLineworkPointKey]GeoPoint + edges map[visibleLineworkEdgeKey][]int +} + +type visibleLineworkArc struct { + start, end GeoPoint + startVector geoVector3 + endVector geoVector3 + arcRadians float64 + normal geoVector3 + normalValid bool +} + +type visibleLineworkPointKey struct { + longitude int64 + latitude int64 +} + +type visibleLineworkEdgeKey struct { + first visibleLineworkPointKey + second visibleLineworkPointKey +} + +const visibleLineworkIndexCoordinateScale = 1e7 + +// Coarse historical solar footprints can leave a bounded spherical residual; +// larger residuals indicate a planar-union shortcut rather than sampling noise. +const visibleLineworkBoundaryMinimumAuditToleranceKM = 100 + +func newVisibleLineworkArcIndex(lines [][]GeoPoint) visibleLineworkArcIndex { + count := 0 + for _, line := range lines { + if len(line) > 1 { + count += len(line) - 1 + } + } + index := visibleLineworkArcIndex{ + arcs: make([]visibleLineworkArc, 0, count), + vertices: make(map[visibleLineworkPointKey]GeoPoint, count+1), + edges: make(map[visibleLineworkEdgeKey][]int, count), + } + for _, line := range lines { + for segment := 1; segment < len(line); segment++ { + start, end := line[segment-1], line[segment] + startVector := geoPointVector(start) + endVector := geoPointVector(end) + arcRadians := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(startVector, endVector)))) + normal, normalValid := geoVectorNormalize(geoVectorCross(startVector, endVector)) + arcIndex := len(index.arcs) + index.arcs = append(index.arcs, visibleLineworkArc{ + start: start, end: end, + startVector: startVector, endVector: endVector, + arcRadians: arcRadians, + normal: normal, normalValid: normalValid, + }) + startKey := visibleLineworkIndexPointKey(start) + endKey := visibleLineworkIndexPointKey(end) + index.vertices[startKey] = start + index.vertices[endKey] = end + edgeKey := visibleLineworkIndexEdgeKey(startKey, endKey) + index.edges[edgeKey] = append(index.edges[edgeKey], arcIndex) + } + } + return index +} + +func (index visibleLineworkArcIndex) pointDistanceKM(point GeoPoint) float64 { + if source, ok := index.vertices[visibleLineworkIndexPointKey(point)]; ok { + return geoPointDistanceKM(point, source) + } + pointVector := geoPointVector(point) + minimum := math.Inf(1) + for _, arc := range index.arcs { + minimum = math.Min(minimum, arc.pointDistanceKM(point, pointVector)) + if minimum == 0 { + return 0 + } + } + return minimum +} + +func (index visibleLineworkArcIndex) edgePointDistanceKM( + first, second, point GeoPoint, +) (float64, bool) { + firstKey := visibleLineworkIndexPointKey(first) + secondKey := visibleLineworkIndexPointKey(second) + arcIndices := index.edges[visibleLineworkIndexEdgeKey(firstKey, secondKey)] + if len(arcIndices) == 0 { + return 0, false + } + pointVector := geoPointVector(point) + minimum := math.Inf(1) + matched := false + for _, arcIndex := range arcIndices { + arc := index.arcs[arcIndex] + direct := geoPointDistanceKM(first, arc.start) <= 0.001 && + geoPointDistanceKM(second, arc.end) <= 0.001 + reverse := geoPointDistanceKM(first, arc.end) <= 0.001 && + geoPointDistanceKM(second, arc.start) <= 0.001 + if !direct && !reverse { + continue + } + matched = true + minimum = math.Min(minimum, arc.pointDistanceKM(point, pointVector)) + } + return minimum, matched +} + +func (arc visibleLineworkArc) pointDistanceKM(point GeoPoint, pointVector geoVector3) float64 { + if arc.normalValid { + projection := geoVectorAdd(pointVector, + geoVectorScale(arc.normal, -geoVectorDot(pointVector, arc.normal))) + if projected, projectedOK := geoVectorNormalize(projection); projectedOK { + for _, candidate := range []geoVector3{projected, geoVectorScale(projected, -1)} { + if sphericalPointOnArcVectors(candidate, arc.startVector, arc.endVector, arc.arcRadians) { + angle := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(pointVector, candidate)))) + return angle * 6378.1366 + } + } + } + } + return math.Min(geoPointDistanceKM(point, arc.start), geoPointDistanceKM(point, arc.end)) +} + +func visibleLineworkIndexPointKey(point GeoPoint) visibleLineworkPointKey { + return visibleLineworkPointKey{ + longitude: int64(math.Round(point.Longitude * visibleLineworkIndexCoordinateScale)), + latitude: int64(math.Round(point.Latitude * visibleLineworkIndexCoordinateScale)), + } +} + +func visibleLineworkIndexEdgeKey( + first, second visibleLineworkPointKey, +) visibleLineworkEdgeKey { + if first.longitude > second.longitude || + (first.longitude == second.longitude && first.latitude > second.latitude) { + first, second = second, first + } + return visibleLineworkEdgeKey{first: first, second: second} +} + +func sphericalPointOnArcVectors(point, first, second geoVector3, arc float64) bool { + if arc <= 1e-14 { + return math.Acos(math.Max(-1, math.Min(1, geoVectorDot(first, point)))) <= 1e-9 + } + firstDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(first, point)))) + secondDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(point, second)))) + return math.Abs(firstDistance+secondDistance-arc) <= 1e-9 +} + +// VisibleLineworkPolygons 将球面边界线网多边形化,并仅保留由给定填充多边形覆盖的面。 +// VisibleLineworkPolygons polygonizes a spherical boundary network and keeps +// only faces covered by at least one supplied fill polygon. Boundary line +// endpoints within snapDistanceKM are treated as the same physical junction. +func VisibleLineworkPolygons( + boundaryLines [][]GeoPoint, + fillPolygons [][]GeoPoint, + coveragePaths [][]GeoPoint, + snapDistanceKM float64, +) ([][]GeoPoint, error) { + return visibleLineworkPolygons( + boundaryLines, fillPolygons, coveragePaths, snapDistanceKM, 1.5*snapDistanceKM, + ) +} + +// VisibleLineworkPolygonsWithAuditTolerance 将边界图节点吸附尺度与源填充审计容差分开。 +// VisibleLineworkPolygonsWithAuditTolerance keeps graph junction snapping and +// source-fill auditing on independent scales. This is needed when a boundary +// is sampled at a fine spatial cadence but the interior witness is a coarser +// sweep: increasing the snap distance would merge nearby physical branches, +// while a bounded audit tolerance does not alter the boundary graph. +func VisibleLineworkPolygonsWithAuditTolerance( + boundaryLines [][]GeoPoint, + fillPolygons [][]GeoPoint, + coveragePaths [][]GeoPoint, + snapDistanceKM, sourceFillToleranceKM float64, +) ([][]GeoPoint, error) { + return visibleLineworkPolygons( + boundaryLines, fillPolygons, coveragePaths, snapDistanceKM, sourceFillToleranceKM, + ) +} + +func visibleLineworkPolygons( + boundaryLines [][]GeoPoint, + fillPolygons [][]GeoPoint, + coveragePaths [][]GeoPoint, + snapDistanceKM, sourceFillToleranceKM float64, +) ([][]GeoPoint, error) { + if len(boundaryLines) == 0 { + return nil, fmt.Errorf("visible linework has no boundary lines") + } + if !(snapDistanceKM >= 0) || !(sourceFillToleranceKM >= 0) || + math.IsInf(snapDistanceKM, 0) || math.IsInf(sourceFillToleranceKM, 0) { + return nil, fmt.Errorf("visible linework tolerances must be finite and non-negative") + } + chartInputs := make([][]GeoPoint, 0, len(boundaryLines)+len(fillPolygons)) + chartInputs = append(chartInputs, boundaryLines...) + chartInputs = append(chartInputs, fillPolygons...) + chart, ok := newPolygonUnionChart(chartInputs) + if !ok { + return nil, fmt.Errorf("visible linework has no stable spherical chart") + } + + projectedLines := make([][]GeoPoint, 0, len(boundaryLines)) + for _, source := range boundaryLines { + line := projectVisibleLine(chart, source) + if len(line) >= 2 { + projectedLines = append(projectedLines, line) + } + } + projectedFill := make([][]GeoPoint, 0, len(fillPolygons)) + for _, source := range fillPolygons { + polygon := projectVisibleLine(chart, openGeoRing(source)) + if len(polygon) >= 3 { + projectedFill = append(projectedFill, polygon) + } + } + if len(projectedLines) == 0 || len(projectedFill) == 0 { + return nil, fmt.Errorf("visible linework has no usable boundary or fill geometry") + } + projectedLines = splitVisibleLineworkIntersections(projectedLines) + if len(coveragePaths) == 0 { + coveragePaths = fillPolygons + } + projectedCoverage := make([][]GeoPoint, 0, len(coveragePaths)) + for _, source := range coveragePaths { + path := projectVisibleLine(chart, source) + if len(path) > 0 { + projectedCoverage = append(projectedCoverage, path) + } + } + for _, probe := range visibleLineworkFilledEdgeProbes(projectedLines, projectedFill) { + projectedCoverage = append(projectedCoverage, []GeoPoint{probe}) + } + + nodes, edges, loops := buildVisibleLineworkGraph(projectedLines, chart, snapDistanceKM) + cycles := enumerateVisibleLineworkCycles(nodes, edges) + cycles = append(cycles, loops...) + // Endpoint snapping and face membership have different numerical scales. + // A tens-of-kilometres junction snap is useful for joining independently sampled phase + // curves, but using that same radius for point-in-face tests makes adjacent + // polar faces overlap and selects the wrong side of a narrow band. + faceToleranceKM := math.Min(5, math.Max(0.5, snapDistanceKM/10)) + projectedTolerance := faceToleranceKM / 111.195 + cycleProbes := visibleLineworkFilledCycleProbes(cycles, projectedFill, projectedTolerance) + if len(cycleProbes) > 0 { + for _, probe := range cycleProbes { + projectedCoverage = append(projectedCoverage, []GeoPoint{probe}) + } + } + // Audit the selected boundary against the complete source fill, not just + // the sparse coverage probes used to choose faces. Otherwise a face can + // contain the probes while its outline still cuts across an unsampled part + // of the instantaneous footprint. + projectedFillSamples := sampleVisibleLineworkFillPaths(projectedFill, 16) + fillSamples := visibleLineworkPointPaths( + unprojectVisibleLineworkPaths(chart, projectedFillSamples), + ) + selectedCycles := make([][]GeoPoint, 0) + if len(cycleProbes) > 0 { + priorityCoverage := make([][]GeoPoint, len(cycleProbes)) + for index, probe := range cycleProbes { + priorityCoverage[index] = []GeoPoint{probe} + } + prioritySelected := selectVisibleLineworkCycles( + cycles, priorityCoverage, projectedTolerance, + ) + if visibleLineworkCyclesCoverCoverage(prioritySelected, projectedCoverage, projectedTolerance) { + selectedCycles = prioritySelected + } + } + if len(selectedCycles) == 0 { + selectedCycles = selectVisibleLineworkCycles( + cycles, projectedCoverage, projectedTolerance, + ) + } + selected := make([][]GeoPoint, 0, len(selectedCycles)) + for _, cycle := range selectedCycles { + polygon := make([]GeoPoint, len(cycle)) + for index, point := range cycle { + polygon[index] = chart.unproject(point) + } + selected = append(selected, polygon) + } + if len(selected) == 0 { + return nil, fmt.Errorf("visible linework produced no covering boundary from %d cycles (coverage=%d cycle_probes=%d misses=%d)", + len(cycles), len(projectedCoverage), len(cycleProbes), + visibleLineworkCoverageMissCount(cycles, projectedCoverage, projectedTolerance)) + } + // A single simple face is already a polygon. Repeating a boolean union + // can lose tiny edges where tangential branches were split into the graph. + merged := selected + if len(selected) > 1 { + var err error + merged, err = UnionPolygons(selected) + if err != nil { + return nil, fmt.Errorf("visible linework face union: %w", err) + } + } + // The common output already consists of short projected edges. Running the + // exact spherical point-to-arc audit on every edge is disproportionately + // expensive in TinyGo/WASM, while an artificial polar chord is necessarily + // visible as a long projected edge. Keep the precise repair/audit only for + // that suspicious case. + suspiciousBoundary := visibleLineworkHasLongProjectedEdge(merged, 45) || visibleLineworkHasProjectedPolarBacktrack(merged) + if suspiciousBoundary { + merged = repairVisibleLineworkBoundaryChords(merged, boundaryLines, 5, 30) + // Re-run the exact source audit only if a suspicious edge remains after + // repair. Normal authoritative rings have no long edge or polar reversal + // at this point, so a full point-to-arc scan would be redundant. + if visibleLineworkHasLongProjectedEdge(merged, 45) || visibleLineworkHasProjectedPolarBacktrack(merged) { + if sourceMiss := visibleLineworkBoundarySourceMissDistanceKM(merged, boundaryLines); sourceMiss > math.Max(visibleLineworkBoundaryMinimumAuditToleranceKM, 2*snapDistanceKM) { + // A planar union can introduce a shortcut while each selected + // source cycle is still a valid physical boundary. Preserve those + // source cycles as separate polygons rather than falling through + // to an endpoint-only sweep that closes through a pole. + if selectedMiss := visibleLineworkBoundarySourceMissDistanceKM(selected, boundaryLines); selectedMiss <= math.Max(visibleLineworkBoundaryMinimumAuditToleranceKM, 2*snapDistanceKM) { + return selected, nil + } + return nil, fmt.Errorf("visible linework boundary leaves source linework by %.1f km", sourceMiss) + } + } + } + if miss := SphericalPolygonsPathMissDistanceKM(merged, fillSamples, true); miss > sourceFillToleranceKM { + return nil, fmt.Errorf("visible linework boundary misses source fill by %.1f km", miss) + } + return merged, nil +} + +func visibleLineworkHasProjectedPolarBacktrack(polygons [][]GeoPoint) bool { + for _, polygon := range polygons { + for index := 1; index+1 < len(polygon); index++ { + first, middle, last := polygon[index-1], polygon[index], polygon[index+1] + if math.Abs(middle.Latitude) < 70 || + visibleLineworkProjectedEdgeDistanceKM(first, middle) > 40 || + visibleLineworkProjectedEdgeDistanceKM(middle, last) > 40 || + visibleLineworkProjectedEdgeDistanceKM(first, last) > 55 { + continue + } + longitudeReversal := math.Remainder(middle.Longitude-first.Longitude, 360)* + math.Remainder(last.Longitude-middle.Longitude, 360) < 0 + latitudeReversal := (middle.Latitude-first.Latitude)*(last.Latitude-middle.Latitude) < 0 + if longitudeReversal || latitudeReversal { + return true + } + } + } + return false +} + +func visibleLineworkHasLongProjectedEdge(polygons [][]GeoPoint, maximumKM float64) bool { + if maximumKM <= 0 { + return true + } + for _, polygon := range polygons { + for index := 1; index < len(polygon); index++ { + if visibleLineworkProjectedEdgeDistanceKM(polygon[index-1], polygon[index]) > maximumKM { + return true + } + } + } + return false +} + +func visibleLineworkProjectedEdgeDistanceKM(first, second GeoPoint) float64 { + latitude := (first.Latitude + second.Latitude) * math.Pi / 360 + deltaLongitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Cos(latitude) + deltaLatitude := second.Latitude - first.Latitude + return math.Hypot(deltaLongitude, deltaLatitude) * 111.195 +} + +func visibleLineworkBoundarySourceMissDistanceKM( + polygons, boundaryLines [][]GeoPoint, +) float64 { + sourceArcs := newVisibleLineworkArcIndex(boundaryLines) + maximumDistance := 0.0 + for _, polygon := range polygons { + for index, point := range polygon { + // Endpoints of a planar shortcut often coincide with real source + // vertices. They must not mask a midpoint that leaves the source + // boundary by hundreds or thousands of kilometres. + edgeMaximum := sourceArcs.pointDistanceKM(point) + if index > 0 { + midpoint := sphericalInterpolate(polygon[index-1], point, 0.5) + midpointDistance, matched := sourceArcs.edgePointDistanceKM( + polygon[index-1], point, midpoint, + ) + if !matched { + midpointDistance = sourceArcs.pointDistanceKM(midpoint) + } + edgeMaximum = math.Max(edgeMaximum, midpointDistance) + } + maximumDistance = math.Max(maximumDistance, edgeMaximum) + } + } + return maximumDistance +} + +// repairVisibleLineworkBoundaryChords restores a source-boundary run when a +// planar union closes a polar face with a shortcut between two vertices on the +// same physical curve. The shortcut is topologically valid in the chart but +// is not part of the supplied linework and renders as an artificial straight +// chord on the map. +func repairVisibleLineworkBoundaryChords( + polygons, boundaryLines [][]GeoPoint, + maxDeviationKM, endpointToleranceKM float64, +) [][]GeoPoint { + if len(polygons) == 0 || len(boundaryLines) == 0 || maxDeviationKM <= 0 { + return polygons + } + sourceArcs := newVisibleLineworkArcIndex(boundaryLines) + result := make([][]GeoPoint, len(polygons)) + for polygonIndex, ring := range polygons { + if len(ring) < 4 { + result[polygonIndex] = ring + continue + } + repaired := make([]GeoPoint, 0, len(ring)) + repaired = append(repaired, ring[0]) + for index := 1; index < len(ring); index++ { + start, end := ring[index-1], ring[index] + midpoint := sphericalInterpolate(start, end, 0.5) + deviation, matched := sourceArcs.edgePointDistanceKM(start, end, midpoint) + if !matched { + deviation = sourceArcs.pointDistanceKM(midpoint) + } + if deviation <= maxDeviationKM { + repaired = append(repaired, end) + continue + } + bridge, ok := visibleLineworkBoundaryBridge( + start, end, boundaryLines, endpointToleranceKM, + ) + if !ok { + repaired = append(repaired, end) + continue + } + repaired = append(repaired, bridge[1:]...) + } + result[polygonIndex] = sweepDeduplicateAdjacent(repaired) + } + return result +} + +func visibleLineworkPointToLinesDistanceKM( + point GeoPoint, + lines [][]GeoPoint, +) float64 { + return newVisibleLineworkArcIndex(lines).pointDistanceKM(point) +} + +func visibleLineworkBoundaryBridge( + start, end GeoPoint, + lines [][]GeoPoint, + toleranceKM float64, +) ([]GeoPoint, bool) { + originalStart := start + var best []GeoPoint + bestLength := math.Inf(1) + for _, line := range lines { + if len(line) < 3 { + continue + } + startIndex, startDistance := visibleLineworkNearestVertex(start, line) + endIndex, endDistance := visibleLineworkNearestVertex(end, line) + if startDistance > toleranceKM || endDistance > toleranceKM || + startIndex == endIndex || absInt(startIndex-endIndex) < 2 { + continue + } + if startIndex > endIndex { + startIndex, endIndex = endIndex, startIndex + start, end = end, start + } + bridge := make([]GeoPoint, 0, endIndex-startIndex+1) + bridge = append(bridge, start) + bridge = append(bridge, line[startIndex+1:endIndex]...) + bridge = append(bridge, end) + if len(bridge) >= 3 { + if !visibleLineworkBridgeIsSimple(bridge, toleranceKM) { + continue + } + if geoPointDistanceKM(originalStart, bridge[0]) > geoPointDistanceKM(originalStart, bridge[len(bridge)-1]) { + reverseGeoPoints(bridge) + } + length := visibleLineworkBridgeLength(bridge) + if length < bestLength { + best, bestLength = bridge, length + } + } + } + if len(best) >= 3 { + return best, true + } + return visibleLineworkCrossLineBoundaryBridge(start, end, lines, toleranceKM) +} + +// visibleLineworkCrossLineBoundaryBridge restores a source-boundary run whose +// endpoints lie on different line strings. Polar phase envelopes commonly +// meet a horizon connector at an endpoint; the planar graph can collapse that +// junction and leave a long shortcut between the two strings. Stitch only +// existing source vertices that are within the same endpoint tolerance, and +// choose the shortest resulting source-path so unrelated lines cannot become a +// new artificial edge. +func visibleLineworkCrossLineBoundaryBridge( + start, end GeoPoint, + lines [][]GeoPoint, + toleranceKM float64, +) ([]GeoPoint, bool) { + type location struct { + lineIndex, pointIndex int + distance float64 + } + startLocations := make([]location, 0) + endLocations := make([]location, 0) + for lineIndex, line := range lines { + if len(line) < 2 { + continue + } + for pointIndex, point := range line { + if distance := geoPointDistanceKM(start, point); distance <= toleranceKM { + startLocations = append(startLocations, location{lineIndex, pointIndex, distance}) + } + if distance := geoPointDistanceKM(end, point); distance <= toleranceKM { + endLocations = append(endLocations, location{lineIndex, pointIndex, distance}) + } + } + } + var best []GeoPoint + bestLength := math.Inf(1) + for _, first := range startLocations { + for _, second := range endLocations { + if first.lineIndex == second.lineIndex { + continue + } + firstLine, secondLine := lines[first.lineIndex], lines[second.lineIndex] + firstIndices := []int{0, len(firstLine) - 1} + secondIndices := []int{0, len(secondLine) - 1} + for _, firstIndex := range firstIndices { + for _, secondIndex := range secondIndices { + if geoPointDistanceKM(firstLine[firstIndex], secondLine[secondIndex]) > toleranceKM { + continue + } + firstPath := visibleLineworkLinePath(firstLine, first.pointIndex, firstIndex, start, firstLine[firstIndex]) + secondPath := visibleLineworkLinePath(secondLine, secondIndex, second.pointIndex, secondLine[secondIndex], end) + if len(firstPath) < 2 || len(secondPath) < 2 { + continue + } + bridge := append(append([]GeoPoint(nil), firstPath...), secondPath[1:]...) + if len(bridge) < 3 { + continue + } + if !visibleLineworkBridgeIsSimple(bridge, toleranceKM) { + continue + } + length := visibleLineworkBridgeLength(bridge) + if length < bestLength { + best, bestLength = bridge, length + } + } + } + } + } + if len(best) < 3 { + return nil, false + } + return best, true +} + +func visibleLineworkBridgeIsSimple(points []GeoPoint, toleranceKM float64) bool { + if len(points) < 3 { + return false + } + endpointRepeatTolerance := math.Min(1, toleranceKM/10) + for index := 1; index+1 < len(points); index++ { + if geoPointDistanceKM(points[index], points[0]) <= endpointRepeatTolerance || + geoPointDistanceKM(points[index], points[len(points)-1]) <= endpointRepeatTolerance { + return false + } + } + return true +} + +func visibleLineworkLinePath( + line []GeoPoint, + startIndex, endIndex int, + start, end GeoPoint, +) []GeoPoint { + if startIndex < 0 || startIndex >= len(line) || endIndex < 0 || endIndex >= len(line) { + return nil + } + path := []GeoPoint{start} + if startIndex <= endIndex { + path = append(path, line[startIndex+1:endIndex+1]...) + } else { + for index := startIndex - 1; index >= endIndex; index-- { + path = append(path, line[index]) + } + } + if len(path) == 0 || geoPointDistanceKM(path[len(path)-1], end) > 1e-9 { + path = append(path, end) + } + return path +} + +func visibleLineworkBridgeLength(points []GeoPoint) float64 { + length := 0.0 + for index := 1; index < len(points); index++ { + length += geoPointDistanceKM(points[index-1], points[index]) + } + return length +} + +func visibleLineworkNearestVertex( + point GeoPoint, + line []GeoPoint, +) (int, float64) { + bestIndex := -1 + bestDistance := math.Inf(1) + for index, candidate := range line { + distance := geoPointDistanceKM(point, candidate) + if distance < bestDistance { + bestIndex = index + bestDistance = distance + } + } + return bestIndex, bestDistance +} + +func absInt(value int) int { + if value < 0 { + return -value + } + return value +} + +func visibleLineworkFilledEdgeProbes(lines, fillPolygons [][]GeoPoint) []GeoPoint { + fillBounds := make([]visibleLineworkBounds, len(fillPolygons)) + for index, polygon := range fillPolygons { + fillBounds[index] = visibleLineworkPolygonBounds(polygon, 0) + } + probes := make([]GeoPoint, 0, len(lines)) + for _, line := range lines { + if len(line) < 2 { + continue + } + segment := (len(line) - 1) / 2 + start, end := line[segment], line[segment+1] + dx := end.Longitude - start.Longitude + dy := end.Latitude - start.Latitude + length := math.Hypot(dx, dy) + if length <= 1e-12 { + continue + } + midpoint := GeoPoint{ + Longitude: (start.Longitude + end.Longitude) / 2, + Latitude: (start.Latitude + end.Latitude) / 2, + } + offset := math.Min(0.0025, 0.02*length) + for _, side := range []float64{-1, 1} { + probe := GeoPoint{ + Longitude: midpoint.Longitude - side*dy*offset/length, + Latitude: midpoint.Latitude + side*dx*offset/length, + } + for fillIndex, polygon := range fillPolygons { + if fillBounds[fillIndex].contains(polygon, probe) && + visibleLineworkContainsWithin(polygon, probe, 0) { + probes = append(probes, probe) + break + } + } + } + } + return probes +} + +func visibleLineworkFilledCycleProbes( + cycles, fillPolygons [][]GeoPoint, + tolerance float64, +) []GeoPoint { + type candidate struct { + polygon []GeoPoint + probe GeoPoint + area float64 + bounds visibleLineworkBounds + } + fillBounds := make([]visibleLineworkBounds, len(fillPolygons)) + for index, polygon := range fillPolygons { + fillBounds[index] = visibleLineworkPolygonBounds(polygon, tolerance) + } + candidates := make([]candidate, 0, len(cycles)) + for _, cycle := range cycles { + probe, ok := visibleLineworkPolygonCentroid(cycle) + if !ok || !visibleLineworkContainsWithin(cycle, probe, tolerance) { + continue + } + for fillIndex, polygon := range fillPolygons { + if fillBounds[fillIndex].contains(polygon, probe) && + visibleLineworkContainsWithin(polygon, probe, tolerance) { + candidates = append(candidates, candidate{ + polygon: cycle, + probe: probe, + area: math.Abs(visibleLineworkSignedArea(cycle)), + bounds: visibleLineworkPolygonBounds(cycle, tolerance), + }) + break + } + } + } + sort.Slice(candidates, func(first, second int) bool { + return candidates[first].area < candidates[second].area + }) + selected := make([]candidate, 0, len(candidates)) + probes := make([]GeoPoint, 0, len(candidates)) + for _, value := range candidates { + coveredBySmallerFace := false + for _, smaller := range selected { + if smaller.bounds.contains(smaller.polygon, value.probe) && + visibleLineworkContainsWithin(smaller.polygon, value.probe, tolerance) { + coveredBySmallerFace = true + break + } + } + if coveredBySmallerFace { + continue + } + selected = append(selected, value) + probes = append(probes, value.probe) + } + return probes +} + +func visibleLineworkPolygonCentroid(polygon []GeoPoint) (GeoPoint, bool) { + if len(polygon) < 3 { + return GeoPoint{}, false + } + area, longitude, latitude := 0.0, 0.0, 0.0 + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + cross := polygon[previous].Longitude*polygon[current].Latitude - + polygon[current].Longitude*polygon[previous].Latitude + area += cross + longitude += (polygon[previous].Longitude + polygon[current].Longitude) * cross + latitude += (polygon[previous].Latitude + polygon[current].Latitude) * cross + } + if math.Abs(area) <= 1e-12 { + return GeoPoint{}, false + } + return GeoPoint{ + Longitude: longitude / (3 * area), + Latitude: latitude / (3 * area), + }, true +} + +type visibleLineworkSegmentSplit struct { + fraction float64 + point GeoPoint +} + +// splitVisibleLineworkIntersections turns interior crossings into graph +// nodes. Lunar rise/set curves can cross at a real polar horizon-grazing +// site, whereas solar-eclipse linework usually meets only at endpoints. +func splitVisibleLineworkIntersections(lines [][]GeoPoint) [][]GeoPoint { + if len(lines) < 2 { + return lines + } + splits := make([][][]visibleLineworkSegmentSplit, len(lines)) + junctions := make([][]bool, len(lines)) + for lineIndex, line := range lines { + if len(line) < 2 { + continue + } + splits[lineIndex] = make([][]visibleLineworkSegmentSplit, len(line)-1) + junctions[lineIndex] = make([]bool, len(line)) + } + for firstLine := 0; firstLine < len(lines); firstLine++ { + for firstSegment := 0; firstSegment+1 < len(lines[firstLine]); firstSegment++ { + firstStart := lines[firstLine][firstSegment] + firstEnd := lines[firstLine][firstSegment+1] + for secondLine := firstLine; secondLine < len(lines); secondLine++ { + secondStartSegment := 0 + if secondLine == firstLine { + secondStartSegment = firstSegment + 2 + } + for secondSegment := secondStartSegment; secondSegment+1 < len(lines[secondLine]); secondSegment++ { + secondStart := lines[secondLine][secondSegment] + secondEnd := lines[secondLine][secondSegment+1] + longitudeShift := 360 * math.Round( + ((firstStart.Longitude+firstEnd.Longitude)-(secondStart.Longitude+secondEnd.Longitude))/720, + ) + shiftedStart := secondStart + shiftedEnd := secondEnd + shiftedStart.Longitude += longitudeShift + shiftedEnd.Longitude += longitudeShift + firstFraction, secondFraction, point, ok := visibleLineworkSegmentIntersection( + firstStart, firstEnd, shiftedStart, shiftedEnd, + ) + if !ok { + continue + } + visibleLineworkRecordSplit( + splits[firstLine], junctions[firstLine], firstSegment, firstFraction, point, + ) + point.Longitude -= longitudeShift + visibleLineworkRecordSplit( + splits[secondLine], junctions[secondLine], secondSegment, secondFraction, point, + ) + } + } + } + } + + result := make([][]GeoPoint, 0, len(lines)*2) + for lineIndex, line := range lines { + if len(line) < 2 { + continue + } + current := []GeoPoint{line[0]} + for segmentIndex := 0; segmentIndex+1 < len(line); segmentIndex++ { + if segmentIndex > 0 && junctions[lineIndex][segmentIndex] { + if len(current) >= 2 { + result = append(result, current) + } + current = []GeoPoint{line[segmentIndex]} + } + segmentSplits := splits[lineIndex][segmentIndex] + sort.Slice(segmentSplits, func(first, second int) bool { + return segmentSplits[first].fraction < segmentSplits[second].fraction + }) + for _, split := range segmentSplits { + if len(current) == 0 || !SameGeoPoint(current[len(current)-1], split.point) { + current = append(current, split.point) + } + if len(current) >= 2 { + result = append(result, current) + } + current = []GeoPoint{split.point} + } + if !SameGeoPoint(current[len(current)-1], line[segmentIndex+1]) { + current = append(current, line[segmentIndex+1]) + } + } + if len(current) >= 2 { + result = append(result, current) + } + } + return result +} + +func visibleLineworkRecordSplit( + splits [][]visibleLineworkSegmentSplit, + junctions []bool, + segment int, + fraction float64, + point GeoPoint, +) { + const endpointTolerance = 1e-8 + if fraction <= endpointTolerance { + junctions[segment] = true + return + } + if fraction >= 1-endpointTolerance { + junctions[segment+1] = true + return + } + for _, existing := range splits[segment] { + if math.Abs(existing.fraction-fraction) <= endpointTolerance { + return + } + } + splits[segment] = append(splits[segment], visibleLineworkSegmentSplit{ + fraction: fraction, + point: point, + }) +} + +func visibleLineworkSegmentIntersection( + firstStart, firstEnd, secondStart, secondEnd GeoPoint, +) (float64, float64, GeoPoint, bool) { + firstX := firstEnd.Longitude - firstStart.Longitude + firstY := firstEnd.Latitude - firstStart.Latitude + secondX := secondEnd.Longitude - secondStart.Longitude + secondY := secondEnd.Latitude - secondStart.Latitude + denominator := firstX*secondY - firstY*secondX + if math.Abs(denominator) <= 1e-12 { + return 0, 0, GeoPoint{}, false + } + offsetX := secondStart.Longitude - firstStart.Longitude + offsetY := secondStart.Latitude - firstStart.Latitude + firstFraction := (offsetX*secondY - offsetY*secondX) / denominator + secondFraction := (offsetX*firstY - offsetY*firstX) / denominator + const intersectionTolerance = 1e-9 + if firstFraction < -intersectionTolerance || firstFraction > 1+intersectionTolerance || + secondFraction < -intersectionTolerance || secondFraction > 1+intersectionTolerance { + // 参数判定只容忍 1e-9,旋转图幅下 T 型交点会被拒绝,这里按垂距回退判定。 + if fraction, ok := visibleLineworkEndpointOnSegment(secondStart, firstStart, firstEnd); ok { + return fraction, 0, secondStart, true + } + if fraction, ok := visibleLineworkEndpointOnSegment(secondEnd, firstStart, firstEnd); ok { + return fraction, 1, secondEnd, true + } + if fraction, ok := visibleLineworkEndpointOnSegment(firstStart, secondStart, secondEnd); ok { + return 0, fraction, firstStart, true + } + if fraction, ok := visibleLineworkEndpointOnSegment(firstEnd, secondStart, secondEnd); ok { + return 1, fraction, firstEnd, true + } + return 0, 0, GeoPoint{}, false + } + firstFraction = math.Max(0, math.Min(1, firstFraction)) + secondFraction = math.Max(0, math.Min(1, secondFraction)) + return firstFraction, secondFraction, GeoPoint{ + Longitude: firstStart.Longitude + firstFraction*firstX, + Latitude: firstStart.Latitude + firstFraction*firstY, + }, true +} + +// visibleLineworkTJunctionToleranceDeg 是端点落在另一段内部的垂距容差(度)。 +const visibleLineworkTJunctionToleranceDeg = 1e-5 + +// visibleLineworkEndpointOnSegment 返回端点落在段内部时的投影参数。 +func visibleLineworkEndpointOnSegment(point, start, end GeoPoint) (float64, bool) { + deltaX := end.Longitude - start.Longitude + deltaY := end.Latitude - start.Latitude + lengthSquared := deltaX*deltaX + deltaY*deltaY + if !(lengthSquared > 0) { + return 0, false + } + offsetX := point.Longitude - start.Longitude + offsetY := point.Latitude - start.Latitude + fraction := (offsetX*deltaX + offsetY*deltaY) / lengthSquared + if !(fraction > 0 && fraction < 1) { + return 0, false + } + distance := math.Hypot(offsetX-fraction*deltaX, offsetY-fraction*deltaY) + if !(distance <= visibleLineworkTJunctionToleranceDeg) { + return 0, false + } + return fraction, true +} + +func selectVisibleLineworkCycles( + cycles, coveragePaths [][]GeoPoint, + boundaryTolerance float64, +) [][]GeoPoint { + return selectVisibleLineworkCyclesWithProbeLimit(cycles, coveragePaths, boundaryTolerance, 768) +} + +func selectVisibleLineworkCyclesWithProbeLimit( + cycles, coveragePaths [][]GeoPoint, + boundaryTolerance float64, + maximumProbes int, +) [][]GeoPoint { + type candidate struct { + polygon []GeoPoint + area float64 + bounds visibleLineworkBounds + index visibleLineworkPolygonIndex + indexed bool + covers []bool + count int + } + probes := visibleLineworkCoverageProbes(coveragePaths, 8) + if len(probes) == 0 { + return nil + } + probes = limitVisibleLineworkCoverageProbes(probes, maximumProbes) + candidates := make([]candidate, 0, len(cycles)) + for _, source := range cycles { + polygon := append([]GeoPoint(nil), source...) + area := visibleLineworkSignedArea(polygon) + if math.Abs(area) <= 1e-10 { + continue + } + if area < 0 { + reverseSweepGeoPoints(polygon) + area = -area + } + candidates = append(candidates, candidate{ + polygon: polygon, + area: area, + bounds: visibleLineworkPolygonBounds(polygon, boundaryTolerance), + }) + } + sort.SliceStable(candidates, func(first, second int) bool { + return candidates[first].area < candidates[second].area + }) + + // Most physical line networks have one face containing every footprint + // probe. A small subset cheaply rejects impossible faces; candidates that + // survive still have to cover the original complete probe set. + sentinels := sampleVisibleLineworkProbes(probes, 16) + best := -1 + for candidateIndex := range candidates { + value := &candidates[candidateIndex] + if best >= 0 && value.area >= candidates[best].area { + continue + } + if !visibleLineworkBoundsContainAll(value.bounds, value.polygon, sentinels) || + !visibleLineworkBoundsContainAll(value.bounds, value.polygon, probes) { + continue + } + if !value.indexed { + value.index = newVisibleLineworkPolygonIndex(value.polygon) + value.indexed = true + } + if !visibleLineworkContainsAllIndexed( + &value.index, value.bounds, sentinels, boundaryTolerance, + ) { + continue + } + if visibleLineworkContainsAllIndexed( + &value.index, value.bounds, probes, boundaryTolerance, + ) { + best = candidateIndex + } + } + if best >= 0 { + return [][]GeoPoint{candidates[best].polygon} + } + + for candidateIndex := range candidates { + value := &candidates[candidateIndex] + value.covers = make([]bool, len(probes)) + for probeIndex, probe := range probes { + if !value.bounds.contains(value.polygon, probe) { + continue + } + if !value.indexed { + value.index = newVisibleLineworkPolygonIndex(value.polygon) + value.indexed = true + } + if visibleLineworkContainsWithinIndexed(&value.index, probe, boundaryTolerance) { + value.covers[probeIndex] = true + value.count++ + } + } + } + covered := make([]bool, len(probes)) + remaining := len(probes) + selected := make([][]GeoPoint, 0, len(candidates)) + used := make([]bool, len(candidates)) + for remaining > 0 { + best, bestGain := -1, 0 + for index, value := range candidates { + if used[index] { + continue + } + gain := 0 + for probeIndex, contains := range value.covers { + if contains && !covered[probeIndex] { + gain++ + } + } + if gain > bestGain || gain == bestGain && gain > 0 && + (best < 0 || value.area < candidates[best].area) { + best, bestGain = index, gain + } + } + if best < 0 || bestGain == 0 { + // A handful of probes can land exactly on a numerically ambiguous + // junction after projection. Keep the already selected faces when + // they cover the overwhelming majority of source probes; the miss + // audit below still rejects a materially incomplete outline. + allowedUncovered := len(probes) / 100 + if allowedUncovered < 1 { + allowedUncovered = 1 + } + if allowedUncovered > 8 { + allowedUncovered = 8 + } + if remaining <= allowedUncovered && len(selected) > 0 { + break + } + return nil + } + used[best] = true + selected = append(selected, candidates[best].polygon) + for probeIndex, contains := range candidates[best].covers { + if contains && !covered[probeIndex] { + covered[probeIndex] = true + remaining-- + } + } + } + return selected +} + +func visibleLineworkBoundsContainAll( + bounds visibleLineworkBounds, + polygon, probes []GeoPoint, +) bool { + for _, probe := range probes { + if !bounds.contains(polygon, probe) { + return false + } + } + return true +} + +func sampleVisibleLineworkProbes(probes []GeoPoint, maximumPoints int) []GeoPoint { + if maximumPoints < 2 || len(probes) <= maximumPoints { + return probes + } + result := make([]GeoPoint, maximumPoints) + for index := range result { + probeIndex := index * (len(probes) - 1) / (maximumPoints - 1) + result[index] = probes[probeIndex] + } + return result +} + +func limitVisibleLineworkCoverageProbes(probes []GeoPoint, maximum int) []GeoPoint { + if maximum < 1 || len(probes) <= maximum { + return probes + } + selected := make([]bool, len(probes)) + reserve := maximum / 4 + if reserve < 1 { + reserve = 1 + } + indices := make([]int, len(probes)) + for index := range indices { + indices[index] = index + } + sort.SliceStable(indices, func(first, second int) bool { + return probes[indices[first]].Latitude > probes[indices[second]].Latitude + }) + for index := 0; index < reserve && index < len(indices); index++ { + selected[indices[index]] = true + selected[indices[len(indices)-1-index]] = true + } + remaining := maximum + for _, value := range selected { + if value { + remaining-- + } + } + if remaining < 1 { + remaining = 1 + } + stride := float64(len(probes)) / float64(remaining) + result := make([]GeoPoint, 0, maximum) + for index, value := range selected { + if value { + result = append(result, probes[index]) + } + } + for cursor := 0.0; len(result) < maximum && int(cursor) < len(probes); cursor += stride { + index := int(cursor) + if selected[index] { + continue + } + selected[index] = true + result = append(result, probes[index]) + } + return result +} + +func visibleLineworkContainsAll( + polygon []GeoPoint, + bounds visibleLineworkBounds, + probes []GeoPoint, + tolerance float64, +) bool { + for _, probe := range probes { + if !bounds.contains(polygon, probe) || + !visibleLineworkContainsWithin(polygon, probe, tolerance) { + return false + } + } + return true +} + +const visibleLineworkLatitudeBins = 64 + +type visibleLineworkPolygonIndex struct { + polygon []GeoPoint + minLatitude float64 + maxLatitude float64 + bins [][]int + marks []uint32 + generation uint32 +} + +func newVisibleLineworkPolygonIndex(polygon []GeoPoint) visibleLineworkPolygonIndex { + index := visibleLineworkPolygonIndex{polygon: polygon} + if len(polygon) == 0 { + return index + } + index.minLatitude, index.maxLatitude = polygon[0].Latitude, polygon[0].Latitude + for _, point := range polygon[1:] { + index.minLatitude = math.Min(index.minLatitude, point.Latitude) + index.maxLatitude = math.Max(index.maxLatitude, point.Latitude) + } + index.bins = make([][]int, visibleLineworkLatitudeBins) + index.marks = make([]uint32, len(polygon)) + for edge := range polygon { + previous := (edge + len(polygon) - 1) % len(polygon) + first, second := polygon[previous], polygon[edge] + firstBin := index.latitudeBin(math.Min(first.Latitude, second.Latitude)) + lastBin := index.latitudeBin(math.Max(first.Latitude, second.Latitude)) + for bin := firstBin; bin <= lastBin; bin++ { + index.bins[bin] = append(index.bins[bin], edge) + } + } + return index +} + +func (index visibleLineworkPolygonIndex) latitudeBin(latitude float64) int { + if index.maxLatitude <= index.minLatitude { + return 0 + } + fraction := (latitude - index.minLatitude) / (index.maxLatitude - index.minLatitude) + if fraction <= 0 { + return 0 + } + if fraction >= 1 { + return visibleLineworkLatitudeBins - 1 + } + return int(fraction * visibleLineworkLatitudeBins) +} + +func (index *visibleLineworkPolygonIndex) nextGeneration() uint32 { + index.generation++ + if index.generation == 0 { + for mark := range index.marks { + index.marks[mark] = 0 + } + index.generation = 1 + } + return index.generation +} + +func visibleLineworkContainsAllIndexed( + index *visibleLineworkPolygonIndex, + bounds visibleLineworkBounds, + probes []GeoPoint, + tolerance float64, +) bool { + for _, probe := range probes { + if !bounds.contains(index.polygon, probe) || + !visibleLineworkContainsWithinIndexed(index, probe, tolerance) { + return false + } + } + return true +} + +func visibleLineworkContainsWithinIndexed( + index *visibleLineworkPolygonIndex, + point GeoPoint, + tolerance float64, +) bool { + if index == nil { + return visibleLineworkContainsWithin(nil, point, tolerance) + } + polygon := index.polygon + if len(polygon) < 3 || tolerance <= 0 { + return visibleLineworkContainsWithin(polygon, point, tolerance) + } + point = visibleLineworkPointNearPolygon(polygon, point) + if point.Latitude < index.minLatitude-tolerance || point.Latitude > index.maxLatitude+tolerance { + return false + } + firstBin := index.latitudeBin(point.Latitude-tolerance) - 2 + lastBin := index.latitudeBin(point.Latitude+tolerance) + 2 + if firstBin < 0 { + firstBin = 0 + } + if lastBin >= visibleLineworkLatitudeBins { + lastBin = visibleLineworkLatitudeBins - 1 + } + generation := index.nextGeneration() + inside := false + for bin := firstBin; bin <= lastBin; bin++ { + for _, edge := range index.bins[bin] { + if index.marks[edge] == generation { + continue + } + index.marks[edge] = generation + current, previous := edge, (edge+len(polygon)-1)%len(polygon) + first, second := polygon[previous], polygon[current] + if (first.Latitude > point.Latitude) == (second.Latitude > point.Latitude) { + continue + } + intersection := first.Longitude + + (point.Latitude-first.Latitude)*(second.Longitude-first.Longitude)/(second.Latitude-first.Latitude) + if intersection >= point.Longitude { + inside = !inside + } + } + } + if inside { + return true + } + + generation = index.nextGeneration() + toleranceSquared := tolerance * tolerance + for bin := firstBin; bin <= lastBin; bin++ { + for _, edge := range index.bins[bin] { + if index.marks[edge] == generation { + continue + } + index.marks[edge] = generation + current, previous := edge, (edge+len(polygon)-1)%len(polygon) + start, end := polygon[previous], polygon[current] + if point.Longitude < math.Min(start.Longitude, end.Longitude)-tolerance || + point.Longitude > math.Max(start.Longitude, end.Longitude)+tolerance || + point.Latitude < math.Min(start.Latitude, end.Latitude)-tolerance || + point.Latitude > math.Max(start.Latitude, end.Latitude)+tolerance { + continue + } + if visibleLineworkPointSegmentDistanceSquared(point, start, end) <= toleranceSquared { + return true + } + } + } + return false +} + +func visibleLineworkCyclesCoverCoverage( + cycles, coveragePaths [][]GeoPoint, + tolerance float64, +) bool { + return visibleLineworkCoverageMissCount(cycles, coveragePaths, tolerance) == 0 +} + +func visibleLineworkCoverageMissCount( + cycles, coveragePaths [][]GeoPoint, + tolerance float64, +) int { + if len(cycles) == 0 { + return -1 + } + probes := visibleLineworkCoverageProbes(coveragePaths, 8) + probes = limitVisibleLineworkCoverageProbes(probes, 768) + if len(probes) == 0 { + return -1 + } + bounds := make([]visibleLineworkBounds, len(cycles)) + for index, cycle := range cycles { + bounds[index] = visibleLineworkPolygonBounds(cycle, tolerance) + } + misses := 0 + for _, probe := range probes { + covered := false + for index, cycle := range cycles { + if bounds[index].contains(cycle, probe) && + visibleLineworkContainsWithin(cycle, probe, tolerance) { + covered = true + break + } + } + if !covered { + misses++ + } + } + return misses +} + +func visibleLineworkPolygonBounds(polygon []GeoPoint, tolerance float64) visibleLineworkBounds { + if len(polygon) == 0 { + return visibleLineworkBounds{} + } + bounds := visibleLineworkBounds{ + minLongitude: polygon[0].Longitude, + maxLongitude: polygon[0].Longitude, + minLatitude: polygon[0].Latitude, + maxLatitude: polygon[0].Latitude, + } + for _, point := range polygon[1:] { + bounds.minLongitude = math.Min(bounds.minLongitude, point.Longitude) + bounds.maxLongitude = math.Max(bounds.maxLongitude, point.Longitude) + bounds.minLatitude = math.Min(bounds.minLatitude, point.Latitude) + bounds.maxLatitude = math.Max(bounds.maxLatitude, point.Latitude) + } + return visibleLineworkBounds{ + minLongitude: bounds.minLongitude - tolerance, + maxLongitude: bounds.maxLongitude + tolerance, + minLatitude: bounds.minLatitude - tolerance, + maxLatitude: bounds.maxLatitude + tolerance, + } +} + +func (bounds visibleLineworkBounds) contains(polygon []GeoPoint, point GeoPoint) bool { + point = visibleLineworkPointNearPolygon(polygon, point) + return point.Longitude >= bounds.minLongitude && point.Longitude <= bounds.maxLongitude && + point.Latitude >= bounds.minLatitude && point.Latitude <= bounds.maxLatitude +} + +func visibleLineworkCoverageProbes(paths [][]GeoPoint, maximumPoints int) []GeoPoint { + if maximumPoints < 2 { + maximumPoints = 2 + } + probes := make([]GeoPoint, 0, len(paths)*maximumPoints) + for _, path := range paths { + step := (len(path) + maximumPoints - 1) / maximumPoints + if step < 1 { + step = 1 + } + for index := 0; index < len(path); index += step { + probes = append(probes, path[index]) + } + if len(path) > 0 && (len(path)-1)%step != 0 { + probes = append(probes, path[len(path)-1]) + } + } + return probes +} + +func visibleLineworkContainsWithin(polygon []GeoPoint, point GeoPoint, tolerance float64) bool { + if len(polygon) < 3 { + return false + } + point = visibleLineworkPointNearPolygon(polygon, point) + if tolerance <= 0 { + return sweepPointInPolygon(polygon, point) + } + inside := false + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + first, second := polygon[previous], polygon[current] + if (first.Latitude > point.Latitude) == (second.Latitude > point.Latitude) { + continue + } + intersection := first.Longitude + + (point.Latitude-first.Latitude)*(second.Longitude-first.Longitude)/(second.Latitude-first.Latitude) + if intersection >= point.Longitude { + inside = !inside + } + } + if inside { + return true + } + toleranceSquared := tolerance * tolerance + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + start, end := polygon[previous], polygon[current] + if point.Longitude < math.Min(start.Longitude, end.Longitude)-tolerance || + point.Longitude > math.Max(start.Longitude, end.Longitude)+tolerance || + point.Latitude < math.Min(start.Latitude, end.Latitude)-tolerance || + point.Latitude > math.Max(start.Latitude, end.Latitude)+tolerance { + continue + } + if visibleLineworkPointSegmentDistanceSquared(point, start, end) <= toleranceSquared { + return true + } + } + return false +} + +func visibleLineworkPointNearPolygon(polygon []GeoPoint, point GeoPoint) GeoPoint { + if len(polygon) == 0 { + return point + } + longitude := point.Longitude + for longitude-polygon[0].Longitude > 180 { + longitude -= 360 + } + for longitude-polygon[0].Longitude < -180 { + longitude += 360 + } + point.Longitude = longitude + return point +} + +func visibleLineworkPointSegmentDistanceSquared(point, start, end GeoPoint) float64 { + dx := end.Longitude - start.Longitude + dy := end.Latitude - start.Latitude + denominator := dx*dx + dy*dy + if denominator <= 1e-20 { + dx = point.Longitude - start.Longitude + dy = point.Latitude - start.Latitude + return dx*dx + dy*dy + } + fraction := ((point.Longitude-start.Longitude)*dx + (point.Latitude-start.Latitude)*dy) / denominator + fraction = math.Max(0, math.Min(1, fraction)) + dx = point.Longitude - (start.Longitude + fraction*dx) + dy = point.Latitude - (start.Latitude + fraction*dy) + return dx*dx + dy*dy +} + +func sampleVisibleLineworkFillPaths(paths [][]GeoPoint, maximumPoints int) [][]GeoPoint { + if maximumPoints < 3 { + maximumPoints = 3 + } + result := make([][]GeoPoint, 0, len(paths)) + for _, path := range paths { + path = openGeoRing(path) + if len(path) <= maximumPoints { + result = append(result, path) + continue + } + indices := make([]int, 0, maximumPoints) + seen := make(map[int]bool, maximumPoints) + for direction := 0; direction < maximumPoints; direction++ { + angle := 2 * math.Pi * float64(direction) / float64(maximumPoints) + x, y := math.Cos(angle), math.Sin(angle) + bestIndex := 0 + bestValue := path[0].Longitude*x + path[0].Latitude*y + for index := 1; index < len(path); index++ { + value := path[index].Longitude*x + path[index].Latitude*y + if value > bestValue { + bestIndex, bestValue = index, value + } + } + if !seen[bestIndex] { + seen[bestIndex] = true + indices = append(indices, bestIndex) + } + } + sort.Ints(indices) + sampled := make([]GeoPoint, 0, len(indices)) + for _, index := range indices { + sampled = append(sampled, path[index]) + } + result = append(result, sampled) + } + return result +} + +func unprojectVisibleLineworkPaths(chart polygonUnionChart, paths [][]GeoPoint) [][]GeoPoint { + result := make([][]GeoPoint, len(paths)) + for pathIndex, path := range paths { + result[pathIndex] = make([]GeoPoint, len(path)) + for pointIndex, point := range path { + result[pathIndex][pointIndex] = chart.unproject(point) + } + } + return result +} + +func visibleLineworkPointPaths(paths [][]GeoPoint) [][]GeoPoint { + result := make([][]GeoPoint, 0) + for _, path := range paths { + for _, point := range path { + result = append(result, []GeoPoint{point}) + } + } + return result +} + +type visibleLineworkCycleStep struct { + edge int + reverse bool +} + +func enumerateVisibleLineworkCycles( + nodes []visibleLineworkNode, + edges []visibleLineworkEdge, +) [][]GeoPoint { + // Walk the face on each side of every edge once. The exterior face of + // each component is retained as a candidate for its complete outer band. + // Enumerating arbitrary combinations of interior faces grows exponentially. + order := make([][]int, len(nodes)) + position := make([]int, 2*len(edges)) + halfAngle := make([]float64, 2*len(edges)) + for index, edge := range edges { + // 正向半边的方向取"首点到相邻的第二点",与反向半边的"末点到倒数第二点"对称; + // 取首末两点会在折线上给出不同的排序角度,进而改变环枚举结果。 + first, next := edge.points[0], edge.points[1] + previous, last := edge.points[len(edge.points)-2], edge.points[len(edge.points)-1] + halfAngle[2*index] = math.Atan2(next.Latitude-first.Latitude, next.Longitude-first.Longitude) + halfAngle[2*index+1] = math.Atan2(previous.Latitude-last.Latitude, previous.Longitude-last.Longitude) + } + for node, value := range nodes { + for _, half := range value.outgoing { + id := 2 * half.edge + if half.reverse { + id++ + } + order[node] = append(order[node], id) + } + sort.SliceStable(order[node], func(i, j int) bool { + return halfAngle[order[node][i]] < halfAngle[order[node][j]] + }) + for i, half := range order[node] { + position[half] = i + } + } + visited := make([]bool, 2*len(edges)) + cycles := make([][]GeoPoint, 0, len(edges)) + seen := make(map[uint64][][]int32) + for start := range visited { + if visited[start] { + continue + } + var steps []visibleLineworkCycleStep + for half := start; !visited[half]; { + visited[half] = true + step := visibleLineworkCycleStep{edge: half / 2, reverse: half%2 != 0} + if len(steps) > 0 && steps[len(steps)-1].edge == step.edge && steps[len(steps)-1].reverse != step.reverse { + steps = steps[:len(steps)-1] + } else { + steps = append(steps, step) + } + next := edges[step.edge].end + if step.reverse { + next = edges[step.edge].start + } + out := order[next] + half = out[(position[half^1]+len(out)-1)%len(out)] + } + for len(steps) > 1 && steps[0].edge == steps[len(steps)-1].edge && steps[0].reverse != steps[len(steps)-1].reverse { + steps = steps[1 : len(steps)-1] + } + if len(steps) < 1 { + continue + } + for _, simple := range visibleLineworkSimpleCycles(edges, steps) { + if visibleLineworkCycleSeen(seen, simple) { + continue + } + if ring := visibleLineworkCyclePoints(edges, simple); len(ring) >= 3 && math.Abs(visibleLineworkSignedArea(ring)) > 1e-12 { + cycles = append(cycles, ring) + } + } + } + return cycles +} + +// The exterior face can revisit an articulation node. Separate its lobes so +// each candidate is a simple ring, including components joined by a bridge. +func visibleLineworkSimpleCycles(edges []visibleLineworkEdge, steps []visibleLineworkCycleStep) [][]visibleLineworkCycleStep { + var cycles [][]visibleLineworkCycleStep + var path []visibleLineworkCycleStep + positions := make(map[int]int) + for _, step := range steps { + start, end := edges[step.edge].start, edges[step.edge].end + if step.reverse { + start, end = end, start + } + positions[start] = len(path) + path = append(path, step) + if at, ok := positions[end]; ok { + cycles = append(cycles, append([]visibleLineworkCycleStep(nil), path[at:]...)) + for _, removed := range path[at:] { + node := edges[removed.edge].start + if removed.reverse { + node = edges[removed.edge].end + } + delete(positions, node) + } + path = path[:at] + } + } + return cycles +} + +// visibleLineworkCycleSeen 按与方向无关的边集合去重,返回该环是否已出现。 +func visibleLineworkCycleSeen(seen map[uint64][][]int32, steps []visibleLineworkCycleStep) bool { + key := make([]int32, len(steps)) + for index, step := range steps { + key[index] = int32(step.edge) + } + sort.Slice(key, func(first, second int) bool { return key[first] < key[second] }) + hash := uint64(14695981039346656037) + for _, edge := range key { + hash = (hash ^ uint64(uint32(edge))) * 1099511628211 + } + for _, existing := range seen[hash] { + if len(existing) != len(key) { + continue + } + same := true + for index, edge := range key { + if existing[index] != edge { + same = false + break + } + } + if same { + return true + } + } + seen[hash] = append(seen[hash], key) + return false +} + +func visibleLineworkCyclePoints( + edges []visibleLineworkEdge, + steps []visibleLineworkCycleStep, +) []GeoPoint { + ring := make([]GeoPoint, 0, len(steps)*4) + for _, step := range steps { + points := visibleLineworkDirectedPoints(edges[step.edge], step.reverse) + if len(ring) == 0 { + ring = append(ring, points...) + } else { + ring = append(ring, points[1:]...) + } + } + return sweepDeduplicateAdjacent(openGeoRing(ring)) +} + +func projectVisibleLine(chart polygonUnionChart, source []GeoPoint) []GeoPoint { + if len(source) == 0 { + return nil + } + line := make([]GeoPoint, 0, len(source)) + for sourceIndex, point := range source { + projected := chart.project(point) + if len(line) > 0 { + for projected.Longitude-line[len(line)-1].Longitude > 180 { + projected.Longitude -= 360 + } + for projected.Longitude-line[len(line)-1].Longitude < -180 { + projected.Longitude += 360 + } + } + if len(line) > 0 { + if math.Hypot(projected.Longitude-line[len(line)-1].Longitude, projected.Latitude-line[len(line)-1].Latitude) > 1e-10 { + line = appendVisibleProjectedArc(line, chart, source[sourceIndex-1], point, line[len(line)-1], projected, 0) + } + continue + } + if len(line) == 0 || math.Hypot( + projected.Longitude-line[len(line)-1].Longitude, + projected.Latitude-line[len(line)-1].Latitude, + ) > 1e-10 { + line = append(line, projected) + } + } + return line +} + +// A great-circle arc becomes curved after rotation into the topology chart. +// Bound its projected chord error before intersections and face containment. +func appendVisibleProjectedArc( + points []GeoPoint, + chart polygonUnionChart, + first, last, start, end GeoPoint, + depth int, +) []GeoPoint { + if depth < 12 && (math.Abs(end.Longitude-start.Longitude) > 0.25 || + math.Abs(end.Latitude-start.Latitude) > 0.25 || + math.Max(math.Abs(start.Latitude), math.Abs(end.Latitude)) > 75) { + middle := sphericalInterpolate(first, last, 0.5) + projected := chart.project(middle) + projected.Longitude = start.Longitude + math.Remainder(projected.Longitude-start.Longitude, 360) + if visibleLineworkPointSegmentDistanceSquared(projected, start, end) > 0.002*0.002 { + points = appendVisibleProjectedArc(points, chart, first, middle, start, projected, depth+1) + return appendVisibleProjectedArc(points, chart, middle, last, projected, end, depth+1) + } + } + return append(points, end) +} + +func buildVisibleLineworkGraph( + lines [][]GeoPoint, + chart polygonUnionChart, + snapDistanceKM float64, +) ([]visibleLineworkNode, []visibleLineworkEdge, [][]GeoPoint) { + const commonJunctionKM = 1e-5 + nodes, edges, loops := buildVisibleLineworkGraphAtTolerance(lines, chart, commonJunctionKM) + if snapDistanceKM <= 0 { + snapDistanceKM = 1 + } + changed := false + for { + first, second := -1, -1 + minimum := snapDistanceKM + // Connected intersections are distinct even when physically close. + // Only an open endpoint can require the wider sampling snap radius. + for index, node := range nodes { + if len(node.outgoing) != 1 { + continue + } + point := chart.unproject(node.point) + for otherIndex, other := range nodes { + if index == otherIndex || len(other.outgoing) == 0 { + continue + } + if distance := geoPointDistanceKM(point, chart.unproject(other.point)); distance < minimum { + first, second, minimum = index, otherIndex, distance + } + } + } + if first < 0 { + break + } + changed = true + for _, half := range nodes[first].outgoing { + edge := &edges[half.edge] + if edge.start == first { + edge.start, edge.points[0] = second, nodes[second].point + } + if edge.end == first { + edge.end, edge.points[len(edge.points)-1] = second, nodes[second].point + } + nodes[second].outgoing = append(nodes[second].outgoing, half) + } + nodes[first].outgoing = nil + } + if !changed { + return nodes, edges, loops + } + joined := make([][]GeoPoint, 0, len(edges)+len(loops)) + for _, edge := range edges { + joined = append(joined, edge.points) + } + for _, loop := range loops { + joined = append(joined, append(append([]GeoPoint(nil), loop...), loop[0])) + } + // Moving an endpoint can create a crossing on its adjacent segment. + // Re-node those intersections before ordering directed edges into faces. + return buildVisibleLineworkGraphAtTolerance(splitVisibleLineworkIntersections(joined), chart, commonJunctionKM) +} + +func buildVisibleLineworkGraphAtTolerance( + lines [][]GeoPoint, + chart polygonUnionChart, + snapDistanceKM float64, +) ([]visibleLineworkNode, []visibleLineworkEdge, [][]GeoPoint) { + if snapDistanceKM <= 0 { + snapDistanceKM = 1 + } + nodes := make([]visibleLineworkNode, 0, len(lines)) + edges := make([]visibleLineworkEdge, 0, len(lines)) + loops := make([][]GeoPoint, 0, 2) + for _, source := range lines { + line := append([]GeoPoint(nil), source...) + start := visibleLineworkNodeIndex(nodes, chart, line[0], snapDistanceKM) + if start < 0 { + start = len(nodes) + nodes = append(nodes, visibleLineworkNode{point: line[0]}) + } + end := visibleLineworkNodeIndex(nodes, chart, line[len(line)-1], snapDistanceKM) + if end < 0 { + end = len(nodes) + nodes = append(nodes, visibleLineworkNode{point: line[len(line)-1]}) + } + line[0] = nodes[start].point + line[len(line)-1] = nodes[end].point + line = sweepDeduplicateAdjacent(line) + if len(line) < 2 { + continue + } + if start == end && len(openGeoRing(line)) < 3 { + continue + } + duplicate := false + for _, edge := range edges { + if len(edge.points) != len(line) { + continue + } + reverse := edge.start == end && edge.end == start + if !reverse && (edge.start != start || edge.end != end) { + continue + } + same := true + for i, point := range line { + j := i + if reverse { + j = len(line) - 1 - i + } + if !SameGeoPoint(point, edge.points[j]) { + same = false + break + } + } + if same { + duplicate = true + break + } + } + if duplicate { + continue + } + edgeIndex := len(edges) + edges = append(edges, visibleLineworkEdge{points: line, start: start, end: end}) + nodes[start].outgoing = append(nodes[start].outgoing, visibleLineworkHalfEdge{ + edge: edgeIndex, + }) + nodes[end].outgoing = append(nodes[end].outgoing, visibleLineworkHalfEdge{ + edge: edgeIndex, reverse: true, + }) + } + return nodes, edges, loops +} + +func visibleLineworkNodeIndex( + nodes []visibleLineworkNode, + chart polygonUnionChart, + projected GeoPoint, + snapDistanceKM float64, +) int { + target := chart.unproject(projected) + bestIndex := -1 + bestDistance := math.Inf(1) + for index, node := range nodes { + distance := geoPointDistanceKM(chart.unproject(node.point), target) + if distance <= snapDistanceKM && distance < bestDistance { + bestIndex, bestDistance = index, distance + } + } + return bestIndex +} + +func visibleLineworkDirectedPoints(edge visibleLineworkEdge, reverse bool) []GeoPoint { + if !reverse { + return edge.points + } + points := make([]GeoPoint, len(edge.points)) + for index := range edge.points { + points[index] = edge.points[len(edge.points)-1-index] + } + return points +} + +func visibleLineworkSignedArea(polygon []GeoPoint) float64 { + if len(polygon) < 3 { + return 0 + } + area := 0.0 + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + area += polygon[previous].Longitude*polygon[current].Latitude - + polygon[current].Longitude*polygon[previous].Latitude + } + return area / 2 +} diff --git a/internal/geodata/linework_test.go b/internal/geodata/linework_test.go new file mode 100644 index 0000000..06e5b58 --- /dev/null +++ b/internal/geodata/linework_test.go @@ -0,0 +1,354 @@ +package geodata + +import ( + "math" + "testing" +) + +func TestVisibleLineworkFacesSeparateTouchingLoops(t *testing.T) { + lines := [][]GeoPoint{ + {{0, 0}, {-2, 0}, {-2, 2}, {0, 2}, {0, 0}}, + {{0, 0}, {2, 0}, {2, -2}, {0, -2}, {0, 0}}, + {{0, 0}, {1, 1}}, + } + chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}} + nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1) + cycles := enumerateVisibleLineworkCycles(nodes, edges) + if len(cycles) != 2 { + t.Fatalf("got %d cycles, want two lobes without the dangling bridge", len(cycles)) + } + for _, ring := range cycles { + if len(ring) != 4 || math.Abs(math.Abs(visibleLineworkSignedArea(ring))-4) > 1e-10 { + t.Fatalf("face contains a repeated articulation or bridge: %+v", ring) + } + } +} + +func TestVisibleLineworkFaceTraversalIsBounded(t *testing.T) { + var lines [][]GeoPoint + for row := 0; row < 12; row++ { + for col := 0; col < 12; col++ { + x, y := float64(col), float64(row) + if col < 11 { + lines = append(lines, []GeoPoint{{x, y}, {x + 1, y}}) + } + if row < 11 { + lines = append(lines, []GeoPoint{{x, y}, {x, y + 1}}) + } + } + } + chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}} + nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1) + cycles := enumerateVisibleLineworkCycles(nodes, edges) + if len(cycles) != 122 { + t.Fatalf("got %d cycles, want 121 cells and one exterior", len(cycles)) + } +} + +func TestVisibleLineworkKeepsDistinctClosedJunctions(t *testing.T) { + var lines [][]GeoPoint + for _, longitude := range []float64{0, 1.001} { + ring := []GeoPoint{{longitude, 0}, {longitude + 1, 0}, {longitude + 1, 1}, {longitude, 1}} + for i, point := range ring { + lines = append(lines, []GeoPoint{point, ring[(i+1)%len(ring)]}) + } + } + chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}} + nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 25) + if len(nodes) != 8 || len(edges) != 8 { + t.Fatalf("independent closed junctions merged: nodes=%d edges=%d", len(nodes), len(edges)) + } +} + +func TestVisibleLineworkProjectionPreservesSphericalArc(t *testing.T) { + chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}} + start, end := GeoPoint{-20, 60}, GeoPoint{20, 60} + line := projectVisibleLine(chart, []GeoPoint{start, end}) + for i := 0; i <= 100; i++ { + point := chart.project(sphericalInterpolate(start, end, float64(i)/100)) + minimum := math.Inf(1) + for j := 1; j < len(line); j++ { + minimum = math.Min(minimum, visibleLineworkPointSegmentDistanceSquared(point, line[j-1], line[j])) + } + if minimum > 0.003*0.003 { + t.Fatalf("projected spherical arc leaves line by %.6f degrees", math.Sqrt(minimum)) + } + } +} + +func TestVisibleLineworkPolygonsSelectsFilledFaces(t *testing.T) { + lines := [][]GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}}, + {{Longitude: 4, Latitude: 0}, {Longitude: 4, Latitude: 4}}, + {{Longitude: 4, Latitude: 4}, {Longitude: 0, Latitude: 4}}, + {{Longitude: 0, Latitude: 4}, {Longitude: 0, Latitude: 0}}, + {{Longitude: 2, Latitude: 0}, {Longitude: 2, Latitude: 4}}, + } + fill := [][]GeoPoint{{ + {Longitude: 0, Latitude: 0}, + {Longitude: 4, Latitude: 0}, + {Longitude: 4, Latitude: 4}, + {Longitude: 0, Latitude: 4}, + }} + polygons, err := VisibleLineworkPolygons(lines, fill, fill, 1) + if err != nil { + t.Fatalf("VisibleLineworkPolygons: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("polygon count=%d, want the two selected faces merged", len(polygons)) + } + for _, point := range []GeoPoint{{Longitude: 1, Latitude: 2}, {Longitude: 3, Latitude: 2}} { + if !sphericalPolygonContainsOrTouches(polygons[0], point) { + t.Fatalf("merged visible linework misses %+v", point) + } + } +} + +func TestVisibleLineworkBoundaryBridgePreservesReverseDirection(t *testing.T) { + line := []GeoPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 1, Latitude: 0}, + {Longitude: 2, Latitude: 0}, + {Longitude: 3, Latitude: 0}, + } + start, end := line[len(line)-1], line[0] + bridge, ok := visibleLineworkBoundaryBridge(start, end, [][]GeoPoint{line}, 120) + if !ok { + t.Fatal("visibleLineworkBoundaryBridge() returned no bridge") + } + if !SameGeoPoint(bridge[0], start) || !SameGeoPoint(bridge[len(bridge)-1], end) { + t.Fatalf("bridge endpoints=%+v -> %+v, want %+v -> %+v", bridge[0], bridge[len(bridge)-1], start, end) + } + if len(bridge) != len(line) { + t.Fatalf("bridge points=%d, want %d", len(bridge), len(line)) + } + for index, point := range bridge { + if !SameGeoPoint(point, line[len(line)-1-index]) { + t.Fatalf("bridge[%d]=%+v, want reverse source point %+v", index, point, line[len(line)-1-index]) + } + } +} + +func TestVisibleLineworkPolygonsSnapsNearbyJunctions(t *testing.T) { + lines := [][]GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}}, + {{Longitude: 4.001, Latitude: 0}, {Longitude: 4, Latitude: 4}}, + {{Longitude: 4, Latitude: 4}, {Longitude: 0, Latitude: 4}}, + {{Longitude: 0, Latitude: 4}, {Longitude: 0, Latitude: 0.001}}, + } + fill := [][]GeoPoint{{ + {Longitude: 0, Latitude: 0}, + {Longitude: 4, Latitude: 0}, + {Longitude: 4, Latitude: 4}, + {Longitude: 0, Latitude: 4}, + }} + if _, err := VisibleLineworkPolygons(lines, fill, fill, 1); err != nil { + t.Fatalf("VisibleLineworkPolygons did not snap sub-kilometer junctions: %v", err) + } +} + +func TestVisibleLineworkPolygonsKeepsSnapAndFillAuditTolerancesIndependent(t *testing.T) { + lines := [][]GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}}, + {{Longitude: 4, Latitude: 0}, {Longitude: 4, Latitude: 4}}, + {{Longitude: 4, Latitude: 4}, {Longitude: 0, Latitude: 4}}, + {{Longitude: 0, Latitude: 4}, {Longitude: 0, Latitude: 0}}, + } + fill := [][]GeoPoint{{ + {Longitude: -0.02, Latitude: 0}, + {Longitude: 4, Latitude: 0}, + {Longitude: 4, Latitude: 4}, + {Longitude: -0.02, Latitude: 4}, + }} + coverage := [][]GeoPoint{{{Longitude: 2, Latitude: 2}}} + if _, err := VisibleLineworkPolygons(lines, fill, coverage, 1); err == nil { + t.Fatal("default fill audit unexpectedly accepted a 2 km source residual") + } + polygons, err := VisibleLineworkPolygonsWithAuditTolerance(lines, fill, coverage, 1, 3) + if err != nil { + t.Fatalf("independent fill audit rejected a bounded source residual: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("polygon count=%d, want one without increasing graph snap distance", len(polygons)) + } +} + +func TestVisibleLineworkPolygonIndexMatchesPointInPolygon(t *testing.T) { + polygon := []GeoPoint{ + {Longitude: -2, Latitude: -2}, + {Longitude: 2, Latitude: -2}, + {Longitude: 2, Latitude: 2}, + {Longitude: -2, Latitude: 2}, + } + index := newVisibleLineworkPolygonIndex(polygon) + for longitude := -3.0; longitude <= 3; longitude += 0.25 { + for latitude := -3.0; latitude <= 3; latitude += 0.25 { + point := GeoPoint{Longitude: longitude, Latitude: latitude} + want := visibleLineworkContainsWithin(polygon, point, 0.05) + got := visibleLineworkContainsWithinIndexed(&index, point, 0.05) + if got != want { + t.Fatalf("indexed containment for %+v=%v, want %v", point, got, want) + } + } + } +} + +func TestSplitVisibleLineworkIntersectionsCreatesSharedGraphNode(t *testing.T) { + lines := [][]GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 10, Latitude: 10}}, + {{Longitude: 0, Latitude: 10}, {Longitude: 10, Latitude: 0}}, + } + split := splitVisibleLineworkIntersections(lines) + if len(split) != 4 { + t.Fatalf("split line count=%d, want four half-edges: %#v", len(split), split) + } + junctionCount := 0 + for _, line := range split { + for _, point := range []GeoPoint{line[0], line[len(line)-1]} { + if math.Abs(point.Longitude-5) <= 1e-9 && math.Abs(point.Latitude-5) <= 1e-9 { + junctionCount++ + } + } + } + if junctionCount != 4 { + t.Fatalf("intersection endpoint count=%d, want four", junctionCount) + } +} + +func TestVisibleLineworkArcIndexMatchesSourceEdgeDistance(t *testing.T) { + line := []GeoPoint{ + {Longitude: 179, Latitude: 70}, + {Longitude: -179, Latitude: 71}, + } + index := newVisibleLineworkArcIndex([][]GeoPoint{line}) + query := sphericalInterpolate(line[0], line[1], 0.5) + query.Latitude += 0.05 + want := sphericalPointArcDistanceKM(query, line[0], line[1]) + + for _, endpoints := range [][2]GeoPoint{ + {line[0], line[1]}, + {line[1], line[0]}, + { + {Longitude: line[0].Longitude + 2e-9, Latitude: line[0].Latitude}, + {Longitude: line[1].Longitude, Latitude: line[1].Latitude - 2e-9}, + }, + } { + got, matched := index.edgePointDistanceKM(endpoints[0], endpoints[1], query) + if !matched { + t.Fatalf("indexed source edge was not matched: %+v", endpoints) + } + if math.Abs(got-want) > 1e-6 { + t.Fatalf("indexed edge distance=%.12f km, want %.12f km", got, want) + } + } + + if _, matched := index.edgePointDistanceKM( + line[0], GeoPoint{Longitude: -178, Latitude: 71}, query, + ); matched { + t.Fatal("non-source edge unexpectedly matched the source index") + } +} + +func TestVisibleLineworkBoundarySourceMissDistanceDoesNotHideLongChord(t *testing.T) { + boundaryLines := [][]GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 0.5, Latitude: 0}}, + {{Longitude: 1.5, Latitude: 0}, {Longitude: 2, Latitude: 0}}, + } + // Both endpoints are source vertices, but the connecting edge is not a + // source arc. Its midpoint is about 55 km from either source segment. + polygons := [][]GeoPoint{{ + {Longitude: 0, Latitude: 0}, + {Longitude: 2, Latitude: 0}, + }} + if miss := visibleLineworkBoundarySourceMissDistanceKM(polygons, boundaryLines); miss < 50 { + t.Fatalf("source miss distance=%.1f km, want midpoint deviation to be retained", miss) + } +} + +func TestVisibleLineworkNodesTJunctionEndpoint(t *testing.T) { + // 端点落在另一段内部(T 型交点):参数解会被 1e-9 的分数容差拒绝,而图幅旋转 + // 带来的误差约 2.4e-6 度,于是两个面会被并成一个。这里用一个 8e-7 度(约 9 厘米) + // 的偏离复现该量级。 + // An endpoint on another segment's interior: the parametric solve rejects it with its + // 1e-9 fraction tolerance while chart rotation carries about 2.4e-6 degrees of error, so + // the two faces used to merge. The offset below (8e-7 degrees, about 9 cm) reproduces + // that magnitude. + horizontal := []GeoPoint{{Longitude: -1, Latitude: 0}, {Longitude: 1, Latitude: 0}} + // 端点离水平段 8e-7 度(约 9 厘米):参数解把它算成 −8e-7 的分数,被 1e-9 容差拒绝。 + // The endpoint sits 8e-7 degrees (about 9 cm) off the horizontal line, which the + // parametric solve turns into a fraction of -8e-7 and rejects against its 1e-9 + // tolerance. + vertical := []GeoPoint{{Longitude: 8e-7, Latitude: 8e-7}, {Longitude: 8e-7, Latitude: 1}} + // 水平段在 T 点被切成两段,竖直段保持一条 ⇒ 2 条线变 3 条线。 + // The horizontal line is cut at the T point while the vertical one stays whole, so two + // input lines become three. + split := splitVisibleLineworkIntersections([][]GeoPoint{horizontal, vertical}) + if len(split) != 3 { + t.Fatalf("lines=%d (%v), want the horizontal line split at the T junction", len(split), split) + } + outerEnds := 0 + for _, line := range split { + if len(line) != 2 { + continue + } + for _, point := range line { + // 水平段被切成两半后,每半各保留一个 lon=±1、lat=0 的外端。 + // After the cut each half keeps exactly one outer end at lon=±1, lat=0. + if math.Abs(math.Abs(point.Longitude)-1) < 1e-12 && math.Abs(point.Latitude) < 1e-12 { + outerEnds++ + } + } + } + if outerEnds != 2 { + t.Fatalf("horizontal halves keeping an outer end = %d, want 2 (%v)", outerEnds, split) + } + // 真分离(起点离水平段约 1.1 公里)不得被打成节点。 + // A genuine separation (the endpoint sits about 1.1 km off the horizontal line) must + // not be noded. + far := []GeoPoint{{Longitude: 0.01, Latitude: 0.01}, {Longitude: 0.01, Latitude: 1}} + splitFar := splitVisibleLineworkIntersections([][]GeoPoint{horizontal, far}) + if len(splitFar) != 2 { + t.Fatalf("a 1 km separation was noded: %v", splitFar) + } +} + +func TestVisibleLineworkPolygonsRejectsInfiniteTolerances(t *testing.T) { + lines := [][]GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}}, + {{Longitude: 4, Latitude: 0}, {Longitude: 4, Latitude: 4}}, + {{Longitude: 4, Latitude: 4}, {Longitude: 0, Latitude: 4}}, + {{Longitude: 0, Latitude: 4}, {Longitude: 0, Latitude: 0}}, + } + fill := [][]GeoPoint{{ + {Longitude: 0, Latitude: 0}, + {Longitude: 4, Latitude: 0}, + {Longitude: 4, Latitude: 4}, + {Longitude: 0, Latitude: 4}, + }} + if _, err := VisibleLineworkPolygons(lines, fill, fill, math.Inf(1)); err == nil { + t.Fatal("infinite snap distance was accepted") + } + if _, err := VisibleLineworkPolygonsWithAuditTolerance(lines, fill, fill, 1, math.Inf(1)); err == nil { + t.Fatal("infinite fill audit tolerance was accepted") + } +} + +func TestVisibleLineworkContainsWithinIndexedHandlesNilIndex(t *testing.T) { + if visibleLineworkContainsWithinIndexed(nil, GeoPoint{Longitude: 1, Latitude: 1}, 0.5) { + t.Fatal("nil index reported containment") + } +} + +func TestVisibleLineworkCyclesDeduplicateReversedFaces(t *testing.T) { + lines := [][]GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 2, Latitude: 0}}, + {{Longitude: 2, Latitude: 0}, {Longitude: 2, Latitude: 2}}, + {{Longitude: 2, Latitude: 2}, {Longitude: 0, Latitude: 2}}, + {{Longitude: 0, Latitude: 2}, {Longitude: 0, Latitude: 0}}, + } + chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}} + nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1) + if cycles := enumerateVisibleLineworkCycles(nodes, edges); len(cycles) != 1 { + t.Fatalf("got %d cycles, want one square counted once for both directions", len(cycles)) + } +} diff --git a/internal/geodata/model.go b/internal/geodata/model.go index bc38e5c..aebc3a9 100644 --- a/internal/geodata/model.go +++ b/internal/geodata/model.go @@ -9,8 +9,24 @@ const ( ProjectionEquirectangular Projection = "equirectangular" ProjectionNorthPolar Projection = "north-polar" ProjectionSouthPolar Projection = "south-polar" + // ProjectionOrthographic 是正射(球面)投影,只画朝向视点的半个地球。 + // ProjectionOrthographic is the orthographic (globe) projection, drawing only the hemisphere facing the view point. + ProjectionOrthographic Projection = "orthographic" ) +// ClipView 是裁剪所依赖的投影及其参数 / ClipView carries the projection and the parameters clipping depends on. +type ClipView struct { + Projection Projection + // Center 是正射投影的视点,也就是可见半球的中心;其他投影忽略它。 + // Center is the orthographic view point, the middle of the visible hemisphere; other projections ignore it. + Center GeoPoint +} + +// Orthographic 判断视图是否为正射投影 / Orthographic reports whether the view uses the orthographic projection. +func (view ClipView) Orthographic() bool { + return view.Projection == ProjectionOrthographic +} + // GeoPoint 是以度表示的地理点,东经为正 / GeoPoint is a geographic point in degrees, with east longitude positive. type GeoPoint struct { Longitude float64 diff --git a/internal/geodata/nonfinite_test.go b/internal/geodata/nonfinite_test.go new file mode 100644 index 0000000..0b2b550 --- /dev/null +++ b/internal/geodata/nonfinite_test.go @@ -0,0 +1,96 @@ +package geodata + +import ( + "math" + "testing" +) + +// 裁剪入口对非有限坐标必须快速失败而不能死循环;超出 ±180 的有限经度按同一子午线归一化。 +func TestTopologyRejectsNonFiniteCoordinates(t *testing.T) { + cases := []struct { + name string + points []GeoPoint + }{ + {"huge-longitude", []GeoPoint{{0, 0}, {1e300, 10}, {20, 20}}}, + {"positive-infinity-longitude", []GeoPoint{{0, 0}, {math.Inf(1), 10}, {20, 20}}}, + {"negative-infinity-longitude", []GeoPoint{{0, 0}, {math.Inf(-1), 10}, {20, 20}}}, + {"nan-longitude", []GeoPoint{{0, 0}, {math.NaN(), 10}, {20, 20}}}, + {"nan-latitude", []GeoPoint{{0, 0}, {10, math.NaN()}, {20, 20}}}, + {"infinity-latitude", []GeoPoint{{0, 0}, {10, math.Inf(1)}, {20, 20}}}, + } + views := []ClipView{ + {}, + {Projection: ProjectionEquirectangular}, + {Projection: ProjectionEquirectangular, Center: GeoPoint{Longitude: 100}}, + {Projection: ProjectionNorthPolar}, + {Projection: ProjectionSouthPolar}, + {Projection: ProjectionOrthographic, Center: GeoPoint{Longitude: 104, Latitude: -1.5}}, + } + for _, testCase := range cases { + wantsRejection := testCase.name != "huge-longitude" + for _, view := range views { + fragments := PolygonFragments(testCase.points, view) + segments := PolylineSegments(testCase.points, view) + if wantsRejection { + if len(fragments) != 0 || len(segments) != 0 { + t.Fatalf("%s %s: fragments=%d segments=%d, want none", + testCase.name, view.Projection, len(fragments), len(segments)) + } + continue + } + checkFinite := func(points []GeoPoint) { + for _, point := range points { + if math.IsNaN(point.Longitude) || math.IsNaN(point.Latitude) || + math.IsInf(point.Longitude, 0) || math.IsInf(point.Latitude, 0) || + math.Abs(point.Longitude) > 180 { + t.Fatalf("%s %s: non-finite or unnormalized point %+v", testCase.name, view.Projection, point) + } + } + } + for _, fragment := range fragments { + checkFinite(fragment) + } + for _, segment := range segments { + checkFinite(segment) + } + } + } +} + +func TestTopologyNormalizesOutOfRangeLongitudeToSameMeridian(t *testing.T) { + views := []ClipView{ + {}, + {Projection: ProjectionEquirectangular, Center: GeoPoint{Longitude: 30}}, + {Projection: ProjectionNorthPolar}, + } + cases := [][2][]GeoPoint{ + { + {{0, 0}, {190, 10}, {20, 20}}, + {{0, 0}, {-170, 10}, {20, 20}}, + }, + { + {{350, -5}, {190, 10}, {181, 20}}, + {{-10, -5}, {-170, 10}, {-179, 20}}, + }, + } + for _, pair := range cases { + for _, view := range views { + got := PolygonFragments(pair[0], view) + want := PolygonFragments(pair[1], view) + if len(got) != len(want) { + t.Fatalf("%s: fragment count %d, want %d", view.Projection, len(got), len(want)) + } + for index := range got { + if len(got[index]) != len(want[index]) { + t.Fatalf("%s: fragment %d length %d, want %d", view.Projection, index, len(got[index]), len(want[index])) + } + for point := range got[index] { + if got[index][point] != want[index][point] { + t.Fatalf("%s: fragment %d point %d = %+v, want %+v", + view.Projection, index, point, got[index][point], want[index][point]) + } + } + } + } + } +} diff --git a/internal/geodata/orthographic.go b/internal/geodata/orthographic.go new file mode 100644 index 0000000..f0959b7 --- /dev/null +++ b/internal/geodata/orthographic.go @@ -0,0 +1,239 @@ +package geodata + +import "math" + +// rad 是度到弧度的换算因子 / rad converts degrees to radians. +const rad = math.Pi / 180 + +const ( + // orthographicRimSteps 是视界闭合弧与整盘回退环的加密段数。 + orthographicRimSteps = 180 + // orthographicCrossingIterations 是视界交点的二分次数,1e-12 弧度量级足够。 + orthographicCrossingIterations = 48 + // orthographicRunCapacity 是单个可见段的初始容量:段长与环长无关, + // 按环长预分配会让反复穿越视界的环退化成 O(段数×环长) 的内存。 + orthographicRunCapacity = 8 +) + +// OrthographicDiskPoint 把点正射投影到可见半球的单位圆盘,x 向东、y 向北。 +// 第二个返回值是深度余弦;false 表示点落在背面,不与可见半球构成一一映射。 +// OrthographicDiskPoint projects a point onto the unit disk of the visible hemisphere, x east and y north. +func OrthographicDiskPoint(point, center GeoPoint) (float64, float64, bool) { + longitude := (point.Longitude - center.Longitude) * rad + latitude := point.Latitude * rad + centerLatitude := center.Latitude * rad + cosLatitude, sinLatitude := math.Cos(latitude), math.Sin(latitude) + sinCenter, cosCenter := math.Sin(centerLatitude), math.Cos(centerLatitude) + cosine := sinCenter*sinLatitude + cosCenter*cosLatitude*math.Cos(longitude) + // 视界本身(余弦为 0)映射到圆盘边界,必须可投影;只有严格背面才折叠到盘内。 + if cosine < -1e-9 { + return 0, 0, false + } + return cosLatitude * math.Sin(longitude), + cosCenter*sinLatitude - sinCenter*cosLatitude*math.Cos(longitude), true +} + +// orthographicDepth 返回点相对视点的深度余弦,正值表示在可见半球上。 +func orthographicDepth(point, center GeoPoint) float64 { + return geoVectorDot(geoPointVector(point), geoPointVector(center)) +} + +// orthographicCrossing 二分求线段与视界大圆的交点;两端同侧时返回 false。 +func orthographicCrossing(first, second GeoPoint, center GeoPoint) (GeoPoint, bool) { + firstDepth := orthographicDepth(first, center) + secondDepth := orthographicDepth(second, center) + if firstDepth == 0 { + return first, true + } + if secondDepth == 0 { + return second, true + } + if (firstDepth > 0) == (secondDepth > 0) { + return GeoPoint{}, false + } + // 收敛到起点那一侧的边界:可见性仍与起点相同就往后挪,翻转了就往前收。 + firstVisible := firstDepth > 0 + low, high := 0.0, 1.0 + for iteration := 0; iteration < orthographicCrossingIterations; iteration++ { + middle := (low + high) / 2 + if (orthographicDepth(InterpolateGreatCircle(first, second, middle), center) > 0) == firstVisible { + low = middle + } else { + high = middle + } + } + return InterpolateGreatCircle(first, second, (low+high)/2), true +} + +// clipPolylineOrthographic 把折线裁到可见半球,并在视界处插入精确交点。 +func clipPolylineOrthographic(points []GeoPoint, center GeoPoint) [][]GeoPoint { + if len(points) == 0 { + return nil + } + segments := make([][]GeoPoint, 0, 2) + current := make([]GeoPoint, 0, len(points)) + for index, point := range points { + if orthographicDepth(point, center) > 0 { + if len(current) == 0 && index > 0 { + if crossing, ok := orthographicCrossing(points[index-1], point, center); ok { + current = append(current, crossing) + } + } + current = append(current, point) + continue + } + if len(current) > 0 { + if crossing, ok := orthographicCrossing(points[index-1], point, center); ok { + current = append(current, crossing) + } + if len(current) >= 2 { + segments = append(segments, current) + } + current = nil + } + } + if len(current) >= 2 { + segments = append(segments, current) + } + return segments +} + +// orthographicRimArc 沿视界大圆从起点加密到终点;long 为 true 时走另一侧的长弧。 +// 视界大圆的法线就是视点方向,必须绕它旋转:两端接近对径时 cross(起点, 终点) 会退化成零向量, +// 那样闭合弧会塌成一条横穿圆盘的直线弦。 +func orthographicRimArc(from, to GeoPoint, center GeoPoint, long bool) []GeoPoint { + axis := geoPointVector(center) + startVector, ok := geoVectorNormalize(geoVectorAdd( + geoPointVector(from), + geoVectorScale(axis, -geoVectorDot(geoPointVector(from), axis)), + )) + if !ok { + return []GeoPoint{from, to} + } + tangent := geoVectorCross(axis, startVector) + endVector := geoPointVector(to) + signed := math.Atan2(geoVectorDot(endVector, tangent), geoVectorDot(endVector, startVector)) + begin, span := 0.0, signed + if long { + turn := 2 * math.Pi + if signed < 0 { + turn = -2 * math.Pi + } + begin, span = signed, turn-signed + } + arc := make([]GeoPoint, 0, orthographicRimSteps+1) + for step := 0; step <= orthographicRimSteps; step++ { + angle := begin + span*float64(step)/orthographicRimSteps + arc = append(arc, geoVectorPoint(geoVectorAdd( + geoVectorScale(startVector, math.Cos(angle)), + geoVectorScale(tangent, math.Sin(angle)), + ))) + } + return arc +} + +// orthographicRimInside 判断某段视界弧是否紧邻环的内部:把弧中点朝可见半球内侧挪一点再看它落在哪一侧。 +func orthographicRimInside(arc []GeoPoint, ring []GeoPoint, center GeoPoint) bool { + if len(arc) == 0 { + return false + } + middle := geoPointVector(arc[len(arc)/2]) + inside := geoVectorAdd(middle, geoVectorScale(geoPointVector(center), 1e-3)) + probe, ok := geoVectorNormalize(inside) + if !ok { + return false + } + return sphericalPolygonContainsOrTouches(ring, geoVectorPoint(probe)) +} + +// closeOrthographicRun 把一段可见折线沿视界大圆闭合回起点,闭合弧取紧邻环内部的那一侧。 +func closeOrthographicRun(run, ring []GeoPoint, center GeoPoint) []GeoPoint { + if len(run) < 2 { + return nil + } + exit, entry := run[len(run)-1], run[0] + shortArc := orthographicRimArc(exit, entry, center, false) + longArc := orthographicRimArc(exit, entry, center, true) + arc := shortArc + switch { + case orthographicRimInside(shortArc, ring, center): + case orthographicRimInside(longArc, ring, center): + arc = longArc + } + closed := make([]GeoPoint, 0, len(run)+len(arc)) + closed = append(closed, run...) + closed = append(closed, arc[1:len(arc)-1]...) + return closed +} + +// polygonFragmentsOrthographic 把环裁到可见半球,并沿视界大圆闭合被切断的部分。 +func polygonFragmentsOrthographic(points []GeoPoint, center GeoPoint) [][]GeoPoint { + if len(points) < 3 { + return nil + } + visible := 0 + for _, point := range points { + if orthographicDepth(point, center) > 0 { + visible++ + } + } + if visible == len(points) { + return [][]GeoPoint{points} + } + if visible == 0 { + // 整环都在背面:只有把视点包在环内的环,其内部才会覆盖整个可见半球——否则可见部分为空。 + if sphericalPolygonContainsOrTouches(points, center) { + return [][]GeoPoint{SphericalCircle(center, 90, orthographicRimSteps)} + } + return nil + } + // 逐边展开成"顶点 + 视界交点"序列,再按可见性切段;闭合环首尾相接,所以按环遍历。 + type rimNode struct { + point GeoPoint + visible bool + } + nodes := make([]rimNode, 0, 2*len(points)) + for index := 0; index < len(points); index++ { + first := points[index] + second := points[(index+1)%len(points)] + firstVisible := orthographicDepth(first, center) > 0 + secondVisible := orthographicDepth(second, center) > 0 + nodes = append(nodes, rimNode{point: first, visible: firstVisible}) + if firstVisible != secondVisible { + if crossing, ok := orthographicCrossing(first, second, center); ok { + // 交点落在视界上,两侧的可见段都要以它收尾/起头,所以它恒属于可见段。 + nodes = append(nodes, rimNode{point: crossing, visible: true}) + } + } + } + runs := make([][]GeoPoint, 0, 4) + current := make([]GeoPoint, 0, orthographicRunCapacity) + for _, node := range nodes { + if node.visible { + current = append(current, node.point) + continue + } + if len(current) >= 2 { + runs = append(runs, current) + } + current = make([]GeoPoint, 0, orthographicRunCapacity) + } + if len(current) >= 2 { + runs = append(runs, current) + } + // 环首尾相接:起点本身可见时,同一段可见区间会被切成首尾两段,必须先接回来再闭合。 + if len(runs) >= 2 && nodes[0].visible && nodes[len(nodes)-1].visible { + merged := make([]GeoPoint, 0, len(runs[0])+len(runs[len(runs)-1])) + merged = append(merged, runs[len(runs)-1]...) + merged = append(merged, runs[0]...) + runs[0] = merged + runs = runs[:len(runs)-1] + } + fragments := make([][]GeoPoint, 0, len(runs)) + for _, run := range runs { + if closed := closeOrthographicRun(run, points, center); len(closed) >= 3 { + fragments = append(fragments, closed) + } + } + return fragments +} diff --git a/internal/geodata/orthographic_test.go b/internal/geodata/orthographic_test.go new file mode 100644 index 0000000..785f9d7 --- /dev/null +++ b/internal/geodata/orthographic_test.go @@ -0,0 +1,207 @@ +package geodata + +import ( + "math" + "runtime" + "testing" +) + +func orthographicTestContainsRing(ring []GeoPoint, point GeoPoint) bool { + return sphericalPolygonContainsOrTouches(ring, point) +} + +// 采样点若紧贴环边界,容差内外的判定会摇摆;这类点不参与比对。 +func orthographicTestBorderline(ring []GeoPoint, point GeoPoint) bool { + base := orthographicTestContainsRing(ring, point) + for _, bearing := range []float64{0, 90, 180, 270} { + offset := InterpolateGreatCircle(point, GeoPoint{ + Longitude: normalizeLongitude(point.Longitude + 0.05*math.Cos(bearing*rad)), + Latitude: point.Latitude + 0.05*math.Sin(bearing*rad), + }, 0.001) + if orthographicTestContainsRing(ring, offset) != base { + return true + } + } + return false +} + +func orthographicTestPlanarContains(polygon [][2]float64, x, y float64) bool { + inside := false + for index, current := range polygon { + previous := polygon[(index+len(polygon)-1)%len(polygon)] + if (current[1] > y) != (previous[1] > y) { + span := previous[1] - current[1] + if span != 0 { + if current[0]+(y-current[1])/span*(previous[0]-current[0]) < x { + inside = !inside + } + } + } + } + return inside +} + +func orthographicTestFillMatches(t *testing.T, name string, ring []GeoPoint, center GeoPoint) { + t.Helper() + fragments := polygonFragmentsOrthographic(ring, center) + projected := make([][][2]float64, 0, len(fragments)) + for _, fragment := range fragments { + polygon := make([][2]float64, 0, len(fragment)) + for _, point := range fragment { + x, y, ok := OrthographicDiskPoint(point, center) + if !ok { + t.Fatalf("%s: fragment point %v is on the back hemisphere", name, point) + } + polygon = append(polygon, [2]float64{x, y}) + } + projected = append(projected, polygon) + } + checked, mismatches := 0, 0 + state := uint64(20260916) + for sample := 0; sample < 4000; sample++ { + state = state*6364136223846793005 + 1442695040888963407 + latitude := float64(int64(state>>11)%18000)/100 - 90 + state = state*6364136223846793005 + 1442695040888963407 + longitude := float64(int64(state>>11)%36000)/100 - 180 + point := GeoPoint{Longitude: longitude, Latitude: latitude} + x, y, visible := OrthographicDiskPoint(point, center) + if !visible { + continue + } + // 视界闭合弧是折线,紧贴视界的一薄层(约 1°)落在弦与圆弧之间,判定本就有歧义; + // 在 600 像素的球面图上这一层不足 0.1 像素,不参与比对。 + if orthographicDepth(point, center) < 0.02 { + continue + } + if orthographicTestBorderline(ring, point) { + continue + } + expected := orthographicTestContainsRing(ring, point) + got := false + for _, polygon := range projected { + if orthographicTestPlanarContains(polygon, x, y) { + got = !got + } + } + checked++ + if expected != got { + mismatches++ + if mismatches <= 3 { + t.Errorf("%s: point %.3f,%.3f expected %v got %v", name, longitude, latitude, expected, got) + } + } + } + if checked < 200 { + t.Fatalf("%s: only %d samples usable", name, checked) + } + t.Logf("%s: %d samples, %d mismatches, fragments=%d", name, checked, mismatches, len(fragments)) + if mismatches != 0 { + t.Fatalf("%s: %d/%d samples disagree with spherical containment", name, mismatches, checked) + } +} + +// 裁剪后的填充必须与"球面包含且位于可见半球"完全一致,这同时验证了视界闭合弧的取侧。 +func TestOrthographicPolygonFragmentsFillMatchesContainment(t *testing.T) { + center := GeoPoint{Longitude: 144.1, Latitude: 24.2} + for _, fixture := range []struct { + name string + ring []GeoPoint + }{ + {name: "visible", ring: SphericalCircle(GeoPoint{Longitude: 140, Latitude: 30}, 25, 180)}, + {name: "straddling", ring: SphericalCircle(GeoPoint{Longitude: 60, Latitude: 40}, 35, 180)}, + {name: "behind", ring: SphericalCircle(GeoPoint{Longitude: -40, Latitude: -30}, 20, 180)}, + {name: "encircling", ring: SphericalCircle(center, 100, 240)}, + {name: "antipodal-cap", ring: SphericalCircle(GeoPoint{Longitude: -35.9, Latitude: -24.2}, 20, 180)}, + {name: "limb-hugging", ring: SphericalCircle(GeoPoint{Longitude: 100, Latitude: 60}, 60, 240)}, + } { + t.Run(fixture.name, func(t *testing.T) { + orthographicTestFillMatches(t, fixture.name, fixture.ring, center) + }) + } +} + +// 折线裁剪只保留可见段,且两端恰好落在视界上。 +func TestOrthographicPolylineSegmentsEndOnLimb(t *testing.T) { + center := GeoPoint{Longitude: 0, Latitude: 0} + points := []GeoPoint{ + {Longitude: -120, Latitude: 10}, + {Longitude: 0, Latitude: 0}, + {Longitude: 120, Latitude: -10}, + } + segments := clipPolylineOrthographic(points, center) + if len(segments) != 1 { + t.Fatalf("expected one visible segment, got %d", len(segments)) + } + segment := segments[0] + first, last := segment[0], segment[len(segment)-1] + if depth := math.Abs(orthographicDepth(first, center)); depth > 1e-9 { + t.Fatalf("segment start is not on the limb: depth=%g", depth) + } + if depth := math.Abs(orthographicDepth(last, center)); depth > 1e-9 { + t.Fatalf("segment end is not on the limb: depth=%g", depth) + } + for _, point := range segment { + if orthographicDepth(point, center) < -1e-12 { + t.Fatalf("segment keeps a back-hemisphere point: %v", point) + } + } +} + +// 视点自身投影到盘心,与之相距 90° 的点落在盘边。 +func TestOrthographicDiskPointReferenceCases(t *testing.T) { + center := GeoPoint{Longitude: 144.1, Latitude: 24.2} + if x, y, ok := OrthographicDiskPoint(center, center); !ok || math.Hypot(x, y) > 1e-12 { + t.Fatalf("center projects to %g,%g ok=%v", x, y, ok) + } + // 视界是与视点相距 90° 的大圆;同纬度加 90° 经度并不等于 90° 球面距离。 + centerVector := geoPointVector(center) + axis := geoVector3{x: 0, y: 0, z: 1} + if math.Abs(geoVectorDot(axis, centerVector)) > 0.9 { + axis = geoVector3{x: 1, y: 0, z: 0} + } + east, ok := geoVectorNormalize(geoVectorAdd(axis, geoVectorScale(centerVector, -geoVectorDot(axis, centerVector)))) + if !ok { + t.Fatal("degenerate tangent basis") + } + onLimbPoints := []GeoPoint{geoVectorPoint(east), geoVectorPoint(geoVectorScale(east, -1))} + north := geoVectorCross(east, centerVector) + onLimbPoints = append(onLimbPoints, geoVectorPoint(north), geoVectorPoint(geoVectorScale(north, -1))) + for _, onLimb := range onLimbPoints { + x, y, ok := OrthographicDiskPoint(onLimb, center) + if !ok || math.Abs(math.Hypot(x, y)-1) > 1e-9 { + t.Fatalf("%v should sit on the limb, got %g,%g ok=%v", onLimb, x, y, ok) + } + } + if _, _, ok := OrthographicDiskPoint(GeoPoint{Longitude: -35.9, Latitude: -24.2}, center); ok { + t.Fatal("the antipode must not be visible") + } +} + +// 反复穿越视界的环会产生大量短可见段;单段预分配若按环长给容量,分配量会退化成 O(段数×环长)。 +func TestOrthographicFragmentsBoundAllocationForManyShortRuns(t *testing.T) { + const cycles = 500 + ring := make([]GeoPoint, 0, 4*cycles) + for index := 0; index < cycles; index++ { + ring = append(ring, + GeoPoint{Longitude: 80, Latitude: 0}, + GeoPoint{Longitude: 80, Latitude: 0.5}, + GeoPoint{Longitude: 100, Latitude: 0}, + GeoPoint{Longitude: 100, Latitude: 0.5}, + ) + } + center := GeoPoint{} + if fragments := polygonFragmentsOrthographic(ring, center); len(fragments) < cycles/2 { + t.Fatalf("synthetic ring produced %d fragments, want at least %d", len(fragments), cycles/2) + } + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + fragments := polygonFragmentsOrthographic(ring, center) + runtime.ReadMemStats(&after) + allocated := after.TotalAlloc - before.TotalAlloc + limit := uint64(12 << 20) + if allocated > limit { + t.Fatalf("fragments with %d runs allocated %d bytes, limit %d", len(fragments), allocated, limit) + } + t.Logf("%d runs allocated %d bytes", len(fragments), allocated) +} diff --git a/internal/geodata/polygon_union.go b/internal/geodata/polygon_union.go new file mode 100644 index 0000000..284d525 --- /dev/null +++ b/internal/geodata/polygon_union.go @@ -0,0 +1,902 @@ +package geodata + +import ( + "fmt" + "math" + "sort" +) + +const polygonUnionEpsilon = 1e-9 +const polygonUnionProbeOffset = 1e-7 +const polygonUnionSnapGrid = 1e-7 +const polygonUnionNodeGrid = 1e-7 +const polygonUnionContainmentToleranceKM = 1.0 +const polygonUnionLocationBinCount = 64 + +type polygonUnionPoint struct { + x float64 + y float64 +} + +type polygonUnionRing struct { + points []polygonUnionPoint + edges []polygonUnionSourceEdge + locationEdges []polygonUnionSourceEdge + locationBins [][]polygonUnionSourceEdge + params [][]float64 + minX float64 + maxX float64 + minY float64 + maxY float64 +} + +type polygonUnionSourceEdge struct { + start polygonUnionPoint + end polygonUnionPoint + minX float64 + maxX float64 + minY float64 + maxY float64 +} + +type polygonUnionSourceEdgeRef struct { + ringIndex int + edgeIndex int + edge polygonUnionSourceEdge +} + +type polygonUnionEdge struct { + start polygonUnionPoint + end polygonUnionPoint +} + +type polygonUnionNode struct { + x int64 + y int64 +} + +type polygonUnionEdgeKey struct { + start polygonUnionNode + end polygonUnionNode +} + +// UnionPolygons 合并相互重叠的地理多边形环,不相交的输入保持分离。 +// UnionPolygons merges overlapping geographic polygon rings. Ordinary small +// regions use the fast equirectangular branch; polar or antimeridian regions +// are rotated to a local spherical chart first, so the planar edge splitter +// never sees a coordinate singularity. Disjoint inputs remain disjoint. +func UnionPolygons(polygons [][]GeoPoint) ([][]GeoPoint, error) { + if polygonUnionNeedsSphericalChart(polygons) { + // Preserve the numerically stable legacy result when it already retains + // every input boundary and pole cap. The rotated chart is a recovery + // path for the singular cases; applying it to an ordinary antimeridian + // band can unnecessarily change the displayed equirectangular chord. + // 绕极环在平面图幅里无法闭合,平面并集必然失败,不必先算一遍。 + if !polygonUnionWindsAroundPole(polygons) { + if planar, err := unionPolygonsPlanar(polygons); err == nil && + polygonUnionContainsInputs(planar, polygons) { + return planar, nil + } + } + result, err := unionPolygonsSphericalChart(polygons) + if err == nil { + return result, nil + } + if snapped := snapPolygonUnionInputs(polygons); snapped != nil { + if result, retryErr := unionPolygonsSphericalChart(snapped); retryErr == nil { + return result, nil + } + } + return nil, err + } + result, err := unionPolygonsPlanar(polygons) + if err == nil { + return result, nil + } + if snapped := snapPolygonUnionInputs(polygons); snapped != nil { + if result, retryErr := unionPolygonsPlanar(snapped); retryErr == nil { + return result, nil + } + } + return nil, err +} + +// snapPolygonUnionInputs retries a failed boolean join after quantizing input +// vertices to a centimetre-scale angular grid. Intersections generated from +// adjacent temporal footprints can differ by a few nanodegrees; the primary +// union keeps full precision, while this bounded retry only closes that +// numerical seam when the exact join cannot form a ring. +func snapPolygonUnionInputs(polygons [][]GeoPoint) [][]GeoPoint { + if len(polygons) == 0 { + return nil + } + result := make([][]GeoPoint, len(polygons)) + changed := false + for polygonIndex, polygon := range polygons { + if len(polygon) < 3 { + return nil + } + result[polygonIndex] = make([]GeoPoint, len(polygon)) + for pointIndex, point := range polygon { + longitude := math.Round(point.Longitude/polygonUnionSnapGrid) * polygonUnionSnapGrid + latitude := math.Round(point.Latitude/polygonUnionSnapGrid) * polygonUnionSnapGrid + result[polygonIndex][pointIndex] = GeoPoint{Longitude: longitude, Latitude: latitude} + changed = changed || longitude != point.Longitude || latitude != point.Latitude + } + } + if !changed { + return nil + } + return result +} + +func polygonUnionContainsInputs(result, inputs [][]GeoPoint) bool { + if !SphericalPolygonsContainPathsWithinKM( + result, inputs, true, polygonUnionContainmentToleranceKM, + ) { + return false + } + for _, input := range inputs { + open := openGeoRing(input) + if !polygonUnionRingWindsAroundPole(open) { + continue + } + for _, pole := range []GeoPoint{{Longitude: 0, Latitude: 90}, {Longitude: 0, Latitude: -90}} { + if sphericalPolygonContainsOrTouches(open, pole) && + !SphericalPolygonsContainPaths(result, [][]GeoPoint{{pole}}, false) { + return false + } + } + } + return true +} + +// polygonUnionWindsAroundPole 报告是否有环绕极点一圈。 +func polygonUnionWindsAroundPole(polygons [][]GeoPoint) bool { + for _, polygon := range polygons { + if polygonUnionRingWindsAroundPole(openGeoRing(polygon)) { + return true + } + } + return false +} + +// polygonUnionRingWindsAroundPole 用与 polygonUnionRings 相同的展开闭合判定识别绕极环。 +func polygonUnionRingWindsAroundPole(ring []GeoPoint) bool { + if len(ring) < 3 { + return false + } + last := ring[0].Longitude + for _, point := range ring[1:] { + last += math.Remainder(point.Longitude-last, 360) + } + closure := last + math.Remainder(ring[0].Longitude-last, 360) + return math.Abs(closure-ring[0].Longitude) > 180 +} + +func unionPolygonsPlanar(polygons [][]GeoPoint) ([][]GeoPoint, error) { + rings, err := polygonUnionRings(polygons) + if err != nil { + return nil, err + } + polygonUnionAddIntersections(rings) + edges := polygonUnionOuterEdges(rings) + if len(edges) == 0 { + return nil, fmt.Errorf("polygon union has no outer edges") + } + result, err := polygonUnionJoinEdges(edges) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("polygon union has no usable rings") + } + return result, nil +} + +type polygonUnionChart struct { + xAxis geoVector3 + yAxis geoVector3 + zAxis geoVector3 +} + +func polygonUnionNeedsSphericalChart(polygons [][]GeoPoint) bool { + for _, polygon := range polygons { + if len(polygon) < 3 { + continue + } + open := openGeoRing(polygon) + for index, point := range open { + if math.Abs(point.Latitude) >= 70 { + return true + } + next := open[(index+1)%len(open)] + if math.Abs(next.Longitude-point.Longitude) > 180 { + return true + } + } + } + return false +} + +func unionPolygonsSphericalChart(polygons [][]GeoPoint) ([][]GeoPoint, error) { + chart, ok := newPolygonUnionChart(polygons) + if !ok { + return unionPolygonsPlanar(polygons) + } + projected := make([][]GeoPoint, len(polygons)) + for polygonIndex, polygon := range polygons { + projected[polygonIndex] = make([]GeoPoint, len(polygon)) + for pointIndex, point := range polygon { + projected[polygonIndex][pointIndex] = chart.project(point) + } + } + merged, err := unionPolygonsPlanar(projected) + if err != nil { + return nil, err + } + result := make([][]GeoPoint, len(merged)) + for polygonIndex, polygon := range merged { + result[polygonIndex] = make([]GeoPoint, len(polygon)) + for pointIndex, point := range polygon { + result[polygonIndex][pointIndex] = chart.unproject(point) + } + } + return result, nil +} + +func newPolygonUnionChart(polygons [][]GeoPoint) (polygonUnionChart, bool) { + center := geoVector3{} + var first geoVector3 + haveFirst := false + for _, polygon := range polygons { + for _, point := range openGeoRing(polygon) { + vector := geoPointVector(point) + center = geoVectorAdd(center, vector) + if !haveFirst { + first = vector + haveFirst = true + } + } + } + center, ok := geoVectorNormalize(center) + if !ok { + center, ok = geoVectorNormalize(first) + if !ok { + return polygonUnionChart{}, false + } + } + globalNorth := geoVector3{z: 1} + zAxis, ok := geoVectorNormalize(geoVectorAdd(globalNorth, geoVectorScale(center, -geoVectorDot(globalNorth, center)))) + if !ok { + zAxis, ok = geoVectorNormalize(geoVectorAdd(geoVector3{x: 1}, geoVectorScale(center, -center.x))) + if !ok { + return polygonUnionChart{}, false + } + } + yAxis, ok := geoVectorNormalize(geoVectorCross(zAxis, center)) + if !ok { + return polygonUnionChart{}, false + } + return polygonUnionChart{xAxis: center, yAxis: yAxis, zAxis: zAxis}, true +} + +func (chart polygonUnionChart) project(point GeoPoint) GeoPoint { + vector := geoPointVector(point) + return GeoPoint{ + Longitude: math.Atan2(geoVectorDot(vector, chart.yAxis), geoVectorDot(vector, chart.xAxis)) * 180 / math.Pi, + Latitude: math.Asin(math.Max(-1, math.Min(1, geoVectorDot(vector, chart.zAxis)))) * 180 / math.Pi, + } +} + +func (chart polygonUnionChart) unproject(point GeoPoint) GeoPoint { + latitude := point.Latitude * math.Pi / 180 + longitude := point.Longitude * math.Pi / 180 + cosLatitude := math.Cos(latitude) + vector := geoVectorAdd( + geoVectorScale(chart.xAxis, cosLatitude*math.Cos(longitude)), + geoVectorAdd( + geoVectorScale(chart.yAxis, cosLatitude*math.Sin(longitude)), + geoVectorScale(chart.zAxis, math.Sin(latitude)), + ), + ) + return geoVectorPoint(vector) +} + +func polygonUnionRings(polygons [][]GeoPoint) ([]polygonUnionRing, error) { + rings := make([]polygonUnionRing, 0, len(polygons)) + reference := 0.0 + haveReference := false + for polygonIndex, source := range polygons { + source = openGeoRing(source) + if len(source) < 3 { + return nil, fmt.Errorf("polygon %d requires at least three points", polygonIndex) + } + points := make([]polygonUnionPoint, len(source)) + points[0] = polygonUnionPoint{x: source[0].Longitude, y: source[0].Latitude} + for index := 1; index < len(source); index++ { + points[index] = polygonUnionPoint{ + x: points[index-1].x + math.Remainder(source[index].Longitude-points[index-1].x, 360), + y: source[index].Latitude, + } + } + // A pole-winding ring closes one full longitude turn away. Joining it + // directly in this chart creates an artificial chord through its interior; + // let UnionPolygons retry in the rotated spherical chart instead. + closure := points[len(points)-1].x + math.Remainder(points[0].x-points[len(points)-1].x, 360) + if math.Abs(closure-points[0].x) > 180 { + return nil, fmt.Errorf("polygon %d winds around a chart pole", polygonIndex) + } + mean := polygonUnionMeanLongitude(points) + if !haveReference { + reference = mean + haveReference = true + } else { + shift := math.Round((reference-mean)/360) * 360 + for index := range points { + points[index].x += shift + } + } + ring := polygonUnionRingForPoints(points) + ring.params = make([][]float64, len(points)) + for index := range ring.params { + ring.params[index] = []float64{0, 1} + } + rings = append(rings, ring) + } + if len(rings) == 0 { + return nil, fmt.Errorf("polygon union requires at least one polygon") + } + return rings, nil +} + +func polygonUnionRingForPoints(points []polygonUnionPoint) polygonUnionRing { + if len(points) == 0 { + return polygonUnionRing{points: points} + } + edges := make([]polygonUnionSourceEdge, len(points)) + for index, start := range points { + end := points[(index+1)%len(points)] + edges[index] = polygonUnionSourceEdge{ + start: start, end: end, + minX: math.Min(start.x, end.x), maxX: math.Max(start.x, end.x), + minY: math.Min(start.y, end.y), maxY: math.Max(start.y, end.y), + } + } + minX, maxX := points[0].x, points[0].x + minY, maxY := points[0].y, points[0].y + for _, point := range points[1:] { + minX, maxX = math.Min(minX, point.x), math.Max(maxX, point.x) + minY, maxY = math.Min(minY, point.y), math.Max(maxY, point.y) + } + locationEdges := append([]polygonUnionSourceEdge(nil), edges...) + sort.Slice(locationEdges, func(first, second int) bool { + if locationEdges[first].minY != locationEdges[second].minY { + return locationEdges[first].minY < locationEdges[second].minY + } + return locationEdges[first].maxY < locationEdges[second].maxY + }) + return polygonUnionRing{ + points: points, edges: edges, locationEdges: locationEdges, + locationBins: polygonUnionBuildLocationBins(edges, minY, maxY), + minX: minX, maxX: maxX, minY: minY, maxY: maxY, + } +} + +func polygonUnionAddIntersections(rings []polygonUnionRing) { + edges := make([]polygonUnionSourceEdgeRef, 0) + for ringIndex, ring := range rings { + for edgeIndex, edge := range ring.edges { + edges = append(edges, polygonUnionSourceEdgeRef{ + ringIndex: ringIndex, + edgeIndex: edgeIndex, + edge: edge, + }) + } + } + sort.Slice(edges, func(first, second int) bool { + a, b := edges[first], edges[second] + if a.edge.minX != b.edge.minX { + return a.edge.minX < b.edge.minX + } + if a.edge.maxX != b.edge.maxX { + return a.edge.maxX < b.edge.maxX + } + if a.ringIndex != b.ringIndex { + return a.ringIndex < b.ringIndex + } + return a.edgeIndex < b.edgeIndex + }) + for firstIndex, first := range edges { + for secondIndex := firstIndex + 1; secondIndex < len(edges); secondIndex++ { + second := edges[secondIndex] + if second.edge.minX > first.edge.maxX+polygonUnionEpsilon { + break + } + if first.ringIndex == second.ringIndex && polygonUnionEdgesAdjacent( + first.edgeIndex, second.edgeIndex, len(rings[first.ringIndex].points), + ) { + continue + } + if !polygonUnionEdgeBoundsOverlap(first.edge, second.edge) { + continue + } + firstParams, secondParams := polygonUnionSegmentIntersections( + first.edge.start, first.edge.end, second.edge.start, second.edge.end, + ) + firstRing := &rings[first.ringIndex] + secondRing := &rings[second.ringIndex] + firstRing.params[first.edgeIndex] = append(firstRing.params[first.edgeIndex], firstParams...) + secondRing.params[second.edgeIndex] = append(secondRing.params[second.edgeIndex], secondParams...) + } + } +} + +func polygonUnionEdgeBoundsOverlap(first, second polygonUnionSourceEdge) bool { + return first.minX <= second.maxX+polygonUnionEpsilon && second.minX <= first.maxX+polygonUnionEpsilon && + first.minY <= second.maxY+polygonUnionEpsilon && second.minY <= first.maxY+polygonUnionEpsilon +} + +func polygonUnionEdgesAdjacent(first, second, count int) bool { + return (first+1)%count == second || (second+1)%count == first +} + +func polygonUnionSegmentIntersections( + firstStart, firstEnd, secondStart, secondEnd polygonUnionPoint, +) ([]float64, []float64) { + firstDelta := polygonUnionSubtract(firstEnd, firstStart) + secondDelta := polygonUnionSubtract(secondEnd, secondStart) + offset := polygonUnionSubtract(secondStart, firstStart) + denominator := polygonUnionCross(firstDelta, secondDelta) + if math.Abs(denominator) > polygonUnionEpsilon { + firstParam := polygonUnionCross(offset, secondDelta) / denominator + secondParam := polygonUnionCross(offset, firstDelta) / denominator + if firstParam < -polygonUnionEpsilon || firstParam > 1+polygonUnionEpsilon || + secondParam < -polygonUnionEpsilon || secondParam > 1+polygonUnionEpsilon { + return nil, nil + } + return []float64{polygonUnionClampParam(firstParam)}, []float64{polygonUnionClampParam(secondParam)} + } + if math.Abs(polygonUnionCross(offset, firstDelta)) > polygonUnionEpsilon { + return nil, nil + } + + firstParams := make([]float64, 0, 2) + secondParams := make([]float64, 0, 2) + for _, point := range []polygonUnionPoint{secondStart, secondEnd} { + if value, ok := polygonUnionPointSegmentParam(point, firstStart, firstEnd); ok { + firstParams = append(firstParams, value) + } + } + for _, point := range []polygonUnionPoint{firstStart, firstEnd} { + if value, ok := polygonUnionPointSegmentParam(point, secondStart, secondEnd); ok { + secondParams = append(secondParams, value) + } + } + return firstParams, secondParams +} + +func polygonUnionPointSegmentParam(point, start, end polygonUnionPoint) (float64, bool) { + delta := polygonUnionSubtract(end, start) + lengthSquared := delta.x*delta.x + delta.y*delta.y + if lengthSquared <= polygonUnionEpsilon*polygonUnionEpsilon { + return 0, false + } + value := ((point.x-start.x)*delta.x + (point.y-start.y)*delta.y) / lengthSquared + if value < -polygonUnionEpsilon || value > 1+polygonUnionEpsilon { + return 0, false + } + projected := polygonUnionInterpolate(start, end, value) + if math.Hypot(projected.x-point.x, projected.y-point.y) > polygonUnionEpsilon { + return 0, false + } + return polygonUnionClampParam(value), true +} + +func polygonUnionOuterEdges(rings []polygonUnionRing) []polygonUnionEdge { + edges := make(map[polygonUnionEdgeKey]polygonUnionEdge) + for ringIndex, ring := range rings { + for edgeIndex, start := range ring.points { + end := ring.points[(edgeIndex+1)%len(ring.points)] + params := polygonUnionUniqueParams(ring.params[edgeIndex]) + for index := 1; index < len(params); index++ { + firstParam, secondParam := params[index-1], params[index] + if secondParam-firstParam <= polygonUnionEpsilon { + continue + } + pieceStart := polygonUnionInterpolate(start, end, firstParam) + pieceEnd := polygonUnionInterpolate(start, end, secondParam) + delta := polygonUnionSubtract(pieceEnd, pieceStart) + length := math.Hypot(delta.x, delta.y) + if length <= polygonUnionEpsilon { + continue + } + middle := polygonUnionInterpolate(pieceStart, pieceEnd, 0.5) + normal := polygonUnionPoint{ + x: -delta.y / length * polygonUnionProbeOffset, + y: delta.x / length * polygonUnionProbeOffset, + } + leftInside := polygonUnionInsideAnyRing(polygonUnionAdd(middle, normal), rings, ringIndex) + rightInside := polygonUnionInsideAnyRing(polygonUnionSubtract(middle, normal), rings, ringIndex) + if leftInside == rightInside { + continue + } + if rightInside { + pieceStart, pieceEnd = pieceEnd, pieceStart + } + edge := polygonUnionEdge{start: pieceStart, end: pieceEnd} + key := polygonUnionKey(edge) + edges[key] = edge + } + } + } + result := make([]polygonUnionEdge, 0, len(edges)) + for _, edge := range edges { + result = append(result, edge) + } + sort.Slice(result, func(first, second int) bool { + a, b := result[first], result[second] + if a.start.x != b.start.x { + return a.start.x < b.start.x + } + if a.start.y != b.start.y { + return a.start.y < b.start.y + } + if a.end.x != b.end.x { + return a.end.x < b.end.x + } + return a.end.y < b.end.y + }) + return result +} + +func polygonUnionInsideAnyRing(point polygonUnionPoint, rings []polygonUnionRing, preferred int) bool { + if preferred >= 0 && preferred < len(rings) && polygonUnionRingContainsPoint(point, rings[preferred]) { + return true + } + for index, ring := range rings { + if index != preferred && polygonUnionRingContainsPoint(point, ring) { + return true + } + } + return false +} + +func polygonUnionRingContainsPoint(point polygonUnionPoint, ring polygonUnionRing) bool { + margin := polygonUnionProbeOffset + polygonUnionEpsilon + if point.x < ring.minX-margin || point.x > ring.maxX+margin || + point.y < ring.minY-margin || point.y > ring.maxY+margin { + return false + } + return polygonUnionPointLocationInRing(point, ring) >= 0 +} + +func polygonUnionJoinEdges(edges []polygonUnionEdge) ([][]GeoPoint, error) { + outgoing := make(map[polygonUnionNode][]int, len(edges)) + for index, edge := range edges { + outgoing[polygonUnionNodeForPoint(edge.start)] = append( + outgoing[polygonUnionNodeForPoint(edge.start)], index, + ) + } + used := make([]bool, len(edges)) + type resultRing struct { + points []GeoPoint + area float64 + } + results := make([]resultRing, 0, 2) + // chartRings 保留图幅坐标,用于并集后检测孔洞。 + chartRings := make([][]polygonUnionPoint, 0, 2) + for firstEdgeIndex := range edges { + if used[firstEdgeIndex] { + continue + } + firstNode := polygonUnionNodeForPoint(edges[firstEdgeIndex].start) + currentEdgeIndex := firstEdgeIndex + points := make([]polygonUnionPoint, 0, len(edges)) + for step := 0; step <= len(edges); step++ { + if used[currentEdgeIndex] { + return nil, fmt.Errorf("polygon union outer edges form a repeated branch") + } + current := edges[currentEdgeIndex] + used[currentEdgeIndex] = true + points = append(points, current.start) + nextNode := polygonUnionNodeForPoint(current.end) + if nextNode == firstNode { + break + } + candidates := outgoing[nextNode] + nextEdgeIndex := -1 + for _, candidate := range candidates { + if used[candidate] { + continue + } + if nextEdgeIndex < 0 || polygonUnionClockwiseTurn( + current, edges[candidate], + ) < polygonUnionClockwiseTurn(current, edges[nextEdgeIndex]) { + nextEdgeIndex = candidate + } + } + if nextEdgeIndex < 0 { + return nil, fmt.Errorf("polygon union outer edges are open at %.9f, %.9f", current.end.x, current.end.y) + } + currentEdgeIndex = nextEdgeIndex + } + points = polygonUnionDeduplicatePoints(points) + area := polygonUnionSignedArea(points) + if len(points) < 3 || math.Abs(area) <= polygonUnionEpsilon { + continue + } + if area < 0 { + polygonUnionReversePoints(points) + area = -area + } + geographic := make([]GeoPoint, len(points)) + for index, point := range points { + geographic[index] = GeoPoint{ + Longitude: polygonUnionNormalizeLongitude(point.x), + Latitude: point.y, + } + } + results = append(results, resultRing{points: geographic, area: area}) + chartRings = append(chartRings, points) + } + // 环列表无法表达孔洞(内外边界都会当成实体面),检测到严格包含就报错。 + if inner, outer, ratio, found := polygonUnionNestedRing(chartRings); found && ratio >= polygonUnionHoleAreaFraction { + return nil, fmt.Errorf( + "polygon union produced a hole (ring %d, area %.6g lies strictly inside ring %d, area %.6g); a ring list cannot represent it", + inner, math.Abs(polygonUnionSignedArea(chartRings[inner])), + outer, math.Abs(polygonUnionSignedArea(chartRings[outer])), + ) + } + sort.Slice(results, func(first, second int) bool { return results[first].area > results[second].area }) + polygons := make([][]GeoPoint, len(results)) + for index, result := range results { + polygons[index] = result.points + } + return polygons, nil +} + +// polygonUnionHoleAreaFraction 是判定真孔洞的相对面积下限:低于它按数值细条处理,达到或超过则报错。 +const polygonUnionHoleAreaFraction = 0.01 + +// polygonUnionNestedRing 找出被另一个环严格包含的环(孔洞);贴边不算包含。 +func polygonUnionNestedRing(rings [][]polygonUnionPoint) (inner, outer int, ratio float64, found bool) { + bestRatio := 0.0 + for candidate := range rings { + candidateArea := math.Abs(polygonUnionSignedArea(rings[candidate])) + for container := range rings { + if candidate == container { + continue + } + strictlyInside := false + contained := true + for _, vertex := range rings[candidate] { + switch polygonUnionPointLocation(vertex, rings[container]) { + case 1: + strictlyInside = true + case 0: + // 贴边:不改变判定 + default: + contained = false + } + if !contained { + break + } + } + if !contained || !strictlyInside { + continue + } + containerArea := math.Abs(polygonUnionSignedArea(rings[container])) + if containerArea <= 0 { + continue + } + if candidateRatio := candidateArea / containerArea; candidateRatio > bestRatio { + bestRatio, inner, outer, found = candidateRatio, candidate, container, true + } + } + } + return inner, outer, bestRatio, found +} + +func polygonUnionClockwiseTurn(incoming, outgoing polygonUnionEdge) float64 { + incomingAngle := math.Atan2(incoming.end.y-incoming.start.y, incoming.end.x-incoming.start.x) + reverseAngle := incomingAngle + math.Pi + outgoingAngle := math.Atan2(outgoing.end.y-outgoing.start.y, outgoing.end.x-outgoing.start.x) + turn := math.Mod(reverseAngle-outgoingAngle, 2*math.Pi) + if turn < 0 { + turn += 2 * math.Pi + } + return turn +} + +// polygonUnionPointLocation returns 1 inside, 0 on the boundary, and -1 outside. +func polygonUnionPointLocation(point polygonUnionPoint, ring []polygonUnionPoint) int { + inside := false + for index, start := range ring { + end := ring[(index+1)%len(ring)] + if _, ok := polygonUnionPointSegmentParam(point, start, end); ok { + return 0 + } + if (start.y > point.y) != (end.y > point.y) { + intersectionX := start.x + (end.x-start.x)*(point.y-start.y)/(end.y-start.y) + if point.x < intersectionX { + inside = !inside + } + } + } + if inside { + return 1 + } + return -1 +} + +func polygonUnionPointLocationOnEdges( + point polygonUnionPoint, + edges []polygonUnionSourceEdge, +) int { + inside := false + for _, edge := range edges { + if edge.minY > point.y+polygonUnionEpsilon { + break + } + if edge.maxY < point.y-polygonUnionEpsilon { + continue + } + if point.x >= edge.minX-polygonUnionEpsilon && point.x <= edge.maxX+polygonUnionEpsilon && + point.y >= edge.minY-polygonUnionEpsilon && point.y <= edge.maxY+polygonUnionEpsilon { + if _, ok := polygonUnionPointSegmentParam(point, edge.start, edge.end); ok { + return 0 + } + } + start := edge.start + end := edge.end + if (start.y > point.y) != (end.y > point.y) { + intersectionX := start.x + (end.x-start.x)*(point.y-start.y)/(end.y-start.y) + if point.x < intersectionX { + inside = !inside + } + } + } + if inside { + return 1 + } + return -1 +} + +func polygonUnionBuildLocationBins( + edges []polygonUnionSourceEdge, + minimumY, maximumY float64, +) [][]polygonUnionSourceEdge { + bins := make([][]polygonUnionSourceEdge, polygonUnionLocationBinCount) + for _, edge := range edges { + first := polygonUnionLocationBin(edge.minY-polygonUnionEpsilon, minimumY, maximumY) + last := polygonUnionLocationBin(edge.maxY+polygonUnionEpsilon, minimumY, maximumY) + for index := first; index <= last; index++ { + bins[index] = append(bins[index], edge) + } + } + for index := range bins { + sort.Slice(bins[index], func(first, second int) bool { + if bins[index][first].minY != bins[index][second].minY { + return bins[index][first].minY < bins[index][second].minY + } + return bins[index][first].maxY < bins[index][second].maxY + }) + } + return bins +} + +func polygonUnionPointLocationInRing(point polygonUnionPoint, ring polygonUnionRing) int { + if len(ring.locationBins) != polygonUnionLocationBinCount { + return polygonUnionPointLocationOnEdges(point, ring.locationEdges) + } + index := polygonUnionLocationBin(point.y, ring.minY, ring.maxY) + return polygonUnionPointLocationOnEdges(point, ring.locationBins[index]) +} + +func polygonUnionLocationBin(value, minimum, maximum float64) int { + if maximum <= minimum { + return 0 + } + index := int((value - minimum) / (maximum - minimum) * polygonUnionLocationBinCount) + if index < 0 { + return 0 + } + if index >= polygonUnionLocationBinCount { + return polygonUnionLocationBinCount - 1 + } + return index +} + +func polygonUnionUniqueParams(values []float64) []float64 { + sort.Float64s(values) + result := values[:0] + for _, value := range values { + value = polygonUnionClampParam(value) + if len(result) == 0 || value-result[len(result)-1] > polygonUnionEpsilon { + result = append(result, value) + } + } + return result +} + +func polygonUnionDeduplicatePoints(points []polygonUnionPoint) []polygonUnionPoint { + result := points[:0] + for _, point := range points { + if len(result) == 0 || polygonUnionNodeForPoint(result[len(result)-1]) != polygonUnionNodeForPoint(point) { + result = append(result, point) + } + } + if len(result) > 1 && polygonUnionNodeForPoint(result[0]) == polygonUnionNodeForPoint(result[len(result)-1]) { + result = result[:len(result)-1] + } + return result +} + +func polygonUnionSignedArea(points []polygonUnionPoint) float64 { + area := 0.0 + for index, current := range points { + next := points[(index+1)%len(points)] + area += current.x*next.y - next.x*current.y + } + return area / 2 +} + +func polygonUnionMeanLongitude(points []polygonUnionPoint) float64 { + value := 0.0 + for _, point := range points { + value += point.x + } + return value / float64(len(points)) +} + +func polygonUnionReversePoints(points []polygonUnionPoint) { + for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 { + points[left], points[right] = points[right], points[left] + } +} + +func polygonUnionInterpolate(start, end polygonUnionPoint, fraction float64) polygonUnionPoint { + return polygonUnionPoint{ + x: start.x + (end.x-start.x)*fraction, + y: start.y + (end.y-start.y)*fraction, + } +} + +func polygonUnionSubtract(first, second polygonUnionPoint) polygonUnionPoint { + return polygonUnionPoint{x: first.x - second.x, y: first.y - second.y} +} + +func polygonUnionAdd(first, second polygonUnionPoint) polygonUnionPoint { + return polygonUnionPoint{x: first.x + second.x, y: first.y + second.y} +} + +func polygonUnionCross(first, second polygonUnionPoint) float64 { + return first.x*second.y - first.y*second.x +} + +func polygonUnionClampParam(value float64) float64 { + return math.Max(0, math.Min(1, value)) +} + +func polygonUnionNodeForPoint(point polygonUnionPoint) polygonUnionNode { + return polygonUnionNode{ + x: int64(math.Round(point.x / polygonUnionNodeGrid)), + y: int64(math.Round(point.y / polygonUnionNodeGrid)), + } +} + +func polygonUnionKey(edge polygonUnionEdge) polygonUnionEdgeKey { + return polygonUnionEdgeKey{ + start: polygonUnionNodeForPoint(edge.start), + end: polygonUnionNodeForPoint(edge.end), + } +} + +func polygonUnionNormalizeLongitude(value float64) float64 { + value = math.Mod(value+180, 360) + if value < 0 { + value += 360 + } + return value - 180 +} diff --git a/internal/geodata/polygon_union_test.go b/internal/geodata/polygon_union_test.go new file mode 100644 index 0000000..b6d49f3 --- /dev/null +++ b/internal/geodata/polygon_union_test.go @@ -0,0 +1,351 @@ +package geodata + +import ( + "math" + "strings" + "testing" +) + +func TestPolygonUnionLatitudeIndexMatchesExactPointLocation(t *testing.T) { + rings, err := polygonUnionRings([][]GeoPoint{{ + {Longitude: -7, Latitude: -2}, + {Longitude: -1, Latitude: -6}, + {Longitude: 5, Latitude: -3}, + {Longitude: 8, Latitude: 2}, + {Longitude: 3, Latitude: 7}, + {Longitude: -4, Latitude: 5}, + }}) + if err != nil { + t.Fatalf("polygonUnionRings: %v", err) + } + ring := rings[0] + for latitudeIndex := -80; latitudeIndex <= 80; latitudeIndex++ { + for longitudeIndex := -100; longitudeIndex <= 100; longitudeIndex++ { + point := polygonUnionPoint{ + x: float64(longitudeIndex) / 10, + y: float64(latitudeIndex) / 10, + } + got := polygonUnionPointLocationOnEdges(point, ring.locationEdges) + want := polygonUnionPointLocation(point, ring.points) + if got != want { + t.Fatalf("indexed point location at %.1f, %.1f = %d, want %d", point.x, point.y, got, want) + } + if got := polygonUnionPointLocationInRing(point, ring); got != want { + t.Fatalf("binned point location at %.1f, %.1f = %d, want %d", point.x, point.y, got, want) + } + } + } + for _, point := range ring.points { + if got := polygonUnionPointLocationOnEdges(point, ring.locationEdges); got != 0 { + t.Fatalf("indexed point location at boundary %+v = %d, want 0", point, got) + } + if got := polygonUnionPointLocationInRing(point, ring); got != 0 { + t.Fatalf("binned point location at boundary %+v = %d, want 0", point, got) + } + } +} + +func TestUnionPolygonsMergesOverlappingRingsWithoutInternalEdges(t *testing.T) { + polygons, err := UnionPolygons([][]GeoPoint{ + { + {Longitude: 0, Latitude: 0}, + {Longitude: 3, Latitude: 0}, + {Longitude: 3, Latitude: 2}, + {Longitude: 0, Latitude: 2}, + }, + { + {Longitude: 2, Latitude: 1}, + {Longitude: 4, Latitude: 1}, + {Longitude: 4, Latitude: 3}, + {Longitude: 2, Latitude: 3}, + }, + }) + if err != nil { + t.Fatalf("UnionPolygons: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("polygon count=%d, want one", len(polygons)) + } + for _, point := range []GeoPoint{ + {Longitude: 1, Latitude: 1}, + {Longitude: 2.5, Latitude: 2.5}, + {Longitude: 3.5, Latitude: 1.5}, + } { + if !sweepPolygonContainsOrTouches(polygons[0], point) { + t.Fatalf("union does not contain %+v", point) + } + } + if sweepPolygonContainsOrTouches(polygons[0], GeoPoint{Longitude: 1, Latitude: 2.5}) { + t.Fatal("union contains a point outside both inputs") + } + + // 并集边界恰为两环的外轮廓:被另一环吞掉的角点 (3,2) 与 (2,1) 不得作为顶点留下, + // 否则说明内部边被当成边界保留。 + wantVertices := []GeoPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 3, Latitude: 0}, + {Longitude: 3, Latitude: 1}, + {Longitude: 4, Latitude: 1}, + {Longitude: 4, Latitude: 3}, + {Longitude: 2, Latitude: 3}, + {Longitude: 2, Latitude: 2}, + {Longitude: 0, Latitude: 2}, + } + for _, want := range wantVertices { + if !ringHasVertex(polygons[0], want) { + t.Fatalf("union boundary lost the outline vertex %+v: %+v", want, polygons[0]) + } + } + for _, swallowed := range []GeoPoint{ + {Longitude: 3, Latitude: 2}, + {Longitude: 2, Latitude: 1}, + } { + if ringHasVertex(polygons[0], swallowed) { + t.Fatalf("union kept the swallowed corner %+v as a boundary vertex (internal edge): %+v", swallowed, polygons[0]) + } + } +} + +// ringHasVertex 判断环上是否存在与目标重合(1e-9 度)的顶点;环首尾重复只算一次。 +func ringHasVertex(ring []GeoPoint, want GeoPoint) bool { + for _, point := range ring { + if math.Abs(point.Longitude-want.Longitude) <= 1e-9 && math.Abs(point.Latitude-want.Latitude) <= 1e-9 { + return true + } + } + return false +} + +func TestUnionPolygonsPreservesDisjointAndContainedRings(t *testing.T) { + polygons, err := UnionPolygons([][]GeoPoint{ + { + {Longitude: 0, Latitude: 0}, + {Longitude: 4, Latitude: 0}, + {Longitude: 4, Latitude: 4}, + {Longitude: 0, Latitude: 4}, + }, + { + {Longitude: 1, Latitude: 1}, + {Longitude: 2, Latitude: 1}, + {Longitude: 2, Latitude: 2}, + {Longitude: 1, Latitude: 2}, + }, + { + {Longitude: 10, Latitude: 0}, + {Longitude: 11, Latitude: 0}, + {Longitude: 11, Latitude: 1}, + {Longitude: 10, Latitude: 1}, + }, + }) + if err != nil { + t.Fatalf("UnionPolygons: %v", err) + } + if len(polygons) != 2 { + t.Fatalf("polygon count=%d, want containing and disjoint rings", len(polygons)) + } +} + +func TestPolygonUnionContainmentChecksInputEdgeMidpoints(t *testing.T) { + result := [][]GeoPoint{{ + {Longitude: 0, Latitude: 0}, + {Longitude: 4, Latitude: 0}, + {Longitude: 4, Latitude: 4}, + {Longitude: 3, Latitude: 4}, + {Longitude: 3, Latitude: 1}, + {Longitude: 1, Latitude: 1}, + {Longitude: 1, Latitude: 4}, + {Longitude: 0, Latitude: 4}, + }} + input := [][]GeoPoint{{ + {Longitude: 0.5, Latitude: 3.5}, + {Longitude: 3.5, Latitude: 3.5}, + {Longitude: 0.5, Latitude: 3}, + }} + for _, point := range input[0] { + if !sphericalPolygonContainsOrTouches(result[0], point) { + t.Fatalf("fixture input vertex is outside result: %+v", point) + } + } + if polygonUnionContainsInputs(result, input) { + t.Fatal("containment accepted an input edge crossing the result's open notch") + } +} + +func TestUnionPolygonsMergesAcrossAntimeridian(t *testing.T) { + polygons, err := UnionPolygons([][]GeoPoint{ + { + {Longitude: 170, Latitude: -5}, + {Longitude: -175, Latitude: -5}, + {Longitude: -175, Latitude: 5}, + {Longitude: 170, Latitude: 5}, + }, + { + {Longitude: 175, Latitude: 0}, + {Longitude: -170, Latitude: 0}, + {Longitude: -170, Latitude: 10}, + {Longitude: 175, Latitude: 10}, + }, + }) + if err != nil { + t.Fatalf("UnionPolygons across antimeridian: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("antimeridian polygon count=%d, want one", len(polygons)) + } + for _, point := range []GeoPoint{ + {Longitude: 172, Latitude: 0}, + {Longitude: 179, Latitude: 3}, + {Longitude: -172, Latitude: 7}, + } { + if !sphericalPolygonContainsOrTouches(polygons[0], point) { + t.Fatalf("antimeridian union does not contain %+v", point) + } + } +} + +func TestUnionPolygonsMergesOverlappingPolarCaps(t *testing.T) { + first := SphericalCircle(GeoPoint{Longitude: 0, Latitude: 86}, 7, 72) + second := SphericalCircle(GeoPoint{Longitude: 120, Latitude: 86}, 7, 72) + polygons, err := UnionPolygons([][]GeoPoint{first, second}) + if err != nil { + t.Fatalf("UnionPolygons at north pole: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("polar polygon count=%d, want one", len(polygons)) + } + for _, point := range []GeoPoint{ + {Longitude: 0, Latitude: 86}, + {Longitude: 120, Latitude: 86}, + {Longitude: 0, Latitude: 90}, + } { + if !sphericalPolygonContainsOrTouches(polygons[0], point) { + t.Fatalf("polar union does not contain %+v", point) + } + } +} + +func TestUnionPolygonsPreservesPoleWindingBoundary(t *testing.T) { + // The minimum lies just after the ring's start. A planar closure across + // 360 degrees cuts off that minimum as a separate thin polygon. + source := []GeoPoint{ + {Longitude: 31.55, Latitude: 3.79}, + {Longitude: 31.78, Latitude: 3.76}, + {Longitude: 32.02, Latitude: 3.74}, + {Longitude: 32.51, Latitude: 3.77}, + {Longitude: 33.02, Latitude: 3.86}, + {Longitude: 60, Latitude: 20}, + {Longitude: 120, Latitude: 50}, + {Longitude: 179, Latitude: 70}, + {Longitude: -120, Latitude: 75}, + {Longitude: -60, Latitude: 55}, + {Longitude: 0, Latitude: 15}, + {Longitude: 30, Latitude: 4.14}, + {Longitude: 31.45, Latitude: 3.81}, + } + for _, south := range []bool{false, true} { + for _, reverse := range []bool{false, true} { + ring := append([]GeoPoint(nil), source...) + if south { + for index := range ring { + ring[index].Latitude = -ring[index].Latitude + } + } + if reverse { + reverseGeoPoints(ring) + } + if _, err := unionPolygonsPlanar([][]GeoPoint{ring}); err == nil { + t.Errorf("south=%v reverse=%v: planar union accepted a pole-winding ring", south, reverse) + } + polygons, err := UnionPolygons([][]GeoPoint{ring}) + if err != nil || len(polygons) != 1 { + t.Fatalf("south=%v reverse=%v: rings=%d err=%v, want one intact ring", south, reverse, len(polygons), err) + } + if len(openGeoRing(polygons[0])) != len(ring) { + t.Fatalf("union changed a single pole-winding boundary: points=%d want=%d", len(openGeoRing(polygons[0])), len(ring)) + } + line := append(append([]GeoPoint(nil), ring...), ring[0]) + if miss := visibleLineworkBoundarySourceMissDistanceKM(polygons, [][]GeoPoint{line}); miss > 1e-3 { + t.Fatalf("pole-winding union leaves source boundary by %.6f km", miss) + } + } + } +} + +func TestUnionPolygonsRejectsHoles(t *testing.T) { + // 4 根条围成方环:并集的外边界面积 4.0、内边界(洞)1.0,真实并集面积 3.0。 + // 环列表表达不了孔洞,因此必须报错,而不是把内边界也当成实体面返回 4+1=5 的超集。 + // Four bars forming a square frame: the union's outer boundary has area 4.0 and the + // inner one (a hole) 1.0, so the true union area is 3.0. A ring list cannot express + // the hole, so the union must fail instead of returning the inner boundary as a solid + // face and reporting the superset 4+1=5. + ring := func(x0, y0, x1, y1 float64) []GeoPoint { + return []GeoPoint{ + {Longitude: x0, Latitude: y0}, + {Longitude: x1, Latitude: y0}, + {Longitude: x1, Latitude: y1}, + {Longitude: x0, Latitude: y1}, + } + } + bars := [][]GeoPoint{ + ring(-1, 0.5, 1, 1), + ring(-1, -1, 1, -0.5), + ring(-1, -0.5, -0.5, 0.5), + ring(0.5, -0.5, 1, 0.5), + } + if _, err := UnionPolygons(bars); err == nil { + t.Fatal("UnionPolygons accepted a union with a hole instead of reporting it") + } + + // 正对照:两个分离的方块仍是两个环,不应报错。 + // Positive control: two disjoint squares stay two rings and must not fail. + disjoint := [][]GeoPoint{ring(0, 0, 1, 1), ring(3, 0, 4, 1)} + result, err := UnionPolygons(disjoint) + if err != nil { + t.Fatalf("disjoint union failed: %v", err) + } + if len(result) != 2 { + t.Fatalf("disjoint union rings=%d, want 2", len(result)) + } +} + +func TestPolygonUnionWindingPredictsPlanarFailure(t *testing.T) { + rings := map[string][]GeoPoint{ + "square": {{0, 0}, {4, 0}, {4, 4}, {0, 4}}, + "equatorial": SphericalCircle(GeoPoint{Longitude: 0, Latitude: 0}, 30, 64), + "north cap": SphericalCircle(GeoPoint{Longitude: 0, Latitude: 86}, 7, 64), + "south cap": SphericalCircle(GeoPoint{Longitude: 120, Latitude: -86}, 7, 64), + } + for name, ring := range rings { + _, err := unionPolygonsPlanar([][]GeoPoint{ring}) + reported := err != nil && strings.Contains(err.Error(), "winds around a chart pole") + if winding := polygonUnionRingWindsAroundPole(ring); winding != reported { + t.Fatalf("%s: winding=%v planar error=%v", name, winding, err) + } + } +} + +func TestUnionPolygonsWindingInputMatchesSphericalChart(t *testing.T) { + cap := SphericalCircle(GeoPoint{Longitude: 40, Latitude: 86}, 8, 72) + want, err := unionPolygonsSphericalChart([][]GeoPoint{cap}) + if err != nil { + t.Fatalf("unionPolygonsSphericalChart: %v", err) + } + got, err := UnionPolygons([][]GeoPoint{cap}) + if err != nil { + t.Fatalf("UnionPolygons: %v", err) + } + if len(got) != len(want) { + t.Fatalf("ring count=%d, want %d", len(got), len(want)) + } + for index := range want { + if len(got[index]) != len(want[index]) { + t.Fatalf("ring %d points=%d, want %d", index, len(got[index]), len(want[index])) + } + for pointIndex := range want[index] { + if !SameGeoPoint(got[index][pointIndex], want[index][pointIndex]) { + t.Fatalf("ring %d point %d=%+v, want %+v", + index, pointIndex, got[index][pointIndex], want[index][pointIndex]) + } + } + } +} diff --git a/internal/geodata/seam_test.go b/internal/geodata/seam_test.go new file mode 100644 index 0000000..d8b976f --- /dev/null +++ b/internal/geodata/seam_test.go @@ -0,0 +1,56 @@ +package geodata + +import ( + "math" + "testing" +) + +// 居中后的等经纬接缝必须落在视图中心对面;旋转前后地理点集合应当一致。 +func TestPolygonFragmentsCentredSeamKeepsGeometry(t *testing.T) { + // 密集采样,避免矩形边在大圆上鼓向极点——库按大圆处理边,稀疏顶点会放大这个效应。 + ring := make([]GeoPoint, 0, 240) + for longitude := -60.0; longitude <= 60; longitude += 2 { + ring = append(ring, GeoPoint{Longitude: longitude, Latitude: -30}) + } + for longitude := 60.0; longitude >= -60; longitude -= 2 { + ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 30}) + } + plain := PolygonFragments(ring, ClipView{Projection: ProjectionEquirectangular}) + if len(plain) != 1 { + t.Fatalf("uncentred: got %d fragments, want 1", len(plain)) + } + // 中心放在 176.269°E 时接缝落到 -3.73°,这个环横跨接缝,应当被切成两段。 + centred := PolygonFragments(ring, ClipView{ + Projection: ProjectionEquirectangular, + Center: GeoPoint{Longitude: 176.269}, + }) + if len(centred) != 2 { + t.Fatalf("centred: got %d fragments, want 2", len(centred)) + } + for _, fragment := range centred { + if len(fragment) < 3 { + t.Fatalf("fragment has %d points", len(fragment)) + } + for _, point := range fragment { + if point.Longitude < -60-1e-6 || point.Longitude > 60+1e-6 { + t.Fatalf("fragment longitude %.3f escaped the ring", point.Longitude) + } + if math.Abs(point.Latitude) > 30+0.5 { + t.Fatalf("fragment latitude %.3f escaped the ring", point.Latitude) + } + } + } + // 两段拼起来的经度跨度和应等于原环的 120°。 + total := 0.0 + for _, fragment := range centred { + minLon, maxLon := fragment[0].Longitude, fragment[0].Longitude + for _, point := range fragment[1:] { + minLon = math.Min(minLon, point.Longitude) + maxLon = math.Max(maxLon, point.Longitude) + } + total += maxLon - minLon + } + if math.Abs(total-120) > 4 { + t.Fatalf("fragment longitude span %.3f, want about 120", total) + } +} diff --git a/internal/geodata/sphere.go b/internal/geodata/sphere.go index cdaa7c4..74062f4 100644 --- a/internal/geodata/sphere.go +++ b/internal/geodata/sphere.go @@ -5,7 +5,8 @@ import "math" // SphericalCircle 返回球面小圆上的等间隔采样点 / SphericalCircle returns evenly spaced points on a small circle on the // 球面小圆;方位角从地理北方顺时针采样 / sphere. Bearings are sampled clockwise from geographic north. func SphericalCircle(center GeoPoint, radiusDegrees float64, points int) []GeoPoint { - if points < 3 { + // r=0 时采样点重合、r=180 时只剩对跖点副本,都不构成环。 + if points < 3 || !(radiusDegrees > 0 && radiusDegrees < 180) { return nil } latitude := center.Latitude * math.Pi / 180 @@ -28,6 +29,361 @@ func SphericalCircle(center GeoPoint, radiusDegrees float64, points int) []GeoPo return result } +type geoVector3 struct { + x, y, z float64 +} + +func geoPointVector(point GeoPoint) geoVector3 { + latitude := point.Latitude * math.Pi / 180 + longitude := point.Longitude * math.Pi / 180 + cosLatitude := math.Cos(latitude) + return geoVector3{ + x: cosLatitude * math.Cos(longitude), + y: cosLatitude * math.Sin(longitude), + z: math.Sin(latitude), + } +} + +func geoVectorPoint(vector geoVector3) GeoPoint { + length := math.Sqrt(vector.x*vector.x + vector.y*vector.y + vector.z*vector.z) + if length == 0 || math.IsNaN(length) || math.IsInf(length, 0) { + return GeoPoint{Longitude: math.NaN(), Latitude: math.NaN()} + } + vector.x /= length + vector.y /= length + vector.z /= length + return GeoPoint{ + Longitude: normalizeLongitude(math.Atan2(vector.y, vector.x) * 180 / math.Pi), + Latitude: math.Asin(math.Max(-1, math.Min(1, vector.z))) * 180 / math.Pi, + } +} + +func geoVectorDot(first, second geoVector3) float64 { + return first.x*second.x + first.y*second.y + first.z*second.z +} + +func geoVectorCross(first, second geoVector3) geoVector3 { + return geoVector3{ + x: first.y*second.z - first.z*second.y, + y: first.z*second.x - first.x*second.z, + z: first.x*second.y - first.y*second.x, + } +} + +func geoVectorScale(vector geoVector3, scale float64) geoVector3 { + return geoVector3{x: vector.x * scale, y: vector.y * scale, z: vector.z * scale} +} + +func geoVectorAdd(first, second geoVector3) geoVector3 { + return geoVector3{x: first.x + second.x, y: first.y + second.y, z: first.z + second.z} +} + +func geoVectorNormalize(vector geoVector3) (geoVector3, bool) { + length := math.Sqrt(geoVectorDot(vector, vector)) + if length <= 1e-15 || math.IsNaN(length) || math.IsInf(length, 0) { + return geoVector3{}, false + } + return geoVectorScale(vector, 1/length), true +} + +// InterpolateGreatCircle 在较短大圆弧上按分数插值,分数超出 [0,1] 时沿弧延长 / interpolates the shorter great-circle arc, extending it beyond an endpoint. +func InterpolateGreatCircle(first, second GeoPoint, fraction float64) GeoPoint { + return sphericalInterpolate(first, second, fraction) +} + +func sphericalInterpolate(first, second GeoPoint, fraction float64) GeoPoint { + a := geoPointVector(first) + b := geoPointVector(second) + dot := math.Max(-1, math.Min(1, geoVectorDot(a, b))) + if dot > 1-1e-14 { + return geoVectorPoint(geoVectorAdd(geoVectorScale(a, 1-fraction), geoVectorScale(b, fraction))) + } + if dot < -1+1e-14 { + // Antipodal endpoints have no unique great circle. The path samplers + // never intentionally create one, but retain a finite fallback for + // malformed caller input. + return GeoPoint{ + Longitude: normalizeLongitude(first.Longitude + fraction*normalizeLongitude(second.Longitude-first.Longitude)), + Latitude: first.Latitude + fraction*(second.Latitude-first.Latitude), + } + } + angle := math.Acos(dot) + sine := math.Sin(angle) + value := geoVectorAdd( + geoVectorScale(a, math.Sin((1-fraction)*angle)/sine), + geoVectorScale(b, math.Sin(fraction*angle)/sine), + ) + return geoVectorPoint(value) +} + +func sphericalPointOnArc(point, first, second GeoPoint) bool { + firstVector := geoPointVector(first) + secondVector := geoPointVector(second) + pointVector := geoPointVector(point) + arc := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, secondVector)))) + if arc <= 1e-14 { + return math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, pointVector)))) <= 1e-9 + } + firstDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, pointVector)))) + secondDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(pointVector, secondVector)))) + return math.Abs(firstDistance+secondDistance-arc) <= 1e-9 +} + +// sphericalPolygonContainsOrTouches tests a simple ring using its minor great +// circle edges. It is intentionally internal: map encoders still own the +// projection-specific winding rules, while topology code needs a seam-free +// containment predicate for pole and antimeridian decisions. +func sphericalPolygonContainsOrTouches(polygon []GeoPoint, point GeoPoint) bool { + if len(polygon) < 3 { + return false + } + if SameGeoPoint(polygon[0], polygon[len(polygon)-1]) { + polygon = polygon[:len(polygon)-1] + } + target := geoPointVector(point) + angleSum := 0.0 + area := 0.0 + origin := geoPointVector(polygon[0]) + for index, currentPoint := range polygon { + previousPoint := polygon[(index+len(polygon)-1)%len(polygon)] + if sphericalPointOnArc(point, previousPoint, currentPoint) { + return true + } + previous := geoPointVector(previousPoint) + current := geoPointVector(currentPoint) + area += 2 * math.Atan2(geoVectorDot(origin, geoVectorCross(previous, current)), + 1+geoVectorDot(origin, previous)+geoVectorDot(previous, current)+geoVectorDot(current, origin)) + previousTangent := geoVectorAdd(previous, geoVectorScale(target, -geoVectorDot(previous, target))) + currentTangent := geoVectorAdd(current, geoVectorScale(target, -geoVectorDot(current, target))) + previousTangent, previousOK := geoVectorNormalize(previousTangent) + currentTangent, currentOK := geoVectorNormalize(currentTangent) + if !previousOK || !currentOK { + // A vertex and its antipode both have no tangent. Only the vertex + // belongs to the ring; acos roundoff can miss it in the arc check. + return (!previousOK && geoVectorDot(previous, target) > 0) || + (!currentOK && geoVectorDot(current, target) > 0) + } + cross := geoVectorCross(previousTangent, currentTangent) + angleSum += math.Atan2(geoVectorDot(target, cross), geoVectorDot(previousTangent, currentTangent)) + } + // Tangent winding has opposite signs inside the polygon and its antipodal + // image. Match the signed minor area instead of accepting both images. + return math.Abs(angleSum) > math.Pi && angleSum*math.Remainder(area, 4*math.Pi) > 0 +} + +// SphericalPolygonsContainPaths 判断每个路径顶点和加密边中点是否都位于球面多边形内。 +// SphericalPolygonsContainPaths reports whether every path vertex and every +// minor-great-circle edge midpoint lies in or on at least one polygon. When +// closePaths is true, the last vertex of each path is also joined to its first. +func SphericalPolygonsContainPaths(polygons, paths [][]GeoPoint, closePaths bool) bool { + return SphericalPolygonsContainPathsWithinKM(polygons, paths, closePaths, 0) +} + +// SphericalPolygonIndex 可复用的球面多边形包含索引 / a reusable containment index for one polygon set. +type SphericalPolygonIndex struct { + containment sphericalPolygonContainment +} + +// NewSphericalPolygonIndex 为多边形集合构建包含索引 / builds a containment index for the polygon set. +func NewSphericalPolygonIndex(polygons [][]GeoPoint) *SphericalPolygonIndex { + return &SphericalPolygonIndex{containment: newSphericalPolygonContainment(polygons)} +} + +// ContainsPoints 返回各点是否位于多边形内,结果与 points 对齐 / reports per-point containment aligned with points. +func (index *SphericalPolygonIndex) ContainsPoints(points []GeoPoint) []bool { + result := make([]bool, len(points)) + if index == nil { + return result + } + for pointIndex, point := range points { + result[pointIndex] = index.containment.contains(point) + } + return result +} + +// SphericalPolygonsContainPoints 用共享索引批量测试各个独立点 / tests independent points against one shared spherical polygon index. +func SphericalPolygonsContainPoints(polygons [][]GeoPoint, points []GeoPoint) []bool { + return NewSphericalPolygonIndex(polygons).ContainsPoints(points) +} + +// SphericalPolygonsContainPathsWithinKM 是 SphericalPolygonsContainPaths 的容差感知形式。 +// SphericalPolygonsContainPathsWithinKM is the tolerance-aware form of +// SphericalPolygonsContainPaths. It stops at the first probe farther than the +// requested distance from every polygon. +func SphericalPolygonsContainPathsWithinKM( + polygons, paths [][]GeoPoint, + closePaths bool, + toleranceKM float64, +) bool { + containment := newSphericalPolygonContainment(polygons) + return visitSphericalPathProbes(paths, closePaths, func(point GeoPoint) bool { + return containment.missDistanceKM(point) <= toleranceKM + }) +} + +// SphericalPolygonsPathMissDistanceKM 返回路径样本到多边形内部或边界的最大偏离距离。 +// SphericalPolygonsPathMissDistanceKM returns the greatest distance from a +// path probe outside all polygons to the nearest polygon edge. Vertices and +// minor-great-circle edge midpoints are probed; a fully contained path returns +// zero. +func SphericalPolygonsPathMissDistanceKM(polygons, paths [][]GeoPoint, closePaths bool) float64 { + containment := newSphericalPolygonContainment(polygons) + maximumMiss := 0.0 + visitSphericalPathProbes(paths, closePaths, func(point GeoPoint) bool { + maximumMiss = math.Max(maximumMiss, containment.missDistanceKM(point)) + return true + }) + return maximumMiss +} + +func visitSphericalPathProbes( + paths [][]GeoPoint, + closePaths bool, + visit func(GeoPoint) bool, +) bool { + for _, path := range paths { + path = openGeoRing(path) + for _, point := range path { + if !visit(point) { + return false + } + } + edgeCount := len(path) - 1 + if closePaths && len(path) > 1 { + edgeCount = len(path) + } + for index := 0; index < edgeCount; index++ { + if !visit(sphericalInterpolate(path[index], path[(index+1)%len(path)], 0.5)) { + return false + } + } + } + return true +} + +type sphericalPolygonContainment struct { + polygons [][]GeoPoint + projected []polygonUnionRing + center geoVector3 + xAxis geoVector3 + yAxis geoVector3 +} + +func newSphericalPolygonContainment(polygons [][]GeoPoint) sphericalPolygonContainment { + result := sphericalPolygonContainment{polygons: polygons} + center := geoVector3{} + for _, polygon := range polygons { + for _, point := range openGeoRing(polygon) { + center = geoVectorAdd(center, geoPointVector(point)) + } + } + var ok bool + result.center, ok = geoVectorNormalize(center) + if !ok { + return result + } + reference := geoVector3{z: 1} + if math.Abs(geoVectorDot(reference, result.center)) > 0.9 { + reference = geoVector3{x: 1} + } + result.xAxis, ok = geoVectorNormalize(geoVectorCross(reference, result.center)) + if !ok { + return result + } + result.yAxis, ok = geoVectorNormalize(geoVectorCross(result.center, result.xAxis)) + if !ok { + return result + } + result.projected = make([]polygonUnionRing, len(polygons)) + for polygonIndex, polygon := range polygons { + polygon = openGeoRing(polygon) + points := make([]polygonUnionPoint, len(polygon)) + for pointIndex, point := range polygon { + projected, projectedOK := result.project(point) + if !projectedOK { + result.projected = nil + return result + } + points[pointIndex] = projected + } + result.projected[polygonIndex] = polygonUnionRingForPoints(points) + } + return result +} + +func (containment sphericalPolygonContainment) project(point GeoPoint) (polygonUnionPoint, bool) { + vector := geoPointVector(point) + denominator := geoVectorDot(vector, containment.center) + if denominator <= 1e-12 { + return polygonUnionPoint{}, false + } + return polygonUnionPoint{ + x: geoVectorDot(vector, containment.xAxis) / denominator, + y: geoVectorDot(vector, containment.yAxis) / denominator, + }, true +} + +func (containment sphericalPolygonContainment) contains(point GeoPoint) bool { + if len(containment.projected) > 0 { + if projected, ok := containment.project(point); ok { + for _, polygon := range containment.projected { + if polygonUnionRingContainsPoint(projected, polygon) { + return true + } + } + return false + } + // Every ring is inside this gnomonic hemisphere. A point beyond its + // horizon cannot be inside and must not use antipodal tangent winding. + return false + } + for _, polygon := range containment.polygons { + if sphericalPolygonContainsOrTouches(polygon, point) { + return true + } + } + return false +} + +func (containment sphericalPolygonContainment) missDistanceKM(point GeoPoint) float64 { + if containment.contains(point) { + return 0 + } + return containment.sphericalMissDistanceKM(point) +} + +func (containment sphericalPolygonContainment) sphericalMissDistanceKM(point GeoPoint) float64 { + nearest := math.Inf(1) + for _, polygon := range containment.polygons { + polygon = openGeoRing(polygon) + for index, start := range polygon { + end := polygon[(index+1)%len(polygon)] + nearest = math.Min(nearest, sphericalPointArcDistanceKM(point, start, end)) + } + } + return nearest +} + +func sphericalPointArcDistanceKM(point, start, end GeoPoint) float64 { + pointVector := geoPointVector(point) + startVector := geoPointVector(start) + endVector := geoPointVector(end) + normal, ok := geoVectorNormalize(geoVectorCross(startVector, endVector)) + if ok { + projection := geoVectorAdd(pointVector, geoVectorScale(normal, -geoVectorDot(pointVector, normal))) + if projected, projectedOK := geoVectorNormalize(projection); projectedOK { + for _, candidate := range []geoVector3{projected, geoVectorScale(projected, -1)} { + candidatePoint := geoVectorPoint(candidate) + if sphericalPointOnArc(candidatePoint, start, end) { + angle := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(pointVector, candidate)))) + return angle * 6378.1366 + } + } + } + } + return math.Min(geoPointDistanceKM(point, start), geoPointDistanceKM(point, end)) +} + // JoinPolylineSegments 按最近端点连接无序边界线段 / JoinPolylineSegments joins unordered boundary segments by their nearest // 端点连接;输入线段不会被修改 / endpoints. The input segments are not modified. func JoinPolylineSegments(segments [][]GeoPoint) []GeoPoint { @@ -77,15 +433,25 @@ func JoinPolylineSegments(segments [][]GeoPoint) []GeoPoint { reverseGeoPoints(segment) } if bestPrepend { - result = append(segment, result...) + result = appendJoinedGeoPoints(segment, result) } else { - result = append(result, segment...) + result = appendJoinedGeoPoints(result, segment) } used[bestIndex] = true } return result } +// appendJoinedGeoPoints 拼接两段并丢掉衔接处重合的顶点。 +func appendJoinedGeoPoints(first, second []GeoPoint) []GeoPoint { + result := append([]GeoPoint(nil), first...) + start := 0 + for start < len(second) && len(result) > 0 && SameGeoPoint(result[len(result)-1], second[start]) { + start++ + } + return append(result, second[start:]...) +} + // ShortestCircleArc 返回两点之间较短的采样圆弧 / ShortestCircleArc returns the shorter sampled arc from one point to another. func ShortestCircleArc(circle []GeoPoint, from, to GeoPoint) []GeoPoint { if len(circle) == 0 { @@ -120,120 +486,6 @@ func SameGeoPoint(a, b GeoPoint) bool { math.Abs(a.Latitude-b.Latitude) < 1e-9 } -// VisibleHemispherePolygons 返回以指定中心为中心的半球多边形 / VisibleHemispherePolygons returns polygons for the hemisphere centered on -// 中心的半球多边形,并裁剪到请求的地图投影 / center, clipped to the requested map projection. -func VisibleHemispherePolygons(center GeoPoint, projection Projection, samples int) [][]GeoPoint { - if samples < 12 { - samples = 12 - } - if projection == ProjectionNorthPolar { - return [][]GeoPoint{polarVisibleHemispherePolygon(center, 1, samples/2)} - } - if projection == ProjectionSouthPolar { - return [][]GeoPoint{polarVisibleHemispherePolygon(center, -1, samples/2)} - } - return equirectangularVisibleHemispherePolygons(center, samples) -} - -func equirectangularVisibleHemispherePolygons(center GeoPoint, samples int) [][]GeoPoint { - if math.Abs(center.Latitude) < 1e-9 { - return equirectangularLongitudeBand(center.Longitude) - } - polygon := make([]GeoPoint, 0, samples+3) - for index := 0; index <= samples; index++ { - longitude := -180 + 360*float64(index)/float64(samples) - polygon = append(polygon, GeoPoint{ - Longitude: longitude, - Latitude: visibleHorizonLatitude(center, longitude), - }) - } - mapEdgeLatitude := math.Copysign(90, center.Latitude) - return [][]GeoPoint{append(polygon, - GeoPoint{Longitude: 180, Latitude: mapEdgeLatitude}, - GeoPoint{Longitude: -180, Latitude: mapEdgeLatitude}, - )} -} - -func equirectangularLongitudeBand(centerLongitude float64) [][]GeoPoint { - centerLongitude = normalizeLongitude(centerLongitude) - start, end := centerLongitude-90, centerLongitude+90 - var polygons [][]GeoPoint - for _, shift := range []float64{-360, 0, 360} { - left := math.Max(-180, start+shift) - right := math.Min(180, end+shift) - if right-left <= 1e-9 { - continue - } - polygons = append(polygons, []GeoPoint{ - {Longitude: left, Latitude: -90}, - {Longitude: right, Latitude: -90}, - {Longitude: right, Latitude: 90}, - {Longitude: left, Latitude: 90}, - }) - } - return polygons -} - -func polarVisibleHemispherePolygon(center GeoPoint, hemisphere float64, samples int) []GeoPoint { - if samples < 6 { - samples = 6 - } - if math.Abs(center.Latitude) < 1e-9 { - polygon := []GeoPoint{ - {Longitude: normalizeLongitude(center.Longitude - 90), Latitude: 0}, - {Longitude: normalizeLongitude(center.Longitude), Latitude: 90 * hemisphere}, - {Longitude: normalizeLongitude(center.Longitude + 90), Latitude: 0}, - } - return appendPolarVisibilityRim(polygon, center.Longitude, false, samples) - } - - centerLongitude := normalizeLongitude(center.Longitude) - centerInsideProjection := center.Latitude*hemisphere > 0 - horizonMidpoint := centerLongitude - if centerInsideProjection { - horizonMidpoint += 180 - } - polygon := make([]GeoPoint, 0, 2*samples+1) - for index := 0; index <= samples; index++ { - longitude := horizonMidpoint - 90 + 180*float64(index)/float64(samples) - latitude := visibleHorizonLatitude(center, longitude) - if latitude*hemisphere < 0 && math.Abs(latitude) < 1e-9 { - latitude = 0 - } - polygon = append(polygon, GeoPoint{ - Longitude: normalizeLongitude(longitude), - Latitude: latitude, - }) - } - return appendPolarVisibilityRim(polygon, centerLongitude, centerInsideProjection, samples) -} - -func appendPolarVisibilityRim( - polygon []GeoPoint, - centerLongitude float64, - centerInsideProjection bool, - samples int, -) []GeoPoint { - for index := 1; index <= samples; index++ { - fraction := float64(index) / float64(samples) - longitude := centerLongitude + 90 - 180*fraction - if centerInsideProjection { - longitude = centerLongitude - 90 + 180*fraction - } - polygon = append(polygon, GeoPoint{ - Longitude: normalizeLongitude(longitude), - Latitude: 0, - }) - } - return polygon -} - -func visibleHorizonLatitude(center GeoPoint, longitude float64) float64 { - declination := center.Latitude * math.Pi / 180 - deltaLongitude := (longitude - center.Longitude) * math.Pi / 180 - return math.Atan(-math.Cos(declination)*math.Cos(deltaLongitude)/math.Sin(declination)) * 180 / math.Pi -} - func nearestGeoPointIndex(points []GeoPoint, target GeoPoint) int { bestIndex := 0 bestDistance := math.Inf(1) diff --git a/internal/geodata/sphere_test.go b/internal/geodata/sphere_test.go index c0e353a..4e10539 100644 --- a/internal/geodata/sphere_test.go +++ b/internal/geodata/sphere_test.go @@ -1,6 +1,9 @@ package geodata -import "testing" +import ( + "math" + "testing" +) func TestJoinPolylineSegmentsUsesBothResultEndpoints(t *testing.T) { segments := [][]GeoPoint{ @@ -25,9 +28,99 @@ func TestJoinPolylineSegmentsUsesBothResultEndpoints(t *testing.T) { } } +func TestSphericalPolygonsContainPointsMatchesIndividualPathChecks(t *testing.T) { + polygons := [][]GeoPoint{{ + {Longitude: -20, Latitude: -10}, + {Longitude: 20, Latitude: -10}, + {Longitude: 20, Latitude: 10}, + {Longitude: -20, Latitude: 10}, + }} + points := []GeoPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 20, Latitude: 0}, + {Longitude: 40, Latitude: 0}, + } + got := SphericalPolygonsContainPoints(polygons, points) + if len(got) != len(points) { + t.Fatalf("result count=%d, want %d", len(got), len(points)) + } + for index, point := range points { + want := SphericalPolygonsContainPaths(polygons, [][]GeoPoint{{point}}, false) + if got[index] != want { + t.Fatalf("point %d result=%v, want %v", index, got[index], want) + } + } +} + func absoluteLongitude(value float64) float64 { if value < 0 { return -value } return value } + +func TestSphericalContainmentRejectsAntipodalInterior(t *testing.T) { + for _, center := range []GeoPoint{{}, {Longitude: 179, Latitude: 70}, {Latitude: -90}} { + polygon := SphericalCircle(center, 12, 48) + antipode := GeoPoint{Longitude: normalizeLongitude(center.Longitude + 180), Latitude: -center.Latitude} + for _, reversed := range []bool{false, true} { + if reversed { + for first, last := 0, len(polygon)-1; first < last; first, last = first+1, last-1 { + polygon[first], polygon[last] = polygon[last], polygon[first] + } + } + if !sphericalPolygonContainsOrTouches(polygon, center) || sphericalPolygonContainsOrTouches(polygon, antipode) { + t.Errorf("center=%+v reversed=%v: direct containment must distinguish the antipode", center, reversed) + } + points := []GeoPoint{center, antipode, polygon[0], { + Longitude: normalizeLongitude(polygon[0].Longitude + 180), Latitude: -polygon[0].Latitude, + }} + got := SphericalPolygonsContainPoints([][]GeoPoint{polygon}, points) + for index, want := range []bool{true, false, true, false} { + if got[index] != want { + t.Errorf("center=%+v reversed=%v point=%+v contains=%v want=%v", center, reversed, points[index], got[index], want) + } + } + } + } + // These components cannot share a gnomonic chart. The spherical fallback + // must also distinguish each component from its antipodal image. + polygons := [][]GeoPoint{ + SphericalCircle(GeoPoint{}, 12, 48), + SphericalCircle(GeoPoint{Longitude: 160}, 12, 48), + } + got := SphericalPolygonsContainPoints(polygons, []GeoPoint{{}, {Longitude: 160}, {Longitude: 180}, {Longitude: -20}}) + for index, want := range []bool{true, true, false, false} { + if got[index] != want { + t.Errorf("disjoint components: point %d contains=%v want=%v", index, got[index], want) + } + } +} + +func TestSphericalCircleRejectsDegenerateRadii(t *testing.T) { + center := GeoPoint{Longitude: 10, Latitude: 20} + for _, radius := range []float64{0, -1, 180, 181, math.Inf(1), math.NaN()} { + if circle := SphericalCircle(center, radius, 32); len(circle) != 0 { + t.Fatalf("radius %v produced %d points, want none", radius, len(circle)) + } + } + if circle := SphericalCircle(center, 1, 32); len(circle) != 32 { + t.Fatalf("valid radius produced %d points, want 32", len(circle)) + } +} + +func TestJoinPolylineSegmentsDropsSharedJunctionVertex(t *testing.T) { + first := []GeoPoint{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}} + second := []GeoPoint{{Longitude: 1, Latitude: 1}, {Longitude: 2, Latitude: 0}} + for _, segments := range [][][]GeoPoint{{first, second}, {second, first}} { + joined := JoinPolylineSegments(segments) + if len(joined) != 3 { + t.Fatalf("joined=%v, want three points", joined) + } + for index := 1; index < len(joined); index++ { + if SameGeoPoint(joined[index-1], joined[index]) { + t.Fatalf("joined=%v keeps the shared junction twice", joined) + } + } + } +} diff --git a/internal/geodata/sweep.go b/internal/geodata/sweep.go new file mode 100644 index 0000000..c07aebf --- /dev/null +++ b/internal/geodata/sweep.go @@ -0,0 +1,672 @@ +package geodata + +import ( + "errors" + "fmt" + "math" +) + +// ErrOpenBoundaryEndpointTrackReversal 表示扫掠边界的端点轨迹发生了物理折返。 +// ErrOpenBoundaryEndpointTrackReversal reports a physical reversal along a swept boundary endpoint track. +var ErrOpenBoundaryEndpointTrackReversal = errors.New("open boundary endpoint track reversal") + +const ( + openBoundaryEndpointStepNoiseKM = 1.0 + openBoundaryEndpointProgressBackKM = 5.0 +) + +// OpenBoundarySweepSample 是用于构造连续扫掠区域的一个边界样本。 +// OpenBoundarySweepSample is one sampled boundary used to build a continuous +// sweep. Closed samples are ignored because they already describe an +// instantaneous footprint and are not part of an open non-central band. +type OpenBoundarySweepSample struct { + Boundaries [][]GeoPoint + Closed bool +} + +type openBoundarySweepSample struct { + boundary []GeoPoint + start GeoPoint + end GeoPoint +} + +// OpenBoundarySweep 将开放边界弧连接为连续扫掠多边形。 +// OpenBoundarySweep joins open boundary arcs into continuous swept polygons. +// The first and last arcs close each group; the matching endpoint tracks form +// the two sides. A large endpoint jump starts a new group instead of creating +// a false bridge across a branch change. +func OpenBoundarySweep(samples []OpenBoundarySweepSample) ([][]GeoPoint, error) { + groups, err := openBoundarySweepGroups(samples) + if err != nil { + return nil, err + } + + polygons := make([][]GeoPoint, 0, len(groups)) + for _, group := range groups { + groupPolygons, groupErr := sweepOpenBoundaryGroup(group) + if groupErr != nil { + return nil, groupErr + } + polygons = append(polygons, groupPolygons...) + } + return polygons, nil +} + +// MonotoneOpenBoundarySweep 连接端点轨迹和中间弧已知单调的开放弧。 +// MonotoneOpenBoundarySweep joins open arcs whose endpoint tracks and +// intermediate arcs are already known to be monotone. It avoids the general +// containment audit used by OpenBoundarySweep and is intended for tightly +// sampled, event-local contact caps. +func MonotoneOpenBoundarySweep(samples []OpenBoundarySweepSample) ([][]GeoPoint, error) { + groups, err := openBoundarySweepGroups(samples) + if err != nil { + return nil, err + } + polygons := make([][]GeoPoint, 0, len(groups)) + for index, group := range groups { + if !sweepEndpointTracksMonotone(group) { + return nil, fmt.Errorf("%w in group %d", ErrOpenBoundaryEndpointTrackReversal, index) + } + outline := sweepOpenBoundaryOutline(group) + if len(outline) < 3 { + return nil, fmt.Errorf("monotone open boundary sweep group %d has no usable outline", index) + } + if !sweepOutlineContainsRepresentativeSamples(outline, group) { + return nil, fmt.Errorf("monotone open boundary sweep group %d folds outside its endpoint tracks", index) + } + polygons = append(polygons, outline) + } + return polygons, nil +} + +func sweepEndpointTracksMonotone(group []openBoundarySweepSample) bool { + if len(group) < 3 { + return true + } + for _, endpoint := range []func(openBoundarySweepSample) GeoPoint{ + func(sample openBoundarySweepSample) GeoPoint { return sample.start }, + func(sample openBoundarySweepSample) GeoPoint { return sample.end }, + } { + first := geoPointUnitVector(endpoint(group[0])) + last := geoPointUnitVector(endpoint(group[len(group)-1])) + direction := geoPointTangentDirection(first, last) + if vectorNorm(direction) <= 1e-12 { + continue + } + previousProgress := 0.0 + previousVector := first + var previousStep [3]float64 + for index := 1; index < len(group); index++ { + current := geoPointUnitVector(endpoint(group[index])) + step := [3]float64{ + current[0] - previousVector[0], + current[1] - previousVector[1], + current[2] - previousVector[2], + } + if index > 1 { + previousLength := vectorNorm(previousStep) + currentLength := vectorNorm(step) + if math.Min(previousLength, currentLength)*6371.0088 > openBoundaryEndpointStepNoiseKM && + vectorDot(previousStep, step) < -0.1*previousLength*currentLength { + return false + } + } + progress := vectorDot(current, direction) + // A long spherical track can deviate slightly from its first-to-last + // tangent chord even while every local step remains forward. Reserve a + // few kilometres for that projection effect; larger gradual reversals + // still invalidate the endpoint envelope. + if progress+openBoundaryEndpointProgressBackKM/6371.0088 < previousProgress { + return false + } + previousProgress = progress + previousVector = current + previousStep = step + } + } + return true +} + +func geoPointUnitVector(point GeoPoint) [3]float64 { + latitude := point.Latitude * math.Pi / 180 + longitude := point.Longitude * math.Pi / 180 + cosLatitude := math.Cos(latitude) + return [3]float64{ + cosLatitude * math.Cos(longitude), + cosLatitude * math.Sin(longitude), + math.Sin(latitude), + } +} + +func geoPointTangentDirection(first, last [3]float64) [3]float64 { + dot := vectorDot(first, last) + return [3]float64{ + last[0] - dot*first[0], + last[1] - dot*first[1], + last[2] - dot*first[2], + } +} + +func vectorDot(first, second [3]float64) float64 { + return first[0]*second[0] + first[1]*second[1] + first[2]*second[2] +} + +func vectorNorm(value [3]float64) float64 { + return math.Sqrt(vectorDot(value, value)) +} + +// OpenBoundaryEndpointOutlines 返回各开放边界分组的低成本端点轨迹轮廓。 +// OpenBoundaryEndpointOutlines returns the inexpensive endpoint-track outline +// for each open-boundary group. The outlines intentionally omit intermediate +// arc bulges, so callers must combine them with the source footprint polygons; +// they are not a standalone geometric union. +func OpenBoundaryEndpointOutlines(samples []OpenBoundarySweepSample) ([][]GeoPoint, error) { + groups, err := openBoundarySweepGroups(samples) + if err != nil { + return nil, err + } + polygons := make([][]GeoPoint, 0, len(groups)) + for index, group := range groups { + if len(group) < 2 { + continue + } + outline := sweepOpenBoundaryOutline(group) + if len(outline) < 3 { + return nil, fmt.Errorf("open boundary endpoint group %d has no usable outline", index) + } + polygons = append(polygons, outline) + } + if len(polygons) == 0 { + return nil, fmt.Errorf("open boundary samples contain no endpoint track") + } + return polygons, nil +} + +// DecimateOpenBoundarySweepSamples 在保留端点和分组的前提下限制通用带状 union 的采样成本。 +// DecimateOpenBoundarySweepSamples bounds the cost of the general ribbon +// union while retaining every group endpoint and closed-sample separator. +func DecimateOpenBoundarySweepSamples( + samples []OpenBoundarySweepSample, + maximumGroupSamples int, + targetSpacingKM float64, +) []OpenBoundarySweepSample { + if maximumGroupSamples < 2 { + maximumGroupSamples = 2 + } + result := make([]OpenBoundarySweepSample, 0, len(samples)) + flush := func(group []OpenBoundarySweepSample) { + if len(group) == 0 { + return + } + selected := group + if len(group) > maximumGroupSamples { + selected = make([]OpenBoundarySweepSample, 0, maximumGroupSamples) + last := len(group) - 1 + lastPosition := -1 + for index := 0; index < maximumGroupSamples; index++ { + position := int(math.Round(float64(index*last) / float64(maximumGroupSamples-1))) + if position != lastPosition { + selected = append(selected, group[position]) + lastPosition = position + } + } + } + for _, sample := range selected { + copySample := OpenBoundarySweepSample{Closed: sample.Closed} + copySample.Boundaries = make([][]GeoPoint, len(sample.Boundaries)) + for index, boundary := range sample.Boundaries { + copySample.Boundaries[index] = decimateOpenBoundaryPoints(boundary, targetSpacingKM) + } + result = append(result, copySample) + } + } + group := make([]OpenBoundarySweepSample, 0, maximumGroupSamples) + for _, sample := range samples { + if sample.Closed { + flush(group) + group = group[:0] + if len(result) == 0 || !result[len(result)-1].Closed { + result = append(result, OpenBoundarySweepSample{Closed: true}) + } + continue + } + group = append(group, sample) + } + flush(group) + return result +} + +// OpenBoundarySweepInnerCaps 闭合紧邻内部闭合阶段的小地平缺口。 +// OpenBoundarySweepInnerCaps closes the small horizon gaps immediately next +// to a closed-sample separator. Those nearly closed arcs overlap the paired +// middle band; leaving their gap open can retain a narrow inward notch after +// polygon union. +func OpenBoundarySweepInnerCaps( + samples []OpenBoundarySweepSample, + maximumGapKM float64, +) [][]GeoPoint { + if maximumGapKM <= 0 { + return nil + } + indices := make([]int, 0, 2) + for index := 0; index < len(samples); index++ { + if !samples[index].Closed { + continue + } + if index > 0 && !samples[index-1].Closed { + indices = append(indices, index-1) + } + for index+1 < len(samples) && samples[index+1].Closed { + index++ + } + if index+1 < len(samples) && !samples[index+1].Closed { + indices = append(indices, index+1) + } + } + caps := make([][]GeoPoint, 0, len(indices)) + for _, index := range indices { + boundary := openGeoRing(JoinPolylineSegments(samples[index].Boundaries)) + if len(boundary) < 3 || geoPointDistanceKM(boundary[0], boundary[len(boundary)-1]) > maximumGapKM { + continue + } + caps = append(caps, boundary) + } + return caps +} + +func decimateOpenBoundaryPoints(points []GeoPoint, targetSpacingKM float64) []GeoPoint { + if len(points) < 3 || targetSpacingKM <= 0 { + return append([]GeoPoint(nil), points...) + } + result := make([]GeoPoint, 1, len(points)) + result[0] = points[0] + for _, point := range points[1 : len(points)-1] { + if geoPointDistanceKM(result[len(result)-1], point) >= targetSpacingKM { + result = append(result, point) + } + } + if !SameGeoPoint(result[len(result)-1], points[len(points)-1]) { + result = append(result, points[len(points)-1]) + } + return result +} + +func sweepOutlineContainsRepresentativeSamples( + outline []GeoPoint, + group []openBoundarySweepSample, +) bool { + const checksPerArc = 5 + for _, sample := range group { + for check := 0; check < checksPerArc; check++ { + index := check * (len(sample.boundary) - 1) / (checksPerArc - 1) + if !sweepPointInPolygon(outline, sample.boundary[index]) { + return false + } + } + } + return true +} + +func openBoundarySweepGroups(samples []OpenBoundarySweepSample) ([][]openBoundarySweepSample, error) { + groups := make([][]openBoundarySweepSample, 0, 2) + current := make([]openBoundarySweepSample, 0, len(samples)) + var previous openBoundarySweepSample + for _, source := range samples { + // A closed footprint is an instantaneous cap, not an open arc. It + // terminates the current open branch so later samples cannot bridge + // across a visibility/type transition. + if source.Closed { + if len(current) > 0 { + groups = append(groups, current) + current = nil + } + previous = openBoundarySweepSample{} + continue + } + boundary := JoinPolylineSegments(source.Boundaries) + boundary = openGeoRing(boundary) + if len(boundary) < 2 { + continue + } + value := openBoundarySweepSample{boundary: boundary, start: boundary[0], end: boundary[len(boundary)-1]} + if len(current) > 0 { + keep := geoPointDistanceKM(previous.start, value.start) + + geoPointDistanceKM(previous.end, value.end) + reverse := geoPointDistanceKM(previous.start, value.end) + + geoPointDistanceKM(previous.end, value.start) + if reverse < keep { + reverseSweepGeoPoints(value.boundary) + value.start, value.end = value.end, value.start + } + if math.Max( + geoPointDistanceKM(previous.start, value.start), + geoPointDistanceKM(previous.end, value.end), + ) > 2000 { + groups = append(groups, current) + current = nil + } + } + current = append(current, value) + previous = value + } + if len(current) > 0 { + groups = append(groups, current) + } + if len(groups) == 0 { + return nil, fmt.Errorf("open boundary samples contain no usable arcs") + } + return groups, nil +} + +// sweepOpenBoundaryGroup constructs the union swept by a sequence of open +// arcs. Each adjacent pair forms a ribbon whose caps are the two sampled arcs +// and whose sides follow their endpoints. Unioning the ribbons retains any +// intermediate arc that becomes part of a non-monotone outer boundary; a +// convex hull would replace that boundary with visibly incorrect chords. +func sweepOpenBoundaryGroup(group []openBoundarySweepSample) ([][]GeoPoint, error) { + if len(group) == 0 { + return nil, fmt.Errorf("open boundary sweep group is empty") + } + if len(group) == 1 { + return [][]GeoPoint{append([]GeoPoint(nil), group[0].boundary...)}, nil + } + if outline := sweepOpenBoundaryOutline(group); len(outline) >= 3 && sweepOutlineContainsSamples(outline, group) { + return [][]GeoPoint{outline}, nil + } + ribbons := make([][]GeoPoint, 0, len(group)-1) + for index := 1; index < len(group); index++ { + first := group[index-1].boundary + second := group[index].boundary + ring := make([]GeoPoint, 0, len(first)+len(second)) + ring = append(ring, first...) + for pointIndex := len(second) - 1; pointIndex >= 0; pointIndex-- { + ring = append(ring, second[pointIndex]) + } + ring = sweepDeduplicateAdjacent(ring) + if len(ring) < 3 { + return nil, fmt.Errorf("open boundary sweep ribbon %d has no usable boundary", index-1) + } + ribbons = append(ribbons, ring) + } + polygons, err := UnionPolygons(ribbons) + if err != nil { + return nil, fmt.Errorf("open boundary sweep ribbons: %w", err) + } + polygons, err = sweepBridgeTouchingPolygons(polygons) + if err != nil { + return nil, fmt.Errorf("open boundary sweep touching ribbons: %w", err) + } + return polygons, nil +} + +// sweepOpenBoundaryOutline is the simple outer envelope for a non-crossing +// sequence of arcs. The two endpoint tracks are the lateral edges of the +// swept band; using them directly avoids retaining every overlapping ribbon +// edge as a false inward spike near a tangent horizon. +func sweepOpenBoundaryOutline(group []openBoundarySweepSample) []GeoPoint { + if len(group) < 2 { + return nil + } + first, last := group[0].boundary, group[len(group)-1].boundary + if len(first) < 2 || len(last) < 2 { + return nil + } + ring := make([]GeoPoint, 0, len(first)+len(last)+2*len(group)) + ring = appendUnwrappedSweepPoints(ring, first) + for index := 1; index < len(group); index++ { + ring = appendUnwrappedSweepPoint(ring, group[index].end) + } + for index := len(last) - 1; index >= 0; index-- { + ring = appendUnwrappedSweepPoint(ring, last[index]) + } + for index := len(group) - 2; index >= 0; index-- { + ring = appendUnwrappedSweepPoint(ring, group[index].start) + } + return sweepDeduplicateAdjacent(ring) +} + +func appendUnwrappedSweepPoints(result []GeoPoint, points []GeoPoint) []GeoPoint { + for _, point := range points { + result = appendUnwrappedSweepPoint(result, point) + } + return result +} + +func appendUnwrappedSweepPoint(result []GeoPoint, point GeoPoint) []GeoPoint { + if len(result) == 0 { + return append(result, point) + } + longitude := point.Longitude + for longitude-result[len(result)-1].Longitude > 180 { + longitude -= 360 + } + for longitude-result[len(result)-1].Longitude < -180 { + longitude += 360 + } + return append(result, GeoPoint{Longitude: longitude, Latitude: point.Latitude}) +} + +func sweepOutlineContainsSamples(outline []GeoPoint, group []openBoundarySweepSample) bool { + if len(outline) < 3 { + return false + } + for _, sample := range group { + for _, point := range sample.boundary { + if !sweepPointInPolygon(outline, point) { + return false + } + } + } + return true +} + +func sweepPointInPolygon(polygon []GeoPoint, point GeoPoint) bool { + if len(polygon) < 3 { + return false + } + longitude := point.Longitude + for longitude-polygon[0].Longitude > 180 { + longitude -= 360 + } + for longitude-polygon[0].Longitude < -180 { + longitude += 360 + } + probe := GeoPoint{Longitude: longitude, Latitude: point.Latitude} + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + if sweepPointOnSegment(probe, polygon[previous], polygon[current]) { + return true + } + } + inside := false + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + first, second := polygon[previous], polygon[current] + if (first.Latitude > point.Latitude) == (second.Latitude > point.Latitude) { + continue + } + intersection := first.Longitude + + (point.Latitude-first.Latitude)*(second.Longitude-first.Longitude)/(second.Latitude-first.Latitude) + if intersection >= longitude { + inside = !inside + } + } + return inside +} + +func sweepPointOnSegment(point, start, end GeoPoint) bool { + deltaLongitude := end.Longitude - start.Longitude + deltaLatitude := end.Latitude - start.Latitude + length := math.Hypot(deltaLongitude, deltaLatitude) + if length <= 1e-12 { + return math.Hypot(point.Longitude-start.Longitude, point.Latitude-start.Latitude) <= 1e-9 + } + cross := deltaLongitude*(point.Latitude-start.Latitude) - + deltaLatitude*(point.Longitude-start.Longitude) + if math.Abs(cross) > 1e-9*length { + return false + } + dot := (point.Longitude-start.Longitude)*deltaLongitude + + (point.Latitude-start.Latitude)*deltaLatitude + return dot >= -1e-9*length && dot <= length*length+1e-9*length +} + +func sweepBridgeTouchingPolygons(polygons [][]GeoPoint) ([][]GeoPoint, error) { + const ( + touchingDistanceKM = 0.01 + bridgeHalfSizeDeg = 1e-4 + ) + for len(polygons) > 1 { + firstIndex, secondIndex, touchingPoint, ok := sweepTouchingPolygonPair(polygons, touchingDistanceKM) + if !ok { + return polygons, nil + } + bridge := []GeoPoint{ + {Longitude: touchingPoint.Longitude - bridgeHalfSizeDeg, Latitude: touchingPoint.Latitude - bridgeHalfSizeDeg}, + {Longitude: touchingPoint.Longitude + bridgeHalfSizeDeg, Latitude: touchingPoint.Latitude - bridgeHalfSizeDeg}, + {Longitude: touchingPoint.Longitude + bridgeHalfSizeDeg, Latitude: touchingPoint.Latitude + bridgeHalfSizeDeg}, + {Longitude: touchingPoint.Longitude - bridgeHalfSizeDeg, Latitude: touchingPoint.Latitude + bridgeHalfSizeDeg}, + } + // 输入是并集输出,环之间互不重叠,因此只需合并这一对。 + merged, err := UnionPolygons([][]GeoPoint{polygons[firstIndex], polygons[secondIndex], bridge}) + if err != nil { + return nil, err + } + if len(merged) >= 2 { + return polygons, nil + } + polygons = sweepReplaceTouchingPolygonPair(polygons, firstIndex, secondIndex, merged) + } + return polygons, nil +} + +const sweepTouchingCellSize = 2e-6 + +type sweepTouchingVertex struct { + polygon int + point GeoPoint +} + +// sweepTouchingPolygonPair 返回扫描顺序最前的一对近邻顶点:先按多边形下标,再按顶点下标。 +func sweepTouchingPolygonPair( + polygons [][]GeoPoint, + thresholdKM float64, +) (int, int, GeoPoint, bool) { + cells := make(map[int64][]sweepTouchingVertex) + for polygonIndex, polygon := range polygons { + for _, point := range polygon { + key := sweepTouchingCellKey(geoPointVector(point)) + cells[key] = append(cells[key], sweepTouchingVertex{polygon: polygonIndex, point: point}) + } + } + pairs := make(map[[2]int]struct{}) + for polygonIndex, polygon := range polygons { + for _, point := range polygon { + x, y, z := sweepTouchingCellCoordinates(geoPointVector(point)) + for deltaX := int64(-1); deltaX <= 1; deltaX++ { + for deltaY := int64(-1); deltaY <= 1; deltaY++ { + for deltaZ := int64(-1); deltaZ <= 1; deltaZ++ { + key := sweepTouchingCellKeyFromCoordinates(x+deltaX, y+deltaY, z+deltaZ) + for _, candidate := range cells[key] { + if candidate.polygon <= polygonIndex || + geoPointDistanceKM(point, candidate.point) > thresholdKM { + continue + } + pairs[[2]int{polygonIndex, candidate.polygon}] = struct{}{} + } + } + } + } + } + } + firstIndex, secondIndex := -1, -1 + for pair := range pairs { + if firstIndex < 0 || pair[0] < firstIndex || (pair[0] == firstIndex && pair[1] < secondIndex) { + firstIndex, secondIndex = pair[0], pair[1] + } + } + if firstIndex < 0 { + return 0, 0, GeoPoint{}, false + } + for _, first := range polygons[firstIndex] { + for _, second := range polygons[secondIndex] { + if !(geoPointDistanceKM(first, second) > thresholdKM) { + return firstIndex, secondIndex, GeoPoint{ + Longitude: first.Longitude + math.Remainder(second.Longitude-first.Longitude, 360)/2, + Latitude: (first.Latitude + second.Latitude) / 2, + }, true + } + } + } + return 0, 0, GeoPoint{}, false +} + +// 网格边长 2e-6 弧度,大于 0.01 公里的球面弦长,近邻顶点必落在相邻格。 +func sweepTouchingCellCoordinates(vector geoVector3) (int64, int64, int64) { + const offset = 1 << 19 + return int64(math.Floor(vector.x/sweepTouchingCellSize)) + offset, + int64(math.Floor(vector.y/sweepTouchingCellSize)) + offset, + int64(math.Floor(vector.z/sweepTouchingCellSize)) + offset +} + +func sweepTouchingCellKey(vector geoVector3) int64 { + x, y, z := sweepTouchingCellCoordinates(vector) + return sweepTouchingCellKeyFromCoordinates(x, y, z) +} + +func sweepTouchingCellKeyFromCoordinates(x, y, z int64) int64 { + return x<<40 | y<<20 | z +} + +func sweepReplaceTouchingPolygonPair( + polygons [][]GeoPoint, + first, second int, + merged [][]GeoPoint, +) [][]GeoPoint { + if first > second { + first, second = second, first + } + result := make([][]GeoPoint, 0, len(polygons)-1+len(merged)) + result = append(result, polygons[:first]...) + result = append(result, merged...) + result = append(result, polygons[first+1:second]...) + result = append(result, polygons[second+1:]...) + return result +} + +func sweepDeduplicateAdjacent(points []GeoPoint) []GeoPoint { + if len(points) < 2 { + return points + } + result := make([]GeoPoint, 0, len(points)) + for _, point := range points { + if len(result) == 0 || !SameGeoPoint(result[len(result)-1], point) { + result = append(result, point) + } + } + if len(result) > 1 && SameGeoPoint(result[0], result[len(result)-1]) { + result = result[:len(result)-1] + } + return result +} + +func openGeoRing(points []GeoPoint) []GeoPoint { + if len(points) > 1 && SameGeoPoint(points[0], points[len(points)-1]) { + return points[:len(points)-1] + } + return points +} + +func reverseSweepGeoPoints(points []GeoPoint) { + for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 { + points[left], points[right] = points[right], points[left] + } +} + +func geoPointDistanceKM(first, second GeoPoint) float64 { + firstLatitude := first.Latitude * math.Pi / 180 + secondLatitude := second.Latitude * math.Pi / 180 + deltaLatitude := secondLatitude - firstLatitude + deltaLongitude := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi) + haversine := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) + + math.Cos(firstLatitude)*math.Cos(secondLatitude)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2) + return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, haversine))) +} diff --git a/internal/geodata/sweep_test.go b/internal/geodata/sweep_test.go new file mode 100644 index 0000000..3fe24e1 --- /dev/null +++ b/internal/geodata/sweep_test.go @@ -0,0 +1,351 @@ +package geodata + +import ( + "errors" + "math" + "testing" + "time" +) + +func TestOpenBoundarySweepContainsIntermediateArcs(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{{Longitude: -2, Latitude: 0}, {Longitude: 2, Latitude: 0}}}}, + {Boundaries: [][]GeoPoint{{ + {Longitude: -1.5, Latitude: 0.5}, + {Longitude: 0, Latitude: 3}, + {Longitude: 1.5, Latitude: 0.5}, + }}}, + {Boundaries: [][]GeoPoint{{{Longitude: -1, Latitude: 1}, {Longitude: 1, Latitude: 1}}}}, + } + polygons, err := OpenBoundarySweep(samples) + if err != nil { + t.Fatalf("OpenBoundarySweep: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("polygon count = %d, want 1", len(polygons)) + } + for _, point := range []GeoPoint{ + {Longitude: -2, Latitude: 0}, + {Longitude: 0, Latitude: 3}, + {Longitude: 2, Latitude: 0}, + } { + if !sweepPolygonContainsOrTouches(polygons[0], point) { + t.Fatalf("sweep polygon does not contain boundary point %+v", point) + } + } +} + +func TestOpenBoundarySweepSeparatesGroupsAcrossClosedSample(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{{Longitude: -20, Latitude: 0}, {Longitude: -10, Latitude: 0}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: -19, Latitude: 1}, {Longitude: -9, Latitude: 1}}}}, + {Closed: true, Boundaries: [][]GeoPoint{{ + {Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 0}, {Longitude: 0, Latitude: 1}, + }}}, + {Boundaries: [][]GeoPoint{{{Longitude: 10, Latitude: 0}, {Longitude: 20, Latitude: 0}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 11, Latitude: 1}, {Longitude: 21, Latitude: 1}}}}, + } + polygons, err := OpenBoundarySweep(samples) + if err != nil { + t.Fatalf("OpenBoundarySweep: %v", err) + } + if len(polygons) != 2 { + t.Fatalf("polygon count=%d, want two groups separated by the closed sample", len(polygons)) + } +} + +func TestMonotoneOpenBoundarySweepUsesEndpointTracks(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{{Longitude: -2, Latitude: 0}, {Longitude: 2, Latitude: 0}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: -1.5, Latitude: 1}, {Longitude: 1.5, Latitude: 1}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: -1, Latitude: 2}, {Longitude: 1, Latitude: 2}}}}, + } + polygons, err := MonotoneOpenBoundarySweep(samples) + if err != nil { + t.Fatalf("MonotoneOpenBoundarySweep: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("polygon count=%d, want one monotone outline", len(polygons)) + } + for _, point := range []GeoPoint{ + {Longitude: -2, Latitude: 0}, + {Longitude: 2, Latitude: 0}, + {Longitude: -1.5, Latitude: 1}, + {Longitude: 1.5, Latitude: 1}, + {Longitude: -1, Latitude: 2}, + {Longitude: 1, Latitude: 2}, + } { + found := false + for _, candidate := range polygons[0] { + if SameGeoPoint(candidate, point) { + found = true + break + } + } + if !found { + t.Fatalf("monotone outline is missing endpoint-track point %+v", point) + } + } +} + +func TestMonotoneOpenBoundarySweepRejectsIntermediateBulge(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{{Longitude: -2, Latitude: 0}, {Longitude: 2, Latitude: 0}}}}, + {Boundaries: [][]GeoPoint{{ + {Longitude: -1.5, Latitude: 0.5}, + {Longitude: 0, Latitude: 3}, + {Longitude: 1.5, Latitude: 0.5}, + }}}, + {Boundaries: [][]GeoPoint{{{Longitude: -1, Latitude: 1}, {Longitude: 1, Latitude: 1}}}}, + } + if _, err := MonotoneOpenBoundarySweep(samples); err == nil { + t.Fatal("monotone sweep accepted an intermediate arc outside its endpoint tracks") + } +} + +func TestMonotoneOpenBoundarySweepRejectsEndpointTrackReversal(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 2, Latitude: 0}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 1, Latitude: 0.5}, {Longitude: 3, Latitude: 0.5}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 0.5, Latitude: 1}, {Longitude: 2.5, Latitude: 1}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 1.5}, {Longitude: 2, Latitude: 1.5}}}}, + } + if _, err := MonotoneOpenBoundarySweep(samples); !errors.Is(err, ErrOpenBoundaryEndpointTrackReversal) { + t.Fatalf("monotone sweep error=%v, want endpoint-track reversal", err) + } +} + +func TestMonotoneOpenBoundarySweepAllowsSubKilometreEndpointNoise(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 2, Latitude: 0}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 0.005, Latitude: 0.5}, {Longitude: 2.005, Latitude: 0.5}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 0.001, Latitude: 1}, {Longitude: 2.001, Latitude: 1}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 0.01, Latitude: 1.5}, {Longitude: 2.01, Latitude: 1.5}}}}, + } + if _, err := MonotoneOpenBoundarySweep(samples); err != nil { + t.Fatalf("monotone sweep rejected sub-kilometre endpoint noise: %v", err) + } +} + +func TestOpenBoundaryEndpointOutlinesRetainTracksAcrossIntermediateBulge(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 1}, {Longitude: 2, Latitude: 4}, {Longitude: 4, Latitude: 1}}}}, + {Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 2}, {Longitude: 4, Latitude: 2}}}}, + } + + polygons, err := OpenBoundaryEndpointOutlines(samples) + if err != nil { + t.Fatalf("OpenBoundaryEndpointOutlines: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("polygon count=%d, want 1", len(polygons)) + } + for _, point := range []GeoPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 0, Latitude: 1}, + {Longitude: 0, Latitude: 2}, + {Longitude: 4, Latitude: 0}, + {Longitude: 4, Latitude: 1}, + {Longitude: 4, Latitude: 2}, + } { + if !sweepPolygonContainsOrTouches(polygons[0], point) { + t.Fatalf("endpoint outline does not contain track point %+v", point) + } + } + if sweepPolygonContainsOrTouches(polygons[0], GeoPoint{Longitude: 2, Latitude: 4}) { + t.Fatal("endpoint-only outline unexpectedly contains the intermediate bulge") + } +} + +func TestOpenBoundaryEndpointOutlinesRejectSingleSampleGroups(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}, {Longitude: 2, Latitude: 0}}}}, + {Closed: true}, + {Boundaries: [][]GeoPoint{{{Longitude: 10, Latitude: 0}, {Longitude: 11, Latitude: 1}, {Longitude: 12, Latitude: 0}}}}, + } + if _, err := OpenBoundaryEndpointOutlines(samples); err == nil { + t.Fatal("endpoint outlines accepted groups without an endpoint track") + } +} + +func TestDecimateOpenBoundarySweepSamplesPreservesGroupsAndEndpoints(t *testing.T) { + var samples []OpenBoundarySweepSample + for group := 0; group < 2; group++ { + for index := 0; index < 10; index++ { + longitude := float64(group*100 + index) + samples = append(samples, OpenBoundarySweepSample{Boundaries: [][]GeoPoint{{ + {Longitude: longitude, Latitude: 0}, + {Longitude: longitude + 0.1, Latitude: 0.1}, + {Longitude: longitude + 0.2, Latitude: 0.2}, + }}}) + } + if group == 0 { + samples = append(samples, OpenBoundarySweepSample{Closed: true}) + } + } + decimated := DecimateOpenBoundarySweepSamples(samples, 4, 20) + if len(decimated) != 9 || !decimated[4].Closed { + t.Fatalf("decimated samples=%d separator=%v, want two groups of four", len(decimated), decimated[4].Closed) + } + for _, index := range []int{0, 3, 5, 8} { + if len(decimated[index].Boundaries) != 1 || len(decimated[index].Boundaries[0]) != 2 { + t.Fatalf("sample %d boundary was not spatially decimated: %#v", index, decimated[index].Boundaries) + } + } + for index, wantLongitude := range map[int]float64{0: 0, 3: 9, 5: 100, 8: 109} { + if got := decimated[index].Boundaries[0][0].Longitude; got != wantLongitude { + t.Fatalf("sample %d first longitude=%.1f, want %.1f", index, got, wantLongitude) + } + } +} + +func TestOpenBoundarySweepInnerCapsCloseOnlyNearSeparator(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{ + {Longitude: -2, Latitude: 0}, {Longitude: 0, Latitude: 2}, {Longitude: 2, Latitude: 0}, + }}}, + {Closed: true}, + {Boundaries: [][]GeoPoint{{ + {Longitude: 10, Latitude: 0}, {Longitude: 12, Latitude: 2}, {Longitude: 14, Latitude: 0}, + }}}, + } + if caps := OpenBoundarySweepInnerCaps(samples, 500); len(caps) != 2 { + t.Fatalf("inner caps=%d, want both sides of the closed separator", len(caps)) + } + if caps := OpenBoundarySweepInnerCaps(samples, 100); len(caps) != 0 { + t.Fatalf("inner caps=%d, want large horizon gaps left open", len(caps)) + } +} + +func TestOpenBoundarySweepStaysContinuousAcrossAntimeridian(t *testing.T) { + samples := []OpenBoundarySweepSample{ + {Boundaries: [][]GeoPoint{{ + {Longitude: 170, Latitude: 82}, + {Longitude: -170, Latitude: 82}, + }}}, + {Boundaries: [][]GeoPoint{{ + {Longitude: 172, Latitude: 84}, + {Longitude: -168, Latitude: 84}, + }}}, + {Boundaries: [][]GeoPoint{{ + {Longitude: 175, Latitude: 86}, + {Longitude: -165, Latitude: 86}, + }}}, + } + polygons, err := OpenBoundarySweep(samples) + if err != nil { + t.Fatalf("OpenBoundarySweep across antimeridian: %v", err) + } + if len(polygons) != 1 { + t.Fatalf("antimeridian sweep polygon count=%d, want one", len(polygons)) + } + for _, sample := range samples { + for _, point := range sample.Boundaries[0] { + if !sphericalPolygonContainsOrTouches(polygons[0], point) { + t.Fatalf("sweep excludes sampled endpoint %+v", point) + } + } + } +} + +func TestSweepPointInPolygonIncludesBoundary(t *testing.T) { + polygon := []GeoPoint{ + {Longitude: 120, Latitude: -72}, + {Longitude: 135, Latitude: -72}, + {Longitude: 135, Latitude: -68}, + {Longitude: 120, Latitude: -68}, + } + for _, point := range []GeoPoint{ + {Longitude: 120, Latitude: -72}, + {Longitude: 127.5, Latitude: -72}, + {Longitude: 135, Latitude: -70}, + {Longitude: 127.5, Latitude: -70}, + } { + if !sweepPointInPolygon(polygon, point) { + t.Fatalf("boundary/interior point %#v was rejected", point) + } + } + if sweepPointInPolygon(polygon, GeoPoint{Longitude: 127.5, Latitude: -73}) { + t.Fatal("outside point was accepted") + } +} + +func sweepPolygonContainsOrTouches(polygon []GeoPoint, point GeoPoint) bool { + inside := false + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + first, second := polygon[previous], polygon[current] + cross := (point.Longitude-first.Longitude)*(second.Latitude-first.Latitude) - + (point.Latitude-first.Latitude)*(second.Longitude-first.Longitude) + if math.Abs(cross) <= 1e-9 && point.Longitude >= math.Min(first.Longitude, second.Longitude)-1e-9 && + point.Longitude <= math.Max(first.Longitude, second.Longitude)+1e-9 && + point.Latitude >= math.Min(first.Latitude, second.Latitude)-1e-9 && + point.Latitude <= math.Max(first.Latitude, second.Latitude)+1e-9 { + return true + } + if (first.Latitude > point.Latitude) != (second.Latitude > point.Latitude) && + point.Longitude < (second.Longitude-first.Longitude)*(point.Latitude-first.Latitude)/ + (second.Latitude-first.Latitude)+first.Longitude { + inside = !inside + } + } + return inside +} + +func TestSweepBridgeTouchingPolygonsBridgesNearPair(t *testing.T) { + polygons := [][]GeoPoint{ + {{0, 0}, {1, 0}, {1, 1}, {0, 1}}, + {{0, 0.00005}, {1, 0.00005}, {1, 1.00005}, {0, 1.00005}}, + } + result, err := sweepBridgeTouchingPolygons(polygons) + if err != nil { + t.Fatalf("sweepBridgeTouchingPolygons: %v", err) + } + if len(result) != 1 { + t.Fatalf("ring count=%d, want one bridged ring", len(result)) + } + for _, point := range []GeoPoint{{Longitude: 0.5, Latitude: 0.5}, {Longitude: 0.5, Latitude: 0.00002}} { + if !sweepPointInPolygon(result[0], point) { + t.Fatalf("bridged ring misses %+v", point) + } + } +} + +func TestSweepBridgeTouchingPolygonsSeparatesDistantRings(t *testing.T) { + polygons := [][]GeoPoint{ + {{0, 0}, {1, 0}, {1, 1}, {0, 1}}, + {{0, 0.001}, {1, 0.001}, {1, 1.001}, {0, 1.001}}, + } + result, err := sweepBridgeTouchingPolygons(polygons) + if err != nil { + t.Fatalf("sweepBridgeTouchingPolygons: %v", err) + } + if len(result) != 2 { + t.Fatalf("ring count=%d, want two rings a kilometre apart", len(result)) + } +} + +func TestSweepBridgeTouchingPolygonsScansManyRingsQuickly(t *testing.T) { + rings := make([][]GeoPoint, 0, 120) + for row := 0; row < 10; row++ { + for col := 0; col < 12; col++ { + rings = append(rings, SphericalCircle(GeoPoint{ + Longitude: 15 * float64(col), + Latitude: 10 + 5*float64(row), + }, 0.5, 128)) + } + } + rings[119] = SphericalCircle(GeoPoint{Longitude: 150.0005, Latitude: 55}, 0.5, 128) + start := time.Now() + result, err := sweepBridgeTouchingPolygons(rings) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("sweepBridgeTouchingPolygons: %v", err) + } + if len(result) != len(rings) { + t.Fatalf("ring count=%d, want %d", len(result), len(rings)) + } + if elapsed > 2*time.Second { + t.Fatalf("scanning 120 rings took %v", elapsed) + } +} diff --git a/internal/geodata/topology.go b/internal/geodata/topology.go index 0e81bb8..1c872a9 100644 --- a/internal/geodata/topology.go +++ b/internal/geodata/topology.go @@ -4,21 +4,42 @@ import "math" // PolylineSegments 将地理折线裁剪到选定投影并 / PolylineSegments clips a geographic polyline to the selected projection and // 在等经纬投影中按日界线拆分路径 / splits equirectangular paths at the antimeridian. -func PolylineSegments(points []GeoPoint, projection Projection) [][]GeoPoint { +func PolylineSegments(points []GeoPoint, view ClipView) [][]GeoPoint { + prepared, ok := prepareTopologyPoints(points) + if !ok { + return nil + } + points = prepared + if view.Orthographic() { + return clipPolylineOrthographic(points, view.Center) + } + projection := view.Projection if projection == ProjectionNorthPolar { return clipPolylineHemisphere(points, 1) } if projection == ProjectionSouthPolar { return clipPolylineHemisphere(points, -1) } + if shift := equirectangularSeamShift(view); shift != 0 { + return splitPolylineAtSeam(points, shift) + } return splitPolylineAntimeridian(points) } // PolygonFragments 将地理多边形裁剪到选定地图范围 / PolygonFragments clips a geographic polygon to the selected map extent. -func PolygonFragments(points []GeoPoint, projection Projection) [][]GeoPoint { +func PolygonFragments(points []GeoPoint, view ClipView) [][]GeoPoint { if len(points) < 3 { return nil } + prepared, ok := prepareTopologyPoints(points) + if !ok { + return nil + } + points = prepared + if view.Orthographic() { + return polygonFragmentsOrthographic(points, view.Center) + } + projection := view.Projection if projection == ProjectionNorthPolar { if clipped := clipPolygonHemisphere(points, 1); len(clipped) >= 3 { return [][]GeoPoint{clipped} @@ -31,9 +52,39 @@ func PolygonFragments(points []GeoPoint, projection Projection) [][]GeoPoint { } return nil } + if shift := equirectangularSeamShift(view); shift != 0 { + return splitPolygonAtSeam(points, shift) + } return splitPolygonAntimeridian(points) } +// prepareTopologyPoints 校验裁剪输入:NaN 与 ±Inf 直接拒绝,经度超出 ±180 时按 360 取模归一化 +// (同一子午线的等价表示),只有真的越界才复制,正常输入保持零分配。 +func prepareTopologyPoints(points []GeoPoint) ([]GeoPoint, bool) { + for _, point := range points { + if math.IsNaN(point.Longitude) || math.IsNaN(point.Latitude) || + math.IsInf(point.Longitude, 0) || math.IsInf(point.Latitude, 0) { + return nil, false + } + } + shifted := false + for _, point := range points { + if point.Longitude < -180 || point.Longitude > 180 { + shifted = true + break + } + } + if !shifted { + return points, true + } + normalized := make([]GeoPoint, len(points)) + copy(normalized, points) + for index := range normalized { + normalized[index].Longitude = normalizeLongitude(normalized[index].Longitude) + } + return normalized, true +} + func splitPolylineAntimeridian(points []GeoPoint) [][]GeoPoint { if len(points) == 0 { return nil @@ -61,8 +112,7 @@ func splitPolylineAntimeridian(points []GeoPoint) [][]GeoPoint { } else { adjustedLongitude += 360 } - fraction := (boundary - a.Longitude) / (adjustedLongitude - a.Longitude) - crossing := GeoPoint{Longitude: boundary, Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude)} + crossing := sphericalLongitudeIntersection(a, b, boundary, adjustedLongitude) current = append(current, crossing) if len(current) >= 2 { segments = append(segments, current) @@ -126,19 +176,22 @@ func splitPolygonAntimeridian(points []GeoPoint) [][]GeoPoint { for index := 1; index < len(points); index++ { point := points[index] previous := unwrapped[index-1].Longitude - for point.Longitude-previous > 180 { - point.Longitude -= 360 - } - for point.Longitude-previous < -180 { - point.Longitude += 360 + // 一次取整到最近的 360 倍数,等价于反复加减 360 但不随偏移量增长。 + if offset := point.Longitude - previous; offset > 180 || offset < -180 { + point.Longitude -= 360 * math.Round(offset/360) } unwrapped[index] = point } + unwrapped = closePoleEnclosingPolygon(unwrapped) minimum, maximum := unwrapped[0].Longitude, unwrapped[0].Longitude for _, point := range unwrapped[1:] { minimum = math.Min(minimum, point.Longitude) maximum = math.Max(maximum, point.Longitude) } + if span := maximum - minimum; !(span >= 0 && span <= 3*360) { + // 经度归一化后每个点最多偏离 ±180 再加一次 360 的展开,跨度不可能超过三个世界。 + return nil + } firstWorld := int(math.Floor((minimum + 180) / 360)) lastWorld := int(math.Floor((maximum + 180) / 360)) var fragments [][]GeoPoint @@ -164,6 +217,113 @@ func splitPolygonAntimeridian(points []GeoPoint) [][]GeoPoint { return fragments } +// closePoleEnclosingPolygon adds the equirectangular map-edge closure for a +// simple spherical ring that winds once around a pole. Without this edge, the +// implicit last-to-first segment cuts across the map instead of representing +// the cap at +90 or -90 degrees. +func closePoleEnclosingPolygon(points []GeoPoint) []GeoPoint { + if len(points) < 3 { + return points + } + first := points[0].Longitude + closure := first + last := points[len(points)-1].Longitude + for closure-last > 180 { + closure -= 360 + } + for closure-last < -180 { + closure += 360 + } + winding := math.Round((closure - first) / 360) + if math.Abs(winding) != 1 { + return points + } + // Choose the smaller map-edge closure from edge geometry. Unlike a vertex + // average, its result is unchanged when a straight boundary edge is resampled. + north := appendPoleClosure(points, closure, first, 90) + south := appendPoleClosure(points, closure, first, -90) + northInside := sphericalPolygonContainsOrTouches(points, GeoPoint{Longitude: first, Latitude: 90}) + southInside := sphericalPolygonContainsOrTouches(points, GeoPoint{Longitude: first, Latitude: -90}) + northArea := math.Abs(signedPolygonArea(north)) + southArea := math.Abs(signedPolygonArea(south)) + if northInside == southInside && math.Abs(northArea-southArea) <= 1e-12 { + return points + } + pole := -90.0 + if (northInside != southInside && northInside) || (northInside == southInside && northArea < southArea) { + pole = 90 + } + // The closure must not cross the physical boundary again. For a concave + // polar ring, only the poleward-most seam crossing has a clear path to + // the pole; the first crossing can turn an excluded pocket into a fill. + points = rotatePoleRingToMapEdge(points, pole) + first = points[0].Longitude + return appendPoleClosure(points, first+360*winding, first, pole) +} + +func rotatePoleRingToMapEdge(points []GeoPoint, poleLatitude float64) []GeoPoint { + if len(points) < 3 { + return points + } + bestIndex := -1 + bestScore := math.Inf(-1) + var crossing GeoPoint + for index := 0; index < len(points); index++ { + next := (index + 1) % len(points) + a, b := points[index], points[next] + bLongitude := b.Longitude + for bLongitude-a.Longitude > 180 { + bLongitude -= 360 + } + for bLongitude-a.Longitude < -180 { + bLongitude += 360 + } + if math.Abs(a.Longitude-bLongitude) <= 1e-12 { + continue + } + target := 180 + 360*math.Ceil((math.Min(a.Longitude, bLongitude)-180-1e-12)/360) + for ; target <= math.Max(a.Longitude, bLongitude)+1e-12; target += 360 { + candidate := sphericalLongitudeIntersection(a, b, target, bLongitude) + if score := candidate.Latitude * poleLatitude; score > bestScore { + bestIndex, bestScore, crossing = index, score, candidate + } + } + } + if bestIndex < 0 { + return points + } + rotated := make([]GeoPoint, 1, len(points)+1) + rotated[0] = crossing + for offset := 1; offset <= len(points); offset++ { + point := points[(bestIndex+offset)%len(points)] + previous := rotated[len(rotated)-1].Longitude + for point.Longitude-previous > 180 { + point.Longitude -= 360 + } + for point.Longitude-previous < -180 { + point.Longitude += 360 + } + rotated = append(rotated, point) + } + return sweepDeduplicateAdjacent(rotated) +} + +func appendPoleClosure(points []GeoPoint, closure, first, poleLatitude float64) []GeoPoint { + result := append([]GeoPoint(nil), points...) + // The unwrapped ring ends in the world adjacent to its first vertex. Repeat + // that vertex in the adjacent world before climbing to the map edge; + // otherwise the last boundary point is connected diagonally to the pole and + // a triangular gap is cut out after antimeridian clipping. + result = append(result, GeoPoint{ + Longitude: closure, + Latitude: points[0].Latitude, + }) + return append(result, + GeoPoint{Longitude: closure, Latitude: poleLatitude}, + GeoPoint{Longitude: first, Latitude: poleLatitude}, + ) +} + func signedPolygonArea(points []GeoPoint) float64 { if len(points) < 3 { return 0 @@ -184,8 +344,7 @@ func clipPolygonLongitude(points []GeoPoint, boundary float64, keepGreater bool) return value.Longitude <= boundary } intersection := func(a, b GeoPoint) GeoPoint { - fraction := (boundary - a.Longitude) / (b.Longitude - a.Longitude) - return GeoPoint{Longitude: boundary, Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude)} + return sphericalLongitudeIntersection(a, b, boundary, b.Longitude) } return clipPolygon(points, inside, intersection) } @@ -216,9 +375,63 @@ func clipPolygon( } func hemisphereIntersection(a, b GeoPoint) GeoPoint { - dLongitude := normalizeLongitude(b.Longitude - a.Longitude) - fraction := -a.Latitude / (b.Latitude - a.Latitude) - return GeoPoint{Longitude: normalizeLongitude(a.Longitude + fraction*dLongitude), Latitude: 0} + first := a + second := b + firstVector := geoPointVector(first) + secondVector := geoPointVector(second) + firstSign := firstVector.z + secondSign := secondVector.z + if math.Abs(firstSign) <= 1e-15 { + return GeoPoint{Longitude: normalizeLongitude(first.Longitude), Latitude: 0} + } + if math.Abs(secondSign) <= 1e-15 { + return GeoPoint{Longitude: normalizeLongitude(second.Longitude), Latitude: 0} + } + left, right := 0.0, 1.0 + for iteration := 0; iteration < 64; iteration++ { + middle := (left + right) / 2 + point := sphericalInterpolate(first, second, middle) + if point.Latitude == 0 || right-left <= 1e-13 { + return GeoPoint{Longitude: normalizeLongitude(point.Longitude), Latitude: 0} + } + if point.Latitude*first.Latitude > 0 { + left = middle + } else { + right = middle + } + } + point := sphericalInterpolate(first, second, (left+right)/2) + return GeoPoint{Longitude: normalizeLongitude(point.Longitude), Latitude: 0} +} + +func sphericalLongitudeIntersection(a, b GeoPoint, boundary, adjustedLongitude float64) GeoPoint { + firstLongitude := a.Longitude + secondLongitude := adjustedLongitude + if math.Abs(secondLongitude-firstLongitude) <= 1e-14 { + return GeoPoint{Longitude: boundary, Latitude: a.Latitude} + } + left, right := 0.0, 1.0 + for iteration := 0; iteration < 64; iteration++ { + middle := (left + right) / 2 + point := sphericalInterpolate(a, b, middle) + middleLongitude := point.Longitude + for middleLongitude-firstLongitude > 180 { + middleLongitude -= 360 + } + for middleLongitude-firstLongitude < -180 { + middleLongitude += 360 + } + if math.Abs(middleLongitude-boundary) <= 1e-12 || right-left <= 1e-13 { + return GeoPoint{Longitude: boundary, Latitude: point.Latitude} + } + if (firstLongitude-boundary)*(middleLongitude-boundary) <= 0 { + right = middle + } else { + left = middle + } + } + point := sphericalInterpolate(a, b, (left+right)/2) + return GeoPoint{Longitude: boundary, Latitude: point.Latitude} } func normalizeLongitude(value float64) float64 { @@ -228,3 +441,51 @@ func normalizeLongitude(value float64) float64 { } return value - 180 } + +// equirectangularSeamShift 返回把等经纬接缝从 ±180 搬到视图中心对面所需的经度旋转量。 +// 视图中心为 0 时返回 0,调用方即可沿用未旋转的原有路径,保证既有输出逐字节不变。 +func equirectangularSeamShift(view ClipView) float64 { + if view.Projection != ProjectionEquirectangular || view.Center.Longitude == 0 { + return 0 + } + return view.Center.Longitude +} + +// splitPolylineAtSeam 先把经度旋转到接缝落在 ±180 的坐标系,交给原有分割逻辑,再旋转回来。 +func splitPolylineAtSeam(points []GeoPoint, shift float64) [][]GeoPoint { + segments := splitPolylineAntimeridian(rotateLongitudes(points, -shift)) + for _, segment := range segments { + rotateLongitudesInPlace(segment, shift) + } + return segments +} + +// splitPolygonAtSeam 与 splitPolylineAtSeam 同理,供多边形使用。 +func splitPolygonAtSeam(points []GeoPoint, shift float64) [][]GeoPoint { + fragments := splitPolygonAntimeridian(rotateLongitudes(points, -shift)) + for _, fragment := range fragments { + rotateLongitudesInPlace(fragment, shift) + } + return fragments +} + +func rotateLongitudes(points []GeoPoint, shift float64) []GeoPoint { + rotated := make([]GeoPoint, len(points)) + copy(rotated, points) + rotateLongitudesInPlace(rotated, shift) + return rotated +} + +func rotateLongitudesInPlace(points []GeoPoint, shift float64) { + for index := range points { + points[index].Longitude = normalizeLongitude180(points[index].Longitude + shift) + } +} + +func normalizeLongitude180(longitude float64) float64 { + value := math.Mod(longitude+180, 360) + if value < 0 { + value += 360 + } + return value - 180 +} diff --git a/internal/geodata/topology_test.go b/internal/geodata/topology_test.go index 29ba955..317813b 100644 --- a/internal/geodata/topology_test.go +++ b/internal/geodata/topology_test.go @@ -9,7 +9,7 @@ func TestPolylineSegmentsTreatsExactAntimeridianAsOneMeridian(t *testing.T) { segments := PolylineSegments([]GeoPoint{ {Longitude: -180, Latitude: 10}, {Longitude: 180, Latitude: 20}, - }, ProjectionEquirectangular) + }, ClipView{Projection: ProjectionEquirectangular}) if len(segments) != 1 || len(segments[0]) != 2 { t.Fatalf("exact-antimeridian line segments = %#v", segments) } @@ -19,6 +19,49 @@ func TestPolylineSegmentsTreatsExactAntimeridianAsOneMeridian(t *testing.T) { } } +func TestPolylineSegmentsInterpolatesAntimeridianOnSphere(t *testing.T) { + segments := PolylineSegments([]GeoPoint{ + {Longitude: 170, Latitude: 80}, + {Longitude: -170, Latitude: 80}, + }, ClipView{Projection: ProjectionEquirectangular}) + if len(segments) != 2 { + t.Fatalf("high-latitude antimeridian line segments = %d, want 2", len(segments)) + } + firstCrossing := segments[0][len(segments[0])-1] + secondCrossing := segments[1][0] + if firstCrossing.Longitude != 180 || secondCrossing.Longitude != -180 { + t.Fatalf("unexpected antimeridian crossings: %+v %+v", firstCrossing, secondCrossing) + } + if math.Abs(firstCrossing.Latitude-secondCrossing.Latitude) > 1e-10 { + t.Fatalf("split crossings disagree in latitude: %.12f != %.12f", + firstCrossing.Latitude, secondCrossing.Latitude) + } + // The shorter great-circle arc bends poleward. Linear longitude/latitude + // interpolation would incorrectly leave the crossing at exactly 80 degrees. + if firstCrossing.Latitude <= 80.1 || firstCrossing.Latitude >= 81 { + t.Fatalf("great-circle crossing latitude = %.9f, want (80.1, 81)", firstCrossing.Latitude) + } +} + +func TestPolarPolylineClippingUsesSphericalEquatorIntersection(t *testing.T) { + segments := PolylineSegments([]GeoPoint{ + {Longitude: 0, Latitude: -10}, + {Longitude: 90, Latitude: 80}, + }, ClipView{Projection: ProjectionNorthPolar}) + if len(segments) != 1 || len(segments[0]) != 2 { + t.Fatalf("north-polar clipped line = %#v", segments) + } + crossing := segments[0][0] + if math.Abs(crossing.Latitude) > 1e-10 { + t.Fatalf("equator crossing latitude = %.12f", crossing.Latitude) + } + // The great circle crosses near 2 degrees, not at the 45 degree + // arithmetic interpolation used by planar clipping. + if crossing.Longitude < 1 || crossing.Longitude > 3 { + t.Fatalf("spherical equator crossing longitude = %.9f, want (1, 3)", crossing.Longitude) + } +} + func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) { fragments := PolygonFragments([]GeoPoint{ {Longitude: -180, Latitude: 15}, @@ -27,7 +70,7 @@ func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) { {Longitude: 150, Latitude: -12}, {Longitude: 180, Latitude: -11}, {Longitude: -180, Latitude: -10}, - }, ProjectionEquirectangular) + }, ClipView{Projection: ProjectionEquirectangular}) if len(fragments) != 1 { t.Fatalf("exact-antimeridian polygon produced %d fragments, want 1", len(fragments)) } @@ -35,3 +78,238 @@ func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) { t.Fatal("exact-antimeridian polygon fragment has zero area") } } + +func TestPolygonFragmentsClosesPoleEnclosingRingsAtEquirectangularMapEdge(t *testing.T) { + for _, test := range []struct { + name string + boundaryLatitude float64 + insideLatitude float64 + outsideLatitude float64 + }{ + {name: "north", boundaryLatitude: 70, insideLatitude: 89, outsideLatitude: -89}, + {name: "south", boundaryLatitude: -70, insideLatitude: -89, outsideLatitude: 89}, + } { + t.Run(test.name, func(t *testing.T) { + polygon := []GeoPoint{ + {Longitude: -135, Latitude: test.boundaryLatitude}, + {Longitude: -45, Latitude: test.boundaryLatitude}, + {Longitude: 45, Latitude: test.boundaryLatitude}, + {Longitude: 135, Latitude: test.boundaryLatitude}, + {Longitude: -135, Latitude: test.boundaryLatitude}, + } + fragments := PolygonFragments(polygon, ClipView{Projection: ProjectionEquirectangular}) + if len(fragments) == 0 { + t.Fatal("pole-enclosing polygon produced no fragments") + } + for _, longitude := range []float64{-150, -90, 0, 90, 150} { + if !polygonFragmentsContain(fragments, longitude, test.insideLatitude) { + t.Fatalf("pole cap does not contain %.0f, %.0f", longitude, test.insideLatitude) + } + if polygonFragmentsContain(fragments, longitude, test.outsideLatitude) { + t.Fatalf("pole cap incorrectly contains %.0f, %.0f", longitude, test.outsideLatitude) + } + } + }) + } +} + +func TestPolygonFragmentsPoleClosurePreservesAntimeridianSeam(t *testing.T) { + for _, test := range []struct { + name string + poleLatitude float64 + boundary float64 + }{ + {name: "north", poleLatitude: 90, boundary: 50}, + {name: "south", poleLatitude: -90, boundary: -50}, + } { + t.Run(test.name, func(t *testing.T) { + polygon := []GeoPoint{ + {Longitude: 150, Latitude: test.boundary}, + {Longitude: 180, Latitude: 5}, + {Longitude: 120, Latitude: -5}, + {Longitude: 30, Latitude: 15}, + {Longitude: -60, Latitude: 25}, + {Longitude: -150, Latitude: 35}, + } + if test.poleLatitude < 0 { + polygon = mirrorGeoPointLatitudes(polygon) + } + fragments := PolygonFragments(polygon, ClipView{Projection: ProjectionEquirectangular}) + if len(fragments) == 0 || len(fragments) > 2 { + t.Fatalf("pole polygon fragments=%d, want one or two map-edge fragments", len(fragments)) + } + for _, longitude := range []float64{149, 151} { + latitude := 60.0 + if test.poleLatitude < 0 { + latitude = -60 + } + if !polygonFragmentsContain(fragments, longitude, latitude) { + t.Fatalf("pole closure lost %.0f, %.0f beside its internal seam", longitude, latitude) + } + } + }) + } +} + +func TestPolygonFragmentsPoleClosureAnchorsInteriorStartAtAntimeridian(t *testing.T) { + // This ring winds around the north pole but starts in the Bering Sea. The + // pole closure must use the map seam, not a meridian through that start + // vertex. + fragments := PolygonFragments([]GeoPoint{ + {Longitude: -172, Latitude: 50}, + {Longitude: -100, Latitude: 70}, + {Longitude: 0, Latitude: 80}, + {Longitude: 100, Latitude: 70}, + {Longitude: 188, Latitude: 50}, + }, ClipView{Projection: ProjectionEquirectangular}) + if len(fragments) == 0 { + t.Fatal("pole-winding ring produced no fragments") + } + for _, fragment := range fragments { + for index, first := range fragment { + second := fragment[(index+1)%len(fragment)] + if math.Abs(first.Longitude-second.Longitude) <= 1e-9 && + math.Abs(first.Latitude-second.Latitude) > 20 && + math.Abs(first.Longitude) < 179.999 { + t.Fatalf("interior pole closure at %.3f: %+v -> %+v", first.Longitude, first, second) + } + } + } +} + +func TestPolygonFragmentsPoleClosureDoesNotDependOnVertexDensity(t *testing.T) { + base := []GeoPoint{ + {Longitude: -135, Latitude: 70}, + {Longitude: -45, Latitude: 70}, + {Longitude: 45, Latitude: -60}, + {Longitude: 135, Latitude: -60}, + } + dense := []GeoPoint{ + {Longitude: -135, Latitude: 70}, + {Longitude: -45, Latitude: 70}, + {Longitude: 45, Latitude: -60}, + {Longitude: 55, Latitude: -60}, + {Longitude: 65, Latitude: -60}, + {Longitude: 75, Latitude: -60}, + {Longitude: 85, Latitude: -60}, + {Longitude: 95, Latitude: -60}, + {Longitude: 105, Latitude: -60}, + {Longitude: 115, Latitude: -60}, + {Longitude: 125, Latitude: -60}, + {Longitude: 135, Latitude: -60}, + } + for _, test := range []struct { + name string + base []GeoPoint + dense []GeoPoint + }{ + {name: "north", base: base, dense: dense}, + {name: "south", base: mirrorGeoPointLatitudes(base), dense: mirrorGeoPointLatitudes(dense)}, + } { + t.Run(test.name, func(t *testing.T) { + baseFragments := PolygonFragments(test.base, ClipView{Projection: ProjectionEquirectangular}) + denseFragments := PolygonFragments(test.dense, ClipView{Projection: ProjectionEquirectangular}) + for _, point := range []GeoPoint{ + {Longitude: 0, Latitude: 89}, + {Longitude: 0, Latitude: -89}, + } { + baseContains := polygonFragmentsContain(baseFragments, point.Longitude, point.Latitude) + denseContains := polygonFragmentsContain(denseFragments, point.Longitude, point.Latitude) + if denseContains != baseContains { + t.Fatalf("densifying a boundary changed containment at %.0f, %.0f: base=%v dense=%v", + point.Longitude, point.Latitude, baseContains, denseContains) + } + } + }) + } +} + +func TestPolygonFragmentsPolarIndentationPreservesInterior(t *testing.T) { + // A polar boundary crosses the map seam three times. Closing through the + // lowest crossing fills the excluded indentation on the east side. + polygon := []GeoPoint{ + {Longitude: -172, Latitude: 50}, {Longitude: -179, Latitude: 54}, + {Longitude: 175, Latitude: 60}, {Longitude: 173, Latitude: 65}, + {Longitude: 175, Latitude: 70}, {Longitude: -178, Latitude: 75}, + {Longitude: -140, Latitude: 79}, {Longitude: -70, Latitude: 68}, + {Longitude: 0, Latitude: 15}, {Longitude: 90, Latitude: 0}, + {Longitude: 140, Latitude: -23}, {Longitude: 175, Latitude: 30}, + {Longitude: -179, Latitude: 40}, + } + for _, sign := range []float64{1, -1} { + points := append([]GeoPoint(nil), polygon...) + for i := range points { + points[i].Latitude *= sign + } + for offset := range points { + rotated := append(append([]GeoPoint(nil), points[offset:]...), points[:offset]...) + for _, reverse := range []bool{false, true} { + if reverse { + for i, j := 0, len(rotated)-1; i < j; i, j = i+1, j-1 { + rotated[i], rotated[j] = rotated[j], rotated[i] + } + } + fragments := PolygonFragments(rotated, ClipView{Projection: ProjectionEquirectangular}) + for _, point := range []GeoPoint{ + {Longitude: 179, Latitude: sign * 65}, {Longitude: 175, Latitude: sign * 65}, + {Longitude: 170, Latitude: sign * 65}, {Longitude: -179, Latitude: sign * 65}, + {Longitude: 179, Latitude: sign * 85}, {Longitude: 0, Latitude: sign * 85}, + } { + want := SphericalPolygonsContainPoints([][]GeoPoint{rotated}, []GeoPoint{point})[0] + if got := polygonFragmentsContain(fragments, point.Longitude, point.Latitude); got != want { + t.Fatalf("sign=%g offset=%d reversed=%v point=%+v: split=%v sphere=%v", sign, offset, reverse, point, got, want) + } + } + } + } + } +} + +func mirrorGeoPointLatitudes(points []GeoPoint) []GeoPoint { + mirrored := make([]GeoPoint, len(points)) + for index, point := range points { + mirrored[index] = GeoPoint{Longitude: point.Longitude, Latitude: -point.Latitude} + } + return mirrored +} + +func polygonFragmentsContain(fragments [][]GeoPoint, longitude, latitude float64) bool { + for _, polygon := range fragments { + inside := false + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + a, b := polygon[previous], polygon[current] + if (a.Latitude > latitude) == (b.Latitude > latitude) { + continue + } + intersection := a.Longitude + + (latitude-a.Latitude)*(b.Longitude-a.Longitude)/(b.Latitude-a.Latitude) + if intersection > longitude { + inside = !inside + } + } + if inside { + return true + } + } + return false +} + +func TestClosePoleEnclosingPolygonRequiresSingleWinding(t *testing.T) { + open := []GeoPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 1, Latitude: 0}, + {Longitude: 1, Latitude: 1}, + } + if closed := closePoleEnclosingPolygon(open); len(closed) != len(open) { + t.Fatalf("a ring without pole winding gained %d closure points", len(closed)-len(open)) + } + winding := []GeoPoint{ + {Longitude: -135, Latitude: 70}, + {Longitude: -45, Latitude: 70}, + {Longitude: 45, Latitude: 70}, + {Longitude: 135, Latitude: 70}, + } + if closed := closePoleEnclosingPolygon(winding); len(closed) <= len(winding) { + t.Fatalf("a single-winding ring kept %d points, want a pole closure", len(closed)) + } +} diff --git a/internal/lunarhorizon/lunarhorizon.go b/internal/lunarhorizon/lunarhorizon.go new file mode 100644 index 0000000..fa782fe --- /dev/null +++ b/internal/lunarhorizon/lunarhorizon.go @@ -0,0 +1,117 @@ +// Package lunarhorizon 在等距圆柱地图坐标里细化月食可见区使用的站心地平线。 +// +// 极点附近一小段球面地平弧可以横跨近 180° 经度,把它的端点直接连成多边形会在极区 +// 切出地平线以外的假可见帽。这里按地图坐标误差自适应细分,并把新增顶点用同一个站心 +// 地平方程校正回零高度。 +// +// Package lunarhorizon refines the topocentric horizon used by lunar-eclipse visibility +// regions in equirectangular map coordinates. +// +// Near a pole a short spherical horizon arc can span almost 180 degrees of longitude, so +// joining its endpoints directly cuts a false visible cap outside the horizon. This package +// subdivides by error measured in map coordinates and corrects every inserted vertex onto the +// same topocentric zero-altitude curve. +package lunarhorizon + +import ( + "math" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +// normalizeLongitude 把经度归一化到 [-180, 180);internal/geodata 与 geojson 各有一份等价的未导出实现,无法跨包复用。 +func normalizeLongitude(value float64) float64 { + value = math.Mod(value+180, 360) + if value < 0 { + value += 360 + } + return value - 180 +} + +// defaultToleranceDegrees 是 GeoJSON 导出使用的误差门限。GeoJSON 客户端可以无限放大, +// 因此这里取远小于任何地图像素的角度。 +// defaultToleranceDegrees is the error tolerance used by the GeoJSON export. GeoJSON clients +// can zoom without limit, so it stays far below any map pixel. +const defaultToleranceDegrees = 0.002 + +// DefaultToleranceDegrees 暴露默认门限,供固定分辨率的调用方设置下限。 +// DefaultToleranceDegrees exposes the default tolerance so fixed-resolution callers can floor it. +const DefaultToleranceDegrees = defaultToleranceDegrees + +// Refine 用 GeoJSON 导出的默认门限细分一条站心地平线。 +// Refine subdivides one topocentric horizon with the GeoJSON export's default tolerance. +// +// Near a pole, a short spherical horizon arc can span almost 180 degrees of +// longitude. Refine in map coordinates before clipping; extra vertices are +// corrected to the same topocentric zero-altitude curve as the source ring. +func Refine(points []geodata.GeoPoint, at time.Time) []geodata.GeoPoint { + return RefineWithin(points, at, defaultToleranceDegrees) +} + +// RefineWithin 用给定误差门限(单位:度)细分一条站心地平线。插入的顶点被校正回零高度, +// 因此细分只增加描述精度,不改变曲线本身;固定分辨率的目标(例如 SVG 地图)可以用 +// 与像素尺度相称的门限,避免为了显示不出来的精度生成成千上万个顶点。 +// RefineWithin subdivides one topocentric horizon with the given error tolerance in degrees. +// Inserted vertices are corrected back onto zero altitude, so refinement adds description +// accuracy without moving the curve. A fixed-resolution target such as an SVG map can pass a +// tolerance matched to its pixel scale instead of emitting vertices no display can resolve. +func RefineWithin(points []geodata.GeoPoint, at time.Time, toleranceDegrees float64) []geodata.GeoPoint { + if !(toleranceDegrees > 0) { + toleranceDegrees = defaultToleranceDegrees + } + if len(points) < 3 { + return points + } + jd := basic.Date2JDE(at.UTC()) + tt := basic.TD2UT(jd, true) + ra, dec := basic.HMoonTrueRaDec(tt) + distanceAU := basic.HMoonAway(tt) / 149597870.7 + sidereal := basic.ApparentSiderealTime(jd) * 15 + center := geodata.GeoPoint{Longitude: normalizeLongitude(ra - sidereal), Latitude: dec} + // Every correction is at the same instant. Reuse its full ephemeris while + // retaining the ellipsoid and topocentric transform used by HMoonHeight. + altitudeAt := func(point geodata.GeoPoint) float64 { + ra, dec := basic.TopocentricRaDec(ra, dec, point.Latitude, point.Longitude, jd, distanceAU, 0) + hourAngle := (sidereal + point.Longitude - ra) * math.Pi / 180 + latitude := point.Latitude * math.Pi / 180 + declination := dec * math.Pi / 180 + return math.Asin(math.Sin(latitude)*math.Sin(declination)+ + math.Cos(declination)*math.Cos(latitude)*math.Cos(hourAngle)) * 180 / math.Pi + } + result := make([]geodata.GeoPoint, 0, len(points)) + var refine func(geodata.GeoPoint, geodata.GeoPoint, int) + refine = func(first, second geodata.GeoPoint, depth int) { + middle := geodata.InterpolateGreatCircle(first, second, 0.5) + linearLongitude := first.Longitude + math.Remainder(second.Longitude-first.Longitude, 360)/2 + errorDeg := math.Hypot(math.Remainder(middle.Longitude-linearLongitude, 360), middle.Latitude-(first.Latitude+second.Latitude)/2) + if errorDeg <= toleranceDegrees || depth >= 20 { + result = append(result, first) + return + } + for iteration := 0; iteration < 5; iteration++ { + altitude := altitudeAt(middle) + if math.Abs(altitude) < 1e-10 { + break + } + angleDeg := basic.StarAngularSeparation(middle.Longitude, middle.Latitude, center.Longitude, center.Latitude) + if !(angleDeg > 0) { + // 只有传入的环并非地平线、中点与该瞬时月下点重合时才会走到这里;此时无法 + // 沿"朝向月下点"的方向修正,保留未修正的中点比产生 NaN 顶点安全。 + // Reached only when the input ring is not a horizon ring and the midpoint + // coincides with that instant's sub-lunar point. There is no direction toward + // the sub-lunar point to correct along, so keep the uncorrected midpoint rather + // than emit a NaN vertex. + break + } + middle = geodata.InterpolateGreatCircle(middle, center, -altitude/angleDeg) + } + refine(first, middle, depth+1) + refine(middle, second, depth+1) + } + for index, point := range points { + refine(point, points[(index+1)%len(points)], 0) + } + return result +} diff --git a/internal/occultationgeo/band_cleanup.go b/internal/occultationgeo/band_cleanup.go new file mode 100644 index 0000000..efd6b4e --- /dev/null +++ b/internal/occultationgeo/band_cleanup.go @@ -0,0 +1,940 @@ +package occultationgeo + +import ( + "fmt" + "math" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +func cleanupOccultationVisibleBandPolygons( + polygons [][]geodata.GeoPoint, + strongPolarSmoothing bool, +) [][]geodata.GeoPoint { + polygons = RemoveTinyPolygonComponents(polygons) + maximumDisplayEdgeKM := 150.0 + if strongPolarSmoothing { + // Inner-contact Saturn fallbacks retain the sampled sweep rather than a + // selected linework face. Keep their rendered chords at the same scale as + // the authoritative path so the polar edge cannot appear stepped. + maximumDisplayEdgeKM = 40 + } + densified := densifyOccultationPolygons(polygons, maximumDisplayEdgeKM) + for index := range densified { + // Densification can turn one long, thin numerical return into more than + // a dozen short edges. Inspect a wider local window, but require a much + // larger detour before removing it so ordinary rounded cusps survive. + densified[index] = removeOccultationHairpins(densified[index], 35, 25, 12) + densified[index] = removeOccultationHairpins(densified[index], 100, 25, 32) + } + if strongPolarSmoothing { + for index := range densified { + densified[index] = smoothOccultationHairpins(densified[index], 180, 35, 16, 50) + densified[index] = removeOccultationPolarKinks(densified[index]) + densified[index] = smoothOccultationPolarCorners(densified[index]) + } + } + for index := range densified { + densified[index] = removeOccultationSharpCorners(densified[index], 20, 30) + } + for index := range densified { + densified[index] = smoothOccultationPolarWobbles(densified[index]) + densified[index] = smoothOccultationOrdinaryWobbles(densified[index]) + } + return densified +} + +// smoothOccultationOrdinaryWobbles removes the small alternating envelope +// error left when adjacent temporal footprints contribute different sampled +// limb vertices. A projected five-point filter is used so the correction +// follows the Web Mercator chart seen by map clients. The displacement and +// local-deviation gates remain deliberately small, so this also handles the +// high-latitude portion of a smooth arc without flattening a real horizon fold. +func smoothOccultationOrdinaryWobbles(points []geodata.GeoPoint) []geodata.GeoPoint { + if len(points) < 7 { + return points + } + closed := len(points) > 1 && geodata.SameGeoPoint(points[0], points[len(points)-1]) + limit := len(points) + if closed { + limit-- + } + if limit < 7 { + return points + } + result := append([]geodata.GeoPoint(nil), points...) + original := append([]geodata.GeoPoint(nil), points...) + const ( + minimumLatitude = 0.5 + maximumLatitude = 85.0 + maximumMoveKM = 20.0 + maximumDeviationKM = 40.0 + ) + for pass := 0; pass < 12; pass++ { + updated := append([]geodata.GeoPoint(nil), result...) + for index := 2; index+2 < limit; index++ { + point := result[index] + if math.Abs(point.Latitude) < minimumLatitude || math.Abs(point.Latitude) > maximumLatitude { + continue + } + weights := [...]float64{-3, 12, 17, 12, -3} + var x, y float64 + for offset := -2; offset <= 2; offset++ { + projected := occultationProjectedPoint(result[index+offset]) + x += weights[offset+2] * projected[0] + y += weights[offset+2] * projected[1] + } + candidate := occultationUnprojectedPoint(x/35, y/35) + move := geoDistanceKM(original[index], candidate) + if move > maximumMoveKM { + continue + } + first := result[index-2] + last := result[index+2] + deviation := occultationProjectedPointLineDistanceKM(point, first, last) + if deviation > maximumDeviationKM { + continue + } + updated[index] = candidate + } + result = updated + } + if closed { + result[len(result)-1] = result[0] + } + return result +} + +func occultationProjectedPoint(point geodata.GeoPoint) [2]float64 { + latitude := math.Max(-85.05112878, math.Min(85.05112878, point.Latitude)) * math.Pi / 180 + return [2]float64{ + EarthRadiusKM * point.Longitude * math.Pi / 180, + EarthRadiusKM * math.Log(math.Tan(math.Pi/4+latitude/2)), + } +} + +func occultationUnprojectedPoint(pointX, pointY float64) geodata.GeoPoint { + longitude := pointX / EarthRadiusKM * 180 / math.Pi + longitude = math.Mod(longitude+180, 360) + if longitude < 0 { + longitude += 360 + } + return geodata.GeoPoint{ + Longitude: longitude - 180, + Latitude: (2*math.Atan(math.Exp(pointY/EarthRadiusKM)) - math.Pi/2) * 180 / math.Pi, + } +} + +func occultationProjectedPointLineDistanceKM(point, first, last geodata.GeoPoint) float64 { + p := occultationProjectedPoint(point) + a := occultationProjectedPoint(first) + b := occultationProjectedPoint(last) + dx, dy := b[0]-a[0], b[1]-a[1] + if lengthSquared := dx*dx + dy*dy; lengthSquared > 0 { + fraction := ((p[0]-a[0])*dx + (p[1]-a[1])*dy) / lengthSquared + fraction = math.Max(0, math.Min(1, fraction)) + return math.Hypot(p[0]-(a[0]+fraction*dx), p[1]-(a[1]+fraction*dy)) + } + return math.Hypot(p[0]-a[0], p[1]-a[1]) +} + +func occultationProjectedTurnAngleDegrees( + first, middle, last geodata.GeoPoint, +) float64 { + firstProjected := occultationProjectedPoint(first) + middleProjected := occultationProjectedPoint(middle) + lastProjected := occultationProjectedPoint(last) + firstX, firstY := firstProjected[0]-middleProjected[0], firstProjected[1]-middleProjected[1] + lastX, lastY := lastProjected[0]-middleProjected[0], lastProjected[1]-middleProjected[1] + firstLength := math.Hypot(firstX, firstY) + lastLength := math.Hypot(lastX, lastY) + if firstLength <= 1e-12 || lastLength <= 1e-12 { + return 180 + } + cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength) + cosine = math.Max(-1, math.Min(1, cosine)) + return math.Acos(cosine) * 180 / math.Pi +} + +// cleanupOccultationFootprintUnionPolygons keeps every component of the +// horizon-closed instantaneous union. Unlike the compact-band cleaner it does +// not discard small components: at a horizon transition a narrow component is +// still a real visible portion of the time union, not a polygonizer sliver. +func cleanupOccultationFootprintUnionPolygons( + polygons [][]geodata.GeoPoint, + strongPolarSmoothing bool, +) [][]geodata.GeoPoint { + result := make([][]geodata.GeoPoint, 0, len(polygons)) + // A disconnected horizon union is already the authoritative topology. In + // particular, Saturn's polar samples contain many narrow real components; + // moving their vertices can bridge a below-horizon cap. Keep those rings + // fixed and only densify their projected edges for rendering. + for _, polygon := range polygons { + if len(openFootprintRing(polygon)) < 3 || math.Abs(geoRingArea(polygon)) <= 1e-12 { + continue + } + ring := densifyOccultationPolygons([][]geodata.GeoPoint{polygon}, 40)[0] + if len(polygons) == 1 { + if strongPolarSmoothing { + ring = removeOccultationHairpins(ring, 190, 25, 32) + } else { + ring = removeOccultationHairpins(ring, 100, 25, 32) + } + } + ring = removeOccultationSharpCorners(ring, 20, 30) + ring = densifyOccultationPolygons([][]geodata.GeoPoint{ring}, 40)[0] + if len(openFootprintRing(ring)) >= 3 && math.Abs(geoRingArea(ring)) > 1e-12 { + result = append(result, ring) + } + } + return result +} + +// Contact-contour output is already a physical continuous envelope. Clean +// only the short, high-latitude reversals that are numerical polygonizer +// vertices, then densify for display; long physical phase branches remain +// untouched. +func cleanupOccultationAuthoritativeBandPolygons( + polygons [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + polygons = RemoveTinyPolygonComponents(polygons) + densified := densifyOccultationPolygons(polygons, 25) + usable := make([][]geodata.GeoPoint, 0, len(densified)) + for _, polygon := range densified { + open := openFootprintRing(polygon) + if len(open) < 3 || math.Abs(geoRingArea(open)) <= 1e-12 { + continue + } + usable = append(usable, polygon) + } + densified = usable + for index := range densified { + densified[index] = removeOccultationPolarKinks(densified[index]) + densified[index] = removeOccultationSharpCorners(densified[index], 60, 45) + densified[index] = removeOccultationPolarSharpCorners(densified[index], 20, 70, 70) + densified[index] = smoothOccultationPolarCorners(densified[index]) + densified[index] = smoothOccultationPolarWobbles(densified[index]) + densified[index] = smoothOccultationOrdinaryWobbles(densified[index]) + } + // The first cleanup can expose a short reversal at the interpolation seam + // of an otherwise longer edge. Run the local cleanup once more before the + // final spacing pass so the GeoJSON consumer receives both smooth turns and + // bounded projected edges. + densified = densifyOccultationPolygons(densified, 25) + for index := range densified { + densified[index] = removeOccultationPolarKinks(densified[index]) + densified[index] = removeOccultationSharpCorners(densified[index], 60, 45) + densified[index] = removeOccultationPolarSharpCorners(densified[index], 20, 70, 70) + densified[index] = smoothOccultationPolarCorners(densified[index]) + densified[index] = smoothOccultationPolarWobbles(densified[index]) + densified[index] = smoothOccultationOrdinaryWobbles(densified[index]) + } + result := make([][]geodata.GeoPoint, 0, len(densified)) + for _, polygon := range densified { + open := openFootprintRing(polygon) + if len(open) >= 3 && math.Abs(geoRingArea(open)) > 1e-12 { + result = append(result, polygon) + } + } + return result +} + +// preserveOccultationPhaseBoundaryEnvelope restores only the part of an +// accepted physical phase cycle that bounded smoothing moved outside the +// cleaned polygon. The source cycle is already topology- and witness-checked; +// unioning it back cannot invent visibility, while leaving unrelated portions +// of the cleaned ring unchanged. +func preserveOccultationPhaseBoundaryEnvelope( + cleaned, physicalBoundary [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + if len(cleaned) == 0 || len(physicalBoundary) == 0 { + return cleaned + } + // The phase cycle can close two branches at a shared polar extremum. Unioning + // that raw closure back into the cleaned footprint restores the very cusp the + // cleanup removed, so round only that junction before the merge. The rounded + // candidate is accepted only when it still contains the complete source cycle. + roundedBoundary := roundOccultationPhaseBoundaryJunctions(physicalBoundary) + input := append([][]geodata.GeoPoint(nil), cleaned...) + input = append(input, roundedBoundary...) + merged, err := geodata.UnionPolygons(input) + if err != nil || len(merged) == 0 { + return cleaned + } + merged = RemoveTinyPolygonComponents(merged) + merged = densifyOccultationPolygons(merged, 40) + return roundOccultationPhaseBoundaryJunctions(merged) +} + +// roundOccultationPhaseBoundaryJunctions replaces only a short polar phase +// junction with a projected C1 curve. The replaced source window remains the +// acceptance witness: if the outward fillet cannot contain it within the +// source-specific numerical projection tolerance, the original ring is +// returned unchanged rather than silently shrinking visibility. +func roundOccultationPhaseBoundaryJunctions( + polygons [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + result := make([][]geodata.GeoPoint, len(polygons)) + for index, polygon := range polygons { + result[index] = roundOccultationPhaseBoundaryRing(polygon) + } + return result +} + +func roundOccultationAuthoritativeBandJunctions( + polygons [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + result := make([][]geodata.GeoPoint, len(polygons)) + for index, polygon := range polygons { + rounded := roundOccultationAuthoritativeBandJunctionRing(polygon) + result[index] = rounded + } + return result +} + +func roundOccultationPhaseBoundaryRing(points []geodata.GeoPoint) []geodata.GeoPoint { + return roundOccultationBandJunctionRing(points, 74, 75, 155, false) +} + +// roundOccultationAuthoritativeBandJunctionRing rounds a compact high-latitude +// latitude return left by a temporal sweep. Unlike a normal sampled arc, these +// seam vertices reverse latitude over a short chord and are shared by the +// partial and total bands. The candidate remains subject to the source-window +// containment gate in roundOccultationBandJunctionRing, so smoothing preserves +// the local visible envelope within the numerical projection tolerance. +func roundOccultationAuthoritativeBandJunctionRing(points []geodata.GeoPoint) []geodata.GeoPoint { + return roundOccultationBandJunctionRing(points, 60, 80, 125, true) +} + +func roundOccultationTotalBandJunctions( + polygons [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + result := roundOccultationAuthoritativeBandJunctions(polygons) + for index := range result { + result[index] = roundOccultationWidePolarShoulder(result[index]) + } + return result +} + +// roundOccultationWidePolarShoulder repairs a broad concave shoulder where a +// horizon phase cap joins the continuously swept inner-contact envelope. The +// ordinary junction rounder above deliberately handles only short chords; a +// several-hundred-kilometre shoulder therefore survives as a visible notch in +// Web Mercator even though every individual vertex has a benign local turn. +// +// This pass is used only by total bands. It requires a high-latitude global +// extremum, a sharp drop in secant slope on one adjacent arc, and a replacement +// which contains the complete source ring. Ordinary convex polar arcs never +// meet that slope signature and are returned unchanged. +func roundOccultationWidePolarShoulder(points []geodata.GeoPoint) []geodata.GeoPoint { + if len(points) < 16 { + return points + } + closed := geodata.SameGeoPoint(points[0], points[len(points)-1]) + open := append([]geodata.GeoPoint(nil), points...) + if closed { + open = open[:len(open)-1] + } + if len(open) < 15 { + return points + } + extreme := 0 + for index := 1; index < len(open); index++ { + if math.Abs(open[index].Latitude) > math.Abs(open[extreme].Latitude) { + extreme = index + } + } + if math.Abs(open[extreme].Latitude) < 75 { + return points + } + type shoulderCandidate struct { + ring []geodata.GeoPoint + gap float64 + } + var best shoulderCandidate + for _, direction := range []int{1, -1} { + candidate, gap, ok := occultationWidePolarShoulderCandidate(open, extreme, direction) + if ok && gap > best.gap { + best = shoulderCandidate{ring: candidate, gap: gap} + } + } + if len(best.ring) == 0 { + return points + } + if closed { + best.ring = append(best.ring, best.ring[0]) + } + return best.ring +} + +func occultationWidePolarShoulderCandidate( + open []geodata.GeoPoint, + extreme, direction int, +) ([]geodata.GeoPoint, float64, bool) { + const ( + minimumSpanKM = 150.0 + maximumSpanKM = 1200.0 + minimumSlopeDrop = 0.20 + minimumInwardGapKM = 20.0 + maximumInwardGapKM = 100.0 + containmentTolerance = 1.0 + ) + n := len(open) + oriented := make([]geodata.GeoPoint, n) + for index := range oriented { + source := (extreme - direction + index*direction) % n + if source < 0 { + source += n + } + oriented[index] = open[source] + } + start := occultationProjectedPoint(oriented[1]) + polarSign := 1.0 + if oriented[1].Latitude > 0 { + polarSign = -1 + } + xSign := 0.0 + minimumSlope := math.Inf(1) + maximumEarlySlope := 0.0 + anchor := -1 + for index := 2; index+1 < len(oriented); index++ { + point := occultationProjectedPoint(oriented[index]) + dx := point[0] - start[0] + if xSign == 0 && math.Abs(dx) > 1 { + xSign = math.Copysign(1, dx) + } + if xSign == 0 || dx*xSign <= 0 { + break + } + span := math.Abs(dx) + if span > maximumSpanKM { + break + } + inward := (point[1] - start[1]) * polarSign + if inward < -containmentTolerance { + continue + } + slope := inward / span + if span < minimumSpanKM { + maximumEarlySlope = math.Max(maximumEarlySlope, slope) + continue + } + if slope < minimumSlope { + minimumSlope = slope + anchor = index + } + } + if anchor < 4 || maximumEarlySlope-minimumSlope < minimumSlopeDrop { + return nil, 0, false + } + anchorPoint := occultationProjectedPoint(oriented[anchor]) + anchorSpan := math.Abs(anchorPoint[0] - start[0]) + maximumGap := 0.0 + for index := 2; index < anchor; index++ { + point := occultationProjectedPoint(oriented[index]) + span := math.Abs(point[0] - start[0]) + if span > anchorSpan { + return nil, 0, false + } + inward := (point[1] - start[1]) * polarSign + maximumGap = math.Max(maximumGap, inward-minimumSlope*span) + } + if maximumGap < minimumInwardGapKM || maximumGap > maximumInwardGapKM { + return nil, 0, false + } + area := geoRingArea(oriented) + if !finiteGeo(area) || math.Abs(area) <= 1e-12 { + return nil, 0, false + } + outwardSign := 1.0 + if area < 0 { + outwardSign = -1 + } + for offset := 0.0; offset <= maximumInwardGapKM; offset += 2.0 { + candidate := roundOccultationPhaseBoundaryWindow( + oriented, 1, anchor, outwardSign, offset, + ) + if len(candidate) < 4 || !geodata.SphericalPolygonsContainPathsWithinKM( + [][]geodata.GeoPoint{candidate}, [][]geodata.GeoPoint{oriented}, true, containmentTolerance, + ) { + continue + } + if direction < 0 { + reverseGeoPointRing(candidate) + } + return candidate, maximumGap, true + } + return nil, 0, false +} + +func roundOccultationBandJunctionRing( + points []geodata.GeoPoint, + minimumLatitudeDegrees, maximumChordKM, minimumTurnDegrees float64, + requireLatitudeReturn bool, +) []geodata.GeoPoint { + if len(points) < 9 { + return points + } + closed := geodata.SameGeoPoint(points[0], points[len(points)-1]) + open := append([]geodata.GeoPoint(nil), points...) + if closed { + open = open[:len(open)-1] + // The polygonizer can place a short polar return across the ring's + // closing edge. Rotate the closed ring so that the original last + // vertex becomes an ordinary interior node and is examined by the + // same junction scan as every other vertex. + if requireLatitudeReturn && len(open) > 3 { + // Keep two successors after the original closing pair. The rounder + // replaces a window and needs one neighbour beyond that window; a + // one-vertex rotation would still leave a seam candidate at the + // final index and make it ineligible for smoothing. + rotated := make([]geodata.GeoPoint, len(open)) + copy(rotated, open[3:]) + copy(rotated[len(open)-3:], open[:3]) + open = rotated + } + } + if len(open) < 9 { + return points + } + area := geoRingArea(open) + if !finiteGeo(area) || math.Abs(area) <= 1e-12 { + return points + } + // A positive ring area has its interior on the left of traversal; the right + // normal is therefore outward. Reverse it for a clockwise ring. + outwardSign := 1.0 + if area < 0 { + outwardSign = -1 + } + result := open + changed := false + sourceWindowContainmentToleranceKM := 0.05 + if requireLatitudeReturn { + sourceWindowContainmentToleranceKM = 1.0 + } + for pass := 0; pass < 8; pass++ { + // A locally sharp candidate can fail the source-window containment gate + // even when a neighbouring return is safely roundable. Keep trying the + // remaining candidates in this pass instead of abandoning the whole ring. + rejected := make(map[int]bool) + passAccepted := false + for { + candidate := -1 + bestLatitude := 0.0 + bestTurn := 180.0 + for index := 1; index+1 < len(result); index++ { + if rejected[index] { + continue + } + point := result[index] + if math.Abs(point.Latitude) < minimumLatitudeDegrees { + continue + } + previous, next := result[index-1], result[index+1] + if occultationProjectedEdgeDistanceKM(previous, next) > maximumChordKM { + continue + } + turn := occultationTurnAngleDegrees(previous, point, next) + if turn >= minimumTurnDegrees { + continue + } + if requireLatitudeReturn && + ((point.Latitude-previous.Latitude)*(next.Latitude-point.Latitude) >= 0 || + occultationProjectedTurnAngleDegrees(previous, point, next) >= minimumTurnDegrees) { + continue + } + extreme := math.Abs(point.Latitude) + if candidate < 0 || turn < bestTurn || + (turn == bestTurn && extreme > bestLatitude) { + candidate = index + bestLatitude = extreme + bestTurn = turn + } + } + if candidate < 0 { + break + } + plateauStart, plateauEnd := candidate, candidate + for plateauStart > 1 && math.Abs(result[plateauStart-1].Latitude-result[candidate].Latitude) <= 1e-5 { + plateauStart-- + } + for plateauEnd+1 < len(result)-1 && math.Abs(result[plateauEnd+1].Latitude-result[candidate].Latitude) <= 1e-5 { + plateauEnd++ + } + left, right := plateauStart-1, plateauEnd+1 + if left < 1 || right >= len(result)-1 || right-left < 2 { + rejected[candidate] = true + continue + } + accepted := false + sourceWindow := append([]geodata.GeoPoint(nil), result[left:right+1]...) + for offset := 0.0; offset <= 80.0; offset += 2.0 { + candidateRing := roundOccultationPhaseBoundaryWindow( + result, left, right, outwardSign, offset, + ) + if len(candidateRing) < 4 || !geodata.SphericalPolygonsContainPathsWithinKM( + [][]geodata.GeoPoint{candidateRing}, [][]geodata.GeoPoint{sourceWindow}, false, sourceWindowContainmentToleranceKM, + ) { + continue + } + result = candidateRing + changed = true + accepted = true + passAccepted = true + break + } + if accepted { + break + } + rejected[candidate] = true + } + if !passAccepted { + break + } + } + if !changed { + return points + } + if closed { + result = append(result, result[0]) + } + return result +} + +func roundOccultationPhaseBoundaryWindow( + points []geodata.GeoPoint, + left, right int, + outwardSign, offsetKM float64, +) []geodata.GeoPoint { + first := occultationProjectedPoint(points[left]) + last := occultationProjectedPoint(points[right]) + firstNeighbour := occultationProjectedPoint(points[left-1]) + lastNeighbour := occultationProjectedPoint(points[right+1]) + startDX, startDY := first[0]-firstNeighbour[0], first[1]-firstNeighbour[1] + endDX, endDY := lastNeighbour[0]-last[0], lastNeighbour[1]-last[1] + startLength, endLength := math.Hypot(startDX, startDY), math.Hypot(endDX, endDY) + chordLength := math.Hypot(last[0]-first[0], last[1]-first[1]) + if startLength <= 1e-9 || endLength <= 1e-9 || chordLength <= 1e-9 { + return nil + } + startDX, startDY = startDX/startLength, startDY/startLength + endDX, endDY = endDX/endLength, endDY/endLength + tangentLength := chordLength / 3 + controlFirst := [2]float64{first[0] + startDX*tangentLength, first[1] + startDY*tangentLength} + controlLast := [2]float64{last[0] - endDX*tangentLength, last[1] - endDY*tangentLength} + chordDX, chordDY := last[0]-first[0], last[1]-first[1] + chordScale := math.Hypot(chordDX, chordDY) + if chordScale <= 1e-9 { + return nil + } + outwardX, outwardY := chordDY/chordScale, -chordDX/chordScale + outwardX *= outwardSign + outwardY *= outwardSign + steps := int(math.Ceil(chordLength / 5)) + if steps < 8 { + steps = 8 + } + if steps > 96 { + steps = 96 + } + replacement := make([]geodata.GeoPoint, 0, steps-1) + for step := 1; step < steps; step++ { + t := float64(step) / float64(steps) + u := 1 - t + weightFirst := u * u * u + weightControlFirst := 3 * u * u * t + weightControlLast := 3 * u * t * t + weightLast := t * t * t + x := weightFirst*first[0] + weightControlFirst*controlFirst[0] + + weightControlLast*controlLast[0] + weightLast*last[0] + y := weightFirst*first[1] + weightControlFirst*controlFirst[1] + + weightControlLast*controlLast[1] + weightLast*last[1] + bulge := offsetKM * math.Sin(math.Pi*t) * math.Sin(math.Pi*t) + x += outwardX * bulge + y += outwardY * bulge + replacement = append(replacement, occultationUnprojectedPoint(x, y)) + } + result := make([]geodata.GeoPoint, 0, len(points)+len(replacement)-right+left) + result = append(result, points[:left+1]...) + result = append(result, replacement...) + result = append(result, points[right:]...) + return result +} + +// CleanAuthoritativeBandPolygons 在调用方执行父子包含 union 等拓扑操作后,重新应用权威掩带的显示清理。 +// CleanAuthoritativeBandPolygons reapplies the authoritative display cleanup +// after a caller performs a topology operation such as a parent/child union. +// Those operations can reintroduce the short polar seams removed from the +// original linework. +func CleanAuthoritativeBandPolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + return cleanupOccultationAuthoritativeBandPolygons(polygons) +} + +// RoundAuthoritativeBandJunctions 对已组装的权威掩带应用最终的仅显示接缝平滑。 +// RoundAuthoritativeBandJunctions applies the final display-only smoothing to +// an already assembled authoritative band. Callers should invoke it after +// topology operations such as parent/child containment unions, since those +// operations can reintroduce the short polar sweep junction. +func RoundAuthoritativeBandJunctions(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + return roundOccultationAuthoritativeBandJunctions(polygons) +} + +// RoundAuthoritativeTotalBandJunctions 应用常规局部接缝清理和仅限全掩带的宽极区肩部修复。 +// RoundAuthoritativeTotalBandJunctions applies the ordinary local seam cleanup +// plus the total-band-only broad polar shoulder repair. +func RoundAuthoritativeTotalBandJunctions(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + return roundOccultationTotalBandJunctions(polygons) +} + +// removeOccultationPolarKinks removes short high-latitude direction reversals +// that are numerical polygonizer vertices rather than physical boundary folds. +// The endpoint/chord limits keep this local: real phase branches and horizon +// connectors span much farther than this pattern, even when they turn sharply. +func removeOccultationPolarKinks(points []geodata.GeoPoint) []geodata.GeoPoint { + if len(points) < 4 { + return points + } + const ( + // Web Mercator magnifies a 5-10 km spherical return to tens of + // kilometres at the south-polar latitudes used by occultation maps. + // Use a wider local window here than the generic corner cleaner, but + // keep it restricted to the genuinely polar branch where these returns + // are numerical polygonizer junctions rather than physical curves. + minimumPolarLatitudeDegrees = 70.0 + maximumAdjacentEdgeKM = 120.0 + // Never delete a vertex when the resulting rendered chord would itself + // exceed this local 75 km cleanup budget; the final densification pass + // restores the output spacing target after the seam is removed. + maximumEndpointChordKM = 75.0 + maximumTurnAngleDegrees = 165.0 + ) + result := append([]geodata.GeoPoint(nil), points...) + for pass := 0; pass < 128; pass++ { + changed := false + for index := 1; index+1 < len(result); index++ { + first, middle, last := result[index-1], result[index], result[index+1] + if math.Abs(middle.Latitude) < minimumPolarLatitudeDegrees || + occultationProjectedEdgeDistanceKM(first, middle) > maximumAdjacentEdgeKM || + occultationProjectedEdgeDistanceKM(middle, last) > maximumAdjacentEdgeKM || + occultationProjectedEdgeDistanceKM(first, last) > maximumEndpointChordKM { + continue + } + // A reversal in either projected axis is required. A merely curved + // sample with a small turn angle is still a valid physical boundary. + longitudeReversal := math.Remainder(middle.Longitude-first.Longitude, 360)* + math.Remainder(last.Longitude-middle.Longitude, 360) < 0 + latitudeReversal := (middle.Latitude-first.Latitude)*(last.Latitude-middle.Latitude) < 0 + if !longitudeReversal && !latitudeReversal { + continue + } + if occultationTurnAngleDegrees(first, middle, last) >= maximumTurnAngleDegrees { + continue + } + result = append(result[:index], result[index+1:]...) + changed = true + break + } + if !changed { + break + } + } + return result +} + +func validateOccultationVisibleBandWitnesses( + visible [][]geodata.GeoPoint, + useContactContours bool, + visibleFill, contourFill, visibleFillCoveragePaths [][]geodata.GeoPoint, +) error { + if !useContactContours || len(visibleFill) == 0 { + return nil + } + if len(visibleFillCoveragePaths) > 0 { + // Footprint probes are sparse, but they are the cheapest witness set that + // still tracks the actual visible region rather than only the continuous + // contact envelope. Catching a wrong-but-valid face here is much cheaper + // than re-running the full linework search. + if !geodata.SphericalPolygonsContainPathsWithinKM(visible, visibleFillCoveragePaths, true, 25) { + return fmt.Errorf("visible contact-contour face misses footprint witnesses by more than 25 km") + } + } + if !geodata.SphericalPolygonsContainPathsWithinKM(visible, visibleFill, true, 50) { + return fmt.Errorf("visible contact-contour face misses footprint fill by more than 50 km") + } + auditFill := contourFill + if len(auditFill) == 0 { + auditFill = visibleFill + } + if !geodata.SphericalPolygonsContainPathsWithinKM(visible, auditFill, true, 150) { + return fmt.Errorf("visible contact-contour face misses authoritative fill by more than 150 km") + } + return nil +} + +func occultationInteriorPolygon( + source []basic.OccultationPathPoint, + interior [][]basic.OccultationPathPoint, +) bool { + for _, candidate := range interior { + if len(source) != len(candidate) { + continue + } + match := true + for index := range source { + if !sameOccultationPoint(source[index], candidate[index]) { + match = false + break + } + } + if match { + return true + } + } + return false +} + +func occultationStaticInteriorPolygon( + source []basic.OccultationPathPoint, + interior [][]basic.OccultationPathPoint, +) bool { + if !occultationInteriorPolygon(source, interior) { + return false + } + // Center repairs contain a triangular bridge from the physical center to + // the nearest sampled contact edge plus a small circular cap around the + // center. Static bands may keep the bridge because it only closes a sampled + // seam; the radius-based cap is for one-instant rendering and would + // artificially enlarge the long-lived band. + open := source + if len(open) > 1 && sameOccultationPoint(open[0], open[len(open)-1]) { + open = open[:len(open)-1] + } + return len(open) <= 3 +} + +func mergeStaticFootprintRepairs( + polygons [][]geodata.GeoPoint, + footprints []basic.OccultationFootprint, +) [][]geodata.GeoPoint { + repairs := footprintStaticInteriorPolygons(footprints) + if len(repairs) == 0 { + return polygons + } + input := append([][]geodata.GeoPoint(nil), polygons...) + input = append(input, repairs...) + merged, err := geodata.UnionPolygons(input) + if err != nil { + return polygons + } + return densifyOccultationPolygons(merged, 50) +} + +func occultationLimitVisibleFillPolygons( + northern, southern []basic.OccultationPathPoint, +) [][]geodata.GeoPoint { + polygons := make([][]geodata.GeoPoint, 0) + for _, sampleRange := range ContinuousPairedBoundaryRanges(northern, southern) { + for index := sampleRange.Start + 1; index < sampleRange.End; index++ { + source := []basic.OccultationPathPoint{ + northern[index-1], + northern[index], + southern[index], + southern[index-1], + } + visibleSource := clipOccultationPolygonToHorizon(source) + if len(visibleSource) < 3 { + continue + } + polygon := make([]geodata.GeoPoint, len(visibleSource)) + for pointIndex, point := range visibleSource { + polygon[pointIndex] = geodata.GeoPoint{ + Longitude: point.Longitude, + Latitude: point.Latitude, + } + } + polygons = append(polygons, polygon) + } + } + if len(polygons) == 0 { + return nil + } + merged, err := geodata.UnionPolygons(polygons) + if err != nil { + return polygons + } + return RemoveTinyPolygonComponents(merged) +} + +// occultationCurveCoverageProbes adds small interior probes on both sides of +// every physical phase curve. A narrow face between two nearly coincident +// curves can contain real visible footprints while missing all footprint +// centroids; sampling the curve sides lets the linework selector retain that +// face without admitting below-horizon regions. +func occultationCurveCoverageProbes( + curves []basic.OccultationRiseSetCurve, + fillPolygons [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + if len(curves) == 0 || len(fillPolygons) == 0 { + return nil + } + const maximumSamplesPerSegment = 64 + candidates := make([]geodata.GeoPoint, 0, len(curves)*maximumSamplesPerSegment) + for _, curve := range curves { + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + step := (len(segment) + maximumSamplesPerSegment - 1) / maximumSamplesPerSegment + if step < 1 { + step = 1 + } + for index := 0; index+1 < len(segment); index += step { + next := index + step + if next >= len(segment) { + next = len(segment) - 1 + } + first, second := segment[index], segment[next] + deltaLongitude := normalizeGeoLongitude(second.Longitude - first.Longitude) + deltaLatitude := second.Latitude - first.Latitude + length := math.Hypot(deltaLongitude, deltaLatitude) + if length <= 1e-12 { + continue + } + midpoint := geodata.GeoPoint{ + Longitude: normalizeGeoLongitude(first.Longitude + deltaLongitude/2), + Latitude: first.Latitude + deltaLatitude/2, + } + for _, offset := range []float64{0.00005, 0.0005, 0.002} { + for _, side := range []float64{-1, 1} { + candidates = append(candidates, geodata.GeoPoint{ + Longitude: normalizeGeoLongitude(midpoint.Longitude - side*deltaLatitude*offset/length), + Latitude: midpoint.Latitude + side*deltaLongitude*offset/length, + }) + } + } + } + } + } + inside := geodata.SphericalPolygonsContainPoints(fillPolygons, candidates) + probes := make([][]geodata.GeoPoint, 0, len(candidates)) + for index, probe := range candidates { + if !inside[index] { + continue + } + duplicate := false + for _, existing := range probes { + if len(existing) > 0 && geoDistanceKM(existing[0], probe) < 1 { + duplicate = true + break + } + } + if !duplicate { + probes = append(probes, []geodata.GeoPoint{probe}) + } + } + return probes +} + +const maximumOccultationBoundaryAlternatives = 8 diff --git a/internal/occultationgeo/band_linework.go b/internal/occultationgeo/band_linework.go new file mode 100644 index 0000000..005501e --- /dev/null +++ b/internal/occultationgeo/band_linework.go @@ -0,0 +1,1316 @@ +package occultationgeo + +import ( + "fmt" + "math" + "sort" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +func occultationVisibleBoundaryLines( + fallbackPolygons [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, + extraLines [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + return occultationVisibleBoundaryLinesFromBase( + occultationFallbackPolygonBoundaryLines(fallbackPolygons), curves, extraLines, + ) +} + +func occultationFallbackPolygonBoundaryLines( + fallbackPolygons [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + boundaryLines := make([][]geodata.GeoPoint, 0, len(fallbackPolygons)) + for _, polygon := range fallbackPolygons { + if len(polygon) < 3 { + continue + } + line := append([]geodata.GeoPoint(nil), polygon...) + if !geodata.SameGeoPoint(line[0], line[len(line)-1]) { + line = append(line, line[0]) + } + boundaryLines = append(boundaryLines, line) + } + return boundaryLines +} + +func occultationContactContourBoundaryLines( + contours [][]basic.OccultationPathPoint, +) [][]geodata.GeoPoint { + boundaryLines := make([][]geodata.GeoPoint, 0, len(contours)) + for _, contour := range contours { + for _, sampleRange := range ContinuousBoundaryRanges(contour) { + if sampleRange.End-sampleRange.Start < 2 { + continue + } + line := occultationPathGeoLine(contour[sampleRange.Start:sampleRange.End]) + if len(line) >= 2 { + boundaryLines = append(boundaryLines, line) + } + } + } + return boundaryLines +} + +func occultationStaticBandCurves( + curves []basic.OccultationRiseSetCurve, +) []basic.OccultationRiseSetCurve { + if len(curves) == 0 { + return nil + } + result := make([]basic.OccultationRiseSetCurve, 0, len(curves)) + for _, curve := range curves { + if curve.Phase == basic.RiseSetPhaseGreatest { + continue + } + result = append(result, curve) + } + return result +} + +func occultationVisibleBoundaryLinesFromBase( + baseLines [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, + extraLines [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + boundaryLines := make([][]geodata.GeoPoint, 0, len(baseLines)+len(curves)*2+len(extraLines)) + for _, source := range baseLines { + if len(source) < 2 { + continue + } + line := append([]geodata.GeoPoint(nil), source...) + boundaryLines = append(boundaryLines, line) + } + for _, curve := range curves { + boundaryLines = append(boundaryLines, occultationCurveBoundaryLines(curve)...) + } + boundaryLines = append(boundaryLines, extraLines...) + return boundaryLines +} + +func occultationHorizonConnectorBoundaryLines( + connectors []HorizonConnector, +) [][]geodata.GeoPoint { + lines := make([][]geodata.GeoPoint, 0, len(connectors)) + for _, connector := range connectors { + if len(connector.Points) < 2 { + continue + } + line := make([]geodata.GeoPoint, len(connector.Points)) + for index, point := range connector.Points { + line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + lines = append(lines, line) + } + return lines +} + +type occultationPhaseBoundaryEdge struct { + points []geodata.GeoPoint +} + +type occultationPhaseBoundaryNode struct { + point geodata.GeoPoint + edges []int +} + +// occultationPhaseBoundaryPolygons constructs the visible outer band from +// start/end phase curves and horizon closures. Greatest is intentionally +// excluded: it is a diagnostic stage curve inside the visible region, not an +// exterior boundary. The returned rings are accepted only when the cycle +// covers the continuous contact envelope and its own edges remain close to +// that envelope. +func occultationPhaseBoundaryPolygons( + curves []basic.OccultationRiseSetCurve, + connectors []HorizonConnector, + fillPolygons [][]geodata.GeoPoint, +) ([][]geodata.GeoPoint, bool) { + if len(fillPolygons) == 0 { + return nil, false + } + edgesByDirection := make(map[basic.RiseSetDirection][]occultationPhaseBoundaryEdge) + for _, curve := range curves { + if curve.Phase == basic.RiseSetPhaseGreatest { + continue + } + if curve.Phase != basic.RiseSetPhaseStart && + curve.Phase != basic.RiseSetPhaseEnd { + continue + } + for _, line := range occultationCurveBoundaryLines(curve) { + if len(line) >= 2 { + edgesByDirection[curve.Direction] = append( + edgesByDirection[curve.Direction], + occultationPhaseBoundaryEdge{points: line}, + ) + } + } + } + for _, connector := range connectors { + if len(connector.Points) < 2 { + continue + } + edgesByDirection[connector.Direction] = append( + edgesByDirection[connector.Direction], + occultationPhaseBoundaryEdge{ + points: occultationPathGeoLine(connector.Points), + }, + ) + } + if len(edgesByDirection) == 0 { + return nil, false + } + + // 每个通过门槛的环都是一条独立相位支路,并集结果与顺序无关;按分数只取"最优环"会 + // 丢掉真实支路,因此这里保留全部环,只按方向键固定生成顺序以保证可复现。 + directions := make([]basic.RiseSetDirection, 0, len(edgesByDirection)) + for direction := range edgesByDirection { + directions = append(directions, direction) + } + sort.Slice(directions, func(first, second int) bool { return directions[first] < directions[second] }) + rings := make([][]geodata.GeoPoint, 0, len(directions)) + for _, direction := range directions { + for _, ring := range occultationPhaseBoundaryCyclesForEdges(edgesByDirection[direction]) { + if len(ring) < 4 { + continue + } + fillMiss := geodata.SphericalPolygonsPathMissDistanceKM( + [][]geodata.GeoPoint{ring}, fillPolygons, true, + ) + if fillMiss > 150 { + continue + } + edgeMiss := geodata.SphericalPolygonsPathMissDistanceKM( + fillPolygons, [][]geodata.GeoPoint{ring}, true, + ) + if edgeMiss > 200 { + continue + } + rings = append(rings, ring) + } + } + if len(rings) == 0 { + return nil, false + } + if len(rings) == 1 { + return rings, true + } + merged, err := geodata.UnionPolygons(rings) + if err != nil || len(merged) == 0 { + return nil, false + } + return merged, true +} + +func occultationPhaseBoundaryCyclesForEdges( + edges []occultationPhaseBoundaryEdge, +) [][]geodata.GeoPoint { + if len(edges) < 2 { + return nil + } + nodes := make([]occultationPhaseBoundaryNode, 0, len(edges)*2) + edgeNodes := make([][2]int, len(edges)) + validEdges := make([]bool, len(edges)) + nodeFor := func(point geodata.GeoPoint) int { + for index := range nodes { + if geoDistanceKM(nodes[index].point, point) <= curveBoundaryJoinDistanceKM { + return index + } + } + nodes = append(nodes, occultationPhaseBoundaryNode{point: point}) + return len(nodes) - 1 + } + for edgeIndex, edge := range edges { + if len(edge.points) < 2 { + continue + } + start := nodeFor(edge.points[0]) + end := nodeFor(edge.points[len(edge.points)-1]) + if start == end { + continue + } + validEdges[edgeIndex] = true + edgeNodes[edgeIndex] = [2]int{start, end} + nodes[start].edges = append(nodes[start].edges, edgeIndex) + nodes[end].edges = append(nodes[end].edges, edgeIndex) + } + + type cycleResult struct { + ring []geodata.GeoPoint + edgeIDs []int + signature string + } + results := make([]cycleResult, 0) + seen := make(map[string]struct{}) + for startEdge, edge := range edges { + if len(edge.points) < 2 || !validEdges[startEdge] { + continue + } + for _, forward := range []bool{true, false} { + startNode := edgeNodes[startEdge][0] + currentNode := edgeNodes[startEdge][1] + points := append([]geodata.GeoPoint(nil), edge.points...) + if !forward { + startNode, currentNode = currentNode, startNode + reverseGeoPointRing(points) + } + used := map[int]bool{startEdge: true} + edgeIDs := []int{startEdge} + var walk func(int, []geodata.GeoPoint, map[int]bool, []int) + walk = func(node int, ring []geodata.GeoPoint, used map[int]bool, usedEdges []int) { + if node == startNode { + if len(usedEdges) < 2 || len(ring) < 3 { + return + } + closed := append([]geodata.GeoPoint(nil), ring...) + closed = append(closed, closed[0]) + ids := append([]int(nil), usedEdges...) + sort.Ints(ids) + signature := fmt.Sprint(ids) + if _, exists := seen[signature]; exists { + return + } + seen[signature] = struct{}{} + results = append(results, cycleResult{ + ring: closed, edgeIDs: ids, signature: signature, + }) + return + } + if len(usedEdges) >= len(edges) { + return + } + for _, nextEdge := range nodes[node].edges { + if used[nextEdge] || !validEdges[nextEdge] || len(edges[nextEdge].points) < 2 { + continue + } + next := edges[nextEdge].points + nextNode := edgeNodes[nextEdge][1] + if edgeNodes[nextEdge][1] == node { + nextNode = edgeNodes[nextEdge][0] + next = append([]geodata.GeoPoint(nil), next...) + reverseGeoPointRing(next) + } + nextRing := append([]geodata.GeoPoint(nil), ring...) + if len(nextRing) > 0 && geoDistanceKM(nextRing[len(nextRing)-1], next[0]) <= curveBoundaryJoinDistanceKM { + next[0] = nextRing[len(nextRing)-1] + } + nextRing = append(nextRing, next[1:]...) + nextUsed := make(map[int]bool, len(used)+1) + for key, value := range used { + nextUsed[key] = value + } + nextUsed[nextEdge] = true + nextEdges := append(append([]int(nil), usedEdges...), nextEdge) + walk(nextNode, nextRing, nextUsed, nextEdges) + } + } + walk(currentNode, points, used, edgeIDs) + } + } + rings := make([][]geodata.GeoPoint, 0, len(results)) + for _, result := range results { + rings = append(rings, result.ring) + } + return rings +} + +// occultationCurveBoundaryAlternatives returns boundary line sets that replace +// one folded multi-branch curve with a single raw branch. The alternatives are +// intentionally bounded: they are only evaluated after the normal topology +// fails, and the shortest branch is tried first because it is the usual polar +// fold connector rather than the long interior branch. +func occultationCurveBoundaryAlternatives( + fallbackPolygons [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, + extraLines [][]geodata.GeoPoint, +) [][][]geodata.GeoPoint { + return occultationCurveBoundaryAlternativesFromBase( + occultationFallbackPolygonBoundaryLines(fallbackPolygons), curves, extraLines, + ) +} + +func occultationCurveBoundaryAlternativesFromBase( + baseLines [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, + extraLines [][]geodata.GeoPoint, +) [][][]geodata.GeoPoint { + base := occultationVisibleBoundaryLinesFromBase(baseLines, nil, extraLines) + curveLines := make([][][]geodata.GeoPoint, len(curves)) + for index, curve := range curves { + curveLines[index] = occultationCurveBoundaryLines(curve) + } + var alternatives [][][]geodata.GeoPoint + for curveIndex, curve := range curves { + branches := make([][]geodata.GeoPoint, 0, len(curve.Segments)) + for _, segment := range curve.Segments { + if len(segment) < 2 { + continue + } + branch := make([]geodata.GeoPoint, len(segment)) + for pointIndex, point := range segment { + branch[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + branches = append(branches, branch) + } + if len(branches) < 2 { + continue + } + sort.SliceStable(branches, func(first, second int) bool { + return len(branches[first]) < len(branches[second]) + }) + for _, branch := range branches { + candidate := make([][]geodata.GeoPoint, 0, len(base)+len(curves)*2) + candidate = append(candidate, base...) + for otherIndex, lines := range curveLines { + if otherIndex == curveIndex { + candidate = append(candidate, branch) + continue + } + candidate = append(candidate, lines...) + } + alternatives = append(alternatives, candidate) + if len(alternatives) >= maximumOccultationBoundaryAlternatives { + return alternatives + } + } + } + return alternatives +} + +const ( + // CurveBoundaryJoinDistanceKM is deliberately much smaller than the + // polygonizer snap radius. It only joins endpoints that represent the same + // physical fold, rather than nearby polar branches that merely converge in + // longitude. + curveBoundaryJoinDistanceKM = 5.0 + curveBoundaryJoinTime = 30 * time.Second +) + +// occultationCurveBoundaryLines stitches same-curve branch segments that meet +// at a common physical endpoint. Public rise/set data keeps the original +// strictly time-ordered segments; the static fill topology needs a spatial +// edge, so a fold such as C->D and B->D becomes B->D->C. +func occultationCurveBoundaryLines(curve basic.OccultationRiseSetCurve) [][]geodata.GeoPoint { + segments := make([][]geodata.GeoPoint, 0, len(curve.Segments)) + segmentTimes := make([][]time.Time, 0, len(curve.Segments)) + for _, source := range curve.Segments { + if len(source) < 2 { + continue + } + line := occultationPathGeoLine(source) + times := make([]time.Time, len(source)) + for index, point := range source { + times[index] = point.Time + } + segments = append(segments, line) + segmentTimes = append(segmentTimes, times) + } + lines := make([][]geodata.GeoPoint, 0, len(segments)) + for len(segments) > 0 { + line := append([]geodata.GeoPoint(nil), segments[0]...) + lineTimes := append([]time.Time(nil), segmentTimes[0]...) + segments = segments[1:] + segmentTimes = segmentTimes[1:] + for { + joined := false + for index := range segments { + if !curveBoundaryEndpointsMatch(line, lineTimes, segments[index], segmentTimes[index]) { + continue + } + line, lineTimes = joinCurveBoundarySegments(line, lineTimes, segments[index], segmentTimes[index]) + segments = append(segments[:index], segments[index+1:]...) + segmentTimes = append(segmentTimes[:index], segmentTimes[index+1:]...) + joined = true + break + } + if !joined { + break + } + } + if len(line) >= 2 { + lines = append(lines, line) + } + } + return lines +} + +func occultationPathGeoLine(source []basic.OccultationPathPoint) []geodata.GeoPoint { + line := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + return line +} + +// StitchedRiseSetCurveSegments 返回面向显示的升落曲线分段。 +// StitchedRiseSetCurveSegments returns display-oriented rise/set curve +// segments. Solver output keeps branch segments time-ordered; map strokes need +// the spatial continuation at shared fold endpoints so a pair such as A->D and +// B->D renders as one continuous A->D->B polyline. +func StitchedRiseSetCurveSegments(curve basic.OccultationRiseSetCurve) [][]basic.OccultationPathPoint { + segments := make([][]basic.OccultationPathPoint, 0, len(curve.Segments)) + for _, source := range curve.Segments { + if len(source) < 2 { + continue + } + segments = append(segments, append([]basic.OccultationPathPoint(nil), source...)) + } + lines := make([][]basic.OccultationPathPoint, 0, len(segments)) + for len(segments) > 0 { + line := append([]basic.OccultationPathPoint(nil), segments[0]...) + segments = segments[1:] + for { + joined := false + for index := range segments { + if !riseSetCurveEndpointsMatch(line, segments[index]) { + continue + } + line = joinRiseSetCurveSegments(line, segments[index]) + segments = append(segments[:index], segments[index+1:]...) + joined = true + break + } + if !joined { + break + } + } + if len(line) >= 2 { + lines = append(lines, line) + } + } + return lines +} + +func curveBoundaryEndpointsMatch( + first []geodata.GeoPoint, + firstTimes []time.Time, + second []geodata.GeoPoint, + secondTimes []time.Time, +) bool { + if len(first) < 2 || len(second) < 2 || len(firstTimes) != len(first) || len(secondTimes) != len(second) { + return false + } + return (curveBoundaryEndpointMatch(first[0], firstTimes[0], second[0], secondTimes[0]) || + curveBoundaryEndpointMatch(first[0], firstTimes[0], second[len(second)-1], secondTimes[len(second)-1]) || + curveBoundaryEndpointMatch(first[len(first)-1], firstTimes[len(first)-1], second[0], secondTimes[0]) || + curveBoundaryEndpointMatch(first[len(first)-1], firstTimes[len(first)-1], second[len(second)-1], secondTimes[len(second)-1])) +} + +func curveBoundaryEndpointMatch(first geodata.GeoPoint, firstTime time.Time, second geodata.GeoPoint, secondTime time.Time) bool { + return geoDistanceKM(first, second) <= curveBoundaryJoinDistanceKM && + !firstTime.IsZero() && !secondTime.IsZero() && absDuration(firstTime.Sub(secondTime)) <= curveBoundaryJoinTime +} + +func riseSetCurveEndpointsMatch(first, second []basic.OccultationPathPoint) bool { + if len(first) < 2 || len(second) < 2 { + return false + } + return riseSetCurveEndpointMatch(first[0], second[0]) || + riseSetCurveEndpointMatch(first[0], second[len(second)-1]) || + riseSetCurveEndpointMatch(first[len(first)-1], second[0]) || + riseSetCurveEndpointMatch(first[len(first)-1], second[len(second)-1]) +} + +func riseSetCurveEndpointMatch(first, second basic.OccultationPathPoint) bool { + return DistanceKM(first, second) <= curveBoundaryJoinDistanceKM && + !first.Time.IsZero() && !second.Time.IsZero() && + absDuration(first.Time.Sub(second.Time)) <= curveBoundaryJoinTime +} + +func joinRiseSetCurveSegments( + first, second []basic.OccultationPathPoint, +) []basic.OccultationPathPoint { + if riseSetCurveEndpointMatch(first[len(first)-1], second[0]) { + return append(first, second[1:]...) + } + if riseSetCurveEndpointMatch(first[len(first)-1], second[len(second)-1]) { + reverseOccultationPathPoints(second) + return append(first, second[1:]...) + } + if riseSetCurveEndpointMatch(first[0], second[len(second)-1]) { + return append(second[:len(second)-1], first...) + } + reverseOccultationPathPoints(second) + return append(second[:len(second)-1], first...) +} + +func reverseOccultationPathPoints(points []basic.OccultationPathPoint) { + for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 { + points[left], points[right] = points[right], points[left] + } +} + +func joinCurveBoundarySegments( + first []geodata.GeoPoint, + firstTimes []time.Time, + second []geodata.GeoPoint, + secondTimes []time.Time, +) ([]geodata.GeoPoint, []time.Time) { + if curveBoundaryEndpointMatch(first[len(first)-1], firstTimes[len(first)-1], second[0], secondTimes[0]) { + return append(first, second[1:]...), append(firstTimes, secondTimes[1:]...) + } + if curveBoundaryEndpointMatch(first[len(first)-1], firstTimes[len(first)-1], second[len(second)-1], secondTimes[len(second)-1]) { + for left, right := 0, len(second)-1; left < right; left, right = left+1, right-1 { + second[left], second[right] = second[right], second[left] + secondTimes[left], secondTimes[right] = secondTimes[right], secondTimes[left] + } + return append(first, second[1:]...), append(firstTimes, secondTimes[1:]...) + } + if curveBoundaryEndpointMatch(first[0], firstTimes[0], second[len(second)-1], secondTimes[len(second)-1]) { + return append(second[:len(second)-1], first...), append(secondTimes[:len(secondTimes)-1], firstTimes...) + } + for left, right := 0, len(second)-1; left < right; left, right = left+1, right-1 { + second[left], second[right] = second[right], second[left] + secondTimes[left], secondTimes[right] = secondTimes[right], secondTimes[left] + } + return append(second[:len(second)-1], first...), append(secondTimes[:len(secondTimes)-1], firstTimes...) +} + +func absDuration(value time.Duration) time.Duration { + if value < 0 { + return -value + } + return value +} + +func densifyOccultationPolygons( + polygons [][]geodata.GeoPoint, + maximumEdgeKM float64, +) [][]geodata.GeoPoint { + if maximumEdgeKM <= 0 { + return polygons + } + result := make([][]geodata.GeoPoint, len(polygons)) + for polygonIndex, polygon := range polygons { + if len(polygon) < 2 { + result[polygonIndex] = polygon + continue + } + ring := make([]geodata.GeoPoint, 0, len(polygon)*2) + for index, point := range polygon { + ring = append(ring, point) + if index+1 >= len(polygon) { + continue + } + next := polygon[index+1] + distance := occultationProjectedEdgeDistanceKM(point, next) + steps := int(math.Ceil(distance / maximumEdgeKM)) + if steps < 2 { + continue + } + for step := 1; step < steps; step++ { + ring = append(ring, interpolateOccultationGeoPoint(point, next, float64(step)/float64(steps))) + } + } + // GeoJSON closes polygon rings by connecting the final point back to + // the first. Densify that implicit edge as well, otherwise a smooth + // fallback can still render one long closing chord. + if len(polygon) > 2 && !geodata.SameGeoPoint(polygon[0], polygon[len(polygon)-1]) { + first, last := polygon[0], polygon[len(polygon)-1] + distance := occultationProjectedEdgeDistanceKM(last, first) + steps := int(math.Ceil(distance / maximumEdgeKM)) + if steps >= 2 { + for step := 1; step < steps; step++ { + ring = append(ring, interpolateOccultationGeoPoint(last, first, float64(step)/float64(steps))) + } + } + } + result[polygonIndex] = ring + } + return result +} + +// occultationProjectedEdgeDistanceKM measures an edge in the Web Mercator +// chart used by the GeoJSON/OpenLayers consumer, while retaining the shortest +// wrapped longitude. At high latitude a small spherical edge expands strongly +// in this chart; using only the great-circle distance leaves visible polygon +// chords even though the source contact contour is densely sampled. +func occultationProjectedEdgeDistanceKM(first, second geodata.GeoPoint) float64 { + const maxLatitude = 85.05112878 + clampLatitude := func(value float64) float64 { + return math.Max(-maxLatitude, math.Min(maxLatitude, value)) + } + longitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180 + firstLatitude := clampLatitude(first.Latitude) * math.Pi / 180 + secondLatitude := clampLatitude(second.Latitude) * math.Pi / 180 + firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2)) + secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2)) + return EarthRadiusKM * math.Hypot(longitude, secondY-firstY) +} + +func interpolateOccultationGeoPoint( + first, second geodata.GeoPoint, + fraction float64, +) geodata.GeoPoint { + firstLongitude, firstLatitude := first.Longitude*math.Pi/180, first.Latitude*math.Pi/180 + secondLongitude, secondLatitude := second.Longitude*math.Pi/180, second.Latitude*math.Pi/180 + firstCos := math.Cos(firstLatitude) + secondCos := math.Cos(secondLatitude) + firstVector := [3]float64{firstCos * math.Cos(firstLongitude), firstCos * math.Sin(firstLongitude), math.Sin(firstLatitude)} + secondVector := [3]float64{secondCos * math.Cos(secondLongitude), secondCos * math.Sin(secondLongitude), math.Sin(secondLatitude)} + dot := firstVector[0]*secondVector[0] + firstVector[1]*secondVector[1] + firstVector[2]*secondVector[2] + dot = math.Max(-1, math.Min(1, dot)) + angle := math.Acos(dot) + var vector [3]float64 + if angle < 1e-12 { + vector = [3]float64{ + (1-fraction)*firstVector[0] + fraction*secondVector[0], + (1-fraction)*firstVector[1] + fraction*secondVector[1], + (1-fraction)*firstVector[2] + fraction*secondVector[2], + } + } else { + firstWeight := math.Sin((1-fraction)*angle) / math.Sin(angle) + secondWeight := math.Sin(fraction*angle) / math.Sin(angle) + vector = [3]float64{ + firstWeight*firstVector[0] + secondWeight*secondVector[0], + firstWeight*firstVector[1] + secondWeight*secondVector[1], + firstWeight*firstVector[2] + secondWeight*secondVector[2], + } + } + length := math.Sqrt(vector[0]*vector[0] + vector[1]*vector[1] + vector[2]*vector[2]) + return geodata.GeoPoint{ + Longitude: math.Atan2(vector[1], vector[0]) * 180 / math.Pi, + Latitude: math.Asin(math.Max(-1, math.Min(1, vector[2]/length))) * 180 / math.Pi, + } +} + +func occultationPolygonNeedsInteriorProbes( + polygon []basic.OccultationPathPoint, +) bool { + for _, point := range polygon { + if math.Abs(point.Latitude) >= 70 { + return true + } + } + return false +} + +// clipOccultationPolygonToHorizon removes the below-horizon part of an +// instantaneous contact footprint. Footprint construction retains the full +// contact-cone arc so that its horizon closure can be swept continuously; +// that arc is not itself a visible area. Linear interpolation is sufficient +// at the sub-degree horizon crossing because the source samples are already +// spatially dense and the resulting linework is subsequently projected. +func clipOccultationPolygonToHorizon( + source []basic.OccultationPathPoint, +) []basic.OccultationPathPoint { + if len(source) < 3 { + return nil + } + points := source + if sameOccultationPoint(points[0], points[len(points)-1]) { + points = points[:len(points)-1] + } + if len(points) < 3 { + return nil + } + inside := func(point basic.OccultationPathPoint) bool { + return finiteGeo(point.MoonAltitude) && point.MoonAltitude >= -1e-9 + } + result := make([]basic.OccultationPathPoint, 0, len(points)+2) + previous := points[len(points)-1] + previousInside := inside(previous) + for _, current := range points { + currentInside := inside(current) + if currentInside != previousInside { + result = append(result, interpolateOccultationHorizonPoint(previous, current)) + } + if currentInside { + result = append(result, current) + } + previous, previousInside = current, currentInside + } + if len(result) < 3 { + return nil + } + return result +} + +func interpolateOccultationHorizonPoint( + first, second basic.OccultationPathPoint, +) basic.OccultationPathPoint { + denominator := first.MoonAltitude - second.MoonAltitude + fraction := 0.5 + if math.Abs(denominator) > 1e-12 { + fraction = first.MoonAltitude / denominator + } + fraction = math.Max(0, math.Min(1, fraction)) + deltaLongitude := second.Longitude - first.Longitude + for deltaLongitude > 180 { + deltaLongitude -= 360 + } + for deltaLongitude < -180 { + deltaLongitude += 360 + } + point := first + point.Time = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction)) + point.Longitude = normalizeGeoLongitude(first.Longitude + fraction*deltaLongitude) + point.Latitude = first.Latitude + fraction*(second.Latitude-first.Latitude) + point.MoonAltitude = 0 + point.WidthKM = first.WidthKM + fraction*(second.WidthKM-first.WidthKM) + return point +} + +func occultationVisibleFootprintProbes( + source []basic.OccultationPathPoint, + polygon []geodata.GeoPoint, +) []geodata.GeoPoint { + index := geodata.NewSphericalPolygonIndex([][]geodata.GeoPoint{polygon}) + probes := occultationPolarFootprintProbes(source, polygon) + if len(source) < 3 || len(source) != len(polygon) { + return probes + } + x, y, z := 0.0, 0.0, 0.0 + for _, point := range polygon { + longitude, latitude := point.Longitude*math.Pi/180, point.Latitude*math.Pi/180 + cosLatitude := math.Cos(latitude) + x += cosLatitude * math.Cos(longitude) + y += cosLatitude * math.Sin(longitude) + z += math.Sin(latitude) + } + centerLength := math.Sqrt(x*x + y*y + z*z) + if centerLength <= 1e-12 { + return probes + } + center := [3]float64{x / centerLength, y / centerLength, z / centerLength} + centerProbe := geodata.GeoPoint{ + Longitude: math.Atan2(center[1], center[0]) * 180 / math.Pi, + Latitude: math.Asin(math.Max(-1, math.Min(1, center[2]))) * 180 / math.Pi, + } + if index.ContainsPoints([]geodata.GeoPoint{centerProbe})[0] { + probes = append(probes, centerProbe) + } + const maximumVertices = 8 + step := (len(polygon) + maximumVertices - 1) / maximumVertices + type candidateRange struct{ start, end int } + vertexCandidates := make([]geodata.GeoPoint, 0, 2*maximumVertices) + vertexRanges := make([]candidateRange, 0, maximumVertices) + for index := 0; index < len(polygon); index += step { + start := len(vertexCandidates) + point := polygon[index] + longitude, latitude := point.Longitude*math.Pi/180, point.Latitude*math.Pi/180 + cosLatitude := math.Cos(latitude) + vertex := [3]float64{cosLatitude * math.Cos(longitude), cosLatitude * math.Sin(longitude), math.Sin(latitude)} + for _, inward := range []float64{0.0005, 0.005, 0.02, 0.05} { + candidateVector := [3]float64{ + (1-inward)*vertex[0] + inward*center[0], + (1-inward)*vertex[1] + inward*center[1], + (1-inward)*vertex[2] + inward*center[2], + } + length := math.Sqrt(candidateVector[0]*candidateVector[0] + candidateVector[1]*candidateVector[1] + candidateVector[2]*candidateVector[2]) + vertexCandidates = append(vertexCandidates, geodata.GeoPoint{ + Longitude: math.Atan2(candidateVector[1], candidateVector[0]) * 180 / math.Pi, + Latitude: math.Asin(math.Max(-1, math.Min(1, candidateVector[2]/length))) * 180 / math.Pi, + }) + } + vertexRanges = append(vertexRanges, candidateRange{start: start, end: len(vertexCandidates)}) + } + vertexInside := index.ContainsPoints(vertexCandidates) + for _, value := range vertexRanges { + for index := value.start; index < value.end; index++ { + if vertexInside[index] { + probes = append(probes, vertexCandidates[index]) + break + } + } + } + // A polar visible sliver can be much narrower than the vector from a + // vertex to the polygon centroid. Probe both sides of sampled edges as + // well, retaining only points that are actually inside the clipped source. + edgeCandidates := make([]geodata.GeoPoint, 0, 4*maximumVertices) + for index := 0; index < len(polygon); index += step { + first := polygon[index] + second := polygon[(index+1)%len(polygon)] + deltaLongitude := second.Longitude - first.Longitude + for deltaLongitude > 180 { + deltaLongitude -= 360 + } + for deltaLongitude < -180 { + deltaLongitude += 360 + } + deltaLatitude := second.Latitude - first.Latitude + length := math.Hypot(deltaLongitude, deltaLatitude) + if length <= 1e-12 { + continue + } + midpoint := geodata.GeoPoint{ + Longitude: normalizeGeoLongitude(first.Longitude + deltaLongitude/2), + Latitude: first.Latitude + deltaLatitude/2, + } + for _, offset := range []float64{0.0002, 0.002, 0.01, 0.03} { + for _, side := range []float64{-1, 1} { + edgeCandidates = append(edgeCandidates, geodata.GeoPoint{ + Longitude: normalizeGeoLongitude(midpoint.Longitude - side*deltaLatitude*offset/length), + Latitude: midpoint.Latitude + side*deltaLongitude*offset/length, + }) + } + } + } + edgeInside := index.ContainsPoints(edgeCandidates) + for index, candidate := range edgeCandidates { + if edgeInside[index] { + probes = append(probes, candidate) + } + } + return probes +} + +func limitOccultationCoveragePaths( + paths [][]geodata.GeoPoint, + maximum int, +) [][]geodata.GeoPoint { + if maximum < 1 || len(paths) <= maximum { + return paths + } + type indexedPoint struct { + index int + latitude float64 + } + ordered := make([]indexedPoint, len(paths)) + for index, path := range paths { + latitude := 0.0 + if len(path) > 0 { + latitude = path[0].Latitude + } + ordered[index] = indexedPoint{index: index, latitude: latitude} + } + sort.SliceStable(ordered, func(first, second int) bool { + return ordered[first].latitude > ordered[second].latitude + }) + keep := make([]bool, len(paths)) + reserve := maximum / 8 + if reserve < 1 { + reserve = 1 + } + for index := 0; index < reserve && index < len(ordered); index++ { + keep[ordered[index].index] = true + } + for index := 0; index < reserve && index < len(ordered); index++ { + keep[ordered[len(ordered)-1-index].index] = true + } + remaining := maximum + for _, value := range keep { + if value { + remaining-- + } + } + if remaining < 0 { + remaining = 0 + } + stride := float64(len(paths)) / float64(remaining) + result := make([][]geodata.GeoPoint, 0, maximum) + for index, path := range paths { + if keep[index] { + result = append(result, path) + } + } + if remaining > 0 { + for cursor := 0.0; len(result) < maximum && int(cursor) < len(paths); cursor += stride { + index := int(cursor) + if keep[index] { + continue + } + keep[index] = true + result = append(result, paths[index]) + } + } + return result +} + +func sameOccultationPoint(first, second basic.OccultationPathPoint) bool { + return math.Abs(first.Latitude-second.Latitude) <= 1e-10 && + math.Abs(normalizeGeoLongitude(first.Longitude-second.Longitude)) <= 1e-10 +} + +func normalizeGeoLongitude(value float64) float64 { + value = math.Mod(value+180, 360) + if value < 0 { + value += 360 + } + return value - 180 +} + +func occultationPolarFootprintProbes( + source []basic.OccultationPathPoint, + polygon []geodata.GeoPoint, +) []geodata.GeoPoint { + if len(source) < 3 || len(source) != len(polygon) { + return nil + } + x, y, z := 0.0, 0.0, 0.0 + polarIndices := make([]int, 0, len(source)) + for index, point := range source { + longitude, latitude := point.Longitude*math.Pi/180, point.Latitude*math.Pi/180 + cosLatitude := math.Cos(latitude) + x += cosLatitude * math.Cos(longitude) + y += cosLatitude * math.Sin(longitude) + z += math.Sin(latitude) + if math.Abs(point.Latitude) >= 70 { + polarIndices = append(polarIndices, index) + } + } + if len(polarIndices) == 0 { + return nil + } + centerLength := math.Sqrt(x*x + y*y + z*z) + if centerLength <= 1e-12 { + return nil + } + const maximumProbes = 16 + step := (len(polarIndices) + maximumProbes - 1) / maximumProbes + candidates := make([]geodata.GeoPoint, 0, maximumProbes) + for position := 0; position < len(polarIndices); position += step { + point := source[polarIndices[position]] + longitude, latitude := point.Longitude*math.Pi/180, point.Latitude*math.Pi/180 + cosLatitude := math.Cos(latitude) + const inward = 0.002 + candidateVector := [3]float64{ + (1-inward)*cosLatitude*math.Cos(longitude) + inward*x/centerLength, + (1-inward)*cosLatitude*math.Sin(longitude) + inward*y/centerLength, + (1-inward)*math.Sin(latitude) + inward*z/centerLength, + } + candidateLength := math.Sqrt( + candidateVector[0]*candidateVector[0] + + candidateVector[1]*candidateVector[1] + + candidateVector[2]*candidateVector[2], + ) + candidates = append(candidates, geodata.GeoPoint{ + Longitude: math.Atan2(candidateVector[1], candidateVector[0]) * 180 / math.Pi, + Latitude: math.Asin(math.Max(-1, math.Min(1, candidateVector[2]/candidateLength))) * 180 / math.Pi, + }) + } + inside := geodata.SphericalPolygonsContainPoints([][]geodata.GeoPoint{polygon}, candidates) + probes := make([]geodata.GeoPoint, 0, len(candidates)) + for index, candidate := range candidates { + if inside[index] { + probes = append(probes, candidate) + } + } + return probes +} + +func removeOccultationHairpins(points []geodata.GeoPoint, maximumClosureKM, minimumDetourKM float64, maximumSpan int) []geodata.GeoPoint { + if len(points) < 5 || maximumSpan < 3 { + return points + } + result := append([]geodata.GeoPoint(nil), points...) + for pass := 0; pass < 8; pass++ { + changed := false + for start := 0; start+3 < len(result); start++ { + limit := start + maximumSpan + if limit >= len(result) { + limit = len(result) - 1 + } + arcLength := 0.0 + best := -1 + for end := start + 1; end <= limit; end++ { + arcLength += geoDistanceKM(result[end-1], result[end]) + if end < start+3 { + continue + } + closure := geoDistanceKM(result[start], result[end]) + if closure <= maximumClosureKM && arcLength-closure >= minimumDetourKM { + best = end + } + } + if best > start+1 { + result = append(result[:start+1], result[best:]...) + changed = true + } + } + if !changed { + break + } + } + return result +} + +// smoothOccultationHairpins replaces a numerical return with a short, +// densified great-circle chord. Deleting the return vertices outright can +// leave a long straight edge in a rendered fallback band, so the replacement +// preserves display sampling while removing only the detected detour. +func smoothOccultationHairpins( + points []geodata.GeoPoint, + maximumClosureKM, minimumDetourKM float64, + maximumSpan int, + maximumEdgeKM float64, +) []geodata.GeoPoint { + if len(points) < 5 || maximumSpan < 3 || maximumEdgeKM <= 0 { + return points + } + result := append([]geodata.GeoPoint(nil), points...) + for pass := 0; pass < 8; pass++ { + changed := false + for start := 0; start+3 < len(result); start++ { + limit := start + maximumSpan + if limit >= len(result) { + limit = len(result) - 1 + } + arcLength := 0.0 + best := -1 + bestDetour := minimumDetourKM + for end := start + 1; end <= limit; end++ { + arcLength += geoDistanceKM(result[end-1], result[end]) + if end < start+3 { + continue + } + closure := geoDistanceKM(result[start], result[end]) + detour := arcLength - closure + if closure <= maximumClosureKM && detour >= bestDetour { + best = end + bestDetour = detour + } + } + if best <= start+1 { + continue + } + first, last := result[start], result[best] + steps := int(math.Ceil(geoDistanceKM(first, last) / maximumEdgeKM)) + if steps < 1 { + steps = 1 + } + replacement := make([]geodata.GeoPoint, 0, steps) + for step := 1; step < steps; step++ { + replacement = append(replacement, + interpolateOccultationGeoPoint(first, last, float64(step)/float64(steps))) + } + next := make([]geodata.GeoPoint, 0, len(result)-best+start+1+len(replacement)) + next = append(next, result[:start+1]...) + next = append(next, replacement...) + next = append(next, result[best:]...) + result = next + changed = true + break + } + if !changed { + break + } + } + return result +} + +// removeOccultationSharpCorners drops tiny numerical backtracks left at a +// shared polar fold. These are not physical phase vertices: the neighbouring +// points are only a few kilometres apart while the rendered edge reverses +// direction, which produces a visible corner in a filled map polygon. +func removeOccultationSharpCorners( + points []geodata.GeoPoint, + maximumChordKM, minimumAngleDegrees float64, +) []geodata.GeoPoint { + if len(points) < 4 || maximumChordKM <= 0 || minimumAngleDegrees <= 0 { + return points + } + result := append([]geodata.GeoPoint(nil), points...) + for pass := 0; pass < 128; pass++ { + changed := false + for index := 1; index+1 < len(result); index++ { + first, middle, last := result[index-1], result[index], result[index+1] + if geoDistanceKM(first, last) > maximumChordKM { + continue + } + if occultationTurnAngleDegrees(first, middle, last) >= minimumAngleDegrees { + continue + } + result = append(result[:index], result[index+1:]...) + changed = true + break + } + if !changed { + break + } + } + return result +} + +// removeOccultationPolarSharpCorners removes a short, high-latitude change of +// direction even when both coordinate axes remain monotonic. Such vertices +// are not reversals, but are the polygonizer's seam between two sampled polar +// branches and render as a visible notch in Web Mercator. +func removeOccultationPolarSharpCorners( + points []geodata.GeoPoint, + maximumChordKM, minimumAngleDegrees, minimumLatitudeDegrees float64, +) []geodata.GeoPoint { + if len(points) < 4 || maximumChordKM <= 0 || minimumAngleDegrees <= 0 { + return points + } + result := append([]geodata.GeoPoint(nil), points...) + for pass := 0; pass < 128; pass++ { + changed := false + for index := 1; index+1 < len(result); index++ { + first, middle, last := result[index-1], result[index], result[index+1] + if math.Abs(middle.Latitude) < minimumLatitudeDegrees || + geoDistanceKM(first, last) > maximumChordKM || + occultationTurnAngleDegrees(first, middle, last) >= minimumAngleDegrees { + continue + } + result = append(result[:index], result[index+1:]...) + changed = true + break + } + if !changed { + break + } + } + return result +} + +// smoothOccultationPolarCorners replaces a short polar backtrack whose direct +// endpoint chord is too long for deletion. Interpolating that chord keeps the +// rendered spacing bounded while removing the sampled branch's angular seam. +func smoothOccultationPolarCorners(points []geodata.GeoPoint) []geodata.GeoPoint { + if len(points) < 4 { + return points + } + result := append([]geodata.GeoPoint(nil), points...) + for pass := 0; pass < 128; pass++ { + changed := false + for index := 1; index+1 < len(result); index++ { + first, middle, last := result[index-1], result[index], result[index+1] + if math.Abs(middle.Latitude) < 70 || + occultationProjectedEdgeDistanceKM(first, last) > 140 || + occultationTurnAngleDegrees(first, middle, last) >= 165 { + continue + } + longitudeReversal := math.Remainder(middle.Longitude-first.Longitude, 360)* + math.Remainder(last.Longitude-middle.Longitude, 360) < 0 + latitudeReversal := (middle.Latitude-first.Latitude)*(last.Latitude-middle.Latitude) < 0 + if !longitudeReversal && !latitudeReversal { + continue + } + steps := int(math.Ceil(occultationProjectedEdgeDistanceKM(first, last) / 40)) + if steps < 2 { + steps = 2 + } + replacement := make([]geodata.GeoPoint, 0, steps-1) + for step := 1; step < steps; step++ { + replacement = append(replacement, + interpolateOccultationGeoPoint(first, last, float64(step)/float64(steps))) + } + next := make([]geodata.GeoPoint, 0, len(result)-1+len(replacement)) + next = append(next, result[:index]...) + next = append(next, replacement...) + next = append(next, result[index+1:]...) + result = next + changed = true + break + } + if !changed { + break + } + } + return result +} + +// smoothOccultationPolarWobbles removes a short latitude oscillation on an +// otherwise monotone polar edge. Polygonizer seams can alternate north/south +// by a few kilometres while spanning a longer longitude interval, which is +// too wide for the point-deletion cleaners above. The replacement is bounded +// to a four-point window, a 35 km deviation, and a 400 km endpoint span; real +// branch folds with a longitude reversal or larger curvature remain intact. +func smoothOccultationPolarWobbles(points []geodata.GeoPoint) []geodata.GeoPoint { + const ( + minimumPolarLatitudeDegrees = 75.0 + maximumDeviationKM = 35.0 + maximumEndpointSpanKM = 400.0 + windowPoints = 3 + ) + if len(points) < windowPoints+1 { + return points + } + result := append([]geodata.GeoPoint(nil), points...) + closed := len(result) > 1 && geodata.SameGeoPoint(result[0], result[len(result)-1]) + limit := len(result) + if closed { + limit-- + } + for pass := 0; pass < 8; pass++ { + changed := false + for start := 0; start+windowPoints < limit; start++ { + end := start + windowPoints + first, last := result[start], result[end] + if math.Abs(first.Latitude) < minimumPolarLatitudeDegrees || + math.Abs(last.Latitude) < minimumPolarLatitudeDegrees || + occultationProjectedEdgeDistanceKM(first, last) > maximumEndpointSpanKM { + continue + } + longitudeDirection := 0.0 + latitudeReversal := false + previousLatitudeDelta := 0.0 + valid := true + for index := start + 1; index <= end; index++ { + point := result[index] + if math.Abs(point.Latitude) < minimumPolarLatitudeDegrees { + valid = false + break + } + longitudeDelta := math.Remainder(point.Longitude-result[index-1].Longitude, 360) + if math.Abs(longitudeDelta) <= 1e-7 { + valid = false + break + } + if longitudeDirection == 0 { + longitudeDirection = math.Copysign(1, longitudeDelta) + } else if longitudeDelta*longitudeDirection <= 0 { + valid = false + break + } + latitudeDelta := point.Latitude - result[index-1].Latitude + if previousLatitudeDelta != 0 && latitudeDelta*previousLatitudeDelta < 0 { + latitudeReversal = true + } + if latitudeDelta != 0 { + previousLatitudeDelta = latitudeDelta + } + } + if !valid || !latitudeReversal { + continue + } + for index := start + 1; index < end; index++ { + fraction := float64(index-start) / float64(windowPoints) + baseline := interpolateOccultationGeoPoint(first, last, fraction) + if geoDistanceKM(result[index], baseline) > maximumDeviationKM { + valid = false + break + } + } + if !valid { + continue + } + for index := start + 1; index < end; index++ { + fraction := float64(index-start) / float64(windowPoints) + result[index] = interpolateOccultationGeoPoint(first, last, fraction) + } + changed = true + start = end - 1 + } + if !changed { + break + } + } + if closed && len(result) > 1 { + result[len(result)-1] = result[0] + } + return result +} + +func occultationTurnAngleDegrees( + first, middle, last geodata.GeoPoint, +) float64 { + latitude := middle.Latitude * math.Pi / 180 + scale := math.Cos(latitude) + firstX := math.Remainder(first.Longitude-middle.Longitude, 360) * scale + firstY := first.Latitude - middle.Latitude + lastX := math.Remainder(last.Longitude-middle.Longitude, 360) * scale + lastY := last.Latitude - middle.Latitude + firstLength := math.Hypot(firstX, firstY) + lastLength := math.Hypot(lastX, lastY) + if firstLength <= 1e-12 || lastLength <= 1e-12 { + return 180 + } + cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength) + cosine = math.Max(-1, math.Min(1, cosine)) + return math.Acos(cosine) * 180 / math.Pi +} diff --git a/internal/occultationgeo/continuity.go b/internal/occultationgeo/continuity.go new file mode 100644 index 0000000..83a1ccd --- /dev/null +++ b/internal/occultationgeo/continuity.go @@ -0,0 +1,878 @@ +// Package occultationgeo provides shared geographic helpers for occultation encoders and renderers. +package occultationgeo + +import ( + "fmt" + "math" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +// 掩星边界连续性判定的几何门限 / geometric thresholds for occultation-boundary continuity checks. +const ( + EarthRadiusKM = 6378.1366 + BoundaryBranchJumpKM = 750.0 + // BoundaryBranchSpeedKMPerSecond 是判定"物理上不可能的支路跳变"的地面速度门限: + // 掩星边界随月球影子移动,地面速度上限约 1.1 km/s,取 2 km/s 留约两倍余量。 + // BoundaryBranchSpeedKMPerSecond gates "physically impossible" branch jumps: the boundary + // follows the lunar shadow at up to about 1.1 km/s on the ground, so 2 km/s keeps a + // factor-of-two margin. + BoundaryBranchSpeedKMPerSecond = 2.0 + staticCenterCapRadiusKM = 31.0 + staticCenterCapPoints = 16 + closedFootprintSweepPoints = 96 + closedFootprintSweepMaxStepKM = 2500.0 +) + +// SampleRange 是一个可在不跨越支路变化时连接的半开样本区间。 +// SampleRange is a half-open range of samples that can be joined without crossing a branch change. +type SampleRange struct { + Start int + End int +} + +// ContinuousBoundaryRanges 在物理上不可能的支路跳变处拆分边界。 +// ContinuousBoundaryRanges splits a boundary at physically impossible branch changes. +// One-sample ranges are retained so callers can represent event endpoints without reconnecting a jump. +func ContinuousBoundaryRanges(points []basic.OccultationPathPoint) []SampleRange { + return continuousRanges(len(points), func(index int) bool { + return BoundaryBranchChanged(points[index-1], points[index]) + }) +} + +// ContinuousPairedBoundaryRanges 在成对边界任一侧换支时拆分区间。 +// ContinuousPairedBoundaryRanges splits paired limits when either side changes branch. +func ContinuousPairedBoundaryRanges( + first, second []basic.OccultationPathPoint, +) []SampleRange { + count := len(first) + if len(second) < count { + count = len(second) + } + return continuousRanges(count, func(index int) bool { + return BoundaryBranchChanged(first[index-1], first[index]) || + BoundaryBranchChanged(second[index-1], second[index]) + }) +} + +// PairedBoundaryPolygons 仅在连续成对边界之间返回扫掠单元。 +// PairedBoundaryPolygons returns sweep cells only across continuous paired +// limit ranges. Instantaneous footprints remain responsible for end caps. +func PairedBoundaryPolygons( + first, second []basic.OccultationPathPoint, +) [][]geodata.GeoPoint { + count := len(first) + if len(second) < count { + count = len(second) + } + polygons := make([][]geodata.GeoPoint, 0, count) + // A branch change on only one side creates a long-lived invalid cross + // section: the changed side has already moved to its new tangent branch + // while the other side remains on the old branch. Suppress cells until the + // next branch transition establishes a new paired branch. + pendingBranch := false + for index := 1; index < count; index++ { + firstChanged := BoundaryBranchChanged(first[index-1], first[index]) + secondChanged := BoundaryBranchChanged(second[index-1], second[index]) + if pendingBranch { + // 抑制以"下一次任一侧换支"为界;此后若不再换支,剩余单元仍是不匹配的支路对。 + if firstChanged || secondChanged { + pendingBranch = false + } + continue + } + if firstChanged != secondChanged { + pendingBranch = true + continue + } + if firstChanged { // both sides changed at the same transition + continue + } + previousFirst, currentFirst := first[index-1], first[index] + previousSecond, currentSecond := second[index-1], second[index] + polygons = append(polygons, []geodata.GeoPoint{ + {Longitude: previousFirst.Longitude, Latitude: previousFirst.Latitude}, + {Longitude: currentFirst.Longitude, Latitude: currentFirst.Latitude}, + {Longitude: currentSecond.Longitude, Latitude: currentSecond.Latitude}, + {Longitude: previousSecond.Longitude, Latitude: previousSecond.Latitude}, + }) + } + return polygons +} + +// RemoveTinyPolygonComponents 删除紧凑极区掩带组面时留下的微小数值薄片。 +// RemoveTinyPolygonComponents drops numerical slivers left when a compact +// sweep closes at a shared endpoint. A component survives only when it reaches +// 0.01% of the largest component area, so components comparable to the main +// band and genuine disjoint projected branches are always preserved; the +// largest component itself is retained unconditionally. +func RemoveTinyPolygonComponents(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + if len(polygons) < 2 { + return polygons + } + areas := make([]float64, len(polygons)) + maximum, maximumIndex := 0.0, 0 + for index, polygon := range polygons { + areas[index] = math.Abs(geoRingArea(polygon)) + if areas[index] > maximum { + maximum, maximumIndex = areas[index], index + } + } + if maximum <= 0 || !finiteGeo(maximum) { + return polygons + } + threshold := maximum * 1e-4 + filtered := make([][]geodata.GeoPoint, 0, len(polygons)) + for index, polygon := range polygons { + // 最大分量必然达到门槛,显式保留以固定"主带不会被过滤"的契约。 + if index == maximumIndex || areas[index] >= threshold { + filtered = append(filtered, polygon) + } + } + return filtered +} + +// removeOccultationPolarSliverComponents drops detached high-latitude faces +// with only a handful of vertices. These are polygonizer junction slivers, +// not independent occultation regions; merging one into the main face turns +// its closure into the staircase visible at the south polar tip. +func removeOccultationPolarSliverComponents(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + if len(polygons) < 2 { + return polygons + } + filtered := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + polar := len(polygon) < 8 + for _, point := range polygon { + if math.Abs(point.Latitude) < 70 { + polar = false + break + } + } + if !polar { + filtered = append(filtered, polygon) + } + } + if len(filtered) == 0 { + return polygons + } + return filtered +} + +// ConstrainPolygonsWithin 修复子掩带对父掩带的小数值突破,大幅差异保持不变以便诊断。 +// ConstrainPolygonsWithin repairs a small numerical breach of a child band +// against its parent band. Finite-disk inner-contact sweeps can differ from +// the outer sweep by a few samples at a branch junction; when the breach is +// small, replacing those samples with the nearest parent boundary vertex +// preserves the child curve while restoring the physical containment +// invariant. Large breaches are left untouched so this helper cannot hide a +// wrong face selection. +func ConstrainPolygonsWithin( + parent, child [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + if len(parent) == 0 || len(child) == 0 { + return child + } + initialMissDistance := geodata.SphericalPolygonsPathMissDistanceKM(parent, child, true) + if initialMissDistance <= 0 { + return child + } + const maximumRepairDistanceKM = 100.0 + const maximumResidualMissDistanceKM = 10.0 + if initialMissDistance > maximumRepairDistanceKM { + return child + } + result := make([][]geodata.GeoPoint, len(child)) + // 父带不变,逐点包含索引只建一次;每遍重建会让 6 遍细化退化成 O(passes×父带边数)。 + parentIndex := geodata.NewSphericalPolygonIndex(parent) + for index, source := range child { + if len(source) < 4 { + if len(openFootprintRing(source)) >= 3 && math.Abs(geoRingArea(source)) > 1e-12 { + result[index] = append([]geodata.GeoPoint(nil), source...) + } + continue + } + ring := append([]geodata.GeoPoint(nil), source...) + closed := geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) + limit := len(ring) + if closed { + limit-- + } + containment := parentIndex.ContainsPoints(ring[:limit]) + for pointIndex := 0; pointIndex < limit; pointIndex++ { + point := ring[pointIndex] + if containment[pointIndex] { + continue + } + nearest, distance := nearestPolygonBoundaryPoint(parent, point) + if distance <= maximumRepairDistanceKM { + ring[pointIndex] = nearest + } + } + if closed { + ring[len(ring)-1] = ring[0] + } + result[index] = ring + } + result = usableOccultationPolygons(result) + if geodata.SphericalPolygonsPathMissDistanceKM(parent, result, true) <= 0 { + return result + } + // Vertex-only repair cannot see a child edge whose endpoints are both + // inside the parent while its great-circle midpoint crosses outside. Split + // the repaired ring at the same projected spacing used by output geometry, + // then apply the local vertex snap to those newly exposed edge probes. + densified := densifyOccultationPolygons(result, 10) + densifiedMiss := initialMissDistance + for pass := 0; pass < 6; pass++ { + changed := false + for index, source := range densified { + ring := append([]geodata.GeoPoint(nil), source...) + closed := len(ring) > 1 && geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) + limit := len(ring) + if closed { + limit-- + } + containment := parentIndex.ContainsPoints(ring[:limit]) + for pointIndex := 0; pointIndex < limit; pointIndex++ { + point := ring[pointIndex] + if containment[pointIndex] { + continue + } + nearest, distance := nearestPolygonBoundaryPoint(parent, point) + if distance <= maximumRepairDistanceKM { + ring[pointIndex] = nearest + changed = true + } + } + if closed { + ring[len(ring)-1] = ring[0] + } + for { + cleaned := removeDirectProjectedSharpCorners(ring, 20, 30) + cleaned = removeOccultationSharpCorners(cleaned, 20, 30) + if len(cleaned) == len(ring) { + break + } + ring = cleaned + } + if closed && len(ring) > 1 && !geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) { + ring = append(ring, ring[0]) + } + densified[index] = ring + } + densified = usableOccultationPolygons(densified) + densifiedMiss = geodata.SphericalPolygonsPathMissDistanceKM(parent, densified, true) + if densifiedMiss <= maximumResidualMissDistanceKM { + return densified + } + if !changed { + break + } + } + // 只有残差实质变小(>10%)才采用细化结果;亚公里级改善不值得改变输出点数, + // 其余情况按"大突破保持不变"的契约返回 child。 + if len(densified) > 0 && densifiedMiss < initialMissDistance*0.9 { + return densified + } + return child +} + +func nearestPolygonVertex( + polygons [][]geodata.GeoPoint, + point geodata.GeoPoint, +) (geodata.GeoPoint, float64) { + nearest := geodata.GeoPoint{} + distance := math.Inf(1) + for _, polygon := range polygons { + for _, candidate := range polygon { + value := geoDistanceKM(point, candidate) + if value < distance { + nearest, distance = candidate, value + } + } + } + return nearest, distance +} + +func nearestPolygonBoundaryPoint( + polygons [][]geodata.GeoPoint, + point geodata.GeoPoint, +) (geodata.GeoPoint, float64) { + nearest := geodata.GeoPoint{} + distance := math.Inf(1) + for _, polygon := range polygons { + if len(polygon) < 2 { + continue + } + limit := len(polygon) + if limit > 1 && geodata.SameGeoPoint(polygon[0], polygon[limit-1]) { + limit-- + } + for index := 0; index < limit; index++ { + start := polygon[index] + end := polygon[(index+1)%limit] + latitude := point.Latitude * math.Pi / 180 + scaleX := math.Cos(latitude) + startX := math.Remainder(start.Longitude-point.Longitude, 360) * scaleX + startY := start.Latitude - point.Latitude + endX := math.Remainder(end.Longitude-point.Longitude, 360) * scaleX + endY := end.Latitude - point.Latitude + deltaX, deltaY := endX-startX, endY-startY + fraction := 0.0 + if lengthSquared := deltaX*deltaX + deltaY*deltaY; lengthSquared > 0 { + fraction = math.Max(0, math.Min(1, + -(startX*deltaX+startY*deltaY)/lengthSquared, + )) + } + candidate := interpolateOccultationGeoPoint(start, end, fraction) + value := geoDistanceKM(point, candidate) + if value < distance { + nearest, distance = candidate, value + } + } + } + if math.IsInf(distance, 1) { + return nearestPolygonVertex(polygons, point) + } + return nearest, distance +} + +// FootprintSweepPolygons 构造瞬时月掩足迹的静态扫掠并集。 +// FootprintSweepPolygons builds the static union of instantaneous occultation +// footprints. Open contact-cone arcs are swept between adjacent samples so +// their per-instant horizon closures do not survive as staircase edges. +// Legacy footprints without Boundaries retain the polygon-union behavior. +func FootprintSweepPolygons( + footprints []basic.OccultationFootprint, + northern, southern []basic.OccultationPathPoint, +) ([][]geodata.GeoPoint, error) { + return footprintSweepPolygons(footprints, northern, southern) +} + +// ContactSweepBoundaryLines 返回由瞬时接触弧的时间扫掠导出的连续边界线网。 +// ContactSweepBoundaryLines returns continuous boundary linework derived from +// open contact-cone arcs. Closed instantaneous footprint rings are deliberately +// excluded so callers can use these lines as static-band boundary candidates. +func ContactSweepBoundaryLines( + footprints []basic.OccultationFootprint, +) [][]geodata.GeoPoint { + if !footprintBoundariesAvailable(footprints) { + return nil + } + polygons, err := footprintOpenSweepPolygons(footprints) + if err != nil { + polygons, err = footprintOpenSweepPolygonsWithoutTransitions(footprints) + } + if err != nil || len(polygons) == 0 { + // A branch change can make the ribbon union fail at a single numerical + // intersection even though its endpoint tracks remain valid. Expose those + // tracks as diagnostic boundary lines; they are also the caps used by the + // bounded endpoint fallback and therefore keep line/fill audits consistent. + samples := make([]geodata.OpenBoundarySweepSample, 0, len(footprints)) + for _, footprint := range footprints { + if footprint.Closed { + samples = append(samples, geodata.OpenBoundarySweepSample{Closed: true}) + continue + } + boundaries := footprintGeoBoundaries(footprint) + if len(boundaries) == 0 { + continue + } + samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: boundaries}) + } + outlines, outlineErr := geodata.OpenBoundaryEndpointOutlines(samples) + if outlineErr != nil { + return nil + } + polygons = outlines + // Keep the actual open contact arcs as diagnostic linework as well. The + // endpoint outline alone omits the intermediate limb bulges and can make a + // valid time-union edge appear a few kilometres detached from its source + // boundary after spherical union interpolation. + for _, footprint := range footprints { + for _, boundary := range footprintGeoBoundaries(footprint) { + if len(boundary) >= 2 { + polygons = append(polygons, boundary) + } + } + } + } + lines := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + if len(polygon) < 3 { + continue + } + line := append([]geodata.GeoPoint(nil), polygon...) + if !geodata.SameGeoPoint(line[0], line[len(line)-1]) { + line = append(line, line[0]) + } + // Sparse polar footprint sweeps can carry a one-sample numerical return + // at a branch junction. Remove that local kink before this line is used + // as a fallback boundary; authoritative contact contours remain untouched. + line = removeOccultationPolarKinks(line) + line = removeOccultationSharpCorners(line, 20, 30) + if len(line) > 1 && !geodata.SameGeoPoint(line[0], line[len(line)-1]) { + line = append(line, line[0]) + } + lines = append(lines, line) + } + return lines +} + +func footprintSweepPolygons( + footprints []basic.OccultationFootprint, + northern, southern []basic.OccultationPathPoint, +) ([][]geodata.GeoPoint, error) { + var ( + staticRepairs [][]geodata.GeoPoint + closedSweep [][]geodata.GeoPoint + legacy [][]geodata.GeoPoint + polygons [][]geodata.GeoPoint + ) + usedOpenSweep := false + if footprintBoundariesAvailable(footprints) { + sweep, err := footprintOpenSweepPolygons(footprints) + if err != nil { + sweep = nil + } + if len(sweep) > 0 && footprintSweepCoversSamples(sweep, footprints) { + // A covering open sweep already contains the closed instantaneous + // footprints between its two horizon-limited runs. Adding those faces and + // their secondary sweep again introduces coincident edges and can make the + // spherical union select a sampled scallop or reject an otherwise closed + // continuous ring. Keep source footprints as witnesses; only independent + // interior repair faces still need to participate in the output union. + hasInterior := false + for _, footprint := range footprints { + hasInterior = hasInterior || len(footprint.InteriorPolygons) > 0 + } + if hasInterior { + staticRepairs = footprintStaticInteriorPolygons(footprints) + polygons = append(polygons, staticRepairs...) + } + polygons = append(polygons, sweep...) + usedOpenSweep = true + } else { + staticRepairs = footprintStaticInteriorPolygons(footprints) + closedSweep = footprintClosedSweepPolygons(footprints) + legacy = append(footprintPolygons(footprints), staticRepairs...) + legacy = append(legacy, closedSweep...) + polygons = legacy + if len(closedSweep) == 0 { + polygons = append(polygons, PairedBoundaryPolygons(northern, southern)...) + } + } + } else { + staticRepairs = footprintStaticInteriorPolygons(footprints) + closedSweep = footprintClosedSweepPolygons(footprints) + legacy = append(footprintPolygons(footprints), staticRepairs...) + legacy = append(legacy, closedSweep...) + polygons = legacy + if len(closedSweep) == 0 { + polygons = append(polygons, PairedBoundaryPolygons(northern, southern)...) + } + } + if len(polygons) == 0 { + return nil, fmt.Errorf("occultation footprint sweep has no usable polygons") + } + polygons = usableOccultationPolygons(polygons) + if len(polygons) == 0 { + return nil, fmt.Errorf("occultation footprint sweep has no non-degenerate polygons") + } + if usedOpenSweep && len(polygons) == 1 { + return cleanupFootprintSweepPolygons(closedOccultationSweepFaces(polygons), northern, southern), nil + } + merged, err := geodata.UnionPolygons(polygons) + if err != nil && usedOpenSweep { + merged, err = occultationRetryOpenSweepUnion(polygons, footprints, northern, southern, err) + } + if err != nil { + if fallback := closedOccultationSweepFaces(polygons); len(fallback) > 0 { + return cleanupFootprintSweepPolygons(fallback, northern, southern), nil + } + return nil, fmt.Errorf("merge instantaneous footprints: %w", err) + } + return cleanupFootprintSweepPolygons(merged, northern, southern), nil +} + +// occultationRetryOpenSweepUnion 在时间扫掠的球面并集失败后依次重试去掉过渡端帽、 +// 只用配对限带、退回传统瞬时面;全部失败才把原始错误交回调用方。 +func occultationRetryOpenSweepUnion( + polygons [][]geodata.GeoPoint, + footprints []basic.OccultationFootprint, + northern, southern []basic.OccultationPathPoint, + original error, +) ([][]geodata.GeoPoint, error) { + bareSweep, bareErr := footprintOpenSweepPolygonsWithoutTransitions(footprints) + if bareErr == nil && len(bareSweep) > 0 && footprintSweepCoversSamples(bareSweep, footprints) { + input := footprintClosedPolygons(footprints) + input = append(input, footprintStaticInteriorPolygons(footprints)...) + input = append(input, footprintClosedSweepPolygons(footprints)...) + input = append(input, bareSweep...) + if merged, err := geodata.UnionPolygons(input); err == nil { + return merged, nil + } + } + paired := PairedBoundaryPolygons(northern, southern) + if len(paired) > 0 { + if merged, err := geodata.UnionPolygons(paired); err == nil { + return merged, nil + } + } + legacy := footprintPolygons(footprints) + legacy = append(legacy, footprintStaticInteriorPolygons(footprints)...) + legacy = append(legacy, footprintClosedSweepPolygons(footprints)...) + legacy = append(legacy, paired...) + if len(legacy) > 0 { + if merged, err := geodata.UnionPolygons(legacy); err == nil { + return merged, nil + } + } + return nil, original +} + +func closedOccultationSweepFaces(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + result := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + open := openFootprintRing(polygon) + if len(open) < 3 || math.Abs(geoRingArea(open)) <= 1e-12 { + continue + } + closed := append([]geodata.GeoPoint(nil), open...) + closed = append(closed, open[0]) + result = append(result, closed) + } + return result +} + +func cleanupFootprintSweepPolygons( + polygons [][]geodata.GeoPoint, + northern, southern []basic.OccultationPathPoint, +) [][]geodata.GeoPoint { + if len(northern) > 0 || len(southern) > 0 { + polygons = RemoveTinyPolygonComponents(polygons) + } + for index := range polygons { + polygons[index] = removeOccultationHairpins(polygons[index], 35, 25, 12) + polygons[index] = removeOccultationHairpins(polygons[index], 100, 25, 32) + polygons[index] = removeOccultationSharpCorners(polygons[index], 20, 30) + } + return polygons +} + +// VisibleBandPolygons 返回瞬时可见接触区域的连续时间并集。 +// VisibleBandPolygons returns the union of the instantaneous visible +// footprints when samples are available. That union is the geographic area +// where the occultation occurs at any time while the Moon is above the local +// horizon; rise/set phase curves are diagnostic/display boundaries, not the +// outer edge of this time-union. The contour/linework construction remains a +// fallback for callers that do not provide footprints. +func VisibleBandPolygons( + footprints []basic.OccultationFootprint, + northern, southern []basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool, error) { + polygons, authoritative, err := visibleBandPolygons(footprints, northern, southern, nil, curves, false) + return normalizeOccultationBandOutput(polygons), authoritative, err +} + +// VisibleTotalBandPolygons 是 VisibleBandPolygons 的全掩带变体。 +// VisibleTotalBandPolygons is the total-occultation variant of +// VisibleBandPolygons. Inner-contact total bands can retain compact numerical +// polar returns after linework polygonization, so they enable the stronger +// smoothing pass that would be too aggressive for partial-band slivers. +func VisibleTotalBandPolygons( + footprints []basic.OccultationFootprint, + northern, southern []basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool, error) { + polygons, authoritative, err := visibleBandPolygons(footprints, northern, southern, nil, curves, true) + if authoritative { + polygons = roundOccultationTotalBandJunctions(polygons) + } + return normalizeOccultationBandOutput(polygons), authoritative, err +} + +// VisibleBandPolygonsFromContours 使用与 VisibleBandPolygons 相同的时间并集语义,并保留连续接触包络。 +// VisibleBandPolygonsFromContours uses the same time-union semantics as +// VisibleBandPolygons. Continuous contact envelopes and rise/set curves remain +// available as fallback/diagnostic geometry, while supplied footprints define +// the static visible area. +func VisibleBandPolygonsFromContours( + footprints []basic.OccultationFootprint, + contours [][]basic.OccultationPathPoint, + northern, southern []basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool, error) { + polygons, authoritative, err := visibleBandPolygons(footprints, northern, southern, contours, curves, false) + return normalizeOccultationBandOutput(polygons), authoritative, err +} + +// VisibleTotalBandPolygonsFromContours 是基于内接触轮廓的全掩带变体。 +// VisibleTotalBandPolygonsFromContours is the inner-contact total-band variant +// of VisibleBandPolygonsFromContours. +func VisibleTotalBandPolygonsFromContours( + footprints []basic.OccultationFootprint, + contours [][]basic.OccultationPathPoint, + northern, southern []basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool, error) { + polygons, authoritative, err := visibleBandPolygons(footprints, northern, southern, contours, curves, true) + if authoritative { + polygons = roundOccultationTotalBandJunctions(polygons) + } + return normalizeOccultationBandOutput(polygons), authoritative, err +} + +// VisibleBandPolygonsFromAnalyticContours 从连续解析接触包络构造静态可见集。 +// VisibleBandPolygonsFromAnalyticContours constructs the static visible set +// from the complete analytic boundary network. Contact contours bound the time +// union of F<=0, visibility contours bound the time union of H>=0, and the +// start/end rise-set curves are their F=0,H=0 transitions. Footprints and limit +// strips select the covered faces only; none of their edges can enter the +// returned boundary. +func VisibleBandPolygonsFromAnalyticContours( + footprints []basic.OccultationFootprint, + contactContours, visibilityContours [][]basic.OccultationPathPoint, + northern, southern []basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool, error) { + return visibleBandPolygonsFromAnalyticContours( + footprints, contactContours, visibilityContours, northern, southern, curves, false, + ) +} + +// VisibleStarBandPolygonsFromAnalyticContours 是点光源恒星掩带的解析轮廓构造入口。 +// VisibleStarBandPolygonsFromAnalyticContours is the point-source stellar +// variant. Stellar start/end contacts can require a short temporal lunar- +// horizon connector and stricter graph snapping than finite-disk contacts. +func VisibleStarBandPolygonsFromAnalyticContours( + footprints []basic.OccultationFootprint, + contactContours, visibilityContours [][]basic.OccultationPathPoint, + northern, southern []basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool, error) { + return visibleBandPolygonsFromAnalyticContours( + footprints, contactContours, visibilityContours, northern, southern, curves, true, + ) +} + +// VisibleTotalBandPolygonsFromAnalyticContours 是解析可见集的内接触全掩带变体。 +// VisibleTotalBandPolygonsFromAnalyticContours is the inner-contact variant of +// VisibleBandPolygonsFromAnalyticContours. +func VisibleTotalBandPolygonsFromAnalyticContours( + footprints []basic.OccultationFootprint, + contactContours, visibilityContours [][]basic.OccultationPathPoint, + northern, southern []basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool, error) { + return visibleBandPolygonsFromAnalyticContours( + footprints, contactContours, visibilityContours, northern, southern, curves, false, + ) +} + +func visibleBandPolygonsFromAnalyticContours( + footprints []basic.OccultationFootprint, + contactContours, visibilityContours [][]basic.OccultationPathPoint, + northern, southern []basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, + pointSource bool, +) ([][]geodata.GeoPoint, bool, error) { + // The analytic boundary is preferred because it preserves the continuous + // contact envelope. Some grazing/polar tracks still produce a valid set of + // instantaneous visible footprints while their phase graph has no accepted + // closed face. Keep that physical time-union as a bounded fallback instead + // of turning a real event into a serialization error. + fallbackVisible := func() ([][]geodata.GeoPoint, bool) { + if len(footprints) == 0 { + return nil, false + } + fallback, fallbackErr := footprintSweepPolygons(footprints, northern, southern) + if fallbackErr != nil || len(fallback) == 0 { + return nil, false + } + fallback = normalizeOccultationBandOutput(fallback) + fallback = densifyOccultationPolygons(fallback, 30) + return fallback, len(fallback) > 0 + } + if len(curves) == 0 && len(footprints) > 0 { + // Analytic contact contours alone do not form a closed visible boundary + // when rise/set computation is disabled. Combine the open sweep with only + // closed instantaneous footprints; a full sparse union is both expensive + // and unnecessary for this compatibility path. + if fallback, fallbackErr := footprintSweepPolygons(footprints, northern, southern); fallbackErr == nil { + closed := footprintClosedPolygons(footprints) + input := append(append([][]geodata.GeoPoint(nil), fallback...), closed...) + if merged, mergeErr := geodata.UnionPolygons(input); mergeErr == nil && len(merged) > 0 { + return cleanupFootprintSweepPolygons(merged, northern, southern), false, nil + } + } + } + contactLines := occultationContactContourBoundaryLines(contactContours) + if len(contactLines) == 0 { + return nil, false, fmt.Errorf("analytic occultation boundary has no contact temporal envelope") + } + boundaryCurves := occultationStaticBandCurves(curves) + lineworkCurves := boundaryCurves + if pointSource && len(visibilityContours) == 0 { + // Without a separate H=0 temporal envelope, a greatest-rise/set arc can + // become part of the outer visible-set boundary in a short horizon wedge. + // Include all six phase curves in the face graph; merging selected faces + // removes any portions that are truly internal. + lineworkCurves = curves + } + connectors := HorizonConnectorSegments(footprints, boundaryCurves, northern, southern) + if pointSource { + connectors = StarHorizonConnectorSegments(footprints, boundaryCurves, northern, southern) + } + connectorLines := occultationHorizonConnectorBoundaryLines(connectors) + boundaryLines := occultationVisibleBoundaryLinesFromBase( + contactLines, lineworkCurves, + occultationContactContourBoundaryLines(visibilityContours), + ) + boundaryLines = append(boundaryLines, connectorLines...) + var footprintFill [][]geodata.GeoPoint + fillReady := false + getFootprintFill := func() [][]geodata.GeoPoint { + if !fillReady { + footprintFill = occultationVisibleFootprintFillOnly(footprints) + fillReady = true + } + return footprintFill + } + var selectionFill [][]geodata.GeoPoint + if pointSource || len(visibilityContours) == 0 { + // Geocentric limit strips can extend beyond the station-corrected + // stellar envelope or omit a finite-disk horizon extremum. Use visible + // footprints as witnesses; the analytic linework supplies every edge. + selectionFill = getFootprintFill() + } + if len(selectionFill) == 0 { + selectionFill = occultationLimitVisibleFillPolygons(northern, southern) + } + if len(selectionFill) == 0 { + selectionFill = getFootprintFill() + } + if len(selectionFill) == 0 { + return nil, false, fmt.Errorf("analytic occultation boundary has no interior selection fill") + } + lineworkToleranceKM := 2.0 + if !pointSource && len(visibilityContours) == 0 { + // Finite-disk compatibility: without a separate H=0 envelope, sparse + // contact/limit samples need the established one-edge graph tolerance. + lineworkToleranceKM = 40 + } + phaseLines := occultationRiseSetBoundaryLines(curves) + polygonize := func(coverage [][]geodata.GeoPoint) ([][]geodata.GeoPoint, error) { + if pointSource { + return geodata.VisibleLineworkPolygonsWithAuditTolerance( + boundaryLines, selectionFill, coverage, lineworkToleranceKM, 30, + ) + } + return geodata.VisibleLineworkPolygons( + boundaryLines, selectionFill, coverage, lineworkToleranceKM, + ) + } + var initialCoverage [][]geodata.GeoPoint + if pointSource && len(visibilityContours) == 0 { + initialCoverage = phaseLines + } + polygons, err := polygonize(initialCoverage) + if err != nil && len(phaseLines) > 0 { + // A multi-branch rise/set network can contain several closed faces with + // identical physical junctions. If fill-only selection chooses the + // adjacent face, require every exported phase line as a coverage witness + // and retry without changing the boundary network or snap tolerance. + polygons, err = polygonize(phaseLines) + } + if err != nil { + if fallback, ok := fallbackVisible(); ok { + return fallback, false, nil + } + return nil, false, fmt.Errorf("analytic occultation boundary: %w", err) + } + polygons = normalizeOccultationBandOutput(polygons) + if pointSource { + polygons = RemoveTinyPolygonComponents(polygons) + } + polygons = densifyOccultationPolygons(polygons, 30) + if len(polygons) == 0 { + if fallback, ok := fallbackVisible(); ok { + return fallback, false, nil + } + return nil, false, fmt.Errorf("analytic occultation boundary produced no polygon") + } + if len(phaseLines) > 0 && !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 2) { + // A coarse limit strip reliably identifies the main face, but it need not + // reach a narrow face that terminates where a rise/set phase meets the + // contact or visibility envelope. Use instantaneous footprints only to + // decide which side of each phase curve is physically inside, then repeat + // face selection against the unchanged analytic boundary network. + phaseCoverage := occultationCurveCoverageProbes(curves, getFootprintFill()) + if len(phaseCoverage) > 0 { + // 相位重试必须与首遍走同一个 polygonize 闭包:单独硬编码节点吸附容差会让 + // 恰好依赖有限圆盘 40 km 吸附的场景静默退回 footprint-sweep。 + selected, selectionErr := polygonize(phaseCoverage) + if selectionErr != nil { + if fallback, ok := fallbackVisible(); ok { + return fallback, false, nil + } + return nil, false, fmt.Errorf("analytic occultation boundary phase selection: %w", selectionErr) + } + selected = normalizeOccultationBandOutput(selected) + if pointSource { + selected = RemoveTinyPolygonComponents(selected) + } + selected = densifyOccultationPolygons(selected, 30) + if len(selected) > 0 { + polygons = selected + } + } + } + if len(phaseLines) > 0 && !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 2) { + miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false) + // When no visibility contour exists, the event is visible throughout the + // contact envelope and there is no H=0 transition to close. A small phase + // residual can remain where the sampled contact envelope meets a rise/set + // branch; keep the analytic face if that residual is below one rendered + // edge. Events with visibility contours retain the strict 2 km invariant. + if !pointSource && len(visibilityContours) == 0 && miss <= 40 { + return polygons, true, nil + } + if fallback, ok := fallbackVisible(); ok { + return fallback, false, nil + } + return nil, false, fmt.Errorf( + "analytic occultation boundary misses a rise/set phase by %.1f km", + miss, + ) + } + return polygons, true, nil +} + +func occultationRiseSetBoundaryLines( + curves []basic.OccultationRiseSetCurve, +) [][]geodata.GeoPoint { + lines := make([][]geodata.GeoPoint, 0, len(curves)*2) + for _, curve := range curves { + lines = append(lines, occultationCurveBoundaryLines(curve)...) + } + return lines +} + +func normalizeOccultationBandOutput(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + if len(polygons) == 0 { + return nil + } + result := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + open := openFootprintRing(polygon) + if len(open) < 3 || math.Abs(geoRingArea(open)) <= 1e-12 { + continue + } + result = append(result, polygon) + } + return result +} diff --git a/internal/occultationgeo/continuity_regression_test.go b/internal/occultationgeo/continuity_regression_test.go new file mode 100644 index 0000000..aa8487d --- /dev/null +++ b/internal/occultationgeo/continuity_regression_test.go @@ -0,0 +1,59 @@ +package occultationgeo + +import ( + "testing" + "time" + + "b612.me/astro/internal/geodata" + "b612.me/astro/moon" +) + +func TestMars20250729TotalBandAvoidsDetachedSliver(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationMars, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil { + t.Fatalf("FindPlanetOccultationPaths: %v", err) + } + if len(paths) != 1 { + t.Fatalf("paths=%d", len(paths)) + } + path := paths[0] + polygons, authoritative, err := VisibleTotalBandPolygonsFromContours( + path.TotalBandFootprints, path.TotalBandContours, + path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves, + ) + if err != nil { + t.Fatalf("VisibleTotalBandPolygonsFromContours: %v", err) + } + if !authoritative { + t.Fatal("total band should be authoritative for contour-driven Mars path") + } + if len(polygons) != 1 { + t.Fatalf("total-band polygons=%d, want one merged face", len(polygons)) + } + for _, test := range []struct { + name string + probe geodata.GeoPoint + inside bool + }{ + // This point was previously rejected by the phase-cycle envelope, but it + // lies inside the horizon-closed total footprint during the event. The + // static band now represents the full time union, so it must remain. + {name: "visible-sweep", probe: geodata.GeoPoint{Longitude: -129.8230, Latitude: -77.4030}, inside: true}, + {name: "selected-west", probe: geodata.GeoPoint{Longitude: -134.6060, Latitude: -78.5601}, inside: true}, + {name: "selected-center", probe: geodata.GeoPoint{Longitude: -134.2300, Latitude: -78.0725}, inside: true}, + } { + inside := geodata.SphericalPolygonsContainPoints(polygons, []geodata.GeoPoint{test.probe})[0] + if inside != test.inside { + t.Fatalf("merged total-band probe %s (%.4f, %.4f) inside=%v, want %v", test.name, test.probe.Longitude, test.probe.Latitude, inside, test.inside) + } + } +} diff --git a/internal/occultationgeo/continuity_test.go b/internal/occultationgeo/continuity_test.go new file mode 100644 index 0000000..9ff02f4 --- /dev/null +++ b/internal/occultationgeo/continuity_test.go @@ -0,0 +1,1331 @@ +package occultationgeo + +import ( + "math" + "reflect" + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +func TestOccultationVisibleFootprintFillOnlyMatchesFullFill(t *testing.T) { + visible := []basic.OccultationPathPoint{ + {Longitude: 0, Latitude: 0, MoonAltitude: -1}, + {Longitude: 2, Latitude: 0, MoonAltitude: 1}, + {Longitude: 2, Latitude: 2, MoonAltitude: 1}, + {Longitude: 0, Latitude: 2, MoonAltitude: -1}, + } + interior := []basic.OccultationPathPoint{ + {Longitude: 1.2, Latitude: 0.5, MoonAltitude: 1}, + {Longitude: 1.8, Latitude: 0.5, MoonAltitude: 1}, + {Longitude: 1.5, Latitude: 1.5, MoonAltitude: 1}, + } + polar := []basic.OccultationPathPoint{ + {Longitude: 0, Latitude: 80, MoonAltitude: 1}, + {Longitude: 120, Latitude: 80, MoonAltitude: 1}, + {Longitude: -120, Latitude: 80, MoonAltitude: 1}, + } + for _, footprints := range [][]basic.OccultationFootprint{ + nil, + {{Polygons: [][]basic.OccultationPathPoint{visible, interior, visible[:2]}, InteriorPolygons: [][]basic.OccultationPathPoint{interior}}}, + {{Polygons: [][]basic.OccultationPathPoint{polar}}}, + } { + want, _ := occultationVisibleFillAndCoverage(footprints) + if got := occultationVisibleFootprintFillOnly(footprints); !reflect.DeepEqual(got, want) { + t.Fatalf("fill-only changed the horizon-clipped source: got=%v want=%v", got, want) + } + } +} + +func TestContinuousBoundaryRangesRetainsEndpointSingletons(t *testing.T) { + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + points := []basic.OccultationPathPoint{ + {Time: start, Longitude: 0, Latitude: 10}, + {Time: start.Add(time.Second), Longitude: 30, Latitude: 10}, + {Time: start.Add(2 * time.Second), Longitude: 30.1, Latitude: 10}, + {Time: start.Add(3 * time.Second), Longitude: 0, Latitude: 10}, + } + ranges := ContinuousBoundaryRanges(points) + want := []SampleRange{{Start: 0, End: 1}, {Start: 1, End: 3}, {Start: 3, End: 4}} + if len(ranges) != len(want) { + t.Fatalf("range count = %d, want %d: %#v", len(ranges), len(want), ranges) + } + for index := range want { + if ranges[index] != want[index] { + t.Fatalf("range %d = %#v, want %#v", index, ranges[index], want[index]) + } + } +} + +func TestStitchedRiseSetCurveSegmentsJoinsSharedFoldEndpoint(t *testing.T) { + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC) + fold := basic.OccultationPathPoint{Time: start.Add(2 * time.Minute), Longitude: 1, Latitude: 1} + curve := basic.OccultationRiseSetCurve{ + Phase: basic.RiseSetPhaseEnd, + Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{ + { + {Time: start, Longitude: 0, Latitude: 0}, + fold, + }, + { + {Time: start.Add(time.Minute), Longitude: 0, Latitude: 2}, + fold, + }, + }, + } + segments := StitchedRiseSetCurveSegments(curve) + if len(segments) != 1 { + t.Fatalf("stitched segment count=%d, want one continuous display segment", len(segments)) + } + line := segments[0] + if len(line) != 3 { + t.Fatalf("stitched point count=%d, want the shared fold once", len(line)) + } + if line[1] != fold { + t.Fatalf("middle point=%+v, want shared fold %+v", line[1], fold) + } +} + +func TestDistanceKMUsesShortestAntimeridianArc(t *testing.T) { + first := basic.OccultationPathPoint{Longitude: 179.9} + second := basic.OccultationPathPoint{Longitude: -179.9} + if distance := DistanceKM(first, second); distance > 25 { + t.Fatalf("antimeridian distance = %.1f km, want shortest arc", distance) + } +} + +func TestValidateRiseSetCurvesRejectsPolarSubsecondBranchJump(t *testing.T) { + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC) + curve := basic.OccultationRiseSetCurve{ + Phase: basic.RiseSetPhaseStart, + Direction: basic.RiseSetDirectionSet, + Segments: [][]basic.OccultationPathPoint{{ + {Time: start, Longitude: -12.591916, Latitude: 81.864617}, + {Time: start.Add(40 * time.Millisecond), Longitude: 22.206242, Latitude: 79.125287}, + }}, + } + if err := ValidateRiseSetCurves([]basic.OccultationRiseSetCurve{curve}, start, start.Add(time.Hour)); err == nil { + t.Fatal("ValidateRiseSetCurves accepted a polar subsecond branch jump") + } +} + +func TestPairedBoundaryPolygonsDoNotBridgeBranchChanges(t *testing.T) { + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + first := []basic.OccultationPathPoint{ + {Time: start, Longitude: 0, Latitude: 10}, + {Time: start.Add(time.Minute), Longitude: 1, Latitude: 10}, + {Time: start.Add(2 * time.Minute), Longitude: 90, Latitude: 10}, + {Time: start.Add(3 * time.Minute), Longitude: 91, Latitude: 10}, + } + second := []basic.OccultationPathPoint{ + {Time: start, Longitude: 0, Latitude: -10}, + {Time: start.Add(time.Minute), Longitude: 1, Latitude: -10}, + {Time: start.Add(2 * time.Minute), Longitude: 90, Latitude: -10}, + {Time: start.Add(3 * time.Minute), Longitude: 91, Latitude: -10}, + } + polygons := PairedBoundaryPolygons(first, second) + if len(polygons) != 2 { + t.Fatalf("polygon count = %d, want two cells separated at the branch change", len(polygons)) + } + if polygons[0][0].Longitude != 0 || polygons[0][1].Longitude != 1 || + polygons[1][0].Longitude != 90 || polygons[1][1].Longitude != 91 { + t.Fatalf("unexpected paired cells: %#v", polygons) + } +} + +func TestPairedBoundaryPolygonsSuppressOneSidedBranchInterval(t *testing.T) { + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + first := []basic.OccultationPathPoint{ + {Time: start, Longitude: 0, Latitude: 10}, + {Time: start.Add(time.Minute), Longitude: 1, Latitude: 10}, + {Time: start.Add(2 * time.Minute), Longitude: 2, Latitude: 10}, + {Time: start.Add(3 * time.Minute), Longitude: 90, Latitude: 10}, + {Time: start.Add(4 * time.Minute), Longitude: 91, Latitude: 10}, + } + second := []basic.OccultationPathPoint{ + {Time: start, Longitude: 0, Latitude: -10}, + {Time: start.Add(time.Minute), Longitude: 1, Latitude: -10}, + {Time: start.Add(2 * time.Minute), Longitude: 90, Latitude: -10}, + {Time: start.Add(3 * time.Minute), Longitude: 91, Latitude: -10}, + {Time: start.Add(4 * time.Minute), Longitude: 92, Latitude: -10}, + } + polygons := PairedBoundaryPolygons(first, second) + if len(polygons) != 2 { + t.Fatalf("polygon count = %d, want two valid cells around the one-sided branch interval", len(polygons)) + } + if polygons[0][0].Longitude != 0 || polygons[0][1].Longitude != 1 || + polygons[1][0].Longitude != 90 || polygons[1][1].Longitude != 91 { + t.Fatalf("unexpected polygons: %#v", polygons) + } +} + +func TestRemoveTinyPolygonComponentsKeepsComparableBranches(t *testing.T) { + polygons := [][]geodata.GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 10, Latitude: 0}, {Longitude: 10, Latitude: 10}, {Longitude: 0, Latitude: 10}}, + {{Longitude: 20, Latitude: 0}, {Longitude: 30, Latitude: 0}, {Longitude: 30, Latitude: 10}, {Longitude: 20, Latitude: 10}}, + {{Longitude: 179.99, Latitude: 0}, {Longitude: -179.99, Latitude: 0}, {Longitude: -179.99, Latitude: 1}, {Longitude: 179.99, Latitude: 1}}, + {{Longitude: 0, Latitude: 0}, {Longitude: 0.01, Latitude: 0}, {Longitude: 0.01, Latitude: 0.01}}, + } + filtered := RemoveTinyPolygonComponents(polygons) + if len(filtered) != 3 { + t.Fatalf("filtered component count = %d, want two large and one dateline component", len(filtered)) + } +} + +func TestFootprintTransitionBoundaryHandlesCoincidentNearestSample(t *testing.T) { + when := time.Date(2025, time.January, 5, 0, 12, 17, 0, time.UTC) + closed := basic.OccultationFootprint{ + Time: when, + Closed: true, + Boundaries: [][]basic.OccultationPathPoint{{ + {Longitude: -86.16, Latitude: 30.50}, + {Longitude: -86.10, Latitude: 30.56}, + {Longitude: -86.04, Latitude: 30.50}, + {Longitude: -86.10, Latitude: 30.44}, + }}, + } + open := basic.OccultationFootprint{ + Time: when.Add(-50 * time.Millisecond), + Boundaries: [][]basic.OccultationPathPoint{{ + {Longitude: -86.12, Latitude: 30.51}, + {Longitude: -86.10, Latitude: 30.56}, + {Longitude: -86.04, Latitude: 30.50}, + {Longitude: -86.10, Latitude: 30.44}, + {Longitude: -86.12, Latitude: 30.49}, + }}, + } + boundary, ok := footprintTransitionBoundary(closed, open) + if !ok { + t.Fatal("coincident nearest sample did not produce a transition boundary") + } + if len(boundary) < len(closed.Boundaries[0])-1 { + t.Fatalf("transition boundary points=%d, want the long physical arc", len(boundary)) + } +} + +func TestRemoveOccultationHairpinsKeepsMainOutline(t *testing.T) { + points := []geodata.GeoPoint{ + {Longitude: -86.3, Latitude: 31.0}, + {Longitude: -86.16, Latitude: 30.61}, + {Longitude: -85.97, Latitude: 30.55}, + {Longitude: -85.74, Latitude: 30.24}, + {Longitude: -86.07, Latitude: 30.43}, + {Longitude: -86.18, Latitude: 30.47}, + {Longitude: -86.4, Latitude: 29.1}, + } + cleaned := removeOccultationHairpins(points, 35, 25, 12) + if len(cleaned) != 4 { + t.Fatalf("cleaned point count=%d, want the main outline and hairpin join: %#v", len(cleaned), cleaned) + } + if cleaned[0] != points[0] || cleaned[len(cleaned)-1] != points[len(points)-1] { + t.Fatalf("hairpin cleanup changed main outline endpoints: %#v", cleaned) + } +} + +func TestSmoothOccultationHairpinsKeepsShortDisplayEdges(t *testing.T) { + points := []geodata.GeoPoint{ + {Longitude: -137.238657, Latitude: -75.841965}, + {Longitude: -135.756780, Latitude: -76.038909}, + {Longitude: -134.234342, Latitude: -76.226631}, + {Longitude: -133.715901, Latitude: -76.650415}, + {Longitude: -133.164148, Latitude: -77.073078}, + {Longitude: -134.361125, Latitude: -77.215854}, + } + smoothed := smoothOccultationHairpins(points, 180, 25, 16, 50) + maximumEdge := 0.0 + maximumDetour := 0.0 + for index := 1; index < len(smoothed); index++ { + maximumEdge = math.Max(maximumEdge, geoDistanceKM(smoothed[index-1], smoothed[index])) + } + for index := 1; index+1 < len(smoothed); index++ { + maximumDetour = math.Max(maximumDetour, + geoDistanceKM(smoothed[index-1], smoothed[index])+ + geoDistanceKM(smoothed[index], smoothed[index+1])- + geoDistanceKM(smoothed[index-1], smoothed[index+1])) + } + if maximumEdge > 50.01 { + t.Fatalf("smoothed maximum edge=%.1f km, want <=50 km", maximumEdge) + } + if maximumDetour > 25 { + t.Fatalf("smoothed maximum local detour=%.1f km, want <=25 km", maximumDetour) + } +} + +func TestSmoothOccultationPolarWobblesRemovesShortLatitudeSeam(t *testing.T) { + points := []geodata.GeoPoint{ + {Longitude: 10.19691, Latitude: 80.96484}, + {Longitude: 9.18809, Latitude: 81.05666}, + {Longitude: 8.61181, Latitude: 80.98198}, + {Longitude: 7.76426, Latitude: 81.01962}, + } + smoothed := smoothOccultationPolarWobbles(points) + if len(smoothed) != len(points) || smoothed[0] != points[0] || smoothed[len(smoothed)-1] != points[len(points)-1] { + t.Fatalf("polar wobble smoothing changed endpoints or point count: %#v", smoothed) + } + for index := 1; index+1 < len(smoothed); index++ { + firstDelta := math.Remainder(smoothed[index].Longitude-smoothed[index-1].Longitude, 360) + secondDelta := math.Remainder(smoothed[index+1].Longitude-smoothed[index].Longitude, 360) + if firstDelta*secondDelta <= 0 { + t.Fatalf("polar seam retains longitude reversal at %d: %#v", index, smoothed) + } + firstLatitudeDelta := smoothed[index].Latitude - smoothed[index-1].Latitude + secondLatitudeDelta := smoothed[index+1].Latitude - smoothed[index].Latitude + if firstLatitudeDelta*secondLatitudeDelta < 0 { + t.Fatalf("polar seam retains latitude reversal at %d: %#v", index, smoothed) + } + } +} + +func TestSmoothOccultationOrdinaryWobblesHandlesHighLatitudeArc(t *testing.T) { + points := []geodata.GeoPoint{ + {Longitude: -30.0, Latitude: 80.000}, + {Longitude: -29.8, Latitude: 80.020}, + {Longitude: -29.6, Latitude: 80.011}, + {Longitude: -29.4, Latitude: 80.031}, + {Longitude: -29.2, Latitude: 80.021}, + {Longitude: -29.0, Latitude: 80.041}, + {Longitude: -28.8, Latitude: 80.032}, + {Longitude: -28.6, Latitude: 80.052}, + {Longitude: -28.4, Latitude: 80.043}, + } + smoothed := smoothOccultationOrdinaryWobbles(points) + if len(smoothed) != len(points) { + t.Fatalf("high-latitude smoothing changed point count: got %d want %d", len(smoothed), len(points)) + } + if smoothed[0] != points[0] || smoothed[len(smoothed)-1] != points[len(points)-1] { + t.Fatal("high-latitude smoothing changed arc endpoints") + } + changed := false + for index := 1; index+1 < len(points); index++ { + if geoDistanceKM(points[index], smoothed[index]) > 0.001 { + changed = true + if geoDistanceKM(points[index], smoothed[index]) > 20 { + t.Fatalf("high-latitude smoothing moved point %d by %.2f km", index, geoDistanceKM(points[index], smoothed[index])) + } + } + } + if !changed { + t.Fatal("high-latitude smoothing did not remove the synthetic local wobble") + } +} + +func TestDensifyOccultationPolygonsIncludesClosingEdge(t *testing.T) { + points := []geodata.GeoPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 1, Latitude: 0}, + {Longitude: 1, Latitude: 1}, + {Longitude: 0, Latitude: 1}, + } + densified := densifyOccultationPolygons([][]geodata.GeoPoint{points}, 50) + ring := densified[0] + maximumEdge := geoDistanceKM(ring[len(ring)-1], ring[0]) + if maximumEdge > 50.01 { + t.Fatalf("closing edge=%.1f km, want <=50 km", maximumEdge) + } +} + +func TestVisibleBandPolygons20250105SaturnExcludesInvisiblePolarCap(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationSaturn, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + t.Logf("total footprints=%d contours=%d northern=%d southern=%d curves=%d", len(path.TotalBandFootprints), len(path.TotalBandContours), len(path.NorthernTotalLimit), len(path.SouthernTotalLimit), len(path.TotalRiseSetCurves)) + for index, contour := range path.TotalBandContours { + if len(contour) == 0 { + continue + } + t.Logf("contour %d len=%d first=(%.4f,%.4f) last=(%.4f,%.4f)", index, len(contour), contour[0].Longitude, contour[0].Latitude, contour[len(contour)-1].Longitude, contour[len(contour)-1].Latitude) + } + visibleSite := []geodata.GeoPoint{{Longitude: -20, Latitude: 81.95}} + visibleFootprints := 0 + for _, footprint := range path.PartialBandFootprints { + for _, source := range footprint.Polygons { + polygon := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + if geodata.SphericalPolygonsContainPaths([][]geodata.GeoPoint{polygon}, [][]geodata.GeoPoint{visibleSite}, false) { + visibleFootprints++ + } + } + } + if visibleFootprints == 0 { + t.Fatal("visible polar site is absent from every instantaneous footprint") + } + polygons, authoritative, err := VisibleBandPolygons( + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil { + t.Fatalf("VisibleBandPolygons: %v", err) + } + if !authoritative { + t.Fatal("VisibleBandPolygons fell back to the footprint sweep") + } + if geodata.SphericalPolygonsContainPaths(polygons, [][]geodata.GeoPoint{{ + {Longitude: -20, Latitude: 82.10}, + }}, false) { + t.Fatal("visible band contains the polar site whose occultation remains below the lunar horizon") + } + for _, site := range []geodata.GeoPoint{ + {Longitude: -20, Latitude: 81.95}, + {Longitude: -20, Latitude: 81.70}, + } { + if !geodata.SphericalPolygonsContainPaths(polygons, [][]geodata.GeoPoint{{site}}, false) { + t.Fatalf("visible band excludes the site %.2f, %.2f whose occultation is above the lunar horizon", site.Longitude, site.Latitude) + } + } +} + +func TestVisibleBandPolygons20250105SaturnEnvelopeCandidates(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationSaturn, + basic.OccultationPathOptions{ + // Keep this legacy fallback regression on its original exact input. + // The current analytic output is exercised for both branches below. + Algorithm: basic.OccultationPathAlgorithmExact, + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + final, _, err := VisibleBandPolygonsFromContours( + path.PartialBandFootprints, path.PartialBandContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil { + t.Fatalf("VisibleBandPolygonsFromContours: %v", err) + } + sweep, err := footprintSweepPolygons( + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, + ) + if err != nil { + t.Fatalf("footprintSweepPolygons: %v", err) + } + visibleUnion := footprintVisibleUnionPolygons(path.PartialBandFootprints) + direct, _ := DirectVisibleBandPolygons(path.PartialBandFootprints) + contourLines := occultationContactContourBoundaryLines(path.PartialBandContours) + phase, _ := occultationPreferredPhaseBand( + true, path.PartialBandFootprints, path.RiseSetCurves, + path.NorthernLimit, path.SouthernLimit, PairedBoundaryPolygons(path.NorthernLimit, path.SouthernLimit), + ) + for name, polygons := range map[string][][]geodata.GeoPoint{ + "final": final, "sweep": sweep, "visible-union": visibleUnion, "direct": direct, + "contours": contourLines, "phase": phase, + } { + t.Logf("%s polygons=%d points=%d roughness=%v", name, len(polygons), + occultationTestPolygonPointCount(polygons), occultationTestEnvelopeRoughness(polygons)) + } + // 首尾端帽由瞬时足迹的端帽/桥接构造而来,本就有折角,不属于普通纬度平滑的检查范围。 + // 接触弧端点(地平线切点)邻域同样按构造排除:可见带在那里转入月球地平线闭合弧, + // 度量到的是切点转折的采样弦高,不是普通纬度抖动。 + horizons := occultationTestFootprintHorizonPoints(path.PartialBandFootprints) + roughness := occultationTestEnvelopeRoughnessExcludingHorizon( + occultationTestTrimRingTail(final, occultationTestEnvelopeCapPoints), + horizons, occultationTestEnvelopeHorizonWindowKM, + ) + t.Logf("asserted final roughness=%v horizons=%d", roughness, len(horizons)) + if roughness["bottom"] > 6 || roughness["back"] > 10 { + t.Fatalf("smoothed Saturn ordinary-latitude envelope remains rough: %v", roughness) + } + if below, checked := occultationTestBelowHorizonVertices(path.PartialBandFootprints); below != 0 || checked < 1000 { + t.Fatalf("band footprints hold %d of %d vertices below the lunar horizon", below, checked) + } + for _, polygon := range final { + for index := 1; index < len(polygon); index++ { + if distance := geoDistanceKM(polygon[index-1], polygon[index]); distance > BoundaryBranchJumpKM { + t.Fatalf("band ring spans a %.1f km edge", distance) + } + } + } +} + +// occultationTestEnvelopeCapPoints 是平滑度断言从环尾剔除的端帽顶点数。 +const occultationTestEnvelopeCapPoints = 48 + +// occultationTestTrimRingTail 返回去掉环尾端帽窗口后的副本。 +func occultationTestTrimRingTail(polygons [][]geodata.GeoPoint, capPoints int) [][]geodata.GeoPoint { + trimmed := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + if len(polygon) <= 2*capPoints+4 { + continue + } + trimmed = append(trimmed, polygon[:len(polygon)-capPoints]) + } + return trimmed +} + +func TestVisibleBandPolygonsSaturnAnalyticAlgorithmBranches(t *testing.T) { + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600)) + for _, algorithm := range []basic.OccultationPathAlgorithm{basic.OccultationPathAlgorithmExact, basic.OccultationPathAlgorithmOptimized} { + t.Run(string(algorithm), func(t *testing.T) { + paths, err := basic.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), basic.OccultationSaturn, + basic.OccultationPathOptions{ + Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute, + }) + if err != nil || len(paths) != 1 { + t.Fatalf("paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.PartialVisibilityContours) == 0 { + t.Fatal("missing analytic visibility contours") + } + polygons, authoritative, err := VisibleBandPolygonsFromAnalyticContours( + path.PartialBandFootprints, path.PartialBandContours, path.PartialVisibilityContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil || !authoritative || len(polygons) != 1 { + t.Fatalf("analytic polygons=%d authoritative=%v err=%v", len(polygons), authoritative, err) + } + roughness := occultationTestEnvelopeRoughness(polygons) + if roughness["bottom"] > 6 || roughness["back"] > 10 { + t.Fatalf("analytic Saturn envelope remains rough: %v", roughness) + } + }) + } +} + +func TestSmoothOccultationOrdinaryWobblesPreservesEndpointsAndBoundedMotion(t *testing.T) { + ring := []geodata.GeoPoint{ + {Longitude: -82.0, Latitude: -4.0}, + {Longitude: -81.0, Latitude: -5.5}, + {Longitude: -80.0, Latitude: -7.0}, + {Longitude: -79.0, Latitude: -6.6}, + {Longitude: -78.0, Latitude: -7.5}, + {Longitude: -77.0, Latitude: -8.0}, + {Longitude: -76.0, Latitude: -7.8}, + {Longitude: -75.0, Latitude: -7.0}, + } + cleaned := smoothOccultationOrdinaryWobbles(ring) + if cleaned[0] != ring[0] || cleaned[len(cleaned)-1] != ring[len(ring)-1] { + t.Fatalf("ordinary smoothing changed open endpoints: %#v", cleaned) + } + for index, point := range cleaned { + if move := geoDistanceKM(point, ring[index]); move > 20.001 { + t.Fatalf("point %d moved %.2f km, want <=20 km", index, move) + } + } +} + +func occultationTestPolygonPointCount(polygons [][]geodata.GeoPoint) int { + count := 0 + for _, polygon := range polygons { + count += len(polygon) + } + return count +} + +func occultationTestEnvelopeRoughness(polygons [][]geodata.GeoPoint) map[string]float64 { + return occultationTestEnvelopeRoughnessExcludingHorizon(polygons, nil, 0) +} + +// occultationTestEnvelopeHorizonWindowKM 是粗糙度度量跳过地平线切点邻域的半径: +// 可见带在切点处从接触包络转入月球地平线闭合弧,那里的折角是构造边界而不是普通纬度抖动。 +const occultationTestEnvelopeHorizonWindowKM = 20.0 + +// occultationTestEnvelopeRoughnessExcludingHorizon 跳过起点或终点落在切点邻域内的五采样窗口。 +func occultationTestEnvelopeRoughnessExcludingHorizon( + polygons [][]geodata.GeoPoint, + horizons []geodata.GeoPoint, + windowKM float64, +) map[string]float64 { + result := map[string]float64{"bottom": 0, "back": 0, "polar": 0} + for _, polygon := range polygons { + for index := 2; index+2 < len(polygon); index++ { + point := polygon[index] + region := "" + switch { + case point.Latitude < 5: + region = "bottom" + case point.Longitude < -75 && point.Latitude >= 25 && point.Latitude <= 45: + region = "back" + case point.Latitude > 75: + region = "polar" + default: + continue + } + if occultationTestWindowTouchesHorizon(polygon, index, horizons, windowKM) { + continue + } + deviation := occultationTestProjectedPointLineDistanceKM( + point, polygon[index-2], polygon[index+2], + ) + result[region] = math.Max(result[region], deviation) + } + } + return result +} + +func occultationTestWindowTouchesHorizon( + polygon []geodata.GeoPoint, + index int, + horizons []geodata.GeoPoint, + windowKM float64, +) bool { + if windowKM <= 0 || len(horizons) == 0 { + return false + } + for offset := -2; offset <= 2; offset++ { + for _, horizon := range horizons { + if geoDistanceKM(polygon[index+offset], horizon) <= windowKM { + return true + } + } + } + return false +} + +// occultationTestFootprintHorizonPoints 返回接触弧两端的地平线切点,作为"地平线切点邻域"的构造锚点。 +func occultationTestFootprintHorizonPoints(footprints []basic.OccultationFootprint) []geodata.GeoPoint { + points := make([]geodata.GeoPoint, 0, len(footprints)*2) + for _, footprint := range footprints { + if footprint.Closed { + continue + } + for _, boundary := range footprint.Boundaries { + if len(boundary) == 0 { + continue + } + for _, index := range []int{0, len(boundary) - 1} { + points = append(points, geodata.GeoPoint{ + Longitude: boundary[index].Longitude, + Latitude: boundary[index].Latitude, + }) + } + } + } + return points +} + +// occultationTestBelowHorizonVertices 统计足迹边界、闭合多边形与修复面中位于月球地平线以下的顶点数。 +func occultationTestBelowHorizonVertices(footprints []basic.OccultationFootprint) (below, checked int) { + for _, footprint := range footprints { + for _, rings := range [][][]basic.OccultationPathPoint{ + footprint.Boundaries, footprint.Polygons, footprint.InteriorPolygons, + } { + for _, ring := range rings { + for _, point := range ring { + checked++ + if point.MoonAltitude < 0 { + below++ + } + } + } + } + } + return below, checked +} + +func occultationTestProjectedPointLineDistanceKM(point, first, last geodata.GeoPoint) float64 { + project := func(value geodata.GeoPoint) (float64, float64) { + const radiusKM = 6378.1366 + latitude := math.Max(-85.05112878, math.Min(85.05112878, value.Latitude)) * math.Pi / 180 + return radiusKM * value.Longitude * math.Pi / 180, + radiusKM * math.Log(math.Tan(math.Pi/4+latitude/2)) + } + px, py := project(point) + ax, ay := project(first) + bx, by := project(last) + dx, dy := bx-ax, by-ay + if lengthSquared := dx*dx + dy*dy; lengthSquared > 0 { + fraction := ((px-ax)*dx + (py-ay)*dy) / lengthSquared + fraction = math.Max(0, math.Min(1, fraction)) + return math.Hypot(px-(ax+fraction*dx), py-(ay+fraction*dy)) + } + return math.Hypot(px-ax, py-ay) +} + +func TestVisibleBandPolygons20250729MarsStitchesEndRiseFold(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationMars, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: 5 * time.Minute, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.CenterLine) != 0 { + t.Fatalf("center-line points=%d, want non-central path", len(path.CenterLine)) + } + if len(path.RiseSetCurves) != 3 { + t.Fatalf("rise/set curve count=%d, want three rising phase curves", len(path.RiseSetCurves)) + } + endCurve := path.RiseSetCurves[2] + if len(endCurve.Segments) < 2 { + t.Fatalf("end-rise segment count=%d, want the polar fold branches", len(endCurve.Segments)) + } + polygons, authoritative, err := VisibleBandPolygons( + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil { + t.Fatalf("VisibleBandPolygons: %v", err) + } + if !authoritative { + t.Fatal("VisibleBandPolygons fell back to the footprint sweep") + } + if len(polygons) != 1 { + t.Fatalf("visible band polygon count=%d, want one continuous polar band", len(polygons)) + } + for footprintIndex, footprint := range path.PartialBandFootprints { + for polygonIndex, source := range footprint.Polygons { + for pointIndex, point := range source { + if point.MoonAltitude < -1e-6 && geodata.SphericalPolygonsContainPaths( + polygons, [][]geodata.GeoPoint{{{Longitude: point.Longitude, Latitude: point.Latitude}}}, false, + ) { + t.Fatalf("visible band contains below-horizon footprint %d polygon %d point %d", footprintIndex, polygonIndex, pointIndex) + } + } + } + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, [][]geodata.GeoPoint{{ + {Longitude: path.PartialBandFootprints[len(path.PartialBandFootprints)/2].Polygons[0][0].Longitude, + Latitude: path.PartialBandFootprints[len(path.PartialBandFootprints)/2].Polygons[0][0].Latitude}, + }}, true); miss > 112.5 { + t.Fatalf("visible band misses a source footprint vertex by %.1f km", miss) + } + if !geodata.SphericalPolygonsContainPaths(polygons, [][]geodata.GeoPoint{{ + {Longitude: -110, Latitude: -75}, + }}, false) { + t.Fatal("visible band misses the eastern footprint-sweep region") + } + for polygonIndex, polygon := range polygons { + maximumDetour := 0.0 + minimumTurn := 180.0 + for index := 1; index+1 < len(polygon); index++ { + first, middle, last := polygon[index-1], polygon[index], polygon[index+1] + detour := geoDistanceKM(first, middle) + geoDistanceKM(middle, last) - geoDistanceKM(first, last) + if detour > maximumDetour { + maximumDetour = detour + } + firstLongitude := first.Longitude - middle.Longitude + firstLatitude := first.Latitude - middle.Latitude + lastLongitude := last.Longitude - middle.Longitude + lastLatitude := last.Latitude - middle.Latitude + firstLength := math.Hypot(firstLongitude, firstLatitude) + lastLength := math.Hypot(lastLongitude, lastLatitude) + if firstLength <= 1e-12 || lastLength <= 1e-12 { + continue + } + cosine := (firstLongitude*lastLongitude + firstLatitude*lastLatitude) / + (firstLength * lastLength) + cosine = math.Max(-1, math.Min(1, cosine)) + minimumTurn = math.Min(minimumTurn, math.Acos(cosine)*180/math.Pi) + } + if maximumDetour > 35 { + t.Fatalf("visible band polygon %d retains %.1f km local hairpin", polygonIndex, maximumDetour) + } + if minimumTurn < 30 { + t.Fatalf("visible band polygon %d retains a %.1f degree numerical corner", polygonIndex, minimumTurn) + } + } +} + +func TestVisibleBandPolygonsFromContours20250729MarsUsesAnalyticBoundaryNetwork(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationMars, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + polygons, authoritative, err := VisibleBandPolygonsFromAnalyticContours( + path.PartialBandFootprints, path.PartialBandContours, path.PartialVisibilityContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil { + t.Fatalf("VisibleBandPolygonsFromContours: %v", err) + } + if !authoritative || len(polygons) != 1 { + t.Fatalf("visible band authoritative=%v rings=%d, want one authoritative ring", authoritative, len(polygons)) + } + partialBoundary := occultationVisibleBoundaryLinesFromBase( + occultationContactContourBoundaryLines(path.PartialBandContours), + occultationStaticBandCurves(path.RiseSetCurves), + occultationContactContourBoundaryLines(path.PartialVisibilityContours), + ) + partialBoundary = append(partialBoundary, occultationHorizonConnectorBoundaryLines( + HorizonConnectorSegments(path.PartialBandFootprints, path.RiseSetCurves, + path.NorthernLimit, path.SouthernLimit), + )...) + assertOccultationPolygonUsesOnlyBoundaryNetwork(t, "partial", polygons, partialBoundary, 1) + phaseLines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves)*2) + for _, curve := range path.RiseSetCurves { + phaseLines = append(phaseLines, occultationCurveBoundaryLines(curve)...) + } + if !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 1) { + miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false) + t.Fatalf("static partial band misses a physical rise/set phase curve by %.3f km, want <=1 km", miss) + } + assertOccultationPolarExtremeSmooth(t, "partial", polygons) + if !geodata.SphericalPolygonsContainPaths(polygons, [][]geodata.GeoPoint{{ + {Longitude: -110, Latitude: -75}, + }}, false) { + t.Fatal("static band misses the eastern event-time footprint") + } + totalPolygons, totalAuthoritative, err := VisibleTotalBandPolygonsFromAnalyticContours( + path.TotalBandFootprints, path.TotalBandContours, path.TotalVisibilityContours, + path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves, + ) + if err != nil { + t.Fatalf("VisibleTotalBandPolygonsFromContours: %v", err) + } + if !totalAuthoritative || len(totalPolygons) != 1 { + t.Fatalf("total visible band authoritative=%v rings=%d, want one authoritative ring", totalAuthoritative, len(totalPolygons)) + } + totalBoundary := occultationVisibleBoundaryLinesFromBase( + occultationContactContourBoundaryLines(path.TotalBandContours), + occultationStaticBandCurves(path.TotalRiseSetCurves), + occultationContactContourBoundaryLines(path.TotalVisibilityContours), + ) + totalBoundary = append(totalBoundary, occultationHorizonConnectorBoundaryLines( + HorizonConnectorSegments(path.TotalBandFootprints, path.TotalRiseSetCurves, + path.NorthernTotalLimit, path.SouthernTotalLimit), + )...) + assertOccultationPolygonUsesOnlyBoundaryNetwork(t, "total", totalPolygons, totalBoundary, 1) + totalPhaseLines := make([][]geodata.GeoPoint, 0, len(path.TotalRiseSetCurves)*2) + for _, curve := range path.TotalRiseSetCurves { + totalPhaseLines = append(totalPhaseLines, occultationCurveBoundaryLines(curve)...) + } + if !geodata.SphericalPolygonsContainPathsWithinKM(totalPolygons, totalPhaseLines, false, 1) { + miss := geodata.SphericalPolygonsPathMissDistanceKM(totalPolygons, totalPhaseLines, false) + t.Fatalf("static total band misses a physical rise/set phase curve by %.3f km, want <=1 km", miss) + } + assertOccultationPolarExtremeSmooth(t, "total", totalPolygons) + if miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, totalPolygons, true); miss > 10 { + t.Fatalf("total band extends %.1f km beyond the partial band", miss) + } +} + +func TestVisibleBandPolygons20250105SaturnUsesCompleteAnalyticBoundaryNetwork(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationSaturn, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + partial, partialAuthoritative, err := VisibleBandPolygonsFromAnalyticContours( + path.PartialBandFootprints, path.PartialBandContours, path.PartialVisibilityContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil || !partialAuthoritative || len(partial) != 1 { + t.Fatalf("partial polygons=%d authoritative=%v err=%v, want one analytic polygon", len(partial), partialAuthoritative, err) + } + total, totalAuthoritative, err := VisibleTotalBandPolygonsFromAnalyticContours( + path.TotalBandFootprints, path.TotalBandContours, path.TotalVisibilityContours, + path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves, + ) + if err != nil || !totalAuthoritative || len(total) != 1 { + t.Fatalf("total polygons=%d authoritative=%v err=%v, want one analytic polygon", len(total), totalAuthoritative, err) + } + for _, band := range []struct { + name string + polygons [][]geodata.GeoPoint + contacts [][]basic.OccultationPathPoint + visibility [][]basic.OccultationPathPoint + curves []basic.OccultationRiseSetCurve + }{ + {name: "partial", polygons: partial, contacts: path.PartialBandContours, visibility: path.PartialVisibilityContours, curves: path.RiseSetCurves}, + {name: "total", polygons: total, contacts: path.TotalBandContours, visibility: path.TotalVisibilityContours, curves: path.TotalRiseSetCurves}, + } { + boundary := occultationVisibleBoundaryLinesFromBase( + occultationContactContourBoundaryLines(band.contacts), + occultationStaticBandCurves(band.curves), + occultationContactContourBoundaryLines(band.visibility), + ) + if band.name == "partial" { + boundary = append(boundary, occultationHorizonConnectorBoundaryLines( + HorizonConnectorSegments(path.PartialBandFootprints, band.curves, + path.NorthernLimit, path.SouthernLimit), + )...) + } else { + boundary = append(boundary, occultationHorizonConnectorBoundaryLines( + HorizonConnectorSegments(path.TotalBandFootprints, band.curves, + path.NorthernTotalLimit, path.SouthernTotalLimit), + )...) + } + assertOccultationPolygonUsesOnlyBoundaryNetwork(t, band.name, band.polygons, boundary, 1) + phaseLines := make([][]geodata.GeoPoint, 0, len(band.curves)*2) + for _, curve := range band.curves { + phaseLines = append(phaseLines, occultationCurveBoundaryLines(curve)...) + } + if !geodata.SphericalPolygonsContainPathsWithinKM(band.polygons, phaseLines, false, 1) { + t.Fatalf("%s misses a rise/set phase by %.3f km", band.name, + geodata.SphericalPolygonsPathMissDistanceKM(band.polygons, phaseLines, false)) + } + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 10 { + t.Fatalf("total band extends %.1f km beyond the partial band", miss) + } +} + +func assertOccultationPolygonUsesOnlyBoundaryNetwork( + t *testing.T, + name string, + polygons, lines [][]geodata.GeoPoint, + maximumDistanceKM float64, +) { + t.Helper() + maximum := 0.0 + maximumPoint := geodata.GeoPoint{} + for _, polygon := range polygons { + for _, point := range polygon { + distance := math.Inf(1) + for _, line := range lines { + for index := 1; index < len(line); index++ { + distance = math.Min(distance, occultationTestProjectedPointLineDistanceKM( + point, line[index-1], line[index], + )) + } + } + if distance > maximum { + maximum, maximumPoint = distance, point + } + } + } + if maximum > maximumDistanceKM { + t.Fatalf("%s boundary leaves the analytic contact/phase network by %.3f km at %+v, want <=%.1f km", + name, maximum, maximumPoint, maximumDistanceKM) + } +} + +func assertOccultationPolarExtremeSmooth( + t *testing.T, + name string, + polygons [][]geodata.GeoPoint, +) { + t.Helper() + minimumLatitude := math.Inf(1) + for _, polygon := range polygons { + for _, point := range polygon { + minimumLatitude = math.Min(minimumLatitude, point.Latitude) + } + } + if minimumLatitude > -75 { + return + } + minimumTurn := 180.0 + maximumDeviation := 0.0 + minimumTurnPoint := geodata.GeoPoint{} + for _, polygon := range polygons { + for index := 2; index+2 < len(polygon); index++ { + if polygon[index].Latitude > minimumLatitude+0.15 { + continue + } + turn := occultationTurnAngleDegrees( + polygon[index-1], polygon[index], polygon[index+1], + ) + if turn < minimumTurn { + minimumTurn = turn + minimumTurnPoint = polygon[index] + } + maximumDeviation = math.Max(maximumDeviation, occultationProjectedPointLineDistanceKM( + polygon[index], polygon[index-2], polygon[index+2], + )) + } + } + if minimumTurn < 155 { + t.Fatalf("%s polar phase junction turn=%.2f degrees at %+v, want >=155", name, minimumTurn, minimumTurnPoint) + } + if maximumDeviation > 10 { + t.Fatalf("%s polar phase junction deviation=%.2f km, want <=10 km", name, maximumDeviation) + } +} + +func TestOccultationPhaseBoundaryCycleExcludesGreatestInteriorLine(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationMars, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + contourFill, _ := occultationContourFillAndCoverage( + true, path.NorthernLimit, path.SouthernLimit, nil, + ) + cycles, ok := occultationPhaseBoundaryPolygons( + path.RiseSetCurves, + HorizonConnectorSegments( + path.PartialBandFootprints, path.RiseSetCurves, + path.NorthernLimit, path.SouthernLimit, + ), + contourFill, + ) + if !ok || len(cycles) != 1 { + t.Fatalf("phase boundary cycles=%d ok=%v, want one closed Mars outer cycle", len(cycles), ok) + } + greatest := [][]geodata.GeoPoint{{ + {Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}, + }} + if !geodata.SphericalPolygonsContainPaths(cycles, greatest, false) { + t.Fatal("greatest point is outside the explicit start/end phase cycle") + } + if below := geodata.SphericalPolygonsPathMissDistanceKM( + cycles, contourFill, true, + ); below > 150 { + t.Fatalf("phase cycle misses contact envelope by %.1f km", below) + } + phaseLines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves)*2) + for _, curve := range path.RiseSetCurves { + if curve.Phase == basic.RiseSetPhaseGreatest { + continue + } + phaseLines = append(phaseLines, occultationCurveBoundaryLines(curve)...) + } + for _, connector := range HorizonConnectorSegments( + path.PartialBandFootprints, path.RiseSetCurves, + path.NorthernLimit, path.SouthernLimit, + ) { + phaseLines = append(phaseLines, occultationPathGeoLine(connector.Points)) + } + for pointIndex, point := range cycles[0] { + if pointIndex == len(cycles[0])-1 && geodata.SameGeoPoint(point, cycles[0][0]) { + continue + } + minimum := math.Inf(1) + for _, line := range phaseLines { + for _, candidate := range line { + minimum = math.Min(minimum, geoDistanceKM(point, candidate)) + } + } + if minimum > 1 { + t.Fatalf("cycle vertex %d is %.1f km away from start/end/connector phase data", pointIndex, minimum) + } + } +} + +func TestRemoveOccultationPolarKinksRemovesShortAxisReversals(t *testing.T) { + points := []geodata.GeoPoint{ + {Longitude: -135.15725570344884, Latitude: -78.65055730057036}, + {Longitude: -135.02235091247215, Latitude: -78.70666016247198}, + {Longitude: -134.7363589035614, Latitude: -78.69259306299674}, + {Longitude: -134.45107516883687, Latitude: -78.67825217368357}, + } + cleaned := removeOccultationPolarKinks(points) + if len(cleaned) >= len(points) { + t.Fatalf("cleaned point count=%d, want fewer than %d", len(cleaned), len(points)) + } + if cleaned[0] != points[0] || cleaned[len(cleaned)-1] != points[len(points)-1] { + t.Fatalf("cleaned endpoints changed: %#v", cleaned) + } +} + +func TestConstrainPolygonsWithinRepairsSmallChildBreach(t *testing.T) { + parent := [][]geodata.GeoPoint{{ + {Longitude: 0, Latitude: 0}, + {Longitude: 2, Latitude: 0}, + {Longitude: 2, Latitude: 2}, + {Longitude: 0, Latitude: 2}, + {Longitude: 0, Latitude: 0}, + }} + child := [][]geodata.GeoPoint{{ + {Longitude: 0.5, Latitude: 0.5}, + {Longitude: 1.5, Latitude: 0.5}, + {Longitude: 2.0005, Latitude: 0.0005}, + {Longitude: 1.5, Latitude: 1.5}, + {Longitude: 0.5, Latitude: 1.5}, + {Longitude: 0.5, Latitude: 0.5}, + }} + repaired := ConstrainPolygonsWithin(parent, child) + if geodata.SphericalPolygonsPathMissDistanceKM(parent, repaired, true) > 0 { + t.Fatal("small child breach remains outside parent") + } + if len(repaired[0]) != len(child[0]) { + t.Fatalf("repair changed ring point count from %d to %d", len(child[0]), len(repaired[0])) + } +} + +func TestConstrainPolygonsWithinLeavesLargeBreachUntouched(t *testing.T) { + parent := [][]geodata.GeoPoint{{ + {Longitude: 0, Latitude: 0}, + {Longitude: 1, Latitude: 0}, + {Longitude: 1, Latitude: 1}, + {Longitude: 0, Latitude: 1}, + {Longitude: 0, Latitude: 0}, + }} + child := [][]geodata.GeoPoint{{ + {Longitude: 0.2, Latitude: 0.2}, + {Longitude: 4, Latitude: 0.2}, + {Longitude: 0.2, Latitude: 0.8}, + {Longitude: 0.2, Latitude: 0.2}, + }} + repaired := ConstrainPolygonsWithin(parent, child) + if miss := geodata.SphericalPolygonsPathMissDistanceKM(parent, repaired, true); miss <= 0 { + t.Fatal("large child breach was unexpectedly constrained") + } +} + +func TestVisibleBandPolygons20250729MarsTotalRiseSetRemovesPolarBacktrack(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationMars, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.TotalRiseSetCurves) == 0 { + t.Fatal("Mars path is missing inner-contact total rise/set curves") + } + polygons, authoritative, err := VisibleTotalBandPolygons( + path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves, + ) + if err != nil { + t.Fatalf("VisibleBandPolygons: %v", err) + } + if !authoritative { + t.Fatal("total band fell back to footprint-sweep geometry instead of inner-contact rise/set boundaries") + } + if len(polygons) != 1 { + t.Fatalf("total polygon count=%d, want one continuous band", len(polygons)) + } + for polygonIndex, polygon := range polygons { + maximumEdge := 0.0 + for startIndex := 0; startIndex < len(polygon); startIndex++ { + arcLength := 0.0 + limit := startIndex + 16 + if limit >= len(polygon) { + limit = len(polygon) - 1 + } + for endIndex := startIndex + 1; endIndex <= limit; endIndex++ { + edge := geoDistanceKM(polygon[endIndex-1], polygon[endIndex]) + arcLength += edge + if edge > maximumEdge { + maximumEdge = edge + } + if endIndex < startIndex+3 { + continue + } + closure := geoDistanceKM(polygon[startIndex], polygon[endIndex]) + if closure <= 180 && arcLength-closure >= 35 { + t.Fatalf("total polygon %d retains a large polar backtrack: start=%d end=%d closure=%.1f km detour=%.1f km", + polygonIndex, startIndex, endIndex, closure, arcLength-closure) + } + } + } + if maximumEdge > 175 { + t.Fatalf("total polygon %d retains a %.1f km display chord", polygonIndex, maximumEdge) + } + } +} + +func TestVisibleBandPolygons20250729MarsTotalSmoothingPreservesGeometry(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationMars, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + rawTotal, authoritative, err := visibleBandPolygons( + path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit, + path.TotalBandContours, path.TotalRiseSetCurves, true, + ) + if err != nil || !authoritative { + t.Fatalf("raw total band authoritative=%v err=%v, want authoritative geometry", authoritative, err) + } + rawTotal = normalizeOccultationBandOutput(rawTotal) + smoothedTotal := roundOccultationTotalBandJunctions(rawTotal) + if shrink := geodata.SphericalPolygonsPathMissDistanceKM(smoothedTotal, rawTotal, true); shrink > 1 { + t.Fatalf("wide-shoulder repair excludes %.1f km of the source total ring", shrink) + } + secondPass := roundOccultationTotalBandJunctions(smoothedTotal) + if change := math.Max( + geodata.SphericalPolygonsPathMissDistanceKM(smoothedTotal, secondPass, true), + geodata.SphericalPolygonsPathMissDistanceKM(secondPass, smoothedTotal, true), + ); change > 0.05 { + t.Fatalf("total-band smoothing is not idempotent: second pass changed %.3f km", change) + } + for polygonIndex, polygon := range smoothedTotal { + for pointIndex := 2; pointIndex+2 < len(polygon); pointIndex++ { + if deviation := occultationTestProjectedPointLineDistanceKM( + polygon[pointIndex], polygon[pointIndex-2], polygon[pointIndex+2], + ); deviation > 35 { + t.Fatalf("total polygon %d retains %.1f km local roughness at point %d", + polygonIndex, deviation, pointIndex) + } + } + } + + partial, partialAuthoritative, err := VisibleBandPolygonsFromContours( + path.PartialBandFootprints, path.PartialBandContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil || !partialAuthoritative { + t.Fatalf("partial band authoritative=%v err=%v, want authoritative geometry", partialAuthoritative, err) + } + rawPartial, _, err := visibleBandPolygons( + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, + path.PartialBandContours, path.RiseSetCurves, false, + ) + if err != nil { + t.Fatalf("raw partial band: %v", err) + } + rawPartial = normalizeOccultationBandOutput(rawPartial) + partialChangeKM := math.Max( + geodata.SphericalPolygonsPathMissDistanceKM(rawPartial, partial, true), + geodata.SphericalPolygonsPathMissDistanceKM(partial, rawPartial, true), + ) + if partialChangeKM > 0.05 { + t.Fatalf("total-only wide-shoulder path changed partial geometry by %.3f km", partialChangeKM) + } +} + +func TestVisibleBandPolygons20250729MarsTotalRiseSetStaysInsidePartialBand(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationMars, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + partial, authoritative, err := VisibleBandPolygons( + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil { + t.Fatalf("partial VisibleBandPolygons: %v", err) + } + if !authoritative { + t.Fatal("partial visible band fell back to the footprint sweep") + } + total, authoritative, err := VisibleTotalBandPolygons( + path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit, path.TotalRiseSetCurves, + ) + if err != nil { + t.Fatalf("total VisibleBandPolygons: %v", err) + } + if !authoritative { + t.Fatal("total visible band fell back to the footprint sweep") + } + greatest := [][]geodata.GeoPoint{{ + {Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}, + }} + if !geodata.SphericalPolygonsContainPaths(total, greatest, false) { + miss := geodata.SphericalPolygonsPathMissDistanceKM(total, greatest, false) + t.Fatalf("total band misses greatest point by %.1f km", miss) + } + if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 10 { + t.Fatalf("total band extends %.1f km outside the partial band", miss) + } +} + +func TestOccultationVisibleBandLineworkInputsPreferContoursWhenAvailable(t *testing.T) { + visibleFill := [][]geodata.GeoPoint{{ + {Longitude: -20, Latitude: 81.5}, + {Longitude: -19, Latitude: 81.5}, + {Longitude: -19, Latitude: 82.0}, + {Longitude: -20, Latitude: 82.0}, + }} + contourFill := [][]geodata.GeoPoint{{ + {Longitude: -20.5, Latitude: 81.6}, + {Longitude: -19.5, Latitude: 81.6}, + {Longitude: -19.5, Latitude: 81.9}, + {Longitude: -20.5, Latitude: 81.9}, + }} + coveragePaths := [][]geodata.GeoPoint{{ + {Longitude: -20.25, Latitude: 81.75}, + }} + + for _, strongPolarSmoothing := range []bool{false, true} { + label := "partial" + if strongPolarSmoothing { + label = "total" + } + selectionFill, selectedCoverage, curveCoveragePaths, visibleFillCoveragePaths := + occultationVisibleBandLineworkInputs( + true, strongPolarSmoothing, visibleFill, contourFill, coveragePaths, nil, + ) + expectedSelection := append(append([][]geodata.GeoPoint(nil), contourFill...), visibleFill...) + if !geoPointCollectionsEqual(selectionFill, expectedSelection) { + t.Fatalf("%s selection fill = %#v, want contour plus footprint witnesses %#v", label, selectionFill, expectedSelection) + } + expectedCoverage := coveragePaths + if !geoPointCollectionsEqual(selectedCoverage, expectedCoverage) { + t.Fatalf("%s coverage paths = %#v, want original footprint probes %#v", label, selectedCoverage, expectedCoverage) + } + if len(curveCoveragePaths) != 0 { + t.Fatalf("%s curve coverage count=%d, want zero with no curves", label, len(curveCoveragePaths)) + } + if !geoPointCollectionsEqual(visibleFillCoveragePaths, coveragePaths) { + t.Fatalf("%s visible-fill coverage = %#v, want original footprint probes %#v", + label, visibleFillCoveragePaths, coveragePaths) + } + } +} + +func geoPointCollectionsEqual(first, second [][]geodata.GeoPoint) bool { + if len(first) != len(second) { + return false + } + for index := range first { + if len(first[index]) != len(second[index]) { + return false + } + for pointIndex := range first[index] { + if first[index][pointIndex] != second[index][pointIndex] { + return false + } + } + } + return true +} diff --git a/internal/occultationgeo/curve_sampling.go b/internal/occultationgeo/curve_sampling.go new file mode 100644 index 0000000..2cab3a5 --- /dev/null +++ b/internal/occultationgeo/curve_sampling.go @@ -0,0 +1,105 @@ +package occultationgeo + +import ( + "math" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +const defaultRiseSetProjectedSpacingKM = 35.0 + +// DensifyRiseSetCurves 按投影距离插入大地线样本,且不改变端点和分支拓扑。 +// DensifyRiseSetCurves inserts geodesic samples using the projected distance +// consumed by map renderers. Rise/set solvers intentionally use a coarser time +// step for performance; using those raw vertices as SVG/GeoJSON linework makes +// high-latitude curves visibly angular even when the physical path is smooth. +// Endpoints, phase, direction, and segment topology are preserved exactly. +func DensifyRiseSetCurves( + curves []basic.OccultationRiseSetCurve, + maximumProjectedSpacingKM float64, +) []basic.OccultationRiseSetCurve { + if len(curves) == 0 || maximumProjectedSpacingKM <= 0 { + return curves + } + result := make([]basic.OccultationRiseSetCurve, len(curves)) + for curveIndex, curve := range curves { + result[curveIndex] = basic.OccultationRiseSetCurve{ + Phase: curve.Phase, Direction: curve.Direction, + Segments: make([][]basic.OccultationPathPoint, len(curve.Segments)), + } + for segmentIndex, segment := range curve.Segments { + result[curveIndex].Segments[segmentIndex] = densifyRiseSetSegment(segment, maximumProjectedSpacingKM) + } + } + return result +} + +// DensifyOccultationPathPoints 将相同的投影感知间距应用于单条月掩路径。 +// DensifyOccultationPathPoints applies the same projection-aware spacing to a +// standalone geographic line such as a horizon connector. It keeps both +// endpoints and does not attach phase metadata to the result. +func DensifyOccultationPathPoints( + points []basic.OccultationPathPoint, + maximumProjectedSpacingKM float64, +) []basic.OccultationPathPoint { + return densifyRiseSetSegment(points, maximumProjectedSpacingKM) +} + +func densifyRiseSetSegment( + segment []basic.OccultationPathPoint, + maximumProjectedSpacingKM float64, +) []basic.OccultationPathPoint { + if len(segment) < 2 { + return append([]basic.OccultationPathPoint(nil), segment...) + } + result := make([]basic.OccultationPathPoint, 0, len(segment)*2) + // The interpolation is geodesic on the sphere, while the spacing check is + // made in Web Mercator. A chord that is exactly at the projected limit can + // grow slightly after interpolation because Mercator is nonlinear in + // latitude. Keep a small margin so the emitted linework stays below the + // requested limit after projection. + effectiveSpacingKM := maximumProjectedSpacingKM * 0.75 + for index, point := range segment { + result = append(result, point) + if index+1 >= len(segment) { + continue + } + next := segment[index+1] + steps := int(math.Ceil(projectedGeoPointDistanceKM(point, next) / effectiveSpacingKM)) + if steps < 2 { + continue + } + for step := 1; step < steps; step++ { + fraction := float64(step) / float64(steps) + middle := interpolateOccultationGeoPoint( + geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}, + geodata.GeoPoint{Longitude: next.Longitude, Latitude: next.Latitude}, + fraction, + ) + result = append(result, basic.OccultationPathPoint{ + Time: point.Time.Add(time.Duration(float64(next.Time.Sub(point.Time)) * fraction)), + Longitude: middle.Longitude, + Latitude: middle.Latitude, + MoonAltitude: point.MoonAltitude + (next.MoonAltitude-point.MoonAltitude)*fraction, + WidthKM: point.WidthKM + (next.WidthKM-point.WidthKM)*fraction, + }) + } + } + return result +} + +func projectedGeoPointDistanceKM(first, second basic.OccultationPathPoint) float64 { + const maxLatitude = 85.05112878 + const radiusKM = 6378.1366 + clampLatitude := func(value float64) float64 { + return math.Max(-maxLatitude, math.Min(maxLatitude, value)) + } + longitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180 + firstLatitude := clampLatitude(first.Latitude) * math.Pi / 180 + secondLatitude := clampLatitude(second.Latitude) * math.Pi / 180 + firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2)) + secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2)) + return radiusKM * math.Hypot(longitude, secondY-firstY) +} diff --git a/internal/occultationgeo/curve_sampling_test.go b/internal/occultationgeo/curve_sampling_test.go new file mode 100644 index 0000000..9ac8f7f --- /dev/null +++ b/internal/occultationgeo/curve_sampling_test.go @@ -0,0 +1,47 @@ +package occultationgeo + +import ( + "testing" + "time" + + "b612.me/astro/basic" +) + +func TestDensifyRiseSetCurvesUsesProjectedSpacingAndPreservesEndpoints(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + firstTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + lastTime := firstTime.Add(10 * time.Minute) + curves := []basic.OccultationRiseSetCurve{{ + Phase: basic.RiseSetPhaseStart, + Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{{ + {Time: firstTime, Longitude: -130, Latitude: -70}, + {Time: lastTime, Longitude: -128, Latitude: -78}, + }}, + }} + dense := DensifyRiseSetCurves(curves, 35) + if len(dense) != 1 || len(dense[0].Segments) != 1 { + t.Fatalf("curve topology changed: %#v", dense) + } + segment := dense[0].Segments[0] + if len(segment) <= 2 { + t.Fatalf("points=%d, want projected densification", len(segment)) + } + if segment[0] != curves[0].Segments[0][0] || segment[len(segment)-1] != curves[0].Segments[0][1] { + t.Fatalf("endpoints changed: first=%+v last=%+v", segment[0], segment[len(segment)-1]) + } + for index := 1; index < len(segment); index++ { + if spacing := projectedGeoPointDistanceKM(segment[index-1], segment[index]); spacing > 35.000001 { + t.Fatalf("edge %d projected spacing=%.3f km, want <=35 km", index, spacing) + } + } +} + +func TestDensifyRiseSetCurvesDoesNotMutateInput(t *testing.T) { + point := basic.OccultationPathPoint{Time: time.Unix(0, 0), Longitude: 1, Latitude: 2} + curves := []basic.OccultationRiseSetCurve{{Segments: [][]basic.OccultationPathPoint{{point, point}}}} + _ = DensifyRiseSetCurves(curves, 35) + if len(curves) != 1 || len(curves[0].Segments) != 1 || len(curves[0].Segments[0]) != 2 { + t.Fatalf("input mutated: %#v", curves) + } +} diff --git a/internal/occultationgeo/differential_test.go b/internal/occultationgeo/differential_test.go new file mode 100644 index 0000000..bc009f7 --- /dev/null +++ b/internal/occultationgeo/differential_test.go @@ -0,0 +1,302 @@ +package occultationgeo + +import ( + "math" + "math/rand" + "reflect" + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +func differentialRing(center geodata.GeoPoint, radius float64, points int, jitter *rand.Rand) []geodata.GeoPoint { + ring := make([]geodata.GeoPoint, 0, points+1) + for index := 0; index <= points; index++ { + angle := 2 * math.Pi * float64(index) / float64(points) + scale := radius + if jitter != nil { + scale *= 0.85 + 0.3*jitter.Float64() + } + ring = append(ring, geodata.GeoPoint{ + Longitude: center.Longitude + scale*math.Cos(angle), + Latitude: center.Latitude + scale*math.Sin(angle), + }) + } + return ring +} + +func differentialCopyPolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + result := make([][]geodata.GeoPoint, len(polygons)) + for index, polygon := range polygons { + result[index] = append([]geodata.GeoPoint(nil), polygon...) + } + return result +} + +// 包围盒剪枝只跳过所有顶点对都超过 60 km 的组合,必须与全扫参考实现逐点一致。 +func TestMergeTouchingVisiblePolygonsMatchesReference(t *testing.T) { + random := rand.New(rand.NewSource(20260915)) + bridged := 0 + for test := 0; test < 24; test++ { + polygons := make([][]geodata.GeoPoint, 0, 12) + longitude := -40.0 + latitude := 20 + 30*random.Float64() + for index := 0; index < 8+random.Intn(5); index++ { + radius := 0.1 + 0.4*random.Float64() + polygons = append(polygons, differentialRing( + geodata.GeoPoint{Longitude: longitude, Latitude: latitude + 0.5*random.Float64()}, + radius, 6+random.Intn(12), random)) + longitude += radius + 0.4 + random.Float64() + } + got := mergeTouchingVisiblePolygons(differentialCopyPolygons(polygons)) + want := mergeTouchingVisiblePolygonsReference(differentialCopyPolygons(polygons)) + if !reflect.DeepEqual(got, want) { + t.Fatalf("case %d: box pruning changed the merge result: got=%d want=%d", + test, len(got), len(want)) + } + if len(got) < len(polygons) { + bridged++ + } + } + if bridged == 0 { + t.Fatal("random inputs never exercised the bridging path") + } +} + +// 最大分量显式保留不得改变任何输入的过滤结果。 +func TestRemoveTinyPolygonComponentsMatchesReference(t *testing.T) { + random := rand.New(rand.NewSource(4242)) + for test := 0; test < 32; test++ { + count := 1 + random.Intn(6) + polygons := make([][]geodata.GeoPoint, 0, count) + for index := 0; index < count; index++ { + scale := math.Pow(10, -6*random.Float64()) + polygons = append(polygons, differentialRing( + geodata.GeoPoint{ + Longitude: -180 + 360*random.Float64(), + Latitude: -80 + 160*random.Float64(), + }, + scale*(0.001+random.Float64()), 3+random.Intn(6), nil)) + } + if test%4 == 0 { + polygons = append(polygons, + []geodata.GeoPoint{{Longitude: 0, Latitude: 0}}, + []geodata.GeoPoint{ + {Longitude: 179.9, Latitude: 0}, {Longitude: -179.9, Latitude: 0}, + {Longitude: -179.9, Latitude: 1}, {Longitude: 179.9, Latitude: 1}, + }) + } + got := RemoveTinyPolygonComponents(differentialCopyPolygons(polygons)) + want := removeTinyPolygonComponentsReference(differentialCopyPolygons(polygons)) + if !reflect.DeepEqual(got, want) { + t.Fatalf("case %d: got %d components, want %d", test, len(got), len(want)) + } + } +} + +// 单侧换支的抑制区间以"下一次任一侧换支"为界,重构不得改变输出。 +func TestPairedBoundaryPolygonsMatchesReference(t *testing.T) { + random := rand.New(rand.NewSource(90210)) + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + for test := 0; test < 24; test++ { + count := 2 + random.Intn(8) + first := make([]basic.OccultationPathPoint, 0, count) + second := make([]basic.OccultationPathPoint, 0, count) + longitude := 0.0 + latitude := -30 + 60*random.Float64() + for index := 0; index < count; index++ { + if random.Float64() < 0.25 { + longitude += 30 + 90*random.Float64() + } else { + longitude += 0.2 + random.Float64() + } + when := start.Add(time.Duration(index) * time.Minute) + first = append(first, basic.OccultationPathPoint{ + Time: when, Longitude: longitude, Latitude: latitude, + }) + second = append(second, basic.OccultationPathPoint{ + Time: when, Longitude: longitude, Latitude: latitude - 1 - random.Float64(), + }) + } + got := PairedBoundaryPolygons(first, second) + want := pairedBoundaryPolygonsReference(first, second) + if !reflect.DeepEqual(got, want) { + t.Fatalf("case %d: got %d cells, want %d", test, len(got), len(want)) + } + } +} + +// ≤5 km 的同相位分支是冗余早退:两种写法在全部距离区间必须给出相同判定。 +func TestSamePhaseHorizonConnectorAllowedMatchesReferenceRule(t *testing.T) { + reference := func(distanceKM float64) bool { + if distanceKM <= horizonConnectorSamePhaseEndpointDistanceKM { + return true + } + return distanceKM <= horizonConnectorSamePhaseBranchDistanceKM + } + for _, distanceKM := range []float64{0, 0.5, 5, 5.001, 120, 1199.9, 1200, 1200.1, 5000} { + first := horizonConnectorEndpoint{ + point: basic.OccultationPathPoint{}, curveIndex: 3, segmentIndex: 0, atStart: true, + } + last := horizonConnectorEndpoint{ + point: basic.OccultationPathPoint{ + Longitude: distanceKM / (EarthRadiusKM * math.Pi / 180), + }, + curveIndex: 3, segmentIndex: 1, atStart: true, + } + measured := DistanceKM(first.point, last.point) + if math.Abs(measured-distanceKM) > 0.02*math.Max(1, distanceKM) { + t.Fatalf("test distance %.3f km measured as %.3f km", distanceKM, measured) + } + if got, want := samePhaseHorizonConnectorAllowed(first, last), reference(distanceKM); got != want { + t.Fatalf("distance=%.3f km: got=%v want=%v", distanceKM, got, want) + } + } +} + +// 相位环候选顺序会被 map 迭代打乱,并集必须与顺序无关。 +func TestUnionPolygonsPermutationInvariant(t *testing.T) { + random := rand.New(rand.NewSource(777)) + for test := 0; test < 8; test++ { + polygons := make([][]geodata.GeoPoint, 0, 6) + for index := 0; index < 6; index++ { + polygons = append(polygons, differentialRing(geodata.GeoPoint{ + Longitude: -20 + 15*random.Float64(), + Latitude: -10 + 20*random.Float64(), + }, 2+3*random.Float64(), 8+random.Intn(8), random)) + } + base, err := geodata.UnionPolygons(differentialCopyPolygons(polygons)) + if err != nil { + t.Fatalf("case %d: %v", test, err) + } + for permutation := 0; permutation < 6; permutation++ { + shuffled := differentialCopyPolygons(polygons) + random.Shuffle(len(shuffled), func(first, second int) { + shuffled[first], shuffled[second] = shuffled[second], shuffled[first] + }) + other, err := geodata.UnionPolygons(shuffled) + if err != nil { + t.Fatalf("case %d permutation %d: %v", test, permutation, err) + } + if len(other) != len(base) { + t.Fatalf("case %d permutation %d: components %d vs %d", test, permutation, len(other), len(base)) + } + miss := math.Max( + geodata.SphericalPolygonsPathMissDistanceKM(base, other, true), + geodata.SphericalPolygonsPathMissDistanceKM(other, base, true), + ) + if miss > 1e-9 { + t.Fatalf("case %d permutation %d: unions differ by %.6f km", test, permutation, miss) + } + } + } +} + +func differentialOpenFootprintRun(centers []geodata.GeoPoint, halfHeight float64, points int) []basic.OccultationFootprint { + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + footprints := make([]basic.OccultationFootprint, 0, len(centers)) + for index, center := range centers { + when := start.Add(time.Duration(index) * 5 * time.Minute) + boundary := make([]basic.OccultationPathPoint, 0, points) + for step := 0; step < points; step++ { + fraction := float64(step) / float64(points-1) + boundary = append(boundary, basic.OccultationPathPoint{ + Time: when, + Longitude: center.Longitude + 0.02*math.Sin(2*math.Pi*fraction), + Latitude: center.Latitude - halfHeight + 2*halfHeight*fraction, + }) + } + footprints = append(footprints, basic.OccultationFootprint{ + Time: when, + Boundaries: [][]basic.OccultationPathPoint{boundary}, + }) + } + return footprints +} + +func differentialPolygonMiss(first, second [][]geodata.GeoPoint) float64 { + return math.Max( + geodata.SphericalPolygonsPathMissDistanceKM(first, second, true), + geodata.SphericalPolygonsPathMissDistanceKM(second, first, true), + ) +} + +// 单调端点轨迹轮廓与逐条 ribbon 球面并集必须给出同一外包络。 +func TestOpenSweepMonotoneMatchesRibbonUnion(t *testing.T) { + random := rand.New(rand.NewSource(31337)) + for test := 0; test < 6; test++ { + centers := make([]geodata.GeoPoint, 0, 9) + longitude := -20 + 10*random.Float64() + latitude := 30 + 20*random.Float64() + for index := 0; index < 9; index++ { + centers = append(centers, geodata.GeoPoint{Longitude: longitude, Latitude: latitude}) + longitude += 0.4 + random.Float64() + latitude += 0.2*random.Float64() - 0.1 + } + footprints := differentialOpenFootprintRun(centers, 0.8, 9) + got, err := footprintOpenSweepPolygons(footprints) + if err != nil { + t.Fatalf("case %d: %v", test, err) + } + want, err := footprintOpenSweepPolygonsWithTransitionsReference(footprints, true) + if err != nil { + t.Fatalf("case %d reference: %v", test, err) + } + if len(got) != len(want) { + t.Fatalf("case %d: components %d vs reference %d", test, len(got), len(want)) + } + if miss := differentialPolygonMiss(got, want); miss > 1 { + t.Fatalf("case %d: monotone outline differs from ribbon union by %.3f km", test, miss) + } + } +} + +// 真实极区事件的单调轮廓与通用并集也必须落在同一包络内。 +func TestOpenSweepMonotoneMatchesRibbonUnionSaturn20250105(t *testing.T) { + path := saturn20250105Path(t) + sweep, err := footprintOpenSweepPolygons(path.PartialBandFootprints) + if err != nil { + t.Fatal(err) + } + reference, err := footprintOpenSweepPolygonsWithTransitionsReference(path.PartialBandFootprints, true) + if err != nil { + t.Fatal(err) + } + miss := differentialPolygonMiss(sweep, reference) + t.Logf("Saturn 2025-01-05 partial open sweep: monotone=%d reference=%d mutual miss=%.3f km", + len(sweep), len(reference), miss) + if miss > 25 { + t.Fatalf("monotone outline leaves the ribbon union by %.3f km", miss) + } +} + +// 包含索引复用只在顶点修复路径上与参考实现一致;逐边修复路径允许保留更好的结果。 +func TestConstrainPolygonsWithinMatchesReference(t *testing.T) { + random := rand.New(rand.NewSource(5150)) + for test := 0; test < 12; test++ { + parent := [][]geodata.GeoPoint{ + differentialRing(geodata.GeoPoint{Longitude: 0, Latitude: 0}, 5, 16, nil), + } + childRing := differentialRing(geodata.GeoPoint{Longitude: 0, Latitude: 0}, 5, 16, nil) + for index := range childRing { + childRing[index].Longitude += 0.005 * (random.Float64() - 0.5) + childRing[index].Latitude += 0.005 * (random.Float64() - 0.5) + } + child := [][]geodata.GeoPoint{childRing} + if miss := geodata.SphericalPolygonsPathMissDistanceKM(parent, child, true); miss > 3 { + continue + } + got := ConstrainPolygonsWithin(parent, differentialCopyPolygons(child)) + want := constrainPolygonsWithinReference(parent, differentialCopyPolygons(child)) + if reflect.DeepEqual(got, want) { + continue + } + gotMiss := geodata.SphericalPolygonsPathMissDistanceKM(parent, got, true) + wantMiss := geodata.SphericalPolygonsPathMissDistanceKM(parent, want, true) + if gotMiss > wantMiss { + t.Fatalf("case %d: repaired miss %.3f km worse than reference %.3f km", test, gotMiss, wantMiss) + } + } +} diff --git a/internal/occultationgeo/direct_band.go b/internal/occultationgeo/direct_band.go new file mode 100644 index 0000000..5a87cba --- /dev/null +++ b/internal/occultationgeo/direct_band.go @@ -0,0 +1,327 @@ +package occultationgeo + +import ( + "math" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +const directBandRingMaximumEdgeKM = 25.0 + +// DirectVisibleBandPolygons 直接从一系列连续开放接触弧组装静态环。 +// DirectVisibleBandPolygons assembles a static ring directly from one +// continuous sequence of open contact arcs. The cycle is: +// +// first contact arc -> endpoint-B track -> last contact arc (reverse) -> endpoint-A track (reverse) +// +// This is the authoritative construction for grazing finite-disk events whose +// instantaneous visible footprints are all open. Events containing closed +// footprints or multiple simultaneous arcs return ok=false and remain on the +// existing coverage/polygonization fallback. +func DirectVisibleBandPolygons( + footprints []basic.OccultationFootprint, +) (polygons [][]geodata.GeoPoint, ok bool) { + if len(footprints) < 2 { + return nil, false + } + samples := make([]directOpenBoundarySample, 0, len(footprints)) + for _, footprint := range footprints { + if footprint.Closed || len(footprint.Boundaries) != 1 || len(footprint.Boundaries[0]) < 2 { + return nil, false + } + boundary := make([]geodata.GeoPoint, 0, len(footprint.Boundaries[0])) + for _, point := range footprint.Boundaries[0] { + boundary = append(boundary, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) + } + if len(boundary) < 2 { + return nil, false + } + if len(samples) > 0 { + previous := samples[len(samples)-1] + direct := geoDistanceKM(previous.a, boundary[0]) + geoDistanceKM(previous.b, boundary[len(boundary)-1]) + swapped := geoDistanceKM(previous.a, boundary[len(boundary)-1]) + geoDistanceKM(previous.b, boundary[0]) + if swapped < direct { + reverseGeoPoints(boundary) + } + } + samples = append(samples, directOpenBoundarySample{ + arc: boundary, a: boundary[0], b: boundary[len(boundary)-1], + }) + } + if len(samples) < 2 { + return nil, false + } + // For a monotone open-arc sequence the endpoint-track outline is already + // the intended exterior envelope. It avoids constructing and unioning one + // quadrilateral per resampled arc segment; retain the older swept union as + // a fallback for folded or otherwise ambiguous tracks. + monotoneSamples := make([]geodata.OpenBoundarySweepSample, len(samples)) + for index, sample := range samples { + monotoneSamples[index] = geodata.OpenBoundarySweepSample{ + Boundaries: [][]geodata.GeoPoint{sample.arc}, + } + } + if monotone, monotoneErr := geodata.MonotoneOpenBoundarySweep(monotoneSamples); monotoneErr == nil { + candidate := normalizeDirectBandPolygons(monotone) + if directBandCoversFootprints(candidate, footprints) { + return candidate, true + } + } + if swept, sweepOK := directOpenBoundarySweep(samples); sweepOK { + candidate := normalizeDirectBandPolygons(directBandAugmentVisibleFill(swept, footprints)) + if directBandCoversFootprints(candidate, footprints) { + return candidate, true + } + } + first, last := samples[0], samples[len(samples)-1] + ring := make([]geodata.GeoPoint, 0, len(first.arc)+len(last.arc)+2*len(samples)+2) + for _, point := range first.arc { + appendDirectRingPoint(&ring, point) + } + for index := 1; index < len(samples); index++ { + appendDirectRingPoint(&ring, samples[index].b) + } + for index := len(last.arc) - 1; index >= 0; index-- { + appendDirectRingPoint(&ring, last.arc[index]) + } + for index := len(samples) - 2; index >= 0; index-- { + appendDirectRingPoint(&ring, samples[index].a) + } + if len(ring) < 4 { + return nil, false + } + appendDirectRingPoint(&ring, ring[0]) + ring = densifyDirectRing(ring, directBandRingMaximumEdgeKM) + ring = removeOccultationHairpins(ring, 35, 25, 12) + ring = removeOccultationHairpins(ring, 100, 25, 32) + ring = removeOccultationSharpCorners(ring, 20, 30) + ring = densifyDirectRing(ring, directBandRingMaximumEdgeKM) + if len(ring) < 4 || math.Abs(directRingArea(ring)) <= 1e-9 { + return nil, false + } + polygons = [][]geodata.GeoPoint{ring} + polygons = normalizeDirectBandPolygons(directBandAugmentVisibleFill(polygons, footprints)) + if !directBandCoversFootprints(polygons, footprints) { + return nil, false + } + return polygons, true +} + +func normalizeDirectBandPolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + result := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, source := range polygons { + if len(source) < 3 { + continue + } + ring := append([]geodata.GeoPoint(nil), source...) + if len(ring) > 1 && !geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) { + ring = append(ring, ring[0]) + } + ring = densifyDirectRing(ring, directBandRingMaximumEdgeKM) + ring = removeDirectProjectedSharpCorners(ring, 20, 30) + ring = densifyDirectRing(ring, directBandRingMaximumEdgeKM) + if len(ring) >= 3 && math.Abs(directRingArea(ring)) > 1e-9 { + result = append(result, ring) + } + } + return result +} + +func removeDirectProjectedSharpCorners( + points []geodata.GeoPoint, + maximumChordKM, minimumAngleDegrees float64, +) []geodata.GeoPoint { + if len(points) < 4 || maximumChordKM <= 0 || minimumAngleDegrees <= 0 { + return points + } + result := append([]geodata.GeoPoint(nil), points...) + for { + changed := false + for index := 1; index+1 < len(result); index++ { + first, middle, last := result[index-1], result[index], result[index+1] + if geoDistanceKM(first, last) > maximumChordKM { + continue + } + scale := math.Cos(middle.Latitude * math.Pi / 180) + firstX := math.Remainder(first.Longitude-middle.Longitude, 360) * scale + firstY := first.Latitude - middle.Latitude + lastX := math.Remainder(last.Longitude-middle.Longitude, 360) * scale + lastY := last.Latitude - middle.Latitude + firstLength := math.Hypot(firstX, firstY) + lastLength := math.Hypot(lastX, lastY) + if firstLength <= 1e-12 || lastLength <= 1e-12 { + continue + } + cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength) + cosine = math.Max(-1, math.Min(1, cosine)) + if math.Acos(cosine)*180/math.Pi >= minimumAngleDegrees { + continue + } + result = append(result[:index], result[index+1:]...) + changed = true + index-- + } + if !changed { + break + } + } + return result +} + +// directBandAugmentVisibleFill adds the horizon-clipped instantaneous faces +// only when the open contact-arc envelope is not enough to witness the source +// footprints. The common case remains a single continuous sweep; polar +// horizon lobes get a bounded union of already-visible source faces instead +// of falling through to the global linework polygonizer. +func directBandAugmentVisibleFill( + polygons [][]geodata.GeoPoint, + footprints []basic.OccultationFootprint, +) [][]geodata.GeoPoint { + if len(polygons) == 0 { + return polygons + } + if directBandCoversFootprints(polygons, footprints) { + return polygons + } + visibleFill, _ := occultationVisibleFillAndCoverage(footprints) + if len(visibleFill) == 0 { + return polygons + } + input := append([][]geodata.GeoPoint(nil), polygons...) + input = append(input, visibleFill...) + merged, err := geodata.UnionPolygons(input) + if err != nil || len(merged) == 0 { + return polygons + } + return merged +} + +// directBandCoversFootprints is a postcondition for the fast open-arc ring. +// Boundary continuity alone is insufficient near a polar fold: a correctly +// paired pair of arcs can still leave an interior lobe outside the assembled +// ring. Reuse the same boundary probes as the ordinary sweep and add the +// polar/interior witnesses used by the authoritative linework validator. +func directBandCoversFootprints( + polygons [][]geodata.GeoPoint, + footprints []basic.OccultationFootprint, +) bool { + if len(polygons) == 0 || !footprintSweepCoversSamples(polygons, footprints) { + return false + } + _, coveragePaths := occultationVisibleFillAndCoverage(footprints) + if len(coveragePaths) == 0 { + return true + } + return geodata.SphericalPolygonsContainPathsWithinKM(polygons, coveragePaths, true, 25) +} + +func directOpenBoundarySweep(samples []directOpenBoundarySample) ([][]geodata.GeoPoint, bool) { + // Source contact arcs are already sampled at roughly 35 km. Sixty-four + // cross-arc samples keeps the rendered chord below the 25 km display target + // after densification while reducing the union input for long timelines. + const arcSamples = 64 + quads := make([][]geodata.GeoPoint, 0, (len(samples)-1)*arcSamples) + previous := resampleDirectArc(samples[0].arc, arcSamples) + for index := 1; index < len(samples); index++ { + current := resampleDirectArc(samples[index].arc, arcSamples) + if len(current) != len(previous) { + return nil, false + } + for pointIndex := 1; pointIndex < len(current); pointIndex++ { + quad := []geodata.GeoPoint{ + previous[pointIndex-1], current[pointIndex-1], current[pointIndex], previous[pointIndex], + } + if math.Abs(directRingArea(quad)) > 1e-10 { + quads = append(quads, quad) + } + } + previous = current + } + if len(quads) == 0 { + return nil, false + } + merged, err := geodata.UnionPolygons(quads) + if err != nil || len(merged) == 0 { + return nil, false + } + for index := range merged { + merged[index] = densifyDirectRing(merged[index], directBandRingMaximumEdgeKM) + } + return merged, true +} + +func resampleDirectArc(arc []geodata.GeoPoint, count int) []geodata.GeoPoint { + if len(arc) < 2 || count < 2 { + return nil + } + result := make([]geodata.GeoPoint, count) + for index := range result { + position := float64(index) * float64(len(arc)-1) / float64(count-1) + left := int(math.Floor(position)) + if left >= len(arc)-1 { + result[index] = arc[len(arc)-1] + continue + } + result[index] = interpolateOccultationGeoPoint(arc[left], arc[left+1], position-float64(left)) + } + return result +} + +type directOpenBoundarySample struct { + arc []geodata.GeoPoint + a geodata.GeoPoint + b geodata.GeoPoint +} + +func reverseGeoPoints(points []geodata.GeoPoint) { + for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 { + points[left], points[right] = points[right], points[left] + } +} + +func appendDirectRingPoint(ring *[]geodata.GeoPoint, point geodata.GeoPoint) { + if len(*ring) > 0 && geoDistanceKM((*ring)[len(*ring)-1], point) <= 1e-6 { + return + } + *ring = append(*ring, point) +} + +func densifyDirectRing(ring []geodata.GeoPoint, maximumEdgeKM float64) []geodata.GeoPoint { + if len(ring) < 2 || maximumEdgeKM <= 0 { + return ring + } + result := make([]geodata.GeoPoint, 0, len(ring)*2) + for index, point := range ring { + result = append(result, point) + if index+1 >= len(ring) { + continue + } + next := ring[index+1] + steps := int(math.Ceil(math.Max( + geoDistanceKM(point, next), occultationProjectedEdgeDistanceKM(point, next), + ) / maximumEdgeKM)) + if steps < 2 { + continue + } + for step := 1; step < steps; step++ { + fraction := float64(step) / float64(steps) + result = append(result, interpolateOccultationGeoPoint(point, next, fraction)) + } + } + return result +} + +func directRingArea(ring []geodata.GeoPoint) float64 { + if len(ring) < 3 { + return 0 + } + area := 0.0 + for index := 1; index < len(ring); index++ { + previous := ring[index-1] + current := ring[index] + latitude := (previous.Latitude + current.Latitude) * math.Pi / 360 + area += math.Remainder(current.Longitude-previous.Longitude, 360) * math.Cos(latitude) + } + return area +} diff --git a/internal/occultationgeo/footprint_sweep.go b/internal/occultationgeo/footprint_sweep.go new file mode 100644 index 0000000..e909b29 --- /dev/null +++ b/internal/occultationgeo/footprint_sweep.go @@ -0,0 +1,872 @@ +package occultationgeo + +import ( + "fmt" + "math" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +func footprintClosedSweepPolygons( + footprints []basic.OccultationFootprint, +) [][]geodata.GeoPoint { + polygons := make([][]geodata.GeoPoint, 0, len(footprints)) + var previous []geodata.GeoPoint + for _, footprint := range footprints { + if !footprint.Closed { + previous = nil + continue + } + ring, ok := footprintClosedSweepRing(footprint) + if !ok { + previous = nil + continue + } + ring = resampleClosedFootprintRing(ring, closedFootprintSweepPoints) + if len(ring) < 4 { + previous = nil + continue + } + if len(previous) == 0 { + previous = ring + continue + } + ring = alignClosedFootprintRing(previous, ring) + if footprintClosedSweepMaximumStep(previous, ring) > closedFootprintSweepMaxStepKM { + previous = ring + continue + } + polygons = append(polygons, footprintClosedSweepCells(previous, ring)...) + previous = ring + } + return polygons +} + +func footprintClosedSweepRing( + footprint basic.OccultationFootprint, +) ([]geodata.GeoPoint, bool) { + boundary := openFootprintRing(geodata.JoinPolylineSegments(footprintGeoBoundaries(footprint))) + if len(boundary) >= 4 { + return boundary, true + } + for _, source := range footprint.Polygons { + if len(source) < 4 || occultationInteriorPolygon(source, footprint.InteriorPolygons) { + continue + } + ring := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + ring[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + ring = openFootprintRing(ring) + if len(ring) >= 4 { + return ring, true + } + } + return nil, false +} + +func resampleClosedFootprintRing( + ring []geodata.GeoPoint, + count int, +) []geodata.GeoPoint { + ring = openFootprintRing(ring) + if count <= 0 || len(ring) <= count { + return append([]geodata.GeoPoint(nil), ring...) + } + result := make([]geodata.GeoPoint, count) + for index := range result { + result[index] = ring[index*len(ring)/count] + } + return result +} + +func alignClosedFootprintRing( + previous, current []geodata.GeoPoint, +) []geodata.GeoPoint { + if len(previous) == 0 || len(current) == 0 || len(previous) != len(current) { + return append([]geodata.GeoPoint(nil), current...) + } + bestShift := 0 + bestReversed := false + bestScore := math.Inf(1) + for _, reversed := range []bool{false, true} { + candidate := append([]geodata.GeoPoint(nil), current...) + if reversed { + reverseGeoPointRing(candidate) + } + for shift := 0; shift < len(candidate); shift++ { + score := closedFootprintRingAlignmentScore(previous, candidate, shift) + if score < bestScore { + bestScore = score + bestShift = shift + bestReversed = reversed + } + } + } + aligned := append([]geodata.GeoPoint(nil), current...) + if bestReversed { + reverseGeoPointRing(aligned) + } + return rotateClosedFootprintRing(aligned, bestShift) +} + +func reverseGeoPointRing(points []geodata.GeoPoint) { + for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 { + points[left], points[right] = points[right], points[left] + } +} + +func closedFootprintRingAlignmentScore( + previous, current []geodata.GeoPoint, + shift int, +) float64 { + samples := 16 + if len(previous) < samples { + samples = len(previous) + } + score := 0.0 + for sample := 0; sample < samples; sample++ { + index := sample * len(previous) / samples + score += geoDistanceKM(previous[index], current[(index+shift)%len(current)]) + } + return score +} + +func rotateClosedFootprintRing( + points []geodata.GeoPoint, + shift int, +) []geodata.GeoPoint { + result := make([]geodata.GeoPoint, len(points)) + for index := range result { + result[index] = points[(index+shift)%len(points)] + } + return result +} + +func footprintClosedSweepMaximumStep( + first, second []geodata.GeoPoint, +) float64 { + count := len(first) + if len(second) < count { + count = len(second) + } + maximum := 0.0 + for index := 0; index < count; index++ { + maximum = math.Max(maximum, geoDistanceKM(first[index], second[index])) + } + return maximum +} + +func footprintClosedSweepCells( + first, second []geodata.GeoPoint, +) [][]geodata.GeoPoint { + count := len(first) + if len(second) < count { + count = len(second) + } + polygons := make([][]geodata.GeoPoint, 0, count) + for index := 0; index < count; index++ { + next := (index + 1) % count + polygon := []geodata.GeoPoint{ + first[index], + second[index], + second[next], + first[next], + first[index], + } + if math.Abs(geoRingArea(polygon)) <= 1e-10 { + continue + } + polygons = append(polygons, polygon) + } + return polygons +} + +// footprintSweepSampleProbes 采样开放接触弧上的可见边界点作为覆盖见证探针。 +func footprintSweepSampleProbes(footprints []basic.OccultationFootprint) [][]geodata.GeoPoint { + probes := make([][]geodata.GeoPoint, 0, len(footprints)*16) + for _, footprint := range footprints { + // Closed footprints are added separately as instantaneous caps. They do + // not belong to the open-boundary sweep, so checking them here would + // reject every valid sweep that contains a horizon-closed sample. + if footprint.Closed { + continue + } + for _, boundary := range footprint.Boundaries { + step := len(boundary) / 16 + if step < 1 { + step = 1 + } + for index := 0; index < len(boundary); index += step { + point := geodata.GeoPoint{Longitude: boundary[index].Longitude, Latitude: boundary[index].Latitude} + probes = append(probes, []geodata.GeoPoint{point}) + } + } + } + return probes +} + +func footprintSweepCoversSamples( + polygons [][]geodata.GeoPoint, + footprints []basic.OccultationFootprint, +) bool { + if len(polygons) == 0 { + return false + } + probes := footprintSweepSampleProbes(footprints) + // At a high-latitude open/closed transition the sweep edge can coincide + // with a source tangent sample within floating-point error. A sub-kilometre + // tolerance accepts that shared physical edge without admitting a real + // branch gap. Ordinary-latitude stellar bands retain the strict check so a + // genuinely missing center-line segment cannot be hidden by the tolerance. + toleranceKM := 0.0 + minimumAbsoluteLatitude := 90.0 + allNorthern, allSouthern := true, true + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + for _, point := range boundary { + minimumAbsoluteLatitude = math.Min(minimumAbsoluteLatitude, math.Abs(point.Latitude)) + allNorthern = allNorthern && point.Latitude >= 0 + allSouthern = allSouthern && point.Latitude <= 0 + } + } + } + if minimumAbsoluteLatitude >= 40 && (allNorthern || allSouthern) { + toleranceKM = 1 + } + return geodata.SphericalPolygonsContainPathsWithinKM(polygons, probes, true, toleranceKM) +} + +// footprintVisibleUnionPolygons returns the sampled, horizon-closed visible +// area without replacing it by open contact-arc ribbons. It is used as a mask +// audit for polar sweeps: the source polygons are the only representation that +// carries the instantaneous Moon-above-horizon closure. +func footprintVisibleUnionPolygons( + footprints []basic.OccultationFootprint, +) [][]geodata.GeoPoint { + visibleFill := occultationVisibleFootprintFillOnly(footprints) + inputs := visibleFill + inputs = append(inputs, footprintStaticInteriorPolygons(footprints)...) + inputs = usableOccultationPolygons(inputs) + if len(inputs) == 0 { + return nil + } + merged, err := geodata.UnionPolygons(inputs) + if err != nil || len(merged) == 0 { + // The spherical union can reject one numerically open edge at a horizon + // transition even though the input footprints form a single temporal + // ribbon. Run the bounded touching merge on the usable source rings so a + // sub-60 km seam does not leak out as one polygon per time sample. + return mergeTouchingVisiblePolygons(inputs) + } + return mergeTouchingVisiblePolygons(merged) +} + +func usableOccultationPolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + result := make([][]geodata.GeoPoint, 0, len(polygons)) + for _, polygon := range polygons { + open := openFootprintRing(polygon) + if len(open) < 3 || math.Abs(geoRingArea(open)) <= 1e-12 { + continue + } + result = append(result, polygon) + } + return result +} + +// occultationUnitBounds 是环的三维单位向量包围盒(已含大圆弧外凸余量)。 +type occultationUnitBounds struct { + minX, minY, minZ float64 + maxX, maxY, maxZ float64 +} + +func occultationUnitVector(point geodata.GeoPoint) (float64, float64, float64) { + latitude := point.Latitude * math.Pi / 180 + longitude := point.Longitude * math.Pi / 180 + cosLatitude := math.Cos(latitude) + return cosLatitude * math.Cos(longitude), cosLatitude * math.Sin(longitude), math.Sin(latitude) +} + +func occultationPolygonUnitBounds(polygon []geodata.GeoPoint) occultationUnitBounds { + bounds := occultationUnitBounds{ + minX: math.Inf(1), minY: math.Inf(1), minZ: math.Inf(1), + maxX: math.Inf(-1), maxY: math.Inf(-1), maxZ: math.Inf(-1), + } + vectors := make([][3]float64, 0, len(polygon)) + minimumCosine := 1.0 + for _, point := range polygon { + x, y, z := occultationUnitVector(point) + vectors = append(vectors, [3]float64{x, y, z}) + } + for index, vector := range vectors { + next := vectors[(index+1)%len(vectors)] + if cosine := vector[0]*next[0] + vector[1]*next[1] + vector[2]*next[2]; cosine < minimumCosine { + minimumCosine = cosine + } + } + // 大圆弧中点会凸出端点坐标,按最长弧的半角放大包围盒,保证下界仍然成立。 + scale := 1.0 + if half := math.Sqrt(math.Max(0, (1+minimumCosine)/2)); half > 1e-9 { + scale = 1 / half + } + for _, vector := range vectors { + for axis, value := range vector { + high, low := value, value + if value > 0 { + high = value * scale + } else { + low = value * scale + } + switch axis { + case 0: + bounds.minX, bounds.maxX = math.Min(bounds.minX, low), math.Max(bounds.maxX, high) + case 1: + bounds.minY, bounds.maxY = math.Min(bounds.minY, low), math.Max(bounds.maxY, high) + default: + bounds.minZ, bounds.maxZ = math.Min(bounds.minZ, low), math.Max(bounds.maxZ, high) + } + } + } + return bounds +} + +// occultationUnitBoundsNear 报告两包围盒的弦距下界是否可能小于 limitKM。 +func occultationUnitBoundsNear(first, second occultationUnitBounds, limitKM float64) bool { + dx := math.Max(0, math.Max(first.minX-second.maxX, second.minX-first.maxX)) + dy := math.Max(0, math.Max(first.minY-second.maxY, second.minY-first.maxY)) + dz := math.Max(0, math.Max(first.minZ-second.maxZ, second.minZ-first.maxZ)) + // 大圆距离不小于弦长,因此弦距下界可以作为 60 km 近邻判定的必要条件。 + return EarthRadiusKM*math.Sqrt(dx*dx+dy*dy+dz*dz) <= limitKM +} + +func mergeTouchingVisiblePolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + const ( + // Boolean operations on adjacent five-minute footprints can leave + // several-kilometre numerical gaps even though the temporal samples + // overlap. Keep a modest 60 km ceiling for the rare last-gap transition; + // larger separations remain disconnected physical components and are not + // bridged. + touchingDistanceKM = 60.0 + minimumBridgeHalfDeg = 0.01 + ) + if len(polygons) < 2 { + return polygons + } + bounds := make([]occultationUnitBounds, len(polygons)) + for index, polygon := range polygons { + bounds[index] = occultationPolygonUnitBounds(polygon) + } + for len(polygons) > 1 { + firstIndex, secondIndex := -1, -1 + var touching, leftTouch, rightTouch geodata.GeoPoint + for first := 0; first < len(polygons) && firstIndex < 0; first++ { + for second := first + 1; second < len(polygons) && firstIndex < 0; second++ { + if !occultationUnitBoundsNear(bounds[first], bounds[second], touchingDistanceKM) { + continue + } + for _, left := range polygons[first] { + for _, right := range polygons[second] { + if geoDistanceKM(left, right) > touchingDistanceKM { + continue + } + firstIndex, secondIndex = first, second + leftTouch, rightTouch = left, right + touching = geodata.GeoPoint{ + Longitude: left.Longitude + math.Remainder(right.Longitude-left.Longitude, 360)/2, + Latitude: (left.Latitude + right.Latitude) / 2, + } + break + } + if firstIndex >= 0 { + break + } + } + } + } + if firstIndex < 0 || secondIndex < 0 { + break + } + bridgeHalfDeg := minimumBridgeHalfDeg + if gap := geoDistanceKM(leftTouch, rightTouch) / EarthRadiusKM * 180 / math.Pi; gap/2+0.002 > bridgeHalfDeg { + bridgeHalfDeg = gap/2 + 0.002 + } + bridge := []geodata.GeoPoint{ + {Longitude: touching.Longitude - bridgeHalfDeg, Latitude: touching.Latitude - bridgeHalfDeg}, + {Longitude: touching.Longitude + bridgeHalfDeg, Latitude: touching.Latitude - bridgeHalfDeg}, + {Longitude: touching.Longitude + bridgeHalfDeg, Latitude: touching.Latitude + bridgeHalfDeg}, + {Longitude: touching.Longitude - bridgeHalfDeg, Latitude: touching.Latitude + bridgeHalfDeg}, + } + leftPolygon := append([]geodata.GeoPoint(nil), polygons[firstIndex]...) + rightPolygon := append([]geodata.GeoPoint(nil), polygons[secondIndex]...) + for index, point := range leftPolygon { + if point == leftTouch { + leftPolygon[index] = touching + break + } + } + for index, point := range rightPolygon { + if point == rightTouch { + rightPolygon[index] = touching + break + } + } + pair, err := geodata.UnionPolygons([][]geodata.GeoPoint{ + leftPolygon, rightPolygon, bridge, + }) + if err != nil || len(pair) != 1 { + pair, err = geodata.UnionPolygons([][]geodata.GeoPoint{ + leftPolygon, rightPolygon, + }) + } + if err != nil || len(pair) != 1 { + break + } + next := make([][]geodata.GeoPoint, 0, len(polygons)-1) + nextBounds := make([]occultationUnitBounds, 0, len(polygons)-1) + for index, polygon := range polygons { + if index == firstIndex { + next = append(next, pair[0]) + nextBounds = append(nextBounds, occultationPolygonUnitBounds(pair[0])) + continue + } + if index == secondIndex { + continue + } + next = append(next, polygon) + nextBounds = append(nextBounds, bounds[index]) + } + polygons, bounds = next, nextBounds + } + return polygons +} + +func footprintSweepNeedsHorizonClipping( + swept, visibleUnion [][]geodata.GeoPoint, + footprints []basic.OccultationFootprint, +) bool { + if len(swept) == 0 || len(visibleUnion) == 0 { + return false + } + // A single continuous open sweep against a fragmented horizon union is a + // topological disagreement, not a small metric residual. The caller must + // use the continuous contact/phase linework in this case; avoid measuring + // every edge of all sampled fragments against the sweep because that exact + // spherical distance check dominates compact polar events. + if len(swept) == 1 && len(visibleUnion) > 1 { + return true + } + // A disconnected open sweep is the common polar-fold failure mode: its + // endpoint ribbons can bridge across a cap even though the horizon-closed + // source remains split into separate visible faces. + if len(swept) > 1 && len(visibleUnion) > len(swept) { + return true + } + if len(swept) > 1 { + for _, footprint := range footprints { + for _, boundary := range footprint.Boundaries { + for _, point := range boundary { + if math.Abs(point.Latitude) >= 70 { + return true + } + } + } + } + } + // Probe vertices and edge midpoints of the smooth sweep against the + // horizon-closed union. A miss larger than a small numerical tolerance means + // the ribbon has crossed into the below-horizon complement. + if !geodata.SphericalPolygonsContainPathsWithinKM(visibleUnion, swept, true, 20) { + return true + } + probes := make([][]geodata.GeoPoint, 0, 2048) + for _, footprint := range footprints { + for _, source := range footprint.Polygons { + if len(source) < 3 { + continue + } + ring := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + ring[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + for _, probe := range occultationVisibleFootprintProbes(source, ring) { + probes = append(probes, []geodata.GeoPoint{probe}) + } + } + } + probes = limitOccultationCoveragePaths(probes, 2048) + if len(probes) > 0 && !geodata.SphericalPolygonsContainPathsWithinKM(swept, probes, false, 25) { + return true + } + // The opposite miss means the open ribbon dropped a legitimate visible + // footprint lobe. Keep the horizon-closed union in that case as well; a + // static band must contain every sampled instantaneous witness. + return !geodata.SphericalPolygonsContainPathsWithinKM(swept, visibleUnion, true, 25) +} + +func footprintBoundariesAvailable(footprints []basic.OccultationFootprint) bool { + if len(footprints) == 0 { + return false + } + for _, footprint := range footprints { + if len(footprint.Boundaries) == 0 { + return false + } + } + return true +} + +func footprintOpenSweepPolygons(footprints []basic.OccultationFootprint) ([][]geodata.GeoPoint, error) { + return footprintOpenSweepPolygonsWithTransitions(footprints, true) +} + +func footprintOpenSweepPolygonsWithoutTransitions( + footprints []basic.OccultationFootprint, +) ([][]geodata.GeoPoint, error) { + return footprintOpenSweepPolygonsWithTransitions(footprints, false) +} + +func footprintOpenSweepPolygonsWithTransitions( + footprints []basic.OccultationFootprint, + includeTransitions bool, +) ([][]geodata.GeoPoint, error) { + polygons := make([][]geodata.GeoPoint, 0, 2) + for start := 0; start < len(footprints); { + for start < len(footprints) && footprints[start].Closed { + start++ + } + if start == len(footprints) { + break + } + end := start + for end < len(footprints) && !footprints[end].Closed { + end++ + } + + samples := make([]geodata.OpenBoundarySweepSample, 0, end-start+2) + if includeTransitions && start > 0 { + boundary, ok := footprintTransitionBoundary(footprints[start-1], footprints[start]) + if ok { + samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: [][]geodata.GeoPoint{boundary}}) + } + } + for index := start; index < end; index++ { + samples = append(samples, geodata.OpenBoundarySweepSample{ + Boundaries: footprintGeoBoundaries(footprints[index]), + }) + } + if includeTransitions && end < len(footprints) { + boundary, ok := footprintTransitionBoundary(footprints[end], footprints[end-1]) + if ok { + samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: [][]geodata.GeoPoint{boundary}}) + } + } + group, err := geodata.OpenBoundarySweep(samples) + if err != nil && includeTransitions { + // At an open/closed transition the exact transition arc can be + // numerically coincident with the first ribbon edge. Retry the same + // physical run without that synthetic endpoint; the sampled open arcs + // still provide both endpoint tracks and avoid a false diagonal cap. + bareSamples := make([]geodata.OpenBoundarySweepSample, 0, end-start) + for index := start; index < end; index++ { + bareSamples = append(bareSamples, geodata.OpenBoundarySweepSample{ + Boundaries: footprintGeoBoundaries(footprints[index]), + }) + } + group, err = geodata.OpenBoundarySweep(bareSamples) + } + if err != nil { + return nil, err + } + polygons = append(polygons, group...) + start = end + } + if len(polygons) == 0 { + return nil, fmt.Errorf("open footprint samples contain no usable sweep") + } + return polygons, nil +} + +func footprintGeoBoundaries(footprint basic.OccultationFootprint) [][]geodata.GeoPoint { + boundaries := make([][]geodata.GeoPoint, len(footprint.Boundaries)) + for boundaryIndex, source := range footprint.Boundaries { + boundary := make([]geodata.GeoPoint, len(source)) + for pointIndex, point := range source { + boundary[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + boundaries[boundaryIndex] = boundary + } + return boundaries +} + +func footprintTransitionBoundary( + closed, adjacent basic.OccultationFootprint, +) ([]geodata.GeoPoint, bool) { + closedRing := openFootprintRing(geodata.JoinPolylineSegments(footprintGeoBoundaries(closed))) + adjacentArc := openFootprintRing(geodata.JoinPolylineSegments(footprintGeoBoundaries(adjacent))) + if len(closedRing) < 3 || len(adjacentArc) < 2 { + return nil, false + } + start := nearestFootprintPointIndex(closedRing, adjacentArc[0]) + end := nearestFootprintPointIndex(closedRing, adjacentArc[len(adjacentArc)-1]) + if start == end { + forward := footprintOpenedRing(closedRing, start, 1) + backward := footprintOpenedRing(closedRing, start, -1) + if footprintArcMatchScore(backward, adjacentArc) < footprintArcMatchScore(forward, adjacentArc) { + return backward, true + } + return forward, true + } + forward := footprintRingArc(closedRing, start, end, 1) + backward := footprintRingArc(closedRing, start, end, -1) + if footprintArcMatchScore(backward, adjacentArc) < footprintArcMatchScore(forward, adjacentArc) { + return backward, true + } + return forward, true +} + +func nearestFootprintPointIndex(points []geodata.GeoPoint, target geodata.GeoPoint) int { + nearest := 0 + distance := math.Inf(1) + for index, point := range points { + candidate := geoDistanceKM(point, target) + if candidate < distance { + nearest, distance = index, candidate + } + } + return nearest +} + +func footprintOpenedRing(points []geodata.GeoPoint, start, direction int) []geodata.GeoPoint { + result := make([]geodata.GeoPoint, len(points)) + for index := range result { + position := (start + direction*index) % len(points) + if position < 0 { + position += len(points) + } + result[index] = points[position] + } + return result +} + +func footprintRingArc(points []geodata.GeoPoint, start, end, direction int) []geodata.GeoPoint { + arc := make([]geodata.GeoPoint, 1, len(points)+1) + arc[0] = points[start] + index := start + for step := 1; step <= len(points); step++ { + index = (index + direction + len(points)) % len(points) + arc = append(arc, points[index]) + if index == end { + return arc + } + } + return arc +} + +func footprintArcMatchScore(candidate, reference []geodata.GeoPoint) float64 { + if len(candidate) < 2 || len(reference) < 2 { + return math.Inf(1) + } + const samples = 17 + score := 0.0 + for index := 0; index < samples; index++ { + candidateIndex := index * (len(candidate) - 1) / (samples - 1) + referenceIndex := index * (len(reference) - 1) / (samples - 1) + score += geoDistanceKM(candidate[candidateIndex], reference[referenceIndex]) + } + return score +} + +func openFootprintRing(points []geodata.GeoPoint) []geodata.GeoPoint { + if len(points) > 1 && geodata.SameGeoPoint(points[0], points[len(points)-1]) { + return points[:len(points)-1] + } + return points +} + +func footprintClosedPolygons(footprints []basic.OccultationFootprint) [][]geodata.GeoPoint { + var polygons [][]geodata.GeoPoint + for _, footprint := range footprints { + if !footprint.Closed || len(footprint.Boundaries) == 0 { + continue + } + polygons = append(polygons, footprintPolygons([]basic.OccultationFootprint{footprint})...) + } + return polygons +} + +func footprintStaticInteriorPolygons(footprints []basic.OccultationFootprint) [][]geodata.GeoPoint { + var polygons [][]geodata.GeoPoint + for _, footprint := range footprints { + if footprint.Closed { + continue + } + for _, source := range footprint.InteriorPolygons { + if len(source) < 3 || !occultationStaticInteriorPolygon(source, footprint.InteriorPolygons) { + continue + } + center := geodata.GeoPoint{Longitude: source[0].Longitude, Latitude: source[0].Latitude} + polygon, ok := footprintStaticBoundaryBridge(center, footprint.Boundaries) + if !ok { + polygon = make([]geodata.GeoPoint, len(source)) + for index, point := range source { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + } + // A footprint can briefly collapse to a tangent point at a + // visibility transition. Its bridge then repeats the same vertex and + // is not a polygon; passing it to UnionPolygons aborts the entire band. + if len(polygon) >= 3 && math.Abs(geoRingArea(polygon)) > 1e-12 { + polygons = append(polygons, polygon) + } + centerCap := geodata.SphericalCircle( + center, + staticCenterCapRadiusKM/EarthRadiusKM*180/math.Pi, + staticCenterCapPoints, + ) + if len(centerCap) >= 3 { + centerCap = append(centerCap, centerCap[0]) + polygons = append(polygons, centerCap) + } + } + } + return polygons +} + +func footprintStaticBoundaryBridge( + center geodata.GeoPoint, + boundaries [][]basic.OccultationPathPoint, +) ([]geodata.GeoPoint, bool) { + nearestDistance := math.Inf(1) + var nearestStart, nearestEnd geodata.GeoPoint + for _, boundary := range boundaries { + for index := 1; index < len(boundary); index++ { + start := geodata.GeoPoint{ + Longitude: boundary[index-1].Longitude, + Latitude: boundary[index-1].Latitude, + } + end := geodata.GeoPoint{ + Longitude: boundary[index].Longitude, + Latitude: boundary[index].Latitude, + } + distance := geoPointSegmentDistanceKM(center, start, end) + if distance < nearestDistance { + nearestDistance = distance + nearestStart = start + nearestEnd = end + } + } + } + if !finiteGeo(nearestDistance) { + return nil, false + } + return []geodata.GeoPoint{center, nearestStart, nearestEnd, center}, true +} + +func footprintPolygons(footprints []basic.OccultationFootprint) [][]geodata.GeoPoint { + var polygons [][]geodata.GeoPoint + for _, footprint := range footprints { + for _, source := range footprint.Polygons { + if occultationInteriorPolygon(source, footprint.InteriorPolygons) { + continue + } + polygon := make([]geodata.GeoPoint, len(source)) + for index, point := range source { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, polygon) + } + } + return polygons +} + +func geoPointSegmentDistanceKM(point, start, end geodata.GeoPoint) float64 { + latitude := point.Latitude * math.Pi / 180 + scaleX := math.Cos(latitude) * EarthRadiusKM * math.Pi / 180 + scaleY := EarthRadiusKM * math.Pi / 180 + x := func(value geodata.GeoPoint) float64 { + return math.Remainder(value.Longitude-point.Longitude, 360) * scaleX + } + y := func(value geodata.GeoPoint) float64 { + return (value.Latitude - point.Latitude) * scaleY + } + startX, startY := x(start), y(start) + endX, endY := x(end), y(end) + deltaX, deltaY := endX-startX, endY-startY + fraction := 0.0 + if lengthSquared := deltaX*deltaX + deltaY*deltaY; lengthSquared > 0 { + fraction = math.Max(0, math.Min(1, + -(startX*deltaX+startY*deltaY)/lengthSquared, + )) + } + return math.Hypot(startX+fraction*deltaX, startY+fraction*deltaY) +} + +func geoRingArea(polygon []geodata.GeoPoint) float64 { + if len(polygon) < 3 { + return 0 + } + longitudes := make([]float64, len(polygon)) + longitudes[0] = polygon[0].Longitude + for index := 1; index < len(polygon); index++ { + longitudes[index] = longitudes[index-1] + math.Remainder( + polygon[index].Longitude-longitudes[index-1], 360, + ) + } + area := 0.0 + for index, point := range polygon { + next := polygon[(index+1)%len(polygon)] + area += longitudes[index]*next.Latitude - longitudes[(index+1)%len(polygon)]*point.Latitude + } + return area / 2 +} + +func finiteGeo(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} + +func continuousRanges(count int, changed func(int) bool) []SampleRange { + if count == 0 { + return nil + } + ranges := make([]SampleRange, 0, 2) + start := 0 + for index := 1; index < count; index++ { + if !changed(index) { + continue + } + ranges = append(ranges, SampleRange{Start: start, End: index}) + start = index + } + return append(ranges, SampleRange{Start: start, End: count}) +} + +// BoundaryBranchChanged 判断两个相邻样本是否距离过大,无法属于同一物理支路。 +// BoundaryBranchChanged reports whether two adjacent samples are too far apart to be one physical branch. +func BoundaryBranchChanged(first, second basic.OccultationPathPoint) bool { + distance := DistanceKM(first, second) + if distance <= BoundaryBranchJumpKM { + return false + } + duration := math.Abs(second.Time.Sub(first.Time).Seconds()) + return duration == 0 || distance/duration > BoundaryBranchSpeedKMPerSecond +} + +// DistanceKM 返回两个边界样本之间的最短球面距离。 +// DistanceKM returns the shortest spherical surface distance between two boundary samples. +func DistanceKM(first, second basic.OccultationPathPoint) float64 { + return geoDistanceKM( + geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude}, + geodata.GeoPoint{Longitude: second.Longitude, Latitude: second.Latitude}, + ) +} + +func geoDistanceKM(first, second geodata.GeoPoint) float64 { + firstLatitude := first.Latitude * math.Pi / 180 + secondLatitude := second.Latitude * math.Pi / 180 + deltaLatitude := secondLatitude - firstLatitude + deltaLongitude := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi) + haversine := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) + + math.Cos(firstLatitude)*math.Cos(secondLatitude)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2) + return 2 * EarthRadiusKM * math.Asin(math.Sqrt(math.Min(1, haversine))) +} diff --git a/internal/occultationgeo/horizon_connector.go b/internal/occultationgeo/horizon_connector.go new file mode 100644 index 0000000..6526c06 --- /dev/null +++ b/internal/occultationgeo/horizon_connector.go @@ -0,0 +1,646 @@ +package occultationgeo + +import ( + "math" + "sort" + "strconv" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +const ( + horizonConnectorMatchDistanceKM = 180.0 + horizonConnectorMinimumLengthKM = 35.0 + // Footprint timelines are sampled at five-minute cadence while rise/set + // endpoints are solved independently. Allow the nearest sparse sample to + // be about one minute away without accepting a different event branch. + horizonConnectorMatchWindow = 90 * time.Second + horizonConnectorDensifyEdgeKM = 25.0 + // 同相位端点在 5 km 以内属于同一极区折叠:显示曲线已由 StitchedRiseSetCurveSegments + // 缝合,再输出连接段会在折点处多出一条悬空线,因此这类配对只用于否决该边界的连接段。 + horizonConnectorSamePhaseEndpointDistanceKM = 5.0 + // A same-phase opening/closing fold can expose two branches of one sampled + // phase curve. It is eligible for a connector only while the footprint + // endpoints remain within this bound. + horizonConnectorSamePhaseBranchDistanceKM = 1200.0 + horizonConnectorBoundaryShiftToleranceKM = 25.0 + // A contact-phase pinch can give two different phase curves the same + // geographic horizon endpoint. There is no missing horizon interval to + // draw in that case; retaining the footprint's closure produces a small + // out-and-back loop instead of a boundary segment. + horizonConnectorDegenerateEndpointDistanceKM = 0.01 + // A start and end phase can meet the same rise/set horizon branch at + // slightly different instants. The missing boundary is then the short H=0 + // trajectory between those exact endpoints, not an instantaneous footprint + // closure. + horizonConnectorTemporalWindow = 2 * time.Minute + horizonConnectorTemporalDistanceKM = 250.0 +) + +// HorizonConnector 是在可见接触弧两端之间连接的同时刻月升或月落边界段。 +// HorizonConnector is a same-instant moonrise/moonset boundary segment that +// connects adjacent local-phase curves at the visible footprint opening or +// closing. It is not itself one of the start/greatest/end phase curves. +type HorizonConnector struct { + Direction basic.RiseSetDirection + Points []basic.OccultationPathPoint +} + +type horizonConnectorEndpoint struct { + point basic.OccultationPathPoint + phase basic.RiseSetPhase + direction basic.RiseSetDirection + curveIndex int + segmentIndex int + atStart bool + index int +} + +type horizonConnectorMatch struct { + endpoint horizonConnectorEndpoint + metric float64 +} + +type horizonConnectorCandidate struct { + connector HorizonConnector + metric float64 +} + +// HorizonConnectorSegments 仅提取端点同时与公开升落阶段端点重合的地平闭合段。 +// HorizonConnectorSegments extracts only the horizon closures whose open +// contact-limb endpoints tie two sampled rise/set phase endpoints together. +// Ordinary per-instant horizon closures are intentionally ignored so compact +// bands do not render as many parallel horizon stripes. Supplied north/south +// limits filter out closures that belong to a branch outside the exported +// limit envelope. +func HorizonConnectorSegments( + footprints []basic.OccultationFootprint, + curves []basic.OccultationRiseSetCurve, + limits ...[]basic.OccultationPathPoint, +) []HorizonConnector { + return horizonConnectorSegments(footprints, curves, false, limits...) +} + +// StarHorizonConnectorSegments 额外闭合点光源起止阶段在同一月球地平支路上相遇时的短时间缺口。 +// StarHorizonConnectorSegments additionally closes a short temporal gap where +// a point-source start and end phase meet the same lunar-horizon branch at +// nearby instants. Finite-disk planet contacts retain same-instant connectors. +func StarHorizonConnectorSegments( + footprints []basic.OccultationFootprint, + curves []basic.OccultationRiseSetCurve, + limits ...[]basic.OccultationPathPoint, +) []HorizonConnector { + return horizonConnectorSegments(footprints, curves, true, limits...) +} +func horizonConnectorSegments( + footprints []basic.OccultationFootprint, + curves []basic.OccultationRiseSetCurve, + includeTemporal bool, + limits ...[]basic.OccultationPathPoint, +) []HorizonConnector { + endpoints := horizonConnectorEndpoints(curves) + if len(endpoints) < 2 || len(footprints) == 0 { + return nil + } + best := make(map[string]horizonConnectorCandidate) + for _, footprint := range footprints { + if footprint.Closed { + continue + } + for boundaryIndex, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + continue + } + firstPoint, lastPoint := boundary[0], boundary[len(boundary)-1] + if DistanceKM(firstPoint, lastPoint) < horizonConnectorMinimumLengthKM { + continue + } + firstMatches := horizonConnectorEndpointMatches(firstPoint, endpoints) + lastMatches := horizonConnectorEndpointMatches(lastPoint, endpoints) + first, last, matched := selectHorizonConnectorMatch(firstMatches, lastMatches) + if !matched { + continue + } + points := horizonClosurePoints(footprint, boundaryIndex) + if len(points) < 2 { + continue + } + samePhaseFold := first.endpoint.phase == last.endpoint.phase && + DistanceKM(first.endpoint.point, last.endpoint.point) <= horizonConnectorSamePhaseEndpointDistanceKM + if samePhaseFold { + // Same-phase fold branches are stitched into one display curve by + // StitchedRiseSetCurveSegments. Emitting the sampled horizon suffix + // here would create a second dangling purple stroke at the fold. + continue + } + // The sparse footprint time can sit a fraction of a second between + // the independently refined phase-curve endpoints. Use those + // authoritative endpoints so the rendered lines meet exactly while + // retaining the already-computed horizon arc between them. + points[0] = last.endpoint.point + points[len(points)-1] = first.endpoint.point + points = trimHorizonConnectorSamples(points) + points = smoothHorizonConnectorJunctions(points) + if horizonConnectorIsDegenerate(points) { + continue + } + if len(points) <= 8 { + points = densifyHorizonConnectorPoints(points, horizonConnectorDensifyEdgeKM) + } + targetTime := first.endpoint.point.Time + if last.endpoint.point.Time.Before(targetTime) { + targetTime = last.endpoint.point.Time + } + firstIndex, lastIndex := first.endpoint.index, last.endpoint.index + if firstIndex > lastIndex { + firstIndex, lastIndex = lastIndex, firstIndex + } + key := string(first.endpoint.direction) + "/" + + targetTime.UTC().Round(time.Second).Format(time.RFC3339) + "/" + + strconv.Itoa(firstIndex) + "/" + strconv.Itoa(lastIndex) + metric := first.metric + last.metric + absDuration(first.endpoint.point.Time.Sub(last.endpoint.point.Time)).Seconds() + if current, ok := best[key]; !ok || metric < current.metric { + best[key] = horizonConnectorCandidate{ + connector: HorizonConnector{Direction: first.endpoint.direction, Points: points}, + metric: metric, + } + } + } + } + if includeTemporal { + for _, candidate := range temporalHorizonConnectorCandidates(footprints, endpoints) { + if current, ok := best[candidate.key]; !ok || candidate.value.metric < current.metric { + best[candidate.key] = candidate.value + } + } + } + if len(best) == 0 { + return nil + } + keys := make([]string, 0, len(best)) + for key := range best { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]HorizonConnector, 0, len(keys)) + for _, key := range keys { + result = append(result, best[key].connector) + } + return horizonConnectorsWithinLimits(result, limits) +} + +// horizonConnectorsWithinLimits 丢弃整段都远离导出南北限的连接段(换支伪影)。 +func horizonConnectorsWithinLimits( + connectors []HorizonConnector, + limits [][]basic.OccultationPathPoint, +) []HorizonConnector { + if len(connectors) == 0 || len(limits) == 0 { + return connectors + } + filtered := make([]HorizonConnector, 0, len(connectors)) + for _, connector := range connectors { + if horizonConnectorTouchesLimits(connector.Points, limits) { + filtered = append(filtered, connector) + } + } + return filtered +} + +func horizonConnectorTouchesLimits( + points []basic.OccultationPathPoint, + limits [][]basic.OccultationPathPoint, +) bool { + hasLimit := false + for _, limit := range limits { + if len(limit) < 2 { + continue + } + hasLimit = true + for index := 1; index < len(limit); index++ { + start := geodata.GeoPoint{Longitude: limit[index-1].Longitude, Latitude: limit[index-1].Latitude} + end := geodata.GeoPoint{Longitude: limit[index].Longitude, Latitude: limit[index].Latitude} + for _, point := range points { + if geoPointSegmentDistanceKM( + geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}, + start, end, + ) <= horizonConnectorMatchDistanceKM { + return true + } + } + } + } + // 限线为空时没有可用的过滤依据,保留原结果。 + return !hasLimit +} + +// smoothHorizonConnectorJunctions removes a short numerical corner where an +// exact phase endpoint replaces the nearest sparse horizon sample. The source +// arc is still the lunar-altitude-zero boundary; deleting only the offending +// interior sample preserves both exact endpoints and time order, while the +// caller's densification restores the display spacing. Real connector bends +// span a longer arc or have a larger deviation and are left unchanged. +func smoothHorizonConnectorJunctions(points []basic.OccultationPathPoint) []basic.OccultationPathPoint { + if len(points) < 4 { + return points + } + result := append([]basic.OccultationPathPoint(nil), points...) + const ( + maximumAdjacentEdgeKM = 140.0 + maximumChordKM = 140.0 + minimumTurnDegrees = 55.0 + minimumDeviationKM = 5.0 + maximumDeviationKM = 25.0 + ) + for pass := 0; pass < 2; pass++ { + changed := false + for index := 1; index+1 < len(result); index++ { + // Only the first/last interior sample can be the sparse sample + // replaced by an independently refined phase endpoint. Interior + // bends belong to the physical H=0 trajectory and must remain. + if index != 1 && index+2 != len(result) { + continue + } + first, middle, last := result[index-1], result[index], result[index+1] + if DistanceKM(first, middle) > maximumAdjacentEdgeKM || + DistanceKM(middle, last) > maximumAdjacentEdgeKM || + DistanceKM(first, last) > maximumChordKM { + continue + } + turn := 180 - occultationTurnAngleDegrees( + geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude}, + geodata.GeoPoint{Longitude: middle.Longitude, Latitude: middle.Latitude}, + geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude}, + ) + if turn < minimumTurnDegrees { + continue + } + deviation := occultationProjectedPointLineDistanceKM( + geodata.GeoPoint{Longitude: middle.Longitude, Latitude: middle.Latitude}, + geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude}, + geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude}, + ) + if deviation < minimumDeviationKM || deviation > maximumDeviationKM { + continue + } + result = append(result[:index], result[index+1:]...) + changed = true + break + } + if !changed { + break + } + } + return result +} + +func horizonConnectorIsDegenerate(points []basic.OccultationPathPoint) bool { + return len(points) >= 2 && + DistanceKM(points[0], points[len(points)-1]) <= horizonConnectorDegenerateEndpointDistanceKM +} + +type keyedHorizonConnectorCandidate struct { + key string + value horizonConnectorCandidate +} + +func temporalHorizonConnectorCandidates( + footprints []basic.OccultationFootprint, + endpoints []horizonConnectorEndpoint, +) []keyedHorizonConnectorCandidate { + result := make([]keyedHorizonConnectorCandidate, 0, 2) + for firstIndex, first := range endpoints { + if first.phase != basic.RiseSetPhaseStart && first.phase != basic.RiseSetPhaseEnd { + continue + } + for secondIndex := firstIndex + 1; secondIndex < len(endpoints); secondIndex++ { + second := endpoints[secondIndex] + if first.phase == second.phase || first.direction != second.direction || + (second.phase != basic.RiseSetPhaseStart && second.phase != basic.RiseSetPhaseEnd) { + continue + } + start, end := first, second + if end.point.Time.Before(start.point.Time) { + start, end = end, start + } + duration := end.point.Time.Sub(start.point.Time) + distance := DistanceKM(start.point, end.point) + if duration <= time.Second || duration > horizonConnectorTemporalWindow || + distance <= 1 || distance > horizonConnectorTemporalDistanceKM { + continue + } + points, metric, ok := temporalHorizonConnectorPoints(footprints, start.point, end.point) + if !ok { + continue + } + points = densifyHorizonConnectorPoints(points, horizonConnectorDensifyEdgeKM) + key := "temporal/" + string(start.direction) + "/" + + start.point.Time.UTC().Round(time.Second).Format(time.RFC3339) + "/" + + end.point.Time.UTC().Round(time.Second).Format(time.RFC3339) + result = append(result, keyedHorizonConnectorCandidate{ + key: key, + value: horizonConnectorCandidate{ + connector: HorizonConnector{Direction: start.direction, Points: points}, + metric: metric, + }, + }) + } + } + return result +} + +func temporalHorizonConnectorPoints( + footprints []basic.OccultationFootprint, + start, end basic.OccultationPathPoint, +) ([]basic.OccultationPathPoint, float64, bool) { + if !start.Time.Before(end.Time) { + return nil, 0, false + } + startAnchor := temporalHorizonEndpointAnchorDistance(footprints, start) + endAnchor := temporalHorizonEndpointAnchorDistance(footprints, end) + if startAnchor > horizonConnectorMatchDistanceKM || endAnchor > horizonConnectorMatchDistanceKM { + return nil, 0, false + } + points := []basic.OccultationPathPoint{start} + for _, footprint := range footprints { + if footprint.Closed || !footprint.Time.After(start.Time) || !footprint.Time.Before(end.Time) { + continue + } + fraction := float64(footprint.Time.Sub(start.Time)) / float64(end.Time.Sub(start.Time)) + target := interpolateOccultationGeoPoint( + geodata.GeoPoint{Longitude: start.Longitude, Latitude: start.Latitude}, + geodata.GeoPoint{Longitude: end.Longitude, Latitude: end.Latitude}, fraction, + ) + candidate, distance, ok := temporalHorizonBoundaryEndpointNear( + footprint, basic.OccultationPathPoint{Longitude: target.Longitude, Latitude: target.Latitude}, + ) + if !ok || distance > horizonConnectorMatchDistanceKM { + continue + } + points = append(points, candidate) + } + points = append(points, end) + sort.SliceStable(points[1:len(points)-1], func(first, second int) bool { + return points[first+1].Time.Before(points[second+1].Time) + }) + points = deduplicateTemporalHorizonConnectorPoints(points) + return points, startAnchor + endAnchor, len(points) >= 2 +} + +func temporalHorizonEndpointAnchorDistance( + footprints []basic.OccultationFootprint, + endpoint basic.OccultationPathPoint, +) float64 { + minimum := math.Inf(1) + for _, footprint := range footprints { + if footprint.Closed || absDuration(footprint.Time.Sub(endpoint.Time)) > horizonConnectorMatchWindow { + continue + } + _, distance, ok := temporalHorizonBoundaryEndpointNear(footprint, endpoint) + if ok { + minimum = math.Min(minimum, distance) + } + } + return minimum +} + +func temporalHorizonBoundaryEndpointNear( + footprint basic.OccultationFootprint, + target basic.OccultationPathPoint, +) (basic.OccultationPathPoint, float64, bool) { + best := basic.OccultationPathPoint{} + minimum := math.Inf(1) + for _, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + continue + } + for _, candidate := range []basic.OccultationPathPoint{boundary[0], boundary[len(boundary)-1]} { + if distance := DistanceKM(candidate, target); distance < minimum { + best, minimum = candidate, distance + } + } + } + return best, minimum, minimum < math.Inf(1) +} + +func deduplicateTemporalHorizonConnectorPoints( + points []basic.OccultationPathPoint, +) []basic.OccultationPathPoint { + if len(points) < 2 { + return points + } + result := make([]basic.OccultationPathPoint, 0, len(points)) + for _, point := range points { + if len(result) > 0 && (!point.Time.After(result[len(result)-1].Time) || + DistanceKM(point, result[len(result)-1]) <= 0.001) { + continue + } + result = append(result, point) + } + return result +} + +func densifyHorizonConnectorPoints( + points []basic.OccultationPathPoint, + maximumEdgeKM float64, +) []basic.OccultationPathPoint { + if len(points) < 2 || maximumEdgeKM <= 0 { + return points + } + result := make([]basic.OccultationPathPoint, 0, len(points)*2) + for index, point := range points { + result = append(result, point) + if index+1 >= len(points) { + continue + } + next := points[index+1] + distance := DistanceKM(point, next) + steps := int(math.Ceil(distance / maximumEdgeKM)) + if steps < 2 { + continue + } + for step := 1; step < steps; step++ { + fraction := float64(step) / float64(steps) + middle := interpolateOccultationGeoPoint( + geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}, + geodata.GeoPoint{Longitude: next.Longitude, Latitude: next.Latitude}, + fraction, + ) + result = append(result, basic.OccultationPathPoint{ + Time: point.Time.Add(time.Duration(float64(next.Time.Sub(point.Time)) * fraction)), + Longitude: middle.Longitude, + Latitude: middle.Latitude, + MoonAltitude: point.MoonAltitude + (next.MoonAltitude-point.MoonAltitude)*fraction, + WidthKM: point.WidthKM + (next.WidthKM-point.WidthKM)*fraction, + }) + } + } + return result +} + +func selectHorizonConnectorMatch( + firstMatches, lastMatches []horizonConnectorMatch, +) (horizonConnectorMatch, horizonConnectorMatch, bool) { + bestMetric := 0.0 + var bestFirst, bestLast horizonConnectorMatch + found := false + for _, first := range firstMatches { + for _, last := range lastMatches { + if first.endpoint.index == last.endpoint.index || + first.endpoint.direction != last.endpoint.direction || + absDuration(first.endpoint.point.Time.Sub(last.endpoint.point.Time)) > horizonConnectorMatchWindow { + continue + } + if first.endpoint.phase == last.endpoint.phase { + if !samePhaseHorizonConnectorAllowed(first.endpoint, last.endpoint) { + continue + } + } + metric := first.metric + last.metric + + absDuration(first.endpoint.point.Time.Sub(last.endpoint.point.Time)).Seconds() + if !found || metric < bestMetric { + bestFirst, bestLast, bestMetric, found = first, last, metric, true + } + } + } + return bestFirst, bestLast, found +} + +// horizonClosurePoints returns the closing lunar-horizon arc stored after an +// open contact-limb boundary in the corresponding visible polygon. The two +// endpoint matches are deliberately performed on Boundaries by the caller: +// those are the contact-phase points, while this suffix is the physical arc +// that must be drawn between them. +func horizonClosurePoints( + footprint basic.OccultationFootprint, + boundaryIndex int, +) []basic.OccultationPathPoint { + if boundaryIndex < 0 || boundaryIndex >= len(footprint.Boundaries) || + boundaryIndex >= len(footprint.Polygons) { + return nil + } + boundary := footprint.Boundaries[boundaryIndex] + polygon := footprint.Polygons[boundaryIndex] + if len(boundary) < 2 || len(polygon) <= len(boundary) { + return nil + } + // Footprint construction copies the contact-limb boundary verbatim before + // appending its horizon closure. Reject a mismatched polygon rather than + // slicing an unrelated ring by position. + for index := range boundary { + if DistanceKM(boundary[index], polygon[index]) > horizonConnectorBoundaryShiftToleranceKM { + return nil + } + } + points := make([]basic.OccultationPathPoint, 0, 1+len(polygon)-len(boundary)) + points = append(points, boundary[len(boundary)-1]) + for _, point := range polygon[len(boundary):] { + if DistanceKM(points[len(points)-1], point) <= 1e-6 { + continue + } + points = append(points, point) + } + if len(points) < 2 || DistanceKM(points[len(points)-1], boundary[0]) > horizonConnectorBoundaryShiftToleranceKM { + return nil + } + points[len(points)-1] = boundary[0] + return points +} + +func horizonConnectorEndpoints(curves []basic.OccultationRiseSetCurve) []horizonConnectorEndpoint { + endpoints := make([]horizonConnectorEndpoint, 0, len(curves)*4) + for curveIndex, curve := range curves { + for segmentIndex, segment := range curve.Segments { + if len(segment) == 0 { + continue + } + endpoints = append(endpoints, horizonConnectorEndpoint{ + point: segment[0], phase: curve.Phase, direction: curve.Direction, + curveIndex: curveIndex, segmentIndex: segmentIndex, atStart: true, index: len(endpoints), + }) + if len(segment) > 1 { + endpoints = append(endpoints, horizonConnectorEndpoint{ + point: segment[len(segment)-1], phase: curve.Phase, direction: curve.Direction, + curveIndex: curveIndex, segmentIndex: segmentIndex, atStart: false, index: len(endpoints), + }) + } + } + } + return endpoints +} + +// samePhaseHorizonConnectorAllowed 允许同相位的独立支路缺口配对;≤5 km 的折叠端点也 +// 允许进入选择,以便否决该边界上的其他配对(真正的丢弃发生在 samePhaseFold)。 +func samePhaseHorizonConnectorAllowed( + first, last horizonConnectorEndpoint, +) bool { + if first.curveIndex != last.curveIndex || first.segmentIndex == last.segmentIndex || + first.atStart != last.atStart { + return false + } + return DistanceKM(first.point, last.point) <= horizonConnectorSamePhaseBranchDistanceKM +} + +// Exact phase endpoints can lie inside the sparse footprint horizon arc. +// Keep only the source samples between them, in their original order. +func trimHorizonConnectorSamples(points []basic.OccultationPathPoint) []basic.OccultationPathPoint { + if len(points) < 3 || DistanceKM(points[0], points[len(points)-1]) > horizonConnectorSamePhaseBranchDistanceKM { + return points + } + first, last := points[0], points[len(points)-1] + const radians = math.Pi / 180 + sinLat, cosLat := math.Sincos(first.Latitude * radians) + components := func(point basic.OccultationPathPoint) (float64, float64, float64) { + latitude, longitude := point.Latitude*radians, (point.Longitude-first.Longitude)*radians + east := math.Cos(latitude) * math.Sin(longitude) + north := cosLat*math.Sin(latitude) - sinLat*math.Cos(latitude)*math.Cos(longitude) + radial := sinLat*math.Sin(latitude) + cosLat*math.Cos(latitude)*math.Cos(longitude) + return east, north, radial + } + east, north, radial := components(last) + length := math.Hypot(east, north) + if length <= 1e-12 { + return points + } + east, north = east/length, north/length + total := math.Atan2(length, radial) + result := make([]basic.OccultationPathPoint, 1, len(points)) + result[0] = first + previous := 0.0 + for _, point := range points[1 : len(points)-1] { + x, y, z := components(point) + progress := math.Atan2(x*east+y*north, z) + if progress > previous && progress < total { + result = append(result, point) + previous = progress + } + } + return append(result, last) +} + +func horizonConnectorEndpointMatches( + point basic.OccultationPathPoint, + endpoints []horizonConnectorEndpoint, +) []horizonConnectorMatch { + matches := make([]horizonConnectorMatch, 0, len(endpoints)) + for _, endpoint := range endpoints { + deltaTime := absDuration(point.Time.Sub(endpoint.point.Time)) + if deltaTime > horizonConnectorMatchWindow { + continue + } + distance := DistanceKM(point, endpoint.point) + if distance > horizonConnectorMatchDistanceKM { + continue + } + metric := distance + deltaTime.Seconds()*5 + matches = append(matches, horizonConnectorMatch{endpoint: endpoint, metric: metric}) + } + sort.SliceStable(matches, func(first, second int) bool { + return matches[first].metric < matches[second].metric + }) + if len(matches) == 0 { + return nil + } + return matches +} diff --git a/internal/occultationgeo/horizon_connector_test.go b/internal/occultationgeo/horizon_connector_test.go new file mode 100644 index 0000000..b863033 --- /dev/null +++ b/internal/occultationgeo/horizon_connector_test.go @@ -0,0 +1,387 @@ +package occultationgeo + +import ( + "math" + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +func TestHorizonConnectorTrimsSamplesOutsideExactEndpoints(t *testing.T) { + for _, offset := range []float64{0, 177, -182} { + var points []basic.OccultationPathPoint + for _, longitude := range []float64{1, 0, 2, 3, 5, 4} { + points = append(points, basic.OccultationPathPoint{Longitude: math.Remainder(longitude+offset, 360), Latitude: 0}) + } + trimmed := trimHorizonConnectorSamples(points) + want := []basic.OccultationPathPoint{points[0], points[2], points[3], points[5]} + if len(trimmed) != len(want) { + t.Fatalf("offset=%g retained %d points, want %d", offset, len(trimmed), len(want)) + } + for i := range want { + if trimmed[i] != want[i] { + t.Fatalf("offset=%g vertex %d changed: got %+v want %+v", offset, i, trimmed[i], want[i]) + } + } + } +} + +func TestHorizonConnectorSegmentsUsesHorizonClosureAndPhaseEndpoints(t *testing.T) { + eventTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC) + contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0} + contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 2} + phaseStart := basic.OccultationPathPoint{Time: eventTime.Add(time.Second), Longitude: -0.1, Latitude: 2} + phaseGreatest := basic.OccultationPathPoint{Time: eventTime.Add(-time.Second), Longitude: -0.1, Latitude: 0} + footprint := basic.OccultationFootprint{ + Boundaries: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 4, Latitude: 1}, + contactEnd, + }}, + Polygons: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 4, Latitude: 1}, + contactEnd, + {Time: eventTime, Longitude: 1, Latitude: 2}, + {Time: eventTime, Longitude: 1, Latitude: 1}, + {Time: eventTime, Longitude: 1, Latitude: 0}, + contactStart, + }}, + } + curves := []basic.OccultationRiseSetCurve{ + { + Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{{phaseStart, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 3}}}, + }, + { + Phase: basic.RiseSetPhaseGreatest, Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{{phaseGreatest, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: -1}}}, + }, + } + + connectors := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves) + if len(connectors) != 1 { + t.Fatalf("connectors=%d, want one", len(connectors)) + } + points := connectors[0].Points + if len(points) <= 4 { + t.Fatalf("connector points=%d, want a densified horizon closure", len(points)) + } + if points[0] != phaseStart || points[len(points)-1] != phaseGreatest { + t.Fatalf("connector endpoints=(%+v, %+v), want phase endpoints (%+v, %+v)", + points[0], points[len(points)-1], phaseStart, phaseGreatest) + } + for _, point := range points { + if point.Longitude == 4 { + t.Fatal("connector contains the contact-limb detour") + } + } +} + +func TestSmoothHorizonConnectorJunctionsRemovesShortEndpointCorner(t *testing.T) { + points := []basic.OccultationPathPoint{ + {Longitude: -49.9687087, Latitude: 22.7599834}, + {Longitude: -49.7995044, Latitude: 22.6531000}, + {Longitude: -49.8520050, Latitude: 22.4586340}, + {Longitude: -49.9043580, Latitude: 22.2641500}, + } + cleaned := smoothHorizonConnectorJunctions(points) + if len(cleaned) != len(points)-1 { + t.Fatalf("cleaned point count=%d, want %d: %#v", len(cleaned), len(points)-1, cleaned) + } + if cleaned[0] != points[0] || cleaned[len(cleaned)-1] != points[len(points)-1] { + t.Fatalf("connector endpoints changed: first=%+v last=%+v", cleaned[0], cleaned[len(cleaned)-1]) + } +} + +func TestSmoothHorizonConnectorJunctionsKeepsBroadBend(t *testing.T) { + points := []basic.OccultationPathPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 0.2, Latitude: 0.1}, + {Longitude: 1.0, Latitude: 0.8}, + {Longitude: 2.0, Latitude: 1.8}, + } + cleaned := smoothHorizonConnectorJunctions(points) + if len(cleaned) != len(points) { + t.Fatalf("broad physical bend was changed: got %d points, want %d", len(cleaned), len(points)) + } +} + +func TestHorizonConnectorSegmentsSmoothsExactEndpointJunctions(t *testing.T) { + cases := []struct { + name string + planet basic.OccultationPlanet + start time.Time + target time.Time + }{ + {"venus-1027", basic.OccultationVenus, time.Date(1027, time.March, 13, 0, 0, 0, 0, time.UTC), time.Date(1027, time.March, 13, 22, 11, 50, 833970457, time.UTC)}, + {"saturn-2629", basic.OccultationSaturn, time.Date(2629, time.December, 30, 0, 0, 0, 0, time.UTC), time.Date(2629, time.December, 30, 18, 28, 18, 614121973, time.UTC)}, + } + for _, sample := range cases { + t.Run(sample.name, func(t *testing.T) { + paths, err := basic.FindPlanetOccultationPaths(sample.start.Add(-12*time.Hour), sample.start.Add(36*time.Hour), sample.planet, basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, Algorithm: basic.OccultationPathAlgorithmExact, + }) + if err != nil || len(paths) == 0 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err) + } + path := paths[0] + for _, candidate := range paths[1:] { + if absDuration(candidate.Greatest.Time.Sub(sample.target)) < absDuration(path.Greatest.Time.Sub(sample.target)) { + path = candidate + } + } + curvesByBand := []struct { + name string + footprints []basic.OccultationFootprint + curves []basic.OccultationRiseSetCurve + }{ + {"partial", path.PartialBandFootprints, path.RiseSetCurves}, + {"total", path.TotalBandFootprints, path.TotalRiseSetCurves}, + } + for _, band := range curvesByBand { + for connectorIndex, connector := range HorizonConnectorSegments(band.footprints, band.curves) { + for index := 1; index+1 < len(connector.Points); index++ { + first, middle, last := connector.Points[index-1], connector.Points[index], connector.Points[index+1] + turn := 180 - occultationTurnAngleDegrees( + geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude}, + geodata.GeoPoint{Longitude: middle.Longitude, Latitude: middle.Latitude}, + geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude}, + ) + deviation := occultationProjectedPointLineDistanceKM( + geodata.GeoPoint{Longitude: middle.Longitude, Latitude: middle.Latitude}, + geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude}, + geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude}, + ) + if turn >= 55 && deviation >= 5 && deviation <= 25 && + DistanceKM(first, middle) <= 75 && DistanceKM(middle, last) <= 75 && DistanceKM(first, last) <= 55 { + t.Fatalf("%s connector %d retains short endpoint corner at %d: turn=%.1f deviation=%.1f points=%+v/%+v/%+v", band.name, connectorIndex, index, turn, deviation, first, middle, last) + } + } + } + } + }) + } +} + +func TestHorizonConnectorSegmentsSkipsSamePhaseFoldBranches(t *testing.T) { + eventTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC) + contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0} + contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 1} + terminal := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0.5} + footprint := basic.OccultationFootprint{ + Boundaries: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 0.5}, + contactEnd, + }}, + Polygons: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 0.5}, + contactEnd, + {Time: eventTime, Longitude: 1, Latitude: 1}, + {Time: eventTime, Longitude: 1, Latitude: 0}, + contactStart, + }}, + } + curves := []basic.OccultationRiseSetCurve{{ + Phase: basic.RiseSetPhaseEnd, Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{ + {terminal, {Time: eventTime.Add(time.Minute), Longitude: 1, Latitude: 1}}, + {terminal, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 1}}, + }, + }} + + connectors := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves) + if len(connectors) != 0 { + t.Fatalf("connectors=%d, want no duplicate terminal closure", len(connectors)) + } +} + +func TestHorizonConnectorSegmentsSkipsCoincidentDifferentPhaseEndpoints(t *testing.T) { + eventTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC) + contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0} + contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 1} + shared := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0.5} + footprint := basic.OccultationFootprint{ + Boundaries: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 0.5}, + contactEnd, + }}, + Polygons: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 0.5}, + contactEnd, + {Time: eventTime, Longitude: 1, Latitude: 1}, + shared, + {Time: eventTime, Longitude: 1, Latitude: 0}, + contactStart, + }}, + } + curves := []basic.OccultationRiseSetCurve{ + {Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{{shared, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 1}}}}, + {Phase: basic.RiseSetPhaseGreatest, Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{{shared, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: -1}}}}, + } + + if connectors := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves); len(connectors) != 0 { + t.Fatalf("connectors=%d, want no zero-length phase pinch loop", len(connectors)) + } +} + +func TestHorizonConnectorSegmentsClosesSamePhaseSeparatedBranchOpening(t *testing.T) { + eventTime := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC) + contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0} + contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 3} + branchStart := basic.OccultationPathPoint{Time: eventTime, Longitude: -0.05, Latitude: 0} + branchEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: -0.05, Latitude: 3} + footprint := basic.OccultationFootprint{ + Boundaries: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 1.5}, + contactEnd, + }}, + Polygons: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 1.5}, + contactEnd, + {Time: eventTime, Longitude: 1, Latitude: 3}, + {Time: eventTime, Longitude: 1, Latitude: 0}, + contactStart, + }}, + } + curves := []basic.OccultationRiseSetCurve{{ + Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{ + {branchStart, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: -1}}, + {branchEnd, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 4}}, + }, + }} + + connectors := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves) + if len(connectors) != 1 { + t.Fatalf("connectors=%d, want one same-phase branch-opening closure", len(connectors)) + } + points := connectors[0].Points + if len(points) <= 4 { + t.Fatalf("connector points=%d, want a densified horizon closure", len(points)) + } + if !sameTestOccultationPoint(points[0], branchEnd) || !sameTestOccultationPoint(points[len(points)-1], branchStart) { + t.Fatalf("connector endpoints=(%+v, %+v), want same-phase branch endpoints (%+v, %+v)", + points[0], points[len(points)-1], branchEnd, branchStart) + } +} + +func TestHorizonConnectorSegmentsClosesShortTemporalHorizonGap(t *testing.T) { + startTime := time.Date(2023, time.September, 21, 10, 1, 49, 0, time.UTC) + endTime := startTime.Add(time.Minute) + start := basic.OccultationPathPoint{Time: startTime, Longitude: 10, Latitude: 20} + end := basic.OccultationPathPoint{Time: endTime, Longitude: 10.2, Latitude: 20.1} + farStart := basic.OccultationPathPoint{Time: startTime, Longitude: 30, Latitude: 20} + farEnd := basic.OccultationPathPoint{Time: endTime, Longitude: 30, Latitude: 20} + footprints := []basic.OccultationFootprint{ + { + Time: startTime, + Boundaries: [][]basic.OccultationPathPoint{{start, farStart}}, + Polygons: [][]basic.OccultationPathPoint{{ + start, farStart, + {Time: startTime, Longitude: 30, Latitude: 19}, + {Time: startTime, Longitude: 10, Latitude: 19}, start, + }}, + }, + { + Time: endTime, + Boundaries: [][]basic.OccultationPathPoint{{end, farEnd}}, + Polygons: [][]basic.OccultationPathPoint{{ + end, farEnd, + {Time: endTime, Longitude: 30, Latitude: 19}, + {Time: endTime, Longitude: 10.2, Latitude: 19}, end, + }}, + }, + } + curves := []basic.OccultationRiseSetCurve{ + { + Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionSet, + Segments: [][]basic.OccultationPathPoint{{ + {Time: startTime.Add(-10 * time.Minute), Longitude: 0, Latitude: 30}, start, + }}, + }, + { + Phase: basic.RiseSetPhaseEnd, Direction: basic.RiseSetDirectionSet, + Segments: [][]basic.OccultationPathPoint{{ + end, {Time: endTime.Add(10 * time.Minute), Longitude: 0, Latitude: 30}, + }}, + }, + } + + if connectors := HorizonConnectorSegments(footprints, curves); len(connectors) != 0 { + t.Fatalf("finite-disk connectors=%d, want no point-source temporal connector", len(connectors)) + } + connectors := StarHorizonConnectorSegments(footprints, curves) + if len(connectors) != 1 { + t.Fatalf("connectors=%d, want one temporal horizon connector", len(connectors)) + } + points := connectors[0].Points + if len(points) < 2 || points[0] != start || points[len(points)-1] != end { + t.Fatalf("connector endpoints=%+v -> %+v, want %+v -> %+v", + points[0], points[len(points)-1], start, end) + } + for index := 1; index < len(points); index++ { + if !points[index].Time.After(points[index-1].Time) { + t.Fatalf("connector time is not strictly increasing at %d", index) + } + } +} + +func TestHorizonConnectorSegmentsMars20250729OneMinuteRiseSetStepKeepsOpeningClosure(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationMars, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + connectors := HorizonConnectorSegments(paths[0].PartialBandFootprints, paths[0].RiseSetCurves) + foundOpening := false + for _, connector := range connectors { + if connector.Direction != basic.RiseSetDirectionRise || len(connector.Points) < 2 { + continue + } + first := connector.Points[0] + last := connector.Points[len(connector.Points)-1] + if !mars20250729OpeningFoldEndpoint(first) || !mars20250729OpeningFoldEndpoint(last) { + continue + } + chord := DistanceKM(first, last) + if chord < 250 || chord > 650 { + t.Fatalf("opening connector chord %.1f km, want the missing same-phase branch gap", chord) + } + foundOpening = true + break + } + if !foundOpening { + t.Fatal("2025-07-29 Mars one-minute rise/set curves are missing the opening horizon closure") + } +} + +func sameTestOccultationPoint(first, second basic.OccultationPathPoint) bool { + return first.Time.Equal(second.Time) && + first.Longitude == second.Longitude && + first.Latitude == second.Latitude +} + +func mars20250729OpeningFoldEndpoint(point basic.OccultationPathPoint) bool { + return point.Longitude >= -128.5 && point.Longitude <= -126 && + point.Latitude >= -48.5 && point.Latitude <= -42 +} diff --git a/internal/occultationgeo/perf_bench_test.go b/internal/occultationgeo/perf_bench_test.go new file mode 100644 index 0000000..5ca668f --- /dev/null +++ b/internal/occultationgeo/perf_bench_test.go @@ -0,0 +1,162 @@ +package occultationgeo + +import ( + "math" + "sync" + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +var ( + saturn20250105Once sync.Once + saturn20250105Paths []basic.PlanetOccultationPath +) + +func saturn20250105Path(tb testing.TB) basic.PlanetOccultationPath { + tb.Helper() + saturn20250105Once.Do(func() { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone) + paths, err := basic.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), basic.OccultationSaturn, + basic.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + tb.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err) + } + saturn20250105Paths = paths + }) + return saturn20250105Paths[0] +} + +func BenchmarkVisibleBandPolygonsSaturn20250105(b *testing.B) { + path := saturn20250105Path(b) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if _, _, err := VisibleBandPolygonsFromContours( + path.PartialBandFootprints, path.PartialBandContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkVisibleBandPolygonsAnalyticSaturn20250105(b *testing.B) { + path := saturn20250105Path(b) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if _, _, err := VisibleBandPolygonsFromAnalyticContours( + path.PartialBandFootprints, path.PartialBandContours, path.PartialVisibilityContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkFootprintSweepPolygonsSaturn20250105(b *testing.B) { + path := saturn20250105Path(b) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if _, err := footprintSweepPolygons( + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, + ); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkFootprintOpenSweepSaturn20250105(b *testing.B) { + path := saturn20250105Path(b) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if _, err := footprintOpenSweepPolygons(path.PartialBandFootprints); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkFootprintVisibleUnionSaturn20250105(b *testing.B) { + path := saturn20250105Path(b) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if polygons := footprintVisibleUnionPolygons(path.PartialBandFootprints); len(polygons) == 0 { + b.Fatal("empty visible union") + } + } +} + +func occultationBenchChainPolygons(count, points int) [][]geodata.GeoPoint { + polygons := make([][]geodata.GeoPoint, 0, count) + for index := 0; index < count; index++ { + center := geodata.GeoPoint{ + Longitude: -0.5 + float64(index)*1.5, + Latitude: 40, + } + ring := make([]geodata.GeoPoint, 0, points+1) + for step := 0; step <= points; step++ { + angle := 2 * math.Pi * float64(step) / float64(points) + ring = append(ring, geodata.GeoPoint{ + Longitude: center.Longitude + 0.5*math.Cos(angle), + Latitude: center.Latitude + 0.5*math.Sin(angle), + }) + } + polygons = append(polygons, ring) + } + return polygons +} + +func BenchmarkMergeTouchingVisiblePolygonsChain(b *testing.B) { + b.Run("count=12/points=16", func(b *testing.B) { + for iteration := 0; iteration < b.N; iteration++ { + polygons := occultationBenchChainPolygons(12, 16) + if merged := mergeTouchingVisiblePolygons(polygons); len(merged) != 1 { + b.Fatalf("merged=%d, want one face", len(merged)) + } + } + }) + b.Run("count=24/points=16", func(b *testing.B) { + for iteration := 0; iteration < b.N; iteration++ { + polygons := occultationBenchChainPolygons(24, 16) + if merged := mergeTouchingVisiblePolygons(polygons); len(merged) != 1 { + b.Fatalf("merged=%d, want one face", len(merged)) + } + } + }) +} + +func occultationBenchConstrainInput(points int) (parent, child [][]geodata.GeoPoint) { + ring := make([]geodata.GeoPoint, 0, points+1) + childRing := make([]geodata.GeoPoint, 0, points+1) + for step := 0; step <= points; step++ { + angle := 2 * math.Pi * float64(step) / float64(points) + longitude := -70 + 20*math.Cos(angle) + latitude := -40 + 8*math.Sin(angle) + ring = append(ring, geodata.GeoPoint{Longitude: longitude, Latitude: latitude}) + childRing = append(childRing, geodata.GeoPoint{ + Longitude: longitude + 0.02*math.Cos(angle), + Latitude: latitude + 0.02*math.Sin(angle), + }) + } + return [][]geodata.GeoPoint{ring}, [][]geodata.GeoPoint{childRing} +} + +func BenchmarkConstrainPolygonsWithinSmallBreach(b *testing.B) { + b.Run("points=512", func(b *testing.B) { + parent, child := occultationBenchConstrainInput(512) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + if repaired := ConstrainPolygonsWithin(parent, child); len(repaired) == 0 { + b.Fatal("empty repair") + } + } + }) +} diff --git a/internal/occultationgeo/reference_test.go b/internal/occultationgeo/reference_test.go new file mode 100644 index 0000000..8e7d404 --- /dev/null +++ b/internal/occultationgeo/reference_test.go @@ -0,0 +1,324 @@ +package occultationgeo + +// 本文件保存改动前的参考实现,仅用于差分对照:新实现必须在相同随机输入上给出逐点相同的输出。 + +import ( + "fmt" + "math" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +func mergeTouchingVisiblePolygonsReference(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + const ( + touchingDistanceKM = 60.0 + minimumBridgeHalfDeg = 0.01 + ) + for len(polygons) > 1 { + firstIndex, secondIndex := -1, -1 + var touching, leftTouch, rightTouch geodata.GeoPoint + for first := 0; first < len(polygons) && firstIndex < 0; first++ { + for second := first + 1; second < len(polygons) && firstIndex < 0; second++ { + for _, left := range polygons[first] { + for _, right := range polygons[second] { + if geoDistanceKM(left, right) > touchingDistanceKM { + continue + } + firstIndex, secondIndex = first, second + leftTouch, rightTouch = left, right + touching = geodata.GeoPoint{ + Longitude: left.Longitude + math.Remainder(right.Longitude-left.Longitude, 360)/2, + Latitude: (left.Latitude + right.Latitude) / 2, + } + break + } + if firstIndex >= 0 { + break + } + } + } + } + if firstIndex < 0 || secondIndex < 0 { + break + } + bridgeHalfDeg := minimumBridgeHalfDeg + if gap := geoDistanceKM(leftTouch, rightTouch) / EarthRadiusKM * 180 / math.Pi; gap/2+0.002 > bridgeHalfDeg { + bridgeHalfDeg = gap/2 + 0.002 + } + bridge := []geodata.GeoPoint{ + {Longitude: touching.Longitude - bridgeHalfDeg, Latitude: touching.Latitude - bridgeHalfDeg}, + {Longitude: touching.Longitude + bridgeHalfDeg, Latitude: touching.Latitude - bridgeHalfDeg}, + {Longitude: touching.Longitude + bridgeHalfDeg, Latitude: touching.Latitude + bridgeHalfDeg}, + {Longitude: touching.Longitude - bridgeHalfDeg, Latitude: touching.Latitude + bridgeHalfDeg}, + } + leftPolygon := append([]geodata.GeoPoint(nil), polygons[firstIndex]...) + rightPolygon := append([]geodata.GeoPoint(nil), polygons[secondIndex]...) + for index, point := range leftPolygon { + if point == leftTouch { + leftPolygon[index] = touching + break + } + } + for index, point := range rightPolygon { + if point == rightTouch { + rightPolygon[index] = touching + break + } + } + pair, err := geodata.UnionPolygons([][]geodata.GeoPoint{ + leftPolygon, rightPolygon, bridge, + }) + if err != nil || len(pair) != 1 { + pair, err = geodata.UnionPolygons([][]geodata.GeoPoint{ + leftPolygon, rightPolygon, + }) + } + if err != nil || len(pair) != 1 { + break + } + next := make([][]geodata.GeoPoint, 0, len(polygons)-1) + for index, polygon := range polygons { + if index == firstIndex { + next = append(next, pair[0]) + continue + } + if index == secondIndex { + continue + } + next = append(next, polygon) + } + polygons = next + } + return polygons +} + +func removeTinyPolygonComponentsReference(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint { + if len(polygons) < 2 { + return polygons + } + areas := make([]float64, len(polygons)) + maximum := 0.0 + for index, polygon := range polygons { + areas[index] = math.Abs(geoRingArea(polygon)) + if areas[index] > maximum { + maximum = areas[index] + } + } + if maximum <= 0 || !finiteGeo(maximum) { + return polygons + } + threshold := maximum * 1e-4 + filtered := make([][]geodata.GeoPoint, 0, len(polygons)) + for index, polygon := range polygons { + if areas[index] >= threshold { + filtered = append(filtered, polygon) + } + } + if len(filtered) == 0 { + return polygons[:1] + } + return filtered +} + +func pairedBoundaryPolygonsReference( + first, second []basic.OccultationPathPoint, +) [][]geodata.GeoPoint { + count := len(first) + if len(second) < count { + count = len(second) + } + polygons := make([][]geodata.GeoPoint, 0, count) + pendingBranch := false + for index := 1; index < count; index++ { + firstChanged := BoundaryBranchChanged(first[index-1], first[index]) + secondChanged := BoundaryBranchChanged(second[index-1], second[index]) + if pendingBranch { + if firstChanged || secondChanged { + pendingBranch = false + } + if firstChanged || secondChanged || pendingBranch { + continue + } + } + if firstChanged != secondChanged { + pendingBranch = true + continue + } + if firstChanged { // both sides changed at the same transition + continue + } + previousFirst, currentFirst := first[index-1], first[index] + previousSecond, currentSecond := second[index-1], second[index] + polygons = append(polygons, []geodata.GeoPoint{ + {Longitude: previousFirst.Longitude, Latitude: previousFirst.Latitude}, + {Longitude: currentFirst.Longitude, Latitude: currentFirst.Latitude}, + {Longitude: currentSecond.Longitude, Latitude: currentSecond.Latitude}, + {Longitude: previousSecond.Longitude, Latitude: previousSecond.Latitude}, + }) + } + return polygons +} + +func constrainPolygonsWithinReference( + parent, child [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + if len(parent) == 0 || len(child) == 0 { + return child + } + initialMissDistance := geodata.SphericalPolygonsPathMissDistanceKM(parent, child, true) + if initialMissDistance <= 0 { + return child + } + const maximumRepairDistanceKM = 100.0 + const maximumResidualMissDistanceKM = 10.0 + if initialMissDistance > maximumRepairDistanceKM { + return child + } + result := make([][]geodata.GeoPoint, len(child)) + for index, source := range child { + if len(source) < 4 { + if len(openFootprintRing(source)) >= 3 && math.Abs(geoRingArea(source)) > 1e-12 { + result[index] = append([]geodata.GeoPoint(nil), source...) + } + continue + } + ring := append([]geodata.GeoPoint(nil), source...) + closed := geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) + limit := len(ring) + if closed { + limit-- + } + containment := geodata.SphericalPolygonsContainPoints(parent, ring[:limit]) + for pointIndex := 0; pointIndex < limit; pointIndex++ { + point := ring[pointIndex] + if containment[pointIndex] { + continue + } + nearest, distance := nearestPolygonBoundaryPoint(parent, point) + if distance <= maximumRepairDistanceKM { + ring[pointIndex] = nearest + } + } + if closed { + ring[len(ring)-1] = ring[0] + } + result[index] = ring + } + result = usableOccultationPolygons(result) + if geodata.SphericalPolygonsPathMissDistanceKM(parent, result, true) <= 0 { + return result + } + // Vertex-only repair cannot see a child edge whose endpoints are both + // inside the parent while its great-circle midpoint crosses outside. Split + // the repaired ring at the same projected spacing used by output geometry, + // then apply the local vertex snap to those newly exposed edge probes. + densified := densifyOccultationPolygons(result, 10) + for pass := 0; pass < 6; pass++ { + changed := false + for index, source := range densified { + ring := append([]geodata.GeoPoint(nil), source...) + closed := len(ring) > 1 && geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) + limit := len(ring) + if closed { + limit-- + } + containment := geodata.SphericalPolygonsContainPoints(parent, ring[:limit]) + for pointIndex := 0; pointIndex < limit; pointIndex++ { + point := ring[pointIndex] + if containment[pointIndex] { + continue + } + nearest, distance := nearestPolygonBoundaryPoint(parent, point) + if distance <= maximumRepairDistanceKM { + ring[pointIndex] = nearest + changed = true + } + } + if closed { + ring[len(ring)-1] = ring[0] + } + for { + cleaned := removeDirectProjectedSharpCorners(ring, 20, 30) + cleaned = removeOccultationSharpCorners(cleaned, 20, 30) + if len(cleaned) == len(ring) { + break + } + ring = cleaned + } + if closed && len(ring) > 1 && !geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) { + ring = append(ring, ring[0]) + } + densified[index] = ring + } + densified = usableOccultationPolygons(densified) + if geodata.SphericalPolygonsPathMissDistanceKM(parent, densified, true) <= maximumResidualMissDistanceKM { + return densified + } + if !changed { + break + } + } + return child +} + +func footprintOpenSweepPolygonsWithTransitionsReference( + footprints []basic.OccultationFootprint, + includeTransitions bool, +) ([][]geodata.GeoPoint, error) { + polygons := make([][]geodata.GeoPoint, 0, 2) + for start := 0; start < len(footprints); { + for start < len(footprints) && footprints[start].Closed { + start++ + } + if start == len(footprints) { + break + } + end := start + for end < len(footprints) && !footprints[end].Closed { + end++ + } + + samples := make([]geodata.OpenBoundarySweepSample, 0, end-start+2) + if includeTransitions && start > 0 { + boundary, ok := footprintTransitionBoundary(footprints[start-1], footprints[start]) + if ok { + samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: [][]geodata.GeoPoint{boundary}}) + } + } + for index := start; index < end; index++ { + samples = append(samples, geodata.OpenBoundarySweepSample{ + Boundaries: footprintGeoBoundaries(footprints[index]), + }) + } + if includeTransitions && end < len(footprints) { + boundary, ok := footprintTransitionBoundary(footprints[end], footprints[end-1]) + if ok { + samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: [][]geodata.GeoPoint{boundary}}) + } + } + group, err := geodata.OpenBoundarySweep(samples) + if err != nil && includeTransitions { + // At an open/closed transition the exact transition arc can be + // numerically coincident with the first ribbon edge. Retry the same + // physical run without that synthetic endpoint; the sampled open arcs + // still provide both endpoint tracks and avoid a false diagonal cap. + bareSamples := make([]geodata.OpenBoundarySweepSample, 0, end-start) + for index := start; index < end; index++ { + bareSamples = append(bareSamples, geodata.OpenBoundarySweepSample{ + Boundaries: footprintGeoBoundaries(footprints[index]), + }) + } + group, err = geodata.OpenBoundarySweep(bareSamples) + } + if err != nil { + return nil, err + } + polygons = append(polygons, group...) + start = end + } + if len(polygons) == 0 { + return nil, fmt.Errorf("open footprint samples contain no usable sweep") + } + return polygons, nil +} diff --git a/internal/occultationgeo/section12_regression_test.go b/internal/occultationgeo/section12_regression_test.go new file mode 100644 index 0000000..1f72a10 --- /dev/null +++ b/internal/occultationgeo/section12_regression_test.go @@ -0,0 +1,197 @@ +package occultationgeo + +import ( + "errors" + "testing" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +// 分裂轮廓场景的接触线网互不连续(离扫掠边界上千公里),无法充当见证;该分支的 +// 权威性来自"单分量 + 同一批瞬时可见面并集"的构造契约,此处把这条契约钉住。 +func TestSplitContourBandUsesSingleComponentSweepContract(t *testing.T) { + path := saturn20250105Path(t) + if len(path.PartialBandContours) <= 2 { + t.Fatalf("contours=%d, want the split-contour branch input", len(path.PartialBandContours)) + } + swept, err := footprintSweepPolygons( + path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit, + ) + if err != nil { + t.Fatal(err) + } + if len(swept) != 1 { + t.Fatalf("sweep components=%d, want the single-component contract", len(swept)) + } + if occultationSweepWitnessedByContactContours(swept, path.PartialBandContours) { + t.Fatal("fragmented contact contours unexpectedly witness the sweep; revisit the split-contour branch") + } + polygons, authoritative, err := VisibleBandPolygonsFromContours( + path.PartialBandFootprints, path.PartialBandContours, + path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, + ) + if err != nil || !authoritative || len(polygons) != 1 { + t.Fatalf("split-contour band polygons=%d authoritative=%v err=%v", len(polygons), authoritative, err) + } +} + +func TestHorizonConnectorSegmentsFiltersBranchesOutsideLimits(t *testing.T) { + footprints, curves := horizonConnectorTestOpeningFootprint() + if got := HorizonConnectorSegments(footprints, curves); len(got) != 1 { + t.Fatalf("connectors without limits=%d, want one baseline closure", len(got)) + } + nearLimit := []basic.OccultationPathPoint{ + {Longitude: 0, Latitude: 0}, + {Longitude: 0, Latitude: 3}, + } + if got := HorizonConnectorSegments(footprints, curves, nearLimit); len(got) != 1 { + t.Fatalf("connectors with an attached limit=%d, want the closure kept", len(got)) + } + farLimit := []basic.OccultationPathPoint{ + {Longitude: 60, Latitude: 0}, + {Longitude: 60, Latitude: 3}, + } + if got := HorizonConnectorSegments(footprints, curves, farLimit); len(got) != 0 { + t.Fatalf("connectors with a detached limit=%d, want the branch filtered out", len(got)) + } +} + +func TestHorizonConnectorSegmentsKeepsSamePhaseBranchOpeningWithLimits(t *testing.T) { + eventTime := horizonConnectorTestEventTime() + contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0} + contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 1} + footprint := basic.OccultationFootprint{ + Boundaries: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 0.5}, + contactEnd, + }}, + Polygons: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 0.5}, + contactEnd, + {Time: eventTime, Longitude: 1, Latitude: 1.2}, + {Time: eventTime, Longitude: 1, Latitude: -0.2}, + contactStart, + }}, + } + for _, test := range []struct { + name string + second basic.OccultationPathPoint + wantCount int + }{ + {name: "separated-branch", second: basic.OccultationPathPoint{Time: eventTime, Longitude: 0.3, Latitude: 0.5}, wantCount: 1}, + {name: "same-phase-fold", second: basic.OccultationPathPoint{Time: eventTime, Longitude: 0.02, Latitude: 0.5}, wantCount: 0}, + } { + t.Run(test.name, func(t *testing.T) { + curves := []basic.OccultationRiseSetCurve{{ + Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{ + {{Time: eventTime, Longitude: 0, Latitude: 0.5}, {Time: eventTime, Longitude: -2, Latitude: 0.5}}, + {test.second, {Time: eventTime, Longitude: -2, Latitude: 0.6}}, + }, + }} + if got := HorizonConnectorSegments([]basic.OccultationFootprint{footprint}, curves); len(got) != test.wantCount { + t.Fatalf("connectors=%d, want %d", len(got), test.wantCount) + } + }) + } +} + +// 通用并集失败后恢复阶梯必须真正重试,而不是直接返回未合并的面。 +func TestOccultationRetryOpenSweepUnionRecoversUnion(t *testing.T) { + footprint := basic.OccultationFootprint{ + Closed: true, + Polygons: [][]basic.OccultationPathPoint{{ + {Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 0}, + {Longitude: 1, Latitude: 1}, {Longitude: 0, Latitude: 1}, {Longitude: 0, Latitude: 0}, + }}, + } + broken := [][]geodata.GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}} + if _, err := geodata.UnionPolygons(broken); err == nil { + t.Skip("primary union no longer fails on a two-point ring") + } + original := errors.New("primary union failed") + merged, err := occultationRetryOpenSweepUnion( + broken, []basic.OccultationFootprint{footprint}, nil, nil, original, + ) + if err != nil { + t.Fatalf("recovery ladder returned the primary error: %v", err) + } + if len(merged) != 1 || len(merged[0]) < 4 { + t.Fatalf("recovered polygons=%d, want the closed instantaneous face", len(merged)) + } + if _, err := occultationRetryOpenSweepUnion(broken, nil, nil, nil, original); !errors.Is(err, original) { + t.Fatalf("ladder without usable faces returned %v, want the primary error", err) + } +} + +// 逐边修复已把残差压小时必须保留细化结果,不能退回未修复的 child。 +func TestConstrainPolygonsWithinKeepsPartialEdgeRepair(t *testing.T) { + // 凹槽父带:子环顶点都在父带内,只有跨越凹槽的长边落在外面,顶点吸附无从下手。 + parent := [][]geodata.GeoPoint{{ + {Longitude: -2, Latitude: -3}, {Longitude: 2, Latitude: -3}, + {Longitude: 2, Latitude: 2}, {Longitude: 1.2, Latitude: 2}, + {Longitude: 1.2, Latitude: -0.2}, {Longitude: -1.2, Latitude: -0.2}, + {Longitude: -1.2, Latitude: 2}, {Longitude: -2, Latitude: 2}, + {Longitude: -2, Latitude: -3}, + }} + child := [][]geodata.GeoPoint{{ + {Longitude: -1.5, Latitude: 0.2}, {Longitude: 1.5, Latitude: 0.2}, + {Longitude: 1.5, Latitude: 0.4}, {Longitude: -1.5, Latitude: 0.4}, + {Longitude: -1.5, Latitude: 0.2}, + }} + initial := geodata.SphericalPolygonsPathMissDistanceKM(parent, child, true) + if initial <= 10 { + t.Fatalf("test child misses the parent by only %.3f km, want an edge-only breach", initial) + } + repaired := ConstrainPolygonsWithin(parent, differentialCopyPolygons(child)) + repairedMiss := geodata.SphericalPolygonsPathMissDistanceKM(parent, repaired, true) + if repairedMiss >= initial*0.9 { + t.Fatalf("edge repair did not substantially reduce the breach: %.3f km -> %.3f km", initial, repairedMiss) + } + if len(repaired) != 1 || len(repaired[0]) <= len(child[0]) { + t.Fatalf("repaired rings=%d points=%d, want the densified ring", len(repaired), len(repaired[0])) + } + discarded := constrainPolygonsWithinReference(parent, differentialCopyPolygons(child)) + if miss := geodata.SphericalPolygonsPathMissDistanceKM(parent, discarded, true); miss != initial { + t.Fatalf("reference implementation unexpectedly repaired the child to %.3f km", miss) + } +} + +func horizonConnectorTestEventTime() time.Time { + return time.Date(2025, time.July, 29, 0, 0, 0, 0, time.UTC) +} + +func horizonConnectorTestOpeningFootprint() ([]basic.OccultationFootprint, []basic.OccultationRiseSetCurve) { + eventTime := horizonConnectorTestEventTime() + contactStart := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 0} + contactEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: 0, Latitude: 3} + branchStart := basic.OccultationPathPoint{Time: eventTime, Longitude: -0.05, Latitude: 0} + branchEnd := basic.OccultationPathPoint{Time: eventTime, Longitude: -0.05, Latitude: 3} + footprints := []basic.OccultationFootprint{{ + Boundaries: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 1.5}, + contactEnd, + }}, + Polygons: [][]basic.OccultationPathPoint{{ + contactStart, + {Time: eventTime, Longitude: 2, Latitude: 1.5}, + contactEnd, + {Time: eventTime, Longitude: 1, Latitude: 3}, + {Time: eventTime, Longitude: 1, Latitude: 0}, + contactStart, + }}, + }} + curves := []basic.OccultationRiseSetCurve{{ + Phase: basic.RiseSetPhaseStart, Direction: basic.RiseSetDirectionRise, + Segments: [][]basic.OccultationPathPoint{ + {branchStart, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: -1}}, + {branchEnd, {Time: eventTime.Add(time.Minute), Longitude: -1, Latitude: 4}}, + }, + }} + return footprints, curves +} diff --git a/internal/occultationgeo/validation.go b/internal/occultationgeo/validation.go new file mode 100644 index 0000000..00c2d5f --- /dev/null +++ b/internal/occultationgeo/validation.go @@ -0,0 +1,155 @@ +package occultationgeo + +import ( + "fmt" + "math" + "time" + + "b612.me/astro/basic" +) + +// ValidateRiseSetCurves 检查 SVG 和 GeoJSON 渲染器消费的公开地平曲线数据。 +// ValidateRiseSetCurves checks the public horizon-curve data consumed by SVG and GeoJSON renderers. +func ValidateRiseSetCurves(curves []basic.OccultationRiseSetCurve, start, end time.Time) error { + seen := make(map[[2]string]bool, len(curves)) + for curveIndex, curve := range curves { + if !validRiseSetPhase(curve.Phase) { + return fmt.Errorf("curve[%d] has unsupported phase %q", curveIndex, curve.Phase) + } + if !validRiseSetDirection(curve.Direction) { + return fmt.Errorf("curve[%d] has unsupported direction %q", curveIndex, curve.Direction) + } + key := [2]string{string(curve.Phase), string(curve.Direction)} + if seen[key] { + return fmt.Errorf("curve[%d] duplicates phase %q and direction %q", curveIndex, curve.Phase, curve.Direction) + } + seen[key] = true + if len(curve.Segments) == 0 { + return fmt.Errorf("curve[%d] must contain at least one segment", curveIndex) + } + for segmentIndex, segment := range curve.Segments { + if len(segment) < 2 { + return fmt.Errorf("curve[%d].segment[%d] must contain at least two points", curveIndex, segmentIndex) + } + for pointIndex, point := range segment { + if err := validatePathPoint(point, start, end); err != nil { + return fmt.Errorf("curve[%d].segment[%d].point[%d]: %w", curveIndex, segmentIndex, pointIndex, err) + } + if pointIndex > 0 && !point.Time.After(segment[pointIndex-1].Time) { + return fmt.Errorf("curve[%d].segment[%d] times must be strictly increasing", curveIndex, segmentIndex) + } + if pointIndex > 0 && riseSetCurveBranchChanged(segment[pointIndex-1], point) { + return fmt.Errorf("curve[%d].segment[%d] contains a discontinuous branch jump between points %d and %d", curveIndex, segmentIndex, pointIndex-1, pointIndex) + } + } + } + } + return nil +} + +func riseSetCurveBranchChanged(first, second basic.OccultationPathPoint) bool { + distance := DistanceKM(first, second) + if !finite(distance) { + return true + } + if distance <= 500 { + return false + } + duration := math.Abs(second.Time.Sub(first.Time).Seconds()) + return duration <= 0 || duration < 1 && distance/duration > 10 +} + +func validRiseSetPhase(phase basic.RiseSetPhase) bool { + switch phase { + case basic.RiseSetPhaseStart, basic.RiseSetPhaseGreatest, basic.RiseSetPhaseEnd: + return true + default: + return false + } +} + +func validRiseSetDirection(direction basic.RiseSetDirection) bool { + switch direction { + case basic.RiseSetDirectionRise, basic.RiseSetDirectionSet: + return true + default: + return false + } +} + +// ValidateFootprints 检查 SVG 和 GeoJSON 渲染器消费的瞬时月掩区域。 +// ValidateFootprints checks instantaneous occultation regions consumed by SVG and GeoJSON renderers. +func ValidateFootprints(footprints []basic.OccultationFootprint, start, end time.Time) error { + previous := time.Time{} + for footprintIndex, footprint := range footprints { + if footprint.Time.IsZero() { + return fmt.Errorf("footprint[%d] time is required", footprintIndex) + } + if footprint.Time.Before(start) || footprint.Time.After(end) { + return fmt.Errorf("footprint[%d] time must be inside its contact interval", footprintIndex) + } + if !previous.IsZero() && !footprint.Time.After(previous) { + return fmt.Errorf("footprint times must be strictly increasing") + } + if len(footprint.Polygons) == 0 { + return fmt.Errorf("footprint[%d] must contain a polygon", footprintIndex) + } + for polygonIndex, polygon := range footprint.Polygons { + if len(polygon) < 3 { + return fmt.Errorf("footprint[%d].polygon[%d] must contain at least three points", footprintIndex, polygonIndex) + } + for pointIndex, point := range polygon { + if err := validatePathPoint(point, start, end); err != nil { + return fmt.Errorf("footprint[%d].polygon[%d].point[%d]: %w", footprintIndex, polygonIndex, pointIndex, err) + } + if !point.Time.Equal(footprint.Time) { + return fmt.Errorf("footprint[%d].polygon[%d].point[%d] time must match its footprint", footprintIndex, polygonIndex, pointIndex) + } + } + } + if footprint.Closed && len(footprint.Boundaries) != 1 { + return fmt.Errorf("footprint[%d] closed boundary must contain exactly one segment", footprintIndex) + } + for boundaryIndex, boundary := range footprint.Boundaries { + if len(boundary) < 2 { + return fmt.Errorf("footprint[%d].boundary[%d] must contain at least two points", footprintIndex, boundaryIndex) + } + for pointIndex, point := range boundary { + if err := validatePathPoint(point, start, end); err != nil { + return fmt.Errorf("footprint[%d].boundary[%d].point[%d]: %w", footprintIndex, boundaryIndex, pointIndex, err) + } + if !point.Time.Equal(footprint.Time) { + return fmt.Errorf("footprint[%d].boundary[%d].point[%d] time must match its footprint", footprintIndex, boundaryIndex, pointIndex) + } + } + } + previous = footprint.Time + } + return nil +} + +func validatePathPoint(point basic.OccultationPathPoint, start, end time.Time) error { + if point.Time.IsZero() { + return fmt.Errorf("time is required") + } + if point.Time.Before(start) || point.Time.After(end) { + return fmt.Errorf("time must be inside the occultation interval") + } + if !finite(point.Longitude) || point.Longitude < -180 || point.Longitude > 180 { + return fmt.Errorf("longitude must be finite and within [-180, 180]") + } + if !finite(point.Latitude) || point.Latitude < -90 || point.Latitude > 90 { + return fmt.Errorf("latitude must be finite and within [-90, 90]") + } + if !finite(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 { + return fmt.Errorf("Moon altitude must be finite and within [-90, 90]") + } + if !finite(point.WidthKM) || point.WidthKM < 0 { + return fmt.Errorf("width must be finite and non-negative") + } + return nil +} + +func finite(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} diff --git a/internal/occultationgeo/visible_band.go b/internal/occultationgeo/visible_band.go new file mode 100644 index 0000000..850ea3c --- /dev/null +++ b/internal/occultationgeo/visible_band.go @@ -0,0 +1,1282 @@ +package occultationgeo + +import ( + "b612.me/astro/basic" + "b612.me/astro/internal/geodata" +) + +func visibleBandPolygons( + footprints []basic.OccultationFootprint, + northern, southern []basic.OccultationPathPoint, + contours [][]basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, + strongPolarSmoothing bool, +) ([][]geodata.GeoPoint, bool, error) { + contactLines := occultationContactContourBoundaryLines(contours) + useContactContours := len(contactLines) > 0 + // Only a single north/south contour pair is a stable global envelope. A + // contour set split into several branch fragments (common near polar folds) + // must retain the sampled visible-union path; feeding those fragments to the + // generic linework selector creates one polygon per numerical branch and is + // both slower and less faithful than the time-union sweep. + stableContactEnvelope := useContactContours && len(contours) == 2 + // A station-corrected north/south contact pair already parameterizes the + // complete time-union boundary. Clip each adjacent time cell to the lunar + // horizon and merge those cells directly. This is the physical definition + // of the visible band; it avoids asking a planar polygonizer to choose among + // folded polar faces and keeps partial/total bands on the same residual + // model. Footprints remain independent witnesses/timeline data. + var ( + fallback [][]geodata.GeoPoint + swept [][]geodata.GeoPoint + sweepErr error + sweepComputed bool + visibleUnion [][]geodata.GeoPoint + visibleUnionComputed bool + ) + computeSweep := func() ([][]geodata.GeoPoint, error) { + if !sweepComputed { + swept, sweepErr = footprintSweepPolygons(footprints, northern, southern) + sweepComputed = true + } + return swept, sweepErr + } + computeVisibleUnion := func() [][]geodata.GeoPoint { + if !visibleUnionComputed { + visibleUnion = footprintVisibleUnionPolygons(footprints) + visibleUnionComputed = true + } + return visibleUnion + } + if !useContactContours && len(footprints) > 0 && len(curves) > 0 { + // Footprints are already clipped to the lunar horizon at each sampled + // instant. With no supplied contact contour there is no alternative + // analytic boundary to select, so the continuous footprint sweep is the + // authoritative sampled time-union rather than a legacy fallback. + swept, err := computeSweep() + if err != nil { + return nil, false, err + } + if len(swept) > 0 { + return cleanupOccultationAuthoritativeBandPolygons(swept), true, nil + } + } + if len(curves) == 0 && len(footprints) > 0 { + // Without rise/set curves there is no closed phase boundary to select a + // face from. Return the horizon-visible footprint sweep directly, even + // when contact contours are present; feeding an open contour network to + // the polygonizer can select a complement face and drop the greatest + // point from the static band. + fallback, err := computeSweep() + if err != nil { + return nil, false, err + } + // The sweep has already passed the ordinary topology cleanup; deleting + // another span here can remove intermediate samples around a polar fold + // and recreate a long straight chord. Densify the cleaned ring instead. + fallback = densifyOccultationPolygons(fallback, 50) + for index := range fallback { + fallback[index] = smoothOccultationHairpins(fallback[index], 180, 25, 16, 50) + fallback[index] = removeOccultationSharpCorners(fallback[index], 20, 30) + } + fallback = mergeStaticFootprintRepairs(fallback, footprints) + // If the sparse open sweep is split at horizon transitions, add only the + // already-closed instantaneous footprints. This restores a closed face + // around greatest without running a boolean union over every sampled + // footprint (which is prohibitively expensive for historical events). + closed := footprintClosedPolygons(footprints) + if len(closed) > 0 { + input := append(append([][]geodata.GeoPoint(nil), fallback...), closed...) + if merged, mergeErr := geodata.UnionPolygons(input); mergeErr == nil && len(merged) > 0 { + return cleanupFootprintSweepPolygons(merged, northern, southern), false, nil + } + } + return fallback, false, nil + } + if len(footprints) > 0 && !stableContactEnvelope { + if candidate, authoritative, handled := occultationEarlyVisibleBandCandidate( + footprints, northern, southern, contours, curves, + useContactContours, strongPolarSmoothing, computeSweep, computeVisibleUnion, + ); handled { + return candidate, authoritative, nil + } + } + if useContactContours && len(contours) > 2 { + // Split contact contours describe several valid numerical branches. A + // planar face selector cannot reliably decide their complement, while the + // horizon-clipped sweep is a deterministic union of the same instantaneous + // states. Keep that union authoritative when it remains one component: + // mutually discontinuous contour fragments cannot witness its boundary, so + // the single-component construction contract is the applicable check here. + if swept, err := computeSweep(); err == nil && len(swept) == 1 { + return cleanupOccultationAuthoritativeBandPolygons(swept), true, nil + } + } + if !useContactContours { + var err error + fallback, err = computeSweep() + if err != nil { + return nil, false, err + } + } + baseBoundaryLines := occultationFallbackPolygonBoundaryLines(fallback) + // Greatest is a diagnostic meridian, not an exterior edge of the static + // visible band. Feeding it to the polygonizer lets a polar fold be selected + // as a legitimate face boundary, which creates a sharp polar-end kink. Keep + // it in curve coverage probes and in + // the exported line features, but exclude it from the authoritative fill + // topology whenever continuous contact contours are available. + boundaryCurves := curves + if useContactContours { + boundaryCurves = occultationStaticBandCurves(curves) + } + // Horizon connectors are part of the authoritative visible boundary, not + // merely a last-resort repair. When the contact contours and rise/set + // curves are both open at a moonrise/moonset transition, omitting this + // short physical arc lets polygonization succeed on a different face and + // leaves the exported phase curve outside (or inside) the filled band. + initialConnectorLines := occultationHorizonConnectorBoundaryLines( + HorizonConnectorSegments(footprints, boundaryCurves, northern, southern), + ) + if useContactContours { + baseBoundaryLines = contactLines + } + visibleFill, coveragePaths := occultationVisibleFillAndCoverage(footprints) + contourFill, _ := occultationContourFillAndCoverage(useContactContours, northern, southern, nil) + // Phase-cycle construction and its spherical witness checks are expensive, + // especially for minute-sampled curves. Several fallback branches need + // the same candidate, so build and validate it once per band generation. + var ( + preferredPhaseBandRaw [][]geodata.GeoPoint + preferredPhaseBandRawOK bool + preferredPhaseBandComputed bool + preferredPhaseBandCandidate [][]geodata.GeoPoint + preferredPhaseBandAccepted bool + ) + computePreferredPhaseBand := func() ([][]geodata.GeoPoint, bool) { + if !preferredPhaseBandComputed { + preferredPhaseBandRaw, preferredPhaseBandRawOK = occultationPreferredPhaseBand( + useContactContours, footprints, curves, northern, southern, contourFill, + ) + if preferredPhaseBandRawOK { + physicalBoundary := preferredPhaseBandRaw + candidate := cleanupOccultationAuthoritativeBandPolygons(preferredPhaseBandRaw) + candidate = preserveOccultationPhaseBoundaryEnvelope(candidate, physicalBoundary) + if occultationPreferredPhaseBandAccepted(candidate, contourFill, visibleFill) { + preferredPhaseBandCandidate = candidate + preferredPhaseBandAccepted = true + } + } + preferredPhaseBandComputed = true + } + return preferredPhaseBandRaw, preferredPhaseBandRawOK + } + computeAcceptedPreferredPhaseBand := func() ([][]geodata.GeoPoint, bool) { + computePreferredPhaseBand() + return preferredPhaseBandCandidate, preferredPhaseBandAccepted + } + var ( + contactSweepLines [][]geodata.GeoPoint + contactSweepLinesComputed bool + ) + computeContactSweepLines := func() [][]geodata.GeoPoint { + if !contactSweepLinesComputed { + contactSweepLines = ContactSweepBoundaryLines(footprints) + contactSweepLinesComputed = true + } + return contactSweepLines + } + boundaryLines := occultationVisibleBoundaryLinesFromBase(baseBoundaryLines, boundaryCurves, initialConnectorLines) + + if preferred, ok := occultationPreferredContactBandCandidate( + useContactContours, computePreferredPhaseBand, computeAcceptedPreferredPhaseBand, + computeVisibleUnion, visibleFill, contourFill, coveragePaths, curves, + ); ok { + return preferred, true, nil + } + if fast, ok := occultationAcceptedFastBandCandidate( + useContactContours, strongPolarSmoothing, boundaryLines, + visibleFill, contourFill, coveragePaths, curves, computePreferredPhaseBand, + ); ok { + return fast, true, nil + } + if combined, ok := occultationContactSweepPhaseCandidate( + useContactContours, footprints, computeSweep, computePreferredPhaseBand, + visibleFill, contourFill, coveragePaths, curves, + ); ok { + return combined, true, nil + } + if candidate, authoritative, ok := occultationBoundedContactSweepCandidate( + useContactContours, computeSweep, contours, strongPolarSmoothing, + ); ok { + return candidate, authoritative, nil + } + if len(visibleFill) == 0 && len(contourFill) == 0 { + return occultationNoVisibleBandFill( + useContactContours, computeSweep, fallback, strongPolarSmoothing, + ) + } + selectionFill, coveragePaths, curveCoveragePaths, visibleFillCoveragePaths := + occultationVisibleBandLineworkInputs( + useContactContours, strongPolarSmoothing, + visibleFill, contourFill, coveragePaths, curves, + ) + visible, lineworkErr := occultationRetryVisibleBandLinework( + occultationVisibleBandLineworkOptions{ + useContactContours: useContactContours, + footprints: footprints, + northern: northern, + southern: southern, + curves: curves, + boundaryCurves: boundaryCurves, + boundaryLines: boundaryLines, + baseBoundaryLines: baseBoundaryLines, + selectionFill: selectionFill, + coveragePaths: coveragePaths, + curveCoveragePaths: curveCoveragePaths, + visibleFill: visibleFill, + visibleFillCoveragePaths: visibleFillCoveragePaths, + initialConnectorLines: initialConnectorLines, + contactLines: contactLines, + computeSweep: computeSweep, + computeContactSweepLines: computeContactSweepLines, + }, + ) + visible = occultationMergePreferredVisibleBand( + visible, useContactContours, footprints, curves, northern, southern, + contourFill, visibleFill, visibleFillCoveragePaths, + ) + if lineworkErr == nil { + lineworkErr = validateOccultationVisibleBandWitnesses( + visible, useContactContours, visibleFill, contourFill, visibleFillCoveragePaths, + ) + } + if lineworkErr != nil && useContactContours && len(visibleFill) > 0 { + if stableVisible, stableErr := occultationRetryStableContactBoundary( + occultationVisibleBandLineworkOptions{ + useContactContours: useContactContours, + boundaryCurves: boundaryCurves, + visibleFill: visibleFill, + visibleFillCoveragePaths: visibleFillCoveragePaths, + initialConnectorLines: initialConnectorLines, + computeSweep: computeSweep, + computeContactSweepLines: computeContactSweepLines, + }, + ); stableErr == nil { + visible, lineworkErr = stableVisible, nil + } + } + if lineworkErr != nil { + if useContactContours { + // A direct open-footprint sweep is a coverage-preserving fallback only. + // It does not share the phase/contact cycle used by the authoritative + // boundary, so never let it replace a successful contour polygonization. + if direct, directOK := DirectVisibleBandPolygons(footprints); directOK { + return cleanupOccultationVisibleBandPolygons(direct, strongPolarSmoothing), false, nil + } + if fallback, err := computeSweep(); err == nil { + if authoritative, ok := authoritativeFallbackBandPolygons(fallback, contours, strongPolarSmoothing); ok { + return authoritative, true, nil + } + return cleanupOccultationVisibleBandPolygons(fallback, strongPolarSmoothing), false, nil + } + if len(visibleFill) > 0 { + return cleanupOccultationVisibleBandPolygons(visibleFill, strongPolarSmoothing), false, nil + } + return nil, false, lineworkErr + } + return cleanupOccultationVisibleBandPolygons(fallback, strongPolarSmoothing), false, nil + } + if lineworkErr == nil && useContactContours && len(visibleFill) > 0 { + // A folded phase cycle can be topologically valid yet select an inner + // branch at a polar horizon. The instantaneous visible footprints provide + // an independent witness for the actual outer envelope. Promote their + // union only when it substantially covers the phase cycle while the phase + // cycle does not cover the union; this keeps ordinary smooth cycles on the + // cheaper contact/rise-set path. + if footprintUnion, unionErr := geodata.UnionPolygons(visibleFill); unionErr == nil && len(footprintUnion) > 0 { + phaseMiss := geodata.SphericalPolygonsPathMissDistanceKM(visible, footprintUnion, true) + unionMiss := geodata.SphericalPolygonsPathMissDistanceKM(footprintUnion, visible, true) + if phaseMiss > 20 && unionMiss <= 50 { + visible = footprintUnion + } + } + } + if !useContactContours { + // The phase-boundary polygonizer supplies the smooth global outline, but a + // polar fold can leave a very narrow face out of its selected cycle. Merge + // the horizon-clipped instantaneous faces back into that result: every + // added point is already Moon-altitude-visible, so this repairs coverage + // without restoring the below-horizon part of the fallback sweep. + combinedVisible := append([][]geodata.GeoPoint(nil), visible...) + combinedVisible = append(combinedVisible, visibleFill...) + if augmented, unionErr := geodata.UnionPolygons(combinedVisible); unionErr == nil { + visible = augmented + } + } + if useContactContours { + return roundOccultationAuthoritativeBandJunctions( + cleanupOccultationAuthoritativeBandPolygons(visible), + ), true, nil + } + return cleanupOccultationVisibleBandPolygons(visible, strongPolarSmoothing), true, nil +} + +func occultationPreferredContactBandCandidate( + useContactContours bool, + computePreferred func() ([][]geodata.GeoPoint, bool), + computeAcceptedPreferred func() ([][]geodata.GeoPoint, bool), + computeVisibleUnion func() [][]geodata.GeoPoint, + visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool) { + if !useContactContours { + return nil, false + } + // The static display contract combines every horizon-visible footprint with + // the exported start/end phase envelope. Try that complete physical union + // before a generic polygonizer can select a folded complement face. + if preferred, ok := computePreferred(); ok { + if visibleUnion := computeVisibleUnion(); len(visibleUnion) > 0 { + combined := append([][]geodata.GeoPoint(nil), visibleUnion...) + combined = append(combined, preferred...) + if merged, err := geodata.UnionPolygons(combined); err == nil && len(merged) > 0 { + merged = cleanupOccultationAuthoritativeBandPolygons(merged) + merged = preserveOccultationPhaseBoundaryEnvelope(merged, preferred) + if occultationFastPolarBandAccepted( + merged, visibleFill, contourFill, coveragePaths, curves, + ) { + return roundOccultationAuthoritativeBandJunctions(merged), true + } + } + } + } + // A complete start/end phase cycle is itself the physical outer boundary of + // a horizon-clipped band. The acceptance gate has already checked its + // contour and footprint witnesses. + if preferred, ok := computeAcceptedPreferred(); ok { + return roundOccultationAuthoritativeBandJunctions(preferred), true + } + return nil, false +} + +func occultationAcceptedFastBandCandidate( + useContactContours, strongPolarSmoothing bool, + boundaryLines, visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, + computePreferred func() ([][]geodata.GeoPoint, bool), +) ([][]geodata.GeoPoint, bool) { + visible, ok := occultationFastPolarBand( + useContactContours, strongPolarSmoothing, boundaryLines, + visibleFill, contourFill, coveragePaths, curves, + ) + if !ok { + return nil, false + } + visible = cleanupOccultationAuthoritativeBandPolygons(visible) + if occultationFastPolarBandAccepted(visible, visibleFill, contourFill, coveragePaths, curves) { + return visible, true + } + if !useContactContours { + return nil, false + } + // A fast polar face can choose an inner branch. Union it with the explicit + // phase cycle and accept only when the same physical witnesses still pass. + preferred, preferredOK := computePreferred() + if !preferredOK { + return nil, false + } + combined := append([][]geodata.GeoPoint(nil), visible...) + combined = append(combined, preferred...) + merged, err := geodata.UnionPolygons(combined) + if err != nil { + return nil, false + } + merged = cleanupOccultationAuthoritativeBandPolygons(merged) + merged = preserveOccultationPhaseBoundaryEnvelope(merged, preferred) + if !occultationFastPolarBandAccepted(merged, visibleFill, contourFill, coveragePaths, curves) { + return nil, false + } + return merged, true +} + +func occultationContactSweepPhaseCandidate( + useContactContours bool, + footprints []basic.OccultationFootprint, + computeSweep func() ([][]geodata.GeoPoint, error), + computePreferred func() ([][]geodata.GeoPoint, bool), + visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool) { + if !useContactContours { + return nil, false + } + // The normal sweep is cheaper; the direct sweep is retained for ribbons + // whose cross-arcs need its denser union. Both must be joined to the same + // accepted phase family and pass identical witnesses. + if swept, err := computeSweep(); err == nil { + if merged, ok := occultationMergeSweepWithPreferredPhase( + swept, computePreferred, visibleFill, contourFill, coveragePaths, curves, + ); ok { + return roundOccultationAuthoritativeBandJunctions(merged), true + } + } + if direct, ok := DirectVisibleBandPolygons(footprints); ok { + if merged, ok := occultationMergeSweepWithPreferredPhase( + direct, computePreferred, visibleFill, contourFill, coveragePaths, curves, + ); ok { + return roundOccultationAuthoritativeBandJunctions(merged), true + } + } + return nil, false +} + +func occultationMergeSweepWithPreferredPhase( + sweep [][]geodata.GeoPoint, + computePreferred func() ([][]geodata.GeoPoint, bool), + visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool) { + if len(sweep) == 0 { + return nil, false + } + preferred, ok := computePreferred() + if !ok { + return nil, false + } + combined := append([][]geodata.GeoPoint(nil), sweep...) + combined = append(combined, preferred...) + merged, err := geodata.UnionPolygons(combined) + if err != nil { + return nil, false + } + merged = cleanupOccultationAuthoritativeBandPolygons(merged) + merged = preserveOccultationPhaseBoundaryEnvelope(merged, preferred) + if !occultationFastPolarBandAccepted(merged, visibleFill, contourFill, coveragePaths, curves) { + return nil, false + } + return merged, true +} + +func occultationBoundedContactSweepCandidate( + useContactContours bool, + computeSweep func() ([][]geodata.GeoPoint, error), + contours [][]basic.OccultationPathPoint, + strongPolarSmoothing bool, +) ([][]geodata.GeoPoint, bool, bool) { + if !useContactContours { + return nil, false, false + } + swept, err := computeSweep() + if err != nil || len(swept) == 0 { + return nil, false, false + } + // Bound the expensive linework retry tree with the already computed, + // coverage-preserving temporal sweep. It is authoritative only when the + // continuous contact contours independently witness its boundary. + if authoritative, ok := authoritativeFallbackBandPolygons( + swept, contours, strongPolarSmoothing, + ); ok { + return authoritative, true, true + } + cleaned := cleanupOccultationVisibleBandPolygons(swept, strongPolarSmoothing) + if len(cleaned) == 0 { + return nil, false, false + } + return cleaned, false, true +} + +func occultationEarlyVisibleBandCandidate( + footprints []basic.OccultationFootprint, + northern, southern []basic.OccultationPathPoint, + contours [][]basic.OccultationPathPoint, + curves []basic.OccultationRiseSetCurve, + useContactContours, strongPolarSmoothing bool, + computeSweep func() ([][]geodata.GeoPoint, error), + computeVisibleUnion func() [][]geodata.GeoPoint, +) ([][]geodata.GeoPoint, bool, bool) { + // A split contour set can still use the bounded endpoint fallback when + // north/south branches remain paired. Odd or incomplete branches continue + // through the phase/linework selector for a smooth envelope. + if useContactContours && len(contours) > 2 && len(contours)%2 == 0 { + contourFill, _ := occultationContourFillAndCoverage(true, northern, southern, nil) + if fallback, ok := footprintEndpointContourFallback(footprints, contourFill); ok && len(fallback) == 1 { + // A continuous sweep is more faithful when it proves that all + // horizon-visible source faces belong to the same temporal ribbon. + if swept, sweepErr := computeSweep(); sweepErr == nil && len(swept) == 1 { + visibleUnion := computeVisibleUnion() + if len(visibleUnion) > 1 && footprintSweepCoversSamples(swept, footprints) { + return cleanupOccultationVisibleBandPolygons(swept, strongPolarSmoothing), true, true + } + } + return fallback, true, true + } + } + sweepRequiresLinework := false + // Prefer one continuous temporal sweep over independently closed + // instantaneous footprints. The sweep carries the moving contact arc + // between samples, avoiding a staircase from a single horizon closure. + if swept, sweepErr := computeSweep(); sweepErr == nil && len(swept) > 0 { + // The same contact-contour witness used by the later linework fallback can + // be checked before constructing the expensive horizon-visible union. When + // it accepts a single sweep, the union cannot change the selected band: its + // only purpose on that path is to discover a fragmented mask that is already + // implied by the witnessed contact envelope. + if useContactContours && len(swept) == 1 && hasBracketedClosedFootprintRun(footprints) { + if authoritative, authoritativeOK := authoritativeFallbackBandPolygons( + swept, contours, strongPolarSmoothing, + ); authoritativeOK { + return authoritative, true, true + } + } + visibleUnion := computeVisibleUnion() + if len(visibleUnion) > 0 && footprintSweepNeedsHorizonClipping(swept, visibleUnion, footprints) { + if len(visibleUnion) > 1 && len(swept) == 1 && footprintSweepCoversSamples(swept, footprints) { + return cleanupOccultationVisibleBandPolygons(swept, strongPolarSmoothing), true, true + } + outsideVisibleKM := 0.0 + // A fragmented union already decides the linework path; avoid the + // expensive edge-distance calculation in that case. + if !(len(visibleUnion) > 1 && len(swept) == 1) { + outsideVisibleKM = geodata.SphericalPolygonsPathMissDistanceKM(visibleUnion, swept, true) + } + if len(visibleUnion) > 1 && len(swept) == 1 { + sweepRequiresLinework = len(curves) > 0 + } + if sweepRequiresLinework && useContactContours { + if authoritative, authoritativeOK := authoritativeFallbackBandPolygons( + swept, contours, strongPolarSmoothing, + ); authoritativeOK { + return authoritative, true, true + } + } + if outsideVisibleKM > 20 && len(curves) > 0 { + sweepRequiresLinework = true + } + if outsideVisibleKM <= 20 && len(curves) > 0 && len(northern) > 0 && len(southern) > 0 { + contourFill, _ := occultationContourFillAndCoverage(true, northern, southern, nil) + if phaseBand, ok := occultationPreferredPhaseBand( + true, footprints, curves, northern, southern, contourFill, + ); ok { + combined := append([][]geodata.GeoPoint(nil), swept...) + combined = append(combined, phaseBand...) + if merged, mergeErr := geodata.UnionPolygons(combined); mergeErr == nil && len(merged) > 0 { + return cleanupOccultationAuthoritativeBandPolygons(merged), true, true + } + } + } + // A sweep entering the below-horizon complement cannot be repaired by + // splitting at classified vertices without manufacturing polar faces. + visibleUnion = cleanupOccultationFootprintUnionPolygons(visibleUnion, strongPolarSmoothing) + if len(visibleUnion) == 1 { + return visibleUnion, true, true + } + if !sweepRequiresLinework { + return cleanupOccultationVisibleBandPolygons(swept, strongPolarSmoothing), true, true + } + } + } + // Legacy footprints without open boundary arcs retain their exact visible + // union as a bounded fallback when continuous linework is unnecessary. + if !sweepRequiresLinework { + if visibleUnion := computeVisibleUnion(); len(visibleUnion) > 0 { + return cleanupOccultationFootprintUnionPolygons( + visibleUnion, strongPolarSmoothing && !useContactContours, + ), true, true + } + } + return nil, false, false +} + +func hasBracketedClosedFootprintRun(footprints []basic.OccultationFootprint) bool { + firstClosed, lastClosed := -1, -1 + for index, footprint := range footprints { + if !footprint.Closed { + continue + } + if firstClosed < 0 { + firstClosed = index + } + lastClosed = index + } + return firstClosed >= 3 && lastClosed > firstClosed && lastClosed+3 < len(footprints) +} + +// footprintEndpointContourFallback is the bounded fallback for a contact +// envelope split into incompatible branches. The paired contact fill carries +// the interval interior while the open-footprint sweep provides temporal caps. +// Unlike a union of every instantaneous horizon polygon, this construction +// cannot turn small sampling seams into hundreds of static-band components. +// The sweep is authoritative only when its boundary is independently witnessed +// by the contact contours supplied by the caller. +func footprintEndpointContourFallback( + footprints []basic.OccultationFootprint, + contourFill [][]geodata.GeoPoint, +) ([][]geodata.GeoPoint, bool) { + if len(footprints) == 0 || len(contourFill) == 0 { + return nil, false + } + samples := make([]geodata.OpenBoundarySweepSample, 0, len(footprints)) + for _, footprint := range footprints { + if footprint.Closed { + samples = append(samples, geodata.OpenBoundarySweepSample{Closed: true}) + continue + } + boundaries := footprintGeoBoundaries(footprint) + if len(boundaries) == 0 { + continue + } + samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: boundaries}) + } + endpointOutlines, err := geodata.OpenBoundaryEndpointOutlines(samples) + if err != nil || len(endpointOutlines) == 0 { + return nil, false + } + inputs := append([][]geodata.GeoPoint(nil), contourFill...) + inputs = append(inputs, endpointOutlines...) + merged, err := geodata.UnionPolygons(inputs) + if err != nil || len(merged) == 0 { + return nil, false + } + // A horizon transition can make the full visible-source union fail at one + // numerically open edge. Recover the missing time-union lobe by greedily + // adding only source faces that (a) have vertices outside the current face + // and (b) union into that same single connected face. Detached components and + // the malformed source are skipped, so this cannot turn a local repair into + // a collection of sampled fragments. + merged = mergeEndpointFallbackVisibleSources(merged, footprints) + merged = normalizeOccultationBandOutput(merged) + if len(merged) == 0 { + return nil, false + } + return densifyOccultationPolygons(merged, 40), true +} + +func mergeEndpointFallbackVisibleSources( + base [][]geodata.GeoPoint, + footprints []basic.OccultationFootprint, +) [][]geodata.GeoPoint { + if len(base) != 1 || len(base[0]) < 3 { + return base + } + sources := occultationVisibleFootprintFillOnly(footprints) + if len(sources) == 0 { + return base + } + merged := base + accepted := 0 + const maximumAcceptedSources = 48 + // Start at the final temporal samples: endpoint fallback already carries the + // first/last contact caps, so the missing lobe is normally adjacent to one + // of the last visible faces. Working backwards avoids adding an unrelated + // early-time face that can move the opposite boundary by a few kilometres. + for sourceIndex := len(sources) - 1; sourceIndex >= 0; sourceIndex-- { + source := sources[sourceIndex] + if accepted >= maximumAcceptedSources || len(source) < 3 { + break + } + // Skip faces already covered by the current candidate. Sampling vertices + // keeps this gate bounded while still catching a narrow endpoint lobe. + outside := false + touches := false + step := (len(source) + 31) / 32 + if step < 1 { + step = 1 + } + for index := 0; index < len(source); index += step { + point := source[index] + if !geodata.SphericalPolygonsContainPoints(merged, []geodata.GeoPoint{point})[0] { + outside = true + _, distance := nearestPolygonBoundaryPoint(merged, point) + if distance <= 120 { + touches = true + } + } + } + if !outside || !touches { + continue + } + input := append([][]geodata.GeoPoint(nil), merged...) + input = append(input, source) + candidate, err := geodata.UnionPolygons(input) + if err != nil || len(candidate) != 1 { + continue + } + merged = candidate + accepted++ + } + return merged +} + +type occultationVisibleBandLineworkOptions struct { + useContactContours bool + footprints []basic.OccultationFootprint + northern []basic.OccultationPathPoint + southern []basic.OccultationPathPoint + curves []basic.OccultationRiseSetCurve + boundaryCurves []basic.OccultationRiseSetCurve + boundaryLines [][]geodata.GeoPoint + baseBoundaryLines [][]geodata.GeoPoint + selectionFill [][]geodata.GeoPoint + coveragePaths [][]geodata.GeoPoint + curveCoveragePaths [][]geodata.GeoPoint + visibleFill [][]geodata.GeoPoint + visibleFillCoveragePaths [][]geodata.GeoPoint + initialConnectorLines [][]geodata.GeoPoint + contactLines [][]geodata.GeoPoint + computeSweep func() ([][]geodata.GeoPoint, error) + computeContactSweepLines func() [][]geodata.GeoPoint +} + +// occultationRetryVisibleBandLinework owns the bounded candidate sequence for +// events that have no accepted direct phase cycle. Keeping the retries in one +// helper makes their order explicit and keeps visibleBandPolygons focused on +// selecting between the fast physical constructions and this slow path. +func occultationRetryVisibleBandLinework( + options occultationVisibleBandLineworkOptions, +) ([][]geodata.GeoPoint, error) { + useContactContours := options.useContactContours + footprints := options.footprints + northern, southern := options.northern, options.southern + curves, boundaryCurves := options.curves, options.boundaryCurves + boundaryLines := options.boundaryLines + baseBoundaryLines := options.baseBoundaryLines + selectionFill := options.selectionFill + coveragePaths := options.coveragePaths + curveCoveragePaths := options.curveCoveragePaths + visibleFill := options.visibleFill + visibleFillCoveragePaths := options.visibleFillCoveragePaths + initialConnectorLines := options.initialConnectorLines + contactLines := options.contactLines + computeContactSweepLines := options.computeContactSweepLines + + visible, lineworkErr := geodata.VisibleLineworkPolygons( + boundaryLines, selectionFill, coveragePaths, 75, + ) + if lineworkErr != nil && useContactContours && len(visibleFill) > 0 { + if retryVisible, retryErr := geodata.VisibleLineworkPolygons( + boundaryLines, visibleFill, visibleFillCoveragePaths, 75, + ); retryErr == nil { + visible, lineworkErr = retryVisible, nil + } + } + if lineworkErr != nil && useContactContours && len(visibleFill) > 0 { + if stableVisible, stableErr := occultationRetryStableContactBoundary(options); stableErr == nil { + visible, lineworkErr = stableVisible, nil + } + } + if lineworkErr != nil { + // A polar rise/set curve can contain a fold with two physical branches. + // Retry a bounded set of branch-specific boundary candidates. + for _, candidate := range occultationCurveBoundaryAlternativesFromBase( + baseBoundaryLines, boundaryCurves, initialConnectorLines, + ) { + visible, lineworkErr = geodata.VisibleLineworkPolygons( + candidate, selectionFill, coveragePaths, 75, + ) + if lineworkErr == nil { + break + } + } + } + if lineworkErr != nil && len(curveCoveragePaths) > 0 { + // Polar footprint probes can land on a numerically ambiguous junction. + // Retry with only curve-side probes while retaining the source audit. + curveCoverage := limitOccultationCoveragePaths(curveCoveragePaths, 2048) + visible, lineworkErr = geodata.VisibleLineworkPolygons( + boundaryLines, selectionFill, curveCoverage, 75, + ) + if lineworkErr != nil { + for _, candidate := range occultationCurveBoundaryAlternativesFromBase( + baseBoundaryLines, boundaryCurves, initialConnectorLines, + ) { + visible, lineworkErr = geodata.VisibleLineworkPolygons( + candidate, selectionFill, curveCoverage, 75, + ) + if lineworkErr == nil { + break + } + } + } + } + if lineworkErr != nil && len(initialConnectorLines) == 0 { + connectorLines := occultationHorizonConnectorBoundaryLines( + HorizonConnectorSegments(footprints, curves, northern, southern), + ) + if len(connectorLines) > 0 { + connectorBoundaryLines := append([][]geodata.GeoPoint(nil), boundaryLines...) + connectorBoundaryLines = append(connectorBoundaryLines, connectorLines...) + visible, lineworkErr = geodata.VisibleLineworkPolygons( + connectorBoundaryLines, selectionFill, coveragePaths, 75, + ) + if lineworkErr != nil { + for _, candidate := range occultationCurveBoundaryAlternativesFromBase( + baseBoundaryLines, boundaryCurves, connectorLines, + ) { + visible, lineworkErr = geodata.VisibleLineworkPolygons( + candidate, selectionFill, coveragePaths, 75, + ) + if lineworkErr == nil { + break + } + } + } + if lineworkErr != nil && len(curveCoveragePaths) > 0 { + curveCoverage := limitOccultationCoveragePaths(curveCoveragePaths, 2048) + visible, lineworkErr = geodata.VisibleLineworkPolygons( + connectorBoundaryLines, selectionFill, curveCoverage, 75, + ) + if lineworkErr != nil { + for _, candidate := range occultationCurveBoundaryAlternativesFromBase( + baseBoundaryLines, boundaryCurves, connectorLines, + ) { + visible, lineworkErr = geodata.VisibleLineworkPolygons( + candidate, selectionFill, curveCoverage, 75, + ) + if lineworkErr == nil { + break + } + } + } + } + } + } + if lineworkErr != nil && useContactContours { + if sweepLines := computeContactSweepLines(); len(sweepLines) > 0 { + sweepBaseBoundaryLines := append(append([][]geodata.GeoPoint(nil), contactLines...), sweepLines...) + sweepBoundaryLines := occultationVisibleBoundaryLinesFromBase( + sweepBaseBoundaryLines, boundaryCurves, initialConnectorLines, + ) + visible, lineworkErr = geodata.VisibleLineworkPolygons( + sweepBoundaryLines, selectionFill, coveragePaths, 75, + ) + if lineworkErr != nil { + for _, candidate := range occultationCurveBoundaryAlternativesFromBase( + sweepBaseBoundaryLines, boundaryCurves, initialConnectorLines, + ) { + visible, lineworkErr = geodata.VisibleLineworkPolygons( + candidate, selectionFill, coveragePaths, 75, + ) + if lineworkErr == nil { + break + } + } + } + if lineworkErr != nil && len(curveCoveragePaths) > 0 { + curveCoverage := limitOccultationCoveragePaths(curveCoveragePaths, 2048) + visible, lineworkErr = geodata.VisibleLineworkPolygons( + sweepBoundaryLines, selectionFill, curveCoverage, 75, + ) + if lineworkErr != nil { + for _, candidate := range occultationCurveBoundaryAlternativesFromBase( + sweepBaseBoundaryLines, boundaryCurves, initialConnectorLines, + ) { + visible, lineworkErr = geodata.VisibleLineworkPolygons( + candidate, selectionFill, curveCoverage, 75, + ) + if lineworkErr == nil { + break + } + } + } + } + if lineworkErr != nil && len(initialConnectorLines) == 0 { + connectorLines := occultationHorizonConnectorBoundaryLines( + HorizonConnectorSegments(footprints, curves, northern, southern), + ) + if len(connectorLines) > 0 { + connectorBoundaryLines := append([][]geodata.GeoPoint(nil), sweepBoundaryLines...) + connectorBoundaryLines = append(connectorBoundaryLines, connectorLines...) + visible, lineworkErr = geodata.VisibleLineworkPolygons( + connectorBoundaryLines, selectionFill, coveragePaths, 75, + ) + if lineworkErr != nil { + for _, candidate := range occultationCurveBoundaryAlternativesFromBase( + sweepBaseBoundaryLines, boundaryCurves, connectorLines, + ) { + visible, lineworkErr = geodata.VisibleLineworkPolygons( + candidate, selectionFill, coveragePaths, 75, + ) + if lineworkErr == nil { + break + } + } + } + if lineworkErr != nil && len(curveCoveragePaths) > 0 { + curveCoverage := limitOccultationCoveragePaths(curveCoveragePaths, 2048) + visible, lineworkErr = geodata.VisibleLineworkPolygons( + connectorBoundaryLines, selectionFill, curveCoverage, 75, + ) + if lineworkErr != nil { + for _, candidate := range occultationCurveBoundaryAlternativesFromBase( + sweepBaseBoundaryLines, boundaryCurves, connectorLines, + ) { + visible, lineworkErr = geodata.VisibleLineworkPolygons( + candidate, selectionFill, curveCoverage, 75, + ) + if lineworkErr == nil { + break + } + } + } + } + } + } + } + } + return visible, lineworkErr +} + +func occultationRetryStableContactBoundary( + options occultationVisibleBandLineworkOptions, +) ([][]geodata.GeoPoint, error) { + stableBoundary, err := options.computeSweep() + if err != nil { + return nil, err + } + stableBoundaryLines := occultationVisibleBoundaryLines( + stableBoundary, options.boundaryCurves, options.initialConnectorLines, + ) + if options.useContactContours { + if sweepLines := options.computeContactSweepLines(); len(sweepLines) > 0 { + stableBoundaryLines = occultationVisibleBoundaryLinesFromBase( + sweepLines, options.boundaryCurves, options.initialConnectorLines, + ) + } + } + return geodata.VisibleLineworkPolygons( + stableBoundaryLines, options.visibleFill, options.visibleFillCoveragePaths, 75, + ) +} + +// occultationSweepWitnessedByContactContours 报告扫掠边界是否被连续接触包络见证。 +func occultationSweepWitnessedByContactContours( + polygons [][]geodata.GeoPoint, + contours [][]basic.OccultationPathPoint, +) bool { + if len(polygons) == 0 || len(contours) == 0 { + return false + } + contactLines := occultationContactContourBoundaryLines(contours) + if len(contactLines) == 0 { + return false + } + return geodata.SphericalPolygonsContainPathsWithinKM(polygons, contactLines, true, 25) +} + +// authoritativeFallbackBandPolygons accepts the instantaneous sweep only when +// its resulting boundary remains tightly attached to the sampled contact +// contours. Some polar Saturn events expose several valid phase branches but +// no single start/end/connector cycle; the sweep is still an authoritative +// geometry in that case because its boundary is independently witnessed by the +// continuous contact envelope. A generous miss would turn an arbitrary face +// into a false authoritative result, so keep this gate deliberately small. +func authoritativeFallbackBandPolygons( + fallback [][]geodata.GeoPoint, + contours [][]basic.OccultationPathPoint, + strongPolarSmoothing bool, +) ([][]geodata.GeoPoint, bool) { + if !occultationSweepWitnessedByContactContours(fallback, contours) { + return nil, false + } + // The fallback is accepted as authoritative only because its boundary is + // witnessed by the continuous contact contours. First complete the shared + // cleanup, then round the finished sweep ring once. Applying this rounder to + // intermediate linework can preserve a seam which the later union would + // otherwise remove. + cleaned := cleanupOccultationAuthoritativeBandPolygons(fallback) + return roundOccultationAuthoritativeBandJunctions(cleaned), true +} + +func occultationContourFillAndCoverage( + useContactContours bool, + northern, southern []basic.OccultationPathPoint, + coveragePaths [][]geodata.GeoPoint, +) ([][]geodata.GeoPoint, [][]geodata.GeoPoint) { + if !useContactContours { + return nil, coveragePaths + } + contourFill := occultationLimitVisibleFillPolygons(northern, southern) + if len(contourFill) > 0 { + coveragePaths = append(coveragePaths, contourFill...) + } + return contourFill, coveragePaths +} + +func occultationNoVisibleBandFill( + useContactContours bool, + computeSweep func() ([][]geodata.GeoPoint, error), + fallback [][]geodata.GeoPoint, + strongPolarSmoothing bool, +) ([][]geodata.GeoPoint, bool, error) { + if useContactContours { + fallback, err := computeSweep() + if err != nil { + return nil, false, err + } + return cleanupOccultationVisibleBandPolygons(fallback, strongPolarSmoothing), false, nil + } + return cleanupOccultationVisibleBandPolygons(fallback, strongPolarSmoothing), false, nil +} + +func occultationPreferredPhaseBand( + useContactContours bool, + footprints []basic.OccultationFootprint, + curves []basic.OccultationRiseSetCurve, + northern, southern []basic.OccultationPathPoint, + contourFill [][]geodata.GeoPoint, +) ([][]geodata.GeoPoint, bool) { + if !useContactContours { + return nil, false + } + return occultationPhaseBoundaryPolygons( + curves, + HorizonConnectorSegments(footprints, curves, northern, southern), + contourFill, + ) +} + +func occultationMergePreferredVisibleBand( + visible [][]geodata.GeoPoint, + useContactContours bool, + footprints []basic.OccultationFootprint, + curves []basic.OccultationRiseSetCurve, + northern, southern []basic.OccultationPathPoint, + contourFill, visibleFill, visibleFillCoveragePaths [][]geodata.GeoPoint, +) [][]geodata.GeoPoint { + if len(visible) > 1 { + visible = removeOccultationPolarSliverComponents(visible) + } + if !useContactContours || len(visible) == 0 { + return visible + } + preferredVisible, preferredOK := occultationPreferredPhaseBand( + useContactContours, footprints, curves, northern, southern, contourFill, + ) + if !preferredOK || len(preferredVisible) == 0 { + return visible + } + candidate := append([][]geodata.GeoPoint(nil), visible...) + candidate = append(candidate, preferredVisible...) + merged, unionErr := geodata.UnionPolygons(candidate) + if unionErr != nil { + return visible + } + pruned := RemoveTinyPolygonComponents(merged) + if len(pruned) == 0 { + return visible + } + if witnessErr := validateOccultationVisibleBandWitnesses( + pruned, useContactContours, visibleFill, contourFill, visibleFillCoveragePaths, + ); witnessErr != nil { + return visible + } + return pruned +} + +func occultationFastPolarBand( + useContactContours, strongPolarSmoothing bool, + boundaryLines, visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, +) ([][]geodata.GeoPoint, bool) { + if !useContactContours || !strongPolarSmoothing { + return nil, false + } + // The fast polar attempt only needs the source fills as witnesses. Building + // curve-side probes here duplicates the expensive spherical containment pass + // that the general retry path performs after this candidate is rejected. + selectionFill := append([][]geodata.GeoPoint(nil), contourFill...) + selectionFill = append(selectionFill, visibleFill...) + fastCoveragePaths := coveragePaths + visible, err := geodata.VisibleLineworkPolygons( + boundaryLines, selectionFill, fastCoveragePaths, 75, + ) + if err != nil { + return nil, false + } + return visible, true +} + +// occultationFastPolarBandAccepted keeps the cheap polar polygonizer on the +// normal path only when its selected face agrees with both independent +// witnesses: the instantaneous visible footprints and every exported phase +// curve. A folded horizon can otherwise produce a valid complement that +// looks like a staircase and leaves the purple visibility line outside the +// blue fill. The miss-distance checks are bounded by the existing probe +// budgets, so rejected faces fall through to the deterministic retries below. +func occultationFastPolarBandAccepted( + visible, visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, +) bool { + if len(visible) == 0 { + return false + } + if validateOccultationVisibleBandWitnesses( + visible, true, visibleFill, contourFill, coveragePaths, + ) != nil { + return false + } + phasePaths := make([][]geodata.GeoPoint, 0, len(curves)*2) + for _, curve := range curves { + for _, line := range occultationCurveBoundaryLines(curve) { + if len(line) >= 2 { + phasePaths = append(phasePaths, line) + } + } + } + if len(phasePaths) == 0 { + return true + } + // A phase curve may lie exactly on the static ring; allow a small numerical + // tolerance for the spherical edge/midpoint probes but reject a different + // polygonizer face by a clearly visible (>75 km) displacement. + return geodata.SphericalPolygonsContainPathsWithinKM( + visible, phasePaths, true, 10, + ) +} + +func occultationPreferredPhaseBandAccepted( + preferred, contourFill, visibleFill [][]geodata.GeoPoint, +) bool { + if len(preferred) == 0 || len(contourFill) == 0 { + return false + } + // Open instantaneous footprints can become very long at lunar rise/set. + // They are valid timeline geometry, but their horizon closure is not the + // compact band's outer envelope. The phase-cycle constructor has already + // checked both directions against the continuous contact contour; repeat + // that bounded check here without allowing the horizon-extended footprint + // fill to reject the physical compact cycle. + if !geodata.SphericalPolygonsContainPathsWithinKM(preferred, contourFill, true, 150) { + return false + } + if !geodata.SphericalPolygonsContainPathsWithinKM(contourFill, preferred, true, 200) { + return false + } + // The phase cycle is only an outer closure candidate. When instantaneous + // visible witnesses extend farther along the event interval, accepting the + // compact cycle would silently turn the full time-union into a moonrise/ + // moonset-only strip. Require the candidate to cover those witnesses before + // promoting it to the static band. + if len(visibleFill) > 0 { + if !geodata.SphericalPolygonsContainPathsWithinKM(preferred, visibleFill, true, 50) { + return false + } + } + return true +} + +func occultationVisibleBandLineworkInputs( + useContactContours, strongPolarSmoothing bool, + visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint, + curves []basic.OccultationRiseSetCurve, +) ( + [][]geodata.GeoPoint, + [][]geodata.GeoPoint, + [][]geodata.GeoPoint, + [][]geodata.GeoPoint, +) { + footprintCoveragePaths := append([][]geodata.GeoPoint(nil), coveragePaths...) + // Contact contours remain the primary static envelope for both partial and + // total bands, but the footprint polygons still carry real interior witness + // points. Feed both into face selection so the chosen face stays anchored to + // the contour while also inheriting the footprint lobe that only the sweep + // sees. + selectionFill := visibleFill + if useContactContours && len(contourFill) > 0 { + selectionFill = append(append([][]geodata.GeoPoint(nil), contourFill...), visibleFill...) + } + if !useContactContours { + // Legacy callers provide rise/set curves without a continuous contact + // contour. Their footprint fill already supplies the face witnesses; curve + // side probes would repeat an O(curve-points * fill-edges) spherical scan + // without adding a reliable distinction between faces. + return selectionFill, coveragePaths, nil, footprintCoveragePaths + } + curveProbeFill := selectionFill + if len(contourFill) > 0 { + curveProbeFill = contourFill + } + curveCoveragePaths := occultationCurveCoverageProbes(curves, curveProbeFill) + coveragePaths = append(coveragePaths, curveCoveragePaths...) + coveragePaths = limitOccultationCoveragePaths(coveragePaths, 2048) + visibleFillCoveragePaths := append([][]geodata.GeoPoint(nil), footprintCoveragePaths...) + visibleFillCoveragePaths = append(visibleFillCoveragePaths, curveCoveragePaths...) + visibleFillCoveragePaths = limitOccultationCoveragePaths(visibleFillCoveragePaths, 2048) + return selectionFill, coveragePaths, curveCoveragePaths, visibleFillCoveragePaths +} + +func occultationVisibleFillAndCoverage( + footprints []basic.OccultationFootprint, +) ([][]geodata.GeoPoint, [][]geodata.GeoPoint) { + visibleFill := make([][]geodata.GeoPoint, 0, len(footprints)*2) + coveragePaths := make([][]geodata.GeoPoint, 0, len(footprints)) + for _, footprint := range footprints { + for _, source := range footprint.Polygons { + if len(source) < 3 { + continue + } + if occultationInteriorPolygon(source, footprint.InteriorPolygons) { + continue + } + visibleSource := clipOccultationPolygonToHorizon(source) + if len(visibleSource) < 3 { + continue + } + polygon := make([]geodata.GeoPoint, len(visibleSource)) + for index, point := range visibleSource { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + visibleFill = append(visibleFill, polygon) + if !occultationPolygonNeedsInteriorProbes(visibleSource) { + continue + } + for _, probe := range occultationVisibleFootprintProbes(visibleSource, polygon) { + if occultationCoverageProbeExists(coveragePaths, probe) { + continue + } + coveragePaths = append(coveragePaths, []geodata.GeoPoint{probe}) + } + } + } + return visibleFill, coveragePaths +} + +// occultationVisibleFootprintFillOnly builds the horizon-clipped source faces +// without the interior probe set used by polygon selection. The mask audit +// only needs the faces themselves, so keeping probe generation out of this +// path avoids repeating the expensive polar witness calculations. +func occultationVisibleFootprintFillOnly( + footprints []basic.OccultationFootprint, +) [][]geodata.GeoPoint { + visibleFill := make([][]geodata.GeoPoint, 0, len(footprints)*2) + for _, footprint := range footprints { + for _, source := range footprint.Polygons { + if len(source) < 3 || occultationInteriorPolygon(source, footprint.InteriorPolygons) { + continue + } + visibleSource := clipOccultationPolygonToHorizon(source) + if len(visibleSource) < 3 { + continue + } + polygon := make([]geodata.GeoPoint, len(visibleSource)) + for index, point := range visibleSource { + polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + visibleFill = append(visibleFill, polygon) + } + } + return visibleFill +} + +func occultationCoverageProbeExists( + coveragePaths [][]geodata.GeoPoint, + probe geodata.GeoPoint, +) bool { + for _, existing := range coveragePaths { + if geoDistanceKM(existing[0], probe) < 10 { + return true + } + } + return false +} diff --git a/internal/solarclosure/closure_test.go b/internal/solarclosure/closure_test.go new file mode 100644 index 0000000..ad4b788 --- /dev/null +++ b/internal/solarclosure/closure_test.go @@ -0,0 +1,262 @@ +package solarclosure + +import ( + "math" + "testing" + + "b612.me/astro/internal/geodata" +) + +var testSubsolar = geodata.GeoPoint{Longitude: 12, Latitude: 34} + +func testTerminator() []geodata.GeoPoint { + return Terminator(testSubsolar) +} + +// testFootprint 取地平圈上一段采样点当开放边界,两端擦地点各外扩两个采样点。 +func testFootprint() Footprint { + circle := testTerminator() + return Footprint{ + Boundaries: [][]geodata.GeoPoint{append([]geodata.GeoPoint(nil), circle[100:131]...)}, + HorizonEnds: []geodata.GeoPoint{circle[98], circle[133]}, + Subsolar: testSubsolar, + } +} + +func angleFromSubsolar(point geodata.GeoPoint) float64 { + cosine := math.Sin(testSubsolar.Latitude*math.Pi/180)*math.Sin(point.Latitude*math.Pi/180) + + math.Cos(testSubsolar.Latitude*math.Pi/180)*math.Cos(point.Latitude*math.Pi/180)* + math.Cos((point.Longitude-testSubsolar.Longitude)*math.Pi/180) + return math.Acos(math.Max(-1, math.Min(1, cosine))) * 180 / math.Pi +} + +func TestTerminatorPointsStayOnHorizonCircle(t *testing.T) { + circle := testTerminator() + if len(circle) != 360 { + t.Fatalf("terminator points=%d, want 360", len(circle)) + } + for index, point := range circle { + if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 { + t.Fatalf("point %d is %.12f degrees from the subsolar point, want 90", index, angle) + } + } +} + +func TestHorizonEndsPairsBothEndsByBoundaryStart(t *testing.T) { + circle := testTerminator() + for _, test := range []struct { + name string + ends []geodata.GeoPoint + want []geodata.GeoPoint + }{ + {name: "in order", ends: []geodata.GeoPoint{circle[98], circle[133]}, + want: []geodata.GeoPoint{circle[98], circle[133]}}, + {name: "reversed", ends: []geodata.GeoPoint{circle[133], circle[98]}, + want: []geodata.GeoPoint{circle[98], circle[133]}}, + } { + footprint := testFootprint() + footprint.HorizonEnds = test.ends + got := HorizonEnds(footprint) + if len(got) != 2 { + t.Fatalf("%s: ends=%d, want 2", test.name, len(got)) + } + for index := range got { + if got[index] != test.want[index] { + t.Fatalf("%s: ends[%d]=%v, want %v", test.name, index, got[index], test.want[index]) + } + } + } +} + +func TestHorizonEndsRejectsIncompleteInput(t *testing.T) { + circle := testTerminator() + base := testFootprint() + for _, test := range []struct { + name string + footprint Footprint + }{ + {name: "no ends", footprint: Footprint{Boundaries: base.Boundaries}}, + {name: "one end", footprint: Footprint{Boundaries: base.Boundaries, + HorizonEnds: []geodata.GeoPoint{circle[98]}}}, + {name: "three ends", footprint: Footprint{Boundaries: base.Boundaries, + HorizonEnds: []geodata.GeoPoint{circle[98], circle[133], circle[134]}}}, + {name: "no boundaries", footprint: Footprint{HorizonEnds: base.HorizonEnds}}, + {name: "empty first segment", footprint: Footprint{ + Boundaries: [][]geodata.GeoPoint{{}}, HorizonEnds: base.HorizonEnds}}, + } { + if ends := HorizonEnds(test.footprint); ends != nil { + t.Fatalf("%s: ends=%v, want nil", test.name, ends) + } + if ExactHorizon(test.footprint) { + t.Fatalf("%s: ExactHorizon=true, want false", test.name) + } + } + if !ExactHorizon(base) { + t.Fatal("ExactHorizon=false for a footprint carrying two grazing points") + } +} + +func TestRingClosesOpenBoundaryAtGrazingPoints(t *testing.T) { + footprint := testFootprint() + curve := Curve(footprint) + if len(curve) != 31 { + t.Fatalf("curve points=%d, want 31", len(curve)) + } + ends := HorizonEnds(footprint) + ring, boundary, ok := Ring(footprint, true) + if !ok { + t.Fatal("Ring reported an unusable boundary") + } + if len(ring) <= len(curve) { + t.Fatalf("ring points=%d, want more than the %d boundary points", len(ring), len(curve)) + } + for index, point := range curve { + if ring[index] != point { + t.Fatalf("ring[%d]=%v, want the boundary point %v", index, ring[index], point) + } + } + if ring[len(curve)] != ends[1] { + t.Fatalf("ring[%d]=%v, want the trailing grazing point %v", len(curve), ring[len(curve)], ends[1]) + } + if ring[len(ring)-1] != ends[0] { + t.Fatalf("ring ends at %v, want the leading grazing point %v", ring[len(ring)-1], ends[0]) + } + if len(boundary) != len(curve)+2 || boundary[0] != ends[0] || + boundary[len(boundary)-1] != ends[1] { + t.Fatalf("boundary=%d points starting %v ending %v", len(boundary), boundary[0], + boundary[len(boundary)-1]) + } + for index, point := range boundary[1 : len(boundary)-1] { + if point != curve[index] { + t.Fatalf("boundary[%d]=%v, want %v", index+1, point, curve[index]) + } + } + for index, point := range ring { + if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 { + t.Fatalf("ring[%d] is %.12f degrees from the subsolar point, want 90", index, angle) + } + } +} + +func TestRingFallsBackToSampledTerminatorArc(t *testing.T) { + footprint := testFootprint() + curve := Curve(footprint) + for _, test := range []struct { + name string + input Footprint + exact bool + }{ + {name: "no grazing points", input: Footprint{ + Boundaries: footprint.Boundaries, Subsolar: testSubsolar}, exact: true}, + {name: "approximate requested", input: footprint, exact: false}, + } { + ring, boundary, ok := Ring(test.input, test.exact) + if !ok { + t.Fatalf("%s: Ring reported an unusable boundary", test.name) + } + if len(ring) <= len(curve) || ring[len(ring)-1] != curve[0] { + t.Fatalf("%s: ring closes at %v, want the boundary start %v", + test.name, ring[len(ring)-1], curve[0]) + } + for index, point := range curve { + if ring[index] != point { + t.Fatalf("%s: ring[%d]=%v, want %v", test.name, index, ring[index], point) + } + } + if len(boundary) != len(curve) { + t.Fatalf("%s: boundary points=%d, want the %d boundary points", test.name, len(boundary), len(curve)) + } + for index, point := range ring { + if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 { + t.Fatalf("%s: ring[%d] is %.12f degrees from the subsolar point, want 90", + test.name, index, angle) + } + } + } +} + +func TestRingDegenerateInputs(t *testing.T) { + circle := testTerminator() + closed := []geodata.GeoPoint{circle[10], circle[40], circle[70], circle[10]} + for _, test := range []struct { + name string + footprint Footprint + wantOK bool + wantRing int + }{ + {name: "no boundary", footprint: Footprint{Closed: true}, wantOK: false}, + {name: "closed triangle", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{ + {circle[10], circle[40], circle[70]}}, Closed: true}, wantOK: true, wantRing: 3}, + {name: "closed with repeated point", footprint: Footprint{ + Boundaries: [][]geodata.GeoPoint{closed}, Closed: true}, wantOK: true, wantRing: 3}, + {name: "closed segment", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{ + {circle[10], circle[40]}}, Closed: true}, wantOK: false}, + {name: "single open point", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{ + {circle[10]}}}, wantOK: true, wantRing: 1}, + {name: "open segment without grazing points", footprint: Footprint{ + Boundaries: [][]geodata.GeoPoint{{circle[10], circle[40]}}, + Subsolar: testSubsolar}, wantOK: true}, + } { + ring, _, ok := Ring(test.footprint, true) + if ok != test.wantOK { + t.Fatalf("%s: ok=%v, want %v", test.name, ok, test.wantOK) + } + if test.wantRing > 0 && len(ring) != test.wantRing { + t.Fatalf("%s: ring points=%d, want %d", test.name, len(ring), test.wantRing) + } + } +} + +func TestHorizonRingAcceptsPrejoinedCurve(t *testing.T) { + footprint := testFootprint() + curve := Curve(footprint) + ring, boundary := HorizonRing(footprint, curve) + ends := HorizonEnds(footprint) + if len(boundary) != len(curve)+2 || boundary[0] != ends[0] || + boundary[len(boundary)-1] != ends[1] { + t.Fatalf("boundary=%d points, want the leading and trailing grazing points around %d points", + len(boundary), len(curve)) + } + if len(ring) < len(curve)+2 || ring[len(curve)] != ends[1] || ring[len(ring)-1] != ends[0] { + t.Fatalf("ring=%d points, want the grazing points closing %d boundary points", + len(ring), len(curve)) + } +} + +func TestBandPolygonsSelectsFaceCoveredByFootprint(t *testing.T) { + box := [][2]geodata.GeoPoint{ + {{Longitude: 0, Latitude: 0}, {Longitude: 10, Latitude: 0}}, + {{Longitude: 10, Latitude: 0}, {Longitude: 10, Latitude: 10}}, + {{Longitude: 10, Latitude: 10}, {Longitude: 0, Latitude: 10}}, + {{Longitude: 0, Latitude: 10}, {Longitude: 0, Latitude: 0}}, + } + contours := make([][]geodata.GeoPoint, 0, len(box)) + for _, edge := range box { + contours = append(contours, []geodata.GeoPoint{edge[0], edge[1]}) + } + inside := []geodata.GeoPoint{ + {Longitude: 4, Latitude: 4}, {Longitude: 6, Latitude: 4}, + {Longitude: 6, Latitude: 6}, {Longitude: 4, Latitude: 6}, + } + footprints := []Footprint{{Boundaries: [][]geodata.GeoPoint{inside}, Closed: true}} + polygons, ok := BandPolygons(contours, nil, footprints, true, SnapDistanceKM) + if !ok || len(polygons) == 0 { + t.Fatalf("band polygons unavailable: ok=%v count=%d", ok, len(polygons)) + } + probes := []geodata.GeoPoint{{Longitude: 5, Latitude: 5}, {Longitude: 1, Latitude: 9}} + contained := geodata.SphericalPolygonsContainPoints(polygons, probes) + if len(contained) != 2 || !contained[0] || !contained[1] { + t.Fatalf("containment=%v, want the whole box face selected", contained) + } + outside := geodata.SphericalPolygonsContainPoints(polygons, + []geodata.GeoPoint{{Longitude: 15, Latitude: 5}}) + if outside[0] { + t.Fatal("a point outside the boundary network was selected") + } +} + +func TestBandPolygonsRejectsEmptyBoundaryNetwork(t *testing.T) { + if polygons, ok := BandPolygons(nil, nil, nil, true, SnapDistanceKM); ok || polygons != nil { + t.Fatalf("polygons=%d ok=%v, want no polygons", len(polygons), ok) + } +} diff --git a/internal/solarclosure/solarclosure.go b/internal/solarclosure/solarclosure.go new file mode 100644 index 0000000..f0e1bc9 --- /dev/null +++ b/internal/solarclosure/solarclosure.go @@ -0,0 +1,179 @@ +// Package solarclosure 把被地平线切断的日食偏食足迹闭合到地平圈,并把零食分包络与 +// 日升日落相位线并集成一块可见域。 +// +// Package solarclosure closes horizon-cut solar-eclipse footprints onto the horizon and +// unions the zero-magnitude envelope with the rise/set phase lines into one visibility region. +package solarclosure + +import ( + "math" + + "b612.me/astro/internal/geodata" +) + +// Footprint 是一个瞬时足迹的闭合输入。 +type Footprint struct { + // Boundaries 是物理边界分段;反经线或无效投影会拆成多段。 + Boundaries [][]geodata.GeoPoint + // HorizonEnds 是未闭合边界两端的地平擦地点,顺序任意,使用时按 Boundaries 走向排序。 + HorizonEnds []geodata.GeoPoint + // Subsolar 是该时刻的太阳直射点,缺少精确擦地点时用它采样地平圈近似补口。 + Subsolar geodata.GeoPoint + // Closed 表示 Boundaries 自身闭合,不需要补口。 + Closed bool +} + +// Terminator 返回以太阳直射点为圆心的地平圈采样点。 +func Terminator(subsolar geodata.GeoPoint) []geodata.GeoPoint { + return geodata.SphericalCircle(subsolar, 90, 360) +} + +// HorizonEnds 按 Boundaries 走向排序两个地平擦地点;点数不是 2 或首段为空时返回 nil。 +func HorizonEnds(footprint Footprint) []geodata.GeoPoint { + if len(footprint.HorizonEnds) != 2 || len(footprint.Boundaries) == 0 || + len(footprint.Boundaries[0]) == 0 { + return nil + } + points := []geodata.GeoPoint{footprint.HorizonEnds[0], footprint.HorizonEnds[1]} + if pointDistanceKM(points[0], footprint.Boundaries[0][0]) > + pointDistanceKM(points[1], footprint.Boundaries[0][0]) { + points[0], points[1] = points[1], points[0] + } + return points +} + +// ExactHorizon 报告足迹能否用两个精确擦地点闭合。 +func ExactHorizon(footprint Footprint) bool { + return len(HorizonEnds(footprint)) == 2 +} + +// Curve 返回足迹的物理边界折线,重复的闭合点已去掉。 +func Curve(footprint Footprint) []geodata.GeoPoint { + return openRing(geodata.JoinPolylineSegments(footprint.Boundaries)) +} + +// Ring 返回足迹的填充环与补口后的物理边界折线;ok 为假表示边界点不足以成环。 +// exact 为假或缺少擦地点时按 Subsolar 地平圈的最短弧近似补口。 +func Ring(footprint Footprint, exact bool) (ring, boundary []geodata.GeoPoint, ok bool) { + curve := Curve(footprint) + // 单点开放边界既不能补口也不该报错:调用方按退化区域丢弃。 + if len(curve) == 1 && !footprint.Closed { + return curve, curve, true + } + minimumPoints := 3 + if !footprint.Closed { + minimumPoints = 2 + } + if len(curve) < minimumPoints { + return nil, nil, false + } + if footprint.Closed { + return append([]geodata.GeoPoint(nil), curve...), curve, true + } + ring, boundary = closeOpen(footprint, curve, exact) + if len(openRing(ring)) < 3 { + return nil, nil, false + } + return ring, boundary, true +} + +// HorizonRing 补出已拼接的开放边界 curve 的填充环与物理边界折线,不做点数校验。 +func HorizonRing(footprint Footprint, curve []geodata.GeoPoint) (ring, boundary []geodata.GeoPoint) { + return closeOpen(footprint, curve, true) +} + +// SnapDistanceKM 是并集线网的节点吸附尺度:比这更近的交点按同一个物理节点处理。 +const SnapDistanceKM = 25 + +// BandPolygons 以零食分连续包络和日升日落相位线为线网、瞬时足迹为覆盖面, +// 返回偏食可见域的并集;ok 为假表示线网无法成面。 +// exact 为真时瞬时足迹按精确擦地点补口,为假时按 Subsolar 地平圈近似补口。 +func BandPolygons( + contours, phaseLines [][]geodata.GeoPoint, + footprints []Footprint, + exact bool, + snapDistanceKM float64, +) ([][]geodata.GeoPoint, bool) { + boundaryLines := make([][]geodata.GeoPoint, 0, len(contours)+len(phaseLines)) + boundaryLines = append(boundaryLines, contours...) + boundaryLines = append(boundaryLines, phaseLines...) + fillPolygons := make([][]geodata.GeoPoint, 0, len(footprints)) + coveragePaths := make([][]geodata.GeoPoint, 0, len(footprints)*2) + for _, footprint := range footprints { + for _, segment := range footprint.Boundaries { + if len(segment) > 0 { + coveragePaths = append(coveragePaths, segment) + } + } + if ring, _, ok := Ring(footprint, exact); ok && len(ring) >= 3 { + fillPolygons = append(fillPolygons, ring) + } + } + polygons, err := geodata.VisibleLineworkPolygons( + boundaryLines, fillPolygons, coveragePaths, snapDistanceKM, + ) + if err != nil || len(polygons) == 0 { + return nil, false + } + for polygonIndex := range polygons { + for pointIndex := range polygons[polygonIndex] { + polygons[polygonIndex][pointIndex].Longitude = + normalizeLongitude(polygons[polygonIndex][pointIndex].Longitude) + } + } + return polygons, true +} + +// closeOpen 用精确擦地点闭合开放边界;擦地点缺失或 exact 为假时改用 Subsolar 地平圈的近似弧。 +func closeOpen(footprint Footprint, curve []geodata.GeoPoint, exact bool) ([]geodata.GeoPoint, []geodata.GeoPoint) { + if len(curve) == 0 { + return nil, nil + } + ends := []geodata.GeoPoint(nil) + if exact { + ends = HorizonEnds(footprint) + } + if len(ends) != 2 { + ring := append([]geodata.GeoPoint(nil), curve...) + arc := geodata.ShortestCircleArc(Terminator(footprint.Subsolar), curve[len(curve)-1], curve[0]) + if len(arc) > 1 { + ring = append(ring, arc[1:]...) + } + return ring, curve + } + boundary := make([]geodata.GeoPoint, 0, len(curve)+2) + boundary = append(boundary, ends[0]) + boundary = append(boundary, curve...) + boundary = append(boundary, ends[1]) + ring := make([]geodata.GeoPoint, 0, len(curve)+3) + ring = append(ring, curve...) + ring = append(ring, ends[1]) + arc := geodata.ShortestCircleArc(Terminator(footprint.Subsolar), ends[1], ends[0]) + if len(arc) > 1 { + ring = append(ring, arc[1:]...) + } + return ring, boundary +} + +func openRing(points []geodata.GeoPoint) []geodata.GeoPoint { + if len(points) > 1 && geodata.SameGeoPoint(points[0], points[len(points)-1]) { + return points[:len(points)-1] + } + return points +} + +func normalizeLongitude(value float64) float64 { + value = math.Mod(value+180, 360) + if value < 0 { + value += 360 + } + return value - 180 +} + +func pointDistanceKM(first, second geodata.GeoPoint) float64 { + lat1, lat2 := first.Latitude*math.Pi/180, second.Latitude*math.Pi/180 + dlat := lat2 - lat1 + dlon := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi) + h := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2) + return 6371.0088 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, h)))) +} diff --git a/internal/svgasset/moon.go b/internal/svgasset/moon.go index 1f04c9a..d71c754 100644 --- a/internal/svgasset/moon.go +++ b/internal/svgasset/moon.go @@ -3,8 +3,8 @@ package svgasset import _ "embed" -// moonFaceSymbol 是精简的公版月面图形,来源于 / moonFaceSymbol is a compact public-domain Moon face derived from -// labs/Full_Moon_clip_art.svg,并针对小型接触盘面做了简化 / labs/Full_Moon_clip_art.svg and simplified for small contact disks. +// moonFaceSymbol 是精简的公版月面图形,针对小型接触盘面做了简化。 +// moonFaceSymbol is a compact public-domain Moon face simplified for small contact disks. // //go:embed lunar_eclipse_moon.svg var moonFaceSymbol string diff --git a/internal/svgchart/label.go b/internal/svgchart/label.go new file mode 100644 index 0000000..fdf4863 --- /dev/null +++ b/internal/svgchart/label.go @@ -0,0 +1,127 @@ +package svgchart + +// LabelBox 是文本或图形占位的保守包围盒。/ LabelBox is a conservative bounding box for a reserved label or graphic. +type LabelBox struct { + X, Y, Width, Height float64 +} + +// LabelPlacement 是一个候选位次:文本锚点与 text-anchor 取值。 +// LabelPlacement is one candidate slot: the text anchor point and its text-anchor value. +type LabelPlacement struct { + X, Y float64 + Anchor string +} + +// LabelTable 记录已占位的矩形,后来的标签按候选顺序避让。 +// LabelTable records reserved rectangles; later labels avoid them in candidate order. +type LabelTable struct { + boxes []LabelBox +} + +// labelClearance 是落位判定要求的间隙:包围盒各向外放 1 px,写出的坐标取整后两个盒子也不会贴成相交。 +const labelClearance = 1.0 + +// Reserve 占位一个矩形;非正宽高直接忽略。 +// Reserve claims a rectangle; non-positive extents are ignored. +func (table *LabelTable) Reserve(x, y, width, height float64) { + if width <= 0 || height <= 0 { + return + } + table.boxes = append(table.boxes, LabelBox{X: x, Y: y, Width: width, Height: height}) +} + +// LabelTextBox 返回文本的保守包围盒,anchor 取 start、middle 或 end。 +// LabelTextBox returns a text's conservative box for a start, middle, or end anchor. +func LabelTextBox(x, baseline, fontSize float64, text, anchor string) LabelBox { + if fontSize <= 0 { + fontSize = 12 + } + width := EstimatedTextWidth(text, fontSize) + left := x + switch anchor { + case "middle": + left = x - width/2 + case "end": + left = x - width + } + above, below := EstimatedTextExtents(fontSize) + return LabelBox{X: left, Y: baseline - above, Width: width, Height: above + below} +} + +// ReserveText 占位一段文本并返回它的包围盒。 +// ReserveText claims a text box and returns it. +func (table *LabelTable) ReserveText(x, baseline, fontSize float64, text, anchor string) LabelBox { + box := LabelTextBox(x, baseline, fontSize, text, anchor) + table.Reserve(box.X, box.Y, box.Width, box.Height) + return box +} + +// Overlaps 报告矩形是否压到已占位矩形;两个盒子之间至少留 labelClearance。 +// Overlaps reports whether a rectangle collides with any reserved box, keeping labelClearance between them. +func (table *LabelTable) Overlaps(x, y, width, height float64) bool { + for _, box := range table.boxes { + if box.X+box.Width+labelClearance <= x || x+width+labelClearance <= box.X { + continue + } + if box.Y+box.Height+labelClearance <= y || y+height+labelClearance <= box.Y { + continue + } + return true + } + return false +} + +// OverlapsText 报告文本是否压到已占位矩形。 +// OverlapsText reports whether a text collides with any reserved box. +func (table *LabelTable) OverlapsText(x, baseline, fontSize float64, text, anchor string) bool { + box := LabelTextBox(x, baseline, fontSize, text, anchor) + return table.Overlaps(box.X, box.Y, box.Width, box.Height) +} + +// Place 按候选顺序放一段文本:返回首个不压叠的位次并占位;全部压叠时返回 false 且不占位。 +// Place takes the first candidate that keeps the text clear, claims it, and reports false when none does. +func (table *LabelTable) Place(text string, fontSize float64, candidates []LabelPlacement) (LabelPlacement, bool) { + for _, candidate := range candidates { + box := LabelTextBox(candidate.X, candidate.Y, fontSize, text, candidate.Anchor) + if table.Overlaps(box.X, box.Y, box.Width, box.Height) { + continue + } + table.Reserve(box.X, box.Y, box.Width, box.Height) + return candidate, true + } + return LabelPlacement{}, false +} + +// MirrorLabelAnchor 返回镜像后的 text-anchor:start 与 end 互换,middle 不变。 +// MirrorLabelAnchor flips start and end, and keeps middle. +func MirrorLabelAnchor(anchor string) string { + switch anchor { + case "start": + return "end" + case "end": + return "start" + default: + return "middle" + } +} + +// LabelCandidates 生成绕锚点的候选位次:原位、上下左右、四角,再按镜像 anchor 重复一遍。 +// LabelCandidates lists candidate slots around an anchor: in place, then the four sides, then the +// four corners, repeated for the mirrored anchor. +func LabelCandidates(x, y float64, anchor string, radius float64) []LabelPlacement { + candidates := make([]LabelPlacement, 0, 18) + for _, current := range []string{anchor, MirrorLabelAnchor(anchor)} { + candidates = append(candidates, + LabelPlacement{X: x, Y: y, Anchor: current}, + LabelPlacement{X: x, Y: y - radius, Anchor: current}, + LabelPlacement{X: x, Y: y + radius, Anchor: current}, + LabelPlacement{X: x + radius, Y: y, Anchor: current}, + LabelPlacement{X: x - radius, Y: y, Anchor: current}, + LabelPlacement{X: x + radius, Y: y - radius, Anchor: current}, + LabelPlacement{X: x - radius, Y: y - radius, Anchor: current}, + LabelPlacement{X: x + radius, Y: y + radius, Anchor: current}, + LabelPlacement{X: x - radius, Y: y + radius, Anchor: current}, + ) + } + return candidates +} diff --git a/internal/svgchart/panel.go b/internal/svgchart/panel.go new file mode 100644 index 0000000..ee4bc1b --- /dev/null +++ b/internal/svgchart/panel.go @@ -0,0 +1,162 @@ +// Package svgchart 提供日月食与月掩星详细版式共用的数据块与版面计算。 +// Package svgchart holds the data blocks and page geometry shared by the detailed eclipse and occultation charts. +package svgchart + +import ( + "fmt" + "html" + "math" + "strings" +) + +// PanelRow 是数据块里的一行;Detail 非空时在数值下方补一行灰色小字。 +type PanelRow struct { + Label string + Value string + Detail string +} + +// PanelBlock 是一个带标题的数据块。 +type PanelBlock struct { + Title string + Rows []PanelRow +} + +// WritePanelBox 画一个数据块:标题加若干行“标签 + 数值”。 +// 行高按行数等分,因此块高变化时不会溢出;Class 用于区分各族图的样式钩子。 +func WritePanelBox( + b *strings.Builder, + class string, + x, y, width, height float64, + title string, + rows []PanelRow, +) { + if len(rows) == 0 || height <= 0 { + return + } + fmt.Fprintf(b, ``, class) + fmt.Fprintf(b, ``, + x, y, width, height) + fmt.Fprintf(b, `%s`, + x+12, y+20, html.EscapeString(title)) + rowHeight := (height - panelBoxHeaderHeight) / float64(len(rows)) + for index, row := range rows { + if row.Label == "" { + continue + } + rowY := y + panelBoxHeaderHeight + float64(index)*rowHeight + size := PanelRowFontSize(width, row) + fmt.Fprintf(b, `%s`, + x+panelBoxPadding, rowY+11, size, html.EscapeString(row.Label)) + fmt.Fprintf(b, `%s`, + x+width-panelBoxPadding, rowY+11, size, html.EscapeString(row.Value)) + if row.Detail != "" { + fmt.Fprintf(b, `%s`, + x+panelBoxPadding, rowY+22, panelRowDetailSize, html.EscapeString(row.Detail)) + } + } + b.WriteString(``) +} + +const ( + panelBoxHeaderHeight = 30 + panelRowLabelSize = 10.5 + panelRowDetailSize = 9 + panelBoxPadding = 12 + panelLabelValueGap = 8 + panelMinimumFontSize = 8 +) + +// EstimatedTextWidth 按字号估算文本宽度:ASCII 取 0.62em,非 ASCII(含全角中文)取 1.05em。 +// 没有字体度量可用,这里只服务于压叠判定,估值偏大以保证判定保守。 +// EstimatedTextWidth estimates text width from the font size: 0.62em per ASCII rune and 1.05em +// otherwise. It exists only for overlap checks, so it deliberately over-estimates. +func EstimatedTextWidth(text string, fontSize float64) float64 { + units := 0.0 + for _, symbol := range text { + if symbol < 128 { + units += 0.62 + continue + } + units += 1.05 + } + return units * fontSize +} + +// EstimatedTextExtents 返回文本相对基线的上下留白,用于构造占位矩形。 +// EstimatedTextExtents returns the space a text keeps above and below its baseline. +func EstimatedTextExtents(fontSize float64) (above, below float64) { + return 0.85 * fontSize, 0.30 * fontSize +} + +// PanelRowFontSize 返回该行“标签 + 数值”放不下时等比缩小后的字号;缩小到下限仍放不下说明块太窄。 +// PanelRowFontSize shrinks a row's font until its label and value fit side by side. +func PanelRowFontSize(width float64, row PanelRow) float64 { + content := width - 2*panelBoxPadding + if content <= 0 { + return panelMinimumFontSize + } + pair := EstimatedTextWidth(row.Label, panelRowLabelSize) + EstimatedTextWidth(row.Value, panelRowLabelSize) + if pair+panelLabelValueGap <= content { + return panelRowLabelSize + } + size := panelRowLabelSize * (content - panelLabelValueGap) / pair + if size < panelMinimumFontSize { + return panelMinimumFontSize + } + return size +} + +func panelTextRect(x, baseline, fontSize float64, text string, anchorEnd bool) panelTextBox { + width := EstimatedTextWidth(text, fontSize) + left := x + if anchorEnd { + left = x - width + } + above, below := EstimatedTextExtents(fontSize) + return panelTextBox{x: left, y: baseline - above, width: width, height: above + below} +} + +// panelTextBox 是文本的保守包围盒。 +type panelTextBox struct { + x, y, width, height float64 +} + +func panelTextBoxesOverlap(first, second panelTextBox) bool { + left, right := math.Max(first.x, second.x), math.Min(first.x+first.width, second.x+second.width) + top, bottom := math.Max(first.y, second.y), math.Min(first.y+first.height, second.y+second.height) + return right-left > 0.5 && bottom-top > 0 +} + +// PanelBoxOverlaps 报告该块在给定位次与尺寸下,块内文本是否会互相压叠。 +// 行高按行数等分,所以块太矮时行距会小于文字高度,此时必须由调用方拒绝该画布而不是画出压叠的文字。 +// PanelBoxOverlaps reports whether the block's own texts would collide at the given box; callers must +// reject a canvas whose panels cannot keep the rows apart. +func PanelBoxOverlaps(x, y, width, height float64, title string, rows []PanelRow) bool { + if len(rows) == 0 || height <= 0 { + return false + } + boxes := make([]panelTextBox, 0, 2*len(rows)+1) + boxes = append(boxes, panelTextRect(x+12, y+20, 12, title, false)) + rowHeight := (height - panelBoxHeaderHeight) / float64(len(rows)) + for index, row := range rows { + if row.Label == "" { + continue + } + rowY := y + panelBoxHeaderHeight + float64(index)*rowHeight + size := PanelRowFontSize(width, row) + boxes = append(boxes, panelTextRect(x+panelBoxPadding, rowY+11, size, row.Label, false)) + boxes = append(boxes, panelTextRect(x+width-panelBoxPadding, rowY+11, size, row.Value, true)) + if row.Detail != "" { + boxes = append(boxes, panelTextRect(x+panelBoxPadding, rowY+22, panelRowDetailSize, row.Detail, false)) + } + } + for first := 0; first < len(boxes); first++ { + for second := first + 1; second < len(boxes); second++ { + if panelTextBoxesOverlap(boxes[first], boxes[second]) { + return true + } + } + } + return false +} diff --git a/internal/svgchart/panel_test.go b/internal/svgchart/panel_test.go new file mode 100644 index 0000000..affb30c --- /dev/null +++ b/internal/svgchart/panel_test.go @@ -0,0 +1,75 @@ +package svgchart + +import ( + "math" + "strings" + "testing" +) + +func TestEstimatedTextWidthUsesSeparateAsciiAndWideRuneUnits(t *testing.T) { + if got := EstimatedTextWidth("", 10); got != 0 { + t.Fatalf("empty text width = %v, want 0", got) + } + if got := EstimatedTextWidth("AB", 10); math.Abs(got-12.4) > 1e-9 { + t.Fatalf("two ASCII runes = %v, want 12.4", got) + } + if got := EstimatedTextWidth("中文", 10); math.Abs(got-21) > 1e-9 { + t.Fatalf("two wide runes = %v, want 21", got) + } + above, below := EstimatedTextExtents(10) + if math.Abs(above-8.5) > 1e-9 || math.Abs(below-3) > 1e-9 { + t.Fatalf("extents = %v/%v, want 8.5/3", above, below) + } +} + +func TestPanelRowFontSizeShrinksUntilLabelAndValueFit(t *testing.T) { + row := PanelRow{Label: "地平视差 H.P.", Value: "00°16'21.4\""} + if got := PanelRowFontSize(320, row); got != panelRowLabelSize { + t.Fatalf("wide box font = %v, want the base size", got) + } + narrow := 170.0 + size := PanelRowFontSize(narrow, row) + if size >= panelRowLabelSize || size < panelMinimumFontSize { + t.Fatalf("narrow box font = %v, want a size in [%v,%v)", size, panelMinimumFontSize, panelRowLabelSize) + } + content := narrow - 2*panelBoxPadding + pair := EstimatedTextWidth(row.Label, size) + EstimatedTextWidth(row.Value, size) + if pair+panelLabelValueGap > content { + t.Fatalf("shrunk label %v + value %v + gap does not fit %v", pair, panelLabelValueGap, content) + } +} + +func TestPanelBoxOverlapsDetectsCrowdedRows(t *testing.T) { + rows := []PanelRow{ + {Label: "赤经 R.A.", Value: "23h21m52.2s"}, + {Label: "赤纬 Dec.", Value: "-05°59'26.5\""}, + {Label: "掩甚", Value: "04:28:23", Detail: "90.9407°E, 11.7283°N"}, + {Label: "视半径 S.D.", Value: "00°16'21.4\""}, + } + if PanelBoxOverlaps(0, 0, 230, 200, "月亮(地心坐标)", rows) { + t.Fatal("tall box reported overlapping rows") + } + if !PanelBoxOverlaps(0, 0, 230, 34, "月亮(地心坐标)", rows) { + t.Fatal("box too short for its rows reported no overlap") + } + if !PanelBoxOverlaps(0, 0, 120, 200, "月亮(地心坐标)", rows) { + t.Fatal("box too narrow for label and value reported no overlap") + } + if PanelBoxOverlaps(0, 0, 230, 200, "标题", nil) { + t.Fatal("empty block reported an overlap") + } +} + +func TestWritePanelBoxKeepsEveryTextInsideTheBlockWidth(t *testing.T) { + row := PanelRow{Label: "地平视差 H.P.", Value: "00°16'21.4\""} + var builder strings.Builder + WritePanelBox(&builder, "panel", 0, 0, 170, 200, "月亮(地心坐标)", []PanelRow{row}) + document := builder.String() + labelX := strings.Index(document, `x="12.000"`) + if labelX < 0 { + t.Fatalf("label is not drawn at the block padding: %s", document) + } + if !strings.Contains(document, `text-anchor="end"`) { + t.Fatalf("value is not right-anchored: %s", document) + } +} diff --git a/internal/svgchart/text.go b/internal/svgchart/text.go new file mode 100644 index 0000000..0412c1d --- /dev/null +++ b/internal/svgchart/text.go @@ -0,0 +1,118 @@ +package svgchart + +import ( + "math" + "strings" + "unicode" +) + +// ellipsisMark 是截断标记;用单个全角省略号,避免与文本里的三个半角点混淆。 +const ellipsisMark = "…" + +// estimatedTextRuneWidth 与 EstimatedTextWidth 的逐字符口径必须一致,否则折行结果会与占位判定矛盾。 +func estimatedTextRuneWidth(symbol rune, fontSize float64) float64 { + if symbol < 128 { + return 0.62 * fontSize + } + return 1.05 * fontSize +} + +// EllipsizeText 把单行文本裁到 maxWidth 内:放不下时保留前缀并追加省略号,全空的返回值表示连省略号都放不下。 +// EllipsizeText clamps one line to maxWidth, appending an ellipsis when the text does not fit; +// an empty result means even the ellipsis does not fit. +func EllipsizeText(value string, maxWidth, fontSize float64) string { + if EstimatedTextWidth(value, fontSize) <= maxWidth { + return value + } + return truncateWithMark(value, maxWidth, fontSize) +} + +// truncateWithMark 无条件追加省略号:被截断的行即使本身放得下,也必须标出后面还有内容。 +func truncateWithMark(value string, maxWidth, fontSize float64) string { + markWidth := EstimatedTextWidth(ellipsisMark, fontSize) + if markWidth > maxWidth { + return "" + } + runes := []rune(value) + for len(runes) > 0 && EstimatedTextWidth(string(runes), fontSize)+markWidth > maxWidth { + runes = runes[:len(runes)-1] + } + return strings.TrimRight(string(runes), " ") + ellipsisMark +} + +// WrapText 按 EstimatedTextWidth 保守折行:优先在空白处断开,单个词或全角文本按字符断开,空行不返回。 +// WrapText wraps conservatively by EstimatedTextWidth, preferring whitespace breaks; empty lines are dropped. +func WrapText(value string, maxWidth, fontSize float64) []string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + if EstimatedTextWidth(value, fontSize) <= maxWidth { + return []string{value} + } + runes := []rune(value) + lines := make([]string, 0, 4) + for len(runes) > 0 { + width := 0.0 + end := 0 + lastSpace := -1 + for end < len(runes) { + nextWidth := width + estimatedTextRuneWidth(runes[end], fontSize) + if nextWidth > maxWidth && end > 0 { + break + } + width = nextWidth + if unicode.IsSpace(runes[end]) { + lastSpace = end + } + end++ + } + if end < len(runes) && lastSpace > 0 { + end = lastSpace + } + if end == 0 { + end = 1 + } + if line := strings.TrimSpace(string(runes[:end])); line != "" { + lines = append(lines, line) + } + runes = runes[end:] + for len(runes) > 0 && unicode.IsSpace(runes[0]) { + runes = runes[1:] + } + } + return lines +} + +// TextLineLimit 返回高度预算 maxHeight 能容纳的行数,至少一行;口径与 EstimatedTextExtents 一致。 +// TextLineLimit reports how many lines fit in maxHeight, never less than one. +func TextLineLimit(fontSize, lineHeight, maxHeight float64) int { + if lineHeight <= 0 { + lineHeight = 1.2 * fontSize + } + above, below := EstimatedTextExtents(fontSize) + limit := int(math.Floor((maxHeight-above-below)/lineHeight)) + 1 + if limit < 1 { + return 1 + } + return limit +} + +// BaselineLineLimit 返回自 firstBaseline 起、底线 bottomLimit 之前能排下的行数,至少一行。 +// BaselineLineLimit reports how many lines fit below firstBaseline and above bottomLimit, never less than one. +func BaselineLineLimit(fontSize, lineHeight, firstBaseline, bottomLimit float64) int { + above, _ := EstimatedTextExtents(fontSize) + return TextLineLimit(fontSize, lineHeight, bottomLimit-firstBaseline+above) +} + +// TruncateTextLines 按行数上限截断折行结果:截断时末行以省略号结尾并重新裁到 maxWidth 内,未截断时原样返回。 +// TruncateTextLines cuts lines to maxLines; a cut last line is ellipsized to maxWidth. +func TruncateTextLines(lines []string, maxWidth, fontSize float64, maxLines int) []string { + if len(lines) == 0 || maxLines <= 0 || len(lines) <= maxLines { + return lines + } + kept := make([]string, maxLines) + copy(kept, lines[:maxLines]) + kept[maxLines-1] = truncateWithMark(kept[maxLines-1], maxWidth, fontSize) + return kept +} diff --git a/internal/svgchart/text_test.go b/internal/svgchart/text_test.go new file mode 100644 index 0000000..43547e6 --- /dev/null +++ b/internal/svgchart/text_test.go @@ -0,0 +1,108 @@ +package svgchart + +import ( + "strings" + "testing" +) + +// 折行与截断必须与 EstimatedTextWidth/EstimatedTextExtents 同口径:留下的每一行都不超宽, +// 截断后的行数不超过高度预算,且末行以省略号结尾。 + +func textFits(t *testing.T, lines []string, maxWidth, fontSize float64) { + t.Helper() + for _, line := range lines { + if width := EstimatedTextWidth(line, fontSize); width > maxWidth { + t.Fatalf("line %q width %.1f exceeds %.1f", line, width, maxWidth) + } + } +} + +func TestWrapTextKeepsEveryLineInsideWidth(t *testing.T) { + for _, test := range []struct { + name string + value string + maxWidth float64 + fontSize float64 + }{ + {name: "ascii words", value: strings.Repeat("alpha beta gamma ", 20), maxWidth: 220, fontSize: 11}, + {name: "chinese runs", value: strings.Repeat("月球掩星示意图说明", 30), maxWidth: 180, fontSize: 11}, + {name: "mixed", value: strings.Repeat("掩星 occultation 说明;", 40), maxWidth: 260, fontSize: 9}, + {name: "single word", value: strings.Repeat("A", 200), maxWidth: 120, fontSize: 10}, + } { + t.Run(test.name, func(t *testing.T) { + lines := WrapText(test.value, test.maxWidth, test.fontSize) + if len(lines) < 2 { + t.Fatalf("lines = %d, want the text to be wrapped", len(lines)) + } + textFits(t, lines, test.maxWidth, test.fontSize) + }) + } +} + +func TestWrapTextKeepsShortTextOnOneLine(t *testing.T) { + lines := WrapText(" 短说明 short note ", 400, 11) + if len(lines) != 1 || lines[0] != "短说明 short note" { + t.Fatalf("lines = %#v, want one trimmed line", lines) + } + if lines := WrapText(" ", 400, 11); lines != nil { + t.Fatalf("blank text lines = %#v, want nil", lines) + } +} + +func TestTruncateTextLinesMarksTheCut(t *testing.T) { + lines := []string{"第一行", "第二行", "第三行", "第四行"} + kept := TruncateTextLines(lines, 400, 11, 2) + if len(kept) != 2 || kept[0] != "第一行" { + t.Fatalf("kept = %#v, want the first two lines", kept) + } + if !strings.HasSuffix(kept[1], ellipsisMark) { + t.Fatalf("last kept line = %q, want the ellipsis mark", kept[1]) + } + if kept := TruncateTextLines(lines, 400, 11, 9); len(kept) != 4 || kept[3] != "第四行" { + t.Fatalf("kept = %#v, want the untouched input", kept) + } +} + +func TestTruncateTextLinesEllipsizesTheLastLineToWidth(t *testing.T) { + lines := []string{strings.Repeat("宽", 20), strings.Repeat("宽", 20)} + kept := TruncateTextLines(lines, 60, 10, 1) + if len(kept) != 1 || !strings.HasSuffix(kept[0], ellipsisMark) { + t.Fatalf("kept = %#v, want one ellipsized line", kept) + } + textFits(t, kept, 60, 10) +} + +func TestTextLineLimitCountsExtents(t *testing.T) { + // 3 行 11 号字、行距 15 的总高约 42.65,预算再少一点就只放得下 2 行。 + if limit := TextLineLimit(11, 15, 42.7); limit != 3 { + t.Fatalf("limit = %d, want 3", limit) + } + if limit := TextLineLimit(11, 15, 42.6); limit != 2 { + t.Fatalf("limit = %d, want 2", limit) + } + if limit := TextLineLimit(11, 15, 1); limit != 1 { + t.Fatalf("limit = %d, want the one-line floor", limit) + } + // 基线口径:首行基线 100,第 2 行要排得下需要底线不低于 100+15+0.3*11。 + if limit := BaselineLineLimit(11, 15, 100, 118.4); limit != 2 { + t.Fatalf("limit = %d, want 2", limit) + } + if limit := BaselineLineLimit(11, 15, 100, 118.2); limit != 1 { + t.Fatalf("limit = %d, want 1", limit) + } +} + +func TestEllipsizeTextStaysInsideWidth(t *testing.T) { + value := strings.Repeat("长文本 ellipsis ", 30) + clamped := EllipsizeText(value, 150, 11) + if !strings.HasSuffix(clamped, ellipsisMark) { + t.Fatalf("clamped = %q, want the ellipsis mark", clamped) + } + textFits(t, []string{clamped}, 150, 11) + if short := EllipsizeText("短", 150, 11); short != "短" { + t.Fatalf("short text = %q, want it untouched", short) + } + if empty := EllipsizeText(value, 1, 11); empty != "" { + t.Fatalf("clamped = %q, want an empty string when even the mark does not fit", empty) + } +} diff --git a/internal/svgmap/geometry.go b/internal/svgmap/geometry.go index 40d88dc..3160d89 100644 --- a/internal/svgmap/geometry.go +++ b/internal/svgmap/geometry.go @@ -24,9 +24,3 @@ func ShortestCircleArc(circle []GeoPoint, from, to GeoPoint) []GeoPoint { func SameGeoPoint(a, b GeoPoint) bool { return geodata.SameGeoPoint(a, b) } - -// VisibleHemispherePolygons 返回以指定中心为中心的半球多边形 / VisibleHemispherePolygons returns polygons for the hemisphere centered on -// 中心的半球多边形,并裁剪到请求的地图投影 / center, clipped to the requested map projection. -func VisibleHemispherePolygons(center GeoPoint, projection Projection, samples int) [][]GeoPoint { - return geodata.VisibleHemispherePolygons(center, projection, samples) -} diff --git a/internal/svgmap/internal/mapgen/main.go b/internal/svgmap/internal/mapgen/main.go deleted file mode 100644 index 4afcc22..0000000 --- a/internal/svgmap/internal/mapgen/main.go +++ /dev/null @@ -1,334 +0,0 @@ -// Command mapgen 将 Natural Earth 陆地多边形转换为供共享世界地图渲染器使用的紧凑 SVG 路径 / -// Command mapgen converts Natural Earth land polygons into compact SVG paths for the shared world-map renderer. -package main - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "io" - "math" - "net/http" - "os" - "path/filepath" - "strconv" - "time" -) - -const ( - naturalEarthURL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_50m_land.geojson" - naturalEarthSHA = "e874b27a51d146452be360cafb3cc50c86001074a67d534113e6534682f9826b" - worldWidth = 5760 - worldHeight = 2880 - polarSize = 2880 -) - -type featureCollection struct { - Features []feature `json:"features"` -} - -type feature struct { - Geometry geometry `json:"geometry"` -} - -type geometry struct { - Type string `json:"type"` - Coordinates json.RawMessage `json:"coordinates"` -} - -type geoPoint struct { - lon float64 - lat float64 -} - -type point struct { - x int - y int -} - -type projection struct { - name string - hemisphere int - project func(geoPoint) point -} - -func main() { - source := flag.String("source", "", "optional local Natural Earth GeoJSON source") - outputDir := flag.String("output-dir", ".", "generated SVG path destination directory") - flag.Parse() - - data, err := readSource(*source) - if err != nil { - fatal(err) - } - if err := verifySource(data); err != nil { - fatal(err) - } - rings, err := decodeRings(data) - if err != nil { - fatal(err) - } - projections := []projection{ - {name: "land_equirectangular.path", project: projectEquirectangular}, - {name: "land_north_polar.path", hemisphere: 1, project: func(value geoPoint) point { return projectPolar(value, 1) }}, - {name: "land_south_polar.path", hemisphere: -1, project: func(value geoPoint) point { return projectPolar(value, -1) }}, - } - for _, current := range projections { - path, ringCount, pointCount := buildPath(rings, current) - output := filepath.Join(*outputDir, current.name) - if err := os.WriteFile(output, path, 0644); err != nil { - fatal(fmt.Errorf("write %s: %w", output, err)) - } - fmt.Printf("generated %s: %d rings, %d quantized points, %d bytes\n", output, ringCount, pointCount, len(path)) - } -} - -func readSource(path string) ([]byte, error) { - if path != "" { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - return data, nil - } - client := &http.Client{Timeout: 30 * time.Second} - response, err := client.Get(naturalEarthURL) - if err != nil { - return nil, fmt.Errorf("download Natural Earth data: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("download Natural Earth data: %s", response.Status) - } - data, err := io.ReadAll(response.Body) - if err != nil { - return nil, fmt.Errorf("read Natural Earth response: %w", err) - } - return data, nil -} - -func verifySource(data []byte) error { - sum := sha256.Sum256(data) - actual := hex.EncodeToString(sum[:]) - if actual != naturalEarthSHA { - return fmt.Errorf("Natural Earth source checksum = %s, want %s", actual, naturalEarthSHA) - } - return nil -} - -func decodeRings(data []byte) ([][]geoPoint, error) { - var collection featureCollection - if err := json.Unmarshal(data, &collection); err != nil { - return nil, fmt.Errorf("decode Natural Earth GeoJSON: %w", err) - } - var rings [][]geoPoint - appendPolygon := func(polygon [][][]float64) { - for _, rawRing := range polygon { - ring := make([]geoPoint, 0, len(rawRing)) - for _, coordinate := range rawRing { - if len(coordinate) >= 2 { - ring = append(ring, geoPoint{lon: coordinate[0], lat: coordinate[1]}) - } - } - if len(ring) >= 3 { - rings = append(rings, ring) - } - } - } - for index, current := range collection.Features { - switch current.Geometry.Type { - case "Polygon": - var polygon [][][]float64 - if err := json.Unmarshal(current.Geometry.Coordinates, &polygon); err != nil { - return nil, fmt.Errorf("decode feature %d polygon: %w", index, err) - } - appendPolygon(polygon) - case "MultiPolygon": - var multiPolygon [][][][]float64 - if err := json.Unmarshal(current.Geometry.Coordinates, &multiPolygon); err != nil { - return nil, fmt.Errorf("decode feature %d multipolygon: %w", index, err) - } - for _, polygon := range multiPolygon { - appendPolygon(polygon) - } - default: - return nil, fmt.Errorf("feature %d has unsupported geometry %q", index, current.Geometry.Type) - } - } - if len(rings) == 0 { - return nil, fmt.Errorf("Natural Earth source contains no usable rings") - } - return rings, nil -} - -func buildPath(rings [][]geoPoint, current projection) ([]byte, int, int) { - path := make([]byte, 0, 256000) - ringCount := 0 - pointCount := 0 - for _, sourceRing := range rings { - ring := sourceRing - if current.hemisphere != 0 { - ring = clipHemisphere(ring, current.hemisphere) - } - projected := projectRing(ring, current) - var added int - path, added = appendRing(path, projected) - if added > 0 { - ringCount++ - pointCount += added - } - } - return path, ringCount, pointCount -} - -func clipHemisphere(points []geoPoint, hemisphere int) []geoPoint { - if len(points) == 0 { - return nil - } - inside := func(value geoPoint) bool { return value.lat*float64(hemisphere) >= 0 } - intersection := func(a, b geoPoint) geoPoint { - dLon := normalizeLongitudeDelta(b.lon - a.lon) - fraction := -a.lat / (b.lat - a.lat) - return geoPoint{lon: normalizeLongitude(a.lon + fraction*dLon), lat: 0} - } - result := make([]geoPoint, 0, len(points)+2) - previous := points[len(points)-1] - previousInside := inside(previous) - for _, current := range points { - currentInside := inside(current) - if currentInside != previousInside { - result = append(result, intersection(previous, current)) - } - if currentInside { - result = append(result, current) - } - previous = current - previousInside = currentInside - } - return result -} - -func projectRing(ring []geoPoint, current projection) []point { - if len(ring) < 3 { - return nil - } - if current.hemisphere == 0 { - result := make([]point, 0, len(ring)) - for _, value := range ring { - result = appendUniquePoint(result, current.project(value)) - } - return result - } - - result := make([]point, 0, len(ring)*2) - for index, start := range ring { - end := ring[(index+1)%len(ring)] - dLon := normalizeLongitudeDelta(end.lon - start.lon) - dLat := end.lat - start.lat - steps := int(math.Ceil(math.Max(math.Abs(dLon), math.Abs(dLat)) / 1.5)) - if steps < 1 { - steps = 1 - } - for step := 0; step < steps; step++ { - fraction := float64(step) / float64(steps) - value := geoPoint{ - lon: normalizeLongitude(start.lon + fraction*dLon), - lat: start.lat + fraction*dLat, - } - result = appendUniquePoint(result, current.project(value)) - } - } - return result -} - -func appendUniquePoint(points []point, value point) []point { - if len(points) == 0 || points[len(points)-1] != value { - return append(points, value) - } - return points -} - -func appendRing(path []byte, points []point) ([]byte, int) { - if len(points) > 1 && points[0] == points[len(points)-1] { - points = points[:len(points)-1] - } - if len(points) < 3 { - return path, 0 - } - path = append(path, 'M') - path = appendNumber(path, points[0].x) - path = appendNumber(path, points[0].y) - previous := points[0] - previousCommand := byte(0) - for _, current := range points[1:] { - dx := current.x - previous.x - dy := current.y - previous.y - command := byte('l') - if dx == 0 { - command = 'v' - } else if dy == 0 { - command = 'h' - } - if command != previousCommand { - path = append(path, command) - previousCommand = command - } - switch command { - case 'h': - path = appendNumber(path, dx) - case 'v': - path = appendNumber(path, dy) - default: - path = appendNumber(path, dx) - path = appendNumber(path, dy) - } - previous = current - } - path = append(path, 'z') - return path, len(points) -} - -func projectEquirectangular(value geoPoint) point { - return point{ - x: int((value.lon+180)/360*worldWidth + 0.5), - y: int((90-value.lat)/180*worldHeight + 0.5), - } -} - -func projectPolar(value geoPoint, hemisphere int) point { - radius := (90 - float64(hemisphere)*value.lat) / 90 * polarSize / 2 - longitude := value.lon * math.Pi / 180 - return point{ - x: int(polarSize/2 + radius*math.Sin(longitude) + 0.5), - y: int(polarSize/2 - radius*math.Cos(longitude) + 0.5), - } -} - -func normalizeLongitude(value float64) float64 { - value = math.Mod(value+180, 360) - if value < 0 { - value += 360 - } - return value - 180 -} - -func normalizeLongitudeDelta(value float64) float64 { - return normalizeLongitude(value) -} - -func appendNumber(path []byte, value int) []byte { - if len(path) > 0 && value >= 0 { - last := path[len(path)-1] - if last >= '0' && last <= '9' { - path = append(path, ' ') - } - } - return strconv.AppendInt(path, int64(value), 10) -} - -func fatal(err error) { - fmt.Fprintln(os.Stderr, "mapgen:", err) - os.Exit(1) -} diff --git a/internal/svgmap/land_bench_test.go b/internal/svgmap/land_bench_test.go new file mode 100644 index 0000000..ebd1d1f --- /dev/null +++ b/internal/svgmap/land_bench_test.go @@ -0,0 +1,31 @@ +package svgmap + +import ( + "strings" + "testing" +) + +func BenchmarkWriteLandOrthographic(b *testing.B) { + frame := Frame{ + X: 57, Y: 184, Width: 360, Height: 360, + Projection: ProjectionOrthographic, + CenterLongitude: 104.145, CenterLatitude: -1.5, + } + b.ReportAllocs() + for index := 0; index < b.N; index++ { + var builder strings.Builder + frame.WriteLand(&builder, "clip") + } +} + +func BenchmarkWriteLandCenteredEquirectangular(b *testing.B) { + frame := Frame{ + X: 57, Y: 184, Width: 360, Height: 180, + Projection: ProjectionEquirectangular, CenterLongitude: -104.145, + } + b.ReportAllocs() + for index := 0; index < b.N; index++ { + var builder strings.Builder + frame.WriteLand(&builder, "clip") + } +} diff --git a/internal/svgmap/land_globe.go b/internal/svgmap/land_globe.go new file mode 100644 index 0000000..6062c71 --- /dev/null +++ b/internal/svgmap/land_globe.go @@ -0,0 +1,190 @@ +package svgmap + +import ( + "fmt" + "strconv" + "strings" + "sync" + + "b612.me/astro/internal/geodata" +) + +var ( + landRingsOnce sync.Once + landRingsCache [][]geodata.GeoPoint +) + +// landRings 把烘焙的等经纬陆地路径还原成经纬度环。 +// 路径编码在 5760x2880 的整数像素网格上,即 1/16° 量化;在球面图上不足 0.2 像素。 +// 正射图的视点随事件变化,无法预烘焙,只能运行时投影。 +func landRings() [][]geodata.GeoPoint { + landRingsOnce.Do(func() { + landRingsCache = decodeLandPath(equirectangularLandPath, worldLandWidth, worldLandHeight) + }) + return landRingsCache +} + +// scanLandNumber 读取一个整数坐标;SVG 允许负号紧接前一个数字,因此不能按空白切分。 +func scanLandNumber(text string, position int) (int, int, bool) { + // 坐标对之间有空白,调用方可能在命令字母之后直接进入,所以这里要自己跳过分隔符。 + for position < len(text) && (text[position] == ' ' || text[position] == ',') { + position++ + } + start := position + if position < len(text) && (text[position] == '-' || text[position] == '+') { + position++ + } + digits := position + for position < len(text) && text[position] >= '0' && text[position] <= '9' { + position++ + } + if position == digits { + return 0, start, false + } + value, err := strconv.Atoi(text[start:position]) + if err != nil { + return 0, start, false + } + return value, position, true +} + +// decodeLandPath 解析内嵌底图的紧凑路径,只用到 M/l/h/v/z 五种命令。 +func decodeLandPath(text string, width, height int) [][]geodata.GeoPoint { + rings := make([][]geodata.GeoPoint, 0, 2048) + var ring []geodata.GeoPoint + x, y := 0, 0 + position := 0 + var command byte + point := func(px, py int) geodata.GeoPoint { + return geodata.GeoPoint{ + Longitude: float64(px)/float64(width)*360 - 180, + Latitude: 90 - float64(py)/float64(height)*180, + } + } + flush := func() { + if len(ring) >= 3 { + rings = append(rings, ring) + } + ring = nil + } + for position < len(text) { + for position < len(text) && (text[position] == ' ' || text[position] == ',') { + position++ + } + if position >= len(text) { + break + } + if character := text[position]; (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') { + command = character + position++ + if command == 'z' || command == 'Z' { + flush() + command = 0 + } + continue + } + value, next, ok := scanLandNumber(text, position) + if !ok { + break + } + position = next + switch command { + case 'M': + second, afterSecond, secondOK := scanLandNumber(text, position) + if !secondOK { + return rings + } + position = afterSecond + flush() + x, y = value, second + ring = append(ring, point(x, y)) + // M 之后的裸坐标对按规范是隐式相对直线段。 + command = 'l' + case 'l': + second, afterSecond, secondOK := scanLandNumber(text, position) + if !secondOK { + return rings + } + position = afterSecond + x += value + y += second + ring = append(ring, point(x, y)) + case 'h': + x += value + ring = append(ring, point(x, y)) + case 'v': + y += value + ring = append(ring, point(x, y)) + } + } + flush() + return rings +} + +// writeOrthographicPath 先按视界裁剪再投影一段地理折线,写成 SVG 路径命令。 +func (frame Frame) writeOrthographicPath(builder *strings.Builder, view geodata.ClipView, points []geodata.GeoPoint, close bool) { + for _, segment := range geodata.PolylineSegments(points, view) { + frame.writeOrthographicRing(builder, segment, close) + } +} + +// writeOrthographicRing 投影一个已经裁剪好的环或折线。 +// 这里不能再调 PolylineSegments:视界上的点深度恰为 0,会被判成不可见而把环拆开, +// 每个碎片再用 Z 直线闭合,就会在圆盘上留下横穿的弦。 +func (frame Frame) writeOrthographicRing(builder *strings.Builder, points []geodata.GeoPoint, close bool) { + written := 0 + for _, item := range points { + x, y, visible := frame.Project(item.Longitude, item.Latitude) + if !visible { + continue + } + command := "L" + if written == 0 { + command = "M" + } + // 球面图上 0.1 像素已远小于海岸线本身的量化误差。 + fmt.Fprintf(builder, "%s%.1f %.1f", command, x, y) + written++ + } + if written >= 2 && close { + builder.WriteString("Z") + } +} + +// writeOrthographicLand 在正射球面图上运行时投影并裁剪陆地。 +func (frame Frame) writeOrthographicLand(builder *strings.Builder, clipID string) { + view := frame.Clip() + var path strings.Builder + for _, ring := range landRings() { + for _, fragment := range geodata.PolygonFragments(ring, view) { + frame.writeOrthographicRing(&path, fragment, true) + } + } + fmt.Fprintf(builder, ``, + clipID, path.String()) +} + +// writeOrthographicGraticule 绘制球面经纬网;经线是完整大圆,纬线是等纬圈,都按视界裁剪。 +func (frame Frame) writeOrthographicGraticule(builder *strings.Builder, clipID string) { + view := frame.Clip() + fmt.Fprintf(builder, ``, clipID) + for longitude := -180.0; longitude < 180; longitude += 30 { + points := make([]geodata.GeoPoint, 0, 91) + for latitude := -90.0; latitude <= 90; latitude += 2 { + points = append(points, geodata.GeoPoint{Longitude: longitude, Latitude: latitude}) + } + builder.WriteString(``) + } + for latitude := -60.0; latitude <= 60; latitude += 30 { + points := make([]geodata.GeoPoint, 0, 181) + for longitude := -180.0; longitude <= 180; longitude += 2 { + points = append(points, geodata.GeoPoint{Longitude: longitude, Latitude: latitude}) + } + builder.WriteString(``) + } + builder.WriteString(``) +} diff --git a/internal/svgmap/land_globe_test.go b/internal/svgmap/land_globe_test.go new file mode 100644 index 0000000..e37b221 --- /dev/null +++ b/internal/svgmap/land_globe_test.go @@ -0,0 +1,86 @@ +package svgmap + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + "testing" + + "b612.me/astro/internal/geodata" +) + +var landGlobeCoordinatePattern = regexp.MustCompile(`[ML](-?[0-9.]+) (-?[0-9.]+)`) + +// renderGlobeLand 渲染球面底图并取回陆地的路径命令与画布。 +func renderGlobeLand(t *testing.T, center geodata.GeoPoint) (Frame, string) { + t.Helper() + frame := Frame{ + X: 30, Y: 30, Width: 640, Height: 640, Projection: ProjectionOrthographic, + CenterLongitude: center.Longitude, CenterLatitude: center.Latitude, + } + var builder strings.Builder + fmt.Fprintf(&builder, `%s`, + frame.ClipDefinition("clip")) + frame.WriteOcean(&builder) + frame.WriteGraticule(&builder, "clip") + frame.WriteLand(&builder, "clip") + builder.WriteString(``) + rendered := builder.String() + const marker = ` radius+0.6 { + t.Fatalf("%v: vertex %.1f,%.1f falls outside the disk", center, point[0], point[1]) + } + next := subpath[(index+1)%len(subpath)] + if gap := math.Hypot(next[0]-point[0], next[1]-point[1]); gap > limit { + t.Fatalf("%v: subpath edge of %.1f px (limit %.1f) between %.1f,%.1f and %.1f,%.1f", + center, gap, limit, point[0], point[1], next[0], next[1]) + } + } + } + t.Logf("%v: subpaths=%d vertices=%d", center, len(subpaths), vertices) + } +} diff --git a/internal/svgmap/map.go b/internal/svgmap/map.go index 3a67cf4..b2f4be0 100644 --- a/internal/svgmap/map.go +++ b/internal/svgmap/map.go @@ -24,8 +24,13 @@ const ( ProjectionEquirectangular = geodata.ProjectionEquirectangular ProjectionNorthPolar = geodata.ProjectionNorthPolar ProjectionSouthPolar = geodata.ProjectionSouthPolar + // ProjectionOrthographic 是正射(球面)投影 / ProjectionOrthographic is the orthographic (globe) projection. + ProjectionOrthographic = geodata.ProjectionOrthographic ) +// ClipView 是裁剪所依赖的投影与视点 / ClipView carries the projection and its view point. +type ClipView = geodata.ClipView + // GeoPoint 是以度表示的地理点,东经为正 / GeoPoint is a geographic point in degrees, with east longitude positive. type GeoPoint = geodata.GeoPoint @@ -36,9 +41,21 @@ type Frame struct { Width float64 Height float64 Projection Projection + // CenterLongitude 与 CenterLatitude 是正射投影的视点;其他投影忽略。 + // CenterLongitude and CenterLatitude are the orthographic view point; other projections ignore them. + CenterLongitude float64 + CenterLatitude float64 } -//go:generate go run ./internal/mapgen -output-dir . +// Clip 返回该画布对应的裁剪视图 / Clip returns the clip view of this frame. +func (frame Frame) Clip() ClipView { + return ClipView{ + Projection: frame.Projection, + Center: GeoPoint{Longitude: frame.CenterLongitude, Latitude: frame.CenterLatitude}, + } +} + +// 底图资产已随仓库提交;重生成工具不随仓库发布。 //go:embed land_equirectangular.path var equirectangularLandPath string @@ -53,7 +70,7 @@ var southPolarLandPath string // 为始终位于一个半球内的高纬事件选择极区视图 / for a high-latitude event that stays in one hemisphere. func ResolveProjection(requested Projection, focusLatitude, minimumLatitude, maximumLatitude float64) Projection { switch requested { - case ProjectionEquirectangular, ProjectionNorthPolar, ProjectionSouthPolar: + case ProjectionEquirectangular, ProjectionNorthPolar, ProjectionSouthPolar, ProjectionOrthographic: return requested } if focusLatitude >= 60 && minimumLatitude >= -2 { @@ -70,11 +87,32 @@ func (frame Frame) IsPolar() bool { return frame.Projection == ProjectionNorthPolar || frame.Projection == ProjectionSouthPolar } +// IsDisk 判断画布是否为圆盘版式:极区方位投影与正射球面图都只画一个圆。 +// IsDisk reports whether the frame is disk shaped: both hemispheric azimuthal and orthographic views draw one circle. +func (frame Frame) IsDisk() bool { + return frame.IsPolar() || frame.Projection == ProjectionOrthographic +} + // Project 将经纬度映射为 SVG 坐标;布尔值为 false 表示点在极区投影半球外 / Project maps longitude and latitude to SVG coordinates. The boolean is false // 点位于极区投影的可见半球之外时返回 false / when a point lies outside a polar projection's visible hemisphere. func (frame Frame) Project(longitude, latitude float64) (float64, float64, bool) { + if frame.Projection == ProjectionOrthographic { + x, y, visible := geodata.OrthographicDiskPoint( + GeoPoint{Longitude: longitude, Latitude: latitude}, + GeoPoint{Longitude: frame.CenterLongitude, Latitude: frame.CenterLatitude}, + ) + if !visible { + return 0, 0, false + } + radius := math.Min(frame.Width, frame.Height) / 2 + return frame.X + frame.Width/2 + radius*x, frame.Y + frame.Height/2 - radius*y, true + } if !frame.IsPolar() { - x := frame.X + (longitude+180)/360*frame.Width + offset := longitude + 180 + if frame.CenterLongitude != 0 { + offset = equirectangularLongitudeOffset(longitude, frame.CenterLongitude) + } + x := frame.X + offset/360*frame.Width y := frame.Y + (90-latitude)/180*frame.Height return x, y, true } @@ -83,14 +121,22 @@ func (frame Frame) Project(longitude, latitude float64) (float64, float64, bool) return 0, 0, false } radius := (90 - hemisphere*latitude) / 90 * math.Min(frame.Width, frame.Height) / 2 - angle := longitude * math.Pi / 180 + // 极点俯视下横坐标是 sin(θ),南北两极的视点方向相反,θ 的符号也相反: + // 北极 θ = C−λ+180、南极 θ = λ−C,各自把事件经线放到背向极点屏幕方向的一侧, + // 使事件附近都是北在上、东在右。符号写错就会得到镜像图(东京跑到北京西边)。 + angle := (frame.CenterLongitude - longitude) * math.Pi / 180 + if hemisphere > 0 { + angle += math.Pi + } else { + angle = -angle + } return frame.X + frame.Width/2 + radius*math.Sin(angle), frame.Y + frame.Height/2 - radius*math.Cos(angle), true } // ClipDefinition 写入地图的矩形或圆形裁剪路径 / ClipDefinition writes the rectangular or circular clipping path for a map. func (frame Frame) ClipDefinition(id string) string { - if frame.IsPolar() { + if frame.IsDisk() { return fmt.Sprintf(``, id, frame.X+frame.Width/2, frame.Y+frame.Height/2, math.Min(frame.Width, frame.Height)/2) } @@ -100,7 +146,7 @@ func (frame Frame) ClipDefinition(id string) string { // WriteOcean 绘制地图的物理范围 / WriteOcean renders the map's physical extent. func (frame Frame) WriteOcean(builder *strings.Builder) { - if frame.IsPolar() { + if frame.IsDisk() { fmt.Fprintf(builder, ``, frame.X+frame.Width/2, frame.Y+frame.Height/2, math.Min(frame.Width, frame.Height)/2) return @@ -111,10 +157,21 @@ func (frame Frame) WriteOcean(builder *strings.Builder) { // WriteGraticule 绘制符合投影的经线和纬线 / WriteGraticule renders projection-correct meridians and parallels. func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) { + if frame.Projection == ProjectionOrthographic { + frame.writeOrthographicGraticule(builder, clipID) + return + } fmt.Fprintf(builder, ``, clipID) if !frame.IsPolar() { - for longitude := -150.0; longitude <= 150; longitude += 30 { + first, last := -150.0, 150.0 + if frame.CenterLongitude != 0 { + first, last = frame.CenterLongitude-180, frame.CenterLongitude+180 + } + for longitude := first; longitude <= last; longitude += 30 { x, _, _ := frame.Project(longitude, 0) + if frame.CenterLongitude != 0 && (x < frame.X-0.5 || x > frame.X+frame.Width+0.5) { + continue + } fmt.Fprintf(builder, ``, x, frame.Y, x, frame.Y+frame.Height) } for latitude := -60.0; latitude <= 60; latitude += 30 { @@ -128,7 +185,11 @@ func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) { for _, fraction := range []float64{1.0 / 3, 2.0 / 3, 1} { fmt.Fprintf(builder, ``, centerX, centerY, radius*fraction) } - for longitude := -150.0; longitude <= 180; longitude += 30 { + first, last := -150.0, 180.0 + if frame.CenterLongitude != 0 { + first, last = frame.CenterLongitude-180, frame.CenterLongitude+180 + } + for longitude := first; longitude <= last; longitude += 30 { x, y, _ := frame.Project(longitude, 0) fmt.Fprintf(builder, ``, centerX, centerY, x, y) } @@ -138,6 +199,14 @@ func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) { // WriteLand 绘制无国界的 Natural Earth 1:50m 陆地 / WriteLand renders Natural Earth 1:50m physical land without borders. func (frame Frame) WriteLand(builder *strings.Builder, clipID string) { + if frame.Projection == ProjectionOrthographic { + frame.writeOrthographicLand(builder, clipID) + return + } + if frame.Projection == ProjectionEquirectangular && frame.CenterLongitude != 0 { + frame.writeCenteredEquirectangularLand(builder, clipID) + return + } path := equirectangularLandPath viewWidth := float64(worldLandWidth) viewHeight := float64(worldLandHeight) @@ -148,15 +217,32 @@ func (frame Frame) WriteLand(builder *strings.Builder, clipID string) { path = southPolarLandPath viewWidth, viewHeight = polarLandSize, polarLandSize } - fmt.Fprintf(builder, ``, clipID) + landX, landScaleX := frame.X, frame.Width/viewWidth + if frame.IsPolar() { + // 极地底图都按 θ=λ 烘焙。北极的投影与之手性相反,需先水平镜像变成 −λ 再转 C+180; + // 南极手性相同,直接转 −C 即可。两侧目标角度都是 θ = ±(C−λ)+偏移。 + landRotation := -frame.CenterLongitude + if frame.hemisphere() > 0 { + landX, landScaleX = frame.X+frame.Width, -frame.Width/viewWidth + landRotation = frame.CenterLongitude + 180 + } + fmt.Fprintf(builder, ``, + landRotation, frame.X+frame.Width/2, frame.Y+frame.Height/2) + } + fmt.Fprintf(builder, ``, - frame.Width/viewWidth, frame.Height/viewHeight, frame.X, frame.Y) + fmt.Fprintf(builder, `" transform="matrix(%.9f 0 0 %.9f %.3f %.3f)" fill-rule="evenodd" vector-effect="non-scaling-stroke"/>`, + landScaleX, frame.Height/viewHeight, landX, frame.Y) + if frame.IsPolar() { + builder.WriteString(``) + } + builder.WriteString(``) } // WriteFrame 绘制地图轮廓 / WriteFrame renders the map outline. func (frame Frame) WriteFrame(builder *strings.Builder) { - if frame.IsPolar() { + if frame.IsDisk() { fmt.Fprintf(builder, ``, frame.X+frame.Width/2, frame.Y+frame.Height/2, math.Min(frame.Width, frame.Height)/2) return @@ -167,13 +253,13 @@ func (frame Frame) WriteFrame(builder *strings.Builder) { // PolylineSegments 将地理折线裁剪到选定投影并 / PolylineSegments clips a geographic polyline to the selected projection and // 在等经纬投影中按日界线拆分路径 / splits equirectangular paths at the antimeridian. -func PolylineSegments(points []GeoPoint, projection Projection) [][]GeoPoint { - return geodata.PolylineSegments(points, projection) +func PolylineSegments(points []GeoPoint, view ClipView) [][]GeoPoint { + return geodata.PolylineSegments(points, view) } // PolygonFragments 将地理多边形裁剪到选定地图范围 / PolygonFragments clips a geographic polygon to the selected map extent. -func PolygonFragments(points []GeoPoint, projection Projection) [][]GeoPoint { - return geodata.PolygonFragments(points, projection) +func PolygonFragments(points []GeoPoint, view ClipView) [][]GeoPoint { + return geodata.PolygonFragments(points, view) } func (frame Frame) hemisphere() float64 { @@ -182,3 +268,39 @@ func (frame Frame) hemisphere() float64 { } return 1 } + +// equirectangularLongitudeOffset 返回经度相对居中经线的偏移,换算成 0…360 的剂量。 +// 居中经线落在画面正中,其对面的经线落在左右任一边界上。 +func equirectangularLongitudeOffset(longitude, center float64) float64 { + offset := math.Mod(longitude-center, 360) + if offset < 0 { + offset += 360 + } + offset += 180 + if offset >= 360 { + offset -= 360 + } + return offset +} + +// writeCenteredEquirectangularLand 画按经度居中后的陆地:底图路径只出现一次, +// 再向图框缺的那一侧补一份平移副本覆盖环绕部分,因此输出大小与不居中时同量级。 +func (frame Frame) writeCenteredEquirectangularLand(builder *strings.Builder, clipID string) { + shift := frame.CenterLongitude / 360 * frame.Width + x := frame.X - shift + fmt.Fprintf(builder, ``, clipID) + builder.WriteString(``, + frame.Width/float64(worldLandWidth), frame.Height/float64(worldLandHeight)) + // 居中经线为负时窗口相对图块整体右移,只有向左补一份才能盖住图框左侧的 |C|° 空带。 + offsets := []float64{0, frame.Width} + if shift < 0 { + offsets = []float64{-frame.Width, 0} + } + for _, dx := range offsets { + fmt.Fprintf(builder, ``, + x+dx, frame.Y) + } + builder.WriteString(``) +} diff --git a/internal/svgmap/map_test.go b/internal/svgmap/map_test.go index 656264a..26cbe87 100644 --- a/internal/svgmap/map_test.go +++ b/internal/svgmap/map_test.go @@ -1,6 +1,7 @@ package svgmap import ( + "fmt" "math" "strings" "testing" @@ -12,9 +13,10 @@ func TestPolarProjectionPlacesPoleAtCenterAndEquatorOnFrame(t *testing.T) { if !ok || math.Abs(x-160) > 1e-9 || math.Abs(y-170) > 1e-9 { t.Fatalf("north pole = %.3f %.3f %v, want map center", x, y, ok) } + // 事件经线朝下,中心经线在赤道上落在圆盘最下方。 x, y, ok = frame.Project(0, 0) - if !ok || math.Abs(x-160) > 1e-9 || math.Abs(y-20) > 1e-9 { - t.Fatalf("prime-meridian equator = %.3f %.3f %v, want top edge", x, y, ok) + if !ok || math.Abs(x-160) > 1e-9 || math.Abs(y-320) > 1e-9 { + t.Fatalf("prime-meridian equator = %.3f %.3f %v, want bottom edge", x, y, ok) } if _, _, ok := frame.Project(0, -1); ok { t.Fatal("north-polar projection accepted a southern-hemisphere point") @@ -23,11 +25,11 @@ func TestPolarProjectionPlacesPoleAtCenterAndEquatorOnFrame(t *testing.T) { func TestProjectionClippingDoesNotSplitPolarAntimeridian(t *testing.T) { points := []GeoPoint{{Longitude: 170, Latitude: 70}, {Longitude: -170, Latitude: 70}} - segments := PolylineSegments(points, ProjectionNorthPolar) + segments := PolylineSegments(points, ClipView{Projection: ProjectionNorthPolar}) if len(segments) != 1 || len(segments[0]) != 2 { t.Fatalf("polar antimeridian segments = %#v, want one continuous segment", segments) } - segments = PolylineSegments(points, ProjectionEquirectangular) + segments = PolylineSegments(points, ClipView{Projection: ProjectionEquirectangular}) if len(segments) != 2 { t.Fatalf("equirectangular antimeridian segment count = %d, want 2", len(segments)) } @@ -49,3 +51,65 @@ func TestNaturalEarthAssetsAndPolarClip(t *testing.T) { } } } + +// 极地投影必须与“从极点上方俯视”一致,并把事件经线转到可读的一侧: +// 北极图的极点在圆心,事件经线落在正下方,于是事件附近是北在上、东在右。 +// 用 +sin(λ−C) 会得到镜像图,东京会跑到北京西边。 +func TestPolarProjectionKeepsTrueEastWest(t *testing.T) { + frame := Frame{Width: 200, Height: 200, Projection: ProjectionNorthPolar} + centreY := frame.Height / 2 + + // 极点落在圆心。 + _, poleY, _ := frame.Project(0, 90) + if math.Abs(poleY-centreY) > 1e-9 { + t.Fatalf("pole projects to y=%.3f, want centre %.3f", poleY, centreY) + } + // 事件经线朝下:赤道上中心经线的点落在圆盘最下方。 + _, meridianY, _ := frame.Project(0, 0) + if meridianY <= centreY { + t.Fatalf("centre meridian projects to y=%.1f, want below centre %.1f", meridianY, centreY) + } + // 事件附近东在右:同纬度上偏东的点横坐标更大。 + west, _, _ := frame.Project(-10, 45) + east, _, _ := frame.Project(10, 45) + if east <= west { + t.Fatalf("10E projects to x=%.1f, want right of 10W at %.1f", east, west) + } +} + +func TestCenteredEquirectangularLandCoversWholeFrame(t *testing.T) { + for _, center := range []float64{-179.5, -104.145, -90, -1, 1, 90, 104.145, 179.5} { + frame := Frame{ + X: 57, Y: 184, Width: 360, Height: 180, + Projection: ProjectionEquirectangular, CenterLongitude: center, + } + var builder strings.Builder + frame.WriteLand(&builder, "clip") + offsets := centeredLandTileOffsets(t, builder.String()) + if len(offsets) == 0 { + t.Fatalf("center %.3f drew no land tiles", center) + } + left, right := math.Inf(1), math.Inf(-1) + for _, offset := range offsets { + left = math.Min(left, offset) + right = math.Max(right, offset+frame.Width) + } + if left > frame.X+1e-6 || right < frame.X+frame.Width-1e-6 { + t.Fatalf("center %.3f covers longitude band [%.3f,%.3f], frame needs [%.3f,%.3f]", + center, left, right, frame.X, frame.X+frame.Width) + } + } +} + +func centeredLandTileOffsets(t *testing.T, document string) []float64 { + t.Helper() + offsets := make([]float64, 0, 2) + for _, piece := range strings.Split(document, ` maxContact { + maxContact = delta + } + if delta > contactTolerance { + t.Fatalf("%s %s offset %v exceeds %v", record.Label, contact.name, contact.got.Sub(contact.want), contactTolerance) + } + } + durations := []struct { + name string + got time.Duration + want time.Duration + }{ + {"D", got.Disappearance.Duration, startDuration}, + {"F", got.Reappearance.Duration, endDuration}, + } + for _, duration := range durations { + delta := duration.got - duration.want + if delta < 0 { + delta = -delta + } + if delta > maxDuration { + maxDuration = delta + } + if delta > durationTolerance { + t.Fatalf("%s %s-phase duration offset %v exceeds %v", record.Label, duration.name, duration.got-duration.want, durationTolerance) + } + } + } + t.Logf("contact offset max %v, duration offset max %v", maxContact, maxDuration) +} diff --git a/jupiter/phenomena_test.go b/jupiter/phenomena_test.go index 2c377e7..5c623c6 100644 --- a/jupiter/phenomena_test.go +++ b/jupiter/phenomena_test.go @@ -2,13 +2,18 @@ package jupiter import ( "encoding/json" + "fmt" "math" "os" "testing" "time" + + "b612.me/astro/basic" ) -const galileanShadowToleranceArcsec = 0.2 +// 基线 shadow 值按 TDB/TT 逆行历元(UTC 观测时刻换算 ΔT 后再减光行时)标定; +// 按 UTC 瞬时求值会让整条曲线平移 ΔT(Io 约 0.46″)并被本容差拒绝。 +const galileanShadowToleranceArcsec = 0.15 type galileanPhenomenaSample struct { UTC string `json:"utc"` @@ -103,3 +108,19 @@ func selectGalileanPhenomenon(info GalileanPhenomenaInfo, name string) GalileanS panic("unknown satellite: " + name) } } + +func TestGalileanPhenomenaUseTTFrame(t *testing.T) { + dates := []time.Time{ + time.Date(1999, 1, 1, 2, 0, 0, 0, time.UTC), + time.Date(2000, 1, 1, 14, 0, 0, 0, time.UTC), + time.Date(2026, 4, 2, 20, 0, 0, 0, time.UTC), + } + for _, date := range dates { + want := basic.JupiterGalileanSatellitePhenomena(basic.TD2UT(basic.Date2JDE(date.UTC()), true)) + got := SatellitePhenomena(date) + phenomena := []GalileanSatellitePhenomenon{got.Io, got.Europa, got.Ganymede, got.Callisto} + for i, phenomenon := range phenomena { + assertSameGalileanPhenomenon(t, fmt.Sprintf("date=%s satellite=%d TT frame", date, i+1), phenomenon, want[i]) + } + } +} diff --git a/jupiter/satellites.go b/jupiter/satellites.go index 97c95e5..681749a 100644 --- a/jupiter/satellites.go +++ b/jupiter/satellites.go @@ -43,10 +43,10 @@ type GalileanSatellitesInfo struct { // Satellites 木星四颗伽利略卫星视位置 / apparent positions of Jupiter's four Galilean satellites. // -// date 表示观测绝对时刻;内部使用该时刻对应的 TT/TDB 历元做 L1 星历求值。 -// date is the observing instant; internally the corresponding TT/TDB epoch is used for the L1 ephemeris evaluation. +// date 表示观测绝对时刻(UTC),内部换算为该时刻对应的 TT/TDB 历元做 L1 星历求值。 +// date is the observing instant in UTC; it is converted to the corresponding TT/TDB epoch for the L1 ephemeris evaluation. func Satellites(date time.Time) GalileanSatellitesInfo { - jde := basic.Date2JDE(date.UTC()) + jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true) observations := basic.JupiterGalileanSatelliteObservations(jde) return GalileanSatellitesInfo{ Io: galileanSatellitePositionFromBasic(observations[0]), diff --git a/jupiter/satellites_test.go b/jupiter/satellites_test.go index c52bb29..f847cf5 100644 --- a/jupiter/satellites_test.go +++ b/jupiter/satellites_test.go @@ -6,9 +6,12 @@ import ( "os" "testing" "time" + + "b612.me/astro/basic" ) -const galileanHorizonsToleranceArcsec = 1.0 +// 基线是 UTC 瞬时地心赤道量的派生偏移,容差取实测最大残差(0.054″)的约三倍。 +const galileanHorizonsToleranceArcsec = 0.15 type galileanHorizonsEquatorialRecord struct { RA float64 `json:"ra_deg"` @@ -95,3 +98,22 @@ func selectGalileanSatellite(info GalileanSatellitesInfo, name string) GalileanS panic("unknown satellite: " + name) } } + +func TestGalileanSatellitesUseTTFrame(t *testing.T) { + dates := []time.Time{ + time.Date(1973, 4, 15, 0, 0, 0, 0, time.UTC), + time.Date(1999, 1, 1, 2, 0, 0, 0, time.UTC), + time.Date(2026, 4, 2, 18, 0, 0, 0, time.UTC), + } + for _, date := range dates { + want := basic.JupiterGalileanSatelliteObservations(basic.TD2UT(basic.Date2JDE(date.UTC()), true)) + got := Satellites(date) + positions := []GalileanSatellitePosition{got.Io, got.Europa, got.Ganymede, got.Callisto} + for i, position := range positions { + expected := galileanSatellitePositionFromBasic(want[i]) + if position != expected { + t.Fatalf("date=%s satellite=%d TT frame mismatch: got %+v want %+v", date, i+1, position, expected) + } + } + } +} diff --git a/jupiter/testdata/galilean_phenomena_horizons.json b/jupiter/testdata/galilean_phenomena_horizons.json index ff58085..c65fa80 100644 --- a/jupiter/testdata/galilean_phenomena_horizons.json +++ b/jupiter/testdata/galilean_phenomena_horizons.json @@ -54,8 +54,8 @@ "occultation": false, "eclipse": false, "shadow_transit": true, - "shadow_x_arcsec": 17.228193145273753, - "shadow_y_arcsec": 3.256241312014926 + "shadow_x_arcsec": 16.76690445965992, + "shadow_y_arcsec": 3.0477215683782015 } } }, @@ -108,8 +108,8 @@ "occultation": false, "eclipse": false, "shadow_transit": true, - "shadow_x_arcsec": 3.158460096080454, - "shadow_y_arcsec": -16.417707231374926 + "shadow_x_arcsec": 2.9731905953542506, + "shadow_y_arcsec": -16.49424583711469 }, "io": { "transit": false, @@ -133,8 +133,8 @@ "occultation": false, "eclipse": false, "shadow_transit": true, - "shadow_x_arcsec": -5.755686876706326, - "shadow_y_arcsec": -12.656123592995895 + "shadow_x_arcsec": -5.978454744969274, + "shadow_y_arcsec": -12.745255093672851 }, "ganymede": { "transit": false, diff --git a/lite/moon/doc.go b/lite/moon/doc.go new file mode 100644 index 0000000..e14a280 --- /dev/null +++ b/lite/moon/doc.go @@ -0,0 +1,3 @@ +// Package moon 轻量月球链路:少量摄动项的月球近似、轻量站心修正、月相月龄和升落搜索,角度单位为度。 +// Package moon is the lightweight lunar chain: a few-perturbation-term Moon approximation, light topocentric correction, phase, age and rise/set search; angles are degrees. +package moon diff --git a/lite/moon/moon.go b/lite/moon/moon.go index 948aa5b..28a8100 100644 --- a/lite/moon/moon.go +++ b/lite/moon/moon.go @@ -60,27 +60,34 @@ func ApparentRaDec(date time.Time, lon, lat float64) (float64, float64) { return state.RightAscension, state.Declination } +func topocentricHorizontal(date time.Time, lon, lat float64) (altitude, azimuth, hourAngle float64) { + jd := basic.Date2JDE(date.UTC()) + state := lite.MoonTopocentric(jd, lon, lat, 0) + return lite.HorizontalCoordinates(state.RightAscension, state.Declination, jd, lon, lat) +} + // HourAngle 轻量时角 / lightweight hour angle. func HourAngle(date time.Time, lon, lat float64) float64 { - _, _, hourAngle := lite.HorizontalCoordinates(ApparentRa(date, lon, lat), ApparentDec(date, lon, lat), basic.Date2JDE(date.UTC()), lon, lat) + _, _, hourAngle := topocentricHorizontal(date, lon, lat) return hourAngle } // Azimuth 轻量方位角 / lightweight azimuth. func Azimuth(date time.Time, lon, lat float64) float64 { - _, azimuth, _ := lite.HorizontalCoordinates(ApparentRa(date, lon, lat), ApparentDec(date, lon, lat), basic.Date2JDE(date.UTC()), lon, lat) + _, azimuth, _ := topocentricHorizontal(date, lon, lat) return azimuth } // Altitude 轻量高度角 / lightweight altitude. func Altitude(date time.Time, lon, lat float64) float64 { - altitude, _, _ := lite.HorizontalCoordinates(ApparentRa(date, lon, lat), ApparentDec(date, lon, lat), basic.Date2JDE(date.UTC()), lon, lat) + altitude, _, _ := topocentricHorizontal(date, lon, lat) return altitude } // Zenith 轻量天顶距 / lightweight zenith distance. func Zenith(date time.Time, lon, lat float64) float64 { - return 90 - Altitude(date, lon, lat) + altitude, _, _ := topocentricHorizontal(date, lon, lat) + return 90 - altitude } // SunMoonLoDiff 轻量日月黄经差 / lightweight Moon-Sun ecliptic-longitude difference. diff --git a/lite/moon/moon_test.go b/lite/moon/moon_test.go index 4e3fed8..f310a74 100644 --- a/lite/moon/moon_test.go +++ b/lite/moon/moon_test.go @@ -2,9 +2,12 @@ package moon import ( "math" + "math/rand" "testing" "time" + "b612.me/astro/basic" + lite "b612.me/astro/lite/internal" fullmoon "b612.me/astro/moon" ) @@ -92,3 +95,83 @@ func assertTimeWithinMinutes(t *testing.T, name string, got, want time.Time, lim t.Fatalf("%s = %s, want %s", name, got, want) } } + +// 四个地平入口须只求一次站心位置:参考实现按旧口径对同一时刻重复求值,逐位对照。 +func TestHorizontalEntriesMatchDuplicatedEvaluation(t *testing.T) { + type site struct { + name string + lon, lat float64 + } + sites := []site{ + {"shanghai", 121.4737, 31.2304}, + {"sydney", 151.2093, -33.8688}, + {"north-pole", 0, 89.9999}, + {"south-pole", 0, -89.9999}, + {"dateline-west", -179.99, 12}, + {"dateline-east", 179.99, -12}, + {"equator", 0, 0}, + } + zones := []*time.Location{ + time.UTC, + time.FixedZone("CST", 8*3600), + time.FixedZone("EST", -5*3600), + time.FixedZone("LMT", -7*3600-52*60-58), + } + years := []int{-500, 1000, 1582, 2025, 2100, 3000, 4000} + hours := []int{0, 5, 12, 20, 23} + + type sample struct { + label string + date time.Time + lon float64 + lat float64 + } + samples := make([]sample, 0, len(sites)*len(zones)*len(years)*len(hours)+200) + for _, st := range sites { + for _, zone := range zones { + for _, year := range years { + for _, hour := range hours { + samples = append(samples, sample{ + label: st.name + "/" + zone.String(), + date: time.Date(year, 3, 17, hour, 43, 21, 123456789, zone), + lon: st.lon, + lat: st.lat, + }) + } + } + } + } + rng := rand.New(rand.NewSource(20260915)) + for i := 0; i < 200; i++ { + zone := time.FixedZone("random", (rng.Intn(97)-48)*1800) + samples = append(samples, sample{ + label: "random", + date: time.Date( + rng.Intn(8000)-2000, time.Month(1+rng.Intn(12)), 1+rng.Intn(28), + rng.Intn(24), rng.Intn(60), rng.Intn(60), rng.Intn(1000000000), zone, + ), + lon: rng.Float64()*360 - 180, + lat: rng.Float64()*179.8 - 89.9, + }) + } + + for _, s := range samples { + jd := basic.Date2JDE(s.date.UTC()) + altitude, azimuth, hourAngle := lite.HorizontalCoordinates(ApparentRa(s.date, s.lon, s.lat), ApparentDec(s.date, s.lon, s.lat), jd, s.lon, s.lat) + checks := []struct { + name string + got float64 + want float64 + }{ + {"HourAngle", HourAngle(s.date, s.lon, s.lat), hourAngle}, + {"Azimuth", Azimuth(s.date, s.lon, s.lat), azimuth}, + {"Altitude", Altitude(s.date, s.lon, s.lat), altitude}, + {"Zenith", Zenith(s.date, s.lon, s.lat), 90 - altitude}, + } + for _, check := range checks { + if check.got != check.want { + t.Fatalf("%s %s %s: got %.17g want %.17g", check.name, s.label, s.date.Format(time.RFC3339Nano), check.got, check.want) + } + } + } +} diff --git a/lite/moon/perf_bench_test.go b/lite/moon/perf_bench_test.go new file mode 100644 index 0000000..8b88073 --- /dev/null +++ b/lite/moon/perf_bench_test.go @@ -0,0 +1,84 @@ +package moon + +import ( + "testing" + "time" + + "b612.me/astro/basic" + lite "b612.me/astro/lite/internal" +) + +var benchmarkHorizontalSink float64 + +func benchmarkHorizontalInputs() (time.Time, float64, float64) { + return time.Date(2026, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)), 121.4737, 31.2304 +} + +func BenchmarkMoonHourAngle(b *testing.B) { + date, lon, lat := benchmarkHorizontalInputs() + b.ReportAllocs() + b.ResetTimer() + sink := 0.0 + for i := 0; i < b.N; i++ { + sink = HourAngle(date, lon, lat) + } + benchmarkHorizontalSink = sink +} + +func BenchmarkMoonAzimuth(b *testing.B) { + date, lon, lat := benchmarkHorizontalInputs() + b.ReportAllocs() + b.ResetTimer() + sink := 0.0 + for i := 0; i < b.N; i++ { + sink = Azimuth(date, lon, lat) + } + benchmarkHorizontalSink = sink +} + +func BenchmarkMoonAltitude(b *testing.B) { + date, lon, lat := benchmarkHorizontalInputs() + b.ReportAllocs() + b.ResetTimer() + sink := 0.0 + for i := 0; i < b.N; i++ { + sink = Altitude(date, lon, lat) + } + benchmarkHorizontalSink = sink +} + +func BenchmarkMoonZenith(b *testing.B) { + date, lon, lat := benchmarkHorizontalInputs() + b.ReportAllocs() + b.ResetTimer() + sink := 0.0 + for i := 0; i < b.N; i++ { + sink = Zenith(date, lon, lat) + } + benchmarkHorizontalSink = sink +} + +func BenchmarkMoonHorizontalLegacy(b *testing.B) { + date, lon, lat := benchmarkHorizontalInputs() + jd := basic.Date2JDE(date.UTC()) + entries := []struct { + name string + pick func(altitude, azimuth, hourAngle float64) float64 + }{ + {"HourAngle", func(_, _, hourAngle float64) float64 { return hourAngle }}, + {"Azimuth", func(_, azimuth, _ float64) float64 { return azimuth }}, + {"Altitude", func(altitude, _, _ float64) float64 { return altitude }}, + {"Zenith", func(altitude, _, _ float64) float64 { return 90 - altitude }}, + } + for _, entry := range entries { + b.Run(entry.name, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + sink := 0.0 + for i := 0; i < b.N; i++ { + sink = entry.pick(lite.HorizontalCoordinates(ApparentRa(date, lon, lat), ApparentDec(date, lon, lat), jd, lon, lat)) + } + benchmarkHorizontalSink = sink + }) + } +} diff --git a/lite/sun/doc.go b/lite/sun/doc.go new file mode 100644 index 0000000..d45ed90 --- /dev/null +++ b/lite/sun/doc.go @@ -0,0 +1,3 @@ +// Package sun 轻量太阳链路:简化的太阳真黄经与视黄经、轻量赤道坐标和升落搜索,角度单位为度。 +// Package sun is the lightweight solar chain: simplified true and apparent solar longitude, light equatorial conversion and rise/set search; angles are degrees. +package sun diff --git a/low_precision_regression_test.go b/low_precision_regression_test.go index 031e16f..94c909c 100644 --- a/low_precision_regression_test.go +++ b/low_precision_regression_test.go @@ -1,3 +1,4 @@ +// 低精度日月基线与高精度派生量的 n 截断收敛契约。 package astro_test import ( @@ -125,51 +126,47 @@ func TestLowPrecisionSunMoonBaselineRegression(t *testing.T) { } } -func TestDerivedHighPrecisionTruncationFullMatchesDefault(t *testing.T) { - jd := 2469808.7654321 +func derivedTruncationCases(samples []lowPrecisionSample) []truncationCase { + jdOf := func(index int) float64 { return samples[index].TTJD } lon := 116.391 lat := 39.907 tz := 8.0 - - assertSame := func(name string, got, want float64) { - t.Helper() - if math.Float64bits(got) != math.Float64bits(want) { - t.Fatalf("%s full-n mismatch", name) - } + // withSite 把带观测点经纬度与时区的入口适配成 jd 探针。 + withSite := func(f func(jd, lon, lat, tz float64, n int) float64) func(float64, int) float64 { + return func(jd float64, n int) float64 { return f(jd, lon, lat, tz, n) } } - assertSamePair := func(name string, got1, got2, want1, want2 float64) { - t.Helper() - assertSame(name+".1", got1, want1) - assertSame(name+".2", got2, want2) + withSiteRaDec := func(f func(jd, lon, lat, tz float64, n int) (float64, float64)) func(float64, int) (float64, float64) { + return func(jd float64, n int) (float64, float64) { return f(jd, lon, lat, tz, n) } } - assertSame("HSunTrueLo", basic.HSunTrueLo(jd), basic.HSunTrueLoN(jd, -1)) - assertSame("HSunTrueBo", basic.HSunTrueBo(jd), basic.HSunTrueBoN(jd, -1)) - assertSame("HSunApparentLo", basic.HSunApparentLo(jd), basic.HSunApparentLoN(jd, -1)) - assertSame("SunLoGXC", basic.SunLoGXC(jd), basic.SunLoGXCN(jd, -1)) - assertSame("EarthAway", basic.EarthAway(jd), basic.EarthAwayN(jd, -1)) - assertSame("HSunApparentRa", basic.HSunApparentRa(jd), basic.HSunApparentRaN(jd, -1)) - assertSame("HSunTrueRa", basic.HSunTrueRa(jd), basic.HSunTrueRaN(jd, -1)) - assertSame("HSunApparentDec", basic.HSunApparentDec(jd), basic.HSunApparentDecN(jd, -1)) - assertSame("HSunTrueDec", basic.HSunTrueDec(jd), basic.HSunTrueDecN(jd, -1)) - ra1, dec1 := basic.HSunApparentRaDec(jd) - ra2, dec2 := basic.HSunApparentRaDecN(jd, -1) - assertSamePair("HSunApparentRaDec", ra1, dec1, ra2, dec2) - - assertSame("HMoonTrueLo", basic.HMoonTrueLo(jd), basic.HMoonTrueLoN(jd, -1)) - assertSame("HMoonTrueBo", basic.HMoonTrueBo(jd), basic.HMoonTrueBoN(jd, -1)) - assertSame("HMoonAway", basic.HMoonAway(jd), basic.HMoonAwayN(jd, -1)) - assertSame("HMoonApparentLo", basic.HMoonApparentLo(jd), basic.HMoonApparentLoN(jd, -1)) - assertSame("HMoonTrueRa", basic.HMoonTrueRa(jd), basic.HMoonTrueRaN(jd, -1)) - assertSame("HMoonTrueDec", basic.HMoonTrueDec(jd), basic.HMoonTrueDecN(jd, -1)) - ra1, dec1 = basic.HMoonTrueRaDec(jd) - ra2, dec2 = basic.HMoonTrueRaDecN(jd, -1) - assertSamePair("HMoonTrueRaDec", ra1, dec1, ra2, dec2) - ra1, dec1 = basic.HMoonApparentRaDec(jd, lon, lat, tz) - ra2, dec2 = basic.HMoonApparentRaDecN(jd, lon, lat, tz, -1) - assertSamePair("HMoonApparentRaDec", ra1, dec1, ra2, dec2) - assertSame("HMoonApparentRa", basic.HMoonApparentRa(jd, lon, lat, tz), basic.HMoonApparentRaN(jd, lon, lat, tz, -1)) - assertSame("HMoonApparentDec", basic.HMoonApparentDec(jd, lon, lat, tz), basic.HMoonApparentDecN(jd, lon, lat, tz, -1)) - assertSame("HMoonAzimuth", basic.HMoonAzimuth(jd, lon, lat, tz), basic.HMoonAzimuthN(jd, lon, lat, tz, -1)) - assertSame("HMoonHeight", basic.HMoonHeight(jd, lon, lat, tz), basic.HMoonHeightN(jd, lon, lat, tz, -1)) + // 上限按 48 个采样点实测最坏值放宽 5 倍以上给出,随 n 递减。 + return []truncationCase{ + scalarTruncation("HSunTrueLoN", jdOf, basic.HSunTrueLoN, true, []float64{0.15, 0.05, 0.02, 0.006}), + scalarTruncation("HSunTrueBoN", jdOf, basic.HSunTrueBoN, true, []float64{1e-3, 5e-4, 3e-4, 1.5e-4}), + scalarTruncation("HSunApparentLoN", jdOf, basic.HSunApparentLoN, true, []float64{0.15, 0.05, 0.02, 0.006}), + scalarTruncation("SunLoGXCN", jdOf, basic.SunLoGXCN, true, []float64{1e-5, 2e-6, 1e-6, 3e-7}), + scalarTruncation("EarthAwayN", jdOf, basic.EarthAwayN, false, []float64{1e-3, 3e-4, 1e-4, 3e-5}), + scalarTruncation("HSunApparentRaN", jdOf, basic.HSunApparentRaN, true, []float64{0.15, 0.05, 0.02, 0.006}), + scalarTruncation("HSunTrueRaN", jdOf, basic.HSunTrueRaN, true, []float64{0.15, 0.05, 0.02, 0.006}), + scalarTruncation("HSunApparentDecN", jdOf, basic.HSunApparentDecN, true, []float64{0.05, 0.02, 0.008, 0.003}), + scalarTruncation("HSunTrueDecN", jdOf, basic.HSunTrueDecN, true, []float64{0.05, 0.02, 0.008, 0.003}), + pairTruncation("HSunApparentRaDecN", jdOf, basic.HSunApparentRaDecN, []float64{0.15, 0.05, 0.02, 0.006}), + scalarTruncation("HMoonTrueLoN", jdOf, basic.HMoonTrueLoN, true, []float64{5, 2.5, 1, 0.25}), + scalarTruncation("HMoonTrueBoN", jdOf, basic.HMoonTrueBoN, true, []float64{3, 0.75, 0.15, 0.06}), + scalarTruncation("HMoonAwayN", jdOf, basic.HMoonAwayN, false, []float64{40000, 7000, 2500, 400}), + scalarTruncation("HMoonApparentLoN", jdOf, basic.HMoonApparentLoN, true, []float64{5, 2.5, 1, 0.25}), + scalarTruncation("HMoonTrueRaN", jdOf, basic.HMoonTrueRaN, true, []float64{5, 2.5, 1, 0.3}), + scalarTruncation("HMoonTrueDecN", jdOf, basic.HMoonTrueDecN, true, []float64{3, 1, 0.4, 0.1}), + pairTruncation("HMoonTrueRaDecN", jdOf, basic.HMoonTrueRaDecN, []float64{5, 2.5, 1, 0.3}), + scalarTruncation("HMoonApparentRaN", jdOf, withSite(basic.HMoonApparentRaN), true, []float64{5, 2.5, 1, 0.3}), + scalarTruncation("HMoonApparentDecN", jdOf, withSite(basic.HMoonApparentDecN), true, []float64{4, 1, 0.3, 0.1}), + pairTruncation("HMoonApparentRaDecN", jdOf, withSiteRaDec(basic.HMoonApparentRaDecN), []float64{5, 2.5, 1, 0.3}), + scalarTruncation("HMoonAzimuthN", jdOf, withSite(basic.HMoonAzimuthN), true, []float64{9, 4, 2, 0.9}), + scalarTruncation("HMoonHeightN", jdOf, withSite(basic.HMoonHeightN), true, []float64{4, 2, 0.7, 0.15}), + } +} + +func TestDerivedHighPrecisionTruncationConverges(t *testing.T) { + samples := loadLowPrecisionSamples(t) + assertTruncationConverges(t, derivedTruncationCases(samples), len(samples)) } diff --git a/mars/doc.go b/mars/doc.go new file mode 100644 index 0000000..abc8307 --- /dev/null +++ b/mars/doc.go @@ -0,0 +1,3 @@ +// Package mars 火星位置、升落、合冲、留、方照、相位、视星等、视直径与物理星历,角度单位为度,距离单位为 AU。 +// Package mars covers Mars position, rise/set, conjunction, opposition, station, quadrature, phase, magnitude, apparent diameter and physical ephemeris; angles are degrees, distances are AU. +package mars diff --git a/mercury/doc.go b/mercury/doc.go new file mode 100644 index 0000000..9db789e --- /dev/null +++ b/mercury/doc.go @@ -0,0 +1,3 @@ +// Package mercury 水星位置、升落、合日、留、大距、地心凌日、相位、视直径与物理星历,角度单位为度。 +// Package mercury covers Mercury position, rise/set, conjunction, station, elongation, geocentric transit, phase, apparent diameter and physical ephemeris; angles are degrees. +package mercury diff --git a/moon/occultation.go b/moon/occultation.go index 4d34374..076d346 100644 --- a/moon/occultation.go +++ b/moon/occultation.go @@ -1,6 +1,10 @@ package moon -import "b612.me/astro/basic" +import ( + "time" + + "b612.me/astro/basic" +) // ErrInvalidOccultationInput 表示月掩输入无效。 // ErrInvalidOccultationInput reports invalid lunar-occultation input. @@ -96,6 +100,16 @@ type OccultationSearchOptions = basic.OccultationSearchOptions // OccultationPathOptions controls global occultation-path sampling. type OccultationPathOptions = basic.OccultationPathOptions +// OccultationPathAlgorithm 选择全球月掩路径的优化或原有精确分支。 +// OccultationPathAlgorithm selects the optimized or original exact global-path branch. +type OccultationPathAlgorithm = basic.OccultationPathAlgorithm + +// 全球月掩路径的两条算法分支取值 / the two global occultation-path algorithm values. +const ( + OccultationPathAlgorithmOptimized = basic.OccultationPathAlgorithmOptimized + OccultationPathAlgorithmExact = basic.OccultationPathAlgorithmExact +) + // StarOccultationInfo 描述一次点光源恒星月掩。 // StarOccultationInfo describes one point-source stellar occultation. type StarOccultationInfo = basic.StarOccultationInfo @@ -108,10 +122,39 @@ type PlanetOccultationInfo = basic.PlanetOccultationInfo // OccultationPathPoint is a geographic sample of a global occultation path. type OccultationPathPoint = basic.OccultationPathPoint +// RiseSetPhase 标识局部月掩阶段。 +// RiseSetPhase identifies a local occultation phase. +type RiseSetPhase = basic.RiseSetPhase + +// RiseSetDirection 标识月升或月落方向。 +// RiseSetDirection identifies moonrise or moonset. +type RiseSetDirection = basic.RiseSetDirection + +// 与 basic 同名的阶段与方向常量 / the phase and direction constants re-exported from basic. +const ( + RiseSetPhaseStart = basic.RiseSetPhaseStart + RiseSetPhaseGreatest = basic.RiseSetPhaseGreatest + RiseSetPhaseEnd = basic.RiseSetPhaseEnd + RiseSetDirectionRise = basic.RiseSetDirectionRise + RiseSetDirectionSet = basic.RiseSetDirectionSet +) + +// OccultationRiseSetCurve 是局部阶段发生在月升或月落时的边界。 +// OccultationRiseSetCurve is a boundary where a local phase occurs at moonrise or moonset. +type OccultationRiseSetCurve = basic.OccultationRiseSetCurve + +// OccultationGreatestTimeContour 是一个固定地方掩甚时刻的等值线支路集合。 +// OccultationGreatestTimeContour contains the continuous branches of one fixed local greatest-occultation time. +type OccultationGreatestTimeContour = basic.OccultationGreatestTimeContour + // StarOccultationPath 包含点光源恒星月掩掩带。 // StarOccultationPath contains a point-source stellar occultation footprint. type StarOccultationPath = basic.StarOccultationPath +// OccultationFootprint 是一个时刻的可见接触区域。 +// OccultationFootprint is one instantaneous visible contact region. +type OccultationFootprint = basic.OccultationFootprint + // PlanetOccultationPath 包含有限盘面行星月掩掩带。 // PlanetOccultationPath contains a finite-disk planetary occultation footprint. type PlanetOccultationPath = basic.PlanetOccultationPath @@ -119,3 +162,23 @@ type PlanetOccultationPath = basic.PlanetOccultationPath // PlanetOccultationFootprint 是一个时刻的可见行星接触区域。 // PlanetOccultationFootprint is one instantaneous visible planetary-contact region. type PlanetOccultationFootprint = basic.PlanetOccultationFootprint + +// StarOccultationInstant 是一个请求时刻的点源恒星月掩可见足迹。 +// StarOccultationInstant is the visible point-source footprint at one requested instant. +type StarOccultationInstant = basic.StarOccultationInstant + +// PlanetOccultationInstant 是一个请求时刻的行星偏掩和全掩可见足迹。 +// PlanetOccultationInstant contains the visible partial and total planetary footprints at one requested instant. +type PlanetOccultationInstant = basic.PlanetOccultationInstant + +// StarOccultationFootprintAt 计算指定时刻的精确恒星月掩可见足迹。 +// StarOccultationFootprintAt calculates the exact visible stellar-occultation footprint at one instant. +func StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error) { + return basic.StarOccultationFootprintAt(at, star) +} + +// PlanetOccultationFootprintsAt 计算指定时刻的精确行星偏掩和全掩可见足迹。 +// PlanetOccultationFootprintsAt calculates the exact visible partial and total planetary footprints at one instant. +func PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error) { + return basic.PlanetOccultationFootprintsAt(at, planet) +} diff --git a/moon/occultation_path_test.go b/moon/occultation_path_test.go index 33101b1..04f5963 100644 --- a/moon/occultation_path_test.go +++ b/moon/occultation_path_test.go @@ -5,8 +5,41 @@ import ( "math" "testing" "time" + + "b612.me/astro/basic" ) +func TestStarOccultationBoundaryTimesRemainStrictAfterAdaptiveConvergence(t *testing.T) { + data, err := basic.StarDataByChinese("进贤增九") + if err != nil { + t.Fatal(err) + } + star, err := StarCoordinateFromStarData(data) + if err != nil { + t.Fatal(err) + } + for _, date := range []time.Time{ + time.Date(2033, time.March, 17, 0, 0, 0, 0, time.UTC), + time.Date(2050, time.December, 8, 0, 0, 0, 0, time.UTC), + } { + paths, err := FindStarOccultationPaths(date, date.Add(24*time.Hour), star, + OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true, DisableRiseSet: true}) + if err != nil { + t.Fatalf("%s: FindStarOccultationPaths: %v", date.Format("2006-01-02"), err) + } + if len(paths) == 0 { + t.Fatalf("%s: no occultation path", date.Format("2006-01-02")) + } + for _, points := range [][]OccultationPathPoint{paths[0].NorthernLimit, paths[0].SouthernLimit, paths[0].CenterLine} { + for index := 1; index < len(points); index++ { + if !points[index].Time.After(points[index-1].Time) { + t.Fatalf("%s: non-increasing path time at %d: %s then %s", date.Format("2006-01-02"), index, points[index-1].Time, points[index].Time) + } + } + } + } +} + func TestFindStarOccultationPathsHR4799(t *testing.T) { location := time.FixedZone("CST", 8*3600) star := StarCoordinate{ @@ -221,6 +254,7 @@ func BenchmarkFindStarOccultationPathTargetSpacing(b *testing.B) { star := hr4799CoordinateForTest() for _, spacing := range []float64{1, 10, 200} { b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) { + b.ReportAllocs() for i := 0; i < b.N; i++ { paths, err := FindStarOccultationPaths( time.Date(2025, 6, 5, 0, 0, 0, 0, location), diff --git a/moon/occultation_planet_path_test.go b/moon/occultation_planet_path_test.go index 281d0bf..4e024a4 100644 --- a/moon/occultation_planet_path_test.go +++ b/moon/occultation_planet_path_test.go @@ -120,6 +120,7 @@ func TestGlobalPlanetOccultationQueriesSelectByGeometricGreatest(t *testing.T) { func BenchmarkFindPlanetOccultationPathTargetSpacing(b *testing.B) { for _, spacing := range []float64{1, 10, 200} { b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) { + b.ReportAllocs() for i := 0; i < b.N; i++ { paths, err := FindPlanetOccultationPaths( time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC), diff --git a/moon/occultation_planet_test.go b/moon/occultation_planet_test.go index 3a74130..dbebbda 100644 --- a/moon/occultation_planet_test.go +++ b/moon/occultation_planet_test.go @@ -124,3 +124,48 @@ func TestFindPlanetOccultationsValidatesInputs(t *testing.T) { }) } } + +func TestFindBestPlanetOccultationsKeepsNonCentralEvents(t *testing.T) { + // 2025-01-05 月掩海王星与 2025-07-28 月掩火星都是月影轴不与地球椭球相交的非中心 + // 事件:最佳站心曾取外接触切点,该点位于掩带边缘、落在掩食之外,整场被事件搜索 + // 丢弃。事件搜索与路径搜索必须报告同一场事件,掩甚站心必须落在掩食之内。 + // The 2025-01-05 Neptune and 2025-07-28 Mars occultations are non-central events + // whose shadow axis misses the ellipsoid. The best station used to be taken at the + // outer contact tangent, which sits on the band edge and outside the occultation, so + // the whole event was dropped from the search. The event and path searches must + // report the same event with a greatest station inside the occultation. + tests := []struct { + name string + planet OccultationPlanet + day time.Time + }{ + {name: "2025-01-05 Neptune", planet: OccultationNeptune, day: time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC)}, + {name: "2025-07-28 Mars", planet: OccultationMars, day: time.Date(2025, time.July, 28, 0, 0, 0, 0, time.UTC)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + start := test.day.Add(-24 * time.Hour) + end := test.day.Add(24 * time.Hour) + events, err := FindBestPlanetOccultations(start, end, test.planet, OccultationSearchOptions{}) + if err != nil { + t.Fatalf("FindBestPlanetOccultations() error = %v", err) + } + paths, err := FindPlanetOccultationPaths(start, end, test.planet, OccultationPathOptions{}) + if err != nil { + t.Fatalf("FindPlanetOccultationPaths() error = %v", err) + } + if len(events) != 1 || len(paths) != 1 { + t.Fatalf("event search returned %d events, path search %d, want one each", len(events), len(paths)) + } + event := events[0] + limit := event.MoonSemidiameterArcsec + event.PlanetSemidiameterArcsec + if event.MinimumSeparationArcsec > limit { + t.Fatalf("greatest station lies outside the occultation: separation=%.3f limit=%.3f", + event.MinimumSeparationArcsec, limit) + } + if delta := math.Abs(event.Greatest.Sub(paths[0].Greatest.Time).Seconds()); delta > 60 { + t.Fatalf("greatest differs from the path search by %.1fs", delta) + } + }) + } +} diff --git a/moon/occultation_star_test.go b/moon/occultation_star_test.go index 0eb4b0e..9d5764b 100644 --- a/moon/occultation_star_test.go +++ b/moon/occultation_star_test.go @@ -196,3 +196,46 @@ func TestFindStarOccultationsValidatesInputs(t *testing.T) { t.Fatalf("FindStarOccultations() observer error = %v, want ErrInvalidOccultationInput", err) } } + +func TestFindBestStarOccultationsKeepsNonCentralEvents(t *testing.T) { + // HR 1118 的黄纬为 +5.58°,接近月掩纬度极限,因此 2025-02-06 与 2025-05-26 的月掩 + // 都是月影轴不与地球椭球相交的非中心事件;最佳站心曾取外接触切点而整场丢失。 + // HR 1118 sits at ecliptic latitude +5.58°, close to the occultation latitude limit, + // so its 2025-02-06 and 2025-05-26 events are non-central: the shadow axis misses the + // ellipsoid and the best station used to be taken at the outer contact tangent, which + // dropped the whole event from the search. + star := StarCoordinate{ + ID: "HR 1118", + RA: 55.19291667, + Dec: 25.32944444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), + Frame: CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: 8, + ProperMotionDecMasPerYear: -15, + } + for _, day := range []time.Time{ + time.Date(2025, time.February, 6, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.May, 26, 0, 0, 0, 0, time.UTC), + } { + t.Run(day.Format("2006-01-02"), func(t *testing.T) { + start := day.Add(-24 * time.Hour) + end := day.Add(24 * time.Hour) + events, err := FindBestStarOccultations(start, end, star, OccultationSearchOptions{}) + if err != nil { + t.Fatalf("FindBestStarOccultations() error = %v", err) + } + paths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{}) + if err != nil { + t.Fatalf("FindStarOccultationPaths() error = %v", err) + } + if len(events) != 1 || len(paths) != 1 { + t.Fatalf("event search returned %d events, path search %d, want one each", len(events), len(paths)) + } + event := events[0] + if event.MinimumSeparationArcsec > event.MoonSemidiameterArcsec { + t.Fatalf("greatest station lies outside the occultation: separation=%.3f moon=%.3f", + event.MinimumSeparationArcsec, event.MoonSemidiameterArcsec) + } + }) + } +} diff --git a/moon/svg/long_text_footer_test.go b/moon/svg/long_text_footer_test.go new file mode 100644 index 0000000..949f7ac --- /dev/null +++ b/moon/svg/long_text_footer_test.go @@ -0,0 +1,176 @@ +package svg + +import ( + "strings" + "testing" + + "b612.me/astro/internal/svgchart" +) + +// 调用方给图题、摘要、地图标题与页脚传超长文本时,产物必须留在画布内并在截断处给出省略号; +// 判定口径与 internal/svgchart 的 EstimatedTextWidth/EstimatedTextExtents 一致。 + +func footerFitLongText(prefix string) string { + piece := prefix + "long custom text 自定义长文本,用于验证折行与截断;" + value := "" + for len([]rune(value)) < 640 { + value += piece + } + return value +} + +func footerFitViolations(t *testing.T, diagram string, width, height int) []string { + t.Helper() + violations := []string{} + for _, text := range occultationSVGTexts(t, diagram) { + textWidth := svgchart.EstimatedTextWidth(text.value, text.fontSize) + left := text.x + switch text.anchor { + case "middle": + left = text.x - textWidth/2 + case "end": + left = text.x - textWidth + } + above, below := svgchart.EstimatedTextExtents(text.fontSize) + if text.x < 0 || text.x > float64(width) || text.y < 0 || text.y > float64(height) { + violations = append(violations, text.value) + continue + } + if left < 0 || left+textWidth > float64(width) || text.y-above < 0 || text.y+below > float64(height) { + violations = append(violations, text.value) + } + } + return violations +} + +func footerFitAssert(t *testing.T, name string, diagram string, width, height int) { + t.Helper() + if violations := footerFitViolations(t, diagram, width, height); len(violations) > 0 { + t.Fatalf("%s: %d texts outside the %dx%d canvas, first = %q", + name, len(violations), width, height, violations[0]) + } + if !strings.Contains(diagram, "…") { + t.Fatalf("%s: truncated custom text is not marked", name) + } +} + +func footerFitStarTexts(long string) StarOccultationSVGOptions { + return StarOccultationSVGOptions{ + Title: long, SummaryText: long, GreatestText: long, + MapTitle: long, ContactsTitle: long, FooterNote: long, + } +} + +func TestStarOccultationPathSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("star ") + if runes := len([]rune(long)); runes < 300 || runes > 800 { + t.Fatalf("long text runes = %d, want 300..800", runes) + } + path := occultationTestStarPath(t) + for _, size := range [][2]int{{640, 480}, {920, 720}, {1200, 800}} { + options := footerFitStarTexts(long) + options.Width, options.Height = size[0], size[1] + diagram, err := StarOccultationPathSVG(path, options) + if err != nil { + t.Fatalf("%dx%d: %v", size[0], size[1], err) + } + footerFitAssert(t, "star-global", diagram, size[0], size[1]) + } +} + +func TestPlanetOccultationPathSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("planet ") + path := occultationTestSaturnPath(t) + for _, size := range [][2]int{{640, 480}, {920, 720}, {1200, 800}} { + options := PlanetOccultationSVGOptions(footerFitStarTexts(long)) + options.Width, options.Height = size[0], size[1] + diagram, err := PlanetOccultationPathSVG(path, options) + if err != nil { + t.Fatalf("%dx%d: %v", size[0], size[1], err) + } + footerFitAssert(t, "planet-global", diagram, size[0], size[1]) + } +} + +func TestOccultationDetailedSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("detailed ") + star := occultationTestStarPath(t) + planetPath := occultationTestSaturnPath(t) + for _, size := range [][2]int{{700, 990}, {1000, 1414}} { + diagram, err := StarOccultationDetailedSVG(star, hr4799StarCoordinate(), OccultationDetailedSVGOptions{ + Width: size[0], Height: size[1], Title: long, FooterNote: long, + }) + if err != nil { + t.Fatalf("star %dx%d: %v", size[0], size[1], err) + } + footerFitAssert(t, "star-detailed", diagram, size[0], size[1]) + diagram, err = PlanetOccultationDetailedSVG(planetPath, OccultationDetailedSVGOptions{ + Width: size[0], Height: size[1], Title: long, FooterNote: long, + }) + if err != nil { + t.Fatalf("planet %dx%d: %v", size[0], size[1], err) + } + footerFitAssert(t, "planet-detailed", diagram, size[0], size[1]) + } +} + +func TestLocalStarOccultationSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("local ") + info := localHR4799Occultation(t) + for _, size := range [][2]int{{640, 520}, {920, 720}} { + diagram, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{ + Width: size[0], Height: size[1], Title: long, SummaryText: long, GreatestText: long, + OverviewTitle: long, PhasePanelsTitle: long, ContactsTitle: long, + DirectionText: long, FooterNote: long, + }) + if err != nil { + t.Fatalf("%dx%d: %v", size[0], size[1], err) + } + footerFitAssert(t, "star-local", diagram, size[0], size[1]) + } +} + +func TestLocalPlanetOccultationSVGCustomLongTextStaysInsideCanvas(t *testing.T) { + long := footerFitLongText("local-planet ") + info := localSaturnOccultation(t) + for _, size := range [][2]int{{760, 600}, {920, 720}} { + diagram, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{ + Width: size[0], Height: size[1], Title: long, SummaryText: long, GreatestText: long, + OverviewTitle: long, PhasePanelsTitle: long, ContactsTitle: long, + DirectionText: long, FooterNote: long, + }) + if err != nil { + t.Fatalf("%dx%d: %v", size[0], size[1], err) + } + footerFitAssert(t, "planet-local", diagram, size[0], size[1]) + } +} + +// 自动生成的默认文案不得进入截断分支:这些产物在改动前后逐字节一致,出现省略号即说明分流写错。 +func TestOccultationSVGDefaultTextIsNotTruncated(t *testing.T) { + star := occultationTestStarPath(t) + planetPath := occultationTestSaturnPath(t) + for _, language := range []string{"zh", "en"} { + documents := []string{} + starGlobal, err := StarOccultationPathSVG(star, StarOccultationSVGOptions{Language: language}) + if err != nil { + t.Fatalf("star global %s: %v", language, err) + } + documents = append(documents, starGlobal) + planetGlobal, err := PlanetOccultationPathSVG(planetPath, PlanetOccultationSVGOptions{Language: language}) + if err != nil { + t.Fatalf("planet global %s: %v", language, err) + } + documents = append(documents, planetGlobal) + detailed, err := StarOccultationDetailedSVG(star, hr4799StarCoordinate(), OccultationDetailedSVGOptions{Language: language}) + if err != nil { + t.Fatalf("star detailed %s: %v", language, err) + } + documents = append(documents, detailed) + for _, document := range documents { + if strings.Contains(document, "…") { + t.Fatalf("%s: default text must stay unmarked", language) + } + } + } +} diff --git a/moon/svg/map_projection_contract_test.go b/moon/svg/map_projection_contract_test.go new file mode 100644 index 0000000..d2c6db5 --- /dev/null +++ b/moon/svg/map_projection_contract_test.go @@ -0,0 +1,44 @@ +package svg + +import ( + "testing" + + eclipsesvg "b612.me/astro/eclipse/svg" + "b612.me/astro/internal/svgmap" +) + +// 两包为同一投影概念各留一套名字:取值字符串与内部 svgmap 映射必须逐对一致。 +func TestMapProjectionMatchesEclipseSVGContract(t *testing.T) { + pairs := []struct { + name string + value MapProjection + eclipse eclipsesvg.EclipseMapProjection + text string + }{ + {name: "auto", value: MapProjectionAuto, eclipse: eclipsesvg.EclipseMapProjectionAuto, text: ""}, + {name: "equirectangular", value: MapProjectionEquirectangular, eclipse: eclipsesvg.EclipseMapProjectionEquirectangular, text: "equirectangular"}, + {name: "north-polar", value: MapProjectionNorthPolar, eclipse: eclipsesvg.EclipseMapProjectionNorthPolar, text: "north-polar"}, + {name: "south-polar", value: MapProjectionSouthPolar, eclipse: eclipsesvg.EclipseMapProjectionSouthPolar, text: "south-polar"}, + {name: "orthographic", value: MapProjectionOrthographic, eclipse: eclipsesvg.EclipseMapProjectionOrthographic, text: "orthographic"}, + } + seen := map[svgmap.Projection]string{} + for _, pair := range pairs { + if got := string(pair.value); got != pair.text { + t.Fatalf("moon/svg %s = %q, want %q", pair.name, got, pair.text) + } + if got := string(pair.eclipse); got != pair.text { + t.Fatalf("eclipse/svg %s = %q, want %q", pair.name, got, pair.text) + } + moonProjection := internalMapProjection(pair.value) + if moonProjection != svgmap.Projection(pair.text) { + t.Fatalf("moon/svg %s maps to %q, want %q", pair.name, moonProjection, svgmap.Projection(pair.text)) + } + if eclipseProjection := svgmap.Projection(pair.eclipse); eclipseProjection != moonProjection { + t.Fatalf("%s maps to %q in moon/svg and %q in eclipse/svg", pair.name, moonProjection, eclipseProjection) + } + if previous, ok := seen[moonProjection]; ok { + t.Fatalf("%s and %s share the internal projection %q", previous, pair.name, moonProjection) + } + seen[moonProjection] = pair.name + } +} diff --git a/moon/svg/occultation.go b/moon/svg/occultation.go index 6013df6..0b93f76 100644 --- a/moon/svg/occultation.go +++ b/moon/svg/occultation.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "b612.me/astro/internal/occultationgeo" + "b612.me/astro/internal/svgchart" "b612.me/astro/internal/svgmap" "b612.me/astro/moon" ) @@ -17,8 +19,8 @@ import ( const ( starOccultationSVGDefaultWidth = 1200 starOccultationSVGDefaultHeight = 800 - starOccultationSVGMinimumWidth = 480 - starOccultationSVGMinimumHeight = 360 + starOccultationSVGMinimumWidth = 640 + starOccultationSVGMinimumHeight = 480 starOccultationSVGDefaultZone = 8 * 60 * 60 starOccultationSVGLanguageChinese = "zh" @@ -32,8 +34,9 @@ var ErrInvalidStarOccultationSVGOptions = errors.New("invalid stellar occultatio // StarOccultationSVGOptions 控制恒星月掩全球掩带 SVG 输出。 // StarOccultationSVGOptions controls a global stellar-occultation path SVG. type StarOccultationSVGOptions struct { - // Width 和 Height 是 SVG 画布尺寸;非正值使用 1200x800 全球地图默认值。 - // Width and Height are the SVG canvas dimensions. Values <= 0 use the 1200x800 global-map default. + // Width 和 Height 是 SVG 画布尺寸;非正值使用 1200x800 全球地图默认值,宽度小于 640 或高度小于 480 时返回 ErrInvalidStarOccultationSVGOptions。 + // Width and Height are the SVG canvas dimensions. Values <= 0 use the 1200x800 global-map default; + // a width below 640 or a height below 480 returns ErrInvalidStarOccultationSVGOptions. Width int Height int // Title 及后续文本字段覆盖自动生成的标签。 @@ -96,7 +99,12 @@ func StarOccultationPathSVG( return "", err } options = normalizeStarOccultationSVGOptions(options) - return renderStarOccultationPathSVG(path, options), nil + diagram, err := renderStarOccultationPathSVG(path, options) + if err != nil { + // 几何引擎失败不是数据格式错误,不能裹成 ErrInvalidStarOccultationPath 哨兵。 + return "", fmt.Errorf("stellar occultation SVG band geometry: %w", err) + } + return diagram, nil } func validateStarOccultationSVGOptions(options StarOccultationSVGOptions) error { @@ -107,7 +115,7 @@ func validateStarOccultationSVGOptions(options StarOccultationSVGOptions) error return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidStarOccultationSVGOptions, starOccultationSVGMinimumHeight) } switch options.Projection { - case MapProjectionAuto, MapProjectionEquirectangular, MapProjectionNorthPolar, MapProjectionSouthPolar: + case MapProjectionAuto, MapProjectionEquirectangular, MapProjectionNorthPolar, MapProjectionSouthPolar, MapProjectionOrthographic: default: return fmt.Errorf("%w: unsupported map projection %q", ErrInvalidStarOccultationSVGOptions, options.Projection) } @@ -139,21 +147,53 @@ func normalizeStarOccultationSVGOptions(options StarOccultationSVGOptions) StarO return options } -func renderStarOccultationPathSVG(path moon.StarOccultationPath, options StarOccultationSVGOptions) string { - return renderOccultationPathSVG(path, nil, options) +func renderStarOccultationPathSVG(path moon.StarOccultationPath, options StarOccultationSVGOptions) (string, error) { + return renderOccultationPathSVG(path, nil, options, starOccultationSVGTextFlags(options)) +} + +// occultationSVGTextFlags 记录哪些文本字段由调用方显式给出:只有这类文本走保守折行与截断, +// 自动文案必须逐字节保持原样。 +type occultationSVGTextFlags struct { + title bool + summaryText bool + greatestText bool + footerNote bool +} + +func starOccultationSVGTextFlags(options StarOccultationSVGOptions) occultationSVGTextFlags { + return occultationSVGTextFlags{ + title: options.Title != "", + summaryText: options.SummaryText != "", + greatestText: options.GreatestText != "", + footerNote: options.FooterNote != "", + } } func renderOccultationPathSVG( path moon.StarOccultationPath, planetPath *moon.PlanetOccultationPath, options StarOccultationSVGOptions, -) string { + flags occultationSVGTextFlags, +) (string, error) { projection := resolveStarOccultationMapProjection(path, options.Projection) options.Projection = MapProjection(projection) title := starOccultationSVGTitle(path, options) - headerLines := starOccultationSVGHeaderLines(path, options) + headerLines := starOccultationSVGHeaderLines(path, options, flags) + // 页眉字号 14/13,省略号按较大的 14 估算。 + headerLines = svgchart.TruncateTextLines(headerLines, float64(options.Width)-80, 14, + starOccultationSVGHeaderLineLimit(options, projection)) headerBottom := 72.0 + float64(len(headerLines))*19 - layout := starOccultationSVGLayoutFor(options, headerBottom, projection) + // 只有正射球面需要视点:不给就会退回 (0°,0°) 而完全不对准事件,与日食球面图不一致。 + // 等经纬与极区图不受影响,避免改动既有版面。 + center := svgmap.GeoPoint{} + if projection == svgmap.ProjectionOrthographic { + if planetPath != nil { + center = planetOccultationOrthographicCentre(*planetPath) + } else { + center = svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude} + } + } + layout := starOccultationSVGLayoutFor(options, headerBottom, projection, center) var b strings.Builder fmt.Fprintf(&b, ``, @@ -164,8 +204,13 @@ func renderOccultationPathSVG( b.WriteString(``) fmt.Fprintf(&b, ``, layout.width-44, layout.height-36) + titleSize := starOccultationTitleFontSize(title, layout.width) + titleText := title + if flags.title { + titleText = svgchart.EllipsizeText(title, layout.width-80, float64(titleSize)) + } fmt.Fprintf(&b, `%s`, - layout.width/2, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title)) + layout.width/2, titleSize, html.EscapeString(titleText)) fmt.Fprintf(&b, ``, layout.width/2-78, layout.width/2+78) for index, line := range headerLines { fontSize := 13 @@ -178,18 +223,24 @@ func renderOccultationPathSVG( layout.width/2, 82+float64(index)*19, fill, fontSize, html.EscapeString(line)) } + mapTitle := occultationTitleText(starOccultationSVGMapTitle(options), options.MapTitle != "", + layout.width-layout.mapX-layout.margin, 14) fmt.Fprintf(&b, `%s`, - layout.mapX, layout.mapY-10, html.EscapeString(starOccultationSVGMapTitle(options))) + layout.mapX, layout.mapY-10, html.EscapeString(mapTitle)) if planetPath == nil { - writeStarOccultationMap(&b, path, layout, options) + if err := writeStarOccultationMap(&b, path, layout, options); err != nil { + return "", err + } writeStarOccultationEventsPanel(&b, path, layout, options) } else { - writePlanetOccultationMap(&b, *planetPath, path, layout, options) + if err := writePlanetOccultationMap(&b, *planetPath, path, layout, options); err != nil { + return "", err + } writePlanetOccultationEventsPanel(&b, *planetPath, layout, options) } - writeStarOccultationFooter(&b, layout, options) + writeStarOccultationFooter(&b, layout, options, flags) b.WriteString(``) - return b.String() + return b.String(), nil } func writeStarOccultationMap( @@ -197,22 +248,69 @@ func writeStarOccultationMap( path moon.StarOccultationPath, layout starOccultationSVGLayout, options StarOccultationSVGOptions, -) { +) error { layout.mapFrame().WriteOcean(b) writeStarOccultationGraticule(b, layout) writeStarOccultationLand(b, layout) - writeStarOccultationBand(b, path, layout) - writeStarOccultationGeoLine(b, path.NorthernLimit, layout, "northern-limit", "#a66f18", 1.35, "") - writeStarOccultationGeoLine(b, path.SouthernLimit, layout, "southern-limit", "#a66f18", 1.35, "") - writeStarOccultationGeoLine(b, path.CenterLine, layout, "center-line", "#59676b", 1.6, "5 4") + band := occultationBandDraw{} + footprints := path.BandFootprints + compact := len(footprints) > 0 + if len(footprints) == 0 { + footprints = path.Footprints + } + if len(footprints) > 0 { + draw, err := (occultationFootprintSweep{ + footprints: footprints, + contours: path.BandContours, + visibilityContours: path.VisibilityContours, + northern: path.NorthernLimit, + southern: path.SouthernLimit, + curves: path.RiseSetCurves, + layout: layout, + layerClass: "occultation-band-layer", + pathClass: "occultation-band", + color: "#e0ae43", + opacity: 0.28, + compactBand: compact, + kind: starOccultationFootprintSweep, + }).write(b) + if err != nil { + return err + } + band = draw + } else { + writeStarOccultationBand(b, path, layout) + } + if !band.compact { + writeStarOccultationGeoLine(b, path.NorthernLimit, layout, + occultationLineStyle{className: "northern-limit", color: "#a66f18", strokeWidth: 1.35, boundary: true}) + writeStarOccultationGeoLine(b, path.SouthernLimit, layout, + occultationLineStyle{className: "southern-limit", color: "#a66f18", strokeWidth: 1.35, boundary: true}) + } + // Draw phase curves after the translucent band so the physical rise/set + // lines remain visible across the filled area. These are open phase curves, + // not an artificial outline of the static band. + writeOccultationRiseSetCurves(b, path.RiseSetCurves, layout, "occultation") + writeOccultationHorizonConnectors( + b, footprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, layout, + true, + ) + // 事件标记先占位、后绘制:三族标签共用一张占位表,后一族必须绕开先放置的标签。 + placer := &occultationLabelPlacer{} + eventMarkers := starOccultationEventMarkerDraws(path, layout, options.Language, placer) + writeOccultationGreatestTimeContours(b, path.GreatestTimeContours, layout, options, placer) + writeStarOccultationGeoLine(b, path.CenterLine, layout, + occultationLineStyle{className: "center-line", color: "#59676b", strokeWidth: 1.6, dash: "5 4"}) writeStarOccultationVisibleCenterLine(b, path.CenterLine, layout) writeOccultationTimeMarkers(b, path.CenterLine, layout, options, - []time.Time{path.Start.Time, path.End.Time}, path.Greatest.Time) - writeStarOccultationEventMarker(b, path.Start, layout, starOccultationEventLabel("start", options.Language), "start") - writeStarOccultationEventMarker(b, path.Greatest, layout, starOccultationEventLabel("greatest", options.Language), "greatest") - writeStarOccultationEventMarker(b, path.End, layout, starOccultationEventLabel("end", options.Language), "end") + []time.Time{path.Start.Time, path.End.Time}, path.Greatest.Time, placer) + for _, marker := range eventMarkers { + marker.write(b) + } layout.mapFrame().WriteFrame(b) - writeStarOccultationLegend(b, layout, options.Language) + writeStarOccultationLegend(b, layout, options.Language, band, len(path.RiseSetCurves) > 0, + len(path.GreatestTimeContours) > 0) + return nil } func writeStarOccultationGraticule(b *strings.Builder, layout starOccultationSVGLayout) { @@ -227,8 +325,14 @@ func writeStarOccultationGraticule(b *strings.Builder, layout starOccultationSVG } for _, latitude := range []float64{-60, -30, 0, 30, 60} { _, y := layout.project(0, latitude) - fmt.Fprintf(b, `%.0f°`, - layout.mapX-5, y+3, latitude) + text := fmt.Sprintf("%.0f°", latitude) + // 图框左侧放不下刻度时改放框内左侧,避免负 x 越出画布。 + x, anchor := layout.mapX-5, "end" + if x-occultationLabelTextWidth(text, 9) < 3 { + x, anchor = layout.mapX+4, "start" + } + fmt.Fprintf(b, `%s`, + x, y+3, anchor, text) } } @@ -236,6 +340,25 @@ func writeStarOccultationLand(b *strings.Builder, layout starOccultationSVGLayou layout.mapFrame().WriteLand(b, "occultation-map-clip") } +// writeOccultationRiseSetCurves 绘制一组升落阶段线;prefix 区分外接触与内接触曲线。 +func writeOccultationRiseSetCurves( + b *strings.Builder, + curves []moon.OccultationRiseSetCurve, + layout starOccultationSVGLayout, + prefix string, +) { + curves = occultationgeo.DensifyRiseSetCurves(curves, 35) + for _, curve := range curves { + className := fmt.Sprintf("%s-rise-set-boundary %s-%s-%s", prefix, prefix, curve.Phase, curve.Direction) + for _, segment := range occultationgeo.StitchedRiseSetCurveSegments(curve) { + writeStarOccultationGeoLine( + b, segment, layout, + occultationLineStyle{className: className, color: "#d97706", strokeWidth: 1.35}, + ) + } + } +} + func writeStarOccultationBand(b *strings.Builder, path moon.StarOccultationPath, layout starOccultationSVGLayout) { writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout, "occultation-band-layer", "occultation-band", "#e0ae43", 0.28) @@ -248,51 +371,61 @@ func writeOccultationBand( layerClass, pathClass, color string, opacity float64, ) { - segments := starOccultationBandFragments(northern, southern, layout.projection) - if len(segments) == 0 { + segments := starOccultationBandFragments(northern, southern, layout.mapFrame().Clip()) + sections := starOccultationBandEndpointSections(northern, southern, layout.mapFrame().Clip()) + if len(segments) == 0 && len(sections) == 0 { return } fmt.Fprintf(b, ``, layerClass, color, opacity) for _, segment := range segments { fmt.Fprintf(b, ``) } + for _, section := range sections { + fmt.Fprintf(b, ``, + color, math.Min(1, opacity+0.35)) + } b.WriteString(``) } +// occultationLineStyle 描述掩带折线的画布样式。 +type occultationLineStyle struct { + className string + color string + strokeWidth float64 + dash string + // boundary 为 true 时按连续边界分段(掩带限线),否则按反经线分段。 + boundary bool +} + func writeStarOccultationGeoLine( b *strings.Builder, points []moon.OccultationPathPoint, layout starOccultationSVGLayout, - className, color string, - strokeWidth float64, - dash string, + style occultationLineStyle, ) { - for _, segment := range starOccultationPathSegmentsForProjection(points, layout.projection) { + segments := starOccultationPathSegmentsForProjection(points, layout.mapFrame().Clip()) + if style.boundary { + segments = starOccultationBoundarySegmentsForProjection(points, layout.mapFrame().Clip()) + } + for _, segment := range segments { if len(segment) < 2 { continue } - fmt.Fprintf(b, ``) } @@ -305,7 +438,8 @@ func writeStarOccultationVisibleCenterLine( ) { visible := make([]moon.OccultationPathPoint, 0, len(points)) flush := func() { - writeStarOccultationGeoLine(b, visible, layout, "visible-center-line", "#087f8c", 2.8, "") + writeStarOccultationGeoLine(b, visible, layout, + occultationLineStyle{className: "visible-center-line", color: "#087f8c", strokeWidth: 2.8}) visible = visible[:0] } for _, point := range points { @@ -321,18 +455,44 @@ func writeStarOccultationVisibleCenterLine( flush() } -func writeStarOccultationEventMarker( - b *strings.Builder, - point moon.OccultationPathPoint, +type starOccultationEventMarkerDraw struct { + x, y float64 + label, kind string + placement starOccultationEventMarkerPlacement +} + +func (draw starOccultationEventMarkerDraw) write(b *strings.Builder) { + writeStarOccultationProjectedEventMarker(b, draw.x, draw.y, draw.label, draw.kind, draw.placement) +} + +// starOccultationEventMarkerDraws 计算起点、掩甚、终点三个事件标记及其标签占位。 +func starOccultationEventMarkerDraws( + path moon.StarOccultationPath, layout starOccultationSVGLayout, - label, kind string, -) { - x, y, visible := layout.mapFrame().Project(point.Longitude, point.Latitude) - if !visible { - return + language string, + placer *occultationLabelPlacer, +) []starOccultationEventMarkerDraw { + markers := []struct { + point moon.OccultationPathPoint + label, kind string + }{ + {point: path.Start, label: starOccultationEventLabel("start", language), kind: "start"}, + {point: path.Greatest, label: starOccultationEventLabel("greatest", language), kind: "greatest"}, + {point: path.End, label: starOccultationEventLabel("end", language), kind: "end"}, } - placement := starOccultationDefaultEventMarkerPlacement(x, y, kind, layout) - writeStarOccultationProjectedEventMarker(b, x, y, label, kind, placement) + draws := make([]starOccultationEventMarkerDraw, 0, len(markers)) + for _, marker := range markers { + x, y, visible := layout.mapFrame().Project(marker.point.Longitude, marker.point.Latitude) + if !visible { + continue + } + draws = append(draws, starOccultationEventMarkerDraw{ + x: x, y: y, label: marker.label, kind: marker.kind, + placement: starOccultationDefaultEventMarkerPlacement(x, y, marker.kind, layout), + }) + } + reserveOccultationEventMarkerLabels(draws, layout, placer) + return draws } type starOccultationEventMarkerPlacement struct { @@ -341,6 +501,69 @@ type starOccultationEventMarkerPlacement struct { leader bool } +// occultationEventMarkerShifts 是事件标签在默认位置之外的候选位移,按优先级排列。 +var occultationEventMarkerShifts = [][2]float64{ + {0, 0}, {0, -14}, {0, 16}, {-12, 0}, {12, 0}, + {-12, -14}, {12, -14}, {-12, 16}, {12, 16}, + {0, -28}, {0, 30}, {-24, 0}, {24, 0}, {0, 44}, {0, -42}, +} + +// reserveOccultationEventMarkerLabels 把事件标签逐个登记到共用占位表。候选位置依次为默认位置、 +// 上下左右位移与左右镜像;全部被占用时退回框内居中的兜底位置,避免标签压到右侧事件面板上。 +func reserveOccultationEventMarkerLabels(draws []starOccultationEventMarkerDraw, layout starOccultationSVGLayout, placer *occultationLabelPlacer) { + for index := range draws { + draw := &draws[index] + base := draw.placement + found := false + for _, mirror := range []bool{false, true} { + for _, shift := range occultationEventMarkerShifts { + if mirror && base.anchor == "middle" { + continue + } + candidate := base + if mirror { + candidate.anchor = "start" + candidate.labelX = draw.x + 7 + shift[0] + if base.anchor == "start" { + candidate.anchor, candidate.labelX = "end", draw.x-7+shift[0] + } + } else { + candidate.labelX = base.labelX + shift[0] + } + candidate.labelY = math.Max(layout.mapY+12, math.Min(layout.mapY+layout.mapHeight-5, base.labelY+shift[1])) + if !occultationLabelInsideFrame(layout, candidate.labelX, candidate.labelY, candidate.anchor, draw.label, 11) { + continue + } + if placer.place(candidate.labelX, candidate.labelY, candidate.anchor, draw.label, 11, 2) { + draw.placement = candidate + found = true + break + } + } + if found { + break + } + } + if found { + continue + } + fallback := base + fallback.anchor = "middle" + width := occultationLabelTextWidth(draw.label, 11) + fallback.labelX = math.Max(layout.mapX+width/2+2, math.Min(layout.mapX+layout.mapWidth-width/2-2, draw.x)) + fallback.labelY = math.Max(layout.mapY+12, math.Min(layout.mapY+layout.mapHeight-5, base.labelY)) + draw.placement = fallback + placer.reserve(occultationLabelRectFor(fallback.labelX, fallback.labelY, fallback.anchor, draw.label, 11).expanded(2)) + } +} + +// occultationLabelInsideFrame 报告标签占位是否完全落在地图框内。 +func occultationLabelInsideFrame(layout starOccultationSVGLayout, x, y float64, anchor, text string, fontSize float64) bool { + rect := occultationLabelRectFor(x, y, anchor, text, fontSize) + return rect.left >= layout.mapX+2 && rect.right <= layout.mapX+layout.mapWidth-2 && + rect.top >= layout.mapY+2 && rect.bottom <= layout.mapY+layout.mapHeight-2 +} + func starOccultationDefaultEventMarkerPlacement( x, y float64, kind string, @@ -402,31 +625,66 @@ func writeStarOccultationProjectedEventMarker( placement.labelX, placement.labelY, placement.anchor, html.EscapeString(label)) } -func writeStarOccultationLegend(b *strings.Builder, layout starOccultationSVGLayout, language string) { +func writeStarOccultationLegend( + b *strings.Builder, + layout starOccultationSVGLayout, + language string, + band occultationBandDraw, + hasRiseSet bool, + hasIsochrones bool, +) { y := layout.mapY + layout.mapHeight + 30 labels := []string{"可见中心线", "几何中心线", "掩带边界"} if language == starOccultationSVGLanguageEnglish { labels = []string{"Visible center line", "Geometric center line", "Occultation limits"} } - available := layout.mapWidth / 3 - styles := []struct { - color string - dash string - }{ - {"#087f8c", ""}, - {"#59676b", "5 4"}, - {"#a66f18", ""}, - } - for index, label := range labels { - x := layout.mapX + float64(index)*available - fmt.Fprintf(b, ``) - fmt.Fprintf(b, `%s`, - x+28, y, html.EscapeString(label)) } + if hasRiseSet { + label := "初掩/掩甚/终掩月升月落线" + if language == starOccultationSVGLanguageEnglish { + label = "Rise/set phase lines" + } + labels = append(labels, label) + } + if hasIsochrones { + label := "掩甚时刻等时线" + if language == starOccultationSVGLanguageEnglish { + label = "Greatest-time isochrones" + } + labels = append(labels, label) + } + colors := []string{"#087f8c", "#59676b", "#a66f18"} + dashes := []string{"", "5 4", ""} + if hasRiseSet { + colors = append(colors, "#d97706") + dashes = append(dashes, "") + } + if hasIsochrones { + colors = append(colors, "#1f6fb2") + dashes = append(dashes, "") + } + items := make([]occultationLegendItem, 0, len(labels)) + for index, label := range labels { + item := occultationLegendItem{ + label: label, color: colors[index], dash: dashes[index], markerWidth: 22, + } + if band.drawn() && index == 2 { + item.fill, item.fillOpacity = "#e0ae43", 0.45 + } + items = append(items, item) + } + writeOccultationLegendItems(b, y, layout, items, 10) } func writeStarOccultationEventsPanel( @@ -446,8 +704,10 @@ func writeOccultationEventsPanel( ) { fmt.Fprintf(b, ``, layout.panelX-10, layout.panelY, layout.panelX-10, layout.panelY+layout.mapHeight) + contactsTitle := occultationTitleText(starOccultationSVGContactsTitle(options), options.ContactsTitle != "", + layout.width-layout.panelX-layout.margin, 14) fmt.Fprintf(b, `%s`, - layout.panelX, layout.panelY+13, html.EscapeString(starOccultationSVGContactsTitle(options))) + layout.panelX, layout.panelY+13, html.EscapeString(contactsTitle)) rowTop := layout.panelY + 27 rowHeight := math.Max(34, (layout.mapHeight-27)/float64(len(rows))) for index, row := range rows { @@ -478,8 +738,11 @@ func writeStarOccultationFooter( b *strings.Builder, layout starOccultationSVGLayout, options StarOccultationSVGOptions, + flags occultationSVGTextFlags, ) { - lines := starOccultationWrapText(starOccultationSVGFooter(options), layout.width-80, 11) + lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, layout.width-80, 11) + lines = svgchart.TruncateTextLines(lines, layout.width-80, 11, + svgchart.BaselineLineLimit(11, 15, layout.footerY, layout.height)) for index, line := range lines { fmt.Fprintf(b, `%s`, layout.footerY+float64(index)*15, html.EscapeString(line)) diff --git a/moon/svg/occultation_band_cache.go b/moon/svg/occultation_band_cache.go new file mode 100644 index 0000000..4471008 --- /dev/null +++ b/moon/svg/occultation_band_cache.go @@ -0,0 +1,120 @@ +package svg + +import ( + "math" + "sync" + + "b612.me/astro/internal/geodata" + "b612.me/astro/moon" +) + +// occultationBandPolygons 是一次紧凑掩带合并的只读结果。 +type occultationBandPolygons struct { + polygons [][]geodata.GeoPoint + authoritative bool +} + +type occultationBandCacheKey struct { + kind occultationFootprintSweepKind + digest [2]uint64 +} + +// occultationBandCacheCapacity 限制常驻的合并结果数量;掩带多边形可能上万点。 +const occultationBandCacheCapacity = 6 + +var occultationBandCache = struct { + sync.RWMutex + entries map[occultationBandCacheKey]occultationBandPolygons + order []occultationBandCacheKey +}{entries: make(map[occultationBandCacheKey]occultationBandPolygons, occultationBandCacheCapacity)} + +func lookupOccultationBandPolygons(key occultationBandCacheKey) (occultationBandPolygons, bool) { + occultationBandCache.RLock() + value, ok := occultationBandCache.entries[key] + occultationBandCache.RUnlock() + return value, ok +} + +func storeOccultationBandPolygons(key occultationBandCacheKey, value occultationBandPolygons) { + occultationBandCache.Lock() + defer occultationBandCache.Unlock() + if _, exists := occultationBandCache.entries[key]; exists { + return + } + if len(occultationBandCache.order) >= occultationBandCacheCapacity { + delete(occultationBandCache.entries, occultationBandCache.order[0]) + occultationBandCache.order = occultationBandCache.order[1:] + } + occultationBandCache.entries[key] = value + occultationBandCache.order = append(occultationBandCache.order, key) +} + +// occultationBandDigest 汇总合并的全部输入;按 64 位字混合而不是逐字节,摘要成本必须远低于合并本身。 +func occultationBandDigest(s occultationFootprintSweep) [2]uint64 { + const ( + seed1 = 0x9e3779b97f4a7c15 + seed2 = 0xc2b2ae3d27d4eb4f + prime = 0x100000001b3 + ) + first, second := uint64(seed1), uint64(seed2) + write := func(value uint64) { + first = (first ^ value) * prime + first ^= first >> 31 + second = (second + value) * prime + second ^= second >> 33 + } + writeFloat := func(value float64) { write(math.Float64bits(value)) } + writeTime := func(value int64) { write(uint64(value)) } + writePoint := func(point moon.OccultationPathPoint) { + writeTime(point.Time.UnixNano()) + writeFloat(point.Longitude) + writeFloat(point.Latitude) + writeFloat(point.MoonAltitude) + writeFloat(point.WidthKM) + writeFloat(point.LimitSeparationKM) + } + writePolygons := func(polygons [][]moon.OccultationPathPoint) { + write(uint64(len(polygons))) + for _, polygon := range polygons { + write(uint64(len(polygon))) + for _, point := range polygon { + writePoint(point) + } + } + } + writeString := func(value string) { + write(uint64(len(value))) + for index := 0; index < len(value); index++ { + write(uint64(value[index])) + } + } + writeFootprints := func(footprints []moon.PlanetOccultationFootprint) { + write(uint64(len(footprints))) + for _, footprint := range footprints { + writeTime(footprint.Time.UnixNano()) + writePolygons(footprint.Polygons) + writePolygons(footprint.InteriorPolygons) + writePolygons(footprint.Boundaries) + if footprint.Closed { + write(1) + } else { + write(0) + } + } + } + writeCurves := func(curves []moon.OccultationRiseSetCurve) { + write(uint64(len(curves))) + for _, curve := range curves { + writeString(string(curve.Phase)) + writeString(string(curve.Direction)) + writePolygons(curve.Segments) + } + } + write(uint64(s.kind)) + writeFootprints(s.footprints) + writePolygons(s.contours) + writePolygons(s.visibilityContours) + writePolygons([][]moon.OccultationPathPoint{s.northern, s.southern}) + writeCurves(s.curves) + return [2]uint64{first, second} +} diff --git a/moon/svg/occultation_band_cache_test.go b/moon/svg/occultation_band_cache_test.go new file mode 100644 index 0000000..5fb936c --- /dev/null +++ b/moon/svg/occultation_band_cache_test.go @@ -0,0 +1,90 @@ +package svg + +import ( + "testing" + "time" + + "b612.me/astro/moon" +) + +// 本文件钉住紧凑掩带合并缓存的契约:同输入复用只读结果、不同几何不会串味、产物逐字节确定。 + +func occultationTestCompactSweep(t *testing.T) occultationFootprintSweep { + t.Helper() + path := occultationTestSaturnPath(t) + if len(path.PartialBandFootprints) == 0 { + t.Fatal("fixture carries no compact band footprints") + } + return occultationFootprintSweep{ + footprints: path.PartialBandFootprints, + contours: path.PartialBandContours, + visibilityContours: path.PartialVisibilityContours, + northern: path.NorthernLimit, + southern: path.SouthernLimit, + curves: path.RiseSetCurves, + compactBand: true, + kind: partialOccultationFootprintSweep, + } +} + +func TestOccultationBandCacheReusesIdenticalGeometry(t *testing.T) { + sweep := occultationTestCompactSweep(t) + first, firstAuthoritative, err := sweep.mergedBandPolygons() + if err != nil { + t.Fatalf("first mergedBandPolygons() error = %v", err) + } + if len(first) == 0 { + t.Fatal("compact band merge produced no polygon") + } + second, secondAuthoritative, err := sweep.mergedBandPolygons() + if err != nil { + t.Fatalf("second mergedBandPolygons() error = %v", err) + } + if len(second) != len(first) || &second[0][0] != &first[0][0] { + t.Fatal("identical band geometry was merged again instead of reusing the cached polygons") + } + if secondAuthoritative != firstAuthoritative { + t.Fatalf("cached authoritative flag = %v, want %v", secondAuthoritative, firstAuthoritative) + } +} + +func TestOccultationBandCacheSeparatesDifferentGeometry(t *testing.T) { + sweep := occultationTestCompactSweep(t) + first, _, err := sweep.mergedBandPolygons() + if err != nil { + t.Fatalf("first mergedBandPolygons() error = %v", err) + } + mutated := sweep + mutated.footprints = append([]moon.PlanetOccultationFootprint(nil), sweep.footprints...) + last := len(mutated.footprints) - 1 + mutated.footprints[last].Polygons = append([][]moon.OccultationPathPoint(nil), mutated.footprints[last].Polygons...) + polygon := append([]moon.OccultationPathPoint(nil), mutated.footprints[last].Polygons[0]...) + polygon[0].Longitude += 1.5 + mutated.footprints[last].Polygons[0] = polygon + second, _, err := mutated.mergedBandPolygons() + if err != nil { + t.Fatalf("mutated mergedBandPolygons() error = %v", err) + } + if len(second) > 0 && len(first) > 0 && &second[0][0] == &first[0][0] { + t.Fatal("a shifted footprint reused the cached band polygons") + } +} + +func TestCompactBandRenderIsDeterministic(t *testing.T) { + path := occultationTestSaturnPath(t) + options := StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC} + first, err := PlanetOccultationPathSVG(path, options) + if err != nil { + t.Fatalf("first render: %v", err) + } + second, err := PlanetOccultationPathSVG(path, options) + if err != nil { + t.Fatalf("second render: %v", err) + } + if first != second { + t.Fatal("compact-band SVG changed between renders after the merge was cached") + } + if len(first) == 0 { + t.Fatal("compact-band render is empty") + } +} diff --git a/moon/svg/occultation_band_width_test.go b/moon/svg/occultation_band_width_test.go new file mode 100644 index 0000000..1b37cf8 --- /dev/null +++ b/moon/svg/occultation_band_width_test.go @@ -0,0 +1,77 @@ +package svg + +import ( + "fmt" + "math" + "strings" + "testing" + "time" +) + +// 本文件钉住星掩图的"掩带宽"口径与行星图、详细版一致:取南北限地面间距, +// 不得与 Greatest.WidthKM 互相换算。 + +func TestStarOccultationSVGUsesLimitSeparationBandWidth(t *testing.T) { + path := sampleStarOccultationPath() + path.GreatestLimitSeparationKM = 3123.4 + path.Greatest.WidthKM = 3456.7 + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC}) + if err != nil { + t.Fatalf("StarOccultationPathSVG() error = %v", err) + } + if !strings.Contains(diagram, "3123.4 km") { + t.Fatal("stellar SVG band width does not use GreatestLimitSeparationKM") + } + if strings.Contains(diagram, "3456.7 km") { + t.Fatal("stellar SVG band width still uses Greatest.WidthKM") + } +} + +func TestStarOccultationSVGBandWidthFallsBackToGreatestWidth(t *testing.T) { + path := sampleStarOccultationPath() + path.GreatestLimitSeparationKM = 0 + path.Greatest.WidthKM = 3456.7 + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC}) + if err != nil { + t.Fatalf("StarOccultationPathSVG() error = %v", err) + } + if !strings.Contains(diagram, "3456.7 km") { + t.Fatal("stellar SVG band width lost the Greatest.WidthKM fallback for paths without limit separations") + } +} + +func TestOccultationBandWidthSourcesAreNotInterchangeable(t *testing.T) { + starPath := occultationTestStarPath(t) + if starPath.GreatestLimitSeparationKM <= 0 || starPath.Greatest.WidthKM <= 0 { + t.Fatalf("fixture widths = %.3f / %.3f, want both populated", + starPath.GreatestLimitSeparationKM, starPath.Greatest.WidthKM) + } + if math.Abs(starPath.GreatestLimitSeparationKM-starPath.Greatest.WidthKM) < 1 { + t.Fatalf("fixture widths %.3f / %.3f are equal; the semantics check would be vacuous", + starPath.GreatestLimitSeparationKM, starPath.Greatest.WidthKM) + } + diagram, err := StarOccultationPathSVG(starPath, StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC}) + if err != nil { + t.Fatalf("StarOccultationPathSVG() error = %v", err) + } + expected := occultationTestWidthText(starPath.GreatestLimitSeparationKM) + if !strings.Contains(diagram, expected) { + t.Fatalf("stellar SVG is missing the limit-separation width %q", expected) + } + if other := occultationTestWidthText(starPath.Greatest.WidthKM); strings.Contains(diagram, other) { + t.Fatalf("stellar SVG reports the non-interchangeable Greatest.WidthKM %q", other) + } + + planetPath := occultationTestSaturnPath(t) + planetDiagram, err := PlanetOccultationPathSVG(planetPath, StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC}) + if err != nil { + t.Fatalf("PlanetOccultationPathSVG() error = %v", err) + } + if expected := occultationTestWidthText(planetPath.GreatestLimitSeparationKM); !strings.Contains(planetDiagram, expected) { + t.Fatalf("planetary SVG is missing the limit-separation width %q", expected) + } +} + +func occultationTestWidthText(width float64) string { + return fmt.Sprintf("%.1f km", width) +} diff --git a/moon/svg/occultation_canvas_test.go b/moon/svg/occultation_canvas_test.go new file mode 100644 index 0000000..8e338ed --- /dev/null +++ b/moon/svg/occultation_canvas_test.go @@ -0,0 +1,117 @@ +package svg + +import ( + "errors" + "strconv" + "testing" + "time" +) + +// 本文件钉住全球掩带图的画布下限与"所有文本都在画布内":480x360、600x400 曾因图例压页脚、经纬刻度出框而被放行。 + +func TestOccultationMapRejectsCanvasBelowMinimum(t *testing.T) { + tests := []struct { + name string + width, height int + }{ + {name: "reference too small", width: 480, height: 360}, + {name: "legend footer collision", width: 600, height: 400}, + {name: "width just below", width: 639, height: 480}, + {name: "height just below", width: 640, height: 479}, + } + starPath := occultationTestStarPath(t) + planetPath := occultationTestSaturnPath(t) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + options := StarOccultationSVGOptions{Width: test.width, Height: test.height} + if _, err := StarOccultationPathSVG(starPath, options); !errors.Is(err, ErrInvalidStarOccultationSVGOptions) { + t.Fatalf("stellar %dx%d error = %v, want ErrInvalidStarOccultationSVGOptions", test.width, test.height, err) + } + if _, err := PlanetOccultationPathSVG(planetPath, options); !errors.Is(err, ErrInvalidPlanetOccultationSVGOptions) { + t.Fatalf("planetary %dx%d error = %v, want ErrInvalidPlanetOccultationSVGOptions", test.width, test.height, err) + } + }) + } +} + +func TestOccultationMapKeepsEveryTextInsideCanvas(t *testing.T) { + sizes := [][2]int{{640, 480}, {800, 600}, {1200, 800}, {1000, 1414}} + projections := []MapProjection{MapProjectionAuto, MapProjectionNorthPolar, MapProjectionOrthographic} + starPath := occultationTestStarPath(t) + planetPath := occultationTestSaturnPath(t) + for _, size := range sizes { + for _, projection := range projections { + name := strconv.Itoa(size[0]) + "x" + strconv.Itoa(size[1]) + "-" + string(projection) + t.Run(name, func(t *testing.T) { + options := StarOccultationSVGOptions{ + Width: size[0], Height: size[1], Location: time.UTC, Projection: projection, + } + diagrams := map[string]string{} + star, err := StarOccultationPathSVG(starPath, options) + if err != nil { + t.Fatalf("stellar render: %v", err) + } + diagrams["stellar"] = star + planet, err := PlanetOccultationPathSVG(planetPath, options) + if err != nil { + t.Fatalf("planetary render: %v", err) + } + diagrams["planetary"] = planet + for label, diagram := range diagrams { + texts := occultationSVGTexts(t, diagram) + if len(texts) == 0 { + t.Fatalf("%s SVG has no text", label) + } + if outside := occultationSVGTextsOutsideCanvas(texts, float64(size[0]), float64(size[1])); len(outside) > 0 { + t.Fatalf("%s SVG has %d texts outside %s: %v", label, len(outside), name, outside) + } + } + }) + } + } +} + +func TestOccultationMapLegendDoesNotOverlapFooter(t *testing.T) { + sizes := [][2]int{{640, 480}, {800, 600}, {1200, 800}} + starPath := occultationTestStarPath(t) + planetPath := occultationTestSaturnPath(t) + for _, size := range sizes { + t.Run(strconv.Itoa(size[0])+"x"+strconv.Itoa(size[1]), func(t *testing.T) { + options := StarOccultationSVGOptions{Width: size[0], Height: size[1], Location: time.UTC} + for _, item := range []struct { + label string + path func() (string, error) + }{ + {label: "stellar", path: func() (string, error) { return StarOccultationPathSVG(starPath, options) }}, + {label: "planetary", path: func() (string, error) { return PlanetOccultationPathSVG(planetPath, options) }}, + } { + diagram, err := item.path() + if err != nil { + t.Fatalf("%s render: %v", item.label, err) + } + texts := occultationSVGTexts(t, diagram) + legend := []occultationSVGText{} + footer := []occultationSVGText{} + for _, text := range texts { + if text.class == "occultation-legend-text" { + legend = append(legend, text) + } + if text.parent == "svg" && text.class == "" && text.fontSize == 11 && text.x == 40 { + footer = append(footer, text) + } + } + if len(legend) < 3 || len(footer) == 0 { + t.Fatalf("%s SVG legend=%d footer=%d, want both present", item.label, len(legend), len(footer)) + } + for _, legendText := range legend { + for _, footerText := range footer { + if occultationSVGTextBox(legendText)[3] > occultationSVGTextBox(footerText)[1] { + t.Fatalf("%s legend %q overlaps footer %q at %dx%d", + item.label, legendText.value, footerText.value, size[0], size[1]) + } + } + } + } + }) + } +} diff --git a/moon/svg/occultation_detailed.go b/moon/svg/occultation_detailed.go new file mode 100644 index 0000000..814ee50 --- /dev/null +++ b/moon/svg/occultation_detailed.go @@ -0,0 +1,590 @@ +package svg + +import ( + "errors" + "fmt" + "html" + "math" + "strings" + "time" + + "b612.me/astro/basic" + "b612.me/astro/internal/svgchart" + "b612.me/astro/internal/svgmap" + "b612.me/astro/moon" +) + +// ErrInvalidOccultationDetailedSVGOptions 表示详细版式画布装不下地图、数据块与页脚。 +// ErrInvalidOccultationDetailedSVGOptions reports a detailed-layout canvas that cannot hold the map, data blocks, and footer. +var ErrInvalidOccultationDetailedSVGOptions = errors.New("invalid occultation detailed SVG options") + +const ( + occultationDetailedMinimumWidth = 480 + occultationDetailedMinimumHeight = 320 + // occultationDetailedMinimumMap 与 occultationDetailedMinimumCell 是球面与数据格仍可辨认的最小尺寸。 + occultationDetailedMinimumMap = 120 + occultationDetailedMinimumCell = 120 +) + +// OccultationDetailedSVGOptions 控制详细版式的月掩星组合图。 +type OccultationDetailedSVGOptions struct { + // Width 与 Height 是画布尺寸;<=0 使用 1000x1414 的整页开本。 + // 版式必须容得下地图与数据块,否则返回 ErrInvalidOccultationDetailedSVGOptions。 + Width int + Height int + // Language 为 "en" 时使用英文,其他值使用中文。 + Language string + // Location 控制显示时刻的时区;nil 使用 UTC+8。 + Location *time.Location + // TimeLabelStep 控制中心线上的时刻标签;零值用 30 分钟,负值禁用。 + TimeLabelStep time.Duration + // Title 与 FooterNote 为空时自动生成。 + Title string + FooterNote string +} + +// occultationDetailedView 汇总详细版式各区块的位置。 +type occultationDetailedView struct { + width, height float64 + margin float64 + mapX, mapY float64 + mapSize float64 + gridX, gridY float64 + cellWidth float64 + cellHeight float64 + gapX, gapY float64 + footerY float64 + landscape bool +} + +func normalizeOccultationDetailedSVGOptions(options OccultationDetailedSVGOptions) OccultationDetailedSVGOptions { + if options.Width <= 0 { + options.Width = 1000 + } + if options.Height <= 0 { + options.Height = 1414 + } + if options.Location == nil { + options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone) + } + if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) { + options.Language = starOccultationSVGLanguageEnglish + } else { + options.Language = starOccultationSVGLanguageChinese + } + if options.TimeLabelStep == 0 { + options.TimeLabelStep = 30 * time.Minute + } + return options +} + +func validateOccultationDetailedSVGOptions(options OccultationDetailedSVGOptions) error { + if options.Width > 0 && options.Width < occultationDetailedMinimumWidth { + return fmt.Errorf("%w: width must be zero or at least %d", + ErrInvalidOccultationDetailedSVGOptions, occultationDetailedMinimumWidth) + } + if options.Height > 0 && options.Height < occultationDetailedMinimumHeight { + return fmt.Errorf("%w: height must be zero or at least %d", + ErrInvalidOccultationDetailedSVGOptions, occultationDetailedMinimumHeight) + } + return nil +} + +func occultationDetailedMargin(width float64) float64 { + return math.Max(30, math.Min(48, width*0.044)) +} + +func occultationDetailedViewFor(options OccultationDetailedSVGOptions, summaryLines int) (occultationDetailedView, error) { + width, height := float64(options.Width), float64(options.Height) + margin := occultationDetailedMargin(width) + // 摘要折行数随画布宽度变化,标题区必须跟着长,否则窄画布上摘压到地图或数据块。 + headerBottom := math.Max(150, math.Max(74+float64(summaryLines)*20+16, height*0.115)) + footerHeight := math.Max(56, height*0.05) + legendHeight := math.Max(42, height*0.04) + gap := math.Max(14, height*0.014) + view := occultationDetailedView{ + width: width, height: height, margin: margin, + gapX: math.Max(14, width*0.014), gapY: math.Max(12, height*0.013), + } + legendY := height - footerHeight - legendHeight + view.footerY = height - 42 + blockHeight := math.Max(74, height*0.055) + panelHeight := math.Max(96, height*0.10) + + if width >= height { + // 横版:地图在左,数据块两栏三行在右。 + view.landscape = true + gridWidth := 2*230 + view.gapX + view.gridX = width - margin - gridWidth + view.cellWidth = 230 + bandTop := headerBottom + gap + bandBottom := legendY - gap + view.gridY = bandTop + view.cellHeight = (bandBottom - bandTop - 2*view.gapY) / 3 + view.mapX = margin + view.mapY = bandTop + view.mapSize = math.Min(view.gridX-gap-margin, bandBottom-bandTop) + if view.mapSize < occultationDetailedMinimumMap || view.cellHeight <= 0 || view.cellWidth <= 0 { + return view, detailedCanvasError(options) + } + return view, nil + } + // 竖版:日月块在地图上方两侧,地图居中,两行三栏面板在地图下方。 + view.mapY = headerBottom + blockHeight + gap + // 球面下方先留图例的位置,再排两行面板;地图边长取剩余高度,不足则拒绝而不是按宽度放大。 + const legendSpace = 56.0 + view.mapSize = math.Min(width-2*margin, + height-view.mapY-legendSpace-2*panelHeight-3*gap-legendHeight-footerHeight) + view.cellWidth = (width - 2*margin - 2*view.gapX) / 3 + if view.mapSize < occultationDetailedMinimumMap || view.cellWidth < occultationDetailedMinimumCell { + return view, detailedCanvasError(options) + } + view.mapX = (width - view.mapSize) / 2 + panelY := view.mapY + view.mapSize + legendSpace + view.gridX = margin + view.gridY = panelY + view.cellHeight = panelHeight + return view, nil +} + +func detailedCanvasError(options OccultationDetailedSVGOptions) error { + return fmt.Errorf("%w: canvas %dx%d cannot hold the map, data blocks, and footer", + ErrInvalidOccultationDetailedSVGOptions, options.Width, options.Height) +} + +func (view occultationDetailedView) mapFrame(projection svgmap.Projection, center svgmap.GeoPoint) svgmap.Frame { + return svgmap.Frame{ + X: view.mapX, Y: view.mapY, Width: view.mapSize, Height: view.mapSize, + Projection: projection, CenterLongitude: center.Longitude, CenterLatitude: center.Latitude, + } +} + +// validateOccultationDetailedBlocks 逐块检查等分行高下文字是否压叠;太矮的画布必须拒绝而不是画出压叠的面板。 +func validateOccultationDetailedBlocks(view occultationDetailedView, blocks []svgchart.PanelBlock) error { + columns := 3 + if view.landscape { + columns = 2 + } + for index, block := range blocks { + if len(block.Rows) == 0 { + continue + } + column, row := index%columns, index/columns + x := view.gridX + float64(column)*(view.cellWidth+view.gapX) + y := view.gridY + float64(row)*(view.cellHeight+view.gapY) + if svgchart.PanelBoxOverlaps(x, y, view.cellWidth, view.cellHeight, block.Title, block.Rows) { + return fmt.Errorf("%w: canvas %gx%g cannot keep data block %q rows apart", + ErrInvalidOccultationDetailedSVGOptions, view.width, view.height, block.Title) + } + } + return nil +} + +// writeOccultationDetailedGrid 按两栏三行(横版)或三栏两行(竖版)画数据块。 +func writeOccultationDetailedGrid(b *strings.Builder, view occultationDetailedView, blocks []svgchart.PanelBlock) { + columns := 3 + if view.landscape { + columns = 2 + } + for index, block := range blocks { + if len(block.Rows) == 0 { + continue + } + column := index % columns + row := index / columns + x := view.gridX + float64(column)*(view.cellWidth+view.gapX) + y := view.gridY + float64(row)*(view.cellHeight+view.gapY) + svgchart.WritePanelBox(b, "occultation-detailed-panel", x, y, view.cellWidth, view.cellHeight, block.Title, block.Rows) + } +} + +// occultationDetailedMoonRows 月亮的地心坐标;S.D./H.P. 由地心月距换算,与日食月食同口径。 +func occultationDetailedMoonRows(at time.Time, language string) []svgchart.PanelRow { + jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true) + ra, dec := basic.HMoonTrueRaDec(jde) + sd := basic.MoonSemidiameter(jde) + hp := math.Asin(math.Sin(sd/3600*math.Pi/180)*6378.137/1737.4) * 180 / math.Pi * 3600 + rows := []svgchart.PanelRow{ + {Label: "赤经 R.A.", Value: occultationDetailedRA(ra)}, + {Label: "赤纬 Dec.", Value: occultationDetailedDec(dec)}, + {Label: "视半径 S.D.", Value: occultationDetailedArcsec(sd)}, + {Label: "地平视差 H.P.", Value: occultationDetailedArcsec(hp)}, + } + if language == starOccultationSVGLanguageEnglish { + rows[0].Label, rows[1].Label = "R.A.", "Dec." + rows[2].Label, rows[3].Label = "S.D.", "H.P." + } + return rows +} + +func occultationDetailedRA(degrees float64) string { + total := math.Mod(degrees, 360) / 15 + hours := math.Floor(total) + minutes := math.Floor((total - hours) * 60) + return fmt.Sprintf("%02.0fh%02.0fm%04.1fs", hours, minutes, ((total-hours)*60-minutes)*60) +} + +func occultationDetailedDec(degrees float64) string { + sign := "+" + if degrees < 0 { + sign, degrees = "-", -degrees + } + whole := math.Floor(degrees) + minutes := math.Floor((degrees - whole) * 60) + return fmt.Sprintf("%s%02.0f°%02.0f'%04.1f\"", sign, whole, minutes, ((degrees-whole)*60-minutes)*60) +} + +func occultationDetailedArcsec(arcsec float64) string { + whole := math.Floor(arcsec / 3600) + minutes := math.Floor((arcsec/3600 - whole) * 60) + return fmt.Sprintf("%02.0f°%02.0f'%04.1f\"", whole, minutes, ((arcsec/3600-whole)*60-minutes)*60) +} + +// StarOccultationDetailedSVG 生成详细版式的恒星月掩星图。 +// StarOccultationDetailedSVG renders a detailed stellar-occultation diagram. +func StarOccultationDetailedSVG( + path moon.StarOccultationPath, + star moon.StarCoordinate, + options OccultationDetailedSVGOptions, +) (string, error) { + if err := validateStarOccultationPath(path); err != nil { + return "", err + } + return renderOccultationDetailedSVG(path, star, nil, options) +} + +// PlanetOccultationDetailedSVG 生成详细版式的行星月掩星图。 +// PlanetOccultationDetailedSVG renders a detailed planetary-occultation diagram. +func PlanetOccultationDetailedSVG( + path moon.PlanetOccultationPath, + options OccultationDetailedSVGOptions, +) (string, error) { + if err := validatePlanetOccultationPath(path); err != nil { + return "", err + } + // 中文标题与目标块要写"木星"这类中文行星名,不能直接用 TargetID 里的英文。 + shape := planetOccultationStarShape(path, path.TargetID) + if options.Language == starOccultationSVGLanguageChinese { + shape.TargetID = planetOccultationChineseName(path.Planet) + } + return renderOccultationDetailedSVG(shape, moon.StarCoordinate{}, &path, options) +} + +func renderOccultationDetailedSVG( + path moon.StarOccultationPath, + star moon.StarCoordinate, + planetPath *moon.PlanetOccultationPath, + options OccultationDetailedSVGOptions, +) (string, error) { + if err := validateOccultationDetailedSVGOptions(options); err != nil { + return "", err + } + options = normalizeOccultationDetailedSVGOptions(options) + summaryLines := occultationDetailedSummaryLines(path, planetPath, options) + view, err := occultationDetailedViewFor(options, len(summaryLines)) + if err != nil { + return "", err + } + center := svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude} + layout := starOccultationSVGLayout{ + width: view.width, height: view.height, margin: view.margin, + mapX: view.mapX, mapY: view.mapY, mapWidth: view.mapSize, mapHeight: view.mapSize, + panelX: view.gridX, panelY: view.gridY, panelWidth: view.cellWidth, + footerY: view.footerY, projection: svgmap.ProjectionOrthographic, center: center, + } + svgOptions := StarOccultationSVGOptions{ + Width: options.Width, Height: options.Height, Language: options.Language, + Location: options.Location, TimeLabelStep: options.TimeLabelStep, + Projection: MapProjectionOrthographic, Title: options.Title, + } + title := starOccultationSVGTitle(path, svgOptions) + + var b strings.Builder + fmt.Fprintf(&b, ``, + options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) + b.WriteString(``) + b.WriteString(layout.mapFrame().ClipDefinition("occultation-map-clip")) + b.WriteString(``) + b.WriteString(``) + fmt.Fprintf(&b, ``, + view.width-44, view.height-36) + titleText := title + if options.Title != "" { + titleText = svgchart.EllipsizeText(title, view.width-80, 25) + } + fmt.Fprintf(&b, `%s`, + view.width/2, html.EscapeString(titleText)) + writeOccultationDetailedSummary(&b, view, summaryLines) + + // 几何引擎失败不是数据格式错误,不能裹成 ErrInvalid…Path 哨兵。 + if planetPath == nil { + if err := writeStarOccultationMap(&b, path, layout, svgOptions); err != nil { + return "", fmt.Errorf("stellar occultation detailed band geometry: %w", err) + } + } else { + if err := writePlanetOccultationMap(&b, *planetPath, path, layout, svgOptions); err != nil { + return "", fmt.Errorf("planetary occultation detailed band geometry: %w", err) + } + } + blocks := occultationDetailedBlocks(path, star, planetPath, options) + if err := validateOccultationDetailedBlocks(view, blocks); err != nil { + return "", err + } + writeOccultationDetailedGrid(&b, view, blocks) + writeOccultationDetailedFooter(&b, view, options) + b.WriteString(``) + return b.String(), nil +} + +// occultationDetailedSummaryLines 汇总标题下的摘要行,并按画布可用宽度折行。 +func occultationDetailedSummaryLines( + path moon.StarOccultationPath, + planetPath *moon.PlanetOccultationPath, + options OccultationDetailedSVGOptions, +) []string { + available := float64(options.Width) - 2*occultationDetailedMargin(float64(options.Width)) + lines := []string{} + for _, line := range occultationDetailedSummaryText(path, planetPath, options) { + lines = append(lines, starOccultationWrapText(line, available, 12.5)...) + } + return lines +} + +func occultationDetailedSummaryText( + path moon.StarOccultationPath, + planetPath *moon.PlanetOccultationPath, + options OccultationDetailedSVGOptions, +) []string { + zone := starOccultationLocationLabel(path.Greatest.Time.In(options.Location), options.Location) + english := options.Language == starOccultationSVGLanguageEnglish + lines := []string{} + format := func(value time.Time) string { + return value.In(options.Location).Format("15:04:05") + } + name := func(zh, en string) string { + if english { + return en + } + return zh + } + if planetPath != nil && planetPath.HasTotalBand { + if english { + lines = append(lines, fmt.Sprintf("Partial begins %s | Total begins %s | Greatest %s | Total ends %s | Partial ends %s (%s)", + format(path.Start.Time), format(planetPath.TotalStart.Time), format(path.Greatest.Time), + format(planetPath.TotalEnd.Time), format(path.End.Time), zone)) + } else { + lines = append(lines, fmt.Sprintf("外掩始 %s | 全掩始 %s | 掩甚 %s | 全掩终 %s | 外掩终 %s(%s)", + format(path.Start.Time), format(planetPath.TotalStart.Time), format(path.Greatest.Time), + format(planetPath.TotalEnd.Time), format(path.End.Time), zone)) + } + } else if english { + lines = append(lines, fmt.Sprintf("Occultation begins %s | Greatest %s | Occultation ends %s (%s)", + format(path.Start.Time), format(path.Greatest.Time), format(path.End.Time), zone)) + } else { + lines = append(lines, fmt.Sprintf("掩始 %s | 掩甚 %s | 掩终 %s(%s)", + format(path.Start.Time), format(path.Greatest.Time), format(path.End.Time), zone)) + } + lines = append(lines, fmt.Sprintf("%s %s | %s", name("掩甚点", "Greatest point"), + starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude), + name("月球高度", "Moon altitude")+" "+starOccultationFormatSignedDegree(path.Greatest.MoonAltitude))) + return lines +} + +func writeOccultationDetailedSummary( + b *strings.Builder, + view occultationDetailedView, + lines []string, +) { + for index, line := range lines { + fmt.Fprintf(b, `%s`, + view.width/2, 74+float64(index)*20, html.EscapeString(line)) + } +} + +func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions) { + text := options.FooterNote + if text == "" { + if options.Language == starOccultationSVGLanguageEnglish { + text = "Orthographic globe centred on the greatest occultation; Natural Earth 1:50m physical land, no administrative boundaries." + } else { + text = "正射球面投影,视点取掩甚点;Natural Earth 1:50m 物理陆地底图,不含行政边界。" + } + } + maxWidth := view.width - 2*view.margin + // 默认说明本来就是单行,只有调用方文本才折行截断。 + lines := []string{text} + if options.FooterNote != "" { + lines = svgchart.TruncateTextLines(svgchart.WrapText(text, maxWidth, 11), maxWidth, 11, + svgchart.BaselineLineLimit(11, 15, view.footerY, view.height)) + } + for index, line := range lines { + fmt.Fprintf(b, `%s`, + view.margin, view.footerY+float64(index)*15, html.EscapeString(line)) + } +} + +// occultationDetailedTargetRows 目标天体的坐标与视半径;恒星没有视圆面,相关行留空。 +func occultationDetailedTargetRows( + star moon.StarCoordinate, + planetPath *moon.PlanetOccultationPath, + at time.Time, + language string, +) []svgchart.PanelRow { + english := language == starOccultationSVGLanguageEnglish + jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true) + rows := []svgchart.PanelRow{{Label: "目标", Value: star.ID}} + if english { + rows[0].Label = "Target" + } + if planetPath != nil { + rows[0].Value = planetPath.TargetID + if language == starOccultationSVGLanguageChinese { + rows[0].Value = planetOccultationChineseName(planetPath.Planet) + } + ra, dec := occultationDetailedPlanetRaDec(planetPath.Planet, jde) + if math.IsNaN(ra) { + rows = append(rows, svgchart.PanelRow{Label: "赤经 R.A.", Value: "—"}, + svgchart.PanelRow{Label: "赤纬 Dec.", Value: "—"}) + } else { + rows = append(rows, svgchart.PanelRow{Label: "赤经 R.A.", Value: occultationDetailedRA(ra)}, + svgchart.PanelRow{Label: "赤纬 Dec.", Value: occultationDetailedDec(dec)}) + } + rows = append(rows, svgchart.PanelRow{Label: "视半径 S.D.", + Value: occultationDetailedArcsec(occultationDetailedPlanetSemidiameter(planetPath.Planet, jde))}) + if english { + rows[1].Label, rows[2].Label, rows[3].Label = "R.A.", "Dec.", "S.D." + } + return rows + } + rows = append(rows, + svgchart.PanelRow{Label: "赤经 R.A.", Value: occultationDetailedRA(star.RA)}, + svgchart.PanelRow{Label: "赤纬 Dec.", Value: occultationDetailedDec(star.Dec)}, + svgchart.PanelRow{Label: "视半径 S.D.", Value: "—"}) + if english { + rows[1].Label, rows[2].Label, rows[3].Label = "R.A.", "Dec.", "S.D." + } + return rows +} + +// occultationDetailedPlanetRaDec 空值表示该行星没有可用的赤经入口。 +func occultationDetailedPlanetRaDec(planet moon.OccultationPlanet, jde float64) (float64, float64) { + switch planet { + case moon.OccultationMercury: + return basic.MercuryApparentRaDec(jde) + case moon.OccultationVenus: + return basic.VenusApparentRaDec(jde) + case moon.OccultationMars: + return basic.MarsApparentRaDec(jde) + case moon.OccultationJupiter: + return basic.JupiterApparentRaDec(jde) + case moon.OccultationSaturn: + return basic.SaturnApparentRaDec(jde) + case moon.OccultationUranus: + return basic.UranusApparentRaDec(jde) + case moon.OccultationNeptune: + return basic.NeptuneApparentRaDec(jde) + } + return math.NaN(), math.NaN() +} + +func occultationDetailedPlanetSemidiameter(planet moon.OccultationPlanet, jde float64) float64 { + switch planet { + case moon.OccultationMercury: + return basic.MercurySemidiameter(jde) + case moon.OccultationVenus: + return basic.VenusSemidiameter(jde) + case moon.OccultationMars: + return basic.MarsSemidiameter(jde) + case moon.OccultationJupiter: + return basic.JupiterSemidiameter(jde) + case moon.OccultationSaturn: + return basic.SaturnSemidiameter(jde) + case moon.OccultationUranus: + return basic.UranusSemidiameter(jde) + case moon.OccultationNeptune: + return basic.NeptuneSemidiameter(jde) + } + return math.NaN() +} + +func occultationDetailedBlocks( + path moon.StarOccultationPath, + star moon.StarCoordinate, + planetPath *moon.PlanetOccultationPath, + options OccultationDetailedSVGOptions, +) []svgchart.PanelBlock { + english := options.Language == starOccultationSVGLanguageEnglish + name := func(zh, en string) string { + if english { + return en + } + return zh + } + format := func(value time.Time) string { + if value.IsZero() { + return "—" + } + return value.In(options.Location).Format("15:04:05") + } + point := func(label string, value moon.OccultationPathPoint) svgchart.PanelRow { + if value.Time.IsZero() { + return svgchart.PanelRow{} + } + return svgchart.PanelRow{Label: label, Value: format(value.Time), + Detail: starOccultationFormatCoordinates(value.Longitude, value.Latitude)} + } + pathRows := []svgchart.PanelRow{ + point(name("外掩始", "Partial begins"), path.Start), + point(name("掩甚", "Greatest"), path.Greatest), + point(name("外掩终", "Partial ends"), path.End), + } + // 行星图传入的 path 是由行星路径合成的恒星壳,带宽口径要从原始行星路径取。 + widthSource := path + if planetPath != nil { + widthSource = moon.StarOccultationPath{Greatest: planetPath.Greatest, + GreatestLimitSeparationKM: planetPath.GreatestLimitSeparationKM} + } + partialWidth := widthSource.GreatestLimitSeparationKM + if partialWidth <= 0 { + partialWidth = widthSource.Greatest.WidthKM + } + ephemeris := []svgchart.PanelRow{ + {Label: name("投影", "Projection"), Value: name("正射球面", "Orthographic")}, + {Label: "ΔT", Value: fmt.Sprintf("%.1f s", basic.DeltaT(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true), true))}, + {Label: name("月距", "Moon distance"), Value: fmt.Sprintf("%.0f km", basic.HMoonAway(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true)))}, + {Label: name("部分掩带宽", "Partial-band width"), Value: fmt.Sprintf("%.1f km", partialWidth)}, + } + if planetPath != nil && planetPath.HasTotalBand { + ephemeris = append(ephemeris, svgchart.PanelRow{Label: name("全掩带宽", "Total-band width"), + Value: fmt.Sprintf("%.1f km", planetPath.GreatestTotalWidthKM)}) + } + jde := basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true) + physical := basic.MoonPhysical(jde) + libration := []svgchart.PanelRow{ + {Label: name("经天平动 l", "Libration l"), Value: fmt.Sprintf("%+.2f°", physical.LibrationLongitude)}, + {Label: name("纬天平动 b", "Libration b"), Value: fmt.Sprintf("%+.2f°", physical.LibrationLatitude)}, + {Label: name("自转轴位置角 c", "Axis position angle c"), Value: fmt.Sprintf("%.2f°", physical.PositionAngle)}, + } + contacts := []svgchart.PanelRow{ + {Label: name("外掩始", "Partial begins"), Value: format(path.Start.Time)}, + } + if planetPath != nil && planetPath.HasTotalBand { + contacts = append(contacts, + svgchart.PanelRow{Label: name("全掩始", "Total begins"), Value: format(planetPath.TotalStart.Time)}) + } + contacts = append(contacts, svgchart.PanelRow{Label: name("掩甚", "Greatest"), Value: format(path.Greatest.Time)}) + if planetPath != nil && planetPath.HasTotalBand { + contacts = append(contacts, + svgchart.PanelRow{Label: name("全掩终", "Total ends"), Value: format(planetPath.TotalEnd.Time)}) + } + contacts = append(contacts, svgchart.PanelRow{Label: name("外掩终", "Partial ends"), Value: format(path.End.Time)}) + + return []svgchart.PanelBlock{ + {Title: name("月亮(地心坐标)", "Moon (geocentric)"), Rows: occultationDetailedMoonRows(path.Greatest.Time, options.Language)}, + {Title: name("目标天体", "Target body"), Rows: occultationDetailedTargetRows(star, planetPath, path.Greatest.Time, options.Language)}, + {Title: name("掩带路径点", "Band path points"), Rows: pathRows}, + {Title: name("接触时刻", "Contacts"), Rows: contacts}, + {Title: name("历表与常数", "Ephemeris and constants"), Rows: ephemeris}, + {Title: name("天平动", "Libration"), Rows: libration}, + } +} diff --git a/moon/svg/occultation_detailed_overlap_contract_test.go b/moon/svg/occultation_detailed_overlap_contract_test.go new file mode 100644 index 0000000..0d14975 --- /dev/null +++ b/moon/svg/occultation_detailed_overlap_contract_test.go @@ -0,0 +1,79 @@ +package svg + +import ( + "errors" + "strconv" + "testing" +) + +// 详细版式的文本盒估算沿用测试工具:ASCII 0.62em、非 ASCII 1.05em,纵向 y-0.85em 到 y+0.3em。 + +func occultationDetailedTextOverlapPairs(texts []occultationSVGText) [][2]occultationSVGText { + pairs := [][2]occultationSVGText{} + for first := 0; first < len(texts); first++ { + for second := first + 1; second < len(texts); second++ { + a, b := occultationSVGTextBox(texts[first]), occultationSVGTextBox(texts[second]) + if a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] { + pairs = append(pairs, [2]occultationSVGText{texts[first], texts[second]}) + } + } + } + return pairs +} + +// 详细版式在能出图的画布上不得有互相压叠的文本;装不下的画布必须返回 ErrInvalidOccultationDetailedSVGOptions。 +func TestOccultationDetailedSVGTextOverlapContract(t *testing.T) { + // 竖版最小画布 600x800 的已知压叠总对数:数量变化即视为回归,必须重新核对而不是继续跳过。 + knownOverlaps := map[[2]int]int{{600, 800}: 10} + sizes := [][2]int{ + {1000, 1414}, {1414, 1000}, {1200, 800}, {900, 560}, {900, 400}, + {800, 600}, {640, 480}, {600, 800}, {480, 320}, + } + starPath := occultationTestStarPath(t) + planetPath := occultationTestSaturnPath(t) + for _, size := range sizes { + size := size + t.Run(strconv.Itoa(size[0])+"x"+strconv.Itoa(size[1]), func(t *testing.T) { + options := OccultationDetailedSVGOptions{Width: size[0], Height: size[1]} + overlapping := 0 + for _, item := range []struct { + label string + render func() (string, error) + }{ + {label: "stellar", render: func() (string, error) { + return StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), options) + }}, + {label: "planetary", render: func() (string, error) { + return PlanetOccultationDetailedSVG(planetPath, options) + }}, + } { + diagram, err := item.render() + if err != nil { + if !errors.Is(err, ErrInvalidOccultationDetailedSVGOptions) { + t.Fatalf("%s %dx%d: %v", item.label, size[0], size[1], err) + } + t.Logf("%s %dx%d rejected: %v", item.label, size[0], size[1], err) + continue + } + texts := occultationSVGTexts(t, diagram) + pairs := occultationDetailedTextOverlapPairs(texts) + overlapping += len(pairs) + t.Logf("%s %dx%d rendered: %d texts, %d overlapping pairs", item.label, size[0], size[1], len(texts), len(pairs)) + for _, pair := range pairs { + t.Logf("%s %dx%d overlaps: %s %v vs %s %v", item.label, size[0], size[1], + pair[0].value, occultationSVGTextBox(pair[0]), pair[1].value, occultationSVGTextBox(pair[1])) + } + } + if overlapping == 0 { + return + } + if expected, known := knownOverlaps[size]; known && expected == overlapping { + t.Skipf("竖版最小画布 %dx%d 仍有 %d 对文本压叠(stellar 与 planetary 各 %d 对):月亮数据块行内 "+ + "%q 与其值横向压叠约 6.1 px,掩甚/外掩终标记与其上方经纬读数纵向压叠约 0.63 px;"+ + "块内行距守卫的估算宽度(ASCII 0.58em/非 ASCII 1em)比本测试(0.62em/1.05em)宽松,故未拒绝该画布。", + size[0], size[1], overlapping, overlapping/len([]string{"stellar", "planetary"}), "地平视差 H.P.") + } + t.Errorf("%dx%d renders %d overlapping text pairs", size[0], size[1], overlapping) + }) + } +} diff --git a/moon/svg/occultation_detailed_test.go b/moon/svg/occultation_detailed_test.go new file mode 100644 index 0000000..688b502 --- /dev/null +++ b/moon/svg/occultation_detailed_test.go @@ -0,0 +1,229 @@ +package svg + +import ( + "errors" + "math" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/moon" +) + +// 本文件钉住详细版式三入口的契约:确定性、画布边界、面板数值与选项校验。 + +func TestStarOccultationDetailedSVGDeterministicXML(t *testing.T) { + path := occultationTestStarPath(t) + options := OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: time.UTC} + first, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), options) + if err != nil { + t.Fatalf("StarOccultationDetailedSVG() error = %v", err) + } + if len(first) == 0 { + t.Fatal("StarOccultationDetailedSVG() returned an empty diagram") + } + second, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), options) + if err != nil { + t.Fatalf("second StarOccultationDetailedSVG() error = %v", err) + } + if first != second { + t.Fatal("detailed stellar SVG is not byte-identical across renders") + } + if err := validateXML(first); err != nil { + t.Fatalf("detailed stellar SVG is not valid XML: %v", err) + } + for _, want := range []string{`class="occultation-detailed-panel"`, "月亮(地心坐标)", "天平动"} { + if !strings.Contains(first, want) { + t.Fatalf("detailed stellar SVG is missing %q", want) + } + } +} + +func TestPlanetOccultationDetailedSVGDeterministicXML(t *testing.T) { + path := samplePlanetOccultationPath() + options := OccultationDetailedSVGOptions{Width: 1414, Height: 1000, Location: time.UTC} + first, err := PlanetOccultationDetailedSVG(path, options) + if err != nil { + t.Fatalf("PlanetOccultationDetailedSVG() error = %v", err) + } + if len(first) == 0 { + t.Fatal("PlanetOccultationDetailedSVG() returned an empty diagram") + } + second, err := PlanetOccultationDetailedSVG(path, options) + if err != nil { + t.Fatalf("second PlanetOccultationDetailedSVG() error = %v", err) + } + if first != second { + t.Fatal("detailed planetary SVG is not byte-identical across renders") + } + if err := validateXML(first); err != nil { + t.Fatalf("detailed planetary SVG is not valid XML: %v", err) + } + if !strings.Contains(first, "全掩始") || !strings.Contains(first, "全掩带宽") { + t.Fatal("detailed planetary SVG dropped total-band content") + } +} + +func TestOccultationDetailedSVGKeepsContentInsideCanvas(t *testing.T) { + sizes := [][2]int{{1000, 1414}, {1414, 1000}, {800, 600}} + starPath := occultationTestStarPath(t) + for _, size := range sizes { + size := size + t.Run(strconv.Itoa(size[0])+"x"+strconv.Itoa(size[1]), func(t *testing.T) { + options := OccultationDetailedSVGOptions{Width: size[0], Height: size[1], Location: time.UTC} + diagrams := map[string]string{} + star, err := StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), options) + if err != nil { + t.Fatalf("stellar detailed render: %v", err) + } + diagrams["stellar"] = star + planet, err := PlanetOccultationDetailedSVG(samplePlanetOccultationPath(), options) + if err != nil { + t.Fatalf("planetary detailed render: %v", err) + } + diagrams["planetary"] = planet + for name, diagram := range diagrams { + texts := occultationSVGTexts(t, diagram) + if len(texts) == 0 { + t.Fatalf("%s detailed SVG has no text", name) + } + if outside := occultationSVGTextsOutsideCanvas(texts, float64(size[0]), float64(size[1])); len(outside) > 0 { + t.Fatalf("%s detailed SVG has %d texts outside %dx%d: %v", name, len(outside), size[0], size[1], outside) + } + for _, rect := range occultationSVGPanelRects(t, diagram) { + if rect[0] < 0 || rect[1] < 0 || rect[0]+rect[2] > float64(size[0]) || rect[1]+rect[3] > float64(size[1]) { + t.Fatalf("%s detailed SVG panel rect %.1f,%.1f %.1fx%.1f leaves %dx%d", + name, rect[0], rect[1], rect[2], rect[3], size[0], size[1]) + } + } + } + }) + } +} + +func TestOccultationDetailedSVGRejectsCanvasTooSmall(t *testing.T) { + starPath := sampleStarOccultationPath() + planetPath := samplePlanetOccultationPath() + tests := []struct { + name string + width, height int + }{ + {name: "reference overflow", width: 400, height: 300}, + {name: "narrow", width: 479, height: 1414}, + {name: "short", width: 1000, height: 319}, + {name: "single pixel", width: 1, height: 1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + options := OccultationDetailedSVGOptions{Width: test.width, Height: test.height} + if _, err := StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), options); !errors.Is(err, ErrInvalidOccultationDetailedSVGOptions) { + t.Fatalf("stellar detailed %dx%d error = %v, want ErrInvalidOccultationDetailedSVGOptions", test.width, test.height, err) + } + if _, err := PlanetOccultationDetailedSVG(planetPath, options); !errors.Is(err, ErrInvalidOccultationDetailedSVGOptions) { + t.Fatalf("planetary detailed %dx%d error = %v, want ErrInvalidOccultationDetailedSVGOptions", test.width, test.height, err) + } + }) + } +} + +func TestOccultationDetailedSVGRejectsInvalidPathOnce(t *testing.T) { + starPath := sampleStarOccultationPath() + starPath.Greatest.Longitude = math.NaN() + _, err := StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), OccultationDetailedSVGOptions{}) + if !errors.Is(err, ErrInvalidStarOccultationPath) { + t.Fatalf("stellar detailed invalid path error = %v, want ErrInvalidStarOccultationPath", err) + } + if count := strings.Count(err.Error(), "invalid stellar occultation path"); count != 1 { + t.Fatalf("stellar detailed error = %q, sentinel prefix appears %d times", err, count) + } + planetPath := samplePlanetOccultationPath() + planetPath.TotalBandFootprints = []moon.PlanetOccultationFootprint{{Time: planetPath.TotalStart.Time}} + _, err = PlanetOccultationDetailedSVG(planetPath, OccultationDetailedSVGOptions{}) + if !errors.Is(err, ErrInvalidPlanetOccultationPath) { + t.Fatalf("planetary detailed invalid path error = %v, want ErrInvalidPlanetOccultationPath", err) + } + if count := strings.Count(err.Error(), "invalid planetary occultation path"); count != 1 { + t.Fatalf("planetary detailed error = %q, sentinel prefix appears %d times", err, count) + } +} + +func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) { + options := OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: time.UTC} + starDiagram, err := StarOccultationDetailedSVG(sampleStarOccultationPath(), hr4799StarCoordinate(), options) + if err != nil { + t.Fatalf("StarOccultationDetailedSVG() error = %v", err) + } + star := occultationSVGDetailedPanelRows(t, starDiagram) + for _, expected := range []struct { + title, label, value string + }{ + {"月亮(地心坐标)", "赤经 R.A.", "23h37m22.4s"}, + {"月亮(地心坐标)", "赤纬 Dec.", "-00°01'34.0\""}, + {"月亮(地心坐标)", "视半径 S.D.", "00°15'25.5\""}, + {"月亮(地心坐标)", "地平视差 H.P.", "00°56'37.7\""}, + {"目标天体", "目标", "HR 4799"}, + {"目标天体", "赤经 R.A.", "12h36m47.4s"}, + {"目标天体", "赤纬 Dec.", "-05°49'55.0\""}, + {"历表与常数", "ΔT", "69.2 s"}, + {"历表与常数", "月距", "387216 km"}, + {"历表与常数", "部分掩带宽", "3300.0 km"}, + {"天平动", "经天平动 l", "-5.86°"}, + {"天平动", "纬天平动 b", "-2.79°"}, + {"天平动", "自转轴位置角 c", "-21.92°"}, + } { + if got := occultationSVGPanelValue(t, star, expected.title, expected.label); got != expected.value { + t.Fatalf("stellar panel %q row %q = %q, want %q", expected.title, expected.label, got, expected.value) + } + } + + planetDiagram, err := PlanetOccultationDetailedSVG(samplePlanetOccultationPath(), options) + if err != nil { + t.Fatalf("PlanetOccultationDetailedSVG() error = %v", err) + } + planet := occultationSVGDetailedPanelRows(t, planetDiagram) + for _, expected := range []struct { + title, label, value string + }{ + {"目标天体", "目标", "土星"}, + {"目标天体", "赤经 R.A.", "00h58m06.2s"}, + {"目标天体", "赤纬 Dec.", "+03°27'41.4\""}, + {"目标天体", "视半径 S.D.", "00°00'09.3\""}, + {"历表与常数", "ΔT", "69.2 s"}, + {"历表与常数", "部分掩带宽", "3300.0 km"}, + {"历表与常数", "全掩带宽", "3100.0 km"}, + {"接触时刻", "全掩始", "10:30:00"}, + {"接触时刻", "全掩终", "12:30:00"}, + } { + if got := occultationSVGPanelValue(t, planet, expected.title, expected.label); got != expected.value { + t.Fatalf("planetary panel %q row %q = %q, want %q", expected.title, expected.label, got, expected.value) + } + } +} + +// TestOccultationDetailedSVGMoonRowsFollowGeocentricEphemeris 卡住月心地心量的量级, +// 避免把 ΔT、视差常数抄错却仍然画出"看起来正常"的面板。 +func TestOccultationDetailedSVGMoonRowsFollowGeocentricEphemeris(t *testing.T) { + diagram, err := StarOccultationDetailedSVG(sampleStarOccultationPath(), hr4799StarCoordinate(), OccultationDetailedSVGOptions{ + Width: 1000, Height: 1414, Location: time.UTC, + }) + if err != nil { + t.Fatalf("StarOccultationDetailedSVG() error = %v", err) + } + panels := occultationSVGDetailedPanelRows(t, diagram) + if deltaT := occultationSVGNumericPrefix(t, occultationSVGPanelValue(t, panels, "历表与常数", "ΔT")); deltaT < 40 || deltaT > 110 { + t.Fatalf("panel ΔT = %.1f s, want a 2026 value between 40 and 110 s", deltaT) + } + if distance := occultationSVGNumericPrefix(t, occultationSVGPanelValue(t, panels, "历表与常数", "月距")); distance < 350000 || distance > 410000 { + t.Fatalf("panel Moon distance = %.0f km, want a physical geocentric distance", distance) + } + if parallax := occultationSVGSexagesimal(t, occultationSVGPanelValue(t, panels, "月亮(地心坐标)", "地平视差 H.P.")); parallax < 0.9 || parallax > 1.02 { + t.Fatalf("panel horizontal parallax = %.4f°, want about 1° from the geocentric distance", parallax) + } + if semidiameter := occultationSVGSexagesimal(t, occultationSVGPanelValue(t, panels, "月亮(地心坐标)", "视半径 S.D.")); semidiameter < 0.23 || semidiameter > 0.28 { + t.Fatalf("panel semidiameter = %.4f°, want about 0.25°", semidiameter) + } + if rightAscension := occultationSVGSexagesimal(t, occultationSVGPanelValue(t, panels, "月亮(地心坐标)", "赤经 R.A.")); rightAscension < 350 || rightAscension > 360 { + t.Fatalf("panel Moon R.A. = %.3f°, want the same geocentric direction as the target star", rightAscension) + } +} diff --git a/moon/svg/occultation_isochrone.go b/moon/svg/occultation_isochrone.go new file mode 100644 index 0000000..62b361b --- /dev/null +++ b/moon/svg/occultation_isochrone.go @@ -0,0 +1,83 @@ +package svg + +import ( + "fmt" + "html" + "strings" + + "b612.me/astro/moon" +) + +// writeOccultationGreatestTimeContours 绘制地方掩甚时刻等值线及其 HH:MM 标注。 +func writeOccultationGreatestTimeContours( + b *strings.Builder, + contours []moon.OccultationGreatestTimeContour, + layout starOccultationSVGLayout, + options StarOccultationSVGOptions, + placer *occultationLabelPlacer, +) { + if len(contours) == 0 { + return + } + type isochroneLabel struct { + x, y float64 + text string + } + labels := make([]isochroneLabel, 0, len(contours)) + for _, contour := range contours { + for _, segment := range contour.Segments { + writeStarOccultationGeoLine(b, segment, layout, + occultationLineStyle{className: "greatest-time-isoline", color: "#1f6fb2", strokeWidth: 1.1}) + } + text := contour.Time.In(options.Location).Format("15:04") + if x, y, ok := occultationGreatestTimeIsolineLabelPlacement(contour, text, layout, placer); ok { + labels = append(labels, isochroneLabel{x: x, y: y, text: text}) + } + } + for _, label := range labels { + fmt.Fprintf(b, `%s`, + label.x, label.y, html.EscapeString(label.text)) + } +} + +// occultationGreatestTimeIsolineLabelPlacement 在最长的支路上取首个未被其它标签占用的标注点。 +func occultationGreatestTimeIsolineLabelPlacement( + contour moon.OccultationGreatestTimeContour, + text string, + layout starOccultationSVGLayout, + placer *occultationLabelPlacer, +) (float64, float64, bool) { + longest := -1 + for index, segment := range contour.Segments { + if longest < 0 || len(segment) > len(contour.Segments[longest]) { + longest = index + } + } + if longest < 0 { + return 0, 0, false + } + segment := contour.Segments[longest] + frame := layout.mapFrame() + middle := len(segment) / 2 + for offset := 0; offset <= middle; offset++ { + for _, index := range []int{middle + offset, middle - offset} { + if index < 0 || index >= len(segment) { + continue + } + point := segment[index] + x, y, visible := frame.Project(point.Longitude, point.Latitude) + if !visible || + x < frame.X+30 || x > frame.X+frame.Width-30 || + y < frame.Y+18 || y > frame.Y+frame.Height-18 { + continue + } + // 优先压在等时线上方,其次下方;两者都被占用就换下一个候选点。 + for _, baseline := range []float64{y - 6, y + 13} { + if placer.place(x, baseline, "middle", text, 9, 2) { + return x, baseline, true + } + } + } + } + return 0, 0, false +} diff --git a/moon/svg/occultation_isochrone_test.go b/moon/svg/occultation_isochrone_test.go new file mode 100644 index 0000000..c398da3 --- /dev/null +++ b/moon/svg/occultation_isochrone_test.go @@ -0,0 +1,89 @@ +package svg + +import ( + "math" + "strings" + "testing" + "time" + + "b612.me/astro/moon" +) + +// 掩甚时刻等时线由路径选项请求,渲染器只在路径携带时绘制。 +func TestPlanetOccultationSVGDrawsGreatestTimeIsochrones(t *testing.T) { + start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC) + render := func(step time.Duration) string { + t.Helper() + paths, err := moon.FindPlanetOccultationPaths(start, end, moon.OccultationSaturn, moon.OccultationPathOptions{ + Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: step, + }) + if err != nil || len(paths) == 0 { + t.Fatalf("no occultation path: %v", err) + } + if step > 0 && len(paths[0].GreatestTimeContours) == 0 { + t.Fatal("path carries no isochrones") + } + rendered, renderErr := PlanetOccultationPathSVG(paths[0], StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: time.UTC, + }) + if renderErr != nil { + t.Fatalf("render: %v", renderErr) + } + return rendered + } + + withIsochrones := render(30 * time.Minute) + if !strings.Contains(withIsochrones, `class="greatest-time-isoline"`) { + t.Fatal("isochrone paths missing") + } + if !strings.Contains(withIsochrones, `class="greatest-time-isoline-label"`) { + t.Fatal("isochrone labels missing") + } + if strings.Contains(render(0), "greatest-time-isoline") { + t.Fatal("a zero step must not draw isochrones") + } +} + +// 等时线也要走路径校验:坏支路必须在渲染前被拦住,而不是画出 NaN 折线。 +func TestPlanetOccultationSVGRejectsBrokenGreatestTimeIsochrones(t *testing.T) { + paths, err := moon.FindPlanetOccultationPaths( + time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute}, + ) + if err != nil || len(paths) == 0 || len(paths[0].GreatestTimeContours) == 0 { + t.Fatalf("no isochrone path: %v", err) + } + options := StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC} + render := func(mutate func(path *moon.PlanetOccultationPath)) error { + path := paths[0] + path.GreatestTimeContours = append([]moon.OccultationGreatestTimeContour(nil), path.GreatestTimeContours...) + mutate(&path) + _, renderErr := PlanetOccultationPathSVG(path, options) + return renderErr + } + if err := render(func(path *moon.PlanetOccultationPath) {}); err != nil { + t.Fatalf("valid isochrones rejected: %v", err) + } + if err := render(func(path *moon.PlanetOccultationPath) { + path.GreatestTimeContours[0].JDE = 0 + }); err == nil { + t.Fatal("zero JDE accepted") + } + if err := render(func(path *moon.PlanetOccultationPath) { + path.GreatestTimeContours[0].Segments[0][0].Longitude = math.NaN() + }); err == nil { + t.Fatal("NaN longitude accepted") + } + if err := render(func(path *moon.PlanetOccultationPath) { + path.GreatestTimeContours[0].Segments[0] = path.GreatestTimeContours[0].Segments[0][:1] + }); err == nil { + t.Fatal("single-point branch accepted") + } + if err := render(func(path *moon.PlanetOccultationPath) { + path.GreatestTimeContours[0].Segments[0][0].Time = path.GreatestTimeContours[0].Segments[0][0].Time.Add(2 * time.Second) + }); err == nil { + t.Fatal("branch point with a mismatched instant accepted") + } +} diff --git a/moon/svg/occultation_labels.go b/moon/svg/occultation_labels.go new file mode 100644 index 0000000..9528bb1 --- /dev/null +++ b/moon/svg/occultation_labels.go @@ -0,0 +1,191 @@ +package svg + +import ( + "fmt" + "html" + "strings" + + "b612.me/astro/internal/svgchart" +) + +// occultationLabelRect 是画布坐标下的标签占位矩形。 +type occultationLabelRect struct { + left, top, right, bottom float64 +} + +func (rect occultationLabelRect) overlaps(other occultationLabelRect) bool { + return rect.left < other.right && other.left < rect.right && rect.top < other.bottom && other.top < rect.bottom +} + +func (rect occultationLabelRect) expanded(padding float64) occultationLabelRect { + return occultationLabelRect{ + left: rect.left - padding, top: rect.top - padding, + right: rect.right + padding, bottom: rect.bottom + padding, + } +} + +// occultationLabelPlacer 是三族地图标签(等时线、中心线时刻、事件标记)共用的已放置表; +// nil 表示不做避让。 +type occultationLabelPlacer struct { + placed []occultationLabelRect +} + +func (placer *occultationLabelPlacer) reserved(rect occultationLabelRect) bool { + if placer == nil { + return false + } + for _, current := range placer.placed { + if rect.overlaps(current) { + return true + } + } + return false +} + +// place 在占位可用时登记并返回 true;padding 是标签之间要求的最小空隙。 +func (placer *occultationLabelPlacer) place(x, y float64, anchor, text string, fontSize, padding float64) bool { + rect := occultationLabelRectFor(x, y, anchor, text, fontSize).expanded(padding) + if placer.reserved(rect) { + return false + } + placer.reserve(rect) + return true +} + +func (placer *occultationLabelPlacer) reserve(rect occultationLabelRect) { + if placer == nil { + return + } + placer.placed = append(placer.placed, rect) +} + +// occultationLabelTextWidth 估算标签宽度,口径与数据块的压叠判定一致。 +func occultationLabelTextWidth(text string, fontSize float64) float64 { + return svgchart.EstimatedTextWidth(text, fontSize) +} + +// occultationLabelRectFor 返回锚点为 (x, y) 的文本占位,上下留白与数据块同口径。 +func occultationLabelRectFor(x, y float64, anchor, text string, fontSize float64) occultationLabelRect { + width := occultationLabelTextWidth(text, fontSize) + above, below := svgchart.EstimatedTextExtents(fontSize) + left := x + switch anchor { + case "middle": + left = x - width/2 + case "end": + left = x - width + } + return occultationLabelRect{ + left: left, top: y - above, right: left + width, bottom: y + below, + } +} + +// occultationLegendItem 是图例中的一项:色块(fill)与线段(color)至少有一个。 +type occultationLegendItem struct { + label string + color string + dash string + markerWidth float64 + fill string + fillOpacity float64 +} + +const ( + occultationLegendRowHeight = 18.0 + occultationLegendMinimumFontSize = 7.0 +) + +func occultationLegendItemWidth(item occultationLegendItem, fontSize float64) float64 { + return item.markerWidth + 6 + occultationLabelTextWidth(item.label, fontSize) +} + +// occultationLegendItemRows 按画布宽度把图例项贪心排成多行;行数不得超过布局预留的两行。 +func occultationLegendItemRows( + layout starOccultationSVGLayout, + items []occultationLegendItem, + fontSize float64, +) [][]occultationLegendItem { + rows := make([][]occultationLegendItem, 0, 2) + current := make([]occultationLegendItem, 0, len(items)) + width := 0.0 + for _, item := range items { + itemWidth := occultationLegendItemWidth(item, fontSize) + // 布局只预留两行,第三行会压到页脚;超出的项并回第二行而不是换行。 + if len(current) > 0 && len(rows) < 1 && width+itemWidth > layout.mapWidth { + rows = append(rows, current) + current = make([]occultationLegendItem, 0, len(items)) + width = 0 + } + current = append(current, item) + width += itemWidth + } + if len(current) > 0 { + rows = append(rows, current) + } + return rows +} + +// occultationLegendFontSize 在放得下的前提下取最大字号:窄地图(详细版式)必须缩字号, +// 否则图例项会溢出地图横向范围压到右侧数据块上。 +func occultationLegendFontSize(layout starOccultationSVGLayout, items []occultationLegendItem, fontSize float64) float64 { + for candidate := fontSize; candidate >= occultationLegendMinimumFontSize; candidate-- { + rows := occultationLegendItemRows(layout, items, candidate) + fits := true + for _, row := range rows { + total := 0.0 + for _, item := range row { + total += occultationLegendItemWidth(item, candidate) + } + if total > layout.mapWidth { + fits = false + break + } + } + if fits { + return candidate + } + } + return occultationLegendMinimumFontSize +} + +// writeOccultationLegendItems 逐行写图例;每行内的项按剩余空间均分间隔,避免文字互相压叠。 +func writeOccultationLegendItems( + b *strings.Builder, + top float64, + layout starOccultationSVGLayout, + items []occultationLegendItem, + fontSize float64, +) { + fontSize = occultationLegendFontSize(layout, items, fontSize) + rows := occultationLegendItemRows(layout, items, fontSize) + y := top + for _, row := range rows { + total := 0.0 + for _, item := range row { + total += occultationLegendItemWidth(item, fontSize) + } + gap := 0.0 + if len(row) > 0 && total < layout.mapWidth { + gap = (layout.mapWidth - total) / float64(len(row)) + } + x := layout.mapX + for _, item := range row { + if item.fill != "" { + fmt.Fprintf(b, ``, + x, y-10, item.markerWidth, item.fill, item.fillOpacity) + } + if item.color != "" { + fmt.Fprintf(b, ``) + } + fmt.Fprintf(b, `%s`, + x+item.markerWidth+6, y, fontSize, html.EscapeString(item.label)) + x += occultationLegendItemWidth(item, fontSize) + gap + } + y += occultationLegendRowHeight + } +} diff --git a/moon/svg/occultation_labels_test.go b/moon/svg/occultation_labels_test.go new file mode 100644 index 0000000..cc8f42d --- /dev/null +++ b/moon/svg/occultation_labels_test.go @@ -0,0 +1,87 @@ +package svg + +import ( + "testing" + "time" +) + +// 本文件量化三族地图标签(等时线、中心线时刻、事件标记)的互相避让: +// 产物里任一族的 占位都不许与另一族相交。 + +type occultationLabelFixture struct { + name string + width int + height int + // labelStep 为 0 时用默认 30 分钟。 + labelStep time.Duration + star bool +} + +func TestOccultationMapLabelsDoNotOverlap(t *testing.T) { + fixtures := []occultationLabelFixture{ + {name: "stellar 1200x800", width: 1200, height: 800, labelStep: 10 * time.Minute, star: true}, + {name: "stellar 720x520", width: 720, height: 520, labelStep: 15 * time.Minute, star: true}, + {name: "stellar 640x480", width: 640, height: 480, star: true}, + {name: "planetary compact 1200x800", width: 1200, height: 800, labelStep: 10 * time.Minute}, + {name: "planetary compact 720x520", width: 720, height: 520, labelStep: 15 * time.Minute}, + } + totalLabels := 0 + for _, fixture := range fixtures { + fixture := fixture + t.Run(fixture.name, func(t *testing.T) { + options := StarOccultationSVGOptions{ + Width: fixture.width, Height: fixture.height, Location: time.UTC, TimeLabelStep: fixture.labelStep, + } + var diagram string + var err error + if fixture.star { + diagram, err = StarOccultationPathSVG(occultationTestStarPath(t), options) + } else { + diagram, err = PlanetOccultationPathSVG(occultationTestSaturnPath(t), options) + } + if err != nil { + t.Fatalf("render: %v", err) + } + labels := occultationSVGLabelTexts(occultationSVGTexts(t, diagram)) + if len(labels) < 3 { + t.Fatalf("fixture produced only %d labels; the overlap check would be vacuous", len(labels)) + } + if outside := occultationSVGTextsOutsideCanvas(labels, float64(fixture.width), float64(fixture.height)); len(outside) > 0 { + t.Fatalf("map labels outside the canvas: %v", outside) + } + if pairs := occultationSVGTextOverlapPairs(labels); pairs != 0 { + t.Fatalf("%d label overlap pairs among %d labels", pairs, len(labels)) + } + totalLabels += len(labels) + }) + } + if totalLabels < 40 { + t.Fatalf("label fixtures only produced %d labels, want a dense label set", totalLabels) + } + t.Logf("checked %d labels across %d fixtures", totalLabels, len(fixtures)) +} + +func TestPlanetOccultationMapLabelsAvoidTotalBandMarkers(t *testing.T) { + diagram, err := PlanetOccultationPathSVG(occultationTestMarsPath(t), StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: time.UTC, TimeLabelStep: 10 * time.Minute, + }) + if err != nil { + t.Fatalf("render: %v", err) + } + labels := occultationSVGLabelTexts(occultationSVGTexts(t, diagram)) + if len(labels) < 5 { + t.Fatalf("total-band fixture produced only %d labels", len(labels)) + } + if pairs := occultationSVGTextOverlapPairs(labels); pairs != 0 { + t.Fatalf("%d label overlap pairs among %d labels", pairs, len(labels)) + } + markers := 0 + for _, label := range labels { + if label.parentClass == "event-marker event-greatest" { + markers++ + } + } + if markers != 1 { + t.Fatalf("greatest-event label count = %d, want one", markers) + } +} diff --git a/moon/svg/occultation_local.go b/moon/svg/occultation_local.go index 3f18739..2ca0144 100644 --- a/moon/svg/occultation_local.go +++ b/moon/svg/occultation_local.go @@ -9,6 +9,7 @@ import ( "time" "b612.me/astro/internal/svgasset" + "b612.me/astro/internal/svgchart" "b612.me/astro/moon" ) @@ -69,13 +70,15 @@ type localStarOccultationSVGLayout struct { stageTop float64 stageBottom float64 footerY float64 + headerMaxLines int } type localStarOccultationEventFrame struct { - label string - name string - time time.Time - frame moon.StarOccultationDiagramFrame + label string + name string + time time.Time + frame moon.StarOccultationDiagramFrame + appearance localOccultationMoonAppearance } // FindLocalStarOccultationSVGs 搜索固定观测地点,并渲染请求时间窗口内的每次点光源恒星月掩。不会加载内嵌星表;空切片表示没有本地事件。 @@ -198,6 +201,9 @@ func renderLocalStarOccultationSVG( ) string { title := localStarOccultationSVGTitle(info, options) headerLines := localStarOccultationSVGHeaderLines(info, options) + // 页眉字号 14/13,省略号按较大的 14 估算;行数上限只与画布高度有关,先按空页眉取上限。 + headerLimit := localStarOccultationSVGLayoutFor(options, 72).headerMaxLines + headerLines = svgchart.TruncateTextLines(headerLines, float64(options.Width)-80, 14, headerLimit) headerBottom := 72.0 + float64(len(headerLines))*19 layout := localStarOccultationSVGLayoutFor(options, headerBottom) events := localStarOccultationSVGEventFrames(info, diagram.Frames, options.Language) @@ -211,8 +217,13 @@ func renderLocalStarOccultationSVG( b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, ``, layout.width-44, layout.height-36) + titleSize := starOccultationTitleFontSize(title, layout.width) + titleText := title + if options.Title != "" { + titleText = svgchart.EllipsizeText(title, layout.width-80, float64(titleSize)) + } fmt.Fprintf(&b, `%s`, - layout.width/2, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title)) + layout.width/2, titleSize, html.EscapeString(titleText)) fmt.Fprintf(&b, ``, layout.width/2-78, layout.width/2+78) for index, line := range headerLines { fontSize, fill := 13, "#3b4143" @@ -258,6 +269,7 @@ func localStarOccultationSVGLayoutFor(options LocalStarOccultationSVGOptions, he stageTop: stageTop, stageBottom: stageBottom, footerY: height - 43, + headerMaxLines: localOccultationHeaderLineLimit(height, footerSpace, stageHeight), } } @@ -268,8 +280,10 @@ func writeLocalStarOccultationOverview( layout localStarOccultationSVGLayout, options LocalStarOccultationSVGOptions, ) { + overviewTitle := occultationTitleText(localStarOccultationSVGOverviewTitle(options), options.OverviewTitle != "", + layout.panelX-layout.overviewLeft-12, 14) fmt.Fprintf(b, `%s`, - layout.overviewLeft, layout.overviewTop-10, html.EscapeString(localStarOccultationSVGOverviewTitle(options))) + layout.overviewLeft, layout.overviewTop-10, html.EscapeString(overviewTitle)) cx := (layout.overviewLeft + layout.overviewRight) / 2 cy := (layout.overviewTop+layout.overviewBottom)/2 + 4 extent := localStarOccultationSVGExtent(diagram.Frames) @@ -284,7 +298,9 @@ func writeLocalStarOccultationOverview( moonRadius := localStarOccultationSVGMaximumMoonRadius(diagram.Frames) * scale writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language) - writeLocalStarOccultationMoon(b, cx, cy, moonRadius, "overview-moon") + writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon", + localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer), + "local-star-occultation-moon-overview") writeLocalStarOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language) writeLocalStarOccultationTrack(b, diagram.Frames, mapX, mapY) for _, event := range events { @@ -400,14 +416,6 @@ func localStarOccultationSVGLunarPathLabel(language string) string { return "白道" } -func writeLocalStarOccultationMoon(b *strings.Builder, cx, cy, radius float64, class string) { - fmt.Fprintf(b, ``, html.EscapeString(class)) - fmt.Fprintf(b, ``, - cx-radius, cy-radius, radius*2, radius*2) - fmt.Fprintf(b, ``, cx, cy, radius) - b.WriteString(``) -} - func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, language string) { north, east, west, south := "北", "东", "西", "南" if language == starOccultationSVGLanguageEnglish { @@ -466,8 +474,10 @@ func writeLocalStarOccultationContacts( ) { fmt.Fprintf(b, ``, layout.panelX-12, layout.overviewTop-12, layout.panelX-12, layout.overviewBottom) + contactsTitle := occultationTitleText(localStarOccultationSVGContactsTitle(options), options.ContactsTitle != "", + layout.width-layout.panelX-layout.margin, 14) fmt.Fprintf(b, `%s`, - layout.panelX, layout.overviewTop-10, html.EscapeString(localStarOccultationSVGContactsTitle(options))) + layout.panelX, layout.overviewTop-10, html.EscapeString(contactsTitle)) available := layout.overviewBottom - layout.overviewTop - 10 rowHeight := math.Min(86, available/math.Max(1, float64(len(events)))) for index, event := range events { @@ -494,8 +504,10 @@ func writeLocalStarOccultationStages( layout localStarOccultationSVGLayout, options LocalStarOccultationSVGOptions, ) { + phaseTitle := occultationTitleText(localStarOccultationSVGPhasePanelsTitle(options), options.PhasePanelsTitle != "", + layout.width-2*layout.margin, 14) fmt.Fprintf(b, `%s`, - layout.margin, layout.stageTop+13, html.EscapeString(localStarOccultationSVGPhasePanelsTitle(options))) + layout.margin, layout.stageTop+13, html.EscapeString(phaseTitle)) if len(events) == 0 { return } @@ -507,7 +519,8 @@ func writeLocalStarOccultationStages( cy := layout.stageTop + 34 + radius for index, event := range events { cx := layout.margin + columnWidth*(float64(index)+0.5) - writeLocalStarOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon") + writeLocalOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon", + event.appearance, fmt.Sprintf("local-star-occultation-moon-stage-%d", index)) x := cx - event.frame.StarXArcsec*scale y := cy - event.frame.StarYArcsec*scale writeLocalStarOccultationStar(b, x, y, event.frame.BehindMoon, "stage-star", event.label) @@ -531,12 +544,14 @@ func writeLocalStarOccultationFooter( layout localStarOccultationSVGLayout, options LocalStarOccultationSVGOptions, ) { - directionLines := starOccultationWrapText(localStarOccultationSVGDirectionText(options), layout.width-80, 10) + directionLines, footerLines := localOccultationFooterLines( + localStarOccultationSVGDirectionText(options), options.DirectionText != "", + localStarOccultationSVGFooterNote(options), options.FooterNote != "", + layout.footerY, layout.width, layout.height) for index, line := range directionLines { fmt.Fprintf(b, `%s`, layout.margin, layout.footerY+float64(index)*14-8, html.EscapeString(line)) } - footerLines := starOccultationWrapText(localStarOccultationSVGFooterNote(options), layout.width-80, 9) for index, line := range footerLines { fmt.Fprintf(b, `%s`, layout.margin, layout.footerY+float64(len(directionLines))*14+float64(index)*12-6, html.EscapeString(line)) @@ -559,10 +574,11 @@ func localStarOccultationSVGEventFrames( for _, frame := range frames { if localStarOccultationSVGFrameHasLabel(frame, label) { result = append(result, localStarOccultationEventFrame{ - label: label, - name: localStarOccultationSVGEventName(label, language), - time: times[label], - frame: frame, + label: label, + name: localStarOccultationSVGEventName(label, language), + time: times[label], + frame: frame, + appearance: localOccultationMoonAppearanceAt(times[label], info.Observer), }) break } @@ -585,15 +601,52 @@ func localStarOccultationSVGFrameHasLabel(frame moon.StarOccultationDiagramFrame func localStarOccultationSVGHeaderLines(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) []string { lines := make([]string, 0, 4) - for _, value := range []string{ - localStarOccultationSVGSummaryText(info, options), - localStarOccultationSVGGreatestText(info, options), + for _, item := range []struct { + value string + custom bool + }{ + {localStarOccultationSVGSummaryText(info, options), options.SummaryText != ""}, + {localStarOccultationSVGGreatestText(info, options), options.GreatestText != ""}, } { - lines = append(lines, starOccultationWrapText(value, float64(options.Width)-80, 13)...) + lines = append(lines, occultationWrappedTextLines(item.value, item.custom, float64(options.Width)-80, 13)...) } return lines } +// localOccultationFooterLines 排布本地图页脚:补充说明贴画布底边,方向说明占用上方余高,两块都按行数上限截断。 +func localOccultationFooterLines( + directionText string, directionCustom bool, + noteText string, noteCustom bool, + footerY, width, height float64, +) ([]string, []string) { + maxWidth := width - 80 + direction := occultationWrappedTextLines(directionText, directionCustom, maxWidth, 10) + note := occultationWrappedTextLines(noteText, noteCustom, maxWidth, 9) + noteAbove, noteBelow := svgchart.EstimatedTextExtents(9) + noteHeight := noteAbove + float64(len(note)-1)*12 + noteBelow + direction = svgchart.TruncateTextLines(direction, maxWidth, 10, + svgchart.BaselineLineLimit(10, 14, footerY-8, height-2-noteHeight)) + noteFirst := footerY + float64(len(direction))*14 - 6 + note = svgchart.TruncateTextLines(note, maxWidth, 9, + svgchart.BaselineLineLimit(9, 12, noteFirst, height-2)) + return direction, note +} + +// localOccultationHeaderLineLimit 由版式的页脚预留与阶段区高度推出页眉行数上限,总览区被压到 120 px 以下时圆盘与方向标记会跑出画布。 +func localOccultationHeaderLineLimit(height, footerSpace, stageHeight float64) int { + const ( + stageGap = 20.0 + overviewTopGap = 26.0 + overviewMin = 120.0 + headerStart = 72.0 + ) + limit := int(math.Floor((height - footerSpace - stageHeight - stageGap - overviewMin - overviewTopGap - headerStart) / 19)) + if limit < 1 { + return 1 + } + return limit +} + func localStarOccultationSVGTitle(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) string { if options.Title != "" { return options.Title diff --git a/moon/svg/occultation_local_test.go b/moon/svg/occultation_local_test.go index 44b8b5d..1b56f34 100644 --- a/moon/svg/occultation_local_test.go +++ b/moon/svg/occultation_local_test.go @@ -2,6 +2,7 @@ package svg import ( "errors" + "fmt" "math" "strings" "testing" @@ -56,6 +57,54 @@ func TestFindLocalStarOccultationSVGsHR4799(t *testing.T) { } } +func TestLocalStarOccultationSVGUsesGreatestMoonPhase(t *testing.T) { + info := localHR4799Occultation(t) + diagram, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{ + Location: time.UTC, + }) + if err != nil { + t.Fatalf("LocalStarOccultationSVG() error = %v", err) + } + appearance := localOccultationMoonAppearanceAt(info.Greatest, info.Observer) + want := fmt.Sprintf(`data-illumination="%.6f" data-bright-limb-position-angle="%.3f"`, + appearance.illumination, appearance.brightLimbPA) + if !strings.Contains(diagram, want) { + t.Fatalf("local SVG does not carry greatest-time Moon appearance %q", want) + } + if appearance.illumination > 1e-6 && appearance.illumination < 1-1e-6 && + !strings.Contains(diagram, ``) { + t.Fatal("non-full Moon overview is missing its phase clip path") + } +} + +func TestLocalOccultationMoonPhasePathSelectsIlluminatedArea(t *testing.T) { + gibbous := localOccultationMoonPhasePath(100, 0.75) + if !strings.Contains(gibbous, `A 50.000 100.000 0 0 1 0 -100.000`) { + t.Fatalf("waxing or waning gibbous terminator uses the wrong arc: %s", gibbous) + } + crescent := localOccultationMoonPhasePath(100, 0.25) + if !strings.Contains(crescent, `A 50.000 100.000 0 0 0 0 -100.000`) { + t.Fatalf("crescent terminator uses the wrong arc: %s", crescent) + } +} + +func TestLocalOccultationMoonSVGRotationUsesNorthUpEastLeft(t *testing.T) { + tests := []struct { + positionAngle float64 + rotation float64 + }{ + {positionAngle: 0, rotation: -90}, + {positionAngle: 90, rotation: -180}, + {positionAngle: 180, rotation: -270}, + {positionAngle: 270, rotation: -360}, + } + for _, test := range tests { + if got := localOccultationMoonSVGRotation(test.positionAngle); got != test.rotation { + t.Fatalf("bright-limb PA %.1f rotation = %.1f, want %.1f", test.positionAngle, got, test.rotation) + } + } +} + func TestLocalStarOccultationSVGEnglishAndCustomText(t *testing.T) { info := localHR4799Occultation(t) diagram, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{ diff --git a/moon/svg/occultation_map.go b/moon/svg/occultation_map.go index d40af7f..e564cd8 100644 --- a/moon/svg/occultation_map.go +++ b/moon/svg/occultation_map.go @@ -4,6 +4,7 @@ import "b612.me/astro/internal/svgmap" // MapProjection 控制全球月掩地图使用的投影。 // 零值根据事件几何自动选择投影。 +// 与 eclipse/svg.EclipseMapProjection 是同一概念的两套名字,取值字符串逐对相同。 // MapProjection controls the projection used by a global occultation map. // The zero value selects a projection from the event geometry. type MapProjection string @@ -21,6 +22,9 @@ const ( // MapProjectionSouthPolar 使用南极方位等距投影。 // MapProjectionSouthPolar uses the south-polar azimuthal equidistant projection. MapProjectionSouthPolar MapProjection = "south-polar" + // MapProjectionOrthographic 使用正射球面投影,视点取事件中心,只画朝向观察者的半球。 + // MapProjectionOrthographic uses the orthographic globe, viewed from the event centre; only the facing hemisphere is drawn. + MapProjectionOrthographic MapProjection = "orthographic" ) func internalMapProjection(value MapProjection) svgmap.Projection { diff --git a/moon/svg/occultation_model.go b/moon/svg/occultation_model.go index bba591b..f96321c 100644 --- a/moon/svg/occultation_model.go +++ b/moon/svg/occultation_model.go @@ -9,6 +9,8 @@ import ( "unicode" "unicode/utf8" + "b612.me/astro/internal/occultationgeo" + "b612.me/astro/internal/svgchart" "b612.me/astro/internal/svgmap" "b612.me/astro/moon" ) @@ -30,6 +32,8 @@ type starOccultationSVGLayout struct { panelWidth float64 footerY float64 projection svgmap.Projection + // center 是正射球面的视点;其他投影忽略它。 + center svgmap.GeoPoint } type starOccultationGeoPoint struct { @@ -91,29 +95,113 @@ func validateStarOccultationPath(path moon.StarOccultationPath) error { return fmt.Errorf("%w: northern and southern limit sample %d times must match", ErrInvalidStarOccultationPath, index) } } + if err := occultationgeo.ValidateRiseSetCurves(path.RiseSetCurves, path.Start.Time, path.End.Time); err != nil { + return fmt.Errorf("%w: invalid rise/set curves: %v", ErrInvalidStarOccultationPath, err) + } + if err := validateOccultationContours( + ErrInvalidStarOccultationPath, "band contours", path.BandContours, + path.Start.Time, path.End.Time, + ); err != nil { + return err + } + if err := validateOccultationContours( + ErrInvalidStarOccultationPath, "visibility contours", path.VisibilityContours, + path.Start.Time, path.End.Time, + ); err != nil { + return err + } + if err := validateOccultationGreatestTimeContours(ErrInvalidStarOccultationPath, path.GreatestTimeContours); err != nil { + return err + } last := len(path.NorthernLimit) - 1 if !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) || !path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) { return fmt.Errorf("%w: global limits must span start through end", ErrInvalidStarOccultationPath) } - return nil + if err := validateOccultationFootprints( + ErrInvalidStarOccultationPath, "stellar", path.Footprints, path.Start.Time, path.End.Time, + ); err != nil { + return err + } + return validateOccultationFootprints( + ErrInvalidStarOccultationPath, "stellar compact band", path.BandFootprints, path.Start.Time, path.End.Time, + ) } func validateStarOccultationPathPoint(name string, point moon.OccultationPathPoint) error { + return validateOccultationPathPoint(ErrInvalidStarOccultationPath, name, point) +} + +func validateOccultationPathPoint(base error, name string, point moon.OccultationPathPoint) error { if point.Time.IsZero() { - return fmt.Errorf("%w: %s time is required", ErrInvalidStarOccultationPath, name) + return fmt.Errorf("%w: %s time is required", base, name) } if !starOccultationFinite(point.Longitude) || point.Longitude < -180 || point.Longitude > 180 { - return fmt.Errorf("%w: %s longitude must be in [-180, 180]", ErrInvalidStarOccultationPath, name) + return fmt.Errorf("%w: %s longitude must be in [-180, 180]", base, name) } if !starOccultationFinite(point.Latitude) || point.Latitude < -90 || point.Latitude > 90 { - return fmt.Errorf("%w: %s latitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name) + return fmt.Errorf("%w: %s latitude must be in [-90, 90]", base, name) } if !starOccultationFinite(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 { - return fmt.Errorf("%w: %s Moon altitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name) + return fmt.Errorf("%w: %s Moon altitude must be in [-90, 90]", base, name) } if !starOccultationFinite(point.WidthKM) || point.WidthKM < 0 { - return fmt.Errorf("%w: %s width must be finite and non-negative", ErrInvalidStarOccultationPath, name) + return fmt.Errorf("%w: %s width must be finite and non-negative", base, name) + } + return nil +} + +func validateOccultationContours( + base error, + name string, + contours [][]moon.OccultationPathPoint, + start, end time.Time, +) error { + for contourIndex, contour := range contours { + if len(contour) < 2 { + return fmt.Errorf("%w: %s[%d] must contain at least two points", base, name, contourIndex) + } + for pointIndex, point := range contour { + if err := validateOccultationPathPoint( + base, fmt.Sprintf("%s[%d][%d]", name, contourIndex, pointIndex), point, + ); err != nil { + return err + } + if point.Time.Before(start) || point.Time.After(end) { + return fmt.Errorf("%w: %s point is outside event time", base, name) + } + if pointIndex > 0 && !point.Time.After(contour[pointIndex-1].Time) { + return fmt.Errorf("%w: %s[%d] times must be strictly increasing", base, name, contourIndex) + } + } + } + return nil +} + +// validateOccultationGreatestTimeContours 校验等时线:支路至少两点、点时刻必须等于该支路的 +// 时刻取值(1 ms 容差覆盖 TT 往返),其余字段与普通路径点同口径。 +func validateOccultationGreatestTimeContours(base error, contours []moon.OccultationGreatestTimeContour) error { + for contourIndex, contour := range contours { + if !starOccultationFinite(contour.JDE) || contour.JDE == 0 { + return fmt.Errorf("%w: greatest-time contour %d JDE is required", base, contourIndex) + } + if contour.Time.IsZero() { + return fmt.Errorf("%w: greatest-time contour %d time is required", base, contourIndex) + } + for segmentIndex, segment := range contour.Segments { + if len(segment) < 2 { + return fmt.Errorf("%w: greatest-time contour %d segment %d must contain at least two points", base, contourIndex, segmentIndex) + } + name := fmt.Sprintf("greatest-time contour %d segment %d", contourIndex, segmentIndex) + for pointIndex, point := range segment { + if err := validateOccultationPathPoint(base, fmt.Sprintf("%s point %d", name, pointIndex), point); err != nil { + return err + } + if delta := point.Time.Sub(contour.Time); delta > time.Millisecond || delta < -time.Millisecond { + return fmt.Errorf("%w: %s point %d time does not match the contour instant", base, name, pointIndex) + } + } + } } return nil } @@ -122,6 +210,7 @@ func starOccultationSVGLayoutFor( options StarOccultationSVGOptions, headerBottom float64, projection svgmap.Projection, + center svgmap.GeoPoint, ) starOccultationSVGLayout { width := float64(options.Width) height := float64(options.Height) @@ -134,9 +223,10 @@ func starOccultationSVGLayoutFor( mapWidth = width - 2*margin - gap - panelWidth } contentTop := headerBottom + 28 - footerSpace := 82.0 + // 底部预留必须同时容纳图例行、页脚正文与它们之间的空隙,否则小画布上图例与页脚会同高叠印。 + footerSpace := 104.0 if projection != svgmap.ProjectionEquirectangular { - footerSpace = 116 + footerSpace = 128 } availableHeight := math.Max(110, height-contentTop-footerSpace) mapHeight := math.Min(mapWidth/2, availableHeight) @@ -149,6 +239,11 @@ func starOccultationSVGLayoutFor( mapWidth = math.Min(mapWidth, 2*mapHeight) } mapY := contentTop + math.Max(0, (availableHeight-mapHeight)/2) + footerY := height - 54 + // 图例最多两行(occultationLegendRowHeight),页脚必须落在第二行下方。 + if legendY := mapY + mapHeight + 30; footerY < legendY+2*occultationLegendRowHeight { + footerY = legendY + 2*occultationLegendRowHeight + } return starOccultationSVGLayout{ width: width, height: height, @@ -160,8 +255,9 @@ func starOccultationSVGLayoutFor( panelX: margin + mapWidth + gap, panelY: mapY, panelWidth: panelWidth, - footerY: height - 54, + footerY: footerY, projection: projection, + center: center, } } @@ -177,6 +273,9 @@ func (layout starOccultationSVGLayout) mapFrame() svgmap.Frame { Width: layout.mapWidth, Height: layout.mapHeight, Projection: layout.projection, + // 正射球面必须给出视点,否则会退回 (0°,0°) 而完全不对准事件。 + CenterLongitude: layout.center.Longitude, + CenterLatitude: layout.center.Latitude, } } @@ -189,6 +288,16 @@ func resolveStarOccultationMapProjection(path moon.StarOccultationPath, requeste maximumLatitude = math.Max(maximumLatitude, point.Latitude) } } + for _, footprints := range [][]moon.OccultationFootprint{path.Footprints, path.BandFootprints} { + for _, footprint := range footprints { + for _, polygon := range footprint.Polygons { + for _, point := range polygon { + minimumLatitude = math.Min(minimumLatitude, point.Latitude) + maximumLatitude = math.Max(maximumLatitude, point.Latitude) + } + } + } + } return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude) } @@ -225,16 +334,16 @@ func starOccultationPathSegments(points []moon.OccultationPathPoint) [][]moon.Oc func starOccultationPathSegmentsForProjection( points []moon.OccultationPathPoint, - projection svgmap.Projection, + view svgmap.ClipView, ) [][]moon.OccultationPathPoint { - if projection == svgmap.ProjectionEquirectangular { + if view.Projection == svgmap.ProjectionEquirectangular { return starOccultationPathSegments(points) } geographic := make([]svgmap.GeoPoint, len(points)) for index, point := range points { geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} } - clipped := svgmap.PolylineSegments(geographic, projection) + clipped := svgmap.PolylineSegments(geographic, view) result := make([][]moon.OccultationPathPoint, 0, len(clipped)) for _, segment := range clipped { converted := make([]moon.OccultationPathPoint, len(segment)) @@ -246,6 +355,36 @@ func starOccultationPathSegmentsForProjection( return result } +func starOccultationBoundarySegmentsForProjection( + points []moon.OccultationPathPoint, + view svgmap.ClipView, +) [][]moon.OccultationPathPoint { + ranges := occultationgeo.ContinuousBoundaryRanges(points) + result := make([][]moon.OccultationPathPoint, 0, len(ranges)) + for _, sampleRange := range ranges { + current := points[sampleRange.Start:sampleRange.End] + if len(current) == 1 { + current = []moon.OccultationPathPoint{current[0], current[0]} + } + if view.Projection == svgmap.ProjectionEquirectangular { + result = append(result, starOccultationPathSegments(current)...) + continue + } + geographic := make([]svgmap.GeoPoint, len(current)) + for index, point := range current { + geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + for _, segment := range svgmap.PolylineSegments(geographic, view) { + converted := make([]moon.OccultationPathPoint, len(segment)) + for index, point := range segment { + converted[index] = moon.OccultationPathPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + result = append(result, converted) + } + } + return result +} + func starOccultationAntimeridianCrossing(a, b moon.OccultationPathPoint) (float64, float64, bool) { if math.Abs(b.Longitude-a.Longitude) <= 180 { return 0, 0, false @@ -289,12 +428,13 @@ func starOccultationInterpolatePathPoint(a, b moon.OccultationPathPoint, fractio func starOccultationBandSegments( northern, southern []moon.OccultationPathPoint, ) [][]starOccultationGeoPoint { - return starOccultationBandFragments(northern, southern, svgmap.ProjectionEquirectangular) + return starOccultationBandFragments(northern, southern, + svgmap.ClipView{Projection: svgmap.ProjectionEquirectangular}) } func starOccultationBandFragments( northern, southern []moon.OccultationPathPoint, - projection svgmap.Projection, + view svgmap.ClipView, ) [][]starOccultationGeoPoint { count := len(northern) if len(southern) < count { @@ -303,32 +443,68 @@ func starOccultationBandFragments( if count < 2 { return nil } - polygon := make([]starOccultationGeoPoint, 0, 2*count) - for _, point := range northern[:count] { - polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude}) - } - for index := count - 1; index >= 0; index-- { - point := southern[index] - polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude}) - } - geographic := make([]svgmap.GeoPoint, len(polygon)) - for index, point := range polygon { - geographic[index] = svgmap.GeoPoint{Longitude: point.longitude, Latitude: point.latitude} - } - fragments := svgmap.PolygonFragments(geographic, projection) - segments := make([][]starOccultationGeoPoint, 0, len(fragments)) - for _, fragment := range fragments { - converted := make([]starOccultationGeoPoint, len(fragment)) - for index, point := range fragment { - converted[index] = starOccultationGeoPoint{longitude: point.Longitude, latitude: point.Latitude} + segments := make([][]starOccultationGeoPoint, 0, 2) + for _, sampleRange := range occultationgeo.ContinuousPairedBoundaryRanges(northern[:count], southern[:count]) { + if sampleRange.End-sampleRange.Start < 2 { + continue } - if math.Abs(starOccultationPolygonArea(converted)) > 1e-9 { - segments = append(segments, converted) + polygon := make([]starOccultationGeoPoint, 0, 2*(sampleRange.End-sampleRange.Start)) + for _, point := range northern[sampleRange.Start:sampleRange.End] { + polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude}) + } + for index := sampleRange.End - 1; index >= sampleRange.Start; index-- { + point := southern[index] + polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude}) + } + geographic := make([]svgmap.GeoPoint, len(polygon)) + for index, point := range polygon { + geographic[index] = svgmap.GeoPoint{Longitude: point.longitude, Latitude: point.latitude} + } + for _, fragment := range svgmap.PolygonFragments(geographic, view) { + converted := make([]starOccultationGeoPoint, len(fragment)) + for index, point := range fragment { + converted[index] = starOccultationGeoPoint{longitude: point.Longitude, latitude: point.Latitude} + } + if math.Abs(starOccultationPolygonArea(converted)) > 1e-9 { + segments = append(segments, converted) + } } } return segments } +func starOccultationBandEndpointSections( + northern, southern []moon.OccultationPathPoint, + view svgmap.ClipView, +) [][]starOccultationGeoPoint { + count := len(northern) + if len(southern) < count { + count = len(southern) + } + if count == 0 { + return nil + } + sections := make([][]starOccultationGeoPoint, 0, 2) + for _, sampleRange := range occultationgeo.ContinuousPairedBoundaryRanges(northern[:count], southern[:count]) { + if sampleRange.End-sampleRange.Start != 1 { + continue + } + index := sampleRange.Start + line := []svgmap.GeoPoint{ + {Longitude: northern[index].Longitude, Latitude: northern[index].Latitude}, + {Longitude: southern[index].Longitude, Latitude: southern[index].Latitude}, + } + for _, segment := range svgmap.PolylineSegments(line, view) { + converted := make([]starOccultationGeoPoint, len(segment)) + for pointIndex, point := range segment { + converted[pointIndex] = starOccultationGeoPoint{longitude: point.Longitude, latitude: point.Latitude} + } + sections = append(sections, converted) + } + } + return sections +} + func legacyStarOccultationBandSegments(polygon []starOccultationGeoPoint) [][]starOccultationGeoPoint { polygon = starOccultationUnwrapPolygon(polygon) minimumLongitude, maximumLongitude := polygon[0].longitude, polygon[0].longitude @@ -424,16 +600,21 @@ func starOccultationPolygonArea(points []starOccultationGeoPoint) float64 { return area / 2 } -func starOccultationSVGHeaderLines(path moon.StarOccultationPath, options StarOccultationSVGOptions) []string { +func starOccultationSVGHeaderLines( + path moon.StarOccultationPath, + options StarOccultationSVGOptions, + flags occultationSVGTextFlags, +) []string { lines := make([]string, 0, 4) for _, item := range []struct { text string fontSize float64 + custom bool }{ - {starOccultationSVGSummaryText(path, options), 14}, - {starOccultationSVGGreatestText(path, options), 13}, + {starOccultationSVGSummaryText(path, options), 14, flags.summaryText}, + {starOccultationSVGGreatestText(path, options), 13, flags.greatestText}, } { - lines = append(lines, starOccultationWrapText(item.text, float64(options.Width)-80, item.fontSize)...) + lines = append(lines, occultationWrappedTextLines(item.text, item.custom, float64(options.Width)-80, item.fontSize)...) } return lines } @@ -483,10 +664,15 @@ func starOccultationSVGGreatestText(path moon.StarOccultationPath, options StarO } coordinates := starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude) altitude := starOccultationFormatSignedDegree(path.Greatest.MoonAltitude) - if options.Language == starOccultationSVGLanguageEnglish { - return fmt.Sprintf("Greatest point %s | path width %.1f km | Moon altitude %s", coordinates, path.Greatest.WidthKM, altitude) + // 带宽与行星图、详细版同口径:取南北限的地面间距。Greatest.WidthKM 是另一种构造,两者不可互换。 + partialWidth := path.GreatestLimitSeparationKM + if partialWidth <= 0 { + partialWidth = path.Greatest.WidthKM } - return fmt.Sprintf("掩甚点 %s | 掩带宽 %.1f km | 月球高度 %s", coordinates, path.Greatest.WidthKM, altitude) + if options.Language == starOccultationSVGLanguageEnglish { + return fmt.Sprintf("Greatest point %s | path width %.1f km | Moon altitude %s", coordinates, partialWidth, altitude) + } + return fmt.Sprintf("掩甚点 %s | 掩带宽 %.1f km | 月球高度 %s", coordinates, partialWidth, altitude) } func starOccultationSVGMapTitle(options StarOccultationSVGOptions) string { @@ -527,6 +713,8 @@ func starOccultationProjectionLabel(projection MapProjection, language string) s return "North-polar azimuthal equidistant projection" case MapProjectionSouthPolar: return "South-polar azimuthal equidistant projection" + case MapProjectionOrthographic: + return "Orthographic globe projection" default: return "Equirectangular projection" } @@ -536,6 +724,8 @@ func starOccultationProjectionLabel(projection MapProjection, language string) s return "北极方位等距投影" case MapProjectionSouthPolar: return "南极方位等距投影" + case MapProjectionOrthographic: + return "正射球面投影" default: return "等经纬投影" } @@ -595,6 +785,33 @@ func starOccultationSameDate(first, second time.Time) bool { return y1 == y2 && m1 == m2 && d1 == d2 } +// occultationTitleText 截断调用方给出的单行标题:自动文案不动,避免改变既有产物。 +func occultationTitleText(value string, custom bool, maxWidth, fontSize float64) string { + // 标题是单行固定槽位,放不下就截断;默认标题本来就放得下时结果不变。 + return svgchart.EllipsizeText(value, maxWidth, fontSize) +} + +// occultationWrappedTextLines 折行一段文本:调用方自定义文本按保守字宽折行,自动生成文本沿用既有口径,改口径会移动断行位置。 +func occultationWrappedTextLines(value string, custom bool, maxWidth, fontSize float64) []string { + if custom { + return svgchart.WrapText(value, maxWidth, fontSize) + } + return starOccultationWrapText(value, maxWidth, fontSize) +} + +// starOccultationSVGHeaderLineLimit 返回页眉行数上限:地图下限 110、上方空隙 28 与页脚预留之外的余高才轮到页眉。 +func starOccultationSVGHeaderLineLimit(options StarOccultationSVGOptions, projection svgmap.Projection) int { + footerSpace := 104.0 + if projection != svgmap.ProjectionEquirectangular { + footerSpace = 128 + } + limit := int(math.Floor((float64(options.Height) - 210 - footerSpace) / 19)) + if limit < 1 { + return 1 + } + return limit +} + func starOccultationWrapText(value string, maxWidth, fontSize float64) []string { value = strings.TrimSpace(value) if value == "" { diff --git a/moon/svg/occultation_moon_phase.go b/moon/svg/occultation_moon_phase.go new file mode 100644 index 0000000..55cff68 --- /dev/null +++ b/moon/svg/occultation_moon_phase.go @@ -0,0 +1,105 @@ +package svg + +import ( + "fmt" + "html" + "math" + "strings" + "time" + + "b612.me/astro/moon" +) + +// localOccultationMoonAppearance contains the apparent lunar illumination used +// by the fixed-site occultation diagrams. The limb angle is topocentric so the +// rendered bright side follows the same observer geometry as the occultation. +type localOccultationMoonAppearance struct { + illumination float64 + brightLimbPA float64 +} + +func localOccultationMoonAppearanceAt(date time.Time, observer moon.Observer) localOccultationMoonAppearance { + illumination := moon.Phase(date) + if !starOccultationFinite(illumination) { + illumination = 1 + } + illumination = math.Max(0, math.Min(1, illumination)) + brightLimbPA := moon.TopocentricBrightLimbPositionAngle( + date, observer.Longitude, observer.Latitude, observer.Height, + ) + if !starOccultationFinite(brightLimbPA) { + brightLimbPA = 0 + } + return localOccultationMoonAppearance{ + illumination: illumination, + brightLimbPA: brightLimbPA, + } +} + +// writeLocalOccultationMoon draws a textured lunar disk with a phase-aware +// terminator. clipID is unique within the SVG document because each stage +// can have a different event time and therefore a different lunar phase. +func writeLocalOccultationMoon( + b *strings.Builder, + cx, cy, radius float64, + class string, + appearance localOccultationMoonAppearance, + clipID string, +) { + if !starOccultationFinite(radius) || radius <= 0 { + return + } + illumination := math.Max(0, math.Min(1, appearance.illumination)) + brightLimbPA := appearance.brightLimbPA + if !starOccultationFinite(brightLimbPA) { + brightLimbPA = 0 + } + fmt.Fprintf(b, ``, + html.EscapeString(class), illumination, brightLimbPA) + if illumination < 1-1e-6 { + fmt.Fprintf(b, ``, cx, cy, radius) + } + if illumination > 1e-6 { + if illumination >= 1-1e-6 { + writeLocalOccultationMoonTexture(b, cx, cy, radius) + } else { + path := localOccultationMoonPhasePath(radius, illumination) + rotation := localOccultationMoonSVGRotation(brightLimbPA) + fmt.Fprintf(b, ``, + html.EscapeString(clipID), path, cx, cy, rotation) + fmt.Fprintf(b, ``, html.EscapeString(clipID)) + writeLocalOccultationMoonTexture(b, cx, cy, radius) + b.WriteString(``) + } + } + fmt.Fprintf(b, ``, cx, cy, radius) + b.WriteString(``) +} + +func writeLocalOccultationMoonTexture(b *strings.Builder, cx, cy, radius float64) { + fmt.Fprintf(b, ``, + cx-radius, cy-radius, radius*2, radius*2) +} + +// The chart uses north-up and east-left coordinates. SVG rotations are +// clockwise from +X, so a celestial position angle of 0 points upward and 90 +// points left after this conversion. +func localOccultationMoonSVGRotation(brightLimbPA float64) float64 { + return -brightLimbPA - 90 +} + +// localOccultationMoonPhasePath returns the illuminated portion of a unit +// projected lunar disk. The outer arc is the visible limb; the inner ellipse +// is the projected terminator. The +X side is the bright side before rotation. +func localOccultationMoonPhasePath(radius, illumination float64) string { + cosPhase := 2*illumination - 1 + terminatorRadius := radius * math.Abs(cosPhase) + sweep := 1 + if cosPhase < 0 { + sweep = 0 + } + return fmt.Sprintf( + "M 0 %.3f A %.3f %.3f 0 0 1 0 %.3f A %.3f %.3f 0 0 %d 0 %.3f Z", + -radius, radius, radius, radius, terminatorRadius, radius, sweep, -radius, + ) +} diff --git a/moon/svg/occultation_panel_fit_test.go b/moon/svg/occultation_panel_fit_test.go new file mode 100644 index 0000000..abdc82e --- /dev/null +++ b/moon/svg/occultation_panel_fit_test.go @@ -0,0 +1,47 @@ +package svg + +import ( + "errors" + "testing" +) + +// 数据块行距不足时详细版必须拒绝画布,而不是画出压叠的面板文字。 +func TestOccultationDetailedSVGRejectsCanvasesThatCrowdPanels(t *testing.T) { + for _, testCase := range []struct { + name string + width, height int + rejected bool + }{ + {name: "landscape too short", width: 900, height: 400, rejected: true}, + {name: "landscape borderline", width: 900, height: 560, rejected: true}, + {name: "portrait too narrow", width: 600, height: 800, rejected: true}, + {name: "smallest landscape", width: 800, height: 600}, + {name: "default page", width: 1000, height: 1414}, + {name: "landscape page", width: 1414, height: 1000}, + } { + for name, render := range map[string]func() (string, error){ + "star": func() (string, error) { + return StarOccultationDetailedSVG(occultationTestStarPath(t), hr4799StarCoordinate(), + OccultationDetailedSVGOptions{Width: testCase.width, Height: testCase.height}) + }, + "planet": func() (string, error) { + return PlanetOccultationDetailedSVG(occultationTestSaturnPath(t), + OccultationDetailedSVGOptions{Width: testCase.width, Height: testCase.height}) + }, + } { + document, err := render() + if testCase.rejected { + if !errors.Is(err, ErrInvalidOccultationDetailedSVGOptions) { + t.Fatalf("%s %s: err = %v, want ErrInvalidOccultationDetailedSVGOptions", name, testCase.name, err) + } + continue + } + if err != nil { + t.Fatalf("%s %s: %v", name, testCase.name, err) + } + if len(document) == 0 { + t.Fatalf("%s %s: empty document", name, testCase.name) + } + } + } +} diff --git a/moon/svg/occultation_planet.go b/moon/svg/occultation_planet.go index 5edd632..cf5c8ff 100644 --- a/moon/svg/occultation_planet.go +++ b/moon/svg/occultation_planet.go @@ -3,11 +3,12 @@ package svg import ( "errors" "fmt" - "html" "math" "strings" "time" + "b612.me/astro/internal/geodata" + "b612.me/astro/internal/occultationgeo" "b612.me/astro/internal/svgmap" "b612.me/astro/moon" ) @@ -81,33 +82,30 @@ func PlanetOccultationPathSVG( starShape.TargetID = targetID } options.Projection = MapProjection(resolvePlanetOccultationMapProjection(path, options.Projection)) + // 默认文案会先写回 options,必须在填充之前记下调用方真正给出的字段。 + flags := starOccultationSVGTextFlags(options) options = planetOccultationSVGDefaults(path, options) - return renderOccultationPathSVG(starShape, &path, options), nil + diagram, err := renderOccultationPathSVG(starShape, &path, options, flags) + if err != nil { + // 几何引擎失败不是数据格式错误,不能裹成 ErrInvalidPlanetOccultationPath 哨兵。 + return "", fmt.Errorf("planetary occultation SVG band geometry: %w", err) + } + return diagram, nil } func resolvePlanetOccultationMapProjection( path moon.PlanetOccultationPath, requested MapProjection, ) svgmap.Projection { - minimumLatitude := path.Greatest.Latitude - maximumLatitude := path.Greatest.Latitude - for _, series := range [][]moon.OccultationPathPoint{ - path.CenterLine, path.NorthernLimit, path.SouthernLimit, - path.NorthernTotalLimit, path.SouthernTotalLimit, - } { - for _, point := range series { - minimumLatitude = math.Min(minimumLatitude, point.Latitude) - maximumLatitude = math.Max(maximumLatitude, point.Latitude) + minimumLatitude, maximumLatitude := planetOccultationBandLatitudeRange(path) + // 掩带包住极点时等经纬会把它拉成横贯上下边缘的长条,必须改用极区图; + // 判据要看带子本身的纬度范围,掩甚点在中纬也可能绕极。 + if requested == MapProjectionAuto { + if maximumLatitude >= occultationPolarBandReach { + return svgmap.ProjectionNorthPolar } - } - for _, footprints := range [][]moon.PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} { - for _, footprint := range footprints { - for _, polygon := range footprint.Polygons { - for _, point := range polygon { - minimumLatitude = math.Min(minimumLatitude, point.Latitude) - maximumLatitude = math.Max(maximumLatitude, point.Latitude) - } - } + if minimumLatitude <= -occultationPolarBandReach { + return svgmap.ProjectionSouthPolar } } return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude) @@ -123,6 +121,7 @@ func planetOccultationStarShape(path moon.PlanetOccultationPath, targetID string CenterLine: path.CenterLine, NorthernLimit: path.NorthernLimit, SouthernLimit: path.SouthernLimit, + RiseSetCurves: path.RiseSetCurves, Step: path.Step, TargetSpacingKM: path.TargetSpacingKM, } @@ -135,13 +134,33 @@ func validatePlanetOccultationPath(path moon.PlanetOccultationPath) error { if err := validateStarOccultationPath(planetOccultationStarShape(path, path.TargetID)); err != nil { return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err) } + if err := validateOccultationGreatestTimeContours(ErrInvalidPlanetOccultationPath, path.GreatestTimeContours); err != nil { + return err + } if !path.HasTotalBand { if path.TotalComplete || !path.TotalStart.Time.IsZero() || !path.TotalEnd.Time.IsZero() || len(path.NorthernTotalLimit) != 0 || len(path.SouthernTotalLimit) != 0 || - len(path.TotalFootprints) != 0 || path.GreatestTotalWidthKM != 0 { + len(path.TotalFootprints) != 0 || len(path.TotalBandFootprints) != 0 || + len(path.TotalBandContours) != 0 || len(path.TotalRiseSetCurves) != 0 || + path.GreatestTotalWidthKM != 0 { return fmt.Errorf("%w: total-band fields require HasTotalBand", ErrInvalidPlanetOccultationPath) } - return validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time) + if err := validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil { + return err + } + if err := validatePlanetOccultationContours( + "partial band contours", path.PartialBandContours, path.Start.Time, path.End.Time, + ); err != nil { + return err + } + if err := validatePlanetOccultationContours( + "partial visibility contours", path.PartialVisibilityContours, path.Start.Time, path.End.Time, + ); err != nil { + return err + } + return validatePlanetOccultationFootprints( + "partial compact band", path.PartialBandFootprints, path.Start.Time, path.End.Time, + ) } if !path.TotalComplete { return fmt.Errorf("%w: global total-occultation band is incomplete", ErrInvalidPlanetOccultationPath) @@ -163,13 +182,51 @@ func validatePlanetOccultationPath(path moon.PlanetOccultationPath) error { if err := validatePlanetOccultationTotalLimits(path); err != nil { return err } + if err := occultationgeo.ValidateRiseSetCurves(path.TotalRiseSetCurves, path.TotalStart.Time, path.TotalEnd.Time); err != nil { + return fmt.Errorf("%w: invalid total rise/set curves: %v", ErrInvalidPlanetOccultationPath, err) + } + if err := validatePlanetOccultationContours( + "partial band contours", path.PartialBandContours, path.Start.Time, path.End.Time, + ); err != nil { + return err + } + if err := validatePlanetOccultationContours( + "partial visibility contours", path.PartialVisibilityContours, path.Start.Time, path.End.Time, + ); err != nil { + return err + } + if err := validatePlanetOccultationContours( + "total band contours", path.TotalBandContours, path.TotalStart.Time, path.TotalEnd.Time, + ); err != nil { + return err + } + if err := validatePlanetOccultationContours( + "total visibility contours", path.TotalVisibilityContours, path.TotalStart.Time, path.TotalEnd.Time, + ); err != nil { + return err + } if err := validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil { return err } + if err := validatePlanetOccultationFootprints( + "partial compact band", path.PartialBandFootprints, path.Start.Time, path.End.Time, + ); err != nil { + return err + } if err := validatePlanetOccultationFootprints("total", path.TotalFootprints, path.TotalStart.Time, path.TotalEnd.Time); err != nil { return err } - return nil + return validatePlanetOccultationFootprints( + "total compact band", path.TotalBandFootprints, path.TotalStart.Time, path.TotalEnd.Time, + ) +} + +func validatePlanetOccultationContours( + name string, + contours [][]moon.OccultationPathPoint, + start, end time.Time, +) error { + return validateOccultationContours(ErrInvalidPlanetOccultationPath, name, contours, start, end) } func validatePlanetOccultationFootprints( @@ -177,34 +234,17 @@ func validatePlanetOccultationFootprints( footprints []moon.PlanetOccultationFootprint, start, end time.Time, ) error { - for footprintIndex, footprint := range footprints { - if footprint.Time.Before(start) || footprint.Time.After(end) { - return fmt.Errorf("%w: %s footprint[%d] time must be inside its contact interval", - ErrInvalidPlanetOccultationPath, name, footprintIndex) - } - if footprintIndex > 0 && !footprint.Time.After(footprints[footprintIndex-1].Time) { - return fmt.Errorf("%w: %s footprint times must be strictly increasing", - ErrInvalidPlanetOccultationPath, name) - } - if len(footprint.Polygons) == 0 { - return fmt.Errorf("%w: %s footprint[%d] must contain a polygon", - ErrInvalidPlanetOccultationPath, name, footprintIndex) - } - for polygonIndex, polygon := range footprint.Polygons { - if len(polygon) < 3 { - return fmt.Errorf("%w: %s footprint[%d].polygon[%d] must contain at least three points", - ErrInvalidPlanetOccultationPath, name, footprintIndex, polygonIndex) - } - for pointIndex, point := range polygon { - pointName := fmt.Sprintf("%s footprint[%d].polygon[%d][%d]", name, footprintIndex, polygonIndex, pointIndex) - if err := validateStarOccultationPathPoint(pointName, point); err != nil { - return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err) - } - if !point.Time.Equal(footprint.Time) { - return fmt.Errorf("%w: %s time must match its footprint", ErrInvalidPlanetOccultationPath, pointName) - } - } - } + return validateOccultationFootprints(ErrInvalidPlanetOccultationPath, name, footprints, start, end) +} + +func validateOccultationFootprints( + baseError error, + name string, + footprints []moon.OccultationFootprint, + start, end time.Time, +) error { + if err := occultationgeo.ValidateFootprints(footprints, start, end); err != nil { + return fmt.Errorf("%w: %s %v", baseError, name, err) } return nil } @@ -307,18 +347,24 @@ func planetOccultationSVGSummaryText(path moon.PlanetOccultationPath, options Pl func planetOccultationSVGGreatestText(path moon.PlanetOccultationPath, language string) string { coordinates := starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude) + // 带宽用南北限的地面间距。Greatest.WidthKM 是另一种构造(接触锥可见弧上横向偏移的极差), + // 数值可以远大于实际带宽,两者不可互换。 + partialWidth := path.GreatestLimitSeparationKM + if partialWidth <= 0 { + partialWidth = path.Greatest.WidthKM + } if path.HasTotalBand { if language == starOccultationSVGLanguageEnglish { return fmt.Sprintf("Greatest point %s | partial-band width %.1f km | total-band width %.1f km", - coordinates, path.Greatest.WidthKM, path.GreatestTotalWidthKM) + coordinates, partialWidth, path.GreatestTotalWidthKM) } return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km | 全掩带宽 %.1f km", - coordinates, path.Greatest.WidthKM, path.GreatestTotalWidthKM) + coordinates, partialWidth, path.GreatestTotalWidthKM) } if language == starOccultationSVGLanguageEnglish { - return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, path.Greatest.WidthKM) + return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, partialWidth) } - return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, path.Greatest.WidthKM) + return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, partialWidth) } func planetOccultationSVGFooter(path moon.PlanetOccultationPath, options PlanetOccultationSVGOptions) string { @@ -345,86 +391,310 @@ func writePlanetOccultationMap( starShape moon.StarOccultationPath, layout starOccultationSVGLayout, options StarOccultationSVGOptions, -) { +) error { layout.mapFrame().WriteOcean(b) writeStarOccultationGraticule(b, layout) writeStarOccultationLand(b, layout) - if len(path.PartialFootprints) > 0 { - writePlanetOccultationFootprintSweep(b, path.PartialFootprints, layout, - "partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34) - } else { - writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout, - "partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34) + partial, err := writePlanetOccultationPartialBand(b, path, layout) + if err != nil { + return err } + total := planetOccultationBand{} if path.HasTotalBand { - if len(path.TotalFootprints) > 0 { - writePlanetOccultationFootprintSweep(b, path.TotalFootprints, layout, - "total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72) - } else { - writeOccultationBand(b, path.NorthernTotalLimit, path.SouthernTotalLimit, layout, - "total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72) + total, err = writePlanetOccultationTotalBand(b, path, layout) + if err != nil { + return err } } - if len(path.PartialFootprints) == 0 { - writeStarOccultationGeoLine(b, path.NorthernLimit, layout, "northern-limit", "#a66f18", 1.25, "") - writeStarOccultationGeoLine(b, path.SouthernLimit, layout, "southern-limit", "#a66f18", 1.25, "") + if !partial.draw.compact && len(path.PartialFootprints) == 0 { + writeStarOccultationGeoLine(b, path.NorthernLimit, layout, + occultationLineStyle{className: "northern-limit", color: "#a66f18", strokeWidth: 1.25, boundary: true}) + writeStarOccultationGeoLine(b, path.SouthernLimit, layout, + occultationLineStyle{className: "southern-limit", color: "#a66f18", strokeWidth: 1.25, boundary: true}) } - if path.HasTotalBand && len(path.TotalFootprints) == 0 { - writeStarOccultationGeoLine(b, path.NorthernTotalLimit, layout, "northern-total-limit", "#355878", 1.3, "") - writeStarOccultationGeoLine(b, path.SouthernTotalLimit, layout, "southern-total-limit", "#355878", 1.3, "") + if path.HasTotalBand && !total.draw.compact && len(path.TotalFootprints) == 0 { + writeStarOccultationGeoLine(b, path.NorthernTotalLimit, layout, + occultationLineStyle{className: "northern-total-limit", color: "#355878", strokeWidth: 1.3, boundary: true}) + writeStarOccultationGeoLine(b, path.SouthernTotalLimit, layout, + occultationLineStyle{className: "southern-total-limit", color: "#355878", strokeWidth: 1.3, boundary: true}) } - writeStarOccultationGeoLine(b, starShape.CenterLine, layout, "center-line", "#59676b", 1.6, "5 4") + // Keep the physical phase curves above both translucent footprint bands; + // do not turn them into a closed static-band outline. + writeOccultationRiseSetCurves(b, path.RiseSetCurves, layout, "occultation") + writeOccultationRiseSetCurves(b, path.TotalRiseSetCurves, layout, "occultation-total") + writeOccultationHorizonConnectors( + b, partial.footprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, layout, + false, + ) + // 事件标记先占位、后绘制:三族标签共用一张占位表,后一族必须绕开先放置的标签。 + placer := &occultationLabelPlacer{} + eventMarkers := planetOccultationEventMarkerDraws(path, layout, options.Language, placer) + writeOccultationGreatestTimeContours(b, path.GreatestTimeContours, layout, options, placer) + writeStarOccultationGeoLine(b, starShape.CenterLine, layout, + occultationLineStyle{className: "center-line", color: "#59676b", strokeWidth: 1.6, dash: "5 4"}) writeStarOccultationVisibleCenterLine(b, starShape.CenterLine, layout) excluded := []time.Time{path.Start.Time, path.End.Time} if path.HasTotalBand { excluded = append(excluded, path.TotalStart.Time, path.TotalEnd.Time) } - writeOccultationTimeMarkers(b, starShape.CenterLine, layout, options, excluded, path.Greatest.Time) - writePlanetOccultationEventMarkers(b, path, layout, options.Language) + writeOccultationTimeMarkers(b, starShape.CenterLine, layout, options, excluded, path.Greatest.Time, placer) + writePlanetOccultationEventMarkers(b, path, eventMarkers) layout.mapFrame().WriteFrame(b) - writePlanetOccultationLegend(b, layout, options.Language, path.HasTotalBand) + writePlanetOccultationLegend( + b, layout, options.Language, path.HasTotalBand, len(path.RiseSetCurves) > 0, + len(path.GreatestTimeContours) > 0, + ) + return nil } -func writePlanetOccultationFootprintSweep( +// planetOccultationBand 是一次行星掩带填充的结果及它采用的足迹。 +type planetOccultationBand struct { + draw occultationBandDraw + footprints []moon.PlanetOccultationFootprint +} + +// writePlanetOccultationPartialBand 绘制偏掩带;没有足迹时退回静态限带。 +func writePlanetOccultationPartialBand( b *strings.Builder, - footprints []moon.PlanetOccultationFootprint, + path moon.PlanetOccultationPath, layout starOccultationSVGLayout, - layerClass, pathClass, color string, - opacity float64, -) { - var geometry strings.Builder - for _, footprint := range footprints { - for _, polygon := range footprint.Polygons { - geographic := make([]svgmap.GeoPoint, len(polygon)) - for index, point := range polygon { - geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} +) (planetOccultationBand, error) { + footprints := path.PartialBandFootprints + compact := len(footprints) > 0 + if len(footprints) == 0 { + footprints = path.PartialFootprints + } + if len(footprints) == 0 { + writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout, + "partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34) + return planetOccultationBand{}, nil + } + draw, err := (occultationFootprintSweep{ + footprints: footprints, + contours: path.PartialBandContours, + visibilityContours: path.PartialVisibilityContours, + northern: path.NorthernLimit, + southern: path.SouthernLimit, + curves: path.RiseSetCurves, + layout: layout, + layerClass: "partial-occultation-band-layer", + pathClass: "occultation-band", + color: "#e0ae43", + opacity: 0.34, + compactBand: compact, + kind: partialOccultationFootprintSweep, + }).write(b) + if err != nil { + return planetOccultationBand{}, err + } + return planetOccultationBand{draw: draw, footprints: footprints}, nil +} + +// writePlanetOccultationTotalBand 绘制全掩带;没有足迹时退回静态内接触限带。 +func writePlanetOccultationTotalBand( + b *strings.Builder, + path moon.PlanetOccultationPath, + layout starOccultationSVGLayout, +) (planetOccultationBand, error) { + footprints := path.TotalBandFootprints + compact := len(footprints) > 0 + if len(footprints) == 0 { + footprints = path.TotalFootprints + } + if len(footprints) == 0 { + writeOccultationBand(b, path.NorthernTotalLimit, path.SouthernTotalLimit, layout, + "total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72) + return planetOccultationBand{}, nil + } + draw, err := (occultationFootprintSweep{ + footprints: footprints, + contours: path.TotalBandContours, + visibilityContours: path.TotalVisibilityContours, + northern: path.NorthernTotalLimit, + southern: path.SouthernTotalLimit, + curves: path.TotalRiseSetCurves, + layout: layout, + layerClass: "total-occultation-band-layer", + pathClass: "total-occultation-band", + color: "#607d98", + opacity: 0.72, + compactBand: compact, + kind: totalOccultationFootprintSweep, + }).write(b) + if err != nil { + return planetOccultationBand{}, err + } + return planetOccultationBand{draw: draw, footprints: footprints}, nil +} + +type occultationFootprintSweepKind uint8 + +const ( + starOccultationFootprintSweep occultationFootprintSweepKind = iota + partialOccultationFootprintSweep + totalOccultationFootprintSweep +) + +// occultationBandSource 说明填充掩带几何的来源。 +type occultationBandSource uint8 + +const ( + occultationBandAbsent occultationBandSource = iota + occultationBandFootprints + occultationBandEnvelope + occultationBandSweep +) + +// occultationBandDraw 报告一次掩带填充的结果。 +type occultationBandDraw struct { + source occultationBandSource + // compact 表示填充由合并后的紧凑带构造,调用方不再叠加限线。 + compact bool +} + +func (draw occultationBandDraw) drawn() bool { return draw.source != occultationBandAbsent } + +// claimBoundary 报告图例能否声明掩带边界:解析包络与直接绘制的瞬时足迹都可以, +// 只有足迹扫掠合并回退不保证边界就是解析边界。 +func (draw occultationBandDraw) claimBoundary() bool { + return draw.source == occultationBandEnvelope || draw.source == occultationBandFootprints +} + +// occultationFootprintSweep 是一次掩带填充需要的几何输入与画布样式。 +type occultationFootprintSweep struct { + footprints []moon.PlanetOccultationFootprint + contours [][]moon.OccultationPathPoint + visibilityContours [][]moon.OccultationPathPoint + northern, southern []moon.OccultationPathPoint + curves []moon.OccultationRiseSetCurve + layout starOccultationSVGLayout + layerClass string + pathClass string + color string + opacity float64 + compactBand bool + kind occultationFootprintSweepKind +} + +// write 写出一个复合填充路径;几何引擎报错时返回错误,不退回原始瞬时足迹。 +func (s occultationFootprintSweep) write(b *strings.Builder) (occultationBandDraw, error) { + rings, source, err := s.resolveRings() + if err != nil { + return occultationBandDraw{}, err + } + draw := occultationBandDraw{compact: s.compactBand && len(rings) > 0} + if len(rings) == 0 { + return draw, nil + } + draw.source = source + stroke, strokeWidth := s.outlineStyle(source) + fmt.Fprintf(b, ``) + return draw, nil +} + +// resolveRings 返回裁剪到画布、定向并简化后的填充环。 +func (s occultationFootprintSweep) resolveRings() ([][]occultationScreenPoint, occultationBandSource, error) { + source := occultationBandFootprints + var polygons [][]geodata.GeoPoint + if s.compactBand { + merged, authoritative, err := s.mergedBandPolygons() + if err != nil { + return nil, source, err + } + if len(merged) == 0 { + return nil, source, errors.New("occultation band geometry produced no polygon") + } + polygons = merged + source = occultationBandSweep + if authoritative { + source = occultationBandEnvelope + } + } else { + polygons = planetOccultationFootprintPolygons(s.footprints) + } + rings := make([][]occultationScreenPoint, 0, len(polygons)) + for _, polygon := range polygons { + geographic := make([]svgmap.GeoPoint, len(polygon)) + for index, point := range polygon { + geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + for _, fragment := range svgmap.PolygonFragments(geographic, s.layout.mapFrame().Clip()) { + if len(fragment) < 3 { + continue } - for _, fragment := range svgmap.PolygonFragments(geographic, layout.projection) { - if len(fragment) < 3 { - continue + if planetOccultationProjectedArea(s.layout, fragment) < 0 { + for left, right := 0, len(fragment)-1; left < right; left, right = left+1, right-1 { + fragment[left], fragment[right] = fragment[right], fragment[left] } - if planetOccultationProjectedArea(layout, fragment) < 0 { - for left, right := 0, len(fragment)-1; left < right; left, right = left+1, right-1 { - fragment[left], fragment[right] = fragment[right], fragment[left] - } - } - for index, point := range fragment { - x, y := layout.project(point.Longitude, point.Latitude) - command := "L" - if index == 0 { - command = "M" - } - fmt.Fprintf(&geometry, "%s %.3f %.3f ", command, x, y) - } - geometry.WriteString("Z ") } + rings = append(rings, s.layout.projectOccultationGeoRing(fragment)) } } - if geometry.Len() == 0 { - return + return rings, source, nil +} + +// mergedBandPolygons 命中缓存时直接复用只读的合并结果:紧凑带的合并远贵于逐足迹绘制。 +func (s occultationFootprintSweep) mergedBandPolygons() ([][]geodata.GeoPoint, bool, error) { + key := occultationBandCacheKey{kind: s.kind, digest: occultationBandDigest(s)} + if cached, ok := lookupOccultationBandPolygons(key); ok { + return cached.polygons, cached.authoritative, nil } - fmt.Fprintf(b, ``, - layerClass, color, opacity, pathClass, geometry.String()) + polygons, authoritative, err := s.mergeBandPolygons() + if err != nil { + return nil, false, err + } + storeOccultationBandPolygons(key, occultationBandPolygons{polygons: polygons, authoritative: authoritative}) + return polygons, authoritative, nil +} + +func (s occultationFootprintSweep) mergeBandPolygons() ([][]geodata.GeoPoint, bool, error) { + if len(s.contours) == 0 { + if s.kind == totalOccultationFootprintSweep { + return occultationgeo.VisibleTotalBandPolygons(s.footprints, s.northern, s.southern, s.curves) + } + return occultationgeo.VisibleBandPolygons(s.footprints, s.northern, s.southern, s.curves) + } + if s.kind == totalOccultationFootprintSweep { + return occultationgeo.VisibleTotalBandPolygonsFromAnalyticContours( + s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves, + ) + } + if s.kind == starOccultationFootprintSweep { + return occultationgeo.VisibleStarBandPolygonsFromAnalyticContours( + s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves, + ) + } + return occultationgeo.VisibleBandPolygonsFromAnalyticContours( + s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves, + ) +} + +// outlineStyle 只给解析轮廓描边;总掩带保持纯填充外观。 +func (s occultationFootprintSweep) outlineStyle(source occultationBandSource) (string, float64) { + if source != occultationBandEnvelope || s.pathClass == "total-occultation-band" { + return "none", 0 + } + return s.color, 1.3 +} + +func planetOccultationFootprintPolygons(footprints []moon.PlanetOccultationFootprint) [][]geodata.GeoPoint { + polygons := make([][]geodata.GeoPoint, 0, len(footprints)) + for _, footprint := range footprints { + for _, polygon := range footprint.Polygons { + geographic := make([]geodata.GeoPoint, len(polygon)) + for index, point := range polygon { + geographic[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} + } + polygons = append(polygons, geographic) + } + } + return polygons } func planetOccultationProjectedArea(layout starOccultationSVGLayout, points []svgmap.GeoPoint) float64 { @@ -447,12 +717,13 @@ type planetOccultationProjectedEventMarker struct { placement starOccultationEventMarkerPlacement } -func writePlanetOccultationEventMarkers( - b *strings.Builder, +// planetOccultationEventMarkerDraws 计算全部事件标记;近似重合的外接触与内接触先分开标签再登记占位。 +func planetOccultationEventMarkerDraws( path moon.PlanetOccultationPath, layout starOccultationSVGLayout, language string, -) { + placer *occultationLabelPlacer, +) []planetOccultationProjectedEventMarker { markers := []planetOccultationProjectedEventMarker{ {point: path.Start, label: planetOccultationEventLabel("start", language), kind: "start"}, } @@ -485,6 +756,34 @@ func writePlanetOccultationEventMarkers( separatePlanetOccultationMarkerPair(markers, 0, 1, layout) separatePlanetOccultationMarkerPair(markers, 4, 3, layout) } + visible := make([]starOccultationEventMarkerDraw, 0, len(markers)) + for index := range markers { + if !markers[index].visible { + continue + } + visible = append(visible, starOccultationEventMarkerDraw{ + x: markers[index].x, y: markers[index].y, + label: markers[index].label, kind: markers[index].kind, + placement: markers[index].placement, + }) + } + reserveOccultationEventMarkerLabels(visible, layout, placer) + offset := 0 + for index := range markers { + if !markers[index].visible { + continue + } + markers[index].placement = visible[offset].placement + offset++ + } + return markers +} + +func writePlanetOccultationEventMarkers( + b *strings.Builder, + path moon.PlanetOccultationPath, + markers []planetOccultationProjectedEventMarker, +) { drawOrder := make([]int, len(markers)) for index := range drawOrder { drawOrder[index] = index @@ -537,39 +836,87 @@ func writePlanetOccultationLegend( layout starOccultationSVGLayout, language string, hasTotal bool, + hasRiseSet bool, + hasIsochrones bool, ) { - labels := []string{"部分掩带", "可见中心线", "几何中心线"} + type legendItem struct { + label string + kind string + } + items := []legendItem{{"部分掩带", "partial"}} if hasTotal { - labels = []string{"部分掩带", "全掩带", "可见中心线", "几何中心线"} + items = append(items, legendItem{"全掩带", "total"}) + } + items = append(items, legendItem{"可见中心线", "visible"}, legendItem{"几何中心线", "geometric"}) + if hasRiseSet { + items = append(items, legendItem{"初掩/掩甚/终掩月升月落线", "rise-set"}) + } + if hasIsochrones { + items = append(items, legendItem{"掩甚时刻等时线", "isochrone"}) } if language == starOccultationSVGLanguageEnglish { - labels = []string{"Partial band", "Visible center", "Geometric center"} - if hasTotal { - labels = []string{"Partial band", "Total band", "Visible center", "Geometric center"} + for index := range items { + switch items[index].kind { + case "partial": + items[index].label = "Partial band" + case "total": + items[index].label = "Total band" + case "visible": + items[index].label = "Visible center" + case "geometric": + items[index].label = "Geometric center" + case "rise-set": + items[index].label = "Rise/set phase lines" + case "isochrone": + items[index].label = "Greatest-time isochrones" + } } } y := layout.mapY + layout.mapHeight + 30 - itemWidth := layout.mapWidth / float64(len(labels)) - for index, label := range labels { - x := layout.mapX + float64(index)*itemWidth - switch { - case index == 0: - fmt.Fprintf(b, ``, x, y-10) - case hasTotal && index == 1: - fmt.Fprintf(b, ``, x, y-10) + legend := make([]occultationLegendItem, 0, len(items)) + for _, item := range items { + current := occultationLegendItem{label: item.label, markerWidth: 18} + switch item.kind { + case "partial": + current.fill, current.fillOpacity = "#e0ae43", 0.55 + case "total": + current.fill, current.fillOpacity = "#607d98", 0.82 + case "geometric": + current.color, current.dash = "#59676b", "5 4" + case "rise-set": + current.color = "#d97706" + case "isochrone": + current.color = "#1f6fb2" default: - color, dash := "#087f8c", "" - if index == len(labels)-1 { - color, dash = "#59676b", "5 4" - } - fmt.Fprintf(b, ``) + current.color = "#087f8c" } - fmt.Fprintf(b, `%s`, - x+23, y, html.EscapeString(label)) + legend = append(legend, current) + } + writeOccultationLegendItems(b, y, layout, legend, 9) +} + +func writeOccultationHorizonConnectors( + b *strings.Builder, + footprints []moon.OccultationFootprint, + northern, southern []moon.OccultationPathPoint, + curves []moon.OccultationRiseSetCurve, + layout starOccultationSVGLayout, + pointSource bool, +) { + connectors := occultationgeo.HorizonConnectorSegments(footprints, curves, northern, southern) + if pointSource { + connectors = occultationgeo.StarHorizonConnectorSegments(footprints, curves, northern, southern) + } + for _, connector := range connectors { + if len(connector.Points) < 2 { + continue + } + points := occultationgeo.DensifyOccultationPathPoints(connector.Points, 35) + className := fmt.Sprintf("occultation-rise-set-boundary occultation-horizon-%s", connector.Direction) + writeStarOccultationGeoLine( + b, points, layout, + occultationLineStyle{className: className, color: "#d97706", strokeWidth: 1.35}, + ) } } @@ -645,3 +992,39 @@ func planetOccultationChineseName(planet moon.OccultationPlanet) string { return "行星" } } + +// occultationPolarBandReach 是判定掩带已经包住极点的纬度阈值。 +const occultationPolarBandReach = 89.0 + +// planetOccultationBandLatitudeRange 返回掩带(中心线、南北限与足印)的纬度范围。 +func planetOccultationBandLatitudeRange(path moon.PlanetOccultationPath) (float64, float64) { + minimumLatitude, maximumLatitude := path.Greatest.Latitude, path.Greatest.Latitude + for _, series := range [][]moon.OccultationPathPoint{ + path.CenterLine, path.NorthernLimit, path.SouthernLimit, + path.NorthernTotalLimit, path.SouthernTotalLimit, + } { + for _, point := range series { + minimumLatitude = math.Min(minimumLatitude, point.Latitude) + maximumLatitude = math.Max(maximumLatitude, point.Latitude) + } + } + for _, footprints := range [][]moon.PlanetOccultationFootprint{ + path.PartialFootprints, path.PartialBandFootprints, + path.TotalFootprints, path.TotalBandFootprints, + } { + for _, footprint := range footprints { + for _, polygon := range footprint.Polygons { + for _, point := range polygon { + minimumLatitude = math.Min(minimumLatitude, point.Latitude) + maximumLatitude = math.Max(maximumLatitude, point.Latitude) + } + } + } + } + return minimumLatitude, maximumLatitude +} + +// planetOccultationOrthographicCentre 取掩甚点作为正射球面的视点。 +func planetOccultationOrthographicCentre(path moon.PlanetOccultationPath) svgmap.GeoPoint { + return svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude} +} diff --git a/moon/svg/occultation_planet_local.go b/moon/svg/occultation_planet_local.go index 587cd0d..907a1b8 100644 --- a/moon/svg/occultation_planet_local.go +++ b/moon/svg/occultation_planet_local.go @@ -9,6 +9,7 @@ import ( "time" "b612.me/astro/internal/svgasset" + "b612.me/astro/internal/svgchart" "b612.me/astro/moon" ) @@ -57,11 +58,12 @@ type LocalPlanetOccultationSVGOptions struct { } type localPlanetOccultationEventFrame struct { - label string - name string - time time.Time - hidden bool - frame moon.PlanetOccultationDiagramFrame + label string + name string + time time.Time + hidden bool + frame moon.PlanetOccultationDiagramFrame + appearance localOccultationMoonAppearance } // FindLocalPlanetOccultationSVGs 搜索固定观测地点,并渲染请求时间窗口内的每次有限盘面行星月掩。空切片表示没有本地事件。 @@ -192,6 +194,9 @@ func renderLocalPlanetOccultationSVG( ) string { title := localPlanetOccultationSVGTitle(info, options) headerLines := localPlanetOccultationSVGHeaderLines(info, options) + // 页眉字号 14/13,省略号按较大的 14 估算;行数上限只与画布高度有关,先按空页眉取上限。 + headerLimit := localPlanetOccultationSVGLayoutFor(options, 72).headerMaxLines + headerLines = svgchart.TruncateTextLines(headerLines, float64(options.Width)-80, 14, headerLimit) headerBottom := 72.0 + float64(len(headerLines))*19 layout := localPlanetOccultationSVGLayoutFor(options, headerBottom) events := localPlanetOccultationSVGEventFrames(info, diagram.Frames, options.Language) @@ -205,8 +210,13 @@ func renderLocalPlanetOccultationSVG( b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, ``, layout.width-44, layout.height-36) + titleSize := starOccultationTitleFontSize(title, layout.width) + titleText := title + if options.Title != "" { + titleText = svgchart.EllipsizeText(title, layout.width-80, float64(titleSize)) + } fmt.Fprintf(&b, `%s`, - layout.width/2, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title)) + layout.width/2, titleSize, html.EscapeString(titleText)) fmt.Fprintf(&b, ``, layout.width/2-78, layout.width/2+78) for index, line := range headerLines { fontSize, fill := 13, "#3b4143" @@ -255,6 +265,7 @@ func localPlanetOccultationSVGLayoutFor( stageTop: stageTop, stageBottom: stageBottom, footerY: height - 62, + headerMaxLines: localOccultationHeaderLineLimit(height, footerSpace, stageHeight), } } @@ -265,8 +276,10 @@ func writeLocalPlanetOccultationOverview( layout localStarOccultationSVGLayout, options LocalPlanetOccultationSVGOptions, ) { + overviewTitle := occultationTitleText(localPlanetOccultationSVGOverviewTitle(options), options.OverviewTitle != "", + layout.panelX-layout.overviewLeft-12, 14) fmt.Fprintf(b, `%s`, - layout.overviewLeft, layout.overviewTop-10, html.EscapeString(localPlanetOccultationSVGOverviewTitle(options))) + layout.overviewLeft, layout.overviewTop-10, html.EscapeString(overviewTitle)) cx := (layout.overviewLeft + layout.overviewRight) / 2 cy := (layout.overviewTop+layout.overviewBottom)/2 + 4 extent := localPlanetOccultationSVGExtent(diagram.Frames) @@ -289,7 +302,9 @@ func writeLocalPlanetOccultationOverview( event.frame.PlanetRadiusArcsec*scale, false, "overview-planet-disk", event.label, ) } - writeLocalStarOccultationMoon(b, cx, cy, moonRadius, "overview-moon") + writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon", + localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer), + "local-planet-occultation-moon-overview") for _, event := range events { x := mapX(event.frame.PlanetXArcsec) y := mapY(event.frame.PlanetYArcsec) @@ -435,8 +450,10 @@ func writeLocalPlanetOccultationContacts( ) { fmt.Fprintf(b, ``, layout.panelX-12, layout.overviewTop-12, layout.panelX-12, layout.overviewBottom) + contactsTitle := occultationTitleText(localPlanetOccultationSVGContactsTitle(options), options.ContactsTitle != "", + layout.width-layout.panelX-layout.margin, 14) fmt.Fprintf(b, `%s`, - layout.panelX, layout.overviewTop-10, html.EscapeString(localPlanetOccultationSVGContactsTitle(options))) + layout.panelX, layout.overviewTop-10, html.EscapeString(contactsTitle)) available := layout.overviewBottom - layout.overviewTop - 10 rowHeight := math.Min(66, available/math.Max(1, float64(len(events)))) for index, event := range events { @@ -465,8 +482,10 @@ func writeLocalPlanetOccultationStages( layout localStarOccultationSVGLayout, options LocalPlanetOccultationSVGOptions, ) { + phaseTitle := occultationTitleText(localPlanetOccultationSVGPhasePanelsTitle(options), options.PhasePanelsTitle != "", + layout.width-2*layout.margin, 14) fmt.Fprintf(b, `%s`, - layout.margin, layout.stageTop+13, html.EscapeString(localPlanetOccultationSVGPhasePanelsTitle(options))) + layout.margin, layout.stageTop+13, html.EscapeString(phaseTitle)) if len(events) == 0 { return } @@ -484,7 +503,8 @@ func writeLocalPlanetOccultationStages( x, y := localPlanetOccultationSVGStageCenter(event, cx, cy, radius, displayRadius, scale) fmt.Fprintf(b, ``, html.EscapeString(event.label), trueRadius, magnification, x, y, displayRadius) - writeLocalStarOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon") + writeLocalOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon", + event.appearance, fmt.Sprintf("local-planet-occultation-moon-stage-%d", index)) if event.hidden { fmt.Fprintf(b, ``, x, y, displayRadius) @@ -527,12 +547,14 @@ func writeLocalPlanetOccultationFooter( layout localStarOccultationSVGLayout, options LocalPlanetOccultationSVGOptions, ) { - directionLines := starOccultationWrapText(localPlanetOccultationSVGDirectionText(options), layout.width-80, 10) + directionLines, footerLines := localOccultationFooterLines( + localPlanetOccultationSVGDirectionText(options), options.DirectionText != "", + localPlanetOccultationSVGFooterNote(info, options), options.FooterNote != "", + layout.footerY, layout.width, layout.height) for index, line := range directionLines { fmt.Fprintf(b, `%s`, layout.margin, layout.footerY+float64(index)*14-8, html.EscapeString(line)) } - footerLines := starOccultationWrapText(localPlanetOccultationSVGFooterNote(info, options), layout.width-80, 9) for index, line := range footerLines { fmt.Fprintf(b, `%s`, layout.margin, layout.footerY+float64(len(directionLines))*14+float64(index)*12-6, html.EscapeString(line)) @@ -562,11 +584,12 @@ func localPlanetOccultationSVGEventFrames( hidden := frame.FullyOcculted || info.Type == moon.OccultationTotal && (label == "C2" || label == "Greatest" || label == "C3") result = append(result, localPlanetOccultationEventFrame{ - label: label, - name: localPlanetOccultationSVGEventName(label, language), - time: times[label], - hidden: hidden, - frame: frame, + label: label, + name: localPlanetOccultationSVGEventName(label, language), + time: times[label], + hidden: hidden, + frame: frame, + appearance: localOccultationMoonAppearanceAt(times[label], info.Observer), }) break } @@ -592,11 +615,14 @@ func localPlanetOccultationSVGHeaderLines( options LocalPlanetOccultationSVGOptions, ) []string { lines := make([]string, 0, 4) - for _, value := range []string{ - localPlanetOccultationSVGSummaryText(info, options), - localPlanetOccultationSVGGreatestText(info, options), + for _, item := range []struct { + value string + custom bool + }{ + {localPlanetOccultationSVGSummaryText(info, options), options.SummaryText != ""}, + {localPlanetOccultationSVGGreatestText(info, options), options.GreatestText != ""}, } { - lines = append(lines, starOccultationWrapText(value, float64(options.Width)-80, 13)...) + lines = append(lines, occultationWrappedTextLines(item.value, item.custom, float64(options.Width)-80, 13)...) } return lines } diff --git a/moon/svg/occultation_planet_local_test.go b/moon/svg/occultation_planet_local_test.go index edf6c7d..db26de6 100644 --- a/moon/svg/occultation_planet_local_test.go +++ b/moon/svg/occultation_planet_local_test.go @@ -2,6 +2,7 @@ package svg import ( "errors" + "fmt" "math" "strings" "testing" @@ -52,6 +53,26 @@ func TestFindLocalPlanetOccultationSVGsSaturnFiveContacts(t *testing.T) { } } +func TestLocalPlanetOccultationSVGUsesGreatestMoonPhase(t *testing.T) { + info := localSaturnOccultation(t) + diagram, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{ + Location: time.UTC, + }) + if err != nil { + t.Fatalf("LocalPlanetOccultationSVG() error = %v", err) + } + appearance := localOccultationMoonAppearanceAt(info.Greatest, info.Observer) + want := fmt.Sprintf(`data-illumination="%.6f" data-bright-limb-position-angle="%.3f"`, + appearance.illumination, appearance.brightLimbPA) + if !strings.Contains(diagram, want) { + t.Fatalf("local planetary SVG does not carry greatest-time Moon appearance %q", want) + } + if appearance.illumination > 1e-6 && appearance.illumination < 1-1e-6 && + !strings.Contains(diagram, ``) { + t.Fatal("non-full Moon overview is missing its phase clip path") + } +} + func TestLocalPlanetOccultationSVGStageCentersPreserveDisplayedTangency(t *testing.T) { event := localPlanetOccultationEventFrame{ label: "C2", diff --git a/moon/svg/occultation_planet_test.go b/moon/svg/occultation_planet_test.go index cda0010..fb36036 100644 --- a/moon/svg/occultation_planet_test.go +++ b/moon/svg/occultation_planet_test.go @@ -36,6 +36,7 @@ func TestFindPlanetOccultationSVGsSaturnFiniteDisk(t *testing.T) { `class="occultation-time-marker"`, `class="event-marker event-total-start"`, `class="event-marker event-greatest"`, `class="event-marker event-total-end"`, `class="event-marker-leader"`, + `class="occultation-rise-set-boundary occultation-start-rise"`, `stroke="#d97706"`, "初掩/掩甚/终掩月升月落线", } { if !strings.Contains(diagram, want) { t.Fatalf("planetary SVG missing %q", want) @@ -68,6 +69,316 @@ func TestFindPlanetOccultationSVGsSaturnFiniteDisk(t *testing.T) { if got := strings.Count(diagram, `fill="#087f8c" stroke="#ffffff" stroke-width="1.0"`); got < 2 { t.Fatalf("planetary SVG total-contact marker count = %d, want at least 2", got) } + bandIndex := strings.Index(diagram, `class="occultation-band"`) + curveIndex := strings.Index(diagram, `class="occultation-rise-set-boundary occultation-start-rise"`) + if bandIndex < 0 || curveIndex < 0 || curveIndex < bandIndex { + t.Fatalf("planetary SVG draws rise/set curves before the filled band: band=%d curve=%d", bandIndex, curveIndex) + } + curveEnd := -1 + if curveIndex >= 0 { + curveEnd = strings.Index(diagram[curveIndex:], "/>") + } + if curveEnd < 0 || strings.Contains(diagram[curveIndex:curveIndex+curveEnd], "stroke-dasharray") { + t.Fatal("planetary rise/set phase line is rendered with a gap-producing dash pattern") + } +} + +func TestPlanetOccultationLegendOmitsDisabledRiseSetCurves(t *testing.T) { + path := samplePlanetOccultationPath() + path.RiseSetCurves = nil + diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{}) + if err != nil { + t.Fatalf("PlanetOccultationPathSVG: %v", err) + } + if strings.Contains(diagram, "初掩/掩甚/终掩月升月落线") || strings.Contains(diagram, "Rise/set phase lines") { + t.Fatal("planetary SVG legend claims disabled rise/set curves are present") + } +} + +func TestPlanetOccultationSVGWithoutFootprintsUsesLimitBands(t *testing.T) { + path := samplePlanetOccultationPath() + path.PartialFootprints = nil + path.TotalFootprints = nil + diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{}) + if err != nil { + t.Fatalf("PlanetOccultationPathSVG: %v", err) + } + for _, className := range []string{ + `class="occultation-band"`, `class="total-occultation-band"`, + `class="northern-limit"`, `class="southern-limit"`, + `class="northern-total-limit"`, `class="southern-total-limit"`, + } { + if !strings.Contains(diagram, className) { + t.Fatalf("planetary SVG without footprints is missing %s", className) + } + } +} + +func TestPlanetOccultationSVG20240725UsesCompactBandFootprints(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, + DisableRiseSet: true, DisableFootprints: true, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.PartialFootprints) != 0 || len(path.TotalFootprints) != 0 || + len(path.PartialBandFootprints) == 0 || len(path.TotalBandFootprints) == 0 { + t.Fatalf("dense/compact footprint counts partial=%d/%d total=%d/%d", + len(path.PartialFootprints), len(path.PartialBandFootprints), + len(path.TotalFootprints), len(path.TotalBandFootprints)) + } + diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{ + Width: 1200, Height: 800, Projection: MapProjectionEquirectangular, + }) + if err != nil { + t.Fatalf("PlanetOccultationPathSVG: %v", err) + } + for _, className := range []string{ + `class="northern-limit"`, `class="southern-limit"`, + `class="northern-total-limit"`, `class="southern-total-limit"`, + } { + if strings.Contains(diagram, className) { + t.Fatalf("compact planetary SVG still overlays discontinuous auxiliary line %s", className) + } + } + x, y := planetOccultationSVGMapPoint( + t, diagram, MapProjectionEquirectangular, path.Greatest.Longitude, path.Greatest.Latitude, + ) + for _, className := range []string{`class="occultation-band"`, `class="total-occultation-band"`} { + pathData := planetOccultationSVGPathData(t, diagram, strings.TrimSuffix(strings.TrimPrefix(className, `class="`), `"`)) + if !strings.Contains(pathData, "Z") { + t.Fatalf("compact planetary SVG %s is not explicitly closed", className) + } + if !planetOccultationSVGPathContains(pathData, x, y) { + t.Fatalf("compact planetary SVG %s excludes greatest at %.3f, %.3f", className, x, y) + } + } +} + +func TestPlanetOccultationSVGMars20250729MergesTotalRiseSetBoundaryBand(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationMars, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.TotalBandFootprints) < 20 { + t.Fatalf("total compact footprint count=%d, want sparse support samples", len(path.TotalBandFootprints)) + } + if len(path.TotalRiseSetCurves) == 0 { + t.Fatal("total compact band is missing inner-contact rise/set curves") + } + diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{ + Width: 1200, Height: 800, Projection: MapProjectionEquirectangular, + }) + if err != nil { + t.Fatalf("PlanetOccultationPathSVG: %v", err) + } + pathData := planetOccultationSVGPathData(t, diagram, "total-occultation-band") + subpaths := strings.Count(pathData, "M ") + if subpaths >= len(path.TotalBandFootprints)/2 { + t.Fatalf("Mars 2025-07-29 total band has %d subpaths for %d compact footprints, want merged boundary geometry", + subpaths, len(path.TotalBandFootprints)) + } + if !strings.Contains(pathData, "Z") { + t.Fatal("Mars 2025-07-29 total boundary band is not explicitly closed") + } +} + +func TestPlanetOccultationPathSVGJupiter20230517PolarBandContainsPole(t *testing.T) { + start := time.Date(2023, time.May, 17, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationJupiter, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one Jupiter path", len(paths), err) + } + for _, projection := range []struct { + name string + value MapProjection + longitude, latitude float64 + }{ + {name: "north-polar", value: MapProjectionNorthPolar, longitude: 0, latitude: 90}, + {name: "equirectangular", value: MapProjectionEquirectangular, longitude: 0, latitude: 89}, + } { + diagram, renderErr := PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{ + Width: 1200, Height: 800, Projection: projection.value, + }) + if renderErr != nil { + t.Fatalf("%s PlanetOccultationPathSVG() error = %v", projection.name, renderErr) + } + x, y := planetOccultationSVGMapPoint(t, diagram, projection.value, projection.longitude, projection.latitude) + for _, className := range []string{"occultation-band", "total-occultation-band"} { + pathData := planetOccultationSVGPathData(t, diagram, className) + if !planetOccultationSVGPathContains(pathData, x, y) { + t.Fatalf("%s %s does not contain %.1f°E %.1f°N at %.3f, %.3f", projection.name, className, + projection.longitude, projection.latitude, x, y) + } + } + } +} + +func TestPlanetOccultationPathSVGJupiter20230517UsesFineFootprintSweep(t *testing.T) { + start := time.Date(2023, time.May, 17, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationJupiter, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one Jupiter path", len(paths), err) + } + path := paths[0] + for _, series := range []struct { + name string + footprints []moon.PlanetOccultationFootprint + }{ + {name: "partial", footprints: path.PartialFootprints}, + {name: "total", footprints: path.TotalFootprints}, + } { + if len(series.footprints) < 100 { + t.Fatalf("%s footprint count = %d, want fine sweep sampling", series.name, len(series.footprints)) + } + for index := 1; index < len(series.footprints); index++ { + gap := series.footprints[index].Time.Sub(series.footprints[index-1].Time) + if gap > time.Minute+time.Millisecond { + t.Fatalf("%s footprint gap at %d = %s, want at most one minute", series.name, index, gap) + } + } + } +} + +func planetOccultationSVGMapCenter(t *testing.T, diagram string) (float64, float64) { + t.Helper() + attributes := planetOccultationSVGElementAttributes(t, diagram, "circle", "map-ocean") + return planetOccultationSVGFloatAttribute(t, attributes, "cx"), + planetOccultationSVGFloatAttribute(t, attributes, "cy") +} + +func planetOccultationSVGMapPoint( + t *testing.T, diagram string, projection MapProjection, longitude, latitude float64, +) (float64, float64) { + t.Helper() + if projection == MapProjectionNorthPolar || projection == MapProjectionSouthPolar { + return planetOccultationSVGMapCenter(t, diagram) + } + attributes := planetOccultationSVGElementAttributes(t, diagram, "rect", "map-ocean") + x := planetOccultationSVGFloatAttribute(t, attributes, "x") + y := planetOccultationSVGFloatAttribute(t, attributes, "y") + width := planetOccultationSVGFloatAttribute(t, attributes, "width") + height := planetOccultationSVGFloatAttribute(t, attributes, "height") + return x + (longitude+180)/360*width, y + (90-latitude)/180*height +} + +func planetOccultationSVGPathData(t testing.TB, diagram, className string) string { + t.Helper() + attributes := planetOccultationSVGElementAttributes(t, diagram, "path", className) + value, ok := attributes["d"] + if !ok { + t.Fatalf("SVG path %q has no d attribute", className) + } + return value +} + +func planetOccultationSVGElementAttributes( + t testing.TB, + diagram, elementName, className string, +) map[string]string { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(diagram)) + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("decode planetary SVG: %v", err) + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local != elementName { + continue + } + attributes := make(map[string]string, len(start.Attr)) + for _, attribute := range start.Attr { + attributes[attribute.Name.Local] = attribute.Value + } + if attributes["class"] == className { + return attributes + } + } + t.Fatalf("SVG element %s.%s not found", elementName, className) + return nil +} + +func planetOccultationSVGFloatAttribute(t testing.TB, attributes map[string]string, name string) float64 { + t.Helper() + value, ok := attributes[name] + if !ok { + t.Fatalf("SVG element has no %s attribute", name) + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + t.Fatalf("parse SVG %s attribute %q: %v", name, value, err) + } + return parsed +} + +func planetOccultationSVGPathContains(pathData string, x, y float64) bool { + fields := strings.Fields(pathData) + polygon := make([][2]float64, 0) + for index := 0; index < len(fields); { + switch fields[index] { + case "M", "L": + if index+2 >= len(fields) { + return false + } + pointX, xErr := strconv.ParseFloat(fields[index+1], 64) + pointY, yErr := strconv.ParseFloat(fields[index+2], 64) + if xErr != nil || yErr != nil { + return false + } + polygon = append(polygon, [2]float64{pointX, pointY}) + index += 3 + case "Z": + if planetOccultationSVGPolygonContains(polygon, x, y) { + return true + } + polygon = polygon[:0] + index++ + default: + return false + } + } + return false +} + +func planetOccultationSVGPolygonContains(polygon [][2]float64, x, y float64) bool { + inside := false + for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { + a, b := polygon[previous], polygon[current] + if (a[1] > y) == (b[1] > y) { + continue + } + if a[0]+(y-a[1])*(b[0]-a[0])/(b[1]-a[1]) > x { + inside = !inside + } + } + return inside } func TestPlanetOccultationPathSVGWrapsFivePhaseSummaryAtRenderedFontSize(t *testing.T) { @@ -77,7 +388,7 @@ func TestPlanetOccultationPathSVGWrapsFivePhaseSummaryAtRenderedFontSize(t *test SummaryText: strings.Repeat("A", 130), }) options = planetOccultationSVGDefaults(path, options) - lines := starOccultationSVGHeaderLines(planetOccultationStarShape(path, path.TargetID), options) + lines := starOccultationSVGHeaderLines(planetOccultationStarShape(path, path.TargetID), options, starOccultationSVGTextFlags(options)) if len(lines) < 3 { t.Fatalf("planetary SVG header lines = %d, want wrapped summary plus greatest line", len(lines)) } @@ -187,6 +498,17 @@ func TestPlanetOccultationPathSVGRejectsInvalidPath(t *testing.T) { } } +func TestPlanetOccultationPathSVGRejectsMalformedRiseSetCurve(t *testing.T) { + path := samplePlanetOccultationPath() + curve := sampleOccultationRiseSetCurve(path.Start.Time) + curve.Direction = moon.RiseSetDirection("bogus") + path.RiseSetCurves = []moon.OccultationRiseSetCurve{curve} + _, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{}) + if !errors.Is(err, ErrInvalidPlanetOccultationPath) { + t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationPath", err) + } +} + func TestPlanetOccultationPathSVGSupportsPartialOnlyGlobalBand(t *testing.T) { path := samplePlanetOccultationPath() path.HasTotalBand = false diff --git a/moon/svg/occultation_review_test.go b/moon/svg/occultation_review_test.go new file mode 100644 index 0000000..800f0a4 --- /dev/null +++ b/moon/svg/occultation_review_test.go @@ -0,0 +1,350 @@ +package svg + +import ( + "errors" + "math" + "strings" + "testing" + "time" + + "b612.me/astro/internal/occultationgeo" + "b612.me/astro/moon" +) + +// 本节钉住入口契约:进入几何引擎的每一组轮廓都要先校验;引擎失败属于内部错误, +// 用非哨兵错误返回(不得谎报数据无效),也不能静默改画原始足迹。 + +func TestStarOccultationPathSVGRejectsMalformedBandContours(t *testing.T) { + base := sampleStarOccultationPath().Start.Time + valid := func() [][]moon.OccultationPathPoint { + return [][]moon.OccultationPathPoint{{ + reviewPoint(base, 10*time.Minute, 110, 10), + reviewPoint(base, 20*time.Minute, 120, 10), + }} + } + tests := []struct { + name string + mutate func([][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint + }{ + {name: "nan longitude", mutate: func(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint { + contours[0][1].Longitude = math.NaN() + return contours + }}, + {name: "duplicate times", mutate: func(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint { + contours[0][1].Time = contours[0][0].Time + return contours + }}, + {name: "single point", mutate: func([][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint { + return [][]moon.OccultationPathPoint{{reviewPoint(base, 10*time.Minute, 110, 10)}} + }}, + {name: "empty contour", mutate: func([][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint { + return [][]moon.OccultationPathPoint{{}} + }}, + {name: "outside event time", mutate: func(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint { + contours[0][0].Time = base.Add(-time.Minute) + return contours + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := sampleStarOccultationPath() + path.BandContours = test.mutate(valid()) + if _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}); !errors.Is(err, ErrInvalidStarOccultationPath) { + t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err) + } + }) + } +} + +func TestStarOccultationPathSVGAcceptsValidBandContours(t *testing.T) { + path := sampleStarOccultationPath() + base := path.Start.Time + path.BandContours = [][]moon.OccultationPathPoint{{ + reviewPoint(base, 10*time.Minute, 110, 10), + reviewPoint(base, 20*time.Minute, 120, 12), + reviewPoint(base, 30*time.Minute, 130, 14), + }} + path.VisibilityContours = [][]moon.OccultationPathPoint{{ + reviewPoint(base, 12*time.Minute, 112, 11), + reviewPoint(base, 24*time.Minute, 124, 13), + }} + if _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}); err != nil { + t.Fatalf("StarOccultationPathSVG() rejected valid contours: %v", err) + } +} + +func TestPlanetOccultationPathSVGRejectsMalformedContours(t *testing.T) { + tests := []struct { + name string + mutate func(*moon.PlanetOccultationPath) + }{ + {name: "partial band contour time order", mutate: func(path *moon.PlanetOccultationPath) { + base := path.Start.Time + path.PartialBandContours = [][]moon.OccultationPathPoint{{ + reviewPoint(base, 20*time.Minute, 120, 10), + reviewPoint(base, 10*time.Minute, 110, 10), + }} + }}, + {name: "partial visibility contour nan", mutate: func(path *moon.PlanetOccultationPath) { + base := path.Start.Time + path.PartialVisibilityContours = [][]moon.OccultationPathPoint{{ + reviewPoint(base, 10*time.Minute, 110, 10), + reviewPoint(base, 20*time.Minute, 120, math.NaN()), + }} + }}, + {name: "total band contour outside total interval", mutate: func(path *moon.PlanetOccultationPath) { + base := path.TotalStart.Time + path.TotalBandContours = [][]moon.OccultationPathPoint{{ + reviewPoint(base, -time.Minute, 100, 8), + reviewPoint(base, 10*time.Minute, 110, 8), + }} + }}, + {name: "total visibility contour point count", mutate: func(path *moon.PlanetOccultationPath) { + path.TotalVisibilityContours = [][]moon.OccultationPathPoint{ + {reviewPoint(path.TotalStart.Time, time.Minute, 100, 8)}, + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := samplePlanetOccultationPath() + test.mutate(&path) + if _, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{}); !errors.Is(err, ErrInvalidPlanetOccultationPath) { + t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationPath", err) + } + }) + } +} + +func TestPlanetOccultationPathSVGAcceptsValidContours(t *testing.T) { + path := samplePlanetOccultationPath() + start, totalStart := path.Start.Time, path.TotalStart.Time + path.PartialBandContours = [][]moon.OccultationPathPoint{{ + reviewPoint(start, 10*time.Minute, 110, 10), + reviewPoint(start, 20*time.Minute, 120, 12), + }} + path.PartialVisibilityContours = [][]moon.OccultationPathPoint{{ + reviewPoint(start, 15*time.Minute, 115, 10), + reviewPoint(start, 25*time.Minute, 125, 12), + }} + path.TotalBandContours = [][]moon.OccultationPathPoint{{ + reviewPoint(totalStart, 5*time.Minute, 105, 8), + reviewPoint(totalStart, 15*time.Minute, 115, 8), + }} + path.TotalVisibilityContours = [][]moon.OccultationPathPoint{{ + reviewPoint(totalStart, 6*time.Minute, 106, 8), + reviewPoint(totalStart, 16*time.Minute, 116, 8), + }} + if _, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{}); err != nil { + t.Fatalf("PlanetOccultationPathSVG() rejected valid contours: %v", err) + } +} + +func TestStarOccultationPathSVGReportsBandGeometryError(t *testing.T) { + path := sampleStarOccultationPath() + path.RiseSetCurves = []moon.OccultationRiseSetCurve{sampleOccultationRiseSetCurve(path.Start.Time)} + path.BandFootprints = []moon.OccultationFootprint{reviewFootprint(path.Greatest.Time)} + path.BandContours = [][]moon.OccultationPathPoint{{ + reviewPoint(path.Start.Time, 10*time.Minute, 110, 0), + reviewPoint(path.Start.Time, 10*time.Minute+time.Second, -80, 0), + }} + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) + if err == nil { + t.Fatal("StarOccultationPathSVG() accepted a band geometry failure") + } + if errors.Is(err, ErrInvalidStarOccultationPath) { + t.Fatalf("geometry engine failure = %v, must not be reported as invalid path data", err) + } + if !strings.Contains(err.Error(), "band geometry") { + t.Fatalf("geometry engine failure = %v, want a render-stage context", err) + } + if diagram != "" { + t.Fatal("stellar renderer returned a diagram after a band geometry failure") + } +} + +func TestPlanetOccultationPathSVGReportsBandGeometryError(t *testing.T) { + path := samplePlanetOccultationPath() + totalStart := path.TotalStart.Time + path.TotalRiseSetCurves = []moon.OccultationRiseSetCurve{ + reviewRiseSetCurve(totalStart.Add(8*time.Minute), totalStart.Add(18*time.Minute)), + } + path.TotalBandFootprints = []moon.OccultationFootprint{reviewFootprint(totalStart.Add(10 * time.Minute))} + path.TotalBandContours = [][]moon.OccultationPathPoint{{ + reviewPoint(totalStart, time.Minute, 100, 0), + reviewPoint(totalStart, time.Minute+time.Second, -80, 0), + }} + diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{}) + if err == nil { + t.Fatal("PlanetOccultationPathSVG() accepted a band geometry failure") + } + if errors.Is(err, ErrInvalidPlanetOccultationPath) { + t.Fatalf("geometry engine failure = %v, must not be reported as invalid path data", err) + } + if !strings.Contains(err.Error(), "band geometry") { + t.Fatalf("geometry engine failure = %v, want a render-stage context", err) + } + if diagram != "" { + t.Fatal("planetary renderer returned a diagram after a band geometry failure") + } +} + +func TestStarOccultationLegendOmitsBoundaryClaimForSweepFallback(t *testing.T) { + path := sampleStarOccultationPath() + path.BandFootprints = []moon.OccultationFootprint{ + reviewFootprint(path.Start.Time.Add(time.Hour)), + reviewFootprint(path.Start.Time.Add(2 * time.Hour)), + } + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) + if err != nil { + t.Fatalf("StarOccultationPathSVG: %v", err) + } + if strings.Contains(diagram, "掩带范围与边界") { + t.Fatal("non-authoritative sweep fallback still claims the analytic band boundary") + } + if !strings.Contains(diagram, "掩带范围") { + t.Fatal("non-authoritative sweep fallback is missing the band extent legend entry") + } +} + +func TestStarOccultationLegendClaimsBoundaryForFootprintSweep(t *testing.T) { + path := starOccultationSimplificationPath(t) + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520}) + if err != nil { + t.Fatalf("StarOccultationPathSVG: %v", err) + } + if !strings.Contains(diagram, "掩带范围与边界") { + t.Fatal("stellar SVG legend dropped the band-and-limits entry for the default footprint sweep") + } +} + +func TestStarOccultationSVGDrawsHorizonConnectorsFromResolvedFootprints(t *testing.T) { + path := reviewHorizonConnectorPath() + connectors := occultationgeo.StarHorizonConnectorSegments( + path.Footprints, path.RiseSetCurves, path.NorthernLimit, path.SouthernLimit) + if len(connectors) == 0 { + t.Fatal("fixture does not yield a horizon connector for the drawn footprints") + } + if compact := occultationgeo.StarHorizonConnectorSegments( + path.BandFootprints, path.RiseSetCurves, path.NorthernLimit, path.SouthernLimit); len(compact) != 0 { + t.Fatalf("fixture unexpectedly yields %d compact-band connectors", len(compact)) + } + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) + if err != nil { + t.Fatalf("StarOccultationPathSVG: %v", err) + } + if !strings.Contains(diagram, `class="occultation-rise-set-boundary occultation-horizon-`) { + t.Fatal("stellar SVG dropped the horizon connectors derived from the drawn footprints") + } +} + +func TestPlanetOccultationSVGDrawsTotalRiseSetCurves(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationMars, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, + }, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + if len(paths[0].TotalRiseSetCurves) == 0 { + t.Fatal("fixture has no inner-contact rise/set curves") + } + diagram, err := PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{ + Width: 1200, Height: 800, Projection: MapProjectionEquirectangular, + }) + if err != nil { + t.Fatalf("PlanetOccultationPathSVG: %v", err) + } + for _, want := range []string{ + `class="occultation-total-rise-set-boundary occultation-total-start-rise"`, + `class="occultation-rise-set-boundary occultation-start-rise"`, + } { + if !strings.Contains(diagram, want) { + t.Fatalf("planetary SVG is missing rise/set curve %s", want) + } + } +} + +func reviewPoint(base time.Time, offset time.Duration, longitude, latitude float64) moon.OccultationPathPoint { + return moon.OccultationPathPoint{ + Time: base.Add(offset), + Longitude: longitude, + Latitude: latitude, + MoonAltitude: 20, + } +} + +func reviewFootprint(when time.Time) moon.OccultationFootprint { + return moon.OccultationFootprint{ + Time: when, + Polygons: [][]moon.OccultationPathPoint{{ + {Time: when, Longitude: -20, Latitude: -10}, + {Time: when, Longitude: 5, Latitude: -10}, + {Time: when, Longitude: 5, Latitude: 10}, + {Time: when, Longitude: -20, Latitude: 10}, + }}, + } +} + +func reviewRiseSetCurve(first, second time.Time) moon.OccultationRiseSetCurve { + return moon.OccultationRiseSetCurve{ + Phase: moon.RiseSetPhaseStart, + Direction: moon.RiseSetDirectionRise, + Segments: [][]moon.OccultationPathPoint{{ + {Time: first, Longitude: 100, Latitude: 8, MoonAltitude: 0}, + {Time: second, Longitude: 110, Latitude: 8, MoonAltitude: 0}, + }}, + } +} + +func reviewHorizonConnectorPath() moon.StarOccultationPath { + path := sampleStarOccultationPath() + start := path.Start.Time + curvePoint := func(offset time.Duration, longitude, latitude float64) moon.OccultationPathPoint { + return moon.OccultationPathPoint{ + Time: start.Add(offset), Longitude: longitude, Latitude: latitude, MoonAltitude: 0, + } + } + path.RiseSetCurves = []moon.OccultationRiseSetCurve{ + { + Phase: moon.RiseSetPhaseStart, Direction: moon.RiseSetDirectionRise, + Segments: [][]moon.OccultationPathPoint{{ + curvePoint(20*time.Minute, 169, 25), curvePoint(21*time.Minute, 170, 25), + }}, + }, + { + Phase: moon.RiseSetPhaseEnd, Direction: moon.RiseSetDirectionRise, + Segments: [][]moon.OccultationPathPoint{{ + curvePoint(21*time.Minute+30*time.Second, 171, 25), curvePoint(22*time.Minute, 172, 25), + }}, + }, + } + footprint := func(offset time.Duration, longitude, latitude float64) moon.OccultationFootprint { + when := start.Add(offset) + return moon.OccultationFootprint{ + Time: when, + Polygons: [][]moon.OccultationPathPoint{{ + {Time: when, Longitude: longitude - 0.5, Latitude: latitude - 0.5}, + {Time: when, Longitude: longitude + 0.5, Latitude: latitude - 0.5}, + {Time: when, Longitude: longitude, Latitude: latitude + 0.5}, + }}, + Boundaries: [][]moon.OccultationPathPoint{{ + {Time: when, Longitude: longitude, Latitude: latitude}, + {Time: when, Longitude: longitude + 0.05, Latitude: latitude + 0.05}, + {Time: when, Longitude: longitude + 0.1, Latitude: latitude + 0.1}, + }}, + } + } + path.Footprints = []moon.OccultationFootprint{ + footprint(21*time.Minute, 170, 25), + footprint(21*time.Minute+30*time.Second, 171, 25), + } + path.BandFootprints = nil + return path +} diff --git a/moon/svg/occultation_simplify.go b/moon/svg/occultation_simplify.go new file mode 100644 index 0000000..bc33102 --- /dev/null +++ b/moon/svg/occultation_simplify.go @@ -0,0 +1,180 @@ +package svg + +import ( + "strconv" + "strings" + + "b612.me/astro/internal/geodata" + "b612.me/astro/moon" +) + +// occultationScreenPoint 是投影到 SVG 画布后的像素坐标。 +type occultationScreenPoint struct { + x float64 + y float64 +} + +// occultationGeometryTolerancePixels 是掩带几何写出前的折线简化容差(像素);0 表示不简化。 +var occultationGeometryTolerancePixels = 0.4 + +// projectOccultationRing 投影并简化一个地理环。 +func (layout starOccultationSVGLayout) projectOccultationRing(points []starOccultationGeoPoint) []occultationScreenPoint { + screen := make([]occultationScreenPoint, len(points)) + for index, point := range points { + x, y := layout.project(point.longitude, point.latitude) + screen[index] = occultationScreenPoint{x: x, y: y} + } + return simplifyOccultationRing(screen, occultationGeometryTolerancePixels) +} + +// projectOccultationPolyline 投影并简化一条地理折线。 +func (layout starOccultationSVGLayout) projectOccultationPolyline(points []starOccultationGeoPoint) []occultationScreenPoint { + screen := make([]occultationScreenPoint, len(points)) + for index, point := range points { + x, y := layout.project(point.longitude, point.latitude) + screen[index] = occultationScreenPoint{x: x, y: y} + } + return simplifyOccultationPolyline(screen, occultationGeometryTolerancePixels) +} + +// projectOccultationPathLine 投影并简化一条月掩路径折线。 +func (layout starOccultationSVGLayout) projectOccultationPathLine(points []moon.OccultationPathPoint) []occultationScreenPoint { + screen := make([]occultationScreenPoint, len(points)) + for index, point := range points { + x, y := layout.project(point.Longitude, point.Latitude) + screen[index] = occultationScreenPoint{x: x, y: y} + } + return simplifyOccultationPolyline(screen, occultationGeometryTolerancePixels) +} + +// projectOccultationGeoRing 投影并简化一条地图多边形环。 +func (layout starOccultationSVGLayout) projectOccultationGeoRing(points []geodata.GeoPoint) []occultationScreenPoint { + screen := make([]occultationScreenPoint, len(points)) + for index, point := range points { + x, y := layout.project(point.Longitude, point.Latitude) + screen[index] = occultationScreenPoint{x: x, y: y} + } + return simplifyOccultationRing(screen, occultationGeometryTolerancePixels) +} + +// simplifyOccultationRing 简化闭合环:首点与离首点最远的顶点保留为两个锚点,两段各自简化。 +// 简化后不足三个顶点时返回原环,保证结果仍可填充。 +func simplifyOccultationRing(points []occultationScreenPoint, tolerance float64) []occultationScreenPoint { + if len(points) < 4 || !(tolerance > 0) { + return points + } + ring := points + closed := points[0] == points[len(points)-1] + if closed { + ring = points[:len(points)-1] + } + if len(ring) < 3 { + return points + } + anchor := 1 + farthest := -1.0 + for index := 1; index < len(ring); index++ { + distance := occultationSquaredDistance(ring[0], ring[index]) + if distance > farthest { + farthest, anchor = distance, index + } + } + first := simplifyOccultationPolyline(ring[:anchor+1], tolerance) + second := make([]occultationScreenPoint, len(ring)-anchor+1) + copy(second, ring[anchor:]) + second[len(second)-1] = ring[0] + second = simplifyOccultationPolyline(second, tolerance) + result := make([]occultationScreenPoint, 0, len(first)+len(second)-2) + result = append(result, first[:len(first)-1]...) + result = append(result, second[:len(second)-1]...) + if len(result) < 3 { + return points + } + if closed { + result = append(result, result[0]) + } + return result +} + +// simplifyOccultationPolyline 用 Douglas-Peucker 简化折线,端点恒保留。 +func simplifyOccultationPolyline(points []occultationScreenPoint, tolerance float64) []occultationScreenPoint { + if len(points) < 3 || !(tolerance > 0) { + return points + } + keep := make([]bool, len(points)) + keep[0], keep[len(points)-1] = true, true + toleranceSquared := tolerance * tolerance + type span struct{ first, last int } + stack := make([]span, 0, 16) + stack = append(stack, span{first: 0, last: len(points) - 1}) + for len(stack) > 0 { + current := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if current.last <= current.first+1 { + continue + } + index, distance := occultationFarthestVertex(points, current.first, current.last) + if distance <= toleranceSquared { + continue + } + keep[index] = true + stack = append(stack, span{first: current.first, last: index}, span{first: index, last: current.last}) + } + result := make([]occultationScreenPoint, 0, len(points)) + for index, point := range points { + if keep[index] { + result = append(result, point) + } + } + return result +} + +// occultationFarthestVertex 返回首末顶点之间离弦最远的顶点及其平方距离。 +func occultationFarthestVertex(points []occultationScreenPoint, first, last int) (int, float64) { + best, bestDistance := first, -1.0 + start, end := points[first], points[last] + for index := first + 1; index < last; index++ { + distance := occultationChordSquaredDistance(points[index], start, end) + if distance > bestDistance { + best, bestDistance = index, distance + } + } + return best, bestDistance +} + +// occultationChordSquaredDistance 返回点到线段的最短平方距离;线段退化时退化为点距。 +func occultationChordSquaredDistance(point, start, end occultationScreenPoint) float64 { + dx, dy := end.x-start.x, end.y-start.y + lengthSquared := dx*dx + dy*dy + if lengthSquared <= 0 { + return occultationSquaredDistance(point, start) + } + position := ((point.x-start.x)*dx + (point.y-start.y)*dy) / lengthSquared + if position < 0 { + position = 0 + } else if position > 1 { + position = 1 + } + return occultationSquaredDistance(point, occultationScreenPoint{x: start.x + position*dx, y: start.y + position*dy}) +} + +func occultationSquaredDistance(first, second occultationScreenPoint) float64 { + dx, dy := first.x-second.x, first.y-second.y + return dx*dx + dy*dy +} + +// writeOccultationPathCommands 写出 `M x y L x y ...` 命令序列,坐标保留三位小数。 +func writeOccultationPathCommands(b *strings.Builder, points []occultationScreenPoint) { + var xBuffer, yBuffer [32]byte + for index, point := range points { + if index == 0 { + b.WriteString("M ") + } else { + b.WriteString("L ") + } + b.Write(strconv.AppendFloat(xBuffer[:0], point.x, 'f', 3, 64)) + b.WriteByte(' ') + b.Write(strconv.AppendFloat(yBuffer[:0], point.y, 'f', 3, 64)) + b.WriteByte(' ') + } +} diff --git a/moon/svg/occultation_simplify_bench_test.go b/moon/svg/occultation_simplify_bench_test.go new file mode 100644 index 0000000..c01235b --- /dev/null +++ b/moon/svg/occultation_simplify_bench_test.go @@ -0,0 +1,178 @@ +package svg + +import ( + "testing" + "time" + + "b612.me/astro/moon" +) + +func BenchmarkStarOccultationSVGHR4799(b *testing.B) { + path := starOccultationSimplificationPath(b) + for _, tolerance := range []float64{0, occultationGeometryTolerancePixels} { + b.Run(occultationBenchmarkToleranceName(tolerance), func(b *testing.B) { + occultationBenchmarkRender(b, tolerance, func() (string, error) { + return StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520}) + }, "occultation-band") + }) + } +} + +func BenchmarkPlanetOccultationSVGSaturn20240725(b *testing.B) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + for _, tolerance := range []float64{0, occultationGeometryTolerancePixels} { + b.Run(occultationBenchmarkToleranceName(tolerance), func(b *testing.B) { + occultationBenchmarkRender(b, tolerance, func() (string, error) { + return PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1200, Height: 800}) + }, "occultation-band") + }) + } +} + +func occultationBenchmarkToleranceName(tolerance float64) string { + if tolerance <= 0 { + return "unsimplified" + } + return "simplified" +} + +func occultationBenchmarkRender( + b *testing.B, + tolerance float64, + render func() (string, error), + className string, +) { + b.Helper() + previous := occultationGeometryTolerancePixels + occultationGeometryTolerancePixels = tolerance + defer func() { occultationGeometryTolerancePixels = previous }() + diagram, err := render() + if err != nil { + b.Fatalf("render: %v", err) + } + b.ReportMetric(float64(len(diagram)), "doc-bytes") + b.ReportMetric(float64(occultationPathVertexCount(planetOccultationSVGPathData(b, diagram, className))), "band-vertices") + b.ResetTimer() + for attempt := 0; attempt < b.N; attempt++ { + if _, err := render(); err != nil { + b.Fatalf("render: %v", err) + } + } +} + +// BenchmarkStarOccultationSimplificationSpeedup 在同一次基准里测原始与简化两档容差, +// 把渲染耗时对比从单元测试移到基准,并直接报告加速比。 +func BenchmarkStarOccultationSimplificationSpeedup(b *testing.B) { + path := starOccultationSimplificationPath(b) + render := func() (string, error) { + return StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520}) + } + raw := occultationBenchmarkDuration(b, 0, render) + simplified := occultationBenchmarkDuration(b, occultationGeometryTolerancePixels, render) + b.ReportMetric(float64(raw.Nanoseconds()), "raw-ns/op") + b.ReportMetric(float64(simplified.Nanoseconds()), "simplified-ns/op") + b.ReportMetric(float64(raw)/float64(simplified), "speedup") +} + +// BenchmarkPlanetOccultationSimplificationSpeedup 是行星路径的同一对比。 +func BenchmarkPlanetOccultationSimplificationSpeedup(b *testing.B) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + render := func() (string, error) { + return PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1200, Height: 800}) + } + raw := occultationBenchmarkDuration(b, 0, render) + simplified := occultationBenchmarkDuration(b, occultationGeometryTolerancePixels, render) + b.ReportMetric(float64(raw.Nanoseconds()), "raw-ns/op") + b.ReportMetric(float64(simplified.Nanoseconds()), "simplified-ns/op") + b.ReportMetric(float64(raw)/float64(simplified), "speedup") +} + +func occultationBenchmarkDuration(b *testing.B, tolerance float64, render func() (string, error)) time.Duration { + b.Helper() + previous := occultationGeometryTolerancePixels + occultationGeometryTolerancePixels = tolerance + defer func() { occultationGeometryTolerancePixels = previous }() + begin := time.Now() + for attempt := 0; attempt < b.N; attempt++ { + if _, err := render(); err != nil { + b.Fatalf("render: %v", err) + } + } + return time.Since(begin) / time.Duration(b.N) +} + +// BenchmarkPlanetOccultationSVGFootprintBands 对比 DisableFootprints 两档: +// 逐条绘制密集瞬时足迹 vs 绘制紧凑带的合并结果(命中只读缓存)。 +func BenchmarkPlanetOccultationSVGFootprintBands(b *testing.B) { + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + for _, disable := range []bool{false, true} { + name := "dense-footprints" + if disable { + name = "compact-band" + } + b.Run(name, func(b *testing.B) { + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, DisableFootprints: disable}, + ) + if err != nil || len(paths) != 1 { + b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + render := func() (string, error) { + return PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1200, Height: 800}) + } + if _, err := render(); err != nil { + b.Fatalf("warm-up render: %v", err) + } + b.ResetTimer() + for attempt := 0; attempt < b.N; attempt++ { + if _, err := render(); err != nil { + b.Fatalf("render: %v", err) + } + } + }) + } +} + +// BenchmarkPlanetOccultationSVGCompactBandCold 是紧凑带合并缓存未命中时的成本, +// 用于说明首次渲染仍要付出一次合并代价。 +func BenchmarkPlanetOccultationSVGCompactBandCold(b *testing.B) { + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, DisableFootprints: true}, + ) + if err != nil || len(paths) != 1 { + b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + b.ResetTimer() + for attempt := 0; attempt < b.N; attempt++ { + occultationBandCache.Lock() + occultationBandCache.entries = make(map[occultationBandCacheKey]occultationBandPolygons, occultationBandCacheCapacity) + occultationBandCache.order = nil + occultationBandCache.Unlock() + if _, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1200, Height: 800}); err != nil { + b.Fatalf("render: %v", err) + } + } +} diff --git a/moon/svg/occultation_simplify_test.go b/moon/svg/occultation_simplify_test.go new file mode 100644 index 0000000..d13c1b4 --- /dev/null +++ b/moon/svg/occultation_simplify_test.go @@ -0,0 +1,300 @@ +package svg + +import ( + "math" + "strconv" + "strings" + "testing" + "time" + + "b612.me/astro/moon" +) + +// 本节钉住写出前折线简化的契约:简化几何与原几何逐点偏差不超过容差,环数不变, +// 文档字节与顶点数显著下降;渲染速度只由同文件的基准度量,单元测试不做墙钟断言。 + +func TestSimplifyOccultationPolylineCollapsesCollinearRun(t *testing.T) { + points := make([]occultationScreenPoint, 0, 101) + for index := 0; index <= 100; index++ { + points = append(points, occultationScreenPoint{x: float64(index), y: 0}) + } + simplified := simplifyOccultationPolyline(points, 0.4) + if len(simplified) != 2 { + t.Fatalf("collinear run simplified to %d points, want 2", len(simplified)) + } + if simplified[0] != points[0] || simplified[1] != points[len(points)-1] { + t.Fatal("polyline simplification dropped an endpoint") + } +} + +func TestSimplifyOccultationRingKeepsClosureAndAnchors(t *testing.T) { + ring := make([]occultationScreenPoint, 0, 73) + for index := 0; index < 72; index++ { + angle := 2 * math.Pi * float64(index) / 72 + ring = append(ring, occultationScreenPoint{x: 200 + 80*math.Cos(angle), y: 200 + 80*math.Sin(angle)}) + } + for _, input := range [][]occultationScreenPoint{ring, append(append([]occultationScreenPoint(nil), ring...), ring[0])} { + simplified := simplifyOccultationRing(input, 0.4) + if len(simplified) >= len(input) { + t.Fatalf("circle simplification kept %d of %d vertices", len(simplified), len(input)) + } + if simplified[0] != input[0] { + t.Fatal("ring simplification dropped the first vertex") + } + if closed := input[0] == input[len(input)-1]; closed != (simplified[0] == simplified[len(simplified)-1]) { + t.Fatalf("ring closure changed: input closed=%v output closed=%v", closed, simplified[0] == simplified[len(simplified)-1]) + } + if deviation := occultationRingMaxDeviation([][]occultationScreenPoint{input}, [][]occultationScreenPoint{simplified}); deviation > 0.4+1e-9 { + t.Fatalf("circle simplification deviation = %.6f px, want <= 0.4", deviation) + } + } +} + +func TestSimplifyOccultationRingKeepsDegenerateRing(t *testing.T) { + ring := []occultationScreenPoint{{x: 10, y: 10}, {x: 10.05, y: 10}, {x: 10, y: 10.05}} + simplified := simplifyOccultationRing(ring, 0.4) + if len(simplified) != len(ring) { + t.Fatalf("sub-tolerance ring simplified to %d points, want %d", len(simplified), len(ring)) + } +} + +func TestStarOccultationBandSimplificationWithinTolerance(t *testing.T) { + path := starOccultationSimplificationPath(t) + raw := occultationRenderWithTolerance(t, path, 0) + simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels) + rawRings := occultationSVGPathRings(t, raw, "occultation-band") + rings := occultationSVGPathRings(t, simplified, "occultation-band") + if len(rawRings) == 0 { + t.Fatal("stellar reference document has no occultation-band rings") + } + if len(rawRings) != len(rings) { + t.Fatalf("band ring count changed from %d to %d", len(rawRings), len(rings)) + } + deviation := occultationRingMaxDeviation(rawRings, rings) + if deviation > occultationGeometryTolerancePixels+0.01 { + t.Fatalf("band deviation = %.4f px, tolerance = %.4f px", deviation, occultationGeometryTolerancePixels) + } +} + +func TestPlanetOccultationBandSimplificationWithinTolerance(t *testing.T) { + zone := time.FixedZone("UTC+8", 8*60*60) + start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone) + paths, err := moon.FindPlanetOccultationPaths( + start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + render := func(tolerance float64) string { + t.Helper() + previous := occultationGeometryTolerancePixels + occultationGeometryTolerancePixels = tolerance + defer func() { occultationGeometryTolerancePixels = previous }() + return occultationRender(t, func() (string, error) { + return PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{Width: 1200, Height: 800}) + }) + } + raw := render(0) + simplified := render(occultationGeometryTolerancePixels) + rawVertices, simplifiedVertices := 0, 0 + for _, className := range []string{"occultation-band", "total-occultation-band"} { + rawRings := occultationSVGPathRings(t, raw, className) + rings := occultationSVGPathRings(t, simplified, className) + if len(rawRings) == 0 || len(rawRings) != len(rings) { + t.Fatalf("%s ring count changed from %d to %d", className, len(rawRings), len(rings)) + } + if deviation := occultationRingMaxDeviation(rawRings, rings); deviation > occultationGeometryTolerancePixels+0.01 { + t.Fatalf("%s deviation = %.4f px, tolerance = %.4f px", className, deviation, occultationGeometryTolerancePixels) + } + rawVertices += occultationPathVertexCount(planetOccultationSVGPathData(t, raw, className)) + simplifiedVertices += occultationPathVertexCount(planetOccultationSVGPathData(t, simplified, className)) + } + if len(simplified)*4 > len(raw)*3 { + t.Fatalf("planetary document bytes %d -> %d, want at least a 25%% reduction", len(raw), len(simplified)) + } + if simplifiedVertices*100 > rawVertices*40 { + t.Fatalf("planetary band vertices %d -> %d, want at least a 60%% reduction", rawVertices, simplifiedVertices) + } + t.Logf("planetary document %d -> %d bytes, band vertices %d -> %d", + len(raw), len(simplified), rawVertices, simplifiedVertices) +} + +func TestStarOccultationSVGSimplificationShrinksOutput(t *testing.T) { + path := starOccultationSimplificationPath(t) + raw := occultationRenderWithTolerance(t, path, 0) + simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels) + rawVertices := occultationBandVertexCount(t, raw) + simplifiedVertices := occultationBandVertexCount(t, simplified) + if rawVertices == 0 || simplifiedVertices == 0 { + t.Fatalf("band vertex count raw=%d simplified=%d", rawVertices, simplifiedVertices) + } + if len(simplified)*4 > len(raw)*3 { + t.Fatalf("document bytes %d -> %d, want at least a 25%% reduction", len(raw), len(simplified)) + } + if simplifiedVertices*100 > rawVertices*35 { + t.Fatalf("band vertices %d -> %d, want at least a 65%% reduction", rawVertices, simplifiedVertices) + } + t.Logf("document %d -> %d bytes, band vertices %d -> %d", + len(raw), len(simplified), rawVertices, simplifiedVertices) +} + +func TestStarOccultationBandSimplificationPreservesRingTopology(t *testing.T) { + path := starOccultationSimplificationPath(t) + raw := occultationRenderWithTolerance(t, path, 0) + simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels) + rawRings := occultationSVGPathRings(t, raw, "occultation-band") + rings := occultationSVGPathRings(t, simplified, "occultation-band") + if len(rawRings) != len(rings) { + t.Fatalf("band ring count %d -> %d, want unchanged", len(rawRings), len(rings)) + } + for index := range rawRings { + if len(rings[index]) < 3 { + t.Fatalf("ring %d has %d vertices after simplification, want at least 3", index, len(rings[index])) + } + if len(rings[index]) > len(rawRings[index]) { + t.Fatalf("ring %d grew from %d to %d vertices", index, len(rawRings[index]), len(rings[index])) + } + if math.Signbit(occultationRingSignedArea(rawRings[index])) != math.Signbit(occultationRingSignedArea(rings[index])) { + t.Fatalf("ring %d winding changed by simplification", index) + } + for _, point := range rings[index] { + if !occultationRingContainsVertex(rawRings[index], point) { + t.Fatalf("ring %d vertex %.3f,%.3f is not an original vertex", index, point.x, point.y) + } + } + } +} + +func occultationRingSignedArea(ring []occultationScreenPoint) float64 { + area := 0.0 + for index, point := range ring { + next := ring[(index+1)%len(ring)] + area += point.x*next.y - next.x*point.y + } + return area / 2 +} + +func occultationRingContainsVertex(ring []occultationScreenPoint, target occultationScreenPoint) bool { + for _, point := range ring { + if point == target { + return true + } + } + return false +} + +func starOccultationSimplificationPath(t testing.TB) moon.StarOccultationPath { + t.Helper() + location := time.FixedZone("CST", 8*3600) + paths, err := moon.FindStarOccultationPaths( + time.Date(2025, 6, 5, 0, 0, 0, 0, location), + time.Date(2025, 6, 6, 0, 0, 0, 0, location), + hr4799StarCoordinate(), + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + return paths[0] +} + +func occultationRenderWithTolerance( + t testing.TB, + path moon.StarOccultationPath, + tolerance float64, +) string { + t.Helper() + previous := occultationGeometryTolerancePixels + occultationGeometryTolerancePixels = tolerance + defer func() { occultationGeometryTolerancePixels = previous }() + return occultationRender(t, func() (string, error) { + return StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520}) + }) +} + +func occultationRender(t testing.TB, render func() (string, error)) string { + t.Helper() + diagram, err := render() + if err != nil { + t.Fatalf("occultation render: %v", err) + } + return diagram +} + +func occultationBandVertexCount(t testing.TB, diagram string) int { + t.Helper() + return occultationPathVertexCount(planetOccultationSVGPathData(t, diagram, "occultation-band")) +} + +func occultationPathVertexCount(pathData string) int { + count := 0 + for _, field := range strings.Fields(pathData) { + if field == "M" || field == "L" { + count++ + } + } + return count +} + +func occultationSVGPathRings(t testing.TB, diagram, className string) [][]occultationScreenPoint { + t.Helper() + fields := strings.Fields(planetOccultationSVGPathData(t, diagram, className)) + rings := make([][]occultationScreenPoint, 0, 8) + for index := 0; index < len(fields); { + switch fields[index] { + case "M": + rings = append(rings, nil) + fallthrough + case "L": + if index+2 >= len(fields) { + t.Fatalf("truncated path data in %q", className) + } + x, xErr := strconv.ParseFloat(fields[index+1], 64) + y, yErr := strconv.ParseFloat(fields[index+2], 64) + if xErr != nil || yErr != nil { + t.Fatalf("parse %q coordinate %q/%q: %v %v", className, fields[index+1], fields[index+2], xErr, yErr) + } + rings[len(rings)-1] = append(rings[len(rings)-1], occultationScreenPoint{x: x, y: y}) + index += 3 + case "Z": + index++ + default: + t.Fatalf("unexpected path command %q in %q", fields[index], className) + } + } + return rings +} + +func occultationRingMaxDeviation(raw, simplified [][]occultationScreenPoint) float64 { + maximum := 0.0 + for index := range raw { + for _, point := range raw[index] { + maximum = math.Max(maximum, occultationRingDeviation(point, simplified[index])) + } + } + return maximum +} + +func occultationRingDeviation(point occultationScreenPoint, ring []occultationScreenPoint) float64 { + minimum := math.Inf(1) + for index := range ring { + minimum = math.Min(minimum, occultationSegmentDistance(point, ring[index], ring[(index+1)%len(ring)])) + } + return minimum +} + +func occultationSegmentDistance(point, start, end occultationScreenPoint) float64 { + dx, dy := end.x-start.x, end.y-start.y + lengthSquared := dx*dx + dy*dy + position := 0.0 + if lengthSquared > 0 { + position = ((point.x-start.x)*dx + (point.y-start.y)*dy) / lengthSquared + } + if position < 0 { + position = 0 + } else if position > 1 { + position = 1 + } + return math.Hypot(point.x-(start.x+position*dx), point.y-(start.y+position*dy)) +} diff --git a/moon/svg/occultation_svg_support_test.go b/moon/svg/occultation_svg_support_test.go new file mode 100644 index 0000000..5200163 --- /dev/null +++ b/moon/svg/occultation_svg_support_test.go @@ -0,0 +1,400 @@ +package svg + +import ( + "encoding/xml" + "io" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "b612.me/astro/moon" +) + +// 本文件提供解析 SVG 产物、按保守字宽估算文本占位的测试工具。 + +type occultationSVGText struct { + class string + parent string + parentClass string + value string + x, y float64 + fontSize float64 + anchor string +} + +func occultationSVGTexts(t *testing.T, diagram string) []occultationSVGText { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(diagram)) + stack := []string{} + classes := []string{} + texts := []occultationSVGText{} + for { + token, err := decoder.Token() + if err != nil { + if err == io.EOF { + break + } + t.Fatalf("decode SVG: %v", err) + } + switch element := token.(type) { + case xml.StartElement: + stack = append(stack, element.Name.Local) + class := "" + for _, attribute := range element.Attr { + if attribute.Name.Local == "class" { + class = attribute.Value + } + } + classes = append(classes, class) + if element.Name.Local != "text" { + continue + } + text := occultationSVGText{class: class, fontSize: 10} + if len(classes) >= 2 { + text.parent = stack[len(stack)-2] + text.parentClass = classes[len(classes)-2] + } + for _, attribute := range element.Attr { + switch attribute.Name.Local { + case "x": + text.x, _ = strconv.ParseFloat(attribute.Value, 64) + case "y": + text.y, _ = strconv.ParseFloat(attribute.Value, 64) + case "font-size": + text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64) + case "text-anchor": + text.anchor = attribute.Value + } + } + texts = append(texts, text) + case xml.EndElement: + if len(stack) == 0 { + t.Fatal("unbalanced SVG element") + } + stack = stack[:len(stack)-1] + classes = classes[:len(classes)-1] + case xml.CharData: + if len(stack) == 0 || stack[len(stack)-1] != "text" || len(texts) == 0 { + continue + } + texts[len(texts)-1].value += string(element) + } + } + return texts +} + +// occultationSVGTextBox 用偏大的字宽估算占位,避免真实字体比测试度量更宽时漏判重叠。 +func occultationSVGTextBox(text occultationSVGText) [4]float64 { + fontSize := text.fontSize + if fontSize <= 0 { + fontSize = 10 + } + units := 0.0 + for _, current := range text.value { + if current < 0x80 { + units += 0.62 + continue + } + units++ + units += 0.05 + } + width := units * fontSize + left := text.x + switch text.anchor { + case "middle": + left = text.x - width/2 + case "end": + left = text.x - width + } + return [4]float64{left, text.y - fontSize*0.85, left + width, text.y + fontSize*0.3} +} + +func occultationSVGTextsOutsideCanvas(texts []occultationSVGText, width, height float64) []string { + outside := []string{} + for _, text := range texts { + box := occultationSVGTextBox(text) + if box[0] < 0 || box[2] > width || box[1] < 0 || box[3] > height { + outside = append(outside, text.value) + } + } + return outside +} + +func occultationSVGTextOverlapPairs(texts []occultationSVGText) int { + pairs := 0 + for first := 0; first < len(texts); first++ { + for second := first + 1; second < len(texts); second++ { + a, b := occultationSVGTextBox(texts[first]), occultationSVGTextBox(texts[second]) + if a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] { + pairs++ + } + } + } + return pairs +} + +// occultationSVGLabelTexts 取三族地图标签:等时线标注、中心线时刻标注、事件标记。 +func occultationSVGLabelTexts(texts []occultationSVGText) []occultationSVGText { + labels := []occultationSVGText{} + for _, text := range texts { + if strings.Contains(text.class, "greatest-time-isoline-label") || + strings.Contains(text.parentClass, "occultation-time-marker") || + strings.Contains(text.parentClass, "event-marker") { + labels = append(labels, text) + } + } + return labels +} + +// occultationSVGDetailedPanelRows 解析详细版式数据块:返回"块标题 → 行标签 → 值"。 +func occultationSVGDetailedPanelRows(t *testing.T, diagram string) map[string]map[string]string { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(diagram)) + panels := map[string]map[string]string{} + inPanel := false + texts := []occultationSVGText{} + for { + token, err := decoder.Token() + if err != nil { + if err == io.EOF { + break + } + t.Fatalf("decode SVG: %v", err) + } + switch element := token.(type) { + case xml.StartElement: + if element.Name.Local == "g" { + for _, attribute := range element.Attr { + if attribute.Name.Local == "class" && attribute.Value == "occultation-detailed-panel" { + inPanel, texts = true, nil + } + } + continue + } + if !inPanel || element.Name.Local != "text" { + continue + } + text := occultationSVGText{} + for _, attribute := range element.Attr { + if attribute.Name.Local == "text-anchor" { + text.anchor = attribute.Value + } + } + texts = append(texts, text) + case xml.EndElement: + if !inPanel || element.Name.Local != "g" { + continue + } + inPanel = false + rows := map[string]string{} + title := "" + for index := 0; index < len(texts); { + if texts[index].anchor == "end" { + index++ + continue + } + if index+1 < len(texts) && texts[index+1].anchor == "end" { + rows[texts[index].value] = texts[index+1].value + index += 2 + continue + } + if title == "" { + title = texts[index].value + } + index++ + } + panels[title] = rows + case xml.CharData: + if inPanel && len(texts) > 0 { + texts[len(texts)-1].value += string(element) + } + } + } + return panels +} + +func occultationSVGPanelValue(t *testing.T, panels map[string]map[string]string, title, label string) string { + t.Helper() + rows, ok := panels[title] + if !ok { + titles := make([]string, 0, len(panels)) + for current := range panels { + titles = append(titles, current) + } + sort.Strings(titles) + t.Fatalf("detailed SVG is missing panel %q, have %v", title, titles) + } + value, ok := rows[label] + if !ok { + labels := make([]string, 0, len(rows)) + for current := range rows { + labels = append(labels, current) + } + sort.Strings(labels) + t.Fatalf("panel %q is missing row %q, have %v", title, label, labels) + } + return value +} + +// occultationSVGNumericPrefix 取 "69.2 s" / "-5.86°" 这类文本开头的数值。 +func occultationSVGNumericPrefix(t *testing.T, value string) float64 { + t.Helper() + trimmed := strings.TrimSpace(value) + end := 0 + for end < len(trimmed) && (trimmed[end] == '+' || trimmed[end] == '-' || trimmed[end] == '.' || + (trimmed[end] >= '0' && trimmed[end] <= '9')) { + end++ + } + parsed, err := strconv.ParseFloat(trimmed[:end], 64) + if err != nil { + t.Fatalf("parse number from %q: %v", value, err) + } + return parsed +} + +// occultationSVGSexagesimal 解析 "±DD°MM'SS.S\"" 与 "HHhMMmSS.Ss" 两种六十进制文本,返回度。 +func occultationSVGSexagesimal(t *testing.T, value string) float64 { + t.Helper() + fields := strings.FieldsFunc(value, func(current rune) bool { + return current != '.' && (current < '0' || current > '9') + }) + if len(fields) != 3 { + t.Fatalf("parse sexagesimal %q: got %d fields", value, len(fields)) + } + numbers := make([]float64, 3) + for index, field := range fields { + parsed, err := strconv.ParseFloat(field, 64) + if err != nil { + t.Fatalf("parse sexagesimal field %q of %q: %v", field, value, err) + } + numbers[index] = parsed + } + degrees := numbers[0] + numbers[1]/60 + numbers[2]/3600 + if strings.HasSuffix(strings.TrimSpace(value), "s") { + degrees *= 15 + } + if strings.HasPrefix(strings.TrimSpace(value), "-") { + degrees = -degrees + } + return degrees +} + +// occultationSVGPanelRects 取详细版式数据块自身的矩形。 +func occultationSVGPanelRects(t *testing.T, diagram string) [][4]float64 { + t.Helper() + decoder := xml.NewDecoder(strings.NewReader(diagram)) + rects := [][4]float64{} + inPanel := false + for { + token, err := decoder.Token() + if err != nil { + if err == io.EOF { + break + } + t.Fatalf("decode SVG: %v", err) + } + switch element := token.(type) { + case xml.StartElement: + if element.Name.Local == "g" { + inPanel = false + for _, attribute := range element.Attr { + if attribute.Name.Local == "class" && attribute.Value == "occultation-detailed-panel" { + inPanel = true + } + } + continue + } + if !inPanel || element.Name.Local != "rect" { + continue + } + rect := [4]float64{} + for _, attribute := range element.Attr { + value, parseErr := strconv.ParseFloat(attribute.Value, 64) + if parseErr != nil { + continue + } + switch attribute.Name.Local { + case "x": + rect[0] = value + case "y": + rect[1] = value + case "width": + rect[2] = value + case "height": + rect[3] = value + } + } + rects = append(rects, rect) + case xml.EndElement: + if element.Name.Local == "g" { + inPanel = false + } + } + } + return rects +} + +var ( + occultationTestStarOnce sync.Once + occultationTestStarEvent moon.StarOccultationPath + occultationTestSaturnOnce sync.Once + occultationTestSaturnData moon.PlanetOccultationPath + occultationTestMarsOnce sync.Once + occultationTestMarsData moon.PlanetOccultationPath +) + +// occultationTestStarPath 取一个带等时线的真实恒星掩事件。 +func occultationTestStarPath(t *testing.T) moon.StarOccultationPath { + t.Helper() + occultationTestStarOnce.Do(func() { + location := time.FixedZone("CST", 8*3600) + paths, err := moon.FindStarOccultationPaths( + time.Date(2025, 6, 5, 0, 0, 0, 0, location), + time.Date(2025, 6, 6, 0, 0, 0, 0, location), + hr4799StarCoordinate(), + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, GreatestTimeStep: 30 * time.Minute}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + occultationTestStarEvent = paths[0] + }) + return occultationTestStarEvent +} + +// occultationTestSaturnPath 取一个关闭密集足迹、带等时线的真实行星掩事件。 +func occultationTestSaturnPath(t *testing.T) moon.PlanetOccultationPath { + t.Helper() + occultationTestSaturnOnce.Do(func() { + start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) + paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationSaturn, + moon.OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute}) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + occultationTestSaturnData = paths[0] + }) + return occultationTestSaturnData +} + +// occultationTestMarsPath 取一个同时带偏掩带与全掩带的真实行星掩事件。 +func occultationTestMarsPath(t *testing.T) moon.PlanetOccultationPath { + t.Helper() + occultationTestMarsOnce.Do(func() { + start := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600)) + paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationMars, + moon.OccultationPathOptions{ + Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, + DisableFootprints: true, IncludeFootprintTimeline: true, + FootprintTimelineStep: 5 * time.Minute, GreatestTimeStep: 30 * time.Minute, + }) + if err != nil || len(paths) != 1 { + t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + occultationTestMarsData = paths[0] + }) + return occultationTestMarsData +} diff --git a/moon/svg/occultation_test.go b/moon/svg/occultation_test.go index ba0f884..cd95dea 100644 --- a/moon/svg/occultation_test.go +++ b/moon/svg/occultation_test.go @@ -3,12 +3,14 @@ package svg import ( "encoding/xml" "errors" + "fmt" "io" "math" "strings" "testing" "time" + "b612.me/astro/internal/occultationgeo" "b612.me/astro/internal/svgmap" "b612.me/astro/moon" ) @@ -33,18 +35,77 @@ func TestFindStarOccultationSVGsHR4799(t *testing.T) { `= 0 { + curveEnd = strings.Index(diagram[curveIndex:], "/>") + } + if curveIndex < 0 || curveEnd < 0 || strings.Contains(diagram[curveIndex:curveIndex+curveEnd], "stroke-dasharray") { + t.Fatal("stellar rise/set phase line is rendered with a gap-producing dash pattern") + } +} + +func TestStarOccultationAutoProjectionIncludesFootprints(t *testing.T) { + path := sampleStarOccultationPath() + path.Greatest.Latitude = 70 + for index := range path.CenterLine { + path.CenterLine[index].Latitude = 65 + path.NorthernLimit[index].Latitude = 70 + path.SouthernLimit[index].Latitude = 60 + } + path.Start.Latitude = 70 + path.End.Latitude = 70 + footprintTime := path.Start.Time.Add(90 * time.Minute) + path.Footprints = []moon.OccultationFootprint{{ + Time: footprintTime, + Polygons: [][]moon.OccultationPathPoint{{ + {Time: footprintTime, Longitude: -10, Latitude: -10}, + {Time: footprintTime, Longitude: 10, Latitude: -10}, + {Time: footprintTime, Longitude: 0, Latitude: 10}, + }}, + }} + + if projection := resolveStarOccultationMapProjection(path, MapProjectionAuto); projection != svgmap.ProjectionEquirectangular { + t.Fatalf("auto projection = %q, want equirectangular for cross-hemisphere footprint", projection) + } + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) + if err != nil { + t.Fatalf("StarOccultationPathSVG() error = %v", err) + } + if !strings.Contains(diagram, "等经纬投影") { + t.Fatal("auto-projected SVG clipped a cross-hemisphere footprint into a polar map") + } +} + +func TestStarOccultationLegendOmitsDisabledRiseSetCurves(t *testing.T) { + path := sampleStarOccultationPath() + path.RiseSetCurves = nil + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) + if err != nil { + t.Fatalf("StarOccultationPathSVG: %v", err) + } + if strings.Contains(diagram, "初掩/掩甚/终掩月升月落线") || strings.Contains(diagram, "Rise/set phase lines") { + t.Fatal("stellar SVG legend claims disabled rise/set curves are present") + } } func TestPolarOccultationLayoutSeparatesLegendAndFooter(t *testing.T) { @@ -52,6 +113,7 @@ func TestPolarOccultationLayoutSeparatesLegendAndFooter(t *testing.T) { StarOccultationSVGOptions{Width: 900, Height: 760}, 110, svgmap.ProjectionNorthPolar, + svgmap.GeoPoint{}, ) legendY := layout.mapY + layout.mapHeight + 30 if gap := layout.footerY - legendY; gap < 24 { @@ -59,6 +121,145 @@ func TestPolarOccultationLayoutSeparatesLegendAndFooter(t *testing.T) { } } +func TestPolarStarOccultationSVGSplitsGrazingBoundaryBranchChanges(t *testing.T) { + tests := []struct { + name string + date time.Time + star moon.StarCoordinate + projection MapProjection + }{ + { + name: "Antares south polar", + date: time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC), + star: moon.StarCoordinate{ + ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24, + }, + projection: MapProjectionSouthPolar, + }, + { + name: "Regulus north polar", + date: time.Date(2025, time.August, 23, 0, 0, 0, 0, time.UTC), + star: moon.StarCoordinate{ + ID: "Regulus", RA: 152.09291666666667, Dec: 11.967222222222222, + Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000, + ProperMotionRACosDecMasPerYear: -248, ProperMotionDecMasPerYear: 6, ParallaxMas: 45, + }, + projection: MapProjectionNorthPolar, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + paths, err := moon.FindStarOccultationPaths( + test.date, test.date.Add(24*time.Hour), test.star, + moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, + ) + if err != nil || len(paths) != 1 { + t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err) + } + path := paths[0] + if len(path.Footprints) == 0 { + t.Fatal("stellar path has no instantaneous footprints") + } + if ranges := occultationgeo.ContinuousPairedBoundaryRanges(path.NorthernLimit, path.SouthernLimit); len(ranges) < 2 { + t.Fatalf("continuous band ranges = %d, want branch change to be split", len(ranges)) + } + projection := internalMapProjection(test.projection) + for _, limit := range [][]moon.OccultationPathPoint{path.NorthernLimit, path.SouthernLimit} { + for _, segment := range starOccultationBoundarySegmentsForProjection(limit, svgmap.ClipView{Projection: projection}) { + for index := 1; index < len(segment); index++ { + if distance := occultationgeo.DistanceKM(segment[index-1], segment[index]); distance > occultationgeo.BoundaryBranchJumpKM+1e-6 { + t.Fatalf("rendered boundary segment still spans %.1f km branch change", distance) + } + } + } + } + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{ + Width: 1200, Height: 800, Location: time.UTC, Projection: test.projection, + }) + if err != nil { + t.Fatalf("StarOccultationPathSVG() error = %v", err) + } + if strings.Count(diagram, `class="occultation-band"`) != 1 { + t.Fatal("instantaneous footprints were not rendered as one compound sweep") + } + for _, className := range []string{`class="northern-limit"`, `class="southern-limit"`} { + if !strings.Contains(diagram, className) { + t.Fatalf("footprint sweep SVG missing split boundary %s", className) + } + } + if err := validateXML(diagram); err != nil { + t.Fatalf("generated SVG is not valid XML: %v", err) + } + }) + } +} + +func TestStarOccultationPathSVGPreservesEndpointBranchFragments(t *testing.T) { + start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + for _, count := range []int{2, 3} { + t.Run(fmt.Sprintf("%d points", count), func(t *testing.T) { + path := svgEndpointBranchJumpPath(start, count) + segments := starOccultationBoundarySegmentsForProjection( + path.NorthernLimit, svgmap.ClipView{Projection: svgmap.ProjectionEquirectangular}, + ) + if len(segments) != 2 { + t.Fatalf("north-limit segment count = %d, want two discontinuous fragments", len(segments)) + } + if !segments[0][0].Time.Equal(path.Start.Time) || + !segments[len(segments)-1][len(segments[len(segments)-1])-1].Time.Equal(path.End.Time) { + t.Fatal("split SVG boundary does not retain start and end samples") + } + for _, segment := range segments { + for index := 1; index < len(segment); index++ { + if distance := occultationgeo.DistanceKM(segment[index-1], segment[index]); distance > occultationgeo.BoundaryBranchJumpKM+1e-6 { + t.Fatalf("endpoint fragment spans an impossible %.1f km jump", distance) + } + } + } + + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{ + Projection: MapProjectionEquirectangular, + }) + if err != nil { + t.Fatalf("StarOccultationPathSVG: %v", err) + } + if got := strings.Count(diagram, `class="occultation-band"`); got != 2 { + t.Fatalf("endpoint-band section count = %d, want 2", got) + } + if got := strings.Count(diagram, `class="northern-limit"`); got != 2 { + t.Fatalf("north-limit path count = %d, want 2", got) + } + if err := validateXML(diagram); err != nil { + t.Fatalf("generated SVG is not valid XML: %v", err) + } + }) + } +} + +func svgEndpointBranchJumpPath(start time.Time, count int) moon.StarOccultationPath { + north := make([]moon.OccultationPathPoint, count) + south := make([]moon.OccultationPathPoint, count) + for index := range north { + when := start.Add(time.Duration(index) * time.Second) + longitude := 30.0 + float64(index)/10 + if index == 0 { + longitude = 0 + } + north[index] = moon.OccultationPathPoint{ + Time: when, Longitude: longitude, Latitude: 10, MoonAltitude: 20, + } + south[index] = moon.OccultationPathPoint{ + Time: when, Longitude: longitude, Latitude: -10, MoonAltitude: 20, + } + } + return moon.StarOccultationPath{ + TargetID: "endpoint-jump", Start: north[0], Greatest: north[0], End: north[count-1], + Complete: true, NorthernLimit: north, SouthernLimit: south, Step: time.Second, + } +} + func TestStarOccultationPathSVGEnglishAndCustomText(t *testing.T) { path := sampleStarOccultationPath() path.TargetID = "Alpha < Beta & Gamma" @@ -273,6 +474,95 @@ func TestStarOccultationPathSVGRejectsInvalidPath(t *testing.T) { } } +func TestStarOccultationPathSVGRejectsMalformedRiseSetCurves(t *testing.T) { + tests := []struct { + name string + mutate func(*moon.OccultationRiseSetCurve) + }{ + {name: "phase", mutate: func(curve *moon.OccultationRiseSetCurve) { + curve.Phase = moon.RiseSetPhase("bogus") + }}, + {name: "direction", mutate: func(curve *moon.OccultationRiseSetCurve) { + curve.Direction = moon.RiseSetDirection("bogus") + }}, + {name: "coordinate", mutate: func(curve *moon.OccultationRiseSetCurve) { + curve.Segments[0][1].Longitude = math.NaN() + }}, + {name: "time order", mutate: func(curve *moon.OccultationRiseSetCurve) { + curve.Segments[0][1].Time = curve.Segments[0][0].Time + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := sampleStarOccultationPath() + curve := sampleOccultationRiseSetCurve(path.Start.Time) + test.mutate(&curve) + path.RiseSetCurves = []moon.OccultationRiseSetCurve{curve} + _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) + if !errors.Is(err, ErrInvalidStarOccultationPath) { + t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err) + } + }) + } +} + +func TestStarOccultationPathSVGRejectsMalformedFootprint(t *testing.T) { + path := sampleStarOccultationPath() + path.Footprints = []moon.OccultationFootprint{{Time: path.Start.Time}} + _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) + if !errors.Is(err, ErrInvalidStarOccultationPath) { + t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err) + } +} + +func TestStarOccultationPathSVGCompactBandUsesClosedOutlineWithoutRawLimits(t *testing.T) { + path := sampleStarOccultationPath() + makeFootprint := func(when time.Time, west, east float64) moon.OccultationFootprint { + return moon.OccultationFootprint{ + Time: when, + Polygons: [][]moon.OccultationPathPoint{{ + {Time: when, Longitude: west, Latitude: -10}, + {Time: when, Longitude: east, Latitude: -10}, + {Time: when, Longitude: east, Latitude: 10}, + {Time: when, Longitude: west, Latitude: 10}, + }}, + } + } + path.BandFootprints = []moon.OccultationFootprint{ + makeFootprint(path.Start.Time.Add(time.Hour), -20, 5), + makeFootprint(path.Start.Time.Add(2*time.Hour), -5, 20), + } + diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) + if err != nil { + t.Fatalf("StarOccultationPathSVG: %v", err) + } + if strings.Contains(diagram, `class="northern-limit"`) || strings.Contains(diagram, `class="southern-limit"`) { + t.Fatal("compact stellar SVG still overlays discontinuous raw limits") + } + pathData := planetOccultationSVGPathData(t, diagram, "occultation-band") + if !strings.Contains(pathData, "Z") { + t.Fatal("compact stellar SVG band is not explicitly closed") + } +} + +func TestStarOccultationPathSVGAllowsDenseAndCompactFootprints(t *testing.T) { + path := sampleStarOccultationPath() + when := path.Greatest.Time + footprint := moon.OccultationFootprint{ + Time: when, + Polygons: [][]moon.OccultationPathPoint{{ + {Time: when, Longitude: -10, Latitude: -10}, + {Time: when, Longitude: 10, Latitude: -10}, + {Time: when, Longitude: 0, Latitude: 10}, + }}, + } + path.Footprints = []moon.OccultationFootprint{footprint} + path.BandFootprints = []moon.OccultationFootprint{footprint} + if _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}); err != nil { + t.Fatalf("StarOccultationPathSVG() rejected coexisting static and timed footprints: %v", err) + } +} + func TestStarOccultationPathSVGRejectsMisalignedLimits(t *testing.T) { path := sampleStarOccultationPath() path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second) @@ -332,6 +622,17 @@ func sampleStarOccultationPath() moon.StarOccultationPath { } } +func sampleOccultationRiseSetCurve(start time.Time) moon.OccultationRiseSetCurve { + return moon.OccultationRiseSetCurve{ + Phase: moon.RiseSetPhaseStart, + Direction: moon.RiseSetDirectionRise, + Segments: [][]moon.OccultationPathPoint{{ + {Time: start.Add(20 * time.Minute), Longitude: 10, Latitude: 20, MoonAltitude: 0}, + {Time: start.Add(40 * time.Minute), Longitude: 12, Latitude: 21, MoonAltitude: 0}, + }}, + } +} + func validateXML(value string) error { decoder := xml.NewDecoder(strings.NewReader(value)) for { diff --git a/moon/svg/occultation_time_labels.go b/moon/svg/occultation_time_labels.go index 3ffad82..9ffe784 100644 --- a/moon/svg/occultation_time_labels.go +++ b/moon/svg/occultation_time_labels.go @@ -17,6 +17,7 @@ func writeOccultationTimeMarkers( options StarOccultationSVGOptions, excluded []time.Time, greatest time.Time, + placer *occultationLabelPlacer, ) { if options.TimeLabelStep <= 0 || len(points) < 2 { return @@ -42,15 +43,28 @@ func writeOccultationTimeMarkers( if tooClose { continue } - projected = append(projected, [2]float64{x, y}) - labelY := y - 8 + text := marker.Time.In(options.Location).Format("15:04") + // 掩甚附近的标签默认压到圆点下方,给掩甚事件标记让位;两个方向都被占用就丢弃这个时刻。 + baselines := []float64{y - 8, y + 15} if occultationTimesNear(marker.Time, greatest, occultationGreatestTimeLabelWindow(options.TimeLabelStep)) { - labelY = y + 15 - } else if labelY < frame.Y+10 { - labelY = y + 15 + baselines[0], baselines[1] = baselines[1], baselines[0] } + labelY, placed := 0.0, false + for _, baseline := range baselines { + if baseline < frame.Y+10 || baseline > frame.Y+frame.Height-8 { + continue + } + if placer.place(x, baseline, "middle", text, 9, 2) { + labelY, placed = baseline, true + break + } + } + if !placed { + continue + } + projected = append(projected, [2]float64{x, y}) fmt.Fprintf(b, `%s`, - x, y, x, labelY, html.EscapeString(marker.Time.In(options.Location).Format("15:04"))) + x, y, x, labelY, html.EscapeString(text)) } } diff --git a/neptune/doc.go b/neptune/doc.go new file mode 100644 index 0000000..13a0695 --- /dev/null +++ b/neptune/doc.go @@ -0,0 +1,3 @@ +// Package neptune 海王星位置、升落、合冲、留、方照、相位、视星等、视直径与物理星历,角度单位为度。 +// Package neptune covers Neptune position, rise/set, conjunction, opposition, station, quadrature, phase, magnitude, apparent diameter and physical ephemeris; angles are degrees. +package neptune diff --git a/orbit/parallactic.go b/orbit/parallactic.go index 3014c14..5182aae 100644 --- a/orbit/parallactic.go +++ b/orbit/parallactic.go @@ -11,10 +11,15 @@ import ( // 返回轨道目标在观测者所在地的视差角,单位度;`observerLon` 东经为正,`observerLat` 北纬为正,`observerHeight` 单位米。 // Returns the parallactic angle of the orbital target for the observing site, in degrees. `observerLon` is east-positive, `observerLat` is north-positive, and `observerHeight` is in meters. func ParallacticAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 { - position := ApparentTopocentricEquatorial(date, elements, observerLon, observerLat, observerHeight) - return basic.ParallacticAngleByHourAngle( - HourAngle(date, elements, observerLon, observerLat, observerHeight), - position.Dec, + // 时角与赤纬须取自同一次站心求解:分属两条儒略日路径(相差 1 ULP ≈ 40 µs)会引入 ≤2e-9 度漂移, + // 因此统一到同一时刻后的亚纳度量级输出变化是有意为之,不是纯性能改动。 + _, dec, hourAngle := basic.OrbitHourAngleWithTopocentric( + basic.Date2JDE(date), + observerLon, observerLat, + observationTimezone(date), + observerHeight, + toBasicElements(elements), ) + return basic.ParallacticAngleByHourAngle(hourAngle, dec, observerLat) } diff --git a/orbit/parallactic_test.go b/orbit/parallactic_test.go index a84ca3f..c9a77d8 100644 --- a/orbit/parallactic_test.go +++ b/orbit/parallactic_test.go @@ -1,7 +1,9 @@ package orbit import ( + "fmt" "math" + "math/rand" "testing" "time" @@ -12,14 +14,166 @@ func TestParallacticAngleMatchesHourAngleForm(t *testing.T) { elements := sampleObservationElements() date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)) + _, dec, _ := basic.OrbitHourAngleWithTopocentric( + basic.Date2JDE(date), + shanghaiLon, shanghaiLat, observationTimezone(date), shanghaiHeightMeters, + toBasicElements(elements), + ) got := ParallacticAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters) - position := ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters) want := basic.ParallacticAngleByHourAngle( HourAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters), - position.Dec, + dec, shanghaiLat, ) - if math.Abs(got-want) > 1e-12 { - t.Fatalf("parallactic angle mismatch: got %.15f want %.15f", got, want) + if got != want { + t.Fatalf("parallactic angle mismatch: got %.17g want %.17g", got, want) + } +} + +// 旧口径把时角与赤纬放在相差 1 ULP 儒略日的两条路径上求值;差分对照用它作参考实现。 +func duplicatedSolveParallacticAngle(date time.Time, elements Elements, observerLon, observerLat, observerHeight float64) float64 { + position := ApparentTopocentricEquatorial(date, elements, observerLon, observerLat, observerHeight) + return basic.ParallacticAngleByHourAngle( + HourAngle(date, elements, observerLon, observerLat, observerHeight), + position.Dec, + observerLat, + ) +} + +func parallacticJDPathsAgree(date time.Time) bool { + utcPath := basic.TD2UT(basic.Date2JDE(date.UTC()), true) + hourAnglePath := basic.TD2UT(basic.Date2JDE(date)-observationTimezone(date)/24.0, true) + return utcPath == hourAnglePath +} + +type parallacticCase struct { + label string + date time.Time + elements Elements + lon, lat float64 +} + +func parallacticElementSets() []struct { + name string + elements Elements +} { + return []struct { + name string + elements Elements + }{ + {"mainbelt", sampleObservationElements()}, + {"neo", Elements{EpochJD: 2461000.5, A: 1.2, E: 0.35, I: 8, Omega: 200.1, W: 120.5, M0: 10}}, + {"perihelion", Elements{EpochJD: 2461000.5, Q: 0.6, E: 0.7, I: 15, Omega: 120, W: 33, TpJD: 2461005.5}}, + {"highinclination", Elements{EpochJD: 2461000.5, A: 3.2, E: 0.3, I: 160, Omega: 20, W: 260, M0: 100}}, + } +} + +func parallacticFixedCases() []parallacticCase { + zones := []*time.Location{ + time.UTC, + time.FixedZone("CST", 8*3600), + time.FixedZone("EST", -5*3600), + time.FixedZone("NPT", 5*3600+45*60), + time.FixedZone("LMT", -7*3600-52*60-58), + } + sites := []struct { + name string + lon, lat float64 + }{ + {"shanghai", 121.4737, 31.2304}, + {"sydney", 151.2093, -33.8688}, + {"north-pole", 0, 89.9999}, + {"south-pole", 0, -89.9999}, + {"dateline-west", -179.99, 12}, + {"dateline-east", 179.99, -12}, + {"reykjavik", -21.8174, 64.1265}, + {"equator", 0, 0}, + } + years := []int{-500, 1000, 1582, 1900, 2025, 2100, 3000, 4000} + hours := []int{0, 3, 7, 12, 17, 20, 23} + cases := make([]parallacticCase, 0, len(parallacticElementSets())*len(sites)*len(years)*len(zones)*len(hours)) + for _, set := range parallacticElementSets() { + for _, site := range sites { + for _, year := range years { + for _, zone := range zones { + for _, hour := range hours { + cases = append(cases, parallacticCase{ + label: fmt.Sprintf("%s/%s/%d/%s/%d", set.name, site.name, year, zone, hour), + date: time.Date(year, 5, 17, hour, 43, 21, 123456789, zone), + elements: set.elements, + lon: site.lon, + lat: site.lat, + }) + } + } + } + } + } + return cases +} + +func parallacticRandomCases(count int) []parallacticCase { + rng := rand.New(rand.NewSource(20260915)) + sets := parallacticElementSets() + cases := make([]parallacticCase, 0, count) + for i := 0; i < count; i++ { + set := sets[rng.Intn(len(sets))] + zone := time.FixedZone("random", (rng.Intn(97)-48)*1800) + date := time.Date( + rng.Intn(8000)-2000, + time.Month(1+rng.Intn(12)), + 1+rng.Intn(28), + rng.Intn(24), rng.Intn(60), rng.Intn(60), rng.Intn(1000000000), + zone, + ) + cases = append(cases, parallacticCase{ + label: fmt.Sprintf("random/%d/%s", i, set.name), + date: date, + elements: set.elements, + lon: rng.Float64()*360 - 180, + lat: rng.Float64()*179.8 - 89.9, + }) + } + return cases +} + +func TestParallacticAngleDifferentialAgainstDuplicatedSolve(t *testing.T) { + const ( + maxAbsoluteTolerance = 2e-9 + maxRelativeTolerance = 5e-11 + ) + cases := append(parallacticFixedCases(), parallacticRandomCases(240)...) + bitwise, drifted := 0, 0 + maxAbsolute, maxRelative := 0.0, 0.0 + maxAbsoluteCase, maxRelativeCase := "", "" + for _, tc := range cases { + got := ParallacticAngle(tc.date, tc.elements, tc.lon, tc.lat, shanghaiHeightMeters) + want := duplicatedSolveParallacticAngle(tc.date, tc.elements, tc.lon, tc.lat, shanghaiHeightMeters) + if got == want { + bitwise++ + continue + } + if parallacticJDPathsAgree(tc.date) { + t.Fatalf("%s: 两条儒略日路径逐位相等,输出必须逐位相同,got %.17g want %.17g", tc.label, got, want) + } + drifted++ + diff := math.Abs(got - want) + if diff > maxAbsolute { + maxAbsolute, maxAbsoluteCase = diff, tc.label + } + if math.Abs(want) > 1 { + if relative := diff / math.Abs(want); relative > maxRelative { + maxRelative, maxRelativeCase = relative, tc.label + } + } + } + t.Logf("cases=%d bitwise=%d (%.1f%%) drifted=%d maxAbs=%.4g deg (%s) maxRel=%.4g (%s)", + len(cases), bitwise, 100*float64(bitwise)/float64(len(cases)), drifted, + maxAbsolute, maxAbsoluteCase, maxRelative, maxRelativeCase) + if maxAbsolute > maxAbsoluteTolerance { + t.Fatalf("max absolute drift %.4g deg exceeds %.4g (%s)", maxAbsolute, maxAbsoluteTolerance, maxAbsoluteCase) + } + if maxRelative > maxRelativeTolerance { + t.Fatalf("max relative drift %.4g exceeds %.4g (%s)", maxRelative, maxRelativeTolerance, maxRelativeCase) } } diff --git a/orbit/perf_bench_test.go b/orbit/perf_bench_test.go new file mode 100644 index 0000000..95cd17d --- /dev/null +++ b/orbit/perf_bench_test.go @@ -0,0 +1,59 @@ +package orbit + +import ( + "testing" + "time" +) + +var ( + benchmarkParallacticSink float64 + benchmarkPositionSink EquatorialPosition +) + +func benchmarkObservationInputs() (time.Time, Elements) { + return time.Date(2025, 11, 21, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)), sampleObservationElements() +} + +func BenchmarkParallacticAngle(b *testing.B) { + date, elements := benchmarkObservationInputs() + b.ReportAllocs() + b.ResetTimer() + sink := 0.0 + for i := 0; i < b.N; i++ { + sink = ParallacticAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters) + } + benchmarkParallacticSink = sink +} + +func BenchmarkParallacticAngleLegacy(b *testing.B) { + date, elements := benchmarkObservationInputs() + b.ReportAllocs() + b.ResetTimer() + sink := 0.0 + for i := 0; i < b.N; i++ { + sink = duplicatedSolveParallacticAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters) + } + benchmarkParallacticSink = sink +} + +func BenchmarkApparentTopocentricEquatorial(b *testing.B) { + date, elements := benchmarkObservationInputs() + b.ReportAllocs() + b.ResetTimer() + sink := EquatorialPosition{} + for i := 0; i < b.N; i++ { + sink = ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters) + } + benchmarkPositionSink = sink +} + +func BenchmarkHourAngle(b *testing.B) { + date, elements := benchmarkObservationInputs() + b.ReportAllocs() + b.ResetTimer() + sink := 0.0 + for i := 0; i < b.N; i++ { + sink = HourAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters) + } + benchmarkParallacticSink = sink +} diff --git a/planet/doc.go b/planet/doc.go new file mode 100644 index 0000000..066181a --- /dev/null +++ b/planet/doc.go @@ -0,0 +1,3 @@ +// Package planet 用内置 VSOP87 截断级数求行星黄经、黄纬与日心距离;jd 为 TT 儒略日,角度单位为度,距离单位为 AU。 +// Package planet evaluates the built-in truncated VSOP87 series for planetary longitude, latitude and heliocentric distance; jd is a TT Julian day, angles are degrees and distances are AU. +package planet diff --git a/planet/planet.go b/planet/planet.go index bda41af..b8385a4 100644 --- a/planet/planet.go +++ b/planet/planet.go @@ -5,16 +5,25 @@ import ( "math" ) -// WherePlanet returns the full VSOP result for the selected body and coordinate. +// WherePlanet 天体 xt 在儒略日 jd 的 VSOP 结果 / VSOP result for body xt at Julian day jd. +// +// xt 取 -1 或 0..7:0 为地球,1..7 依次为水星、金星、火星、木星、土星、天王星、海王星;-1 表示地球,zn 取 0 时给日心黄经。 +// zn 取 0 黄经、1 黄纬、2 日心距(AU);xt 或 zn 越界返回 NaN 而不 panic。 +// xt is -1 or 0..7 (0 Earth, 1..7 Mercury through Neptune; -1 selects Earth, giving its heliocentric longitude for zn 0). +// zn is 0 longitude, 1 latitude, 2 heliocentric distance in AU; an out-of-range xt or zn yields NaN instead of panicking. func WherePlanet(xt, zn int, jd float64) float64 { return WherePlanetN(xt, zn, jd, -1) } -// WherePlanetN returns the VSOP result for the selected body and coordinate. -// When n < 0, all terms are used. Otherwise n follows the original eph0.js -// truncation semantics: keep roughly n principal terms from the 0th order -// series and scale higher-order series proportionally. +// WherePlanetN 同 WherePlanet 的截断版 / truncated form of WherePlanet. +// +// n < 0 时使用全部项;否则保留约 n 个主项并按比例缩短高阶项。取值域与越界行为同 WherePlanet。 +// When n < 0 all terms are used; otherwise roughly n principal terms are kept and higher orders scaled proportionally. Domain and out-of-range behavior match WherePlanet. func WherePlanetN(xt, zn int, jd float64, n int) float64 { + if xt < -1 || xt > 7 || zn < 0 || zn > 2 { + return math.NaN() + } + sata := 0 if xt == -1 { xt = 0 diff --git a/planet/planet_bench_test.go b/planet/planet_bench_test.go new file mode 100644 index 0000000..fce1bf7 --- /dev/null +++ b/planet/planet_bench_test.go @@ -0,0 +1,13 @@ +package planet + +import "testing" + +func BenchmarkWherePlanet(b *testing.B) { + const jd = 2451545.0 + var sink float64 + b.ReportAllocs() + for i := 0; i < b.N; i++ { + sink += WherePlanet(4, 0, jd) + } + _ = sink +} diff --git a/planet/planet_test.go b/planet/planet_test.go index b169e80..d1f463f 100644 --- a/planet/planet_test.go +++ b/planet/planet_test.go @@ -5,25 +5,6 @@ import ( "testing" ) -func TestWherePlanetNFullMatchesDefault(t *testing.T) { - jds := []float64{ - 2415020.123456789, - 2451545.0, - 2469808.7654321, - } - for _, jd := range jds { - for xt := -1; xt < 8; xt++ { - for zn := 0; zn < 3; zn++ { - got := WherePlanet(xt, zn, jd) - gotN := WherePlanetN(xt, zn, jd, -1) - if math.Float64bits(got) != math.Float64bits(gotN) { - t.Fatalf("jd=%f xt=%d zn=%d full mismatch: got=%v gotN=%v", jd, xt, zn, got, gotN) - } - } - } - } -} - func TestPlanetViewsMatchRawCuts(t *testing.T) { views := planetViews() for bodyIndex, raw := range planetRawData { @@ -55,3 +36,69 @@ func TestBuildPlanetViewsRejectsInvalidCuts(t *testing.T) { t.Fatal("expected invalid cut error") } } + +func TestWherePlanetDomainGuards(t *testing.T) { + jds := []float64{2415020.123456789, 2451545.0, 2469808.7654321} + for _, jd := range jds { + for xt := -1; xt <= 7; xt++ { + for zn := 0; zn <= 2; zn++ { + for _, n := range []int{-1, 0, 3, 100} { + got := WherePlanetN(xt, zn, jd, n) + if math.IsNaN(got) || math.IsInf(got, 0) { + t.Fatalf("in-domain WherePlanetN(%d, %d, %v, %d) = %v", xt, zn, jd, n, got) + } + } + got := WherePlanet(xt, zn, jd) + if math.IsNaN(got) || math.IsInf(got, 0) { + t.Fatalf("in-domain WherePlanet(%d, %d, %v) = %v", xt, zn, jd, got) + } + } + } + for _, xt := range []int{-2, -100, 8, 9, 100} { + for zn := 0; zn <= 2; zn++ { + assertWherePlanetNaN(t, xt, zn, jd) + } + } + for _, zn := range []int{-1, -100, 3, 100} { + for xt := -1; xt <= 7; xt++ { + assertWherePlanetNaN(t, xt, zn, jd) + } + } + } +} + +func assertWherePlanetNaN(t *testing.T, xt, zn int, jd float64) { + t.Helper() + if got := WherePlanet(xt, zn, jd); !math.IsNaN(got) { + t.Fatalf("WherePlanet(%d, %d, %v) = %v, want NaN", xt, zn, jd, got) + } + for _, n := range []int{-1, 0, 6} { + if got := WherePlanetN(xt, zn, jd, n); !math.IsNaN(got) { + t.Fatalf("WherePlanetN(%d, %d, %v, %d) = %v, want NaN", xt, zn, jd, n, got) + } + } +} + +func TestWherePlanetGoldenValues(t *testing.T) { + jd := 2451545.0 + cases := []struct { + xt, zn int + want float64 + }{ + {-1, 0, 100.37782037193487}, + {-1, 1, -0.00022991463883506049}, + {-1, 2, 0.98332764743480028}, + {0, 0, 280.37782037193489}, + {0, 1, 0.00022991463883506049}, + {0, 2, 0.98332764743480028}, + {4, 0, 36.29462069188007}, + {4, 1, -1.1745385942079292}, + {4, 2, 4.9653814383525443}, + } + for _, tc := range cases { + got := WherePlanet(tc.xt, tc.zn, jd) + if math.Abs(got-tc.want) > 1e-12 { + t.Fatalf("WherePlanet(%d, %d, %v) = %.17g, want %.17g", tc.xt, tc.zn, jd, got, tc.want) + } + } +} diff --git a/planet/sun_low.go b/planet/sun_low.go index 309a25e..7eef2d8 100644 --- a/planet/sun_low.go +++ b/planet/sun_low.go @@ -24,7 +24,7 @@ func Earthe(JD float64) float64 { func EarthPI(JD float64) float64 { T := (JD - 2451545) / 36525 - return 102.93735 + 1.71953*T + 000046*T*T + return 102.93735 + 1.71953*T + 0.00046*T*T } func SunMidFun(JD float64) float64 { diff --git a/saturn/doc.go b/saturn/doc.go new file mode 100644 index 0000000..1ddbcd7 --- /dev/null +++ b/saturn/doc.go @@ -0,0 +1,3 @@ +// Package saturn 土星位置、升落、合冲、留、方照、相位、视直径、物理星历与土星环观测参数;角度单位为度,环长短轴单位为角秒。 +// Package saturn covers Saturn position, rise/set, conjunction, opposition, station, quadrature, phase, diameter, physical ephemeris and ring parameters; angles are degrees and ring axes are arcseconds. +package saturn diff --git a/semantics_regression_test.go b/semantics_regression_test.go index cc13aa7..2a3b667 100644 --- a/semantics_regression_test.go +++ b/semantics_regression_test.go @@ -156,33 +156,101 @@ func TestCalendarAddPreservesOriginalTimezone(t *testing.T) { } } -func TestObservationZenithSemantics(t *testing.T) { - date := time.Date(2026, 4, 26, 9, 30, 45, 123456789, time.FixedZone("CST", 8*3600)) - lon := 116.391 - lat := 39.907 - ra := 6.752477 - dec := -16.716116 - - checks := []struct { +func TestObservationZenithMatchesIndependentFormula(t *testing.T) { + places := []struct { name string - altitude func() float64 - zenith func() float64 + lon, lat float64 }{ - {"sun", func() float64 { return sun.Altitude(date, lon, lat) }, func() float64 { return sun.Zenith(date, lon, lat) }}, - {"moon", func() float64 { return moon.Altitude(date, lon, lat) }, func() float64 { return moon.Zenith(date, lon, lat) }}, - {"star", func() float64 { return star.Altitude(date, ra, dec, lon, lat) }, func() float64 { return star.Zenith(date, ra, dec, lon, lat) }}, - {"mercury", func() float64 { return mercury.Altitude(date, lon, lat) }, func() float64 { return mercury.Zenith(date, lon, lat) }}, - {"venus", func() float64 { return venus.Altitude(date, lon, lat) }, func() float64 { return venus.Zenith(date, lon, lat) }}, - {"mars", func() float64 { return mars.Altitude(date, lon, lat) }, func() float64 { return mars.Zenith(date, lon, lat) }}, - {"jupiter", func() float64 { return jupiter.Altitude(date, lon, lat) }, func() float64 { return jupiter.Zenith(date, lon, lat) }}, - {"saturn", func() float64 { return saturn.Altitude(date, lon, lat) }, func() float64 { return saturn.Zenith(date, lon, lat) }}, - {"uranus", func() float64 { return uranus.Altitude(date, lon, lat) }, func() float64 { return uranus.Zenith(date, lon, lat) }}, - {"neptune", func() float64 { return neptune.Altitude(date, lon, lat) }, func() float64 { return neptune.Zenith(date, lon, lat) }}, + {"beijing", 116.391, 39.907}, + {"sydney", 151.2093, -33.8688}, + {"tromso", 18.9553, 69.6492}, } + dates := []time.Time{ + time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), + time.Date(2024, 2, 29, 23, 59, 59, 0, time.UTC), + time.Date(2026, 4, 26, 9, 30, 45, 123456789, time.FixedZone("CST", 8*3600)), + time.Date(2100, 6, 15, 3, 4, 5, 0, time.UTC), + } + starRa := 6.752477 + starDec := -16.716116 - for _, tc := range checks { - if !nearlyEqual(tc.zenith(), 90-tc.altitude()) { - t.Fatalf("%s zenith should equal 90-altitude", tc.name) + // 容差取实测最大偏差的 5 倍以上;太阳还含视位置的入口差异,月光低精度级数与高精度级数本身不同源。 + const ( + sunTolerance = 2e-3 + moonTolerance = 5e-8 + moonLowTolerance = 3e-3 + starTolerance = 1e-12 + planetTolerance = 5e-9 + ) + + for _, date := range dates { + for _, place := range places { + jde := basic.Date2JDE(date) + _, loc := date.Zone() + timezone := float64(loc) / 3600.0 + tt := basic.TD2UT(jde-timezone/24, true) + + checks := []struct { + name string + tol float64 + zenith float64 + witness float64 + }{ + {"sun", sunTolerance, + sun.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(sun.HourAngle(date, place.lon, place.lat), basic.HSunApparentDec(tt), place.lat)}, + {"moon", moonTolerance, + moon.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(moon.HourAngle(date, place.lon, place.lat), moon.ApparentDec(date, place.lon, place.lat), place.lat)}, + {"moon-low-precision-series", moonLowTolerance, + moon.Zenith(date, place.lon, place.lat), + 90 - basic.MoonHeight(jde, place.lon, place.lat, timezone)}, + {"star", starTolerance, + star.Zenith(date, starRa, starDec, place.lon, place.lat), + zenithFromHourAngle(star.HourAngle(date, starRa, place.lon), starDec, place.lat)}, + {"mercury", planetTolerance, + mercury.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(mercury.HourAngle(date, place.lon), mercury.ApparentDec(date), place.lat)}, + {"venus", planetTolerance, + venus.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(venus.HourAngle(date, place.lon), venus.ApparentDec(date), place.lat)}, + {"mars", planetTolerance, + mars.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(mars.HourAngle(date, place.lon), mars.ApparentDec(date), place.lat)}, + {"jupiter", planetTolerance, + jupiter.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(jupiter.HourAngle(date, place.lon), jupiter.ApparentDec(date), place.lat)}, + {"saturn", planetTolerance, + saturn.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(saturn.HourAngle(date, place.lon), saturn.ApparentDec(date), place.lat)}, + {"uranus", planetTolerance, + uranus.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(uranus.HourAngle(date, place.lon), uranus.ApparentDec(date), place.lat)}, + {"neptune", planetTolerance, + neptune.Zenith(date, place.lon, place.lat), + zenithFromHourAngle(neptune.HourAngle(date, place.lon), neptune.ApparentDec(date), place.lat)}, + } + + for _, tc := range checks { + if delta := math.Abs(tc.zenith - tc.witness); delta > tc.tol { + t.Fatalf("%s %s at %s: zenith %.9f, independent formula %.9f, delta %.3g > %.3g", + place.name, tc.name, date.Format(time.RFC3339), tc.zenith, tc.witness, delta, tc.tol) + } + } } } } + +// zenithFromHourAngle 由时角与赤纬按 cos z = sinφ·sinδ + cosφ·cosδ·cos H 独立求天顶距,单位度。 +func zenithFromHourAngle(hourAngle, dec, lat float64) float64 { + rad := math.Pi / 180 + sinZenith := math.Sin(lat*rad)*math.Sin(dec*rad) + math.Cos(dec*rad)*math.Cos(lat*rad)*math.Cos(hourAngle*rad) + if sinZenith > 1 { + sinZenith = 1 + } + if sinZenith < -1 { + sinZenith = -1 + } + return math.Acos(sinZenith) / rad +} diff --git a/star/doc.go b/star/doc.go new file mode 100644 index 0000000..9f3446e --- /dev/null +++ b/star/doc.go @@ -0,0 +1,3 @@ +// Package star 内置恒星星表的查询、自行与岁差修正、星座判定,以及恒星升落与地平坐标计算;角度单位为度。 +// Package star provides the built-in star catalogue, proper-motion and precession corrections, constellation lookup, and sidereal rise/set and horizontal coordinates; angles are degrees. +package star diff --git a/sun/doc.go b/sun/doc.go new file mode 100644 index 0000000..bb0c689 --- /dev/null +++ b/sun/doc.go @@ -0,0 +1,3 @@ +// Package sun 太阳位置、升落、晨昏朦影、均时差、真太阳时、视高度角、视差角、视直径与日面物理星历;角度单位为度。 +// Package sun covers solar position, rise/set, twilight, equation of time, apparent solar time, apparent altitude, parallactic angle, diameter and solar physical ephemeris; angles are degrees. +package sun diff --git a/testdata/low_precision_sun_moon_baseline.json b/testdata/low_precision_sun_moon_baseline.json index 5f0e102..4df4233 100644 --- a/testdata/low_precision_sun_moon_baseline.json +++ b/testdata/low_precision_sun_moon_baseline.json @@ -6,12 +6,12 @@ "lo_bits": 4643644058337657344, "m_bits": 4616775564505907200, "ecc_bits": 4580520928358041124, - "peri_bits": 4649377394771943990, + "peri_bits": 4636461720405767355, "mid_bits": 4594776565210069001, "true_lo_bits": 4643646796884981335, "apparent_lo_bits": 4643646779016942587, "apparent_ra_bits": 4643663430560358501, - "apparent_dec_bits": 13850554665364115628, + "apparent_dec_bits": 13850554517814798795, "true_ra_bits": 4643663449915719638, "true_dec_bits": 13850554494693696930, "distance_bits": 4607030926112900801 @@ -39,12 +39,12 @@ "lo_bits": 4636635766038654976, "m_bits": 4640564147063524864, "ecc_bits": 4580509121120802973, - "peri_bits": 4646424355368144153, + "peri_bits": 4636581882168598594, "mid_bits": 13806306770763298295, "true_lo_bits": 4636633989302204757, "apparent_lo_bits": 4636633461574336297, "apparent_ra_bits": 4636686688732540312, - "apparent_dec_bits": 4627223074107447880, + "apparent_dec_bits": 4627223732328497022, "true_ra_bits": 4636687261705707799, "true_dec_bits": 4627223596653323917, "distance_bits": 4607258227846077786 @@ -72,12 +72,12 @@ "lo_bits": 4644663444316210944, "m_bits": 4633506899028791296, "ecc_bits": 4580497222720634402, - "peri_bits": 4643044072868313348, + "peri_bits": 4636702316308681060, "mid_bits": 4610190699819423505, "true_lo_bits": 4644692787599987415, "apparent_lo_bits": 4644692645316864696, "apparent_ra_bits": 4644719454521518373, - "apparent_dec_bits": 13843783428063435972, + "apparent_dec_bits": 13843782646540569447, "true_ra_bits": 4644719587451476549, "true_dec_bits": 13843779202867127003, "distance_bits": 4607106526163669669 @@ -105,12 +105,12 @@ "lo_bits": 4643636766106037632, "m_bits": 4645022708557196416, "ecc_bits": 4580485232921475862, - "peri_bits": 4639102272369378328, + "peri_bits": 4636823021843746790, "mid_bits": 13808604515146542494, "true_lo_bits": 4643636147007251503, "apparent_lo_bits": 4643636129494972159, "apparent_ra_bits": 4643651833165467615, - "apparent_dec_bits": 13850556768239290697, + "apparent_dec_bits": 13850556901372162958, "true_ra_bits": 4643651852143578166, "true_dec_bits": 13850556880005534356, "distance_bits": 4607031577601555095 @@ -138,12 +138,12 @@ "lo_bits": 4636648304887805440, "m_bits": 4640419307800570368, "ecc_bits": 4580479141683729330, - "peri_bits": 4637539388006146402, + "peri_bits": 4636884096802829354, "mid_bits": 4592573283667125214, "true_lo_bits": 4636656028386832745, "apparent_lo_bits": 4636655613258973362, "apparent_ra_bits": 4636710597219641895, - "apparent_dec_bits": 4627210992221268029, + "apparent_dec_bits": 4627210282461323623, "true_ra_bits": 4636711047711583668, "true_dec_bits": 4627210171998067590, "distance_bits": 4607257641359679559 @@ -171,12 +171,12 @@ "lo_bits": 4643641647158238862, "m_bits": 4644997347677479789, "ecc_bits": 4580473152371062391, - "peri_bits": 4636943988987807554, + "peri_bits": 4636943988987807553, "mid_bits": 13814109226243925204, "true_lo_bits": 4643640164548686620, "apparent_lo_bits": 4643639995599776967, "apparent_ra_bits": 4643656001901566158, - "apparent_dec_bits": 13850547872533939253, + "apparent_dec_bits": 13850548278254742155, "true_ra_bits": 4643656184927171754, "true_dec_bits": 13850548067788586233, "distance_bits": 4607032074606135011 @@ -204,12 +204,12 @@ "lo_bits": 4644676610264216736, "m_bits": 4633070399441404160, "ecc_bits": 4580470222631626536, - "peri_bits": 4637129347503631564, + "peri_bits": 4636973228271323083, "mid_bits": 4609902029738458784, "true_lo_bits": 4644704825930489439, "apparent_lo_bits": 4644704700269409783, "apparent_ra_bits": 4644730646568804127, - "apparent_dec_bits": 13843481330210166292, + "apparent_dec_bits": 13843480498549103166, "true_ra_bits": 4644730763848246811, "true_dec_bits": 13843477449777195210, "distance_bits": 4607099950694890560 @@ -237,12 +237,12 @@ "lo_bits": 4643633548918856192, "m_bits": 4644867817863428608, "ecc_bits": 4580423994212105140, - "peri_bits": 4649551846325065240, + "peri_bits": 4637429712208079499, "mid_bits": 13822995073203971438, "true_lo_bits": 4643627736988747511, "apparent_lo_bits": 4643627716231975215, "apparent_ra_bits": 4643642625943235273, - "apparent_dec_bits": 13850549508253547401, + "apparent_dec_bits": 13850549273567939670, "true_ra_bits": 4643642648439205801, "true_dec_bits": 13850549249490457681, "distance_bits": 4607035728140217014 @@ -270,12 +270,12 @@ "lo_bits": 4643648581124559360, "m_bits": 4616046903886512128, "ecc_bits": 4580515122135725850, - "peri_bits": 4648158106378165605, + "peri_bits": 4636520891781248230, "mid_bits": 4594047583764237540, "true_lo_bits": 4643650963723911754, "apparent_lo_bits": 4643650795292750825, "apparent_ra_bits": 4643667772137941606, - "apparent_dec_bits": 13850548182315143967, + "apparent_dec_bits": 13850547771447375077, "true_ra_bits": 4643667954530826493, "true_dec_bits": 13850547548933587289, "distance_bits": 4607030988966524392 @@ -303,12 +303,12 @@ "lo_bits": 4635114475696813056, "m_bits": 4639823469501786624, "ecc_bits": 4580513052263384740, - "peri_bits": 4647763016986683524, + "peri_bits": 4636541947610479374, "mid_bits": 4604086839050321266, "true_lo_bits": 4635160660224196219, "apparent_lo_bits": 4635159931862766420, "apparent_ra_bits": 4635083812697850719, - "apparent_dec_bits": 4627139729550925958, + "apparent_dec_bits": 4627139573946821679, "true_ra_bits": 4635084599966773193, "true_dec_bits": 4627139844799885186, "distance_bits": 4607253740013734327 @@ -336,12 +336,12 @@ "lo_bits": 4642471880649184768, "m_bits": 4644292493820059136, "ecc_bits": 4580510979574450803, - "peri_bits": 4647061444395765700, + "peri_bits": 4636563012021903545, "mid_bits": 13832018202691806464, "true_lo_bits": 4642425260753369270, "apparent_lo_bits": 4642424894868082969, "apparent_ra_bits": 4642340739408665535, - "apparent_dec_bits": 13849333668862996028, + "apparent_dec_bits": 13849333765317160029, "true_ra_bits": 4642341113530469667, "true_dec_bits": 13849334495048372187, "distance_bits": 4607071852338779563 @@ -369,12 +369,12 @@ "lo_bits": 4629226535416561664, "m_bits": 4637617679197842432, "ecc_bits": 4580509024654024666, - "peri_bits": 4646392192433320384, + "peri_bits": 4636582861222975542, "mid_bits": 4610638605190517844, "true_lo_bits": 4629712219489774243, "apparent_lo_bits": 4629711005276479666, "apparent_ra_bits": 4629106856616482695, - "apparent_dec_bits": 4623064347734820667, + "apparent_dec_bits": 4623064914319925286, "true_ra_bits": 4629109188408861817, "true_dec_bits": 4623066591681475750, "distance_bits": 4607212526388758075 @@ -402,12 +402,12 @@ "lo_bits": 4639860497881697792, "m_bits": 4642741976332601344, "ecc_bits": 4580506956891418352, - "peri_bits": 4645724247507404038, + "peri_bits": 4636603836838817802, "mid_bits": 13833689597705550354, "true_lo_bits": 4639800820212337420, "apparent_lo_bits": 4639800580115676824, "apparent_ra_bits": 4639856836472972290, - "apparent_dec_bits": 4620795661824867277, + "apparent_dec_bits": 4620796125410618631, "true_ra_bits": 4639857061252598534, "true_dec_bits": 4620794681524468941, "distance_bits": 4607218216005663465 @@ -435,12 +435,12 @@ "lo_bits": 4644302415117701120, "m_bits": 4630775907616403456, "ecc_bits": 4580504997374976128, - "peri_bits": 4645129045633135239, + "peri_bits": 4636623696115599789, "mid_bits": 4608307090651215030, "true_lo_bits": 4644324400553164277, "apparent_lo_bits": 4644324340414249948, "apparent_ra_bits": 4644367057197067252, - "apparent_dec_bits": 13848040022809564379, + "apparent_dec_bits": 13848040808678184673, "true_ra_bits": 4644367116354578653, "true_dec_bits": 13848040207122370062, "distance_bits": 4607066828512794217 @@ -468,12 +468,12 @@ "lo_bits": 4637729832661136384, "m_bits": 4641079740252059648, "ecc_bits": 4580502916931919439, - "peri_bits": 4644537223466640791, + "peri_bits": 4636644761550167882, "mid_bits": 13826069330827936926, "true_lo_bits": 4637694503954668535, "apparent_lo_bits": 4637694365665809598, "apparent_ra_bits": 4637825826774916747, - "apparent_dec_bits": 4626717696997484901, + "apparent_dec_bits": 4626718104708545188, "true_ra_bits": 4637825973113886491, "true_dec_bits": 4626718009969746275, "distance_bits": 4607255499859210307 @@ -501,12 +501,12 @@ "lo_bits": 4643478039990150912, "m_bits": 4644903280164258048, "ecc_bits": 4580500833996435882, - "peri_bits": 4643985951693047396, + "peri_bits": 4636665832258702824, "mid_bits": 13821862078965514854, "true_lo_bits": 4643473334499728223, "apparent_lo_bits": 4643473315216038563, "apparent_ra_bits": 4643474740578517171, - "apparent_dec_bits": 13850669949862898816, + "apparent_dec_bits": 13850670148966844320, "true_ra_bits": 4643474761600224300, "true_dec_bits": 13850670146864765779, "distance_bits": 4607032519815102541 @@ -534,12 +534,12 @@ "lo_bits": 4630810153250676736, "m_bits": 4638188276478841856, "ecc_bits": 4580498877957156626, - "peri_bits": 4643505737796394972, + "peri_bits": 4636685601170325049, "mid_bits": 4610059032403586898, "true_lo_bits": 4631040784914143611, "apparent_lo_bits": 4631040580360800107, "apparent_ra_bits": 4630697244228937240, - "apparent_dec_bits": 4624807391264929910, + "apparent_dec_bits": 4624806970516022677, "true_ra_bits": 4630697445919117484, "true_dec_bits": 4624807223446494674, "distance_bits": 4607221884318453887 @@ -567,12 +567,12 @@ "lo_bits": 4641097388279128064, "m_bits": 4643569332120008192, "ecc_bits": 4580496791164855209, - "peri_bits": 4642855420539981452, + "peri_bits": 4636706672248155324, "mid_bits": 13834681281692511829, "true_lo_bits": 4641029963078619555, "apparent_lo_bits": 4641029870064854005, "apparent_ra_bits": 4640990539523607545, - "apparent_dec_bits": 13841283955278182776, + "apparent_dec_bits": 13841283460003192702, "true_ra_bits": 4640990625643940401, "true_dec_bits": 13841284615428939105, "distance_bits": 4607168198537222469 @@ -600,12 +600,12 @@ "lo_bits": 4644921571798050176, "m_bits": 4634863743093166592, "ecc_bits": 4580494821669327801, - "peri_bits": 4642039019246959624, + "peri_bits": 4636726540715379202, "mid_bits": 4611027132988382365, "true_lo_bits": 4644954182398892893, "apparent_lo_bits": 4644954078629025582, "apparent_ra_bits": 4644961219578173220, - "apparent_dec_bits": 13834883104251921223, + "apparent_dec_bits": 13834882195780562263, "true_ra_bits": 4644961314877807279, "true_dec_bits": 13834871650610940186, "distance_bits": 4607141522583696971 @@ -633,12 +633,12 @@ "lo_bits": 4639491715413716736, "m_bits": 4642301301968069120, "ecc_bits": 4580492730327791068, - "peri_bits": 4641251945476968542, + "peri_bits": 4636747619063450857, "mid_bits": 13832607772525815814, "true_lo_bits": 4639440489503573040, "apparent_lo_bits": 4639440217517639539, "apparent_ra_bits": 4639515681237794715, - "apparent_dec_bits": 4622886731143363602, + "apparent_dec_bits": 4622886098644539126, "true_ra_bits": 4639515941803946207, "true_dec_bits": 4622884583650518734, "distance_bits": 4607231583786443023 @@ -666,12 +666,12 @@ "lo_bits": 4643650576939941760, "m_bits": 4603101343998607360, "ecc_bits": 4580490765744919716, - "peri_bits": 4640587259827787428, + "peri_bits": 4636767401760841571, "mid_bits": 4581057193304433049, "true_lo_bits": 4643650906528070179, "apparent_lo_bits": 4643650728033648150, "apparent_ra_bits": 4643667657475985654, - "apparent_dec_bits": 13850540670331933866, + "apparent_dec_bits": 13850540415292657121, "true_ra_bits": 4643667850730935946, "true_dec_bits": 13850540179860960823, "distance_bits": 4607031386673058168 @@ -699,12 +699,12 @@ "lo_bits": 4635053119554652160, "m_bits": 4639669525155915520, "ecc_bits": 4580488670862963692, - "peri_bits": 4639957953018082758, + "peri_bits": 4636788477302921647, "mid_bits": 4605258962426249454, "true_lo_bits": 4635108461295909762, "apparent_lo_bits": 4635107724676750253, "apparent_ra_bits": 4635027611040898794, - "apparent_dec_bits": 4627112170358726533, + "apparent_dec_bits": 4627112190617032666, "true_ra_bits": 4635028406260429600, "true_dec_bits": 4627112480111589711, "distance_bits": 4607251244880979615 @@ -732,12 +732,12 @@ "lo_bits": 4642474422568557568, "m_bits": 4644232125147572480, "ecc_bits": 4580486572511977680, - "peri_bits": 4639409572658869749, + "peri_bits": 4636809567915278155, "mid_bits": 13832358731282106288, "true_lo_bits": 4642425142293130353, "apparent_lo_bits": 4642424788842767249, "apparent_ra_bits": 4642340833224879483, - "apparent_dec_bits": 13849327367157934343, + "apparent_dec_bits": 13849327598674723508, "true_ra_bits": 4642341194612323391, "true_dec_bits": 13849328302798982330, "distance_bits": 4607078861478806357 @@ -765,12 +765,12 @@ "lo_bits": 4628969732682795008, "m_bits": 4637306901789911040, "ecc_bits": 4580484594038815014, - "peri_bits": 4638967433264781111, + "peri_bits": 4636829435507879546, "mid_bits": 4610840270174314629, "true_lo_bits": 4629479823370399240, "apparent_lo_bits": 4629477686945087037, "apparent_ra_bits": 4628874315714714990, - "apparent_dec_bits": 4622887746826783542, + "apparent_dec_bits": 4622888389255436630, "true_ra_bits": 4628876362748794459, "true_dec_bits": 4622889875405354718, "distance_bits": 4607206982710368665 @@ -798,12 +798,12 @@ "lo_bits": 4639828412481315328, "m_bits": 4642586591201621120, "ecc_bits": 4580482501041140959, - "peri_bits": 4638449594761716964, + "peri_bits": 4636850434053298886, "mid_bits": 13833316320575292354, "true_lo_bits": 4639771651039535096, "apparent_lo_bits": 4639771453108564192, "apparent_ra_bits": 4639829414757483271, - "apparent_dec_bits": 4620965218775265467, + "apparent_dec_bits": 4620965714391547940, "true_ra_bits": 4639829600384615177, "true_dec_bits": 4620964531141628443, "distance_bits": 4607223001154311403 @@ -831,12 +831,12 @@ "lo_bits": 4644304426023293376, "m_bits": 4630298745969669632, "ecc_bits": 4580480517290811106, - "peri_bits": 4637861852867919904, + "peri_bits": 4636870318484115388, "mid_bits": 4607872781910952920, "true_lo_bits": 4644324714940239884, "apparent_lo_bits": 4644324671990042742, "apparent_ra_bits": 4644367286332975223, - "apparent_dec_bits": 13848027722289156767, + "apparent_dec_bits": 13848028377294730257, "true_ra_bits": 4644367328581936921, "true_dec_bits": 13848027948050858561, "distance_bits": 4607061776310376995 @@ -864,12 +864,12 @@ "lo_bits": 4637665645294606464, "m_bits": 4640924324642303552, "ecc_bits": 4580478411853593220, - "peri_bits": 4637396543688867973, + "peri_bits": 4636891403491943735, "mid_bits": 13823501080069033348, "true_lo_bits": 4637640420984855090, "apparent_lo_bits": 4637640327118362810, "apparent_ra_bits": 4637768290600542807, - "apparent_dec_bits": 4626747656239825860, + "apparent_dec_bits": 4626747925873248599, "true_ra_bits": 4637768390095360815, "true_dec_bits": 4626747863566230443, "distance_bits": 4607256414645116778 @@ -897,12 +897,12 @@ "lo_bits": 4643479350661480768, "m_bits": 4644842923324937824, "ecc_bits": 4580476303244184612, - "peri_bits": 4637093610312804792, + "peri_bits": 4636912500542021573, "mid_bits": 13823916602905698884, "true_lo_bits": 4643472638800022743, "apparent_lo_bits": 4643472622758356221, "apparent_ra_bits": 4643473982475776062, - "apparent_dec_bits": 13850662909342468723, + "apparent_dec_bits": 13850662929456847410, "true_ra_bits": 4643473999959900130, "true_dec_bits": 13850662927786964015, "distance_bits": 4607034822445407754 @@ -930,12 +930,12 @@ "lo_bits": 4630681971163624448, "m_bits": 4637877497232515200, "ecc_bits": 4580474323761501774, - "peri_bits": 4636957294962380904, + "peri_bits": 4636932287743697803, "mid_bits": 4610345537152876451, "true_lo_bits": 4630921556100506621, "apparent_lo_bits": 4630921254586631974, "apparent_ra_bits": 4630580507006044170, - "apparent_dec_bits": 4624650098885375539, + "apparent_dec_bits": 4624649500882831479, "true_ra_bits": 4630580803572331634, "true_dec_bits": 4624649877653979393, "distance_bits": 4607216659633457038 @@ -963,12 +963,12 @@ "lo_bits": 4641136134901744576, "m_bits": 4643527025965630496, "ecc_bits": 4580472210953503006, - "peri_bits": 4636969524078091509, + "peri_bits": 4636953388606403085, "mid_bits": 13834662363714906541, "true_lo_bits": 4641068857497936109, "apparent_lo_bits": 4641068728919098000, "apparent_ra_bits": 4641026638242305373, - "apparent_dec_bits": 13841758710598027973, + "apparent_dec_bits": 13841758083648179262, "true_ra_bits": 4641026757491837952, "true_dec_bits": 13841759672392804552, "distance_bits": 4607174563832411103 @@ -996,12 +996,12 @@ "lo_bits": 4644923621943924288, "m_bits": 4634625207522599168, "ecc_bits": 4580470217899302163, - "peri_bits": 4637129899100696424, + "peri_bits": 4636973275470196730, "mid_bits": 4610832909827171651, "true_lo_bits": 4644955473860543525, "apparent_lo_bits": 4644955349256387480, "apparent_ra_bits": 4644962370695142936, - "apparent_dec_bits": 13834744587230334639, + "apparent_dec_bits": 13834743729914545627, "true_ra_bits": 4644962485147378186, "true_dec_bits": 13834731079988869587, "distance_bits": 4607133186319026680 @@ -1029,12 +1029,12 @@ "lo_bits": 4639529061721721088, "m_bits": 4642215267146885184, "ecc_bits": 4580468100896248742, - "peri_bits": 4637458028502513588, + "peri_bits": 4636994380020973683, "mid_bits": 13832332369754734219, "true_lo_bits": 4639479987395726467, "apparent_lo_bits": 4639479679864576913, "apparent_ra_bits": 4639553263380144410, - "apparent_dec_bits": 4622658591512877150, + "apparent_dec_bits": 4622658071226923322, "true_ra_bits": 4639553557197708881, "true_dec_bits": 4622656342680121656, "distance_bits": 4607233694919725726 @@ -1062,12 +1062,12 @@ "lo_bits": 4643651913809921664, "m_bits": 4644990066255869632, "ecc_bits": 4580466112863070158, - "peri_bits": 4637913719721727848, + "peri_bits": 4637014181014195517, "mid_bits": 13815115361806267668, "true_lo_bits": 4643650185561804397, "apparent_lo_bits": 4643650001957306056, "apparent_ra_bits": 4643666829604143687, - "apparent_dec_bits": 13850533987867506527, + "apparent_dec_bits": 13850533904793708619, "true_ra_bits": 4643667028364403402, "true_dec_bits": 13850533663821576644, "distance_bits": 4607032346810983341 @@ -1095,12 +1095,12 @@ "lo_bits": 4635127803897299968, "m_bits": 4639583463960337664, "ecc_bits": 4580463992313272081, - "peri_bits": 4638556836685370784, + "peri_bits": 4637035282888245175, "mid_bits": 4605875137000928194, "true_lo_bits": 4635187959502422248, "apparent_lo_bits": 4635187235856747955, "apparent_ra_bits": 4635113697604546671, - "apparent_dec_bits": 4627134505323392840, + "apparent_dec_bits": 4627134699410918112, "true_ra_bits": 4635114479897843712, "true_dec_bits": 4627134959621662605, "distance_bits": 4607249533832176131 @@ -1128,12 +1128,12 @@ "lo_bits": 4642478566429780480, "m_bits": 4644172490444369920, "ecc_bits": 4580461868930742733, - "peri_bits": 4639035191658886220, + "peri_bits": 4637056393361143899, "mid_bits": 13832667558910283092, "true_lo_bits": 4642426873438508133, "apparent_lo_bits": 4642426542105331379, "apparent_ra_bits": 4642342827795675694, - "apparent_dec_bits": 13849324779067776946, + "apparent_dec_bits": 13849325131271861358, "true_ra_bits": 4642343166603393511, "true_dec_bits": 13849325789806225566, "distance_bits": 4607086098122004589 @@ -1161,12 +1161,12 @@ "lo_bits": 4629280370365796352, "m_bits": 4637137709708078080, "ecc_bits": 4580459866245357677, - "peri_bits": 4639489282167451924, + "peri_bits": 4637076285964668789, "mid_bits": 4610909695023708037, "true_lo_bits": 4629747608521678780, "apparent_lo_bits": 4629746702081072667, "apparent_ra_bits": 4629178272442395219, - "apparent_dec_bits": 4623098305766772240, + "apparent_dec_bits": 4623099020523590665, "true_ra_bits": 4629180014684530080, "true_dec_bits": 4623100266838617978, "distance_bits": 4607203868315774236 @@ -1194,12 +1194,12 @@ "lo_bits": 4639865777957045760, "m_bits": 4642500519548406784, "ecc_bits": 4580457748026176918, - "peri_bits": 4640047538445718390, + "peri_bits": 4637097307298897142, "mid_bits": 13833076794768169930, "true_lo_bits": 4639810887810633672, "apparent_lo_bits": 4639810731600957453, "apparent_ra_bits": 4639866088096703296, - "apparent_dec_bits": 4620724825338373909, + "apparent_dec_bits": 4620725279905432208, "true_ra_bits": 4639866234308289413, "true_dec_bits": 4620724341004389306, "distance_bits": 4607225426082702852 @@ -1227,12 +1227,12 @@ "lo_bits": 4644305786703058688, "m_bits": 4629815846135303168, "ecc_bits": 4580455740715503681, - "peri_bits": 4640650318181899221, + "peri_bits": 4637117210127992823, "mid_bits": 4607418126954723429, "true_lo_bits": 4644324299624082424, "apparent_lo_bits": 4644324270253172148, "apparent_ra_bits": 4644366794497322294, - "apparent_dec_bits": 13848022814652798073, + "apparent_dec_bits": 13848023297904341268, "true_ra_bits": 4644366823391985765, "true_dec_bits": 13848023004794089411, "distance_bits": 4607057159377670471 @@ -1260,12 +1260,12 @@ "lo_bits": 4637743357731580928, "m_bits": 4640839721310231808, "ecc_bits": 4580453609594789897, - "peri_bits": 4641368590002599690, + "peri_bits": 4637138321609352634, "mid_bits": 13822075771890070091, "true_lo_bits": 4637723701031903630, "apparent_lo_bits": 4637723631962512688, "apparent_ra_bits": 4637856159102930986, - "apparent_dec_bits": 4626684638162403031, + "apparent_dec_bits": 4626684749884306686, "true_ra_bits": 4637856232103715031, "true_dec_bits": 4626684701921541955, "distance_bits": 4607256551967526981 @@ -1293,12 +1293,12 @@ "lo_bits": 4643481443547599744, "m_bits": 4644783281506056192, "ecc_bits": 4580451475973452203, - "peri_bits": 4642168261191902566, + "peri_bits": 4637159438374316544, "mid_bits": 13825898935033280542, "true_lo_bits": 4643472795814923378, "apparent_lo_bits": 4643472777703040725, "apparent_ra_bits": 4643474148096245854, - "apparent_dec_bits": 13850655790887720545, + "apparent_dec_bits": 13850655630766733529, "true_ra_bits": 4643474167832860447, "true_dec_bits": 13850655628864262042, "distance_bits": 4607037633624626721 @@ -1326,12 +1326,12 @@ "lo_bits": 4630831590056771584, "m_bits": 4637705343069343744, "ecc_bits": 4580449472398524201, - "peri_bits": 4642992357232931694, + "peri_bits": 4637179250350526416, "mid_bits": 4610465791983084381, "true_lo_bits": 4631074932957097755, "apparent_lo_bits": 4631074503731686511, "apparent_ra_bits": 4630732262085295154, - "apparent_dec_bits": 4624831034617998931, + "apparent_dec_bits": 4624830273179773932, "true_ra_bits": 4630732685638261268, "true_dec_bits": 4624830800927554607, "distance_bits": 4607213621210582067 @@ -1359,12 +1359,12 @@ "lo_bits": 4641104217933449728, "m_bits": 4643449322471892224, "ecc_bits": 4580447334914858636, - "peri_bits": 4643580321767041475, + "peri_bits": 4637200367613609372, "mid_bits": 13834624191231904602, "true_lo_bits": 4641037238752164713, "apparent_lo_bits": 4641037069804191997, "apparent_ra_bits": 4640997382995874712, - "apparent_dec_bits": 13841360135111653792, + "apparent_dec_bits": 13841359494223755550, "true_ra_bits": 4640997539521074611, "true_dec_bits": 13841361586835750058, "distance_bits": 4607184274367808410 @@ -1392,12 +1392,12 @@ "lo_bits": 4644925010491899648, "m_bits": 4634383756617431040, "ecc_bits": 4580445317638411765, - "peri_bits": 4644068717092354080, + "peri_bits": 4637220279498147207, "mid_bits": 4610608325335339861, "true_lo_bits": 4644955985125347667, "apparent_lo_bits": 4644955841232569367, "apparent_ra_bits": 4644962806851631035, - "apparent_dec_bits": 13834685219359694251, + "apparent_dec_bits": 13834684458520099415, "true_ra_bits": 4644962939045683159, "true_dec_bits": 13834669865650506242, "distance_bits": 4607125006588384103 @@ -1425,12 +1425,12 @@ "lo_bits": 4639498598144061952, "m_bits": 4642061291069645312, "ecc_bits": 4580443175611395556, - "peri_bits": 4644626287587513659, + "peri_bits": 4637241403898900045, "mid_bits": 13831841681692539937, "true_lo_bits": 4639453357318553224, "apparent_lo_bits": 4639453020798363227, "apparent_ra_bits": 4639527595582102265, - "apparent_dec_bits": 4622800521038128109, + "apparent_dec_bits": 4622800115790144777, "true_ra_bits": 4639527917743942814, "true_dec_bits": 4622798239273391344, "distance_bits": 4607237405238517047 @@ -1458,12 +1458,12 @@ "lo_bits": 4643654030538121216, "m_bits": 4644930420488286720, "ecc_bits": 4580441163462711974, - "peri_bits": 4645186508443372366, + "peri_bits": 4637261229816140373, "mid_bits": 13820186864397548643, "true_lo_bits": 4643650296745517584, "apparent_lo_bits": 4643650113308511910, "apparent_ra_bits": 4643666908331727188, - "apparent_dec_bits": 13850526175978197671, + "apparent_dec_bits": 13850526270135960434, "true_ra_bits": 4643667106871808560, "true_dec_bits": 13850526029558312761, "distance_bits": 4607033855565688677 @@ -1491,12 +1491,12 @@ "lo_bits": 4635064108161617920, "m_bits": 4639428081277923840, "ecc_bits": 4580439017924983471, - "peri_bits": 4645822659504310424, + "peri_bits": 4637282351260755913, "mid_bits": 4606972191538761570, "true_lo_bits": 4635132834505317023, "apparent_lo_bits": 4635132144430762639, "apparent_ra_bits": 4635054366317792482, - "apparent_dec_bits": 4627106480504464939, + "apparent_dec_bits": 4627106835996495176, "true_ra_bits": 4635055111387420569, "true_dec_bits": 4627107099557137110, "distance_bits": 4607246444951378696 @@ -1524,12 +1524,12 @@ "lo_bits": 4642481417490959360, "m_bits": 4644112141958486272, "ecc_bits": 4580436868858398623, - "peri_bits": 4646499873530576599, + "peri_bits": 4637303488083003861, "mid_bits": 13832952123246554899, "true_lo_bits": 4642427501340809890, "apparent_lo_bits": 4642427200415695856, "apparent_ra_bits": 4642343701502424818, - "apparent_dec_bits": 13849320057686985889, + "apparent_dec_bits": 13849320508308913626, "true_ra_bits": 4642344009218816572, "true_dec_bits": 13849321105438367695, "distance_bits": 4607093692965775650 @@ -1557,12 +1557,12 @@ "lo_bits": 4629025702515875840, "m_bits": 4636826928206385152, "ecc_bits": 4580434842628636345, - "peri_bits": 4647174951778055249, + "peri_bits": 4637323399066790921, "mid_bits": 4611032704151624537, "true_lo_bits": 4629547820327061942, "apparent_lo_bits": 4629546346768985502, "apparent_ra_bits": 4628942892495826319, - "apparent_dec_bits": 4622920731742269652, + "apparent_dec_bits": 4622921434011903063, "true_ra_bits": 4628944305573582895, "true_dec_bits": 4622922454443850373, "distance_bits": 4607198201501875367 diff --git a/testdata/planet_moon_baseline.json b/testdata/planet_moon_baseline.json index 343f174..06c7640 100644 --- a/testdata/planet_moon_baseline.json +++ b/testdata/planet_moon_baseline.json @@ -61,8 +61,8 @@ } ], "moon": { - "lon_bits": 4642703566356152320, - "lat_bits": 13838280003701194988, + "lon_bits": 4642703566356165663, + "lat_bits": 13838280003701175786, "dis_bits": 4690687534481092685 } }, @@ -128,8 +128,8 @@ } ], "moon": { - "lon_bits": 4643462260046901248, - "lat_bits": 4616917233542457983, + "lon_bits": 4643462260046889600, + "lat_bits": 4616917233542464674, "dis_bits": 4690599235908759957 } }, @@ -195,8 +195,8 @@ } ], "moon": { - "lon_bits": 4630873213473505280, - "lat_bits": 4607454259782934213, + "lon_bits": 4630873213473501197, + "lat_bits": 4607454259782928460, "dis_bits": 4690676867789391739 } }, @@ -262,8 +262,8 @@ } ], "moon": { - "lon_bits": 4643499933447367680, - "lat_bits": 4607670097670251456, + "lon_bits": 4643499933447369438, + "lat_bits": 4607670097670234719, "dis_bits": 4690072502213175515 } }, @@ -329,8 +329,8 @@ } ], "moon": { - "lon_bits": 4643951937002011648, - "lat_bits": 13840133036405474167, + "lon_bits": 4643951937002009729, + "lat_bits": 13840133036405473337, "dis_bits": 4690106641539113360 } }, @@ -463,8 +463,8 @@ } ], "moon": { - "lon_bits": 4641961590079382016, - "lat_bits": 13835431727641386174, + "lon_bits": 4641961590079382001, + "lat_bits": 13835431727641387292, "dis_bits": 4690597354355432970 } }, @@ -530,8 +530,8 @@ } ], "moon": { - "lon_bits": 4636051534344601600, - "lat_bits": 4610276332064136409, + "lon_bits": 4636051534344597080, + "lat_bits": 4610276332064209395, "dis_bits": 4690405490165985423 } }, @@ -597,8 +597,8 @@ } ], "moon": { - "lon_bits": 4643324952289411072, - "lat_bits": 13835727040415344443, + "lon_bits": 4643324952289412002, + "lat_bits": 13835727040415404105, "dis_bits": 4689935845115404279 } }, @@ -664,8 +664,8 @@ } ], "moon": { - "lon_bits": 4643767394002661376, - "lat_bits": 13836618269050289008, + "lon_bits": 4643767394002659643, + "lat_bits": 13836618269050263998, "dis_bits": 4690040806150944578 } }, @@ -731,8 +731,8 @@ } ], "moon": { - "lon_bits": 4644615338753896448, - "lat_bits": 13839935011675626470, + "lon_bits": 4644615338753891637, + "lat_bits": 13839935011675630965, "dis_bits": 4690525021527648079 } }, @@ -798,8 +798,8 @@ } ], "moon": { - "lon_bits": 4641561823828017152, - "lat_bits": 4616879893060677424, + "lon_bits": 4641561823828015805, + "lat_bits": 4616879893060689393, "dis_bits": 4689969299237321900 } }, @@ -865,8 +865,8 @@ } ], "moon": { - "lon_bits": 4643067207359193088, - "lat_bits": 4617632817424287862, + "lon_bits": 4643067207359181942, + "lat_bits": 4617632817424290027, "dis_bits": 4690403894211723378 } }, @@ -932,8 +932,8 @@ } ], "moon": { - "lon_bits": 4639548304658636800, - "lat_bits": 13839724589381895624, + "lon_bits": 4639548304658646665, + "lat_bits": 13839724589381894061, "dis_bits": 4689978621382180904 } }, @@ -999,8 +999,8 @@ } ], "moon": { - "lon_bits": 4640381333447770112, - "lat_bits": 13839858919025616129, + "lon_bits": 4640381333447754323, + "lat_bits": 13839858919025607733, "dis_bits": 4690237814202972083 } }, @@ -1066,8 +1066,8 @@ } ], "moon": { - "lon_bits": 4641516329377808384, - "lat_bits": 13839276807482655928, + "lon_bits": 4641516329377805521, + "lat_bits": 13839276807482647191, "dis_bits": 4690588446011074333 } }, @@ -1133,8 +1133,8 @@ } ], "moon": { - "lon_bits": 4636031558655107072, - "lat_bits": 4612279728193544657, + "lon_bits": 4636031558655112088, + "lat_bits": 4612279728193564013, "dis_bits": 4690095339893654795 } }, @@ -1200,8 +1200,8 @@ } ], "moon": { - "lon_bits": 4637513385011052544, - "lat_bits": 4613156052910299241, + "lon_bits": 4637513385011062140, + "lat_bits": 4613156052910295524, "dis_bits": 4690395263187889419 } }, @@ -1267,8 +1267,8 @@ } ], "moon": { - "lon_bits": 4622488175933456384, - "lat_bits": 4605905212356789923, + "lon_bits": 4622488175933564896, + "lat_bits": 4605905212356804062, "dis_bits": 4689971568426458407 } }, @@ -1334,8 +1334,8 @@ } ], "moon": { - "lon_bits": 4632021230455177216, - "lat_bits": 4611952842806216276, + "lon_bits": 4632021230455203942, + "lat_bits": 4611952842806220179, "dis_bits": 4690345535861477141 } }, @@ -1401,8 +1401,8 @@ } ], "moon": { - "lon_bits": 4644240129305743360, - "lat_bits": 13840352907797288057, + "lon_bits": 4644240129305741743, + "lat_bits": 13840352907797281919, "dis_bits": 4690021506005158681 } }, @@ -1468,8 +1468,8 @@ } ], "moon": { - "lon_bits": 4644395248742334464, - "lat_bits": 13839774692164332034, + "lon_bits": 4644395248742336493, + "lat_bits": 13839774692164340499, "dis_bits": 4690200953222741960 } }, @@ -1535,8 +1535,8 @@ } ], "moon": { - "lon_bits": 4626280075118837760, - "lat_bits": 13840749310371452270, + "lon_bits": 4626280075118799584, + "lat_bits": 13840749310371453925, "dis_bits": 4690654038765820554 } }, @@ -1602,8 +1602,8 @@ } ], "moon": { - "lon_bits": 4642839348659779584, - "lat_bits": 4617435609606599017, + "lon_bits": 4642839348659779252, + "lat_bits": 4617435609606598581, "dis_bits": 4690035940276859468 } }, @@ -1669,8 +1669,8 @@ } ], "moon": { - "lon_bits": 4643706423299420160, - "lat_bits": 4617420289584881855, + "lon_bits": 4643706423299420318, + "lat_bits": 4617420289584881771, "dis_bits": 4690507345631790211 } }, @@ -1736,8 +1736,8 @@ } ], "moon": { - "lon_bits": 4641324629893599232, - "lat_bits": 13833791350925404756, + "lon_bits": 4641324629893600623, + "lat_bits": 13833791350925404467, "dis_bits": 4690213089826162801 } }, @@ -1803,8 +1803,8 @@ } ], "moon": { - "lon_bits": 4641556377594630144, - "lat_bits": 13837768310901459232, + "lon_bits": 4641556377594629831, + "lat_bits": 13837768310901459134, "dis_bits": 4690397140046265341 } }, @@ -1870,8 +1870,8 @@ } ], "moon": { - "lon_bits": 4643051441069542912, - "lat_bits": 13833107142211283660, + "lon_bits": 4643051441069542501, + "lat_bits": 13833107142211279169, "dis_bits": 4690677149244434140 } }, @@ -1937,8 +1937,8 @@ } ], "moon": { - "lon_bits": 4638432992787820544, - "lat_bits": 4604452760560701707, + "lon_bits": 4638432992787820099, + "lat_bits": 4604452760560707200, "dis_bits": 4690267031166735019 } }, @@ -2004,8 +2004,8 @@ } ], "moon": { - "lon_bits": 4640123619528452864, - "lat_bits": 13830731082133780967, + "lon_bits": 4640123619528452947, + "lat_bits": 13830731082133779786, "dis_bits": 4690607724011744183 } }, @@ -2071,8 +2071,8 @@ } ], "moon": { - "lon_bits": 4633852310035421184, - "lat_bits": 4615682048096585205, + "lon_bits": 4633852310035417422, + "lat_bits": 4615682048096582680, "dis_bits": 4690212480098815596 } }, @@ -2138,8 +2138,8 @@ } ], "moon": { - "lon_bits": 4637145177669916672, - "lat_bits": 4616900277817928896, + "lon_bits": 4637145177669915422, + "lat_bits": 4616900277817929835, "dis_bits": 4690614442338180693 } }, @@ -2205,8 +2205,8 @@ } ], "moon": { - "lon_bits": 4614455286541713408, - "lat_bits": 13840927208361378610, + "lon_bits": 4614455286541562880, + "lat_bits": 13840927208361378699, "dis_bits": 4690295607855098105 } }, @@ -2272,8 +2272,8 @@ } ], "moon": { - "lon_bits": 4626935746920202240, - "lat_bits": 13840897241651807740, + "lon_bits": 4626935746920169792, + "lat_bits": 13840897241651807531, "dis_bits": 4690516724456487249 } }, @@ -2339,8 +2339,8 @@ } ], "moon": { - "lon_bits": 4634210562258849792, - "lat_bits": 13840127478609364033, + "lon_bits": 4634210562258838071, + "lat_bits": 13840127478609363399, "dis_bits": 4690696403266423212 } }, @@ -2406,8 +2406,8 @@ } ], "moon": { - "lon_bits": 4644119518453577728, - "lat_bits": 4615426109234215819, + "lon_bits": 4644119518453577519, + "lat_bits": 4615426109234223158, "dis_bits": 4690408942032414804 } }, @@ -2473,8 +2473,8 @@ } ], "moon": { - "lon_bits": 4644700588811679744, - "lat_bits": 4613591416442408228, + "lon_bits": 4644700588811680923, + "lat_bits": 4613591416442411934, "dis_bits": 4690634378419114212 } }, @@ -2540,8 +2540,8 @@ } ], "moon": { - "lon_bits": 4642943731706744832, - "lat_bits": 4606506864952181279, + "lon_bits": 4642943731706748638, + "lat_bits": 4606506864952206674, "dis_bits": 4690471964800105050 } }, @@ -2607,8 +2607,8 @@ } ], "moon": { - "lon_bits": 4643391205727983616, - "lat_bits": 4601151950608976245, + "lon_bits": 4643391205727984053, + "lat_bits": 4601151950608969816, "dis_bits": 4690610322901604493 } }, @@ -2674,8 +2674,8 @@ } ], "moon": { - "lon_bits": 4643906201745895424, - "lat_bits": 4608149586846800631, + "lon_bits": 4643906201745898928, + "lat_bits": 4608149586846800616, "dis_bits": 4690707405037298459 } }, @@ -2741,8 +2741,8 @@ } ], "moon": { - "lon_bits": 4640603850349592576, - "lat_bits": 13836987463359397063, + "lon_bits": 4640603850349599151, + "lat_bits": 13836987463359423075, "dis_bits": 4690520910950297694 } }, @@ -2808,8 +2808,8 @@ } ], "moon": { - "lon_bits": 4641244650587095040, - "lat_bits": 13836341301492908390, + "lon_bits": 4641244650587096651, + "lat_bits": 13836341301492965152, "dis_bits": 4690618967879711097 } }, @@ -2875,8 +2875,8 @@ } ], "moon": { - "lon_bits": 4637271578162331648, - "lat_bits": 4617400747377698964, + "lon_bits": 4637271578162302544, + "lat_bits": 4617400747377696538, "dis_bits": 4690456878564521499 } }, @@ -2942,8 +2942,8 @@ } ], "moon": { - "lon_bits": 4639059173504950272, - "lat_bits": 4617277886260054492, + "lon_bits": 4639059173504940115, + "lat_bits": 4617277886260056028, "dis_bits": 4690641584792848686 } }, @@ -3009,8 +3009,8 @@ } ], "moon": { - "lon_bits": 4632094096877387776, - "lat_bits": 13839565169113197704, + "lon_bits": 4632094096877304437, + "lat_bits": 13839565169113192988, "dis_bits": 4690497601478546817 } }, @@ -3076,8 +3076,8 @@ } ], "moon": { - "lon_bits": 4632801898690576384, - "lat_bits": 13840319372111723577, + "lon_bits": 4632801898690539162, + "lat_bits": 13840319372111715796, "dis_bits": 4690554308247424615 } }, @@ -3143,8 +3143,8 @@ } ], "moon": { - "lon_bits": 4637293943754194944, - "lat_bits": 13837068903226901013, + "lon_bits": 4637293943754220840, + "lat_bits": 13837068903226882614, "dis_bits": 4690706776824027633 } }, @@ -3210,8 +3210,8 @@ } ], "moon": { - "lon_bits": 4644683567580639232, - "lat_bits": 4612558200506501443, + "lon_bits": 4644683567580635771, + "lat_bits": 4612558200506475435, "dis_bits": 4690510952714007430 } } diff --git a/tools/doc.go b/tools/doc.go new file mode 100644 index 0000000..957d24d --- /dev/null +++ b/tools/doc.go @@ -0,0 +1,3 @@ +// Package tools 以度为角度单位的三角函数、角度格式化和取整辅助。 +// Package tools provides degree-based trigonometry, angle formatting and rounding helpers. +package tools diff --git a/tools/format.go b/tools/format.go index afcb469..6fd1b43 100644 --- a/tools/format.go +++ b/tools/format.go @@ -5,6 +5,7 @@ import ( "math" ) +// Format 把角度格式化为度分秒;typed 为 0 时输出度分秒,非 0 时输出时分秒 / format an angle as degrees-minutes-seconds, or hours-minutes-seconds when typed is non-zero. func Format(val float64, typed uint8) string { belowZero := false if val < 0 { diff --git a/tools/math.go b/tools/math.go index de8c0ac..a1ba14d 100644 --- a/tools/math.go +++ b/tools/math.go @@ -4,31 +4,37 @@ import ( "math" ) +// Sin 正弦,角度参数单位为度 / sine; the angle is in degrees. func Sin(x float64) float64 { return (math.Sin(x * math.Pi / 180.00000)) } +// Cos 余弦,角度参数单位为度 / cosine; the angle is in degrees. func Cos(x float64) float64 { return (math.Cos(x * math.Pi / 180.00000)) } +// Tan 正切,角度参数单位为度 / tangent; the angle is in degrees. func Tan(x float64) float64 { return (math.Tan(x * math.Pi / 180.00000)) } +// ArcSin 反正弦,结果单位为度 / arc sine; the result is in degrees. func ArcSin(x float64) float64 { return (math.Asin(x) / math.Pi * 180.00000) } +// ArcCos 反余弦,结果单位为度 / arc cosine; the result is in degrees. func ArcCos(x float64) float64 { return (math.Acos(x) / math.Pi * 180.00000) } +// ArcTan 反正切,结果单位为度 / arc tangent; the result is in degrees. func ArcTan(x float64) float64 { return (math.Atan(x) / math.Pi * 180.00000) } -// ArcTan2 计算两个变量的反正切并转换为角度,处理所有象限 +// ArcTan2 两变量反正切,结果单位为度并归一到 [0,360) / two-argument arc tangent; the result is in degrees and normalized to [0,360). func ArcTan2(y, x float64) float64 { angle := math.Atan2(y, x) * 180.0 / math.Pi if angle < 0 { @@ -37,11 +43,13 @@ func ArcTan2(y, x float64) float64 { return angle } +// FloatRound 四舍五入到 n 位小数,实现为 floor(f*10^n+0.5) / round to n decimal places via floor(f*10^n+0.5). func FloatRound(f float64, n int) float64 { p := math.Pow10(n) return math.Floor(f*p+0.5) / p } +// Limit360 把角度归一到 [0,360) / normalize an angle into [0,360). func Limit360(x float64) float64 { x = math.Mod(x, 360) if x < 0 { @@ -50,10 +58,12 @@ func Limit360(x float64) float64 { return x } +// FR 四舍五入到 14 位小数 / round to 14 decimal places. func FR(f float64) float64 { return FloatRound(f, 14) } +// Abs 取 int 的绝对值,只接受 int,与 math.Abs 同名且不支持浮点 / absolute value of an int; same name as math.Abs but accepts only int. func Abs(x int) int { if x < 0 { return -x diff --git a/tools/tools_test.go b/tools/tools_test.go new file mode 100644 index 0000000..434c57d --- /dev/null +++ b/tools/tools_test.go @@ -0,0 +1,196 @@ +// tools 的对外契约:三角函数以度为角度单位,反三角以度为结果单位,取整为 floor 语义。 +package tools_test + +import ( + "math" + "testing" + + "b612.me/astro/tools" +) + +// 度数约定:与 math 的弧度结果必须显著不同,否则说明换算被删掉了。 +func TestTrigTakesDegrees(t *testing.T) { + if math.Abs(tools.Sin(1)-0.01745240643728351) > 1e-15 { + t.Fatalf("Sin(1) 应为 sin 1° = 0.01745240643728351,得到 %v", tools.Sin(1)) + } + if math.Abs(tools.Sin(1)-math.Sin(1)) < 0.8 { + t.Fatal("Sin 的入参似乎被当成弧度") + } + if math.Abs(tools.ArcSin(0.5)-30) > 1e-12 { + t.Fatalf("ArcSin(0.5) 应为 30 度,得到 %v", tools.ArcSin(0.5)) + } +} + +func TestTrigExactAngles(t *testing.T) { + cases := []struct { + name string + got float64 + want float64 + }{ + {"Sin(0)", tools.Sin(0), 0}, + {"Sin(30)", tools.Sin(30), 0.5}, + {"Sin(90)", tools.Sin(90), 1}, + {"Sin(180)", tools.Sin(180), 0}, + {"Sin(270)", tools.Sin(270), -1}, + {"Cos(0)", tools.Cos(0), 1}, + {"Cos(60)", tools.Cos(60), 0.5}, + {"Cos(90)", tools.Cos(90), 0}, + {"Cos(180)", tools.Cos(180), -1}, + {"Tan(0)", tools.Tan(0), 0}, + {"Tan(45)", tools.Tan(45), 1}, + {"Tan(135)", tools.Tan(135), -1}, + } + for _, tc := range cases { + if math.Abs(tc.got-tc.want) > 1e-14 { + t.Fatalf("%s = %v,期望 %v", tc.name, tc.got, tc.want) + } + } + if math.Abs(tools.Tan(90)) < 1e15 || math.IsInf(tools.Tan(90), 0) { + t.Fatalf("Tan(90) 应为极大有限值,得到 %v", tools.Tan(90)) + } +} + +func TestInverseTrigReturnsDegrees(t *testing.T) { + cases := []struct { + name string + got float64 + want float64 + }{ + {"ArcSin(-1)", tools.ArcSin(-1), -90}, + {"ArcSin(0)", tools.ArcSin(0), 0}, + {"ArcSin(1)", tools.ArcSin(1), 90}, + {"ArcCos(-1)", tools.ArcCos(-1), 180}, + {"ArcCos(0)", tools.ArcCos(0), 90}, + {"ArcCos(0.5)", tools.ArcCos(0.5), 60}, + {"ArcCos(1)", tools.ArcCos(1), 0}, + {"ArcTan(-1)", tools.ArcTan(-1), -45}, + {"ArcTan(0)", tools.ArcTan(0), 0}, + {"ArcTan(1)", tools.ArcTan(1), 45}, + } + for _, tc := range cases { + if math.Abs(tc.got-tc.want) > 1e-12 { + t.Fatalf("%s = %v,期望 %v", tc.name, tc.got, tc.want) + } + } + for _, deg := range []float64{-89.5, -45, -0.25, 0, 30, 44.9, 89.5} { + if got := tools.ArcSin(tools.Sin(deg)); math.Abs(got-deg) > 1e-11 { + t.Fatalf("ArcSin(Sin(%v)) = %v", deg, got) + } + } +} + +func TestArcTan2Quadrants(t *testing.T) { + cases := []struct { + y, x float64 + want float64 + }{ + {0, 1, 0}, + {1, 1, 45}, + {1, 0, 90}, + {1, -1, 135}, + {0, -1, 180}, + {-1, -1, 225}, + {-1, 0, 270}, + {-1, 1, 315}, + {-0.0, -1, 180}, + } + for _, tc := range cases { + if got := tools.ArcTan2(tc.y, tc.x); math.Abs(got-tc.want) > 1e-12 { + t.Fatalf("ArcTan2(%v, %v) = %v,期望 %v", tc.y, tc.x, got, tc.want) + } + } + for _, tc := range cases { + got := tools.ArcTan2(tc.y, tc.x) + if got < 0 || got >= 360 { + t.Fatalf("ArcTan2(%v, %v) = %v 未落在 [0,360)", tc.y, tc.x, got) + } + } +} + +func TestLimit360(t *testing.T) { + cases := []struct { + in float64 + want float64 + }{ + {0, 0}, + {359.5, 359.5}, + {360, 0}, + {720, 0}, + {-1, 359}, + {-360, 0}, + {-720.5, 359.5}, + {1e9, 280}, + {-1e9, 80}, + } + for _, tc := range cases { + if got := tools.Limit360(tc.in); math.Abs(got-tc.want) > 1e-9 { + t.Fatalf("Limit360(%v) = %v,期望 %v", tc.in, got, tc.want) + } + } + if !math.IsNaN(tools.Limit360(math.NaN())) { + t.Fatal("Limit360(NaN) 应为 NaN") + } +} + +func TestFloatRoundFloorSemantics(t *testing.T) { + cases := []struct { + f float64 + n int + want float64 + }{ + {0.5, 0, 1}, + {1.5, 0, 2}, + {2.5, 0, 3}, + {-0.5, 0, 0}, + {-1.5, 0, -1}, + {-2.5, 0, -2}, + {1.25, 1, 1.3}, + {-1.25, 1, -1.2}, + {3.14159, 2, 3.14}, + {2.71828, 3, 2.718}, + {1, 5, 1}, + } + for _, tc := range cases { + if got := tools.FloatRound(tc.f, tc.n); got != tc.want { + t.Fatalf("FloatRound(%v, %d) = %v,期望 %v", tc.f, tc.n, got, tc.want) + } + } + if got := tools.FR(1.0 / 3.0); got != 0.33333333333333 { + t.Fatalf("FR(1/3) = %v,期望 14 位", got) + } + if tools.FR(1.0/3.0) != tools.FloatRound(1.0/3.0, 14) { + t.Fatal("FR 应为 FloatRound(f, 14)") + } +} + +func TestAbs(t *testing.T) { + for _, tc := range []struct { + in int + want int + }{{0, 0}, {7, 7}, {-7, 7}, {-1, 1}} { + if got := tools.Abs(tc.in); got != tc.want { + t.Fatalf("Abs(%d) = %d,期望 %d", tc.in, got, tc.want) + } + } +} + +func TestFormatDegreesAndHours(t *testing.T) { + cases := []struct { + val float64 + typed uint8 + want string + }{ + {0, 0, "0\u00b00\u20320.00\u2033"}, + {12.3456, 0, "12\u00b020\u203244.16\u2033"}, + {-12.3456, 0, "-12\u00b020\u203244.16\u2033"}, + {359.999, 0, "359\u00b059\u203256.40\u2033"}, + {12.3456, 1, "12h20m44.16s"}, + {23.5, 1, "23h30m0.00s"}, + {0, 1, "0h0m0.00s"}, + } + for _, tc := range cases { + if got := tools.Format(tc.val, tc.typed); got != tc.want { + t.Fatalf("Format(%v, %d) = %q,期望 %q", tc.val, tc.typed, got, tc.want) + } + } +} diff --git a/uranus/doc.go b/uranus/doc.go new file mode 100644 index 0000000..a416d18 --- /dev/null +++ b/uranus/doc.go @@ -0,0 +1,3 @@ +// Package uranus 天王星位置、升落、合冲、留、方照、相位、视星等、视直径与物理星历,角度单位为度。 +// Package uranus covers Uranus position, rise/set, conjunction, opposition, station, quadrature, phase, magnitude, apparent diameter and physical ephemeris; angles are degrees. +package uranus diff --git a/venus/doc.go b/venus/doc.go new file mode 100644 index 0000000..4c64419 --- /dev/null +++ b/venus/doc.go @@ -0,0 +1,3 @@ +// Package venus 金星位置、升落、合日、留、大距、地心凌日、相位、视星等、视直径与物理星历,角度单位为度。 +// Package venus covers Venus position, rise/set, conjunction, station, elongation, geocentric transit, phase, magnitude, apparent diameter and physical ephemeris; angles are degrees. +package venus