feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
package geojson
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
eclipsecore "b612.me/astro/eclipse"
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/internal/solarclosure"
|
||||
)
|
||||
|
||||
// 一致性契约:GeoJSON 的偏食可见域与 SVG 渲染域同源。并集按 SVG 的精确补口口径复算,
|
||||
// 逐足迹区域的收口点必须是核心层给出的精确擦地点,而不是采样端点外推的近似弧。
|
||||
func TestSolarEclipseRegionsUseSharedExactClosure(t *testing.T) {
|
||||
for _, date := range closureGeoJSONDates() {
|
||||
partial, ok := eclipsecore.SolarEclipsePartialFootprints(date, eclipsecore.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("missing partial footprints for %s", date.Format("2006-01-02"))
|
||||
}
|
||||
data, err := MarshalSolarEclipse(partial, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", date.Format("2006-01-02"), err)
|
||||
}
|
||||
band, footprints := closureDecodeSolarRegions(t, data)
|
||||
if len(band) == 0 {
|
||||
t.Fatalf("%s: no partial-band region", date.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
svgBand, ok := closureSVGBandPolygons(partial)
|
||||
if !ok {
|
||||
t.Fatalf("%s: shared union is unavailable", date.Format("2006-01-02"))
|
||||
}
|
||||
bandDeviation := closureRegionDeviationKM(svgBand, band)
|
||||
if bandDeviation > closureGeoJSONBandToleranceKM {
|
||||
t.Fatalf("%s: band regions differ by %.6f km, tolerance %.6f km",
|
||||
date.Format("2006-01-02"), bandDeviation, closureGeoJSONBandToleranceKM)
|
||||
}
|
||||
|
||||
compared, skipped := 0, 0
|
||||
worst := 0.0
|
||||
for _, footprint := range partial.Footprints {
|
||||
region := footprints[footprint.Time.UTC().Format(time.RFC3339Nano)]
|
||||
if len(region) == 0 {
|
||||
continue
|
||||
}
|
||||
if closureRegionReachesPole(region) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if !footprint.Closed {
|
||||
for _, end := range footprint.HorizonEnds {
|
||||
if !closureRegionCarriesPoint(region, geodata.GeoPoint{
|
||||
Longitude: end.Longitude, Latitude: end.Latitude,
|
||||
}) {
|
||||
t.Fatalf("%s: the %s region is not closed at the grazing point %v",
|
||||
date.Format("2006-01-02"), footprint.Time.Format(time.RFC3339), end)
|
||||
}
|
||||
}
|
||||
for _, end := range footprint.HorizonEnds {
|
||||
worst = math.Max(worst, geodata.SphericalPolygonsPathMissDistanceKM(
|
||||
region,
|
||||
[][]geodata.GeoPoint{{{Longitude: end.Longitude, Latitude: end.Latitude}}},
|
||||
false,
|
||||
))
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Fatalf("%s: no footprint region was checked", date.Format("2006-01-02"))
|
||||
}
|
||||
if worst > closureGeoJSONGrazingToleranceKM {
|
||||
t.Fatalf("%s: grazing points miss the region by %.6f km, tolerance %.6f km",
|
||||
date.Format("2006-01-02"), worst, closureGeoJSONGrazingToleranceKM)
|
||||
}
|
||||
t.Logf("%s band=%.6f km footprints=%d skipped=%d grazing=%.6f km",
|
||||
date.Format("2006-01-02"), bandDeviation, compared, skipped, worst)
|
||||
}
|
||||
}
|
||||
|
||||
func closureGeoJSONDates() []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),
|
||||
}
|
||||
}
|
||||
|
||||
// closureSVGBandPolygons 用 SVG 侧的口径复算并集:瞬时足迹按精确擦地点补口。
|
||||
func closureSVGBandPolygons(
|
||||
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||
) ([][]geodata.GeoPoint, bool) {
|
||||
if len(partial.PartialBandContours) == 0 || len(partial.RiseSetCurves) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
contours := make([][]geodata.GeoPoint, 0, len(partial.PartialBandContours))
|
||||
for _, contour := range partial.PartialBandContours {
|
||||
contours = append(contours, solarCentralBandGeoPoints(contour))
|
||||
}
|
||||
phaseLines := make([][]geodata.GeoPoint, 0, len(partial.RiseSetCurves)*2)
|
||||
for _, curve := range partial.RiseSetCurves {
|
||||
for _, segment := range curve.Segments {
|
||||
phaseLines = append(phaseLines, solarCentralBandGeoPoints(segment))
|
||||
}
|
||||
}
|
||||
footprints := make([]solarclosure.Footprint, 0, len(partial.Footprints))
|
||||
for _, footprint := range partial.Footprints {
|
||||
input, err := solarClosureFootprint(footprint)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
footprints = append(footprints, input)
|
||||
}
|
||||
return solarclosure.BandPolygons(
|
||||
contours, phaseLines, footprints, true, solarclosure.SnapDistanceKM,
|
||||
)
|
||||
}
|
||||
|
||||
func closureDecodeSolarRegions(
|
||||
t *testing.T,
|
||||
data []byte,
|
||||
) (band [][]geodata.GeoPoint, footprints map[string][][]geodata.GeoPoint) {
|
||||
t.Helper()
|
||||
var collection struct {
|
||||
Features []struct {
|
||||
Properties map[string]interface{} `json:"properties"`
|
||||
Geometry struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates json.RawMessage `json:"coordinates"`
|
||||
} `json:"geometry"`
|
||||
} `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &collection); err != nil {
|
||||
t.Fatalf("decode GeoJSON: %v", err)
|
||||
}
|
||||
footprints = 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)
|
||||
}
|
||||
rings := make([][]geodata.GeoPoint, 0, len(polygons))
|
||||
for _, polygon := range polygons {
|
||||
for _, ring := range polygon {
|
||||
points := make([]geodata.GeoPoint, len(ring))
|
||||
for index, point := range ring {
|
||||
points[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}
|
||||
}
|
||||
rings = append(rings, points)
|
||||
}
|
||||
}
|
||||
switch role {
|
||||
case "partial-band":
|
||||
band = append(band, rings...)
|
||||
case "partial-footprint":
|
||||
value, _ := feature.Properties["time"].(string)
|
||||
footprints[value] = append(footprints[value], rings...)
|
||||
}
|
||||
}
|
||||
return band, footprints
|
||||
}
|
||||
|
||||
func closureRegionReachesPole(region [][]geodata.GeoPoint) bool {
|
||||
for _, ring := range region {
|
||||
for _, point := range ring {
|
||||
if math.Abs(point.Latitude) >= 89.9 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func closureRegionCarriesPoint(region [][]geodata.GeoPoint, target geodata.GeoPoint) bool {
|
||||
for _, ring := range region {
|
||||
for _, point := range ring {
|
||||
if math.Abs(point.Latitude-target.Latitude) < 1e-6 &&
|
||||
math.Abs(math.Remainder(point.Longitude-target.Longitude, 360)) < 1e-6 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func closureRegionDeviationKM(first, second [][]geodata.GeoPoint) float64 {
|
||||
return math.Max(
|
||||
geodata.SphericalPolygonsPathMissDistanceKM(second, first, true),
|
||||
geodata.SphericalPolygonsPathMissDistanceKM(first, second, true),
|
||||
)
|
||||
}
|
||||
|
||||
const (
|
||||
// 两侧并集只差面选择口径,实测在米级。
|
||||
closureGeoJSONBandToleranceKM = 1.0
|
||||
// 擦地点是区域的收口顶点,序列化往返只允许亚米级偏差。
|
||||
closureGeoJSONGrazingToleranceKM = 0.01
|
||||
)
|
||||
Reference in New Issue
Block a user