feat: 增强日月食搜索、沙罗周期与内行星凌日

- 使用压缩表加速查找日月食沙罗周期信息
- 优化日月食搜索跳步,减少非食季朔望月扫描
- 新增本地日全食、日环食、月全食搜索接口,返回 ok 区分未找到结果
- 新增水星、金星地心凌日查询及测试
This commit is contained in:
兔子 2026-05-03 19:00:08 +08:00
parent 3ffdbe0034
commit bec7b8a0d8
Signed by: b612
GPG Key ID: 99DD2222B612B612
20 changed files with 1987 additions and 408 deletions

View File

@ -46,7 +46,7 @@ go get b612.me/astro
- Lunar position, rise/set, Earth distance, phase, new/full/quarter times, apparent diameter, bright-limb angle, parallactic angle, geocentric/topocentric libration, apsides, nodes, maximum declination
- `lite/sun` and `lite/moon` lightweight approximation chains for watches, frontends, mini programs, and other resource-constrained environments
- Global and local solar/lunar eclipses, solar central paths, partial footprints, visible local lunar eclipses, Saros metadata, and SVG diagrams
- Seven major planets with positions, rise/set, conjunction/opposition/station events, quadratures, elongations, nodes, phase, apparent magnitude, apparent diameter, parallactic angle, and physical ephemerides
- Seven major planets with positions, rise/set, conjunction/opposition/station events, quadratures, elongations, Mercury/Venus geocentric transits, nodes, phase, apparent magnitude, apparent diameter, parallactic angle, and physical ephemerides
- 9100+ star catalog entries, constellation lookup, proper-motion propagation, rise/set, parallactic angle, and apparent altitude
- Coordinate transforms, topocentric coordinates, sidereal time, precession, nutation, angular distance, refraction, airmass, parallactic angle, and Galactic coordinates
- Standalone formulas for blackbody radiation, synodic periods, photometry, telescope limiting magnitude, stellar radius/temperature/luminosity relations, and airmass models
@ -63,7 +63,7 @@ go get b612.me/astro
| `moon` | Lunar position, rise/set, phases, new/full/quarter times, apparent altitude, parallactic angle, diameter, bright-limb angle, geocentric/topocentric libration, apsides, nodes, maximum declination |
| `lite/sun` / `lite/moon` | Lightweight Sun/Moon approximation chains for minute-level rise/set, lightweight sky position, and lunar-phase work |
| `eclipse` / `eclipse/svg` | Global/local solar and lunar eclipses, solar central paths, partial footprints, local visibility filtering, Saros metadata, SVG output |
| `mercury` / `venus` | Positions, rise/set, conjunctions, stations, elongations, phase, parallactic angle, magnitude, diameter, nodes, physical ephemerides |
| `mercury` / `venus` | Positions, rise/set, conjunctions, stations, elongations, geocentric transits, phase, parallactic angle, magnitude, diameter, nodes, physical ephemerides |
| `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | Positions, rise/set, conjunction/opposition, stations, quadratures, phase, parallactic angle, magnitude, diameter, nodes, physical ephemerides |
| `earth` | Earth orbital eccentricity, perihelion, aphelion |
| `star` | Constellation lookup, star catalog, proper motion / precession / nutation correction, stellar rise/set, parallactic angle, apparent altitude |
@ -674,6 +674,8 @@ Common entry points:
- `LastSolarEclipse` / `NextSolarEclipse` / `ClosestSolarEclipse`: search global solar eclipses
- `LocalSolarEclipseOnDate`: detect whether a site can see a local solar eclipse on that date
- `LastLocalSolarEclipse` / `NextLocalSolarEclipse` / `ClosestLocalSolarEclipse`: search locally visible solar eclipses
- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`: search locally visible total solar eclipses, returning `(info, ok)`
- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`: search locally visible annular solar eclipses, returning `(info, ok)`
- `SolarEclipseCentralPath`: compute central line, northern/southern limits, and greatest-eclipse point
- `SolarEclipsePartialFootprints`: compute the partial-eclipse penumbral footprint on Earth
- `eclipse/svg.LocalSolarEclipseSVG`: render a local solar-disk SVG
@ -925,6 +927,7 @@ Common entry points:
- `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`: search global lunar eclipses
- `LocalLunarEclipseOnDate`: detect whether a visible lunar eclipse is visible from a site on a local date
- `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`: search visible local lunar eclipses
- `LastLocalTotalLunarEclipse` / `NextLocalTotalLunarEclipse` / `ClosestLocalTotalLunarEclipse`: search visible local total lunar eclipses, returning `(info, ok)`
- `GeometricLocalLunarEclipseOnDate`: detect geometric lunar eclipse overlap without filtering by whether the Moon is above the horizon
- `eclipse/svg.LunarEclipseSVG`: render a lunar-eclipse shadow-path SVG
@ -1191,6 +1194,67 @@ For `date := 2020-01-01 08:08:08 CST`, the output is:
76.86008484515058 256.8600848451506 // Venus ascending-node and descending-node longitudes, degrees
```
Mercury and Venus also expose `NextTransit` / `LastTransit` / `ClosestTransit` for geocentric planetary transits. "Geocentric" means the planet disk crosses the solar disk as seen from Earth's center; it does not test whether the Sun is above the horizon at a particular observing site. For observing plans, combine this with local solar altitude and weather.
```go
package main
import (
"fmt"
"time"
"b612.me/astro/mercury"
"b612.me/astro/venus"
)
func main() {
// Next geocentric Mercury transit after the beginning of 2019.
mercuryTransit := mercury.NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC))
fmt.Println(mercuryTransit.Valid)
fmt.Println(mercuryTransit.Start)
fmt.Println(mercuryTransit.InternalStart)
fmt.Println(mercuryTransit.Greatest)
fmt.Println(mercuryTransit.InternalEnd)
fmt.Println(mercuryTransit.End)
fmt.Println(mercuryTransit.Duration)
fmt.Println(mercuryTransit.MinimumSeparationArcsec)
fmt.Println(mercuryTransit.SunSemidiameterArcsec)
fmt.Println(mercuryTransit.PlanetSemidiameterArcsec)
// Next geocentric Venus transit after the beginning of 2012.
venusTransit := venus.NextTransit(time.Date(2012, 1, 1, 0, 0, 0, 0, time.UTC))
fmt.Println(venusTransit.Valid)
fmt.Println(venusTransit.Start)
fmt.Println(venusTransit.InternalStart)
fmt.Println(venusTransit.Greatest)
fmt.Println(venusTransit.InternalEnd)
fmt.Println(venusTransit.End)
fmt.Println(venusTransit.Duration)
}
```
Output:
```text
true // a valid geocentric Mercury transit was found
2019-11-11 12:35:31.637522578 +0000 UTC // first contact: Mercury externally enters the solar disk
2019-11-11 12:37:12.887506484 +0000 UTC // second contact: Mercury is fully inside the solar disk
2019-11-11 15:19:48.430488109 +0000 UTC // greatest transit: Mercury center is closest to the Sun center
2019-11-11 18:02:29.246907234 +0000 UTC // third contact: Mercury starts leaving the solar disk
2019-11-11 18:04:10.707873702 +0000 UTC // fourth contact: Mercury externally leaves the solar disk
5h28m39.070351124s // geocentric transit duration from first to fourth contact
75.92460219695154 // minimum Mercury-Sun center separation at greatest transit, arcseconds
968.8881521396688 // solar semidiameter at greatest transit, arcseconds
4.978442856283907 // Mercury semidiameter at greatest transit, arcseconds
true // a valid geocentric Venus transit was found
2012-06-05 22:09:47.581470608 +0000 UTC // first contact: Venus externally enters the solar disk
2012-06-05 22:27:35.979940295 +0000 UTC // second contact: Venus is fully inside the solar disk
2012-06-06 01:29:35.686955451 +0000 UTC // greatest transit: Venus center is closest to the Sun center
2012-06-06 04:31:35.18302828 +0000 UTC // third contact: Venus starts leaving the solar disk
2012-06-06 04:49:23.581457734 +0000 UTC // fourth contact: Venus externally leaves the solar disk
6h39m35.999987126s // geocentric transit duration from first to fourth contact
```
#### Outer planets
```go
@ -1767,7 +1831,7 @@ Notes:
- `lite/sun` and `lite/moon` lightweight Sun/Moon chains for minute-level rise/set, lightweight position, and lunar-phase work
- Earth eccentricity, Sun-Earth distance, perihelion, aphelion
- Apparent/mean sidereal time, constellation lookup, common coordinate transforms, refraction, airmass, parallactic angle, Galactic coordinates
- Seven major-planet coordinates, Sun/body and Earth/body distances, special events, physical ephemerides, apparent diameters, phases, parallactic angles, and nodes
- Seven major-planet coordinates, Sun/body and Earth/body distances, special events, Mercury/Venus geocentric transits, physical ephemerides, apparent diameters, phases, parallactic angles, and nodes
- Chinese lunisolar calendar conversion from 104 BCE to 3000 CE
- 9100+ star catalog
- Generic small-body orbit propagation, H-G apparent magnitude, visual-binary position angle and separation

View File

@ -44,7 +44,7 @@ go get b612.me/astro
- 🌙 **月亮计算**:天球位置、月出月落、地月距离、月相、朔望时间、视直径、亮边位置角、视差角、地心/站心天平动、近远地点、交点、最大赤纬等
- 🪶 **轻量链路**`lite/sun``lite/moon` 提供面向手表、前端、小程序和其它资源受限环境的轻量近似太阳/月亮算法,覆盖天球位置、升落和月相
- 🌗 **日月食**:全局日食、站心日食、中心线/偏食足迹、月食、地方可见月食与 SVG 示意图
- 🪐 **行星计算**:七大行星天球位置、升落时间、合冲留等特殊天象时间、升交点/降交点、视直径/视半径、相位、视差角、节点、视星等与物理星历
- 🪐 **行星计算**:七大行星天球位置、升落时间、合冲留、大距、水星/金星地心凌日等特殊天象时间、升交点/降交点、视直径/视半径、相位、视差角、节点、视星等与物理星历
- ⭐ **恒星计算**指定天球坐标所属星座同时包含9100颗恒星数据库可计算升降时间、视差角和视高度角获取指定日期的恒星坐标信息
- 🧭 **坐标工具**:黄道/赤道/地平坐标转换、站心坐标、恒星时、岁差、章动、角距离、大气折射、大气质量、视差角、银道坐标
- 🔭 **研究公式**:黑体辐射、会合周期、星等距离换算、望远镜极限星等、恒星半径/温度/光度换算、大气质量模型
@ -61,7 +61,7 @@ go get b612.me/astro
| `moon` | 月亮位置、月出月落、月相、朔望弦、视高度角、视差角、视直径、亮边位置角、地心/站心天平动、近远地点、交点、最大赤纬 |
| `lite/sun` / `lite/moon` | 轻量太阳/月亮近似链路,面向分钟级升落、轻量天球位置和月相计算 |
| `eclipse` / `eclipse/svg` | 全局/局地日月食、日食中心线与偏食足迹、局地可见性筛选、日月食 SVG |
| `mercury` / `venus` | 水星、金星位置、升落、合日、留、大距、相位、视差角、视星等、视直径、节点和物理星历 |
| `mercury` / `venus` | 水星、金星位置、升落、合日、留、大距、地心凌日、相位、视差角、视星等、视直径、节点和物理星历 |
| `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | 外行星位置、升落、合冲、留、方照、相位、视差角、视星等、视直径、节点和物理星历 |
| `earth` | 地球轨道偏心率、近日点、远日点 |
| `star` | 星座判定、恒星数据库、恒星自行/岁差/章动修正、恒星升落、视差角、视高度角 |
@ -742,6 +742,8 @@ func main() {
- `LastSolarEclipse` / `NextSolarEclipse` / `ClosestSolarEclipse`:搜索全局日食
- `LocalSolarEclipseOnDate`:判断某地当天是否能看到站心日食
- `LastLocalSolarEclipse` / `NextLocalSolarEclipse` / `ClosestLocalSolarEclipse`:搜索某地可见的站心日食
- `LastLocalTotalSolarEclipse` / `NextLocalTotalSolarEclipse` / `ClosestLocalTotalSolarEclipse`:搜索某地可见的日全食,返回 `(info, ok)`
- `LastLocalAnnularSolarEclipse` / `NextLocalAnnularSolarEclipse` / `ClosestLocalAnnularSolarEclipse`:搜索某地可见的日环食,返回 `(info, ok)`
- `SolarEclipseCentralPath`:计算中心线、南北界和食甚点
- `SolarEclipsePartialFootprints`:计算偏食半影在地球表面的足迹
- `eclipse/svg.LocalSolarEclipseSVG`:生成某地的日面视圆 SVG
@ -995,6 +997,7 @@ true 13424 // 北京日全食 SVG 生成成功,长度 13424 字节
- `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`:搜索全局月食
- `LocalLunarEclipseOnDate`:判断某地当天是否能看到可见月食
- `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`:搜索某地可见月食
- `LastLocalTotalLunarEclipse` / `NextLocalTotalLunarEclipse` / `ClosestLocalTotalLunarEclipse`:搜索某地可见月全食,返回 `(info, ok)`
- `GeometricLocalLunarEclipseOnDate`:判断某地当天是否发生几何月食,不做“月亮在地平线上方”的可见性过滤
- `eclipse/svg.LunarEclipseSVG`:生成月食穿影图 SVG
@ -1267,6 +1270,67 @@ fmt.Println(venus.AscendingNode(date), venus.DescendingNode(date))
76.86008484515058 256.8600848451506 // 金星升交点、降交点黄经,单位度
```
水星和金星还提供 `NextTransit` / `LastTransit` / `ClosestTransit` 地心凌日查询。这里的“地心”指从地球中心看到的行星圆面经过太阳圆面,不判断某个地点当时太阳是否在地平线上;如果要做观测计划,还需要结合本地太阳高度角和天气条件。
```go
package main
import (
"fmt"
"time"
"b612.me/astro/mercury"
"b612.me/astro/venus"
)
func main() {
// 查询 2019 年之后下一次地心水星凌日。
mercuryTransit := mercury.NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC))
fmt.Println(mercuryTransit.Valid)
fmt.Println(mercuryTransit.Start)
fmt.Println(mercuryTransit.InternalStart)
fmt.Println(mercuryTransit.Greatest)
fmt.Println(mercuryTransit.InternalEnd)
fmt.Println(mercuryTransit.End)
fmt.Println(mercuryTransit.Duration)
fmt.Println(mercuryTransit.MinimumSeparationArcsec)
fmt.Println(mercuryTransit.SunSemidiameterArcsec)
fmt.Println(mercuryTransit.PlanetSemidiameterArcsec)
// 查询 2012 年之后下一次地心金星凌日。
venusTransit := venus.NextTransit(time.Date(2012, 1, 1, 0, 0, 0, 0, time.UTC))
fmt.Println(venusTransit.Valid)
fmt.Println(venusTransit.Start)
fmt.Println(venusTransit.InternalStart)
fmt.Println(venusTransit.Greatest)
fmt.Println(venusTransit.InternalEnd)
fmt.Println(venusTransit.End)
fmt.Println(venusTransit.Duration)
}
```
输出结果:
```text
true // 找到一次有效的地心水星凌日
2019-11-11 12:35:31.637522578 +0000 UTC // 一触:水星外切进入太阳圆面
2019-11-11 12:37:12.887506484 +0000 UTC // 二触:水星完全进入太阳圆面
2019-11-11 15:19:48.430488109 +0000 UTC // 凌甚:水星中心最接近太阳中心
2019-11-11 18:02:29.246907234 +0000 UTC // 三触:水星开始离开太阳圆面
2019-11-11 18:04:10.707873702 +0000 UTC // 四触:水星外切离开太阳圆面
5h28m39.070351124s // 一触到四触的地心凌日持续时间
75.92460219695154 // 凌甚时水星中心与太阳中心的最小角距离,单位角秒
968.8881521396688 // 凌甚时太阳视半径,单位角秒
4.978442856283907 // 凌甚时水星视半径,单位角秒
true // 找到一次有效的地心金星凌日
2012-06-05 22:09:47.581470608 +0000 UTC // 一触:金星外切进入太阳圆面
2012-06-05 22:27:35.979940295 +0000 UTC // 二触:金星完全进入太阳圆面
2012-06-06 01:29:35.686955451 +0000 UTC // 凌甚:金星中心最接近太阳中心
2012-06-06 04:31:35.18302828 +0000 UTC // 三触:金星开始离开太阳圆面
2012-06-06 04:49:23.581457734 +0000 UTC // 四触:金星外切离开太阳圆面
6h39m35.999987126s // 一触到四触的地心凌日持续时间
```
#### 外行星
```go
@ -1849,7 +1913,7 @@ func main() {
- ✅ `lite/sun``lite/moon` 轻量太阳/月亮链路:面向分钟级升落、轻量位置和月相计算
- ✅ 地球偏心率、日地距离、近日点、远日点
- ✅ 真平恒星时、星座计算、常用坐标转换、大气折射、大气质量、视差角、银道坐标
- ✅ 七大行星坐标、距日距地距离、特殊天象、物理星历、视直径、相位、视差角与节点
- ✅ 七大行星坐标、距日距地距离、特殊天象、水星/金星地心凌日、物理星历、视直径、相位、视差角与节点
- ✅ 公农历转换公元前104年-公元3000年
- ✅ 9100+恒星数据库
- ✅ 通用小天体轨道传播、H-G 视星等、视双星位置角/角距

520
basic/planet_transit.go Normal file
View File

@ -0,0 +1,520 @@
package basic
import (
"math"
. "b612.me/astro/tools"
)
const (
planetTransitMeanSolarMotionDegPerDay = 360.0 / 365.2422
planetTransitTropicalYearDays = 365.2422
planetTransitSeasonProbeStepDays = 20.0
planetTransitSearchLimit = 2400
planetTransitSearchEpsilonDays = 1.0 / 86400.0
planetTransitGreatestWindowDays = 1.2
planetTransitGreatestToleranceDays = 0.25 / 86400.0
planetTransitContactStepDays = 0.02
planetTransitContactSpanDays = 1.0
planetTransitContactToleranceDays = 0.25 / 86400.0
planetTransitCoarseN = 16
)
// PlanetTransitResult 表示一次地心行星凌日结果。
//
// Valid 为 false 时表示没有找到有效凌日。所有时刻字段均为 UT 儒略日。
// MinimumSeparationArcsec、SunSemidiameterArcsec、PlanetSemidiameterArcsec 的单位均为角秒。
type PlanetTransitResult struct {
Valid bool
// PlanetIndex 为行星序号1 表示水星2 表示金星。
PlanetIndex int
// ExternalIngress / ExternalEgress 为一触 / 四触。
ExternalIngress float64
ExternalEgress float64
// InternalIngress / InternalEgress 为二触 / 三触。掠凌没有内切接触时为 0。
InternalIngress float64
InternalEgress float64
// Greatest 为凌甚,即行星中心最接近太阳中心的时刻。
Greatest float64
MinimumSeparationArcsec float64
SunSemidiameterArcsec float64
PlanetSemidiameterArcsec float64
HasExternal bool
HasInternal bool
}
type planetTransitConfig struct {
planetIndex int
synodicPeriodDays float64
anchorInferiorTT float64
seasonWindowDays float64
latitudePrefilter float64
conjunctionStepDay float64
apparentLoN func(float64, int) float64
apparentBoN func(float64, int) float64
apparentRaDecN func(float64, int) (float64, float64)
semidiameterN func(float64, int) float64
earthDistanceN func(float64, int) float64
nodeN func(float64, int) float64
}
type planetTransitState struct {
jdTT float64
separationArcsec float64
separationSquared float64
sunSemidiameter float64
planetSemidiameter float64
externalContactMetric float64
internalContactMetric float64
}
func mercuryTransitConfig() planetTransitConfig {
return planetTransitConfig{
planetIndex: 1,
synodicPeriodDays: MERCURY_S_PERIOD,
anchorInferiorTT: TD2UT(JDECalc(2019, 11, 11+(15+21.0/60+40.0/3600)/24), true),
seasonWindowDays: 12,
latitudePrefilter: 1.0,
conjunctionStepDay: 0.00001,
apparentLoN: MercuryApparentLoN,
apparentBoN: MercuryApparentBoN,
apparentRaDecN: MercuryApparentRaDecN,
semidiameterN: MercurySemidiameterN,
earthDistanceN: EarthMercuryAwayN,
nodeN: MercuryAscendingNodeN,
}
}
func venusTransitConfig() planetTransitConfig {
return planetTransitConfig{
planetIndex: 2,
synodicPeriodDays: VENUS_S_PERIOD,
anchorInferiorTT: TD2UT(JDECalc(2012, 6, 6+(1+29.0/60)/24), true),
seasonWindowDays: 8,
latitudePrefilter: 0.8,
conjunctionStepDay: 0.00001,
apparentLoN: VenusApparentLoN,
apparentBoN: VenusApparentBoN,
apparentRaDecN: VenusApparentRaDecN,
semidiameterN: VenusSemidiameterN,
earthDistanceN: EarthVenusAwayN,
nodeN: VenusAscendingNodeN,
}
}
// NextMercuryTransit 返回给定时刻之后的下一次地心水星凌日。
func NextMercuryTransit(jde float64) PlanetTransitResult {
result, _ := searchPlanetTransit(jde, mercuryTransitConfig(), 1, false)
return result
}
// LastMercuryTransit 返回给定时刻之前的上一次地心水星凌日。
func LastMercuryTransit(jde float64) PlanetTransitResult {
result, _ := searchPlanetTransit(jde, mercuryTransitConfig(), -1, true)
return result
}
// ClosestMercuryTransit 返回距给定时刻最近的一次地心水星凌日。
func ClosestMercuryTransit(jde float64) PlanetTransitResult {
return closestPlanetTransit(jde, mercuryTransitConfig())
}
// NextVenusTransit 返回给定时刻之后的下一次地心金星凌日。
func NextVenusTransit(jde float64) PlanetTransitResult {
result, _ := searchPlanetTransit(jde, venusTransitConfig(), 1, false)
return result
}
// LastVenusTransit 返回给定时刻之前的上一次地心金星凌日。
func LastVenusTransit(jde float64) PlanetTransitResult {
result, _ := searchPlanetTransit(jde, venusTransitConfig(), -1, true)
return result
}
// ClosestVenusTransit 返回距给定时刻最近的一次地心金星凌日。
func ClosestVenusTransit(jde float64) PlanetTransitResult {
return closestPlanetTransit(jde, venusTransitConfig())
}
func closestPlanetTransit(jde float64, cfg planetTransitConfig) PlanetTransitResult {
last, hasLast := searchPlanetTransit(jde, cfg, -1, true)
next, hasNext := searchPlanetTransit(jde, cfg, 1, false)
switch {
case hasLast && !hasNext:
return last
case !hasLast && hasNext:
return next
case !hasLast && !hasNext:
return PlanetTransitResult{}
}
if math.Abs(last.Greatest-jde) <= math.Abs(next.Greatest-jde) {
return last
}
return next
}
func searchPlanetTransit(jde float64, cfg planetTransitConfig, direction int, includeCurrent bool) (PlanetTransitResult, bool) {
if !isFiniteFloat(jde) || direction == 0 {
return PlanetTransitResult{}, false
}
targetTT := TD2UT(jde, true)
probeTT := targetTT
for i := 0; i < planetTransitSearchLimit; i++ {
seasonTT, ok := nextPlanetTransitSeasonTT(probeTT, cfg, direction)
if !ok {
return PlanetTransitResult{}, false
}
seedTT := nearestPlanetTransitInferiorSeedTT(seasonTT, cfg)
if math.Abs(seedTT-seasonTT) <= cfg.seasonWindowDays {
conjunctionTT := refinePlanetTransitInferiorConjunctionTT(seedTT, cfg)
if math.Abs(conjunctionTT-seasonTT) <= cfg.seasonWindowDays+1 && isPotentialPlanetTransit(conjunctionTT, cfg) {
resultTT, ok := planetTransitAtInferiorConjunctionTT(conjunctionTT, cfg)
if ok && planetTransitMatchesDirection(resultTT.Greatest, targetTT, direction, includeCurrent) {
return planetTransitResultTTToUT(resultTT), true
}
}
}
probeTT = seasonTT + float64(direction)*planetTransitSeasonProbeStepDays
}
return PlanetTransitResult{}, false
}
func nextPlanetTransitSeasonTT(jdTT float64, cfg planetTransitConfig, direction int) (float64, bool) {
best := math.NaN()
for nodeOffset := 0; nodeOffset <= 1; nodeOffset++ {
candidate := estimatePlanetTransitSeasonTT(jdTT, cfg, nodeOffset, direction)
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset)
for !planetTransitMatchesDirection(candidate, jdTT, direction, false) {
candidate += float64(direction) * planetTransitTropicalYearDays
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset)
}
if !isFiniteFloat(best) || math.Abs(candidate-jdTT) < math.Abs(best-jdTT) {
best = candidate
}
}
if !isFiniteFloat(best) {
return 0, false
}
return best, true
}
func estimatePlanetTransitSeasonTT(jdTT float64, cfg planetTransitConfig, nodeOffset int, direction int) float64 {
sunLongitude := HSunApparentLoN(jdTT, planetTransitCoarseN)
nodeLongitude := planetTransitNodeLongitude(jdTT, cfg, nodeOffset, planetTransitCoarseN)
if direction > 0 {
delta := Limit360(nodeLongitude - sunLongitude)
if delta <= planetTransitSearchEpsilonDays {
delta += 360
}
return jdTT + delta/planetTransitMeanSolarMotionDegPerDay
}
delta := Limit360(sunLongitude - nodeLongitude)
if delta <= planetTransitSearchEpsilonDays {
delta += 360
}
return jdTT - delta/planetTransitMeanSolarMotionDegPerDay
}
func refinePlanetTransitSeasonTT(seedTT float64, cfg planetTransitConfig, nodeOffset int) float64 {
current := seedTT
for i := 0; i < 8; i++ {
prev := current
value := planetTransitSunNodeLongitudeDelta(prev, cfg, nodeOffset)
slope := (planetTransitSunNodeLongitudeDelta(prev+0.5, cfg, nodeOffset) -
planetTransitSunNodeLongitudeDelta(prev-0.5, cfg, nodeOffset)) / 1.0
if slope == 0 || !isFiniteFloat(slope) {
break
}
current = prev - value/slope
if math.Abs(current-prev) <= 0.00001 {
break
}
}
return current
}
func planetTransitSunNodeLongitudeDelta(jdTT float64, cfg planetTransitConfig, nodeOffset int) float64 {
return planetTransitAngleDelta(HSunApparentLoN(jdTT, planetTransitCoarseN) -
planetTransitNodeLongitude(jdTT, cfg, nodeOffset, planetTransitCoarseN))
}
func planetTransitNodeLongitude(jdTT float64, cfg planetTransitConfig, nodeOffset int, n int) float64 {
return Limit360(cfg.nodeN(jdTT, n) + float64(nodeOffset)*180)
}
func nearestPlanetTransitInferiorSeedTT(seasonTT float64, cfg planetTransitConfig) float64 {
k := math.Round((seasonTT - cfg.anchorInferiorTT) / cfg.synodicPeriodDays)
return cfg.anchorInferiorTT + k*cfg.synodicPeriodDays
}
func refinePlanetTransitInferiorConjunctionTT(seedTT float64, cfg planetTransitConfig) float64 {
current := seedTT
for i := 0; i < 4; i++ {
prev := current
value := planetTransitLongitudeDeltaN(prev, cfg, planetTransitCoarseN)
slope := (planetTransitLongitudeDeltaN(prev+cfg.conjunctionStepDay, cfg, planetTransitCoarseN) -
planetTransitLongitudeDeltaN(prev-cfg.conjunctionStepDay, cfg, planetTransitCoarseN)) / (2 * cfg.conjunctionStepDay)
if slope == 0 || !isFiniteFloat(slope) {
break
}
current = prev - value/slope
if math.Abs(current-prev) <= 30.0/86400.0 {
break
}
}
for i := 0; i < 8; i++ {
prev := current
value := planetTransitLongitudeDeltaN(prev, cfg, -1)
slope := (planetTransitLongitudeDeltaN(prev+cfg.conjunctionStepDay, cfg, -1) -
planetTransitLongitudeDeltaN(prev-cfg.conjunctionStepDay, cfg, -1)) / (2 * cfg.conjunctionStepDay)
if slope == 0 || !isFiniteFloat(slope) {
break
}
current = prev - value/slope
if math.Abs(current-prev) <= cfg.conjunctionStepDay {
break
}
}
return current
}
func planetTransitLongitudeDeltaN(jdTT float64, cfg planetTransitConfig, n int) float64 {
return planetTransitAngleDelta(cfg.apparentLoN(jdTT, n) - HSunApparentLoN(jdTT, n))
}
func isPotentialPlanetTransit(conjunctionTT float64, cfg planetTransitConfig) bool {
if cfg.earthDistanceN(conjunctionTT, planetTransitCoarseN) > EarthAwayN(conjunctionTT, planetTransitCoarseN) {
return false
}
return math.Abs(cfg.apparentBoN(conjunctionTT, planetTransitCoarseN)) <= cfg.latitudePrefilter
}
func planetTransitAtInferiorConjunctionTT(conjunctionTT float64, cfg planetTransitConfig) (PlanetTransitResult, bool) {
greatestTT := greatestPlanetTransitTT(conjunctionTT, cfg)
greatestState := planetTransitStateAt(greatestTT, cfg, -1)
if !isFiniteFloat(greatestState.externalContactMetric) || greatestState.externalContactMetric > 0 {
return PlanetTransitResult{}, false
}
result := PlanetTransitResult{
Valid: true,
PlanetIndex: cfg.planetIndex,
Greatest: greatestTT,
MinimumSeparationArcsec: greatestState.separationArcsec,
SunSemidiameterArcsec: greatestState.sunSemidiameter,
PlanetSemidiameterArcsec: greatestState.planetSemidiameter,
HasExternal: true,
HasInternal: greatestState.internalContactMetric <= 0,
}
externalIngress, ok := refinePlanetTransitContactTT(greatestTT, cfg, -1, false)
if !ok {
return PlanetTransitResult{}, false
}
externalEgress, ok := refinePlanetTransitContactTT(greatestTT, cfg, 1, false)
if !ok || externalEgress <= externalIngress {
return PlanetTransitResult{}, false
}
result.ExternalIngress = externalIngress
result.ExternalEgress = externalEgress
if result.HasInternal {
internalIngress, ok := refinePlanetTransitContactTT(greatestTT, cfg, -1, true)
if ok {
result.InternalIngress = internalIngress
}
internalEgress, ok := refinePlanetTransitContactTT(greatestTT, cfg, 1, true)
if ok && internalEgress > internalIngress {
result.InternalEgress = internalEgress
}
result.HasInternal = result.InternalIngress != 0 && result.InternalEgress != 0
}
return result, true
}
func greatestPlanetTransitTT(seedTT float64, cfg planetTransitConfig) float64 {
left := seedTT - planetTransitGreatestWindowDays
right := seedTT + planetTransitGreatestWindowDays
goldenRatio := (math.Sqrt(5) - 1) / 2
x1 := right - goldenRatio*(right-left)
x2 := left + goldenRatio*(right-left)
f1 := planetTransitStateAt(x1, cfg, planetTransitCoarseN).separationSquared
f2 := planetTransitStateAt(x2, cfg, planetTransitCoarseN).separationSquared
for i := 0; i < 80 && right-left > planetTransitGreatestToleranceDays; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - goldenRatio*(right-left)
f1 = planetTransitStateAt(x1, cfg, planetTransitCoarseN).separationSquared
continue
}
left = x1
x1 = x2
f1 = f2
x2 = left + goldenRatio*(right-left)
f2 = planetTransitStateAt(x2, cfg, planetTransitCoarseN).separationSquared
}
center := (left + right) / 2
left = center - 2.0/24.0
right = center + 2.0/24.0
x1 = right - goldenRatio*(right-left)
x2 = left + goldenRatio*(right-left)
f1 = planetTransitStateAt(x1, cfg, -1).separationSquared
f2 = planetTransitStateAt(x2, cfg, -1).separationSquared
for i := 0; i < 80 && right-left > planetTransitGreatestToleranceDays; i++ {
if f1 <= f2 {
right = x2
x2 = x1
f2 = f1
x1 = right - goldenRatio*(right-left)
f1 = planetTransitStateAt(x1, cfg, -1).separationSquared
continue
}
left = x1
x1 = x2
f1 = f2
x2 = left + goldenRatio*(right-left)
f2 = planetTransitStateAt(x2, cfg, -1).separationSquared
}
return (left + right) / 2
}
func planetTransitStateAt(jdTT float64, cfg planetTransitConfig, n int) planetTransitState {
planetRA, planetDec := cfg.apparentRaDecN(jdTT, n)
sunRA, sunDec := HSunApparentRaDecN(jdTT, n)
separationArcsec := StarAngularSeparation(planetRA, planetDec, sunRA, sunDec) * 3600
sunSemidiameter := SunSemidiameterN(jdTT, n)
planetSemidiameter := cfg.semidiameterN(jdTT, n)
return planetTransitState{
jdTT: jdTT,
separationArcsec: separationArcsec,
separationSquared: separationArcsec * separationArcsec,
sunSemidiameter: sunSemidiameter,
planetSemidiameter: planetSemidiameter,
externalContactMetric: separationArcsec - (sunSemidiameter + planetSemidiameter),
internalContactMetric: separationArcsec - (sunSemidiameter - planetSemidiameter),
}
}
func refinePlanetTransitContactTT(greatestTT float64, cfg planetTransitConfig, direction int, internal bool) (float64, bool) {
if direction != -1 && direction != 1 {
return 0, false
}
metric := func(jdTT float64) float64 {
state := planetTransitStateAt(jdTT, cfg, -1)
if internal {
return state.internalContactMetric
}
return state.externalContactMetric
}
nearJD := greatestTT
nearValue := metric(nearJD)
if !isFiniteFloat(nearValue) || nearValue > 0 {
return 0, false
}
maxSteps := int(math.Ceil(planetTransitContactSpanDays / planetTransitContactStepDays))
for i := 1; i <= maxSteps; i++ {
farJD := greatestTT + float64(direction)*planetTransitContactStepDays*float64(i)
farValue := metric(farJD)
if !isFiniteFloat(farValue) {
continue
}
if farValue >= 0 {
return bisectPlanetTransitContactTT(nearJD, nearValue, farJD, farValue, metric)
}
nearJD = farJD
nearValue = farValue
}
return 0, false
}
func bisectPlanetTransitContactTT(leftJD, leftValue, rightJD, rightValue float64, metric func(float64) float64) (float64, bool) {
if leftJD > rightJD {
leftJD, rightJD = rightJD, leftJD
leftValue, rightValue = rightValue, leftValue
}
if leftValue == 0 {
return leftJD, true
}
if rightValue == 0 {
return rightJD, true
}
if leftValue*rightValue > 0 {
return 0, false
}
for i := 0; i < 80 && rightJD-leftJD > planetTransitContactToleranceDays; i++ {
midJD := (leftJD + rightJD) / 2
midValue := metric(midJD)
if !isFiniteFloat(midValue) {
return 0, false
}
if midValue == 0 {
return midJD, true
}
if leftValue*midValue <= 0 {
rightJD = midJD
rightValue = midValue
continue
}
leftJD = midJD
leftValue = midValue
}
return (leftJD + rightJD) / 2, true
}
func planetTransitResultTTToUT(result PlanetTransitResult) PlanetTransitResult {
result.Greatest = TD2UT(result.Greatest, false)
result.ExternalIngress = TD2UT(result.ExternalIngress, false)
result.ExternalEgress = TD2UT(result.ExternalEgress, false)
if result.InternalIngress != 0 {
result.InternalIngress = TD2UT(result.InternalIngress, false)
}
if result.InternalEgress != 0 {
result.InternalEgress = TD2UT(result.InternalEgress, false)
}
return result
}
func planetTransitMatchesDirection(eventJDE, targetJDE float64, direction int, includeCurrent bool) bool {
delta := eventJDE - targetJDE
if math.Abs(delta) <= planetTransitSearchEpsilonDays {
return includeCurrent
}
if direction > 0 {
return delta > 0
}
return delta < 0
}
func planetTransitAngleDelta(diff float64) float64 {
diff = Limit360(diff)
if diff > 180 {
diff -= 360
}
if diff < -180 {
diff += 360
}
return diff
}
func isFiniteFloat(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}

