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 }