Files
astro/basic/solar_eclipse_rise_set_scan_test.go
T

254 lines
9.7 KiB
Go
Raw Normal View History

package basic
import (
"fmt"
"math"
"testing"
"b612.me/astro/internal/geodata"
)
func TestSolarEclipsePathTopologyAcrossSarosAnchors(t *testing.T) {
for _, year := range []int{1526, 1600, 1700, 1800, 1900, 2000, 2100, 2200, 2300, 2400, 2526} {
events := solarEclipseScanEvents(JDECalc(year, 1, 1), JDECalc(year+1, 1, 1))
if len(events) == 0 {
t.Fatalf("%d has no solar eclipse candidate", year)
}
eventJDE := events[0]
name := JDE2Date(eventJDE).Format("2006-01-02")
t.Run(name, func(t *testing.T) {
assertSolarEclipsePathTopology(t, eventJDE, 60.0/1440.0)
})
}
}
func assertSolarEclipsePathTopology(t *testing.T, eventJDE, stepDays float64) {
t.Helper()
issues := solarEclipsePathTopologyIssues(eventJDE, stepDays)
if len(issues) > 0 {
t.Fatalf("path topology issues: %s", issues[0])
}
}
func solarEclipsePathTopologyIssues(eventJDE, stepDays float64) []string {
result := SolarEclipsePartialFootprints(eventJDE, SolarEclipsePartialFootprintOptions{
StepDays: stepDays, BoundaryPoints: 24, DisableRiseSetCurves: true,
})
issues := make([]string, 0)
if !result.Eclipse.HasPartial {
return []string{"partial eclipse result is missing"}
}
contacts := []SolarEclipsePathPoint{result.P1, result.P2, result.P3, result.P4}
previous := 0.0
for index, contact := range contacts {
if contact.JDE == 0 {
continue
}
if previous != 0 && contact.JDE <= previous {
issues = append(issues, fmt.Sprintf("penumbral contacts not increasing at %d", index))
}
previous = contact.JDE
}
centralContacts := []SolarEclipsePathPoint{result.U1, result.U2, result.U3, result.U4}
previous = 0
for index, contact := range centralContacts {
if contact.JDE == 0 {
continue
}
if previous != 0 && contact.JDE <= previous {
issues = append(issues, fmt.Sprintf("central contacts not increasing at %d", index))
}
previous = contact.JDE
}
for footprintIndex, footprint := range append(append([]SolarEclipsePartialFootprint(nil), result.Footprints...), result.CentralShadowFootprints...) {
totalBoundaryPoints := 0
for _, boundary := range footprint.Boundaries {
totalBoundaryPoints += len(boundary)
}
for boundaryIndex, boundary := range footprint.Boundaries {
if len(boundary) < 2 && (footprint.Closed || totalBoundaryPoints != 1) {
issues = append(issues, fmt.Sprintf("footprint %d boundary %d is too short", footprintIndex, boundaryIndex))
continue
}
for pointIndex, point := range boundary {
if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) ||
point.Longitude < -180 || point.Longitude > 180 || point.Latitude < -90 || point.Latitude > 90 {
issues = append(issues, fmt.Sprintf("footprint %d boundary %d point %d is invalid", footprintIndex, boundaryIndex, pointIndex))
}
if pointIndex > 0 && solarEclipsePathDistanceKM(boundary[pointIndex-1], point) > 12000 {
issues = append(issues, fmt.Sprintf("footprint %d boundary %d has a discontinuity", footprintIndex, boundaryIndex))
}
}
}
if footprint.Closed {
if len(footprint.Boundaries) == 0 {
issues = append(issues, fmt.Sprintf("closed footprint %d has no boundary", footprintIndex))
}
}
}
if result.Eclipse.HasCentral {
// Limit derivation needs the normal one-minute tangent samples. A
// coarse diagnostic footprint step can leave only two center points and
// is intentionally not reused for the paired cross-sections.
centralStepDays := solarEclipsePathDefaultStepDays
path := SolarEclipseCentralPath(eventJDE, SolarEclipsePathOptions{StepDays: centralStepDays})
if len(path.CenterLine) < 2 || len(path.NorthernLimit) < 2 || len(path.NorthernLimit) != len(path.SouthernLimit) {
issues = append(issues, "central path or paired limits are incomplete")
}
for index := 1; index < len(path.CenterLine); index++ {
if path.CenterLine[index].JDE <= path.CenterLine[index-1].JDE {
issues = append(issues, "center-line times are not increasing")
break
}
}
for index := 1; index < len(path.NorthernLimit); index++ {
if path.NorthernLimit[index].JDE <= path.NorthernLimit[index-1].JDE ||
path.SouthernLimit[index].JDE <= path.SouthernLimit[index-1].JDE {
issues = append(issues, "central-limit times are not increasing")
break
}
insideTwoLimitPath := result.Eclipse.Centrality == SolarEclipseCentralTwoLimits &&
index > 1 && index < len(path.NorthernLimit)-1
northGap := solarEclipsePathDistanceKM(path.NorthernLimit[index-1], path.NorthernLimit[index]) > 500 &&
!(math.Abs(path.NorthernLimit[index-1].Latitude) > 80 && math.Abs(path.NorthernLimit[index].Latitude) > 80)
southGap := solarEclipsePathDistanceKM(path.SouthernLimit[index-1], path.SouthernLimit[index]) > 500 &&
!(math.Abs(path.SouthernLimit[index-1].Latitude) > 80 && math.Abs(path.SouthernLimit[index].Latitude) > 80)
if insideTwoLimitPath && (northGap || southGap) {
issues = append(issues, "central-limit branch has a gap above 500 km")
break
}
}
if len(path.CenterLine) >= 2 {
first := path.CenterLine[0]
last := path.CenterLine[len(path.CenterLine)-1]
if math.Abs(first.SunAltitude) > 0.02 || math.Abs(last.SunAltitude) > 0.02 {
issues = append(issues, fmt.Sprintf(
"center-line limits are off the horizon: first=%.6f last=%.6f",
first.SunAltitude, last.SunAltitude,
))
}
if !finite(first.WidthKM) || !finite(last.WidthKM) ||
first.WidthKM < 0 || last.WidthKM < 0 || first.WidthKM > 5000 || last.WidthKM > 5000 {
issues = append(issues, fmt.Sprintf(
"center-line limit widths are invalid: first=%.3f last=%.3f",
first.WidthKM, last.WidthKM,
))
}
}
} else if result.Eclipse.Type != SolarEclipsePartial && result.Eclipse.Centrality == SolarEclipseNonCentral {
if len(result.CentralBandSegments) == 0 {
if err := auditSolarEclipseOpenBandSweep(result.CentralBandFootprints); err != nil {
issues = append(issues, "non-central eclipse has no usable central band: "+err.Error())
}
}
for index, segment := range result.CentralBandSegments {
if len(segment) < 4 || solarEclipsePathDistanceKM(segment[0], segment[len(segment)-1]) > 0.1 {
issues = append(issues, fmt.Sprintf("non-central band segment %d is not closed", index))
}
}
if len(result.CentralBandSegments) > 0 {
if err := auditSolarEclipseBandContainsFootprints(result.CentralBandSegments, result.CentralBandFootprints); err != nil {
issues = append(issues, "non-central band excludes an instantaneous central-shadow boundary: "+err.Error())
}
}
}
return issues
}
func auditSolarEclipseBandContainsFootprints(
segments [][]SolarEclipsePathPoint,
footprints []SolarEclipsePartialFootprint,
) error {
if solarEclipseNonCentralBandContainsFootprints(segments, footprints) {
return nil
}
miss := solarEclipseBandFootprintMissDistanceKM(segments, footprints)
return fmt.Errorf("maximum boundary miss %.6f km exceeds %.3f km tolerance",
miss, solarEclipseNonCentralBandContainmentToleranceKM)
}
func solarEclipseBandFootprintMissDistanceKM(
segments [][]SolarEclipsePathPoint,
footprints []SolarEclipsePartialFootprint,
) float64 {
polygons := make([][]geodata.GeoPoint, 0, len(segments))
for _, segment := range segments {
polygon := make([]geodata.GeoPoint, len(segment))
for index, point := range segment {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
polygons = append(polygons, polygon)
}
paths := make([][]geodata.GeoPoint, 0, len(footprints))
for _, footprint := range footprints {
for _, boundary := range footprint.Boundaries {
path := make([]geodata.GeoPoint, len(boundary))
for index, point := range boundary {
path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
paths = append(paths, path)
}
}
return geodata.SphericalPolygonsPathMissDistanceKM(polygons, paths, false)
}
func auditSolarEclipseOpenBandSweep(footprints []SolarEclipsePartialFootprint) error {
samples := make([]geodata.OpenBoundarySweepSample, 0, len(footprints))
for _, footprint := range footprints {
boundaries := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries))
for _, boundary := range footprint.Boundaries {
points := make([]geodata.GeoPoint, len(boundary))
for index, point := range boundary {
if !finite(point.Longitude) || !finite(point.Latitude) {
return fmt.Errorf("footprint contains a non-finite point")
}
points[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
boundaries = append(boundaries, points)
}
samples = append(samples, geodata.OpenBoundarySweepSample{
Boundaries: boundaries,
Closed: footprint.Closed,
})
}
polygons, err := geodata.OpenBoundarySweep(samples)
if err != nil {
return err
}
if len(polygons) == 0 {
return fmt.Errorf("open footprint sweep contains no polygon")
}
for polygonIndex, polygon := range polygons {
if len(polygon) < 3 {
return fmt.Errorf("open footprint sweep polygon %d has only %d points", polygonIndex, len(polygon))
}
for pointIndex, point := range polygon {
if !finite(point.Longitude) || !finite(point.Latitude) ||
point.Longitude < -180 || point.Longitude > 180 || point.Latitude < -90 || point.Latitude > 90 {
return fmt.Errorf("open footprint sweep polygon %d point %d is invalid", polygonIndex, pointIndex)
}
}
}
return nil
}
func solarEclipseScanEvents(startJDE, endJDE float64) []float64 {
seed := CalcMoonSHByJDE(startJDE, 0)
if seed < startJDE {
seed = CalcMoonSHByJDE(seed+25, 0)
}
var events []float64
for seed < endJDE {
result := SolarEclipse(seed)
if result.HasPartial {
events = append(events, result.GreatestEclipse)
}
next := CalcMoonSHByJDE(seed+25, 0)
if !finite(next) || next <= seed+20 {
break
}
seed = next
}
return events
}