feat: 扩展天文计算能力

- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
This commit is contained in:
2026-05-01 22:38:44 +08:00
parent 98ff574495
commit 3ffdbe0034
365 changed files with 63589 additions and 17508 deletions
+487
View File
@@ -0,0 +1,487 @@
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 = basic.CalcMoonSHByJDE(candidateTT+float64(direction)*lunarEclipseSynodicMonthDays, 1)
}
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)
}
+433
View File
@@ -0,0 +1,433 @@
package eclipse
import (
"math"
"time"
"b612.me/astro/basic"
)
const localLunarEclipseSearchLimit = 6000
type localLunarEclipseQueryMode int
const (
localLunarEclipseQueryVisible localLunarEclipseQueryMode = iota
localLunarEclipseQueryGeometric
)
// LocalLunarEclipseInfo 站点月食信息, local lunar eclipse information.
//
// 所有时刻字段都保持用户输入的时区。
// 不存在的阶段使用零值 time.Time。
type LocalLunarEclipseInfo 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
// Longitude 观测点经度,东正西负, observer longitude, east positive.
Longitude float64
// Latitude 观测点纬度,北正南负, observer latitude, north positive.
Latitude float64
// Height 观测点海拔高度,单位米, observer height in meters.
Height float64
// 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
// MoonAltitude 食甚时月亮高度角,单位度, Moon altitude at maximum in degrees.
MoonAltitude float64
// MoonAzimuth 食甚时月亮方位角,单位度, Moon azimuth at maximum in degrees.
MoonAzimuth float64
// VisibleAtMaximum 食甚时月亮中心在本地几何地平线上方, Moon center above the local geometric horizon at maximum.
VisibleAtMaximum bool
// HasPenumbral 有半影阶段, has penumbral phase.
HasPenumbral bool
// HasPartial 有偏食阶段, has partial phase.
HasPartial bool
// HasTotal 有全食阶段, has total phase.
HasTotal bool
}
// LocalLunarEclipseOnDate 当地可见月食查询 / local visible lunar eclipse query.
// Determine whether a visible local lunar eclipse occurs on the local date, using Danjon by default.
func LocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return LocalLunarEclipseOnDateDanjon(date, lon, lat, height)
}
// LocalLunarEclipseOnDateDanjon 当地可见月食查询(Danjon / local visible lunar eclipse query with Danjon model.
// Determine whether a visible local lunar eclipse occurs on the local date with the Danjon model.
func LocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
}
// LocalLunarEclipseOnDateChauvenet 当地可见月食查询(Chauvenet / local visible lunar eclipse query with Chauvenet model.
// Determine whether a visible local lunar eclipse occurs on the local date with the Chauvenet model.
func LocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
}
// GeometricLocalLunarEclipseOnDate 当地几何月食查询 / local geometric lunar eclipse query.
// Determine whether a geometric local lunar eclipse occurs on the local date, using Danjon by default.
func GeometricLocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return GeometricLocalLunarEclipseOnDateDanjon(date, lon, lat, height)
}
// GeometricLocalLunarEclipseOnDateDanjon 当地几何月食查询(Danjon / local geometric lunar eclipse query with Danjon model.
// Determine whether a geometric local lunar eclipse occurs on the local date with the Danjon model.
func GeometricLocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
}
// GeometricLocalLunarEclipseOnDateChauvenet 当地几何月食查询(Chauvenet / local geometric lunar eclipse query with Chauvenet model.
// Determine whether a geometric local lunar eclipse occurs on the local date with the Chauvenet model.
func GeometricLocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
}
func localLunarEclipseOnDate(
date time.Time,
lon, lat, height float64,
calculator lunarEclipseCalculator,
mode localLunarEclipseQueryMode,
) (LocalLunarEclipseInfo, bool) {
location := date.Location()
dayStart, dayMid, dayEnd := lunarEclipseLocalDayBounds(date)
phaseDiff := moonSunLoDiff(dayMid)
if phaseDiff < lunarEclipseDayPhaseMin || phaseDiff > lunarEclipseDayPhaseMax {
return LocalLunarEclipseInfo{}, false
}
candidateTT := basic.CalcMoonSHByJDE(timeToTTJDE(dayMid), 1)
if !isPotentialLunarEclipse(candidateTT) {
return LocalLunarEclipseInfo{}, false
}
result := calculator(candidateTT)
if result.Type == basic.LunarEclipseNone {
return LocalLunarEclipseInfo{}, false
}
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, location)
if !localLunarEclipseOverlapsDate(info, dayStart, dayEnd) {
return LocalLunarEclipseInfo{}, false
}
if mode == localLunarEclipseQueryVisible && !localLunarEclipseVisibleOnDate(info, dayStart, dayEnd) {
return LocalLunarEclipseInfo{}, false
}
return info, true
}
// LastLocalLunarEclipse 上次可见月食 / previous visible local lunar eclipse.
// Previous visible local lunar eclipse, using Danjon by default.
func LastLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return LastLocalLunarEclipseDanjon(date, lon, lat, height)
}
// LastLocalLunarEclipseDanjon 上次可见月食(Danjon / previous visible local lunar eclipse with Danjon model.
// Previous visible local lunar eclipse with the Danjon model.
func LastLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
return info
}
// LastLocalLunarEclipseChauvenet 上次可见月食(Chauvenet / previous visible local lunar eclipse with Chauvenet model.
// Previous visible local lunar eclipse with the Chauvenet model.
func LastLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
return info
}
// LastGeometricLocalLunarEclipse 上次几何月食 / previous geometric local lunar eclipse.
// Previous geometric local lunar eclipse, using Danjon by default.
func LastGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return LastGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// LastGeometricLocalLunarEclipseDanjon 上次几何月食(Danjon / previous geometric local lunar eclipse with Danjon model.
// Previous geometric local lunar eclipse with the Danjon model.
func LastGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
return info
}
// LastGeometricLocalLunarEclipseChauvenet 上次几何月食(Chauvenet / previous geometric local lunar eclipse with Chauvenet model.
// Previous geometric local lunar eclipse with the Chauvenet model.
func LastGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
return info
}
// NextLocalLunarEclipse 下次可见月食 / next visible local lunar eclipse.
// Next visible local lunar eclipse, using Danjon by default.
func NextLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return NextLocalLunarEclipseDanjon(date, lon, lat, height)
}
// NextLocalLunarEclipseDanjon 下次可见月食(Danjon / next visible local lunar eclipse with Danjon model.
// Next visible local lunar eclipse with the Danjon model.
func NextLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
return info
}
// NextLocalLunarEclipseChauvenet 下次可见月食(Chauvenet / next visible local lunar eclipse with Chauvenet model.
// Next visible local lunar eclipse with the Chauvenet model.
func NextLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
return info
}
// NextGeometricLocalLunarEclipse 下次几何月食 / next geometric local lunar eclipse.
// Next geometric local lunar eclipse, using Danjon by default.
func NextGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return NextGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// NextGeometricLocalLunarEclipseDanjon 下次几何月食(Danjon / next geometric local lunar eclipse with Danjon model.
// Next geometric local lunar eclipse with the Danjon model.
func NextGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
return info
}
// NextGeometricLocalLunarEclipseChauvenet 下次几何月食(Chauvenet / next geometric local lunar eclipse with Chauvenet model.
// Next geometric local lunar eclipse with the Chauvenet model.
func NextGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
return info
}
// ClosestLocalLunarEclipse 最近一次可见月食 / closest visible local lunar eclipse.
// Closest visible local lunar eclipse, using Danjon by default.
func ClosestLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return ClosestLocalLunarEclipseDanjon(date, lon, lat, height)
}
// ClosestLocalLunarEclipseDanjon 最近一次可见月食(Danjon / closest visible local lunar eclipse with Danjon model.
// Closest visible local lunar eclipse with the Danjon model.
func ClosestLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
return closestLocalLunarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestLocalLunarEclipseChauvenet 最近一次可见月食(Chauvenet / closest visible local lunar eclipse with Chauvenet model.
// Closest visible local lunar eclipse with the Chauvenet model.
func ClosestLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
return closestLocalLunarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestGeometricLocalLunarEclipse 最近一次几何月食 / closest geometric local lunar eclipse.
// Closest geometric local lunar eclipse, using Danjon by default.
func ClosestGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return ClosestGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// ClosestGeometricLocalLunarEclipseDanjon 最近一次几何月食(Danjon / closest geometric local lunar eclipse with Danjon model.
// Closest geometric local lunar eclipse with the Danjon model.
func ClosestGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
return closestLocalLunarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestGeometricLocalLunarEclipseChauvenet 最近一次几何月食(Chauvenet / closest geometric local lunar eclipse with Chauvenet model.
// Closest geometric local lunar eclipse with the Chauvenet model.
func ClosestGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
return closestLocalLunarEclipse(date, last, hasLast, next, hasNext)
}
func closestLocalLunarEclipse(
date time.Time,
last LocalLunarEclipseInfo,
hasLast bool,
next LocalLunarEclipseInfo,
hasNext bool,
) LocalLunarEclipseInfo {
switch {
case hasLast && !hasNext:
return last
case !hasLast && hasNext:
return next
case !hasLast && !hasNext:
return LocalLunarEclipseInfo{}
}
lastDistance := math.Abs(date.Sub(last.Maximum).Seconds())
nextDistance := math.Abs(next.Maximum.Sub(date).Seconds())
if lastDistance <= nextDistance {
return last
}
return next
}
func searchLocalLunarEclipse(
date time.Time,
lon, lat, height float64,
direction int,
includeCurrent bool,
calculator lunarEclipseCalculator,
mode localLunarEclipseQueryMode,
) (LocalLunarEclipseInfo, bool) {
targetTT := timeToTTJDE(date)
candidateTT := basic.CalcMoonSHByJDE(targetTT, 1)
for i := 0; i < localLunarEclipseSearchLimit; i++ {
if isPotentialLunarEclipse(candidateTT) {
result := calculator(candidateTT)
if result.Type != basic.LunarEclipseNone {
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, date.Location())
if (mode != localLunarEclipseQueryVisible || localLunarEclipseVisible(info)) &&
lunarEclipseMatchesDirection(result.Maximum, targetTT, direction, includeCurrent) {
return info, true
}
}
}
candidateTT = basic.CalcMoonSHByJDE(candidateTT+float64(direction)*lunarEclipseSynodicMonthDays, 1)
}
return LocalLunarEclipseInfo{}, false
}
func localLunarEclipseInfoFromBasic(
result basic.LunarEclipseResult,
lon, lat, height float64,
location *time.Location,
) LocalLunarEclipseInfo {
maximum := ttJDEToTime(result.Maximum, location)
visibleThreshold := localLunarEclipseVisibilityThreshold(height, lat)
moonAltitude := lunarAltitude(maximum, lon, lat)
saros, hasSaros := lunarSarosInfo(result.Maximum)
return LocalLunarEclipseInfo{
HasSaros: hasSaros,
Saros: saros,
Type: mapBasicLunarEclipseType(result.Type),
Longitude: lon,
Latitude: lat,
Height: height,
PenumbralMagnitude: result.PenumbralMagnitude,
UmbralMagnitude: result.Magnitude,
PenumbralStart: ttJDEToTime(result.PenumbralStart, location),
PartialStart: ttJDEToTime(result.PartialStart, location),
TotalStart: ttJDEToTime(result.TotalStart, location),
Maximum: maximum,
TotalEnd: ttJDEToTime(result.TotalEnd, location),
PartialEnd: ttJDEToTime(result.PartialEnd, location),
PenumbralEnd: ttJDEToTime(result.PenumbralEnd, location),
MoonAltitude: moonAltitude,
MoonAzimuth: lunarAzimuth(maximum, lon, lat),
VisibleAtMaximum: moonAltitude > visibleThreshold,
HasPenumbral: result.HasPenumbral,
HasPartial: result.HasPartial,
HasTotal: result.HasTotal,
}
}
func localLunarEclipseOverlapsDate(info LocalLunarEclipseInfo, dayStart, dayEnd time.Time) bool {
eventStart, eventEnd, ok := localLunarEclipseRange(info)
if !ok {
return false
}
return !eventEnd.Before(dayStart) && eventStart.Before(dayEnd)
}
func localLunarEclipseRange(info LocalLunarEclipseInfo) (time.Time, time.Time, bool) {
if !info.HasPenumbral {
return time.Time{}, time.Time{}, false
}
return info.PenumbralStart, info.PenumbralEnd, true
}
func localLunarEclipseVisible(info LocalLunarEclipseInfo) bool {
eventStart, eventEnd, ok := localLunarEclipseRange(info)
if !ok {
return false
}
return localLunarEclipseVisibleDuring(info, eventStart, eventEnd)
}
func localLunarEclipseVisibleOnDate(info LocalLunarEclipseInfo, dayStart, dayEnd time.Time) bool {
eventStart, eventEnd, ok := localLunarEclipseRange(info)
if !ok {
return false
}
segmentStart := maxLocalLunarTime(eventStart, dayStart)
segmentEnd := minLocalLunarTime(eventEnd, dayEnd)
if !segmentStart.Before(segmentEnd) {
return false
}
return localLunarEclipseVisibleDuring(info, segmentStart, segmentEnd)
}
func localLunarEclipseVisibleDuring(info LocalLunarEclipseInfo, start, end time.Time) bool {
if localLunarEclipseAltitudeVisible(start, info) || localLunarEclipseAltitudeVisible(end, info) {
return true
}
for dayStart, _, _ := lunarEclipseLocalDayBounds(start); !dayStart.After(end); dayStart = nextLunarEclipseLocalDayStart(dayStart) {
_, culminationSeed, _ := lunarEclipseLocalDayBounds(dayStart)
culmination := lunarCulminationTime(culminationSeed, info.Longitude, info.Latitude)
if culmination.Before(start) || culmination.After(end) {
continue
}
if localLunarEclipseAltitudeVisible(culmination, info) {
return true
}
}
return false
}
func localLunarEclipseAltitudeVisible(date time.Time, info LocalLunarEclipseInfo) bool {
return lunarAltitude(date, info.Longitude, info.Latitude) > localLunarEclipseVisibilityThreshold(info.Height, info.Latitude)
}
func localLunarEclipseVisibilityThreshold(height, latitude float64) float64 {
if height <= 0 {
return 0
}
return -basic.HeightDegreeByLat(height, latitude)
}
func maxLocalLunarTime(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
func minLocalLunarTime(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}
+295
View File
@@ -0,0 +1,295 @@
package eclipse
import (
"testing"
"time"
)
func TestLocalLunarEclipseOnDateByLocalDay(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
testCases := []struct {
name string
date time.Time
want bool
}{
{
name: "day before no eclipse",
date: time.Date(2025, 3, 12, 12, 0, 0, 0, loc),
want: false,
},
{
name: "local start day overlaps",
date: time.Date(2025, 3, 13, 12, 0, 0, 0, loc),
want: true,
},
{
name: "local end day overlaps",
date: time.Date(2025, 3, 14, 12, 0, 0, 0, loc),
want: true,
},
{
name: "day after no eclipse",
date: time.Date(2025, 3, 15, 12, 0, 0, 0, loc),
want: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info, ok := LocalLunarEclipseOnDate(tc.date, lon, lat, height)
if ok != tc.want {
t.Fatalf("LocalLunarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
}
if !ok {
return
}
if info.Type != LunarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipseTotal)
}
if info.Maximum.Location() != loc {
t.Fatalf("maximum location mismatch: got %q want %q", info.Maximum.Location(), loc)
}
if info.PenumbralStart.Day() != 13 || info.PenumbralEnd.Day() != 14 {
t.Fatalf("unexpected local date span: start=%v end=%v", info.PenumbralStart, info.PenumbralEnd)
}
})
}
}
func TestLocalLunarEclipseVisibilityFilter(t *testing.T) {
chicagoLoc := time.FixedZone("CDT", -5*3600)
chicagoDate := time.Date(2023, 10, 28, 12, 0, 0, 0, chicagoLoc)
chicagoLon, chicagoLat, chicagoHeight := -87.65, 41.85, 0.0
geometricInfo, geometricOK := GeometricLocalLunarEclipseOnDate(chicagoDate, chicagoLon, chicagoLat, chicagoHeight)
if !geometricOK {
t.Fatalf("expected geometric local eclipse on date")
}
if geometricInfo.Type != LunarEclipsePartial {
t.Fatalf("unexpected geometric eclipse type: got %s want %s", geometricInfo.Type, LunarEclipsePartial)
}
if geometricInfo.VisibleAtMaximum {
t.Fatalf("expected geometric eclipse to be below horizon at maximum: %+v", geometricInfo)
}
visibleInfo, visibleOK := LocalLunarEclipseOnDate(chicagoDate, chicagoLon, chicagoLat, chicagoHeight)
if visibleOK {
t.Fatalf("expected visible filter to reject invisible eclipse, got %+v", visibleInfo)
}
londonDate := time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC)
londonInfo, londonOK := LocalLunarEclipseOnDate(londonDate, -0.1278, 51.5074, 0)
if !londonOK {
t.Fatalf("expected visible local eclipse in London")
}
if londonInfo.VisibleAtMaximum {
t.Fatalf("expected London maximum to be below horizon, got %+v", londonInfo)
}
}
func TestLocalLunarEclipseSearchSemantics(t *testing.T) {
loc := time.UTC
lon, lat, height := -0.1278, 51.5074, 0.0
current := ClosestLocalLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc), lon, lat, height)
if current.Type != LunarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
}
assertSameLocalLunarEclipse(t, "ClosestLocalLunarEclipse(default)", ClosestLocalLunarEclipse(current.Maximum, lon, lat, height), current, time.Second)
last := LastLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
assertSameLocalLunarEclipse(t, "LastLocalLunarEclipseDanjon(current.Maximum)", last, current, time.Second)
closest := ClosestLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
assertSameLocalLunarEclipse(t, "ClosestLocalLunarEclipseDanjon(current.Maximum)", closest, current, time.Second)
next := NextLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
if !next.Maximum.After(current.Maximum) {
t.Fatalf("NextLocalLunarEclipseDanjon should be strictly future: current=%v next=%v", current.Maximum, next.Maximum)
}
if next.Type != LunarEclipseTotal {
t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, LunarEclipseTotal)
}
wantNextMax := time.Date(2025, 9, 7, 18, 11, 49, 0, loc)
assertTimeClose(t, "next.Maximum", next.Maximum, wantNextMax, 2*time.Minute)
}
func TestLocalLunarEclipseSearchBeyondFiveYears(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
current := ClosestLocalLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc), lon, lat, height)
if current.Type != LunarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
}
next := NextLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
if next.Type == LunarEclipseNone || next.Maximum.IsZero() {
t.Fatalf("expected a future visible local lunar eclipse beyond the old 60-lunation window")
}
if !next.Maximum.After(current.Maximum) {
t.Fatalf("expected strictly future local lunar eclipse: current=%v next=%v", current.Maximum, next.Maximum)
}
}
func TestLocalLunarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("JST", 9*3600)
lon, lat, height := 139.6917, 35.6895, 1234.0
testCases := []struct {
name string
calc func(time.Time, float64, float64, float64) LocalLunarEclipseInfo
}{
{name: "danjon", calc: ClosestLocalLunarEclipseDanjon},
{name: "chauvenet", calc: ClosestLocalLunarEclipseChauvenet},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := tc.calc(time.Date(2023, 10, 29, 12, 0, 0, 0, loc), lon, lat, height)
if info.Type != LunarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipsePartial)
}
if info.Longitude != lon || info.Latitude != lat || info.Height != height {
t.Fatalf("observer metadata mismatch: got (%f,%f,%f)", info.Longitude, info.Latitude, info.Height)
}
for _, item := range []struct {
name string
tm time.Time
}{
{name: "PenumbralStart", tm: info.PenumbralStart},
{name: "PartialStart", tm: info.PartialStart},
{name: "Maximum", tm: info.Maximum},
{name: "PartialEnd", tm: info.PartialEnd},
{name: "PenumbralEnd", tm: info.PenumbralEnd},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
}
}
})
}
}
func TestLocalLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.FixedZone("CDT", -5*3600))
lon, lat, height := -87.65, 41.85, 0.0
defaultInfo := ClosestLocalLunarEclipse(date, lon, lat, height)
chauvenetInfo := ClosestLocalLunarEclipseChauvenet(date, lon, lat, height)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285431290, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182811712, 1e-6)
if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) {
t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude)
}
if !(chauvenetInfo.PenumbralStart.Before(defaultInfo.PenumbralStart) && chauvenetInfo.PenumbralEnd.After(defaultInfo.PenumbralEnd)) {
t.Fatalf("expected Chauvenet penumbral span to be wider: chauvenet=(%v,%v) danjon=(%v,%v)", chauvenetInfo.PenumbralStart, chauvenetInfo.PenumbralEnd, defaultInfo.PenumbralStart, defaultInfo.PenumbralEnd)
}
}
func TestLocalLunarEclipseAgainstNASABaseline(t *testing.T) {
testCases := []struct {
name string
date time.Time
lon float64
lat float64
height float64
wantType LunarEclipseType
wantPenumbralMag float64
wantUmbralMag float64
wantPenumbralStart time.Time
wantPartialStart time.Time
wantTotalStart time.Time
wantMaximum time.Time
wantTotalEnd time.Time
wantPartialEnd time.Time
wantPenumbralEnd time.Time
wantMoonAltitude float64
}{
{
name: "2023-10-29 tokyo partial",
date: time.Date(2023, 10, 29, 12, 0, 0, 0, time.FixedZone("JST", 9*3600)),
lon: 139.6917,
lat: 35.6895,
height: 0,
wantType: LunarEclipsePartial,
wantPenumbralMag: 1.1181,
wantUmbralMag: 0.122,
wantPenumbralStart: time.Date(2023, 10, 29, 03, 01, 43, 0, time.FixedZone("JST", 9*3600)),
wantPartialStart: time.Date(2023, 10, 29, 04, 35, 18, 0, time.FixedZone("JST", 9*3600)),
wantMaximum: time.Date(2023, 10, 29, 05, 14, 06, 0, time.FixedZone("JST", 9*3600)),
wantPartialEnd: time.Date(2023, 10, 29, 05, 52, 53, 0, time.FixedZone("JST", 9*3600)),
wantPenumbralEnd: time.Date(2023, 10, 29, 07, 26, 19, 0, time.FixedZone("JST", 9*3600)),
wantMoonAltitude: 9.1,
},
{
name: "2025-03-14 chicago total",
date: time.Date(2025, 3, 14, 12, 0, 0, 0, time.FixedZone("CDT", -5*3600)),
lon: -87.65,
lat: 41.85,
height: 0,
wantType: LunarEclipseTotal,
wantPenumbralMag: 2.2595,
wantUmbralMag: 1.1784,
wantPenumbralStart: time.Date(2025, 3, 13, 22, 57, 28, 0, time.FixedZone("CDT", -5*3600)),
wantPartialStart: time.Date(2025, 3, 14, 0, 9, 40, 0, time.FixedZone("CDT", -5*3600)),
wantTotalStart: time.Date(2025, 3, 14, 1, 26, 6, 0, time.FixedZone("CDT", -5*3600)),
wantMaximum: time.Date(2025, 3, 14, 1, 58, 41, 0, time.FixedZone("CDT", -5*3600)),
wantTotalEnd: time.Date(2025, 3, 14, 2, 31, 26, 0, time.FixedZone("CDT", -5*3600)),
wantPartialEnd: time.Date(2025, 3, 14, 3, 47, 56, 0, time.FixedZone("CDT", -5*3600)),
wantPenumbralEnd: time.Date(2025, 3, 14, 5, 0, 9, 0, time.FixedZone("CDT", -5*3600)),
wantMoonAltitude: 48.2,
},
}
const timeTolerance = 2 * time.Minute
const umbralMagnitudeTolerance = 0.02
const penumbralMagnitudeTolerance = 0.1
const altitudeTolerance = 1.5
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := ClosestLocalLunarEclipse(tc.date, tc.lon, tc.lat, tc.height)
if info.Type != tc.wantType {
t.Fatalf("type mismatch: got %s want %s", info.Type, tc.wantType)
}
assertFloatClose(t, "PenumbralMagnitude", info.PenumbralMagnitude, tc.wantPenumbralMag, penumbralMagnitudeTolerance)
assertFloatClose(t, "UmbralMagnitude", info.UmbralMagnitude, tc.wantUmbralMag, umbralMagnitudeTolerance)
assertTimeClose(t, "PenumbralStart", info.PenumbralStart, tc.wantPenumbralStart, timeTolerance)
assertTimeClose(t, "PartialStart", info.PartialStart, tc.wantPartialStart, timeTolerance)
assertTimeClose(t, "TotalStart", info.TotalStart, tc.wantTotalStart, timeTolerance)
assertTimeClose(t, "Maximum", info.Maximum, tc.wantMaximum, timeTolerance)
assertTimeClose(t, "TotalEnd", info.TotalEnd, tc.wantTotalEnd, timeTolerance)
assertTimeClose(t, "PartialEnd", info.PartialEnd, tc.wantPartialEnd, timeTolerance)
assertTimeClose(t, "PenumbralEnd", info.PenumbralEnd, tc.wantPenumbralEnd, timeTolerance)
assertFloatClose(t, "MoonAltitude", info.MoonAltitude, tc.wantMoonAltitude, altitudeTolerance)
})
}
}
func TestLocalPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
cdt := time.FixedZone("CDT", -5*3600)
info := ClosestLocalLunarEclipse(time.Date(2024, 3, 25, 2, 0, 0, 0, cdt), -87.65, 41.85, 0)
if info.Type != LunarEclipsePenumbral {
t.Fatalf("type mismatch: got %s want %s", info.Type, LunarEclipsePenumbral)
}
if !(info.UmbralMagnitude < 0) {
t.Fatalf("expected negative umbral magnitude for penumbral eclipse, got %.12f", info.UmbralMagnitude)
}
if !(info.PenumbralMagnitude > 0) {
t.Fatalf("expected positive penumbral magnitude, got %.12f", info.PenumbralMagnitude)
}
}
func assertSameLocalLunarEclipse(t *testing.T, name string, got, want LocalLunarEclipseInfo, tolerance time.Duration) {
t.Helper()
if got.Type != want.Type {
t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
}
assertTimeClose(t, name+".Maximum", got.Maximum, want.Maximum, tolerance)
}
+345
View File
@@ -0,0 +1,345 @@
package eclipse
import (
"testing"
"time"
)
func TestLunarEclipseLocalDayBoundsRespectDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skipf("tzdata unavailable: %v", err)
}
testCases := []struct {
name string
date time.Time
wantDuration time.Duration
}{
{
name: "spring forward 2025-03-09",
date: time.Date(2025, 3, 9, 8, 0, 0, 0, loc),
wantDuration: 23 * time.Hour,
},
{
name: "fall back 2025-11-02",
date: time.Date(2025, 11, 2, 8, 0, 0, 0, loc),
wantDuration: 25 * time.Hour,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dayStart, dayMid, dayEnd := lunarEclipseLocalDayBounds(tc.date)
if dayStart.Hour() != 0 || dayStart.Minute() != 0 || dayStart.Second() != 0 {
t.Fatalf("dayStart should be local midnight, got %v", dayStart)
}
if dayMid.Hour() != 12 || dayMid.Minute() != 0 || dayMid.Second() != 0 {
t.Fatalf("dayMid should be local noon, got %v", dayMid)
}
if dayEnd.Hour() != 0 || dayEnd.Minute() != 0 || dayEnd.Second() != 0 {
t.Fatalf("dayEnd should be next local midnight, got %v", dayEnd)
}
if got := dayEnd.Sub(dayStart); got != tc.wantDuration {
t.Fatalf("day length mismatch: got %v want %v", got, tc.wantDuration)
}
})
}
}
func TestLunarEclipseOnDateByLocalDay(t *testing.T) {
loc := time.FixedZone("UTC-05", -5*3600)
testCases := []struct {
name string
date time.Time
want bool
}{
{
name: "day before no eclipse",
date: time.Date(2025, 3, 12, 12, 0, 0, 0, loc),
want: false,
},
{
name: "local start day overlaps",
date: time.Date(2025, 3, 13, 12, 0, 0, 0, loc),
want: true,
},
{
name: "local end day overlaps",
date: time.Date(2025, 3, 14, 12, 0, 0, 0, loc),
want: true,
},
{
name: "day after no eclipse",
date: time.Date(2025, 3, 15, 12, 0, 0, 0, loc),
want: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info, ok := LunarEclipseOnDate(tc.date)
if ok != tc.want {
t.Fatalf("LunarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
}
if !ok {
return
}
if info.Type != LunarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipseTotal)
}
if info.Maximum.Location() != loc {
t.Fatalf("maximum location mismatch: got %q want %q", info.Maximum.Location(), loc)
}
if info.PenumbralStart.Day() != 13 || info.PenumbralEnd.Day() != 14 {
t.Fatalf("unexpected local date span: start=%v end=%v", info.PenumbralStart, info.PenumbralEnd)
}
})
}
}
func TestLunarEclipseSearchSemantics(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
current := ClosestLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc))
if current.Type != LunarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
}
assertSameEclipse(t, "ClosestLunarEclipse(default)", ClosestLunarEclipse(current.Maximum), current, time.Second)
last := LastLunarEclipseDanjon(current.Maximum)
assertSameEclipse(t, "LastLunarEclipseDanjon(current.Maximum)", last, current, time.Second)
closest := ClosestLunarEclipseDanjon(current.Maximum)
assertSameEclipse(t, "ClosestLunarEclipseDanjon(current.Maximum)", closest, current, time.Second)
next := NextLunarEclipseDanjon(current.Maximum)
if !next.Maximum.After(current.Maximum) {
t.Fatalf("NextLunarEclipseDanjon should be strictly future: current=%v next=%v", current.Maximum, next.Maximum)
}
if next.Type != LunarEclipseTotal {
t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, LunarEclipseTotal)
}
wantNextMax := time.Date(2025, 9, 8, 2, 12, 58, 0, loc)
assertTimeClose(t, "next.Maximum", next.Maximum, wantNextMax, 2*time.Minute)
}
func TestLunarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
testCases := []struct {
name string
calc func(time.Time) LunarEclipseInfo
}{
{name: "danjon", calc: ClosestLunarEclipseDanjon},
{name: "chauvenet", calc: ClosestLunarEclipseChauvenet},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := tc.calc(time.Date(2023, 10, 29, 12, 0, 0, 0, loc))
if info.Type != LunarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipsePartial)
}
for _, item := range []struct {
name string
tm time.Time
}{
{name: "PenumbralStart", tm: info.PenumbralStart},
{name: "PartialStart", tm: info.PartialStart},
{name: "Maximum", tm: info.Maximum},
{name: "PartialEnd", tm: info.PartialEnd},
{name: "PenumbralEnd", tm: info.PenumbralEnd},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
}
}
for _, point := range info.ContactPoints {
if point.Time.Location() != loc {
t.Fatalf("contact %s location mismatch: got %q want %q", point.Label, point.Time.Location(), loc)
}
}
})
}
}
func TestLunarEclipseContactPoints(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
info := ClosestLunarEclipse(time.Date(2026, 3, 3, 12, 0, 0, 0, loc))
if info.Type != LunarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipseTotal)
}
if got, want := len(info.ContactPoints), 6; got != want {
t.Fatalf("contact point count = %d, want %d", got, want)
}
points := make(map[string]LunarEclipseContactPoint, len(info.ContactPoints))
for _, point := range info.ContactPoints {
points[point.Label] = point
}
u1 := points["U1"]
assertFloatClose(t, "U1.ContactPositionAngle", u1.ContactPositionAngle, 96.181711, 1e-3)
assertFloatClose(t, "U1.ContactClockwiseAngle", u1.ContactClockwiseAngle, 263.818289, 1e-3)
u2 := points["U2"]
assertFloatClose(t, "U2.ContactPositionAngle", u2.ContactPositionAngle, 243.025171, 1e-3)
assertFloatClose(t, "U2.MoonCenterPositionAngle", u2.MoonCenterPositionAngle, 243.025171, 1e-3)
}
func TestLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC)
defaultInfo := ClosestLunarEclipse(date)
chauvenetInfo := ClosestLunarEclipseChauvenet(date)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285431290, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182811712, 1e-6)
if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) {
t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude)
}
if !(chauvenetInfo.PenumbralStart.Before(defaultInfo.PenumbralStart) && chauvenetInfo.PenumbralEnd.After(defaultInfo.PenumbralEnd)) {
t.Fatalf("expected Chauvenet penumbral span to be wider: chauvenet=(%v,%v) danjon=(%v,%v)", chauvenetInfo.PenumbralStart, chauvenetInfo.PenumbralEnd, defaultInfo.PenumbralStart, defaultInfo.PenumbralEnd)
}
}
func TestLunarEclipseDefaultFallsBackForUltraShallowPenumbralEdge(t *testing.T) {
date := time.Date(-780, 12, 13, 12, 0, 0, 0, time.UTC)
defaultInfo := ClosestLunarEclipse(date)
danjonInfo := ClosestLunarEclipseDanjon(date)
chauvenetInfo := ClosestLunarEclipseChauvenet(date)
if defaultInfo.Type != LunarEclipsePenumbral {
t.Fatalf("default type mismatch: got %s want %s", defaultInfo.Type, LunarEclipsePenumbral)
}
if !defaultInfo.HasSaros || defaultInfo.Saros.Series != 61 || defaultInfo.Saros.Member != 1 || defaultInfo.Saros.Count != 78 {
t.Fatalf("default shallow Saros mismatch: got has=%v saros=%+v", defaultInfo.HasSaros, defaultInfo.Saros)
}
if danjonInfo.Maximum.Equal(defaultInfo.Maximum) {
t.Fatalf("default fallback should differ from explicit Danjon in this edge case: default=%v danjon=%v", defaultInfo.Maximum, danjonInfo.Maximum)
}
if !defaultInfo.Maximum.Equal(chauvenetInfo.Maximum) {
t.Fatalf("default fallback should reuse Chauvenet edge event timing: default=%v chauvenet=%v", defaultInfo.Maximum, chauvenetInfo.Maximum)
}
if !(defaultInfo.PenumbralMagnitude > 0 && defaultInfo.PenumbralMagnitude <= lunarEclipseDefaultFallbackMaxPenumbralMagnitude) {
t.Fatalf("default fallback penumbral magnitude out of narrow edge range: %.9f", defaultInfo.PenumbralMagnitude)
}
}
func TestLunarEclipseAgainstNASABaseline(t *testing.T) {
// NASA GSFC lunar eclipse catalog / plot pages:
// - 2023 Oct 28 partial: LE2023Oct28P.pdf
// - 2025 Mar 14 total: LE2025Mar14T.pdf
testCases := []struct {
name string
date time.Time
wantType LunarEclipseType
wantPenumbralMag float64
wantUmbralMag float64
wantPenumbralStart time.Time
wantPartialStart time.Time
wantTotalStart time.Time
wantMaximum time.Time
wantTotalEnd time.Time
wantPartialEnd time.Time
wantPenumbralEnd time.Time
}{
{
name: "2023-10-28 partial",
date: time.Date(2023, 10, 28, 0, 0, 0, 0, time.UTC),
wantType: LunarEclipsePartial,
wantPenumbralMag: 1.1181,
wantUmbralMag: 0.122,
wantPenumbralStart: time.Date(2023, 10, 28, 18, 1, 43, 0, time.UTC),
wantPartialStart: time.Date(2023, 10, 28, 19, 35, 18, 0, time.UTC),
wantMaximum: time.Date(2023, 10, 28, 20, 14, 6, 0, time.UTC),
wantPartialEnd: time.Date(2023, 10, 28, 20, 52, 53, 0, time.UTC),
wantPenumbralEnd: time.Date(2023, 10, 28, 22, 26, 19, 0, time.UTC),
},
{
name: "2025-03-14 total",
date: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
wantType: LunarEclipseTotal,
wantPenumbralMag: 2.2595,
wantUmbralMag: 1.1784,
wantPenumbralStart: time.Date(2025, 3, 14, 3, 57, 28, 0, time.UTC),
wantPartialStart: time.Date(2025, 3, 14, 5, 9, 40, 0, time.UTC),
wantTotalStart: time.Date(2025, 3, 14, 6, 26, 6, 0, time.UTC),
wantMaximum: time.Date(2025, 3, 14, 6, 58, 41, 0, time.UTC),
wantTotalEnd: time.Date(2025, 3, 14, 7, 31, 26, 0, time.UTC),
wantPartialEnd: time.Date(2025, 3, 14, 8, 47, 56, 0, time.UTC),
wantPenumbralEnd: time.Date(2025, 3, 14, 10, 0, 9, 0, time.UTC),
},
}
const timeTolerance = 2 * time.Minute
const umbralMagnitudeTolerance = 0.02
const penumbralMagnitudeTolerance = 0.1
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assertSameEclipse(t, "ClosestLunarEclipse(default)", ClosestLunarEclipse(tc.date), ClosestLunarEclipseDanjon(tc.date), time.Second)
info := ClosestLunarEclipse(tc.date)
if info.Type != tc.wantType {
t.Fatalf("type mismatch: got %s want %s", info.Type, tc.wantType)
}
assertFloatClose(t, "PenumbralMagnitude", info.PenumbralMagnitude, tc.wantPenumbralMag, penumbralMagnitudeTolerance)
assertFloatClose(t, "UmbralMagnitude", info.UmbralMagnitude, tc.wantUmbralMag, umbralMagnitudeTolerance)
assertTimeClose(t, "PenumbralStart", info.PenumbralStart, tc.wantPenumbralStart, timeTolerance)
assertTimeClose(t, "PartialStart", info.PartialStart, tc.wantPartialStart, timeTolerance)
assertTimeClose(t, "TotalStart", info.TotalStart, tc.wantTotalStart, timeTolerance)
assertTimeClose(t, "Maximum", info.Maximum, tc.wantMaximum, timeTolerance)
assertTimeClose(t, "TotalEnd", info.TotalEnd, tc.wantTotalEnd, timeTolerance)
assertTimeClose(t, "PartialEnd", info.PartialEnd, tc.wantPartialEnd, timeTolerance)
assertTimeClose(t, "PenumbralEnd", info.PenumbralEnd, tc.wantPenumbralEnd, timeTolerance)
})
}
}
func TestPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
info := ClosestLunarEclipse(time.Date(2024, 3, 25, 0, 0, 0, 0, time.UTC))
if info.Type != LunarEclipsePenumbral {
t.Fatalf("type mismatch: got %s want %s", info.Type, LunarEclipsePenumbral)
}
if !(info.UmbralMagnitude < 0) {
t.Fatalf("expected negative umbral magnitude for penumbral eclipse, got %.12f", info.UmbralMagnitude)
}
if !(info.PenumbralMagnitude > 0) {
t.Fatalf("expected positive penumbral magnitude, got %.12f", info.PenumbralMagnitude)
}
}
func assertSameEclipse(t *testing.T, name string, got, want LunarEclipseInfo, tolerance time.Duration) {
t.Helper()
if got.Type != want.Type {
t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
}
assertTimeClose(t, name+".Maximum", got.Maximum, want.Maximum, tolerance)
}
func assertTimeClose(t *testing.T, name string, got, want time.Time, tolerance time.Duration) {
t.Helper()
diff := got.Sub(want)
if diff < 0 {
diff = -diff
}
if diff > tolerance {
t.Fatalf("%s mismatch: got %v want %v diff=%v", name, got, want, diff)
}
}
func assertFloatClose(t *testing.T, name string, got, want, tolerance float64) {
t.Helper()
diff := got - want
if diff < 0 {
diff = -diff
}
if diff > tolerance {
t.Fatalf("%s mismatch: got %.6f want %.6f diff=%.6f", name, got, want, diff)
}
}
+50
View File
@@ -0,0 +1,50 @@
package eclipse
import (
"time"
"b612.me/astro/basic"
"b612.me/astro/tools"
)
func moonSunLoDiff(date time.Time) float64 {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
sunLo := basic.HSunApparentLo(jde)
moonLo := basic.HMoonApparentLo(jde)
return tools.Limit360(moonLo - sunLo)
}
func solarAltitude(date time.Time, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
return basic.SunHeight(jde, lon, lat, float64(loc)/3600.0)
}
func solarCulminationTime(date time.Time, lon float64) time.Time {
jde := basic.Date2JDE(date.Add(time.Duration(-1*date.Hour())*time.Hour)) + 0.5
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
calcJde := basic.CulminationTime(jde, lon, timezone) - timezone/24.0
return basic.JDE2DateByZone(calcJde, date.Location(), false)
}
func lunarAzimuth(date time.Time, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
return basic.HMoonAzimuth(jde, lon, lat, float64(loc)/3600.0)
}
func lunarAltitude(date time.Time, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
_, loc := date.Zone()
return basic.HMoonHeight(jde, lon, lat, float64(loc)/3600.0)
}
func lunarCulminationTime(date time.Time, lon, lat float64) time.Time {
if date.Hour() > 12 {
date = date.Add(-12 * time.Hour)
}
jde := basic.Date2JDE(date)
_, loc := date.Zone()
return basic.JDE2DateByZone(basic.MoonCulminationTime(jde, lon, lat, float64(loc)/3600.0), date.Location(), true)
}
+154
View File
@@ -0,0 +1,154 @@
package eclipse
import (
"time"
"b612.me/astro/basic"
)
const (
sarosCycleLunations = 223
sarosCycleDays = float64(sarosCycleLunations) * solarEclipseSynodicMonthDays
sarosWalkLimit = 100
)
// SarosInfo 沙罗序列信息, Saros series metadata.
type SarosInfo struct {
// Series 是 NASA 沙罗系列编号;太阳食可能出现 0 号系列。
// Series is the NASA Saros series number; solar eclipses may use series 0.
Series int
// Member 是本次食在该系列中的序号,从 1 开始计数。
// Member is the 1-based index of this eclipse within the series.
Member int
// Count 是该系列的总成员数。
// Count is the total number of eclipses in the series.
Count int
}
type sarosAnchor struct {
Series int16
Count uint8
Year int16
Month uint8
Day uint8
}
type sarosHeadOverride struct {
Series int16
Count uint8
HeadYear int16
HeadMonth uint8
HeadDay uint8
MemberOffset int8
}
var solarSarosHeadOverrides = [...]sarosHeadOverride{
{Series: 22, Count: 71, HeadYear: -2192, HeadMonth: 5, HeadDay: 17, MemberOffset: -1},
}
var lunarSarosHeadOverrides = [...]sarosHeadOverride{
{Series: 4, Count: 78, HeadYear: -2483, HeadMonth: 1, HeadDay: 12, MemberOffset: 2},
{Series: 8, Count: 86, HeadYear: -2494, HeadMonth: 8, HeadDay: 7, MemberOffset: 0},
{Series: 61, Count: 78, HeadYear: -762, HeadMonth: 12, HeadDay: 24, MemberOffset: 1},
}
func solarSarosInfo(ttJDE float64) (SarosInfo, bool) {
headTT, member, ok := solarSarosHead(ttJDE)
if !ok {
return SarosInfo{}, false
}
if info, ok := matchSarosHeadOverride(solarSarosHeadOverrides[:], headTT, member); ok {
return info, true
}
anchor, ok := matchSarosAnchor(solarSarosAnchors[:], headTT)
if !ok || member > int(anchor.Count) {
return SarosInfo{}, false
}
return SarosInfo{
Series: int(anchor.Series),
Member: member,
Count: int(anchor.Count),
}, true
}
func lunarSarosInfo(ttJDE float64) (SarosInfo, bool) {
headTT, member, ok := lunarSarosHead(ttJDE)
if !ok {
return SarosInfo{}, false
}
if info, ok := matchSarosHeadOverride(lunarSarosHeadOverrides[:], headTT, member); ok {
return info, true
}
anchor, ok := matchSarosAnchor(lunarSarosAnchors[:], headTT)
if !ok || member > int(anchor.Count) {
return SarosInfo{}, false
}
return SarosInfo{
Series: int(anchor.Series),
Member: member,
Count: int(anchor.Count),
}, true
}
func solarSarosHead(ttJDE float64) (float64, int, bool) {
currentTT := ttJDE
member := 1
for step := 0; step < sarosWalkLimit; step++ {
previousSeed := basic.CalcMoonSHByJDE(currentTT-sarosCycleDays, 0)
previous := basic.SolarEclipseNASABulletinSplitK(previousSeed)
if previous.Type == basic.SolarEclipseNone {
return currentTT, member, true
}
currentTT = previous.GreatestEclipse
member++
}
return 0, 0, false
}
func lunarSarosHead(ttJDE float64) (float64, int, bool) {
currentTT := ttJDE
member := 1
for step := 0; step < sarosWalkLimit; step++ {
previousSeed := basic.CalcMoonSHByJDE(currentTT-sarosCycleDays, 1)
previous := basic.LunarEclipseDanjon(previousSeed)
if previous.Type == basic.LunarEclipseNone {
return currentTT, member, true
}
currentTT = previous.Maximum
member++
}
return 0, 0, false
}
func matchSarosAnchor(anchors []sarosAnchor, headTT float64) (sarosAnchor, bool) {
headDate := basic.JDE2DateByZone(headTT, time.UTC, true)
year, month, day := headDate.Date()
monthNumber := int(month)
for _, anchor := range anchors {
if int(anchor.Year) == year && int(anchor.Month) == monthNumber && int(anchor.Day) == day {
return anchor, true
}
}
return sarosAnchor{}, false
}
func matchSarosHeadOverride(overrides []sarosHeadOverride, headTT float64, member int) (SarosInfo, bool) {
headDate := basic.JDE2DateByZone(headTT, time.UTC, true)
year, month, day := headDate.Date()
monthNumber := int(month)
for _, override := range overrides {
if int(override.HeadYear) != year || int(override.HeadMonth) != monthNumber || int(override.HeadDay) != day {
continue
}
adjustedMember := member + int(override.MemberOffset)
if adjustedMember < 1 || adjustedMember > int(override.Count) {
return SarosInfo{}, false
}
return SarosInfo{
Series: int(override.Series),
Member: adjustedMember,
Count: int(override.Count),
}, true
}
return SarosInfo{}, false
}
+186
View File
@@ -0,0 +1,186 @@
package eclipse
// Code generated by /tmp/generate_saros_tables.go; DO NOT EDIT.
var lunarSarosAnchors = [...]sarosAnchor{
{Series: 1, Count: 73, Year: -2570, Month: 3, Day: 14},
{Series: 2, Count: 73, Year: -2523, Month: 3, Day: 3},
{Series: 3, Count: 76, Year: -2567, Month: 12, Day: 30},
{Series: 4, Count: 78, Year: -2646, Month: 10, Day: 6},
{Series: 5, Count: 77, Year: -2455, Month: 12, Day: 22},
{Series: 6, Count: 86, Year: -2624, Month: 8, Day: 4},
{Series: 7, Count: 89, Year: -2595, Month: 7, Day: 16},
{Series: 8, Count: 86, Year: -2494, Month: 8, Day: 8},
{Series: 9, Count: 75, Year: -2501, Month: 6, Day: 26},
{Series: 10, Count: 74, Year: -2454, Month: 6, Day: 17},
{Series: 11, Count: 74, Year: -2371, Month: 6, Day: 29},
{Series: 12, Count: 73, Year: -2360, Month: 5, Day: 28},
{Series: 13, Count: 73, Year: -2313, Month: 5, Day: 20},
{Series: 14, Count: 73, Year: -2230, Month: 6, Day: 1},
{Series: 15, Count: 73, Year: -2219, Month: 4, Day: 30},
{Series: 16, Count: 73, Year: -2172, Month: 4, Day: 21},
{Series: 17, Count: 72, Year: -2089, Month: 5, Day: 4},
{Series: 18, Count: 73, Year: -2078, Month: 4, Day: 2},
{Series: 19, Count: 73, Year: -2031, Month: 3, Day: 24},
{Series: 20, Count: 72, Year: -1948, Month: 4, Day: 5},
{Series: 21, Count: 74, Year: -1955, Month: 2, Day: 22},
{Series: 22, Count: 74, Year: -1926, Month: 2, Day: 2},
{Series: 23, Count: 73, Year: -1825, Month: 2, Day: 25},
{Series: 24, Count: 85, Year: -2031, Month: 9, Day: 16},
{Series: 25, Count: 87, Year: -2038, Month: 8, Day: 6},
{Series: 26, Count: 85, Year: -1919, Month: 9, Day: 9},
{Series: 27, Count: 85, Year: -1926, Month: 7, Day: 28},
{Series: 28, Count: 74, Year: -1897, Month: 7, Day: 9},
{Series: 29, Count: 83, Year: -1814, Month: 7, Day: 21},
{Series: 30, Count: 74, Year: -1803, Month: 6, Day: 19},
{Series: 31, Count: 73, Year: -1774, Month: 5, Day: 30},
{Series: 32, Count: 73, Year: -1673, Month: 6, Day: 23},
{Series: 33, Count: 73, Year: -1662, Month: 5, Day: 22},
{Series: 34, Count: 72, Year: -1615, Month: 5, Day: 13},
{Series: 35, Count: 72, Year: -1532, Month: 5, Day: 25},
{Series: 36, Count: 73, Year: -1521, Month: 4, Day: 24},
{Series: 37, Count: 72, Year: -1492, Month: 4, Day: 3},
{Series: 38, Count: 72, Year: -1391, Month: 4, Day: 27},
{Series: 39, Count: 73, Year: -1380, Month: 3, Day: 26},
{Series: 40, Count: 73, Year: -1369, Month: 2, Day: 24},
{Series: 41, Count: 73, Year: -1268, Month: 3, Day: 18},
{Series: 42, Count: 74, Year: -1275, Month: 2, Day: 4},
{Series: 43, Count: 85, Year: -1463, Month: 9, Day: 7},
{Series: 44, Count: 76, Year: -1199, Month: 1, Day: 6},
{Series: 45, Count: 85, Year: -1351, Month: 8, Day: 29},
{Series: 46, Count: 76, Year: -1358, Month: 7, Day: 19},
{Series: 47, Count: 86, Year: -1275, Month: 7, Day: 31},
{Series: 48, Count: 75, Year: -1228, Month: 7, Day: 21},
{Series: 49, Count: 73, Year: -1217, Month: 6, Day: 21},
{Series: 50, Count: 73, Year: -1134, Month: 7, Day: 3},
{Series: 51, Count: 73, Year: -1105, Month: 6, Day: 13},
{Series: 52, Count: 72, Year: -1076, Month: 5, Day: 23},
{Series: 53, Count: 72, Year: -993, Month: 6, Day: 5},
{Series: 54, Count: 72, Year: -946, Month: 5, Day: 26},
{Series: 55, Count: 72, Year: -935, Month: 4, Day: 25},
{Series: 56, Count: 72, Year: -852, Month: 5, Day: 7},
{Series: 57, Count: 73, Year: -823, Month: 4, Day: 16},
{Series: 58, Count: 73, Year: -812, Month: 3, Day: 16},
{Series: 59, Count: 71, Year: -711, Month: 4, Day: 9},
{Series: 60, Count: 73, Year: -700, Month: 3, Day: 8},
{Series: 61, Count: 78, Year: -780, Month: 12, Day: 13},
{Series: 62, Count: 74, Year: -624, Month: 2, Day: 8},
{Series: 63, Count: 82, Year: -722, Month: 11, Day: 3},
{Series: 64, Count: 84, Year: -783, Month: 8, Day: 20},
{Series: 65, Count: 86, Year: -736, Month: 8, Day: 11},
{Series: 66, Count: 84, Year: -671, Month: 8, Day: 12},
{Series: 67, Count: 73, Year: -660, Month: 7, Day: 11},
{Series: 68, Count: 72, Year: -595, Month: 7, Day: 14},
{Series: 69, Count: 73, Year: -530, Month: 7, Day: 15},
{Series: 70, Count: 72, Year: -519, Month: 6, Day: 13},
{Series: 71, Count: 72, Year: -472, Month: 6, Day: 4},
{Series: 72, Count: 72, Year: -389, Month: 6, Day: 17},
{Series: 73, Count: 72, Year: -378, Month: 5, Day: 16},
{Series: 74, Count: 72, Year: -331, Month: 5, Day: 7},
{Series: 75, Count: 72, Year: -266, Month: 5, Day: 8},
{Series: 76, Count: 73, Year: -255, Month: 4, Day: 7},
{Series: 77, Count: 72, Year: -190, Month: 4, Day: 9},
{Series: 78, Count: 72, Year: -125, Month: 4, Day: 10},
{Series: 79, Count: 73, Year: -132, Month: 2, Day: 27},
{Series: 80, Count: 74, Year: -103, Month: 2, Day: 7},
{Series: 81, Count: 74, Year: -20, Month: 2, Day: 19},
{Series: 82, Count: 84, Year: -208, Month: 9, Day: 21},
{Series: 83, Count: 84, Year: -197, Month: 8, Day: 22},
{Series: 84, Count: 84, Year: -96, Month: 9, Day: 13},
{Series: 85, Count: 76, Year: -103, Month: 8, Day: 2},
{Series: 86, Count: 73, Year: -74, Month: 7, Day: 13},
{Series: 87, Count: 73, Year: 27, Month: 8, Day: 6},
{Series: 88, Count: 72, Year: 38, Month: 7, Day: 5},
{Series: 89, Count: 72, Year: 67, Month: 6, Day: 15},
{Series: 90, Count: 72, Year: 150, Month: 6, Day: 27},
{Series: 91, Count: 72, Year: 179, Month: 6, Day: 7},
{Series: 92, Count: 71, Year: 208, Month: 5, Day: 17},
{Series: 93, Count: 71, Year: 291, Month: 5, Day: 30},
{Series: 94, Count: 71, Year: 320, Month: 5, Day: 9},
{Series: 95, Count: 71, Year: 349, Month: 4, Day: 19},
{Series: 96, Count: 71, Year: 432, Month: 5, Day: 1},
{Series: 97, Count: 72, Year: 443, Month: 3, Day: 31},
{Series: 98, Count: 74, Year: 436, Month: 2, Day: 18},
{Series: 99, Count: 72, Year: 555, Month: 3, Day: 24},
{Series: 100, Count: 79, Year: 439, Month: 12, Day: 6},
{Series: 101, Count: 83, Year: 360, Month: 9, Day: 11},
{Series: 102, Count: 84, Year: 461, Month: 10, Day: 5},
{Series: 103, Count: 82, Year: 472, Month: 9, Day: 3},
{Series: 104, Count: 72, Year: 483, Month: 8, Day: 4},
{Series: 105, Count: 73, Year: 566, Month: 8, Day: 16},
{Series: 106, Count: 73, Year: 595, Month: 7, Day: 27},
{Series: 107, Count: 72, Year: 606, Month: 6, Day: 26},
{Series: 108, Count: 72, Year: 689, Month: 7, Day: 8},
{Series: 109, Count: 71, Year: 736, Month: 6, Day: 27},
{Series: 110, Count: 72, Year: 747, Month: 5, Day: 28},
{Series: 111, Count: 71, Year: 830, Month: 6, Day: 10},
{Series: 112, Count: 72, Year: 859, Month: 5, Day: 20},
{Series: 113, Count: 71, Year: 888, Month: 4, Day: 29},
{Series: 114, Count: 71, Year: 971, Month: 5, Day: 13},
{Series: 115, Count: 72, Year: 1000, Month: 4, Day: 21},
{Series: 116, Count: 73, Year: 993, Month: 3, Day: 11},
{Series: 117, Count: 71, Year: 1094, Month: 4, Day: 3},
{Series: 118, Count: 73, Year: 1105, Month: 3, Day: 2},
{Series: 119, Count: 82, Year: 935, Month: 10, Day: 14},
{Series: 120, Count: 83, Year: 1000, Month: 10, Day: 16},
{Series: 121, Count: 82, Year: 1047, Month: 10, Day: 6},
{Series: 122, Count: 74, Year: 1022, Month: 8, Day: 14},
{Series: 123, Count: 72, Year: 1087, Month: 8, Day: 16},
{Series: 124, Count: 73, Year: 1152, Month: 8, Day: 17},
{Series: 125, Count: 72, Year: 1163, Month: 7, Day: 17},
{Series: 126, Count: 70, Year: 1228, Month: 7, Day: 18},
{Series: 127, Count: 72, Year: 1275, Month: 7, Day: 9},
{Series: 128, Count: 71, Year: 1304, Month: 6, Day: 18},
{Series: 129, Count: 71, Year: 1351, Month: 6, Day: 10},
{Series: 130, Count: 71, Year: 1416, Month: 6, Day: 10},
{Series: 131, Count: 72, Year: 1427, Month: 5, Day: 10},
{Series: 132, Count: 71, Year: 1492, Month: 5, Day: 12},
{Series: 133, Count: 71, Year: 1557, Month: 5, Day: 13},
{Series: 134, Count: 72, Year: 1550, Month: 4, Day: 1},
{Series: 135, Count: 71, Year: 1615, Month: 4, Day: 13},
{Series: 136, Count: 72, Year: 1680, Month: 4, Day: 13},
{Series: 137, Count: 78, Year: 1564, Month: 12, Day: 17},
{Series: 138, Count: 82, Year: 1521, Month: 10, Day: 15},
{Series: 139, Count: 79, Year: 1658, Month: 12, Day: 9},
{Series: 140, Count: 77, Year: 1597, Month: 9, Day: 25},
{Series: 141, Count: 72, Year: 1608, Month: 8, Day: 25},
{Series: 142, Count: 73, Year: 1709, Month: 9, Day: 19},
{Series: 143, Count: 72, Year: 1720, Month: 8, Day: 18},
{Series: 144, Count: 71, Year: 1749, Month: 7, Day: 29},
{Series: 145, Count: 71, Year: 1832, Month: 8, Day: 11},
{Series: 146, Count: 72, Year: 1843, Month: 7, Day: 11},
{Series: 147, Count: 70, Year: 1890, Month: 7, Day: 2},
{Series: 148, Count: 70, Year: 1973, Month: 7, Day: 15},
{Series: 149, Count: 71, Year: 1984, Month: 6, Day: 13},
{Series: 150, Count: 71, Year: 2013, Month: 5, Day: 25},
{Series: 151, Count: 71, Year: 2096, Month: 6, Day: 6},
{Series: 152, Count: 72, Year: 2107, Month: 5, Day: 7},
{Series: 153, Count: 71, Year: 2136, Month: 4, Day: 16},
{Series: 154, Count: 71, Year: 2237, Month: 5, Day: 10},
{Series: 155, Count: 73, Year: 2212, Month: 3, Day: 18},
{Series: 156, Count: 81, Year: 2060, Month: 11, Day: 8},
{Series: 157, Count: 73, Year: 2306, Month: 3, Day: 1},
{Series: 158, Count: 81, Year: 2154, Month: 10, Day: 21},
{Series: 159, Count: 73, Year: 2147, Month: 9, Day: 9},
{Series: 160, Count: 72, Year: 2248, Month: 10, Day: 3},
{Series: 161, Count: 73, Year: 2259, Month: 9, Day: 2},
{Series: 162, Count: 71, Year: 2288, Month: 8, Day: 12},
{Series: 163, Count: 70, Year: 2371, Month: 8, Day: 27},
{Series: 164, Count: 71, Year: 2400, Month: 8, Day: 5},
{Series: 165, Count: 71, Year: 2411, Month: 7, Day: 6},
{Series: 166, Count: 70, Year: 2494, Month: 7, Day: 18},
{Series: 167, Count: 71, Year: 2541, Month: 7, Day: 9},
{Series: 168, Count: 71, Year: 2552, Month: 6, Day: 8},
{Series: 169, Count: 70, Year: 2635, Month: 6, Day: 22},
{Series: 170, Count: 71, Year: 2664, Month: 6, Day: 1},
{Series: 171, Count: 71, Year: 2675, Month: 5, Day: 1},
{Series: 172, Count: 70, Year: 2758, Month: 5, Day: 15},
{Series: 173, Count: 72, Year: 2787, Month: 4, Day: 24},
{Series: 174, Count: 79, Year: 2635, Month: 12, Day: 16},
{Series: 175, Count: 74, Year: 2791, Month: 2, Day: 11},
{Series: 176, Count: 79, Year: 2747, Month: 12, Day: 9},
{Series: 177, Count: 73, Year: 2704, Month: 10, Day: 5},
{Series: 178, Count: 70, Year: 2769, Month: 10, Day: 7},
{Series: 179, Count: 73, Year: 2816, Month: 9, Day: 27},
{Series: 180, Count: 71, Year: 2827, Month: 8, Day: 28},
}
+187
View File
@@ -0,0 +1,187 @@
package eclipse
// Code generated by /tmp/generate_saros_tables.go; DO NOT EDIT.
var solarSarosAnchors = [...]sarosAnchor{
{Series: 0, Count: 72, Year: -2955, Month: 5, Day: 23},
{Series: 1, Count: 72, Year: -2872, Month: 6, Day: 4},
{Series: 2, Count: 73, Year: -2861, Month: 5, Day: 4},
{Series: 3, Count: 72, Year: -2814, Month: 4, Day: 24},
{Series: 4, Count: 72, Year: -2731, Month: 5, Day: 6},
{Series: 5, Count: 73, Year: -2720, Month: 4, Day: 4},
{Series: 6, Count: 72, Year: -2673, Month: 3, Day: 27},
{Series: 7, Count: 72, Year: -2590, Month: 4, Day: 8},
{Series: 8, Count: 73, Year: -2579, Month: 3, Day: 7},
{Series: 9, Count: 74, Year: -2568, Month: 2, Day: 6},
{Series: 10, Count: 73, Year: -2467, Month: 2, Day: 28},
{Series: 11, Count: 76, Year: -2492, Month: 1, Day: 6},
{Series: 12, Count: 86, Year: -2662, Month: 8, Day: 20},
{Series: 13, Count: 85, Year: -2543, Month: 9, Day: 23},
{Series: 14, Count: 85, Year: -2550, Month: 8, Day: 11},
{Series: 15, Count: 75, Year: -2557, Month: 7, Day: 1},
{Series: 16, Count: 85, Year: -2456, Month: 7, Day: 23},
{Series: 17, Count: 74, Year: -2427, Month: 7, Day: 3},
{Series: 18, Count: 73, Year: -2416, Month: 6, Day: 2},
{Series: 19, Count: 73, Year: -2333, Month: 6, Day: 15},
{Series: 20, Count: 72, Year: -2286, Month: 6, Day: 5},
{Series: 21, Count: 72, Year: -2275, Month: 5, Day: 5},
{Series: 22, Count: 71, Year: -2174, Month: 5, Day: 28},
{Series: 23, Count: 72, Year: -2145, Month: 5, Day: 7},
{Series: 24, Count: 72, Year: -2134, Month: 4, Day: 6},
{Series: 25, Count: 71, Year: -2033, Month: 4, Day: 30},
{Series: 26, Count: 72, Year: -2004, Month: 4, Day: 8},
{Series: 27, Count: 72, Year: -1993, Month: 3, Day: 9},
{Series: 28, Count: 72, Year: -1910, Month: 3, Day: 22},
{Series: 29, Count: 73, Year: -1881, Month: 3, Day: 1},
{Series: 30, Count: 83, Year: -2051, Month: 10, Day: 12},
{Series: 31, Count: 74, Year: -1805, Month: 1, Day: 31},
{Series: 32, Count: 84, Year: -1957, Month: 9, Day: 24},
{Series: 33, Count: 84, Year: -1982, Month: 8, Day: 2},
{Series: 34, Count: 86, Year: -1917, Month: 8, Day: 4},
{Series: 35, Count: 84, Year: -1870, Month: 7, Day: 25},
{Series: 36, Count: 73, Year: -1859, Month: 6, Day: 23},
{Series: 37, Count: 73, Year: -1794, Month: 6, Day: 25},
{Series: 38, Count: 73, Year: -1729, Month: 6, Day: 26},
{Series: 39, Count: 72, Year: -1718, Month: 5, Day: 26},
{Series: 40, Count: 72, Year: -1653, Month: 5, Day: 28},
{Series: 41, Count: 72, Year: -1588, Month: 5, Day: 28},
{Series: 42, Count: 72, Year: -1577, Month: 4, Day: 28},
{Series: 43, Count: 72, Year: -1512, Month: 4, Day: 29},
{Series: 44, Count: 72, Year: -1447, Month: 4, Day: 30},
{Series: 45, Count: 72, Year: -1436, Month: 3, Day: 30},
{Series: 46, Count: 72, Year: -1371, Month: 4, Day: 1},
{Series: 47, Count: 72, Year: -1306, Month: 4, Day: 2},
{Series: 48, Count: 74, Year: -1331, Month: 2, Day: 8},
{Series: 49, Count: 72, Year: -1248, Month: 2, Day: 22},
{Series: 50, Count: 73, Year: -1201, Month: 2, Day: 11},
{Series: 51, Count: 85, Year: -1407, Month: 9, Day: 2},
{Series: 52, Count: 86, Year: -1378, Month: 8, Day: 14},
{Series: 53, Count: 84, Year: -1277, Month: 9, Day: 6},
{Series: 54, Count: 74, Year: -1284, Month: 7, Day: 25},
{Series: 55, Count: 73, Year: -1255, Month: 7, Day: 6},
{Series: 56, Count: 74, Year: -1172, Month: 7, Day: 17},
{Series: 57, Count: 73, Year: -1161, Month: 6, Day: 17},
{Series: 58, Count: 72, Year: -1114, Month: 6, Day: 7},
{Series: 59, Count: 72, Year: -1031, Month: 6, Day: 19},
{Series: 60, Count: 72, Year: -1020, Month: 5, Day: 18},
{Series: 61, Count: 71, Year: -973, Month: 5, Day: 10},
{Series: 62, Count: 71, Year: -890, Month: 5, Day: 22},
{Series: 63, Count: 72, Year: -879, Month: 4, Day: 20},
{Series: 64, Count: 71, Year: -832, Month: 4, Day: 11},
{Series: 65, Count: 71, Year: -749, Month: 4, Day: 24},
{Series: 66, Count: 73, Year: -756, Month: 3, Day: 12},
{Series: 67, Count: 72, Year: -709, Month: 3, Day: 4},
{Series: 68, Count: 72, Year: -626, Month: 3, Day: 16},
{Series: 69, Count: 78, Year: -724, Month: 12, Day: 9},
{Series: 70, Count: 84, Year: -821, Month: 9, Day: 5},
{Series: 71, Count: 82, Year: -684, Month: 10, Day: 19},
{Series: 72, Count: 83, Year: -727, Month: 8, Day: 16},
{Series: 73, Count: 72, Year: -698, Month: 7, Day: 27},
{Series: 74, Count: 75, Year: -615, Month: 8, Day: 8},
{Series: 75, Count: 73, Year: -604, Month: 7, Day: 7},
{Series: 76, Count: 72, Year: -575, Month: 6, Day: 18},
{Series: 77, Count: 71, Year: -474, Month: 7, Day: 11},
{Series: 78, Count: 72, Year: -463, Month: 6, Day: 9},
{Series: 79, Count: 71, Year: -434, Month: 5, Day: 21},
{Series: 80, Count: 71, Year: -333, Month: 6, Day: 13},
{Series: 81, Count: 72, Year: -322, Month: 5, Day: 12},
{Series: 82, Count: 71, Year: -293, Month: 4, Day: 22},
{Series: 83, Count: 71, Year: -210, Month: 5, Day: 5},
{Series: 84, Count: 72, Year: -181, Month: 4, Day: 14},
{Series: 85, Count: 72, Year: -170, Month: 3, Day: 14},
{Series: 86, Count: 71, Year: -69, Month: 4, Day: 6},
{Series: 87, Count: 73, Year: -76, Month: 2, Day: 23},
{Series: 88, Count: 83, Year: -246, Month: 10, Day: 6},
{Series: 89, Count: 73, Year: 18, Month: 2, Day: 4},
{Series: 90, Count: 83, Year: -134, Month: 9, Day: 28},
{Series: 91, Count: 75, Year: -159, Month: 8, Day: 6},
{Series: 92, Count: 74, Year: -76, Month: 8, Day: 19},
{Series: 93, Count: 74, Year: -29, Month: 8, Day: 9},
{Series: 94, Count: 72, Year: -18, Month: 7, Day: 9},
{Series: 95, Count: 71, Year: 47, Month: 7, Day: 11},
{Series: 96, Count: 72, Year: 94, Month: 7, Day: 1},
{Series: 97, Count: 71, Year: 123, Month: 6, Day: 11},
{Series: 98, Count: 71, Year: 188, Month: 6, Day: 12},
{Series: 99, Count: 72, Year: 235, Month: 6, Day: 3},
{Series: 100, Count: 71, Year: 264, Month: 5, Day: 13},
{Series: 101, Count: 71, Year: 329, Month: 5, Day: 15},
{Series: 102, Count: 71, Year: 376, Month: 5, Day: 5},
{Series: 103, Count: 72, Year: 387, Month: 4, Day: 4},
{Series: 104, Count: 70, Year: 470, Month: 4, Day: 17},
{Series: 105, Count: 72, Year: 499, Month: 3, Day: 27},
{Series: 106, Count: 75, Year: 456, Month: 1, Day: 23},
{Series: 107, Count: 72, Year: 557, Month: 2, Day: 15},
{Series: 108, Count: 76, Year: 550, Month: 1, Day: 4},
{Series: 109, Count: 81, Year: 416, Month: 9, Day: 7},
{Series: 110, Count: 72, Year: 463, Month: 8, Day: 30},
{Series: 111, Count: 79, Year: 528, Month: 8, Day: 30},
{Series: 112, Count: 72, Year: 539, Month: 7, Day: 31},
{Series: 113, Count: 71, Year: 586, Month: 7, Day: 22},
{Series: 114, Count: 72, Year: 651, Month: 7, Day: 23},
{Series: 115, Count: 72, Year: 662, Month: 6, Day: 21},
{Series: 116, Count: 70, Year: 727, Month: 6, Day: 23},
{Series: 117, Count: 71, Year: 792, Month: 6, Day: 24},
{Series: 118, Count: 72, Year: 803, Month: 5, Day: 24},
{Series: 119, Count: 71, Year: 850, Month: 5, Day: 15},
{Series: 120, Count: 71, Year: 933, Month: 5, Day: 27},
{Series: 121, Count: 71, Year: 944, Month: 4, Day: 25},
{Series: 122, Count: 70, Year: 991, Month: 4, Day: 17},
{Series: 123, Count: 70, Year: 1074, Month: 4, Day: 29},
{Series: 124, Count: 73, Year: 1049, Month: 3, Day: 6},
{Series: 125, Count: 73, Year: 1060, Month: 2, Day: 4},
{Series: 126, Count: 72, Year: 1179, Month: 3, Day: 10},
{Series: 127, Count: 82, Year: 991, Month: 10, Day: 10},
{Series: 128, Count: 73, Year: 984, Month: 8, Day: 29},
{Series: 129, Count: 80, Year: 1103, Month: 10, Day: 3},
{Series: 130, Count: 73, Year: 1096, Month: 8, Day: 20},
{Series: 131, Count: 70, Year: 1125, Month: 8, Day: 1},
{Series: 132, Count: 71, Year: 1208, Month: 8, Day: 13},
{Series: 133, Count: 72, Year: 1219, Month: 7, Day: 13},
{Series: 134, Count: 71, Year: 1248, Month: 6, Day: 22},
{Series: 135, Count: 71, Year: 1331, Month: 7, Day: 5},
{Series: 136, Count: 71, Year: 1360, Month: 6, Day: 14},
{Series: 137, Count: 70, Year: 1389, Month: 5, Day: 25},
{Series: 138, Count: 70, Year: 1472, Month: 6, Day: 6},
{Series: 139, Count: 71, Year: 1501, Month: 5, Day: 17},
{Series: 140, Count: 71, Year: 1512, Month: 4, Day: 16},
{Series: 141, Count: 70, Year: 1613, Month: 5, Day: 19},
{Series: 142, Count: 72, Year: 1624, Month: 4, Day: 17},
{Series: 143, Count: 72, Year: 1617, Month: 3, Day: 7},
{Series: 144, Count: 70, Year: 1736, Month: 4, Day: 11},
{Series: 145, Count: 77, Year: 1639, Month: 1, Day: 4},
{Series: 146, Count: 76, Year: 1541, Month: 9, Day: 19},
{Series: 147, Count: 80, Year: 1624, Month: 10, Day: 12},
{Series: 148, Count: 75, Year: 1653, Month: 9, Day: 21},
{Series: 149, Count: 71, Year: 1664, Month: 8, Day: 21},
{Series: 150, Count: 71, Year: 1729, Month: 8, Day: 24},
{Series: 151, Count: 72, Year: 1776, Month: 8, Day: 14},
{Series: 152, Count: 70, Year: 1805, Month: 7, Day: 26},
{Series: 153, Count: 70, Year: 1870, Month: 7, Day: 28},
{Series: 154, Count: 71, Year: 1917, Month: 7, Day: 19},
{Series: 155, Count: 71, Year: 1928, Month: 6, Day: 17},
{Series: 156, Count: 69, Year: 2011, Month: 7, Day: 1},
{Series: 157, Count: 70, Year: 2058, Month: 6, Day: 21},
{Series: 158, Count: 70, Year: 2069, Month: 5, Day: 20},
{Series: 159, Count: 70, Year: 2134, Month: 5, Day: 23},
{Series: 160, Count: 71, Year: 2181, Month: 5, Day: 13},
{Series: 161, Count: 72, Year: 2174, Month: 4, Day: 1},
{Series: 162, Count: 70, Year: 2257, Month: 4, Day: 15},
{Series: 163, Count: 72, Year: 2286, Month: 3, Day: 25},
{Series: 164, Count: 80, Year: 2098, Month: 10, Day: 24},
{Series: 165, Count: 72, Year: 2145, Month: 10, Day: 16},
{Series: 166, Count: 77, Year: 2228, Month: 10, Day: 29},
{Series: 167, Count: 72, Year: 2203, Month: 9, Day: 6},
{Series: 168, Count: 70, Year: 2250, Month: 8, Day: 28},
{Series: 169, Count: 71, Year: 2333, Month: 9, Day: 10},
{Series: 170, Count: 71, Year: 2344, Month: 8, Day: 9},
{Series: 171, Count: 69, Year: 2391, Month: 8, Day: 1},
{Series: 172, Count: 70, Year: 2474, Month: 8, Day: 13},
{Series: 173, Count: 70, Year: 2485, Month: 7, Day: 12},
{Series: 174, Count: 69, Year: 2532, Month: 7, Day: 4},
{Series: 175, Count: 70, Year: 2597, Month: 7, Day: 5},
{Series: 176, Count: 71, Year: 2608, Month: 6, Day: 4},
{Series: 177, Count: 69, Year: 2655, Month: 5, Day: 27},
{Series: 178, Count: 70, Year: 2738, Month: 6, Day: 9},
{Series: 179, Count: 71, Year: 2731, Month: 4, Day: 28},
{Series: 180, Count: 70, Year: 2760, Month: 4, Day: 8},
}
+201
View File
@@ -0,0 +1,201 @@
package eclipse
import (
"testing"
"time"
)
func TestSolarSarosInfoAgainstNASAExamples(t *testing.T) {
t.Run("2024 Apr 08 total", func(t *testing.T) {
info := ClosestSolarEclipse(time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 139, 30, 71)
})
t.Run("1501 May 17 first member", func(t *testing.T) {
info := ClosestSolarEclipse(time.Date(1501, 5, 17, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 139, 1, 71)
})
t.Run("2763 Jul 03 last member", func(t *testing.T) {
info := ClosestSolarEclipse(time.Date(2763, 7, 3, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 139, 71, 71)
})
t.Run("series 22 edge-range member", func(t *testing.T) {
info := ClosestSolarEclipse(time.Date(-1994, 9, 13, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 22, 11, 71)
})
}
func TestLocalSolarSarosMatchesGlobal(t *testing.T) {
date := time.Date(2009, 7, 22, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
global := ClosestSolarEclipse(date)
local, ok := LocalSolarEclipseOnDate(date, 121.9850, 30.6167, 0)
if !ok {
t.Fatal("expected a visible local solar eclipse")
}
if !global.HasSaros || !local.HasSaros {
t.Fatalf("expected both global and local solar eclipses to have Saros info: global=%v local=%v", global.HasSaros, local.HasSaros)
}
if global.Saros != local.Saros {
t.Fatalf("local solar Saros mismatch: got %+v want %+v", local.Saros, global.Saros)
}
}
func TestLunarSarosInfoAgainstNASAExamples(t *testing.T) {
t.Run("2025 Mar 14 total", func(t *testing.T) {
info := ClosestLunarEclipse(time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 123, 53, 72)
})
t.Run("1087 Aug 16 first member", func(t *testing.T) {
info := ClosestLunarEclipse(time.Date(1087, 8, 16, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 123, 1, 72)
})
t.Run("2367 Oct 08 last member", func(t *testing.T) {
info := ClosestLunarEclipse(time.Date(2367, 10, 8, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 123, 72, 72)
})
t.Run("series 4 edge-range member", func(t *testing.T) {
info := ClosestLunarEclipse(time.Date(-1997, 10, 31, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 4, 30, 78)
})
t.Run("series 8 edge-range member", func(t *testing.T) {
info := ClosestLunarEclipse(time.Date(-1989, 6, 6, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 8, 29, 86)
})
t.Run("series 61 mid-series member", func(t *testing.T) {
info := ClosestLunarEclipse(time.Date(14, 4, 4, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 61, 45, 78)
})
t.Run("series 61 shallow first member default", func(t *testing.T) {
info := ClosestLunarEclipse(time.Date(-780, 12, 13, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 61, 1, 78)
})
}
func TestLunarSarosShallowFirstMemberChauvenet(t *testing.T) {
info := ClosestLunarEclipseChauvenet(time.Date(-780, 12, 13, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, info.HasSaros, info.Saros, 61, 1, 78)
}
func TestLocalLunarSarosMatchesGlobal(t *testing.T) {
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.FixedZone("CDT", -5*3600))
global := ClosestLunarEclipse(date)
local, ok := LocalLunarEclipseOnDate(date, -95.3698, 29.7604, 0)
if !ok {
t.Fatal("expected a visible local lunar eclipse")
}
if !global.HasSaros || !local.HasSaros {
t.Fatalf("expected both global and local lunar eclipses to have Saros info: global=%v local=%v", global.HasSaros, local.HasSaros)
}
if global.Saros != local.Saros {
t.Fatalf("local lunar Saros mismatch: got %+v want %+v", local.Saros, global.Saros)
}
}
func TestSolarPathAndFootprintsCarrySaros(t *testing.T) {
date := time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)
global := ClosestSolarEclipse(date)
path, ok := SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok {
t.Fatal("expected central path data")
}
assertSarosInfo(t, path.Eclipse.HasSaros, path.Eclipse.Saros, global.Saros.Series, global.Saros.Member, global.Saros.Count)
footprints, ok := SolarEclipsePartialFootprints(date, SolarEclipsePartialFootprintOptions{})
if !ok {
t.Fatal("expected partial footprints data")
}
assertSarosInfo(t, footprints.Eclipse.HasSaros, footprints.Eclipse.Saros, global.Saros.Series, global.Saros.Member, global.Saros.Count)
}
func TestSarosAnchorSanity(t *testing.T) {
assertSarosAnchorTable(t, solarSarosAnchors[:], true)
assertSarosAnchorTable(t, lunarSarosAnchors[:], false)
assertSarosHeadOverrides(t, solarSarosHeadOverrides[:], solarSarosAnchors[:])
assertSarosHeadOverrides(t, lunarSarosHeadOverrides[:], lunarSarosAnchors[:])
}
func assertSarosInfo(t *testing.T, has bool, got SarosInfo, wantSeries, wantMember, wantCount int) {
t.Helper()
if !has {
t.Fatal("expected Saros info")
}
if got.Series != wantSeries || got.Member != wantMember || got.Count != wantCount {
t.Fatalf(
"unexpected Saros info: got {Series:%d Member:%d Count:%d} want {Series:%d Member:%d Count:%d}",
got.Series,
got.Member,
got.Count,
wantSeries,
wantMember,
wantCount,
)
}
}
func assertSarosAnchorTable(t *testing.T, anchors []sarosAnchor, solar bool) {
t.Helper()
if len(anchors) == 0 {
t.Fatal("expected non-empty Saros anchor table")
}
seenDates := make(map[[3]int]int, len(anchors))
lastSeries := int(anchors[0].Series) - 1
for _, anchor := range anchors {
series := int(anchor.Series)
if series <= lastSeries {
t.Fatalf("series not strictly increasing: prev=%d current=%d", lastSeries, series)
}
lastSeries = series
if anchor.Count == 0 || int(anchor.Count) >= sarosWalkLimit {
t.Fatalf("unexpected anchor count for series %d: %d", series, anchor.Count)
}
dateKey := [3]int{int(anchor.Year), int(anchor.Month), int(anchor.Day)}
if previous, ok := seenDates[dateKey]; ok {
t.Fatalf("duplicate Saros head date %v for series %d and %d", dateKey, previous, series)
}
seenDates[dateKey] = series
}
if solar {
if got := int(anchors[0].Series); got != 0 {
t.Fatalf("unexpected first solar series: got %d want 0", got)
}
} else {
if got := int(anchors[0].Series); got != 1 {
t.Fatalf("unexpected first lunar series: got %d want 1", got)
}
}
}
func assertSarosHeadOverrides(t *testing.T, overrides []sarosHeadOverride, anchors []sarosAnchor) {
t.Helper()
if len(overrides) == 0 {
return
}
seenHeads := make(map[[3]int]int, len(overrides))
anchorSeries := make(map[int]int, len(anchors))
for _, anchor := range anchors {
anchorSeries[int(anchor.Series)] = int(anchor.Count)
}
for _, override := range overrides {
key := [3]int{int(override.HeadYear), int(override.HeadMonth), int(override.HeadDay)}
if previous, ok := seenHeads[key]; ok {
t.Fatalf("duplicate Saros override head date %v for series %d and %d", key, previous, override.Series)
}
seenHeads[key] = int(override.Series)
count, ok := anchorSeries[int(override.Series)]
if !ok {
t.Fatalf("override references unknown series %d", override.Series)
}
if count != int(override.Count) {
t.Fatalf("override count mismatch for series %d: got %d want %d", override.Series, override.Count, count)
}
}
}
+360
View File
@@ -0,0 +1,360 @@
package eclipse
import (
"math"
"time"
"b612.me/astro/basic"
)
const (
solarEclipseSynodicMonthDays = 29.530588853
solarEclipseSearchLimit = 36
solarEclipseSearchEpsilonDay = 1e-8
)
type solarEclipseCalculator func(float64) basic.SolarEclipseResult
// SolarEclipseRadiusModel 日食月亮半径模型, lunar radius model for solar eclipses.
type SolarEclipseRadiusModel string
const (
// SolarEclipseModelIAUSingleK IAU 单一 k 值模型, IAU single-k model.
SolarEclipseModelIAUSingleK SolarEclipseRadiusModel = "iau_single_k"
// SolarEclipseModelNASABulletinSplitK NASA bulletin 分裂 k 值模型, NASA bulletin split-k model.
SolarEclipseModelNASABulletinSplitK SolarEclipseRadiusModel = "nasa_bulletin_split_k"
)
// SolarEclipseType 全局日食食型, global solar eclipse type.
type SolarEclipseType string
const (
// SolarEclipseNone 无日食, no solar eclipse.
SolarEclipseNone SolarEclipseType = "none"
// SolarEclipsePartial 日偏食, partial solar eclipse.
SolarEclipsePartial SolarEclipseType = "partial"
// SolarEclipseAnnular 日环食, annular solar eclipse.
SolarEclipseAnnular SolarEclipseType = "annular"
// SolarEclipseTotal 日全食, total solar eclipse.
SolarEclipseTotal SolarEclipseType = "total"
// SolarEclipseHybrid 全环食, hybrid solar eclipse.
SolarEclipseHybrid SolarEclipseType = "hybrid"
)
// SolarEclipseCentrality 中心线进入地球的方式, global eclipse centrality.
type SolarEclipseCentrality string
const (
// SolarEclipseNonCentral 非中心食, non-central eclipse.
SolarEclipseNonCentral SolarEclipseCentrality = "non_central"
// SolarEclipseCentralOneLimit 单界中心食, central eclipse with one limit.
SolarEclipseCentralOneLimit SolarEclipseCentrality = "central_one_limit"
// SolarEclipseCentralTwoLimits 双界中心食, central eclipse with two limits.
SolarEclipseCentralTwoLimits SolarEclipseCentrality = "central_two_limits"
)
// SolarEclipseInfo 全局日食信息, global solar eclipse information.
//
// 所有时刻字段都保持用户输入的时区。
// 不存在的阶段使用零值 time.Time。
type SolarEclipseInfo struct {
// Model 日食月亮半径模型, eclipse lunar radius model.
Model SolarEclipseRadiusModel
// Type 全局食型, global eclipse type.
Type SolarEclipseType
// Centrality 中心性, eclipse centrality.
Centrality SolarEclipseCentrality
// 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
// GreatestEclipse 食甚时刻, greatest eclipse.
GreatestEclipse time.Time
// PartialBeginOnEarth 地球范围偏食始, partial eclipse begins on Earth.
PartialBeginOnEarth time.Time
// PartialEndOnEarth 地球范围偏食终, partial eclipse ends on Earth.
PartialEndOnEarth time.Time
// CentralBeginOnEarth 地球范围中心食始, central eclipse begins on Earth.
CentralBeginOnEarth time.Time
// CentralEndOnEarth 地球范围中心食终, central eclipse ends on Earth.
CentralEndOnEarth time.Time
// Magnitude 全局食分, global eclipse magnitude.
Magnitude float64
// Gamma 食甚时影轴到地心的距离, gamma at greatest eclipse.
Gamma float64
// PathWidthKM 食甚处中心食带宽度, central path width at greatest eclipse.
PathWidthKM float64
// GreatestLongitude 食甚点经度,东正西负, longitude of greatest eclipse, east positive.
GreatestLongitude float64
// GreatestLatitude 食甚点纬度,北正南负, latitude of greatest eclipse, north positive.
GreatestLatitude float64
// HasPartial 存在偏食阶段, has partial phase.
HasPartial bool
// HasCentral 存在中心食阶段, has central phase.
HasCentral bool
// HasAnnular 存在环食阶段, has annular phase.
HasAnnular bool
// HasTotal 存在全食阶段, has total phase.
HasTotal bool
// HasHybrid 为混合食, is hybrid eclipse.
HasHybrid bool
}
// SolarEclipseOnDate 当地自然日全局日食查询 / local-date global solar eclipse query.
// Determine whether a global solar eclipse overlaps the local date, using NASA bulletin Split-K by default.
func SolarEclipseOnDate(date time.Time) (SolarEclipseInfo, bool) {
return SolarEclipseOnDateNASABulletinSplitK(date)
}
// SolarEclipseOnDateNASABulletinSplitK 当地自然日全局日食查询(NASA bulletin Split-K / local-date global solar eclipse query with NASA bulletin Split-K.
// Determine whether a global solar eclipse overlaps the local date with the NASA bulletin Split-K model.
func SolarEclipseOnDateNASABulletinSplitK(date time.Time) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, basic.SolarEclipseNASABulletinSplitK)
}
// SolarEclipseOnDateIAUSingleK 当地自然日全局日食查询(IAU Single-K / local-date global solar eclipse query with IAU Single-K.
// Determine whether a global solar eclipse overlaps the local date with the IAU Single-K model.
func SolarEclipseOnDateIAUSingleK(date time.Time) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, basic.SolarEclipseIAUSingleK)
}
func solarEclipseOnDate(date time.Time, calculator solarEclipseCalculator) (SolarEclipseInfo, bool) {
location := date.Location()
dayStart, dayMid, dayEnd := solarEclipseLocalDayBounds(date)
candidateTT := basic.CalcMoonSHByJDE(solarEclipseTimeToTTJDE(dayMid), 0)
result := calculator(candidateTT)
if result.Type == basic.SolarEclipseNone {
return SolarEclipseInfo{}, false
}
info := solarEclipseInfoFromBasic(result, location)
if !solarEclipseOverlapsDate(info, dayStart, dayEnd) {
return SolarEclipseInfo{}, false
}
return info, true
}
// LastSolarEclipse 上次日食 / previous solar eclipse.
// Previous solar eclipse, using NASA bulletin Split-K by default.
func LastSolarEclipse(date time.Time) SolarEclipseInfo {
return LastSolarEclipseNASABulletinSplitK(date)
}
// LastSolarEclipseNASABulletinSplitK 上次日食(NASA bulletin Split-K / previous solar eclipse with NASA bulletin Split-K.
// Previous solar eclipse with the NASA bulletin Split-K model.
func LastSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, basic.SolarEclipseNASABulletinSplitK)
return info
}
// LastSolarEclipseIAUSingleK 上次日食(IAU Single-K / previous solar eclipse with IAU Single-K.
// Previous solar eclipse with the IAU Single-K model.
func LastSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, basic.SolarEclipseIAUSingleK)
return info
}
// NextSolarEclipse 下次日食 / next solar eclipse.
// Next solar eclipse, using NASA bulletin Split-K by default.
func NextSolarEclipse(date time.Time) SolarEclipseInfo {
return NextSolarEclipseNASABulletinSplitK(date)
}
// NextSolarEclipseNASABulletinSplitK 下次日食(NASA bulletin Split-K / next solar eclipse with NASA bulletin Split-K.
// Next solar eclipse with the NASA bulletin Split-K model.
func NextSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, basic.SolarEclipseNASABulletinSplitK)
return info
}
// NextSolarEclipseIAUSingleK 下次日食(IAU Single-K / next solar eclipse with IAU Single-K.
// Next solar eclipse with the IAU Single-K model.
func NextSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, basic.SolarEclipseIAUSingleK)
return info
}
// ClosestSolarEclipse 最近一次日食 / closest solar eclipse.
// Closest solar eclipse, using NASA bulletin Split-K by default.
func ClosestSolarEclipse(date time.Time) SolarEclipseInfo {
return ClosestSolarEclipseNASABulletinSplitK(date)
}
// ClosestSolarEclipseNASABulletinSplitK 最近一次日食(NASA bulletin Split-K / closest solar eclipse with NASA bulletin Split-K.
// Closest solar eclipse with the NASA bulletin Split-K model.
func ClosestSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, basic.SolarEclipseNASABulletinSplitK)
next, hasNext := searchSolarEclipse(date, 1, false, basic.SolarEclipseNASABulletinSplitK)
return closestSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestSolarEclipseIAUSingleK 最近一次日食(IAU Single-K / closest solar eclipse with IAU Single-K.
// Closest solar eclipse with the IAU Single-K model.
func ClosestSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, basic.SolarEclipseIAUSingleK)
next, hasNext := searchSolarEclipse(date, 1, false, basic.SolarEclipseIAUSingleK)
return closestSolarEclipse(date, last, hasLast, next, hasNext)
}
func closestSolarEclipse(
date time.Time,
last SolarEclipseInfo,
hasLast bool,
next SolarEclipseInfo,
hasNext bool,
) SolarEclipseInfo {
switch {
case hasLast && !hasNext:
return last
case !hasLast && hasNext:
return next
case !hasLast && !hasNext:
return SolarEclipseInfo{}
}
lastDistance := math.Abs(date.Sub(last.GreatestEclipse).Seconds())
nextDistance := math.Abs(next.GreatestEclipse.Sub(date).Seconds())
if lastDistance <= nextDistance {
return last
}
return next
}
func searchSolarEclipse(
date time.Time,
direction int,
includeCurrent bool,
calculator solarEclipseCalculator,
) (SolarEclipseInfo, bool) {
targetTT := solarEclipseTimeToTTJDE(date)
candidateTT := basic.CalcMoonSHByJDE(targetTT, 0)
for i := 0; i < solarEclipseSearchLimit; i++ {
result := calculator(candidateTT)
if result.Type != basic.SolarEclipseNone && solarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) {
return solarEclipseInfoFromBasic(result, date.Location()), true
}
candidateTT = basic.CalcMoonSHByJDE(candidateTT+float64(direction)*solarEclipseSynodicMonthDays, 0)
}
return SolarEclipseInfo{}, false
}
func solarEclipseMatchesDirection(greatestTT, targetTT float64, direction int, includeCurrent bool) bool {
delta := greatestTT - targetTT
if math.Abs(delta) <= solarEclipseSearchEpsilonDay {
return direction < 0 && includeCurrent
}
if direction > 0 {
return delta > 0
}
return delta < 0
}
func solarEclipseLocalDayBounds(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 nextSolarEclipseLocalDayStart(dayStart time.Time) time.Time {
location := dayStart.Location()
return time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day()+1, 0, 0, 0, 0, location)
}
func solarEclipseInfoFromBasic(result basic.SolarEclipseResult, location *time.Location) SolarEclipseInfo {
saros, hasSaros := solarSarosInfo(result.GreatestEclipse)
return SolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
Type: mapBasicSolarEclipseType(result.Type),
Centrality: mapBasicSolarEclipseCentrality(result.Centrality),
HasSaros: hasSaros,
Saros: saros,
GreatestEclipse: solarEclipseTTJDEToTime(result.GreatestEclipse, location),
PartialBeginOnEarth: solarEclipseTTJDEToTime(result.PartialBeginOnEarth, location),
PartialEndOnEarth: solarEclipseTTJDEToTime(result.PartialEndOnEarth, location),
CentralBeginOnEarth: solarEclipseTTJDEToTime(result.CentralBeginOnEarth, location),
CentralEndOnEarth: solarEclipseTTJDEToTime(result.CentralEndOnEarth, location),
Magnitude: result.Magnitude,
Gamma: result.Gamma,
PathWidthKM: result.PathWidthKM,
GreatestLongitude: result.GreatestLongitude,
GreatestLatitude: result.GreatestLatitude,
HasPartial: result.HasPartial,
HasCentral: result.HasCentral,
HasAnnular: result.HasAnnular,
HasTotal: result.HasTotal,
HasHybrid: result.HasHybrid,
}
}
func mapBasicSolarEclipseModel(model basic.SolarEclipseRadiusModel) SolarEclipseRadiusModel {
switch model {
case basic.SolarEclipseModelIAUSingleK:
return SolarEclipseModelIAUSingleK
default:
return SolarEclipseModelNASABulletinSplitK
}
}
func mapBasicSolarEclipseType(eclipseType basic.SolarEclipseType) SolarEclipseType {
switch eclipseType {
case basic.SolarEclipsePartial:
return SolarEclipsePartial
case basic.SolarEclipseAnnular:
return SolarEclipseAnnular
case basic.SolarEclipseTotal:
return SolarEclipseTotal
case basic.SolarEclipseHybrid:
return SolarEclipseHybrid
default:
return SolarEclipseNone
}
}
func mapBasicSolarEclipseCentrality(centrality basic.SolarEclipseCentrality) SolarEclipseCentrality {
switch centrality {
case basic.SolarEclipseCentralOneLimit:
return SolarEclipseCentralOneLimit
case basic.SolarEclipseCentralTwoLimits:
return SolarEclipseCentralTwoLimits
default:
return SolarEclipseNonCentral
}
}
func solarEclipseOverlapsDate(info SolarEclipseInfo, dayStart, dayEnd time.Time) bool {
eventStart, eventEnd, ok := solarEclipseRange(info)
if !ok {
return false
}
return !eventEnd.Before(dayStart) && eventStart.Before(dayEnd)
}
func solarEclipseRange(info SolarEclipseInfo) (time.Time, time.Time, bool) {
if !info.HasPartial {
return time.Time{}, time.Time{}, false
}
return info.PartialBeginOnEarth, info.PartialEndOnEarth, true
}
func solarEclipseTTJDEToTime(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 solarEclipseTimeToTTJDE(date time.Time) float64 {
utcJDE := basic.Date2JDE(date.UTC())
return basic.TD2UT(utcJDE, true)
}
+582
View File
@@ -0,0 +1,582 @@
package eclipse
import (
"math"
"time"
"b612.me/astro/basic"
)
const (
localSolarEclipseSynodicMonthDays = 29.530588853
localSolarEclipseSearchLimit = 6000
localSolarEclipseSearchEpsilonDay = 1e-8
localSolarEclipseLatitudeLimitDeg = 2.0
)
type localSolarEclipseCalculator struct {
global func(float64) basic.SolarEclipseResult
local func(float64, float64, float64, float64) basic.LocalSolarEclipseResult
}
type localSolarEclipseQueryMode int
const (
localSolarEclipseQueryVisible localSolarEclipseQueryMode = iota
localSolarEclipseQueryGeometric
)
// LocalSolarEclipseContactPoint 表示站心日食在日面上的接触点方位。
// LocalSolarEclipseContactPoint describes a local solar eclipse contact point on the Sun limb.
type LocalSolarEclipseContactPoint struct {
// Label 是接触标签,如 C1/C2/C3/C4。
// Label is the contact label, such as C1/C2/C3/C4.
Label string
// Time 是该接触时刻,保持用户输入时区。
// Time is the contact time, preserving the input timezone.
Time time.Time
// ContactPositionAngle 是日面接触点位置角,从天球北点起向东量,单位度。
// ContactPositionAngle is the Sun-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 Sun center, in degrees.
MoonCenterPositionAngle float64
}
// LocalSolarEclipseInfo 站心日食信息, local solar eclipse information.
//
// 所有时刻字段都保持用户输入的时区。
// 不存在的阶段使用零值 time.Time。
type LocalSolarEclipseInfo struct {
// Model 日食月亮半径模型, eclipse lunar radius model.
Model SolarEclipseRadiusModel
// Type 站心食型, local eclipse type.
Type SolarEclipseType
// 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
// Longitude 观测点经度,东正西负, observer longitude, east positive.
Longitude float64
// Latitude 观测点纬度,北正南负, observer latitude, north positive.
Latitude float64
// Height 观测点海拔高度,单位米, observer height in meters.
Height float64
// GreatestEclipse 食甚时刻, greatest eclipse.
GreatestEclipse time.Time
// PartialStart 偏食始 / 初亏, partial eclipse begins.
PartialStart time.Time
// PartialEnd 偏食终 / 复圆, partial eclipse ends.
PartialEnd time.Time
// CentralStart 中心食始;对全食为食既,对环食为环食始, central eclipse begins.
CentralStart time.Time
// CentralEnd 中心食终;对全食为生光,对环食为环食终, central eclipse ends.
CentralEnd time.Time
// Magnitude 站心食分, local eclipse magnitude.
Magnitude float64
// Obscuration 食甚时太阳视圆面遮蔽率, obscuration at greatest eclipse.
Obscuration float64
// Separation 食甚时日月中心角距,单位度, center separation at greatest eclipse in degrees.
Separation float64
// SunAltitude 食甚时太阳高度角,单位度, Sun altitude at greatest eclipse in degrees.
SunAltitude float64
// SunAzimuth 食甚时太阳方位角,单位度, Sun azimuth at greatest eclipse in degrees.
SunAzimuth float64
// VisibleAtGreatest 食甚时太阳中心在地平线上方, Sun center above horizon at greatest eclipse.
VisibleAtGreatest bool
// ContactPoints 是各接触时刻在日面上的接触点方位。
// ContactPoints are Sun-limb contact position angles at eclipse contacts.
ContactPoints []LocalSolarEclipseContactPoint
// HasPartial 存在偏食阶段, has partial phase.
HasPartial bool
// HasCentral 存在中心食阶段, has central phase.
HasCentral bool
// HasAnnular 存在环食阶段, has annular phase.
HasAnnular bool
// HasTotal 存在全食阶段, has total phase.
HasTotal bool
}
// LocalSolarEclipseOnDate 当地站心日食查询 / local topocentric solar eclipse query.
// Determine whether a visible local solar eclipse overlaps the local date, using NASA bulletin Split-K by default.
func LocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return LocalSolarEclipseOnDateNASABulletinSplitK(date, lon, lat, height)
}
// LocalSolarEclipseOnDateNASABulletinSplitK 当地站心日食查询(NASA bulletin Split-K / local topocentric solar eclipse query with NASA bulletin Split-K.
// Determine whether a visible local solar eclipse overlaps the local date with the NASA bulletin Split-K model.
func LocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
}
// LocalSolarEclipseOnDateIAUSingleK 当地站心日食查询(IAU Single-K / local topocentric solar eclipse query with IAU Single-K.
// Determine whether a visible local solar eclipse overlaps the local date with the IAU Single-K model.
func LocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
}
// GeometricLocalSolarEclipseOnDate 当地站心几何日食查询 / local geometric solar eclipse query.
// Determine whether a geometric local solar eclipse overlaps the local date, using NASA bulletin Split-K by default.
func GeometricLocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date, lon, lat, height)
}
// GeometricLocalSolarEclipseOnDateNASABulletinSplitK 当地站心几何日食查询(NASA bulletin Split-K / local geometric solar eclipse query with NASA bulletin Split-K.
// Determine whether a geometric local solar eclipse overlaps the local date with the NASA bulletin Split-K model.
func GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
}
// GeometricLocalSolarEclipseOnDateIAUSingleK 当地站心几何日食查询(IAU Single-K / local geometric solar eclipse query with IAU Single-K.
// Determine whether a geometric local solar eclipse overlaps the local date with the IAU Single-K model.
func GeometricLocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
}
func localSolarEclipseOnDate(
date time.Time,
lon, lat, height float64,
calculator localSolarEclipseCalculator,
mode localSolarEclipseQueryMode,
) (LocalSolarEclipseInfo, bool) {
location := date.Location()
dayStart, dayMid, dayEnd := solarEclipseLocalDayBounds(date)
candidateTT := basic.CalcMoonSHByJDE(solarEclipseTimeToTTJDE(dayMid), 0)
if !isPotentialLocalSolarEclipse(candidateTT) {
return LocalSolarEclipseInfo{}, false
}
globalResult := calculator.global(candidateTT)
if globalResult.Type == basic.SolarEclipseNone {
return LocalSolarEclipseInfo{}, false
}
result := calculator.local(globalResult.GreatestEclipse, lon, lat, height)
if result.Type == basic.SolarEclipseNone {
return LocalSolarEclipseInfo{}, false
}
info := localSolarEclipseInfoFromBasic(result, lon, lat, height, location)
if !localSolarEclipseOverlapsDate(info, dayStart, dayEnd) {
return LocalSolarEclipseInfo{}, false
}
if mode == localSolarEclipseQueryVisible && !localSolarEclipseVisibleOnDate(info, dayStart, dayEnd) {
return LocalSolarEclipseInfo{}, false
}
return info, true
}
// LastLocalSolarEclipse 上次站心日食 / previous local solar eclipse.
// Previous visible local solar eclipse, using NASA bulletin Split-K by default.
func LastLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return LastLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// LastLocalSolarEclipseNASABulletinSplitK 上次站心日食(NASA bulletin Split-K / previous local solar eclipse with NASA bulletin Split-K.
// Previous visible local solar eclipse with the NASA bulletin Split-K model.
func LastLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
return info
}
// LastLocalSolarEclipseIAUSingleK 上次站心日食(IAU Single-K / previous local solar eclipse with IAU Single-K.
// Previous visible local solar eclipse with the IAU Single-K model.
func LastLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
return info
}
// LastGeometricLocalSolarEclipse 上次站心几何日食 / previous geometric local solar eclipse.
// Previous geometric local solar eclipse, using NASA bulletin Split-K by default.
func LastGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return LastGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// LastGeometricLocalSolarEclipseNASABulletinSplitK 上次站心几何日食(NASA bulletin Split-K / previous geometric local solar eclipse with NASA bulletin Split-K.
// Previous geometric local solar eclipse with the NASA bulletin Split-K model.
func LastGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
return info
}
// LastGeometricLocalSolarEclipseIAUSingleK 上次站心几何日食(IAU Single-K / previous geometric local solar eclipse with IAU Single-K.
// Previous geometric local solar eclipse with the IAU Single-K model.
func LastGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
return info
}
// NextLocalSolarEclipse 下次站心日食 / next local solar eclipse.
// Next visible local solar eclipse, using NASA bulletin Split-K by default.
func NextLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return NextLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// NextLocalSolarEclipseNASABulletinSplitK 下次站心日食(NASA bulletin Split-K / next local solar eclipse with NASA bulletin Split-K.
// Next visible local solar eclipse with the NASA bulletin Split-K model.
func NextLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
return info
}
// NextLocalSolarEclipseIAUSingleK 下次站心日食(IAU Single-K / next local solar eclipse with IAU Single-K.
// Next visible local solar eclipse with the IAU Single-K model.
func NextLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
return info
}
// NextGeometricLocalSolarEclipse 下次站心几何日食 / next geometric local solar eclipse.
// Next geometric local solar eclipse, using NASA bulletin Split-K by default.
func NextGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return NextGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// NextGeometricLocalSolarEclipseNASABulletinSplitK 下次站心几何日食(NASA bulletin Split-K / next geometric local solar eclipse with NASA bulletin Split-K.
// Next geometric local solar eclipse with the NASA bulletin Split-K model.
func NextGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
return info
}
// NextGeometricLocalSolarEclipseIAUSingleK 下次站心几何日食(IAU Single-K / next geometric local solar eclipse with IAU Single-K.
// Next geometric local solar eclipse with the IAU Single-K model.
func NextGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
return info
}
// ClosestLocalSolarEclipse 最近一次站心日食 / closest local solar eclipse.
// Closest visible local solar eclipse, using NASA bulletin Split-K by default.
func ClosestLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return ClosestLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// ClosestLocalSolarEclipseNASABulletinSplitK 最近一次站心日食(NASA bulletin Split-K / closest local solar eclipse with NASA bulletin Split-K.
// Closest visible local solar eclipse with the NASA bulletin Split-K model.
func ClosestLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestLocalSolarEclipseIAUSingleK 最近一次站心日食(IAU Single-K / closest local solar eclipse with IAU Single-K.
// Closest visible local solar eclipse with the IAU Single-K model.
func ClosestLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestGeometricLocalSolarEclipse 最近一次站心几何日食 / closest geometric local solar eclipse.
// Closest geometric local solar eclipse, using NASA bulletin Split-K by default.
func ClosestGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// ClosestGeometricLocalSolarEclipseNASABulletinSplitK 最近一次站心几何日食(NASA bulletin Split-K / closest geometric local solar eclipse with NASA bulletin Split-K.
// Closest geometric local solar eclipse with the NASA bulletin Split-K model.
func ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestGeometricLocalSolarEclipseIAUSingleK 最近一次站心几何日食(IAU Single-K / closest geometric local solar eclipse with IAU Single-K.
// Closest geometric local solar eclipse with the IAU Single-K model.
func ClosestGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
func closestLocalSolarEclipse(
date time.Time,
last LocalSolarEclipseInfo,
hasLast bool,
next LocalSolarEclipseInfo,
hasNext bool,
) LocalSolarEclipseInfo {
switch {
case hasLast && !hasNext:
return last
case !hasLast && hasNext:
return next
case !hasLast && !hasNext:
return LocalSolarEclipseInfo{}
}
lastDistance := math.Abs(date.Sub(last.GreatestEclipse).Seconds())
nextDistance := math.Abs(next.GreatestEclipse.Sub(date).Seconds())
if lastDistance <= nextDistance {
return last
}
return next
}
func searchLocalSolarEclipse(
date time.Time,
lon, lat, height float64,
direction int,
includeCurrent bool,
calculator localSolarEclipseCalculator,
mode localSolarEclipseQueryMode,
) (LocalSolarEclipseInfo, bool) {
targetTT := solarEclipseTimeToTTJDE(date)
candidateTT := basic.CalcMoonSHByJDE(targetTT, 0)
for i := 0; i < localSolarEclipseSearchLimit; i++ {
if isPotentialLocalSolarEclipse(candidateTT) {
globalResult := calculator.global(candidateTT)
if globalResult.Type != basic.SolarEclipseNone {
result := calculator.local(globalResult.GreatestEclipse, lon, lat, height)
if result.Type != basic.SolarEclipseNone {
info := localSolarEclipseInfoFromBasic(result, lon, lat, height, date.Location())
if (mode != localSolarEclipseQueryVisible || localSolarEclipseVisible(info)) &&
localSolarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) {
return info, true
}
}
}
}
candidateTT = basic.CalcMoonSHByJDE(candidateTT+float64(direction)*localSolarEclipseSynodicMonthDays, 0)
}
return LocalSolarEclipseInfo{}, false
}
func isPotentialLocalSolarEclipse(newMoonTT float64) bool {
return math.Abs(basic.HMoonTrueBo(newMoonTT)) <= localSolarEclipseLatitudeLimitDeg
}
func localSolarEclipseMatchesDirection(greatestTT, targetTT float64, direction int, includeCurrent bool) bool {
delta := greatestTT - targetTT
if math.Abs(delta) <= localSolarEclipseSearchEpsilonDay {
return direction < 0 && includeCurrent
}
if direction > 0 {
return delta > 0
}
return delta < 0
}
func localSolarEclipseInfoFromBasic(
result basic.LocalSolarEclipseResult,
lon, lat, height float64,
location *time.Location,
) LocalSolarEclipseInfo {
info := localSolarEclipseInfoFieldsFromBasic(result, lon, lat, height, location)
info.ContactPoints = localSolarEclipseContactPointsFromBasic(result, lon, lat, height, location)
return info
}
func localSolarEclipseInfoFromDiagram(
diagram basic.LocalSolarEclipseDiagramResult,
lon, lat, height float64,
location *time.Location,
) LocalSolarEclipseInfo {
info := localSolarEclipseInfoFieldsFromBasic(diagram.Eclipse, lon, lat, height, location)
info.ContactPoints = localSolarEclipseContactPointsFromFrames(diagram.Frames, location)
return info
}
func localSolarEclipseInfoFieldsFromBasic(
result basic.LocalSolarEclipseResult,
lon, lat, height float64,
location *time.Location,
) LocalSolarEclipseInfo {
visibleThreshold := localSolarEclipseVisibilityThreshold(height, lat)
saros, hasSaros := solarSarosInfo(result.GreatestEclipse)
return LocalSolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
Type: mapBasicSolarEclipseType(result.Type),
HasSaros: hasSaros,
Saros: saros,
Longitude: lon,
Latitude: lat,
Height: height,
GreatestEclipse: solarEclipseTTJDEToTime(result.GreatestEclipse, location),
PartialStart: solarEclipseTTJDEToTime(result.PartialStart, location),
PartialEnd: solarEclipseTTJDEToTime(result.PartialEnd, location),
CentralStart: solarEclipseTTJDEToTime(result.CentralStart, location),
CentralEnd: solarEclipseTTJDEToTime(result.CentralEnd, location),
Magnitude: result.Magnitude,
Obscuration: result.Obscuration,
Separation: result.Separation,
SunAltitude: result.SunAltitude,
SunAzimuth: result.SunAzimuth,
VisibleAtGreatest: result.SunAltitude > visibleThreshold,
HasPartial: result.HasPartial,
HasCentral: result.HasCentral,
HasAnnular: result.HasAnnular,
HasTotal: result.HasTotal,
}
}
func localSolarEclipseContactPointsFromBasic(
result basic.LocalSolarEclipseResult,
lon, lat, height float64,
location *time.Location,
) []LocalSolarEclipseContactPoint {
if !result.HasPartial {
return nil
}
options := basic.LocalSolarEclipseDiagramOptions{StepDays: 1}
var diagram basic.LocalSolarEclipseDiagramResult
if result.Model == basic.SolarEclipseModelIAUSingleK {
diagram = basic.LocalSolarEclipseDiagramIAUSingleK(result.GreatestEclipse, lon, lat, height, options)
} else {
diagram = basic.LocalSolarEclipseDiagramNASABulletinSplitK(result.GreatestEclipse, lon, lat, height, options)
}
return localSolarEclipseContactPointsFromFrames(diagram.Frames, location)
}
func localSolarEclipseContactPointsFromFrames(
frames []basic.LocalSolarEclipseDiagramFrame,
location *time.Location,
) []LocalSolarEclipseContactPoint {
contacts := make([]LocalSolarEclipseContactPoint, 0, 4)
for _, frame := range frames {
for _, label := range localSolarEclipseFrameLabels(frame) {
switch label {
case "C1", "C2", "C3", "C4":
contactPA := frame.PositionAngle
if (label == "C2" || label == "C3") && frame.MoonRadius >= frame.SunRadius {
contactPA = normalizeSolarEclipseDegree360(contactPA + 180)
}
contacts = append(contacts, LocalSolarEclipseContactPoint{
Label: label,
Time: solarEclipseTTJDEToTime(frame.JDE, location),
ContactPositionAngle: contactPA,
ContactClockwiseAngle: normalizeSolarEclipseDegree360(360 - contactPA),
MoonCenterPositionAngle: frame.PositionAngle,
})
}
}
}
return contacts
}
func localSolarEclipseFrameLabels(frame basic.LocalSolarEclipseDiagramFrame) []string {
if len(frame.Labels) > 0 {
return frame.Labels
}
if frame.Label == "" {
return nil
}
return []string{frame.Label}
}
func localSolarEclipseOverlapsDate(info LocalSolarEclipseInfo, dayStart, dayEnd time.Time) bool {
eventStart, eventEnd, ok := localSolarEclipseRange(info)
if !ok {
return false
}
return !eventEnd.Before(dayStart) && eventStart.Before(dayEnd)
}
func localSolarEclipseRange(info LocalSolarEclipseInfo) (time.Time, time.Time, bool) {
if !info.HasPartial {
return time.Time{}, time.Time{}, false
}
return info.PartialStart, info.PartialEnd, true
}
func localSolarEclipseVisible(info LocalSolarEclipseInfo) bool {
eventStart, eventEnd, ok := localSolarEclipseRange(info)
if !ok {
return false
}
return localSolarEclipseVisibleDuring(info, eventStart, eventEnd)
}
func localSolarEclipseVisibleOnDate(info LocalSolarEclipseInfo, dayStart, dayEnd time.Time) bool {
eventStart, eventEnd, ok := localSolarEclipseRange(info)
if !ok {
return false
}
segmentStart := maxLocalSolarTime(eventStart, dayStart)
segmentEnd := minLocalSolarTime(eventEnd, dayEnd)
if !segmentStart.Before(segmentEnd) {
return false
}
return localSolarEclipseVisibleDuring(info, segmentStart, segmentEnd)
}
func localSolarEclipseVisibleDuring(info LocalSolarEclipseInfo, start, end time.Time) bool {
if !start.Before(end) && !start.Equal(end) {
return false
}
if localSolarEclipseAltitudeVisible(start, info) || localSolarEclipseAltitudeVisible(end, info) {
return true
}
for dayStart, _, _ := solarEclipseLocalDayBounds(start); !dayStart.After(end); dayStart = nextSolarEclipseLocalDayStart(dayStart) {
_, culminationSeed, _ := solarEclipseLocalDayBounds(dayStart)
culmination := solarCulminationTime(culminationSeed, info.Longitude)
if culmination.Before(start) || culmination.After(end) {
continue
}
if localSolarEclipseAltitudeVisible(culmination, info) {
return true
}
}
return false
}
func localSolarEclipseAltitudeVisible(date time.Time, info LocalSolarEclipseInfo) bool {
return solarAltitude(date, info.Longitude, info.Latitude) > localSolarEclipseVisibilityThreshold(info.Height, info.Latitude)
}
func localSolarEclipseVisibilityThreshold(height, latitude float64) float64 {
if height <= 0 {
return 0
}
return -basic.HeightDegreeByLat(height, latitude)
}
func normalizeSolarEclipseDegree360(angle float64) float64 {
angle = math.Mod(angle, 360)
if angle < 0 {
angle += 360
}
return angle
}
func maxLocalSolarTime(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
func minLocalSolarTime(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}
var (
localSolarEclipseNASABulletinSplitK = localSolarEclipseCalculator{
global: basic.SolarEclipseNASABulletinSplitK,
local: basic.LocalSolarEclipseNASABulletinSplitK,
}
localSolarEclipseIAUSingleK = localSolarEclipseCalculator{
global: basic.SolarEclipseIAUSingleK,
local: basic.LocalSolarEclipseIAUSingleK,
}
)
+356
View File
@@ -0,0 +1,356 @@
package eclipse
import (
"testing"
"time"
"b612.me/astro/basic"
)
func TestLocalSolarEclipseOnDateByLocalDay(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
testCases := []struct {
name string
date time.Time
want bool
}{
{
name: "day before no eclipse",
date: time.Date(2024, 4, 7, 12, 0, 0, 0, loc),
want: false,
},
{
name: "local event day overlaps",
date: time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
want: true,
},
{
name: "day after no eclipse",
date: time.Date(2024, 4, 9, 12, 0, 0, 0, loc),
want: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info, ok := LocalSolarEclipseOnDate(tc.date, lon, lat, height)
if ok != tc.want {
t.Fatalf("LocalSolarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
}
if !ok {
return
}
if info.Type != SolarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipsePartial)
}
if info.GreatestEclipse.Location() != loc {
t.Fatalf("greatest eclipse location mismatch: got %q want %q", info.GreatestEclipse.Location(), loc)
}
if info.PartialStart.Day() != 8 || info.PartialEnd.Day() != 8 {
t.Fatalf("unexpected local date span: begin=%v end=%v", info.PartialStart, info.PartialEnd)
}
})
}
}
func TestLocalSolarEclipseVisibilityFilter(t *testing.T) {
loc := time.FixedZone("JST", 9*3600)
date := time.Date(2024, 4, 9, 12, 0, 0, 0, loc)
lon, lat, height := 139.6917, 35.6895, 0.0
geometricInfo, geometricOK := GeometricLocalSolarEclipseOnDate(date, lon, lat, height)
if !geometricOK {
t.Fatalf("expected geometric local eclipse on date")
}
if geometricInfo.Type != SolarEclipsePartial {
t.Fatalf("unexpected geometric eclipse type: got %s want %s", geometricInfo.Type, SolarEclipsePartial)
}
if geometricInfo.VisibleAtGreatest {
t.Fatalf("expected geometric eclipse to be below horizon at greatest: %+v", geometricInfo)
}
visibleInfo, visibleOK := LocalSolarEclipseOnDate(date, lon, lat, height)
if visibleOK {
t.Fatalf("expected visible filter to reject invisible eclipse, got %+v", visibleInfo)
}
}
func TestLocalSolarEclipseSearchSemantics(t *testing.T) {
loc := time.UTC
lon, lat, height := -0.1278, 51.5074, 0.0
current := ClosestLocalSolarEclipseNASABulletinSplitK(time.Date(2025, 3, 29, 12, 0, 0, 0, loc), lon, lat, height)
if current.Type != SolarEclipsePartial {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, SolarEclipsePartial)
}
assertSameLocalSolarEclipse(
t,
"ClosestLocalSolarEclipse(default)",
ClosestLocalSolarEclipse(current.GreatestEclipse, lon, lat, height),
current,
time.Second,
)
last := LastLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
assertSameLocalSolarEclipse(t, "LastLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", last, current, time.Second)
closest := ClosestLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
assertSameLocalSolarEclipse(t, "ClosestLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", closest, current, time.Second)
next := NextLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
if !next.GreatestEclipse.After(current.GreatestEclipse) {
t.Fatalf("NextLocalSolarEclipseNASABulletinSplitK should be strictly future: current=%v next=%v", current.GreatestEclipse, next.GreatestEclipse)
}
if next.Type != SolarEclipsePartial {
t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, SolarEclipsePartial)
}
assertSolarTimeClose(t, "NextLocalSolarEclipseNASABulletinSplitK", next.GreatestEclipse, time.Date(2026, 8, 12, 18, 13, 21, 0, time.UTC), 2*time.Minute)
}
func TestLocalSolarEclipseSearchSkipsInvisibleCurrentCandidate(t *testing.T) {
loc := time.FixedZone("JST", 9*3600)
lon, lat, height := 139.6917, 35.6895, 0.0
current, ok := GeometricLocalSolarEclipseOnDate(time.Date(2024, 4, 9, 12, 0, 0, 0, loc), lon, lat, height)
if !ok {
t.Fatalf("expected geometric local eclipse on date")
}
if current.VisibleAtGreatest {
t.Fatalf("expected current geometric eclipse to be below horizon: %+v", current)
}
next := NextLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
if next.Type == SolarEclipseNone || next.GreatestEclipse.IsZero() {
t.Fatalf("expected search to skip the invisible current candidate and find a future visible eclipse")
}
if !next.GreatestEclipse.After(current.GreatestEclipse) {
t.Fatalf("expected strictly future local solar eclipse: current=%v next=%v", current.GreatestEclipse, next.GreatestEclipse)
}
}
func TestLocalSolarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
lon, lat, height := -104.1, 25.3, 1234.0
testCases := []struct {
name string
calc func(time.Time, float64, float64, float64) LocalSolarEclipseInfo
}{
{name: "nasa", calc: ClosestLocalSolarEclipseNASABulletinSplitK},
{name: "iau", calc: ClosestLocalSolarEclipseIAUSingleK},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := tc.calc(time.Date(2024, 4, 8, 12, 0, 0, 0, loc), lon, lat, height)
if info.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseTotal)
}
if info.Longitude != lon || info.Latitude != lat || info.Height != height {
t.Fatalf("observer metadata mismatch: got (%f,%f,%f)", info.Longitude, info.Latitude, info.Height)
}
for _, item := range []struct {
name string
tm time.Time
}{
{name: "GreatestEclipse", tm: info.GreatestEclipse},
{name: "PartialStart", tm: info.PartialStart},
{name: "PartialEnd", tm: info.PartialEnd},
{name: "CentralStart", tm: info.CentralStart},
{name: "CentralEnd", tm: info.CentralEnd},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
}
}
for _, point := range info.ContactPoints {
if point.Time.Location() != loc {
t.Fatalf("contact %s location mismatch: got %q want %q", point.Label, point.Time.Location(), loc)
}
}
})
}
}
func TestLocalSolarEclipseContactPoints(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
info := ClosestLocalSolarEclipse(
time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
-96.7970,
32.7767,
0,
)
if info.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseTotal)
}
if got, want := len(info.ContactPoints), 4; got != want {
t.Fatalf("contact point count = %d, want %d", got, want)
}
points := make(map[string]LocalSolarEclipseContactPoint, len(info.ContactPoints))
for _, point := range info.ContactPoints {
points[point.Label] = point
}
assertSolarFloatClose(t, "C1.ContactPositionAngle", points["C1"].ContactPositionAngle, 226.219228, 1e-3)
assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, 19.137089, 1e-3)
assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, 199.137089, 1e-3)
assertSolarFloatClose(t, "C4.ContactClockwiseAngle", points["C4"].ContactClockwiseAngle, 310.781438, 1e-3)
}
func TestLocalSolarEclipseContactPointsFromMergedFrameLabels(t *testing.T) {
frames := []basic.LocalSolarEclipseDiagramFrame{
{
JDE: 2460409.25,
SunRadius: 1,
MoonRadius: 1.05,
PositionAngle: 199.137089,
Label: "Greatest",
Labels: []string{"C2", "Greatest", "C3"},
},
}
points := localSolarEclipseContactPointsFromFrames(frames, time.UTC)
if got, want := len(points), 2; got != want {
t.Fatalf("contact point count = %d, want %d", got, want)
}
if points[0].Label != "C2" || points[1].Label != "C3" {
t.Fatalf("labels = %#v, want [C2 C3]", []string{points[0].Label, points[1].Label})
}
assertSolarFloatClose(t, "C2.ContactPositionAngle", points[0].ContactPositionAngle, 19.137089, 1e-6)
assertSolarFloatClose(t, "C3.ContactPositionAngle", points[1].ContactPositionAngle, 19.137089, 1e-6)
}
func TestLocalSolarEclipseIAUSingleKRemainsAvailable(t *testing.T) {
date := time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)
lon, lat, height := -104.1, 25.3, 0.0
defaultInfo := ClosestLocalSolarEclipse(date, lon, lat, height)
iauInfo := ClosestLocalSolarEclipseIAUSingleK(date, lon, lat, height)
if defaultInfo.Type != SolarEclipseTotal || iauInfo.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse types: default=%s iau=%s", defaultInfo.Type, iauInfo.Type)
}
assertSolarTimeClose(t, "GreatestEclipse", iauInfo.GreatestEclipse, defaultInfo.GreatestEclipse, time.Second)
if !(iauInfo.CentralEnd.Sub(iauInfo.CentralStart) > defaultInfo.CentralEnd.Sub(defaultInfo.CentralStart)) {
t.Fatalf("expected IAU central duration > NASA duration: iau=%v nasa=%v", iauInfo.CentralEnd.Sub(iauInfo.CentralStart), defaultInfo.CentralEnd.Sub(defaultInfo.CentralStart))
}
if !(iauInfo.Magnitude > defaultInfo.Magnitude) {
t.Fatalf("expected IAU magnitude > NASA magnitude: iau=%.9f nasa=%.9f", iauInfo.Magnitude, defaultInfo.Magnitude)
}
}
func TestLocalSolarEclipseAgainstNASABaseline(t *testing.T) {
chicagoLoc := time.FixedZone("CDT", -5*3600)
testCases := []struct {
name string
date time.Time
lon float64
lat float64
height float64
wantType SolarEclipseType
wantGreatest time.Time
wantPartialStart time.Time
wantPartialEnd time.Time
wantMagnitude float64
wantObscuration float64
wantSunAltitude float64
wantSunAzimuth float64
wantCentralDuration time.Duration
}{
{
name: "2024-04-08 chicago partial",
date: time.Date(2024, 4, 8, 12, 0, 0, 0, chicagoLoc),
lon: -87.65,
lat: 41.85,
height: 0,
wantType: SolarEclipsePartial,
wantGreatest: time.Date(2024, 4, 8, 14, 7, 0, 0, chicagoLoc),
wantPartialStart: time.Date(2024, 4, 8, 12, 51, 0, 0, chicagoLoc),
wantPartialEnd: time.Date(2024, 4, 8, 15, 22, 0, 0, chicagoLoc),
wantMagnitude: 0.942,
wantObscuration: 0.938,
wantSunAltitude: 52,
wantSunAzimuth: 211,
},
{
name: "2024-04-08 greatest total",
date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
lon: -104.1,
lat: 25.3,
height: 0,
wantType: SolarEclipseTotal,
wantGreatest: time.Date(2024, 4, 8, 18, 17, 15, 0, time.UTC),
wantPartialStart: time.Date(2024, 4, 8, 16, 58, 0, 0, time.UTC),
wantPartialEnd: time.Date(2024, 4, 8, 19, 40, 0, 0, time.UTC),
wantMagnitude: 1.0566,
wantObscuration: 1.0,
wantSunAltitude: 70,
wantSunAzimuth: 150,
wantCentralDuration: 4*time.Minute + 28*time.Second,
},
{
name: "2024-10-02 greatest annular",
date: time.Date(2024, 10, 2, 0, 0, 0, 0, time.UTC),
lon: -114.5,
lat: -22.0,
height: 0,
wantType: SolarEclipseAnnular,
wantGreatest: time.Date(2024, 10, 2, 18, 44, 59, 0, time.UTC),
wantPartialStart: time.Date(2024, 10, 2, 17, 3, 0, 0, time.UTC),
wantPartialEnd: time.Date(2024, 10, 2, 20, 33, 0, 0, time.UTC),
wantMagnitude: 0.9326,
wantObscuration: 0.871,
wantSunAltitude: 69,
wantSunAzimuth: 31,
wantCentralDuration: 7*time.Minute + 25*time.Second,
},
}
const (
partialTimeTolerance = 90 * time.Second
greatestTimeTolerance = 45 * time.Second
floatTolerance = 0.01
altitudeTolerance = 1.0
azimuthTolerance = 1.5
durationTolerance = 5 * time.Second
)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := ClosestLocalSolarEclipse(tc.date, tc.lon, tc.lat, tc.height)
if info.Type != tc.wantType {
t.Fatalf("type mismatch: got %s want %s", info.Type, tc.wantType)
}
assertSolarTimeClose(t, "GreatestEclipse", info.GreatestEclipse, tc.wantGreatest, greatestTimeTolerance)
assertSolarTimeClose(t, "PartialStart", info.PartialStart, tc.wantPartialStart, partialTimeTolerance)
assertSolarTimeClose(t, "PartialEnd", info.PartialEnd, tc.wantPartialEnd, partialTimeTolerance)
assertSolarFloatClose(t, "Magnitude", info.Magnitude, tc.wantMagnitude, floatTolerance)
assertSolarFloatClose(t, "Obscuration", info.Obscuration, tc.wantObscuration, floatTolerance)
assertSolarFloatClose(t, "SunAltitude", info.SunAltitude, tc.wantSunAltitude, altitudeTolerance)
assertSolarFloatClose(t, "SunAzimuth", info.SunAzimuth, tc.wantSunAzimuth, azimuthTolerance)
if tc.wantCentralDuration > 0 {
duration := info.CentralEnd.Sub(info.CentralStart)
assertSolarTimeClose(
t,
"CentralDuration",
time.Unix(0, int64(duration)),
time.Unix(0, int64(tc.wantCentralDuration)),
durationTolerance,
)
} else if info.HasCentral || !info.CentralStart.IsZero() || !info.CentralEnd.IsZero() {
t.Fatalf("expected no central phase, got %+v", info)
}
})
}
}
func assertSameLocalSolarEclipse(t *testing.T, name string, got, want LocalSolarEclipseInfo, tolerance time.Duration) {
t.Helper()
if got.Type != want.Type {
t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
}
assertSolarTimeClose(t, name+".GreatestEclipse", got.GreatestEclipse, want.GreatestEclipse, tolerance)
}
+278
View File
@@ -0,0 +1,278 @@
package eclipse
import (
"math"
"time"
"b612.me/astro/basic"
)
// SolarEclipsePathOptions 控制日食中心路径采样。
// SolarEclipsePathOptions controls central solar eclipse path sampling.
type SolarEclipsePathOptions struct {
// Step 是基础时间采样步长;<=0 时使用 1 分钟。
// Step is the base time step; values <= 0 use one minute.
Step time.Duration
// TargetSpacingKM 是相邻中心线点的最大目标地表距离;<=0 时不按距离加密。
// TargetSpacingKM is the target maximum ground spacing between centerline points; values <= 0 disable spacing refinement.
TargetSpacingKM float64
}
// SolarEclipsePathPoint 表示日食路径上的一个地理点。
// SolarEclipsePathPoint is one geographic point on a solar eclipse path.
type SolarEclipsePathPoint struct {
// Time 时刻,保持用户输入时区, time in the input location.
Time time.Time
// Longitude 经度,东正西负, longitude in degrees, east positive.
Longitude float64
// Latitude 纬度,北正南负, latitude in degrees, north positive.
Latitude float64
// SunAltitude 太阳高度角,单位度, Sun altitude in degrees.
SunAltitude float64
// WidthKM 中心食带宽度,单位千米;仅中心线点有意义。
// WidthKM is the central path width in kilometers; meaningful for centerline points.
WidthKM float64
}
// SolarEclipsePath 表示一次中心日食的路径数据。
// SolarEclipsePath contains central solar eclipse path data.
type SolarEclipsePath struct {
// Eclipse 是对应的全局日食信息, related global solar eclipse information.
Eclipse SolarEclipseInfo
// Greatest 是食甚点/最佳观测点, greatest eclipse point.
Greatest SolarEclipsePathPoint
// CenterLine 是中心线, central line.
CenterLine []SolarEclipsePathPoint
// NorthernLimit 是中心食带北界近似线, approximate northern limit of the central path.
NorthernLimit []SolarEclipsePathPoint
// SouthernLimit 是中心食带南界近似线, approximate southern limit of the central path.
SouthernLimit []SolarEclipsePathPoint
// Step 是实际采用的基础时间采样步长, effective base time step.
Step time.Duration
// TargetSpacingKM 是实际采用的目标空间采样距离,单位千米。
// TargetSpacingKM is the effective target spacing in kilometers.
TargetSpacingKM float64
}
// SolarEclipsePartialFootprintOptions 控制日食偏食半影足迹采样。
// SolarEclipsePartialFootprintOptions controls solar eclipse penumbral footprint sampling.
type SolarEclipsePartialFootprintOptions struct {
// Step 是基础时间采样步长;<=0 时使用 5 分钟。
// Step is the base time step; values <= 0 use five minutes.
Step time.Duration
// BoundaryPoints 是每个瞬时半影边界的角向采样点数;<=0 时使用 180。
// BoundaryPoints is the angular sample count for each instantaneous penumbral boundary; values <= 0 use 180.
BoundaryPoints int
}
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
// SolarEclipsePartialAreaOptions is a compatibility alias for SolarEclipsePartialFootprintOptions.
type SolarEclipsePartialAreaOptions = SolarEclipsePartialFootprintOptions
// SolarEclipsePartialFootprint 表示某一时刻的半影足迹边界。
// SolarEclipsePartialFootprint is the penumbral footprint boundary at one instant.
type SolarEclipsePartialFootprint struct {
// Time 时刻,保持用户输入时区, time in the input location.
Time time.Time
// Boundaries 是半影边界分段;反经线或无效投影会拆成多段。
// Boundaries are segmented penumbral boundary polylines, split at invalid projections or the antimeridian.
Boundaries [][]SolarEclipsePathPoint
// Closed 表示 Boundaries 是否构成一个闭合边界。
// Closed indicates whether Boundaries form one closed boundary.
Closed bool
}
// SolarEclipsePartialFootprintsInfo 表示一次日食的偏食半影足迹序列。
// SolarEclipsePartialFootprintsInfo contains penumbral footprint samples for a solar eclipse.
type SolarEclipsePartialFootprintsInfo struct {
// Eclipse 是对应的全局日食信息, related global solar eclipse information.
Eclipse SolarEclipseInfo
// Footprints 是按时间采样的瞬时半影足迹, sampled instantaneous penumbral footprints.
Footprints []SolarEclipsePartialFootprint
// Step 是实际采用的基础时间采样步长, effective base time step.
Step time.Duration
// BoundaryPoints 是实际采用的边界角向采样点数。
// BoundaryPoints is the effective angular sample count for each boundary.
BoundaryPoints int
}
// SolarEclipsePartialAreaInfo 是 SolarEclipsePartialFootprintsInfo 的兼容别名。
// SolarEclipsePartialAreaInfo is a compatibility alias for SolarEclipsePartialFootprintsInfo.
type SolarEclipsePartialAreaInfo = SolarEclipsePartialFootprintsInfo
type solarEclipsePathCalculator func(float64, basic.SolarEclipsePathOptions) basic.SolarEclipsePathResult
type solarEclipsePartialFootprintsCalculator func(float64, basic.SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintsResult
// SolarEclipseCentralPath 日食中心路径查询 / central solar eclipse path query.
// SolarEclipseCentralPath computes the central path near the given date, using NASA bulletin Split-K by default.
func SolarEclipseCentralPath(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return SolarEclipseCentralPathNASABulletinSplitK(date, options)
}
// SolarEclipseCentralPathNASABulletinSplitK 日食中心路径查询(NASA bulletin Split-K / central solar eclipse path query with NASA bulletin Split-K.
// SolarEclipseCentralPathNASABulletinSplitK computes the central path with the NASA bulletin Split-K model.
func SolarEclipseCentralPathNASABulletinSplitK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathNASABulletinSplitK)
}
// SolarEclipseCentralPathIAUSingleK 日食中心路径查询(IAU Single-K / central solar eclipse path query with IAU Single-K.
// SolarEclipseCentralPathIAUSingleK computes the central path with the IAU Single-K model.
func SolarEclipseCentralPathIAUSingleK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathIAUSingleK)
}
// SolarEclipsePartialFootprints 日食偏食足迹查询 / solar eclipse penumbral footprints query.
// SolarEclipsePartialFootprints computes penumbral footprint samples near the given date, using NASA bulletin Split-K by default.
func SolarEclipsePartialFootprints(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
}
// SolarEclipsePartialFootprintsNASABulletinSplitK 日食偏食足迹查询(NASA bulletin Split-K / solar eclipse penumbral footprints query with NASA bulletin Split-K.
// SolarEclipsePartialFootprintsNASABulletinSplitK computes penumbral footprint samples with the NASA bulletin Split-K model.
func SolarEclipsePartialFootprintsNASABulletinSplitK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsNASABulletinSplitK)
}
// SolarEclipsePartialFootprintsIAUSingleK 日食偏食足迹查询(IAU Single-K / solar eclipse penumbral footprints query with IAU Single-K.
// SolarEclipsePartialFootprintsIAUSingleK computes penumbral footprint samples with the IAU Single-K model.
func SolarEclipsePartialFootprintsIAUSingleK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsIAUSingleK)
}
// SolarEclipsePartialArea 偏食足迹兼容包装 / compatibility wrapper for penumbral footprints.
// SolarEclipsePartialArea computes penumbral footprint samples and is a compatibility wrapper for SolarEclipsePartialFootprints.
func SolarEclipsePartialArea(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprints(date, options)
}
// SolarEclipsePartialAreaNASABulletinSplitK 偏食足迹兼容包装(NASA bulletin Split-K / compatibility wrapper for penumbral footprints with NASA bulletin Split-K.
// SolarEclipsePartialAreaNASABulletinSplitK is a compatibility wrapper for SolarEclipsePartialFootprintsNASABulletinSplitK.
func SolarEclipsePartialAreaNASABulletinSplitK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
}
// SolarEclipsePartialAreaIAUSingleK 偏食足迹兼容包装(IAU Single-K / compatibility wrapper for penumbral footprints with IAU Single-K.
// SolarEclipsePartialAreaIAUSingleK is a compatibility wrapper for SolarEclipsePartialFootprintsIAUSingleK.
func SolarEclipsePartialAreaIAUSingleK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprintsIAUSingleK(date, options)
}
func solarEclipseCentralPath(
date time.Time,
options SolarEclipsePathOptions,
calculator solarEclipsePathCalculator,
) (SolarEclipsePath, bool) {
location := date.Location()
result := calculator(solarEclipseTimeToTTJDE(date), basicSolarEclipsePathOptions(options))
if !result.Eclipse.HasCentral || len(result.CenterLine) == 0 {
return SolarEclipsePath{}, false
}
path := SolarEclipsePath{
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
Greatest: solarEclipsePathPointFromBasic(result.Greatest, location),
CenterLine: solarEclipsePathPointsFromBasic(result.CenterLine, location),
NorthernLimit: solarEclipsePathPointsFromBasic(result.NorthernLimit, location),
SouthernLimit: solarEclipsePathPointsFromBasic(result.SouthernLimit, location),
Step: solarEclipsePathStepDuration(result.StepDays),
TargetSpacingKM: result.TargetSpacingKM,
}
return path, true
}
func solarEclipsePartialFootprints(
date time.Time,
options SolarEclipsePartialFootprintOptions,
calculator solarEclipsePartialFootprintsCalculator,
) (SolarEclipsePartialFootprintsInfo, bool) {
location := date.Location()
result := calculator(solarEclipseTimeToTTJDE(date), basicSolarEclipsePartialFootprintOptions(options))
if !result.Eclipse.HasPartial || len(result.Footprints) == 0 {
return SolarEclipsePartialFootprintsInfo{}, false
}
footprints := SolarEclipsePartialFootprintsInfo{
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
Footprints: solarEclipsePartialFootprintsFromBasic(result.Footprints, location),
Step: solarEclipsePathStepDuration(result.StepDays),
BoundaryPoints: result.BoundaryPoints,
}
return footprints, true
}
func basicSolarEclipsePathOptions(options SolarEclipsePathOptions) basic.SolarEclipsePathOptions {
basicOptions := basic.SolarEclipsePathOptions{
TargetSpacingKM: options.TargetSpacingKM,
}
if options.Step > 0 {
basicOptions.StepDays = options.Step.Hours() / 24
}
return basicOptions
}
func basicSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintOptions {
basicOptions := basic.SolarEclipsePartialFootprintOptions{
BoundaryPoints: options.BoundaryPoints,
}
if options.Step > 0 {
basicOptions.StepDays = options.Step.Hours() / 24
}
return basicOptions
}
func solarEclipsePathStepDuration(stepDays float64) time.Duration {
return time.Duration(math.Round(stepDays * 24 * float64(time.Hour)))
}
func solarEclipsePathPointsFromBasic(points []basic.SolarEclipsePathPoint, location *time.Location) []SolarEclipsePathPoint {
if len(points) == 0 {
return nil
}
result := make([]SolarEclipsePathPoint, len(points))
for i, point := range points {
result[i] = solarEclipsePathPointFromBasic(point, location)
}
return result
}
func solarEclipsePathPointFromBasic(point basic.SolarEclipsePathPoint, location *time.Location) SolarEclipsePathPoint {
return SolarEclipsePathPoint{
Time: solarEclipseTTJDEToTime(point.JDE, location),
Longitude: point.Longitude,
Latitude: point.Latitude,
SunAltitude: point.SunAltitude,
WidthKM: point.WidthKM,
}
}
func solarEclipsePartialFootprintsFromBasic(
footprints []basic.SolarEclipsePartialFootprint,
location *time.Location,
) []SolarEclipsePartialFootprint {
if len(footprints) == 0 {
return nil
}
result := make([]SolarEclipsePartialFootprint, len(footprints))
for i, footprint := range footprints {
result[i] = SolarEclipsePartialFootprint{
Time: solarEclipseTTJDEToTime(footprint.JDE, location),
Boundaries: solarEclipsePartialBoundariesFromBasic(footprint.Boundaries, location),
Closed: footprint.Closed,
}
}
return result
}
func solarEclipsePartialBoundariesFromBasic(
boundaries [][]basic.SolarEclipsePathPoint,
location *time.Location,
) [][]SolarEclipsePathPoint {
if len(boundaries) == 0 {
return nil
}
result := make([][]SolarEclipsePathPoint, len(boundaries))
for i, boundary := range boundaries {
result[i] = solarEclipsePathPointsFromBasic(boundary, location)
}
return result
}
+134
View File
@@ -0,0 +1,134 @@
package eclipse
import (
"math"
"testing"
"time"
)
func TestSolarEclipseCentralPathKeepsLocation(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
path, ok := SolarEclipseCentralPath(
time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
SolarEclipsePathOptions{Step: 5 * time.Minute},
)
if !ok {
t.Fatalf("expected central path")
}
if path.Eclipse.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", path.Eclipse.Type, SolarEclipseTotal)
}
if math.Abs(float64(path.Step-5*time.Minute)) > float64(time.Millisecond) {
t.Fatalf("step mismatch: got %s want %s", path.Step, 5*time.Minute)
}
for _, item := range []struct {
name string
tm time.Time
}{
{name: "Eclipse.GreatestEclipse", tm: path.Eclipse.GreatestEclipse},
{name: "Greatest.Time", tm: path.Greatest.Time},
{name: "CenterLine[0].Time", tm: path.CenterLine[0].Time},
{name: "CenterLine[last].Time", tm: path.CenterLine[len(path.CenterLine)-1].Time},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
}
}
}
func TestSolarEclipseCentralPathStepControlsDensity(t *testing.T) {
date := time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)
coarse, ok := SolarEclipseCentralPath(date, SolarEclipsePathOptions{Step: 10 * time.Minute})
if !ok {
t.Fatalf("expected coarse central path")
}
fine, ok := SolarEclipseCentralPath(date, SolarEclipsePathOptions{Step: 2 * time.Minute})
if !ok {
t.Fatalf("expected fine central path")
}
if len(fine.CenterLine) <= len(coarse.CenterLine) {
t.Fatalf("finer step should produce more points: coarse=%d fine=%d", len(coarse.CenterLine), len(fine.CenterLine))
}
}
func TestSolarEclipseCentralPathReturnsFalseForPartialOnly(t *testing.T) {
_, ok := SolarEclipseCentralPath(time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC), SolarEclipsePathOptions{})
if ok {
t.Fatalf("partial-only eclipse should not return a central path")
}
}
func TestSolarEclipsePartialFootprintsKeepLocation(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
footprints, ok := SolarEclipsePartialFootprints(
time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
SolarEclipsePartialFootprintOptions{
Step: 30 * time.Minute,
BoundaryPoints: 72,
},
)
if !ok {
t.Fatalf("expected partial footprints")
}
if footprints.Eclipse.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipseTotal)
}
if math.Abs(float64(footprints.Step-30*time.Minute)) > float64(time.Millisecond) {
t.Fatalf("step mismatch: got %s want %s", footprints.Step, 30*time.Minute)
}
if footprints.BoundaryPoints != 72 {
t.Fatalf("boundary points mismatch: got %d want 72", footprints.BoundaryPoints)
}
for _, item := range []struct {
name string
tm time.Time
}{
{name: "Eclipse.GreatestEclipse", tm: footprints.Eclipse.GreatestEclipse},
{name: "Footprints[0].Time", tm: footprints.Footprints[0].Time},
{name: "Footprints[last].Time", tm: footprints.Footprints[len(footprints.Footprints)-1].Time},
{name: "Boundary point", tm: footprints.Footprints[0].Boundaries[0][0].Time},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
}
}
}
func TestSolarEclipsePartialFootprintsWorkForPartialOnly(t *testing.T) {
footprints, ok := SolarEclipsePartialFootprints(
time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC),
SolarEclipsePartialFootprintOptions{Step: 30 * time.Minute, BoundaryPoints: 72},
)
if !ok {
t.Fatalf("expected partial footprints for partial-only eclipse")
}
if footprints.Eclipse.Type != SolarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipsePartial)
}
}
func TestSolarEclipsePartialFootprintsReturnFalseForNoEvent(t *testing.T) {
_, ok := SolarEclipsePartialFootprints(time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), SolarEclipsePartialFootprintOptions{})
if ok {
t.Fatalf("no eclipse should not return partial footprints")
}
}
func TestSolarEclipsePartialAreaCompatibilityWrapper(t *testing.T) {
date := time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)
options := SolarEclipsePartialAreaOptions{Step: 30 * time.Minute, BoundaryPoints: 72}
compat, compatOK := SolarEclipsePartialArea(date, options)
primary, primaryOK := SolarEclipsePartialFootprints(date, options)
if compatOK != primaryOK {
t.Fatalf("compat ok mismatch: got %v want %v", compatOK, primaryOK)
}
if compat.Eclipse.Type != primary.Eclipse.Type {
t.Fatalf("compat type mismatch: got %s want %s", compat.Eclipse.Type, primary.Eclipse.Type)
}
if len(compat.Footprints) != len(primary.Footprints) {
t.Fatalf("compat footprint count mismatch: got %d want %d", len(compat.Footprints), len(primary.Footprints))
}
}
+304
View File
@@ -0,0 +1,304 @@
package eclipse
import (
"math"
"testing"
"time"
)
func TestSolarEclipseLocalDayBoundsRespectDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skipf("tzdata unavailable: %v", err)
}
testCases := []struct {
name string
date time.Time
wantDuration time.Duration
}{
{
name: "spring forward 2025-03-09",
date: time.Date(2025, 3, 9, 8, 0, 0, 0, loc),
wantDuration: 23 * time.Hour,
},
{
name: "fall back 2025-11-02",
date: time.Date(2025, 11, 2, 8, 0, 0, 0, loc),
wantDuration: 25 * time.Hour,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dayStart, dayMid, dayEnd := solarEclipseLocalDayBounds(tc.date)
if dayStart.Hour() != 0 || dayStart.Minute() != 0 || dayStart.Second() != 0 {
t.Fatalf("dayStart should be local midnight, got %v", dayStart)
}
if dayMid.Hour() != 12 || dayMid.Minute() != 0 || dayMid.Second() != 0 {
t.Fatalf("dayMid should be local noon, got %v", dayMid)
}
if dayEnd.Hour() != 0 || dayEnd.Minute() != 0 || dayEnd.Second() != 0 {
t.Fatalf("dayEnd should be next local midnight, got %v", dayEnd)
}
if got := dayEnd.Sub(dayStart); got != tc.wantDuration {
t.Fatalf("day length mismatch: got %v want %v", got, tc.wantDuration)
}
})
}
}
func TestSolarEclipseOnDateByLocalDay(t *testing.T) {
loc := time.FixedZone("UTC+14", 14*3600)
testCases := []struct {
name string
date time.Time
want bool
}{
{
name: "day before no eclipse",
date: time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
want: false,
},
{
name: "local event day overlaps",
date: time.Date(2024, 4, 9, 12, 0, 0, 0, loc),
want: true,
},
{
name: "day after no eclipse",
date: time.Date(2024, 4, 10, 12, 0, 0, 0, loc),
want: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info, ok := SolarEclipseOnDate(tc.date)
if ok != tc.want {
t.Fatalf("SolarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
}
if !ok {
return
}
if info.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseTotal)
}
if info.GreatestEclipse.Location() != loc {
t.Fatalf("greatest eclipse location mismatch: got %q want %q", info.GreatestEclipse.Location(), loc)
}
if info.PartialBeginOnEarth.Day() != 9 || info.PartialEndOnEarth.Day() != 9 {
t.Fatalf("unexpected local date span: begin=%v end=%v", info.PartialBeginOnEarth, info.PartialEndOnEarth)
}
})
}
}
func TestSolarEclipseSearchSemantics(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
current := ClosestSolarEclipseNASABulletinSplitK(time.Date(2024, 4, 8, 12, 0, 0, 0, loc))
if current.Type != SolarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, SolarEclipseTotal)
}
assertSameSolarEclipse(t, "ClosestSolarEclipse(default)", ClosestSolarEclipse(current.GreatestEclipse), current, time.Second)
last := LastSolarEclipseNASABulletinSplitK(current.GreatestEclipse)
assertSameSolarEclipse(t, "LastSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", last, current, time.Second)
closest := ClosestSolarEclipseNASABulletinSplitK(current.GreatestEclipse)
assertSameSolarEclipse(t, "ClosestSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", closest, current, time.Second)
next := NextSolarEclipseNASABulletinSplitK(current.GreatestEclipse)
if !next.GreatestEclipse.After(current.GreatestEclipse) {
t.Fatalf("NextSolarEclipseNASABulletinSplitK should be strictly future: current=%v next=%v", current.GreatestEclipse, next.GreatestEclipse)
}
if next.Type != SolarEclipseAnnular {
t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, SolarEclipseAnnular)
}
wantNext := ClosestSolarEclipseNASABulletinSplitK(time.Date(2024, 10, 2, 12, 0, 0, 0, loc))
assertSameSolarEclipse(t, "NextSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", next, wantNext, time.Second)
}
func TestSolarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
testCases := []struct {
name string
calc func(time.Time) SolarEclipseInfo
}{
{name: "nasa", calc: ClosestSolarEclipseNASABulletinSplitK},
{name: "iau", calc: ClosestSolarEclipseIAUSingleK},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := tc.calc(time.Date(2024, 10, 2, 12, 0, 0, 0, loc))
if info.Type != SolarEclipseAnnular {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseAnnular)
}
for _, item := range []struct {
name string
tm time.Time
}{
{name: "GreatestEclipse", tm: info.GreatestEclipse},
{name: "PartialBeginOnEarth", tm: info.PartialBeginOnEarth},
{name: "PartialEndOnEarth", tm: info.PartialEndOnEarth},
{name: "CentralBeginOnEarth", tm: info.CentralBeginOnEarth},
{name: "CentralEndOnEarth", tm: info.CentralEndOnEarth},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
}
}
})
}
}
func TestSolarEclipseIAUSingleKRemainsAvailable(t *testing.T) {
date := time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)
defaultInfo := ClosestSolarEclipse(date)
iauInfo := ClosestSolarEclipseIAUSingleK(date)
if defaultInfo.Type != SolarEclipseTotal || iauInfo.Type != SolarEclipseTotal {
t.Fatalf("unexpected eclipse types: default=%s iau=%s", defaultInfo.Type, iauInfo.Type)
}
assertSolarTimeClose(t, "GreatestEclipse", iauInfo.GreatestEclipse, defaultInfo.GreatestEclipse, time.Second)
if !(iauInfo.PathWidthKM > defaultInfo.PathWidthKM) {
t.Fatalf("expected IAU path width > NASA path width: iau=%.6f nasa=%.6f", iauInfo.PathWidthKM, defaultInfo.PathWidthKM)
}
if !(iauInfo.Magnitude > defaultInfo.Magnitude) {
t.Fatalf("expected IAU magnitude > NASA magnitude: iau=%.9f nasa=%.9f", iauInfo.Magnitude, defaultInfo.Magnitude)
}
}
func TestSolarEclipseAgainstNASAUTBaseline(t *testing.T) {
testCases := []struct {
name string
date time.Time
wantType SolarEclipseType
wantGreatest time.Time
wantGamma float64
wantMagnitude float64
wantLongitude float64
wantLatitude float64
wantPathWidthKM float64
wantCentrality SolarEclipseCentrality
}{
{
name: "2023-04-20 hybrid",
date: time.Date(2023, 4, 20, 0, 0, 0, 0, time.UTC),
wantType: SolarEclipseHybrid,
wantGreatest: time.Date(2023, 4, 20, 4, 16, 43, 0, time.UTC),
wantGamma: -0.3952,
wantMagnitude: 1.0132,
wantLongitude: 125.8,
wantLatitude: -9.6,
wantPathWidthKM: 49.0,
wantCentrality: SolarEclipseCentralTwoLimits,
},
{
name: "2024-04-08 total",
date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
wantType: SolarEclipseTotal,
wantGreatest: time.Date(2024, 4, 8, 18, 17, 15, 0, time.UTC),
wantGamma: 0.3431,
wantMagnitude: 1.0566,
wantLongitude: -104.1,
wantLatitude: 25.3,
wantPathWidthKM: 197.5,
wantCentrality: SolarEclipseCentralTwoLimits,
},
{
name: "2024-10-02 annular",
date: time.Date(2024, 10, 2, 0, 0, 0, 0, time.UTC),
wantType: SolarEclipseAnnular,
wantGreatest: time.Date(2024, 10, 2, 18, 44, 59, 0, time.UTC),
wantGamma: -0.3509,
wantMagnitude: 0.9326,
wantLongitude: -114.5,
wantLatitude: -22.0,
wantPathWidthKM: 266.5,
wantCentrality: SolarEclipseCentralTwoLimits,
},
{
name: "2025-03-29 partial",
date: time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC),
wantType: SolarEclipsePartial,
wantGreatest: time.Date(2025, 3, 29, 10, 47, 21, 0, time.UTC),
wantGamma: 1.0405,
wantMagnitude: 0.9376,
wantLongitude: -77.1,
wantLatitude: 61.1,
wantPathWidthKM: 0.0,
wantCentrality: SolarEclipseNonCentral,
},
}
const (
// 这里对 UT 使用稍宽容差,因为 `sun` 层会把 TT 转成 UT
// 与 NASA 页面公开的 ΔT 口径存在数秒级差异;几何本身已在 basic 层用 TT 锁定。
timeTolerance = 8 * time.Second
gammaTolerance = 5e-4
magnitudeTolerance = 5e-4
coordinateTolerance = 0.1
pathWidthTolerance = 5.0
)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assertSameSolarEclipse(
t,
"ClosestSolarEclipse(default)",
ClosestSolarEclipse(tc.date),
ClosestSolarEclipseNASABulletinSplitK(tc.date),
time.Second,
)
info := ClosestSolarEclipse(tc.date)
if info.Type != tc.wantType {
t.Fatalf("type mismatch: got %s want %s", info.Type, tc.wantType)
}
if info.Centrality != tc.wantCentrality {
t.Fatalf("centrality mismatch: got %s want %s", info.Centrality, tc.wantCentrality)
}
assertSolarTimeClose(t, "GreatestEclipse", info.GreatestEclipse, tc.wantGreatest, timeTolerance)
assertSolarFloatClose(t, "Gamma", info.Gamma, tc.wantGamma, gammaTolerance)
assertSolarFloatClose(t, "Magnitude", info.Magnitude, tc.wantMagnitude, magnitudeTolerance)
assertSolarFloatClose(t, "GreatestLongitude", info.GreatestLongitude, tc.wantLongitude, coordinateTolerance)
assertSolarFloatClose(t, "GreatestLatitude", info.GreatestLatitude, tc.wantLatitude, coordinateTolerance)
assertSolarFloatClose(t, "PathWidthKM", info.PathWidthKM, tc.wantPathWidthKM, pathWidthTolerance)
})
}
}
func assertSameSolarEclipse(t *testing.T, name string, got, want SolarEclipseInfo, tolerance time.Duration) {
t.Helper()
if got.Type != want.Type {
t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
}
assertSolarTimeClose(t, name+".GreatestEclipse", got.GreatestEclipse, want.GreatestEclipse, tolerance)
}
func assertSolarTimeClose(t *testing.T, name string, got, want time.Time, tolerance time.Duration) {
t.Helper()
diff := got.Sub(want)
if diff < 0 {
diff = -diff
}
if diff > tolerance {
t.Fatalf("%s mismatch: got %v want %v diff=%v", name, got, want, diff)
}
}
func assertSolarFloatClose(t *testing.T, name string, got, want, tolerance float64) {
t.Helper()
if math.Abs(got-want) > tolerance {
t.Fatalf("%s mismatch: got %.6f want %.6f diff=%.6f", name, got, want, math.Abs(got-want))
}
}
+923
View File
@@ -0,0 +1,923 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
const (
lunarEclipseSVGDefaultWidth = 960
lunarEclipseSVGDefaultHeight = 620
lunarEclipseSVGDefaultZone = 8 * 60 * 60
lunarEclipseSVGLanguageChinese = "zh"
lunarEclipseSVGLanguageEnglish = "en"
)
// LunarEclipseSVGOptions 控制月食穿影 SVG 输出。
// LunarEclipseSVGOptions controls lunar eclipse shadow-path SVG output.
type LunarEclipseSVGOptions struct {
// Width / Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
// Width/Height are SVG canvas size; values <= 0 use defaults.
Width int
Height int
// Step 是月心路径采样步长;<=0 时使用 5 分钟。
// Step is the Moon-center path sampling step; values <= 0 use five minutes.
Step time.Duration
// Title 是图题;为空时自动生成。
// Title is the chart title; empty values use an automatic title.
Title string
// SummaryText 是标题下第一行摘要;为空时自动生成。
// SummaryText is the first summary line below the title; empty values use an automatic summary.
SummaryText string
// MaximumText 是标题下第二行食甚说明;为空时自动生成。
// MaximumText is the second line below the title for maximum-eclipse details; empty values use automatic text.
MaximumText string
// CoordinatesText 是标题下第三行坐标说明;为空时自动生成。
// CoordinatesText is the third line below the title for coordinates; empty values use automatic text.
CoordinatesText string
// DurationText 是标题下第四行历时说明;为空时自动生成。
// DurationText is the fourth line below the title for durations; empty values use automatic text.
DurationText string
// MetaText 是标题下第五行补充说明;为空时自动生成沙罗信息。
// MetaText is the fifth line below the title; empty values use automatic Saros text.
MetaText string
// ContactsTitle 是接触时刻区标题;为空时自动生成。
// ContactsTitle is the contacts-panel title; empty values use an automatic title.
ContactsTitle string
// DirectionText 是底部方向说明;为空时自动生成。
// DirectionText is the footer direction note; empty values use an automatic note.
DirectionText string
// FooterNote 是底部补充说明;为空时自动生成。
// FooterNote is the footer explanatory note; empty values use an automatic note.
FooterNote string
// Language 是标签语言;"en" 使用英文,其他值或空值使用中文。
// Language controls label language; "en" uses English, other values or empty values use Chinese.
Language string
// Location 是图中显示时刻的时区;nil 时使用 UTC+8。
// Location is the display timezone for chart times; nil uses UTC+8.
Location *time.Location
}
type lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult
type lunarEclipseSVGFinder func(time.Time) LunarEclipseInfo
type lunarEclipseSVGLayout struct {
width float64
height float64
cx float64
cy float64
scale float64
diagramLeft float64
diagramRight float64
panelX float64
panelY float64
}
type lunarEclipseSVGPoint struct {
basic.LunarEclipseDiagramPoint
X float64
Y float64
}
type lunarEclipseSVGMaximumCoordinates struct {
RA float64
Dec float64
EclipticLongitude float64
EclipticLatitude float64
ConstellationCode string
ConstellationName string
}
// LunarEclipseSVG 生成月食穿影图 SVG,默认使用 Danjon 影半径模型。
// LunarEclipseSVG generates an SVG lunar eclipse shadow-path chart, using the Danjon shadow model by default.
func LunarEclipseSVG(date time.Time, options LunarEclipseSVGOptions) (string, bool) {
return LunarEclipseSVGDanjon(date, options)
}
// LunarEclipseSVGDanjon 生成月食穿影图 SVG,使用 Danjon 影半径模型。
// LunarEclipseSVGDanjon generates an SVG lunar eclipse shadow-path chart with the Danjon shadow model.
func LunarEclipseSVGDanjon(date time.Time, options LunarEclipseSVGOptions) (string, bool) {
return lunarEclipseSVG(date, options, basic.LunarEclipseDiagramDanjon, eclipsecore.ClosestLunarEclipseDanjon)
}
// LunarEclipseSVGChauvenet 生成月食穿影图 SVG,使用 Chauvenet 影半径模型。
// LunarEclipseSVGChauvenet generates an SVG lunar eclipse shadow-path chart with the Chauvenet shadow model.
func LunarEclipseSVGChauvenet(date time.Time, options LunarEclipseSVGOptions) (string, bool) {
return lunarEclipseSVG(date, options, basic.LunarEclipseDiagramChauvenet, eclipsecore.ClosestLunarEclipseChauvenet)
}
func lunarEclipseSVG(
date time.Time,
options LunarEclipseSVGOptions,
calculator lunarEclipseSVGCalculator,
finder lunarEclipseSVGFinder,
) (string, bool) {
options = normalizeLunarEclipseSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
})
if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 {
return "", false
}
info := lunarEclipseInfoFromBasic(diagram.Eclipse, options.Location)
if finder != nil {
coreInfo := finder(info.Maximum)
info.HasSaros = coreInfo.HasSaros
info.Saros = coreInfo.Saros
}
return renderLunarEclipseSVG(info, diagram, options), true
}
func normalizeLunarEclipseSVGOptions(options LunarEclipseSVGOptions) LunarEclipseSVGOptions {
if options.Width <= 0 {
options.Width = lunarEclipseSVGDefaultWidth
}
if options.Height <= 0 {
options.Height = lunarEclipseSVGDefaultHeight
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", lunarEclipseSVGDefaultZone)
}
if strings.EqualFold(options.Language, lunarEclipseSVGLanguageEnglish) {
options.Language = lunarEclipseSVGLanguageEnglish
} else {
options.Language = lunarEclipseSVGLanguageChinese
}
return options
}
func renderLunarEclipseSVG(
info LunarEclipseInfo,
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
) string {
headerTexts := lunarEclipseSVGHeaderTexts(info, options)
layout := lunarEclipseSVGLayoutFor(diagram, options, lunarEclipseSVGHeaderBottom(headerTexts))
points := lunarEclipseSVGPoints(diagram.Points)
mapX := func(x float64) float64 { return layout.cx - x*layout.scale }
mapY := func(y float64) float64 { return layout.cy - y*layout.scale }
title := lunarEclipseSVGTitleText(info, options)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, options.Width, options.Height, options.Width, options.Height)
b.WriteString(`<defs>`)
b.WriteString(lunarEclipseSVGMoonSymbol)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
layout.width-44, layout.height-36)
fmt.Fprintf(&b, `<text x="%.3f" y="44" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="26" font-weight="700" text-anchor="middle">%s</text>`,
layout.width/2, html.EscapeString(title))
fmt.Fprintf(&b, `<line x1="%.3f" y1="57" x2="%.3f" y2="57" stroke="#555" stroke-width="1"/>`, layout.width/2-78, layout.width/2+78)
writeLunarEclipseSummary(&b, headerTexts, options)
fmt.Fprintf(&b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="#e9e9e9" stroke="#d6d6d6" stroke-width="1.2"/>`,
layout.cx, layout.cy, diagram.PenumbraRadius*layout.scale)
fmt.Fprintf(&b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="#b21f16" stroke="#83170f" stroke-width="1.3"/>`,
layout.cx, layout.cy, diagram.UmbraRadius*layout.scale)
writeLunarEclipseShadowLabels(&b, layout, diagram, options.Language)
writeLunarEclipseAxes(&b, layout.cx, layout.cy, diagram.PenumbraRadius*layout.scale, options.Language)
writeLunarEclipseEclipticLine(&b, layout, diagram, mapX, mapY, options.Language)
if len(points) > 0 {
b.WriteString(`<path d="`)
for i, point := range points {
if i == 0 {
fmt.Fprintf(&b, `M %.3f %.3f`, mapX(point.X), mapY(point.Y))
continue
}
fmt.Fprintf(&b, ` L %.3f %.3f`, mapX(point.X), mapY(point.Y))
}
b.WriteString(`" fill="none" stroke="#333333" stroke-width="1.1" stroke-dasharray="5 4" stroke-linecap="round" stroke-linejoin="round"/>`)
}
eventPoints := lunarEclipseSVGEventPoints(points)
for _, point := range eventPoints {
if point.Label == "Greatest" {
continue
}
writeLunarEclipseMoon(&b, point, diagram, mapX(point.X), mapY(point.Y), layout.scale, false)
}
for _, point := range eventPoints {
if point.Label == "Greatest" {
writeLunarEclipseMoon(&b, point, diagram, mapX(point.X), mapY(point.Y), layout.scale, true)
break
}
}
for _, point := range eventPoints {
if point.Label == "" {
continue
}
x := mapX(point.X)
y := mapY(point.Y)
writeLunarEclipseEventLabel(&b, point.Label, x, y, layout.cx, diagram.MoonRadius*layout.scale, options.Language)
}
writeLunarEclipseContacts(&b, info, options, layout.panelX, layout.panelY)
writeLunarEclipseFooter(&b, info, options, layout)
b.WriteString(`</svg>`)
return b.String()
}
func lunarEclipseSVGLayoutFor(
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
headerBottom float64,
) lunarEclipseSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
margin := math.Max(32, math.Min(48, width*0.05))
panelWidth := math.Max(210, math.Min(260, width*0.24))
diagramLeft := margin
diagramRight := width - panelWidth - 34
if diagramRight-diagramLeft < width*0.48 {
diagramRight = width - margin
}
topReserved := math.Max(166, headerBottom+24)
bottomReserved := 82.0
extent := diagram.PenumbraRadius + diagram.MoonRadius + 0.72
scale := math.Min((diagramRight-diagramLeft)/(2*extent), (height-topReserved-bottomReserved)/(2*extent))
if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
scale = 1
}
cx := (diagramLeft + diagramRight) / 2
cy := topReserved + (height-topReserved-bottomReserved)/2 + 12
panelX := diagramRight + 22
if panelX+panelWidth > width-margin/2 {
panelX = width - panelWidth - margin/2
}
if panelX < diagramRight {
panelX = diagramRight
}
return lunarEclipseSVGLayout{
width: width,
height: height,
cx: cx,
cy: cy,
scale: scale,
diagramLeft: diagramLeft,
diagramRight: diagramRight,
panelX: panelX,
panelY: math.Max(148, cy-88),
}
}
func lunarEclipseSVGTitle(info LunarEclipseInfo, language string) string {
return fmt.Sprintf("%s %s", info.Maximum.Format("2006-01-02"), lunarEclipseSVGTypeName(info.Type, language))
}
func lunarEclipseSVGTitleText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.Title != "" {
return options.Title
}
return lunarEclipseSVGTitle(info, options.Language)
}
func lunarEclipseSVGHeaderTexts(info LunarEclipseInfo, options LunarEclipseSVGOptions) []string {
lines := []string{
lunarEclipseSVGSummaryText(info, options),
lunarEclipseSVGMaximumTextValue(info, options),
lunarEclipseSVGCoordinatesTextValue(info, options),
lunarEclipseSVGDurationTextValue(info, options),
lunarEclipseSVGMetaTextValue(info, options),
}
filtered := make([]string, 0, len(lines))
for _, line := range lines {
if line != "" {
filtered = append(filtered, line)
}
}
return filtered
}
func lunarEclipseSVGSummaryText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.SummaryText != "" {
return options.SummaryText
}
return lunarEclipseSVGSummary(info, options.Language)
}
func lunarEclipseSVGMaximumTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.MaximumText != "" {
return options.MaximumText
}
return lunarEclipseSVGMaximumText(info, options)
}
func lunarEclipseSVGCoordinatesTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.CoordinatesText != "" {
return options.CoordinatesText
}
coordinates := lunarEclipseSVGMaximumCoordinatesFor(info, options.Language)
return lunarEclipseSVGMaximumCoordinatesText(coordinates, options.Language)
}
func lunarEclipseSVGDurationTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.DurationText != "" {
return options.DurationText
}
return lunarEclipseSVGDurationSummary(info, options.Language)
}
func lunarEclipseSVGMetaTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.MetaText != "" {
return options.MetaText
}
return lunarEclipseSVGMetaText(info, options.Language)
}
func lunarEclipseSVGContactsTitleText(options LunarEclipseSVGOptions) string {
if options.ContactsTitle != "" {
return options.ContactsTitle
}
if options.Language == lunarEclipseSVGLanguageEnglish {
return "Contacts"
}
return "接触时刻"
}
func lunarEclipseSVGDirectionTextValue(options LunarEclipseSVGOptions) string {
if options.DirectionText != "" {
return options.DirectionText
}
return lunarEclipseSVGDirectionText(options.Language)
}
func lunarEclipseSVGFooterNoteText(options LunarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
note := "图中月面大小和影半径均按实际相对角半径缩放。"
if options.Language == lunarEclipseSVGLanguageEnglish {
note = "Moon disks and shadow radii are drawn to the same relative angular-radius scale."
}
return note
}
func lunarEclipseSVGHeaderLineY(index int) float64 {
return 84 + float64(index)*22
}
func lunarEclipseSVGHeaderBottom(lines []string) float64 {
if len(lines) == 0 {
return 72
}
return lunarEclipseSVGHeaderLineY(len(lines)-1) + 14
}
func lunarEclipseSVGSummary(info LunarEclipseInfo, language string) string {
if language == lunarEclipseSVGLanguageEnglish {
return fmt.Sprintf("type=%s penumbral=%.4f umbral=%.4f",
lunarEclipseSVGTypeName(info.Type, language), info.PenumbralMagnitude, info.UmbralMagnitude)
}
return fmt.Sprintf("食型=%s 半影食分=%.4f 本影食分=%.4f",
lunarEclipseSVGTypeName(info.Type, language), info.PenumbralMagnitude, info.UmbralMagnitude)
}
func writeLunarEclipseSummary(b *strings.Builder, lines []string, options LunarEclipseSVGOptions) {
for index, line := range lines {
fontSize := 13
fill := "#333"
if index == 0 {
fontSize = 14
fill = "#222"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="%d" text-anchor="middle">%s</text>`,
float64(options.Width)/2, lunarEclipseSVGHeaderLineY(index), fill, fontSize, html.EscapeString(line))
}
}
func lunarEclipseSVGDirectionText(language string) string {
if language == lunarEclipseSVGLanguageEnglish {
return "North is up and east is left; the ecliptic is projected near greatest eclipse."
}
return "上北下南,左东右西;黄道按食甚附近天球投影绘制。"
}
func lunarEclipseSVGMaximumText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
maximum := info.Maximum.In(options.Location).Format("2006-01-02 15:04:05 MST")
if options.Language == lunarEclipseSVGLanguageEnglish {
return "Maximum: " + maximum
}
return "食甚:" + maximum
}
func lunarEclipseSVGMaximumCoordinatesFor(
info LunarEclipseInfo,
language string,
) lunarEclipseSVGMaximumCoordinates {
jde := timeToTTJDE(info.Maximum)
ra, dec := basic.HMoonTrueRaDec(jde)
lon := basic.HMoonApparentLo(jde)
lat := basic.HMoonTrueBo(jde)
code := basic.ConstellationCode(ra, dec, jde)
name := basic.ConstellationNameByCodeZH(code)
if language == lunarEclipseSVGLanguageEnglish {
name = basic.ConstellationNameByCodeEN(code)
}
return lunarEclipseSVGMaximumCoordinates{
RA: ra,
Dec: dec,
EclipticLongitude: lon,
EclipticLatitude: lat,
ConstellationCode: code,
ConstellationName: name,
}
}
func lunarEclipseSVGMaximumCoordinatesText(
coordinates lunarEclipseSVGMaximumCoordinates,
language string,
) string {
if language == lunarEclipseSVGLanguageEnglish {
return fmt.Sprintf("Moon: RA %s Dec %s ecl.lon %.4f deg ecl.lat %.4f deg %s",
lunarEclipseSVGFormatRA(coordinates.RA),
lunarEclipseSVGFormatSignedAngle(coordinates.Dec),
coordinates.EclipticLongitude,
coordinates.EclipticLatitude,
coordinates.ConstellationName,
)
}
return fmt.Sprintf("月球:赤经 %s 赤纬 %s 黄经 %.4f° 黄纬 %.4f° %s",
lunarEclipseSVGFormatRA(coordinates.RA),
lunarEclipseSVGFormatSignedAngle(coordinates.Dec),
coordinates.EclipticLongitude,
coordinates.EclipticLatitude,
coordinates.ConstellationName,
)
}
func lunarEclipseSVGFormatRA(degree float64) string {
totalSeconds := int(math.Round(normalizeDegree360(degree) / 15 * 3600))
totalSeconds %= 24 * 3600
hours := totalSeconds / 3600
minutes := totalSeconds % 3600 / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02dh%02dm%02ds", hours, minutes, seconds)
}
func lunarEclipseSVGFormatSignedAngle(degree float64) string {
sign := "+"
if degree < 0 {
sign = "-"
degree = -degree
}
totalSeconds := int(math.Round(degree * 3600))
degrees := totalSeconds / 3600
minutes := totalSeconds % 3600 / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%s%02d°%02d%02d″", sign, degrees, minutes, seconds)
}
func lunarEclipseSVGDurationSummary(info LunarEclipseInfo, language string) string {
penumbral := lunarEclipseSVGFormatDuration(info.PenumbralEnd.Sub(info.PenumbralStart))
if language == lunarEclipseSVGLanguageEnglish {
parts := []string{"Penumbral duration " + penumbral}
if info.HasPartial {
parts = append(parts, "Umbral duration "+lunarEclipseSVGFormatDuration(info.PartialEnd.Sub(info.PartialStart)))
}
if info.HasTotal {
parts = append(parts, "Total duration "+lunarEclipseSVGFormatDuration(info.TotalEnd.Sub(info.TotalStart)))
}
return strings.Join(parts, " ")
}
parts := []string{"半影历时 " + penumbral}
if info.HasPartial {
parts = append(parts, "本影历时 "+lunarEclipseSVGFormatDuration(info.PartialEnd.Sub(info.PartialStart)))
}
if info.HasTotal {
parts = append(parts, "全食历时 "+lunarEclipseSVGFormatDuration(info.TotalEnd.Sub(info.TotalStart)))
}
return strings.Join(parts, " ")
}
func lunarEclipseSVGMetaText(info LunarEclipseInfo, language string) string {
if !info.HasSaros {
return ""
}
if language == lunarEclipseSVGLanguageEnglish {
return fmt.Sprintf("Lunar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count)
}
return fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)
}
func lunarEclipseSVGFormatDuration(duration time.Duration) string {
if duration < 0 {
duration = -duration
}
totalSeconds := int(duration.Round(time.Second).Seconds())
hours := totalSeconds / 3600
minutes := totalSeconds % 3600 / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func lunarEclipseSVGTypeName(eclipseType LunarEclipseType, language string) string {
if language == lunarEclipseSVGLanguageEnglish {
switch eclipseType {
case LunarEclipsePenumbral:
return "Penumbral Lunar Eclipse"
case LunarEclipsePartial:
return "Partial Lunar Eclipse"
case LunarEclipseTotal:
return "Total Lunar Eclipse"
default:
return "No Lunar Eclipse"
}
}
switch eclipseType {
case LunarEclipsePenumbral:
return "半影月食"
case LunarEclipsePartial:
return "月偏食"
case LunarEclipseTotal:
return "月全食"
default:
return "无月食"
}
}
func writeLunarEclipseAxes(b *strings.Builder, cx, cy, radius float64, language string) {
north, east, west, south := "北", "东", "西", "南"
if language == lunarEclipseSVGLanguageEnglish {
north, east, west, south = "N", "E", "W", "S"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700" text-anchor="middle">%s</text>`,
cx, cy-radius-18, html.EscapeString(north))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700" text-anchor="middle">%s</text>`,
cx-radius-22, cy+4, html.EscapeString(east))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700" text-anchor="middle">%s</text>`,
cx+radius+22, cy+4, html.EscapeString(west))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700" text-anchor="middle">%s</text>`,
cx, cy+radius+28, html.EscapeString(south))
}
func lunarEclipseSVGPoints(points []basic.LunarEclipseDiagramPoint) []lunarEclipseSVGPoint {
result := make([]lunarEclipseSVGPoint, 0, len(points))
for _, point := range points {
distance := math.Hypot(point.X, point.Y)
positionAngleRad := lunarEclipseMoonCenterPositionAngle(point.JDE) * math.Pi / 180
result = append(result, lunarEclipseSVGPoint{
LunarEclipseDiagramPoint: point,
X: distance * math.Sin(positionAngleRad),
Y: distance * math.Cos(positionAngleRad),
})
}
return result
}
func writeLunarEclipseEclipticLine(
b *strings.Builder,
layout lunarEclipseSVGLayout,
diagram basic.LunarEclipseDiagramResult,
mapX, mapY func(float64) float64,
language string,
) {
unitX, unitY, ok := lunarEclipseSVGEclipticDirection(diagram.Eclipse.Maximum)
if !ok {
return
}
extent := (diagram.PenumbraRadius + diagram.MoonRadius) * 1.42
screenStartX := mapX(-unitX * extent)
screenStartY := mapY(-unitY * extent)
screenEndX := mapX(unitX * extent)
screenEndY := mapY(unitY * extent)
if math.IsNaN(screenStartX) || math.IsNaN(screenStartY) || math.IsNaN(screenEndX) || math.IsNaN(screenEndY) {
return
}
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#555" stroke-width="1" stroke-dasharray="4 3" opacity="0.82"/>`,
screenStartX, screenStartY, screenEndX, screenEndY)
labelX := screenStartX
labelY := screenStartY
anchor := "end"
if screenEndX < screenStartX {
labelX = screenEndX
labelY = screenEndY
}
labelX -= 8
if labelX < layout.diagramLeft+18 {
labelX += 16
anchor = "start"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#444" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="%s">%s</text>`,
labelX, labelY+4, anchor, html.EscapeString(lunarEclipseSVGLabelEcliptic(language)))
}
func lunarEclipseSVGEclipticDirection(ttJDE float64) (float64, float64, bool) {
originRA, originDec := lunarEclipseShadowCenterRaDec(ttJDE)
centerLongitude := normalizeDegree360(basic.HSunApparentLo(ttJDE) + 180)
ra1, dec1 := basic.LoBoToRaDec(ttJDE, centerLongitude-1, 0)
ra2, dec2 := basic.LoBoToRaDec(ttJDE, centerLongitude+1, 0)
x1, y1 := lunarEclipseSVGTangentOffset(originRA, originDec, ra1, dec1)
x2, y2 := lunarEclipseSVGTangentOffset(originRA, originDec, ra2, dec2)
dx := x2 - x1
dy := y2 - y1
length := math.Hypot(dx, dy)
if length == 0 || math.IsNaN(length) || math.IsInf(length, 0) {
return 0, 0, false
}
return dx / length, dy / length, true
}
func lunarEclipseSVGTangentOffset(originRA, originDec, targetRA, targetDec float64) (float64, float64) {
separation := lunarEclipseSVGAngularSeparation(originRA, originDec, targetRA, targetDec)
positionAngleRad := lunarEclipsePositionAngle(originRA, originDec, targetRA, targetDec) * math.Pi / 180
return separation * math.Sin(positionAngleRad), separation * math.Cos(positionAngleRad)
}
func lunarEclipseSVGAngularSeparation(ra1, dec1, ra2, dec2 float64) float64 {
ra1Rad := ra1 * math.Pi / 180
dec1Rad := dec1 * math.Pi / 180
ra2Rad := ra2 * math.Pi / 180
dec2Rad := dec2 * math.Pi / 180
cosDistance := math.Sin(dec1Rad)*math.Sin(dec2Rad) +
math.Cos(dec1Rad)*math.Cos(dec2Rad)*math.Cos(ra2Rad-ra1Rad)
if cosDistance > 1 {
cosDistance = 1
}
if cosDistance < -1 {
cosDistance = -1
}
return math.Acos(cosDistance) * 180 / math.Pi
}
func lunarEclipseSVGLabelEcliptic(language string) string {
if language == lunarEclipseSVGLanguageEnglish {
return "Ecliptic"
}
return "黄道"
}
func writeLunarEclipseShadowLabels(
b *strings.Builder,
layout lunarEclipseSVGLayout,
diagram basic.LunarEclipseDiagramResult,
language string,
) {
penumbraLabel, umbraLabel := "地球半影", "地球本影"
if language == lunarEclipseSVGLanguageEnglish {
penumbraLabel, umbraLabel = "Earth's Penumbra", "Earth's Umbra"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="700" text-anchor="middle">%s</text>`,
layout.cx, layout.cy-diagram.PenumbraRadius*layout.scale+28, html.EscapeString(penumbraLabel))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="700" text-anchor="middle">%s</text>`,
layout.cx, layout.cy-diagram.UmbraRadius*layout.scale+22, html.EscapeString(umbraLabel))
}
func lunarEclipseSVGEventPoints(points []lunarEclipseSVGPoint) []lunarEclipseSVGPoint {
events := make([]lunarEclipseSVGPoint, 0, 7)
for _, point := range points {
for _, label := range lunarEclipseSVGPointLabels(point) {
event := point
event.Label = label
event.Labels = []string{label}
events = append(events, event)
}
}
return events
}
func lunarEclipseSVGPointLabels(point lunarEclipseSVGPoint) []string {
if len(point.Labels) > 0 {
return point.Labels
}
if point.Label == "" {
return nil
}
return []string{point.Label}
}
func writeLunarEclipseMoon(
b *strings.Builder,
point lunarEclipseSVGPoint,
diagram basic.LunarEclipseDiagramResult,
x, y, scale float64,
greatest bool,
) {
radius := diagram.MoonRadius * scale
opacity := 0.64
if greatest {
opacity = 0.9
}
fmt.Fprintf(b, `<g class="event-moon">`)
fmt.Fprintf(b, `<use href="#le-moon" x="%.3f" y="%.3f" width="%.3f" height="%.3f" opacity="%.2f"/>`,
x-radius, y-radius, radius*2, radius*2, opacity)
if lunarEclipseSVGDeepUmbra(point.LunarEclipseDiagramPoint, diagram) {
tintOpacity := 0.46
if greatest {
tintOpacity = 0.58
}
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="#d66f1f" opacity="%.2f"/>`,
x, y, radius, tintOpacity)
}
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#b9b9b9" stroke-width="0.8" opacity="0.9"/>`, x, y, radius)
b.WriteString(`</g>`)
}
func lunarEclipseSVGDeepUmbra(point basic.LunarEclipseDiagramPoint, diagram basic.LunarEclipseDiagramResult) bool {
return math.Hypot(point.X, point.Y) < diagram.UmbraRadius+diagram.MoonRadius*0.75
}
func writeLunarEclipseEventLabel(
b *strings.Builder,
label string,
x, y, cx, moonRadius float64,
language string,
) {
text := lunarEclipseSVGEventName(label, language)
dx := moonRadius*0.72 + 6
anchor := "start"
if x < cx {
dx = -dx
anchor = "end"
}
dy := -moonRadius*0.35 - 4
if label == "Greatest" {
dy = moonRadius + 15
anchor = "middle"
dx = 0
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#2554c7" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="%s">%s</text>`,
x+dx, y+dy, anchor, html.EscapeString(text))
}
func lunarEclipseSVGEventName(label, language string) string {
if language == lunarEclipseSVGLanguageEnglish {
switch label {
case "Greatest":
return "Greatest"
default:
return label
}
}
switch label {
case "P1":
return "P1 半影始"
case "U1":
return "U1 初亏"
case "U2":
return "U2 食既"
case "Greatest":
return "食甚"
case "U3":
return "U3 生光"
case "U4":
return "U4 复圆"
case "P4":
return "P4 半影终"
default:
return label
}
}
type lunarEclipseSVGContact struct {
label string
name string
time time.Time
angle float64
hasAngle bool
}
func writeLunarEclipseContacts(
b *strings.Builder,
info LunarEclipseInfo,
options LunarEclipseSVGOptions,
x, y float64,
) {
contacts := lunarEclipseSVGContacts(info, options.Language)
if len(contacts) == 0 {
return
}
title := lunarEclipseSVGContactsTitleText(options)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="700">%s (%s)</text>`,
x, y, html.EscapeString(title), html.EscapeString(options.Location.String()))
for index, contact := range contacts {
line := fmt.Sprintf("%s %s %s", contact.label, contact.name, contact.time.In(options.Location).Format("15:04:05"))
if contact.hasAngle {
if options.Language == lunarEclipseSVGLanguageEnglish {
line = fmt.Sprintf("%s PA %.1f°", line, contact.angle)
} else {
line = fmt.Sprintf("%s 方位 %.1f°", line, contact.angle)
}
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#222" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
x, y+float64(index+1)*18, html.EscapeString(line))
}
}
func writeLunarEclipseFooter(
b *strings.Builder,
info LunarEclipseInfo,
options LunarEclipseSVGOptions,
layout lunarEclipseSVGLayout,
) {
_ = info
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, layout.height-54, html.EscapeString(lunarEclipseSVGDirectionTextValue(options)))
note := lunarEclipseSVGFooterNoteText(options)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, layout.height-34, html.EscapeString(note))
}
func lunarEclipseSVGContacts(info LunarEclipseInfo, language string) []lunarEclipseSVGContact {
angles := lunarEclipseContactAngleMap(info.ContactPoints)
contacts := []lunarEclipseSVGContact{
lunarEclipseSVGContactFor("P1", lunarEclipseSVGContactName("P1", language), info.PenumbralStart, angles),
}
if info.HasPartial {
contacts = append(contacts, lunarEclipseSVGContactFor("U1", lunarEclipseSVGContactName("U1", language), info.PartialStart, angles))
}
if info.HasTotal {
contacts = append(contacts, lunarEclipseSVGContactFor("U2", lunarEclipseSVGContactName("U2", language), info.TotalStart, angles))
}
contacts = append(contacts, lunarEclipseSVGContact{label: "GE", name: lunarEclipseSVGContactName("Greatest", language), time: info.Maximum})
if info.HasTotal {
contacts = append(contacts, lunarEclipseSVGContactFor("U3", lunarEclipseSVGContactName("U3", language), info.TotalEnd, angles))
}
if info.HasPartial {
contacts = append(contacts, lunarEclipseSVGContactFor("U4", lunarEclipseSVGContactName("U4", language), info.PartialEnd, angles))
}
contacts = append(contacts, lunarEclipseSVGContactFor("P4", lunarEclipseSVGContactName("P4", language), info.PenumbralEnd, angles))
return contacts
}
func lunarEclipseSVGContactFor(
label, name string,
time time.Time,
angles map[string]float64,
) lunarEclipseSVGContact {
angle, ok := angles[label]
return lunarEclipseSVGContact{
label: label,
name: name,
time: time,
angle: angle,
hasAngle: ok,
}
}
func lunarEclipseContactAngleMap(points []LunarEclipseContactPoint) map[string]float64 {
angles := make(map[string]float64, len(points))
for _, point := range points {
angles[point.Label] = point.ContactPositionAngle
}
return angles
}
func lunarEclipseSVGContactName(label, language string) string {
if language == lunarEclipseSVGLanguageEnglish {
switch label {
case "P1":
return "Penumbral begins"
case "U1":
return "Partial begins"
case "U2":
return "Total begins"
case "Greatest":
return "Greatest"
case "U3":
return "Total ends"
case "U4":
return "Partial ends"
case "P4":
return "Penumbral ends"
default:
return label
}
}
switch label {
case "P1":
return "半影始"
case "U1":
return "初亏"
case "U2":
return "食既"
case "Greatest":
return "食甚"
case "U3":
return "生光"
case "U4":
return "复圆"
case "P4":
return "半影终"
default:
return label
}
}
func durationToDays(duration time.Duration) float64 {
if duration <= 0 {
return 0
}
return duration.Hours() / 24
}
+9
View File
@@ -0,0 +1,9 @@
package svg
import _ "embed"
// lunarEclipseSVGMoonSymbol is a compact public-domain Moon face derived from
// labs/Full_Moon_clip_art.svg and simplified for small eclipse contact disks.
//
//go:embed lunar_eclipse_moon.svg
var lunarEclipseSVGMoonSymbol string
File diff suppressed because one or more lines are too long
+146
View File
@@ -0,0 +1,146 @@
package svg
import (
"math"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
type LunarEclipseType = eclipsecore.LunarEclipseType
type LunarEclipseContactPoint = eclipsecore.LunarEclipseContactPoint
type LunarEclipseInfo = eclipsecore.LunarEclipseInfo
const (
LunarEclipseNone = eclipsecore.LunarEclipseNone
LunarEclipsePenumbral = eclipsecore.LunarEclipsePenumbral
LunarEclipsePartial = eclipsecore.LunarEclipsePartial
LunarEclipseTotal = eclipsecore.LunarEclipseTotal
)
func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.Location) LunarEclipseInfo {
return LunarEclipseInfo{
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 lunarEclipsePositionAngle(shadowRA, shadowDec, moonRA, moonDec)
}
func lunarEclipseShadowCenterRaDec(ttJDE float64) (float64, float64) {
sunRA, sunDec := basic.HSunApparentRaDec(ttJDE)
return normalizeDegree360(sunRA + 180), -sunDec
}
func lunarEclipsePositionAngle(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 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 normalizeDegree360(angle float64) float64 {
angle = math.Mod(angle, 360)
if angle < 0 {
angle += 360
}
return angle
}
+104
View File
@@ -0,0 +1,104 @@
package svg
import (
"strings"
"testing"
"time"
"b612.me/astro/basic"
)
func TestLunarEclipseSVG(t *testing.T) {
svg, ok := LunarEclipseSVG(
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
LunarEclipseSVGOptions{Width: 720, Height: 480, Step: 10 * time.Minute},
)
if !ok {
t.Fatalf("expected lunar eclipse SVG")
}
for _, want := range []string{"<svg", "月全食", "黄道", "赤经", "黄经", "狮子座", "方位", "P1", "U1", "U2", "食甚", "U3", "U4", "P4", "UTC+8", "沙罗"} {
if !strings.Contains(svg, want) {
t.Fatalf("SVG missing %q", want)
}
}
if got := strings.Count(svg, `class="event-moon"`); got != 7 {
t.Fatalf("event moon count = %d, want 7", got)
}
}
func TestLunarEclipseSVGEnglishOption(t *testing.T) {
svg, ok := LunarEclipseSVG(
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
LunarEclipseSVGOptions{Language: "en", Location: time.UTC},
)
if !ok {
t.Fatalf("expected lunar eclipse SVG")
}
for _, want := range []string{"Total Lunar Eclipse", "Ecliptic", "Moon: RA", "ecl.lon", "PA", "Greatest", "Contacts (UTC)", "Lunar Saros"} {
if !strings.Contains(svg, want) {
t.Fatalf("SVG missing %q", want)
}
}
}
func TestLunarEclipseSVGCustomText(t *testing.T) {
svg, ok := LunarEclipseSVG(
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
LunarEclipseSVGOptions{
Title: "Custom lunar title",
SummaryText: "Custom lunar summary",
MaximumText: "Custom lunar maximum",
CoordinatesText: "Custom lunar coordinates",
DurationText: "Custom lunar duration",
MetaText: "Custom lunar meta",
ContactsTitle: "Custom lunar contacts",
DirectionText: "Custom lunar direction",
FooterNote: "Custom lunar footer",
},
)
if !ok {
t.Fatalf("expected lunar eclipse SVG")
}
for _, want := range []string{
"Custom lunar title",
"Custom lunar summary",
"Custom lunar maximum",
"Custom lunar coordinates",
"Custom lunar duration",
"Custom lunar meta",
"Custom lunar contacts",
"Custom lunar direction",
"Custom lunar footer",
} {
if !strings.Contains(svg, want) {
t.Fatalf("SVG missing custom text %q", want)
}
}
}
func TestLunarEclipseSVGNoEvent(t *testing.T) {
_, ok := LunarEclipseSVG(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), LunarEclipseSVGOptions{})
if ok {
t.Fatalf("unexpected lunar eclipse SVG for no-event date")
}
}
func TestLunarEclipseSVGEventPointsExpandMergedLabels(t *testing.T) {
events := lunarEclipseSVGEventPoints([]lunarEclipseSVGPoint{
{
LunarEclipseDiagramPoint: basic.LunarEclipseDiagramPoint{
Label: "Greatest",
Labels: []string{"U2", "Greatest", "U3"},
},
},
})
if got, want := len(events), 3; got != want {
t.Fatalf("event point count = %d, want %d", got, want)
}
want := []string{"U2", "Greatest", "U3"}
for i, label := range want {
if events[i].Label != label {
t.Fatalf("event labels = %#v, want %v", []string{events[0].Label, events[1].Label, events[2].Label}, want)
}
}
}
+1120
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
package svg
import (
"math"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
type SolarEclipseRadiusModel = eclipsecore.SolarEclipseRadiusModel
type SolarEclipseType = eclipsecore.SolarEclipseType
type LocalSolarEclipseContactPoint = eclipsecore.LocalSolarEclipseContactPoint
type LocalSolarEclipseInfo = eclipsecore.LocalSolarEclipseInfo
const (
SolarEclipseModelIAUSingleK = eclipsecore.SolarEclipseModelIAUSingleK
SolarEclipseModelNASABulletinSplitK = eclipsecore.SolarEclipseModelNASABulletinSplitK
SolarEclipseNone = eclipsecore.SolarEclipseNone
SolarEclipsePartial = eclipsecore.SolarEclipsePartial
SolarEclipseAnnular = eclipsecore.SolarEclipseAnnular
SolarEclipseTotal = eclipsecore.SolarEclipseTotal
SolarEclipseHybrid = eclipsecore.SolarEclipseHybrid
)
func localSolarEclipseInfoFromDiagram(
diagram basic.LocalSolarEclipseDiagramResult,
lon, lat, height float64,
location *time.Location,
) LocalSolarEclipseInfo {
info := localSolarEclipseInfoFieldsFromBasic(diagram.Eclipse, lon, lat, height, location)
info.ContactPoints = localSolarEclipseContactPointsFromFrames(diagram.Frames, location)
return info
}
func localSolarEclipseInfoFieldsFromBasic(
result basic.LocalSolarEclipseResult,
lon, lat, height float64,
location *time.Location,
) LocalSolarEclipseInfo {
visibleThreshold := localSolarEclipseVisibilityThreshold(height, lat)
return LocalSolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
Type: mapBasicSolarEclipseType(result.Type),
Longitude: lon,
Latitude: lat,
Height: height,
GreatestEclipse: solarEclipseTTJDEToTime(result.GreatestEclipse, location),
PartialStart: solarEclipseTTJDEToTime(result.PartialStart, location),
PartialEnd: solarEclipseTTJDEToTime(result.PartialEnd, location),
CentralStart: solarEclipseTTJDEToTime(result.CentralStart, location),
CentralEnd: solarEclipseTTJDEToTime(result.CentralEnd, location),
Magnitude: result.Magnitude,
Obscuration: result.Obscuration,
Separation: result.Separation,
SunAltitude: result.SunAltitude,
SunAzimuth: result.SunAzimuth,
VisibleAtGreatest: result.SunAltitude > visibleThreshold,
HasPartial: result.HasPartial,
HasCentral: result.HasCentral,
HasAnnular: result.HasAnnular,
HasTotal: result.HasTotal,
}
}
func localSolarEclipseContactPointsFromFrames(
frames []basic.LocalSolarEclipseDiagramFrame,
location *time.Location,
) []LocalSolarEclipseContactPoint {
contacts := make([]LocalSolarEclipseContactPoint, 0, 4)
for _, frame := range frames {
for _, label := range localSolarEclipseFrameLabels(frame) {
switch label {
case "C1", "C2", "C3", "C4":
contactPA := frame.PositionAngle
if (label == "C2" || label == "C3") && frame.MoonRadius >= frame.SunRadius {
contactPA = normalizeSolarEclipseDegree360(contactPA + 180)
}
contacts = append(contacts, LocalSolarEclipseContactPoint{
Label: label,
Time: solarEclipseTTJDEToTime(frame.JDE, location),
ContactPositionAngle: contactPA,
ContactClockwiseAngle: normalizeSolarEclipseDegree360(360 - contactPA),
MoonCenterPositionAngle: frame.PositionAngle,
})
}
}
}
return contacts
}
func localSolarEclipseFrameLabels(frame basic.LocalSolarEclipseDiagramFrame) []string {
if len(frame.Labels) > 0 {
return frame.Labels
}
if frame.Label == "" {
return nil
}
return []string{frame.Label}
}
func mapBasicSolarEclipseModel(model basic.SolarEclipseRadiusModel) SolarEclipseRadiusModel {
switch model {
case basic.SolarEclipseModelIAUSingleK:
return SolarEclipseModelIAUSingleK
default:
return SolarEclipseModelNASABulletinSplitK
}
}
func mapBasicSolarEclipseType(eclipseType basic.SolarEclipseType) SolarEclipseType {
switch eclipseType {
case basic.SolarEclipsePartial:
return SolarEclipsePartial
case basic.SolarEclipseAnnular:
return SolarEclipseAnnular
case basic.SolarEclipseTotal:
return SolarEclipseTotal
case basic.SolarEclipseHybrid:
return SolarEclipseHybrid
default:
return SolarEclipseNone
}
}
func solarEclipseTTJDEToTime(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 solarEclipseTimeToTTJDE(date time.Time) float64 {
utcJDE := basic.Date2JDE(date.UTC())
return basic.TD2UT(utcJDE, true)
}
func localSolarEclipseVisibilityThreshold(height, latitude float64) float64 {
if height <= 0 {
return 0
}
return -basic.HeightDegreeByLat(height, latitude)
}
func normalizeSolarEclipseDegree360(angle float64) float64 {
angle = math.Mod(angle, 360)
if angle < 0 {
angle += 360
}
return angle
}
+142
View File
@@ -0,0 +1,142 @@
package svg
import (
"math"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
func TestLocalSolarEclipseSVG(t *testing.T) {
svg, ok := LocalSolarEclipseSVG(
time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC),
-96.7970,
32.7767,
0,
LocalSolarEclipseSVGOptions{Width: 640, Height: 480, Step: 5 * time.Minute},
)
if !ok {
t.Fatalf("expected local solar eclipse SVG")
}
for _, want := range []string{"<svg", "站心日全食", "全局路径", "阶段视圆图", "黄道", "C1", "食既", "食甚", "生光", "C4", "方位", "左东右西", "UTC+8", "太阳位于", "沙罗", "全食历时", `fill="#efefed"`, `class="contact-point"`} {
if !strings.Contains(svg, want) {
t.Fatalf("SVG missing %q", want)
}
}
for _, notWant := range []string{"中心食始", "中心食终"} {
if strings.Contains(svg, notWant) {
t.Fatalf("SVG should not contain %q for total eclipse", notWant)
}
}
if got := strings.Count(svg, `class="event-moon"`); got != 3 {
t.Fatalf("event moon count = %d, want 3", got)
}
if got := strings.Count(svg, `class="stage-moon"`); got != 5 {
t.Fatalf("stage moon count = %d, want 5", got)
}
if got := strings.Count(svg, `class="event-center"`); got != 5 {
t.Fatalf("event center count = %d, want 5", got)
}
}
func TestLocalSolarEclipseSVGEnglishOption(t *testing.T) {
svg, ok := LocalSolarEclipseSVG(
time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC),
-96.7970,
32.7767,
0,
LocalSolarEclipseSVGOptions{Language: "en", Location: time.UTC},
)
if !ok {
t.Fatalf("expected local solar eclipse SVG")
}
for _, want := range []string{"Local Solar Eclipse", "Greatest", "PA", "East is left", "Ecliptic", "Contacts (UTC)", "Sun in", "Solar Saros", "Totality"} {
if !strings.Contains(svg, want) {
t.Fatalf("SVG missing %q", want)
}
}
}
func TestLocalSolarEclipseSVGCustomText(t *testing.T) {
svg, ok := LocalSolarEclipseSVG(
time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC),
-96.7970,
32.7767,
0,
LocalSolarEclipseSVGOptions{
Title: "Custom solar title",
SummaryText: "Custom solar summary",
GreatestText: "Custom solar greatest",
MetaText: "Custom solar meta",
OverviewTitle: "Custom solar overview",
PhasePanelsTitle: "Custom solar phases",
ContactsTitle: "Custom solar contacts",
DirectionText: "Custom solar direction",
FooterNote: "Custom solar footer",
},
)
if !ok {
t.Fatalf("expected local solar eclipse SVG")
}
for _, want := range []string{
"Custom solar title",
"Custom solar summary",
"Custom solar greatest",
"Custom solar meta",
"Custom solar overview",
"Custom solar phases",
"Custom solar contacts",
"Custom solar direction",
"Custom solar footer",
} {
if !strings.Contains(svg, want) {
t.Fatalf("SVG missing custom text %q", want)
}
}
}
func TestLocalSolarEclipseSVGStagePanelsShareScale(t *testing.T) {
svg, ok := LocalSolarEclipseSVG(
time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC),
-96.7970,
32.7767,
0,
LocalSolarEclipseSVGOptions{Width: 640, Height: 480, Step: 5 * time.Minute},
)
if !ok {
t.Fatalf("expected local solar eclipse SVG")
}
re := regexp.MustCompile(`<circle cx="[-0-9.]+" cy="[-0-9.]+" r="([0-9.]+)" fill="url\(#se-sun\)" stroke="#c78211" stroke-width="1"/>`)
matches := re.FindAllStringSubmatch(svg, -1)
if got, want := len(matches), 5; got != want {
t.Fatalf("stage sun count = %d, want %d", got, want)
}
first, err := strconv.ParseFloat(matches[0][1], 64)
if err != nil {
t.Fatalf("parse first radius: %v", err)
}
for i := 1; i < len(matches); i++ {
radius, err := strconv.ParseFloat(matches[i][1], 64)
if err != nil {
t.Fatalf("parse radius %d: %v", i, err)
}
if math.Abs(radius-first) > 1e-9 {
t.Fatalf("stage panel radii differ: first=%f current=%f", first, radius)
}
}
}
func TestLocalSolarEclipseSVGNoEvent(t *testing.T) {
_, ok := LocalSolarEclipseSVG(
time.Date(2024, 5, 15, 12, 0, 0, 0, time.UTC),
-96.7970,
32.7767,
0,
LocalSolarEclipseSVGOptions{},
)
if ok {
t.Fatalf("unexpected local solar eclipse SVG for no-event date")
}
}