feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user