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

458 lines
16 KiB
Go

package svg
import (
"encoding/xml"
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
// 月食全球可见图的分区契约:四类区域对全球网格穷尽且互斥,"不可见"只覆盖 P1、食甚、P4 三刻都在
// 地平下的地点,"全程可见"恰好等于 P1、食甚、P4 三刻都在地平上的地点。
// Partition contract: the four regions tile the globe exhaustively and exclusively.
const (
lunarPartitionCategoryEntire = "entire"
lunarPartitionCategoryMoonset = "moonset"
lunarPartitionCategoryMoonrise = "moonrise"
lunarPartitionCategoryUnavailable = "unavailable"
lunarPartitionCategoryPenumbra = "penumbra-only"
)
type lunarPartitionLayer struct {
category string
shape string
masks []string
clips []string
}
type lunarPartition struct {
frame svgFrameBox
shapes map[string][][][2]float64
masks map[string][]string
clips map[string][]string
layers []lunarPartitionLayer
}
func lunarMapPartitionOf(t *testing.T, doc string) lunarPartition {
t.Helper()
partition := lunarPartition{
frame: lunarPenumbralTestFrame(t, doc),
shapes: map[string][][][2]float64{},
masks: map[string][]string{},
clips: map[string][]string{},
}
type frame struct {
masks []string
clips []string
}
var stack []frame
inRegions := false
var container, containerID string
decoder := xml.NewDecoder(strings.NewReader(doc))
for {
token, err := decoder.Token()
if err != nil {
break
}
switch typed := token.(type) {
case xml.StartElement:
class := lunarPartitionAttr(typed, "class")
reference := strings.TrimPrefix(lunarPartitionAttr(typed, "href"), "#")
mask := lunarPartitionURLID(lunarPartitionAttr(typed, "mask"))
clip := lunarPartitionURLID(lunarPartitionAttr(typed, "clip-path"))
switch typed.Name.Local {
case "path":
if id := lunarPartitionAttr(typed, "id"); id != "" {
partition.shapes[id] = lunarPenumbralTestRings(t, lunarPartitionAttr(typed, "d"))
}
case "mask", "clipPath":
container, containerID = typed.Name.Local, lunarPartitionAttr(typed, "id")
case "use":
switch container {
case "mask":
if reference != "" {
partition.masks[containerID] = append(partition.masks[containerID], reference)
}
case "clipPath":
if reference != "" {
partition.clips[containerID] = append(partition.clips[containerID], reference)
}
}
case "rect", "circle":
if container == "clipPath" && containerID != "" && partition.clips[containerID] == nil {
partition.clips[containerID] = []string{}
}
case "g":
if strings.Contains(class, "lunar-visibility-regions") {
inRegions = true
}
inherited := frame{}
if len(stack) > 0 {
inherited = stack[len(stack)-1]
}
if mask != "" {
inherited.masks = append(append([]string(nil), inherited.masks...), mask)
}
if clip != "" {
inherited.clips = append(append([]string(nil), inherited.clips...), clip)
}
stack = append(stack, inherited)
}
if inRegions && (typed.Name.Local == "use" || typed.Name.Local == "rect" || typed.Name.Local == "circle") {
current := frame{}
if len(stack) > 0 {
current = stack[len(stack)-1]
}
if mask != "" {
current.masks = append(append([]string(nil), current.masks...), mask)
}
if clip != "" {
current.clips = append(append([]string(nil), current.clips...), clip)
}
partition.layers = append(partition.layers, lunarPartitionLayer{
category: lunarPartitionCategory(class, typed.Name.Local),
shape: reference,
masks: current.masks,
clips: current.clips,
})
}
case xml.EndElement:
if typed.Name.Local == "mask" || typed.Name.Local == "clipPath" {
container, containerID = "", ""
}
if typed.Name.Local == "g" {
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
if len(stack) == 0 {
inRegions = false
}
}
}
}
if len(partition.layers) == 0 {
t.Fatal("visibility region layers not found")
}
return partition
}
func lunarPartitionAttr(element xml.StartElement, name string) string {
for _, attribute := range element.Attr {
if attribute.Name.Local == name {
return attribute.Value
}
}
return ""
}
func lunarPartitionURLID(value string) string {
value = strings.TrimSuffix(strings.TrimPrefix(value, "url(#"), ")")
return value
}
func lunarPartitionCategory(class, element string) string {
switch {
case strings.Contains(class, "entire-eclipse-region"):
return lunarPartitionCategoryEntire
case strings.Contains(class, "penumbra-only-region"):
return lunarPartitionCategoryPenumbra
case strings.Contains(class, "moonset-region"):
return lunarPartitionCategoryMoonset
case strings.Contains(class, "moonrise-region"):
return lunarPartitionCategoryMoonrise
case strings.Contains(class, "eclipse-unavailable-region"):
return lunarPartitionCategoryUnavailable
}
return element + ":" + class
}
// covers 逐点复算一层是否落在该点:形状、外层 clip-path(多形状取并)与嵌套 mask 都要通过,
// mask 里的黑色形状按挖空处理。
func (partition lunarPartition) covers(layer lunarPartitionLayer, x, y float64) bool {
if layer.shape != "" && !partition.inside(layer.shape, x, y) {
return false
}
// clipPath 里的多个形状取并集:落在其中任意一个之内就算通过。
for _, clip := range layer.clips {
shapes := partition.clips[clip]
if len(shapes) == 0 {
continue
}
inside := false
for _, shape := range shapes {
if partition.inside(shape, x, y) {
inside = true
break
}
}
if !inside {
return false
}
}
for _, maskID := range layer.masks {
for _, excluded := range partition.masks[maskID] {
if partition.inside(excluded, x, y) {
return false
}
}
}
return true
}
func (partition lunarPartition) inside(shapeID string, x, y float64) bool {
return lunarPenumbralTestInside(partition.shapes[shapeID], x, y)
}
// painted 返回覆盖该点的最上层区域(SVG 后画的盖住先画的)与覆盖层数。
func (partition lunarPartition) painted(x, y float64) (string, int) {
category, count := "", 0
for _, layer := range partition.layers {
if partition.covers(layer, x, y) {
category, count = layer.category, count+1
}
}
return category, count
}
// partitionGrid 遍历 1°×1° 全球网格(64800 点,格心取 ±0.5°)。
func (partition lunarPartition) grid(visit func(longitude, latitude float64, x, y float64, category string, covered int)) {
for longitude := -179.5; longitude < 180; longitude += 1 {
for latitude := -89.5; latitude < 90; latitude += 1 {
x, y, projectable := partition.frame.Project(longitude, latitude)
if !projectable {
continue
}
category, count := partition.painted(x, y)
visit(longitude, latitude, x, y, category, count)
}
}
}
func lunarPartitionAltitude(at time.Time, longitude, latitude float64) float64 {
return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0)
}
func TestLunarEclipseMapSVGVisibilityRegionsTileTheGlobe(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),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
} {
// 只检查四类底图的分区:半影带另有覆盖性用例。
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC, DisablePenumbralPhase: true})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
counts := map[string]int{}
uncovered, covered, multiple := 0, 64800, 0
var firstUncovered string
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
counts[category]++
if category == "" {
uncovered++
if firstUncovered == "" {
firstUncovered = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")"
}
return
}
if layers > 1 {
multiple++
}
})
t.Logf("%s %s: %v", date.Format("2006-01-02"), countsString(counts), "uncovered="+lunarPartitionFormat(float64(uncovered)))
if uncovered != 0 {
t.Errorf("%s: %d of %d grid points carry no region colour, first at %s",
date.Format("2006-01-02"), uncovered, covered, firstUncovered)
}
if multiple != 0 {
t.Errorf("%s: %d grid points are painted by more than one region layer", date.Format("2006-01-02"), multiple)
}
for _, category := range []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset,
lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable} {
if counts[category] == 0 {
t.Errorf("%s: region %s covers no grid point", date.Format("2006-01-02"), category)
}
}
}
}
// 打开半影阶段后,"仅见半影"带与带食月出/带食月落必须互斥:两种半透明底色叠在一起会混出图例里
// 没有的颜色,所以底带要被同源的 U1/U4 形状裁掉;同时裁切不能切出没有着色的缝。
func TestLunarEclipseMapSVGPenumbralPhaseKeepsCoverage(t *testing.T) {
date := time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC)
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing penumbral-phase map")
}
partition := lunarMapPartitionOf(t, doc)
uncovered, penumbraOnly, doubleTinted := 0, 0, 0
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
if category == "" {
uncovered++
return
}
if category != lunarPartitionCategoryPenumbra {
return
}
penumbraOnly++
for _, layer := range partition.layers {
if layer.category == lunarPartitionCategoryPenumbra || !partition.covers(layer, x, y) {
continue
}
if layer.category == lunarPartitionCategoryMoonset || layer.category == lunarPartitionCategoryMoonrise {
doubleTinted++
return
}
}
})
if uncovered != 0 {
t.Errorf("penumbral-phase map leaves %d grid points without a region colour", uncovered)
}
if penumbraOnly == 0 || doubleTinted != 0 {
t.Errorf("penumbra-only bands cover %d points, %d of them still tinted by a moonrise/moonset band", penumbraOnly, doubleTinted)
}
}
// 与 NASA/EclipseWise 世界图同口径:月球在食甚时刻已在地平上的地点,无论月出月落落在食内哪一段,
// 都属于可见区,绝不能画成"不可见";反之不可见区必须三刻都在地平下。
func TestLunarEclipseMapSVGNotVisibleFollowsHorizon(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),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
} {
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok {
t.Fatalf("%s: missing eclipse", date.Format("2006-01-02"))
}
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
// 0.02° 容差内的地点落在边界上,任何一类都不算错。
wronglyInvisible, upAtMaximumInvisible, wronglyVisible := 0, 0, 0
var sample string
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
start := lunarPartitionAltitude(info.PenumbralStart, longitude, latitude)
maximum := lunarPartitionAltitude(info.Maximum, longitude, latitude)
end := lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude)
if math.Abs(start) < 0.02 || math.Abs(maximum) < 0.02 || math.Abs(end) < 0.02 {
return
}
invisible := category == lunarPartitionCategoryUnavailable
wantInvisible := start < 0 && maximum < 0 && end < 0
if invisible && maximum > 0 {
upAtMaximumInvisible++
if sample == "" {
sample = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")"
}
}
if invisible != wantInvisible {
wronglyInvisible++
}
if !invisible && start > 0 && maximum > 0 && end > 0 && category != lunarPartitionCategoryEntire {
wronglyVisible++
}
})
t.Logf("%s: 食甚在地平上却画成不可见 %d 点%s;与三刻地平判据不符 %d 点;全程在地平上却不在全程可见区 %d 点",
date.Format("2006-01-02"), upAtMaximumInvisible, sample, wronglyInvisible, wronglyVisible)
if upAtMaximumInvisible != 0 {
t.Errorf("%s: %d grid points whose Moon is above the horizon at greatest eclipse %s are painted as not visible",
date.Format("2006-01-02"), upAtMaximumInvisible, sample)
}
if wronglyInvisible != 0 || wronglyVisible != 0 {
t.Errorf("%s: visibility regions disagree with the horizon at P1/greatest/P4 for %d + %d grid points",
date.Format("2006-01-02"), wronglyInvisible, wronglyVisible)
}
}
}
// "全程可见"必须同时满足 P1、食甚、P4 三刻月亮都在地平上:两端可见推不出中途可见,
// 高纬下中天会让月亮在食甚前后落到地平下,这类点归入带食月落。
// Visible throughout requires the Moon above the horizon at P1, greatest and P4 alike:
// visible at both contacts does not imply visible in between.
func TestLunarEclipseMapSVGEntireRegionRequiresGreatestVisibility(t *testing.T) {
for _, date := range []time.Time{
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
} {
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok {
t.Fatalf("%s: missing eclipse", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
holes, holeArea, missing, mislabelled, partialBand := 0, 0.0, 0, 0, 0
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
inP1 := partition.inside("lunar-visible-p1-shape", x, y)
inP4 := partition.inside("lunar-visible-p4-shape", x, y)
inMaximum := partition.inside("lunar-visible-maximum-shape", x, y)
cell := math.Cos(latitude * math.Pi / 180)
switch {
case inP1 && inP4 && inMaximum && category != lunarPartitionCategoryEntire:
holes++
holeArea += cell
case !(inP1 && inP4 && inMaximum) && category == lunarPartitionCategoryEntire:
missing++
case inP1 && inP4 && !inMaximum && category == lunarPartitionCategoryMoonset:
partialBand++
}
// 几何复核:三刻中任一刻月亮在地平下(留 0.05° 余量)就不得涂成全程可见。
if category == lunarPartitionCategoryEntire {
if lunarPartitionAltitude(info.PenumbralStart, longitude, latitude) < -0.05 ||
lunarPartitionAltitude(info.Maximum, longitude, latitude) < -0.05 ||
lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude) < -0.05 {
mislabelled++
}
}
})
t.Logf("%s: p1∩p4∩max 内未着全程可见色 %d 点(%.1f 平方度),全程可见区越界 %d 点,中间不可见带 %d 点,误标 %d 点",
date.Format("2006-01-02"), holes, holeArea, missing, partialBand, mislabelled)
if holes != 0 || missing != 0 || mislabelled != 0 {
t.Errorf("%s: entire-eclipse region is not exactly p1∩p4∩max (holes=%d, extra=%d, mislabelled=%d)",
date.Format("2006-01-02"), holes, missing, mislabelled)
}
}
}
func lunarPartitionFormat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func countsString(counts map[string]int) string {
keys := []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset,
lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable, "", lunarPartitionCategoryPenumbra}
parts := make([]string, 0, len(keys))
for _, key := range keys {
if counts[key] == 0 {
continue
}
name := key
if name == "" {
name = "uncovered"
}
parts = append(parts, name+"="+lunarPartitionFormat(float64(counts[key])))
}
return strings.Join(parts, " ")
}