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

189 lines
7.3 KiB
Go

package geojson_test
import (
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/eclipse"
"b612.me/astro/geojson"
)
// 两条"仅见半影"带的判据:月落侧 = 食始在地平上、本影阶段整段在地平下、食终也在地平下;
// 月出侧 = 食终在地平上、本影阶段整段在地平下、食始也在地平下。本影可见性取整个区间的最大高度,
// 只比 U1/U4 会漏掉极区掠射时两刻之间的短暂窗口。用高度独立复算,并允许边界附近半格误差。
func TestLunarGeoJSONPenumbraBandsFollowUmbralContacts(t *testing.T) {
date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok || !info.HasPartial {
t.Fatalf("missing umbral lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
moonset := featureWithRole(t, collection, "penumbra-moonset")
moonrise := featureWithRole(t, collection, "penumbra-moonrise")
altitude := func(at time.Time, longitude, latitude float64) float64 {
return basic.MoonStateAt(basic.Date2JD(at.UTC())).HMoonHeight(longitude, latitude)
}
umbralExtremum := func(longitude, latitude float64) float64 {
best, step := -99.0, info.PartialEnd.Sub(info.PartialStart)/400
for at := info.PartialStart; !at.After(info.PartialEnd); at = at.Add(step) {
if value := altitude(at, longitude, latitude); value > best {
best = value
}
}
return best
}
checked := map[string]int{}
for _, role := range []string{"penumbra-moonset", "penumbra-moonrise"} {
feature := moonset
if role == "penumbra-moonrise" {
feature = moonrise
}
for longitude := -179.5; longitude < 180; longitude += 3 {
for latitude := -89.5; latitude < 90; latitude += 3 {
heights := []float64{
altitude(info.PenumbralStart, longitude, latitude),
altitude(info.PartialStart, longitude, latitude),
altitude(info.PartialEnd, longitude, latitude),
altitude(info.PenumbralEnd, longitude, latitude),
}
umbral := umbralExtremum(longitude, latitude)
want := false
if role == "penumbra-moonset" {
want = heights[0] > 0 && umbral < 0 && heights[3] < 0
} else {
want = heights[3] > 0 && umbral < 0 && heights[0] < 0
}
// 区域边缘的半格误差:任一条判据高度落在 ±0.5° 内就不作断言。
margin := 0.5
for _, height := range heights {
if height < 0 {
height = -height
}
if height < margin {
margin = height
}
}
if margin < 0.5 {
continue
}
got := geometryContainsPoint(t, feature.Geometry, longitude, latitude)
if got != want {
t.Fatalf("%s (%.1f,%.1f): geometry=%v altitude criterion=%v (P1=%.2f U1=%.2f U4=%.2f P4=%.2f)",
role, longitude, latitude, got, want, heights[0], heights[1], heights[2], heights[3])
}
if want {
checked[role]++
}
}
}
}
if checked["penumbra-moonset"] < 20 || checked["penumbra-moonrise"] < 20 {
t.Fatalf("too few grid points verified: %v", checked)
}
}
// 两条带必须与本地可见性类别的细分一致,且可被 SkipRoles 关掉;纯半影月食没有本影接触,不画带。
func TestLunarGeoJSONPenumbraBandsMatchVisibilityClasses(t *testing.T) {
date := time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, _ := eclipse.LunarEclipseOnDate(date)
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
for _, witness := range []struct {
role string
lon, lat float64
class eclipse.LocalLunarEclipseVisibility
}{
{"penumbra-moonset", -179, -61, eclipse.LocalLunarEclipsePenumbraMoonset},
{"penumbra-moonrise", -69, 61, eclipse.LocalLunarEclipsePenumbraMoonrise},
} {
local, ok := eclipse.GeometricLocalLunarEclipseOnDate(date, witness.lon, witness.lat, 0)
if !ok || local.Visibility != witness.class {
t.Fatalf("(%.0f,%.0f) visibility=%q ok=%v, want %q", witness.lon, witness.lat, local.Visibility, ok, witness.class)
}
if !geometryContainsPoint(t, featureWithRole(t, collection, witness.role).Geometry, witness.lon, witness.lat) {
t.Fatalf("%s does not cover its witness point", witness.role)
}
}
skipped, err := geojson.MarshalLunarEclipseWithOptions(info, 360, geojson.LunarEclipseOptions{
SkipRoles: []string{"penumbra-moonset", "penumbra-moonrise"},
})
if err != nil {
t.Fatal(err)
}
skippedCollection := decodeCollection(t, skipped)
for _, role := range []string{"penumbra-moonset", "penumbra-moonrise"} {
if len(featuresWithRole(skippedCollection, role)) != 0 {
t.Fatalf("%s survived SkipRoles", role)
}
}
assertRoles(t, skippedCollection, "visible-at-p1", "visible-at-p4")
penumbralDate := time.Date(2020, 1, 11, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
penumbral, ok := eclipse.LunarEclipseOnDate(penumbralDate)
if !ok || penumbral.HasPartial {
t.Fatalf("expected a purely penumbral eclipse, HasPartial=%v ok=%v", penumbral.HasPartial, ok)
}
penumbralData, err := geojson.MarshalLunarEclipse(penumbral, 360)
if err != nil {
t.Fatal(err)
}
penumbralCollection := decodeCollection(t, penumbralData)
for _, role := range []string{"penumbra-moonset", "penumbra-moonrise"} {
if len(featuresWithRole(penumbralCollection, role)) != 0 {
t.Fatalf("purely penumbral eclipse must not export %s", role)
}
}
}
// 极区掠射见证:本影区间中途再短暂也算见到本影,不能算进"仅见半影";本影整段在地平下才进带。
func TestLunarGeoJSONPenumbraBandsExcludeUmbralGrazingWindow(t *testing.T) {
date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC)
info, ok := eclipse.LunarEclipseOnDate(date)
if !ok || !info.HasPartial {
t.Fatal("missing umbral lunar eclipse")
}
data, err := geojson.MarshalLunarEclipse(info, 360)
if err != nil {
t.Fatal(err)
}
collection := decodeCollection(t, data)
moonset := featureWithRole(t, collection, "penumbra-moonset")
moonrise := featureWithRole(t, collection, "penumbra-moonrise")
for _, witness := range []struct {
name string
lon, lat float64
want eclipse.LocalLunarEclipseVisibility
}{
{"本影窗口 +0.0006 度", -91.8, -88.7675, eclipse.LocalLunarEclipseMoonrise},
{"U1 已在地平上", -91.75, -88.75, eclipse.LocalLunarEclipseMoonrise},
{"U1 高度 +0.21 度(陡边界)", -75.5, 6.5, eclipse.LocalLunarEclipseMoonset},
{"本影极值 -2.25 度(宽余量)", -68.75, -70.15, eclipse.LocalLunarEclipsePenumbraMoonset},
} {
local, found := eclipse.GeometricLocalLunarEclipseOnDate(date, witness.lon, witness.lat, 0)
if !found || local.Visibility != witness.want {
t.Fatalf("%s: 站点 API visibility=%q ok=%v, want %q", witness.name, local.Visibility, found, witness.want)
}
inMoonset := geometryContainsPoint(t, moonset.Geometry, witness.lon, witness.lat)
inMoonrise := geometryContainsPoint(t, moonrise.Geometry, witness.lon, witness.lat)
switch witness.want {
case eclipse.LocalLunarEclipsePenumbraMoonset:
if !inMoonset || inMoonrise {
t.Fatalf("%s: 半影月落带包含=%v 半影月出带包含=%v,want 只在前者", witness.name, inMoonset, inMoonrise)
}
default:
if inMoonset || inMoonrise {
t.Fatalf("%s: 见了本影却被算进半影带(月落=%v 月出=%v)", witness.name, inMoonset, inMoonrise)
}
}
}
}