Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9ee2163cc7
|
@@ -8,7 +8,9 @@ A personal astronomy library developed over years for calendrical work, amateur
|
|||||||
|
|
||||||
> 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* and provides calendar conversion, Sun/Moon/planet positions, eclipses, rise/set/transit times, lunar phases, stars, coordinate transforms, physical ephemerides, research formulas, and generic small-body orbit propagation. 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 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 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.
|
||||||
|
|
||||||
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`.
|
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,6 +26,8 @@ Unless noted otherwise, coordinates are apparent-of-date coordinates. Angles are
|
|||||||
- [Lite Sun And Moon](#lite-sun-and-moon)
|
- [Lite Sun And Moon](#lite-sun-and-moon)
|
||||||
- [Solar Eclipse](#solar-eclipse)
|
- [Solar Eclipse](#solar-eclipse)
|
||||||
- [Lunar Eclipse](#lunar-eclipse)
|
- [Lunar Eclipse](#lunar-eclipse)
|
||||||
|
- [Lunar Occultations](#lunar-occultations)
|
||||||
|
- [Event Maps And GeoJSON](#event-maps-and-geojson)
|
||||||
- [Planets](#planets)
|
- [Planets](#planets)
|
||||||
- [Stars](#stars)
|
- [Stars](#stars)
|
||||||
- [Coordinate Tools](#coordinate-tools)
|
- [Coordinate Tools](#coordinate-tools)
|
||||||
@@ -45,7 +49,9 @@ go get b612.me/astro
|
|||||||
- Solar position, rise/set, Earth distance, apparent solar time, apparent altitude, parallactic angle, solar `P/B0/L0`, apparent diameter
|
- 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
|
- 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
|
- `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, and SVG diagrams
|
- 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
|
- 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
|
- 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
|
- Coordinate transforms, topocentric coordinates, sidereal time, precession, nutation, angular distance, refraction, airmass, parallactic angle, and Galactic coordinates
|
||||||
@@ -60,9 +66,11 @@ go get b612.me/astro
|
|||||||
| `calendar` | Gregorian/lunisolar conversion, solar terms, historical era names, old-calendar metadata |
|
| `calendar` | Gregorian/lunisolar conversion, solar terms, historical era names, old-calendar metadata |
|
||||||
| `coord` | Ecliptic/equatorial/horizontal transforms, sidereal time, precession, nutation, topocentric helpers, refraction, airmass, parallactic angle, Galactic coordinates, and research helpers with manual obliquity/hour angle |
|
| `coord` | Ecliptic/equatorial/horizontal transforms, sidereal time, precession, nutation, topocentric helpers, refraction, airmass, parallactic angle, Galactic coordinates, and research helpers with manual obliquity/hour angle |
|
||||||
| `sun` | Solar position, rise/set, twilight, equation of time, apparent solar time, apparent altitude, parallactic angle, diameter, solar `P/B0/L0` |
|
| `sun` | Solar position, rise/set, twilight, equation of time, apparent solar time, apparent altitude, parallactic angle, diameter, solar `P/B0/L0` |
|
||||||
| `moon` | Lunar position, rise/set, phases, new/full/quarter times, apparent altitude, parallactic angle, diameter, bright-limb angle, geocentric/topocentric libration, apsides, nodes, maximum declination |
|
| `moon` | Lunar position, rise/set, phases, new/full/quarter times, apparent altitude, parallactic angle, diameter, bright-limb angle, geocentric/topocentric libration, apsides, nodes, maximum declination, and stellar/planetary lunar occultations with global paths |
|
||||||
| `lite/sun` / `lite/moon` | Lightweight Sun/Moon approximation chains for minute-level rise/set, lightweight sky position, and lunar-phase work |
|
| `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, SVG output |
|
| `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 |
|
||||||
| `mercury` / `venus` | Positions, rise/set, conjunctions, stations, elongations, geocentric transits, phase, parallactic angle, magnitude, diameter, nodes, physical ephemerides |
|
| `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 |
|
| `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 |
|
| `earth` | Earth orbital eccentricity, perihelion, aphelion |
|
||||||
@@ -103,7 +111,9 @@ This is suitable for ordinary calendrical work, observing support, outreach, and
|
|||||||
|
|
||||||
### Moon
|
### 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. It is suitable for Chinese-calendar new moons, lunar phases, rise/set, lunar eclipses, and ordinary positional work. For lunar laser ranging, long-term physical libration, or professional occultation work, use JPL or a dedicated lunar ephemeris.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
The four principal phases keep the historical pinyin names and also expose English aliases:
|
The four principal phases keep the historical pinyin names and also expose English aliases:
|
||||||
|
|
||||||
@@ -162,6 +172,10 @@ The following areas have been checked against JPL Horizons, NASA GSFC, IMCCE, an
|
|||||||
- apparent diameters of the Sun, planets, and Moon
|
- apparent diameters of the Sun, planets, and Moon
|
||||||
- solar physical ephemerides `P/B0/L0`
|
- solar physical ephemerides `P/B0/L0`
|
||||||
- planetary rise, transit, and set events
|
- planetary rise, transit, and set events
|
||||||
|
- 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
|
- 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
|
- 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
|
- Moon perigee and apogee
|
||||||
@@ -169,6 +183,10 @@ The following areas have been checked against JPL Horizons, NASA GSFC, IMCCE, an
|
|||||||
- solar and lunar eclipses
|
- solar and lunar eclipses
|
||||||
- Galilean satellite events
|
- 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.
|
||||||
|
|
||||||
The README examples are illustrative. The repository tests contain the exact baselines.
|
The README examples are illustrative. The repository tests contain the exact baselines.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -398,20 +416,20 @@ func main() {
|
|||||||
|
|
||||||
// Civil morning twilight begins when the Sun is 6 degrees below the horizon.
|
// Civil morning twilight begins when the Sun is 6 degrees below the horizon.
|
||||||
fmt.Println(sun.MorningTwilight(date, lon, lat, -6))
|
fmt.Println(sun.MorningTwilight(date, lon, lat, -6))
|
||||||
// Sunrise in Xi'an on this date, with atmospheric refraction.
|
// Sunrise: dynamic standard refraction and instantaneous solar semidiameter, upper limb.
|
||||||
fmt.Println(sun.RiseTime(date, lon, lat, height, true))
|
fmt.Println(sun.RiseTime(date, lon, lat, height, true))
|
||||||
// Upper culmination of the Sun in Xi'an.
|
// Upper culmination of the Sun in Xi'an.
|
||||||
fmt.Println(sun.CulminationTime(date, lon))
|
fmt.Println(sun.CulminationTime(date, lon))
|
||||||
// Sunset in Xi'an on this date, with atmospheric refraction.
|
// Sunset: dynamic standard refraction and instantaneous solar semidiameter, upper limb.
|
||||||
fmt.Println(sun.SetTime(date, lon, lat, height, true))
|
fmt.Println(sun.SetTime(date, lon, lat, height, true))
|
||||||
// Civil evening twilight ends when the Sun is 6 degrees below the horizon.
|
// Civil evening twilight ends when the Sun is 6 degrees below the horizon.
|
||||||
fmt.Println(sun.EveningTwilight(date, lon, lat, -6))
|
fmt.Println(sun.EveningTwilight(date, lon, lat, -6))
|
||||||
|
|
||||||
// Moonrise in Xi'an on this date, with atmospheric refraction.
|
// Moonrise: dynamic standard refraction and instantaneous lunar semidiameter, upper limb.
|
||||||
fmt.Println(moon.RiseTime(date, lon, lat, height, true))
|
fmt.Println(moon.RiseTime(date, lon, lat, height, true))
|
||||||
// Upper culmination of the Moon in Xi'an.
|
// Upper culmination of the Moon in Xi'an.
|
||||||
fmt.Println(moon.CulminationTime(date, lon, lat))
|
fmt.Println(moon.CulminationTime(date, lon, lat))
|
||||||
// Moonset in Xi'an on this date, with atmospheric refraction.
|
// Moonset: dynamic standard refraction and instantaneous lunar semidiameter, upper limb.
|
||||||
fmt.Println(moon.SetTime(date, lon, lat, height, true))
|
fmt.Println(moon.SetTime(date, lon, lat, height, true))
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -420,13 +438,13 @@ Output:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
2020-01-01 07:22:27.960488498 +0800 CST <nil> // civil morning twilight begins
|
2020-01-01 07:22:27.960488498 +0800 CST <nil> // civil morning twilight begins
|
||||||
2020-01-01 07:50:14.530648291 +0800 CST <nil> // sunrise
|
2020-01-01 07:49:52.413689196 +0800 CST <nil> // sunrise
|
||||||
2020-01-01 12:47:35.933117866 +0800 CST // solar upper culmination
|
2020-01-01 12:47:35.933117866 +0800 CST // solar upper culmination
|
||||||
2020-01-01 17:44:47.070974707 +0800 CST <nil> // sunset
|
2020-01-01 17:45:09.188657999 +0800 CST <nil> // sunset
|
||||||
2020-01-01 18:12:33.624035418 +0800 CST <nil> // civil evening twilight ends
|
2020-01-01 18:12:33.624035418 +0800 CST <nil> // civil evening twilight ends
|
||||||
2020-01-01 11:52:45.157297253 +0800 CST <nil> // moonrise
|
2020-01-01 11:52:49.860912859 +0800 CST <nil> // moonrise
|
||||||
2020-01-01 17:38:02.510787248 +0800 CST // lunar upper culmination
|
2020-01-01 17:38:02.510787248 +0800 CST // lunar upper culmination
|
||||||
2020-01-01 23:26:51.580328643 +0800 CST <nil> // moonset
|
2020-01-01 23:26:49.313593804 +0800 CST <nil> // moonset
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Sun and Moon position
|
#### Sun and Moon position
|
||||||
@@ -695,9 +713,13 @@ Common entry points:
|
|||||||
- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`: search locally visible total solar eclipses, returning `(info, ok)`
|
- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`: search locally visible total solar eclipses, returning `(info, ok)`
|
||||||
- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`: search locally visible annular solar eclipses, returning `(info, ok)`
|
- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`: search locally visible annular solar eclipses, returning `(info, ok)`
|
||||||
- `SolarEclipseCentralPath`: compute central line, northern/southern limits, and greatest-eclipse point
|
- `SolarEclipseCentralPath`: compute central line, northern/southern limits, and greatest-eclipse point
|
||||||
- `SolarEclipsePartialFootprints`: compute the partial-eclipse penumbral footprint on Earth
|
- `SolarEclipsePartialFootprints`: compute the partial-eclipse penumbral footprint on Earth, with optional sampled umbral/antumbral outlines
|
||||||
- `eclipse/svg.LocalSolarEclipseSVG`: render a local solar-disk SVG
|
- `eclipse/svg.LocalSolarEclipseSVG`: render a local solar-disk SVG
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
`SolarEclipseInfo`, `LocalSolarEclipseInfo`, and the embedded `Eclipse` field in `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` include Saros metadata:
|
`SolarEclipseInfo`, `LocalSolarEclipseInfo`, and the embedded `Eclipse` field in `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` include Saros metadata:
|
||||||
|
|
||||||
- `HasSaros`: whether a Saros series was matched
|
- `HasSaros`: whether a Saros series was matched
|
||||||
@@ -1121,6 +1143,334 @@ Rendered example:
|
|||||||
- NASA 2024-03-25 penumbral lunar eclipse diagram: <https://eclipse.gsfc.nasa.gov/LEplot/LEplot2001/LE2024Mar25N.pdf>
|
- NASA 2024-03-25 penumbral lunar eclipse diagram: <https://eclipse.gsfc.nasa.gov/LEplot/LEplot2001/LE2024Mar25N.pdf>
|
||||||
- NASA lunar eclipse algorithm and history notes: <https://eclipse.gsfc.nasa.gov/LEhistory/LEhistory.html>
|
- NASA lunar eclipse algorithm and history notes: <https://eclipse.gsfc.nasa.gov/LEhistory/LEhistory.html>
|
||||||
|
|
||||||
|
### Lunar Occultations
|
||||||
|
|
||||||
|
Lunar-occultation APIs live in `moon` and search only the target supplied by the caller; they never enumerate the star catalog. Fixed-site APIs take `start`, `end`, longitude, latitude, and elevation directly.
|
||||||
|
|
||||||
|
Global-path results contain WGS84 samples suitable for `moon/svg` or `geojson`.
|
||||||
|
|
||||||
|
Targets use two distinct contact models:
|
||||||
|
|
||||||
|
- **Stars** are point sources. Results contain immersion, greatest occultation, and emersion.
|
||||||
|
- **Planets** are finite disks. C1/C4 are external contacts; a fully covered disk also has C2/C3 internal contacts. Partial and grazing events have no C2/C3.
|
||||||
|
- The planetary model uses the equatorial body radius and excludes rings, atmospheric extensions, and oblateness.
|
||||||
|
- `FindBestStarOccultations` and `FindBestPlanetOccultations` return the global sea-level geometric greatest point. They do not score horizon visibility, lunar altitude, duration, or magnitude.
|
||||||
|
- `VisibleAtGreatest` only reports visibility at the selected point.
|
||||||
|
- The query window selects events by their greatest instant. Once selected, complete contacts or a complete global path are returned rather than clipped at the query endpoints.
|
||||||
|
- Contact times solve topocentric geometry between the target and lunar limb without atmospheric refraction.
|
||||||
|
- `MoonAltitudeAtGreatest` is the true altitude of the lunar center. `VisibleAtGreatest` reports whether it is at or above the geometric horizon.
|
||||||
|
|
||||||
|
#### Stellar occultations
|
||||||
|
|
||||||
|
Callers supply a `StarCoordinate`. `RA` and `Dec` are degrees; `Epoch` and `Frame` are required. Proper motions use `mas/year`; `ProperMotionRACosDecMasPerYear` follows the usual catalog convention `dRA*cos(Dec)`.
|
||||||
|
|
||||||
|
A coordinate can be constructed directly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
target := 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,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, explicitly load the embedded 9100-star catalog and convert a `StarData` value with `StarCoordinateFromStarData`.
|
||||||
|
|
||||||
|
The occultation search itself does not load the catalog; calls such as `star.InitStarDatabase`, `StarDataByName`, and `StarDataByHR` do.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/moon"
|
||||||
|
"b612.me/astro/star"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cst := time.FixedZone("CST", 8*3600)
|
||||||
|
start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst)
|
||||||
|
end := start.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
_ = star.InitStarDatabase()
|
||||||
|
data, _ := star.StarDataByHR(4799)
|
||||||
|
target, _ := moon.StarCoordinateFromStarData(data)
|
||||||
|
target.ID = "HR 4799" // Optional display label.
|
||||||
|
|
||||||
|
events, _ := moon.FindStarOccultations(
|
||||||
|
start, end, target,
|
||||||
|
121.56601, 6.80706, 0,
|
||||||
|
moon.OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
for _, event := range events {
|
||||||
|
fmt.Println(event.TargetID, event.Type)
|
||||||
|
fmt.Println(
|
||||||
|
event.Immersion.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
event.Greatest.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
event.Emersion.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
)
|
||||||
|
fmt.Printf("altitude=%.3f visible=%v\n", event.MoonAltitudeAtGreatest, event.VisibleAtGreatest)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths, _ := moon.FindStarOccultationPaths(
|
||||||
|
start, end, target,
|
||||||
|
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
||||||
|
)
|
||||||
|
for _, path := range paths {
|
||||||
|
fmt.Println(
|
||||||
|
path.Start.Time.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
path.Greatest.Time.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
path.End.Time.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
)
|
||||||
|
fmt.Printf("greatest=%.6f %.6f width=%.1fkm center=%d\n",
|
||||||
|
path.Greatest.Longitude, path.Greatest.Latitude,
|
||||||
|
path.Greatest.WidthKM, len(path.CenterLine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
#### Planetary occultations
|
||||||
|
|
||||||
|
Planet targets use the constants from `OccultationMercury` through `OccultationNeptune`. This example solves C1-C4 for the `2025-02-01` occultation of Saturn at a site near the global geometric greatest point:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/moon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cst := time.FixedZone("CST", 8*3600)
|
||||||
|
start := time.Date(2025, 2, 1, 0, 0, 0, 0, cst)
|
||||||
|
events, _ := moon.FindPlanetOccultations(
|
||||||
|
start, start.Add(24*time.Hour), moon.OccultationSaturn,
|
||||||
|
104.52219613, 55.25401991, 0,
|
||||||
|
moon.OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
for _, event := range events {
|
||||||
|
fmt.Println(event.TargetID, event.Type, event.HasInternalContacts)
|
||||||
|
fmt.Println(event.ExternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C1
|
||||||
|
fmt.Println(event.InternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C2
|
||||||
|
fmt.Println(event.Greatest.Format("2006-01-02 15:04:05.000 MST"))
|
||||||
|
fmt.Println(event.InternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C3
|
||||||
|
fmt.Println(event.ExternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### Lunar-occultation SVG
|
||||||
|
|
||||||
|
`moon/svg` provides both search-and-render and render-an-existing-result entry points:
|
||||||
|
|
||||||
|
- `FindLocalStarOccultationSVGs` / `FindLocalPlanetOccultationSVGs`: fixed-site apparent tracks, the lunar path, and contact-stage panels.
|
||||||
|
- `FindStarOccultationSVGs` / `FindPlanetOccultationSVGs`: global bands, center lines, event points, and time labels.
|
||||||
|
- `LocalStarOccultationSVG` / `LocalPlanetOccultationSVG`: render an existing fixed-site event.
|
||||||
|
- `StarOccultationPathSVG` / `PlanetOccultationPathSVG`: render an existing global path.
|
||||||
|
|
||||||
|
```go
|
||||||
|
localSVGs, err := moonsvg.FindLocalStarOccultationSVGs(
|
||||||
|
start, end, target,
|
||||||
|
121.56601, 6.80706, 0,
|
||||||
|
moon.OccultationSearchOptions{},
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
A lunar map shows P1/P4 visible hemispheres, moonrise/moonset transition regions, and the region that sees the entire event.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
eclipsesvg "b612.me/astro/eclipse/svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cst := time.FixedZone("CST", 8*3600)
|
||||||
|
|
||||||
|
solar, ok := eclipsesvg.SolarEclipseMapSVG(
|
||||||
|
time.Date(2009, 7, 22, 12, 0, 0, 0, cst),
|
||||||
|
eclipsesvg.SolarEclipseMapSVGOptions{
|
||||||
|
Width: 1200, Height: 800, Location: cst,
|
||||||
|
Language: "en",
|
||||||
|
TimeLabelStep: 30 * time.Minute,
|
||||||
|
PenumbralOutlineStep: 60 * time.Minute,
|
||||||
|
CentralShadowStep: 10 * time.Minute,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if ok {
|
||||||
|
_ = os.WriteFile("doc/solar-eclipse-yangshan-2009-global-en.svg", []byte(solar), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
lunar, ok := eclipsesvg.LunarEclipseMapSVG(
|
||||||
|
time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
|
||||||
|
eclipsesvg.LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "en", Location: cst},
|
||||||
|
)
|
||||||
|
if ok {
|
||||||
|
_ = os.WriteFile("doc/lunar-eclipse-2029-01-01-global-en.svg", []byte(lunar), 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The partial-eclipse visibility region of the `2012-05-21` annular eclipse includes the North Pole. The same event is therefore shown again with a forced north-polar azimuthal-equidistant projection, making its antimeridian-crossing Arctic visibility easier to inspect. The circular outline is the projection boundary, not an administrative or political boundary:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The lunar-eclipse example reuses the cross-year total eclipse on `2029-01-01`, separating entire-event visibility, moonrise during eclipse, moonset during eclipse, and unavailable regions:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/geojson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)
|
||||||
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
||||||
|
date,
|
||||||
|
eclipse.SolarEclipsePartialFootprintOptions{
|
||||||
|
Step: 10 * time.Minute, BoundaryPoints: 180,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
central, hasCentral := eclipse.SolarEclipseCentralPath(
|
||||||
|
date,
|
||||||
|
eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20},
|
||||||
|
)
|
||||||
|
var centralPath *eclipse.SolarEclipsePath
|
||||||
|
if hasCentral {
|
||||||
|
centralPath = ¢ral
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(
|
||||||
|
partial, centralPath,
|
||||||
|
geojson.TimeMarkerOptions{
|
||||||
|
Step: 30 * time.Minute,
|
||||||
|
Location: time.FixedZone("CST", 8*3600),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fmt.Println(err, json.Valid(data))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each event type has plain and time-marker variants:
|
||||||
|
|
||||||
|
- `MarshalSolarEclipse` / `MarshalSolarEclipseWithTimeMarkers`
|
||||||
|
- `MarshalLunarEclipse` / `MarshalLunarEclipseWithTimeMarkers`
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
`WithTimeMarkers` adds Point Features with `role=time-marker`. Localized `label` values are for display, while `time` remains UTC RFC 3339.
|
||||||
|
|
||||||
|
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
|
### Planets
|
||||||
|
|
||||||
#### Inner planets
|
#### Inner planets
|
||||||
@@ -1614,7 +1964,7 @@ Output:
|
|||||||
143.99353431082105 18.7404068044953 // topocentric RA and Dec
|
143.99353431082105 18.7404068044953 // topocentric RA and Dec
|
||||||
manual az=281.869347 alt=24.489608 zen=65.510392 ha=73.866900 // manual-LST horizontal result and hour angle
|
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
|
gal lon=0.000047 lat=-0.000079 // Galactic longitude and latitude
|
||||||
apparent alt=10.092644 // apparent altitude after refraction estimate
|
apparent alt=10.093429 // 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`.
|
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`.
|
||||||
@@ -1845,7 +2195,8 @@ Notes:
|
|||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
- Sun position, altitude, zenith distance, azimuth, culmination, twilight, rise/set, solar terms, solar eclipses, solar physical ephemerides
|
- 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
|
- 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
|
- `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
|
- Earth eccentricity, Sun-Earth distance, perihelion, aphelion
|
||||||
- Apparent/mean sidereal time, constellation lookup, common coordinate transforms, refraction, airmass, parallactic angle, Galactic coordinates
|
- Apparent/mean sidereal time, constellation lookup, common coordinate transforms, refraction, airmass, parallactic angle, Galactic coordinates
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
>📚 本项目主要用于天文算法学习与验证,计算结果满足业余爱好级别需求。
|
>📚 本项目主要用于天文算法学习与验证,计算结果满足业余爱好级别需求。
|
||||||
|
|
||||||
基于《天文算法》(Astronomical Algorithms)一书实现,提供历法转换、太阳/月亮/行星位置、日月食、升落、中天、月相、恒星、坐标变换、物理星历、研究公式和通用小天体轨道传播等功能。太阳和行星部分使用内置 VSOP87 解析项,月球部分使用内置 ELP2000/82 解析级数,不依赖外部 JPL 星历文件。
|
基于《天文算法》(Astronomical Algorithms)一书实现,提供历法转换、太阳/月亮/行星位置、日月食、月掩、升落、中天、月相、恒星、坐标变换、物理星历、研究公式和通用小天体轨道传播等功能。太阳和行星部分使用内置 VSOP87 解析项,月球部分使用内置 ELP2000/82 解析级数,不依赖外部 JPL 星历文件。
|
||||||
|
|
||||||
没有特殊标注时,本程序所提供的坐标均为瞬时天球坐标;角度单位默认是度,视直径/视半径单位是角秒,距离单位按函数名使用 AU 或 km。
|
没有特殊标注时,本程序所提供的坐标均为瞬时天球坐标;角度单位默认是度,视直径/视半径单位是角秒,距离单位按函数名使用 AU 或 km。
|
||||||
|
|
||||||
@@ -23,12 +23,15 @@
|
|||||||
- [历法转换与节气](#历法转换与节气)
|
- [历法转换与节气](#历法转换与节气)
|
||||||
- [太阳与月亮](#太阳与月亮)
|
- [太阳与月亮](#太阳与月亮)
|
||||||
- [Lite 轻量太阳与月亮](#lite-轻量太阳与月亮)
|
- [Lite 轻量太阳与月亮](#lite-轻量太阳与月亮)
|
||||||
|
- [月掩](#月掩)
|
||||||
|
- [天象地图与 GeoJSON](#天象地图与-geojson)
|
||||||
- [行星](#行星)
|
- [行星](#行星)
|
||||||
- [恒星](#恒星)
|
- [恒星](#恒星)
|
||||||
- [坐标工具](#坐标工具)
|
- [坐标工具](#坐标工具)
|
||||||
- [研究公式](#研究公式)
|
- [研究公式](#研究公式)
|
||||||
- [通用小天体轨道](#通用小天体轨道)
|
- [通用小天体轨道](#通用小天体轨道)
|
||||||
- [日晷与真太阳时](#日晷与真太阳时)
|
- [日晷与真太阳时](#日晷与真太阳时)
|
||||||
|
- [已实现](#已实现)
|
||||||
- [TODO](#todo)
|
- [TODO](#todo)
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
@@ -43,7 +46,9 @@ go get b612.me/astro
|
|||||||
- 🌞 **太阳计算**:天球位置、日出日落、日地距离、真太阳时、视高度角、视差角、日面物理参数(`P/B0/L0`)、视直径等
|
- 🌞 **太阳计算**:天球位置、日出日落、日地距离、真太阳时、视高度角、视差角、日面物理参数(`P/B0/L0`)、视直径等
|
||||||
- 🌙 **月亮计算**:天球位置、月出月落、地月距离、月相、朔望时间、视直径、亮边位置角、视差角、地心/站心天平动、近远地点、交点、最大赤纬等
|
- 🌙 **月亮计算**:天球位置、月出月落、地月距离、月相、朔望时间、视直径、亮边位置角、视差角、地心/站心天平动、近远地点、交点、最大赤纬等
|
||||||
- 🪶 **轻量链路**:`lite/sun` 与 `lite/moon` 提供面向手表、前端、小程序和其它资源受限环境的轻量近似太阳/月亮算法,覆盖天球位置、升落和月相
|
- 🪶 **轻量链路**:`lite/sun` 与 `lite/moon` 提供面向手表、前端、小程序和其它资源受限环境的轻量近似太阳/月亮算法,覆盖天球位置、升落和月相
|
||||||
- 🌗 **日月食**:全局日食、站心日食、中心线/偏食足迹、月食、地方可见月食与 SVG 示意图
|
- 🌗 **日月食**:全局日食、站心日食、中心线/偏食足迹、月食、地方可见月食,以及局地示意图和全球见食图 SVG
|
||||||
|
- 🌘 **月掩**:按指定赤经赤纬搜索恒星月掩,按有限圆盘计算行星月掩,支持指定地点接触时刻、全球掩带、几何掩甚点和 SVG
|
||||||
|
- 🗺️ **地理输出**:日食、月食和月掩结果可编码为带时间数据与可选时间标记的 GeoJSON;全球 SVG 使用无行政边界海岸线,并支持等经纬和南北极投影
|
||||||
- 🪐 **行星计算**:七大行星天球位置、升落时间、合冲留、大距、水星/金星地心凌日等特殊天象时间、升交点/降交点、视直径/视半径、相位、视差角、节点、视星等与物理星历
|
- 🪐 **行星计算**:七大行星天球位置、升落时间、合冲留、大距、水星/金星地心凌日等特殊天象时间、升交点/降交点、视直径/视半径、相位、视差角、节点、视星等与物理星历
|
||||||
- ⭐ **恒星计算**:指定天球坐标所属星座;同时包含9100颗恒星数据库,可计算升降时间、视差角和视高度角,获取指定日期的恒星坐标信息
|
- ⭐ **恒星计算**:指定天球坐标所属星座;同时包含9100颗恒星数据库,可计算升降时间、视差角和视高度角,获取指定日期的恒星坐标信息
|
||||||
- 🧭 **坐标工具**:黄道/赤道/地平坐标转换、站心坐标、恒星时、岁差、章动、角距离、大气折射、大气质量、视差角、银道坐标
|
- 🧭 **坐标工具**:黄道/赤道/地平坐标转换、站心坐标、恒星时、岁差、章动、角距离、大气折射、大气质量、视差角、银道坐标
|
||||||
@@ -58,9 +63,11 @@ go get b612.me/astro
|
|||||||
| `calendar` | 公历/农历互转、节气、历史朝代年号、古代历法信息 |
|
| `calendar` | 公历/农历互转、节气、历史朝代年号、古代历法信息 |
|
||||||
| `coord` | 黄道/赤道/地平互转、恒星时、岁差、章动、站心坐标、大气折射、大气质量、视差角、银道坐标、手动黄赤交角和手动时角的研究接口 |
|
| `coord` | 黄道/赤道/地平互转、恒星时、岁差、章动、站心坐标、大气折射、大气质量、视差角、银道坐标、手动黄赤交角和手动时角的研究接口 |
|
||||||
| `sun` | 太阳位置、日出日落、晨昏朦影、均时差、真太阳时、视高度角、视差角、视直径、日面 `P/B0/L0` |
|
| `sun` | 太阳位置、日出日落、晨昏朦影、均时差、真太阳时、视高度角、视差角、视直径、日面 `P/B0/L0` |
|
||||||
| `moon` | 月亮位置、月出月落、月相、朔望弦、视高度角、视差角、视直径、亮边位置角、地心/站心天平动、近远地点、交点、最大赤纬 |
|
| `moon` | 月亮位置、月出月落、月相、朔望弦、视高度角、视差角、视直径、亮边位置角、地心/站心天平动、近远地点、交点、最大赤纬,以及恒星/行星月掩和全球掩带 |
|
||||||
| `lite/sun` / `lite/moon` | 轻量太阳/月亮近似链路,面向分钟级升落、轻量天球位置和月相计算 |
|
| `lite/sun` / `lite/moon` | 轻量太阳/月亮近似链路,面向分钟级升落、轻量天球位置和月相计算 |
|
||||||
| `eclipse` / `eclipse/svg` | 全局/局地日月食、日食中心线与偏食足迹、局地可见性筛选、日月食 SVG |
|
| `eclipse` / `eclipse/svg` | 全局/局地日月食、日食中心线与偏食足迹、局地可见性筛选、局地示意图与全球见食图 SVG |
|
||||||
|
| `moon/svg` | 指定地点恒星/行星月掩视圆图,以及带掩带、中心线和时间标记的全球投影 SVG |
|
||||||
|
| `geojson` | 将日食、月食和月掩的既有地理结果编码为 RFC 7946 GeoJSON,投影与样式由应用负责 |
|
||||||
| `mercury` / `venus` | 水星、金星位置、升落、合日、留、大距、地心凌日、相位、视差角、视星等、视直径、节点和物理星历 |
|
| `mercury` / `venus` | 水星、金星位置、升落、合日、留、大距、地心凌日、相位、视差角、视星等、视直径、节点和物理星历 |
|
||||||
| `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | 外行星位置、升落、合冲、留、方照、相位、视差角、视星等、视直径、节点和物理星历 |
|
| `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | 外行星位置、升落、合冲、留、方照、相位、视差角、视星等、视直径、节点和物理星历 |
|
||||||
| `earth` | 地球轨道偏心率、近日点、远日点 |
|
| `earth` | 地球轨道偏心率、近日点、远日点 |
|
||||||
@@ -101,7 +108,7 @@ go get b612.me/astro
|
|||||||
|
|
||||||
### 月球
|
### 月球
|
||||||
|
|
||||||
月球使用内置的 ELP/MPP02 DE405 解析级数(截断版,保留主要周期项),库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食和常规位置计算;若需要极高精度月球测距、长期物理天平动或专业掩星,请以 JPL 星历或专门月球星历为准。
|
月球使用内置的 ELP/MPP02 DE405 解析级数(截断版,保留主要周期项),库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食、业余月掩预报和常规位置计算;若需要极高精度月球测距、长期物理天平动或专业掩星,请以 JPL 星历或专门月球星历为准。
|
||||||
|
|
||||||
### Lite 轻量链路
|
### Lite 轻量链路
|
||||||
|
|
||||||
@@ -153,11 +160,16 @@ go get b612.me/astro
|
|||||||
- 太阳/行星/月亮视直径:与外部基线最大差异从 `0.000002"` 到 `0.194598"` 不等,月亮因视差和距离变化更敏感
|
- 太阳/行星/月亮视直径:与外部基线最大差异从 `0.000002"` 到 `0.194598"` 不等,月亮因视差和距离变化更敏感
|
||||||
- 太阳物理星历 `P/B0/L0`:最大差异约 `0.003349° / 0.003986° / 0.047394°`
|
- 太阳物理星历 `P/B0/L0`:最大差异约 `0.003349° / 0.003986° / 0.047394°`
|
||||||
- 行星升/中天/落:已用 JPL Horizons 电视事件(TVH, Time-Varying Hourly)做对比校验;该基线按 1 分钟步长生成,当前结果与 Horizons 事件时间在分钟级上对齐
|
- 行星升/中天/落:已用 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`
|
- 地球近日点/远日点:时刻最大差异约 `1m28.84s`,距离最大差异约 `0.000000039837 AU`
|
||||||
- 月球主链位置:当前算法为 ELP/MPP02 DE405 解析级数截断版;在 `-2000` 年四个 JPL/Horizons `JDTT` 样本上,相对 JPL/Horizons 的最大差异约为黄经 `219.6"`、黄纬 `25.8"`、距离 `34.3 km`
|
- 月球主链位置:当前算法为 ELP/MPP02 DE405 解析级数截断版;在 `-2000` 年四个 JPL/Horizons `JDTT` 样本上,相对 JPL/Horizons 的最大差异约为黄经 `219.6"`、黄纬 `25.8"`、距离 `34.3 km`
|
||||||
- 月球近地点/远地点:时刻最大差异约 `15m53.45s`,距离最大差异约 `39.758 km`
|
- 月球近地点/远地点:时刻最大差异约 `15m53.45s`,距离最大差异约 `39.758 km`
|
||||||
- 月球最大赤纬:时刻最大差异约 `2.43s`,赤纬最大差异约 `0.00006431°`
|
- 月球最大赤纬:时刻最大差异约 `2.43s`,赤纬最大差异约 `0.00006431°`
|
||||||
|
|
||||||
|
站心时角修复后的影响边界:月出/月落、指定地点月掩、月球站心坐标,以及地方月食结果中的食甚月高和可见性判断使用修复后的 UT 时角链。日食和月食的全球接触时刻、食甚、食分等主体结果来自各自的地心/贝塞尔几何,不经过该站心转换,因此现有 NASA 精度数据无需随之改写。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 历法转换与节气
|
### 历法转换与节气
|
||||||
@@ -469,20 +481,20 @@ func main() {
|
|||||||
// 西安市2020年1月1日民用晨朦影开始时间
|
// 西安市2020年1月1日民用晨朦影开始时间
|
||||||
// 民用朦影,太阳位于地平线下6度,航海朦影=地平线下12度,天文朦影=地平线下18度
|
// 民用朦影,太阳位于地平线下6度,航海朦影=地平线下12度,天文朦影=地平线下18度
|
||||||
fmt.Println(sun.MorningTwilight(date, lon, lat, -6))
|
fmt.Println(sun.MorningTwilight(date, lon, lat, -6))
|
||||||
// 西安市2020年1月1日日出时间,计算大气影响
|
// 西安市2020年1月1日日出时间,按动态标准折射和实时太阳视半径计算上缘过地平线
|
||||||
fmt.Println(sun.RiseTime(date, lon, lat, height, true))
|
fmt.Println(sun.RiseTime(date, lon, lat, height, true))
|
||||||
// 西安市2020年1月1日太阳上中天时间
|
// 西安市2020年1月1日太阳上中天时间
|
||||||
fmt.Println(sun.CulminationTime(date, lon))
|
fmt.Println(sun.CulminationTime(date, lon))
|
||||||
// 西安市2020年1月1日日落时间,计算大气影响
|
// 西安市2020年1月1日日落时间,按动态标准折射和实时太阳视半径计算上缘过地平线
|
||||||
fmt.Println(sun.SetTime(date, lon, lat, height, true))
|
fmt.Println(sun.SetTime(date, lon, lat, height, true))
|
||||||
// 西安市2020年1月1日民用昏朦影结束时间
|
// 西安市2020年1月1日民用昏朦影结束时间
|
||||||
fmt.Println(sun.EveningTwilight(date, lon, lat, -6))
|
fmt.Println(sun.EveningTwilight(date, lon, lat, -6))
|
||||||
|
|
||||||
// 西安市2020年1月1日月出时间,计算大气影响
|
// 西安市2020年1月1日月出时间,按动态标准折射和实时月球视半径计算上缘过地平线
|
||||||
fmt.Println(moon.RiseTime(date, lon, lat, height, true))
|
fmt.Println(moon.RiseTime(date, lon, lat, height, true))
|
||||||
// 西安市2020年1月1日月亮上中天时间
|
// 西安市2020年1月1日月亮上中天时间
|
||||||
fmt.Println(moon.CulminationTime(date, lon, lat))
|
fmt.Println(moon.CulminationTime(date, lon, lat))
|
||||||
// 西安市2020年1月1日月落时间,计算大气影响
|
// 西安市2020年1月1日月落时间,按动态标准折射和实时月球视半径计算上缘过地平线
|
||||||
fmt.Println(moon.SetTime(date, lon, lat, height, true))
|
fmt.Println(moon.SetTime(date, lon, lat, height, true))
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -492,13 +504,13 @@ func main() {
|
|||||||
|
|
||||||
```
|
```
|
||||||
2020-01-01 07:22:27.960488498 +0800 CST <nil>
|
2020-01-01 07:22:27.960488498 +0800 CST <nil>
|
||||||
2020-01-01 07:50:14.530648291 +0800 CST <nil>
|
2020-01-01 07:49:52.413689196 +0800 CST <nil>
|
||||||
2020-01-01 12:47:35.933117866 +0800 CST
|
2020-01-01 12:47:35.933117866 +0800 CST
|
||||||
2020-01-01 17:44:47.070974707 +0800 CST <nil>
|
2020-01-01 17:45:09.188657999 +0800 CST <nil>
|
||||||
2020-01-01 18:12:33.624035418 +0800 CST <nil>
|
2020-01-01 18:12:33.624035418 +0800 CST <nil>
|
||||||
2020-01-01 11:52:45.157297253 +0800 CST <nil>
|
2020-01-01 11:52:49.860912859 +0800 CST <nil>
|
||||||
2020-01-01 17:38:02.510787248 +0800 CST
|
2020-01-01 17:38:02.510787248 +0800 CST
|
||||||
2020-01-01 23:26:51.580328643 +0800 CST <nil>
|
2020-01-01 23:26:49.313593804 +0800 CST <nil>
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -761,9 +773,13 @@ func main() {
|
|||||||
- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`:搜索某地可见的日全食,返回 `(info, ok)`
|
- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`:搜索某地可见的日全食,返回 `(info, ok)`
|
||||||
- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`:搜索某地可见的日环食,返回 `(info, ok)`
|
- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`:搜索某地可见的日环食,返回 `(info, ok)`
|
||||||
- `SolarEclipseCentralPath`:计算中心线、南北界和食甚点
|
- `SolarEclipseCentralPath`:计算中心线、南北界和食甚点
|
||||||
- `SolarEclipsePartialFootprints`:计算偏食半影在地球表面的足迹
|
- `SolarEclipsePartialFootprints`:计算偏食半影在地球表面的足迹;可选采样本影/反本影瞬时轮廓
|
||||||
- `eclipse/svg.LocalSolarEclipseSVG`:生成某地的日面视圆 SVG
|
- `eclipse/svg.LocalSolarEclipseSVG`:生成某地的日面视圆 SVG
|
||||||
|
|
||||||
|
`SolarEclipsePartialFootprintsInfo` 还给出影锥与地球的全球接触:`P1/P4` 是半影外切,`P2/P3` 是半影内切;`U1/U4` 是本影或反本影外切,`U2/U3` 是内切。某次日食不存在的接触保持 `time.Time` 零值。`CentralBeginOnEarth` / `CentralEndOnEarth` 仍表示影轴进入和离开地球,不等同于 `U1/U4`。
|
||||||
|
|
||||||
|
需要结构化的瞬时中心影轮廓时,可在 `SolarEclipsePartialFootprintOptions` 中设置 `CentralShadowStep`;结果写入 `CentralShadowFootprints`。零值关闭该额外计算,SVG 入口则默认按 10 分钟采样。
|
||||||
|
|
||||||
日食结果 `SolarEclipseInfo`、`LocalSolarEclipseInfo`,以及 `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` 里的 `Eclipse` 字段还会附带沙罗序列信息:
|
日食结果 `SolarEclipseInfo`、`LocalSolarEclipseInfo`,以及 `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` 里的 `Eclipse` 字段还会附带沙罗序列信息:
|
||||||
|
|
||||||
- `HasSaros`:是否成功匹配到沙罗序列
|
- `HasSaros`:是否成功匹配到沙罗序列
|
||||||
@@ -1197,6 +1213,311 @@ true 19671
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 月掩
|
||||||
|
|
||||||
|
月掩接口位于 `moon`,按用户给定的目标搜索,不会遍历恒星表。固定地点接口直接接收 `start`、`end`、经度、纬度和海拔;全球路径接口返回 WGS84 经纬度采样,可继续交给 `moon/svg` 或 `geojson`。
|
||||||
|
|
||||||
|
目标和接触语义分为两类:
|
||||||
|
|
||||||
|
- **恒星**按点光源处理,返回掩始 `Immersion`、掩甚 `Greatest` 和掩终 `Emersion`。
|
||||||
|
- **行星**按有限圆盘处理,外切为 C1/C4,完全被月面覆盖时另有内切 C2/C3;偏掩和擦掩没有 C2/C3。行星半径取赤道本体半径,不含行星环、大气延伸和扁率。
|
||||||
|
- `FindBestStarOccultations` / `FindBestPlanetOccultations` 返回全球海平面几何掩甚点,不按地平线、月高、可见时长或食分评分;`VisibleAtGreatest` 仅报告该点的可见性。
|
||||||
|
- 搜索时间窗按掩甚时刻选择事件。命中后会返回完整接触时刻或完整全球路径,不会把结果裁剪到查询端点。
|
||||||
|
- 接触时刻按目标与月面边缘的站心几何求解,不加入大气折射。`MoonAltitudeAtGreatest` 是月心真高度,`VisibleAtGreatest` 表示它是否不低于几何地平线。
|
||||||
|
|
||||||
|
#### 恒星月掩
|
||||||
|
|
||||||
|
恒星由调用者传入 `StarCoordinate`。`RA` / `Dec` 单位为度,`Epoch` 和 `Frame` 必填;自行单位为 `mas/year`,其中 `ProperMotionRACosDecMasPerYear` 使用星表常见的 `dRA*cos(Dec)` 口径。
|
||||||
|
|
||||||
|
可以直接构造坐标:
|
||||||
|
|
||||||
|
```go
|
||||||
|
target := 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,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以显式加载内置 9100 星表,再用 `StarCoordinateFromStarData` 转换。月掩搜索本身不会加载星表;只有调用 `star.InitStarDatabase`、`StarDataByName`、`StarDataByHR` 等星表接口时才会加载。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/moon"
|
||||||
|
"b612.me/astro/star"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cst := time.FixedZone("CST", 8*3600)
|
||||||
|
start := time.Date(2025, 6, 5, 0, 0, 0, 0, cst)
|
||||||
|
end := start.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
_ = star.InitStarDatabase()
|
||||||
|
data, _ := star.StarDataByName("进贤增九")
|
||||||
|
target, _ := moon.StarCoordinateFromStarData(data)
|
||||||
|
|
||||||
|
events, _ := moon.FindStarOccultations(
|
||||||
|
start, end, target,
|
||||||
|
121.56601, 6.80706, 0,
|
||||||
|
moon.OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
for _, event := range events {
|
||||||
|
fmt.Println(event.TargetID, event.Type)
|
||||||
|
fmt.Println(
|
||||||
|
event.Immersion.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
event.Greatest.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
event.Emersion.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
)
|
||||||
|
fmt.Printf("altitude=%.3f visible=%v\n", event.MoonAltitudeAtGreatest, event.VisibleAtGreatest)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths, _ := moon.FindStarOccultationPaths(
|
||||||
|
start, end, target,
|
||||||
|
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
||||||
|
)
|
||||||
|
for _, path := range paths {
|
||||||
|
fmt.Println(
|
||||||
|
path.Start.Time.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
path.Greatest.Time.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
path.End.Time.Format("2006-01-02 15:04:05.000 MST"),
|
||||||
|
)
|
||||||
|
fmt.Printf("greatest=%.6f %.6f width=%.1fkm center=%d\n",
|
||||||
|
path.Greatest.Longitude, path.Greatest.Latitude,
|
||||||
|
path.Greatest.WidthKM, len(path.CenterLine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
输出结果:
|
||||||
|
|
||||||
|
```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
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
`OccultationSearchOptions` 的零值使用默认搜索步长和安全余量;`MaxEvents > 0` 限制返回数量。`OccultationPathOptions.Step` 控制基础时间采样,`TargetSpacingKM` 按地面距离自适应加密中心线;过密请求超出确定性预算时返回 `ErrOccultationPathSamplingLimit`。
|
||||||
|
|
||||||
|
#### 行星月掩
|
||||||
|
|
||||||
|
行星目标使用 `OccultationMercury` 到 `OccultationNeptune` 常量。下面以 `2025-02-01` 月掩土星为例,在靠近全球几何掩甚点的位置求 C1-C4:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/moon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cst := time.FixedZone("CST", 8*3600)
|
||||||
|
start := time.Date(2025, 2, 1, 0, 0, 0, 0, cst)
|
||||||
|
events, _ := moon.FindPlanetOccultations(
|
||||||
|
start, start.Add(24*time.Hour), moon.OccultationSaturn,
|
||||||
|
104.52219613, 55.25401991, 0,
|
||||||
|
moon.OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
for _, event := range events {
|
||||||
|
fmt.Println(event.TargetID, event.Type, event.HasInternalContacts)
|
||||||
|
fmt.Println(event.ExternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C1
|
||||||
|
fmt.Println(event.InternalImmersion.Format("2006-01-02 15:04:05.000 MST")) // C2
|
||||||
|
fmt.Println(event.Greatest.Format("2006-01-02 15:04:05.000 MST"))
|
||||||
|
fmt.Println(event.InternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C3
|
||||||
|
fmt.Println(event.ExternalEmersion.Format("2006-01-02 15:04:05.000 MST")) // C4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
输出结果:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
`FindPlanetOccultationPaths` 的全球结果同时包含任意圆盘重叠的部分掩区域和整颗行星被遮住的全掩区域。`HasTotalBand` 表示是否存在全掩带,`GreatestTotalWidthKM` 是掩甚处全掩带宽;中心线、边界和瞬时足迹都带采样时刻。
|
||||||
|
|
||||||
|
#### 月掩 SVG
|
||||||
|
|
||||||
|
`moon/svg` 同时提供“搜索并渲染”和“渲染已计算结果”两组入口:
|
||||||
|
|
||||||
|
- `FindLocalStarOccultationSVGs` / `FindLocalPlanetOccultationSVGs`:指定地点的实际月面轨迹、白道和接触阶段图。
|
||||||
|
- `FindStarOccultationSVGs` / `FindPlanetOccultationSVGs`:全球掩带、中心线、阶段点和时间标记图。
|
||||||
|
- `LocalStarOccultationSVG` / `LocalPlanetOccultationSVG`:渲染已有的固定地点事件。
|
||||||
|
- `StarOccultationPathSVG` / `PlanetOccultationPathSVG`:渲染已有的全球路径。
|
||||||
|
|
||||||
|
```go
|
||||||
|
localSVGs, err := moonsvg.FindLocalStarOccultationSVGs(
|
||||||
|
start, end, target,
|
||||||
|
121.56601, 6.80706, 0,
|
||||||
|
moon.OccultationSearchOptions{},
|
||||||
|
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`,靠近全球几何掩甚点;图中的掩始、掩甚和掩终是该地点实际看到的站心接触时刻,并同时给出月面方向、白道、月高、方位和地平可见性。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
全球图展示同一事件的掩带边界、可见/几何中心线、全球阶段点以及每 30 分钟的中心线时间标记。全球掩始和掩终表示月影首次接触和最后离开地球,并不是上述指定地点的接触时刻。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 天象地图与 GeoJSON
|
||||||
|
|
||||||
|
#### 全球见食图 SVG
|
||||||
|
|
||||||
|
`eclipse/svg` 可直接生成日食和月食全球图。日食图绘制完整偏食可见区、全食/环食中心带、中心线、全球阶段信息和中心线时间标记;同时显示食甚时的晨昏圈与日下点、影轴进出地球点、`P1-P4/U1-U4` 接触点、定时半影轮廓和本影/反本影轮廓。月食图绘制 P1/P4 可见半球、月出/月落过渡区和整场可见区。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
eclipsesvg "b612.me/astro/eclipse/svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cst := time.FixedZone("CST", 8*3600)
|
||||||
|
|
||||||
|
solar, ok := eclipsesvg.SolarEclipseMapSVG(
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if ok {
|
||||||
|
_ = os.WriteFile("doc/solar-eclipse-yangshan-2009-global.svg", []byte(solar), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
lunar, ok := eclipsesvg.LunarEclipseMapSVG(
|
||||||
|
time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
|
||||||
|
eclipsesvg.LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst},
|
||||||
|
)
|
||||||
|
if ok {
|
||||||
|
_ = os.WriteFile("doc/lunar-eclipse-2029-01-01-global.svg", []byte(lunar), 0o644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
日食图右侧事件表按时间列出可用的 `P1-P4/U1-U4`、影轴进出地球和食甚时刻,空间允许时同时显示接触点经纬度;摘要还包含沙罗序列、食带宽、食甚点太阳高度/方位和中心食持续时间。图中的橙色虚线是带 `HH:MM` 标记的瞬时半影边界,灰色虚线是食甚时晨昏圈,棕色实线是本影或反本影瞬时轮廓。
|
||||||
|
|
||||||
|
`PenumbralOutlineStep` 与 `CentralShadowStep` 的零值分别使用 60 分钟和 10 分钟,负值关闭对应轮廓;显式设置仍可使用 30 分钟等其他间隔。`TimeLabelStep` 的零值为 30 分钟,负值关闭中心线时刻标记。
|
||||||
|
|
||||||
|
日食和月掩的自动投影会在适合时选择北极或南极图;月食默认使用等经纬投影。投影仅影响 SVG 表达,不改变底层 WGS84 地理结果。
|
||||||
|
|
||||||
|
日月食通过 `EclipseMapProjectionEquirectangular`、`EclipseMapProjectionNorthPolar`、`EclipseMapProjectionSouthPolar` 强制投影;月掩使用对应的 `MapProjection...` 常量。
|
||||||
|
|
||||||
|
下面的全球图沿用前文局地 SVG 的事件日期。2009 长江大日食、2012 厦门日环食和 2035 北京日全食使用等经纬投影:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`2012-05-21` 日环食的偏食可见区覆盖北极点。下面把同一事件强制切换为北极方位等距投影,以便查看跨反经线的北极区见食范围;圆形边界是投影范围,不是行政或政治边界:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
月食沿用前文 `2029-01-01` 跨年月全食,显示全程可见、带食月出、带食月落和不可见区域:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### GeoJSON
|
||||||
|
|
||||||
|
`geojson` 接收已经计算好的日食、月食或月掩结果,返回 `[]byte`。这段字节是完整的 UTF-8 RFC 7946 `FeatureCollection` JSON,不是图片,也不是压缩数据,可以直接写入 `.geojson`、交给 `encoding/json`,或发送给前端地图组件。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/geojson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)
|
||||||
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
||||||
|
date,
|
||||||
|
eclipse.SolarEclipsePartialFootprintOptions{
|
||||||
|
Step: 10 * time.Minute, BoundaryPoints: 180,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
central, hasCentral := eclipse.SolarEclipseCentralPath(
|
||||||
|
date,
|
||||||
|
eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 20},
|
||||||
|
)
|
||||||
|
var centralPath *eclipse.SolarEclipsePath
|
||||||
|
if hasCentral {
|
||||||
|
centralPath = ¢ral
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(
|
||||||
|
partial, centralPath,
|
||||||
|
geojson.TimeMarkerOptions{
|
||||||
|
Step: 30 * time.Minute,
|
||||||
|
Location: time.FixedZone("CST", 8*3600),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fmt.Println(err, json.Valid(data))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
对应的无时间标记和带时间标记入口包括:
|
||||||
|
|
||||||
|
- `MarshalSolarEclipse` / `MarshalSolarEclipseWithTimeMarkers`
|
||||||
|
- `MarshalLunarEclipse` / `MarshalLunarEclipseWithTimeMarkers`
|
||||||
|
- `MarshalStarOccultation` / `MarshalStarOccultationWithTimeMarkers`
|
||||||
|
- `MarshalPlanetOccultation` / `MarshalPlanetOccultationWithTimeMarkers`
|
||||||
|
|
||||||
|
坐标统一为 WGS84 经度、纬度,跨反经线的线和面会拆分。带时路径的 `times` 属性与各段坐标逐点对齐;`WithTimeMarkers` 另加 `role=time-marker` 的 Point Feature,本地化 `label` 用于显示,`time` 始终是 UTC RFC 3339。
|
||||||
|
|
||||||
|
`TimeMarkerOptions.Step` 的零值为 30 分钟,正值至少 1 分钟,每次导出最多 1440 个标记。GeoJSON 不携带底图、国家边界、样式或投影;Web Mercator、极区图、瓦片选择和政治边界由应用自行决定。
|
||||||
|
|
||||||
### 行星
|
### 行星
|
||||||
|
|
||||||
#### 内行星
|
#### 内行星
|
||||||
@@ -1692,7 +2013,7 @@ func main() {
|
|||||||
143.99353431082105 18.7404068044953
|
143.99353431082105 18.7404068044953
|
||||||
manual az=281.869347 alt=24.489608 zen=65.510392 ha=73.866900
|
manual az=281.869347 alt=24.489608 zen=65.510392 ha=73.866900
|
||||||
gal lon=0.000047 lat=-0.000079
|
gal lon=0.000047 lat=-0.000079
|
||||||
apparent alt=10.092644
|
apparent alt=10.093429
|
||||||
```
|
```
|
||||||
|
|
||||||
`coord` 里的研究型接口不会自动代入当前日期的黄赤交角或恒星时,适合做“不同自转轴倾角”“手工指定时角”这类推演。常规观测计算仍建议使用 `EclipticToEquatorial`、`EquatorialToHorizontal` 等带 `time.Time` 的接口。
|
`coord` 里的研究型接口不会自动代入当前日期的黄赤交角或恒星时,适合做“不同自转轴倾角”“手工指定时角”这类推演。常规观测计算仍建议使用 `EclipticToEquatorial`、`EquatorialToHorizontal` 等带 `time.Time` 的接口。
|
||||||
@@ -1925,7 +2246,8 @@ func main() {
|
|||||||
## 已实现
|
## 已实现
|
||||||
|
|
||||||
- ✅ 太阳位置、高度角、天顶距、方位角、中天、晨昏朦影、日出日落、节气、日食、日面物理参数
|
- ✅ 太阳位置、高度角、天顶距、方位角、中天、晨昏朦影、日出日落、节气、日食、日面物理参数
|
||||||
- ✅ 月亮位置、高度角、天顶距、方位角、中天、升落、月相、月食、天平动、近远地点、最大赤纬
|
- ✅ 月亮位置、高度角、天顶距、方位角、中天、升落、月相、月食、天平动、近远地点、最大赤纬,以及恒星/行星月掩
|
||||||
|
- ✅ 日食、月食和月掩的全球 SVG 投影图、指定地点月掩图、GeoJSON 与可选时间标记
|
||||||
- ✅ `lite/sun`、`lite/moon` 轻量太阳/月亮链路:面向分钟级升落、轻量位置和月相计算
|
- ✅ `lite/sun`、`lite/moon` 轻量太阳/月亮链路:面向分钟级升落、轻量位置和月相计算
|
||||||
- ✅ 地球偏心率、日地距离、近日点、远日点
|
- ✅ 地球偏心率、日地距离、近日点、远日点
|
||||||
- ✅ 真平恒星时、星座计算、常用坐标转换、大气折射、大气质量、视差角、银道坐标
|
- ✅ 真平恒星时、星座计算、常用坐标转换、大气折射、大气质量、视差角、银道坐标
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ancientStationTolerance = 10.0 / 1440.0
|
||||||
|
|
||||||
|
func ancientStationTT(year int, month time.Month, day int) float64 {
|
||||||
|
return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ancientStationUT(year int, month time.Month, day, hour, minute int) float64 {
|
||||||
|
return Date2JDE(time.Date(year, month, day, hour, minute, 0, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAncientNextStation(t *testing.T, queryTT, gotUT, wantUT float64) {
|
||||||
|
t.Helper()
|
||||||
|
if !eventUTQueryAfterOrEqual(gotUT, queryTT) {
|
||||||
|
t.Fatalf("station is before query: query TT %.9f, got UT %.9f", queryTT, gotUT)
|
||||||
|
}
|
||||||
|
if diff := math.Abs(gotUT - wantUT); diff > ancientStationTolerance {
|
||||||
|
t.Fatalf("station differs by %.3f minutes: got %s, want %s", diff*1440,
|
||||||
|
JDE2DateByZone(gotUT, time.UTC, false), JDE2DateByZone(wantUT, time.UTC, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarsAncientNextStationsStayOnTheirOppositionSides(t *testing.T) {
|
||||||
|
queryTT := ancientStationTT(-210, time.April, 1)
|
||||||
|
p2r := NextMarsProgradeToRetrograde(queryTT)
|
||||||
|
r2p := NextMarsRetrogradeToPrograde(queryTT)
|
||||||
|
|
||||||
|
assertAncientNextStation(t, queryTT, p2r, ancientStationUT(-209, time.March, 25, 20, 20))
|
||||||
|
assertAncientNextStation(t, queryTT, r2p, ancientStationUT(-209, time.June, 6, 11, 4))
|
||||||
|
if sameEventJD(p2r, r2p) {
|
||||||
|
t.Fatalf("typed Mars stations collapsed to one event: %.9f", p2r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMercuryAncientNextStationsDoNotSkipOrLoop(t *testing.T) {
|
||||||
|
janQueryTT := ancientStationTT(-210, time.January, 1)
|
||||||
|
assertAncientNextStation(t, janQueryTT, NextMercuryProgradeToRetrograde(janQueryTT), ancientStationUT(-210, time.February, 6, 21, 29))
|
||||||
|
assertAncientNextStation(t, janQueryTT, NextMercuryRetrogradeToPrograde(janQueryTT), ancientStationUT(-210, time.March, 1, 14, 20))
|
||||||
|
|
||||||
|
marQueryTT := ancientStationTT(-210, time.March, 1)
|
||||||
|
assertAncientNextStation(t, marQueryTT, NextMercuryProgradeToRetrograde(marQueryTT), ancientStationUT(-210, time.June, 11, 18, 50))
|
||||||
|
assertAncientNextStation(t, marQueryTT, NextMercuryRetrogradeToPrograde(marQueryTT), ancientStationUT(-210, time.March, 1, 14, 20))
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
|
|||||||
sinpi := Sin(0.0024427777777) / au
|
sinpi := Sin(0.0024427777777) / au
|
||||||
pcosi := pcosi(lat, h)
|
pcosi := pcosi(lat, h)
|
||||||
psini := psini(lat, h)
|
psini := psini(lat, h)
|
||||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra)
|
||||||
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
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
|
ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
||||||
@@ -92,22 +92,12 @@ func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TopocentricRa(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
func TopocentricRa(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
||||||
sinpi := Sin(0.0024427777777) / au
|
topocentricRA, _ := TopocentricRaDec(ra, dec, lat, lon, jd, au, h)
|
||||||
pcosi := pcosi(lat, h)
|
return topocentricRA
|
||||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
|
||||||
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
|
||||||
return ra + nra
|
|
||||||
}
|
}
|
||||||
func TopocentricDec(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
func TopocentricDec(ra, dec, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
||||||
|
_, topocentricDec := TopocentricRaDec(ra, dec, lat, lon, jd, au, h)
|
||||||
sinpi := Sin(0.0024427777777) / au
|
return topocentricDec
|
||||||
pcosi := pcosi(lat, h)
|
|
||||||
psini := psini(lat, h)
|
|
||||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + 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
|
|
||||||
return ndec
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TopocentricLo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
func TopocentricLo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林尼治标准时
|
||||||
@@ -115,7 +105,7 @@ func TopocentricLo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林
|
|||||||
s := psini(lat, h)
|
s := psini(lat, h)
|
||||||
sinpi := Sin(0.0024427777777) / au
|
sinpi := Sin(0.0024427777777) / au
|
||||||
ra := LoToRa(jd, lo, bo)
|
ra := LoToRa(jd, lo, bo)
|
||||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra)
|
||||||
n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH)
|
n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH)
|
||||||
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi
|
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi
|
||||||
return nlo
|
return nlo
|
||||||
@@ -126,7 +116,7 @@ func TopocentricBo(lo, bo, lat, lon, jd, au, h float64) float64 { //jd为格林
|
|||||||
s := psini(lat, h)
|
s := psini(lat, h)
|
||||||
sinpi := Sin(0.0024427777777) / au
|
sinpi := Sin(0.0024427777777) / au
|
||||||
ra := LoToRa(jd, lo, bo)
|
ra := LoToRa(jd, lo, bo)
|
||||||
tH := Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + lon - ra)
|
tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra)
|
||||||
n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH)
|
n := Cos(lo)*Cos(bo) - c*sinpi*Cos(tH)
|
||||||
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi
|
nlo := math.Atan2(Sin(lo)*Cos(bo)-sinpi*(s*Sin(TrueObliquity(jd))+c*Cos(TrueObliquity(jd))*Sin(tH)), n) * 180 / math.Pi
|
||||||
nbo := math.Atan2(Cos(nlo)*(Sin(bo)-sinpi*(s*Cos(TrueObliquity(jd))-c*Sin(TrueObliquity(jd))*Sin(tH))), n) * 180 / math.Pi
|
nbo := math.Atan2(Cos(nlo)*(Sin(bo)-sinpi*(s*Cos(TrueObliquity(jd))-c*Sin(TrueObliquity(jd))*Sin(tH))), n) * 180 / math.Pi
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
. "b612.me/astro/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTopocentricRaDecUsesUTJulianDateForSiderealTime(t *testing.T) {
|
||||||
|
ut := Date2JDE(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC))
|
||||||
|
ra := 189.527817246
|
||||||
|
dec := -5.973400893
|
||||||
|
lat := 6.79657
|
||||||
|
lon := 121.55381
|
||||||
|
distanceAU := HMoonAwayN(TD2UT(ut, true), -1) / 149597870.7
|
||||||
|
|
||||||
|
gotRA, gotDec := TopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
|
||||||
|
wantRA, wantDec := independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
|
||||||
|
if delta := angularDistanceArcsec(gotRA, gotDec, wantRA, wantDec); delta > 1e-6 {
|
||||||
|
t.Fatalf("TopocentricRaDec differs from independent formula by %.9f arcsec", delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopocentricRaAndDecMatchCombinedResult(t *testing.T) {
|
||||||
|
ut := Date2JDE(time.Date(2025, 6, 5, 12, 2, 7, 700000000, time.UTC))
|
||||||
|
ra := 189.527817246
|
||||||
|
dec := -5.973400893
|
||||||
|
lat := 6.79657
|
||||||
|
lon := 121.55381
|
||||||
|
distanceAU := HMoonAwayN(TD2UT(ut, true), -1) / 149597870.7
|
||||||
|
|
||||||
|
wantRA, wantDec := TopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, 0)
|
||||||
|
if got := TopocentricRa(ra, dec, lat, lon, ut, distanceAU, 0); got != wantRA {
|
||||||
|
t.Fatalf("TopocentricRa = %.12f, want %.12f", got, wantRA)
|
||||||
|
}
|
||||||
|
if got := TopocentricDec(ra, dec, lat, lon, ut, distanceAU, 0); got != wantDec {
|
||||||
|
t.Fatalf("TopocentricDec = %.12f, want %.12f", got, wantDec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMoonHeightUsesUTForTopocentricCorrection(t *testing.T) {
|
||||||
|
ut := Date2JDE(time.Date(2026, 4, 28, 16, 1, 30, 0, time.UTC))
|
||||||
|
longitude := 0.0
|
||||||
|
latitude := 51.4779
|
||||||
|
ra, dec := HMoonApparentRaDecN(ut, longitude, latitude, 0, -1)
|
||||||
|
hourAngle := Limit360(ApparentSiderealTime(ut)*15 + longitude - ra)
|
||||||
|
want := ArcSin(Sin(latitude)*Sin(dec) + Cos(dec)*Cos(latitude)*Cos(hourAngle))
|
||||||
|
got := HMoonHeightN(ut, longitude, latitude, 0, -1)
|
||||||
|
if difference := math.Abs(got - want); difference > 1e-10 {
|
||||||
|
t.Fatalf("HMoonHeightN differs from the UT topocentric position by %.12f degrees", difference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func independentTopocentricRaDec(ra, dec, lat, lon, ut, distanceAU, height float64) (float64, float64) {
|
||||||
|
const (
|
||||||
|
equatorialRadiusKM = 6378.14
|
||||||
|
polarRadiusKM = 6356.755
|
||||||
|
)
|
||||||
|
u := math.Atan(polarRadiusKM / equatorialRadiusKM * Tan(lat))
|
||||||
|
rhoCos := math.Cos(u) + height/6378140.0*Cos(lat)
|
||||||
|
rhoSin := polarRadiusKM/equatorialRadiusKM*math.Sin(u) + height/6378140.0*Sin(lat)
|
||||||
|
sinParallax := Sin(0.0024427777777) / distanceAU
|
||||||
|
hourAngle := Limit360(ApparentSiderealTime(ut)*15 + lon - ra)
|
||||||
|
deltaRA := math.Atan2(
|
||||||
|
-rhoCos*sinParallax*Sin(hourAngle),
|
||||||
|
Cos(dec)-rhoCos*sinParallax*Cos(hourAngle),
|
||||||
|
)
|
||||||
|
topRA := ra + deltaRA*180/math.Pi
|
||||||
|
topDec := math.Atan2(
|
||||||
|
(Sin(dec)-rhoSin*sinParallax)*math.Cos(deltaRA),
|
||||||
|
Cos(dec)-rhoCos*sinParallax*Cos(hourAngle),
|
||||||
|
) * 180 / math.Pi
|
||||||
|
return topRA, topDec
|
||||||
|
}
|
||||||
|
|
||||||
|
func angularDistanceArcsec(ra1, dec1, ra2, dec2 float64) float64 {
|
||||||
|
cosDistance := Sin(dec1)*Sin(dec2) + Cos(dec1)*Cos(dec2)*Cos(ra1-ra2)
|
||||||
|
return math.Acos(math.Max(-1, math.Min(1, cosDistance))) * 180 / math.Pi * 3600
|
||||||
|
}
|
||||||
@@ -2,6 +2,125 @@ package basic
|
|||||||
|
|
||||||
import "math"
|
import "math"
|
||||||
|
|
||||||
|
const (
|
||||||
|
eventNewtonMaxIterations = 24
|
||||||
|
eventDirectionalSearchIterations = 128
|
||||||
|
eventRiseSetScanStep = 1.0 / 1440
|
||||||
|
)
|
||||||
|
|
||||||
|
func isFiniteFloat(value float64) bool {
|
||||||
|
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventNewtonRefine 执行有界牛顿迭代;修正函数返回 f(x)/f'(x),调用者保留现有导数计算 / eventNewtonRefine performs a bounded Newton iteration. The correction
|
||||||
|
// 对格式错误输入和不收敛迭代快速失败 / function returns f(x)/f'(x), so callers retain their existing derivative
|
||||||
|
// 计算 / calculation while malformed input and non-convergent iterations fail fast.
|
||||||
|
func eventNewtonRefine(seed, tolerance float64, correction func(float64) float64) (float64, bool) {
|
||||||
|
if !isFiniteFloat(seed) || !isFiniteFloat(tolerance) || tolerance <= 0 {
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
current := seed
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
|
step := correction(current)
|
||||||
|
if !isFiniteFloat(step) {
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
next := current - step
|
||||||
|
if !isFiniteFloat(next) {
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
if math.Abs(next-current) <= tolerance {
|
||||||
|
return next, true
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventRiseSetCandidateValid(candidate, civilDayStart, slope float64, isRise bool) bool {
|
||||||
|
if !isFiniteFloat(candidate) || !isFiniteFloat(civilDayStart) || !isFiniteFloat(slope) ||
|
||||||
|
candidate < civilDayStart || candidate >= civilDayStart+1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isRise {
|
||||||
|
return slope > 0
|
||||||
|
}
|
||||||
|
return slope < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventDirectionalRiseSetSearch(civilDayStart float64, isRise bool, fallbackErr error,
|
||||||
|
residual func(float64) float64) (float64, error) {
|
||||||
|
if !isFiniteFloat(civilDayStart) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
|
||||||
|
previousJD := civilDayStart
|
||||||
|
previousValue := residual(previousJD)
|
||||||
|
if !isFiniteFloat(previousValue) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
minimum, maximum := previousValue, previousValue
|
||||||
|
steps := int(math.Round(1 / eventRiseSetScanStep))
|
||||||
|
for i := 1; i <= steps; i++ {
|
||||||
|
currentJD := civilDayStart + float64(i)*eventRiseSetScanStep
|
||||||
|
currentValue := residual(currentJD)
|
||||||
|
if !isFiniteFloat(currentValue) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
minimum = math.Min(minimum, currentValue)
|
||||||
|
maximum = math.Max(maximum, currentValue)
|
||||||
|
if eventCrossesDirection(previousValue, currentValue, isRise) {
|
||||||
|
eventJD := eventDirectionalBracketRefine(previousJD, currentJD, previousValue, currentValue, residual)
|
||||||
|
if eventJD < civilDayStart+1 {
|
||||||
|
return eventJD, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousJD = currentJD
|
||||||
|
previousValue = currentValue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case fallbackErr != nil:
|
||||||
|
return 0, fallbackErr
|
||||||
|
case maximum < 0:
|
||||||
|
return 0, ErrNeverRise
|
||||||
|
case minimum > 0:
|
||||||
|
return 0, ErrNeverSet
|
||||||
|
default:
|
||||||
|
return 0, ErrNotOnThisDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventCrossesDirection(leftValue, rightValue float64, isRise bool) bool {
|
||||||
|
if isRise {
|
||||||
|
return leftValue <= 0 && rightValue >= 0 && leftValue != rightValue
|
||||||
|
}
|
||||||
|
return leftValue >= 0 && rightValue <= 0 && leftValue != rightValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventDirectionalBracketRefine(leftJD, rightJD, leftValue, rightValue float64, residual func(float64) float64) float64 {
|
||||||
|
if leftValue == 0 {
|
||||||
|
return leftJD
|
||||||
|
}
|
||||||
|
if rightValue == 0 {
|
||||||
|
return rightJD
|
||||||
|
}
|
||||||
|
for i := 0; i < 48; i++ {
|
||||||
|
middleJD := (leftJD + rightJD) / 2
|
||||||
|
middleValue := residual(middleJD)
|
||||||
|
if middleValue == 0 {
|
||||||
|
return middleJD
|
||||||
|
}
|
||||||
|
if (leftValue < 0) == (middleValue < 0) {
|
||||||
|
leftJD = middleJD
|
||||||
|
leftValue = middleValue
|
||||||
|
} else {
|
||||||
|
rightJD = middleJD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (leftJD + rightJD) / 2
|
||||||
|
}
|
||||||
|
|
||||||
func eventFixedScanRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
|
func eventFixedScanRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
|
||||||
start := seed - halfWindow
|
start := seed - halfWindow
|
||||||
bestJD := start
|
bestJD := start
|
||||||
|
|||||||
@@ -173,14 +173,14 @@ func JupiterCulminationTime(jde, lon, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return currentHourAngle
|
return currentHourAngle
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
return hourAngleDelta / hourAngleSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func jupiterRADerivativeN(jde, delta float64, n int) float64 {
|
|||||||
|
|
||||||
func jupiterConjunctionFull(jde, degree float64, next uint8) float64 {
|
func jupiterConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := JUPITER_S_PERIOD / 360
|
daysPerDegree := JUPITER_S_PERIOD / 360
|
||||||
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
|
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -72,20 +75,29 @@ func jupiterConjunctionFull(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func jupiterConjunction(jde, degree float64, next uint8) float64 {
|
func jupiterConjunction(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := JUPITER_S_PERIOD / 360
|
daysPerDegree := JUPITER_S_PERIOD / 360
|
||||||
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
|
currentDelta := jupiterSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -94,24 +106,36 @@ func jupiterConjunction(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := jupiterSunLongitudeDeltaN(prevJD, degree, true, jupiterEventSearchN)
|
longitudeDelta := jupiterSunLongitudeDeltaN(prevJD, degree, true, jupiterEventSearchN)
|
||||||
longitudeSlope := (jupiterSunLongitudeDeltaN(prevJD+0.000005, degree, true, jupiterEventSearchN) - jupiterSunLongitudeDeltaN(prevJD-0.000005, degree, true, jupiterEventSearchN)) / 0.00001
|
longitudeSlope := (jupiterSunLongitudeDeltaN(prevJD+0.000005, degree, true, jupiterEventSearchN) - jupiterSunLongitudeDeltaN(prevJD-0.000005, degree, true, jupiterEventSearchN)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= jupiterPhaseCoarseTolerance {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= jupiterPhaseCoarseTolerance {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for {
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
converged = false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := jupiterSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (jupiterSunLongitudeDelta(prevJD+0.000005, degree, true) - jupiterSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -361,17 +361,32 @@ func jupiterGalileanElementsToPV(mu float64, elements jupiterGalileanElements) [
|
|||||||
p := elements.P
|
p := elements.P
|
||||||
a := elements.A
|
a := elements.A
|
||||||
al := elements.L
|
al := elements.L
|
||||||
|
invalid := func() [6]float64 {
|
||||||
|
nan := math.NaN()
|
||||||
|
return [6]float64{nan, nan, nan, nan, nan, nan}
|
||||||
|
}
|
||||||
|
if !isFiniteFloat(mu) || !isFiniteFloat(k) || !isFiniteFloat(h) || !isFiniteFloat(q) || !isFiniteFloat(p) || !isFiniteFloat(a) || !isFiniteFloat(al) || a == 0 {
|
||||||
|
return invalid()
|
||||||
|
}
|
||||||
an := math.Sqrt(mu / math.Pow(a, 3))
|
an := math.Sqrt(mu / math.Pow(a, 3))
|
||||||
ee := al + k*math.Sin(al) - h*math.Cos(al)
|
ee := al + k*math.Sin(al) - h*math.Cos(al)
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
ce := math.Cos(ee)
|
ce := math.Cos(ee)
|
||||||
se := math.Sin(ee)
|
se := math.Sin(ee)
|
||||||
de := (al - ee + k*se - h*ce) / (1 - k*ce - h*se)
|
de := (al - ee + k*se - h*ce) / (1 - k*ce - h*se)
|
||||||
|
if !isFiniteFloat(de) {
|
||||||
|
return invalid()
|
||||||
|
}
|
||||||
ee += de
|
ee += de
|
||||||
if math.Abs(de) < 1e-12 {
|
if math.Abs(de) < 1e-12 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return invalid()
|
||||||
|
}
|
||||||
ce := math.Cos(ee)
|
ce := math.Cos(ee)
|
||||||
se := math.Sin(ee)
|
se := math.Sin(ee)
|
||||||
dle := h*ce - k*se
|
dle := h*ce - k*se
|
||||||
|
|||||||
@@ -183,14 +183,14 @@ func MarsCulminationTime(jde, lon, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return currentHourAngle
|
return currentHourAngle
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
return hourAngleDelta / hourAngleSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import (
|
|||||||
// Pos
|
// Pos
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
|
MARS_S_PERIOD = 1 / ((1 / 365.256363004) - (1 / 686.98))
|
||||||
marsEventSearchN = 16
|
marsEventSearchN = 16
|
||||||
marsPhaseCoarseTolerance = 30.0 / 86400.0
|
marsPhaseCoarseTolerance = 30.0 / 86400.0
|
||||||
|
marsStationDerivativeStepDay = 0.01
|
||||||
|
marsStationCoarseStepDay = 6.0
|
||||||
|
marsStationHalfWindowDay = 6.0
|
||||||
)
|
)
|
||||||
|
|
||||||
func marsSunLongitudeDelta(jde, degree float64, filter bool) float64 {
|
func marsSunLongitudeDelta(jde, degree float64, filter bool) float64 {
|
||||||
@@ -64,6 +67,9 @@ func marsRADerivativeN(jde, val float64, n int) float64 {
|
|||||||
|
|
||||||
func marsConjunctionFull(jde, degree float64, next uint8) float64 {
|
func marsConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := MARS_S_PERIOD / 360
|
daysPerDegree := MARS_S_PERIOD / 360
|
||||||
currentDelta := marsSunLongitudeDelta(jde, degree, false)
|
currentDelta := marsSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -72,20 +78,29 @@ func marsConjunctionFull(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func marsConjunction(jde, degree float64, next uint8) float64 {
|
func marsConjunction(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := MARS_S_PERIOD / 360
|
daysPerDegree := MARS_S_PERIOD / 360
|
||||||
currentDelta := marsSunLongitudeDelta(jde, degree, false)
|
currentDelta := marsSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -94,24 +109,36 @@ func marsConjunction(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := marsSunLongitudeDeltaN(prevJD, degree, true, marsEventSearchN)
|
longitudeDelta := marsSunLongitudeDeltaN(prevJD, degree, true, marsEventSearchN)
|
||||||
longitudeSlope := (marsSunLongitudeDeltaN(prevJD+0.000005, degree, true, marsEventSearchN) - marsSunLongitudeDeltaN(prevJD-0.000005, degree, true, marsEventSearchN)) / 0.00001
|
longitudeSlope := (marsSunLongitudeDeltaN(prevJD+0.000005, degree, true, marsEventSearchN) - marsSunLongitudeDeltaN(prevJD-0.000005, degree, true, marsEventSearchN)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= marsPhaseCoarseTolerance {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= marsPhaseCoarseTolerance {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for {
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
converged = false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := marsSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (marsSunLongitudeDelta(prevJD+0.000005, degree, true) - marsSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,122 +175,60 @@ func LastMarsWesternQuadrature(jde float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
||||||
jde := oppositionJD
|
oppositionTT := TD2UT(oppositionJD, true)
|
||||||
|
startTT := oppositionTT
|
||||||
|
endTT := oppositionTT
|
||||||
if searchBeforeOpposition {
|
if searchBeforeOpposition {
|
||||||
jde -= 60
|
easternQuadratureUT := marsConjunction(oppositionTT, 90, 0)
|
||||||
|
startTT = TD2UT(easternQuadratureUT, true)
|
||||||
} else {
|
} else {
|
||||||
jde += 60
|
westernQuadratureUT := marsConjunction(oppositionTT, 270, 1)
|
||||||
|
endTT = TD2UT(westernQuadratureUT, true)
|
||||||
}
|
}
|
||||||
for {
|
bestJD := zeroEventInWindow(startTT, endTT, marsStationCoarseStepDay, marsStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 {
|
||||||
currentRate := marsRADerivative(jde, 1.0/86400.0)
|
return marsRADerivativeN(jd, marsStationDerivativeStepDay, marsEventSearchN)
|
||||||
if math.Abs(currentRate) > 0.55 {
|
}, func(jd float64) float64 {
|
||||||
jde += 2
|
return marsRADerivative(jd, marsStationDerivativeStepDay)
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
estimateJD := jde
|
|
||||||
for {
|
|
||||||
prevJD := estimateJD
|
|
||||||
rateValue := marsRADerivative(prevJD, 2.0/86400.0)
|
|
||||||
rateSlope := (marsRADerivative(prevJD+15.0/86400.0, 2.0/86400.0) - marsRADerivative(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
|
|
||||||
estimateJD = prevJD - rateValue/rateSlope
|
|
||||||
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
|
||||||
return marsRADerivative(jd, 0.5/86400.0)
|
|
||||||
})
|
})
|
||||||
return TD2UT(bestJD, false)
|
return TD2UT(bestJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func marsOppositionFromBefore(oppositionJD float64) float64 {
|
|
||||||
return marsConjunctionFull(eventUTLastQueryTT(oppositionJD), 180, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func marsOppositionFromAfter(oppositionJD float64) float64 {
|
|
||||||
return marsConjunctionFull(eventUTNextQueryTT(oppositionJD), 180, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stabilizeMarsStationNearQuery(jde, date float64, searchBeforeOpposition bool) float64 {
|
|
||||||
if math.Abs(eventUTQueryTTDelta(date, jde)) > exactEventTolerance {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
if searchBeforeOpposition {
|
|
||||||
stableOppositionJD := NextMarsOpposition(jde)
|
|
||||||
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
|
|
||||||
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, true), marsRetrogradeAroundOpposition(sameOppositionJD, true))
|
|
||||||
}
|
|
||||||
stableOppositionJD := LastMarsOpposition(jde)
|
|
||||||
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
|
|
||||||
return closestEventUTToQueryTT(jde, date, marsRetrogradeAroundOpposition(stableOppositionJD, false), marsRetrogradeAroundOpposition(sameOppositionJD, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
func NextMarsRetrogradeToPrograde(jde float64) float64 {
|
func NextMarsRetrogradeToPrograde(jde float64) float64 {
|
||||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||||
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
|
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||||
date = stabilizeMarsStationNearQuery(jde, date, false)
|
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||||
if sameEventUTQueryTT(date, jde) {
|
return date
|
||||||
stableOppositionJD := LastMarsOpposition(jde)
|
|
||||||
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
|
|
||||||
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
|
|
||||||
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
|
|
||||||
}
|
}
|
||||||
if !eventUTQueryAfterOrEqual(date, jde) {
|
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
return marsRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||||
return marsRetrogradeAroundOpposition(nextOppositionJD, false)
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LastMarsRetrogradeToPrograde(jde float64) float64 {
|
func LastMarsRetrogradeToPrograde(jde float64) float64 {
|
||||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||||
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
|
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||||
date = stabilizeMarsStationNearQuery(jde, date, false)
|
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
if sameEventUTQueryTT(date, jde) {
|
return date
|
||||||
stableOppositionJD := LastMarsOpposition(jde)
|
|
||||||
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, false)
|
|
||||||
sameOppositionJD := marsOppositionFromBefore(stableOppositionJD)
|
|
||||||
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, false))
|
|
||||||
}
|
}
|
||||||
if !eventUTQueryBeforeOrEqual(date, jde) {
|
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
||||||
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
return marsRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||||
return marsRetrogradeAroundOpposition(previousOppositionJD, false)
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NextMarsProgradeToRetrograde(jde float64) float64 {
|
func NextMarsProgradeToRetrograde(jde float64) float64 {
|
||||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||||
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
|
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||||
date = stabilizeMarsStationNearQuery(jde, date, true)
|
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||||
if sameEventUTQueryTT(date, jde) {
|
return date
|
||||||
stableOppositionJD := NextMarsOpposition(jde)
|
|
||||||
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
|
|
||||||
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
|
|
||||||
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
|
|
||||||
}
|
}
|
||||||
if !eventUTQueryAfterOrEqual(date, jde) {
|
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
||||||
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
return marsRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||||
return marsRetrogradeAroundOpposition(followingOppositionJD, true)
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LastMarsProgradeToRetrograde(jde float64) float64 {
|
func LastMarsProgradeToRetrograde(jde float64) float64 {
|
||||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||||
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
|
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||||
date = stabilizeMarsStationNearQuery(jde, date, true)
|
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
if sameEventUTQueryTT(date, jde) {
|
return date
|
||||||
stableOppositionJD := NextMarsOpposition(jde)
|
|
||||||
stableDate := marsRetrogradeAroundOpposition(stableOppositionJD, true)
|
|
||||||
sameOppositionJD := marsOppositionFromAfter(stableOppositionJD)
|
|
||||||
return closestEventUTToQueryTT(jde, date, stableDate, marsRetrogradeAroundOpposition(sameOppositionJD, true))
|
|
||||||
}
|
}
|
||||||
if !eventUTQueryBeforeOrEqual(date, jde) {
|
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
return marsRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||||
return marsRetrogradeAroundOpposition(lastOppositionJD, true)
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,14 +166,14 @@ func MercuryCulminationTime(jde, lon, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return currentHourAngle
|
return currentHourAngle
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
return hourAngleDelta / hourAngleSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const (
|
|||||||
mercuryConjunctionDerivativeStepDay = 2e-5 * 36525.0
|
mercuryConjunctionDerivativeStepDay = 2e-5 * 36525.0
|
||||||
mercuryLightTimeDaysPerAU = 0.0057755183
|
mercuryLightTimeDaysPerAU = 0.0057755183
|
||||||
mercuryEventSearchN = 16
|
mercuryEventSearchN = 16
|
||||||
|
mercuryStationWindowDays = 30.0
|
||||||
|
mercuryStationDerivativeStepDay = 0.01
|
||||||
|
mercuryStationCoarseStepDay = 2.0
|
||||||
|
mercuryStationHalfWindowDay = 2.0
|
||||||
|
mercuryStationMotionTolerance = 1e-3
|
||||||
)
|
)
|
||||||
|
|
||||||
type mercuryConjunctionLBR struct {
|
type mercuryConjunctionLBR struct {
|
||||||
@@ -109,65 +114,29 @@ func mercuryConjunctionApproxTT(seed float64, inferior bool) float64 {
|
|||||||
|
|
||||||
func mercuryConjunctionExactTT(seed float64, inferior bool) float64 {
|
func mercuryConjunctionExactTT(seed float64, inferior bool) float64 {
|
||||||
estimateJD := mercuryConjunctionApproxTT(seed, inferior)
|
estimateJD := mercuryConjunctionApproxTT(seed, inferior)
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := mercuryConjunctionExactDelta(prevJD)
|
longitudeDelta := mercuryConjunctionExactDelta(prevJD)
|
||||||
longitudeSlope := (mercuryConjunctionExactDelta(prevJD+0.000005) - mercuryConjunctionExactDelta(prevJD-0.000005)) / 0.00001
|
longitudeSlope := (mercuryConjunctionExactDelta(prevJD+0.000005) - mercuryConjunctionExactDelta(prevJD-0.000005)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|
||||||
func mercuryConjunctionLegacy(jde float64, next uint8) float64 {
|
|
||||||
//0=last 1=next
|
|
||||||
longitudeDeltaAt := func(jde float64) float64 {
|
|
||||||
return mercuryConjunctionExactDelta(jde)
|
|
||||||
}
|
|
||||||
currentDelta := longitudeDeltaAt(jde)
|
|
||||||
distanceTrend := math.Abs(longitudeDeltaAt(jde+1/86400.0)) - math.Abs(currentDelta)
|
|
||||||
if distanceTrend >= 0 && next == 1 && currentDelta > 0 {
|
|
||||||
jde += MERCURY_S_PERIOD/8.0 + 2
|
|
||||||
}
|
|
||||||
if distanceTrend >= 0 && next == 1 && currentDelta < 0 {
|
|
||||||
jde += MERCURY_S_PERIOD/6.0 + 2
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
currentDelta := longitudeDeltaAt(jde)
|
|
||||||
distanceTrend := math.Abs(longitudeDeltaAt(jde+1/86400.0)) - math.Abs(currentDelta)
|
|
||||||
if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) {
|
|
||||||
if next == 1 {
|
|
||||||
jde += 2
|
|
||||||
} else {
|
|
||||||
jde -= 2
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
estimateJD := jde
|
|
||||||
for {
|
|
||||||
prevJD := estimateJD
|
|
||||||
longitudeDelta := longitudeDeltaAt(prevJD)
|
|
||||||
longitudeSlope := (longitudeDeltaAt(prevJD+0.000005) - longitudeDeltaAt(prevJD-0.000005)) / 0.00001
|
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TD2UT(estimateJD, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mercuryConjunction(jde float64, next uint8) float64 {
|
func mercuryConjunction(jde float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if math.Abs(mercuryConjunctionExactDelta(jde)) <= 30.0/86400.0 {
|
if math.Abs(mercuryConjunctionExactDelta(jde)) <= 30.0/86400.0 {
|
||||||
best := math.NaN()
|
best := math.NaN()
|
||||||
consider := func(inferior bool) {
|
consider := func(inferior bool) {
|
||||||
@@ -203,9 +172,14 @@ func mercuryConjunction(jde float64, next uint8) float64 {
|
|||||||
if distanceTrend <= 0 && next == 0 && currentDelta > 0 {
|
if distanceTrend <= 0 && next == 0 && currentDelta > 0 {
|
||||||
jde -= MERCURY_S_PERIOD/6.0 + 2
|
jde -= MERCURY_S_PERIOD/6.0 + 2
|
||||||
}
|
}
|
||||||
for {
|
found := false
|
||||||
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
currentDelta := mercuryConjunctionExactDelta(jde)
|
currentDelta := mercuryConjunctionExactDelta(jde)
|
||||||
distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta)
|
nextDelta := mercuryConjunctionExactDelta(jde + 1/86400.0)
|
||||||
|
if !isFiniteFloat(currentDelta) || !isFiniteFloat(nextDelta) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
distanceTrend := math.Abs(nextDelta) - math.Abs(currentDelta)
|
||||||
if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) {
|
if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) {
|
||||||
if next == 1 {
|
if next == 1 {
|
||||||
jde += 2
|
jde += 2
|
||||||
@@ -214,11 +188,18 @@ func mercuryConjunction(jde float64, next uint8) float64 {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
|
||||||
inferior := mercuryConjunctionExactTT(jde, true)
|
inferior := mercuryConjunctionExactTT(jde, true)
|
||||||
superior := mercuryConjunctionExactTT(jde, false)
|
superior := mercuryConjunctionExactTT(jde, false)
|
||||||
|
if !isFiniteFloat(inferior) || !isFiniteFloat(superior) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
best := inferior
|
best := inferior
|
||||||
if math.Abs(superior-jde) < math.Abs(inferior-jde) {
|
if math.Abs(superior-jde) < math.Abs(inferior-jde) {
|
||||||
best = superior
|
best = superior
|
||||||
@@ -274,51 +255,6 @@ func LastMercurySuperiorConjunction(jde float64) float64 {
|
|||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
|
|
||||||
func mercuryRetrograde(jde float64) float64 {
|
|
||||||
//0=last 1=next
|
|
||||||
solarRADelta := func(jde float64) float64 {
|
|
||||||
sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde))
|
|
||||||
if sub > 180 {
|
|
||||||
sub -= 360
|
|
||||||
}
|
|
||||||
if sub < -180 {
|
|
||||||
sub += 360
|
|
||||||
}
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
lastConjunction := mercuryConjunctionLegacy(jde, 0)
|
|
||||||
nextConjunction := mercuryConjunctionLegacy(jde, 1)
|
|
||||||
currentRADelta := solarRADelta(jde)
|
|
||||||
if currentRADelta > 0 {
|
|
||||||
jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.0 * 3.5)
|
|
||||||
} else {
|
|
||||||
jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.5)
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
currentRate := mercuryRADerivative(jde, 1.0/86400.0)
|
|
||||||
if math.Abs(currentRate) > 0.55 {
|
|
||||||
jde += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
estimateJD := jde
|
|
||||||
for {
|
|
||||||
prevJD := estimateJD
|
|
||||||
rateValue := mercuryRADerivative(prevJD, 2.0/86400.0)
|
|
||||||
rateSlope := (mercuryRADerivative(prevJD+15.0/86400.0, 2.0/86400.0) - mercuryRADerivative(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
|
|
||||||
estimateJD = prevJD - rateValue/rateSlope
|
|
||||||
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
|
||||||
return mercuryRADerivative(jd, 0.5/86400.0)
|
|
||||||
})
|
|
||||||
//fmt.Println((bestJD - lastConjunction) / (nextConjunction - lastConjunction))
|
|
||||||
return TD2UT(bestJD, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mercuryRADerivative(jde, delta float64) float64 {
|
func mercuryRADerivative(jde, delta float64) float64 {
|
||||||
sub := MercuryApparentRa(jde+delta) - MercuryApparentRa(jde-delta)
|
sub := MercuryApparentRa(jde+delta) - MercuryApparentRa(jde-delta)
|
||||||
if sub > 180 {
|
if sub > 180 {
|
||||||
@@ -330,55 +266,174 @@ func mercuryRADerivative(jde, delta float64) float64 {
|
|||||||
return sub / (2 * delta)
|
return sub / (2 * delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mercuryStationIsProgradeToRetrograde(eventUT float64) bool {
|
func mercuryRADerivativeN(jde, delta float64, n int) float64 {
|
||||||
for _, offset := range []float64{0.25, 0.5, 1.0} {
|
sub := MercuryApparentRaN(jde+delta, n) - MercuryApparentRaN(jde-delta, n)
|
||||||
before := mercuryRADerivative(eventUT-offset, 0.5/86400.0)
|
if sub > 180 {
|
||||||
after := mercuryRADerivative(eventUT+offset, 0.5/86400.0)
|
sub -= 360
|
||||||
if before > 0 && after < 0 {
|
}
|
||||||
|
if sub < -180 {
|
||||||
|
sub += 360
|
||||||
|
}
|
||||||
|
return sub / (2 * delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mercuryStationInWindow(startTT, endTT float64) float64 {
|
||||||
|
bestJD := zeroEventInWindow(startTT, endTT, mercuryStationCoarseStepDay, mercuryStationHalfWindowDay, 30.0/86400.0, func(jd float64) float64 {
|
||||||
|
return mercuryRADerivativeN(jd, mercuryStationDerivativeStepDay, mercuryEventSearchN)
|
||||||
|
}, func(jd float64) float64 {
|
||||||
|
return mercuryRADerivative(jd, mercuryStationDerivativeStepDay)
|
||||||
|
})
|
||||||
|
return TD2UT(bestJD, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mercuryStationBetween(startTT, endTT float64) bool {
|
||||||
|
if endTT < startTT {
|
||||||
|
startTT, endTT = endTT, startTT
|
||||||
|
}
|
||||||
|
if endTT-startTT <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if endTT-startTT > mercuryStationWindowDays {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 截断扫描足以判断单候选快速路径是否安全 / A truncated scan is enough to decide whether the one-candidate fast path is safe.
|
||||||
|
left := startTT
|
||||||
|
leftValue := mercuryRADerivativeN(left, mercuryStationDerivativeStepDay, mercuryEventSearchN)
|
||||||
|
for left < endTT {
|
||||||
|
right := left + mercuryStationCoarseStepDay
|
||||||
|
if right > endTT {
|
||||||
|
right = endTT
|
||||||
|
}
|
||||||
|
rightValue := mercuryRADerivativeN(right, mercuryStationDerivativeStepDay, mercuryEventSearchN)
|
||||||
|
if leftValue == 0 || leftValue*rightValue < 0 || rightValue == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if before < 0 && after > 0 {
|
left = right
|
||||||
return false
|
leftValue = rightValue
|
||||||
}
|
|
||||||
}
|
}
|
||||||
before := mercuryRADerivative(eventUT-0.25, 0.5/86400.0)
|
return false
|
||||||
after := mercuryRADerivative(eventUT+0.25, 0.5/86400.0)
|
|
||||||
return before > after
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextMercuryTypedStation(jde float64, progradeToRetrograde bool) float64 {
|
func mercuryProgradeToRetrogradeAroundInferior(inferiorUT float64) float64 {
|
||||||
date := NextMercuryRetrogradeStrict(jde)
|
inferiorTT := TD2UT(inferiorUT, true)
|
||||||
for mercuryStationIsProgradeToRetrograde(date) != progradeToRetrograde {
|
return mercuryStationInWindow(inferiorTT-mercuryStationWindowDays, inferiorTT)
|
||||||
date = NextMercuryRetrogradeStrict(eventUTNextQueryTT(date))
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastMercuryTypedStation(jde float64, progradeToRetrograde bool) float64 {
|
func mercuryRetrogradeToProgradeAroundInferior(inferiorUT float64) float64 {
|
||||||
date := LastMercuryRetrogradeStrict(jde)
|
inferiorTT := TD2UT(inferiorUT, true)
|
||||||
for mercuryStationIsProgradeToRetrograde(date) != progradeToRetrograde {
|
return mercuryStationInWindow(inferiorTT, inferiorTT+mercuryStationWindowDays)
|
||||||
date = LastMercuryRetrogradeStrict(eventUTLastQueryTT(date))
|
}
|
||||||
|
|
||||||
|
func NextMercuryProgradeToRetrograde(jde float64) float64 {
|
||||||
|
inferior := NextMercuryInferiorConjunction(jde)
|
||||||
|
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
|
||||||
|
if eventUTQueryAfterOrEqual(date, jde) {
|
||||||
|
return date
|
||||||
}
|
}
|
||||||
return date
|
followingInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
|
||||||
|
return mercuryProgradeToRetrogradeAroundInferior(followingInferior)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NextMercuryRetrogradeToPrograde(jde float64) float64 {
|
||||||
|
inferior := LastMercuryInferiorConjunction(jde)
|
||||||
|
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
|
||||||
|
if eventUTQueryAfterOrEqual(date, jde) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
nextInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
|
||||||
|
return mercuryRetrogradeToProgradeAroundInferior(nextInferior)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LastMercuryProgradeToRetrograde(jde float64) float64 {
|
||||||
|
inferior := NextMercuryInferiorConjunction(jde)
|
||||||
|
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
|
||||||
|
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
previousInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
|
||||||
|
return mercuryProgradeToRetrogradeAroundInferior(previousInferior)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LastMercuryRetrogradeToPrograde(jde float64) float64 {
|
||||||
|
inferior := LastMercuryInferiorConjunction(jde)
|
||||||
|
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
|
||||||
|
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
previousInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
|
||||||
|
return mercuryRetrogradeToProgradeAroundInferior(previousInferior)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextMercuryRetrogradeFromTyped(jde float64) float64 {
|
||||||
|
p2r := NextMercuryProgradeToRetrograde(jde)
|
||||||
|
r2p := NextMercuryRetrogradeToPrograde(jde)
|
||||||
|
if p2r < r2p {
|
||||||
|
return p2r
|
||||||
|
}
|
||||||
|
return r2p
|
||||||
}
|
}
|
||||||
|
|
||||||
func NextMercuryRetrograde(jde float64) float64 {
|
func NextMercuryRetrograde(jde float64) float64 {
|
||||||
date := mercuryRetrograde(jde)
|
motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay)
|
||||||
if !eventUTQueryAfterOrEqual(date, jde) {
|
if motion > mercuryStationMotionTolerance {
|
||||||
nextConjunction := NextMercuryConjunctionStrict(jde)
|
p2r := NextMercuryProgradeToRetrograde(jde)
|
||||||
return mercuryRetrograde(nextConjunction + 2)
|
if !mercuryStationBetween(jde, TD2UT(p2r, true)) {
|
||||||
|
return p2r
|
||||||
|
}
|
||||||
|
r2p := NextMercuryRetrogradeToPrograde(jde)
|
||||||
|
if p2r < r2p {
|
||||||
|
return p2r
|
||||||
|
}
|
||||||
|
return r2p
|
||||||
}
|
}
|
||||||
return date
|
if motion < -mercuryStationMotionTolerance {
|
||||||
|
r2p := NextMercuryRetrogradeToPrograde(jde)
|
||||||
|
if !mercuryStationBetween(jde, TD2UT(r2p, true)) {
|
||||||
|
return r2p
|
||||||
|
}
|
||||||
|
p2r := NextMercuryProgradeToRetrograde(jde)
|
||||||
|
if p2r < r2p {
|
||||||
|
return p2r
|
||||||
|
}
|
||||||
|
return r2p
|
||||||
|
}
|
||||||
|
return nextMercuryRetrogradeFromTyped(jde)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastMercuryRetrogradeFromTyped(jde float64) float64 {
|
||||||
|
p2r := LastMercuryProgradeToRetrograde(jde)
|
||||||
|
r2p := LastMercuryRetrogradeToPrograde(jde)
|
||||||
|
if p2r > r2p {
|
||||||
|
return p2r
|
||||||
|
}
|
||||||
|
return r2p
|
||||||
}
|
}
|
||||||
|
|
||||||
func LastMercuryRetrograde(jde float64) float64 {
|
func LastMercuryRetrograde(jde float64) float64 {
|
||||||
lastConjunction := LastMercuryConjunctionStrict(jde)
|
motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay)
|
||||||
date := mercuryRetrograde(lastConjunction + 2)
|
if motion > mercuryStationMotionTolerance {
|
||||||
if !eventUTQueryBeforeOrEqual(date, jde) {
|
r2p := LastMercuryRetrogradeToPrograde(jde)
|
||||||
previousConjunction := LastMercuryConjunctionStrict(eventUTLastQueryTT(lastConjunction))
|
if !mercuryStationBetween(TD2UT(r2p, true), jde) {
|
||||||
return mercuryRetrograde(previousConjunction + 2)
|
return r2p
|
||||||
|
}
|
||||||
|
p2r := LastMercuryProgradeToRetrograde(jde)
|
||||||
|
if p2r > r2p {
|
||||||
|
return p2r
|
||||||
|
}
|
||||||
|
return r2p
|
||||||
}
|
}
|
||||||
return date
|
if motion < -mercuryStationMotionTolerance {
|
||||||
|
p2r := LastMercuryProgradeToRetrograde(jde)
|
||||||
|
if !mercuryStationBetween(TD2UT(p2r, true), jde) {
|
||||||
|
return p2r
|
||||||
|
}
|
||||||
|
r2p := LastMercuryRetrogradeToPrograde(jde)
|
||||||
|
if p2r > r2p {
|
||||||
|
return p2r
|
||||||
|
}
|
||||||
|
return r2p
|
||||||
|
}
|
||||||
|
return lastMercuryRetrogradeFromTyped(jde)
|
||||||
}
|
}
|
||||||
|
|
||||||
func LastMercuryRetrogradeStrict(jde float64) float64 {
|
func LastMercuryRetrogradeStrict(jde float64) float64 {
|
||||||
@@ -389,22 +444,6 @@ func NextMercuryRetrogradeStrict(jde float64) float64 {
|
|||||||
return NextMercuryRetrograde(jde)
|
return NextMercuryRetrograde(jde)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NextMercuryProgradeToRetrograde(jde float64) float64 {
|
|
||||||
return nextMercuryTypedStation(jde, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NextMercuryRetrogradeToPrograde(jde float64) float64 {
|
|
||||||
return nextMercuryTypedStation(jde, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LastMercuryProgradeToRetrograde(jde float64) float64 {
|
|
||||||
return lastMercuryTypedStation(jde, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LastMercuryRetrogradeToPrograde(jde float64) float64 {
|
|
||||||
return lastMercuryTypedStation(jde, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func MercurySunElongation(jde float64) float64 {
|
func MercurySunElongation(jde float64) float64 {
|
||||||
lo1, bo1 := MercuryApparentLoBo(jde)
|
lo1, bo1 := MercuryApparentLoBo(jde)
|
||||||
lo2 := HSunApparentLo(jde)
|
lo2 := HSunApparentLo(jde)
|
||||||
@@ -466,52 +505,77 @@ func mercuryWestElongationWindowContaining(jde float64) (float64, float64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func nextMercuryGreatestElongationTyped(jde float64, east bool) float64 {
|
func nextMercuryGreatestElongationTyped(jde float64, east bool) float64 {
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if east {
|
if east {
|
||||||
start, windowEnd := mercuryEastElongationWindowContaining(jde)
|
start, windowEnd := mercuryEastElongationWindowContaining(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := mercuryGreatestElongationInWindow(start, windowEnd)
|
date := mercuryGreatestElongationInWindow(start, windowEnd)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryAfterOrEqual(date, jde) {
|
if eventUTQueryAfterOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
nextInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(windowEnd))
|
nextInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(windowEnd))
|
||||||
start, windowEnd = mercuryEastElongationWindowEndingAt(nextInferior)
|
start, windowEnd = mercuryEastElongationWindowEndingAt(nextInferior)
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
start, windowEnd := mercuryWestElongationWindowContaining(jde)
|
start, windowEnd := mercuryWestElongationWindowContaining(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := mercuryGreatestElongationInWindow(start, windowEnd)
|
date := mercuryGreatestElongationInWindow(start, windowEnd)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryAfterOrEqual(date, jde) {
|
if eventUTQueryAfterOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
nextSuperior := NextMercurySuperiorConjunction(eventUTNextQueryTT(windowEnd))
|
nextSuperior := NextMercurySuperiorConjunction(eventUTNextQueryTT(windowEnd))
|
||||||
start, windowEnd = mercuryWestElongationWindowEndingAt(nextSuperior)
|
start, windowEnd = mercuryWestElongationWindowEndingAt(nextSuperior)
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastMercuryGreatestElongationTyped(jde float64, east bool) float64 {
|
func lastMercuryGreatestElongationTyped(jde float64, east bool) float64 {
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if east {
|
if east {
|
||||||
start, windowEnd := mercuryEastElongationWindowContaining(jde)
|
start, windowEnd := mercuryEastElongationWindowContaining(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := mercuryGreatestElongationInWindow(start, windowEnd)
|
date := mercuryGreatestElongationInWindow(start, windowEnd)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryBeforeOrEqual(date, jde) {
|
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
prevInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(start))
|
prevInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(start))
|
||||||
start, windowEnd = mercuryEastElongationWindowEndingAt(prevInferior)
|
start, windowEnd = mercuryEastElongationWindowEndingAt(prevInferior)
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
start, windowEnd := mercuryWestElongationWindowContaining(jde)
|
start, windowEnd := mercuryWestElongationWindowContaining(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := mercuryGreatestElongationInWindow(start, windowEnd)
|
date := mercuryGreatestElongationInWindow(start, windowEnd)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryBeforeOrEqual(date, jde) {
|
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
prevSuperior := LastMercurySuperiorConjunction(eventUTLastQueryTT(start))
|
prevSuperior := LastMercurySuperiorConjunction(eventUTLastQueryTT(start))
|
||||||
start, windowEnd = mercuryWestElongationWindowEndingAt(prevSuperior)
|
start, windowEnd = mercuryWestElongationWindowEndingAt(prevSuperior)
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mercuryGreatestElongation(jde float64) float64 {
|
func mercuryGreatestElongation(jde float64) float64 {
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
solarRADelta := func(jde float64) float64 {
|
solarRADelta := func(jde float64) float64 {
|
||||||
sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde))
|
sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde))
|
||||||
if sub > 180 {
|
if sub > 180 {
|
||||||
@@ -540,23 +604,31 @@ func mercuryGreatestElongation(jde float64) float64 {
|
|||||||
} else {
|
} else {
|
||||||
jde = lastConjunction + ((nextConjunction - lastConjunction) / 6.0)
|
jde = lastConjunction + ((nextConjunction - lastConjunction) / 6.0)
|
||||||
}
|
}
|
||||||
for {
|
found := false
|
||||||
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
currentRate := elongationRate(jde, 1.0/86400.0)
|
currentRate := elongationRate(jde, 1.0/86400.0)
|
||||||
|
if !isFiniteFloat(currentRate) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if math.Abs(currentRate) > 0.4 {
|
if math.Abs(currentRate) > 0.4 {
|
||||||
jde += 2
|
jde += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 30.0/86400.0, func(prevJD float64) float64 {
|
||||||
rateValue := elongationRate(prevJD, 2.0/86400.0)
|
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)
|
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)
|
||||||
estimateJD = prevJD - rateValue/rateSlope
|
return rateValue / rateSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 30.0/86400.0 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
||||||
return elongationRate(jd, 0.5/86400.0)
|
return elongationRate(jd, 0.5/86400.0)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMercuryRetrogradeFastPathKeepsTypedCandidateOrder(t *testing.T) {
|
||||||
|
// 这些查询覆盖一个普通古代周期和两个长间隔边界 / These queries cover a normal ancient cycle and two long-gap boundaries
|
||||||
|
//,类型化驻留搜索必须作为最终权威 / where the typed station search must remain the final authority.
|
||||||
|
for _, queryUT := range []float64{
|
||||||
|
1026548.810500779,
|
||||||
|
1644733.927538287,
|
||||||
|
1645082.416782375,
|
||||||
|
} {
|
||||||
|
queryTT := TD2UT(queryUT, true)
|
||||||
|
nextP2R := NextMercuryProgradeToRetrograde(queryTT)
|
||||||
|
nextR2P := NextMercuryRetrogradeToPrograde(queryTT)
|
||||||
|
wantNext := math.Min(nextP2R, nextR2P)
|
||||||
|
if got := NextMercuryRetrograde(queryTT); math.Abs(got-wantNext) > 1e-7 {
|
||||||
|
t.Fatalf("next aggregate mismatch at %.9f: got %.12f want %.12f", queryUT, got, wantNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastP2R := LastMercuryProgradeToRetrograde(queryTT)
|
||||||
|
lastR2P := LastMercuryRetrogradeToPrograde(queryTT)
|
||||||
|
wantLast := math.Max(lastP2R, lastR2P)
|
||||||
|
if got := LastMercuryRetrograde(queryTT); math.Abs(got-wantLast) > 1e-7 {
|
||||||
|
t.Fatalf("last aggregate mismatch at %.9f: got %.12f want %.12f", queryUT, got, wantLast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,16 +89,50 @@ func HMoonHeight(jd, lon, lat, tz float64) float64 {
|
|||||||
return HMoonHeightN(jd, lon, lat, tz, -1)
|
return HMoonHeightN(jd, lon, lat, tz, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type moonObservationState struct {
|
||||||
|
altitude float64
|
||||||
|
distanceKM float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func hMoonObservationStateN(jd, lon, lat, tz, height float64, n int) moonObservationState {
|
||||||
|
calculationJD := TD2UT(jd-tz/24, true)
|
||||||
|
ra, dec := HMoonTrueRaDecN(calculationJD, n)
|
||||||
|
distanceKM := HMoonAwayN(calculationJD, n)
|
||||||
|
distanceAU := distanceKM / angularDiameterAstronomicalUnitKM
|
||||||
|
topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, lat, lon, jd-tz/24, distanceAU, height)
|
||||||
|
siderealTime := Limit360(ApparentSiderealTime(jd-tz/24)*15 + lon)
|
||||||
|
hourAngle := Limit360(siderealTime - topocentricRA)
|
||||||
|
altitudeSine := Sin(lat)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(lat)*Cos(hourAngle)
|
||||||
|
return moonObservationState{
|
||||||
|
altitude: ArcSin(altitudeSine),
|
||||||
|
distanceKM: distanceKM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func HMoonHeightN(jd, lon, lat, tz float64, n int) float64 {
|
func HMoonHeightN(jd, lon, lat, tz float64, n int) float64 {
|
||||||
calcjd := TD2UT(jd-tz/24, true)
|
return hMoonObservationStateN(jd, lon, lat, tz, 0, n).altitude
|
||||||
ra, dec := HMoonTrueRaDecN(calcjd, n)
|
}
|
||||||
away := HMoonAwayN(calcjd, n) / 149597870.7
|
|
||||||
nra, ndec := TopocentricRaDec(ra, dec, lat, lon, calcjd, away, 0)
|
func moonRiseSetResidual(jd, longitude, latitude, timeZone, zenithShift, height float64, n int) float64 {
|
||||||
calcjd = jd - tz/24
|
state := hMoonObservationStateN(jd, longitude, latitude, timeZone, height, n)
|
||||||
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
|
// 相对观测者下沉地平线的视上缘高度角 / Apparent upper-limb altitude relative to the observer's depressed horizon.
|
||||||
hourAngle := Limit360(st - nra)
|
residual := state.altitude + HeightDegreeByLat(height, latitude)
|
||||||
tmp2 := Sin(lat)*Sin(ndec) + Cos(ndec)*Cos(lat)*Cos(hourAngle)
|
if zenithShift != 0 {
|
||||||
return ArcSin(tmp2)
|
residual += RefractionFromTrueAltitude(state.altitude, refractionStandardPressureHPa, refractionStandardTemperatureC)
|
||||||
|
residual += angularSemidiameterArcsec(moonEquatorialRadiusKM, state.distanceKM) / 3600
|
||||||
|
}
|
||||||
|
return residual
|
||||||
|
}
|
||||||
|
|
||||||
|
func moonRiseSetOnCivilDay(candidate, slope, civilDayStart, longitude, latitude, originalTimeZone,
|
||||||
|
localTimeZone, zenithShift, height float64, isRise bool, fallbackErr error) (float64, error) {
|
||||||
|
if eventRiseSetCandidateValid(candidate, civilDayStart, slope, isRise) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
return eventDirectionalRiseSetSearch(civilDayStart, isRise, fallbackErr, func(outputJD float64) float64 {
|
||||||
|
localJD := outputJD + localTimeZone/24 - originalTimeZone/24
|
||||||
|
return moonRiseSetResidual(localJD, longitude, latitude, localTimeZone, zenithShift, height, -1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 废弃
|
// 废弃
|
||||||
@@ -109,14 +143,14 @@ func GetMoonTZTime(jd, lon, lat, tz float64) float64 { //实际中天时间{
|
|||||||
jd += 0.5
|
jd += 0.5
|
||||||
}
|
}
|
||||||
estimateJD := jd
|
estimateJD := jd
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := MoonTimeAngle(prevJD, lon, lat, tz) - 359.599
|
stDegree := MoonTimeAngle(prevJD, lon, lat, tz) - 359.599
|
||||||
stDegreep := (MoonTimeAngle(prevJD+0.000005, lon, lat, tz) - MoonTimeAngle(prevJD-0.000005, lon, lat, tz)) / 0.00001
|
stDegreep := (MoonTimeAngle(prevJD+0.000005, lon, lat, tz) - MoonTimeAngle(prevJD-0.000005, lon, lat, tz)) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
@@ -133,14 +167,14 @@ func MoonCulminationTime(jde, lon, lat, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return ha
|
return ha
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := limitHA(prevJD, lon, timezone) - 360
|
stDegree := limitHA(prevJD, lon, timezone) - 360
|
||||||
stDegreep := (limitHA(prevJD+0.000005, lon, timezone) - limitHA(prevJD-0.000005, lon, timezone)) / 0.00001
|
stDegreep := (limitHA(prevJD+0.000005, lon, timezone) - limitHA(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
@@ -155,21 +189,24 @@ func MoonTimeAngle(jd, lon, lat, tz float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
||||||
|
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
originalTimeZone := timeZone
|
originalTimeZone := timeZone
|
||||||
timeZone = longitude / 15
|
timeZone = longitude / 15
|
||||||
var timeToMeridian float64
|
var timeToMeridian float64
|
||||||
julianDayZero := math.Floor(julianDay) + 0.5
|
civilDayStart := math.Floor(julianDay) + 0.5
|
||||||
//julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE
|
//julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE
|
||||||
//fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错
|
//fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错
|
||||||
julianDay = math.Floor(julianDay) + 0.5
|
julianDay = math.Floor(julianDay) + 0.5
|
||||||
estimatedTime := julianDay
|
estimatedTime := julianDay
|
||||||
moonHeight := MoonHeight(julianDay, longitude, latitude, originalTimeZone) // 求此时月亮高度
|
moonResidual := moonRiseSetResidual(julianDay, longitude, latitude, originalTimeZone, zenithShift, height, -1)
|
||||||
|
|
||||||
moonAngle := StandardAltitudeMoon(zenithShift, height, latitude)
|
moonAngle := StandardAltitudeMoon(zenithShift, height, latitude)
|
||||||
|
|
||||||
moonAngleTime := MoonTimeAngle(julianDay, longitude, latitude, originalTimeZone)
|
moonAngleTime := MoonTimeAngle(julianDay, longitude, latitude, originalTimeZone)
|
||||||
|
|
||||||
if moonHeight-moonAngle > 0 { // 月亮在地平线上或在落下与下中天之间
|
if moonResidual > 0 { // 月亮在地平线上或在落下与下中天之间
|
||||||
if moonAngleTime > 180 {
|
if moonAngleTime > 180 {
|
||||||
timeToMeridian = (180 + 360 - moonAngleTime) / 15
|
timeToMeridian = (180 + 360 - moonAngleTime) / 15
|
||||||
} else {
|
} else {
|
||||||
@@ -178,10 +215,10 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
|||||||
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if moonHeight-moonAngle < 0 && moonAngleTime > 180 {
|
if moonResidual < 0 && moonAngleTime > 180 {
|
||||||
timeToMeridian = (180 - moonAngleTime) / 15
|
timeToMeridian = (180 - moonAngleTime) / 15
|
||||||
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
||||||
} else if moonHeight-moonAngle < 0 && moonAngleTime < 180 {
|
} else if moonResidual < 0 && moonAngleTime < 180 {
|
||||||
timeToMeridian = (180 - moonAngleTime) / 15
|
timeToMeridian = (180 - moonAngleTime) / 15
|
||||||
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24*12.0)/15.0/24.0)
|
||||||
}
|
}
|
||||||
@@ -191,10 +228,11 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
|||||||
estimatedTime += (180 - currentAngle) * 4.0 / 60.0 / 24.0
|
estimatedTime += (180 - currentAngle) * 4.0 / 60.0 / 24.0
|
||||||
}
|
}
|
||||||
|
|
||||||
currentHeight := HMoonHeight(estimatedTime, longitude, latitude, timeZone)
|
currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1)
|
||||||
if !(currentHeight < -10 && math.Abs(latitude) < 60) {
|
if !(currentResidual < -10 && math.Abs(latitude) < 60) {
|
||||||
if currentHeight > moonAngle {
|
if currentResidual > 0 {
|
||||||
return 0, ErrNeverSet
|
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||||
|
originalTimeZone, timeZone, zenithShift, height, true, ErrNeverSet)
|
||||||
}
|
}
|
||||||
checkTime := estimatedTime + 12.0/24.0 + 6.0/15.0/24.0
|
checkTime := estimatedTime + 12.0/24.0 + 6.0/15.0/24.0
|
||||||
checkAngle := MoonTimeAngle(checkTime, longitude, latitude, timeZone)
|
checkAngle := MoonTimeAngle(checkTime, longitude, latitude, timeZone)
|
||||||
@@ -202,8 +240,9 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
|||||||
checkAngle += 360
|
checkAngle += 360
|
||||||
}
|
}
|
||||||
checkTime += (360 - checkAngle) * 4.0 / 60.0 / 24.0
|
checkTime += (360 - checkAngle) * 4.0 / 60.0 / 24.0
|
||||||
if HMoonHeight(checkTime, longitude, latitude, timeZone) < moonAngle {
|
if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) < 0 {
|
||||||
return 0, ErrNeverRise
|
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||||
|
originalTimeZone, timeZone, zenithShift, height, true, ErrNeverRise)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +254,7 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
|||||||
estimatedTime += hourAngle/24.00 + hourAngle/33.00/15.00
|
estimatedTime += hourAngle/24.00 + hourAngle/33.00/15.00
|
||||||
} else {
|
} else {
|
||||||
i := 0
|
i := 0
|
||||||
for MoonHeight(estimatedTime, longitude, latitude, timeZone) < moonAngle {
|
for moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1) < 0 {
|
||||||
i++
|
i++
|
||||||
estimatedTime += 15.0 / 60.0 / 24.0
|
estimatedTime += 15.0 / 60.0 / 24.0
|
||||||
if i > 48 {
|
if i > 48 {
|
||||||
@@ -225,41 +264,40 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用牛顿迭代法求精确解
|
// 使用牛顿迭代法求精确解
|
||||||
estimatedTime = moonRiseSetNewtonRaphsonIteration(estimatedTime, longitude, latitude, timeZone, moonAngle, HMoonHeight, 0.00002)
|
estimatedTime, slope := moonRiseSetResidualIteration(estimatedTime, longitude, latitude, timeZone, zenithShift, height, 0.00002)
|
||||||
|
|
||||||
estimatedTime = estimatedTime - timeZone/24 + originalTimeZone/24
|
estimatedTime = estimatedTime - timeZone/24 + originalTimeZone/24
|
||||||
|
return moonRiseSetOnCivilDay(estimatedTime, slope, civilDayStart, longitude, latitude,
|
||||||
if estimatedTime > julianDayZero+1 || estimatedTime < julianDayZero {
|
originalTimeZone, timeZone, zenithShift, height, true, nil)
|
||||||
return 0, ErrNotOnThisDate
|
|
||||||
}
|
|
||||||
return estimatedTime, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
||||||
|
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
originalTimeZone := timeZone
|
originalTimeZone := timeZone
|
||||||
timeZone = longitude / 15
|
timeZone = longitude / 15
|
||||||
var timeToMeridian float64
|
var timeToMeridian float64
|
||||||
julianDayZero := math.Floor(julianDay) + 0.5
|
civilDayStart := math.Floor(julianDay) + 0.5
|
||||||
//julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE
|
//julianDay = math.Floor(julianDay) + 0.5 - originalTimeZone/24 + timeZone/24 // 求0时JDE
|
||||||
//fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错
|
//fix:这里时间分界线应当以传入的时区为准,不应当使用当地时区,否则在0时的判断会出错
|
||||||
julianDay = math.Floor(julianDay) + 0.5
|
julianDay = math.Floor(julianDay) + 0.5
|
||||||
estimatedTime := julianDay
|
estimatedTime := julianDay
|
||||||
moonHeight := MoonHeight(julianDay, longitude, latitude, originalTimeZone) // 求此时月亮高度
|
moonResidual := moonRiseSetResidual(julianDay, longitude, latitude, originalTimeZone, zenithShift, height, -1)
|
||||||
|
|
||||||
moonAngle := StandardAltitudeMoon(zenithShift, height, latitude)
|
moonAngle := StandardAltitudeMoon(zenithShift, height, latitude)
|
||||||
|
|
||||||
moonAngleTime := MoonTimeAngle(julianDay, longitude, latitude, originalTimeZone)
|
moonAngleTime := MoonTimeAngle(julianDay, longitude, latitude, originalTimeZone)
|
||||||
|
|
||||||
if moonHeight-moonAngle < 0 {
|
if moonResidual < 0 {
|
||||||
timeToMeridian = (360 - moonAngleTime) / 15
|
timeToMeridian = (360 - moonAngleTime) / 15
|
||||||
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
estimatedTime += (timeToMeridian/24 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 月亮在地平线上或在落下与下中天之间
|
// 月亮在地平线上或在落下与下中天之间
|
||||||
if moonHeight-moonAngle > 0 && moonAngleTime < 180 {
|
if moonResidual > 0 && moonAngleTime < 180 {
|
||||||
timeToMeridian = (-moonAngleTime) / 15
|
timeToMeridian = (-moonAngleTime) / 15
|
||||||
estimatedTime += (timeToMeridian/24.0 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
estimatedTime += (timeToMeridian/24.0 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
||||||
} else if moonHeight-moonAngle > 0 {
|
} else if moonResidual > 0 {
|
||||||
timeToMeridian = (360 - moonAngleTime) / 15
|
timeToMeridian = (360 - moonAngleTime) / 15
|
||||||
estimatedTime += (timeToMeridian/24.0 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
estimatedTime += (timeToMeridian/24.0 + (timeToMeridian/24.0*12.0)/15.0/24.0)
|
||||||
}
|
}
|
||||||
@@ -273,16 +311,18 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
|||||||
}
|
}
|
||||||
|
|
||||||
// estimatedTime = 月球中天时间
|
// estimatedTime = 月球中天时间
|
||||||
currentHeight := HMoonHeight(estimatedTime, longitude, latitude, timeZone)
|
currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1)
|
||||||
if !(currentHeight > 10 && math.Abs(latitude) < 60) {
|
if !(currentResidual > 10 && math.Abs(latitude) < 60) {
|
||||||
if currentHeight < moonAngle {
|
if currentResidual < 0 {
|
||||||
return 0, ErrNeverRise
|
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||||
|
originalTimeZone, timeZone, zenithShift, height, false, ErrNeverRise)
|
||||||
}
|
}
|
||||||
checkTime := estimatedTime + 12.0/24.0 + 6.0/15.0/24.0
|
checkTime := estimatedTime + 12.0/24.0 + 6.0/15.0/24.0
|
||||||
angleSubtraction := 180 - MoonTimeAngle(checkTime, longitude, latitude, timeZone)
|
angleSubtraction := 180 - MoonTimeAngle(checkTime, longitude, latitude, timeZone)
|
||||||
checkTime += angleSubtraction * 4.0 / 60.0 / 24.0
|
checkTime += angleSubtraction * 4.0 / 60.0 / 24.0
|
||||||
if HMoonHeight(checkTime, longitude, latitude, timeZone) > moonAngle {
|
if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) > 0 {
|
||||||
return 0, ErrNeverSet
|
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||||
|
originalTimeZone, timeZone, zenithShift, height, false, ErrNeverSet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +334,7 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
|||||||
estimatedTime += hourAngle/24 + hourAngle/33.0/15.0
|
estimatedTime += hourAngle/24 + hourAngle/33.0/15.0
|
||||||
} else {
|
} else {
|
||||||
i := 0
|
i := 0
|
||||||
for MoonHeight(estimatedTime, longitude, latitude, timeZone) > moonAngle {
|
for moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1) > 0 {
|
||||||
i++
|
i++
|
||||||
estimatedTime += 15.0 / 60.0 / 24.0
|
estimatedTime += 15.0 / 60.0 / 24.0
|
||||||
if i > 48 {
|
if i > 48 {
|
||||||
@@ -304,14 +344,10 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用牛顿迭代法求精确解
|
// 使用牛顿迭代法求精确解
|
||||||
estimatedTime = moonRiseSetNewtonRaphsonIteration(estimatedTime, longitude, latitude, timeZone, moonAngle, HMoonHeight, 0.00002)
|
estimatedTime, slope := moonRiseSetResidualIteration(estimatedTime, longitude, latitude, timeZone, zenithShift, height, 0.00002)
|
||||||
|
|
||||||
estimatedTime = estimatedTime - timeZone/24 + originalTimeZone/24
|
estimatedTime = estimatedTime - timeZone/24 + originalTimeZone/24
|
||||||
|
return moonRiseSetOnCivilDay(estimatedTime, slope, civilDayStart, longitude, latitude,
|
||||||
if estimatedTime > julianDayZero+1 || estimatedTime < julianDayZero {
|
originalTimeZone, timeZone, zenithShift, height, false, nil)
|
||||||
return 0, ErrNotOnThisDate
|
|
||||||
}
|
|
||||||
return estimatedTime, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// heightFunction 高度函数类型定义,用于牛顿迭代法
|
// heightFunction 高度函数类型定义,用于牛顿迭代法
|
||||||
@@ -324,24 +360,32 @@ func moonRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, timeZon
|
|||||||
|
|
||||||
currentTime := initialTime
|
currentTime := initialTime
|
||||||
|
|
||||||
for {
|
var ok bool
|
||||||
previousTime := currentTime
|
currentTime, ok = eventNewtonRefine(currentTime, tolerance, func(previousTime float64) float64 {
|
||||||
|
|
||||||
// 计算函数值:f(t) = height(t) - targetAngle
|
|
||||||
functionValue := heightFunc(previousTime, longitude, latitude, timeZone) - targetAngle
|
functionValue := heightFunc(previousTime, longitude, latitude, timeZone) - targetAngle
|
||||||
|
|
||||||
// 计算导数:f'(t) ≈ (f(t+h) - f(t-h)) / (2h)
|
|
||||||
derivative := (heightFunc(previousTime+derivativeStep, longitude, latitude, timeZone) -
|
derivative := (heightFunc(previousTime+derivativeStep, longitude, latitude, timeZone) -
|
||||||
heightFunc(previousTime-derivativeStep, longitude, latitude, timeZone)) / (2 * derivativeStep)
|
heightFunc(previousTime-derivativeStep, longitude, latitude, timeZone)) / (2 * derivativeStep)
|
||||||
|
return functionValue / derivative
|
||||||
// 牛顿-拉夫逊公式:t_new = t_old - f(t) / f'(t)
|
})
|
||||||
currentTime = previousTime - functionValue/derivative
|
if !ok {
|
||||||
|
return math.NaN()
|
||||||
// 检查收敛
|
|
||||||
if math.Abs(currentTime-previousTime) <= tolerance {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentTime
|
return currentTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moonRiseSetResidualIteration(initialTime, longitude, latitude, timeZone, zenithShift, height, tolerance float64) (float64, float64) {
|
||||||
|
const derivativeStep = 0.000005
|
||||||
|
|
||||||
|
slope := math.NaN()
|
||||||
|
currentTime, ok := eventNewtonRefine(initialTime, tolerance, func(previousTime float64) float64 {
|
||||||
|
functionValue := moonRiseSetResidual(previousTime, longitude, latitude, timeZone, zenithShift, height, -1)
|
||||||
|
slope = (moonRiseSetResidual(previousTime+derivativeStep, longitude, latitude, timeZone, zenithShift, height, -1) -
|
||||||
|
moonRiseSetResidual(previousTime-derivativeStep, longitude, latitude, timeZone, zenithShift, height, -1)) / (2 * derivativeStep)
|
||||||
|
return functionValue / slope
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return math.NaN(), math.NaN()
|
||||||
|
}
|
||||||
|
return currentTime, slope
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ func SunMoonSeek(jde float64, degree float64) float64 {
|
|||||||
func CalcMoonSHByJDE(jde float64, phaseType int) float64 {
|
func CalcMoonSHByJDE(jde float64, phaseType int) float64 {
|
||||||
phaseType = phaseType * 180
|
phaseType = phaseType * 180
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := SunMoonSeek(prevJD, float64(phaseType))
|
stDegree := SunMoonSeek(prevJD, float64(phaseType))
|
||||||
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(phaseType)) - SunMoonSeek(prevJD-0.000005, float64(phaseType))) / 0.00001
|
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(phaseType)) - SunMoonSeek(prevJD-0.000005, float64(phaseType))) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
@@ -54,14 +54,14 @@ func CalcMoonSH(year float64, phaseType int) float64 {
|
|||||||
jde := CalcMoonS(year, phaseType)
|
jde := CalcMoonS(year, phaseType)
|
||||||
phaseType = phaseType * 180
|
phaseType = phaseType * 180
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := SunMoonSeek(prevJD, float64(phaseType))
|
stDegree := SunMoonSeek(prevJD, float64(phaseType))
|
||||||
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(phaseType)) - SunMoonSeek(prevJD-0.000005, float64(phaseType))) / 0.00001
|
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(phaseType)) - SunMoonSeek(prevJD-0.000005, float64(phaseType))) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
@@ -125,14 +125,14 @@ func CalcMoonXHByJDE(jde float64, quarterType int) float64 {
|
|||||||
quarterType = -90
|
quarterType = -90
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := SunMoonSeek(prevJD, float64(quarterType))
|
stDegree := SunMoonSeek(prevJD, float64(quarterType))
|
||||||
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(quarterType)) - SunMoonSeek(prevJD-0.000005, float64(quarterType))) / 0.00001
|
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(quarterType)) - SunMoonSeek(prevJD-0.000005, float64(quarterType))) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
@@ -145,14 +145,14 @@ func CalcMoonXH(year float64, quarterType int) float64 {
|
|||||||
quarterType = -90
|
quarterType = -90
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := SunMoonSeek(prevJD, float64(quarterType))
|
stDegree := SunMoonSeek(prevJD, float64(quarterType))
|
||||||
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(quarterType)) - SunMoonSeek(prevJD-0.000005, float64(quarterType))) / 0.00001
|
stDegreep := (SunMoonSeek(prevJD+0.000005, float64(quarterType)) - SunMoonSeek(prevJD-0.000005, float64(quarterType))) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type moonRiseSetExternalEvents struct {
|
||||||
|
RiseUTC string `json:"rise_utc"`
|
||||||
|
SetUTC string `json:"set_utc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type moonRiseSetExternalSample struct {
|
||||||
|
Site string `json:"site"`
|
||||||
|
DateUTC string `json:"date_utc"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
ObserverHeight float64 `json:"observer_height_m"`
|
||||||
|
Horizons moonRiseSetExternalEvents `json:"jpl_horizons"`
|
||||||
|
METNorway moonRiseSetExternalEvents `json:"met_norway"`
|
||||||
|
IMCCEMiriade moonRiseSetExternalEvents `json:"imcce_miriade"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type moonRiseSetExternalBaseline struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Sources map[string]struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
} `json:"sources"`
|
||||||
|
Samples []moonRiseSetExternalSample `json:"samples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type moonRiseSetErrorStats struct {
|
||||||
|
Total time.Duration
|
||||||
|
Max time.Duration
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stats *moonRiseSetErrorStats) Add(value time.Duration) {
|
||||||
|
stats.Total += value
|
||||||
|
stats.Count++
|
||||||
|
if value > stats.Max {
|
||||||
|
stats.Max = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stats moonRiseSetErrorStats) Mean() time.Duration {
|
||||||
|
if stats.Count == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return stats.Total / time.Duration(stats.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
type moonRiseSetExternalTolerances struct {
|
||||||
|
Horizons time.Duration
|
||||||
|
METNorway time.Duration
|
||||||
|
IMCCEMiriade time.Duration
|
||||||
|
HorizonsVsMET time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type moonRiseSetComparisonStats struct {
|
||||||
|
CurrentHorizons moonRiseSetErrorStats
|
||||||
|
LegacyHorizons moonRiseSetErrorStats
|
||||||
|
CurrentMET moonRiseSetErrorStats
|
||||||
|
LegacyMET moonRiseSetErrorStats
|
||||||
|
CurrentIMCCE moonRiseSetErrorStats
|
||||||
|
LegacyIMCCE moonRiseSetErrorStats
|
||||||
|
HorizonsVsMET moonRiseSetErrorStats
|
||||||
|
HorizonsVsIMCCE moonRiseSetErrorStats
|
||||||
|
CurrentCloserJPL int
|
||||||
|
LegacyCloserJPL int
|
||||||
|
TiesJPL int
|
||||||
|
CurrentCloserMET int
|
||||||
|
LegacyCloserMET int
|
||||||
|
TiesMET int
|
||||||
|
CurrentCloserIMCCE int
|
||||||
|
LegacyCloserIMCCE int
|
||||||
|
TiesIMCCE int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoonRiseSetMatchesExternalBaselines(t *testing.T) {
|
||||||
|
previousDeltaT := defDeltaTFn
|
||||||
|
SetDeltaTFn(DefaultDeltaTv2)
|
||||||
|
defer SetDeltaTFn(previousDeltaT)
|
||||||
|
|
||||||
|
baseline := loadMoonRiseSetExternalBaseline(t)
|
||||||
|
if baseline.SchemaVersion != 1 {
|
||||||
|
t.Fatalf("unsupported baseline schema version %d", baseline.SchemaVersion)
|
||||||
|
}
|
||||||
|
if baseline.Sources["jpl_horizons"].Model != "DE441" {
|
||||||
|
t.Fatalf("unexpected Horizons model %q", baseline.Sources["jpl_horizons"].Model)
|
||||||
|
}
|
||||||
|
if len(baseline.Samples) < 7 {
|
||||||
|
t.Fatalf("external baseline has only %d samples", len(baseline.Samples))
|
||||||
|
}
|
||||||
|
|
||||||
|
tolerances := moonRiseSetExternalTolerances{
|
||||||
|
Horizons: 2 * time.Second,
|
||||||
|
METNorway: 90 * time.Second,
|
||||||
|
IMCCEMiriade: 8 * time.Minute,
|
||||||
|
HorizonsVsMET: 90 * time.Second,
|
||||||
|
}
|
||||||
|
var stats moonRiseSetComparisonStats
|
||||||
|
|
||||||
|
for _, sample := range baseline.Samples {
|
||||||
|
day, err := time.Parse("2006-01-02", sample.DateUTC)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %s date %q: %v", sample.Site, sample.DateUTC, err)
|
||||||
|
}
|
||||||
|
jd := Date2JDE(day)
|
||||||
|
currentRiseJD, err := GetMoonRiseTime(jd, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s current moonrise: %v", sample.Site, err)
|
||||||
|
}
|
||||||
|
currentSetJD, err := GetMoonSetTime(jd, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s current moonset: %v", sample.Site, err)
|
||||||
|
}
|
||||||
|
legacyRiseJD, err := legacyMoonRiseSetFromCurrent(currentRiseJD, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s legacy moonrise: %v", sample.Site, err)
|
||||||
|
}
|
||||||
|
legacySetJD, err := legacyMoonRiseSetFromCurrent(currentSetJD, sample.Longitude, sample.Latitude, 0, 1, sample.ObserverHeight)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s legacy moonset: %v", sample.Site, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
compareMoonRiseSetEvent(t, sample.Site+".rise", currentRiseJD, legacyRiseJD,
|
||||||
|
sample.Horizons.RiseUTC, sample.METNorway.RiseUTC, sample.IMCCEMiriade.RiseUTC,
|
||||||
|
tolerances, &stats)
|
||||||
|
compareMoonRiseSetEvent(t, sample.Site+".set", currentSetJD, legacySetJD,
|
||||||
|
sample.Horizons.SetUTC, sample.METNorway.SetUTC, sample.IMCCEMiriade.SetUTC,
|
||||||
|
tolerances, &stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("moon rise/set external baseline: current vs JPL mean=%v max=%v; legacy vs JPL mean=%v max=%v",
|
||||||
|
stats.CurrentHorizons.Mean(), stats.CurrentHorizons.Max, stats.LegacyHorizons.Mean(), stats.LegacyHorizons.Max)
|
||||||
|
t.Logf("moon rise/set external baseline: current vs MET mean=%v max=%v; legacy vs MET mean=%v max=%v",
|
||||||
|
stats.CurrentMET.Mean(), stats.CurrentMET.Max, stats.LegacyMET.Mean(), stats.LegacyMET.Max)
|
||||||
|
t.Logf("moon rise/set external baseline: current vs IMCCE mean=%v max=%v; legacy vs IMCCE mean=%v max=%v",
|
||||||
|
stats.CurrentIMCCE.Mean(), stats.CurrentIMCCE.Max, stats.LegacyIMCCE.Mean(), stats.LegacyIMCCE.Max)
|
||||||
|
t.Logf("moon rise/set external baseline: JPL vs MET mean=%v max=%v; JPL vs IMCCE mean=%v max=%v",
|
||||||
|
stats.HorizonsVsMET.Mean(), stats.HorizonsVsMET.Max, stats.HorizonsVsIMCCE.Mean(), stats.HorizonsVsIMCCE.Max)
|
||||||
|
t.Logf("moon rise/set external baseline: JPL current closer=%d legacy closer=%d ties=%d",
|
||||||
|
stats.CurrentCloserJPL, stats.LegacyCloserJPL, stats.TiesJPL)
|
||||||
|
t.Logf("moon rise/set external baseline: MET current closer=%d legacy closer=%d ties=%d",
|
||||||
|
stats.CurrentCloserMET, stats.LegacyCloserMET, stats.TiesMET)
|
||||||
|
t.Logf("moon rise/set external baseline: IMCCE current closer=%d legacy closer=%d ties=%d",
|
||||||
|
stats.CurrentCloserIMCCE, stats.LegacyCloserIMCCE, stats.TiesIMCCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoonRiseSetLegacyComparatorMatchesPreFixSnapshot(t *testing.T) {
|
||||||
|
previousDeltaT := defDeltaTFn
|
||||||
|
SetDeltaTFn(DefaultDeltaTv2)
|
||||||
|
defer SetDeltaTFn(previousDeltaT)
|
||||||
|
|
||||||
|
jd := JDECalc(2023, 1, 15)
|
||||||
|
currentRise, err := GetMoonRiseTime(jd, 116.4074, 39.9042, 8, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("current moonrise: %v", err)
|
||||||
|
}
|
||||||
|
currentSet, err := GetMoonSetTime(jd, 116.4074, 39.9042, 8, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("current moonset: %v", err)
|
||||||
|
}
|
||||||
|
legacyRise, err := legacyMoonRiseSetFromCurrent(currentRise, 116.4074, 39.9042, 8, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legacy moonrise: %v", err)
|
||||||
|
}
|
||||||
|
legacySet, err := legacyMoonRiseSetFromCurrent(currentSet, 116.4074, 39.9042, 8, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legacy moonset: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotTolerance = 2.0 / 86400
|
||||||
|
if difference := math.Abs(legacyRise - 2459959.509182); difference > snapshotTolerance {
|
||||||
|
t.Errorf("legacy moonrise snapshot mismatch: got %.9f want %.9f difference=%.3fs",
|
||||||
|
legacyRise, 2459959.509182, difference*86400)
|
||||||
|
}
|
||||||
|
if difference := math.Abs(legacySet - 2459959.988676); difference > snapshotTolerance {
|
||||||
|
t.Errorf("legacy moonset snapshot mismatch: got %.9f want %.9f difference=%.3fs",
|
||||||
|
legacySet, 2459959.988676, difference*86400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMoonRiseSetExternalBaseline(t *testing.T) moonRiseSetExternalBaseline {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile("testdata/moon_rise_set_baseline.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read moon rise/set baseline: %v", err)
|
||||||
|
}
|
||||||
|
var baseline moonRiseSetExternalBaseline
|
||||||
|
if err := json.Unmarshal(data, &baseline); err != nil {
|
||||||
|
t.Fatalf("decode moon rise/set baseline: %v", err)
|
||||||
|
}
|
||||||
|
return baseline
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareMoonRiseSetEvent(t *testing.T, name string, currentJD, legacyJD float64,
|
||||||
|
horizonsUTC, metUTC, imcceUTC string, tolerances moonRiseSetExternalTolerances,
|
||||||
|
stats *moonRiseSetComparisonStats) {
|
||||||
|
t.Helper()
|
||||||
|
current := JDE2DateByZone(currentJD, time.UTC, false)
|
||||||
|
legacy := JDE2DateByZone(legacyJD, time.UTC, false)
|
||||||
|
horizons := parseMoonRiseSetExternalTime(t, name+".jpl", horizonsUTC)
|
||||||
|
met := parseMoonRiseSetExternalTime(t, name+".met", metUTC)
|
||||||
|
imcce := parseMoonRiseSetExternalTime(t, name+".imcce", imcceUTC)
|
||||||
|
currentHorizonsError := absoluteTimeDifference(current, horizons)
|
||||||
|
legacyHorizonsError := absoluteTimeDifference(legacy, horizons)
|
||||||
|
currentMETError := absoluteTimeDifference(current, met)
|
||||||
|
legacyMETError := absoluteTimeDifference(legacy, met)
|
||||||
|
currentIMCCEError := absoluteTimeDifference(current, imcce)
|
||||||
|
legacyIMCCEError := absoluteTimeDifference(legacy, imcce)
|
||||||
|
stats.CurrentHorizons.Add(currentHorizonsError)
|
||||||
|
stats.LegacyHorizons.Add(legacyHorizonsError)
|
||||||
|
stats.CurrentMET.Add(currentMETError)
|
||||||
|
stats.LegacyMET.Add(legacyMETError)
|
||||||
|
stats.CurrentIMCCE.Add(currentIMCCEError)
|
||||||
|
stats.LegacyIMCCE.Add(legacyIMCCEError)
|
||||||
|
horizonsVsMET := absoluteTimeDifference(horizons, met)
|
||||||
|
stats.HorizonsVsMET.Add(horizonsVsMET)
|
||||||
|
stats.HorizonsVsIMCCE.Add(absoluteTimeDifference(horizons, imcce))
|
||||||
|
|
||||||
|
if currentHorizonsError > tolerances.Horizons {
|
||||||
|
t.Errorf("%s current mismatch against JPL: got %s want %s difference=%v tolerance=%v",
|
||||||
|
name, current.Format(time.RFC3339Nano), horizonsUTC, currentHorizonsError, tolerances.Horizons)
|
||||||
|
}
|
||||||
|
if currentMETError > tolerances.METNorway {
|
||||||
|
t.Errorf("%s current mismatch against MET Norway: got %s want %s difference=%v tolerance=%v",
|
||||||
|
name, current.Format(time.RFC3339Nano), metUTC, currentMETError, tolerances.METNorway)
|
||||||
|
}
|
||||||
|
if currentIMCCEError > tolerances.IMCCEMiriade {
|
||||||
|
t.Errorf("%s current mismatch against IMCCE Miriade: got %s want %s difference=%v tolerance=%v",
|
||||||
|
name, current.Format(time.RFC3339Nano), imcceUTC, currentIMCCEError, tolerances.IMCCEMiriade)
|
||||||
|
}
|
||||||
|
if horizonsVsMET > tolerances.HorizonsVsMET {
|
||||||
|
t.Errorf("%s external sources disagree: JPL=%s MET=%s difference=%v tolerance=%v",
|
||||||
|
name, horizonsUTC, metUTC, horizonsVsMET, tolerances.HorizonsVsMET)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case currentHorizonsError < legacyHorizonsError:
|
||||||
|
stats.CurrentCloserJPL++
|
||||||
|
case legacyHorizonsError < currentHorizonsError:
|
||||||
|
stats.LegacyCloserJPL++
|
||||||
|
default:
|
||||||
|
stats.TiesJPL++
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case currentMETError < legacyMETError:
|
||||||
|
stats.CurrentCloserMET++
|
||||||
|
case legacyMETError < currentMETError:
|
||||||
|
stats.LegacyCloserMET++
|
||||||
|
default:
|
||||||
|
stats.TiesMET++
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case currentIMCCEError < legacyIMCCEError:
|
||||||
|
stats.CurrentCloserIMCCE++
|
||||||
|
case legacyIMCCEError < currentIMCCEError:
|
||||||
|
stats.LegacyCloserIMCCE++
|
||||||
|
default:
|
||||||
|
stats.TiesIMCCE++
|
||||||
|
}
|
||||||
|
t.Logf("%s current_jpl=%v legacy_jpl=%v current_met=%v legacy_met=%v current_imcce=%v legacy_imcce=%v", name,
|
||||||
|
currentHorizonsError, legacyHorizonsError, currentMETError, legacyMETError, currentIMCCEError, legacyIMCCEError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMoonRiseSetExternalTime(t *testing.T, name, value string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %s time %q: %v", name, value, err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func absoluteTimeDifference(left, right time.Time) time.Duration {
|
||||||
|
difference := left.Sub(right)
|
||||||
|
if difference < 0 {
|
||||||
|
return -difference
|
||||||
|
}
|
||||||
|
return difference
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyMoonRiseSetFromCurrent(currentJD, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
||||||
|
localTimeZone := longitude / 15
|
||||||
|
localJD := currentJD + localTimeZone/24 - timeZone/24
|
||||||
|
targetAltitude := StandardAltitudeMoon(zenithShift, height, latitude)
|
||||||
|
legacyJD := moonRiseSetNewtonRaphsonIteration(localJD, longitude, latitude, localTimeZone,
|
||||||
|
targetAltitude, legacyHMoonHeight, 0.00002)
|
||||||
|
if math.IsNaN(legacyJD) || math.IsInf(legacyJD, 0) {
|
||||||
|
return 0, fmt.Errorf("legacy height iteration did not converge")
|
||||||
|
}
|
||||||
|
return legacyJD - localTimeZone/24 + timeZone/24, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyHMoonHeight(jd, longitude, latitude, timeZone float64) float64 {
|
||||||
|
calculationJD := TD2UT(jd-timeZone/24, true)
|
||||||
|
ra, dec := HMoonTrueRaDecN(calculationJD, -1)
|
||||||
|
distanceAU := HMoonAwayN(calculationJD, -1) / 149597870.7
|
||||||
|
topocentricRA, topocentricDec := legacyTopocentricRaDec(ra, dec, latitude, longitude, calculationJD, distanceAU, 0)
|
||||||
|
siderealTime := tools.Limit360(ApparentSiderealTime(jd-timeZone/24)*15 + longitude)
|
||||||
|
hourAngle := tools.Limit360(siderealTime - topocentricRA)
|
||||||
|
altitudeSine := tools.Sin(latitude)*tools.Sin(topocentricDec) +
|
||||||
|
tools.Cos(topocentricDec)*tools.Cos(latitude)*tools.Cos(hourAngle)
|
||||||
|
return tools.ArcSin(altitudeSine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyTopocentricRaDec(ra, dec, latitude, longitude, jd, distanceAU, height float64) (float64, float64) {
|
||||||
|
horizontalParallaxSine := tools.Sin(0.0024427777777) / distanceAU
|
||||||
|
observerCosine := pcosi(latitude, height)
|
||||||
|
observerSine := psini(latitude, height)
|
||||||
|
hourAngle := tools.Limit360(TD2UT(ApparentSiderealTime(jd), false)*15 + longitude - ra)
|
||||||
|
raCorrection := math.Atan2(-observerCosine*horizontalParallaxSine*tools.Sin(hourAngle),
|
||||||
|
tools.Cos(dec)-observerCosine*horizontalParallaxSine*tools.Cos(hourAngle)) * 180 / math.Pi
|
||||||
|
correctedDec := math.Atan2((tools.Sin(dec)-observerSine*horizontalParallaxSine)*tools.Cos(raCorrection),
|
||||||
|
tools.Cos(dec)-observerCosine*horizontalParallaxSine*tools.Cos(hourAngle)) * 180 / math.Pi
|
||||||
|
return ra + raCorrection, correctedDec
|
||||||
|
}
|
||||||
@@ -173,14 +173,14 @@ func NeptuneCulminationTime(jde, lon, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return currentHourAngle
|
return currentHourAngle
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
return hourAngleDelta / hourAngleSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func neptuneRADerivativeN(jde, delta float64, n int) float64 {
|
|||||||
|
|
||||||
func neptuneConjunctionFull(jde, degree float64, next uint8) float64 {
|
func neptuneConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := NEPTUNE_S_PERIOD / 360
|
daysPerDegree := NEPTUNE_S_PERIOD / 360
|
||||||
currentDelta := neptuneSunLongitudeDelta(jde, degree, false)
|
currentDelta := neptuneSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -72,20 +75,29 @@ func neptuneConjunctionFull(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func neptuneConjunction(jde, degree float64, next uint8) float64 {
|
func neptuneConjunction(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := NEPTUNE_S_PERIOD / 360
|
daysPerDegree := NEPTUNE_S_PERIOD / 360
|
||||||
currentDelta := neptuneSunLongitudeDelta(jde, degree, false)
|
currentDelta := neptuneSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -94,24 +106,36 @@ func neptuneConjunction(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := neptuneSunLongitudeDeltaN(prevJD, degree, true, neptuneEventSearchN)
|
longitudeDelta := neptuneSunLongitudeDeltaN(prevJD, degree, true, neptuneEventSearchN)
|
||||||
longitudeSlope := (neptuneSunLongitudeDeltaN(prevJD+0.000005, degree, true, neptuneEventSearchN) - neptuneSunLongitudeDeltaN(prevJD-0.000005, degree, true, neptuneEventSearchN)) / 0.00001
|
longitudeSlope := (neptuneSunLongitudeDeltaN(prevJD+0.000005, degree, true, neptuneEventSearchN) - neptuneSunLongitudeDeltaN(prevJD-0.000005, degree, true, neptuneEventSearchN)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= neptunePhaseCoarseTolerance {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= neptunePhaseCoarseTolerance {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for {
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
converged = false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := neptuneSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (neptuneSunLongitudeDelta(prevJD+0.000005, degree, true) - neptuneSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidOccultationInput 表示月掩输入契约无效。
|
||||||
|
// ErrInvalidOccultationInput reports invalid lunar-occultation contract input.
|
||||||
|
var ErrInvalidOccultationInput = errors.New("invalid lunar occultation input")
|
||||||
|
|
||||||
|
// ErrOccultationPathSamplingLimit 表示请求的时间步长、中心线间距或有限盘面采样超过确定性的工作量或输出预算。
|
||||||
|
// ErrOccultationPathSamplingLimit reports that the requested time step, center-line spacing, or aggregate finite-disk sampling would exceed the implementation's deterministic work or output budget.
|
||||||
|
var ErrOccultationPathSamplingLimit = errors.New("lunar occultation path sampling limit exceeded")
|
||||||
|
|
||||||
|
const (
|
||||||
|
occultationSearchMinimumStep = 250 * time.Millisecond
|
||||||
|
occultationPathMinimumStep = time.Second
|
||||||
|
occultationPathMinimumTargetSpacingKM = 1.0
|
||||||
|
occultationEventSelectionTolerance = 10 * time.Millisecond
|
||||||
|
occultationEventSelectionToleranceDays = float64(occultationEventSelectionTolerance) / float64(24*time.Hour)
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoordinateFrame 标识恒星输入坐标使用的赤道坐标系。
|
||||||
|
// CoordinateFrame identifies the equatorial coordinate frame used by an input stellar coordinate.
|
||||||
|
type CoordinateFrame string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CoordinateFrameICRS 表示 ICRS 星表坐标系。
|
||||||
|
// CoordinateFrameICRS is the ICRS catalog frame.
|
||||||
|
CoordinateFrameICRS CoordinateFrame = "icrs"
|
||||||
|
// CoordinateFrameJ2000 表示 J2000 平均赤道坐标系。
|
||||||
|
// CoordinateFrameJ2000 is the mean equatorial J2000 frame.
|
||||||
|
CoordinateFrameJ2000 CoordinateFrame = "j2000"
|
||||||
|
// CoordinateFrameApparentOfDate 表示历元时刻的视赤道坐标系。
|
||||||
|
// CoordinateFrameApparentOfDate is the apparent equatorial frame of date.
|
||||||
|
CoordinateFrameApparentOfDate CoordinateFrame = "apparent_of_date"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OccultationType 标识月掩结果的几何类型。
|
||||||
|
// OccultationType identifies the result geometry.
|
||||||
|
type OccultationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OccultationTotal 表示掩甚时目标盘面被完全覆盖。
|
||||||
|
// OccultationTotal means the target disk is fully covered at greatest occultation.
|
||||||
|
OccultationTotal OccultationType = "total"
|
||||||
|
// OccultationPartial 表示掩甚时有限目标盘面只有部分被覆盖。
|
||||||
|
// OccultationPartial means only part of a finite target disk is covered at greatest occultation.
|
||||||
|
OccultationPartial OccultationType = "partial"
|
||||||
|
// OccultationGrazing 表示两边缘相切,且没有正持续时间的重叠。
|
||||||
|
// OccultationGrazing means the limbs are tangent without a positive-duration overlap.
|
||||||
|
OccultationGrazing OccultationType = "grazing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OccultationPlanet 标识有限盘面的行星目标。
|
||||||
|
// OccultationPlanet identifies a finite-disk planetary target.
|
||||||
|
type OccultationPlanet string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OccultationMercury 表示水星有限盘面目标。
|
||||||
|
// OccultationMercury identifies Mercury as the finite-disk target.
|
||||||
|
OccultationMercury OccultationPlanet = "mercury"
|
||||||
|
// OccultationVenus 表示金星有限盘面目标。
|
||||||
|
// OccultationVenus identifies Venus as the finite-disk target.
|
||||||
|
OccultationVenus OccultationPlanet = "venus"
|
||||||
|
// OccultationMars 表示火星有限盘面目标。
|
||||||
|
// OccultationMars identifies Mars as the finite-disk target.
|
||||||
|
OccultationMars OccultationPlanet = "mars"
|
||||||
|
// OccultationJupiter 表示木星有限盘面目标。
|
||||||
|
// OccultationJupiter identifies Jupiter as the finite-disk target.
|
||||||
|
OccultationJupiter OccultationPlanet = "jupiter"
|
||||||
|
// OccultationSaturn 表示土星有限盘面目标。
|
||||||
|
// OccultationSaturn identifies Saturn as the finite-disk target.
|
||||||
|
OccultationSaturn OccultationPlanet = "saturn"
|
||||||
|
// OccultationUranus 表示天王星有限盘面目标。
|
||||||
|
// OccultationUranus identifies Uranus as the finite-disk target.
|
||||||
|
OccultationUranus OccultationPlanet = "uranus"
|
||||||
|
// OccultationNeptune 表示海王星有限盘面目标。
|
||||||
|
// OccultationNeptune identifies Neptune as the finite-disk target.
|
||||||
|
OccultationNeptune OccultationPlanet = "neptune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String 返回结果标识中使用的英文目标名称。
|
||||||
|
// String returns the English target name used in result identifiers.
|
||||||
|
func (p OccultationPlanet) String() string {
|
||||||
|
switch p {
|
||||||
|
case OccultationMercury:
|
||||||
|
return "Mercury"
|
||||||
|
case OccultationVenus:
|
||||||
|
return "Venus"
|
||||||
|
case OccultationMars:
|
||||||
|
return "Mars"
|
||||||
|
case OccultationJupiter:
|
||||||
|
return "Jupiter"
|
||||||
|
case OccultationSaturn:
|
||||||
|
return "Saturn"
|
||||||
|
case OccultationUranus:
|
||||||
|
return "Uranus"
|
||||||
|
case OccultationNeptune:
|
||||||
|
return "Neptune"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 检查行星目标是否受支持。
|
||||||
|
// Validate checks whether the planetary target is supported.
|
||||||
|
func (p OccultationPlanet) Validate() error {
|
||||||
|
if p.String() == "" {
|
||||||
|
return fmt.Errorf("%w: unsupported occultation planet %q", ErrInvalidOccultationInput, p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOccultationTimeRange(start, end time.Time) error {
|
||||||
|
if start.IsZero() || end.IsZero() {
|
||||||
|
return fmt.Errorf("%w: start and end are required", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if !end.After(start) {
|
||||||
|
return fmt.Errorf("%w: end must be after start", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationTimeInSelectionWindow(value, start, end time.Time) bool {
|
||||||
|
return value.Sub(start) >= -occultationEventSelectionTolerance &&
|
||||||
|
value.Sub(end) <= occultationEventSelectionTolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observer 描述站心观测地点。
|
||||||
|
// Observer describes the topocentric observing site.
|
||||||
|
type Observer struct {
|
||||||
|
// Longitude 是经度,东经为正,单位为度。
|
||||||
|
// Longitude is east-positive, in degrees.
|
||||||
|
Longitude float64
|
||||||
|
// Latitude 是纬度,北纬为正,单位为度。
|
||||||
|
// Latitude is north-positive, in degrees.
|
||||||
|
Latitude float64
|
||||||
|
// Height 是观测者相对平均海平面的高度,单位为米。
|
||||||
|
// Height is the observer elevation above mean sea level, in meters.
|
||||||
|
Height float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// moonTopocentricSemidiameterN 返回指定地点看到的月球角半径。
|
||||||
|
// 通用 MoonSemidiameterN 使用地心距离;月掩接触使用同一站心视差修正后的观测者到月球距离。
|
||||||
|
// moonTopocentricSemidiameterN returns the lunar angular radius as seen from the supplied site.
|
||||||
|
// The usual MoonSemidiameterN uses geocentric distance; occultation contacts use the observer-to-Moon distance after the same topocentric parallax correction as the direction.
|
||||||
|
func moonTopocentricSemidiameterN(tt float64, observer Observer, n int) float64 {
|
||||||
|
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, n)
|
||||||
|
moonDistanceKM := HMoonAwayN(tt, n)
|
||||||
|
if !finite(moonRA) || !finite(moonDec) || !finite(moonDistanceKM) || moonDistanceKM <= 0 {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
distanceKM := topocentricDistanceKM(moonRA, moonDec, moonDistanceKM, observer, TD2UT(tt, false))
|
||||||
|
if !finite(distanceKM) || distanceKM <= 0 {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
func topocentricDistanceKM(ra, dec, distanceKM float64, observer Observer, ut float64) float64 {
|
||||||
|
const earthEquatorialRadius = 6378.14
|
||||||
|
const astronomicalUnitKM = angularDiameterAstronomicalUnitKM
|
||||||
|
|
||||||
|
distanceAU := distanceKM / astronomicalUnitKM
|
||||||
|
if distanceAU <= 0 {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
raRad := ra * math.Pi / 180
|
||||||
|
decRad := dec * math.Pi / 180
|
||||||
|
moon := [3]float64{
|
||||||
|
distanceAU * math.Cos(decRad) * math.Cos(raRad),
|
||||||
|
distanceAU * math.Cos(decRad) * math.Sin(raRad),
|
||||||
|
distanceAU * math.Sin(decRad),
|
||||||
|
}
|
||||||
|
theta := (ApparentSiderealTime(ut)*15 + observer.Longitude) * math.Pi / 180
|
||||||
|
observerAU := earthEquatorialRadius / astronomicalUnitKM
|
||||||
|
observerVector := [3]float64{
|
||||||
|
observerAU * pcosi(observer.Latitude, observer.Height) * math.Cos(theta),
|
||||||
|
observerAU * pcosi(observer.Latitude, observer.Height) * math.Sin(theta),
|
||||||
|
observerAU * psini(observer.Latitude, observer.Height),
|
||||||
|
}
|
||||||
|
dx := moon[0] - observerVector[0]
|
||||||
|
dy := moon[1] - observerVector[1]
|
||||||
|
dz := moon[2] - observerVector[2]
|
||||||
|
return math.Sqrt(dx*dx+dy*dy+dz*dz) * astronomicalUnitKM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 检查站心计算所需的地理范围。
|
||||||
|
// Validate checks the geographic bounds needed by topocentric calculations.
|
||||||
|
func (o Observer) Validate() error {
|
||||||
|
if !finite(o.Longitude) || !finite(o.Latitude) || !finite(o.Height) {
|
||||||
|
return fmt.Errorf("%w: observer values must be finite", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if o.Longitude < -180 || o.Longitude > 180 {
|
||||||
|
return fmt.Errorf("%w: observer longitude must be in [-180, 180]", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if o.Latitude < -90 || o.Latitude > 90 {
|
||||||
|
return fmt.Errorf("%w: observer latitude must be in [-90, 90]", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarCoordinate 是调用者为恒星提供的星表坐标或视位置坐标。
|
||||||
|
// RA 和 Dec 的单位为度;ProperMotionRACosDecMasPerYear 使用星表常见的 dRA*cos(Dec) 约定,单位为毫角秒/年。
|
||||||
|
// StarCoordinate is a catalog or apparent coordinate supplied for a star.
|
||||||
|
// RA and Dec are degrees; ProperMotionRACosDecMasPerYear uses the usual catalog convention of dRA*cos(Dec), in milliarcseconds per year.
|
||||||
|
type StarCoordinate struct {
|
||||||
|
ID string
|
||||||
|
|
||||||
|
RA float64
|
||||||
|
Dec float64
|
||||||
|
Epoch time.Time
|
||||||
|
Frame CoordinateFrame
|
||||||
|
|
||||||
|
ProperMotionRACosDecMasPerYear float64
|
||||||
|
ProperMotionDecMasPerYear float64
|
||||||
|
ParallaxMas float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 在构造目标前检查恒星坐标契约。
|
||||||
|
// Validate checks the coordinate contract before a target is constructed.
|
||||||
|
func (s StarCoordinate) Validate() error {
|
||||||
|
if !finite(s.RA) || s.RA < 0 || s.RA >= 360 {
|
||||||
|
return fmt.Errorf("%w: star RA must be in [0, 360)", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if !finite(s.Dec) || s.Dec < -90 || s.Dec > 90 {
|
||||||
|
return fmt.Errorf("%w: star Dec must be in [-90, 90]", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if s.Epoch.IsZero() {
|
||||||
|
return fmt.Errorf("%w: star epoch is required", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if !validCoordinateFrame(s.Frame) {
|
||||||
|
return fmt.Errorf("%w: unsupported star coordinate frame %q", ErrInvalidOccultationInput, s.Frame)
|
||||||
|
}
|
||||||
|
if !finite(s.ProperMotionRACosDecMasPerYear) || !finite(s.ProperMotionDecMasPerYear) {
|
||||||
|
return fmt.Errorf("%w: star proper motion must be finite", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if !finite(s.ParallaxMas) || s.ParallaxMas < 0 {
|
||||||
|
return fmt.Errorf("%w: star parallax must be finite and non-negative", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarCoordinateFromStarData 将一条内嵌星表记录转换为月掩搜索使用的 J2000 坐标契约。
|
||||||
|
// 星表自行从角秒/年转换为毫角秒/年;正的秒差距距离转换为毫角秒年视差。本函数只转换传入值,不会加载星表。
|
||||||
|
// StarCoordinateFromStarData converts one embedded-catalog entry into the J2000 coordinate contract used by lunar-occultation searches.
|
||||||
|
// The catalog's proper motions are converted from arcseconds/year to milliarcseconds/year; a positive parsec distance is converted to annual parallax in milliarcseconds.
|
||||||
|
// This function only converts the supplied value and never loads the catalog.
|
||||||
|
func StarCoordinateFromStarData(star StarData) (StarCoordinate, error) {
|
||||||
|
if star.HR == 0 {
|
||||||
|
return StarCoordinate{}, fmt.Errorf("%w: star catalog HR number is required", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if !finite(star.Pc) || star.Pc < 0 {
|
||||||
|
return StarCoordinate{}, fmt.Errorf("%w: star distance must be finite and non-negative", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
parallaxMas := 0.0
|
||||||
|
if star.Pc > 0 {
|
||||||
|
parallaxMas = 1000 / star.Pc
|
||||||
|
}
|
||||||
|
coordinate := StarCoordinate{
|
||||||
|
ID: starCoordinateIDFromStarData(star),
|
||||||
|
RA: star.Ra,
|
||||||
|
Dec: star.Dec,
|
||||||
|
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||||
|
Frame: CoordinateFrameJ2000,
|
||||||
|
ProperMotionRACosDecMasPerYear: star.PmRA * 1000,
|
||||||
|
ProperMotionDecMasPerYear: star.PmDec * 1000,
|
||||||
|
ParallaxMas: parallaxMas,
|
||||||
|
}
|
||||||
|
if err := coordinate.Validate(); err != nil {
|
||||||
|
return StarCoordinate{}, fmt.Errorf("convert star catalog coordinate: %w", err)
|
||||||
|
}
|
||||||
|
return coordinate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func starCoordinateIDFromStarData(star StarData) string {
|
||||||
|
for _, name := range []string{star.ChineseName, star.ChineseAlias, star.CommonName, star.Name} {
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if star.HR > 0 {
|
||||||
|
return fmt.Sprintf("HR %d", star.HR)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// OccultationSearchOptions 控制固定目标和行星月掩搜索。
|
||||||
|
// 零值使用实现默认值;MaxEvents == 0 表示不限制数量。
|
||||||
|
// OccultationSearchOptions controls fixed-target and planetary occultation searches.
|
||||||
|
// Zero values select implementation defaults; MaxEvents == 0 means unlimited.
|
||||||
|
type OccultationSearchOptions struct {
|
||||||
|
// MaxStep 是粗略搜索的最大步长;小于 250ms 的正值会被拒绝,因为在支持的时间范围内无法可靠地用儒略日浮点数推进。
|
||||||
|
// MaxStep is the maximum coarse-search step. Positive values below 250 ms are rejected because they cannot be advanced reliably in Julian-day floating-point arithmetic over the supported time span.
|
||||||
|
MaxStep time.Duration
|
||||||
|
// SafetyMarginArcsec 是加入粗略候选和黄纬预筛的安全余量,单位为角秒。
|
||||||
|
// SafetyMarginArcsec is added to coarse candidate and latitude prefilters.
|
||||||
|
SafetyMarginArcsec float64
|
||||||
|
// MaxEvents 为正时限制返回事件数量。
|
||||||
|
// MaxEvents limits the number of returned events when positive.
|
||||||
|
MaxEvents int
|
||||||
|
}
|
||||||
|
|
||||||
|
// OccultationPathOptions 控制全球月掩路径采样。
|
||||||
|
//
|
||||||
|
// Step 为路径采样的基础时间步长,正值至少为 1 秒。TargetSpacingKM 要求相邻中心线点超过目标地面距离时进行自适应加密。
|
||||||
|
// 正的 TargetSpacingKM 至少为 1 km;超过中心线或有限盘面路径工作量预算时返回 ErrOccultationPathSamplingLimit,不会静默降低请求分辨率。行星瞬时足迹使用结果中说明的独立有界采样策略。
|
||||||
|
// 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.
|
||||||
|
type OccultationPathOptions struct {
|
||||||
|
Step time.Duration
|
||||||
|
TargetSpacingKM float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 检查全球路径采样选项。
|
||||||
|
// Validate checks global path sampling options.
|
||||||
|
func (o OccultationPathOptions) Validate() error {
|
||||||
|
if o.Step < 0 {
|
||||||
|
return fmt.Errorf("%w: path step cannot be negative", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if o.Step > 0 && o.Step < occultationPathMinimumStep {
|
||||||
|
return fmt.Errorf("%w: path step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep)
|
||||||
|
}
|
||||||
|
if !finite(o.TargetSpacingKM) || o.TargetSpacingKM < 0 {
|
||||||
|
return fmt.Errorf("%w: path target spacing must be finite and non-negative", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if o.TargetSpacingKM > 0 && o.TargetSpacingKM < occultationPathMinimumTargetSpacingKM {
|
||||||
|
return fmt.Errorf("%w: path target spacing must be zero or at least %.0f km", ErrInvalidOccultationInput, occultationPathMinimumTargetSpacingKM)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 检查选项值,但不选择算法专用默认值。
|
||||||
|
// Validate checks option values without selecting algorithm-specific defaults.
|
||||||
|
func (o OccultationSearchOptions) Validate() error {
|
||||||
|
if o.MaxStep < 0 {
|
||||||
|
return fmt.Errorf("%w: search max step cannot be negative", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if o.MaxStep > 0 && o.MaxStep < occultationSearchMinimumStep {
|
||||||
|
return fmt.Errorf("%w: search max step must be zero or at least %s", ErrInvalidOccultationInput, occultationSearchMinimumStep)
|
||||||
|
}
|
||||||
|
if !finite(o.SafetyMarginArcsec) || o.SafetyMarginArcsec < 0 {
|
||||||
|
return fmt.Errorf("%w: search safety margin must be finite and non-negative", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
if o.MaxEvents < 0 {
|
||||||
|
return fmt.Errorf("%w: search max events cannot be negative", ErrInvalidOccultationInput)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarOccultationInfo 描述点光源恒星月掩;掩始和掩终是月缘交点。
|
||||||
|
// StarOccultationInfo describes a point-source stellar occultation. The immersion and emersion times are the Moon-limb crossings.
|
||||||
|
type StarOccultationInfo struct {
|
||||||
|
TargetID string
|
||||||
|
Observer Observer
|
||||||
|
Type OccultationType
|
||||||
|
|
||||||
|
Immersion time.Time
|
||||||
|
Greatest time.Time
|
||||||
|
Emersion time.Time
|
||||||
|
// ContactsComplete 表示两个月缘接触时刻均已求解。
|
||||||
|
// ContactsComplete is true when both lunar-limb contacts were solved.
|
||||||
|
ContactsComplete bool
|
||||||
|
|
||||||
|
MinimumSeparationArcsec float64
|
||||||
|
PositionAngleDeg float64
|
||||||
|
MoonSemidiameterArcsec float64
|
||||||
|
|
||||||
|
MoonAltitudeAtGreatest float64
|
||||||
|
MoonAzimuthAtGreatest float64
|
||||||
|
VisibleAtGreatest bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanetOccultationInfo 描述有限盘面行星月掩。
|
||||||
|
// ExternalImmersion 和 ExternalEmersion 分别是 C1 和 C4;全掩事件的 InternalImmersion 和 InternalEmersion 分别是 C2 和 C3,偏掩和掠掩时为零。
|
||||||
|
// ContactsComplete 表示报告几何适用的所有接触均已求解;目标按赤道半径建模为圆盘,环、大气延伸和扁率不在模型内。
|
||||||
|
// PlanetOccultationInfo describes a finite-disk planetary occultation.
|
||||||
|
// ExternalImmersion and ExternalEmersion are C1 and C4. For a total event, InternalImmersion and InternalEmersion are C2 and C3; they are zero for partial and grazing events.
|
||||||
|
// ContactsComplete means every contact applicable to the reported geometry was solved. The target is modeled as a circular disk using its equatorial body radius; rings, atmospheric extensions, and oblateness are outside this contact model.
|
||||||
|
type PlanetOccultationInfo struct {
|
||||||
|
Planet OccultationPlanet
|
||||||
|
TargetID string
|
||||||
|
Observer Observer
|
||||||
|
Type OccultationType
|
||||||
|
|
||||||
|
ExternalImmersion time.Time
|
||||||
|
InternalImmersion time.Time
|
||||||
|
Greatest time.Time
|
||||||
|
InternalEmersion time.Time
|
||||||
|
ExternalEmersion time.Time
|
||||||
|
|
||||||
|
HasInternalContacts bool
|
||||||
|
ContactsComplete bool
|
||||||
|
|
||||||
|
MinimumSeparationArcsec float64
|
||||||
|
PositionAngleDeg float64
|
||||||
|
MoonSemidiameterArcsec float64
|
||||||
|
PlanetSemidiameterArcsec float64
|
||||||
|
|
||||||
|
MoonAltitudeAtGreatest float64
|
||||||
|
MoonAzimuthAtGreatest float64
|
||||||
|
VisibleAtGreatest bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// OccultationPathPoint 是全球月掩路径上的一个地理采样点。
|
||||||
|
// Start 和 End 描述月缘外接触掩带;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.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarOccultationPath 包含点光源恒星月掩的全球掩带。
|
||||||
|
// 中心线是月心与恒星对齐的轨迹;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.
|
||||||
|
type StarOccultationPath struct {
|
||||||
|
TargetID string
|
||||||
|
|
||||||
|
Start OccultationPathPoint
|
||||||
|
Greatest OccultationPathPoint
|
||||||
|
End OccultationPathPoint
|
||||||
|
// Complete 表示 Start 和 End 是全球月缘外接触点,而不是查询窗口裁剪点。
|
||||||
|
// Complete is true when Start and End are the global outer-limb contacts rather than query-window clipping points.
|
||||||
|
Complete bool
|
||||||
|
|
||||||
|
CenterLine []OccultationPathPoint
|
||||||
|
NorthernLimit []OccultationPathPoint
|
||||||
|
SouthernLimit []OccultationPathPoint
|
||||||
|
|
||||||
|
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
|
||||||
|
Polygons [][]OccultationPathPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanetOccultationPath 包含有限盘面行星月掩的全球掩带。
|
||||||
|
// NorthernLimit 和 SouthernLimit 是行星盘面任意部分被覆盖的外接触边界。
|
||||||
|
// HasTotalBand 为 true 时,NorthernTotalLimit 和 SouthernTotalLimit 是行星圆盘完全被月球覆盖的内接触边界;
|
||||||
|
// 环、大气延伸和扁率不在两种接触模型内。
|
||||||
|
// PlanetOccultationPath contains the global footprint of a finite-disk planetary occultation.
|
||||||
|
// NorthernLimit and SouthernLimit are the outer-contact boundaries where any part of the planet disk is covered.
|
||||||
|
// When HasTotalBand is true, NorthernTotalLimit and SouthernTotalLimit are the inner-contact boundaries where the complete circular planet disk is covered by the Moon. Rings, atmospheric extensions, and oblateness are outside both contact models.
|
||||||
|
type PlanetOccultationPath struct {
|
||||||
|
Planet OccultationPlanet
|
||||||
|
TargetID string
|
||||||
|
|
||||||
|
Start OccultationPathPoint
|
||||||
|
Greatest OccultationPathPoint
|
||||||
|
End OccultationPathPoint
|
||||||
|
// Complete 表示 Start 和 End 是全球外接触点。
|
||||||
|
// Complete is true when Start and End are the global outer contacts.
|
||||||
|
Complete bool
|
||||||
|
|
||||||
|
CenterLine []OccultationPathPoint
|
||||||
|
NorthernLimit []OccultationPathPoint
|
||||||
|
SouthernLimit []OccultationPathPoint
|
||||||
|
// PartialFootprints 是时刻采样的可见外接触区域,其扫掠构成全球偏掩区域;为限制输出和运行时间,采样可能比 Step 更粗,
|
||||||
|
// 每个足迹携带实际采样时刻。
|
||||||
|
// 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 []PlanetOccultationFootprint
|
||||||
|
|
||||||
|
HasTotalBand bool
|
||||||
|
// TotalStart 和 TotalEnd 是全球内接触的起止点。
|
||||||
|
// TotalStart and TotalEnd are the first and last global inner contacts.
|
||||||
|
TotalStart OccultationPathPoint
|
||||||
|
TotalEnd OccultationPathPoint
|
||||||
|
// TotalComplete 表示 TotalStart 和 TotalEnd 未被内部搜索范围截断。
|
||||||
|
// TotalComplete is true when TotalStart and TotalEnd are not clipped by the internal search span.
|
||||||
|
TotalComplete bool
|
||||||
|
|
||||||
|
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.
|
||||||
|
TotalFootprints []PlanetOccultationFootprint
|
||||||
|
// GreatestTotalWidthKM 是全球掩甚时的全掩带宽度。
|
||||||
|
// GreatestTotalWidthKM is the full-coverage band width at global greatest.
|
||||||
|
GreatestTotalWidthKM float64
|
||||||
|
|
||||||
|
Step time.Duration
|
||||||
|
TargetSpacingKM float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func finite(value float64) bool {
|
||||||
|
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCoordinateFrame(frame CoordinateFrame) bool {
|
||||||
|
switch frame {
|
||||||
|
case CoordinateFrameICRS, CoordinateFrameJ2000, CoordinateFrameApparentOfDate:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
planetOccultationDefaultStepDays = 0.25
|
||||||
|
planetOccultationCandidateLimitArcsec = 10 * 3600.0
|
||||||
|
planetOccultationLatitudeMarginArcsec = 3600.0
|
||||||
|
planetOccultationContactStepDays = 10.0 / 1440.0
|
||||||
|
planetOccultationContactSpanDays = 2.0
|
||||||
|
planetOccultationGrazingToleranceArcsec = 0.01
|
||||||
|
planetOccultationRootToleranceDays = occultationEventSelectionToleranceDays
|
||||||
|
planetOccultationMaxContactSteps = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
type planetOccultationConfig struct {
|
||||||
|
planet OccultationPlanet
|
||||||
|
equatorialRadiusKM float64
|
||||||
|
apparentRaDecN func(float64, int) (float64, float64)
|
||||||
|
earthDistanceN func(float64, int) float64
|
||||||
|
semidiameterN func(float64, int) float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type planetMoonPosition struct {
|
||||||
|
moonRA, moonDec float64
|
||||||
|
planetRA, planetDec float64
|
||||||
|
valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type planetOccultationState struct {
|
||||||
|
position planetMoonPosition
|
||||||
|
separationArcsec float64
|
||||||
|
moonSemidiameter float64
|
||||||
|
planetSemidiameter float64
|
||||||
|
externalContactMetric float64
|
||||||
|
internalContactMetric float64
|
||||||
|
valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPlanetOccultations 搜索固定观测点的有限盘面行星月掩。
|
||||||
|
// 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。目标位置、视差和视半径会在每次候选、掩甚和接触计算时重新计算。
|
||||||
|
// FindPlanetOccultations searches one finite-disk planet at a fixed observing site.
|
||||||
|
// Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is the observer elevation above mean sea level in meters. The target position, parallax, and semidiameter are recomputed at every candidate, greatest, and contact evaluation.
|
||||||
|
func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
|
||||||
|
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
|
||||||
|
if err := validateOccultationTimeRange(start, end); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := planet.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
observer := Observer{Longitude: longitude, Latitude: latitude, Height: height}
|
||||||
|
if err := observer.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := options.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
config, _ := planetOccultationConfigFor(planet)
|
||||||
|
startTT := occultationTimeToTT(start)
|
||||||
|
endTT := occultationTimeToTT(end)
|
||||||
|
resultLocation := start.Location()
|
||||||
|
candidates := planetOccultationCandidateGreatestTimes(
|
||||||
|
startTT, endTT, planetOccultationCoarseStepDays(options), config, &observer, options.SafetyMarginArcsec,
|
||||||
|
)
|
||||||
|
results := make([]PlanetOccultationInfo, 0, len(candidates))
|
||||||
|
for _, greatestTT := range candidates {
|
||||||
|
info, ok := planetOccultationInfoAtGreatest(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 {
|
||||||
|
results = append(results, info)
|
||||||
|
if options.MaxEvents > 0 && len(results) >= options.MaxEvents {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) })
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindBestPlanetOccultations 返回窗口内每次有限盘面行星月掩的全球海平面几何掩甚点。
|
||||||
|
// 地心数据只用于搜索初值;返回位置与 FindPlanetOccultationPaths 一致,地平线可见性只报告、不参与点选择。距离查询端点 10 ms 内的掩甚时刻也会包含,与数值根精度一致。
|
||||||
|
// FindBestPlanetOccultations returns the global geometric greatest point at sea level for every finite-disk planetary occultation in the window.
|
||||||
|
// Geocentric data only seeds the search. The returned location matches FindPlanetOccultationPaths; horizon visibility is reported but does not select the point. A greatest instant within 10 ms of either query endpoint is included, matching the numerical root precision.
|
||||||
|
func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
|
||||||
|
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
|
||||||
|
if err := validateOccultationTimeRange(start, end); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := planet.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := options.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
config, _ := planetOccultationConfigFor(planet)
|
||||||
|
startTT := occultationTimeToTT(start)
|
||||||
|
endTT := occultationTimeToTT(end)
|
||||||
|
selectionStartTT := startTT - occultationEventSelectionToleranceDays
|
||||||
|
selectionEndTT := endTT + occultationEventSelectionToleranceDays
|
||||||
|
candidateStartTT := startTT - occultationPathSearchSpanDays
|
||||||
|
candidateEndTT := endTT + occultationPathSearchSpanDays
|
||||||
|
resultLocation := start.Location()
|
||||||
|
candidates := planetOccultationCandidateGreatestTimes(
|
||||||
|
candidateStartTT, candidateEndTT, planetOccultationCoarseStepDays(options), config, nil, options.SafetyMarginArcsec,
|
||||||
|
)
|
||||||
|
results := make([]PlanetOccultationInfo, 0, len(candidates))
|
||||||
|
for _, seedTT := range candidates {
|
||||||
|
greatestTT, observer, _, observerOK := planetOccultationBestObserver(seedTT, selectionStartTT, selectionEndTT, config)
|
||||||
|
if !observerOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, ok := planetOccultationInfoAtGreatest(greatestTT, config, observer, options.SafetyMarginArcsec, resultLocation)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 {
|
||||||
|
results = append(results, info)
|
||||||
|
if options.MaxEvents > 0 && len(results) >= options.MaxEvents {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) })
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationConfigFor(planet OccultationPlanet) (planetOccultationConfig, bool) {
|
||||||
|
config := planetOccultationConfig{planet: planet}
|
||||||
|
switch planet {
|
||||||
|
case OccultationMercury:
|
||||||
|
config.equatorialRadiusKM = mercuryEquatorialRadiusKM
|
||||||
|
config.apparentRaDecN = MercuryApparentRaDecN
|
||||||
|
config.earthDistanceN = EarthMercuryAwayN
|
||||||
|
config.semidiameterN = MercurySemidiameterN
|
||||||
|
case OccultationVenus:
|
||||||
|
config.equatorialRadiusKM = venusEquatorialRadiusKM
|
||||||
|
config.apparentRaDecN = VenusApparentRaDecN
|
||||||
|
config.earthDistanceN = EarthVenusAwayN
|
||||||
|
config.semidiameterN = VenusSemidiameterN
|
||||||
|
case OccultationMars:
|
||||||
|
config.equatorialRadiusKM = marsEquatorialRadiusKM
|
||||||
|
config.apparentRaDecN = MarsApparentRaDecN
|
||||||
|
config.earthDistanceN = EarthMarsAwayN
|
||||||
|
config.semidiameterN = MarsSemidiameterN
|
||||||
|
case OccultationJupiter:
|
||||||
|
config.equatorialRadiusKM = jupiterEquatorialRadiusKM
|
||||||
|
config.apparentRaDecN = JupiterApparentRaDecN
|
||||||
|
config.earthDistanceN = EarthJupiterAwayN
|
||||||
|
config.semidiameterN = JupiterSemidiameterN
|
||||||
|
case OccultationSaturn:
|
||||||
|
config.equatorialRadiusKM = saturnEquatorialRadiusKM
|
||||||
|
config.apparentRaDecN = SaturnApparentRaDecN
|
||||||
|
config.earthDistanceN = EarthSaturnAwayN
|
||||||
|
config.semidiameterN = SaturnSemidiameterN
|
||||||
|
case OccultationUranus:
|
||||||
|
config.equatorialRadiusKM = uranusEquatorialRadiusKM
|
||||||
|
config.apparentRaDecN = UranusApparentRaDecN
|
||||||
|
config.earthDistanceN = EarthUranusAwayN
|
||||||
|
config.semidiameterN = UranusSemidiameterN
|
||||||
|
case OccultationNeptune:
|
||||||
|
config.equatorialRadiusKM = neptuneEquatorialRadiusKM
|
||||||
|
config.apparentRaDecN = NeptuneApparentRaDecN
|
||||||
|
config.earthDistanceN = EarthNeptuneAwayN
|
||||||
|
config.semidiameterN = NeptuneSemidiameterN
|
||||||
|
default:
|
||||||
|
return planetOccultationConfig{}, false
|
||||||
|
}
|
||||||
|
return config, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationCandidateGreatestTimes(
|
||||||
|
startTT, endTT, step float64,
|
||||||
|
config planetOccultationConfig,
|
||||||
|
observer *Observer,
|
||||||
|
safetyMarginArcsec float64,
|
||||||
|
) []float64 {
|
||||||
|
if endTT <= startTT {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
duration := endTT - startTT
|
||||||
|
if step > duration/4 {
|
||||||
|
step = math.Max(duration/4, 0.25/86400.0)
|
||||||
|
}
|
||||||
|
step = math.Max(step, 0.25/86400.0)
|
||||||
|
scanStart := startTT - step
|
||||||
|
scanEnd := endTT + step
|
||||||
|
|
||||||
|
leftTT := scanStart
|
||||||
|
centerTT := math.Min(leftTT+step, scanEnd)
|
||||||
|
leftValue := planetOccultationExternalContactMetric(leftTT, config, observer, 8)
|
||||||
|
centerValue := planetOccultationExternalContactMetric(centerTT, config, observer, 8)
|
||||||
|
results := make([]float64, 0)
|
||||||
|
for centerTT < scanEnd {
|
||||||
|
rightTT := math.Min(centerTT+step, scanEnd)
|
||||||
|
rightValue := planetOccultationExternalContactMetric(rightTT, config, observer, 8)
|
||||||
|
candidateLimit := planetOccultationCandidateLimitArcsec + safetyMarginArcsec
|
||||||
|
if finite(leftValue) && finite(centerValue) && finite(rightValue) &&
|
||||||
|
centerValue <= leftValue && centerValue <= rightValue && centerValue <= candidateLimit {
|
||||||
|
// 最小外接触度量同时包含动态月面和行星盘面,因此定义事件是否存在以及报告的掩甚时刻。
|
||||||
|
// The minimum outer-contact metric includes both dynamic disks and therefore defines event existence and the reported greatest instant.
|
||||||
|
greatestTT := planetOccultationMinimizeExternalMetric(leftTT, rightTT, config, observer)
|
||||||
|
if greatestTT >= startTT && greatestTT <= endTT &&
|
||||||
|
planetOccultationLatitudePass(greatestTT, config, observer, safetyMarginArcsec) {
|
||||||
|
if len(results) == 0 || math.Abs(greatestTT-results[len(results)-1]) > 60.0/86400.0 {
|
||||||
|
results = append(results, greatestTT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leftTT, leftValue = centerTT, centerValue
|
||||||
|
centerTT, centerValue = rightTT, rightValue
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationMinimizeExternalMetric(left, right float64, config planetOccultationConfig, observer *Observer) float64 {
|
||||||
|
if right <= left {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
const goldenRatio = 0.6180339887498949
|
||||||
|
x1 := right - goldenRatio*(right-left)
|
||||||
|
x2 := left + goldenRatio*(right-left)
|
||||||
|
f1 := planetOccultationExternalContactMetric(x1, config, observer, -1)
|
||||||
|
f2 := planetOccultationExternalContactMetric(x2, config, observer, -1)
|
||||||
|
for i := 0; i < 64 && right-left > planetOccultationRootToleranceDays; i++ {
|
||||||
|
if f1 > f2 {
|
||||||
|
left = x1
|
||||||
|
x1, f1 = x2, f2
|
||||||
|
x2 = left + goldenRatio*(right-left)
|
||||||
|
f2 = planetOccultationExternalContactMetric(x2, config, observer, -1)
|
||||||
|
} else {
|
||||||
|
right = x2
|
||||||
|
x2, f2 = x1, f1
|
||||||
|
x1 = right - goldenRatio*(right-left)
|
||||||
|
f1 = planetOccultationExternalContactMetric(x1, config, observer, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (left + right) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planetOccultationConfig) (float64, Observer, float64, bool) {
|
||||||
|
frameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationPathFrameAt(tt, config)
|
||||||
|
}
|
||||||
|
searchStart := seedTT - occultationPathSearchSpanDays
|
||||||
|
searchEnd := seedTT + occultationPathSearchSpanDays
|
||||||
|
outerStart, outerEnd, ok := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, false)
|
||||||
|
if !ok {
|
||||||
|
return 0, Observer{}, 0, false
|
||||||
|
}
|
||||||
|
greatestTT := occultationPathGreatestForFrame(seedTT, outerStart, outerEnd, frameAt)
|
||||||
|
if greatestTT < startTT || greatestTT > endTT {
|
||||||
|
return 0, Observer{}, 0, false
|
||||||
|
}
|
||||||
|
point, pointOK := occultationPathCenterPointForFrame(greatestTT, frameAt, time.UTC)
|
||||||
|
if !pointOK {
|
||||||
|
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
|
||||||
|
}
|
||||||
|
if !pointOK {
|
||||||
|
return 0, Observer{}, 0, false
|
||||||
|
}
|
||||||
|
observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||||
|
state := planetOccultationStateAt(greatestTT, config, &observer, -1)
|
||||||
|
if !state.valid {
|
||||||
|
return 0, Observer{}, 0, false
|
||||||
|
}
|
||||||
|
return greatestTT, observer, state.externalContactMetric, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationInfoAtGreatest(
|
||||||
|
greatestTT float64,
|
||||||
|
config planetOccultationConfig,
|
||||||
|
observer Observer,
|
||||||
|
safetyMarginArcsec float64,
|
||||||
|
location *time.Location,
|
||||||
|
) (PlanetOccultationInfo, bool) {
|
||||||
|
if !planetOccultationLatitudePass(greatestTT, config, &observer, safetyMarginArcsec) {
|
||||||
|
return PlanetOccultationInfo{}, false
|
||||||
|
}
|
||||||
|
state := planetOccultationStateAt(greatestTT, config, &observer, -1)
|
||||||
|
if !state.valid || state.externalContactMetric > 0 {
|
||||||
|
return PlanetOccultationInfo{}, false
|
||||||
|
}
|
||||||
|
info := PlanetOccultationInfo{
|
||||||
|
Planet: config.planet,
|
||||||
|
TargetID: config.planet.String(),
|
||||||
|
Observer: observer,
|
||||||
|
Type: OccultationPartial,
|
||||||
|
Greatest: occultationTTToLocation(greatestTT, location),
|
||||||
|
MinimumSeparationArcsec: state.separationArcsec,
|
||||||
|
PositionAngleDeg: occultationPositionAngle(state.position.moonRA, state.position.moonDec, state.position.planetRA, state.position.planetDec),
|
||||||
|
MoonSemidiameterArcsec: state.moonSemidiameter,
|
||||||
|
PlanetSemidiameterArcsec: state.planetSemidiameter,
|
||||||
|
MoonAltitudeAtGreatest: occultationAltitude(greatestTT, observer, state.position.moonRA, state.position.moonDec),
|
||||||
|
MoonAzimuthAtGreatest: occultationAzimuth(greatestTT, observer, state.position.moonRA, state.position.moonDec),
|
||||||
|
}
|
||||||
|
info.VisibleAtGreatest = info.MoonAltitudeAtGreatest >= 0
|
||||||
|
|
||||||
|
if math.Abs(state.externalContactMetric) <= planetOccultationGrazingToleranceArcsec {
|
||||||
|
info.Type = OccultationGrazing
|
||||||
|
info.ExternalImmersion = info.Greatest
|
||||||
|
info.ExternalEmersion = info.Greatest
|
||||||
|
info.ContactsComplete = true
|
||||||
|
return info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
externalImmersionTT, externalImmersionOK := planetOccultationContact(greatestTT, -1, false, config, observer)
|
||||||
|
externalEmersionTT, externalEmersionOK := planetOccultationContact(greatestTT, 1, false, config, observer)
|
||||||
|
if !externalImmersionOK || !externalEmersionOK || externalEmersionTT <= externalImmersionTT {
|
||||||
|
return PlanetOccultationInfo{}, false
|
||||||
|
}
|
||||||
|
info.ExternalImmersion = occultationTTToLocation(externalImmersionTT, location)
|
||||||
|
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)
|
||||||
|
if !internalImmersionOK || !internalEmersionOK ||
|
||||||
|
internalImmersionTT <= externalImmersionTT || internalEmersionTT >= externalEmersionTT ||
|
||||||
|
internalImmersionTT >= greatestTT || internalEmersionTT <= greatestTT {
|
||||||
|
return PlanetOccultationInfo{}, false
|
||||||
|
}
|
||||||
|
info.Type = OccultationTotal
|
||||||
|
info.InternalImmersion = occultationTTToLocation(internalImmersionTT, location)
|
||||||
|
info.InternalEmersion = occultationTTToLocation(internalEmersionTT, location)
|
||||||
|
info.HasInternalContacts = true
|
||||||
|
}
|
||||||
|
info.ContactsComplete = true
|
||||||
|
return info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetMoonPositionAt(tt float64, config planetOccultationConfig, observer *Observer, n int) planetMoonPosition {
|
||||||
|
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, n)
|
||||||
|
planetRA, planetDec := config.apparentRaDecN(tt, n)
|
||||||
|
if observer != nil {
|
||||||
|
ut := TD2UT(tt, false)
|
||||||
|
moonDistanceAU := HMoonAwayN(tt, n) / angularDiameterAstronomicalUnitKM
|
||||||
|
planetDistanceAU := config.earthDistanceN(tt, n)
|
||||||
|
moonRA, moonDec = TopocentricRaDec(moonRA, moonDec, observer.Latitude, observer.Longitude, ut, moonDistanceAU, observer.Height)
|
||||||
|
planetRA, planetDec = TopocentricRaDec(planetRA, planetDec, observer.Latitude, observer.Longitude, ut, planetDistanceAU, observer.Height)
|
||||||
|
moonRA = normalizeRA(moonRA)
|
||||||
|
planetRA = normalizeRA(planetRA)
|
||||||
|
}
|
||||||
|
return planetMoonPosition{
|
||||||
|
moonRA: moonRA,
|
||||||
|
moonDec: moonDec,
|
||||||
|
planetRA: planetRA,
|
||||||
|
planetDec: planetDec,
|
||||||
|
valid: finite(moonRA) && finite(moonDec) && finite(planetRA) && finite(planetDec),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationStateAt(tt float64, config planetOccultationConfig, observer *Observer, n int) planetOccultationState {
|
||||||
|
position := planetMoonPositionAt(tt, config, observer, n)
|
||||||
|
moonRadius := MoonSemidiameterN(tt, n)
|
||||||
|
planetRadius := config.semidiameterN(tt, n)
|
||||||
|
if observer != nil {
|
||||||
|
moonRadius = moonTopocentricSemidiameterN(tt, *observer, n)
|
||||||
|
planetRadius = planetTopocentricSemidiameterN(tt, config, *observer, n)
|
||||||
|
}
|
||||||
|
if !position.valid || !finite(moonRadius) || !finite(planetRadius) || moonRadius <= planetRadius || planetRadius <= 0 {
|
||||||
|
return planetOccultationState{}
|
||||||
|
}
|
||||||
|
separation := angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600
|
||||||
|
return planetOccultationState{
|
||||||
|
position: position,
|
||||||
|
separationArcsec: separation,
|
||||||
|
moonSemidiameter: moonRadius,
|
||||||
|
planetSemidiameter: planetRadius,
|
||||||
|
externalContactMetric: separation - (moonRadius + planetRadius),
|
||||||
|
internalContactMetric: separation - (moonRadius - planetRadius),
|
||||||
|
valid: finite(separation),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetTopocentricSemidiameterN(tt float64, config planetOccultationConfig, observer Observer, n int) float64 {
|
||||||
|
ra, dec := config.apparentRaDecN(tt, n)
|
||||||
|
distanceKM := config.earthDistanceN(tt, n) * angularDiameterAstronomicalUnitKM
|
||||||
|
if !finite(ra) || !finite(dec) || !finite(distanceKM) || distanceKM <= 0 {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
distanceKM = topocentricDistanceKM(ra, dec, distanceKM, observer, TD2UT(tt, false))
|
||||||
|
if !finite(distanceKM) || distanceKM <= config.equatorialRadiusKM {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
return angularSemidiameterArcsec(config.equatorialRadiusKM, distanceKM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationExternalContactMetric(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 {
|
||||||
|
state := planetOccultationStateAt(tt, config, observer, n)
|
||||||
|
if !state.valid {
|
||||||
|
return math.Inf(1)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, moonLatitude := RaDecToLoBo(tt, state.position.moonRA, state.position.moonDec)
|
||||||
|
_, planetLatitude := RaDecToLoBo(tt, state.position.planetRA, state.position.planetDec)
|
||||||
|
limit := state.moonSemidiameter + state.planetSemidiameter + planetOccultationLatitudeMarginArcsec + safetyMarginArcsec
|
||||||
|
return math.Abs(moonLatitude-planetLatitude)*3600 <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationContact(
|
||||||
|
greatestTT float64,
|
||||||
|
direction int,
|
||||||
|
internal bool,
|
||||||
|
config planetOccultationConfig,
|
||||||
|
observer Observer,
|
||||||
|
) (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
|
||||||
|
}
|
||||||
|
nearTT := greatestTT
|
||||||
|
nearValue := metric(nearTT)
|
||||||
|
if !finite(nearValue) || nearValue > 0 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationCoarseStepDays(options OccultationSearchOptions) float64 {
|
||||||
|
step := planetOccultationDefaultStepDays
|
||||||
|
if options.MaxStep > 0 {
|
||||||
|
requested := options.MaxStep.Hours() / 24
|
||||||
|
if requested > 0 && requested < step {
|
||||||
|
step = requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return math.Max(step, occultationSearchMinimumStep.Hours()/24)
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
planetOccultationDiagramDefaultStepDays = 2.0 / 1440.0
|
||||||
|
planetOccultationDiagramMinStepDays = 1.0 / 86400.0
|
||||||
|
planetOccultationDiagramMaxSamples = 2000
|
||||||
|
planetOccultationDiagramDuplicateDays = 1e-10
|
||||||
|
planetOccultationDiagramGeometryArcsec = 0.05
|
||||||
|
planetOccultationDiagramPositionDeg = 0.01
|
||||||
|
planetOccultationDiagramContactTimeDays = 1e-6
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlanetOccultationDiagramOptions 控制本地行星月掩轨迹采样。
|
||||||
|
// PlanetOccultationDiagramOptions controls local planetary-occultation track sampling.
|
||||||
|
type PlanetOccultationDiagramOptions struct {
|
||||||
|
// StepDays 是请求的轨迹采样步长,单位为日;非正值或非有限值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,使基础轨迹不超过 2000 个采样点;必要阶段帧仍会额外保留。结果会报告实际采用的值。
|
||||||
|
// StepDays is the requested track sampling step in days. Non-positive or non-finite values use two minutes, and positive values below one second use one second. Long events may increase the effective step to keep the base track within 2000 samples; required phase frames are retained in addition. The result reports the effective value.
|
||||||
|
StepDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanetOccultationDiagramFrame 描述一个时刻的站心月球与行星几何。
|
||||||
|
// PlanetOccultationDiagramFrame describes topocentric Moon-planet geometry at one instant.
|
||||||
|
type PlanetOccultationDiagramFrame struct {
|
||||||
|
// JDE 是 TT 儒略历书日。
|
||||||
|
// JDE is the TT Julian ephemeris day.
|
||||||
|
JDE float64
|
||||||
|
// PlanetXArcsec 和 PlanetYArcsec 是相对月心的切平面偏移,单位为角秒。X 向东为正,Y 向北为正。
|
||||||
|
// PlanetXArcsec and PlanetYArcsec are tangent-plane offsets from the lunar center. X is positive east and Y is positive north.
|
||||||
|
PlanetXArcsec float64
|
||||||
|
PlanetYArcsec float64
|
||||||
|
// MoonRadiusArcsec 和 PlanetRadiusArcsec 是站心视半径,单位为角秒。
|
||||||
|
// MoonRadiusArcsec and PlanetRadiusArcsec are topocentric apparent semidiameters.
|
||||||
|
MoonRadiusArcsec float64
|
||||||
|
PlanetRadiusArcsec float64
|
||||||
|
// SeparationArcsec 和 PositionAngleDeg 描述行星中心相对月心的位置。
|
||||||
|
// SeparationArcsec and PositionAngleDeg describe the planet center relative to the lunar center.
|
||||||
|
SeparationArcsec float64
|
||||||
|
PositionAngleDeg float64
|
||||||
|
// MoonAltitudeDeg 和 MoonAzimuthDeg 是站心地平坐标。
|
||||||
|
// MoonAltitudeDeg and MoonAzimuthDeg are topocentric horizontal coordinates.
|
||||||
|
MoonAltitudeDeg float64
|
||||||
|
MoonAzimuthDeg float64
|
||||||
|
// DisksOverlap 表示两个视盘面存在正面积交集。
|
||||||
|
// DisksOverlap is true while the two apparent disks have a positive-area intersection.
|
||||||
|
DisksOverlap bool
|
||||||
|
// FullyOcculted 表示行星盘面完全位于月缘内侧。
|
||||||
|
// FullyOcculted is true while the planet disk lies strictly inside the lunar limb.
|
||||||
|
FullyOcculted bool
|
||||||
|
// Label 是主阶段标识;Labels 在掠掩事件中保留重合阶段。
|
||||||
|
// Label is the primary key phase; Labels retains coincident phases for grazing events.
|
||||||
|
Label string
|
||||||
|
Labels []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanetOccultationDiagramResult 包含固定地点行星月掩的几何数据。
|
||||||
|
// PlanetOccultationDiagramResult contains geometry for a fixed-site planetary occultation.
|
||||||
|
type PlanetOccultationDiagramResult struct {
|
||||||
|
Occultation PlanetOccultationInfo
|
||||||
|
Frames []PlanetOccultationDiagramFrame
|
||||||
|
// StepDays 是实际采用的基础轨迹采样步长,单位为日。
|
||||||
|
// StepDays is the effective base-track sampling step in days.
|
||||||
|
StepDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type planetOccultationDiagramTime struct {
|
||||||
|
jde float64
|
||||||
|
labels []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanetOccultationDiagram 为已求解的固定地点行星月掩计算以月心为原点的切平面轨迹。事件数据无效或不完整时,结果不含帧。
|
||||||
|
// PlanetOccultationDiagram computes a Moon-centered tangent-plane track for an already solved fixed-site planetary occultation. Invalid or incomplete event data produces a result without frames.
|
||||||
|
func PlanetOccultationDiagram(
|
||||||
|
info PlanetOccultationInfo,
|
||||||
|
options PlanetOccultationDiagramOptions,
|
||||||
|
) PlanetOccultationDiagramResult {
|
||||||
|
options = normalizePlanetOccultationDiagramOptions(options)
|
||||||
|
result := PlanetOccultationDiagramResult{Occultation: info, StepDays: options.StepDays}
|
||||||
|
if !planetOccultationDiagramInputValid(info) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
config, ok := planetOccultationConfigFor(info.Planet)
|
||||||
|
if !ok {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
startTT := occultationTimeToTT(info.ExternalImmersion)
|
||||||
|
greatestTT := occultationTimeToTT(info.Greatest)
|
||||||
|
endTT := occultationTimeToTT(info.ExternalEmersion)
|
||||||
|
externalImmersionFrame, externalImmersionOK := planetOccultationDiagramFrameAt(startTT, config, info.Observer)
|
||||||
|
greatestFrame, greatestOK := planetOccultationDiagramFrameAt(greatestTT, config, info.Observer)
|
||||||
|
externalEmersionFrame, externalEmersionOK := planetOccultationDiagramFrameAt(endTT, config, info.Observer)
|
||||||
|
if !externalImmersionOK || !greatestOK || !externalEmersionOK {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
internalImmersionFrame, internalEmersionFrame PlanetOccultationDiagramFrame
|
||||||
|
internalImmersionOK, internalEmersionOK bool
|
||||||
|
)
|
||||||
|
if info.HasInternalContacts {
|
||||||
|
internalImmersionFrame, internalImmersionOK = planetOccultationDiagramFrameAt(
|
||||||
|
occultationTimeToTT(info.InternalImmersion), config, info.Observer,
|
||||||
|
)
|
||||||
|
internalEmersionFrame, internalEmersionOK = planetOccultationDiagramFrameAt(
|
||||||
|
occultationTimeToTT(info.InternalEmersion), config, info.Observer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (info.HasInternalContacts && (!internalImmersionOK || !internalEmersionOK)) ||
|
||||||
|
!planetOccultationDiagramMatchesInfo(
|
||||||
|
info, startTT, greatestTT, endTT,
|
||||||
|
externalImmersionFrame, internalImmersionFrame, greatestFrame, internalEmersionFrame, externalEmersionFrame,
|
||||||
|
) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
times, stepDays := planetOccultationDiagramTimes(info, startTT, greatestTT, endTT, options.StepDays)
|
||||||
|
result.StepDays = stepDays
|
||||||
|
result.Frames = make([]PlanetOccultationDiagramFrame, 0, len(times))
|
||||||
|
for _, item := range times {
|
||||||
|
frame, frameOK := planetOccultationDiagramFrameAt(item.jde, config, info.Observer)
|
||||||
|
if !frameOK {
|
||||||
|
return PlanetOccultationDiagramResult{Occultation: info, StepDays: stepDays}
|
||||||
|
}
|
||||||
|
frame.Labels = append([]string(nil), item.labels...)
|
||||||
|
frame.Label = planetOccultationDiagramPrimaryLabel(item.labels)
|
||||||
|
result.Frames = append(result.Frames, frame)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationDiagramMatchesInfo(
|
||||||
|
info PlanetOccultationInfo,
|
||||||
|
startTT, greatestTT, endTT float64,
|
||||||
|
externalImmersion, internalImmersion, greatest, internalEmersion, externalEmersion PlanetOccultationDiagramFrame,
|
||||||
|
) bool {
|
||||||
|
if !finite(info.MinimumSeparationArcsec) || info.MinimumSeparationArcsec < 0 ||
|
||||||
|
!finite(info.PositionAngleDeg) ||
|
||||||
|
!finite(info.MoonSemidiameterArcsec) || info.MoonSemidiameterArcsec <= 0 ||
|
||||||
|
!finite(info.PlanetSemidiameterArcsec) || info.PlanetSemidiameterArcsec <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if math.Abs(externalImmersion.SeparationArcsec-externalImmersion.MoonRadiusArcsec-externalImmersion.PlanetRadiusArcsec) > planetOccultationDiagramGeometryArcsec ||
|
||||||
|
math.Abs(externalEmersion.SeparationArcsec-externalEmersion.MoonRadiusArcsec-externalEmersion.PlanetRadiusArcsec) > planetOccultationDiagramGeometryArcsec {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if math.Abs(greatest.SeparationArcsec-info.MinimumSeparationArcsec) > planetOccultationDiagramGeometryArcsec ||
|
||||||
|
math.Abs(greatest.MoonRadiusArcsec-info.MoonSemidiameterArcsec) > planetOccultationDiagramGeometryArcsec ||
|
||||||
|
math.Abs(greatest.PlanetRadiusArcsec-info.PlanetSemidiameterArcsec) > planetOccultationDiagramGeometryArcsec ||
|
||||||
|
math.Abs(signedAngleDifference(greatest.PositionAngleDeg, info.PositionAngleDeg)) > planetOccultationDiagramPositionDeg {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
externalMetric := greatest.SeparationArcsec - greatest.MoonRadiusArcsec - greatest.PlanetRadiusArcsec
|
||||||
|
internalMetric := greatest.SeparationArcsec - greatest.MoonRadiusArcsec + greatest.PlanetRadiusArcsec
|
||||||
|
switch info.Type {
|
||||||
|
case OccultationPartial:
|
||||||
|
return externalMetric < -planetOccultationGrazingToleranceArcsec &&
|
||||||
|
internalMetric >= -planetOccultationGrazingToleranceArcsec
|
||||||
|
case OccultationGrazing:
|
||||||
|
return math.Abs(externalMetric) <= planetOccultationGrazingToleranceArcsec &&
|
||||||
|
math.Abs(startTT-greatestTT) <= planetOccultationDiagramContactTimeDays &&
|
||||||
|
math.Abs(endTT-greatestTT) <= planetOccultationDiagramContactTimeDays
|
||||||
|
case OccultationTotal:
|
||||||
|
if math.Abs(internalImmersion.SeparationArcsec-internalImmersion.MoonRadiusArcsec+internalImmersion.PlanetRadiusArcsec) > planetOccultationDiagramGeometryArcsec ||
|
||||||
|
math.Abs(internalEmersion.SeparationArcsec-internalEmersion.MoonRadiusArcsec+internalEmersion.PlanetRadiusArcsec) > planetOccultationDiagramGeometryArcsec {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return externalMetric < -planetOccultationGrazingToleranceArcsec &&
|
||||||
|
internalMetric < -planetOccultationGrazingToleranceArcsec
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePlanetOccultationDiagramOptions(options PlanetOccultationDiagramOptions) PlanetOccultationDiagramOptions {
|
||||||
|
if options.StepDays <= 0 || !finite(options.StepDays) {
|
||||||
|
options.StepDays = planetOccultationDiagramDefaultStepDays
|
||||||
|
}
|
||||||
|
if options.StepDays < planetOccultationDiagramMinStepDays {
|
||||||
|
options.StepDays = planetOccultationDiagramMinStepDays
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationDiagramInputValid(info PlanetOccultationInfo) bool {
|
||||||
|
if info.Planet.Validate() != nil || info.Observer.Validate() != nil || !info.ContactsComplete ||
|
||||||
|
info.ExternalImmersion.IsZero() || info.Greatest.IsZero() || info.ExternalEmersion.IsZero() ||
|
||||||
|
info.Greatest.Before(info.ExternalImmersion) || info.ExternalEmersion.Before(info.Greatest) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch info.Type {
|
||||||
|
case OccultationTotal:
|
||||||
|
return info.HasInternalContacts && !info.InternalImmersion.IsZero() && !info.InternalEmersion.IsZero() &&
|
||||||
|
info.InternalImmersion.After(info.ExternalImmersion) && info.InternalImmersion.Before(info.Greatest) &&
|
||||||
|
info.InternalEmersion.After(info.Greatest) && info.InternalEmersion.Before(info.ExternalEmersion)
|
||||||
|
case OccultationPartial:
|
||||||
|
return !info.HasInternalContacts && info.InternalImmersion.IsZero() && info.InternalEmersion.IsZero()
|
||||||
|
case OccultationGrazing:
|
||||||
|
return !info.HasInternalContacts && info.InternalImmersion.IsZero() && info.InternalEmersion.IsZero()
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationDiagramTimes(
|
||||||
|
info PlanetOccultationInfo,
|
||||||
|
startTT, greatestTT, endTT, stepDays float64,
|
||||||
|
) ([]planetOccultationDiagramTime, float64) {
|
||||||
|
if !finite(startTT) || !finite(greatestTT) || !finite(endTT) || greatestTT < startTT || endTT < greatestTT {
|
||||||
|
return nil, stepDays
|
||||||
|
}
|
||||||
|
if endTT > startTT {
|
||||||
|
if sampleCount := int(math.Ceil((endTT-startTT)/stepDays)) + 1; sampleCount > planetOccultationDiagramMaxSamples {
|
||||||
|
stepDays = (endTT - startTT) / float64(planetOccultationDiagramMaxSamples-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
times := []planetOccultationDiagramTime{
|
||||||
|
{jde: startTT, labels: []string{"C1"}},
|
||||||
|
{jde: greatestTT, labels: []string{"Greatest"}},
|
||||||
|
{jde: endTT, labels: []string{"C4"}},
|
||||||
|
}
|
||||||
|
if info.HasInternalContacts {
|
||||||
|
times = append(times,
|
||||||
|
planetOccultationDiagramTime{jde: occultationTimeToTT(info.InternalImmersion), labels: []string{"C2"}},
|
||||||
|
planetOccultationDiagramTime{jde: occultationTimeToTT(info.InternalEmersion), labels: []string{"C3"}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for jde := startTT + stepDays; jde < endTT; jde += stepDays {
|
||||||
|
times = append(times, planetOccultationDiagramTime{jde: jde})
|
||||||
|
}
|
||||||
|
sort.SliceStable(times, func(i, j int) bool {
|
||||||
|
if times[i].jde == times[j].jde {
|
||||||
|
return planetOccultationDiagramLabelPriority(times[i].labels) < planetOccultationDiagramLabelPriority(times[j].labels)
|
||||||
|
}
|
||||||
|
return times[i].jde < times[j].jde
|
||||||
|
})
|
||||||
|
return uniquePlanetOccultationDiagramTimes(times), stepDays
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniquePlanetOccultationDiagramTimes(times []planetOccultationDiagramTime) []planetOccultationDiagramTime {
|
||||||
|
unique := times[:0]
|
||||||
|
for _, item := range times {
|
||||||
|
if !finite(item.jde) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(unique) == 0 || math.Abs(item.jde-unique[len(unique)-1].jde) > planetOccultationDiagramDuplicateDays {
|
||||||
|
item.labels = append([]string(nil), item.labels...)
|
||||||
|
unique = append(unique, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unique[len(unique)-1].labels = mergeStarOccultationDiagramLabels(unique[len(unique)-1].labels, item.labels)
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationDiagramPrimaryLabel(labels []string) string {
|
||||||
|
for _, label := range labels {
|
||||||
|
if label == "Greatest" {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return labels[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationDiagramLabelPriority(labels []string) int {
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return 99
|
||||||
|
}
|
||||||
|
switch labels[0] {
|
||||||
|
case "C1":
|
||||||
|
return 0
|
||||||
|
case "C2":
|
||||||
|
return 1
|
||||||
|
case "Greatest":
|
||||||
|
return 2
|
||||||
|
case "C3":
|
||||||
|
return 3
|
||||||
|
case "C4":
|
||||||
|
return 4
|
||||||
|
default:
|
||||||
|
return 99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationDiagramFrameAt(
|
||||||
|
tt float64,
|
||||||
|
config planetOccultationConfig,
|
||||||
|
observer Observer,
|
||||||
|
) (PlanetOccultationDiagramFrame, bool) {
|
||||||
|
state := planetOccultationStateAt(tt, config, &observer, -1)
|
||||||
|
if !state.valid {
|
||||||
|
return PlanetOccultationDiagramFrame{}, false
|
||||||
|
}
|
||||||
|
positionAngle := occultationPositionAngle(
|
||||||
|
state.position.moonRA, state.position.moonDec,
|
||||||
|
state.position.planetRA, state.position.planetDec,
|
||||||
|
)
|
||||||
|
if !finite(positionAngle) {
|
||||||
|
return PlanetOccultationDiagramFrame{}, false
|
||||||
|
}
|
||||||
|
angle := positionAngle * math.Pi / 180
|
||||||
|
return PlanetOccultationDiagramFrame{
|
||||||
|
JDE: tt,
|
||||||
|
PlanetXArcsec: state.separationArcsec * math.Sin(angle),
|
||||||
|
PlanetYArcsec: state.separationArcsec * math.Cos(angle),
|
||||||
|
MoonRadiusArcsec: state.moonSemidiameter,
|
||||||
|
PlanetRadiusArcsec: state.planetSemidiameter,
|
||||||
|
SeparationArcsec: state.separationArcsec,
|
||||||
|
PositionAngleDeg: positionAngle,
|
||||||
|
MoonAltitudeDeg: occultationAltitude(tt, observer, state.position.moonRA, state.position.moonDec),
|
||||||
|
MoonAzimuthDeg: occultationAzimuth(tt, observer, state.position.moonRA, state.position.moonDec),
|
||||||
|
DisksOverlap: state.externalContactMetric < -planetOccultationGrazingToleranceArcsec,
|
||||||
|
FullyOcculted: state.internalContactMetric < -planetOccultationGrazingToleranceArcsec,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanetOccultationDiagramSaturnIncludesFiveDynamicDiskStages(t *testing.T) {
|
||||||
|
location := time.FixedZone("UTC+8", 8*3600)
|
||||||
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, location)
|
||||||
|
end := start.Add(24 * time.Hour)
|
||||||
|
events, err := FindPlanetOccultations(
|
||||||
|
start, end, OccultationSaturn, 104.52219613, 55.25401991, 0, OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlanetOccultations() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(events))
|
||||||
|
}
|
||||||
|
diagram := PlanetOccultationDiagram(events[0], PlanetOccultationDiagramOptions{})
|
||||||
|
if len(diagram.Frames) < 5 {
|
||||||
|
t.Fatalf("PlanetOccultationDiagram() returned %d frames, want at least 5", len(diagram.Frames))
|
||||||
|
}
|
||||||
|
|
||||||
|
frames := make(map[string]PlanetOccultationDiagramFrame)
|
||||||
|
for _, frame := range diagram.Frames {
|
||||||
|
for _, label := range frame.Labels {
|
||||||
|
frames[label] = frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, label := range []string{"C1", "C2", "Greatest", "C3", "C4"} {
|
||||||
|
if _, ok := frames[label]; !ok {
|
||||||
|
t.Fatalf("diagram is missing %s", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, label := range []string{"C1", "C4"} {
|
||||||
|
frame := frames[label]
|
||||||
|
residual := frame.SeparationArcsec - frame.MoonRadiusArcsec - frame.PlanetRadiusArcsec
|
||||||
|
if math.Abs(residual) > 0.1 {
|
||||||
|
t.Errorf("%s outer-contact residual = %.6f arcsec", label, residual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, label := range []string{"C2", "C3"} {
|
||||||
|
frame := frames[label]
|
||||||
|
residual := frame.SeparationArcsec - frame.MoonRadiusArcsec + frame.PlanetRadiusArcsec
|
||||||
|
if math.Abs(residual) > 0.1 {
|
||||||
|
t.Errorf("%s inner-contact residual = %.6f arcsec", label, residual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !frames["Greatest"].FullyOcculted {
|
||||||
|
t.Fatal("greatest frame does not report a fully occulted Saturn disk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationDiagramRejectsIncompleteEvent(t *testing.T) {
|
||||||
|
result := PlanetOccultationDiagram(PlanetOccultationInfo{Planet: OccultationSaturn}, PlanetOccultationDiagramOptions{})
|
||||||
|
if len(result.Frames) != 0 {
|
||||||
|
t.Fatalf("invalid event returned %d frames, want none", len(result.Frames))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationDiagramRejectsTargetGeometryMismatch(t *testing.T) {
|
||||||
|
location := time.FixedZone("UTC+8", 8*3600)
|
||||||
|
events, err := FindPlanetOccultations(
|
||||||
|
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
|
||||||
|
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
|
||||||
|
OccultationSaturn, 104.52219613, 55.25401991, 0, OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
if err != nil || len(events) != 1 {
|
||||||
|
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
|
||||||
|
}
|
||||||
|
// TargetID 只是显示元数据,但改变建模行星必须 / TargetID is display metadata, but changing the modeled planet must
|
||||||
|
// 使事件几何失效,而不是静默绘制另一个天体 / invalidate the event geometry rather than silently drawing another body.
|
||||||
|
event := events[0]
|
||||||
|
event.Planet = OccultationVenus
|
||||||
|
event.TargetID = "Venus"
|
||||||
|
result := PlanetOccultationDiagram(event, PlanetOccultationDiagramOptions{})
|
||||||
|
if len(result.Frames) != 0 {
|
||||||
|
t.Fatalf("diagram accepted mismatched planet and returned %d frames", len(result.Frames))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationDiagramPartialEventHasOnlyExternalStages(t *testing.T) {
|
||||||
|
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
|
||||||
|
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
|
||||||
|
events, err := FindPlanetOccultations(
|
||||||
|
start, end, OccultationSaturn, -30.072, -6.5, 0, OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
if err != nil || len(events) != 1 {
|
||||||
|
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
|
||||||
|
}
|
||||||
|
if events[0].Type != OccultationPartial {
|
||||||
|
t.Fatalf("event type = %q, want partial", events[0].Type)
|
||||||
|
}
|
||||||
|
diagram := PlanetOccultationDiagram(events[0], PlanetOccultationDiagramOptions{})
|
||||||
|
labels := make(map[string]bool)
|
||||||
|
for _, frame := range diagram.Frames {
|
||||||
|
for _, label := range frame.Labels {
|
||||||
|
labels[label] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, label := range []string{"C1", "Greatest", "C4"} {
|
||||||
|
if !labels[label] {
|
||||||
|
t.Fatalf("partial diagram is missing %s", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if labels["C2"] || labels["C3"] {
|
||||||
|
t.Fatalf("partial diagram contains internal contacts: %+v", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
planetOccultationFootprintBoundaryPoints = 180
|
||||||
|
planetOccultationHorizonPoints = 360
|
||||||
|
planetOccultationFootprintMaxSamples = 360
|
||||||
|
)
|
||||||
|
|
||||||
|
type planetOccultationFootprintSample struct {
|
||||||
|
point OccultationPathPoint
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationFootprints(
|
||||||
|
startTT, endTT, greatestTT float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
options OccultationPathOptions,
|
||||||
|
location *time.Location,
|
||||||
|
) []PlanetOccultationFootprint {
|
||||||
|
stepDays := float64(options.Step) / float64(24*time.Hour)
|
||||||
|
times := occultationPathSampleTimesWithLimit(
|
||||||
|
startTT, endTT, greatestTT, stepDays, planetOccultationFootprintMaxSamples,
|
||||||
|
)
|
||||||
|
footprints := make([]PlanetOccultationFootprint, 0, len(times))
|
||||||
|
for _, tt := range times {
|
||||||
|
footprint, ok := planetOccultationFootprintAt(tt, frameAt, location)
|
||||||
|
if ok {
|
||||||
|
footprints = append(footprints, footprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return footprints
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationFootprintAt(
|
||||||
|
tt float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
location *time.Location,
|
||||||
|
) (PlanetOccultationFootprint, bool) {
|
||||||
|
frame, ok := frameAt(tt)
|
||||||
|
if !ok {
|
||||||
|
return PlanetOccultationFootprint{}, false
|
||||||
|
}
|
||||||
|
samples := make([]planetOccultationFootprintSample, planetOccultationFootprintBoundaryPoints)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segments, closed := planetOccultationFootprintSegments(samples)
|
||||||
|
polygons := make([][]OccultationPathPoint, 0, len(segments))
|
||||||
|
for _, segment := range segments {
|
||||||
|
if len(segment) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
polygon := append([]OccultationPathPoint(nil), segment...)
|
||||||
|
if closed {
|
||||||
|
polygon = append(polygon, polygon[0])
|
||||||
|
} else {
|
||||||
|
polygon = append(polygon, planetOccultationHorizonArc(tt, frame, segment[len(segment)-1], segment[0], location)...)
|
||||||
|
}
|
||||||
|
if len(polygon) >= 4 {
|
||||||
|
polygons = append(polygons, polygon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(polygons) == 0 {
|
||||||
|
return PlanetOccultationFootprint{}, false
|
||||||
|
}
|
||||||
|
return PlanetOccultationFootprint{
|
||||||
|
Time: occultationTTToLocation(tt, location),
|
||||||
|
Polygons: polygons,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationFootprintSegments(
|
||||||
|
samples []planetOccultationFootprintSample,
|
||||||
|
) ([][]OccultationPathPoint, bool) {
|
||||||
|
segments := make([][]OccultationPathPoint, 0, 2)
|
||||||
|
current := make([]OccultationPathPoint, 0, len(samples))
|
||||||
|
allValid := len(samples) > 0
|
||||||
|
for _, sample := range samples {
|
||||||
|
if !sample.ok {
|
||||||
|
allValid = false
|
||||||
|
if len(current) > 0 {
|
||||||
|
segments = append(segments, current)
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = append(current, sample.point)
|
||||||
|
}
|
||||||
|
if len(current) > 0 {
|
||||||
|
segments = append(segments, current)
|
||||||
|
}
|
||||||
|
if len(segments) > 1 && samples[0].ok && samples[len(samples)-1].ok {
|
||||||
|
first := segments[0]
|
||||||
|
last := segments[len(segments)-1]
|
||||||
|
merged := make([]OccultationPathPoint, 0, len(last)+len(first))
|
||||||
|
merged = append(merged, last...)
|
||||||
|
merged = append(merged, first...)
|
||||||
|
segments[0] = merged
|
||||||
|
segments = segments[:len(segments)-1]
|
||||||
|
}
|
||||||
|
return segments, allValid && len(segments) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationHorizonArc(
|
||||||
|
tt float64,
|
||||||
|
frame occultationPathFrame,
|
||||||
|
from, to OccultationPathPoint,
|
||||||
|
location *time.Location,
|
||||||
|
) []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
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
return append(arc, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationSphericalCircle(
|
||||||
|
value time.Time,
|
||||||
|
centerLongitude, centerLatitude, radius float64,
|
||||||
|
count int,
|
||||||
|
) []OccultationPathPoint {
|
||||||
|
centerLongitude *= math.Pi / 180
|
||||||
|
centerLatitude *= math.Pi / 180
|
||||||
|
radius *= math.Pi / 180
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanetOccultationSaturnFootprintsContainCenterLine(t *testing.T) {
|
||||||
|
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn occultation config is unavailable")
|
||||||
|
}
|
||||||
|
seedTT := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
|
||||||
|
for _, contact := range []struct {
|
||||||
|
name string
|
||||||
|
frameAt occultationPathFrameFunc
|
||||||
|
}{
|
||||||
|
{name: "partial", frameAt: func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationPathFrameAt(tt, config)
|
||||||
|
}},
|
||||||
|
{name: "total", frameAt: func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationTotalPathFrameAt(tt, config)
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
startTT, endTT, found := occultationPathWindowForFrame(
|
||||||
|
seedTT, seedTT-occultationPathSearchSpanDays, seedTT+occultationPathSearchSpanDays,
|
||||||
|
contact.frameAt, true,
|
||||||
|
)
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("%s center interval is unavailable", contact.name)
|
||||||
|
}
|
||||||
|
checked := 0
|
||||||
|
for tt := startTT + 2.0/1440.0; tt < endTT-2.0/1440.0; tt += 5.0 / 1440.0 {
|
||||||
|
frame, frameOK := contact.frameAt(tt)
|
||||||
|
center, _, centerOK := occultationEarthLineIntersection(frame.moon, frame.axis)
|
||||||
|
footprint, footprintOK := planetOccultationFootprintAt(tt, contact.frameAt, time.UTC)
|
||||||
|
if !frameOK || !centerOK || !footprintOK {
|
||||||
|
t.Fatalf("%s center or footprint is unavailable at TT %.9f", contact.name, tt)
|
||||||
|
}
|
||||||
|
longitude, latitude := occultationPathGeodetic(tt, center)
|
||||||
|
if !planetOccultationFootprintContains(footprint, longitude, latitude) {
|
||||||
|
t.Fatalf("%s footprint does not contain center %.6f, %.6f at TT %.9f",
|
||||||
|
contact.name, longitude, latitude, tt)
|
||||||
|
}
|
||||||
|
checked++
|
||||||
|
}
|
||||||
|
if checked < 10 {
|
||||||
|
t.Fatalf("%s checked only %d center samples", contact.name, checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationPathRejectsExcessiveAggregateSampling(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
_, err := FindPlanetOccultationPaths(
|
||||||
|
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||||
|
OccultationPathOptions{Step: time.Second},
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrOccultationPathSamplingLimit) {
|
||||||
|
t.Fatalf("FindPlanetOccultationPaths() error = %v, want ErrOccultationPathSamplingLimit", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationFootprintsHaveIndependentSampleBudget(t *testing.T) {
|
||||||
|
times := occultationPathSampleTimesWithLimit(
|
||||||
|
0, 1, 0.500001, 1.0/86400.0, planetOccultationFootprintMaxSamples,
|
||||||
|
)
|
||||||
|
if len(times) > planetOccultationFootprintMaxSamples {
|
||||||
|
t.Fatalf("footprint sample count = %d, maximum %d", len(times), planetOccultationFootprintMaxSamples)
|
||||||
|
}
|
||||||
|
foundGreatest := false
|
||||||
|
for _, sample := range times {
|
||||||
|
if sample == 0.500001 {
|
||||||
|
foundGreatest = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundGreatest {
|
||||||
|
t.Fatal("bounded footprint samples omitted greatest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationFootprintContains(
|
||||||
|
footprint PlanetOccultationFootprint,
|
||||||
|
longitude, latitude float64,
|
||||||
|
) bool {
|
||||||
|
for _, polygon := range footprint.Polygons {
|
||||||
|
inside := false
|
||||||
|
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
|
||||||
|
currentX := math.Remainder(polygon[current].Longitude-longitude, 360)
|
||||||
|
previousX := math.Remainder(polygon[previous].Longitude-longitude, 360)
|
||||||
|
currentY := polygon[current].Latitude
|
||||||
|
previousY := polygon[previous].Latitude
|
||||||
|
if math.Abs(currentX-previousX) > 180 {
|
||||||
|
if currentX < previousX {
|
||||||
|
currentX += 360
|
||||||
|
} else {
|
||||||
|
previousX += 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentY > latitude) == (previousY > latitude) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
intersectionX := previousX + (latitude-previousY)*(currentX-previousX)/(currentY-previousY)
|
||||||
|
if intersectionX > 0 {
|
||||||
|
inside = !inside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inside {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,895 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const planetOccultationPathMaxTemporalSamples = 5000
|
||||||
|
|
||||||
|
type occultationPathFrameFunc func(float64) (occultationPathFrame, bool)
|
||||||
|
|
||||||
|
// FindPlanetOccultationPaths 搜索有限盘面行星月掩的全球外接触和内接触掩带。
|
||||||
|
// 查询窗口按全球几何掩甚点选择事件;端点容差 10 ms 与数值根精度一致。求解成功时,每条路径扩展到完整全球起止点。
|
||||||
|
// FindPlanetOccultationPaths searches the global outer- and inner-contact footprints of one finite-disk planet.
|
||||||
|
// The query window selects events by global geometric greatest, with a 10 ms endpoint tolerance matching the numerical root precision. Each returned path expands to its complete global start and end when solved.
|
||||||
|
func FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
|
||||||
|
options OccultationPathOptions) ([]PlanetOccultationPath, error) {
|
||||||
|
if err := validateOccultationTimeRange(start, end); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := planet.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := options.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
config, _ := planetOccultationConfigFor(planet)
|
||||||
|
options = normalizeOccultationPathOptions(options)
|
||||||
|
startTT := occultationTimeToTT(start)
|
||||||
|
endTT := occultationTimeToTT(end)
|
||||||
|
candidateStartTT := startTT - occultationPathSearchSpanDays
|
||||||
|
candidateEndTT := endTT + occultationPathSearchSpanDays
|
||||||
|
candidates := planetOccultationCandidateGreatestTimes(
|
||||||
|
candidateStartTT, candidateEndTT, planetOccultationDefaultStepDays, config, nil, 0,
|
||||||
|
)
|
||||||
|
paths := make([]PlanetOccultationPath, 0, len(candidates))
|
||||||
|
for _, seedTT := range candidates {
|
||||||
|
path, ok, err := planetOccultationPathAtSeed(seedTT, config, options, start.Location())
|
||||||
|
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
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
sort.SliceStable(paths, func(i, j int) bool {
|
||||||
|
return paths[i].Greatest.Time.Before(paths[j].Greatest.Time)
|
||||||
|
})
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationPathAtSeed(
|
||||||
|
seedTT float64,
|
||||||
|
config planetOccultationConfig,
|
||||||
|
options OccultationPathOptions,
|
||||||
|
location *time.Location,
|
||||||
|
) (PlanetOccultationPath, bool, error) {
|
||||||
|
frameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationPathFrameAt(tt, config)
|
||||||
|
}
|
||||||
|
totalFrameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationTotalPathFrameAt(tt, config)
|
||||||
|
}
|
||||||
|
searchStart := seedTT - occultationPathSearchSpanDays
|
||||||
|
searchEnd := seedTT + occultationPathSearchSpanDays
|
||||||
|
outerStart, outerEnd, ok := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, false)
|
||||||
|
if !ok {
|
||||||
|
return PlanetOccultationPath{}, false, nil
|
||||||
|
}
|
||||||
|
centerStart, centerEnd, hasCenter := occultationPathWindowForFrame(seedTT, searchStart, searchEnd, frameAt, true)
|
||||||
|
greatestTT := occultationPathGreatestForFrame(seedTT, outerStart, outerEnd, 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)
|
||||||
|
}
|
||||||
|
if !greatestOK {
|
||||||
|
return PlanetOccultationPath{}, false, nil
|
||||||
|
}
|
||||||
|
_, _, greatestWidth, greatestWidthOK := occultationPathLimitsAndWidthForFrame(greatestTT, frameAt)
|
||||||
|
if !greatestWidthOK || greatestWidth <= 0 {
|
||||||
|
return PlanetOccultationPath{}, false, nil
|
||||||
|
}
|
||||||
|
// 仅有边界的事件没有影轴与椭球交点;原回退点使用纬度极值弦宽,全掩带使用下方的地面横向宽度。统一两种接触带宽度定义,使有限盘面内外接触宽度可比较。
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
hasTotal = hasTotal && greatestTT >= totalStartTT && greatestTT <= totalEndTT
|
||||||
|
if planetOccultationPathTemporalSampleCount(
|
||||||
|
outerStart, outerEnd, centerStart, centerEnd, hasCenter,
|
||||||
|
totalStartTT, totalEndTT, hasTotal, greatestTT, options,
|
||||||
|
) > planetOccultationPathMaxTemporalSamples {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
centerLine, northern, southern, err := planetOccultationPathSamples(
|
||||||
|
outerStart, outerEnd, centerStart, centerEnd, hasCenter, greatestTT, frameAt, options, location,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return PlanetOccultationPath{}, false, err
|
||||||
|
}
|
||||||
|
path := PlanetOccultationPath{
|
||||||
|
Planet: config.planet,
|
||||||
|
TargetID: config.planet.String(),
|
||||||
|
Start: start.point,
|
||||||
|
Greatest: greatest,
|
||||||
|
End: end.point,
|
||||||
|
Complete: outerStart > searchStart && outerEnd < searchEnd,
|
||||||
|
CenterLine: centerLine,
|
||||||
|
NorthernLimit: occultationPathWithEndpoints(start.point, end.point, northern),
|
||||||
|
SouthernLimit: occultationPathWithEndpoints(start.point, end.point, southern),
|
||||||
|
Step: options.Step,
|
||||||
|
TargetSpacingKM: options.TargetSpacingKM,
|
||||||
|
}
|
||||||
|
path.PartialFootprints = planetOccultationFootprints(
|
||||||
|
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)
|
||||||
|
if !totalStart.valid || !totalEnd.valid || !totalWidthOK || totalWidth <= 0 {
|
||||||
|
return path, true, nil
|
||||||
|
}
|
||||||
|
totalNorthern, totalSouthern := occultationPathBoundarySamplesForFrame(
|
||||||
|
totalStartTT, totalEndTT, greatestTT, totalFrameAt, options, location,
|
||||||
|
)
|
||||||
|
if len(totalNorthern) == 0 || len(totalSouthern) == 0 {
|
||||||
|
return path, true, nil
|
||||||
|
}
|
||||||
|
path.HasTotalBand = true
|
||||||
|
path.TotalStart = totalStart.point
|
||||||
|
path.TotalEnd = totalEnd.point
|
||||||
|
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.GreatestTotalWidthKM = totalWidth
|
||||||
|
return path, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationPathTemporalSampleCount(
|
||||||
|
outerStartTT, outerEndTT float64,
|
||||||
|
centerStartTT, centerEndTT float64,
|
||||||
|
hasCenter bool,
|
||||||
|
totalStartTT, totalEndTT float64,
|
||||||
|
hasTotal bool,
|
||||||
|
greatestTT float64,
|
||||||
|
options OccultationPathOptions,
|
||||||
|
) 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 hasCenter {
|
||||||
|
count += len(occultationPathSampleTimes(centerStartTT, centerEndTT, greatestTT, stepDays))
|
||||||
|
}
|
||||||
|
if hasTotal {
|
||||||
|
count += len(occultationPathSampleTimes(totalStartTT, totalEndTT, greatestTT, stepDays))
|
||||||
|
count += len(occultationPathSampleTimesWithLimit(
|
||||||
|
totalStartTT, totalEndTT, greatestTT, stepDays, planetOccultationFootprintMaxSamples,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathWindowForFrame(
|
||||||
|
seedTT, startTT, endTT float64,
|
||||||
|
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 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if center {
|
||||||
|
_, _, ok = occultationEarthLineIntersection(frame.moon, frame.axis)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationPathSamples(
|
||||||
|
outerStartTT, outerEndTT float64,
|
||||||
|
centerStartTT, centerEndTT float64,
|
||||||
|
hasCenter bool,
|
||||||
|
greatestTT float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
options OccultationPathOptions,
|
||||||
|
location *time.Location,
|
||||||
|
) ([]OccultationPathPoint, []OccultationPathPoint, []OccultationPathPoint, error) {
|
||||||
|
var centerLine []OccultationPathPoint
|
||||||
|
if hasCenter {
|
||||||
|
var err error
|
||||||
|
centerLine, err = occultationPathCenterSamplesForFrame(centerStartTT, centerEndTT, greatestTT, frameAt, options, location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
northern, southern := occultationPathBoundarySamplesForFrame(
|
||||||
|
outerStartTT, outerEndTT, greatestTT, frameAt, options, location,
|
||||||
|
)
|
||||||
|
return centerLine, northern, southern, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathBoundarySamplesForFrame(
|
||||||
|
startTT, endTT, greatestTT float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
options OccultationPathOptions,
|
||||||
|
location *time.Location,
|
||||||
|
) ([]OccultationPathPoint, []OccultationPathPoint) {
|
||||||
|
stepDays := float64(options.Step) / float64(24*time.Hour)
|
||||||
|
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
|
||||||
|
samples := make([]occultationPathBoundaryPairSample, 0, len(times))
|
||||||
|
for _, tt := range times {
|
||||||
|
firstVector, secondVector, ok := occultationPathCrossTrackLimitsForFrame(tt, frameAt)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sample := occultationPathBoundaryPairSample{tt: tt, first: firstVector, second: secondVector}
|
||||||
|
if len(samples) == 0 {
|
||||||
|
samples = append(samples, sample)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
samples = appendOccultationPathBoundaryPairSegment(samples, samples[len(samples)-1], sample, frameAt, 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)
|
||||||
|
}
|
||||||
|
return occultationPathOrientBoundarySamples(first, second, greatestTT)
|
||||||
|
}
|
||||||
|
|
||||||
|
type occultationPathBoundaryPairSample struct {
|
||||||
|
tt float64
|
||||||
|
first, second occultationPathVector
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOccultationPathBoundaryPairSegment(
|
||||||
|
samples []occultationPathBoundaryPairSample,
|
||||||
|
start, end occultationPathBoundaryPairSample,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
depth int,
|
||||||
|
) []occultationPathBoundaryPairSample {
|
||||||
|
if depth >= occultationPathMaxAdaptiveDepth ||
|
||||||
|
occultationPathBoundaryPairSpacing(start, end) <= occultationPathBoundarySpacingKM {
|
||||||
|
return append(samples, end)
|
||||||
|
}
|
||||||
|
midTT := (start.tt + end.tt) / 2
|
||||||
|
first, second, ok := occultationPathCrossTrackLimitsForFrame(midTT, frameAt)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
direct := math.Max(
|
||||||
|
occultationPathNorm(occultationPathSub(secondA, firstA)),
|
||||||
|
occultationPathNorm(occultationPathSub(secondB, firstB)),
|
||||||
|
)
|
||||||
|
swapped := math.Max(
|
||||||
|
occultationPathNorm(occultationPathSub(secondB, firstA)),
|
||||||
|
occultationPathNorm(occultationPathSub(secondA, firstB)),
|
||||||
|
)
|
||||||
|
return math.Min(direct, swapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathCrossTrackLimitsForFrame(
|
||||||
|
tt float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
) (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
|
||||||
|
}
|
||||||
|
vx := after.moonProjectionX() - before.moonProjectionX()
|
||||||
|
vy := after.moonProjectionY() - before.moonProjectionY()
|
||||||
|
if math.Hypot(vx, vy) <= 1e-12 {
|
||||||
|
return occultationPathVector{}, occultationPathVector{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复用日食中心线构造:取基准面中垂直于运动方向的两条影锥母线。点源掠过阶段将缺失母线限制到可见角度区间;有限目标使用最近可见区间两端,直到两条横向母线分别与地球相交。
|
||||||
|
// Match the solar-eclipse central-path construction: take the two shadow generators perpendicular to motion in the fundamental plane. During a grazing point-source phase, clamp a missing generator to the visible-angle interval. For a finite target, use both ends of the nearest visible interval until the two cross-track generators intersect Earth independently.
|
||||||
|
theta := math.Atan2(vx, -vy)
|
||||||
|
first, _, firstOK := occultationPathBoundaryVector(frame, theta)
|
||||||
|
second, _, secondOK := occultationPathBoundaryVector(frame, theta+math.Pi)
|
||||||
|
if frame.targetRadius == 0 {
|
||||||
|
if !firstOK {
|
||||||
|
first, firstOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta)
|
||||||
|
}
|
||||||
|
if !secondOK {
|
||||||
|
second, secondOK = occultationPathBoundaryAtNearestPointSourceTheta(frame, theta+math.Pi)
|
||||||
|
}
|
||||||
|
if !firstOK || !secondOK {
|
||||||
|
return occultationPathVector{}, occultationPathVector{}, false
|
||||||
|
}
|
||||||
|
return first, second, true
|
||||||
|
}
|
||||||
|
intervals := occultationPathBoundaryThetaIntervals(frame)
|
||||||
|
interval, intervalOK := occultationPathNearestThetaInterval(intervals, theta)
|
||||||
|
if firstOK && secondOK {
|
||||||
|
if intervalOK && occultationPathAngleDistance(interval.left, theta) > occultationPathAngleDistance(interval.left, theta+math.Pi) {
|
||||||
|
first, second = second, first
|
||||||
|
}
|
||||||
|
return first, second, true
|
||||||
|
}
|
||||||
|
if !intervalOK {
|
||||||
|
return occultationPathVector{}, occultationPathVector{}, false
|
||||||
|
}
|
||||||
|
first, _, firstOK = occultationPathBoundaryVector(frame, interval.left)
|
||||||
|
second, _, secondOK = occultationPathBoundaryVector(frame, interval.right)
|
||||||
|
if !firstOK || !secondOK {
|
||||||
|
return occultationPathVector{}, occultationPathVector{}, false
|
||||||
|
}
|
||||||
|
return first, second, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathAngleDistance(first, second float64) float64 {
|
||||||
|
return math.Abs(math.Remainder(first-second, 2*math.Pi))
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathBoundaryAtNearestPointSourceTheta(
|
||||||
|
frame occultationPathFrame,
|
||||||
|
theta float64,
|
||||||
|
) (occultationPathVector, bool) {
|
||||||
|
_, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame)
|
||||||
|
if !tangentOK {
|
||||||
|
return occultationPathVector{}, false
|
||||||
|
}
|
||||||
|
left, right, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta)
|
||||||
|
if !intervalOK {
|
||||||
|
return occultationPathVector{}, false
|
||||||
|
}
|
||||||
|
middle := (left + right) / 2
|
||||||
|
theta += 2 * math.Pi * math.Round((middle-theta)/(2*math.Pi))
|
||||||
|
if theta < left {
|
||||||
|
theta = left
|
||||||
|
} else if theta > right {
|
||||||
|
theta = right
|
||||||
|
}
|
||||||
|
point, _, ok := occultationPathBoundaryVector(frame, theta)
|
||||||
|
return point, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type occultationPathThetaInterval struct {
|
||||||
|
left, right float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathNearestThetaInterval(
|
||||||
|
intervals []occultationPathThetaInterval,
|
||||||
|
theta float64,
|
||||||
|
) (occultationPathThetaInterval, bool) {
|
||||||
|
var closest occultationPathThetaInterval
|
||||||
|
closestDistance := math.Inf(1)
|
||||||
|
for _, interval := range intervals {
|
||||||
|
middle := (interval.left + interval.right) / 2
|
||||||
|
firstDelta := math.Abs(math.Remainder(theta-middle, 2*math.Pi))
|
||||||
|
secondDelta := math.Abs(math.Remainder(theta+math.Pi-middle, 2*math.Pi))
|
||||||
|
distance := math.Min(firstDelta, secondDelta)
|
||||||
|
if distance < closestDistance {
|
||||||
|
closest = interval
|
||||||
|
closestDistance = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !finite(closestDistance) {
|
||||||
|
return occultationPathThetaInterval{}, false
|
||||||
|
}
|
||||||
|
return closest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathBoundaryThetaIntervals(frame occultationPathFrame) []occultationPathThetaInterval {
|
||||||
|
step := 2 * math.Pi / float64(occultationPathBoundaryScanPoints)
|
||||||
|
discriminants := make([]float64, occultationPathBoundaryScanPoints)
|
||||||
|
for index := range discriminants {
|
||||||
|
discriminant, _, _, ok := occultationPathBoundaryLine(frame, step*float64(index))
|
||||||
|
if !ok {
|
||||||
|
discriminant = math.Inf(-1)
|
||||||
|
}
|
||||||
|
discriminants[index] = discriminant
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals := make([]occultationPathThetaInterval, 0, 2)
|
||||||
|
for index, value := range discriminants {
|
||||||
|
previous := discriminants[(index+len(discriminants)-1)%len(discriminants)]
|
||||||
|
next := discriminants[(index+1)%len(discriminants)]
|
||||||
|
if value < previous || value < next {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
theta := occultationPathRefineBoundaryMaximum(frame, step*float64(index), step)
|
||||||
|
discriminant, b, scale, ok := occultationPathBoundaryLine(frame, theta)
|
||||||
|
tolerance := 1e-12 * math.Max(scale, 1)
|
||||||
|
if !ok || b >= 0 || discriminant < -tolerance {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
left, right, intervalOK := occultationPathBoundaryThetaInterval(frame, theta)
|
||||||
|
if intervalOK {
|
||||||
|
intervals = append(intervals, occultationPathThetaInterval{left: left, right: right})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathRefineBoundaryMaximum(frame occultationPathFrame, center, step float64) float64 {
|
||||||
|
left := center - step
|
||||||
|
right := center + step
|
||||||
|
const goldenRatio = 0.6180339887498949
|
||||||
|
x1 := right - goldenRatio*(right-left)
|
||||||
|
x2 := left + goldenRatio*(right-left)
|
||||||
|
f1, _, _, _ := occultationPathBoundaryLine(frame, x1)
|
||||||
|
f2, _, _, _ := occultationPathBoundaryLine(frame, x2)
|
||||||
|
for iteration := 0; iteration < 40; iteration++ {
|
||||||
|
if f1 < f2 {
|
||||||
|
left = x1
|
||||||
|
x1, f1 = x2, f2
|
||||||
|
x2 = left + goldenRatio*(right-left)
|
||||||
|
f2, _, _, _ = occultationPathBoundaryLine(frame, x2)
|
||||||
|
} else {
|
||||||
|
right = x2
|
||||||
|
x2, f2 = x1, f1
|
||||||
|
x1 = right - goldenRatio*(right-left)
|
||||||
|
f1, _, _, _ = occultationPathBoundaryLine(frame, x1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (left + right) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathOrientBoundarySamples(
|
||||||
|
first, second []OccultationPathPoint,
|
||||||
|
greatestTT float64,
|
||||||
|
) ([]OccultationPathPoint, []OccultationPathPoint) {
|
||||||
|
if len(first) == 0 || len(first) != len(second) {
|
||||||
|
return first, second
|
||||||
|
}
|
||||||
|
nearest := 0
|
||||||
|
nearestDelta := math.Inf(1)
|
||||||
|
for index := range first {
|
||||||
|
delta := math.Abs(centerTimeTT(first[index].Time) - greatestTT)
|
||||||
|
if delta < nearestDelta {
|
||||||
|
nearest = index
|
||||||
|
nearestDelta = delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if first[nearest].Latitude >= second[nearest].Latitude {
|
||||||
|
return first, second
|
||||||
|
}
|
||||||
|
return second, first
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathCenterSamplesForFrame(
|
||||||
|
startTT, endTT, greatestTT float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
options OccultationPathOptions,
|
||||||
|
location *time.Location,
|
||||||
|
) ([]OccultationPathPoint, error) {
|
||||||
|
stepDays := float64(options.Step) / float64(24*time.Hour)
|
||||||
|
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
|
||||||
|
points := make([]OccultationPathPoint, 0, len(times))
|
||||||
|
for _, tt := range times {
|
||||||
|
point, ok := occultationPathCenterPointForFrame(tt, frameAt, location)
|
||||||
|
if ok {
|
||||||
|
points = append(points, point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if options.TargetSpacingKM > 0 {
|
||||||
|
return refineOccultationPathSpacingForFrame(points, frameAt, options.TargetSpacingKM, location)
|
||||||
|
}
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refineOccultationPathSpacingForFrame(
|
||||||
|
points []OccultationPathPoint,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
targetSpacingKM float64,
|
||||||
|
location *time.Location,
|
||||||
|
) ([]OccultationPathPoint, error) {
|
||||||
|
if len(points) < 2 || targetSpacingKM <= 0 {
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
refined := make([]OccultationPathPoint, 0, len(points))
|
||||||
|
refined = append(refined, points[0])
|
||||||
|
widthAt := func(tt float64) (float64, bool) {
|
||||||
|
_, _, width, ok := occultationPathLimitsAndWidthForFrame(tt, frameAt)
|
||||||
|
return width, ok
|
||||||
|
}
|
||||||
|
for i := 1; i < len(points); i++ {
|
||||||
|
segmentStart := len(refined) - 1
|
||||||
|
var err error
|
||||||
|
refined, err = appendOccultationPathSegmentForFrame(refined, points[i-1], points[i], frameAt, targetSpacingKM, location, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refineOccultationPathWidths(refined[segmentStart:], widthAt)
|
||||||
|
}
|
||||||
|
return refined, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOccultationPathSegmentForFrame(
|
||||||
|
points []OccultationPathPoint,
|
||||||
|
start, end OccultationPathPoint,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
targetSpacingKM float64,
|
||||||
|
location *time.Location,
|
||||||
|
depth int,
|
||||||
|
) ([]OccultationPathPoint, error) {
|
||||||
|
distance := occultationPathDistanceKM(start, end)
|
||||||
|
if distance <= targetSpacingKM {
|
||||||
|
if len(points) >= occultationPathMaxSampleCount {
|
||||||
|
return nil, ErrOccultationPathSamplingLimit
|
||||||
|
}
|
||||||
|
return append(points, end), nil
|
||||||
|
}
|
||||||
|
if depth >= occultationPathMaxAdaptiveDepth || len(points) >= occultationPathMaxSampleCount {
|
||||||
|
return nil, ErrOccultationPathSamplingLimit
|
||||||
|
}
|
||||||
|
midTT := (centerTimeTT(start.Time) + centerTimeTT(end.Time)) / 2
|
||||||
|
mid, ok := occultationPathCenterPointForFrameWithoutWidth(midTT, frameAt, location)
|
||||||
|
if !ok {
|
||||||
|
return append(points, end), nil
|
||||||
|
}
|
||||||
|
mid.WidthKM = (start.WidthKM + end.WidthKM) / 2
|
||||||
|
var err error
|
||||||
|
points, err = appendOccultationPathSegmentForFrame(points, start, mid, frameAt, targetSpacingKM, location, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return appendOccultationPathSegmentForFrame(points, mid, end, frameAt, targetSpacingKM, location, depth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationContactPathFrameAt(tt, config, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationTotalPathFrameAt(tt float64, config planetOccultationConfig) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationContactPathFrameAt(tt, config, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func planetOccultationContactPathFrameAt(tt float64, config planetOccultationConfig, total bool) (occultationPathFrame, bool) {
|
||||||
|
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 {
|
||||||
|
return occultationPathFrame{}, false
|
||||||
|
}
|
||||||
|
moon := occultationPathRaDecVector(moonRA, moonDec, moonDistance)
|
||||||
|
target := occultationPathRaDecVector(planetRA, planetDec, planetDistance)
|
||||||
|
moonToTarget := occultationPathSub(target, moon)
|
||||||
|
moonToTargetDistance := occultationPathNorm(moonToTarget)
|
||||||
|
moonRadius := MoonSemidiameter(tt) * math.Pi / (180 * 3600)
|
||||||
|
moonRadiusKM := occultationPathNorm(moon) * math.Sin(moonRadius)
|
||||||
|
contactRadiusKM := moonRadiusKM + config.equatorialRadiusKM
|
||||||
|
if total {
|
||||||
|
contactRadiusKM = moonRadiusKM - config.equatorialRadiusKM
|
||||||
|
}
|
||||||
|
if moonToTargetDistance <= math.Abs(contactRadiusKM) {
|
||||||
|
return occultationPathFrame{}, false
|
||||||
|
}
|
||||||
|
axis := occultationPathUnit(occultationPathScale(moonToTarget, -1))
|
||||||
|
north := occultationPathVector{z: 1}
|
||||||
|
first := occultationPathCross(north, axis)
|
||||||
|
if occultationPathNorm(first) < 1e-12 {
|
||||||
|
first = occultationPathCross(occultationPathVector{x: 1}, axis)
|
||||||
|
}
|
||||||
|
first = occultationPathUnit(first)
|
||||||
|
second := occultationPathUnit(occultationPathCross(axis, first))
|
||||||
|
return occultationPathFrame{
|
||||||
|
moon: moon,
|
||||||
|
axis: axis,
|
||||||
|
first: first,
|
||||||
|
second: second,
|
||||||
|
moonRadius: moonRadius,
|
||||||
|
targetRadius: math.Asin(contactRadiusKM / moonToTargetDistance),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathFrameHasBoundary(frame occultationPathFrame) bool {
|
||||||
|
_, _, ok := occultationPathBoundaryTangent(frame)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathBoundaryEndpointForFrame(
|
||||||
|
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*offset)*0.5/86400.0
|
||||||
|
frame, ok := frameAt(candidateTT)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vector, _, valid := occultationPathBoundaryTangent(frame)
|
||||||
|
if valid {
|
||||||
|
return occultationPathEndpoint{point: occultationPathPointFromVector(candidateTT, vector, 0, location), valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return occultationPathEndpoint{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathCenterPointForFrame(
|
||||||
|
tt float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
location *time.Location,
|
||||||
|
) (OccultationPathPoint, bool) {
|
||||||
|
frame, ok := frameAt(tt)
|
||||||
|
if !ok {
|
||||||
|
return OccultationPathPoint{}, false
|
||||||
|
}
|
||||||
|
point, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
|
||||||
|
if !ok {
|
||||||
|
return OccultationPathPoint{}, false
|
||||||
|
}
|
||||||
|
width := 0.0
|
||||||
|
if _, _, tangentWidth, limitsOK := occultationPathLimitsAndWidthForFrame(tt, frameAt); limitsOK {
|
||||||
|
width = tangentWidth
|
||||||
|
}
|
||||||
|
return occultationPathPointFromVectorWithMoon(tt, point, width, frame.moon, location), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathCenterPointForFrameWithoutWidth(
|
||||||
|
tt float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
location *time.Location,
|
||||||
|
) (OccultationPathPoint, bool) {
|
||||||
|
frame, ok := frameAt(tt)
|
||||||
|
if !ok {
|
||||||
|
return OccultationPathPoint{}, false
|
||||||
|
}
|
||||||
|
point, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
|
||||||
|
if !ok {
|
||||||
|
return OccultationPathPoint{}, false
|
||||||
|
}
|
||||||
|
return occultationPathPointFromVectorWithMoon(tt, point, 0, frame.moon, location), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathBoundaryPointForFrame(
|
||||||
|
tt float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
location *time.Location,
|
||||||
|
) (OccultationPathPoint, bool) {
|
||||||
|
frame, ok := frameAt(tt)
|
||||||
|
if !ok {
|
||||||
|
return OccultationPathPoint{}, false
|
||||||
|
}
|
||||||
|
point, _, ok := occultationPathBoundaryTangent(frame)
|
||||||
|
if !ok {
|
||||||
|
return OccultationPathPoint{}, false
|
||||||
|
}
|
||||||
|
north, south, limitsOK := occultationPathScannedLimitsAtFrame(tt, frame)
|
||||||
|
return occultationPathPointFromVector(tt, point, occultationPathBoundaryWidth(north, south, limitsOK), location), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathLimitsAndWidthForFrame(
|
||||||
|
tt float64,
|
||||||
|
frameAt occultationPathFrameFunc,
|
||||||
|
) (occultationPathVector, occultationPathVector, float64, bool) {
|
||||||
|
frame, ok := frameAt(tt)
|
||||||
|
if !ok {
|
||||||
|
return occultationPathVector{}, occultationPathVector{}, 0, false
|
||||||
|
}
|
||||||
|
beforeFrame, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
|
||||||
|
afterFrame, afterOK := frameAt(tt + occultationPathVelocityStepDays)
|
||||||
|
if !beforeOK || !afterOK {
|
||||||
|
north, south, scannedOK := occultationPathScannedLimitsForFrame(tt, frame)
|
||||||
|
return north, south, occultationPathBoundaryWidth(north, south, scannedOK), scannedOK
|
||||||
|
}
|
||||||
|
|
||||||
|
vx := afterFrame.moonProjectionX() - beforeFrame.moonProjectionX()
|
||||||
|
vy := afterFrame.moonProjectionY() - beforeFrame.moonProjectionY()
|
||||||
|
speed := math.Hypot(vx, vy)
|
||||||
|
if speed <= 1e-12 {
|
||||||
|
north, south, scannedOK := occultationPathScannedLimitsForFrame(tt, frame)
|
||||||
|
return north, south, occultationPathBoundaryWidth(north, south, scannedOK), scannedOK
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if centerOK && beforeCenterOK && afterCenterOK {
|
||||||
|
centerFixed = occultationPathEarthFixedVector(tt, center)
|
||||||
|
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 := math.Inf(1)
|
||||||
|
maximumOffset := math.Inf(-1)
|
||||||
|
minimumGroundOffset := math.Inf(1)
|
||||||
|
maximumGroundOffset := math.Inf(-1)
|
||||||
|
var minimumPoint, maximumPoint occultationPathVector
|
||||||
|
var minimumGroundPoint, maximumGroundPoint occultationPathVector
|
||||||
|
consider := func(point occultationPathVector) {
|
||||||
|
offset := occultationPathDot(point, planeCrossTrack)
|
||||||
|
if offset < minimumOffset {
|
||||||
|
minimumOffset = offset
|
||||||
|
minimumPoint = point
|
||||||
|
}
|
||||||
|
if offset > maximumOffset {
|
||||||
|
maximumOffset = offset
|
||||||
|
maximumPoint = point
|
||||||
|
}
|
||||||
|
if groundWidthOK {
|
||||||
|
fixed := occultationPathEarthFixedVector(tt, point)
|
||||||
|
groundOffset := occultationPathDot(occultationPathSub(fixed, centerFixed), groundCrossTrack)
|
||||||
|
if groundOffset < minimumGroundOffset {
|
||||||
|
minimumGroundOffset = groundOffset
|
||||||
|
minimumGroundPoint = point
|
||||||
|
}
|
||||||
|
if groundOffset > maximumGroundOffset {
|
||||||
|
maximumGroundOffset = groundOffset
|
||||||
|
maximumGroundPoint = point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tangentPoint, tangentTheta, tangentOK := occultationPathBoundaryTangent(frame); tangentOK {
|
||||||
|
consider(tangentPoint)
|
||||||
|
if leftTheta, rightTheta, intervalOK := occultationPathBoundaryThetaInterval(frame, tangentTheta); intervalOK {
|
||||||
|
const intervalSamples = 128
|
||||||
|
for i := 0; i <= intervalSamples; i++ {
|
||||||
|
theta := leftTheta + (rightTheta-leftTheta)*float64(i)/intervalSamples
|
||||||
|
if point, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
|
||||||
|
consider(point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < occultationPathBoundaryScanPoints; i++ {
|
||||||
|
point, _, pointOK := occultationPathBoundaryVector(frame, 2*math.Pi*float64(i)/float64(occultationPathBoundaryScanPoints))
|
||||||
|
if !pointOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consider(point)
|
||||||
|
}
|
||||||
|
if !finite(minimumOffset) || !finite(maximumOffset) {
|
||||||
|
return occultationPathVector{}, occultationPathVector{}, 0, false
|
||||||
|
}
|
||||||
|
width := maximumOffset - minimumOffset
|
||||||
|
if groundWidthOK && finite(minimumGroundOffset) && finite(maximumGroundOffset) {
|
||||||
|
width = maximumGroundOffset - minimumGroundOffset
|
||||||
|
// 有限目标会把月影打开或收束成圆锥;接近地平线时其影面投影可能折叠,使全球投影极值在远处地平线交点间跳变。地面轨迹极值仍位于掩带的同一物理侧。
|
||||||
|
// A finite target opens or closes the lunar shadow into a cone. Near the horizon its shadow-plane projection can fold, causing the global projected extremum to jump between distant horizon intersections. Ground-track extrema remain on the same physical sides of the band.
|
||||||
|
if frame.targetRadius != 0 {
|
||||||
|
return maximumGroundPoint, minimumGroundPoint, width, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, minimumLatitude := occultationPathGeodetic(tt, minimumPoint)
|
||||||
|
_, maximumLatitude := occultationPathGeodetic(tt, maximumPoint)
|
||||||
|
if maximumLatitude >= minimumLatitude {
|
||||||
|
return maximumPoint, minimumPoint, width, true
|
||||||
|
}
|
||||||
|
return minimumPoint, maximumPoint, width, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathScannedLimitsForFrame(
|
||||||
|
tt float64,
|
||||||
|
frame occultationPathFrame,
|
||||||
|
) (occultationPathVector, occultationPathVector, bool) {
|
||||||
|
return occultationPathScannedLimitsAtFrame(tt, frame)
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanetOccultationFiniteDiskExpandsOuterAndContractsTotalPath(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))
|
||||||
|
frameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationPathFrameAt(tt, config)
|
||||||
|
}
|
||||||
|
_, _, finiteWidth, finiteOK := occultationPathLimitsAndWidthForFrame(tt, frameAt)
|
||||||
|
if !finiteOK {
|
||||||
|
t.Fatal("finite-disk path limits are unavailable")
|
||||||
|
}
|
||||||
|
pointFrameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
frame, valid := planetOccultationPathFrameAt(tt, config)
|
||||||
|
frame.targetRadius = 0
|
||||||
|
return frame, valid
|
||||||
|
}
|
||||||
|
_, _, pointWidth, pointOK := occultationPathLimitsAndWidthForFrame(tt, pointFrameAt)
|
||||||
|
if !pointOK {
|
||||||
|
t.Fatal("point-source comparison limits are unavailable")
|
||||||
|
}
|
||||||
|
if finiteWidth <= pointWidth {
|
||||||
|
t.Fatalf("finite-disk outer width = %.6f km, want greater than point-source width %.6f km", finiteWidth, pointWidth)
|
||||||
|
}
|
||||||
|
if finiteWidth-pointWidth < 1 {
|
||||||
|
t.Fatalf("finite-disk expansion = %.6f km, want a measurable planetary-radius contribution", finiteWidth-pointWidth)
|
||||||
|
}
|
||||||
|
innerFrameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationTotalPathFrameAt(tt, config)
|
||||||
|
}
|
||||||
|
_, _, totalWidth, totalOK := occultationPathLimitsAndWidthForFrame(tt, innerFrameAt)
|
||||||
|
if !totalOK {
|
||||||
|
t.Fatal("finite-disk total-occultation limits are unavailable")
|
||||||
|
}
|
||||||
|
if totalWidth >= pointWidth {
|
||||||
|
t.Fatalf("finite-disk total width = %.6f km, want less than point-source width %.6f km", totalWidth, pointWidth)
|
||||||
|
}
|
||||||
|
if pointWidth-totalWidth < 1 {
|
||||||
|
t.Fatalf("finite-disk contraction = %.6f km, want a measurable planetary-radius contribution", pointWidth-totalWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationConesUseTwoSphereCommonTangents(t *testing.T) {
|
||||||
|
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn occultation config is unavailable")
|
||||||
|
}
|
||||||
|
tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
|
||||||
|
outer, ok := planetOccultationPathFrameAt(tt, config)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn outer-contact cone is unavailable")
|
||||||
|
}
|
||||||
|
inner, ok := planetOccultationTotalPathFrameAt(tt, config)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn inner-contact cone is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
planetRA, planetDec := config.apparentRaDecN(tt, -1)
|
||||||
|
planetDistance := config.earthDistanceN(tt, -1) * occultationPathAstronomicalUnitKM
|
||||||
|
target := occultationPathRaDecVector(planetRA, planetDec, planetDistance)
|
||||||
|
moonToTargetDistance := occultationPathNorm(occultationPathSub(target, outer.moon))
|
||||||
|
moonRadiusKM := occultationPathNorm(outer.moon) * math.Sin(outer.moonRadius)
|
||||||
|
wantOuter := math.Asin((moonRadiusKM + config.equatorialRadiusKM) / moonToTargetDistance)
|
||||||
|
wantInner := math.Asin((moonRadiusKM - config.equatorialRadiusKM) / moonToTargetDistance)
|
||||||
|
if difference := math.Abs(outer.targetRadius - wantOuter); difference > 1e-15 {
|
||||||
|
t.Fatalf("outer-contact cone angle = %.15g rad, want %.15g (difference %.3g)", outer.targetRadius, wantOuter, difference)
|
||||||
|
}
|
||||||
|
if difference := math.Abs(inner.targetRadius - wantInner); difference > 1e-15 {
|
||||||
|
t.Fatalf("inner-contact cone angle = %.15g rad, want %.15g (difference %.3g)", inner.targetRadius, wantInner, difference)
|
||||||
|
}
|
||||||
|
for _, contact := range []struct {
|
||||||
|
name string
|
||||||
|
frame occultationPathFrame
|
||||||
|
}{
|
||||||
|
{name: "outer", frame: outer},
|
||||||
|
{name: "inner", frame: inner},
|
||||||
|
} {
|
||||||
|
origin, direction, rayOK := occultationPathBoundaryRay(contact.frame, 0.73)
|
||||||
|
if !rayOK {
|
||||||
|
t.Fatalf("%s-contact boundary ray is unavailable", contact.name)
|
||||||
|
}
|
||||||
|
moonNormal := occultationPathSub(origin, contact.frame.moon)
|
||||||
|
if difference := math.Abs(occultationPathNorm(moonNormal) - moonRadiusKM); difference > 1e-6 {
|
||||||
|
t.Fatalf("%s-contact lunar tangency radius differs by %.9f km", contact.name, difference)
|
||||||
|
}
|
||||||
|
if residual := math.Abs(occultationPathDot(moonNormal, direction)); residual > 1e-6 {
|
||||||
|
t.Fatalf("%s-contact ray/lunar-radius dot product = %.9f km", contact.name, residual)
|
||||||
|
}
|
||||||
|
targetParameter := occultationPathDot(occultationPathSub(target, origin), direction)
|
||||||
|
targetTangent := occultationPathAdd(origin, occultationPathScale(direction, targetParameter))
|
||||||
|
targetNormal := occultationPathSub(targetTangent, target)
|
||||||
|
if difference := math.Abs(occultationPathNorm(targetNormal) - config.equatorialRadiusKM); difference > 1e-5 {
|
||||||
|
t.Fatalf("%s-contact planetary tangency radius differs by %.9f km", contact.name, difference)
|
||||||
|
}
|
||||||
|
if residual := math.Abs(occultationPathDot(targetNormal, direction)); residual > 1e-5 {
|
||||||
|
t.Fatalf("%s-contact ray/planet-radius dot product = %.9f km", contact.name, residual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationInnerConeUsesSignedTargetRadius(t *testing.T) {
|
||||||
|
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn occultation config is unavailable")
|
||||||
|
}
|
||||||
|
tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
|
||||||
|
frame, ok := planetOccultationTotalPathFrameAt(tt, config)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn inner-contact cone is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := 0; index < occultationPathBoundaryScanPoints; index++ {
|
||||||
|
theta := 2 * math.Pi * float64(index) / float64(occultationPathBoundaryScanPoints)
|
||||||
|
want, _, wantOK := occultationPathBoundaryVector(frame, theta)
|
||||||
|
if !wantOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discriminant, _, scale, lineOK := occultationPathBoundaryLine(frame, theta)
|
||||||
|
if !lineOK || discriminant < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, _, gotOK := occultationPathBoundaryIntersection(frame, theta, 1e-12*math.Max(scale, 1))
|
||||||
|
if !gotOK {
|
||||||
|
t.Fatalf("signed inner-cone intersection is unavailable at theta %.9f", theta)
|
||||||
|
}
|
||||||
|
if difference := occultationPathNorm(occultationPathSub(got, want)); difference > 1e-6 {
|
||||||
|
t.Fatalf("inner-cone intersection differs by %.6f km at theta %.9f", difference, theta)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("no comparable Saturn inner-cone boundary point found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOccultationPathBoundaryTangentFindsBetweenSamples(t *testing.T) {
|
||||||
|
const boundaryRadiusKM = 1737.4
|
||||||
|
theta := math.Pi / float64(occultationPathBoundaryScanPoints)
|
||||||
|
offset := occultationPathEarthEquatorialRadiusKM + boundaryRadiusKM - 0.01
|
||||||
|
moon := occultationPathVector{
|
||||||
|
x: 384000,
|
||||||
|
y: -offset * math.Cos(theta),
|
||||||
|
z: -offset * math.Sin(theta),
|
||||||
|
}
|
||||||
|
frame := occultationPathFrame{
|
||||||
|
moon: moon,
|
||||||
|
axis: occultationPathVector{x: -1},
|
||||||
|
first: occultationPathVector{y: 1},
|
||||||
|
second: occultationPathVector{z: 1},
|
||||||
|
moonRadius: math.Asin(boundaryRadiusKM / occultationPathNorm(moon)),
|
||||||
|
}
|
||||||
|
for _, sampledTheta := range []float64{0, 2 * math.Pi / float64(occultationPathBoundaryScanPoints)} {
|
||||||
|
if _, _, ok := occultationPathBoundaryVector(frame, sampledTheta); ok {
|
||||||
|
t.Fatalf("fixture is not narrower than the old sample spacing at theta %.9f", sampledTheta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
point, tangentTheta, ok := occultationPathBoundaryTangent(frame)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("continuous boundary tangency was not found between scan points")
|
||||||
|
}
|
||||||
|
if math.Abs(tangentTheta-theta) > 5e-5 {
|
||||||
|
t.Fatalf("tangent theta = %.9f, want %.9f", tangentTheta, theta)
|
||||||
|
}
|
||||||
|
polarRatioSquared := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
|
||||||
|
ellipsoidResidual := point.x*point.x + point.y*point.y + point.z*point.z/polarRatioSquared -
|
||||||
|
occultationPathEarthEquatorialRadiusKM*occultationPathEarthEquatorialRadiusKM
|
||||||
|
if math.Abs(ellipsoidResidual) > 1e-3 {
|
||||||
|
t.Fatalf("tangent point ellipsoid residual = %.9f", ellipsoidResidual)
|
||||||
|
}
|
||||||
|
frameAt := func(float64) (occultationPathFrame, bool) { return frame, true }
|
||||||
|
if _, _, centerOK := occultationEarthLineIntersection(frame.moon, frame.axis); centerOK {
|
||||||
|
t.Fatal("synthetic center line unexpectedly intersects Earth")
|
||||||
|
}
|
||||||
|
north, south, width, limitsOK := occultationPathLimitsAndWidthForFrame(2451545, frameAt)
|
||||||
|
if !limitsOK {
|
||||||
|
t.Fatal("boundary-only event did not produce path limits")
|
||||||
|
}
|
||||||
|
if separation := occultationPathNorm(occultationPathSub(north, south)); separation <= 1e-6 {
|
||||||
|
t.Fatalf("boundary-only path limits collapsed to one point: separation=%.12f km", separation)
|
||||||
|
}
|
||||||
|
if width <= 0 {
|
||||||
|
t.Fatalf("boundary-only path width = %.12f km, want positive", width)
|
||||||
|
}
|
||||||
|
greatest, greatestOK := occultationPathBoundaryPointForFrame(2451545, frameAt, time.UTC)
|
||||||
|
if !greatestOK {
|
||||||
|
t.Fatal("boundary-only event did not produce a greatest surface point")
|
||||||
|
}
|
||||||
|
if greatest.WidthKM <= 0 {
|
||||||
|
t.Fatalf("boundary-only greatest width = %.12f km, want positive", greatest.WidthKM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationSaturnLimitsRemainContinuous(t *testing.T) {
|
||||||
|
location := time.FixedZone("UTC+8", 8*3600)
|
||||||
|
paths, err := FindPlanetOccultationPaths(
|
||||||
|
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
|
||||||
|
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
|
||||||
|
OccultationSaturn,
|
||||||
|
OccultationPathOptions{Step: 2 * time.Minute},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlanetOccultationPaths() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(paths) != 1 {
|
||||||
|
t.Fatalf("FindPlanetOccultationPaths() returned %d paths, want 1", len(paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, limit := range []struct {
|
||||||
|
name string
|
||||||
|
points []OccultationPathPoint
|
||||||
|
}{
|
||||||
|
{name: "outer northern", points: paths[0].NorthernLimit},
|
||||||
|
{name: "outer southern", points: paths[0].SouthernLimit},
|
||||||
|
{name: "total northern", points: paths[0].NorthernTotalLimit},
|
||||||
|
{name: "total southern", points: paths[0].SouthernTotalLimit},
|
||||||
|
} {
|
||||||
|
for index := 1; index < len(limit.points); index++ {
|
||||||
|
distance := occultationPathDistanceKM(limit.points[index-1], limit.points[index])
|
||||||
|
if distance > 1000 {
|
||||||
|
t.Fatalf("%s limit jumps %.1f km between %v and %v", limit.name, distance,
|
||||||
|
limit.points[index-1].Time, limit.points[index].Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefinedPlanetOccultationCenterLineRespectsWidthTolerance(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
paths, err := FindPlanetOccultationPaths(
|
||||||
|
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||||
|
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 50},
|
||||||
|
)
|
||||||
|
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 occultation config is unavailable")
|
||||||
|
}
|
||||||
|
frameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return planetOccultationPathFrameAt(tt, config)
|
||||||
|
}
|
||||||
|
for index, point := range paths[0].CenterLine {
|
||||||
|
exact, pointOK := occultationPathCenterPointForFrame(centerTimeTT(point.Time), frameAt, time.UTC)
|
||||||
|
if !pointOK {
|
||||||
|
t.Fatalf("exact center point %d is unavailable", index)
|
||||||
|
}
|
||||||
|
if difference := math.Abs(point.WidthKM - exact.WidthKM); difference > occultationPathWidthToleranceKM {
|
||||||
|
t.Fatalf("center point %d width differs from exact value by %.9f km: got %.9f want %.9f",
|
||||||
|
index, difference, point.WidthKM, exact.WidthKM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationSaturnLimitsDoNotDependOnStep(t *testing.T) {
|
||||||
|
location := time.FixedZone("UTC+8", 8*3600)
|
||||||
|
start := time.Date(2024, time.August, 21, 0, 0, 0, 0, location)
|
||||||
|
end := time.Date(2024, time.August, 22, 0, 0, 0, 0, location)
|
||||||
|
fine := findSinglePlanetOccultationPath(t, start, end, 30*time.Second)
|
||||||
|
coarse := findSinglePlanetOccultationPath(t, start, end, 2*time.Minute)
|
||||||
|
|
||||||
|
for _, limits := range []struct {
|
||||||
|
name string
|
||||||
|
fine, coarse []OccultationPathPoint
|
||||||
|
}{
|
||||||
|
{name: "outer northern", fine: fine.NorthernLimit, coarse: coarse.NorthernLimit},
|
||||||
|
{name: "outer southern", fine: fine.SouthernLimit, coarse: coarse.SouthernLimit},
|
||||||
|
{name: "total northern", fine: fine.NorthernTotalLimit, coarse: coarse.NorthernTotalLimit},
|
||||||
|
{name: "total southern", fine: fine.SouthernTotalLimit, coarse: coarse.SouthernTotalLimit},
|
||||||
|
} {
|
||||||
|
assertOccultationPathCommonSamplesEqual(t, limits.name, limits.fine, limits.coarse)
|
||||||
|
for index := 1; index+1 < len(limits.coarse); index++ {
|
||||||
|
paired := coarse.SouthernLimit
|
||||||
|
if strings.HasPrefix(limits.name, "total") {
|
||||||
|
paired = coarse.SouthernTotalLimit
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(limits.name, "southern") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if distance := occultationPathDistanceKM(limits.coarse[index], paired[index]); distance < 0.001 {
|
||||||
|
t.Fatalf("%s and southern limit collapse at %v", limits.name, limits.coarse[index].Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSinglePlanetOccultationPath(t *testing.T, start, end time.Time, step time.Duration) PlanetOccultationPath {
|
||||||
|
t.Helper()
|
||||||
|
paths, err := FindPlanetOccultationPaths(start, end, OccultationSaturn, OccultationPathOptions{Step: step})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlanetOccultationPaths(step=%v) error = %v", step, err)
|
||||||
|
}
|
||||||
|
if len(paths) != 1 {
|
||||||
|
t.Fatalf("FindPlanetOccultationPaths(step=%v) returned %d paths, want 1", step, len(paths))
|
||||||
|
}
|
||||||
|
if !paths[0].HasTotalBand {
|
||||||
|
t.Fatalf("FindPlanetOccultationPaths(step=%v) has no total band", step)
|
||||||
|
}
|
||||||
|
return paths[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOccultationPathCommonSamplesEqual(t *testing.T, name string, fine, coarse []OccultationPathPoint) {
|
||||||
|
t.Helper()
|
||||||
|
matched := 0
|
||||||
|
fineIndex := 0
|
||||||
|
for _, coarsePoint := range coarse[1 : len(coarse)-1] {
|
||||||
|
for fineIndex+1 < len(fine) && fine[fineIndex].Time.Before(coarsePoint.Time.Add(-20*time.Millisecond)) {
|
||||||
|
fineIndex++
|
||||||
|
}
|
||||||
|
nearest := -1
|
||||||
|
nearestDelta := math.Inf(1)
|
||||||
|
for candidateIndex := fineIndex - 2; candidateIndex <= fineIndex+2; candidateIndex++ {
|
||||||
|
if candidateIndex < 0 || candidateIndex >= len(fine) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delta := math.Abs(fine[candidateIndex].Time.Sub(coarsePoint.Time).Seconds())
|
||||||
|
if delta < nearestDelta {
|
||||||
|
nearest = candidateIndex
|
||||||
|
nearestDelta = delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nearest < 0 || nearestDelta > 0.00001 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matched++
|
||||||
|
if distance := occultationPathDistanceKM(fine[nearest], coarsePoint); distance > 5 {
|
||||||
|
t.Fatalf("%s differs by %.1f km at common time %v (sample delta %.6f s)",
|
||||||
|
name, distance, coarsePoint.Time, nearestDelta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched < 10 {
|
||||||
|
t.Fatalf("%s compared only %d common samples, want at least 10", name, matched)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanetOccultationSupportsAllPlanetTargets(t *testing.T) {
|
||||||
|
tt := TD2UT(Date2JDE(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)), true)
|
||||||
|
tests := []struct {
|
||||||
|
planet OccultationPlanet
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{OccultationMercury, "Mercury"},
|
||||||
|
{OccultationVenus, "Venus"},
|
||||||
|
{OccultationMars, "Mars"},
|
||||||
|
{OccultationJupiter, "Jupiter"},
|
||||||
|
{OccultationSaturn, "Saturn"},
|
||||||
|
{OccultationUranus, "Uranus"},
|
||||||
|
{OccultationNeptune, "Neptune"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if err := test.planet.Validate(); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
if test.planet.String() != test.name {
|
||||||
|
t.Fatalf("String() = %q, want %q", test.planet.String(), test.name)
|
||||||
|
}
|
||||||
|
config, ok := planetOccultationConfigFor(test.planet)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("planet occultation config is unavailable")
|
||||||
|
}
|
||||||
|
state := planetOccultationStateAt(tt, config, nil, -1)
|
||||||
|
if !state.valid || state.planetSemidiameter <= 0 || state.moonSemidiameter <= state.planetSemidiameter {
|
||||||
|
t.Fatalf("invalid planet state: %+v", state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationSaturnContactsSolveDynamicDiskMetrics(t *testing.T) {
|
||||||
|
observer := Observer{Longitude: -30.072, Latitude: 16.21}
|
||||||
|
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
|
||||||
|
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
|
||||||
|
results, err := FindPlanetOccultations(
|
||||||
|
start, end, OccultationSaturn,
|
||||||
|
observer.Longitude, observer.Latitude, observer.Height,
|
||||||
|
OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlanetOccultations() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(results))
|
||||||
|
}
|
||||||
|
result := results[0]
|
||||||
|
if result.Type != OccultationTotal || !result.HasInternalContacts || !result.ContactsComplete {
|
||||||
|
t.Fatalf("unexpected event geometry: type=%q internal=%v complete=%v", result.Type, result.HasInternalContacts, result.ContactsComplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn occultation config is unavailable")
|
||||||
|
}
|
||||||
|
contacts := []struct {
|
||||||
|
name string
|
||||||
|
value time.Time
|
||||||
|
internal bool
|
||||||
|
}{
|
||||||
|
{"C1", result.ExternalImmersion, false},
|
||||||
|
{"C2", result.InternalImmersion, true},
|
||||||
|
{"C3", result.InternalEmersion, true},
|
||||||
|
{"C4", result.ExternalEmersion, false},
|
||||||
|
}
|
||||||
|
for _, contact := range contacts {
|
||||||
|
state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &observer, -1)
|
||||||
|
metric := state.externalContactMetric
|
||||||
|
if contact.internal {
|
||||||
|
metric = state.internalContactMetric
|
||||||
|
}
|
||||||
|
if math.Abs(metric) > 0.1 {
|
||||||
|
t.Errorf("%s contact residual = %.6f arcsec, want <= 0.1", contact.name, metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(result.ExternalImmersion.Before(result.InternalImmersion) &&
|
||||||
|
result.InternalImmersion.Before(result.Greatest) &&
|
||||||
|
result.Greatest.Before(result.InternalEmersion) &&
|
||||||
|
result.InternalEmersion.Before(result.ExternalEmersion)) {
|
||||||
|
t.Fatalf("contact order is invalid: %+v", result)
|
||||||
|
}
|
||||||
|
if result.PlanetSemidiameterArcsec <= 0 || result.MoonSemidiameterArcsec <= result.PlanetSemidiameterArcsec {
|
||||||
|
t.Fatalf("invalid dynamic semidiameters: Moon=%.6f planet=%.6f", result.MoonSemidiameterArcsec, result.PlanetSemidiameterArcsec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationUsesStationMoonDistanceForRadius(t *testing.T) {
|
||||||
|
tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 49, 10, 0, time.UTC))
|
||||||
|
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn occultation config is unavailable")
|
||||||
|
}
|
||||||
|
moonRA, _ := HMoonGeocentricApparentRaDecN(tt, -1)
|
||||||
|
subMoonLongitude := normalizeLongitude180(moonRA - ApparentSiderealTime(TD2UT(tt, false))*15)
|
||||||
|
near := Observer{Longitude: subMoonLongitude, Latitude: 0}
|
||||||
|
far := Observer{Longitude: normalizeLongitude180(subMoonLongitude + 180), Latitude: 0}
|
||||||
|
nearState := planetOccultationStateAt(tt, config, &near, -1)
|
||||||
|
farState := planetOccultationStateAt(tt, config, &far, -1)
|
||||||
|
if !nearState.valid || !farState.valid {
|
||||||
|
t.Fatalf("invalid station states: near=%+v far=%+v", nearState, farState)
|
||||||
|
}
|
||||||
|
if nearState.moonSemidiameter <= farState.moonSemidiameter {
|
||||||
|
t.Fatalf("station Moon radius did not follow station distance: near=%.6f far=%.6f", nearState.moonSemidiameter, farState.moonSemidiameter)
|
||||||
|
}
|
||||||
|
if nearState.moonSemidiameter-MoonSemidiameterN(tt, -1) <= 0 ||
|
||||||
|
farState.moonSemidiameter-MoonSemidiameterN(tt, -1) >= 0 {
|
||||||
|
t.Fatalf("station radius does not straddle geocentric radius: near=%.6f geo=%.6f far=%.6f",
|
||||||
|
nearState.moonSemidiameter, MoonSemidiameterN(tt, -1), farState.moonSemidiameter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationUsesStationPlanetDistanceForRadius(t *testing.T) {
|
||||||
|
tt := occultationTimeToTT(time.Date(2024, time.March, 11, 1, 0, 0, 0, time.UTC))
|
||||||
|
config, ok := planetOccultationConfigFor(OccultationMercury)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Mercury occultation config is unavailable")
|
||||||
|
}
|
||||||
|
planetRA, _ := config.apparentRaDecN(tt, -1)
|
||||||
|
subPlanetLongitude := normalizeLongitude180(planetRA - ApparentSiderealTime(TD2UT(tt, false))*15)
|
||||||
|
near := Observer{Longitude: subPlanetLongitude, Latitude: 0}
|
||||||
|
far := Observer{Longitude: normalizeLongitude180(subPlanetLongitude + 180), Latitude: 0}
|
||||||
|
nearState := planetOccultationStateAt(tt, config, &near, -1)
|
||||||
|
farState := planetOccultationStateAt(tt, config, &far, -1)
|
||||||
|
if !nearState.valid || !farState.valid {
|
||||||
|
t.Fatalf("invalid station states: near=%+v far=%+v", nearState, farState)
|
||||||
|
}
|
||||||
|
if nearState.planetSemidiameter <= farState.planetSemidiameter {
|
||||||
|
t.Fatalf("station planet radius did not follow station distance: near=%.12f far=%.12f",
|
||||||
|
nearState.planetSemidiameter, farState.planetSemidiameter)
|
||||||
|
}
|
||||||
|
geocentric := config.semidiameterN(tt, -1)
|
||||||
|
if nearState.planetSemidiameter <= geocentric || farState.planetSemidiameter >= geocentric {
|
||||||
|
t.Fatalf("station planet radius does not straddle geocentric radius: near=%.12f geo=%.12f far=%.12f",
|
||||||
|
nearState.planetSemidiameter, geocentric, farState.planetSemidiameter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationBestObserverRefinesDynamicMetric(t *testing.T) {
|
||||||
|
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Saturn occultation config is unavailable")
|
||||||
|
}
|
||||||
|
startTT := occultationTimeToTT(time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC))
|
||||||
|
endTT := occultationTimeToTT(time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC))
|
||||||
|
seedTT := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 49, 10, 0, time.UTC))
|
||||||
|
bestTT, observer, _, bestOK := planetOccultationBestObserver(seedTT, startTT, endTT, config)
|
||||||
|
if !bestOK {
|
||||||
|
t.Fatal("best-observer search failed")
|
||||||
|
}
|
||||||
|
metric := planetOccultationExternalContactMetric(bestTT, config, &observer, -1)
|
||||||
|
for _, deltaSeconds := range []float64{-0.1, 0.1} {
|
||||||
|
neighbor := planetOccultationExternalContactMetric(bestTT+deltaSeconds/86400, config, &observer, -1)
|
||||||
|
if metric > neighbor+1e-6 {
|
||||||
|
t.Fatalf("best time does not minimize the dynamic metric: center=%.12f neighbor(%+.1fs)=%.12f",
|
||||||
|
metric, deltaSeconds, neighbor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationInnerPlanetContactsUseDynamicTargets(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
planet OccultationPlanet
|
||||||
|
start time.Time
|
||||||
|
end time.Time
|
||||||
|
}{
|
||||||
|
{"Mercury", OccultationMercury, time.Date(2024, time.March, 10, 0, 0, 0, 0, time.UTC), time.Date(2024, time.March, 12, 0, 0, 0, 0, time.UTC)},
|
||||||
|
{"Venus", OccultationVenus, time.Date(2024, time.April, 6, 0, 0, 0, 0, time.UTC), time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
results, err := FindBestPlanetOccultations(test.start, test.end, test.planet, OccultationSearchOptions{MaxEvents: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindBestPlanetOccultations() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("FindBestPlanetOccultations() returned %d events, want 1", len(results))
|
||||||
|
}
|
||||||
|
result := results[0]
|
||||||
|
if result.Planet != test.planet || !result.HasInternalContacts || !result.ContactsComplete {
|
||||||
|
t.Fatalf("unexpected event geometry: %+v", result)
|
||||||
|
}
|
||||||
|
if !(result.ExternalImmersion.Before(result.InternalImmersion) &&
|
||||||
|
result.InternalImmersion.Before(result.Greatest) &&
|
||||||
|
result.Greatest.Before(result.InternalEmersion) &&
|
||||||
|
result.InternalEmersion.Before(result.ExternalEmersion)) {
|
||||||
|
t.Fatalf("contact order is invalid: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, ok := planetOccultationConfigFor(test.planet)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s occultation config is unavailable", test.name)
|
||||||
|
}
|
||||||
|
contacts := []struct {
|
||||||
|
value time.Time
|
||||||
|
internal bool
|
||||||
|
}{
|
||||||
|
{result.ExternalImmersion, false},
|
||||||
|
{result.InternalImmersion, true},
|
||||||
|
{result.InternalEmersion, true},
|
||||||
|
{result.ExternalEmersion, false},
|
||||||
|
}
|
||||||
|
for index, contact := range contacts {
|
||||||
|
state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &result.Observer, -1)
|
||||||
|
metric := state.externalContactMetric
|
||||||
|
if contact.internal {
|
||||||
|
metric = state.internalContactMetric
|
||||||
|
}
|
||||||
|
if math.Abs(metric) > 0.1 {
|
||||||
|
t.Errorf("contact %d residual = %.6f arcsec, want <= 0.1", index+1, metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetOccultationOuterPlanetContactsUseDynamicTargets(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
planet OccultationPlanet
|
||||||
|
start time.Time
|
||||||
|
end time.Time
|
||||||
|
observer Observer
|
||||||
|
}{
|
||||||
|
{"Mars", OccultationMars, time.Date(2020, 2, 18, 11, 0, 0, 0, time.UTC), time.Date(2020, 2, 18, 16, 0, 0, 0, time.UTC), Observer{Longitude: -76.01, Latitude: 29.918}},
|
||||||
|
{"Jupiter", OccultationJupiter, time.Date(2020, 1, 23, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 23, 5, 0, 0, 0, time.UTC), Observer{Longitude: 120.15, Latitude: -45.552}},
|
||||||
|
{"Uranus", OccultationUranus, time.Date(2022, 2, 7, 19, 0, 0, 0, time.UTC), time.Date(2022, 2, 7, 22, 0, 0, 0, time.UTC), Observer{Longitude: 11.186, Latitude: -62.948}},
|
||||||
|
{"Neptune", OccultationNeptune, time.Date(2023, 9, 1, 7, 0, 0, 0, time.UTC), time.Date(2023, 9, 1, 10, 0, 0, 0, time.UTC), Observer{Longitude: -13.896, Latitude: -62.038}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
results, err := FindPlanetOccultations(
|
||||||
|
test.start, test.end, test.planet,
|
||||||
|
test.observer.Longitude, test.observer.Latitude, test.observer.Height,
|
||||||
|
OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlanetOccultations() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(results))
|
||||||
|
}
|
||||||
|
result := results[0]
|
||||||
|
if result.Type != OccultationTotal || !result.HasInternalContacts || !result.ContactsComplete {
|
||||||
|
t.Fatalf("unexpected event geometry: %+v", result)
|
||||||
|
}
|
||||||
|
config, ok := planetOccultationConfigFor(test.planet)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s occultation config is unavailable", test.name)
|
||||||
|
}
|
||||||
|
contacts := []struct {
|
||||||
|
value time.Time
|
||||||
|
internal bool
|
||||||
|
}{
|
||||||
|
{result.ExternalImmersion, false},
|
||||||
|
{result.InternalImmersion, true},
|
||||||
|
{result.InternalEmersion, true},
|
||||||
|
{result.ExternalEmersion, false},
|
||||||
|
}
|
||||||
|
for index, contact := range contacts {
|
||||||
|
state := planetOccultationStateAt(occultationTimeToTT(contact.value), config, &test.observer, -1)
|
||||||
|
metric := state.externalContactMetric
|
||||||
|
if contact.internal {
|
||||||
|
metric = state.internalContactMetric
|
||||||
|
}
|
||||||
|
if math.Abs(metric) > 0.1 {
|
||||||
|
t.Errorf("contact %d residual = %.6f arcsec, want <= 0.1", index+1, metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
starOccultationSiderealMonthDays = 27.321661
|
||||||
|
starOccultationDefaultStepDays = 0.25
|
||||||
|
starOccultationContactStepDays = 10.0 / 1440.0
|
||||||
|
starOccultationContactSpanDays = 2.0
|
||||||
|
starOccultationLatitudeMarginAS = 3600.0
|
||||||
|
starOccultationMoonLatitudeDeg = 6.0
|
||||||
|
starOccultationGrazingTolerance = 0.01
|
||||||
|
starOccultationRootToleranceDays = occultationEventSelectionToleranceDays
|
||||||
|
starOccultationMaxContactSteps = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindStarOccultations 搜索单颗点源恒星的月掩星。
|
||||||
|
//
|
||||||
|
// 输入坐标会从历元传播并转换到当日视坐标系;若提供视差,还会修正观测者的恒星视差。
|
||||||
|
// 搜索不会加载内嵌 9100 星表;需要全星表搜索时,调用者必须显式加载并选择恒星。经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。
|
||||||
|
// FindStarOccultations searches for lunar occultations of one point-source star.
|
||||||
|
// The input coordinate is propagated from its epoch, converted to the apparent frame of date, and corrected for the observer's stellar parallax when one is supplied.
|
||||||
|
// The search does not load the embedded 9100-star catalog; callers that need a catalog-wide search must load and select stars explicitly. Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is the observer elevation above mean sea level in meters.
|
||||||
|
func FindStarOccultations(start, end time.Time, star StarCoordinate, longitude, latitude, height float64,
|
||||||
|
options OccultationSearchOptions) ([]StarOccultationInfo, error) {
|
||||||
|
if err := validateOccultationTimeRange(start, end); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := star.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
observer := Observer{Longitude: longitude, Latitude: latitude, Height: height}
|
||||||
|
if err := observer.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := options.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
startTT := occultationTimeToTT(start)
|
||||||
|
endTT := occultationTimeToTT(end)
|
||||||
|
resultLocation := start.Location()
|
||||||
|
results := make([]StarOccultationInfo, 0)
|
||||||
|
for _, greatestTT := range starOccultationCandidateGreatestTimes(startTT, endTT, starOccultationCoarseStepDays(options), star, observer, options.SafetyMarginArcsec) {
|
||||||
|
info, ok := starOccultationInfoAtGreatest(greatestTT, star, observer, options.SafetyMarginArcsec, resultLocation)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 {
|
||||||
|
results = append(results, info)
|
||||||
|
if options.MaxEvents > 0 && len(results) >= options.MaxEvents {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) })
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindBestStarOccultations 返回窗口内每次恒星月掩在地球上的全球几何掩甚点。
|
||||||
|
// 返回的 StarOccultationInfo.Observer 是海平面大地测量位置,由月掩几何选择,不使用地平线或可见性评分。
|
||||||
|
//
|
||||||
|
// 地心数据只用于月周期搜索初值;最终点是与 FindStarOccultationPaths 一致的标准全球掩甚路径点,然后在该处重新进行站心接触和可见性计算。查询端点 10 ms 内的掩甚时刻也会包含,与数值根精度一致。
|
||||||
|
// FindBestStarOccultations returns the global geometric greatest point on Earth for each stellar occultation in the window.
|
||||||
|
// The returned StarOccultationInfo.Observer is the geodetic location at sea level; it is selected from the occultation geometry, without a horizon or visibility score.
|
||||||
|
// Geocentric data only seeds each lunar-month search. The final point is the canonical global greatest-path point, matching FindStarOccultationPaths; event contacts and visibility are then recomputed topocentrically there. A greatest instant within 10 ms of either query endpoint is included, matching the numerical root precision.
|
||||||
|
func FindBestStarOccultations(start, end time.Time, star StarCoordinate, options OccultationSearchOptions) ([]StarOccultationInfo, error) {
|
||||||
|
if err := validateOccultationTimeRange(start, end); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := star.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := options.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
startTT := occultationTimeToTT(start)
|
||||||
|
endTT := occultationTimeToTT(end)
|
||||||
|
selectionStartTT := startTT - occultationEventSelectionToleranceDays
|
||||||
|
selectionEndTT := endTT + occultationEventSelectionToleranceDays
|
||||||
|
candidateStartTT := startTT - occultationPathSearchSpanDays
|
||||||
|
candidateEndTT := endTT + occultationPathSearchSpanDays
|
||||||
|
resultLocation := start.Location()
|
||||||
|
results := make([]StarOccultationInfo, 0)
|
||||||
|
for _, seedTT := range starOccultationGeocentricCandidateGreatestTimes(candidateStartTT, candidateEndTT, starOccultationCoarseStepDays(options), star, options.SafetyMarginArcsec) {
|
||||||
|
greatestTT, observer, _, observerOK := starOccultationBestObserver(seedTT, selectionStartTT, selectionEndTT, star)
|
||||||
|
if !observerOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, ok := starOccultationInfoAtGreatest(greatestTT, star, observer, options.SafetyMarginArcsec, resultLocation)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(results) == 0 || math.Abs(results[len(results)-1].Greatest.Sub(info.Greatest).Seconds()) > 60 {
|
||||||
|
results = append(results, info)
|
||||||
|
if options.MaxEvents > 0 && len(results) >= options.MaxEvents {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(results, func(i, j int) bool { return results[i].Greatest.Before(results[j].Greatest) })
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationCandidateGreatestTimes(startTT, endTT, step float64, star StarCoordinate, observer Observer, safetyMarginArcsec float64) []float64 {
|
||||||
|
results := make([]float64, 0)
|
||||||
|
for cycleStart := startTT; cycleStart < endTT; cycleStart += starOccultationSiderealMonthDays {
|
||||||
|
cycleEnd := math.Min(cycleStart+starOccultationSiderealMonthDays, endTT)
|
||||||
|
scanStart := math.Max(startTT-step, cycleStart-step)
|
||||||
|
scanEnd := math.Min(endTT+step, cycleEnd+step)
|
||||||
|
if !starOccultationLatitudeEnvelopePass(scanStart, scanEnd, star, &observer, safetyMarginArcsec) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, starOccultationScanCandidates(
|
||||||
|
scanStart, scanEnd, step,
|
||||||
|
func(tt float64) float64 { return starMoonSeparationArcsec(tt, star, observer) },
|
||||||
|
func(tt float64) bool {
|
||||||
|
return starOccultationLatitudePass(tt, star, observer, safetyMarginArcsec)
|
||||||
|
},
|
||||||
|
)...)
|
||||||
|
}
|
||||||
|
return uniqueOccultationCandidateTimes(results, startTT, endTT)
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationGeocentricCandidateGreatestTimes(startTT, endTT, step float64, star StarCoordinate, safetyMarginArcsec float64) []float64 {
|
||||||
|
results := make([]float64, 0)
|
||||||
|
for cycleStart := startTT; cycleStart < endTT; cycleStart += starOccultationSiderealMonthDays {
|
||||||
|
cycleEnd := math.Min(cycleStart+starOccultationSiderealMonthDays, endTT)
|
||||||
|
scanStart := math.Max(startTT-step, cycleStart-step)
|
||||||
|
scanEnd := math.Min(endTT+step, cycleEnd+step)
|
||||||
|
if !starOccultationLatitudeEnvelopePass(scanStart, scanEnd, star, nil, safetyMarginArcsec) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, starOccultationScanCandidates(
|
||||||
|
scanStart, scanEnd, step,
|
||||||
|
func(tt float64) float64 { return starOccultationGeocentricSeparationArcsec(tt, star) },
|
||||||
|
func(tt float64) bool {
|
||||||
|
return starOccultationLatitudePassGeocentric(tt, star, safetyMarginArcsec)
|
||||||
|
},
|
||||||
|
)...)
|
||||||
|
}
|
||||||
|
return uniqueOccultationCandidateTimes(results, startTT, endTT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// starOccultationScanCandidates 找出粗扫描中的所有局部最小值。
|
||||||
|
// 恒星月仍适合作为黄纬预筛桶,但不能假定每个桶恰好只有一个最近接近。
|
||||||
|
// starOccultationScanCandidates finds every local minimum in a coarse scan.
|
||||||
|
// A lunar month remains a useful latitude-prefilter bucket, but it must not be treated as a promise that exactly one closest approach exists in that bucket.
|
||||||
|
func starOccultationScanCandidates(
|
||||||
|
startTT, endTT, step float64,
|
||||||
|
value func(float64) float64,
|
||||||
|
accept func(float64) bool,
|
||||||
|
) []float64 {
|
||||||
|
if endTT < startTT {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if endTT == startTT {
|
||||||
|
if accept(startTT) && finite(value(startTT)) {
|
||||||
|
return []float64{startTT}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if step <= 0 || !finite(step) {
|
||||||
|
step = starOccultationDefaultStepDays
|
||||||
|
}
|
||||||
|
if step > (endTT-startTT)/2 {
|
||||||
|
step = (endTT - startTT) / 2
|
||||||
|
}
|
||||||
|
appendCandidate := func(results *[]float64, tt float64) {
|
||||||
|
if tt < startTT || tt > endTT || !finite(tt) || !finite(value(tt)) || !accept(tt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(*results) == 0 || math.Abs(tt-(*results)[len(*results)-1]) > 60.0/86400.0 {
|
||||||
|
*results = append(*results, tt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]float64, 0, 2)
|
||||||
|
leftTT, leftValue := startTT, value(startTT)
|
||||||
|
centerTT := math.Min(startTT+step, endTT)
|
||||||
|
centerValue := value(centerTT)
|
||||||
|
for centerTT < endTT {
|
||||||
|
rightTT := math.Min(centerTT+step, endTT)
|
||||||
|
rightValue := value(rightTT)
|
||||||
|
if finite(leftValue) && finite(centerValue) && finite(rightValue) &&
|
||||||
|
centerValue <= leftValue && centerValue <= rightValue {
|
||||||
|
candidate := starOccultationMinimizeValue(leftTT, rightTT, value)
|
||||||
|
appendCandidate(&results, candidate)
|
||||||
|
}
|
||||||
|
leftTT, leftValue = centerTT, centerValue
|
||||||
|
centerTT, centerValue = rightTT, rightValue
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueOccultationCandidateTimes(times []float64, startTT, endTT float64) []float64 {
|
||||||
|
if len(times) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Float64s(times)
|
||||||
|
unique := times[:0]
|
||||||
|
for _, tt := range times {
|
||||||
|
if tt < startTT || tt > endTT {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(unique) == 0 || math.Abs(tt-unique[len(unique)-1]) > 60.0/86400.0 {
|
||||||
|
unique = append(unique, tt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationLatitudeEnvelopePass(startTT, endTT float64, star StarCoordinate, observer *Observer, safetyMarginArcsec float64) bool {
|
||||||
|
minimumLatitude := math.Inf(1)
|
||||||
|
maximumLatitude := math.Inf(-1)
|
||||||
|
maximumMoonRadiusDeg := 0.0
|
||||||
|
for _, tt := range []float64{startTT, (startTT + endTT) / 2, endTT} {
|
||||||
|
var ra, dec float64
|
||||||
|
if observer == nil {
|
||||||
|
ra, dec = starApparentRaDecGeocentric(tt, star)
|
||||||
|
} else {
|
||||||
|
ra, dec = starApparentRaDec(tt, star, *observer)
|
||||||
|
}
|
||||||
|
_, latitude := RaDecToLoBo(tt, ra, dec)
|
||||||
|
minimumLatitude = math.Min(minimumLatitude, latitude)
|
||||||
|
maximumLatitude = math.Max(maximumLatitude, latitude)
|
||||||
|
moonRadius := MoonSemidiameter(tt)
|
||||||
|
if observer != nil {
|
||||||
|
moonRadius = moonTopocentricSemidiameterN(tt, *observer, -1)
|
||||||
|
}
|
||||||
|
maximumMoonRadiusDeg = math.Max(maximumMoonRadiusDeg, moonRadius/3600)
|
||||||
|
}
|
||||||
|
limit := starOccultationMoonLatitudeDeg + starOccultationLatitudeMarginAS/3600 + safetyMarginArcsec/3600 + maximumMoonRadiusDeg
|
||||||
|
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
|
||||||
|
}
|
||||||
|
const goldenRatio = 0.6180339887498949
|
||||||
|
x1 := right - goldenRatio*(right-left)
|
||||||
|
x2 := left + goldenRatio*(right-left)
|
||||||
|
f1 := value(x1)
|
||||||
|
f2 := value(x2)
|
||||||
|
for i := 0; i < 64 && right-left > starOccultationRootToleranceDays; i++ {
|
||||||
|
if f1 > f2 {
|
||||||
|
left = x1
|
||||||
|
x1, f1 = x2, f2
|
||||||
|
x2 = left + goldenRatio*(right-left)
|
||||||
|
f2 = value(x2)
|
||||||
|
} else {
|
||||||
|
right = x2
|
||||||
|
x2, f2 = x1, f1
|
||||||
|
x1 = right - goldenRatio*(right-left)
|
||||||
|
f1 = value(x1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (left + right) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationGeocentricSeparationArcsec(tt float64, star StarCoordinate) float64 {
|
||||||
|
moonRA, moonDec := HMoonGeocentricApparentRaDecN(tt, -1)
|
||||||
|
starRA, starDec := starApparentRaDecGeocentric(tt, star)
|
||||||
|
return angularSeparationDegrees(moonRA, moonDec, starRA, starDec) * 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationBestObserver(seedTT, startTT, endTT float64, star StarCoordinate) (float64, Observer, float64, bool) {
|
||||||
|
searchStart := seedTT - occultationPathSearchSpanDays
|
||||||
|
searchEnd := seedTT + occultationPathSearchSpanDays
|
||||||
|
outerStart, outerEnd, ok := starOccultationPathWindow(seedTT, searchStart, searchEnd, star, false)
|
||||||
|
if !ok {
|
||||||
|
return 0, Observer{}, 0, false
|
||||||
|
}
|
||||||
|
greatestTT := starOccultationPathGreatest(seedTT, outerStart, outerEnd, star)
|
||||||
|
if greatestTT < startTT || greatestTT > endTT {
|
||||||
|
return 0, Observer{}, 0, false
|
||||||
|
}
|
||||||
|
point, pointOK := starOccultationPathCenterPoint(greatestTT, star, time.UTC)
|
||||||
|
if !pointOK {
|
||||||
|
frameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||||
|
return starOccultationPathFrameAt(tt, star)
|
||||||
|
}
|
||||||
|
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
|
||||||
|
}
|
||||||
|
if !pointOK {
|
||||||
|
return 0, Observer{}, 0, false
|
||||||
|
}
|
||||||
|
observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||||
|
position := starMoonPositionAt(greatestTT, star, observer)
|
||||||
|
moonRadius := moonTopocentricSemidiameterN(greatestTT, observer, -1)
|
||||||
|
if !position.valid || !finite(moonRadius) {
|
||||||
|
return 0, Observer{}, 0, false
|
||||||
|
}
|
||||||
|
metric := angularSeparationDegrees(position.moonRA, position.moonDec, position.starRA, position.starDec)*3600 - moonRadius
|
||||||
|
return greatestTT, observer, metric, finite(metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLongitude180(longitude float64) float64 {
|
||||||
|
longitude = math.Mod(longitude+180, 360)
|
||||||
|
if longitude < 0 {
|
||||||
|
longitude += 360
|
||||||
|
}
|
||||||
|
return longitude - 180
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationInfoAtGreatest(greatestTT float64, star StarCoordinate, observer Observer, safetyMarginArcsec float64, location *time.Location) (StarOccultationInfo, bool) {
|
||||||
|
if !starOccultationLatitudePass(greatestTT, star, observer, safetyMarginArcsec) {
|
||||||
|
return StarOccultationInfo{}, false
|
||||||
|
}
|
||||||
|
minimumSeparation := starMoonSeparationArcsec(greatestTT, star, observer)
|
||||||
|
moonRadius := moonTopocentricSemidiameterN(greatestTT, observer, -1)
|
||||||
|
if !finite(minimumSeparation) || !finite(moonRadius) || minimumSeparation > moonRadius {
|
||||||
|
return StarOccultationInfo{}, false
|
||||||
|
}
|
||||||
|
greatestPosition := starMoonPositionAt(greatestTT, star, observer)
|
||||||
|
if !greatestPosition.valid {
|
||||||
|
return StarOccultationInfo{}, false
|
||||||
|
}
|
||||||
|
info := StarOccultationInfo{
|
||||||
|
TargetID: star.ID,
|
||||||
|
Observer: observer,
|
||||||
|
Type: OccultationTotal,
|
||||||
|
Greatest: occultationTTToLocation(greatestTT, location),
|
||||||
|
MinimumSeparationArcsec: minimumSeparation,
|
||||||
|
PositionAngleDeg: occultationPositionAngle(greatestPosition.moonRA, greatestPosition.moonDec, greatestPosition.starRA, greatestPosition.starDec),
|
||||||
|
MoonSemidiameterArcsec: moonRadius,
|
||||||
|
MoonAltitudeAtGreatest: occultationAltitude(greatestTT, observer, greatestPosition.moonRA, greatestPosition.moonDec),
|
||||||
|
MoonAzimuthAtGreatest: occultationAzimuth(greatestTT, observer, greatestPosition.moonRA, greatestPosition.moonDec),
|
||||||
|
}
|
||||||
|
info.VisibleAtGreatest = info.MoonAltitudeAtGreatest >= 0
|
||||||
|
|
||||||
|
minimumResidual := minimumSeparation - moonRadius
|
||||||
|
if math.Abs(minimumResidual) <= starOccultationGrazingTolerance {
|
||||||
|
info.Type = OccultationGrazing
|
||||||
|
info.Immersion = info.Greatest
|
||||||
|
info.Emersion = info.Greatest
|
||||||
|
info.ContactsComplete = true
|
||||||
|
return info, true
|
||||||
|
}
|
||||||
|
immersionTT, immersionOK := starOccultationContact(greatestTT, greatestTT-starOccultationContactSpanDays, -1, star, observer)
|
||||||
|
emersionTT, emersionOK := starOccultationContact(greatestTT, greatestTT+starOccultationContactSpanDays, 1, star, observer)
|
||||||
|
if !immersionOK || !emersionOK {
|
||||||
|
return StarOccultationInfo{}, false
|
||||||
|
}
|
||||||
|
info.Immersion = occultationTTToLocation(immersionTT, location)
|
||||||
|
info.Emersion = occultationTTToLocation(emersionTT, location)
|
||||||
|
info.ContactsComplete = true
|
||||||
|
return info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type starMoonPosition struct {
|
||||||
|
moonRA, moonDec float64
|
||||||
|
starRA, starDec float64
|
||||||
|
valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func starMoonPositionAt(tt float64, star StarCoordinate, observer Observer) starMoonPosition {
|
||||||
|
moonRA, moonDec := moonTopocentricApparentRaDec(tt, observer, -1)
|
||||||
|
starRA, starDec := starApparentRaDec(tt, star, observer)
|
||||||
|
return starMoonPosition{
|
||||||
|
moonRA: moonRA,
|
||||||
|
moonDec: moonDec,
|
||||||
|
starRA: starRA,
|
||||||
|
starDec: starDec,
|
||||||
|
valid: finite(moonRA) && finite(moonDec) && finite(starRA) && finite(starDec),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moonTopocentricApparentRaDec(tt float64, observer Observer, n int) (float64, float64) {
|
||||||
|
ra, dec := HMoonGeocentricApparentRaDecN(tt, n)
|
||||||
|
ut := TD2UT(tt, false)
|
||||||
|
distanceAU := HMoonAwayN(tt, n) / 149597870.7
|
||||||
|
ra, dec = TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, ut, distanceAU, observer.Height)
|
||||||
|
return normalizeRA(ra), dec
|
||||||
|
}
|
||||||
|
|
||||||
|
func starApparentRaDec(tt float64, star StarCoordinate, observer Observer) (float64, float64) {
|
||||||
|
ra, dec := starApparentRaDecGeocentric(tt, star)
|
||||||
|
if star.ParallaxMas > 0 {
|
||||||
|
// 1 秒差距处 1 角秒对应 206264.806 AU。
|
||||||
|
// One arcsecond at 1 pc corresponds to 206264.806 AU.
|
||||||
|
distanceAU := 206264806.247 / star.ParallaxMas
|
||||||
|
ra, dec = TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, TD2UT(tt, false), distanceAU, observer.Height)
|
||||||
|
ra = normalizeRA(ra)
|
||||||
|
}
|
||||||
|
return ra, dec
|
||||||
|
}
|
||||||
|
|
||||||
|
func starApparentRaDecGeocentric(tt float64, star StarCoordinate) (float64, float64) {
|
||||||
|
epochJD := occultationTimeToTT(star.Epoch)
|
||||||
|
years := (tt - epochJD) / 365.25
|
||||||
|
ra := star.RA
|
||||||
|
dec := star.Dec
|
||||||
|
precessionEpoch := 2451545.0
|
||||||
|
if star.Frame == CoordinateFrameICRS {
|
||||||
|
ra, dec = starICRSToMeanJ2000RaDec(ra, dec)
|
||||||
|
} else if star.Frame == CoordinateFrameApparentOfDate {
|
||||||
|
ra, dec = starApparentToMeanRaDec(epochJD, ra, dec, star.ParallaxMas)
|
||||||
|
precessionEpoch = epochJD
|
||||||
|
}
|
||||||
|
cosDec := math.Cos(dec * math.Pi / 180)
|
||||||
|
if math.Abs(cosDec) > 1e-12 {
|
||||||
|
ra += years * star.ProperMotionRACosDecMasPerYear / (3600000.0 * cosDec)
|
||||||
|
}
|
||||||
|
dec += years * star.ProperMotionDecMasPerYear / 3600000.0
|
||||||
|
dec = math.Max(-90, math.Min(90, dec))
|
||||||
|
|
||||||
|
ra, dec = Precess(ra, dec, precessionEpoch, tt)
|
||||||
|
return starMeanToApparentRaDec(tt, ra, dec, star.ParallaxMas)
|
||||||
|
}
|
||||||
|
|
||||||
|
func starICRSToMeanJ2000RaDec(ra, dec float64) (float64, float64) {
|
||||||
|
// IAU SOFA 框架偏差矩阵,将 GCRS/ICRS 向量转换为 J2000.0 平均赤道和春分点。
|
||||||
|
// IAU SOFA frame-bias matrix, transforming a GCRS/ICRS vector to the mean equator and equinox of J2000.0.
|
||||||
|
const (
|
||||||
|
b00 = 0.9999999999999942
|
||||||
|
b01 = -0.7078279744199197e-7
|
||||||
|
b02 = 0.8056217146976134e-7
|
||||||
|
b10 = 0.7078279477857337e-7
|
||||||
|
b11 = 0.9999999999999969
|
||||||
|
b12 = 0.3306041454222148e-7
|
||||||
|
b20 = -0.8056217380986972e-7
|
||||||
|
b21 = -0.3306040883980553e-7
|
||||||
|
b22 = 0.9999999999999962
|
||||||
|
)
|
||||||
|
raRad := ra * math.Pi / 180
|
||||||
|
decRad := dec * math.Pi / 180
|
||||||
|
x := math.Cos(decRad) * math.Cos(raRad)
|
||||||
|
y := math.Cos(decRad) * math.Sin(raRad)
|
||||||
|
z := math.Sin(decRad)
|
||||||
|
biasedX := b00*x + b01*y + b02*z
|
||||||
|
biasedY := b10*x + b11*y + b12*z
|
||||||
|
biasedZ := b20*x + b21*y + b22*z
|
||||||
|
return normalizeRA(math.Atan2(biasedY, biasedX) * 180 / math.Pi),
|
||||||
|
math.Atan2(biasedZ, math.Hypot(biasedX, biasedY)) * 180 / math.Pi
|
||||||
|
}
|
||||||
|
|
||||||
|
func starMeanToApparentRaDec(tt, ra, dec, parallaxMas float64) (float64, float64) {
|
||||||
|
longitude, latitude := starMeanEquatorialToEcliptic(tt, ra, dec)
|
||||||
|
if parallaxMas > 0 {
|
||||||
|
longitude, latitude = starAnnualParallaxEcliptic(tt, longitude, latitude, parallaxMas)
|
||||||
|
}
|
||||||
|
meanLongitude, meanLatitude := longitude, latitude
|
||||||
|
longitude = normalizeRA(meanLongitude + GXCLo(meanLongitude, meanLatitude, tt)/3600 + Nutation2000Bi(tt))
|
||||||
|
latitude = meanLatitude + GXCBo(meanLongitude, meanLatitude, tt)/3600
|
||||||
|
ra, dec = LoBoToRaDec(tt, longitude, latitude)
|
||||||
|
return normalizeRA(ra), dec
|
||||||
|
}
|
||||||
|
|
||||||
|
func starApparentToMeanRaDec(tt, apparentRA, apparentDec, parallaxMas float64) (float64, float64) {
|
||||||
|
meanRA, meanDec := apparentRA, apparentDec
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
computedRA, computedDec := starMeanToApparentRaDec(tt, meanRA, meanDec, parallaxMas)
|
||||||
|
meanRA = normalizeRA(meanRA - signedAngleDifference(computedRA, apparentRA))
|
||||||
|
meanDec -= computedDec - apparentDec
|
||||||
|
}
|
||||||
|
return meanRA, math.Max(-90, math.Min(90, meanDec))
|
||||||
|
}
|
||||||
|
|
||||||
|
func starMeanEquatorialToEcliptic(tt, ra, dec float64) (float64, float64) {
|
||||||
|
obliquity := EclipticObliquity(tt, false) * math.Pi / 180
|
||||||
|
ra *= math.Pi / 180
|
||||||
|
dec *= math.Pi / 180
|
||||||
|
longitude := math.Atan2(
|
||||||
|
math.Sin(ra)*math.Cos(obliquity)+math.Tan(dec)*math.Sin(obliquity),
|
||||||
|
math.Cos(ra),
|
||||||
|
) * 180 / math.Pi
|
||||||
|
latitude := math.Asin(
|
||||||
|
math.Sin(dec)*math.Cos(obliquity)-math.Cos(dec)*math.Sin(obliquity)*math.Sin(ra),
|
||||||
|
) * 180 / math.Pi
|
||||||
|
return normalizeRA(longitude), latitude
|
||||||
|
}
|
||||||
|
|
||||||
|
func starAnnualParallaxEcliptic(tt, longitude, latitude, parallaxMas float64) (float64, float64) {
|
||||||
|
distanceAU := 206264806.247 / parallaxMas
|
||||||
|
longitudeRad := longitude * math.Pi / 180
|
||||||
|
latitudeRad := latitude * math.Pi / 180
|
||||||
|
cosLatitude := math.Cos(latitudeRad)
|
||||||
|
starX := distanceAU * cosLatitude * math.Cos(longitudeRad)
|
||||||
|
starY := distanceAU * cosLatitude * math.Sin(longitudeRad)
|
||||||
|
starZ := distanceAU * math.Sin(latitudeRad)
|
||||||
|
|
||||||
|
earthLongitude := normalizeRA(HSunTrueLoN(tt, -1)+180) * math.Pi / 180
|
||||||
|
earthDistance := EarthAwayN(tt, -1)
|
||||||
|
starX -= earthDistance * math.Cos(earthLongitude)
|
||||||
|
starY -= earthDistance * math.Sin(earthLongitude)
|
||||||
|
|
||||||
|
longitude = math.Atan2(starY, starX) * 180 / math.Pi
|
||||||
|
latitude = math.Atan2(starZ, math.Hypot(starX, starY)) * 180 / math.Pi
|
||||||
|
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)
|
||||||
|
starLongitude, _ := RaDecToLoBo(tt, starRA, starDec)
|
||||||
|
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 {
|
||||||
|
return math.Inf(1)
|
||||||
|
}
|
||||||
|
return angularSeparationDegrees(position.moonRA, position.moonDec, position.starRA, position.starDec) * 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationLatitudePass(tt float64, star StarCoordinate, observer Observer, safetyMarginArcsec float64) bool {
|
||||||
|
starRA, starDec := starApparentRaDec(tt, star, observer)
|
||||||
|
_, starLatitude := RaDecToLoBo(tt, starRA, starDec)
|
||||||
|
moonLatitude := HMoonTrueBoN(tt, 8)
|
||||||
|
moonRadius := moonTopocentricSemidiameterN(tt, observer, -1)
|
||||||
|
limit := moonRadius + starOccultationLatitudeMarginAS + safetyMarginArcsec
|
||||||
|
return math.Abs(starLatitude-moonLatitude)*3600 <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationLatitudePassGeocentric(tt float64, star StarCoordinate, safetyMarginArcsec float64) bool {
|
||||||
|
starRA, starDec := starApparentRaDecGeocentric(tt, star)
|
||||||
|
_, starLatitude := RaDecToLoBo(tt, starRA, starDec)
|
||||||
|
moonLatitude := HMoonTrueBoN(tt, 8)
|
||||||
|
limit := MoonSemidiameter(tt) + starOccultationLatitudeMarginAS + safetyMarginArcsec
|
||||||
|
return math.Abs(starLatitude-moonLatitude)*3600 <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationContact(greatestTT, boundaryTT float64, direction int, star StarCoordinate, observer Observer) (float64, bool) {
|
||||||
|
valueAtGreatest := starMoonSeparationArcsec(greatestTT, star, observer) - moonTopocentricSemidiameterN(greatestTT, observer, -1)
|
||||||
|
if !finite(valueAtGreatest) || valueAtGreatest > 0 {
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
currentTT := greatestTT
|
||||||
|
currentValue := valueAtGreatest
|
||||||
|
step := starOccultationContactStepDays
|
||||||
|
for i := 0; i < starOccultationMaxContactSteps; i++ {
|
||||||
|
nextTT := currentTT + float64(direction)*step
|
||||||
|
if direction < 0 && nextTT < boundaryTT {
|
||||||
|
nextTT = boundaryTT
|
||||||
|
}
|
||||||
|
if direction > 0 && nextTT > boundaryTT {
|
||||||
|
nextTT = boundaryTT
|
||||||
|
}
|
||||||
|
nextValue := starMoonSeparationArcsec(nextTT, star, observer) - moonTopocentricSemidiameterN(nextTT, observer, -1)
|
||||||
|
if finite(nextValue) && nextValue >= 0 {
|
||||||
|
return starOccultationRoot(currentTT, nextTT, currentValue, nextValue, star, observer)
|
||||||
|
}
|
||||||
|
if nextTT == boundaryTT {
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
currentTT = nextTT
|
||||||
|
currentValue = nextValue
|
||||||
|
}
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationRoot(leftTT, rightTT, leftValue, rightValue float64, star StarCoordinate, observer Observer) (float64, bool) {
|
||||||
|
if !finite(leftValue) || !finite(rightValue) || leftValue*rightValue > 0 {
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
if leftValue == 0 {
|
||||||
|
return leftTT, true
|
||||||
|
}
|
||||||
|
if rightValue == 0 {
|
||||||
|
return rightTT, true
|
||||||
|
}
|
||||||
|
for i := 0; i < 64 && math.Abs(rightTT-leftTT) > starOccultationRootToleranceDays; i++ {
|
||||||
|
midTT := (leftTT + rightTT) / 2
|
||||||
|
midValue := starMoonSeparationArcsec(midTT, star, observer) - moonTopocentricSemidiameterN(midTT, observer, -1)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationCoarseStepDays(options OccultationSearchOptions) float64 {
|
||||||
|
step := starOccultationDefaultStepDays
|
||||||
|
if options.MaxStep > 0 {
|
||||||
|
requested := options.MaxStep.Hours() / 24
|
||||||
|
if requested > 0 && requested < step {
|
||||||
|
step = requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return math.Max(step, occultationSearchMinimumStep.Hours()/24)
|
||||||
|
}
|
||||||
|
|
||||||
|
func angularSeparationDegrees(ra1, dec1, ra2, dec2 float64) float64 {
|
||||||
|
ra1 *= math.Pi / 180
|
||||||
|
ra2 *= math.Pi / 180
|
||||||
|
dec1 *= math.Pi / 180
|
||||||
|
dec2 *= math.Pi / 180
|
||||||
|
cosSeparation := math.Sin(dec1)*math.Sin(dec2) + math.Cos(dec1)*math.Cos(dec2)*math.Cos(ra1-ra2)
|
||||||
|
return math.Acos(math.Max(-1, math.Min(1, cosSeparation))) * 180 / math.Pi
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPositionAngle(moonRA, moonDec, starRA, starDec float64) float64 {
|
||||||
|
deltaRA := (starRA - moonRA) * math.Pi / 180
|
||||||
|
moonDecRad := moonDec * math.Pi / 180
|
||||||
|
starDecRad := starDec * math.Pi / 180
|
||||||
|
y := math.Sin(deltaRA) * math.Cos(starDecRad)
|
||||||
|
x := math.Cos(moonDecRad)*math.Sin(starDecRad) - math.Sin(moonDecRad)*math.Cos(starDecRad)*math.Cos(deltaRA)
|
||||||
|
return normalizeRA(math.Atan2(y, x) * 180 / math.Pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationAltitude(tt float64, observer Observer, ra, dec float64) float64 {
|
||||||
|
hourAngle := signedAngleDifference(ApparentSiderealTime(TD2UT(tt, false))*15+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)
|
||||||
|
return math.Asin(math.Max(-1, math.Min(1, sinAltitude))) * 180 / math.Pi
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationAzimuth(tt float64, observer Observer, ra, dec float64) float64 {
|
||||||
|
hourAngle := signedAngleDifference(ApparentSiderealTime(TD2UT(tt, false))*15+observer.Longitude, ra) * math.Pi / 180
|
||||||
|
lat := observer.Latitude * math.Pi / 180
|
||||||
|
declination := dec * math.Pi / 180
|
||||||
|
y := math.Sin(hourAngle)
|
||||||
|
x := math.Cos(hourAngle)*math.Sin(lat) - math.Tan(declination)*math.Cos(lat)
|
||||||
|
return normalizeRA(math.Atan2(y, x)*180/math.Pi + 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedAngleDifference(a, b float64) float64 {
|
||||||
|
difference := math.Mod(a-b+180, 360)
|
||||||
|
if difference < 0 {
|
||||||
|
difference += 360
|
||||||
|
}
|
||||||
|
return difference - 180
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRA(ra float64) float64 {
|
||||||
|
ra = math.Mod(ra, 360)
|
||||||
|
if ra < 0 {
|
||||||
|
ra += 360
|
||||||
|
}
|
||||||
|
return ra
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationTimeToTT(value time.Time) float64 {
|
||||||
|
return TD2UT(Date2JDE(value.UTC()), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationTTToLocation(tt float64, location *time.Location) time.Time {
|
||||||
|
if location == nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
return JDE2DateByZone(TD2UT(tt, false), location, false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
starOccultationDiagramDefaultStepDays = 2.0 / 1440.0
|
||||||
|
starOccultationDiagramMinStepDays = 1.0 / 86400.0
|
||||||
|
starOccultationDiagramMaxSamples = 2000
|
||||||
|
starOccultationDiagramDuplicateDays = 1e-10
|
||||||
|
starOccultationDiagramGeometryArcsec = 0.05
|
||||||
|
starOccultationDiagramPositionDeg = 0.01
|
||||||
|
)
|
||||||
|
|
||||||
|
// StarOccultationDiagramOptions 控制本地恒星月掩图的轨迹采样。
|
||||||
|
// StarOccultationDiagramOptions controls local stellar-occultation diagram sampling.
|
||||||
|
type StarOccultationDiagramOptions struct {
|
||||||
|
// StepDays 是请求的轨迹采样步长,单位为日;非正值或非有限值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,使基础轨迹不超过 2000 个采样点;必要阶段帧仍会额外保留。结果会报告实际采用的值。
|
||||||
|
// StepDays is the requested track sampling step in days. Non-positive or non-finite values use two minutes, and positive values below one second use one second. Long events may increase the effective step to keep the base track within 2000 samples; required phase frames are retained in addition. The result reports the effective value.
|
||||||
|
StepDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarOccultationDiagramFrame 描述一个时刻的站心月球与恒星几何。
|
||||||
|
// StarOccultationDiagramFrame describes topocentric Moon-star geometry at one instant.
|
||||||
|
type StarOccultationDiagramFrame struct {
|
||||||
|
// JDE 是 TT 儒略历书日。
|
||||||
|
// JDE is the TT Julian ephemeris day.
|
||||||
|
JDE float64
|
||||||
|
// StarXArcsec 和 StarYArcsec 是相对月心的切平面偏移,单位为角秒。X 向东为正,Y 向北为正。
|
||||||
|
// StarXArcsec and StarYArcsec are tangent-plane offsets from the lunar center. X is positive east and Y is positive north.
|
||||||
|
StarXArcsec float64
|
||||||
|
StarYArcsec float64
|
||||||
|
// MoonRadiusArcsec 是站心月球视半径,单位为角秒。
|
||||||
|
// MoonRadiusArcsec is the topocentric apparent lunar semidiameter.
|
||||||
|
MoonRadiusArcsec float64
|
||||||
|
// SeparationArcsec 和 PositionAngleDeg 描述恒星相对月心的位置。
|
||||||
|
// SeparationArcsec and PositionAngleDeg describe the star relative to the lunar center.
|
||||||
|
SeparationArcsec float64
|
||||||
|
PositionAngleDeg float64
|
||||||
|
// MoonAltitudeDeg 和 MoonAzimuthDeg 是站心地平坐标。
|
||||||
|
// MoonAltitudeDeg and MoonAzimuthDeg are topocentric horizontal coordinates.
|
||||||
|
MoonAltitudeDeg float64
|
||||||
|
MoonAzimuthDeg float64
|
||||||
|
// BehindMoon 表示点光源恒星位于月缘内侧。
|
||||||
|
// BehindMoon is true while the point-source star lies strictly inside the lunar limb.
|
||||||
|
BehindMoon bool
|
||||||
|
// Label 是主阶段标识;Labels 在掠掩事件中保留重合阶段。
|
||||||
|
// Label is the primary key phase; Labels retains coincident phases for grazing events.
|
||||||
|
Label string
|
||||||
|
Labels []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarOccultationDiagramResult 包含固定地点恒星月掩的几何数据。
|
||||||
|
// StarOccultationDiagramResult contains geometry for a fixed-site stellar occultation.
|
||||||
|
type StarOccultationDiagramResult struct {
|
||||||
|
Occultation StarOccultationInfo
|
||||||
|
Frames []StarOccultationDiagramFrame
|
||||||
|
// StepDays 是实际采用的基础轨迹采样步长,单位为日。
|
||||||
|
// StepDays is the effective base-track sampling step in days.
|
||||||
|
StepDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type starOccultationDiagramTime struct {
|
||||||
|
jde float64
|
||||||
|
labels []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarOccultationDiagram 为已求解的固定地点恒星月掩计算以月心为原点的切平面轨迹。事件数据无效或不完整时,结果不含帧。
|
||||||
|
// StarOccultationDiagram computes a Moon-centered tangent-plane track for an already solved fixed-site stellar occultation. Invalid or incomplete event data produces a result without frames.
|
||||||
|
func StarOccultationDiagram(
|
||||||
|
info StarOccultationInfo,
|
||||||
|
star StarCoordinate,
|
||||||
|
options StarOccultationDiagramOptions,
|
||||||
|
) StarOccultationDiagramResult {
|
||||||
|
options = normalizeStarOccultationDiagramOptions(options)
|
||||||
|
result := StarOccultationDiagramResult{Occultation: info, StepDays: options.StepDays}
|
||||||
|
if star.Validate() != nil || info.Observer.Validate() != nil ||
|
||||||
|
!info.ContactsComplete || info.Immersion.IsZero() || info.Greatest.IsZero() || info.Emersion.IsZero() ||
|
||||||
|
info.Greatest.Before(info.Immersion) || info.Emersion.Before(info.Greatest) ||
|
||||||
|
(info.Type != OccultationTotal && info.Type != OccultationGrazing) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
startTT := occultationTimeToTT(info.Immersion)
|
||||||
|
greatestTT := occultationTimeToTT(info.Greatest)
|
||||||
|
endTT := occultationTimeToTT(info.Emersion)
|
||||||
|
immersionFrame, immersionOK := starOccultationDiagramFrameAt(startTT, star, info.Observer)
|
||||||
|
greatestFrame, greatestOK := starOccultationDiagramFrameAt(greatestTT, star, info.Observer)
|
||||||
|
emersionFrame, emersionOK := starOccultationDiagramFrameAt(endTT, star, info.Observer)
|
||||||
|
if !immersionOK || !greatestOK || !emersionOK ||
|
||||||
|
!starOccultationDiagramMatchesInfo(info, immersionFrame, greatestFrame, emersionFrame) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
times, stepDays := starOccultationDiagramTimes(startTT, greatestTT, endTT, options.StepDays)
|
||||||
|
result.StepDays = stepDays
|
||||||
|
result.Frames = make([]StarOccultationDiagramFrame, 0, len(times))
|
||||||
|
for _, item := range times {
|
||||||
|
frame, ok := starOccultationDiagramFrameAt(item.jde, star, info.Observer)
|
||||||
|
if !ok {
|
||||||
|
return StarOccultationDiagramResult{Occultation: info, StepDays: stepDays}
|
||||||
|
}
|
||||||
|
frame.Labels = append([]string(nil), item.labels...)
|
||||||
|
frame.Label = starOccultationDiagramPrimaryLabel(item.labels)
|
||||||
|
result.Frames = append(result.Frames, frame)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationDiagramMatchesInfo(
|
||||||
|
info StarOccultationInfo,
|
||||||
|
immersion, greatest, emersion StarOccultationDiagramFrame,
|
||||||
|
) bool {
|
||||||
|
if !finite(info.MinimumSeparationArcsec) || !finite(info.MoonSemidiameterArcsec) ||
|
||||||
|
!finite(info.PositionAngleDeg) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if math.Abs(immersion.SeparationArcsec-immersion.MoonRadiusArcsec) > starOccultationDiagramGeometryArcsec ||
|
||||||
|
math.Abs(emersion.SeparationArcsec-emersion.MoonRadiusArcsec) > starOccultationDiagramGeometryArcsec {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return math.Abs(greatest.SeparationArcsec-info.MinimumSeparationArcsec) <= starOccultationDiagramGeometryArcsec &&
|
||||||
|
math.Abs(greatest.MoonRadiusArcsec-info.MoonSemidiameterArcsec) <= starOccultationDiagramGeometryArcsec &&
|
||||||
|
math.Abs(signedAngleDifference(greatest.PositionAngleDeg, info.PositionAngleDeg)) <= starOccultationDiagramPositionDeg
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStarOccultationDiagramOptions(options StarOccultationDiagramOptions) StarOccultationDiagramOptions {
|
||||||
|
if options.StepDays <= 0 || !finite(options.StepDays) {
|
||||||
|
options.StepDays = starOccultationDiagramDefaultStepDays
|
||||||
|
}
|
||||||
|
if options.StepDays < starOccultationDiagramMinStepDays {
|
||||||
|
options.StepDays = starOccultationDiagramMinStepDays
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationDiagramTimes(startTT, greatestTT, endTT, stepDays float64) ([]starOccultationDiagramTime, float64) {
|
||||||
|
if !finite(startTT) || !finite(greatestTT) || !finite(endTT) || greatestTT < startTT || endTT < greatestTT {
|
||||||
|
return nil, stepDays
|
||||||
|
}
|
||||||
|
if endTT > startTT {
|
||||||
|
if sampleCount := int(math.Ceil((endTT-startTT)/stepDays)) + 1; sampleCount > starOccultationDiagramMaxSamples {
|
||||||
|
stepDays = (endTT - startTT) / float64(starOccultationDiagramMaxSamples-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
times := []starOccultationDiagramTime{
|
||||||
|
{jde: startTT, labels: []string{"Immersion"}},
|
||||||
|
{jde: greatestTT, labels: []string{"Greatest"}},
|
||||||
|
{jde: endTT, labels: []string{"Emersion"}},
|
||||||
|
}
|
||||||
|
for jde := startTT + stepDays; jde < endTT; jde += stepDays {
|
||||||
|
times = append(times, starOccultationDiagramTime{jde: jde})
|
||||||
|
}
|
||||||
|
sort.SliceStable(times, func(i, j int) bool {
|
||||||
|
if times[i].jde == times[j].jde {
|
||||||
|
return starOccultationDiagramLabelPriority(times[i].labels) < starOccultationDiagramLabelPriority(times[j].labels)
|
||||||
|
}
|
||||||
|
return times[i].jde < times[j].jde
|
||||||
|
})
|
||||||
|
return uniqueStarOccultationDiagramTimes(times), stepDays
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStarOccultationDiagramTimes(times []starOccultationDiagramTime) []starOccultationDiagramTime {
|
||||||
|
unique := times[:0]
|
||||||
|
for _, item := range times {
|
||||||
|
if !finite(item.jde) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(unique) == 0 || math.Abs(item.jde-unique[len(unique)-1].jde) > starOccultationDiagramDuplicateDays {
|
||||||
|
item.labels = append([]string(nil), item.labels...)
|
||||||
|
unique = append(unique, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unique[len(unique)-1].labels = mergeStarOccultationDiagramLabels(unique[len(unique)-1].labels, item.labels)
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeStarOccultationDiagramLabels(existing, incoming []string) []string {
|
||||||
|
for _, label := range incoming {
|
||||||
|
found := false
|
||||||
|
for _, current := range existing {
|
||||||
|
if current == label {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
existing = append(existing, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationDiagramPrimaryLabel(labels []string) string {
|
||||||
|
for _, label := range labels {
|
||||||
|
if label == "Greatest" {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return labels[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationDiagramLabelPriority(labels []string) int {
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return 99
|
||||||
|
}
|
||||||
|
switch labels[0] {
|
||||||
|
case "Immersion":
|
||||||
|
return 0
|
||||||
|
case "Greatest":
|
||||||
|
return 1
|
||||||
|
case "Emersion":
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationDiagramFrameAt(tt float64, star StarCoordinate, observer Observer) (StarOccultationDiagramFrame, bool) {
|
||||||
|
position := starMoonPositionAt(tt, star, observer)
|
||||||
|
moonRadius := moonTopocentricSemidiameterN(tt, observer, -1)
|
||||||
|
if !position.valid || !finite(moonRadius) || moonRadius <= 0 {
|
||||||
|
return StarOccultationDiagramFrame{}, false
|
||||||
|
}
|
||||||
|
separation := angularSeparationDegrees(position.moonRA, position.moonDec, position.starRA, position.starDec) * 3600
|
||||||
|
positionAngle := occultationPositionAngle(position.moonRA, position.moonDec, position.starRA, position.starDec)
|
||||||
|
if !finite(separation) || !finite(positionAngle) {
|
||||||
|
return StarOccultationDiagramFrame{}, false
|
||||||
|
}
|
||||||
|
angle := positionAngle * math.Pi / 180
|
||||||
|
return StarOccultationDiagramFrame{
|
||||||
|
JDE: tt,
|
||||||
|
StarXArcsec: separation * math.Sin(angle),
|
||||||
|
StarYArcsec: separation * math.Cos(angle),
|
||||||
|
MoonRadiusArcsec: moonRadius,
|
||||||
|
SeparationArcsec: separation,
|
||||||
|
PositionAngleDeg: positionAngle,
|
||||||
|
MoonAltitudeDeg: occultationAltitude(tt, observer, position.moonRA, position.moonDec),
|
||||||
|
MoonAzimuthDeg: occultationAzimuth(tt, observer, position.moonRA, position.moonDec),
|
||||||
|
BehindMoon: separation < moonRadius-starOccultationGrazingTolerance,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStarOccultationDiagramUsesSolvedLocalContacts(t *testing.T) {
|
||||||
|
location := time.FixedZone("CST", 8*3600)
|
||||||
|
star := StarCoordinate{
|
||||||
|
ID: "HR 4799",
|
||||||
|
RA: 189.1975,
|
||||||
|
Dec: -5.831944444444,
|
||||||
|
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||||
|
Frame: CoordinateFrameJ2000,
|
||||||
|
ProperMotionRACosDecMasPerYear: -28,
|
||||||
|
ProperMotionDecMasPerYear: -18,
|
||||||
|
}
|
||||||
|
events, err := FindStarOccultations(
|
||||||
|
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||||
|
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||||
|
star, 121.56601, 6.80706, 0, OccultationSearchOptions{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindStarOccultations() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("FindStarOccultations() returned %d events, want 1", len(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
diagram := StarOccultationDiagram(events[0], star, StarOccultationDiagramOptions{StepDays: 2.0 / 1440})
|
||||||
|
if len(diagram.Frames) < 3 {
|
||||||
|
t.Fatalf("StarOccultationDiagram() frame count = %d, want at least 3", len(diagram.Frames))
|
||||||
|
}
|
||||||
|
if !starOccultationDiagramFrameHasLabel(diagram.Frames[0], "Immersion") {
|
||||||
|
t.Fatalf("first frame labels = %v, want Immersion", diagram.Frames[0].Labels)
|
||||||
|
}
|
||||||
|
last := diagram.Frames[len(diagram.Frames)-1]
|
||||||
|
if !starOccultationDiagramFrameHasLabel(last, "Emersion") {
|
||||||
|
t.Fatalf("last frame labels = %v, want Emersion", last.Labels)
|
||||||
|
}
|
||||||
|
if residual := math.Abs(diagram.Frames[0].SeparationArcsec - diagram.Frames[0].MoonRadiusArcsec); residual > 0.1 {
|
||||||
|
t.Fatalf("immersion limb residual = %.6f arcsec, want <= 0.1", residual)
|
||||||
|
}
|
||||||
|
if residual := math.Abs(last.SeparationArcsec - last.MoonRadiusArcsec); residual > 0.1 {
|
||||||
|
t.Fatalf("emersion limb residual = %.6f arcsec, want <= 0.1", residual)
|
||||||
|
}
|
||||||
|
greatest, ok := starOccultationDiagramFrameByLabel(diagram.Frames, "Greatest")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("diagram does not contain Greatest frame")
|
||||||
|
}
|
||||||
|
if !greatest.BehindMoon || greatest.SeparationArcsec >= greatest.MoonRadiusArcsec {
|
||||||
|
t.Fatalf("greatest frame is not behind Moon: separation=%.6f radius=%.6f", greatest.SeparationArcsec, greatest.MoonRadiusArcsec)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrongStar := star
|
||||||
|
wrongStar.RA += 30
|
||||||
|
if frames := StarOccultationDiagram(events[0], wrongStar, StarOccultationDiagramOptions{}).Frames; len(frames) != 0 {
|
||||||
|
t.Fatalf("same-ID wrong coordinate produced %d frames, want none", len(frames))
|
||||||
|
}
|
||||||
|
infoWithoutID := events[0]
|
||||||
|
infoWithoutID.TargetID = ""
|
||||||
|
wrongStar.ID = ""
|
||||||
|
if frames := StarOccultationDiagram(infoWithoutID, wrongStar, StarOccultationDiagramOptions{}).Frames; len(frames) != 0 {
|
||||||
|
t.Fatalf("empty-ID wrong coordinate produced %d frames, want none", len(frames))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationDiagramFrameHasLabel(frame StarOccultationDiagramFrame, label string) bool {
|
||||||
|
for _, current := range frame.Labels {
|
||||||
|
if current == label {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func starOccultationDiagramFrameByLabel(frames []StarOccultationDiagramFrame, label string) (StarOccultationDiagramFrame, bool) {
|
||||||
|
for _, frame := range frames {
|
||||||
|
if starOccultationDiagramFrameHasLabel(frame, label) {
|
||||||
|
return frame, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StarOccultationDiagramFrame{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStarOccultationLatitudeEnvelopePrefilter(t *testing.T) {
|
||||||
|
start := occultationTimeToTT(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
end := start + starOccultationSiderealMonthDays
|
||||||
|
polar := StarCoordinate{RA: 0, Dec: 89, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameICRS}
|
||||||
|
if starOccultationLatitudeEnvelopePass(start, end, polar, nil, 0) {
|
||||||
|
t.Fatal("polar star should be rejected by global ecliptic-latitude envelope")
|
||||||
|
}
|
||||||
|
if !starOccultationLatitudeEnvelopePass(start, end, hr4799OccultationCoordinateForTest(), nil, 0) {
|
||||||
|
t.Fatal("HR 4799 should pass the global ecliptic-latitude envelope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarOccultationScanCandidatesFindsMultipleLocalMinima(t *testing.T) {
|
||||||
|
value := func(tt float64) float64 {
|
||||||
|
return (tt-1)*(tt-1)*(tt-3)*(tt-3) + 0.001
|
||||||
|
}
|
||||||
|
got := starOccultationScanCandidates(0, 4, 0.5, value, func(float64) bool { return true })
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("candidate count = %d, want 2: %v", len(got), got)
|
||||||
|
}
|
||||||
|
if math.Abs(got[0]-1) > 1e-6 || math.Abs(got[1]-3) > 1e-6 {
|
||||||
|
t.Fatalf("candidate minima = %v, want [1 3]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarOccultationScanCandidatesFindsNarrowWindowMidpoint(t *testing.T) {
|
||||||
|
const (
|
||||||
|
start = 10.0
|
||||||
|
end = 10.1
|
||||||
|
want = (start + end) / 2
|
||||||
|
)
|
||||||
|
value := func(tt float64) float64 { return (tt - want) * (tt - want) }
|
||||||
|
got := starOccultationScanCandidates(start, end, starOccultationDefaultStepDays, value, func(float64) bool { return true })
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("candidate count = %d, want 1: %v", len(got), got)
|
||||||
|
}
|
||||||
|
if math.Abs(got[0]-want) > 1e-7 {
|
||||||
|
t.Fatalf("candidate midpoint = %.12f, want %.12f", got[0], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniqueOccultationCandidateTimesFiltersSingleOutsideCandidate(t *testing.T) {
|
||||||
|
if got := uniqueOccultationCandidateTimes([]float64{9.9}, 10, 11); len(got) != 0 {
|
||||||
|
t.Fatalf("outside candidate was not filtered: %v", got)
|
||||||
|
}
|
||||||
|
got := uniqueOccultationCandidateTimes([]float64{10.5}, 10, 11)
|
||||||
|
if len(got) != 1 || got[0] != 10.5 {
|
||||||
|
t.Fatalf("inside candidate = %v, want [10.5]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarOccultationPropagatesApparentCoordinateProperMotion(t *testing.T) {
|
||||||
|
epoch := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
target := epoch.Add(365*24*time.Hour + 6*time.Hour)
|
||||||
|
star := StarCoordinate{
|
||||||
|
RA: 10,
|
||||||
|
Dec: 20,
|
||||||
|
Epoch: epoch,
|
||||||
|
Frame: CoordinateFrameApparentOfDate,
|
||||||
|
ProperMotionRACosDecMasPerYear: 360000,
|
||||||
|
ProperMotionDecMasPerYear: -720000,
|
||||||
|
}
|
||||||
|
baseline := star
|
||||||
|
baseline.ProperMotionRACosDecMasPerYear = 0
|
||||||
|
baseline.ProperMotionDecMasPerYear = 0
|
||||||
|
baselineRA, baselineDec := starApparentRaDec(occultationTimeToTT(target), baseline, Observer{})
|
||||||
|
ra, dec := starApparentRaDec(occultationTimeToTT(target), star, Observer{})
|
||||||
|
raMotion := signedAngleDifference(ra, baselineRA) * math.Cos(baselineDec*math.Pi/180)
|
||||||
|
decMotion := dec - baselineDec
|
||||||
|
if math.Abs(raMotion-0.1) > 0.001 {
|
||||||
|
t.Fatalf("propagated RA*cos(Dec) motion = %.10f deg, want 0.1", raMotion)
|
||||||
|
}
|
||||||
|
if math.Abs(decMotion-(-0.2)) > 0.001 {
|
||||||
|
t.Fatalf("propagated Dec motion = %.10f deg, want -0.2", decMotion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarOccultationApparentOfDateRoundTripsAtEpoch(t *testing.T) {
|
||||||
|
epoch := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||||
|
star := StarCoordinate{RA: 189.5, Dec: -6.2, Epoch: epoch, Frame: CoordinateFrameApparentOfDate, ParallaxMas: 100}
|
||||||
|
ra, dec := starApparentRaDecGeocentric(occultationTimeToTT(epoch), star)
|
||||||
|
if math.Abs(signedAngleDifference(ra, star.RA))*3600 > 1e-5 || math.Abs(dec-star.Dec)*3600 > 1e-5 {
|
||||||
|
t.Fatalf("apparent coordinate did not round-trip at epoch: got %.12f %.12f", ra, dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarOccultationApparentPlaceCorrections(t *testing.T) {
|
||||||
|
location := time.FixedZone("CST", 8*3600)
|
||||||
|
tt := occultationTimeToTT(time.Date(2025, 6, 5, 20, 2, 7, 700000000, location))
|
||||||
|
star := hr4799OccultationCoordinateForTest()
|
||||||
|
|
||||||
|
gotRA, gotDec := starApparentRaDecGeocentric(tt, star)
|
||||||
|
if math.Abs(signedAngleDifference(gotRA, 189.527817)) > 0.0002 || math.Abs(gotDec-(-5.973401)) > 0.0002 {
|
||||||
|
t.Fatalf("apparent place = %.9f %.9f, want near 189.527817 -5.973401", gotRA, gotDec)
|
||||||
|
}
|
||||||
|
|
||||||
|
years := (tt - Date2JDE(star.Epoch.UTC())) / 365.25
|
||||||
|
meanRA := star.RA + years*star.ProperMotionRACosDecMasPerYear/(3600000*math.Cos(star.Dec*math.Pi/180))
|
||||||
|
meanDec := star.Dec + years*star.ProperMotionDecMasPerYear/3600000
|
||||||
|
meanRA, meanDec = Precess(meanRA, meanDec, 2451545, tt)
|
||||||
|
correction := angularSeparationDegrees(meanRA, meanDec, gotRA, gotDec) * 3600
|
||||||
|
if correction < 5 || correction > 30 {
|
||||||
|
t.Fatalf("apparent-place correction = %.6f arcsec, want a plausible annual correction", correction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarOccultationApparentPlaceAppliesAnnualParallax(t *testing.T) {
|
||||||
|
star := hr4799OccultationCoordinateForTest()
|
||||||
|
tt := occultationTimeToTT(time.Date(2025, 6, 5, 12, 0, 0, 0, time.UTC))
|
||||||
|
withoutRA, withoutDec := starApparentRaDecGeocentric(tt, star)
|
||||||
|
star.ParallaxMas = 1000
|
||||||
|
withRA, withDec := starApparentRaDecGeocentric(tt, star)
|
||||||
|
shift := angularSeparationDegrees(withoutRA, withoutDec, withRA, withDec) * 3600
|
||||||
|
if shift < 0.05 || shift > 1.1 {
|
||||||
|
t.Fatalf("annual parallax shift = %.6f arcsec, want (0.05, 1.1]", shift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarOccultationICRSAppliesJ2000FrameBias(t *testing.T) {
|
||||||
|
epoch := time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
icrs := StarCoordinate{RA: 0, Dec: 0, Epoch: epoch, Frame: CoordinateFrameICRS}
|
||||||
|
j2000 := icrs
|
||||||
|
j2000.Frame = CoordinateFrameJ2000
|
||||||
|
tt := occultationTimeToTT(epoch)
|
||||||
|
icrsRA, icrsDec := starApparentRaDecGeocentric(tt, icrs)
|
||||||
|
j2000RA, j2000Dec := starApparentRaDecGeocentric(tt, j2000)
|
||||||
|
raBiasMas := signedAngleDifference(icrsRA, j2000RA) * 3600000
|
||||||
|
decBiasMas := (icrsDec - j2000Dec) * 3600000
|
||||||
|
if math.Abs(raBiasMas-14.6) > 0.1 || math.Abs(decBiasMas-(-16.617)) > 0.1 {
|
||||||
|
t.Fatalf("ICRS frame bias = %.6f %.6f mas, want about 14.6 -16.617", raBiasMas, decBiasMas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefinedStarOccultationCenterLineRespectsWidthTolerance(t *testing.T) {
|
||||||
|
star := hr4799OccultationCoordinateForTest()
|
||||||
|
start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC)
|
||||||
|
paths, err := FindStarOccultationPaths(
|
||||||
|
start, start.Add(24*time.Hour), star,
|
||||||
|
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 50},
|
||||||
|
)
|
||||||
|
if err != nil || len(paths) != 1 {
|
||||||
|
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||||
|
}
|
||||||
|
for index, point := range paths[0].CenterLine {
|
||||||
|
exact, ok := starOccultationPathCenterPoint(centerTimeTT(point.Time), star, time.UTC)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("exact center point %d is unavailable", index)
|
||||||
|
}
|
||||||
|
if difference := math.Abs(point.WidthKM - exact.WidthKM); difference > occultationPathWidthToleranceKM {
|
||||||
|
t.Fatalf("center point %d width differs from exact value by %.9f km: got %.9f want %.9f",
|
||||||
|
index, difference, point.WidthKM, exact.WidthKM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefineOccultationPathWidthsBoundsSmoothInterpolationError(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
startTT := occultationTimeToTT(start)
|
||||||
|
widthAt := func(tt float64) (float64, bool) {
|
||||||
|
seconds := (tt - startTT) * 86400
|
||||||
|
return 3500 + 0.0002*(seconds-50)*(seconds-50), true
|
||||||
|
}
|
||||||
|
points := make([]OccultationPathPoint, 101)
|
||||||
|
for index := range points {
|
||||||
|
points[index].Time = start.Add(time.Duration(index) * time.Second)
|
||||||
|
}
|
||||||
|
points[0].WidthKM, _ = widthAt(centerTimeTT(points[0].Time))
|
||||||
|
points[len(points)-1].WidthKM, _ = widthAt(centerTimeTT(points[len(points)-1].Time))
|
||||||
|
refineOccultationPathWidths(points, widthAt)
|
||||||
|
for index, point := range points {
|
||||||
|
exact, _ := widthAt(centerTimeTT(point.Time))
|
||||||
|
if difference := math.Abs(point.WidthKM - exact); difference > occultationPathWidthToleranceKM {
|
||||||
|
t.Fatalf("interpolated width %d differs by %.9f km, tolerance %.9f", index, difference, occultationPathWidthToleranceKM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hr4799OccultationCoordinateForTest() StarCoordinate {
|
||||||
|
return StarCoordinate{
|
||||||
|
ID: "HR 4799",
|
||||||
|
RA: 189.1975,
|
||||||
|
Dec: -5.831944444444,
|
||||||
|
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||||
|
Frame: CoordinateFrameJ2000,
|
||||||
|
ProperMotionRACosDecMasPerYear: -28,
|
||||||
|
ProperMotionDecMasPerYear: -18,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,9 @@ func OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight floa
|
|||||||
|
|
||||||
// OrbitCulminationTime 返回轨道目标的中天时刻,输入输出均沿用本仓库现有观测函数的 JD 语义。
|
// OrbitCulminationTime 返回轨道目标的中天时刻,输入输出均沿用本仓库现有观测函数的 JD 语义。
|
||||||
func OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 {
|
func OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 {
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(observerLon) || !isFiniteFloat(observerLat) || !isFiniteFloat(timezone) || !isFiniteFloat(observerHeight) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
jde = math.Floor(jde) + 0.5
|
jde = math.Floor(jde) + 0.5
|
||||||
estimateJD := jde + Limit360(360-OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight, elements))/15.0/24.0*0.99726851851851851851
|
estimateJD := jde + Limit360(360-OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight, elements))/15.0/24.0*0.99726851851851851851
|
||||||
normalizedHourAngle := func(jde float64) float64 {
|
normalizedHourAngle := func(jde float64) float64 {
|
||||||
@@ -61,14 +64,14 @@ func OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeigh
|
|||||||
}
|
}
|
||||||
return currentHourAngle
|
return currentHourAngle
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
hourAngleDelta := normalizedHourAngle(prevJD) - 360
|
hourAngleDelta := normalizedHourAngle(prevJD) - 360
|
||||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005) - normalizedHourAngle(prevJD-0.000005)) / 0.00001
|
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005) - normalizedHourAngle(prevJD-0.000005)) / 0.00001
|
||||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
return hourAngleDelta / hourAngleSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
@@ -84,19 +87,33 @@ func OrbitSetTime(jde, observerLon, observerLat, timezone, aeroCorrection, obser
|
|||||||
}
|
}
|
||||||
|
|
||||||
func orbitRiseDown(jde, observerLon, observerLat, timezone, aeroCorrection, observerHeight float64, elements OrbitElements, isRise bool) (float64, error) {
|
func orbitRiseDown(jde, observerLon, observerLat, timezone, aeroCorrection, observerHeight float64, elements OrbitElements, isRise bool) (float64, error) {
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(observerLon) || !isFiniteFloat(observerLat) || !isFiniteFloat(timezone) || !isFiniteFloat(aeroCorrection) || !isFiniteFloat(observerHeight) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
localTimezone := math.Round(observerLon / 15)
|
localTimezone := math.Round(observerLon / 15)
|
||||||
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, observerLat)
|
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, observerLat)
|
||||||
|
|
||||||
culminationJD := OrbitCulminationTime(jde, observerLon, observerLat, localTimezone, observerHeight, elements)
|
culminationJD := OrbitCulminationTime(jde, observerLon, observerLat, localTimezone, observerHeight, elements)
|
||||||
if OrbitHeight(culminationJD, observerLon, observerLat, localTimezone, observerHeight, elements) < targetAltitude {
|
if !isFiniteFloat(culminationJD) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
culminationHeight := OrbitHeight(culminationJD, observerLon, observerLat, localTimezone, observerHeight, elements)
|
||||||
|
previousHeight := OrbitHeight(culminationJD-0.5, observerLon, observerLat, localTimezone, observerHeight, elements)
|
||||||
|
if !isFiniteFloat(culminationHeight) || !isFiniteFloat(previousHeight) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
if culminationHeight < targetAltitude {
|
||||||
return 0, ErrNeverRise
|
return 0, ErrNeverRise
|
||||||
}
|
}
|
||||||
if OrbitHeight(culminationJD-0.5, observerLon, observerLat, localTimezone, observerHeight, elements) > targetAltitude {
|
if previousHeight > targetAltitude {
|
||||||
return 0, ErrNeverSet
|
return 0, ErrNeverSet
|
||||||
}
|
}
|
||||||
|
|
||||||
_, dec, _ := orbitTopocentricObservation(culminationJD, observerLon, observerLat, observerHeight, localTimezone, elements)
|
_, dec, _ := orbitTopocentricObservation(culminationJD, observerLon, observerLat, observerHeight, localTimezone, elements)
|
||||||
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(observerLat)) / (Cos(dec) * Cos(observerLat))
|
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(observerLat)) / (Cos(dec) * Cos(observerLat))
|
||||||
|
if !isFiniteFloat(dec) || !isFiniteFloat(cosHourAngle) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
|
||||||
var eventJD float64
|
var eventJD float64
|
||||||
if math.Abs(cosHourAngle) <= 1 {
|
if math.Abs(cosHourAngle) <= 1 {
|
||||||
@@ -122,15 +139,13 @@ func orbitRiseDown(jde, observerLon, observerLat, timezone, aeroCorrection, obse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
estimateJD := eventJD
|
estimateJD, ok := eventNewtonRefine(eventJD, 0.00001, func(prevJD float64) float64 {
|
||||||
for {
|
|
||||||
prevJD := estimateJD
|
|
||||||
altitudeDelta := OrbitHeight(prevJD, observerLon, observerLat, localTimezone, observerHeight, elements) - targetAltitude
|
altitudeDelta := OrbitHeight(prevJD, observerLon, observerLat, localTimezone, observerHeight, elements) - targetAltitude
|
||||||
altitudeSlope := (OrbitHeight(prevJD+0.000005, observerLon, observerLat, localTimezone, observerHeight, elements) - OrbitHeight(prevJD-0.000005, observerLon, observerLat, localTimezone, observerHeight, elements)) / 0.00001
|
altitudeSlope := (OrbitHeight(prevJD+0.000005, observerLon, observerLat, localTimezone, observerHeight, elements) - OrbitHeight(prevJD-0.000005, observerLon, observerLat, localTimezone, observerHeight, elements)) / 0.00001
|
||||||
estimateJD = prevJD - altitudeDelta/altitudeSlope
|
return altitudeDelta / altitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return 0, ErrInvalidObservationInput
|
||||||
}
|
}
|
||||||
return estimateJD - localTimezone/24 + timezone/24, nil
|
return estimateJD - localTimezone/24 + timezone/24, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanetEventsRejectNonFiniteQuery(t *testing.T) {
|
||||||
|
nan := math.NaN()
|
||||||
|
events := []struct {
|
||||||
|
name string
|
||||||
|
fn func(float64) float64
|
||||||
|
}{
|
||||||
|
{"Mars conjunction", NextMarsConjunction},
|
||||||
|
{"Jupiter conjunction", NextJupiterConjunction},
|
||||||
|
{"Saturn conjunction", NextSaturnConjunction},
|
||||||
|
{"Uranus conjunction", NextUranusConjunction},
|
||||||
|
{"Neptune conjunction", NextNeptuneConjunction},
|
||||||
|
{"Mercury conjunction", NextMercuryConjunction},
|
||||||
|
{"Mercury greatest elongation", NextMercuryGreatestElongation},
|
||||||
|
{"Venus greatest elongation", NextVenusGreatestElongation},
|
||||||
|
{"Venus station", NextVenusProgradeToRetrograde},
|
||||||
|
}
|
||||||
|
for _, tc := range events {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := tc.fn(nan); !math.IsNaN(got) {
|
||||||
|
t.Fatalf("got %v, want NaN", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanetObservationRejectsNonFiniteQuery(t *testing.T) {
|
||||||
|
nan := math.NaN()
|
||||||
|
culminations := []struct {
|
||||||
|
name string
|
||||||
|
fn func(float64, float64, float64) float64
|
||||||
|
}{
|
||||||
|
{"Mercury", MercuryCulminationTime},
|
||||||
|
{"Venus", VenusCulminationTime},
|
||||||
|
{"Mars", MarsCulminationTime},
|
||||||
|
{"Jupiter", JupiterCulminationTime},
|
||||||
|
{"Saturn", SaturnCulminationTime},
|
||||||
|
{"Uranus", UranusCulminationTime},
|
||||||
|
{"Neptune", NeptuneCulminationTime},
|
||||||
|
}
|
||||||
|
for _, tc := range culminations {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := tc.fn(nan, 0, 0); !math.IsNaN(got) {
|
||||||
|
t.Fatalf("got %v, want NaN", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := OrbitCulminationTime(nan, 0, 0, 0, 0, OrbitElements{}); !math.IsNaN(got) {
|
||||||
|
t.Fatalf("OrbitCulminationTime got %v, want NaN", got)
|
||||||
|
}
|
||||||
|
if got := MercuryCulminationTimeN(nan, 0, 0, 16); !math.IsNaN(got) {
|
||||||
|
t.Fatalf("MercuryCulminationTimeN got %v, want NaN", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := MarsRiseTime(nan, 0, 0, 0, 0, 0); err != ErrInvalidObservationInput {
|
||||||
|
t.Fatalf("MarsRiseTime error = %v, want ErrInvalidObservationInput", err)
|
||||||
|
}
|
||||||
|
if _, err := MercuryRiseTimeN(nan, 0, 0, 0, 0, 0, 16); err != ErrInvalidObservationInput {
|
||||||
|
t.Fatalf("MercuryRiseTimeN error = %v, want ErrInvalidObservationInput", err)
|
||||||
|
}
|
||||||
|
if got := JupiterGalileanSatelliteState(nan, 1); !math.IsNaN(got.X) {
|
||||||
|
t.Fatalf("JupiterGalileanSatelliteState X = %v, want NaN", got.X)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedObservationRejectsNonFiniteQuery(t *testing.T) {
|
||||||
|
nan := math.NaN()
|
||||||
|
if got := MoonCulminationTime(nan, 0, 0, 0); !math.IsNaN(got) {
|
||||||
|
t.Fatalf("MoonCulminationTime got %v, want NaN", got)
|
||||||
|
}
|
||||||
|
if got := StarCulminationTime(nan, 0, 0, 0); !math.IsNaN(got) {
|
||||||
|
t.Fatalf("StarCulminationTime got %v, want NaN", got)
|
||||||
|
}
|
||||||
|
if got := CalcMoonSHByJDE(nan, 0); !math.IsNaN(got) {
|
||||||
|
t.Fatalf("CalcMoonSHByJDE got %v, want NaN", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := GetSunRiseTime(nan, 0, 0, 0, 0, 0); err != ErrInvalidObservationInput {
|
||||||
|
t.Fatalf("GetSunRiseTime error = %v, want ErrInvalidObservationInput", err)
|
||||||
|
}
|
||||||
|
if _, err := GetMoonRiseTime(nan, 0, 0, 0, 0, 0); err != ErrInvalidObservationInput {
|
||||||
|
t.Fatalf("GetMoonRiseTime error = %v, want ErrInvalidObservationInput", err)
|
||||||
|
}
|
||||||
|
if _, err := StarRiseTime(nan, 0, 0, 0, 0, 0, 0, false); err != ErrInvalidObservationInput {
|
||||||
|
t.Fatalf("StarRiseTime error = %v, want ErrInvalidObservationInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,9 +195,23 @@ func nextPlanetTransitSeasonTT(jdTT float64, cfg planetTransitConfig, direction
|
|||||||
for nodeOffset := 0; nodeOffset <= 1; nodeOffset++ {
|
for nodeOffset := 0; nodeOffset <= 1; nodeOffset++ {
|
||||||
candidate := estimatePlanetTransitSeasonTT(jdTT, cfg, nodeOffset, direction)
|
candidate := estimatePlanetTransitSeasonTT(jdTT, cfg, nodeOffset, direction)
|
||||||
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset)
|
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset)
|
||||||
for !planetTransitMatchesDirection(candidate, jdTT, direction, false) {
|
if !isFiniteFloat(candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchedDirection := false
|
||||||
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
|
if planetTransitMatchesDirection(candidate, jdTT, direction, false) {
|
||||||
|
matchedDirection = true
|
||||||
|
break
|
||||||
|
}
|
||||||
candidate += float64(direction) * planetTransitTropicalYearDays
|
candidate += float64(direction) * planetTransitTropicalYearDays
|
||||||
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset)
|
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset)
|
||||||
|
if !isFiniteFloat(candidate) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matchedDirection {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if !isFiniteFloat(best) || math.Abs(candidate-jdTT) < math.Abs(best-jdTT) {
|
if !isFiniteFloat(best) || math.Abs(candidate-jdTT) < math.Abs(best-jdTT) {
|
||||||
best = candidate
|
best = candidate
|
||||||
@@ -514,7 +528,3 @@ func planetTransitAngleDelta(diff float64) float64 {
|
|||||||
}
|
}
|
||||||
return diff
|
return diff
|
||||||
}
|
}
|
||||||
|
|
||||||
func isFiniteFloat(value float64) bool {
|
|
||||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ func planetHourAngleN(jd, lon, timezone float64, n int, apparentRa func(float64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func planetCulminationTimeN(jde, lon, timezone float64, n int, hourAngle func(float64, float64, float64, int) float64) float64 {
|
func planetCulminationTimeN(jde, lon, timezone float64, n int, hourAngle func(float64, float64, float64, int) float64) float64 {
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(lon) || !isFiniteFloat(timezone) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
jde = math.Floor(jde) + 0.5
|
jde = math.Floor(jde) + 0.5
|
||||||
estimateJD := jde + Limit360(360-hourAngle(jde, lon, timezone, n))/15.0/24.0*0.99726851851851851851
|
estimateJD := jde + Limit360(360-hourAngle(jde, lon, timezone, n))/15.0/24.0*0.99726851851851851851
|
||||||
normalizedHourAngle := func(jde, lon, timezone float64) float64 {
|
normalizedHourAngle := func(jde, lon, timezone float64) float64 {
|
||||||
@@ -101,31 +104,45 @@ func planetCulminationTimeN(jde, lon, timezone float64, n int, hourAngle func(fl
|
|||||||
}
|
}
|
||||||
return currentHourAngle
|
return currentHourAngle
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
return hourAngleDelta / hourAngleSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|
||||||
func planetRiseDownN(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, n int, culmination func(float64, float64, float64, int) float64, height func(float64, float64, float64, float64, int) float64, declination planetDeclinationFuncN) (float64, error) {
|
func planetRiseDownN(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, n int, culmination func(float64, float64, float64, int) float64, height func(float64, float64, float64, float64, int) float64, declination planetDeclinationFuncN) (float64, error) {
|
||||||
|
if !isFiniteFloat(jd) || !isFiniteFloat(lon) || !isFiniteFloat(lat) || !isFiniteFloat(timezone) || !isFiniteFloat(aeroCorrection) || !isFiniteFloat(observerHeight) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
jd = math.Floor(jd) + 0.5
|
jd = math.Floor(jd) + 0.5
|
||||||
localTimezone := math.Round(lon / 15)
|
localTimezone := math.Round(lon / 15)
|
||||||
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, lat)
|
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, lat)
|
||||||
culminationJD := culmination(jd, lon, localTimezone, n)
|
culminationJD := culmination(jd, lon, localTimezone, n)
|
||||||
if height(culminationJD, lon, lat, localTimezone, n) < targetAltitude {
|
if !isFiniteFloat(culminationJD) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
culminationHeight := height(culminationJD, lon, lat, localTimezone, n)
|
||||||
|
previousHeight := height(culminationJD-0.5, lon, lat, localTimezone, n)
|
||||||
|
if !isFiniteFloat(culminationHeight) || !isFiniteFloat(previousHeight) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
if culminationHeight < targetAltitude {
|
||||||
return 0, ErrNeverRise
|
return 0, ErrNeverRise
|
||||||
}
|
}
|
||||||
if height(culminationJD-0.5, lon, lat, localTimezone, n) > targetAltitude {
|
if previousHeight > targetAltitude {
|
||||||
return 0, ErrNeverSet
|
return 0, ErrNeverSet
|
||||||
}
|
}
|
||||||
dec := declination(TD2UT(culminationJD-localTimezone/24, true), n)
|
dec := declination(TD2UT(culminationJD-localTimezone/24, true), n)
|
||||||
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat))
|
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat))
|
||||||
|
if !isFiniteFloat(dec) || !isFiniteFloat(cosHourAngle) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
var eventJD float64
|
var eventJD float64
|
||||||
if math.Abs(cosHourAngle) <= 1 {
|
if math.Abs(cosHourAngle) <= 1 {
|
||||||
hourOffset := ArcCos(cosHourAngle) / 15
|
hourOffset := ArcCos(cosHourAngle) / 15
|
||||||
@@ -149,15 +166,13 @@ func planetRiseDownN(jd, lon, lat, timezone, aeroCorrection, observerHeight floa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
estimateJD := eventJD
|
estimateJD, ok := eventNewtonRefine(eventJD, 0.00001, func(prevJD float64) float64 {
|
||||||
for {
|
|
||||||
prevJD := estimateJD
|
|
||||||
altitudeDelta := height(prevJD, lon, lat, localTimezone, n) - targetAltitude
|
altitudeDelta := height(prevJD, lon, lat, localTimezone, n) - targetAltitude
|
||||||
altitudeSlope := (height(prevJD+0.000005, lon, lat, localTimezone, n) - height(prevJD-0.000005, lon, lat, localTimezone, n)) / 0.00001
|
altitudeSlope := (height(prevJD+0.000005, lon, lat, localTimezone, n) - height(prevJD-0.000005, lon, lat, localTimezone, n)) / 0.00001
|
||||||
estimateJD = prevJD - altitudeDelta/altitudeSlope
|
return altitudeDelta / altitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return 0, ErrInvalidObservationInput
|
||||||
}
|
}
|
||||||
return estimateJD - localTimezone/24 + timezone/24, nil
|
return estimateJD - localTimezone/24 + timezone/24, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,32 +4,37 @@ import "math"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
refractionStandardPressureHPa = 1010.0
|
refractionStandardPressureHPa = 1010.0
|
||||||
refractionStandardTemperatureK = 283.0
|
refractionStandardTemperatureC = 10.0
|
||||||
|
refractionStandardTemperatureK = 283.15
|
||||||
refractionAbsoluteZeroC = -273.15
|
refractionAbsoluteZeroC = -273.15
|
||||||
refractionLowerLimitAltitudeDeg = -5.0
|
refractionLowerLimitAltitudeDeg = -5.0
|
||||||
refractionUpperLimitAltitudeDeg = 90.0
|
refractionUpperLimitAltitudeDeg = 90.0
|
||||||
)
|
)
|
||||||
|
|
||||||
// RefractionFromApparentAltitude 大气折射修正量,单位度;输入为视高度角。
|
// RefractionFromApparentAltitude 大气折射修正量,单位度;输入为视高度角。
|
||||||
// 返回值应从真高度角加上后得到视高度角。
|
// 返回值应从视高度角减去后得到真高度角。
|
||||||
|
// 若模型在支持的真高度范围内没有逆解,则返回 NaN。
|
||||||
func RefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC float64) float64 {
|
func RefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC float64) float64 {
|
||||||
if !validRefractionInputs(apparentAltitude, pressureHPa, temperatureC) {
|
if !validRefractionInputs(apparentAltitude, pressureHPa, temperatureC) {
|
||||||
return math.NaN()
|
return math.NaN()
|
||||||
}
|
}
|
||||||
if apparentAltitude < refractionLowerLimitAltitudeDeg || apparentAltitude > refractionUpperLimitAltitudeDeg {
|
if apparentAltitude <= refractionLowerLimitAltitudeDeg || apparentAltitude >= refractionUpperLimitAltitudeDeg {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
angle := (apparentAltitude + 10.3/(apparentAltitude+5.11)) * math.Pi / 180
|
trueAltitude := trueAltitudeFromApparent(apparentAltitude, pressureHPa, temperatureC)
|
||||||
return refractionScale(pressureHPa, temperatureC) * (1.02 / math.Tan(angle)) / 60
|
return apparentAltitude - trueAltitude
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrueAltitude 真高度角,单位度;输入为视高度角。
|
// TrueAltitude 真高度角,单位度;输入为视高度角。若折射模型在支持的
|
||||||
|
// 真高度范围内没有逆解,则返回 NaN。
|
||||||
func TrueAltitude(apparentAltitude, pressureHPa, temperatureC float64) float64 {
|
func TrueAltitude(apparentAltitude, pressureHPa, temperatureC float64) float64 {
|
||||||
refraction := RefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC)
|
if !validRefractionInputs(apparentAltitude, pressureHPa, temperatureC) {
|
||||||
if math.IsNaN(refraction) {
|
|
||||||
return math.NaN()
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return apparentAltitude - refraction
|
if apparentAltitude <= refractionLowerLimitAltitudeDeg || apparentAltitude >= refractionUpperLimitAltitudeDeg {
|
||||||
|
return apparentAltitude
|
||||||
|
}
|
||||||
|
return trueAltitudeFromApparent(apparentAltitude, pressureHPa, temperatureC)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApparentAltitude 视高度角,单位度;输入为真高度角。
|
// ApparentAltitude 视高度角,单位度;输入为真高度角。
|
||||||
@@ -37,45 +42,118 @@ func ApparentAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
|
|||||||
if !validRefractionInputs(trueAltitude, pressureHPa, temperatureC) {
|
if !validRefractionInputs(trueAltitude, pressureHPa, temperatureC) {
|
||||||
return math.NaN()
|
return math.NaN()
|
||||||
}
|
}
|
||||||
if trueAltitude < refractionLowerLimitAltitudeDeg || trueAltitude > refractionUpperLimitAltitudeDeg {
|
return trueAltitude + refractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC)
|
||||||
return trueAltitude
|
|
||||||
}
|
|
||||||
|
|
||||||
estimate := trueAltitude + RefractionFromApparentAltitude(trueAltitude, pressureHPa, temperatureC)
|
|
||||||
for i := 0; i < 8; i++ {
|
|
||||||
refraction := RefractionFromApparentAltitude(estimate, pressureHPa, temperatureC)
|
|
||||||
if math.IsNaN(refraction) {
|
|
||||||
return math.NaN()
|
|
||||||
}
|
|
||||||
value := estimate - refraction - trueAltitude
|
|
||||||
if math.Abs(value) < 1e-12 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = 1e-6
|
|
||||||
refractionPlus := RefractionFromApparentAltitude(estimate+delta, pressureHPa, temperatureC)
|
|
||||||
refractionMinus := RefractionFromApparentAltitude(estimate-delta, pressureHPa, temperatureC)
|
|
||||||
if math.IsNaN(refractionPlus) || math.IsNaN(refractionMinus) {
|
|
||||||
return math.NaN()
|
|
||||||
}
|
|
||||||
|
|
||||||
derivative := 1 - (refractionPlus-refractionMinus)/(2*delta)
|
|
||||||
if derivative == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
estimate -= value / derivative
|
|
||||||
}
|
|
||||||
return estimate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefractionFromTrueAltitude 大气折射修正量,单位度;输入为真高度角。
|
// RefractionFromTrueAltitude 大气折射修正量,单位度;输入为真高度角。
|
||||||
// 返回值应从真高度角加上后得到视高度角。
|
// 返回值应从真高度角加上后得到视高度角。
|
||||||
func RefractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
|
func RefractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
|
||||||
apparentAltitude := ApparentAltitude(trueAltitude, pressureHPa, temperatureC)
|
if !validRefractionInputs(trueAltitude, pressureHPa, temperatureC) {
|
||||||
if math.IsNaN(apparentAltitude) {
|
|
||||||
return math.NaN()
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return apparentAltitude - trueAltitude
|
return refractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saemundsson 公式以真高度角为输入;逆 API 对同一模型做数值求解,保持公开真/视高度语义一致。
|
||||||
|
// Saemundsson's formula takes true altitude. The inverse APIs solve the same model numerically so the public true/apparent semantics remain consistent.
|
||||||
|
func refractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC float64) float64 {
|
||||||
|
if trueAltitude <= refractionLowerLimitAltitudeDeg || trueAltitude >= refractionUpperLimitAltitudeDeg {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
angle := (trueAltitude + 10.3/(trueAltitude+5.11)) * math.Pi / 180
|
||||||
|
return refractionScale(pressureHPa, temperatureC) * (1.02 / math.Tan(angle)) / 60
|
||||||
|
}
|
||||||
|
|
||||||
|
func trueAltitudeFromApparent(apparentAltitude, pressureHPa, temperatureC float64) float64 {
|
||||||
|
lower := math.Nextafter(refractionLowerLimitAltitudeDeg, math.Inf(1))
|
||||||
|
upper := apparentAltitude
|
||||||
|
// Saemundsson 近似在 90 度以下极窄范围会略为负值;此时真高度角高于视高度角。若用视高度角作为根区间上界会漏掉有效解,因此保留模型明确的 90 度边界,允许逆解越过输入的视高度角。
|
||||||
|
// Saemundsson's approximation becomes slightly negative just below 90 degrees. In that narrow range the true altitude is above the apparent altitude, so using the apparent altitude as the root bracket would omit the valid solution. Keep the model's explicit 90-degree boundary while allowing the inverse to cross above the apparent input.
|
||||||
|
if refractionFromTrueAltitude(apparentAltitude, pressureHPa, temperatureC) < 0 {
|
||||||
|
upper = math.Nextafter(refractionUpperLimitAltitudeDeg, math.Inf(-1))
|
||||||
|
}
|
||||||
|
if estimate, ok := trueAltitudeFromApparentNewton(apparentAltitude, pressureHPa, temperatureC, lower, upper); ok {
|
||||||
|
return estimate
|
||||||
|
}
|
||||||
|
return trueAltitudeFromApparentBisection(apparentAltitude, pressureHPa, temperatureC, lower, upper)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trueAltitudeFromApparentNewton(apparentAltitude, pressureHPa, temperatureC, lower, upper float64) (float64, bool) {
|
||||||
|
estimate := apparentAltitude - refractionFromTrueAltitude(apparentAltitude, pressureHPa, temperatureC)
|
||||||
|
const delta = 1e-6
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
if estimate < lower || estimate > upper || !finiteRefractionValue(estimate) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
value := refractionInverseResidual(estimate, apparentAltitude, pressureHPa, temperatureC)
|
||||||
|
if math.Abs(value) < 1e-12 {
|
||||||
|
return estimate, true
|
||||||
|
}
|
||||||
|
refractionPlus := refractionFromTrueAltitude(estimate+delta, pressureHPa, temperatureC)
|
||||||
|
refractionMinus := refractionFromTrueAltitude(estimate-delta, pressureHPa, temperatureC)
|
||||||
|
derivative := 1 + (refractionPlus-refractionMinus)/(2*delta)
|
||||||
|
if math.Abs(derivative) < 1e-12 || !finiteRefractionValue(derivative) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
next := estimate - value/derivative
|
||||||
|
if next < lower || next > upper || !finiteRefractionValue(next) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
estimate = next
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func trueAltitudeFromApparentBisection(apparentAltitude, pressureHPa, temperatureC, lower, upper float64) float64 {
|
||||||
|
lowerValue := refractionInverseResidual(lower, apparentAltitude, pressureHPa, temperatureC)
|
||||||
|
upperValue := refractionInverseResidual(upper, apparentAltitude, pressureHPa, temperatureC)
|
||||||
|
if !finiteRefractionValue(lowerValue) || !finiteRefractionValue(upperValue) || lowerValue > 0 || upperValue < 0 {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
if math.Abs(lowerValue) < 1e-12 {
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
if math.Abs(upperValue) < 1e-12 {
|
||||||
|
return upper
|
||||||
|
}
|
||||||
|
|
||||||
|
best, bestResidual := lower, math.Abs(lowerValue)
|
||||||
|
if math.Abs(upperValue) < bestResidual {
|
||||||
|
best, bestResidual = upper, math.Abs(upperValue)
|
||||||
|
}
|
||||||
|
for i := 0; i < 96; i++ {
|
||||||
|
midpoint := lower + (upper-lower)/2
|
||||||
|
if midpoint == lower || midpoint == upper {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := refractionInverseResidual(midpoint, apparentAltitude, pressureHPa, temperatureC)
|
||||||
|
if !finiteRefractionValue(value) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
if residual := math.Abs(value); residual < bestResidual {
|
||||||
|
best, bestResidual = midpoint, residual
|
||||||
|
}
|
||||||
|
if math.Abs(value) < 1e-12 {
|
||||||
|
return midpoint
|
||||||
|
}
|
||||||
|
if value > 0 {
|
||||||
|
upper = midpoint
|
||||||
|
} else {
|
||||||
|
lower = midpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestResidual <= 1e-9 {
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refractionInverseResidual(trueAltitude, apparentAltitude, pressureHPa, temperatureC float64) float64 {
|
||||||
|
return trueAltitude + refractionFromTrueAltitude(trueAltitude, pressureHPa, temperatureC) - apparentAltitude
|
||||||
|
}
|
||||||
|
|
||||||
|
func finiteRefractionValue(value float64) bool {
|
||||||
|
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validRefractionInputs(altitude, pressureHPa, temperatureC float64) bool {
|
func validRefractionInputs(altitude, pressureHPa, temperatureC float64) bool {
|
||||||
@@ -85,5 +163,5 @@ func validRefractionInputs(altitude, pressureHPa, temperatureC float64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refractionScale(pressureHPa, temperatureC float64) float64 {
|
func refractionScale(pressureHPa, temperatureC float64) float64 {
|
||||||
return pressureHPa / refractionStandardPressureHPa * refractionStandardTemperatureK / (273 + temperatureC)
|
return pressureHPa / refractionStandardPressureHPa * refractionStandardTemperatureK / (temperatureC - refractionAbsoluteZeroC)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRefractionFromApparentAltitudeStandardAtmosphere(t *testing.T) {
|
func TestRefractionFromTrueAltitudeStandardAtmosphere(t *testing.T) {
|
||||||
assertClose(t, "Refraction@0deg", RefractionFromApparentAltitude(0, 1010, 10), 0.483032, 0.0001)
|
assertClose(t, "RefractionFromTrue@0deg", RefractionFromTrueAltitude(0, 1010, 10), 0.483032, 0.0001)
|
||||||
assertClose(t, "Refraction@45deg", RefractionFromApparentAltitude(45, 1010, 10), 0.016878, 0.0001)
|
assertClose(t, "RefractionFromTrue@45deg", RefractionFromTrueAltitude(45, 1010, 10), 0.016878, 0.0001)
|
||||||
|
assertClose(t, "ApparentAltitude@0deg", ApparentAltitude(0, 1010, 10), 0.483032, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefractionFromApparentAltitudeUsesApparentInput(t *testing.T) {
|
||||||
|
// 视地平线处修正约为 34.5 角分,而不是真高度角公式在 0 度返回的 29 角分 / At the apparent horizon the correction is about 34.5 arcminutes, not
|
||||||
|
// 29 角分 / the 29 arcminutes returned by the true-altitude formula at 0 degrees.
|
||||||
|
assertClose(t, "RefractionFromApparent@0deg", RefractionFromApparentAltitude(0, 1010, 10), 0.574, 0.001)
|
||||||
|
assertClose(t, "TrueAltitude@0deg", TrueAltitude(0, 1010, 10), -0.574, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefractionRoundTrip(t *testing.T) {
|
func TestRefractionRoundTrip(t *testing.T) {
|
||||||
@@ -35,29 +43,77 @@ func TestRefractionRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefractionRoundTripNearZenith(t *testing.T) {
|
||||||
|
for _, trueAltitude := range []float64{89.90, 89.95, 89.99} {
|
||||||
|
apparentAltitude := ApparentAltitude(trueAltitude, 1010, 10)
|
||||||
|
if math.IsNaN(apparentAltitude) || math.IsInf(apparentAltitude, 0) {
|
||||||
|
t.Fatalf("true altitude %.2f produced invalid apparent altitude %v", trueAltitude, apparentAltitude)
|
||||||
|
}
|
||||||
|
got := TrueAltitude(apparentAltitude, 1010, 10)
|
||||||
|
if math.IsNaN(got) || math.IsInf(got, 0) {
|
||||||
|
t.Fatalf("apparent altitude %.12f produced invalid true altitude %v", apparentAltitude, got)
|
||||||
|
}
|
||||||
|
assertClose(t, "NearZenithRoundTrip", got, trueAltitude, 1e-9)
|
||||||
|
if refraction := RefractionFromApparentAltitude(apparentAltitude, 1010, 10); math.IsNaN(refraction) || math.IsInf(refraction, 0) {
|
||||||
|
t.Fatalf("apparent altitude %.12f produced invalid refraction %v", apparentAltitude, refraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefractionExtremeTemperatureInverse(t *testing.T) {
|
||||||
|
const (
|
||||||
|
apparentAltitude = 0.0
|
||||||
|
pressureHPa = 1010.0
|
||||||
|
)
|
||||||
|
trueAltitude := TrueAltitude(apparentAltitude, pressureHPa, -273)
|
||||||
|
if math.IsNaN(trueAltitude) || math.IsInf(trueAltitude, 0) {
|
||||||
|
t.Fatalf("-273 C inverse returned invalid true altitude %v", trueAltitude)
|
||||||
|
}
|
||||||
|
assertClose(t, "ExtremeTemperatureRoundTrip",
|
||||||
|
ApparentAltitude(trueAltitude, pressureHPa, -273), apparentAltitude, 1e-9)
|
||||||
|
|
||||||
|
for _, temperatureC := range []float64{-273.14, math.Nextafter(refractionAbsoluteZeroC, math.Inf(1))} {
|
||||||
|
if got := TrueAltitude(apparentAltitude, pressureHPa, temperatureC); !math.IsNaN(got) {
|
||||||
|
t.Errorf("temperature %.17g C inverse = %v, want NaN when the model has no root", temperatureC, got)
|
||||||
|
}
|
||||||
|
if got := RefractionFromApparentAltitude(apparentAltitude, pressureHPa, temperatureC); !math.IsNaN(got) {
|
||||||
|
t.Errorf("temperature %.17g C apparent refraction = %v, want NaN when the model has no root", temperatureC, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRefractionScalingAndBounds(t *testing.T) {
|
func TestRefractionScalingAndBounds(t *testing.T) {
|
||||||
lowPressure := RefractionFromApparentAltitude(0, 980, 10)
|
lowPressure := RefractionFromTrueAltitude(0, 980, 10)
|
||||||
highPressure := RefractionFromApparentAltitude(0, 1030, 10)
|
highPressure := RefractionFromTrueAltitude(0, 1030, 10)
|
||||||
if !(lowPressure < highPressure) {
|
if !(lowPressure < highPressure) {
|
||||||
t.Fatalf("pressure scaling mismatch: low %.12f high %.12f", lowPressure, highPressure)
|
t.Fatalf("pressure scaling mismatch: low %.12f high %.12f", lowPressure, highPressure)
|
||||||
}
|
}
|
||||||
|
|
||||||
cold := RefractionFromApparentAltitude(0, 1010, 0)
|
cold := RefractionFromTrueAltitude(0, 1010, 0)
|
||||||
hot := RefractionFromApparentAltitude(0, 1010, 30)
|
hot := RefractionFromTrueAltitude(0, 1010, 30)
|
||||||
if !(cold > hot) {
|
if !(cold > hot) {
|
||||||
t.Fatalf("temperature scaling mismatch: cold %.12f hot %.12f", cold, hot)
|
t.Fatalf("temperature scaling mismatch: cold %.12f hot %.12f", cold, hot)
|
||||||
}
|
}
|
||||||
|
|
||||||
if RefractionFromApparentAltitude(-6, 1010, 10) != 0 {
|
if RefractionFromTrueAltitude(-6, 1010, 10) != 0 {
|
||||||
t.Fatalf("refraction below lower limit should be 0")
|
t.Fatalf("refraction below lower limit should be 0")
|
||||||
}
|
}
|
||||||
if RefractionFromApparentAltitude(95, 1010, 10) != 0 {
|
if RefractionFromTrueAltitude(95, 1010, 10) != 0 {
|
||||||
t.Fatalf("refraction above upper limit should be 0")
|
t.Fatalf("refraction above upper limit should be 0")
|
||||||
}
|
}
|
||||||
if !math.IsNaN(RefractionFromApparentAltitude(0, 0, 10)) {
|
if !math.IsNaN(RefractionFromTrueAltitude(0, 0, 10)) {
|
||||||
t.Fatalf("invalid pressure should produce NaN")
|
t.Fatalf("invalid pressure should produce NaN")
|
||||||
}
|
}
|
||||||
if !math.IsNaN(RefractionFromApparentAltitude(0, 1010, -274)) {
|
if !math.IsNaN(RefractionFromTrueAltitude(0, 1010, -274)) {
|
||||||
t.Fatalf("invalid temperature should produce NaN")
|
t.Fatalf("invalid temperature should produce NaN")
|
||||||
}
|
}
|
||||||
|
if !math.IsNaN(RefractionFromTrueAltitude(0, 1010, -273.15)) {
|
||||||
|
t.Fatalf("absolute zero should produce NaN")
|
||||||
|
}
|
||||||
|
for _, temperatureC := range []float64{-273, -273.14} {
|
||||||
|
refraction := RefractionFromTrueAltitude(0, 1010, temperatureC)
|
||||||
|
if math.IsNaN(refraction) || math.IsInf(refraction, 0) || refraction <= 0 {
|
||||||
|
t.Fatalf("temperature %.2f C produced invalid refraction %v", temperatureC, refraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNeverRise = errors.New("rise event does not occur on this date")
|
ErrNeverRise = errors.New("rise event does not occur on this date")
|
||||||
ErrNeverSet = errors.New("set 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")
|
ErrNotOnThisDate = errors.New("rise/set event occurs on adjacent date")
|
||||||
|
ErrInvalidObservationInput = errors.New("invalid observation input")
|
||||||
)
|
)
|
||||||
|
|
||||||
func StandardAltitudeStar(aero bool, observerHeight, lat float64) float64 {
|
func StandardAltitudeStar(aero bool, observerHeight, lat float64) float64 {
|
||||||
@@ -50,20 +51,34 @@ type planetHeightFunc func(float64, float64, float64, float64) float64
|
|||||||
type planetDeclinationFunc func(float64) float64
|
type planetDeclinationFunc func(float64) float64
|
||||||
|
|
||||||
func planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, culmination planetCulminationFunc, height planetHeightFunc, declination planetDeclinationFunc) (float64, error) {
|
func planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float64, isRise bool, culmination planetCulminationFunc, height planetHeightFunc, declination planetDeclinationFunc) (float64, error) {
|
||||||
|
if !isFiniteFloat(jd) || !isFiniteFloat(lon) || !isFiniteFloat(lat) || !isFiniteFloat(timezone) || !isFiniteFloat(aeroCorrection) || !isFiniteFloat(observerHeight) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
jd = math.Floor(jd) + 0.5
|
jd = math.Floor(jd) + 0.5
|
||||||
localTimezone := math.Round(lon / 15)
|
localTimezone := math.Round(lon / 15)
|
||||||
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, lat)
|
targetAltitude := StandardAltitudePlanet(aeroCorrection, observerHeight, lat)
|
||||||
|
|
||||||
culminationJD := culmination(jd, lon, localTimezone)
|
culminationJD := culmination(jd, lon, localTimezone)
|
||||||
if height(culminationJD, lon, lat, localTimezone) < targetAltitude {
|
if !isFiniteFloat(culminationJD) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
culminationHeight := height(culminationJD, lon, lat, localTimezone)
|
||||||
|
previousHeight := height(culminationJD-0.5, lon, lat, localTimezone)
|
||||||
|
if !isFiniteFloat(culminationHeight) || !isFiniteFloat(previousHeight) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
if culminationHeight < targetAltitude {
|
||||||
return 0, ErrNeverRise
|
return 0, ErrNeverRise
|
||||||
}
|
}
|
||||||
if height(culminationJD-0.5, lon, lat, localTimezone) > targetAltitude {
|
if previousHeight > targetAltitude {
|
||||||
return 0, ErrNeverSet
|
return 0, ErrNeverSet
|
||||||
}
|
}
|
||||||
|
|
||||||
dec := declination(TD2UT(culminationJD-localTimezone/24, true))
|
dec := declination(TD2UT(culminationJD-localTimezone/24, true))
|
||||||
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat))
|
cosHourAngle := (Sin(targetAltitude) - Sin(dec)*Sin(lat)) / (Cos(dec) * Cos(lat))
|
||||||
|
if !isFiniteFloat(dec) || !isFiniteFloat(cosHourAngle) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
|
||||||
var eventJD float64
|
var eventJD float64
|
||||||
if math.Abs(cosHourAngle) <= 1 {
|
if math.Abs(cosHourAngle) <= 1 {
|
||||||
@@ -89,15 +104,13 @@ func planetRiseDown(jd, lon, lat, timezone, aeroCorrection, observerHeight float
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
estimateJD := eventJD
|
estimateJD, ok := eventNewtonRefine(eventJD, 0.00001, func(prevJD float64) float64 {
|
||||||
for {
|
|
||||||
prevJD := estimateJD
|
|
||||||
altitudeDelta := height(prevJD, lon, lat, localTimezone) - targetAltitude
|
altitudeDelta := height(prevJD, lon, lat, localTimezone) - targetAltitude
|
||||||
altitudeSlope := (height(prevJD+0.000005, lon, lat, localTimezone) - height(prevJD-0.000005, lon, lat, localTimezone)) / 0.00001
|
altitudeSlope := (height(prevJD+0.000005, lon, lat, localTimezone) - height(prevJD-0.000005, lon, lat, localTimezone)) / 0.00001
|
||||||
estimateJD = prevJD - altitudeDelta/altitudeSlope
|
return altitudeDelta / altitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return 0, ErrInvalidObservationInput
|
||||||
}
|
}
|
||||||
return estimateJD - localTimezone/24 + timezone/24, nil
|
return estimateJD - localTimezone/24 + timezone/24, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package basic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
. "b612.me/astro/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSunRiseSetDynamicResidual(t *testing.T) {
|
||||||
|
const (
|
||||||
|
longitude = 116.4074
|
||||||
|
latitude = 39.9042
|
||||||
|
timeZone = 8.0
|
||||||
|
height = 0.0
|
||||||
|
)
|
||||||
|
jd := JDECalc(2025, 6, 5)
|
||||||
|
|
||||||
|
for _, event := range []struct {
|
||||||
|
name string
|
||||||
|
get func(float64, float64, float64, float64, float64, float64) (float64, error)
|
||||||
|
}{
|
||||||
|
{name: "rise", get: GetSunRiseTime},
|
||||||
|
{name: "set", get: GetSunSetTime},
|
||||||
|
} {
|
||||||
|
eventJD, err := event.get(jd, longitude, latitude, timeZone, 1, height)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", event.name, err)
|
||||||
|
}
|
||||||
|
naturalTimeZone := math.Round(longitude / 15)
|
||||||
|
localJD := eventJD + naturalTimeZone/24 - timeZone/24
|
||||||
|
residual := sunRiseSetResidual(localJD, longitude, latitude, naturalTimeZone, 1, height, -1)
|
||||||
|
if math.Abs(residual) > 0.001 {
|
||||||
|
t.Fatalf("%s dynamic horizon residual = %.9f degrees", event.name, residual)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedResidual := SunHeight(localJD, longitude, latitude, naturalTimeZone) - StandardAltitudeSun(1, height, latitude)
|
||||||
|
if math.Abs(fixedResidual) < 0.01 {
|
||||||
|
t.Fatalf("%s still matches the legacy fixed-altitude event: residual %.9f degrees", event.name, fixedResidual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSunApparentStateReusesDistanceWithoutChangingCoordinates(t *testing.T) {
|
||||||
|
const jd = 2460827.5
|
||||||
|
ra, dec, distanceAU := hSunApparentRaDecDistanceN(jd, -1)
|
||||||
|
wantRA, wantDec := LoBoToRaDec(jd, HSunApparentLoN(jd, -1), HSunTrueBoN(jd, -1))
|
||||||
|
assertClose(t, "sun state RA", ra, wantRA, 1e-12)
|
||||||
|
assertClose(t, "sun state Dec", dec, wantDec, 1e-12)
|
||||||
|
assertClose(t, "sun state distance", distanceAU, EarthAwayN(jd, -1), 1e-15)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSunRiseSetDynamicGrazingKeepsDateAndDirection(t *testing.T) {
|
||||||
|
date := time.Date(2025, 6, 10, 0, 0, 0, 0, time.UTC)
|
||||||
|
jd := Date2JDE(date)
|
||||||
|
dayStart := math.Floor(jd) + 0.5
|
||||||
|
|
||||||
|
rise, err := GetSunRiseTime(jd, 0, 66, 0, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sunrise: %v", err)
|
||||||
|
}
|
||||||
|
set, err := GetSunSetTime(jd, 0, 66, 0, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sunset: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRiseSetEvent(t, "sunrise", rise, dayStart, true, func(eventJD float64) float64 {
|
||||||
|
return sunRiseSetResidual(eventJD, 0, 66, 0, 1, 0, -1)
|
||||||
|
})
|
||||||
|
assertRiseSetEvent(t, "sunset", set, dayStart, false, func(eventJD float64) float64 {
|
||||||
|
return sunRiseSetResidual(eventJD, 0, 66, 0, 1, 0, -1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoonSetDynamicGrazingKeepsDirection(t *testing.T) {
|
||||||
|
date := time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC)
|
||||||
|
jd := Date2JDE(date)
|
||||||
|
dayStart := math.Floor(jd) + 0.5
|
||||||
|
set, err := GetMoonSetTime(jd, 0, 80, 0, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("moonset: %v", err)
|
||||||
|
}
|
||||||
|
assertRiseSetEvent(t, "moonset", set, dayStart, false, func(eventJD float64) float64 {
|
||||||
|
return moonRiseSetResidual(eventJD, 0, 80, 0, 1, 0, -1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoonRiseSetDirectionalFallbackPreservesMissingEventError(t *testing.T) {
|
||||||
|
date := time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)
|
||||||
|
jd := Date2JDE(date)
|
||||||
|
_, err := GetMoonRiseTime(jd, -42.6043, 71.7069, -3, 1, 0)
|
||||||
|
if !errors.Is(err, ErrNeverRise) {
|
||||||
|
t.Fatalf("moonrise error = %v, want %v", err, ErrNeverRise)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoonRiseSetDynamicUsesObserverHeightForParallax(t *testing.T) {
|
||||||
|
date := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
|
||||||
|
jd := Date2JDE(date)
|
||||||
|
const (
|
||||||
|
longitude = 116.4074
|
||||||
|
latitude = 39.9042
|
||||||
|
height = 10000.0
|
||||||
|
)
|
||||||
|
rise, err := GetMoonRiseTime(jd, longitude, latitude, 0, 1, height)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("moonrise: %v", err)
|
||||||
|
}
|
||||||
|
residual := moonRiseSetResidualAtObserverHeight(rise, longitude, latitude, 0, 1, height)
|
||||||
|
if math.Abs(residual) > 1e-5 {
|
||||||
|
t.Fatalf("moonrise observer-height residual = %.12f degrees", residual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRiseSetEvent(t *testing.T, name string, eventJD, dayStart float64, isRise bool, residual func(float64) float64) {
|
||||||
|
t.Helper()
|
||||||
|
if eventJD < dayStart || eventJD >= dayStart+1 {
|
||||||
|
t.Fatalf("%s %.12f is outside civil day [%.12f, %.12f)", name, eventJD, dayStart, dayStart+1)
|
||||||
|
}
|
||||||
|
const step = 1.0 / 1440
|
||||||
|
slope := (residual(eventJD+step) - residual(eventJD-step)) / (2 * step)
|
||||||
|
if isRise && slope <= 0 {
|
||||||
|
t.Fatalf("%s slope = %.9f degrees/day, want positive", name, slope)
|
||||||
|
}
|
||||||
|
if !isRise && slope >= 0 {
|
||||||
|
t.Fatalf("%s slope = %.9f degrees/day, want negative", name, slope)
|
||||||
|
}
|
||||||
|
if value := residual(eventJD); math.Abs(value) > 1e-4 {
|
||||||
|
t.Fatalf("%s residual = %.12f degrees", name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moonRiseSetResidualAtObserverHeight(jd, longitude, latitude, timeZone, zenithShift, height float64) float64 {
|
||||||
|
calculationJD := TD2UT(jd-timeZone/24, true)
|
||||||
|
ra, dec := HMoonTrueRaDecN(calculationJD, -1)
|
||||||
|
distanceKM := HMoonAwayN(calculationJD, -1)
|
||||||
|
topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, latitude, longitude,
|
||||||
|
jd-timeZone/24, distanceKM/angularDiameterAstronomicalUnitKM, height)
|
||||||
|
siderealTime := Limit360(ApparentSiderealTime(jd-timeZone/24)*15 + longitude)
|
||||||
|
hourAngle := Limit360(siderealTime - topocentricRA)
|
||||||
|
altitude := ArcSin(Sin(latitude)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(latitude)*Cos(hourAngle))
|
||||||
|
residual := altitude + HeightDegreeByLat(height, latitude)
|
||||||
|
if zenithShift != 0 {
|
||||||
|
residual += RefractionFromTrueAltitude(altitude, refractionStandardPressureHPa, refractionStandardTemperatureC)
|
||||||
|
residual += angularSemidiameterArcsec(moonEquatorialRadiusKM, distanceKM) / 3600
|
||||||
|
}
|
||||||
|
return residual
|
||||||
|
}
|
||||||
@@ -167,14 +167,14 @@ func SaturnCulminationTime(jde, lon, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return currentHourAngle
|
return currentHourAngle
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
return hourAngleDelta / hourAngleSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func saturnRADerivativeN(jde, delta float64, n int) float64 {
|
|||||||
|
|
||||||
func saturnConjunctionFull(jde, degree float64, next uint8) float64 {
|
func saturnConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := SATURN_S_PERIOD / 360
|
daysPerDegree := SATURN_S_PERIOD / 360
|
||||||
currentDelta := saturnSunLongitudeDelta(jde, degree, false)
|
currentDelta := saturnSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -72,20 +75,29 @@ func saturnConjunctionFull(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := saturnSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := saturnSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (saturnSunLongitudeDelta(prevJD+0.000005, degree, true) - saturnSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (saturnSunLongitudeDelta(prevJD+0.000005, degree, true) - saturnSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func saturnConjunction(jde, degree float64, next uint8) float64 {
|
func saturnConjunction(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := SATURN_S_PERIOD / 360
|
daysPerDegree := SATURN_S_PERIOD / 360
|
||||||
currentDelta := saturnSunLongitudeDelta(jde, degree, false)
|
currentDelta := saturnSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -94,24 +106,36 @@ func saturnConjunction(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := saturnSunLongitudeDeltaN(prevJD, degree, true, saturnEventSearchN)
|
longitudeDelta := saturnSunLongitudeDeltaN(prevJD, degree, true, saturnEventSearchN)
|
||||||
longitudeSlope := (saturnSunLongitudeDeltaN(prevJD+0.000005, degree, true, saturnEventSearchN) - saturnSunLongitudeDeltaN(prevJD-0.000005, degree, true, saturnEventSearchN)) / 0.00001
|
longitudeSlope := (saturnSunLongitudeDeltaN(prevJD+0.000005, degree, true, saturnEventSearchN) - saturnSunLongitudeDeltaN(prevJD-0.000005, degree, true, saturnEventSearchN)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= saturnPhaseCoarseTolerance {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= saturnPhaseCoarseTolerance {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for {
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
converged = false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := saturnSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := saturnSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (saturnSunLongitudeDelta(prevJD+0.000005, degree, true) - saturnSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (saturnSunLongitudeDelta(prevJD+0.000005, degree, true) - saturnSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ const (
|
|||||||
solarEclipsePartialFootprintMaxBoundaryPoints = 1440
|
solarEclipsePartialFootprintMaxBoundaryPoints = 1440
|
||||||
solarEclipsePartialFootprintPointTolerance = 1e-12
|
solarEclipsePartialFootprintPointTolerance = 1e-12
|
||||||
solarEclipsePartialFootprintIterationLimit = 10
|
solarEclipsePartialFootprintIterationLimit = 10
|
||||||
|
solarEclipsePartialFootprintTransitionIterations = 48
|
||||||
|
solarEclipseShadowContactSearchStepDays = 10.0 / 1440.0
|
||||||
|
solarEclipseShadowContactSearchSpanDays = 0.75
|
||||||
|
solarEclipseShadowContactToleranceDays = 1e-9
|
||||||
|
)
|
||||||
|
|
||||||
|
type solarEclipseShadowKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
solarEclipsePenumbralShadow solarEclipseShadowKind = iota
|
||||||
|
solarEclipseCentralShadow
|
||||||
)
|
)
|
||||||
|
|
||||||
// SolarEclipsePathOptions 控制日食中心路径采样。
|
// SolarEclipsePathOptions 控制日食中心路径采样。
|
||||||
@@ -78,6 +89,9 @@ type SolarEclipsePartialFootprintOptions struct {
|
|||||||
// BoundaryPoints 是每个瞬时半影边界的角向采样点数;<=0 时使用 180。
|
// BoundaryPoints 是每个瞬时半影边界的角向采样点数;<=0 时使用 180。
|
||||||
// BoundaryPoints is the angular sample count for each instantaneous penumbral boundary; values <= 0 use 180.
|
// BoundaryPoints is the angular sample count for each instantaneous penumbral boundary; values <= 0 use 180.
|
||||||
BoundaryPoints int
|
BoundaryPoints int
|
||||||
|
// CentralShadowStepDays 是本影/反本影瞬时足迹的时间步长,单位为日;<=0 时不计算。
|
||||||
|
// CentralShadowStepDays is the umbral/antumbral footprint step in days; values <= 0 disable it.
|
||||||
|
CentralShadowStepDays float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
|
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
|
||||||
@@ -104,12 +118,30 @@ type SolarEclipsePartialFootprintsResult struct {
|
|||||||
Eclipse SolarEclipseResult
|
Eclipse SolarEclipseResult
|
||||||
// Footprints 是按时间采样的瞬时半影足迹, sampled instantaneous penumbral footprints.
|
// Footprints 是按时间采样的瞬时半影足迹, sampled instantaneous penumbral footprints.
|
||||||
Footprints []SolarEclipsePartialFootprint
|
Footprints []SolarEclipsePartialFootprint
|
||||||
|
// CentralShadowFootprints 是按时间采样的本影/反本影足迹。
|
||||||
|
// CentralShadowFootprints are sampled umbral/antumbral footprints.
|
||||||
|
CentralShadowFootprints []SolarEclipsePartialFootprint
|
||||||
|
// P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。
|
||||||
|
// P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero.
|
||||||
|
P1 SolarEclipsePathPoint
|
||||||
|
P2 SolarEclipsePathPoint
|
||||||
|
P3 SolarEclipsePathPoint
|
||||||
|
P4 SolarEclipsePathPoint
|
||||||
|
// U1-U4 是本影/反本影与地球的外切/内切接触点;不存在时保持零值。
|
||||||
|
// U1-U4 are external/internal umbral/antumbral contacts; absent contacts remain zero.
|
||||||
|
U1 SolarEclipsePathPoint
|
||||||
|
U2 SolarEclipsePathPoint
|
||||||
|
U3 SolarEclipsePathPoint
|
||||||
|
U4 SolarEclipsePathPoint
|
||||||
// StepDays 是实际采用的基础时间采样步长,单位为日。
|
// StepDays 是实际采用的基础时间采样步长,单位为日。
|
||||||
// StepDays is the effective base time step in days.
|
// StepDays is the effective base time step in days.
|
||||||
StepDays float64
|
StepDays float64
|
||||||
// BoundaryPoints 是实际采用的边界角向采样点数。
|
// BoundaryPoints 是实际采用的边界角向采样点数。
|
||||||
// BoundaryPoints is the effective angular sample count for each boundary.
|
// BoundaryPoints is the effective angular sample count for each boundary.
|
||||||
BoundaryPoints int
|
BoundaryPoints int
|
||||||
|
// CentralShadowStepDays 是本影/反本影足迹的实际采样步长;0 表示未计算。
|
||||||
|
// CentralShadowStepDays is the effective umbral/antumbral footprint step; zero means disabled.
|
||||||
|
CentralShadowStepDays float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialAreaResult 是 SolarEclipsePartialFootprintsResult 的兼容别名。
|
// SolarEclipsePartialAreaResult 是 SolarEclipsePartialFootprintsResult 的兼容别名。
|
||||||
@@ -237,9 +269,10 @@ func solarEclipsePartialFootprints(
|
|||||||
options = normalizeSolarEclipsePartialFootprintOptions(options)
|
options = normalizeSolarEclipsePartialFootprintOptions(options)
|
||||||
result := solarEclipse(seedJDE, model)
|
result := solarEclipse(seedJDE, model)
|
||||||
footprintsResult := SolarEclipsePartialFootprintsResult{
|
footprintsResult := SolarEclipsePartialFootprintsResult{
|
||||||
Eclipse: result,
|
Eclipse: result,
|
||||||
StepDays: options.StepDays,
|
StepDays: options.StepDays,
|
||||||
BoundaryPoints: options.BoundaryPoints,
|
BoundaryPoints: options.BoundaryPoints,
|
||||||
|
CentralShadowStepDays: options.CentralShadowStepDays,
|
||||||
}
|
}
|
||||||
if !result.HasPartial {
|
if !result.HasPartial {
|
||||||
return footprintsResult
|
return footprintsResult
|
||||||
@@ -247,6 +280,20 @@ func solarEclipsePartialFootprints(
|
|||||||
|
|
||||||
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
|
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
|
||||||
solver := newSolarEclipseSolver(newMoonJDE, model)
|
solver := newSolarEclipseSolver(newMoonJDE, model)
|
||||||
|
footprintsResult.P1, footprintsResult.P4, _ = solver.shadowContactPair(
|
||||||
|
result.GreatestEclipse, solarEclipsePenumbralShadow, false,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
footprints, stepDays := solver.partialFootprints(
|
footprints, stepDays := solver.partialFootprints(
|
||||||
result.PartialBeginOnEarth,
|
result.PartialBeginOnEarth,
|
||||||
result.PartialEndOnEarth,
|
result.PartialEndOnEarth,
|
||||||
@@ -255,6 +302,17 @@ func solarEclipsePartialFootprints(
|
|||||||
)
|
)
|
||||||
footprintsResult.StepDays = stepDays
|
footprintsResult.StepDays = stepDays
|
||||||
footprintsResult.Footprints = footprints
|
footprintsResult.Footprints = footprints
|
||||||
|
if options.CentralShadowStepDays > 0 &&
|
||||||
|
footprintsResult.U1.JDE != 0 && footprintsResult.U4.JDE != 0 {
|
||||||
|
footprintsResult.CentralShadowFootprints, footprintsResult.CentralShadowStepDays = solver.shadowFootprints(
|
||||||
|
footprintsResult.U1.JDE,
|
||||||
|
footprintsResult.U4.JDE,
|
||||||
|
result.GreatestEclipse,
|
||||||
|
options.CentralShadowStepDays,
|
||||||
|
options.BoundaryPoints,
|
||||||
|
solarEclipseCentralShadow,
|
||||||
|
)
|
||||||
|
}
|
||||||
return footprintsResult
|
return footprintsResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +332,11 @@ func normalizeSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFoo
|
|||||||
if options.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints {
|
if options.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints {
|
||||||
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
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,15 +379,30 @@ func (solver solarEclipseSolver) centralPathPoints(
|
|||||||
func (solver solarEclipseSolver) partialFootprints(
|
func (solver solarEclipseSolver) partialFootprints(
|
||||||
startJDE, endJDE, greatestJDE float64,
|
startJDE, endJDE, greatestJDE float64,
|
||||||
options SolarEclipsePartialFootprintOptions,
|
options SolarEclipsePartialFootprintOptions,
|
||||||
|
) ([]SolarEclipsePartialFootprint, float64) {
|
||||||
|
return solver.shadowFootprints(
|
||||||
|
startJDE,
|
||||||
|
endJDE,
|
||||||
|
greatestJDE,
|
||||||
|
options.StepDays,
|
||||||
|
options.BoundaryPoints,
|
||||||
|
solarEclipsePenumbralShadow,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (solver solarEclipseSolver) shadowFootprints(
|
||||||
|
startJDE, endJDE, greatestJDE, requestedStepDays float64,
|
||||||
|
boundaryPoints int,
|
||||||
|
kind solarEclipseShadowKind,
|
||||||
) ([]SolarEclipsePartialFootprint, float64) {
|
) ([]SolarEclipsePartialFootprint, float64) {
|
||||||
if endJDE < startJDE {
|
if endJDE < startJDE {
|
||||||
startJDE, endJDE = endJDE, startJDE
|
startJDE, endJDE = endJDE, startJDE
|
||||||
}
|
}
|
||||||
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
|
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
|
||||||
return nil, options.StepDays
|
return nil, requestedStepDays
|
||||||
}
|
}
|
||||||
|
|
||||||
stepDays := options.StepDays
|
stepDays := requestedStepDays
|
||||||
if sampleCount := int(math.Ceil((endJDE-startJDE)/stepDays)) + 1; sampleCount > solarEclipsePathMaxSampleCount {
|
if sampleCount := int(math.Ceil((endJDE-startJDE)/stepDays)) + 1; sampleCount > solarEclipsePathMaxSampleCount {
|
||||||
stepDays = (endJDE - startJDE) / float64(solarEclipsePathMaxSampleCount-1)
|
stepDays = (endJDE - startJDE) / float64(solarEclipsePathMaxSampleCount-1)
|
||||||
}
|
}
|
||||||
@@ -338,7 +416,7 @@ func (solver solarEclipseSolver) partialFootprints(
|
|||||||
|
|
||||||
footprints := make([]SolarEclipsePartialFootprint, 0, len(times))
|
footprints := make([]SolarEclipsePartialFootprint, 0, len(times))
|
||||||
for _, jd := range times {
|
for _, jd := range times {
|
||||||
footprint := solver.partialFootprintAt(jd, options.BoundaryPoints)
|
footprint := solver.shadowFootprintAt(jd, boundaryPoints, kind)
|
||||||
if len(footprint.Boundaries) > 0 {
|
if len(footprint.Boundaries) > 0 {
|
||||||
footprints = append(footprints, footprint)
|
footprints = append(footprints, footprint)
|
||||||
}
|
}
|
||||||
@@ -509,18 +587,164 @@ func solarEclipsePathPointFromBesselXY(jd, x, y float64, axis solarEclipseAxis)
|
|||||||
}, true
|
}, 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 {
|
||||||
|
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) {
|
||||||
|
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) 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 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 {
|
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)
|
moon := solver.besselMoonAt(jd)
|
||||||
axis := solver.besselAxisAt(jd)
|
axis := solver.besselAxisAt(jd)
|
||||||
samples := make([]solarEclipsePartialBoundarySample, boundaryPoints)
|
samples := make([]solarEclipsePartialBoundarySample, boundaryPoints)
|
||||||
for i := range samples {
|
for i := range samples {
|
||||||
angle := 2 * math.Pi * float64(i) / float64(boundaryPoints)
|
angle := 2 * math.Pi * float64(i) / float64(boundaryPoints)
|
||||||
point, ok := solver.partialFootprintPointAt(jd, moon, axis, angle)
|
point, ok := solver.shadowFootprintPointAt(jd, moon, axis, angle, kind)
|
||||||
samples[i] = solarEclipsePartialBoundarySample{
|
samples[i] = solarEclipsePartialBoundarySample{
|
||||||
point: point,
|
point: point,
|
||||||
ok: ok,
|
ok: ok,
|
||||||
|
angle: angle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
samples = solver.refineShadowFootprintTransitions(jd, moon, axis, samples, kind)
|
||||||
|
|
||||||
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
|
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
|
||||||
return SolarEclipsePartialFootprint{
|
return SolarEclipsePartialFootprint{
|
||||||
@@ -533,17 +757,83 @@ func (solver solarEclipseSolver) partialFootprintAt(jd float64, boundaryPoints i
|
|||||||
type solarEclipsePartialBoundarySample struct {
|
type solarEclipsePartialBoundarySample struct {
|
||||||
point SolarEclipsePathPoint
|
point SolarEclipsePathPoint
|
||||||
ok bool
|
ok bool
|
||||||
|
angle float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (solver solarEclipseSolver) partialFootprintPointAt(
|
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,
|
jd float64,
|
||||||
moon [3]float64,
|
moon [3]float64,
|
||||||
axis solarEclipseAxis,
|
axis solarEclipseAxis,
|
||||||
angle float64,
|
angle float64,
|
||||||
|
kind solarEclipseShadowKind,
|
||||||
) (SolarEclipsePathPoint, bool) {
|
) (SolarEclipsePathPoint, bool) {
|
||||||
cosAngle := math.Cos(angle)
|
cosAngle := math.Cos(angle)
|
||||||
sinAngle := math.Sin(angle)
|
sinAngle := math.Sin(angle)
|
||||||
radius := solver.shadowRadiiAt(moon[2]).penumbraRadius
|
radius := solver.shadowRadiusAt(moon[2], kind)
|
||||||
if radius <= 0 {
|
if radius <= 0 {
|
||||||
return SolarEclipsePathPoint{}, false
|
return SolarEclipsePathPoint{}, false
|
||||||
}
|
}
|
||||||
@@ -567,7 +857,7 @@ func (solver solarEclipseSolver) partialFootprintPointAt(
|
|||||||
return SolarEclipsePathPoint{}, false
|
return SolarEclipsePathPoint{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
nextRadius := solver.shadowRadiiAt(moon[2] - intersection.r2).penumbraRadius
|
nextRadius := solver.shadowRadiusAt(moon[2]-intersection.r2, kind)
|
||||||
if nextRadius <= 0 {
|
if nextRadius <= 0 {
|
||||||
return SolarEclipsePathPoint{}, false
|
return SolarEclipsePathPoint{}, false
|
||||||
}
|
}
|
||||||
@@ -608,8 +898,10 @@ func (solver solarEclipseSolver) partialFootprintPointAt(
|
|||||||
func solarEclipsePartialBoundarySegments(samples []solarEclipsePartialBoundarySample) ([][]SolarEclipsePathPoint, bool) {
|
func solarEclipsePartialBoundarySegments(samples []solarEclipsePartialBoundarySample) ([][]SolarEclipsePathPoint, bool) {
|
||||||
segments := make([][]SolarEclipsePathPoint, 0, 2)
|
segments := make([][]SolarEclipsePathPoint, 0, 2)
|
||||||
var current []SolarEclipsePathPoint
|
var current []SolarEclipsePathPoint
|
||||||
|
allSamplesValid := len(samples) > 0
|
||||||
for _, sample := range samples {
|
for _, sample := range samples {
|
||||||
if !sample.ok {
|
if !sample.ok {
|
||||||
|
allSamplesValid = false
|
||||||
segments = appendSolarEclipsePartialSegment(segments, current)
|
segments = appendSolarEclipsePartialSegment(segments, current)
|
||||||
current = nil
|
current = nil
|
||||||
continue
|
continue
|
||||||
@@ -623,11 +915,20 @@ func solarEclipsePartialBoundarySegments(samples []solarEclipsePartialBoundarySa
|
|||||||
segments = appendSolarEclipsePartialSegment(segments, current)
|
segments = appendSolarEclipsePartialSegment(segments, current)
|
||||||
segments = mergeSolarEclipsePartialWrapSegment(segments, samples)
|
segments = mergeSolarEclipsePartialWrapSegment(segments, samples)
|
||||||
|
|
||||||
if len(segments) == 1 && len(segments[0]) > 2 && !solarEclipsePathCrossesAntimeridian(segments[0][len(segments[0])-1], segments[0][0]) {
|
if !allSamplesValid {
|
||||||
segments[0] = append(segments[0], segments[0][0])
|
return segments, false
|
||||||
return segments, true
|
|
||||||
}
|
}
|
||||||
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(
|
func appendSolarEclipsePartialSegment(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package basic
|
|||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSolarEclipseCentralPathMatchesGlobalGreatest(t *testing.T) {
|
func TestSolarEclipseCentralPathMatchesGlobalGreatest(t *testing.T) {
|
||||||
@@ -128,6 +129,9 @@ func TestSolarEclipsePartialFootprintsIncludeGreatest(t *testing.T) {
|
|||||||
if !foundGreatest {
|
if !foundGreatest {
|
||||||
t.Fatalf("partial footprints should include greatest eclipse JDE %.12f", global.GreatestEclipse)
|
t.Fatalf("partial footprints should include greatest eclipse JDE %.12f", global.GreatestEclipse)
|
||||||
}
|
}
|
||||||
|
if footprints.Footprints[0].Closed {
|
||||||
|
t.Fatal("grazing first footprint must remain open for horizon closure")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) {
|
func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) {
|
||||||
@@ -147,6 +151,101 @@ func TestSolarEclipsePartialFootprintsWorkForPartialOnlyEclipse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseShadowContactsAgainstNASA2012Baseline(t *testing.T) {
|
||||||
|
result := SolarEclipsePartialFootprints(
|
||||||
|
solarEclipseUTToTTJDE(time.Date(2012, 5, 20, 0, 0, 0, 0, time.UTC)),
|
||||||
|
SolarEclipsePartialFootprintOptions{
|
||||||
|
StepDays: 30.0 / 1440.0,
|
||||||
|
BoundaryPoints: 72,
|
||||||
|
CentralShadowStepDays: 10.0 / 1440.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
baseline := []struct {
|
||||||
|
name string
|
||||||
|
point SolarEclipsePathPoint
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{"P1", result.P1, time.Date(2012, 5, 20, 20, 56, 7, 0, time.UTC)},
|
||||||
|
{"P4", result.P4, time.Date(2012, 5, 21, 2, 49, 21, 500000000, time.UTC)},
|
||||||
|
{"U1", result.U1, time.Date(2012, 5, 20, 22, 6, 16, 600000000, time.UTC)},
|
||||||
|
{"U2", result.U2, time.Date(2012, 5, 20, 22, 11, 46, 400000000, time.UTC)},
|
||||||
|
{"U3", result.U3, time.Date(2012, 5, 21, 1, 33, 42, 800000000, time.UTC)},
|
||||||
|
{"U4", result.U4, time.Date(2012, 5, 21, 1, 39, 11, 200000000, time.UTC)},
|
||||||
|
}
|
||||||
|
for _, contact := range baseline {
|
||||||
|
if contact.point.JDE == 0 {
|
||||||
|
t.Fatalf("%s contact is absent", contact.name)
|
||||||
|
}
|
||||||
|
assertLocalSolarEclipseJDEClose(
|
||||||
|
t,
|
||||||
|
contact.name,
|
||||||
|
contact.point.JDE,
|
||||||
|
solarEclipseUTToTTJDE(contact.want),
|
||||||
|
3*time.Second,
|
||||||
|
)
|
||||||
|
if math.Abs(contact.point.SunAltitude) > 0.01 {
|
||||||
|
t.Fatalf("%s Sun altitude = %.9f degrees, want horizon contact", contact.name, contact.point.SunAltitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.P2.JDE != 0 || result.P3.JDE != 0 {
|
||||||
|
t.Fatalf("2012 eclipse unexpectedly has P2/P3 contacts: P2=%+v P3=%+v", result.P2, result.P3)
|
||||||
|
}
|
||||||
|
if len(result.CentralShadowFootprints) == 0 {
|
||||||
|
t.Fatal("expected sampled central-shadow footprints")
|
||||||
|
}
|
||||||
|
if math.Abs(result.CentralShadowStepDays-10.0/1440.0) > 1e-12 {
|
||||||
|
t.Fatalf("central shadow step = %.12f days, want ten minutes", result.CentralShadowStepDays)
|
||||||
|
}
|
||||||
|
for _, footprint := range result.CentralShadowFootprints {
|
||||||
|
if len(footprint.Boundaries) == 0 {
|
||||||
|
t.Fatalf("central-shadow footprint at %.12f has no boundary", footprint.JDE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseShadowContactsIncludeP2P3WhenPenumbraEntersEarthDisk(t *testing.T) {
|
||||||
|
result := SolarEclipsePartialFootprints(
|
||||||
|
solarEclipseUTToTTJDE(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)),
|
||||||
|
SolarEclipsePartialFootprintOptions{StepDays: 30.0 / 1440.0, BoundaryPoints: 36},
|
||||||
|
)
|
||||||
|
for name, contacts := range map[string][]SolarEclipsePathPoint{
|
||||||
|
"penumbral": {result.P1, result.P2, result.P3, result.P4},
|
||||||
|
"central": {result.U1, result.U2, result.U3, result.U4},
|
||||||
|
} {
|
||||||
|
for index, contact := range contacts {
|
||||||
|
if contact.JDE == 0 {
|
||||||
|
t.Fatalf("%s contact %d is absent", name, index)
|
||||||
|
}
|
||||||
|
if index > 0 && !(contacts[index-1].JDE < contact.JDE) {
|
||||||
|
t.Fatalf("%s contacts out of order at %d: %.12f >= %.12f", name, index, contacts[index-1].JDE, contact.JDE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !(result.P1.JDE < result.U1.JDE && result.U4.JDE < result.P4.JDE) {
|
||||||
|
t.Fatalf("central shadow contacts must lie inside partial phase: P1=%v U1=%v U4=%v P4=%v",
|
||||||
|
result.P1.JDE, result.U1.JDE, result.U4.JDE, result.P4.JDE)
|
||||||
|
}
|
||||||
|
if result.CentralShadowFootprints != nil || result.CentralShadowStepDays != 0 {
|
||||||
|
t.Fatal("central-shadow footprints must remain disabled by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipsePartialBoundarySegmentsRemainClosedAcrossAntimeridian(t *testing.T) {
|
||||||
|
samples := []solarEclipsePartialBoundarySample{
|
||||||
|
{point: SolarEclipsePathPoint{Longitude: 170, Latitude: 20}, ok: true},
|
||||||
|
{point: SolarEclipsePathPoint{Longitude: -170, Latitude: 25}, ok: true},
|
||||||
|
{point: SolarEclipsePathPoint{Longitude: -160, Latitude: 10}, ok: true},
|
||||||
|
{point: SolarEclipsePathPoint{Longitude: 160, Latitude: 5}, ok: true},
|
||||||
|
}
|
||||||
|
boundaries, closed := solarEclipsePartialBoundarySegments(samples)
|
||||||
|
if !closed {
|
||||||
|
t.Fatal("complete spherical boundary must remain closed after antimeridian splitting")
|
||||||
|
}
|
||||||
|
if len(boundaries) < 2 {
|
||||||
|
t.Fatalf("expected antimeridian split, got %d boundary segment(s)", len(boundaries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSolarEclipsePartialFootprintsNoEvent(t *testing.T) {
|
func TestSolarEclipsePartialFootprintsNoEvent(t *testing.T) {
|
||||||
footprints := SolarEclipsePartialFootprints(JDECalc(2023, 5, 15), SolarEclipsePartialFootprintOptions{})
|
footprints := SolarEclipsePartialFootprints(JDECalc(2023, 5, 15), SolarEclipsePartialFootprintOptions{})
|
||||||
|
|
||||||
@@ -180,8 +279,11 @@ func assertSolarEclipseFootprintClosedFlag(t *testing.T, footprint SolarEclipseP
|
|||||||
if !footprint.Closed {
|
if !footprint.Closed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(footprint.Boundaries) != 1 {
|
if len(footprint.Boundaries) == 0 {
|
||||||
t.Fatalf("closed footprint should have one boundary: got %d", len(footprint.Boundaries))
|
t.Fatal("closed footprint has no boundaries")
|
||||||
|
}
|
||||||
|
if len(footprint.Boundaries) > 1 {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
boundary := footprint.Boundaries[0]
|
boundary := footprint.Boundaries[0]
|
||||||
if len(boundary) < 2 {
|
if len(boundary) < 2 {
|
||||||
|
|||||||
@@ -79,16 +79,14 @@ func GetJQTime(year, angle int) float64 {
|
|||||||
|
|
||||||
// Newton-Raphson iteration to find precise Julian date
|
// Newton-Raphson iteration to find precise Julian date
|
||||||
currentJD := initialJD
|
currentJD := initialJD
|
||||||
for {
|
var ok bool
|
||||||
previousJD := currentJD
|
currentJD, ok = eventNewtonRefine(currentJD, 0.00001, func(previousJD float64) float64 {
|
||||||
errorValue := JQLospec(previousJD, targetAngle) - targetAngle
|
errorValue := JQLospec(previousJD, targetAngle) - targetAngle
|
||||||
derivative := (JQLospec(previousJD+0.000005, targetAngle) - JQLospec(previousJD-0.000005, targetAngle)) / 0.00001
|
derivative := (JQLospec(previousJD+0.000005, targetAngle) - JQLospec(previousJD-0.000005, targetAngle)) / 0.00001
|
||||||
currentJD = previousJD - errorValue/derivative
|
return errorValue / derivative
|
||||||
|
})
|
||||||
// Check for convergence
|
if !ok {
|
||||||
if math.Abs(currentJD-previousJD) <= 0.00001 {
|
return math.NaN()
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to UT and return
|
// Convert to UT and return
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ func StarSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero bool) (f
|
|||||||
}
|
}
|
||||||
|
|
||||||
func StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero, isRise bool) (float64, error) {
|
func StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero, isRise bool) (float64, error) {
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(ra) || !isFiniteFloat(dec) || !isFiniteFloat(lon) || !isFiniteFloat(lat) || !isFiniteFloat(height) || !isFiniteFloat(timezone) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
||||||
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
||||||
jde = math.Floor(jde) + 0.5
|
jde = math.Floor(jde) + 0.5
|
||||||
@@ -148,19 +151,22 @@ func StarRiseSetTime(jde, ra, dec, lon, lat, height, timezone float64, aero, isR
|
|||||||
} else {
|
} else {
|
||||||
estimateJD = sct + ArcCos(tmp)/15.0/24.0
|
estimateJD = sct + ArcCos(tmp)/15.0/24.0
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := StarHeight(prevJD, ra, dec, lon, lat, timezone) - targetAltitude
|
stDegree := StarHeight(prevJD, ra, dec, lon, lat, timezone) - targetAltitude
|
||||||
stDegreep := (StarHeight(prevJD+0.000005, ra, dec, lon, lat, timezone) - StarHeight(prevJD-0.000005, ra, dec, lon, lat, timezone)) / 0.00001
|
stDegreep := (StarHeight(prevJD+0.000005, ra, dec, lon, lat, timezone) - StarHeight(prevJD-0.000005, ra, dec, lon, lat, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return 0, ErrInvalidObservationInput
|
||||||
}
|
}
|
||||||
return estimateJD, nil
|
return estimateJD, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func StarCulminationTime(jde, ra, lon, timezone float64) float64 {
|
func StarCulminationTime(jde, ra, lon, timezone float64) float64 {
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(ra) || !isFiniteFloat(lon) || !isFiniteFloat(timezone) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
//jde 世界时,非力学时,当地时区 0时,无需转换力学时
|
||||||
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
//ra,dec 瞬时天球座标,非J2000等时间天球坐标
|
||||||
jde = math.Floor(jde) + 0.5
|
jde = math.Floor(jde) + 0.5
|
||||||
@@ -172,14 +178,14 @@ func StarCulminationTime(jde, ra, lon, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return ha
|
return ha
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := limitStarHA(prevJD, ra, lon, timezone) - 360
|
stDegree := limitStarHA(prevJD, ra, lon, timezone) - 360
|
||||||
stDegreep := (limitStarHA(prevJD+0.000005, ra, lon, timezone) - limitStarHA(prevJD-0.000005, ra, lon, timezone)) / 0.00001
|
stDegreep := (limitStarHA(prevJD+0.000005, ra, lon, timezone) - limitStarHA(prevJD-0.000005, ra, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// this file contains bright 9100 stars
|
// 本文件包含约 9100 颗亮星 / this file contains bright 9100 stars
|
||||||
// 9100颗亮星列表
|
// 9100颗亮星列表
|
||||||
|
|
||||||
type InnerStarData struct {
|
type InnerStarData struct {
|
||||||
@@ -20,8 +21,8 @@ type InnerStarData struct {
|
|||||||
Ra float64 //Ra J2000;J2000历元赤经
|
Ra float64 //Ra J2000;J2000历元赤经
|
||||||
Dec float64 //De J2000;J2000历元赤纬
|
Dec float64 //De J2000;J2000历元赤纬
|
||||||
Mag float64 //视星等
|
Mag float64 //视星等
|
||||||
PmRA float64 //赤经年自行
|
PmRA float64 //赤经投影年自行 cos(赤纬)*d赤经/dt,单位角秒/年
|
||||||
PmDec float64 //赤纬年自行
|
PmDec float64 //赤纬年自行,单位角秒/年
|
||||||
RadVel float64 //径向速度 km/s
|
RadVel float64 //径向速度 km/s
|
||||||
RotVel float64 //自行速度 km/s
|
RotVel float64 //自行速度 km/s
|
||||||
Pc float64 //秒差距
|
Pc float64 //秒差距
|
||||||
@@ -252,9 +253,15 @@ func StarDataByHR(hr int) (StarData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s InnerStarData) RaDecByJde(jde float64) (float64, float64) {
|
func (s InnerStarData) RaDecByJde(jde float64) (float64, float64) {
|
||||||
//计算自行
|
// BSC 的 pmRA 是投影自行 cos(Dec)*dRA/dt,而不是 dRA/dt / BSC pmRA is the projected motion cos(Dec)*dRA/dt, not dRA/dt.
|
||||||
year := ((jde - 2451545.0) / 365.2422)
|
year := ((jde - 2451545.0) / 365.2422)
|
||||||
return Precess(s.Ra+(year*s.PmRA/3600), s.Dec+(year*s.PmDec/3600), 2451545.0, jde)
|
dec := s.Dec + year*s.PmDec/3600
|
||||||
|
cosDec := math.Cos(s.Dec * math.Pi / 180)
|
||||||
|
ra := s.Ra
|
||||||
|
if math.Abs(cosDec) > 1e-12 {
|
||||||
|
ra += year * s.PmRA / (3600 * cosDec)
|
||||||
|
}
|
||||||
|
return Precess(ra, dec, 2451545.0, jde)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s StarData) RaDecByDate(date time.Time) (float64, float64) {
|
func (s StarData) RaDecByDate(date time.Time) (float64, float64) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func TestStarDataRegressionSamples(t *testing.T) {
|
|||||||
{15, 677, "21Alp And", "壁宿二", "", "仙女座α", "Alpheratz", "Andromeda", "仙女座", 2.097083333333, 29.090555555556, 2.06, 0.136, -0.163},
|
{15, 677, "21Alp And", "壁宿二", "", "仙女座α", "Alpheratz", "Andromeda", "仙女座", 2.097083333333, 29.090555555556, 2.06, 0.136, -0.163},
|
||||||
{424, 11767, "1Alp UMi", "勾陈一", "北极星", "小熊座α", "Polaris", "UrsaMinor", "小熊座", 37.952916666667, 89.264166666667, 2.02, 0.038, -0.015},
|
{424, 11767, "1Alp UMi", "勾陈一", "北极星", "小熊座α", "Polaris", "UrsaMinor", "小熊座", 37.952916666667, 89.264166666667, 2.02, 0.038, -0.015},
|
||||||
{2491, 32349, "9Alp CMa", "天狼", "", "大犬座α", "Sirius", "CanisMajor", "大犬座", 101.287083333333, -16.716111111111, -1.46, -0.553, -1.205},
|
{2491, 32349, "9Alp CMa", "天狼", "", "大犬座α", "Sirius", "CanisMajor", "大犬座", 101.287083333333, -16.716111111111, -1.46, -0.553, -1.205},
|
||||||
|
{4799, 61558, "25 Vir", "进贤增九", "", "室女座25", "", "Virgo", "室女座", 189.1975, -5.831944444444, 5.87, -0.028, -0.018},
|
||||||
{7001, 91262, "3Alp Lyr", "织女一", "织女", "天琴座α", "Vega", "Lyra", "天琴座", 279.234583333333, 38.783611111111, 0.03, 0.202, 0.286},
|
{7001, 91262, "3Alp Lyr", "织女一", "织女", "天琴座α", "Vega", "Lyra", "天琴座", 279.234583333333, 38.783611111111, 0.03, 0.202, 0.286},
|
||||||
{9100, 330, "9 Cas", "", "", "", "", "", "", 1.056666666667, 62.287777777778, 5.88, -0.004, 0.006},
|
{9100, 330, "9 Cas", "", "", "", "", "", "", 1.056666666667, 62.287777777778, 5.88, -0.004, 0.006},
|
||||||
}
|
}
|
||||||
@@ -90,6 +91,7 @@ func TestStarDataByChineseAlias(t *testing.T) {
|
|||||||
{"北极", 424, 11767},
|
{"北极", 424, 11767},
|
||||||
{"北极星", 424, 11767},
|
{"北极星", 424, 11767},
|
||||||
{"织女", 7001, 91262},
|
{"织女", 7001, 91262},
|
||||||
|
{"进贤增九", 4799, 61558},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
got, err := StarDataByChinese(tc.name)
|
got, err := StarDataByChinese(tc.name)
|
||||||
|
|||||||
@@ -9,15 +9,23 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// star_catalog.dat layout after gzip decompression:
|
// star_catalog.dat gzip 解压后的布局 / star_catalog.dat layout after gzip decompression:
|
||||||
// magic[8] | version[1] | rawDataLen[4] | rawData | stringCount[2] |
|
// magic[8] | version[1] | rawDataLen[4] | rawData | stringCount[2](字符串计数) |
|
||||||
// repeated(stringLen[uvarint] + stringBytes) |
|
// repeated(stringLen[uvarint] + stringBytes)(字符串表) |
|
||||||
// maxHR[2] | repeated(maxHR * 6 * stringIndex[2]) | repeated(maxHR * hip[4]).
|
// maxHR[2] | repeated(maxHR * 6 * stringIndex[2]) | repeated(maxHR * hip[4])(星表记录)。
|
||||||
const starCatalogMagic = "STRCAT01"
|
const starCatalogMagic = "STRCAT01"
|
||||||
|
|
||||||
//go:embed star_catalog.dat
|
//go:embed star_catalog.dat
|
||||||
var starCatalogCompressed []byte
|
var starCatalogCompressed []byte
|
||||||
|
|
||||||
|
// supplementalStarDetails 保存嵌入载荷没有对应命名条目的星表元数据 / supplementalStarDetails contains catalog metadata that is maintained in
|
||||||
|
// 源码形式 / source form when the embedded payload has no corresponding named entry.
|
||||||
|
// 六个字段按编码顺序排列:中文名、别名、拜耳命名、通用名、中文星座和 IAU 星座 / The six fields follow the encoded detail order: Chinese name, alias, Bayer
|
||||||
|
// 命名、通用名、中文星座和 IAU 星座 / designation, common name, Chinese constellation, and IAU constellation.
|
||||||
|
var supplementalStarDetails = map[uint16][]string{
|
||||||
|
4799: {"进贤增九", "", "室女座25", "", "室女座", "Virgo"},
|
||||||
|
}
|
||||||
|
|
||||||
func initStarCatalogData() []byte {
|
func initStarCatalogData() []byte {
|
||||||
reader, err := gzip.NewReader(bytes.NewReader(starCatalogCompressed))
|
reader, err := gzip.NewReader(bytes.NewReader(starCatalogCompressed))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,6 +40,9 @@ func initStarCatalogData() []byte {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
for hr, record := range supplementalStarDetails {
|
||||||
|
detail[hr] = record
|
||||||
|
}
|
||||||
hr2detail = detail
|
hr2detail = detail
|
||||||
hr2hip = hip
|
hr2hip = hip
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -60,3 +60,14 @@ func TestGetRaDecByDate(t *testing.T) {
|
|||||||
t.Fatal("unexpected empty formatted catalog coordinates")
|
t.Fatal("unexpected empty formatted catalog coordinates")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRaDecByJdeUsesProjectedRightAscensionMotion(t *testing.T) {
|
||||||
|
star := InnerStarData{Ra: 10, Dec: 60, PmRA: 3600, PmDec: 0}
|
||||||
|
jde := 2451545.0 + 365.2422
|
||||||
|
ra, dec := star.RaDecByJde(jde)
|
||||||
|
// 投影坐标每年 1 度在 Dec=60 度时对应赤经每年 2 度 / 1 deg/year in the projected coordinate is 2 deg/year in RA at Dec=60.
|
||||||
|
wantRA, wantDec := Precess(12, 60, 2451545.0, jde)
|
||||||
|
if math.Abs(signedAngleDifference(ra, wantRA)) > 1e-8 || math.Abs(dec-wantDec) > 1e-8 {
|
||||||
|
t.Fatalf("position = %.12f %.12f, want %.12f %.12f", ra, dec, wantRA, wantDec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,7 +148,17 @@ func HSunApparentRaDec(jd float64) (float64, float64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func HSunApparentRaDecN(jd float64, n int) (float64, float64) {
|
func HSunApparentRaDecN(jd float64, n int) (float64, float64) {
|
||||||
return LoBoToRaDec(jd, HSunApparentLoN(jd, n), HSunTrueBoN(jd, n))
|
ra, dec, _ := hSunApparentRaDecDistanceN(jd, n)
|
||||||
|
return ra, dec
|
||||||
|
}
|
||||||
|
|
||||||
|
func hSunApparentRaDecDistanceN(jd float64, n int) (ra, dec, distanceAU float64) {
|
||||||
|
trueLongitude := HSunTrueLoN(jd, n)
|
||||||
|
trueLatitude := HSunTrueBoN(jd, n)
|
||||||
|
distanceAU = EarthAwayN(jd, n)
|
||||||
|
apparentLongitude := trueLongitude + Nutation2000Bi(jd) - 20.49552/distanceAU/3600
|
||||||
|
ra, dec = LoBoToRaDec(jd, apparentLongitude, trueLatitude)
|
||||||
|
return ra, dec, distanceAU
|
||||||
}
|
}
|
||||||
|
|
||||||
func HSunApparentRa(jd float64) float64 { // '太阳视赤经
|
func HSunApparentRa(jd float64) float64 { // '太阳视赤经
|
||||||
|
|||||||
@@ -49,14 +49,14 @@ func EveningTwilight(jd, lon, lat, tz, targetAltitude float64) (float64, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
estimateJD := sundown - 5.00/24.00/60.00
|
estimateJD := sundown - 5.00/24.00/60.00
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := SunHeight(prevJD, lon, lat, localTimeZone) - targetAltitude
|
stDegree := SunHeight(prevJD, lon, lat, localTimeZone) - targetAltitude
|
||||||
stDegreep := (SunHeight(prevJD+0.000005, lon, lat, localTimeZone) - SunHeight(prevJD-0.000005, lon, lat, localTimeZone)) / 0.00001
|
stDegreep := (SunHeight(prevJD+0.000005, lon, lat, localTimeZone) - SunHeight(prevJD-0.000005, lon, lat, localTimeZone)) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) < 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return 0, ErrInvalidObservationInput
|
||||||
}
|
}
|
||||||
return estimateJD - localTimeZone/24 + tz/24, nil
|
return estimateJD - localTimeZone/24 + tz/24, nil
|
||||||
}
|
}
|
||||||
@@ -88,14 +88,14 @@ func EveningTwilightN(jd, lon, lat, tz, targetAltitude float64, n int) (float64,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
estimateJD := sundown - 5.00/24.00/60.00
|
estimateJD := sundown - 5.00/24.00/60.00
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := SunHeightN(prevJD, lon, lat, localTimeZone, n) - targetAltitude
|
stDegree := SunHeightN(prevJD, lon, lat, localTimeZone, n) - targetAltitude
|
||||||
stDegreep := (SunHeightN(prevJD+0.000005, lon, lat, localTimeZone, n) - SunHeightN(prevJD-0.000005, lon, lat, localTimeZone, n)) / 0.00001
|
stDegreep := (SunHeightN(prevJD+0.000005, lon, lat, localTimeZone, n) - SunHeightN(prevJD-0.000005, lon, lat, localTimeZone, n)) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) < 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return 0, ErrInvalidObservationInput
|
||||||
}
|
}
|
||||||
return estimateJD - localTimeZone/24 + tz/24, nil
|
return estimateJD - localTimeZone/24 + tz/24, nil
|
||||||
}
|
}
|
||||||
@@ -134,15 +134,14 @@ func MorningTwilight(jd, lon, lat, tz, targetAltitude float64) (float64, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
estimateJD := sunrise - 5.0/(24.0*60.0)
|
estimateJD := sunrise - 5.0/(24.0*60.0)
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
heightDiff := SunHeight(prevJD, lon, lat, localTimeZone) - targetAltitude
|
heightDiff := SunHeight(prevJD, lon, lat, localTimeZone) - targetAltitude
|
||||||
heightDerivative := (SunHeight(prevJD+0.000005, lon, lat, localTimeZone) - SunHeight(prevJD-0.000005, lon, lat, localTimeZone)) / 0.00001
|
heightDerivative := (SunHeight(prevJD+0.000005, lon, lat, localTimeZone) - SunHeight(prevJD-0.000005, lon, lat, localTimeZone)) / 0.00001
|
||||||
estimateJD = prevJD - heightDiff/heightDerivative
|
return heightDiff / heightDerivative
|
||||||
|
})
|
||||||
if math.Abs(estimateJD-prevJD) < 0.00001 {
|
if !ok {
|
||||||
break
|
return 0, ErrInvalidObservationInput
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return estimateJD - localTimeZone/24 + tz/24, nil
|
return estimateJD - localTimeZone/24 + tz/24, nil
|
||||||
@@ -174,15 +173,14 @@ func MorningTwilightN(jd, lon, lat, tz, targetAltitude float64, n int) (float64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
estimateJD := sunrise - 5.0/(24.0*60.0)
|
estimateJD := sunrise - 5.0/(24.0*60.0)
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
heightDiff := SunHeightN(prevJD, lon, lat, localTimeZone, n) - targetAltitude
|
heightDiff := SunHeightN(prevJD, lon, lat, localTimeZone, n) - targetAltitude
|
||||||
heightDerivative := (SunHeightN(prevJD+0.000005, lon, lat, localTimeZone, n) - SunHeightN(prevJD-0.000005, lon, lat, localTimeZone, n)) / 0.00001
|
heightDerivative := (SunHeightN(prevJD+0.000005, lon, lat, localTimeZone, n) - SunHeightN(prevJD-0.000005, lon, lat, localTimeZone, n)) / 0.00001
|
||||||
estimateJD = prevJD - heightDiff/heightDerivative
|
return heightDiff / heightDerivative
|
||||||
|
})
|
||||||
if math.Abs(estimateJD-prevJD) < 0.00001 {
|
if !ok {
|
||||||
break
|
return 0, ErrInvalidObservationInput
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return estimateJD - localTimeZone/24 + tz/24, nil
|
return estimateJD - localTimeZone/24 + tz/24, nil
|
||||||
@@ -209,6 +207,46 @@ func SunTimeAngleN(jd, lon, lat, tz float64, n int) float64 {
|
|||||||
return timeangle
|
return timeangle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sunObservationState struct {
|
||||||
|
altitude float64
|
||||||
|
distanceAU float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func sunObservationStateN(jd, lon, lat, tz float64, n int) sunObservationState {
|
||||||
|
calculationJD := jd - tz/24.0
|
||||||
|
tt := TD2UT(calculationJD, true)
|
||||||
|
siderealTime := Limit360(ApparentSiderealTime(calculationJD)*15 + lon)
|
||||||
|
ra, dec, distanceAU := hSunApparentRaDecDistanceN(tt, n)
|
||||||
|
hourAngle := Limit360(siderealTime - ra)
|
||||||
|
altitudeSine := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
|
||||||
|
return sunObservationState{
|
||||||
|
altitude: ArcSin(altitudeSine),
|
||||||
|
distanceAU: distanceAU,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sunRiseSetResidual(jd, longitude, latitude, timeZone, zenithShift, height float64, n int) float64 {
|
||||||
|
state := sunObservationStateN(jd, longitude, latitude, timeZone, n)
|
||||||
|
// 相对观测者下沉地平线的视上缘高度角 / Apparent upper-limb altitude relative to the observer's depressed horizon.
|
||||||
|
residual := state.altitude + HeightDegreeByLat(height, latitude)
|
||||||
|
if zenithShift != 0 {
|
||||||
|
residual += RefractionFromTrueAltitude(state.altitude, refractionStandardPressureHPa, refractionStandardTemperatureC)
|
||||||
|
residual += angularSemidiameterFromAU(sunEquatorialRadiusKM, state.distanceAU) / 3600
|
||||||
|
}
|
||||||
|
return residual
|
||||||
|
}
|
||||||
|
|
||||||
|
func sunRiseSetOnCivilDay(candidate, slope, civilDayStart, longitude, latitude, requestedTimeZone,
|
||||||
|
localTimeZone, zenithShift, height float64, isSunrise bool, n int, fallbackErr error) (float64, error) {
|
||||||
|
if eventRiseSetCandidateValid(candidate, civilDayStart, slope, isSunrise) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
return eventDirectionalRiseSetSearch(civilDayStart, isSunrise, fallbackErr, func(outputJD float64) float64 {
|
||||||
|
localJD := outputJD + localTimeZone/24 - requestedTimeZone/24
|
||||||
|
return sunRiseSetResidual(localJD, longitude, latitude, localTimeZone, zenithShift, height, n)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetSunRiseTime 精确计算日出时间,传入当日0时JDE
|
// GetSunRiseTime 精确计算日出时间,传入当日0时JDE
|
||||||
func GetSunRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
func GetSunRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64) (float64, error) {
|
||||||
return calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height, true)
|
return calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height, true)
|
||||||
@@ -229,6 +267,10 @@ func GetSunSetTimeN(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
|||||||
|
|
||||||
// calculateSunRiseSetTime 统一的日出日落计算函数
|
// calculateSunRiseSetTime 统一的日出日落计算函数
|
||||||
func calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64, isSunrise bool) (float64, error) {
|
func calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShift, height float64, isSunrise bool) (float64, error) {
|
||||||
|
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
civilDayStart := math.Floor(julianDay) + 0.5
|
||||||
julianDay = math.Floor(julianDay) + 1.5
|
julianDay = math.Floor(julianDay) + 1.5
|
||||||
naturalTimeZone := math.Round(longitude / 15)
|
naturalTimeZone := math.Round(longitude / 15)
|
||||||
sunAngle := StandardAltitudeSun(zenithShift, height, latitude)
|
sunAngle := StandardAltitudeSun(zenithShift, height, latitude)
|
||||||
@@ -237,34 +279,44 @@ func calculateSunRiseSetTime(julianDay, longitude, latitude, timeZone, zenithShi
|
|||||||
solarNoonTime := CulminationTime(julianDay, longitude, naturalTimeZone)
|
solarNoonTime := CulminationTime(julianDay, longitude, naturalTimeZone)
|
||||||
|
|
||||||
// 检查极夜极昼条件
|
// 检查极夜极昼条件
|
||||||
if err := checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise); err != nil {
|
if err := checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, isSunrise); err != nil {
|
||||||
return 0, err
|
return sunRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude, timeZone,
|
||||||
|
naturalTimeZone, zenithShift, height, isSunrise, -1, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算初始估算时间
|
// 计算初始估算时间
|
||||||
initialTime := calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise)
|
initialTime := calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, zenithShift, height, isSunrise)
|
||||||
|
|
||||||
// 牛顿-拉夫逊迭代求精确解
|
// 牛顿-拉夫逊迭代求精确解
|
||||||
return sunRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, naturalTimeZone, sunAngle, timeZone), nil
|
result, slope := sunRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, naturalTimeZone, zenithShift, height, timeZone)
|
||||||
|
return sunRiseSetOnCivilDay(result, slope, civilDayStart, longitude, latitude, timeZone,
|
||||||
|
naturalTimeZone, zenithShift, height, isSunrise, -1, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateSunRiseSetTimeN(julianDay, longitude, latitude, timeZone, zenithShift, height float64, isSunrise bool, n int) (float64, error) {
|
func calculateSunRiseSetTimeN(julianDay, longitude, latitude, timeZone, zenithShift, height float64, isSunrise bool, n int) (float64, error) {
|
||||||
|
if !isFiniteFloat(julianDay) || !isFiniteFloat(longitude) || !isFiniteFloat(latitude) || !isFiniteFloat(timeZone) || !isFiniteFloat(zenithShift) || !isFiniteFloat(height) {
|
||||||
|
return 0, ErrInvalidObservationInput
|
||||||
|
}
|
||||||
|
civilDayStart := math.Floor(julianDay) + 0.5
|
||||||
julianDay = math.Floor(julianDay) + 1.5
|
julianDay = math.Floor(julianDay) + 1.5
|
||||||
naturalTimeZone := math.Round(longitude / 15)
|
naturalTimeZone := math.Round(longitude / 15)
|
||||||
sunAngle := StandardAltitudeSun(zenithShift, height, latitude)
|
sunAngle := StandardAltitudeSun(zenithShift, height, latitude)
|
||||||
|
|
||||||
solarNoonTime := CulminationTimeN(julianDay, longitude, naturalTimeZone, n)
|
solarNoonTime := CulminationTimeN(julianDay, longitude, naturalTimeZone, n)
|
||||||
if err := checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise, n); err != nil {
|
if err := checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, isSunrise, n); err != nil {
|
||||||
return 0, err
|
return sunRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude, timeZone,
|
||||||
|
naturalTimeZone, zenithShift, height, isSunrise, n, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
initialTime := calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise, n)
|
initialTime := calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, zenithShift, height, isSunrise, n)
|
||||||
return sunRiseSetNewtonRaphsonIterationN(initialTime, longitude, latitude, naturalTimeZone, sunAngle, timeZone, n), nil
|
result, slope := sunRiseSetNewtonRaphsonIterationN(initialTime, longitude, latitude, naturalTimeZone, zenithShift, height, timeZone, n)
|
||||||
|
return sunRiseSetOnCivilDay(result, slope, civilDayStart, longitude, latitude, timeZone,
|
||||||
|
naturalTimeZone, zenithShift, height, isSunrise, n, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkPolarConditions 检查极夜极昼条件
|
// checkPolarConditions 检查极夜极昼条件
|
||||||
func checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool) error {
|
func checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height float64, isSunrise bool) error {
|
||||||
if SunHeight(solarNoonTime, longitude, latitude, naturalTimeZone) < sunAngle {
|
if sunRiseSetResidual(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, -1) < 0 {
|
||||||
return ErrNeverRise
|
return ErrNeverRise
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,15 +325,15 @@ func checkPolarConditions(solarNoonTime, longitude, latitude, naturalTimeZone, s
|
|||||||
checkTime = solarNoonTime - 0.5
|
checkTime = solarNoonTime - 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
if SunHeight(checkTime, longitude, latitude, naturalTimeZone) > sunAngle {
|
if sunRiseSetResidual(checkTime, longitude, latitude, naturalTimeZone, zenithShift, height, -1) > 0 {
|
||||||
return ErrNeverSet
|
return ErrNeverSet
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool, n int) error {
|
func checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height float64, isSunrise bool, n int) error {
|
||||||
if SunHeightN(solarNoonTime, longitude, latitude, naturalTimeZone, n) < sunAngle {
|
if sunRiseSetResidual(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, n) < 0 {
|
||||||
return ErrNeverRise
|
return ErrNeverRise
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +342,7 @@ func checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone,
|
|||||||
checkTime = solarNoonTime - 0.5
|
checkTime = solarNoonTime - 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
if SunHeightN(checkTime, longitude, latitude, naturalTimeZone, n) > sunAngle {
|
if sunRiseSetResidual(checkTime, longitude, latitude, naturalTimeZone, zenithShift, height, n) > 0 {
|
||||||
return ErrNeverSet
|
return ErrNeverSet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +350,7 @@ func checkPolarConditionsN(solarNoonTime, longitude, latitude, naturalTimeZone,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// calculateInitialSunTime 计算日出日落的初始估算时间
|
// calculateInitialSunTime 计算日出日落的初始估算时间
|
||||||
func calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool) float64 {
|
func calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, zenithShift, height float64, isSunrise bool) float64 {
|
||||||
// 使用球面三角法计算: (sin(ho)-sin(φ)*sin(δ))/(cos(φ)*cos(δ))
|
// 使用球面三角法计算: (sin(ho)-sin(φ)*sin(δ))/(cos(φ)*cos(δ))
|
||||||
apparentDeclination := HSunApparentDec(solarNoonTime)
|
apparentDeclination := HSunApparentDec(solarNoonTime)
|
||||||
cosHourAngle := (Sin(sunAngle) - Sin(apparentDeclination)*Sin(latitude)) / (Cos(apparentDeclination) * Cos(latitude))
|
cosHourAngle := (Sin(sunAngle) - Sin(apparentDeclination)*Sin(latitude)) / (Cos(apparentDeclination) * Cos(latitude))
|
||||||
@@ -318,11 +370,11 @@ func calculateInitialSunTime(solarNoonTime, longitude, latitude, naturalTimeZone
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 使用迭代逼近法(极地条件)
|
// 使用迭代逼近法(极地条件)
|
||||||
return iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise)
|
return iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, isSunrise)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool, n int) float64 {
|
func calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, zenithShift, height float64, isSunrise bool, n int) float64 {
|
||||||
apparentDeclination := HSunApparentDecN(solarNoonTime, n)
|
apparentDeclination := HSunApparentDecN(solarNoonTime, n)
|
||||||
cosHourAngle := (Sin(sunAngle) - Sin(apparentDeclination)*Sin(latitude)) / (Cos(apparentDeclination) * Cos(latitude))
|
cosHourAngle := (Sin(sunAngle) - Sin(apparentDeclination)*Sin(latitude)) / (Cos(apparentDeclination) * Cos(latitude))
|
||||||
|
|
||||||
@@ -339,11 +391,11 @@ func calculateInitialSunTimeN(solarNoonTime, longitude, latitude, naturalTimeZon
|
|||||||
return solarNoonTime + hourAngle/24 + timeOffset
|
return solarNoonTime + hourAngle/24 + timeOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
return iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle, isSunrise, n)
|
return iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height, isSunrise, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// iterativeApproach 迭代逼近法计算(用于极地等特殊条件)
|
// iterativeApproach 迭代逼近法计算(用于极地等特殊条件)
|
||||||
func iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool) float64 {
|
func iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height float64, isSunrise bool) float64 {
|
||||||
estimatedTime := solarNoonTime
|
estimatedTime := solarNoonTime
|
||||||
stepSize := 15.0 / 60.0 / 24.0 // 15分钟步长
|
stepSize := 15.0 / 60.0 / 24.0 // 15分钟步长
|
||||||
if isSunrise {
|
if isSunrise {
|
||||||
@@ -351,14 +403,14 @@ func iterativeApproach(solarNoonTime, longitude, latitude, naturalTimeZone, sunA
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxIterations = 48
|
const maxIterations = 48
|
||||||
for i := 0; i < maxIterations && LowSunHeight(estimatedTime, longitude, latitude, naturalTimeZone) > sunAngle; i++ {
|
for i := 0; i < maxIterations && sunRiseSetResidual(estimatedTime, longitude, latitude, naturalTimeZone, zenithShift, height, -1) > 0; i++ {
|
||||||
estimatedTime += stepSize
|
estimatedTime += stepSize
|
||||||
}
|
}
|
||||||
|
|
||||||
return estimatedTime
|
return estimatedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
func iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, sunAngle float64, isSunrise bool, n int) float64 {
|
func iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, zenithShift, height float64, isSunrise bool, n int) float64 {
|
||||||
estimatedTime := solarNoonTime
|
estimatedTime := solarNoonTime
|
||||||
stepSize := 15.0 / 60.0 / 24.0
|
stepSize := 15.0 / 60.0 / 24.0
|
||||||
if isSunrise {
|
if isSunrise {
|
||||||
@@ -366,7 +418,7 @@ func iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, sun
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxIterations = 48
|
const maxIterations = 48
|
||||||
for i := 0; i < maxIterations && lowSunHeightForN(estimatedTime, longitude, latitude, naturalTimeZone, n) > sunAngle; i++ {
|
for i := 0; i < maxIterations && sunRiseSetResidual(estimatedTime, longitude, latitude, naturalTimeZone, zenithShift, height, n) > 0; i++ {
|
||||||
estimatedTime += stepSize
|
estimatedTime += stepSize
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,82 +426,60 @@ func iterativeApproachN(solarNoonTime, longitude, latitude, naturalTimeZone, sun
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sunRiseSetNewtonRaphsonIteration 牛顿-拉夫逊迭代法求精确解
|
// sunRiseSetNewtonRaphsonIteration 牛顿-拉夫逊迭代法求精确解
|
||||||
func sunRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, naturalTimeZone, sunAngle, timeZone float64) float64 {
|
func sunRiseSetNewtonRaphsonIteration(initialTime, longitude, latitude, naturalTimeZone, zenithShift, height, timeZone float64) (float64, float64) {
|
||||||
const (
|
const (
|
||||||
convergenceThreshold = 0.00001
|
convergenceThreshold = 0.00001
|
||||||
derivativeStep = 0.000005
|
derivativeStep = 0.000005
|
||||||
)
|
)
|
||||||
|
|
||||||
currentTime := initialTime
|
currentTime := initialTime
|
||||||
|
slope := math.NaN()
|
||||||
for {
|
var ok bool
|
||||||
previousTime := currentTime
|
currentTime, ok = eventNewtonRefine(currentTime, convergenceThreshold, func(previousTime float64) float64 {
|
||||||
|
functionValue := sunRiseSetResidual(previousTime, longitude, latitude, naturalTimeZone, zenithShift, height, -1)
|
||||||
// 计算函数值:f(t) = SunHeight(t) - targetAngle
|
slope = (sunRiseSetResidual(previousTime+derivativeStep, longitude, latitude, naturalTimeZone, zenithShift, height, -1) -
|
||||||
functionValue := SunHeight(previousTime, longitude, latitude, naturalTimeZone) - sunAngle
|
sunRiseSetResidual(previousTime-derivativeStep, longitude, latitude, naturalTimeZone, zenithShift, height, -1)) / (2 * derivativeStep)
|
||||||
|
return functionValue / slope
|
||||||
// 计算导数:f'(t) ≈ (f(t+h) - f(t-h)) / (2h)
|
})
|
||||||
derivative := (SunHeight(previousTime+derivativeStep, longitude, latitude, naturalTimeZone) -
|
if !ok {
|
||||||
SunHeight(previousTime-derivativeStep, longitude, latitude, naturalTimeZone)) / (2 * derivativeStep)
|
return math.NaN(), math.NaN()
|
||||||
|
|
||||||
// 牛顿-拉夫逊公式:t_new = t_old - f(t) / f'(t)
|
|
||||||
currentTime = previousTime - functionValue/derivative
|
|
||||||
|
|
||||||
// 检查收敛
|
|
||||||
if math.Abs(currentTime-previousTime) <= convergenceThreshold {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为指定时区
|
// 转换为指定时区
|
||||||
return currentTime - naturalTimeZone/24 + timeZone/24
|
return currentTime - naturalTimeZone/24 + timeZone/24, slope
|
||||||
}
|
}
|
||||||
|
|
||||||
func sunRiseSetNewtonRaphsonIterationN(initialTime, longitude, latitude, naturalTimeZone, sunAngle, timeZone float64, n int) float64 {
|
func sunRiseSetNewtonRaphsonIterationN(initialTime, longitude, latitude, naturalTimeZone, zenithShift, height, timeZone float64, n int) (float64, float64) {
|
||||||
const (
|
const (
|
||||||
convergenceThreshold = 0.00001
|
convergenceThreshold = 0.00001
|
||||||
derivativeStep = 0.000005
|
derivativeStep = 0.000005
|
||||||
)
|
)
|
||||||
|
|
||||||
currentTime := initialTime
|
currentTime := initialTime
|
||||||
|
slope := math.NaN()
|
||||||
for {
|
var ok bool
|
||||||
previousTime := currentTime
|
currentTime, ok = eventNewtonRefine(currentTime, convergenceThreshold, func(previousTime float64) float64 {
|
||||||
functionValue := SunHeightN(previousTime, longitude, latitude, naturalTimeZone, n) - sunAngle
|
functionValue := sunRiseSetResidual(previousTime, longitude, latitude, naturalTimeZone, zenithShift, height, n)
|
||||||
derivative := (SunHeightN(previousTime+derivativeStep, longitude, latitude, naturalTimeZone, n) -
|
slope = (sunRiseSetResidual(previousTime+derivativeStep, longitude, latitude, naturalTimeZone, zenithShift, height, n) -
|
||||||
SunHeightN(previousTime-derivativeStep, longitude, latitude, naturalTimeZone, n)) / (2 * derivativeStep)
|
sunRiseSetResidual(previousTime-derivativeStep, longitude, latitude, naturalTimeZone, zenithShift, height, n)) / (2 * derivativeStep)
|
||||||
currentTime = previousTime - functionValue/derivative
|
return functionValue / slope
|
||||||
if math.Abs(currentTime-previousTime) <= convergenceThreshold {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN(), math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentTime - naturalTimeZone/24 + timeZone/24
|
return currentTime - naturalTimeZone/24 + timeZone/24, slope
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 太阳高度角 世界时
|
* 太阳高度角 世界时
|
||||||
*/
|
*/
|
||||||
func SunHeight(jd, lon, lat, tz float64) float64 {
|
func SunHeight(jd, lon, lat, tz float64) float64 {
|
||||||
//tmp := (tz*15 - lon) * 4 / 60
|
return SunHeightN(jd, lon, lat, tz, -1)
|
||||||
//truejd := jd - tmp/24
|
|
||||||
calcjd := jd - tz/24.0
|
|
||||||
tjde := TD2UT(calcjd, true)
|
|
||||||
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
|
|
||||||
ra, dec := HSunApparentRaDec(tjde)
|
|
||||||
hourAngle := Limit360(st - ra)
|
|
||||||
tmp2 := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
|
|
||||||
return ArcSin(tmp2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SunHeightN(jd, lon, lat, tz float64, n int) float64 {
|
func SunHeightN(jd, lon, lat, tz float64, n int) float64 {
|
||||||
calcjd := jd - tz/24.0
|
return sunObservationStateN(jd, lon, lat, tz, n).altitude
|
||||||
tjde := TD2UT(calcjd, true)
|
|
||||||
st := Limit360(ApparentSiderealTime(calcjd)*15 + lon)
|
|
||||||
ra, dec := HSunApparentRaDecN(tjde, n)
|
|
||||||
hourAngle := Limit360(st - ra)
|
|
||||||
tmp2 := Sin(lat)*Sin(dec) + Cos(dec)*Cos(lat)*Cos(hourAngle)
|
|
||||||
return ArcSin(tmp2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LowSunHeight(jd, lon, lat, tz float64) float64 {
|
func LowSunHeight(jd, lon, lat, tz float64) float64 {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"generated_utc": "2026-08-02T15:47:59Z",
|
||||||
|
"sources": {
|
||||||
|
"imcce_miriade": {
|
||||||
|
"provider": "IMCCE/LTE Miriade RTS",
|
||||||
|
"url": "https://ssp.imcce.fr/webservices/miriade/api/rts.php",
|
||||||
|
"definition": "direct rise and set service; the RTS API does not expose its horizon, refraction, or ephemeris-theory settings",
|
||||||
|
"resolution": "0.1-second display precision; not an uncertainty estimate"
|
||||||
|
},
|
||||||
|
"jpl_horizons": {
|
||||||
|
"provider": "NASA/JPL Horizons",
|
||||||
|
"url": "https://ssd.jpl.nasa.gov/api/horizons.api",
|
||||||
|
"model": "DE441",
|
||||||
|
"definition": "refracted apparent upper limb crossing the reference-ellipsoid visual horizon at sea level",
|
||||||
|
"resolution": "linear zero interpolation from 1-minute refracted center elevation and angular diameter"
|
||||||
|
},
|
||||||
|
"met_norway": {
|
||||||
|
"provider": "MET Norway Sunrise API",
|
||||||
|
"url": "https://api.met.no/weatherapi/sunrise/3.0/moon",
|
||||||
|
"model": "Skyfield 1.53 with JPL DE440s",
|
||||||
|
"definition": "fixed 0.5666-degree refraction plus 0.2667-degree lunar radius; queried by local solar date and returned in UTC",
|
||||||
|
"resolution": "1 minute",
|
||||||
|
"license_url": "https://api.met.no/license_data.html"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"site": "greenwich",
|
||||||
|
"date_utc": "2026-04-28",
|
||||||
|
"longitude": 0,
|
||||||
|
"latitude": 51.4779,
|
||||||
|
"observer_height_m": 0,
|
||||||
|
"jpl_horizons": {
|
||||||
|
"rise_utc": "2026-04-28T16:01:21Z",
|
||||||
|
"set_utc": "2026-04-28T03:19:32Z"
|
||||||
|
},
|
||||||
|
"met_norway": {
|
||||||
|
"rise_utc": "2026-04-28T16:01:00Z",
|
||||||
|
"set_utc": "2026-04-28T03:19:00Z"
|
||||||
|
},
|
||||||
|
"imcce_miriade": {
|
||||||
|
"rise_utc": "2026-04-28T16:03:10.4Z",
|
||||||
|
"set_utc": "2026-04-28T03:17:47.1Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site": "beijing",
|
||||||
|
"date_utc": "2025-06-05",
|
||||||
|
"longitude": 116.4074,
|
||||||
|
"latitude": 39.9042,
|
||||||
|
"observer_height_m": 0,
|
||||||
|
"jpl_horizons": {
|
||||||
|
"rise_utc": "2025-06-05T06:01:29Z",
|
||||||
|
"set_utc": "2025-06-05T17:41:15Z"
|
||||||
|
},
|
||||||
|
"met_norway": {
|
||||||
|
"rise_utc": "2025-06-05T06:01:00Z",
|
||||||
|
"set_utc": "2025-06-05T17:41:00Z"
|
||||||
|
},
|
||||||
|
"imcce_miriade": {
|
||||||
|
"rise_utc": "2025-06-05T06:02:54.6Z",
|
||||||
|
"set_utc": "2025-06-05T17:39:51.3Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site": "singapore",
|
||||||
|
"date_utc": "2025-01-15",
|
||||||
|
"longitude": 103.8198,
|
||||||
|
"latitude": 1.3521,
|
||||||
|
"observer_height_m": 0,
|
||||||
|
"jpl_horizons": {
|
||||||
|
"rise_utc": "2025-01-15T12:36:30Z",
|
||||||
|
"set_utc": "2025-01-15T00:15:05Z"
|
||||||
|
},
|
||||||
|
"met_norway": {
|
||||||
|
"rise_utc": "2025-01-15T12:36:00Z",
|
||||||
|
"set_utc": "2025-01-15T00:14:00Z"
|
||||||
|
},
|
||||||
|
"imcce_miriade": {
|
||||||
|
"rise_utc": "2025-01-15T12:37:41.8Z",
|
||||||
|
"set_utc": "2025-01-15T00:13:51.3Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site": "sydney",
|
||||||
|
"date_utc": "2025-07-16",
|
||||||
|
"longitude": 151.2093,
|
||||||
|
"latitude": -33.8688,
|
||||||
|
"observer_height_m": 0,
|
||||||
|
"jpl_horizons": {
|
||||||
|
"rise_utc": "2025-07-16T12:40:33Z",
|
||||||
|
"set_utc": "2025-07-16T00:09:02Z"
|
||||||
|
},
|
||||||
|
"met_norway": {
|
||||||
|
"rise_utc": "2025-07-16T12:40:00Z",
|
||||||
|
"set_utc": "2025-07-16T00:08:00Z"
|
||||||
|
},
|
||||||
|
"imcce_miriade": {
|
||||||
|
"rise_utc": "2025-07-16T12:41:59.3Z",
|
||||||
|
"set_utc": "2025-07-16T00:07:38.7Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site": "new_york",
|
||||||
|
"date_utc": "2025-10-15",
|
||||||
|
"longitude": -74.006,
|
||||||
|
"latitude": 40.7128,
|
||||||
|
"observer_height_m": 0,
|
||||||
|
"jpl_horizons": {
|
||||||
|
"rise_utc": "2025-10-15T04:57:44Z",
|
||||||
|
"set_utc": "2025-10-15T19:47:14Z"
|
||||||
|
},
|
||||||
|
"met_norway": {
|
||||||
|
"rise_utc": "2025-10-15T04:58:00Z",
|
||||||
|
"set_utc": "2025-10-15T19:46:00Z"
|
||||||
|
},
|
||||||
|
"imcce_miriade": {
|
||||||
|
"rise_utc": "2025-10-15T04:59:27.6Z",
|
||||||
|
"set_utc": "2025-10-15T19:45:38.0Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site": "anchorage",
|
||||||
|
"date_utc": "2025-03-20",
|
||||||
|
"longitude": -149.9003,
|
||||||
|
"latitude": 61.2181,
|
||||||
|
"observer_height_m": 0,
|
||||||
|
"jpl_horizons": {
|
||||||
|
"rise_utc": "2025-03-20T12:53:14Z",
|
||||||
|
"set_utc": "2025-03-20T16:04:16Z"
|
||||||
|
},
|
||||||
|
"met_norway": {
|
||||||
|
"rise_utc": "2025-03-20T12:54:00Z",
|
||||||
|
"set_utc": "2025-03-20T16:03:00Z"
|
||||||
|
},
|
||||||
|
"imcce_miriade": {
|
||||||
|
"rise_utc": "2025-03-20T12:59:56.0Z",
|
||||||
|
"set_utc": "2025-03-20T15:57:34.4Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"site": "rio_de_janeiro",
|
||||||
|
"date_utc": "2026-01-15",
|
||||||
|
"longitude": -43.1729,
|
||||||
|
"latitude": -22.9068,
|
||||||
|
"observer_height_m": 0,
|
||||||
|
"jpl_horizons": {
|
||||||
|
"rise_utc": "2026-01-15T05:13:59Z",
|
||||||
|
"set_utc": "2026-01-15T19:26:42Z"
|
||||||
|
},
|
||||||
|
"met_norway": {
|
||||||
|
"rise_utc": "2026-01-15T05:14:00Z",
|
||||||
|
"set_utc": "2026-01-15T19:26:00Z"
|
||||||
|
},
|
||||||
|
"imcce_miriade": {
|
||||||
|
"rise_utc": "2026-01-15T05:15:20.6Z",
|
||||||
|
"set_utc": "2026-01-15T19:25:19.4Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -59,15 +59,24 @@ func GetWuHouTime(Year, Angle int) float64 {
|
|||||||
if Angle <= 5 {
|
if Angle <= 5 {
|
||||||
Angle = 360 + Angle
|
Angle = 360 + Angle
|
||||||
}
|
}
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
JD0 := JD1
|
JD0 := JD1
|
||||||
stDegree := JQLospec(JD0, float64(Angle)) - float64(Angle)
|
stDegree := JQLospec(JD0, float64(Angle)) - float64(Angle)
|
||||||
stDegreep := (JQLospec(JD0+0.000005, float64(Angle)) - JQLospec(JD0-0.000005, float64(Angle))) / 0.00001
|
stDegreep := (JQLospec(JD0+0.000005, float64(Angle)) - JQLospec(JD0-0.000005, float64(Angle))) / 0.00001
|
||||||
JD1 = JD0 - stDegree/stDegreep
|
nextJD := JD0 - stDegree/stDegreep
|
||||||
if math.Abs(JD1-JD0) <= 0.00001 {
|
if !isFiniteFloat(nextJD) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
JD1 = nextJD
|
||||||
|
if math.Abs(nextJD-JD0) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(JD1, false)
|
return TD2UT(JD1, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,14 +173,14 @@ func UranusCulminationTime(jde, lon, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return currentHourAngle
|
return currentHourAngle
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
hourAngleDelta := normalizedHourAngle(prevJD, lon, timezone) - 360
|
||||||
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
hourAngleSlope := (normalizedHourAngle(prevJD+0.000005, lon, timezone) - normalizedHourAngle(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - hourAngleDelta/hourAngleSlope
|
return hourAngleDelta / hourAngleSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func uranusRADerivativeN(jde, delta float64, n int) float64 {
|
|||||||
|
|
||||||
func uranusConjunctionFull(jde, degree float64, next uint8) float64 {
|
func uranusConjunctionFull(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := URANUS_S_PERIOD / 360
|
daysPerDegree := URANUS_S_PERIOD / 360
|
||||||
currentDelta := uranusSunLongitudeDelta(jde, degree, false)
|
currentDelta := uranusSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -72,20 +75,29 @@ func uranusConjunctionFull(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := uranusSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := uranusSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (uranusSunLongitudeDelta(prevJD+0.000005, degree, true) - uranusSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (uranusSunLongitudeDelta(prevJD+0.000005, degree, true) - uranusSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func uranusConjunction(jde, degree float64, next uint8) float64 {
|
func uranusConjunction(jde, degree float64, next uint8) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) || !isFiniteFloat(degree) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
daysPerDegree := URANUS_S_PERIOD / 360
|
daysPerDegree := URANUS_S_PERIOD / 360
|
||||||
currentDelta := uranusSunLongitudeDelta(jde, degree, false)
|
currentDelta := uranusSunLongitudeDelta(jde, degree, false)
|
||||||
if next == 0 {
|
if next == 0 {
|
||||||
@@ -94,24 +106,36 @@ func uranusConjunction(jde, degree float64, next uint8) float64 {
|
|||||||
jde += daysPerDegree * currentDelta
|
jde += daysPerDegree * currentDelta
|
||||||
}
|
}
|
||||||
estimateJD := jde
|
estimateJD := jde
|
||||||
for {
|
converged := false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := uranusSunLongitudeDeltaN(prevJD, degree, true, uranusEventSearchN)
|
longitudeDelta := uranusSunLongitudeDeltaN(prevJD, degree, true, uranusEventSearchN)
|
||||||
longitudeSlope := (uranusSunLongitudeDeltaN(prevJD+0.000005, degree, true, uranusEventSearchN) - uranusSunLongitudeDeltaN(prevJD-0.000005, degree, true, uranusEventSearchN)) / 0.00001
|
longitudeSlope := (uranusSunLongitudeDeltaN(prevJD+0.000005, degree, true, uranusEventSearchN) - uranusSunLongitudeDeltaN(prevJD-0.000005, degree, true, uranusEventSearchN)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= uranusPhaseCoarseTolerance {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= uranusPhaseCoarseTolerance {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for {
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
converged = false
|
||||||
|
for i := 0; i < eventNewtonMaxIterations; i++ {
|
||||||
prevJD := estimateJD
|
prevJD := estimateJD
|
||||||
longitudeDelta := uranusSunLongitudeDelta(prevJD, degree, true)
|
longitudeDelta := uranusSunLongitudeDelta(prevJD, degree, true)
|
||||||
longitudeSlope := (uranusSunLongitudeDelta(prevJD+0.000005, degree, true) - uranusSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
longitudeSlope := (uranusSunLongitudeDelta(prevJD+0.000005, degree, true) - uranusSunLongitudeDelta(prevJD-0.000005, degree, true)) / 0.00001
|
||||||
estimateJD = prevJD - longitudeDelta/longitudeSlope
|
nextJD := prevJD - longitudeDelta/longitudeSlope
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
estimateJD = nextJD
|
||||||
|
if math.Abs(nextJD-prevJD) <= 0.00001 {
|
||||||
|
converged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !converged {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
return TD2UT(estimateJD, false)
|
return TD2UT(estimateJD, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,14 +173,14 @@ func VenusCulminationTime(jde, lon, timezone float64) float64 {
|
|||||||
}
|
}
|
||||||
return ha
|
return ha
|
||||||
}
|
}
|
||||||
for {
|
var ok bool
|
||||||
prevJD := estimateJD
|
estimateJD, ok = eventNewtonRefine(estimateJD, 0.00001, func(prevJD float64) float64 {
|
||||||
stDegree := limitHA(prevJD, lon, timezone) - 360
|
stDegree := limitHA(prevJD, lon, timezone) - 360
|
||||||
stDegreep := (limitHA(prevJD+0.000005, lon, timezone) - limitHA(prevJD-0.000005, lon, timezone)) / 0.00001
|
stDegreep := (limitHA(prevJD+0.000005, lon, timezone) - limitHA(prevJD-0.000005, lon, timezone)) / 0.00001
|
||||||
estimateJD = prevJD - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(estimateJD-prevJD) <= 0.00001 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
return estimateJD
|
return estimateJD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type venusEventBaselineSample struct {
|
type venusEventBaselineSample struct {
|
||||||
@@ -47,7 +48,13 @@ func TestVenusEventBaselineRegression(t *testing.T) {
|
|||||||
t.Fatalf("%s missing baseline event %s", sample.InputUTC, event.name)
|
t.Fatalf("%s missing baseline event %s", sample.InputUTC, event.name)
|
||||||
}
|
}
|
||||||
want := math.Float64frombits(wantBits)
|
want := math.Float64frombits(wantBits)
|
||||||
|
if math.IsNaN(want) || math.IsInf(want, 0) {
|
||||||
|
t.Fatalf("%s %s baseline is non-finite %v", sample.InputUTC, event.name, want)
|
||||||
|
}
|
||||||
got := event.fn(jd)
|
got := event.fn(jd)
|
||||||
|
if math.IsNaN(got) || math.IsInf(got, 0) {
|
||||||
|
t.Fatalf("%s %s returned non-finite result %v", sample.InputUTC, event.name, got)
|
||||||
|
}
|
||||||
diff := math.Abs(got - want)
|
diff := math.Abs(got - want)
|
||||||
if diff > event.tolerance {
|
if diff > event.tolerance {
|
||||||
t.Fatalf("%s %s diff %.12f > tolerance %.12f", sample.InputUTC, event.name, diff, event.tolerance)
|
t.Fatalf("%s %s diff %.12f > tolerance %.12f", sample.InputUTC, event.name, diff, event.tolerance)
|
||||||
@@ -55,3 +62,16 @@ func TestVenusEventBaselineRegression(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVenusConjunctionExtremeInputIsBounded(t *testing.T) {
|
||||||
|
started := time.Now()
|
||||||
|
for _, next := range []uint8{0, 1} {
|
||||||
|
got := venusConjunction(1e8, next)
|
||||||
|
if math.IsInf(got, 0) {
|
||||||
|
t.Fatalf("venusConjunction(1e8, %d) returned infinite result %v", next, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(started); elapsed > 2*time.Second {
|
||||||
|
t.Fatalf("venusConjunction extreme input took %s, want <= 2s", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ func venusElongationDerivativeN(jde, val float64, n int) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func venusConjunction(jde float64, next uint8) float64 {
|
func venusConjunction(jde float64, next uint8) float64 {
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
queryTT := jde
|
queryTT := jde
|
||||||
direction := -1.0
|
direction := -1.0
|
||||||
if next == 1 {
|
if next == 1 {
|
||||||
@@ -203,13 +206,14 @@ func venusConjunction(jde float64, next uint8) float64 {
|
|||||||
left := queryTT
|
left := queryTT
|
||||||
leftVal := venusSunLongitudeDeltaN(left, venusEventSearchN)
|
leftVal := venusSunLongitudeDeltaN(left, venusEventSearchN)
|
||||||
if math.Abs(venusSunLongitudeDelta(queryTT)) <= 30.0/86400.0 {
|
if math.Abs(venusSunLongitudeDelta(queryTT)) <= 30.0/86400.0 {
|
||||||
exact := eventZeroRefine(left, 1.0, 0.000005, venusSunLongitudeDelta)
|
if exact, ok := venusConjunctionRefine(left, 1.0); ok {
|
||||||
eventUT := TD2UT(exact, false)
|
eventUT := TD2UT(exact, false)
|
||||||
if next == 0 && eventUTQueryBeforeOrEqual(eventUT, queryTT) {
|
if next == 0 && eventUTQueryBeforeOrEqual(eventUT, queryTT) {
|
||||||
return eventUT
|
return eventUT
|
||||||
}
|
}
|
||||||
if next == 1 && eventUTQueryAfterOrEqual(eventUT, queryTT) {
|
if next == 1 && eventUTQueryAfterOrEqual(eventUT, queryTT) {
|
||||||
return eventUT
|
return eventUT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const step = 8.0
|
const step = 8.0
|
||||||
@@ -219,12 +223,36 @@ func venusConjunction(jde float64, next uint8) float64 {
|
|||||||
if leftVal == 0 || rightVal == 0 || leftVal*rightVal <= 0 {
|
if leftVal == 0 || rightVal == 0 || leftVal*rightVal <= 0 {
|
||||||
center := (left + right) / 2.0
|
center := (left + right) / 2.0
|
||||||
halfWindow := math.Abs(right-left) / 2.0
|
halfWindow := math.Abs(right-left) / 2.0
|
||||||
return TD2UT(eventZeroRefine(center, halfWindow, 0.000005, venusSunLongitudeDelta), false)
|
if exact, ok := venusConjunctionRefine(center, halfWindow); ok {
|
||||||
|
return TD2UT(exact, false)
|
||||||
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
left = right
|
left = right
|
||||||
leftVal = rightVal
|
leftVal = rightVal
|
||||||
}
|
}
|
||||||
return TD2UT(eventZeroRefine(queryTT, VENUS_S_PERIOD, 0.000005, venusSunLongitudeDelta), false)
|
// 640 天已经覆盖一个金星会合周期;仍无括号通常表示输入超出解析项的可靠范围。
|
||||||
|
// 继续按 5 微日扫描整个周期会产生数亿次星历计算,因此在这里有界失败。
|
||||||
|
// The 640-day directional scan already exceeds one Venus synodic period. If it
|
||||||
|
// still finds no bracket, fail in a bounded way instead of scanning hundreds
|
||||||
|
// of millions of five-microday samples across the full fallback window.
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
|
||||||
|
func venusConjunctionRefine(seed, halfWindow float64) (float64, bool) {
|
||||||
|
leftJD := seed - halfWindow
|
||||||
|
centerJD := seed
|
||||||
|
rightJD := seed + halfWindow
|
||||||
|
leftVal := venusSunLongitudeDelta(leftJD)
|
||||||
|
centerVal := venusSunLongitudeDelta(centerJD)
|
||||||
|
rightVal := venusSunLongitudeDelta(rightJD)
|
||||||
|
if !isFiniteFloat(leftVal) || !isFiniteFloat(centerVal) || !isFiniteFloat(rightVal) {
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
if _, _, _, _, ok := eventZeroBracket(leftJD, leftVal, centerJD, centerVal, rightJD, rightVal); !ok {
|
||||||
|
return math.NaN(), false
|
||||||
|
}
|
||||||
|
return eventZeroRefine(seed, halfWindow, 0.000005, venusSunLongitudeDelta), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func venusConjunctionTypeAt(eventUT float64) bool {
|
func venusConjunctionTypeAt(eventUT float64) bool {
|
||||||
@@ -289,6 +317,9 @@ func LastVenusSuperiorConjunction(jde float64) float64 {
|
|||||||
|
|
||||||
func venusRetrograde(jde float64) float64 {
|
func venusRetrograde(jde float64) float64 {
|
||||||
//0=last 1=next
|
//0=last 1=next
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
lastHe := LastVenusConjunctionStrict(jde)
|
lastHe := LastVenusConjunctionStrict(jde)
|
||||||
nextHe := NextVenusConjunctionStrict(jde)
|
nextHe := NextVenusConjunctionStrict(jde)
|
||||||
nowSub := venusSunRADelta(jde)
|
nowSub := venusSunRADelta(jde)
|
||||||
@@ -297,23 +328,31 @@ func venusRetrograde(jde float64) float64 {
|
|||||||
} else {
|
} else {
|
||||||
jde = lastHe + 10
|
jde = lastHe + 10
|
||||||
}
|
}
|
||||||
for {
|
found := false
|
||||||
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
nowSub := venusRADerivativeN(jde, 1.0/86400.0, venusEventSearchN)
|
nowSub := venusRADerivativeN(jde, 1.0/86400.0, venusEventSearchN)
|
||||||
|
if !isFiniteFloat(nowSub) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if math.Abs(nowSub) > 0.5 {
|
if math.Abs(nowSub) > 0.5 {
|
||||||
jde += 5
|
jde += 5
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
JD1 := jde
|
JD1 := jde
|
||||||
for {
|
var ok bool
|
||||||
JD0 := JD1
|
JD1, ok = eventNewtonRefine(JD1, 20.0/86400.0, func(JD0 float64) float64 {
|
||||||
stDegree := venusRADerivative(JD0, 0.5/86400.0)
|
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)
|
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)
|
||||||
JD1 = JD0 - stDegree/stDegreep
|
return stDegree / stDegreep
|
||||||
if math.Abs(JD1-JD0) <= 20.0/86400.0 {
|
})
|
||||||
break
|
if !ok {
|
||||||
}
|
return math.NaN()
|
||||||
}
|
}
|
||||||
min := eventZeroRefine(JD1, 10.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
min := eventZeroRefine(JD1, 10.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
||||||
return venusRADerivative(jd, 0.5/86400.0)
|
return venusRADerivative(jd, 0.5/86400.0)
|
||||||
@@ -376,46 +415,62 @@ func venusRetrogradeToProgradeAroundInferior(inferior float64) float64 {
|
|||||||
|
|
||||||
func NextVenusProgradeToRetrograde(jde float64) float64 {
|
func NextVenusProgradeToRetrograde(jde float64) float64 {
|
||||||
inferior := NextVenusInferiorConjunction(jde)
|
inferior := NextVenusInferiorConjunction(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := venusProgradeToRetrogradeAroundInferior(inferior)
|
date := venusProgradeToRetrogradeAroundInferior(inferior)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryAfterOrEqual(date, jde) {
|
if eventUTQueryAfterOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
inferior = NextVenusInferiorConjunction(eventUTNextQueryTT(inferior))
|
inferior = NextVenusInferiorConjunction(eventUTNextQueryTT(inferior))
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NextVenusRetrogradeToPrograde(jde float64) float64 {
|
func NextVenusRetrogradeToPrograde(jde float64) float64 {
|
||||||
inferior := LastVenusInferiorConjunction(jde)
|
inferior := LastVenusInferiorConjunction(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := venusRetrogradeToProgradeAroundInferior(inferior)
|
date := venusRetrogradeToProgradeAroundInferior(inferior)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryAfterOrEqual(date, jde) {
|
if eventUTQueryAfterOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
inferior = NextVenusInferiorConjunction(eventUTNextQueryTT(inferior))
|
inferior = NextVenusInferiorConjunction(eventUTNextQueryTT(inferior))
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func LastVenusProgradeToRetrograde(jde float64) float64 {
|
func LastVenusProgradeToRetrograde(jde float64) float64 {
|
||||||
inferior := NextVenusInferiorConjunction(jde)
|
inferior := NextVenusInferiorConjunction(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := venusProgradeToRetrogradeAroundInferior(inferior)
|
date := venusProgradeToRetrogradeAroundInferior(inferior)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryBeforeOrEqual(date, jde) {
|
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
inferior = LastVenusInferiorConjunction(eventUTLastQueryTT(inferior))
|
inferior = LastVenusInferiorConjunction(eventUTLastQueryTT(inferior))
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func LastVenusRetrogradeToPrograde(jde float64) float64 {
|
func LastVenusRetrogradeToPrograde(jde float64) float64 {
|
||||||
inferior := LastVenusInferiorConjunction(jde)
|
inferior := LastVenusInferiorConjunction(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := venusRetrogradeToProgradeAroundInferior(inferior)
|
date := venusRetrogradeToProgradeAroundInferior(inferior)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryBeforeOrEqual(date, jde) {
|
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
inferior = LastVenusInferiorConjunction(eventUTLastQueryTT(inferior))
|
inferior = LastVenusInferiorConjunction(eventUTLastQueryTT(inferior))
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func VenusSunElongation(jde float64) float64 {
|
func VenusSunElongation(jde float64) float64 {
|
||||||
@@ -465,52 +520,77 @@ func venusWestElongationWindowContaining(jde float64) (float64, float64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func nextVenusGreatestElongationTyped(jde float64, east bool) float64 {
|
func nextVenusGreatestElongationTyped(jde float64, east bool) float64 {
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if east {
|
if east {
|
||||||
start, windowEnd := venusEastElongationWindowContaining(jde)
|
start, windowEnd := venusEastElongationWindowContaining(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := venusGreatestElongationInWindow(start, windowEnd)
|
date := venusGreatestElongationInWindow(start, windowEnd)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryAfterOrEqual(date, jde) {
|
if eventUTQueryAfterOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
nextInferior := NextVenusInferiorConjunction(eventUTNextQueryTT(windowEnd))
|
nextInferior := NextVenusInferiorConjunction(eventUTNextQueryTT(windowEnd))
|
||||||
start, windowEnd = venusEastElongationWindowEndingAt(nextInferior)
|
start, windowEnd = venusEastElongationWindowEndingAt(nextInferior)
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
start, windowEnd := venusWestElongationWindowContaining(jde)
|
start, windowEnd := venusWestElongationWindowContaining(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := venusGreatestElongationInWindow(start, windowEnd)
|
date := venusGreatestElongationInWindow(start, windowEnd)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryAfterOrEqual(date, jde) {
|
if eventUTQueryAfterOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
nextSuperior := NextVenusSuperiorConjunction(eventUTNextQueryTT(windowEnd))
|
nextSuperior := NextVenusSuperiorConjunction(eventUTNextQueryTT(windowEnd))
|
||||||
start, windowEnd = venusWestElongationWindowEndingAt(nextSuperior)
|
start, windowEnd = venusWestElongationWindowEndingAt(nextSuperior)
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastVenusGreatestElongationTyped(jde float64, east bool) float64 {
|
func lastVenusGreatestElongationTyped(jde float64, east bool) float64 {
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if east {
|
if east {
|
||||||
start, windowEnd := venusEastElongationWindowContaining(jde)
|
start, windowEnd := venusEastElongationWindowContaining(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := venusGreatestElongationInWindow(start, windowEnd)
|
date := venusGreatestElongationInWindow(start, windowEnd)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryBeforeOrEqual(date, jde) {
|
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
prevInferior := LastVenusInferiorConjunction(eventUTLastQueryTT(start))
|
prevInferior := LastVenusInferiorConjunction(eventUTLastQueryTT(start))
|
||||||
start, windowEnd = venusEastElongationWindowEndingAt(prevInferior)
|
start, windowEnd = venusEastElongationWindowEndingAt(prevInferior)
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
start, windowEnd := venusWestElongationWindowContaining(jde)
|
start, windowEnd := venusWestElongationWindowContaining(jde)
|
||||||
for {
|
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||||
date := venusGreatestElongationInWindow(start, windowEnd)
|
date := venusGreatestElongationInWindow(start, windowEnd)
|
||||||
|
if !isFiniteFloat(date) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
if eventUTQueryBeforeOrEqual(date, jde) {
|
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
prevSuperior := LastVenusSuperiorConjunction(eventUTLastQueryTT(start))
|
prevSuperior := LastVenusSuperiorConjunction(eventUTLastQueryTT(start))
|
||||||
start, windowEnd = venusWestElongationWindowEndingAt(prevSuperior)
|
start, windowEnd = venusWestElongationWindowEndingAt(prevSuperior)
|
||||||
}
|
}
|
||||||
|
return math.NaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func venusGreatestElongation(jde float64) float64 {
|
func venusGreatestElongation(jde float64) float64 {
|
||||||
|
if !isFiniteFloat(jde) {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
east := venusSunRADelta(jde) > 0
|
east := venusSunRADelta(jde) > 0
|
||||||
if east {
|
if east {
|
||||||
return nextVenusGreatestElongationTyped(jde, true)
|
return nextVenusGreatestElongationTyped(jde, true)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 673 KiB |
|
After Width: | Height: | Size: 673 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 716 KiB |
@@ -63,6 +63,9 @@ type SolarEclipsePartialFootprintOptions struct {
|
|||||||
// BoundaryPoints 是每个瞬时半影边界的角向采样点数;<=0 时使用 180。
|
// BoundaryPoints 是每个瞬时半影边界的角向采样点数;<=0 时使用 180。
|
||||||
// BoundaryPoints is the angular sample count for each instantaneous penumbral boundary; values <= 0 use 180.
|
// BoundaryPoints is the angular sample count for each instantaneous penumbral boundary; values <= 0 use 180.
|
||||||
BoundaryPoints int
|
BoundaryPoints int
|
||||||
|
// CentralShadowStep 是本影/反本影瞬时足迹的采样步长;<=0 时不计算。
|
||||||
|
// CentralShadowStep is the umbral/antumbral footprint step; values <= 0 disable it.
|
||||||
|
CentralShadowStep time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
|
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
|
||||||
@@ -89,11 +92,29 @@ type SolarEclipsePartialFootprintsInfo struct {
|
|||||||
Eclipse SolarEclipseInfo
|
Eclipse SolarEclipseInfo
|
||||||
// Footprints 是按时间采样的瞬时半影足迹, sampled instantaneous penumbral footprints.
|
// Footprints 是按时间采样的瞬时半影足迹, sampled instantaneous penumbral footprints.
|
||||||
Footprints []SolarEclipsePartialFootprint
|
Footprints []SolarEclipsePartialFootprint
|
||||||
|
// CentralShadowFootprints 是按时间采样的本影/反本影足迹。
|
||||||
|
// CentralShadowFootprints are sampled umbral/antumbral footprints.
|
||||||
|
CentralShadowFootprints []SolarEclipsePartialFootprint
|
||||||
|
// P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。
|
||||||
|
// P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero.
|
||||||
|
P1 SolarEclipsePathPoint
|
||||||
|
P2 SolarEclipsePathPoint
|
||||||
|
P3 SolarEclipsePathPoint
|
||||||
|
P4 SolarEclipsePathPoint
|
||||||
|
// U1-U4 是本影/反本影与地球的外切/内切接触点;不存在时保持零值。
|
||||||
|
// U1-U4 are external/internal umbral/antumbral contacts; absent contacts remain zero.
|
||||||
|
U1 SolarEclipsePathPoint
|
||||||
|
U2 SolarEclipsePathPoint
|
||||||
|
U3 SolarEclipsePathPoint
|
||||||
|
U4 SolarEclipsePathPoint
|
||||||
// Step 是实际采用的基础时间采样步长, effective base time step.
|
// Step 是实际采用的基础时间采样步长, effective base time step.
|
||||||
Step time.Duration
|
Step time.Duration
|
||||||
// BoundaryPoints 是实际采用的边界角向采样点数。
|
// BoundaryPoints 是实际采用的边界角向采样点数。
|
||||||
// BoundaryPoints is the effective angular sample count for each boundary.
|
// BoundaryPoints is the effective angular sample count for each boundary.
|
||||||
BoundaryPoints int
|
BoundaryPoints int
|
||||||
|
// CentralShadowStep 是本影/反本影足迹的实际采样步长;0 表示未计算。
|
||||||
|
// CentralShadowStep is the effective umbral/antumbral footprint step; zero means disabled.
|
||||||
|
CentralShadowStep time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialAreaInfo 是 SolarEclipsePartialFootprintsInfo 的兼容别名。
|
// SolarEclipsePartialAreaInfo 是 SolarEclipsePartialFootprintsInfo 的兼容别名。
|
||||||
@@ -103,55 +124,55 @@ type SolarEclipsePartialAreaInfo = SolarEclipsePartialFootprintsInfo
|
|||||||
type solarEclipsePathCalculator func(float64, basic.SolarEclipsePathOptions) basic.SolarEclipsePathResult
|
type solarEclipsePathCalculator func(float64, basic.SolarEclipsePathOptions) basic.SolarEclipsePathResult
|
||||||
type solarEclipsePartialFootprintsCalculator func(float64, basic.SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintsResult
|
type solarEclipsePartialFootprintsCalculator func(float64, basic.SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintsResult
|
||||||
|
|
||||||
// SolarEclipseCentralPath 日食中心路径查询 / central solar eclipse path query.
|
// SolarEclipseCentralPath 计算指定日期附近的日食中心路径,默认使用 NASA bulletin Split-K 模型。
|
||||||
// SolarEclipseCentralPath computes the central path near the given date, using NASA bulletin Split-K by default.
|
// SolarEclipseCentralPath computes the central path near the given date, using NASA bulletin Split-K by default.
|
||||||
func SolarEclipseCentralPath(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
|
func SolarEclipseCentralPath(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
|
||||||
return SolarEclipseCentralPathNASABulletinSplitK(date, options)
|
return SolarEclipseCentralPathNASABulletinSplitK(date, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipseCentralPathNASABulletinSplitK 日食中心路径查询(NASA bulletin Split-K) / central solar eclipse path query with NASA bulletin Split-K.
|
// SolarEclipseCentralPathNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食中心路径。
|
||||||
// SolarEclipseCentralPathNASABulletinSplitK computes the central path with the NASA bulletin Split-K model.
|
// SolarEclipseCentralPathNASABulletinSplitK computes the central path with the NASA bulletin Split-K model.
|
||||||
func SolarEclipseCentralPathNASABulletinSplitK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
|
func SolarEclipseCentralPathNASABulletinSplitK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
|
||||||
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathNASABulletinSplitK)
|
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathNASABulletinSplitK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipseCentralPathIAUSingleK 日食中心路径查询(IAU Single-K) / central solar eclipse path query with IAU Single-K.
|
// SolarEclipseCentralPathIAUSingleK 使用 IAU Single-K 模型计算日食中心路径。
|
||||||
// SolarEclipseCentralPathIAUSingleK computes the central path with the IAU Single-K model.
|
// SolarEclipseCentralPathIAUSingleK computes the central path with the IAU Single-K model.
|
||||||
func SolarEclipseCentralPathIAUSingleK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
|
func SolarEclipseCentralPathIAUSingleK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
|
||||||
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathIAUSingleK)
|
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathIAUSingleK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialFootprints 日食偏食足迹查询 / solar eclipse penumbral footprints query.
|
// SolarEclipsePartialFootprints 计算指定日期附近的日食半影足迹,默认使用 NASA bulletin Split-K 模型。
|
||||||
// SolarEclipsePartialFootprints computes penumbral footprint samples near the given date, using NASA bulletin Split-K by default.
|
// SolarEclipsePartialFootprints computes penumbral footprint samples near the given date, using NASA bulletin Split-K by default.
|
||||||
func SolarEclipsePartialFootprints(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
|
func SolarEclipsePartialFootprints(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
|
||||||
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
|
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialFootprintsNASABulletinSplitK 日食偏食足迹查询(NASA bulletin Split-K) / solar eclipse penumbral footprints query with NASA bulletin Split-K.
|
// SolarEclipsePartialFootprintsNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食半影足迹。
|
||||||
// SolarEclipsePartialFootprintsNASABulletinSplitK computes penumbral footprint samples with the NASA bulletin Split-K model.
|
// SolarEclipsePartialFootprintsNASABulletinSplitK computes penumbral footprint samples with the NASA bulletin Split-K model.
|
||||||
func SolarEclipsePartialFootprintsNASABulletinSplitK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
|
func SolarEclipsePartialFootprintsNASABulletinSplitK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
|
||||||
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsNASABulletinSplitK)
|
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsNASABulletinSplitK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialFootprintsIAUSingleK 日食偏食足迹查询(IAU Single-K) / solar eclipse penumbral footprints query with IAU Single-K.
|
// SolarEclipsePartialFootprintsIAUSingleK 使用 IAU Single-K 模型计算日食半影足迹。
|
||||||
// SolarEclipsePartialFootprintsIAUSingleK computes penumbral footprint samples with the IAU Single-K model.
|
// SolarEclipsePartialFootprintsIAUSingleK computes penumbral footprint samples with the IAU Single-K model.
|
||||||
func SolarEclipsePartialFootprintsIAUSingleK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
|
func SolarEclipsePartialFootprintsIAUSingleK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
|
||||||
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsIAUSingleK)
|
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsIAUSingleK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialArea 偏食足迹兼容包装 / compatibility wrapper for penumbral footprints.
|
// SolarEclipsePartialArea 计算半影足迹,是 SolarEclipsePartialFootprints 的兼容包装。
|
||||||
// SolarEclipsePartialArea computes penumbral footprint samples and is a compatibility wrapper for SolarEclipsePartialFootprints.
|
// SolarEclipsePartialArea computes penumbral footprint samples and is a compatibility wrapper for SolarEclipsePartialFootprints.
|
||||||
func SolarEclipsePartialArea(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
|
func SolarEclipsePartialArea(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
|
||||||
return SolarEclipsePartialFootprints(date, options)
|
return SolarEclipsePartialFootprints(date, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialAreaNASABulletinSplitK 偏食足迹兼容包装(NASA bulletin Split-K) / compatibility wrapper for penumbral footprints with NASA bulletin Split-K.
|
// SolarEclipsePartialAreaNASABulletinSplitK 是 SolarEclipsePartialFootprintsNASABulletinSplitK 的兼容包装。
|
||||||
// SolarEclipsePartialAreaNASABulletinSplitK is a compatibility wrapper for SolarEclipsePartialFootprintsNASABulletinSplitK.
|
// SolarEclipsePartialAreaNASABulletinSplitK is a compatibility wrapper for SolarEclipsePartialFootprintsNASABulletinSplitK.
|
||||||
func SolarEclipsePartialAreaNASABulletinSplitK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
|
func SolarEclipsePartialAreaNASABulletinSplitK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
|
||||||
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
|
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SolarEclipsePartialAreaIAUSingleK 偏食足迹兼容包装(IAU Single-K) / compatibility wrapper for penumbral footprints with IAU Single-K.
|
// SolarEclipsePartialAreaIAUSingleK 是 SolarEclipsePartialFootprintsIAUSingleK 的兼容包装。
|
||||||
// SolarEclipsePartialAreaIAUSingleK is a compatibility wrapper for SolarEclipsePartialFootprintsIAUSingleK.
|
// SolarEclipsePartialAreaIAUSingleK is a compatibility wrapper for SolarEclipsePartialFootprintsIAUSingleK.
|
||||||
func SolarEclipsePartialAreaIAUSingleK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
|
func SolarEclipsePartialAreaIAUSingleK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
|
||||||
return SolarEclipsePartialFootprintsIAUSingleK(date, options)
|
return SolarEclipsePartialFootprintsIAUSingleK(date, options)
|
||||||
@@ -192,10 +213,20 @@ func solarEclipsePartialFootprints(
|
|||||||
}
|
}
|
||||||
|
|
||||||
footprints := SolarEclipsePartialFootprintsInfo{
|
footprints := SolarEclipsePartialFootprintsInfo{
|
||||||
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
|
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
|
||||||
Footprints: solarEclipsePartialFootprintsFromBasic(result.Footprints, location),
|
Footprints: solarEclipsePartialFootprintsFromBasic(result.Footprints, location),
|
||||||
Step: solarEclipsePathStepDuration(result.StepDays),
|
CentralShadowFootprints: solarEclipsePartialFootprintsFromBasic(result.CentralShadowFootprints, location),
|
||||||
BoundaryPoints: result.BoundaryPoints,
|
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),
|
||||||
}
|
}
|
||||||
return footprints, true
|
return footprints, true
|
||||||
}
|
}
|
||||||
@@ -217,6 +248,9 @@ func basicSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootpri
|
|||||||
if options.Step > 0 {
|
if options.Step > 0 {
|
||||||
basicOptions.StepDays = options.Step.Hours() / 24
|
basicOptions.StepDays = options.Step.Hours() / 24
|
||||||
}
|
}
|
||||||
|
if options.CentralShadowStep > 0 {
|
||||||
|
basicOptions.CentralShadowStepDays = options.CentralShadowStep.Hours() / 24
|
||||||
|
}
|
||||||
return basicOptions
|
return basicOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +279,13 @@ func solarEclipsePathPointFromBasic(point basic.SolarEclipsePathPoint, location
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func solarEclipseOptionalPathPointFromBasic(point basic.SolarEclipsePathPoint, location *time.Location) SolarEclipsePathPoint {
|
||||||
|
if point.JDE == 0 {
|
||||||
|
return SolarEclipsePathPoint{}
|
||||||
|
}
|
||||||
|
return solarEclipsePathPointFromBasic(point, location)
|
||||||
|
}
|
||||||
|
|
||||||
func solarEclipsePartialFootprintsFromBasic(
|
func solarEclipsePartialFootprintsFromBasic(
|
||||||
footprints []basic.SolarEclipsePartialFootprint,
|
footprints []basic.SolarEclipsePartialFootprint,
|
||||||
location *time.Location,
|
location *time.Location,
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ func TestSolarEclipsePartialFootprintsKeepLocation(t *testing.T) {
|
|||||||
footprints, ok := SolarEclipsePartialFootprints(
|
footprints, ok := SolarEclipsePartialFootprints(
|
||||||
time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
|
time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
|
||||||
SolarEclipsePartialFootprintOptions{
|
SolarEclipsePartialFootprintOptions{
|
||||||
Step: 30 * time.Minute,
|
Step: 30 * time.Minute,
|
||||||
BoundaryPoints: 72,
|
BoundaryPoints: 72,
|
||||||
|
CentralShadowStep: 10 * time.Minute,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -80,6 +81,9 @@ func TestSolarEclipsePartialFootprintsKeepLocation(t *testing.T) {
|
|||||||
if footprints.BoundaryPoints != 72 {
|
if footprints.BoundaryPoints != 72 {
|
||||||
t.Fatalf("boundary points mismatch: got %d want 72", footprints.BoundaryPoints)
|
t.Fatalf("boundary points mismatch: got %d want 72", footprints.BoundaryPoints)
|
||||||
}
|
}
|
||||||
|
if footprints.CentralShadowStep != 10*time.Minute || len(footprints.CentralShadowFootprints) == 0 {
|
||||||
|
t.Fatalf("central shadow sampling mismatch: step=%s footprints=%d", footprints.CentralShadowStep, len(footprints.CentralShadowFootprints))
|
||||||
|
}
|
||||||
|
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -89,6 +93,15 @@ func TestSolarEclipsePartialFootprintsKeepLocation(t *testing.T) {
|
|||||||
{name: "Footprints[0].Time", tm: footprints.Footprints[0].Time},
|
{name: "Footprints[0].Time", tm: footprints.Footprints[0].Time},
|
||||||
{name: "Footprints[last].Time", tm: footprints.Footprints[len(footprints.Footprints)-1].Time},
|
{name: "Footprints[last].Time", tm: footprints.Footprints[len(footprints.Footprints)-1].Time},
|
||||||
{name: "Boundary point", tm: footprints.Footprints[0].Boundaries[0][0].Time},
|
{name: "Boundary point", tm: footprints.Footprints[0].Boundaries[0][0].Time},
|
||||||
|
{name: "P1 contact", tm: footprints.P1.Time},
|
||||||
|
{name: "P2 contact", tm: footprints.P2.Time},
|
||||||
|
{name: "P3 contact", tm: footprints.P3.Time},
|
||||||
|
{name: "P4 contact", tm: footprints.P4.Time},
|
||||||
|
{name: "U1 contact", tm: footprints.U1.Time},
|
||||||
|
{name: "U2 contact", tm: footprints.U2.Time},
|
||||||
|
{name: "U3 contact", tm: footprints.U3.Time},
|
||||||
|
{name: "U4 contact", tm: footprints.U4.Time},
|
||||||
|
{name: "Central shadow", tm: footprints.CentralShadowFootprints[0].Time},
|
||||||
} {
|
} {
|
||||||
if item.tm.Location() != loc {
|
if item.tm.Location() != loc {
|
||||||
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
|
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
|
||||||
@@ -107,6 +120,9 @@ func TestSolarEclipsePartialFootprintsWorkForPartialOnly(t *testing.T) {
|
|||||||
if footprints.Eclipse.Type != SolarEclipsePartial {
|
if footprints.Eclipse.Type != SolarEclipsePartial {
|
||||||
t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipsePartial)
|
t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipsePartial)
|
||||||
}
|
}
|
||||||
|
if !footprints.U1.Time.IsZero() || !footprints.U4.Time.IsZero() || len(footprints.CentralShadowFootprints) != 0 {
|
||||||
|
t.Fatal("partial-only eclipse must not return central-shadow contacts or footprints")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSolarEclipsePartialFootprintsReturnFalseForNoEvent(t *testing.T) {
|
func TestSolarEclipsePartialFootprintsReturnFalseForNoEvent(t *testing.T) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"b612.me/astro/basic"
|
"b612.me/astro/basic"
|
||||||
eclipsecore "b612.me/astro/eclipse"
|
eclipsecore "b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/internal/svgasset"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -23,7 +24,7 @@ const (
|
|||||||
// LunarEclipseSVGOptions 控制月食穿影 SVG 输出。
|
// LunarEclipseSVGOptions 控制月食穿影 SVG 输出。
|
||||||
// LunarEclipseSVGOptions controls lunar eclipse shadow-path SVG output.
|
// LunarEclipseSVGOptions controls lunar eclipse shadow-path SVG output.
|
||||||
type LunarEclipseSVGOptions struct {
|
type LunarEclipseSVGOptions struct {
|
||||||
// Width / Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
|
// Width 和 Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
|
||||||
// Width/Height are SVG canvas size; values <= 0 use defaults.
|
// Width/Height are SVG canvas size; values <= 0 use defaults.
|
||||||
Width int
|
Width int
|
||||||
Height int
|
Height int
|
||||||
@@ -168,7 +169,7 @@ func renderLunarEclipseSVG(
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, options.Width, options.Height, options.Width, options.Height)
|
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, options.Width, options.Height, options.Width, options.Height)
|
||||||
b.WriteString(`<defs>`)
|
b.WriteString(`<defs>`)
|
||||||
b.WriteString(lunarEclipseSVGMoonSymbol)
|
b.WriteString(svgasset.MoonFaceSymbol())
|
||||||
b.WriteString(`</defs>`)
|
b.WriteString(`</defs>`)
|
||||||
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
|
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
|
||||||
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
|
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package svg
|
|
||||||
|
|
||||||
import _ "embed"
|
|
||||||
|
|
||||||
// lunarEclipseSVGMoonSymbol is a compact public-domain Moon face derived from
|
|
||||||
// labs/Full_Moon_clip_art.svg and simplified for small eclipse contact disks.
|
|
||||||
//
|
|
||||||
//go:embed lunar_eclipse_moon.svg
|
|
||||||
var lunarEclipseSVGMoonSymbol string
|
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/basic"
|
||||||
|
eclipsecore "b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/internal/svgmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lunarEclipseMapDefaultWidth = 960
|
||||||
|
lunarEclipseMapDefaultHeight = 640
|
||||||
|
)
|
||||||
|
|
||||||
|
// LunarEclipseMapSVGOptions 控制无国界全球可见性地图。
|
||||||
|
// LunarEclipseMapSVGOptions controls a border-free global visibility map.
|
||||||
|
type LunarEclipseMapSVGOptions 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 int
|
||||||
|
Height int
|
||||||
|
// Language 为 "en"(不区分大小写)时使用英文,否则使用中文。
|
||||||
|
// Language uses English for "en" (case-insensitive) and Chinese otherwise.
|
||||||
|
Language string
|
||||||
|
// Location 控制显示的事件时刻;nil 使用 date.Location()。
|
||||||
|
// Location controls displayed event times. Nil uses date.Location().
|
||||||
|
Location *time.Location
|
||||||
|
// Projection 选择地图投影;零值使用等经纬投影,不支持的值使渲染器返回 false。
|
||||||
|
// Projection selects the map projection. The zero value selects the equirectangular projection; unsupported values make the renderer return false.
|
||||||
|
Projection EclipseMapProjection
|
||||||
|
// 空文本字段使用本地化的自动标签。
|
||||||
|
// Empty text fields use localized automatic labels.
|
||||||
|
Title string
|
||||||
|
FooterNote string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LunarEclipseMapSVG 使用默认月食模型绘制全球 P1-P4 可见区域。
|
||||||
|
// LunarEclipseMapSVG renders the global P1-P4 visibility regions using the default lunar-eclipse model.
|
||||||
|
func LunarEclipseMapSVG(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
|
||||||
|
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LunarEclipseMapSVGDanjon 使用 Danjon 模型绘制全球可见区域。
|
||||||
|
// LunarEclipseMapSVGDanjon renders the global visibility regions with Danjon's model.
|
||||||
|
func LunarEclipseMapSVGDanjon(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
|
||||||
|
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDateDanjon)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LunarEclipseMapSVGChauvenet 使用 Chauvenet 模型绘制全球可见区域。
|
||||||
|
// LunarEclipseMapSVGChauvenet renders the global visibility regions with Chauvenet's model.
|
||||||
|
func LunarEclipseMapSVGChauvenet(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
|
||||||
|
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDateChauvenet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lunarEclipseMapSVG(
|
||||||
|
date time.Time,
|
||||||
|
options LunarEclipseMapSVGOptions,
|
||||||
|
calculator func(time.Time) (eclipsecore.LunarEclipseInfo, bool),
|
||||||
|
) (string, bool) {
|
||||||
|
if !validEclipseMapProjection(options.Projection) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
info, ok := calculator(date)
|
||||||
|
if !ok || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
options = normalizeLunarEclipseMapSVGOptions(date, options)
|
||||||
|
projection := internalEclipseMapProjection(options.Projection)
|
||||||
|
if projection == "" {
|
||||||
|
projection = svgmap.ProjectionEquirectangular
|
||||||
|
}
|
||||||
|
return renderLunarEclipseMapSVG(info, options, projection), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapSVGOptions) LunarEclipseMapSVGOptions {
|
||||||
|
if options.Width < 640 {
|
||||||
|
options.Width = lunarEclipseMapDefaultWidth
|
||||||
|
}
|
||||||
|
if options.Height < 420 {
|
||||||
|
options.Height = lunarEclipseMapDefaultHeight
|
||||||
|
}
|
||||||
|
if strings.EqualFold(options.Language, "en") {
|
||||||
|
options.Language = "en"
|
||||||
|
} else {
|
||||||
|
options.Language = "zh"
|
||||||
|
}
|
||||||
|
if options.Location == nil {
|
||||||
|
options.Location = date.Location()
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderLunarEclipseMapSVG(
|
||||||
|
info eclipsecore.LunarEclipseInfo,
|
||||||
|
options LunarEclipseMapSVGOptions,
|
||||||
|
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")
|
||||||
|
if options.Language == "en" {
|
||||||
|
title = fmt.Sprintf("%s %s Global Visibility", date, lunarEclipseSVGTypeName(info.Type, "en"))
|
||||||
|
} else {
|
||||||
|
title = fmt.Sprintf("%s %s全球可见图", date, lunarEclipseSVGTypeName(info.Type, "zh"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
fmt.Fprintf(&builder, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
|
||||||
|
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
|
||||||
|
builder.WriteString(`<defs>`)
|
||||||
|
builder.WriteString(frame.ClipDefinition("lunar-map-clip"))
|
||||||
|
fmt.Fprintf(&builder, `<path id="lunar-visible-p1-shape" d="%s"/>`, startPath)
|
||||||
|
fmt.Fprintf(&builder, `<path id="lunar-visible-p4-shape" d="%s"/>`, endPath)
|
||||||
|
builder.WriteString(`<clipPath id="lunar-visible-p4"><use href="#lunar-visible-p4-shape"/></clipPath>`)
|
||||||
|
writeLunarEclipseVisibilityMasks(&builder, frame)
|
||||||
|
builder.WriteString(`</defs>`)
|
||||||
|
builder.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
|
||||||
|
fmt.Fprintf(&builder, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
|
||||||
|
options.Width-44, options.Height-36)
|
||||||
|
fmt.Fprintf(&builder, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
|
||||||
|
float64(options.Width)/2, html.EscapeString(title))
|
||||||
|
writeLunarEclipseMapSummary(&builder, info, options)
|
||||||
|
|
||||||
|
frame.WriteOcean(&builder)
|
||||||
|
frame.WriteGraticule(&builder, "lunar-map-clip")
|
||||||
|
frame.WriteLand(&builder, "lunar-map-clip")
|
||||||
|
fmt.Fprintf(&builder, `<g class="lunar-visibility-regions" clip-path="url(#lunar-map-clip)">`)
|
||||||
|
writeLunarEclipseUnavailableRegion(&builder, frame)
|
||||||
|
builder.WriteString(`<use class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)" href="#lunar-visible-p1-shape" fill="#e2aa4b" fill-opacity="0.34"/>`)
|
||||||
|
builder.WriteString(`<use class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)" href="#lunar-visible-p4-shape" fill="#4e9da0" fill-opacity="0.34"/>`)
|
||||||
|
builder.WriteString(`<g clip-path="url(#lunar-visible-p4)"><use class="entire-eclipse-region" href="#lunar-visible-p1-shape" fill="#5e846d" fill-opacity="0.34"/></g>`)
|
||||||
|
builder.WriteString(`</g>`)
|
||||||
|
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")
|
||||||
|
frame.WriteFrame(&builder)
|
||||||
|
writeLunarEclipseMapLegend(&builder, frame, options.Language)
|
||||||
|
writeLunarEclipseMapFooter(&builder, frame, options, projection)
|
||||||
|
builder.WriteString(`</svg>`)
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLunarEclipseVisibilityMasks(builder *strings.Builder, frame svgmap.Frame) {
|
||||||
|
writeLunarEclipseVisibilityMask(builder, "lunar-not-visible-mask", frame,
|
||||||
|
"lunar-visible-p1-shape", "lunar-visible-p4-shape")
|
||||||
|
writeLunarEclipseVisibilityMask(builder, "lunar-not-p4-mask", frame, "lunar-visible-p4-shape")
|
||||||
|
writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-mask", frame, "lunar-visible-p1-shape")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLunarEclipseVisibilityMask(
|
||||||
|
builder *strings.Builder,
|
||||||
|
id string,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
excludedShapeIDs ...string,
|
||||||
|
) {
|
||||||
|
fmt.Fprintf(builder, `<mask id="%s" maskUnits="userSpaceOnUse" x="%.3f" y="%.3f" width="%.3f" height="%.3f">`,
|
||||||
|
id, frame.X, frame.Y, frame.Width, frame.Height)
|
||||||
|
fmt.Fprintf(builder, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#ffffff"/>`,
|
||||||
|
frame.X, frame.Y, frame.Width, frame.Height)
|
||||||
|
for _, shapeID := range excludedShapeIDs {
|
||||||
|
fmt.Fprintf(builder, `<use href="#%s" fill="#000000"/>`, shapeID)
|
||||||
|
}
|
||||||
|
builder.WriteString(`</mask>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func eclipseMapFrame(width, height int, projection svgmap.Projection, top, bottom float64) svgmap.Frame {
|
||||||
|
availableWidth := float64(width) - 90
|
||||||
|
availableHeight := float64(height) - top - bottom
|
||||||
|
mapWidth := math.Min(availableWidth, availableHeight*2)
|
||||||
|
mapHeight := mapWidth / 2
|
||||||
|
if projection != svgmap.ProjectionEquirectangular {
|
||||||
|
mapWidth = math.Min(availableWidth, availableHeight)
|
||||||
|
mapHeight = mapWidth
|
||||||
|
}
|
||||||
|
return svgmap.Frame{
|
||||||
|
X: (float64(width) - mapWidth) / 2,
|
||||||
|
Y: top,
|
||||||
|
Width: mapWidth,
|
||||||
|
Height: mapHeight,
|
||||||
|
Projection: projection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 appendEclipseMapPolygonPath(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) {
|
||||||
|
if len(points) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for index, point := range points {
|
||||||
|
x, y, ok := frame.Project(point.Longitude, point.Latitude)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
command := "L"
|
||||||
|
if index == 0 {
|
||||||
|
command = "M"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `%s %.3f %.3f `, command, x, y)
|
||||||
|
}
|
||||||
|
builder.WriteString(`Z `)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeEclipseMapGeoLine(
|
||||||
|
builder *strings.Builder,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
points []svgmap.GeoPoint,
|
||||||
|
className, color string,
|
||||||
|
strokeWidth float64,
|
||||||
|
dash, clipID string,
|
||||||
|
) {
|
||||||
|
for _, segment := range svgmap.PolylineSegments(points, frame.Projection) {
|
||||||
|
if len(segment) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<path class="%s" d="`, className)
|
||||||
|
for index, point := range segment {
|
||||||
|
x, y, ok := frame.Project(point.Longitude, point.Latitude)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
command := "L"
|
||||||
|
if index == 0 {
|
||||||
|
command = "M"
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLunarEclipseUnavailableRegion(builder *strings.Builder, frame svgmap.Frame) {
|
||||||
|
if frame.IsPolar() {
|
||||||
|
fmt.Fprintf(builder, `<circle class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="#747b7d" fill-opacity="0.34"/>`,
|
||||||
|
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<rect class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#747b7d" fill-opacity="0.34"/>`,
|
||||||
|
frame.X, frame.Y, frame.Width, frame.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLunarEclipseMapSummary(builder *strings.Builder, info eclipsecore.LunarEclipseInfo, options LunarEclipseMapSVGOptions) {
|
||||||
|
start := info.PenumbralStart.In(options.Location)
|
||||||
|
maximum := info.Maximum.In(options.Location)
|
||||||
|
end := info.PenumbralEnd.In(options.Location)
|
||||||
|
zone, _ := maximum.Zone()
|
||||||
|
if zone == "" {
|
||||||
|
zone = "UTC"
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("P1 %s | 食甚 %s | P4 %s (%s) | 半影食分 %.3f | 本影食分 %.3f",
|
||||||
|
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone,
|
||||||
|
info.PenumbralMagnitude, info.UmbralMagnitude)
|
||||||
|
if options.Language == "en" {
|
||||||
|
text = fmt.Sprintf("P1 %s | Greatest %s | P4 %s (%s) | penumbral magnitude %.3f | umbral magnitude %.3f",
|
||||||
|
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone,
|
||||||
|
info.PenumbralMagnitude, info.UmbralMagnitude)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
|
||||||
|
float64(options.Width)/2, html.EscapeString(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, language string) {
|
||||||
|
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, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`, x, y-8, colors[index])
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="10">%s</text>`,
|
||||||
|
x+24, y, html.EscapeString(label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLunarEclipseMapFooter(
|
||||||
|
builder *strings.Builder,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
options LunarEclipseMapSVGOptions,
|
||||||
|
projection svgmap.Projection,
|
||||||
|
) {
|
||||||
|
text := options.FooterNote
|
||||||
|
if text == "" {
|
||||||
|
if options.Language == "en" {
|
||||||
|
text = eclipseMapProjectionLabel(projection, "en") + "; P1/P4 Moon-visible hemispheres; Natural Earth 1:50m physical land, no administrative boundaries."
|
||||||
|
} else {
|
||||||
|
text = eclipseMapProjectionLabel(projection, "zh") + ";按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
|
||||||
|
frame.X, float64(options.Height)-38, html.EscapeString(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDegree180(value float64) float64 {
|
||||||
|
value = math.Mod(value+180, 360)
|
||||||
|
if value < 0 {
|
||||||
|
value += 360
|
||||||
|
}
|
||||||
|
return value - 180
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/internal/svgmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLunarEclipseMapSVGVisibilityRegions(t *testing.T) {
|
||||||
|
diagram, ok := LunarEclipseMapSVG(
|
||||||
|
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
|
||||||
|
LunarEclipseMapSVGOptions{Width: 900, Height: 620, Location: time.UTC},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected lunar-eclipse visibility map")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"月全食全球可见图", "P1", "P4", "食甚", "全程可见", "带食月出", "带食月落", "不可见",
|
||||||
|
`class="entire-eclipse-region"`, "moonrise-region", "moonset-region",
|
||||||
|
`class="p1-horizon"`, `class="p4-horizon"`, `class="land"`, "不含行政边界",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(diagram, want) {
|
||||||
|
t.Fatalf("lunar-eclipse map missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateEclipseMapXML(diagram); err != nil {
|
||||||
|
t.Fatalf("lunar-eclipse map is not valid XML: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) {
|
||||||
|
cst := time.FixedZone("CST", 8*60*60)
|
||||||
|
diagram, ok := LunarEclipseMapSVG(
|
||||||
|
time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
|
||||||
|
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected lunar-eclipse visibility map")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`mask id="lunar-not-visible-mask"`,
|
||||||
|
`mask id="lunar-not-p4-mask"`,
|
||||||
|
`mask id="lunar-not-p1-mask"`,
|
||||||
|
`class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)"`,
|
||||||
|
`class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)"`,
|
||||||
|
`class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(diagram, want) {
|
||||||
|
t.Fatalf("lunar-eclipse map does not render exclusive visibility regions: missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLunarEclipseMapSVGSupportsForcedPolarProjection(t *testing.T) {
|
||||||
|
diagram, ok := LunarEclipseMapSVG(
|
||||||
|
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
|
||||||
|
LunarEclipseMapSVGOptions{Projection: EclipseMapProjectionSouthPolar},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected forced south-polar lunar-eclipse map")
|
||||||
|
}
|
||||||
|
for _, want := range []string{`<circle class="map-ocean"`, `<circle class="map-frame"`, "南极方位等距投影"} {
|
||||||
|
if !strings.Contains(diagram, want) {
|
||||||
|
t.Fatalf("south-polar lunar-eclipse map missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateEclipseMapXML(diagram); err != nil {
|
||||||
|
t.Fatalf("south-polar lunar-eclipse map is not valid XML: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
} {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if test.projection == svgmap.ProjectionSouthPolar && latitude >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLunarEclipseMapSVGRejectsNoEventAndInvalidProjection(t *testing.T) {
|
||||||
|
if _, ok := LunarEclipseMapSVG(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), LunarEclipseMapSVGOptions{}); ok {
|
||||||
|
t.Fatal("unexpected lunar-eclipse map for a no-event date")
|
||||||
|
}
|
||||||
|
if _, ok := LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC), LunarEclipseMapSVGOptions{Projection: "invalid"}); ok {
|
||||||
|
t.Fatal("invalid projection was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateEclipseMapXML(value string) error {
|
||||||
|
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||||
|
for {
|
||||||
|
if _, err := decoder.Token(); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
a, b := polygon[current], polygon[previous]
|
||||||
|
crosses := (a[1] > y) != (b[1] > y)
|
||||||
|
if crosses && x < (b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0] {
|
||||||
|
inside = !inside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
import "b612.me/astro/internal/svgmap"
|
||||||
|
|
||||||
|
// EclipseMapProjection 控制全球日月食地图投影;零值根据事件几何选择投影。
|
||||||
|
// EclipseMapProjection controls a global eclipse map projection. The zero value selects the projection from the event geometry.
|
||||||
|
type EclipseMapProjection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// EclipseMapProjectionAuto 根据事件几何自动选择投影。
|
||||||
|
// EclipseMapProjectionAuto selects a projection from event geometry.
|
||||||
|
EclipseMapProjectionAuto EclipseMapProjection = ""
|
||||||
|
// EclipseMapProjectionEquirectangular 使用等经纬投影。
|
||||||
|
// EclipseMapProjectionEquirectangular uses the equirectangular projection.
|
||||||
|
EclipseMapProjectionEquirectangular EclipseMapProjection = "equirectangular"
|
||||||
|
// EclipseMapProjectionNorthPolar 使用北极方位等距投影。
|
||||||
|
// EclipseMapProjectionNorthPolar uses the north-polar azimuthal equidistant projection.
|
||||||
|
EclipseMapProjectionNorthPolar EclipseMapProjection = "north-polar"
|
||||||
|
// EclipseMapProjectionSouthPolar 使用南极方位等距投影。
|
||||||
|
// EclipseMapProjectionSouthPolar uses the south-polar azimuthal equidistant projection.
|
||||||
|
EclipseMapProjectionSouthPolar EclipseMapProjection = "south-polar"
|
||||||
|
)
|
||||||
|
|
||||||
|
func internalEclipseMapProjection(value EclipseMapProjection) svgmap.Projection {
|
||||||
|
return svgmap.Projection(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEclipseMapProjection(value EclipseMapProjection) bool {
|
||||||
|
switch value {
|
||||||
|
case EclipseMapProjectionAuto, EclipseMapProjectionEquirectangular,
|
||||||
|
EclipseMapProjectionNorthPolar, EclipseMapProjectionSouthPolar:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func eclipseMapProjectionLabel(projection svgmap.Projection, language string) string {
|
||||||
|
if language == "en" {
|
||||||
|
switch projection {
|
||||||
|
case svgmap.ProjectionNorthPolar:
|
||||||
|
return "North-polar azimuthal equidistant projection"
|
||||||
|
case svgmap.ProjectionSouthPolar:
|
||||||
|
return "South-polar azimuthal equidistant projection"
|
||||||
|
default:
|
||||||
|
return "Equirectangular projection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch projection {
|
||||||
|
case svgmap.ProjectionNorthPolar:
|
||||||
|
return "北极方位等距投影"
|
||||||
|
case svgmap.ProjectionSouthPolar:
|
||||||
|
return "南极方位等距投影"
|
||||||
|
default:
|
||||||
|
return "等经纬投影"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
// LocalSolarEclipseSVGOptions 控制站心日食视圆 SVG 输出。
|
// LocalSolarEclipseSVGOptions 控制站心日食视圆 SVG 输出。
|
||||||
// LocalSolarEclipseSVGOptions controls local solar eclipse disk SVG output.
|
// LocalSolarEclipseSVGOptions controls local solar eclipse disk SVG output.
|
||||||
type LocalSolarEclipseSVGOptions struct {
|
type LocalSolarEclipseSVGOptions struct {
|
||||||
// Width / Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
|
// Width 和 Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
|
||||||
// Width/Height are SVG canvas size; values <= 0 use defaults.
|
// Width/Height are SVG canvas size; values <= 0 use defaults.
|
||||||
Width int
|
Width int
|
||||||
Height int
|
Height int
|
||||||
|
|||||||
@@ -0,0 +1,826 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/basic"
|
||||||
|
eclipsecore "b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/internal/svgmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
solarEclipseMapDefaultWidth = 960
|
||||||
|
solarEclipseMapDefaultHeight = 640
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 int
|
||||||
|
Height int
|
||||||
|
// Language 为 "en"(不区分大小写)时使用英文,否则使用中文。
|
||||||
|
// Language uses English for "en" (case-insensitive) and Chinese otherwise.
|
||||||
|
Language string
|
||||||
|
// Location 控制显示的事件时刻;nil 使用 date.Location()。
|
||||||
|
// Location controls displayed event times. Nil uses date.Location().
|
||||||
|
Location *time.Location
|
||||||
|
// Projection 选择地图投影;零值从事件几何中自动选择,不支持的值使渲染器返回 false。
|
||||||
|
// Projection selects the map projection. The zero value selects one from the event geometry; unsupported values make the renderer return false.
|
||||||
|
Projection EclipseMapProjection
|
||||||
|
// 空文本字段使用本地化的自动标签。
|
||||||
|
// Empty text fields use localized automatic labels.
|
||||||
|
Title string
|
||||||
|
MapTitle string
|
||||||
|
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 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 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 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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
type solarEclipseMapCalculators struct {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SolarEclipseMapSVG 使用 NASA bulletin Split-K 绘制完整偏食可见范围,并在存在时绘制全食或环食中心线。
|
||||||
|
// SolarEclipseMapSVG renders the full partial-visibility sweep and, when present, the total or annular central path using NASA bulletin Split-K.
|
||||||
|
func SolarEclipseMapSVG(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
|
||||||
|
return SolarEclipseMapSVGNASABulletinSplitK(date, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SolarEclipseMapSVGNASABulletinSplitK 使用 NASA bulletin Split-K 绘制地图。
|
||||||
|
// SolarEclipseMapSVGNASABulletinSplitK renders a NASA bulletin Split-K map.
|
||||||
|
func SolarEclipseMapSVGNASABulletinSplitK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
|
||||||
|
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
|
||||||
|
partial: eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK,
|
||||||
|
central: eclipsecore.SolarEclipseCentralPathNASABulletinSplitK,
|
||||||
|
local: eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SolarEclipseMapSVGIAUSingleK 使用 IAU Single-K 模型绘制地图。
|
||||||
|
// SolarEclipseMapSVGIAUSingleK renders an IAU Single-K map.
|
||||||
|
func SolarEclipseMapSVGIAUSingleK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
|
||||||
|
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
|
||||||
|
partial: eclipsecore.SolarEclipsePartialFootprintsIAUSingleK,
|
||||||
|
central: eclipsecore.SolarEclipseCentralPathIAUSingleK,
|
||||||
|
local: eclipsecore.GeometricLocalSolarEclipseOnDateIAUSingleK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapSVG(
|
||||||
|
date time.Time,
|
||||||
|
options SolarEclipseMapSVGOptions,
|
||||||
|
calculators solarEclipseMapCalculators,
|
||||||
|
) (string, bool) {
|
||||||
|
if !validEclipseMapProjection(options.Projection) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
options = normalizeSolarEclipseMapSVGOptions(date, options)
|
||||||
|
partial, ok := calculators.partial(date, eclipsecore.SolarEclipsePartialFootprintOptions{
|
||||||
|
Step: options.PartialStep,
|
||||||
|
BoundaryPoints: options.BoundaryPoints,
|
||||||
|
CentralShadowStep: options.CentralShadowStep,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
central, hasCentral := calculators.central(date, eclipsecore.SolarEclipsePathOptions{
|
||||||
|
Step: options.CentralStep,
|
||||||
|
TargetSpacingKM: options.TargetSpacingKM,
|
||||||
|
})
|
||||||
|
local, hasLocal := calculators.local(
|
||||||
|
partial.Eclipse.GreatestEclipse,
|
||||||
|
partial.Eclipse.GreatestLongitude,
|
||||||
|
partial.Eclipse.GreatestLatitude,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
projection := resolveSolarEclipseMapProjection(partial, central, hasCentral, options.Projection)
|
||||||
|
return renderSolarEclipseMapSVG(partial, central, hasCentral, local, hasLocal, options, projection), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSolarEclipseMapSVGOptions(date time.Time, options SolarEclipseMapSVGOptions) SolarEclipseMapSVGOptions {
|
||||||
|
if options.Width < 640 {
|
||||||
|
options.Width = solarEclipseMapDefaultWidth
|
||||||
|
}
|
||||||
|
if options.Height < 420 {
|
||||||
|
options.Height = solarEclipseMapDefaultHeight
|
||||||
|
}
|
||||||
|
if strings.EqualFold(options.Language, "en") {
|
||||||
|
options.Language = "en"
|
||||||
|
} else {
|
||||||
|
options.Language = "zh"
|
||||||
|
}
|
||||||
|
if options.Location == nil {
|
||||||
|
options.Location = date.Location()
|
||||||
|
}
|
||||||
|
if options.PartialStep <= 0 {
|
||||||
|
options.PartialStep = 2 * time.Minute
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
options.PenumbralOutlineStep = time.Minute
|
||||||
|
}
|
||||||
|
if options.CentralShadowStep < 0 {
|
||||||
|
options.CentralShadowStep = 0
|
||||||
|
} else if options.CentralShadowStep == 0 {
|
||||||
|
options.CentralShadowStep = 10 * time.Minute
|
||||||
|
} else if options.CentralShadowStep < time.Minute {
|
||||||
|
options.CentralShadowStep = time.Minute
|
||||||
|
}
|
||||||
|
if options.CentralStep <= 0 {
|
||||||
|
options.CentralStep = 2 * time.Minute
|
||||||
|
}
|
||||||
|
if options.TargetSpacingKM <= 0 {
|
||||||
|
options.TargetSpacingKM = 150
|
||||||
|
}
|
||||||
|
if options.TimeLabelStep < 0 {
|
||||||
|
options.TimeLabelStep = 0
|
||||||
|
} else if options.TimeLabelStep == 0 {
|
||||||
|
options.TimeLabelStep = 30 * time.Minute
|
||||||
|
} else if options.TimeLabelStep < time.Minute {
|
||||||
|
options.TimeLabelStep = time.Minute
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSolarEclipseMapProjection(
|
||||||
|
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||||
|
central eclipsecore.SolarEclipsePath,
|
||||||
|
hasCentral bool,
|
||||||
|
requested EclipseMapProjection,
|
||||||
|
) svgmap.Projection {
|
||||||
|
if requested != EclipseMapProjectionAuto {
|
||||||
|
return internalEclipseMapProjection(requested)
|
||||||
|
}
|
||||||
|
focus := partial.Eclipse.GreatestLatitude
|
||||||
|
minimum, maximum := focus, focus
|
||||||
|
for _, footprint := range partial.Footprints {
|
||||||
|
for _, boundary := range footprint.Boundaries {
|
||||||
|
for _, point := range boundary {
|
||||||
|
minimum = math.Min(minimum, point.Latitude)
|
||||||
|
maximum = math.Max(maximum, point.Latitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasCentral {
|
||||||
|
for _, series := range [][]eclipsecore.SolarEclipsePathPoint{central.CenterLine, central.NorthernLimit, central.SouthernLimit} {
|
||||||
|
for _, point := range series {
|
||||||
|
minimum = math.Min(minimum, point.Latitude)
|
||||||
|
maximum = math.Max(maximum, point.Latitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSolarEclipseMapSVG(
|
||||||
|
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||||
|
central eclipsecore.SolarEclipsePath,
|
||||||
|
hasCentral bool,
|
||||||
|
local eclipsecore.LocalSolarEclipseInfo,
|
||||||
|
hasLocal bool,
|
||||||
|
options SolarEclipseMapSVGOptions,
|
||||||
|
projection svgmap.Projection,
|
||||||
|
) string {
|
||||||
|
layout := solarEclipseMapLayoutFor(options, projection)
|
||||||
|
frame := layout.frame
|
||||||
|
title := solarEclipseMapTitle(partial.Eclipse, options)
|
||||||
|
partialPath := solarEclipsePartialSweepPath(partial, frame)
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
fmt.Fprintf(&builder, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
|
||||||
|
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
|
||||||
|
builder.WriteString(`<defs>`)
|
||||||
|
builder.WriteString(frame.ClipDefinition("solar-map-clip"))
|
||||||
|
builder.WriteString(`</defs>`)
|
||||||
|
builder.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
|
||||||
|
fmt.Fprintf(&builder, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
|
||||||
|
options.Width-44, options.Height-36)
|
||||||
|
fmt.Fprintf(&builder, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
|
||||||
|
float64(options.Width)/2, html.EscapeString(title))
|
||||||
|
writeSolarEclipseMapSummary(&builder, partial.Eclipse, local, hasLocal, options)
|
||||||
|
writeSolarEclipseMapSectionTitle(&builder, layout, options, hasCentral)
|
||||||
|
|
||||||
|
frame.WriteOcean(&builder)
|
||||||
|
frame.WriteGraticule(&builder, "solar-map-clip")
|
||||||
|
frame.WriteLand(&builder, "solar-map-clip")
|
||||||
|
fmt.Fprintf(&builder, `<path class="partial-eclipse-region" d="%s" clip-path="url(#solar-map-clip)" fill="#dfb84d" fill-opacity="0.46" fill-rule="nonzero"/>`, partialPath)
|
||||||
|
writeSolarEclipseTerminator(&builder, partial.Eclipse, frame)
|
||||||
|
writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options)
|
||||||
|
if hasCentral {
|
||||||
|
writeSolarEclipseCentralPath(&builder, central, frame, partial.Eclipse.Type)
|
||||||
|
}
|
||||||
|
writeSolarEclipseCentralShadowOutlines(&builder, partial.CentralShadowFootprints, frame)
|
||||||
|
if hasCentral {
|
||||||
|
writeSolarEclipseTimeMarkers(&builder, central, frame, options)
|
||||||
|
writeSolarEclipseAxisMarkers(&builder, central, frame, options)
|
||||||
|
}
|
||||||
|
writeSolarEclipseContactMarkers(&builder, partial, frame)
|
||||||
|
writeSolarEclipseGreatestMarker(&builder, partial.Eclipse, frame, options.Language)
|
||||||
|
writeSolarEclipseSubsolarMarker(&builder, partial.Eclipse, frame, options.Language)
|
||||||
|
frame.WriteFrame(&builder)
|
||||||
|
writeSolarEclipseMapLegend(&builder, layout, partial.Eclipse, hasCentral, options)
|
||||||
|
writeSolarEclipseGlobalEventsPanel(&builder, partial, central, hasCentral, layout, options)
|
||||||
|
writeSolarEclipseMapFooter(&builder, frame, options, projection)
|
||||||
|
builder.WriteString(`</svg>`)
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
segments = append(segments, segment)
|
||||||
|
}
|
||||||
|
boundary := svgmap.JoinPolylineSegments(segments)
|
||||||
|
if len(boundary) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(boundary) > 1 && svgmap.SameGeoPoint(boundary[0], boundary[len(boundary)-1]) {
|
||||||
|
boundary = boundary[:len(boundary)-1]
|
||||||
|
}
|
||||||
|
polygon := solarEclipsePartialFootprintPolygon(boundary, footprint.Time, footprint.Closed, frame.Projection)
|
||||||
|
for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) {
|
||||||
|
appendEclipseMapPolygonPathConsistent(&builder, frame, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipsePartialFootprintPolygon(
|
||||||
|
boundary []svgmap.GeoPoint,
|
||||||
|
value time.Time,
|
||||||
|
closed bool,
|
||||||
|
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])...)
|
||||||
|
}
|
||||||
|
if interior, ok := solarEclipseSphericalBoundaryCentroid(polygon); ok && projection == svgmap.ProjectionEquirectangular {
|
||||||
|
polygon = solarEclipseAppendEquirectangularPoleRim(polygon, interior)
|
||||||
|
}
|
||||||
|
return polygon
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseSphericalBoundaryCentroid(points []svgmap.GeoPoint) (svgmap.GeoPoint, bool) {
|
||||||
|
var sum solarEclipseMapVector
|
||||||
|
for _, point := range points {
|
||||||
|
value := solarEclipseMapUnitVector(point)
|
||||||
|
sum[0] += value[0]
|
||||||
|
sum[1] += value[1]
|
||||||
|
sum[2] += value[2]
|
||||||
|
}
|
||||||
|
length := math.Sqrt(solarEclipseMapDot(sum, sum))
|
||||||
|
if length < 1e-12 {
|
||||||
|
return svgmap.GeoPoint{}, false
|
||||||
|
}
|
||||||
|
for index := range sum {
|
||||||
|
sum[index] /= length
|
||||||
|
}
|
||||||
|
return svgmap.GeoPoint{
|
||||||
|
Longitude: math.Atan2(sum[1], sum[0]) * 180 / math.Pi,
|
||||||
|
Latitude: math.Asin(math.Max(-1, math.Min(1, sum[2]))) * 180 / math.Pi,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseAppendEquirectangularPoleRim(
|
||||||
|
polygon []svgmap.GeoPoint,
|
||||||
|
interior svgmap.GeoPoint,
|
||||||
|
) []svgmap.GeoPoint {
|
||||||
|
interiorAngle := solarEclipseSphericalPolygonAngle(polygon, interior)
|
||||||
|
if math.Abs(interiorAngle) < math.Pi {
|
||||||
|
return polygon
|
||||||
|
}
|
||||||
|
poleLatitude := 0.0
|
||||||
|
for _, candidate := range []float64{90, -90} {
|
||||||
|
angle := solarEclipseSphericalPolygonAngle(polygon, svgmap.GeoPoint{Latitude: candidate})
|
||||||
|
if math.Abs(angle) >= math.Pi && math.Signbit(angle) == math.Signbit(interiorAngle) {
|
||||||
|
poleLatitude = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if poleLatitude == 0 {
|
||||||
|
return polygon
|
||||||
|
}
|
||||||
|
|
||||||
|
firstLongitude, lastLongitude := solarEclipseUnwrappedLongitudeEndpoints(polygon)
|
||||||
|
delta := firstLongitude - lastLongitude
|
||||||
|
if math.Abs(delta) < 180 {
|
||||||
|
return polygon
|
||||||
|
}
|
||||||
|
steps := int(math.Ceil(math.Abs(delta) / 90))
|
||||||
|
result := append([]svgmap.GeoPoint(nil), polygon...)
|
||||||
|
for step := 1; step <= steps; step++ {
|
||||||
|
longitude := lastLongitude + delta*float64(step)/float64(steps)
|
||||||
|
result = append(result, svgmap.GeoPoint{
|
||||||
|
Longitude: normalizeDegree180(longitude),
|
||||||
|
Latitude: poleLatitude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseUnwrappedLongitudeEndpoints(points []svgmap.GeoPoint) (float64, float64) {
|
||||||
|
first := points[0].Longitude
|
||||||
|
previous := first
|
||||||
|
for _, point := range points[1:] {
|
||||||
|
longitude := point.Longitude
|
||||||
|
for longitude-previous > 180 {
|
||||||
|
longitude -= 360
|
||||||
|
}
|
||||||
|
for longitude-previous < -180 {
|
||||||
|
longitude += 360
|
||||||
|
}
|
||||||
|
previous = longitude
|
||||||
|
}
|
||||||
|
return first, previous
|
||||||
|
}
|
||||||
|
|
||||||
|
type solarEclipseMapVector [3]float64
|
||||||
|
|
||||||
|
func solarEclipseSphericalPolygonAngle(points []svgmap.GeoPoint, target svgmap.GeoPoint) float64 {
|
||||||
|
if len(points) < 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
reference := solarEclipseMapUnitVector(target)
|
||||||
|
angle := 0.0
|
||||||
|
for index, point := range points {
|
||||||
|
current, currentOK := solarEclipseMapTangentDirection(reference, solarEclipseMapUnitVector(point))
|
||||||
|
next, nextOK := solarEclipseMapTangentDirection(reference, solarEclipseMapUnitVector(points[(index+1)%len(points)]))
|
||||||
|
if !currentOK || !nextOK {
|
||||||
|
return math.Copysign(2*math.Pi, angle)
|
||||||
|
}
|
||||||
|
angle += math.Atan2(
|
||||||
|
solarEclipseMapDot(reference, solarEclipseMapCross(current, next)),
|
||||||
|
solarEclipseMapDot(current, next),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return angle
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapUnitVector(point svgmap.GeoPoint) solarEclipseMapVector {
|
||||||
|
longitude := point.Longitude * math.Pi / 180
|
||||||
|
latitude := point.Latitude * math.Pi / 180
|
||||||
|
return solarEclipseMapVector{
|
||||||
|
math.Cos(latitude) * math.Cos(longitude),
|
||||||
|
math.Cos(latitude) * math.Sin(longitude),
|
||||||
|
math.Sin(latitude),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapTangentDirection(
|
||||||
|
reference, value solarEclipseMapVector,
|
||||||
|
) (solarEclipseMapVector, bool) {
|
||||||
|
projection := solarEclipseMapDot(reference, value)
|
||||||
|
result := solarEclipseMapVector{
|
||||||
|
value[0] - projection*reference[0],
|
||||||
|
value[1] - projection*reference[1],
|
||||||
|
value[2] - projection*reference[2],
|
||||||
|
}
|
||||||
|
length := math.Sqrt(solarEclipseMapDot(result, result))
|
||||||
|
if length < 1e-12 {
|
||||||
|
return solarEclipseMapVector{}, false
|
||||||
|
}
|
||||||
|
return solarEclipseMapVector{result[0] / length, result[1] / length, result[2] / length}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapDot(a, b solarEclipseMapVector) float64 {
|
||||||
|
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapCross(a, b solarEclipseMapVector) solarEclipseMapVector {
|
||||||
|
return solarEclipseMapVector{
|
||||||
|
a[1]*b[2] - a[2]*b[1],
|
||||||
|
a[2]*b[0] - a[0]*b[2],
|
||||||
|
a[0]*b[1] - a[1]*b[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint {
|
||||||
|
ttJDE := solarEclipseTimeToTTJDE(value)
|
||||||
|
ra, dec := basic.HSunApparentRaDec(ttJDE)
|
||||||
|
utJDE := basic.TD2UT(ttJDE, false)
|
||||||
|
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15)
|
||||||
|
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendEclipseMapPolygonPathConsistent(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) {
|
||||||
|
if projectedPolygonArea(frame, points) < 0 {
|
||||||
|
points = append([]svgmap.GeoPoint(nil), points...)
|
||||||
|
reverseGeoPoints(points)
|
||||||
|
}
|
||||||
|
appendEclipseMapPolygonPath(builder, frame, points)
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectedPolygonArea(frame svgmap.Frame, points []svgmap.GeoPoint) float64 {
|
||||||
|
area := 0.0
|
||||||
|
for index, point := range points {
|
||||||
|
next := points[(index+1)%len(points)]
|
||||||
|
x1, y1, ok1 := frame.Project(point.Longitude, point.Latitude)
|
||||||
|
x2, y2, ok2 := frame.Project(next.Longitude, next.Latitude)
|
||||||
|
if ok1 && ok2 {
|
||||||
|
area += x1*y2 - x2*y1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return area / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipseCentralPath(
|
||||||
|
builder *strings.Builder,
|
||||||
|
path eclipsecore.SolarEclipsePath,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if paired {
|
||||||
|
polygon := make([]svgmap.GeoPoint, 0, 2*count)
|
||||||
|
for _, point := range path.NorthernLimit[:count] {
|
||||||
|
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||||
|
}
|
||||||
|
for index := count - 1; index >= 0; index-- {
|
||||||
|
point := path.SouthernLimit[index]
|
||||||
|
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||||
|
}
|
||||||
|
color := solarEclipseCentralPathColor(eclipseType)
|
||||||
|
fmt.Fprintf(builder, `<g class="central-eclipse-band" clip-path="url(#solar-map-clip)" fill="%s" fill-opacity="0.82">`, color)
|
||||||
|
for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) {
|
||||||
|
builder.WriteString(`<path d="`)
|
||||||
|
appendEclipseMapPolygonPathConsistent(builder, frame, fragment)
|
||||||
|
builder.WriteString(`"/>`)
|
||||||
|
}
|
||||||
|
builder.WriteString(`</g>`)
|
||||||
|
}
|
||||||
|
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, path.CenterLine, "solar-center-line", "#263f58", 1.8, "5 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarPathLine(
|
||||||
|
builder *strings.Builder,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
points []eclipsecore.SolarEclipsePathPoint,
|
||||||
|
className, color string,
|
||||||
|
width float64,
|
||||||
|
dash string,
|
||||||
|
) {
|
||||||
|
geographic := make([]svgmap.GeoPoint, len(points))
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipseGreatestMarker(
|
||||||
|
builder *strings.Builder,
|
||||||
|
info eclipsecore.SolarEclipseInfo,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
language string,
|
||||||
|
) {
|
||||||
|
x, y, ok := frame.Project(info.GreatestLongitude, info.GreatestLatitude)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label := "食甚"
|
||||||
|
if language == "en" {
|
||||||
|
label = "Greatest"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<g class="solar-greatest-marker"><circle cx="%.3f" cy="%.3f" r="5" fill="#c44336" stroke="#fff" stroke-width="1.3"/><text x="%.3f" y="%.3f" fill="#182124" stroke="#fff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="middle">%s</text></g>`,
|
||||||
|
x, y, x, y-10, html.EscapeString(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapTitle(info eclipsecore.SolarEclipseInfo, options SolarEclipseMapSVGOptions) string {
|
||||||
|
if options.Title != "" {
|
||||||
|
return options.Title
|
||||||
|
}
|
||||||
|
date := info.GreatestEclipse.In(options.Location).Format("2006-01-02")
|
||||||
|
if options.Language == "en" {
|
||||||
|
return fmt.Sprintf("%s %s Global Visibility", date, solarEclipseMapTypeName(info.Type, "en"))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s全球见食图", date, solarEclipseMapTypeName(info.Type, "zh"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipseMapSummary(
|
||||||
|
builder *strings.Builder,
|
||||||
|
info eclipsecore.SolarEclipseInfo,
|
||||||
|
local eclipsecore.LocalSolarEclipseInfo,
|
||||||
|
hasLocal bool,
|
||||||
|
options SolarEclipseMapSVGOptions,
|
||||||
|
) {
|
||||||
|
start := info.PartialBeginOnEarth.In(options.Location)
|
||||||
|
maximum := info.GreatestEclipse.In(options.Location)
|
||||||
|
end := info.PartialEndOnEarth.In(options.Location)
|
||||||
|
zone, _ := maximum.Zone()
|
||||||
|
if zone == "" {
|
||||||
|
zone = "UTC"
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("偏食始 %s | 食甚 %s | 偏食终 %s (%s) | 食分 %.3f | Gamma %.4f",
|
||||||
|
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.Magnitude, info.Gamma)
|
||||||
|
if options.Language == "en" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
|
||||||
|
float64(options.Width)/2, 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))
|
||||||
|
} else {
|
||||||
|
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))
|
||||||
|
} else {
|
||||||
|
details = append(details, fmt.Sprintf("太阳沙罗 %d,第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasLocal {
|
||||||
|
if options.Language == "en" {
|
||||||
|
details = append(details, fmt.Sprintf("Sun alt %.1f° az %.1f°", local.SunAltitude, local.SunAzimuth))
|
||||||
|
} else {
|
||||||
|
details = append(details, fmt.Sprintf("食甚点太阳高度 %.1f° 方位 %.1f°", local.SunAltitude, local.SunAzimuth))
|
||||||
|
}
|
||||||
|
if local.HasCentral && !local.CentralStart.IsZero() && !local.CentralEnd.IsZero() {
|
||||||
|
duration := local.CentralEnd.Sub(local.CentralStart)
|
||||||
|
if duration > 0 {
|
||||||
|
if options.Language == "en" {
|
||||||
|
details = append(details, "central duration "+formatSolarEclipseMapDuration(duration))
|
||||||
|
} else {
|
||||||
|
details = append(details, "中心食持续 "+formatSolarEclipseMapDuration(duration))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(details) > 0 {
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="106" fill="#596164" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
|
||||||
|
float64(options.Width)/2, html.EscapeString(strings.Join(details, " | ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSolarEclipseMapDuration(value time.Duration) string {
|
||||||
|
seconds := int(math.Round(value.Seconds()))
|
||||||
|
if seconds < 0 {
|
||||||
|
seconds = -seconds
|
||||||
|
}
|
||||||
|
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
|
||||||
|
builder.WriteString(`<g class="solar-map-legend">`)
|
||||||
|
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, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1.6" stroke-dasharray="%s"/>`,
|
||||||
|
x, y-4, x+20, y-4, item.color, item.dash)
|
||||||
|
case "contact":
|
||||||
|
fmt.Fprintf(builder, `<circle cx="%.3f" cy="%.3f" r="3" fill="%s" stroke="#ffffff" stroke-width="0.8"/>`,
|
||||||
|
x+8, y-4, item.color)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(builder, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`,
|
||||||
|
x, y-8, item.color)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="10">%s</text>`,
|
||||||
|
x+25, y, html.EscapeString(item.label))
|
||||||
|
}
|
||||||
|
builder.WriteString(`</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseOutlineLegendLabel(kind string, eclipseType eclipsecore.SolarEclipseType, step time.Duration, language string) string {
|
||||||
|
interval := solarEclipseMapStepLabel(step, language)
|
||||||
|
if kind == "penumbra" {
|
||||||
|
if language == "en" {
|
||||||
|
return "Penumbral outlines (" + interval + ")"
|
||||||
|
}
|
||||||
|
return "半影时刻线(" + interval + ")"
|
||||||
|
}
|
||||||
|
name := "本影轮廓"
|
||||||
|
if eclipseType == eclipsecore.SolarEclipseAnnular {
|
||||||
|
name = "反本影轮廓"
|
||||||
|
} else if eclipseType == eclipsecore.SolarEclipseHybrid {
|
||||||
|
name = "本影/反本影轮廓"
|
||||||
|
}
|
||||||
|
if language == "en" {
|
||||||
|
name = "Umbral outlines"
|
||||||
|
if eclipseType == eclipsecore.SolarEclipseAnnular {
|
||||||
|
name = "Antumbral outlines"
|
||||||
|
} else if eclipseType == eclipsecore.SolarEclipseHybrid {
|
||||||
|
name = "Umbral/antumbral outlines"
|
||||||
|
}
|
||||||
|
return name + " (" + interval + ")"
|
||||||
|
}
|
||||||
|
return name + "(" + interval + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapStepLabel(step time.Duration, language string) string {
|
||||||
|
if step%time.Minute != 0 {
|
||||||
|
return step.String()
|
||||||
|
}
|
||||||
|
minutes := int(step / time.Minute)
|
||||||
|
if language == "en" {
|
||||||
|
return fmt.Sprintf("%d min", minutes)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d 分钟", minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipseMapFooter(
|
||||||
|
builder *strings.Builder,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
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 物理陆地底图,不含行政边界。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
|
||||||
|
frame.X, float64(options.Height)-38, html.EscapeString(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapTypeName(value eclipsecore.SolarEclipseType, language string) string {
|
||||||
|
if language == "en" {
|
||||||
|
switch value {
|
||||||
|
case eclipsecore.SolarEclipseTotal:
|
||||||
|
return "Total Solar Eclipse"
|
||||||
|
case eclipsecore.SolarEclipseAnnular:
|
||||||
|
return "Annular Solar Eclipse"
|
||||||
|
case eclipsecore.SolarEclipseHybrid:
|
||||||
|
return "Hybrid Solar Eclipse"
|
||||||
|
default:
|
||||||
|
return "Partial Solar Eclipse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case eclipsecore.SolarEclipseTotal:
|
||||||
|
return "日全食"
|
||||||
|
case eclipsecore.SolarEclipseAnnular:
|
||||||
|
return "日环食"
|
||||||
|
case eclipsecore.SolarEclipseHybrid:
|
||||||
|
return "全环食"
|
||||||
|
default:
|
||||||
|
return "日偏食"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseCentralPathLabel(value eclipsecore.SolarEclipseType, language string) string {
|
||||||
|
if language == "en" {
|
||||||
|
switch value {
|
||||||
|
case eclipsecore.SolarEclipseAnnular:
|
||||||
|
return "Annular path"
|
||||||
|
case eclipsecore.SolarEclipseHybrid:
|
||||||
|
return "Hybrid central path"
|
||||||
|
default:
|
||||||
|
return "Path of totality"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case eclipsecore.SolarEclipseAnnular:
|
||||||
|
return "环食带"
|
||||||
|
case eclipsecore.SolarEclipseHybrid:
|
||||||
|
return "全环食中心带"
|
||||||
|
default:
|
||||||
|
return "全食带"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseCentralPathColor(value eclipsecore.SolarEclipseType) string {
|
||||||
|
if value == eclipsecore.SolarEclipseAnnular {
|
||||||
|
return "#a94f3f"
|
||||||
|
}
|
||||||
|
if value == eclipsecore.SolarEclipseHybrid {
|
||||||
|
return "#76506f"
|
||||||
|
}
|
||||||
|
return "#38516d"
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseGeoPoints(points []svgmap.GeoPoint) {
|
||||||
|
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
|
||||||
|
points[left], points[right] = points[right], points[left]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
eclipsecore "b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/internal/svgmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type solarEclipseMapLayout struct {
|
||||||
|
frame svgmap.Frame
|
||||||
|
panelX float64
|
||||||
|
panelY float64
|
||||||
|
panelWidth float64
|
||||||
|
panelHeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type solarEclipseGlobalEventRow struct {
|
||||||
|
kind string
|
||||||
|
name string
|
||||||
|
time time.Time
|
||||||
|
point eclipsecore.SolarEclipsePathPoint
|
||||||
|
hasPoint bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMapLayoutFor(options SolarEclipseMapSVGOptions, projection svgmap.Projection) 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipseMapSectionTitle(
|
||||||
|
builder *strings.Builder,
|
||||||
|
layout solarEclipseMapLayout,
|
||||||
|
options SolarEclipseMapSVGOptions,
|
||||||
|
hasCentral bool,
|
||||||
|
) {
|
||||||
|
label := options.MapTitle
|
||||||
|
if label == "" {
|
||||||
|
if options.Language == "en" && hasCentral {
|
||||||
|
label = "Global visibility and central path"
|
||||||
|
} else if options.Language == "en" {
|
||||||
|
label = "Global visibility"
|
||||||
|
} else if hasCentral {
|
||||||
|
label = "全球见食范围与中心食带"
|
||||||
|
} else {
|
||||||
|
label = "全球见食范围"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
|
||||||
|
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 = "全球阶段"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<g class="solar-global-events">`)
|
||||||
|
fmt.Fprintf(builder, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#d0d3d1" stroke-width="1"/>`,
|
||||||
|
layout.panelX-10, layout.panelY, layout.panelX-10, layout.panelY+layout.panelHeight)
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
|
||||||
|
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, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#e0e1df" stroke-width="0.8"/>`,
|
||||||
|
layout.panelX, y-5, layout.panelX+layout.panelWidth, y-5)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" font-weight="700">%s</text>`,
|
||||||
|
layout.panelX, y+10, html.EscapeString(row.name))
|
||||||
|
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="10" text-anchor="end">%s</text>`,
|
||||||
|
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, `<text x="%.3f" y="%.3f" fill="#4c585a" font-family="Arial, sans-serif" font-size="10">%s</text>`,
|
||||||
|
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, `<text x="%.3f" y="%.3f" fill="#697476" font-family="Arial, sans-serif" font-size="9">%s</text>`,
|
||||||
|
layout.panelX, y+43, html.EscapeString(detail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.WriteString(`</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
builder *strings.Builder,
|
||||||
|
info eclipsecore.SolarEclipseInfo,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
) {
|
||||||
|
terminator := svgmap.SphericalCircle(solarEclipseSubsolarPoint(info.GreatestEclipse), 90, 360)
|
||||||
|
if len(terminator) > 0 {
|
||||||
|
terminator = append(terminator, terminator[0])
|
||||||
|
}
|
||||||
|
writeEclipseMapGeoLine(
|
||||||
|
builder, frame, terminator, "solar-greatest-terminator", "#6f7778", 1.05, "4 3", "solar-map-clip",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipsePenumbralOutlines(
|
||||||
|
builder *strings.Builder,
|
||||||
|
info eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
options SolarEclipseMapSVGOptions,
|
||||||
|
) {
|
||||||
|
if options.PenumbralOutlineStep <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selected := solarEclipseFootprintsAtStep(
|
||||||
|
info.Footprints,
|
||||||
|
options.PenumbralOutlineStep,
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
if mapTimeDistance(footprint.Time, info.Eclipse.GreatestEclipse) <= info.Step/2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x, y, ok := solarEclipseFootprintLabelPosition(footprint, frame)
|
||||||
|
if !ok || solarEclipseMapLabelOverlaps(x, y, labelPositions) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
labelPositions = append(labelPositions, [2]float64{x, y})
|
||||||
|
labelTime := solarEclipseMapAlignedTime(footprint.Time, options.PenumbralOutlineStep, options.Location)
|
||||||
|
fmt.Fprintf(builder, `<text class="solar-penumbral-time-label" x="%.3f" y="%.3f" fill="#8b5b08" stroke="#ffffff" stroke-width="2.4" paint-order="stroke" font-family="Arial, sans-serif" font-size="8" font-weight="700" text-anchor="middle">%s</text>`,
|
||||||
|
x, y-4, html.EscapeString(labelTime.Format("15:04")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipseCentralShadowOutlines(
|
||||||
|
builder *strings.Builder,
|
||||||
|
footprints []eclipsecore.SolarEclipsePartialFootprint,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
) {
|
||||||
|
for _, footprint := range footprints {
|
||||||
|
writeSolarEclipseFootprintBoundary(
|
||||||
|
builder, footprint, frame, "solar-central-shadow-outline", "#7b5a42", 0.8, "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseFootprintLabelPosition(
|
||||||
|
footprint eclipsecore.SolarEclipsePartialFootprint,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
) (float64, float64, bool) {
|
||||||
|
bestX, bestY, bestScore := 0.0, 0.0, math.Inf(1)
|
||||||
|
centerX := frame.X + frame.Width/2
|
||||||
|
for _, boundary := range footprint.Boundaries {
|
||||||
|
for _, point := range boundary {
|
||||||
|
x, y, visible := frame.Project(point.Longitude, point.Latitude)
|
||||||
|
if !visible || x < frame.X+24 || x > frame.X+frame.Width-24 ||
|
||||||
|
y < frame.Y+14 || y > frame.Y+frame.Height-14 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := y + 0.05*math.Abs(x-centerX)
|
||||||
|
if score < bestScore {
|
||||||
|
bestX, bestY, bestScore = x, y, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
elapsed := local.Sub(dayStart)
|
||||||
|
return dayStart.Add(((elapsed + step/2) / step) * step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipseFootprintBoundary(
|
||||||
|
builder *strings.Builder,
|
||||||
|
footprint eclipsecore.SolarEclipsePartialFootprint,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
className, color string,
|
||||||
|
strokeWidth float64,
|
||||||
|
dash 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseFootprintsAtStep(
|
||||||
|
footprints []eclipsecore.SolarEclipsePartialFootprint,
|
||||||
|
step time.Duration,
|
||||||
|
location *time.Location,
|
||||||
|
include time.Time,
|
||||||
|
) []eclipsecore.SolarEclipsePartialFootprint {
|
||||||
|
if len(footprints) == 0 || step <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
targets := make([]time.Time, 0)
|
||||||
|
for value := firstMapTimeLabelAfter(footprints[0].Time, step, location); !value.After(footprints[len(footprints)-1].Time); value = value.Add(step) {
|
||||||
|
targets = append(targets, value)
|
||||||
|
}
|
||||||
|
if !include.IsZero() {
|
||||||
|
targets = append(targets, include)
|
||||||
|
}
|
||||||
|
sort.Slice(targets, func(i, j int) bool { return targets[i].Before(targets[j]) })
|
||||||
|
|
||||||
|
selected := make([]eclipsecore.SolarEclipsePartialFootprint, 0, len(targets))
|
||||||
|
index := 0
|
||||||
|
for _, target := range targets {
|
||||||
|
for index+1 < len(footprints) &&
|
||||||
|
mapTimeDistance(footprints[index+1].Time, target) < mapTimeDistance(footprints[index].Time, target) {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
candidate := footprints[index]
|
||||||
|
if len(selected) == 0 || !selected[len(selected)-1].Time.Equal(candidate.Time) {
|
||||||
|
selected = append(selected, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapTimeDistance(a, b time.Time) time.Duration {
|
||||||
|
value := a.Sub(b)
|
||||||
|
if value < 0 {
|
||||||
|
return -value
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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, `<g class="solar-shadow-contact solar-contact-%s"><circle cx="%.3f" cy="%.3f" r="2.7" fill="%s" stroke="#ffffff" stroke-width="0.9"/><text x="%.3f" y="%.3f" fill="%s" stroke="#ffffff" stroke-width="2.4" paint-order="stroke" font-family="Arial, sans-serif" font-size="8" font-weight="700" text-anchor="%s">%s</text></g>`,
|
||||||
|
strings.ToLower(marker.name), x, y, marker.color, labelX, y+marker.labelDY, marker.color,
|
||||||
|
textAnchor, marker.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSolarEclipseAxisMarkers(
|
||||||
|
builder *strings.Builder,
|
||||||
|
path eclipsecore.SolarEclipsePath,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
options SolarEclipseMapSVGOptions,
|
||||||
|
) {
|
||||||
|
if len(path.CenterLine) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
points := []eclipsecore.SolarEclipsePathPoint{path.CenterLine[0], path.CenterLine[len(path.CenterLine)-1]}
|
||||||
|
for index, point := range points {
|
||||||
|
x, y, visible := frame.Project(point.Longitude, point.Latitude)
|
||||||
|
if !visible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := "中心线始"
|
||||||
|
if index == 1 {
|
||||||
|
label = "中心线终"
|
||||||
|
}
|
||||||
|
if options.Language == "en" {
|
||||||
|
label = "Axis enters"
|
||||||
|
if index == 1 {
|
||||||
|
label = "Axis exits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<g class="solar-axis-contact" aria-label="%s"><title>%s</title><rect x="%.3f" y="%.3f" width="5" height="5" fill="#263f58" stroke="#ffffff" stroke-width="0.8"/></g>`,
|
||||||
|
html.EscapeString(label), html.EscapeString(label), x-2.5, y-2.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label := "日下点"
|
||||||
|
if language == "en" {
|
||||||
|
label = "Subsolar"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(builder, `<g class="solar-subsolar-marker"><circle cx="%.3f" cy="%.3f" r="4" fill="#f4c542" stroke="#714f00" stroke-width="1"/><path d="M %.3f %.3f h 8 M %.3f %.3f v 8" fill="none" stroke="#714f00" stroke-width="1"/><text x="%.3f" y="%.3f" fill="#714f00" stroke="#ffffff" stroke-width="2.5" paint-order="stroke" font-family="Arial, sans-serif" font-size="9" font-weight="700" text-anchor="middle">%s</text></g>`,
|
||||||
|
x, y, x-4, y, x, y-4, x, y-9, html.EscapeString(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseFormatCoordinates(longitude, latitude float64) string {
|
||||||
|
lonSuffix := "E"
|
||||||
|
if longitude < 0 {
|
||||||
|
lonSuffix = "W"
|
||||||
|
}
|
||||||
|
latSuffix := "N"
|
||||||
|
if latitude < 0 {
|
||||||
|
latSuffix = "S"
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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, `<g class="solar-time-marker"><circle cx="%.3f" cy="%.3f" r="2.3" fill="#263f58" stroke="#ffffff" stroke-width="1"/><text x="%.3f" y="%.3f" fill="#263f58" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="9" font-weight="700" text-anchor="middle">%s</text></g>`,
|
||||||
|
x, y, x, labelY, html.EscapeString(marker.Time.In(options.Location).Format("15:04")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseGreatestTimeLabelWindow(step time.Duration) time.Duration {
|
||||||
|
window := step / 3
|
||||||
|
if window < 10*time.Minute {
|
||||||
|
return 10 * time.Minute
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseTimeMarkerPoints(
|
||||||
|
points []eclipsecore.SolarEclipsePathPoint,
|
||||||
|
step time.Duration,
|
||||||
|
location *time.Location,
|
||||||
|
excluded []time.Time,
|
||||||
|
) []eclipsecore.SolarEclipsePathPoint {
|
||||||
|
if len(points) < 2 || step <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
start := points[0].Time
|
||||||
|
end := points[len(points)-1].Time
|
||||||
|
current := firstMapTimeLabelAfter(start, step, location)
|
||||||
|
window := step / 4
|
||||||
|
if window > 5*time.Minute {
|
||||||
|
window = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if window < 30*time.Second {
|
||||||
|
window = 30 * time.Second
|
||||||
|
}
|
||||||
|
result := make([]eclipsecore.SolarEclipsePathPoint, 0)
|
||||||
|
segment := 1
|
||||||
|
for current.Before(end) {
|
||||||
|
for segment < len(points) && points[segment].Time.Before(current) {
|
||||||
|
segment++
|
||||||
|
}
|
||||||
|
if segment >= len(points) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !mapTimeNearAny(current, excluded, window) {
|
||||||
|
a, b := points[segment-1], points[segment]
|
||||||
|
span := b.Time.Sub(a.Time)
|
||||||
|
if span > 0 {
|
||||||
|
fraction := float64(current.Sub(a.Time)) / float64(span)
|
||||||
|
result = append(result, interpolateSolarEclipsePathPoint(a, b, fraction, current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.Add(step)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func interpolateSolarEclipsePathPoint(
|
||||||
|
a, b eclipsecore.SolarEclipsePathPoint,
|
||||||
|
fraction float64,
|
||||||
|
value time.Time,
|
||||||
|
) eclipsecore.SolarEclipsePathPoint {
|
||||||
|
deltaLongitude := b.Longitude - a.Longitude
|
||||||
|
if deltaLongitude > 180 {
|
||||||
|
deltaLongitude -= 360
|
||||||
|
} else if deltaLongitude < -180 {
|
||||||
|
deltaLongitude += 360
|
||||||
|
}
|
||||||
|
longitude := a.Longitude + fraction*deltaLongitude
|
||||||
|
if longitude > 180 {
|
||||||
|
longitude -= 360
|
||||||
|
} else if longitude < -180 {
|
||||||
|
longitude += 360
|
||||||
|
}
|
||||||
|
return eclipsecore.SolarEclipsePathPoint{
|
||||||
|
Time: value,
|
||||||
|
Longitude: longitude,
|
||||||
|
Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude),
|
||||||
|
SunAltitude: a.SunAltitude + fraction*(b.SunAltitude-a.SunAltitude),
|
||||||
|
WidthKM: a.WidthKM + fraction*(b.WidthKM-a.WidthKM),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstMapTimeLabelAfter(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)
|
||||||
|
elapsed := local.Sub(dayStart)
|
||||||
|
return dayStart.Add((elapsed/step + 1) * step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapTimeNearAny(value time.Time, excluded []time.Time, window time.Duration) bool {
|
||||||
|
for _, candidate := range excluded {
|
||||||
|
if candidate.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delta := value.Sub(candidate)
|
||||||
|
if delta < 0 {
|
||||||
|
delta = -delta
|
||||||
|
}
|
||||||
|
if delta <= window {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapTimesNear(a, b time.Time, window time.Duration) bool {
|
||||||
|
delta := a.Sub(b)
|
||||||
|
if delta < 0 {
|
||||||
|
delta = -delta
|
||||||
|
}
|
||||||
|
return delta <= window
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/internal/svgmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVGTotalIncludesPartialAndCentralRegions(t *testing.T) {
|
||||||
|
diagram, ok := SolarEclipseMapSVG(
|
||||||
|
time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
|
||||||
|
SolarEclipseMapSVGOptions{Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected total solar-eclipse map")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"日全食全球见食图", "偏食始", "偏食终", "偏食可见区", "全食带", "中心线",
|
||||||
|
"全球见食范围与中心食带", "全球阶段", "中心食始", "中心食终", "食带宽",
|
||||||
|
"太阳沙罗 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</text>`,
|
||||||
|
`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="land"`, "不含行政边界",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(diagram, want) {
|
||||||
|
t.Fatalf("total solar-eclipse map missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateEclipseMapXML(diagram); err != nil {
|
||||||
|
t.Fatalf("total solar-eclipse map is not valid XML: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVGPartialOnlyUsesPolarProjection(t *testing.T) {
|
||||||
|
diagram, ok := SolarEclipseMapSVG(
|
||||||
|
time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC),
|
||||||
|
SolarEclipseMapSVGOptions{PartialStep: 10 * time.Minute},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected partial solar-eclipse map")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"日偏食全球见食图", `class="partial-eclipse-region"`, `<circle class="map-ocean"`, "北极方位等距投影"} {
|
||||||
|
if !strings.Contains(diagram, want) {
|
||||||
|
t.Fatalf("partial solar-eclipse map missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, "全球阶段") {
|
||||||
|
t.Fatal("partial-only map is missing global phase information")
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagram, "全球见食范围") || strings.Contains(diagram, "全球见食范围与中心食带") {
|
||||||
|
t.Fatal("partial-only map claims to contain a central path")
|
||||||
|
}
|
||||||
|
if strings.Contains(diagram, `class="solar-time-marker"`) {
|
||||||
|
t.Fatal("partial-only map contains center-line time markers")
|
||||||
|
}
|
||||||
|
if err := validateEclipseMapXML(diagram); err != nil {
|
||||||
|
t.Fatalf("partial solar-eclipse map is not valid XML: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVGCanDisableTimeLabels(t *testing.T) {
|
||||||
|
diagram, ok := SolarEclipseMapSVG(
|
||||||
|
time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
|
||||||
|
SolarEclipseMapSVGOptions{Location: time.UTC, TimeLabelStep: -1},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected total solar-eclipse map")
|
||||||
|
}
|
||||||
|
if strings.Contains(diagram, `class="solar-time-marker"`) {
|
||||||
|
t.Fatal("disabled solar time labels were rendered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVGCanDisableSampledShadowOutlines(t *testing.T) {
|
||||||
|
diagram, ok := SolarEclipseMapSVG(
|
||||||
|
time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
|
||||||
|
SolarEclipseMapSVGOptions{
|
||||||
|
Location: time.UTC,
|
||||||
|
PenumbralOutlineStep: -1,
|
||||||
|
CentralShadowStep: -1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected total solar-eclipse map")
|
||||||
|
}
|
||||||
|
if strings.Contains(diagram, `class="solar-penumbral-outline"`) ||
|
||||||
|
strings.Contains(diagram, `class="solar-central-shadow-outline"`) {
|
||||||
|
t.Fatal("disabled sampled shadow outlines were rendered")
|
||||||
|
}
|
||||||
|
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"`) ||
|
||||||
|
!strings.Contains(diagram, `class="solar-shadow-contact solar-contact-u1"`) {
|
||||||
|
t.Fatal("disabling sampled outlines removed required contact geometry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVGExplainsSampledShadowLines(t *testing.T) {
|
||||||
|
cst := time.FixedZone("UTC+8", 8*60*60)
|
||||||
|
date := time.Date(2035, 9, 2, 12, 0, 0, 0, cst)
|
||||||
|
options := SolarEclipseMapSVGOptions{
|
||||||
|
Width: 1200, Height: 800, Location: cst,
|
||||||
|
Projection: EclipseMapProjectionEquirectangular,
|
||||||
|
}
|
||||||
|
normalized := normalizeSolarEclipseMapSVGOptions(date, options)
|
||||||
|
if normalized.PenumbralOutlineStep != time.Hour {
|
||||||
|
t.Fatalf("default penumbral outline step = %s, want 1h", 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)
|
||||||
|
}
|
||||||
|
diagram, ok := SolarEclipseMapSVG(date, options)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 2035 total solar-eclipse map")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"半影时刻线(60 分钟)", "食甚晨昏圈", "本影轮廓(10 分钟)", "P/U 影锥接触",
|
||||||
|
`class="solar-penumbral-time-label"`, `class="solar-map-legend"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(diagram, want) {
|
||||||
|
t.Fatalf("solar-eclipse map does not explain %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVGAntarcticEventUsesSouthPolarProjection(t *testing.T) {
|
||||||
|
diagram, ok := SolarEclipseMapSVG(
|
||||||
|
time.Date(2021, 12, 4, 0, 0, 0, 0, time.UTC),
|
||||||
|
SolarEclipseMapSVGOptions{PartialStep: 10 * time.Minute},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected Antarctic total solar-eclipse map")
|
||||||
|
}
|
||||||
|
for _, want := range []string{`<circle class="map-ocean"`, `<circle class="map-frame"`, "南极方位等距投影", `class="central-eclipse-band"`} {
|
||||||
|
if !strings.Contains(diagram, want) {
|
||||||
|
t.Fatalf("Antarctic solar-eclipse map missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateEclipseMapXML(diagram); err != nil {
|
||||||
|
t.Fatalf("Antarctic solar-eclipse map is not valid XML: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVGAnnularLabelsCentralBand(t *testing.T) {
|
||||||
|
diagram, ok := SolarEclipseMapSVG(
|
||||||
|
time.Date(2023, 10, 14, 0, 0, 0, 0, time.UTC),
|
||||||
|
SolarEclipseMapSVGOptions{PartialStep: 15 * time.Minute},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected annular solar-eclipse map")
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagram, "日环食全球见食图") || !strings.Contains(diagram, "环食带") {
|
||||||
|
t.Fatal("annular map does not distinguish the annular path")
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagram, "反本影轮廓(10 分钟)") {
|
||||||
|
t.Fatal("annular map does not explain the antumbral outlines")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVG2012DoesNotFillAntimeridianSpikes(t *testing.T) {
|
||||||
|
cst := time.FixedZone("UTC+8", 8*60*60)
|
||||||
|
date := time.Date(2012, 5, 21, 12, 0, 0, 0, cst)
|
||||||
|
options := SolarEclipseMapSVGOptions{
|
||||||
|
Width: 1200,
|
||||||
|
Height: 800,
|
||||||
|
Location: cst,
|
||||||
|
Projection: EclipseMapProjectionEquirectangular,
|
||||||
|
PartialStep: 2 * time.Minute,
|
||||||
|
}
|
||||||
|
diagram, ok := SolarEclipseMapSVG(date, options)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 2012 annular solar-eclipse map")
|
||||||
|
}
|
||||||
|
frame := solarEclipseMapLayoutFor(
|
||||||
|
normalizeSolarEclipseMapSVGOptions(date, options),
|
||||||
|
svgmap.ProjectionEquirectangular,
|
||||||
|
).frame
|
||||||
|
|
||||||
|
for _, point := range []svgmap.GeoPoint{
|
||||||
|
{Longitude: -170, Latitude: -50},
|
||||||
|
{Longitude: 170, Latitude: -50},
|
||||||
|
} {
|
||||||
|
if solarPartialRegionContainsGeoPoint(t, diagram, frame, point) {
|
||||||
|
t.Fatalf("known invisible point %#v is inside the rendered partial-eclipse region", point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visible := svgmap.GeoPoint{Longitude: 0, Latitude: 85}
|
||||||
|
if !solarPartialRegionContainsGeoPoint(t, diagram, frame, visible) {
|
||||||
|
t.Fatalf("known visible Arctic point %#v is outside the rendered partial-eclipse region", visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVG2012SupportsNorthPolarProjection(t *testing.T) {
|
||||||
|
diagram, ok := SolarEclipseMapSVG(
|
||||||
|
time.Date(2012, 5, 21, 0, 0, 0, 0, time.UTC),
|
||||||
|
SolarEclipseMapSVGOptions{
|
||||||
|
Projection: EclipseMapProjectionNorthPolar,
|
||||||
|
PartialStep: 10 * time.Minute,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 2012 annular solar-eclipse north-polar map")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`<circle class="map-ocean"`, `<circle class="map-frame"`,
|
||||||
|
"北极方位等距投影", `class="central-eclipse-band"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(diagram, want) {
|
||||||
|
t.Fatalf("2012 north-polar solar-eclipse map missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateEclipseMapXML(diagram); err != nil {
|
||||||
|
t.Fatalf("2012 north-polar solar-eclipse map is not valid XML: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolarEclipseMapSVGRejectsNoEventAndInvalidProjection(t *testing.T) {
|
||||||
|
if _, ok := SolarEclipseMapSVG(time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), SolarEclipseMapSVGOptions{}); ok {
|
||||||
|
t.Fatal("unexpected solar-eclipse map for a no-event date")
|
||||||
|
}
|
||||||
|
if _, ok := SolarEclipseMapSVG(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), SolarEclipseMapSVGOptions{Projection: "invalid"}); ok {
|
||||||
|
t.Fatal("invalid projection was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarPartialRegionContainsGeoPoint(
|
||||||
|
t *testing.T,
|
||||||
|
diagram string,
|
||||||
|
frame svgmap.Frame,
|
||||||
|
point svgmap.GeoPoint,
|
||||||
|
) bool {
|
||||||
|
t.Helper()
|
||||||
|
const prefix = `<path class="partial-eclipse-region" d="`
|
||||||
|
start := strings.Index(diagram, prefix)
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatal("partial-eclipse SVG path is missing")
|
||||||
|
}
|
||||||
|
value := diagram[start+len(prefix):]
|
||||||
|
end := strings.IndexByte(value, '"')
|
||||||
|
if end < 0 {
|
||||||
|
t.Fatal("partial-eclipse SVG path is malformed")
|
||||||
|
}
|
||||||
|
polygons := solarSVGPathPolygons(t, value[:end])
|
||||||
|
x, y, ok := frame.Project(point.Longitude, point.Latitude)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, polygon := range polygons {
|
||||||
|
if solarSVGPointInPolygon(x, y, polygon) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarSVGPathPolygons(t *testing.T, path string) [][][2]float64 {
|
||||||
|
t.Helper()
|
||||||
|
fields := strings.Fields(path)
|
||||||
|
polygons := make([][][2]float64, 0)
|
||||||
|
var current [][2]float64
|
||||||
|
for index := 0; index < len(fields); {
|
||||||
|
switch fields[index] {
|
||||||
|
case "M":
|
||||||
|
if len(current) > 0 {
|
||||||
|
polygons = append(polygons, current)
|
||||||
|
}
|
||||||
|
current = nil
|
||||||
|
index++
|
||||||
|
case "L":
|
||||||
|
index++
|
||||||
|
case "Z":
|
||||||
|
if len(current) > 0 {
|
||||||
|
polygons = append(polygons, current)
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
default:
|
||||||
|
if index+1 >= len(fields) {
|
||||||
|
t.Fatalf("incomplete SVG coordinate at token %d", index)
|
||||||
|
}
|
||||||
|
x, err := strconv.ParseFloat(fields[index], 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invalid SVG x coordinate %q: %v", fields[index], err)
|
||||||
|
}
|
||||||
|
y, err := strconv.ParseFloat(fields[index+1], 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invalid SVG y coordinate %q: %v", fields[index+1], err)
|
||||||
|
}
|
||||||
|
current = append(current, [2]float64{x, y})
|
||||||
|
index += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(current) > 0 {
|
||||||
|
polygons = append(polygons, current)
|
||||||
|
}
|
||||||
|
return polygons
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarSVGPointInPolygon(x, y float64, polygon [][2]float64) bool {
|
||||||
|
inside := false
|
||||||
|
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
|
||||||
|
a, b := polygon[current], polygon[previous]
|
||||||
|
if (a[1] > y) != (b[1] > y) && x < (b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0] {
|
||||||
|
inside = !inside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
package geojson
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/basic"
|
||||||
|
eclipsecore "b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/internal/geodata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateSolarEclipseInput(
|
||||||
|
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||||
|
central *eclipsecore.SolarEclipsePath,
|
||||||
|
) error {
|
||||||
|
info := partial.Eclipse
|
||||||
|
if info.GreatestEclipse.IsZero() {
|
||||||
|
return fmt.Errorf("geojson: solar eclipse greatest time is required")
|
||||||
|
}
|
||||||
|
if !info.HasPartial {
|
||||||
|
return fmt.Errorf("geojson: solar eclipse must contain a partial phase")
|
||||||
|
}
|
||||||
|
if info.PartialBeginOnEarth.IsZero() || info.PartialEndOnEarth.IsZero() {
|
||||||
|
return fmt.Errorf("geojson: solar eclipse partial contact times are required")
|
||||||
|
}
|
||||||
|
if !info.PartialBeginOnEarth.Before(info.GreatestEclipse) ||
|
||||||
|
!info.GreatestEclipse.Before(info.PartialEndOnEarth) {
|
||||||
|
return fmt.Errorf("geojson: solar eclipse times must be ordered partial begin, greatest, partial end")
|
||||||
|
}
|
||||||
|
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 !previous.IsZero() && !footprint.Time.After(previous) {
|
||||||
|
return fmt.Errorf("geojson: solar partial footprint times must be strictly increasing")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
previous = footprint.Time
|
||||||
|
}
|
||||||
|
if central == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !central.Eclipse.GreatestEclipse.Equal(info.GreatestEclipse) ||
|
||||||
|
central.Eclipse.Type != info.Type || central.Eclipse.Model != info.Model {
|
||||||
|
return fmt.Errorf("geojson: partial footprints and central path describe different eclipses")
|
||||||
|
}
|
||||||
|
if central.Eclipse.CentralBeginOnEarth.IsZero() || central.Eclipse.CentralEndOnEarth.IsZero() ||
|
||||||
|
!central.Eclipse.CentralBeginOnEarth.Before(central.Eclipse.GreatestEclipse) ||
|
||||||
|
!central.Eclipse.GreatestEclipse.Before(central.Eclipse.CentralEndOnEarth) {
|
||||||
|
return fmt.Errorf("geojson: solar central path contact times are invalid")
|
||||||
|
}
|
||||||
|
if err := validateSolarPathPoint("solar central greatest", central.Greatest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !central.Greatest.Time.Equal(central.Eclipse.GreatestEclipse) {
|
||||||
|
return fmt.Errorf("geojson: solar central greatest time does not match eclipse greatest")
|
||||||
|
}
|
||||||
|
if err := validateSolarPathSeries("solar center line", central.CenterLine, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if central.Greatest.Time.Before(central.CenterLine[0].Time) ||
|
||||||
|
central.Greatest.Time.After(central.CenterLine[len(central.CenterLine)-1].Time) {
|
||||||
|
return fmt.Errorf("geojson: solar greatest time is outside the center-line interval")
|
||||||
|
}
|
||||||
|
if central.CenterLine[0].Time.Before(central.Eclipse.CentralBeginOnEarth) ||
|
||||||
|
central.CenterLine[len(central.CenterLine)-1].Time.After(central.Eclipse.CentralEndOnEarth) {
|
||||||
|
return fmt.Errorf("geojson: solar center line is outside the central interval")
|
||||||
|
}
|
||||||
|
if len(central.NorthernLimit) != len(central.SouthernLimit) {
|
||||||
|
return fmt.Errorf("geojson: solar central limits must have the same sample count")
|
||||||
|
}
|
||||||
|
if len(central.NorthernLimit) > 0 {
|
||||||
|
if err := validateSolarPathSeries("solar northern limit", central.NorthernLimit, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateSolarPathSeries("solar southern limit", central.SouthernLimit, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for index := range central.NorthernLimit {
|
||||||
|
if !central.NorthernLimit[index].Time.Equal(central.SouthernLimit[index].Time) {
|
||||||
|
return fmt.Errorf("geojson: solar central limit sample %d times must match", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
previous := time.Time{}
|
||||||
|
for index, point := range points {
|
||||||
|
if err := validateSolarPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !previous.IsZero() && !point.Time.After(previous) {
|
||||||
|
return fmt.Errorf("geojson: %s times must be strictly increasing", name)
|
||||||
|
}
|
||||||
|
previous = point.Time
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSolarPathPoint(name string, point eclipsecore.SolarEclipsePathPoint) error {
|
||||||
|
if point.Time.IsZero() {
|
||||||
|
return fmt.Errorf("geojson: %s time is required", name)
|
||||||
|
}
|
||||||
|
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||||
|
return fmt.Errorf("geojson: %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if !finiteGeoJSON(point.SunAltitude) || point.SunAltitude < -90 || point.SunAltitude > 90 {
|
||||||
|
return fmt.Errorf("geojson: %s sun altitude must be finite and within [-90, 90]", name)
|
||||||
|
}
|
||||||
|
if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 {
|
||||||
|
return fmt.Errorf("geojson: %s width must be finite and non-negative", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLunarEclipseInfo(info eclipsecore.LunarEclipseInfo) error {
|
||||||
|
if !info.HasPenumbral || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
|
||||||
|
return fmt.Errorf("geojson: lunar eclipse penumbral contact times are required")
|
||||||
|
}
|
||||||
|
if info.Maximum.IsZero() {
|
||||||
|
return fmt.Errorf("geojson: lunar eclipse greatest time is required")
|
||||||
|
}
|
||||||
|
if info.Type != eclipsecore.LunarEclipsePenumbral && info.Type != eclipsecore.LunarEclipsePartial &&
|
||||||
|
info.Type != eclipsecore.LunarEclipseTotal {
|
||||||
|
return fmt.Errorf("geojson: lunar eclipse type is invalid")
|
||||||
|
}
|
||||||
|
switch info.Type {
|
||||||
|
case eclipsecore.LunarEclipsePenumbral:
|
||||||
|
if info.HasPartial || info.HasTotal {
|
||||||
|
return fmt.Errorf("geojson: penumbral eclipse cannot contain partial or total phases")
|
||||||
|
}
|
||||||
|
case eclipsecore.LunarEclipsePartial:
|
||||||
|
if !info.HasPartial || info.HasTotal {
|
||||||
|
return fmt.Errorf("geojson: partial eclipse must contain only a partial phase")
|
||||||
|
}
|
||||||
|
case eclipsecore.LunarEclipseTotal:
|
||||||
|
if !info.HasPartial || !info.HasTotal {
|
||||||
|
return fmt.Errorf("geojson: total eclipse must contain partial and total phases")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !info.HasPartial && (!info.PartialStart.IsZero() || !info.PartialEnd.IsZero()) {
|
||||||
|
return fmt.Errorf("geojson: partial contact times require a partial phase")
|
||||||
|
}
|
||||||
|
if !info.HasTotal && (!info.TotalStart.IsZero() || !info.TotalEnd.IsZero()) {
|
||||||
|
return fmt.Errorf("geojson: total contact times require a total phase")
|
||||||
|
}
|
||||||
|
ordered := []time.Time{info.PenumbralStart}
|
||||||
|
if info.HasPartial {
|
||||||
|
if info.PartialStart.IsZero() || info.PartialEnd.IsZero() {
|
||||||
|
return fmt.Errorf("geojson: lunar eclipse partial contact times are required")
|
||||||
|
}
|
||||||
|
ordered = append(ordered, info.PartialStart)
|
||||||
|
}
|
||||||
|
if info.HasTotal {
|
||||||
|
if info.TotalStart.IsZero() || info.TotalEnd.IsZero() {
|
||||||
|
return fmt.Errorf("geojson: lunar eclipse total contact times are required")
|
||||||
|
}
|
||||||
|
ordered = append(ordered, info.TotalStart)
|
||||||
|
}
|
||||||
|
ordered = append(ordered, info.Maximum)
|
||||||
|
if info.HasTotal {
|
||||||
|
ordered = append(ordered, info.TotalEnd)
|
||||||
|
}
|
||||||
|
if info.HasPartial {
|
||||||
|
ordered = append(ordered, info.PartialEnd)
|
||||||
|
}
|
||||||
|
ordered = append(ordered, info.PenumbralEnd)
|
||||||
|
for index := 1; index < len(ordered); index++ {
|
||||||
|
if !ordered[index-1].Before(ordered[index]) {
|
||||||
|
return fmt.Errorf("geojson: lunar eclipse contact times are not strictly ordered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
solarEclipseEvent = "solar-eclipse"
|
||||||
|
lunarEclipseEvent = "lunar-eclipse"
|
||||||
|
|
||||||
|
defaultLunarBoundaryPoints = 360
|
||||||
|
minimumLunarBoundaryPoints = 12
|
||||||
|
maximumLunarBoundaryPoints = 1440
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarshalSolarEclipse 将日食半影足迹和可选中心食带编码为 GeoJSON。
|
||||||
|
// MarshalSolarEclipse encodes penumbral footprints and an optional central path as GeoJSON.
|
||||||
|
func MarshalSolarEclipse(
|
||||||
|
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||||
|
central *eclipsecore.SolarEclipsePath,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return marshalSolarEclipse(partial, central, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalSolarEclipseWithTimeMarkers 编码日食,并沿中心线按固定间隔追加 Point 要素;已有要素不变,标记标签使用 options.Location,时间值保持 UTC。
|
||||||
|
// MarshalSolarEclipseWithTimeMarkers encodes a solar eclipse and adds Point Features at regular intervals along the central line. Existing features are unchanged; marker labels use options.Location while time values stay UTC.
|
||||||
|
func MarshalSolarEclipseWithTimeMarkers(
|
||||||
|
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||||
|
central *eclipsecore.SolarEclipsePath,
|
||||||
|
options TimeMarkerOptions,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return marshalSolarEclipse(partial, central, &options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalSolarEclipse(
|
||||||
|
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||||
|
central *eclipsecore.SolarEclipsePath,
|
||||||
|
markerOptions *TimeMarkerOptions,
|
||||||
|
) ([]byte, error) {
|
||||||
|
if markerOptions != nil {
|
||||||
|
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(partial.Footprints) == 0 {
|
||||||
|
return nil, fmt.Errorf("geojson: solar eclipse has no partial footprints")
|
||||||
|
}
|
||||||
|
if err := validateSolarEclipseInput(partial, central); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
properties := map[string]interface{}{
|
||||||
|
"eclipse_type": string(partial.Eclipse.Type),
|
||||||
|
"model": string(partial.Eclipse.Model),
|
||||||
|
}
|
||||||
|
features := make([]feature, 0, len(partial.Footprints)+8)
|
||||||
|
for _, footprint := range partial.Footprints {
|
||||||
|
polygon, err := solarPartialFootprintPolygon(footprint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
footprintProperties := cloneProperties(properties)
|
||||||
|
footprintProperties["time"] = formatTime(footprint.Time)
|
||||||
|
footprintProperties["source_boundary_closed"] = footprint.Closed
|
||||||
|
if len(polygon) == 1 {
|
||||||
|
value, pointErr := pointGeometry(polygon[0].Longitude, polygon[0].Latitude)
|
||||||
|
if pointErr != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), pointErr)
|
||||||
|
}
|
||||||
|
features = append(features, newFeature(
|
||||||
|
solarEclipseEvent, "partial-footprint", value, footprintProperties,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := multiPolygonGeometry([][]geodata.GeoPoint{polygon})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), err)
|
||||||
|
}
|
||||||
|
features = append(features, newFeature(
|
||||||
|
solarEclipseEvent, "partial-footprint", value, footprintProperties,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if central != nil {
|
||||||
|
if len(central.NorthernLimit) > 0 {
|
||||||
|
band, err := pairedLimitPolygon(central.NorthernLimit, central.SouthernLimit)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
features = append(features, newFeature(
|
||||||
|
solarEclipseEvent, "central-band", value, cloneProperties(properties),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
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 err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
features, err = appendSolarPathLine(features, "south-limit", central.SouthernLimit, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if markerOptions != nil {
|
||||||
|
features, err = appendTimeMarkerFeatures(
|
||||||
|
features,
|
||||||
|
solarEclipseEvent,
|
||||||
|
"center-line",
|
||||||
|
solarPathSamples(central.CenterLine),
|
||||||
|
*markerOptions,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
greatest := pathSample{
|
||||||
|
Time: partial.Eclipse.GreatestEclipse,
|
||||||
|
Longitude: partial.Eclipse.GreatestLongitude,
|
||||||
|
Latitude: partial.Eclipse.GreatestLatitude,
|
||||||
|
}
|
||||||
|
greatestProperties := solarEclipseMetadata(partial.Eclipse)
|
||||||
|
if central != nil {
|
||||||
|
greatest = solarPathSample(central.Greatest)
|
||||||
|
greatestProperties["width_km"] = central.Greatest.WidthKM
|
||||||
|
greatestProperties["sun_altitude_deg"] = central.Greatest.SunAltitude
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
features, err = appendPointFeature(
|
||||||
|
features, solarEclipseEvent, "greatest", greatest, greatestProperties,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalFeatureCollection(features)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalLunarEclipse 将月食 P1/P4 可见半球和地平线边界编码为 GeoJSON。
|
||||||
|
// MarshalLunarEclipse encodes the P1/P4 visible hemispheres and horizon boundaries as GeoJSON.
|
||||||
|
// 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) {
|
||||||
|
return marshalLunarEclipse(info, boundaryPoints, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalLunarEclipseWithTimeMarkers 编码月食,并沿半影开始到结束的月下点轨迹追加 Point 要素。
|
||||||
|
// MarshalLunarEclipseWithTimeMarkers encodes a lunar eclipse and adds Point Features along the sublunar track from penumbral start through end.
|
||||||
|
// 已有要素保持不变;标记标签使用 options.Location,时间值保持 UTC。
|
||||||
|
// Existing features are unchanged; marker labels use options.Location while time values stay UTC.
|
||||||
|
func MarshalLunarEclipseWithTimeMarkers(
|
||||||
|
info eclipsecore.LunarEclipseInfo,
|
||||||
|
boundaryPoints int,
|
||||||
|
options TimeMarkerOptions,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return marshalLunarEclipse(info, boundaryPoints, &options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalLunarEclipse(
|
||||||
|
info eclipsecore.LunarEclipseInfo,
|
||||||
|
boundaryPoints int,
|
||||||
|
markerOptions *TimeMarkerOptions,
|
||||||
|
) ([]byte, error) {
|
||||||
|
if markerOptions != nil {
|
||||||
|
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateLunarEclipseInfo(info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
boundaryPoints = normalizeLunarBoundaryPoints(boundaryPoints)
|
||||||
|
properties := map[string]interface{}{
|
||||||
|
"eclipse_type": string(info.Type),
|
||||||
|
"boundary_points": boundaryPoints,
|
||||||
|
}
|
||||||
|
features := make([]feature, 0, 5)
|
||||||
|
|
||||||
|
contacts := []struct {
|
||||||
|
role string
|
||||||
|
horizonRole string
|
||||||
|
time time.Time
|
||||||
|
}{
|
||||||
|
{role: "visible-at-p1", horizonRole: "p1-horizon", time: info.PenumbralStart},
|
||||||
|
{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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
features = append(features, newFeature(
|
||||||
|
lunarEclipseEvent,
|
||||||
|
contact.horizonRole,
|
||||||
|
horizonValue,
|
||||||
|
map[string]interface{}{
|
||||||
|
"eclipse_type": string(info.Type),
|
||||||
|
"time": formatTime(contact.time),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
maximum := lunarSubpoint(info.Maximum)
|
||||||
|
features, err := appendPointFeature(
|
||||||
|
features,
|
||||||
|
lunarEclipseEvent,
|
||||||
|
"greatest",
|
||||||
|
pathSample{Time: info.Maximum, Longitude: maximum.Longitude, Latitude: maximum.Latitude},
|
||||||
|
lunarEclipseMetadata(info),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if markerOptions != nil {
|
||||||
|
markers, markerErr := lunarEclipseTimeMarkerSamples(info, *markerOptions)
|
||||||
|
if markerErr != nil {
|
||||||
|
return nil, markerErr
|
||||||
|
}
|
||||||
|
features, err = appendTimeMarkerPointFeatures(
|
||||||
|
features,
|
||||||
|
lunarEclipseEvent,
|
||||||
|
"sublunar-track",
|
||||||
|
markers,
|
||||||
|
markerOptions.Location,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return marshalFeatureCollection(features)
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarPartialFootprintPolygon(
|
||||||
|
footprint eclipsecore.SolarEclipsePartialFootprint,
|
||||||
|
) ([]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)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(openRing(polygon)) < 3 {
|
||||||
|
return nil, fmt.Errorf("geojson: solar partial footprint polygon is incomplete")
|
||||||
|
}
|
||||||
|
return polygon, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pairedLimitPolygon(
|
||||||
|
northern, southern []eclipsecore.SolarEclipsePathPoint,
|
||||||
|
) ([]geodata.GeoPoint, error) {
|
||||||
|
if len(northern) != len(southern) {
|
||||||
|
return nil, 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")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if !northern[index].Time.Equal(southern[index].Time) {
|
||||||
|
return nil, 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})
|
||||||
|
}
|
||||||
|
for index := count - 1; index >= 0; index-- {
|
||||||
|
point := southern[index]
|
||||||
|
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||||
|
}
|
||||||
|
return polygon, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendSolarPathLine(
|
||||||
|
features []feature,
|
||||||
|
role string,
|
||||||
|
points []eclipsecore.SolarEclipsePathPoint,
|
||||||
|
properties map[string]interface{},
|
||||||
|
) ([]feature, error) {
|
||||||
|
samples := make([]pathSample, len(points))
|
||||||
|
for index, point := range points {
|
||||||
|
samples[index] = solarPathSample(point)
|
||||||
|
}
|
||||||
|
return appendTimedLineFeature(features, solarEclipseEvent, role, samples, properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarPathSamples(points []eclipsecore.SolarEclipsePathPoint) []pathSample {
|
||||||
|
samples := make([]pathSample, len(points))
|
||||||
|
for index, point := range points {
|
||||||
|
samples[index] = solarPathSample(point)
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarPathSample(point eclipsecore.SolarEclipsePathPoint) pathSample {
|
||||||
|
return pathSample{Time: point.Time, Longitude: point.Longitude, Latitude: point.Latitude}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarEclipseMetadata(info eclipsecore.SolarEclipseInfo) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"eclipse_type": string(info.Type),
|
||||||
|
"model": string(info.Model),
|
||||||
|
"centrality": string(info.Centrality),
|
||||||
|
"magnitude": info.Magnitude,
|
||||||
|
"gamma": info.Gamma,
|
||||||
|
"path_width_km": info.PathWidthKM,
|
||||||
|
"partial_begin_on_earth": formatTime(info.PartialBeginOnEarth),
|
||||||
|
"partial_end_on_earth": formatTime(info.PartialEndOnEarth),
|
||||||
|
"central_begin_on_earth": formatTime(info.CentralBeginOnEarth),
|
||||||
|
"central_end_on_earth": formatTime(info.CentralEndOnEarth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lunarEclipseMetadata(info eclipsecore.LunarEclipseInfo) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"eclipse_type": string(info.Type),
|
||||||
|
"penumbral_magnitude": info.PenumbralMagnitude,
|
||||||
|
"umbral_magnitude": info.UmbralMagnitude,
|
||||||
|
"penumbral_start": formatTime(info.PenumbralStart),
|
||||||
|
"partial_start": formatTime(info.PartialStart),
|
||||||
|
"total_start": formatTime(info.TotalStart),
|
||||||
|
"total_end": formatTime(info.TotalEnd),
|
||||||
|
"partial_end": formatTime(info.PartialEnd),
|
||||||
|
"penumbral_end": formatTime(info.PenumbralEnd),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solarSubsolarPoint(value time.Time) geodata.GeoPoint {
|
||||||
|
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
|
||||||
|
ra, dec := basic.HSunApparentRaDec(ttJDE)
|
||||||
|
utJDE := basic.TD2UT(ttJDE, false)
|
||||||
|
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
|
||||||
|
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lunarSubpoint(value time.Time) geodata.GeoPoint {
|
||||||
|
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
|
||||||
|
ra, dec := basic.HMoonTrueRaDec(ttJDE)
|
||||||
|
utJDE := basic.TD2UT(ttJDE, false)
|
||||||
|
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
|
||||||
|
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lunarEclipseTimeMarkerSamples(
|
||||||
|
info eclipsecore.LunarEclipseInfo,
|
||||||
|
options TimeMarkerOptions,
|
||||||
|
) ([]pathSample, error) {
|
||||||
|
step, err := normalizeTimeMarkerStep(options.Step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
|
||||||
|
}
|
||||||
|
location := normalizeTimeMarkerLocation(options.Location)
|
||||||
|
start, end := info.PenumbralStart, info.PenumbralEnd
|
||||||
|
capacity, err := timeMarkerCapacity(start, end, step, location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
|
||||||
|
}
|
||||||
|
current := firstTimeMarkerAfter(start, step, location)
|
||||||
|
markers := make([]pathSample, 0, capacity)
|
||||||
|
for current.Before(end) {
|
||||||
|
point := lunarSubpoint(current)
|
||||||
|
markers = append(markers, pathSample{
|
||||||
|
Time: current,
|
||||||
|
Longitude: point.Longitude,
|
||||||
|
Latitude: point.Latitude,
|
||||||
|
})
|
||||||
|
current = current.Add(step)
|
||||||
|
}
|
||||||
|
return markers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLunarBoundaryPoints(value int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return defaultLunarBoundaryPoints
|
||||||
|
}
|
||||||
|
if value < minimumLunarBoundaryPoints {
|
||||||
|
return minimumLunarBoundaryPoints
|
||||||
|
}
|
||||||
|
if value > maximumLunarBoundaryPoints {
|
||||||
|
return maximumLunarBoundaryPoints
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package geojson_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/geojson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleMarshalSolarEclipse() {
|
||||||
|
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||||
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
||||||
|
date,
|
||||||
|
eclipse.SolarEclipsePartialFootprintOptions{
|
||||||
|
Step: 10 * time.Minute,
|
||||||
|
BoundaryPoints: 180,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
central, hasCentral := eclipse.SolarEclipseCentralPath(
|
||||||
|
date,
|
||||||
|
eclipse.SolarEclipsePathOptions{
|
||||||
|
Step: time.Minute,
|
||||||
|
TargetSpacingKM: 20,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
var centralPath *eclipse.SolarEclipsePath
|
||||||
|
if hasCentral {
|
||||||
|
centralPath = ¢ral
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalSolarEclipse(partial, centralPath)
|
||||||
|
fmt.Println(err == nil, json.Valid(data))
|
||||||
|
// Output: true true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleMarshalSolarEclipseWithTimeMarkers() {
|
||||||
|
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||||
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
||||||
|
date,
|
||||||
|
eclipse.SolarEclipsePartialFootprintOptions{Step: 20 * time.Minute, BoundaryPoints: 36},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
central, ok := eclipse.SolarEclipseCentralPath(
|
||||||
|
date,
|
||||||
|
eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(
|
||||||
|
partial,
|
||||||
|
¢ral,
|
||||||
|
geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: time.FixedZone("CST", 8*60*60)},
|
||||||
|
)
|
||||||
|
fmt.Println(err == nil, json.Valid(data))
|
||||||
|
// Output: true true
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
// Package geojson 将日月食和月掩地理结果编码为 RFC 7946 GeoJSON FeatureCollections。
|
||||||
|
// 坐标是 WGS84 经度和纬度,单位为度;地图投影和样式由应用处理。
|
||||||
|
// Package geojson encodes eclipse and lunar-occultation geographic results as RFC 7946 GeoJSON FeatureCollections.
|
||||||
|
// Coordinates are WGS84 longitude and latitude in degrees; map projection and styling remain application concerns.
|
||||||
|
//
|
||||||
|
// 每个要素都包含 event 和 role 属性。带时间的 MultiLineString 要素还包含与坐标段对齐的嵌套 times 数组。
|
||||||
|
// Times 编码为 UTC RFC 3339 字符串;路径采样由上游日月食和月掩选项控制后再传入本包。
|
||||||
|
// WithTimeMarkers 变体还会追加 role 为 time-marker 的 Point 要素,标签按请求地点格式化。
|
||||||
|
// 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.
|
||||||
|
package geojson
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/internal/geodata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
featureCollectionType = "FeatureCollection"
|
||||||
|
minimumTimeMarkerStep = time.Minute
|
||||||
|
maximumTimeMarkerCount = 1440
|
||||||
|
)
|
||||||
|
|
||||||
|
type featureCollection struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Features []feature `json:"features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type feature struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Properties map[string]interface{} `json:"properties"`
|
||||||
|
Geometry geometry `json:"geometry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geometry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Coordinates interface{} `json:"coordinates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pathSample struct {
|
||||||
|
Time time.Time
|
||||||
|
Longitude float64
|
||||||
|
Latitude float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeMarkerOptions 控制供地图客户端绘制路径时间标签的可选 Point Feature。
|
||||||
|
// TimeMarkerOptions controls optional Point Features used by map clients to draw time labels along a moving event path.
|
||||||
|
// Step 控制标记间隔;零值使用 30 分钟,并对齐到下一个本地整点边界。
|
||||||
|
// Step controls the marker interval; zero uses 30 minutes and aligns markers to the next local clock boundary.
|
||||||
|
// Location 控制 HH:MM 标签,默认 UTC;底层 time 属性仍为 UTC RFC 3339。
|
||||||
|
// Location controls the HH:MM label and defaults to UTC. The underlying time property remains UTC RFC 3339.
|
||||||
|
// 正 Step 至少为一分钟,单次导出最多 1440 个标记。
|
||||||
|
// Positive Step values must be at least one minute, and one export is limited to 1440 markers.
|
||||||
|
type TimeMarkerOptions struct {
|
||||||
|
// Step 是时间标记之间的间隔;零值使用 30 分钟。
|
||||||
|
// Step is the interval between time markers; zero uses 30 minutes.
|
||||||
|
Step time.Duration
|
||||||
|
// Location 是格式化 HH:MM 标签时使用的时区;nil 使用 UTC。
|
||||||
|
// Location is the timezone used to format HH:MM labels; nil uses UTC.
|
||||||
|
Location *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalFeatureCollection(features []feature) ([]byte, error) {
|
||||||
|
if len(features) == 0 {
|
||||||
|
return nil, fmt.Errorf("geojson: no geographic features")
|
||||||
|
}
|
||||||
|
value, err := json.Marshal(featureCollection{Type: featureCollectionType, Features: features})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: encode feature collection: %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFeature(event, role string, value geometry, properties map[string]interface{}) feature {
|
||||||
|
if properties == nil {
|
||||||
|
properties = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
properties["event"] = event
|
||||||
|
properties["role"] = role
|
||||||
|
return feature{Type: "Feature", Properties: properties, Geometry: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendTimedLineFeature(
|
||||||
|
features []feature,
|
||||||
|
event, role string,
|
||||||
|
points []pathSample,
|
||||||
|
properties map[string]interface{},
|
||||||
|
) ([]feature, error) {
|
||||||
|
value, times, err := timedMultiLineGeometry(points)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: %s: %w", role, err)
|
||||||
|
}
|
||||||
|
properties = cloneProperties(properties)
|
||||||
|
properties["times"] = times
|
||||||
|
return append(features, newFeature(event, role, value, properties)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPointFeature(
|
||||||
|
features []feature,
|
||||||
|
event, role string,
|
||||||
|
point pathSample,
|
||||||
|
properties map[string]interface{},
|
||||||
|
) ([]feature, error) {
|
||||||
|
if point.Time.IsZero() {
|
||||||
|
return nil, fmt.Errorf("geojson: %s: point time is required", role)
|
||||||
|
}
|
||||||
|
value, err := pointGeometry(point.Longitude, point.Latitude)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: %s: %w", role, err)
|
||||||
|
}
|
||||||
|
properties = cloneProperties(properties)
|
||||||
|
properties["time"] = formatTime(point.Time)
|
||||||
|
return append(features, newFeature(event, role, value, properties)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendTimeMarkerFeatures(
|
||||||
|
features []feature,
|
||||||
|
event, sourceRole string,
|
||||||
|
points []pathSample,
|
||||||
|
options TimeMarkerOptions,
|
||||||
|
) ([]feature, error) {
|
||||||
|
markers, err := timeMarkerPoints(points, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: %s time markers: %w", sourceRole, err)
|
||||||
|
}
|
||||||
|
return appendTimeMarkerPointFeatures(features, event, sourceRole, markers, options.Location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTimeMarkerOptions(options TimeMarkerOptions) error {
|
||||||
|
if _, err := normalizeTimeMarkerStep(options.Step); err != nil {
|
||||||
|
return fmt.Errorf("geojson: time markers: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendTimeMarkerPointFeatures(
|
||||||
|
features []feature,
|
||||||
|
event, sourceRole string,
|
||||||
|
markers []pathSample,
|
||||||
|
location *time.Location,
|
||||||
|
) ([]feature, error) {
|
||||||
|
location = normalizeTimeMarkerLocation(location)
|
||||||
|
for _, marker := range markers {
|
||||||
|
properties := map[string]interface{}{
|
||||||
|
"source_role": sourceRole,
|
||||||
|
"label": marker.Time.In(location).Format("15:04"),
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
features, err = appendPointFeature(features, event, "time-marker", marker, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return features, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeMarkerPoints(points []pathSample, options TimeMarkerOptions) ([]pathSample, error) {
|
||||||
|
step, err := normalizeTimeMarkerStep(options.Step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(points) < 2 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
location := normalizeTimeMarkerLocation(options.Location)
|
||||||
|
for index, point := range points {
|
||||||
|
if point.Time.IsZero() {
|
||||||
|
return nil, fmt.Errorf("sample %d has a zero time", index)
|
||||||
|
}
|
||||||
|
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||||
|
return nil, fmt.Errorf("sample %d: %w", index, err)
|
||||||
|
}
|
||||||
|
if index > 0 && !point.Time.After(points[index-1].Time) {
|
||||||
|
return nil, fmt.Errorf("sample times must be strictly increasing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start, end := points[0].Time, points[len(points)-1].Time
|
||||||
|
capacity, err := timeMarkerCapacity(start, end, step, location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current := firstTimeMarkerAfter(start, step, location)
|
||||||
|
markers := make([]pathSample, 0, capacity)
|
||||||
|
segment := 1
|
||||||
|
for current.Before(end) {
|
||||||
|
for segment < len(points) && points[segment].Time.Before(current) {
|
||||||
|
segment++
|
||||||
|
}
|
||||||
|
if segment >= len(points) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a, b := points[segment-1], points[segment]
|
||||||
|
span := b.Time.Sub(a.Time)
|
||||||
|
if span > 0 {
|
||||||
|
fraction := float64(current.Sub(a.Time)) / float64(span)
|
||||||
|
markers = append(markers, interpolateTimeMarker(a, b, fraction, current))
|
||||||
|
}
|
||||||
|
current = current.Add(step)
|
||||||
|
}
|
||||||
|
return markers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTimeMarkerStep(value time.Duration) (time.Duration, error) {
|
||||||
|
if value < 0 {
|
||||||
|
return 0, fmt.Errorf("step must be zero or positive")
|
||||||
|
}
|
||||||
|
if value == 0 {
|
||||||
|
return 30 * time.Minute, nil
|
||||||
|
}
|
||||||
|
if value < minimumTimeMarkerStep {
|
||||||
|
return 0, fmt.Errorf("step must be at least %s", minimumTimeMarkerStep)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeMarkerCapacity(start, end time.Time, step time.Duration, location *time.Location) (int, error) {
|
||||||
|
if !end.After(start) {
|
||||||
|
return 0, fmt.Errorf("marker time range must be strictly increasing")
|
||||||
|
}
|
||||||
|
first := firstTimeMarkerAfter(start, step, normalizeTimeMarkerLocation(location))
|
||||||
|
if !first.Before(end) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
count := 1 + (end.Sub(first)-time.Nanosecond)/step
|
||||||
|
if count > maximumTimeMarkerCount {
|
||||||
|
return 0, fmt.Errorf("time marker count %d exceeds limit %d", count, maximumTimeMarkerCount)
|
||||||
|
}
|
||||||
|
return int(count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstTimeMarkerAfter(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)
|
||||||
|
elapsed := local.Sub(dayStart)
|
||||||
|
return dayStart.Add((elapsed/step + 1) * step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func interpolateTimeMarker(a, b pathSample, fraction float64, value time.Time) pathSample {
|
||||||
|
deltaLongitude := b.Longitude - a.Longitude
|
||||||
|
if deltaLongitude > 180 {
|
||||||
|
deltaLongitude -= 360
|
||||||
|
} else if deltaLongitude < -180 {
|
||||||
|
deltaLongitude += 360
|
||||||
|
}
|
||||||
|
return pathSample{
|
||||||
|
Time: value,
|
||||||
|
Longitude: normalizeLongitude(a.Longitude + fraction*deltaLongitude),
|
||||||
|
Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTimeMarkerLocation(location *time.Location) *time.Location {
|
||||||
|
if location == nil {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneProperties(source map[string]interface{}) map[string]interface{} {
|
||||||
|
result := make(map[string]interface{}, len(source)+2)
|
||||||
|
for key, value := range source {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointGeometry(longitude, latitude float64) (geometry, error) {
|
||||||
|
if err := validateCoordinate(longitude, latitude); err != nil {
|
||||||
|
return geometry{}, err
|
||||||
|
}
|
||||||
|
return geometry{Type: "Point", Coordinates: []float64{longitude, latitude}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
coordinates = append(coordinates, line)
|
||||||
|
times = append(times, lineTimes)
|
||||||
|
}
|
||||||
|
return geometry{Type: "MultiLineString", Coordinates: coordinates}, times, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func geoMultiLineGeometry(points []geodata.GeoPoint, closeLine bool) (geometry, error) {
|
||||||
|
if len(points) < 2 {
|
||||||
|
return geometry{}, fmt.Errorf("geojson: line requires at least two points")
|
||||||
|
}
|
||||||
|
for _, point := range points {
|
||||||
|
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||||
|
return geometry{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
geographic := append([]geodata.GeoPoint(nil), points...)
|
||||||
|
if closeLine && !geodata.SameGeoPoint(geographic[0], geographic[len(geographic)-1]) {
|
||||||
|
geographic = append(geographic, geographic[0])
|
||||||
|
}
|
||||||
|
segments := geodata.PolylineSegments(geographic, geodata.ProjectionEquirectangular)
|
||||||
|
coordinates := make([][][]float64, 0, len(segments))
|
||||||
|
for _, segment := range segments {
|
||||||
|
if len(segment) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := make([][]float64, len(segment))
|
||||||
|
for index, point := range segment {
|
||||||
|
line[index] = []float64{point.Longitude, point.Latitude}
|
||||||
|
}
|
||||||
|
coordinates = append(coordinates, line)
|
||||||
|
}
|
||||||
|
if len(coordinates) == 0 {
|
||||||
|
return geometry{}, fmt.Errorf("geojson: line has no valid segments")
|
||||||
|
}
|
||||||
|
return geometry{Type: "MultiLineString", Coordinates: coordinates}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiPolygonGeometry(polygons [][]geodata.GeoPoint) (geometry, error) {
|
||||||
|
fragments := make([][]geodata.GeoPoint, 0, len(polygons))
|
||||||
|
for index, polygon := range polygons {
|
||||||
|
polygon = openRing(polygon)
|
||||||
|
if len(polygon) < 3 {
|
||||||
|
return geometry{}, fmt.Errorf("geojson: polygon %d requires at least three points", index)
|
||||||
|
}
|
||||||
|
for _, point := range polygon {
|
||||||
|
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||||
|
return geometry{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fragments = append(fragments,
|
||||||
|
geodata.PolygonFragments(polygon, geodata.ProjectionEquirectangular)...)
|
||||||
|
}
|
||||||
|
return multiPolygonGeometryFromFragments(fragments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiPolygonGeometryFromFragments(fragments [][]geodata.GeoPoint) (geometry, error) {
|
||||||
|
coordinates := make([][][][]float64, 0, len(fragments))
|
||||||
|
for _, fragment := range fragments {
|
||||||
|
ring, err := geoJSONRing(fragment)
|
||||||
|
if err != nil {
|
||||||
|
return geometry{}, err
|
||||||
|
}
|
||||||
|
coordinates = append(coordinates, [][][]float64{ring})
|
||||||
|
}
|
||||||
|
if len(coordinates) == 0 {
|
||||||
|
return geometry{}, fmt.Errorf("geojson: polygon has no valid rings")
|
||||||
|
}
|
||||||
|
return geometry{Type: "MultiPolygon", Coordinates: coordinates}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func geoJSONRing(points []geodata.GeoPoint) ([][]float64, error) {
|
||||||
|
points = openRing(points)
|
||||||
|
if len(points) < 3 {
|
||||||
|
return nil, fmt.Errorf("geojson: polygon ring requires at least three points")
|
||||||
|
}
|
||||||
|
for _, point := range points {
|
||||||
|
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
area := polygonArea(points)
|
||||||
|
if math.Abs(area) < 1e-12 {
|
||||||
|
return nil, fmt.Errorf("geojson: polygon ring has zero area")
|
||||||
|
}
|
||||||
|
if area < 0 {
|
||||||
|
points = append([]geodata.GeoPoint(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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ring := make([][]float64, 0, len(points)+1)
|
||||||
|
for _, point := range points {
|
||||||
|
ring = append(ring, []float64{point.Longitude, point.Latitude})
|
||||||
|
}
|
||||||
|
return append(ring, []float64{points[0].Longitude, points[0].Latitude}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 polygonArea(points []geodata.GeoPoint) float64 {
|
||||||
|
area := 0.0
|
||||||
|
for index, point := range points {
|
||||||
|
next := points[(index+1)%len(points)]
|
||||||
|
area += point.Longitude*next.Latitude - next.Longitude*point.Latitude
|
||||||
|
}
|
||||||
|
return area / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitTimedLine(points []pathSample) ([][]pathSample, error) {
|
||||||
|
if len(points) < 2 {
|
||||||
|
return nil, fmt.Errorf("geojson: line requires at least two points")
|
||||||
|
}
|
||||||
|
for index, point := range points {
|
||||||
|
if point.Time.IsZero() {
|
||||||
|
return nil, fmt.Errorf("geojson: timed line contains a zero time")
|
||||||
|
}
|
||||||
|
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if index > 0 && !point.Time.After(points[index-1].Time) {
|
||||||
|
return nil, fmt.Errorf("geojson: timed line times must be strictly increasing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := make([][]pathSample, 0, 2)
|
||||||
|
current := []pathSample{points[0]}
|
||||||
|
previousUnwrapped := points[0]
|
||||||
|
worldShift := 0.0
|
||||||
|
for index := 1; index < len(points); index++ {
|
||||||
|
b := points[index]
|
||||||
|
for b.Longitude-previousUnwrapped.Longitude > 180 {
|
||||||
|
b.Longitude -= 360
|
||||||
|
}
|
||||||
|
for b.Longitude-previousUnwrapped.Longitude < -180 {
|
||||||
|
b.Longitude += 360
|
||||||
|
}
|
||||||
|
localLongitude := b.Longitude - worldShift
|
||||||
|
if localLongitude >= -180 && localLongitude <= 180 {
|
||||||
|
b.Longitude = localLongitude
|
||||||
|
current = append(current, b)
|
||||||
|
previousUnwrapped = points[index]
|
||||||
|
previousUnwrapped.Longitude = b.Longitude + worldShift
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
boundary := 180.0
|
||||||
|
if localLongitude < -180 {
|
||||||
|
boundary = -180
|
||||||
|
}
|
||||||
|
unwrappedBoundary := boundary + worldShift
|
||||||
|
fraction := (unwrappedBoundary - previousUnwrapped.Longitude) /
|
||||||
|
(b.Longitude - previousUnwrapped.Longitude)
|
||||||
|
crossing := interpolatePathSample(previousUnwrapped, b, fraction, boundary)
|
||||||
|
if !crossing.Time.Equal(current[len(current)-1].Time) {
|
||||||
|
current = append(current, crossing)
|
||||||
|
}
|
||||||
|
if len(current) >= 2 {
|
||||||
|
segments = append(segments, current)
|
||||||
|
}
|
||||||
|
crossing.Longitude = -boundary
|
||||||
|
if boundary > 0 {
|
||||||
|
worldShift += 360
|
||||||
|
} else {
|
||||||
|
worldShift -= 360
|
||||||
|
}
|
||||||
|
b.Longitude -= worldShift
|
||||||
|
current = []pathSample{crossing, b}
|
||||||
|
previousUnwrapped = points[index]
|
||||||
|
previousUnwrapped.Longitude = b.Longitude + worldShift
|
||||||
|
}
|
||||||
|
if len(current) >= 2 {
|
||||||
|
segments = append(segments, current)
|
||||||
|
}
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return nil, fmt.Errorf("geojson: line has no valid segments")
|
||||||
|
}
|
||||||
|
return segments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func interpolatePathSample(a, b pathSample, fraction, longitude float64) pathSample {
|
||||||
|
duration := b.Time.Sub(a.Time)
|
||||||
|
return pathSample{
|
||||||
|
Time: a.Time.Add(time.Duration(float64(duration) * fraction)),
|
||||||
|
Longitude: longitude,
|
||||||
|
Latitude: a.Latitude + (b.Latitude-a.Latitude)*fraction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCoordinate(longitude, latitude float64) error {
|
||||||
|
if math.IsNaN(longitude) || math.IsInf(longitude, 0) || longitude < -180 || longitude > 180 {
|
||||||
|
return fmt.Errorf("geojson: longitude must be finite and within [-180, 180]")
|
||||||
|
}
|
||||||
|
if math.IsNaN(latitude) || math.IsInf(latitude, 0) || latitude < -90 || latitude > 90 {
|
||||||
|
return fmt.Errorf("geojson: latitude must be finite and within [-90, 90]")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finiteGeoJSON(value float64) bool {
|
||||||
|
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLongitude(value float64) float64 {
|
||||||
|
value = math.Mod(value+180, 360)
|
||||||
|
if value < 0 {
|
||||||
|
value += 360
|
||||||
|
}
|
||||||
|
return value - 180
|
||||||
|
}
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
package geojson_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/eclipse"
|
||||||
|
"b612.me/astro/geojson"
|
||||||
|
"b612.me/astro/moon"
|
||||||
|
)
|
||||||
|
|
||||||
|
type decodedCollection struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Features []decodedFeature `json:"features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type decodedFeature struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Properties map[string]interface{} `json:"properties"`
|
||||||
|
Geometry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Coordinates json.RawMessage `json:"coordinates"`
|
||||||
|
} `json:"geometry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Step: 20 * time.Minute,
|
||||||
|
BoundaryPoints: 36,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected solar partial footprints")
|
||||||
|
}
|
||||||
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
|
||||||
|
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)
|
||||||
|
assertRoles(t, collection,
|
||||||
|
"partial-footprint", "central-band", "center-line", "north-limit", "south-limit", "greatest")
|
||||||
|
assertCollectionCoordinates(t, collection)
|
||||||
|
assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band"))
|
||||||
|
assertTimedLineAligned(t, featureWithRole(t, collection, "center-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{
|
||||||
|
Step: 20 * time.Minute, BoundaryPoints: 24,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected solar partial footprints")
|
||||||
|
}
|
||||||
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 10 * time.Minute})
|
||||||
|
if !ok || central.Eclipse.Centrality != eclipse.SolarEclipseCentralOneLimit {
|
||||||
|
t.Fatalf("expected one-limit central eclipse, got ok=%v centrality=%s", ok, central.Eclipse.Centrality)
|
||||||
|
}
|
||||||
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||||
|
if err != nil {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalSolarEclipseAllowsLowSampleOpenFootprints(t *testing.T) {
|
||||||
|
for _, fixture := range []struct {
|
||||||
|
date time.Time
|
||||||
|
step time.Duration
|
||||||
|
}{
|
||||||
|
{time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
|
||||||
|
{time.Date(2025, time.March, 29, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
|
||||||
|
} {
|
||||||
|
partial, ok := eclipse.SolarEclipsePartialFootprints(fixture.date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||||
|
Step: fixture.step, BoundaryPoints: 12,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s: expected solar partial footprints", fixture.date.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalSolarEclipse(partial, nil); err != nil {
|
||||||
|
t.Fatalf("%s low-sample GeoJSON: %v", fixture.date.Format("2006-01-02"), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalSolarEclipseWithTimeMarkers(t *testing.T) {
|
||||||
|
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||||
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||||
|
Step: 20 * time.Minute,
|
||||||
|
BoundaryPoints: 36,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected solar partial footprints")
|
||||||
|
}
|
||||||
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected solar central path")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, ¢ral, geojson.TimeMarkerOptions{
|
||||||
|
Step: time.Hour,
|
||||||
|
Location: time.FixedZone("CST", 8*60*60),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalSolarEclipseWithTimeMarkers: %v", err)
|
||||||
|
}
|
||||||
|
collection := decodeCollection(t, data)
|
||||||
|
markers := featuresWithRole(collection, "time-marker")
|
||||||
|
if len(markers) == 0 {
|
||||||
|
t.Fatal("solar eclipse has no time markers")
|
||||||
|
}
|
||||||
|
for _, marker := range markers {
|
||||||
|
if marker.Properties["source_role"] != "center-line" {
|
||||||
|
t.Fatalf("time marker source_role=%v, want center-line", marker.Properties["source_role"])
|
||||||
|
}
|
||||||
|
label, ok := marker.Properties["label"].(string)
|
||||||
|
if !ok || len(label) != len("15:04") || label[2] != ':' {
|
||||||
|
t.Fatalf("invalid time marker label %q", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalLunarEclipseUsesRequestedBoundarySampling(t *testing.T) {
|
||||||
|
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected lunar eclipse")
|
||||||
|
}
|
||||||
|
data, err := geojson.MarshalLunarEclipse(info, 24)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalLunarEclipse: %v", err)
|
||||||
|
}
|
||||||
|
collection := decodeCollection(t, data)
|
||||||
|
assertRoles(t, collection, "visible-at-p1", "visible-at-p4", "p1-horizon", "p4-horizon", "greatest")
|
||||||
|
assertCollectionCoordinates(t, collection)
|
||||||
|
visible := featureWithRole(t, collection, "visible-at-p1")
|
||||||
|
if got := visible.Properties["boundary_points"]; got != float64(24) {
|
||||||
|
t.Fatalf("boundary_points=%v, want 24", got)
|
||||||
|
}
|
||||||
|
assertClosedMultiPolygon(t, visible)
|
||||||
|
horizon := featureWithRole(t, collection, "p1-horizon")
|
||||||
|
var lines [][][]float64
|
||||||
|
if err := json.Unmarshal(horizon.Geometry.Coordinates, &lines); err != nil {
|
||||||
|
t.Fatalf("decode P1 horizon: %v", err)
|
||||||
|
}
|
||||||
|
pointCount := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
pointCount += len(line)
|
||||||
|
}
|
||||||
|
if pointCount < 24 {
|
||||||
|
t.Fatalf("P1 horizon has %d points, want at least 24", pointCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalLunarEclipseWithTimeMarkers(t *testing.T) {
|
||||||
|
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected lunar eclipse")
|
||||||
|
}
|
||||||
|
data, err := geojson.MarshalLunarEclipseWithTimeMarkers(info, 24, geojson.TimeMarkerOptions{Step: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalLunarEclipseWithTimeMarkers: %v", err)
|
||||||
|
}
|
||||||
|
collection := decodeCollection(t, data)
|
||||||
|
markers := featuresWithRole(collection, "time-marker")
|
||||||
|
if len(markers) == 0 {
|
||||||
|
t.Fatal("lunar eclipse has no time markers")
|
||||||
|
}
|
||||||
|
for _, marker := range markers {
|
||||||
|
if marker.Properties["source_role"] != "sublunar-track" {
|
||||||
|
t.Fatalf("time marker source_role=%v, want sublunar-track", marker.Properties["source_role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
firstLabel, _ := markers[0].Properties["label"].(string)
|
||||||
|
lastLabel, _ := markers[len(markers)-1].Properties["label"].(string)
|
||||||
|
if firstLabel != "09:00" || lastLabel != "14:00" {
|
||||||
|
t.Fatalf("lunar marker endpoints = %q..%q, want 09:00..14:00", firstLabel, lastLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalLunarEclipseRejectsInvalidContactOrder(t *testing.T) {
|
||||||
|
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected lunar eclipse")
|
||||||
|
}
|
||||||
|
info.Maximum = info.PenumbralStart.Add(-time.Minute)
|
||||||
|
if _, err := geojson.MarshalLunarEclipse(info, 24); err == nil {
|
||||||
|
t.Fatal("reversed lunar eclipse contacts were accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalStarOccultationSplitsAntimeridian(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||||
|
center := occultationSamples(start, []float64{160, 175, -175, -160}, []float64{8, 4, 0, -4})
|
||||||
|
north := occultationSamples(start, []float64{158, 174, -174, -158}, []float64{18, 14, 10, 6})
|
||||||
|
south := occultationSamples(start, []float64{162, 176, -176, -162}, []float64{-2, -6, -10, -14})
|
||||||
|
path := moon.StarOccultationPath{
|
||||||
|
TargetID: "HR 4799",
|
||||||
|
Start: north[0],
|
||||||
|
Greatest: center[2],
|
||||||
|
End: north[len(north)-1],
|
||||||
|
Complete: true,
|
||||||
|
CenterLine: center,
|
||||||
|
NorthernLimit: north,
|
||||||
|
SouthernLimit: south,
|
||||||
|
Step: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalStarOccultation(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
||||||
|
}
|
||||||
|
collection := decodeCollection(t, data)
|
||||||
|
assertRoles(t, collection, "occultation-band", "center-line", "north-limit", "south-limit", "start", "greatest", "end")
|
||||||
|
assertCollectionCoordinates(t, collection)
|
||||||
|
centerFeature := featureWithRole(t, collection, "center-line")
|
||||||
|
var lines [][][]float64
|
||||||
|
if err := json.Unmarshal(centerFeature.Geometry.Coordinates, &lines); err != nil {
|
||||||
|
t.Fatalf("decode center line: %v", err)
|
||||||
|
}
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Fatalf("center line has %d antimeridian segments, want 2", len(lines))
|
||||||
|
}
|
||||||
|
for _, line := range lines {
|
||||||
|
for index := 1; index < len(line); index++ {
|
||||||
|
if math.Abs(line[index][0]-line[index-1][0]) > 180 {
|
||||||
|
t.Fatalf("center line still crosses antimeridian: %#v", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTimedLineAligned(t, centerFeature)
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
north := occultationSamples(start, []float64{-179, 179, 178}, []float64{12, 11, 10})
|
||||||
|
south := occultationSamples(start, []float64{-179, 179, 178}, []float64{-8, -9, -10})
|
||||||
|
path := moon.StarOccultationPath{
|
||||||
|
TargetID: "exact-antimeridian", Start: north[0], Greatest: center[1], End: north[2],
|
||||||
|
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south,
|
||||||
|
Step: time.Hour,
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalStarOccultation(path); err != nil {
|
||||||
|
t.Fatalf("exact antimeridian path: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalStarOccultationWithTimeMarkers(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||||
|
center := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{2, 1, 0, -1})
|
||||||
|
north := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{12, 11, 10, 9})
|
||||||
|
south := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{-8, -9, -10, -11})
|
||||||
|
path := moon.StarOccultationPath{
|
||||||
|
TargetID: "HR 4799",
|
||||||
|
Start: north[0],
|
||||||
|
Greatest: center[1],
|
||||||
|
End: north[len(north)-1],
|
||||||
|
Complete: true,
|
||||||
|
CenterLine: center,
|
||||||
|
NorthernLimit: north,
|
||||||
|
SouthernLimit: south,
|
||||||
|
Step: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Hour})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalStarOccultationWithTimeMarkers: %v", err)
|
||||||
|
}
|
||||||
|
collection := decodeCollection(t, data)
|
||||||
|
markers := featuresWithRole(collection, "time-marker")
|
||||||
|
if len(markers) != 3 {
|
||||||
|
t.Fatalf("got %d time markers, want 3", len(markers))
|
||||||
|
}
|
||||||
|
for _, marker := range markers {
|
||||||
|
if marker.Properties["source_role"] != "center-line" {
|
||||||
|
t.Fatalf("time marker source_role=%v, want center-line", marker.Properties["source_role"])
|
||||||
|
}
|
||||||
|
if marker.Geometry.Type != "Point" {
|
||||||
|
t.Fatalf("time marker geometry=%q, want Point", marker.Geometry.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
north := occultationSamples(start, []float64{168, -168}, []float64{12, 10})
|
||||||
|
south := occultationSamples(start, []float64{172, -172}, []float64{-8, -10})
|
||||||
|
path := moon.StarOccultationPath{
|
||||||
|
TargetID: "HR 4799",
|
||||||
|
Start: north[0],
|
||||||
|
Greatest: center[0],
|
||||||
|
End: north[len(north)-1],
|
||||||
|
Complete: true,
|
||||||
|
CenterLine: center,
|
||||||
|
NorthernLimit: north,
|
||||||
|
SouthernLimit: south,
|
||||||
|
Step: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: 15 * time.Minute})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalStarOccultationWithTimeMarkers: %v", err)
|
||||||
|
}
|
||||||
|
markers := featuresWithRole(decodeCollection(t, data), "time-marker")
|
||||||
|
if len(markers) != 3 {
|
||||||
|
t.Fatalf("got %d time markers, want 3", len(markers))
|
||||||
|
}
|
||||||
|
for _, marker := range markers {
|
||||||
|
var coordinate []float64
|
||||||
|
if err := json.Unmarshal(marker.Geometry.Coordinates, &coordinate); err != nil {
|
||||||
|
t.Fatalf("decode time marker: %v", err)
|
||||||
|
}
|
||||||
|
if math.Abs(coordinate[0]) < 170 {
|
||||||
|
t.Fatalf("time marker crossed through longitude %.6f instead of the antimeridian", coordinate[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalPlanetOccultationIncludesPartialAndTotalFootprints(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||||||
|
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||||||
|
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||||||
|
totalNorth := occultationSamples(start, []float64{22, 30, 38}, []float64{8, 7, 6})
|
||||||
|
totalSouth := occultationSamples(start, []float64{22, 30, 38}, []float64{-4, -5, -6})
|
||||||
|
for index := range totalNorth {
|
||||||
|
at := start.Add(time.Duration(index+1) * 30 * time.Minute)
|
||||||
|
totalNorth[index].Time = at
|
||||||
|
totalSouth[index].Time = at
|
||||||
|
}
|
||||||
|
path := moon.PlanetOccultationPath{
|
||||||
|
Planet: moon.OccultationSaturn,
|
||||||
|
TargetID: "Saturn",
|
||||||
|
Start: north[0],
|
||||||
|
Greatest: center[1],
|
||||||
|
End: north[len(north)-1],
|
||||||
|
Complete: true,
|
||||||
|
CenterLine: center,
|
||||||
|
NorthernLimit: north,
|
||||||
|
SouthernLimit: south,
|
||||||
|
PartialFootprints: []moon.PlanetOccultationFootprint{sampleFootprint(start.Add(time.Hour), 18, -10, 42, 14)},
|
||||||
|
HasTotalBand: true,
|
||||||
|
TotalStart: totalNorth[0],
|
||||||
|
TotalEnd: totalNorth[len(totalNorth)-1],
|
||||||
|
TotalComplete: true,
|
||||||
|
NorthernTotalLimit: totalNorth,
|
||||||
|
SouthernTotalLimit: totalSouth,
|
||||||
|
TotalFootprints: []moon.PlanetOccultationFootprint{sampleFootprint(start.Add(time.Hour), 23, -5, 37, 9)},
|
||||||
|
GreatestTotalWidthKM: 2500,
|
||||||
|
Step: time.Hour,
|
||||||
|
TargetSpacingKM: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalPlanetOccultation(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
||||||
|
}
|
||||||
|
collection := decodeCollection(t, data)
|
||||||
|
assertRoles(t, collection,
|
||||||
|
"partial-footprint", "total-footprint", "center-line", "north-limit", "south-limit",
|
||||||
|
"north-total-limit", "south-total-limit", "start", "total-start", "greatest", "total-end", "end")
|
||||||
|
assertCollectionCoordinates(t, collection)
|
||||||
|
if got := featureWithRole(t, collection, "greatest").Properties["planet"]; got != "saturn" {
|
||||||
|
t.Fatalf("planet=%v, want saturn", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalPlanetOccultationAllowsMissingCenterLine(t *testing.T) {
|
||||||
|
start := time.Date(2024, time.September, 5, 0, 0, 0, 0, time.UTC)
|
||||||
|
paths, err := moon.FindPlanetOccultationPaths(
|
||||||
|
start, start.AddDate(0, 0, 1), moon.OccultationVenus, moon.OccultationPathOptions{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlanetOccultationPaths: %v", err)
|
||||||
|
}
|
||||||
|
if len(paths) != 1 || len(paths[0].CenterLine) != 0 {
|
||||||
|
t.Fatalf("unexpected Venus path count/center line: paths=%d center=%d", len(paths), len(paths[0].CenterLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := geojson.MarshalPlanetOccultationWithTimeMarkers(
|
||||||
|
paths[0], geojson.TimeMarkerOptions{Step: 30 * time.Minute},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalPlanetOccultationWithTimeMarkers: %v", err)
|
||||||
|
}
|
||||||
|
collection := decodeCollection(t, data)
|
||||||
|
if len(featuresWithRole(collection, "center-line")) != 0 || len(featuresWithRole(collection, "time-marker")) != 0 {
|
||||||
|
t.Fatal("edge-only planetary path contains center-line features")
|
||||||
|
}
|
||||||
|
assertRoles(t, collection, "north-limit", "south-limit", "start", "greatest", "end")
|
||||||
|
}
|
||||||
|
|
||||||
|
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{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected solar partial footprints")
|
||||||
|
}
|
||||||
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected solar central path")
|
||||||
|
}
|
||||||
|
central.SouthernLimit = central.SouthernLimit[:len(central.SouthernLimit)-1]
|
||||||
|
if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err == nil {
|
||||||
|
t.Fatal("misaligned solar limits were accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalStarOccultationRejectsInvalidPathData(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||||
|
valid := sampleStarOccultationPath(start)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*moon.StarOccultationPath)
|
||||||
|
}{
|
||||||
|
{name: "misaligned limits", mutate: func(path *moon.StarOccultationPath) {
|
||||||
|
path.SouthernLimit = path.SouthernLimit[:len(path.SouthernLimit)-1]
|
||||||
|
}},
|
||||||
|
{name: "mismatched limit times", mutate: func(path *moon.StarOccultationPath) {
|
||||||
|
path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second)
|
||||||
|
}},
|
||||||
|
{name: "non-monotonic line", mutate: func(path *moon.StarOccultationPath) {
|
||||||
|
path.CenterLine[1].Time = path.CenterLine[0].Time
|
||||||
|
}},
|
||||||
|
{name: "zero event time", mutate: func(path *moon.StarOccultationPath) {
|
||||||
|
path.Start.Time = time.Time{}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := valid
|
||||||
|
path.CenterLine = append([]moon.OccultationPathPoint(nil), valid.CenterLine...)
|
||||||
|
path.NorthernLimit = append([]moon.OccultationPathPoint(nil), valid.NorthernLimit...)
|
||||||
|
path.SouthernLimit = append([]moon.OccultationPathPoint(nil), valid.SouthernLimit...)
|
||||||
|
test.mutate(&path)
|
||||||
|
if _, err := geojson.MarshalStarOccultation(path); err == nil {
|
||||||
|
t.Fatal("invalid stellar occultation path was accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalPlanetOccultationRejectsInvalidFootprintPolygon(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
path := samplePlanetOccultationPath(start)
|
||||||
|
path.PartialFootprints = []moon.PlanetOccultationFootprint{sampleFootprint(start, 10, -10, 20, 10)}
|
||||||
|
path.PartialFootprints[0].Polygons = append(path.PartialFootprints[0].Polygons, []moon.OccultationPathPoint{
|
||||||
|
{Longitude: 30, Latitude: 0}, {Longitude: 31, Latitude: 0},
|
||||||
|
})
|
||||||
|
if _, err := geojson.MarshalPlanetOccultation(path); err == nil {
|
||||||
|
t.Fatal("invalid footprint polygon was silently dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalFunctionsRejectIncompleteInput(t *testing.T) {
|
||||||
|
if _, err := geojson.MarshalSolarEclipse(eclipse.SolarEclipsePartialFootprintsInfo{}, nil); err == nil {
|
||||||
|
t.Fatal("empty solar eclipse input was accepted")
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalLunarEclipse(eclipse.LunarEclipseInfo{}, 360); err == nil {
|
||||||
|
t.Fatal("empty lunar eclipse input was accepted")
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalStarOccultation(moon.StarOccultationPath{}); err == nil {
|
||||||
|
t.Fatal("empty stellar occultation input was accepted")
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalPlanetOccultation(moon.PlanetOccultationPath{}); err == nil {
|
||||||
|
t.Fatal("empty planetary occultation input was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeMarkerOptionsRejectNegativeStep(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||||
|
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||||||
|
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||||||
|
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||||||
|
path := moon.StarOccultationPath{
|
||||||
|
TargetID: "HR 4799",
|
||||||
|
Start: north[0],
|
||||||
|
Greatest: center[1],
|
||||||
|
End: north[len(north)-1],
|
||||||
|
Complete: true,
|
||||||
|
CenterLine: center,
|
||||||
|
NorthernLimit: north,
|
||||||
|
SouthernLimit: south,
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: -time.Minute}); err == nil {
|
||||||
|
t.Fatal("negative time-marker step was accepted")
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||||||
|
t.Fatal("sub-minute time-marker step was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
excessiveEnd := path.CenterLine[0].Time.Add(24*time.Hour + 2*time.Minute)
|
||||||
|
path.End.Time = excessiveEnd
|
||||||
|
path.CenterLine[len(path.CenterLine)-1].Time = excessiveEnd
|
||||||
|
path.NorthernLimit[len(path.NorthernLimit)-1].Time = excessiveEnd
|
||||||
|
path.SouthernLimit[len(path.SouthernLimit)-1].Time = excessiveEnd
|
||||||
|
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Minute}); err == nil {
|
||||||
|
t.Fatal("excessive time-marker count was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeMarkerOptionsAreValidatedWithoutCenterLine(t *testing.T) {
|
||||||
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||||
|
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||||||
|
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||||||
|
path := moon.StarOccultationPath{
|
||||||
|
TargetID: "edge-only", Start: north[0], Greatest: north[1], End: north[2], Complete: true,
|
||||||
|
NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||||||
|
t.Fatal("edge-only stellar path accepted sub-minute markers")
|
||||||
|
}
|
||||||
|
planet := moon.PlanetOccultationPath{
|
||||||
|
Planet: moon.OccultationVenus, TargetID: "Venus", Start: north[0], Greatest: north[1], End: north[2],
|
||||||
|
Complete: true, NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||||||
|
}
|
||||||
|
if _, err := geojson.MarshalPlanetOccultationWithTimeMarkers(planet, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||||||
|
t.Fatal("edge-only planetary path accepted sub-minute markers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCollection(t *testing.T, data []byte) decodedCollection {
|
||||||
|
t.Helper()
|
||||||
|
var collection decodedCollection
|
||||||
|
if err := json.Unmarshal(data, &collection); err != nil {
|
||||||
|
t.Fatalf("decode GeoJSON: %v", err)
|
||||||
|
}
|
||||||
|
if collection.Type != "FeatureCollection" {
|
||||||
|
t.Fatalf("collection type=%q, want FeatureCollection", collection.Type)
|
||||||
|
}
|
||||||
|
if len(collection.Features) == 0 {
|
||||||
|
t.Fatal("GeoJSON contains no features")
|
||||||
|
}
|
||||||
|
for _, feature := range collection.Features {
|
||||||
|
if feature.Type != "Feature" {
|
||||||
|
t.Fatalf("feature type=%q, want Feature", feature.Type)
|
||||||
|
}
|
||||||
|
if _, ok := feature.Properties["event"]; !ok {
|
||||||
|
t.Fatal("feature has no event property")
|
||||||
|
}
|
||||||
|
if _, ok := feature.Properties["role"]; !ok {
|
||||||
|
t.Fatal("feature has no role property")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRoles(t *testing.T, collection decodedCollection, roles ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, role := range roles {
|
||||||
|
featureWithRole(t, collection, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func featureWithRole(t *testing.T, collection decodedCollection, role string) decodedFeature {
|
||||||
|
t.Helper()
|
||||||
|
for _, feature := range collection.Features {
|
||||||
|
if feature.Properties["role"] == role {
|
||||||
|
return feature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("GeoJSON is missing role %q", role)
|
||||||
|
return decodedFeature{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func featuresWithRole(collection decodedCollection, role string) []decodedFeature {
|
||||||
|
result := make([]decodedFeature, 0)
|
||||||
|
for _, feature := range collection.Features {
|
||||||
|
if feature.Properties["role"] == role {
|
||||||
|
result = append(result, feature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCollectionCoordinates(t *testing.T, collection decodedCollection) {
|
||||||
|
t.Helper()
|
||||||
|
for _, feature := range collection.Features {
|
||||||
|
var coordinates interface{}
|
||||||
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &coordinates); err != nil {
|
||||||
|
t.Fatalf("decode %v coordinates: %v", feature.Properties["role"], err)
|
||||||
|
}
|
||||||
|
assertCoordinateTree(t, coordinates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCoordinateTree(t *testing.T, value interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
items, ok := value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("coordinate node has type %T", value)
|
||||||
|
}
|
||||||
|
if len(items) >= 2 {
|
||||||
|
longitude, lonOK := items[0].(float64)
|
||||||
|
latitude, latOK := items[1].(float64)
|
||||||
|
if lonOK && latOK {
|
||||||
|
if math.IsNaN(longitude) || math.IsInf(longitude, 0) || longitude < -180 || longitude > 180 {
|
||||||
|
t.Fatalf("invalid longitude %.12f", longitude)
|
||||||
|
}
|
||||||
|
if math.IsNaN(latitude) || math.IsInf(latitude, 0) || latitude < -90 || latitude > 90 {
|
||||||
|
t.Fatalf("invalid latitude %.12f", latitude)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
assertCoordinateTree(t, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertClosedMultiPolygon(t *testing.T, feature decodedFeature) {
|
||||||
|
t.Helper()
|
||||||
|
if feature.Geometry.Type != "MultiPolygon" {
|
||||||
|
t.Fatalf("%v 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 %v polygon: %v", feature.Properties["role"], err)
|
||||||
|
}
|
||||||
|
if len(polygons) == 0 {
|
||||||
|
t.Fatalf("%v has no polygons", feature.Properties["role"])
|
||||||
|
}
|
||||||
|
for _, polygon := range polygons {
|
||||||
|
if len(polygon) == 0 || len(polygon[0]) < 4 {
|
||||||
|
t.Fatalf("%v contains an incomplete ring", feature.Properties["role"])
|
||||||
|
}
|
||||||
|
ring := polygon[0]
|
||||||
|
first, last := ring[0], ring[len(ring)-1]
|
||||||
|
if first[0] != last[0] || first[1] != last[1] {
|
||||||
|
t.Fatalf("%v ring is not closed", feature.Properties["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTimedLineAligned(t *testing.T, feature decodedFeature) {
|
||||||
|
t.Helper()
|
||||||
|
var lines [][][]float64
|
||||||
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
||||||
|
t.Fatalf("decode line coordinates: %v", err)
|
||||||
|
}
|
||||||
|
timeSegments, ok := feature.Properties["times"].([]interface{})
|
||||||
|
if !ok || len(timeSegments) != len(lines) {
|
||||||
|
t.Fatalf("times do not align with %d line segments: %#v", len(lines), feature.Properties["times"])
|
||||||
|
}
|
||||||
|
for index, rawSegment := range timeSegments {
|
||||||
|
times, ok := rawSegment.([]interface{})
|
||||||
|
if !ok || len(times) != len(lines[index]) {
|
||||||
|
t.Fatalf("times segment %d does not align with %d coordinates", index, len(lines[index]))
|
||||||
|
}
|
||||||
|
for _, rawTime := range times {
|
||||||
|
value, ok := rawTime.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("time has type %T", rawTime)
|
||||||
|
}
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, value); err != nil {
|
||||||
|
t.Fatalf("invalid RFC3339 time %q: %v", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationSamples(start time.Time, longitudes, latitudes []float64) []moon.OccultationPathPoint {
|
||||||
|
result := make([]moon.OccultationPathPoint, len(longitudes))
|
||||||
|
for index := range result {
|
||||||
|
result[index] = moon.OccultationPathPoint{
|
||||||
|
Time: start.Add(time.Duration(index) * time.Hour),
|
||||||
|
Longitude: longitudes[index],
|
||||||
|
Latitude: latitudes[index],
|
||||||
|
MoonAltitude: 30,
|
||||||
|
WidthKM: 3000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleStarOccultationPath(start time.Time) moon.StarOccultationPath {
|
||||||
|
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||||||
|
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||||||
|
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||||||
|
return moon.StarOccultationPath{
|
||||||
|
TargetID: "HR 4799", Start: north[0], Greatest: center[1], End: north[len(north)-1],
|
||||||
|
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func samplePlanetOccultationPath(start time.Time) moon.PlanetOccultationPath {
|
||||||
|
star := sampleStarOccultationPath(start)
|
||||||
|
return moon.PlanetOccultationPath{
|
||||||
|
Planet: moon.OccultationSaturn, TargetID: "Saturn",
|
||||||
|
Start: star.Start, Greatest: star.Greatest, End: star.End, Complete: true,
|
||||||
|
CenterLine: star.CenterLine, NorthernLimit: star.NorthernLimit, SouthernLimit: star.SouthernLimit,
|
||||||
|
Step: time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleFootprint(at time.Time, west, south, east, north float64) moon.PlanetOccultationFootprint {
|
||||||
|
return moon.PlanetOccultationFootprint{
|
||||||
|
Time: at,
|
||||||
|
Polygons: [][]moon.OccultationPathPoint{{
|
||||||
|
{Longitude: west, Latitude: south},
|
||||||
|
{Longitude: east, Latitude: south},
|
||||||
|
{Longitude: east, Latitude: north},
|
||||||
|
{Longitude: west, Latitude: north},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
package geojson
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"b612.me/astro/internal/geodata"
|
||||||
|
"b612.me/astro/moon"
|
||||||
|
)
|
||||||
|
|
||||||
|
const lunarOccultationEvent = "lunar-occultation"
|
||||||
|
|
||||||
|
// MarshalStarOccultation 将月掩恒星的全球掩带和中心线编码为 GeoJSON。
|
||||||
|
// MarshalStarOccultation encodes a global stellar occultation band and center line as GeoJSON.
|
||||||
|
func MarshalStarOccultation(path moon.StarOccultationPath) ([]byte, error) {
|
||||||
|
return marshalStarOccultation(path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalStarOccultationWithTimeMarkers 编码恒星月掩,并沿中心线按固定间隔追加 Point 要素。
|
||||||
|
// MarshalStarOccultationWithTimeMarkers encodes a stellar occultation and adds Point Features at regular intervals along its center line.
|
||||||
|
func MarshalStarOccultationWithTimeMarkers(
|
||||||
|
path moon.StarOccultationPath,
|
||||||
|
options TimeMarkerOptions,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return marshalStarOccultation(path, &options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalStarOccultation(path moon.StarOccultationPath, markerOptions *TimeMarkerOptions) ([]byte, error) {
|
||||||
|
if markerOptions != nil {
|
||||||
|
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateStarOccultationPathData(path); err != nil {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
features := []feature{
|
||||||
|
newFeature(lunarOccultationEvent, "occultation-band", value, cloneProperties(properties)),
|
||||||
|
}
|
||||||
|
if len(path.CenterLine) > 0 {
|
||||||
|
features, err = appendOccultationPathLine(features, "center-line", path.CenterLine, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
features, err = appendOccultationPathLine(features, "north-limit", path.NorthernLimit, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
features, err = appendOccultationPathLine(features, "south-limit", path.SouthernLimit, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if markerOptions != nil && len(path.CenterLine) > 0 {
|
||||||
|
features, err = appendTimeMarkerFeatures(
|
||||||
|
features,
|
||||||
|
lunarOccultationEvent,
|
||||||
|
"center-line",
|
||||||
|
occultationPathSamples(path.CenterLine),
|
||||||
|
*markerOptions,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, marker := range []struct {
|
||||||
|
role string
|
||||||
|
point moon.OccultationPathPoint
|
||||||
|
}{
|
||||||
|
{role: "start", point: path.Start},
|
||||||
|
{role: "greatest", point: path.Greatest},
|
||||||
|
{role: "end", point: path.End},
|
||||||
|
} {
|
||||||
|
features, err = appendOccultationPoint(features, marker.role, marker.point, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return marshalFeatureCollection(features)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalPlanetOccultation 将月掩行星的部分掩、全掩和中心线编码为 GeoJSON。
|
||||||
|
// MarshalPlanetOccultation encodes partial, total, and center-line planetary occultation geometry as GeoJSON.
|
||||||
|
func MarshalPlanetOccultation(path moon.PlanetOccultationPath) ([]byte, error) {
|
||||||
|
return marshalPlanetOccultation(path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalPlanetOccultationWithTimeMarkers 编码行星月掩,并沿中心线按固定间隔追加 Point 要素。
|
||||||
|
// MarshalPlanetOccultationWithTimeMarkers encodes a planetary occultation and adds Point Features at regular intervals along its center line.
|
||||||
|
func MarshalPlanetOccultationWithTimeMarkers(
|
||||||
|
path moon.PlanetOccultationPath,
|
||||||
|
options TimeMarkerOptions,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return marshalPlanetOccultation(path, &options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *TimeMarkerOptions) ([]byte, error) {
|
||||||
|
if markerOptions != nil {
|
||||||
|
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := path.Planet.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: planetary occultation target: %w", err)
|
||||||
|
}
|
||||||
|
if err := validatePlanetOccultationPathData(path); err != nil {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
features := make([]feature, 0, len(path.PartialFootprints)+len(path.TotalFootprints)+12)
|
||||||
|
var err error
|
||||||
|
if len(path.PartialFootprints) > 0 {
|
||||||
|
features, err = appendOccultationFootprints(
|
||||||
|
features, "partial-footprint", path.PartialFootprints, properties,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
features, err = appendOccultationBand(
|
||||||
|
features, "partial-band", path.NorthernLimit, path.SouthernLimit, properties,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.HasTotalBand {
|
||||||
|
if len(path.TotalFootprints) > 0 {
|
||||||
|
features, err = appendOccultationFootprints(
|
||||||
|
features, "total-footprint", path.TotalFootprints, properties,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
features, err = appendOccultationBand(
|
||||||
|
features, "total-band", path.NorthernTotalLimit, path.SouthernTotalLimit, properties,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(path.CenterLine) > 0 {
|
||||||
|
features, err = appendOccultationPathLine(features, "center-line", path.CenterLine, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
features, err = appendOccultationPathLine(features, "north-limit", path.NorthernLimit, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
features, err = appendOccultationPathLine(features, "south-limit", path.SouthernLimit, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if path.HasTotalBand {
|
||||||
|
features, err = appendOccultationPathLine(
|
||||||
|
features, "north-total-limit", path.NorthernTotalLimit, properties,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
features, err = appendOccultationPathLine(
|
||||||
|
features, "south-total-limit", path.SouthernTotalLimit, properties,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if markerOptions != nil && len(path.CenterLine) > 0 {
|
||||||
|
features, err = appendTimeMarkerFeatures(
|
||||||
|
features,
|
||||||
|
lunarOccultationEvent,
|
||||||
|
"center-line",
|
||||||
|
occultationPathSamples(path.CenterLine),
|
||||||
|
*markerOptions,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markers := []struct {
|
||||||
|
role string
|
||||||
|
point moon.OccultationPathPoint
|
||||||
|
}{
|
||||||
|
{role: "start", point: path.Start},
|
||||||
|
}
|
||||||
|
if path.HasTotalBand {
|
||||||
|
markers = append(markers, struct {
|
||||||
|
role string
|
||||||
|
point moon.OccultationPathPoint
|
||||||
|
}{role: "total-start", point: path.TotalStart})
|
||||||
|
}
|
||||||
|
markers = append(markers, struct {
|
||||||
|
role string
|
||||||
|
point moon.OccultationPathPoint
|
||||||
|
}{role: "greatest", point: path.Greatest})
|
||||||
|
if path.HasTotalBand {
|
||||||
|
markers = append(markers, struct {
|
||||||
|
role string
|
||||||
|
point moon.OccultationPathPoint
|
||||||
|
}{role: "total-end", point: path.TotalEnd})
|
||||||
|
}
|
||||||
|
markers = append(markers, struct {
|
||||||
|
role string
|
||||||
|
point moon.OccultationPathPoint
|
||||||
|
}{role: "end", point: path.End})
|
||||||
|
for _, marker := range markers {
|
||||||
|
features, err = appendOccultationPoint(features, marker.role, marker.point, properties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return marshalFeatureCollection(features)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOccultationBand(
|
||||||
|
features []feature,
|
||||||
|
role string,
|
||||||
|
northern, southern []moon.OccultationPathPoint,
|
||||||
|
properties map[string]interface{},
|
||||||
|
) ([]feature, error) {
|
||||||
|
band, err := occultationBandPolygon(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(
|
||||||
|
lunarOccultationEvent, role, value, cloneProperties(properties),
|
||||||
|
)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOccultationFootprints(
|
||||||
|
features []feature,
|
||||||
|
role string,
|
||||||
|
footprints []moon.PlanetOccultationFootprint,
|
||||||
|
properties map[string]interface{},
|
||||||
|
) ([]feature, error) {
|
||||||
|
appended := 0
|
||||||
|
for _, footprint := range footprints {
|
||||||
|
if footprint.Time.IsZero() {
|
||||||
|
return nil, fmt.Errorf("geojson: %s time is required", role)
|
||||||
|
}
|
||||||
|
polygons := make([][]geodata.GeoPoint, 0, len(footprint.Polygons))
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
polygons = append(polygons, polygon)
|
||||||
|
}
|
||||||
|
value, err := multiPolygonGeometry(polygons)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("geojson: %s at %s: %w", role, formatTime(footprint.Time), err)
|
||||||
|
}
|
||||||
|
footprintProperties := cloneProperties(properties)
|
||||||
|
footprintProperties["time"] = formatTime(footprint.Time)
|
||||||
|
features = append(features, newFeature(
|
||||||
|
lunarOccultationEvent, role, value, footprintProperties,
|
||||||
|
))
|
||||||
|
appended++
|
||||||
|
}
|
||||||
|
if appended == 0 {
|
||||||
|
return nil, fmt.Errorf("geojson: %s has no valid polygons", role)
|
||||||
|
}
|
||||||
|
return features, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationBandPolygon(
|
||||||
|
northern, southern []moon.OccultationPathPoint,
|
||||||
|
) ([]geodata.GeoPoint, error) {
|
||||||
|
if len(northern) != len(southern) {
|
||||||
|
return nil, 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")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if !northern[index].Time.Equal(southern[index].Time) {
|
||||||
|
return nil, 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})
|
||||||
|
}
|
||||||
|
for index := count - 1; index >= 0; index-- {
|
||||||
|
point := southern[index]
|
||||||
|
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||||
|
}
|
||||||
|
return polygon, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOccultationPathLine(
|
||||||
|
features []feature,
|
||||||
|
role string,
|
||||||
|
points []moon.OccultationPathPoint,
|
||||||
|
properties map[string]interface{},
|
||||||
|
) ([]feature, error) {
|
||||||
|
samples := make([]pathSample, len(points))
|
||||||
|
for index, point := range points {
|
||||||
|
samples[index] = occultationPathSample(point)
|
||||||
|
}
|
||||||
|
return appendTimedLineFeature(features, lunarOccultationEvent, role, samples, properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathSamples(points []moon.OccultationPathPoint) []pathSample {
|
||||||
|
samples := make([]pathSample, len(points))
|
||||||
|
for index, point := range points {
|
||||||
|
samples[index] = occultationPathSample(point)
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendOccultationPoint(
|
||||||
|
features []feature,
|
||||||
|
role string,
|
||||||
|
point moon.OccultationPathPoint,
|
||||||
|
properties map[string]interface{},
|
||||||
|
) ([]feature, error) {
|
||||||
|
pointProperties := cloneProperties(properties)
|
||||||
|
pointProperties["moon_altitude_deg"] = point.MoonAltitude
|
||||||
|
pointProperties["width_km"] = point.WidthKM
|
||||||
|
return appendPointFeature(
|
||||||
|
features, lunarOccultationEvent, role, occultationPathSample(point), pointProperties,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func occultationPathSample(point moon.OccultationPathPoint) pathSample {
|
||||||
|
return pathSample{Time: point.Time, Longitude: point.Longitude, Latitude: point.Latitude}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStarOccultationPathData(path moon.StarOccultationPath) error {
|
||||||
|
if !path.Complete {
|
||||||
|
return fmt.Errorf("geojson: occultation path is incomplete")
|
||||||
|
}
|
||||||
|
if err := (moon.OccultationPathOptions{Step: path.Step, TargetSpacingKM: path.TargetSpacingKM}).Validate(); err != nil {
|
||||||
|
return fmt.Errorf("geojson: invalid occultation path sampling metadata: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathPoint("start", path.Start); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathPoint("greatest", path.Greatest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathPoint("end", path.End); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if path.Greatest.Time.Before(path.Start.Time) || path.End.Time.Before(path.Greatest.Time) {
|
||||||
|
return fmt.Errorf("geojson: occultation times must be ordered start, greatest, end")
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathSeries("center line", path.CenterLine, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathSeries("northern limit", path.NorthernLimit, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathSeries("southern limit", path.SouthernLimit, true); 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")
|
||||||
|
}
|
||||||
|
for index := range path.NorthernLimit {
|
||||||
|
if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) {
|
||||||
|
return fmt.Errorf("geojson: occultation limit sample %d times must match", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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("geojson: occultation limits must span start through end")
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
return fmt.Errorf("geojson: occultation center line must be inside start and end")
|
||||||
|
}
|
||||||
|
if path.Greatest.Time.Before(path.CenterLine[0].Time) ||
|
||||||
|
path.Greatest.Time.After(path.CenterLine[len(path.CenterLine)-1].Time) {
|
||||||
|
return fmt.Errorf("geojson: occultation greatest time is outside the center-line interval")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePlanetOccultationPathData(path moon.PlanetOccultationPath) error {
|
||||||
|
starPath := moon.StarOccultationPath{
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if err := validateStarOccultationPathData(starPath); 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 {
|
||||||
|
return fmt.Errorf("geojson: total-band fields require HasTotalBand")
|
||||||
|
}
|
||||||
|
return validateOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time)
|
||||||
|
}
|
||||||
|
if !path.TotalComplete {
|
||||||
|
return fmt.Errorf("geojson: total-occultation band is incomplete")
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathPoint("total start", path.TotalStart); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathPoint("total end", path.TotalEnd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !path.Start.Time.Before(path.TotalStart.Time) || !path.TotalStart.Time.Before(path.Greatest.Time) ||
|
||||||
|
!path.Greatest.Time.Before(path.TotalEnd.Time) || !path.TotalEnd.Time.Before(path.End.Time) {
|
||||||
|
return fmt.Errorf("geojson: total-band times must be inside outer start, greatest, and end")
|
||||||
|
}
|
||||||
|
if !finiteGeoJSON(path.GreatestTotalWidthKM) || path.GreatestTotalWidthKM <= 0 ||
|
||||||
|
!finiteGeoJSON(path.Greatest.WidthKM) || path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
|
||||||
|
return fmt.Errorf("geojson: total-band width must be positive and narrower than the outer band")
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathSeries("northern total limit", path.NorthernTotalLimit, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOccultationPathSeries("southern total limit", path.SouthernTotalLimit, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(path.NorthernTotalLimit) != len(path.SouthernTotalLimit) {
|
||||||
|
return fmt.Errorf("geojson: total northern and southern limits must have the same sample count")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last := len(path.NorthernTotalLimit) - 1
|
||||||
|
if !path.NorthernTotalLimit[0].Time.Equal(path.TotalStart.Time) ||
|
||||||
|
!path.SouthernTotalLimit[0].Time.Equal(path.TotalStart.Time) ||
|
||||||
|
!path.NorthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) ||
|
||||||
|
!path.SouthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) {
|
||||||
|
return fmt.Errorf("geojson: total limits must span total start through total end")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOccultationPathSeries(name string, points []moon.OccultationPathPoint, required bool) error {
|
||||||
|
if required && len(points) < 2 {
|
||||||
|
return fmt.Errorf("geojson: %s requires at least two points", name)
|
||||||
|
}
|
||||||
|
previous := time.Time{}
|
||||||
|
for index, point := range points {
|
||||||
|
if err := validateOccultationPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !previous.IsZero() && !point.Time.After(previous) {
|
||||||
|
return fmt.Errorf("geojson: %s times must be strictly increasing", name)
|
||||||
|
}
|
||||||
|
previous = point.Time
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOccultationPathPoint(name string, point moon.OccultationPathPoint) error {
|
||||||
|
if point.Time.IsZero() {
|
||||||
|
return fmt.Errorf("geojson: %s time is required", name)
|
||||||
|
}
|
||||||
|
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||||
|
return fmt.Errorf("geojson: %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if !finiteGeoJSON(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 {
|
||||||
|
return fmt.Errorf("geojson: %s Moon altitude must be finite and within [-90, 90]", name)
|
||||||
|
}
|
||||||
|
if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 {
|
||||||
|
return fmt.Errorf("geojson: %s width must be finite and non-negative", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOccultationFootprints(
|
||||||
|
name string,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Package geodata 提供与投影无关的地理拓扑辅助函数,用于 / Package geodata provides projection-neutral geographic topology helpers for
|
||||||
|
// 地图渲染器和传输编码器使用 / map renderers and transport encoders.
|
||||||
|
package geodata
|
||||||
|
|
||||||
|
// Projection 标识受支持的地图投影 / Projection identifies one of the supported map projections.
|
||||||
|
type Projection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProjectionEquirectangular Projection = "equirectangular"
|
||||||
|
ProjectionNorthPolar Projection = "north-polar"
|
||||||
|
ProjectionSouthPolar Projection = "south-polar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeoPoint 是以度表示的地理点,东经为正 / GeoPoint is a geographic point in degrees, with east longitude positive.
|
||||||
|
type GeoPoint struct {
|
||||||
|
Longitude float64
|
||||||
|
Latitude float64
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package geodata
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
latitude := center.Latitude * math.Pi / 180
|
||||||
|
longitude := center.Longitude * math.Pi / 180
|
||||||
|
radius := radiusDegrees * math.Pi / 180
|
||||||
|
result := make([]GeoPoint, points)
|
||||||
|
for index := range result {
|
||||||
|
bearing := 2 * math.Pi * float64(index) / float64(points)
|
||||||
|
lat := math.Asin(math.Sin(latitude)*math.Cos(radius) +
|
||||||
|
math.Cos(latitude)*math.Sin(radius)*math.Cos(bearing))
|
||||||
|
lon := longitude + math.Atan2(
|
||||||
|
math.Sin(bearing)*math.Sin(radius)*math.Cos(latitude),
|
||||||
|
math.Cos(radius)-math.Sin(latitude)*math.Sin(lat),
|
||||||
|
)
|
||||||
|
result[index] = GeoPoint{
|
||||||
|
Longitude: normalizeLongitude(lon * 180 / math.Pi),
|
||||||
|
Latitude: lat * 180 / math.Pi,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinPolylineSegments 按最近端点连接无序边界线段 / JoinPolylineSegments joins unordered boundary segments by their nearest
|
||||||
|
// 端点连接;输入线段不会被修改 / endpoints. The input segments are not modified.
|
||||||
|
func JoinPolylineSegments(segments [][]GeoPoint) []GeoPoint {
|
||||||
|
filtered := make([][]GeoPoint, 0, len(segments))
|
||||||
|
for _, segment := range segments {
|
||||||
|
if len(segment) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, append([]GeoPoint(nil), segment...))
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := append([]GeoPoint(nil), filtered[0]...)
|
||||||
|
used := make([]bool, len(filtered))
|
||||||
|
used[0] = true
|
||||||
|
for joined := 1; joined < len(filtered); joined++ {
|
||||||
|
bestIndex := -1
|
||||||
|
bestReverse := false
|
||||||
|
bestPrepend := false
|
||||||
|
bestDistance := math.Inf(1)
|
||||||
|
start := result[0]
|
||||||
|
end := result[len(result)-1]
|
||||||
|
for index, segment := range filtered {
|
||||||
|
if used[index] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if distance := angularDistanceDegrees(end, segment[0]); distance < bestDistance {
|
||||||
|
bestIndex, bestReverse, bestPrepend, bestDistance = index, false, false, distance
|
||||||
|
}
|
||||||
|
if distance := angularDistanceDegrees(end, segment[len(segment)-1]); distance < bestDistance {
|
||||||
|
bestIndex, bestReverse, bestPrepend, bestDistance = index, true, false, distance
|
||||||
|
}
|
||||||
|
if distance := angularDistanceDegrees(start, segment[len(segment)-1]); distance < bestDistance {
|
||||||
|
bestIndex, bestReverse, bestPrepend, bestDistance = index, false, true, distance
|
||||||
|
}
|
||||||
|
if distance := angularDistanceDegrees(start, segment[0]); distance < bestDistance {
|
||||||
|
bestIndex, bestReverse, bestPrepend, bestDistance = index, true, true, distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestIndex < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
segment := filtered[bestIndex]
|
||||||
|
if bestReverse {
|
||||||
|
reverseGeoPoints(segment)
|
||||||
|
}
|
||||||
|
if bestPrepend {
|
||||||
|
result = append(segment, result...)
|
||||||
|
} else {
|
||||||
|
result = append(result, segment...)
|
||||||
|
}
|
||||||
|
used[bestIndex] = true
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortestCircleArc 返回两点之间较短的采样圆弧 / ShortestCircleArc returns the shorter sampled arc from one point to another.
|
||||||
|
func ShortestCircleArc(circle []GeoPoint, from, to GeoPoint) []GeoPoint {
|
||||||
|
if len(circle) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fromIndex := nearestGeoPointIndex(circle, from)
|
||||||
|
toIndex := nearestGeoPointIndex(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
|
||||||
|
}
|
||||||
|
result := make([]GeoPoint, 0, steps+2)
|
||||||
|
result = append(result, from)
|
||||||
|
for step := 1; step < steps; step++ {
|
||||||
|
index := (fromIndex + direction*step) % len(circle)
|
||||||
|
if index < 0 {
|
||||||
|
index += len(circle)
|
||||||
|
}
|
||||||
|
result = append(result, circle[index])
|
||||||
|
}
|
||||||
|
return append(result, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameGeoPoint 判断两个经纬度点是否在拓扑所需精度内相等 / SameGeoPoint reports whether two longitude/latitude points are equal within
|
||||||
|
// 地图拓扑辅助函数所需的精度内相等 / the precision needed by the map topology helpers.
|
||||||
|
func SameGeoPoint(a, b GeoPoint) bool {
|
||||||
|
return math.Abs(normalizeLongitude(a.Longitude-b.Longitude)) < 1e-9 &&
|
||||||
|
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)
|
||||||
|
for index, point := range points {
|
||||||
|
if distance := angularDistanceDegrees(point, target); distance < bestDistance {
|
||||||
|
bestIndex, bestDistance = index, distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func angularDistanceDegrees(a, b GeoPoint) float64 {
|
||||||
|
lat1 := a.Latitude * math.Pi / 180
|
||||||
|
lat2 := b.Latitude * math.Pi / 180
|
||||||
|
dLongitude := normalizeLongitude(b.Longitude-a.Longitude) * math.Pi / 180
|
||||||
|
cosine := math.Sin(lat1)*math.Sin(lat2) +
|
||||||
|
math.Cos(lat1)*math.Cos(lat2)*math.Cos(dLongitude)
|
||||||
|
return math.Acos(math.Max(-1, math.Min(1, cosine))) * 180 / math.Pi
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseGeoPoints(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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package geodata
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestJoinPolylineSegmentsUsesBothResultEndpoints(t *testing.T) {
|
||||||
|
segments := [][]GeoPoint{
|
||||||
|
{
|
||||||
|
{Longitude: -179, Latitude: 16},
|
||||||
|
{Longitude: -41, Latitude: 64},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Longitude: 51, Latitude: 60},
|
||||||
|
{Longitude: 179, Latitude: 16},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
joined := JoinPolylineSegments(segments)
|
||||||
|
if len(joined) != 4 {
|
||||||
|
t.Fatalf("joined point count = %d, want 4", len(joined))
|
||||||
|
}
|
||||||
|
if absoluteLongitude(joined[0].Longitude) > 90 || absoluteLongitude(joined[len(joined)-1].Longitude) > 90 {
|
||||||
|
t.Fatalf("joined open endpoints are on the antimeridian: first=%+v last=%+v", joined[0], joined[len(joined)-1])
|
||||||
|
}
|
||||||
|
if angularDistanceDegrees(joined[1], joined[2]) > 3 {
|
||||||
|
t.Fatalf("nearest antimeridian endpoints were not joined: %+v -> %+v", joined[1], joined[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func absoluteLongitude(value float64) float64 {
|
||||||
|
if value < 0 {
|
||||||
|
return -value
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package geodata
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if projection == ProjectionNorthPolar {
|
||||||
|
return clipPolylineHemisphere(points, 1)
|
||||||
|
}
|
||||||
|
if projection == ProjectionSouthPolar {
|
||||||
|
return clipPolylineHemisphere(points, -1)
|
||||||
|
}
|
||||||
|
return splitPolylineAntimeridian(points)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolygonFragments 将地理多边形裁剪到选定地图范围 / PolygonFragments clips a geographic polygon to the selected map extent.
|
||||||
|
func PolygonFragments(points []GeoPoint, projection Projection) [][]GeoPoint {
|
||||||
|
if len(points) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if projection == ProjectionNorthPolar {
|
||||||
|
if clipped := clipPolygonHemisphere(points, 1); len(clipped) >= 3 {
|
||||||
|
return [][]GeoPoint{clipped}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if projection == ProjectionSouthPolar {
|
||||||
|
if clipped := clipPolygonHemisphere(points, -1); len(clipped) >= 3 {
|
||||||
|
return [][]GeoPoint{clipped}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return splitPolygonAntimeridian(points)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPolylineAntimeridian(points []GeoPoint) [][]GeoPoint {
|
||||||
|
if len(points) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
segments := make([][]GeoPoint, 0, 2)
|
||||||
|
current := []GeoPoint{points[0]}
|
||||||
|
for index := 1; index < len(points); index++ {
|
||||||
|
a, b := points[index-1], points[index]
|
||||||
|
if (a.Longitude == -180 && b.Longitude == 180) || (a.Longitude == 180 && b.Longitude == -180) {
|
||||||
|
// -180/+180 的精确端点属于同一子午线,不要 / Exact -180/+180 endpoints are the same meridian; do not
|
||||||
|
// 不要让它们进入分母为零的交叉插值 / feed them into the crossing interpolation with a zero denominator.
|
||||||
|
b.Longitude = a.Longitude
|
||||||
|
current = append(current, b)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if math.Abs(b.Longitude-a.Longitude) <= 180 {
|
||||||
|
current = append(current, b)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
boundary := 180.0
|
||||||
|
adjustedLongitude := b.Longitude
|
||||||
|
if a.Longitude < 0 {
|
||||||
|
boundary = -180
|
||||||
|
adjustedLongitude -= 360
|
||||||
|
} else {
|
||||||
|
adjustedLongitude += 360
|
||||||
|
}
|
||||||
|
fraction := (boundary - a.Longitude) / (adjustedLongitude - a.Longitude)
|
||||||
|
crossing := GeoPoint{Longitude: boundary, Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude)}
|
||||||
|
current = append(current, crossing)
|
||||||
|
if len(current) >= 2 {
|
||||||
|
segments = append(segments, current)
|
||||||
|
}
|
||||||
|
crossing.Longitude = -boundary
|
||||||
|
current = []GeoPoint{crossing, b}
|
||||||
|
}
|
||||||
|
if len(current) >= 2 {
|
||||||
|
segments = append(segments, current)
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipPolylineHemisphere(points []GeoPoint, hemisphere float64) [][]GeoPoint {
|
||||||
|
if len(points) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inside := func(value GeoPoint) bool { return value.Latitude*hemisphere >= 0 }
|
||||||
|
var segments [][]GeoPoint
|
||||||
|
var current []GeoPoint
|
||||||
|
for index, point := range points {
|
||||||
|
pointInside := inside(point)
|
||||||
|
if index == 0 {
|
||||||
|
if pointInside {
|
||||||
|
current = append(current, point)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
previous := points[index-1]
|
||||||
|
previousInside := inside(previous)
|
||||||
|
if previousInside != pointInside {
|
||||||
|
crossing := hemisphereIntersection(previous, point)
|
||||||
|
if previousInside {
|
||||||
|
current = append(current, crossing)
|
||||||
|
if len(current) >= 2 {
|
||||||
|
segments = append(segments, current)
|
||||||
|
}
|
||||||
|
current = nil
|
||||||
|
} else {
|
||||||
|
current = []GeoPoint{crossing}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pointInside {
|
||||||
|
current = append(current, point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(current) >= 2 {
|
||||||
|
segments = append(segments, current)
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipPolygonHemisphere(points []GeoPoint, hemisphere float64) []GeoPoint {
|
||||||
|
inside := func(value GeoPoint) bool { return value.Latitude*hemisphere >= 0 }
|
||||||
|
return clipPolygon(points, inside, hemisphereIntersection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPolygonAntimeridian(points []GeoPoint) [][]GeoPoint {
|
||||||
|
unwrapped := make([]GeoPoint, len(points))
|
||||||
|
unwrapped[0] = points[0]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
unwrapped[index] = point
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
firstWorld := int(math.Floor((minimum + 180) / 360))
|
||||||
|
lastWorld := int(math.Floor((maximum + 180) / 360))
|
||||||
|
var fragments [][]GeoPoint
|
||||||
|
for world := firstWorld; world <= lastWorld; world++ {
|
||||||
|
left := -180.0 + 360*float64(world)
|
||||||
|
right := 180.0 + 360*float64(world)
|
||||||
|
clipped := clipPolygonLongitude(unwrapped, left, true)
|
||||||
|
clipped = clipPolygonLongitude(clipped, right, false)
|
||||||
|
if len(clipped) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for index := range clipped {
|
||||||
|
clipped[index].Longitude -= 360 * float64(world)
|
||||||
|
}
|
||||||
|
if math.Abs(signedPolygonArea(clipped)) < 1e-12 {
|
||||||
|
// 边恰好落在日界线上的多边形可能在相邻世界各输出一次 / A polygon whose edge lies exactly on the antimeridian can be
|
||||||
|
// 可能在相邻世界各输出一次;丢弃重复的 / emitted once for each adjacent world. Drop the duplicate
|
||||||
|
// 零面积片段后再做 GeoJSON 环验证 / zero-area fragment before GeoJSON ring validation.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fragments = append(fragments, clipped)
|
||||||
|
}
|
||||||
|
return fragments
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedPolygonArea(points []GeoPoint) float64 {
|
||||||
|
if len(points) < 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
area := 0.0
|
||||||
|
for index, point := range points {
|
||||||
|
next := points[(index+1)%len(points)]
|
||||||
|
area += point.Longitude*next.Latitude - next.Longitude*point.Latitude
|
||||||
|
}
|
||||||
|
return area / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipPolygonLongitude(points []GeoPoint, boundary float64, keepGreater bool) []GeoPoint {
|
||||||
|
inside := func(value GeoPoint) bool {
|
||||||
|
if keepGreater {
|
||||||
|
return value.Longitude >= boundary
|
||||||
|
}
|
||||||
|
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 clipPolygon(points, inside, intersection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipPolygon(
|
||||||
|
points []GeoPoint,
|
||||||
|
inside func(GeoPoint) bool,
|
||||||
|
intersection func(GeoPoint, GeoPoint) GeoPoint,
|
||||||
|
) []GeoPoint {
|
||||||
|
if len(points) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLongitude(value float64) float64 {
|
||||||
|
value = math.Mod(value+180, 360)
|
||||||
|
if value < 0 {
|
||||||
|
value += 360
|
||||||
|
}
|
||||||
|
return value - 180
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package geodata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPolylineSegmentsTreatsExactAntimeridianAsOneMeridian(t *testing.T) {
|
||||||
|
segments := PolylineSegments([]GeoPoint{
|
||||||
|
{Longitude: -180, Latitude: 10},
|
||||||
|
{Longitude: 180, Latitude: 20},
|
||||||
|
}, ProjectionEquirectangular)
|
||||||
|
if len(segments) != 1 || len(segments[0]) != 2 {
|
||||||
|
t.Fatalf("exact-antimeridian line segments = %#v", segments)
|
||||||
|
}
|
||||||
|
if segments[0][0].Longitude != segments[0][1].Longitude {
|
||||||
|
t.Fatalf("exact-antimeridian line spans %.1f degrees",
|
||||||
|
math.Abs(segments[0][1].Longitude-segments[0][0].Longitude))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) {
|
||||||
|
fragments := PolygonFragments([]GeoPoint{
|
||||||
|
{Longitude: -180, Latitude: 15},
|
||||||
|
{Longitude: 180, Latitude: 14},
|
||||||
|
{Longitude: 150, Latitude: 13},
|
||||||
|
{Longitude: 150, Latitude: -12},
|
||||||
|
{Longitude: 180, Latitude: -11},
|
||||||
|
{Longitude: -180, Latitude: -10},
|
||||||
|
}, ProjectionEquirectangular)
|
||||||
|
if len(fragments) != 1 {
|
||||||
|
t.Fatalf("exact-antimeridian polygon produced %d fragments, want 1", len(fragments))
|
||||||
|
}
|
||||||
|
if math.Abs(signedPolygonArea(fragments[0])) < 1e-12 {
|
||||||
|
t.Fatal("exact-antimeridian polygon fragment has zero area")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Package svgasset 为对外 SVG 包提供共享的内嵌 SVG 片段 / Package svgasset provides shared embedded SVG fragments to the public SVG packages.
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
//go:embed lunar_eclipse_moon.svg
|
||||||
|
var moonFaceSymbol string
|
||||||
|
|
||||||
|
// MoonFaceSymbol 返回共享的月面 <symbol> 定义 / MoonFaceSymbol returns the shared Moon-face <symbol> definition.
|
||||||
|
func MoonFaceSymbol() string {
|
||||||
|
return moonFaceSymbol
|
||||||
|
}
|
||||||