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

264 lines
9.8 KiB
Go

package eclipse
import (
"bytes"
"flag"
"fmt"
"go/format"
"os"
"sort"
"testing"
"time"
"b612.me/astro/basic"
)
// 默认只校验发布表的新鲜度与金标准;重新生成必须同时给出 -saros-generate 与环境变量。
// The release table is verified by default; regeneration additionally requires an
// explicit environment variable so an ordinary go test never rewrites it.
//
//go:generate go test -run=TestGenerateSarosExtensionTables -count=1 -timeout=20m -args -saros-generate
var generateSarosTables = flag.Bool("saros-generate", false, "regenerate the extended Saros table")
const sarosGenerateEnv = "ASTRO_REGENERATE_SAROS_TABLE"
// 扩展表窗口按冻结偏移换算,不随默认政策变化。
const sarosWindowPolicy = basic.TimeScaleFreezeUTCOffset
func pinSarosWindowPolicy(t *testing.T) {
t.Helper()
previous := basic.GetTimeScaleFuturePolicy()
basic.SetTimeScaleFuturePolicy(sarosWindowPolicy)
t.Cleanup(func() { basic.SetTimeScaleFuturePolicy(previous) })
}
func sarosTableGenerationEnabled() bool {
return *generateSarosTables && os.Getenv(sarosGenerateEnv) == "1"
}
type sarosTableGolden struct {
name string
span sarosSpan
}
var sarosTableGoldens = []sarosTableGolden{
{name: "solar/head", span: sarosSpan{Series: -47, First: -79891, Last: -61605, Member: 1, Count: 83}},
{name: "solar/202", span: sarosSpan{Series: 202, First: 15941, Last: 31551, Member: 1, Count: 71}},
{name: "solar/tail", span: sarosSpan{Series: 293, First: 48742, Last: 63906, Member: 1, Count: 69}},
{name: "lunar/head", span: sarosSpan{Series: -54, First: -81564, Last: -61717, Member: 1, Count: 90}},
{name: "lunar/4-head", span: sarosSpan{Series: 4, First: -57901, Last: -56563, Member: 1, Count: 84}},
{name: "lunar/4-body", span: sarosSpan{Series: 4, First: -55671, Last: -38723, Member: 8, Count: 84}},
{name: "lunar/tail", span: sarosSpan{Series: 287, First: 49211, Last: 64821, Member: 1, Count: 71}},
}
// 发布表必须与文档化的窗口、生成时的序列总数和若干金标准行一致。
func TestSarosExtensionTableIsFresh(t *testing.T) {
pinSarosWindowPolicy(t)
if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) ||
sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatal("extended table window does not cover -3000..+6000")
}
for _, table := range []struct {
name string
phase int
rows int
span []sarosSpan
}{
{name: "solar", phase: 0, rows: 159, span: solarSarosExtended[:]},
{name: "lunar", phase: 1, rows: 249, span: lunarSarosExtended[:]},
} {
if len(table.span) != table.rows {
t.Fatalf("%s table has %d rows, golden table has %d", table.name, len(table.span), table.rows)
}
validateSarosExtensionSpans(t, table.name, table.phase, table.span)
}
for _, golden := range sarosTableGoldens {
found := false
for _, span := range append(solarSarosExtended[:], lunarSarosExtended[:]...) {
if span == golden.span {
found = true
break
}
}
if !found {
t.Fatalf("golden span %s missing: %+v", golden.name, golden.span)
}
}
}
func validateSarosExtensionSpans(t *testing.T, name string, phase int, spans []sarosSpan) {
t.Helper()
seen := make(map[int]bool)
for index, span := range spans {
if span.First > span.Last || (span.Last-span.First)%sarosCycleLunations != 0 {
t.Fatalf("%s span %d is malformed: %+v", name, index, span)
}
lastMember := span.Member + (span.Last-span.First)/sarosCycleLunations
if span.Member < 1 || lastMember > span.Count {
t.Fatalf("%s span %d member range %d..%d exceeds count %d", name, index, span.Member, lastMember, span.Count)
}
if index > 0 && spans[index-1].Series > span.Series {
t.Fatalf("%s span %d is out of series order: %+v after %+v", name, index, span, spans[index-1])
}
for _, k := range []int{span.First, span.Last} {
if seen[k] {
t.Fatalf("%s return %d is listed twice", name, k)
}
seen[k] = true
if got := sarosNumber(k, phase); got != span.Series {
t.Fatalf("%s return %d belongs to series %d, table says %d", name, k, got, span.Series)
}
}
}
}
type sarosGenerationEvent struct {
k int
tt float64
series int
assigned bool
}
func TestGenerateSarosExtensionTables(t *testing.T) {
if !sarosTableGenerationEnabled() {
t.Skipf("set %s=1 and pass -saros-generate to rewrite saros_table_extended.go", sarosGenerateEnv)
}
pinSarosWindowPolicy(t)
startTT := timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC))
endTT := timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC))
var buf bytes.Buffer
fmt.Fprintln(&buf, "// Code generated by go generate ./eclipse; DO NOT EDIT.")
fmt.Fprintln(&buf, "\npackage eclipse")
fmt.Fprintln(&buf, "\n// UTC astronomical years -3000 through +6000, including both end years.")
fmt.Fprintln(&buf, "// Series numbers follow NASA's Saros-Inex rules. Members are computed with")
fmt.Fprintln(&buf, "// Split-K solar geometry and the union of Danjon/Chauvenet lunar detections.")
fmt.Fprintf(&buf, "const sarosExtendedStartTT = %.12f\nconst sarosExtendedEndTT = %.12f\n", startTT, endTT)
for phase, name := range []string{"solar", "lunar"} {
events := scanSarosGenerationEvents(t, phase)
assignSarosGenerationNumbers(t, events, phase)
spans := buildSarosGenerationSpans(t, events, phase, startTT, endTT)
// 写盘前先按发布口径校验,坏表不得进入仓库。
validateSarosExtensionSpans(t, name, phase, spans)
fmt.Fprintf(&buf, "\nvar %sSarosExtended = [...]sarosSpan{\n", name)
for _, span := range spans {
fmt.Fprintf(&buf, "{Series: %d, First: %d, Last: %d, Member: %d, Count: %d},\n", span.Series, span.First, span.Last, span.Member, span.Count)
}
fmt.Fprintln(&buf, "}")
t.Logf("%s: %d events scanned, %d extension spans", name, len(events), len(spans))
}
source, err := format.Source(buf.Bytes())
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile("saros_table_extended.go", source, 0644); err != nil {
t.Fatal(err)
}
t.Logf("rewrote saros_table_extended.go; rerun TestSarosExtensionTableIsFresh and update the goldens")
}
func scanSarosGenerationEvents(t *testing.T, phase int) []sarosGenerationEvent {
t.Helper()
// The 2000-year margins include complete lifetimes for every series that
// intersects the requested years. buildSarosGenerationSpans checks clipping.
start, _ := sarosLunation(timeToTTJDE(time.Date(-5000, 1, 1, 0, 0, 0, 0, time.UTC)), phase)
end, _ := sarosLunation(timeToTTJDE(time.Date(8001, 1, 1, 0, 0, 0, 0, time.UTC)), phase)
var events []sarosGenerationEvent
for k := start; k <= end; k++ {
tt, exists, valid := sarosEclipse(k, phase)
if !valid {
t.Fatalf("invalid ephemeris: phase=%d lunation=%d", phase, k)
}
if exists {
events = append(events, sarosGenerationEvent{k: k, tt: tt})
}
if (k-start)%20000 == 0 {
t.Logf("phase=%d scanned %d/%d lunations", phase, k-start, end-start)
}
}
return events
}
func sarosGenerationCatalog(tt float64, phase int) (SarosInfo, bool) {
if phase == 0 {
return matchSarosMagic(solarSarosAnchors[:], 0, solarSarosHeadOverrides[:], tt)
}
return matchSarosMagic(lunarSarosAnchors[:], 1, lunarSarosHeadOverrides[:], tt)
}
func assignSarosGenerationNumbers(t *testing.T, events []sarosGenerationEvent, phase int) {
t.Helper()
byLunation := make(map[int]int, len(events))
queue := make([]int, 0, len(events))
for i := range events {
byLunation[events[i].k] = i
if info, ok := sarosGenerationCatalog(events[i].tt, phase); ok {
events[i].series, events[i].assigned = info.Series, true
queue = append(queue, i)
}
}
// Propagate in both directions from every catalog match. A second route
// must agree before any generated data can be written.
for head := 0; head < len(queue); head++ {
event := events[queue[head]]
for _, period := range []struct{ months, series int }{{1, 38}, {5, -33}, {6, 5}, {223, 0}, {358, 1}, {669, 0}} {
for _, direction := range []int{-1, 1} {
i, ok := byLunation[event.k+direction*period.months]
if !ok {
continue
}
series := event.series + direction*period.series
if events[i].assigned {
if events[i].series != series {
t.Fatalf("numbering conflict: phase=%d k=%d series=%d want=%d from k=%d", phase, events[i].k, events[i].series, series, event.k)
}
continue
}
events[i].series, events[i].assigned = series, true
queue = append(queue, i)
}
}
}
for _, event := range events {
if !event.assigned || sarosNumber(event.k, phase) != event.series {
t.Fatalf("unresolved or inconsistent numbering: phase=%d k=%d propagated=%d assigned=%v arithmetic=%d", phase, event.k, event.series, event.assigned, sarosNumber(event.k, phase))
}
}
}
func buildSarosGenerationSpans(t *testing.T, events []sarosGenerationEvent, phase int, startTT, endTT float64) []sarosSpan {
t.Helper()
groups := make(map[int][]sarosGenerationEvent)
needed := make(map[int]bool)
for _, event := range events {
groups[event.series] = append(groups[event.series], event)
if event.tt >= startTT && event.tt < endTT {
if _, known := sarosGenerationCatalog(event.tt, phase); !known {
needed[event.series] = true
}
}
}
var spans []sarosSpan
for series := range needed {
group := groups[series]
first, last := group[0].k, group[len(group)-1].k
if first-events[0].k < sarosCycleLunations || events[len(events)-1].k-last < sarosCycleLunations {
t.Fatalf("series %d reaches the scan edge", series)
}
start := 0
for i := 1; i <= len(group); i++ {
if i < len(group) && group[i].k-group[i-1].k == sarosCycleLunations {
continue
}
spans = append(spans, sarosSpan{Series: series, First: group[start].k, Last: group[i-1].k, Member: start + 1, Count: len(group)})
start = i
}
}
sort.Slice(spans, func(i, j int) bool {
if spans[i].Series != spans[j].Series {
return spans[i].Series < spans[j].Series
}
return spans[i].First < spans[j].First
})
return spans
}