package geojson_test import ( "encoding/json" "testing" "time" "b612.me/astro/eclipse" "b612.me/astro/geojson" ) func TestSolarEclipse20120521HasCentralBandHorizonClosure(t *testing.T) { date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC) partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute, }) if !ok { t.Fatal("expected solar eclipse footprints") } if len(partial.CentralBandHorizonClosures) != 2 { t.Fatalf("central-limit horizon closures=%d, want start and end", len(partial.CentralBandHorizonClosures)) } expectedRoots := [2][2][]float64{ {{109.6236411, 19.9359003}, {107.7419895, 22.3910846}}, {{-100.0879481, 34.1224726}, {-102.2069471, 31.7284052}}, } for closureIndex, closure := range partial.CentralBandHorizonClosures { if len(closure) < 2 { t.Fatalf("horizon closure %d has %d points", closureIndex, len(closure)) } assertSolarPathMaximumEdgeKM(t, closure, 12) roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} for rootIndex, root := range roots { if distance := geoJSONCoordinateDistanceKM( []float64{root.Longitude, root.Latitude}, expectedRoots[closureIndex][rootIndex], ); distance > 0.1 { t.Fatalf("horizon closure %d root %d differs by %.3f km", closureIndex, rootIndex, distance) } } } for closureIndex, closure := range partial.CentralBandHorizonClosures { direction := eclipse.RiseSetDirectionRise if closureIndex == 1 { direction = eclipse.RiseSetDirectionSet } for pointIndex, point := range closure { if !solarGreatestCurveContainsPoint(partial.RiseSetCurves, point, direction) { t.Fatalf("horizon closure %d point %d is missing from its greatest/%s curve", closureIndex, pointIndex, direction) } } } central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ Step: 2 * time.Minute, TargetSpacingKM: 700, }) if !ok || len(central.CenterLine) < 2 { t.Fatal("expected a central eclipse 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.Properties["source"] != "besselian-critical-envelope" { t.Fatalf("central-band source=%v, want continuous critical envelope", band.Properties["source"]) } assertClosedMultiPolygon(t, band) 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 central-band boundary", closureIndex, pointIndex, distance) } } } for segmentIndex, segment := range central.CenterLine { point := []float64{segment.Longitude, segment.Latitude} if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) && geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 10 { t.Fatalf("center-line point %d lies outside central band", segmentIndex) } } } func TestSolarEclipse20120521CentralBandContainsVisibleAnnularStation(t *testing.T) { localDate := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) local, ok := eclipse.LocalSolarEclipseOnDate(localDate, 120.4913, 27.4779, 0) if !ok || local.Type != eclipse.SolarEclipseAnnular || !local.HasCentral || local.SunAltitude <= 0 { t.Fatalf("reference station is not visibly annular: ok=%v type=%s central=%v altitude=%.6f", ok, local.Type, local.HasCentral, local.SunAltitude) } date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC) central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{ Step: 2 * time.Minute, TargetSpacingKM: 700, }) if !ok { t.Fatal("expected solar central path") } for _, shadowStep := range []time.Duration{0, 2 * time.Minute} { shadowStep := shadowStep t.Run(shadowStep.String(), func(t *testing.T) { partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: shadowStep, }) if !ok { t.Fatal("expected solar eclipse footprints") } data, err := geojson.MarshalSolarEclipse(partial, ¢ral) if err != nil { t.Fatalf("MarshalSolarEclipse: %v", err) } band := featureWithRole(t, decodeCollection(t, data), "central-band") if !geometryContainsPoint(t, band.Geometry, 120.4913, 27.4779) { t.Fatal("central-band omits a station that visibly sees annularity") } if source := band.Properties["source"]; source != "besselian-critical-envelope" { t.Fatalf("central-band source=%v, want continuous critical envelope", source) } }) } } func assertSolarPathMaximumEdgeKM( t *testing.T, points []eclipse.SolarEclipsePathPoint, maximumKM float64, ) { t.Helper() for index := 1; index < len(points); index++ { distance := geoJSONCoordinateDistanceKM( []float64{points[index-1].Longitude, points[index-1].Latitude}, []float64{points[index].Longitude, points[index].Latitude}, ) if distance > maximumKM { t.Fatalf("horizon closure edge %d is %.3f km, want at most %.3f km", index-1, distance, maximumKM) } } } func TestSolarEclipse20120521HorizonClosuresAreStableAcrossSampling(t *testing.T) { date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC) expectedRoots := [2][2][]float64{ {{109.6236411, 19.9359003}, {107.7419895, 22.3910846}}, {{-100.0879481, 34.1224726}, {-102.2069471, 31.7284052}}, } for _, step := range []time.Duration{time.Minute, 5 * time.Minute, 10 * time.Minute} { partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ Step: step, BoundaryPoints: 96, CentralShadowStep: step, }) if !ok || len(partial.CentralBandHorizonClosures) != 2 { t.Fatalf("step %s: closures=%d ok=%v, want two", step, len(partial.CentralBandHorizonClosures), ok) } for closureIndex, closure := range partial.CentralBandHorizonClosures { roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]} for rootIndex, root := range roots { if distance := geoJSONCoordinateDistanceKM( []float64{root.Longitude, root.Latitude}, expectedRoots[closureIndex][rootIndex], ); distance > 0.1 { t.Fatalf("step %s: closure %d root %d differs by %.3f km", step, closureIndex, rootIndex, distance) } } } } } func solarGreatestCurveContainsPoint( curves []eclipse.SolarEclipseRiseSetCurve, want eclipse.SolarEclipsePathPoint, direction eclipse.RiseSetDirection, ) bool { for _, curve := range curves { if curve.Phase != eclipse.RiseSetPhaseGreatest || curve.Direction != direction { continue } for _, segment := range curve.Segments { for _, point := range segment { if point.Time.Equal(want.Time) && geoJSONCoordinateDistanceKM( []float64{point.Longitude, point.Latitude}, []float64{want.Longitude, want.Latitude}, ) <= 0.001 { return true } } } } return false }