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