feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
@@ -0,0 +1,127 @@
package geojson_test
import (
"math"
"testing"
"time"
"b612.me/astro/geojson"
"b612.me/astro/moon"
)
// 导出属性必须同时给出 WidthKM(width_km)与限线间距(greatest_limit_separation_km),且两者口径不同。
func occultationLimitSeparationJSONValue(t *testing.T, feature decodedFeature, key string) float64 {
t.Helper()
raw, ok := feature.Properties[key]
if !ok {
t.Fatalf("feature role=%v has no %s property", feature.Properties["role"], key)
}
value, ok := raw.(float64)
if !ok || math.IsNaN(value) || math.IsInf(value, 0) {
t.Fatalf("feature role=%v %s=%v, want a finite number", feature.Properties["role"], key, raw)
}
return value
}
func TestMarshalPlanetOccultationCarriesGreatestLimitSeparation(t *testing.T) {
start := time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationJupiter,
moon.OccultationPathOptions{
Step: 10 * 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)
}
data, err := geojson.MarshalPlanetOccultation(paths[0])
if err != nil {
t.Fatalf("MarshalPlanetOccultation: %v", err)
}
collection := decodeCollection(t, data)
var greatestWidth, limitSeparation float64
foundSeparation, foundGreatest := false, false
for _, feature := range collection.Features {
if value := occultationLimitSeparationJSONValue(t, feature, "greatest_limit_separation_km"); !foundSeparation {
limitSeparation = value
foundSeparation = true
}
if feature.Properties["role"] == "greatest" {
greatestWidth = occultationLimitSeparationJSONValue(t, feature, "width_km")
foundGreatest = true
}
}
if !foundSeparation || !foundGreatest {
t.Fatalf("limit separation=%v greatest width=%v, want both properties present", foundSeparation, foundGreatest)
}
if math.Abs(limitSeparation-3593.197135) > 0.5 {
t.Fatalf("greatest_limit_separation_km=%.6f, want 3593.197135 within 0.5 km", limitSeparation)
}
if math.Abs(greatestWidth-5319.263900) > 0.5 {
t.Fatalf("greatest width_km=%.6f, want 5319.263900 within 0.5 km", greatestWidth)
}
if math.Abs(greatestWidth-limitSeparation) < 1000 {
t.Fatalf("width_km=%.3f and greatest_limit_separation_km=%.3f are too close, want the two distinct constructions",
greatestWidth, limitSeparation)
}
}
func TestMarshalPlanetOccultationRejectsNonFiniteLimitSeparation(t *testing.T) {
start := time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationJupiter,
moon.OccultationPathOptions{
Step: 10 * 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)
}
broken := paths[0]
broken.NorthernLimit = append([]moon.OccultationPathPoint(nil), paths[0].NorthernLimit...)
broken.NorthernLimit[1].LimitSeparationKM = math.NaN()
if _, err := geojson.MarshalPlanetOccultation(broken); err == nil {
t.Fatal("MarshalPlanetOccultation accepted a NaN limit separation on a limit point")
}
broken = paths[0]
broken.GreatestLimitSeparationKM = math.Inf(1)
if _, err := geojson.MarshalPlanetOccultation(broken); err == nil {
t.Fatal("MarshalPlanetOccultation accepted a non-finite greatest limit separation")
}
}
func TestMarshalStarOccultationCarriesGreatestLimitSeparation(t *testing.T) {
start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindStarOccultationPaths(
start, start.Add(24*time.Hour), hr4799OccultationCoordinateForGeoJSONLimitTest(),
moon.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)
}
data, err := geojson.MarshalStarOccultation(paths[0])
if err != nil {
t.Fatalf("MarshalStarOccultation: %v", err)
}
collection := decodeCollection(t, data)
value := occultationLimitSeparationJSONValue(t, collection.Features[0], "greatest_limit_separation_km")
if math.Abs(value-3666.576690) > 0.5 {
t.Fatalf("greatest_limit_separation_km=%.6f, want 3666.576690 within 0.5 km", value)
}
}
func hr4799OccultationCoordinateForGeoJSONLimitTest() moon.StarCoordinate {
return moon.StarCoordinate{
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28,
ProperMotionDecMasPerYear: -18,
}
}