feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFindStarOccultationPathsHR4799(t *testing.T) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
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,
|
||||
}
|
||||
paths, err := FindStarOccultationPaths(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||
star, OccultationPathOptions{
|
||||
Step: 5 * time.Minute,
|
||||
TargetSpacingKM: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultationPaths() error = %v", err)
|
||||
}
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
|
||||
}
|
||||
path := paths[0]
|
||||
t.Logf("start=%s %.6f %.6f greatest=%s %.6f %.6f width=%.3f end=%s %.6f %.6f center=%d north=%d south=%d",
|
||||
path.Start.Time, path.Start.Longitude, path.Start.Latitude,
|
||||
path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude, path.Greatest.WidthKM,
|
||||
path.End.Time, path.End.Longitude, path.End.Latitude,
|
||||
len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
|
||||
if !path.Start.Time.Before(path.Greatest.Time) || !path.Greatest.Time.Before(path.End.Time) {
|
||||
t.Fatalf("path times are not ordered: start=%v greatest=%v end=%v", path.Start.Time, path.Greatest.Time, path.End.Time)
|
||||
}
|
||||
if !path.Complete {
|
||||
t.Fatal("global path must be marked complete")
|
||||
}
|
||||
if math.Abs(path.Start.Time.Sub(time.Date(2025, 6, 5, 17, 45, 26, 200000000, location)).Seconds()) > 5 {
|
||||
t.Fatalf("path start is too far from reference: got %v", path.Start.Time)
|
||||
}
|
||||
if math.Abs(path.End.Time.Sub(time.Date(2025, 6, 5, 22, 18, 53, 100000000, location)).Seconds()) > 5 {
|
||||
t.Fatalf("path end is too far from reference: got %v", path.End.Time)
|
||||
}
|
||||
if math.Abs(path.Start.Longitude-63.97388) > 0.5 || math.Abs(path.Start.Latitude-39.11772) > 0.5 {
|
||||
t.Fatalf("path start point is too far from reference: got %.6f %.6f", path.Start.Longitude, path.Start.Latitude)
|
||||
}
|
||||
if math.Abs(path.End.Longitude-172.33859) > 0.5 || math.Abs(path.End.Latitude-(-16.67159)) > 0.5 {
|
||||
t.Fatalf("path end point is too far from reference: got %.6f %.6f", path.End.Longitude, path.End.Latitude)
|
||||
}
|
||||
if math.Abs(path.Greatest.Longitude-121.55381) > 0.1 || math.Abs(path.Greatest.Latitude-6.79657) > 0.1 {
|
||||
t.Fatalf("greatest point is too far from reference: got %.6f %.6f", path.Greatest.Longitude, path.Greatest.Latitude)
|
||||
}
|
||||
if len(path.CenterLine) == 0 || len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0 {
|
||||
t.Fatalf("path limits missing: center=%d north=%d south=%d", len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
|
||||
}
|
||||
if math.Abs(path.Greatest.Time.Sub(time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)).Seconds()) > 5 {
|
||||
t.Fatalf("greatest time is too far from reference: got %v", path.Greatest.Time)
|
||||
}
|
||||
if math.Abs(path.Greatest.WidthKM-3571.9) > 20 {
|
||||
t.Fatalf("unexpected occultation width: got %.3f km", path.Greatest.WidthKM)
|
||||
}
|
||||
if len(path.NorthernLimit) != len(path.SouthernLimit) {
|
||||
t.Fatalf("limit counts differ: north=%d south=%d", len(path.NorthernLimit), len(path.SouthernLimit))
|
||||
}
|
||||
last := len(path.NorthernLimit) - 1
|
||||
if last < 1 || !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
|
||||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
|
||||
t.Fatalf("limits do not cover the global start/end")
|
||||
}
|
||||
best, err := FindBestStarOccultations(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||
star, OccultationSearchOptions{MaxEvents: 1},
|
||||
)
|
||||
if err != nil || len(best) != 1 {
|
||||
t.Fatalf("FindBestStarOccultations() = %d events, %v; want one event", len(best), err)
|
||||
}
|
||||
if math.Abs(best[0].Greatest.Sub(path.Greatest.Time).Seconds()) > 0.1 ||
|
||||
math.Abs(best[0].Observer.Longitude-path.Greatest.Longitude) > 1e-6 ||
|
||||
math.Abs(best[0].Observer.Latitude-path.Greatest.Latitude) > 1e-6 {
|
||||
t.Fatalf("best observer does not match global greatest: best=%v %+v path=%v %.9f %.9f",
|
||||
best[0].Greatest, best[0].Observer, path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindStarOccultationPathsHR4799LimitsRemainContinuous(t *testing.T) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
paths, err := FindStarOccultationPaths(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||
hr4799CoordinateForTest(),
|
||||
OccultationPathOptions{Step: 30 * time.Second},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultationPaths() error = %v", err)
|
||||
}
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
|
||||
}
|
||||
|
||||
for _, limit := range []struct {
|
||||
name string
|
||||
points []OccultationPathPoint
|
||||
}{
|
||||
{name: "northern", points: paths[0].NorthernLimit},
|
||||
{name: "southern", points: paths[0].SouthernLimit},
|
||||
} {
|
||||
for i := 1; i < len(limit.points); i++ {
|
||||
distance := occultationPathPointDistanceKM(limit.points[i-1], limit.points[i])
|
||||
if distance > 750 {
|
||||
t.Fatalf("%s limit jumps %.1f km between %v and %v", limit.name, distance,
|
||||
limit.points[i-1].Time, limit.points[i].Time)
|
||||
}
|
||||
}
|
||||
for i := 1; i+1 < len(limit.points); i++ {
|
||||
detour := occultationPathPointDistanceKM(limit.points[i-1], limit.points[i]) +
|
||||
occultationPathPointDistanceKM(limit.points[i], limit.points[i+1]) -
|
||||
occultationPathPointDistanceKM(limit.points[i-1], limit.points[i+1])
|
||||
if detour > 100 {
|
||||
t.Fatalf("%s limit has a %.1f km three-point detour at %v", limit.name, detour, limit.points[i].Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func occultationPathPointDistanceKM(a, b OccultationPathPoint) float64 {
|
||||
lat1 := a.Latitude * math.Pi / 180
|
||||
lat2 := b.Latitude * math.Pi / 180
|
||||
dLat := lat2 - lat1
|
||||
dLon := (b.Longitude - a.Longitude) * math.Pi / 180
|
||||
dLon = math.Atan2(math.Sin(dLon), math.Cos(dLon))
|
||||
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, h)))
|
||||
}
|
||||
|
||||
func TestFindStarOccultationPathsReturnsFullPathOutsideQueryWindow(t *testing.T) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
greatest := time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)
|
||||
start := greatest.Add(-time.Minute)
|
||||
end := greatest.Add(time.Minute)
|
||||
paths, err := FindStarOccultationPaths(start, end, hr4799CoordinateForTest(), OccultationPathOptions{Step: 5 * time.Minute})
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultationPaths() error = %v", err)
|
||||
}
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
|
||||
}
|
||||
path := paths[0]
|
||||
if !path.Complete || !path.Start.Time.Before(start) || !path.End.Time.After(end) {
|
||||
t.Fatalf("path was clipped to query window: complete=%v start=%v window=%v..%v end=%v", path.Complete, path.Start.Time, start, end, path.End.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalStarOccultationQueriesSelectByGeometricGreatest(t *testing.T) {
|
||||
star := hr4799CoordinateForTest()
|
||||
dayStart := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindStarOccultationPaths(
|
||||
dayStart, dayStart.Add(24*time.Hour), star,
|
||||
OccultationPathOptions{Step: 5 * time.Minute},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("full-day FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
|
||||
start := paths[0].Greatest.Time.Add(-500 * time.Microsecond)
|
||||
end := start.Add(time.Millisecond)
|
||||
narrowPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{Step: 5 * time.Minute})
|
||||
if err != nil || len(narrowPaths) != 1 {
|
||||
t.Fatalf("narrow FindStarOccultationPaths() paths=%d err=%v, want one", len(narrowPaths), err)
|
||||
}
|
||||
if !occultationTimeWithinNumericalWindowForTest(narrowPaths[0].Greatest.Time, start, end) {
|
||||
t.Fatalf("path greatest %v is outside query window %v..%v", narrowPaths[0].Greatest.Time, start, end)
|
||||
}
|
||||
|
||||
best, err := FindBestStarOccultations(start, end, star, OccultationSearchOptions{})
|
||||
if err != nil || len(best) != 1 {
|
||||
t.Fatalf("narrow FindBestStarOccultations() events=%d err=%v, want one", len(best), err)
|
||||
}
|
||||
if !occultationTimeWithinNumericalWindowForTest(best[0].Greatest, start, end) {
|
||||
t.Fatalf("best greatest %v is outside query window %v..%v", best[0].Greatest, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
func occultationTimeWithinNumericalWindowForTest(value, start, end time.Time) bool {
|
||||
const tolerance = 10 * time.Millisecond
|
||||
return !value.Before(start.Add(-tolerance)) && !value.After(end.Add(tolerance))
|
||||
}
|
||||
|
||||
func TestOccultationPathOptionsValidate(t *testing.T) {
|
||||
if err := (OccultationPathOptions{Step: -time.Second}).Validate(); err == nil {
|
||||
t.Fatal("negative path step should be rejected")
|
||||
}
|
||||
if err := (OccultationPathOptions{Step: 500 * time.Millisecond}).Validate(); err == nil {
|
||||
t.Fatal("subsecond positive path step should be rejected")
|
||||
}
|
||||
if err := (OccultationPathOptions{Step: time.Second}).Validate(); err != nil {
|
||||
t.Fatalf("one-second path step rejected: %v", err)
|
||||
}
|
||||
if err := (OccultationPathOptions{TargetSpacingKM: math.NaN()}).Validate(); err == nil {
|
||||
t.Fatal("non-finite target spacing should be rejected")
|
||||
}
|
||||
if err := (OccultationPathOptions{TargetSpacingKM: 0.5}).Validate(); err == nil {
|
||||
t.Fatal("sub-kilometer target spacing should be rejected")
|
||||
}
|
||||
if err := (OccultationPathOptions{TargetSpacingKM: 1}).Validate(); err != nil {
|
||||
t.Fatalf("one-kilometer target spacing rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFindStarOccultationPathTargetSpacing(b *testing.B) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
star := hr4799CoordinateForTest()
|
||||
for _, spacing := range []float64{1, 10, 200} {
|
||||
b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
paths, err := FindStarOccultationPaths(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||
star, OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: spacing},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
b.Fatalf("FindStarOccultationPaths() paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user