View File

@ -0,0 +1,145 @@
package basic
import (
"math"
"testing"
"time"
)
func TestKnownMercuryTransits(t *testing.T) {
tests := []struct {
name string
query time.Time
greatest time.Time
}{
{
name: "2016 May",
query: time.Date(2016, 1, 1, 0, 0, 0, 0, time.UTC),
greatest: time.Date(2016, 5, 9, 14, 57, 0, 0, time.UTC),
},
{
name: "2019 Nov",
query: time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC),
greatest: time.Date(2019, 11, 11, 15, 20, 0, 0, time.UTC),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := NextMercuryTransit(Date2JDE(tc.query))
if !result.Valid {
t.Fatal("expected valid transit")
}
got := JDE2DateByZone(result.Greatest, time.UTC, false)
t.Logf("start=%s greatest=%s end=%s min=%.3f sun=%.3f planet=%.3f",
JDE2DateByZone(result.ExternalIngress, time.UTC, false),
got,
JDE2DateByZone(result.ExternalEgress, time.UTC, false),
result.MinimumSeparationArcsec,
result.SunSemidiameterArcsec,
result.PlanetSemidiameterArcsec,
)
if math.Abs(got.Sub(tc.greatest).Seconds()) > 20*60 {
t.Fatalf("greatest mismatch: got %s want near %s", got, tc.greatest)
}
if !result.HasInternal {
t.Fatalf("expected internal contacts")
}
if !(result.ExternalIngress < result.InternalIngress &&
result.InternalIngress < result.Greatest &&
result.Greatest < result.InternalEgress &&
result.InternalEgress < result.ExternalEgress) {
t.Fatalf("contacts out of order: %+v", result)
}
})
}
}
func TestKnownVenusTransits(t *testing.T) {
tests := []struct {
name string
query time.Time
greatest time.Time
}{
{
name: "2004 Jun",
query: time.Date(2004, 1, 1, 0, 0, 0, 0, time.UTC),
greatest: time.Date(2004, 6, 8, 8, 20, 0, 0, time.UTC),
},
{
name: "2012 Jun",
query: time.Date(2012, 1, 1, 0, 0, 0, 0, time.UTC),
greatest: time.Date(2012, 6, 6, 1, 29, 0, 0, time.UTC),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := NextVenusTransit(Date2JDE(tc.query))
if !result.Valid {
t.Fatal("expected valid transit")
}
got := JDE2DateByZone(result.Greatest, time.UTC, false)
t.Logf("start=%s greatest=%s end=%s min=%.3f sun=%.3f planet=%.3f",
JDE2DateByZone(result.ExternalIngress, time.UTC, false),
got,
JDE2DateByZone(result.ExternalEgress, time.UTC, false),
result.MinimumSeparationArcsec,
result.SunSemidiameterArcsec,
result.PlanetSemidiameterArcsec,
)
if math.Abs(got.Sub(tc.greatest).Seconds()) > 20*60 {
t.Fatalf("greatest mismatch: got %s want near %s", got, tc.greatest)
}
if !result.HasInternal {
t.Fatalf("expected internal contacts")
}
if !(result.ExternalIngress < result.InternalIngress &&
result.InternalIngress < result.Greatest &&
result.Greatest < result.InternalEgress &&
result.InternalEgress < result.ExternalEgress) {
t.Fatalf("contacts out of order: %+v", result)
}
})
}
}
func TestTransitSearchSkipsSparseEvents(t *testing.T) {
mercuryResult := NextMercuryTransit(Date2JDE(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
if !mercuryResult.Valid {
t.Fatal("expected Mercury transit")
}
mercuryGreatest := JDE2DateByZone(mercuryResult.Greatest, time.UTC, false)
if mercuryGreatest.Year() != 2032 || mercuryGreatest.Month() != time.November {
t.Fatalf("unexpected next Mercury transit: %s", mercuryGreatest)
}
venusResult := NextVenusTransit(Date2JDE(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
if !venusResult.Valid {
t.Fatal("expected Venus transit")
}
venusGreatest := JDE2DateByZone(venusResult.Greatest, time.UTC, false)
if venusGreatest.Year() != 2117 || venusGreatest.Month() != time.December {
t.Fatalf("unexpected next Venus transit: %s", venusGreatest)
}
}
func BenchmarkNextMercuryTransitFrom2026(b *testing.B) {
jd := Date2JDE(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
for i := 0; i < b.N; i++ {
result := NextMercuryTransit(jd)
if !result.Valid {
b.Fatal("expected valid transit")
}
}
}
func BenchmarkNextVenusTransitFrom2026(b *testing.B) {
jd := Date2JDE(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
for i := 0; i < b.N; i++ {
result := NextVenusTransit(jd)
if !result.Valid {
b.Fatal("expected valid transit")
}
}
}

View File

@ -279,7 +279,7 @@ func searchLunarEclipse(
return lunarEclipseInfoFromBasic(result, date.Location()), true
}
}
candidateTT = basic.CalcMoonSHByJDE(candidateTT+float64(direction)*lunarEclipseSynodicMonthDays, 1)
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays)
}
return LunarEclipseInfo{}, false

View File

@ -147,6 +147,12 @@ func LastLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarE
return LastLocalLunarEclipseDanjon(date, lon, lat, height)
}
// LastLocalTotalLunarEclipse 上次可见月全食 / previous visible local total lunar eclipse.
// Previous visible local total lunar eclipse, using Danjon by default.
func LastLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return searchLocalTotalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
}
// 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 {
@ -187,6 +193,12 @@ func NextLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarE
return NextLocalLunarEclipseDanjon(date, lon, lat, height)
}
// NextLocalTotalLunarEclipse 下次可见月全食 / next visible local total lunar eclipse.
// Next visible local total lunar eclipse, using Danjon by default.
func NextLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return searchLocalTotalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
}
// 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 {
@ -227,6 +239,14 @@ func ClosestLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLun
return ClosestLocalLunarEclipseDanjon(date, lon, lat, height)
}
// ClosestLocalTotalLunarEclipse 最近一次可见月全食 / closest visible local total lunar eclipse.
// Closest visible local total lunar eclipse, using Danjon by default.
func ClosestLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
last, hasLast := searchLocalTotalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
next, hasNext := searchLocalTotalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
return closestLocalLunarEclipseResult(date, last, hasLast, next, hasNext)
}
// 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 {
@ -272,21 +292,32 @@ func closestLocalLunarEclipse(
next LocalLunarEclipseInfo,
hasNext bool,
) LocalLunarEclipseInfo {
info, _ := closestLocalLunarEclipseResult(date, last, hasLast, next, hasNext)
return info
}
func closestLocalLunarEclipseResult(
date time.Time,
last LocalLunarEclipseInfo,
hasLast bool,
next LocalLunarEclipseInfo,
hasNext bool,
) (LocalLunarEclipseInfo, bool) {
switch {
case hasLast && !hasNext:
return last
return last, true
case !hasLast && hasNext:
return next
return next, true
case !hasLast && !hasNext:
return LocalLunarEclipseInfo{}
return LocalLunarEclipseInfo{}, false
}
lastDistance := math.Abs(date.Sub(last.Maximum).Seconds())
nextDistance := math.Abs(next.Maximum.Sub(date).Seconds())
if lastDistance <= nextDistance {
return last
return last, true
}
return next
return next, true
}
func searchLocalLunarEclipse(
@ -311,7 +342,35 @@ func searchLocalLunarEclipse(
}
}
}
candidateTT = basic.CalcMoonSHByJDE(candidateTT+float64(direction)*lunarEclipseSynodicMonthDays, 1)
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays)
}
return LocalLunarEclipseInfo{}, false
}
func searchLocalTotalLunarEclipse(
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.HasTotal {
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, date.Location())
if (mode != localLunarEclipseQueryVisible || localTotalLunarEclipseVisible(info)) &&
lunarEclipseMatchesDirection(result.Maximum, targetTT, direction, includeCurrent) {
return info, true
}
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays)
}
return LocalLunarEclipseInfo{}, false
@ -375,6 +434,13 @@ func localLunarEclipseVisible(info LocalLunarEclipseInfo) bool {
return localLunarEclipseVisibleDuring(info, eventStart, eventEnd)
}
func localTotalLunarEclipseVisible(info LocalLunarEclipseInfo) bool {
if !info.HasTotal || info.TotalStart.IsZero() || info.TotalEnd.IsZero() {
return false
}
return localLunarEclipseVisibleDuring(info, info.TotalStart, info.TotalEnd)
}
func localLunarEclipseVisibleOnDate(info LocalLunarEclipseInfo, dayStart, dayEnd time.Time) bool {
eventStart, eventEnd, ok := localLunarEclipseRange(info)
if !ok {

View File

@ -133,6 +133,61 @@ func TestLocalLunarEclipseSearchBeyondFiveYears(t *testing.T) {
}
}
func TestLocalTotalLunarEclipseSearch(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
date := time.Date(2025, 3, 13, 0, 0, 0, 0, loc)
next, ok := NextLocalTotalLunarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find next local total lunar eclipse")
}
if next.Type != LunarEclipseTotal || !next.HasTotal {
t.Fatalf("unexpected next total lunar eclipse: %+v", next)
}
assertTimeClose(t, "NextLocalTotalLunarEclipse", next.Maximum, time.Date(2025, 3, 14, 1, 58, 47, 0, loc), 2*time.Minute)
last, ok := LastLocalTotalLunarEclipse(next.Maximum, lon, lat, height)
if !ok {
t.Fatal("expected to find previous local total lunar eclipse")
}
if last.Type != LunarEclipseTotal || !last.HasTotal {
t.Fatalf("unexpected last total lunar eclipse: %+v", last)
}
assertTimeClose(t, "LastLocalTotalLunarEclipse", last.Maximum, next.Maximum, time.Second)
}
func TestLocalTotalLunarEclipseClosest(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
date := time.Date(2025, 3, 14, 0, 0, 0, 0, loc)
info, ok := ClosestLocalTotalLunarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find closest local total lunar eclipse")
}
if info.Type != LunarEclipseTotal || !info.HasTotal {
t.Fatalf("unexpected closest total lunar eclipse: %+v", info)
}
assertTimeClose(t, "ClosestLocalTotalLunarEclipse", info.Maximum, time.Date(2025, 3, 14, 1, 58, 47, 0, loc), 2*time.Minute)
}
func TestLocalTotalLunarEclipseVisibleRequiresTotalPhaseVisibility(t *testing.T) {
info, ok := LocalLunarEclipseOnDate(time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC), -0.1278, 51.5074, 0)
if !ok {
t.Fatalf("expected visible local eclipse in London")
}
if info.Type != LunarEclipseTotal || !info.HasTotal {
t.Fatalf("unexpected eclipse type: %+v", info)
}
if !localLunarEclipseVisible(info) {
t.Fatalf("expected some phase to be visible")
}
if localTotalLunarEclipseVisible(info) {
t.Fatalf("expected total phase below horizon to be rejected")
}
}
func TestLocalLunarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("JST", 9*3600)
lon, lat, height := 139.6917, 35.6895, 1234.0

