package basic import ( "math" "testing" "time" ) func occultationIsochroneTestStar() StarCoordinate { return StarCoordinate{ ID: "Regulus", RA: 152.09292, Dec: 11.96719, Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000, } } // 等时线必须与站心掩甚定义自洽:支路上任意点由站心事件搜索独立求出的掩甚时刻等于该支路电平。 func TestOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) { star := occultationIsochroneTestStar() start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC) end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC) options := OccultationPathOptions{ Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute, } paths, err := FindStarOccultationPaths(start, end, star, options) if err != nil || len(paths) == 0 { t.Fatalf("no path with isochrones: %v", err) } contours := paths[0].GreatestTimeContours if len(contours) < 4 { t.Fatalf("expected a full isochrone fan, got %d contours", len(contours)) } for _, contour := range contours { if contour.Time.Second() != 0 || contour.Time.Nanosecond() != 0 || contour.Time.Minute()%15 != 0 { t.Fatalf("contour time %s is not aligned to the requested step", contour.Time.Format("15:04:05.000")) } } verified := 0 worst := 0.0 for _, contour := range contours { branchPoints := 0 for _, segment := range contour.Segments { if len(segment) < 2 { t.Fatalf("contour %.6f has a degenerate branch", contour.JDE) } branchPoints += len(segment) } // 弧长步长上限决定了单条等时线的点数上界;点数爆表意味着延拓没有真正前进。 if branchPoints > 4000 { t.Fatalf("contour %.6f has %d points, continuation is not advancing", contour.JDE, branchPoints) } level := occultationTTToLocation(contour.JDE, time.UTC) for _, segment := range contour.Segments { for index := 0; index < len(segment) && verified < 80; index += 1 + len(segment)/8 { point := segment[index] infos, searchErr := FindStarOccultations( level.Add(-2*time.Hour), level.Add(2*time.Hour), star, point.Longitude, point.Latitude, 0, OccultationSearchOptions{}, ) if searchErr != nil || len(infos) == 0 { continue } delta := math.Abs(infos[0].Greatest.Sub(point.Time).Seconds()) if delta > worst { worst = delta } verified++ } } } if verified < 10 { t.Fatalf("verified only %d points", verified) } t.Logf("%d contours, %d points verified, worst %.2f s", len(contours), verified, worst) if worst > 1 { t.Fatalf("isochrone disagrees with local greatest occultation by %.2f s", worst) } } func TestOccultationGreatestTimeContoursDisabledByDefault(t *testing.T) { star := occultationIsochroneTestStar() start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC) end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC) paths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{ Step: 2 * time.Minute, DisableFootprints: true, }) if err != nil || len(paths) == 0 { t.Fatalf("no occultation path: %v", err) } if len(paths[0].GreatestTimeContours) != 0 { t.Fatalf("contours computed without a request: %d", len(paths[0].GreatestTimeContours)) } } // 行星是有限盘面,等时线按外接触锥门控,必须与站心行星掩甚一致。 func TestPlanetOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) { start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC) options := OccultationPathOptions{ Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute, } paths, err := FindPlanetOccultationPaths(start, end, OccultationSaturn, options) if err != nil || len(paths) == 0 { t.Fatalf("no planetary path with isochrones: %v", err) } contours := paths[0].GreatestTimeContours if len(contours) == 0 { t.Fatal("no greatest-time contours") } verified := 0 worst := 0.0 for _, contour := range contours { level := occultationTTToLocation(contour.JDE, time.UTC) for _, segment := range contour.Segments { if len(segment) < 2 { t.Fatalf("contour %.6f has a degenerate branch", contour.JDE) } for index := 0; index < len(segment) && verified < 60; index += 1 + len(segment)/8 { point := segment[index] infos, searchErr := FindPlanetOccultations( level.Add(-3*time.Hour), level.Add(3*time.Hour), OccultationSaturn, point.Longitude, point.Latitude, 0, OccultationSearchOptions{}, ) if searchErr != nil || len(infos) == 0 { continue } delta := math.Abs(infos[0].Greatest.Sub(point.Time).Seconds()) if delta > worst { worst = delta } verified++ } } } if verified < 10 { t.Fatalf("verified only %d points", verified) } t.Logf("%d contours, %d points verified, worst %.2f s", len(contours), verified, worst) if worst > 1 { t.Fatalf("isochrone disagrees with local greatest occultation by %.2f s", worst) } } // 只要存在落在可见域内的序列种子,就必须至少有一个被投影接受:全部被拒会让整条等时线退回 5° 粗扫, // 产生相位错开的重复支路。投影判据只能按位置偏移给(残差量纲随口径变化),不能用绝对残差阈值。 func TestOccultationGreatestTimeContourSeedsConvergeInVisibleDomain(t *testing.T) { star := occultationIsochroneTestStar() starPaths, err := FindStarOccultationPaths( time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC), time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC), star, OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute}, ) if err != nil || len(starPaths) == 0 { t.Fatalf("star: %v", err) } planetPaths, err := FindPlanetOccultationPaths( time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC), OccultationSaturn, OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute}, ) if err != nil || len(planetPaths) == 0 { t.Fatalf("planet: %v", err) } config, _ := planetOccultationConfigFor(OccultationSaturn) starCache := newStarOccultationEventCache(star) planetCache := newPlanetOccultationEventCache(config) cases := []struct { name string contours []OccultationGreatestTimeContour series [][]OccultationPathPoint arcAt func(level float64) occultationGreatestTimeArc }{ { name: "star", contours: starPaths[0].GreatestTimeContours, series: [][]OccultationPathPoint{ starPaths[0].CenterLine, starPaths[0].NorthernLimit, starPaths[0].SouthernLimit, }, arcAt: func(level float64) occultationGreatestTimeArc { return occultationGreatestTimeArc{ evaluation: starCache.riseSetCache.evaluation(level), location: time.UTC, } }, }, { name: "planet", contours: planetPaths[0].GreatestTimeContours, series: [][]OccultationPathPoint{ planetPaths[0].CenterLine, planetPaths[0].NorthernLimit, planetPaths[0].SouthernLimit, }, arcAt: func(level float64) occultationGreatestTimeArc { return occultationGreatestTimeArc{ evaluation: planetCache.riseSetCache.evaluation(level), location: time.UTC, useContactMetric: true, } }, }, } sampled := 0 for _, tc := range cases { if len(tc.contours) == 0 { t.Fatalf("%s: no contours", tc.name) } for _, contour := range tc.contours { arc := tc.arcAt(contour.JDE) inDomain, accepted := 0, 0 for _, seed := range occultationGreatestTimeContourSeeds(tc.series, contour.JDE) { if _, _, ok := arc.sample(seed[0], seed[1]); !ok { continue } inDomain++ if _, _, _, corrected := arc.correct(seed[0], seed[1]); corrected { accepted++ } } sampled += inDomain if inDomain > 0 && accepted == 0 { t.Fatalf("%s contour %s: %d in-domain seeds, none accepted", tc.name, contour.Time.Format("15:04"), inDomain) } } } if sampled == 0 { t.Fatalf("no in-domain seeds sampled") } } // 同一时刻取值的等时线必须是单条连通曲线:曾因"覆盖判据用点到顶点距离"而被重复延拓 // (行星掩星每个取值 3 条重叠支路)。 func TestOccultationGreatestTimeContoursHaveSingleBranchPerLevel(t *testing.T) { star := occultationIsochroneTestStar() starPaths, err := FindStarOccultationPaths( time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC), time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC), star, OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute}, ) if err != nil || len(starPaths) == 0 { t.Fatalf("star: %v", err) } planetPaths, err := FindPlanetOccultationPaths( time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC), OccultationSaturn, OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute}, ) if err != nil || len(planetPaths) == 0 { t.Fatalf("planet: %v", err) } for _, tc := range []struct { name string contours []OccultationGreatestTimeContour }{ {"star", starPaths[0].GreatestTimeContours}, {"planet", planetPaths[0].GreatestTimeContours}, } { if len(tc.contours) == 0 { t.Fatalf("%s: no contours", tc.name) } for _, contour := range tc.contours { if len(contour.Segments) != 1 { t.Fatalf("%s contour %s has %d branches, want a single connected curve", tc.name, contour.Time.Format("15:04"), len(contour.Segments)) } } } } // 显式时刻取值:按 TT 儒略日给出,回显时刻取整到毫秒,窗口外取值被丢弃,且同样受上限保护。 func TestOccultationGreatestTimeContoursAcceptExplicitValues(t *testing.T) { star := occultationIsochroneTestStar() start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC) end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC) base := OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute} stepPaths, err := FindStarOccultationPaths(start, end, star, base) if err != nil || len(stepPaths) == 0 || len(stepPaths[0].GreatestTimeContours) == 0 { t.Fatalf("step request failed: %v", err) } stepContours := stepPaths[0].GreatestTimeContours // 用步长路径得到的取值当显式取值,外加 8 个窗口外取值,检验回显与过滤。 values := make([]float64, 0, len(stepContours)+8) for _, contour := range stepContours { values = append(values, contour.JDE) } for index := 0; index < 8; index++ { values = append(values, occultationTimeToTT(start.Add(-time.Duration(index+1)*time.Hour))) } explicitPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{ Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeValues: values, }) if err != nil || len(explicitPaths) == 0 { t.Fatalf("explicit request failed: %v", err) } contours := explicitPaths[0].GreatestTimeContours if len(contours) != len(stepContours) { t.Fatalf("explicit values produced %d contours, step request produced %d", len(contours), len(stepContours)) } for index, contour := range contours { if contour.JDE != stepContours[index].JDE { t.Fatalf("contour %d JDE %.9f does not match the requested value %.9f", index, contour.JDE, stepContours[index].JDE) } if contour.Time.Nanosecond() != 0 { t.Fatalf("contour time %s is not rounded to the millisecond", contour.Time.Format("15:04:05.000000000")) } } // 上限:72 个互不相同、都落在窗口内的取值,返回条数不得超过共享上限。 dense := make([]float64, 0, greatestTimeContourMaxLevels+8) for index := 0; index < greatestTimeContourMaxLevels+8; index++ { dense = append(dense, stepContours[0].JDE+float64(index)*1e-6) } cappedPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{ Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeValues: dense, }) if err != nil || len(cappedPaths) == 0 { t.Fatalf("capped request failed: %v", err) } if count := len(cappedPaths[0].GreatestTimeContours); count > greatestTimeContourMaxLevels { t.Fatalf("level cap not applied: %d contours", count) } } func BenchmarkOccultationGreatestTimeContours(b *testing.B) { star := occultationIsochroneTestStar() start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC) end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC) options := OccultationPathOptions{ Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute, } for index := 0; index < b.N; index++ { FindStarOccultationPaths(start, end, star, options) } }