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

233 lines
8.2 KiB
Go

package svg
import (
"encoding/json"
"math"
"testing"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
"b612.me/astro/internal/geodata"
)
// 一致性契约:同一事件下 SVG 与 GeoJSON 的偏食可见域必须落在同一几何上。
// 并集只允许面选择带来的亚千米差异,逐足迹区域必须逐个重合在精确擦地点上。
func TestSolarEclipsePartialRegionMatchesGeoJSON(t *testing.T) {
for _, date := range closureConsistencyDates() {
info, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 2 * time.Minute, BoundaryPoints: 96,
})
if !ok {
t.Fatalf("missing partial footprints for %s", date.Format("2006-01-02"))
}
data, err := geojson.MarshalSolarEclipse(info, nil)
if err != nil {
t.Fatalf("%s: %v", date.Format("2006-01-02"), err)
}
regions := closureGeoJSONRegions(t, data)
band := regions["partial-band"]
if len(band) == 0 {
t.Fatalf("%s: GeoJSON carries no partial-band region", date.Format("2006-01-02"))
}
polygons, ok := solarEclipsePartialBandPolygons(info)
if !ok {
t.Fatalf("%s: SVG carries no partial-band union", date.Format("2006-01-02"))
}
bandDeviation := closureSetDeviationKM(polygons, band)
if bandDeviation > closureBandToleranceKM {
t.Fatalf("%s: band regions differ by %.6f km, tolerance %.6f km",
date.Format("2006-01-02"), bandDeviation, closureBandToleranceKM)
}
footprintDeviation := 0.0
compared, skipped := 0, 0
for _, footprint := range info.Footprints {
reference := closureFootprintRegionByTime(regions, footprint.Time)
if len(reference) == 0 {
continue
}
// 极区足迹沿地图上下边闭合成环,两侧的拓扑表示不同,只比对非极区足迹。
if closureRegionsReachPole(reference) {
skipped++
continue
}
polygon := solarEclipsePartialFootprintPolygon(footprint, geodata.ProjectionEquirectangular)
if len(polygon) < 3 {
t.Fatalf("%s: SVG dropped the %s footprint region",
date.Format("2006-01-02"), footprint.Time.Format(time.RFC3339))
}
deviation := closureRingDeviationKM(polygon, reference)
if !footprint.Closed && !closureRingCarriesGrazingPoints(polygon, footprint) {
t.Fatalf("%s: the %s footprint region ignores its exact grazing points (deviation %.4f km)",
date.Format("2006-01-02"), footprint.Time.Format(time.RFC3339), deviation)
}
footprintDeviation = math.Max(footprintDeviation, deviation)
compared++
}
if compared == 0 {
t.Fatalf("%s: no footprint region pair was compared", date.Format("2006-01-02"))
}
if footprintDeviation > closureFootprintToleranceKM {
t.Fatalf("%s: footprint regions differ by %.6f km, tolerance %.6f km",
date.Format("2006-01-02"), footprintDeviation, closureFootprintToleranceKM)
}
t.Logf("%s band=%.6f km footprints=%d skipped=%d max=%.6f km",
date.Format("2006-01-02"), bandDeviation, compared, skipped, footprintDeviation)
}
}
// 缺少精确擦地点时必须降级到逐足迹扫掠,并让 data-source 落到采样扫掠词条。
func TestSolarEclipsePartialRegionDegradesWithoutExactHorizon(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 10 * time.Minute, BoundaryPoints: 96,
})
if !ok {
t.Fatal("missing 2024-04-08 partial footprints")
}
options := SolarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: time.UTC}
frame := solarEclipseMapLayoutFor(options, geodata.ProjectionEquirectangular,
geodata.GeoPoint{Longitude: info.Eclipse.GreatestLongitude, Latitude: info.Eclipse.GreatestLatitude}).frame
if _, source := solarEclipsePartialSweepPath(info, frame); source != eclipseMapSourcePartialBandUnion {
t.Fatalf("source=%q, want %q", source, eclipseMapSourcePartialBandUnion)
}
degraded := info
degraded.Footprints = append([]eclipse.SolarEclipsePartialFootprint(nil), info.Footprints...)
open := 0
for index := range degraded.Footprints {
if degraded.Footprints[index].Closed {
continue
}
degraded.Footprints[index].HorizonEnds = nil
open++
}
if open == 0 {
t.Fatal("the event has no horizon-cut footprint to degrade")
}
if polygons, ok := solarEclipsePartialBandPolygons(degraded); ok || polygons != nil {
t.Fatalf("union survived without exact grazing points: %d polygons", len(polygons))
}
path, source := solarEclipsePartialSweepPath(degraded, frame)
if source != eclipseMapSourceSampledFootprintSweep {
t.Fatalf("degraded source=%q, want %q", source, eclipseMapSourceSampledFootprintSweep)
}
if path == "" {
t.Fatal("degraded sweep rendered no geometry")
}
}
func closureConsistencyDates() []time.Time {
return []time.Time{
time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC),
time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC),
time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC),
time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC),
}
}
type closureGeoJSONFeature struct {
Properties map[string]interface{} `json:"properties"`
Geometry struct {
Type string `json:"type"`
Coordinates json.RawMessage `json:"coordinates"`
} `json:"geometry"`
}
type closureGeoJSONCollection struct {
Features []closureGeoJSONFeature `json:"features"`
}
// closureGeoJSONRegions 按角色汇总多边形区域,时间属性用作逐足迹区域的键。
func closureGeoJSONRegions(t *testing.T, data []byte) map[string][][]geodata.GeoPoint {
t.Helper()
var collection closureGeoJSONCollection
if err := json.Unmarshal(data, &collection); err != nil {
t.Fatalf("decode GeoJSON: %v", err)
}
regions := make(map[string][][]geodata.GeoPoint)
for _, feature := range collection.Features {
role, _ := feature.Properties["role"].(string)
if feature.Geometry.Type != "MultiPolygon" {
continue
}
var polygons [][][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
t.Fatalf("decode %s coordinates: %v", role, err)
}
key := role
if role == "partial-footprint" {
value, _ := feature.Properties["time"].(string)
key = role + "@" + value
}
for _, polygon := range polygons {
for _, ring := range polygon {
points := make([]geodata.GeoPoint, len(ring))
for index, point := range ring {
if len(point) < 2 {
t.Fatalf("%s carries a %d-value coordinate", role, len(point))
}
points[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
regions[key] = append(regions[key], points)
}
}
}
return regions
}
// closureRegionsReachPole 报告区域是否触及极点;极区环沿地图边闭合,不参与几何比对。
func closureRegionsReachPole(regions [][]geodata.GeoPoint) bool {
for _, ring := range regions {
for _, point := range ring {
if math.Abs(point.Latitude) >= 89.9 {
return true
}
}
}
return false
}
// closureRingCarriesGrazingPoints 报告填充环是否仍以精确擦地点收口。
func closureRingCarriesGrazingPoints(ring []geodata.GeoPoint, footprint eclipse.SolarEclipsePartialFootprint) bool {
if len(footprint.HorizonEnds) != 2 {
return false
}
for _, end := range footprint.HorizonEnds {
found := false
for _, point := range ring {
if math.Abs(point.Longitude-end.Longitude) < 1e-9 && math.Abs(point.Latitude-end.Latitude) < 1e-9 {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func closureFootprintRegionByTime(regions map[string][][]geodata.GeoPoint, value time.Time) [][]geodata.GeoPoint {
return regions["partial-footprint@"+value.UTC().Format(time.RFC3339Nano)]
}
func closureSetDeviationKM(first, second [][]geodata.GeoPoint) float64 {
return math.Max(
geodata.SphericalPolygonsPathMissDistanceKM(second, first, true),
geodata.SphericalPolygonsPathMissDistanceKM(first, second, true),
)
}
func closureRingDeviationKM(ring []geodata.GeoPoint, reference [][]geodata.GeoPoint) float64 {
return closureSetDeviationKM([][]geodata.GeoPoint{ring}, reference)
}
const (
// 并集的面选择由瞬时足迹提示决定,两种闭合口径的实测差异在米级。
closureBandToleranceKM = 1.0
// 逐足迹区域两侧都直接调用同一个精确闭合,只允许序列化往返误差。
closureFootprintToleranceKM = 0.01
)