2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
223 lines
8.6 KiB
Go
223 lines
8.6 KiB
Go
package geojson_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
|
|
"b612.me/astro/eclipse"
|
|
"b612.me/astro/geojson"
|
|
)
|
|
|
|
func TestMarshalSolarEclipse20560713CentralBandHasNoEndFold(t *testing.T) {
|
|
date := time.Date(2056, time.July, 13, 0, 0, 0, 0, time.UTC)
|
|
localDate := time.Date(2056, time.July, 12, 0, 0, 0, 0, time.UTC)
|
|
local, localOK := eclipse.LocalSolarEclipseOnDate(localDate, -64.3737, -5.4978, 0)
|
|
if !localOK || local.Type != eclipse.SolarEclipseAnnular || !local.HasCentral || local.SunAltitude <= 0 {
|
|
t.Fatalf("reference site is not a visible annular eclipse: ok=%v type=%s central=%v altitude=%.6f",
|
|
localOK, local.Type, local.HasCentral, local.SunAltitude)
|
|
}
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
Step: 2 * time.Minute,
|
|
BoundaryPoints: 96,
|
|
CentralShadowStep: 2 * time.Minute,
|
|
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected solar eclipse footprints")
|
|
}
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
Step: 2 * time.Minute, TargetSpacingKM: 700,
|
|
})
|
|
if !ok || len(central.CenterLine) < 2 {
|
|
t.Fatal("expected a central eclipse path")
|
|
}
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
if err != nil {
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
}
|
|
collection := decodeCollection(t, data)
|
|
band := featureWithRole(t, collection, "central-band")
|
|
assertClosedMultiPolygon(t, band)
|
|
|
|
var polygons [][][][]float64
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
t.Fatalf("decode central band: %v", err)
|
|
}
|
|
if len(polygons) == 0 || len(polygons) > 2 {
|
|
t.Fatalf("central band has %d polygons, want one physical band with at most one antimeridian split", len(polygons))
|
|
}
|
|
if len(polygons) == 2 && !solarCentralBandPartsMeetAntimeridian(polygons) {
|
|
t.Fatal("two central-band polygons do not form an antimeridian split")
|
|
}
|
|
for _, polygon := range polygons {
|
|
if len(polygon) == 0 {
|
|
t.Fatal("central-band polygon has no exterior ring")
|
|
}
|
|
assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250)
|
|
}
|
|
if !geometryContainsPoint(t, band.Geometry, -64.3737, -5.4978) {
|
|
t.Fatal("central-band omits the locally visible annular greatest point near U4")
|
|
}
|
|
|
|
center := featureWithRole(t, collection, "center-line")
|
|
var centerLines [][][]float64
|
|
if err := json.Unmarshal(center.Geometry.Coordinates, ¢erLines); err != nil {
|
|
t.Fatalf("decode center line: %v", err)
|
|
}
|
|
for segmentIndex, segment := range centerLines {
|
|
for pointIndex, point := range segment {
|
|
if !geometryContainsPoint(t, band.Geometry, point[0], point[1]) &&
|
|
geoJSONMultiPolygonBoundaryDistanceKM(polygons, point) > 10 {
|
|
t.Fatalf("center-line segment %d point %d lies outside central band", segmentIndex, pointIndex)
|
|
}
|
|
}
|
|
}
|
|
|
|
start := central.CenterLine[0]
|
|
end := central.CenterLine[len(central.CenterLine)-1]
|
|
assertSolarCentralBandDoesNotReverseNear(t, polygons, []float64{start.Longitude, start.Latitude}, 1600)
|
|
assertSolarCentralBandDoesNotReverseNear(t, polygons, []float64{end.Longitude, end.Latitude}, 1600)
|
|
}
|
|
|
|
func TestMarshalSolarEclipse20560713HorizonClosureIsStableAcrossSampling(t *testing.T) {
|
|
date := time.Date(2056, time.July, 13, 0, 0, 0, 0, time.UTC)
|
|
var referenceRoots [2][2]eclipse.SolarEclipsePathPoint
|
|
for _, step := range []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute} {
|
|
t.Run(step.String(), func(t *testing.T) {
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
|
Step: step, BoundaryPoints: 96, CentralShadowStep: step,
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected solar eclipse footprints")
|
|
}
|
|
if len(partial.CentralBandHorizonClosures) != 2 {
|
|
t.Fatalf("central-limit horizon closures=%d, want start and end", len(partial.CentralBandHorizonClosures))
|
|
}
|
|
for closureIndex, closure := range partial.CentralBandHorizonClosures {
|
|
if len(closure) < 2 {
|
|
t.Fatalf("horizon closure %d has %d points", closureIndex, len(closure))
|
|
}
|
|
assertSolarPathMaximumEdgeKM(t, closure, 12)
|
|
roots := [2]eclipse.SolarEclipsePathPoint{closure[0], closure[len(closure)-1]}
|
|
if referenceRoots[closureIndex][0].Time.IsZero() {
|
|
referenceRoots[closureIndex] = roots
|
|
} else {
|
|
for rootIndex := range roots {
|
|
if difference := roots[rootIndex].Time.Sub(referenceRoots[closureIndex][rootIndex].Time); difference < -time.Millisecond || difference > time.Millisecond {
|
|
t.Fatalf("horizon closure %d root %d time differs by %s across sampling", closureIndex, rootIndex, difference)
|
|
}
|
|
if distance := geoJSONCoordinateDistanceKM(
|
|
[]float64{roots[rootIndex].Longitude, roots[rootIndex].Latitude},
|
|
[]float64{referenceRoots[closureIndex][rootIndex].Longitude, referenceRoots[closureIndex][rootIndex].Latitude},
|
|
); distance > 0.01 {
|
|
t.Fatalf("horizon closure %d root %d differs by %.3f km across sampling", closureIndex, rootIndex, distance)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{
|
|
Step: step, TargetSpacingKM: 700,
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected central eclipse path")
|
|
}
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
if err != nil {
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
|
}
|
|
band := featureWithRole(t, decodeCollection(t, data), "central-band")
|
|
if band.Properties["source"] != "besselian-critical-envelope" {
|
|
t.Fatalf("central-band source=%v, want continuous critical envelope", band.Properties["source"])
|
|
}
|
|
if !geometryContainsPoint(t, band.Geometry, -64.3737, -5.4978) {
|
|
t.Fatal("central-band omits the locally visible annular point")
|
|
}
|
|
var polygons [][][][]float64
|
|
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
|
|
t.Fatalf("decode central band: %v", err)
|
|
}
|
|
if len(polygons) == 0 || len(polygons) > 2 {
|
|
t.Fatalf("central band has %d polygons", len(polygons))
|
|
}
|
|
for _, polygon := range polygons {
|
|
assertSolarCentralBandRingSimpleAndSampled(t, polygon[0], 250)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func solarCentralBandPartsMeetAntimeridian(polygons [][][][]float64) bool {
|
|
hasEast, hasWest := false, false
|
|
for _, polygon := range polygons {
|
|
for _, ring := range polygon {
|
|
for _, point := range ring {
|
|
hasEast = hasEast || math.Abs(point[0]-180) <= 1e-9
|
|
hasWest = hasWest || math.Abs(point[0]+180) <= 1e-9
|
|
}
|
|
}
|
|
}
|
|
return hasEast && hasWest
|
|
}
|
|
|
|
func assertSolarCentralBandRingSimpleAndSampled(t *testing.T, ring [][]float64, maximumEdgeKM float64) {
|
|
t.Helper()
|
|
for index := 1; index < len(ring); index++ {
|
|
if distance := geoJSONCoordinateDistanceKM(ring[index-1], ring[index]); distance > maximumEdgeKM {
|
|
t.Fatalf("central-band edge %d is %.1f km, want at most %.1f km", index, distance, maximumEdgeKM)
|
|
}
|
|
}
|
|
for first := 0; first+1 < len(ring); first++ {
|
|
for second := first + 2; second+1 < len(ring); second++ {
|
|
if first == 0 && second+1 == len(ring)-1 {
|
|
continue
|
|
}
|
|
if geoJSONSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) {
|
|
t.Fatalf("central-band ring self-intersects between edges %d and %d", first, second)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertSolarCentralBandDoesNotReverseNear(t *testing.T, polygons [][][][]float64, center []float64, radiusKM float64) {
|
|
t.Helper()
|
|
checked := 0
|
|
for _, polygon := range polygons {
|
|
if len(polygon) == 0 {
|
|
continue
|
|
}
|
|
ring := polygon[0]
|
|
for index := 1; index+1 < len(ring); index++ {
|
|
if geoJSONCoordinateDistanceKM(ring[index], center) > radiusKM {
|
|
continue
|
|
}
|
|
checked++
|
|
incoming := solarRegressionProjectedVector(ring[index], ring[index-1])
|
|
outgoing := solarRegressionProjectedVector(ring[index], ring[index+1])
|
|
incomingLength := math.Hypot(incoming[0], incoming[1])
|
|
outgoingLength := math.Hypot(outgoing[0], outgoing[1])
|
|
if incomingLength == 0 || outgoingLength == 0 {
|
|
continue
|
|
}
|
|
cosine := (incoming[0]*outgoing[0] + incoming[1]*outgoing[1]) / (incomingLength * outgoingLength)
|
|
if cosine > 0.985 {
|
|
t.Fatalf("central-band boundary reverses by %.1f degrees at %.5f, %.5f between [%.5f, %.5f] and [%.5f, %.5f]",
|
|
math.Acos(math.Max(-1, math.Min(1, cosine)))*180/math.Pi,
|
|
ring[index][0], ring[index][1],
|
|
ring[index-1][0], ring[index-1][1],
|
|
ring[index+1][0], ring[index+1][1])
|
|
}
|
|
}
|
|
}
|
|
if checked == 0 {
|
|
t.Fatal("central-band boundary has no samples near the event end")
|
|
}
|
|
}
|
|
|
|
func solarRegressionProjectedVector(origin, point []float64) [2]float64 {
|
|
latitude := origin[1] * math.Pi / 180
|
|
deltaLongitude := math.Remainder(point[0]-origin[0], 360)
|
|
return [2]float64{deltaLongitude * math.Cos(latitude), point[1] - origin[1]}
|
|
}
|