2026-08-06 12:00:56 +08:00
|
|
|
package geojson_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-09-17 12:27:40 +08:00
|
|
|
"fmt"
|
2026-08-06 12:00:56 +08:00
|
|
|
"math"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"b612.me/astro/eclipse"
|
|
|
|
|
"b612.me/astro/geojson"
|
2026-09-17 12:27:40 +08:00
|
|
|
"b612.me/astro/internal/geodata"
|
2026-08-06 12:00:56 +08:00
|
|
|
"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"`
|
2026-09-17 12:27:40 +08:00
|
|
|
Geometries json.RawMessage `json:"geometries"`
|
2026-08-06 12:00:56 +08:00
|
|
|
} `json:"geometry"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
func solarGeoJSONMapClosureEdge(first, second []float64) bool {
|
|
|
|
|
return len(first) >= 2 && len(second) >= 2 &&
|
|
|
|
|
(math.Abs(first[1]) >= 89.999999 || math.Abs(second[1]) >= 89.999999 ||
|
|
|
|
|
math.Abs(first[0]) == 180 && first[0] == second[0])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// solarGeoJSONScanCollection 是扫描类测试读取导出集合的最小结构:只需要属性与几何坐标。
|
|
|
|
|
type solarGeoJSONScanCollection struct {
|
|
|
|
|
Features []struct {
|
|
|
|
|
Properties map[string]interface{} `json:"properties"`
|
|
|
|
|
Geometry struct {
|
|
|
|
|
Coordinates json.RawMessage `json:"coordinates"`
|
|
|
|
|
} `json:"geometry"`
|
|
|
|
|
} `json:"features"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
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{
|
2026-09-17 12:27:40 +08:00
|
|
|
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", "partial-band", "central-band", "center-line", "north-limit", "south-limit", "greatest")
|
|
|
|
|
assertCollectionCoordinates(t, collection)
|
|
|
|
|
partialBand := featureWithRole(t, collection, "partial-band")
|
|
|
|
|
if partialBand.Geometry.Type != "MultiPolygon" {
|
|
|
|
|
t.Fatalf("partial-band geometry=%q, want MultiPolygon", partialBand.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
if partialBand.Properties["source"] != "zero-magnitude-envelope+horizon-boundary" {
|
|
|
|
|
t.Fatalf("partial-band source=%v", partialBand.Properties["source"])
|
|
|
|
|
}
|
|
|
|
|
assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band"))
|
|
|
|
|
assertTimedLineAligned(t, featureWithRole(t, collection, "center-line"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20090722BuildsContinuousPartialBand(t *testing.T) {
|
|
|
|
|
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 2 * time.Minute, BoundaryPoints: 96,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 2009-07-22 solar eclipse")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
overlays := featuresWithRole(collection, "partial-band")
|
|
|
|
|
if len(overlays) != 1 {
|
|
|
|
|
t.Fatalf("partial-band count=%d, want one authoritative visibility region", len(overlays))
|
|
|
|
|
}
|
|
|
|
|
if overlays[0].Properties["source"] != "zero-magnitude-envelope+horizon-boundary" {
|
|
|
|
|
t.Fatalf("partial-band source=%v", overlays[0].Properties["source"])
|
|
|
|
|
}
|
|
|
|
|
assertClosedMultiPolygon(t, overlays[0])
|
|
|
|
|
assertCollectionCoordinates(t, collection)
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(overlays[0].Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode partial-band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) < 2 {
|
|
|
|
|
t.Fatalf("partial-band fragments=%d, want antimeridian-safe split geometry", len(polygons))
|
|
|
|
|
}
|
|
|
|
|
for footprintIndex, footprint := range partial.Footprints {
|
|
|
|
|
for boundaryIndex, boundary := range footprint.Boundaries {
|
|
|
|
|
if len(boundary) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
step := (len(boundary) + 7) / 8
|
|
|
|
|
for pointIndex := 0; pointIndex < len(boundary); pointIndex += step {
|
|
|
|
|
point := []float64{boundary[pointIndex].Longitude, boundary[pointIndex].Latitude}
|
|
|
|
|
if !geoJSONMultiPolygonContains(polygons, point[0], point[1]) &&
|
|
|
|
|
geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 5 {
|
|
|
|
|
t.Fatalf("partial footprint %d boundary %d point %d protrudes outside the continuous band",
|
|
|
|
|
footprintIndex, boundaryIndex, pointIndex)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
authoritativeLines := solarPartialBandAuthoritativeLines(partial)
|
|
|
|
|
for polygonIndex, polygon := range polygons {
|
|
|
|
|
for ringIndex, ring := range polygon {
|
|
|
|
|
for pointIndex, point := range ring {
|
|
|
|
|
if distance := geoJSONPointToLinesDistanceKM(point, authoritativeLines); distance > 5 {
|
|
|
|
|
t.Fatalf("partial-band polygon %d ring %d point %d is %.1f km from the zero-magnitude or horizon boundary",
|
|
|
|
|
polygonIndex, ringIndex, pointIndex, distance)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipsePartialBandFallsBackWithoutRiseSetTopology(t *testing.T) {
|
|
|
|
|
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 2 * time.Minute, BoundaryPoints: 48, DisableRiseSet: true,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 2009-07-22 solar eclipse")
|
|
|
|
|
}
|
|
|
|
|
if len(partial.PartialBandContours) != 0 || len(partial.RiseSetCurves) != 0 {
|
|
|
|
|
t.Fatal("disabled rise/set topology unexpectedly produced authoritative boundary lines")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, data), "partial-band")
|
|
|
|
|
if source := band.Properties["source"]; source != "open-boundary-endpoint-outlines" {
|
|
|
|
|
t.Fatalf("fallback partial-band source=%v", source)
|
|
|
|
|
}
|
|
|
|
|
assertClosedMultiPolygon(t, band)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func solarPartialBandAuthoritativeLines(partial eclipse.SolarEclipsePartialFootprintsInfo) [][][]float64 {
|
|
|
|
|
lines := make([][][]float64, 0, len(partial.PartialBandContours)+12)
|
|
|
|
|
appendSegment := func(segment []eclipse.SolarEclipsePathPoint) {
|
|
|
|
|
line := make([][]float64, len(segment))
|
|
|
|
|
for index, point := range segment {
|
|
|
|
|
line[index] = []float64{point.Longitude, point.Latitude}
|
|
|
|
|
}
|
|
|
|
|
lines = append(lines, line)
|
|
|
|
|
}
|
|
|
|
|
for _, contour := range partial.PartialBandContours {
|
|
|
|
|
appendSegment(contour)
|
|
|
|
|
}
|
|
|
|
|
for _, curve := range partial.RiseSetCurves {
|
|
|
|
|
for _, segment := range curve.Segments {
|
|
|
|
|
appendSegment(segment)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return lines
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func geoJSONPointToLinesDistanceKM(point []float64, lines [][][]float64) float64 {
|
|
|
|
|
minimum := math.Inf(1)
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
for index := 1; index < len(line); index++ {
|
|
|
|
|
minimum = math.Min(minimum, geoJSONPointSegmentDistanceKM(point, line[index-1], line[index]))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return minimum
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20350902RiseSetCurveOrder(t *testing.T) {
|
|
|
|
|
date := time.Date(2035, time.September, 2, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar partial footprints")
|
|
|
|
|
}
|
|
|
|
|
for curveIndex, curve := range partial.RiseSetCurves {
|
|
|
|
|
for segmentIndex, segment := range curve.Segments {
|
|
|
|
|
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
|
|
|
|
|
if !segment[pointIndex].Time.After(segment[pointIndex-1].Time) {
|
|
|
|
|
t.Fatalf("curve=%d phase=%s direction=%s segment=%d point=%d previous=%s current=%s", curveIndex, curve.Phase, curve.Direction, segmentIndex, pointIndex, segment[pointIndex-1].Time.Format(time.RFC3339Nano), segment[pointIndex].Time.Format(time.RFC3339Nano))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar central path")
|
|
|
|
|
}
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse19851101CentralLimitsHaveStrictTimes(t *testing.T) {
|
|
|
|
|
date := time.Date(1985, time.November, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute, BoundaryPoints: 36,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 1985-11-01 partial footprints")
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
|
|
|
Step: 10 * time.Minute,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 1985-11-01 central path")
|
|
|
|
|
}
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipseExportsVisibilityAntumbralAndMagnitudeLines(t *testing.T) {
|
|
|
|
|
date := time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute,
|
|
|
|
|
BoundaryPoints: 24,
|
|
|
|
|
CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
MagnitudeValues: []float64{0.4, 0.8, 1.0},
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected non-central annular partial footprints")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
assertRoles(t, collection, "visibility-boundary", "central-shadow-footprint", "central-band", "magnitude-line", "greatest")
|
|
|
|
|
if got := len(featuresWithRole(collection, "central-shadow-footprint")); got < 3 {
|
|
|
|
|
t.Fatalf("central-shadow-footprint count=%d, want at least 3", got)
|
|
|
|
|
}
|
|
|
|
|
// 被地平线切断的中心影足迹仍然是区域;物理边界另出 central-shadow-boundary 供描边。
|
|
|
|
|
// A horizon-cut central-shadow footprint is still a region; its physical boundary
|
|
|
|
|
// is exported separately as central-shadow-boundary.
|
|
|
|
|
openFootprints := 0
|
|
|
|
|
for _, footprint := range featuresWithRole(collection, "central-shadow-footprint") {
|
|
|
|
|
if footprint.Geometry.Type != "MultiPolygon" {
|
|
|
|
|
t.Fatalf("central-shadow-footprint geometry=%q, want MultiPolygon", footprint.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
if closed, ok := footprint.Properties["source_boundary_closed"].(bool); !ok || !closed {
|
|
|
|
|
openFootprints++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
boundaries := featuresWithRole(collection, "central-shadow-boundary")
|
|
|
|
|
if len(boundaries) != openFootprints {
|
|
|
|
|
t.Fatalf("central-shadow-boundary count=%d, want %d horizon-cut footprints", len(boundaries), openFootprints)
|
|
|
|
|
}
|
|
|
|
|
for _, boundary := range boundaries {
|
|
|
|
|
if boundary.Geometry.Type != "MultiLineString" {
|
|
|
|
|
t.Fatalf("central-shadow-boundary geometry=%q, want MultiLineString", boundary.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
centralBand := featureWithRole(t, collection, "central-band")
|
|
|
|
|
assertClosedMultiPolygon(t, centralBand)
|
|
|
|
|
var centralBandPolygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(centralBand.Geometry.Coordinates, ¢ralBandPolygons); err != nil {
|
|
|
|
|
t.Fatalf("decode central-band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(centralBandPolygons) != 1 {
|
|
|
|
|
t.Fatalf("non-central central-band polygon count=%d, want one continuous sweep", len(centralBandPolygons))
|
|
|
|
|
}
|
|
|
|
|
if centralBand.Properties["source"] != "besselian-critical-envelope" {
|
|
|
|
|
t.Fatalf("central-band source=%v, want besselian-critical-envelope", centralBand.Properties["source"])
|
|
|
|
|
}
|
|
|
|
|
lines := featuresWithRole(collection, "magnitude-line")
|
|
|
|
|
if len(lines) != 2 {
|
|
|
|
|
t.Fatalf("magnitude-line count=%d, want one MultiLineString per requested magnitude", len(lines))
|
|
|
|
|
}
|
|
|
|
|
seen := make(map[string]bool)
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
magnitude, ok := line.Properties["magnitude"].(float64)
|
|
|
|
|
if !ok || magnitude <= 0 || magnitude > 1 {
|
|
|
|
|
t.Fatalf("invalid magnitude property: %#v", line.Properties["magnitude"])
|
|
|
|
|
}
|
|
|
|
|
if line.Geometry.Type != "MultiLineString" {
|
|
|
|
|
t.Fatalf("magnitude %.1f geometry=%q, want MultiLineString", magnitude, line.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
assertTimedLineAligned(t, line)
|
|
|
|
|
seen[fmt.Sprintf("%.1f", magnitude)] = true
|
|
|
|
|
}
|
|
|
|
|
for _, key := range []string{"0.4", "0.8"} {
|
|
|
|
|
if !seen[key] {
|
|
|
|
|
t.Fatalf("missing magnitude line %s", key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
assertRiseSetBoundaryFeatures(t, collection, "sun")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20140429NonCentralBandHasNoCombTeeth(t *testing.T) {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
|
|
|
|
time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 2014 non-central annular eclipse")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
partialBand := featureWithRole(t, collection, "partial-band")
|
|
|
|
|
source, _ := partialBand.Properties["source"].(string)
|
|
|
|
|
if source != "zero-magnitude-envelope+horizon-boundary" &&
|
|
|
|
|
source != "open-boundary-endpoint-outlines" {
|
|
|
|
|
t.Fatalf("2014 partial-band source=%q", source)
|
|
|
|
|
}
|
|
|
|
|
assertClosedMultiPolygon(t, partialBand)
|
|
|
|
|
if source == "zero-magnitude-envelope+horizon-boundary" {
|
|
|
|
|
var partialPolygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(partialBand.Geometry.Coordinates, &partialPolygons); err != nil {
|
|
|
|
|
t.Fatalf("decode partial band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for footprintIndex, footprint := range partial.Footprints {
|
|
|
|
|
for boundaryIndex, boundary := range footprint.Boundaries {
|
|
|
|
|
for pointIndex, point := range boundary {
|
|
|
|
|
coordinate := []float64{point.Longitude, point.Latitude}
|
|
|
|
|
if !geoJSONMultiPolygonContains(partialPolygons, coordinate[0], coordinate[1]) &&
|
|
|
|
|
geoJSONMultiPolygonBoundaryDistanceKM(partialPolygons, coordinate) > 5 {
|
|
|
|
|
t.Fatalf("authoritative partial-band misses footprint %d boundary %d point %d",
|
|
|
|
|
footprintIndex, boundaryIndex, pointIndex)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, collection, "central-band")
|
|
|
|
|
if band.Properties["source"] != "besselian-critical-envelope" {
|
|
|
|
|
t.Fatalf("central-band source=%v, want besselian-critical-envelope", band.Properties["source"])
|
|
|
|
|
}
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode central band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) != 1 || len(polygons[0]) != 1 {
|
|
|
|
|
t.Fatalf("2014 central band polygons=%d rings=%d, want one exterior ring", len(polygons), len(polygons[0]))
|
|
|
|
|
}
|
|
|
|
|
ring := polygons[0][0]
|
|
|
|
|
if len(ring) >= 300 {
|
|
|
|
|
t.Fatalf("2014 central-band ring points=%d, sampled ribbon union likely retained comb teeth", len(ring))
|
|
|
|
|
}
|
|
|
|
|
perimeter := 0.0
|
|
|
|
|
maximumEdge := 0.0
|
|
|
|
|
minimumGreatestDistance := math.Inf(1)
|
|
|
|
|
greatest := []float64{partial.Eclipse.GreatestLongitude, partial.Eclipse.GreatestLatitude}
|
|
|
|
|
for index := 1; index < len(ring); index++ {
|
|
|
|
|
edge := geoJSONCoordinateDistanceKM(ring[index-1], ring[index])
|
|
|
|
|
perimeter += edge
|
|
|
|
|
maximumEdge = math.Max(maximumEdge, edge)
|
|
|
|
|
minimumGreatestDistance = math.Min(minimumGreatestDistance,
|
|
|
|
|
geoJSONPointSegmentDistanceKM(greatest, ring[index-1], ring[index]))
|
|
|
|
|
}
|
|
|
|
|
if perimeter >= 3000 {
|
|
|
|
|
t.Fatalf("2014 central band perimeter=%.1f km, likely contains comb-like retracing", perimeter)
|
|
|
|
|
}
|
|
|
|
|
if maximumEdge > 16 {
|
|
|
|
|
t.Fatalf("2014 central-band maximum edge=%.3f km, want a spatially refined boundary", maximumEdge)
|
|
|
|
|
}
|
|
|
|
|
// NASA rounds the non-central greatest marker independently from the local
|
|
|
|
|
// greatest-at-sunset boundary. They are close, but are not the same root.
|
|
|
|
|
if minimumGreatestDistance > 2 {
|
|
|
|
|
t.Fatalf("2014 greatest marker is %.3f km from the grazing band tip", minimumGreatestDistance)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var greatestSet decodedFeature
|
|
|
|
|
foundGreatestSet := false
|
|
|
|
|
for _, feature := range featuresWithRole(decodeCollection(t, data), "visibility-boundary") {
|
|
|
|
|
if feature.Properties["phase"] == "greatest" && feature.Properties["horizon"] == "set" {
|
|
|
|
|
greatestSet, foundGreatestSet = feature, true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !foundGreatestSet {
|
|
|
|
|
t.Fatal("missing greatest-at-sunset visibility boundary")
|
|
|
|
|
}
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(greatestSet.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode greatest-at-sunset line: %v", err)
|
|
|
|
|
}
|
|
|
|
|
maximumSharedBoundaryDistance := 0.0
|
|
|
|
|
sharedPoints := 0
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
for _, point := range line {
|
|
|
|
|
minimumDistance := math.Inf(1)
|
|
|
|
|
for index := 1; index < len(ring); index++ {
|
|
|
|
|
minimumDistance = math.Min(minimumDistance,
|
|
|
|
|
geoJSONPointSegmentDistanceKM(point, ring[index-1], ring[index]))
|
|
|
|
|
}
|
|
|
|
|
if minimumDistance <= 0.01 {
|
|
|
|
|
sharedPoints++
|
|
|
|
|
maximumSharedBoundaryDistance = math.Max(maximumSharedBoundaryDistance, minimumDistance)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if sharedPoints < 80 || maximumSharedBoundaryDistance > 0.01 {
|
|
|
|
|
t.Fatalf("greatest-at-sunset line shares %d exact band-edge samples (max %.6f km), want a dense coincident edge", sharedPoints, maximumSharedBoundaryDistance)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20430409NonCentralTotalBandUsesCriticalEnvelope(t *testing.T) {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
|
|
|
|
time.Date(2043, time.April, 9, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if !ok || partial.Eclipse.Type != eclipse.SolarEclipseTotal ||
|
|
|
|
|
partial.Eclipse.Centrality != eclipse.SolarEclipseNonCentral {
|
|
|
|
|
t.Fatalf("2043 eclipse type=%s centrality=%s ok=%v, want non-central total",
|
|
|
|
|
partial.Eclipse.Type, partial.Eclipse.Centrality, ok)
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, mustMarshalSolarEclipse(t, partial)), "central-band")
|
|
|
|
|
if band.Properties["source"] != "besselian-critical-envelope" {
|
|
|
|
|
t.Fatalf("2043 central-band source=%v, want besselian-critical-envelope", band.Properties["source"])
|
|
|
|
|
}
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode 2043 central band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) != 1 || len(polygons[0]) != 1 {
|
|
|
|
|
t.Fatalf("2043 central band polygons=%d rings=%d, want one exterior ring", len(polygons), len(polygons[0]))
|
|
|
|
|
}
|
|
|
|
|
ring := polygons[0][0]
|
|
|
|
|
if len(ring) < 100 || len(ring) > 500 {
|
|
|
|
|
t.Fatalf("2043 central-band ring points=%d, want one compact smooth envelope", len(ring))
|
|
|
|
|
}
|
|
|
|
|
for index := 1; index < len(ring); index++ {
|
|
|
|
|
if edge := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); edge > 28 {
|
|
|
|
|
t.Fatalf("2043 central-band edge %d=%.3f km, want adaptive spatial sampling", index-1, edge)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for first := 0; first+1 < len(ring); first++ {
|
|
|
|
|
for second := first + 2; second+1 < len(ring); second++ {
|
|
|
|
|
if first == 0 && second+1 == len(ring)-1 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) {
|
|
|
|
|
t.Fatalf("2043 central-band ring self-intersects between edges %d and %d", first, second)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20430409PartialBandHasNoPolarSeam(t *testing.T) {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
|
|
|
|
time.Date(2043, time.April, 9, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 2043-04-09 solar eclipse")
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, mustMarshalSolarEclipse(t, partial)), "partial-band")
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode 2043 partial band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
assertClosedMultiPolygon(t, band)
|
|
|
|
|
// A band containing the pole can be one closed map fragment. Check both
|
|
|
|
|
// sides of the antimeridian and independent station visibility instead of
|
|
|
|
|
// requiring a particular number of fragments.
|
|
|
|
|
for _, point := range [][2]float64{
|
|
|
|
|
{149, 60}, {149.5, 60}, {179, 60}, {-179, 60}, {179, 80}, {-179, 80}, {0, 89},
|
|
|
|
|
{0, 40}, {-100, 20}, {110, 40},
|
|
|
|
|
} {
|
|
|
|
|
_, visible := eclipse.LocalSolarEclipseOnDate(time.Date(2043, 4, 9, 0, 0, 0, 0, time.UTC), point[0], point[1], 0)
|
|
|
|
|
if got := geoJSONMultiPolygonContains(polygons, point[0], point[1]); got != visible {
|
|
|
|
|
t.Errorf("2043 partial-band at %v contains=%v, station visibility=%v", point, got, visible)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipseNonCentralGreatestHorizonFolds(t *testing.T) {
|
|
|
|
|
for _, date := range []time.Time{
|
|
|
|
|
time.Date(1656, time.July, 21, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(1928, time.May, 19, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(1957, time.October, 23, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(1967, time.November, 2, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
} {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 2 * time.Minute, BoundaryPoints: 24, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
RiseSetStep: 2 * time.Minute,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("%s: expected eclipse", date.Format("2006-01-02"))
|
|
|
|
|
}
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, nil); err != nil {
|
|
|
|
|
t.Fatalf("%s: MarshalSolarEclipse: %v", date.Format("2006-01-02"), err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse19500318UsesValidatedOpenSweepFallback(t *testing.T) {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
|
|
|
|
time.Date(1950, time.March, 18, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
eclipse.SolarEclipsePartialFootprintOptions{Step: 5 * time.Minute, BoundaryPoints: 24},
|
|
|
|
|
)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 1950 non-central annular eclipse")
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, mustMarshalSolarEclipse(t, partial)), "central-band")
|
|
|
|
|
if band.Properties["source"] != "central-shadow-sweep" {
|
|
|
|
|
t.Fatalf("1950 central-band source=%v, want validated open sweep fallback", band.Properties["source"])
|
|
|
|
|
}
|
|
|
|
|
assertClosedMultiPolygon(t, band)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mustMarshalSolarEclipse(t *testing.T, partial eclipse.SolarEclipsePartialFootprintsInfo) []byte {
|
|
|
|
|
t.Helper()
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func geoJSONSegmentsCross(a, b, c, d []float64) bool {
|
|
|
|
|
orientation := func(first, second, third []float64) float64 {
|
|
|
|
|
return (second[0]-first[0])*(third[1]-first[1]) -
|
|
|
|
|
(second[1]-first[1])*(third[0]-first[0])
|
|
|
|
|
}
|
|
|
|
|
first, second := orientation(a, b, c), orientation(a, b, d)
|
|
|
|
|
third, fourth := orientation(c, d, a), orientation(c, d, b)
|
|
|
|
|
return ((first > 1e-10 && second < -1e-10) || (first < -1e-10 && second > 1e-10)) &&
|
|
|
|
|
((third > 1e-10 && fourth < -1e-10) || (third < -1e-10 && fourth > 1e-10))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipseCoarseCentralPathGracefullyRecovers(t *testing.T) {
|
|
|
|
|
for _, date := range []time.Time{
|
|
|
|
|
time.Date(1891, time.June, 6, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(2119, time.March, 11, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
} {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 60 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, DisableRiseSet: true,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("%s partial footprints unavailable", date.Format("2006-01-02"))
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 60 * time.Minute})
|
|
|
|
|
if !ok || len(central.CenterLine) < 2 {
|
|
|
|
|
t.Fatalf("%s coarse central path points=%d, want at least two", date.Format("2006-01-02"), len(central.CenterLine))
|
|
|
|
|
}
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err != nil {
|
|
|
|
|
t.Fatalf("%s MarshalSolarEclipse: %v", date.Format("2006-01-02"), err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20230420HybridBandFollowsCenterLine(t *testing.T) {
|
|
|
|
|
date := time.Date(2023, time.April, 20, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
MagnitudeValues: []float64{1.0, 1.01},
|
|
|
|
|
})
|
|
|
|
|
if !ok || partial.Eclipse.Type != eclipse.SolarEclipseHybrid {
|
|
|
|
|
t.Fatalf("expected hybrid eclipse, got ok=%v type=%s", ok, partial.Eclipse.Type)
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected hybrid central path")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
band := featureWithRole(t, collection, "central-band")
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode hybrid central band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) != 4 {
|
|
|
|
|
t.Fatalf("hybrid central band polygon count=%d, want three physical lobes split at the antimeridian", len(polygons))
|
|
|
|
|
}
|
|
|
|
|
center := featureWithRole(t, collection, "center-line")
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(center.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode hybrid center line: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for lineIndex, line := range lines {
|
|
|
|
|
for pointIndex, point := range line {
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) {
|
|
|
|
|
t.Fatalf("center line[%d] point %d lies outside hybrid central band at %.6f, %.6f", lineIndex, pointIndex, point[0], point[1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
magnitudeLines := featuresWithRole(collection, "magnitude-line")
|
|
|
|
|
if len(magnitudeLines) != 2 {
|
|
|
|
|
t.Fatalf("hybrid magnitude lines=%d, want two (1.0 and 1.01)", len(magnitudeLines))
|
|
|
|
|
}
|
|
|
|
|
foundHighMagnitude := false
|
|
|
|
|
var magnitudeOne decodedFeature
|
|
|
|
|
for _, line := range magnitudeLines {
|
|
|
|
|
if line.Properties["magnitude"] == 1.0 {
|
|
|
|
|
magnitudeOne = line
|
|
|
|
|
}
|
|
|
|
|
if line.Properties["magnitude"] == 1.01 {
|
|
|
|
|
foundHighMagnitude = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !foundHighMagnitude {
|
|
|
|
|
t.Fatal("hybrid GeoJSON is missing the 1.01 magnitude contour")
|
|
|
|
|
}
|
|
|
|
|
var magnitudeOneLines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(magnitudeOne.Geometry.Coordinates, &magnitudeOneLines); err != nil {
|
|
|
|
|
t.Fatalf("decode hybrid 1.0 magnitude line: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for lineIndex, line := range magnitudeOneLines {
|
|
|
|
|
for _, pointIndex := range []int{0, len(line) - 1} {
|
|
|
|
|
minimumDistance := math.Inf(1)
|
|
|
|
|
for _, centerLine := range lines {
|
|
|
|
|
for _, centerPoint := range centerLine {
|
|
|
|
|
minimumDistance = math.Min(minimumDistance, geoJSONCoordinateDistanceKM(line[pointIndex], centerPoint))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if minimumDistance > 0.1 {
|
|
|
|
|
t.Fatalf("hybrid 1.0 line[%d] endpoint %d misses center line by %.3f km", lineIndex, pointIndex, minimumDistance)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipseExportsTotalMagnitudeAboveOne(t *testing.T) {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
|
|
|
|
time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute, BoundaryPoints: 24, MagnitudeValues: []float64{1.01},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if !ok || len(partial.MagnitudeContours) != 1 {
|
|
|
|
|
t.Fatalf("expected one totality magnitude contour, got ok=%v contours=%d", ok, len(partial.MagnitudeContours))
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
lines := featuresWithRole(collection, "magnitude-line")
|
|
|
|
|
if len(lines) != 1 || lines[0].Properties["magnitude"] != 1.01 {
|
|
|
|
|
t.Fatalf("magnitude lines = %#v, want one line at 1.01", lines)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20260812CentralBandFollowsTotalityEnvelope(t *testing.T) {
|
|
|
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
|
|
|
|
date := time.Date(2026, time.August, 12, 0, 0, 0, 0, zone)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
MagnitudeValues: []float64{1.0},
|
|
|
|
|
})
|
|
|
|
|
if !ok || partial.Eclipse.Type != eclipse.SolarEclipseTotal {
|
|
|
|
|
t.Fatalf("expected 2026-08-12 total eclipse, got ok=%v type=%s", ok, partial.Eclipse.Type)
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
|
|
|
Step: 2 * time.Minute, TargetSpacingKM: 700,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected central path")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
|
|
|
|
assertClosedMultiPolygon(t, band)
|
|
|
|
|
if source := band.Properties["source"]; source != "magnitude-one-envelope" {
|
|
|
|
|
t.Fatalf("2026 central-band source=%v, want magnitude-one-envelope", source)
|
|
|
|
|
}
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode central band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) == 0 || len(polygons) > 2 {
|
|
|
|
|
t.Fatalf("central band has %d polygons, want one physical band with at most one antimeridian split", len(polygons))
|
|
|
|
|
}
|
|
|
|
|
for _, polygon := range polygons {
|
|
|
|
|
if len(polygon) == 0 {
|
|
|
|
|
t.Fatal("central-band polygon has no exterior ring")
|
|
|
|
|
}
|
|
|
|
|
assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250)
|
|
|
|
|
}
|
|
|
|
|
for closureIndex, closure := range partial.CentralBandHorizonClosures {
|
|
|
|
|
for pointIndex, point := range closure {
|
|
|
|
|
coordinate := []float64{point.Longitude, point.Latitude}
|
|
|
|
|
if distance := geoJSONMultiPolygonBoundaryDistanceKM(polygons, coordinate); distance > 0.1 {
|
|
|
|
|
t.Fatalf("horizon closure %d point %d is %.3f km from the magnitude-one boundary", closureIndex, pointIndex, distance)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// These points are independently classified by the local Split-K solver
|
|
|
|
|
// as total, but the old same-time cross-section band omitted them.
|
|
|
|
|
for _, point := range [][2]float64{{-4, 43.25}, {-4, 43.75}, {-2, 42.25}} {
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) {
|
|
|
|
|
t.Fatalf("central band omits independently total point %.2f, %.2f", point[0], point[1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20100115CentralBandContainsFuyang(t *testing.T) {
|
|
|
|
|
date := time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute,
|
|
|
|
|
BoundaryPoints: 180,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar partial footprints")
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
|
|
|
Step: 10 * time.Minute, TargetSpacingKM: 100,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar central path")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
|
|
|
|
if source := band.Properties["source"]; source != "besselian-critical-envelope" {
|
|
|
|
|
t.Fatalf("2010 central-band source=%v, want continuous critical envelope", source)
|
|
|
|
|
}
|
|
|
|
|
if band.Geometry.Type != "MultiPolygon" {
|
|
|
|
|
t.Fatalf("central-band geometry=%q, want one merged MultiPolygon without internal seams", band.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode central-band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) != 1 {
|
|
|
|
|
t.Fatalf("central-band polygon count=%d, want one continuous outline", len(polygons))
|
|
|
|
|
}
|
|
|
|
|
ring := polygons[0][0]
|
|
|
|
|
for first := 0; first+1 < len(ring); first++ {
|
|
|
|
|
for second := first + 2; second+1 < len(ring); second++ {
|
|
|
|
|
if first == 0 && second+1 == len(ring)-1 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) {
|
|
|
|
|
t.Fatalf("2010 central-band ring self-intersects between edges %d and %d", first, second)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for index := 1; index < len(ring); index++ {
|
|
|
|
|
if distance := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); distance > 250 {
|
|
|
|
|
t.Fatalf("central-band edge %d is %.1f km, want a sampled curved outline", index, distance)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
minimumEndTurn := 180.0
|
|
|
|
|
for index := 1; index+1 < len(ring); index++ {
|
|
|
|
|
point := ring[index]
|
|
|
|
|
if point[0] < 120 || point[0] > 123 || point[1] < 36 || point[1] > 39 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
incoming := math.Atan2(point[1]-ring[index-1][1], point[0]-ring[index-1][0])
|
|
|
|
|
outgoing := math.Atan2(ring[index+1][1]-point[1], ring[index+1][0]-point[0])
|
|
|
|
|
minimumEndTurn = math.Min(minimumEndTurn, math.Remainder((outgoing-incoming)*180/math.Pi, 360))
|
|
|
|
|
}
|
|
|
|
|
if minimumEndTurn < -30 {
|
|
|
|
|
t.Fatalf("2010 eastern central-band cap turns inward by %.1f degrees", minimumEndTurn)
|
|
|
|
|
}
|
|
|
|
|
for name, point := range map[string]eclipse.SolarEclipsePathPoint{"U1": partial.U1, "U4": partial.U4} {
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) &&
|
|
|
|
|
geoJSONMultiPolygonBoundaryDistanceKM(polygons, []float64{point.Longitude, point.Latitude}) > 1 {
|
|
|
|
|
t.Fatalf("%s external shadow contact lies outside the swept central band", name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, role := range []string{"north-limit", "south-limit"} {
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(featureWithRole(t, decodeCollection(t, data), role).Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode %s: %v", role, err)
|
|
|
|
|
}
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
for _, coordinate := range line {
|
|
|
|
|
for name, point := range map[string]eclipse.SolarEclipsePathPoint{"U1": partial.U1, "U4": partial.U4} {
|
|
|
|
|
if math.Abs(coordinate[0]-point.Longitude) <= 1e-10 && math.Abs(coordinate[1]-point.Latitude) <= 1e-10 {
|
|
|
|
|
t.Fatalf("%s retains %s external-contact vertex", role, name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for first := 0; first+1 < len(ring); first++ {
|
|
|
|
|
for second := first + 2; second+1 < len(ring); second++ {
|
|
|
|
|
if geoJSONCoordinateDistanceKM(ring[first], ring[second]) < 0.001 {
|
|
|
|
|
t.Fatalf("central-band ring repeats non-adjacent vertices %d and %d", first, second)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, 115.4, 32.9) {
|
|
|
|
|
t.Fatal("Fuyang (115.4E, 32.9N) is outside the 2010-01-15 annular central band")
|
|
|
|
|
}
|
|
|
|
|
center := featureWithRole(t, decodeCollection(t, data), "center-line")
|
|
|
|
|
var centerLines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil {
|
|
|
|
|
t.Fatalf("decode center-line: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for segmentIndex, segment := range centerLines {
|
|
|
|
|
for pointIndex, point := range segment {
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) &&
|
|
|
|
|
geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 10 {
|
|
|
|
|
t.Fatalf("center-line segment %d point %d lies outside the central band at %.6f, %.6f",
|
|
|
|
|
segmentIndex, pointIndex, point[0], point[1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipseHistoricalCrossedLimitsUseSimpleRibbonUnion(t *testing.T) {
|
|
|
|
|
for _, date := range []time.Time{
|
|
|
|
|
time.Date(1547, time.November, 12, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(1565, time.November, 22, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
} {
|
|
|
|
|
date := date
|
|
|
|
|
t.Run(date.Format("2006-01-02"), func(t *testing.T) {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected historical annular eclipse")
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
|
|
|
Step: 2 * time.Minute, TargetSpacingKM: 700,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected historical central path")
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, mustMarshalSolarEclipseWithPath(t, partial, ¢ral)), "central-band")
|
|
|
|
|
if source := band.Properties["source"]; source != "paired-limits+central-shadow-ribbon-union" &&
|
|
|
|
|
source != "paired-limits-ribbon-union" && source != "besselian-critical-envelope" {
|
|
|
|
|
t.Fatalf("central-band source=%v, want a validated central-band geometry", source)
|
|
|
|
|
}
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode central-band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) != 1 || len(polygons[0]) != 1 {
|
|
|
|
|
t.Fatalf("central-band polygons=%d rings=%d, want one exterior ring", len(polygons), len(polygons[0]))
|
|
|
|
|
}
|
|
|
|
|
ring := polygons[0][0]
|
|
|
|
|
for first := 0; first+1 < len(ring); first++ {
|
|
|
|
|
for second := first + 2; second+1 < len(ring); second++ {
|
|
|
|
|
if first == 0 && second+1 == len(ring)-1 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) {
|
|
|
|
|
t.Fatalf("central-band ring self-intersects between edges %d and %d", first, second)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for index := 1; index < len(ring); index++ {
|
|
|
|
|
if edge := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); edge > 210 {
|
|
|
|
|
t.Fatalf("central-band edge %d=%.3f km, want spatial refinement", index-1, edge)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mustMarshalSolarEclipseWithPath(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
partial eclipse.SolarEclipsePartialFootprintsInfo,
|
|
|
|
|
central *eclipse.SolarEclipsePath,
|
|
|
|
|
) []byte {
|
|
|
|
|
t.Helper()
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, central)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20100115CenterLineEndsAtGreatestSetBoundary(t *testing.T) {
|
|
|
|
|
date := time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute, BoundaryPoints: 24, RiseSetStep: 2 * time.Minute,
|
2026-08-06 12:00:56 +08:00
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar partial footprints")
|
|
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
|
|
|
Step: 2 * time.Minute, TargetSpacingKM: 100,
|
|
|
|
|
})
|
2026-08-06 12:00:56 +08:00
|
|
|
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)
|
2026-09-17 12:27:40 +08:00
|
|
|
|
|
|
|
|
center := featureWithRole(t, collection, "center-line")
|
|
|
|
|
var centerLines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil {
|
|
|
|
|
t.Fatalf("decode center-line: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(centerLines) == 0 || len(centerLines[len(centerLines)-1]) == 0 {
|
|
|
|
|
t.Fatal("center-line has no coordinates")
|
|
|
|
|
}
|
|
|
|
|
lastLine := centerLines[len(centerLines)-1]
|
|
|
|
|
last := lastLine[len(lastLine)-1]
|
|
|
|
|
// NASA's path-table Limits row is 36 deg 49.6 min N, 121 deg 40.9 min E.
|
|
|
|
|
if math.Abs(last[0]-121.6817) > 0.12 || math.Abs(last[1]-36.8267) > 0.12 {
|
|
|
|
|
t.Fatalf("center-line limit = (%.6f, %.6f), want NASA limit near (121.6817, 36.8267)",
|
|
|
|
|
last[0], last[1])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foundGreatestSet := false
|
|
|
|
|
for _, boundary := range featuresWithRole(collection, "visibility-boundary") {
|
|
|
|
|
if boundary.Properties["phase"] != "greatest" || boundary.Properties["horizon"] != "set" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode greatest-at-sunset boundary: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
for _, point := range line {
|
|
|
|
|
if point[0] == last[0] && point[1] == last[1] {
|
|
|
|
|
foundGreatestSet = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !foundGreatestSet {
|
|
|
|
|
t.Fatal("center-line limit is not a shared GeoJSON vertex of the greatest-at-sunset boundary")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20080801CentralBandContainsCenterLine(t *testing.T) {
|
|
|
|
|
date := time.Date(2008, time.August, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 30 * time.Minute, BoundaryPoints: 180,
|
|
|
|
|
CentralShadowStep: 2 * time.Minute, DisableRiseSet: true,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar partial footprints")
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
|
|
|
Step: 2 * time.Minute,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar central path")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
|
|
|
|
center := featureWithRole(t, decodeCollection(t, data), "center-line")
|
|
|
|
|
var centerLines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil {
|
|
|
|
|
t.Fatalf("decode center-line: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for segmentIndex, segment := range centerLines {
|
|
|
|
|
for pointIndex, point := range segment {
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) {
|
|
|
|
|
t.Fatalf("center-line segment %d point %d lies outside 2008 central band at %.6f, %.6f", segmentIndex, pointIndex, point[0], point[1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipseCentralShadowStepTwoMinutesFallsBackToStableBand(t *testing.T) {
|
|
|
|
|
for _, date := range []time.Time{
|
|
|
|
|
time.Date(2037, time.July, 13, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(2038, time.July, 2, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
} {
|
|
|
|
|
t.Run(date.Format("2006-01-02"), func(t *testing.T) {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute, BoundaryPoints: 180, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar partial footprints")
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar central path")
|
|
|
|
|
}
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalCentralEclipseBandUnionAcrossEvents(t *testing.T) {
|
|
|
|
|
for _, date := range []time.Time{
|
|
|
|
|
time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(2012, time.May, 20, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(2017, time.August, 21, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
} {
|
|
|
|
|
date := date
|
|
|
|
|
t.Run(date.Format("2006-01-02"), func(t *testing.T) {
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute,
|
|
|
|
|
BoundaryPoints: 48,
|
|
|
|
|
CentralShadowStep: 5 * time.Minute,
|
|
|
|
|
DisableRiseSet: true,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar partial footprints")
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
|
|
|
Step: 10 * time.Minute, TargetSpacingKM: 500,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar central path")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
|
|
|
|
if band.Geometry.Type != "MultiPolygon" {
|
|
|
|
|
t.Fatalf("central-band geometry=%q, want merged MultiPolygon", band.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
assertClosedMultiPolygon(t, band)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipseAllowsFoldedMagnitudeContourTimes(t *testing.T) {
|
|
|
|
|
date := time.Date(2031, time.May, 21, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 20 * time.Minute, BoundaryPoints: 24, MagnitudeValues: []float64{0.8},
|
|
|
|
|
})
|
|
|
|
|
if !ok || len(partial.MagnitudeContours) != 1 {
|
|
|
|
|
t.Fatalf("magnitude contours=%d ok=%v, want one", len(partial.MagnitudeContours), ok)
|
|
|
|
|
}
|
|
|
|
|
folded := false
|
|
|
|
|
for _, segment := range partial.MagnitudeContours[0].Segments {
|
|
|
|
|
for index := 1; index < len(segment); index++ {
|
|
|
|
|
if !segment[index].Time.After(segment[index-1].Time) {
|
|
|
|
|
folded = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !folded {
|
|
|
|
|
t.Fatal("2031 magnitude contour did not exercise a folded greatest-time branch")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
|
|
|
}
|
|
|
|
|
line := featureWithRole(t, decodeCollection(t, data), "magnitude-line")
|
|
|
|
|
assertTimedLineAligned(t, line)
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-17 12:27:40 +08:00
|
|
|
if len(featuresWithRole(collection, "center-line")) != 1 || len(featuresWithRole(collection, "central-band")) != 1 {
|
|
|
|
|
t.Fatal("single-limit central eclipse should export both its center line and central band")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalSolarEclipse20330330SeparatesOpenSweepsAcrossClosedPhase(t *testing.T) {
|
|
|
|
|
date := time.Date(2033, time.March, 30, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute, BoundaryPoints: 180, CentralShadowStep: 2 * time.Minute,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 2033-03-30 solar eclipse")
|
|
|
|
|
}
|
|
|
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected 2033-03-30 central path")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
assertClosedMultiPolygon(t, featureWithRole(t, decodeCollection(t, data), "central-band"))
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
func TestMarshalStarOccultationAntaresCenterLineStaysInsideFootprintBand(t *testing.T) {
|
|
|
|
|
start := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
paths, err := moon.FindStarOccultationPaths(
|
|
|
|
|
start,
|
|
|
|
|
start.Add(24*time.Hour),
|
|
|
|
|
moon.StarCoordinate{
|
|
|
|
|
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
|
|
|
|
|
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
|
|
|
|
Frame: moon.CoordinateFrameJ2000,
|
|
|
|
|
ProperMotionRACosDecMasPerYear: -10,
|
|
|
|
|
ProperMotionDecMasPerYear: -20,
|
|
|
|
|
ParallaxMas: 24,
|
|
|
|
|
},
|
|
|
|
|
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
if len(paths[0].Footprints) == 0 {
|
|
|
|
|
t.Fatal("Antares path has no instantaneous footprints")
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalStarOccultation(paths[0])
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
assertRiseSetBoundaryFeatures(t, collection, "moon")
|
|
|
|
|
band := featureWithRole(t, collection, "occultation-band")
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode occultation band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons)*10 >= len(paths[0].Footprints) {
|
|
|
|
|
t.Fatalf("merged occultation sweep retained %d polygons for %d instantaneous footprints",
|
|
|
|
|
len(polygons), len(paths[0].Footprints))
|
|
|
|
|
}
|
|
|
|
|
center := featureWithRole(t, collection, "center-line")
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(center.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode center line: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for lineIndex, line := range lines {
|
|
|
|
|
for index := 1; index < len(line); index++ {
|
|
|
|
|
for sample := 0; sample <= 10; sample++ {
|
|
|
|
|
fraction := float64(sample) / 10
|
|
|
|
|
longitude := line[index-1][0] + fraction*(line[index][0]-line[index-1][0])
|
|
|
|
|
latitude := line[index-1][1] + fraction*(line[index][1]-line[index-1][1])
|
|
|
|
|
if !geoJSONMultiPolygonContains(polygons, longitude, latitude) {
|
|
|
|
|
t.Fatalf("center line[%d] segment %d sample %d lies outside footprint band at %.6f, %.6f",
|
|
|
|
|
lineIndex, index-1, sample, longitude, latitude)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, role := range []string{"north-limit", "south-limit"} {
|
|
|
|
|
limit := featureWithRole(t, collection, role)
|
|
|
|
|
var segments [][][]float64
|
|
|
|
|
if err := json.Unmarshal(limit.Geometry.Coordinates, &segments); err != nil {
|
|
|
|
|
t.Fatalf("decode %s: %v", role, err)
|
|
|
|
|
}
|
|
|
|
|
for segmentIndex, segment := range segments {
|
|
|
|
|
for index := 1; index < len(segment); index++ {
|
|
|
|
|
if distance := geoJSONCoordinateDistanceKM(segment[index-1], segment[index]); distance > 750+1e-6 {
|
|
|
|
|
t.Fatalf("%s segment %d still spans %.1f km branch change", role, segmentIndex, distance)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
assertTimedLineAligned(t, limit)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalStarOccultationAntaresCompactBandContainsCenterLine(t *testing.T) {
|
|
|
|
|
start := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
paths, err := moon.FindStarOccultationPaths(
|
|
|
|
|
start, start.Add(24*time.Hour),
|
|
|
|
|
moon.StarCoordinate{
|
|
|
|
|
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
|
|
|
|
|
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
|
|
|
|
Frame: moon.CoordinateFrameJ2000,
|
|
|
|
|
ProperMotionRACosDecMasPerYear: -10,
|
|
|
|
|
ProperMotionDecMasPerYear: -20,
|
|
|
|
|
ParallaxMas: 24,
|
|
|
|
|
},
|
|
|
|
|
moon.OccultationPathOptions{
|
|
|
|
|
Step: 5 * time.Minute, TargetSpacingKM: 200,
|
|
|
|
|
DisableRiseSet: true, DisableFootprints: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
path := paths[0]
|
|
|
|
|
if len(path.Footprints) != 0 || len(path.BandFootprints) == 0 {
|
|
|
|
|
t.Fatalf("dense/compact footprint counts=%d/%d, want zero/nonzero", len(path.Footprints), len(path.BandFootprints))
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalStarOccultation(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, decodeCollection(t, data), "occultation-band")
|
|
|
|
|
for index, point := range path.CenterLine {
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) {
|
|
|
|
|
t.Fatalf("compact stellar band excludes center-line sample %d at %.6f, %.6f", index, point.Longitude, point.Latitude)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
func TestMarshalStarOccultationSplitsImpossibleBoundaryJump(t *testing.T) {
|
|
|
|
|
start := time.Date(2026, time.February, 11, 10, 0, 0, 0, time.UTC)
|
|
|
|
|
times := []time.Time{
|
|
|
|
|
start,
|
|
|
|
|
start.Add(time.Minute),
|
|
|
|
|
start.Add(time.Minute + time.Second),
|
|
|
|
|
start.Add(2 * time.Minute),
|
|
|
|
|
}
|
|
|
|
|
series := func(longitudes, latitudes []float64) []moon.OccultationPathPoint {
|
|
|
|
|
points := make([]moon.OccultationPathPoint, len(times))
|
|
|
|
|
for index := range times {
|
|
|
|
|
points[index] = moon.OccultationPathPoint{
|
|
|
|
|
Time: times[index], Longitude: longitudes[index], Latitude: latitudes[index], MoonAltitude: 20,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return points
|
|
|
|
|
}
|
|
|
|
|
center := series([]float64{0, 0.1, 0.2, 0.3}, []float64{0, 0, 0, 0})
|
|
|
|
|
north := series([]float64{0, 0.1, 30, 30.1}, []float64{10, 10, 10, 10})
|
|
|
|
|
south := series([]float64{0, 0.1, 0.2, 0.3}, []float64{-10, -10, -10, -10})
|
|
|
|
|
path := moon.StarOccultationPath{
|
|
|
|
|
TargetID: "branch-jump", Start: north[0], Greatest: center[2], End: north[3],
|
|
|
|
|
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south, Step: time.Second,
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalStarOccultation(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
northFeature := featureWithRole(t, decodeCollection(t, data), "north-limit")
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(northFeature.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode north limit: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(lines) != 2 {
|
|
|
|
|
t.Fatalf("north limit segment count = %d, want 2 around branch jump", len(lines))
|
|
|
|
|
}
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
for index := 1; index < len(line); index++ {
|
|
|
|
|
if jump := math.Abs(line[index][0] - line[index-1][0]); jump > 5 {
|
|
|
|
|
t.Fatalf("north limit still contains %.1f degree branch jump", jump)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
assertTimedLineAligned(t, northFeature)
|
|
|
|
|
assertOccultationBandPolygonCount(t, decodeCollection(t, data), "occultation-band", 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalStarOccultationPreservesEndpointBranchFragments(t *testing.T) {
|
|
|
|
|
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
for _, count := range []int{2, 3} {
|
|
|
|
|
t.Run(fmt.Sprintf("%d points", count), func(t *testing.T) {
|
|
|
|
|
path := endpointBranchJumpPath(start, count)
|
|
|
|
|
data, err := geojson.MarshalStarOccultation(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
north := featureWithRole(t, collection, "north-limit")
|
|
|
|
|
assertTimedLineAligned(t, north)
|
|
|
|
|
timeSegments, ok := north.Properties["times"].([]interface{})
|
|
|
|
|
if !ok || len(timeSegments) != 2 {
|
|
|
|
|
t.Fatalf("north-limit time segments = %#v, want two discontinuous fragments", north.Properties["times"])
|
|
|
|
|
}
|
|
|
|
|
first := timeSegments[0].([]interface{})[0]
|
|
|
|
|
lastSegment := timeSegments[len(timeSegments)-1].([]interface{})
|
|
|
|
|
last := lastSegment[len(lastSegment)-1]
|
|
|
|
|
if first != path.Start.Time.Format(time.RFC3339Nano) || last != path.End.Time.Format(time.RFC3339Nano) {
|
|
|
|
|
t.Fatalf("north-limit time span = %v..%v, want %s..%s",
|
|
|
|
|
first, last, path.Start.Time.Format(time.RFC3339Nano), path.End.Time.Format(time.RFC3339Nano))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
band := featureWithRole(t, collection, "occultation-band")
|
|
|
|
|
if count == 2 && band.Geometry.Type != "MultiLineString" {
|
|
|
|
|
t.Fatalf("two-point discontinuous band geometry = %q, want MultiLineString endpoint sections", band.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
if count == 3 {
|
|
|
|
|
if band.Geometry.Type != "GeometryCollection" {
|
|
|
|
|
t.Fatalf("partially continuous band geometry = %q, want GeometryCollection", band.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
var geometries []struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Geometries, &geometries); err != nil {
|
|
|
|
|
t.Fatalf("decode band geometries: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(geometries) != 2 || geometries[0].Type != "MultiPolygon" || geometries[1].Type != "MultiLineString" {
|
|
|
|
|
t.Fatalf("band geometries = %#v, want polygon sweep plus endpoint sections", geometries)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func endpointBranchJumpPath(start time.Time, count int) moon.StarOccultationPath {
|
|
|
|
|
north := make([]moon.OccultationPathPoint, count)
|
|
|
|
|
south := make([]moon.OccultationPathPoint, count)
|
|
|
|
|
for index := range north {
|
|
|
|
|
when := start.Add(time.Duration(index) * time.Second)
|
|
|
|
|
longitude := 30.0 + float64(index)/10
|
|
|
|
|
if index == 0 {
|
|
|
|
|
longitude = 0
|
|
|
|
|
}
|
|
|
|
|
north[index] = moon.OccultationPathPoint{
|
|
|
|
|
Time: when, Longitude: longitude, Latitude: 10, MoonAltitude: 20,
|
|
|
|
|
}
|
|
|
|
|
south[index] = moon.OccultationPathPoint{
|
|
|
|
|
Time: when, Longitude: longitude, Latitude: -10, MoonAltitude: 20,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return moon.StarOccultationPath{
|
|
|
|
|
TargetID: "endpoint-jump", Start: north[0], Greatest: north[0], End: north[count-1],
|
|
|
|
|
Complete: true, NorthernLimit: north, SouthernLimit: south, Step: time.Second,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalPlanetOccultationSplitsImpossibleFallbackBandJump(t *testing.T) {
|
|
|
|
|
start := time.Date(2026, time.February, 11, 10, 0, 0, 0, time.UTC)
|
|
|
|
|
times := []time.Time{
|
|
|
|
|
start,
|
|
|
|
|
start.Add(time.Minute),
|
|
|
|
|
start.Add(time.Minute + time.Second),
|
|
|
|
|
start.Add(2 * time.Minute),
|
|
|
|
|
}
|
|
|
|
|
series := func(longitudes, latitudes []float64) []moon.OccultationPathPoint {
|
|
|
|
|
points := make([]moon.OccultationPathPoint, len(times))
|
|
|
|
|
for index := range times {
|
|
|
|
|
points[index] = moon.OccultationPathPoint{
|
|
|
|
|
Time: times[index], Longitude: longitudes[index], Latitude: latitudes[index], MoonAltitude: 20,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return points
|
|
|
|
|
}
|
|
|
|
|
center := series([]float64{0, 0.1, 30, 30.1}, []float64{0, 0, 0, 0})
|
|
|
|
|
north := series([]float64{0, 0.1, 30, 30.1}, []float64{10, 10, 10, 10})
|
|
|
|
|
south := series([]float64{0, 0.1, 30, 30.1}, []float64{-10, -10, -10, -10})
|
|
|
|
|
path := moon.PlanetOccultationPath{
|
|
|
|
|
Planet: moon.OccultationSaturn, TargetID: "branch-jump",
|
|
|
|
|
Start: north[0], Greatest: center[2], End: north[3], Complete: true,
|
|
|
|
|
CenterLine: center, NorthernLimit: north, SouthernLimit: south, Step: time.Second,
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
assertOccultationBandPolygonCount(t, decodeCollection(t, data), "partial-band", 2)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
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 TestMarshalPlanetOccultationExportsSixRiseSetPhaseBoundaries(t *testing.T) {
|
|
|
|
|
start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
paths, err := moon.FindPlanetOccultationPaths(
|
|
|
|
|
start, start.Add(24*time.Hour), moon.OccultationSaturn,
|
|
|
|
|
moon.OccultationPathOptions{Step: 10 * time.Minute},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
assertRiseSetBoundaryFeatures(t, decodeCollection(t, data), "moon")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalPlanetOccultation20250105PreservesClosedPolarRiseSetBranches(t *testing.T) {
|
|
|
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
|
|
|
|
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
|
|
|
|
|
paths, err := moon.FindPlanetOccultationPaths(
|
|
|
|
|
start, start.Add(24*time.Hour), moon.OccultationSaturn,
|
|
|
|
|
moon.OccultationPathOptions{
|
|
|
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
assertRiseSetBoundaryFeatures(t, collection, "moon")
|
|
|
|
|
startRise := riseSetBoundaryFeature(t, collection, "start", "rise")
|
|
|
|
|
startSet := riseSetBoundaryFeature(t, collection, "start", "set")
|
|
|
|
|
if !riseSetFeaturesShareEndpointInRegion(t, startRise, startSet, -60, 60, 70, 90) {
|
|
|
|
|
t.Fatal("serialized start moonrise/moonset curves do not share their polar direction junction")
|
|
|
|
|
}
|
|
|
|
|
if !riseSetFeatureSegmentsShareEndpointInRegion(t, startSet, -60, 60, 70, 90) &&
|
|
|
|
|
!riseSetFeatureHasInteriorVertexInRegion(t, startSet, -60, 60, 70, 90) {
|
|
|
|
|
t.Fatal("serialized moonset start-phase branches do not preserve their polar fold vertex")
|
|
|
|
|
}
|
|
|
|
|
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
|
|
|
|
|
if feature.Properties["phase"] == "horizon" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
assertRiseSetFeatureHasNoInstantaneousBranchJump(t, feature)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalPlanetOccultation20250630MarsClosesMoonsetAndBand(t *testing.T) {
|
|
|
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
|
|
|
|
start := time.Date(2025, time.June, 30, 0, 0, 0, 0, zone)
|
|
|
|
|
paths, err := moon.FindPlanetOccultationPaths(
|
|
|
|
|
start, start.Add(24*time.Hour), moon.OccultationMars,
|
|
|
|
|
moon.OccultationPathOptions{
|
|
|
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: 5 * time.Minute,
|
|
|
|
|
DisableFootprints: true, IncludeFootprintTimeline: true,
|
|
|
|
|
FootprintTimelineStep: 5 * time.Minute,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
assertRiseSetBoundaryFeatures(t, collection, "moon")
|
|
|
|
|
startSet := riseSetBoundaryFeature(t, collection, "start", "set")
|
|
|
|
|
greatestSet := riseSetBoundaryFeature(t, collection, "greatest", "set")
|
|
|
|
|
endSet := riseSetBoundaryFeature(t, collection, "end", "set")
|
|
|
|
|
if !riseSetFeaturesShareVertexInRegion(t, startSet, endSet, -90, -70, -40, -10) {
|
|
|
|
|
t.Fatal("serialized moonset start/end curves do not share the first narrow phase junction")
|
|
|
|
|
}
|
|
|
|
|
if !riseSetFeaturesShareVertexInRegion(t, greatestSet, endSet, -90, -70, -40, -10) {
|
|
|
|
|
t.Fatal("serialized moonset greatest/end curves do not share the second narrow phase junction")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
band := featureWithRole(t, collection, "partial-band")
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode partial-band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if band.Geometry.Type != "MultiPolygon" || len(polygons) == 0 || len(polygons) > 2 {
|
|
|
|
|
t.Fatalf("partial-band geometry=%s polygons=%d, want one spherical band split at most once by the antimeridian", band.Geometry.Type, len(polygons))
|
|
|
|
|
}
|
|
|
|
|
for _, polygon := range polygons {
|
|
|
|
|
for _, ring := range polygon {
|
|
|
|
|
if len(ring) < 4 || ring[0][0] != ring[len(ring)-1][0] || ring[0][1] != ring[len(ring)-1][1] {
|
|
|
|
|
t.Fatal("partial-band contains an unclosed polygon ring")
|
|
|
|
|
}
|
|
|
|
|
assertGeoJSONRingHasNoShortHairpins(t, "partial-band", ring, 50, 75, 24)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalPlanetOccultation20250729MarsUsesClosedPolarBand(t *testing.T) {
|
|
|
|
|
fixture := mars20250729TestFixture(t, 5*time.Minute, false)
|
|
|
|
|
path, collection := fixture.path, fixture.collection
|
|
|
|
|
if len(path.CenterLine) != 0 || len(path.RiseSetCurves) != 3 || len(path.RiseSetCurves[2].Segments) < 2 {
|
|
|
|
|
t.Fatalf("unexpected polar path topology: center=%d curves=%d end-rise-segments=%d",
|
|
|
|
|
len(path.CenterLine), len(path.RiseSetCurves), len(path.RiseSetCurves[2].Segments))
|
|
|
|
|
}
|
|
|
|
|
band := featureWithRole(t, collection, "partial-band")
|
|
|
|
|
if band.Geometry.Type != "MultiPolygon" {
|
|
|
|
|
t.Fatalf("partial-band geometry=%q, want MultiPolygon", band.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
if authoritative, ok := band.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
|
|
|
|
|
t.Fatalf("partial-band static_band_authoritative=%v, want true", band.Properties["static_band_authoritative"])
|
|
|
|
|
}
|
|
|
|
|
if source := band.Properties["source"]; source != "visible-footprint-sweep" {
|
|
|
|
|
t.Fatalf("partial-band source=%v, want visible-footprint-sweep", source)
|
|
|
|
|
}
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode partial-band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) != 1 {
|
|
|
|
|
t.Fatalf("partial-band polygons=%d, want one continuous polar band", len(polygons))
|
|
|
|
|
}
|
|
|
|
|
for polygonIndex, polygon := range polygons {
|
|
|
|
|
for ringIndex, ring := range polygon {
|
|
|
|
|
if len(ring) < 4 || ring[0][0] != ring[len(ring)-1][0] || ring[0][1] != ring[len(ring)-1][1] {
|
|
|
|
|
t.Fatalf("partial-band polygon %d ring %d is not closed", polygonIndex, ringIndex)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
greatest := featureWithRole(t, collection, "greatest")
|
|
|
|
|
var greatestPoint []float64
|
|
|
|
|
if err := json.Unmarshal(greatest.Geometry.Coordinates, &greatestPoint); err != nil {
|
|
|
|
|
t.Fatalf("decode greatest point: %v", err)
|
|
|
|
|
}
|
|
|
|
|
totalBand := featureWithRole(t, collection, "total-band")
|
|
|
|
|
var totalPolygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(totalBand.Geometry.Coordinates, &totalPolygons); err != nil {
|
|
|
|
|
t.Fatalf("decode total-band: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for _, testPoint := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
lon float64
|
|
|
|
|
lat float64
|
|
|
|
|
inside bool
|
|
|
|
|
}{
|
|
|
|
|
{name: "selected", lon: -134.2280, lat: -78.0725, inside: true},
|
|
|
|
|
{name: "visible-sweep", lon: -129.8230, lat: -77.4030, inside: true},
|
|
|
|
|
} {
|
|
|
|
|
partialInside := geoJSONMultiPolygonContains(polygons, testPoint.lon, testPoint.lat)
|
|
|
|
|
totalInside := geoJSONMultiPolygonContains(totalPolygons, testPoint.lon, testPoint.lat)
|
|
|
|
|
if partialInside != testPoint.inside || totalInside != testPoint.inside {
|
|
|
|
|
t.Fatalf("%s point %.4f, %.4f partial=%v total=%v, want inside=%v", testPoint.name, testPoint.lon, testPoint.lat, partialInside, totalInside, testPoint.inside)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
totalRings := geoJSONMultiPolygonOuterRings(t, totalBand)
|
|
|
|
|
greatestPath := [][]geodata.GeoPoint{{
|
|
|
|
|
{Longitude: greatestPoint[0], Latitude: greatestPoint[1]},
|
|
|
|
|
}}
|
|
|
|
|
if miss := geodata.SphericalPolygonsPathMissDistanceKM(totalRings, greatestPath, false); miss > 10 {
|
|
|
|
|
t.Fatalf("total-band excludes greatest point %.6f, %.6f by %.1f km", greatestPoint[0], greatestPoint[1], miss)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalPlanetOccultationWithoutFootprintsUsesBands(t *testing.T) {
|
|
|
|
|
start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
paths, err := moon.FindPlanetOccultationPaths(
|
|
|
|
|
start, start.Add(24*time.Hour), moon.OccultationSaturn,
|
|
|
|
|
moon.OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
2026-08-06 12:00:56 +08:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
2026-09-17 12:27:40 +08:00
|
|
|
assertRoles(t, collection, "partial-band", "total-band", "center-line", "north-limit", "south-limit",
|
|
|
|
|
"north-total-limit", "south-total-limit", "visibility-boundary", "start", "total-start", "greatest", "total-end", "end")
|
|
|
|
|
if len(featuresWithRole(collection, "partial-footprint")) != 0 || len(featuresWithRole(collection, "total-footprint")) != 0 {
|
|
|
|
|
t.Fatal("disabled instantaneous footprints were serialized")
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
assertRiseSetBoundaryFeatures(t, collection, "moon")
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
func TestMarshalPlanetOccultationCompactBandHandlesTangentBranchConvergence(t *testing.T) {
|
|
|
|
|
for _, date := range []time.Time{
|
|
|
|
|
time.Date(1954, time.June, 30, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(1962, time.April, 1, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
time.Date(1965, time.June, 27, 0, 0, 0, 0, time.UTC),
|
|
|
|
|
} {
|
|
|
|
|
paths, err := moon.FindPlanetOccultationPaths(
|
|
|
|
|
date, date.Add(24*time.Hour), moon.OccultationJupiter,
|
|
|
|
|
moon.OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true, DisableRiseSet: true},
|
|
|
|
|
)
|
|
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("%s: paths=%d err=%v, want one", date.Format("2006-01-02"), len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("%s: MarshalPlanetOccultation: %v", date.Format("2006-01-02"), err)
|
|
|
|
|
}
|
|
|
|
|
assertRoles(t, decodeCollection(t, data), "partial-band", "total-band", "center-line")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalPlanetOccultation20240725CompactBandsFollowCenterLine(t *testing.T) {
|
|
|
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
|
|
|
|
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
|
2026-08-06 12:00:56 +08:00
|
|
|
paths, err := moon.FindPlanetOccultationPaths(
|
2026-09-17 12:27:40 +08:00
|
|
|
start, start.Add(24*time.Hour), moon.OccultationSaturn,
|
|
|
|
|
moon.OccultationPathOptions{
|
|
|
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
|
|
|
|
DisableFootprints: true,
|
|
|
|
|
},
|
2026-08-06 12:00:56 +08:00
|
|
|
)
|
2026-09-17 12:27:40 +08:00
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
2026-08-06 12:00:56 +08:00
|
|
|
if err != nil {
|
2026-09-17 12:27:40 +08:00
|
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
for _, test := range []struct {
|
|
|
|
|
role string
|
|
|
|
|
points []moon.OccultationPathPoint
|
|
|
|
|
start, end time.Time
|
|
|
|
|
}{
|
|
|
|
|
{role: "partial-band", points: paths[0].CenterLine},
|
|
|
|
|
{
|
|
|
|
|
role: "total-band", points: paths[0].CenterLine,
|
|
|
|
|
start: paths[0].TotalStart.Time, end: paths[0].TotalEnd.Time,
|
|
|
|
|
},
|
|
|
|
|
} {
|
|
|
|
|
band := featureWithRole(t, collection, test.role)
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode %s: %v", test.role, err)
|
|
|
|
|
}
|
|
|
|
|
if band.Geometry.Type != "MultiPolygon" || len(polygons) != 1 {
|
|
|
|
|
t.Fatalf("%s geometry=%s polygons=%d, want one continuous MultiPolygon", test.role, band.Geometry.Type, len(polygons))
|
|
|
|
|
}
|
|
|
|
|
if compact, ok := band.Properties["compact_band"].(bool); !ok || !compact {
|
|
|
|
|
t.Fatalf("%s compact_band=%v, want true", test.role, band.Properties["compact_band"])
|
|
|
|
|
}
|
|
|
|
|
maximumEdge := 0.0
|
|
|
|
|
for _, polygon := range polygons {
|
|
|
|
|
for _, ring := range polygon {
|
|
|
|
|
if len(ring) < 4 || ring[0][0] != ring[len(ring)-1][0] || ring[0][1] != ring[len(ring)-1][1] {
|
|
|
|
|
t.Fatalf("%s contains an unclosed polygon ring", test.role)
|
|
|
|
|
}
|
|
|
|
|
for pointIndex := 1; pointIndex < len(ring); pointIndex++ {
|
|
|
|
|
edge := geoJSONCoordinateDistanceKM(ring[pointIndex-1], ring[pointIndex])
|
|
|
|
|
maximumEdge = math.Max(maximumEdge, edge)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if maximumEdge > 750 {
|
|
|
|
|
t.Fatalf("%s maximum edge=%.1f km, likely contains a disconnected shard", test.role, maximumEdge)
|
|
|
|
|
}
|
|
|
|
|
for index, point := range test.points {
|
|
|
|
|
if (!test.start.IsZero() && point.Time.Before(test.start)) ||
|
|
|
|
|
(!test.end.IsZero() && point.Time.After(test.end)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) {
|
|
|
|
|
t.Fatalf("%s excludes center-line sample %d at %.6f, %.6f", test.role, index, point.Longitude, point.Latitude)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
}
|
2026-08-06 12:00:56 +08:00
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
func TestMarshalPlanetOccultation20250105CompactBandsHaveSmoothContinuousOutline(t *testing.T) {
|
|
|
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
|
|
|
|
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
|
|
|
|
|
paths, err := moon.FindPlanetOccultationPaths(
|
|
|
|
|
start, start.Add(24*time.Hour), moon.OccultationSaturn,
|
|
|
|
|
moon.OccultationPathOptions{
|
|
|
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
|
|
|
|
DisableFootprints: true, IncludeFootprintTimeline: true,
|
|
|
|
|
FootprintTimelineStep: 5 * time.Minute,
|
|
|
|
|
},
|
2026-08-06 12:00:56 +08:00
|
|
|
)
|
2026-09-17 12:27:40 +08:00
|
|
|
if err != nil || len(paths) != 1 {
|
|
|
|
|
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
|
|
|
}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
2026-08-06 12:00:56 +08:00
|
|
|
if err != nil {
|
2026-09-17 12:27:40 +08:00
|
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
2026-09-17 12:27:40 +08:00
|
|
|
for _, role := range []string{"partial-band", "total-band"} {
|
|
|
|
|
band := featureWithRole(t, collection, role)
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode %s: %v", role, err)
|
|
|
|
|
}
|
|
|
|
|
if band.Geometry.Type != "MultiPolygon" || len(polygons) != 1 {
|
|
|
|
|
t.Fatalf("%s geometry=%s polygons=%d, want one continuous MultiPolygon", role, band.Geometry.Type, len(polygons))
|
|
|
|
|
}
|
|
|
|
|
maximumEdge := 0.0
|
|
|
|
|
for _, polygon := range polygons {
|
|
|
|
|
for _, ring := range polygon {
|
|
|
|
|
if len(ring) < 4 || ring[0][0] != ring[len(ring)-1][0] || ring[0][1] != ring[len(ring)-1][1] {
|
|
|
|
|
t.Fatalf("%s contains an unclosed polygon ring", role)
|
|
|
|
|
}
|
|
|
|
|
assertGeoJSONRingHasNoShortHairpins(t, role, ring, 35, 25, 12)
|
|
|
|
|
for index := 1; index < len(ring); index++ {
|
|
|
|
|
maximumEdge = math.Max(maximumEdge, geoJSONCoordinateDistanceKM(ring[index-1], ring[index]))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if maximumEdge > 175 {
|
|
|
|
|
t.Fatalf("%s maximum edge=%.1f km, want a spatially refined outline", role, maximumEdge)
|
|
|
|
|
}
|
|
|
|
|
for index, point := range paths[0].CenterLine {
|
|
|
|
|
if role == "total-band" && (point.Time.Before(paths[0].TotalStart.Time) || point.Time.After(paths[0].TotalEnd.Time)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) {
|
|
|
|
|
t.Fatalf("%s excludes center-line sample %d at %.6f, %.6f", role, index, point.Longitude, point.Latitude)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
partial := featureWithRole(t, collection, "partial-band")
|
|
|
|
|
total := featureWithRole(t, collection, "total-band")
|
|
|
|
|
for index, point := range paths[0].CenterLine {
|
|
|
|
|
if point.Time.Before(paths[0].TotalStart.Time) || point.Time.After(paths[0].TotalEnd.Time) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !geometryContainsPoint(t, partial.Geometry, point.Longitude, point.Latitude) ||
|
|
|
|
|
!geometryContainsPoint(t, total.Geometry, point.Longitude, point.Latitude) {
|
|
|
|
|
t.Fatalf("total-band sample %d is not covered by both bands", index)
|
|
|
|
|
}
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
func TestMarshalSolarEclipseRejectsMalformedDerivedGeometry(t *testing.T) {
|
|
|
|
|
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
valid, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
|
|
|
Step: 10 * time.Minute, BoundaryPoints: 24,
|
|
|
|
|
})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected solar eclipse")
|
|
|
|
|
}
|
|
|
|
|
t.Run("magnitude above event maximum", func(t *testing.T) {
|
|
|
|
|
partial := valid
|
|
|
|
|
partial.MagnitudeContours = []eclipse.SolarEclipseMagnitudeContour{{
|
|
|
|
|
Magnitude: partial.Eclipse.Magnitude + 0.01,
|
|
|
|
|
Segments: [][]eclipse.SolarEclipsePathPoint{{partial.Footprints[0].Boundaries[0][0], partial.Footprints[0].Boundaries[0][1]}},
|
|
|
|
|
}}
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil {
|
|
|
|
|
t.Fatal("magnitude contour above event maximum was accepted")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
t.Run("invalid rise set phase", func(t *testing.T) {
|
|
|
|
|
partial := valid
|
|
|
|
|
partial.RiseSetCurves = append([]eclipse.SolarEclipseRiseSetCurve(nil), valid.RiseSetCurves...)
|
|
|
|
|
partial.RiseSetCurves[0].Phase = eclipse.RiseSetPhase("bogus")
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil {
|
|
|
|
|
t.Fatal("invalid solar rise/set phase was accepted")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
t.Run("central band footprint outside contacts", func(t *testing.T) {
|
|
|
|
|
partial := valid
|
|
|
|
|
partial.CentralBandFootprints = append([]eclipse.SolarEclipsePartialFootprint(nil), valid.CentralBandFootprints...)
|
|
|
|
|
partial.CentralBandFootprints[0].Time = partial.U1.Time.Add(-time.Hour)
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil {
|
|
|
|
|
t.Fatal("central-band footprint outside U1-U4 was accepted")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
t.Run("partial eclipse cannot contain central band footprints", func(t *testing.T) {
|
|
|
|
|
partial := valid
|
|
|
|
|
partial.Eclipse.Type = eclipse.SolarEclipsePartial
|
|
|
|
|
if len(partial.CentralBandFootprints) == 0 {
|
|
|
|
|
t.Fatal("expected central-band footprints in the fixture")
|
|
|
|
|
}
|
|
|
|
|
if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil {
|
|
|
|
|
t.Fatal("partial eclipse central-band footprints were accepted")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
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{}
|
|
|
|
|
}},
|
2026-09-17 12:27:40 +08:00
|
|
|
{name: "footprint outside event", mutate: func(path *moon.StarOccultationPath) {
|
|
|
|
|
path.Footprints = []moon.OccultationFootprint{{
|
|
|
|
|
Time: path.End.Time.Add(time.Second),
|
|
|
|
|
Polygons: [][]moon.OccultationPathPoint{{
|
|
|
|
|
{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 0}, {Longitude: 0, Latitude: 1},
|
|
|
|
|
}},
|
|
|
|
|
}}
|
|
|
|
|
}},
|
|
|
|
|
{name: "compact footprint outside event", mutate: func(path *moon.StarOccultationPath) {
|
|
|
|
|
path.BandFootprints = []moon.OccultationFootprint{sampleFootprint(
|
|
|
|
|
path.End.Time.Add(time.Second), 10, -10, 20, 10,
|
|
|
|
|
)}
|
|
|
|
|
}},
|
|
|
|
|
{name: "empty footprint", mutate: func(path *moon.StarOccultationPath) {
|
|
|
|
|
path.Footprints = []moon.OccultationFootprint{{Time: path.Greatest.Time}}
|
|
|
|
|
}},
|
|
|
|
|
{name: "mismatched footprint point time", mutate: func(path *moon.StarOccultationPath) {
|
|
|
|
|
footprint := sampleFootprint(path.Greatest.Time, 10, -10, 20, 10)
|
|
|
|
|
footprint.Polygons[0][0].Time = footprint.Time.Add(time.Second)
|
|
|
|
|
path.Footprints = []moon.OccultationFootprint{footprint}
|
|
|
|
|
}},
|
|
|
|
|
{name: "non-finite footprint altitude", mutate: func(path *moon.StarOccultationPath) {
|
|
|
|
|
footprint := sampleFootprint(path.Greatest.Time, 10, -10, 20, 10)
|
|
|
|
|
footprint.Polygons[0][0].MoonAltitude = math.NaN()
|
|
|
|
|
path.Footprints = []moon.OccultationFootprint{footprint}
|
|
|
|
|
}},
|
|
|
|
|
{name: "negative footprint width", mutate: func(path *moon.StarOccultationPath) {
|
|
|
|
|
footprint := sampleFootprint(path.Greatest.Time, 10, -10, 20, 10)
|
|
|
|
|
footprint.Polygons[0][0].WidthKM = -1
|
|
|
|
|
path.Footprints = []moon.OccultationFootprint{footprint}
|
|
|
|
|
}},
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
func TestMarshalPlanetOccultationAllowsCompactBandAndTimedFootprints(t *testing.T) {
|
|
|
|
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
path := samplePlanetOccultationPath(start)
|
|
|
|
|
footprint := sampleFootprint(start.Add(time.Hour), 10, -10, 20, 10)
|
|
|
|
|
path.PartialFootprints = []moon.PlanetOccultationFootprint{footprint}
|
|
|
|
|
path.PartialBandFootprints = []moon.PlanetOccultationFootprint{footprint}
|
|
|
|
|
data, err := geojson.MarshalPlanetOccultation(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
collection := decodeCollection(t, data)
|
|
|
|
|
if len(featuresWithRole(collection, "partial-band")) != 1 || len(featuresWithRole(collection, "partial-footprint")) != 1 {
|
|
|
|
|
t.Fatal("compact band and timed footprint were not both serialized")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalOccultationRejectsMalformedRiseSetCurves(t *testing.T) {
|
|
|
|
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
curve := moon.OccultationRiseSetCurve{
|
|
|
|
|
Phase: moon.RiseSetPhaseStart,
|
|
|
|
|
Direction: moon.RiseSetDirectionRise,
|
|
|
|
|
Segments: [][]moon.OccultationPathPoint{{
|
|
|
|
|
{Time: start.Add(20 * time.Minute), Longitude: 10, Latitude: 20},
|
|
|
|
|
{Time: start.Add(40 * time.Minute), Longitude: math.NaN(), Latitude: 21},
|
|
|
|
|
}},
|
|
|
|
|
}
|
|
|
|
|
starPath := sampleStarOccultationPath(start)
|
|
|
|
|
starPath.RiseSetCurves = []moon.OccultationRiseSetCurve{curve}
|
|
|
|
|
if _, err := geojson.MarshalStarOccultation(starPath); err == nil {
|
|
|
|
|
t.Fatal("stellar rise/set curve with a NaN coordinate was accepted")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
planetPath := samplePlanetOccultationPath(start)
|
|
|
|
|
curve.Segments[0][1].Longitude = 12
|
|
|
|
|
curve.Phase = moon.RiseSetPhase("bogus")
|
|
|
|
|
planetPath.RiseSetCurves = []moon.OccultationRiseSetCurve{curve}
|
|
|
|
|
if _, err := geojson.MarshalPlanetOccultation(planetPath); err == nil {
|
|
|
|
|
t.Fatal("planetary rise/set curve with an invalid phase was accepted")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
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"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
// geometryContainsPoint 判定点是否落在导出的几何内;解不出坐标时直接失败,避免 wantInside=false 的用例静默通过。
|
|
|
|
|
func geometryContainsPoint(t *testing.T, value struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Coordinates json.RawMessage `json:"coordinates"`
|
|
|
|
|
Geometries json.RawMessage `json:"geometries"`
|
|
|
|
|
}, longitude, latitude float64) bool {
|
|
|
|
|
t.Helper()
|
|
|
|
|
switch value.Type {
|
|
|
|
|
case "MultiPolygon":
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(value.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode MultiPolygon coordinates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return geoJSONMultiPolygonContains(polygons, longitude, latitude)
|
|
|
|
|
case "Polygon":
|
|
|
|
|
var polygon [][][]float64
|
|
|
|
|
if err := json.Unmarshal(value.Coordinates, &polygon); err != nil {
|
|
|
|
|
t.Fatalf("decode Polygon coordinates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygon) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return geoJSONRingContains(polygon[0], longitude, latitude)
|
|
|
|
|
case "GeometryCollection":
|
|
|
|
|
var geometries []struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Coordinates json.RawMessage `json:"coordinates"`
|
|
|
|
|
Geometries json.RawMessage `json:"geometries"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(value.Geometries, &geometries); err != nil {
|
|
|
|
|
t.Fatalf("decode GeometryCollection: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for _, geometry := range geometries {
|
|
|
|
|
if geometryContainsPoint(t, geometry, longitude, latitude) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
func assertRiseSetBoundaryFeatures(t *testing.T, collection decodedCollection, body string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
features := featuresWithRole(collection, "visibility-boundary")
|
|
|
|
|
seen := make(map[string]map[string]bool, 2)
|
|
|
|
|
phaseCurveCount := make(map[string]int, 2)
|
|
|
|
|
for _, feature := range features {
|
|
|
|
|
phase, phaseOK := feature.Properties["phase"].(string)
|
|
|
|
|
horizon, horizonOK := feature.Properties["horizon"].(string)
|
|
|
|
|
if !phaseOK || !horizonOK || feature.Properties["body"] != body {
|
|
|
|
|
t.Fatalf("invalid %s visibility properties: %#v", body, feature.Properties)
|
|
|
|
|
}
|
|
|
|
|
if feature.Geometry.Type != "MultiLineString" {
|
|
|
|
|
t.Fatalf("%s %s/%s geometry=%q, want MultiLineString", body, phase, horizon, feature.Geometry.Type)
|
|
|
|
|
}
|
|
|
|
|
assertTimedLineAligned(t, feature)
|
|
|
|
|
if phase == "horizon" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
band := ""
|
|
|
|
|
if value, ok := feature.Properties["band"].(string); ok {
|
|
|
|
|
band = value
|
|
|
|
|
}
|
|
|
|
|
if seen[band] == nil {
|
|
|
|
|
seen[band] = make(map[string]bool, 6)
|
|
|
|
|
}
|
|
|
|
|
phaseCurveCount[band]++
|
|
|
|
|
seen[band][phase+"/"+horizon] = true
|
|
|
|
|
}
|
|
|
|
|
for band, count := range phaseCurveCount {
|
|
|
|
|
if count != 6 {
|
|
|
|
|
t.Fatalf("%s/%s phase visibility-boundary count=%d, want 6", body, band, count)
|
|
|
|
|
}
|
|
|
|
|
for _, phase := range []string{"start", "greatest", "end"} {
|
|
|
|
|
for _, horizon := range []string{"rise", "set"} {
|
|
|
|
|
if !seen[band][phase+"/"+horizon] {
|
|
|
|
|
t.Fatalf("missing %s/%s visibility boundary %s/%s", body, band, phase, horizon)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type decodedRiseSetEndpoint struct {
|
|
|
|
|
coordinate []float64
|
|
|
|
|
time string
|
|
|
|
|
segment int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetBoundaryFeature(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
collection decodedCollection,
|
|
|
|
|
phase, horizon string,
|
|
|
|
|
) decodedFeature {
|
|
|
|
|
t.Helper()
|
|
|
|
|
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
|
|
|
|
|
if feature.Properties["phase"] == phase && feature.Properties["horizon"] == horizon {
|
|
|
|
|
return feature
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
t.Fatalf("visibility-boundary %s/%s not found", phase, horizon)
|
|
|
|
|
return decodedFeature{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetFeatureEndpoints(t *testing.T, feature decodedFeature) []decodedRiseSetEndpoint {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode visibility-boundary coordinates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
timeSegments, ok := feature.Properties["times"].([]interface{})
|
|
|
|
|
if !ok || len(timeSegments) != len(lines) {
|
|
|
|
|
t.Fatalf("visibility-boundary times do not align with %d segments", len(lines))
|
|
|
|
|
}
|
|
|
|
|
endpoints := make([]decodedRiseSetEndpoint, 0, len(lines)*2)
|
|
|
|
|
for segmentIndex, line := range lines {
|
|
|
|
|
times, ok := timeSegments[segmentIndex].([]interface{})
|
|
|
|
|
if !ok || len(times) != len(line) || len(line) < 2 {
|
|
|
|
|
t.Fatalf("visibility-boundary segment %d has %d coordinates and invalid times", segmentIndex, len(line))
|
|
|
|
|
}
|
|
|
|
|
for _, pointIndex := range []int{0, len(line) - 1} {
|
|
|
|
|
value, ok := times[pointIndex].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("visibility-boundary segment %d time %d has type %T", segmentIndex, pointIndex, times[pointIndex])
|
|
|
|
|
}
|
|
|
|
|
endpoints = append(endpoints, decodedRiseSetEndpoint{
|
|
|
|
|
coordinate: line[pointIndex], time: value, segment: segmentIndex,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return endpoints
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetFeaturesShareEndpointInRegion(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
first, second decodedFeature,
|
|
|
|
|
minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64,
|
|
|
|
|
) bool {
|
|
|
|
|
t.Helper()
|
|
|
|
|
firstEndpoints := riseSetFeatureEndpoints(t, first)
|
|
|
|
|
secondEndpoints := riseSetFeatureEndpoints(t, second)
|
|
|
|
|
for _, current := range firstEndpoints {
|
|
|
|
|
if !riseSetEndpointInRegion(current, minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, other := range secondEndpoints {
|
|
|
|
|
if riseSetEndpointsMatch(current, other) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetFeaturesShareVertexInRegion(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
first, second decodedFeature,
|
|
|
|
|
minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64,
|
|
|
|
|
) bool {
|
|
|
|
|
t.Helper()
|
|
|
|
|
firstVertices := riseSetFeatureVertices(t, first)
|
|
|
|
|
secondVertices := riseSetFeatureVertices(t, second)
|
|
|
|
|
for _, current := range firstVertices {
|
|
|
|
|
if !riseSetEndpointInRegion(current, minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, other := range secondVertices {
|
|
|
|
|
if riseSetEndpointsMatch(current, other) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetFeatureVertices(t *testing.T, feature decodedFeature) []decodedRiseSetEndpoint {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode visibility-boundary coordinates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
timeSegments, ok := feature.Properties["times"].([]interface{})
|
|
|
|
|
if !ok || len(timeSegments) != len(lines) {
|
|
|
|
|
t.Fatalf("visibility-boundary times do not align with %d segments", len(lines))
|
|
|
|
|
}
|
|
|
|
|
vertices := make([]decodedRiseSetEndpoint, 0)
|
|
|
|
|
for segmentIndex, line := range lines {
|
|
|
|
|
times, ok := timeSegments[segmentIndex].([]interface{})
|
|
|
|
|
if !ok || len(times) != len(line) {
|
|
|
|
|
t.Fatalf("visibility-boundary segment %d times do not align with coordinates", segmentIndex)
|
|
|
|
|
}
|
|
|
|
|
for pointIndex, point := range line {
|
|
|
|
|
value, ok := times[pointIndex].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("visibility-boundary segment %d time %d has type %T", segmentIndex, pointIndex, times[pointIndex])
|
|
|
|
|
}
|
|
|
|
|
vertices = append(vertices, decodedRiseSetEndpoint{
|
|
|
|
|
coordinate: point, time: value, segment: segmentIndex,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return vertices
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetFeatureSegmentsShareEndpointInRegion(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
feature decodedFeature,
|
|
|
|
|
minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64,
|
|
|
|
|
) bool {
|
|
|
|
|
t.Helper()
|
|
|
|
|
endpoints := riseSetFeatureEndpoints(t, feature)
|
|
|
|
|
for index, current := range endpoints {
|
|
|
|
|
if !riseSetEndpointInRegion(current, minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, other := range endpoints[index+1:] {
|
|
|
|
|
if current.segment != other.segment && riseSetEndpointsMatch(current, other) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetFeatureHasInteriorVertexInRegion(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
feature decodedFeature,
|
|
|
|
|
minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64,
|
|
|
|
|
) bool {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode visibility-boundary coordinates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
for index := 1; index+1 < len(line); index++ {
|
|
|
|
|
point := decodedRiseSetEndpoint{coordinate: line[index]}
|
|
|
|
|
if riseSetEndpointInRegion(point, minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetEndpointInRegion(
|
|
|
|
|
point decodedRiseSetEndpoint,
|
|
|
|
|
minimumLongitude, maximumLongitude, minimumLatitude, maximumLatitude float64,
|
|
|
|
|
) bool {
|
|
|
|
|
return len(point.coordinate) >= 2 &&
|
|
|
|
|
point.coordinate[0] >= minimumLongitude && point.coordinate[0] <= maximumLongitude &&
|
|
|
|
|
point.coordinate[1] >= minimumLatitude && point.coordinate[1] <= maximumLatitude
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func riseSetEndpointsMatch(first, second decodedRiseSetEndpoint) bool {
|
|
|
|
|
return first.time == second.time &&
|
|
|
|
|
math.Abs(first.coordinate[0]-second.coordinate[0]) <= 1e-9 &&
|
|
|
|
|
math.Abs(first.coordinate[1]-second.coordinate[1]) <= 1e-9
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertRiseSetFeatureHasNoInstantaneousBranchJump(t *testing.T, feature decodedFeature) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var lines [][][]float64
|
|
|
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
|
|
|
|
t.Fatalf("decode visibility-boundary coordinates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
timeSegments, ok := feature.Properties["times"].([]interface{})
|
|
|
|
|
if !ok || len(timeSegments) != len(lines) {
|
|
|
|
|
t.Fatalf("visibility-boundary times do not align with %d segments", len(lines))
|
|
|
|
|
}
|
|
|
|
|
for segmentIndex, line := range lines {
|
|
|
|
|
times, ok := timeSegments[segmentIndex].([]interface{})
|
|
|
|
|
if !ok || len(times) != len(line) {
|
|
|
|
|
t.Fatalf("visibility-boundary segment %d times do not align with coordinates", segmentIndex)
|
|
|
|
|
}
|
|
|
|
|
for pointIndex := 1; pointIndex < len(line); pointIndex++ {
|
|
|
|
|
previous, previousOK := times[pointIndex-1].(string)
|
|
|
|
|
current, currentOK := times[pointIndex].(string)
|
|
|
|
|
previousTime, previousErr := time.Parse(time.RFC3339Nano, previous)
|
|
|
|
|
currentTime, currentErr := time.Parse(time.RFC3339Nano, current)
|
|
|
|
|
if !previousOK || !currentOK || previousErr != nil || currentErr != nil {
|
|
|
|
|
t.Fatalf("visibility-boundary segment %d has invalid adjacent times", segmentIndex)
|
|
|
|
|
}
|
|
|
|
|
distance := geoJSONCoordinateDistanceKM(line[pointIndex-1], line[pointIndex])
|
|
|
|
|
if distance > 500 && absoluteDuration(currentTime.Sub(previousTime)) < time.Second {
|
|
|
|
|
t.Fatalf("visibility-boundary segment %d contains %.1f km jump in %s",
|
|
|
|
|
segmentIndex, distance, currentTime.Sub(previousTime))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func absoluteDuration(value time.Duration) time.Duration {
|
|
|
|
|
if value < 0 {
|
|
|
|
|
return -value
|
|
|
|
|
}
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertOccultationBandPolygonCount(t *testing.T, collection decodedCollection, role string, want int) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
band := featureWithRole(t, collection, role)
|
|
|
|
|
var polygons [][][][]float64
|
|
|
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
|
|
|
t.Fatalf("decode %s: %v", role, err)
|
|
|
|
|
}
|
|
|
|
|
if len(polygons) != want {
|
|
|
|
|
t.Fatalf("%s polygon count = %d, want %d continuous bands", role, len(polygons), want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func geoJSONMultiPolygonContains(polygons [][][][]float64, longitude, latitude float64) bool {
|
|
|
|
|
for _, polygon := range polygons {
|
|
|
|
|
if len(polygon) > 0 && geoJSONRingContains(polygon[0], longitude, latitude) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func geoJSONMultiPolygonBoundaryDistanceKM(polygons [][][][]float64, point []float64) float64 {
|
|
|
|
|
minimum := math.Inf(1)
|
|
|
|
|
for _, polygon := range polygons {
|
|
|
|
|
for _, ring := range polygon {
|
|
|
|
|
for index := 1; index < len(ring); index++ {
|
|
|
|
|
minimum = math.Min(minimum, geoJSONPointSegmentDistanceKM(point, ring[index-1], ring[index]))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return minimum
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertGeoJSONRingHasNoShortHairpins(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
role string,
|
|
|
|
|
ring [][]float64,
|
|
|
|
|
maximumClosureKM, minimumDetourKM float64,
|
|
|
|
|
maximumSpan int,
|
|
|
|
|
) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
for start := 0; start+3 < len(ring); start++ {
|
|
|
|
|
limit := start + maximumSpan
|
|
|
|
|
if limit >= len(ring) {
|
|
|
|
|
limit = len(ring) - 1
|
|
|
|
|
}
|
|
|
|
|
arcLength := 0.0
|
|
|
|
|
for end := start + 1; end <= limit; end++ {
|
|
|
|
|
arcLength += geoJSONCoordinateDistanceKM(ring[end-1], ring[end])
|
|
|
|
|
if end < start+3 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
closure := geoJSONCoordinateDistanceKM(ring[start], ring[end])
|
|
|
|
|
if closure <= maximumClosureKM && arcLength-closure >= minimumDetourKM {
|
|
|
|
|
t.Fatalf(
|
|
|
|
|
"%s ring has a short hairpin at points %d..%d: closure %.1f km, arc %.1f km",
|
|
|
|
|
role, start, end, closure, arcLength,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func geoJSONRingContains(ring [][]float64, longitude, latitude float64) bool {
|
|
|
|
|
inside := false
|
|
|
|
|
for current, previous := 0, len(ring)-1; current < len(ring); previous, current = current, current+1 {
|
|
|
|
|
a, b := ring[previous], ring[current]
|
|
|
|
|
cross := (longitude-a[0])*(b[1]-a[1]) - (latitude-a[1])*(b[0]-a[0])
|
|
|
|
|
if math.Abs(cross) <= 1e-9 &&
|
|
|
|
|
longitude >= math.Min(a[0], b[0])-1e-9 && longitude <= math.Max(a[0], b[0])+1e-9 &&
|
|
|
|
|
latitude >= math.Min(a[1], b[1])-1e-9 && latitude <= math.Max(a[1], b[1])+1e-9 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if (a[1] > latitude) == (b[1] > latitude) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
intersection := a[0] + (latitude-a[1])*(b[0]-a[0])/(b[1]-a[1])
|
|
|
|
|
if intersection > longitude {
|
|
|
|
|
inside = !inside
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return inside
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func geoJSONCoordinateDistanceKM(first, second []float64) float64 {
|
|
|
|
|
firstLatitude := first[1] * math.Pi / 180
|
|
|
|
|
secondLatitude := second[1] * math.Pi / 180
|
|
|
|
|
deltaLatitude := secondLatitude - firstLatitude
|
|
|
|
|
deltaLongitude := math.Remainder((second[0]-first[0])*math.Pi/180, 2*math.Pi)
|
|
|
|
|
haversine := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) +
|
|
|
|
|
math.Cos(firstLatitude)*math.Cos(secondLatitude)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2)
|
|
|
|
|
return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, haversine)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func geoJSONPointSegmentDistanceKM(point, start, end []float64) float64 {
|
|
|
|
|
latitude := point[1] * math.Pi / 180
|
|
|
|
|
x := func(value []float64) float64 {
|
|
|
|
|
return math.Remainder(value[0]-point[0], 360) * math.Cos(latitude) * math.Pi / 180 * 6378.1366
|
|
|
|
|
}
|
|
|
|
|
y := func(value []float64) float64 {
|
|
|
|
|
return (value[1] - point[1]) * math.Pi / 180 * 6378.1366
|
|
|
|
|
}
|
|
|
|
|
startX, startY := x(start), y(start)
|
|
|
|
|
endX, endY := x(end), y(end)
|
|
|
|
|
deltaX, deltaY := endX-startX, endY-startY
|
|
|
|
|
fraction := 0.0
|
|
|
|
|
if lengthSquared := deltaX*deltaX + deltaY*deltaY; lengthSquared > 0 {
|
|
|
|
|
fraction = math.Max(0, math.Min(1, -(startX*deltaX+startY*deltaY)/lengthSquared))
|
|
|
|
|
}
|
|
|
|
|
return math.Hypot(startX+fraction*deltaX, startY+fraction*deltaY)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
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{{
|
2026-09-17 12:27:40 +08:00
|
|
|
{Time: at, Longitude: west, Latitude: south, MoonAltitude: 30, WidthKM: 1000},
|
|
|
|
|
{Time: at, Longitude: east, Latitude: south, MoonAltitude: 30, WidthKM: 1000},
|
|
|
|
|
{Time: at, Longitude: east, Latitude: north, MoonAltitude: 30, WidthKM: 1000},
|
|
|
|
|
{Time: at, Longitude: west, Latitude: north, MoonAltitude: 30, WidthKM: 1000},
|
2026-08-06 12:00:56 +08:00
|
|
|
}},
|
|
|
|
|
}
|
|
|
|
|
}
|