feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+328
View File
@@ -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
View File
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)
}
}
+243
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+183
View File
@@ -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)
}
}
})
}
}
+80
View File
@@ -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)
}
}
}
})
}
}
+76
View File
@@ -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
View File
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)
}
}
})
}
}
}
+239
View File
@@ -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)
}
}
}
+924
View File
@@ -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)
}
}
+77
View File
@@ -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)
}
}
})
}
}
+82
View File
@@ -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, &central)
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, &central)
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")
}
+186
View File
@@ -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)
}
}
+333
View File
@@ -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)
}
}
}
}
+205
View File
@@ -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, &central)
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, &central)
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, &central)
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, &central)
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, &central)
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, &centerLines); 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, &central)
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, &central)
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, &central)
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, &central)
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, &central)
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, &central)
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, &central)
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, &central)
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, &central)
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, &central)
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, &central} {
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, &central)
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{&central, 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 = &central
}
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])
}
}
}
}
}
}
})
}
}
+82
View File
@@ -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)
}
+292
View File
@@ -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)
}
}
+275
View File
@@ -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()
}
+207
View File
@@ -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
)
+177
View File
@@ -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)
}