View File

@ -1,6 +1,7 @@
package eclipse
import (
"math"
"time"
"b612.me/astro/basic"
@ -10,6 +11,18 @@ const (
sarosCycleLunations = 223
sarosCycleDays = float64(sarosCycleLunations) * solarEclipseSynodicMonthDays
sarosWalkLimit = 100
sarosMagicYearOffset = 3000
sarosMagicCountMask = 0x7f
sarosMagicDayMask = 0x1f
sarosMagicMonthMask = 0x0f
sarosMagicYearMask = 0x1fff
sarosMagicCountShift = 0
sarosMagicDayShift = 7
sarosMagicMonthShift = 12
sarosMagicYearShift = 16
sarosMagicMatchLimitDay = 12.0
sarosMagicTieEpsilonDay = 1e-9
)
// SarosInfo 沙罗序列信息, Saros series metadata.
@ -25,6 +38,8 @@ type SarosInfo struct {
Count int
}
type sarosMagic uint32
type sarosAnchor struct {
Series int16
Count uint8
@ -53,6 +68,20 @@ var lunarSarosHeadOverrides = [...]sarosHeadOverride{
}
func solarSarosInfo(ttJDE float64) (SarosInfo, bool) {
if info, ok := matchSarosMagic(solarSarosAnchors[:], 0, solarSarosHeadOverrides[:], ttJDE); ok {
return info, true
}
return solarSarosInfoByWalk(ttJDE)
}
func lunarSarosInfo(ttJDE float64) (SarosInfo, bool) {
if info, ok := matchSarosMagic(lunarSarosAnchors[:], 1, lunarSarosHeadOverrides[:], ttJDE); ok {
return info, true
}
return lunarSarosInfoByWalk(ttJDE)
}
func solarSarosInfoByWalk(ttJDE float64) (SarosInfo, bool) {
headTT, member, ok := solarSarosHead(ttJDE)
if !ok {
return SarosInfo{}, false
@ -60,7 +89,7 @@ func solarSarosInfo(ttJDE float64) (SarosInfo, bool) {
if info, ok := matchSarosHeadOverride(solarSarosHeadOverrides[:], headTT, member); ok {
return info, true
}
anchor, ok := matchSarosAnchor(solarSarosAnchors[:], headTT)
anchor, ok := matchSarosAnchor(solarSarosAnchors[:], 0, headTT)
if !ok || member > int(anchor.Count) {
return SarosInfo{}, false
}
@ -71,7 +100,7 @@ func solarSarosInfo(ttJDE float64) (SarosInfo, bool) {
}, true
}
func lunarSarosInfo(ttJDE float64) (SarosInfo, bool) {
func lunarSarosInfoByWalk(ttJDE float64) (SarosInfo, bool) {
headTT, member, ok := lunarSarosHead(ttJDE)
if !ok {
return SarosInfo{}, false
@ -79,7 +108,7 @@ func lunarSarosInfo(ttJDE float64) (SarosInfo, bool) {
if info, ok := matchSarosHeadOverride(lunarSarosHeadOverrides[:], headTT, member); ok {
return info, true
}
anchor, ok := matchSarosAnchor(lunarSarosAnchors[:], headTT)
anchor, ok := matchSarosAnchor(lunarSarosAnchors[:], 1, headTT)
if !ok || member > int(anchor.Count) {
return SarosInfo{}, false
}
@ -120,11 +149,102 @@ func lunarSarosHead(ttJDE float64) (float64, int, bool) {
return 0, 0, false
}
func matchSarosAnchor(anchors []sarosAnchor, headTT float64) (sarosAnchor, bool) {
func matchSarosMagic(anchors []sarosMagic, seriesBase int, overrides []sarosHeadOverride, ttJDE float64) (SarosInfo, bool) {
if info, ok := matchSarosMagicOverrides(overrides, ttJDE); ok {
return info, true
}
bestDistance := math.Inf(1)
best := SarosInfo{}
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, seriesBase+index)
info, distance, ok := matchSarosMagicCandidate(ttJDE, anchor, 0)
if !ok {
continue
}
if betterSarosMagicMatch(info, distance, best, bestDistance) {
bestDistance = distance
best = info
}
}
if bestDistance <= sarosMagicMatchLimitDay {
return best, true
}
return SarosInfo{}, false
}
func matchSarosMagicOverrides(overrides []sarosHeadOverride, ttJDE float64) (SarosInfo, bool) {
bestDistance := math.Inf(1)
best := SarosInfo{}
for _, override := range overrides {
anchor := sarosAnchor{
Series: override.Series,
Count: override.Count,
Year: override.HeadYear,
Month: override.HeadMonth,
Day: override.HeadDay,
}
info, distance, ok := matchSarosMagicCandidate(ttJDE, anchor, int(override.MemberOffset))
if !ok {
continue
}
if betterSarosMagicMatch(info, distance, best, bestDistance) {
bestDistance = distance
best = info
}
}
if bestDistance <= sarosMagicMatchLimitDay {
return best, true
}
return SarosInfo{}, false
}
func matchSarosMagicCandidate(ttJDE float64, anchor sarosAnchor, memberOffset int) (SarosInfo, float64, bool) {
headTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
if math.IsNaN(headTT) {
return SarosInfo{}, 0, false
}
member := int(math.Round((ttJDE-headTT)/sarosCycleDays)) + 1 + memberOffset
if member < 1 || member > int(anchor.Count) {
return SarosInfo{}, 0, false
}
expectedTT := headTT + float64(member-1-memberOffset)*sarosCycleDays
return SarosInfo{
Series: int(anchor.Series),
Member: member,
Count: int(anchor.Count),
}, math.Abs(ttJDE - expectedTT), true
}
func betterSarosMagicMatch(info SarosInfo, distance float64, best SarosInfo, bestDistance float64) bool {
if distance < bestDistance-sarosMagicTieEpsilonDay {
return true
}
if math.Abs(distance-bestDistance) > sarosMagicTieEpsilonDay {
return false
}
if info.Series != best.Series {
return info.Series < best.Series
}
return info.Member < best.Member
}
func decodeSarosMagic(magic sarosMagic, series int) sarosAnchor {
value := uint32(magic)
return sarosAnchor{
Series: int16(series),
Count: uint8((value >> sarosMagicCountShift) & sarosMagicCountMask),
Year: int16(int((value>>sarosMagicYearShift)&sarosMagicYearMask) - sarosMagicYearOffset),
Month: uint8((value >> sarosMagicMonthShift) & sarosMagicMonthMask),
Day: uint8((value >> sarosMagicDayShift) & sarosMagicDayMask),
}
}
func matchSarosAnchor(anchors []sarosMagic, seriesBase int, headTT float64) (sarosAnchor, bool) {
headDate := basic.JDE2DateByZone(headTT, time.UTC, true)
year, month, day := headDate.Date()
monthNumber := int(month)
for _, anchor := range anchors {
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, seriesBase+index)
if int(anchor.Year) == year && int(anchor.Month) == monthNumber && int(anchor.Day) == day {
return anchor, true
}

View File

@ -2,185 +2,185 @@ 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},
var lunarSarosAnchors = [...]sarosMagic{
0x21ae3749,
0x21dd31c9,
0x21b1cf4c,
0x2162a34e,
0x2221cb4d,
0x21788256,
0x21957859,
0x21fa8456,
0x21f36d4b,
0x222268ca,
0x22756eca,
0x22805e49,
0x22af5a49,
0x230260c9,
0x230d4f49,
0x233c4ac9,
0x238f5248,
0x239a4149,
0x23c93c49,
0x241c42c8,
0x24152b4a,
0x2432214a,
0x24972cc9,
0x23c99855,
0x23c28357,
0x243994d5,
0x24327e55,
0x244f74ca,
0x24a27ad3,
0x24ad69ca,
0x24ca5f49,
0x252f6bc9,
0x253a5b49,
0x256956c8,
0x25bc5cc8,
0x25c74c49,
0x25e441c8,
0x26494dc8,
0x26543d49,
0x265f2c49,
0x26c43949,
0x26bd224a,
0x260193d5,
0x2709134c,
0x26718ed5,
0x266a79cc,
0x26bd7fd6,
0x26ec7acb,
0x26f76ac9,
0x274a71c9,
0x276766c9,
0x27845bc8,
0x27d762c8,
0x28065d48,
0x28114cc8,
0x286453c8,
0x28814849,
0x288c3849,
0x28f144c7,
0x28fc3449,
0x28acc6ce,
0x2948244a,
0x28e6b1d2,
0x28a98a54,
0x28d885d6,
0x29198654,
0x292475c9,
0x29657748,
0x29a677c9,
0x29b166c8,
0x29e06248,
0x2a3368c8,
0x2a3e5848,
0x2a6d53c8,
0x2aae5448,
0x2ab943c9,
0x2afa44c8,
0x2b3b4548,
0x2b342dc9,
0x2b5123ca,
0x2ba429ca,
0x2ae89ad4,
0x2af38b54,
0x2b5896d4,
0x2b51814c,
0x2b6e76c9,
0x2bd38349,
0x2bde72c8,
0x2bfb67c8,
0x2c4e6dc8,
0x2c6b63c8,
0x2c8858c7,
0x2cdb5f47,
0x2cf854c7,
0x2d1549c7,
0x2d6850c7,
0x2d733fc8,
0x2d6c294a,
0x2de33c48,
0x2d6fc34f,
0x2d2095d3,
0x2d85a2d4,
0x2d9091d2,
0x2d9b8248,
0x2dee8849,
0x2e0b7dc9,
0x2e166d48,
0x2e697448,
0x2e986dc7,
0x2ea35e48,
0x2ef66547,
0x2f135a48,
0x2f304ec7,
0x2f8356c7,
0x2fa04ac8,
0x2f9935c9,
0x2ffe41c7,
0x30093149,
0x2f5fa752,
0x2fa0a853,
0x2fcfa352,
0x2fb6874a,
0x2ff78848,
0x303888c9,
0x304378c8,
0x30847946,
0x30b374c8,
0x30d06947,
0x30ff6547,
0x31406547,
0x314b5548,
0x318c5647,
0x31cd56c7,
0x31c640c8,
0x320746c7,
0x324846c8,
0x31d4c8ce,
0x31a9a7d2,
0x3232c4cf,
0x31f59ccd,
0x32008cc8,
0x326599c9,
0x32708948,
0x328d7ec7,
0x32e085c7,
0x32eb75c8,
0x331a7146,
0x336d77c6,
0x337866c7,
0x33955cc7,
0x33e86347,
0x33f353c8,
0x34104847,
0x34755547,
0x345c3949,
0x33c4b451,
0x34ba30c9,
0x3422aad1,
0x341b94c9,
0x3480a1c8,
0x348b9149,
0x34a88647,
0x34fb8dc6,
0x351882c7,
0x35237347,
0x35767946,
0x35a574c7,
0x35b06447,
0x36036b46,
0x362060c7,
0x362b50c7,
0x367e57c6,
0x369b4c48,
0x3603c84f,
0x369f25ca,
0x3673c4cf,
0x3648a2c9,
0x3689a3c6,
0x36b89dc9,
0x36c38e47,
}

View File

@ -2,186 +2,186 @@ 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},
var solarSarosAnchors = [...]sarosMagic{
0x202d5bc8,
0x20806248,
0x208b5249,
0x20ba4c48,
0x210d5348,
0x21184249,
0x21473dc8,
0x219a4448,
0x21a533c9,
0x21b0234a,
0x22152e49,
0x21fc134c,
0x21528a56,
0x21c99bd5,
0x21c285d5,
0x21bb70cb,
0x22207bd5,
0x223d71ca,
0x22486149,
0x229b67c9,
0x22ca62c8,
0x22d552c8,
0x233a5e47,
0x235753c8,
0x23624348,
0x23c74f47,
0x23e44448,
0x23ef34c8,
0x24423b48,
0x245f30c9,
0x23b5a653,
0x24ab1fca,
0x24139c54,
0x23fa8154,
0x243b8256,
0x246a7cd4,
0x24756bc9,
0x24b66cc9,
0x24f76d49,
0x25025d48,
0x25435e48,
0x25845e48,
0x258f4e48,
0x25d04ec8,
0x26114f48,
0x261c3f48,
0x265d40c8,
0x269e4148,
0x2685244a,
0x26d82b48,
0x270725c9,
0x26399155,
0x26568756,
0x26bb9354,
0x26b47cca,
0x26d17349,
0x272478ca,
0x272f68c9,
0x275e63c8,
0x27b169c8,
0x27bc5948,
0x27eb5547,
0x283e5b47,
0x28494a48,
0x287845c7,
0x28cb4c47,
0x28c43649,
0x28f33248,
0x29463848,
0x28e4c4ce,
0x288392d4,
0x290ca9d2,
0x28e18853,
0x28fe7dc8,
0x2951844b,
0x295c73c9,
0x29796948,
0x29de75c7,
0x29e964c8,
0x2a065ac7,
0x2a6b66c7,
0x2a765648,
0x2a934b47,
0x2ae652c7,
0x2b034748,
0x2b0e3748,
0x2b734347,
0x2b6c2bc9,
0x2ac2a353,
0x2bca2249,
0x2b329e53,
0x2b19834b,
0x2b6c89ca,
0x2b9b84ca,
0x2ba674c8,
0x2be775c7,
0x2c1670c8,
0x2c3365c7,
0x2c746647,
0x2ca361c8,
0x2cc056c7,
0x2d0157c7,
0x2d3052c7,
0x2d3b4248,
0x2d8e48c6,
0x2dab3dc8,
0x2d801bcb,
0x2de527c8,
0x2dde124c,
0x2d5893d1,
0x2d878f48,
0x2dc88f4f,
0x2dd37fc8,
0x2e027b47,
0x2e437bc8,
0x2e4e6ac8,
0x2e8f6bc6,
0x2ed06c47,
0x2edb5c48,
0x2f0a57c7,
0x2f5d5dc7,
0x2f684cc7,
0x2f9748c6,
0x2fea4ec6,
0x2fd13349,
0x2fdc2249,
0x30533548,
0x2f97a552,
0x2f908ec9,
0x3007a1d0,
0x30008a49,
0x301d80c6,
0x307086c7,
0x307b76c8,
0x30986b47,
0x30eb72c7,
0x31086747,
0x31255cc6,
0x31786346,
0x319558c7,
0x31a04847,
0x320559c6,
0x321048c8,
0x320933c8,
0x328045c6,
0x321f124d,
0x31bd99cc,
0x3210a650,
0x322d9acb,
0x32388ac7,
0x32798c47,
0x32a88748,
0x32c57d46,
0x33067e46,
0x333579c7,
0x334068c7,
0x339370c5,
0x33c26ac6,
0x33cd5a46,
0x340e5bc6,
0x343d56c7,
0x343640c8,
0x348947c6,
0x34a63cc8,
0x33eaac50,
0x3419a848,
0x346caecd,
0x34539348,
0x34828e46,
0x34d59547,
0x34e084c7,
0x350f80c5,
0x356286c6,
0x356d7646,
0x359c7245,
0x35dd72c6,
0x35e86247,
0x36175dc5,
0x366a64c6,
0x36634e47,
0x36804446,
}

View File

@ -117,10 +117,10 @@ func TestSolarPathAndFootprintsCarrySaros(t *testing.T) {
}
func TestSarosAnchorSanity(t *testing.T) {
assertSarosAnchorTable(t, solarSarosAnchors[:], true)
assertSarosAnchorTable(t, lunarSarosAnchors[:], false)
assertSarosHeadOverrides(t, solarSarosHeadOverrides[:], solarSarosAnchors[:])
assertSarosHeadOverrides(t, lunarSarosHeadOverrides[:], lunarSarosAnchors[:])
assertSarosAnchorTable(t, solarSarosAnchors[:], 0)
assertSarosAnchorTable(t, lunarSarosAnchors[:], 1)
assertSarosHeadOverrides(t, solarSarosHeadOverrides[:], solarSarosAnchors[:], 0)
assertSarosHeadOverrides(t, lunarSarosHeadOverrides[:], lunarSarosAnchors[:], 1)
}
func assertSarosInfo(t *testing.T, has bool, got SarosInfo, wantSeries, wantMember, wantCount int) {
@ -141,14 +141,15 @@ func assertSarosInfo(t *testing.T, has bool, got SarosInfo, wantSeries, wantMemb
}
}
func assertSarosAnchorTable(t *testing.T, anchors []sarosAnchor, solar bool) {
func assertSarosAnchorTable(t *testing.T, anchors []sarosMagic, seriesBase int) {
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 {
lastSeries := seriesBase - 1
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, seriesBase+index)
series := int(anchor.Series)
if series <= lastSeries {
t.Fatalf("series not strictly increasing: prev=%d current=%d", lastSeries, series)
@ -163,25 +164,20 @@ func assertSarosAnchorTable(t *testing.T, anchors []sarosAnchor, solar bool) {
}
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)
}
if got := int(decodeSarosMagic(anchors[0], seriesBase).Series); got != seriesBase {
t.Fatalf("unexpected first series: got %d want %d", got, seriesBase)
}
}
func assertSarosHeadOverrides(t *testing.T, overrides []sarosHeadOverride, anchors []sarosAnchor) {
func assertSarosHeadOverrides(t *testing.T, overrides []sarosHeadOverride, anchors []sarosMagic, seriesBase int) {
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 {
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, seriesBase+index)
anchorSeries[int(anchor.Series)] = int(anchor.Count)
}
for _, override := range overrides {

36
eclipse/search_skip.go Normal file
View File

@ -0,0 +1,36 @@
package eclipse
import (
"math"
"b612.me/astro/basic"
)
const (
eclipseSeasonNodeDistanceLimitDeg = 35.0
eclipseSeasonMaxSearchStep = 4
)
func nextEclipseSearchCandidateTT(candidateTT float64, phaseType, direction int, synodicMonthDays float64) float64 {
step := eclipseSearchStep(candidateTT, direction, synodicMonthDays)
return basic.CalcMoonSHByJDE(candidateTT+float64(direction*step)*synodicMonthDays, phaseType)
}
func eclipseSearchStep(candidateTT float64, direction int, synodicMonthDays float64) int {
step := 1
for nextStep := 2; nextStep <= eclipseSeasonMaxSearchStep; nextStep++ {
skippedTT := candidateTT + float64(direction*(nextStep-1))*synodicMonthDays
if eclipseNodeDistance(skippedTT) < eclipseSeasonNodeDistanceLimitDeg {
break
}
step = nextStep
}
return step
}
func eclipseNodeDistance(ttJDE float64) float64 {
argument := normalizeDegree360(basic.MoonLonX(ttJDE))
toAscending := math.Min(argument, 360-argument)
toDescending := math.Abs(argument - 180)
return math.Min(toAscending, toDescending)
}

View File

@ -0,0 +1,73 @@
package eclipse
import (
"testing"
"time"
"b612.me/astro/basic"
)
func TestEclipseSearchStepDoesNotSkipPotentialCandidates(t *testing.T) {
testCases := []struct {
name string
phaseType int
synodicMonthDays float64
potential func(float64) bool
}{
{
name: "solar",
phaseType: 0,
synodicMonthDays: solarEclipseSynodicMonthDays,
potential: isPotentialSolarEclipse,
},
{
name: "lunar",
phaseType: 1,
synodicMonthDays: lunarEclipseSynodicMonthDays,
potential: isPotentialLunarEclipse,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
candidates := eclipseSearchTestCandidates(1600, 800, tc.phaseType, tc.synodicMonthDays)
for index, candidateTT := range candidates {
for _, direction := range []int{-1, 1} {
step := eclipseSearchStep(candidateTT, direction, tc.synodicMonthDays)
for offset := 1; offset < step; offset++ {
skippedIndex := index + direction*offset
if skippedIndex < 0 || skippedIndex >= len(candidates) {
continue
}
if tc.potential(candidates[skippedIndex]) {
t.Fatalf(
"%s skip crosses potential candidate: index=%d direction=%d step=%d offset=%d jd=%.8f",
tc.name,
index,
direction,
step,
offset,
candidates[skippedIndex],
)
}
}
}
}
})
}
}
func eclipseSearchTestCandidates(startYear, years, phaseType int, synodicMonthDays float64) []float64 {
startTT := basic.Date2JDE(time.Date(startYear, 1, 1, 0, 0, 0, 0, time.UTC))
endTT := basic.Date2JDE(time.Date(startYear+years, 1, 1, 0, 0, 0, 0, time.UTC))
candidateTT := basic.CalcMoonSHByJDE(startTT, phaseType)
candidates := make([]float64, 0, years*13)
for candidateTT < endTT {
if candidateTT >= startTT {
candidates = append(candidates, candidateTT)
}
candidateTT = basic.CalcMoonSHByJDE(candidateTT+synodicMonthDays, phaseType)
}
return candidates
}

View File

@ -11,6 +11,7 @@ const (
solarEclipseSynodicMonthDays = 29.530588853
solarEclipseSearchLimit = 36
solarEclipseSearchEpsilonDay = 1e-8
solarEclipseLatitudeLimitDeg = 2.0
)
type solarEclipseCalculator func(float64) basic.SolarEclipseResult
@ -236,16 +237,22 @@ func searchSolarEclipse(
candidateTT := basic.CalcMoonSHByJDE(targetTT, 0)
for i := 0; i < solarEclipseSearchLimit; i++ {
if isPotentialSolarEclipse(candidateTT) {
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)
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, solarEclipseSynodicMonthDays)
}
return SolarEclipseInfo{}, false
}
func isPotentialSolarEclipse(newMoonTT float64) bool {
return math.Abs(basic.HMoonTrueBo(newMoonTT)) <= solarEclipseLatitudeLimitDeg
}
func solarEclipseMatchesDirection(greatestTT, targetTT float64, direction int, includeCurrent bool) bool {
delta := greatestTT - targetTT
if math.Abs(delta) <= solarEclipseSearchEpsilonDay {

View File

@ -217,6 +217,18 @@ func LastGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height f
return info
}
// LastLocalTotalSolarEclipse 上次站心日全食 / previous local total solar eclipse.
// Previous visible local total solar eclipse, using NASA bulletin Split-K by default.
func LastLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalTotalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
}
// LastLocalAnnularSolarEclipse 上次站心日环食 / previous local annular solar eclipse.
// Previous visible local annular solar eclipse, using NASA bulletin Split-K by default.
func LastLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalAnnularSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
}
// 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 {
@ -257,6 +269,18 @@ func NextGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height f
return info
}
// NextLocalTotalSolarEclipse 下次站心日全食 / next local total solar eclipse.
// Next visible local total solar eclipse, using NASA bulletin Split-K by default.
func NextLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalTotalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
}
// NextLocalAnnularSolarEclipse 下次站心日环食 / next local annular solar eclipse.
// Next visible local annular solar eclipse, using NASA bulletin Split-K by default.
func NextLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalAnnularSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
}
// 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 {
@ -301,6 +325,22 @@ func ClosestGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, heigh
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestLocalTotalSolarEclipse 最近一次站心日全食 / closest local total solar eclipse.
// Closest visible local total solar eclipse, using NASA bulletin Split-K by default.
func ClosestLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
last, hasLast := searchLocalTotalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
next, hasNext := searchLocalTotalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
return closestLocalSolarEclipseResult(date, last, hasLast, next, hasNext)
}
// ClosestLocalAnnularSolarEclipse 最近一次站心日环食 / closest local annular solar eclipse.
// Closest visible local annular solar eclipse, using NASA bulletin Split-K by default.
func ClosestLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
last, hasLast := searchLocalAnnularSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
next, hasNext := searchLocalAnnularSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
return closestLocalSolarEclipseResult(date, last, hasLast, next, hasNext)
}
func closestLocalSolarEclipse(
date time.Time,
last LocalSolarEclipseInfo,
@ -308,21 +348,32 @@ func closestLocalSolarEclipse(
next LocalSolarEclipseInfo,
hasNext bool,
) LocalSolarEclipseInfo {
info, _ := closestLocalSolarEclipseResult(date, last, hasLast, next, hasNext)
return info
}
func closestLocalSolarEclipseResult(
date time.Time,
last LocalSolarEclipseInfo,
hasLast bool,
next LocalSolarEclipseInfo,
hasNext bool,
) (LocalSolarEclipseInfo, bool) {
switch {
case hasLast && !hasNext:
return last
return last, true
case !hasLast && hasNext:
return next
return next, true
case !hasLast && !hasNext:
return LocalSolarEclipseInfo{}
return LocalSolarEclipseInfo{}, false
}
lastDistance := math.Abs(date.Sub(last.GreatestEclipse).Seconds())
nextDistance := math.Abs(next.GreatestEclipse.Sub(date).Seconds())
if lastDistance <= nextDistance {
return last
return last, true
}
return next
return next, true
}
func searchLocalSolarEclipse(
@ -350,7 +401,69 @@ func searchLocalSolarEclipse(
}
}
}
candidateTT = basic.CalcMoonSHByJDE(candidateTT+float64(direction)*localSolarEclipseSynodicMonthDays, 0)
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays)
}
return LocalSolarEclipseInfo{}, false
}
func searchLocalTotalSolarEclipse(
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.HasTotal || globalResult.HasHybrid {
result := calculator.local(globalResult.GreatestEclipse, lon, lat, height)
if result.HasTotal {
info := localSolarEclipseInfoFromBasic(result, lon, lat, height, date.Location())
if (mode != localSolarEclipseQueryVisible || localCentralSolarEclipseVisible(info)) &&
localSolarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) {
return info, true
}
}
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays)
}
return LocalSolarEclipseInfo{}, false
}
func searchLocalAnnularSolarEclipse(
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.HasAnnular || globalResult.HasHybrid {
result := calculator.local(globalResult.GreatestEclipse, lon, lat, height)
if result.HasAnnular && !result.HasTotal {
info := localSolarEclipseInfoFromBasic(result, lon, lat, height, date.Location())
if (mode != localSolarEclipseQueryVisible || localCentralSolarEclipseVisible(info)) &&
localSolarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) {
return info, true
}
}
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays)
}
return LocalSolarEclipseInfo{}, false
@ -501,6 +614,13 @@ func localSolarEclipseVisible(info LocalSolarEclipseInfo) bool {
return localSolarEclipseVisibleDuring(info, eventStart, eventEnd)
}
func localCentralSolarEclipseVisible(info LocalSolarEclipseInfo) bool {
if !info.HasCentral || info.CentralStart.IsZero() || info.CentralEnd.IsZero() {
return false
}
return localSolarEclipseVisibleDuring(info, info.CentralStart, info.CentralEnd)
}
func localSolarEclipseVisibleOnDate(info LocalSolarEclipseInfo, dayStart, dayEnd time.Time) bool {
eventStart, eventEnd, ok := localSolarEclipseRange(info)
if !ok {

View File

@ -129,6 +129,116 @@ func TestLocalSolarEclipseSearchSkipsInvisibleCurrentCandidate(t *testing.T) {
}
}
func TestLocalTotalSolarEclipseSearch(t *testing.T) {
loc := time.UTC
lon, lat, height := -104.1, 25.3, 0.0
date := time.Date(2024, 4, 7, 0, 0, 0, 0, loc)
next, ok := NextLocalTotalSolarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find next local total solar eclipse")
}
if next.Type != SolarEclipseTotal || !next.HasTotal {
t.Fatalf("unexpected next total eclipse: %+v", next)
}
assertSolarTimeClose(t, "NextLocalTotalSolarEclipse", next.GreatestEclipse, time.Date(2024, 4, 8, 18, 17, 15, 0, loc), time.Minute)
assertSolarDurationClose(t, "NextLocalTotalSolarEclipse duration", next.CentralEnd.Sub(next.CentralStart), 4*time.Minute+28*time.Second, 5*time.Second)
last, ok := LastLocalTotalSolarEclipse(next.GreatestEclipse, lon, lat, height)
if !ok {
t.Fatal("expected to find previous local total solar eclipse")
}
if last.Type != SolarEclipseTotal || !last.HasTotal {
t.Fatalf("unexpected last total eclipse: %+v", last)
}
assertSolarTimeClose(t, "LastLocalTotalSolarEclipse", last.GreatestEclipse, next.GreatestEclipse, time.Second)
assertSolarDurationClose(t, "LastLocalTotalSolarEclipse duration", last.CentralEnd.Sub(last.CentralStart), 4*time.Minute+28*time.Second, 5*time.Second)
}
func TestLocalTotalSolarEclipseClosest(t *testing.T) {
loc := time.UTC
lon, lat, height := -104.1, 25.3, 0.0
date := time.Date(2024, 4, 8, 12, 0, 0, 0, loc)
info, ok := ClosestLocalTotalSolarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find closest local total solar eclipse")
}
if info.Type != SolarEclipseTotal || !info.HasTotal {
t.Fatalf("unexpected closest total eclipse: %+v", info)
}
assertSolarTimeClose(t, "ClosestLocalTotalSolarEclipse", info.GreatestEclipse, time.Date(2024, 4, 8, 18, 17, 15, 0, loc), time.Minute)
}
func TestLocalAnnularSolarEclipseSearch(t *testing.T) {
loc := time.UTC
lon, lat, height := -114.5, -22.0, 0.0
date := time.Date(2024, 10, 1, 0, 0, 0, 0, loc)
next, ok := NextLocalAnnularSolarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find next local annular solar eclipse")
}
if next.Type != SolarEclipseAnnular || !next.HasAnnular || next.HasTotal {
t.Fatalf("unexpected next annular eclipse: %+v", next)
}
assertSolarTimeClose(t, "NextLocalAnnularSolarEclipse", next.GreatestEclipse, time.Date(2024, 10, 2, 18, 44, 59, 0, loc), time.Minute)
assertSolarDurationClose(t, "NextLocalAnnularSolarEclipse duration", next.CentralEnd.Sub(next.CentralStart), 7*time.Minute+25*time.Second, 5*time.Second)
last, ok := LastLocalAnnularSolarEclipse(next.GreatestEclipse, lon, lat, height)
if !ok {
t.Fatal("expected to find previous local annular solar eclipse")
}
if last.Type != SolarEclipseAnnular || !last.HasAnnular || last.HasTotal {
t.Fatalf("unexpected last annular eclipse: %+v", last)
}
assertSolarTimeClose(t, "LastLocalAnnularSolarEclipse", last.GreatestEclipse, next.GreatestEclipse, time.Second)
}
func TestLocalAnnularSolarEclipseClosest(t *testing.T) {
loc := time.UTC
lon, lat, height := -114.5, -22.0, 0.0
date := time.Date(2024, 10, 2, 12, 0, 0, 0, loc)
info, ok := ClosestLocalAnnularSolarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find closest local annular solar eclipse")
}
if info.Type != SolarEclipseAnnular || !info.HasAnnular || info.HasTotal {
t.Fatalf("unexpected closest annular eclipse: %+v", info)
}
assertSolarTimeClose(t, "ClosestLocalAnnularSolarEclipse", info.GreatestEclipse, time.Date(2024, 10, 2, 18, 44, 59, 0, loc), time.Minute)
}
func TestLocalCentralSolarEclipseVisibleRequiresCentralPhaseVisibility(t *testing.T) {
info := LocalSolarEclipseInfo{
Type: SolarEclipseTotal,
Longitude: 0,
Latitude: 0,
PartialStart: time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC),
PartialEnd: time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC),
CentralStart: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
CentralEnd: time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC),
HasPartial: true,
HasCentral: true,
HasTotal: true,
VisibleAtGreatest: false,
}
if !localSolarEclipseVisible(info) {
t.Fatalf("expected partial phase to be visible")
}
if localCentralSolarEclipseVisible(info) {
t.Fatalf("expected central phase below horizon to be rejected")
}
info.Type = SolarEclipseAnnular
info.HasTotal = false
info.HasAnnular = true
if localCentralSolarEclipseVisible(info) {
t.Fatalf("expected annular central phase below horizon to be rejected")
}
}
func TestLocalSolarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
lon, lat, height := -104.1, 25.3, 1234.0
@ -354,3 +464,14 @@ func assertSameLocalSolarEclipse(t *testing.T, name string, got, want LocalSolar
}
assertSolarTimeClose(t, name+".GreatestEclipse", got.GreatestEclipse, want.GreatestEclipse, tolerance)
}
func assertSolarDurationClose(t *testing.T, name string, got, want, tolerance time.Duration) {
t.Helper()
diff := got - want
if diff < 0 {
diff = -diff
}
if diff > tolerance {
t.Fatalf("%s mismatch: got %v want %v diff=%v", name, got, want, diff)
}
}

