package basic import ( "math" "testing" ) func TestSolarEclipseShadowInstantMatchesPackagedSamples(t *testing.T) { result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) if len(result.CentralShadowFootprints) < 3 { t.Fatalf("central shadow samples=%d, want a full series", len(result.CentralShadowFootprints)) } solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) checked := map[string]bool{} for _, index := range []int{0, len(result.CentralShadowFootprints) / 2, len(result.CentralShadowFootprints) - 1} { sampled := result.CentralShadowFootprints[index] instant, ok := solver.ShadowAtJDE(sampled.JDE) if !ok { t.Fatalf("instant call reported no umbra at %v, packaged sample has %d segments", sampled.JDE, len(sampled.Boundaries)) } if instant.Closed != sampled.Closed { t.Fatalf("closed mismatch at %v: instant=%v packaged=%v", sampled.JDE, instant.Closed, sampled.Closed) } if len(instant.Boundaries) != len(sampled.Boundaries) { t.Fatalf("segments mismatch at %v: instant=%d packaged=%d", sampled.JDE, len(instant.Boundaries), len(sampled.Boundaries)) } for segmentIndex := range sampled.Boundaries { if len(instant.Boundaries[segmentIndex]) != len(sampled.Boundaries[segmentIndex]) { t.Fatalf("segment %d length mismatch at %v: instant=%d packaged=%d", segmentIndex, sampled.JDE, len(instant.Boundaries[segmentIndex]), len(sampled.Boundaries[segmentIndex])) } for pointIndex := range sampled.Boundaries[segmentIndex] { got := instant.Boundaries[segmentIndex][pointIndex] want := sampled.Boundaries[segmentIndex][pointIndex] if got.Longitude != want.Longitude || got.Latitude != want.Latitude { t.Fatalf("point mismatch at %v segment %d point %d: got %.12f,%.12f want %.12f,%.12f", sampled.JDE, segmentIndex, pointIndex, got.Longitude, got.Latitude, want.Longitude, want.Latitude) } } } checked[instant.Topology.Signature()] = true } if len(checked) < 2 { t.Fatalf("expected distinct topology signatures across the series, got %v", checked) } } func TestSolarEclipseShadowInstantEmptyOffPath(t *testing.T) { solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) // 2009-07-22 食甚前后 12 小时已经远离地球上的本影。 instant, ok := solver.ShadowAtJDE(JDCalc(2009, 7, 22) + 0.6) if ok || !instant.Empty() { t.Fatalf("off-path instant reported ok=%v empty=%v", ok, instant.Empty()) } if instant.Topology.Signature() != "empty" { t.Fatalf("empty topology signature=%q, want empty", instant.Topology.Signature()) } if instant.DeltaTSeconds <= 0 { t.Fatalf("off-path instant must still report the ΔT used, got %v", instant.DeltaTSeconds) } } func TestSolarEclipseShadowTopologySignatureChangesAtHorizonCut(t *testing.T) { result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) signatures := map[string]int{} for _, sampled := range result.CentralShadowFootprints { instant, ok := solver.ShadowAtJDE(sampled.JDE) if !ok { continue } signatures[instant.Topology.Signature()]++ } closedSeen, horizonSeen := false, false for signature := range signatures { if len(signature) >= 7 && signature[:7] == "umbra-c" { closedSeen = true } if len(signature) >= 7 && signature[:7] == "umbra-h" { horizonSeen = true } } if !closedSeen || !horizonSeen { t.Fatalf("expected both self-closed and horizon-cut signatures, got %v", signatures) } } func TestSolarEclipseStationStateMatchesLocalEclipse(t *testing.T) { seed := JDCalc(2024, 4, 8) const lon, lat = -96.8, 32.8 local := LocalSolarEclipse(seed, lon, lat, 0) if !local.HasTotal { t.Fatalf("expected a total eclipse at the test station, got type=%v magnitude=%.3f", local.Type, local.Magnitude) } solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) state := solver.StationStateAtJDE(local.GreatestEclipse, lon, lat, 0) if math.Abs(state.SeparationDeg-local.Separation) > 1e-9 { t.Fatalf("separation mismatch: station=%.12f local=%.12f", state.SeparationDeg, local.Separation) } if math.Abs(state.SunAltitudeDeg-local.SunAltitude) > 1e-9 || math.Abs(state.SunAzimuthDeg-local.SunAzimuth) > 1e-9 { t.Fatalf("sun position mismatch: station=(%.9f,%.9f) local=(%.9f,%.9f)", state.SunAltitudeDeg, state.SunAzimuthDeg, local.SunAltitude, local.SunAzimuth) } if math.Abs(state.Obscuration-local.Obscuration) > 1e-12 { t.Fatalf("obscuration mismatch: station=%.12f local=%.12f", state.Obscuration, local.Obscuration) } if !state.InCentralPhase || !state.HasTotalPhase || state.HasAnnularPhase { t.Fatalf("central phase flags wrong: %+v", state) } if !state.Visible { t.Fatal("expected the Sun above the horizon at greatest eclipse") } } func TestSolarEclipseShadowDeltaTMovesOnlyEarthRotation(t *testing.T) { result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) if len(result.CentralShadowFootprints) == 0 { t.Fatal("no central shadow samples") } jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE base := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) shifted := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{DeltaTSeconds: 100}) first, okFirst := base.ShadowAtJDE(jde) second, okSecond := shifted.ShadowAtJDE(jde) if !okFirst || !okSecond { t.Fatalf("expected a footprint at %v (ok=%v/%v)", jde, okFirst, okSecond) } if first.JDE != second.JDE { t.Fatalf("geometry instant moved with ΔT: %v vs %v", first.JDE, second.JDE) } if math.Abs(second.DeltaTSeconds-100) > 1e-12 || first.DeltaTSeconds == second.DeltaTSeconds { t.Fatalf("ΔT not reported per solver: %.6f vs %.6f", first.DeltaTSeconds, second.DeltaTSeconds) } centroid := func(instant SolarEclipseShadowInstant) (float64, float64) { var sumX, sumY, sumZ, count float64 for _, segment := range instant.Boundaries { for _, point := range segment { longitude, latitude := point.Longitude*rad, point.Latitude*rad sumX += math.Cos(latitude) * math.Cos(longitude) sumY += math.Cos(latitude) * math.Sin(longitude) sumZ += math.Sin(latitude) count++ } } length := math.Sqrt(sumX*sumX + sumY*sumY + sumZ*sumZ) return math.Atan2(sumY/length, sumX/length) / rad, math.Asin(sumZ/length) / rad } firstLon, firstLat := centroid(first) secondLon, secondLat := centroid(second) shift := DeltaTGroundShiftKM(100-first.DeltaTSeconds, firstLat) measured := solarEclipsePathDistanceKM( SolarEclipsePathPoint{Longitude: firstLon, Latitude: firstLat}, SolarEclipsePathPoint{Longitude: secondLon, Latitude: secondLat}, ) if math.Abs(measured-shift) > 0.05*shift { t.Fatalf("ΔT ground shift=%.1f km, want about %.1f km", measured, shift) } } func BenchmarkSolarEclipseShadowAtJDE(b *testing.B) { solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE b.ResetTimer() for index := 0; index < b.N; index++ { if _, ok := solver.ShadowAtJDE(jde + float64(index)*1e-9); !ok { b.Fatal("no footprint") } } } func BenchmarkSolarEclipseStationStateAtJDE(b *testing.B) { solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) jde := JDCalc(2024, 4, 8) + 0.78 b.ResetTimer() for index := 0; index < b.N; index++ { _ = solver.StationStateAtJDE(jde+float64(index)*1e-9, -96.8, 32.8, 0) } } func TestSolarEclipseShadowClampsBoundaryPoints(t *testing.T) { result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) if len(result.CentralShadowFootprints) == 0 { t.Fatal("no central shadow samples") } jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE // 个位数的边界点数会产出无意义的"足迹"(真实案例:1 个点也能 ok=true),必须被夹到下限。 solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{BoundaryPoints: 1}) instant, ok := solver.ShadowAtJDE(jde) if !ok { t.Fatal("expected a footprint") } points := 0 for _, segment := range instant.Boundaries { points += len(segment) } if points < solarEclipsePartialFootprintMinBoundaryPoints { t.Fatalf("BoundaryPoints=1 produced %d vertices, want at least %d", points, solarEclipsePartialFootprintMinBoundaryPoints) } upper := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{BoundaryPoints: 100000}) clamped, okClamped := upper.ShadowAtJDE(jde) if !okClamped { t.Fatal("expected a footprint with a clamped point count") } clampedPoints := 0 for _, segment := range clamped.Boundaries { clampedPoints += len(segment) } if clampedPoints > solarEclipseShadowMaximumBoundaryPoints+2 { t.Fatalf("BoundaryPoints=100000 produced %d vertices, want at most %d", clampedPoints, solarEclipseShadowMaximumBoundaryPoints) } } func TestSolarEclipseShadowHandleSeesDeltaTChange(t *testing.T) { // 复用同一个句柄、中途覆盖进程级 ΔT:缓存里的贝塞尔轴带着由 ΔT 决定的 gst,而键 // 只有 jd,旧实现会返回"新 ΔT + 旧几何"(实测边界漂移 0 km,新建句柄却差 58 km)。 // Reusing one handle across a process-wide ΔT override used to replay the cached // Besselian axis, whose gst depends on ΔT, and report the new ΔT with the old // geometry (0 km drift where a fresh handle moved 58 km). original := GetDeltaTFn() defer SetDeltaTFn(original) SetDeltaTFn(DefaultDeltaTv2) // 取 2009-07-22 本影阶段内的一个真实时刻(与相邻测试同一取法)。 // Use a real instant inside the 2009-07-22 umbral phase, derived like the neighbour test. samples := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) if len(samples.CentralShadowFootprints) == 0 { t.Fatal("no central shadow samples") } jde := samples.CentralShadowFootprints[len(samples.CentralShadowFootprints)/2].JDE reused := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) before, okBefore := reused.ShadowAtJDE(jde) if !okBefore { t.Fatalf("no footprint at %v with the process ΔT", jde) } SetDeltaTFn(func(float64, bool) float64 { return 200 }) after, okAfter := reused.ShadowAtJDE(jde) if !okAfter { t.Fatalf("no footprint at %v after the ΔT override", jde) } fresh := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}) want, okWant := fresh.ShadowAtJDE(jde) if !okWant { t.Fatalf("no footprint at %v from a fresh handle", jde) } if math.Abs(after.DeltaTSeconds-200) > 1e-12 || math.Abs(want.DeltaTSeconds-200) > 1e-12 { t.Fatalf("ΔT not reported after the override: reused=%.6f fresh=%.6f", after.DeltaTSeconds, want.DeltaTSeconds) } maxDelta := func(first, second SolarEclipseShadowInstant) float64 { worst := 0.0 for index, segment := range first.Boundaries { if index >= len(second.Boundaries) { return math.Inf(1) } if len(segment) != len(second.Boundaries[index]) { return math.Inf(1) } for pointIndex, point := range segment { other := second.Boundaries[index][pointIndex] worst = math.Max(worst, math.Abs(point.Longitude-other.Longitude)) worst = math.Max(worst, math.Abs(point.Latitude-other.Latitude)) } } return worst } if moved := maxDelta(before, after); moved < 1e-4 { t.Fatalf("reused handle did not move with ΔT (max %.9f deg)", moved) } if drift := maxDelta(after, want); drift > 1e-9 { t.Fatalf("reused handle differs from a fresh one by %.9f deg after the ΔT override", drift) } } func TestSolarEclipseShadowPenumbraMatchesPackagedSamples(t *testing.T) { result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) if len(result.Footprints) == 0 { t.Fatal("no partial footprints") } solver := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{ Kind: SolarEclipseShadowPenumbra, BoundaryPoints: 96, }) sampled := result.Footprints[len(result.Footprints)/2] instant, ok := solver.ShadowAtJDE(sampled.JDE) if !ok { t.Fatal("penumbra instant reported no footprint") } if instant.Kind != SolarEclipseShadowPenumbra { t.Fatalf("kind=%v, want penumbra", instant.Kind) } if instant.Closed != sampled.Closed || len(instant.Boundaries) != len(sampled.Boundaries) { t.Fatalf("shape mismatch: closed=%v/%v segments=%d/%d", instant.Closed, sampled.Closed, len(instant.Boundaries), len(sampled.Boundaries)) } for index := range sampled.Boundaries { if len(instant.Boundaries[index]) != len(sampled.Boundaries[index]) { t.Fatalf("segment %d length %d, want %d", index, len(instant.Boundaries[index]), len(sampled.Boundaries[index])) } for pointIndex := range sampled.Boundaries[index] { got, want := instant.Boundaries[index][pointIndex], sampled.Boundaries[index][pointIndex] // 空间加密的递归顺序会让同一时刻相差约 1e-12 度,取 1e-9 度(亚毫米)即可。 if math.Abs(got.Longitude-want.Longitude) > 1e-9 || math.Abs(got.Latitude-want.Latitude) > 1e-9 { t.Fatalf("point %d/%d = %.12f,%.12f want %.12f,%.12f", index, pointIndex, got.Longitude, got.Latitude, want.Longitude, want.Latitude) } } } if signature := instant.Topology.Signature(); len(signature) < 8 || signature[:8] != "penumbra" { t.Fatalf("signature=%q, want a penumbra prefix", signature) } } func TestSolarEclipseShadowKindChangesFootprint(t *testing.T) { result := SolarEclipsePartialFootprints(JDCalc(2009, 7, 22), SolarEclipsePartialFootprintOptions{ StepDays: 2.0 / 1440.0, BoundaryPoints: 96, CentralShadowStepDays: 2.0 / 1440.0, DisableRiseSetCurves: true, }) if len(result.CentralShadowFootprints) == 0 || len(result.Footprints) == 0 { t.Fatal("missing samples") } jde := result.CentralShadowFootprints[len(result.CentralShadowFootprints)/2].JDE umbra, okUmbra := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).ShadowAtJDE(jde) penumbra, okPenumbra := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{ Kind: SolarEclipseShadowPenumbra, }).ShadowAtJDE(jde) if !okUmbra || !okPenumbra { t.Fatalf("expected both footprints (umbra=%v penumbra=%v)", okUmbra, okPenumbra) } if umbra.Topology.Vertices == 0 || penumbra.Topology.Vertices == 0 { t.Fatal("empty topology") } umbraMin, umbraMax := longitudeRange(umbra) penumbraMin, penumbraMax := longitudeRange(penumbra) if penumbraMax-penumbraMin <= umbraMax-umbraMin { t.Fatalf("penumbra span %.1f should exceed the umbra span %.1f", penumbraMax-penumbraMin, umbraMax-umbraMin) } } func longitudeRange(instant SolarEclipseShadowInstant) (float64, float64) { minimum, maximum := 361.0, -361.0 for _, segment := range instant.Boundaries { for _, point := range segment { if point.Longitude < minimum { minimum = point.Longitude } if point.Longitude > maximum { maximum = point.Longitude } } } return minimum, maximum }