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

451 lines
17 KiB
Go

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)
}
}
}
}
}