feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user