16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
254 lines
9.7 KiB
Go
254 lines
9.7 KiB
Go
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(JDCalc(year, 1, 1), JDCalc(year+1, 1, 1))
|
|
if len(events) == 0 {
|
|
t.Fatalf("%d has no solar eclipse candidate", year)
|
|
}
|
|
eventJDE := events[0]
|
|
name := JD2Date(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
|
|
}
|