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
@@ -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)
}