From bec7b8a0d824c67023e6042559553c4f1a52bbd8 Mon Sep 17 00:00:00 2001 From: starainrt Date: Sun, 3 May 2026 19:00:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=97=A5=E6=9C=88?= =?UTF-8?q?=E9=A3=9F=E6=90=9C=E7=B4=A2=E3=80=81=E6=B2=99=E7=BD=97=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E4=B8=8E=E5=86=85=E8=A1=8C=E6=98=9F=E5=87=8C=E6=97=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用压缩表加速查找日月食沙罗周期信息 - 优化日月食搜索跳步,减少非食季朔望月扫描 - 新增本地日全食、日环食、月全食搜索接口,返回 ok 区分未找到结果 - 新增水星、金星地心凌日查询及测试 --- README.en.md | 70 ++++- README.md | 70 ++++- basic/planet_transit.go | 520 +++++++++++++++++++++++++++++++++++ basic/planet_transit_test.go | 145 ++++++++++ eclipse/lunar.go | 2 +- eclipse/lunar_local.go | 78 +++++- eclipse/lunar_local_test.go | 55 ++++ eclipse/saros.go | 130 ++++++++- eclipse/saros_table_lunar.go | 362 ++++++++++++------------ eclipse/saros_table_solar.go | 364 ++++++++++++------------ eclipse/saros_test.go | 30 +- eclipse/search_skip.go | 36 +++ eclipse/search_skip_test.go | 73 +++++ eclipse/solar.go | 15 +- eclipse/solar_local.go | 132 ++++++++- eclipse/solar_local_test.go | 121 ++++++++ mercury/transit.go | 73 +++++ mercury/transit_test.go | 23 ++ venus/transit.go | 73 +++++ venus/transit_test.go | 23 ++ 20 files changed, 1987 insertions(+), 408 deletions(-) create mode 100644 basic/planet_transit.go create mode 100644 basic/planet_transit_test.go create mode 100644 eclipse/search_skip.go create mode 100644 eclipse/search_skip_test.go create mode 100644 mercury/transit.go create mode 100644 mercury/transit_test.go create mode 100644 venus/transit.go create mode 100644 venus/transit_test.go diff --git a/README.en.md b/README.en.md index b74ceb9..9ef63c1 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.md b/README.md index 5411949..574e66d 100644 --- a/README.md +++ b/README.md @@ -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 视星等、视双星位置角/角距 diff --git a/basic/planet_transit.go b/basic/planet_transit.go new file mode 100644 index 0000000..81be681 --- /dev/null +++ b/basic/planet_transit.go @@ -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) +} diff --git a/basic/planet_transit_test.go b/basic/planet_transit_test.go new file mode 100644 index 0000000..dac89f1 --- /dev/null +++ b/basic/planet_transit_test.go @@ -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") + } + } +} diff --git a/eclipse/lunar.go b/eclipse/lunar.go index 20e4e8a..2235812 100644 --- a/eclipse/lunar.go +++ b/eclipse/lunar.go @@ -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 diff --git a/eclipse/lunar_local.go b/eclipse/lunar_local.go index 7cc9b2c..2151b43 100644 --- a/eclipse/lunar_local.go +++ b/eclipse/lunar_local.go @@ -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 { diff --git a/eclipse/lunar_local_test.go b/eclipse/lunar_local_test.go index fd95b33..f681154 100644 --- a/eclipse/lunar_local_test.go +++ b/eclipse/lunar_local_test.go @@ -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 diff --git a/eclipse/saros.go b/eclipse/saros.go index 942fba2..14555f3 100644 --- a/eclipse/saros.go +++ b/eclipse/saros.go @@ -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 } diff --git a/eclipse/saros_table_lunar.go b/eclipse/saros_table_lunar.go index c07fb8a..11b7bd9 100644 --- a/eclipse/saros_table_lunar.go +++ b/eclipse/saros_table_lunar.go @@ -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, } diff --git a/eclipse/saros_table_solar.go b/eclipse/saros_table_solar.go index 185f6a3..0efeace 100644 --- a/eclipse/saros_table_solar.go +++ b/eclipse/saros_table_solar.go @@ -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, } diff --git a/eclipse/saros_test.go b/eclipse/saros_test.go index 1de07e1..9a50354 100644 --- a/eclipse/saros_test.go +++ b/eclipse/saros_test.go @@ -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 { diff --git a/eclipse/search_skip.go b/eclipse/search_skip.go new file mode 100644 index 0000000..a89e127 --- /dev/null +++ b/eclipse/search_skip.go @@ -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) +} diff --git a/eclipse/search_skip_test.go b/eclipse/search_skip_test.go new file mode 100644 index 0000000..029c56e --- /dev/null +++ b/eclipse/search_skip_test.go @@ -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 +} diff --git a/eclipse/solar.go b/eclipse/solar.go index 71a0c84..92fedcc 100644 --- a/eclipse/solar.go +++ b/eclipse/solar.go @@ -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++ { - result := calculator(candidateTT) - if result.Type != basic.SolarEclipseNone && solarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) { - return solarEclipseInfoFromBasic(result, date.Location()), true + 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 { diff --git a/eclipse/solar_local.go b/eclipse/solar_local.go index 7e9ceef..2b90fa5 100644 --- a/eclipse/solar_local.go +++ b/eclipse/solar_local.go @@ -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 { diff --git a/eclipse/solar_local_test.go b/eclipse/solar_local_test.go index f9c75f1..32cb465 100644 --- a/eclipse/solar_local_test.go +++ b/eclipse/solar_local_test.go @@ -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) + } +} diff --git a/mercury/transit.go b/mercury/transit.go new file mode 100644 index 0000000..ba39b57 --- /dev/null +++ b/mercury/transit.go @@ -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 +} diff --git a/mercury/transit_test.go b/mercury/transit_test.go new file mode 100644 index 0000000..f8dfb87 --- /dev/null +++ b/mercury/transit_test.go @@ -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) + } +} diff --git a/venus/transit.go b/venus/transit.go new file mode 100644 index 0000000..5598a66 --- /dev/null +++ b/venus/transit.go @@ -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 +} diff --git a/venus/transit_test.go b/venus/transit_test.go new file mode 100644 index 0000000..7d3646c --- /dev/null +++ b/venus/transit_test.go @@ -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) + } +}