feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+924
View File
@@ -0,0 +1,924 @@
package geojson_test
import (
"encoding/json"
"math"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/moon"
)
// TestSolarEclipseP2SarosGeoJSONSamples serializes real eclipse paths at
// evenly spaced Saros-family offsets around the current epoch. It is kept
// intentionally smaller than the opt-in millennium diagnostic while still
// exercising antimeridian, polar and non-central topology in normal tests.
func TestSolarEclipseP2SarosGeoJSONSamples(t *testing.T) {
const sarosDays = 6585.321314
seed := basic.JDECalc(2024, 4, 8)
for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} {
familyIndex := familyIndex
t.Run("saros-"+formatP2SignedIndex(familyIndex), func(t *testing.T) {
date := basic.JDE2DateByZone(seed+float64(familyIndex)*sarosDays, time.UTC, false)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute,
DisableRiseSet: true,
})
if !ok || !partial.Eclipse.HasPartial {
t.Fatalf("solar eclipse unavailable at %s: ok=%v type=%s", date.Format("2006-01-02"), ok, partial.Eclipse.Type)
}
data, err := geojson.MarshalSolarEclipse(partial, nil)
if err != nil {
t.Fatalf("MarshalSolarEclipse(%s): %v", date.Format("2006-01-02"), err)
}
collection := decodeCollection(t, data)
assertCollectionCoordinates(t, collection)
assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band"))
if partial.Eclipse.Type == eclipse.SolarEclipsePartial {
if len(featuresWithRole(collection, "central-band")) != 0 {
t.Fatal("partial-only eclipse unexpectedly contains a central band")
}
} else {
assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band"))
}
if len(featuresWithRole(collection, "greatest")) != 1 {
t.Fatalf("greatest feature count=%d, want one", len(featuresWithRole(collection, "greatest")))
}
})
}
}
func TestOccultationP2RepresentativeGeoJSONPathsRemainClosed(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
star := moon.StarCoordinate{
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18,
}
starCases := []struct {
name string
start time.Time
}{
{name: "hr4799-2025", start: time.Date(2025, time.June, 5, 0, 0, 0, 0, zone)},
}
for _, test := range starCases {
t.Run(test.name, func(t *testing.T) {
paths, err := moon.FindStarOccultationPaths(test.start, test.start.Add(24*time.Hour), star,
moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true})
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
data, err := geojson.MarshalStarOccultation(paths[0])
if err != nil {
t.Fatalf("MarshalStarOccultation: %v", err)
}
collection := decodeCollection(t, data)
assertCollectionCoordinates(t, collection)
assertClosedMultiPolygon(t, featureWithRole(t, collection, "occultation-band"))
assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline"))
assertRoles(t, collection, "occultation-band", "band-outline", "visibility-boundary", "center-line", "north-limit", "south-limit")
})
}
planetCases := []struct {
name string
start time.Time
planet moon.OccultationPlanet
}{
{name: "venus-2025-09-19", start: time.Date(2025, time.September, 19, 0, 0, 0, 0, zone), planet: moon.OccultationVenus},
{name: "saturn-2025-01-05", start: time.Date(2025, time.January, 5, 0, 0, 0, 0, zone), planet: moon.OccultationSaturn},
{name: "mars-2025-06-30", start: time.Date(2025, time.June, 30, 0, 0, 0, 0, zone), planet: moon.OccultationMars},
}
for _, test := range planetCases {
t.Run(test.name, func(t *testing.T) {
paths, err := moon.FindPlanetOccultationPaths(test.start, test.start.Add(24*time.Hour), test.planet,
moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true})
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
data, err := geojson.MarshalPlanetOccultation(paths[0])
if err != nil {
t.Fatalf("MarshalPlanetOccultation: %v", err)
}
collection := decodeCollection(t, data)
assertCollectionCoordinates(t, collection)
assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band"))
assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline"))
assertOccultationP0ContactContourSource(t, featureWithRole(t, collection, "partial-band"))
if paths[0].HasTotalBand {
assertClosedMultiPolygon(t, featureWithRole(t, collection, "total-band"))
assertClosedMultiLineFeature(t, featureWithRole(t, collection, "total-band-outline"))
assertOccultationP0ContactContourSource(t, featureWithRole(t, collection, "total-band"))
}
assertRoles(t, collection, "partial-band", "band-outline", "visibility-boundary", "center-line", "north-limit", "south-limit")
})
}
}
func TestOccultationP2Venus20250919HasNoInternalOutline(t *testing.T) {
start := time.Date(2025, time.September, 19, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
for _, algorithm := range []moon.OccultationPathAlgorithm{moon.OccultationPathAlgorithmOptimized, moon.OccultationPathAlgorithmExact} {
t.Run(string(algorithm), func(t *testing.T) {
paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationVenus,
moon.OccultationPathOptions{
Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableFootprints: true, RiseSetStep: time.Minute,
})
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
data, err := geojson.MarshalPlanetOccultation(path)
if err != nil {
t.Fatalf("MarshalPlanetOccultation: %v", err)
}
collection := decodeCollection(t, data)
assertCollectionCoordinates(t, collection)
for _, role := range []string{"partial-band", "total-band"} {
feature := featureWithRole(t, collection, role)
assertClosedMultiPolygon(t, feature)
assertOccultationP0ContactContourSource(t, feature)
rings := geoJSONMultiPolygonOuterRings(t, feature)
if len(rings) != 1 {
t.Errorf("%s rings=%d, want one continuous band without overlapping slivers", role, len(rings))
}
contours, curves, footprints := path.PartialBandContours, path.RiseSetCurves, path.PartialBandFootprints
if role == "total-band" {
contours, curves, footprints = path.TotalBandContours, path.TotalRiseSetCurves, path.TotalBandFootprints
}
lines := occultationP0AuthoritativeBoundaryLines(contours, curves, footprints)
for _, ring := range rings {
for _, point := range ring {
// Test the physical southern boundary, not the map's pole/dateline closure.
if point.Latitude < 10 && point.Longitude > 25 && point.Longitude < 40 {
if miss := geoPointLineDistanceKM(point, lines); miss > 1 {
t.Errorf("%s southern outline leaves source by %.3f km at %+v", role, miss, point)
}
}
}
}
}
assertOccultationP2PhaseCurvesInsidePartialBand(t, collection, 1)
partial := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "partial-band"))
total := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band"))
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 1 {
t.Fatalf("total band extends %.3f km beyond partial band", miss)
}
})
}
}
func TestOccultationP2Mars20250114GeoJSONAcceptsFoldedBandContours(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.January, 14, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationMars,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
data, err := geojson.MarshalPlanetOccultationWithTimeMarkers(
paths[0], geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: zone},
)
if err != nil {
t.Fatalf("MarshalPlanetOccultationWithTimeMarkers: %v", err)
}
collection := decodeCollection(t, data)
assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band"))
assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline"))
}
func TestOccultationP2Mars20250729AddsMoonriseHorizonConnector(t *testing.T) {
collection := mars20250729TestFixture(t, 5*time.Minute, true).collection
assertOccultationHorizonConnectorsAreAuxiliary(t, collection)
connector := horizonConnectorFeature(t, collection, "rise")
var lines [][][]float64
if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode horizon connector coordinates: %v", err)
}
if len(lines) == 0 || len(lines) > 3 {
t.Fatalf("horizon connector segment count=%d, want one to three selected transition segments", len(lines))
}
phaseEndpoints := mars20250729PhaseEndpoints(t, collection, "rise")
foundOpeningConnector := false
for _, line := range lines {
if len(line) < 2 {
continue
}
first, last := line[0], line[len(line)-1]
chord := geoJSONCoordinateDistanceKM(first, last)
if chord < 750 || chord > 1000 {
continue
}
total := 0.0
maximumStep := 0.0
for index := 1; index < len(line); index++ {
step := geoJSONCoordinateDistanceKM(line[index-1], line[index])
total += step
if step > maximumStep {
maximumStep = step
}
}
if total > 1.25*chord || maximumStep > 200 {
t.Fatalf("moonrise horizon connector detours along the contact limb: total=%.1f chord=%.1f max-step=%.1f km", total, chord, maximumStep)
}
if !geoJSONCoordinateMatchesAny(line[0], phaseEndpoints) ||
!geoJSONCoordinateMatchesAny(line[len(line)-1], phaseEndpoints) {
t.Fatalf("moonrise horizon connector endpoints do not meet physical phase endpoints: first=%v last=%v",
line[0], line[len(line)-1])
}
foundOpeningConnector = true
break
}
if !foundOpeningConnector {
t.Fatal("2025-07-29 Mars occultation is missing the moonrise horizon connector in the South Pacific opening region")
}
}
func TestOccultationP2Mars20250729OneMinuteRiseSetAddsOpeningConnector(t *testing.T) {
collection := mars20250729TestFixture(t, time.Minute, true).collection
connector := horizonConnectorFeature(t, collection, "rise")
var lines [][][]float64
if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode horizon connector coordinates: %v", err)
}
startRise := riseSetBoundaryFeature(t, collection, "start", "rise")
startRiseEndpoints := riseSetFeatureEndpoints(t, startRise)
foundOpeningConnector := false
for _, line := range lines {
if len(line) < 2 {
continue
}
total := 0.0
for pointIndex, point := range line {
if pointIndex > 0 {
total += geoJSONCoordinateDistanceKM(line[pointIndex-1], point)
}
}
chord := geoJSONCoordinateDistanceKM(line[0], line[len(line)-1])
if chord < 250 || chord > 650 || total < 400 || total > 700 {
continue
}
maximumStep := 0.0
for index := 1; index < len(line); index++ {
step := geoJSONCoordinateDistanceKM(line[index-1], line[index])
if step > maximumStep {
maximumStep = step
}
}
if maximumStep > 120 {
t.Fatalf("one-minute moonrise opening connector max-step=%.1f km, want smooth closure", maximumStep)
}
if !geoJSONCoordinateMatchesAny(line[0], startRiseEndpoints) ||
!geoJSONCoordinateMatchesAny(line[len(line)-1], startRiseEndpoints) {
t.Fatalf("one-minute moonrise opening connector endpoints do not meet start/rise phase endpoints: first=%v last=%v",
line[0], line[len(line)-1])
}
foundOpeningConnector = true
break
}
if !foundOpeningConnector {
t.Fatal("2025-07-29 Mars one-minute rise/set GeoJSON is missing the opening moonrise connector")
}
}
func TestOccultationP2Mars20250729DoesNotDuplicateSamePhaseFinalFold(t *testing.T) {
collection := mars20250729TestFixture(t, 5*time.Minute, true).collection
connector := horizonConnectorFeature(t, collection, "rise")
var lines [][][]float64
if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode horizon connector coordinates: %v", err)
}
if len(lines) != 1 {
t.Fatalf("moonrise horizon connector segment count=%d, want only the opening closure; same-phase final fold must be part of end/rise",
len(lines))
}
}
func TestOccultationP2Mars20250729StaticBandsStayAuthoritativeAndBounded(t *testing.T) {
collection := mars20250729TestFixture(t, time.Minute, true).collection
for _, role := range []string{"partial-band", "total-band"} {
band := featureWithRole(t, collection, role)
if authoritative, ok := band.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
t.Fatalf("%s is not authoritative: properties=%v", role, band.Properties)
}
outlineRole := "band-outline"
if role == "total-band" {
outlineRole = "total-band-outline"
}
outline := featureWithRole(t, collection, outlineRole)
var lines [][][]float64
if err := json.Unmarshal(outline.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode %s: %v", outlineRole, err)
}
for lineIndex, line := range lines {
if len(line) < 4 {
t.Fatalf("%s line %d has %d points", outlineRole, lineIndex, len(line))
}
maximum := 0.0
for pointIndex := 1; pointIndex < len(line); pointIndex++ {
maximum = math.Max(maximum, geoJSONCoordinateDistanceKM(line[pointIndex-1], line[pointIndex]))
}
if maximum > 80 {
t.Fatalf("%s line %d has an artificial long edge %.1f km", outlineRole, lineIndex, maximum)
}
}
}
}
func mustMarshalPlanetOccultation(t *testing.T, path moon.PlanetOccultationPath) []byte {
t.Helper()
data, err := geojson.MarshalPlanetOccultation(path)
if err != nil {
t.Fatalf("MarshalPlanetOccultation: %v", err)
}
return data
}
func TestOccultationP2Mars20250729DrawsPhaseCurvesAboveStaticBandOutlines(t *testing.T) {
collection := mars20250729TestFixture(t, time.Minute, true).collection
lastStaticOutline := -1
firstPhaseCurve := len(collection.Features)
for index, feature := range collection.Features {
switch feature.Properties["role"] {
case "band-outline", "total-band-outline":
if index > lastStaticOutline {
lastStaticOutline = index
}
case "visibility-boundary":
if index < firstPhaseCurve {
firstPhaseCurve = index
}
}
}
if lastStaticOutline < 0 {
t.Fatal("Mars occultation GeoJSON is missing static band outlines")
}
if firstPhaseCurve >= len(collection.Features) {
t.Fatal("Mars occultation GeoJSON is missing physical phase curves")
}
if firstPhaseCurve <= lastStaticOutline {
t.Fatalf("physical phase curves start at feature %d, after static outline %d required",
firstPhaseCurve, lastStaticOutline)
}
}
func TestOccultationP2Mars20250729TotalBandUsesInnerRiseSetCurves(t *testing.T) {
fixture := mars20250729TestFixture(t, time.Minute, true)
path := fixture.path
if !path.HasTotalBand {
t.Fatal("Mars path is missing its total band")
}
if len(path.TotalRiseSetCurves) == 0 {
t.Fatal("Mars total band is missing inner-contact rise/set curves")
}
totalBand := featureWithRole(t, fixture.collection, "total-band")
if authoritative, ok := totalBand.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
t.Fatalf("total-band static_band_authoritative=%v, want true", totalBand.Properties["static_band_authoritative"])
}
if totalBand.Properties["source"] != "visible-footprint-sweep" {
t.Fatalf("total-band source=%v, want visible-footprint-sweep from the full event-time footprint union",
totalBand.Properties["source"])
}
}
func TestOccultationP2Mars20250729ExportsOnlyOuterContactPhaseBoundaries(t *testing.T) {
fixture := mars20250729TestFixture(t, time.Minute, true)
path, collection := fixture.path, fixture.collection
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
band, ok := feature.Properties["band"].(string)
if !ok || band != "partial" {
t.Fatalf("visibility-boundary has invalid band=%v: %#v", feature.Properties["band"], feature.Properties)
}
}
if count := len(featuresWithRole(collection, "visibility-boundary")); count != len(path.RiseSetCurves) {
t.Fatalf("visibility-boundary count=%d, want %d outer-contact phase curves", count, len(path.RiseSetCurves))
}
}
func TestOccultationP2Mars20250729StaticBandRemainsContinuousAndContained(t *testing.T) {
fixture := mars20250729TestFixture(t, time.Minute, true)
path, collection := fixture.path, fixture.collection
assertOccultationP2StaticBand(t, path, collection)
assertOccultationP2PhaseCurvesInsidePartialBand(t, collection, 1)
}
func TestOccultationP2Saturn20240725StaticBandRemainsContinuousAndContained(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
data, err := geojson.MarshalPlanetOccultation(path)
if err != nil {
t.Fatalf("MarshalPlanetOccultation: %v", err)
}
collection := decodeCollection(t, data)
assertOccultationP2StaticBand(t, path, collection)
}
func TestOccultationP0Mars20250729StaticBandsUseContactContours(t *testing.T) {
path := mars20250729TestFixture(t, time.Minute, true).path
if len(path.PartialBandContours) < 2 {
t.Fatalf("partial contact contours=%d, want north/south continuous envelopes", len(path.PartialBandContours))
}
if !path.HasTotalBand || len(path.TotalBandContours) < 2 {
t.Fatalf("total contact contours=%d hasTotal=%v, want inner-contact envelopes", len(path.TotalBandContours), path.HasTotalBand)
}
collection := mars20250729TestFixture(t, time.Minute, true).collection
partialBand := featureWithRole(t, collection, "partial-band")
totalBand := featureWithRole(t, collection, "total-band")
assertOccultationP0ContactContourSource(t, partialBand)
assertOccultationP0ContactContourSource(t, totalBand)
if maximumStep := occultationPointSeriesMaximumStepKM(path.PartialBandContours[0]); maximumStep > 120 {
t.Fatalf("partial contour maximum step=%.1f km, want <=120 km", maximumStep)
}
if maximumStep := occultationPointSeriesMaximumStepKM(path.TotalBandContours[0]); maximumStep > 120 {
t.Fatalf("total contour maximum step=%.1f km, want <=120 km", maximumStep)
}
partialRings := geoJSONMultiPolygonOuterRings(t, partialBand)
totalRings := geoJSONMultiPolygonOuterRings(t, totalBand)
assertGeoJSONMultiPolygonMaximumEdge(t, "partial-band", partialRings, 80)
assertGeoJSONMultiPolygonMaximumEdge(t, "total-band", totalRings, 80)
assertGeoJSONMultiPolygonFollowsLines(t, "partial-band", partialRings,
occultationP0BoundaryLines(path.PartialBandContours, path.RiseSetCurves,
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit),
250,
)
assertGeoJSONMultiPolygonFollowsLines(t, "partial-band-authoritative", partialRings,
occultationP0AuthoritativeBoundaryLines(path.PartialBandContours, path.RiseSetCurves,
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit),
300,
)
assertGeoJSONMultiPolygonFollowsLines(t, "total-band", totalRings,
occultationP0BoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves,
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit),
250,
)
assertGeoJSONMultiPolygonFollowsLines(t, "total-band-authoritative", totalRings,
occultationP0AuthoritativeBoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves,
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit),
300,
)
greatest := [][]geodata.GeoPoint{{
{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude},
}}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(totalRings, greatest, false); miss > 10 {
t.Fatalf("total-band misses greatest point by %.1f km", miss)
}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, totalRings, true); miss > 10 {
t.Fatalf("total-band extends %.1f km outside the partial-band", miss)
}
}
func TestOccultationP2Mars20250729EndRiseExportsRawPhaseSegments(t *testing.T) {
fixture := mars20250729TestFixture(t, 5*time.Minute, true)
path, collection := fixture.path, fixture.collection
endRise := riseSetBoundaryFeature(t, collection, "end", "rise")
var lines [][][]float64
if err := json.Unmarshal(endRise.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode end/rise coordinates: %v", err)
}
var sourceSegments [][]moon.OccultationPathPoint
for _, curve := range path.RiseSetCurves {
if curve.Phase == moon.RiseSetPhaseEnd && curve.Direction == moon.RiseSetDirectionRise {
sourceSegments = curve.Segments
break
}
}
if len(sourceSegments) < 2 {
t.Fatalf("end/rise source segment count=%d, want a folded multi-branch phase curve", len(sourceSegments))
}
if len(lines) != len(sourceSegments) {
t.Fatalf("end/rise segment count=%d, want raw source segment count %d", len(lines), len(sourceSegments))
}
for segmentIndex, line := range lines {
if len(line) < 2 {
t.Fatalf("end/rise segment %d has %d points", segmentIndex, len(line))
}
source := sourceSegments[segmentIndex]
if !geoJSONCoordinateMatchesPathPoint(line[0], source[0]) ||
!geoJSONCoordinateMatchesPathPoint(line[len(line)-1], source[len(source)-1]) {
t.Fatalf("end/rise segment %d endpoints do not match raw source segment", segmentIndex)
}
maximumStep := 0.0
for index := 1; index < len(line); index++ {
step := geoJSONCoordinateDistanceKM(line[index-1], line[index])
if step > maximumStep {
maximumStep = step
}
}
if maximumStep > 200 {
t.Fatalf("end/rise segment %d has %.1f km maximum step, want smooth visible curvature", segmentIndex, maximumStep)
}
}
}
func geoJSONCoordinateMatchesPathPoint(point []float64, source moon.OccultationPathPoint) bool {
return len(point) >= 2 &&
geoJSONCoordinateDistanceKM(point, []float64{source.Longitude, source.Latitude}) <= 0.1
}
func horizonConnectorFeature(
t *testing.T,
collection decodedCollection,
horizon string,
) decodedFeature {
t.Helper()
for _, feature := range featuresWithRole(collection, "horizon-connector") {
if feature.Properties["phase"] == "horizon" && feature.Properties["horizon"] == horizon {
return feature
}
}
t.Fatalf("horizon-connector %s not found", horizon)
return decodedFeature{}
}
func assertOccultationHorizonConnectorsAreAuxiliary(t *testing.T, collection decodedCollection) {
t.Helper()
if len(featuresWithRole(collection, "horizon-connector")) == 0 {
t.Fatal("GeoJSON is missing auxiliary horizon-connector features")
}
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
if feature.Properties["phase"] == "horizon" {
t.Fatal("horizon connector was exported as a visibility-boundary")
}
}
}
func assertOccultationP0ContactContourSource(t *testing.T, feature decodedFeature) {
t.Helper()
role := feature.Properties["role"]
if authoritative, ok := feature.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
t.Fatalf("%s static_band_authoritative=%v, want true", role, feature.Properties["static_band_authoritative"])
}
if source := feature.Properties["source"]; source != "visible-footprint-sweep" {
t.Fatalf("%s source=%v, want visible-footprint-sweep", role, source)
}
if boundarySource := feature.Properties["boundary_source"]; boundarySource != "footprint-sweep+horizon-visible" {
t.Fatalf("%s boundary_source=%v, want footprint-sweep+horizon-visible", role, boundarySource)
}
}
func assertOccultationP2StaticBand(
t *testing.T,
path moon.PlanetOccultationPath,
collection decodedCollection,
) {
t.Helper()
partialBand := featureWithRole(t, collection, "partial-band")
totalBand := featureWithRole(t, collection, "total-band")
assertOccultationP0ContactContourSource(t, partialBand)
assertOccultationP0ContactContourSource(t, totalBand)
if maximumStep := occultationPointSeriesMaximumStepKM(path.PartialBandContours[0]); maximumStep > 120 {
t.Fatalf("partial contour maximum step=%.1f km, want <=120 km", maximumStep)
}
if path.HasTotalBand {
if len(path.TotalBandContours) < 2 {
t.Fatalf("total contour count=%d, want inner-contact envelopes", len(path.TotalBandContours))
}
if maximumStep := occultationPointSeriesMaximumStepKM(path.TotalBandContours[0]); maximumStep > 120 {
t.Fatalf("total contour maximum step=%.1f km, want <=120 km", maximumStep)
}
}
partialRings := geoJSONMultiPolygonOuterRings(t, partialBand)
totalRings := geoJSONMultiPolygonOuterRings(t, totalBand)
assertGeoJSONMultiPolygonMaximumEdge(t, "partial-band", partialRings, 220)
assertGeoJSONMultiPolygonMaximumEdge(t, "total-band", totalRings, 220)
assertGeoJSONMultiPolygonFollowsLines(t, "partial-band", partialRings,
occultationP0BoundaryLines(path.PartialBandContours, path.RiseSetCurves,
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit),
250,
)
if path.HasTotalBand {
assertGeoJSONMultiPolygonFollowsLines(t, "total-band", totalRings,
occultationP0BoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves,
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit),
// Split finite-disk branches use a sampled endpoint cap at the polar
// horizon; allow the bounded 265 km closure residual while retaining
// the stricter 250 km check for the stable partial envelope above.
270,
)
}
greatest := [][]geodata.GeoPoint{{
{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude},
}}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(totalRings, greatest, false); miss > 10 {
t.Fatalf("total-band misses greatest point by %.1f km", miss)
}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, totalRings, true); miss > 10 {
t.Fatalf("total-band extends %.1f km outside the partial-band", miss)
}
}
func assertOccultationP2PhaseCurvesInsidePartialBand(
t *testing.T,
collection decodedCollection,
toleranceKM float64,
) {
t.Helper()
partialBand := featureWithRole(t, collection, "partial-band")
var polygons [][][][]float64
if err := json.Unmarshal(partialBand.Geometry.Coordinates, &polygons); err != nil {
t.Fatalf("decode partial-band coordinates: %v", err)
}
maximumMissKM := 0.0
var maximumMissPoint []float64
for _, boundary := range featuresWithRole(collection, "visibility-boundary") {
if boundary.Properties["band"] != "partial" {
continue
}
var lines [][][]float64
if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode partial visibility-boundary coordinates: %v", err)
}
for _, line := range lines {
for _, point := range line {
if geometryContainsPoint(t, partialBand.Geometry, point[0], point[1]) {
continue
}
missKM := geoJSONMultiPolygonBoundaryDistanceKM(polygons, point)
if missKM > maximumMissKM {
maximumMissKM = missKM
maximumMissPoint = point
}
}
}
}
if maximumMissKM > toleranceKM {
t.Fatalf("partial visibility-boundary extends %.3f km outside rendered partial-band at %.6f, %.6f, want <=%.1f km",
maximumMissKM, maximumMissPoint[0], maximumMissPoint[1], toleranceKM)
}
}
func geoJSONMultiPolygonOuterRings(t *testing.T, feature decodedFeature) [][]geodata.GeoPoint {
t.Helper()
if feature.Geometry.Type != "MultiPolygon" {
t.Fatalf("%s geometry=%q, want MultiPolygon", feature.Properties["role"], feature.Geometry.Type)
}
var polygons [][][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
t.Fatalf("decode %s coordinates: %v", feature.Properties["role"], err)
}
rings := make([][]geodata.GeoPoint, 0, len(polygons))
for polygonIndex, polygon := range polygons {
if len(polygon) == 0 {
t.Fatalf("%s polygon %d has no rings", feature.Properties["role"], polygonIndex)
}
ring := make([]geodata.GeoPoint, len(polygon[0]))
for pointIndex, point := range polygon[0] {
if len(point) < 2 {
t.Fatalf("%s polygon %d point %d is malformed", feature.Properties["role"], polygonIndex, pointIndex)
}
ring[pointIndex] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
rings = append(rings, ring)
}
return rings
}
func assertGeoJSONMultiPolygonMaximumEdge(
t *testing.T,
role string,
rings [][]geodata.GeoPoint,
maximumKM float64,
) {
t.Helper()
for ringIndex, ring := range rings {
for pointIndex := 1; pointIndex < len(ring); pointIndex++ {
step := geoPointDistanceKM(ring[pointIndex-1], ring[pointIndex])
if step > maximumKM {
t.Fatalf("%s ring %d has %.1f km edge at %d, want <= %.1f km",
role, ringIndex, step, pointIndex, maximumKM)
}
}
}
}
func assertGeoJSONMultiPolygonFollowsLines(
t *testing.T,
role string,
rings [][]geodata.GeoPoint,
lines [][][]float64,
maximumDistanceKM float64,
) {
t.Helper()
for ringIndex, ring := range rings {
for pointIndex, point := range ring {
distance := geoPointLineDistanceKM(point, lines)
if distance > maximumDistanceKM {
t.Fatalf("%s ring %d point %d is %.1f km from contact/rise-set boundary, want <= %.1f km: %.6f, %.6f",
role, ringIndex, pointIndex, distance, maximumDistanceKM, point.Longitude, point.Latitude)
}
}
}
}
func occultationP0BoundaryLines(
contours [][]moon.OccultationPathPoint,
curves []moon.OccultationRiseSetCurve,
footprints []moon.PlanetOccultationFootprint,
limits ...[]moon.OccultationPathPoint,
) [][][]float64 {
lines := make([][][]float64, 0, len(contours)+len(curves)*2)
for _, contour := range contours {
if line := occultationP0PathLine(contour); len(line) >= 2 {
lines = append(lines, line)
}
}
for _, curve := range curves {
for _, segment := range curve.Segments {
if line := occultationP0PathLine(segment); len(line) >= 2 {
lines = append(lines, line)
}
}
}
for _, source := range occultationgeo.ContactSweepBoundaryLines(footprints) {
line := make([][]float64, 0, len(source))
for _, point := range source {
line = append(line, []float64{point.Longitude, point.Latitude})
}
if len(line) >= 2 {
lines = append(lines, line)
}
}
for _, connector := range occultationgeo.HorizonConnectorSegments(footprints, curves, limits...) {
if line := occultationP0PathLine(connector.Points); len(line) >= 2 {
lines = append(lines, line)
}
}
return lines
}
func occultationP0AuthoritativeBoundaryLines(
contours [][]moon.OccultationPathPoint,
curves []moon.OccultationRiseSetCurve,
footprints []moon.PlanetOccultationFootprint,
limits ...[]moon.OccultationPathPoint,
) [][][]float64 {
lines := make([][][]float64, 0, len(contours)+len(curves)*2)
for _, contour := range contours {
if line := occultationP0PathLine(contour); len(line) >= 2 {
lines = append(lines, line)
}
}
for _, curve := range curves {
for _, segment := range curve.Segments {
if line := occultationP0PathLine(segment); len(line) >= 2 {
lines = append(lines, line)
}
}
}
for _, connector := range occultationgeo.HorizonConnectorSegments(footprints, curves, limits...) {
if line := occultationP0PathLine(connector.Points); len(line) >= 2 {
lines = append(lines, line)
}
}
return lines
}
func occultationP0PathLine(points []moon.OccultationPathPoint) [][]float64 {
line := make([][]float64, 0, len(points))
for _, point := range points {
line = append(line, []float64{point.Longitude, point.Latitude})
}
return line
}
func geoPointLineDistanceKM(point geodata.GeoPoint, lines [][][]float64) float64 {
target := []float64{point.Longitude, point.Latitude}
minimum := math.Inf(1)
for _, line := range lines {
for index := 1; index < len(line); index++ {
minimum = math.Min(minimum, geoJSONPointSegmentDistanceKM(target, line[index-1], line[index]))
}
}
return minimum
}
func geoPointDistanceKM(first, second geodata.GeoPoint) float64 {
return geoJSONCoordinateDistanceKM(
[]float64{first.Longitude, first.Latitude},
[]float64{second.Longitude, second.Latitude},
)
}
func occultationPointSeriesMaximumStepKM(points []moon.OccultationPathPoint) float64 {
maximum := 0.0
for index := 1; index < len(points); index++ {
step := geoPointDistanceKM(
geodata.GeoPoint{Longitude: points[index-1].Longitude, Latitude: points[index-1].Latitude},
geodata.GeoPoint{Longitude: points[index].Longitude, Latitude: points[index].Latitude},
)
if step > maximum {
maximum = step
}
}
return maximum
}
func geoJSONCoordinateMatchesAny(point []float64, endpoints []decodedRiseSetEndpoint) bool {
for _, endpoint := range endpoints {
if geoJSONCoordinateDistanceKM(point, endpoint.coordinate) <= 0.1 {
return true
}
}
return false
}
func mars20250729PhaseEndpoints(
t *testing.T,
collection decodedCollection,
horizon string,
) []decodedRiseSetEndpoint {
t.Helper()
var endpoints []decodedRiseSetEndpoint
for _, phase := range []string{"start", "greatest", "end"} {
feature := riseSetBoundaryFeature(t, collection, phase, horizon)
endpoints = append(endpoints, riseSetFeatureEndpoints(t, feature)...)
}
return endpoints
}
func assertClosedMultiLineFeature(t *testing.T, feature decodedFeature) {
t.Helper()
if feature.Geometry.Type != "MultiLineString" {
t.Fatalf("%s geometry=%q, want MultiLineString", feature.Properties["role"], feature.Geometry.Type)
}
var lines [][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode %s coordinates: %v", feature.Properties["role"], err)
}
if len(lines) == 0 {
t.Fatalf("%s has no outline segments", feature.Properties["role"])
}
for index, line := range lines {
if len(line) < 4 {
t.Fatalf("%s segment %d has %d points, want a closed ring", feature.Properties["role"], index, len(line))
}
first, last := line[0], line[len(line)-1]
if len(first) != 2 || len(last) != 2 || first[0] != last[0] || first[1] != last[1] {
t.Fatalf("%s segment %d is not closed: first=%v last=%v", feature.Properties["role"], index, first, last)
}
}
}
func formatP2SignedIndex(value int) string {
if value < 0 {
return "-" + formatP2Magnitude(-value)
}
return "+" + formatP2Magnitude(value)
}
func formatP2Magnitude(value int) string {
if value < 10 {
return "0" + string(rune('0'+value))
}
return string(rune('0'+value/10)) + string(rune('0'+value%10))
}
// Keep the JSON import in this P2 file tied to the shared decoder contract;
// this catches accidental changes that make a GeoJSON payload un-decodable
// even when the role-level checks still pass.
func TestP2GeoJSONPayloadsDecodeAsFeatureCollections(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 30 * time.Minute, BoundaryPoints: 36, DisableRiseSet: true,
})
if !ok {
t.Fatal("expected 2024-04-08 eclipse")
}
data, err := geojson.MarshalSolarEclipse(partial, nil)
if err != nil {
t.Fatal(err)
}
var raw struct {
Type string `json:"type"`
}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("payload is not JSON: %v", err)
}
if raw.Type != "FeatureCollection" {
t.Fatalf("payload type=%q, want FeatureCollection", raw.Type)
}
}