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
+239
View File
@@ -0,0 +1,239 @@
package geojson_test
import (
"encoding/json"
"testing"
"time"
"b612.me/astro/geojson"
"b612.me/astro/moon"
)
func TestMarshalPlanetOccultationFootprintsUsesTimelineRoles(t *testing.T) {
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
events, err := moon.FindBestPlanetOccultations(
start, start.Add(24*time.Hour), moon.OccultationSaturn, moon.OccultationSearchOptions{MaxEvents: 1},
)
if err != nil || len(events) != 1 {
t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err)
}
at := events[0].Greatest
instant, err := moon.PlanetOccultationFootprintsAt(at, moon.OccultationSaturn)
if err != nil {
t.Fatalf("PlanetOccultationFootprintsAt: %v", err)
}
raw, err := geojson.MarshalPlanetOccultationFootprints(instant)
if err != nil {
t.Fatalf("MarshalPlanetOccultationFootprints: %v", err)
}
roles, times := instantFootprintRoles(t, raw)
if roles["partial-footprint"] != 1 || roles["total-footprint"] != 1 {
t.Fatalf("roles=%v, want one partial and one total footprint", roles)
}
for role, value := range times {
if value != at.UTC().Format(time.RFC3339Nano) {
t.Fatalf("%s time=%q, want exact requested instant", role, value)
}
}
}
func TestMarshalStarOccultationFootprintUsesTimelineRole(t *testing.T) {
star := moon.StarCoordinate{
ID: "Antares", RA: 247.35166667, Dec: -26.43194444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: moon.CoordinateFrameJ2000,
}
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC)
events, err := moon.FindBestStarOccultations(
start, start.Add(24*time.Hour), star, moon.OccultationSearchOptions{MaxEvents: 1},
)
if err != nil || len(events) != 1 {
t.Fatalf("FindBestStarOccultations events=%d err=%v, want one", len(events), err)
}
instant, err := moon.StarOccultationFootprintAt(events[0].Greatest, star)
if err != nil {
t.Fatalf("StarOccultationFootprintAt: %v", err)
}
if instant.Footprint == nil {
t.Fatal("stellar footprint is nil at the event instant")
}
raw, err := geojson.MarshalStarOccultationFootprint(instant)
if err != nil {
t.Fatalf("MarshalStarOccultationFootprint: %v", err)
}
roles, _ := instantFootprintRoles(t, raw)
if roles["occultation-footprint"] != 1 {
t.Fatalf("roles=%v, want one stellar footprint", roles)
}
}
func TestMarshalOccultationInstantOutsideEventReturnsEmptyCollection(t *testing.T) {
at := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
marshal func() ([]byte, error)
}{
{
name: "star",
marshal: func() ([]byte, error) {
return geojson.MarshalStarOccultationFootprint(moon.StarOccultationInstant{
Time: at, TargetID: "outside-event",
})
},
},
{
name: "planet",
marshal: func() ([]byte, error) {
return geojson.MarshalPlanetOccultationFootprints(moon.PlanetOccultationInstant{
Time: at, Planet: moon.OccultationSaturn, TargetID: "Saturn",
})
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
raw, err := test.marshal()
if err != nil {
t.Fatalf("marshal outside-event footprint: %v", err)
}
roles, _ := instantFootprintRoles(t, raw)
if len(roles) != 0 {
t.Fatalf("roles=%v, want empty FeatureCollection", roles)
}
var collection struct {
Features []json.RawMessage `json:"features"`
}
if err := json.Unmarshal(raw, &collection); err != nil {
t.Fatalf("decode empty FeatureCollection: %v", err)
}
if collection.Features == nil {
t.Fatal("features is null, want an empty JSON array")
}
})
}
}
func instantFootprintRoles(t *testing.T, raw []byte) (map[string]int, map[string]string) {
t.Helper()
var collection struct {
Features []struct {
Properties map[string]interface{} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(raw, &collection); err != nil {
t.Fatalf("decode GeoJSON: %v", err)
}
roles := make(map[string]int)
times := make(map[string]string)
for _, feature := range collection.Features {
role, _ := feature.Properties["role"].(string)
roles[role]++
times[role], _ = feature.Properties["time"].(string)
}
return roles, times
}
func TestMarshalPlanetOccultationFootprintsCarriesInstantMetadata(t *testing.T) {
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
events, err := moon.FindBestPlanetOccultations(
start, start.Add(24*time.Hour), moon.OccultationSaturn, moon.OccultationSearchOptions{MaxEvents: 1},
)
if err != nil || len(events) != 1 {
t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err)
}
instant, err := moon.PlanetOccultationFootprintsAt(events[0].Greatest, moon.OccultationSaturn)
if err != nil {
t.Fatalf("PlanetOccultationFootprintsAt: %v", err)
}
raw, err := geojson.MarshalPlanetOccultationFootprints(instant)
if err != nil {
t.Fatalf("MarshalPlanetOccultationFootprints: %v", err)
}
var collection struct {
Features []struct {
Properties map[string]interface{} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(raw, &collection); err != nil {
t.Fatalf("decode: %v", err)
}
if len(collection.Features) == 0 {
t.Fatal("no instant features")
}
if instant.DeltaTSeconds <= 0 || instant.SublunarLongitude == 0 && instant.SublunarLatitude == 0 {
t.Fatalf("instant metadata missing: ΔT=%.3f sublunar=(%.3f,%.3f)",
instant.DeltaTSeconds, instant.SublunarLongitude, instant.SublunarLatitude)
}
for _, feature := range collection.Features {
properties := feature.Properties
deltaT, ok := properties["delta_t_seconds"].(float64)
if !ok || deltaT <= 0 {
t.Fatalf("delta_t_seconds=%v, want a positive value", properties["delta_t_seconds"])
}
if signature, _ := properties["interp_signature"].(string); signature == "" || signature == "empty" {
t.Fatalf("interp_signature=%v, want a footprint signature", properties["interp_signature"])
}
closed, present := properties["source_boundary_closed"].(bool)
if !present {
t.Fatal("source_boundary_closed is missing")
}
closure, hasClosure := properties["closure"].(map[string]interface{})
if closed {
if hasClosure {
t.Fatal("self-closed occultation footprint must not carry a closure")
}
continue
}
if !hasClosure || closure["kind"] != "target-horizon" || closure["body"] != "moon" {
t.Fatalf("closure=%v, want kind=target-horizon body=moon", properties["closure"])
}
if _, present := closure["sublunar"]; !present {
t.Fatal("target-horizon closure is missing the sublunar reference")
}
if properties["geometry_role"] != "horizon-closed-region" {
t.Fatalf("geometry_role=%v, want horizon-closed-region", properties["geometry_role"])
}
}
}
func TestMarshalStarOccultationFootprintHorizonCutCarriesClosure(t *testing.T) {
star := moon.StarCoordinate{
ID: "Antares", RA: 247.35166667, Dec: -26.43194444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: moon.CoordinateFrameJ2000,
}
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC)
events, err := moon.FindBestStarOccultations(start, start.Add(24*time.Hour), star, moon.OccultationSearchOptions{MaxEvents: 1})
if err != nil || len(events) != 1 {
t.Fatalf("FindBestStarOccultations events=%d err=%v, want one", len(events), err)
}
instant, err := moon.StarOccultationFootprintAt(events[0].Greatest.Add(-120*time.Minute), star)
if err != nil {
t.Fatalf("StarOccultationFootprintAt: %v", err)
}
if instant.Footprint == nil {
t.Fatal("expected a footprint inside the event window")
}
if instant.Footprint.Closed {
t.Skip("this instant is self-closed; the horizon-cut branch is covered elsewhere")
}
raw, err := geojson.MarshalStarOccultationFootprint(instant)
if err != nil {
t.Fatalf("MarshalStarOccultationFootprint: %v", err)
}
var collection struct {
Features []struct {
Properties map[string]interface{} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(raw, &collection); err != nil {
t.Fatalf("decode: %v", err)
}
if len(collection.Features) != 1 {
t.Fatalf("features=%d, want one", len(collection.Features))
}
closure, ok := collection.Features[0].Properties["closure"].(map[string]interface{})
if !ok || closure["kind"] != "target-horizon" || closure["body"] != "moon" {
t.Fatalf("closure=%v, want kind=target-horizon body=moon", collection.Features[0].Properties["closure"])
}
}