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