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

420 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package svg
import (
"crypto/md5"
"encoding/hex"
"math"
"regexp"
"sort"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
// 半影阶段开关的契约:零值输出与不区分半影阶段的基线逐字节一致,打开后补画 U1–U4 地平边界、
// 单独着色只看得见半影的月出月落带,且每条边界仍落在它自己的接触时刻上。
// Contract of the penumbral-phase switch: DisablePenumbralPhase reproduces the penumbral-agnostic baseline
// byte for byte, while the enabled value adds the U1-U4 horizons and the penumbra-only bands, with
// every boundary still sitting on its own contact instant.
var lunarPenumbralPhaseBaseline = []struct {
name string
digest string
length int
}{
{"2026-03-03 全食 960x640 zh CST", "4e83ea42b18c703bdde6715f572169d5", 243483},
{"2026-03-03 全食 960x640 en CST", "21696a72ec3bc77a21752e2d675a230b", 243544},
{"2026-03-03 全食 1200x800 zh UTC", "e5a72a8f0b73449f85b4e433f6054069", 247413},
{"2026-03-03 全食 1200x800 en UTC", "1a0386abae194bdbee0bd5c0cb7a5a15", 247482},
{"2025-03-14 全食 960x640 zh CST", "84c39bb9f893678c1cdf354ba8acf850", 250237},
{"2025-03-14 全食 1200x800 zh UTC", "dad36f434ba2bfd7369a430b6f00e567", 255213},
{"2026-03-03 北极投影 zh", "8ee2f75f0f7c617b136576635d908cb4", 159000},
{"2026-03-03 南极投影 zh", "931e502692cdc5b0d09e8f91629f1b2f", 84934},
{"2016-08-18 半影 1200x800 zh UTC", "6d9c60787cc1b703abdd13c4b75a2a92", 241387},
{"2029-01-01 全食 960x640 en CST", "95c0a9d618bda4c0510fa827e104ceaa", 231567},
{"2026-03-03 详细版 zh", "31795334a05621f3a5a0beff1b63b5bd", 270844},
{"2026-03-03 详细版 en", "c5ee0c76f875c3c5fe4a7ff29a90a653", 270992},
{"2029-01-01 详细版 zh", "b0521aa165454262df4344168c9100e9", 257156},
{"2016-08-18 详细版 en", "0c5fdc6c04f993504ae970c2e8861ccb", 261488},
}
func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) {
render := lunarPenumbralPhaseBaselineRenderers()
if len(render) != len(lunarPenumbralPhaseBaseline) {
t.Fatalf("baseline table has %d cases, renderers %d", len(lunarPenumbralPhaseBaseline), len(render))
}
for _, want := range lunarPenumbralPhaseBaseline {
draw, ok := render[want.name]
if !ok {
t.Fatalf("baseline case %q has no renderer", want.name)
}
doc, rendered := draw()
if !rendered {
t.Fatalf("%s: chart missing", want.name)
}
sum := md5.Sum([]byte(doc))
if got := hex.EncodeToString(sum[:]); got != want.digest || len(doc) != want.length {
t.Fatalf("%s: md5=%s bytes=%d, want md5=%s bytes=%d",
want.name, got, len(doc), want.digest, want.length)
}
}
}
func lunarPenumbralPhaseBaselineRenderers() map[string]func() (string, bool) {
cst := time.FixedZone("CST", 8*3600)
utc := time.UTC
return map[string]func() (string, bool){
"2026-03-03 全食 960x640 zh CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 全食 960x640 en CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 全食 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2026-03-03 全食 1200x800 en UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "en", Location: utc, DisablePenumbralPhase: true})
},
"2025-03-14 全食 960x640 zh CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2025-03-14 全食 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2026-03-03 北极投影 zh": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Language: "zh", Location: utc, Projection: EclipseMapProjectionNorthPolar, DisablePenumbralPhase: true})
},
"2026-03-03 南极投影 zh": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Language: "zh", Location: utc, Projection: EclipseMapProjectionSouthPolar, DisablePenumbralPhase: true})
},
"2016-08-18 半影 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2029-01-01 全食 960x640 en CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 详细版 zh": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 详细版 en": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2029-01-01 详细版 zh": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2016-08-18 详细版 en": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2016, 8, 18, 12, 0, 0, 0, utc),
LunarEclipseDetailedSVGOptions{Language: "en", Location: cst, DisablePenumbralPhase: true})
},
}
}
func TestLunarEclipseMapSVGPenumbralPhaseAddsUmbralContacts(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
date := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
options := LunarEclipseMapSVGOptions{Language: "zh", Location: cst}
on, ok := LunarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing penumbral-phase lunar-eclipse map")
}
options.DisablePenumbralPhase = true
off, ok := LunarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing lunar-eclipse map")
}
for _, want := range []string{
`class="u1-horizon"`, `class="u2-horizon"`, `class="u3-horizon"`, `class="u4-horizon"`,
`class="penumbra-only-region penumbra-moonset-region"`, `class="penumbra-only-region penumbra-moonrise-region"`,
`mask id="lunar-not-umbral-visible-mask"`, `clip-path="url(#lunar-visible-umbral)"`,
`id="lunar-visible-umbral-1"`,
`id="lunar-visible-u1-shape"`, `id="lunar-visible-u4-shape"`,
"半影月出", "半影月落", "本影阶段 U1",
} {
if !strings.Contains(on, want) {
t.Fatalf("penumbral-phase map missing %q", want)
}
if strings.Contains(off, want) {
t.Fatalf("default map must not contain %q", want)
}
}
if err := validateEclipseMapXML(on); err != nil {
t.Fatalf("penumbral-phase map is not valid XML: %v", err)
}
for _, id := range []string{"lunar-visible-p1-shape", "lunar-visible-p4-shape", "lunar-visible-maximum-shape"} {
shape := regexp.MustCompile(`id="` + id + `" d="([^"]*)"`)
a, b := shape.FindStringSubmatch(off), shape.FindStringSubmatch(on)
if a == nil || b == nil || a[1] != b[1] {
t.Fatalf("shape %s changed when the penumbral phase is enabled", id)
}
}
english, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Language: "en", Location: cst})
if !ok || !strings.Contains(english, "Penumbra moonrise") || !strings.Contains(english, "Penumbra moonset") ||
!strings.Contains(english, "Umbral phases U1") {
t.Fatal("English penumbral-phase map misses its legend entry or contact row")
}
// 纯半影月食没有本影接触,按 NASA 口径不该凭空生成本影边界。
onlyPenumbral, ok := LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
LunarEclipseMapSVGOptions{Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing penumbral-only lunar-eclipse map")
}
for _, unwanted := range []string{"u1-horizon", "penumbra-only-region", "半影月出", "半影月落"} {
if strings.Contains(onlyPenumbral, unwanted) {
t.Fatalf("penumbral-only eclipse must not gain %q", unwanted)
}
}
onlyPenumbralDefault, ok := LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
LunarEclipseMapSVGOptions{Language: "zh", Location: time.UTC, DisablePenumbralPhase: true})
if !ok || onlyPenumbralDefault != onlyPenumbral {
t.Fatal("penumbral-only eclipse must render identically with and without the switch")
}
}
// 同判:U1/U4 形状等价于「该接触时刻月球中心在地平线上」,半影带等价于对应的三段高度判据,
// 每条地平边界线都落在自己的接触时刻上(P1 线不能落在 U1 时刻)。
func TestLunarEclipseMapSVGPenumbraBandsMatchHorizonCriterion(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
} {
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok || !info.HasPartial {
t.Fatalf("%s: missing umbral lunar eclipse", date.Format("2006-01-02"))
}
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 1200, Height: 800, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing penumbral-phase map", date.Format("2006-01-02"))
}
frame := lunarPenumbralTestFrame(t, doc)
p1 := lunarPenumbralTestShape(t, doc, "lunar-visible-p1-shape")
p4 := lunarPenumbralTestShape(t, doc, "lunar-visible-p4-shape")
u1 := lunarPenumbralTestShape(t, doc, "lunar-visible-u1-shape")
u4 := lunarPenumbralTestShape(t, doc, "lunar-visible-u4-shape")
for _, line := range []struct {
class string
at time.Time
other time.Time
}{
{"p1-horizon", info.PenumbralStart, info.PartialStart},
{"p4-horizon", info.PenumbralEnd, info.PartialEnd},
{"u1-horizon", info.PartialStart, info.PenumbralStart},
{"u4-horizon", info.PartialEnd, info.PenumbralEnd},
} {
maxOnContact := 0.0
var otherAltitudes []float64
vertices := lunarPenumbralTestLine(t, doc, line.class)
if len(vertices) < 30 {
t.Fatalf("%s: %s has %d vertices", date.Format("2006-01-02"), line.class, len(vertices))
}
for _, vertex := range vertices {
longitude, latitude := lunarPenumbralTestUnproject(frame, vertex[0], vertex[1])
maxOnContact = math.Max(maxOnContact, math.Abs(lunarPenumbralTestAltitude(line.at, longitude, latitude)))
otherAltitudes = append(otherAltitudes, math.Abs(lunarPenumbralTestAltitude(line.other, longitude, latitude)))
}
if maxOnContact > 0.01 {
t.Fatalf("%s: %s deviates %.4f deg from its own contact", date.Format("2006-01-02"), line.class, maxOnContact)
}
// 两条地平线必然相交于两点,交点是整条线唯一贴近另一组接触的地方;中位数说明其余部分分得很开。
sort.Float64s(otherAltitudes)
median := otherAltitudes[len(otherAltitudes)/2]
if median < 2 {
t.Fatalf("%s: %s sits only %.3f deg (median) from the other contact", date.Format("2006-01-02"), line.class, median)
}
t.Logf("%s %s: 与自身接触最大偏差 %.5f 度,与另一组接触 |alt| 中位数 %.3f 度(最小 %.3f)",
date.Format("2006-01-02"), line.class, maxOnContact, median, otherAltitudes[0])
}
judged, mismatches := 0, 0
for longitude := -179.0; longitude < 180; longitude += 2 {
for latitude := -89.0; latitude < 90; latitude += 2 {
x, y, projectable := frame.Project(longitude, latitude)
if !projectable {
continue
}
altitudes := [4]float64{
lunarPenumbralTestAltitude(info.PenumbralStart, longitude, latitude),
lunarPenumbralTestAltitude(info.PartialStart, longitude, latitude),
lunarPenumbralTestAltitude(info.PartialEnd, longitude, latitude),
lunarPenumbralTestAltitude(info.PenumbralEnd, longitude, latitude),
}
nearHorizon := false
for _, altitude := range altitudes {
if math.Abs(altitude) < 0.05 {
nearHorizon = true
}
}
if nearHorizon {
continue
}
judged++
if lunarPenumbralTestInside(u1, x, y) != (altitudes[1] > 0) {
t.Errorf("%s: U1 shape disagrees with the U1 horizon at (%g,%g)", date.Format("2006-01-02"), longitude, latitude)
}
if lunarPenumbralTestInside(u4, x, y) != (altitudes[2] > 0) {
t.Errorf("%s: U4 shape disagrees with the U4 horizon at (%g,%g)", date.Format("2006-01-02"), longitude, latitude)
}
// SVG 组合规则:月落带 = P1 可见、P4 与 U1 不可见;月出带 = P4 可见、P1 与 U4 不可见。
svgMoonset := lunarPenumbralTestInside(p1, x, y) && !lunarPenumbralTestInside(p4, x, y) && !lunarPenumbralTestInside(u1, x, y)
svgMoonrise := lunarPenumbralTestInside(p4, x, y) && !lunarPenumbralTestInside(p1, x, y) && !lunarPenumbralTestInside(u4, x, y)
wantMoonset := altitudes[0] > 0 && altitudes[1] <= 0 && altitudes[3] <= 0
wantMoonrise := altitudes[3] > 0 && altitudes[2] <= 0 && altitudes[0] <= 0
if svgMoonset != wantMoonset {
mismatches++
}
if svgMoonrise != wantMoonrise {
mismatches++
}
}
}
if judged < 5000 {
t.Fatalf("%s: too few judged samples (%d)", date.Format("2006-01-02"), judged)
}
if mismatches != 0 {
t.Fatalf("%s: penumbra band boundaries disagree with the horizon criterion at %d of %d judged sites",
date.Format("2006-01-02"), mismatches, judged)
}
}
}
func lunarPenumbralTestAltitude(at time.Time, longitude, latitude float64) float64 {
return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0)
}
func lunarPenumbralTestFrame(t *testing.T, doc string) svgFrameBox {
t.Helper()
match := regexp.MustCompile(`<clipPath id="lunar-map-clip"><rect x="([-0-9.]+)" y="([-0-9.]+)" width="([-0-9.]+)" height="([-0-9.]+)"`).FindStringSubmatch(doc)
if match == nil {
// 圆盘版式(极区/正射)画的是圆,用 map-frame 圆的外接方形当图框。
disk := regexp.MustCompile(`<circle class="map-frame" cx="([-0-9.]+)" cy="([-0-9.]+)" r="([-0-9.]+)"`).FindStringSubmatch(doc)
if disk == nil {
t.Fatal("map frame not found")
}
cx, _ := strconv.ParseFloat(disk[1], 64)
cy, _ := strconv.ParseFloat(disk[2], 64)
radius, _ := strconv.ParseFloat(disk[3], 64)
return svgFrameBox{x: cx - radius, y: cy - radius, width: 2 * radius, height: 2 * radius}
}
values := make([]float64, 4)
for index := range values {
values[index], _ = strconv.ParseFloat(match[index+1], 64)
}
return svgFrameBox{x: values[0], y: values[1], width: values[2], height: values[3]}
}
type svgFrameBox struct{ x, y, width, height float64 }
func (frame svgFrameBox) Project(longitude, latitude float64) (float64, float64, bool) {
return frame.x + (longitude+180)/360*frame.width, frame.y + (90-latitude)/180*frame.height, true
}
func lunarPenumbralTestUnproject(frame svgFrameBox, x, y float64) (float64, float64) {
return (x-frame.x)/frame.width*360 - 180, 90 - (y-frame.y)/frame.height*180
}
func lunarPenumbralTestShape(t *testing.T, doc, id string) [][][2]float64 {
t.Helper()
match := regexp.MustCompile(`id="` + id + `" d="([^"]*)"`).FindStringSubmatch(doc)
if match == nil {
t.Fatalf("shape %s not found", id)
}
return lunarPenumbralTestRings(t, match[1])
}
func lunarPenumbralTestLine(t *testing.T, doc, class string) [][2]float64 {
t.Helper()
match := regexp.MustCompile(`class="` + class + `" d="([^"]*)"`).FindStringSubmatch(doc)
if match == nil {
t.Fatalf("boundary %s not found", class)
}
rings := lunarPenumbralTestRings(t, match[1])
if len(rings) == 0 {
t.Fatalf("boundary %s has no vertices", class)
}
return rings[0]
}
func lunarPenumbralTestRings(t *testing.T, path string) [][][2]float64 {
t.Helper()
var rings [][][2]float64
for _, chunk := range strings.Split(path, "Z") {
fields := strings.Fields(chunk)
var ring [][2]float64
for index := 0; index < len(fields); {
if fields[index] == "M" || fields[index] == "L" {
x, _ := strconv.ParseFloat(fields[index+1], 64)
y, _ := strconv.ParseFloat(fields[index+2], 64)
ring = append(ring, [2]float64{x, y})
index += 3
continue
}
index++
}
if len(ring) >= 3 {
rings = append(rings, ring)
}
}
return rings
}
func lunarPenumbralTestInside(rings [][][2]float64, x, y float64) bool {
for _, ring := range rings {
if pointInLunarVisibilityPolygon(x, y, ring) {
return true
}
}
return false
}
// 仅见半影的两条带必须排除"本影区间里月亮曾在地平上"的站点:只挖 U1/U4 两刻会漏掉两刻之间的窗口,
// 按经度插值又会把近乎子午线方向的边界切进真实区域(1932-03-22 西经 75.5° 一带 0.5° 经度内上边界
// 从 +19.9° 掉到 −27.2°)。掩膜按 15 分钟档位取样整球可见半球,残余的掠射窗口深度约 0.05°
// (约 0.15 像素);深度更浅、只持续几分钟的窗口只由站点 API 与 GeoJSON 精确判定。
func TestLunarEclipseMapSVGPenumbralBandsExcludeUmbralWindow(t *testing.T) {
date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC)
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 1200, Height: 800, Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing lunar-eclipse map")
}
partition := lunarMapPartitionOf(t, doc)
for _, witness := range []struct {
name string
lon, lat float64
want string
}{
{"U1 已在地平上(+0.0041 度)", -91.75, -88.75, lunarPartitionCategoryMoonrise},
{"U1 高度 +0.21 度(陡边界,1° 列会切进去)", -75.5, 6.5, lunarPartitionCategoryMoonset},
{"本影极值 -2.25 度(宽余量)", -68.75, -70.15, lunarPartitionCategoryPenumbra},
} {
x, y, projectable := partition.frame.Project(witness.lon, witness.lat)
if !projectable {
t.Fatalf("%s: 投影失败", witness.name)
}
category, _ := partition.painted(x, y)
if category != witness.want {
t.Fatalf("%s (%.4f,%.4f) category=%q want %q", witness.name, witness.lon, witness.lat, category, witness.want)
}
}
}