feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user