Files
astro/basic/occultation_limit_separation_test.go
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

404 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 掠射端点锚点的容差:只钉住端点补采带来的百公里级缺口,不追求末位。
occultationLimitGrazingAnchorToleranceKM = 0.01
)
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)
}
}
// 掠射时横向极值恰好落在可见 θ 区间的相切端点上,端点判别式为 0:等差写法的末样本
// 与 interval.right 相差 1 ULP,足以把判别式推成负值而丢掉端点,端点必须按原值补采。
if vector, _, pointOK := occultationPathBoundaryVector(frame, interval.right); 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)
}
}
// TestPlanetOccultationGrazingEndpointWidthAnchors 钉住两个掠射相切端点样本的带宽。
//
// 这两个时刻的横向极值恰好落在可见 θ 区间的相切端点上,只能靠端点原值取到:等差写法
// left+(right-left)*n/n 与端点相差 1 ULP,该 ULP 会把端点判别式推成负值而被判"无地面交点",
// 于是 Saturn 2024-08-21 的 CenterLine[160] 少 47.9 km(3810.7491 对 3858.7641)、
// Venus 1227-05-19 的 CenterLine[15] 少 158.6 km(6047.4858 对 6206.0535)。
func TestPlanetOccultationGrazingEndpointWidthAnchors(t *testing.T) {
testCases := []struct {
name string
planet OccultationPlanet
start time.Time
options OccultationPathOptions
tt float64
widthKM float64
}{
{
name: "Saturn 2024-08-21 center-line[160]", planet: OccultationSaturn,
start: time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC),
tt: 2460543.661859489,
widthKM: 3858.7641,
},
{
name: "Venus 1227-05-19 center-line[15]", planet: OccultationVenus,
start: time.Date(1227, time.May, 18, 12, 0, 0, 0, time.UTC),
options: OccultationPathOptions{
Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute,
TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute,
},
tt: 2169357.811307699,
widthKM: 6206.0535,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
paths, err := FindPlanetOccultationPaths(tc.start, tc.start.Add(24*time.Hour), tc.planet, tc.options)
if err != nil || len(paths) != 1 {
t.Fatalf("paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
frameAt := occultationLimitExactFrame(t, tc.planet, centerTimeTT(path.Greatest.Time))
matched := 0
for _, point := range append([]OccultationPathPoint{path.Greatest}, path.CenterLine...) {
tt := centerTimeTT(point.Time)
if math.Abs(tt-tc.tt) > 1e-6 {
continue
}
matched++
if difference := math.Abs(point.WidthKM - tc.widthKM); difference > occultationLimitGrazingAnchorToleranceKM {
t.Fatalf("width at %v = %.6f km, want %.4f within %.4f km",
point.Time, point.WidthKM, tc.widthKM, occultationLimitGrazingAnchorToleranceKM)
}
independent, ok := occultationLimitIndependentGroundWidth(tt, frameAt)
if !ok || independent <= 0 {
t.Fatalf("independent ground width at %v is unavailable", point.Time)
}
if difference := math.Abs(point.WidthKM - independent); difference > occultationLimitIndependentTolerance*independent {
t.Fatalf("width at %v field=%.9f independent=%.9f, want within %.3e relative",
point.Time, point.WidthKM, independent, occultationLimitIndependentTolerance)
}
}
if matched != 1 {
t.Fatalf("matched %d samples at tt=%.9f, want exactly one", matched, tc.tt)
}
})
}
}