feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationStationEnvelopeEqualTimeBranches(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
times []int
|
||||
segments int
|
||||
}{
|
||||
{"leading plateau", []int{0, 0, 0, 1, 2}, 1},
|
||||
{"interior plateau", []int{0, 1, 1, 1, 2}, 2},
|
||||
{"trailing plateau", []int{0, 1, 1, 1}, 1},
|
||||
{"entire plateau", []int{0, 0, 0}, 0},
|
||||
{"reverse branches", []int{2, 1, 1, 1, 0}, 2},
|
||||
{"ordinary fold", []int{0, 1, 2, 1, 0}, 2},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
points := make([]OccultationPathPoint, len(test.times))
|
||||
for index, seconds := range test.times {
|
||||
points[index] = OccultationPathPoint{Time: time.Unix(int64(seconds), 0), Longitude: float64(index)}
|
||||
}
|
||||
segments := occultationStationSplitEnvelopeAtTimeFolds(points)
|
||||
if len(segments) != test.segments {
|
||||
t.Fatalf("segments=%d, want %d", len(segments), test.segments)
|
||||
}
|
||||
for _, segment := range segments {
|
||||
for index := 1; index < len(segment); index++ {
|
||||
if !segment[index].Time.After(segment[index-1].Time) {
|
||||
t.Fatalf("non-increasing segment: %+v", segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every edge with elapsed time must survive the split unchanged.
|
||||
for index := 1; index < len(points); index++ {
|
||||
first, second := points[index-1], points[index]
|
||||
if first.Time.Equal(second.Time) {
|
||||
continue
|
||||
}
|
||||
if first.Time.After(second.Time) {
|
||||
first, second = second, first
|
||||
}
|
||||
found := false
|
||||
for _, segment := range segments {
|
||||
for offset := 1; offset < len(segment); offset++ {
|
||||
found = found || segment[offset-1] == first && segment[offset] == second
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("lost monotone edge %d", index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationStationOracleRefinesPlanetContours(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
cases := []struct {
|
||||
name string
|
||||
start time.Time
|
||||
planet OccultationPlanet
|
||||
}{
|
||||
{name: "Mars-20250729", start: time.Date(2025, time.July, 29, 0, 0, 0, 0, zone), planet: OccultationMars},
|
||||
{name: "Saturn-20240725", start: time.Date(2024, time.July, 25, 0, 0, 0, 0, zone), planet: OccultationSaturn},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
paths, err := FindPlanetOccultationPaths(test.start, test.start.Add(24*time.Hour), test.planet, OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, DisableRiseSet: true,
|
||||
})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, ok := planetOccultationConfigFor(test.planet)
|
||||
if !ok {
|
||||
t.Fatalf("%s config unavailable", test.planet)
|
||||
}
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time))
|
||||
checked := 0
|
||||
maxOffset := 0.0
|
||||
maxSeedResidual := 0.0
|
||||
unsolved := 0
|
||||
for _, contourSet := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
frameAt occultationPathFrameFunc
|
||||
total bool
|
||||
}{
|
||||
{name: "partial", contours: occultationContactBandContoursWithAdditionalTimes(
|
||||
paths[0].Start, paths[0].End, centerTimeTT(paths[0].Start.Time), centerTimeTT(paths[0].End.Time),
|
||||
centerTimeTT(paths[0].Greatest.Time), cache.outerFrameAt,
|
||||
OccultationPathOptions{Step: 20 * time.Minute, DisableRiseSet: true}, time.UTC, nil,
|
||||
), frameAt: cache.outerFrameAt},
|
||||
{name: "total", contours: occultationContactBandContoursWithAdditionalTimes(
|
||||
paths[0].TotalStart, paths[0].TotalEnd, centerTimeTT(paths[0].TotalStart.Time), centerTimeTT(paths[0].TotalEnd.Time),
|
||||
centerTimeTT(paths[0].Greatest.Time), cache.totalFrameAt,
|
||||
OccultationPathOptions{Step: 20 * time.Minute, DisableRiseSet: true}, time.UTC, nil,
|
||||
), frameAt: cache.totalFrameAt, total: true},
|
||||
} {
|
||||
for _, contour := range contourSet.contours {
|
||||
if len(contour) == 0 {
|
||||
continue
|
||||
}
|
||||
stride := int(math.Max(1, math.Ceil(float64(len(contour))/12)))
|
||||
for index := stride; index+stride < len(contour); index += stride {
|
||||
seed := contour[index]
|
||||
sample, solved := occultationStationCorrectBoundaryPoint(
|
||||
centerTimeTT(seed.Time), seed, contourSet.frameAt, cache.riseSetContextAt,
|
||||
contourSet.total, time.UTC,
|
||||
)
|
||||
if !solved || !sample.valid {
|
||||
unsolved++
|
||||
continue
|
||||
}
|
||||
if math.Abs(sample.contactResidualDeg) > 1e-5 {
|
||||
t.Fatalf("%s contour sample %d contact residual=%.9g arcsec", contourSet.name, index, sample.contactResidualDeg)
|
||||
}
|
||||
maxOffset = math.Max(maxOffset, math.Abs(sample.offsetKM))
|
||||
maxSeedResidual = math.Max(maxSeedResidual, math.Abs(sample.seedResidualDeg))
|
||||
checked++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked < 8 || unsolved > checked {
|
||||
t.Fatalf("checked %d station contour samples, unsolved=%d", checked, unsolved)
|
||||
}
|
||||
t.Logf("station oracle samples=%d unsolved=%d max offset=%.1f km max geocentric residual=%.6f arcsec", checked, unsolved, maxOffset, maxSeedResidual)
|
||||
if maxOffset > occultationStationOracleMaximumOffsetKM {
|
||||
t.Fatalf("max station correction offset=%.1f km exceeds oracle bound", maxOffset)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationStationCorrectedOpenFootprintEndpointsStayOnHorizon(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationMars,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, _ := planetOccultationConfigFor(OccultationMars)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.preparePathEphemeris(occultationTimeToTT(paths[0].Greatest.Time), OccultationPathAlgorithmOptimized)
|
||||
horizonEndpoints, coneEndpoints := 0, 0
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
frameAt occultationPathFrameFunc
|
||||
footprints []PlanetOccultationFootprint
|
||||
}{
|
||||
{name: "partial", frameAt: cache.outerFrameAt, footprints: paths[0].PartialBandFootprints},
|
||||
{name: "total", frameAt: cache.totalFrameAt, footprints: paths[0].TotalBandFootprints},
|
||||
} {
|
||||
for footprintIndex, footprint := range band.footprints {
|
||||
if footprint.Closed {
|
||||
continue
|
||||
}
|
||||
geocentric, geocentricOK := planetOccultationFootprintAtWithResolution(
|
||||
occultationTimeToTT(footprint.Time), band.frameAt, zone,
|
||||
planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints,
|
||||
planetOccultationBandTargetSpacingKM,
|
||||
)
|
||||
if !geocentricOK {
|
||||
t.Fatalf("%s footprint %d has no geocentric support at %v", band.name, footprintIndex, footprint.Time)
|
||||
}
|
||||
for boundaryIndex, boundary := range footprint.Boundaries {
|
||||
if len(boundary) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, pointIndex := range []int{0, len(boundary) - 1} {
|
||||
point := boundary[pointIndex]
|
||||
if math.Abs(point.MoonAltitude) <= 1e-5 {
|
||||
horizonEndpoints++
|
||||
continue
|
||||
}
|
||||
// 接触锥离开椭球处的尖点端点在地平线以上,地平线求解无法也
|
||||
// 不应移动它;此时端点必须与同一时刻的地心边界端点重合。
|
||||
if !planetOccultationFootprintHasGeocentricConeEndpoint(geocentric, point) {
|
||||
t.Fatalf(
|
||||
"%s footprint %d boundary %d endpoint %d MoonAltitude=%.9f deg is neither on the horizon nor a geocentric cone endpoint",
|
||||
band.name, footprintIndex, boundaryIndex, pointIndex, point.MoonAltitude,
|
||||
)
|
||||
}
|
||||
coneEndpoints++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if horizonEndpoints < 20 {
|
||||
t.Fatalf("checked only %d horizon endpoints", horizonEndpoints)
|
||||
}
|
||||
if coneEndpoints == 0 {
|
||||
t.Fatal("no cone-cusp endpoint observed; the cone-edge branch is untested")
|
||||
}
|
||||
}
|
||||
|
||||
func planetOccultationFootprintHasGeocentricConeEndpoint(
|
||||
footprint PlanetOccultationFootprint,
|
||||
point OccultationPathPoint,
|
||||
) bool {
|
||||
for _, boundary := range footprint.Boundaries {
|
||||
if len(boundary) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range []OccultationPathPoint{boundary[0], boundary[len(boundary)-1]} {
|
||||
// 锥体尖点端点在月球地平线以上,因此用“离开地平线”筛选地心对应点。
|
||||
if math.Abs(candidate.MoonAltitude) <= 1e-5 {
|
||||
continue
|
||||
}
|
||||
if occultationPathDistanceKM(candidate, point) <= 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestOccultationStationCorrectedContoursStayOnTemporalEnvelope(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationMars,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, ok := planetOccultationConfigFor(OccultationMars)
|
||||
if !ok {
|
||||
t.Fatal("Mars config unavailable")
|
||||
}
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time))
|
||||
|
||||
checked := 0
|
||||
failed := 0
|
||||
firstFailure := ""
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
total bool
|
||||
}{
|
||||
{name: "partial", contours: paths[0].PartialBandContours},
|
||||
{name: "total", contours: paths[0].TotalBandContours, total: true},
|
||||
} {
|
||||
for contourIndex, contour := range band.contours {
|
||||
for pointIndex, point := range contour {
|
||||
tt := centerTimeTT(point.Time)
|
||||
contextAt := cache.riseSetContextAt
|
||||
if band.total {
|
||||
contextAt = cache.totalRiseSetContextAt
|
||||
}
|
||||
evaluation := occultationRiseSetEvaluation{
|
||||
tt: tt,
|
||||
center: contextAt(tt),
|
||||
before: contextAt(tt - occultationRiseSetDerivativeStepDays),
|
||||
after: contextAt(tt + occultationRiseSetDerivativeStepDays),
|
||||
}
|
||||
state := evaluation.center.stateAt(point.Longitude, point.Latitude)
|
||||
derivative := evaluation.contactDerivative(point.Longitude, point.Latitude)
|
||||
if !state.valid || !finite(derivative) {
|
||||
t.Fatalf("%s contour %d point %d has invalid station state", band.name, contourIndex, pointIndex)
|
||||
}
|
||||
if math.Abs(state.contactMetric) > 1e-5 || math.Abs(derivative) > occultationRiseSetJunctionDerivativeTolerance {
|
||||
failed++
|
||||
if firstFailure == "" {
|
||||
firstFailure = fmt.Sprintf(
|
||||
"%s contour %d point %d contact=%.9g arcsec derivative=%.9g arcsec/day",
|
||||
band.name, contourIndex, pointIndex, state.contactMetric, derivative,
|
||||
)
|
||||
}
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked < 100 {
|
||||
t.Fatalf("checked only %d station envelope points", checked)
|
||||
}
|
||||
if failed > 0 {
|
||||
t.Fatalf("%d/%d points miss the temporal envelope; first: %s", failed, checked, firstFailure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationStationCorrectedContoursCloseOnVisiblePhaseJunctions(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationMars,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
curves []OccultationRiseSetCurve
|
||||
}{
|
||||
{name: "partial", contours: paths[0].PartialBandContours, curves: paths[0].RiseSetCurves},
|
||||
{name: "total", contours: paths[0].TotalBandContours, curves: paths[0].TotalRiseSetCurves},
|
||||
} {
|
||||
phasePoints := make([]OccultationPathPoint, 0)
|
||||
for _, curve := range band.curves {
|
||||
if curve.Phase != RiseSetPhaseStart && curve.Phase != RiseSetPhaseEnd {
|
||||
continue
|
||||
}
|
||||
for _, segment := range curve.Segments {
|
||||
phasePoints = append(phasePoints, segment...)
|
||||
}
|
||||
}
|
||||
if len(band.contours) == 0 || len(phasePoints) == 0 {
|
||||
t.Fatalf("%s has contours=%d phase points=%d, want visible envelope and phase boundary", band.name, len(band.contours), len(phasePoints))
|
||||
}
|
||||
for contourIndex, contour := range band.contours {
|
||||
if len(contour) < 2 {
|
||||
t.Fatalf("%s contour %d has %d points", band.name, contourIndex, len(contour))
|
||||
}
|
||||
for pointIndex, point := range contour {
|
||||
if point.MoonAltitude < -1e-7 {
|
||||
t.Fatalf("%s contour %d point %d is below the lunar horizon: altitude=%.9g", band.name, contourIndex, pointIndex, point.MoonAltitude)
|
||||
}
|
||||
if pointIndex > 0 && !point.Time.After(contour[pointIndex-1].Time) {
|
||||
t.Fatalf("%s contour %d times are not strictly increasing at point %d", band.name, contourIndex, pointIndex)
|
||||
}
|
||||
}
|
||||
for _, endpoint := range []OccultationPathPoint{contour[0], contour[len(contour)-1]} {
|
||||
if math.Abs(endpoint.MoonAltitude) <= 1e-6 {
|
||||
nearestKM := math.Inf(1)
|
||||
for _, phasePoint := range phasePoints {
|
||||
nearestKM = math.Min(nearestKM, occultationPathDistanceKM(endpoint, phasePoint))
|
||||
}
|
||||
if nearestKM > 0.1 {
|
||||
t.Fatalf("%s contour %d horizon endpoint is %.3f km from the start/end phase line, want <=0.1 km", band.name, contourIndex, nearestKM)
|
||||
}
|
||||
continue
|
||||
}
|
||||
shared := false
|
||||
for otherIndex, other := range band.contours {
|
||||
if otherIndex == contourIndex || len(other) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, otherEndpoint := range []OccultationPathPoint{other[0], other[len(other)-1]} {
|
||||
if math.Abs(endpoint.Time.Sub(otherEndpoint.Time).Seconds()) <= 0.01 &&
|
||||
occultationPathDistanceKM(endpoint, otherEndpoint) <= 0.001 {
|
||||
shared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared {
|
||||
t.Fatalf("%s contour %d non-horizon endpoint is not a shared temporal fold", band.name, contourIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationStationVisibilityContoursStayOnActiveTemporalMaximum(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn config unavailable")
|
||||
}
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time))
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
total bool
|
||||
}{
|
||||
{name: "partial", contours: paths[0].PartialVisibilityContours},
|
||||
{name: "total", contours: paths[0].TotalVisibilityContours, total: true},
|
||||
} {
|
||||
if len(band.contours) == 0 {
|
||||
t.Fatalf("%s has no lunar-visibility temporal contour", band.name)
|
||||
}
|
||||
for contourIndex, contour := range band.contours {
|
||||
if len(contour) < 3 {
|
||||
t.Fatalf("%s contour %d has %d points", band.name, contourIndex, len(contour))
|
||||
}
|
||||
for pointIndex, point := range contour {
|
||||
tt := centerTimeTT(point.Time)
|
||||
context := cache.riseSetContextAt
|
||||
if band.total {
|
||||
context = func(value float64) occultationRiseSetContext {
|
||||
return cache.riseSetContextAt(value).withInternalContact()
|
||||
}
|
||||
}
|
||||
evaluation := occultationRiseSetEvaluation{
|
||||
tt: tt, center: context(tt),
|
||||
before: context(tt - occultationRiseSetDerivativeStepDays),
|
||||
after: context(tt + occultationRiseSetDerivativeStepDays),
|
||||
}
|
||||
state := evaluation.center.stateAt(point.Longitude, point.Latitude)
|
||||
altitudeDerivative := evaluation.moonAltitudeDerivative(point.Longitude, point.Latitude)
|
||||
altitudeSecondDerivative := evaluation.moonAltitudeSecondDerivative(point.Longitude, point.Latitude)
|
||||
if !state.valid || math.Abs(state.moonAltitude) > 1e-5 ||
|
||||
math.Abs(altitudeDerivative) > occultationRiseSetJunctionDerivativeTolerance ||
|
||||
altitudeSecondDerivative >= 0 || state.contactMetric > 1e-5 {
|
||||
t.Fatalf(
|
||||
"%s contour %d point %d residuals H=%.9g Ht=%.9g Htt=%.9g F=%.9g",
|
||||
band.name, contourIndex, pointIndex, state.moonAltitude,
|
||||
altitudeDerivative, altitudeSecondDerivative, state.contactMetric,
|
||||
)
|
||||
}
|
||||
}
|
||||
for _, endpoint := range []OccultationPathPoint{contour[0], contour[len(contour)-1]} {
|
||||
context := cache.riseSetContextAt(centerTimeTT(endpoint.Time))
|
||||
if band.total {
|
||||
context = context.withInternalContact()
|
||||
}
|
||||
state := context.stateAt(endpoint.Longitude, endpoint.Latitude)
|
||||
if !state.valid || math.Abs(state.contactMetric) > 1e-5 {
|
||||
t.Fatalf("%s contour %d endpoint contact residual=%.9g", band.name, contourIndex, state.contactMetric)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user