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,257 @@
package geojson_test
import (
"encoding/json"
"testing"
"time"
"b612.me/astro/geojson"
"b612.me/astro/internal/geodata"
"b612.me/astro/moon"
)
// TestMarshalPlanetOccultationNonCentralPartialBandContainsTotalBand 是非中心月掩偏掩带
// 截断的回归:默认(密集瞬时足迹)模式下偏掩带曾只由纯足迹扫掠构造,极向部分被截断,
// 使月升可见性边界落在掩带之外,全掩带反而越出偏掩带。两场事件都是月影轴不与地球椭球
// 相交、且相位曲线在极区折点处与零残差相切的非中心事件;全掩带必须完全落在偏掩带内。
// TestMarshalPlanetOccultationNonCentralPartialBandContainsTotalBand covers the truncated
// partial band of non-central occultations: in the default dense-footprint mode the band
// used to be built by a pure footprint sweep, which cut off its poleward part, left the
// moonrise visibility boundary outside the band and let the total band escape it. Both
// events have a shadow axis that misses the ellipsoid and a phase curve that tangents the
// zero residual at a polar fold. The total band must stay inside the partial band.
func TestMarshalPlanetOccultationNonCentralPartialBandContainsTotalBand(t *testing.T) {
for _, start := range []time.Time{
time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC),
time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC),
} {
t.Run(start.Format("2006-01-02"), func(t *testing.T) {
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationNeptune, moon.OccultationPathOptions{},
)
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)
partialBand := featureWithRole(t, collection, "partial-band")
if authoritative, ok := partialBand.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
t.Fatalf("partial-band source=%v authoritative=%v, want the analytic authoritative band",
partialBand.Properties["source"], partialBand.Properties["static_band_authoritative"])
}
partialRings := geoJSONMultiPolygonOuterRings(t, partialBand)
totalRings := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band"))
if len(totalRings) == 0 {
t.Fatal("total-band geometry is missing")
}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, totalRings, true); miss > 1 {
t.Fatalf("total-band escapes partial-band by %.1f km", miss)
}
})
}
}
// TestMarshalPlanetOccultationDenseModeBandKeepsCompactBandFlagFalse 固定默认(密集瞬时足迹)
// 模式的掩带来源契约:几何由解析接触/相位网络给出(static_band_authoritative),而
// compact_band 只表达调用方是否请求了紧凑掩带模式,因此默认模式下必须仍为 false。
// TestMarshalPlanetOccultationDenseModeBandKeepsCompactBandFlagFalse pins the dense-footprint
// default: the geometry comes from the analytic contact/phase network, while compact_band only
// reports whether the caller requested compact-band mode and therefore stays false.
func TestMarshalPlanetOccultationDenseModeBandKeepsCompactBandFlagFalse(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationNeptune, moon.OccultationPathOptions{},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
if len(paths[0].PartialFootprints) == 0 || len(paths[0].PartialBandFootprints) != 0 {
t.Fatalf("dense mode footprints=%d bandFootprints=%d, want the dense domain only",
len(paths[0].PartialFootprints), len(paths[0].PartialBandFootprints))
}
data, err := geojson.MarshalPlanetOccultation(paths[0])
if err != nil {
t.Fatalf("MarshalPlanetOccultation: %v", err)
}
collection := decodeCollection(t, data)
partialBand := featureWithRole(t, collection, "partial-band")
if compact, ok := partialBand.Properties["compact_band"].(bool); !ok || compact {
t.Fatalf("partial-band compact_band=%v, want false because compact mode was not requested",
partialBand.Properties["compact_band"])
}
if authoritative, ok := partialBand.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
t.Fatalf("partial-band source=%v authoritative=%v, want the analytic authoritative band",
partialBand.Properties["source"], partialBand.Properties["static_band_authoritative"])
}
}
// TestMarshalPlanetOccultationCentralEventIsStable ensures that the analytic
// fallback selection does not introduce nondeterministic output for an ordinary
// central event. Repeated marshaling of the same computed path must be byte stable.
func TestMarshalPlanetOccultationCentralEventIsStable(t *testing.T) {
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
first, err := geojson.MarshalPlanetOccultation(paths[0])
if err != nil {
t.Fatalf("first MarshalPlanetOccultation: %v", err)
}
second, err := geojson.MarshalPlanetOccultation(paths[0])
if err != nil {
t.Fatalf("second MarshalPlanetOccultation: %v", err)
}
if string(first) != string(second) {
t.Fatal("repeated central-event GeoJSON marshaling is not byte stable")
}
}
// TestMarshalPlanetOccultationVisibilityBoundaryStaysInsidePartialBand 是可见性边界必须落在掩带
// 内的回归:密集模式下偏掩带曾被纯足迹扫掠截断,月升可见性边界因此越出掩带。两种采样步长都要
// 满足该不变式,否则紫色相位曲线会画在掩带之外。
// TestMarshalPlanetOccultationVisibilityBoundaryStaysInsidePartialBand is the regression that
// keeps the visibility boundary inside the band: in dense mode the partial band used to be
// truncated by a pure footprint sweep and the moonrise boundary escaped it. Both sampling steps
// must satisfy the invariant, otherwise the phase curves are drawn outside the band.
func TestMarshalPlanetOccultationVisibilityBoundaryStaysInsidePartialBand(t *testing.T) {
const maximumMissKM = 2.0
for _, test := range []struct {
name string
start time.Time
opts moon.OccultationPathOptions
}{
{
name: "2025-01-05-dense",
start: time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC),
},
{
name: "2025-02-01-dense",
start: time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "2025-02-01-compact",
start: time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC),
opts: moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableFootprints: true, RiseSetStep: time.Minute,
},
},
} {
t.Run(test.name, func(t *testing.T) {
paths, err := moon.FindPlanetOccultationPaths(
test.start, test.start.Add(24*time.Hour), moon.OccultationNeptune, test.opts,
)
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)
partialRings := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "partial-band"))
boundaryPaths := make([][]geodata.GeoPoint, 0)
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
boundaryPaths = append(boundaryPaths, geoJSONLineStringPaths(t, feature)...)
}
if len(boundaryPaths) == 0 {
t.Fatal("GeoJSON is missing the visibility-boundary phase curves")
}
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, boundaryPaths, false); miss > maximumMissKM {
t.Fatalf("visibility-boundary escapes partial-band by %.3f km, want <= %.1f km", miss, maximumMissKM)
}
})
}
}
// TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource 固定连接线与掩带同源的
// 契约:紧凑模式下两者都由紧凑足迹给出,连接线存在;默认(密集瞬时足迹)模式下瞬时足迹没有
// 可与相位端点配对的开放地平边界,因此不产生连接线。此前掩带会走解析回退、连接线却按空的
// 原始紧凑足迹生成,两种模式的连接线来源不一致。
// TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource pins the contract that
// connectors and band share one footprint source: in compact mode both come from the compact
// footprints and connectors exist, while dense instantaneous footprints carry no open horizon
// boundary to pair with phase endpoints and therefore yield none. Previously a band built from
// the analytic fallback still asked for connectors from the empty compact footprints, so the two
// modes disagreed about the connector source.
func TestMarshalPlanetOccultationHorizonConnectorsFollowBandFootprintSource(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
for _, test := range []struct {
name string
opts moon.OccultationPathOptions
wantConnect int
}{
{
name: "compact",
opts: moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableFootprints: true, RiseSetStep: time.Minute,
},
wantConnect: 1,
},
{name: "dense", opts: moon.OccultationPathOptions{}, wantConnect: 0},
} {
t.Run(test.name, func(t *testing.T) {
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationNeptune, test.opts,
)
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)
connectors := featuresWithRole(collection, "horizon-connector")
if test.wantConnect == 0 && len(connectors) != 0 {
t.Fatalf("horizon-connector count=%d, want none in dense mode", len(connectors))
}
if test.wantConnect > 0 && len(connectors) == 0 {
t.Fatal("horizon-connector count=0, want the compact mode closures")
}
})
}
}
func geoJSONLineStringPaths(t *testing.T, feature decodedFeature) [][]geodata.GeoPoint {
t.Helper()
role := feature.Properties["role"]
var lines [][][]float64
switch feature.Geometry.Type {
case "LineString":
var line [][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &line); err != nil {
t.Fatalf("decode %s coordinates: %v", role, err)
}
lines = [][][]float64{line}
case "MultiLineString":
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode %s coordinates: %v", role, err)
}
default:
t.Fatalf("%s geometry=%q, want LineString or MultiLineString", role, feature.Geometry.Type)
}
paths := make([][]geodata.GeoPoint, 0, len(lines))
for lineIndex, line := range lines {
path := make([]geodata.GeoPoint, len(line))
for pointIndex, point := range line {
if len(point) < 2 {
t.Fatalf("%s line %d point %d is malformed", role, lineIndex, pointIndex)
}
path[pointIndex] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
paths = append(paths, path)
}
return paths
}