feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息 - 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算 - 新增木星伽利略卫星位置、现象与接触事件计算 - 新增恒星星表、星座判定、自行修正与观测辅助能力 - 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包 - 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算 - 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试 - 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试 - 更新中文和英文 README,补充示例、精度说明、SVG 配图
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user