329 lines
12 KiB
Go
329 lines
12 KiB
Go
|
|
package geojson_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"math"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"b612.me/astro/geojson"
|
||
|
|
"b612.me/astro/internal/geodata"
|
||
|
|
"b612.me/astro/internal/occultationgeo"
|
||
|
|
"b612.me/astro/moon"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestMarshalStarOccultationAntares20240303UsesContinuousVisibleEnvelope(t *testing.T) {
|
||
|
|
zone := time.FixedZone("UTC+8", 8*60*60)
|
||
|
|
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, zone)
|
||
|
|
star := moon.StarCoordinate{
|
||
|
|
ID: "HR 6134", RA: 247.35166667, Dec: -26.43194444,
|
||
|
|
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||
|
|
Frame: moon.CoordinateFrameJ2000,
|
||
|
|
ProperMotionRACosDecMasPerYear: -10,
|
||
|
|
ProperMotionDecMasPerYear: -20,
|
||
|
|
ParallaxMas: 24,
|
||
|
|
}
|
||
|
|
paths, err := moon.FindStarOccultationPaths(
|
||
|
|
start, start.Add(24*time.Hour), star,
|
||
|
|
moon.OccultationPathOptions{
|
||
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||
|
|
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||
|
|
FootprintTimelineStep: 5 * time.Minute,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalStarOccultation(paths[0])
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
band := featureWithRole(t, collection, "occultation-band")
|
||
|
|
if band.Properties["static_band_authoritative"] != true {
|
||
|
|
t.Fatalf("occultation-band source=%v authoritative=%v, want analytic authoritative boundary",
|
||
|
|
band.Properties["source"], band.Properties["static_band_authoritative"])
|
||
|
|
}
|
||
|
|
for index, point := range paths[0].CenterLine {
|
||
|
|
if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) {
|
||
|
|
t.Fatalf("center-line sample %d lies outside visible band at %.6f, %.6f",
|
||
|
|
index, point.Longitude, point.Latitude)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
|
||
|
|
var lines [][][]float64
|
||
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
||
|
|
t.Fatalf("decode visibility-boundary: %v", err)
|
||
|
|
}
|
||
|
|
for segmentIndex, line := range lines {
|
||
|
|
for pointIndex, point := range line {
|
||
|
|
if !geometryContainsPointWithinKM(t, band.Geometry, point[0], point[1], 2) {
|
||
|
|
t.Fatalf("visibility-boundary segment %d point %d lies outside visible band at %.6f, %.6f",
|
||
|
|
segmentIndex, pointIndex, point[0], point[1])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
assertOccultationBandMaximumEdge(t, band, 45)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalStarOccultationAntares20240303RetainsNarrowGreatestSetFold(t *testing.T) {
|
||
|
|
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC)
|
||
|
|
paths, err := moon.FindStarOccultationPaths(
|
||
|
|
start, start.Add(24*time.Hour), antaresCoordinateForGeoJSONRegression(),
|
||
|
|
moon.OccultationPathOptions{
|
||
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||
|
|
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||
|
|
FootprintTimelineStep: 5 * time.Minute,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||
|
|
}
|
||
|
|
var curve moon.OccultationRiseSetCurve
|
||
|
|
for _, candidate := range paths[0].RiseSetCurves {
|
||
|
|
if candidate.Phase == moon.RiseSetPhaseGreatest && candidate.Direction == moon.RiseSetDirectionSet {
|
||
|
|
curve = candidate
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(curve.Segments) < 2 {
|
||
|
|
t.Fatalf("greatest/set segments=%d, want the narrow fold branch recovered at one-minute sampling", len(curve.Segments))
|
||
|
|
}
|
||
|
|
shared := false
|
||
|
|
for first := 0; first < len(curve.Segments); first++ {
|
||
|
|
for second := first + 1; second < len(curve.Segments); second++ {
|
||
|
|
for _, left := range []moon.OccultationPathPoint{curve.Segments[first][0], curve.Segments[first][len(curve.Segments[first])-1]} {
|
||
|
|
for _, right := range []moon.OccultationPathPoint{curve.Segments[second][0], curve.Segments[second][len(curve.Segments[second])-1]} {
|
||
|
|
if left.Time.Sub(right.Time) < -time.Second || left.Time.Sub(right.Time) > time.Second {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if geojsonPointDistanceKM(left.Longitude, left.Latitude, right.Longitude, right.Latitude) <= 1 {
|
||
|
|
shared = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !shared {
|
||
|
|
t.Fatal("greatest/set fold branches do not share a physical endpoint")
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalStarOccultation(paths[0])
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
||
|
|
}
|
||
|
|
boundary := riseSetBoundaryFeature(t, decodeCollection(t, data), "greatest", "set")
|
||
|
|
var lines [][][]float64
|
||
|
|
if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil {
|
||
|
|
t.Fatalf("decode greatest/set boundary: %v", err)
|
||
|
|
}
|
||
|
|
if len(lines) < 2 {
|
||
|
|
t.Fatalf("serialized greatest/set segments=%d, want at least two folded branches", len(lines))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func geojsonPointDistanceKM(firstLongitude, firstLatitude, secondLongitude, secondLatitude float64) float64 {
|
||
|
|
const earthRadiusKM = 6378.1366
|
||
|
|
const degreesToRadians = 3.141592653589793 / 180
|
||
|
|
firstLat, secondLat := firstLatitude*degreesToRadians, secondLatitude*degreesToRadians
|
||
|
|
deltaLat := (secondLatitude - firstLatitude) * degreesToRadians
|
||
|
|
deltaLon := (secondLongitude - firstLongitude) * degreesToRadians
|
||
|
|
a := math.Sin(deltaLat/2)*math.Sin(deltaLat/2) + math.Cos(firstLat)*math.Cos(secondLat)*math.Sin(deltaLon/2)*math.Sin(deltaLon/2)
|
||
|
|
return 2 * earthRadiusKM * math.Asin(math.Sqrt(math.Max(0, math.Min(1, a))))
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalStarOccultationAntaresRepresentative2022To2026Topology(t *testing.T) {
|
||
|
|
star := antaresCoordinateForGeoJSONRegression()
|
||
|
|
for _, date := range []string{
|
||
|
|
"2023-09-21", // first event in the series; short temporal horizon closure
|
||
|
|
"2023-10-18", // complete endpoint network without a temporal connector
|
||
|
|
"2024-03-03", // original disconnected footprint-sweep regression
|
||
|
|
"2024-06-20", // antimeridian split
|
||
|
|
"2025-08-31", // multi-branch southern polar turn and numerical sliver
|
||
|
|
"2026-02-11", // pole-enclosing equirectangular output
|
||
|
|
"2026-12-08", // returning ordinary-latitude branch
|
||
|
|
} {
|
||
|
|
date := date
|
||
|
|
t.Run(date, func(t *testing.T) {
|
||
|
|
start, err := time.Parse("2006-01-02", date)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
paths, err := moon.FindStarOccultationPaths(
|
||
|
|
start, start.Add(24*time.Hour), star,
|
||
|
|
moon.OccultationPathOptions{
|
||
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||
|
|
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||
|
|
FootprintTimelineStep: 5 * time.Minute,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if err != nil || len(paths) != 1 {
|
||
|
|
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||
|
|
}
|
||
|
|
path := paths[0]
|
||
|
|
polygons, authoritative, err := occultationgeo.VisibleStarBandPolygonsFromAnalyticContours(
|
||
|
|
path.BandFootprints, path.BandContours, path.VisibilityContours,
|
||
|
|
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
|
||
|
|
)
|
||
|
|
if err != nil || !authoritative || len(polygons) != 1 {
|
||
|
|
t.Fatalf("analytic polygons=%d authoritative=%v err=%v, want one physical band", len(polygons), authoritative, err)
|
||
|
|
}
|
||
|
|
assertOccultationPhysicalPolygonsMaximumEdge(t, polygons, 45)
|
||
|
|
assertOccultationPhysicalPolygonsHaveNoShortHairpins(t, polygons, 35, 25, 12)
|
||
|
|
if !geodata.SphericalPolygonsContainPathsWithinKM(
|
||
|
|
polygons, occultationPathPointLines([][]moon.OccultationPathPoint{path.CenterLine}), false, 0.1,
|
||
|
|
) {
|
||
|
|
t.Fatalf("analytic band misses center line by %.3f km",
|
||
|
|
geodata.SphericalPolygonsPathMissDistanceKM(
|
||
|
|
polygons, occultationPathPointLines([][]moon.OccultationPathPoint{path.CenterLine}), false,
|
||
|
|
))
|
||
|
|
}
|
||
|
|
phaseLines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves))
|
||
|
|
for _, curve := range path.RiseSetCurves {
|
||
|
|
phaseLines = append(phaseLines,
|
||
|
|
occultationPathPointLines(occultationgeo.StitchedRiseSetCurveSegments(curve))...,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
if !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 2) {
|
||
|
|
t.Fatalf("analytic band misses a displayed rise/set phase by %.3f km",
|
||
|
|
geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false))
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalStarOccultation(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
band := featureWithRole(t, collection, "occultation-band")
|
||
|
|
if band.Properties["static_band_authoritative"] != true {
|
||
|
|
t.Fatalf("occultation-band source=%v authoritative=%v",
|
||
|
|
band.Properties["source"], band.Properties["static_band_authoritative"])
|
||
|
|
}
|
||
|
|
for index, point := range path.CenterLine {
|
||
|
|
if !geometryContainsPointWithinKM(t, band.Geometry, point.Longitude, point.Latitude, 0.1) {
|
||
|
|
t.Fatalf("GeoJSON band excludes center sample %d at %.6f, %.6f",
|
||
|
|
index, point.Longitude, point.Latitude)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func antaresCoordinateForGeoJSONRegression() moon.StarCoordinate {
|
||
|
|
return moon.StarCoordinate{
|
||
|
|
ID: "HR 6134", RA: 247.35166667, Dec: -26.43194444,
|
||
|
|
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||
|
|
Frame: moon.CoordinateFrameJ2000,
|
||
|
|
ProperMotionRACosDecMasPerYear: -10,
|
||
|
|
ProperMotionDecMasPerYear: -20,
|
||
|
|
ParallaxMas: 24,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func occultationPathPointLines(sources [][]moon.OccultationPathPoint) [][]geodata.GeoPoint {
|
||
|
|
result := make([][]geodata.GeoPoint, 0, len(sources))
|
||
|
|
for _, source := range sources {
|
||
|
|
if len(source) < 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
line := make([]geodata.GeoPoint, len(source))
|
||
|
|
for index, point := range source {
|
||
|
|
line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
|
|
}
|
||
|
|
result = append(result, line)
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationPhysicalPolygonsMaximumEdge(
|
||
|
|
t *testing.T,
|
||
|
|
polygons [][]geodata.GeoPoint,
|
||
|
|
maximumKM float64,
|
||
|
|
) {
|
||
|
|
t.Helper()
|
||
|
|
for polygonIndex, polygon := range polygons {
|
||
|
|
for index := range polygon {
|
||
|
|
next := (index + 1) % len(polygon)
|
||
|
|
distance := geoJSONCoordinateDistanceKM(
|
||
|
|
[]float64{polygon[index].Longitude, polygon[index].Latitude},
|
||
|
|
[]float64{polygon[next].Longitude, polygon[next].Latitude},
|
||
|
|
)
|
||
|
|
if distance > maximumKM {
|
||
|
|
t.Fatalf("physical polygon %d edge %d is %.1f km, want <=%.1f km",
|
||
|
|
polygonIndex, index, distance, maximumKM)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationPhysicalPolygonsHaveNoShortHairpins(
|
||
|
|
t *testing.T,
|
||
|
|
polygons [][]geodata.GeoPoint,
|
||
|
|
maximumClosureKM, minimumDetourKM float64,
|
||
|
|
maximumSpan int,
|
||
|
|
) {
|
||
|
|
t.Helper()
|
||
|
|
for _, polygon := range polygons {
|
||
|
|
ring := make([][]float64, len(polygon))
|
||
|
|
for index, point := range polygon {
|
||
|
|
ring[index] = []float64{point.Longitude, point.Latitude}
|
||
|
|
}
|
||
|
|
assertGeoJSONRingHasNoShortHairpins(
|
||
|
|
t, "physical occultation-band", ring,
|
||
|
|
maximumClosureKM, minimumDetourKM, maximumSpan,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func geometryContainsPointWithinKM(t *testing.T, value struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
Coordinates json.RawMessage `json:"coordinates"`
|
||
|
|
Geometries json.RawMessage `json:"geometries"`
|
||
|
|
}, longitude, latitude, toleranceKM float64) bool {
|
||
|
|
if geometryContainsPoint(t, value, longitude, latitude) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
var polygons [][][][]float64
|
||
|
|
if value.Type == "MultiPolygon" {
|
||
|
|
if json.Unmarshal(value.Coordinates, &polygons) != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
} else if value.Type == "Polygon" {
|
||
|
|
var polygon [][][]float64
|
||
|
|
if json.Unmarshal(value.Coordinates, &polygon) != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
polygons = [][][][]float64{polygon}
|
||
|
|
} else {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
point := []float64{longitude, latitude}
|
||
|
|
for _, polygon := range polygons {
|
||
|
|
for _, ring := range polygon {
|
||
|
|
for index := 1; index < len(ring); index++ {
|
||
|
|
if geoJSONPointSegmentDistanceKM(point, ring[index-1], ring[index]) <= toleranceKM {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertOccultationBandMaximumEdge(t *testing.T, feature decodedFeature, maximumKM float64) {
|
||
|
|
t.Helper()
|
||
|
|
var polygons [][][][]float64
|
||
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
||
|
|
t.Fatalf("decode %v polygons: %v", feature.Properties["role"], err)
|
||
|
|
}
|
||
|
|
for polygonIndex, polygon := range polygons {
|
||
|
|
for ringIndex, ring := range polygon {
|
||
|
|
for pointIndex := 1; pointIndex < len(ring); pointIndex++ {
|
||
|
|
if distance := geoJSONCoordinateDistanceKM(ring[pointIndex-1], ring[pointIndex]); distance > maximumKM {
|
||
|
|
t.Fatalf("%v polygon %d ring %d edge %d is %.1f km, want <= %.1f km",
|
||
|
|
feature.Properties["role"], polygonIndex, ringIndex, pointIndex, distance, maximumKM)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|