73
mercury/transit.go Normal file
View File

@ -0,0 +1,73 @@
package mercury
import (
"time"
"b612.me/astro/basic"
)
// TransitInfo 地心水星凌日信息 / geocentric Mercury transit information.
//
// Start、Greatest、End、InternalStart、InternalEnd 都保持调用者输入的时区。
// 内切接触不存在时 InternalStart / InternalEnd 为零值。
// Start, Greatest, End, InternalStart, and InternalEnd preserve the caller's timezone.
// InternalStart and InternalEnd are zero values when internal contacts do not exist.
type TransitInfo struct {
Valid bool
Start time.Time
InternalStart time.Time
Greatest time.Time
InternalEnd time.Time
End time.Time
Duration time.Duration
InternalDuration time.Duration
MinimumSeparationArcsec float64
SunSemidiameterArcsec float64
PlanetSemidiameterArcsec float64
HasInternal bool
}
// NextTransit 下一次地心水星凌日 / next geocentric Mercury transit.
func NextTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.NextMercuryTransit(basic.Date2JDE(date.UTC())), date.Location())
}
// LastTransit 上一次地心水星凌日 / previous geocentric Mercury transit.
func LastTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.LastMercuryTransit(basic.Date2JDE(date.UTC())), date.Location())
}
// ClosestTransit 最近一次地心水星凌日 / closest geocentric Mercury transit.
func ClosestTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.ClosestMercuryTransit(basic.Date2JDE(date.UTC())), date.Location())
}
func transitInfoFromBasic(result basic.PlanetTransitResult, loc *time.Location) TransitInfo {
if !result.Valid {
return TransitInfo{}
}
start := basic.JDE2DateByZone(result.ExternalIngress, loc, false)
greatest := basic.JDE2DateByZone(result.Greatest, loc, false)
end := basic.JDE2DateByZone(result.ExternalEgress, loc, false)
info := TransitInfo{
Valid: true,
Start: start,
Greatest: greatest,
End: end,
Duration: end.Sub(start),
MinimumSeparationArcsec: result.MinimumSeparationArcsec,
SunSemidiameterArcsec: result.SunSemidiameterArcsec,
PlanetSemidiameterArcsec: result.PlanetSemidiameterArcsec,
HasInternal: result.HasInternal,
}
if result.HasInternal {
info.InternalStart = basic.JDE2DateByZone(result.InternalIngress, loc, false)
info.InternalEnd = basic.JDE2DateByZone(result.InternalEgress, loc, false)
info.InternalDuration = info.InternalEnd.Sub(info.InternalStart)
}
return info
}

