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