package eclipse import ( "math" "time" "b612.me/astro/basic" ) const ( lunarEclipseSynodicMonthDays = 29.530588853 lunarEclipseSearchLimit = 24 lunarEclipseSearchEpsilonDay = 1e-8 // 默认口径仍以 Danjon 为主,但对五千年目录边界那类“Danjon 判无食、Chauvenet 判极浅半影食”的个例, // 允许在公开默认包装层回退到 Chauvenet,避免把目录中确有记录的边缘半影食整个跳过。 lunarEclipseDefaultFallbackMaxPenumbralMagnitude = 0.03 // 当天判断的第一层只做粗筛: // 如果本地中午的日月黄经差明显不在满月附近,则这一天不可能发生月食。 lunarEclipseDayPhaseMin = 120.0 lunarEclipseDayPhaseMax = 240.0 // 月食只会发生在月球接近黄道节点时。 // 这里保守放宽到 2 度,作为“是否值得进入精算”的预筛条件。 lunarEclipseLatitudeLimitDeg = 2.0 lunarEclipseLongitudeLimitDeg = 15.0 ) type lunarEclipseCalculator func(float64) basic.LunarEclipseResult // LunarEclipseType 月食类型, lunar eclipse type. type LunarEclipseType string const ( // LunarEclipseNone 无月食, no lunar eclipse. LunarEclipseNone LunarEclipseType = "none" // LunarEclipsePenumbral 半影月食, penumbral lunar eclipse. LunarEclipsePenumbral LunarEclipseType = "penumbral" // LunarEclipsePartial 月偏食, partial lunar eclipse. LunarEclipsePartial LunarEclipseType = "partial" // LunarEclipseTotal 月全食, total lunar eclipse. LunarEclipseTotal LunarEclipseType = "total" ) // LunarEclipseContactPoint 表示月食接触点在月面上的方位。 // LunarEclipseContactPoint describes a lunar eclipse contact point on the Moon limb. type LunarEclipseContactPoint struct { // Label 是接触标签,如 P1/U1/U2/U3/U4/P4。 // Label is the contact label, such as P1/U1/U2/U3/U4/P4. Label string // Time 是该接触时刻,保持用户输入时区。 // Time is the contact time, preserving the input timezone. Time time.Time // ContactPositionAngle 是月面接触点位置角,从天球北点起向东量,单位度。 // ContactPositionAngle is the Moon-limb contact position angle from celestial north toward east, in degrees. ContactPositionAngle float64 // ContactClockwiseAngle 是图面上从北点顺时针量到接触点的角度,单位度。 // ContactClockwiseAngle is the chart clockwise angle from north to the contact point, in degrees. ContactClockwiseAngle float64 // MoonCenterPositionAngle 是月心相对地影中心的位置角,从北点起向东量,单位度。 // MoonCenterPositionAngle is the Moon-center position angle from the shadow center, in degrees. MoonCenterPositionAngle float64 // ShadowCenterPositionAngle 是地影中心相对月心的位置角,从北点起向东量,单位度。 // ShadowCenterPositionAngle is the shadow-center position angle from the Moon center, in degrees. ShadowCenterPositionAngle float64 } // LunarEclipseInfo 月食信息, lunar eclipse information. // // 所有时刻字段都保持用户输入的时区。 // 不存在的阶段使用零值 time.Time。 type LunarEclipseInfo struct { // Type 月食类型, eclipse type. Type LunarEclipseType // HasSaros 存在沙罗序列信息, has Saros series metadata. HasSaros bool // Saros 是沙罗序列信息,包括系列号、系列内序号和总成员数。 // Saros is Saros series metadata with the series number, member index, and total member count. Saros SarosInfo // PenumbralMagnitude 半影食分, penumbral magnitude. PenumbralMagnitude float64 // UmbralMagnitude 本影食分;纯半影月食时可为负值, umbral magnitude; can be negative for purely penumbral eclipses. UmbralMagnitude float64 // PenumbralStart 半影始, penumbral eclipse begins. PenumbralStart time.Time // PartialStart 初亏, partial eclipse begins. PartialStart time.Time // TotalStart 食既, total eclipse begins. TotalStart time.Time // Maximum 食甚, greatest eclipse. Maximum time.Time // TotalEnd 生光, total eclipse ends. TotalEnd time.Time // PartialEnd 复圆, partial eclipse ends. PartialEnd time.Time // PenumbralEnd 半影终, penumbral eclipse ends. PenumbralEnd time.Time // ContactPoints 是各接触时刻在月面上的接触点方位。 // ContactPoints are Moon-limb contact position angles at eclipse contacts. ContactPoints []LunarEclipseContactPoint // HasPenumbral 有半影阶段, has penumbral phase. HasPenumbral bool // HasPartial 有偏食阶段, has partial phase. HasPartial bool // HasTotal 有全食阶段, has total phase. HasTotal bool } // LunarEclipseOnDate 当地自然日月食查询 / local-date lunar eclipse query. // Determine whether a lunar eclipse occurs on the local date. // The default path uses Danjon and falls back to Chauvenet for ultra-shallow penumbral edge cases. // // 只要该自然日内有任意一个接触时刻,或整场月食与该自然日有时间重叠,就返回 true。 func LunarEclipseOnDate(date time.Time) (LunarEclipseInfo, bool) { return lunarEclipseOnDateWithFallback(date, basic.LunarEclipseDanjon, true) } // LunarEclipseOnDateDanjon 当地自然日月食查询(Danjon) / local-date lunar eclipse query with Danjon model. // Determine whether a lunar eclipse occurs on the local date with the Danjon model. func LunarEclipseOnDateDanjon(date time.Time) (LunarEclipseInfo, bool) { return lunarEclipseOnDateWithFallback(date, basic.LunarEclipseDanjon, false) } // LunarEclipseOnDateChauvenet 当地自然日月食查询(Chauvenet) / local-date lunar eclipse query with Chauvenet model. // Determine whether a lunar eclipse occurs on the local date with the Chauvenet model. func LunarEclipseOnDateChauvenet(date time.Time) (LunarEclipseInfo, bool) { return lunarEclipseOnDateWithFallback(date, basic.LunarEclipseChauvenet, false) } func lunarEclipseOnDateWithFallback(date time.Time, calculator lunarEclipseCalculator, allowDefaultFallback bool) (LunarEclipseInfo, bool) { location := date.Location() dayStart, dayMid, dayEnd := lunarEclipseLocalDayBounds(date) phaseDiff := moonSunLoDiff(dayMid) if phaseDiff < lunarEclipseDayPhaseMin || phaseDiff > lunarEclipseDayPhaseMax { return LunarEclipseInfo{}, false } candidateTT := basic.CalcMoonSHByJDE(timeToTTJDE(dayMid), 1) if !isPotentialLunarEclipse(candidateTT) { return LunarEclipseInfo{}, false } result := calculator(candidateTT) if result.Type == basic.LunarEclipseNone && allowDefaultFallback { if fallback, ok := lunarEclipseDefaultFallback(candidateTT); ok { result = fallback } } if result.Type == basic.LunarEclipseNone { return LunarEclipseInfo{}, false } info := lunarEclipseInfoFromBasic(result, location) if !lunarEclipseOverlapsDate(info, dayStart, dayEnd) { return LunarEclipseInfo{}, false } return info, true } // LastLunarEclipse 上次月食 / previous lunar eclipse. // Previous lunar eclipse. // The default path uses Danjon and falls back to Chauvenet for ultra-shallow penumbral edge cases. func LastLunarEclipse(date time.Time) LunarEclipseInfo { info, _ := searchLunarEclipse(date, -1, true, basic.LunarEclipseDanjon, true) return info } // LastLunarEclipseDanjon 上次月食(Danjon) / previous lunar eclipse with Danjon model. // Previous lunar eclipse with the Danjon model. func LastLunarEclipseDanjon(date time.Time) LunarEclipseInfo { info, _ := searchLunarEclipse(date, -1, true, basic.LunarEclipseDanjon, false) return info } // LastLunarEclipseChauvenet 上次月食(Chauvenet) / previous lunar eclipse with Chauvenet model. // Previous lunar eclipse with the Chauvenet model. func LastLunarEclipseChauvenet(date time.Time) LunarEclipseInfo { info, _ := searchLunarEclipse(date, -1, true, basic.LunarEclipseChauvenet, false) return info } // NextLunarEclipse 下次月食 / next lunar eclipse. // Next lunar eclipse. // The default path uses Danjon and falls back to Chauvenet for ultra-shallow penumbral edge cases. func NextLunarEclipse(date time.Time) LunarEclipseInfo { info, _ := searchLunarEclipse(date, 1, false, basic.LunarEclipseDanjon, true) return info } // NextLunarEclipseDanjon 下次月食(Danjon) / next lunar eclipse with Danjon model. // Next lunar eclipse with the Danjon model. func NextLunarEclipseDanjon(date time.Time) LunarEclipseInfo { info, _ := searchLunarEclipse(date, 1, false, basic.LunarEclipseDanjon, false) return info } // NextLunarEclipseChauvenet 下次月食(Chauvenet) / next lunar eclipse with Chauvenet model. // Next lunar eclipse with the Chauvenet model. func NextLunarEclipseChauvenet(date time.Time) LunarEclipseInfo { info, _ := searchLunarEclipse(date, 1, false, basic.LunarEclipseChauvenet, false) return info } // ClosestLunarEclipse 最近一次月食 / closest lunar eclipse. // Closest lunar eclipse. // The default path uses Danjon and falls back to Chauvenet for ultra-shallow penumbral edge cases. func ClosestLunarEclipse(date time.Time) LunarEclipseInfo { last, hasLast := searchLunarEclipse(date, -1, true, basic.LunarEclipseDanjon, true) next, hasNext := searchLunarEclipse(date, 1, false, basic.LunarEclipseDanjon, true) return closestLunarEclipse(date, last, hasLast, next, hasNext) } // ClosestLunarEclipseDanjon 最近一次月食(Danjon) / closest lunar eclipse with Danjon model. // Closest lunar eclipse with the Danjon model. func ClosestLunarEclipseDanjon(date time.Time) LunarEclipseInfo { last, hasLast := searchLunarEclipse(date, -1, true, basic.LunarEclipseDanjon, false) next, hasNext := searchLunarEclipse(date, 1, false, basic.LunarEclipseDanjon, false) return closestLunarEclipse(date, last, hasLast, next, hasNext) } // ClosestLunarEclipseChauvenet 最近一次月食(Chauvenet) / closest lunar eclipse with Chauvenet model. // Closest lunar eclipse with the Chauvenet model. func ClosestLunarEclipseChauvenet(date time.Time) LunarEclipseInfo { last, hasLast := searchLunarEclipse(date, -1, true, basic.LunarEclipseChauvenet, false) next, hasNext := searchLunarEclipse(date, 1, false, basic.LunarEclipseChauvenet, false) return closestLunarEclipse(date, last, hasLast, next, hasNext) } func closestLunarEclipse( date time.Time, last LunarEclipseInfo, hasLast bool, next LunarEclipseInfo, hasNext bool, ) LunarEclipseInfo { switch { case hasLast && !hasNext: return last case !hasLast && hasNext: return next case !hasLast && !hasNext: return LunarEclipseInfo{} } lastDistance := math.Abs(date.Sub(last.Maximum).Seconds()) nextDistance := math.Abs(next.Maximum.Sub(date).Seconds()) if lastDistance <= nextDistance { return last } return next } func searchLunarEclipse( date time.Time, direction int, includeCurrent bool, calculator lunarEclipseCalculator, allowDefaultFallback bool, ) (LunarEclipseInfo, bool) { targetTT := timeToTTJDE(date) candidateTT := basic.CalcMoonSHByJDE(targetTT, 1) for i := 0; i < lunarEclipseSearchLimit; i++ { if isPotentialLunarEclipse(candidateTT) { result := calculator(candidateTT) if result.Type == basic.LunarEclipseNone && allowDefaultFallback { if fallback, ok := lunarEclipseDefaultFallback(candidateTT); ok { result = fallback } } if result.Type != basic.LunarEclipseNone && lunarEclipseMatchesDirection(result.Maximum, targetTT, direction, includeCurrent) { return lunarEclipseInfoFromBasic(result, date.Location()), true } } candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays) } return LunarEclipseInfo{}, false } func lunarEclipseDefaultFallback(candidateTT float64) (basic.LunarEclipseResult, bool) { result := basic.LunarEclipseChauvenet(candidateTT) if result.Type != basic.LunarEclipsePenumbral { return basic.LunarEclipseResult{}, false } if result.HasPartial || result.HasTotal { return basic.LunarEclipseResult{}, false } if result.PenumbralMagnitude <= 0 || result.PenumbralMagnitude > lunarEclipseDefaultFallbackMaxPenumbralMagnitude { return basic.LunarEclipseResult{}, false } return result, true } func lunarEclipseMatchesDirection(maximumTT, targetTT float64, direction int, includeCurrent bool) bool { delta := maximumTT - targetTT if math.Abs(delta) <= lunarEclipseSearchEpsilonDay { return direction < 0 && includeCurrent } if direction > 0 { return delta > 0 } return delta < 0 } func isPotentialLunarEclipse(fullMoonTT float64) bool { moonLatitude := math.Abs(basic.HMoonTrueBo(fullMoonTT)) if moonLatitude > lunarEclipseLatitudeLimitDeg { return false } phaseDiff := math.Abs(normalizeDegree180(basic.HMoonApparentLo(fullMoonTT) - basic.HSunApparentLo(fullMoonTT) - 180)) return phaseDiff <= lunarEclipseLongitudeLimitDeg } func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.Location) LunarEclipseInfo { saros, hasSaros := lunarSarosInfo(result.Maximum) return LunarEclipseInfo{ HasSaros: hasSaros, Saros: saros, Type: mapBasicLunarEclipseType(result.Type), PenumbralMagnitude: result.PenumbralMagnitude, UmbralMagnitude: result.Magnitude, PenumbralStart: ttJDEToTime(result.PenumbralStart, location), PartialStart: ttJDEToTime(result.PartialStart, location), TotalStart: ttJDEToTime(result.TotalStart, location), Maximum: ttJDEToTime(result.Maximum, location), TotalEnd: ttJDEToTime(result.TotalEnd, location), PartialEnd: ttJDEToTime(result.PartialEnd, location), PenumbralEnd: ttJDEToTime(result.PenumbralEnd, location), ContactPoints: lunarEclipseContactPointsFromBasic(result, location), HasPenumbral: result.HasPenumbral, HasPartial: result.HasPartial, HasTotal: result.HasTotal, } } func lunarEclipseContactPointsFromBasic( result basic.LunarEclipseResult, location *time.Location, ) []LunarEclipseContactPoint { if !result.HasPenumbral { return nil } contacts := []LunarEclipseContactPoint{ lunarEclipseContactPoint("P1", result.PenumbralStart, location, false), } if result.HasPartial { contacts = append(contacts, lunarEclipseContactPoint("U1", result.PartialStart, location, false)) } if result.HasTotal { contacts = append(contacts, lunarEclipseContactPoint("U2", result.TotalStart, location, true)) } if result.HasTotal { contacts = append(contacts, lunarEclipseContactPoint("U3", result.TotalEnd, location, true)) } if result.HasPartial { contacts = append(contacts, lunarEclipseContactPoint("U4", result.PartialEnd, location, false)) } contacts = append(contacts, lunarEclipseContactPoint("P4", result.PenumbralEnd, location, false)) return contacts } func lunarEclipseContactPoint( label string, ttJDE float64, location *time.Location, internalContact bool, ) LunarEclipseContactPoint { moonCenterPA := lunarEclipseMoonCenterPositionAngle(ttJDE) shadowCenterPA := normalizeDegree360(moonCenterPA + 180) contactPA := shadowCenterPA if internalContact { contactPA = moonCenterPA } return LunarEclipseContactPoint{ Label: label, Time: ttJDEToTime(ttJDE, location), ContactPositionAngle: contactPA, ContactClockwiseAngle: normalizeDegree360(360 - contactPA), MoonCenterPositionAngle: moonCenterPA, ShadowCenterPositionAngle: shadowCenterPA, } } func lunarEclipseMoonCenterPositionAngle(ttJDE float64) float64 { shadowRA, shadowDec := lunarEclipseShadowCenterRaDec(ttJDE) moonRA, moonDec := basic.HMoonTrueRaDec(ttJDE) return positionAngle(shadowRA, shadowDec, moonRA, moonDec) } func lunarEclipseShadowCenterRaDec(ttJDE float64) (float64, float64) { sunRA, sunDec := basic.HSunApparentRaDec(ttJDE) return normalizeDegree360(sunRA + 180), -sunDec } func positionAngle(fromRA, fromDec, toRA, toDec float64) float64 { dRA := (toRA - fromRA) * math.Pi / 180 fromDecRad := fromDec * math.Pi / 180 toDecRad := toDec * math.Pi / 180 angle := math.Atan2( math.Sin(dRA), math.Cos(fromDecRad)*math.Tan(toDecRad)-math.Sin(fromDecRad)*math.Cos(dRA), ) * 180 / math.Pi return normalizeDegree360(angle) } func mapBasicLunarEclipseType(eclipseType basic.LunarEclipseType) LunarEclipseType { switch eclipseType { case basic.LunarEclipsePenumbral: return LunarEclipsePenumbral case basic.LunarEclipsePartial: return LunarEclipsePartial case basic.LunarEclipseTotal: return LunarEclipseTotal default: return LunarEclipseNone } } func lunarEclipseOverlapsDate(info LunarEclipseInfo, dayStart, dayEnd time.Time) bool { eventStart, eventEnd, ok := lunarEclipseRange(info) if !ok { return false } return !eventEnd.Before(dayStart) && eventStart.Before(dayEnd) } func lunarEclipseRange(info LunarEclipseInfo) (time.Time, time.Time, bool) { if !info.HasPenumbral { return time.Time{}, time.Time{}, false } return info.PenumbralStart, info.PenumbralEnd, true } func ttJDEToTime(ttJDE float64, location *time.Location) time.Time { if ttJDE == 0 { return time.Time{} } utcJDE := basic.TD2UT(ttJDE, false) return basic.JDE2DateByZone(utcJDE, location, false) } func timeToTTJDE(date time.Time) float64 { utcJDE := basic.Date2JDE(date.UTC()) return basic.TD2UT(utcJDE, true) } func normalizeDegree180(angle float64) float64 { angle = math.Mod(angle, 360) if angle > 180 { angle -= 360 } if angle <= -180 { angle += 360 } return angle } func normalizeDegree360(angle float64) float64 { angle = math.Mod(angle, 360) if angle < 0 { angle += 360 } return angle } func lunarEclipseLocalDayBounds(date time.Time) (time.Time, time.Time, time.Time) { location := date.Location() dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) dayMid := time.Date(date.Year(), date.Month(), date.Day(), 12, 0, 0, 0, location) dayEnd := time.Date(date.Year(), date.Month(), date.Day()+1, 0, 0, 0, 0, location) return dayStart, dayMid, dayEnd } func nextLunarEclipseLocalDayStart(dayStart time.Time) time.Time { location := dayStart.Location() return time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day()+1, 0, 0, 0, 0, location) }