23
mercury/transit_test.go Normal file
View File

@ -0,0 +1,23 @@
package mercury
import (
"testing"
"time"
)
func TestTransitWrappers(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
info := NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, loc))
if !info.Valid {
t.Fatal("expected valid transit")
}
if info.Greatest.Location() != loc {
t.Fatalf("timezone mismatch: got %v want %v", info.Greatest.Location(), loc)
}
if info.Greatest.Year() != 2019 || info.Greatest.Month() != time.November || info.Greatest.Day() != 11 {
t.Fatalf("unexpected greatest time: %s", info.Greatest)
}
if !info.HasInternal || info.Duration <= 0 || info.InternalDuration <= 0 {
t.Fatalf("unexpected durations: %+v", info)
}
}

73
venus/transit.go Normal file
View File

@ -0,0 +1,73 @@
package venus
import (
"time"
"b612.me/astro/basic"
)
// TransitInfo 地心金星凌日信息 / geocentric Venus transit information.
//
// Start、Greatest、End、InternalStart、InternalEnd 都保持调用者输入的时区。
// 内切接触不存在时 InternalStart / InternalEnd 为零值。
// Start, Greatest, End, InternalStart, and InternalEnd preserve the caller's timezone.
// InternalStart and InternalEnd are zero values when internal contacts do not exist.
type TransitInfo struct {
Valid bool
Start time.Time
InternalStart time.Time
Greatest time.Time
InternalEnd time.Time
End time.Time
Duration time.Duration
InternalDuration time.Duration
MinimumSeparationArcsec float64
SunSemidiameterArcsec float64
PlanetSemidiameterArcsec float64
HasInternal bool
}
// NextTransit 下一次地心金星凌日 / next geocentric Venus transit.
func NextTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.NextVenusTransit(basic.Date2JDE(date.UTC())), date.Location())
}
// LastTransit 上一次地心金星凌日 / previous geocentric Venus transit.
func LastTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.LastVenusTransit(basic.Date2JDE(date.UTC())), date.Location())
}
// ClosestTransit 最近一次地心金星凌日 / closest geocentric Venus transit.
func ClosestTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.ClosestVenusTransit(basic.Date2JDE(date.UTC())), date.Location())
}
func transitInfoFromBasic(result basic.PlanetTransitResult, loc *time.Location) TransitInfo {
if !result.Valid {
return TransitInfo{}
}
start := basic.JDE2DateByZone(result.ExternalIngress, loc, false)
greatest := basic.JDE2DateByZone(result.Greatest, loc, false)
end := basic.JDE2DateByZone(result.ExternalEgress, loc, false)
info := TransitInfo{
Valid: true,
Start: start,
Greatest: greatest,
End: end,
Duration: end.Sub(start),
MinimumSeparationArcsec: result.MinimumSeparationArcsec,
SunSemidiameterArcsec: result.SunSemidiameterArcsec,
PlanetSemidiameterArcsec: result.PlanetSemidiameterArcsec,
HasInternal: result.HasInternal,
}
if result.HasInternal {
info.InternalStart = basic.JDE2DateByZone(result.InternalIngress, loc, false)
info.InternalEnd = basic.JDE2DateByZone(result.InternalEgress, loc, false)
info.InternalDuration = info.InternalEnd.Sub(info.InternalStart)
}
return info
}

23
venus/transit_test.go Normal file
View File

@ -0,0 +1,23 @@
package venus
import (
"testing"
"time"
)
func TestTransitWrappers(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
info := NextTransit(time.Date(2012, 1, 1, 0, 0, 0, 0, loc))
if !info.Valid {
t.Fatal("expected valid transit")
}
if info.Greatest.Location() != loc {
t.Fatalf("timezone mismatch: got %v want %v", info.Greatest.Location(), loc)
}
if info.Greatest.Year() != 2012 || info.Greatest.Month() != time.June || info.Greatest.Day() != 6 {
t.Fatalf("unexpected greatest time: %s", info.Greatest)
}
if !info.HasInternal || info.Duration <= 0 || info.InternalDuration <= 0 {
t.Fatalf("unexpected durations: %+v", info)
}
}