feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user