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

329 lines
12 KiB
Go

package geojson_test
import (
"encoding/json"
"math"
"testing"
"time"
"b612.me/astro/geojson"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/moon"
)
func TestMarshalStarOccultationAntares20240303UsesContinuousVisibleEnvelope(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, zone)
star := moon.StarCoordinate{
ID: "HR 6134", RA: 247.35166667, Dec: -26.43194444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -10,
ProperMotionDecMasPerYear: -20,
ParallaxMas: 24,
}
paths, err := moon.FindStarOccultationPaths(
start, start.Add(24*time.Hour), star,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
data, err := geojson.MarshalStarOccultation(paths[0])
if err != nil {
t.Fatalf("MarshalStarOccultation: %v", err)
}
collection := decodeCollection(t, data)
band := featureWithRole(t, collection, "occultation-band")
if band.Properties["static_band_authoritative"] != true {
t.Fatalf("occultation-band source=%v authoritative=%v, want analytic authoritative boundary",
band.Properties["source"], band.Properties["static_band_authoritative"])
}
for index, point := range paths[0].CenterLine {
if !geometryContainsPoint(t, band.Geometry, point.Longitude, point.Latitude) {
t.Fatalf("center-line sample %d lies outside visible band at %.6f, %.6f",
index, point.Longitude, point.Latitude)
}
}
for _, feature := range featuresWithRole(collection, "visibility-boundary") {
var lines [][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode visibility-boundary: %v", err)
}
for segmentIndex, line := range lines {
for pointIndex, point := range line {
if !geometryContainsPointWithinKM(t, band.Geometry, point[0], point[1], 2) {
t.Fatalf("visibility-boundary segment %d point %d lies outside visible band at %.6f, %.6f",
segmentIndex, pointIndex, point[0], point[1])
}
}
}
}
assertOccultationBandMaximumEdge(t, band, 45)
}
func TestMarshalStarOccultationAntares20240303RetainsNarrowGreatestSetFold(t *testing.T) {
start := time.Date(2024, time.March, 3, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindStarOccultationPaths(
start, start.Add(24*time.Hour), antaresCoordinateForGeoJSONRegression(),
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
var curve moon.OccultationRiseSetCurve
for _, candidate := range paths[0].RiseSetCurves {
if candidate.Phase == moon.RiseSetPhaseGreatest && candidate.Direction == moon.RiseSetDirectionSet {
curve = candidate
break
}
}
if len(curve.Segments) < 2 {
t.Fatalf("greatest/set segments=%d, want the narrow fold branch recovered at one-minute sampling", len(curve.Segments))
}
shared := false
for first := 0; first < len(curve.Segments); first++ {
for second := first + 1; second < len(curve.Segments); second++ {
for _, left := range []moon.OccultationPathPoint{curve.Segments[first][0], curve.Segments[first][len(curve.Segments[first])-1]} {
for _, right := range []moon.OccultationPathPoint{curve.Segments[second][0], curve.Segments[second][len(curve.Segments[second])-1]} {
if left.Time.Sub(right.Time) < -time.Second || left.Time.Sub(right.Time) > time.Second {
continue
}
if geojsonPointDistanceKM(left.Longitude, left.Latitude, right.Longitude, right.Latitude) <= 1 {
shared = true
}
}
}
}
}
if !shared {
t.Fatal("greatest/set fold branches do not share a physical endpoint")
}
data, err := geojson.MarshalStarOccultation(paths[0])
if err != nil {
t.Fatalf("MarshalStarOccultation: %v", err)
}
boundary := riseSetBoundaryFeature(t, decodeCollection(t, data), "greatest", "set")
var lines [][][]float64
if err := json.Unmarshal(boundary.Geometry.Coordinates, &lines); err != nil {
t.Fatalf("decode greatest/set boundary: %v", err)
}
if len(lines) < 2 {
t.Fatalf("serialized greatest/set segments=%d, want at least two folded branches", len(lines))
}
}
func geojsonPointDistanceKM(firstLongitude, firstLatitude, secondLongitude, secondLatitude float64) float64 {
const earthRadiusKM = 6378.1366
const degreesToRadians = 3.141592653589793 / 180
firstLat, secondLat := firstLatitude*degreesToRadians, secondLatitude*degreesToRadians
deltaLat := (secondLatitude - firstLatitude) * degreesToRadians
deltaLon := (secondLongitude - firstLongitude) * degreesToRadians
a := math.Sin(deltaLat/2)*math.Sin(deltaLat/2) + math.Cos(firstLat)*math.Cos(secondLat)*math.Sin(deltaLon/2)*math.Sin(deltaLon/2)
return 2 * earthRadiusKM * math.Asin(math.Sqrt(math.Max(0, math.Min(1, a))))
}
func TestMarshalStarOccultationAntaresRepresentative2022To2026Topology(t *testing.T) {
star := antaresCoordinateForGeoJSONRegression()
for _, date := range []string{
"2023-09-21", // first event in the series; short temporal horizon closure
"2023-10-18", // complete endpoint network without a temporal connector
"2024-03-03", // original disconnected footprint-sweep regression
"2024-06-20", // antimeridian split
"2025-08-31", // multi-branch southern polar turn and numerical sliver
"2026-02-11", // pole-enclosing equirectangular output
"2026-12-08", // returning ordinary-latitude branch
} {
date := date
t.Run(date, func(t *testing.T) {
start, err := time.Parse("2006-01-02", date)
if err != nil {
t.Fatal(err)
}
paths, err := moon.FindStarOccultationPaths(
start, start.Add(24*time.Hour), star,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
polygons, authoritative, err := occultationgeo.VisibleStarBandPolygonsFromAnalyticContours(
path.BandFootprints, path.BandContours, path.VisibilityContours,
path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves,
)
if err != nil || !authoritative || len(polygons) != 1 {
t.Fatalf("analytic polygons=%d authoritative=%v err=%v, want one physical band", len(polygons), authoritative, err)
}
assertOccultationPhysicalPolygonsMaximumEdge(t, polygons, 45)
assertOccultationPhysicalPolygonsHaveNoShortHairpins(t, polygons, 35, 25, 12)
if !geodata.SphericalPolygonsContainPathsWithinKM(
polygons, occultationPathPointLines([][]moon.OccultationPathPoint{path.CenterLine}), false, 0.1,
) {
t.Fatalf("analytic band misses center line by %.3f km",
geodata.SphericalPolygonsPathMissDistanceKM(
polygons, occultationPathPointLines([][]moon.OccultationPathPoint{path.CenterLine}), false,
))
}
phaseLines := make([][]geodata.GeoPoint, 0, len(path.RiseSetCurves))
for _, curve := range path.RiseSetCurves {
phaseLines = append(phaseLines,
occultationPathPointLines(occultationgeo.StitchedRiseSetCurveSegments(curve))...,
)
}
if !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 2) {
t.Fatalf("analytic band misses a displayed rise/set phase by %.3f km",
geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false))
}
data, err := geojson.MarshalStarOccultation(path)
if err != nil {
t.Fatalf("MarshalStarOccultation: %v", err)
}
collection := decodeCollection(t, data)
band := featureWithRole(t, collection, "occultation-band")
if band.Properties["static_band_authoritative"] != true {
t.Fatalf("occultation-band source=%v authoritative=%v",
band.Properties["source"], band.Properties["static_band_authoritative"])
}
for index, point := range path.CenterLine {
if !geometryContainsPointWithinKM(t, band.Geometry, point.Longitude, point.Latitude, 0.1) {
t.Fatalf("GeoJSON band excludes center sample %d at %.6f, %.6f",
index, point.Longitude, point.Latitude)
}
}
})
}
}
func antaresCoordinateForGeoJSONRegression() moon.StarCoordinate {
return moon.StarCoordinate{
ID: "HR 6134", RA: 247.35166667, Dec: -26.43194444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -10,
ProperMotionDecMasPerYear: -20,
ParallaxMas: 24,
}
}
func occultationPathPointLines(sources [][]moon.OccultationPathPoint) [][]geodata.GeoPoint {
result := make([][]geodata.GeoPoint, 0, len(sources))
for _, source := range sources {
if len(source) < 2 {
continue
}
line := make([]geodata.GeoPoint, len(source))
for index, point := range source {
line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
result = append(result, line)
}
return result
}
func assertOccultationPhysicalPolygonsMaximumEdge(
t *testing.T,
polygons [][]geodata.GeoPoint,
maximumKM float64,
) {
t.Helper()
for polygonIndex, polygon := range polygons {
for index := range polygon {
next := (index + 1) % len(polygon)
distance := geoJSONCoordinateDistanceKM(
[]float64{polygon[index].Longitude, polygon[index].Latitude},
[]float64{polygon[next].Longitude, polygon[next].Latitude},
)
if distance > maximumKM {
t.Fatalf("physical polygon %d edge %d is %.1f km, want <=%.1f km",
polygonIndex, index, distance, maximumKM)
}
}
}
}
func assertOccultationPhysicalPolygonsHaveNoShortHairpins(
t *testing.T,
polygons [][]geodata.GeoPoint,
maximumClosureKM, minimumDetourKM float64,
maximumSpan int,
) {
t.Helper()
for _, polygon := range polygons {
ring := make([][]float64, len(polygon))
for index, point := range polygon {
ring[index] = []float64{point.Longitude, point.Latitude}
}
assertGeoJSONRingHasNoShortHairpins(
t, "physical occultation-band", ring,
maximumClosureKM, minimumDetourKM, maximumSpan,
)
}
}
func geometryContainsPointWithinKM(t *testing.T, value struct {
Type string `json:"type"`
Coordinates json.RawMessage `json:"coordinates"`
Geometries json.RawMessage `json:"geometries"`
}, longitude, latitude, toleranceKM float64) bool {
if geometryContainsPoint(t, value, longitude, latitude) {
return true
}
var polygons [][][][]float64
if value.Type == "MultiPolygon" {
if json.Unmarshal(value.Coordinates, &polygons) != nil {
return false
}
} else if value.Type == "Polygon" {
var polygon [][][]float64
if json.Unmarshal(value.Coordinates, &polygon) != nil {
return false
}
polygons = [][][][]float64{polygon}
} else {
return false
}
point := []float64{longitude, latitude}
for _, polygon := range polygons {
for _, ring := range polygon {
for index := 1; index < len(ring); index++ {
if geoJSONPointSegmentDistanceKM(point, ring[index-1], ring[index]) <= toleranceKM {
return true
}
}
}
}
return false
}
func assertOccultationBandMaximumEdge(t *testing.T, feature decodedFeature, maximumKM float64) {
t.Helper()
var polygons [][][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
t.Fatalf("decode %v polygons: %v", feature.Properties["role"], err)
}
for polygonIndex, polygon := range polygons {
for ringIndex, ring := range polygon {
for pointIndex := 1; pointIndex < len(ring); pointIndex++ {
if distance := geoJSONCoordinateDistanceKM(ring[pointIndex-1], ring[pointIndex]); distance > maximumKM {
t.Fatalf("%v polygon %d ring %d edge %d is %.1f km, want <= %.1f km",
feature.Properties["role"], polygonIndex, ringIndex, pointIndex, distance, maximumKM)
}
}
}
}
}