Files
astro/geojson/lunar_horizon_test.go
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

471 lines
15 KiB
Go

package geojson_test
import (
"encoding/json"
"fmt"
"math"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
)
func TestLunarGeoJSONUsesTopocentricHorizon(t *testing.T) {
date := time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
for _, contact := range []struct {
role, horizon string
at time.Time
}{
{"visible-at-p1", "p1-horizon", info.PenumbralStart},
{"visible-at-p4", "p4-horizon", info.PenumbralEnd},
} {
band := featureWithRole(t, collection, contact.role)
line := featureWithRole(t, collection, contact.horizon)
var segments [][][]float64
if err := json.Unmarshal(line.Geometry.Coordinates, &segments); err != nil {
t.Fatal(err)
}
jd := basic.Date2JD(contact.at.UTC())
for _, segment := range segments {
for _, point := range segment {
if math.Abs(point[0]) == 180 {
continue
}
if altitude := basic.HMoonHeight(jd, point[0], point[1], 0); math.Abs(altitude) > 1e-8 {
t.Fatalf("horizon altitude=%g", altitude)
}
}
}
for lon := -175.; lon < 180; lon += 10 {
for lat := -85.; lat < 90; lat += 10 {
altitude := basic.HMoonHeight(jd, lon, lat, 0)
if math.Abs(altitude) < 0.05 {
continue
}
if inside := geometryContainsPoint(t, band.Geometry, lon, lat); inside != (altitude > 0) {
t.Fatalf("%s point=(%v,%v) inside=%v altitude=%v", contact.role, lon, lat, inside, altitude)
}
}
}
}
}
// TestLunarGeoJSONEnvelopeCoversPolarLens 固定时间包络的必要性:见证站点不在任何一块 P1/P4 瞬时半球里。
func TestLunarGeoJSONEnvelopeCoversPolarLens(t *testing.T) {
date := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
const longitude, latitude = 108.729001, -59.937452
during := featureWithRole(t, collection, "visible-during-eclipse")
if !geometryContainsPoint(t, during.Geometry, longitude, latitude) {
t.Fatal("visible-during-eclipse does not cover the polar lens witness")
}
if geometryContainsPoint(t, featureWithRole(t, collection, "visible-throughout-eclipse").Geometry, longitude, latitude) {
t.Fatal("visible-throughout-eclipse covers a site that loses the penumbral ends")
}
for _, role := range []string{"visible-at-p1", "visible-at-p4"} {
if geometryContainsPoint(t, featureWithRole(t, collection, role).Geometry, longitude, latitude) {
t.Fatalf("%s unexpectedly covers the polar lens witness", role)
}
}
local, localOK := eclipse.LocalLunarEclipseOnDate(date, longitude, latitude, 0)
if !localOK || local.Visibility != eclipse.LocalLunarEclipseRiseAndSet {
t.Fatalf("local visibility=%q ok=%v, want %q", local.Visibility, localOK, eclipse.LocalLunarEclipseRiseAndSet)
}
}
// TestLunarGeoJSONEnvelopesMatchAltitudeExtrema 用高度极值独立判据钉住两个时间包络;采样取整点经度,
// 并跳过零高度 0.05° 以内的边界点,1° 经度采样在区域边缘的半格误差不算失配。
func TestLunarGeoJSONEnvelopesMatchAltitudeExtrema(t *testing.T) {
for _, day := range []string{
"0275-09-22", "0386-09-24", "1076-09-15", "1904-09-24", "2396-03-25",
"2779-03-24", "2955-09-23", "3188-09-27", "3738-03-19", "4026-03-16",
} {
t.Run(day, func(t *testing.T) {
date, err := time.Parse("2006-01-02", day)
if err != nil {
t.Fatal(err)
}
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
during := featureWithRole(t, collection, "visible-during-eclipse")
throughout := featureWithRole(t, collection, "visible-throughout-eclipse")
jdStart := basic.Date2JD(info.PenumbralStart.UTC())
jdEnd := basic.Date2JD(info.PenumbralEnd.UTC())
for _, base := range []float64{-88, 87.5} {
for longitude := -176.0; longitude < 180; longitude += 8 {
for latitude := base; latitude <= base+2.5; latitude += 0.5 {
maximum, minimum := lunarAltitudeExtrema(jdStart, jdEnd, longitude, latitude)
if math.Abs(maximum) > 0.05 &&
geometryContainsPoint(t, during.Geometry, longitude, latitude) != (maximum > 0) {
t.Fatalf("visible-during-eclipse (%v,%v) maximum=%g", longitude, latitude, maximum)
}
if math.Abs(minimum) > 0.05 &&
geometryContainsPoint(t, throughout.Geometry, longitude, latitude) != (minimum > 0) {
t.Fatalf("visible-throughout-eclipse (%v,%v) minimum=%g", longitude, latitude, minimum)
}
}
}
}
})
}
}
func lunarAltitudeExtrema(jdStart, jdEnd, longitude, latitude float64) (float64, float64) {
const samples = 48
maximum, minimum := math.Inf(-1), math.Inf(1)
for index := 0; index <= samples; index++ {
altitude := basic.HMoonHeight(jdStart+(jdEnd-jdStart)*float64(index)/samples, longitude, latitude, 0)
maximum = math.Max(maximum, altitude)
minimum = math.Min(minimum, altitude)
}
return maximum, minimum
}
func TestLunarGeoJSONTimeEnvelopesCoverPolarWindow(t *testing.T) {
date := time.Date(1800, 4, 9, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
during := featureWithRole(t, collection, "visible-during-eclipse")
throughout := featureWithRole(t, collection, "visible-throughout-eclipse")
if during.Properties["aggregation"] != "union" || throughout.Properties["aggregation"] != "intersection" {
t.Fatalf("unexpected aggregations: during=%v throughout=%v", during.Properties["aggregation"], throughout.Properties["aggregation"])
}
if !geometryContainsPoint(t, during.Geometry, 121, 82) {
t.Fatal("visible-during-eclipse misses a short polar visibility interval")
}
if geometryContainsPoint(t, throughout.Geometry, 121, 82) {
t.Fatal("visible-throughout-eclipse contains a rise-and-set site")
}
if !geometryContainsPoint(t, during.Geometry, -69, -84) {
t.Fatal("visible-during-eclipse misses the interrupted-site witness")
}
if geometryContainsPoint(t, throughout.Geometry, -69, -84) {
t.Fatal("visible-throughout-eclipse contains an interrupted site")
}
}
func TestLunarGeoJSON19040924DoesNotFillFalseSouthPolarCap(t *testing.T) {
date := time.Date(1904, 9, 24, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
band := featureWithRole(t, collection, "visible-at-p1")
point := struct {
longitude float64
latitude float64
}{-115, -89.9}
if altitude := basic.HMoonHeight(basic.Date2JD(info.PenumbralStart), point.longitude, point.latitude, 0); altitude >= -0.01 {
t.Fatalf("regression witness altitude=%g, want below horizon", altitude)
}
if geometryContainsPoint(t, band.Geometry, point.longitude, point.latitude) {
t.Fatalf("visible-at-p1 contains below-horizon polar witness %+v", point)
}
}
func TestLunarGeoJSONPolarVisibilityGrid(t *testing.T) {
for _, day := range []string{
"0275-09-22", "0386-09-24", "1076-09-15", "1904-09-24", "2396-03-25",
"2779-03-24", "2955-09-23", "3188-09-27", "3738-03-19", "4026-03-16",
} {
t.Run(day, func(t *testing.T) {
date, err := time.Parse("2006-01-02", day)
if err != nil {
t.Fatal(err)
}
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
counts := []int{360}
if day == "1904-09-24" {
counts = []int{12, 96, 360, 1440}
}
for _, count := range counts {
t.Run(fmt.Sprint(count), func(t *testing.T) {
assertLunarVisibilityGrid(t, info, count)
})
}
})
}
}
func assertLunarVisibilityGrid(t *testing.T, info eclipse.LunarEclipseInfo, count int) {
t.Helper()
data, err := geojson.MarshalLunarEclipse(info, count)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
latitudes := []float64{-89.999999, -89.999, -89.99, -89.9, -89.5, 89.5, 89.9, 89.99, 89.999, 89.999999}
for lat := -89.0; lat <= 89; lat += 2 {
latitudes = append(latitudes, lat)
}
for _, contact := range []struct {
role string
at time.Time
}{
{"visible-at-p1", info.PenumbralStart},
{"visible-at-p4", info.PenumbralEnd},
} {
band := featureWithRole(t, collection, contact.role)
var polygons [][][][]float64
if err := json.Unmarshal(band.Geometry.Coordinates, &polygons); err != nil {
t.Fatal(err)
}
jd := basic.Date2JD(contact.at.UTC())
visible, invisible := 0, 0
for _, lat := range latitudes {
for lon := -179.5; lon < 180; lon += 5 {
altitude := basic.HMoonHeight(jd, lon, lat, 0)
tolerance := 0.003
if math.Abs(lat) > 89.99 {
tolerance = 1e-5
}
if math.Abs(altitude) <= tolerance {
continue
}
inside := geoJSONMultiPolygonContains(polygons, lon, lat)
if inside != (altitude > 0) {
t.Fatalf("%s point=(%g,%g) inside=%v altitude=%g", contact.role, lon, lat, inside, altitude)
}
if inside {
visible++
} else {
invisible++
}
}
}
if visible == 0 || invisible == 0 {
t.Fatalf("%s grid must exercise both sides: visible=%d invisible=%d", contact.role, visible, invisible)
}
}
}
func BenchmarkLunarEclipseGeoJSON(b *testing.B) {
for _, day := range []string{"1904-09-24", "2026-03-03", "4026-03-16"} {
date, err := time.Parse("2006-01-02", day)
if err != nil {
b.Fatal(err)
}
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
b.Fatal("missing lunar eclipse")
}
b.Run(day, func(b *testing.B) {
b.ReportAllocs()
for iteration := 0; iteration < b.N; iteration++ {
if _, err := geojson.MarshalLunarEclipse(info, 360); err != nil {
b.Fatal(err)
}
}
})
}
}
// TestLunarGeoJSONEnvelopeBoundariesMatchDenseTimeSweep 用 1000 点密集时间求极值作为连续时间真值,
// 核对两个包络的边界纬度:包络按 48 个时刻离散采样,边界处的极值是掠射型,误差必须远小于经度列距。
func TestLunarGeoJSONEnvelopeBoundariesMatchDenseTimeSweep(t *testing.T) {
for _, testCase := range []struct {
day string
longitude float64
starts []float64
}{
{"2029-01-01", -150, []float64{-20, 30}},
{"1904-09-24", -170, []float64{0, 40}},
} {
t.Run(testCase.day, func(t *testing.T) {
location := time.UTC
if testCase.day == "2029-01-01" {
location = time.FixedZone("CST", 8*3600)
}
date, err := time.ParseInLocation("2006-01-02", testCase.day, location)
if err != nil {
t.Fatal(err)
}
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
jdStart := basic.Date2JD(info.PenumbralStart.UTC())
jdEnd := basic.Date2JD(info.PenumbralEnd.UTC())
for _, role := range []struct {
name string
maximum bool
}{
{"visible-during-eclipse", true},
{"visible-throughout-eclipse", false},
} {
feature := featureWithRole(t, collection, role.name)
for _, start := range testCase.starts {
for _, limit := range []float64{90, -90} {
got, hasPolygonEdge := lunarEnvelopeBoundaryLatitude(t, feature, testCase.longitude, start, limit)
want, hasTrueEdge := lunarDenseExtremumBoundaryLatitude(
t, jdStart, jdEnd, testCase.longitude, start, limit, role.maximum,
)
if hasPolygonEdge != hasTrueEdge {
t.Fatalf("%s 经度 %v 起点 %v 朝 %v:包络有边界=%v,密集时间真值有边界=%v",
role.name, testCase.longitude, start, limit, hasPolygonEdge, hasTrueEdge)
}
if !hasPolygonEdge {
continue
}
if difference := math.Abs(got - want); difference > 0.01 {
t.Fatalf("%s 经度 %v 起点 %v 朝 %v:包络边界 %.4f,密集时间真值 %.4f,差 %.4f",
role.name, testCase.longitude, start, limit, got, want, difference)
}
}
}
}
})
}
}
// TestLunarGeoJSONEnvelopesCrossAntimeridian 固定包络在 ±180° 的连续性:两侧同纬度必须同号,
// 且日界线拆分后的碎片仍覆盖该经度。
func TestLunarGeoJSONEnvelopesCrossAntimeridian(t *testing.T) {
date := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
for _, role := range []string{"visible-during-eclipse", "visible-throughout-eclipse"} {
feature := featureWithRole(t, collection, role)
var polygons [][][][]float64
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
t.Fatal(err)
}
touchesSeam, insideBoth := false, false
for _, polygon := range polygons {
for _, ring := range polygon {
for _, point := range ring {
if math.Abs(point[0]) == 180 {
touchesSeam = true
}
}
}
}
if !touchesSeam {
t.Fatalf("%s 没有落在 ±180° 上的碎片", role)
}
for latitude := -85.0; latitude <= 85; latitude += 5 {
left := geometryContainsPoint(t, feature.Geometry, -179.5, latitude)
right := geometryContainsPoint(t, feature.Geometry, 179.5, latitude)
if left != right {
t.Fatalf("%s 在纬 %.0f 跨越日界线不连续:%v / %v", role, latitude, left, right)
}
if left {
insideBoth = true
}
}
if !insideBoth {
t.Fatalf("%s 在 ±180° 两侧没有任何共同可见纬度", role)
}
}
}
func lunarEnvelopeBoundaryLatitude(
t *testing.T, feature decodedFeature, longitude, start, limit float64,
) (float64, bool) {
t.Helper()
if !geometryContainsPoint(t, feature.Geometry, longitude, start) ||
geometryContainsPoint(t, feature.Geometry, longitude, limit) {
return 0, false
}
low, high := start, limit
for iteration := 0; iteration < 40; iteration++ {
middle := (low + high) / 2
if geometryContainsPoint(t, feature.Geometry, longitude, middle) {
low = middle
} else {
high = middle
}
}
return low, true
}
func lunarDenseExtremumBoundaryLatitude(
t *testing.T, jdStart, jdEnd, longitude, start, limit float64, maximum bool,
) (float64, bool) {
t.Helper()
extreme := func(latitude float64) float64 {
value := math.Inf(1)
if maximum {
value = math.Inf(-1)
}
const samples = 1000
for index := 0; index <= samples; index++ {
altitude := basic.HMoonHeight(jdStart+(jdEnd-jdStart)*float64(index)/samples, longitude, latitude, 0)
if maximum {
value = math.Max(value, altitude)
} else {
value = math.Min(value, altitude)
}
}
return value
}
if extreme(start) <= 0 || extreme(limit) > 0 {
return 0, false
}
low, high := start, limit
for iteration := 0; iteration < 30; iteration++ {
middle := (low + high) / 2
if extreme(middle) > 0 {
low = middle
} else {
high = middle
}
}
return low, true
}