feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
// 本文件钉住 §1.5 的三条契约:全掩可见性轮廓必须校验、退化影区整条缺省、
|
||||
// 地平闭合必须能区分精确擦地点闭合与采样端点近似闭合。
|
||||
|
||||
func review15CopyContours(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
|
||||
copied := make([][]moon.OccultationPathPoint, len(contours))
|
||||
for index, contour := range contours {
|
||||
copied[index] = append([]moon.OccultationPathPoint(nil), contour...)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func TestMarshalPlanetOccultationValidatesTotalVisibilityContours(t *testing.T) {
|
||||
path := mars20250729TestFixture(t, time.Minute, true).path
|
||||
if !path.HasTotalBand || len(path.TotalBandContours) == 0 || len(path.TotalBandContours[0]) < 2 {
|
||||
t.Fatal("fixture has no usable total band contour")
|
||||
}
|
||||
// 复制的样本本身合法,只有注入的可见性轮廓是待测对象。
|
||||
valid := append([]moon.OccultationPathPoint(nil),
|
||||
path.TotalBandContours[0][0], path.TotalBandContours[0][1])
|
||||
|
||||
outOfOrder := path
|
||||
outOfOrder.TotalVisibilityContours = [][]moon.OccultationPathPoint{{valid[0], valid[0]}}
|
||||
if _, err := geojson.MarshalPlanetOccultation(outOfOrder); err == nil {
|
||||
t.Fatal("out-of-order total visibility contours must be rejected")
|
||||
}
|
||||
|
||||
outside := path
|
||||
outside.TotalVisibilityContours = [][]moon.OccultationPathPoint{{
|
||||
{Time: path.TotalStart.Time.Add(-time.Minute)}, valid[1],
|
||||
}}
|
||||
if _, err := geojson.MarshalPlanetOccultation(outside); err == nil {
|
||||
t.Fatal("total visibility contours outside the total interval must be rejected")
|
||||
}
|
||||
|
||||
orphan := path
|
||||
orphan.HasTotalBand = false
|
||||
orphan.TotalComplete = false
|
||||
orphan.TotalStart = moon.OccultationPathPoint{}
|
||||
orphan.TotalEnd = moon.OccultationPathPoint{}
|
||||
orphan.NorthernTotalLimit = nil
|
||||
orphan.SouthernTotalLimit = nil
|
||||
orphan.TotalFootprints = nil
|
||||
orphan.TotalBandFootprints = nil
|
||||
orphan.TotalBandContours = nil
|
||||
orphan.TotalRiseSetCurves = nil
|
||||
orphan.GreatestTotalWidthKM = 0
|
||||
orphan.TotalVisibilityContours = [][]moon.OccultationPathPoint{{valid[0], valid[1]}}
|
||||
if _, err := geojson.MarshalPlanetOccultation(orphan); err == nil {
|
||||
t.Fatal("total visibility contours without HasTotalBand must be rejected")
|
||||
}
|
||||
orphan.TotalVisibilityContours = nil
|
||||
if _, err := geojson.MarshalPlanetOccultation(orphan); err != nil {
|
||||
t.Fatalf("cleared total-band fields must marshal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func review15DegenerateBoundary(stamp time.Time) [][]eclipse.SolarEclipsePathPoint {
|
||||
return [][]eclipse.SolarEclipsePathPoint{{
|
||||
{Time: stamp, Longitude: 0, Latitude: 0},
|
||||
{Time: stamp, Longitude: 0.01, Latitude: 0},
|
||||
{Time: stamp, Longitude: 0.01, Latitude: 1e-11},
|
||||
{Time: stamp, Longitude: 0, Latitude: 1e-11},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseShadowInstantSkipsDegenerateRegion(t *testing.T) {
|
||||
stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
|
||||
instant := eclipse.SolarEclipseShadowInstant{
|
||||
Time: stamp, Closed: true, Boundaries: review15DegenerateBoundary(stamp),
|
||||
}
|
||||
raw, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("degenerate footprint must be omitted, not rejected: %v", err)
|
||||
}
|
||||
var collection struct {
|
||||
Features []json.RawMessage `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatalf("decode GeoJSON: %v", err)
|
||||
}
|
||||
if len(collection.Features) != 0 {
|
||||
t.Fatalf("features=%d, want the degenerate region omitted", len(collection.Features))
|
||||
}
|
||||
}
|
||||
|
||||
func review15HorizonCutInstant(t *testing.T) eclipse.SolarEclipseShadowInstant {
|
||||
t.Helper()
|
||||
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||||
info, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
||||
DisableRiseSet: true,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the 2009-07-22 eclipse")
|
||||
}
|
||||
for _, footprint := range info.CentralShadowFootprints {
|
||||
if footprint.Closed || len(footprint.HorizonEnds) != 2 {
|
||||
continue
|
||||
}
|
||||
return eclipse.SolarEclipseShadowInstant{
|
||||
Time: footprint.Time, Kind: eclipse.SolarEclipseShadowUmbra,
|
||||
Boundaries: footprint.Boundaries, HorizonEnds: footprint.HorizonEnds,
|
||||
}
|
||||
}
|
||||
t.Fatal("fixture has no horizon-cut central-shadow footprint")
|
||||
return eclipse.SolarEclipseShadowInstant{}
|
||||
}
|
||||
|
||||
func review15ClosureExact(t *testing.T, instant eclipse.SolarEclipseShadowInstant) interface{} {
|
||||
t.Helper()
|
||||
raw, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err)
|
||||
}
|
||||
region := featureWithRole(t, decodeCollection(t, raw), "central-shadow-footprint")
|
||||
closure, ok := region.Properties["closure"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("horizon-cut region has no closure property")
|
||||
}
|
||||
if closure["kind"] != "horizon" {
|
||||
t.Fatalf("closure kind=%v, want horizon", closure["kind"])
|
||||
}
|
||||
return closure["exact"]
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseShadowInstantMarksSampledClosure(t *testing.T) {
|
||||
instant := review15HorizonCutInstant(t)
|
||||
if exact := review15ClosureExact(t, instant); exact != true {
|
||||
t.Fatalf("closure exact=%v, want true with two grazing points", exact)
|
||||
}
|
||||
sampled := instant
|
||||
sampled.HorizonEnds = nil
|
||||
if exact := review15ClosureExact(t, sampled); exact != false {
|
||||
t.Fatalf("closure exact=%v, want false without grazing points", exact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseCentralShadowClosureExactnessMatchesGrazingPoints(t *testing.T) {
|
||||
fixtures := centralShadowRegionFixtures()
|
||||
exactRegions := 0
|
||||
cutRegions := 0
|
||||
for _, fixture := range fixtures {
|
||||
collection, info := centralShadowMarshalFixture(t, fixture)
|
||||
grazing := make(map[string]bool, len(info.CentralShadowFootprints))
|
||||
for _, footprint := range info.CentralShadowFootprints {
|
||||
grazing[footprint.Time.UTC().Format(time.RFC3339Nano)] = len(footprint.HorizonEnds) == 2
|
||||
}
|
||||
for _, region := range featuresWithRole(collection, "central-shadow-footprint") {
|
||||
closure, ok := region.Properties["closure"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cutRegions++
|
||||
stamp, _ := region.Properties["time"].(string)
|
||||
want, present := grazing[stamp]
|
||||
if !present {
|
||||
t.Fatalf("%s: region %s has no matching footprint", fixture.name, stamp)
|
||||
}
|
||||
exact, ok := closure["exact"].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("%s: closure of %s has no exact flag", fixture.name, stamp)
|
||||
}
|
||||
if exact != want {
|
||||
t.Fatalf("%s: closure exact=%v, want %v for %s", fixture.name, exact, want, stamp)
|
||||
}
|
||||
if exact {
|
||||
exactRegions++
|
||||
}
|
||||
}
|
||||
}
|
||||
if cutRegions == 0 || exactRegions == 0 {
|
||||
t.Fatalf("horizon-cut regions=%d with grazing points=%d; the marker is untested", cutRegions, exactRegions)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user