490 lines
22 KiB
Go
490 lines
22 KiB
Go
|
|
package basic
|
||
|
|
|
||
|
|
import (
|
||
|
|
"math"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"b612.me/astro/internal/geodata"
|
||
|
|
"b612.me/astro/moon/limb"
|
||
|
|
)
|
||
|
|
|
||
|
|
// recordingLunarLimb 记录最后一次查询参数,返回固定半径。
|
||
|
|
type recordingLunarLimb struct {
|
||
|
|
lon, lat, angle float64
|
||
|
|
radiusKM float64
|
||
|
|
}
|
||
|
|
|
||
|
|
func (limb *recordingLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) {
|
||
|
|
limb.lon, limb.lat, limb.angle = lon, lat, angle
|
||
|
|
return limb.radiusKM, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func lunarLimbAngleDiff(a, b float64) float64 {
|
||
|
|
d := math.Mod(a-b, 360)
|
||
|
|
if d > 180 {
|
||
|
|
d -= 360
|
||
|
|
}
|
||
|
|
if d < -180 {
|
||
|
|
d += 360
|
||
|
|
}
|
||
|
|
return d
|
||
|
|
}
|
||
|
|
|
||
|
|
// 地心观测者所见的月面经纬必须还原该时刻的总天平动;目标位于月球北极投影方向时位置角为 0,
|
||
|
|
// 向天东转 90° 时为 90°。
|
||
|
|
func TestLunarLimbFrameRoundTrip(t *testing.T) {
|
||
|
|
for _, jde := range []float64{2460409.25, 2451545.0, 2462000.7} {
|
||
|
|
_, moon := solarEclipseSunMoonEquatorial(jde)
|
||
|
|
moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2])
|
||
|
|
limb := &recordingLunarLimb{radiusKM: 1737.4}
|
||
|
|
frame := newLunarLimbFrame(limb, jde, moonXYZ)
|
||
|
|
if !frame.valid {
|
||
|
|
t.Fatalf("jde %v: frame invalid", jde)
|
||
|
|
}
|
||
|
|
physical := MoonPhysical(jde)
|
||
|
|
moonUnit, _ := lunarLimbNormalize(moonXYZ)
|
||
|
|
north, east, _ := lunarLimbSkyBasis(moonUnit)
|
||
|
|
|
||
|
|
for _, offset := range []float64{0, 90, 200} {
|
||
|
|
pa := (physical.PositionAngle + offset) * rad
|
||
|
|
// 目标放在月心旁 0.5° 处、位置角为 P + offset。
|
||
|
|
sky := lunarLimbCombine(math.Cos(pa), north, math.Sin(pa), east)
|
||
|
|
target := lunarLimbCombine(math.Cos(0.5*rad), moonUnit, math.Sin(0.5*rad), sky)
|
||
|
|
target = [3]float64{target[0] * 1.5e8, target[1] * 1.5e8, target[2] * 1.5e8}
|
||
|
|
if _, ok := frame.radiusKM(moonXYZ, target, false); !ok {
|
||
|
|
t.Fatalf("jde %v: radiusKM failed", jde)
|
||
|
|
}
|
||
|
|
if d := lunarLimbAngleDiff(limb.lon, physical.LibrationLongitude); math.Abs(d) > 1e-6 {
|
||
|
|
t.Errorf("jde %v: libration longitude %.9f, want %.9f", jde, limb.lon, physical.LibrationLongitude)
|
||
|
|
}
|
||
|
|
if d := limb.lat - physical.LibrationLatitude; math.Abs(d) > 1e-6 {
|
||
|
|
t.Errorf("jde %v: libration latitude %.9f, want %.9f", jde, limb.lat, physical.LibrationLatitude)
|
||
|
|
}
|
||
|
|
if d := lunarLimbAngleDiff(limb.angle, -offset); math.Abs(d) > 1e-6 {
|
||
|
|
t.Errorf("jde %v offset %v: position angle %.9f", jde, offset, limb.angle)
|
||
|
|
}
|
||
|
|
if _, ok := frame.radiusKM(moonXYZ, target, true); !ok {
|
||
|
|
t.Fatalf("jde %v: away radiusKM failed", jde)
|
||
|
|
}
|
||
|
|
if d := lunarLimbAngleDiff(limb.angle, 180-offset); math.Abs(d) > 1e-6 {
|
||
|
|
t.Errorf("jde %v offset %v: away position angle %.9f", jde, offset, limb.angle)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 参考球半径等于 k·R⊕ 的常数月缘只能让结果在末位附近变化,用来确认替换路径本身不引入偏差。
|
||
|
|
func TestLocalSolarEclipseConstantLimbMatchesCircular(t *testing.T) {
|
||
|
|
const seed, lon, lat = 2460409.25, -96.8, 32.8 // 2024-04-08,达拉斯附近
|
||
|
|
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK}
|
||
|
|
circular := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
|
||
|
|
if !circular.HasTotal {
|
||
|
|
t.Fatalf("expected total eclipse, got %v", circular.Type)
|
||
|
|
}
|
||
|
|
options.LunarLimb = &recordingLunarLimb{
|
||
|
|
radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale,
|
||
|
|
}
|
||
|
|
limbed := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
|
||
|
|
for _, pair := range [][2]float64{
|
||
|
|
{circular.PartialStart, limbed.PartialStart},
|
||
|
|
{circular.CentralStart, limbed.CentralStart},
|
||
|
|
{circular.CentralEnd, limbed.CentralEnd},
|
||
|
|
{circular.PartialEnd, limbed.PartialEnd},
|
||
|
|
} {
|
||
|
|
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 1e-3 {
|
||
|
|
t.Errorf("contact moved by %.6f s with a constant limb", d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLunarEclipseConstantLimbMatchesCircular(t *testing.T) {
|
||
|
|
limb := &recordingLunarLimb{radiusKM: lunarMoonRadiusScale}
|
||
|
|
for _, chauvenet := range []bool{false, true} {
|
||
|
|
circular := LunarEclipseDanjonWithInstance(2460926.0, nil)
|
||
|
|
if chauvenet {
|
||
|
|
circular = LunarEclipseChauvenetWithInstance(2460926.0, nil)
|
||
|
|
}
|
||
|
|
limbed := LunarEclipseWithLunarLimb(circular, limb, nil)
|
||
|
|
for _, pair := range [][2]float64{
|
||
|
|
{circular.PenumbralStart, limbed.PenumbralStart},
|
||
|
|
{circular.PartialStart, limbed.PartialStart},
|
||
|
|
{circular.TotalStart, limbed.TotalStart},
|
||
|
|
{circular.TotalEnd, limbed.TotalEnd},
|
||
|
|
{circular.PartialEnd, limbed.PartialEnd},
|
||
|
|
{circular.PenumbralEnd, limbed.PenumbralEnd},
|
||
|
|
} {
|
||
|
|
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 0.05 {
|
||
|
|
t.Errorf("chauvenet=%v: contact moved by %.4f s with a constant limb", chauvenet, d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if limbed.Maximum != circular.Maximum || limbed.Magnitude != circular.Magnitude {
|
||
|
|
t.Error("maximum or magnitude changed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if got := LunarEclipseWithLunarLimb(LunarEclipseDanjonWithInstance(2460926.0, nil), nil, nil); got != LunarEclipseDanjonWithInstance(2460926.0, nil) {
|
||
|
|
t.Error("nil limb must return the input unchanged")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationConstantLimbMatchesCircular(t *testing.T) {
|
||
|
|
limb := &recordingLunarLimb{radiusKM: moonEquatorialRadiusKM}
|
||
|
|
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
|
||
|
|
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
|
||
|
|
observer := Observer{Longitude: -30.072, Latitude: 16.21}
|
||
|
|
plain, err := FindPlanetOccultationsWithInstance(start, end, OccultationSaturn,
|
||
|
|
observer.Longitude, observer.Latitude, 0, OccultationSearchOptions{}, nil)
|
||
|
|
if err != nil || len(plain) != 1 {
|
||
|
|
t.Fatalf("circular planet search: %v, %d events", err, len(plain))
|
||
|
|
}
|
||
|
|
limbed, err := FindPlanetOccultationsWithInstance(start, end, OccultationSaturn,
|
||
|
|
observer.Longitude, observer.Latitude, 0, OccultationSearchOptions{LunarLimb: limb}, nil)
|
||
|
|
if err != nil || len(limbed) != 1 {
|
||
|
|
t.Fatalf("limb planet search: %v, %d events", err, len(limbed))
|
||
|
|
}
|
||
|
|
a, b := plain[0], limbed[0]
|
||
|
|
for i, pair := range [][2]time.Time{
|
||
|
|
{a.ExternalImmersion, b.ExternalImmersion}, {a.InternalImmersion, b.InternalImmersion},
|
||
|
|
{a.InternalEmersion, b.InternalEmersion}, {a.ExternalEmersion, b.ExternalEmersion},
|
||
|
|
} {
|
||
|
|
if d := math.Abs(pair[0].Sub(pair[1]).Seconds()); d > 0.01 {
|
||
|
|
t.Errorf("planet contact %d moved by %.4f s with a constant limb", i, d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !a.Greatest.Equal(b.Greatest) || a.Type != b.Type {
|
||
|
|
t.Error("greatest or type changed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGlobalSolarEclipseConstantLimbMatchesCircular(t *testing.T) {
|
||
|
|
// IAU Single-K 的内外 k 相同,常数月缘可同时复现外切与内切半径。
|
||
|
|
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale}
|
||
|
|
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK}
|
||
|
|
for _, seed := range []float64{2460409.0, 2460232.0} {
|
||
|
|
circular := SolarEclipseWithOptionsWithInstance(seed, options, nil)
|
||
|
|
withLimb := options
|
||
|
|
withLimb.LunarLimb = limb
|
||
|
|
limbed := SolarEclipseWithOptionsWithInstance(seed, withLimb, nil)
|
||
|
|
for i, pair := range [][2]float64{
|
||
|
|
{circular.PartialBeginOnEarth, limbed.PartialBeginOnEarth},
|
||
|
|
{circular.PartialEndOnEarth, limbed.PartialEndOnEarth},
|
||
|
|
} {
|
||
|
|
if d := math.Abs(pair[0]-pair[1]) * 86400; d > 0.05 {
|
||
|
|
t.Errorf("seed %.0f contact %d moved by %.4f s with a constant limb", seed, i, d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if limbed.GreatestEclipse != circular.GreatestEclipse || limbed.Type != circular.Type ||
|
||
|
|
limbed.CentralBeginOnEarth != circular.CentralBeginOnEarth || limbed.PathWidthKM != circular.PathWidthKM {
|
||
|
|
t.Errorf("seed %.0f: non-contact fields changed", seed)
|
||
|
|
}
|
||
|
|
if d := math.Abs(limbed.CentralDurationDays-circular.CentralDurationDays) * 86400; d > 0.05 {
|
||
|
|
t.Errorf("seed %.0f: central duration moved by %.4f s with a constant limb", seed, d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCentralPathConstantLimbKeepsLimits(t *testing.T) {
|
||
|
|
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
|
||
|
|
for _, seed := range []float64{2460409.0, 2460232.0} {
|
||
|
|
circular := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{}, nil)
|
||
|
|
limbed := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: limb}, nil)
|
||
|
|
if len(circular.NorthernLimit) == 0 || len(limbed.NorthernLimit) != len(circular.NorthernLimit) ||
|
||
|
|
len(limbed.SouthernLimit) != len(circular.SouthernLimit) {
|
||
|
|
t.Fatalf("seed %.0f: limit lengths %d/%d vs %d/%d", seed,
|
||
|
|
len(limbed.NorthernLimit), len(limbed.SouthernLimit), len(circular.NorthernLimit), len(circular.SouthernLimit))
|
||
|
|
}
|
||
|
|
worst := 0.0
|
||
|
|
for _, pair := range [][2][]SolarEclipsePathPoint{{circular.NorthernLimit, limbed.NorthernLimit}, {circular.SouthernLimit, limbed.SouthernLimit}} {
|
||
|
|
for i := range pair[0] {
|
||
|
|
d := math.Hypot((pair[0][i].Longitude-pair[1][i].Longitude)*math.Cos(pair[0][i].Latitude*rad), pair[0][i].Latitude-pair[1][i].Latitude) * 111.2
|
||
|
|
worst = math.Max(worst, d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if worst > 0.05 {
|
||
|
|
t.Errorf("seed %.0f: constant limb moved a limit by %.3f km", seed, worst)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOccultationPathConstantLimbKeepsLimits(t *testing.T) {
|
||
|
|
limb := &recordingLunarLimb{radiusKM: moonEquatorialRadiusKM}
|
||
|
|
star := StarCoordinate{
|
||
|
|
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
|
||
|
|
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
|
||
|
|
ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18,
|
||
|
|
}
|
||
|
|
start := time.Date(2025, 6, 4, 16, 0, 0, 0, time.UTC)
|
||
|
|
end := time.Date(2025, 6, 5, 16, 0, 0, 0, time.UTC)
|
||
|
|
options := OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, DisableFootprints: true, DisableRiseSet: true}
|
||
|
|
circular, err := FindStarOccultationPathsWithInstance(start, end, star, options, nil)
|
||
|
|
if err != nil || len(circular) != 1 {
|
||
|
|
t.Fatalf("circular: %v %d", err, len(circular))
|
||
|
|
}
|
||
|
|
options.LunarLimb = limb
|
||
|
|
limbed, err := FindStarOccultationPathsWithInstance(start, end, star, options, nil)
|
||
|
|
if err != nil || len(limbed) != 1 {
|
||
|
|
t.Fatalf("limbed: %v %d", err, len(limbed))
|
||
|
|
}
|
||
|
|
worst := 0.0
|
||
|
|
for _, pair := range [][2][]OccultationPathPoint{{circular[0].NorthernLimit, limbed[0].NorthernLimit}, {circular[0].SouthernLimit, limbed[0].SouthernLimit}} {
|
||
|
|
if len(pair[0]) == 0 || len(pair[0]) != len(pair[1]) {
|
||
|
|
t.Fatalf("limit lengths %d vs %d", len(pair[0]), len(pair[1]))
|
||
|
|
}
|
||
|
|
for i := range pair[0] {
|
||
|
|
d := math.Hypot((pair[0][i].Longitude-pair[1][i].Longitude)*math.Cos(pair[0][i].Latitude*rad), pair[0][i].Latitude-pair[1][i].Latitude) * 111.2
|
||
|
|
worst = math.Max(worst, d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if worst > 0.05 {
|
||
|
|
t.Errorf("constant limb moved a graze limit by %.3f km", worst)
|
||
|
|
}
|
||
|
|
if limbed[0].LunarLimbApplied {
|
||
|
|
t.Fatal("constant circular limb set LunarLimbApplied = true")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCentralPathConstantLimbKeepsWidthsAndBand(t *testing.T) {
|
||
|
|
limb := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
|
||
|
|
for _, seed := range []float64{2460409.0, 2460232.0} {
|
||
|
|
circular := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{}, nil)
|
||
|
|
limbed := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: limb}, nil)
|
||
|
|
if len(limbed.CenterLine) != len(circular.CenterLine) || len(limbed.CentralBandSegments) != len(circular.CentralBandSegments) {
|
||
|
|
t.Fatalf("seed %.0f: sample counts changed", seed)
|
||
|
|
}
|
||
|
|
worstWidth := 0.0
|
||
|
|
for i := range circular.CenterLine {
|
||
|
|
worstWidth = math.Max(worstWidth, math.Abs(limbed.CenterLine[i].WidthKM-circular.CenterLine[i].WidthKM))
|
||
|
|
}
|
||
|
|
if worstWidth > 0.1 {
|
||
|
|
t.Errorf("seed %.0f: constant limb moved a width by %.3f km", seed, worstWidth)
|
||
|
|
}
|
||
|
|
if d := math.Abs(limbed.Greatest.WidthKM - circular.Greatest.WidthKM); d > 0.1 {
|
||
|
|
t.Errorf("seed %.0f: greatest width moved by %.3f km", seed, d)
|
||
|
|
}
|
||
|
|
if d := math.Abs(limbed.MaxCentralDurationDays-circular.MaxCentralDurationDays) * 86400; d > 0.05 {
|
||
|
|
t.Errorf("seed %.0f: max duration moved by %.4f s", seed, d)
|
||
|
|
}
|
||
|
|
worstBand := 0.0
|
||
|
|
for s := range circular.CentralBandSegments {
|
||
|
|
if len(limbed.CentralBandSegments[s]) != len(circular.CentralBandSegments[s]) {
|
||
|
|
t.Fatalf("seed %.0f: band segment %d length changed", seed, s)
|
||
|
|
}
|
||
|
|
for i, a := range circular.CentralBandSegments[s] {
|
||
|
|
b := limbed.CentralBandSegments[s][i]
|
||
|
|
d := math.Hypot((a.Longitude-b.Longitude)*math.Cos(a.Latitude*rad), a.Latitude-b.Latitude) * 111.2
|
||
|
|
worstBand = math.Max(worstBand, d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if worstBand > 0.1 {
|
||
|
|
t.Errorf("seed %.0f: constant limb moved a band vertex by %.3f km", seed, worstBand)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// failingLunarLimb 处处无值:月缘修正必须整体退回圆月面且不置 LunarLimbApplied。
|
||
|
|
type failingLunarLimb struct{}
|
||
|
|
|
||
|
|
func (failingLunarLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return 0, false }
|
||
|
|
|
||
|
|
func TestFailingLimbKeepsEclipsesCircular(t *testing.T) {
|
||
|
|
const seed, lon, lat = 2460409.25, -96.8, 32.8 // 2024-04-08,达拉斯附近
|
||
|
|
limb := failingLunarLimb{}
|
||
|
|
for _, model := range []SolarEclipseRadiusModel{SolarEclipseModelNASABulletinSplitK, SolarEclipseModelIAUSingleK} {
|
||
|
|
options := SolarEclipseOptions{RadiusModel: model}
|
||
|
|
withLimb := options
|
||
|
|
withLimb.LunarLimb = limb
|
||
|
|
|
||
|
|
circularLocal := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil)
|
||
|
|
limbedLocal := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, withLimb, nil)
|
||
|
|
if limbedLocal != circularLocal {
|
||
|
|
t.Errorf("model %v: failing limb changed the local result", model)
|
||
|
|
}
|
||
|
|
|
||
|
|
circularGlobal := SolarEclipseWithOptionsWithInstance(seed, options, nil)
|
||
|
|
limbedGlobal := SolarEclipseWithOptionsWithInstance(seed, withLimb, nil)
|
||
|
|
if limbedGlobal.LunarLimbApplied ||
|
||
|
|
limbedGlobal.PartialBeginOnEarth != circularGlobal.PartialBeginOnEarth ||
|
||
|
|
limbedGlobal.PartialEndOnEarth != circularGlobal.PartialEndOnEarth ||
|
||
|
|
limbedGlobal.CentralDurationDays != circularGlobal.CentralDurationDays {
|
||
|
|
t.Errorf("model %v: failing limb changed the global result or set LunarLimbApplied", model)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
circularLunar := LunarEclipseDanjonWithInstance(2460926.0, nil)
|
||
|
|
if got := LunarEclipseWithLunarLimb(circularLunar, limb, nil); got != circularLunar {
|
||
|
|
t.Error("failing limb changed the lunar eclipse result")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPartialBoundaryLimbCorrection(t *testing.T) {
|
||
|
|
const seed = 2460409.25 // 2024-04-08
|
||
|
|
circularOptions := SolarEclipsePartialFootprintOptions{}
|
||
|
|
circular := solarEclipsePartialFootprints(seed, SolarEclipseModelIAUSingleK, circularOptions, nil)
|
||
|
|
if len(circular.PartialBandContours) == 0 || len(circular.RiseSetCurves) == 0 {
|
||
|
|
t.Fatal("circular partial boundary missing")
|
||
|
|
}
|
||
|
|
samePoints := func(a, b []SolarEclipsePathPoint, toleranceKM float64) float64 {
|
||
|
|
worst := 0.0
|
||
|
|
for index := range a {
|
||
|
|
km := math.Hypot(signedAngleDifference(a[index].Longitude, b[index].Longitude)*111.19*math.Cos(a[index].Latitude*rad),
|
||
|
|
(a[index].Latitude-b[index].Latitude)*111.19)
|
||
|
|
worst = math.Max(worst, km)
|
||
|
|
}
|
||
|
|
return worst
|
||
|
|
}
|
||
|
|
compare := func(name string, limb LunarLimb, wantApplied bool, maxShiftKM float64) {
|
||
|
|
options := circularOptions
|
||
|
|
options.LunarLimb = limb
|
||
|
|
limbed := solarEclipsePartialFootprints(seed, SolarEclipseModelIAUSingleK, options, nil)
|
||
|
|
if limbed.PartialBoundaryLimbApplied != wantApplied {
|
||
|
|
t.Errorf("%s: PartialBoundaryLimbApplied = %v, want %v", name, limbed.PartialBoundaryLimbApplied, wantApplied)
|
||
|
|
}
|
||
|
|
if limbed.RiseSetTopologyDegraded {
|
||
|
|
t.Errorf("%s: rise/set topology degraded", name)
|
||
|
|
}
|
||
|
|
if len(limbed.PartialBandContours) != len(circular.PartialBandContours) || len(limbed.RiseSetCurves) != len(circular.RiseSetCurves) {
|
||
|
|
t.Fatalf("%s: boundary structure changed", name)
|
||
|
|
}
|
||
|
|
worst := 0.0
|
||
|
|
for index := range circular.PartialBandContours {
|
||
|
|
worst = math.Max(worst, samePoints(circular.PartialBandContours[index], limbed.PartialBandContours[index], 0))
|
||
|
|
}
|
||
|
|
for index, curve := range circular.RiseSetCurves {
|
||
|
|
for segment := range curve.Segments {
|
||
|
|
shift := samePoints(curve.Segments[segment], limbed.RiseSetCurves[index].Segments[segment], 0)
|
||
|
|
if curve.Phase == RiseSetPhaseGreatest && shift != 0 {
|
||
|
|
t.Errorf("%s: greatest rise/set curve moved by %.3f km", name, shift)
|
||
|
|
}
|
||
|
|
worst = math.Max(worst, shift)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if worst > maxShiftKM {
|
||
|
|
t.Errorf("%s: boundary moved by %.3f km, want <= %.3f km", name, worst, maxShiftKM)
|
||
|
|
}
|
||
|
|
if wantApplied && worst < 0.5 {
|
||
|
|
t.Errorf("%s: boundary moved only %.3f km", name, worst)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
compare("failing", failingLunarLimb{}, false, 0)
|
||
|
|
compare("constant", &recordingLunarLimb{
|
||
|
|
radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale,
|
||
|
|
}, false, 0.05)
|
||
|
|
compare("profile", sawtoothLunarLimb{}, true, limbLimitMaxShiftKM)
|
||
|
|
}
|
||
|
|
|
||
|
|
// sawtoothLunarLimb 以位置角 10° 为周期在参考半径上叠加 ±3 km 起伏,模拟月缘山谷。
|
||
|
|
type sawtoothLunarLimb struct{}
|
||
|
|
|
||
|
|
func (sawtoothLunarLimb) LimbRadiusKM(_, _, angle float64) (float64, bool) {
|
||
|
|
phase := math.Mod(angle, 10) / 10
|
||
|
|
return geodata.EarthEquatorialRadiusKM*solarEclipseIAUSingleRadiusK*localSolarMoonRadiusScale + 6*math.Abs(phase-0.5) - 1.5, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGlobalSolarEclipseLimbContactMatchesTime 检查月缘修正后的偏食始末接触点与修正时刻同源:
|
||
|
|
// 接触点必须落在该时刻重解的半影外切点上,而不是沿地球边缘扫描网格上的样点。
|
||
|
|
func TestGlobalSolarEclipseLimbContactMatchesTime(t *testing.T) {
|
||
|
|
for _, seed := range []float64{2460409.25, 2460232.25, 2461275.25} {
|
||
|
|
options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: limb.Default()}
|
||
|
|
result := SolarEclipseWithOptionsWithInstance(seed, options, nil)
|
||
|
|
if !result.LunarLimbApplied {
|
||
|
|
t.Fatalf("seed %.2f: the default limb profile changed nothing", seed)
|
||
|
|
}
|
||
|
|
solver := newSolarEclipseSolverWithOptions(CalcMoonSHByJDE(seed, 0), options, nil)
|
||
|
|
for _, pair := range []struct {
|
||
|
|
name string
|
||
|
|
jde float64
|
||
|
|
contact SolarEclipseLimbContact
|
||
|
|
}{
|
||
|
|
{"P1", result.PartialBeginOnEarth, result.PartialBeginContact},
|
||
|
|
{"P4", result.PartialEndOnEarth, result.PartialEndContact},
|
||
|
|
} {
|
||
|
|
if !pair.contact.Defined {
|
||
|
|
t.Errorf("seed %.2f %s: the corrected contact location is missing", seed, pair.name)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
point, ok := solver.shadowContactPointAt(pair.jde, solarEclipsePenumbralShadow, false)
|
||
|
|
if !ok {
|
||
|
|
t.Errorf("seed %.2f %s: no penumbral tangent point at the corrected time", seed, pair.name)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
longitude := signedAngleDifference(pair.contact.Longitude, point.Longitude) * math.Cos(point.Latitude*rad)
|
||
|
|
if d := math.Hypot(longitude, pair.contact.Latitude-point.Latitude) * geodata.EarthKMPerDegree; d > 0.05 {
|
||
|
|
t.Errorf("seed %.2f %s: contact location is %.3f km from the tangent point at its own time", seed, pair.name, d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLimbFlagsIgnoreNoiseWithConstantLimb 检查常数月缘等价于圆月面时,各层月缘标志都不因离散残差误报。
|
||
|
|
func TestLimbFlagsIgnoreNoiseWithConstantLimb(t *testing.T) {
|
||
|
|
iau := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseIAUSingleRadiusK * localSolarMoonRadiusScale}
|
||
|
|
umbral := &recordingLunarLimb{radiusKM: geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale}
|
||
|
|
for _, seed := range []float64{2460409.25, 2460232.25, 2461275.25} {
|
||
|
|
global := SolarEclipseWithOptionsWithInstance(seed, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK, LunarLimb: iau}, nil)
|
||
|
|
if global.LunarLimbApplied || global.PartialBeginContact.Defined || global.PartialEndContact.Defined {
|
||
|
|
t.Errorf("seed %.2f: constant limb reported a global correction", seed)
|
||
|
|
}
|
||
|
|
central := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: umbral}, nil)
|
||
|
|
if central.CentralLimitsLimbApplied {
|
||
|
|
t.Errorf("seed %.2f: constant limb reported moved central limits", seed)
|
||
|
|
}
|
||
|
|
local := LocalSolarEclipseWithOptionsWithInstance(seed, -96.8, 32.8, 0, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK, LunarLimb: iau}, nil)
|
||
|
|
if local.LunarLimbApplied {
|
||
|
|
t.Errorf("seed %.2f: constant limb reported a local correction", seed)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
real := SolarEclipseCentralPathNASABulletinSplitKWithInstance(2460409.25, SolarEclipsePathOptions{LunarLimb: limb.Default()}, nil)
|
||
|
|
if !real.CentralLimitsLimbApplied {
|
||
|
|
t.Error("default limb profile must move the central limits")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLocalSolarEclipseLimbFlagCoversMagnitude 检查只改变食分、遮蔽率而不改变接触时刻时站心标志仍为真。
|
||
|
|
func TestLocalSolarEclipseLimbFlagCoversMagnitude(t *testing.T) {
|
||
|
|
circular := LocalSolarEclipseResult{Type: SolarEclipsePartial, PartialStart: 1, PartialEnd: 2, Magnitude: 0.5, Obscuration: 0.4}
|
||
|
|
limbed := circular
|
||
|
|
if localSolarEclipseLimbChanged(limbed, circular) {
|
||
|
|
t.Fatal("identical results reported a change")
|
||
|
|
}
|
||
|
|
limbed.Magnitude += 1e-12
|
||
|
|
if localSolarEclipseLimbChanged(limbed, circular) {
|
||
|
|
t.Error("floating-point noise in magnitude reported a change")
|
||
|
|
}
|
||
|
|
limbed.Magnitude = circular.Magnitude + 1e-6
|
||
|
|
if !localSolarEclipseLimbChanged(limbed, circular) {
|
||
|
|
t.Error("a visible magnitude change was not reported")
|
||
|
|
}
|
||
|
|
limbed = circular
|
||
|
|
limbed.Obscuration += 1e-6
|
||
|
|
if !localSolarEclipseLimbChanged(limbed, circular) {
|
||
|
|
t.Error("a visible obscuration change was not reported")
|
||
|
|
}
|
||
|
|
limbed = circular
|
||
|
|
limbed.Type = SolarEclipseAnnular
|
||
|
|
if !localSolarEclipseLimbChanged(limbed, circular) {
|
||
|
|
t.Error("a type change was not reported")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// steppedLunarLimb 报告指定网格步长的常半径剖面。
|
||
|
|
type steppedLunarLimb struct{ step float64 }
|
||
|
|
|
||
|
|
func (limb steppedLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) {
|
||
|
|
return 1737.4, true
|
||
|
|
}
|
||
|
|
func (limb steppedLunarLimb) RadiusRangeKM() (float64, float64) { return 1737.4, 1737.4 }
|
||
|
|
func (limb steppedLunarLimb) AngleStepDeg() float64 { return limb.step }
|
||
|
|
|
||
|
|
func TestLunarLimbFrameRejectsTinyStep(t *testing.T) {
|
||
|
|
_, moon := solarEclipseSunMoonEquatorial(2460409.25)
|
||
|
|
moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2])
|
||
|
|
for _, c := range []struct{ step, want float64 }{
|
||
|
|
{1e-300, lunarLimbDefaultStepDeg}, {1e-4, lunarLimbDefaultStepDeg}, {0.001, 0.001}, {0.5, 0.5}, {2, lunarLimbDefaultStepDeg},
|
||
|
|
} {
|
||
|
|
frame := newLunarLimbFrame(steppedLunarLimb{c.step}, 2460409.25, moonXYZ)
|
||
|
|
if !frame.valid || frame.stepDeg != c.want {
|
||
|
|
t.Errorf("step %g: got %g valid=%v, want %g", c.step, frame.stepDeg, frame.valid, c.want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|