2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
330 lines
14 KiB
Go
330 lines
14 KiB
Go
package basic
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// 掩带两条横向口径的契约:WidthKM 是中心线采样处的地面横向宽度,南北限是外接触锥的擦边切点轨迹,
|
|
// 限线间距由 LimitSeparationKM 单独给出,两者不相等也不可互相换算。
|
|
|
|
const (
|
|
occultationLimitTangencyToleranceArcsec = 5e-3
|
|
occultationLimitEndpointToleranceArcsec = 0.5
|
|
occultationLimitIndependentTolerance = 1e-4
|
|
occultationLimitExactTolerance = 1e-9
|
|
occultationLimitAnchorToleranceKM = 0.5
|
|
occultationLimitRatioTolerance = 0.002
|
|
)
|
|
|
|
type occultationLimitFixture struct {
|
|
name string
|
|
planet OccultationPlanet
|
|
start time.Time
|
|
widthKM float64
|
|
limitSeparationKM float64
|
|
totalWidthKM float64
|
|
limitRatio float64
|
|
centerLineSamples int
|
|
}
|
|
|
|
func occultationLimitFixtures() []occultationLimitFixture {
|
|
return []occultationLimitFixture{
|
|
{
|
|
name: "Jupiter 1962-10-10", planet: OccultationJupiter,
|
|
start: time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC),
|
|
widthKM: 5319.263900,
|
|
limitSeparationKM: 3593.197135,
|
|
totalWidthKM: 3385.147026,
|
|
limitRatio: 0.675506,
|
|
centerLineSamples: 12,
|
|
},
|
|
{
|
|
name: "Mars 2025-07-29", planet: OccultationMars,
|
|
start: time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)),
|
|
widthKM: 2190.685431,
|
|
limitSeparationKM: 1819.273229,
|
|
totalWidthKM: 1788.503498,
|
|
limitRatio: 0.830458,
|
|
},
|
|
}
|
|
}
|
|
|
|
func occultationLimitOptions() OccultationPathOptions {
|
|
return OccultationPathOptions{
|
|
Step: 10 * time.Minute, TargetSpacingKM: 900,
|
|
DisableFootprints: true, DisableRiseSet: true,
|
|
}
|
|
}
|
|
|
|
func occultationLimitPath(t *testing.T, fixture occultationLimitFixture) PlanetOccultationPath {
|
|
t.Helper()
|
|
paths, err := FindPlanetOccultationPaths(
|
|
fixture.start, fixture.start.Add(24*time.Hour), fixture.planet, occultationLimitOptions(),
|
|
)
|
|
if err != nil || len(paths) != 1 {
|
|
t.Fatalf("%s: paths=%d err=%v, want one", fixture.name, len(paths), err)
|
|
}
|
|
return paths[0]
|
|
}
|
|
|
|
func occultationLimitExactFrame(t *testing.T, planet OccultationPlanet, centerTT float64) occultationPathFrameFunc {
|
|
t.Helper()
|
|
config, ok := planetOccultationConfigFor(planet)
|
|
if !ok {
|
|
t.Fatalf("%v config is unavailable", planet)
|
|
}
|
|
cache := newPlanetOccultationEventCache(config)
|
|
cache.preparePathEphemeris(centerTT, OccultationPathAlgorithmExact)
|
|
return cache.outerFrameAt
|
|
}
|
|
|
|
func occultationLimitExternalContactGap(t *testing.T, planet OccultationPlanet, point OccultationPathPoint) (float64, bool) {
|
|
t.Helper()
|
|
config, ok := planetOccultationConfigFor(planet)
|
|
if !ok {
|
|
t.Fatalf("%v config is unavailable", planet)
|
|
}
|
|
observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude}
|
|
state := planetOccultationStateAt(centerTimeTT(point.Time), config, &observer, -1)
|
|
if !state.valid {
|
|
return 0, false
|
|
}
|
|
return state.externalContactMetric, true
|
|
}
|
|
|
|
// occultationLimitIndependentGroundWidth 按定义独立重算地面横向宽度:自建接触弧网格并自算地面横向方向。
|
|
func occultationLimitIndependentGroundWidth(tt float64, frameAt occultationPathFrameFunc) (float64, bool) {
|
|
frame, ok := frameAt(tt)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
before, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
|
|
after, afterOK := frameAt(tt + occultationPathVelocityStepDays)
|
|
if !beforeOK || !afterOK {
|
|
return 0, false
|
|
}
|
|
center, centerOK := occultationPathTrackReference(frame)
|
|
beforeCenter, beforeCenterOK := occultationPathTrackReference(before)
|
|
afterCenter, afterCenterOK := occultationPathTrackReference(after)
|
|
if !centerOK || !beforeCenterOK || !afterCenterOK {
|
|
return 0, false
|
|
}
|
|
rotation := occultationPathEarthRotationAt(tt)
|
|
centerFixed := occultationPathEarthFixedVectorWithRotation(center, rotation)
|
|
beforeFixed := occultationPathEarthFixedVector(tt-occultationPathVelocityStepDays, beforeCenter)
|
|
afterFixed := occultationPathEarthFixedVector(tt+occultationPathVelocityStepDays, afterCenter)
|
|
polar := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
|
|
normal := occultationPathUnit(occultationPathVector{x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polar})
|
|
track := occultationPathSub(afterFixed, beforeFixed)
|
|
track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal)))
|
|
if occultationPathNorm(track) <= 1e-12 {
|
|
return 0, false
|
|
}
|
|
cross := occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track)))
|
|
offset := func(vector occultationPathVector) float64 {
|
|
fixed := occultationPathEarthFixedVectorWithRotation(vector, rotation)
|
|
return occultationPathDot(occultationPathSub(fixed, centerFixed), cross)
|
|
}
|
|
minimum, maximum := math.Inf(1), math.Inf(-1)
|
|
consider := func(vector occultationPathVector) {
|
|
value := offset(vector)
|
|
minimum = math.Min(minimum, value)
|
|
maximum = math.Max(maximum, value)
|
|
}
|
|
const probeSamples = 4096
|
|
for _, interval := range occultationPathBoundaryThetaIntervals(frame) {
|
|
for index := 0; index <= probeSamples; index++ {
|
|
theta := interval.left + (interval.right-interval.left)*float64(index)/probeSamples
|
|
if vector, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
|
|
consider(vector)
|
|
}
|
|
}
|
|
}
|
|
for index := 0; index < occultationPathBoundaryScanPoints; index++ {
|
|
if vector, _, pointOK := occultationPathBoundaryVector(frame, 2*math.Pi*float64(index)/float64(occultationPathBoundaryScanPoints)); pointOK {
|
|
consider(vector)
|
|
}
|
|
}
|
|
if !finite(minimum) || !finite(maximum) {
|
|
return 0, false
|
|
}
|
|
return maximum - minimum, true
|
|
}
|
|
|
|
func TestPlanetOccultationLimitTracksAreGrazingTangencies(t *testing.T) {
|
|
for _, fixture := range occultationLimitFixtures() {
|
|
path := occultationLimitPath(t, fixture)
|
|
if len(path.NorthernLimit) != len(path.SouthernLimit) || len(path.NorthernLimit) < 32 {
|
|
t.Fatalf("%s: limit sample counts=%d/%d, want equal and at least 32",
|
|
fixture.name, len(path.NorthernLimit), len(path.SouthernLimit))
|
|
}
|
|
checked := 0
|
|
for _, track := range [][]OccultationPathPoint{path.NorthernLimit, path.SouthernLimit} {
|
|
for index, point := range track {
|
|
gap, ok := occultationLimitExternalContactGap(t, fixture.planet, point)
|
|
if !ok {
|
|
t.Fatalf("%s: limit point %d at %v has no valid contact state", fixture.name, index, point.Time)
|
|
}
|
|
// 首末采样是南北限的公共端点(构造上重合),残差略大,单独放宽。
|
|
if index == 0 || index == len(track)-1 {
|
|
if math.Abs(gap) > occultationLimitEndpointToleranceArcsec {
|
|
t.Fatalf("%s: closure endpoint %d external contact gap=%.6f arcsec, want at most %.6f",
|
|
fixture.name, index, gap, occultationLimitEndpointToleranceArcsec)
|
|
}
|
|
continue
|
|
}
|
|
if index >= 3 && index < len(track)-3 {
|
|
checked++
|
|
}
|
|
if math.Abs(gap) > occultationLimitTangencyToleranceArcsec {
|
|
t.Fatalf("%s: limit point %d at %v external contact gap=%.6f arcsec, want at most %.6f",
|
|
fixture.name, index, point.Time, gap, occultationLimitTangencyToleranceArcsec)
|
|
}
|
|
}
|
|
}
|
|
if checked < 100 {
|
|
t.Fatalf("%s: checked %d interior limit points, want at least 100", fixture.name, checked)
|
|
}
|
|
if separation := occultationPathDistanceKM(path.NorthernLimit[0], path.SouthernLimit[0]); separation != 0 {
|
|
t.Fatalf("%s: start closure separation=%.6f km, want the shared endpoint", fixture.name, separation)
|
|
}
|
|
last := len(path.NorthernLimit) - 1
|
|
if separation := occultationPathDistanceKM(path.NorthernLimit[last], path.SouthernLimit[last]); separation != 0 {
|
|
t.Fatalf("%s: end closure separation=%.6f km, want the shared endpoint", fixture.name, separation)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanetOccultationWidthKMIsGroundCrossTrackWidth(t *testing.T) {
|
|
for _, fixture := range occultationLimitFixtures() {
|
|
path := occultationLimitPath(t, fixture)
|
|
if len(path.CenterLine) != fixture.centerLineSamples {
|
|
t.Fatalf("%s: center line samples=%d, want %d", fixture.name, len(path.CenterLine), fixture.centerLineSamples)
|
|
}
|
|
frameAt := occultationLimitExactFrame(t, fixture.planet, centerTimeTT(path.Greatest.Time))
|
|
for _, point := range append([]OccultationPathPoint{path.Greatest}, path.CenterLine...) {
|
|
tt := centerTimeTT(point.Time)
|
|
_, _, exact, ok := occultationPathLimitsAndWidthForFrame(tt, frameAt)
|
|
if !ok || exact <= 0 {
|
|
t.Fatalf("%s: width at %v is unavailable", fixture.name, point.Time)
|
|
}
|
|
if difference := math.Abs(point.WidthKM - exact); difference > occultationLimitExactTolerance*exact {
|
|
t.Fatalf("%s: width at %v field=%.9f recomputed=%.9f, want within %.3e relative",
|
|
fixture.name, point.Time, point.WidthKM, exact, occultationLimitExactTolerance)
|
|
}
|
|
independent, ok := occultationLimitIndependentGroundWidth(tt, frameAt)
|
|
if !ok || independent <= 0 {
|
|
t.Fatalf("%s: independent ground width at %v is unavailable", fixture.name, point.Time)
|
|
}
|
|
if difference := math.Abs(point.WidthKM - independent); difference > occultationLimitIndependentTolerance*independent {
|
|
t.Fatalf("%s: width at %v field=%.9f independent ground cross-track width=%.9f, want within %.3e relative",
|
|
fixture.name, point.Time, point.WidthKM, independent, occultationLimitIndependentTolerance)
|
|
}
|
|
}
|
|
for _, point := range path.CenterLine {
|
|
if point.LimitSeparationKM != 0 {
|
|
t.Fatalf("%s: center-line point at %v carries limit separation %.6f, want zero outside limit tracks",
|
|
fixture.name, point.Time, point.LimitSeparationKM)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanetOccultationLimitSeparationMatchesExportedLimits(t *testing.T) {
|
|
for _, fixture := range occultationLimitFixtures() {
|
|
path := occultationLimitPath(t, fixture)
|
|
nearestIndex, nearestDelta := 0, math.Inf(1)
|
|
for index := range path.NorthernLimit {
|
|
if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) {
|
|
t.Fatalf("%s: limit sample %d times differ between tracks", fixture.name, index)
|
|
}
|
|
separation := occultationPathDistanceKM(path.NorthernLimit[index], path.SouthernLimit[index])
|
|
if path.NorthernLimit[index].LimitSeparationKM != separation ||
|
|
path.SouthernLimit[index].LimitSeparationKM != separation {
|
|
t.Fatalf("%s: limit sample %d separations=%.9f/%.9f, want the exported pair distance %.9f",
|
|
fixture.name, index, path.NorthernLimit[index].LimitSeparationKM,
|
|
path.SouthernLimit[index].LimitSeparationKM, separation)
|
|
}
|
|
if delta := math.Abs(centerTimeTT(path.NorthernLimit[index].Time) - centerTimeTT(path.Greatest.Time)); delta < nearestDelta {
|
|
nearestIndex, nearestDelta = index, delta
|
|
}
|
|
}
|
|
nearest := occultationPathDistanceKM(path.NorthernLimit[nearestIndex], path.SouthernLimit[nearestIndex])
|
|
if path.GreatestLimitSeparationKM != nearest {
|
|
t.Fatalf("%s: greatest limit separation=%.9f, want the nearest limit sample pair %.9f at %v",
|
|
fixture.name, path.GreatestLimitSeparationKM, nearest, path.NorthernLimit[nearestIndex].Time)
|
|
}
|
|
for index := range path.NorthernTotalLimit {
|
|
separation := occultationPathDistanceKM(path.NorthernTotalLimit[index], path.SouthernTotalLimit[index])
|
|
if path.NorthernTotalLimit[index].LimitSeparationKM != separation {
|
|
t.Fatalf("%s: total limit sample %d separation=%.9f, want %.9f",
|
|
fixture.name, index, path.NorthernTotalLimit[index].LimitSeparationKM, separation)
|
|
}
|
|
}
|
|
for _, anchor := range []struct {
|
|
name string
|
|
got float64
|
|
want float64
|
|
}{
|
|
{name: "greatest width", got: path.Greatest.WidthKM, want: fixture.widthKM},
|
|
{name: "greatest limit separation", got: path.GreatestLimitSeparationKM, want: fixture.limitSeparationKM},
|
|
{name: "greatest total width", got: path.GreatestTotalWidthKM, want: fixture.totalWidthKM},
|
|
} {
|
|
if difference := math.Abs(anchor.got - anchor.want); difference > occultationLimitAnchorToleranceKM {
|
|
t.Fatalf("%s: %s=%.6f km, want %.6f km within %.3f km",
|
|
fixture.name, anchor.name, anchor.got, anchor.want, occultationLimitAnchorToleranceKM)
|
|
}
|
|
}
|
|
ratio := path.GreatestLimitSeparationKM / path.Greatest.WidthKM
|
|
if math.Abs(ratio-fixture.limitRatio) > occultationLimitRatioTolerance {
|
|
t.Fatalf("%s: limit separation/width=%.6f, want %.6f within %.4f",
|
|
fixture.name, ratio, fixture.limitRatio, occultationLimitRatioTolerance)
|
|
}
|
|
if path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
|
|
t.Fatalf("%s: total width=%.6f must stay below the outer width=%.6f",
|
|
fixture.name, path.GreatestTotalWidthKM, path.Greatest.WidthKM)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStarOccultationLimitSeparationMatchesExportedLimits(t *testing.T) {
|
|
start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC)
|
|
paths, err := FindStarOccultationPaths(
|
|
start, start.Add(24*time.Hour), hr4799OccultationCoordinateForTest(),
|
|
OccultationPathOptions{
|
|
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
|
DisableFootprints: true, DisableRiseSet: true,
|
|
},
|
|
)
|
|
if err != nil || len(paths) != 1 {
|
|
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
|
}
|
|
path := paths[0]
|
|
nearestIndex, nearestDelta := 0, math.Inf(1)
|
|
for index := range path.NorthernLimit {
|
|
separation := occultationPathDistanceKM(path.NorthernLimit[index], path.SouthernLimit[index])
|
|
if path.NorthernLimit[index].LimitSeparationKM != separation {
|
|
t.Fatalf("limit sample %d separation=%.9f, want %.9f",
|
|
index, path.NorthernLimit[index].LimitSeparationKM, separation)
|
|
}
|
|
if delta := math.Abs(centerTimeTT(path.NorthernLimit[index].Time) - centerTimeTT(path.Greatest.Time)); delta < nearestDelta {
|
|
nearestIndex, nearestDelta = index, delta
|
|
}
|
|
}
|
|
nearest := occultationPathDistanceKM(path.NorthernLimit[nearestIndex], path.SouthernLimit[nearestIndex])
|
|
if path.GreatestLimitSeparationKM != nearest {
|
|
t.Fatalf("greatest limit separation=%.9f, want %.9f", path.GreatestLimitSeparationKM, nearest)
|
|
}
|
|
if math.Abs(path.Greatest.WidthKM-3582.363599) > occultationLimitAnchorToleranceKM ||
|
|
math.Abs(path.GreatestLimitSeparationKM-3666.576690) > occultationLimitAnchorToleranceKM {
|
|
t.Fatalf("greatest width=%.6f limit separation=%.6f, want 3582.363599 and 3666.576690 within %.3f km",
|
|
path.Greatest.WidthKM, path.GreatestLimitSeparationKM, occultationLimitAnchorToleranceKM)
|
|
}
|
|
ratio := path.GreatestLimitSeparationKM / path.Greatest.WidthKM
|
|
if math.Abs(ratio-1.023508) > occultationLimitRatioTolerance {
|
|
t.Fatalf("greatest limit separation/width=%.6f, want 1.023508 within %.4f", ratio, occultationLimitRatioTolerance)
|
|
}
|
|
}
|