package basic // 月缘等效半径必须与全部位置角的暴力判据一致,且站心全食与中心带多边形对同一地点结论一致。 import ( "math" "testing" "time" "b612.me/astro/internal/geodata" "b612.me/astro/moon/limb" ) func limbBruteMargins(contact lunarLimbContact) (outer, cover, inside float64) { outer, cover, inside = math.Inf(-1), math.Inf(1), math.Inf(1) step := contact.frame.stepDeg / 4 start := math.Ceil(contact.view.angle/step)*step - contact.view.angle for offset := start - 180; offset < 180; offset += step { radius, ok := contact.radius(offset, true) if !ok { continue } // 远交点为负的射线背向日面,不是真实交点;月心在日面外时远交点为正即近交点为正。 near, far, hit := contact.rayChord(offset) if hit && far > 0 { outer = math.Max(outer, radius-near) cover = math.Min(cover, radius-far) } if _, far, hit := contact.rayChord(offset + 180); hit && far > 0 { if radius, ok := contact.radius(offset+180, true); ok { inside = math.Min(inside, far-radius) } } } return } func TestLunarLimbContactMatchesBruteForce(t *testing.T) { seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC)) params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard) params.lunarLimb = limb.Default() greatest := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), 116.2310*rad, 39.7215*rad, 0, params) const tolerance = 1e-4 / 3600 * rad for _, dtSec := range []float64{-60, -20, -5, 0, 5, 20, 60} { context := newLocalSolarEclipseStateContext(greatest+dtSec/86400, params) for _, latDeg := range []float64{39.70, 39.7215, 39.73, 39.74} { observer := localSolarEclipseObserverXYZ(context.gst, 116.2310*rad, latDeg*rad, 0) moon := [3]float64{context.moonXYZ[0] - observer[0], context.moonXYZ[1] - observer[1], context.moonXYZ[2] - observer[2]} sun := [3]float64{context.sunXYZ[0] - observer[0], context.sunXYZ[1] - observer[1], context.sunXYZ[2] - observer[2]} view, ok := context.limbFrame.viewToward(moon, sun) if !ok { t.Fatal("view failed") } state := context.stateAt(116.2310*rad, latDeg*rad, 0) contact := lunarLimbContact{frame: context.limbFrame, view: view, distanceKM: math.Sqrt(lunarLimbDot(moon, moon)), separation: state.separationRad, targetRadius: state.sunRadiusRad} outer, cover, _ := limbBruteMargins(contact) low, high, _ := contact.bounds() relief := high - low d, sunRadius := state.separationRad, state.sunRadiusRad // 门控区只保证间隙符号,门控外须与暴力判据逐值一致。 gotOuter, _ := contact.outerRadius() wantOuterGap := -outer if gotGap := d - gotOuter - sunRadius; d > sunRadius && math.Abs(wantOuterGap) < relief { if math.Abs(gotGap-wantOuterGap) > tolerance { t.Errorf("dt %.0f lat %.4f: outer gap %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantOuterGap/rad*3600) } } else if (gotGap > 0) != (wantOuterGap > 0) { t.Errorf("dt %.0f lat %.4f: outer gap sign %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantOuterGap/rad*3600) } gotInner, _ := contact.innerRadius(true) gotGap := d - math.Abs(gotInner-sunRadius) wantGap := math.Min(-cover, d) if math.Abs(cover) < relief { if math.Abs(gotGap-wantGap) > tolerance { t.Errorf("dt %.0f lat %.4f: inner gap %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantGap/rad*3600) } } else if (gotGap > 0) != (wantGap > 0) { t.Errorf("dt %.0f lat %.4f: inner gap sign %.4f\" want %.4f\"", dtSec, latDeg, gotGap/rad*3600, wantGap/rad*3600) } } } } func limbPointInBand(segments [][]SolarEclipsePathPoint, lon, lat float64) bool { inside := false for _, ring := range segments { for i, j := 0, len(ring)-1; i < len(ring); j, i = i, i+1 { a, b := ring[i], ring[j] if (a.Latitude > lat) != (b.Latitude > lat) && lon < (b.Longitude-a.Longitude)*(lat-a.Latitude)/(b.Latitude-a.Latitude)+a.Longitude { inside = !inside } } } return inside } func TestLimbCentralBandMatchesLocalTotality(t *testing.T) { seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC)) profile := limb.Default() path := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: profile}, nil) options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: profile} const lon, marginDeg = 116.2310, 0.5 / 111.2 for lat := 39.60; lat <= 39.85; lat += 0.005 { inBand := limbPointInBand(path.CentralBandSegments, lon, lat) if inBand != limbPointInBand(path.CentralBandSegments, lon, lat-marginDeg) || inBand != limbPointInBand(path.CentralBandSegments, lon, lat+marginDeg) { continue } local := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil) if local.HasCentral != inBand { t.Errorf("lat %.4f: local central %v, band %v", lat, local.HasCentral, inBand) } } local := LocalSolarEclipseWithOptionsWithInstance(seed, lon, 39.7215, 0, options, nil) if local.HasCentral || limbPointInBand(path.CentralBandSegments, lon, 39.7215) { t.Errorf("39.7215: local central %v must agree with band (outside)", local.HasCentral) } } // limbTestContact 2035-09-02 北京附近某一时刻的月缘接触上下文,日面半径与角距可替换以构造环食。 func limbTestContact(t *testing.T) lunarLimbContact { t.Helper() seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC)) params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard) params.lunarLimb = limb.Default() greatest := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), 116.2310*rad, 39.7215*rad, 0, params) context := newLocalSolarEclipseStateContext(greatest, params) observer := localSolarEclipseObserverXYZ(context.gst, 116.2310*rad, 39.7215*rad, 0) moon := [3]float64{context.moonXYZ[0] - observer[0], context.moonXYZ[1] - observer[1], context.moonXYZ[2] - observer[2]} sun := [3]float64{context.sunXYZ[0] - observer[0], context.sunXYZ[1] - observer[1], context.sunXYZ[2] - observer[2]} view, ok := context.limbFrame.viewToward(moon, sun) if !ok { t.Fatal("view failed") } state := context.stateAt(116.2310*rad, 39.7215*rad, 0) return lunarLimbContact{frame: context.limbFrame, view: view, distanceKM: math.Sqrt(lunarLimbDot(moon, moon)), separation: state.separationRad, targetRadius: state.sunRadiusRad} } func TestLunarLimbAnnularAndUnrangedMatchBruteForce(t *testing.T) { base := limbTestContact(t) const tolerance = 1e-4 / 3600 * rad meanRadius := math.Asin(1737.4 / base.distanceKM) unranged := *base.frame unranged.ranged = false for _, ranged := range []bool{true, false} { for _, separationArcsec := range []float64{0, 1, 5, 12, 20} { contact := base if !ranged { contact.frame = &unranged } contact.targetRadius = meanRadius + 6/3600.0*rad contact.separation = separationArcsec / 3600 * rad _, _, inside := limbBruteMargins(contact) got, ok := contact.innerRadius(false) if !ok { t.Fatalf("ranged %v d %.0f\": annular radius failed", ranged, separationArcsec) } // 环食等效内半径必须小于日面半径,否则食分会大于 1;只比间隙看不出 R 关于 s 的镜像错误。 if got > contact.targetRadius { t.Errorf("ranged %v d %.0f\": annular radius %.4f\" exceeds the solar radius %.4f\"", ranged, separationArcsec, got/rad*3600, contact.targetRadius/rad*3600) } gotGap := contact.separation - math.Abs(got-contact.targetRadius) wantGap := math.Min(-inside, contact.separation) if math.Abs(gotGap-wantGap) > tolerance && !(gotGap > 0 && wantGap > 0) { t.Errorf("ranged %v d %.0f\": annular gap %.4f\" want %.4f\"", ranged, separationArcsec, gotGap/rad*3600, wantGap/rad*3600) } if !ranged { contact.targetRadius = meanRadius - 6/3600.0*rad contact.separation = (separationArcsec + 1900) / 3600 * rad outer, _, _ := limbBruteMargins(contact) got, ok := contact.outerRadius() if !ok { t.Fatal("unranged outer failed") } if gap := contact.separation - got - contact.targetRadius; math.Abs(gap+outer) > tolerance { t.Errorf("unranged d %.0f\": outer gap %.4f\" want %.4f\"", separationArcsec, gap/rad*3600, -outer/rad*3600) } } } } } func TestLimbCentralLimitMatchesLocalLimit(t *testing.T) { seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC)) profile := limb.Default() path := SolarEclipseCentralPathNASABulletinSplitKWithInstance(seed, SolarEclipsePathOptions{LunarLimb: profile}, nil) options := SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: profile} const lon = 116.2310 bisect := func(inside func(float64) bool) float64 { low, high := 39.5, 39.9 for i := 0; i < 40; i++ { mid := (low + high) / 2 if inside(mid) { high = mid } else { low = mid } } return (low + high) / 2 } local := bisect(func(lat float64) bool { return LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, options, nil).HasCentral }) band := bisect(func(lat float64) bool { return limbPointInBand(path.CentralBandSegments, lon, lat) }) if d := math.Abs(local-band) * 111.2; d > 0.5 { t.Errorf("southern limit: local %.5f, band %.5f, %.2f km apart", local, band, d) } } func TestLocalSolarEclipseLimbCanFlipTotalToAnnular(t *testing.T) { seed := Date2JD(time.Date(2023, 4, 20, 0, 0, 0, 0, time.UTC)) const lon, lat = 170.1481, 4.6940 circular := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK}, nil) if circular.Type != SolarEclipseTotal { t.Fatalf("circular type %v, want total near the hybrid transition", circular.Type) } // 常数月缘比圆月面内半径小 2 km:圆月面判全食,月缘下日面在各方向都露出,应为环食。 innerKM := geodata.EarthEquatorialRadiusKM * solarEclipseUmbralK * localSolarMoonRadiusScale smaller := &recordingLunarLimb{radiusKM: innerKM - 2} limbed := LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, 0, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK, LunarLimb: smaller}, nil) if limbed.Type != SolarEclipseAnnular || !limbed.HasCentral || !limbed.HasAnnular || limbed.HasTotal { t.Fatalf("smaller constant limb: type %v central %v annular %v total %v, want annular", limbed.Type, limbed.HasCentral, limbed.HasAnnular, limbed.HasTotal) } if limbed.CentralEnd <= limbed.CentralStart { t.Errorf("annular phase has no duration: %.8f–%.8f", limbed.CentralStart, limbed.CentralEnd) } } // sectorLunarLimb 只在以 center 为中心、半宽 halfWidth 的位置角扇区内给出剖面值,并报告整张表的半径范围。 type sectorLunarLimb struct { center, halfWidth float64 profile *limb.Profile } func (sector sectorLunarLimb) LimbRadiusKM(lon, lat, angle float64) (float64, bool) { if math.Abs(math.Mod(angle-sector.center+540, 360)-180) > sector.halfWidth { return 0, false } return sector.profile.LimbRadiusKM(lon, lat, angle) } func (sector sectorLunarLimb) RadiusRangeKM() (float64, float64) { return sector.profile.RadiusRangeKM() } func (sector sectorLunarLimb) AngleStepDeg() float64 { return sector.profile.AngleStepDeg() } func TestLocalSolarEclipseStateSeparatesOuterAndInnerLimb(t *testing.T) { params := solarEclipseModelParams(SolarEclipseModelNASABulletinSplitK, SolarEclipseSunRadiusStandard) seed := Date2JD(time.Date(2035, 9, 2, 0, 0, 0, 0, time.UTC)) const lon, lat = 116.2310 * rad, 39.70 * rad // 食甚附近月盘深入日面:外切间隙远离零点被门控,只查日心方向;内切接近零点必须全方向扫描。 tt := localSolarEclipseGreatest(solarEclipseAnchorNewMoon(seed), lon, lat, 0, params) circular := newLocalSolarEclipseStateContext(tt, params).stateAt(lon, lat, 0) params.lunarLimb = limb.Default() full := newLocalSolarEclipseStateContext(tt, params) observer := localSolarEclipseObserverXYZ(full.gst, lon, lat, 0) view, ok := full.limbFrame.viewToward( [3]float64{full.moonXYZ[0] - observer[0], full.moonXYZ[1] - observer[1], full.moonXYZ[2] - observer[2]}, [3]float64{full.sunXYZ[0] - observer[0], full.sunXYZ[1] - observer[1], full.sunXYZ[2] - observer[2]}) if !ok { t.Fatal("view failed") } params.lunarLimb = sectorLunarLimb{center: view.angle, halfWidth: 2, profile: limb.Default()} state := newLocalSolarEclipseStateContext(tt, params).stateAt(lon, lat, 0) if !state.outerLimbApplied || state.limbApplied { t.Fatalf("outerLimbApplied %v limbApplied %v, want outer only", state.outerLimbApplied, state.limbApplied) } if state.moonInnerRadiusRad != circular.moonInnerRadiusRad || state.moonOuterRadiusRad == circular.moonOuterRadiusRad { t.Errorf("inner radius must stay circular and outer must move: inner %.9g vs %.9g, outer %.9g vs %.9g", state.moonInnerRadiusRad, circular.moonInnerRadiusRad, state.moonOuterRadiusRad, circular.moonOuterRadiusRad) } }