feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/internal/occultationgeo"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestMarshalStarOccultationAntares20240303UsesContinuousVisibleEnvelope(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, zone)
|
||||
star := moon.StarCoordinate{
|
||||
ID: "HR 6134", RA: 247.35166667, Dec: -26.43194444,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -10,
|
||||
ProperMotionDecMasPerYear: -20,
|
||||
ParallaxMas: 24,
|
||||
}
|
||||
paths, err := moon.FindStarOccultationPaths(
|
||||
start, start.Add(24*time.Hour), star,
|
||||
moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
data, err := geojson.MarshalStarOccultation(paths[0])
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
band := featureWithRole(t, collection, "occultation-band")
|
||||
if band.Properties["static_band_authoritative"] != true {
|
||||
t.Fatalf("occultation-band source=%v authoritative=%v, want analytic authoritative boundary",
|
||||
band.Properties["source"], band.Properties["static_band_authoritative"])
|
||||
}
|
||||
for index, point := range paths[0].CenterLine {
|
||||
if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) {
|
||||
t.Fatalf("center-line sample %d lies outside visible band at %.6f, %.6f",
|
||||
index, point.Longitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode visibility-boundary: %v", err)
|
||||
}
|
||||
for segmentIndex, line := range lines {
|
||||
for pointIndex, point := range line {
|
||||
if !geometryContainsPointWithinKM(t, band.Geometry, point[0], point[1], 2) {
|
||||
t.Fatalf("visibility-boundary segment %d point %d lies outside visible band at %.6f, %.6f",
|
||||
segmentIndex, pointIndex, point[0], point[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertOccultationBandMaximumEdge(t, band, 45)
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationAntares20240303RetainsNarrowGreatestSetFold(t *testing.T) {
|
||||
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := moon.FindStarOccultationPaths(
|
||||
start, start.Add(24*time.Hour), antaresCoordinateForGeoJSONRegression(),
|
||||
moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
var curve moon.OccultationRiseSetCurve
|
||||
for _, candidate := range paths[0].RiseSetCurves {
|
||||
if candidate.Phase == moon.RiseSetPhaseGreatest && candidate.Direction == moon.RiseSetDirectionSet {
|
||||
curve = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(curve.Segments) < 2 {
|
||||
t.Fatalf("greatest/set segments=%d, want the narrow fold branch recovered at one-minute sampling", len(curve.Segments))
|
||||
}
|
||||
shared := false
|
||||
for first := 0; first < len(curve.Segments); first++ {
|
||||
for second := first + 1; second < len(curve.Segments); second++ {
|
||||
for _, left := range []moon.OccultationPathPoint{curve.Segments[first][0], curve.Segments[first][len(curve.Segments[first])-1]} {
|
||||
for _, right := range []moon.OccultationPathPoint{curve.Segments[second][0], curve.Segments[second][len(curve.Segments[second])-1]} {
|
||||
if left.Time.Sub(right.Time) < -time.Second || left.Time.Sub(right.Time) > time.Second {
|
||||
continue
|
||||
}
|
||||
if geojsonPointDistanceKM(left.Longitude, left.Latitude, right.Longitude, right.Latitude) <= 1 {
|
||||
shared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared {
|
||||
t.Fatal("greatest/set fold branches do not share a physical endpoint")
|
||||
}
|
||||
data, err := geojson.MarshalStarOccultation(paths[0])
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultation: %v", err)
|
||||
}
|
||||
boundary := riseSetBoundaryFeature(t, decodeCollection(t, data), "greatest", "set")
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode greatest/set boundary: %v", err)
|
||||
}
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("serialized greatest/set segments=%d, want at least two folded branches", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func geojsonPointDistanceKM(firstLongitude, firstLatitude, secondLongitude, secondLatitude float64) float64 {
|
||||
const earthRadiusKM = 6378.1366
|
||||
const degreesToRadians = 3.141592653589793 / 180
|
||||
firstLat, secondLat := firstLatitude*degreesToRadians, secondLatitude*degreesToRadians
|
||||
deltaLat := (secondLatitude - firstLatitude) * degreesToRadians
|
||||
deltaLon := (secondLongitude - firstLongitude) * degreesToRadians
|
||||
a := math.Sin(deltaLat/2)*math.Sin(deltaLat/2) + math.Cos(firstLat)*math.Cos(secondLat)*math.Sin(deltaLon/2)*math.Sin(deltaLon/2)
|
||||
return 2 * earthRadiusKM * math.Asin(math.Sqrt(math.Max(0, math.Min(1, a))))
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationAntaresRepresentative2022To2026Topology(t *testing.T) {
|
||||
star := antaresCoordinateForGeoJSONRegression()
|
||||
for _, date := range []string{
|
||||
"2023-09-21", // first event in the series; short temporal horizon closure
|
||||
"2023-10-18", // complete endpoint network without a temporal connector
|
||||
"2024-03-03", // original disconnected footprint-sweep regression
|
||||
"2024-06-20", // antimeridian split
|
||||
"2025-08-31", // multi-branch southern polar turn and numerical sliver
|
||||
"2026-02-11", // pole-enclosing equirectangular output
|
||||
"2026-12-08", // returning ordinary-latitude branch
|
||||
} {
|
||||
date := date
|
||||
t.Run(date, func(t *testing.T) {
|
||||
start, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths, err := moon.FindStarOccultationPaths(
|
||||
start, start.Add(24*time.Hour), star,
|
||||
moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
polygons, authoritative, err := occultationgeo.VisibleStarBandPolygonsFromAnalyticContours(
|
||||
path.BandFootprints, path.BandContours, path.VisibilityContours,
|
||||
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
|
||||
)
|
||||
if err != nil || !authoritative || len(polygons) != 1 {
|
||||
t.Fatalf("analytic polygons=%d authoritative=%v err=%v, want one physical band", len(polygons), authoritative, err)
|
||||
}
|
||||
assertOccultationPhysicalPolygonsMaximumEdge(t, polygons, 45)
|
||||
assertOccultationPhysicalPolygonsHaveNoShortHairpins(t, polygons, 35, 25, 12)
|
||||
if !geodata.SphericalPolygonsContainPathsWithinKM(
|
||||
polygons, occultationPathPointLines([][]moon.OccultationPathPoint{path.CenterLine}), false, 0.1,
|
||||
) {
|
||||
t.Fatalf("analytic band misses center line by %.3f km",
|
||||
geodata.SphericalPolygonsPathMissDistanceKM(
|
||||
polygons, occultationPathPointLines([][]moon.OccultationPathPoint{path.CenterLine}), false,
|
||||
))
|
||||
}
|
||||
phaseLines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves))
|
||||
for _, curve := range path.RiseSetCurves {
|
||||
phaseLines = append(phaseLines,
|
||||
occultationPathPointLines(occultationgeo.StitchedRiseSetCurveSegments(curve))...,
|
||||
)
|
||||
}
|
||||
if !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 2) {
|
||||
t.Fatalf("analytic band misses a displayed rise/set phase by %.3f km",
|
||||
geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false))
|
||||
}
|
||||
data, err := geojson.MarshalStarOccultation(path)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
band := featureWithRole(t, collection, "occultation-band")
|
||||
if band.Properties["static_band_authoritative"] != true {
|
||||
t.Fatalf("occultation-band source=%v authoritative=%v",
|
||||
band.Properties["source"], band.Properties["static_band_authoritative"])
|
||||
}
|
||||
for index, point := range path.CenterLine {
|
||||
if !geometryContainsPointWithinKM(t, band.Geometry, point.Longitude, point.Latitude, 0.1) {
|
||||
t.Fatalf("GeoJSON band excludes center sample %d at %.6f, %.6f",
|
||||
index, point.Longitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func antaresCoordinateForGeoJSONRegression() moon.StarCoordinate {
|
||||
return moon.StarCoordinate{
|
||||
ID: "HR 6134", RA: 247.35166667, Dec: -26.43194444,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -10,
|
||||
ProperMotionDecMasPerYear: -20,
|
||||
ParallaxMas: 24,
|
||||
}
|
||||
}
|
||||
|
||||
func occultationPathPointLines(sources [][]moon.OccultationPathPoint) [][]geodata.GeoPoint {
|
||||
result := make([][]geodata.GeoPoint, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
if len(source) < 2 {
|
||||
continue
|
||||
}
|
||||
line := make([]geodata.GeoPoint, len(source))
|
||||
for index, point := range source {
|
||||
line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
result = append(result, line)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertOccultationPhysicalPolygonsMaximumEdge(
|
||||
t *testing.T,
|
||||
polygons [][]geodata.GeoPoint,
|
||||
maximumKM float64,
|
||||
) {
|
||||
t.Helper()
|
||||
for polygonIndex, polygon := range polygons {
|
||||
for index := range polygon {
|
||||
next := (index + 1) % len(polygon)
|
||||
distance := geoJSONCoordinateDistanceKM(
|
||||
[]float64{polygon[index].Longitude, polygon[index].Latitude},
|
||||
[]float64{polygon[next].Longitude, polygon[next].Latitude},
|
||||
)
|
||||
if distance > maximumKM {
|
||||
t.Fatalf("physical polygon %d edge %d is %.1f km, want <=%.1f km",
|
||||
polygonIndex, index, distance, maximumKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertOccultationPhysicalPolygonsHaveNoShortHairpins(
|
||||
t *testing.T,
|
||||
polygons [][]geodata.GeoPoint,
|
||||
maximumClosureKM, minimumDetourKM float64,
|
||||
maximumSpan int,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, polygon := range polygons {
|
||||
ring := make([][]float64, len(polygon))
|
||||
for index, point := range polygon {
|
||||
ring[index] = []float64{point.Longitude, point.Latitude}
|
||||
}
|
||||
assertGeoJSONRingHasNoShortHairpins(
|
||||
t, "physical occultation-band", ring,
|
||||
maximumClosureKM, minimumDetourKM, maximumSpan,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func geometryContainsPointWithinKM(t *testing.T, value struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates json.RawMessage `json:"coordinates"`
|
||||
Geometries json.RawMessage `json:"geometries"`
|
||||
}, longitude, latitude, toleranceKM float64) bool {
|
||||
if geometryContainsPoint(t, value, longitude, latitude) {
|
||||
return true
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if value.Type == "MultiPolygon" {
|
||||
if json.Unmarshal(value.Coordinates, &polygons) != nil {
|
||||
return false
|
||||
}
|
||||
} else if value.Type == "Polygon" {
|
||||
var polygon [][][]float64
|
||||
if json.Unmarshal(value.Coordinates, &polygon) != nil {
|
||||
return false
|
||||
}
|
||||
polygons = [][][][]float64{polygon}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
point := []float64{longitude, latitude}
|
||||
for _, polygon := range polygons {
|
||||
for _, ring := range polygon {
|
||||
for index := 1; index < len(ring); index++ {
|
||||
if geoJSONPointSegmentDistanceKM(point, ring[index-1], ring[index]) <= toleranceKM {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertOccultationBandMaximumEdge(t *testing.T, feature decodedFeature, maximumKM float64) {
|
||||
t.Helper()
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode %v polygons: %v", feature.Properties["role"], err)
|
||||
}
|
||||
for polygonIndex, polygon := range polygons {
|
||||
for ringIndex, ring := range polygon {
|
||||
for pointIndex := 1; pointIndex < len(ring); pointIndex++ {
|
||||
if distance := geoJSONCoordinateDistanceKM(ring[pointIndex-1], ring[pointIndex]); distance > maximumKM {
|
||||
t.Fatalf("%v polygon %d ring %d edge %d is %.1f km, want <= %.1f km",
|
||||
feature.Properties["role"], polygonIndex, ringIndex, pointIndex, distance, maximumKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3285
-62
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
package geojson
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
eclipsecore "b612.me/astro/eclipse"
|
||||
)
|
||||
|
||||
// solarCentralBandPointSegmentKM 用于判断解析限线与导出带边界的偏离量(超过
|
||||
// solarCentralBandSnapToleranceKM 就会改用环导出边)。跨换日线的限线必须先把经度差
|
||||
// 归约到 ±180°,否则会被判成数万公里之外。
|
||||
// solarCentralBandPointSegmentKM measures how far an analytic limit line deviates from the
|
||||
// exported band boundary (beyond solarCentralBandSnapToleranceKM the ring edge wins). A
|
||||
// limit line crossing the antimeridian must wrap its longitude difference to ±180°, or it
|
||||
// reads as tens of thousands of kilometres away.
|
||||
func TestSolarCentralBandPointSegmentWrapsAtAntimeridian(t *testing.T) {
|
||||
point := eclipsecore.SolarEclipsePathPoint{Longitude: 179.99, Latitude: 10}
|
||||
first := eclipsecore.SolarEclipsePathPoint{Longitude: 179.95, Latitude: 10}
|
||||
second := eclipsecore.SolarEclipsePathPoint{Longitude: -179.95, Latitude: 10}
|
||||
|
||||
// 该点落在这一段的内部(180° 处是段内点),距离必须是 0。
|
||||
// The point lies inside the segment (which passes 180°), so the distance is zero.
|
||||
if got := solarCentralBandPointSegmentKM(point, first, second); math.Abs(got) > 0.5 {
|
||||
t.Fatalf("point on an antimeridian-crossing segment measured %v km away", got)
|
||||
}
|
||||
// 反向跨越(从 +179.95 到 -179.95 之外)同样要归约。
|
||||
// Wrapping must also hold when the segment runs the other way.
|
||||
if got := solarCentralBandPointSegmentKM(point, second, first); math.Abs(got) > 0.5 {
|
||||
t.Fatalf("reversed segment measured %v km away", got)
|
||||
}
|
||||
// 普通(非换日线)段的距离不受影响:与水平段相差 1° 纬度 ≈ 111.32 km。
|
||||
// An ordinary segment keeps its plain latitudinal distance: 1° of latitude off a
|
||||
// horizontal segment is 111.32 km.
|
||||
far := eclipsecore.SolarEclipsePathPoint{Longitude: 179.99, Latitude: 11}
|
||||
if got := solarCentralBandPointSegmentKM(far, first, second); math.Abs(got-111.32) > 0.5 {
|
||||
t.Fatalf("plain segment distance = %v km, want about 111.32 km", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package geojson_test
|
||||
|
||||
// greatest-time-line 逐支路导出,time 与 jde 取自等时线本身,几何与支路逐点一致。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func cloneSolarGreatestTimeContours(
|
||||
source []eclipse.SolarEclipseGreatestTimeContour,
|
||||
) []eclipse.SolarEclipseGreatestTimeContour {
|
||||
result := make([]eclipse.SolarEclipseGreatestTimeContour, len(source))
|
||||
for index, contour := range source {
|
||||
segments := make([][]eclipse.SolarEclipsePathPoint, len(contour.Segments))
|
||||
for segmentIndex, segment := range contour.Segments {
|
||||
segments[segmentIndex] = append([]eclipse.SolarEclipsePathPoint(nil), segment...)
|
||||
}
|
||||
contour.Segments = segments
|
||||
result[index] = contour
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseGreatestTimeLines(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*3600)
|
||||
stepped := eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 20 * time.Minute, BoundaryPoints: 24, DisableRiseSet: true,
|
||||
GreatestTimeStep: 30 * time.Minute,
|
||||
}
|
||||
valued := stepped
|
||||
valued.GreatestTimeStep = 0
|
||||
valued.GreatestTimeValues = []time.Time{
|
||||
time.Date(2009, time.July, 22, 9, 0, 0, 0, location),
|
||||
time.Date(2009, time.July, 22, 10, 0, 0, 0, location),
|
||||
time.Date(2009, time.July, 22, 11, 0, 0, 0, location),
|
||||
}
|
||||
for _, fixture := range []struct {
|
||||
name string
|
||||
date time.Time
|
||||
options eclipse.SolarEclipsePartialFootprintOptions
|
||||
central bool
|
||||
wantMultiBranch bool
|
||||
}{
|
||||
{
|
||||
name: "stepped-2021", date: time.Date(2021, time.June, 10, 0, 0, 0, 0, time.UTC),
|
||||
options: stepped, wantMultiBranch: true,
|
||||
},
|
||||
{
|
||||
name: "valued-2009-central", date: time.Date(2009, time.July, 22, 0, 0, 0, 0, location),
|
||||
options: valued, central: true,
|
||||
},
|
||||
} {
|
||||
t.Run(fixture.name, func(t *testing.T) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(fixture.date, fixture.options)
|
||||
if !ok || len(partial.GreatestTimeContours) == 0 {
|
||||
t.Fatal("expected greatest-time contours")
|
||||
}
|
||||
multiBranch := false
|
||||
for _, contour := range partial.GreatestTimeContours {
|
||||
if len(contour.Segments) > 1 {
|
||||
multiBranch = true
|
||||
}
|
||||
}
|
||||
if multiBranch != fixture.wantMultiBranch {
|
||||
t.Fatalf("fixture multi-branch=%v, want %v", multiBranch, fixture.wantMultiBranch)
|
||||
}
|
||||
var central *eclipse.SolarEclipsePath
|
||||
if fixture.central {
|
||||
path, pathOK := eclipse.SolarEclipseCentralPath(
|
||||
fixture.date, eclipse.SolarEclipsePathOptions{Step: 10 * time.Minute},
|
||||
)
|
||||
if !pathOK {
|
||||
t.Fatal("expected central path")
|
||||
}
|
||||
central = &path
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, central)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
lines := featuresWithRole(decodeCollection(t, data), "greatest-time-line")
|
||||
branches := 0
|
||||
for _, contour := range partial.GreatestTimeContours {
|
||||
branches += len(contour.Segments)
|
||||
}
|
||||
if len(lines) != branches {
|
||||
t.Fatalf("greatest-time-line features=%d, branches=%d", len(lines), branches)
|
||||
}
|
||||
lineIndex := 0
|
||||
for _, contour := range partial.GreatestTimeContours {
|
||||
for _, segment := range contour.Segments {
|
||||
line := lines[lineIndex]
|
||||
lineIndex++
|
||||
if line.Geometry.Type != "MultiLineString" {
|
||||
t.Fatalf("line %d geometry=%q, want MultiLineString", lineIndex-1, line.Geometry.Type)
|
||||
}
|
||||
if got, want := line.Properties["time"], contour.Time.UTC().Format(time.RFC3339Nano); got != want {
|
||||
t.Fatalf("line %d time=%v, want %v", lineIndex-1, got, want)
|
||||
}
|
||||
if got, ok := line.Properties["jde"].(float64); !ok || got != contour.JDE {
|
||||
t.Fatalf("line %d jde=%v, want %v", lineIndex-1, line.Properties["jde"], contour.JDE)
|
||||
}
|
||||
assertTimedLineAligned(t, line)
|
||||
var coordinates [][][]float64
|
||||
if err := json.Unmarshal(line.Geometry.Coordinates, &coordinates); err != nil {
|
||||
t.Fatalf("decode line %d: %v", lineIndex-1, err)
|
||||
}
|
||||
if len(coordinates) != 1 || len(coordinates[0]) != len(segment) {
|
||||
t.Fatalf("line %d coordinate lines=%d points=%d, branch points=%d",
|
||||
lineIndex-1, len(coordinates), coordinateCount(coordinates), len(segment))
|
||||
}
|
||||
for pointIndex, point := range segment {
|
||||
if coordinates[0][pointIndex][0] != point.Longitude ||
|
||||
coordinates[0][pointIndex][1] != point.Latitude {
|
||||
t.Fatalf("line %d point %d=(%v, %v), branch point=(%v, %v)",
|
||||
lineIndex-1, pointIndex, coordinates[0][pointIndex][0],
|
||||
coordinates[0][pointIndex][1], point.Longitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func coordinateCount(coordinates [][][]float64) int {
|
||||
count := 0
|
||||
for _, line := range coordinates {
|
||||
count += len(line)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseRejectsInvalidGreatestTimeContours(t *testing.T) {
|
||||
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||||
base, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 20 * time.Minute, BoundaryPoints: 24, DisableRiseSet: true,
|
||||
GreatestTimeValues: []time.Time{
|
||||
time.Date(2009, time.July, 22, 1, 0, 0, 0, time.UTC),
|
||||
time.Date(2009, time.July, 22, 2, 0, 0, 0, time.UTC),
|
||||
},
|
||||
})
|
||||
if !ok || len(base.GreatestTimeContours) == 0 || len(base.GreatestTimeContours[0].Segments) == 0 {
|
||||
t.Fatal("expected greatest-time contours")
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
mutate func([]eclipse.SolarEclipseGreatestTimeContour)
|
||||
}{
|
||||
{name: "nan-longitude", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].Segments[0][0].Longitude = math.NaN()
|
||||
}},
|
||||
{name: "infinite-latitude", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].Segments[0][0].Latitude = math.Inf(1)
|
||||
}},
|
||||
{name: "longitude-out-of-range", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].Segments[0][0].Longitude = 180.5
|
||||
}},
|
||||
{name: "latitude-out-of-range", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].Segments[0][0].Latitude = -90.5
|
||||
}},
|
||||
{name: "single-point-branch", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].Segments[0] = contours[0].Segments[0][:1]
|
||||
}},
|
||||
{name: "zero-jde", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].JDE = 0
|
||||
}},
|
||||
{name: "nan-jde", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].JDE = math.NaN()
|
||||
}},
|
||||
{name: "zero-time", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].Time = time.Time{}
|
||||
}},
|
||||
{name: "zero-point-time", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].Segments[0][0].Time = time.Time{}
|
||||
}},
|
||||
{name: "no-branches", mutate: func(contours []eclipse.SolarEclipseGreatestTimeContour) {
|
||||
contours[0].Segments = nil
|
||||
}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
partial := base
|
||||
partial.GreatestTimeContours = cloneSolarGreatestTimeContours(base.GreatestTimeContours)
|
||||
testCase.mutate(partial.GreatestTimeContours)
|
||||
if _, err := geojson.MarshalSolarEclipse(partial, nil); err == nil {
|
||||
t.Fatal("invalid greatest-time contour was accepted")
|
||||
} else if !strings.Contains(err.Error(), "greatest-time contour") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseWithoutGreatestTimeLinesIsUnchanged(t *testing.T) {
|
||||
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||||
options := eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 20 * time.Minute, BoundaryPoints: 24, DisableRiseSet: true,
|
||||
}
|
||||
plain, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
|
||||
if !ok {
|
||||
t.Fatal("expected solar partial footprints")
|
||||
}
|
||||
first, err := geojson.MarshalSolarEclipse(plain, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
second, err := geojson.MarshalSolarEclipse(plain, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("default solar eclipse export is not byte stable")
|
||||
}
|
||||
if bytes.Contains(first, []byte("greatest-time-line")) {
|
||||
t.Fatal("default solar eclipse export contains greatest-time lines")
|
||||
}
|
||||
requested := options
|
||||
requested.GreatestTimeValues = []time.Time{
|
||||
time.Date(2009, time.July, 22, 1, 0, 0, 0, time.UTC),
|
||||
time.Date(2009, time.July, 22, 2, 0, 0, 0, time.UTC),
|
||||
}
|
||||
withLines, ok := eclipse.SolarEclipsePartialFootprintsNASABulletinSplitK(date, requested)
|
||||
if !ok || len(withLines.GreatestTimeContours) == 0 {
|
||||
t.Fatal("expected greatest-time contours")
|
||||
}
|
||||
cleared := withLines
|
||||
cleared.GreatestTimeContours = nil
|
||||
third, err := geojson.MarshalSolarEclipse(cleared, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, third) {
|
||||
t.Fatal("clearing greatest-time contours changes the export")
|
||||
}
|
||||
}
|
||||
+85
-19
@@ -6,9 +6,13 @@
|
||||
// 每个要素都包含 event 和 role 属性。带时间的 MultiLineString 要素还包含与坐标段对齐的嵌套 times 数组。
|
||||
// Times 编码为 UTC RFC 3339 字符串;路径采样由上游日月食和月掩选项控制后再传入本包。
|
||||
// WithTimeMarkers 变体还会追加 role 为 time-marker 的 Point 要素,标签按请求地点格式化。
|
||||
// 月掩 fallback 掩带通常为 MultiPolygon;支路跳变留下的孤立零时长截面以 MultiLineString 表达,
|
||||
// 与连续扫掠同时存在时使用 GeometryCollection,避免用虚假多边形重新连接跳变。
|
||||
// Every feature has event and role properties. Timed MultiLineString features also contain a nested times array aligned with their coordinate segments.
|
||||
// Times are encoded as UTC RFC 3339 strings. Path sampling is controlled by the source eclipse and occultation options before values reach this package.
|
||||
// The WithTimeMarkers variants additionally append Point Features whose role is time-marker and whose label is formatted for the requested location.
|
||||
// Fallback occultation bands normally use MultiPolygon. Isolated zero-duration sections left by branch changes use MultiLineString,
|
||||
// combined with a continuous sweep in a GeometryCollection, so discontinuities are never reconnected by a false polygon.
|
||||
package geojson
|
||||
|
||||
import (
|
||||
@@ -39,7 +43,8 @@ type feature struct {
|
||||
|
||||
type geometry struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates interface{} `json:"coordinates"`
|
||||
Coordinates interface{} `json:"coordinates,omitempty"`
|
||||
Geometries []geometry `json:"geometries,omitempty"`
|
||||
}
|
||||
|
||||
type pathSample struct {
|
||||
@@ -69,6 +74,14 @@ func marshalFeatureCollection(features []feature) ([]byte, error) {
|
||||
if len(features) == 0 {
|
||||
return nil, fmt.Errorf("geojson: no geographic features")
|
||||
}
|
||||
return encodeFeatureCollection(features)
|
||||
}
|
||||
|
||||
func marshalEmptyFeatureCollection() ([]byte, error) {
|
||||
return encodeFeatureCollection(make([]feature, 0))
|
||||
}
|
||||
|
||||
func encodeFeatureCollection(features []feature) ([]byte, error) {
|
||||
value, err := json.Marshal(featureCollection{Type: featureCollectionType, Features: features})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: encode feature collection: %w", err)
|
||||
@@ -277,21 +290,54 @@ func pointGeometry(longitude, latitude float64) (geometry, error) {
|
||||
}
|
||||
|
||||
func timedMultiLineGeometry(points []pathSample) (geometry, [][]string, error) {
|
||||
segments, err := splitTimedLine(points)
|
||||
if err != nil {
|
||||
return geometry{}, nil, err
|
||||
}
|
||||
coordinates := make([][][]float64, 0, len(segments))
|
||||
times := make([][]string, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
line := make([][]float64, len(segment))
|
||||
lineTimes := make([]string, len(segment))
|
||||
for index, point := range segment {
|
||||
line[index] = []float64{point.Longitude, point.Latitude}
|
||||
lineTimes[index] = formatTime(point.Time)
|
||||
return timedMultiLineGeometryFromSegments([][]pathSample{points})
|
||||
}
|
||||
|
||||
func timedMultiLineGeometryFromSegments(sourceSegments [][]pathSample) (geometry, [][]string, error) {
|
||||
return timedMultiLineGeometryFromSegmentsWithTimeOrder(sourceSegments, true)
|
||||
}
|
||||
|
||||
func timedMultiLineGeometryFromSegmentsWithTimeOrder(
|
||||
sourceSegments [][]pathSample,
|
||||
requireIncreasingTimes bool,
|
||||
) (geometry, [][]string, error) {
|
||||
coordinates := make([][][]float64, 0, len(sourceSegments))
|
||||
times := make([][]string, 0, len(sourceSegments))
|
||||
for _, source := range sourceSegments {
|
||||
if len(source) == 0 {
|
||||
continue
|
||||
}
|
||||
coordinates = append(coordinates, line)
|
||||
times = append(times, lineTimes)
|
||||
if len(source) == 1 {
|
||||
point := source[0]
|
||||
if point.Time.IsZero() {
|
||||
return geometry{}, nil, fmt.Errorf("geojson: timed line contains a zero time")
|
||||
}
|
||||
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||
return geometry{}, nil, err
|
||||
}
|
||||
position := []float64{point.Longitude, point.Latitude}
|
||||
formattedTime := formatTime(point.Time)
|
||||
coordinates = append(coordinates, [][]float64{position, append([]float64(nil), position...)})
|
||||
times = append(times, []string{formattedTime, formattedTime})
|
||||
continue
|
||||
}
|
||||
segments, err := splitTimedLine(source, requireIncreasingTimes)
|
||||
if err != nil {
|
||||
return geometry{}, nil, err
|
||||
}
|
||||
for _, segment := range segments {
|
||||
line := make([][]float64, len(segment))
|
||||
lineTimes := make([]string, len(segment))
|
||||
for index, point := range segment {
|
||||
line[index] = []float64{point.Longitude, point.Latitude}
|
||||
lineTimes[index] = formatTime(point.Time)
|
||||
}
|
||||
coordinates = append(coordinates, line)
|
||||
times = append(times, lineTimes)
|
||||
}
|
||||
}
|
||||
if len(coordinates) == 0 {
|
||||
return geometry{}, nil, fmt.Errorf("geojson: line has no valid segments")
|
||||
}
|
||||
return geometry{Type: "MultiLineString", Coordinates: coordinates}, times, nil
|
||||
}
|
||||
@@ -309,7 +355,7 @@ func geoMultiLineGeometry(points []geodata.GeoPoint, closeLine bool) (geometry,
|
||||
if closeLine && !geodata.SameGeoPoint(geographic[0], geographic[len(geographic)-1]) {
|
||||
geographic = append(geographic, geographic[0])
|
||||
}
|
||||
segments := geodata.PolylineSegments(geographic, geodata.ProjectionEquirectangular)
|
||||
segments := geodata.PolylineSegments(geographic, geodata.ClipView{Projection: geodata.ProjectionEquirectangular})
|
||||
coordinates := make([][][]float64, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
if len(segment) < 2 {
|
||||
@@ -327,7 +373,24 @@ func geoMultiLineGeometry(points []geodata.GeoPoint, closeLine bool) (geometry,
|
||||
return geometry{Type: "MultiLineString", Coordinates: coordinates}, nil
|
||||
}
|
||||
|
||||
// multiPolygonFillGeometry tags fill-only polygons, which tolerate a coarser
|
||||
// map chord bound than the path strokes.
|
||||
func multiPolygonFillGeometry(polygons [][]geodata.GeoPoint) (geometry, error) {
|
||||
return multiPolygonGeometryWithin(
|
||||
polygons, sphericalFillChordLimitKM, sphericalFillChordErrorDegrees,
|
||||
)
|
||||
}
|
||||
|
||||
func multiPolygonGeometry(polygons [][]geodata.GeoPoint) (geometry, error) {
|
||||
return multiPolygonGeometryWithin(
|
||||
polygons, sphericalMapChordLimitKM, sphericalMapChordErrorDegrees,
|
||||
)
|
||||
}
|
||||
|
||||
func multiPolygonGeometryWithin(
|
||||
polygons [][]geodata.GeoPoint,
|
||||
chordLimitKM, chordErrorDegrees float64,
|
||||
) (geometry, error) {
|
||||
fragments := make([][]geodata.GeoPoint, 0, len(polygons))
|
||||
for index, polygon := range polygons {
|
||||
polygon = openRing(polygon)
|
||||
@@ -340,7 +403,10 @@ func multiPolygonGeometry(polygons [][]geodata.GeoPoint) (geometry, error) {
|
||||
}
|
||||
}
|
||||
fragments = append(fragments,
|
||||
geodata.PolygonFragments(polygon, geodata.ProjectionEquirectangular)...)
|
||||
geodata.PolygonFragments(
|
||||
sampleSphericalMapRingWithin(polygon, chordLimitKM, chordErrorDegrees),
|
||||
geodata.ClipView{Projection: geodata.ProjectionEquirectangular},
|
||||
)...)
|
||||
}
|
||||
return multiPolygonGeometryFromFragments(fragments)
|
||||
}
|
||||
@@ -403,7 +469,7 @@ func polygonArea(points []geodata.GeoPoint) float64 {
|
||||
return area / 2
|
||||
}
|
||||
|
||||
func splitTimedLine(points []pathSample) ([][]pathSample, error) {
|
||||
func splitTimedLine(points []pathSample, requireIncreasingTimes bool) ([][]pathSample, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, fmt.Errorf("geojson: line requires at least two points")
|
||||
}
|
||||
@@ -414,7 +480,7 @@ func splitTimedLine(points []pathSample) ([][]pathSample, error) {
|
||||
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if index > 0 && !point.Time.After(points[index-1].Time) {
|
||||
if requireIncreasingTimes && index > 0 && !point.Time.After(points[index-1].Time) {
|
||||
return nil, fmt.Errorf("geojson: timed line times must be strictly increasing")
|
||||
}
|
||||
}
|
||||
|
||||
+2175
-7
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestLunarGeoJSONUsesTopocentricHorizon(t *testing.T) {
|
||||
date := time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC)
|
||||
info, ok := eclipse.LunarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatal("missing lunar eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalLunarEclipse(info, 360)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
for _, contact := range []struct {
|
||||
role, horizon string
|
||||
at time.Time
|
||||
}{
|
||||
{"visible-at-p1", "p1-horizon", info.PenumbralStart},
|
||||
{"visible-at-p4", "p4-horizon", info.PenumbralEnd},
|
||||
} {
|
||||
band := featureWithRole(t, collection, contact.role)
|
||||
line := featureWithRole(t, collection, contact.horizon)
|
||||
var segments [][][]float64
|
||||
if err := json.Unmarshal(line.Geometry.Coordinates, &segments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jd := basic.Date2JDE(contact.at.UTC())
|
||||
for _, segment := range segments {
|
||||
for _, point := range segment {
|
||||
if math.Abs(point[0]) == 180 {
|
||||
continue
|
||||
}
|
||||
if altitude := basic.HMoonHeight(jd, point[0], point[1], 0); math.Abs(altitude) > 1e-8 {
|
||||
t.Fatalf("horizon altitude=%g", altitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
for lon := -175.; lon < 180; lon += 10 {
|
||||
for lat := -85.; lat < 90; lat += 10 {
|
||||
altitude := basic.HMoonHeight(jd, lon, lat, 0)
|
||||
if math.Abs(altitude) < 0.05 {
|
||||
continue
|
||||
}
|
||||
if inside := geometryContainsPoint(t, band.Geometry, lon, lat); inside != (altitude > 0) {
|
||||
t.Fatalf("%s point=(%v,%v) inside=%v altitude=%v", contact.role, lon, lat, inside, altitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLunarGeoJSON19040924DoesNotFillFalseSouthPolarCap(t *testing.T) {
|
||||
date := time.Date(1904, 9, 24, 0, 0, 0, 0, time.UTC)
|
||||
info, ok := eclipse.LunarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatal("missing lunar eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalLunarEclipse(info, 360)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
band := featureWithRole(t, collection, "visible-at-p1")
|
||||
point := struct {
|
||||
longitude float64
|
||||
latitude float64
|
||||
}{-115, -89.9}
|
||||
if altitude := basic.HMoonHeight(basic.Date2JDE(info.PenumbralStart), point.longitude, point.latitude, 0); altitude >= -0.01 {
|
||||
t.Fatalf("regression witness altitude=%g, want below horizon", altitude)
|
||||
}
|
||||
if geometryContainsPoint(t, band.Geometry, point.longitude, point.latitude) {
|
||||
t.Fatalf("visible-at-p1 contains below-horizon polar witness %+v", point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLunarGeoJSONPolarVisibilityGrid(t *testing.T) {
|
||||
for _, day := range []string{
|
||||
"0275-09-22", "0386-09-24", "1076-09-15", "1904-09-24", "2396-03-25",
|
||||
"2779-03-24", "2955-09-23", "3188-09-27", "3738-03-19", "4026-03-16",
|
||||
} {
|
||||
t.Run(day, func(t *testing.T) {
|
||||
date, err := time.Parse("2006-01-02", day)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, ok := eclipse.LunarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatal("missing lunar eclipse")
|
||||
}
|
||||
counts := []int{360}
|
||||
if day == "1904-09-24" {
|
||||
counts = []int{12, 96, 360, 1440}
|
||||
}
|
||||
for _, count := range counts {
|
||||
t.Run(fmt.Sprint(count), func(t *testing.T) {
|
||||
assertLunarVisibilityGrid(t, info, count)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertLunarVisibilityGrid(t *testing.T, info eclipse.LunarEclipseInfo, count int) {
|
||||
t.Helper()
|
||||
data, err := geojson.MarshalLunarEclipse(info, count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
latitudes := []float64{-89.999999, -89.999, -89.99, -89.9, -89.5, 89.5, 89.9, 89.99, 89.999, 89.999999}
|
||||
for lat := -89.0; lat <= 89; lat += 2 {
|
||||
latitudes = append(latitudes, lat)
|
||||
}
|
||||
for _, contact := range []struct {
|
||||
role string
|
||||
at time.Time
|
||||
}{{"visible-at-p1", info.PenumbralStart}, {"visible-at-p4", info.PenumbralEnd}} {
|
||||
band := featureWithRole(t, collection, contact.role)
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jd := basic.Date2JDE(contact.at.UTC())
|
||||
visible, invisible := 0, 0
|
||||
for _, lat := range latitudes {
|
||||
for lon := -179.5; lon < 180; lon += 5 {
|
||||
altitude := basic.HMoonHeight(jd, lon, lat, 0)
|
||||
tolerance := 0.003
|
||||
if math.Abs(lat) > 89.99 {
|
||||
tolerance = 1e-5
|
||||
}
|
||||
if math.Abs(altitude) <= tolerance {
|
||||
continue
|
||||
}
|
||||
inside := geoJSONMultiPolygonContains(polygons, lon, lat)
|
||||
if inside != (altitude > 0) {
|
||||
t.Fatalf("%s point=(%g,%g) inside=%v altitude=%g", contact.role, lon, lat, inside, altitude)
|
||||
}
|
||||
if inside {
|
||||
visible++
|
||||
} else {
|
||||
invisible++
|
||||
}
|
||||
}
|
||||
}
|
||||
if visible == 0 || invisible == 0 {
|
||||
t.Fatalf("%s grid must exercise both sides: visible=%d invisible=%d", contact.role, visible, invisible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLunarEclipseGeoJSON(b *testing.B) {
|
||||
for _, day := range []string{"1904-09-24", "2026-03-03", "4026-03-16"} {
|
||||
date, err := time.Parse("2006-01-02", day)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
info, ok := eclipse.LunarEclipseOnDate(date)
|
||||
if !ok {
|
||||
b.Fatal("missing lunar eclipse")
|
||||
}
|
||||
b.Run(day, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for iteration := 0; iteration < b.N; iteration++ {
|
||||
if _, err := geojson.MarshalLunarEclipse(info, 360); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
// TestLunarGeoJSONDropsAntimeridianSlivers 固定月食可见区的环卫生契约:日界线剪裁会在相邻
|
||||
// 世界各输出一次"全部顶点落在同一条子午线、平面面积只剩浮点噪声"的零宽薄片(实测
|
||||
// 1.8e-12 deg²,刚好越过共享剪裁器 1e-12 的零面积阈值),导出时必须在月食这一层丢弃,
|
||||
// 同时不能连带丢掉有面积的面。
|
||||
// TestLunarGeoJSONDropsAntimeridianSlivers pins the ring-hygiene contract of the lunar
|
||||
// visibility export: the antimeridian split emits one zero-width sliver per adjacent world whose
|
||||
// vertices all sit on one meridian with a planar area of floating-point noise (measured
|
||||
// 1.8e-12 deg^2, just past the shared splitter's 1e-12 zero-area floor). The lunar export must
|
||||
// drop it without dropping polygons that do have area.
|
||||
func TestLunarGeoJSONDropsAntimeridianSlivers(t *testing.T) {
|
||||
for _, day := range []string{"2022-11-08", "2026-03-03", "4026-03-16", "1904-09-24", "2025-03-14"} {
|
||||
t.Run(day, func(t *testing.T) {
|
||||
date, err := time.Parse("2006-01-02", day)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, ok := eclipse.LunarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatal("missing lunar eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalLunarEclipse(info, 360)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
for _, role := range []string{"visible-at-p1", "visible-at-p4"} {
|
||||
feature := featureWithRole(t, collection, role)
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(polygons) == 0 {
|
||||
t.Fatalf("%s has no polygons", role)
|
||||
}
|
||||
for index, polygon := range polygons {
|
||||
area := 0.0
|
||||
for _, ring := range polygon {
|
||||
if len(ring) < 4 {
|
||||
t.Fatalf("%s polygon %d ring has %d positions", role, index, len(ring))
|
||||
}
|
||||
distinct := 0
|
||||
for position, point := range ring {
|
||||
if position == 0 || math.Abs(point[0]-ring[position-1][0]) > 1e-9 ||
|
||||
math.Abs(point[1]-ring[position-1][1]) > 1e-9 {
|
||||
distinct++
|
||||
}
|
||||
}
|
||||
if distinct > 1 &&
|
||||
math.Abs(ring[0][0]-ring[len(ring)-1][0]) <= 1e-9 &&
|
||||
math.Abs(ring[0][1]-ring[len(ring)-1][1]) <= 1e-9 {
|
||||
// 闭合点不是独立顶点 / The closing position is not a distinct vertex.
|
||||
distinct--
|
||||
}
|
||||
if distinct < 3 {
|
||||
t.Fatalf("%s polygon %d is a zero-width sliver: %v", role, index, ring)
|
||||
}
|
||||
for position := 0; position+1 < len(ring); position++ {
|
||||
area += ring[position][0]*ring[position+1][1] - ring[position+1][0]*ring[position][1]
|
||||
}
|
||||
}
|
||||
if math.Abs(area/2) < 1e-12 {
|
||||
t.Fatalf("%s polygon %d has no area: %v", role, index, polygon)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
type mars20250729FixtureKey struct {
|
||||
riseSetStep time.Duration
|
||||
includeTimeline bool
|
||||
}
|
||||
|
||||
type mars20250729Fixture struct {
|
||||
path moon.PlanetOccultationPath
|
||||
collection decodedCollection
|
||||
}
|
||||
|
||||
var mars20250729Fixtures = struct {
|
||||
sync.Mutex
|
||||
values map[mars20250729FixtureKey]*mars20250729Fixture
|
||||
}{values: make(map[mars20250729FixtureKey]*mars20250729Fixture)}
|
||||
|
||||
// mars20250729TestFixture computes each distinct Mars regression scenario once.
|
||||
// The returned path and decoded collection are read-only test fixtures; caching
|
||||
// them avoids repeating the expensive ephemeris and GeoJSON setup in related
|
||||
// assertions without sharing mutable production state.
|
||||
func mars20250729TestFixture(
|
||||
t *testing.T,
|
||||
riseSetStep time.Duration,
|
||||
includeTimeline bool,
|
||||
) *mars20250729Fixture {
|
||||
t.Helper()
|
||||
key := mars20250729FixtureKey{
|
||||
riseSetStep: riseSetStep,
|
||||
includeTimeline: includeTimeline,
|
||||
}
|
||||
|
||||
mars20250729Fixtures.Lock()
|
||||
defer mars20250729Fixtures.Unlock()
|
||||
if fixture := mars20250729Fixtures.values[key]; fixture != nil {
|
||||
return fixture
|
||||
}
|
||||
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
options := moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute,
|
||||
TargetSpacingKM: 900,
|
||||
RiseSetStep: riseSetStep,
|
||||
DisableFootprints: true,
|
||||
}
|
||||
if includeTimeline {
|
||||
options.IncludeFootprintTimeline = true
|
||||
options.FootprintTimelineStep = 5 * time.Minute
|
||||
}
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), moon.OccultationMars, options,
|
||||
)
|
||||
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)
|
||||
}
|
||||
fixture := &mars20250729Fixture{
|
||||
path: paths[0],
|
||||
collection: decodeCollection(t, data),
|
||||
}
|
||||
mars20250729Fixtures.values[key] = fixture
|
||||
return fixture
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
func TestMars20250729RenderedLineworkUsesProjectedSpacing(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
for _, role := range []string{"partial-band", "total-band", "band-outline", "total-band-outline"} {
|
||||
feature := featureWithRole(t, collection, role)
|
||||
if maximum := maxProjectedGeometryEdgeKM(feature.Geometry.Coordinates); maximum > 50 {
|
||||
t.Fatalf("%s projected edge=%.1f km, want <=50 km", role, maximum)
|
||||
}
|
||||
}
|
||||
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
|
||||
if maximum := maxProjectedGeometryEdgeKM(feature.Geometry.Coordinates); maximum > 40 {
|
||||
t.Fatalf("visibility-boundary projected edge=%.1f km, want <=40 km", maximum)
|
||||
}
|
||||
}
|
||||
for _, feature := range featuresWithRole(collection, "horizon-connector") {
|
||||
if maximum := maxProjectedGeometryEdgeKM(feature.Geometry.Coordinates); maximum > 40 {
|
||||
t.Fatalf("horizon-connector projected edge=%.1f km, want <=40 km", maximum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMars20250729AuthoritativeBandsRejectPolarBacktracks(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
for _, role := range []string{"partial-band", "total-band"} {
|
||||
for ringIndex, ring := range geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, role)) {
|
||||
for pointIndex := 1; pointIndex+1 < len(ring); pointIndex++ {
|
||||
if geoPointDistanceKM(ring[pointIndex-1], ring[pointIndex+1]) > 20 {
|
||||
} else {
|
||||
angle := geoJSONRingTurnDegrees(ring[pointIndex-1], ring[pointIndex], ring[pointIndex+1])
|
||||
if angle < 30 {
|
||||
t.Fatalf("%s ring %d retains a %.2f degree polar backtrack at point %d", role, ringIndex, angle, pointIndex)
|
||||
}
|
||||
}
|
||||
|
||||
previous, middle, next := ring[pointIndex-1], ring[pointIndex], ring[pointIndex+1]
|
||||
if math.Abs(middle.Latitude) < 60 ||
|
||||
(middle.Latitude-previous.Latitude)*(next.Latitude-middle.Latitude) >= 0 ||
|
||||
projectedGeoJSONPointDistanceKM(previous, next) > 80 {
|
||||
continue
|
||||
}
|
||||
if angle := projectedGeoJSONRingTurnDegrees(previous, middle, next); angle < 110 {
|
||||
t.Fatalf("%s ring %d retains a %.2f degree projected sweep junction at point %d", role, ringIndex, angle, pointIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMars20250729OuterPhaseEnvelopeSharesBandOutline(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
outline := featureWithRole(t, collection, "band-outline")
|
||||
var outlineLines [][][]float64
|
||||
if err := json.Unmarshal(outline.Geometry.Coordinates, &outlineLines); err != nil {
|
||||
t.Fatalf("decode band-outline: %v", err)
|
||||
}
|
||||
if len(outlineLines) != 1 || len(outlineLines[0]) < 1000 {
|
||||
t.Fatalf("band-outline has %d lines and %d points, want one retained outer ring", len(outlineLines), len(outlineLines[0]))
|
||||
}
|
||||
maximumMatchedFraction := 0.0
|
||||
maximumSourceArcKM := 0.0
|
||||
for _, boundary := range featuresWithRole(collection, "visibility-boundary") {
|
||||
phase, _ := boundary.Properties["phase"].(string)
|
||||
if phase != "start" && phase != "end" {
|
||||
continue
|
||||
}
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode visibility-boundary: %v", err)
|
||||
}
|
||||
for _, line := range lines {
|
||||
if len(line) < 2 {
|
||||
continue
|
||||
}
|
||||
arc := 0.0
|
||||
matched := 0
|
||||
for index, point := range line {
|
||||
if index > 0 {
|
||||
arc += geoJSONCoordinateDistanceKM(line[index-1], point)
|
||||
}
|
||||
if geoPointLineDistanceKM(
|
||||
geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}, outlineLines,
|
||||
) <= 0.5 {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
if arc > maximumSourceArcKM {
|
||||
maximumSourceArcKM = arc
|
||||
maximumMatchedFraction = float64(matched) / float64(len(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
if maximumSourceArcKM < 3000 || maximumMatchedFraction < 0.98 {
|
||||
t.Fatalf("outer phase envelope matched fraction=%.3f over %.1f km, want >=.98 over the long exterior arc",
|
||||
maximumMatchedFraction, maximumSourceArcKM)
|
||||
}
|
||||
}
|
||||
|
||||
func geoJSONRingTurnDegrees(first, middle, last geodata.GeoPoint) float64 {
|
||||
latitude := middle.Latitude * math.Pi / 180
|
||||
scale := math.Cos(latitude)
|
||||
firstX := (first.Longitude - middle.Longitude) * scale
|
||||
firstY := first.Latitude - middle.Latitude
|
||||
lastX := (last.Longitude - middle.Longitude) * scale
|
||||
lastY := last.Latitude - middle.Latitude
|
||||
firstLength := math.Hypot(firstX, firstY)
|
||||
lastLength := math.Hypot(lastX, lastY)
|
||||
if firstLength <= 1e-12 || lastLength <= 1e-12 {
|
||||
return 180
|
||||
}
|
||||
cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength)
|
||||
cosine = math.Max(-1, math.Min(1, cosine))
|
||||
return math.Acos(cosine) * 180 / math.Pi
|
||||
}
|
||||
|
||||
func projectedGeoJSONRingTurnDegrees(first, middle, last geodata.GeoPoint) float64 {
|
||||
firstX, firstY := projectedGeoJSONPoint(first)
|
||||
middleX, middleY := projectedGeoJSONPoint(middle)
|
||||
lastX, lastY := projectedGeoJSONPoint(last)
|
||||
firstX, firstY = firstX-middleX, firstY-middleY
|
||||
lastX, lastY = lastX-middleX, lastY-middleY
|
||||
firstLength := math.Hypot(firstX, firstY)
|
||||
lastLength := math.Hypot(lastX, lastY)
|
||||
if firstLength <= 1e-12 || lastLength <= 1e-12 {
|
||||
return 180
|
||||
}
|
||||
cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength)
|
||||
cosine = math.Max(-1, math.Min(1, cosine))
|
||||
return math.Acos(cosine) * 180 / math.Pi
|
||||
}
|
||||
|
||||
func projectedGeoJSONPointDistanceKM(first, second geodata.GeoPoint) float64 {
|
||||
firstX, firstY := projectedGeoJSONPoint(first)
|
||||
secondX, secondY := projectedGeoJSONPoint(second)
|
||||
return math.Hypot(secondX-firstX, secondY-firstY)
|
||||
}
|
||||
|
||||
func projectedGeoJSONPoint(point geodata.GeoPoint) (float64, float64) {
|
||||
latitude := math.Max(-85.05112878, math.Min(85.05112878, point.Latitude)) * math.Pi / 180
|
||||
return 6378.1366 * point.Longitude * math.Pi / 180,
|
||||
6378.1366 * math.Log(math.Tan(math.Pi/4+latitude/2))
|
||||
}
|
||||
|
||||
func maxProjectedGeometryEdgeKM(raw json.RawMessage) float64 {
|
||||
var value interface{}
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return maxProjectedGeometryValueEdgeKM(value)
|
||||
}
|
||||
|
||||
func maxProjectedGeometryValueEdgeKM(value interface{}) float64 {
|
||||
array, ok := value.([]interface{})
|
||||
if !ok || len(array) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(array) >= 2 {
|
||||
if _, ok := array[0].(float64); ok {
|
||||
return 0
|
||||
}
|
||||
if _, ok := array[0].([]interface{}); ok {
|
||||
if first, ok := array[0].([]interface{}); ok && len(first) >= 2 {
|
||||
if _, ok := first[0].(float64); ok {
|
||||
maximum := 0.0
|
||||
for index := 1; index < len(array); index++ {
|
||||
previous := array[index-1].([]interface{})
|
||||
current := array[index].([]interface{})
|
||||
maximum = math.Max(maximum, projectedCoordinateDistanceKM(previous, current))
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
maximum := 0.0
|
||||
for _, child := range array {
|
||||
maximum = math.Max(maximum, maxProjectedGeometryValueEdgeKM(child))
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
func projectedCoordinateDistanceKM(first, second []interface{}) float64 {
|
||||
longitudeFirst := first[0].(float64)
|
||||
latitudeFirst := math.Max(-85.05112878, math.Min(85.05112878, first[1].(float64))) * math.Pi / 180
|
||||
longitudeSecond := second[0].(float64)
|
||||
latitudeSecond := math.Max(-85.05112878, math.Min(85.05112878, second[1].(float64))) * math.Pi / 180
|
||||
longitude := math.Remainder(longitudeSecond-longitudeFirst, 360) * math.Pi / 180
|
||||
firstY := math.Log(math.Tan(math.Pi/4 + latitudeFirst/2))
|
||||
secondY := math.Log(math.Tan(math.Pi/4 + latitudeSecond/2))
|
||||
return 6378.1366 * math.Hypot(longitude, secondY-firstY)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/internal/occultationgeo"
|
||||
)
|
||||
|
||||
func TestMars20250729TotalBandIsSingleMergedFace(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
totalBand := featureWithRole(t, collection, "total-band")
|
||||
rings := geoJSONMultiPolygonOuterRings(t, totalBand)
|
||||
if len(rings) != 1 {
|
||||
t.Fatalf("total-band polygons=%d, want one merged face", len(rings))
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
probe geodata.GeoPoint
|
||||
inside bool
|
||||
}{
|
||||
{name: "visible-sweep", probe: geodata.GeoPoint{Longitude: -129.8230, Latitude: -77.4030}, inside: true},
|
||||
{name: "selected-west", probe: geodata.GeoPoint{Longitude: -134.6060, Latitude: -78.5601}, inside: true},
|
||||
{name: "selected-center", probe: geodata.GeoPoint{Longitude: -134.2300, Latitude: -78.0725}, inside: true},
|
||||
} {
|
||||
inside := geodata.SphericalPolygonsContainPoints(rings, []geodata.GeoPoint{test.probe})[0]
|
||||
if inside != test.inside {
|
||||
t.Fatalf("total-band probe %s (%.4f, %.4f) inside=%v, want %v", test.name, test.probe.Longitude, test.probe.Latitude, inside, test.inside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMars20250729FinalTotalBandHasWidePolarShoulderRepair(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
partial := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "partial-band"))
|
||||
total := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band"))
|
||||
|
||||
// The final GeoJSON pass must already have applied the total-only broad
|
||||
// polar repair. Reapplying it should therefore be geometrically inert.
|
||||
rerounded := occultationgeo.RoundAuthoritativeTotalBandJunctions(total)
|
||||
remainingRepairKM := math.Max(
|
||||
geodata.SphericalPolygonsPathMissDistanceKM(total, rerounded, true),
|
||||
geodata.SphericalPolygonsPathMissDistanceKM(rerounded, total, true),
|
||||
)
|
||||
if remainingRepairKM > 1 {
|
||||
t.Fatalf("final total-band still has a %.1f km repairable polar shoulder", remainingRepairKM)
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 1 {
|
||||
t.Fatalf("smoothed total-band lies %.1f km outside partial-band", miss)
|
||||
}
|
||||
|
||||
roundedPartial := occultationgeo.RoundAuthoritativeBandJunctions(partial)
|
||||
partialChangeKM := math.Max(
|
||||
geodata.SphericalPolygonsPathMissDistanceKM(partial, roundedPartial, true),
|
||||
geodata.SphericalPolygonsPathMissDistanceKM(roundedPartial, partial, true),
|
||||
)
|
||||
if partialChangeKM > 1 {
|
||||
t.Fatalf("final partial-band unexpectedly needs %.1f km of further repair", partialChangeKM)
|
||||
}
|
||||
}
|
||||
+1303
-67
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestOccultationDistantEnvelopesRemainComplete(t *testing.T) {
|
||||
if err := basic.LoadStarData(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
year, month, day int
|
||||
planet moon.OccultationPlanet
|
||||
hr int
|
||||
}{
|
||||
{"mercury", 26, 4, 4, moon.OccultationMercury, 0},
|
||||
{"aldebaran", 26, 7, 26, "", 1457},
|
||||
{"regulus", 4026, 12, 14, "", 3982},
|
||||
{"saturn", 4026, 12, 29, moon.OccultationSaturn, 0},
|
||||
{"mars-fold", 1426, 7, 1, moon.OccultationMars, 0},
|
||||
{"venus-tangent", 1227, 5, 19, moon.OccultationVenus, 0},
|
||||
{"jupiter-horizon", 1226, 12, 23, moon.OccultationJupiter, 0},
|
||||
{"jupiter-time", 3627, 9, 20, moon.OccultationJupiter, 0},
|
||||
} {
|
||||
for _, algorithm := range []moon.OccultationPathAlgorithm{moon.OccultationPathAlgorithmExact, moon.OccultationPathAlgorithmOptimized} {
|
||||
t.Run(test.name+"/"+string(algorithm), func(t *testing.T) {
|
||||
day := time.Date(test.year, time.Month(test.month), test.day, 0, 0, 0, 0, time.UTC)
|
||||
options := moon.OccultationPathOptions{Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute}
|
||||
var data []byte
|
||||
var err error
|
||||
role := "partial-band"
|
||||
if test.hr == 0 {
|
||||
paths, findErr := moon.FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), test.planet, options)
|
||||
if findErr != nil || len(paths) != 1 {
|
||||
t.Fatalf("paths=%d err=%v", len(paths), findErr)
|
||||
}
|
||||
data, err = geojson.MarshalPlanetOccultation(paths[0])
|
||||
} else {
|
||||
catalog, catalogErr := basic.StarDataByHR(test.hr)
|
||||
if catalogErr != nil {
|
||||
t.Fatal(catalogErr)
|
||||
}
|
||||
star, starErr := moon.StarCoordinateFromStarData(catalog)
|
||||
if starErr != nil {
|
||||
t.Fatal(starErr)
|
||||
}
|
||||
paths, findErr := moon.FindStarOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), star, options)
|
||||
if findErr != nil || len(paths) != 1 {
|
||||
t.Fatalf("paths=%d err=%v", len(paths), findErr)
|
||||
}
|
||||
data, err = geojson.MarshalStarOccultation(paths[0])
|
||||
role = "occultation-band"
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertCollectionCoordinates(t, collection)
|
||||
band := featureWithRole(t, collection, role)
|
||||
assertClosedMultiPolygon(t, band)
|
||||
if test.hr == 0 {
|
||||
assertOccultationP2PhaseCurvesInsidePartialBand(t, collection, 2)
|
||||
partial := geoJSONMultiPolygonOuterRings(t, band)
|
||||
total := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band"))
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 2 {
|
||||
t.Fatalf("total extends %.3f km beyond partial", miss)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestMarshalPlanetOccultationFootprintsUsesTimelineRoles(t *testing.T) {
|
||||
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
|
||||
events, err := moon.FindBestPlanetOccultations(
|
||||
start, start.Add(24*time.Hour), moon.OccultationSaturn, moon.OccultationSearchOptions{MaxEvents: 1},
|
||||
)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err)
|
||||
}
|
||||
at := events[0].Greatest
|
||||
instant, err := moon.PlanetOccultationFootprintsAt(at, moon.OccultationSaturn)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationFootprintsAt: %v", err)
|
||||
}
|
||||
raw, err := geojson.MarshalPlanetOccultationFootprints(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultationFootprints: %v", err)
|
||||
}
|
||||
roles, times := instantFootprintRoles(t, raw)
|
||||
if roles["partial-footprint"] != 1 || roles["total-footprint"] != 1 {
|
||||
t.Fatalf("roles=%v, want one partial and one total footprint", roles)
|
||||
}
|
||||
for role, value := range times {
|
||||
if value != at.UTC().Format(time.RFC3339Nano) {
|
||||
t.Fatalf("%s time=%q, want exact requested instant", role, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationFootprintUsesTimelineRole(t *testing.T) {
|
||||
star := moon.StarCoordinate{
|
||||
ID: "Antares", RA: 247.35166667, Dec: -26.43194444,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
}
|
||||
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC)
|
||||
events, err := moon.FindBestStarOccultations(
|
||||
start, start.Add(24*time.Hour), star, moon.OccultationSearchOptions{MaxEvents: 1},
|
||||
)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("FindBestStarOccultations events=%d err=%v, want one", len(events), err)
|
||||
}
|
||||
instant, err := moon.StarOccultationFootprintAt(events[0].Greatest, star)
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationFootprintAt: %v", err)
|
||||
}
|
||||
if instant.Footprint == nil {
|
||||
t.Fatal("stellar footprint is nil at the event instant")
|
||||
}
|
||||
raw, err := geojson.MarshalStarOccultationFootprint(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultationFootprint: %v", err)
|
||||
}
|
||||
roles, _ := instantFootprintRoles(t, raw)
|
||||
if roles["occultation-footprint"] != 1 {
|
||||
t.Fatalf("roles=%v, want one stellar footprint", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalOccultationInstantOutsideEventReturnsEmptyCollection(t *testing.T) {
|
||||
at := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
marshal func() ([]byte, error)
|
||||
}{
|
||||
{
|
||||
name: "star",
|
||||
marshal: func() ([]byte, error) {
|
||||
return geojson.MarshalStarOccultationFootprint(moon.StarOccultationInstant{
|
||||
Time: at, TargetID: "outside-event",
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "planet",
|
||||
marshal: func() ([]byte, error) {
|
||||
return geojson.MarshalPlanetOccultationFootprints(moon.PlanetOccultationInstant{
|
||||
Time: at, Planet: moon.OccultationSaturn, TargetID: "Saturn",
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
raw, err := test.marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal outside-event footprint: %v", err)
|
||||
}
|
||||
roles, _ := instantFootprintRoles(t, raw)
|
||||
if len(roles) != 0 {
|
||||
t.Fatalf("roles=%v, want empty FeatureCollection", roles)
|
||||
}
|
||||
var collection struct {
|
||||
Features []json.RawMessage `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatalf("decode empty FeatureCollection: %v", err)
|
||||
}
|
||||
if collection.Features == nil {
|
||||
t.Fatal("features is null, want an empty JSON array")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func instantFootprintRoles(t *testing.T, raw []byte) (map[string]int, map[string]string) {
|
||||
t.Helper()
|
||||
var collection struct {
|
||||
Features []struct {
|
||||
Properties map[string]interface{} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatalf("decode GeoJSON: %v", err)
|
||||
}
|
||||
roles := make(map[string]int)
|
||||
times := make(map[string]string)
|
||||
for _, feature := range collection.Features {
|
||||
role, _ := feature.Properties["role"].(string)
|
||||
roles[role]++
|
||||
times[role], _ = feature.Properties["time"].(string)
|
||||
}
|
||||
return roles, times
|
||||
}
|
||||
|
||||
func TestMarshalPlanetOccultationFootprintsCarriesInstantMetadata(t *testing.T) {
|
||||
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
|
||||
events, err := moon.FindBestPlanetOccultations(
|
||||
start, start.Add(24*time.Hour), moon.OccultationSaturn, moon.OccultationSearchOptions{MaxEvents: 1},
|
||||
)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err)
|
||||
}
|
||||
instant, err := moon.PlanetOccultationFootprintsAt(events[0].Greatest, moon.OccultationSaturn)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationFootprintsAt: %v", err)
|
||||
}
|
||||
raw, err := geojson.MarshalPlanetOccultationFootprints(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultationFootprints: %v", err)
|
||||
}
|
||||
var collection struct {
|
||||
Features []struct {
|
||||
Properties map[string]interface{} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(collection.Features) == 0 {
|
||||
t.Fatal("no instant features")
|
||||
}
|
||||
if instant.DeltaTSeconds <= 0 || instant.SublunarLongitude == 0 && instant.SublunarLatitude == 0 {
|
||||
t.Fatalf("instant metadata missing: ΔT=%.3f sublunar=(%.3f,%.3f)",
|
||||
instant.DeltaTSeconds, instant.SublunarLongitude, instant.SublunarLatitude)
|
||||
}
|
||||
for _, feature := range collection.Features {
|
||||
properties := feature.Properties
|
||||
deltaT, ok := properties["delta_t_seconds"].(float64)
|
||||
if !ok || deltaT <= 0 {
|
||||
t.Fatalf("delta_t_seconds=%v, want a positive value", properties["delta_t_seconds"])
|
||||
}
|
||||
if signature, _ := properties["interp_signature"].(string); signature == "" || signature == "empty" {
|
||||
t.Fatalf("interp_signature=%v, want a footprint signature", properties["interp_signature"])
|
||||
}
|
||||
closed, present := properties["source_boundary_closed"].(bool)
|
||||
if !present {
|
||||
t.Fatal("source_boundary_closed is missing")
|
||||
}
|
||||
closure, hasClosure := properties["closure"].(map[string]interface{})
|
||||
if closed {
|
||||
if hasClosure {
|
||||
t.Fatal("self-closed occultation footprint must not carry a closure")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !hasClosure || closure["kind"] != "target-horizon" || closure["body"] != "moon" {
|
||||
t.Fatalf("closure=%v, want kind=target-horizon body=moon", properties["closure"])
|
||||
}
|
||||
if _, present := closure["sublunar"]; !present {
|
||||
t.Fatal("target-horizon closure is missing the sublunar reference")
|
||||
}
|
||||
if properties["geometry_role"] != "horizon-closed-region" {
|
||||
t.Fatalf("geometry_role=%v, want horizon-closed-region", properties["geometry_role"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationFootprintHorizonCutCarriesClosure(t *testing.T) {
|
||||
star := moon.StarCoordinate{
|
||||
ID: "Antares", RA: 247.35166667, Dec: -26.43194444,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
}
|
||||
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC)
|
||||
events, err := moon.FindBestStarOccultations(start, start.Add(24*time.Hour), star, moon.OccultationSearchOptions{MaxEvents: 1})
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("FindBestStarOccultations events=%d err=%v, want one", len(events), err)
|
||||
}
|
||||
instant, err := moon.StarOccultationFootprintAt(events[0].Greatest.Add(-120*time.Minute), star)
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationFootprintAt: %v", err)
|
||||
}
|
||||
if instant.Footprint == nil {
|
||||
t.Fatal("expected a footprint inside the event window")
|
||||
}
|
||||
if instant.Footprint.Closed {
|
||||
t.Skip("this instant is self-closed; the horizon-cut branch is covered elsewhere")
|
||||
}
|
||||
raw, err := geojson.MarshalStarOccultationFootprint(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultationFootprint: %v", err)
|
||||
}
|
||||
var collection struct {
|
||||
Features []struct {
|
||||
Properties map[string]interface{} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(collection.Features) != 1 {
|
||||
t.Fatalf("features=%d, want one", len(collection.Features))
|
||||
}
|
||||
closure, ok := collection.Features[0].Properties["closure"].(map[string]interface{})
|
||||
if !ok || closure["kind"] != "target-horizon" || closure["body"] != "moon" {
|
||||
t.Fatalf("closure=%v, want kind=target-horizon body=moon", collection.Features[0].Properties["closure"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
// 导出属性必须同时给出 WidthKM(width_km)与限线间距(greatest_limit_separation_km),且两者口径不同。
|
||||
|
||||
func occultationLimitSeparationJSONValue(t *testing.T, feature decodedFeature, key string) float64 {
|
||||
t.Helper()
|
||||
raw, ok := feature.Properties[key]
|
||||
if !ok {
|
||||
t.Fatalf("feature role=%v has no %s property", feature.Properties["role"], key)
|
||||
}
|
||||
value, ok := raw.(float64)
|
||||
if !ok || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
t.Fatalf("feature role=%v %s=%v, want a finite number", feature.Properties["role"], key, raw)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestMarshalPlanetOccultationCarriesGreatestLimitSeparation(t *testing.T) {
|
||||
start := time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), moon.OccultationJupiter,
|
||||
moon.OccultationPathOptions{
|
||||
Step: 10 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, DisableRiseSet: true,
|
||||
},
|
||||
)
|
||||
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)
|
||||
var greatestWidth, limitSeparation float64
|
||||
foundSeparation, foundGreatest := false, false
|
||||
for _, feature := range collection.Features {
|
||||
if value := occultationLimitSeparationJSONValue(t, feature, "greatest_limit_separation_km"); !foundSeparation {
|
||||
limitSeparation = value
|
||||
foundSeparation = true
|
||||
}
|
||||
if feature.Properties["role"] == "greatest" {
|
||||
greatestWidth = occultationLimitSeparationJSONValue(t, feature, "width_km")
|
||||
foundGreatest = true
|
||||
}
|
||||
}
|
||||
if !foundSeparation || !foundGreatest {
|
||||
t.Fatalf("limit separation=%v greatest width=%v, want both properties present", foundSeparation, foundGreatest)
|
||||
}
|
||||
if math.Abs(limitSeparation-3593.197135) > 0.5 {
|
||||
t.Fatalf("greatest_limit_separation_km=%.6f, want 3593.197135 within 0.5 km", limitSeparation)
|
||||
}
|
||||
if math.Abs(greatestWidth-5319.263900) > 0.5 {
|
||||
t.Fatalf("greatest width_km=%.6f, want 5319.263900 within 0.5 km", greatestWidth)
|
||||
}
|
||||
if math.Abs(greatestWidth-limitSeparation) < 1000 {
|
||||
t.Fatalf("width_km=%.3f and greatest_limit_separation_km=%.3f are too close, want the two distinct constructions",
|
||||
greatestWidth, limitSeparation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalPlanetOccultationRejectsNonFiniteLimitSeparation(t *testing.T) {
|
||||
start := time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), moon.OccultationJupiter,
|
||||
moon.OccultationPathOptions{
|
||||
Step: 10 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, DisableRiseSet: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
broken := paths[0]
|
||||
broken.NorthernLimit = append([]moon.OccultationPathPoint(nil), paths[0].NorthernLimit...)
|
||||
broken.NorthernLimit[1].LimitSeparationKM = math.NaN()
|
||||
if _, err := geojson.MarshalPlanetOccultation(broken); err == nil {
|
||||
t.Fatal("MarshalPlanetOccultation accepted a NaN limit separation on a limit point")
|
||||
}
|
||||
broken = paths[0]
|
||||
broken.GreatestLimitSeparationKM = math.Inf(1)
|
||||
if _, err := geojson.MarshalPlanetOccultation(broken); err == nil {
|
||||
t.Fatal("MarshalPlanetOccultation accepted a non-finite greatest limit separation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationCarriesGreatestLimitSeparation(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := moon.FindStarOccultationPaths(
|
||||
start, start.Add(24*time.Hour), hr4799OccultationCoordinateForGeoJSONLimitTest(),
|
||||
moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, DisableRiseSet: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
data, err := geojson.MarshalStarOccultation(paths[0])
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
value := occultationLimitSeparationJSONValue(t, collection.Features[0], "greatest_limit_separation_km")
|
||||
if math.Abs(value-3666.576690) > 0.5 {
|
||||
t.Fatalf("greatest_limit_separation_km=%.6f, want 3666.576690 within 0.5 km", value)
|
||||
}
|
||||
}
|
||||
|
||||
func hr4799OccultationCoordinateForGeoJSONLimitTest() moon.StarCoordinate {
|
||||
return moon.StarCoordinate{
|
||||
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -28,
|
||||
ProperMotionDecMasPerYear: -18,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestMarshalPlanetOccultationVenus20250919OptimizedContoursAreStrictlyOrdered(t *testing.T) {
|
||||
day := time.Date(2025, 9, 19, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
day.Add(-12*time.Hour), day.Add(36*time.Hour), moon.OccultationVenus,
|
||||
moon.OccultationPathOptions{
|
||||
Step: 5 * time.Minute,
|
||||
TargetSpacingKM: 300,
|
||||
DisableFootprints: true,
|
||||
RiseSetStep: 30 * time.Second,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths paths=%d err=%v, want one path", len(paths), err)
|
||||
}
|
||||
if len(paths[0].PartialBandContours) != 2 {
|
||||
t.Fatalf("partial contours=%d, want two", len(paths[0].PartialBandContours))
|
||||
}
|
||||
for contourIndex, contour := range paths[0].PartialBandContours {
|
||||
for index := 1; index < len(contour); index++ {
|
||||
if !contour[index].Time.After(contour[index-1].Time) {
|
||||
t.Fatalf("contour %d sample %d is not strictly increasing: %s then %s", contourIndex, index, contour[index-1].Time, contour[index].Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := geojson.MarshalPlanetOccultation(paths[0]); err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func BenchmarkP2Mars20250729PlanetOccultationFind(b *testing.B) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
options := moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute,
|
||||
TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute,
|
||||
DisableFootprints: true,
|
||||
IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for index := 0; index < b.N; index++ {
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), moon.OccultationMars, options,
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkP2Mars20250729PlanetOccultationGeoJSONMarshal(b *testing.B) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), moon.OccultationMars,
|
||||
moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute,
|
||||
TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute,
|
||||
DisableFootprints: true,
|
||||
IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
||||
if err != nil || len(data) == 0 {
|
||||
b.Fatalf("MarshalPlanetOccultation() bytes=%d err=%v", len(data), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkP2SolarEclipseGeoJSONMarshal(b *testing.B) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
||||
time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC),
|
||||
eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 10 * time.Minute, BoundaryPoints: 72, CentralShadowStep: 5 * time.Minute,
|
||||
DisableRiseSet: true,
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
b.Fatal("expected 2024-04-08 eclipse")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
||||
if err != nil || len(data) == 0 {
|
||||
b.Fatalf("MarshalSolarEclipse() bytes=%d err=%v", len(data), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkP2StarOccultationGeoJSONMarshal(b *testing.B) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
star := moon.StarCoordinate{
|
||||
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18,
|
||||
}
|
||||
paths, err := moon.FindStarOccultationPaths(
|
||||
time.Date(2025, time.June, 5, 0, 0, 0, 0, zone),
|
||||
time.Date(2025, time.June, 6, 0, 0, 0, 0, zone), star,
|
||||
moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
b.Fatalf("FindStarOccultationPaths() paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
data, err := geojson.MarshalStarOccultation(paths[0])
|
||||
if err != nil || len(data) == 0 {
|
||||
b.Fatalf("MarshalStarOccultation() bytes=%d err=%v", len(data), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkP2PlanetOccultationGeoJSONMarshal(b *testing.B) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
time.Date(2025, time.January, 5, 0, 0, 0, 0, zone),
|
||||
time.Date(2025, time.January, 6, 0, 0, 0, 0, zone), moon.OccultationSaturn,
|
||||
moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
||||
if err != nil || len(data) == 0 {
|
||||
b.Fatalf("MarshalPlanetOccultation() bytes=%d err=%v", len(data), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/internal/occultationgeo"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
// TestSolarEclipseP2SarosGeoJSONSamples serializes real eclipse paths at
|
||||
// evenly spaced Saros-family offsets around the current epoch. It is kept
|
||||
// intentionally smaller than the opt-in millennium diagnostic while still
|
||||
// exercising antimeridian, polar and non-central topology in normal tests.
|
||||
func TestSolarEclipseP2SarosGeoJSONSamples(t *testing.T) {
|
||||
const sarosDays = 6585.321314
|
||||
seed := basic.JDECalc(2024, 4, 8)
|
||||
for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} {
|
||||
familyIndex := familyIndex
|
||||
t.Run("saros-"+formatP2SignedIndex(familyIndex), func(t *testing.T) {
|
||||
date := basic.JDE2DateByZone(seed+float64(familyIndex)*sarosDays, time.UTC, false)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute,
|
||||
DisableRiseSet: true,
|
||||
})
|
||||
if !ok || !partial.Eclipse.HasPartial {
|
||||
t.Fatalf("solar eclipse unavailable at %s: ok=%v type=%s", date.Format("2006-01-02"), ok, partial.Eclipse.Type)
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse(%s): %v", date.Format("2006-01-02"), err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertCollectionCoordinates(t, collection)
|
||||
assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band"))
|
||||
if partial.Eclipse.Type == eclipse.SolarEclipsePartial {
|
||||
if len(featuresWithRole(collection, "central-band")) != 0 {
|
||||
t.Fatal("partial-only eclipse unexpectedly contains a central band")
|
||||
}
|
||||
} else {
|
||||
assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band"))
|
||||
}
|
||||
if len(featuresWithRole(collection, "greatest")) != 1 {
|
||||
t.Fatalf("greatest feature count=%d, want one", len(featuresWithRole(collection, "greatest")))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2RepresentativeGeoJSONPathsRemainClosed(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
star := moon.StarCoordinate{
|
||||
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18,
|
||||
}
|
||||
starCases := []struct {
|
||||
name string
|
||||
start time.Time
|
||||
}{
|
||||
{name: "hr4799-2025", start: time.Date(2025, time.June, 5, 0, 0, 0, 0, zone)},
|
||||
}
|
||||
for _, test := range starCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
paths, err := moon.FindStarOccultationPaths(test.start, test.start.Add(24*time.Hour), star,
|
||||
moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
data, err := geojson.MarshalStarOccultation(paths[0])
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertCollectionCoordinates(t, collection)
|
||||
assertClosedMultiPolygon(t, featureWithRole(t, collection, "occultation-band"))
|
||||
assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline"))
|
||||
assertRoles(t, collection, "occultation-band", "band-outline", "visibility-boundary", "center-line", "north-limit", "south-limit")
|
||||
})
|
||||
}
|
||||
|
||||
planetCases := []struct {
|
||||
name string
|
||||
start time.Time
|
||||
planet moon.OccultationPlanet
|
||||
}{
|
||||
{name: "venus-2025-09-19", start: time.Date(2025, time.September, 19, 0, 0, 0, 0, zone), planet: moon.OccultationVenus},
|
||||
{name: "saturn-2025-01-05", start: time.Date(2025, time.January, 5, 0, 0, 0, 0, zone), planet: moon.OccultationSaturn},
|
||||
{name: "mars-2025-06-30", start: time.Date(2025, time.June, 30, 0, 0, 0, 0, zone), planet: moon.OccultationMars},
|
||||
}
|
||||
for _, test := range planetCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
paths, err := moon.FindPlanetOccultationPaths(test.start, test.start.Add(24*time.Hour), test.planet,
|
||||
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)
|
||||
}
|
||||
data, err := geojson.MarshalPlanetOccultation(paths[0])
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertCollectionCoordinates(t, collection)
|
||||
assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band"))
|
||||
assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline"))
|
||||
assertOccultationP0ContactContourSource(t, featureWithRole(t, collection, "partial-band"))
|
||||
if paths[0].HasTotalBand {
|
||||
assertClosedMultiPolygon(t, featureWithRole(t, collection, "total-band"))
|
||||
assertClosedMultiLineFeature(t, featureWithRole(t, collection, "total-band-outline"))
|
||||
assertOccultationP0ContactContourSource(t, featureWithRole(t, collection, "total-band"))
|
||||
}
|
||||
assertRoles(t, collection, "partial-band", "band-outline", "visibility-boundary", "center-line", "north-limit", "south-limit")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Venus20250919HasNoInternalOutline(t *testing.T) {
|
||||
start := time.Date(2025, time.September, 19, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
|
||||
for _, algorithm := range []moon.OccultationPathAlgorithm{moon.OccultationPathAlgorithmOptimized, moon.OccultationPathAlgorithmExact} {
|
||||
t.Run(string(algorithm), func(t *testing.T) {
|
||||
paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationVenus,
|
||||
moon.OccultationPathOptions{
|
||||
Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, RiseSetStep: time.Minute,
|
||||
})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
data, err := geojson.MarshalPlanetOccultation(path)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertCollectionCoordinates(t, collection)
|
||||
for _, role := range []string{"partial-band", "total-band"} {
|
||||
feature := featureWithRole(t, collection, role)
|
||||
assertClosedMultiPolygon(t, feature)
|
||||
assertOccultationP0ContactContourSource(t, feature)
|
||||
rings := geoJSONMultiPolygonOuterRings(t, feature)
|
||||
if len(rings) != 1 {
|
||||
t.Errorf("%s rings=%d, want one continuous band without overlapping slivers", role, len(rings))
|
||||
}
|
||||
contours, curves, footprints := path.PartialBandContours, path.RiseSetCurves, path.PartialBandFootprints
|
||||
if role == "total-band" {
|
||||
contours, curves, footprints = path.TotalBandContours, path.TotalRiseSetCurves, path.TotalBandFootprints
|
||||
}
|
||||
lines := occultationP0AuthoritativeBoundaryLines(contours, curves, footprints)
|
||||
for _, ring := range rings {
|
||||
for _, point := range ring {
|
||||
// Test the physical southern boundary, not the map's pole/dateline closure.
|
||||
if point.Latitude < 10 && point.Longitude > 25 && point.Longitude < 40 {
|
||||
if miss := geoPointLineDistanceKM(point, lines); miss > 1 {
|
||||
t.Errorf("%s southern outline leaves source by %.3f km at %+v", role, miss, point)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertOccultationP2PhaseCurvesInsidePartialBand(t, collection, 1)
|
||||
partial := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "partial-band"))
|
||||
total := geoJSONMultiPolygonOuterRings(t, featureWithRole(t, collection, "total-band"))
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partial, total, true); miss > 1 {
|
||||
t.Fatalf("total band extends %.3f km beyond partial band", miss)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250114GeoJSONAcceptsFoldedBandContours(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.January, 14, 0, 0, 0, 0, zone)
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), moon.OccultationMars,
|
||||
moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
data, err := geojson.MarshalPlanetOccultationWithTimeMarkers(
|
||||
paths[0], geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: zone},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultationWithTimeMarkers: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertClosedMultiPolygon(t, featureWithRole(t, collection, "partial-band"))
|
||||
assertClosedMultiLineFeature(t, featureWithRole(t, collection, "band-outline"))
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729AddsMoonriseHorizonConnector(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, 5*time.Minute, true).collection
|
||||
assertOccultationHorizonConnectorsAreAuxiliary(t, collection)
|
||||
connector := horizonConnectorFeature(t, collection, "rise")
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode horizon connector coordinates: %v", err)
|
||||
}
|
||||
if len(lines) == 0 || len(lines) > 3 {
|
||||
t.Fatalf("horizon connector segment count=%d, want one to three selected transition segments", len(lines))
|
||||
}
|
||||
phaseEndpoints := mars20250729PhaseEndpoints(t, collection, "rise")
|
||||
foundOpeningConnector := false
|
||||
for _, line := range lines {
|
||||
if len(line) < 2 {
|
||||
continue
|
||||
}
|
||||
first, last := line[0], line[len(line)-1]
|
||||
chord := geoJSONCoordinateDistanceKM(first, last)
|
||||
if chord < 750 || chord > 1000 {
|
||||
continue
|
||||
}
|
||||
total := 0.0
|
||||
maximumStep := 0.0
|
||||
for index := 1; index < len(line); index++ {
|
||||
step := geoJSONCoordinateDistanceKM(line[index-1], line[index])
|
||||
total += step
|
||||
if step > maximumStep {
|
||||
maximumStep = step
|
||||
}
|
||||
}
|
||||
if total > 1.25*chord || maximumStep > 200 {
|
||||
t.Fatalf("moonrise horizon connector detours along the contact limb: total=%.1f chord=%.1f max-step=%.1f km", total, chord, maximumStep)
|
||||
}
|
||||
if !geoJSONCoordinateMatchesAny(line[0], phaseEndpoints) ||
|
||||
!geoJSONCoordinateMatchesAny(line[len(line)-1], phaseEndpoints) {
|
||||
t.Fatalf("moonrise horizon connector endpoints do not meet physical phase endpoints: first=%v last=%v",
|
||||
line[0], line[len(line)-1])
|
||||
}
|
||||
foundOpeningConnector = true
|
||||
break
|
||||
}
|
||||
if !foundOpeningConnector {
|
||||
t.Fatal("2025-07-29 Mars occultation is missing the moonrise horizon connector in the South Pacific opening region")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729OneMinuteRiseSetAddsOpeningConnector(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
connector := horizonConnectorFeature(t, collection, "rise")
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode horizon connector coordinates: %v", err)
|
||||
}
|
||||
startRise := riseSetBoundaryFeature(t, collection, "start", "rise")
|
||||
startRiseEndpoints := riseSetFeatureEndpoints(t, startRise)
|
||||
foundOpeningConnector := false
|
||||
for _, line := range lines {
|
||||
if len(line) < 2 {
|
||||
continue
|
||||
}
|
||||
total := 0.0
|
||||
for pointIndex, point := range line {
|
||||
if pointIndex > 0 {
|
||||
total += geoJSONCoordinateDistanceKM(line[pointIndex-1], point)
|
||||
}
|
||||
}
|
||||
chord := geoJSONCoordinateDistanceKM(line[0], line[len(line)-1])
|
||||
if chord < 250 || chord > 650 || total < 400 || total > 700 {
|
||||
continue
|
||||
}
|
||||
maximumStep := 0.0
|
||||
for index := 1; index < len(line); index++ {
|
||||
step := geoJSONCoordinateDistanceKM(line[index-1], line[index])
|
||||
if step > maximumStep {
|
||||
maximumStep = step
|
||||
}
|
||||
}
|
||||
if maximumStep > 120 {
|
||||
t.Fatalf("one-minute moonrise opening connector max-step=%.1f km, want smooth closure", maximumStep)
|
||||
}
|
||||
if !geoJSONCoordinateMatchesAny(line[0], startRiseEndpoints) ||
|
||||
!geoJSONCoordinateMatchesAny(line[len(line)-1], startRiseEndpoints) {
|
||||
t.Fatalf("one-minute moonrise opening connector endpoints do not meet start/rise phase endpoints: first=%v last=%v",
|
||||
line[0], line[len(line)-1])
|
||||
}
|
||||
foundOpeningConnector = true
|
||||
break
|
||||
}
|
||||
if !foundOpeningConnector {
|
||||
t.Fatal("2025-07-29 Mars one-minute rise/set GeoJSON is missing the opening moonrise connector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729DoesNotDuplicateSamePhaseFinalFold(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, 5*time.Minute, true).collection
|
||||
connector := horizonConnectorFeature(t, collection, "rise")
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(connector.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode horizon connector coordinates: %v", err)
|
||||
}
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("moonrise horizon connector segment count=%d, want only the opening closure; same-phase final fold must be part of end/rise",
|
||||
len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729StaticBandsStayAuthoritativeAndBounded(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
for _, role := range []string{"partial-band", "total-band"} {
|
||||
band := featureWithRole(t, collection, role)
|
||||
if authoritative, ok := band.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
|
||||
t.Fatalf("%s is not authoritative: properties=%v", role, band.Properties)
|
||||
}
|
||||
outlineRole := "band-outline"
|
||||
if role == "total-band" {
|
||||
outlineRole = "total-band-outline"
|
||||
}
|
||||
outline := featureWithRole(t, collection, outlineRole)
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(outline.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode %s: %v", outlineRole, err)
|
||||
}
|
||||
for lineIndex, line := range lines {
|
||||
if len(line) < 4 {
|
||||
t.Fatalf("%s line %d has %d points", outlineRole, lineIndex, len(line))
|
||||
}
|
||||
maximum := 0.0
|
||||
for pointIndex := 1; pointIndex < len(line); pointIndex++ {
|
||||
maximum = math.Max(maximum, geoJSONCoordinateDistanceKM(line[pointIndex-1], line[pointIndex]))
|
||||
}
|
||||
if maximum > 80 {
|
||||
t.Fatalf("%s line %d has an artificial long edge %.1f km", outlineRole, lineIndex, maximum)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalPlanetOccultation(t *testing.T, path moon.PlanetOccultationPath) []byte {
|
||||
t.Helper()
|
||||
data, err := geojson.MarshalPlanetOccultation(path)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729DrawsPhaseCurvesAboveStaticBandOutlines(t *testing.T) {
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
lastStaticOutline := -1
|
||||
firstPhaseCurve := len(collection.Features)
|
||||
for index, feature := range collection.Features {
|
||||
switch feature.Properties["role"] {
|
||||
case "band-outline", "total-band-outline":
|
||||
if index > lastStaticOutline {
|
||||
lastStaticOutline = index
|
||||
}
|
||||
case "visibility-boundary":
|
||||
if index < firstPhaseCurve {
|
||||
firstPhaseCurve = index
|
||||
}
|
||||
}
|
||||
}
|
||||
if lastStaticOutline < 0 {
|
||||
t.Fatal("Mars occultation GeoJSON is missing static band outlines")
|
||||
}
|
||||
if firstPhaseCurve >= len(collection.Features) {
|
||||
t.Fatal("Mars occultation GeoJSON is missing physical phase curves")
|
||||
}
|
||||
if firstPhaseCurve <= lastStaticOutline {
|
||||
t.Fatalf("physical phase curves start at feature %d, after static outline %d required",
|
||||
firstPhaseCurve, lastStaticOutline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729TotalBandUsesInnerRiseSetCurves(t *testing.T) {
|
||||
fixture := mars20250729TestFixture(t, time.Minute, true)
|
||||
path := fixture.path
|
||||
if !path.HasTotalBand {
|
||||
t.Fatal("Mars path is missing its total band")
|
||||
}
|
||||
if len(path.TotalRiseSetCurves) == 0 {
|
||||
t.Fatal("Mars total band is missing inner-contact rise/set curves")
|
||||
}
|
||||
totalBand := featureWithRole(t, fixture.collection, "total-band")
|
||||
if authoritative, ok := totalBand.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
|
||||
t.Fatalf("total-band static_band_authoritative=%v, want true", totalBand.Properties["static_band_authoritative"])
|
||||
}
|
||||
if totalBand.Properties["source"] != "visible-footprint-sweep" {
|
||||
t.Fatalf("total-band source=%v, want visible-footprint-sweep from the full event-time footprint union",
|
||||
totalBand.Properties["source"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729ExportsOnlyOuterContactPhaseBoundaries(t *testing.T) {
|
||||
fixture := mars20250729TestFixture(t, time.Minute, true)
|
||||
path, collection := fixture.path, fixture.collection
|
||||
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
|
||||
band, ok := feature.Properties["band"].(string)
|
||||
if !ok || band != "partial" {
|
||||
t.Fatalf("visibility-boundary has invalid band=%v: %#v", feature.Properties["band"], feature.Properties)
|
||||
}
|
||||
}
|
||||
if count := len(featuresWithRole(collection, "visibility-boundary")); count != len(path.RiseSetCurves) {
|
||||
t.Fatalf("visibility-boundary count=%d, want %d outer-contact phase curves", count, len(path.RiseSetCurves))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729StaticBandRemainsContinuousAndContained(t *testing.T) {
|
||||
fixture := mars20250729TestFixture(t, time.Minute, true)
|
||||
path, collection := fixture.path, fixture.collection
|
||||
assertOccultationP2StaticBand(t, path, collection)
|
||||
assertOccultationP2PhaseCurvesInsidePartialBand(t, collection, 1)
|
||||
}
|
||||
|
||||
func TestOccultationP2Saturn20240725StaticBandRemainsContinuousAndContained(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), moon.OccultationSaturn,
|
||||
moon.OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
data, err := geojson.MarshalPlanetOccultation(path)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertOccultationP2StaticBand(t, path, collection)
|
||||
}
|
||||
|
||||
func TestOccultationP0Mars20250729StaticBandsUseContactContours(t *testing.T) {
|
||||
path := mars20250729TestFixture(t, time.Minute, true).path
|
||||
if len(path.PartialBandContours) < 2 {
|
||||
t.Fatalf("partial contact contours=%d, want north/south continuous envelopes", len(path.PartialBandContours))
|
||||
}
|
||||
if !path.HasTotalBand || len(path.TotalBandContours) < 2 {
|
||||
t.Fatalf("total contact contours=%d hasTotal=%v, want inner-contact envelopes", len(path.TotalBandContours), path.HasTotalBand)
|
||||
}
|
||||
collection := mars20250729TestFixture(t, time.Minute, true).collection
|
||||
partialBand := featureWithRole(t, collection, "partial-band")
|
||||
totalBand := featureWithRole(t, collection, "total-band")
|
||||
assertOccultationP0ContactContourSource(t, partialBand)
|
||||
assertOccultationP0ContactContourSource(t, totalBand)
|
||||
if maximumStep := occultationPointSeriesMaximumStepKM(path.PartialBandContours[0]); maximumStep > 120 {
|
||||
t.Fatalf("partial contour maximum step=%.1f km, want <=120 km", maximumStep)
|
||||
}
|
||||
if maximumStep := occultationPointSeriesMaximumStepKM(path.TotalBandContours[0]); maximumStep > 120 {
|
||||
t.Fatalf("total contour maximum step=%.1f km, want <=120 km", maximumStep)
|
||||
}
|
||||
partialRings := geoJSONMultiPolygonOuterRings(t, partialBand)
|
||||
totalRings := geoJSONMultiPolygonOuterRings(t, totalBand)
|
||||
assertGeoJSONMultiPolygonMaximumEdge(t, "partial-band", partialRings, 80)
|
||||
assertGeoJSONMultiPolygonMaximumEdge(t, "total-band", totalRings, 80)
|
||||
assertGeoJSONMultiPolygonFollowsLines(t, "partial-band", partialRings,
|
||||
occultationP0BoundaryLines(path.PartialBandContours, path.RiseSetCurves,
|
||||
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit),
|
||||
250,
|
||||
)
|
||||
assertGeoJSONMultiPolygonFollowsLines(t, "partial-band-authoritative", partialRings,
|
||||
occultationP0AuthoritativeBoundaryLines(path.PartialBandContours, path.RiseSetCurves,
|
||||
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit),
|
||||
300,
|
||||
)
|
||||
assertGeoJSONMultiPolygonFollowsLines(t, "total-band", totalRings,
|
||||
occultationP0BoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves,
|
||||
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit),
|
||||
250,
|
||||
)
|
||||
assertGeoJSONMultiPolygonFollowsLines(t, "total-band-authoritative", totalRings,
|
||||
occultationP0AuthoritativeBoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves,
|
||||
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit),
|
||||
300,
|
||||
)
|
||||
greatest := [][]geodata.GeoPoint{{
|
||||
{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude},
|
||||
}}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(totalRings, greatest, false); miss > 10 {
|
||||
t.Fatalf("total-band misses greatest point by %.1f km", miss)
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, totalRings, true); miss > 10 {
|
||||
t.Fatalf("total-band extends %.1f km outside the partial-band", miss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationP2Mars20250729EndRiseExportsRawPhaseSegments(t *testing.T) {
|
||||
fixture := mars20250729TestFixture(t, 5*time.Minute, true)
|
||||
path, collection := fixture.path, fixture.collection
|
||||
endRise := riseSetBoundaryFeature(t, collection, "end", "rise")
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(endRise.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode end/rise coordinates: %v", err)
|
||||
}
|
||||
var sourceSegments [][]moon.OccultationPathPoint
|
||||
for _, curve := range path.RiseSetCurves {
|
||||
if curve.Phase == moon.RiseSetPhaseEnd && curve.Direction == moon.RiseSetDirectionRise {
|
||||
sourceSegments = curve.Segments
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(sourceSegments) < 2 {
|
||||
t.Fatalf("end/rise source segment count=%d, want a folded multi-branch phase curve", len(sourceSegments))
|
||||
}
|
||||
if len(lines) != len(sourceSegments) {
|
||||
t.Fatalf("end/rise segment count=%d, want raw source segment count %d", len(lines), len(sourceSegments))
|
||||
}
|
||||
for segmentIndex, line := range lines {
|
||||
if len(line) < 2 {
|
||||
t.Fatalf("end/rise segment %d has %d points", segmentIndex, len(line))
|
||||
}
|
||||
source := sourceSegments[segmentIndex]
|
||||
if !geoJSONCoordinateMatchesPathPoint(line[0], source[0]) ||
|
||||
!geoJSONCoordinateMatchesPathPoint(line[len(line)-1], source[len(source)-1]) {
|
||||
t.Fatalf("end/rise segment %d endpoints do not match raw source segment", segmentIndex)
|
||||
}
|
||||
maximumStep := 0.0
|
||||
for index := 1; index < len(line); index++ {
|
||||
step := geoJSONCoordinateDistanceKM(line[index-1], line[index])
|
||||
if step > maximumStep {
|
||||
maximumStep = step
|
||||
}
|
||||
}
|
||||
if maximumStep > 200 {
|
||||
t.Fatalf("end/rise segment %d has %.1f km maximum step, want smooth visible curvature", segmentIndex, maximumStep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func geoJSONCoordinateMatchesPathPoint(point []float64, source moon.OccultationPathPoint) bool {
|
||||
return len(point) >= 2 &&
|
||||
geoJSONCoordinateDistanceKM(point, []float64{source.Longitude, source.Latitude}) <= 0.1
|
||||
}
|
||||
|
||||
func horizonConnectorFeature(
|
||||
t *testing.T,
|
||||
collection decodedCollection,
|
||||
horizon string,
|
||||
) decodedFeature {
|
||||
t.Helper()
|
||||
for _, feature := range featuresWithRole(collection, "horizon-connector") {
|
||||
if feature.Properties["phase"] == "horizon" && feature.Properties["horizon"] == horizon {
|
||||
return feature
|
||||
}
|
||||
}
|
||||
t.Fatalf("horizon-connector %s not found", horizon)
|
||||
return decodedFeature{}
|
||||
}
|
||||
|
||||
func assertOccultationHorizonConnectorsAreAuxiliary(t *testing.T, collection decodedCollection) {
|
||||
t.Helper()
|
||||
if len(featuresWithRole(collection, "horizon-connector")) == 0 {
|
||||
t.Fatal("GeoJSON is missing auxiliary horizon-connector features")
|
||||
}
|
||||
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
|
||||
if feature.Properties["phase"] == "horizon" {
|
||||
t.Fatal("horizon connector was exported as a visibility-boundary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertOccultationP0ContactContourSource(t *testing.T, feature decodedFeature) {
|
||||
t.Helper()
|
||||
role := feature.Properties["role"]
|
||||
if authoritative, ok := feature.Properties["static_band_authoritative"].(bool); !ok || !authoritative {
|
||||
t.Fatalf("%s static_band_authoritative=%v, want true", role, feature.Properties["static_band_authoritative"])
|
||||
}
|
||||
if source := feature.Properties["source"]; source != "visible-footprint-sweep" {
|
||||
t.Fatalf("%s source=%v, want visible-footprint-sweep", role, source)
|
||||
}
|
||||
if boundarySource := feature.Properties["boundary_source"]; boundarySource != "footprint-sweep+horizon-visible" {
|
||||
t.Fatalf("%s boundary_source=%v, want footprint-sweep+horizon-visible", role, boundarySource)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOccultationP2StaticBand(
|
||||
t *testing.T,
|
||||
path moon.PlanetOccultationPath,
|
||||
collection decodedCollection,
|
||||
) {
|
||||
t.Helper()
|
||||
partialBand := featureWithRole(t, collection, "partial-band")
|
||||
totalBand := featureWithRole(t, collection, "total-band")
|
||||
assertOccultationP0ContactContourSource(t, partialBand)
|
||||
assertOccultationP0ContactContourSource(t, totalBand)
|
||||
if maximumStep := occultationPointSeriesMaximumStepKM(path.PartialBandContours[0]); maximumStep > 120 {
|
||||
t.Fatalf("partial contour maximum step=%.1f km, want <=120 km", maximumStep)
|
||||
}
|
||||
if path.HasTotalBand {
|
||||
if len(path.TotalBandContours) < 2 {
|
||||
t.Fatalf("total contour count=%d, want inner-contact envelopes", len(path.TotalBandContours))
|
||||
}
|
||||
if maximumStep := occultationPointSeriesMaximumStepKM(path.TotalBandContours[0]); maximumStep > 120 {
|
||||
t.Fatalf("total contour maximum step=%.1f km, want <=120 km", maximumStep)
|
||||
}
|
||||
}
|
||||
partialRings := geoJSONMultiPolygonOuterRings(t, partialBand)
|
||||
totalRings := geoJSONMultiPolygonOuterRings(t, totalBand)
|
||||
assertGeoJSONMultiPolygonMaximumEdge(t, "partial-band", partialRings, 220)
|
||||
assertGeoJSONMultiPolygonMaximumEdge(t, "total-band", totalRings, 220)
|
||||
assertGeoJSONMultiPolygonFollowsLines(t, "partial-band", partialRings,
|
||||
occultationP0BoundaryLines(path.PartialBandContours, path.RiseSetCurves,
|
||||
path.PartialBandFootprints, path.NorthernLimit, path.SouthernLimit),
|
||||
250,
|
||||
)
|
||||
if path.HasTotalBand {
|
||||
assertGeoJSONMultiPolygonFollowsLines(t, "total-band", totalRings,
|
||||
occultationP0BoundaryLines(path.TotalBandContours, path.TotalRiseSetCurves,
|
||||
path.TotalBandFootprints, path.NorthernTotalLimit, path.SouthernTotalLimit),
|
||||
// Split finite-disk branches use a sampled endpoint cap at the polar
|
||||
// horizon; allow the bounded 265 km closure residual while retaining
|
||||
// the stricter 250 km check for the stable partial envelope above.
|
||||
270,
|
||||
)
|
||||
}
|
||||
greatest := [][]geodata.GeoPoint{{
|
||||
{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude},
|
||||
}}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(totalRings, greatest, false); miss > 10 {
|
||||
t.Fatalf("total-band misses greatest point by %.1f km", miss)
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(partialRings, totalRings, true); miss > 10 {
|
||||
t.Fatalf("total-band extends %.1f km outside the partial-band", miss)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOccultationP2PhaseCurvesInsidePartialBand(
|
||||
t *testing.T,
|
||||
collection decodedCollection,
|
||||
toleranceKM float64,
|
||||
) {
|
||||
t.Helper()
|
||||
partialBand := featureWithRole(t, collection, "partial-band")
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(partialBand.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode partial-band coordinates: %v", err)
|
||||
}
|
||||
maximumMissKM := 0.0
|
||||
var maximumMissPoint []float64
|
||||
for _, boundary := range featuresWithRole(collection, "visibility-boundary") {
|
||||
if boundary.Properties["band"] != "partial" {
|
||||
continue
|
||||
}
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode partial visibility-boundary coordinates: %v", err)
|
||||
}
|
||||
for _, line := range lines {
|
||||
for _, point := range line {
|
||||
if geometryContainsPoint(t, partialBand.Geometry, point[0], point[1]) {
|
||||
continue
|
||||
}
|
||||
missKM := geoJSONMultiPolygonBoundaryDistanceKM(polygons, point)
|
||||
if missKM > maximumMissKM {
|
||||
maximumMissKM = missKM
|
||||
maximumMissPoint = point
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if maximumMissKM > toleranceKM {
|
||||
t.Fatalf("partial visibility-boundary extends %.3f km outside rendered partial-band at %.6f, %.6f, want <=%.1f km",
|
||||
maximumMissKM, maximumMissPoint[0], maximumMissPoint[1], toleranceKM)
|
||||
}
|
||||
}
|
||||
|
||||
func geoJSONMultiPolygonOuterRings(t *testing.T, feature decodedFeature) [][]geodata.GeoPoint {
|
||||
t.Helper()
|
||||
if feature.Geometry.Type != "MultiPolygon" {
|
||||
t.Fatalf("%s geometry=%q, want MultiPolygon", feature.Properties["role"], feature.Geometry.Type)
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode %s coordinates: %v", feature.Properties["role"], err)
|
||||
}
|
||||
rings := make([][]geodata.GeoPoint, 0, len(polygons))
|
||||
for polygonIndex, polygon := range polygons {
|
||||
if len(polygon) == 0 {
|
||||
t.Fatalf("%s polygon %d has no rings", feature.Properties["role"], polygonIndex)
|
||||
}
|
||||
ring := make([]geodata.GeoPoint, len(polygon[0]))
|
||||
for pointIndex, point := range polygon[0] {
|
||||
if len(point) < 2 {
|
||||
t.Fatalf("%s polygon %d point %d is malformed", feature.Properties["role"], polygonIndex, pointIndex)
|
||||
}
|
||||
ring[pointIndex] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}
|
||||
}
|
||||
rings = append(rings, ring)
|
||||
}
|
||||
return rings
|
||||
}
|
||||
|
||||
func assertGeoJSONMultiPolygonMaximumEdge(
|
||||
t *testing.T,
|
||||
role string,
|
||||
rings [][]geodata.GeoPoint,
|
||||
maximumKM float64,
|
||||
) {
|
||||
t.Helper()
|
||||
for ringIndex, ring := range rings {
|
||||
for pointIndex := 1; pointIndex < len(ring); pointIndex++ {
|
||||
step := geoPointDistanceKM(ring[pointIndex-1], ring[pointIndex])
|
||||
if step > maximumKM {
|
||||
t.Fatalf("%s ring %d has %.1f km edge at %d, want <= %.1f km",
|
||||
role, ringIndex, step, pointIndex, maximumKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertGeoJSONMultiPolygonFollowsLines(
|
||||
t *testing.T,
|
||||
role string,
|
||||
rings [][]geodata.GeoPoint,
|
||||
lines [][][]float64,
|
||||
maximumDistanceKM float64,
|
||||
) {
|
||||
t.Helper()
|
||||
for ringIndex, ring := range rings {
|
||||
for pointIndex, point := range ring {
|
||||
distance := geoPointLineDistanceKM(point, lines)
|
||||
if distance > maximumDistanceKM {
|
||||
t.Fatalf("%s ring %d point %d is %.1f km from contact/rise-set boundary, want <= %.1f km: %.6f, %.6f",
|
||||
role, ringIndex, pointIndex, distance, maximumDistanceKM, point.Longitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func occultationP0BoundaryLines(
|
||||
contours [][]moon.OccultationPathPoint,
|
||||
curves []moon.OccultationRiseSetCurve,
|
||||
footprints []moon.PlanetOccultationFootprint,
|
||||
limits ...[]moon.OccultationPathPoint,
|
||||
) [][][]float64 {
|
||||
lines := make([][][]float64, 0, len(contours)+len(curves)*2)
|
||||
for _, contour := range contours {
|
||||
if line := occultationP0PathLine(contour); len(line) >= 2 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
for _, curve := range curves {
|
||||
for _, segment := range curve.Segments {
|
||||
if line := occultationP0PathLine(segment); len(line) >= 2 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, source := range occultationgeo.ContactSweepBoundaryLines(footprints) {
|
||||
line := make([][]float64, 0, len(source))
|
||||
for _, point := range source {
|
||||
line = append(line, []float64{point.Longitude, point.Latitude})
|
||||
}
|
||||
if len(line) >= 2 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
for _, connector := range occultationgeo.HorizonConnectorSegments(footprints, curves, limits...) {
|
||||
if line := occultationP0PathLine(connector.Points); len(line) >= 2 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func occultationP0AuthoritativeBoundaryLines(
|
||||
contours [][]moon.OccultationPathPoint,
|
||||
curves []moon.OccultationRiseSetCurve,
|
||||
footprints []moon.PlanetOccultationFootprint,
|
||||
limits ...[]moon.OccultationPathPoint,
|
||||
) [][][]float64 {
|
||||
lines := make([][][]float64, 0, len(contours)+len(curves)*2)
|
||||
for _, contour := range contours {
|
||||
if line := occultationP0PathLine(contour); len(line) >= 2 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
for _, curve := range curves {
|
||||
for _, segment := range curve.Segments {
|
||||
if line := occultationP0PathLine(segment); len(line) >= 2 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, connector := range occultationgeo.HorizonConnectorSegments(footprints, curves, limits...) {
|
||||
if line := occultationP0PathLine(connector.Points); len(line) >= 2 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func occultationP0PathLine(points []moon.OccultationPathPoint) [][]float64 {
|
||||
line := make([][]float64, 0, len(points))
|
||||
for _, point := range points {
|
||||
line = append(line, []float64{point.Longitude, point.Latitude})
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func geoPointLineDistanceKM(point geodata.GeoPoint, lines [][][]float64) float64 {
|
||||
target := []float64{point.Longitude, point.Latitude}
|
||||
minimum := math.Inf(1)
|
||||
for _, line := range lines {
|
||||
for index := 1; index < len(line); index++ {
|
||||
minimum = math.Min(minimum, geoJSONPointSegmentDistanceKM(target, line[index-1], line[index]))
|
||||
}
|
||||
}
|
||||
return minimum
|
||||
}
|
||||
|
||||
func geoPointDistanceKM(first, second geodata.GeoPoint) float64 {
|
||||
return geoJSONCoordinateDistanceKM(
|
||||
[]float64{first.Longitude, first.Latitude},
|
||||
[]float64{second.Longitude, second.Latitude},
|
||||
)
|
||||
}
|
||||
|
||||
func occultationPointSeriesMaximumStepKM(points []moon.OccultationPathPoint) float64 {
|
||||
maximum := 0.0
|
||||
for index := 1; index < len(points); index++ {
|
||||
step := geoPointDistanceKM(
|
||||
geodata.GeoPoint{Longitude: points[index-1].Longitude, Latitude: points[index-1].Latitude},
|
||||
geodata.GeoPoint{Longitude: points[index].Longitude, Latitude: points[index].Latitude},
|
||||
)
|
||||
if step > maximum {
|
||||
maximum = step
|
||||
}
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
func geoJSONCoordinateMatchesAny(point []float64, endpoints []decodedRiseSetEndpoint) bool {
|
||||
for _, endpoint := range endpoints {
|
||||
if geoJSONCoordinateDistanceKM(point, endpoint.coordinate) <= 0.1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mars20250729PhaseEndpoints(
|
||||
t *testing.T,
|
||||
collection decodedCollection,
|
||||
horizon string,
|
||||
) []decodedRiseSetEndpoint {
|
||||
t.Helper()
|
||||
var endpoints []decodedRiseSetEndpoint
|
||||
for _, phase := range []string{"start", "greatest", "end"} {
|
||||
feature := riseSetBoundaryFeature(t, collection, phase, horizon)
|
||||
endpoints = append(endpoints, riseSetFeatureEndpoints(t, feature)...)
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func assertClosedMultiLineFeature(t *testing.T, feature decodedFeature) {
|
||||
t.Helper()
|
||||
if feature.Geometry.Type != "MultiLineString" {
|
||||
t.Fatalf("%s geometry=%q, want MultiLineString", feature.Properties["role"], feature.Geometry.Type)
|
||||
}
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode %s coordinates: %v", feature.Properties["role"], err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
t.Fatalf("%s has no outline segments", feature.Properties["role"])
|
||||
}
|
||||
for index, line := range lines {
|
||||
if len(line) < 4 {
|
||||
t.Fatalf("%s segment %d has %d points, want a closed ring", feature.Properties["role"], index, len(line))
|
||||
}
|
||||
first, last := line[0], line[len(line)-1]
|
||||
if len(first) != 2 || len(last) != 2 || first[0] != last[0] || first[1] != last[1] {
|
||||
t.Fatalf("%s segment %d is not closed: first=%v last=%v", feature.Properties["role"], index, first, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatP2SignedIndex(value int) string {
|
||||
if value < 0 {
|
||||
return "-" + formatP2Magnitude(-value)
|
||||
}
|
||||
return "+" + formatP2Magnitude(value)
|
||||
}
|
||||
|
||||
func formatP2Magnitude(value int) string {
|
||||
if value < 10 {
|
||||
return "0" + string(rune('0'+value))
|
||||
}
|
||||
return string(rune('0'+value/10)) + string(rune('0'+value%10))
|
||||
}
|
||||
|
||||
// Keep the JSON import in this P2 file tied to the shared decoder contract;
|
||||
// this catches accidental changes that make a GeoJSON payload un-decodable
|
||||
// even when the role-level checks still pass.
|
||||
func TestP2GeoJSONPayloadsDecodeAsFeatureCollections(t *testing.T) {
|
||||
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 30 * time.Minute, BoundaryPoints: 36, DisableRiseSet: true,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected 2024-04-08 eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
t.Fatalf("payload is not JSON: %v", err)
|
||||
}
|
||||
if raw.Type != "FeatureCollection" {
|
||||
t.Fatalf("payload type=%q, want FeatureCollection", raw.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func BenchmarkStaticMap(b *testing.B) {
|
||||
if err := basic.LoadStarData(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
year, month, day int
|
||||
planet moon.OccultationPlanet
|
||||
hr int
|
||||
}{
|
||||
{"saturn", 2025, 1, 5, moon.OccultationSaturn, 0},
|
||||
{"mars", 2025, 1, 14, moon.OccultationMars, 0},
|
||||
{"venus", 2025, 9, 19, moon.OccultationVenus, 0},
|
||||
{"star", 2025, 6, 5, "", 4799},
|
||||
{"partial", 2025, 3, 29, "", 0},
|
||||
{"hybrid", 2023, 4, 20, "", 0},
|
||||
{"annular2012", 2012, 5, 21, "", 0},
|
||||
{"annular2056", 2056, 7, 13, "", 0},
|
||||
} {
|
||||
b.Run(test.name, func(b *testing.B) {
|
||||
zone := time.FixedZone("UTC+8", 8*3600)
|
||||
day := time.Date(test.year, time.Month(test.month), test.day, 0, 0, 0, 0, zone)
|
||||
var star moon.StarCoordinate
|
||||
if test.hr > 0 {
|
||||
data, err := basic.StarDataByHR(test.hr)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
star, err = moon.StarCoordinateFromStarData(data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
options := moon.OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var data []byte
|
||||
var err error
|
||||
if test.planet != "" {
|
||||
paths, findErr := moon.FindPlanetOccultationPaths(day, day.Add(24*time.Hour), test.planet, options)
|
||||
if findErr != nil || len(paths) != 1 {
|
||||
b.Fatalf("paths=%d err=%v", len(paths), findErr)
|
||||
}
|
||||
data, err = geojson.MarshalPlanetOccultation(paths[0])
|
||||
} else if test.hr > 0 {
|
||||
paths, findErr := moon.FindStarOccultationPaths(day, day.Add(24*time.Hour), star, options)
|
||||
if findErr != nil || len(paths) != 1 {
|
||||
b.Fatalf("paths=%d err=%v", len(paths), findErr)
|
||||
}
|
||||
data, err = geojson.MarshalStarOccultation(paths[0])
|
||||
} else {
|
||||
path, ok := eclipse.SolarEclipsePartialFootprints(day, eclipse.SolarEclipsePartialFootprintOptions{Step: 5 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute})
|
||||
if !ok {
|
||||
b.Fatal("missing eclipse")
|
||||
}
|
||||
data, err = geojson.MarshalSolarEclipse(path, nil)
|
||||
}
|
||||
if err != nil || len(data) == 0 {
|
||||
b.Fatalf("bytes=%d err=%v", len(data), err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
// 本文件钉住 §1.5 涉及的导出路径成本:掠食带的限线派生与打包足迹都在这条路径上。
|
||||
func review15BenchmarkDate(b *testing.B, text string) time.Time {
|
||||
b.Helper()
|
||||
if strings.HasPrefix(text, "-") {
|
||||
parts := strings.Split(strings.TrimPrefix(text, "-"), "-")
|
||||
if len(parts) != 3 {
|
||||
b.Fatalf("invalid astronomical date %q", text)
|
||||
}
|
||||
year, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
month, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
day, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return time.Date(-year, time.Month(month), day, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
date, err := time.Parse("2006-01-02", text)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func benchmarkReview15SolarEclipseMarshal(b *testing.B, text string) {
|
||||
date := review15BenchmarkDate(b, text)
|
||||
info, ok := eclipse.SolarEclipseOnDate(date)
|
||||
if !ok || !info.HasCentral {
|
||||
b.Fatalf("expected a central solar eclipse on %s", text)
|
||||
}
|
||||
partialOptions, pathOptions := grazingBandOptions(info, "overview")
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
||||
if !ok {
|
||||
b.Fatal("missing partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
||||
if !ok {
|
||||
b.Fatal("missing central path")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil || len(data) == 0 {
|
||||
b.Fatalf("MarshalSolarEclipse() bytes=%d err=%v", len(data), err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(data)))
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
value, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil || len(value) == 0 {
|
||||
b.Fatalf("MarshalSolarEclipse() bytes=%d err=%v", len(value), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReview15SolarEclipseMarshalGrazing(b *testing.B) {
|
||||
for _, text := range []string{"2003-05-31", "1874-10-10", "2061-10-13"} {
|
||||
b.Run(text, func(b *testing.B) {
|
||||
benchmarkReview15SolarEclipseMarshal(b, text)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReview15SolarEclipseMarshalOrdinary(b *testing.B) {
|
||||
benchmarkReview15SolarEclipseMarshal(b, "2024-04-08")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package geojson
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
eclipsecore "b612.me/astro/eclipse"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// 本文件钉住 §1.5 的两条导出契约:覆盖判据必须逐点成立(不能靠抽样),
|
||||
// 中心线走廊必须让每个探针都落在容差内,且只修补越界的那一段。
|
||||
const review15ToleranceKM = solarCentralBandCoverageToleranceKM
|
||||
|
||||
func review15SquareRing(minLongitude, minLatitude, maxLongitude, maxLatitude float64) []geodata.GeoPoint {
|
||||
return []geodata.GeoPoint{
|
||||
{Longitude: minLongitude, Latitude: minLatitude},
|
||||
{Longitude: maxLongitude, Latitude: minLatitude},
|
||||
{Longitude: maxLongitude, Latitude: maxLatitude},
|
||||
{Longitude: minLongitude, Latitude: maxLatitude},
|
||||
}
|
||||
}
|
||||
|
||||
func review15CenterLine(points ...[2]float64) []eclipsecore.SolarEclipsePathPoint {
|
||||
base := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
|
||||
line := make([]eclipsecore.SolarEclipsePathPoint, len(points))
|
||||
for index, point := range points {
|
||||
line[index] = eclipsecore.SolarEclipsePathPoint{
|
||||
Time: base.Add(time.Duration(index) * time.Minute),
|
||||
Longitude: point[0],
|
||||
Latitude: point[1],
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func TestSolarCentralBandPointsCoverProbesEveryVertex(t *testing.T) {
|
||||
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
|
||||
points := make([]geodata.GeoPoint, 0, 257)
|
||||
for index := 0; index < 257; index++ {
|
||||
points = append(points, geodata.GeoPoint{
|
||||
Longitude: 0.1 + 0.8*float64(index)/256,
|
||||
Latitude: 0.1 + 0.8*float64(index%97)/96,
|
||||
})
|
||||
}
|
||||
if !solarCentralBandPointsCover(polygons, points, review15ToleranceKM) {
|
||||
t.Fatal("points inside the ring must be covered")
|
||||
}
|
||||
for index := range points {
|
||||
moved := append([]geodata.GeoPoint(nil), points...)
|
||||
moved[index] = geodata.GeoPoint{Longitude: 40, Latitude: 40}
|
||||
if solarCentralBandPointsCover(polygons, moved, review15ToleranceKM) {
|
||||
t.Fatalf("vertex %d outside the tolerance was accepted; every vertex must be probed", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarCentralBandPointsCoverKeepsMacroTolerance(t *testing.T) {
|
||||
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
|
||||
near := []geodata.GeoPoint{{Longitude: 0.5, Latitude: 1.4}}
|
||||
far := []geodata.GeoPoint{{Longitude: 0.5, Latitude: 2.0}}
|
||||
if !solarCentralBandPointsCover(polygons, near, review15ToleranceKM) {
|
||||
t.Fatal("a point 45 km outside must stay inside the 100 km macro tolerance")
|
||||
}
|
||||
if solarCentralBandPointsCover(polygons, far, review15ToleranceKM) {
|
||||
t.Fatal("a point 111 km outside must exceed the 100 km macro tolerance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarCentralBandRingsCoverProbesEveryFootprintVertex(t *testing.T) {
|
||||
rings := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
|
||||
stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
|
||||
boundary := make([]eclipsecore.SolarEclipsePathPoint, 0, 64)
|
||||
for index := 0; index < 64; index++ {
|
||||
boundary = append(boundary, eclipsecore.SolarEclipsePathPoint{
|
||||
Time: stamp,
|
||||
Longitude: 0.2 + 0.6*float64(index)/63,
|
||||
Latitude: 0.3,
|
||||
})
|
||||
}
|
||||
footprints := []eclipsecore.SolarEclipsePartialFootprint{{
|
||||
Time: stamp,
|
||||
Boundaries: [][]eclipsecore.SolarEclipsePathPoint{boundary},
|
||||
}}
|
||||
if !solarCentralBandRingsCover(rings, review15CenterLine([2]float64{0.5, 0.5}), footprints) {
|
||||
t.Fatal("a covered footprint must pass the coverage check")
|
||||
}
|
||||
for index := range boundary {
|
||||
moved := append([]eclipsecore.SolarEclipsePathPoint(nil), boundary...)
|
||||
moved[index] = eclipsecore.SolarEclipsePathPoint{Time: stamp, Longitude: 40, Latitude: 40}
|
||||
probe := []eclipsecore.SolarEclipsePartialFootprint{{
|
||||
Time: stamp,
|
||||
Boundaries: [][]eclipsecore.SolarEclipsePathPoint{moved},
|
||||
}}
|
||||
if solarCentralBandRingsCover(rings, review15CenterLine([2]float64{0.5, 0.5}), probe) {
|
||||
t.Fatalf("footprint vertex %d outside the tolerance was accepted", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarCentralBandRingsCoverProbesEveryCenterLineVertex(t *testing.T) {
|
||||
rings := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
|
||||
line := review15CenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 0.6}, [2]float64{0.5, 0.7})
|
||||
if !solarCentralBandRingsCover(rings, line, nil) {
|
||||
t.Fatal("a covered center line must pass the coverage check")
|
||||
}
|
||||
for index := range line {
|
||||
moved := append([]eclipsecore.SolarEclipsePathPoint(nil), line...)
|
||||
moved[index].Longitude = 40
|
||||
moved[index].Latitude = 40
|
||||
if solarCentralBandRingsCover(rings, moved, nil) {
|
||||
t.Fatalf("center-line vertex %d outside the tolerance was accepted", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarCentralBandCorridorCoversEveryCenterlineProbe(t *testing.T) {
|
||||
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
|
||||
line := review15CenterLine([2]float64{0.5, 1.2}, [2]float64{0.5, 1.8})
|
||||
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
|
||||
probes := solarCentralBandCenterlineProbes(line)
|
||||
miss := geodata.SphericalPolygonsPathMissDistanceKM(
|
||||
repaired, [][]geodata.GeoPoint{probes}, false,
|
||||
)
|
||||
if miss > solarCentralBandCenterlineToleranceKM {
|
||||
t.Fatalf("center-line probe sits %.1f km from the repaired band, want <= %.1f km",
|
||||
miss, solarCentralBandCenterlineToleranceKM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarCentralBandCorridorPatchesOnlyTheClippedSegment(t *testing.T) {
|
||||
polygons := [][]geodata.GeoPoint{review15SquareRing(-1, -0.05, 1, 0.05)}
|
||||
line := review15CenterLine([2]float64{-0.5, 0.08}, [2]float64{0.5, 0.6})
|
||||
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
|
||||
witness := []geodata.GeoPoint{{Longitude: -0.5, Latitude: 0.35}}
|
||||
if covered := geodata.SphericalPolygonsContainPoints(repaired, witness); covered[0] {
|
||||
t.Fatal("the repaired band inflated the shallow end to the deepest probe's radius")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarCentralBandCorridorLeavesUnboundedMissAlone(t *testing.T) {
|
||||
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
|
||||
line := review15CenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 20})
|
||||
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
|
||||
if len(repaired) != len(polygons) {
|
||||
t.Fatalf("polygons=%d, want the input unchanged when the miss exceeds the corridor cap", len(repaired))
|
||||
}
|
||||
for index := range polygons {
|
||||
if len(repaired[index]) != len(polygons[index]) {
|
||||
t.Fatalf("ring %d has %d vertices, want %d", index, len(repaired[index]), len(polygons[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarShadowRegionDegenerateSkipsOnlyDegenerateRings(t *testing.T) {
|
||||
normal := review15SquareRing(0, 0, 0.1, 0.1)
|
||||
if solarShadowRegionDegenerate(normal, normal) {
|
||||
t.Fatal("an 11 km square must not be degenerate")
|
||||
}
|
||||
sliver := []geodata.GeoPoint{
|
||||
{Longitude: 0, Latitude: 0},
|
||||
{Longitude: 0.01, Latitude: 0},
|
||||
{Longitude: 0.01, Latitude: 1e-11},
|
||||
{Longitude: 0, Latitude: 1e-11},
|
||||
}
|
||||
if !solarShadowRegionDegenerate(sliver, sliver) {
|
||||
t.Fatal("a 2.2 km long sliver with 1e-13 square degrees of area must be degenerate")
|
||||
}
|
||||
short := []geodata.GeoPoint{{Longitude: 0, Latitude: 0}, {Longitude: 0.001, Latitude: 0}}
|
||||
if !solarShadowRegionDegenerate(short, short) {
|
||||
t.Fatal("a 0.1 km boundary must be degenerate")
|
||||
}
|
||||
crossing := []geodata.GeoPoint{
|
||||
{Longitude: 179.9, Latitude: -0.05},
|
||||
{Longitude: -179.9, Latitude: -0.05},
|
||||
{Longitude: -179.9, Latitude: 0.05},
|
||||
{Longitude: 179.9, Latitude: 0.05},
|
||||
}
|
||||
if solarShadowRegionDegenerate(crossing, crossing) {
|
||||
t.Fatal("a region crossing the antimeridian must not be degenerate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarShadowSegmentClosedUsesBasicLayerCaliber(t *testing.T) {
|
||||
start := eclipsecore.SolarEclipsePathPoint{Longitude: 12, Latitude: 30}
|
||||
nearClosed := []eclipsecore.SolarEclipsePathPoint{
|
||||
start, {Longitude: 12.5, Latitude: 30.5}, {Longitude: 12 + 5e-9, Latitude: 30 + 5e-9},
|
||||
}
|
||||
if !solarShadowSegmentClosed(nearClosed) {
|
||||
t.Fatal("a 0.8 mm gap counts as closed for the basic layer signature")
|
||||
}
|
||||
open := []eclipsecore.SolarEclipsePathPoint{
|
||||
start, {Longitude: 12.5, Latitude: 30.5}, {Longitude: 12.001, Latitude: 30.001},
|
||||
}
|
||||
if solarShadowSegmentClosed(open) {
|
||||
t.Fatal("a 150 m gap is not a closed ring")
|
||||
}
|
||||
if solarShadowSegmentClosed(nearClosed[:2]) {
|
||||
t.Fatal("a two-point segment is not a closed ring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationBandSourcePropertiesCoverBothPaths(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
authoritative bool
|
||||
contours int
|
||||
source string
|
||||
boundary string
|
||||
}{
|
||||
{authoritative: true, contours: 2, source: "visible-footprint-sweep", boundary: "footprint-sweep+horizon-visible"},
|
||||
{authoritative: false, contours: 2, source: "footprint-sweep-fallback", boundary: "contact-contours+horizon-boundary"},
|
||||
{authoritative: false, contours: 0, source: "footprint-sweep-fallback", boundary: ""},
|
||||
} {
|
||||
properties := map[string]interface{}{}
|
||||
applyOccultationBandSourceProperties(properties, testCase.authoritative, testCase.contours)
|
||||
if properties["source"] != testCase.source {
|
||||
t.Fatalf("source=%v, want %s", properties["source"], testCase.source)
|
||||
}
|
||||
boundary, present := properties["boundary_source"]
|
||||
if testCase.boundary == "" {
|
||||
if present {
|
||||
t.Fatalf("boundary_source=%v, want absent", boundary)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if boundary != testCase.boundary {
|
||||
t.Fatalf("boundary_source=%v, want %s", boundary, testCase.boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSolarFootprintFeaturesSkipsDegenerateFootprint(t *testing.T) {
|
||||
stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
|
||||
degenerate := eclipsecore.SolarEclipsePartialFootprint{
|
||||
Time: stamp,
|
||||
Closed: true,
|
||||
Boundaries: [][]eclipsecore.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},
|
||||
}},
|
||||
}
|
||||
features, err := appendSolarFootprintFeatures(
|
||||
nil, solarCentralShadowFootprintRole,
|
||||
[]eclipsecore.SolarEclipsePartialFootprint{degenerate},
|
||||
map[string]interface{}{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("degenerate footprint must be omitted, not rejected: %v", err)
|
||||
}
|
||||
if len(features) != 0 {
|
||||
t.Fatalf("features=%d, want the degenerate footprint omitted", len(features))
|
||||
}
|
||||
}
|
||||
|
||||
// review15ProjectTime 独立复算顶点在中心线上的投影时刻。
|
||||
func review15ProjectTime(
|
||||
point eclipsecore.SolarEclipsePathPoint,
|
||||
centerLine []eclipsecore.SolarEclipsePathPoint,
|
||||
) time.Time {
|
||||
bestDistance := math.Inf(1)
|
||||
bestTime := centerLine[0].Time
|
||||
scale := math.Cos(point.Latitude * math.Pi / 180)
|
||||
for index := 0; index+1 < len(centerLine); index++ {
|
||||
first, second := centerLine[index], centerLine[index+1]
|
||||
ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale
|
||||
ay := first.Latitude - point.Latitude
|
||||
bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale
|
||||
by := second.Latitude - point.Latitude
|
||||
dx, dy := bx-ax, by-ay
|
||||
length := dx*dx + dy*dy
|
||||
fraction := 0.0
|
||||
if length > 0 {
|
||||
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
|
||||
}
|
||||
distance := math.Hypot(ax+fraction*dx, ay+fraction*dy)
|
||||
if distance >= bestDistance {
|
||||
continue
|
||||
}
|
||||
bestDistance = distance
|
||||
bestTime = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction))
|
||||
}
|
||||
return bestTime
|
||||
}
|
||||
|
||||
func TestSolarCentralBandLimitSidesKeepProjectionTimes(t *testing.T) {
|
||||
base := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
|
||||
centerLine := []eclipsecore.SolarEclipsePathPoint{
|
||||
{Time: base, Longitude: 0, Latitude: 0},
|
||||
{Time: base.Add(time.Minute), Longitude: 0.3, Latitude: 0},
|
||||
{Time: base.Add(20 * time.Minute), Longitude: 1, Latitude: 0},
|
||||
}
|
||||
ring := []eclipsecore.SolarEclipsePathPoint{
|
||||
{Longitude: 0, Latitude: 0.1},
|
||||
{Longitude: 0.3, Latitude: 0.1},
|
||||
{Longitude: 1, Latitude: 0.1},
|
||||
{Longitude: 1.05, Latitude: -0.05},
|
||||
{Longitude: 1, Latitude: -0.1},
|
||||
{Longitude: 0.3, Latitude: -0.1},
|
||||
{Longitude: 0, Latitude: -0.1},
|
||||
{Longitude: -0.05, Latitude: -0.05},
|
||||
}
|
||||
north, south, ok := solarCentralBandLimitSidesFromRings(
|
||||
[][]eclipsecore.SolarEclipsePathPoint{ring}, centerLine,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("synthetic band must produce two limit sides")
|
||||
}
|
||||
if len(north) != 3 || len(south) != 5 {
|
||||
t.Fatalf("north=%d south=%d, want the 3 north and 5 south vertices without the end caps",
|
||||
len(north), len(south))
|
||||
}
|
||||
for _, side := range []struct {
|
||||
name string
|
||||
north bool
|
||||
points []eclipsecore.SolarEclipsePathPoint
|
||||
}{{"north-limit", true, north}, {"south-limit", false, south}} {
|
||||
for index, point := range side.points {
|
||||
if side.north && point.Latitude <= 0 {
|
||||
t.Fatalf("%s vertex %d latitude=%v, want north of the center line", side.name, index, point.Latitude)
|
||||
}
|
||||
if !side.north && point.Latitude >= 0 {
|
||||
t.Fatalf("%s vertex %d latitude=%v, want south of the center line", side.name, index, point.Latitude)
|
||||
}
|
||||
projected := review15ProjectTime(point, centerLine)
|
||||
if delta := point.Time.Sub(projected); delta > time.Millisecond || delta < -time.Millisecond {
|
||||
t.Fatalf("%s vertex %d time=%v, projected=%v", side.name, index, point.Time, projected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
// 本文件钉住 §1.5 的限线口径:north-limit / south-limit 的标签由每个顶点自身的投影侧决定,
|
||||
// times 是该顶点在中心线上的投影时刻,不按顶点顺序均匀铺开。
|
||||
const review15LimitSideSlackKM = 25.0
|
||||
|
||||
type review15LimitLine struct {
|
||||
coordinates [][2]float64
|
||||
times []time.Time
|
||||
}
|
||||
|
||||
func review15SolarEclipseFixture(
|
||||
t *testing.T,
|
||||
text string,
|
||||
) ([]byte, eclipse.SolarEclipsePath) {
|
||||
t.Helper()
|
||||
date := grazingBandDate(t, text)
|
||||
info, ok := eclipse.SolarEclipseOnDate(date)
|
||||
if !ok || !info.HasCentral {
|
||||
t.Fatalf("expected a central solar eclipse on %s", text)
|
||||
}
|
||||
partialOptions, pathOptions := grazingBandOptions(info, "overview")
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing central path")
|
||||
}
|
||||
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
return raw, central
|
||||
}
|
||||
|
||||
func review15LimitLineFrom(t *testing.T, raw []byte, role string) review15LimitLine {
|
||||
t.Helper()
|
||||
feature := featureWithRole(t, decodeCollection(t, raw), role)
|
||||
var line [][2]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &line); err != nil {
|
||||
var lines [][][2]float64
|
||||
if multiErr := json.Unmarshal(feature.Geometry.Coordinates, &lines); multiErr != nil {
|
||||
t.Fatalf("%s coordinates: %v", role, err)
|
||||
}
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("%s is split into %d segments", role, len(lines))
|
||||
}
|
||||
line = lines[0]
|
||||
}
|
||||
encoded, ok := feature.Properties["times"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s has no times array", role)
|
||||
}
|
||||
if len(encoded) == 1 {
|
||||
if nested, nestedOK := encoded[0].([]interface{}); nestedOK {
|
||||
encoded = nested
|
||||
}
|
||||
}
|
||||
if len(encoded) != len(line) {
|
||||
t.Fatalf("%s times=%d coordinates=%d, want one time per vertex", role, len(encoded), len(line))
|
||||
}
|
||||
times := make([]time.Time, len(encoded))
|
||||
for index, value := range encoded {
|
||||
text, _ := value.(string)
|
||||
stamp, err := time.Parse(time.RFC3339Nano, text)
|
||||
if err != nil {
|
||||
t.Fatalf("%s time %d: %v", role, index, err)
|
||||
}
|
||||
times[index] = stamp
|
||||
}
|
||||
return review15LimitLine{coordinates: line, times: times}
|
||||
}
|
||||
|
||||
// review15ProjectOnCenterLine 独立复算顶点在中心线上的投影纬度与插值时刻。
|
||||
func review15ProjectOnCenterLine(
|
||||
point [2]float64,
|
||||
centerLine []eclipse.SolarEclipsePathPoint,
|
||||
) (float64, time.Time) {
|
||||
bestDistance := math.Inf(1)
|
||||
bestLatitude := centerLine[0].Latitude
|
||||
bestTime := centerLine[0].Time
|
||||
scale := math.Cos(point[1] * math.Pi / 180)
|
||||
for index := 0; index+1 < len(centerLine); index++ {
|
||||
first, second := centerLine[index], centerLine[index+1]
|
||||
ax := math.Remainder(first.Longitude-point[0], 360) * scale
|
||||
ay := first.Latitude - point[1]
|
||||
bx := math.Remainder(second.Longitude-point[0], 360) * scale
|
||||
by := second.Latitude - point[1]
|
||||
dx, dy := bx-ax, by-ay
|
||||
length := dx*dx + dy*dy
|
||||
fraction := 0.0
|
||||
if length > 0 {
|
||||
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
|
||||
}
|
||||
distance := math.Hypot(ax+fraction*dx, ay+fraction*dy)
|
||||
if distance >= bestDistance {
|
||||
continue
|
||||
}
|
||||
bestDistance = distance
|
||||
bestLatitude = first.Latitude + fraction*(second.Latitude-first.Latitude)
|
||||
bestTime = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction))
|
||||
}
|
||||
return bestLatitude, bestTime
|
||||
}
|
||||
|
||||
func TestSolarEclipseLimitSidesCarryTheirOwnProjectionSide(t *testing.T) {
|
||||
for _, text := range []string{"2003-05-31", "1874-10-10", "2185-07-26", "4862-09-28"} {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
raw, central := review15SolarEclipseFixture(t, text)
|
||||
for _, side := range []struct {
|
||||
role string
|
||||
north bool
|
||||
}{{"north-limit", true}, {"south-limit", false}} {
|
||||
line := review15LimitLineFrom(t, raw, side.role)
|
||||
for index, point := range line.coordinates {
|
||||
latitude, _ := review15ProjectOnCenterLine(point, central.CenterLine)
|
||||
offset := (point[1] - latitude) * 111.3
|
||||
if side.north && offset < -review15LimitSideSlackKM {
|
||||
t.Fatalf("%s vertex %d sits %.1f km south of the center line", side.role, index, -offset)
|
||||
}
|
||||
if !side.north && offset > review15LimitSideSlackKM {
|
||||
t.Fatalf("%s vertex %d sits %.1f km north of the center line", side.role, index, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// review15BandRingVertices 收集 central-band 环上的顶点,用于判定限线是否由带边界派生。
|
||||
func review15BandRingVertices(t *testing.T, raw []byte) map[[2]float64]bool {
|
||||
t.Helper()
|
||||
vertices := map[[2]float64]bool{}
|
||||
for _, ring := range grazingBandRings(t, raw) {
|
||||
for _, point := range ring {
|
||||
vertices[[2]float64{point.Longitude, point.Latitude}] = true
|
||||
}
|
||||
}
|
||||
return vertices
|
||||
}
|
||||
|
||||
func review15LimitFollowsBandRing(line review15LimitLine, vertices map[[2]float64]bool) bool {
|
||||
if len(line.coordinates) < 3 || len(vertices) == 0 {
|
||||
return false
|
||||
}
|
||||
matched := 0
|
||||
for _, point := range line.coordinates {
|
||||
if vertices[point] {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
return matched*5 >= len(line.coordinates)*4
|
||||
}
|
||||
|
||||
func TestSolarEclipseDerivedLimitTimesAreProjectionTimes(t *testing.T) {
|
||||
for _, text := range []string{"2003-05-31"} {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
raw, central := review15SolarEclipseFixture(t, text)
|
||||
ringVertices := review15BandRingVertices(t, raw)
|
||||
derived := 0
|
||||
for _, role := range []string{"north-limit", "south-limit"} {
|
||||
line := review15LimitLineFrom(t, raw, role)
|
||||
if !review15LimitFollowsBandRing(line, ringVertices) {
|
||||
continue
|
||||
}
|
||||
derived++
|
||||
for index, point := range line.coordinates {
|
||||
_, projected := review15ProjectOnCenterLine(point, central.CenterLine)
|
||||
if delta := line.times[index].Sub(projected); delta > 2*time.Second || delta < -2*time.Second {
|
||||
t.Fatalf("%s vertex %d time=%v, projected=%v",
|
||||
role, index, line.times[index].UTC(), projected.UTC())
|
||||
}
|
||||
}
|
||||
minimum, maximum := time.Duration(math.MaxInt64), time.Duration(0)
|
||||
for index := 1; index < len(line.times); index++ {
|
||||
step := line.times[index].Sub(line.times[index-1])
|
||||
if step < minimum {
|
||||
minimum = step
|
||||
}
|
||||
if step > maximum {
|
||||
maximum = step
|
||||
}
|
||||
}
|
||||
if maximum < 3*minimum {
|
||||
t.Fatalf("%s times look uniformly spread: min=%v max=%v", role, minimum, maximum)
|
||||
}
|
||||
}
|
||||
if derived == 0 {
|
||||
t.Fatal("no derived limit in the fixture set; the projection-time contract is untested")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
// A one-limit polar total eclipse has U1/U4 samples while the axis is still
|
||||
// below the horizon. Those open samples must not flare the static central band
|
||||
// beyond the central-line interval.
|
||||
func TestMarshalSolarEclipse15220327ClipsOneLimitEndSweeps(t *testing.T) {
|
||||
date := time.Date(1522, time.March, 27, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96, RiseSetStep: 2 * time.Minute,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar eclipse footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
||||
Step: 2 * time.Minute, TargetSpacingKM: 150,
|
||||
})
|
||||
if !ok || central.Eclipse.Centrality != eclipse.SolarEclipseCentralOneLimit {
|
||||
t.Fatalf("expected one-limit central path, got ok=%v centrality=%s", ok, central.Eclipse.Centrality)
|
||||
}
|
||||
if len(central.CenterLine) < 2 || len(partial.CentralBandFootprints) == 0 {
|
||||
t.Fatal("missing central path or end footprints")
|
||||
}
|
||||
first := partial.CentralBandFootprints[0]
|
||||
if !first.Time.Before(central.CenterLine[0].Time) || len(first.Boundaries) == 0 || len(first.Boundaries[0]) == 0 {
|
||||
t.Fatal("fixture no longer exercises a pre-horizon end footprint")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
for _, point := range first.Boundaries[0] {
|
||||
if geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) {
|
||||
t.Fatalf("central band includes pre-horizon footprint point %.6f, %.6f", point.Longitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
var lines [][][]float64
|
||||
center := featureWithRole(t, decodeCollection(t, data), "center-line")
|
||||
if err := json.Unmarshal(center.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, line := range lines {
|
||||
for _, point := range line {
|
||||
if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) {
|
||||
t.Fatalf("central band omits center-line point %.6f, %.6f", point[0], point[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestSolarEclipse20120521HasCentralBandHorizonClosure(t *testing.T) {
|
||||
date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar eclipse footprints")
|
||||
}
|
||||
if len(partial.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("central-limit horizon closures=%d, want start and end", len(partial.CentralBandHorizonClosures))
|
||||
}
|
||||
expectedRoots := [2][2][]float64{
|
||||
{{109.6236411, 19.9359003}, {107.7419895, 22.3910846}},
|
||||
{{-100.0879481, 34.1224726}, {-102.2069471, 31.7284052}},
|
||||
}
|
||||
for closureIndex, closure := range partial.CentralBandHorizonClosures {
|
||||
if len(closure) < 2 {
|
||||
t.Fatalf("horizon closure %d has %d points", closureIndex, len(closure))
|
||||
}
|
||||
assertSolarPathMaximumEdgeKM(t, closure, 12)
|
||||
roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]}
|
||||
for rootIndex, root := range roots {
|
||||
if distance := geoJSONCoordinateDistanceKM(
|
||||
[]float64{root.Longitude, root.Latitude}, expectedRoots[closureIndex][rootIndex],
|
||||
); distance > 0.1 {
|
||||
t.Fatalf("horizon closure %d root %d differs by %.3f km", closureIndex, rootIndex, distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
for closureIndex, closure := range partial.CentralBandHorizonClosures {
|
||||
direction := eclipse.RiseSetDirectionRise
|
||||
if closureIndex == 1 {
|
||||
direction = eclipse.RiseSetDirectionSet
|
||||
}
|
||||
for pointIndex, point := range closure {
|
||||
if !solarGreatestCurveContainsPoint(partial.RiseSetCurves, point, direction) {
|
||||
t.Fatalf("horizon closure %d point %d is missing from its greatest/%s curve", closureIndex, pointIndex, direction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
||||
Step: 2 * time.Minute, TargetSpacingKM: 700,
|
||||
})
|
||||
if !ok || len(central.CenterLine) < 2 {
|
||||
t.Fatal("expected a central eclipse path")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
if band.Properties["source"] != "besselian-critical-envelope" {
|
||||
t.Fatalf("central-band source=%v, want continuous critical envelope", band.Properties["source"])
|
||||
}
|
||||
assertClosedMultiPolygon(t, band)
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode central band: %v", err)
|
||||
}
|
||||
if len(polygons) == 0 || len(polygons) > 2 {
|
||||
t.Fatalf("central band has %d polygons, want one physical band with at most one antimeridian split", len(polygons))
|
||||
}
|
||||
for _, polygon := range polygons {
|
||||
if len(polygon) == 0 {
|
||||
t.Fatal("central-band polygon has no exterior ring")
|
||||
}
|
||||
assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250)
|
||||
}
|
||||
for closureIndex, closure := range partial.CentralBandHorizonClosures {
|
||||
for pointIndex, point := range closure {
|
||||
coordinate := []float64{point.Longitude, point.Latitude}
|
||||
if distance := geoJSONMultiPolygonBoundaryDistanceKM(polygons, coordinate); distance > 0.1 {
|
||||
t.Fatalf("horizon closure %d point %d is %.3f km from the central-band boundary", closureIndex, pointIndex, distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
for segmentIndex, segment := range central.CenterLine {
|
||||
point := []float64{segment.Longitude, segment.Latitude}
|
||||
if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) &&
|
||||
geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 10 {
|
||||
t.Fatalf("center-line point %d lies outside central band", segmentIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipse20120521CentralBandContainsVisibleAnnularStation(t *testing.T) {
|
||||
localDate := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
|
||||
local, ok := eclipse.LocalSolarEclipseOnDate(localDate, 120.4913, 27.4779, 0)
|
||||
if !ok || local.Type != eclipse.SolarEclipseAnnular || !local.HasCentral || local.SunAltitude <= 0 {
|
||||
t.Fatalf("reference station is not visibly annular: ok=%v type=%s central=%v altitude=%.6f",
|
||||
ok, local.Type, local.HasCentral, local.SunAltitude)
|
||||
}
|
||||
date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC)
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
||||
Step: 2 * time.Minute, TargetSpacingKM: 700,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar central path")
|
||||
}
|
||||
for _, shadowStep := range []time.Duration{0, 2 * time.Minute} {
|
||||
shadowStep := shadowStep
|
||||
t.Run(shadowStep.String(), func(t *testing.T) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: shadowStep,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar eclipse footprints")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
if !geometryContainsPoint(t, band.Geometry, 120.4913, 27.4779) {
|
||||
t.Fatal("central-band omits a station that visibly sees annularity")
|
||||
}
|
||||
if source := band.Properties["source"]; source != "besselian-critical-envelope" {
|
||||
t.Fatalf("central-band source=%v, want continuous critical envelope", source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSolarPathMaximumEdgeKM(
|
||||
t *testing.T,
|
||||
points []eclipse.SolarEclipsePathPoint,
|
||||
maximumKM float64,
|
||||
) {
|
||||
t.Helper()
|
||||
for index := 1; index < len(points); index++ {
|
||||
distance := geoJSONCoordinateDistanceKM(
|
||||
[]float64{points[index-1].Longitude, points[index-1].Latitude},
|
||||
[]float64{points[index].Longitude, points[index].Latitude},
|
||||
)
|
||||
if distance > maximumKM {
|
||||
t.Fatalf("horizon closure edge %d is %.3f km, want at most %.3f km", index-1, distance, maximumKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipse20120521HorizonClosuresAreStableAcrossSampling(t *testing.T) {
|
||||
date := time.Date(2012, time.May, 21, 0, 0, 0, 0, time.UTC)
|
||||
expectedRoots := [2][2][]float64{
|
||||
{{109.6236411, 19.9359003}, {107.7419895, 22.3910846}},
|
||||
{{-100.0879481, 34.1224726}, {-102.2069471, 31.7284052}},
|
||||
}
|
||||
for _, step := range []time.Duration{time.Minute, 5 * time.Minute, 10 * time.Minute} {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: step, BoundaryPoints: 96, CentralShadowStep: step,
|
||||
})
|
||||
if !ok || len(partial.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("step %s: closures=%d ok=%v, want two", step, len(partial.CentralBandHorizonClosures), ok)
|
||||
}
|
||||
for closureIndex, closure := range partial.CentralBandHorizonClosures {
|
||||
roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]}
|
||||
for rootIndex, root := range roots {
|
||||
if distance := geoJSONCoordinateDistanceKM(
|
||||
[]float64{root.Longitude, root.Latitude}, expectedRoots[closureIndex][rootIndex],
|
||||
); distance > 0.1 {
|
||||
t.Fatalf("step %s: closure %d root %d differs by %.3f km", step, closureIndex, rootIndex, distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func solarGreatestCurveContainsPoint(
|
||||
curves []eclipse.SolarEclipseRiseSetCurve,
|
||||
want eclipse.SolarEclipsePathPoint,
|
||||
direction eclipse.RiseSetDirection,
|
||||
) bool {
|
||||
for _, curve := range curves {
|
||||
if curve.Phase != eclipse.RiseSetPhaseGreatest || curve.Direction != direction {
|
||||
continue
|
||||
}
|
||||
for _, segment := range curve.Segments {
|
||||
for _, point := range segment {
|
||||
if point.Time.Equal(want.Time) && geoJSONCoordinateDistanceKM(
|
||||
[]float64{point.Longitude, point.Latitude},
|
||||
[]float64{want.Longitude, want.Latitude},
|
||||
) <= 0.001 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestMarshalSolarEclipse20560713CentralBandHasNoEndFold(t *testing.T) {
|
||||
date := time.Date(2056, time.July, 13, 0, 0, 0, 0, time.UTC)
|
||||
localDate := time.Date(2056, time.July, 12, 0, 0, 0, 0, time.UTC)
|
||||
local, localOK := eclipse.LocalSolarEclipseOnDate(localDate, -64.3737, -5.4978, 0)
|
||||
if !localOK || local.Type != eclipse.SolarEclipseAnnular || !local.HasCentral || local.SunAltitude <= 0 {
|
||||
t.Fatalf("reference site is not a visible annular eclipse: ok=%v type=%s central=%v altitude=%.6f",
|
||||
localOK, local.Type, local.HasCentral, local.SunAltitude)
|
||||
}
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute,
|
||||
BoundaryPoints: 96,
|
||||
CentralShadowStep: 2 * time.Minute,
|
||||
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar eclipse footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
||||
Step: 2 * time.Minute, TargetSpacingKM: 700,
|
||||
})
|
||||
if !ok || len(central.CenterLine) < 2 {
|
||||
t.Fatal("expected a central eclipse path")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
band := featureWithRole(t, collection, "central-band")
|
||||
assertClosedMultiPolygon(t, band)
|
||||
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode central band: %v", err)
|
||||
}
|
||||
if len(polygons) == 0 || len(polygons) > 2 {
|
||||
t.Fatalf("central band has %d polygons, want one physical band with at most one antimeridian split", len(polygons))
|
||||
}
|
||||
if len(polygons) == 2 && !solarCentralBandPartsMeetAntimeridian(polygons) {
|
||||
t.Fatal("two central-band polygons do not form an antimeridian split")
|
||||
}
|
||||
for _, polygon := range polygons {
|
||||
if len(polygon) == 0 {
|
||||
t.Fatal("central-band polygon has no exterior ring")
|
||||
}
|
||||
assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250)
|
||||
}
|
||||
if !geometryContainsPoint(t, band.Geometry, -64.3737, -5.4978) {
|
||||
t.Fatal("central-band omits the locally visible annular greatest point near U4")
|
||||
}
|
||||
|
||||
center := featureWithRole(t, collection, "center-line")
|
||||
var centerLines [][][]float64
|
||||
if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil {
|
||||
t.Fatalf("decode center line: %v", err)
|
||||
}
|
||||
for segmentIndex, segment := range centerLines {
|
||||
for pointIndex, point := range segment {
|
||||
if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) &&
|
||||
geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 10 {
|
||||
t.Fatalf("center-line segment %d point %d lies outside central band", segmentIndex, pointIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start := central.CenterLine[0]
|
||||
end := central.CenterLine[len(central.CenterLine)-1]
|
||||
assertSolarCentralBandDoesNotReverseNear(t, polygons, []float64{start.Longitude, start.Latitude}, 1600)
|
||||
assertSolarCentralBandDoesNotReverseNear(t, polygons, []float64{end.Longitude, end.Latitude}, 1600)
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipse20560713HorizonClosureIsStableAcrossSampling(t *testing.T) {
|
||||
date := time.Date(2056, time.July, 13, 0, 0, 0, 0, time.UTC)
|
||||
var referenceRoots [2][2]eclipse.SolarEclipsePathPoint
|
||||
for _, step := range []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute} {
|
||||
t.Run(step.String(), func(t *testing.T) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: step, BoundaryPoints: 96, CentralShadowStep: step,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar eclipse footprints")
|
||||
}
|
||||
if len(partial.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("central-limit horizon closures=%d, want start and end", len(partial.CentralBandHorizonClosures))
|
||||
}
|
||||
for closureIndex, closure := range partial.CentralBandHorizonClosures {
|
||||
if len(closure) < 2 {
|
||||
t.Fatalf("horizon closure %d has %d points", closureIndex, len(closure))
|
||||
}
|
||||
assertSolarPathMaximumEdgeKM(t, closure, 12)
|
||||
roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]}
|
||||
if referenceRoots[closureIndex][0].Time.IsZero() {
|
||||
referenceRoots[closureIndex] = roots
|
||||
} else {
|
||||
for rootIndex := range roots {
|
||||
if difference := roots[rootIndex].Time.Sub(referenceRoots[closureIndex][rootIndex].Time); difference < -time.Millisecond || difference > time.Millisecond {
|
||||
t.Fatalf("horizon closure %d root %d time differs by %s across sampling", closureIndex, rootIndex, difference)
|
||||
}
|
||||
if distance := geoJSONCoordinateDistanceKM(
|
||||
[]float64{roots[rootIndex].Longitude, roots[rootIndex].Latitude},
|
||||
[]float64{referenceRoots[closureIndex][rootIndex].Longitude, referenceRoots[closureIndex][rootIndex].Latitude},
|
||||
); distance > 0.01 {
|
||||
t.Fatalf("horizon closure %d root %d differs by %.3f km across sampling", closureIndex, rootIndex, distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
||||
Step: step, TargetSpacingKM: 700,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected central eclipse path")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
if band.Properties["source"] != "besselian-critical-envelope" {
|
||||
t.Fatalf("central-band source=%v, want continuous critical envelope", band.Properties["source"])
|
||||
}
|
||||
if !geometryContainsPoint(t, band.Geometry, -64.3737, -5.4978) {
|
||||
t.Fatal("central-band omits the locally visible annular point")
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode central band: %v", err)
|
||||
}
|
||||
if len(polygons) == 0 || len(polygons) > 2 {
|
||||
t.Fatalf("central band has %d polygons", len(polygons))
|
||||
}
|
||||
for _, polygon := range polygons {
|
||||
assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func solarCentralBandPartsMeetAntimeridian(polygons [][][][]float64) bool {
|
||||
hasEast, hasWest := false, false
|
||||
for _, polygon := range polygons {
|
||||
for _, ring := range polygon {
|
||||
for _, point := range ring {
|
||||
hasEast = hasEast || math.Abs(point[0]-180) <= 1e-9
|
||||
hasWest = hasWest || math.Abs(point[0]+180) <= 1e-9
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasEast && hasWest
|
||||
}
|
||||
|
||||
func assertSolarCentralBandRingSimpleAndSampled(t *testing.T, ring [][]float64, maximumEdgeKM float64) {
|
||||
t.Helper()
|
||||
for index := 1; index < len(ring); index++ {
|
||||
if distance := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); distance > maximumEdgeKM {
|
||||
t.Fatalf("central-band edge %d is %.1f km, want at most %.1f km", index, distance, maximumEdgeKM)
|
||||
}
|
||||
}
|
||||
for first := 0; first+1 < len(ring); first++ {
|
||||
for second := first + 2; second+1 < len(ring); second++ {
|
||||
if first == 0 && second+1 == len(ring)-1 {
|
||||
continue
|
||||
}
|
||||
if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) {
|
||||
t.Fatalf("central-band ring self-intersects between edges %d and %d", first, second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSolarCentralBandDoesNotReverseNear(t *testing.T, polygons [][][][]float64, center []float64, radiusKM float64) {
|
||||
t.Helper()
|
||||
checked := 0
|
||||
for _, polygon := range polygons {
|
||||
if len(polygon) == 0 {
|
||||
continue
|
||||
}
|
||||
ring := polygon[0]
|
||||
for index := 1; index+1 < len(ring); index++ {
|
||||
if geoJSONCoordinateDistanceKM(ring[index], center) > radiusKM {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
incoming := solarRegressionProjectedVector(ring[index], ring[index-1])
|
||||
outgoing := solarRegressionProjectedVector(ring[index], ring[index+1])
|
||||
incomingLength := math.Hypot(incoming[0], incoming[1])
|
||||
outgoingLength := math.Hypot(outgoing[0], outgoing[1])
|
||||
if incomingLength == 0 || outgoingLength == 0 {
|
||||
continue
|
||||
}
|
||||
cosine := (incoming[0]*outgoing[0] + incoming[1]*outgoing[1]) / (incomingLength * outgoingLength)
|
||||
if cosine > 0.985 {
|
||||
t.Fatalf("central-band boundary reverses by %.1f degrees at %.5f, %.5f between [%.5f, %.5f] and [%.5f, %.5f]",
|
||||
math.Acos(math.Max(-1, math.Min(1, cosine)))*180/math.Pi,
|
||||
ring[index][0], ring[index][1],
|
||||
ring[index-1][0], ring[index-1][1],
|
||||
ring[index+1][0], ring[index+1][1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("central-band boundary has no samples near the event end")
|
||||
}
|
||||
}
|
||||
|
||||
func solarRegressionProjectedVector(origin, point []float64) [2]float64 {
|
||||
latitude := origin[1] * math.Pi / 180
|
||||
deltaLongitude := math.Remainder(point[0]-origin[0], 360)
|
||||
return [2]float64{deltaLongitude * math.Cos(latitude), point[1] - origin[1]}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestMarshalSolarEclipse20611013TangentCentralBandAvoidsAxisEndpointCap(t *testing.T) {
|
||||
date := time.Date(2061, 10, 13, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
||||
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1},
|
||||
})
|
||||
if !ok || partial.Eclipse.Type != eclipse.SolarEclipseAnnular ||
|
||||
partial.Eclipse.Centrality != eclipse.SolarEclipseCentralTwoLimits {
|
||||
t.Fatalf("unexpected event: ok=%v type=%s centrality=%s", ok, partial.Eclipse.Type, partial.Eclipse.Centrality)
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
||||
Step: 2 * time.Minute, TargetSpacingKM: 700,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected central path")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
if source := band.Properties["source"]; source != "besselian-critical-envelope" {
|
||||
t.Fatalf("central-band source=%v, want a validated continuous central band", source)
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode central band: %v", err)
|
||||
}
|
||||
if len(polygons) != 1 || len(polygons[0]) == 0 {
|
||||
t.Fatalf("central-band polygons=%d, want one exterior ring", len(polygons))
|
||||
}
|
||||
ring := polygons[0][0]
|
||||
if len(ring) < 20 {
|
||||
t.Fatalf("central-band ring points=%d, want a spatially refined envelope", len(ring))
|
||||
}
|
||||
assertSolarCentralBandRingSimpleAndSampled(t, ring, 250)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestMarshalSolarEclipse21640323ContainsVisibleAnnularEnds(t *testing.T) {
|
||||
date := time.Date(2164, 3, 23, 0, 0, 0, 0, time.UTC)
|
||||
sites := [][2]float64{{-110.6, 48.75}, {-110.6, 48.5}, {-110.7, 48.76}, {-120.575, 48.75}, {-120.575, 48.745}}
|
||||
for _, site := range sites {
|
||||
local, ok := eclipse.LocalSolarEclipseOnDate(date, site[0], site[1], 0)
|
||||
if !ok || local.Type != eclipse.SolarEclipseAnnular || local.SunAltitude <= 0 {
|
||||
t.Fatalf("reference site %v is not visible annular greatest: %+v", site, local)
|
||||
}
|
||||
}
|
||||
for _, step := range []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute} {
|
||||
t.Run(step.String(), func(t *testing.T) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: step, BoundaryPoints: 96, CentralShadowStep: step,
|
||||
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1},
|
||||
})
|
||||
if !ok || partial.Eclipse.Type != eclipse.SolarEclipseHybrid {
|
||||
t.Fatal("expected hybrid eclipse")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: step, TargetSpacingKM: 700})
|
||||
if !ok {
|
||||
t.Fatal("expected central path")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
for _, site := range sites {
|
||||
if !geometryContainsPoint(t, band.Geometry, site[0], site[1]) {
|
||||
t.Errorf("central band omits visible annular site %v", site)
|
||||
}
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, polygon := range polygons {
|
||||
assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250)
|
||||
}
|
||||
if len(partial.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("horizon closures=%d, want sunrise and sunset", len(partial.CentralBandHorizonClosures))
|
||||
}
|
||||
for _, closure := range partial.CentralBandHorizonClosures {
|
||||
assertSolarPathMaximumEdgeKM(t, closure, 12)
|
||||
for _, point := range closure {
|
||||
if distance := geoJSONMultiPolygonBoundaryDistanceKM(polygons, []float64{point.Longitude, point.Latitude}); distance > 0.02 {
|
||||
t.Fatalf("central band misses horizon closure by %.3f km", distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
func TestMarshalSolarEclipse25441017PreservesHybridEnvelopeComponents(t *testing.T) {
|
||||
date := time.Date(2544, 10, 17, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 5 * time.Minute,
|
||||
BoundaryPoints: 96,
|
||||
CentralShadowStep: 5 * time.Minute,
|
||||
RiseSetStep: 5 * time.Minute,
|
||||
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1},
|
||||
})
|
||||
if !ok || partial.Eclipse.Type != eclipse.SolarEclipseHybrid || len(partial.CentralBandSegments) != 3 {
|
||||
t.Fatalf("unexpected hybrid envelope: ok=%v type=%s segments=%d", ok, partial.Eclipse.Type, len(partial.CentralBandSegments))
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
||||
Step: 5 * time.Minute, TargetSpacingKM: 700,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("missing hybrid central path")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
if source := band.Properties["source"]; source != "besselian-critical-envelope-components" && source != "besselian-critical-envelope" {
|
||||
t.Fatalf("central band source=%v, want critical envelope", source)
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(polygons) < 3 {
|
||||
t.Fatalf("central band polygons=%d, want at least three physical components", len(polygons))
|
||||
}
|
||||
for polygonIndex, polygon := range polygons {
|
||||
if len(polygon) != 1 {
|
||||
t.Fatalf("central band polygon %d rings=%d, want one", polygonIndex, len(polygon))
|
||||
}
|
||||
assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250)
|
||||
}
|
||||
var sourceRings [][]geodata.GeoPoint
|
||||
for _, segment := range partial.CentralBandSegments {
|
||||
var ring []geodata.GeoPoint
|
||||
for _, point := range segment {
|
||||
ring = append(ring, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
sourceRings = append(sourceRings, ring)
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(geoJSONMultiPolygonOuterRings(t, band), sourceRings, true); miss > 1 {
|
||||
t.Fatalf("hybrid components leave the exported band by %.3f km", miss)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestMarshalSolarEclipse29020726DoesNotFillNighttimeCanada(t *testing.T) {
|
||||
date := time.Date(2902, time.July, 26, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
||||
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar eclipse footprints")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "partial-band")
|
||||
if band.Geometry.Type != "MultiPolygon" {
|
||||
t.Fatalf("partial-band geometry=%q, want MultiPolygon", band.Geometry.Type)
|
||||
}
|
||||
for _, site := range []struct {
|
||||
name string
|
||||
longitude float64
|
||||
latitude float64
|
||||
wantInside bool
|
||||
}{
|
||||
{name: "Edmonton", longitude: -113.4909, latitude: 53.5461},
|
||||
{name: "Churchill", longitude: -94.1650, latitude: 58.7684},
|
||||
{name: "Iqaluit", longitude: -68.5170, latitude: 63.7467},
|
||||
{name: "Resolute", longitude: -94.8297, latitude: 74.6973, wantInside: true},
|
||||
{name: "Alert", longitude: -62.3481, latitude: 82.5018, wantInside: true},
|
||||
{name: "Bering east of envelope", longitude: 179, latitude: 65},
|
||||
{name: "Bering inside excluded pocket", longitude: 175, latitude: 65},
|
||||
{name: "Bering west of envelope", longitude: 170, latitude: 65, wantInside: true},
|
||||
{name: "Bering west world", longitude: -179, latitude: 65},
|
||||
{name: "Arctic east world", longitude: 179.9, latitude: 80, wantInside: true},
|
||||
} {
|
||||
if got := geometryContainsPoint(t, band.Geometry, site.longitude, site.latitude); got != site.wantInside {
|
||||
t.Errorf("partial-band contains %s=%v, want %v", site.name, got, site.wantInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// 3288-11-16 in the local display falls on 3288-11-15 UTC. It is an
|
||||
// extremely shallow non-central eclipse: the sampled footprints are open,
|
||||
// while the rise/set phase tracks still form part of the visible envelope.
|
||||
func TestMarshalSolarEclipse32881115FallbackCoversPhaseTracks(t *testing.T) {
|
||||
path, ok := eclipse.SolarEclipsePartialFootprints(
|
||||
time.Date(3288, time.November, 15, 0, 0, 0, 0, time.UTC),
|
||||
eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 5 * time.Minute, BoundaryPoints: 96,
|
||||
CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute,
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected 3288-11-15 UTC solar eclipse")
|
||||
}
|
||||
if len(path.PartialBandContours) == 0 || len(path.RiseSetCurves) == 0 {
|
||||
t.Fatalf("unexpected topology: contours=%d curves=%d", len(path.PartialBandContours), len(path.RiseSetCurves))
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "partial-band")
|
||||
rings := geoJSONMultiPolygonOuterRings(t, band)
|
||||
lines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves)*2)
|
||||
for _, curve := range path.RiseSetCurves {
|
||||
for _, segment := range curve.Segments {
|
||||
line := make([]geodata.GeoPoint, len(segment))
|
||||
for index, point := range segment {
|
||||
line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, lines, false); miss > 2 {
|
||||
t.Fatalf("fallback partial-band misses phase tracks by %.1f km", miss)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
geojson "b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestMarshalSolarEclipse43290612UsesUnifiedPolarCentralBand(t *testing.T) {
|
||||
for _, sample := range []struct{ year, month, day int }{{4329, 6, 12}, {4005, 4, 22}} {
|
||||
t.Run(time.Date(sample.year, time.Month(sample.month), sample.day, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), func(t *testing.T) {
|
||||
date := time.Date(sample.year, time.Month(sample.month), sample.day, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{Step: 2 * time.Minute, BoundaryPoints: 96})
|
||||
if !ok || len(partial.CentralBandSegments) != 1 {
|
||||
t.Fatalf("partial central envelope unavailable: ok=%v segments=%d", ok, len(partial.CentralBandSegments))
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute, TargetSpacingKM: 700})
|
||||
if !ok || len(central.CentralBandSegments) != 1 {
|
||||
t.Fatalf("central path envelope unavailable: ok=%v segments=%d", ok, len(central.CentralBandSegments))
|
||||
}
|
||||
if got, want := len(central.CentralBandSegments[0]), len(partial.CentralBandSegments[0]); got != want {
|
||||
t.Fatalf("path/partial envelope point count=%d/%d", got, want)
|
||||
}
|
||||
for _, index := range []int{0, len(partial.CentralBandSegments[0]) / 2} {
|
||||
got, want := central.CentralBandSegments[0][index], partial.CentralBandSegments[0][index]
|
||||
if math.Abs(got.Longitude-want.Longitude) > 1e-9 || math.Abs(got.Latitude-want.Latitude) > 1e-9 {
|
||||
t.Fatalf("path/partial envelope diverges at %d: got=(%.9f,%.9f) want=(%.9f,%.9f)", index, got.Longitude, got.Latitude, want.Longitude, want.Latitude)
|
||||
}
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
if source, ok := band.Properties["source"].(string); !ok || source == "" {
|
||||
t.Fatalf("central-band source=%v, want a physical envelope source", band.Properties["source"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
// 本文件把上游消费方要的契约钉死:
|
||||
// 1. central-shadow-footprint 要么缺省、要么是 Polygon/MultiPolygon,永不出现线类型;
|
||||
// 2. 被地平线切断的足迹用该时刻地平圈上的擦地点闭合,闭合弧与地平圈在容差内一致;
|
||||
// 3. 物理边界曲线另出 central-shadow-boundary,顶点与采样曲线一致,供调用方描边;
|
||||
// 4. 区域外环保持右手定则,且不出现跨图收口边(含绕极环)。
|
||||
// This file pins the contract the consumers asked for: the footprint role is always a
|
||||
// region, the horizon closure really lies on the horizon circle of the footprint time,
|
||||
// the physical boundary is exported separately with unchanged vertices, and every
|
||||
// outer ring is right-handed without a synthetic seam.
|
||||
|
||||
const centralShadowHorizonToleranceDegrees = 0.01
|
||||
|
||||
type centralShadowRegionFixture struct {
|
||||
name string
|
||||
year int
|
||||
month time.Month
|
||||
day int
|
||||
wantHorizon bool
|
||||
}
|
||||
|
||||
func centralShadowRegionFixtures() []centralShadowRegionFixture {
|
||||
return []centralShadowRegionFixture{
|
||||
{name: "2009-07-22 total, both limb cuts", year: 2009, month: time.July, day: 22, wantHorizon: true},
|
||||
{name: "2014-04-29 non-central annular, every footprint cut", year: 2014, month: time.April, day: 29, wantHorizon: true},
|
||||
{name: "2043-04-09 total, every footprint cut", year: 2043, month: time.April, day: 9, wantHorizon: true},
|
||||
{name: "2021-12-04 antarctic total", year: 2021, month: time.December, day: 4, wantHorizon: false},
|
||||
{name: "2021-06-10 pole-enclosing footprints", year: 2021, month: time.June, day: 10, wantHorizon: false},
|
||||
{name: "2061-10-13 annular with many cut footprints", year: 2061, month: time.October, day: 13, wantHorizon: true},
|
||||
}
|
||||
}
|
||||
|
||||
func centralShadowMarshalFixture(
|
||||
t *testing.T,
|
||||
fixture centralShadowRegionFixture,
|
||||
) (decodedCollection, eclipse.SolarEclipsePartialFootprintsInfo) {
|
||||
t.Helper()
|
||||
info, ok := eclipse.SolarEclipsePartialFootprints(
|
||||
time.Date(fixture.year, fixture.month, fixture.day, 0, 0, 0, 0, time.UTC),
|
||||
eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
||||
DisableRiseSet: true,
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatalf("%s: no solar eclipse", fixture.name)
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(info, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: MarshalSolarEclipse: %v", fixture.name, err)
|
||||
}
|
||||
return decodeCollection(t, data), info
|
||||
}
|
||||
|
||||
func TestSolarEclipseCentralShadowFootprintIsAlwaysARegion(t *testing.T) {
|
||||
cutRegions := 0
|
||||
for _, fixture := range centralShadowRegionFixtures() {
|
||||
collection, info := centralShadowMarshalFixture(t, fixture)
|
||||
if len(info.CentralShadowFootprints) == 0 {
|
||||
t.Fatalf("%s: sampler produced no central-shadow footprints", fixture.name)
|
||||
}
|
||||
regions := featuresWithRole(collection, "central-shadow-footprint")
|
||||
if len(regions) == 0 {
|
||||
t.Fatalf("%s: GeoJSON has no central-shadow-footprint", fixture.name)
|
||||
}
|
||||
openRegions := 0
|
||||
for _, region := range regions {
|
||||
switch region.Geometry.Type {
|
||||
case "Polygon", "MultiPolygon":
|
||||
default:
|
||||
t.Fatalf("%s: central-shadow-footprint geometry=%q, want Polygon or MultiPolygon",
|
||||
fixture.name, region.Geometry.Type)
|
||||
}
|
||||
closed, present := region.Properties["source_boundary_closed"].(bool)
|
||||
if !present {
|
||||
t.Fatalf("%s: central-shadow-footprint has no source_boundary_closed property", fixture.name)
|
||||
}
|
||||
if !closed {
|
||||
openRegions++
|
||||
closure, present := region.Properties["closure"].(map[string]interface{})
|
||||
if !present {
|
||||
t.Fatalf("%s: horizon-cut footprint has no closure property", fixture.name)
|
||||
}
|
||||
if closure["kind"] != "horizon" {
|
||||
t.Fatalf("%s: closure kind=%v, want horizon", fixture.name, closure["kind"])
|
||||
}
|
||||
if closure["time"] != region.Properties["time"] {
|
||||
t.Fatalf("%s: closure time=%v, want the footprint time %v",
|
||||
fixture.name, closure["time"], region.Properties["time"])
|
||||
}
|
||||
} else if _, present := region.Properties["closure"]; present {
|
||||
t.Fatalf("%s: self-closed footprint must not carry a closure property", fixture.name)
|
||||
}
|
||||
}
|
||||
boundaries := featuresWithRole(collection, "central-shadow-boundary")
|
||||
if len(boundaries) != openRegions {
|
||||
t.Fatalf("%s: central-shadow-boundary count=%d, want one per horizon-cut region (%d)",
|
||||
fixture.name, len(boundaries), openRegions)
|
||||
}
|
||||
for _, boundary := range boundaries {
|
||||
if boundary.Geometry.Type != "MultiLineString" {
|
||||
t.Fatalf("%s: central-shadow-boundary geometry=%q, want MultiLineString",
|
||||
fixture.name, boundary.Geometry.Type)
|
||||
}
|
||||
if closed, _ := boundary.Properties["source_boundary_closed"].(bool); closed {
|
||||
t.Fatalf("%s: central-shadow-boundary must stay an open boundary", fixture.name)
|
||||
}
|
||||
}
|
||||
if fixture.wantHorizon && openRegions == 0 {
|
||||
t.Fatalf("%s: expected at least one horizon-cut central-shadow footprint", fixture.name)
|
||||
}
|
||||
cutRegions += openRegions
|
||||
}
|
||||
if cutRegions == 0 {
|
||||
t.Fatal("no horizon-cut central-shadow footprint in the fixture set; the contract is untested")
|
||||
}
|
||||
}
|
||||
|
||||
func centralShadowRegionRings(t *testing.T, feature decodedFeature) [][][2]float64 {
|
||||
t.Helper()
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode central-shadow-footprint polygon: %v", err)
|
||||
}
|
||||
rings := make([][][2]float64, 0, len(polygons))
|
||||
for _, polygon := range polygons {
|
||||
if len(polygon) == 0 {
|
||||
continue
|
||||
}
|
||||
ring := make([][2]float64, 0, len(polygon[0]))
|
||||
for _, coordinate := range polygon[0] {
|
||||
ring = append(ring, [2]float64{coordinate[0], coordinate[1]})
|
||||
}
|
||||
rings = append(rings, ring)
|
||||
}
|
||||
return rings
|
||||
}
|
||||
|
||||
func centralShadowBoundaryLines(t *testing.T, feature decodedFeature) [][][2]float64 {
|
||||
t.Helper()
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode central-shadow-boundary line: %v", err)
|
||||
}
|
||||
result := make([][][2]float64, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
points := make([][2]float64, 0, len(line))
|
||||
for _, coordinate := range line {
|
||||
points = append(points, [2]float64{coordinate[0], coordinate[1]})
|
||||
}
|
||||
result = append(result, points)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func centralShadowSubsolarPoint(value time.Time) (float64, float64) {
|
||||
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
|
||||
ra, dec := basic.HSunApparentRaDec(ttJDE)
|
||||
utJDE := basic.TD2UT(ttJDE, false)
|
||||
longitude := ra - basic.ApparentSiderealTime(utJDE)*15
|
||||
for longitude > 180 {
|
||||
longitude -= 360
|
||||
}
|
||||
for longitude < -180 {
|
||||
longitude += 360
|
||||
}
|
||||
return longitude, dec
|
||||
}
|
||||
|
||||
func centralShadowHorizonDistanceDegrees(vertex [2]float64, longitude, latitude float64) float64 {
|
||||
first, second := vertex[1]*math.Pi/180, latitude*math.Pi/180
|
||||
deltaLongitude := (longitude - vertex[0]) * math.Pi / 180
|
||||
deltaLatitude := second - first
|
||||
h := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) +
|
||||
math.Cos(first)*math.Cos(second)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2)
|
||||
if h > 1 {
|
||||
h = 1
|
||||
}
|
||||
// 90 degrees minus the distance to the subsolar point is the solar altitude.
|
||||
return 90 - 2*math.Asin(math.Sqrt(h))*180/math.Pi
|
||||
}
|
||||
|
||||
// centralShadowClosureViolations 返回所有"没有落在地平圈上"的闭合弧顶点描述。
|
||||
// centralShadowClosureViolations reports every closure vertex that misses the horizon.
|
||||
func centralShadowClosureViolations(
|
||||
t *testing.T,
|
||||
collection decodedCollection,
|
||||
) []string {
|
||||
t.Helper()
|
||||
return solarShadowClosureViolations(t, collection, "central-shadow-footprint")
|
||||
}
|
||||
|
||||
func solarShadowClosureViolations(
|
||||
t *testing.T,
|
||||
collection decodedCollection,
|
||||
role string,
|
||||
) []string {
|
||||
t.Helper()
|
||||
var issues []string
|
||||
for _, region := range featuresWithRole(collection, role) {
|
||||
closed, _ := region.Properties["source_boundary_closed"].(bool)
|
||||
if closed {
|
||||
continue
|
||||
}
|
||||
stamp, _ := region.Properties["time"].(string)
|
||||
value, err := time.Parse(time.RFC3339Nano, stamp)
|
||||
if err != nil {
|
||||
t.Fatalf("parse footprint time %q: %v", stamp, err)
|
||||
}
|
||||
longitude, latitude := centralShadowSubsolarPoint(value)
|
||||
onHorizon := 0
|
||||
for _, ring := range centralShadowRegionRings(t, region) {
|
||||
for _, vertex := range ring {
|
||||
altitude := centralShadowHorizonDistanceDegrees(vertex, longitude, latitude)
|
||||
if altitude < -centralShadowHorizonToleranceDegrees {
|
||||
issues = append(issues, fmt.Sprintf(
|
||||
"%s: vertex %.6f,%.6f is %.4f deg below the horizon", stamp, vertex[0], vertex[1], altitude))
|
||||
continue
|
||||
}
|
||||
if math.Abs(altitude) <= centralShadowHorizonToleranceDegrees {
|
||||
onHorizon++
|
||||
}
|
||||
}
|
||||
}
|
||||
if onHorizon < 2 {
|
||||
issues = append(issues, fmt.Sprintf(
|
||||
"%s: only %d ring vertices lie on the horizon, want the closing arc", stamp, onHorizon))
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func TestSolarEclipseCentralShadowClosureLiesOnTheHorizon(t *testing.T) {
|
||||
for _, fixture := range centralShadowRegionFixtures() {
|
||||
collection, _ := centralShadowMarshalFixture(t, fixture)
|
||||
if issues := centralShadowClosureViolations(t, collection); len(issues) > 0 {
|
||||
t.Fatalf("%s: %s", fixture.name, issues[0])
|
||||
}
|
||||
// The physical boundary feature must start and end on the horizon as well,
|
||||
// otherwise the region would still be cut short of the terminator.
|
||||
for _, boundary := range featuresWithRole(collection, "central-shadow-boundary") {
|
||||
stamp, _ := boundary.Properties["time"].(string)
|
||||
value, err := time.Parse(time.RFC3339Nano, stamp)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: parse boundary time %q: %v", fixture.name, stamp, err)
|
||||
}
|
||||
longitude, latitude := centralShadowSubsolarPoint(value)
|
||||
for _, line := range centralShadowBoundaryLines(t, boundary) {
|
||||
for _, index := range []int{0, len(line) - 1} {
|
||||
altitude := centralShadowHorizonDistanceDegrees(line[index], longitude, latitude)
|
||||
if math.Abs(altitude) > centralShadowHorizonToleranceDegrees {
|
||||
t.Fatalf("%s: %s boundary endpoint %.6f,%.6f has altitude %.4f deg, want on the horizon",
|
||||
fixture.name, stamp, line[index][0], line[index][1], altitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolarEclipseCentralShadowClosureTestDetectsCutShortBoundaries 证明上面的容差检查
|
||||
// 真的能抓住"边界提前停止"的几何:把擦地点清空后退回旧的封口方式,检查必须报错。
|
||||
// TestSolarEclipseCentralShadowClosureTestDetectsCutShortBoundaries proves the
|
||||
// tolerance check above has teeth: without the grazing points the fallback closure
|
||||
// stops short of the horizon and the check must report it.
|
||||
func TestSolarEclipseCentralShadowClosureTestDetectsCutShortBoundaries(t *testing.T) {
|
||||
var fixture centralShadowRegionFixture
|
||||
for _, candidate := range centralShadowRegionFixtures() {
|
||||
if candidate.wantHorizon {
|
||||
fixture = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
info, ok := eclipse.SolarEclipsePartialFootprints(
|
||||
time.Date(fixture.year, fixture.month, fixture.day, 0, 0, 0, 0, time.UTC),
|
||||
eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
||||
DisableRiseSet: true,
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatalf("%s: no solar eclipse", fixture.name)
|
||||
}
|
||||
open := 0
|
||||
for index := range info.CentralShadowFootprints {
|
||||
if info.CentralShadowFootprints[index].Closed {
|
||||
continue
|
||||
}
|
||||
info.CentralShadowFootprints[index].HorizonEnds = nil
|
||||
open++
|
||||
}
|
||||
if open == 0 {
|
||||
t.Fatalf("%s: no horizon-cut footprint to degrade", fixture.name)
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(info, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: MarshalSolarEclipse: %v", fixture.name, err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
if len(featuresWithRole(collection, "central-shadow-boundary")) == 0 {
|
||||
t.Fatal("degraded export lost its physical boundary features")
|
||||
}
|
||||
if issues := centralShadowClosureViolations(t, collection); len(issues) == 0 {
|
||||
t.Fatal("the horizon tolerance check passed on a closure that stops short of the horizon")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseCentralShadowBoundaryKeepsTheSampledCurve(t *testing.T) {
|
||||
fixture := centralShadowRegionFixtures()[0]
|
||||
collection, info := centralShadowMarshalFixture(t, fixture)
|
||||
boundaries := featuresWithRole(collection, "central-shadow-boundary")
|
||||
if len(boundaries) == 0 {
|
||||
t.Fatalf("%s: no central-shadow-boundary features", fixture.name)
|
||||
}
|
||||
byTime := map[string]decodedFeature{}
|
||||
for _, boundary := range boundaries {
|
||||
stamp, _ := boundary.Properties["time"].(string)
|
||||
byTime[stamp] = boundary
|
||||
}
|
||||
checked := 0
|
||||
for _, footprint := range info.CentralShadowFootprints {
|
||||
if footprint.Closed {
|
||||
continue
|
||||
}
|
||||
feature, present := byTime[footprint.Time.UTC().Format(time.RFC3339Nano)]
|
||||
if !present {
|
||||
t.Fatalf("%s: horizon-cut footprint %v has no central-shadow-boundary feature",
|
||||
fixture.name, footprint.Time.UTC())
|
||||
}
|
||||
expected := make([][2]float64, 0, len(footprint.HorizonEnds)+2)
|
||||
expected = append(expected, [2]float64{footprint.HorizonEnds[0].Longitude, footprint.HorizonEnds[0].Latitude})
|
||||
for _, segment := range footprint.Boundaries {
|
||||
for _, point := range segment {
|
||||
expected = append(expected, [2]float64{point.Longitude, point.Latitude})
|
||||
}
|
||||
}
|
||||
expected = append(expected, [2]float64{footprint.HorizonEnds[1].Longitude, footprint.HorizonEnds[1].Latitude})
|
||||
lines := centralShadowBoundaryLines(t, feature)
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("%s: %v boundary has %d segments, want the joined physical curve",
|
||||
fixture.name, footprint.Time.UTC(), len(lines))
|
||||
}
|
||||
if len(lines[0]) != len(expected) {
|
||||
t.Fatalf("%s: %v boundary has %d vertices, want %d (grazing points plus the sampled curve)",
|
||||
fixture.name, footprint.Time.UTC(), len(lines[0]), len(expected))
|
||||
}
|
||||
for index := range expected {
|
||||
if lines[0][index] != expected[index] {
|
||||
t.Fatalf("%s: %v boundary vertex %d = %.9f,%.9f, want %.9f,%.9f",
|
||||
fixture.name, footprint.Time.UTC(), index,
|
||||
lines[0][index][0], lines[0][index][1], expected[index][0], expected[index][1])
|
||||
}
|
||||
}
|
||||
checked++
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatalf("%s: no horizon-cut footprint was compared", fixture.name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseCentralShadowRegionRingsAreRightHanded(t *testing.T) {
|
||||
poleRings := 0
|
||||
for _, fixture := range centralShadowRegionFixtures() {
|
||||
collection, _ := centralShadowMarshalFixture(t, fixture)
|
||||
for _, region := range featuresWithRole(collection, "central-shadow-footprint") {
|
||||
for _, ring := range centralShadowRegionRings(t, region) {
|
||||
if len(ring) < 4 {
|
||||
t.Fatalf("%s: ring has %d vertices, want a closed ring", fixture.name, len(ring))
|
||||
}
|
||||
area, jumps, winding := 0.0, 0, 0.0
|
||||
for index := range ring {
|
||||
next := ring[(index+1)%len(ring)]
|
||||
area += ring[index][0]*next[1] - next[0]*ring[index][1]
|
||||
delta := math.Remainder(next[0]-ring[index][0], 360)
|
||||
winding += delta
|
||||
if math.Abs(delta) > 180 {
|
||||
jumps++
|
||||
}
|
||||
}
|
||||
if area <= 0 {
|
||||
t.Fatalf("%s: %v ring is not counter-clockwise in lon/lat (area %.6f)",
|
||||
fixture.name, region.Properties["time"], area/2)
|
||||
}
|
||||
if jumps > 0 {
|
||||
t.Fatalf("%s: %v ring has %d segment(s) jumping across the map edge",
|
||||
fixture.name, region.Properties["time"], jumps)
|
||||
}
|
||||
if math.Abs(winding) >= 180 {
|
||||
poleRings++
|
||||
if hemisphereArea := 360 * 180; area/2 >= float64(hemisphereArea) {
|
||||
t.Fatalf("%s: %v pole ring covers %.1f deg2, want the enclosed cap",
|
||||
fixture.name, region.Properties["time"], area/2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if poleRings == 0 {
|
||||
t.Fatal("no pole-enclosing ring in the fixture set; the polar convention is untested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseSampledPartialClosureLiesOnTheHorizon(t *testing.T) {
|
||||
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||||
partial, 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")
|
||||
}
|
||||
collection := decodeCollection(t, mustMarshalSolarEclipse(t, partial))
|
||||
if issues := solarShadowClosureViolations(t, collection, "partial-footprint"); len(issues) > 0 {
|
||||
t.Fatalf("sampled partial footprint: %s", issues[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// The static central band must describe the region where the eclipse is
|
||||
// actually annular or total. For grazing events the shadow axis crosses Earth
|
||||
// over only a fraction of the umbral contact interval, so a band derived from
|
||||
// the paired limits alone silently drops the flared ends of the real path.
|
||||
// These fixtures pin the coverage that NASA's path tables list from U1 to U4
|
||||
// (for example 2003 May 31: limits from 004 35.9W to 060 19.3W).
|
||||
const grazingBandCoverageToleranceKM = 100.0
|
||||
|
||||
type grazingBandCase struct {
|
||||
date string
|
||||
options string
|
||||
limit float64 // maximum tolerated footprint distance outside the band
|
||||
}
|
||||
|
||||
var grazingBandCases = []grazingBandCase{
|
||||
// One-limit (|gamma| ~ 0.98-0.997) annulars: the reported defect.
|
||||
{"2003-05-31", "overview", 25},
|
||||
{"2003-05-31", "detail", 25},
|
||||
{"1874-10-10", "overview", 60},
|
||||
{"1874-10-10", "detail", 60},
|
||||
// One-limit total across the antimeridian.
|
||||
{"2185-07-26", "overview", 50},
|
||||
{"2185-07-26", "detail", 25},
|
||||
// Two-limit annulars whose analytic envelope is unavailable and whose
|
||||
// paired-limit ribbon used to be accepted without validation.
|
||||
{"1552-07-21", "overview", 25},
|
||||
{"-1480-12-27", "overview", 60},
|
||||
{"4862-09-28", "overview", 60},
|
||||
{"1042-06-20", "overview", 80},
|
||||
{"5705-06-17", "overview", 80},
|
||||
// Already-correct polar one-limit totality: must not regress.
|
||||
{"1522-03-27", "overview", 10},
|
||||
{"1522-03-27", "detail", 10},
|
||||
// Ordinary two-limit totality.
|
||||
{"2024-04-08", "overview", 25},
|
||||
}
|
||||
|
||||
func TestSolarEclipseGrazingCentralBandCoversUmbralSweep(t *testing.T) {
|
||||
for _, testCase := range grazingBandCases {
|
||||
t.Run(testCase.date+"-"+testCase.options, func(t *testing.T) {
|
||||
date := grazingBandDate(t, testCase.date)
|
||||
info, ok := eclipse.SolarEclipseOnDate(date)
|
||||
if !ok || !info.HasCentral {
|
||||
t.Fatalf("expected a central solar eclipse on %s", testCase.date)
|
||||
}
|
||||
partialOptions, pathOptions := grazingBandOptions(info, testCase.options)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing central path")
|
||||
}
|
||||
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rings := grazingBandRings(t, raw)
|
||||
if len(rings) == 0 {
|
||||
t.Fatal("missing central-band feature")
|
||||
}
|
||||
paths := grazingFootprintPaths(partial.CentralBandFootprints)
|
||||
if len(paths) == 0 {
|
||||
t.Fatal("missing central band footprints")
|
||||
}
|
||||
miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, paths, true)
|
||||
if miss > testCase.limit {
|
||||
t.Fatalf("central band leaves the umbral sweep %.1f km outside (limit %.1f km)",
|
||||
miss, testCase.limit)
|
||||
}
|
||||
centerPath := make([]geodata.GeoPoint, 0, len(central.CenterLine))
|
||||
for _, point := range central.CenterLine {
|
||||
centerPath = append(centerPath, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
if centerMiss := geodata.SphericalPolygonsPathMissDistanceKM(
|
||||
rings, [][]geodata.GeoPoint{centerPath}, false,
|
||||
); centerMiss > 25 {
|
||||
t.Fatalf("central band leaves the center line %.1f km outside", centerMiss)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolarEclipseGrazingCentralBandIsNotRejectedAsEnvelope guards the other
|
||||
// direction: the flared-end band must still be a single closed continuous
|
||||
// activation per mode, not a fan of open slices.
|
||||
func TestSolarEclipseGrazingCentralBandIsSingleContinuousBand(t *testing.T) {
|
||||
for _, testCase := range grazingBandCases {
|
||||
if testCase.options != "overview" {
|
||||
continue
|
||||
}
|
||||
t.Run(testCase.date, func(t *testing.T) {
|
||||
date := grazingBandDate(t, testCase.date)
|
||||
info, ok := eclipse.SolarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatalf("missing eclipse on %s", testCase.date)
|
||||
}
|
||||
partialOptions, pathOptions := grazingBandOptions(info, testCase.options)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing central path")
|
||||
}
|
||||
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var collection solarGeoJSONScanCollection
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bands := 0
|
||||
for _, feature := range collection.Features {
|
||||
if role, _ := feature.Properties["role"].(string); role == "central-band" {
|
||||
bands++
|
||||
}
|
||||
}
|
||||
if bands != 1 {
|
||||
t.Fatalf("central-band feature count = %d, want 1", bands)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func grazingBandDate(t *testing.T, text string) time.Time {
|
||||
t.Helper()
|
||||
if strings.HasPrefix(text, "-") {
|
||||
parts := strings.Split(strings.TrimPrefix(text, "-"), "-")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("invalid astronomical date %q", text)
|
||||
}
|
||||
year, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
month, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
day, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return time.Date(-year, time.Month(month), day, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
date, err := time.Parse("2006-01-02", text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func grazingBandOptions(
|
||||
info eclipse.SolarEclipseInfo,
|
||||
mode string,
|
||||
) (eclipse.SolarEclipsePartialFootprintOptions, eclipse.SolarEclipsePathOptions) {
|
||||
if mode == "detail" {
|
||||
return eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute,
|
||||
BoundaryPoints: 96,
|
||||
CentralShadowStep: 2 * time.Minute,
|
||||
RiseSetStep: time.Minute,
|
||||
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
|
||||
}, eclipse.SolarEclipsePathOptions{
|
||||
Step: 2 * time.Minute,
|
||||
TargetSpacingKM: 700,
|
||||
}
|
||||
}
|
||||
partial := eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute,
|
||||
BoundaryPoints: 96,
|
||||
RiseSetStep: 2 * time.Minute,
|
||||
}
|
||||
if info.Type == eclipse.SolarEclipseTotal {
|
||||
partial.MagnitudeValues = []float64{1}
|
||||
}
|
||||
return partial, eclipse.SolarEclipsePathOptions{
|
||||
Step: 2 * time.Minute,
|
||||
TargetSpacingKM: 150,
|
||||
}
|
||||
}
|
||||
|
||||
func grazingBandRings(t *testing.T, raw []byte) [][]geodata.GeoPoint {
|
||||
t.Helper()
|
||||
var collection solarGeoJSONScanCollection
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rings [][]geodata.GeoPoint
|
||||
for _, feature := range collection.Features {
|
||||
if role, _ := feature.Properties["role"].(string); role != "central-band" {
|
||||
continue
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, polygon := range polygons {
|
||||
if len(polygon) == 0 {
|
||||
continue
|
||||
}
|
||||
ring := make([]geodata.GeoPoint, 0, len(polygon[0]))
|
||||
for _, position := range polygon[0] {
|
||||
if len(position) < 2 {
|
||||
continue
|
||||
}
|
||||
ring = append(ring, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]})
|
||||
}
|
||||
if len(ring) >= 4 {
|
||||
rings = append(rings, ring)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rings
|
||||
}
|
||||
|
||||
func grazingFootprintPaths(footprints []eclipse.SolarEclipsePartialFootprint) [][]geodata.GeoPoint {
|
||||
var paths [][]geodata.GeoPoint
|
||||
for _, footprint := range footprints {
|
||||
for _, boundary := range footprint.Boundaries {
|
||||
path := make([]geodata.GeoPoint, 0, len(boundary))
|
||||
for _, point := range boundary {
|
||||
path = append(path, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
if len(path) >= 3 {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// TestSolarEclipseGrazingLimitsFollowBandBoundary pins the contract the map
|
||||
// relies on: the dashed north/south limits and the filled central band must
|
||||
// describe the same region. Ordinary events agree to a few kilometres because
|
||||
// the band is built from those very limits; a grazing band is rebuilt from the
|
||||
// umbral sweep, where the instantaneous cross-section limits stop describing
|
||||
// the boundary at all (1136-06-01 sat 456 km inside its own band).
|
||||
func TestSolarEclipseGrazingLimitsFollowBandBoundary(t *testing.T) {
|
||||
for _, testCase := range grazingBandCases {
|
||||
if testCase.options != "overview" {
|
||||
continue
|
||||
}
|
||||
t.Run(testCase.date, func(t *testing.T) {
|
||||
date := grazingBandDate(t, testCase.date)
|
||||
info, ok := eclipse.SolarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatalf("missing eclipse on %s", testCase.date)
|
||||
}
|
||||
partialOptions, pathOptions := grazingBandOptions(info, testCase.options)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing central path")
|
||||
}
|
||||
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rings := grazingBandRings(t, raw)
|
||||
if len(rings) == 0 {
|
||||
t.Fatal("missing central-band feature")
|
||||
}
|
||||
if len(rings) == 0 {
|
||||
t.Fatal("missing central-band feature")
|
||||
}
|
||||
for _, role := range []string{"north-limit", "south-limit"} {
|
||||
path, ok := grazingLimitPath(t, raw, role)
|
||||
if !ok {
|
||||
t.Fatalf("missing %s feature", role)
|
||||
}
|
||||
miss := geodata.SphericalPolygonsPathMissDistanceKM(
|
||||
rings, [][]geodata.GeoPoint{path}, false,
|
||||
)
|
||||
if miss > 1.0 {
|
||||
t.Fatalf("%s sits %.1f km from the band boundary", role, miss)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolarEclipseSampledBandEdgeFollowsGreatestHorizonCurve pins the export
|
||||
// contract the map shows: for a band rebuilt from sampled footprints, the edge
|
||||
// that is bounded by the greatest-at-horizon condition must lie on that curve,
|
||||
// otherwise the filled band and the drawn visibility line weave across each
|
||||
// other at high zoom.
|
||||
func TestSolarEclipseSampledBandEdgeFollowsGreatestHorizonCurve(t *testing.T) {
|
||||
exercised, skipped := 0, 0
|
||||
for _, testCase := range grazingBandCases {
|
||||
if testCase.options != "overview" || testCase.limit > 60 {
|
||||
continue
|
||||
}
|
||||
t.Run(testCase.date, func(t *testing.T) {
|
||||
date := grazingBandDate(t, testCase.date)
|
||||
info, ok := eclipse.SolarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatalf("missing eclipse on %s", testCase.date)
|
||||
}
|
||||
partialOptions, pathOptions := grazingBandOptions(info, testCase.options)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing partial footprints")
|
||||
}
|
||||
if !partial.CentralBandSampled {
|
||||
skipped++
|
||||
t.Skip("analytic envelope: the band is already the exact boundary")
|
||||
}
|
||||
exercised++
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing central path")
|
||||
}
|
||||
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rings := grazingBandRings(t, raw)
|
||||
if len(rings) == 0 {
|
||||
t.Fatal("missing central-band feature")
|
||||
}
|
||||
curves := grazingGreatestCurves(t, raw)
|
||||
if len(curves) == 0 {
|
||||
t.Fatal("missing greatest visibility curves")
|
||||
}
|
||||
closest := math.Inf(1)
|
||||
for _, point := range rings[0] {
|
||||
for _, curve := range curves {
|
||||
for index := 0; index+1 < len(curve); index++ {
|
||||
closest = math.Min(closest, grazingPointSegmentKM(point, curve[index], curve[index+1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
if closest > 1.0 {
|
||||
t.Fatalf("band edge stays %.1f km away from the greatest-at-horizon curve", closest)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 采样带是少数情形:15 个夹具里只有 4 个走这条断言。若夹具筛选或"权威带"来源变化,
|
||||
// 这些用例会退化成一堆 skip 而不是失败,所以钉住覆盖数下限。
|
||||
if exercised < 4 {
|
||||
t.Fatalf("sampled-band assertion exercised by %d fixtures (%d analytic skips); the fixture filter or the band source narrowed silently", exercised, skipped)
|
||||
}
|
||||
t.Logf("sampled-band edge assertion exercised by %d fixtures, %d analytic skips", exercised, skipped)
|
||||
}
|
||||
|
||||
// grazingGreatestCurves returns the exported greatest-at-horizon boundaries.
|
||||
func grazingGreatestCurves(t *testing.T, raw []byte) [][]geodata.GeoPoint {
|
||||
t.Helper()
|
||||
var collection solarGeoJSONScanCollection
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var curves [][]geodata.GeoPoint
|
||||
for _, feature := range collection.Features {
|
||||
if role, _ := feature.Properties["role"].(string); role != "visibility-boundary" {
|
||||
continue
|
||||
}
|
||||
if phase, _ := feature.Properties["phase"].(string); phase != "greatest" {
|
||||
continue
|
||||
}
|
||||
var lines [][][]float64
|
||||
encoded, err := json.Marshal(feature.Geometry.Coordinates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &lines); err != nil {
|
||||
// A single LineString is exported as one coordinate array.
|
||||
var line [][]float64
|
||||
if lineErr := json.Unmarshal(encoded, &line); lineErr != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines = [][][]float64{line}
|
||||
}
|
||||
for _, line := range lines {
|
||||
curve := make([]geodata.GeoPoint, 0, len(line))
|
||||
for _, position := range line {
|
||||
if len(position) < 2 {
|
||||
continue
|
||||
}
|
||||
curve = append(curve, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]})
|
||||
}
|
||||
if len(curve) >= 2 {
|
||||
curves = append(curves, curve)
|
||||
}
|
||||
}
|
||||
}
|
||||
return curves
|
||||
}
|
||||
|
||||
// grazingPointSegmentKM is the planar distance from a point to one segment.
|
||||
func grazingPointSegmentKM(point, first, second geodata.GeoPoint) float64 {
|
||||
scale := math.Cos(point.Latitude * math.Pi / 180)
|
||||
ax := (first.Longitude - point.Longitude) * scale
|
||||
ay := first.Latitude - point.Latitude
|
||||
bx := (second.Longitude - point.Longitude) * scale
|
||||
by := second.Latitude - point.Latitude
|
||||
dx, dy := bx-ax, by-ay
|
||||
length := dx*dx + dy*dy
|
||||
fraction := 0.0
|
||||
if length > 0 {
|
||||
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
|
||||
}
|
||||
return 111.32 * math.Hypot(ax+fraction*dx, ay+fraction*dy)
|
||||
}
|
||||
|
||||
// grazingLimitPath returns one exported limit line as a geographic path.
|
||||
func grazingLimitPath(t *testing.T, raw []byte, role string) ([]geodata.GeoPoint, bool) {
|
||||
t.Helper()
|
||||
var collection solarGeoJSONScanCollection
|
||||
if err := json.Unmarshal(raw, &collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, feature := range collection.Features {
|
||||
if value, _ := feature.Properties["role"].(string); value != role {
|
||||
continue
|
||||
}
|
||||
var line [][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &line); err != nil {
|
||||
var lines [][][]float64
|
||||
if multiErr := json.Unmarshal(feature.Geometry.Coordinates, &lines); multiErr != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
line = lines[0]
|
||||
}
|
||||
path := make([]geodata.GeoPoint, 0, len(line))
|
||||
for _, position := range line {
|
||||
if len(position) < 2 {
|
||||
continue
|
||||
}
|
||||
path = append(path, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]})
|
||||
}
|
||||
if len(path) >= 2 {
|
||||
return path, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TestMarshalSolarEclipse11360601KeepsItsCentralBandSimple covers a shallow
|
||||
// two-limit event whose northern limit runs through a cusp near the apex of a
|
||||
// high-latitude path. Concatenating the two limits into one ribbon ring used to
|
||||
// fold the ring onto itself, so the export contained a spike triangle plus
|
||||
// disconnected end pieces instead of the swept band.
|
||||
func TestMarshalSolarEclipse11360601KeepsItsCentralBandSimple(t *testing.T) {
|
||||
date := time.Date(1136, time.June, 1, 12, 0, 0, 0, time.UTC)
|
||||
info, ok := eclipse.SolarEclipseOnDate(date)
|
||||
if !ok || !info.HasCentral {
|
||||
t.Fatal("expected a central solar eclipse on 1136-06-01")
|
||||
}
|
||||
partialOptions, pathOptions := grazingBandOptions(info, "overview")
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
||||
if !ok {
|
||||
t.Fatal("missing central path")
|
||||
}
|
||||
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rings := grazingBandRings(t, raw)
|
||||
if len(rings) != 1 {
|
||||
t.Fatalf("central band exported as %d polygons, want 1 simple ring", len(rings))
|
||||
}
|
||||
if len(rings[0]) < 8 {
|
||||
t.Fatalf("central band ring has %d vertices", len(rings[0]))
|
||||
}
|
||||
if i, j, crossed := grazingRingCrossing(rings[0]); crossed {
|
||||
t.Fatalf("central band ring crosses itself between vertices %d and %d", i, j)
|
||||
}
|
||||
centerPath := make([]geodata.GeoPoint, 0, len(central.CenterLine))
|
||||
for _, point := range central.CenterLine {
|
||||
centerPath = append(centerPath, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(
|
||||
rings, [][]geodata.GeoPoint{centerPath}, false,
|
||||
); miss > 25 {
|
||||
t.Fatalf("central band leaves the center line %.1f km outside", miss)
|
||||
}
|
||||
}
|
||||
|
||||
// grazingRingCrossing reports the first planar self-intersection of a ring.
|
||||
func grazingRingCrossing(ring []geodata.GeoPoint) (int, int, bool) {
|
||||
for first := 0; first+1 < len(ring); first++ {
|
||||
for second := first + 2; second+1 < len(ring); second++ {
|
||||
if first == 0 && second+1 == len(ring)-1 {
|
||||
continue
|
||||
}
|
||||
if grazingSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) {
|
||||
return first, second, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func grazingSegmentsCross(a, b, c, d geodata.GeoPoint) bool {
|
||||
side := func(p, q, r geodata.GeoPoint) float64 {
|
||||
return (q.Longitude-p.Longitude)*(r.Latitude-p.Latitude) - (q.Latitude-p.Latitude)*(r.Longitude-p.Longitude)
|
||||
}
|
||||
first := side(c, d, a)
|
||||
second := side(c, d, b)
|
||||
third := side(a, b, c)
|
||||
fourth := side(a, b, d)
|
||||
return (first > 0) != (second > 0) && (third > 0) != (fourth > 0)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
func TestSolarPolarBandPreservesContinuousEnvelope(t *testing.T) {
|
||||
for _, day := range [][3]int{
|
||||
{2003, 11, 23}, {2021, 12, 4}, {2039, 12, 15}, {2061, 10, 13}, {2981, 10, 19},
|
||||
} {
|
||||
date := time.Date(day[0], time.Month(day[1]), day[2], 0, 0, 0, 0, time.UTC)
|
||||
t.Run(date.Format("2006-01-02"), func(t *testing.T) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96, DisableRiseSet: true,
|
||||
})
|
||||
if !ok || len(partial.CentralBandSegments) != 1 {
|
||||
t.Fatal("missing continuous envelope")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 2 * time.Minute, TargetSpacingKM: 700})
|
||||
if !ok {
|
||||
t.Fatal("missing central path")
|
||||
}
|
||||
for _, path := range []*eclipse.SolarEclipsePath{nil, ¢ral} {
|
||||
data, err := geojson.MarshalSolarEclipse(partial, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
source, _ := band.Properties["source"].(string)
|
||||
if !strings.Contains(source, "envelope") {
|
||||
t.Fatalf("continuous envelope replaced by %q", source)
|
||||
}
|
||||
rings := geoJSONMultiPolygonOuterRings(t, band)
|
||||
var centers []geodata.GeoPoint
|
||||
for _, point := range central.CenterLine {
|
||||
centers = append(centers, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, [][]geodata.GeoPoint{centers}, false); miss > 2 {
|
||||
t.Fatalf("central band omits center line by %.3f km", miss)
|
||||
}
|
||||
assertClosedMultiPolygon(t, band)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
func TestMarshalSolarEclipsePolarTwoLimitBandUsesSimpleFaces(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
date time.Time
|
||||
centralStep time.Duration
|
||||
}{
|
||||
{time.Date(767, time.April, 3, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
|
||||
{time.Date(767, time.April, 3, 0, 0, 0, 0, time.UTC), time.Minute},
|
||||
{time.Date(2981, time.October, 19, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
|
||||
{time.Date(2981, time.October, 19, 0, 0, 0, 0, time.UTC), time.Minute},
|
||||
} {
|
||||
t.Run(sample.date.Format("2006-01-02")+"/"+sample.centralStep.String(), func(t *testing.T) {
|
||||
date := sample.date
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 5 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute,
|
||||
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar eclipse footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
||||
Step: sample.centralStep, TargetSpacingKM: 500,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected central path")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
band := featureWithRole(t, collection, "central-band")
|
||||
assertClosedMultiPolygon(t, band)
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode central-band: %v", err)
|
||||
}
|
||||
if len(polygons) == 0 {
|
||||
t.Fatal("polar central-band has no polygon")
|
||||
}
|
||||
for polygonIndex, polygon := range polygons {
|
||||
if len(polygon) == 0 {
|
||||
t.Fatalf("polygon %d has no exterior ring", polygonIndex)
|
||||
}
|
||||
ring := polygon[0]
|
||||
for first := 0; first+1 < len(ring); first++ {
|
||||
for second := first + 2; second+1 < len(ring); second++ {
|
||||
if first == 0 && second+1 == len(ring)-1 {
|
||||
continue
|
||||
}
|
||||
if polarAntimeridianFragmentEdge(ring[first], ring[first+1]) ||
|
||||
polarAntimeridianFragmentEdge(ring[second], ring[second+1]) {
|
||||
continue
|
||||
}
|
||||
if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) {
|
||||
t.Fatalf("polygon %d self-intersects between edges %d and %d", polygonIndex, first, second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rings := geoJSONMultiPolygonOuterRings(t, band)
|
||||
paths := make([][]geodata.GeoPoint, 0, len(central.CenterLine))
|
||||
for _, series := range [][]eclipse.SolarEclipsePathPoint{
|
||||
central.NorthernLimit, central.SouthernLimit, central.CenterLine,
|
||||
} {
|
||||
for _, point := range series {
|
||||
// Cross-section limits near the limb can have local greatest
|
||||
// below the horizon; they are not visible-band witnesses.
|
||||
local, ok := eclipse.GeometricLocalSolarEclipseOnDate(date, point.Longitude, point.Latitude, 0)
|
||||
if ok && local.VisibleAtGreatest && local.Type != eclipse.SolarEclipsePartial {
|
||||
paths = append(paths, []geodata.GeoPoint{{Longitude: point.Longitude, Latitude: point.Latitude}})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
t.Fatal("missing independently verified visible witnesses")
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, paths, false); miss > 2 {
|
||||
t.Fatalf("central-band misses source path by %.3f km", miss)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func polarAntimeridianFragmentEdge(first, second []float64) bool {
|
||||
return len(first) >= 2 && len(second) >= 2 &&
|
||||
(math.Abs(first[0]) == 180 || math.Abs(second[0]) == 180)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestSolarTotalBandIndependentOfMagnitudeLines(t *testing.T) {
|
||||
for _, fixture := range []struct {
|
||||
date time.Time
|
||||
lon, lat float64
|
||||
}{
|
||||
{time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC), -5.991755201, 45.070085755},
|
||||
{time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), -32.007627414, 49.493804579},
|
||||
} {
|
||||
t.Run(fixture.date.Format("2006-01-02"), func(t *testing.T) {
|
||||
central, ok := eclipse.SolarEclipseCentralPath(fixture.date, eclipse.SolarEclipsePathOptions{Step: time.Minute})
|
||||
if !ok {
|
||||
t.Fatal("missing central path")
|
||||
}
|
||||
local, ok := eclipse.GeometricLocalSolarEclipseOnDate(fixture.date, fixture.lon, fixture.lat, 0)
|
||||
if !ok || local.Type != eclipse.SolarEclipseTotal || !local.VisibleAtGreatest {
|
||||
t.Fatal("invalid totality witness")
|
||||
}
|
||||
var reference [][]eclipse.SolarEclipsePathPoint
|
||||
for _, magnitudes := range [][]float64{nil, {1}} {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(fixture.date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 2 * time.Minute, BoundaryPoints: 96, MagnitudeValues: magnitudes,
|
||||
})
|
||||
if !ok || len(partial.CentralBandSegments) == 0 {
|
||||
t.Fatal("missing authoritative totality band")
|
||||
}
|
||||
if reference == nil {
|
||||
reference = partial.CentralBandSegments
|
||||
} else if !reflect.DeepEqual(reference, partial.CentralBandSegments) {
|
||||
t.Fatal("magnitude display option changes totality geometry")
|
||||
}
|
||||
if len(magnitudes) == 0 && len(partial.MagnitudeContours) != 0 {
|
||||
t.Fatal("unrequested magnitude lines")
|
||||
}
|
||||
for _, ring := range partial.CentralBandSegments {
|
||||
assertSolarPathMaximumEdgeKM(t, ring, 250)
|
||||
}
|
||||
for _, path := range []*eclipse.SolarEclipsePath{¢ral, nil} {
|
||||
data, err := geojson.MarshalSolarEclipse(partial, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
if !geometryContainsPoint(t, band.Geometry, fixture.lon, fixture.lat) {
|
||||
t.Fatal("totality band omits visible site")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestSolarHybridThinComponentsDoNotCross(t *testing.T) {
|
||||
for _, date := range [][3]int{{881, 3, 4}, {985, 7, 20}, {1703, 1, 17}, {2386, 4, 29}, {3405, 7, 18}, {3667, 1, 7}} {
|
||||
day := time.Date(date[0], time.Month(date[1]), date[2], 0, 0, 0, 0, time.UTC)
|
||||
t.Run(day.Format("2006-01-02"), func(t *testing.T) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(day, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 5 * time.Minute, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute, BoundaryPoints: 96,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("missing solar eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for pi, polygon := range polygons {
|
||||
for _, ring := range polygon {
|
||||
for i := 0; i+1 < len(ring); i++ {
|
||||
for j := i + 2; j+1 < len(ring); j++ {
|
||||
if i == 0 && j+1 == len(ring)-1 || math.Abs(ring[i][0]) == 180 && math.Abs(ring[i+1][0]) == 180 || math.Abs(ring[j][0]) == 180 && math.Abs(ring[j+1][0]) == 180 {
|
||||
continue
|
||||
}
|
||||
if geoJSONSegmentsCross(ring[i], ring[i+1], ring[j], ring[j+1]) {
|
||||
t.Fatalf("component %d crosses itself at edges %d/%d", pi, i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
func TestSolarPartialEnvelopeCoversHorizonCurves(t *testing.T) {
|
||||
for _, date := range []time.Time{
|
||||
time.Date(43, 3, 29, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(1848, 9, 27, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2023, 4, 20, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
t.Run(date.Format("2006-01-02"), func(t *testing.T) {
|
||||
path, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 5 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("missing eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
band := featureWithRole(t, collection, "partial-band")
|
||||
if source := band.Properties["source"]; source != "zero-magnitude-envelope+horizon-boundary" {
|
||||
t.Fatalf("incomplete envelope fallback: %v", source)
|
||||
}
|
||||
assertClosedMultiPolygon(t, band)
|
||||
rings := geoJSONMultiPolygonOuterRings(t, band)
|
||||
var lines [][]geodata.GeoPoint
|
||||
for _, curve := range path.RiseSetCurves {
|
||||
for _, segment := range curve.Segments {
|
||||
line := make([]geodata.GeoPoint, len(segment))
|
||||
for i, point := range segment {
|
||||
line[i] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, lines, false); miss > 2 {
|
||||
t.Fatalf("phase curves miss envelope by %.3f km", miss)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func TestSolarPolarProjectedPhaseCoverage(t *testing.T) {
|
||||
for _, date := range [][3]int{{163, 3, 22}, {564, 9, 21}, {936, 9, 18}, {1327, 9, 16}, {3429, 9, 21}, {3820, 9, 20}, {3932, 3, 20}, {4008, 3, 20}, {4026, 9, 24}} {
|
||||
day := time.Date(date[0], time.Month(date[1]), date[2], 0, 0, 0, 0, time.UTC)
|
||||
t.Run(day.Format("2006-01-02"), func(t *testing.T) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(day, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 5 * time.Minute, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute, BoundaryPoints: 96,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("missing eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
band := featureWithRole(t, decodeCollection(t, data), "partial-band")
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, curve := range partial.RiseSetCurves {
|
||||
for _, segment := range curve.Segments {
|
||||
for _, p := range segment {
|
||||
if !geoJSONMultiPolygonContains(polygons, p.Longitude, p.Latitude) {
|
||||
if miss := geoJSONMultiPolygonBoundaryDistanceKM(polygons, []float64{p.Longitude, p.Latitude}); miss > 2 {
|
||||
t.Fatalf("projected phase point %.8f/%.8f misses by %.3f km", p.Longitude, p.Latitude, miss)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarProjectedPolarAndPartialRingsDoNotCross(t *testing.T) {
|
||||
for _, date := range [][3]int{{33, 9, 12}, {767, 4, 3}, {1527, 11, 23}, {2459, 5, 3}, {2981, 10, 19}, {3284, 2, 8}} {
|
||||
day := time.Date(date[0], time.Month(date[1]), date[2], 0, 0, 0, 0, time.UTC)
|
||||
t.Run(day.Format("2006-01-02"), func(t *testing.T) {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(day, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 5 * time.Minute, CentralShadowStep: 5 * time.Minute, RiseSetStep: 2 * time.Minute, BoundaryPoints: 96,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("missing eclipse")
|
||||
}
|
||||
central, hasCentral := eclipse.SolarEclipseCentralPath(day, eclipse.SolarEclipsePathOptions{Step: time.Minute, TargetSpacingKM: 500})
|
||||
var centralPath *eclipse.SolarEclipsePath
|
||||
if hasCentral {
|
||||
centralPath = ¢ral
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, centralPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range decodeCollection(t, data).Features {
|
||||
if f.Properties["role"] != "partial-band" && f.Properties["role"] != "central-band" {
|
||||
continue
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(f.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for pi, polygon := range polygons {
|
||||
for _, ring := range polygon {
|
||||
for i := 0; i+1 < len(ring); i++ {
|
||||
for j := i + 2; j+1 < len(ring); j++ {
|
||||
if i == 0 && j+1 == len(ring)-1 || math.Abs(ring[i][0]) == 180 && math.Abs(ring[i+1][0]) == 180 || math.Abs(ring[j][0]) == 180 && math.Abs(ring[j+1][0]) == 180 {
|
||||
continue
|
||||
}
|
||||
if geoJSONSegmentsCross(ring[i], ring[i+1], ring[j], ring[j+1]) {
|
||||
t.Fatalf("%s component %d crosses at %d/%d: %v %v / %v %v", f.Properties["role"], pi, i, j, ring[i], ring[i+1], ring[j], ring[j+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package geojson
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
eclipsecore "b612.me/astro/eclipse"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// MarshalSolarEclipseShadowInstant 单时刻阴影足迹的最小 GeoJSON:只含该时刻的区域与物理边界 / one instant as the smallest useful GeoJSON.
|
||||
func MarshalSolarEclipseShadowInstant(
|
||||
instant eclipsecore.SolarEclipseShadowInstant,
|
||||
) ([]byte, error) {
|
||||
if instant.Empty() {
|
||||
return marshalEmptyFeatureCollection()
|
||||
}
|
||||
curve, err := solarShadowFootprintCurveFromSegments(instant.Boundaries)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar shadow instant at %s: %w", formatTime(instant.Time), err)
|
||||
}
|
||||
if len(curve) < 2 {
|
||||
return marshalEmptyFeatureCollection()
|
||||
}
|
||||
properties := map[string]interface{}{
|
||||
"model": string(instant.Model),
|
||||
"delta_t_seconds": instant.DeltaTSeconds,
|
||||
"interp_signature": instant.Topology.Signature(),
|
||||
}
|
||||
regionRole, boundaryRole := solarCentralShadowFootprintRole, solarCentralShadowBoundaryRole
|
||||
if instant.Kind == eclipsecore.SolarEclipseShadowPenumbra {
|
||||
regionRole, boundaryRole = solarPartialFootprintRole, solarPartialFootprintBoundaryRole
|
||||
}
|
||||
features := make([]feature, 0, 2)
|
||||
regionProperties := cloneProperties(properties)
|
||||
regionProperties["time"] = formatTime(instant.Time)
|
||||
if instant.Closed {
|
||||
// 与整包导出同构:自身闭合的区域不带 geometry_role,也不带 closure。
|
||||
regionProperties["source_boundary_closed"] = true
|
||||
ring := append([]geodata.GeoPoint(nil), curve...)
|
||||
if solarShadowRegionDegenerate(curve, ring) {
|
||||
// 首末接触附近区域收缩到零:整条缺省,不退化成线,也不让近零面积的环触发导出错误。
|
||||
return marshalEmptyFeatureCollection()
|
||||
}
|
||||
value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar shadow instant at %s: %w", formatTime(instant.Time), err)
|
||||
}
|
||||
return marshalFeatureCollection([]feature{
|
||||
newFeature(solarEclipseEvent, regionRole, value, regionProperties),
|
||||
})
|
||||
}
|
||||
regionProperties["source_boundary_closed"] = false
|
||||
regionProperties["geometry_role"] = "horizon-closed-region"
|
||||
regionProperties["closure"] = solarHorizonClosureProperties(
|
||||
instant.Time, solarHorizonClosureExact(instant.Boundaries, instant.HorizonEnds),
|
||||
)
|
||||
ring, boundary := solarShadowFootprintHorizonRing(
|
||||
instant.Time, instant.Boundaries, instant.HorizonEnds, curve,
|
||||
)
|
||||
if solarShadowRegionDegenerate(curve, ring) {
|
||||
return marshalEmptyFeatureCollection()
|
||||
}
|
||||
value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar shadow instant at %s: %w", formatTime(instant.Time), err)
|
||||
}
|
||||
features = append(features, newFeature(
|
||||
solarEclipseEvent, regionRole, value, regionProperties,
|
||||
))
|
||||
boundaryValue, err := geoMultiLineGeometry(boundary, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar shadow instant at %s: %w", formatTime(instant.Time), err)
|
||||
}
|
||||
boundaryProperties := cloneProperties(properties)
|
||||
boundaryProperties["time"] = formatTime(instant.Time)
|
||||
boundaryProperties["source_boundary_closed"] = false
|
||||
boundaryProperties["geometry_role"] = "open-boundary"
|
||||
features = append(features, newFeature(
|
||||
solarEclipseEvent, boundaryRole, boundaryValue, boundaryProperties,
|
||||
))
|
||||
return marshalFeatureCollection(features)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
// 契约:单时刻本影导出只含该时刻的 region 与被切断时的物理边界;同一时刻的几何必须与
|
||||
// 整包采样的对应 feature 完全一致,前端的"插值→精确替换"才不会跳变。
|
||||
// Contract: the single-instant export contains only that instant's region (and the
|
||||
// physical boundary when the horizon cuts it), and its geometry must match the packaged
|
||||
// sample at the same time exactly.
|
||||
|
||||
func TestMarshalSolarEclipseShadowInstantMatchesPackagedFeatures(t *testing.T) {
|
||||
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||||
partial, 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")
|
||||
}
|
||||
packaged := decodeCollection(t, mustMarshalSolarEclipse(t, partial))
|
||||
regions := featuresWithRole(packaged, "central-shadow-footprint")
|
||||
boundaries := map[string]decodedFeature{}
|
||||
for _, feature := range featuresWithRole(packaged, "central-shadow-boundary") {
|
||||
stamp, _ := feature.Properties["time"].(string)
|
||||
boundaries[stamp] = feature
|
||||
}
|
||||
if len(regions) == 0 {
|
||||
t.Fatal("packaged export has no central-shadow-footprint")
|
||||
}
|
||||
comparedOpen := 0
|
||||
for _, index := range []int{0, len(regions) / 2, len(regions) - 1} {
|
||||
feature := regions[index]
|
||||
stamp, _ := feature.Properties["time"].(string)
|
||||
value, err := time.Parse(time.RFC3339Nano, stamp)
|
||||
if err != nil {
|
||||
t.Fatalf("parse packaged time %q: %v", stamp, err)
|
||||
}
|
||||
instant, ok := eclipse.SolarEclipseShadowAt(value)
|
||||
if !ok {
|
||||
t.Fatalf("%s: single-instant call reported no umbra but the package has a feature", stamp)
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: MarshalSolarEclipseShadowInstant: %v", stamp, err)
|
||||
}
|
||||
single := decodeCollection(t, data)
|
||||
if len(single.Features) == 0 {
|
||||
t.Fatalf("%s: single-instant export has no features", stamp)
|
||||
}
|
||||
instantRegion := featureWithRole(t, single, "central-shadow-footprint")
|
||||
// 时间戳经 time.Time 往返会有约 1e-9 日的舍入,几何容差取 1e-7 度(约 1 厘米)。
|
||||
assertSameCoordinates(t, stamp+" region", feature.Geometry.Coordinates, instantRegion.Geometry.Coordinates)
|
||||
// 整包 feature 与单时刻导出的插值签名必须由同一口径给出,前端才能用同一个键比较。
|
||||
if feature.Properties["interp_signature"] != instantRegion.Properties["interp_signature"] {
|
||||
t.Fatalf("%s: packaged interp_signature=%v, instant=%v",
|
||||
stamp, feature.Properties["interp_signature"], instantRegion.Properties["interp_signature"])
|
||||
}
|
||||
if feature.Properties["interp_signature"] != instant.Topology.Signature() {
|
||||
t.Fatalf("%s: packaged interp_signature=%v, topology=%q",
|
||||
stamp, feature.Properties["interp_signature"], instant.Topology.Signature())
|
||||
}
|
||||
closed, _ := feature.Properties["source_boundary_closed"].(bool)
|
||||
if closed {
|
||||
if len(single.Features) != 1 {
|
||||
t.Fatalf("%s: self-closed instant exported %d features, want only the region", stamp, len(single.Features))
|
||||
}
|
||||
continue
|
||||
}
|
||||
comparedOpen++
|
||||
packagedBoundary, present := boundaries[stamp]
|
||||
if !present {
|
||||
t.Fatalf("%s: packaged export has no boundary feature to compare", stamp)
|
||||
}
|
||||
instantBoundary := featureWithRole(t, single, "central-shadow-boundary")
|
||||
assertSameCoordinates(t, stamp+" boundary", packagedBoundary.Geometry.Coordinates, instantBoundary.Geometry.Coordinates)
|
||||
for _, key := range []string{"closure", "interp_signature", "delta_t_seconds", "geometry_role"} {
|
||||
if _, present := instantRegion.Properties[key]; !present {
|
||||
t.Fatalf("%s: instant region is missing property %q", stamp, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if comparedOpen == 0 {
|
||||
t.Fatal("no horizon-cut instant was compared against the packaged boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseShadowInstantEmpty(t *testing.T) {
|
||||
instant, ok := eclipse.SolarEclipseShadowAt(time.Date(2009, time.July, 22, 12, 0, 0, 0, time.UTC))
|
||||
if ok || !instant.Empty() {
|
||||
t.Fatalf("expected an empty instant, got ok=%v empty=%v", ok, instant.Empty())
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("empty instant returned an error: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Type string `json:"type"`
|
||||
Features []interface{} `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("decode empty export: %v", err)
|
||||
}
|
||||
if decoded.Type != "FeatureCollection" || len(decoded.Features) != 0 {
|
||||
t.Fatalf("empty export shape: type=%q features=%d", decoded.Type, len(decoded.Features))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseShadowInstantProperties(t *testing.T) {
|
||||
value := time.Date(2009, time.July, 22, 4, 19, 23, 378387689, time.UTC)
|
||||
instant, ok := eclipse.SolarEclipseShadowAt(value)
|
||||
if !ok {
|
||||
t.Fatal("expected an umbral footprint at the test instant")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
if len(collection.Features) != 2 {
|
||||
t.Fatalf("features=%d, want region + boundary", len(collection.Features))
|
||||
}
|
||||
region := featureWithRole(t, collection, "central-shadow-footprint")
|
||||
if region.Geometry.Type != "MultiPolygon" || region.Properties["geometry_role"] != "horizon-closed-region" {
|
||||
t.Fatalf("region shape: type=%v role=%v", region.Geometry.Type, region.Properties["geometry_role"])
|
||||
}
|
||||
if region.Properties["source_boundary_closed"] != false {
|
||||
t.Fatalf("region source_boundary_closed=%v, want false", region.Properties["source_boundary_closed"])
|
||||
}
|
||||
closure, ok := region.Properties["closure"].(map[string]interface{})
|
||||
if !ok || closure["kind"] != "horizon" {
|
||||
t.Fatalf("closure=%v, want kind=horizon", region.Properties["closure"])
|
||||
}
|
||||
if signature, _ := region.Properties["interp_signature"].(string); signature != instant.Topology.Signature() {
|
||||
t.Fatalf("interp_signature=%v, want %q", region.Properties["interp_signature"], instant.Topology.Signature())
|
||||
}
|
||||
boundary := featureWithRole(t, collection, "central-shadow-boundary")
|
||||
if boundary.Geometry.Type != "MultiLineString" || boundary.Properties["geometry_role"] != "open-boundary" {
|
||||
t.Fatalf("boundary shape: type=%v role=%v", boundary.Geometry.Type, boundary.Properties["geometry_role"])
|
||||
}
|
||||
if boundary.Properties["interp_signature"] != region.Properties["interp_signature"] {
|
||||
t.Fatal("region and boundary must share the interpolation signature")
|
||||
}
|
||||
}
|
||||
|
||||
// assertSameCoordinates 比较两份几何坐标:结构必须一致,数值容差 1e-7 度。
|
||||
// assertSameCoordinates compares two coordinate trees with a 1e-7 degree tolerance.
|
||||
func assertSameCoordinates(t *testing.T, label string, want, got json.RawMessage) {
|
||||
t.Helper()
|
||||
var wantTree, gotTree interface{}
|
||||
if err := json.Unmarshal(want, &wantTree); err != nil {
|
||||
t.Fatalf("%s: decode packaged coordinates: %v", label, err)
|
||||
}
|
||||
if err := json.Unmarshal(got, &gotTree); err != nil {
|
||||
t.Fatalf("%s: decode instant coordinates: %v", label, err)
|
||||
}
|
||||
var compare func(path string, a, b interface{})
|
||||
compare = func(path string, a, b interface{}) {
|
||||
aList, aOK := a.([]interface{})
|
||||
bList, bOK := b.([]interface{})
|
||||
if aOK != bOK {
|
||||
t.Fatalf("%s%s: structure differs", label, path)
|
||||
}
|
||||
if !aOK {
|
||||
aNumber, aIsNumber := a.(float64)
|
||||
bNumber, bIsNumber := b.(float64)
|
||||
if !aIsNumber || !bIsNumber {
|
||||
return
|
||||
}
|
||||
if difference := aNumber - bNumber; difference > 1e-7 || difference < -1e-7 {
|
||||
t.Fatalf("%s%s: %.12f vs %.12f", label, path, aNumber, bNumber)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(aList) != len(bList) {
|
||||
t.Fatalf("%s%s: length %d vs %d", label, path, len(aList), len(bList))
|
||||
}
|
||||
for index := range aList {
|
||||
compare(fmt.Sprintf("%s[%d]", path, index), aList[index], bList[index])
|
||||
}
|
||||
}
|
||||
compare("", wantTree, gotTree)
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseShadowInstantSelfClosedShape(t *testing.T) {
|
||||
instant, ok := eclipse.SolarEclipseShadowAt(time.Date(2009, time.July, 22, 0, 55, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("expected a self-closed footprint")
|
||||
}
|
||||
if !instant.Closed {
|
||||
t.Fatalf("expected closed at the test instant, signature=%s", instant.Topology.Signature())
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
if len(collection.Features) != 1 {
|
||||
t.Fatalf("self-closed instant exported %d features, want only the region", len(collection.Features))
|
||||
}
|
||||
region := collection.Features[0]
|
||||
if region.Properties["source_boundary_closed"] != true {
|
||||
t.Fatalf("source_boundary_closed=%v, want true", region.Properties["source_boundary_closed"])
|
||||
}
|
||||
// 与整包导出同构:自身闭合的区域没有 geometry_role,也没有 closure。
|
||||
for _, key := range []string{"geometry_role", "closure"} {
|
||||
if _, present := region.Properties[key]; present {
|
||||
t.Fatalf("self-closed region must not carry %q (packaged export does not)", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseShadowInstantPenumbraRoles(t *testing.T) {
|
||||
solver := eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{
|
||||
Kind: eclipse.SolarEclipseShadowPenumbra,
|
||||
})
|
||||
instant, ok := solver.ShadowAt(time.Date(2009, time.July, 22, 0, 52, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("expected a penumbra footprint")
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
region := featureWithRole(t, collection, "partial-footprint")
|
||||
if region.Geometry.Type != "MultiPolygon" {
|
||||
t.Fatalf("penumbra region geometry=%q, want MultiPolygon", region.Geometry.Type)
|
||||
}
|
||||
if signature, _ := region.Properties["interp_signature"].(string); len(signature) < 8 || signature[:8] != "penumbra" {
|
||||
t.Fatalf("penumbra signature=%v, want a penumbra prefix", region.Properties["interp_signature"])
|
||||
}
|
||||
if _, present := region.Properties["delta_t_seconds"]; !present {
|
||||
t.Fatal("penumbra region is missing delta_t_seconds")
|
||||
}
|
||||
if instant.Closed {
|
||||
if len(collection.Features) != 1 {
|
||||
t.Fatalf("self-closed penumbra exported %d features, want only the region", len(collection.Features))
|
||||
}
|
||||
return
|
||||
}
|
||||
boundary := featureWithRole(t, collection, "partial-footprint-boundary")
|
||||
if boundary.Geometry.Type != "MultiLineString" {
|
||||
t.Fatalf("penumbra boundary geometry=%q, want MultiLineString", boundary.Geometry.Type)
|
||||
}
|
||||
if closure, _ := region.Properties["closure"].(map[string]interface{}); closure["kind"] != "horizon" {
|
||||
t.Fatalf("penumbra closure=%v, want kind=horizon", region.Properties["closure"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalSampledPartialFootprintCarriesClosureAndHorizonEndpoints(t *testing.T) {
|
||||
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||||
partial, 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")
|
||||
}
|
||||
collection := decodeCollection(t, mustMarshalSolarEclipse(t, partial))
|
||||
openCount, closureCount := 0, 0
|
||||
for _, feature := range featuresWithRole(collection, "partial-footprint") {
|
||||
if _, present := feature.Properties["interp_signature"]; !present {
|
||||
t.Fatal("packaged partial footprint is missing interp_signature")
|
||||
}
|
||||
closed, _ := feature.Properties["source_boundary_closed"].(bool)
|
||||
if closed {
|
||||
if _, present := feature.Properties["closure"]; present {
|
||||
t.Fatal("self-closed partial footprint must not carry a closure")
|
||||
}
|
||||
continue
|
||||
}
|
||||
openCount++
|
||||
if _, present := feature.Properties["closure"]; present {
|
||||
closureCount++
|
||||
}
|
||||
if feature.Properties["geometry_role"] != "horizon-closed-region" {
|
||||
t.Fatalf("open partial footprint geometry_role=%v, want horizon-closed-region",
|
||||
feature.Properties["geometry_role"])
|
||||
}
|
||||
}
|
||||
if openCount == 0 || closureCount != openCount {
|
||||
t.Fatalf("open=%d withClosure=%d, want every open footprint annotated", openCount, closureCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package geojson
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
|
||||
eclipsecore "b612.me/astro/eclipse"
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/internal/solarclosure"
|
||||
)
|
||||
|
||||
const (
|
||||
// 区域角色永远是面,退化则缺省;物理边界角色永远只承载曲线。
|
||||
solarCentralShadowFootprintRole = "central-shadow-footprint"
|
||||
solarCentralShadowBoundaryRole = "central-shadow-boundary"
|
||||
// 半影用与采样序列同名的区域角色,物理边界另立角色。
|
||||
// 细于该长度的足迹是首末接触处的残片,不导出。
|
||||
solarCentralShadowMinimumRegionLengthKM = 1.0
|
||||
// 面积下限比导出侧的零面积下限(1e-12 平方度)高 1000 倍,退化的环直接缺省。
|
||||
solarCentralShadowMinimumRegionAreaDegrees2 = 1e-9
|
||||
|
||||
solarPartialFootprintRole = "partial-footprint"
|
||||
solarPartialFootprintBoundaryRole = "partial-footprint-boundary"
|
||||
)
|
||||
|
||||
// appendSolarHorizonClosedShadowFootprint 输出被地平线切断的瞬时中心影足迹:区域加物理边界 / horizon-cut footprint as a region plus its physical boundary.
|
||||
func appendSolarHorizonClosedShadowFootprint(
|
||||
features []feature,
|
||||
footprint eclipsecore.SolarEclipsePartialFootprint,
|
||||
properties map[string]interface{},
|
||||
) ([]feature, error) {
|
||||
curve, err := solarShadowFootprintCurve(footprint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar %s at %s: %w",
|
||||
solarCentralShadowFootprintRole, formatTime(footprint.Time), err)
|
||||
}
|
||||
if len(curve) < 2 {
|
||||
return features, nil
|
||||
}
|
||||
ring, boundary := solarShadowFootprintHorizonRing(
|
||||
footprint.Time, footprint.Boundaries, footprint.HorizonEnds, curve,
|
||||
)
|
||||
if solarShadowRegionDegenerate(curve, ring) {
|
||||
return features, nil
|
||||
}
|
||||
value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar %s at %s: %w",
|
||||
solarCentralShadowFootprintRole, formatTime(footprint.Time), err)
|
||||
}
|
||||
regionProperties := cloneProperties(properties)
|
||||
regionProperties["time"] = formatTime(footprint.Time)
|
||||
regionProperties["source_boundary_closed"] = false
|
||||
regionProperties["geometry_role"] = "horizon-closed-region"
|
||||
regionProperties["closure"] = solarHorizonClosureProperties(
|
||||
footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds),
|
||||
)
|
||||
regionProperties["interp_signature"] = solarShadowFootprintSignature(
|
||||
footprint.Boundaries, false, eclipsecore.SolarEclipseShadowUmbra,
|
||||
)
|
||||
features = append(features, newFeature(
|
||||
solarEclipseEvent, solarCentralShadowFootprintRole, value, regionProperties,
|
||||
))
|
||||
|
||||
boundaryValue, err := geoMultiLineGeometry(boundary, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar %s at %s: %w",
|
||||
solarCentralShadowBoundaryRole, formatTime(footprint.Time), err)
|
||||
}
|
||||
boundaryProperties := cloneProperties(properties)
|
||||
boundaryProperties["time"] = formatTime(footprint.Time)
|
||||
boundaryProperties["source_boundary_closed"] = false
|
||||
boundaryProperties["geometry_role"] = "open-boundary"
|
||||
boundaryProperties["interp_signature"] = solarShadowFootprintSignature(
|
||||
footprint.Boundaries, false, eclipsecore.SolarEclipseShadowUmbra,
|
||||
)
|
||||
return append(features, newFeature(
|
||||
solarEclipseEvent, solarCentralShadowBoundaryRole, boundaryValue, boundaryProperties,
|
||||
)), nil
|
||||
}
|
||||
|
||||
func solarShadowFootprintCurve(
|
||||
footprint eclipsecore.SolarEclipsePartialFootprint,
|
||||
) ([]geodata.GeoPoint, error) {
|
||||
return solarShadowFootprintCurveFromSegments(footprint.Boundaries)
|
||||
}
|
||||
|
||||
func solarShadowFootprintCurveFromSegments(
|
||||
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
||||
) ([]geodata.GeoPoint, error) {
|
||||
segments := make([][]geodata.GeoPoint, 0, len(boundaries))
|
||||
for _, source := range boundaries {
|
||||
segment := make([]geodata.GeoPoint, len(source))
|
||||
for index, point := range source {
|
||||
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
return geodata.JoinPolylineSegments(segments), nil
|
||||
}
|
||||
|
||||
// solarShadowRegionDegenerate 判定影区是否退化:物理边界过短或闭合面积近零,导出侧会拒绝。
|
||||
func solarShadowRegionDegenerate(curve, ring []geodata.GeoPoint) bool {
|
||||
if len(curve) < 2 || solarShadowFootprintCurveLengthKM(curve) < solarCentralShadowMinimumRegionLengthKM {
|
||||
return true
|
||||
}
|
||||
points := openRing(ring)
|
||||
if len(points) < 3 {
|
||||
return true
|
||||
}
|
||||
return math.Abs(solarShadowRegionAreaDegrees2(points)) < solarCentralShadowMinimumRegionAreaDegrees2
|
||||
}
|
||||
|
||||
// solarShadowRegionAreaDegrees2 用展开经度算鞋带面积,跨换日线的环不会得到假的近零面积。
|
||||
func solarShadowRegionAreaDegrees2(ring []geodata.GeoPoint) float64 {
|
||||
if len(ring) < 3 {
|
||||
return 0
|
||||
}
|
||||
unwrapped := make([]geodata.GeoPoint, len(ring))
|
||||
unwrapped[0] = ring[0]
|
||||
for index := 1; index < len(ring); index++ {
|
||||
longitude := ring[index].Longitude
|
||||
for longitude-unwrapped[index-1].Longitude > 180 {
|
||||
longitude -= 360
|
||||
}
|
||||
for longitude-unwrapped[index-1].Longitude < -180 {
|
||||
longitude += 360
|
||||
}
|
||||
unwrapped[index] = geodata.GeoPoint{Longitude: longitude, Latitude: ring[index].Latitude}
|
||||
}
|
||||
area := 0.0
|
||||
for index, point := range unwrapped {
|
||||
next := unwrapped[(index+1)%len(unwrapped)]
|
||||
area += point.Longitude*next.Latitude - next.Longitude*point.Latitude
|
||||
}
|
||||
return area / 2
|
||||
}
|
||||
|
||||
func solarShadowFootprintCurveLengthKM(curve []geodata.GeoPoint) float64 {
|
||||
length := 0.0
|
||||
for index := 1; index < len(curve); index++ {
|
||||
length += solarCentralBandGeoPointDistanceKM(curve[index-1], curve[index])
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
func solarShadowFootprintHorizonRing(
|
||||
value time.Time,
|
||||
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
||||
horizonEnds []eclipsecore.SolarEclipsePathPoint,
|
||||
curve []geodata.GeoPoint,
|
||||
) ([]geodata.GeoPoint, []geodata.GeoPoint) {
|
||||
return solarclosure.HorizonRing(solarclosure.Footprint{
|
||||
Boundaries: solarClosureSegments(boundaries),
|
||||
HorizonEnds: solarClosureEnds(horizonEnds),
|
||||
Subsolar: solarSubsolarPoint(value),
|
||||
}, curve)
|
||||
}
|
||||
|
||||
func solarShadowFootprintHorizonEnds(
|
||||
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
||||
horizonEnds []eclipsecore.SolarEclipsePathPoint,
|
||||
) []geodata.GeoPoint {
|
||||
return solarclosure.HorizonEnds(solarclosure.Footprint{
|
||||
Boundaries: solarClosureSegments(boundaries),
|
||||
HorizonEnds: solarClosureEnds(horizonEnds),
|
||||
})
|
||||
}
|
||||
|
||||
// solarClosureSegments 按原顺序转换边界分段,不校验坐标。
|
||||
func solarClosureSegments(boundaries [][]eclipsecore.SolarEclipsePathPoint) [][]geodata.GeoPoint {
|
||||
segments := make([][]geodata.GeoPoint, len(boundaries))
|
||||
for index, source := range boundaries {
|
||||
segments[index] = solarCentralBandGeoPoints(source)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// solarClosureEnds 转换地平擦地点;点数不是 2 或坐标非法时返回 nil,闭合退回近似弧。
|
||||
func solarClosureEnds(horizonEnds []eclipsecore.SolarEclipsePathPoint) []geodata.GeoPoint {
|
||||
if len(horizonEnds) != 2 {
|
||||
return nil
|
||||
}
|
||||
points := make([]geodata.GeoPoint, 2)
|
||||
for index, end := range horizonEnds {
|
||||
if err := validateCoordinate(end.Longitude, end.Latitude); err != nil {
|
||||
return nil
|
||||
}
|
||||
points[index] = geodata.GeoPoint{Longitude: end.Longitude, Latitude: end.Latitude}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// solarClosureFootprint 转换瞬时足迹;边界坐标非法时报错。
|
||||
func solarClosureFootprint(
|
||||
footprint eclipsecore.SolarEclipsePartialFootprint,
|
||||
) (solarclosure.Footprint, error) {
|
||||
segments := make([][]geodata.GeoPoint, len(footprint.Boundaries))
|
||||
for index, source := range footprint.Boundaries {
|
||||
segment := make([]geodata.GeoPoint, len(source))
|
||||
for pointIndex, point := range source {
|
||||
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||
return solarclosure.Footprint{}, err
|
||||
}
|
||||
segment[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
segments[index] = segment
|
||||
}
|
||||
return solarclosure.Footprint{
|
||||
Boundaries: segments,
|
||||
HorizonEnds: solarClosureEnds(footprint.HorizonEnds),
|
||||
Subsolar: solarSubsolarPoint(footprint.Time),
|
||||
Closed: footprint.Closed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// solarHorizonClosureExact 报告能否用两个精确擦地点闭合区域。
|
||||
func solarHorizonClosureExact(
|
||||
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
||||
horizonEnds []eclipsecore.SolarEclipsePathPoint,
|
||||
) bool {
|
||||
return len(solarShadowFootprintHorizonEnds(boundaries, horizonEnds)) == 2
|
||||
}
|
||||
|
||||
// solarHorizonClosureProperties 声明式闭合弧;exact 为假表示缺精确擦地点、按采样端点近似闭合。
|
||||
func solarHorizonClosureProperties(value time.Time, exact bool) map[string]interface{} {
|
||||
subsolar := solarSubsolarPoint(value)
|
||||
return map[string]interface{}{
|
||||
"kind": "horizon",
|
||||
"exact": exact,
|
||||
"time": formatTime(value),
|
||||
"subsolar": []float64{subsolar.Longitude, subsolar.Latitude},
|
||||
}
|
||||
}
|
||||
|
||||
// solarShadowSegmentClosed 与 basic 层同口径:分段首末点相距不到 1 mm 才算真正闭合。
|
||||
func solarShadowSegmentClosed(segment []eclipsecore.SolarEclipsePathPoint) bool {
|
||||
if len(segment) <= 2 {
|
||||
return false
|
||||
}
|
||||
return solarCentralBandPathDistanceKM(segment[0], segment[len(segment)-1]) < 1e-6
|
||||
}
|
||||
|
||||
// solarShadowFootprintSignature 由物理边界算出与 basic 层同口径的插值签名 / interpolation signature from the physical boundary.
|
||||
func solarShadowFootprintSignature(
|
||||
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
||||
closed bool,
|
||||
kind eclipsecore.SolarEclipseShadowKind,
|
||||
) string {
|
||||
topology := basic.SolarEclipseShadowTopology{
|
||||
Kind: basic.SolarEclipseShadowKind(kind),
|
||||
Segments: len(boundaries),
|
||||
Closed: closed,
|
||||
}
|
||||
winding := 0.0
|
||||
for _, segment := range boundaries {
|
||||
topology.Vertices += len(segment)
|
||||
for index := 1; index < len(segment); index++ {
|
||||
winding += math.Remainder(segment[index].Longitude-segment[index-1].Longitude, 360)
|
||||
}
|
||||
if solarShadowSegmentClosed(segment) {
|
||||
winding += math.Remainder(segment[0].Longitude-segment[len(segment)-1].Longitude, 360)
|
||||
}
|
||||
}
|
||||
if topology.Segments == 1 && math.Abs(winding) >= 180 {
|
||||
topology.EnclosesPole = true
|
||||
}
|
||||
return topology.Signature()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
package geojson
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// GeoJSON joins vertices with straight longitude/latitude segments. Bound
|
||||
// their deviation from the spherical edges before antimeridian clipping,
|
||||
// especially for a short arc that passes close to either pole.
|
||||
func sampleSphericalMapRing(points []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
return sampleSphericalMapRingWithin(points, sphericalMapChordLimitKM, sphericalMapChordErrorDegrees)
|
||||
}
|
||||
|
||||
// Exported map chords are bounded so that a straight lon/lat segment still
|
||||
// follows the spherical edge. Filled footprint polygons tolerate a much coarser
|
||||
// bound than the strokes that carry the path limits: they are drawn as
|
||||
// translucent fills, and their rings dominate the payload (a solar eclipse
|
||||
// exports a hundred penumbral footprints).
|
||||
const (
|
||||
sphericalMapChordLimitKM = 100.0
|
||||
sphericalMapChordErrorDegrees = 0.002
|
||||
sphericalFillChordLimitKM = 400.0
|
||||
sphericalFillChordErrorDegrees = 0.02
|
||||
)
|
||||
|
||||
// sampleSphericalMapRingWithin is sampleSphericalMapRing with explicit chord
|
||||
// limits, used for fill-only geometry.
|
||||
func sampleSphericalMapRingWithin(
|
||||
points []geodata.GeoPoint,
|
||||
chordLimitKM, chordErrorDegrees float64,
|
||||
) []geodata.GeoPoint {
|
||||
if len(points) < 3 {
|
||||
return points
|
||||
}
|
||||
if len(points) == 3 {
|
||||
return sampleSphericalMapTriangle(points)
|
||||
}
|
||||
result := make([]geodata.GeoPoint, 0, len(points))
|
||||
for index, point := range points {
|
||||
result = appendSphericalMapArcWithin(
|
||||
result, point, points[(index+1)%len(points)], 0, chordLimitKM, chordErrorDegrees,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sphericalMapChordError(first, middle, last geodata.GeoPoint) float64 {
|
||||
longitude := first.Longitude + math.Remainder(last.Longitude-first.Longitude, 360)/2
|
||||
return math.Hypot(math.Remainder(middle.Longitude-longitude, 360), middle.Latitude-(first.Latitude+last.Latitude)/2)
|
||||
}
|
||||
|
||||
func appendSphericalMapArc(points []geodata.GeoPoint, first, last geodata.GeoPoint, depth int) []geodata.GeoPoint {
|
||||
return appendSphericalMapArcWithin(
|
||||
points, first, last, depth, sphericalMapChordLimitKM, sphericalMapChordErrorDegrees,
|
||||
)
|
||||
}
|
||||
|
||||
func appendSphericalMapArcWithin(
|
||||
points []geodata.GeoPoint,
|
||||
first, last geodata.GeoPoint,
|
||||
depth int,
|
||||
chordLimitKM, chordErrorDegrees float64,
|
||||
) []geodata.GeoPoint {
|
||||
middle := geodata.InterpolateGreatCircle(first, last, 0.5)
|
||||
// Keep exported map chords bounded even when a great-circle arc is nearly
|
||||
// linear in lon/lat (notably the long horizon closure edges of shallow
|
||||
// polar eclipses). The adaptive angular-error test alone cannot see that
|
||||
// case and leaves visually abrupt 250+ km segments.
|
||||
arcDistanceKM := solarCentralBandGeoPointDistanceKM(first, last)
|
||||
if (sphericalMapChordError(first, middle, last) <= chordErrorDegrees && arcDistanceKM <= chordLimitKM) || depth >= 20 {
|
||||
return append(points, first)
|
||||
}
|
||||
points = appendSphericalMapArcWithin(points, first, middle, depth+1, chordLimitKM, chordErrorDegrees)
|
||||
return appendSphericalMapArcWithin(points, middle, last, depth+1, chordLimitKM, chordErrorDegrees)
|
||||
}
|
||||
|
||||
func sampleSphericalMapPath(source []pathSample) []pathSample {
|
||||
if len(source) < 2 {
|
||||
return source
|
||||
}
|
||||
result := make([]pathSample, 0, len(source))
|
||||
var refine func(pathSample, pathSample, int)
|
||||
refine = func(first, last pathSample, depth int) {
|
||||
a := geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude}
|
||||
b := geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude}
|
||||
middle := geodata.InterpolateGreatCircle(a, b, 0.5)
|
||||
at := first.Time.Add(last.Time.Sub(first.Time) / 2)
|
||||
if sphericalMapChordError(a, middle, b) <= 0.002 || depth >= 20 || at.Equal(first.Time) || at.Equal(last.Time) {
|
||||
result = append(result, first)
|
||||
return
|
||||
}
|
||||
point := pathSample{Time: at, Longitude: middle.Longitude, Latitude: middle.Latitude}
|
||||
refine(first, point, depth+1)
|
||||
refine(point, last, depth+1)
|
||||
}
|
||||
for i := 1; i < len(source); i++ {
|
||||
refine(source[i-1], source[i], 0)
|
||||
}
|
||||
return append(result, source[len(source)-1])
|
||||
}
|
||||
|
||||
// Match the subdivisions on both long sides of a thin spherical triangle.
|
||||
// Independent chord approximations can cross even with a small absolute error.
|
||||
func sampleSphericalMapTriangle(points []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
apex, shortest := 0, math.Inf(1)
|
||||
for i := range points {
|
||||
if length := solarCentralBandGeoPointDistanceKM(points[(i+1)%3], points[(i+2)%3]); length < shortest {
|
||||
apex, shortest = i, length
|
||||
}
|
||||
}
|
||||
a, b, c := points[apex], points[(apex+1)%3], points[(apex+2)%3]
|
||||
var left, right []geodata.GeoPoint
|
||||
var refine func(geodata.GeoPoint, geodata.GeoPoint, geodata.GeoPoint, geodata.GeoPoint, int)
|
||||
refine = func(a, b, c, d geodata.GeoPoint, depth int) {
|
||||
m, n := geodata.InterpolateGreatCircle(a, b, 0.5), geodata.InterpolateGreatCircle(c, d, 0.5)
|
||||
if depth >= 20 || math.Max(sphericalMapChordError(a, m, b), sphericalMapChordError(c, n, d)) <= 0.002 {
|
||||
left, right = append(left, a), append(right, c)
|
||||
return
|
||||
}
|
||||
refine(a, m, c, n, depth+1)
|
||||
refine(m, b, n, d, depth+1)
|
||||
}
|
||||
refine(a, b, a, c, 0)
|
||||
left, right = append(left, b), append(right, c)
|
||||
left, right = alignSphericalMapTriangleSides(left, right)
|
||||
left = left[:len(left)-1]
|
||||
left = appendSphericalMapArc(left, b, c, 0)
|
||||
left = append(left, c)
|
||||
for i := len(right) - 2; i > 0; i-- {
|
||||
left = append(left, right[i])
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func alignSphericalMapTriangleSides(left, right []geodata.GeoPoint) ([]geodata.GeoPoint, []geodata.GeoPoint) {
|
||||
var longitudes []float64
|
||||
for _, side := range [][]geodata.GeoPoint{left, right} {
|
||||
for i := range side {
|
||||
if i > 0 {
|
||||
side[i].Longitude = side[i-1].Longitude + math.Remainder(side[i].Longitude-side[i-1].Longitude, 360)
|
||||
}
|
||||
longitudes = append(longitudes, side[i].Longitude)
|
||||
}
|
||||
}
|
||||
sort.Float64s(longitudes)
|
||||
align := func(side []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
result := []geodata.GeoPoint{side[0]}
|
||||
for i := 1; i < len(side); i++ {
|
||||
a, b := side[i-1], side[i]
|
||||
lo, hi := math.Min(a.Longitude, b.Longitude), math.Max(a.Longitude, b.Longitude)
|
||||
first, last := sort.SearchFloat64s(longitudes, lo), sort.SearchFloat64s(longitudes, hi)
|
||||
for j := first; j < last; j++ {
|
||||
index := j
|
||||
if a.Longitude > b.Longitude {
|
||||
index = first + last - 1 - j
|
||||
}
|
||||
lon := longitudes[index]
|
||||
if lon <= lo+1e-12 || lon >= hi-1e-12 || index > 0 && lon == longitudes[index-1] {
|
||||
continue
|
||||
}
|
||||
// The great-circle plane intersects each intermediate meridian once.
|
||||
lonA, latA, lonB, latB := a.Longitude*math.Pi/180, a.Latitude*math.Pi/180, b.Longitude*math.Pi/180, b.Latitude*math.Pi/180
|
||||
x, y, z := math.Cos(latA)*math.Cos(lonA), math.Cos(latA)*math.Sin(lonA), math.Sin(latA)
|
||||
u, v, w := math.Cos(latB)*math.Cos(lonB), math.Cos(latB)*math.Sin(lonB), math.Sin(latB)
|
||||
nx, ny, nz := y*w-z*v, z*u-x*w, x*v-y*u
|
||||
lat := math.Atan(-(nx*math.Cos(lon*math.Pi/180)+ny*math.Sin(lon*math.Pi/180))/nz) * 180 / math.Pi
|
||||
result = append(result, geodata.GeoPoint{Longitude: lon, Latitude: lat})
|
||||
}
|
||||
result = append(result, b)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return align(left), align(right)
|
||||
}
|
||||
Reference in New Issue
Block a user