250 lines
9.4 KiB
Go
250 lines
9.4 KiB
Go
|
|
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
|
||
|
|
}
|