233 lines
8.2 KiB
Go
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
|
||
|
|
)
|