feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
package eclipse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 默认只校验发布表的新鲜度与金标准;重新生成必须同时给出 -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"
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user